@namzu/cli 18.0.0 → 19.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 (227) hide show
  1. package/CHANGELOG.md +5898 -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 +13 -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 +3 -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 +34 -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 +133 -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 +122 -2
  43. package/dist/config/load.js.map +1 -1
  44. package/dist/config/schema.d.ts +92 -0
  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 +22 -0
  48. package/dist/context/doctrine.d.ts.map +1 -0
  49. package/dist/context/doctrine.js +22 -0
  50. package/dist/context/doctrine.js.map +1 -0
  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/context/turn-snapshot.d.ts +59 -0
  56. package/dist/context/turn-snapshot.d.ts.map +1 -0
  57. package/dist/context/turn-snapshot.js +122 -0
  58. package/dist/context/turn-snapshot.js.map +1 -0
  59. package/dist/integrations/mcp/servers.d.ts +19 -0
  60. package/dist/integrations/mcp/servers.d.ts.map +1 -1
  61. package/dist/integrations/mcp/servers.js +23 -2
  62. package/dist/integrations/mcp/servers.js.map +1 -1
  63. package/dist/integrations/plugins/runtime.d.ts +8 -2
  64. package/dist/integrations/plugins/runtime.d.ts.map +1 -1
  65. package/dist/integrations/plugins/runtime.js +24 -12
  66. package/dist/integrations/plugins/runtime.js.map +1 -1
  67. package/dist/integrations/providers/chain-capabilities.js +1 -1
  68. package/dist/integrations/providers/chain-capabilities.js.map +1 -1
  69. package/dist/integrations/providers/credential-store.d.ts +1 -1
  70. package/dist/integrations/providers/credential-store.js +1 -1
  71. package/dist/integrations/providers/harness-credentials.d.ts.map +1 -1
  72. package/dist/integrations/providers/harness-credentials.js +7 -0
  73. package/dist/integrations/providers/harness-credentials.js.map +1 -1
  74. package/dist/integrations/providers/registry.d.ts +2 -2
  75. package/dist/integrations/providers/registry.js +2 -2
  76. package/dist/integrations/sessions/store.d.ts +14 -22
  77. package/dist/integrations/sessions/store.d.ts.map +1 -1
  78. package/dist/integrations/sessions/store.js +78 -175
  79. package/dist/integrations/sessions/store.js.map +1 -1
  80. package/dist/integrations/sessions/transcript-export.d.ts.map +1 -1
  81. package/dist/integrations/sessions/transcript-export.js +6 -2
  82. package/dist/integrations/sessions/transcript-export.js.map +1 -1
  83. package/dist/integrations/state/identity.d.ts +21 -0
  84. package/dist/integrations/state/identity.d.ts.map +1 -0
  85. package/dist/integrations/state/identity.js +49 -0
  86. package/dist/integrations/state/identity.js.map +1 -0
  87. package/dist/integrations/state/immutable-json.d.ts +10 -0
  88. package/dist/integrations/state/immutable-json.d.ts.map +1 -0
  89. package/dist/integrations/state/immutable-json.js +38 -0
  90. package/dist/integrations/state/immutable-json.js.map +1 -0
  91. package/dist/integrations/state/project.d.ts +10 -0
  92. package/dist/integrations/state/project.d.ts.map +1 -0
  93. package/dist/integrations/state/project.js +15 -0
  94. package/dist/integrations/state/project.js.map +1 -0
  95. package/dist/integrations/state/report.d.ts.map +1 -1
  96. package/dist/integrations/state/report.js +42 -12
  97. package/dist/integrations/state/report.js.map +1 -1
  98. package/dist/integrations/subagents/definitions.d.ts +19 -0
  99. package/dist/integrations/subagents/definitions.d.ts.map +1 -0
  100. package/dist/integrations/subagents/definitions.js +26 -0
  101. package/dist/integrations/subagents/definitions.js.map +1 -0
  102. package/dist/integrations/subagents/parent.d.ts +13 -0
  103. package/dist/integrations/subagents/parent.d.ts.map +1 -0
  104. package/dist/integrations/subagents/parent.js +57 -0
  105. package/dist/integrations/subagents/parent.js.map +1 -0
  106. package/dist/integrations/subagents/runtime.d.ts +27 -3
  107. package/dist/integrations/subagents/runtime.d.ts.map +1 -1
  108. package/dist/integrations/subagents/runtime.js +302 -82
  109. package/dist/integrations/subagents/runtime.js.map +1 -1
  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 +12 -20
  115. package/dist/permissions/mode.d.ts.map +1 -1
  116. package/dist/permissions/mode.js +18 -3
  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.map +1 -1
  123. package/dist/tui/AgentExplorer.js +14 -18
  124. package/dist/tui/AgentExplorer.js.map +1 -1
  125. package/dist/tui/App.d.ts.map +1 -1
  126. package/dist/tui/App.js +843 -208
  127. package/dist/tui/App.js.map +1 -1
  128. package/dist/tui/BrandHeader.d.ts +12 -0
  129. package/dist/tui/BrandHeader.d.ts.map +1 -0
  130. package/dist/tui/BrandHeader.js +17 -0
  131. package/dist/tui/BrandHeader.js.map +1 -0
  132. package/dist/tui/ChoicePicker.d.ts +11 -1
  133. package/dist/tui/ChoicePicker.d.ts.map +1 -1
  134. package/dist/tui/ChoicePicker.js +46 -13
  135. package/dist/tui/ChoicePicker.js.map +1 -1
  136. package/dist/tui/Composer.d.ts +9 -1
  137. package/dist/tui/Composer.d.ts.map +1 -1
  138. package/dist/tui/Composer.js +21 -5
  139. package/dist/tui/Composer.js.map +1 -1
  140. package/dist/tui/ComposerFrame.d.ts +10 -0
  141. package/dist/tui/ComposerFrame.d.ts.map +1 -0
  142. package/dist/tui/ComposerFrame.js +93 -0
  143. package/dist/tui/ComposerFrame.js.map +1 -0
  144. package/dist/tui/EditPromptPicker.d.ts.map +1 -1
  145. package/dist/tui/EditPromptPicker.js +1 -1
  146. package/dist/tui/EditPromptPicker.js.map +1 -1
  147. package/dist/tui/LiveActivity.d.ts +12 -5
  148. package/dist/tui/LiveActivity.d.ts.map +1 -1
  149. package/dist/tui/LiveActivity.js +17 -32
  150. package/dist/tui/LiveActivity.js.map +1 -1
  151. package/dist/tui/PermissionOverlay.d.ts +26 -2
  152. package/dist/tui/PermissionOverlay.d.ts.map +1 -1
  153. package/dist/tui/PermissionOverlay.js +118 -11
  154. package/dist/tui/PermissionOverlay.js.map +1 -1
  155. package/dist/tui/Picker.d.ts +2 -2
  156. package/dist/tui/Picker.d.ts.map +1 -1
  157. package/dist/tui/Picker.js +79 -52
  158. package/dist/tui/Picker.js.map +1 -1
  159. package/dist/tui/StatusBar.d.ts.map +1 -1
  160. package/dist/tui/StatusBar.js +5 -2
  161. package/dist/tui/StatusBar.js.map +1 -1
  162. package/dist/tui/TaskList.d.ts +32 -0
  163. package/dist/tui/TaskList.d.ts.map +1 -0
  164. package/dist/tui/TaskList.js +67 -0
  165. package/dist/tui/TaskList.js.map +1 -0
  166. package/dist/tui/Transcript.d.ts +3 -3
  167. package/dist/tui/Transcript.d.ts.map +1 -1
  168. package/dist/tui/Transcript.js +16 -34
  169. package/dist/tui/Transcript.js.map +1 -1
  170. package/dist/tui/agent.d.ts +152 -48
  171. package/dist/tui/agent.d.ts.map +1 -1
  172. package/dist/tui/agent.js +622 -256
  173. package/dist/tui/agent.js.map +1 -1
  174. package/dist/tui/choice-selection.d.ts +17 -0
  175. package/dist/tui/choice-selection.d.ts.map +1 -0
  176. package/dist/tui/choice-selection.js +38 -0
  177. package/dist/tui/choice-selection.js.map +1 -0
  178. package/dist/tui/exit-summary.d.ts +7 -1
  179. package/dist/tui/exit-summary.d.ts.map +1 -1
  180. package/dist/tui/exit-summary.js +12 -3
  181. package/dist/tui/exit-summary.js.map +1 -1
  182. package/dist/tui/index.d.ts +3 -1
  183. package/dist/tui/index.d.ts.map +1 -1
  184. package/dist/tui/index.js +5 -3
  185. package/dist/tui/index.js.map +1 -1
  186. package/dist/tui/logo.d.ts +7 -22
  187. package/dist/tui/logo.d.ts.map +1 -1
  188. package/dist/tui/logo.js +8 -34
  189. package/dist/tui/logo.js.map +1 -1
  190. package/dist/tui/model-choices.d.ts.map +1 -1
  191. package/dist/tui/model-choices.js +21 -6
  192. package/dist/tui/model-choices.js.map +1 -1
  193. package/dist/tui/permission-review.d.ts +15 -4
  194. package/dist/tui/permission-review.d.ts.map +1 -1
  195. package/dist/tui/permission-review.js +199 -10
  196. package/dist/tui/permission-review.js.map +1 -1
  197. package/dist/tui/provider-selection.d.ts +4 -0
  198. package/dist/tui/provider-selection.d.ts.map +1 -0
  199. package/dist/tui/provider-selection.js +16 -0
  200. package/dist/tui/provider-selection.js.map +1 -0
  201. package/dist/tui/run-interruption.d.ts +3 -2
  202. package/dist/tui/run-interruption.d.ts.map +1 -1
  203. package/dist/tui/run-interruption.js +2 -1
  204. package/dist/tui/run-interruption.js.map +1 -1
  205. package/dist/tui/shell-escape.d.ts +42 -0
  206. package/dist/tui/shell-escape.d.ts.map +1 -0
  207. package/dist/tui/shell-escape.js +110 -0
  208. package/dist/tui/shell-escape.js.map +1 -0
  209. package/dist/tui/slashCommands.d.ts +77 -31
  210. package/dist/tui/slashCommands.d.ts.map +1 -1
  211. package/dist/tui/slashCommands.js +343 -303
  212. package/dist/tui/slashCommands.js.map +1 -1
  213. package/dist/tui/stream-blocks.d.ts +24 -0
  214. package/dist/tui/stream-blocks.d.ts.map +1 -1
  215. package/dist/tui/stream-blocks.js +74 -0
  216. package/dist/tui/stream-blocks.js.map +1 -1
  217. package/dist/tui/terminal-choice-text.d.ts +8 -0
  218. package/dist/tui/terminal-choice-text.d.ts.map +1 -0
  219. package/dist/tui/terminal-choice-text.js +37 -0
  220. package/dist/tui/terminal-choice-text.js.map +1 -0
  221. package/dist/tui/theme.d.ts +3 -4
  222. package/dist/tui/theme.d.ts.map +1 -1
  223. package/dist/tui/theme.js +16 -17
  224. package/dist/tui/theme.js.map +1 -1
  225. package/dist/tui/types.d.ts +8 -1
  226. package/dist/tui/types.d.ts.map +1 -1
  227. package/package.json +8 -6
