detached 6084eb1

feat(goal): support pausing and resuming goals (#218)

commit
6084eb1f98083e6f487a2a00e128241b32d2409b
author
bnema · · 2026-09-21 11:36 UTC
committer
GitHub · · 2026-09-21 11:36 UTC
parent
076e2bb
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.41.0
6 files · +107 -18
M README.md +2 -2
@@ -271,7 +271,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
271271 - `worktree`: manage Git worktrees with the `create`, `list`, and `cleanup` actions. Creation checks out a new branch under `$ZUT_HOME/worktrees/<repo-id>`; an absolute local Git setting, `zut.worktrees.path`, overrides that root per repository. Listing inventories registered worktrees. Cleanup requires exact paths, confirmation through the normal preview gate, and revalidation immediately before removal. It refuses the primary worktree, dirty or detached checkouts, and commits not merged into the current branch. It removes neither local branches nor unmerged work.
272272 - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. Successful native edits, writes, renames, and applied code actions automatically attach bounded diagnostics when available. See [docs/lsp.md](docs/lsp.md).
273273 - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open` opens either a complete public URL or a referenced page, while `web_find` and `web_click` respectively find literal text in an opened page and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).
274-- `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, mark the active goal `done` or `blocked`, or atomically supersede an invalid goal with a necessary in-mission replacement. Pausing, resuming, and clearing missions remain user-controlled through `/goal`.
274+- `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, pause work while a decision is pending, resume it once settled, mark the active goal `done` or `blocked`, or atomically supersede an invalid goal with a necessary in-mission replacement. Users can also pause and resume through `/goal`; clearing remains user-controlled.
275275 - `plan`: maintain a session-scoped checklist of `pending`, `in_progress`, and `completed` steps through `set` (replace every step), `add`, `update` one step, `remove` one step, `clear`, and `show`. At most one step may be `in_progress`. `set` requires at least one step; `clear` is how every step is removed. There is no step-count limit, but a plan carried across compaction shows only its first 12 steps. The plan is persisted in every mode through the tool-result commit path, inherited by forks and imports, and cleared by `/clear`. Successful updates appear inline as a checklist and emit `plan_update` events in JSON and RPC modes.
276276 - `schedule`: add, list, or cancel recurring five-field cron prompts for the current interactive session. A task captures the machine's current timezone when it is created. While this zut process remains open, a due task waits for an active target turn to finish, or resumes an inactive target session in the background; its output is recorded in that target transcript. Resumed background tasks also show a completion notification in the current TUI. Scheduling is in-memory only: quitting zut cancels pending tasks, and schedules do not survive restart or reboot. Print, stream, JSON, and RPC modes do not expose this tool.
277277
@@ -360,7 +360,7 @@ Extension-registered commands appear under a divider at the bottom of the popup,
360360
361361 ### `/goal`
362362
363-In interactive mode, the first user request establishes a durable user-owned mission. `/goal <objective>` explicitly starts or replaces its current user-owned goal and begins work immediately. While a goal is active, zut starts one leased hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. When no mission is active, the main agent can use `update_goal` to start one of its own; within an active mission, it can settle the current goal, set a bounded next goal, or atomically mark an invalid objective `superseded` and replace it with a necessary objective inside the same mission. Superseding cannot broaden scope or merely avoid difficult work. An omitted `mission_id` targets the current mission; when supplied, it must match that mission. Subagents cannot start or update missions. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist with the session.
363+In interactive mode, the first user request establishes a durable user-owned mission. `/goal <objective>` explicitly starts or replaces its current user-owned goal and begins work immediately. While a goal is active, zut starts one leased hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. When no mission is active, the main agent can use `update_goal` to start one of its own; within an active mission, it can pause while a decision is pending, resume after that decision is settled, settle the current goal, set a bounded next goal, or atomically mark an invalid objective `superseded` and replace it with a necessary objective inside the same mission. Superseding cannot broaden scope or merely avoid difficult work. An omitted `mission_id` targets the current mission; when supplied, it must match that mission. Subagents cannot start or update missions. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist with the session.
364364
365365 Goals are unlimited by default. Zut records token use for each goal but only enforces a budget when it is explicitly configured in `config.json`:
366366
@@ -271,7 +271,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
271 - `worktree`: manage Git worktrees with the `create`, `list`, and `cleanup` actions. Creation checks out a new branch under `$ZUT_HOME/worktrees/<repo-id>`; an absolute local Git setting, `zut.worktrees.path`, overrides that root per repository. Listing inventories registered worktrees. Cleanup requires exact paths, confirmation through the normal preview gate, and revalidation immediately before removal. It refuses the primary worktree, dirty or detached checkouts, and commits not merged into the current branch. It removes neither local branches nor unmerged work.271 - `worktree`: manage Git worktrees with the `create`, `list`, and `cleanup` actions. Creation checks out a new branch under `$ZUT_HOME/worktrees/<repo-id>`; an absolute local Git setting, `zut.worktrees.path`, overrides that root per repository. Listing inventories registered worktrees. Cleanup requires exact paths, confirmation through the normal preview gate, and revalidation immediately before removal. It refuses the primary worktree, dirty or detached checkouts, and commits not merged into the current branch. It removes neither local branches nor unmerged work.
272 - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. Successful native edits, writes, renames, and applied code actions automatically attach bounded diagnostics when available. See [docs/lsp.md](docs/lsp.md).272 - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. Successful native edits, writes, renames, and applied code actions automatically attach bounded diagnostics when available. See [docs/lsp.md](docs/lsp.md).
273 - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open` opens either a complete public URL or a referenced page, while `web_find` and `web_click` respectively find literal text in an opened page and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).273 - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open` opens either a complete public URL or a referenced page, while `web_find` and `web_click` respectively find literal text in an opened page and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).
274-- `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, mark the active goal `done` or `blocked`, or atomically supersede an invalid goal with a necessary in-mission replacement. Pausing, resuming, and clearing missions remain user-controlled through `/goal`.274+- `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, pause work while a decision is pending, resume it once settled, mark the active goal `done` or `blocked`, or atomically supersede an invalid goal with a necessary in-mission replacement. Users can also pause and resume through `/goal`; clearing remains user-controlled.
275 - `plan`: maintain a session-scoped checklist of `pending`, `in_progress`, and `completed` steps through `set` (replace every step), `add`, `update` one step, `remove` one step, `clear`, and `show`. At most one step may be `in_progress`. `set` requires at least one step; `clear` is how every step is removed. There is no step-count limit, but a plan carried across compaction shows only its first 12 steps. The plan is persisted in every mode through the tool-result commit path, inherited by forks and imports, and cleared by `/clear`. Successful updates appear inline as a checklist and emit `plan_update` events in JSON and RPC modes.275 - `plan`: maintain a session-scoped checklist of `pending`, `in_progress`, and `completed` steps through `set` (replace every step), `add`, `update` one step, `remove` one step, `clear`, and `show`. At most one step may be `in_progress`. `set` requires at least one step; `clear` is how every step is removed. There is no step-count limit, but a plan carried across compaction shows only its first 12 steps. The plan is persisted in every mode through the tool-result commit path, inherited by forks and imports, and cleared by `/clear`. Successful updates appear inline as a checklist and emit `plan_update` events in JSON and RPC modes.
276 - `schedule`: add, list, or cancel recurring five-field cron prompts for the current interactive session. A task captures the machine's current timezone when it is created. While this zut process remains open, a due task waits for an active target turn to finish, or resumes an inactive target session in the background; its output is recorded in that target transcript. Resumed background tasks also show a completion notification in the current TUI. Scheduling is in-memory only: quitting zut cancels pending tasks, and schedules do not survive restart or reboot. Print, stream, JSON, and RPC modes do not expose this tool.276 - `schedule`: add, list, or cancel recurring five-field cron prompts for the current interactive session. A task captures the machine's current timezone when it is created. While this zut process remains open, a due task waits for an active target turn to finish, or resumes an inactive target session in the background; its output is recorded in that target transcript. Resumed background tasks also show a completion notification in the current TUI. Scheduling is in-memory only: quitting zut cancels pending tasks, and schedules do not survive restart or reboot. Print, stream, JSON, and RPC modes do not expose this tool.
277 277
@@ -360,7 +360,7 @@ Extension-registered commands appear under a divider at the bottom of the popup,
360 360
361 ### `/goal`361 ### `/goal`
362 362
363-In interactive mode, the first user request establishes a durable user-owned mission. `/goal <objective>` explicitly starts or replaces its current user-owned goal and begins work immediately. While a goal is active, zut starts one leased hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. When no mission is active, the main agent can use `update_goal` to start one of its own; within an active mission, it can settle the current goal, set a bounded next goal, or atomically mark an invalid objective `superseded` and replace it with a necessary objective inside the same mission. Superseding cannot broaden scope or merely avoid difficult work. An omitted `mission_id` targets the current mission; when supplied, it must match that mission. Subagents cannot start or update missions. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist with the session.363+In interactive mode, the first user request establishes a durable user-owned mission. `/goal <objective>` explicitly starts or replaces its current user-owned goal and begins work immediately. While a goal is active, zut starts one leased hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. When no mission is active, the main agent can use `update_goal` to start one of its own; within an active mission, it can pause while a decision is pending, resume after that decision is settled, settle the current goal, set a bounded next goal, or atomically mark an invalid objective `superseded` and replace it with a necessary objective inside the same mission. Superseding cannot broaden scope or merely avoid difficult work. An omitted `mission_id` targets the current mission; when supplied, it must match that mission. Subagents cannot start or update missions. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist with the session.
364 364
365 Goals are unlimited by default. Zut records token use for each goal but only enforces a budget when it is explicitly configured in `config.json`:365 Goals are unlimited by default. Zut records token use for each goal but only enforces a budget when it is explicitly configured in `config.json`:
366 366
M packages/agent/cli.go +18 -0
@@ -821,6 +821,21 @@ func persistGoalToolResult(sess *core.Session, result core.ToolResult, goalMaxTo
821821 return nil
822822 }
823823 if update.Status == core.GoalActive {
824+ if strings.TrimSpace(update.Objective) == "" {
825+ if sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalPaused {
826+ return nil
827+ }
828+ if sess.Meta.Mission != nil && update.MissionID != "" && update.MissionID != sess.Meta.Mission.ID {
829+ return errGoalMissionMismatch
830+ }
831+ goal := *sess.Meta.Goal
832+ goal.Status = core.GoalActive
833+ goal.Reason = ""
834+ if err := sess.UpdateGoal(&goal); err != nil {
835+ return fmt.Errorf("%w: %w", errGoalStateWrite, err)
836+ }
837+ return nil
838+ }
824839 if sess.Meta.Goal != nil && sess.Meta.Goal.Status == core.GoalActive {
825840 return errGoalActiveReplacement
826841 }
@@ -840,6 +855,9 @@ func persistGoalToolResult(sess *core.Session, result core.ToolResult, goalMaxTo
840855 if sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive {
841856 return nil
842857 }
858+ if sess.Meta.Mission != nil && update.MissionID != "" && update.MissionID != sess.Meta.Mission.ID {
859+ return errGoalMissionMismatch
860+ }
843861 goal := *sess.Meta.Goal
844862 goal.Status = update.Status
845863 goal.Reason = update.Reason
@@ -821,6 +821,21 @@ func persistGoalToolResult(sess *core.Session, result core.ToolResult, goalMaxTo
821 return nil821 return nil
822 }822 }
823 if update.Status == core.GoalActive {823 if update.Status == core.GoalActive {
824+ if strings.TrimSpace(update.Objective) == "" {
825+ if sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalPaused {
826+ return nil
827+ }
828+ if sess.Meta.Mission != nil && update.MissionID != "" && update.MissionID != sess.Meta.Mission.ID {
829+ return errGoalMissionMismatch
830+ }
831+ goal := *sess.Meta.Goal
832+ goal.Status = core.GoalActive
833+ goal.Reason = ""
834+ if err := sess.UpdateGoal(&goal); err != nil {
835+ return fmt.Errorf("%w: %w", errGoalStateWrite, err)
836+ }
837+ return nil
838+ }
824 if sess.Meta.Goal != nil && sess.Meta.Goal.Status == core.GoalActive {839 if sess.Meta.Goal != nil && sess.Meta.Goal.Status == core.GoalActive {
825 return errGoalActiveReplacement840 return errGoalActiveReplacement
826 }841 }
@@ -840,6 +855,9 @@ func persistGoalToolResult(sess *core.Session, result core.ToolResult, goalMaxTo
840 if sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive {855 if sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive {
841 return nil856 return nil
842 }857 }
858+ if sess.Meta.Mission != nil && update.MissionID != "" && update.MissionID != sess.Meta.Mission.ID {
859+ return errGoalMissionMismatch
860+ }
843 goal := *sess.Meta.Goal861 goal := *sess.Meta.Goal
844 goal.Status = update.Status862 goal.Status = update.Status
845 goal.Reason = update.Reason863 goal.Reason = update.Reason
M packages/agent/cli_session_test.go +46 -0
@@ -283,6 +283,52 @@ func TestPersistGoalToolResultStartsGoalInCurrentMissionWithoutID(t *testing.T)
283283 }
284284 }
285285
286+func TestPersistGoalToolResultPausesAndResumesCurrentGoal(t *testing.T) {
287+ sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
288+ if err != nil {
289+ t.Fatal(err)
290+ }
291+ defer sess.Close()
292+ if err := sess.UpdateGoal(&core.SessionGoal{Objective: "current work", Status: core.GoalActive}); err != nil {
293+ t.Fatal(err)
294+ }
295+ goalID := sess.Meta.Goal.ID
296+ missionID := sess.Meta.Mission.ID
297+
298+ if err := persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalPaused}}); err != nil {
299+ t.Fatal(err)
300+ }
301+ if got := sess.Meta.Goal; got == nil || got.Status != core.GoalPaused || got.ID != goalID || got.Objective != "current work" {
302+ t.Fatalf("paused goal = %#v", got)
303+ }
304+
305+ if err := persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, MissionID: missionID}}); err != nil {
306+ t.Fatal(err)
307+ }
308+ if got := sess.Meta.Goal; got == nil || got.Status != core.GoalActive || got.ID != goalID || got.Objective != "current work" {
309+ t.Fatalf("resumed goal = %#v", got)
310+ }
311+}
312+
313+func TestPersistGoalToolResultRejectsPauseForDifferentMission(t *testing.T) {
314+ sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
315+ if err != nil {
316+ t.Fatal(err)
317+ }
318+ defer sess.Close()
319+ if err := sess.UpdateGoal(&core.SessionGoal{Objective: "current work", Status: core.GoalActive}); err != nil {
320+ t.Fatal(err)
321+ }
322+
323+ err = persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalPaused, MissionID: "other-mission"}})
324+ if !errors.Is(err, errGoalMissionMismatch) {
325+ t.Fatalf("error = %v, want %v", err, errGoalMissionMismatch)
326+ }
327+ if got := sess.Meta.Goal; got == nil || got.Status != core.GoalActive {
328+ t.Fatalf("goal changed after rejected pause: %#v", got)
329+ }
330+}
331+
286332 func TestPersistGoalToolResultRejectsActiveGoalReplacement(t *testing.T) {
287333 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
288334 if err != nil {
@@ -283,6 +283,52 @@ func TestPersistGoalToolResultStartsGoalInCurrentMissionWithoutID(t *testing.T)
283 }283 }
284 }284 }
285 285
286+func TestPersistGoalToolResultPausesAndResumesCurrentGoal(t *testing.T) {
287+ sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
288+ if err != nil {
289+ t.Fatal(err)
290+ }
291+ defer sess.Close()
292+ if err := sess.UpdateGoal(&core.SessionGoal{Objective: "current work", Status: core.GoalActive}); err != nil {
293+ t.Fatal(err)
294+ }
295+ goalID := sess.Meta.Goal.ID
296+ missionID := sess.Meta.Mission.ID
297+
298+ if err := persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalPaused}}); err != nil {
299+ t.Fatal(err)
300+ }
301+ if got := sess.Meta.Goal; got == nil || got.Status != core.GoalPaused || got.ID != goalID || got.Objective != "current work" {
302+ t.Fatalf("paused goal = %#v", got)
303+ }
304+
305+ if err := persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, MissionID: missionID}}); err != nil {
306+ t.Fatal(err)
307+ }
308+ if got := sess.Meta.Goal; got == nil || got.Status != core.GoalActive || got.ID != goalID || got.Objective != "current work" {
309+ t.Fatalf("resumed goal = %#v", got)
310+ }
311+}
312+
313+func TestPersistGoalToolResultRejectsPauseForDifferentMission(t *testing.T) {
314+ sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
315+ if err != nil {
316+ t.Fatal(err)
317+ }
318+ defer sess.Close()
319+ if err := sess.UpdateGoal(&core.SessionGoal{Objective: "current work", Status: core.GoalActive}); err != nil {
320+ t.Fatal(err)
321+ }
322+
323+ err = persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalPaused, MissionID: "other-mission"}})
324+ if !errors.Is(err, errGoalMissionMismatch) {
325+ t.Fatalf("error = %v, want %v", err, errGoalMissionMismatch)
326+ }
327+ if got := sess.Meta.Goal; got == nil || got.Status != core.GoalActive {
328+ t.Fatalf("goal changed after rejected pause: %#v", got)
329+ }
330+}
331+
286 func TestPersistGoalToolResultRejectsActiveGoalReplacement(t *testing.T) {332 func TestPersistGoalToolResultRejectsActiveGoalReplacement(t *testing.T) {
287 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")333 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
288 if err != nil {334 if err != nil {
M packages/agent/modes/interactive_events.go +9 -7
@@ -180,17 +180,19 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
180180 // Esc return for the replaced goal must not fire.
181181 i.interruptedGoalReturn = nil
182182 } else if update.Status == core.GoalActive {
183- // A manager may advance a terminal goal to the next persisted
184- // goal in the same mission. Do not let a late tool result resume
185- // a goal explicitly paused by the user.
186- if i.goalStatus != core.GoalPaused {
183+ // Persistence runs before this event. Reflect a manager resume or
184+ // newly active goal, but do not let a stale result undo a user pause.
185+ if i.cfg.CurrentGoal != nil {
186+ if current := i.cfg.CurrentGoal(); current != nil {
187+ i.goalStatus = current.Status
188+ }
189+ } else if i.goalStatus != core.GoalPaused {
187190 i.goalStatus = core.GoalActive
188191 }
189192 } else if i.goalStatus == core.GoalActive {
190193 i.goalStatus = update.Status
191- // Terminal update_goal transitions settle the mission; an Esc
192- // return for the settled goal is inert by identity, drop it.
193- if update.Status == core.GoalDone || update.Status == core.GoalBlocked {
194+ // Paused and terminal goals must not inherit an Esc-armed return.
195+ if update.Status == core.GoalPaused || update.Status == core.GoalDone || update.Status == core.GoalBlocked {
194196 i.interruptedGoalReturn = nil
195197 }
196198 }
@@ -180,17 +180,19 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
180 // Esc return for the replaced goal must not fire.180 // Esc return for the replaced goal must not fire.
181 i.interruptedGoalReturn = nil181 i.interruptedGoalReturn = nil
182 } else if update.Status == core.GoalActive {182 } else if update.Status == core.GoalActive {
183- // A manager may advance a terminal goal to the next persisted183+ // Persistence runs before this event. Reflect a manager resume or
184- // goal in the same mission. Do not let a late tool result resume184+ // newly active goal, but do not let a stale result undo a user pause.
185- // a goal explicitly paused by the user.185+ if i.cfg.CurrentGoal != nil {
186- if i.goalStatus != core.GoalPaused {186+ if current := i.cfg.CurrentGoal(); current != nil {
187+ i.goalStatus = current.Status
188+ }
189+ } else if i.goalStatus != core.GoalPaused {
187 i.goalStatus = core.GoalActive190 i.goalStatus = core.GoalActive
188 }191 }
189 } else if i.goalStatus == core.GoalActive {192 } else if i.goalStatus == core.GoalActive {
190 i.goalStatus = update.Status193 i.goalStatus = update.Status
191- // Terminal update_goal transitions settle the mission; an Esc194+ // Paused and terminal goals must not inherit an Esc-armed return.
192- // return for the settled goal is inert by identity, drop it.195+ if update.Status == core.GoalPaused || update.Status == core.GoalDone || update.Status == core.GoalBlocked {
193- if update.Status == core.GoalDone || update.Status == core.GoalBlocked {
194 i.interruptedGoalReturn = nil196 i.interruptedGoalReturn = nil
195 }197 }
196 }198 }
M packages/agent/tools/update_goal.go +8 -8
@@ -13,7 +13,7 @@ import (
1313
1414 const (
1515 UpdateGoalToolName = "update_goal"
16- updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["active","complete","blocked","superseded"],"description":"Start a mission, set the next active goal, settle the active goal, or atomically replace it within the same mission."},"objective":{"type":"string","description":"Concrete objective required for active and superseded."},"goal_id":{"type":"string","description":"Current goal identifier required for superseded, copied from the active-goal context."},"mission_id":{"type":"string","description":"Optional identifier from the active-goal context. When supplied, it must match the current mission; omit it when starting or continuing the current mission."},"reason":{"type":"string","description":"Concise reason when the goal is blocked."}},"required":["status"],"additionalProperties":false}`
16+ updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["active","paused","complete","blocked","superseded"],"description":"Start or resume a mission, pause it, set the next active goal, settle the active goal, or atomically replace it within the same mission."},"objective":{"type":"string","description":"Concrete objective required when starting or advancing with active, and when superseding. Omit active's objective to resume the paused goal."},"goal_id":{"type":"string","description":"Current goal identifier required for superseded, copied from the active-goal context."},"mission_id":{"type":"string","description":"Optional identifier from the active-goal context. When supplied, it must match the current mission; omit it when starting or continuing the current mission."},"reason":{"type":"string","description":"Concise reason when the goal is blocked."}},"required":["status"],"additionalProperties":false}`
1717 )
1818
1919 // UpdateGoalTool lets the main agent start a mission, set its next concrete
@@ -42,7 +42,7 @@ type GoalUpdate struct {
4242 func (t *UpdateGoalTool) Name() string { return UpdateGoalToolName }
4343
4444 func (t *UpdateGoalTool) Description() string {
45- return "When no mission is active and the user's objective is clear, bounded, settled, and requires substantial multi-step work, call update_goal with status active before substantial execution. Do not start a mission for brief questions, casual conversation, or requests completed immediately. Inside a mission, set the next concrete goal, mark the active goal complete or blocked, or supersede it with a necessary replacement inside the same mission. Never broaden the mission or use superseded to avoid difficult work. Incidental messages and interrupted turns do not end an active mission."
45+ return "When no mission is active and the user's objective is clear, bounded, settled, and requires substantial multi-step work, call update_goal with status active and an objective before substantial execution. Do not start a mission for brief questions, casual conversation, or requests completed immediately. Inside a mission, set the next concrete goal, pause the active goal when work should wait for a decision, resume the paused goal with status active and no objective once the decision is settled, mark the active goal complete or blocked, or supersede it with a necessary replacement inside the same mission. Never broaden the mission or use superseded to avoid difficult work. Incidental messages and interrupted turns do not end an active mission."
4646 }
4747
4848 func (t *UpdateGoalTool) Schema() json.RawMessage { return json.RawMessage(updateGoalSchema) }
@@ -66,12 +66,12 @@ func (t *UpdateGoalTool) Execute(_ context.Context, raw json.RawMessage, _ func(
6666 var update GoalUpdate
6767 switch args.Status {
6868 case "active":
69- if args.Objective == "" {
70- return goalToolError("objective is required when setting a goal"), nil
71- }
7269 update.Status = core.GoalActive
7370 update.Objective = args.Objective
7471 update.MissionID = args.MissionID
72+ case "paused":
73+ update.Status = core.GoalPaused
74+ update.MissionID = args.MissionID
7575 case "complete":
7676 update.Status = core.GoalDone
7777 case "superseded":
@@ -92,7 +92,7 @@ func (t *UpdateGoalTool) Execute(_ context.Context, raw json.RawMessage, _ func(
9292 update.Status = core.GoalBlocked
9393 update.Reason = args.Reason
9494 default:
95- return goalToolError("status must be active, complete, blocked, or superseded"), nil
95+ return goalToolError("status must be active, paused, complete, blocked, or superseded"), nil
9696 }
9797
9898 text := "autonomous goal marked " + string(update.Status)
@@ -114,10 +114,10 @@ func GoalUpdateFromResult(result core.ToolResult) (GoalUpdate, bool) {
114114 if !ok {
115115 return GoalUpdate{}, false
116116 }
117- if update.Status != core.GoalActive && update.Status != core.GoalDone && update.Status != core.GoalBlocked && update.Status != core.GoalSuperseded {
117+ if update.Status != core.GoalActive && update.Status != core.GoalPaused && update.Status != core.GoalDone && update.Status != core.GoalBlocked && update.Status != core.GoalSuperseded {
118118 return GoalUpdate{}, false
119119 }
120- if (update.Status == core.GoalActive || update.Status == core.GoalSuperseded) && strings.TrimSpace(update.Objective) == "" {
120+ if update.Status == core.GoalSuperseded && strings.TrimSpace(update.Objective) == "" {
121121 return GoalUpdate{}, false
122122 }
123123 if update.Status == core.GoalSuperseded && strings.TrimSpace(update.GoalID) == "" {
@@ -13,7 +13,7 @@ import (
13 13
14 const (14 const (
15 UpdateGoalToolName = "update_goal"15 UpdateGoalToolName = "update_goal"
16- updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["active","complete","blocked","superseded"],"description":"Start a mission, set the next active goal, settle the active goal, or atomically replace it within the same mission."},"objective":{"type":"string","description":"Concrete objective required for active and superseded."},"goal_id":{"type":"string","description":"Current goal identifier required for superseded, copied from the active-goal context."},"mission_id":{"type":"string","description":"Optional identifier from the active-goal context. When supplied, it must match the current mission; omit it when starting or continuing the current mission."},"reason":{"type":"string","description":"Concise reason when the goal is blocked."}},"required":["status"],"additionalProperties":false}`16+ updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["active","paused","complete","blocked","superseded"],"description":"Start or resume a mission, pause it, set the next active goal, settle the active goal, or atomically replace it within the same mission."},"objective":{"type":"string","description":"Concrete objective required when starting or advancing with active, and when superseding. Omit active's objective to resume the paused goal."},"goal_id":{"type":"string","description":"Current goal identifier required for superseded, copied from the active-goal context."},"mission_id":{"type":"string","description":"Optional identifier from the active-goal context. When supplied, it must match the current mission; omit it when starting or continuing the current mission."},"reason":{"type":"string","description":"Concise reason when the goal is blocked."}},"required":["status"],"additionalProperties":false}`
17 )17 )
18 18
19 // UpdateGoalTool lets the main agent start a mission, set its next concrete19 // UpdateGoalTool lets the main agent start a mission, set its next concrete
@@ -42,7 +42,7 @@ type GoalUpdate struct {
42 func (t *UpdateGoalTool) Name() string { return UpdateGoalToolName }42 func (t *UpdateGoalTool) Name() string { return UpdateGoalToolName }
43 43
44 func (t *UpdateGoalTool) Description() string {44 func (t *UpdateGoalTool) Description() string {
45- return "When no mission is active and the user's objective is clear, bounded, settled, and requires substantial multi-step work, call update_goal with status active before substantial execution. Do not start a mission for brief questions, casual conversation, or requests completed immediately. Inside a mission, set the next concrete goal, mark the active goal complete or blocked, or supersede it with a necessary replacement inside the same mission. Never broaden the mission or use superseded to avoid difficult work. Incidental messages and interrupted turns do not end an active mission."45+ return "When no mission is active and the user's objective is clear, bounded, settled, and requires substantial multi-step work, call update_goal with status active and an objective before substantial execution. Do not start a mission for brief questions, casual conversation, or requests completed immediately. Inside a mission, set the next concrete goal, pause the active goal when work should wait for a decision, resume the paused goal with status active and no objective once the decision is settled, mark the active goal complete or blocked, or supersede it with a necessary replacement inside the same mission. Never broaden the mission or use superseded to avoid difficult work. Incidental messages and interrupted turns do not end an active mission."
46 }46 }
47 47
48 func (t *UpdateGoalTool) Schema() json.RawMessage { return json.RawMessage(updateGoalSchema) }48 func (t *UpdateGoalTool) Schema() json.RawMessage { return json.RawMessage(updateGoalSchema) }
@@ -66,12 +66,12 @@ func (t *UpdateGoalTool) Execute(_ context.Context, raw json.RawMessage, _ func(
66 var update GoalUpdate66 var update GoalUpdate
67 switch args.Status {67 switch args.Status {
68 case "active":68 case "active":
69- if args.Objective == "" {
70- return goalToolError("objective is required when setting a goal"), nil
71- }
72 update.Status = core.GoalActive69 update.Status = core.GoalActive
73 update.Objective = args.Objective70 update.Objective = args.Objective
74 update.MissionID = args.MissionID71 update.MissionID = args.MissionID
72+ case "paused":
73+ update.Status = core.GoalPaused
74+ update.MissionID = args.MissionID
75 case "complete":75 case "complete":
76 update.Status = core.GoalDone76 update.Status = core.GoalDone
77 case "superseded":77 case "superseded":
@@ -92,7 +92,7 @@ func (t *UpdateGoalTool) Execute(_ context.Context, raw json.RawMessage, _ func(
92 update.Status = core.GoalBlocked92 update.Status = core.GoalBlocked
93 update.Reason = args.Reason93 update.Reason = args.Reason
94 default:94 default:
95- return goalToolError("status must be active, complete, blocked, or superseded"), nil95+ return goalToolError("status must be active, paused, complete, blocked, or superseded"), nil
96 }96 }
97 97
98 text := "autonomous goal marked " + string(update.Status)98 text := "autonomous goal marked " + string(update.Status)
@@ -114,10 +114,10 @@ func GoalUpdateFromResult(result core.ToolResult) (GoalUpdate, bool) {
114 if !ok {114 if !ok {
115 return GoalUpdate{}, false115 return GoalUpdate{}, false
116 }116 }
117- if update.Status != core.GoalActive && update.Status != core.GoalDone && update.Status != core.GoalBlocked && update.Status != core.GoalSuperseded {117+ if update.Status != core.GoalActive && update.Status != core.GoalPaused && update.Status != core.GoalDone && update.Status != core.GoalBlocked && update.Status != core.GoalSuperseded {
118 return GoalUpdate{}, false118 return GoalUpdate{}, false
119 }119 }
120- if (update.Status == core.GoalActive || update.Status == core.GoalSuperseded) && strings.TrimSpace(update.Objective) == "" {120+ if update.Status == core.GoalSuperseded && strings.TrimSpace(update.Objective) == "" {
121 return GoalUpdate{}, false121 return GoalUpdate{}, false
122 }122 }
123 if update.Status == core.GoalSuperseded && strings.TrimSpace(update.GoalID) == "" {123 if update.Status == core.GoalSuperseded && strings.TrimSpace(update.GoalID) == "" {
M packages/agent/tools/update_goal_test.go +24 -1
@@ -46,6 +46,29 @@ func TestUpdateGoalToolReturnsPersistableStatus(t *testing.T) {
4646 }
4747 }
4848
49+func TestUpdateGoalToolReturnsPauseAndResumeTransitions(t *testing.T) {
50+ tool := &UpdateGoalTool{}
51+ for _, test := range []struct {
52+ name string
53+ raw string
54+ want core.GoalStatus
55+ }{
56+ {name: "pause", raw: `{"status":"paused"}`, want: core.GoalPaused},
57+ {name: "resume", raw: `{"status":"active"}`, want: core.GoalActive},
58+ } {
59+ t.Run(test.name, func(t *testing.T) {
60+ result, err := tool.Execute(context.Background(), json.RawMessage(test.raw), nil)
61+ if err != nil {
62+ t.Fatal(err)
63+ }
64+ update, ok := GoalUpdateFromResult(result)
65+ if !ok || update.Status != test.want || update.Objective != "" {
66+ t.Fatalf("update = %#v, ok = %v", update, ok)
67+ }
68+ })
69+ }
70+}
71+
4972 func TestUpdateGoalToolReturnsPersistableManagerGoal(t *testing.T) {
5073 tool := &UpdateGoalTool{}
5174 result, err := tool.Execute(context.Background(), json.RawMessage(`{"status":"active","objective":"reproduce the reported failure"}`), nil)
@@ -117,7 +140,7 @@ func TestUpdateGoalToolRejectsSupersededWithoutReplacement(t *testing.T) {
117140
118141 func TestUpdateGoalToolRejectsUnsupportedStatus(t *testing.T) {
119142 tool := &UpdateGoalTool{}
120- raw := json.RawMessage(`{"status":"active"}`)
143+ raw := json.RawMessage(`{"status":"waiting"}`)
121144 result, err := tool.Execute(context.Background(), raw, nil)
122145 if err != nil {
123146 t.Fatal(err)
@@ -46,6 +46,29 @@ func TestUpdateGoalToolReturnsPersistableStatus(t *testing.T) {
46 }46 }
47 }47 }
48 48
49+func TestUpdateGoalToolReturnsPauseAndResumeTransitions(t *testing.T) {
50+ tool := &UpdateGoalTool{}
51+ for _, test := range []struct {
52+ name string
53+ raw string
54+ want core.GoalStatus
55+ }{
56+ {name: "pause", raw: `{"status":"paused"}`, want: core.GoalPaused},
57+ {name: "resume", raw: `{"status":"active"}`, want: core.GoalActive},
58+ } {
59+ t.Run(test.name, func(t *testing.T) {
60+ result, err := tool.Execute(context.Background(), json.RawMessage(test.raw), nil)
61+ if err != nil {
62+ t.Fatal(err)
63+ }
64+ update, ok := GoalUpdateFromResult(result)
65+ if !ok || update.Status != test.want || update.Objective != "" {
66+ t.Fatalf("update = %#v, ok = %v", update, ok)
67+ }
68+ })
69+ }
70+}
71+
49 func TestUpdateGoalToolReturnsPersistableManagerGoal(t *testing.T) {72 func TestUpdateGoalToolReturnsPersistableManagerGoal(t *testing.T) {
50 tool := &UpdateGoalTool{}73 tool := &UpdateGoalTool{}
51 result, err := tool.Execute(context.Background(), json.RawMessage(`{"status":"active","objective":"reproduce the reported failure"}`), nil)74 result, err := tool.Execute(context.Background(), json.RawMessage(`{"status":"active","objective":"reproduce the reported failure"}`), nil)
@@ -117,7 +140,7 @@ func TestUpdateGoalToolRejectsSupersededWithoutReplacement(t *testing.T) {
117 140
118 func TestUpdateGoalToolRejectsUnsupportedStatus(t *testing.T) {141 func TestUpdateGoalToolRejectsUnsupportedStatus(t *testing.T) {
119 tool := &UpdateGoalTool{}142 tool := &UpdateGoalTool{}
120- raw := json.RawMessage(`{"status":"active"}`)143+ raw := json.RawMessage(`{"status":"waiting"}`)
121 result, err := tool.Execute(context.Background(), raw, nil)144 result, err := tool.Execute(context.Background(), raw, nil)
122 if err != nil {145 if err != nil {
123 t.Fatal(err)146 t.Fatal(err)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close