@namzu/cli 18.1.0 → 20.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/CHANGELOG.md +5951 -0
  2. package/README.md +23 -24
  3. package/dist/bin.js +12 -1
  4. package/dist/bin.js.map +1 -1
  5. package/dist/checkpoints/store.d.ts +75 -0
  6. package/dist/checkpoints/store.d.ts.map +1 -0
  7. package/dist/checkpoints/store.js +177 -0
  8. package/dist/checkpoints/store.js.map +1 -0
  9. package/dist/checkpoints/wrap.d.ts +15 -0
  10. package/dist/checkpoints/wrap.d.ts.map +1 -0
  11. package/dist/checkpoints/wrap.js +37 -0
  12. package/dist/checkpoints/wrap.js.map +1 -0
  13. package/dist/cli.d.ts +6 -0
  14. package/dist/cli.d.ts.map +1 -1
  15. package/dist/cli.js +12 -1
  16. package/dist/cli.js.map +1 -1
  17. package/dist/commands/acp.d.ts.map +1 -1
  18. package/dist/commands/acp.js +2 -0
  19. package/dist/commands/acp.js.map +1 -1
  20. package/dist/commands/drain.d.ts.map +1 -1
  21. package/dist/commands/drain.js +15 -2
  22. package/dist/commands/drain.js.map +1 -1
  23. package/dist/commands/provider-wait.d.ts +44 -0
  24. package/dist/commands/provider-wait.d.ts.map +1 -0
  25. package/dist/commands/provider-wait.js +65 -0
  26. package/dist/commands/provider-wait.js.map +1 -0
  27. package/dist/commands/run-flags.d.ts +13 -1
  28. package/dist/commands/run-flags.d.ts.map +1 -1
  29. package/dist/commands/run-flags.js +41 -0
  30. package/dist/commands/run-flags.js.map +1 -1
  31. package/dist/commands/run-stream.d.ts.map +1 -1
  32. package/dist/commands/run-stream.js +33 -13
  33. package/dist/commands/run-stream.js.map +1 -1
  34. package/dist/commands/run.d.ts.map +1 -1
  35. package/dist/commands/run.js +132 -78
  36. package/dist/commands/run.js.map +1 -1
  37. package/dist/commands/stdin.d.ts +17 -0
  38. package/dist/commands/stdin.d.ts.map +1 -0
  39. package/dist/commands/stdin.js +69 -0
  40. package/dist/commands/stdin.js.map +1 -0
  41. package/dist/config/load.d.ts.map +1 -1
  42. package/dist/config/load.js +107 -2
  43. package/dist/config/load.js.map +1 -1
  44. package/dist/config/schema.d.ts +80 -5
  45. package/dist/config/schema.d.ts.map +1 -1
  46. package/dist/config/schema.js.map +1 -1
  47. package/dist/context/doctrine.d.ts +16 -38
  48. package/dist/context/doctrine.d.ts.map +1 -1
  49. package/dist/context/doctrine.js +16 -75
  50. package/dist/context/doctrine.js.map +1 -1
  51. package/dist/context/environment.d.ts +2 -0
  52. package/dist/context/environment.d.ts.map +1 -1
  53. package/dist/context/environment.js +3 -0
  54. package/dist/context/environment.js.map +1 -1
  55. package/dist/integrations/mcp/servers.d.ts +19 -0
  56. package/dist/integrations/mcp/servers.d.ts.map +1 -1
  57. package/dist/integrations/mcp/servers.js +23 -2
  58. package/dist/integrations/mcp/servers.js.map +1 -1
  59. package/dist/integrations/plugins/runtime.d.ts +8 -2
  60. package/dist/integrations/plugins/runtime.d.ts.map +1 -1
  61. package/dist/integrations/plugins/runtime.js +24 -12
  62. package/dist/integrations/plugins/runtime.js.map +1 -1
  63. package/dist/integrations/providers/chain-capabilities.js +1 -1
  64. package/dist/integrations/providers/chain-capabilities.js.map +1 -1
  65. package/dist/integrations/providers/credential-store.d.ts +1 -1
  66. package/dist/integrations/providers/credential-store.js +1 -1
  67. package/dist/integrations/providers/harness-credentials.d.ts.map +1 -1
  68. package/dist/integrations/providers/harness-credentials.js +7 -0
  69. package/dist/integrations/providers/harness-credentials.js.map +1 -1
  70. package/dist/integrations/providers/registry.d.ts +2 -2
  71. package/dist/integrations/providers/registry.js +2 -2
  72. package/dist/integrations/sessions/store.d.ts +14 -22
  73. package/dist/integrations/sessions/store.d.ts.map +1 -1
  74. package/dist/integrations/sessions/store.js +78 -175
  75. package/dist/integrations/sessions/store.js.map +1 -1
  76. package/dist/integrations/sessions/transcript-export.d.ts.map +1 -1
  77. package/dist/integrations/sessions/transcript-export.js +6 -2
  78. package/dist/integrations/sessions/transcript-export.js.map +1 -1
  79. package/dist/integrations/state/identity.d.ts +21 -0
  80. package/dist/integrations/state/identity.d.ts.map +1 -0
  81. package/dist/integrations/state/identity.js +49 -0
  82. package/dist/integrations/state/identity.js.map +1 -0
  83. package/dist/integrations/state/immutable-json.d.ts +10 -0
  84. package/dist/integrations/state/immutable-json.d.ts.map +1 -0
  85. package/dist/integrations/state/immutable-json.js +38 -0
  86. package/dist/integrations/state/immutable-json.js.map +1 -0
  87. package/dist/integrations/state/project.d.ts +10 -0
  88. package/dist/integrations/state/project.d.ts.map +1 -0
  89. package/dist/integrations/state/project.js +15 -0
  90. package/dist/integrations/state/project.js.map +1 -0
  91. package/dist/integrations/state/report.d.ts.map +1 -1
  92. package/dist/integrations/state/report.js +42 -12
  93. package/dist/integrations/state/report.js.map +1 -1
  94. package/dist/integrations/subagents/definitions.d.ts +19 -0
  95. package/dist/integrations/subagents/definitions.d.ts.map +1 -0
  96. package/dist/integrations/subagents/definitions.js +26 -0
  97. package/dist/integrations/subagents/definitions.js.map +1 -0
  98. package/dist/integrations/subagents/parent.d.ts +13 -0
  99. package/dist/integrations/subagents/parent.d.ts.map +1 -0
  100. package/dist/integrations/subagents/parent.js +57 -0
  101. package/dist/integrations/subagents/parent.js.map +1 -0
  102. package/dist/integrations/subagents/runtime.d.ts +33 -16
  103. package/dist/integrations/subagents/runtime.d.ts.map +1 -1
  104. package/dist/integrations/subagents/runtime.js +450 -150
  105. package/dist/integrations/subagents/runtime.js.map +1 -1
  106. package/dist/memory/presentation.d.ts +9 -0
  107. package/dist/memory/presentation.d.ts.map +1 -0
  108. package/dist/memory/presentation.js +37 -0
  109. package/dist/memory/presentation.js.map +1 -0
  110. package/dist/memory/store.d.ts +35 -15
  111. package/dist/memory/store.d.ts.map +1 -1
  112. package/dist/memory/store.js +79 -25
  113. package/dist/memory/store.js.map +1 -1
  114. package/dist/permissions/mode.d.ts +15 -45
  115. package/dist/permissions/mode.d.ts.map +1 -1
  116. package/dist/permissions/mode.js +36 -13
  117. package/dist/permissions/mode.js.map +1 -1
  118. package/dist/release-notes.d.ts +20 -0
  119. package/dist/release-notes.d.ts.map +1 -0
  120. package/dist/release-notes.js +64 -0
  121. package/dist/release-notes.js.map +1 -0
  122. package/dist/tui/AgentExplorer.d.ts +1 -1
  123. package/dist/tui/AgentExplorer.d.ts.map +1 -1
  124. package/dist/tui/AgentExplorer.js +25 -21
  125. package/dist/tui/AgentExplorer.js.map +1 -1
  126. package/dist/tui/App.d.ts.map +1 -1
  127. package/dist/tui/App.js +701 -245
  128. package/dist/tui/App.js.map +1 -1
  129. package/dist/tui/BrandHeader.d.ts +12 -0
  130. package/dist/tui/BrandHeader.d.ts.map +1 -0
  131. package/dist/tui/BrandHeader.js +18 -0
  132. package/dist/tui/BrandHeader.js.map +1 -0
  133. package/dist/tui/ChoicePicker.d.ts +11 -1
  134. package/dist/tui/ChoicePicker.d.ts.map +1 -1
  135. package/dist/tui/ChoicePicker.js +46 -13
  136. package/dist/tui/ChoicePicker.js.map +1 -1
  137. package/dist/tui/Composer.d.ts +5 -2
  138. package/dist/tui/Composer.d.ts.map +1 -1
  139. package/dist/tui/Composer.js +6 -11
  140. package/dist/tui/Composer.js.map +1 -1
  141. package/dist/tui/ComposerFrame.d.ts +10 -0
  142. package/dist/tui/ComposerFrame.d.ts.map +1 -0
  143. package/dist/tui/ComposerFrame.js +93 -0
  144. package/dist/tui/ComposerFrame.js.map +1 -0
  145. package/dist/tui/EditPromptPicker.d.ts.map +1 -1
  146. package/dist/tui/EditPromptPicker.js +1 -1
  147. package/dist/tui/EditPromptPicker.js.map +1 -1
  148. package/dist/tui/LiveActivity.d.ts +12 -5
  149. package/dist/tui/LiveActivity.d.ts.map +1 -1
  150. package/dist/tui/LiveActivity.js +17 -32
  151. package/dist/tui/LiveActivity.js.map +1 -1
  152. package/dist/tui/PermissionOverlay.d.ts +26 -2
  153. package/dist/tui/PermissionOverlay.d.ts.map +1 -1
  154. package/dist/tui/PermissionOverlay.js +109 -16
  155. package/dist/tui/PermissionOverlay.js.map +1 -1
  156. package/dist/tui/Picker.d.ts +2 -2
  157. package/dist/tui/Picker.d.ts.map +1 -1
  158. package/dist/tui/Picker.js +82 -52
  159. package/dist/tui/Picker.js.map +1 -1
  160. package/dist/tui/StatusBar.d.ts.map +1 -1
  161. package/dist/tui/StatusBar.js +5 -2
  162. package/dist/tui/StatusBar.js.map +1 -1
  163. package/dist/tui/TaskList.d.ts +3 -1
  164. package/dist/tui/TaskList.d.ts.map +1 -1
  165. package/dist/tui/TaskList.js +13 -4
  166. package/dist/tui/TaskList.js.map +1 -1
  167. package/dist/tui/Transcript.d.ts +4 -12
  168. package/dist/tui/Transcript.d.ts.map +1 -1
  169. package/dist/tui/Transcript.js +45 -75
  170. package/dist/tui/Transcript.js.map +1 -1
  171. package/dist/tui/agent.d.ts +116 -51
  172. package/dist/tui/agent.d.ts.map +1 -1
  173. package/dist/tui/agent.js +539 -307
  174. package/dist/tui/agent.js.map +1 -1
  175. package/dist/tui/choice-selection.d.ts +17 -0
  176. package/dist/tui/choice-selection.d.ts.map +1 -0
  177. package/dist/tui/choice-selection.js +38 -0
  178. package/dist/tui/choice-selection.js.map +1 -0
  179. package/dist/tui/exit-summary.d.ts +7 -1
  180. package/dist/tui/exit-summary.d.ts.map +1 -1
  181. package/dist/tui/exit-summary.js +12 -3
  182. package/dist/tui/exit-summary.js.map +1 -1
  183. package/dist/tui/index.d.ts +3 -1
  184. package/dist/tui/index.d.ts.map +1 -1
  185. package/dist/tui/index.js +5 -3
  186. package/dist/tui/index.js.map +1 -1
  187. package/dist/tui/logo.d.ts +7 -22
  188. package/dist/tui/logo.d.ts.map +1 -1
  189. package/dist/tui/logo.js +8 -34
  190. package/dist/tui/logo.js.map +1 -1
  191. package/dist/tui/model-choices.d.ts.map +1 -1
  192. package/dist/tui/model-choices.js +21 -6
  193. package/dist/tui/model-choices.js.map +1 -1
  194. package/dist/tui/permission-review.d.ts +15 -4
  195. package/dist/tui/permission-review.d.ts.map +1 -1
  196. package/dist/tui/permission-review.js +48 -9
  197. package/dist/tui/permission-review.js.map +1 -1
  198. package/dist/tui/provider-selection.d.ts +4 -0
  199. package/dist/tui/provider-selection.d.ts.map +1 -0
  200. package/dist/tui/provider-selection.js +16 -0
  201. package/dist/tui/provider-selection.js.map +1 -0
  202. package/dist/tui/run-interruption.d.ts +3 -2
  203. package/dist/tui/run-interruption.d.ts.map +1 -1
  204. package/dist/tui/run-interruption.js +2 -1
  205. package/dist/tui/run-interruption.js.map +1 -1
  206. package/dist/tui/shell-escape.d.ts +42 -0
  207. package/dist/tui/shell-escape.d.ts.map +1 -0
  208. package/dist/tui/shell-escape.js +110 -0
  209. package/dist/tui/shell-escape.js.map +1 -0
  210. package/dist/tui/slashCommands.d.ts +82 -31
  211. package/dist/tui/slashCommands.d.ts.map +1 -1
  212. package/dist/tui/slashCommands.js +518 -306
  213. package/dist/tui/slashCommands.js.map +1 -1
  214. package/dist/tui/terminal-choice-text.d.ts +8 -0
  215. package/dist/tui/terminal-choice-text.d.ts.map +1 -0
  216. package/dist/tui/terminal-choice-text.js +37 -0
  217. package/dist/tui/terminal-choice-text.js.map +1 -0
  218. package/dist/tui/theme.d.ts +3 -4
  219. package/dist/tui/theme.d.ts.map +1 -1
  220. package/dist/tui/theme.js +16 -17
  221. package/dist/tui/theme.js.map +1 -1
  222. package/dist/tui/types.d.ts +9 -13
  223. package/dist/tui/types.d.ts.map +1 -1
  224. package/package.json +7 -5
