@namzu/cli 18.1.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 (219) 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 +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 +24 -13
  103. package/dist/integrations/subagents/runtime.d.ts.map +1 -1
  104. package/dist/integrations/subagents/runtime.js +282 -122
  105. package/dist/integrations/subagents/runtime.js.map +1 -1
  106. package/dist/memory/store.d.ts +35 -15
  107. package/dist/memory/store.d.ts.map +1 -1
  108. package/dist/memory/store.js +79 -25
  109. package/dist/memory/store.js.map +1 -1
  110. package/dist/permissions/mode.d.ts +12 -45
  111. package/dist/permissions/mode.d.ts.map +1 -1
  112. package/dist/permissions/mode.js +18 -13
  113. package/dist/permissions/mode.js.map +1 -1
  114. package/dist/release-notes.d.ts +20 -0
  115. package/dist/release-notes.d.ts.map +1 -0
  116. package/dist/release-notes.js +64 -0
  117. package/dist/release-notes.js.map +1 -0
  118. package/dist/tui/AgentExplorer.d.ts.map +1 -1
  119. package/dist/tui/AgentExplorer.js +14 -18
  120. package/dist/tui/AgentExplorer.js.map +1 -1
  121. package/dist/tui/App.d.ts.map +1 -1
  122. package/dist/tui/App.js +606 -207
  123. package/dist/tui/App.js.map +1 -1
  124. package/dist/tui/BrandHeader.d.ts +12 -0
  125. package/dist/tui/BrandHeader.d.ts.map +1 -0
  126. package/dist/tui/BrandHeader.js +17 -0
  127. package/dist/tui/BrandHeader.js.map +1 -0
  128. package/dist/tui/ChoicePicker.d.ts +11 -1
  129. package/dist/tui/ChoicePicker.d.ts.map +1 -1
  130. package/dist/tui/ChoicePicker.js +46 -13
  131. package/dist/tui/ChoicePicker.js.map +1 -1
  132. package/dist/tui/Composer.d.ts +4 -1
  133. package/dist/tui/Composer.d.ts.map +1 -1
  134. package/dist/tui/Composer.js +4 -4
  135. package/dist/tui/Composer.js.map +1 -1
  136. package/dist/tui/ComposerFrame.d.ts +10 -0
  137. package/dist/tui/ComposerFrame.d.ts.map +1 -0
  138. package/dist/tui/ComposerFrame.js +93 -0
  139. package/dist/tui/ComposerFrame.js.map +1 -0
  140. package/dist/tui/EditPromptPicker.d.ts.map +1 -1
  141. package/dist/tui/EditPromptPicker.js +1 -1
  142. package/dist/tui/EditPromptPicker.js.map +1 -1
  143. package/dist/tui/LiveActivity.d.ts +12 -5
  144. package/dist/tui/LiveActivity.d.ts.map +1 -1
  145. package/dist/tui/LiveActivity.js +17 -32
  146. package/dist/tui/LiveActivity.js.map +1 -1
  147. package/dist/tui/PermissionOverlay.d.ts +26 -2
  148. package/dist/tui/PermissionOverlay.d.ts.map +1 -1
  149. package/dist/tui/PermissionOverlay.js +108 -16
  150. package/dist/tui/PermissionOverlay.js.map +1 -1
  151. package/dist/tui/Picker.d.ts +2 -2
  152. package/dist/tui/Picker.d.ts.map +1 -1
  153. package/dist/tui/Picker.js +79 -52
  154. package/dist/tui/Picker.js.map +1 -1
  155. package/dist/tui/StatusBar.d.ts.map +1 -1
  156. package/dist/tui/StatusBar.js +5 -2
  157. package/dist/tui/StatusBar.js.map +1 -1
  158. package/dist/tui/TaskList.d.ts +3 -1
  159. package/dist/tui/TaskList.d.ts.map +1 -1
  160. package/dist/tui/TaskList.js +13 -4
  161. package/dist/tui/TaskList.js.map +1 -1
  162. package/dist/tui/Transcript.d.ts +3 -3
  163. package/dist/tui/Transcript.d.ts.map +1 -1
  164. package/dist/tui/Transcript.js +16 -34
  165. package/dist/tui/Transcript.js.map +1 -1
  166. package/dist/tui/agent.d.ts +113 -50
  167. package/dist/tui/agent.d.ts.map +1 -1
  168. package/dist/tui/agent.js +512 -293
  169. package/dist/tui/agent.js.map +1 -1
  170. package/dist/tui/choice-selection.d.ts +17 -0
  171. package/dist/tui/choice-selection.d.ts.map +1 -0
  172. package/dist/tui/choice-selection.js +38 -0
  173. package/dist/tui/choice-selection.js.map +1 -0
  174. package/dist/tui/exit-summary.d.ts +7 -1
  175. package/dist/tui/exit-summary.d.ts.map +1 -1
  176. package/dist/tui/exit-summary.js +12 -3
  177. package/dist/tui/exit-summary.js.map +1 -1
  178. package/dist/tui/index.d.ts +3 -1
  179. package/dist/tui/index.d.ts.map +1 -1
  180. package/dist/tui/index.js +5 -3
  181. package/dist/tui/index.js.map +1 -1
  182. package/dist/tui/logo.d.ts +7 -22
  183. package/dist/tui/logo.d.ts.map +1 -1
  184. package/dist/tui/logo.js +8 -34
  185. package/dist/tui/logo.js.map +1 -1
  186. package/dist/tui/model-choices.d.ts.map +1 -1
  187. package/dist/tui/model-choices.js +21 -6
  188. package/dist/tui/model-choices.js.map +1 -1
  189. package/dist/tui/permission-review.d.ts +15 -4
  190. package/dist/tui/permission-review.d.ts.map +1 -1
  191. package/dist/tui/permission-review.js +48 -9
  192. package/dist/tui/permission-review.js.map +1 -1
  193. package/dist/tui/provider-selection.d.ts +4 -0
  194. package/dist/tui/provider-selection.d.ts.map +1 -0
  195. package/dist/tui/provider-selection.js +16 -0
  196. package/dist/tui/provider-selection.js.map +1 -0
  197. package/dist/tui/run-interruption.d.ts +3 -2
  198. package/dist/tui/run-interruption.d.ts.map +1 -1
  199. package/dist/tui/run-interruption.js +2 -1
  200. package/dist/tui/run-interruption.js.map +1 -1
  201. package/dist/tui/shell-escape.d.ts +42 -0
  202. package/dist/tui/shell-escape.d.ts.map +1 -0
  203. package/dist/tui/shell-escape.js +110 -0
  204. package/dist/tui/shell-escape.js.map +1 -0
  205. package/dist/tui/slashCommands.d.ts +77 -31
  206. package/dist/tui/slashCommands.d.ts.map +1 -1
  207. package/dist/tui/slashCommands.js +343 -303
  208. package/dist/tui/slashCommands.js.map +1 -1
  209. package/dist/tui/terminal-choice-text.d.ts +8 -0
  210. package/dist/tui/terminal-choice-text.d.ts.map +1 -0
  211. package/dist/tui/terminal-choice-text.js +37 -0
  212. package/dist/tui/terminal-choice-text.js.map +1 -0
  213. package/dist/tui/theme.d.ts +3 -4
  214. package/dist/tui/theme.d.ts.map +1 -1
  215. package/dist/tui/theme.js +16 -17
  216. package/dist/tui/theme.js.map +1 -1
  217. package/dist/tui/types.d.ts +6 -1
  218. package/dist/tui/types.d.ts.map +1 -1
  219. package/package.json +6 -4
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,7 @@ 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();
808
893
  let subagentRuntime;