package/dist/tui/agent.js CHANGED
@@ -20,18 +20,24 @@
20
20
  * `emptySession()` whose `send()` yields a single error event so the UI
21
21
  * renders an actionable hint rather than crashing.
22
22
  */
23
- import { BOOT_EVENT_NAMES, DefaultPathBuilder, DiskMemoryStore, DiskTaskStore, EVENT_NAME_ATTRIBUTE, ProviderRegistry, SESSION_GOAL_TOOL_NAMES, SearchToolsTool, ToolRegistry, asProjectId, asRunId, asSessionId, asTenantId, asTopicId, buildMemoryTools, buildSessionGoalTools, compactNow, createComputerUseTool, createMemoryPromoter, createToolPresenter, generateRunId, getBuiltinTools, isTrustedReadOnly, query, resumeRun, withProviderFallback, } from '@namzu/sdk';
23
+ import { BOOT_EVENT_NAMES, BackgroundJobRegistry, DefaultPathBuilder, DiskCheckpointStore, DiskMemoryStore, DiskTaskStore, EVENT_NAME_ATTRIBUTE, GuardedFetchProvider, PromptContributionRegistry, ProviderRegistry, SESSION_GOAL_TOOL_NAMES, SearchToolsTool, ToolRegistry, WebFetchTool, batchNeedsReview, buildAskUserQuestionTool, buildMemoryTools, buildSessionGoalTools, compactNow, createComputerUseTool, createMemoryPromoter, createReviewHandler, createToolPresenter, generateProjectId, generateRunId, generateSessionId, generateTenantId, generateTopicId, getBuiltinTools, isReviewExempt, query, resumeRun, webGuidanceContribution, withProviderFallback, } from '@namzu/sdk';
24
24
  import { SubprocessComputerUseHost } from '@namzu/computer-use';
25
25
  import { realpath, stat } from 'node:fs/promises';
26
26
  import { join, parse, resolve } from 'node:path';
27
+ import { FileCheckpointStore } from '../checkpoints/store.js';
28
+ import { CHECKPOINTED_TOOLS, withCheckpoints } from '../checkpoints/wrap.js';
27
29
  import { probeCapabilities } from '../context/capabilities.js';
30
+ import { NAMZU_DELEGATION_DOCTRINE, NAMZU_PLAN_MODE_DOCTRINE, NAMZU_WORKING_DOCTRINE, } from '../context/doctrine.js';
28
31
  import { composeEnvironmentPrompt, readEnvironmentFacts } from '../context/environment.js';
29
32
  import { ProjectInstructionTracker } from '../context/project-tracker.js';
30
33
  import { resolveSandbox, sandboxResolvedSeverity, } from '../context/sandbox.js';
34
+ import { composeTurnSnapshot, readTurnSnapshot } from '../context/turn-snapshot.js';
31
35
  import { connectMcpServers, } from '../integrations/mcp/servers.js';
32
36
  import { createCliPluginRuntime } from '../integrations/plugins/runtime.js';
33
37
  import { CredentialRefreshRejectedError, CredentialWithdrawnError, PROVIDER_REGISTRY, chainCapabilityDisagreements, chainPositionName, describeAcceptedMismatch, describeCapabilityRefusal, discoverProviders, ensureFreshAnthropicToken, ensureFreshStoredCodexCredential, ensureRegistered, findDetected, isAnthropicOAuthToken, isRegistered, missingCredentialMessage, primaryProvider, readCodexCredentialFile, readPreferences, readSubscriptionCredential, resolveChainCapabilities, sameOAuthCredential, unresolvedMembers, unsupportedProviderMessage, } from '../integrations/providers/index.js';
34
38
  import { ensurePrivateStateDirectory } from '../integrations/state/private-directory.js';
39
+ import { discoverAgentDefinitions } from '../integrations/subagents/definitions.js';
40
+ import { SubagentPathBuilder, resolveSubagentParent } from '../integrations/subagents/parent.js';
35
41
  import { CLI_INTERACTIVE_RUN_TIMEOUT_MS } from '../integrations/subagents/policy.js';
36
42
  import { createSubagentRuntime } from '../integrations/subagents/runtime.js';
37
43
  import { cliLogger } from '../logging.js';
