@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
package/dist/tui/agent.js CHANGED
@@ -20,10 +20,12 @@
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, GuardedFetchProvider, PromptContributionRegistry, ProviderRegistry, SESSION_GOAL_TOOL_NAMES, SearchToolsTool, ToolRegistry, WebFetchTool, asProjectId, asRunId, asSessionId, asTenantId, asTopicId, buildCoordinatorTools, buildMemoryTools, buildSessionGoalTools, compactNow, createComputerUseTool, createMemoryPromoter, createToolPresenter, generateRunId, getBuiltinTools, isTrustedReadOnly, query, resumeRun, webGuidanceContribution, 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';
28
30
  import { NAMZU_DELEGATION_DOCTRINE, NAMZU_PLAN_MODE_DOCTRINE, NAMZU_WORKING_DOCTRINE, } from '../context/doctrine.js';
29
31
  import { composeEnvironmentPrompt, readEnvironmentFacts } from '../context/environment.js';
@@ -34,11 +36,12 @@ import { connectMcpServers, } from '../integrations/mcp/servers.js';
34
36
  import { createCliPluginRuntime } from '../integrations/plugins/runtime.js';
35
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';
36
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';
37
41
  import { CLI_INTERACTIVE_RUN_TIMEOUT_MS } from '../integrations/subagents/policy.js';
38
42
  import { createSubagentRuntime } from '../integrations/subagents/runtime.js';
39
43
  import { cliLogger } from '../logging.js';
40
44
  import { composeMemoryPrompt, readMemory } from '../memory/store.js';
41
- import { ACCEPT_EDITS_TOOLS, PLAN_MODE_REFUSAL } from '../permissions/mode.js';
42
45
  import { projectRunConversation } from './conversation-history.js';
43
46
  /**
44
47
  * Let one caller stop waiting without cutting a shared queue in the middle.
@@ -241,7 +244,17 @@ async function drainIterator(iterator) {
241
244
  */
242
245
  export async function probeAgentSession() {
243
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');
244
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
+ });
245
258
  switch (read.status) {
246
259
  case 'ok':
247
260
  return {
@@ -360,9 +373,21 @@ function builtinTools(backgroundJobs) {
360
373
  return [tool.name === 'bash' ? foregroundOnlyBash(tool) : tool];
361
374
  });
362
375
  }
363
- function buildToolRegistry(cwd, projectStateRoot = join(cwd, '.namzu'), backgroundJobs = true) {
376
+ function buildToolRegistry(cwd, projectStateRoot = join(cwd, '.namzu'), backgroundJobs = true, checkpoints) {
364
377
  const registry = new ToolRegistry();
365
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
+ }
366
391
  // SDK memory: the agent gets search_memory / read_memory / save_memory over
367
392
  // a structured store in this Project's generated-state directory. CLI
368
393
  // surfaces inject the central application-home hierarchy; embedded callers
@@ -714,8 +739,46 @@ export async function createAgentSession(prefs, detected, options = {}) {
714
739
  [EVENT_NAME_ATTRIBUTE]: BOOT_EVENT_NAMES.SANDBOX_RESOLVED,
715
740
  'namzu.sandbox.unconfined': sandbox.unconfined,
716
741
  });
717
- const backgroundJobs = sandbox.provider === undefined;
718
- 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);
719
782
  // Package presence is not tool reachability. The CLI used to probe and
720
783
  // report @namzu/computer-use without ever constructing its host or mounting
721
784
  // SDK's computer_use definition, so even an installed, healthy package was
@@ -735,6 +798,28 @@ export async function createAgentSession(prefs, detected, options = {}) {
735
798
  catch (error) {
736
799
  computerUseError = error instanceof Error ? error : new Error(String(error));
737
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
+ }));
738
823
  }
739
824
  }
740
825
  // Registered only on the main session path. Sub-agents call