@@ -3,56 +3,36 @@
3
3
  * DEFINE a specialist on the fly — pass a `role` (the persona / system prompt)
4
4
  * and namzu spins up a fresh sub-agent with that role at runtime, no
5
5
  * pre-registered definition needed. Omit `role` for a general-purpose one.
6
- * The call blocks until the child finishes and its final text returns as the
7
- * tool result, so a delegation surfaces in the transcript as a normal
8
- * `⏺ Agent(...)` call.
6
+ * The call waits for a final result unless operator input releases the wait.
7
+ * The child then stays owned by the parent run, and its completion reaches the
8
+ * same query's inbox. Headless callers without an input waiter remain blocking.
9
9
  *
10
10
  * The runtime is fully self-contained: a dedicated in-memory session/thread
11
11
  * store backs the AgentManager, so sub-agent bookkeeping never touches the
12
12
  * CLI's on-disk `/resume` conversation store.
13
13
  */
14
- import { AgentManager, AgentRegistry, DefaultCapacityValidator, InMemorySessionStore, InMemoryTopicStore, LocalTaskScheduler, ReactiveAgent, RunCancelled, SessionSummaryMaterializer, ToolRegistry, TopicManager, WorkspaceBackendRegistry, asRunId, asSummaryId, asTenantId, asUserId, defineTool, mcpJsonSchemaToZod, } from '@namzu/sdk';
14
+ import { AgentManager, AgentRegistry, CompletionInbox, DefaultCapacityValidator, EXPLORE_AGENT_DESCRIPTION, EXPLORE_AGENT_ID, EXPLORE_AGENT_PROMPT, InMemorySessionStore, InMemoryTopicStore, LocalTaskScheduler, ReactiveAgent, RunCancelled, SessionSummaryMaterializer, TopicArchivedError, TopicManager, WorkspaceBackendRegistry, asTaskId, defineTool, filterReadOnlyTools, filterToolsNamed, generateSummaryId, isTerminalAgentTaskState, mcpJsonSchemaToZod, openTokenBudget, requireOpenProject, } from '@namzu/sdk';
15
15
  import { NAMZU_WORKING_DOCTRINE } from '../../context/doctrine.js';
