detached 2830302

fix(agent): preserve swarm state across cwd changes

commit
2830302af84ac11e06b8cd63d56ec22cac46fe11
author
brice · · 2026-08-04 04:45 UTC
parent
26cdde5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +179 -56
M packages/agent/cli.go +55 -33
@@ -1054,17 +1054,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
10541054 //
10551055 // Steps, in order:
10561056 // 1. resolve + validate the new path (~ expansion, abs/rel)
1057- // 2. close the current session, flushing pending messages
1058- // 3. mutate captured args.CWD + r.CWD so future buildAgent
1059- // calls bind to the new cwd
1060- // 4. re-root the shared sandbox («·«) so /jail follows the
1061- // session into the new cwd instead of widening or silently
1062- // dropping
1063- // 5. rebuild the agent via buildAgent() so tools, AGENTS.md
1064- // addendum, system prompt, sessions dir all bind correctly
1065- // 6. open a fresh session in the new cwd's bucket
1066- // 7. push the new state into the running Interactive
1067- // 8. re-scope the swarm dashboard to the freshly-opened session
1057+ // 2. stage the candidate cwd, sandbox, and swarm state
1058+ // 3. rebuild the agent and allocate its new session
1059+ // 4. commit all new state together, closing the old session last
1060+ // 5. push the committed state into the running Interactive
10681061 //
10691062 // The /jail state is preserved verbatim: if the sandbox was locked
10701063 // to the old cwd, it stays locked, just re-pointed at the new one.
@@ -1104,21 +1097,39 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11041097 return fmt.Errorf("no agent running; log in first")
11051098 }
11061099
1107- // Close the current session before we drop the reference.
1108- // Per-message persistence keeps it current already; this is
1109- // a defensive flush + final fsync via Close.
1110- persistMu.Lock()
1111- if sess != nil {
1112- writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
1113- _ = sess.Close()
1114- sess = nil
1100+ // Stage the candidate state while retaining a complete rollback
1101+ // snapshot. A failed rebuild or session allocation must leave the
1102+ // current agent, session, sandbox, and swarm root usable.
1103+ oldCWD := args.CWD
1104+ oldResolvedCWD := r.CWD
1105+ oldPermissionSet := args.PermissionSet
1106+ oldSwarmRoot := ""
1107+ if swarmMgr != nil {
1108+ oldSwarmRoot = swarmMgr.RepoRoot()
1109+ }
1110+ oldSandboxRoot := ""
1111+ oldSandboxLocked := false
1112+ if sharedSandbox != nil {
1113+ oldSandboxRoot = sharedSandbox.Root
1114+ oldSandboxLocked = sharedSandbox.Locked()
1115+ }
1116+ rollback := func() {
1117+ args.CWD = oldCWD
1118+ r.CWD = oldResolvedCWD
1119+ args.PermissionSet = oldPermissionSet
1120+ if swarmMgr != nil {
1121+ swarmMgr.SetRepoRoot(oldSwarmRoot)
1122+ }
1123+ if sharedSandbox != nil {
1124+ sharedSandbox.Root = oldSandboxRoot
1125+ if oldSandboxLocked {
1126+ sharedSandbox.Lock()
1127+ } else {
1128+ sharedSandbox.Unlock()
1129+ }
1130+ }
11151131 }
1116- sessBaselineMsgs = 0
1117- persistMu.Unlock()
11181132
1119- // Mutate captured state so subsequent agent rebuilds and
1120- // session opens see the new cwd.
1121- wasJailed := sharedSandbox != nil && sharedSandbox.Locked()
11221133 args.CWD = absPath
11231134 r.CWD = absPath
11241135 if swarmMgr != nil {
@@ -1130,7 +1141,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11301141 }
11311142 if sharedSandbox != nil {
11321143 sharedSandbox.Root = absPath
1133- if wasJailed {
1144+ if oldSandboxLocked {
11341145 sharedSandbox.Lock()
11351146 } else {
11361147 sharedSandbox.Unlock()
@@ -1141,27 +1152,38 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11411152 // re-bind to the new cwd. buildAgent() reads from args + r.
11421153 newAg, newProvider, newModel, berr := buildAgent()
11431154 if berr != nil {
1155+ rollback()
11441156 return fmt.Errorf("rebuild agent: %v", berr)
11451157 }
1146- ag = newAg
11471158
11481159 // Fresh session in the new cwd's bucket. We bypass
11491160 // openOrCreateSession's --continue / --resume branches
11501161 // because /cd's semantics are "start fresh here", matching
11511162 // what relaunching `zot --cwd <path>` would do today.
1163+ var newSess *core.Session
11521164 if !args.NoSess {
11531165 newRoot := agentSessionsRoot(ZotHome(), args)
11541166 core.PruneEmptySessions(newRoot, absPath)
1155- newSess, serr := core.NewSession(newRoot, absPath, newProvider, newModel, version)
1167+ var serr error
1168+ newSess, serr = core.NewSession(newRoot, absPath, newProvider, newModel, version)
11561169 if serr != nil {
1170+ rollback()
11571171 return fmt.Errorf("open session in %s: %v", absPath, serr)
11581172 }
1159- persistMu.Lock()
1160- sess = newSess
1161- sessBaselineMsgs = 0
1162- persistMu.Unlock()
11631173 }
11641174
1175+ // Commit only after every fallible operation has succeeded. Close
1176+ // the old session last so rollback never has to resurrect it.
1177+ persistMu.Lock()
1178+ if sess != nil {
1179+ writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
1180+ _ = sess.Close()
1181+ }
1182+ sess = newSess
1183+ sessBaselineMsgs = 0
1184+ persistMu.Unlock()
1185+ ag = newAg
1186+
11651187 // Push the new state into the running Interactive.
11661188 if iv != nil {
11671189 startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZotHome()))
@@ -1169,8 +1191,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11691191 }
11701192
11711193 // Re-scope the swarm dashboard to the new session.
1172- if swarmMgr != nil && sess != nil {
1173- swarmMgr.SetActiveSession(sess.ID)
1194+ if swarmMgr != nil && newSess != nil {
1195+ swarmMgr.SetActiveSession(newSess.ID)
11741196 }
11751197 return nil
11761198 }
@@ -1054,17 +1054,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1054 //1054 //
1055 // Steps, in order:1055 // Steps, in order:
1056 // 1. resolve + validate the new path (~ expansion, abs/rel)1056 // 1. resolve + validate the new path (~ expansion, abs/rel)
1057- // 2. close the current session, flushing pending messages1057+ // 2. stage the candidate cwd, sandbox, and swarm state
1058- // 3. mutate captured args.CWD + r.CWD so future buildAgent1058+ // 3. rebuild the agent and allocate its new session
1059- // calls bind to the new cwd1059+ // 4. commit all new state together, closing the old session last
1060- // 4. re-root the shared sandbox («·«) so /jail follows the1060+ // 5. push the committed state into the running Interactive
1061- // session into the new cwd instead of widening or silently
1062- // dropping
1063- // 5. rebuild the agent via buildAgent() so tools, AGENTS.md
1064- // addendum, system prompt, sessions dir all bind correctly
1065- // 6. open a fresh session in the new cwd's bucket
1066- // 7. push the new state into the running Interactive
1067- // 8. re-scope the swarm dashboard to the freshly-opened session
1068 //1061 //
1069 // The /jail state is preserved verbatim: if the sandbox was locked1062 // The /jail state is preserved verbatim: if the sandbox was locked
1070 // to the old cwd, it stays locked, just re-pointed at the new one.1063 // to the old cwd, it stays locked, just re-pointed at the new one.
@@ -1104,21 +1097,39 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1104 return fmt.Errorf("no agent running; log in first")1097 return fmt.Errorf("no agent running; log in first")
1105 }1098 }
1106 1099
1107- // Close the current session before we drop the reference.1100+ // Stage the candidate state while retaining a complete rollback
1108- // Per-message persistence keeps it current already; this is1101+ // snapshot. A failed rebuild or session allocation must leave the
1109- // a defensive flush + final fsync via Close.1102+ // current agent, session, sandbox, and swarm root usable.
1110- persistMu.Lock()1103+ oldCWD := args.CWD
1111- if sess != nil {1104+ oldResolvedCWD := r.CWD
1112- writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)1105+ oldPermissionSet := args.PermissionSet
1113- _ = sess.Close()1106+ oldSwarmRoot := ""
1114- sess = nil1107+ if swarmMgr != nil {
1108+ oldSwarmRoot = swarmMgr.RepoRoot()
1109+ }
1110+ oldSandboxRoot := ""
1111+ oldSandboxLocked := false
1112+ if sharedSandbox != nil {
1113+ oldSandboxRoot = sharedSandbox.Root
1114+ oldSandboxLocked = sharedSandbox.Locked()
1115+ }
1116+ rollback := func() {
1117+ args.CWD = oldCWD
1118+ r.CWD = oldResolvedCWD
1119+ args.PermissionSet = oldPermissionSet
1120+ if swarmMgr != nil {
1121+ swarmMgr.SetRepoRoot(oldSwarmRoot)
1122+ }
1123+ if sharedSandbox != nil {
1124+ sharedSandbox.Root = oldSandboxRoot
1125+ if oldSandboxLocked {
1126+ sharedSandbox.Lock()
1127+ } else {
1128+ sharedSandbox.Unlock()
1129+ }
1130+ }
1115 }1131 }
1116- sessBaselineMsgs = 0
1117- persistMu.Unlock()
1118 1132
1119- // Mutate captured state so subsequent agent rebuilds and
1120- // session opens see the new cwd.
1121- wasJailed := sharedSandbox != nil && sharedSandbox.Locked()
1122 args.CWD = absPath1133 args.CWD = absPath
1123 r.CWD = absPath1134 r.CWD = absPath
1124 if swarmMgr != nil {1135 if swarmMgr != nil {
@@ -1130,7 +1141,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1130 }1141 }
1131 if sharedSandbox != nil {1142 if sharedSandbox != nil {
1132 sharedSandbox.Root = absPath1143 sharedSandbox.Root = absPath
1133- if wasJailed {1144+ if oldSandboxLocked {
1134 sharedSandbox.Lock()1145 sharedSandbox.Lock()
1135 } else {1146 } else {
1136 sharedSandbox.Unlock()1147 sharedSandbox.Unlock()
@@ -1141,27 +1152,38 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1141 // re-bind to the new cwd. buildAgent() reads from args + r.1152 // re-bind to the new cwd. buildAgent() reads from args + r.
1142 newAg, newProvider, newModel, berr := buildAgent()1153 newAg, newProvider, newModel, berr := buildAgent()
1143 if berr != nil {1154 if berr != nil {
1155+ rollback()
1144 return fmt.Errorf("rebuild agent: %v", berr)1156 return fmt.Errorf("rebuild agent: %v", berr)
1145 }1157 }
1146- ag = newAg
1147 1158
1148 // Fresh session in the new cwd's bucket. We bypass1159 // Fresh session in the new cwd's bucket. We bypass
1149 // openOrCreateSession's --continue / --resume branches1160 // openOrCreateSession's --continue / --resume branches
1150 // because /cd's semantics are "start fresh here", matching1161 // because /cd's semantics are "start fresh here", matching
1151 // what relaunching `zot --cwd <path>` would do today.1162 // what relaunching `zot --cwd <path>` would do today.
1163+ var newSess *core.Session
1152 if !args.NoSess {1164 if !args.NoSess {
1153 newRoot := agentSessionsRoot(ZotHome(), args)1165 newRoot := agentSessionsRoot(ZotHome(), args)
1154 core.PruneEmptySessions(newRoot, absPath)1166 core.PruneEmptySessions(newRoot, absPath)
1155- newSess, serr := core.NewSession(newRoot, absPath, newProvider, newModel, version)1167+ var serr error
1168+ newSess, serr = core.NewSession(newRoot, absPath, newProvider, newModel, version)
1156 if serr != nil {1169 if serr != nil {
1170+ rollback()
1157 return fmt.Errorf("open session in %s: %v", absPath, serr)1171 return fmt.Errorf("open session in %s: %v", absPath, serr)
1158 }1172 }
1159- persistMu.Lock()
1160- sess = newSess
1161- sessBaselineMsgs = 0
1162- persistMu.Unlock()
1163 }1173 }
1164 1174
1175+ // Commit only after every fallible operation has succeeded. Close
1176+ // the old session last so rollback never has to resurrect it.
1177+ persistMu.Lock()
1178+ if sess != nil {
1179+ writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
1180+ _ = sess.Close()
1181+ }
1182+ sess = newSess
1183+ sessBaselineMsgs = 0
1184+ persistMu.Unlock()
1185+ ag = newAg
1186+
1165 // Push the new state into the running Interactive.1187 // Push the new state into the running Interactive.
1166 if iv != nil {1188 if iv != nil {
1167 startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZotHome()))1189 startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZotHome()))
@@ -1169,8 +1191,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1169 }1191 }
1170 1192
1171 // Re-scope the swarm dashboard to the new session.1193 // Re-scope the swarm dashboard to the new session.
1172- if swarmMgr != nil && sess != nil {1194+ if swarmMgr != nil && newSess != nil {
1173- swarmMgr.SetActiveSession(sess.ID)1195+ swarmMgr.SetActiveSession(newSess.ID)
1174 }1196 }
1175 return nil1197 return nil
1176 }1198 }
M packages/agent/subagents/profiles.go +44 -6
@@ -204,9 +204,16 @@ func load(path, source string) (*Profile, error) {
204204 if name == "" {
205205 name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
206206 }
207- thinking := unquote(values["thinking"])
207+ thinking, err := parseThinkingValue(values["thinking"], "thinking")
208+ if err != nil {
209+ return nil, err
210+ }
211+ reasoning, err := parseThinkingValue(values["reasoning"], "reasoning")
212+ if err != nil {
213+ return nil, err
214+ }
208215 if thinking == "" {
209- thinking = unquote(values["reasoning"])
216+ thinking = reasoning
210217 }
211218 profile := &Profile{
212219 Name: strings.TrimSpace(name),
@@ -220,13 +227,27 @@ func load(path, source string) (*Profile, error) {
220227 Source: source,
221228 }
222229 profile.SystemPromptMode = "append"
223- if strings.EqualFold(unquote(values["systempromptmode"]), "replace") {
224- profile.SystemPromptMode = "replace"
230+ if mode, ok := values["systempromptmode"]; ok {
231+ switch strings.ToLower(strings.TrimSpace(unquote(mode))) {
232+ case "", "append":
233+ case "replace":
234+ profile.SystemPromptMode = "replace"
235+ default:
236+ return nil, fmt.Errorf("systemPromptMode must be append or replace")
237+ }
225238 }
226- if value, ok := parseOptionalBool(values["inheritprojectcontext"]); ok {
239+ if raw, ok := values["inheritprojectcontext"]; ok {
240+ value, parsed := parseOptionalBool(raw)
241+ if !parsed {
242+ return nil, fmt.Errorf("inheritProjectContext must be true or false")
243+ }
227244 profile.InheritProjectContext = &value
228245 }
229- if value, ok := parseOptionalBool(values["inheritskills"]); ok {
246+ if raw, ok := values["inheritskills"]; ok {
247+ value, parsed := parseOptionalBool(raw)
248+ if !parsed {
249+ return nil, fmt.Errorf("inheritSkills must be true or false")
250+ }
230251 profile.InheritSkills = &value
231252 }
232253 return profile, nil
@@ -323,6 +344,23 @@ func parseList(value string) []string {
323344 return out
324345 }
325346
347+func parseThinkingValue(value, field string) (string, error) {
348+ switch strings.ToLower(strings.TrimSpace(unquote(value))) {
349+ case "":
350+ return "", nil
351+ case "off":
352+ return "off", nil
353+ case "minimal", "minimum":
354+ return "minimum", nil
355+ case "low", "medium", "high", "xhigh", "max":
356+ return strings.ToLower(strings.TrimSpace(unquote(value))), nil
357+ case "maximum":
358+ return "xhigh", nil
359+ default:
360+ return "", fmt.Errorf("%s must be off|minimum|low|medium|high|xhigh|max", field)
361+ }
362+}
363+
326364 func parseOptionalBool(value string) (bool, bool) {
327365 parsed, err := strconv.ParseBool(strings.TrimSpace(unquote(value)))
328366 return parsed, err == nil
@@ -204,9 +204,16 @@ func load(path, source string) (*Profile, error) {
204 if name == "" {204 if name == "" {
205 name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))205 name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
206 }206 }
207- thinking := unquote(values["thinking"])207+ thinking, err := parseThinkingValue(values["thinking"], "thinking")
208+ if err != nil {
209+ return nil, err
210+ }
211+ reasoning, err := parseThinkingValue(values["reasoning"], "reasoning")
212+ if err != nil {
213+ return nil, err
214+ }
208 if thinking == "" {215 if thinking == "" {
209- thinking = unquote(values["reasoning"])216+ thinking = reasoning
210 }217 }
211 profile := &Profile{218 profile := &Profile{
212 Name: strings.TrimSpace(name),219 Name: strings.TrimSpace(name),
@@ -220,13 +227,27 @@ func load(path, source string) (*Profile, error) {
220 Source: source,227 Source: source,
221 }228 }
222 profile.SystemPromptMode = "append"229 profile.SystemPromptMode = "append"
223- if strings.EqualFold(unquote(values["systempromptmode"]), "replace") {230+ if mode, ok := values["systempromptmode"]; ok {
224- profile.SystemPromptMode = "replace"231+ switch strings.ToLower(strings.TrimSpace(unquote(mode))) {
232+ case "", "append":
233+ case "replace":
234+ profile.SystemPromptMode = "replace"
235+ default:
236+ return nil, fmt.Errorf("systemPromptMode must be append or replace")
237+ }
225 }238 }
226- if value, ok := parseOptionalBool(values["inheritprojectcontext"]); ok {239+ if raw, ok := values["inheritprojectcontext"]; ok {
240+ value, parsed := parseOptionalBool(raw)
241+ if !parsed {
242+ return nil, fmt.Errorf("inheritProjectContext must be true or false")
243+ }
227 profile.InheritProjectContext = &value244 profile.InheritProjectContext = &value
228 }245 }
229- if value, ok := parseOptionalBool(values["inheritskills"]); ok {246+ if raw, ok := values["inheritskills"]; ok {
247+ value, parsed := parseOptionalBool(raw)
248+ if !parsed {
249+ return nil, fmt.Errorf("inheritSkills must be true or false")
250+ }
230 profile.InheritSkills = &value251 profile.InheritSkills = &value
231 }252 }
232 return profile, nil253 return profile, nil
@@ -323,6 +344,23 @@ func parseList(value string) []string {
323 return out344 return out
324 }345 }
325 346
347+func parseThinkingValue(value, field string) (string, error) {
348+ switch strings.ToLower(strings.TrimSpace(unquote(value))) {
349+ case "":
350+ return "", nil
351+ case "off":
352+ return "off", nil
353+ case "minimal", "minimum":
354+ return "minimum", nil
355+ case "low", "medium", "high", "xhigh", "max":
356+ return strings.ToLower(strings.TrimSpace(unquote(value))), nil
357+ case "maximum":
358+ return "xhigh", nil
359+ default:
360+ return "", fmt.Errorf("%s must be off|minimum|low|medium|high|xhigh|max", field)
361+ }
362+}
363+
326 func parseOptionalBool(value string) (bool, bool) {364 func parseOptionalBool(value string) (bool, bool) {
327 parsed, err := strconv.ParseBool(strings.TrimSpace(unquote(value)))365 parsed, err := strconv.ParseBool(strings.TrimSpace(unquote(value)))
328 return parsed, err == nil366 return parsed, err == nil
M packages/agent/subagents/profiles_test.go +23 -0
@@ -1,6 +1,7 @@
11 package subagents
22
33 import (
4+ "fmt"
45 "os"
56 "path/filepath"
67 "strings"
@@ -99,6 +100,28 @@ No.
99100 }
100101 }
101102
103+func TestLoadRejectsInvalidClosedSchemaMetadata(t *testing.T) {
104+ cases := []struct {
105+ name string
106+ field string
107+ }{
108+ {name: "invalid-thinking", field: "thinking: extreme"},
109+ {name: "invalid-reasoning", field: "reasoning: extreme"},
110+ {name: "invalid-prompt-mode", field: "systemPromptMode: merge"},
111+ {name: "invalid-project-context", field: "inheritProjectContext: sometimes"},
112+ {name: "invalid-skills", field: "inheritSkills: sometimes"},
113+ }
114+ for _, tc := range cases {
115+ t.Run(tc.name, func(t *testing.T) {
116+ path := filepath.Join(t.TempDir(), "profile.md")
117+ writeProfile(t, path, fmt.Sprintf("---\nname: test\n%s\n---\nInstructions.\n", tc.field))
118+ if _, err := load(path, "test"); err == nil {
119+ t.Fatalf("load succeeded for invalid metadata %q", tc.field)
120+ }
121+ })
122+ }
123+}
124+
102125 func TestProfileModelSelection(t *testing.T) {
103126 profile := &Profile{Model: "openrouter/anthropic/claude-sonnet"}
104127 provider, model := profile.ModelSelection()
@@ -1,6 +1,7 @@
1 package subagents1 package subagents
2 2
3 import (3 import (
4+ "fmt"
4 "os"5 "os"
5 "path/filepath"6 "path/filepath"
6 "strings"7 "strings"
@@ -99,6 +100,28 @@ No.
99 }100 }
100 }101 }
101 102
103+func TestLoadRejectsInvalidClosedSchemaMetadata(t *testing.T) {
104+ cases := []struct {
105+ name string
106+ field string
107+ }{
108+ {name: "invalid-thinking", field: "thinking: extreme"},
109+ {name: "invalid-reasoning", field: "reasoning: extreme"},
110+ {name: "invalid-prompt-mode", field: "systemPromptMode: merge"},
111+ {name: "invalid-project-context", field: "inheritProjectContext: sometimes"},
112+ {name: "invalid-skills", field: "inheritSkills: sometimes"},
113+ }
114+ for _, tc := range cases {
115+ t.Run(tc.name, func(t *testing.T) {
116+ path := filepath.Join(t.TempDir(), "profile.md")
117+ writeProfile(t, path, fmt.Sprintf("---\nname: test\n%s\n---\nInstructions.\n", tc.field))
118+ if _, err := load(path, "test"); err == nil {
119+ t.Fatalf("load succeeded for invalid metadata %q", tc.field)
120+ }
121+ })
122+ }
123+}
124+
102 func TestProfileModelSelection(t *testing.T) {125 func TestProfileModelSelection(t *testing.T) {
103 profile := &Profile{Model: "openrouter/anthropic/claude-sonnet"}126 profile := &Profile{Model: "openrouter/anthropic/claude-sonnet"}
104 provider, model := profile.ModelSelection()127 provider, model := profile.ModelSelection()
M packages/agent/swarm/persist.go +2 -0
@@ -353,6 +353,7 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) {
353353 Dir: existing.Dir, Started: existing.Started,
354354 Model: existing.Model, Provider: existing.Provider,
355355 Reasoning: existing.Reasoning, Subagent: existing.Subagent,
356+ SessionID: existing.SessionID,
356357 InboxPath: inboxPath, EventLogPath: existing.EventLogPath,
357358 SessionPath: existing.SessionPath,
358359 }
@@ -366,6 +367,7 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) {
366367 Provider: m.Provider,
367368 Reasoning: m.Reasoning,
368369 Subagent: m.Subagent,
370+ SessionID: m.SessionID,
369371 InboxPath: m.InboxPath,
370372 EventLogPath: m.EventLogPath,
371373 SessionPath: m.SessionPath,
@@ -353,6 +353,7 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) {
353 Dir: existing.Dir, Started: existing.Started,353 Dir: existing.Dir, Started: existing.Started,
354 Model: existing.Model, Provider: existing.Provider,354 Model: existing.Model, Provider: existing.Provider,
355 Reasoning: existing.Reasoning, Subagent: existing.Subagent,355 Reasoning: existing.Reasoning, Subagent: existing.Subagent,
356+ SessionID: existing.SessionID,
356 InboxPath: inboxPath, EventLogPath: existing.EventLogPath,357 InboxPath: inboxPath, EventLogPath: existing.EventLogPath,
357 SessionPath: existing.SessionPath,358 SessionPath: existing.SessionPath,
358 }359 }
@@ -366,6 +367,7 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) {
366 Provider: m.Provider,367 Provider: m.Provider,
367 Reasoning: m.Reasoning,368 Reasoning: m.Reasoning,
368 Subagent: m.Subagent,369 Subagent: m.Subagent,
370+ SessionID: m.SessionID,
369 InboxPath: m.InboxPath,371 InboxPath: m.InboxPath,
370 EventLogPath: m.EventLogPath,372 EventLogPath: m.EventLogPath,
371 SessionPath: m.SessionPath,373 SessionPath: m.SessionPath,
M packages/agent/swarm/persist_test.go +25 -7
@@ -536,6 +536,7 @@ func TestSpawnReqPersistsModel(t *testing.T) {
536536 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
537537 },
538538 })
539+ f.SetActiveSession("host-session")
539540 a, err := f.SpawnReq(context.Background(), SpawnRequest{
540541 Task: "x", Model: "claude-sonnet-4-5", Provider: "anthropic",
541542 Reasoning: "high", Subagent: "reviewer",
@@ -543,8 +544,8 @@ func TestSpawnReqPersistsModel(t *testing.T) {
543544 if err != nil {
544545 t.Fatalf("spawn: %v", err)
545546 }
546- if a.Model != "claude-sonnet-4-5" || a.Provider != "anthropic" || a.Reasoning != "high" || a.Subagent != "reviewer" {
547- t.Fatalf("agent fields = (%q,%q,%q,%q); want model/provider/reasoning/profile", a.Model, a.Provider, a.Reasoning, a.Subagent)
547+ if a.Model != "claude-sonnet-4-5" || a.Provider != "anthropic" || a.Reasoning != "high" || a.Subagent != "reviewer" || a.SessionID != "host-session" {
548+ t.Fatalf("agent fields = (%q,%q,%q,%q,%q); want model/provider/reasoning/profile/session", a.Model, a.Provider, a.Reasoning, a.Subagent, a.SessionID)
548549 }
549550 snap := a.Snapshot()
550551 if snap.Model != "claude-sonnet-4-5" || snap.Provider != "anthropic" || snap.Reasoning != "high" || snap.Subagent != "reviewer" {
@@ -565,14 +566,19 @@ func TestSpawnReqPersistsModel(t *testing.T) {
565566 if err := json.Unmarshal(metaBytes, &got); err != nil {
566567 t.Fatal(err)
567568 }
568- if got.Model != "claude-sonnet-4-5" || got.Provider != "anthropic" || got.Reasoning != "high" || got.Subagent != "reviewer" {
569- t.Errorf("meta = (%q,%q,%q,%q); want model/provider/reasoning/profile persisted", got.Model, got.Provider, got.Reasoning, got.Subagent)
569+ if got.Model != "claude-sonnet-4-5" || got.Provider != "anthropic" || got.Reasoning != "high" || got.Subagent != "reviewer" || got.SessionID != "host-session" {
570+ t.Errorf("meta = (%q,%q,%q,%q,%q); want model/provider/reasoning/profile/session persisted", got.Model, got.Provider, got.Reasoning, got.Subagent, got.SessionID)
570571 }
571572
572573 // Reload in a fresh Swarm and confirm the detached agent still
573574 // carries the model/provider so Resume can route the child
574575 // subprocess back to the same model.
575- g := New(Config{Root: root, RepoRoot: root})
576+ g := New(Config{
577+ Root: root, RepoRoot: root,
578+ NewRunner: func(a *Agent) Runner {
579+ return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
580+ },
581+ })
576582 if loaded, errs := g.Reload(); loaded != 1 || len(errs) > 0 {
577583 t.Fatalf("reload loaded=%d errs=%v", loaded, errs)
578584 }
@@ -580,9 +586,21 @@ func TestSpawnReqPersistsModel(t *testing.T) {
580586 if re == nil {
581587 t.Fatal("reloaded agent missing")
582588 }
583- if re.Model != "claude-sonnet-4-5" || re.Provider != "anthropic" || re.Reasoning != "high" || re.Subagent != "reviewer" {
584- t.Errorf("reloaded fields = (%q,%q,%q,%q); want preserved", re.Model, re.Provider, re.Reasoning, re.Subagent)
589+ if re.Model != "claude-sonnet-4-5" || re.Provider != "anthropic" || re.Reasoning != "high" || re.Subagent != "reviewer" || re.SessionID != "host-session" {
590+ t.Errorf("reloaded fields = (%q,%q,%q,%q,%q); want preserved", re.Model, re.Provider, re.Reasoning, re.Subagent, re.SessionID)
591+ }
592+
593+ resumed, err := g.Resume(context.Background(), re.ID)
594+ if err != nil {
595+ t.Fatalf("resume: %v", err)
596+ }
597+ if resumed.Reasoning != "high" || resumed.Subagent != "reviewer" || resumed.SessionID != "host-session" {
598+ t.Errorf("resumed fields = (%q,%q,%q); want reasoning/profile/session preserved", resumed.Reasoning, resumed.Subagent, resumed.SessionID)
599+ }
600+ if err := g.Stop(resumed.ID); err != nil {
601+ t.Fatalf("stop resumed agent: %v", err)
585602 }
603+ resumed.Wait()
586604 }
587605
588606 // TestSwarmAgentArgsIncludesModelFlags pins the argv contract that
@@ -536,6 +536,7 @@ func TestSpawnReqPersistsModel(t *testing.T) {
536 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })536 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
537 },537 },
538 })538 })
539+ f.SetActiveSession("host-session")
539 a, err := f.SpawnReq(context.Background(), SpawnRequest{540 a, err := f.SpawnReq(context.Background(), SpawnRequest{
540 Task: "x", Model: "claude-sonnet-4-5", Provider: "anthropic",541 Task: "x", Model: "claude-sonnet-4-5", Provider: "anthropic",
541 Reasoning: "high", Subagent: "reviewer",542 Reasoning: "high", Subagent: "reviewer",
@@ -543,8 +544,8 @@ func TestSpawnReqPersistsModel(t *testing.T) {
543 if err != nil {544 if err != nil {
544 t.Fatalf("spawn: %v", err)545 t.Fatalf("spawn: %v", err)
545 }546 }
546- if a.Model != "claude-sonnet-4-5" || a.Provider != "anthropic" || a.Reasoning != "high" || a.Subagent != "reviewer" {547+ if a.Model != "claude-sonnet-4-5" || a.Provider != "anthropic" || a.Reasoning != "high" || a.Subagent != "reviewer" || a.SessionID != "host-session" {
547- t.Fatalf("agent fields = (%q,%q,%q,%q); want model/provider/reasoning/profile", a.Model, a.Provider, a.Reasoning, a.Subagent)548+ t.Fatalf("agent fields = (%q,%q,%q,%q,%q); want model/provider/reasoning/profile/session", a.Model, a.Provider, a.Reasoning, a.Subagent, a.SessionID)
548 }549 }
549 snap := a.Snapshot()550 snap := a.Snapshot()
550 if snap.Model != "claude-sonnet-4-5" || snap.Provider != "anthropic" || snap.Reasoning != "high" || snap.Subagent != "reviewer" {551 if snap.Model != "claude-sonnet-4-5" || snap.Provider != "anthropic" || snap.Reasoning != "high" || snap.Subagent != "reviewer" {
@@ -565,14 +566,19 @@ func TestSpawnReqPersistsModel(t *testing.T) {
565 if err := json.Unmarshal(metaBytes, &got); err != nil {566 if err := json.Unmarshal(metaBytes, &got); err != nil {
566 t.Fatal(err)567 t.Fatal(err)
567 }568 }
568- if got.Model != "claude-sonnet-4-5" || got.Provider != "anthropic" || got.Reasoning != "high" || got.Subagent != "reviewer" {569+ if got.Model != "claude-sonnet-4-5" || got.Provider != "anthropic" || got.Reasoning != "high" || got.Subagent != "reviewer" || got.SessionID != "host-session" {
569- t.Errorf("meta = (%q,%q,%q,%q); want model/provider/reasoning/profile persisted", got.Model, got.Provider, got.Reasoning, got.Subagent)570+ t.Errorf("meta = (%q,%q,%q,%q,%q); want model/provider/reasoning/profile/session persisted", got.Model, got.Provider, got.Reasoning, got.Subagent, got.SessionID)
570 }571 }
571 572
572 // Reload in a fresh Swarm and confirm the detached agent still573 // Reload in a fresh Swarm and confirm the detached agent still
573 // carries the model/provider so Resume can route the child574 // carries the model/provider so Resume can route the child
574 // subprocess back to the same model.575 // subprocess back to the same model.
575- g := New(Config{Root: root, RepoRoot: root})576+ g := New(Config{
577+ Root: root, RepoRoot: root,
578+ NewRunner: func(a *Agent) Runner {
579+ return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
580+ },
581+ })
576 if loaded, errs := g.Reload(); loaded != 1 || len(errs) > 0 {582 if loaded, errs := g.Reload(); loaded != 1 || len(errs) > 0 {
577 t.Fatalf("reload loaded=%d errs=%v", loaded, errs)583 t.Fatalf("reload loaded=%d errs=%v", loaded, errs)
578 }584 }
@@ -580,9 +586,21 @@ func TestSpawnReqPersistsModel(t *testing.T) {
580 if re == nil {586 if re == nil {
581 t.Fatal("reloaded agent missing")587 t.Fatal("reloaded agent missing")
582 }588 }
583- if re.Model != "claude-sonnet-4-5" || re.Provider != "anthropic" || re.Reasoning != "high" || re.Subagent != "reviewer" {589+ if re.Model != "claude-sonnet-4-5" || re.Provider != "anthropic" || re.Reasoning != "high" || re.Subagent != "reviewer" || re.SessionID != "host-session" {
584- t.Errorf("reloaded fields = (%q,%q,%q,%q); want preserved", re.Model, re.Provider, re.Reasoning, re.Subagent)590+ t.Errorf("reloaded fields = (%q,%q,%q,%q,%q); want preserved", re.Model, re.Provider, re.Reasoning, re.Subagent, re.SessionID)
591+ }
592+
593+ resumed, err := g.Resume(context.Background(), re.ID)
594+ if err != nil {
595+ t.Fatalf("resume: %v", err)
596+ }
597+ if resumed.Reasoning != "high" || resumed.Subagent != "reviewer" || resumed.SessionID != "host-session" {
598+ t.Errorf("resumed fields = (%q,%q,%q); want reasoning/profile/session preserved", resumed.Reasoning, resumed.Subagent, resumed.SessionID)
599+ }
600+ if err := g.Stop(resumed.ID); err != nil {
601+ t.Fatalf("stop resumed agent: %v", err)
585 }602 }
603+ resumed.Wait()
586 }604 }
587 605
588 // TestSwarmAgentArgsIncludesModelFlags pins the argv contract that606 // TestSwarmAgentArgsIncludesModelFlags pins the argv contract that
M packages/agent/swarm/runner_test.go +5 -0
@@ -171,6 +171,11 @@ func TestSwarmAgentArgsEmptyTaskOmitsPositional(t *testing.T) {
171171 if a := args[len(args)-1]; strings.HasPrefix(a, "--") {
172172 t.Fatalf("argv ends on a flag with no value: %v", args)
173173 }
174+ for _, flag := range []string{"--reasoning", "--subagent"} {
175+ if indexOf(args, flag) >= 0 {
176+ t.Fatalf("empty child options should omit %s: %v", flag, args)
177+ }
178+ }
174179 }
175180
176181 // TestDefaultChildArgsSpawnIncludesTask pins the spawn shape: a
@@ -171,6 +171,11 @@ func TestSwarmAgentArgsEmptyTaskOmitsPositional(t *testing.T) {
171 if a := args[len(args)-1]; strings.HasPrefix(a, "--") {171 if a := args[len(args)-1]; strings.HasPrefix(a, "--") {
172 t.Fatalf("argv ends on a flag with no value: %v", args)172 t.Fatalf("argv ends on a flag with no value: %v", args)
173 }173 }
174+ for _, flag := range []string{"--reasoning", "--subagent"} {
175+ if indexOf(args, flag) >= 0 {
176+ t.Fatalf("empty child options should omit %s: %v", flag, args)
177+ }
178+ }
174 }179 }
175 180
176 // TestDefaultChildArgsSpawnIncludesTask pins the spawn shape: a181 // TestDefaultChildArgsSpawnIncludesTask pins the spawn shape: a
M packages/agent/swarm/swarm.go +8 -0
@@ -148,6 +148,14 @@ func (f *Swarm) SetRepoRoot(root string) {
148148 f.cfg.RepoRoot = root
149149 }
150150
151+// RepoRoot returns the working directory used by subsequently spawned
152+// agents.
153+func (f *Swarm) RepoRoot() string {
154+ f.mu.Lock()
155+ defer f.mu.Unlock()
156+ return f.cfg.RepoRoot
157+}
158+
151159 // SetActiveSession scopes the dashboard view (and Spawn stamping)
152160 // to a particular host zot session id. Pass empty to clear the
153161 // scope and revert to "show every agent" (the original behaviour).
@@ -148,6 +148,14 @@ func (f *Swarm) SetRepoRoot(root string) {
148 f.cfg.RepoRoot = root148 f.cfg.RepoRoot = root
149 }149 }
150 150
151+// RepoRoot returns the working directory used by subsequently spawned
152+// agents.
153+func (f *Swarm) RepoRoot() string {
154+ f.mu.Lock()
155+ defer f.mu.Unlock()
156+ return f.cfg.RepoRoot
157+}
158+
151 // SetActiveSession scopes the dashboard view (and Spawn stamping)159 // SetActiveSession scopes the dashboard view (and Spawn stamping)
152 // to a particular host zot session id. Pass empty to clear the160 // to a particular host zot session id. Pass empty to clear the
153 // scope and revert to "show every agent" (the original behaviour).161 // scope and revert to "show every agent" (the original behaviour).
M packages/agent/tools/swarm_spawn.go +17 -10
@@ -101,10 +101,10 @@ func (t *SwarmSpawnTool) Schema() json.RawMessage { return json.RawMessage(swarm
101101
102102 func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
103103 if t.Swarm == nil {
104- return toolErr("swarm_spawn: swarm supervisor not available in this mode"), nil
104+ return protocolToolError("swarm_spawn: swarm supervisor not available in this mode")
105105 }
106106 if t.Enabled == nil || !t.Enabled() {
107- return toolErr("swarm_spawn: auto-swarm is disabled. Ask the user to enable it from /settings before delegating sub-tasks."), nil
107+ return protocolToolError("swarm_spawn: auto-swarm is disabled. Ask the user to enable it from /settings before delegating sub-tasks.")
108108 }
109109 var a swarmSpawnArgs
110110 if err := json.Unmarshal(raw, &a); err != nil {
@@ -112,22 +112,22 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
112112 }
113113 task := strings.TrimSpace(a.Task)
114114 if task == "" {
115- return toolErr("swarm_spawn: task is required"), nil
115+ return protocolToolError("swarm_spawn: task is required")
116116 }
117117
118118 agentName := strings.TrimSpace(a.Agent)
119119 var profile *subagents.Profile
120120 if agentName != "" {
121121 if t.ResolveSubagent == nil {
122- return toolErr("swarm_spawn: named subagent profiles are unavailable"), nil
122+ return protocolToolError("swarm_spawn: named subagent profiles are unavailable")
123123 }
124124 var err error
125125 profile, err = t.ResolveSubagent(agentName)
126126 if err != nil {
127- return toolErr("swarm_spawn: " + err.Error()), nil
127+ return protocolToolError("swarm_spawn: " + err.Error())
128128 }
129129 if profile == nil {
130- return toolErr("swarm_spawn: unknown subagent profile " + agentName), nil
130+ return protocolToolError("swarm_spawn: unknown subagent profile " + agentName)
131131 }
132132 agentName = profile.Name
133133 }
@@ -135,7 +135,7 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
135135 model := strings.TrimSpace(a.Model)
136136 providerID := strings.TrimSpace(a.Provider)
137137 if (model == "") != (providerID == "") {
138- return toolErr("swarm_spawn: omit both model/provider to inherit the host or profile, or provide both explicitly"), nil
138+ return protocolToolError("swarm_spawn: omit both model/provider to inherit the host or profile, or provide both explicitly")
139139 }
140140 if model == "" && providerID == "" && profile != nil {
141141 // A fully-qualified profile model is useful metadata for the
@@ -158,18 +158,18 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
158158
159159 reasoning, err := reasoningOverride(a.Reasoning, a.Thinking)
160160 if err != nil {
161- return toolErr("swarm_spawn: " + err.Error()), nil
161+ return protocolToolError("swarm_spawn: " + err.Error())
162162 }
163163 if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" {
164164 reasoning, err = reasoningOverride(profile.Thinking, "")
165165 if err != nil {
166- return toolErr("swarm_spawn: profile " + profile.Name + ": " + err.Error()), nil
166+ return protocolToolError("swarm_spawn: profile " + profile.Name + ": " + err.Error())
167167 }
168168 }
169169 if reasoning == "" && t.DefaultReasoning != nil {
170170 reasoning, err = reasoningOverride(t.DefaultReasoning(), "")
171171 if err != nil {
172- return toolErr("swarm_spawn: host " + err.Error()), nil
172+ return protocolToolError("swarm_spawn: host " + err.Error())
173173 }
174174 }
175175
@@ -241,6 +241,13 @@ func reasoningOverride(reasoning, thinking string) (string, error) {
241241 }
242242 }
243243
244+// protocolToolError keeps model-visible validation failures in the
245+// ToolResult channel rather than treating them as host execution errors.
246+func protocolToolError(msg string) (core.ToolResult, error) {
247+ //nolint:nilerr // ToolResult.IsError is the established tool protocol.
248+ return toolErr(msg), nil
249+}
250+
244251 func toolErr(msg string) core.ToolResult {
245252 return core.ToolResult{
246253 Content: []provider.Content{provider.TextBlock{Text: msg}},
@@ -101,10 +101,10 @@ func (t *SwarmSpawnTool) Schema() json.RawMessage { return json.RawMessage(swarm
101 101
102 func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {102 func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
103 if t.Swarm == nil {103 if t.Swarm == nil {
104- return toolErr("swarm_spawn: swarm supervisor not available in this mode"), nil104+ return protocolToolError("swarm_spawn: swarm supervisor not available in this mode")
105 }105 }
106 if t.Enabled == nil || !t.Enabled() {106 if t.Enabled == nil || !t.Enabled() {
107- return toolErr("swarm_spawn: auto-swarm is disabled. Ask the user to enable it from /settings before delegating sub-tasks."), nil107+ return protocolToolError("swarm_spawn: auto-swarm is disabled. Ask the user to enable it from /settings before delegating sub-tasks.")
108 }108 }
109 var a swarmSpawnArgs109 var a swarmSpawnArgs
110 if err := json.Unmarshal(raw, &a); err != nil {110 if err := json.Unmarshal(raw, &a); err != nil {
@@ -112,22 +112,22 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
112 }112 }
113 task := strings.TrimSpace(a.Task)113 task := strings.TrimSpace(a.Task)
114 if task == "" {114 if task == "" {
115- return toolErr("swarm_spawn: task is required"), nil115+ return protocolToolError("swarm_spawn: task is required")
116 }116 }
117 117
118 agentName := strings.TrimSpace(a.Agent)118 agentName := strings.TrimSpace(a.Agent)
119 var profile *subagents.Profile119 var profile *subagents.Profile
120 if agentName != "" {120 if agentName != "" {
121 if t.ResolveSubagent == nil {121 if t.ResolveSubagent == nil {
122- return toolErr("swarm_spawn: named subagent profiles are unavailable"), nil122+ return protocolToolError("swarm_spawn: named subagent profiles are unavailable")
123 }123 }
124 var err error124 var err error
125 profile, err = t.ResolveSubagent(agentName)125 profile, err = t.ResolveSubagent(agentName)
126 if err != nil {126 if err != nil {
127- return toolErr("swarm_spawn: " + err.Error()), nil127+ return protocolToolError("swarm_spawn: " + err.Error())
128 }128 }
129 if profile == nil {129 if profile == nil {
130- return toolErr("swarm_spawn: unknown subagent profile " + agentName), nil130+ return protocolToolError("swarm_spawn: unknown subagent profile " + agentName)
131 }131 }
132 agentName = profile.Name132 agentName = profile.Name
133 }133 }
@@ -135,7 +135,7 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
135 model := strings.TrimSpace(a.Model)135 model := strings.TrimSpace(a.Model)
136 providerID := strings.TrimSpace(a.Provider)136 providerID := strings.TrimSpace(a.Provider)
137 if (model == "") != (providerID == "") {137 if (model == "") != (providerID == "") {
138- return toolErr("swarm_spawn: omit both model/provider to inherit the host or profile, or provide both explicitly"), nil138+ return protocolToolError("swarm_spawn: omit both model/provider to inherit the host or profile, or provide both explicitly")
139 }139 }
140 if model == "" && providerID == "" && profile != nil {140 if model == "" && providerID == "" && profile != nil {
141 // A fully-qualified profile model is useful metadata for the141 // A fully-qualified profile model is useful metadata for the
@@ -158,18 +158,18 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
158 158
159 reasoning, err := reasoningOverride(a.Reasoning, a.Thinking)159 reasoning, err := reasoningOverride(a.Reasoning, a.Thinking)
160 if err != nil {160 if err != nil {
161- return toolErr("swarm_spawn: " + err.Error()), nil161+ return protocolToolError("swarm_spawn: " + err.Error())
162 }162 }
163 if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" {163 if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" {
164 reasoning, err = reasoningOverride(profile.Thinking, "")164 reasoning, err = reasoningOverride(profile.Thinking, "")
165 if err != nil {165 if err != nil {
166- return toolErr("swarm_spawn: profile " + profile.Name + ": " + err.Error()), nil166+ return protocolToolError("swarm_spawn: profile " + profile.Name + ": " + err.Error())
167 }167 }
168 }168 }
169 if reasoning == "" && t.DefaultReasoning != nil {169 if reasoning == "" && t.DefaultReasoning != nil {
170 reasoning, err = reasoningOverride(t.DefaultReasoning(), "")170 reasoning, err = reasoningOverride(t.DefaultReasoning(), "")
171 if err != nil {171 if err != nil {
172- return toolErr("swarm_spawn: host " + err.Error()), nil172+ return protocolToolError("swarm_spawn: host " + err.Error())
173 }173 }
174 }174 }
175 175
@@ -241,6 +241,13 @@ func reasoningOverride(reasoning, thinking string) (string, error) {
241 }241 }
242 }242 }
243 243
244+// protocolToolError keeps model-visible validation failures in the
245+// ToolResult channel rather than treating them as host execution errors.
246+func protocolToolError(msg string) (core.ToolResult, error) {
247+ //nolint:nilerr // ToolResult.IsError is the established tool protocol.
248+ return toolErr(msg), nil
249+}
250+
244 func toolErr(msg string) core.ToolResult {251 func toolErr(msg string) core.ToolResult {
245 return core.ToolResult{252 return core.ToolResult{
246 Content: []provider.Content{provider.TextBlock{Text: msg}},253 Content: []provider.Content{provider.TextBlock{Text: msg}},

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 477

↑↓ moveenter openesc close