809
894
  // Stays empty when the runtime below throws, which is the honest answer: the
810
895
  // catch is non-fatal and the session then genuinely has no delegate to
@@ -812,10 +897,28 @@ export async function createAgentSession(prefs, detected, options = {}) {
812
897
  // would name agents that are not there.
813
898
  let allowedAgentIds = [];
814
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
+ }
815
910
  const sub = await createSubagentRuntime({
816
911
  cwd,
817
912
  model,
818
- 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
+ },
819
922
  sandboxWorkspace,
820
923
  resolveResumeHandler: (runId) => delegatedResumeHandlers.get(runId),
821
924
  ...(sandbox.provider ? { sandboxProvider: sandbox.provider } : {}),
@@ -862,7 +965,6 @@ export async function createAgentSession(prefs, detected, options = {}) {
862
965
  });
863
966
  subagentRuntime = sub;
864
967
  registry.register([sub.agentTool]);
865
- subagentGateway = sub.gateway;
866
968
  allowedAgentIds = sub.allowedAgentIds;
867
969
  }
868
970
  catch (err) {
@@ -883,7 +985,7 @@ export async function createAgentSession(prefs, detected, options = {}) {
883
985
  // gateway the tool builder requires; a session without one has no
884
986
  // question tool either, and says nothing — it also has no `Agent`.
885
987
  let currentOnQuestion;
886
- if (options.askUser && subagentGateway) {
988
+ if (options.askUser && subagentRuntime) {
887
989
  const parkQuestion = async (request) => {
888
990
  if (request.type !== 'user_question')
889
991
  return { action: 'continue' };
@@ -905,20 +1007,10 @@ export async function createAgentSession(prefs, detected, options = {}) {
905
1007
  return { action: 'continue' };
906
1008
  }
907
1009
  };
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);
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 }));
922
1014
  }