16
16
  import { MAX_AGENT_ACTIVITY_LABEL_CODE_UNITS, MAX_AGENT_PHASE_ORDER, SubagentActivityMonitor, } from './activity.js';
17
17
  import { CLI_INTERACTIVE_RUN_TIMEOUT_MS } from './policy.js';
18
18
  export const GENERAL_PURPOSE_SUBAGENT = 'general-purpose';
19
19
  /**
20
- * A sub-agent that can only look.
21
- *
22
- * "Find where X is defined", "which files reference Y", "how does Z work" are
23
- * the delegations a parent makes most, and every one of them ran with
24
- * `write`, `edit` and `bash` in the child's roster — so a lookup needed the
25
- * same approvals as a change, and an operator in `accept-edits` mode was
26
- * still asked about a child's shell command that only wanted to `cat`. This
27
- * definition's registry is the parent's working set filtered to tools that
28
- * declare themselves read-only, so a child that cannot change anything is
29
- * never asked whether it may.
20
+ * The kernel's read-only delegate, with this application's doctrine under
21
+ * its prompt. Roster and prompt come from `@namzu/sdk`; what is added here is
22
+ * the same working rules every namzu child gets.
30
23
  */
31
- export const EXPLORE_SUBAGENT = 'explore';
32
- const EXPLORE_PROMPT = [
33
- 'You are a read-only sub-agent dispatched by namzu to find things out: where something is defined, which files reference it, how a piece of code works, what a directory contains.',
34
- 'You cannot see the parent conversation — work only from the prompt you were given.',
35
- 'You have reading and searching tools only. You cannot edit, write or run commands, and you must not describe a change as made.',
36
- 'Search broadly first, then read what matters. Report file paths with line numbers, quote the relevant lines, and say plainly what you did not find.',
37
- 'End with a concise answer the parent can act on; do not ask the parent questions.',
38
- '',
39
- NAMZU_WORKING_DOCTRINE,
40
- ].join('\n');
24
+ export const EXPLORE_SUBAGENT = EXPLORE_AGENT_ID;
25
+ const EXPLORE_PROMPT = [EXPLORE_AGENT_PROMPT, '', NAMZU_WORKING_DOCTRINE].join('\n');
41
26
  /**
42
- * The parent's roster, minus everything that can change state.
43
- *
44
- * Decided by each tool's own `isReadOnly` with no input — the declaration
45
- * `defineTool` records — rather than by a name list here, so a new read-only
46
- * builtin joins the explore roster without this file learning its name and
47
- * a tool that stops being read-only leaves it the same way.
27
+ * A file-defined agent's roster: the parent's working set, kept to the
28
+ * file's `tools` allowlist when there is one, and to read-only tools when
29
+ * the file says `readOnly: true`. Intersection, never union.
48
30
  */