@@ -804,7 +889,8 @@ export async function createAgentSession(prefs, detected, options = {}) {
804
889
  // Native sub-agents: register the canonical `Agent` tool so the model can
805
890
  // delegate a self-contained task to a fresh sub-agent (own context window).
806
891
  // Best-effort — if the runtime can't stand up, the chat still works.
807
- let subagentGateway;
892
+ const delegationScopes = new Map();
893
+ const delegatedInputWaiters = new Map();
808
894
  let subagentRuntime;
809
895
  // Stays empty when the runtime below throws, which is the honest answer: the
810
896
  // catch is non-fatal and the session then genuinely has no delegate to
@@ -812,12 +898,31 @@ export async function createAgentSession(prefs, detected, options = {}) {
812
898
  // would name agents that are not there.
813
899
  let allowedAgentIds = [];
814
900
  try {
901
+ // Agents the project or user defined in files. A file that cannot be
902
+ // loaded is named with its reason rather than silently absent: "namzu
903
+ // ignored my reviewer" and "namzu never saw it" call for opposite fixes.
904
+ const discovered = await discoverAgentDefinitions({ cwd });
905
+ for (const skipped of discovered.skipped) {
906
+ cliLogger().warn('agent definition skipped', {
907
+ 'namzu.agent.definition.path': skipped.path,
908
+ 'namzu.agent.definition.reason': skipped.reason,
909
+ });
910
+ }
815
911
  const sub = await createSubagentRuntime({
816
912
  cwd,
817
913
  model,
818
- pathBuilder: new DefaultPathBuilder(join(projectStateRoot, 'subagents')),
914
+ tokenBudget: options.limits?.tokenBudget,
915
+ definitions: discovered.definitions,
916
+ pathBuilder: new SubagentPathBuilder(projectStateRoot, scope.projectId),
917
+ resolveParent: async (runId) => {
918
+ const parent = delegationScopes.get(runId);
919
+ if (!parent)
920
+ throw new Error(`Run ${runId} no longer owns delegation authority`);
921
+ return resolveSubagentParent(parent, cwd, options.stateRoot ? hierarchyRoot : undefined);
922
+ },
819
923
  sandboxWorkspace,
820
924
  resolveResumeHandler: (runId) => delegatedResumeHandlers.get(runId),
925
+ resolveWaitForInbound: (runId) => delegatedInputWaiters.get(runId),
821
926
  ...(sandbox.provider ? { sandboxProvider: sandbox.provider } : {}),
822
927
  ...(options.sandbox?.teardownTimeoutMs !== undefined
823
928
  ? { sandboxTeardownTimeoutMs: options.sandbox.teardownTimeoutMs }
@@ -861,8 +966,7 @@ export async function createAgentSession(prefs, detected, options = {}) {
861
966
  authorizationGate: gateFor(options.rules),
862
967
  });
863
968
  subagentRuntime = sub;
864
- registry.register([sub.agentTool]);
865
- subagentGateway = sub.gateway;
969
+ registry.register([sub.agentTool, sub.waitForTaskTool]);
866
970
  allowedAgentIds = sub.allowedAgentIds;
867
971
  }
868
972
  catch (err) {
@@ -883,7 +987,7 @@ export async function createAgentSession(prefs, detected, options = {}) {
883
987
  // gateway the tool builder requires; a session without one has no
884
988
  // question tool either, and says nothing — it also has no `Agent`.
885
989
  let currentOnQuestion;
886
- if (options.askUser && subagentGateway) {
990
+ if (options.askUser && subagentRuntime) {
887
991
  const parkQuestion = async (request) => {
888
992
  if (request.type !== 'user_question')
889
993
  return { action: 'continue' };
@@ -905,20 +1009,10 @@ export async function createAgentSession(prefs, detected, options = {}) {
905
1009
  return { action: 'continue' };
906
1010
  }
907
1011
  };
908
- const askTool = buildCoordinatorTools({
909
- gateway: subagentGateway,
910
- workingDirectory: cwd,
911
- allowedAgentIds: [],
912
- allowDelegation: false,
913
- resumeHandler: parkQuestion,
914
- // The builder stamps this on the park request. The handler above
915
- // routes by the question, not by the run, and no durable park
916
- // recorder is supplied, so a session-scoped id is what is true: the
917
- // tool is built once per session and the turn is not known yet.
918
- runId: asRunId('run_namzu-interactive-question'),
919
- }).find((tool) => tool.name === 'ask_user_question');
920
- if (askTool)
921
- registry.register(askTool);
1012
+ // The park request carries the run id of the call that asked; the
1013
+ // handler above routes by the question, not by the run, and no durable
1014
+ // park recorder is supplied.
1015
+ registry.register(buildAskUserQuestionTool({ resumeHandler: parkQuestion }));
922
1016
  }
923
1017
  // Task store → query registers task_create / task_update / task_list and
924
1018
  // emits task_created/task_updated, so the agent can track a plan for the
@@ -937,11 +1031,39 @@ export async function createAgentSession(prefs, detected, options = {}) {
937
1031
  // captured on this line. The count at connect time is unchanged; what
938
1032
  // changes is that asking again later gets a later answer.
939
1033
  ensurePrivateStateDirectory(projectStateRoot, 'tenants');
940
- const taskStore = new DiskTaskStore({
1034
+ const taskStoreForRun = (runId, tenantId) => new DiskTaskStore({
941
1035
  baseDir: projectStateRoot,
942
- defaultRunId: asRunId('run_namzu-cli'),
943
- tenantId: scope.tenantId,
1036
+ defaultRunId: runId,
1037
+ tenantId,
944
1038
  });
1039
+ let selectedTaskStore;
1040
+ let taskSelectionGeneration = 0;
1041
+ const resetTaskStore = () => {
1042
+ selectedTaskStore = undefined;
1043
+ taskSelectionGeneration += 1;
1044
+ };
1045
+ const matchesCurrentScope = (candidate) => candidate.sessionId === scope.sessionId &&
1046
+ candidate.projectId === scope.projectId &&
1047
+ candidate.tenantId === scope.tenantId &&
1048
+ candidate.topicId === scope.topicId;
1049
+ const currentTaskStore = () => {
1050
+ if (selectedTaskStore && !matchesCurrentScope(selectedTaskStore.scope))
1051
+ resetTaskStore();
1052
+ return selectedTaskStore?.store;
1053
+ };
1054
+ const beginTaskStoreReadout = () => {
1055
+ // A starting turn must not show its predecessor's plan while credentials
1056
+ // and other asynchronous setup are still being prepared.
1057
+ resetTaskStore();
1058
+ const generation = taskSelectionGeneration;
1059
+ return (runId, runScope) => {
1060
+ const store = taskStoreForRun(runId, runScope.tenantId);
1061
+ if (generation === taskSelectionGeneration && matchesCurrentScope(runScope)) {
1062
+ selectedTaskStore = { scope: { ...runScope }, store };
1063
+ }
1064
+ return store;
1065
+ };
1066
+ };
945
1067
  // Persists across turns: once the user picks "approve all", later tool
946
1068
  // batches in this session run without prompting.
947
1069
  const approval = { all: false };
@@ -963,12 +1085,31 @@ export async function createAgentSession(prefs, detected, options = {}) {
963
1085
  // registries, so executable plugins remain a top-level-session capability.
964
1086
  let pluginRuntime;
965
1087
  try {
966
- pluginRuntime = await createCliPluginRuntime(options.plugins, registry, cwd);
1088
+ pluginRuntime = await createCliPluginRuntime(options.plugins, registry, cwd, options.hooks);
967
1089
  }
968
1090
  catch (error) {
969
1091
  await Promise.allSettled([mcp.close(), computerUseHost?.dispose()]);
970
1092
  return emptySession(describeError(error));
971
1093
  }
1094
+ // The session's own lifecycle, for hooks that set up or tear down
1095
+ // something per session rather than per run. The run id is minted for
1096
+ // these two calls: they belong to no turn. `session_start` waits for
1097
+ // the first turn rather than firing here, because the conversation id
1098
+ // the scope holds at construction is provisional — it is replaced when
1099
+ // the conversation is first made durable — and a hook given the
1100
+ // provisional id could never match it to a run.
1101
+ const sessionPlugins = pluginRuntime;
1102
+ const sessionHookRunId = generateRunId();
1103
+ let sessionStarted = false;
1104
+ const announceSessionStart = async () => {
1105
+ if (!sessionPlugins || sessionStarted)
1106
+ return;
1107
+ sessionStarted = true;
1108
+ await sessionPlugins.manager.executeHooks('session_start', {
1109
+ runId: sessionHookRunId,
1110
+ sessionId: scope.sessionId,
1111
+ });
1112
+ };
972
1113
  if (pluginRuntime) {
973
1114
  cliLogger().info('discovery complete', {
974
1115
  [EVENT_NAME_ATTRIBUTE]: BOOT_EVENT_NAMES.DISCOVERY_COMPLETED,
@@ -990,9 +1131,19 @@ export async function createAgentSession(prefs, detected, options = {}) {
990
1131
  const operations = new SessionOperationOwner(async () => {
991
1132
  const results = await Promise.allSettled([
992
1133
  subagentRuntime?.close?.(),
993
- pluginRuntime?.close(),
1134
+ sessionPlugins
1135
+ ? sessionPlugins.manager
1136
+ .executeHooks('session_end', {
1137
+ runId: sessionHookRunId,
1138
+ sessionId: scope.sessionId,
1139
+ })
1140
+ .catch(() => [])
1141
+ .then(() => sessionPlugins.close())
1142
+ : undefined,
994
1143
  mcp.close(),
995
1144
  computerUseHost?.dispose(),
1145
+ jobRegistry?.killOwner(jobOwner),
1146
+ checkpoints.close(),
996
1147
  ]);
997
1148
  const failures = results
998
1149
  .filter((result) => result.status === 'rejected')
@@ -1032,6 +1183,200 @@ export async function createAgentSession(prefs, detected, options = {}) {
1032
1183
  reasoningEffortDefault = undefined;
1033
1184
  effortNotice = `Reasoning effort levels could not be established for this session: ${describeError(error)}`;
1034
1185
  }
1186
+ /**
1187
+ * The kernel's resume with this session's half of the run attached: the
1188
+ * provider, the tools, the working directory, the doctrine — the part a
1189
+ * checkpoint cannot carry. `resumeDurable` and `resumePaused` differ only
1190
+ * in where the run and its store come from.
1191
+ */
1192
+ const kernelResume = ({ entry, checkpointStore, claimFence, signal, checkpointId, listener, }) => operations.promise(signal, async (ownedSignal) => {
1193
+ const selectTaskStore = beginTaskStoreReadout();
1194
+ // The same prelude a turn runs, and for the same reasons: a lapsed
1195
+ // OAuth token has to be renewed before the provider is used, and the
1196
+ // fallback chain has to be built AFTER that so its members do not
1197
+ // hold a client the refresh just replaced.
1198
+ await prepareProviderCredential(ownedSignal);
1199
+ const pluginSkills = pluginRuntime
1200
+ ? await currentPluginSkills(pluginRuntime.skills)
1201
+ : undefined;
1202
+ const memoryPrompt = composeMemoryPrompt(readMemory(undefined, cwd));
1203
+ const environmentPrompt = composeEnvironmentPrompt({
1204
+ ...(await readEnvironmentFacts(cwd)),
1205
+ additionalDirectories: [...directories],
1206
+ });
1207
+ const systemPrompt = [
1208
+ NAMZU_IDENTITY,
1209
+ NAMZU_WORKING_DOCTRINE,
1210
+ NAMZU_DELEGATION_DOCTRINE,
1211
+ environmentPrompt,
1212
+ memoryPrompt,
1213
+ ]
1214
+ .filter((s) => Boolean(s))
1215
+ .join('\n\n') || undefined;
1216
+ const resumeHandler = makeResumeHandler(approval, undefined, options.permissionMode, (name, input) => isPromptExempt(registry, name, input));
1217
+ if (delegatedResumeHandlers.has(entry.runId)) {
1218
+ throw new Error(`Run ${entry.runId} already owns a delegated review channel.`);
1219
+ }
1220
+ delegatedResumeHandlers.set(entry.runId, resumeHandler);
1221
+ delegationScopes.set(entry.runId, { ...entry, topicId: scope.topicId });
1222
+ try {
1223
+ return await resumeRun({
1224
+ provider,
1225
+ fallbackProviders: fallbackPlan.build(currentToken),
1226
+ tools: registry,
1227
+ pluginManager: pluginRuntime?.manager,
1228
+ skillRegistry: pluginRuntime?.skills,
1229
+ skills: pluginSkills,
1230
+ taskStore: selectTaskStore(entry.runId, { ...entry, topicId: scope.topicId }),
1231
+ // The same availability the original run registered under.
1232
+ // A resumed run re-registers the task tools; leaving them at
1233
+ // the kernel's `deferred` default would hand the model a plan
1234
+ // it started with active tools and can no longer update.
1235
+ runtimeToolOverrides: {
1236
+ task_create: 'active',
1237
+ task_update: 'active',
1238
+ task_list: 'active',
1239
+ },
1240
+ ...(subagentRuntime
1241
+ ? {
1242
+ taskScheduler: await subagentRuntime.gatewayForRun(entry.runId),
1243
+ }
1244
+ : {}),
1245
+ authorizationGate: gateFor(options.rules),
1246
+ compactionConfig: compactionConfigFor(options.compaction),
1247
+ projectInstructionContext: projectInstructions.createRunContext(),
1248
+ pathBuilder,
1249
+ ...(sandbox.provider ? { sandboxProvider: sandbox.provider } : {}),
1250
+ ...(options.sandbox?.teardownTimeoutMs !== undefined
1251
+ ? { sandboxTeardownTimeoutMs: options.sandbox.teardownTimeoutMs }
1252
+ : {}),
1253
+ // NOT `emergencySave`, unlike a turn. The manager is a singleton
1254
+ // whose `attach` detaches whoever held it before, so a caller
1255
+ // resuming several runs in one process would leave only the last
1256
+ // one covered — and would look covered. A turn owns its process
1257
+ // end to end; a drainer does not.
1258
+ runConfig: {
1259
+ model,
1260
+ ...(sandbox.provider ? { sandbox: { workspace: sandboxWorkspace } } : {}),
1261
+ timeoutMs: CLI_INTERACTIVE_RUN_TIMEOUT_MS,
1262
+ tokenBudget: options.limits?.tokenBudget ?? 1_000_000,
1263
+ maxIterations: options.limits?.maxIterations ?? 50,
1264
+ maxResponseTokens: 8192,
1265
+ permissionMode: 'auto',
1266
+ },
1267
+ agentId: 'namzu',
1268
+ agentName: 'namzu',
1269
+ ...(systemPrompt ? { systemPrompt } : {}),
1270
+ workingDirectory: cwd,
1271
+ ...(directories.length > 0 ? { additionalDirectories: [...directories] } : {}),
1272
+ ...(options.limits ? { limits: options.limits } : {}),
1273
+ // No `onPermission`: there is nobody at a drainer's terminal, so a
1274
+ // prompt would block the pass forever on a run nobody is watching.
1275
+ // The gate's deny rules still apply.
1276
+ // One presenter for the whole stream, built from the registry this
1277
+ // scope already holds. It was the absence of the registry HERE that
1278
+ // forced presentation to be name matching: `toAgentEvent` was pure
1279
+ // over a `RunEvent` and could not ask a tool anything.
1280
+ resumeHandler,
1281
+ signal: ownedSignal,
1282
+ // Attribution comes from the ENTRY, not from this session: the run
1283
+ // belongs to whoever started it, and stamping the drainer's ids onto
1284
+ // it would file another tenant's work under this one.
1285
+ tenantId: entry.tenantId,
1286
+ projectId: entry.projectId,
1287
+ sessionId: entry.sessionId,
1288
+ // …except the topic, which no checkpoint records — see
1289
+ // `RunStateScope`. This one is the drainer's, and honestly so:
1290
+ // supplied here rather than pretended to have been recovered.
1291
+ topicId: scope.topicId,
1292
+ scope: { ...entry, topicId: scope.topicId },
1293
+ checkpointStore,
1294
+ ...(claimFence !== undefined ? { claimFence } : {}),
1295
+ ...(checkpointId !== undefined ? { checkpointId } : {}),
1296
+ ...(listener ? { listener } : {}),
1297
+ });
1298
+ }
1299
+ finally {
1300
+ if (delegatedResumeHandlers.get(entry.runId) === resumeHandler) {
1301
+ delegatedResumeHandlers.delete(entry.runId);
1302
+ delegationScopes.delete(entry.runId);
1303
+ await subagentRuntime?.releaseRun(entry.runId);
1304
+ }
1305
+ }
1306
+ });
1307
+ /**
1308
+ * `resumeRun` drains the loop and returns a settled run; the events go to a
1309
+ * listener. A small queue turns that into the stream `send` gives, so a
1310
+ * headless caller renders a resumed run exactly as it rendered the turn.
1311
+ */
1312
+ const resumePausedStream = ({ runId, checkpointId, signal, }) => {
1313
+ const queue = [];
1314
+ let wake;
1315
+ let settled = false;
1316
+ let failure;
1317
+ const presenter = createToolPresenter(registry);
1318
+ // The store the turn's run manager wrote to, built the same way it
1319
+ // built it (see the kernel's `RunPersistence`): the session directory's
1320
+ // `runs/`, attributed to this tenant and project.
1321
+ const store = new DiskCheckpointStore({
1322
+ baseDir: join(pathBuilder.sessionDir(scope.projectId, scope.sessionId), 'runs'),
1323
+ }, {
1324
+ tenantId: scope.tenantId,
1325
+ projectId: scope.projectId,
1326
+ sessionId: scope.sessionId,
1327
+ });
1328
+ const entry = {
1329
+ tenantId: scope.tenantId,
1330
+ projectId: scope.projectId,
1331
+ sessionId: scope.sessionId,
1332
+ runId: runId,
1333
+ };
1334
+ const outcome = kernelResume({
1335
+ entry,
1336
+ checkpointStore: store,
1337
+ ...(signal ? { signal } : {}),
1338
+ checkpointId: checkpointId,
1339
+ listener: (event) => {
1340
+ queue.push(event);
1341
+ wake?.();
1342
+ },
1343
+ })
1344
+ .then((result) => {
1345
+ if (!result.resumed) {
1346
+ failure = new Error(result.reason === 'no-checkpoint'
1347
+ ? `no checkpoint ${checkpointId} is recorded for run ${runId}`
1348
+ : `run ${runId} is parked on a decision only a person can answer`);
1349
+ }
1350
+ })
1351
+ .catch((err) => {
1352
+ failure = err instanceof Error ? err : new Error(String(err));
1353
+ })
1354
+ .finally(() => {
1355
+ settled = true;
1356
+ wake?.();
1357
+ });
1358
+ return (async function* () {
1359
+ for (;;) {
1360
+ while (queue.length > 0) {
1361
+ const next = queue.shift();
1362
+ if (!next)
1363
+ break;
1364
+ const mapped = toAgentEvent(next, presenter);
1365
+ if (mapped)
1366
+ yield mapped;
1367
+ }
1368
+ if (settled)
1369
+ break;
1370
+ await new Promise((resolve) => {
1371
+ wake = resolve;
1372
+ });
1373
+ wake = undefined;
1374
+ }
1375
+ await outcome;
1376
+ if (failure)
1377
+ yield { kind: 'error', message: failure.message };
1378
+ })();
1379
+ };
1035
1380
  return {
1036
1381
  hasProvider: true,
1037
1382
  sandbox: {
@@ -1049,7 +1394,7 @@ export async function createAgentSession(prefs, detected, options = {}) {
1049
1394
  await prepareProviderCredential(signal);
1050
1395
  return compactNow({
1051
1396
  messages,
1052
- config: COMPACTION_CONFIG,
1397
+ config: compactionConfigFor(options.compaction),
1053
1398
  provider,
1054
1399
  model,
1055
1400
  signal,
@@ -1063,6 +1408,16 @@ export async function createAgentSession(prefs, detected, options = {}) {
1063
1408
  .map((t) => t.name)
1064
1409
  .filter((name) => !goalToolNames.has(name)),
1065
1410
  agentIds: allowedAgentIds,
1411
+ currentTaskStore,
1412
+ resetTaskStore,
1413
+ jobs: () => jobRegistry?.list(jobOwner) ?? [],
1414
+ ...(options.hooks ? { hooks: options.hooks } : {}),
1415
+ checkpoints,
1416
+ directories: sessionDirectories,
1417
+ onJobExit: (listener) => jobRegistry?.onExit((job) => {
1418
+ if (job.owner === jobOwner)
1419
+ listener(job);
1420
+ }) ?? (() => { }),
1066
1421
  ...(subagentRuntime ? { subagents: subagentRuntime.activity } : {}),
1067
1422
  get instructionFiles() {
1068
1423
  return projectInstructions.instructionFiles;
@@ -1096,6 +1451,7 @@ export async function createAgentSession(prefs, detected, options = {}) {
1096
1451
  },
1097
1452
  promptExemptTools: () => promptExemptToolNames(registry).filter((name) => !goalToolNames.has(name)),
1098
1453
  send: (messages, opts) => operations.stream(opts?.signal, (signal) => (async function* () {
1454
+ const selectTaskStore = beginTaskStoreReadout();
1099
1455
  const runId = opts?.runId ?? generateRunId();
1100
1456
  const turnOpts = { ...opts, runId, signal };
1101
1457
  const resumeHandler = makeResumeHandler(approval, opts?.onPermission, opts?.permissionMode ?? options.permissionMode, (name, input) => isPromptExempt(registry, name, input));
@@ -1103,6 +1459,10 @@ export async function createAgentSession(prefs, detected, options = {}) {
1103
1459
  throw new Error(`Run ${runId} already owns a delegated review channel.`);
1104
1460
  }
1105
1461
  delegatedResumeHandlers.set(runId, resumeHandler);
1462
+ if (opts?.waitForInbound)
1463
+ delegatedInputWaiters.set(runId, opts.waitForInbound);
1464
+ const turnScope = { ...scope };
1465
+ delegationScopes.set(runId, turnScope);
1106
1466
  try {
1107
1467
  // Renew a lapsed OAuth token before the turn runs (no-op for valid
1108
1468
  // tokens and non-subscription credentials).
@@ -1121,13 +1481,16 @@ export async function createAgentSession(prefs, detected, options = {}) {
1121
1481
  const pluginSkills = pluginRuntime
1122
1482
  ? await currentPluginSkills(pluginRuntime.skills)
1123
1483
  : undefined;
1124
- const memoryPrompt = composeMemoryPrompt(readMemory());
1484
+ const memoryPrompt = composeMemoryPrompt(readMemory(undefined, cwd));
1125
1485
  currentOnQuestion = opts?.onQuestion;
1126
1486
  const [environmentFacts, turnSnapshot] = await Promise.all([
1127
1487
  readEnvironmentFacts(cwd),
1128
1488
  readTurnSnapshot(cwd),
1129
1489
  ]);
1130
- const environmentPrompt = composeEnvironmentPrompt(environmentFacts);
1490
+ const environmentPrompt = composeEnvironmentPrompt({
1491
+ ...environmentFacts,
1492
+ additionalDirectories: [...directories],
1493
+ });
1131
1494
  // The repository as it stood when THIS turn began, through the
1132
1495
  // SDK's `turn` placement — the ephemeral trailing message that is
1133
1496
  // never cached and never enters history. FIRST iteration only:
@@ -1162,6 +1525,8 @@ export async function createAgentSession(prefs, detected, options = {}) {
1162
1525
  ]
1163
1526
  .filter((s) => Boolean(s))
1164
1527
  .join('\n\n') || undefined;
1528
+ await announceSessionStart();
1529
+ checkpoints.beginTurn(lastUserText(messages));
1165
1530
  let capturedAuthority;
1166
1531
  if (opts?.goalRound) {
1167
1532
  if (!opts.runId)
@@ -1191,6 +1556,14 @@ export async function createAgentSession(prefs, detected, options = {}) {
1191
1556
  try {
1192
1557
  yield* runTurn({
1193
1558
  provider,
1559
+ compactionConfig: compactionConfigFor(options.compaction),
1560
+ ...(options.compaction?.consolidate ? { consolidateInto: memoryStore } : {}),
1561
+ ...(jobRegistry
1562
+ ? {
1563
+ backgroundJobs: jobRegistry,
1564
+ backgroundJobOwner: jobOwner,
1565
+ }
1566
+ : {}),
1194
1567
  // Constructed HERE, per turn, and that is not an optimisation to
1195
1568
  // undo. `refreshTokenIfNeeded` above replaces the head's client
1196
1569
  // object when an OAuth token rotates, so a member list built once at
@@ -1204,21 +1577,24 @@ export async function createAgentSession(prefs, detected, options = {}) {
1204
1577
  pluginManager: pluginRuntime?.manager,
1205
1578
  skillRegistry: pluginRuntime?.skills,
1206
1579
  skills: pluginSkills,
1207
- scope,
1580
+ scope: turnScope,
1208
1581
  pathBuilder,
1209
1582
  workingDirectory: cwd,
1583
+ ...(directories.length > 0 ? { additionalDirectories: [...directories] } : {}),
1584
+ ...(options.limits ? { limits: options.limits } : {}),
1210
1585
  sandboxWorkspace,
1211
1586
  rules: options.rules,
1212
1587
  reviewAnswer: options.reviewAnswer,
1213
1588
  maxAnswerReviews: options.maxAnswerReviews,
1214
1589
  promoteMemory,
1215
- taskStore,
1590
+ taskStore: selectTaskStore(runId, turnScope),
1216
1591
  systemPrompt,
1217
1592
  messages,
1218
1593
  projectInstructionContext: projectInstructions.createRunContext(),
1219
1594
  opts: turnOpts,
1220
1595
  resumeHandler,
1221
- taskGateway: subagentGateway,
1596
+ taskGateway: await subagentRuntime?.gatewayForRun(runId),
1597
+ completionInbox: await subagentRuntime?.completionInboxForRun(runId),
1222
1598
  promptContributions,
1223
1599
  ...(webCapability ? { web: webCapability } : {}),
1224
1600
  // Active, not deferred: the doctrine tells the model to open a
@@ -1249,109 +1625,19 @@ export async function createAgentSession(prefs, detected, options = {}) {
1249
1625
  finally {
1250
1626
  if (delegatedResumeHandlers.get(runId) === resumeHandler) {
1251
1627
  delegatedResumeHandlers.delete(runId);
1628
+ delegatedInputWaiters.delete(runId);
1629
+ delegationScopes.delete(runId);
1630
+ await subagentRuntime?.releaseRun(runId);
1252
1631
  }
1253
1632
  }
1254
1633
  })()),
1255
- resumeDurable: ({ entry, checkpointStore, claimFence, signal }) => operations.promise(signal, async (ownedSignal) => {
1256
- // The same prelude a turn runs, and for the same reasons: a lapsed
1257
- // OAuth token has to be renewed before the provider is used, and the
1258
- // fallback chain has to be built AFTER that so its members do not
1259
- // hold a client the refresh just replaced.
1260
- await prepareProviderCredential(ownedSignal);
1261
- const pluginSkills = pluginRuntime
1262
- ? await currentPluginSkills(pluginRuntime.skills)
1263
- : undefined;
1264
- const memoryPrompt = composeMemoryPrompt(readMemory());
1265
- const environmentPrompt = composeEnvironmentPrompt(await readEnvironmentFacts(cwd));
1266
- const systemPrompt = [
1267
- NAMZU_IDENTITY,
1268
- NAMZU_WORKING_DOCTRINE,
1269
- NAMZU_DELEGATION_DOCTRINE,
1270
- environmentPrompt,
1271
- memoryPrompt,
1272
- ]
1273
- .filter((s) => Boolean(s))
1274
- .join('\n\n') || undefined;
1275
- const resumeHandler = makeResumeHandler(approval, undefined, options.permissionMode, (name, input) => isPromptExempt(registry, name, input));
1276
- if (delegatedResumeHandlers.has(entry.runId)) {
1277
- throw new Error(`Run ${entry.runId} already owns a delegated review channel.`);
1278
- }
1279
- delegatedResumeHandlers.set(entry.runId, resumeHandler);
1280
- try {
1281
- return await resumeRun({
1282
- provider,
1283
- fallbackProviders: fallbackPlan.build(currentToken),
1284
- tools: registry,
1285
- pluginManager: pluginRuntime?.manager,
1286
- skillRegistry: pluginRuntime?.skills,
1287
- skills: pluginSkills,
1288
- taskStore,
1289
- // The same availability the original run registered under.
1290
- // A resumed run re-registers the task tools; leaving them at
1291
- // the kernel's `deferred` default would hand the model a plan
1292
- // it started with active tools and can no longer update.
1293
- runtimeToolOverrides: {
1294
- task_create: 'active',
1295
- task_update: 'active',
1296
- task_list: 'active',
1297
- },
1298
- ...(subagentGateway ? { taskGateway: subagentGateway } : {}),
1299
- authorizationGate: gateFor(options.rules),
1300
- compactionConfig: COMPACTION_CONFIG,
1301
- projectInstructionContext: projectInstructions.createRunContext(),
1302
- pathBuilder,
1303
- ...(sandbox.provider ? { sandboxProvider: sandbox.provider } : {}),
1304
- ...(options.sandbox?.teardownTimeoutMs !== undefined
1305
- ? { sandboxTeardownTimeoutMs: options.sandbox.teardownTimeoutMs }
1306
- : {}),
1307
- // NOT `emergencySave`, unlike a turn. The manager is a singleton
1308
- // whose `attach` detaches whoever held it before, so a caller
1309
- // resuming several runs in one process would leave only the last
1310
- // one covered — and would look covered. A turn owns its process
1311
- // end to end; a drainer does not.
1312
- runConfig: {
1313
- model,
1314
- ...(sandbox.provider ? { sandbox: { workspace: sandboxWorkspace } } : {}),
1315
- timeoutMs: CLI_INTERACTIVE_RUN_TIMEOUT_MS,
1316
- tokenBudget: 1_000_000,
1317
- maxIterations: 50,
1318
- maxResponseTokens: 8192,
1319
- permissionMode: 'auto',
1320
- },
1321
- agentId: 'namzu',
1322
- agentName: 'namzu',
1323
- ...(systemPrompt ? { systemPrompt } : {}),
1324
- workingDirectory: cwd,
1325
- // No `onPermission`: there is nobody at a drainer's terminal, so a
1326
- // prompt would block the pass forever on a run nobody is watching.
1327
- // The gate's deny rules still apply.
1328
- // One presenter for the whole stream, built from the registry this
1329
- // scope already holds. It was the absence of the registry HERE that
1330
- // forced presentation to be name matching: `toAgentEvent` was pure
1331
- // over a `RunEvent` and could not ask a tool anything.
1332
- resumeHandler,
1333
- signal: ownedSignal,
1334
- // Attribution comes from the ENTRY, not from this session: the run
1335
- // belongs to whoever started it, and stamping the drainer's ids onto
1336
- // it would file another tenant's work under this one.
1337
- tenantId: entry.tenantId,
1338
- projectId: entry.projectId,
1339
- sessionId: entry.sessionId,
1340
- // …except the topic, which no checkpoint records — see
1341
- // `RunStateScope`. This one is the drainer's, and honestly so:
1342
- // supplied here rather than pretended to have been recovered.
1343
- topicId: scope.topicId,
1344
- scope: { ...entry, topicId: scope.topicId },
1345
- checkpointStore,
1346
- ...(claimFence !== undefined ? { claimFence } : {}),
1347
- });
1348
- }
1349
- finally {
1350
- if (delegatedResumeHandlers.get(entry.runId) === resumeHandler) {
1351
- delegatedResumeHandlers.delete(entry.runId);
1352
- }
1353
- }
1634
+ resumeDurable: ({ entry, checkpointStore, claimFence, signal }) => kernelResume({
1635
+ entry,
1636
+ checkpointStore,
1637
+ ...(claimFence !== undefined ? { claimFence } : {}),
1638
+ ...(signal ? { signal } : {}),
1354
1639
  }),
1640
+ resumePaused: (params) => resumePausedStream(params),
1355
1641
  };
1356
1642
  }
1357
1643
  function planFallbacks(members, detected) {
@@ -1631,18 +1917,26 @@ export async function listProviderModels(id, det) {
1631
1917
  const listing = await describeProviderModels(id, det);
1632
1918
  return listing.kind === 'ok' ? [...listing.models] : [];
1633
1919
  }
1634
- /** One scope per launched TUI session; runId is minted fresh per turn by the SDK. */
1920
+ /** The newest user turn's text, for labels. */
1921
+ function lastUserText(messages) {
1922
+ for (let i = messages.length - 1; i >= 0; i--) {
1923
+ const m = messages[i];
1924
+ if (m?.role === 'user' && typeof m.content === 'string')
1925
+ return m.content;
1926
+ }
1927
+ return '';
1928
+ }
1929
+ /**
1930
+ * A scope for a session no host supplied one for: four minted ids. Minted
1931
+ * rather than spelled, because a spelled id is a place a typo hides and
1932
+ * these types accept either spelling until they are nominal.
1933
+ */
1635
1934
  function mintScope() {
1636
- const suffix = `tui-${Date.now().toString(36)}`;
1637
- // Through the constructors rather than as four bare template literals.
1638
- // One suffix shared by four ids is exactly the shape a typo hides in —
1639
- // `top_` and `tnt_` differ by two characters, and the types accept either
1640
- // spelling for either field while they are still structural.
1641
1935
  return {
1642
- sessionId: asSessionId(`ses_${suffix}`),
1643
- topicId: asTopicId(`top_${suffix}`),
1644
- projectId: asProjectId(`prj_${suffix}`),
1645
- tenantId: asTenantId(`tnt_${suffix}`),
1936
+ sessionId: generateSessionId(),
1937
+ topicId: generateTopicId(),
1938
+ projectId: generateProjectId(),
1939
+ tenantId: generateTenantId(),
1646
1940
  };
1647
1941
  }
1648
1942
  // Pre-execution safety gate: hard-deny catastrophic shell patterns
@@ -1681,7 +1975,7 @@ function gateFor(rules) {
1681
1975
  // from `runConfig.model`, which is the value the user actually chose. Pinning
1682
1976
  // a number here would fix one window across every model the CLI can talk to.
1683
1977
  const COMPACTION_CONFIG = {
1684
- strategy: 'structured',
1978
+ strategy: 'salience',
1685
1979
  // On, and this is the CLI making a choice rather than taking a default.
1686
1980
  // A session's transcript is the only record of what was compacted away;
1687
1981
  // the size trade this costs is the operator's to see and turn off.
@@ -1709,7 +2003,17 @@ const COMPACTION_CONFIG = {
1709
2003
  maxCharsPerRequirement: 300,
1710
2004
  maxCharsPerTask: 400,
1711
2005
  };
1712
- 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, promptContributions, runtimeToolOverrides, web, sandboxProvider, sandboxTeardownTimeoutMs, onRunEvent, }) {
2006
+ /** The shipped configuration with the strategy the project chose, if it chose one. */
2007
+ function compactionConfigFor(compaction) {
2008
+ return {
2009
+ ...COMPACTION_CONFIG,
2010
+ strategy: compaction?.strategy ?? COMPACTION_CONFIG.strategy,
2011
+ ...(compaction?.contextWindowTokens !== undefined
2012
+ ? { contextWindowTokens: compaction.contextWindowTokens }
2013
+ : {}),
2014
+ };
2015
+ }
2016
+ 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, completionInbox, promptContributions, runtimeToolOverrides, web, sandboxProvider, sandboxTeardownTimeoutMs, onRunEvent, }) {
1713
2017
  const signal = opts?.signal;
1714
2018
  // One presenter for the whole stream, built from the registry this scope
1715
2019
  // already holds. Its absence HERE is what forced presentation to be name
@@ -1733,7 +2037,7 @@ async function* runTurn({ provider, fallbackProviders, model, tools, pluginManag
1733
2037
  // turn. An admitted send owns the exact run-scoped authority above.
1734
2038
  ...(!opts?.goalRound ? { deniedTools: SESSION_GOAL_TOOL_NAMES } : {}),
1735
2039
  taskStore,
1736
- ...(taskGateway ? { taskGateway } : {}),
2040
+ ...(taskGateway ? { taskScheduler: taskGateway } : {}),
1737
2041
  // `gateFor`, not the bare default: the default's `rules` is a hardcoded
1738
2042
  // empty array, so passing it here discarded the operator's rules on the
1739
2043
  // path that runs every top-level turn. The sub-agent path called
@@ -1741,7 +2045,9 @@ async function* runTurn({ provider, fallbackProviders, model, tools, pluginManag
1741
2045
  ...(sandboxProvider ? { sandboxProvider } : {}),
1742
2046
  ...(sandboxTeardownTimeoutMs !== undefined ? { sandboxTeardownTimeoutMs } : {}),
1743
2047
  authorizationGate: gateFor(rules),
1744
- compactionConfig: COMPACTION_CONFIG,
2048
+ compactionConfig,
2049
+ ...(consolidateInto ? { consolidateInto } : {}),
2050
+ ...(backgroundJobs ? { backgroundJobs, backgroundJobOwner } : {}),
1745
2051
  // The CLI owns its process end to end, so it can safely hand the
1746
2052
  // termination path to the kernel: a Ctrl-C mid-run now leaves a
1747
2053
  // dump under the injected hierarchy's emergency partition instead of
@@ -1752,8 +2058,8 @@ async function* runTurn({ provider, fallbackProviders, model, tools, pluginManag
1752
2058
  ...(sandboxProvider ? { sandbox: { workspace: sandboxWorkspace } } : {}),
1753
2059
  ...(opts?.effort !== undefined ? { effort: opts.effort } : {}),
1754
2060
  timeoutMs: CLI_INTERACTIVE_RUN_TIMEOUT_MS,
1755
- tokenBudget: 1_000_000,
1756
- maxIterations: 50,
2061
+ tokenBudget: limits?.tokenBudget ?? 1_000_000,
2062
+ maxIterations: limits?.maxIterations ?? 50,
1757
2063
  maxResponseTokens: 8192,
1758
2064
  permissionMode: 'auto',
1759
2065
  },
@@ -1773,7 +2079,10 @@ async function* runTurn({ provider, fallbackProviders, model, tools, pluginManag
1773
2079
  projectInstructionContext,
1774
2080
  messages: [...messages],
1775
2081
  ...(opts?.inboundMessages ? { inboundMessages: opts.inboundMessages } : {}),
2082
+ ...(opts?.waitForInbound ? { waitForInbound: opts.waitForInbound } : {}),
2083
+ ...(completionInbox ? { completionInbox } : {}),
1776
2084
  workingDirectory,
2085
+ ...(additionalDirectories?.length ? { additionalDirectories } : {}),
1777
2086
  // The exemption reads `tools` at decision time, so it sees the task
1778
2087
  // tools `query()` registers deferred below and any tool server that
1779
2088
  // connected after this session was built.
@@ -1785,6 +2094,7 @@ async function* runTurn({ provider, fallbackProviders, model, tools, pluginManag
1785
2094
  ...scope,
1786
2095
  });
1787
2096
  let settled = false;
2097
+ let abortReported = false;
1788
2098
  try {
1789
2099
  while (true) {
1790
2100
  const next = await events.next();
@@ -1805,8 +2115,14 @@ async function* runTurn({ provider, fallbackProviders, model, tools, pluginManag
1805
2115
  // be a recording of the interface rather than of the session.
1806
2116
  onRunEvent?.(event);
1807
2117
  if (signal?.aborted) {
1808
- yield { kind: 'error', message: 'aborted' };
1809
- return;
2118
+ if (!abortReported) {
2119
+ abortReported = true;
2120
+ yield { kind: 'error', message: 'aborted' };
2121
+ }
2122
+ // Let cancellation settle in the kernel. Calling return() here
2123
+ // discarded its Run and forced App to save only visible prose,
2124
+ // losing tool receipts and reasoning before the next user turn.
2125
+ continue;
1810
2126
  }
1811
2127
  const mapped = toAgentEvent(event, presenter);
1812
2128
  if (!mapped)
@@ -1830,143 +2146,26 @@ async function* runTurn({ provider, fallbackProviders, model, tools, pluginManag
1830
2146
  }
1831
2147
  }
1832
2148
  /**
1833
- * Bridge the SDK's HITL `tool_review` request to the TUI's permission
1834
- * callback. Read-only batches (nothing destructive) run silently; batches
1835
- * with a destructive call prompt the user unless they've already chosen
1836
- * "approve all" for the session. Plans and iteration checkpoints are
1837
- * auto-continued (the TUI doesn't use plan mode).
1838
- */
1839
- export function makeResumeHandler(approval, onPermission, mode = onPermission ? 'prompt' : 'auto',
1840
- /**
1841
- * Which calls skip the prompt. Injected rather than reached for, so this
1842
- * handler stays testable without a registry — and so the answer comes from
1843
- * the live roster at the moment of the call.
1844
- */
1845
- exempt = () => false) {
1846
- return async (request) => {
1847
- if (request.type !== 'tool_review') {
1848
- return request.type === 'plan_approval' ? { action: 'approve_plan' } : { action: 'continue' };
1849
- }
1850
- // Only calls the gate routed to REVIEW arrive here — a rule that denied
1851
- // one already stopped it, and a rule that allowed one never asked. So the
1852
- // mode decides what happens to the undecided, and cannot reopen anything
1853
- // a rule closed. That is the whole precedence story between a flag and a
1854
- // config file, and it is one sentence on purpose.
1855
- if (!batchNeedsPrompt(request.toolCalls, exempt)) {
1856
- return { action: 'approve_tools' };
1857
- }
1858
- // A batch of nothing but non-destructive file edits is the case this
1859
- // mode exists for. One bash call in the same batch and the whole batch
1860
- // asks — the operator reviews the batch as a unit, and a prompt that
1861
- // showed only the shell command while the edits went through beside it
1862
- // would be approving something it did not show.
1863
- if (mode === 'accept-edits' &&
1864
- request.toolCalls.every((tc) => !tc.isDestructive && (ACCEPT_EDITS_TOOLS.has(tc.name) || exempt(tc.name, tc.input)))) {
1865
- return { action: 'approve_tools' };
1866
- }
1867
- // Reads were already approved above (they are exempt). Anything that
1868
- // reached here would change something, and plan mode's answer to that
1869
- // is the same every time: not now, tell the user what you would do.
1870
- if (mode === 'plan') {
1871
- return { action: 'reject_tools', feedback: PLAN_MODE_REFUSAL };
1872
- }
1873
- if (mode === 'strict') {
1874
- return {
1875
- action: 'reject_tools',
1876
- 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.',
1877
- };
1878
- }
1879
- if (mode === 'auto' || !onPermission || approval.all) {
1880
- return { action: 'approve_tools' };
1881
- }
1882
- const decision = await onPermission({
1883
- toolCalls: request.toolCalls.map((tc) => ({
1884
- id: tc.id,
1885
- name: tc.name,
1886
- input: tc.input,
1887
- isDestructive: tc.isDestructive,
1888
- })),
1889
- });
1890
- switch (decision.kind) {
1891
- case 'approve':
1892
- return { action: 'approve_tools' };
1893
- case 'approve-all':
1894
- approval.all = true;
1895
- return { action: 'approve_tools' };
1896
- case 'reject':
1897
- return {
1898
- action: 'reject_tools',
1899
- feedback: decision.feedback ?? 'User declined to run the proposed tool(s).',
1900
- };
1901
- }
1902
- };
1903
- }
1904
- /**
1905
- * Writes that skip the prompt anyway, in spite of declaring `readOnly: false`.
1906
- *
1907
- * This is an OVERRIDE of the tool's own declaration, and it is named as one.
1908
- * The list it replaced was called `READ_ONLY_TOOLS` and contained three tools
1909
- * that declare `readOnly: false` — a constant asserting the exact property it
1910
- * was getting wrong, which is how the disagreement survived: nothing reading it
1911
- * had reason to doubt the name.
1912
- *
1913
- * The bar for an entry is that prompting would be unusable AND a bad write
1914
- * cannot reach beyond the agent's own bookkeeping. Each one is justified here,
1915
- * or it does not belong here.
2149
+ * The kernel's review policy with the TUI's prompt behind it.
1916
2150
  *
1917
- * - `task_create` / `task_update` — the model's own plan for the current
1918
- * request, written several times per planning turn; prompting each would put
1919
- * a consent dialog between the agent and its todo list. What a bad write
1920
- * costs is a polluted task list, which is visible in the transcript and
1921
- * grants nothing. Worth knowing while reading that: these DO outlive the
1922
- * session, because the CLI's task store uses a fixed run id
1923
- * (`run_namzu-cli`), so "run-scoped" is not the reason they are here — the
1924
- * blast radius is.
1925
- *
1926
- * `save_memory` was on the list it replaced and is deliberately NOT here. Its
1927
- * effect outlives the run in a way the task tools' does not: content saved now
1928
- * is retrievable by `search_memory` in a later session, so a tool result or
1929
- * fetched page that talks the model into saving something reaches a future
1930
- * run's reasoning. It is not auto-injected into the prompt — that is
1931
- * `MEMORY.md`, a different thing — but retrievable is enough. A write that
1932
- * survives the process, into the user's own repository, is not read-only under
1933
- * any reading, and it now prompts.
2151
+ * The five modes, the exemptions and the batch rule live in `@namzu/sdk`
2152
+ * (`createReviewHandler`); what this application adds is the person to ask
2153
+ * and the session's "approve all" box, which the screen also reads.
1934
2154
  */
1935
- const PROMPT_EXEMPT_WRITES = new Set(['task_create', 'task_update', 'update_goal']);
2155
+ export function makeResumeHandler(approval, onPermission, mode = onPermission ? 'prompt' : 'auto', exempt = () => false) {
2156
+ return createReviewHandler({
2157
+ mode,
2158
+ prompt: onPermission,
2159
+ exempt,
2160
+ remembered: approval,
2161
+ });
2162
+ }
1936
2163
  /**
1937
- * Whether a call runs without asking: it declares itself read-only, or it is a
1938
- * named exemption above.
1939
- *
1940
- * The read-only half comes from the tool's own `isReadOnly(input)`, never from
1941
- * a list of names kept here. A name list in the consumer is a second source of
1942
- * truth for a property the producer already states: a new read-only tool
1943
- * missing from it merely gets prompted, but a RENAMED tool silently changes
1944
- * posture with nothing to notice.
1945
- *
1946
- * Resolved per call rather than snapshotted, because the roster changes after
1947
- * this module has run — the task tools are registered deferred inside
1948
- * `query()`, and tool servers connect during startup, so anything computed
1949
- * eagerly would be answering about a registry that no longer exists.
1950
- *
1951
- * A tool the registry does not know, or one that declares nothing, prompts.
1952
- * That is the safe-by-default direction the previous comment claimed and this
1953
- * keeps: consent is the answer when the question cannot be established.
2164
+ * Whether a call runs without asking. The kernel's rule: a trusted read-only
2165
+ * declaration or a named bookkeeping write, never a fetch, never a tool the
2166
+ * registry does not know.
1954
2167
  */
1955
- export function isPromptExempt(registry, name, input) {
1956
- if (PROMPT_EXEMPT_WRITES.has(name.toLowerCase()))
1957
- return true;
1958
- const tool = registry.get(name) ?? registry.get(name.toLowerCase());
1959
- // A fetch changes nothing here and declares itself read-only, and it is
1960
- // still a request leaving the machine to an address the model chose. The
1961
- // operator sees the URL before it goes, the way they see a shell command.
1962
- if (tool?.category === 'network')
1963
- return false;
1964
- // A connected server's own claim about its own tool cannot skip the
1965
- // prompt. Same predicate the kernel gate and plan mode use -- three
1966
- // doors, one rule, because fixing two would close the issue and leave
1967
- // the boundary open.
1968
- return isTrustedReadOnly(tool, input);
1969
- }
2168
+ export const isPromptExempt = isReviewExempt;
1970
2169
  /** The exempt roster, sorted, for the surface that has to NAME it. */
1971
2170
  export function promptExemptToolNames(registry) {
1972
2171
  return registry
@@ -1975,13 +2174,8 @@ export function promptExemptToolNames(registry) {
1975
2174
  .map((t) => t.name)
1976
2175
  .sort();
1977
2176
  }
1978
- /**
1979
- * A batch needs explicit approval when any call mutates state: flagged
1980
- * destructive by the SDK, or not exempt from the prompt.
1981
- */
1982
- export function batchNeedsPrompt(toolCalls, exempt) {
1983
- return toolCalls.some((tc) => tc.isDestructive || !exempt(tc.name, tc.input));
1984
- }
2177
+ /** A batch needs explicit approval when any call mutates state. */
2178
+ export const batchNeedsPrompt = batchNeedsReview;
1985
2179
  /**
1986
2180
  * Translate one SDK `RunEvent` into the TUI's `AgentEvent` vocabulary, or
1987
2181
  * `null` for events the chat surface doesn't render (iteration markers,
@@ -1996,6 +2190,14 @@ export function toAgentEvent(event, presenter) {
1996
2190
  ...(event.messageId ? { messageId: event.messageId } : {}),
1997
2191
  ...(event.runId ? { runId: event.runId } : {}),
1998
2192
  };
2193
+ case 'reasoning_started':
2194
+ // A redacted block has no text to show; the empty delta still says
2195
+ // "thinking" so the region does not sit silent for its duration.
2196
+ return { kind: 'reasoning', text: '' };
2197
+ case 'reasoning_delta':
2198
+ return { kind: 'reasoning', text: event.text };
2199
+ case 'reasoning_completed':
2200
+ return { kind: 'reasoning', text: '', done: true };
1999
2201
  case 'tool_executing':
2000
2202
  return {
2001
2203
  kind: 'tool-start',
@@ -2027,8 +2229,12 @@ export function toAgentEvent(event, presenter) {
2027
2229
  success: !event.isError,
2028
2230
  output: event.result,
2029
2231
  });
2030
- const summary = firstLine(event.result);
2031
2232
  const detail = viewToLines(view);
2233
+ // Drop only an exact duplicate. A shortened summary cannot replace
2234
+ // the first line's evidence in expanded or raw output.
2235
+ const summary = view.kind === 'terminal' && detail && detail.length > 0
2236
+ ? truncate(detail[0], 120)
2237
+ : firstLine(event.result);
2032
2238
  const withoutRepeatedSummary = view.kind === 'terminal' && detail?.[0] === summary ? detail.slice(1) : detail;
2033
2239
  return {
2034
2240
  kind: 'tool-end',
@@ -2061,6 +2267,7 @@ export function toAgentEvent(event, presenter) {
2061
2267
  return {
2062
2268
  kind: 'usage',
2063
2269
  totalTokens: event.usage.totalTokens,
2270
+ ...(event.budget ? { budget: event.budget } : {}),
2064
2271
  cost: event.cost,
2065
2272
  ...(event.contextTokens !== undefined ? { contextTokens: event.contextTokens } : {}),
2066
2273
  ...(event.contextMeasuredBy !== undefined
@@ -2124,6 +2331,8 @@ export function toAgentEvent(event, presenter) {
2124
2331
  // the SDK had explicitly stopped.
2125
2332
  return {
2126
2333
  kind: 'paused',
2334
+ ...(event.budget ? { budget: event.budget } : {}),
2335
+ runId: String(event.runId),
2127
2336
  checkpointId: event.checkpointId,
2128
2337
  reason: event.reason,
2129
2338
  ...(event.failure ? { failure: event.failure } : {}),
@@ -2138,6 +2347,7 @@ export function toAgentEvent(event, presenter) {
2138
2347
  // answer was refused.
2139
2348
  return {
2140
2349
  kind: 'done',
2350
+ ...(event.budget ? { budget: event.budget } : {}),
2141
2351
  ...(event.stopReason ? { stopReason: event.stopReason } : {}),
2142
2352
  };
2143
2353
  case 'run_failed':
@@ -2147,13 +2357,29 @@ export function toAgentEvent(event, presenter) {
2147
2357
  // still forcing every host to parse prose.
2148
2358
  return {
2149
2359
  kind: 'error',
2360
+ ...(event.budget ? { budget: event.budget } : {}),
2150
2361
  message: event.error,
2151
2362
  ...(event.failure ? { failure: event.failure } : {}),
2152
2363
  ...(event.providerError ? { providerError: event.providerError } : {}),
2153
2364
  ...(event.explanation ? { explanation: event.explanation } : {}),
2154
2365
  };
2366
+ case 'background_job_exited':
2367
+ return {
2368
+ kind: 'job',
2369
+ jobId: event.jobId,
2370
+ command: event.command,
2371
+ status: event.status,
2372
+ ...(event.exitCode !== undefined ? { exitCode: event.exitCode } : {}),
2373
+ ...(event.signal ? { signal: event.signal } : {}),
2374
+ };
2155
2375
  case 'compaction_completed':
2156
- return { kind: 'context', text: describeCompaction(event), shed: true };
2376
+ return {
2377
+ kind: 'context',
2378
+ text: describeCompaction(event),
2379
+ shed: true,
2380
+ summarised: true,
2381
+ reclaimedTokens: Math.max(0, event.tokensBefore - event.tokensAfter),
2382
+ };
2157
2383
  case 'compaction_tool_results_cleared':
2158
2384
  // `shed: true` on both branches: the tool-result bodies are gone
2159
2385
  // either way. `reliefWasEnough: false` additionally means a
@@ -2161,8 +2387,11 @@ export function toAgentEvent(event, presenter) {
2161
2387
  // so this one says what IT cost rather than claiming the total.
2162
2388
  return {
2163
2389
  kind: 'context',
2164
- text: `cleared ${event.clearedCount} oversized tool result${event.clearedCount === 1 ? '' : 's'} (~${event.reclaimedTokens.toLocaleString()} tokens)${event.reliefWasEnough ? '' : ' — not enough, compacting'}`,
2390
+ 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'}`,
2165
2391
  shed: true,
2392
+ cleared: event.clearedCount,
2393
+ stubbed: event.stubbedCount ?? 0,
2394
+ reclaimedTokens: event.reclaimedTokens,
2166
2395
  };
2167
2396
  case 'compaction_failed':
2168
2397
  return {
@@ -2267,7 +2496,7 @@ function describeFallback(event) {
2267
2496
  * server's, a plugin's — could not get a diff no matter what it did.
2268
2497
  *
2269
2498
  * The tool now says which admitted shape it wants, and this decides what
2270
- * that looks like in a terminal. Clamping and the `STDOUT:`/`STDERR:`
2499
+ * that looks like in a terminal. Collapsing and the `STDOUT:`/`STDERR:`
2271
2500
  * cleanup stay here on purpose: how many rows fit and how a shell labels
2272
2501
  * its streams are properties of this surface, not of the tool.
2273
2502
  */
@@ -2283,13 +2512,13 @@ export function viewToLines(view) {
2283
2512
  // never produces this — it returns no view at all for an insert,
2284
2513
  // rather than claim the file was empty.
2285
2514
  if (view.before === '') {
2286
- const lines = clampLines(view.after);
2515
+ const lines = outputLines(view.after);
2287
2516
  return lines.length > 0 ? lines : undefined;
2288
2517
  }
2289
2518
  const lines = [];
2290
- for (const line of clampLines(view.before))
2519
+ for (const line of outputLines(view.before))
2291
2520
  lines.push(`- ${line}`);
2292
- for (const line of clampLines(view.after))
2521
+ for (const line of outputLines(view.after))
2293
2522
  lines.push(`+ ${line}`);
2294
2523
  return lines.length > 0 ? lines : undefined;
2295
2524
  }
@@ -2298,7 +2527,7 @@ export function viewToLines(view) {
2298
2527
  return undefined;
2299
2528
  const lines = resultToLines(view.output);
2300
2529
  // A single short line is already the summary — no need to repeat it.
2301
- return lines.length <= 1 ? undefined : lines;
2530
+ return lines.length === 1 && lines[0] === truncate(lines[0] ?? '', 120) ? undefined : lines;
2302
2531
  }
2303
2532
  }
2304
2533
  }
@@ -2317,10 +2546,10 @@ function truncate(value, max) {
2317
2546
  const oneLine = value.replace(/\s+/g, ' ');
2318
2547
  return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
2319
2548
  }
2320
- const MAX_DETAIL_LINES = 200;
2321
- function clampLines(value) {
2322
- const lines = value.replace(/\s+$/, '').split('\n');
2323
- return lines.length > MAX_DETAIL_LINES ? lines.slice(0, MAX_DETAIL_LINES) : lines;
2549
+ function outputLines(value) {
2550
+ // The renderer bounds the preview. Retain admitted output so expanding
2551
+ // or selecting raw text never loses a diagnostic after an arbitrary line.
2552
+ return value.replace(/\s+$/, '').split('\n');
2324
2553
  }
2325
2554
  /** Parse a string as a JSON object, or null. Connector tools return JSON. */
2326
2555
  function parseJsonObject(s) {
@@ -2362,11 +2591,11 @@ function payloadString(result) {
2362
2591
  function resultToLines(result) {
2363
2592
  const payload = payloadString(result);
2364
2593
  if (payload !== null)
2365
- return clampLines(payload);
2594
+ return outputLines(payload);
2366
2595
  const obj = parseJsonObject(result);
2367
2596
  if (obj)
2368
- return clampLines(JSON.stringify(obj, null, 2));
2369
- return clampLines(cleanToolText(result.trim()));
2597
+ return outputLines(JSON.stringify(obj, null, 2));
2598
+ return outputLines(cleanToolText(result.trim()));
2370
2599
  }
2371
2600
  /** Concise one-line summary of a tool result for the `⎿` line. */
2372
2601
  function firstLine(result) {
@@ -2502,6 +2731,9 @@ function emptySession(errorHint, errorKind = 'environment') {
2502
2731
  resumeDurable: async () => {
2503
2732
  throw new Error(errorHint);
2504
2733
  },
2734
+ resumePaused: async function* () {
2735
+ yield { kind: 'error', message: 'no provider: nothing to resume' };
2736
+ },
2505
2737
  close: async () => {
2506
2738
  // Nothing was ever connected on this path.
2507
2739
  },