923
1015
  // Task store → query registers task_create / task_update / task_list and
924
1016
  // emits task_created/task_updated, so the agent can track a plan for the
@@ -937,11 +1029,39 @@ export async function createAgentSession(prefs, detected, options = {}) {
937
1029
  // captured on this line. The count at connect time is unchanged; what
938
1030
  // changes is that asking again later gets a later answer.
939
1031
  ensurePrivateStateDirectory(projectStateRoot, 'tenants');
940
- const taskStore = new DiskTaskStore({
1032
+ const taskStoreForRun = (runId, tenantId) => new DiskTaskStore({
941
1033
  baseDir: projectStateRoot,
942
- defaultRunId: asRunId('run_namzu-cli'),
943
- tenantId: scope.tenantId,
1034
+ defaultRunId: runId,
1035
+ tenantId,
944
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
+ };
945
1065
  // Persists across turns: once the user picks "approve all", later tool
946
1066
  // batches in this session run without prompting.
947
1067
  const approval = { all: false };
@@ -963,12 +1083,31 @@ export async function createAgentSession(prefs, detected, options = {}) {
963
1083
  // registries, so executable plugins remain a top-level-session capability.
964
1084
  let pluginRuntime;
965
1085
  try {
966
- pluginRuntime = await createCliPluginRuntime(options.plugins, registry, cwd);
1086
+ pluginRuntime = await createCliPluginRuntime(options.plugins, registry, cwd, options.hooks);
967
1087
  }
968
1088
  catch (error) {
969
1089
  await Promise.allSettled([mcp.close(), computerUseHost?.dispose()]);
970
1090
  return emptySession(describeError(error));
971
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
+ };
972
1111
  if (pluginRuntime) {
973
1112
  cliLogger().info('discovery complete', {
974
1113
  [EVENT_NAME_ATTRIBUTE]: BOOT_EVENT_NAMES.DISCOVERY_COMPLETED,
@@ -990,9 +1129,19 @@ export async function createAgentSession(prefs, detected, options = {}) {
990
1129
  const operations = new SessionOperationOwner(async () => {
991
1130
  const results = await Promise.allSettled([
992
1131
  subagentRuntime?.close?.(),
993
- 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,
994
1141
  mcp.close(),
995
1142
  computerUseHost?.dispose(),
1143
+ jobRegistry?.killOwner(jobOwner),
1144
+ checkpoints.close(),
996
1145
  ]);
997
1146
  const failures = results
998
1147
  .filter((result) => result.status === 'rejected')
@@ -1032,6 +1181,200 @@ export async function createAgentSession(prefs, detected, options = {}) {
1032
1181
  reasoningEffortDefault = undefined;
1033
1182
  effortNotice = `Reasoning effort levels could not be established for this session: ${describeError(error)}`;
1034
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
+ };
1035
1378
  return {
1036
1379
  hasProvider: true,
1037
1380
  sandbox: {
@@ -1049,7 +1392,7 @@ export async function createAgentSession(prefs, detected, options = {}) {
1049
1392
  await prepareProviderCredential(signal);
1050
1393
  return compactNow({
1051
1394
  messages,
1052
- config: COMPACTION_CONFIG,
1395
+ config: compactionConfigFor(options.compaction),
1053
1396
  provider,
1054
1397
  model,
1055
1398
  signal,
@@ -1063,6 +1406,16 @@ export async function createAgentSession(prefs, detected, options = {}) {
1063
1406
  .map((t) => t.name)
1064
1407
  .filter((name) => !goalToolNames.has(name)),
1065
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
+ }) ?? (() => { }),
1066
1419
  ...(subagentRuntime ? { subagents: subagentRuntime.activity } : {}),
1067
1420
  get instructionFiles() {
1068
1421
  return projectInstructions.instructionFiles;
@@ -1096,6 +1449,7 @@ export async function createAgentSession(prefs, detected, options = {}) {
1096
1449
  },
1097
1450
  promptExemptTools: () => promptExemptToolNames(registry).filter((name) => !goalToolNames.has(name)),
1098
1451
  send: (messages, opts) => operations.stream(opts?.signal, (signal) => (async function* () {
1452
+ const selectTaskStore = beginTaskStoreReadout();
1099
1453
  const runId = opts?.runId ?? generateRunId();
1100
1454
  const turnOpts = { ...opts, runId, signal };
1101
1455
  const resumeHandler = makeResumeHandler(approval, opts?.onPermission, opts?.permissionMode ?? options.permissionMode, (name, input) => isPromptExempt(registry, name, input));
@@ -1103,6 +1457,8 @@ export async function createAgentSession(prefs, detected, options = {}) {
1103
1457
  throw new Error(`Run ${runId} already owns a delegated review channel.`);
1104
1458
  }
1105
1459
  delegatedResumeHandlers.set(runId, resumeHandler);
1460
+ const turnScope = { ...scope };
1461
+ delegationScopes.set(runId, turnScope);
1106
1462
  try {
1107
1463
  // Renew a lapsed OAuth token before the turn runs (no-op for valid
1108
1464
  // tokens and non-subscription credentials).
@@ -1121,13 +1477,16 @@ export async function createAgentSession(prefs, detected, options = {}) {
1121
1477
  const pluginSkills = pluginRuntime
1122
1478
  ? await currentPluginSkills(pluginRuntime.skills)
1123
1479
  : undefined;
1124
- const memoryPrompt = composeMemoryPrompt(readMemory());
1480
+ const memoryPrompt = composeMemoryPrompt(readMemory(undefined, cwd));
1125
1481
  currentOnQuestion = opts?.onQuestion;
1126
1482
  const [environmentFacts, turnSnapshot] = await Promise.all([
1127
1483
  readEnvironmentFacts(cwd),
1128
1484
  readTurnSnapshot(cwd),
1129
1485
  ]);
1130
- const environmentPrompt = composeEnvironmentPrompt(environmentFacts);
1486
+ const environmentPrompt = composeEnvironmentPrompt({
1487
+ ...environmentFacts,
1488
+ additionalDirectories: [...directories],
1489
+ });
1131
1490
  // The repository as it stood when THIS turn began, through the
1132
1491
  // SDK's `turn` placement — the ephemeral trailing message that is
1133
1492
  // never cached and never enters history. FIRST iteration only:
@@ -1162,6 +1521,8 @@ export async function createAgentSession(prefs, detected, options = {}) {
1162
1521
  ]
1163
1522
  .filter((s) => Boolean(s))
1164
1523
  .join('\n\n') || undefined;
1524
+ await announceSessionStart();
1525
+ checkpoints.beginTurn(lastUserText(messages));
1165
1526
  let capturedAuthority;
1166
1527
  if (opts?.goalRound) {
1167
1528
  if (!opts.runId)
@@ -1191,6 +1552,14 @@ export async function createAgentSession(prefs, detected, options = {}) {
1191
1552
  try {
1192
1553
  yield* runTurn({
1193
1554
  provider,
1555
+ compactionConfig: compactionConfigFor(options.compaction),
1556
+ ...(options.compaction?.consolidate ? { consolidateInto: memoryStore } : {}),
1557
+ ...(jobRegistry
1558
+ ? {
1559
+ backgroundJobs: jobRegistry,
1560
+ backgroundJobOwner: jobOwner,
1561
+ }
1562
+ : {}),
1194
1563
  // Constructed HERE, per turn, and that is not an optimisation to
1195
1564
  // undo. `refreshTokenIfNeeded` above replaces the head's client
1196
1565
  // object when an OAuth token rotates, so a member list built once at
@@ -1204,21 +1573,23 @@ export async function createAgentSession(prefs, detected, options = {}) {
1204
1573
  pluginManager: pluginRuntime?.manager,
1205
1574
  skillRegistry: pluginRuntime?.skills,
1206
1575
  skills: pluginSkills,
1207
- scope,
1576
+ scope: turnScope,
1208
1577
  pathBuilder,
1209
1578
  workingDirectory: cwd,
1579
+ ...(directories.length > 0 ? { additionalDirectories: [...directories] } : {}),
1580
+ ...(options.limits ? { limits: options.limits } : {}),
1210
1581
  sandboxWorkspace,
1211
1582
  rules: options.rules,
1212
1583
  reviewAnswer: options.reviewAnswer,
1213
1584
  maxAnswerReviews: options.maxAnswerReviews,
1214
1585
  promoteMemory,
1215
- taskStore,
1586
+ taskStore: selectTaskStore(runId, turnScope),
1216
1587
  systemPrompt,
1217
1588
  messages,
1218
1589
  projectInstructionContext: projectInstructions.createRunContext(),
1219
1590
  opts: turnOpts,
1220
1591
  resumeHandler,
1221
- taskGateway: subagentGateway,
1592
+ taskGateway: await subagentRuntime?.gatewayForRun(runId),
1222
1593
  promptContributions,
1223
1594
  ...(webCapability ? { web: webCapability } : {}),
1224
1595
  // Active, not deferred: the doctrine tells the model to open a
@@ -1249,109 +1620,18 @@ export async function createAgentSession(prefs, detected, options = {}) {
1249
1620
  finally {
1250
1621
  if (delegatedResumeHandlers.get(runId) === resumeHandler) {
1251
1622
  delegatedResumeHandlers.delete(runId);
1623
+ delegationScopes.delete(runId);
1624
+ await subagentRuntime?.releaseRun(runId);
1252
1625
  }
1253
1626
  }
1254
1627
  })()),
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
- }
1628
+ resumeDurable: ({ entry, checkpointStore, claimFence, signal }) => kernelResume({
1629
+ entry,
1630
+ checkpointStore,
1631
+ ...(claimFence !== undefined ? { claimFence } : {}),
1632
+ ...(signal ? { signal } : {}),
1354
1633
  }),
1634
+ resumePaused: (params) => resumePausedStream(params),
1355
1635
  };
1356
1636
  }
1357
1637
  function planFallbacks(members, detected) {
@@ -1631,18 +1911,26 @@ export async function listProviderModels(id, det) {
1631
1911
  const listing = await describeProviderModels(id, det);
1632
1912
  return listing.kind === 'ok' ? [...listing.models] : [];
1633
1913
  }
1634
- /** 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
+ */
1635
1928
  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
1929
  return {
1642
- sessionId: asSessionId(`ses_${suffix}`),
1643
- topicId: asTopicId(`top_${suffix}`),
1644
- projectId: asProjectId(`prj_${suffix}`),
1645
- tenantId: asTenantId(`tnt_${suffix}`),
1930
+ sessionId: generateSessionId(),
1931
+ topicId: generateTopicId(),
1932
+ projectId: generateProjectId(),
1933
+ tenantId: generateTenantId(),
1646
1934
  };
1647
1935
  }
1648
1936
  // Pre-execution safety gate: hard-deny catastrophic shell patterns
@@ -1681,7 +1969,7 @@ function gateFor(rules) {
1681
1969
  // from `runConfig.model`, which is the value the user actually chose. Pinning
1682
1970
  // a number here would fix one window across every model the CLI can talk to.
1683
1971
  const COMPACTION_CONFIG = {
1684
- strategy: 'structured',
1972
+ strategy: 'salience',
1685
1973
  // On, and this is the CLI making a choice rather than taking a default.
1686
1974
  // A session's transcript is the only record of what was compacted away;
1687
1975
  // the size trade this costs is the operator's to see and turn off.
@@ -1709,7 +1997,17 @@ const COMPACTION_CONFIG = {
1709
1997
  maxCharsPerRequirement: 300,
1710
1998
  maxCharsPerTask: 400,
1711
1999
  };
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, }) {
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, }) {
1713
2011
  const signal = opts?.signal;
1714
2012
  // One presenter for the whole stream, built from the registry this scope
1715
2013
  // already holds. Its absence HERE is what forced presentation to be name
@@ -1733,7 +2031,7 @@ async function* runTurn({ provider, fallbackProviders, model, tools, pluginManag
1733
2031
  // turn. An admitted send owns the exact run-scoped authority above.
1734
2032
  ...(!opts?.goalRound ? { deniedTools: SESSION_GOAL_TOOL_NAMES } : {}),
1735
2033
  taskStore,
1736
- ...(taskGateway ? { taskGateway } : {}),
2034
+ ...(taskGateway ? { taskScheduler: taskGateway } : {}),
1737
2035
  // `gateFor`, not the bare default: the default's `rules` is a hardcoded
1738
2036
  // empty array, so passing it here discarded the operator's rules on the
1739
2037
  // path that runs every top-level turn. The sub-agent path called
@@ -1741,7 +2039,9 @@ async function* runTurn({ provider, fallbackProviders, model, tools, pluginManag
1741
2039
  ...(sandboxProvider ? { sandboxProvider } : {}),
1742
2040
  ...(sandboxTeardownTimeoutMs !== undefined ? { sandboxTeardownTimeoutMs } : {}),
1743
2041
  authorizationGate: gateFor(rules),
1744
- compactionConfig: COMPACTION_CONFIG,
2042
+ compactionConfig,
2043
+ ...(consolidateInto ? { consolidateInto } : {}),
2044
+ ...(backgroundJobs ? { backgroundJobs, backgroundJobOwner } : {}),
1745
2045
  // The CLI owns its process end to end, so it can safely hand the
1746
2046
  // termination path to the kernel: a Ctrl-C mid-run now leaves a
1747
2047
  // dump under the injected hierarchy's emergency partition instead of
@@ -1752,8 +2052,8 @@ async function* runTurn({ provider, fallbackProviders, model, tools, pluginManag
1752
2052
  ...(sandboxProvider ? { sandbox: { workspace: sandboxWorkspace } } : {}),
1753
2053
  ...(opts?.effort !== undefined ? { effort: opts.effort } : {}),
1754
2054
  timeoutMs: CLI_INTERACTIVE_RUN_TIMEOUT_MS,
1755
- tokenBudget: 1_000_000,
1756
- maxIterations: 50,
2055
+ tokenBudget: limits?.tokenBudget ?? 1_000_000,
2056
+ maxIterations: limits?.maxIterations ?? 50,
1757
2057
  maxResponseTokens: 8192,
1758
2058
  permissionMode: 'auto',
1759
2059
  },
@@ -1774,6 +2074,7 @@ async function* runTurn({ provider, fallbackProviders, model, tools, pluginManag
1774
2074
  messages: [...messages],
1775
2075
  ...(opts?.inboundMessages ? { inboundMessages: opts.inboundMessages } : {}),
1776
2076
  workingDirectory,
2077
+ ...(additionalDirectories?.length ? { additionalDirectories } : {}),
1777
2078
  // The exemption reads `tools` at decision time, so it sees the task
1778
2079
  // tools `query()` registers deferred below and any tool server that
1779
2080
  // connected after this session was built.
@@ -1830,143 +2131,26 @@ async function* runTurn({ provider, fallbackProviders, model, tools, pluginManag
1830
2131
  }
1831
2132
  }
1832
2133
  /**
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.
2134
+ * The kernel's review policy with the TUI's prompt behind it.
1916
2135
  *
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.
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.
1934
2139
  */
1935
- 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
+ }
1936
2148
  /**
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.
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.
1954
2152
  */
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
- }
2153
+ export const isPromptExempt = isReviewExempt;
1970
2154
  /** The exempt roster, sorted, for the surface that has to NAME it. */
1971
2155
  export function promptExemptToolNames(registry) {
1972
2156
  return registry
@@ -1975,13 +2159,8 @@ export function promptExemptToolNames(registry) {
1975
2159
  .map((t) => t.name)
1976
2160
  .sort();
1977
2161
  }
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
- }
2162
+ /** A batch needs explicit approval when any call mutates state. */
2163
+ export const batchNeedsPrompt = batchNeedsReview;
1985
2164
  /**
1986
2165
  * Translate one SDK `RunEvent` into the TUI's `AgentEvent` vocabulary, or
1987
2166
  * `null` for events the chat surface doesn't render (iteration markers,
@@ -1996,6 +2175,14 @@ export function toAgentEvent(event, presenter) {
1996
2175
  ...(event.messageId ? { messageId: event.messageId } : {}),
1997
2176
  ...(event.runId ? { runId: event.runId } : {}),
1998
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 };
1999
2186
  case 'tool_executing':
2000
2187
  return {
2001
2188
  kind: 'tool-start',
@@ -2027,9 +2214,15 @@ export function toAgentEvent(event, presenter) {
2027
2214
  success: !event.isError,
2028
2215
  output: event.result,
2029
2216
  });
2030
- const summary = firstLine(event.result);
2031
2217
  const detail = viewToLines(view);
2032
- 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;
2033
2226
  return {
2034
2227
  kind: 'tool-end',
2035
2228
  runId: event.runId,
@@ -2061,6 +2254,7 @@ export function toAgentEvent(event, presenter) {
2061
2254
  return {
2062
2255
  kind: 'usage',
2063
2256
  totalTokens: event.usage.totalTokens,
2257
+ ...(event.budget ? { budget: event.budget } : {}),
2064
2258
  cost: event.cost,
2065
2259
  ...(event.contextTokens !== undefined ? { contextTokens: event.contextTokens } : {}),
2066
2260
  ...(event.contextMeasuredBy !== undefined
@@ -2124,6 +2318,8 @@ export function toAgentEvent(event, presenter) {
2124
2318
  // the SDK had explicitly stopped.
2125
2319
  return {
2126
2320
  kind: 'paused',
2321
+ ...(event.budget ? { budget: event.budget } : {}),
2322
+ runId: String(event.runId),
2127
2323
  checkpointId: event.checkpointId,
2128
2324
  reason: event.reason,
2129
2325
  ...(event.failure ? { failure: event.failure } : {}),
@@ -2138,6 +2334,7 @@ export function toAgentEvent(event, presenter) {
2138
2334
  // answer was refused.
2139
2335
  return {
2140
2336
  kind: 'done',
2337
+ ...(event.budget ? { budget: event.budget } : {}),
2141
2338
  ...(event.stopReason ? { stopReason: event.stopReason } : {}),
2142
2339
  };
2143
2340
  case 'run_failed':
@@ -2147,13 +2344,29 @@ export function toAgentEvent(event, presenter) {
2147
2344
  // still forcing every host to parse prose.
2148
2345
  return {
2149
2346
  kind: 'error',
2347
+ ...(event.budget ? { budget: event.budget } : {}),
2150
2348
  message: event.error,
2151
2349
  ...(event.failure ? { failure: event.failure } : {}),
2152
2350
  ...(event.providerError ? { providerError: event.providerError } : {}),
2153
2351
  ...(event.explanation ? { explanation: event.explanation } : {}),
2154
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
+ };
2155
2362
  case 'compaction_completed':
2156
- 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
+ };
2157
2370
  case 'compaction_tool_results_cleared':
2158
2371
  // `shed: true` on both branches: the tool-result bodies are gone
2159
2372
  // either way. `reliefWasEnough: false` additionally means a
@@ -2161,8 +2374,11 @@ export function toAgentEvent(event, presenter) {
2161
2374
  // so this one says what IT cost rather than claiming the total.
2162
2375
  return {
2163
2376
  kind: 'context',
2164
- 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'}`,
2165
2378
  shed: true,
2379
+ cleared: event.clearedCount,
2380
+ stubbed: event.stubbedCount ?? 0,
2381
+ reclaimedTokens: event.reclaimedTokens,
2166
2382
  };
2167
2383
  case 'compaction_failed':
2168
2384
  return {
@@ -2502,6 +2718,9 @@ function emptySession(errorHint, errorKind = 'environment') {
2502
2718
  resumeDurable: async () => {
2503
2719
  throw new Error(errorHint);
2504
2720
  },
2721
+ resumePaused: async function* () {
2722
+ yield { kind: 'error', message: 'no provider: nothing to resume' };
2723
+ },
2505
2724
  close: async () => {
2506
2725
  // Nothing was ever connected on this path.
2507
2726
  },