detached 7e9841f

feat(agent): add persistent autonomous goals

commit
7e9841fcf077e93770552aec1122b6b4c46ce522
author
brice · · 2026-08-09 18:12 UTC
committer
brice · · 2026-08-09 18:33 UTC
parent
e5afabc
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
19 files · +1113 -34
M README.md +10 -2
@@ -9,7 +9,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go.
99
1010 - one static binary.
1111 - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models.
12-- seven core built-in tools (read, write, edit, bash, create_worktree, lsp, and web_search); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for web-search egress and availability boundaries.
12+- eight core built-in tools (read, write, edit, bash, create_worktree, lsp, web_search, and update_goal); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for web-search egress and availability boundaries.
1313 - three run modes (interactive tui, print, json).
1414 - built-in telegram bot.
1515 - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md).
@@ -239,6 +239,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
239239 - `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path.
240240 - `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. See [docs/lsp.md](docs/lsp.md).
241241 - `web_search`: search the public web through DuckDuckGo HTML and return bounded source titles, URLs, and snippets. Enabled by default for normal CLI sessions; it is unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).
242+- `update_goal`: mark an explicitly started autonomous session goal `done` or `blocked`. Starting, pausing, resuming, and clearing goals remain user-controlled through `/goal`.
242243
243244 When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. Jail does not sandbox web-search network egress.
244245
@@ -295,6 +296,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
295296 | `/reasoning` | Set the reasoning level for subsequent model calls. |
296297 | `/fast` | Toggle fast mode for subsequent model calls. |
297298 | `/orchestrator` | Toggle automatic subagent orchestration. When disabled, subagent tools remain available for explicit delegation. |
299+| `/goal <objective>` | Start an autonomous interactive-session goal. Use `/goal` for status and `/goal pause`, `resume`, or `clear` for control. |
298300 | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. |
299301 | `/sessions` | Resume a previous session for this directory. The picker omits branches created only for tree navigation. |
300302 | `/fork` | Pick a previous user message and fork the current session after that turn. The selected turn becomes branch context and no provider turn starts during the checkout. |
@@ -315,6 +317,12 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
315317
316318 Extension-registered commands appear under a divider at the bottom of the popup, sorted by name.
317319
320+### `/goal`
321+
322+In interactive mode, `/goal <objective>` starts work immediately and keeps the objective active across normal provider turn boundaries. While the goal is active, zut starts a hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. The model marks the goal `done` or `blocked` with the built-in `update_goal` tool. The current state appears in the status bar and is persisted with the session. `esc` pauses an interrupted goal, and terminal provider errors block it instead of retrying indefinitely. Use `/goal` to inspect it, `/goal pause` or `/goal resume` to control execution, and `/goal clear` to remove it.
323+
324+A branch that copies the complete transcript inherits the goal. Forking from an earlier turn clears it because the later objective did not necessarily exist at that point. Manual `/compact` leaves an active goal recorded but does not start a hidden follow-up; run `/goal resume` to continue it.
325+
318326 ### Shell escape (`!command`)
319327
320328 Type `!` followed by a command to run it directly without going through the model. Everything after the `!` is passed to the same shell the `bash` tool uses (`/bin/bash -c` when available on Unix, then `bash -c` from `PATH`, with POSIX `/bin/sh -c` as a fallback; `cmd /C` on Windows), runs in the session working directory, and honors the `/jail` sandbox. The command, merged output, and exit status are appended to the transcript as user context, so the model can use them on the next turn. Running the command does not itself start a model turn. A running `!command` shares the busy state with the agent: `esc` cancels it, and you cannot start one while a turn (or another shell escape) is in flight.
@@ -1084,7 +1092,7 @@ packages/agent/extproto/ extension wire-format types
10841092 packages/agent/modes/ interactive tui, print, json, dialogs
10851093 packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface)
10861094 packages/agent/modes/telegram/ telegram adapter, api client, daemon
1087-packages/agent/tools/ read, write, edit, bash, create_worktree, lsp, web search, sandbox
1095+packages/agent/tools/ read, write, edit, bash, create_worktree, lsp, web search, goals, sandbox
10881096 packages/agent/skills/ skill discovery, frontmatter parser, skill tool
10891097 packages/agent/subagents/ named profiles, supervisor, and background runtime
10901098 packages/agent/sdk/ public Go SDK for embedding zut in-process (package sdk)
@@ -9,7 +9,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go.
9 9
10 - one static binary.10 - one static binary.
11 - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models.11 - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models.
12-- seven core built-in tools (read, write, edit, bash, create_worktree, lsp, and web_search); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for web-search egress and availability boundaries.12+- eight core built-in tools (read, write, edit, bash, create_worktree, lsp, web_search, and update_goal); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for web-search egress and availability boundaries.
13 - three run modes (interactive tui, print, json).13 - three run modes (interactive tui, print, json).
14 - built-in telegram bot.14 - built-in telegram bot.
15 - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md).15 - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md).
@@ -239,6 +239,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
239 - `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path.239 - `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path.
240 - `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. See [docs/lsp.md](docs/lsp.md).240 - `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. See [docs/lsp.md](docs/lsp.md).
241 - `web_search`: search the public web through DuckDuckGo HTML and return bounded source titles, URLs, and snippets. Enabled by default for normal CLI sessions; it is unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).241 - `web_search`: search the public web through DuckDuckGo HTML and return bounded source titles, URLs, and snippets. Enabled by default for normal CLI sessions; it is unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).
242+- `update_goal`: mark an explicitly started autonomous session goal `done` or `blocked`. Starting, pausing, resuming, and clearing goals remain user-controlled through `/goal`.
242 243
243 When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. Jail does not sandbox web-search network egress.244 When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. Jail does not sandbox web-search network egress.
244 245
@@ -295,6 +296,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
295 | `/reasoning` | Set the reasoning level for subsequent model calls. |296 | `/reasoning` | Set the reasoning level for subsequent model calls. |
296 | `/fast` | Toggle fast mode for subsequent model calls. |297 | `/fast` | Toggle fast mode for subsequent model calls. |
297 | `/orchestrator` | Toggle automatic subagent orchestration. When disabled, subagent tools remain available for explicit delegation. |298 | `/orchestrator` | Toggle automatic subagent orchestration. When disabled, subagent tools remain available for explicit delegation. |
299+| `/goal <objective>` | Start an autonomous interactive-session goal. Use `/goal` for status and `/goal pause`, `resume`, or `clear` for control. |
298 | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. |300 | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. |
299 | `/sessions` | Resume a previous session for this directory. The picker omits branches created only for tree navigation. |301 | `/sessions` | Resume a previous session for this directory. The picker omits branches created only for tree navigation. |
300 | `/fork` | Pick a previous user message and fork the current session after that turn. The selected turn becomes branch context and no provider turn starts during the checkout. |302 | `/fork` | Pick a previous user message and fork the current session after that turn. The selected turn becomes branch context and no provider turn starts during the checkout. |
@@ -315,6 +317,12 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
315 317
316 Extension-registered commands appear under a divider at the bottom of the popup, sorted by name.318 Extension-registered commands appear under a divider at the bottom of the popup, sorted by name.
317 319
320+### `/goal`
321+
322+In interactive mode, `/goal <objective>` starts work immediately and keeps the objective active across normal provider turn boundaries. While the goal is active, zut starts a hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. The model marks the goal `done` or `blocked` with the built-in `update_goal` tool. The current state appears in the status bar and is persisted with the session. `esc` pauses an interrupted goal, and terminal provider errors block it instead of retrying indefinitely. Use `/goal` to inspect it, `/goal pause` or `/goal resume` to control execution, and `/goal clear` to remove it.
323+
324+A branch that copies the complete transcript inherits the goal. Forking from an earlier turn clears it because the later objective did not necessarily exist at that point. Manual `/compact` leaves an active goal recorded but does not start a hidden follow-up; run `/goal resume` to continue it.
325+
318 ### Shell escape (`!command`)326 ### Shell escape (`!command`)
319 327
320 Type `!` followed by a command to run it directly without going through the model. Everything after the `!` is passed to the same shell the `bash` tool uses (`/bin/bash -c` when available on Unix, then `bash -c` from `PATH`, with POSIX `/bin/sh -c` as a fallback; `cmd /C` on Windows), runs in the session working directory, and honors the `/jail` sandbox. The command, merged output, and exit status are appended to the transcript as user context, so the model can use them on the next turn. Running the command does not itself start a model turn. A running `!command` shares the busy state with the agent: `esc` cancels it, and you cannot start one while a turn (or another shell escape) is in flight.328 Type `!` followed by a command to run it directly without going through the model. Everything after the `!` is passed to the same shell the `bash` tool uses (`/bin/bash -c` when available on Unix, then `bash -c` from `PATH`, with POSIX `/bin/sh -c` as a fallback; `cmd /C` on Windows), runs in the session working directory, and honors the `/jail` sandbox. The command, merged output, and exit status are appended to the transcript as user context, so the model can use them on the next turn. Running the command does not itself start a model turn. A running `!command` shares the busy state with the agent: `esc` cancels it, and you cannot start one while a turn (or another shell escape) is in flight.
@@ -1084,7 +1092,7 @@ packages/agent/extproto/ extension wire-format types
1084 packages/agent/modes/ interactive tui, print, json, dialogs1092 packages/agent/modes/ interactive tui, print, json, dialogs
1085 packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface)1093 packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface)
1086 packages/agent/modes/telegram/ telegram adapter, api client, daemon1094 packages/agent/modes/telegram/ telegram adapter, api client, daemon
1087-packages/agent/tools/ read, write, edit, bash, create_worktree, lsp, web search, sandbox1095+packages/agent/tools/ read, write, edit, bash, create_worktree, lsp, web search, goals, sandbox
1088 packages/agent/skills/ skill discovery, frontmatter parser, skill tool1096 packages/agent/skills/ skill discovery, frontmatter parser, skill tool
1089 packages/agent/subagents/ named profiles, supervisor, and background runtime1097 packages/agent/subagents/ named profiles, supervisor, and background runtime
1090 packages/agent/sdk/ public Go SDK for embedding zut in-process (package sdk)1098 packages/agent/sdk/ public Go SDK for embedding zut in-process (package sdk)
M packages/agent/build.go +3 -2
@@ -101,7 +101,7 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
101101 // capability policy excludes the current session. An extension must
102102 // not turn a normal CLI opt-out into a differently implemented search
103103 // capability with the same model-visible name.
104- if info.Name == "web_search" {
104+ if info.Name == "web_search" || info.Name == "update_goal" {
105105 continue
106106 }
107107 if _, exists := r.ToolRegistry[info.Name]; exists {
@@ -1291,6 +1291,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
12911291 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
12921292 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
12931293 "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox},
1294+ "update_goal": &tools.UpdateGoalTool{},
12941295 }
12951296 if webSearchAllowedForRegistry(args) {
12961297 all["web_search"] = tools.NewWebSearchTool()
@@ -1377,7 +1378,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
13771378 return false
13781379 }
13791380
1380-var nativeToolSummaryOrder = []string{"read", "write", "edit", "bash", "create_worktree", "lsp", "web_search"}
1381+var nativeToolSummaryOrder = []string{"read", "write", "edit", "bash", "create_worktree", "lsp", "web_search", "update_goal"}
13811382
13821383 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
13831384 var out []ToolSummary
@@ -101,7 +101,7 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
101 // capability policy excludes the current session. An extension must101 // capability policy excludes the current session. An extension must
102 // not turn a normal CLI opt-out into a differently implemented search102 // not turn a normal CLI opt-out into a differently implemented search
103 // capability with the same model-visible name.103 // capability with the same model-visible name.
104- if info.Name == "web_search" {104+ if info.Name == "web_search" || info.Name == "update_goal" {
105 continue105 continue
106 }106 }
107 if _, exists := r.ToolRegistry[info.Name]; exists {107 if _, exists := r.ToolRegistry[info.Name]; exists {
@@ -1291,6 +1291,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
1291 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},1291 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1292 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},1292 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1293 "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox},1293 "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox},
1294+ "update_goal": &tools.UpdateGoalTool{},
1294 }1295 }
1295 if webSearchAllowedForRegistry(args) {1296 if webSearchAllowedForRegistry(args) {
1296 all["web_search"] = tools.NewWebSearchTool()1297 all["web_search"] = tools.NewWebSearchTool()
@@ -1377,7 +1378,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
1377 return false1378 return false
1378 }1379 }
1379 1380
1380-var nativeToolSummaryOrder = []string{"read", "write", "edit", "bash", "create_worktree", "lsp", "web_search"}1381+var nativeToolSummaryOrder = []string{"read", "write", "edit", "bash", "create_worktree", "lsp", "web_search", "update_goal"}
1381 1382
1382 func toolSummaries(reg core.Registry, args Args) []ToolSummary {1383 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1383 var out []ToolSummary1384 var out []ToolSummary
M packages/agent/build_test.go +23 -8
@@ -36,6 +36,15 @@ func (webSearchConflictSource) NewExtensionTool(ExtensionToolInfo) core.Tool {
3636 return &tools.ReadTool{}
3737 }
3838
39+type updateGoalConflictSource struct{}
40+
41+func (updateGoalConflictSource) Tools() []ExtensionToolInfo {
42+ return []ExtensionToolInfo{{Extension: "test", Name: "update_goal"}}
43+}
44+func (updateGoalConflictSource) NewExtensionTool(ExtensionToolInfo) core.Tool {
45+ return &tools.ReadTool{}
46+}
47+
3948 type unorderedExtensionSource struct{}
4049
4150 func (unorderedExtensionSource) Tools() []ExtensionToolInfo {
@@ -84,11 +93,14 @@ func TestMergeExtensionToolsRespectsDisabledSkillDiscovery(t *testing.T) {
8493 }
8594 }
8695
87-func TestMergeExtensionToolsReservesWebSearchName(t *testing.T) {
96+func TestMergeExtensionToolsReservesNativeControlNames(t *testing.T) {
8897 r := Resolved{ToolRegistry: make(core.Registry)}
8998 r.MergeExtensionTools(webSearchConflictSource{})
90- if _, ok := r.ToolRegistry["web_search"]; ok {
91- t.Fatal("extension replaced reserved native web_search name")
99+ r.MergeExtensionTools(updateGoalConflictSource{})
100+ for _, name := range []string{"web_search", "update_goal"} {
101+ if _, ok := r.ToolRegistry[name]; ok {
102+ t.Fatalf("extension replaced reserved native %s name", name)
103+ }
92104 }
93105 }
94106
@@ -103,7 +115,7 @@ func TestMergeExtensionToolsKeepsDeterministicNativeSummaryOrder(t *testing.T) {
103115 for _, summary := range r.ToolSummary {
104116 names = append(names, summary.Name)
105117 }
106- want := "read,write,edit,bash,create_worktree,web_search,alpha,zeta"
118+ want := "read,write,edit,bash,create_worktree,web_search,update_goal,alpha,zeta"
107119 if got := strings.Join(names, ","); got != want {
108120 t.Fatalf("merged tool summary order = %q, want %q", got, want)
109121 }
@@ -153,6 +165,9 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
153165 if _, ok := registry["web_search"].(*tools.WebSearchTool); !ok {
154166 t.Fatalf("default registry web_search = %#v", registry["web_search"])
155167 }
168+ if _, ok := registry["update_goal"].(*tools.UpdateGoalTool); !ok {
169+ t.Fatalf("default registry update_goal = %#v", registry["update_goal"])
170+ }
156171 write, ok := registry["write"].(*tools.WriteTool)
157172 if !ok || write.LSP == nil || !write.LSPDiagnostics {
158173 t.Fatalf("write tool LSP wiring = %#v", write)
@@ -161,8 +176,8 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
161176 if !ok || edit.LSP == nil || edit.LSPDiagnostics {
162177 t.Fatalf("edit tool LSP wiring = %#v", edit)
163178 }
164- if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 6 {
165- t.Fatalf("LSP-disabled registry has %d tools, want 6", len(disabled))
179+ if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 7 {
180+ t.Fatalf("LSP-disabled registry has %d tools, want 7", len(disabled))
166181 }
167182 readOnly := buildToolRegistry(Args{Tools: []string{"read"}}, root, tools.NewSandbox(root), true, true, true)
168183 if read, ok := readOnly["read"].(*tools.ReadTool); !ok || read == nil {
@@ -193,12 +208,12 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
193208 }
194209 }
195210
196-func TestToolSummariesIncludeCreateWorktreeAndWebSearch(t *testing.T) {
211+func TestToolSummariesIncludeGoalUpdates(t *testing.T) {
197212 root := t.TempDir()
198213 registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, false, false)
199214
200215 summaries := toolSummaries(registry, Args{})
201- want := []string{"read", "write", "edit", "bash", "create_worktree", "web_search"}
216+ want := []string{"read", "write", "edit", "bash", "create_worktree", "web_search", "update_goal"}
202217 if len(summaries) != len(want) {
203218 t.Fatalf("tool summaries = %#v, want %v", summaries, want)
204219 }
@@ -36,6 +36,15 @@ func (webSearchConflictSource) NewExtensionTool(ExtensionToolInfo) core.Tool {
36 return &tools.ReadTool{}36 return &tools.ReadTool{}
37 }37 }
38 38
39+type updateGoalConflictSource struct{}
40+
41+func (updateGoalConflictSource) Tools() []ExtensionToolInfo {
42+ return []ExtensionToolInfo{{Extension: "test", Name: "update_goal"}}
43+}
44+func (updateGoalConflictSource) NewExtensionTool(ExtensionToolInfo) core.Tool {
45+ return &tools.ReadTool{}
46+}
47+
39 type unorderedExtensionSource struct{}48 type unorderedExtensionSource struct{}
40 49
41 func (unorderedExtensionSource) Tools() []ExtensionToolInfo {50 func (unorderedExtensionSource) Tools() []ExtensionToolInfo {
@@ -84,11 +93,14 @@ func TestMergeExtensionToolsRespectsDisabledSkillDiscovery(t *testing.T) {
84 }93 }
85 }94 }
86 95
87-func TestMergeExtensionToolsReservesWebSearchName(t *testing.T) {96+func TestMergeExtensionToolsReservesNativeControlNames(t *testing.T) {
88 r := Resolved{ToolRegistry: make(core.Registry)}97 r := Resolved{ToolRegistry: make(core.Registry)}
89 r.MergeExtensionTools(webSearchConflictSource{})98 r.MergeExtensionTools(webSearchConflictSource{})
90- if _, ok := r.ToolRegistry["web_search"]; ok {99+ r.MergeExtensionTools(updateGoalConflictSource{})
91- t.Fatal("extension replaced reserved native web_search name")100+ for _, name := range []string{"web_search", "update_goal"} {
101+ if _, ok := r.ToolRegistry[name]; ok {
102+ t.Fatalf("extension replaced reserved native %s name", name)
103+ }
92 }104 }
93 }105 }
94 106
@@ -103,7 +115,7 @@ func TestMergeExtensionToolsKeepsDeterministicNativeSummaryOrder(t *testing.T) {
103 for _, summary := range r.ToolSummary {115 for _, summary := range r.ToolSummary {
104 names = append(names, summary.Name)116 names = append(names, summary.Name)
105 }117 }
106- want := "read,write,edit,bash,create_worktree,web_search,alpha,zeta"118+ want := "read,write,edit,bash,create_worktree,web_search,update_goal,alpha,zeta"
107 if got := strings.Join(names, ","); got != want {119 if got := strings.Join(names, ","); got != want {
108 t.Fatalf("merged tool summary order = %q, want %q", got, want)120 t.Fatalf("merged tool summary order = %q, want %q", got, want)
109 }121 }
@@ -153,6 +165,9 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
153 if _, ok := registry["web_search"].(*tools.WebSearchTool); !ok {165 if _, ok := registry["web_search"].(*tools.WebSearchTool); !ok {
154 t.Fatalf("default registry web_search = %#v", registry["web_search"])166 t.Fatalf("default registry web_search = %#v", registry["web_search"])
155 }167 }
168+ if _, ok := registry["update_goal"].(*tools.UpdateGoalTool); !ok {
169+ t.Fatalf("default registry update_goal = %#v", registry["update_goal"])
170+ }
156 write, ok := registry["write"].(*tools.WriteTool)171 write, ok := registry["write"].(*tools.WriteTool)
157 if !ok || write.LSP == nil || !write.LSPDiagnostics {172 if !ok || write.LSP == nil || !write.LSPDiagnostics {
158 t.Fatalf("write tool LSP wiring = %#v", write)173 t.Fatalf("write tool LSP wiring = %#v", write)
@@ -161,8 +176,8 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
161 if !ok || edit.LSP == nil || edit.LSPDiagnostics {176 if !ok || edit.LSP == nil || edit.LSPDiagnostics {
162 t.Fatalf("edit tool LSP wiring = %#v", edit)177 t.Fatalf("edit tool LSP wiring = %#v", edit)
163 }178 }
164- if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 6 {179+ if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 7 {
165- t.Fatalf("LSP-disabled registry has %d tools, want 6", len(disabled))180+ t.Fatalf("LSP-disabled registry has %d tools, want 7", len(disabled))
166 }181 }
167 readOnly := buildToolRegistry(Args{Tools: []string{"read"}}, root, tools.NewSandbox(root), true, true, true)182 readOnly := buildToolRegistry(Args{Tools: []string{"read"}}, root, tools.NewSandbox(root), true, true, true)
168 if read, ok := readOnly["read"].(*tools.ReadTool); !ok || read == nil {183 if read, ok := readOnly["read"].(*tools.ReadTool); !ok || read == nil {
@@ -193,12 +208,12 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
193 }208 }
194 }209 }
195 210
196-func TestToolSummariesIncludeCreateWorktreeAndWebSearch(t *testing.T) {211+func TestToolSummariesIncludeGoalUpdates(t *testing.T) {
197 root := t.TempDir()212 root := t.TempDir()
198 registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, false, false)213 registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, false, false)
199 214
200 summaries := toolSummaries(registry, Args{})215 summaries := toolSummaries(registry, Args{})
201- want := []string{"read", "write", "edit", "bash", "create_worktree", "web_search"}216+ want := []string{"read", "write", "edit", "bash", "create_worktree", "web_search", "update_goal"}
202 if len(summaries) != len(want) {217 if len(summaries) != len(want) {
203 t.Fatalf("tool summaries = %#v, want %v", summaries, want)218 t.Fatalf("tool summaries = %#v, want %v", summaries, want)
204 }219 }
M packages/agent/cli.go +54 -10
@@ -692,17 +692,38 @@ func persistExtensionToolResult(mgr *extensions.Manager, sess *core.Session, res
692692 if sess == nil {
693693 return
694694 }
695- details, ok := result.Details.(extensions.ToolResultDetails)
696- if !ok || details.Extension == "" || len(details.State) == 0 {
697- return
695+ if details, ok := result.Details.(extensions.ToolResultDetails); ok && details.Extension != "" && len(details.State) > 0 {
696+ if err := sess.AppendExtensionState(details.Extension, details.State); err != nil {
697+ fmt.Fprintf(os.Stderr, "extension state: %v\n", err)
698+ } else if mgr != nil {
699+ mgr.UpdateSessionState(details.Extension, details.State)
700+ }
698701 }
699- if err := sess.AppendExtensionState(details.Extension, details.State); err != nil {
700- fmt.Fprintf(os.Stderr, "extension state: %v\n", err)
701- return
702+ if err := persistGoalToolResult(sess, result); err != nil {
703+ fmt.Fprintf(os.Stderr, "goal state: %v\n", err)
704+ }
705+}
706+
707+func persistToolResultForCurrentSession(sessionTransitionMu *sync.RWMutex, persistMu *sync.Mutex, currentSession func() *core.Session, mgr *extensions.Manager, result core.ToolResult) {
708+ sessionTransitionMu.RLock()
709+ defer sessionTransitionMu.RUnlock()
710+ persistMu.Lock()
711+ defer persistMu.Unlock()
712+ persistExtensionToolResult(mgr, currentSession(), result)
713+}
714+
715+func persistGoalToolResult(sess *core.Session, result core.ToolResult) error {
716+ if sess == nil || sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive {
717+ return nil
702718 }
703- if mgr != nil {
704- mgr.UpdateSessionState(details.Extension, details.State)
719+ update, ok := tools.GoalUpdateFromResult(result)
720+ if !ok {
721+ return nil
705722 }
723+ goal := *sess.Meta.Goal
724+ goal.Status = update.Status
725+ goal.Reason = update.Reason
726+ return sess.UpdateGoal(&goal)
706727 }
707728
708729 func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
@@ -1802,10 +1823,27 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
18021823 }
18031824 return append(json.RawMessage(nil), sess.Meta.CompactHandoff...)
18041825 }
1805- persistToolResult := func(_ string, result core.ToolResult) {
1826+ persistGoal := func(goal *core.SessionGoal) error {
1827+ sessionTransitionMu.RLock()
1828+ defer sessionTransitionMu.RUnlock()
1829+ persistMu.Lock()
1830+ defer persistMu.Unlock()
1831+ if sess == nil {
1832+ return errors.New("session persistence is disabled")
1833+ }
1834+ return sess.UpdateGoal(goal)
1835+ }
1836+ currentGoal := func() *core.SessionGoal {
18061837 persistMu.Lock()
18071838 defer persistMu.Unlock()
1808- persistExtensionToolResult(extMgr, sess, result)
1839+ if sess == nil || sess.Meta.Goal == nil {
1840+ return nil
1841+ }
1842+ goal := *sess.Meta.Goal
1843+ return &goal
1844+ }
1845+ persistToolResult := func(_ string, result core.ToolResult) {
1846+ persistToolResultForCurrentSession(&sessionTransitionMu, &persistMu, func() *core.Session { return sess }, extMgr, result)
18091847 }
18101848 wireAgentPersist := func(a *core.Agent) *core.Agent {
18111849 if a == nil {
@@ -1964,6 +2002,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
19642002 }
19652003 committed = true
19662004 persistMu.Unlock()
2005+ if iv != nil {
2006+ iv.RefreshGoal()
2007+ }
19672008 if candidate.session.Meta.Parent != "" {
19682009 extMgr.EmitSessionEvent("session_forked", sessionContext(candidate.session), newStates)
19692010 }
@@ -2144,6 +2185,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
21442185 if iv != nil {
21452186 startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZutHome()))
21462187 iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths)
2188+ iv.RefreshGoal()
21472189 }
21482190
21492191 // Re-scope the subagent dashboard to the new session.
@@ -2401,6 +2443,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
24012443 ChangeCWD: changeCWD,
24022444 PersistCompactHandoff: persistCompactHandoff,
24032445 CurrentCompactHandoff: currentCompactHandoff,
2446+ PersistGoal: persistGoal,
2447+ CurrentGoal: currentGoal,
24042448 CurrentSessionPath: func() string {
24052449 persistMu.Lock()
24062450 defer persistMu.Unlock()
@@ -692,17 +692,38 @@ func persistExtensionToolResult(mgr *extensions.Manager, sess *core.Session, res
692 if sess == nil {692 if sess == nil {
693 return693 return
694 }694 }
695- details, ok := result.Details.(extensions.ToolResultDetails)695+ if details, ok := result.Details.(extensions.ToolResultDetails); ok && details.Extension != "" && len(details.State) > 0 {
696- if !ok || details.Extension == "" || len(details.State) == 0 {696+ if err := sess.AppendExtensionState(details.Extension, details.State); err != nil {
697- return697+ fmt.Fprintf(os.Stderr, "extension state: %v\n", err)
698+ } else if mgr != nil {
699+ mgr.UpdateSessionState(details.Extension, details.State)
700+ }
698 }701 }
699- if err := sess.AppendExtensionState(details.Extension, details.State); err != nil {702+ if err := persistGoalToolResult(sess, result); err != nil {
700- fmt.Fprintf(os.Stderr, "extension state: %v\n", err)703+ fmt.Fprintf(os.Stderr, "goal state: %v\n", err)
701- return704+ }
705+}
706+
707+func persistToolResultForCurrentSession(sessionTransitionMu *sync.RWMutex, persistMu *sync.Mutex, currentSession func() *core.Session, mgr *extensions.Manager, result core.ToolResult) {
708+ sessionTransitionMu.RLock()
709+ defer sessionTransitionMu.RUnlock()
710+ persistMu.Lock()
711+ defer persistMu.Unlock()
712+ persistExtensionToolResult(mgr, currentSession(), result)
713+}
714+
715+func persistGoalToolResult(sess *core.Session, result core.ToolResult) error {
716+ if sess == nil || sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive {
717+ return nil
702 }718 }
703- if mgr != nil {719+ update, ok := tools.GoalUpdateFromResult(result)
704- mgr.UpdateSessionState(details.Extension, details.State)720+ if !ok {
721+ return nil
705 }722 }
723+ goal := *sess.Meta.Goal
724+ goal.Status = update.Status
725+ goal.Reason = update.Reason
726+ return sess.UpdateGoal(&goal)
706 }727 }
707 728
708 func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {729 func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
@@ -1802,10 +1823,27 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1802 }1823 }
1803 return append(json.RawMessage(nil), sess.Meta.CompactHandoff...)1824 return append(json.RawMessage(nil), sess.Meta.CompactHandoff...)
1804 }1825 }
1805- persistToolResult := func(_ string, result core.ToolResult) {1826+ persistGoal := func(goal *core.SessionGoal) error {
1827+ sessionTransitionMu.RLock()
1828+ defer sessionTransitionMu.RUnlock()
1829+ persistMu.Lock()
1830+ defer persistMu.Unlock()
1831+ if sess == nil {
1832+ return errors.New("session persistence is disabled")
1833+ }
1834+ return sess.UpdateGoal(goal)
1835+ }
1836+ currentGoal := func() *core.SessionGoal {
1806 persistMu.Lock()1837 persistMu.Lock()
1807 defer persistMu.Unlock()1838 defer persistMu.Unlock()
1808- persistExtensionToolResult(extMgr, sess, result)1839+ if sess == nil || sess.Meta.Goal == nil {
1840+ return nil
1841+ }
1842+ goal := *sess.Meta.Goal
1843+ return &goal
1844+ }
1845+ persistToolResult := func(_ string, result core.ToolResult) {
1846+ persistToolResultForCurrentSession(&sessionTransitionMu, &persistMu, func() *core.Session { return sess }, extMgr, result)
1809 }1847 }
1810 wireAgentPersist := func(a *core.Agent) *core.Agent {1848 wireAgentPersist := func(a *core.Agent) *core.Agent {
1811 if a == nil {1849 if a == nil {
@@ -1964,6 +2002,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1964 }2002 }
1965 committed = true2003 committed = true
1966 persistMu.Unlock()2004 persistMu.Unlock()
2005+ if iv != nil {
2006+ iv.RefreshGoal()
2007+ }
1967 if candidate.session.Meta.Parent != "" {2008 if candidate.session.Meta.Parent != "" {
1968 extMgr.EmitSessionEvent("session_forked", sessionContext(candidate.session), newStates)2009 extMgr.EmitSessionEvent("session_forked", sessionContext(candidate.session), newStates)
1969 }2010 }
@@ -2144,6 +2185,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2144 if iv != nil {2185 if iv != nil {
2145 startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZutHome()))2186 startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZutHome()))
2146 iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths)2187 iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths)
2188+ iv.RefreshGoal()
2147 }2189 }
2148 2190
2149 // Re-scope the subagent dashboard to the new session.2191 // Re-scope the subagent dashboard to the new session.
@@ -2401,6 +2443,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2401 ChangeCWD: changeCWD,2443 ChangeCWD: changeCWD,
2402 PersistCompactHandoff: persistCompactHandoff,2444 PersistCompactHandoff: persistCompactHandoff,
2403 CurrentCompactHandoff: currentCompactHandoff,2445 CurrentCompactHandoff: currentCompactHandoff,
2446+ PersistGoal: persistGoal,
2447+ CurrentGoal: currentGoal,
2404 CurrentSessionPath: func() string {2448 CurrentSessionPath: func() string {
2405 persistMu.Lock()2449 persistMu.Lock()
2406 defer persistMu.Unlock()2450 defer persistMu.Unlock()
M packages/agent/cli_session_test.go +89 -0
@@ -7,17 +7,106 @@ import (
77 "fmt"
88 "os"
99 "path/filepath"
10+ "runtime"
1011 "strings"
1112 "sync"
1213 "testing"
1314 "time"
1415
1516 "github.com/bnema/zut/packages/agent/modes"
17+ "github.com/bnema/zut/packages/agent/tools"
1618 "github.com/bnema/zut/packages/core"
1719 "github.com/bnema/zut/packages/provider"
1820 "github.com/google/uuid"
1921 )
2022
23+func TestPersistToolResultFinishesBeforeSessionTransition(t *testing.T) {
24+ root := t.TempDir()
25+ original, err := core.NewSession(root, "original", "provider", "model", "test")
26+ if err != nil {
27+ t.Fatal(err)
28+ }
29+ defer original.Close()
30+ next, err := core.NewSession(root, "next", "provider", "model", "test")
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ defer next.Close()
35+ for _, sess := range []*core.Session{original, next} {
36+ if err := sess.UpdateGoal(&core.SessionGoal{Objective: "finish", Status: core.GoalActive}); err != nil {
37+ t.Fatal(err)
38+ }
39+ }
40+
41+ var transitionMu sync.RWMutex
42+ var persistMu sync.Mutex
43+ current := original
44+ result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalDone}}
45+ persistMu.Lock()
46+ persisted := make(chan struct{})
47+ go func() {
48+ persistToolResultForCurrentSession(&transitionMu, &persistMu, func() *core.Session { return current }, nil, result)
49+ close(persisted)
50+ }()
51+
52+ deadline := time.Now().Add(2 * time.Second)
53+ for transitionMu.TryLock() {
54+ transitionMu.Unlock()
55+ if time.Now().After(deadline) {
56+ persistMu.Unlock()
57+ t.Fatal("tool-result callback did not acquire the session read lock")
58+ }
59+ runtime.Gosched()
60+ }
61+
62+ transitioned := make(chan struct{})
63+ go func() {
64+ transitionMu.Lock()
65+ persistMu.Lock()
66+ current = next
67+ persistMu.Unlock()
68+ transitionMu.Unlock()
69+ close(transitioned)
70+ }()
71+ persistMu.Unlock()
72+ select {
73+ case <-persisted:
74+ case <-time.After(2 * time.Second):
75+ t.Fatal("tool result did not persist")
76+ }
77+ select {
78+ case <-transitioned:
79+ case <-time.After(2 * time.Second):
80+ t.Fatal("session transition did not finish")
81+ }
82+
83+ if original.Meta.Goal == nil || original.Meta.Goal.Status != core.GoalDone {
84+ t.Fatalf("original goal = %#v, want done", original.Meta.Goal)
85+ }
86+ if next.Meta.Goal == nil || next.Meta.Goal.Status != core.GoalActive {
87+ t.Fatalf("next goal = %#v, want active", next.Meta.Goal)
88+ }
89+}
90+
91+func TestPersistGoalToolResultUpdatesActiveGoal(t *testing.T) {
92+ sess, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
93+ if err != nil {
94+ t.Fatal(err)
95+ }
96+ defer sess.Close()
97+ if err := sess.UpdateGoal(&core.SessionGoal{Objective: "finish the change", Status: core.GoalActive}); err != nil {
98+ t.Fatal(err)
99+ }
100+
101+ result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalBlocked, Reason: "need user input"}}
102+ if err := persistGoalToolResult(sess, result); err != nil {
103+ t.Fatal(err)
104+ }
105+ if got := sess.Meta.Goal; got == nil || got.Status != core.GoalBlocked || got.Reason != "need user input" {
106+ t.Fatalf("persisted goal = %#v", got)
107+ }
108+}
109+
21110 type failingStdinReader struct{}
22111
23112 func (failingStdinReader) Read([]byte) (int, error) { return 0, errors.New("stdin failed") }
@@ -7,17 +7,106 @@ import (
7 "fmt"7 "fmt"
8 "os"8 "os"
9 "path/filepath"9 "path/filepath"
10+ "runtime"
10 "strings"11 "strings"
11 "sync"12 "sync"
12 "testing"13 "testing"
13 "time"14 "time"
14 15
15 "github.com/bnema/zut/packages/agent/modes"16 "github.com/bnema/zut/packages/agent/modes"
17+ "github.com/bnema/zut/packages/agent/tools"
16 "github.com/bnema/zut/packages/core"18 "github.com/bnema/zut/packages/core"
17 "github.com/bnema/zut/packages/provider"19 "github.com/bnema/zut/packages/provider"
18 "github.com/google/uuid"20 "github.com/google/uuid"
19 )21 )
20 22
23+func TestPersistToolResultFinishesBeforeSessionTransition(t *testing.T) {
24+ root := t.TempDir()
25+ original, err := core.NewSession(root, "original", "provider", "model", "test")
26+ if err != nil {
27+ t.Fatal(err)
28+ }
29+ defer original.Close()
30+ next, err := core.NewSession(root, "next", "provider", "model", "test")
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ defer next.Close()
35+ for _, sess := range []*core.Session{original, next} {
36+ if err := sess.UpdateGoal(&core.SessionGoal{Objective: "finish", Status: core.GoalActive}); err != nil {
37+ t.Fatal(err)
38+ }
39+ }
40+
41+ var transitionMu sync.RWMutex
42+ var persistMu sync.Mutex
43+ current := original
44+ result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalDone}}
45+ persistMu.Lock()
46+ persisted := make(chan struct{})
47+ go func() {
48+ persistToolResultForCurrentSession(&transitionMu, &persistMu, func() *core.Session { return current }, nil, result)
49+ close(persisted)
50+ }()
51+
52+ deadline := time.Now().Add(2 * time.Second)
53+ for transitionMu.TryLock() {
54+ transitionMu.Unlock()
55+ if time.Now().After(deadline) {
56+ persistMu.Unlock()
57+ t.Fatal("tool-result callback did not acquire the session read lock")
58+ }
59+ runtime.Gosched()
60+ }
61+
62+ transitioned := make(chan struct{})
63+ go func() {
64+ transitionMu.Lock()
65+ persistMu.Lock()
66+ current = next
67+ persistMu.Unlock()
68+ transitionMu.Unlock()
69+ close(transitioned)
70+ }()
71+ persistMu.Unlock()
72+ select {
73+ case <-persisted:
74+ case <-time.After(2 * time.Second):
75+ t.Fatal("tool result did not persist")
76+ }
77+ select {
78+ case <-transitioned:
79+ case <-time.After(2 * time.Second):
80+ t.Fatal("session transition did not finish")
81+ }
82+
83+ if original.Meta.Goal == nil || original.Meta.Goal.Status != core.GoalDone {
84+ t.Fatalf("original goal = %#v, want done", original.Meta.Goal)
85+ }
86+ if next.Meta.Goal == nil || next.Meta.Goal.Status != core.GoalActive {
87+ t.Fatalf("next goal = %#v, want active", next.Meta.Goal)
88+ }
89+}
90+
91+func TestPersistGoalToolResultUpdatesActiveGoal(t *testing.T) {
92+ sess, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
93+ if err != nil {
94+ t.Fatal(err)
95+ }
96+ defer sess.Close()
97+ if err := sess.UpdateGoal(&core.SessionGoal{Objective: "finish the change", Status: core.GoalActive}); err != nil {
98+ t.Fatal(err)
99+ }
100+
101+ result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalBlocked, Reason: "need user input"}}
102+ if err := persistGoalToolResult(sess, result); err != nil {
103+ t.Fatal(err)
104+ }
105+ if got := sess.Meta.Goal; got == nil || got.Status != core.GoalBlocked || got.Reason != "need user input" {
106+ t.Fatalf("persisted goal = %#v", got)
107+ }
108+}
109+
21 type failingStdinReader struct{}110 type failingStdinReader struct{}
22 111
23 func (failingStdinReader) Read([]byte) (int, error) { return 0, errors.New("stdin failed") }112 func (failingStdinReader) Read([]byte) (int, error) { return 0, errors.New("stdin failed") }
M packages/agent/modes/compact_queue_test.go +91 -0
@@ -17,6 +17,7 @@ type compactQueueClient struct {
1717 compactionStarted chan struct{}
1818 releaseCompaction chan struct{}
1919 followUpRequest chan provider.Request
20+ onFollowUp func()
2021
2122 mu sync.Mutex
2223 calls int
@@ -45,6 +46,9 @@ func (c *compactQueueClient) Stream(ctx context.Context, req provider.Request) (
4546 return
4647 }
4748
49+ if c.onFollowUp != nil {
50+ c.onFollowUp()
51+ }
4852 c.followUpRequest <- req
4953 out <- provider.EventDone{
5054 Stop: provider.StopEnd,
@@ -137,6 +141,93 @@ func TestAutoCompactionSettlesFinalStreamingStateBeforeCompacting(t *testing.T)
137141 close(client.releaseCompaction)
138142 }
139143
144+func TestActiveGoalContinuesAfterAutomaticCompaction(t *testing.T) {
145+ var goalMu sync.Mutex
146+ goal := &core.SessionGoal{Objective: "finish the goal", Status: core.GoalActive}
147+ currentGoal := func() *core.SessionGoal {
148+ goalMu.Lock()
149+ defer goalMu.Unlock()
150+ return cloneSessionGoal(goal)
151+ }
152+ persistGoal := func(next *core.SessionGoal) error {
153+ goalMu.Lock()
154+ goal = cloneSessionGoal(next)
155+ goalMu.Unlock()
156+ return nil
157+ }
158+ client := &compactQueueClient{
159+ compactionStarted: make(chan struct{}),
160+ releaseCompaction: make(chan struct{}),
161+ followUpRequest: make(chan provider.Request, 1),
162+ onFollowUp: func() {
163+ // Pause before publishing the follow-up so the loop stops after one turn.
164+ _ = persistGoal(&core.SessionGoal{Objective: "finish the goal", Status: core.GoalPaused})
165+ },
166+ }
167+ agent := core.NewAgent(client, "test-model", "", goalToolRegistry())
168+ agent.SetMessages([]provider.Message{
169+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "request"}}},
170+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "first result"}}},
171+ })
172+ interactive := NewInteractive(InteractiveConfig{Agent: agent, CurrentGoal: currentGoal, PersistGoal: persistGoal})
173+ interactive.runCtx = context.Background()
174+
175+ interactive.runCompact(context.Background(), compactContinuationRequest{origin: compactOriginAfterTurnThreshold, lastStop: provider.StopEnd})
176+ select {
177+ case <-client.compactionStarted:
178+ case <-time.After(2 * time.Second):
179+ t.Fatal("automatic compaction did not start")
180+ }
181+ close(client.releaseCompaction)
182+
183+ select {
184+ case req := <-client.followUpRequest:
185+ if len(req.Messages) == 0 || req.Messages[len(req.Messages)-1].Meta[goalContinueMetaKey] != "true" {
186+ t.Fatalf("goal continuation tail = %#v", req.Messages)
187+ }
188+ case <-time.After(2 * time.Second):
189+ t.Fatal("active goal did not continue after automatic compaction")
190+ }
191+ waitInteractiveIdle(t, interactive)
192+}
193+
194+func TestActiveGoalDoesNotContinueAfterManualCompaction(t *testing.T) {
195+ goal := &core.SessionGoal{Objective: "finish the goal", Status: core.GoalActive}
196+ client := &compactQueueClient{
197+ compactionStarted: make(chan struct{}),
198+ releaseCompaction: make(chan struct{}),
199+ followUpRequest: make(chan provider.Request, 1),
200+ }
201+ agent := core.NewAgent(client, "test-model", "", goalToolRegistry())
202+ agent.SetMessages([]provider.Message{
203+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "request"}}},
204+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "result"}}},
205+ })
206+ interactive := NewInteractive(InteractiveConfig{
207+ Agent: agent,
208+ CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) },
209+ PersistGoal: func(next *core.SessionGoal) error {
210+ goal = cloneSessionGoal(next)
211+ return nil
212+ },
213+ })
214+ interactive.runCtx = context.Background()
215+
216+ interactive.runCompact(context.Background(), compactContinuationRequest{origin: compactOriginManual})
217+ select {
218+ case <-client.compactionStarted:
219+ case <-time.After(2 * time.Second):
220+ t.Fatal("manual compaction did not start")
221+ }
222+ close(client.releaseCompaction)
223+ waitInteractiveIdle(t, interactive)
224+ select {
225+ case req := <-client.followUpRequest:
226+ t.Fatalf("manual compaction started goal continuation: %#v", req)
227+ default:
228+ }
229+}
230+
140231 func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) {
141232 client := &compactQueueClient{
142233 compactionStarted: make(chan struct{}),
@@ -17,6 +17,7 @@ type compactQueueClient struct {
17 compactionStarted chan struct{}17 compactionStarted chan struct{}
18 releaseCompaction chan struct{}18 releaseCompaction chan struct{}
19 followUpRequest chan provider.Request19 followUpRequest chan provider.Request
20+ onFollowUp func()
20 21
21 mu sync.Mutex22 mu sync.Mutex
22 calls int23 calls int
@@ -45,6 +46,9 @@ func (c *compactQueueClient) Stream(ctx context.Context, req provider.Request) (
45 return46 return
46 }47 }
47 48
49+ if c.onFollowUp != nil {
50+ c.onFollowUp()
51+ }
48 c.followUpRequest <- req52 c.followUpRequest <- req
49 out <- provider.EventDone{53 out <- provider.EventDone{
50 Stop: provider.StopEnd,54 Stop: provider.StopEnd,
@@ -137,6 +141,93 @@ func TestAutoCompactionSettlesFinalStreamingStateBeforeCompacting(t *testing.T)
137 close(client.releaseCompaction)141 close(client.releaseCompaction)
138 }142 }
139 143
144+func TestActiveGoalContinuesAfterAutomaticCompaction(t *testing.T) {
145+ var goalMu sync.Mutex
146+ goal := &core.SessionGoal{Objective: "finish the goal", Status: core.GoalActive}
147+ currentGoal := func() *core.SessionGoal {
148+ goalMu.Lock()
149+ defer goalMu.Unlock()
150+ return cloneSessionGoal(goal)
151+ }
152+ persistGoal := func(next *core.SessionGoal) error {
153+ goalMu.Lock()
154+ goal = cloneSessionGoal(next)
155+ goalMu.Unlock()
156+ return nil
157+ }
158+ client := &compactQueueClient{
159+ compactionStarted: make(chan struct{}),
160+ releaseCompaction: make(chan struct{}),
161+ followUpRequest: make(chan provider.Request, 1),
162+ onFollowUp: func() {
163+ // Pause before publishing the follow-up so the loop stops after one turn.
164+ _ = persistGoal(&core.SessionGoal{Objective: "finish the goal", Status: core.GoalPaused})
165+ },
166+ }
167+ agent := core.NewAgent(client, "test-model", "", goalToolRegistry())
168+ agent.SetMessages([]provider.Message{
169+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "request"}}},
170+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "first result"}}},
171+ })
172+ interactive := NewInteractive(InteractiveConfig{Agent: agent, CurrentGoal: currentGoal, PersistGoal: persistGoal})
173+ interactive.runCtx = context.Background()
174+
175+ interactive.runCompact(context.Background(), compactContinuationRequest{origin: compactOriginAfterTurnThreshold, lastStop: provider.StopEnd})
176+ select {
177+ case <-client.compactionStarted:
178+ case <-time.After(2 * time.Second):
179+ t.Fatal("automatic compaction did not start")
180+ }
181+ close(client.releaseCompaction)
182+
183+ select {
184+ case req := <-client.followUpRequest:
185+ if len(req.Messages) == 0 || req.Messages[len(req.Messages)-1].Meta[goalContinueMetaKey] != "true" {
186+ t.Fatalf("goal continuation tail = %#v", req.Messages)
187+ }
188+ case <-time.After(2 * time.Second):
189+ t.Fatal("active goal did not continue after automatic compaction")
190+ }
191+ waitInteractiveIdle(t, interactive)
192+}
193+
194+func TestActiveGoalDoesNotContinueAfterManualCompaction(t *testing.T) {
195+ goal := &core.SessionGoal{Objective: "finish the goal", Status: core.GoalActive}
196+ client := &compactQueueClient{
197+ compactionStarted: make(chan struct{}),
198+ releaseCompaction: make(chan struct{}),
199+ followUpRequest: make(chan provider.Request, 1),
200+ }
201+ agent := core.NewAgent(client, "test-model", "", goalToolRegistry())
202+ agent.SetMessages([]provider.Message{
203+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "request"}}},
204+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "result"}}},
205+ })
206+ interactive := NewInteractive(InteractiveConfig{
207+ Agent: agent,
208+ CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) },
209+ PersistGoal: func(next *core.SessionGoal) error {
210+ goal = cloneSessionGoal(next)
211+ return nil
212+ },
213+ })
214+ interactive.runCtx = context.Background()
215+
216+ interactive.runCompact(context.Background(), compactContinuationRequest{origin: compactOriginManual})
217+ select {
218+ case <-client.compactionStarted:
219+ case <-time.After(2 * time.Second):
220+ t.Fatal("manual compaction did not start")
221+ }
222+ close(client.releaseCompaction)
223+ waitInteractiveIdle(t, interactive)
224+ select {
225+ case req := <-client.followUpRequest:
226+ t.Fatalf("manual compaction started goal continuation: %#v", req)
227+ default:
228+ }
229+}
230+
140 func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) {231 func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) {
141 client := &compactQueueClient{232 client := &compactQueueClient{
142 compactionStarted: make(chan struct{}),233 compactionStarted: make(chan struct{}),
A packages/agent/modes/goal.go +262 -0
@@ -0,0 +1,262 @@
1+package modes
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ agenttools "github.com/bnema/zut/packages/agent/tools"
9+ "github.com/bnema/zut/packages/core"
10+ "github.com/bnema/zut/packages/provider"
11+)
12+
13+const goalContinueMetaKey = "zut_goal_continue"
14+
15+func (i *Interactive) goalUpdatesAvailable() bool {
16+ ag := i.Agent()
17+ if ag == nil {
18+ return false
19+ }
20+ _, ok := ag.ToolsSnapshot()[agenttools.UpdateGoalToolName]
21+ return ok
22+}
23+
24+func (i *Interactive) goalContinuationMessage() (provider.Message, bool) {
25+ if i.cfg.CurrentGoal == nil || !i.goalUpdatesAvailable() {
26+ return provider.Message{}, false
27+ }
28+ goal := i.cfg.CurrentGoal()
29+ if goal == nil || goal.Status != core.GoalActive || strings.TrimSpace(goal.Objective) == "" {
30+ return provider.Message{}, false
31+ }
32+ text := goal.Objective +
33+ "\n\nContinue working autonomously toward this active goal. Do not stop at a progress update. Continue until the goal is complete or blocked, then call update_goal."
34+ return provider.Message{
35+ Role: provider.RoleUser,
36+ Content: []provider.Content{provider.TextBlock{Text: text}},
37+ Meta: map[string]string{goalContinueMetaKey: "true"},
38+ }, true
39+}
40+
41+func (i *Interactive) goalIdleLocked() bool {
42+ return !i.busy && i.agent != nil && len(i.queued) == 0 && i.agent.QueuedMessageCount() == 0
43+}
44+
45+func (i *Interactive) goalContinuationIfIdle() (provider.Message, bool) {
46+ message, ok := i.goalContinuationMessage()
47+ if !ok {
48+ return provider.Message{}, false
49+ }
50+ i.mu.Lock()
51+ defer i.mu.Unlock()
52+ if !i.goalIdleLocked() {
53+ return provider.Message{}, false
54+ }
55+ return message, true
56+}
57+
58+func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool {
59+ message, ok := i.goalContinuationMessage()
60+ if !ok {
61+ return false
62+ }
63+ i.mu.Lock()
64+ if !i.goalIdleLocked() {
65+ i.mu.Unlock()
66+ return false
67+ }
68+ i.busy = true
69+ i.mu.Unlock()
70+ i.startGoalContinuation(parent, message)
71+ return true
72+}
73+
74+func (i *Interactive) startGoalContinuation(parent context.Context, message provider.Message) {
75+ goalMessage, goalActive := i.goalContinuationMessage()
76+ i.mu.Lock()
77+ ag := i.agent
78+ if ag == nil {
79+ i.busy = false
80+ pendingIdleWork := i.takePendingIdleWorkLocked()
81+ i.mu.Unlock()
82+ runPendingIdleWork(pendingIdleWork)
83+ i.invalidate()
84+ return
85+ }
86+ if len(i.queued) > 0 {
87+ next := i.queued[0]
88+ i.queued = i.queued[1:]
89+ i.mu.Unlock()
90+ i.startTurn(parent, next)
91+ return
92+ }
93+ if ag.QueuedMessageCount() > 0 {
94+ i.mu.Unlock()
95+ i.startTurnRequest(parent, "", nil, true, false)
96+ return
97+ }
98+ if !goalActive || userMessageText(goalMessage) != userMessageText(message) {
99+ i.busy = false
100+ pendingIdleWork := i.takePendingIdleWorkLocked()
101+ i.mu.Unlock()
102+ runPendingIdleWork(pendingIdleWork)
103+ i.invalidate()
104+ return
105+ }
106+ i.mu.Unlock()
107+ ag.AppendUserContext(userMessageText(message), message.Meta)
108+
109+ // Persistence callbacks run synchronously from AppendUserContext, so never
110+ // hold the TUI mutex across it. Re-check user work that may have arrived
111+ // while the hidden continuation was being persisted.
112+ i.mu.Lock()
113+ if len(i.queued) > 0 {
114+ next := i.queued[0]
115+ i.queued = i.queued[1:]
116+ i.mu.Unlock()
117+ i.startTurn(parent, next)
118+ return
119+ }
120+ i.mu.Unlock()
121+ i.startTurnRequest(parent, "", nil, true, false)
122+}
123+
124+func copySessionGoal(goal *core.SessionGoal) *core.SessionGoal {
125+ if goal == nil {
126+ return nil
127+ }
128+ copyGoal := *goal
129+ return &copyGoal
130+}
131+
132+func (i *Interactive) updateActiveGoal(status core.GoalStatus, reason string) {
133+ if i.cfg.CurrentGoal == nil || i.cfg.PersistGoal == nil {
134+ return
135+ }
136+ goal := copySessionGoal(i.cfg.CurrentGoal())
137+ if goal == nil || goal.Status != core.GoalActive {
138+ return
139+ }
140+ goal.Status = status
141+ goal.Reason = reason
142+ if err := i.cfg.PersistGoal(goal); err != nil {
143+ i.ReportError(fmt.Errorf("persist goal: %w", err))
144+ return
145+ }
146+ i.setGoalStatus(goal)
147+}
148+
149+func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []string) {
150+ if i.cfg.CurrentGoal == nil || i.cfg.PersistGoal == nil {
151+ i.setGoalCommandError("goal: session persistence is unavailable")
152+ return
153+ }
154+
155+ current := copySessionGoal(i.cfg.CurrentGoal())
156+ if len(parts) == 1 {
157+ if current == nil {
158+ i.setGoalCommandStatus("no autonomous goal")
159+ return
160+ }
161+ status := fmt.Sprintf("goal %s: %s", current.Status, current.Objective)
162+ if current.Reason != "" {
163+ status += " (" + current.Reason + ")"
164+ }
165+ i.setGoalCommandStatus(status)
166+ return
167+ }
168+
169+ startIfIdle := false
170+ startTitle := false
171+ arg := ""
172+ if len(parts) == 2 {
173+ arg = strings.ToLower(parts[1])
174+ }
175+ switch arg {
176+ case "pause":
177+ if current == nil {
178+ i.setGoalCommandError("goal: no goal to pause")
179+ return
180+ }
181+ current.Status = core.GoalPaused
182+ current.Reason = ""
183+ case "resume":
184+ if current == nil {
185+ i.setGoalCommandError("goal: no goal to resume")
186+ return
187+ }
188+ current.Status = core.GoalActive
189+ current.Reason = ""
190+ startIfIdle = true
191+ case "clear":
192+ if err := i.cfg.PersistGoal(nil); err != nil {
193+ i.setGoalCommandError("goal: " + err.Error())
194+ return
195+ }
196+ i.setGoalStatus(nil)
197+ i.setGoalCommandStatus("autonomous goal cleared")
198+ return
199+ default:
200+ objective := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(cmd), parts[0]))
201+ if objective == "" {
202+ i.setGoalCommandError("usage: /goal <objective>|pause|resume|clear")
203+ return
204+ }
205+ current = &core.SessionGoal{Objective: objective, Status: core.GoalActive}
206+ startIfIdle = true
207+ startTitle = true
208+ }
209+
210+ if startIfIdle && !i.goalUpdatesAvailable() {
211+ i.setGoalCommandError("goal: update_goal is unavailable; enable the default tools")
212+ return
213+ }
214+ if err := i.cfg.PersistGoal(current); err != nil {
215+ i.setGoalCommandError("goal: " + err.Error())
216+ return
217+ }
218+ i.setGoalStatus(current)
219+ i.setGoalCommandStatus(fmt.Sprintf("autonomous goal %s: %s", current.Status, current.Objective))
220+ if startTitle {
221+ i.maybeStartSessionTitle(ctx, current.Objective)
222+ }
223+ if startIfIdle {
224+ i.requestGoalContinuationIfIdle(ctx)
225+ }
226+}
227+
228+// RefreshGoal reloads the status badge after the CLI commits a session switch.
229+func (i *Interactive) RefreshGoal() {
230+ if i.cfg.CurrentGoal == nil {
231+ i.setGoalStatus(nil)
232+ return
233+ }
234+ i.setGoalStatus(i.cfg.CurrentGoal())
235+}
236+
237+func (i *Interactive) setGoalStatus(goal *core.SessionGoal) {
238+ status := core.GoalStatus("")
239+ if goal != nil {
240+ status = goal.Status
241+ }
242+ i.mu.Lock()
243+ i.goalStatus = status
244+ i.mu.Unlock()
245+ i.invalidate()
246+}
247+
248+func (i *Interactive) setGoalCommandStatus(message string) {
249+ i.mu.Lock()
250+ i.statusOK = message
251+ i.statusErr = ""
252+ i.mu.Unlock()
253+ i.invalidate()
254+}
255+
256+func (i *Interactive) setGoalCommandError(message string) {
257+ i.mu.Lock()
258+ i.statusOK = ""
259+ i.statusErr = message
260+ i.mu.Unlock()
261+ i.invalidate()
262+}
@@ -0,0 +1,262 @@
1+package modes
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ agenttools "github.com/bnema/zut/packages/agent/tools"
9+ "github.com/bnema/zut/packages/core"
10+ "github.com/bnema/zut/packages/provider"
11+)
12+
13+const goalContinueMetaKey = "zut_goal_continue"
14+
15+func (i *Interactive) goalUpdatesAvailable() bool {
16+ ag := i.Agent()
17+ if ag == nil {
18+ return false
19+ }
20+ _, ok := ag.ToolsSnapshot()[agenttools.UpdateGoalToolName]
21+ return ok
22+}
23+
24+func (i *Interactive) goalContinuationMessage() (provider.Message, bool) {
25+ if i.cfg.CurrentGoal == nil || !i.goalUpdatesAvailable() {
26+ return provider.Message{}, false
27+ }
28+ goal := i.cfg.CurrentGoal()
29+ if goal == nil || goal.Status != core.GoalActive || strings.TrimSpace(goal.Objective) == "" {
30+ return provider.Message{}, false
31+ }
32+ text := goal.Objective +
33+ "\n\nContinue working autonomously toward this active goal. Do not stop at a progress update. Continue until the goal is complete or blocked, then call update_goal."
34+ return provider.Message{
35+ Role: provider.RoleUser,
36+ Content: []provider.Content{provider.TextBlock{Text: text}},
37+ Meta: map[string]string{goalContinueMetaKey: "true"},
38+ }, true
39+}
40+
41+func (i *Interactive) goalIdleLocked() bool {
42+ return !i.busy && i.agent != nil && len(i.queued) == 0 && i.agent.QueuedMessageCount() == 0
43+}
44+
45+func (i *Interactive) goalContinuationIfIdle() (provider.Message, bool) {
46+ message, ok := i.goalContinuationMessage()
47+ if !ok {
48+ return provider.Message{}, false
49+ }
50+ i.mu.Lock()
51+ defer i.mu.Unlock()
52+ if !i.goalIdleLocked() {
53+ return provider.Message{}, false
54+ }
55+ return message, true
56+}
57+
58+func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool {
59+ message, ok := i.goalContinuationMessage()
60+ if !ok {
61+ return false
62+ }
63+ i.mu.Lock()
64+ if !i.goalIdleLocked() {
65+ i.mu.Unlock()
66+ return false
67+ }
68+ i.busy = true
69+ i.mu.Unlock()
70+ i.startGoalContinuation(parent, message)
71+ return true
72+}
73+
74+func (i *Interactive) startGoalContinuation(parent context.Context, message provider.Message) {
75+ goalMessage, goalActive := i.goalContinuationMessage()
76+ i.mu.Lock()
77+ ag := i.agent
78+ if ag == nil {
79+ i.busy = false
80+ pendingIdleWork := i.takePendingIdleWorkLocked()
81+ i.mu.Unlock()
82+ runPendingIdleWork(pendingIdleWork)
83+ i.invalidate()
84+ return
85+ }
86+ if len(i.queued) > 0 {
87+ next := i.queued[0]
88+ i.queued = i.queued[1:]
89+ i.mu.Unlock()
90+ i.startTurn(parent, next)
91+ return
92+ }
93+ if ag.QueuedMessageCount() > 0 {
94+ i.mu.Unlock()
95+ i.startTurnRequest(parent, "", nil, true, false)
96+ return
97+ }
98+ if !goalActive || userMessageText(goalMessage) != userMessageText(message) {
99+ i.busy = false
100+ pendingIdleWork := i.takePendingIdleWorkLocked()
101+ i.mu.Unlock()
102+ runPendingIdleWork(pendingIdleWork)
103+ i.invalidate()
104+ return
105+ }
106+ i.mu.Unlock()
107+ ag.AppendUserContext(userMessageText(message), message.Meta)
108+
109+ // Persistence callbacks run synchronously from AppendUserContext, so never
110+ // hold the TUI mutex across it. Re-check user work that may have arrived
111+ // while the hidden continuation was being persisted.
112+ i.mu.Lock()
113+ if len(i.queued) > 0 {
114+ next := i.queued[0]
115+ i.queued = i.queued[1:]
116+ i.mu.Unlock()
117+ i.startTurn(parent, next)
118+ return
119+ }
120+ i.mu.Unlock()
121+ i.startTurnRequest(parent, "", nil, true, false)
122+}
123+
124+func copySessionGoal(goal *core.SessionGoal) *core.SessionGoal {
125+ if goal == nil {
126+ return nil
127+ }
128+ copyGoal := *goal
129+ return &copyGoal
130+}
131+
132+func (i *Interactive) updateActiveGoal(status core.GoalStatus, reason string) {
133+ if i.cfg.CurrentGoal == nil || i.cfg.PersistGoal == nil {
134+ return
135+ }
136+ goal := copySessionGoal(i.cfg.CurrentGoal())
137+ if goal == nil || goal.Status != core.GoalActive {
138+ return
139+ }
140+ goal.Status = status
141+ goal.Reason = reason
142+ if err := i.cfg.PersistGoal(goal); err != nil {
143+ i.ReportError(fmt.Errorf("persist goal: %w", err))
144+ return
145+ }
146+ i.setGoalStatus(goal)
147+}
148+
149+func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []string) {
150+ if i.cfg.CurrentGoal == nil || i.cfg.PersistGoal == nil {
151+ i.setGoalCommandError("goal: session persistence is unavailable")
152+ return
153+ }
154+
155+ current := copySessionGoal(i.cfg.CurrentGoal())
156+ if len(parts) == 1 {
157+ if current == nil {
158+ i.setGoalCommandStatus("no autonomous goal")
159+ return
160+ }
161+ status := fmt.Sprintf("goal %s: %s", current.Status, current.Objective)
162+ if current.Reason != "" {
163+ status += " (" + current.Reason + ")"
164+ }
165+ i.setGoalCommandStatus(status)
166+ return
167+ }
168+
169+ startIfIdle := false
170+ startTitle := false
171+ arg := ""
172+ if len(parts) == 2 {
173+ arg = strings.ToLower(parts[1])
174+ }
175+ switch arg {
176+ case "pause":
177+ if current == nil {
178+ i.setGoalCommandError("goal: no goal to pause")
179+ return
180+ }
181+ current.Status = core.GoalPaused
182+ current.Reason = ""
183+ case "resume":
184+ if current == nil {
185+ i.setGoalCommandError("goal: no goal to resume")
186+ return
187+ }
188+ current.Status = core.GoalActive
189+ current.Reason = ""
190+ startIfIdle = true
191+ case "clear":
192+ if err := i.cfg.PersistGoal(nil); err != nil {
193+ i.setGoalCommandError("goal: " + err.Error())
194+ return
195+ }
196+ i.setGoalStatus(nil)
197+ i.setGoalCommandStatus("autonomous goal cleared")
198+ return
199+ default:
200+ objective := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(cmd), parts[0]))
201+ if objective == "" {
202+ i.setGoalCommandError("usage: /goal <objective>|pause|resume|clear")
203+ return
204+ }
205+ current = &core.SessionGoal{Objective: objective, Status: core.GoalActive}
206+ startIfIdle = true
207+ startTitle = true
208+ }
209+
210+ if startIfIdle && !i.goalUpdatesAvailable() {
211+ i.setGoalCommandError("goal: update_goal is unavailable; enable the default tools")
212+ return
213+ }
214+ if err := i.cfg.PersistGoal(current); err != nil {
215+ i.setGoalCommandError("goal: " + err.Error())
216+ return
217+ }
218+ i.setGoalStatus(current)
219+ i.setGoalCommandStatus(fmt.Sprintf("autonomous goal %s: %s", current.Status, current.Objective))
220+ if startTitle {
221+ i.maybeStartSessionTitle(ctx, current.Objective)
222+ }
223+ if startIfIdle {
224+ i.requestGoalContinuationIfIdle(ctx)
225+ }
226+}
227+
228+// RefreshGoal reloads the status badge after the CLI commits a session switch.
229+func (i *Interactive) RefreshGoal() {
230+ if i.cfg.CurrentGoal == nil {
231+ i.setGoalStatus(nil)
232+ return
233+ }
234+ i.setGoalStatus(i.cfg.CurrentGoal())
235+}
236+
237+func (i *Interactive) setGoalStatus(goal *core.SessionGoal) {
238+ status := core.GoalStatus("")
239+ if goal != nil {
240+ status = goal.Status
241+ }
242+ i.mu.Lock()
243+ i.goalStatus = status
244+ i.mu.Unlock()
245+ i.invalidate()
246+}
247+
248+func (i *Interactive) setGoalCommandStatus(message string) {
249+ i.mu.Lock()
250+ i.statusOK = message
251+ i.statusErr = ""
252+ i.mu.Unlock()
253+ i.invalidate()
254+}
255+
256+func (i *Interactive) setGoalCommandError(message string) {
257+ i.mu.Lock()
258+ i.statusOK = ""
259+ i.statusErr = message
260+ i.mu.Unlock()
261+ i.invalidate()
262+}
A packages/agent/modes/goal_test.go +231 -0
@@ -0,0 +1,231 @@
1+package modes
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ agenttools "github.com/bnema/zut/packages/agent/tools"
12+ "github.com/bnema/zut/packages/core"
13+ "github.com/bnema/zut/packages/provider"
14+)
15+
16+func goalToolRegistry() core.Registry {
17+ return core.Registry{agenttools.UpdateGoalToolName: &agenttools.UpdateGoalTool{}}
18+}
19+
20+type goalContinuationClient struct {
21+ mu sync.Mutex
22+ requests []provider.Request
23+ onSecond func()
24+}
25+
26+func (c *goalContinuationClient) Name() string { return "goal-continuation" }
27+
28+func (c *goalContinuationClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
29+ c.mu.Lock()
30+ c.requests = append(c.requests, req)
31+ call := len(c.requests)
32+ onSecond := c.onSecond
33+ c.mu.Unlock()
34+ if call == 2 && onSecond != nil {
35+ onSecond()
36+ }
37+ out := make(chan provider.Event, 1)
38+ out <- provider.EventDone{
39+ Stop: provider.StopEnd,
40+ Message: provider.Message{
41+ Role: provider.RoleAssistant,
42+ Content: []provider.Content{provider.TextBlock{Text: "working"}},
43+ },
44+ }
45+ close(out)
46+ return out, nil
47+}
48+
49+func TestActiveGoalStartsAnotherTurnWhenThreadBecomesIdle(t *testing.T) {
50+ var mu sync.Mutex
51+ var goal *core.SessionGoal
52+ currentGoal := func() *core.SessionGoal {
53+ mu.Lock()
54+ defer mu.Unlock()
55+ return cloneSessionGoal(goal)
56+ }
57+ persistGoal := func(next *core.SessionGoal) error {
58+ mu.Lock()
59+ goal = cloneSessionGoal(next)
60+ mu.Unlock()
61+ return nil
62+ }
63+ client := &goalContinuationClient{onSecond: func() {
64+ _ = persistGoal(&core.SessionGoal{Objective: "finish issue 97", Status: core.GoalPaused})
65+ }}
66+ turnFlushed := make(chan struct{}, 2)
67+ interactive := NewInteractive(InteractiveConfig{
68+ Agent: core.NewAgent(client, "model", "system", goalToolRegistry()),
69+ CurrentGoal: currentGoal,
70+ PersistGoal: persistGoal,
71+ FlushSession: func() {
72+ turnFlushed <- struct{}{}
73+ },
74+ })
75+ interactive.runCtx = context.Background()
76+ interactive.runSlash(context.Background(), "/goal finish issue 97")
77+ for turn := 1; turn <= 2; turn++ {
78+ select {
79+ case <-turnFlushed:
80+ case <-time.After(2 * time.Second):
81+ t.Fatalf("turn %d did not finish", turn)
82+ }
83+ }
84+
85+ client.mu.Lock()
86+ defer client.mu.Unlock()
87+ if len(client.requests) != 2 {
88+ t.Fatalf("provider requests = %d, want 2", len(client.requests))
89+ }
90+ messages := client.requests[1].Messages
91+ if len(messages) == 0 || messages[len(messages)-1].Meta[goalContinueMetaKey] != "true" {
92+ t.Fatalf("continuation request tail = %#v", messages)
93+ }
94+}
95+
96+func TestGoalSlashCommandStartsAndControlsGoal(t *testing.T) {
97+ var goal *core.SessionGoal
98+ interactive := NewInteractive(InteractiveConfig{
99+ Agent: core.NewAgent(nil, "model", "system", goalToolRegistry()),
100+ CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) },
101+ PersistGoal: func(next *core.SessionGoal) error {
102+ goal = cloneSessionGoal(next)
103+ return nil
104+ },
105+ })
106+ interactive.busy = true // exercise controls without launching a provider turn
107+
108+ interactive.runSlash(context.Background(), "/goal finish issue 97")
109+ if goal == nil || goal.Status != core.GoalActive || goal.Objective != "finish issue 97" {
110+ t.Fatalf("started goal = %#v", goal)
111+ }
112+ if interactive.goalStatus != core.GoalActive {
113+ t.Fatalf("started badge = %q", interactive.goalStatus)
114+ }
115+
116+ interactive.runSlash(context.Background(), "/goal pause")
117+ if goal.Status != core.GoalPaused {
118+ t.Fatalf("paused goal = %#v", goal)
119+ }
120+ if interactive.goalStatus != core.GoalPaused {
121+ t.Fatalf("paused badge = %q", interactive.goalStatus)
122+ }
123+ interactive.runSlash(context.Background(), "/goal resume")
124+ if goal.Status != core.GoalActive {
125+ t.Fatalf("resumed goal = %#v", goal)
126+ }
127+ if interactive.goalStatus != core.GoalActive {
128+ t.Fatalf("resumed badge = %q", interactive.goalStatus)
129+ }
130+ interactive.runSlash(context.Background(), "/goal clear")
131+ if goal != nil {
132+ t.Fatalf("cleared goal = %#v", goal)
133+ }
134+ if interactive.goalStatus != "" {
135+ t.Fatalf("cleared badge = %q", interactive.goalStatus)
136+ }
137+ interactive.runSlash(context.Background(), "/goal pause deployment safely")
138+ if goal == nil || goal.Status != core.GoalActive || goal.Objective != "pause deployment safely" {
139+ t.Fatalf("multiword control-like objective = %#v", goal)
140+ }
141+ goal.Status = core.GoalBlocked
142+ goal.Reason = "waiting for input"
143+ interactive.runSlash(context.Background(), "/goal")
144+ if !strings.Contains(interactive.statusOK, "waiting for input") {
145+ t.Fatalf("goal status = %q", interactive.statusOK)
146+ }
147+}
148+
149+func TestGoalControlRequiresSessionPersistence(t *testing.T) {
150+ interactive := NewInteractive(InteractiveConfig{})
151+ interactive.runSlash(context.Background(), "/goal pause")
152+ if !strings.Contains(interactive.statusErr, "session persistence is unavailable") {
153+ t.Fatalf("status error = %q", interactive.statusErr)
154+ }
155+}
156+
157+func TestGoalCommandDoesNotMutateLiveStateWhenPersistenceFails(t *testing.T) {
158+ goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive}
159+ interactive := NewInteractive(InteractiveConfig{
160+ Agent: core.NewAgent(nil, "model", "system", goalToolRegistry()),
161+ CurrentGoal: func() *core.SessionGoal { return goal },
162+ PersistGoal: func(*core.SessionGoal) error { return errors.New("disk full") },
163+ })
164+ interactive.busy = true
165+
166+ interactive.runSlash(context.Background(), "/goal pause")
167+ if goal.Status != core.GoalActive {
168+ t.Fatalf("live goal status = %q, want active", goal.Status)
169+ }
170+ if interactive.goalStatus != core.GoalActive {
171+ t.Fatalf("goal badge = %q, want active", interactive.goalStatus)
172+ }
173+}
174+
175+func TestGoalSlashCommandRequiresUpdateGoalTool(t *testing.T) {
176+ var persisted bool
177+ interactive := NewInteractive(InteractiveConfig{
178+ Agent: core.NewAgent(nil, "model", "system", nil),
179+ CurrentGoal: func() *core.SessionGoal { return nil },
180+ PersistGoal: func(*core.SessionGoal) error {
181+ persisted = true
182+ return nil
183+ },
184+ })
185+
186+ interactive.runSlash(context.Background(), "/goal finish issue 97")
187+ if persisted {
188+ t.Fatal("goal was persisted without the update_goal tool")
189+ }
190+ if !strings.Contains(interactive.statusErr, "update_goal is unavailable") {
191+ t.Fatalf("status error = %q", interactive.statusErr)
192+ }
193+}
194+
195+func TestGoalContinuationRequiresActiveIdleGoalAndYieldsToQueuedUser(t *testing.T) {
196+ goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive}
197+ agent := core.NewAgent(nil, "model", "system", goalToolRegistry())
198+ interactive := &Interactive{
199+ agent: agent,
200+ cfg: InteractiveConfig{CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) }},
201+ }
202+
203+ message, ok := interactive.goalContinuationIfIdle()
204+ if !ok {
205+ t.Fatal("active idle goal did not request continuation")
206+ }
207+ if message.Meta[goalContinueMetaKey] != "true" || message.Role != provider.RoleUser {
208+ t.Fatalf("continuation message = %#v", message)
209+ }
210+ if !isHiddenTranscriptMessage(message) {
211+ t.Fatal("goal continuation was rendered as a user message")
212+ }
213+
214+ agent.QueueMessage("user follow-up")
215+ if _, ok := interactive.goalContinuationIfIdle(); ok {
216+ t.Fatal("goal continuation bypassed queued user work")
217+ }
218+ agent.DrainQueuedMessages()
219+ goal.Status = core.GoalPaused
220+ if _, ok := interactive.goalContinuationIfIdle(); ok {
221+ t.Fatal("paused goal requested continuation")
222+ }
223+}
224+
225+func cloneSessionGoal(goal *core.SessionGoal) *core.SessionGoal {
226+ if goal == nil {
227+ return nil
228+ }
229+ clone := *goal
230+ return &clone
231+}
@@ -0,0 +1,231 @@
1+package modes
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ agenttools "github.com/bnema/zut/packages/agent/tools"
12+ "github.com/bnema/zut/packages/core"
13+ "github.com/bnema/zut/packages/provider"
14+)
15+
16+func goalToolRegistry() core.Registry {
17+ return core.Registry{agenttools.UpdateGoalToolName: &agenttools.UpdateGoalTool{}}
18+}
19+
20+type goalContinuationClient struct {
21+ mu sync.Mutex
22+ requests []provider.Request
23+ onSecond func()
24+}
25+
26+func (c *goalContinuationClient) Name() string { return "goal-continuation" }
27+
28+func (c *goalContinuationClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
29+ c.mu.Lock()
30+ c.requests = append(c.requests, req)
31+ call := len(c.requests)
32+ onSecond := c.onSecond
33+ c.mu.Unlock()
34+ if call == 2 && onSecond != nil {
35+ onSecond()
36+ }
37+ out := make(chan provider.Event, 1)
38+ out <- provider.EventDone{
39+ Stop: provider.StopEnd,
40+ Message: provider.Message{
41+ Role: provider.RoleAssistant,
42+ Content: []provider.Content{provider.TextBlock{Text: "working"}},
43+ },
44+ }
45+ close(out)
46+ return out, nil
47+}
48+
49+func TestActiveGoalStartsAnotherTurnWhenThreadBecomesIdle(t *testing.T) {
50+ var mu sync.Mutex
51+ var goal *core.SessionGoal
52+ currentGoal := func() *core.SessionGoal {
53+ mu.Lock()
54+ defer mu.Unlock()
55+ return cloneSessionGoal(goal)
56+ }
57+ persistGoal := func(next *core.SessionGoal) error {
58+ mu.Lock()
59+ goal = cloneSessionGoal(next)
60+ mu.Unlock()
61+ return nil
62+ }
63+ client := &goalContinuationClient{onSecond: func() {
64+ _ = persistGoal(&core.SessionGoal{Objective: "finish issue 97", Status: core.GoalPaused})
65+ }}
66+ turnFlushed := make(chan struct{}, 2)
67+ interactive := NewInteractive(InteractiveConfig{
68+ Agent: core.NewAgent(client, "model", "system", goalToolRegistry()),
69+ CurrentGoal: currentGoal,
70+ PersistGoal: persistGoal,
71+ FlushSession: func() {
72+ turnFlushed <- struct{}{}
73+ },
74+ })
75+ interactive.runCtx = context.Background()
76+ interactive.runSlash(context.Background(), "/goal finish issue 97")
77+ for turn := 1; turn <= 2; turn++ {
78+ select {
79+ case <-turnFlushed:
80+ case <-time.After(2 * time.Second):
81+ t.Fatalf("turn %d did not finish", turn)
82+ }
83+ }
84+
85+ client.mu.Lock()
86+ defer client.mu.Unlock()
87+ if len(client.requests) != 2 {
88+ t.Fatalf("provider requests = %d, want 2", len(client.requests))
89+ }
90+ messages := client.requests[1].Messages
91+ if len(messages) == 0 || messages[len(messages)-1].Meta[goalContinueMetaKey] != "true" {
92+ t.Fatalf("continuation request tail = %#v", messages)
93+ }
94+}
95+
96+func TestGoalSlashCommandStartsAndControlsGoal(t *testing.T) {
97+ var goal *core.SessionGoal
98+ interactive := NewInteractive(InteractiveConfig{
99+ Agent: core.NewAgent(nil, "model", "system", goalToolRegistry()),
100+ CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) },
101+ PersistGoal: func(next *core.SessionGoal) error {
102+ goal = cloneSessionGoal(next)
103+ return nil
104+ },
105+ })
106+ interactive.busy = true // exercise controls without launching a provider turn
107+
108+ interactive.runSlash(context.Background(), "/goal finish issue 97")
109+ if goal == nil || goal.Status != core.GoalActive || goal.Objective != "finish issue 97" {
110+ t.Fatalf("started goal = %#v", goal)
111+ }
112+ if interactive.goalStatus != core.GoalActive {
113+ t.Fatalf("started badge = %q", interactive.goalStatus)
114+ }
115+
116+ interactive.runSlash(context.Background(), "/goal pause")
117+ if goal.Status != core.GoalPaused {
118+ t.Fatalf("paused goal = %#v", goal)
119+ }
120+ if interactive.goalStatus != core.GoalPaused {
121+ t.Fatalf("paused badge = %q", interactive.goalStatus)
122+ }
123+ interactive.runSlash(context.Background(), "/goal resume")
124+ if goal.Status != core.GoalActive {
125+ t.Fatalf("resumed goal = %#v", goal)
126+ }
127+ if interactive.goalStatus != core.GoalActive {
128+ t.Fatalf("resumed badge = %q", interactive.goalStatus)
129+ }
130+ interactive.runSlash(context.Background(), "/goal clear")
131+ if goal != nil {
132+ t.Fatalf("cleared goal = %#v", goal)
133+ }
134+ if interactive.goalStatus != "" {
135+ t.Fatalf("cleared badge = %q", interactive.goalStatus)
136+ }
137+ interactive.runSlash(context.Background(), "/goal pause deployment safely")
138+ if goal == nil || goal.Status != core.GoalActive || goal.Objective != "pause deployment safely" {
139+ t.Fatalf("multiword control-like objective = %#v", goal)
140+ }
141+ goal.Status = core.GoalBlocked
142+ goal.Reason = "waiting for input"
143+ interactive.runSlash(context.Background(), "/goal")
144+ if !strings.Contains(interactive.statusOK, "waiting for input") {
145+ t.Fatalf("goal status = %q", interactive.statusOK)
146+ }
147+}
148+
149+func TestGoalControlRequiresSessionPersistence(t *testing.T) {
150+ interactive := NewInteractive(InteractiveConfig{})
151+ interactive.runSlash(context.Background(), "/goal pause")
152+ if !strings.Contains(interactive.statusErr, "session persistence is unavailable") {
153+ t.Fatalf("status error = %q", interactive.statusErr)
154+ }
155+}
156+
157+func TestGoalCommandDoesNotMutateLiveStateWhenPersistenceFails(t *testing.T) {
158+ goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive}
159+ interactive := NewInteractive(InteractiveConfig{
160+ Agent: core.NewAgent(nil, "model", "system", goalToolRegistry()),
161+ CurrentGoal: func() *core.SessionGoal { return goal },
162+ PersistGoal: func(*core.SessionGoal) error { return errors.New("disk full") },
163+ })
164+ interactive.busy = true
165+
166+ interactive.runSlash(context.Background(), "/goal pause")
167+ if goal.Status != core.GoalActive {
168+ t.Fatalf("live goal status = %q, want active", goal.Status)
169+ }
170+ if interactive.goalStatus != core.GoalActive {
171+ t.Fatalf("goal badge = %q, want active", interactive.goalStatus)
172+ }
173+}
174+
175+func TestGoalSlashCommandRequiresUpdateGoalTool(t *testing.T) {
176+ var persisted bool
177+ interactive := NewInteractive(InteractiveConfig{
178+ Agent: core.NewAgent(nil, "model", "system", nil),
179+ CurrentGoal: func() *core.SessionGoal { return nil },
180+ PersistGoal: func(*core.SessionGoal) error {
181+ persisted = true
182+ return nil
183+ },
184+ })
185+
186+ interactive.runSlash(context.Background(), "/goal finish issue 97")
187+ if persisted {
188+ t.Fatal("goal was persisted without the update_goal tool")
189+ }
190+ if !strings.Contains(interactive.statusErr, "update_goal is unavailable") {
191+ t.Fatalf("status error = %q", interactive.statusErr)
192+ }
193+}
194+
195+func TestGoalContinuationRequiresActiveIdleGoalAndYieldsToQueuedUser(t *testing.T) {
196+ goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive}
197+ agent := core.NewAgent(nil, "model", "system", goalToolRegistry())
198+ interactive := &Interactive{
199+ agent: agent,
200+ cfg: InteractiveConfig{CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) }},
201+ }
202+
203+ message, ok := interactive.goalContinuationIfIdle()
204+ if !ok {
205+ t.Fatal("active idle goal did not request continuation")
206+ }
207+ if message.Meta[goalContinueMetaKey] != "true" || message.Role != provider.RoleUser {
208+ t.Fatalf("continuation message = %#v", message)
209+ }
210+ if !isHiddenTranscriptMessage(message) {
211+ t.Fatal("goal continuation was rendered as a user message")
212+ }
213+
214+ agent.QueueMessage("user follow-up")
215+ if _, ok := interactive.goalContinuationIfIdle(); ok {
216+ t.Fatal("goal continuation bypassed queued user work")
217+ }
218+ agent.DrainQueuedMessages()
219+ goal.Status = core.GoalPaused
220+ if _, ok := interactive.goalContinuationIfIdle(); ok {
221+ t.Fatal("paused goal requested continuation")
222+ }
223+}
224+
225+func cloneSessionGoal(goal *core.SessionGoal) *core.SessionGoal {
226+ if goal == nil {
227+ return nil
228+ }
229+ clone := *goal
230+ return &clone
231+}
M packages/agent/modes/interactive.go +43 -5
@@ -372,6 +372,13 @@ type InteractiveConfig struct {
372372 // after a manual rename has already been written to disk.
373373 OnSessionTitleChanged func(title string)
374374
375+ // CurrentGoal and PersistGoal expose the autonomous goal attached to the
376+ // current session. CurrentGoal must return a copy, not the live session
377+ // pointer, so failed persistence cannot mutate in-memory state. The callbacks
378+ // follow session switches without coupling the TUI to the session file owner.
379+ CurrentGoal func() *core.SessionGoal
380+ PersistGoal func(goal *core.SessionGoal) error
381+
375382 OnAssistant func(m provider.Message)
376383 OnToolResult func(id string, r core.ToolResult)
377384
@@ -661,6 +668,7 @@ type Interactive struct {
661668 toolGate map[string]int
662669 statusErr string
663670 statusOK string
671+ goalStatus core.GoalStatus
664672 reloadStatusSeq uint64
665673 extStatuses map[string]map[string]extensionStatus
666674 extWidgets map[string]map[string]extensionWidget
@@ -916,6 +924,12 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
916924 startupContextPaths := []string(nil)
917925 startupExtensionNames := []string(nil)
918926 startupSkillNames := []string(nil)
927+ initialGoalStatus := core.GoalStatus("")
928+ if cfg.CurrentGoal != nil {
929+ if goal := cfg.CurrentGoal(); goal != nil {
930+ initialGoalStatus = goal.Status
931+ }
932+ }
919933 if cfg.ShowInstructionsAtStartup != nil && *cfg.ShowInstructionsAtStartup {
920934 startupAgentName = cfg.StartupAgentName
921935 startupContextPaths = append(startupContextPaths, cfg.StartupContextPaths...)
@@ -969,6 +983,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
969983 spin: newSpinner(cfg.Theme),
970984 inputHistoryIndex: -1,
971985 clock: time.Now,
986+ goalStatus: initialGoalStatus,
972987 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
973988 compactContinuation: decodeCompactHandoff(cfg.InitialCompactHandoff),
974989 }
@@ -2066,6 +2081,7 @@ func (i *Interactive) redraw() {
20662081 if workingWithStatus {
20672082 statusBusyPrefix = busyPrefix
20682083 }
2084+ goalStatus := string(i.goalStatus)
20692085 statusLines := tui.StatusBar(tui.StatusBarParams{
20702086 Theme: i.cfg.Theme,
20712087 Provider: i.cfg.Provider,
@@ -2077,6 +2093,7 @@ func (i *Interactive) redraw() {
20772093 CWD: i.cfg.CWD,
20782094 Locked: i.cfg.Sandbox.Locked(),
20792095 NoYolo: i.cfg.NoYolo,
2096+ GoalStatus: goalStatus,
20802097 Usage: i.cumUsage,
20812098 Subscription: i.cfg.AuthMethod == "oauth",
20822099 ContextUsed: i.lastCtxInput,
@@ -2544,7 +2561,7 @@ func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message
25442561 }
25452562
25462563 func isHiddenTranscriptMessage(m provider.Message) bool {
2547- if m.Meta[autoCompactContinueMetaKey] == "true" {
2564+ if m.Meta[autoCompactContinueMetaKey] == "true" || m.Meta[goalContinueMetaKey] == "true" {
25482565 return true
25492566 }
25502567 if m.Role != provider.RoleUser || len(m.Content) == 0 {
@@ -3290,6 +3307,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
32903307 }
32913308 i.mu.Unlock()
32923309 if busyCancel {
3310+ i.updateActiveGoal(core.GoalPaused, "interrupted by user")
32933311 cancelTurn()
32943312 // If a confirm dialog is pending, refuse it so the agent
32953313 // goroutine unblocks and the context cancellation can
@@ -5908,6 +5926,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
59085926 }
59095927
59105928 switch strings.ToLower(parts[0]) {
5929+ case "/goal":
5930+ i.runGoalCommand(ctx, cmd, parts)
59115931 case "/exit":
59125932 return true
59135933 case "/clear":
@@ -7293,6 +7313,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
72937313 }
72947314 summary, err := i.agent.Compact(ctx, keepTail, sink)
72957315 _ = summary
7316+ goalMessage, goalActive := i.goalContinuationMessage()
72967317 i.mu.Lock()
72977318 // Keep busy/compacting asserted while cleanup and queue selection run.
72987319 // Completion updates can arrive in this window; clearing busy before
@@ -7311,6 +7332,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
73117332 var hasNext bool
73127333 var continueExisting bool
73137334 var continueAutomatically bool
7335+ var continueGoal bool
73147336 var handoff json.RawMessage
73157337 var persistHandoff bool
73167338
@@ -7398,6 +7420,9 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
73987420 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
73997421 }
74007422 continueAutomatically = true
7423+ case auto && goalActive:
7424+ continueGoal = true
7425+ handoff, persistHandoff = i.resetCompactContinuationLocked()
74017426 default:
74027427 handoff, persistHandoff = i.resetCompactContinuationLocked()
74037428 }
@@ -7406,7 +7431,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
74067431 // the mutex. A completion update arriving after the compaction result
74077432 // but before this assignment is then queued for the selected follow-up
74087433 // instead of starting a competing turn.
7409- i.busy = hasNext || continueExisting || continueAutomatically
7434+ i.busy = hasNext || continueExisting || continueAutomatically || continueGoal
74107435 i.compacting = false
74117436 i.autoCompacting = false
74127437 i.mu.Unlock()
@@ -7416,7 +7441,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
74167441 runPendingIdleWork(pendingIdleWork)
74177442 i.invalidate()
74187443
7419- if hasNext || continueExisting || continueAutomatically {
7444+ if hasNext || continueExisting || continueAutomatically || continueGoal {
74207445 p := i.runCtx
74217446 if p == nil {
74227447 p = context.Background()
@@ -7428,6 +7453,8 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
74287453 i.startTurnWithImages(p, next, nextImages)
74297454 case continueAutomatically:
74307455 i.startAutoCompactContinuation(p)
7456+ case continueGoal:
7457+ i.startGoalContinuation(p, goalMessage)
74317458 }
74327459 }
74337460 }()
@@ -7762,6 +7789,11 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
77627789 if flush != nil {
77637790 flush()
77647791 }
7792+ terminalGoalError := ctx.Err() == nil && !offer && !recoverContextOverflow && (err != nil || lastTurnErr != nil || lastStop == provider.StopError)
7793+ if terminalGoalError {
7794+ i.updateActiveGoal(core.GoalBlocked, "turn ended with an error")
7795+ }
7796+ goalMessage, goalActive := i.goalContinuationMessage()
77657797 i.mu.Lock()
77667798 awaitingPre := i.awaitingStartupPre
77677799 // A newer explicit prompt may have cleared the handoff while the
@@ -7812,6 +7844,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
78127844 if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {
78137845 handoff, persistHandoff = i.resetCompactContinuationLocked()
78147846 }
7847+ continueGoal := goalActive && !awaitingPre && !hasNext && !continueQueued && !continueStatusRescue && !offer && !recoverContextOverflow && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil
78157848 // The agent run can finish before the paced final text reaches the
78167849 // transcript. A compaction replaces that transcript, so it must never
78177850 // race the still-live stream frame; otherwise stale deltas can repaint
@@ -7819,8 +7852,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
78197852 if recoverContextOverflow || shouldAutoCompact {
78207853 i.resetStreamingStateLocked()
78217854 }
7822- alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)
7823- i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact
7855+ alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0 || continueGoal, offer, recoverContextOverflow, shouldAutoCompact)
7856+ i.busy = hasNext || continueQueued || continueStatusRescue || continueGoal || recoverContextOverflow || shouldAutoCompact
78247857 i.mu.Unlock()
78257858 if persistHandoff {
78267859 i.persistCompactHandoff(handoff)
@@ -7844,6 +7877,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
78447877 i.startTurnRequest(parent, "", nil, true, false)
78457878 case continueStatusRescue:
78467879 i.startAutoCompactContinuation(parent)
7880+ case continueGoal:
7881+ i.startGoalContinuation(parent, goalMessage)
78477882 case offer:
78487883 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
78497884 case recoverContextOverflow:
@@ -8269,6 +8304,9 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
82698304 if i.cfg.OnToolResult != nil {
82708305 i.cfg.OnToolResult(e.ID, e.Result)
82718306 }
8307+ if update, ok := tools.GoalUpdateFromResult(e.Result); ok && i.goalStatus == core.GoalActive {
8308+ i.goalStatus = update.Status
8309+ }
82728310 case core.EvUsage:
82738311 i.cumUsage = e.Cumulative
82748312 if contextUsed := e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens; contextUsed > 0 {
@@ -372,6 +372,13 @@ type InteractiveConfig struct {
372 // after a manual rename has already been written to disk.372 // after a manual rename has already been written to disk.
373 OnSessionTitleChanged func(title string)373 OnSessionTitleChanged func(title string)
374 374
375+ // CurrentGoal and PersistGoal expose the autonomous goal attached to the
376+ // current session. CurrentGoal must return a copy, not the live session
377+ // pointer, so failed persistence cannot mutate in-memory state. The callbacks
378+ // follow session switches without coupling the TUI to the session file owner.
379+ CurrentGoal func() *core.SessionGoal
380+ PersistGoal func(goal *core.SessionGoal) error
381+
375 OnAssistant func(m provider.Message)382 OnAssistant func(m provider.Message)
376 OnToolResult func(id string, r core.ToolResult)383 OnToolResult func(id string, r core.ToolResult)
377 384
@@ -661,6 +668,7 @@ type Interactive struct {
661 toolGate map[string]int668 toolGate map[string]int
662 statusErr string669 statusErr string
663 statusOK string670 statusOK string
671+ goalStatus core.GoalStatus
664 reloadStatusSeq uint64672 reloadStatusSeq uint64
665 extStatuses map[string]map[string]extensionStatus673 extStatuses map[string]map[string]extensionStatus
666 extWidgets map[string]map[string]extensionWidget674 extWidgets map[string]map[string]extensionWidget
@@ -916,6 +924,12 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
916 startupContextPaths := []string(nil)924 startupContextPaths := []string(nil)
917 startupExtensionNames := []string(nil)925 startupExtensionNames := []string(nil)
918 startupSkillNames := []string(nil)926 startupSkillNames := []string(nil)
927+ initialGoalStatus := core.GoalStatus("")
928+ if cfg.CurrentGoal != nil {
929+ if goal := cfg.CurrentGoal(); goal != nil {
930+ initialGoalStatus = goal.Status
931+ }
932+ }
919 if cfg.ShowInstructionsAtStartup != nil && *cfg.ShowInstructionsAtStartup {933 if cfg.ShowInstructionsAtStartup != nil && *cfg.ShowInstructionsAtStartup {
920 startupAgentName = cfg.StartupAgentName934 startupAgentName = cfg.StartupAgentName
921 startupContextPaths = append(startupContextPaths, cfg.StartupContextPaths...)935 startupContextPaths = append(startupContextPaths, cfg.StartupContextPaths...)
@@ -969,6 +983,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
969 spin: newSpinner(cfg.Theme),983 spin: newSpinner(cfg.Theme),
970 inputHistoryIndex: -1,984 inputHistoryIndex: -1,
971 clock: time.Now,985 clock: time.Now,
986+ goalStatus: initialGoalStatus,
972 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),987 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
973 compactContinuation: decodeCompactHandoff(cfg.InitialCompactHandoff),988 compactContinuation: decodeCompactHandoff(cfg.InitialCompactHandoff),
974 }989 }
@@ -2066,6 +2081,7 @@ func (i *Interactive) redraw() {
2066 if workingWithStatus {2081 if workingWithStatus {
2067 statusBusyPrefix = busyPrefix2082 statusBusyPrefix = busyPrefix
2068 }2083 }
2084+ goalStatus := string(i.goalStatus)
2069 statusLines := tui.StatusBar(tui.StatusBarParams{2085 statusLines := tui.StatusBar(tui.StatusBarParams{
2070 Theme: i.cfg.Theme,2086 Theme: i.cfg.Theme,
2071 Provider: i.cfg.Provider,2087 Provider: i.cfg.Provider,
@@ -2077,6 +2093,7 @@ func (i *Interactive) redraw() {
2077 CWD: i.cfg.CWD,2093 CWD: i.cfg.CWD,
2078 Locked: i.cfg.Sandbox.Locked(),2094 Locked: i.cfg.Sandbox.Locked(),
2079 NoYolo: i.cfg.NoYolo,2095 NoYolo: i.cfg.NoYolo,
2096+ GoalStatus: goalStatus,
2080 Usage: i.cumUsage,2097 Usage: i.cumUsage,
2081 Subscription: i.cfg.AuthMethod == "oauth",2098 Subscription: i.cfg.AuthMethod == "oauth",
2082 ContextUsed: i.lastCtxInput,2099 ContextUsed: i.lastCtxInput,
@@ -2544,7 +2561,7 @@ func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message
2544 }2561 }
2545 2562
2546 func isHiddenTranscriptMessage(m provider.Message) bool {2563 func isHiddenTranscriptMessage(m provider.Message) bool {
2547- if m.Meta[autoCompactContinueMetaKey] == "true" {2564+ if m.Meta[autoCompactContinueMetaKey] == "true" || m.Meta[goalContinueMetaKey] == "true" {
2548 return true2565 return true
2549 }2566 }
2550 if m.Role != provider.RoleUser || len(m.Content) == 0 {2567 if m.Role != provider.RoleUser || len(m.Content) == 0 {
@@ -3290,6 +3307,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
3290 }3307 }
3291 i.mu.Unlock()3308 i.mu.Unlock()
3292 if busyCancel {3309 if busyCancel {
3310+ i.updateActiveGoal(core.GoalPaused, "interrupted by user")
3293 cancelTurn()3311 cancelTurn()
3294 // If a confirm dialog is pending, refuse it so the agent3312 // If a confirm dialog is pending, refuse it so the agent
3295 // goroutine unblocks and the context cancellation can3313 // goroutine unblocks and the context cancellation can
@@ -5908,6 +5926,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
5908 }5926 }
5909 5927
5910 switch strings.ToLower(parts[0]) {5928 switch strings.ToLower(parts[0]) {
5929+ case "/goal":
5930+ i.runGoalCommand(ctx, cmd, parts)
5911 case "/exit":5931 case "/exit":
5912 return true5932 return true
5913 case "/clear":5933 case "/clear":
@@ -7293,6 +7313,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
7293 }7313 }
7294 summary, err := i.agent.Compact(ctx, keepTail, sink)7314 summary, err := i.agent.Compact(ctx, keepTail, sink)
7295 _ = summary7315 _ = summary
7316+ goalMessage, goalActive := i.goalContinuationMessage()
7296 i.mu.Lock()7317 i.mu.Lock()
7297 // Keep busy/compacting asserted while cleanup and queue selection run.7318 // Keep busy/compacting asserted while cleanup and queue selection run.
7298 // Completion updates can arrive in this window; clearing busy before7319 // Completion updates can arrive in this window; clearing busy before
@@ -7311,6 +7332,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
7311 var hasNext bool7332 var hasNext bool
7312 var continueExisting bool7333 var continueExisting bool
7313 var continueAutomatically bool7334 var continueAutomatically bool
7335+ var continueGoal bool
7314 var handoff json.RawMessage7336 var handoff json.RawMessage
7315 var persistHandoff bool7337 var persistHandoff bool
7316 7338
@@ -7398,6 +7420,9 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
7398 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})7420 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
7399 }7421 }
7400 continueAutomatically = true7422 continueAutomatically = true
7423+ case auto && goalActive:
7424+ continueGoal = true
7425+ handoff, persistHandoff = i.resetCompactContinuationLocked()
7401 default:7426 default:
7402 handoff, persistHandoff = i.resetCompactContinuationLocked()7427 handoff, persistHandoff = i.resetCompactContinuationLocked()
7403 }7428 }
@@ -7406,7 +7431,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
7406 // the mutex. A completion update arriving after the compaction result7431 // the mutex. A completion update arriving after the compaction result
7407 // but before this assignment is then queued for the selected follow-up7432 // but before this assignment is then queued for the selected follow-up
7408 // instead of starting a competing turn.7433 // instead of starting a competing turn.
7409- i.busy = hasNext || continueExisting || continueAutomatically7434+ i.busy = hasNext || continueExisting || continueAutomatically || continueGoal
7410 i.compacting = false7435 i.compacting = false
7411 i.autoCompacting = false7436 i.autoCompacting = false
7412 i.mu.Unlock()7437 i.mu.Unlock()
@@ -7416,7 +7441,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
7416 runPendingIdleWork(pendingIdleWork)7441 runPendingIdleWork(pendingIdleWork)
7417 i.invalidate()7442 i.invalidate()
7418 7443
7419- if hasNext || continueExisting || continueAutomatically {7444+ if hasNext || continueExisting || continueAutomatically || continueGoal {
7420 p := i.runCtx7445 p := i.runCtx
7421 if p == nil {7446 if p == nil {
7422 p = context.Background()7447 p = context.Background()
@@ -7428,6 +7453,8 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
7428 i.startTurnWithImages(p, next, nextImages)7453 i.startTurnWithImages(p, next, nextImages)
7429 case continueAutomatically:7454 case continueAutomatically:
7430 i.startAutoCompactContinuation(p)7455 i.startAutoCompactContinuation(p)
7456+ case continueGoal:
7457+ i.startGoalContinuation(p, goalMessage)
7431 }7458 }
7432 }7459 }
7433 }()7460 }()
@@ -7762,6 +7789,11 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
7762 if flush != nil {7789 if flush != nil {
7763 flush()7790 flush()
7764 }7791 }
7792+ terminalGoalError := ctx.Err() == nil && !offer && !recoverContextOverflow && (err != nil || lastTurnErr != nil || lastStop == provider.StopError)
7793+ if terminalGoalError {
7794+ i.updateActiveGoal(core.GoalBlocked, "turn ended with an error")
7795+ }
7796+ goalMessage, goalActive := i.goalContinuationMessage()
7765 i.mu.Lock()7797 i.mu.Lock()
7766 awaitingPre := i.awaitingStartupPre7798 awaitingPre := i.awaitingStartupPre
7767 // A newer explicit prompt may have cleared the handoff while the7799 // A newer explicit prompt may have cleared the handoff while the
@@ -7812,6 +7844,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
7812 if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {7844 if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {
7813 handoff, persistHandoff = i.resetCompactContinuationLocked()7845 handoff, persistHandoff = i.resetCompactContinuationLocked()
7814 }7846 }
7847+ continueGoal := goalActive && !awaitingPre && !hasNext && !continueQueued && !continueStatusRescue && !offer && !recoverContextOverflow && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil
7815 // The agent run can finish before the paced final text reaches the7848 // The agent run can finish before the paced final text reaches the
7816 // transcript. A compaction replaces that transcript, so it must never7849 // transcript. A compaction replaces that transcript, so it must never
7817 // race the still-live stream frame; otherwise stale deltas can repaint7850 // race the still-live stream frame; otherwise stale deltas can repaint
@@ -7819,8 +7852,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
7819 if recoverContextOverflow || shouldAutoCompact {7852 if recoverContextOverflow || shouldAutoCompact {
7820 i.resetStreamingStateLocked()7853 i.resetStreamingStateLocked()
7821 }7854 }
7822- alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)7855+ alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0 || continueGoal, offer, recoverContextOverflow, shouldAutoCompact)
7823- i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact7856+ i.busy = hasNext || continueQueued || continueStatusRescue || continueGoal || recoverContextOverflow || shouldAutoCompact
7824 i.mu.Unlock()7857 i.mu.Unlock()
7825 if persistHandoff {7858 if persistHandoff {
7826 i.persistCompactHandoff(handoff)7859 i.persistCompactHandoff(handoff)
@@ -7844,6 +7877,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
7844 i.startTurnRequest(parent, "", nil, true, false)7877 i.startTurnRequest(parent, "", nil, true, false)
7845 case continueStatusRescue:7878 case continueStatusRescue:
7846 i.startAutoCompactContinuation(parent)7879 i.startAutoCompactContinuation(parent)
7880+ case continueGoal:
7881+ i.startGoalContinuation(parent, goalMessage)
7847 case offer:7882 case offer:
7848 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)7883 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
7849 case recoverContextOverflow:7884 case recoverContextOverflow:
@@ -8269,6 +8304,9 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
8269 if i.cfg.OnToolResult != nil {8304 if i.cfg.OnToolResult != nil {
8270 i.cfg.OnToolResult(e.ID, e.Result)8305 i.cfg.OnToolResult(e.ID, e.Result)
8271 }8306 }
8307+ if update, ok := tools.GoalUpdateFromResult(e.Result); ok && i.goalStatus == core.GoalActive {
8308+ i.goalStatus = update.Status
8309+ }
8272 case core.EvUsage:8310 case core.EvUsage:
8273 i.cumUsage = e.Cumulative8311 i.cumUsage = e.Cumulative
8274 if contextUsed := e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens; contextUsed > 0 {8312 if contextUsed := e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens; contextUsed > 0 {
M packages/agent/modes/slash_suggest.go +1 -0
@@ -53,6 +53,7 @@ var slashCatalog = []slashCommand{
5353 {Name: "/reasoning", Desc: "set the reasoning level"},
5454 {Name: "/fast", Desc: "toggle fast mode"},
5555 {Name: "/orchestrator", Desc: "toggle automatic subagent orchestration"},
56+ {Name: "/goal", Desc: "start or control an autonomous goal"},
5657 {Name: "/llama", Desc: "manage llama.cpp router models"},
5758 {Name: "/sessions", Desc: "resume a previous session for this directory"},
5859 {Name: "/fork", Desc: "pick a user message and fork the current session"},
@@ -53,6 +53,7 @@ var slashCatalog = []slashCommand{
53 {Name: "/reasoning", Desc: "set the reasoning level"},53 {Name: "/reasoning", Desc: "set the reasoning level"},
54 {Name: "/fast", Desc: "toggle fast mode"},54 {Name: "/fast", Desc: "toggle fast mode"},
55 {Name: "/orchestrator", Desc: "toggle automatic subagent orchestration"},55 {Name: "/orchestrator", Desc: "toggle automatic subagent orchestration"},
56+ {Name: "/goal", Desc: "start or control an autonomous goal"},
56 {Name: "/llama", Desc: "manage llama.cpp router models"},57 {Name: "/llama", Desc: "manage llama.cpp router models"},
57 {Name: "/sessions", Desc: "resume a previous session for this directory"},58 {Name: "/sessions", Desc: "resume a previous session for this directory"},
58 {Name: "/fork", Desc: "pick a user message and fork the current session"},59 {Name: "/fork", Desc: "pick a user message and fork the current session"},
M packages/agent/tools/read.go +2 -1
@@ -1,4 +1,5 @@
1-// Package tools implements zut's built-in tools: read, write, edit, bash, create_worktree, lsp, and web search.
1+// Package tools implements zut's built-in tools: read, write, edit, bash,
2+// create_worktree, lsp, web search, and autonomous goal updates.
23 package tools
34
45 import (
@@ -1,4 +1,5 @@
1-// Package tools implements zut's built-in tools: read, write, edit, bash, create_worktree, lsp, and web search.1+// Package tools implements zut's built-in tools: read, write, edit, bash,
2+// create_worktree, lsp, web search, and autonomous goal updates.
2 package tools3 package tools
3 4
4 import (5 import (
A packages/agent/tools/update_goal.go +99 -0
@@ -0,0 +1,99 @@
1+package tools
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "io"
8+ "strings"
9+
10+ "github.com/bnema/zut/packages/core"
11+ "github.com/bnema/zut/packages/provider"
12+)
13+
14+const (
15+ UpdateGoalToolName = "update_goal"
16+ updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["complete","blocked"],"description":"Mark the active autonomous goal complete or blocked."},"reason":{"type":"string","description":"Concise reason when the goal is blocked."}},"required":["status"],"additionalProperties":false}`
17+)
18+
19+// UpdateGoalTool lets the model stop an explicitly user-started autonomous
20+// goal. Starting, pausing, and resuming remain user-controlled.
21+type UpdateGoalTool struct{}
22+
23+type updateGoalArgs struct {
24+ Status string `json:"status"`
25+ Reason string `json:"reason,omitempty"`
26+}
27+
28+// GoalUpdate is trusted metadata returned only by UpdateGoalTool.
29+type GoalUpdate struct {
30+ Status core.GoalStatus
31+ Reason string
32+}
33+
34+func (t *UpdateGoalTool) Name() string { return UpdateGoalToolName }
35+
36+func (t *UpdateGoalTool) Description() string {
37+ return "Mark the active autonomous goal complete or blocked. Only use this for a goal explicitly started by the user with /goal."
38+}
39+
40+func (t *UpdateGoalTool) Schema() json.RawMessage { return json.RawMessage(updateGoalSchema) }
41+
42+func (t *UpdateGoalTool) Execute(_ context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
43+ var args updateGoalArgs
44+ decoder := json.NewDecoder(bytes.NewReader(raw))
45+ decoder.DisallowUnknownFields()
46+ if err := decoder.Decode(&args); err != nil {
47+ return goalToolError("invalid arguments"), nil
48+ }
49+ if err := decoder.Decode(&struct{}{}); err != io.EOF {
50+ return goalToolError("invalid arguments"), nil
51+ }
52+ args.Status = strings.TrimSpace(args.Status)
53+ args.Reason = strings.TrimSpace(args.Reason)
54+
55+ var update GoalUpdate
56+ switch args.Status {
57+ case "complete":
58+ update.Status = core.GoalDone
59+ case "blocked":
60+ if args.Reason == "" {
61+ return goalToolError("reason is required when blocking a goal"), nil
62+ }
63+ update.Status = core.GoalBlocked
64+ update.Reason = args.Reason
65+ default:
66+ return goalToolError("status must be complete or blocked"), nil
67+ }
68+
69+ text := "autonomous goal marked " + string(update.Status)
70+ if update.Reason != "" {
71+ text += ": " + update.Reason
72+ }
73+ return core.ToolResult{
74+ Content: []provider.Content{provider.TextBlock{Text: text}},
75+ Details: update,
76+ }, nil
77+}
78+
79+// GoalUpdateFromResult extracts a valid update_goal state transition.
80+func GoalUpdateFromResult(result core.ToolResult) (GoalUpdate, bool) {
81+ if result.IsError {
82+ return GoalUpdate{}, false
83+ }
84+ update, ok := result.Details.(GoalUpdate)
85+ if !ok {
86+ return GoalUpdate{}, false
87+ }
88+ if update.Status != core.GoalDone && update.Status != core.GoalBlocked {
89+ return GoalUpdate{}, false
90+ }
91+ return update, true
92+}
93+
94+func goalToolError(message string) core.ToolResult {
95+ return core.ToolResult{
96+ Content: []provider.Content{provider.TextBlock{Text: message}},
97+ IsError: true,
98+ }
99+}
@@ -0,0 +1,99 @@
1+package tools
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "io"
8+ "strings"
9+
10+ "github.com/bnema/zut/packages/core"
11+ "github.com/bnema/zut/packages/provider"
12+)
13+
14+const (
15+ UpdateGoalToolName = "update_goal"
16+ updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["complete","blocked"],"description":"Mark the active autonomous goal complete or blocked."},"reason":{"type":"string","description":"Concise reason when the goal is blocked."}},"required":["status"],"additionalProperties":false}`
17+)
18+
19+// UpdateGoalTool lets the model stop an explicitly user-started autonomous
20+// goal. Starting, pausing, and resuming remain user-controlled.
21+type UpdateGoalTool struct{}
22+
23+type updateGoalArgs struct {
24+ Status string `json:"status"`
25+ Reason string `json:"reason,omitempty"`
26+}
27+
28+// GoalUpdate is trusted metadata returned only by UpdateGoalTool.
29+type GoalUpdate struct {
30+ Status core.GoalStatus
31+ Reason string
32+}
33+
34+func (t *UpdateGoalTool) Name() string { return UpdateGoalToolName }
35+
36+func (t *UpdateGoalTool) Description() string {
37+ return "Mark the active autonomous goal complete or blocked. Only use this for a goal explicitly started by the user with /goal."
38+}
39+
40+func (t *UpdateGoalTool) Schema() json.RawMessage { return json.RawMessage(updateGoalSchema) }
41+
42+func (t *UpdateGoalTool) Execute(_ context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
43+ var args updateGoalArgs
44+ decoder := json.NewDecoder(bytes.NewReader(raw))
45+ decoder.DisallowUnknownFields()
46+ if err := decoder.Decode(&args); err != nil {
47+ return goalToolError("invalid arguments"), nil
48+ }
49+ if err := decoder.Decode(&struct{}{}); err != io.EOF {
50+ return goalToolError("invalid arguments"), nil
51+ }
52+ args.Status = strings.TrimSpace(args.Status)
53+ args.Reason = strings.TrimSpace(args.Reason)
54+
55+ var update GoalUpdate
56+ switch args.Status {
57+ case "complete":
58+ update.Status = core.GoalDone
59+ case "blocked":
60+ if args.Reason == "" {
61+ return goalToolError("reason is required when blocking a goal"), nil
62+ }
63+ update.Status = core.GoalBlocked
64+ update.Reason = args.Reason
65+ default:
66+ return goalToolError("status must be complete or blocked"), nil
67+ }
68+
69+ text := "autonomous goal marked " + string(update.Status)
70+ if update.Reason != "" {
71+ text += ": " + update.Reason
72+ }
73+ return core.ToolResult{
74+ Content: []provider.Content{provider.TextBlock{Text: text}},
75+ Details: update,
76+ }, nil
77+}
78+
79+// GoalUpdateFromResult extracts a valid update_goal state transition.
80+func GoalUpdateFromResult(result core.ToolResult) (GoalUpdate, bool) {
81+ if result.IsError {
82+ return GoalUpdate{}, false
83+ }
84+ update, ok := result.Details.(GoalUpdate)
85+ if !ok {
86+ return GoalUpdate{}, false
87+ }
88+ if update.Status != core.GoalDone && update.Status != core.GoalBlocked {
89+ return GoalUpdate{}, false
90+ }
91+ return update, true
92+}
93+
94+func goalToolError(message string) core.ToolResult {
95+ return core.ToolResult{
96+ Content: []provider.Content{provider.TextBlock{Text: message}},
97+ IsError: true,
98+ }
99+}
A packages/agent/tools/update_goal_test.go +57 -0
@@ -0,0 +1,57 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "testing"
7+
8+ "github.com/bnema/zut/packages/core"
9+)
10+
11+func TestUpdateGoalToolReturnsPersistableStatus(t *testing.T) {
12+ tool := &UpdateGoalTool{}
13+ raw, err := json.Marshal(map[string]string{"status": "complete"})
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ result, err := tool.Execute(context.Background(), raw, nil)
18+ if err != nil {
19+ t.Fatal(err)
20+ }
21+ if result.IsError {
22+ t.Fatalf("result = %#v, want success", result)
23+ }
24+ update, ok := GoalUpdateFromResult(result)
25+ if !ok {
26+ t.Fatalf("details = %#v, want goal update", result.Details)
27+ }
28+ if update.Status != core.GoalDone || update.Reason != "" {
29+ t.Fatalf("update = %#v", update)
30+ }
31+}
32+
33+func TestUpdateGoalToolRejectsUnknownFields(t *testing.T) {
34+ tool := &UpdateGoalTool{}
35+ result, err := tool.Execute(context.Background(), json.RawMessage(`{"status":"complete","unexpected":true}`), nil)
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ if !result.IsError {
40+ t.Fatalf("result = %#v, want tool error", result)
41+ }
42+}
43+
44+func TestUpdateGoalToolRejectsUnsupportedStatus(t *testing.T) {
45+ tool := &UpdateGoalTool{}
46+ raw := json.RawMessage(`{"status":"active"}`)
47+ result, err := tool.Execute(context.Background(), raw, nil)
48+ if err != nil {
49+ t.Fatal(err)
50+ }
51+ if !result.IsError {
52+ t.Fatalf("result = %#v, want tool error", result)
53+ }
54+ if _, ok := GoalUpdateFromResult(result); ok {
55+ t.Fatal("invalid result exposed a goal update")
56+ }
57+}
@@ -0,0 +1,57 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "testing"
7+
8+ "github.com/bnema/zut/packages/core"
9+)
10+
11+func TestUpdateGoalToolReturnsPersistableStatus(t *testing.T) {
12+ tool := &UpdateGoalTool{}
13+ raw, err := json.Marshal(map[string]string{"status": "complete"})
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ result, err := tool.Execute(context.Background(), raw, nil)
18+ if err != nil {
19+ t.Fatal(err)
20+ }
21+ if result.IsError {
22+ t.Fatalf("result = %#v, want success", result)
23+ }
24+ update, ok := GoalUpdateFromResult(result)
25+ if !ok {
26+ t.Fatalf("details = %#v, want goal update", result.Details)
27+ }
28+ if update.Status != core.GoalDone || update.Reason != "" {
29+ t.Fatalf("update = %#v", update)
30+ }
31+}
32+
33+func TestUpdateGoalToolRejectsUnknownFields(t *testing.T) {
34+ tool := &UpdateGoalTool{}
35+ result, err := tool.Execute(context.Background(), json.RawMessage(`{"status":"complete","unexpected":true}`), nil)
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ if !result.IsError {
40+ t.Fatalf("result = %#v, want tool error", result)
41+ }
42+}
43+
44+func TestUpdateGoalToolRejectsUnsupportedStatus(t *testing.T) {
45+ tool := &UpdateGoalTool{}
46+ raw := json.RawMessage(`{"status":"active"}`)
47+ result, err := tool.Execute(context.Background(), raw, nil)
48+ if err != nil {
49+ t.Fatal(err)
50+ }
51+ if !result.IsError {
52+ t.Fatalf("result = %#v, want tool error", result)
53+ }
54+ if _, ok := GoalUpdateFromResult(result); ok {
55+ t.Fatal("invalid result exposed a goal update")
56+ }
57+}
M packages/core/core_test.go +40 -0
@@ -296,6 +296,46 @@ func TestSessionCompactHandoffRoundTripAndClear(t *testing.T) {
296296 }
297297 }
298298
299+func TestSessionGoalRoundTripAndClear(t *testing.T) {
300+ sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
301+ if err != nil {
302+ t.Fatal(err)
303+ }
304+ goal := &SessionGoal{Objective: "finish the requested change", Status: GoalActive}
305+ if err := sess.UpdateGoal(goal); err != nil {
306+ t.Fatal(err)
307+ }
308+ if sess.Meta.Goal == nil || *sess.Meta.Goal != *goal {
309+ t.Fatalf("live goal = %#v, want %#v", sess.Meta.Goal, goal)
310+ }
311+ if err := sess.Close(); err != nil {
312+ t.Fatal(err)
313+ }
314+
315+ reopened, _, err := OpenSession(sess.Path)
316+ if err != nil {
317+ t.Fatal(err)
318+ }
319+ if reopened.Meta.Goal == nil || *reopened.Meta.Goal != *goal {
320+ t.Fatalf("reopened goal = %#v, want %#v", reopened.Meta.Goal, goal)
321+ }
322+ if err := reopened.UpdateGoal(nil); err != nil {
323+ t.Fatal(err)
324+ }
325+ if err := reopened.Close(); err != nil {
326+ t.Fatal(err)
327+ }
328+
329+ reopened, _, err = OpenSession(sess.Path)
330+ if err != nil {
331+ t.Fatal(err)
332+ }
333+ defer reopened.Close()
334+ if reopened.Meta.Goal != nil {
335+ t.Fatalf("cleared goal = %#v, want nil", reopened.Meta.Goal)
336+ }
337+}
338+
299339 func TestCostAdd(t *testing.T) {
300340 var c CostTracker
301341 c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, ReasoningTokens: 20, ReasoningTokensKnown: true, CostUSD: 0.01})
@@ -296,6 +296,46 @@ func TestSessionCompactHandoffRoundTripAndClear(t *testing.T) {
296 }296 }
297 }297 }
298 298
299+func TestSessionGoalRoundTripAndClear(t *testing.T) {
300+ sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
301+ if err != nil {
302+ t.Fatal(err)
303+ }
304+ goal := &SessionGoal{Objective: "finish the requested change", Status: GoalActive}
305+ if err := sess.UpdateGoal(goal); err != nil {
306+ t.Fatal(err)
307+ }
308+ if sess.Meta.Goal == nil || *sess.Meta.Goal != *goal {
309+ t.Fatalf("live goal = %#v, want %#v", sess.Meta.Goal, goal)
310+ }
311+ if err := sess.Close(); err != nil {
312+ t.Fatal(err)
313+ }
314+
315+ reopened, _, err := OpenSession(sess.Path)
316+ if err != nil {
317+ t.Fatal(err)
318+ }
319+ if reopened.Meta.Goal == nil || *reopened.Meta.Goal != *goal {
320+ t.Fatalf("reopened goal = %#v, want %#v", reopened.Meta.Goal, goal)
321+ }
322+ if err := reopened.UpdateGoal(nil); err != nil {
323+ t.Fatal(err)
324+ }
325+ if err := reopened.Close(); err != nil {
326+ t.Fatal(err)
327+ }
328+
329+ reopened, _, err = OpenSession(sess.Path)
330+ if err != nil {
331+ t.Fatal(err)
332+ }
333+ defer reopened.Close()
334+ if reopened.Meta.Goal != nil {
335+ t.Fatalf("cleared goal = %#v, want nil", reopened.Meta.Goal)
336+ }
337+}
338+
299 func TestCostAdd(t *testing.T) {339 func TestCostAdd(t *testing.T) {
300 var c CostTracker340 var c CostTracker
301 c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, ReasoningTokens: 20, ReasoningTokensKnown: true, CostUSD: 0.01})341 c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, ReasoningTokens: 20, ReasoningTokensKnown: true, CostUSD: 0.01})
M packages/core/session.go +40 -1
@@ -47,6 +47,23 @@ type Session struct {
4747 messagesAppended int
4848 }
4949
50+// GoalStatus is the persisted lifecycle state of an autonomous session goal.
51+type GoalStatus string
52+
53+const (
54+ GoalActive GoalStatus = "active"
55+ GoalPaused GoalStatus = "paused"
56+ GoalBlocked GoalStatus = "blocked"
57+ GoalDone GoalStatus = "done"
58+)
59+
60+// SessionGoal is the concise autonomous objective attached to one session.
61+type SessionGoal struct {
62+ Objective string `json:"objective"`
63+ Status GoalStatus `json:"status"`
64+ Reason string `json:"reason,omitempty"`
65+}
66+
5067 // SessionMeta is written as the first line of every session file.
5168 type SessionMeta struct {
5269 ID string `json:"id"`
@@ -65,6 +82,9 @@ type SessionMeta struct {
6582 // continuation. It is session metadata, never provider context.
6683 // Missing or invalid values are handled by the owning host as no handoff.
6784 CompactHandoff json.RawMessage `json:"compact_handoff,omitempty"`
85+ // Goal is the current autonomous objective for this session. Completed
86+ // goals remain inspectable until the user clears or replaces them.
87+ Goal *SessionGoal `json:"goal,omitempty"`
6888
6989 // Parent is the ID of the session this one was forked from, or
7090 // empty for top-level sessions. The tree picker walks parents
@@ -1295,6 +1315,25 @@ func (s *Session) UpdateCompactHandoff(state json.RawMessage) error {
12951315 return nil
12961316 }
12971317
1318+// UpdateGoal records or clears the current autonomous session goal.
1319+func (s *Session) UpdateGoal(goal *SessionGoal) error {
1320+ if s == nil {
1321+ return nil
1322+ }
1323+ previous := s.Meta.Goal
1324+ if goal == nil {
1325+ s.Meta.Goal = nil
1326+ } else {
1327+ copyGoal := *goal
1328+ s.Meta.Goal = &copyGoal
1329+ }
1330+ if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil {
1331+ s.Meta.Goal = previous
1332+ return fmt.Errorf("update goal: %w", err)
1333+ }
1334+ return nil
1335+}
1336+
12981337 // UpdateTitle records a session title without adding anything to the
12991338 // conversation transcript. The title is kept in memory as well so the live
13001339 // session can restore it when the user switches sessions in the TUI.
@@ -1374,7 +1413,7 @@ func (s *Session) Close() error {
13741413 }
13751414 flushErr := s.Flush()
13761415 closeErr := s.writer.Close()
1377- if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 && len(s.Meta.CompactHandoff) == 0 {
1416+ if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 && len(s.Meta.CompactHandoff) == 0 && s.Meta.Goal == nil {
13781417 // Best-effort cleanup. We deliberately don't propagate the
13791418 // remove error: if it fails (file already gone, perms changed)
13801419 // the worst case is one stale empty file in the listing.
@@ -47,6 +47,23 @@ type Session struct {
47 messagesAppended int47 messagesAppended int
48 }48 }
49 49
50+// GoalStatus is the persisted lifecycle state of an autonomous session goal.
51+type GoalStatus string
52+
53+const (
54+ GoalActive GoalStatus = "active"
55+ GoalPaused GoalStatus = "paused"
56+ GoalBlocked GoalStatus = "blocked"
57+ GoalDone GoalStatus = "done"
58+)
59+
60+// SessionGoal is the concise autonomous objective attached to one session.
61+type SessionGoal struct {
62+ Objective string `json:"objective"`
63+ Status GoalStatus `json:"status"`
64+ Reason string `json:"reason,omitempty"`
65+}
66+
50 // SessionMeta is written as the first line of every session file.67 // SessionMeta is written as the first line of every session file.
51 type SessionMeta struct {68 type SessionMeta struct {
52 ID string `json:"id"`69 ID string `json:"id"`
@@ -65,6 +82,9 @@ type SessionMeta struct {
65 // continuation. It is session metadata, never provider context.82 // continuation. It is session metadata, never provider context.
66 // Missing or invalid values are handled by the owning host as no handoff.83 // Missing or invalid values are handled by the owning host as no handoff.
67 CompactHandoff json.RawMessage `json:"compact_handoff,omitempty"`84 CompactHandoff json.RawMessage `json:"compact_handoff,omitempty"`
85+ // Goal is the current autonomous objective for this session. Completed
86+ // goals remain inspectable until the user clears or replaces them.
87+ Goal *SessionGoal `json:"goal,omitempty"`
68 88
69 // Parent is the ID of the session this one was forked from, or89 // Parent is the ID of the session this one was forked from, or
70 // empty for top-level sessions. The tree picker walks parents90 // empty for top-level sessions. The tree picker walks parents
@@ -1295,6 +1315,25 @@ func (s *Session) UpdateCompactHandoff(state json.RawMessage) error {
1295 return nil1315 return nil
1296 }1316 }
1297 1317
1318+// UpdateGoal records or clears the current autonomous session goal.
1319+func (s *Session) UpdateGoal(goal *SessionGoal) error {
1320+ if s == nil {
1321+ return nil
1322+ }
1323+ previous := s.Meta.Goal
1324+ if goal == nil {
1325+ s.Meta.Goal = nil
1326+ } else {
1327+ copyGoal := *goal
1328+ s.Meta.Goal = &copyGoal
1329+ }
1330+ if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil {
1331+ s.Meta.Goal = previous
1332+ return fmt.Errorf("update goal: %w", err)
1333+ }
1334+ return nil
1335+}
1336+
1298 // UpdateTitle records a session title without adding anything to the1337 // UpdateTitle records a session title without adding anything to the
1299 // conversation transcript. The title is kept in memory as well so the live1338 // conversation transcript. The title is kept in memory as well so the live
1300 // session can restore it when the user switches sessions in the TUI.1339 // session can restore it when the user switches sessions in the TUI.
@@ -1374,7 +1413,7 @@ func (s *Session) Close() error {
1374 }1413 }
1375 flushErr := s.Flush()1414 flushErr := s.Flush()
1376 closeErr := s.writer.Close()1415 closeErr := s.writer.Close()
1377- if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 && len(s.Meta.CompactHandoff) == 0 {1416+ if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 && len(s.Meta.CompactHandoff) == 0 && s.Meta.Goal == nil {
1378 // Best-effort cleanup. We deliberately don't propagate the1417 // Best-effort cleanup. We deliberately don't propagate the
1379 // remove error: if it fails (file already gone, perms changed)1418 // remove error: if it fails (file already gone, perms changed)
1380 // the worst case is one stale empty file in the listing.1419 // the worst case is one stale empty file in the listing.
M packages/core/session_portable.go +21 -3
@@ -228,6 +228,7 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) {
228228 Timezone: timezone,
229229 TimezoneOffset: timezoneOffset,
230230 CompactHandoff: append(json.RawMessage(nil), snapshot.Meta.CompactHandoff...),
231+ Goal: cloneSessionGoal(snapshot.Meta.Goal),
231232 }
232233 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta})
233234 if err != nil {
@@ -320,7 +321,8 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi
320321 return "", err
321322 }
322323 compactHandoff := compactHandoffAtBranch(snapshot.Meta, snapshot.Messages, limit)
323- return writeBranchSession(root, cwd, version, snapshot.Meta, snapshot.Messages, snapshot.UsageCheckpoints, limit, hideFromSessions, extensionState, compactHandoff)
324+ goal := goalAtBranch(snapshot.Meta, snapshot.Messages, limit)
325+ return writeBranchSession(root, cwd, version, snapshot.Meta, snapshot.Messages, snapshot.UsageCheckpoints, limit, hideFromSessions, extensionState, compactHandoff, goal)
324326 }
325327
326328 // BranchSessionHiddenFromHistory creates a hidden tree branch from a
@@ -350,7 +352,7 @@ func BranchSessionHiddenFromHistory(parentPath, root, cwd, version string, segme
350352 if err != nil {
351353 return "", err
352354 }
353- return writeBranchSession(root, cwd, version, snapshot.Meta, segment.Messages, segment.UsageCheckpoints, limit, true, extensionState, nil)
355+ return writeBranchSession(root, cwd, version, snapshot.Meta, segment.Messages, segment.UsageCheckpoints, limit, true, extensionState, nil, nil)
354356 }
355357
356358 // readExtensionStateAtFork reconstructs the latest extension snapshot that was
@@ -437,7 +439,22 @@ func compactHandoffAtBranch(parent SessionMeta, messages []provider.Message, lim
437439 return append(json.RawMessage(nil), parent.CompactHandoff...)
438440 }
439441
440-func writeBranchSession(root, cwd, version string, parent SessionMeta, messages []provider.Message, checkpoints []SessionUsageCheckpoint, limit int, hideFromSessions bool, extensionState map[string]json.RawMessage, compactHandoff json.RawMessage) (string, error) {
442+func goalAtBranch(parent SessionMeta, messages []provider.Message, limit int) *SessionGoal {
443+ if limit != len(messages) {
444+ return nil
445+ }
446+ return cloneSessionGoal(parent.Goal)
447+}
448+
449+func cloneSessionGoal(goal *SessionGoal) *SessionGoal {
450+ if goal == nil {
451+ return nil
452+ }
453+ clone := *goal
454+ return &clone
455+}
456+
457+func writeBranchSession(root, cwd, version string, parent SessionMeta, messages []provider.Message, checkpoints []SessionUsageCheckpoint, limit int, hideFromSessions bool, extensionState map[string]json.RawMessage, compactHandoff json.RawMessage, goal *SessionGoal) (string, error) {
441458 dir := SessionsDir(root, cwd)
442459 if err := os.MkdirAll(dir, 0o755); err != nil {
443460 return "", err
@@ -479,6 +496,7 @@ func writeBranchSession(root, cwd, version string, parent SessionMeta, messages
479496 ForkPoint: limit,
480497 HideFromSessions: hideFromSessions,
481498 CompactHandoff: append(json.RawMessage(nil), compactHandoff...),
499+ Goal: cloneSessionGoal(goal),
482500 }
483501 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta})
484502 if err != nil {
@@ -228,6 +228,7 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) {
228 Timezone: timezone,228 Timezone: timezone,
229 TimezoneOffset: timezoneOffset,229 TimezoneOffset: timezoneOffset,
230 CompactHandoff: append(json.RawMessage(nil), snapshot.Meta.CompactHandoff...),230 CompactHandoff: append(json.RawMessage(nil), snapshot.Meta.CompactHandoff...),
231+ Goal: cloneSessionGoal(snapshot.Meta.Goal),
231 }232 }
232 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta})233 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta})
233 if err != nil {234 if err != nil {
@@ -320,7 +321,8 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi
320 return "", err321 return "", err
321 }322 }
322 compactHandoff := compactHandoffAtBranch(snapshot.Meta, snapshot.Messages, limit)323 compactHandoff := compactHandoffAtBranch(snapshot.Meta, snapshot.Messages, limit)
323- return writeBranchSession(root, cwd, version, snapshot.Meta, snapshot.Messages, snapshot.UsageCheckpoints, limit, hideFromSessions, extensionState, compactHandoff)324+ goal := goalAtBranch(snapshot.Meta, snapshot.Messages, limit)
325+ return writeBranchSession(root, cwd, version, snapshot.Meta, snapshot.Messages, snapshot.UsageCheckpoints, limit, hideFromSessions, extensionState, compactHandoff, goal)
324 }326 }
325 327
326 // BranchSessionHiddenFromHistory creates a hidden tree branch from a328 // BranchSessionHiddenFromHistory creates a hidden tree branch from a
@@ -350,7 +352,7 @@ func BranchSessionHiddenFromHistory(parentPath, root, cwd, version string, segme
350 if err != nil {352 if err != nil {
351 return "", err353 return "", err
352 }354 }
353- return writeBranchSession(root, cwd, version, snapshot.Meta, segment.Messages, segment.UsageCheckpoints, limit, true, extensionState, nil)355+ return writeBranchSession(root, cwd, version, snapshot.Meta, segment.Messages, segment.UsageCheckpoints, limit, true, extensionState, nil, nil)
354 }356 }
355 357
356 // readExtensionStateAtFork reconstructs the latest extension snapshot that was358 // readExtensionStateAtFork reconstructs the latest extension snapshot that was
@@ -437,7 +439,22 @@ func compactHandoffAtBranch(parent SessionMeta, messages []provider.Message, lim
437 return append(json.RawMessage(nil), parent.CompactHandoff...)439 return append(json.RawMessage(nil), parent.CompactHandoff...)
438 }440 }
439 441
440-func writeBranchSession(root, cwd, version string, parent SessionMeta, messages []provider.Message, checkpoints []SessionUsageCheckpoint, limit int, hideFromSessions bool, extensionState map[string]json.RawMessage, compactHandoff json.RawMessage) (string, error) {442+func goalAtBranch(parent SessionMeta, messages []provider.Message, limit int) *SessionGoal {
443+ if limit != len(messages) {
444+ return nil
445+ }
446+ return cloneSessionGoal(parent.Goal)
447+}
448+
449+func cloneSessionGoal(goal *SessionGoal) *SessionGoal {
450+ if goal == nil {
451+ return nil
452+ }
453+ clone := *goal
454+ return &clone
455+}
456+
457+func writeBranchSession(root, cwd, version string, parent SessionMeta, messages []provider.Message, checkpoints []SessionUsageCheckpoint, limit int, hideFromSessions bool, extensionState map[string]json.RawMessage, compactHandoff json.RawMessage, goal *SessionGoal) (string, error) {
441 dir := SessionsDir(root, cwd)458 dir := SessionsDir(root, cwd)
442 if err := os.MkdirAll(dir, 0o755); err != nil {459 if err := os.MkdirAll(dir, 0o755); err != nil {
443 return "", err460 return "", err
@@ -479,6 +496,7 @@ func writeBranchSession(root, cwd, version string, parent SessionMeta, messages
479 ForkPoint: limit,496 ForkPoint: limit,
480 HideFromSessions: hideFromSessions,497 HideFromSessions: hideFromSessions,
481 CompactHandoff: append(json.RawMessage(nil), compactHandoff...),498 CompactHandoff: append(json.RawMessage(nil), compactHandoff...),
499+ Goal: cloneSessionGoal(goal),
482 }500 }
483 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta})501 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta})
484 if err != nil {502 if err != nil {
M packages/core/session_portable_test.go +17 -0
@@ -80,6 +80,10 @@ func TestSessionExportImportRoundTrip(t *testing.T) {
8080 if err := sess.UpdateCompactHandoff(handoff); err != nil {
8181 t.Fatal(err)
8282 }
83+ goal := &SessionGoal{Objective: "finish the portable task", Status: GoalPaused}
84+ if err := sess.UpdateGoal(goal); err != nil {
85+ t.Fatal(err)
86+ }
8387 _ = sess.Close()
8488
8589 // Export to a directory — helper should build a name inside it.
@@ -124,6 +128,9 @@ func TestSessionExportImportRoundTrip(t *testing.T) {
124128 if got := string(imported.Meta.CompactHandoff); got != string(handoff) {
125129 t.Errorf("compact handoff = %q, want %q", got, handoff)
126130 }
131+ if imported.Meta.Goal == nil || *imported.Meta.Goal != *goal {
132+ t.Errorf("goal = %#v, want %#v", imported.Meta.Goal, goal)
133+ }
127134 if len(msgs) != 2 {
128135 t.Fatalf("want 2 messages, got %d", len(msgs))
129136 }
@@ -564,6 +571,10 @@ func TestBranchSessionCompactHandoffFollowsCopiedEffectiveTail(t *testing.T) {
564571 if err := parent.UpdateCompactHandoff(handoff); err != nil {
565572 t.Fatal(err)
566573 }
574+ goal := &SessionGoal{Objective: "finish the branch", Status: GoalActive}
575+ if err := parent.UpdateGoal(goal); err != nil {
576+ t.Fatal(err)
577+ }
567578 if err := parent.Close(); err != nil {
568579 t.Fatal(err)
569580 }
@@ -580,6 +591,9 @@ func TestBranchSessionCompactHandoffFollowsCopiedEffectiveTail(t *testing.T) {
580591 if got := string(full.Meta.CompactHandoff); got != string(handoff) {
581592 t.Fatalf("full branch compact handoff = %q, want %q", got, handoff)
582593 }
594+ if full.Meta.Goal == nil || *full.Meta.Goal != *goal {
595+ t.Fatalf("full branch goal = %#v, want %#v", full.Meta.Goal, goal)
596+ }
583597
584598 prefixPath, err := BranchSession(parent.Path, root, cwd, "test", 1)
585599 if err != nil {
@@ -593,6 +607,9 @@ func TestBranchSessionCompactHandoffFollowsCopiedEffectiveTail(t *testing.T) {
593607 if len(prefix.Meta.CompactHandoff) != 0 {
594608 t.Fatalf("prefix branch compact handoff = %q, want empty", prefix.Meta.CompactHandoff)
595609 }
610+ if prefix.Meta.Goal != nil {
611+ t.Fatalf("prefix branch goal = %#v, want nil", prefix.Meta.Goal)
612+ }
596613 }
597614
598615 func TestBranchSessionHiddenFromHistoryClearsCompactHandoff(t *testing.T) {
@@ -80,6 +80,10 @@ func TestSessionExportImportRoundTrip(t *testing.T) {
80 if err := sess.UpdateCompactHandoff(handoff); err != nil {80 if err := sess.UpdateCompactHandoff(handoff); err != nil {
81 t.Fatal(err)81 t.Fatal(err)
82 }82 }
83+ goal := &SessionGoal{Objective: "finish the portable task", Status: GoalPaused}
84+ if err := sess.UpdateGoal(goal); err != nil {
85+ t.Fatal(err)
86+ }
83 _ = sess.Close()87 _ = sess.Close()
84 88
85 // Export to a directory — helper should build a name inside it.89 // Export to a directory — helper should build a name inside it.
@@ -124,6 +128,9 @@ func TestSessionExportImportRoundTrip(t *testing.T) {
124 if got := string(imported.Meta.CompactHandoff); got != string(handoff) {128 if got := string(imported.Meta.CompactHandoff); got != string(handoff) {
125 t.Errorf("compact handoff = %q, want %q", got, handoff)129 t.Errorf("compact handoff = %q, want %q", got, handoff)
126 }130 }
131+ if imported.Meta.Goal == nil || *imported.Meta.Goal != *goal {
132+ t.Errorf("goal = %#v, want %#v", imported.Meta.Goal, goal)
133+ }
127 if len(msgs) != 2 {134 if len(msgs) != 2 {
128 t.Fatalf("want 2 messages, got %d", len(msgs))135 t.Fatalf("want 2 messages, got %d", len(msgs))
129 }136 }
@@ -564,6 +571,10 @@ func TestBranchSessionCompactHandoffFollowsCopiedEffectiveTail(t *testing.T) {
564 if err := parent.UpdateCompactHandoff(handoff); err != nil {571 if err := parent.UpdateCompactHandoff(handoff); err != nil {
565 t.Fatal(err)572 t.Fatal(err)
566 }573 }
574+ goal := &SessionGoal{Objective: "finish the branch", Status: GoalActive}
575+ if err := parent.UpdateGoal(goal); err != nil {
576+ t.Fatal(err)
577+ }
567 if err := parent.Close(); err != nil {578 if err := parent.Close(); err != nil {
568 t.Fatal(err)579 t.Fatal(err)
569 }580 }
@@ -580,6 +591,9 @@ func TestBranchSessionCompactHandoffFollowsCopiedEffectiveTail(t *testing.T) {
580 if got := string(full.Meta.CompactHandoff); got != string(handoff) {591 if got := string(full.Meta.CompactHandoff); got != string(handoff) {
581 t.Fatalf("full branch compact handoff = %q, want %q", got, handoff)592 t.Fatalf("full branch compact handoff = %q, want %q", got, handoff)
582 }593 }
594+ if full.Meta.Goal == nil || *full.Meta.Goal != *goal {
595+ t.Fatalf("full branch goal = %#v, want %#v", full.Meta.Goal, goal)
596+ }
583 597
584 prefixPath, err := BranchSession(parent.Path, root, cwd, "test", 1)598 prefixPath, err := BranchSession(parent.Path, root, cwd, "test", 1)
585 if err != nil {599 if err != nil {
@@ -593,6 +607,9 @@ func TestBranchSessionCompactHandoffFollowsCopiedEffectiveTail(t *testing.T) {
593 if len(prefix.Meta.CompactHandoff) != 0 {607 if len(prefix.Meta.CompactHandoff) != 0 {
594 t.Fatalf("prefix branch compact handoff = %q, want empty", prefix.Meta.CompactHandoff)608 t.Fatalf("prefix branch compact handoff = %q, want empty", prefix.Meta.CompactHandoff)
595 }609 }
610+ if prefix.Meta.Goal != nil {
611+ t.Fatalf("prefix branch goal = %#v, want nil", prefix.Meta.Goal)
612+ }
596 }613 }
597 614
598 func TestBranchSessionHiddenFromHistoryClearsCompactHandoff(t *testing.T) {615 func TestBranchSessionHiddenFromHistoryClearsCompactHandoff(t *testing.T) {
M packages/tui/statusbar_test.go +24 -0
@@ -7,6 +7,30 @@ import (
77 "github.com/bnema/zut/packages/provider"
88 )
99
10+func TestStatusBarShowsActiveGoal(t *testing.T) {
11+ params := StatusBarParams{
12+ Theme: Dark,
13+ Provider: "openai",
14+ Model: "gpt-test",
15+ GoalStatus: "active",
16+ CWD: "/tmp/project",
17+ Cols: 100,
18+ }
19+ lines := StatusBar(params)
20+ if len(lines) == 0 || !strings.Contains(lines[0], "goal:active") {
21+ t.Fatalf("status bar = %q, want active goal", lines)
22+ }
23+
24+ params.Cols = 35
25+ lines = StatusBar(params)
26+ for _, line := range lines {
27+ if strings.Contains(line, "goal:active") {
28+ return
29+ }
30+ }
31+ t.Fatalf("narrow status bar = %q, want active goal", lines)
32+}
33+
1034 // TestStatusBarAlwaysTwoLines verifies the status bar always emits
1135 // two lines when a cwd is present, regardless of terminal width, and
1236 // that the cwd is indented with the 2-space pad so it lines up under
@@ -7,6 +7,30 @@ import (
7 "github.com/bnema/zut/packages/provider"7 "github.com/bnema/zut/packages/provider"
8 )8 )
9 9
10+func TestStatusBarShowsActiveGoal(t *testing.T) {
11+ params := StatusBarParams{
12+ Theme: Dark,
13+ Provider: "openai",
14+ Model: "gpt-test",
15+ GoalStatus: "active",
16+ CWD: "/tmp/project",
17+ Cols: 100,
18+ }
19+ lines := StatusBar(params)
20+ if len(lines) == 0 || !strings.Contains(lines[0], "goal:active") {
21+ t.Fatalf("status bar = %q, want active goal", lines)
22+ }
23+
24+ params.Cols = 35
25+ lines = StatusBar(params)
26+ for _, line := range lines {
27+ if strings.Contains(line, "goal:active") {
28+ return
29+ }
30+ }
31+ t.Fatalf("narrow status bar = %q, want active goal", lines)
32+}
33+
10 // TestStatusBarAlwaysTwoLines verifies the status bar always emits34 // TestStatusBarAlwaysTwoLines verifies the status bar always emits
11 // two lines when a cwd is present, regardless of terminal width, and35 // two lines when a cwd is present, regardless of terminal width, and
12 // that the cwd is indented with the 2-space pad so it lines up under36 // that the cwd is indented with the 2-space pad so it lines up under
M packages/tui/view.go +6 -2
@@ -2828,8 +2828,9 @@ type StatusBarParams struct {
28282828 Busy bool
28292829 BusyPrefix string // spinner + funny line when busy
28302830 CWD string
2831- Locked bool // sandbox on?
2832- NoYolo bool // confirmation mode enabled?
2831+ Locked bool // sandbox on?
2832+ NoYolo bool // confirmation mode enabled?
2833+ GoalStatus string // autonomous goal lifecycle, empty when none
28332834
28342835 // Cumulative session usage and cost.
28352836 Usage provider.Usage
@@ -2904,6 +2905,9 @@ func StatusBar(p StatusBarParams) []string {
29042905 if costStr != "" {
29052906 stats = append(stats, costStr)
29062907 }
2908+ if p.GoalStatus != "" {
2909+ stats = append(stats, "goal:"+p.GoalStatus)
2910+ }
29072911
29082912 // Context %. Color-coded: yellow >70, red >90.
29092913 ctx, ctxColor := contextUsage(th, p.ContextUsed, p.ContextMax)
@@ -2828,8 +2828,9 @@ type StatusBarParams struct {
2828 Busy bool2828 Busy bool
2829 BusyPrefix string // spinner + funny line when busy2829 BusyPrefix string // spinner + funny line when busy
2830 CWD string2830 CWD string
2831- Locked bool // sandbox on?2831+ Locked bool // sandbox on?
2832- NoYolo bool // confirmation mode enabled?2832+ NoYolo bool // confirmation mode enabled?
2833+ GoalStatus string // autonomous goal lifecycle, empty when none
2833 2834
2834 // Cumulative session usage and cost.2835 // Cumulative session usage and cost.
2835 Usage provider.Usage2836 Usage provider.Usage
@@ -2904,6 +2905,9 @@ func StatusBar(p StatusBarParams) []string {
2904 if costStr != "" {2905 if costStr != "" {
2905 stats = append(stats, costStr)2906 stats = append(stats, costStr)
2906 }2907 }
2908+ if p.GoalStatus != "" {
2909+ stats = append(stats, "goal:"+p.GoalStatus)
2910+ }
2907 2911
2908 // Context %. Color-coded: yellow >70, red >90.2912 // Context %. Color-coded: yellow >70, red >90.
2909 ctx, ctxColor := contextUsage(th, p.ContextUsed, p.ContextMax)2913 ctx, ctxColor := contextUsage(th, p.ContextUsed, p.ContextMax)

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