49
- function readOnlyToolsFrom(source) {
50
- const filtered = new ToolRegistry();
51
- for (const tool of source.getAll()) {
52
- if (tool.isReadOnly?.(undefined) === true)
53
- filtered.register(tool);
54
- }
55
- return filtered;
31
+ function fileAgentTools(definition, opts) {
32
+ return () => {
33
+ const source = definition.readOnly ? filterReadOnlyTools(opts.buildTools()) : opts.buildTools();
34
+ return definition.tools ? filterToolsNamed(source, definition.tools) : source;
35
+ };
56
36
  }
57
37
  const SUBAGENT_PROMPT = [
58
38
  'You are a focused sub-agent dispatched by namzu to complete one self-contained task and report back.',
@@ -70,6 +50,39 @@ const SUBAGENT_PROMPT = [
70
50
  // the parent does not is the same defect in the other direction.
71
51
  NAMZU_WORKING_DOCTRINE,
72
52
  ].join('\n');
53
+ /** Recheck the invoking run before admitting work through a retained gateway. */
54
+ class ParentTaskScheduler extends LocalTaskScheduler {
55
+ validateParent;
56
+ constructor(manager, context, onEvent, validateParent) {
57
+ super(manager, context, onEvent);
58
+ this.validateParent = validateParent;
59
+ }
60
+ async createTask(options) {
61
+ await this.validateParent();
62
+ return super.createTask(options);
63
+ }
64
+ owns(taskId) {
65
+ return super.listTasks().some((task) => task.taskId === taskId);
66
+ }
67
+ getTask(taskId) {
68
+ return this.owns(taskId) ? super.getTask(taskId) : undefined;
69
+ }
70
+ cancelTask(taskId, cause) {
71
+ if (this.owns(taskId))
72
+ super.cancelTask(taskId, cause);
73
+ }
74
+ async waitForTask(taskId) {
75
+ if (!this.owns(taskId))
76
+ throw new Error(`Task ${taskId} does not belong to this parent run`);
77
+ return super.waitForTask(taskId);
78
+ }
79
+ async continueTask(taskId, message) {
80
+ if (!this.owns(taskId))
81
+ throw new Error(`Task ${taskId} does not belong to this parent run`);
82
+ await this.validateParent();
83
+ await super.continueTask(taskId, message);
84
+ }
85
+ }
73
86
  /** A delegated run may never inherit the SDK's headless auto-approval fallback. */
74
87
  const refuseUnownedChildReview = async () => ({
75
88
  action: 'abort',
@@ -77,63 +90,216 @@ const refuseUnownedChildReview = async () => ({
77
90
  });
78
91
  /**
79
92
  * Stand up the AgentManager + gateway + `Agent` tool. Returns the tool to
80
- * register on the parent and the gateway to pass to `query({ taskGateway })`.
93
+ * register on the parent and the gateway to pass to `query({ taskScheduler })`.
81
94
  */
82
95
  export async function createSubagentRuntime(opts) {
83
- const tenantId = asTenantId('tnt_namzu-cli');
84
- const store = new InMemorySessionStore();
85
- const topicStore = new InMemoryTopicStore();
86
- const userActor = {
87
- kind: 'user',
88
- userId: asUserId('usr_namzu'),
89
- tenantId,
90
- };
91
- const project = await store.createProject({ tenantId, name: 'namzu-cli' }, tenantId);
92
- const thread = await topicStore.createTopic({ projectId: project.id, title: 'namzu-cli' }, tenantId);
93
- // No workspace gate here, and that is a finding rather than an omission.
94
- //
95
- // A direct `createSession` bypasses `requireOpenProject`, which is why the
96
- // CLI's persistent conversation store now calls it explicitly. This site is
97
- // the other shape: the store is a fresh `InMemorySessionStore` built four
98
- // lines up, the project was created two lines up, and neither outlives this
99
- // runtime — so the id can never be one an owner has closed. A gate on a path
100
- // that always creates its own project checks a condition that cannot be
101
- // false, and a check that cannot fail teaches the next reader nothing except
102
- // that gates here are decoration.
103
- //
104
- // The trigger to add one is the day this store is replaced by a persistent
105
- // one, or the project id starts arriving from a caller.
106
- const parentSession = await store.createSession({ topicId: thread.id, projectId: project.id, currentActor: userActor }, tenantId);
107
- await store.updateSession({ ...parentSession, status: 'active' }, tenantId);
108
- let summaryCounter = 0;
109
- const materializer = new SessionSummaryMaterializer({
110
- store,
111
- generateSummaryId: () => asSummaryId(`sum_namzu_${++summaryCounter}`),
112
- });
113
96
  const registry = new AgentRegistry();
114
97
  registry.register(buildDefinition(GENERAL_PURPOSE_SUBAGENT, 'A general-purpose sub-agent.', SUBAGENT_PROMPT, opts));
115
- registry.register(buildDefinition(EXPLORE_SUBAGENT, 'A read-only sub-agent for finding files, symbols and answers.', EXPLORE_PROMPT, opts, () => readOnlyToolsFrom(opts.buildTools())));
116
- const topicManager = new TopicManager({ topicStore, sessionStore: store });
117
- const manager = new AgentManager(registry, { childTimeoutMs: CLI_INTERACTIVE_RUN_TIMEOUT_MS }, {
118
- sessionStore: store,
119
- summaryMaterializer: materializer,
120
- workspaceRegistry: new WorkspaceBackendRegistry(),
121
- capacity: new DefaultCapacityValidator(store),
122
- topicManager,
123
- });
124
- const taskContext = {
125
- parentRunId: asRunId('run_namzu-cli'),
126
- parentAgentId: 'namzu',
127
- parentAbortController: new AbortController(),
128
- depth: 0,
129
- budgetTracker: { total: 1_000_000, remaining: 1_000_000 },
130
- tenantId,
131
- topicId: thread.id,
132
- sessionId: parentSession.id,
133
- projectId: project.id,
134
- parentActor: userActor,
98
+ registry.register(buildDefinition(EXPLORE_SUBAGENT, EXPLORE_AGENT_DESCRIPTION, EXPLORE_PROMPT, opts, () => filterReadOnlyTools(opts.buildTools())));
99
+ // Agents a project or user defined in a file, each a type of its own.
100
+ // The roster is the file's allowlist intersected with the parent's set —
101
+ // a file cannot grant a tool the parent does not have — narrowed further
102
+ // to read-only when the file says so. The prompt is the file's body over
103
+ // the same sub-agent base every child gets.
104
+ const fileAgents = new Map();
105
+ for (const definition of opts.definitions ?? []) {
106
+ fileAgents.set(definition.name, definition);
107
+ registry.register(buildDefinition(definition.name, definition.description, definition.prompt, opts, fileAgentTools(definition, opts), definition.model ?? opts.model));
108
+ }
109
+ const agentTypeIds = [GENERAL_PURPOSE_SUBAGENT, EXPLORE_SUBAGENT, ...fileAgents.keys()];
110
+ const fileAgentSummary = [...fileAgents.values()]
111
+ .map((definition) => `"${definition.name}" — ${definition.description}`)
112
+ .join('; ');
113
+ const parents = new Map();
114
+ const sessions = new Map();
115
+ let closed = false;
116
+ const sessionKey = ({ project, sessionId }) => JSON.stringify([project.tenantId, project.id, sessionId]);
117
+ const resolveParent = async (runId) => {
118
+ const parent = structuredClone(await opts.resolveParent(runId));
119
+ const { project, topic } = parent;
120
+ if (topic.projectId !== project.id || topic.tenantId !== project.tenantId) {
121
+ throw new Error('Delegation parent topic does not belong to its project and tenant');
122
+ }
123
+ await requireOpenProject({ getProject: async () => project }, project.id, project.tenantId, 'spawn');
124
+ if (topic.status === 'archived')
125
+ throw new TopicArchivedError({ topicId: topic.id, op: 'spawn' });
126
+ return parent;
127
+ };
128
+ const createSessionRuntime = async (parent) => {
129
+ const { project, topic, sessionId } = parent;
130
+ const tenantId = project.tenantId;
131
+ const store = new InMemorySessionStore([project]);
132
+ const topicStore = new InMemoryTopicStore([topic]);
133
+ const parentActor = { kind: 'agent', agentId: 'namzu', tenantId };
134
+ const session = await store.createSession({
135
+ id: sessionId,
136
+ topicId: topic.id,
137
+ projectId: project.id,
138
+ currentActor: parentActor,
139
+ }, tenantId);
140
+ await store.updateSession({ ...session, status: 'active' }, tenantId);
141
+ const manager = new AgentManager(registry, { childTimeoutMs: CLI_INTERACTIVE_RUN_TIMEOUT_MS }, {
142
+ sessionStore: store,
143
+ summaryMaterializer: new SessionSummaryMaterializer({
144
+ store,
145
+ generateSummaryId,
146
+ }),
147
+ workspaceRegistry: new WorkspaceBackendRegistry(),
148
+ capacity: new DefaultCapacityValidator(store),
149
+ topicManager: new TopicManager({ topicStore, sessionStore: store }),
150
+ });
151
+ return {
152
+ manager,
153
+ store,
154
+ topicId: topic.id,
155
+ projectUpdatedAt: project.updatedAt.getTime(),
156
+ };
157
+ };
158
+ const refreshLimits = async (shared, parent) => {
159
+ if (shared.topicId !== parent.topic.id)
160
+ throw new Error('Delegation parent changed its topic while runs are active');
161
+ // A slow metadata read must not overwrite a newer project snapshot.
162
+ const updatedAt = parent.project.updatedAt.getTime();
163
+ if (updatedAt < shared.projectUpdatedAt)
164
+ return;
165
+ shared.projectUpdatedAt = updatedAt;
166
+ await shared.store.updateProject(parent.project.id, parent.project.config, parent.project.tenantId);
167
+ };
168
+ const acquireSession = async (parent) => {
169
+ const key = sessionKey(parent);
170
+ let entry = sessions.get(key);
171
+ if (!entry) {
172
+ entry = { ready: createSessionRuntime(parent), owners: 0 };
173
+ sessions.set(key, entry);
174
+ }
175
+ entry.owners++;
176
+ const ownedEntry = entry;
177
+ let released = false;
178
+ const release = () => {
179
+ if (released)
180
+ return;
181
+ released = true;
182
+ ownedEntry.owners--;
183
+ if (ownedEntry.owners !== 0)
184
+ return;
185
+ if (sessions.get(key) === ownedEntry)
186
+ sessions.delete(key);
187
+ void ownedEntry.ready.then((shared) => shared.manager.dispose(), () => undefined);
188
+ };
189
+ try {
190
+ const shared = await entry.ready;
191
+ await refreshLimits(shared, parent);
192
+ return { shared, release };
193
+ }
194
+ catch (error) {
195
+ release();
196
+ throw error;
197
+ }
198
+ };
199
+ const gatewayForRun = async (runId) => {
200
+ if (closed)
201
+ throw new Error('Sub-agent runtime is closed');
202
+ let pending = parents.get(runId);
203
+ if (!pending) {
204
+ pending = (async () => {
205
+ const parent = await resolveParent(runId);
206
+ const budget = await openTokenBudget({
207
+ scope: {
208
+ tenantId: parent.project.tenantId,
209
+ projectId: parent.project.id,
210
+ sessionId: parent.sessionId,
211
+ runId,
212
+ },
213
+ limit: opts.tokenBudget ?? 1_000_000,
214
+ pathBuilder: opts.pathBuilder,
215
+ workingDirectory: opts.cwd,
216
+ });
217
+ const lease = await acquireSession(parent);
218
+ const { manager } = lease.shared;
219
+ const parentAbortController = new AbortController();
220
+ let released = false;
221
+ const assertOwned = () => {
222
+ if (closed ||
223
+ released ||
224
+ parentAbortController.signal.aborted ||
225
+ parents.get(runId) !== pending) {
226
+ throw new Error(`Parent run ${runId} was released`);
227
+ }
228
+ };
229
+ const taskContext = {
230
+ parentRunId: runId,
231
+ parentAgentId: 'namzu',
232
+ parentAbortController,
233
+ depth: 0,
234
+ budget,
235
+ tenantId: parent.project.tenantId,
236
+ topicId: parent.topic.id,
237
+ sessionId: parent.sessionId,
238
+ projectId: parent.project.id,
239
+ parentActor: {
240
+ kind: 'agent',
241
+ agentId: 'namzu',
242
+ tenantId: parent.project.tenantId,
243
+ },
244
+ };
245
+ const gateway = new ParentTaskScheduler(manager, taskContext, opts.onEvent, async () => {
246
+ assertOwned();
247
+ const current = await resolveParent(runId);
248
+ assertOwned();
249
+ if (sessionKey(current) !== sessionKey(parent))
250
+ throw new Error('Delegation run changed its parent scope');
251
+ await refreshLimits(lease.shared, current);
252
+ assertOwned();
253
+ });
254
+ const completionInbox = new CompletionInbox();
255
+ completionInbox.attach(gateway);
256
+ const runtime = {
257
+ gateway,
258
+ completionInbox,
259
+ close() {
260
+ if (released)
261
+ return;
262
+ released = true;
263
+ parentAbortController.abort(new RunCancelled('parent'));
264
+ manager.cancelAll(runId, 'parent');
265
+ completionInbox.close();
266
+ lease.release();
267
+ },
268
+ };
269
+ if (closed) {
270
+ runtime.close();
271
+ throw new Error('Sub-agent runtime is closed');
272
+ }
273
+ return runtime;
274
+ })();
275
+ parents.set(runId, pending);
276
+ }
277
+ try {
278
+ const runtime = await pending;
279
+ if (closed || parents.get(runId) !== pending)
280
+ throw new Error(`Parent run ${runId} was released`);
281
+ return runtime.gateway;
282
+ }
283
+ catch (error) {
284
+ if (parents.get(runId) === pending)
285
+ parents.delete(runId);
286
+ throw error;
287
+ }
288
+ };
289
+ const releaseRun = async (runId) => {
290
+ const pending = parents.get(runId);
291
+ if (!pending)
292
+ return;
293
+ parents.delete(runId);
294
+ await pending.then((runtime) => runtime.close(), () => undefined);
295
+ };
296
+ const completionInboxForRun = async (runId) => {
297
+ await gatewayForRun(runId);
298
+ const pending = parents.get(runId);
299
+ if (!pending)
300
+ throw new Error(`Parent run ${runId} was released`);
301
+ return (await pending).completionInbox;
135
302
  };
136
- const gateway = new LocalTaskScheduler(manager, taskContext, opts.onEvent);
137
303
  const activity = new SubagentActivityMonitor();
138
304
  // Dynamic `Agent` tool: the model passes an optional `role` (the persona /
139
305
  // system prompt) and we register + spawn a fresh specialist for it at call
@@ -141,19 +307,21 @@ export async function createSubagentRuntime(opts) {
141
307
  let dynCounter = 0;
142
308
  const agentTool = defineTool({
143
309
  name: 'Agent',
144
- description: 'Delegate a self-contained task to a sub-agent and get its result back (BLOCKING). ' +
145
- 'Pick `subagent_type: "explore"` for anything that only needs to look — where is X defined, which ' +
146
- 'files reference Y, how does Z work — it has reading and searching tools only and never asks for ' +
147
- 'permission. Use the default "general-purpose" when the task must change files or run commands. ' +
148
- 'Define a specialist inline with `role` — a system prompt describing who the sub-agent ' +
149
- 'is and how to behave (e.g. "You are a security auditor; flag vulnerabilities and rate severity"); ' +
150
- 'with `subagent_type: "explore"` the role keeps the read-only roster. ' +
151
- 'Omit `role` for the plain sub-agent. The sub-agent runs in its own context with its own ' +
152
- 'tools and cannot see this conversation — put everything it needs in `prompt`. Call this multiple ' +
153
- 'times in one response to run specialists in parallel. When coordinating several specialists, give ' +
154
- 'them the same `workflow` label and an explicit `phase` plus `phase_order` so the operator can follow ' +
155
- 'the work in the agent cockpit. These fields are display annotations only; they do not create ' +
156
- 'dependencies, barriers, or serial execution.',
310
+ description: [
311
+ 'Delegate a self-contained task to a sub-agent and wait for its result. If the operator sends a message while you wait, this call returns the running task ID so you can respond; the child keeps working and its result arrives later as a task notification.',
312
+ 'Pick `subagent_type: "explore"` for anything that only needs to look — where is X defined, which files reference Y, how does Z work — it has reading and searching tools only and never asks for permission.',
313
+ 'Use the default "general-purpose" when the task must change files or run commands.',
314
+ 'Define a specialist inline with `role` — a system prompt describing who the sub-agent is and how to behave (e.g.',
315
+ '"You are a security auditor; flag vulnerabilities and rate severity"); with `subagent_type: "explore"` the role keeps the read-only roster.',
316
+ 'Omit `role` for the plain sub-agent.',
317
+ 'The sub-agent runs in its own context with its own tools and cannot see this conversation — put everything it needs in `prompt`.',
318
+ 'Call this multiple times in one response to run specialists in parallel.',
319
+ 'When coordinating several specialists, give them the same `workflow` label and an explicit `phase` plus `phase_order` so the operator can follow the work in the agent cockpit.',
320
+ 'These fields are display annotations only; they do not create dependencies, barriers, or serial execution.',
321
+ fileAgentSummary.length > 0 ? `Project-defined types: ${fileAgentSummary}.` : '',
322
+ ]
323
+ .filter((part) => part.length > 0)
324
+ .join(' '),
157
325
  inputSchema: mcpJsonSchemaToZod({
158
326
  type: 'object',
159
327
  properties: {
@@ -167,8 +335,10 @@ export async function createSubagentRuntime(opts) {
167
335
  },
168
336
  subagent_type: {
169
337
  type: 'string',
170
- enum: [GENERAL_PURPOSE_SUBAGENT, EXPLORE_SUBAGENT],
171
- description: '"explore" for read-only lookups (find, search, explain); "general-purpose" (default) when the task changes files or runs commands.',
338
+ enum: agentTypeIds,
339
+ description: `"explore" for read-only lookups (find, search, explain); "general-purpose" (default) when the task changes files or runs commands.${fileAgentSummary.length > 0
340
+ ? ` This project also defines: ${fileAgentSummary}. Prefer a project-defined type when its description fits the task.`
341
+ : ''}`,
172
342
  },
173
343
  role: {
174
344
  type: 'string',
@@ -202,15 +372,22 @@ export async function createSubagentRuntime(opts) {
202
372
  async execute(input, context) {
203
373
  const { description, prompt, subagent_type, role, workflow, phase, phase_order } = input;
204
374
  const explore = subagent_type === EXPLORE_SUBAGENT;
205
- let agentId = explore ? EXPLORE_SUBAGENT : GENERAL_PURPOSE_SUBAGENT;
375
+ const fileAgent = subagent_type !== undefined ? fileAgents.get(subagent_type) : undefined;
376
+ let agentId = fileAgent?.name ?? (explore ? EXPLORE_SUBAGENT : GENERAL_PURPOSE_SUBAGENT);
206
377
  const persona = typeof role === 'string' ? role.trim() : '';
207
378
  const dynamic = persona.length > 0;
208
379
  if (dynamic) {
209
- // A role on top of explore keeps explore's roster: the persona says
210
- // who the child is, the type says what it may touch, and a role
211
- // must not be a way to hand a read-only child a `write` tool.
380
+ // A role on top of a type keeps that type's roster and model: the
381
+ // persona says who the child is, the type says what it may touch,
382
+ // and a role must not be a way to hand a read-only child a `write`
383
+ // tool. Over a file-defined agent the role is appended to the
384
+ // file's prompt rather than replacing it.
212
385
  agentId = `dyn-${++dynCounter}`;
213
- registry.register(buildDefinition(agentId, `Dynamic specialist: ${agentId}`, persona, opts, explore ? () => readOnlyToolsFrom(opts.buildTools()) : opts.buildTools));
386
+ registry.register(buildDefinition(agentId, `Dynamic specialist: ${agentId}`, fileAgent ? `${fileAgent.prompt}\n\n${persona}` : persona, opts, fileAgent
387
+ ? fileAgentTools(fileAgent, opts)
388
+ : explore
389
+ ? () => filterReadOnlyTools(opts.buildTools())
390
+ : opts.buildTools, fileAgent?.model ?? opts.model));
214
391
  }
215
392
  const tracker = activity.begin({
216
393
  agentId,
@@ -234,10 +411,24 @@ export async function createSubagentRuntime(opts) {
234
411
  ...(Object.keys(context.env ?? {}).length > 0 ? { env: context.env } : {}),
235
412
  resumeHandler,
236
413
  };
414
+ let taskOwnsCleanup = false;
415
+ const cleanupDefinition = () => {
416
+ if (dynamic)
417
+ registry.unregister(agentId);
418
+ };
237
419
  try {
238
- const completed = await runBlockingAgentTask({
239
- gateway,
420
+ const completionInbox = await completionInboxForRun(context.runId);
421
+ const outcome = await runBlockingAgentTask({
422
+ gateway: await gatewayForRun(context.runId),
240
423
  signal: context.abortSignal,
424
+ waitForInbound: opts.resolveWaitForInbound?.(context.runId),
425
+ completionInbox,
426
+ onCreated: () => {
427
+ taskOwnsCleanup = true;
428
+ },
429
+ onSettled: (completed) => tracker.settle(completed),
430
+ onFailed: (error) => tracker.fail(error),
431
+ onFinished: cleanupDefinition,
241
432
  create: {
242
433
  agentId,
243
434
  prompt,
@@ -252,59 +443,105 @@ export async function createSubagentRuntime(opts) {
252
443
  onEvent: tracker.onEvent,
253
444
  },
254
445
  });
255
- tracker.settle(completed);
256
- const runStatus = completed.result?.status;
257
- const succeeded = completed.state === 'completed' && (runStatus === undefined || runStatus === 'completed');
258
- const resultText = typeof completed.result?.result === 'string'
259
- ? completed.result.result
260
- : completed.result?.result !== undefined
261
- ? JSON.stringify(completed.result.result)
262
- : '';
263
- if (!succeeded) {
446
+ if (outcome.kind === 'yielded') {
264
447
  return {
265
- success: false,
266
- output: '',
267
- error: `Sub-agent ${agentId} ${completed.state}: ${completed.result?.lastError ?? resultText ?? '(no detail)'}`,
448
+ success: true,
449
+ output: `Sub-agent ${agentId} is still running as task ${outcome.handle.taskId}; it has not completed. Waiting was released because the operator sent a message. Respond to that message while this task continues. Its actual result will arrive as a task notification; do not launch the same work again.`,
450
+ data: {
451
+ task_id: outcome.handle.taskId,
452
+ state: outcome.handle.state,
453
+ wait_released: 'operator_input',
454
+ },
268
455
  };
269
456
  }
270
- return {
271
- success: true,
272
- output: resultText || '(sub-agent returned no text)',
273
- };
457
+ return completedAgentResult(outcome.handle);
274
458
  }
275
459
  catch (error) {
276
460
  tracker.fail(error);
277
461
  throw error;
278
462
  }
279
463
  finally {
280
- // A per-call dynamic specialist is single-use — drop its definition
281
- // (and retained persona string) so long sessions don't leak `dyn-N`
282
- // registrations whether the task succeeded, failed, or threw.
283
- if (dynamic)
284
- registry.unregister(agentId);
464
+ // A yielded task retains its definition until its actual completion.
465
+ // Before creation succeeds, this invocation owns that cleanup.
466
+ if (!taskOwnsCleanup)
467
+ cleanupDefinition();
285
468
  }
286
469
  },
287
470
  });
471
+ const waitForTaskTool = defineTool({
472
+ name: 'wait_for_task',
473
+ description: 'Wait for a task this run already launched, or retrieve its complete result after a task notification. This does not start new work. Operator input releases the wait while the task continues.',
474
+ inputSchema: mcpJsonSchemaToZod({
475
+ type: 'object',
476
+ properties: {
477
+ task_id: {
478
+ type: 'string',
479
+ description: 'The task ID returned by Agent or a task notification.',
480
+ },
481
+ },
482
+ required: ['task_id'],
483
+ }),
484
+ category: 'custom',
485
+ permissions: [],
486
+ readOnly: true,
487
+ destructive: false,
488
+ concurrencySafe: true,
489
+ timeoutMs: CLI_INTERACTIVE_RUN_TIMEOUT_MS,
490
+ async execute(input, context) {
491
+ const gateway = await gatewayForRun(context.runId);
492
+ let taskId;
493
+ try {
494
+ taskId = asTaskId(input.task_id);
495
+ }
496
+ catch {
497
+ return {
498
+ success: false,
499
+ output: '',
500
+ error: 'task_id must be a task UUID returned by Agent or a task notification.',
501
+ };
502
+ }
503
+ const task = gateway.getTask(taskId);
504
+ if (!task)
505
+ return {
506
+ success: false,
507
+ output: '',
508
+ error: `Task ${taskId} does not belong to this parent run.`,
509
+ };
510
+ const outcome = await runBlockingAgentTask({
511
+ gateway,
512
+ task,
513
+ signal: context.abortSignal,
514
+ completionInbox: await completionInboxForRun(context.runId),
515
+ waitForInbound: opts.resolveWaitForInbound?.(context.runId),
516
+ onCreated: () => { },
517
+ onSettled: () => { },
518
+ onFailed: () => { },
519
+ onFinished: () => { },
520
+ });
521
+ if (outcome.kind === 'completed')
522
+ return completedAgentResult(outcome.handle);
523
+ return {
524
+ success: true,
525
+ output: `Task ${taskId} is still running; it has not completed. Waiting was released for an operator message. Its result will arrive as a task notification.`,
526
+ data: { task_id: taskId, state: outcome.handle.state, wait_released: 'operator_input' },
527
+ };
528
+ },
529
+ });
288
530
  let closePromise;
289
531
  const close = () => {
290
532
  if (closePromise)
291
533
  return closePromise;
292
- closePromise = Promise.resolve().then(() => {
293
- // This controller is the parent of every child the manager creates.
294
- // Abort it before disposing bookkeeping so a live child keeps the
295
- // structured reason even if its blocking tool wait already detached.
296
- if (!taskContext.parentAbortController.signal.aborted) {
297
- taskContext.parentAbortController.abort(new RunCancelled('parent'));
298
- }
299
- manager.dispose();
300
- activity.close();
301
- });
534
+ closed = true;
535
+ closePromise = Promise.all([...parents.keys()].map(releaseRun)).then(() => activity.close());
302
536
  return closePromise;
303
537
  };
304
538
  return {
305
- gateway,
539
+ gatewayForRun,
540
+ completionInboxForRun,
541
+ releaseRun,
306
542
  agentTool,
307
- allowedAgentIds: [GENERAL_PURPOSE_SUBAGENT, EXPLORE_SUBAGENT],
543
+ waitForTaskTool,
544
+ allowedAgentIds: agentTypeIds,
308
545
  activity,
309
546
  close,
310
547
  };
@@ -316,6 +553,7 @@ async function runBlockingAgentTask(input) {
316
553
  let handle;
317
554
  let cancellationRequested = false;
318
555
  let taskCancellationAttempted = false;
556
+ const wakeController = new AbortController();
319
557
  let rejectAbort = () => { };
320
558
  const aborted = new Promise((_resolve, reject) => {
321
559
  rejectAbort = reject;
@@ -341,7 +579,7 @@ async function runBlockingAgentTask(input) {
341
579
  signal.addEventListener('abort', onAbort, { once: true });
342
580
  if (signal.aborted)
343
581
  onAbort();
344
- const creation = gateway.createTask(input.create);
582
+ const creation = input.create === undefined ? Promise.resolve(input.task) : gateway.createTask(input.create);
345
583
  creation.catch(() => { });
346
584
  try {
347
585
  handle = await Promise.race([creation, aborted]);
@@ -349,8 +587,30 @@ async function runBlockingAgentTask(input) {
349
587
  cancel(handle);
350
588
  signal.throwIfAborted();
351
589
  }
352
- const completed = await Promise.race([gateway.waitForTask(handle.taskId), aborted]);
353
- return completed;
590
+ input.onCreated();
591
+ input.completionInbox.launched(handle.taskId);
592
+ const completion = gateway.waitForTask(handle.taskId).then((completed) => {
593
+ input.onSettled(completed);
594
+ input.onFinished();
595
+ return { kind: 'completed', handle: completed };
596
+ }, (error) => {
597
+ input.onFailed(error);
598
+ input.onFinished();
599
+ throw error;
600
+ });
601
+ const inbound = input
602
+ .waitForInbound?.(wakeController.signal)
603
+ .then(() => ({ kind: 'inbound' }));
604
+ const outcome = await Promise.race(inbound ? [completion, aborted, inbound] : [completion, aborted]);
605
+ const current = gateway.getTask(handle.taskId);
606
+ if (outcome.kind === 'completed' || (current && isTerminalAgentTaskState(current.state))) {
607
+ input.completionInbox.claim(handle.taskId);
608
+ return outcome.kind === 'completed' ? outcome : await completion;
609
+ }
610
+ // Only the wait has ended. Parent runtime ownership, token reservations and
611
+ // the completion observer all outlive this tool invocation's local signal.
612
+ input.completionInbox.expect(handle.taskId);
613
+ return { kind: 'yielded', handle };
354
614
  }
355
615
  catch (error) {
356
616
  if (handle)
@@ -362,8 +622,46 @@ async function runBlockingAgentTask(input) {
362
622
  void creation.then(cancel, () => { });
363
623
  }
364
624
  signal.removeEventListener('abort', onAbort);
625
+ wakeController.abort();
365
626
  }
366
627
  }
628
+ /** Lifecycle completion is not proof the requested task finished successfully. */
629
+ function completedAgentResult(completed) {
630
+ const run = completed.result;
631
+ const succeeded = completed.state === 'completed' && (run?.status === undefined || run.status === 'completed');
632
+ const value = run?.structuredOutput ?? run?.result;
633
+ let resultText = typeof value === 'string' ? value : value === undefined ? '' : JSON.stringify(value);
634
+ if (!resultText && run?.stopReason && run.stopReason !== 'end_turn') {
635
+ // A hard limit can stop between tool rounds without setting Run.result.
636
+ // Keep the child's last visible statement, never its reasoning or tool data.
637
+ const partial = [...run.messages]
638
+ .reverse()
639
+ .find((message) => message.role === 'assistant' &&
640
+ typeof message.content === 'string' &&
641
+ message.content.trim().length > 0)?.content;
642
+ if (typeof partial === 'string')
643
+ resultText = partial;
644
+ }
645
+ const stopNote = run?.stopReason && run.stopReason !== 'end_turn'
646
+ ? `Sub-agent run ended with stop reason "${run.stopReason}". Output may be partial; this does not establish task completion.\n\n`
647
+ : '';
648
+ const output = stopNote + (resultText || '(sub-agent returned no text)');
649
+ return {
650
+ success: succeeded,
651
+ output,
652
+ ...(!succeeded
653
+ ? {
654
+ error: `Sub-agent ${completed.agentId} ${completed.state}: ${run?.lastError ?? ''}\n${output}`,
655
+ }
656
+ : {}),
657
+ data: {
658
+ task_id: completed.taskId,
659
+ state: completed.state,
660
+ ...(run?.status ? { status: run.status } : {}),
661
+ ...(run?.stopReason ? { stop_reason: run.stopReason } : {}),
662
+ },
663
+ };
664
+ }
367
665
  /**
368
666
  * Build an agent definition with the given id + persona (system prompt). Used
369
667
  * for the static `general-purpose` agent and for each dynamically-defined
@@ -371,7 +669,9 @@ async function runBlockingAgentTask(input) {
371
669
  */
372
670
  function buildDefinition(id, description, systemPrompt, opts,
373
671
  /** This definition's own roster; absent means the parent's working set. */
374
- tools = opts.buildTools) {
672
+ tools = opts.buildTools,
673
+ /** This definition's model; absent means the session's. */
674
+ model = opts.model) {
375
675
  const agent = new ReactiveAgent({
376
676
  id,
377
677
  name: id,
@@ -391,7 +691,7 @@ tools = opts.buildTools) {
391
691
  category: 'general',
392
692
  description,
393
693
  tools: [],
394
- defaults: { model: opts.model, tokenBudget: 200_000 },
694
+ defaults: { model, tokenBudget: 200_000 },
395
695
  },
396
696
  // ReactiveAgent is Agent<ReactiveAgentConfig,…>; the registry stores the
397
697
  // erased Agent<BaseAgentConfig,…>. configBuilder supplies the richer config.
@@ -403,7 +703,7 @@ tools = opts.buildTools) {
403
703
  // is worse than one that was never told.
404
704
  const environment = opts.readEnvironment ? await opts.readEnvironment() : null;
405
705
  return {
406
- model: options.model ?? opts.model,
706
+ model: options.model ?? model,
407
707
  tokenBudget: options.tokenBudget ?? 200_000,
408
708
  timeoutMs: options.timeoutMs ?? CLI_INTERACTIVE_RUN_TIMEOUT_MS,
409
709
  maxIterations: 40,