@@ -238,7 +244,17 @@ async function drainIterator(iterator) {
238
244
  */
239
245
  export async function probeAgentSession() {
240
246
  const read = readPreferences();
247
+ // Bracketed in the log because this is where a boot has stalled without
248
+ // a record on either side: it reads credential files, and on WSL it asks
249
+ // Windows for the paired home. A hang that shows the last line before it
250
+ // and nothing after is this step.
251
+ const discoveryStartedAt = Date.now();
252
+ cliLogger().debug('discovering provider credentials');
241
253
  const detected = await discoverProviders();
254
+ cliLogger().debug('provider credentials discovered', {
255
+ 'namzu.boot.discovery_ms': Date.now() - discoveryStartedAt,
256
+ 'namzu.boot.detected_count': detected.length,
257
+ });
242
258
  switch (read.status) {
243
259
  case 'ok':
244
260
  return {
@@ -357,9 +373,21 @@ function builtinTools(backgroundJobs) {
357
373
  return [tool.name === 'bash' ? foregroundOnlyBash(tool) : tool];
358
374
  });
359
375
  }
360
- function buildToolRegistry(cwd, projectStateRoot = join(cwd, '.namzu'), backgroundJobs = true) {
376
+ function buildToolRegistry(cwd, projectStateRoot = join(cwd, '.namzu'), backgroundJobs = true, checkpoints) {
361
377
  const registry = new ToolRegistry();
362
378
  registry.register(builtinTools(backgroundJobs));
379
+ // The file tools take a checkpoint before they write, so `/restore` can
380
+ // put the tree back. Only the session's own registry: a sub-agent's
381
+ // writes are not checkpointed yet, and the page says so.
382
+ if (checkpoints) {
383
+ for (const name of CHECKPOINTED_TOOLS) {
384
+ const tool = registry.get(name);
385
+ if (!tool)
386
+ continue;
387
+ registry.unregister(name);
388
+ registry.register(withCheckpoints(tool, checkpoints));
389
+ }
390
+ }
363
391
  // SDK memory: the agent gets search_memory / read_memory / save_memory over
364
392
  // a structured store in this Project's generated-state directory. CLI
365
393
  // surfaces inject the central application-home hierarchy; embedded callers
@@ -711,8 +739,46 @@ export async function createAgentSession(prefs, detected, options = {}) {
711
739
  [EVENT_NAME_ATTRIBUTE]: BOOT_EVENT_NAMES.SANDBOX_RESOLVED,
712
740
  'namzu.sandbox.unconfined': sandbox.unconfined,
713
741
  });
714
- const backgroundJobs = sandbox.provider === undefined;
715
- const { registry, memoryStore } = buildToolRegistry(cwd, projectStateRoot, backgroundJobs);
742
+ // Always built: the executor hands it to the tools only where it is
743
+ // safe — on the host, or inside a sandbox that can start a detached
744
+ // process — so a session under a sandbox that cannot simply has none.
745
+ const backgroundJobs = true;
746
+ // One registry per session, and jobs bound to the SESSION: a dev server
747
+ // started in one turn is still there in the next, and the kernel tells
748
+ // the model when a job ends. Stopped when the session closes, below.
749
+ // Withheld under a sandbox for the reason the kernel gives: the registry
750
+ // runs on the host and must not sit beside a sandbox in one tool context.
751
+ const jobRegistry = backgroundJobs ? new BackgroundJobRegistry() : undefined;
752
+ const jobOwner = scope.sessionId;
753
+ // Session-scoped and mutable: `/add-dir` adds to it, and every turn reads
754
+ // it fresh — the query, the sandbox binds and the environment prompt.
755
+ const directories = [];
756
+ for (const dir of options.additionalDirectories ?? []) {
757
+ const absolute = resolve(cwd, dir);
758
+ if (absolute !== resolve(cwd) && !directories.includes(absolute))
759
+ directories.push(absolute);
760
+ }
761
+ const sessionDirectories = {
762
+ list: () => [...directories],
763
+ add: async (path) => {
764
+ const absolute = resolve(cwd, path);
765
+ if (absolute === resolve(cwd))
766
+ return {
767
+ added: false,
768
+ path: absolute,
769
+ reason: 'That is the working directory.',
770
+ };
771
+ if (directories.includes(absolute))
772
+ return { added: false, path: absolute, reason: 'Already added.' };
773
+ const entry = await stat(absolute).catch(() => null);
774
+ if (!entry?.isDirectory())
775
+ return { added: false, path: absolute, reason: 'Not a directory.' };
776
+ directories.push(absolute);
777
+ return { added: true, path: absolute };
778
+ },
779
+ };
780
+ const checkpoints = new FileCheckpointStore(join(ensurePrivateStateDirectory(projectStateRoot, 'checkpoints'), scope.sessionId), cwd);
781
+ const { registry, memoryStore } = buildToolRegistry(cwd, projectStateRoot, backgroundJobs, checkpoints);
716
782
  // Package presence is not tool reachability. The CLI used to probe and
717
783
  // report @namzu/computer-use without ever constructing its host or mounting
718
784
  // SDK's computer_use definition, so even an installed, healthy package was
@@ -732,6 +798,28 @@ export async function createAgentSession(prefs, detected, options = {}) {
732
798
  catch (error) {
733
799
  computerUseError = error instanceof Error ? error : new Error(String(error));
734
800
  await candidate.dispose().catch(() => { });
801
+ // Mounted anyway, with every capability false and the reason on it.
802
+ // A tool that is absent is a tool the model reasons about from the
803
+ // wrong premise; a tool that says "this desktop did not answer, and
804
+ // why" is one call the model reads once and does not repeat.
805
+ registry.register(createComputerUseTool({
806
+ id: candidate.id,
807
+ capabilities: {
808
+ ...candidate.capabilities,
809
+ screenshot: false,
810
+ mouse: false,
811
+ keyboard: false,
812
+ cursorPosition: false,
813
+ clipboard: false,
814
+ unavailableReason: describeError(computerUseError),
815
+ },
816
+ getDisplayGeometry: async () => {
817
+ throw computerUseError;
818
+ },
819
+ execute: async () => {
820
+ throw computerUseError;
821
+ },
822
+ }));
735
823
  }
736
824
  }
737
825
  // Registered only on the main session path. Sub-agents call
@@ -789,10 +877,19 @@ export async function createAgentSession(prefs, detected, options = {}) {
789
877
  // This session passes a `taskStore` to query() below, which registers the
790
878
  // task tools deferred — so `search_tools` has something to find here.
791
879
  registry.register([SearchToolsTool]);
880
+ // Web reach, opted into in the config file and nowhere else. The parent's
881
+ // registry only: a child's config carries no provider, and a tool whose
882
+ // provider is missing is a tool that reports itself unwired — truthful,
883
+ // and noise. The guarded provider refuses private and loopback addresses
884
+ // and bounds redirects and body; every fetch is reviewed like a shell
885
+ // command (see `isPromptExempt`).
886
+ const webCapability = options.web?.fetch ? { fetch: new GuardedFetchProvider() } : undefined;
887
+ if (webCapability)
888
+ registry.register(WebFetchTool);
792
889
  // Native sub-agents: register the canonical `Agent` tool so the model can
793
890
  // delegate a self-contained task to a fresh sub-agent (own context window).
794
891
  // Best-effort — if the runtime can't stand up, the chat still works.
795
- let subagentGateway;
892
+ const delegationScopes = new Map();
796
893
  let subagentRuntime;
797
894
  // Stays empty when the runtime below throws, which is the honest answer: the
798
895
  // catch is non-fatal and the session then genuinely has no delegate to
@@ -800,10 +897,28 @@ export async function createAgentSession(prefs, detected, options = {}) {
800
897
  // would name agents that are not there.
801
898
  let allowedAgentIds = [];
802
899
  try {
900
+ // Agents the project or user defined in files. A file that cannot be
901
+ // loaded is named with its reason rather than silently absent: "namzu
902
+ // ignored my reviewer" and "namzu never saw it" call for opposite fixes.
903
+ const discovered = await discoverAgentDefinitions({ cwd });
904
+ for (const skipped of discovered.skipped) {
905
+ cliLogger().warn('agent definition skipped', {
906
+ 'namzu.agent.definition.path': skipped.path,
907
+ 'namzu.agent.definition.reason': skipped.reason,
908
+ });
909
+ }
803
910
  const sub = await createSubagentRuntime({
804
911
  cwd,
805
912
  model,
806
- pathBuilder: new DefaultPathBuilder(join(projectStateRoot, 'subagents')),
913
+ tokenBudget: options.limits?.tokenBudget,
914
+ definitions: discovered.definitions,
915
+ pathBuilder: new SubagentPathBuilder(projectStateRoot, scope.projectId),
916
+ resolveParent: async (runId) => {
917
+ const parent = delegationScopes.get(runId);
918
+ if (!parent)
919
+ throw new Error(`Run ${runId} no longer owns delegation authority`);
920
+ return resolveSubagentParent(parent, cwd, options.stateRoot ? hierarchyRoot : undefined);
921
+ },
807
922
  sandboxWorkspace,
808
923
  resolveResumeHandler: (runId) => delegatedResumeHandlers.get(runId),
809
924
  ...(sandbox.provider ? { sandboxProvider: sandbox.provider } : {}),
@@ -850,7 +965,6 @@ export async function createAgentSession(prefs, detected, options = {}) {
850
965
  });
851
966
  subagentRuntime = sub;
852
967
  registry.register([sub.agentTool]);
853
- subagentGateway = sub.gateway;
854
968
  allowedAgentIds = sub.allowedAgentIds;
855
969
  }
856
970
  catch (err) {
@@ -864,24 +978,90 @@ export async function createAgentSession(prefs, detected, options = {}) {
864
978
  // and got none had nothing on stderr to say why.
865
979
  cliLogger().warn('sub-agent runtime unavailable this session', exceptionAttributes(err));
866
980
  }
867
- // Task store → query registers task_create / task_update / task_list as
868
- // DEFERRED tools and emits task_created/task_updated, so the agent can track
869
- // a plan for the current request. Tasks are run-scoped.
981
+ // `ask_user_question`, where somebody can answer. The SDK tool parks the
982
+ // run through the handler it was BUILT with, so that handler reads the
983
+ // turn's answerer through a holder the prelude fills: the tool is per
984
+ // session, the person answering is per turn. Needs the delegation
985
+ // gateway the tool builder requires; a session without one has no
986
+ // question tool either, and says nothing — it also has no `Agent`.
987
+ let currentOnQuestion;
988
+ if (options.askUser && subagentRuntime) {
989
+ const parkQuestion = async (request) => {
990
+ if (request.type !== 'user_question')
991
+ return { action: 'continue' };
992
+ const ask = currentOnQuestion;
993
+ if (!ask)
994
+ return { action: 'continue' };
995
+ const answer = await ask(request.question);
996
+ switch (answer.kind) {
997
+ case 'answer':
998
+ return {
999
+ action: 'answer_question',
1000
+ selectedOptionIds: [...answer.selectedOptionIds],
1001
+ ...(answer.freeText !== undefined ? { freeText: answer.freeText } : {}),
1002
+ questionId: request.question.questionId,
1003
+ };
1004
+ case 'abort':
1005
+ return { action: 'abort', reason: 'The user declined to answer.' };
1006
+ default:
1007
+ return { action: 'continue' };
1008
+ }
1009
+ };
1010
+ // The park request carries the run id of the call that asked; the
1011
+ // handler above routes by the question, not by the run, and no durable
1012
+ // park recorder is supplied.
1013
+ registry.register(buildAskUserQuestionTool({ resumeHandler: parkQuestion }));
1014
+ }
1015
+ // Task store → query registers task_create / task_update / task_list and
1016
+ // emits task_created/task_updated, so the agent can track a plan for the
1017
+ // current request. Tasks are run-scoped. The kernel's default availability
1018
+ // for them is `deferred`; this session overrides that to `active` at the
1019
+ // query call, because the doctrine tells the model to plan with them and a
1020
+ // tool it must search for first is a tool it skips.
870
1021
  //
871
- // "Deferred" is why this session mounts `search_tools` above: these three are
872
- // the roster it searches. They are registered inside query(), after this
873
- // function returns, which is why the connect line reports no count of them —
874
- // counting here would mean restating query's registration order in the CLI.
1022
+ // `search_tools` stays mounted above even so: a tool server or plugin can
1023
+ // still register a deferred roster, and that is what the search is for. The
1024
+ // task tools are registered inside query(), after this function returns,
1025
+ // which is why the connect line reports no count of them — counting here
1026
+ // would mean restating query's registration order in the CLI.
875
1027
  //
876
1028
  // It is also why `toolNames` below reads the registry rather than a list
877
1029
  // captured on this line. The count at connect time is unchanged; what
878
1030
  // changes is that asking again later gets a later answer.
879
1031
  ensurePrivateStateDirectory(projectStateRoot, 'tenants');
880
- const taskStore = new DiskTaskStore({
1032
+ const taskStoreForRun = (runId, tenantId) => new DiskTaskStore({
881
1033
  baseDir: projectStateRoot,
882
- defaultRunId: asRunId('run_namzu-cli'),
883
- tenantId: scope.tenantId,
1034
+ defaultRunId: runId,
1035
+ tenantId,
884
1036
  });
1037
+ let selectedTaskStore;
1038
+ let taskSelectionGeneration = 0;
1039
+ const resetTaskStore = () => {
1040
+ selectedTaskStore = undefined;
1041
+ taskSelectionGeneration += 1;
1042
+ };
1043
+ const matchesCurrentScope = (candidate) => candidate.sessionId === scope.sessionId &&
1044
+ candidate.projectId === scope.projectId &&
1045
+ candidate.tenantId === scope.tenantId &&
1046
+ candidate.topicId === scope.topicId;
1047
+ const currentTaskStore = () => {
1048
+ if (selectedTaskStore && !matchesCurrentScope(selectedTaskStore.scope))
1049
+ resetTaskStore();
1050
+ return selectedTaskStore?.store;
1051
+ };
1052
+ const beginTaskStoreReadout = () => {
1053
+ // A starting turn must not show its predecessor's plan while credentials
1054
+ // and other asynchronous setup are still being prepared.
1055
+ resetTaskStore();
1056
+ const generation = taskSelectionGeneration;
1057
+ return (runId, runScope) => {
1058
+ const store = taskStoreForRun(runId, runScope.tenantId);
1059
+ if (generation === taskSelectionGeneration && matchesCurrentScope(runScope)) {
1060
+ selectedTaskStore = { scope: { ...runScope }, store };
1061
+ }
1062
+ return store;
1063
+ };
1064
+ };
885
1065
  // Persists across turns: once the user picks "approve all", later tool
886
1066
  // batches in this session run without prompting.
887
1067
  const approval = { all: false };
@@ -903,12 +1083,31 @@ export async function createAgentSession(prefs, detected, options = {}) {
903
1083
  // registries, so executable plugins remain a top-level-session capability.
904
1084
  let pluginRuntime;
905
1085
  try {
906
- pluginRuntime = await createCliPluginRuntime(options.plugins, registry, cwd);
1086
+ pluginRuntime = await createCliPluginRuntime(options.plugins, registry, cwd, options.hooks);
907
1087
  }
908
1088
  catch (error) {
909
1089
  await Promise.allSettled([mcp.close(), computerUseHost?.dispose()]);
910
1090
  return emptySession(describeError(error));
911
1091
  }
1092
+ // The session's own lifecycle, for hooks that set up or tear down
1093
+ // something per session rather than per run. The run id is minted for
1094
+ // these two calls: they belong to no turn. `session_start` waits for
1095
+ // the first turn rather than firing here, because the conversation id
1096
+ // the scope holds at construction is provisional — it is replaced when
1097
+ // the conversation is first made durable — and a hook given the
1098
+ // provisional id could never match it to a run.
1099
+ const sessionPlugins = pluginRuntime;
1100
+ const sessionHookRunId = generateRunId();
1101
+ let sessionStarted = false;
1102
+ const announceSessionStart = async () => {
1103
+ if (!sessionPlugins || sessionStarted)
1104
+ return;
1105
+ sessionStarted = true;
1106
+ await sessionPlugins.manager.executeHooks('session_start', {
1107
+ runId: sessionHookRunId,
1108
+ sessionId: scope.sessionId,
1109
+ });
1110
+ };
912
1111
  if (pluginRuntime) {
913
1112
  cliLogger().info('discovery complete', {
914
1113
  [EVENT_NAME_ATTRIBUTE]: BOOT_EVENT_NAMES.DISCOVERY_COMPLETED,
@@ -930,9 +1129,19 @@ export async function createAgentSession(prefs, detected, options = {}) {
930
1129
  const operations = new SessionOperationOwner(async () => {
931
1130
  const results = await Promise.allSettled([
932
1131
  subagentRuntime?.close?.(),
933
- pluginRuntime?.close(),
1132
+ sessionPlugins
1133
+ ? sessionPlugins.manager
1134
+ .executeHooks('session_end', {
1135
+ runId: sessionHookRunId,
1136
+ sessionId: scope.sessionId,
1137
+ })
1138
+ .catch(() => [])
1139
+ .then(() => sessionPlugins.close())
1140
+ : undefined,
934
1141
  mcp.close(),
935
1142
  computerUseHost?.dispose(),
1143
+ jobRegistry?.killOwner(jobOwner),
1144
+ checkpoints.close(),
936
1145
  ]);
937
1146
  const failures = results
938
1147
  .filter((result) => result.status === 'rejected')
@@ -972,6 +1181,200 @@ export async function createAgentSession(prefs, detected, options = {}) {
972
1181
  reasoningEffortDefault = undefined;
973
1182
  effortNotice = `Reasoning effort levels could not be established for this session: ${describeError(error)}`;
974
1183
  }
1184
+ /**
1185
+ * The kernel's resume with this session's half of the run attached: the
1186
+ * provider, the tools, the working directory, the doctrine — the part a
1187
+ * checkpoint cannot carry. `resumeDurable` and `resumePaused` differ only
1188
+ * in where the run and its store come from.
1189
+ */
1190
+ const kernelResume = ({ entry, checkpointStore, claimFence, signal, checkpointId, listener, }) => operations.promise(signal, async (ownedSignal) => {
1191
+ const selectTaskStore = beginTaskStoreReadout();
1192
+ // The same prelude a turn runs, and for the same reasons: a lapsed
1193
+ // OAuth token has to be renewed before the provider is used, and the
1194
+ // fallback chain has to be built AFTER that so its members do not
1195
+ // hold a client the refresh just replaced.
1196
+ await prepareProviderCredential(ownedSignal);
1197
+ const pluginSkills = pluginRuntime
1198
+ ? await currentPluginSkills(pluginRuntime.skills)
1199
+ : undefined;
1200
+ const memoryPrompt = composeMemoryPrompt(readMemory(undefined, cwd));
1201
+ const environmentPrompt = composeEnvironmentPrompt({
1202
+ ...(await readEnvironmentFacts(cwd)),
1203
+ additionalDirectories: [...directories],
1204
+ });
1205
+ const systemPrompt = [
1206
+ NAMZU_IDENTITY,
1207
+ NAMZU_WORKING_DOCTRINE,
1208
+ NAMZU_DELEGATION_DOCTRINE,
1209
+ environmentPrompt,
1210
+ memoryPrompt,
1211
+ ]
1212
+ .filter((s) => Boolean(s))
1213
+ .join('\n\n') || undefined;
1214
+ const resumeHandler = makeResumeHandler(approval, undefined, options.permissionMode, (name, input) => isPromptExempt(registry, name, input));
1215
+ if (delegatedResumeHandlers.has(entry.runId)) {
1216
+ throw new Error(`Run ${entry.runId} already owns a delegated review channel.`);
1217
+ }
1218
+ delegatedResumeHandlers.set(entry.runId, resumeHandler);
1219
+ delegationScopes.set(entry.runId, { ...entry, topicId: scope.topicId });
1220
+ try {
1221
+ return await resumeRun({
1222
+ provider,
1223
+ fallbackProviders: fallbackPlan.build(currentToken),
1224
+ tools: registry,
1225
+ pluginManager: pluginRuntime?.manager,
1226
+ skillRegistry: pluginRuntime?.skills,
1227
+ skills: pluginSkills,
1228
+ taskStore: selectTaskStore(entry.runId, { ...entry, topicId: scope.topicId }),
1229
+ // The same availability the original run registered under.
1230
+ // A resumed run re-registers the task tools; leaving them at
1231
+ // the kernel's `deferred` default would hand the model a plan
1232
+ // it started with active tools and can no longer update.
1233
+ runtimeToolOverrides: {
1234
+ task_create: 'active',
1235
+ task_update: 'active',
1236
+ task_list: 'active',
1237
+ },
1238
+ ...(subagentRuntime
1239
+ ? {
1240
+ taskScheduler: await subagentRuntime.gatewayForRun(entry.runId),
1241
+ }
1242
+ : {}),
1243
+ authorizationGate: gateFor(options.rules),
1244
+ compactionConfig: compactionConfigFor(options.compaction),
1245
+ projectInstructionContext: projectInstructions.createRunContext(),
1246
+ pathBuilder,
1247
+ ...(sandbox.provider ? { sandboxProvider: sandbox.provider } : {}),
1248
+ ...(options.sandbox?.teardownTimeoutMs !== undefined
1249
+ ? { sandboxTeardownTimeoutMs: options.sandbox.teardownTimeoutMs }
1250
+ : {}),
1251
+ // NOT `emergencySave`, unlike a turn. The manager is a singleton
1252
+ // whose `attach` detaches whoever held it before, so a caller
1253
+ // resuming several runs in one process would leave only the last
1254
+ // one covered — and would look covered. A turn owns its process
1255
+ // end to end; a drainer does not.
1256
+ runConfig: {
1257
+ model,
1258
+ ...(sandbox.provider ? { sandbox: { workspace: sandboxWorkspace } } : {}),
1259
+ timeoutMs: CLI_INTERACTIVE_RUN_TIMEOUT_MS,
1260
+ tokenBudget: options.limits?.tokenBudget ?? 1_000_000,
1261
+ maxIterations: options.limits?.maxIterations ?? 50,
1262
+ maxResponseTokens: 8192,
1263
+ permissionMode: 'auto',
1264
+ },
1265
+ agentId: 'namzu',
1266
+ agentName: 'namzu',
1267
+ ...(systemPrompt ? { systemPrompt } : {}),
1268
+ workingDirectory: cwd,
1269
+ ...(directories.length > 0 ? { additionalDirectories: [...directories] } : {}),
1270
+ ...(options.limits ? { limits: options.limits } : {}),
1271
+ // No `onPermission`: there is nobody at a drainer's terminal, so a
1272
+ // prompt would block the pass forever on a run nobody is watching.
1273
+ // The gate's deny rules still apply.
1274
+ // One presenter for the whole stream, built from the registry this
1275
+ // scope already holds. It was the absence of the registry HERE that
1276
+ // forced presentation to be name matching: `toAgentEvent` was pure
1277
+ // over a `RunEvent` and could not ask a tool anything.
1278
+ resumeHandler,
1279
+ signal: ownedSignal,
1280
+ // Attribution comes from the ENTRY, not from this session: the run
1281
+ // belongs to whoever started it, and stamping the drainer's ids onto
1282
+ // it would file another tenant's work under this one.
1283
+ tenantId: entry.tenantId,
1284
+ projectId: entry.projectId,
1285
+ sessionId: entry.sessionId,
1286
+ // …except the topic, which no checkpoint records — see
1287
+ // `RunStateScope`. This one is the drainer's, and honestly so:
1288
+ // supplied here rather than pretended to have been recovered.
1289
+ topicId: scope.topicId,
1290
+ scope: { ...entry, topicId: scope.topicId },
1291
+ checkpointStore,
1292
+ ...(claimFence !== undefined ? { claimFence } : {}),
1293
+ ...(checkpointId !== undefined ? { checkpointId } : {}),
1294
+ ...(listener ? { listener } : {}),
1295
+ });
1296
+ }
1297
+ finally {
1298
+ if (delegatedResumeHandlers.get(entry.runId) === resumeHandler) {
1299
+ delegatedResumeHandlers.delete(entry.runId);
1300
+ delegationScopes.delete(entry.runId);
1301
+ await subagentRuntime?.releaseRun(entry.runId);
1302
+ }
1303
+ }
1304
+ });
1305
+ /**
1306
+ * `resumeRun` drains the loop and returns a settled run; the events go to a
1307
+ * listener. A small queue turns that into the stream `send` gives, so a
1308
+ * headless caller renders a resumed run exactly as it rendered the turn.
1309
+ */
1310
+ const resumePausedStream = ({ runId, checkpointId, signal, }) => {
1311
+ const queue = [];
1312
+ let wake;
1313
+ let settled = false;
1314
+ let failure;
1315
+ const presenter = createToolPresenter(registry);
1316
+ // The store the turn's run manager wrote to, built the same way it
1317
+ // built it (see the kernel's `RunPersistence`): the session directory's
1318
+ // `runs/`, attributed to this tenant and project.
1319
+ const store = new DiskCheckpointStore({
1320
+ baseDir: join(pathBuilder.sessionDir(scope.projectId, scope.sessionId), 'runs'),
1321
+ }, {
1322
+ tenantId: scope.tenantId,
1323
+ projectId: scope.projectId,
1324
+ sessionId: scope.sessionId,
1325
+ });
1326
+ const entry = {
1327
+ tenantId: scope.tenantId,
1328
+ projectId: scope.projectId,
1329
+ sessionId: scope.sessionId,
1330
+ runId: runId,
1331
+ };
1332
+ const outcome = kernelResume({
1333
+ entry,
1334
+ checkpointStore: store,
1335
+ ...(signal ? { signal } : {}),
1336
+ checkpointId: checkpointId,
1337
+ listener: (event) => {
1338
+ queue.push(event);
1339
+ wake?.();
1340
+ },
1341
+ })
1342
+ .then((result) => {
1343
+ if (!result.resumed) {
1344
+ failure = new Error(result.reason === 'no-checkpoint'
1345
+ ? `no checkpoint ${checkpointId} is recorded for run ${runId}`
1346
+ : `run ${runId} is parked on a decision only a person can answer`);
1347
+ }
1348
+ })
1349
+ .catch((err) => {
1350
+ failure = err instanceof Error ? err : new Error(String(err));
1351
+ })
1352
+ .finally(() => {
1353
+ settled = true;
1354
+ wake?.();
1355
+ });
1356
+ return (async function* () {
1357
+ for (;;) {
1358
+ while (queue.length > 0) {
1359
+ const next = queue.shift();
1360
+ if (!next)
1361
+ break;
1362
+ const mapped = toAgentEvent(next, presenter);
1363
+ if (mapped)
1364
+ yield mapped;
1365
+ }
1366
+ if (settled)
1367
+ break;
1368
+ await new Promise((resolve) => {
1369
+ wake = resolve;
1370
+ });
1371
+ wake = undefined;
1372
+ }
1373
+ await outcome;
1374
+ if (failure)
1375
+ yield { kind: 'error', message: failure.message };
1376
+ })();
1377
+ };
975
1378
  return {
976
1379
  hasProvider: true,
977
1380
  sandbox: {
@@ -989,7 +1392,7 @@ export async function createAgentSession(prefs, detected, options = {}) {
989
1392
  await prepareProviderCredential(signal);
990
1393
  return compactNow({
991
1394
  messages,
992
- config: COMPACTION_CONFIG,
1395
+ config: compactionConfigFor(options.compaction),
993
1396
  provider,
994
1397
  model,
995
1398
  signal,
@@ -1003,6 +1406,16 @@ export async function createAgentSession(prefs, detected, options = {}) {
1003
1406
  .map((t) => t.name)
1004
1407
  .filter((name) => !goalToolNames.has(name)),
1005
1408
  agentIds: allowedAgentIds,
1409
+ currentTaskStore,
1410
+ resetTaskStore,
1411
+ jobs: () => jobRegistry?.list(jobOwner) ?? [],
1412
+ ...(options.hooks ? { hooks: options.hooks } : {}),
1413
+ checkpoints,
1414
+ directories: sessionDirectories,
1415
+ onJobExit: (listener) => jobRegistry?.onExit((job) => {
1416
+ if (job.owner === jobOwner)
1417
+ listener(job);
1418
+ }) ?? (() => { }),
1006
1419
  ...(subagentRuntime ? { subagents: subagentRuntime.activity } : {}),
1007
1420
  get instructionFiles() {
1008
1421
  return projectInstructions.instructionFiles;
@@ -1036,6 +1449,7 @@ export async function createAgentSession(prefs, detected, options = {}) {
1036
1449
  },
1037
1450
  promptExemptTools: () => promptExemptToolNames(registry).filter((name) => !goalToolNames.has(name)),
1038
1451
  send: (messages, opts) => operations.stream(opts?.signal, (signal) => (async function* () {
1452
+ const selectTaskStore = beginTaskStoreReadout();
1039
1453
  const runId = opts?.runId ?? generateRunId();
1040
1454
  const turnOpts = { ...opts, runId, signal };
1041
1455
  const resumeHandler = makeResumeHandler(approval, opts?.onPermission, opts?.permissionMode ?? options.permissionMode, (name, input) => isPromptExempt(registry, name, input));
@@ -1043,6 +1457,8 @@ export async function createAgentSession(prefs, detected, options = {}) {
1043
1457
  throw new Error(`Run ${runId} already owns a delegated review channel.`);
1044
1458
  }
1045
1459
  delegatedResumeHandlers.set(runId, resumeHandler);
1460
+ const turnScope = { ...scope };
1461
+ delegationScopes.set(runId, turnScope);
1046
1462
  try {
1047
1463
  // Renew a lapsed OAuth token before the turn runs (no-op for valid
1048
1464
  // tokens and non-subscription credentials).
@@ -1061,11 +1477,52 @@ export async function createAgentSession(prefs, detected, options = {}) {
1061
1477
  const pluginSkills = pluginRuntime
1062
1478
  ? await currentPluginSkills(pluginRuntime.skills)
1063
1479
  : undefined;
1064
- const memoryPrompt = composeMemoryPrompt(readMemory());
1065
- const environmentPrompt = composeEnvironmentPrompt(await readEnvironmentFacts(cwd));
1066
- const systemPrompt = [NAMZU_IDENTITY, environmentPrompt, memoryPrompt, opts?.extraSystem]
1480
+ const memoryPrompt = composeMemoryPrompt(readMemory(undefined, cwd));
1481
+ currentOnQuestion = opts?.onQuestion;
1482
+ const [environmentFacts, turnSnapshot] = await Promise.all([
1483
+ readEnvironmentFacts(cwd),
1484
+ readTurnSnapshot(cwd),
1485
+ ]);
1486
+ const environmentPrompt = composeEnvironmentPrompt({
1487
+ ...environmentFacts,
1488
+ additionalDirectories: [...directories],
1489
+ });
1490
+ // The repository as it stood when THIS turn began, through the
1491
+ // SDK's `turn` placement — the ephemeral trailing message that is
1492
+ // never cached and never enters history. FIRST iteration only:
1493
+ // later iterations work from state the model itself changed, and
1494
+ // `git status` is the honest source for that. A registry per
1495
+ // turn, closed over this turn's snapshot, rather than one
1496
+ // session-scoped holder every send overwrites: two overlapping
1497
+ // sends would otherwise both render whichever ran second.
1498
+ const turnSnapshotPrompt = turnSnapshot ? composeTurnSnapshot(turnSnapshot) : null;
1499
+ const promptContributions = new PromptContributionRegistry();
1500
+ promptContributions.register({
1501
+ id: 'namzu.turn-snapshot',
1502
+ placement: 'turn',
1503
+ render: ({ iteration }) => (iteration === 1 ? turnSnapshotPrompt : null),
1504
+ });
1505
+ // The citation rules that come with the web tools, only when the
1506
+ // tools are there: guidance about a capability the turn does not
1507
+ // have reads as a capability it should be looking for.
1508
+ if (webCapability)
1509
+ promptContributions.register(webGuidanceContribution);
1510
+ const systemPrompt = [
1511
+ NAMZU_IDENTITY,
1512
+ NAMZU_WORKING_DOCTRINE,
1513
+ NAMZU_DELEGATION_DOCTRINE,
1514
+ // Present only while the turn runs under `plan`. A mode change
1515
+ // is rare, so the cached prefix it re-keys is a price paid once
1516
+ // per switch rather than once per turn.
1517
+ opts?.permissionMode === 'plan' ? NAMZU_PLAN_MODE_DOCTRINE : undefined,
1518
+ environmentPrompt,
1519
+ memoryPrompt,
1520
+ opts?.extraSystem,
1521
+ ]
1067
1522
  .filter((s) => Boolean(s))
1068
1523
  .join('\n\n') || undefined;
1524
+ await announceSessionStart();
1525
+ checkpoints.beginTurn(lastUserText(messages));
1069
1526
  let capturedAuthority;
1070
1527
  if (opts?.goalRound) {
1071
1528
  if (!opts.runId)
@@ -1095,6 +1552,14 @@ export async function createAgentSession(prefs, detected, options = {}) {
1095
1552
  try {
1096
1553
  yield* runTurn({
1097
1554
  provider,
1555
+ compactionConfig: compactionConfigFor(options.compaction),
1556
+ ...(options.compaction?.consolidate ? { consolidateInto: memoryStore } : {}),
1557
+ ...(jobRegistry
1558
+ ? {
1559
+ backgroundJobs: jobRegistry,
1560
+ backgroundJobOwner: jobOwner,
1561
+ }
1562
+ : {}),
1098
1563
  // Constructed HERE, per turn, and that is not an optimisation to
1099
1564
  // undo. `refreshTokenIfNeeded` above replaces the head's client
1100
1565
  // object when an OAuth token rotates, so a member list built once at
@@ -1108,21 +1573,33 @@ export async function createAgentSession(prefs, detected, options = {}) {
1108
1573
  pluginManager: pluginRuntime?.manager,
1109
1574
  skillRegistry: pluginRuntime?.skills,
1110
1575
  skills: pluginSkills,
1111
- scope,
1576
+ scope: turnScope,
1112
1577
  pathBuilder,
1113
1578
  workingDirectory: cwd,
1579
+ ...(directories.length > 0 ? { additionalDirectories: [...directories] } : {}),
1580
+ ...(options.limits ? { limits: options.limits } : {}),
1114
1581
  sandboxWorkspace,
1115
1582
  rules: options.rules,
1116
1583
  reviewAnswer: options.reviewAnswer,
1117
1584
  maxAnswerReviews: options.maxAnswerReviews,
1118
1585
  promoteMemory,
1119
- taskStore,
1586
+ taskStore: selectTaskStore(runId, turnScope),
1120
1587
  systemPrompt,
1121
1588
  messages,
1122
1589
  projectInstructionContext: projectInstructions.createRunContext(),
1123
1590
  opts: turnOpts,
1124
1591
  resumeHandler,
1125
- taskGateway: subagentGateway,
1592
+ taskGateway: await subagentRuntime?.gatewayForRun(runId),
1593
+ promptContributions,
1594
+ ...(webCapability ? { web: webCapability } : {}),
1595
+ // Active, not deferred: the doctrine tells the model to open a
1596
+ // task list for multi-step work, and a tool it has to search
1597
+ // for first is a tool it will skip.
1598
+ runtimeToolOverrides: {
1599
+ task_create: 'active',
1600
+ task_update: 'active',
1601
+ task_list: 'active',
1602
+ },
1126
1603
  onRunEvent: options.onRunEvent,
1127
1604
  ...(sandbox.provider ? { sandboxProvider: sandbox.provider } : {}),
1128
1605
  ...(options.sandbox?.teardownTimeoutMs !== undefined
@@ -1143,94 +1620,18 @@ export async function createAgentSession(prefs, detected, options = {}) {
1143
1620
  finally {
1144
1621
  if (delegatedResumeHandlers.get(runId) === resumeHandler) {
1145
1622
  delegatedResumeHandlers.delete(runId);
1623
+ delegationScopes.delete(runId);
1624
+ await subagentRuntime?.releaseRun(runId);
1146
1625
  }
1147
1626
  }
1148
1627
  })()),
1149
- resumeDurable: ({ entry, checkpointStore, claimFence, signal }) => operations.promise(signal, async (ownedSignal) => {
1150
- // The same prelude a turn runs, and for the same reasons: a lapsed
1151
- // OAuth token has to be renewed before the provider is used, and the
1152
- // fallback chain has to be built AFTER that so its members do not
1153
- // hold a client the refresh just replaced.
1154
- await prepareProviderCredential(ownedSignal);
1155
- const pluginSkills = pluginRuntime
1156
- ? await currentPluginSkills(pluginRuntime.skills)
1157
- : undefined;
1158
- const memoryPrompt = composeMemoryPrompt(readMemory());
1159
- const environmentPrompt = composeEnvironmentPrompt(await readEnvironmentFacts(cwd));
1160
- const systemPrompt = [NAMZU_IDENTITY, environmentPrompt, memoryPrompt]
1161
- .filter((s) => Boolean(s))
1162
- .join('\n\n') || undefined;
1163
- const resumeHandler = makeResumeHandler(approval, undefined, options.permissionMode, (name, input) => isPromptExempt(registry, name, input));
1164
- if (delegatedResumeHandlers.has(entry.runId)) {
1165
- throw new Error(`Run ${entry.runId} already owns a delegated review channel.`);
1166
- }
1167
- delegatedResumeHandlers.set(entry.runId, resumeHandler);
1168
- try {
1169
- return await resumeRun({
1170
- provider,
1171
- fallbackProviders: fallbackPlan.build(currentToken),
1172
- tools: registry,
1173
- pluginManager: pluginRuntime?.manager,
1174
- skillRegistry: pluginRuntime?.skills,
1175
- skills: pluginSkills,
1176
- taskStore,
1177
- ...(subagentGateway ? { taskGateway: subagentGateway } : {}),
1178
- authorizationGate: gateFor(options.rules),
1179
- compactionConfig: COMPACTION_CONFIG,
1180
- projectInstructionContext: projectInstructions.createRunContext(),
1181
- pathBuilder,
1182
- ...(sandbox.provider ? { sandboxProvider: sandbox.provider } : {}),
1183
- ...(options.sandbox?.teardownTimeoutMs !== undefined
1184
- ? { sandboxTeardownTimeoutMs: options.sandbox.teardownTimeoutMs }
1185
- : {}),
1186
- // NOT `emergencySave`, unlike a turn. The manager is a singleton
1187
- // whose `attach` detaches whoever held it before, so a caller
1188
- // resuming several runs in one process would leave only the last
1189
- // one covered — and would look covered. A turn owns its process
1190
- // end to end; a drainer does not.
1191
- runConfig: {
1192
- model,
1193
- ...(sandbox.provider ? { sandbox: { workspace: sandboxWorkspace } } : {}),
1194
- timeoutMs: CLI_INTERACTIVE_RUN_TIMEOUT_MS,
1195
- tokenBudget: 1_000_000,
1196
- maxIterations: 50,
1197
- maxResponseTokens: 8192,
1198
- permissionMode: 'auto',
1199
- },
1200
- agentId: 'namzu',
1201
- agentName: 'namzu',
1202
- ...(systemPrompt ? { systemPrompt } : {}),
1203
- workingDirectory: cwd,
1204
- // No `onPermission`: there is nobody at a drainer's terminal, so a
1205
- // prompt would block the pass forever on a run nobody is watching.
1206
- // The gate's deny rules still apply.
1207
- // One presenter for the whole stream, built from the registry this
1208
- // scope already holds. It was the absence of the registry HERE that
1209
- // forced presentation to be name matching: `toAgentEvent` was pure
1210
- // over a `RunEvent` and could not ask a tool anything.
1211
- resumeHandler,
1212
- signal: ownedSignal,
1213
- // Attribution comes from the ENTRY, not from this session: the run
1214
- // belongs to whoever started it, and stamping the drainer's ids onto
1215
- // it would file another tenant's work under this one.
1216
- tenantId: entry.tenantId,
1217
- projectId: entry.projectId,
1218
- sessionId: entry.sessionId,
1219
- // …except the topic, which no checkpoint records — see
1220
- // `RunStateScope`. This one is the drainer's, and honestly so:
1221
- // supplied here rather than pretended to have been recovered.
1222
- topicId: scope.topicId,
1223
- scope: { ...entry, topicId: scope.topicId },
1224
- checkpointStore,
1225
- ...(claimFence !== undefined ? { claimFence } : {}),
1226
- });
1227
- }
1228
- finally {
1229
- if (delegatedResumeHandlers.get(entry.runId) === resumeHandler) {
1230
- delegatedResumeHandlers.delete(entry.runId);
1231
- }
1232
- }
1628
+ resumeDurable: ({ entry, checkpointStore, claimFence, signal }) => kernelResume({
1629
+ entry,
1630
+ checkpointStore,
1631
+ ...(claimFence !== undefined ? { claimFence } : {}),
1632
+ ...(signal ? { signal } : {}),
1233
1633
  }),
1634
+ resumePaused: (params) => resumePausedStream(params),
1234
1635
  };
1235
1636
  }
1236
1637
  function planFallbacks(members, detected) {
@@ -1510,18 +1911,26 @@ export async function listProviderModels(id, det) {
1510
1911
  const listing = await describeProviderModels(id, det);
1511
1912
  return listing.kind === 'ok' ? [...listing.models] : [];
1512
1913
  }
1513
- /** One scope per launched TUI session; runId is minted fresh per turn by the SDK. */
1914
+ /** The newest user turn's text, for labels. */
1915
+ function lastUserText(messages) {
1916
+ for (let i = messages.length - 1; i >= 0; i--) {
1917
+ const m = messages[i];
1918
+ if (m?.role === 'user' && typeof m.content === 'string')
1919
+ return m.content;
1920
+ }
1921
+ return '';
1922
+ }
1923
+ /**
1924
+ * A scope for a session no host supplied one for: four minted ids. Minted
1925
+ * rather than spelled, because a spelled id is a place a typo hides and
1926
+ * these types accept either spelling until they are nominal.
1927
+ */
1514
1928
  function mintScope() {
1515
- const suffix = `tui-${Date.now().toString(36)}`;
1516
- // Through the constructors rather than as four bare template literals.
1517
- // One suffix shared by four ids is exactly the shape a typo hides in —
1518
- // `top_` and `tnt_` differ by two characters, and the types accept either
1519
- // spelling for either field while they are still structural.
1520
1929
  return {
1521
- sessionId: asSessionId(`ses_${suffix}`),
1522
- topicId: asTopicId(`top_${suffix}`),
1523
- projectId: asProjectId(`prj_${suffix}`),
1524
- tenantId: asTenantId(`tnt_${suffix}`),
1930
+ sessionId: generateSessionId(),
1931
+ topicId: generateTopicId(),
1932
+ projectId: generateProjectId(),
1933
+ tenantId: generateTenantId(),
1525
1934
  };
1526
1935
  }
1527
1936
  // Pre-execution safety gate: hard-deny catastrophic shell patterns
@@ -1560,7 +1969,7 @@ function gateFor(rules) {
1560
1969
  // from `runConfig.model`, which is the value the user actually chose. Pinning
1561
1970
  // a number here would fix one window across every model the CLI can talk to.
1562
1971
  const COMPACTION_CONFIG = {
1563
- strategy: 'structured',
1972
+ strategy: 'salience',
1564
1973
  // On, and this is the CLI making a choice rather than taking a default.
1565
1974
  // A session's transcript is the only record of what was compacted away;
1566
1975
  // the size trade this costs is the operator's to see and turn off.
@@ -1588,7 +1997,17 @@ const COMPACTION_CONFIG = {
1588
1997
  maxCharsPerRequirement: 300,
1589
1998
  maxCharsPerTask: 400,
1590
1999
  };
1591
- async function* runTurn({ provider, fallbackProviders, model, tools, pluginManager, skillRegistry, skills, scope, pathBuilder, workingDirectory, sandboxWorkspace, rules, reviewAnswer, maxAnswerReviews, promoteMemory, resumeHandler, taskStore, systemPrompt, messages, projectInstructionContext, opts, taskGateway, sandboxProvider, sandboxTeardownTimeoutMs, onRunEvent, }) {
2000
+ /** The shipped configuration with the strategy the project chose, if it chose one. */
2001
+ function compactionConfigFor(compaction) {
2002
+ return {
2003
+ ...COMPACTION_CONFIG,
2004
+ strategy: compaction?.strategy ?? COMPACTION_CONFIG.strategy,
2005
+ ...(compaction?.contextWindowTokens !== undefined
2006
+ ? { contextWindowTokens: compaction.contextWindowTokens }
2007
+ : {}),
2008
+ };
2009
+ }
2010
+ async function* runTurn({ provider, compactionConfig, consolidateInto, backgroundJobs, backgroundJobOwner, fallbackProviders, model, tools, pluginManager, skillRegistry, skills, scope, pathBuilder, workingDirectory, limits, additionalDirectories, sandboxWorkspace, rules, reviewAnswer, maxAnswerReviews, promoteMemory, resumeHandler, taskStore, systemPrompt, messages, projectInstructionContext, opts, taskGateway, promptContributions, runtimeToolOverrides, web, sandboxProvider, sandboxTeardownTimeoutMs, onRunEvent, }) {
1592
2011
  const signal = opts?.signal;
1593
2012
  // One presenter for the whole stream, built from the registry this scope
1594
2013
  // already holds. Its absence HERE is what forced presentation to be name
@@ -1612,7 +2031,7 @@ async function* runTurn({ provider, fallbackProviders, model, tools, pluginManag
1612
2031
  // turn. An admitted send owns the exact run-scoped authority above.
1613
2032
  ...(!opts?.goalRound ? { deniedTools: SESSION_GOAL_TOOL_NAMES } : {}),
1614
2033
  taskStore,
1615
- ...(taskGateway ? { taskGateway } : {}),
2034
+ ...(taskGateway ? { taskScheduler: taskGateway } : {}),
1616
2035
  // `gateFor`, not the bare default: the default's `rules` is a hardcoded
1617
2036
  // empty array, so passing it here discarded the operator's rules on the
1618
2037
  // path that runs every top-level turn. The sub-agent path called
@@ -1620,7 +2039,9 @@ async function* runTurn({ provider, fallbackProviders, model, tools, pluginManag
1620
2039
  ...(sandboxProvider ? { sandboxProvider } : {}),
1621
2040
  ...(sandboxTeardownTimeoutMs !== undefined ? { sandboxTeardownTimeoutMs } : {}),
1622
2041
  authorizationGate: gateFor(rules),
1623
- compactionConfig: COMPACTION_CONFIG,
2042
+ compactionConfig,
2043
+ ...(consolidateInto ? { consolidateInto } : {}),
2044
+ ...(backgroundJobs ? { backgroundJobs, backgroundJobOwner } : {}),
1624
2045
  // The CLI owns its process end to end, so it can safely hand the
1625
2046
  // termination path to the kernel: a Ctrl-C mid-run now leaves a
1626
2047
  // dump under the injected hierarchy's emergency partition instead of
@@ -1631,8 +2052,8 @@ async function* runTurn({ provider, fallbackProviders, model, tools, pluginManag
1631
2052
  ...(sandboxProvider ? { sandbox: { workspace: sandboxWorkspace } } : {}),
1632
2053
  ...(opts?.effort !== undefined ? { effort: opts.effort } : {}),
1633
2054
  timeoutMs: CLI_INTERACTIVE_RUN_TIMEOUT_MS,
1634
- tokenBudget: 1_000_000,
1635
- maxIterations: 50,
2055
+ tokenBudget: limits?.tokenBudget ?? 1_000_000,
2056
+ maxIterations: limits?.maxIterations ?? 50,
1636
2057
  maxResponseTokens: 8192,
1637
2058
  permissionMode: 'auto',
1638
2059
  },
@@ -1653,10 +2074,14 @@ async function* runTurn({ provider, fallbackProviders, model, tools, pluginManag
1653
2074
  messages: [...messages],
1654
2075
  ...(opts?.inboundMessages ? { inboundMessages: opts.inboundMessages } : {}),
1655
2076
  workingDirectory,
2077
+ ...(additionalDirectories?.length ? { additionalDirectories } : {}),
1656
2078
  // The exemption reads `tools` at decision time, so it sees the task
1657
2079
  // tools `query()` registers deferred below and any tool server that
1658
2080
  // connected after this session was built.
1659
2081
  resumeHandler,
2082
+ ...(promptContributions ? { promptContributions } : {}),
2083
+ ...(runtimeToolOverrides ? { runtimeToolOverrides } : {}),
2084
+ ...(web ? { web } : {}),
1660
2085
  signal,
1661
2086
  ...scope,
1662
2087
  });
@@ -1706,123 +2131,26 @@ async function* runTurn({ provider, fallbackProviders, model, tools, pluginManag
1706
2131
  }
1707
2132
  }
1708
2133
  /**
1709
- * Bridge the SDK's HITL `tool_review` request to the TUI's permission
1710
- * callback. Read-only batches (nothing destructive) run silently; batches
1711
- * with a destructive call prompt the user unless they've already chosen
1712
- * "approve all" for the session. Plans and iteration checkpoints are
1713
- * auto-continued (the TUI doesn't use plan mode).
1714
- */
1715
- export function makeResumeHandler(approval, onPermission, mode = onPermission ? 'prompt' : 'auto',
1716
- /**
1717
- * Which calls skip the prompt. Injected rather than reached for, so this
1718
- * handler stays testable without a registry — and so the answer comes from
1719
- * the live roster at the moment of the call.
1720
- */
1721
- exempt = () => false) {
1722
- return async (request) => {
1723
- if (request.type !== 'tool_review') {
1724
- return request.type === 'plan_approval' ? { action: 'approve_plan' } : { action: 'continue' };
1725
- }
1726
- // Only calls the gate routed to REVIEW arrive here — a rule that denied
1727
- // one already stopped it, and a rule that allowed one never asked. So the
1728
- // mode decides what happens to the undecided, and cannot reopen anything
1729
- // a rule closed. That is the whole precedence story between a flag and a
1730
- // config file, and it is one sentence on purpose.
1731
- if (!batchNeedsPrompt(request.toolCalls, exempt)) {
1732
- return { action: 'approve_tools' };
1733
- }
1734
- if (mode === 'strict') {
1735
- return {
1736
- action: 'reject_tools',
1737
- feedback: 'Refused: this run only permits tools an explicit rule allows, and no rule covers this call. Asking again will not change it — either the operator adds a rule, or this has to be done another way.',
1738
- };
1739
- }
1740
- if (mode === 'auto' || !onPermission || approval.all) {
1741
- return { action: 'approve_tools' };
1742
- }
1743
- const decision = await onPermission({
1744
- toolCalls: request.toolCalls.map((tc) => ({
1745
- id: tc.id,
1746
- name: tc.name,
1747
- input: tc.input,
1748
- isDestructive: tc.isDestructive,
1749
- })),
1750
- });
1751
- switch (decision.kind) {
1752
- case 'approve':
1753
- return { action: 'approve_tools' };
1754
- case 'approve-all':
1755
- approval.all = true;
1756
- return { action: 'approve_tools' };
1757
- case 'reject':
1758
- return {
1759
- action: 'reject_tools',
1760
- feedback: decision.feedback ?? 'User declined to run the proposed tool(s).',
1761
- };
1762
- }
1763
- };
1764
- }
1765
- /**
1766
- * Writes that skip the prompt anyway, in spite of declaring `readOnly: false`.
1767
- *
1768
- * This is an OVERRIDE of the tool's own declaration, and it is named as one.
1769
- * The list it replaced was called `READ_ONLY_TOOLS` and contained three tools
1770
- * that declare `readOnly: false` — a constant asserting the exact property it
1771
- * was getting wrong, which is how the disagreement survived: nothing reading it
1772
- * had reason to doubt the name.
2134
+ * The kernel's review policy with the TUI's prompt behind it.
1773
2135
  *
1774
- * The bar for an entry is that prompting would be unusable AND a bad write
1775
- * cannot reach beyond the agent's own bookkeeping. Each one is justified here,
1776
- * or it does not belong here.
1777
- *
1778
- * - `task_create` / `task_update` — the model's own plan for the current
1779
- * request, written several times per planning turn; prompting each would put
1780
- * a consent dialog between the agent and its todo list. What a bad write
1781
- * costs is a polluted task list, which is visible in the transcript and
1782
- * grants nothing. Worth knowing while reading that: these DO outlive the
1783
- * session, because the CLI's task store uses a fixed run id
1784
- * (`run_namzu-cli`), so "run-scoped" is not the reason they are here — the
1785
- * blast radius is.
1786
- *
1787
- * `save_memory` was on the list it replaced and is deliberately NOT here. Its
1788
- * effect outlives the run in a way the task tools' does not: content saved now
1789
- * is retrievable by `search_memory` in a later session, so a tool result or
1790
- * fetched page that talks the model into saving something reaches a future
1791
- * run's reasoning. It is not auto-injected into the prompt — that is
1792
- * `MEMORY.md`, a different thing — but retrievable is enough. A write that
1793
- * survives the process, into the user's own repository, is not read-only under
1794
- * any reading, and it now prompts.
2136
+ * The five modes, the exemptions and the batch rule live in `@namzu/sdk`
2137
+ * (`createReviewHandler`); what this application adds is the person to ask
2138
+ * and the session's "approve all" box, which the screen also reads.
1795
2139
  */
1796
- const PROMPT_EXEMPT_WRITES = new Set(['task_create', 'task_update', 'update_goal']);
2140
+ export function makeResumeHandler(approval, onPermission, mode = onPermission ? 'prompt' : 'auto', exempt = () => false) {
2141
+ return createReviewHandler({
2142
+ mode,
2143
+ prompt: onPermission,
2144
+ exempt,
2145
+ remembered: approval,
2146
+ });
2147
+ }
1797
2148
  /**
1798
- * Whether a call runs without asking: it declares itself read-only, or it is a
1799
- * named exemption above.
1800
- *
1801
- * The read-only half comes from the tool's own `isReadOnly(input)`, never from
1802
- * a list of names kept here. A name list in the consumer is a second source of
1803
- * truth for a property the producer already states: a new read-only tool
1804
- * missing from it merely gets prompted, but a RENAMED tool silently changes
1805
- * posture with nothing to notice.
1806
- *
1807
- * Resolved per call rather than snapshotted, because the roster changes after
1808
- * this module has run — the task tools are registered deferred inside
1809
- * `query()`, and tool servers connect during startup, so anything computed
1810
- * eagerly would be answering about a registry that no longer exists.
1811
- *
1812
- * A tool the registry does not know, or one that declares nothing, prompts.
1813
- * That is the safe-by-default direction the previous comment claimed and this
1814
- * keeps: consent is the answer when the question cannot be established.
2149
+ * Whether a call runs without asking. The kernel's rule: a trusted read-only
2150
+ * declaration or a named bookkeeping write, never a fetch, never a tool the
2151
+ * registry does not know.
1815
2152
  */
1816
- export function isPromptExempt(registry, name, input) {
1817
- if (PROMPT_EXEMPT_WRITES.has(name.toLowerCase()))
1818
- return true;
1819
- const tool = registry.get(name) ?? registry.get(name.toLowerCase());
1820
- // A connected server's own claim about its own tool cannot skip the
1821
- // prompt. Same predicate the kernel gate and plan mode use -- three
1822
- // doors, one rule, because fixing two would close the issue and leave
1823
- // the boundary open.
1824
- return isTrustedReadOnly(tool, input);
1825
- }
2153
+ export const isPromptExempt = isReviewExempt;
1826
2154
  /** The exempt roster, sorted, for the surface that has to NAME it. */
1827
2155
  export function promptExemptToolNames(registry) {
1828
2156
  return registry
@@ -1831,13 +2159,8 @@ export function promptExemptToolNames(registry) {
1831
2159
  .map((t) => t.name)
1832
2160
  .sort();
1833
2161
  }
1834
- /**
1835
- * A batch needs explicit approval when any call mutates state: flagged
1836
- * destructive by the SDK, or not exempt from the prompt.
1837
- */
1838
- export function batchNeedsPrompt(toolCalls, exempt) {
1839
- return toolCalls.some((tc) => tc.isDestructive || !exempt(tc.name, tc.input));
1840
- }
2162
+ /** A batch needs explicit approval when any call mutates state. */
2163
+ export const batchNeedsPrompt = batchNeedsReview;
1841
2164
  /**
1842
2165
  * Translate one SDK `RunEvent` into the TUI's `AgentEvent` vocabulary, or
1843
2166
  * `null` for events the chat surface doesn't render (iteration markers,
@@ -1852,6 +2175,14 @@ export function toAgentEvent(event, presenter) {
1852
2175
  ...(event.messageId ? { messageId: event.messageId } : {}),
1853
2176
  ...(event.runId ? { runId: event.runId } : {}),
1854
2177
  };
2178
+ case 'reasoning_started':
2179
+ // A redacted block has no text to show; the empty delta still says
2180
+ // "thinking" so the region does not sit silent for its duration.
2181
+ return { kind: 'reasoning', text: '' };
2182
+ case 'reasoning_delta':
2183
+ return { kind: 'reasoning', text: event.text };
2184
+ case 'reasoning_completed':
2185
+ return { kind: 'reasoning', text: '', done: true };
1855
2186
  case 'tool_executing':
1856
2187
  return {
1857
2188
  kind: 'tool-start',
@@ -1883,9 +2214,15 @@ export function toAgentEvent(event, presenter) {
1883
2214
  success: !event.isError,
1884
2215
  output: event.result,
1885
2216
  });
1886
- const summary = firstLine(event.result);
1887
2217
  const detail = viewToLines(view);
1888
- const withoutRepeatedSummary = view.kind === 'terminal' && detail?.[0] === summary ? detail.slice(1) : detail;
2218
+ // For output shown line by line, the summary IS the first rendered
2219
+ // line, so the body can drop it without a second, differently
2220
+ // whitespaced copy of the same text — a `read` used to show its
2221
+ // first line twice, once collapsed and once numbered.
2222
+ const summary = view.kind === 'terminal' && detail && detail.length > 0
2223
+ ? truncate(detail[0], 120)
2224
+ : firstLine(event.result);
2225
+ const withoutRepeatedSummary = view.kind === 'terminal' && detail && detail.length > 0 ? detail.slice(1) : detail;
1889
2226
  return {
1890
2227
  kind: 'tool-end',
1891
2228
  runId: event.runId,
@@ -1917,6 +2254,7 @@ export function toAgentEvent(event, presenter) {
1917
2254
  return {
1918
2255
  kind: 'usage',
1919
2256
  totalTokens: event.usage.totalTokens,
2257
+ ...(event.budget ? { budget: event.budget } : {}),
1920
2258
  cost: event.cost,
1921
2259
  ...(event.contextTokens !== undefined ? { contextTokens: event.contextTokens } : {}),
1922
2260
  ...(event.contextMeasuredBy !== undefined
@@ -1963,13 +2301,16 @@ export function toAgentEvent(event, presenter) {
1963
2301
  };
1964
2302
  }
1965
2303
  case 'task_created':
1966
- return { kind: 'task', subject: event.subject, status: event.status };
1967
2304
  case 'task_updated':
1968
- // Only surface completions — skip pending/in-progress churn so the
1969
- // transcript shows "todo added" then "todo done", not every flip.
1970
- return event.status === 'completed'
1971
- ? { kind: 'task', subject: event.subject, status: event.status }
1972
- : null;
2305
+ // Every change, not only completions: the live task list needs the
2306
+ // in-progress flips to show which step is current. The transcript
2307
+ // decides for itself which of these it records.
2308
+ return {
2309
+ kind: 'task',
2310
+ taskId: String(event.taskId),
2311
+ subject: event.subject,
2312
+ status: event.status,
2313
+ };
1973
2314
  case 'run_paused':
1974
2315
  // A pause is not an error and not an invisible end. The checkpoint and
1975
2316
  // classification are the recovery surface; dropping this event made a
@@ -1977,6 +2318,8 @@ export function toAgentEvent(event, presenter) {
1977
2318
  // the SDK had explicitly stopped.
1978
2319
  return {
1979
2320
  kind: 'paused',
2321
+ ...(event.budget ? { budget: event.budget } : {}),
2322
+ runId: String(event.runId),
1980
2323
  checkpointId: event.checkpointId,
1981
2324
  reason: event.reason,
1982
2325
  ...(event.failure ? { failure: event.failure } : {}),
@@ -1991,6 +2334,7 @@ export function toAgentEvent(event, presenter) {
1991
2334
  // answer was refused.
1992
2335
  return {
1993
2336
  kind: 'done',
2337
+ ...(event.budget ? { budget: event.budget } : {}),
1994
2338
  ...(event.stopReason ? { stopReason: event.stopReason } : {}),
1995
2339
  };
1996
2340
  case 'run_failed':
@@ -2000,13 +2344,29 @@ export function toAgentEvent(event, presenter) {
2000
2344
  // still forcing every host to parse prose.
2001
2345
  return {
2002
2346
  kind: 'error',
2347
+ ...(event.budget ? { budget: event.budget } : {}),
2003
2348
  message: event.error,
2004
2349
  ...(event.failure ? { failure: event.failure } : {}),
2005
2350
  ...(event.providerError ? { providerError: event.providerError } : {}),
2006
2351
  ...(event.explanation ? { explanation: event.explanation } : {}),
2007
2352
  };
2353
+ case 'background_job_exited':
2354
+ return {
2355
+ kind: 'job',
2356
+ jobId: event.jobId,
2357
+ command: event.command,
2358
+ status: event.status,
2359
+ ...(event.exitCode !== undefined ? { exitCode: event.exitCode } : {}),
2360
+ ...(event.signal ? { signal: event.signal } : {}),
2361
+ };
2008
2362
  case 'compaction_completed':
2009
- return { kind: 'context', text: describeCompaction(event), shed: true };
2363
+ return {
2364
+ kind: 'context',
2365
+ text: describeCompaction(event),
2366
+ shed: true,
2367
+ summarised: true,
2368
+ reclaimedTokens: Math.max(0, event.tokensBefore - event.tokensAfter),
2369
+ };
2010
2370
  case 'compaction_tool_results_cleared':
2011
2371
  // `shed: true` on both branches: the tool-result bodies are gone
2012
2372
  // either way. `reliefWasEnough: false` additionally means a
@@ -2014,8 +2374,11 @@ export function toAgentEvent(event, presenter) {
2014
2374
  // so this one says what IT cost rather than claiming the total.
2015
2375
  return {
2016
2376
  kind: 'context',
2017
- text: `cleared ${event.clearedCount} oversized tool result${event.clearedCount === 1 ? '' : 's'} (~${event.reclaimedTokens.toLocaleString()} tokens)${event.reliefWasEnough ? '' : ' — not enough, compacting'}`,
2377
+ text: `cleared ${event.clearedCount} tool result${event.clearedCount === 1 ? '' : 's'}${event.stubbedCount ? `, stubbed ${event.stubbedCount} narration${event.stubbedCount === 1 ? '' : 's'}` : ''} (~${event.reclaimedTokens.toLocaleString()} tokens)${event.reliefWasEnough ? '' : ' — not enough, compacting'}`,
2018
2378
  shed: true,
2379
+ cleared: event.clearedCount,
2380
+ stubbed: event.stubbedCount ?? 0,
2381
+ reclaimedTokens: event.reclaimedTokens,
2019
2382
  };
2020
2383
  case 'compaction_failed':
2021
2384
  return {
@@ -2355,6 +2718,9 @@ function emptySession(errorHint, errorKind = 'environment') {
2355
2718
  resumeDurable: async () => {
2356
2719
  throw new Error(errorHint);
2357
2720
  },
2721
+ resumePaused: async function* () {
2722
+ yield { kind: 'error', message: 'no provider: nothing to resume' };
2723
+ },
2358
2724
  close: async () => {
2359
2725
  // Nothing was ever connected on this path.
2360
2726
  },