@namzu/cli 15.1.2 → 16.1.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 (80) hide show
  1. package/README.md +37 -7
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +43 -8
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/state.d.ts +9 -0
  6. package/dist/commands/state.d.ts.map +1 -0
  7. package/dist/commands/state.js +110 -0
  8. package/dist/commands/state.js.map +1 -0
  9. package/dist/config/load.d.ts.map +1 -1
  10. package/dist/config/load.js +8 -0
  11. package/dist/config/load.js.map +1 -1
  12. package/dist/config/schema.d.ts +8 -0
  13. package/dist/config/schema.d.ts.map +1 -1
  14. package/dist/config/schema.js.map +1 -1
  15. package/dist/context/capabilities.d.ts +7 -4
  16. package/dist/context/capabilities.d.ts.map +1 -1
  17. package/dist/context/capabilities.js +7 -4
  18. package/dist/context/capabilities.js.map +1 -1
  19. package/dist/context/sandbox.d.ts +5 -0
  20. package/dist/context/sandbox.d.ts.map +1 -1
  21. package/dist/context/sandbox.js +11 -4
  22. package/dist/context/sandbox.js.map +1 -1
  23. package/dist/integrations/sessions/store.d.ts.map +1 -1
  24. package/dist/integrations/sessions/store.js +25 -2
  25. package/dist/integrations/sessions/store.js.map +1 -1
  26. package/dist/integrations/state/private-directory.d.ts +15 -0
  27. package/dist/integrations/state/private-directory.d.ts.map +1 -0
  28. package/dist/integrations/state/private-directory.js +41 -0
  29. package/dist/integrations/state/private-directory.js.map +1 -0
  30. package/dist/integrations/state/report.d.ts +125 -0
  31. package/dist/integrations/state/report.d.ts.map +1 -0
  32. package/dist/integrations/state/report.js +784 -0
  33. package/dist/integrations/state/report.js.map +1 -0
  34. package/dist/integrations/subagents/activity.d.ts +88 -0
  35. package/dist/integrations/subagents/activity.d.ts.map +1 -0
  36. package/dist/integrations/subagents/activity.js +389 -0
  37. package/dist/integrations/subagents/activity.js.map +1 -0
  38. package/dist/integrations/subagents/runtime.d.ts +11 -1
  39. package/dist/integrations/subagents/runtime.d.ts.map +1 -1
  40. package/dist/integrations/subagents/runtime.js +66 -3
  41. package/dist/integrations/subagents/runtime.js.map +1 -1
  42. package/dist/tui/AgentExplorer.d.ts +57 -0
  43. package/dist/tui/AgentExplorer.d.ts.map +1 -0
  44. package/dist/tui/AgentExplorer.js +285 -0
  45. package/dist/tui/AgentExplorer.js.map +1 -0
  46. package/dist/tui/App.d.ts.map +1 -1
  47. package/dist/tui/App.js +479 -150
  48. package/dist/tui/App.js.map +1 -1
  49. package/dist/tui/Composer.d.ts +7 -1
  50. package/dist/tui/Composer.d.ts.map +1 -1
  51. package/dist/tui/Composer.js +26 -5
  52. package/dist/tui/Composer.js.map +1 -1
  53. package/dist/tui/LiveActivity.d.ts +9 -3
  54. package/dist/tui/LiveActivity.d.ts.map +1 -1
  55. package/dist/tui/LiveActivity.js +27 -10
  56. package/dist/tui/LiveActivity.js.map +1 -1
  57. package/dist/tui/PermissionOverlay.d.ts +8 -9
  58. package/dist/tui/PermissionOverlay.d.ts.map +1 -1
  59. package/dist/tui/PermissionOverlay.js +6 -10
  60. package/dist/tui/PermissionOverlay.js.map +1 -1
  61. package/dist/tui/StatusBar.d.ts +1 -47
  62. package/dist/tui/StatusBar.d.ts.map +1 -1
  63. package/dist/tui/StatusBar.js +9 -79
  64. package/dist/tui/StatusBar.js.map +1 -1
  65. package/dist/tui/agent.d.ts +3 -0
  66. package/dist/tui/agent.d.ts.map +1 -1
  67. package/dist/tui/agent.js +217 -178
  68. package/dist/tui/agent.js.map +1 -1
  69. package/dist/tui/permission-review.d.ts +18 -2
  70. package/dist/tui/permission-review.d.ts.map +1 -1
  71. package/dist/tui/permission-review.js +88 -2
  72. package/dist/tui/permission-review.js.map +1 -1
  73. package/dist/tui/slashCommands.d.ts +4 -0
  74. package/dist/tui/slashCommands.d.ts.map +1 -1
  75. package/dist/tui/slashCommands.js +14 -0
  76. package/dist/tui/slashCommands.js.map +1 -1
  77. package/dist/user-commands/store.d.ts.map +1 -1
  78. package/dist/user-commands/store.js +26 -5
  79. package/dist/user-commands/store.js.map +1 -1
  80. package/package.json +2 -2
package/dist/tui/agent.js CHANGED
@@ -20,9 +20,10 @@
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, DiskMemoryStore, DiskTaskStore, EVENT_NAME_ATTRIBUTE, ProviderRegistry, SESSION_GOAL_TOOL_NAMES, SearchToolsTool, ToolRegistry, asProjectId, asRunId, asSessionId, asTenantId, asTopicId, buildMemoryTools, buildSessionGoalTools, compactNow, createComputerUseTool, createMemoryPromoter, createToolPresenter, genericLabel, getBuiltinTools, isTrustedReadOnly, query, resumeRun, withProviderFallback, } from '@namzu/sdk';
23
+ import { BOOT_EVENT_NAMES, DiskMemoryStore, DiskTaskStore, EVENT_NAME_ATTRIBUTE, ProviderRegistry, SESSION_GOAL_TOOL_NAMES, SearchToolsTool, ToolRegistry, asProjectId, asRunId, asSessionId, asTenantId, asTopicId, buildMemoryTools, buildSessionGoalTools, compactNow, createComputerUseTool, createMemoryPromoter, createToolPresenter, generateRunId, getBuiltinTools, isTrustedReadOnly, query, resumeRun, withProviderFallback, } from '@namzu/sdk';
24
24
  import { SubprocessComputerUseHost } from '@namzu/computer-use';
25
- import { join } from 'node:path';
25
+ import { realpath, stat } from 'node:fs/promises';
26
+ import { join, parse, resolve } from 'node:path';
26
27
  import { probeCapabilities } from '../context/capabilities.js';
27
28
  import { composeEnvironmentPrompt, readEnvironmentFacts } from '../context/environment.js';
28
29
  import { ProjectInstructionTracker } from '../context/project-tracker.js';
@@ -30,6 +31,7 @@ import { resolveSandbox, sandboxResolvedSeverity, } from '../context/sandbox.js'
30
31
  import { connectMcpServers, } from '../integrations/mcp/servers.js';
31
32
  import { createCliPluginRuntime } from '../integrations/plugins/runtime.js';
32
33
  import { CredentialRefreshRejectedError, CredentialWithdrawnError, PROVIDER_REGISTRY, chainCapabilityDisagreements, chainPositionName, describeAcceptedMismatch, describeCapabilityRefusal, discoverProviders, ensureFreshAnthropicToken, ensureFreshStoredCodexCredential, ensureRegistered, findDetected, isAnthropicOAuthToken, isRegistered, missingCredentialMessage, primaryProvider, readCodexCredentialFile, readPreferences, readSubscriptionCredential, resolveChainCapabilities, sameOAuthCredential, unresolvedMembers, unsupportedProviderMessage, } from '../integrations/providers/index.js';
34
+ import { ensurePrivateStateDirectory } from '../integrations/state/private-directory.js';
33
35
  import { createSubagentRuntime } from '../integrations/subagents/runtime.js';
34
36
  import { cliLogger } from '../logging.js';
35
37
  import { composeMemoryPrompt, readMemory } from '../memory/store.js';
@@ -326,7 +328,9 @@ function buildToolRegistry(cwd) {
326
328
  // `save_memory` look like it touched only namzu's own state and was part of
327
329
  // why it sat on the no-prompt list; it writes into the user's project.
328
330
  // Separate from the user-curated MEMORY.md that is injected into the prompt.
329
- const memoryStore = new DiskMemoryStore({ baseDir: join(cwd, '.namzu') });
331
+ const stateRoot = join(cwd, '.namzu');
332
+ ensurePrivateStateDirectory(stateRoot, 'memory');
333
+ const memoryStore = new DiskMemoryStore({ baseDir: stateRoot });
330
334
  // Search through the store's async boundary. Its concrete index is lazy:
331
335
  // handing `getIndex()` to the synchronous overload before the first store
332
336
  // read makes a new process report every persisted memory as absent.
@@ -350,7 +354,35 @@ async function currentPluginSkills(registry) {
350
354
  }
351
355
  export async function createAgentSession(prefs, detected, options = {}) {
352
356
  const scope = options.scope ?? mintScope();
353
- const cwd = options.cwd ?? process.cwd();
357
+ const requestedCwd = resolve(options.cwd ?? process.cwd());
358
+ let cwd;
359
+ try {
360
+ const entry = await stat(requestedCwd);
361
+ if (!entry.isDirectory()) {
362
+ return emptySession(`Working directory is not a directory: ${requestedCwd}`, 'invocation');
363
+ }
364
+ cwd = await realpath(requestedCwd);
365
+ }
366
+ catch (error) {
367
+ return emptySession(`Working directory is unavailable: ${requestedCwd} — ${describeError(error)}`, 'invocation');
368
+ }
369
+ const sandboxWorkspace = options.sandbox?.workspace ?? 'working-directory';
370
+ if (options.sandbox?.enabled !== false &&
371
+ sandboxWorkspace === 'working-directory' &&
372
+ cwd === parse(cwd).root) {
373
+ return emptySession(`Working-directory sandboxing refuses filesystem root ${cwd}; choose a project directory so confinement has a boundary, or explicitly select an ephemeral workspace.`, 'invocation');
374
+ }
375
+ try {
376
+ // Refuse an aliased or otherwise unsafe generated-state root before any
377
+ // provider, sandbox or plugin runtime is constructed. Project-authored
378
+ // `.namzu` content may coexist here, but generated memory must never be
379
+ // redirected outside the trusted working directory through an ancestor
380
+ // symlink.
381
+ ensurePrivateStateDirectory(join(cwd, '.namzu'), 'memory');
382
+ }
383
+ catch (error) {
384
+ return emptySession(`Project state is unavailable: ${describeError(error)}`, 'environment');
385
+ }
354
386
  // The head serves; the tail is fallen over to, in order, when it cannot.
355
387
  const primary = primaryProvider(prefs);
356
388
  const entry = PROVIDER_REGISTRY[primary.id];
@@ -662,6 +694,10 @@ export async function createAgentSession(prefs, detected, options = {}) {
662
694
  // `buildToolRegistry` directly below, so they never receive these tools.
663
695
  // Per-send denial further keeps the schemas out of ordinary human turns.
664
696
  const goalAuthorities = new Map();
697
+ // A child is created after the parent query has started, from inside its
698
+ // Agent tool. Keying the review channel by the executing run keeps two
699
+ // concurrent sends from borrowing each other's prompt or approval latch.
700
+ const delegatedResumeHandlers = new Map();
665
701
  const goalToolNames = new Set(SESSION_GOAL_TOOL_NAMES);
666
702
  if (options.sessionGoals) {
667
703
  registry.register(buildSessionGoalTools(options.sessionGoals, (runId) => goalAuthorities.get(runId)));
@@ -714,11 +750,6 @@ export async function createAgentSession(prefs, detected, options = {}) {
714
750
  // Best-effort — if the runtime can't stand up, the chat still works.
715
751
  let subagentGateway;
716
752
  let subagentRuntime;
717
- // Buffer of the in-flight sub-agent's tool steps. The gateway streams the
718
- // child's events here while the parent's `Agent` tool call blocks; runTurn
719
- // drains it onto the `Agent` result as a `├─/└─` tree. Scoped per `Agent`
720
- // call (cleared when one starts).
721
- const childSteps = [];
722
753
  // Stays empty when the runtime below throws, which is the honest answer: the
723
754
  // catch is non-fatal and the session then genuinely has no delegate to
724
755
  // dispatch to. A roster reported from the request rather than the result
@@ -728,6 +759,8 @@ export async function createAgentSession(prefs, detected, options = {}) {
728
759
  const sub = await createSubagentRuntime({
729
760
  cwd,
730
761
  model,
762
+ sandboxWorkspace,
763
+ resolveResumeHandler: (runId) => delegatedResumeHandlers.get(runId),
731
764
  ...(sandbox.provider ? { sandboxProvider: sandbox.provider } : {}),
732
765
  ...(options.sandbox?.teardownTimeoutMs !== undefined
733
766
  ? { sandboxTeardownTimeoutMs: options.sandbox.teardownTimeoutMs }
@@ -769,11 +802,6 @@ export async function createAgentSession(prefs, detected, options = {}) {
769
802
  return buildToolRegistry(cwd).registry;
770
803
  },
771
804
  authorizationGate: gateFor(options.rules),
772
- onEvent: (e) => {
773
- if (e.type === 'tool_executing') {
774
- childSteps.push(`${e.toolName}(${genericLabel(e.input)})`);
775
- }
776
- },
777
805
  });
778
806
  subagentRuntime = sub;
779
807
  registry.register([sub.agentTool]);
@@ -803,8 +831,10 @@ export async function createAgentSession(prefs, detected, options = {}) {
803
831
  // It is also why `toolNames` below reads the registry rather than a list
804
832
  // captured on this line. The count at connect time is unchanged; what
805
833
  // changes is that asking again later gets a later answer.
834
+ const stateRoot = join(cwd, '.namzu');
835
+ ensurePrivateStateDirectory(stateRoot, 'tenants');
806
836
  const taskStore = new DiskTaskStore({
807
- baseDir: join(cwd, '.namzu'),
837
+ baseDir: stateRoot,
808
838
  defaultRunId: asRunId('run_namzu-cli'),
809
839
  tenantId: scope.tenantId,
810
840
  });
@@ -905,6 +935,7 @@ export async function createAgentSession(prefs, detected, options = {}) {
905
935
  ...(sandbox.environment ? { environment: sandbox.environment } : {}),
906
936
  enforced: sandbox.enforced,
907
937
  required: sandbox.required,
938
+ workspace: sandbox.workspace,
908
939
  },
909
940
  providerSummary: entry.label,
910
941
  modelSummary: model,
@@ -928,6 +959,7 @@ export async function createAgentSession(prefs, detected, options = {}) {
928
959
  .map((t) => t.name)
929
960
  .filter((name) => !goalToolNames.has(name)),
930
961
  agentIds: allowedAgentIds,
962
+ ...(subagentRuntime ? { subagents: subagentRuntime.activity } : {}),
931
963
  get instructionFiles() {
932
964
  return projectInstructions.instructionFiles;
933
965
  },
@@ -960,172 +992,198 @@ export async function createAgentSession(prefs, detected, options = {}) {
960
992
  },
961
993
  promptExemptTools: () => promptExemptToolNames(registry).filter((name) => !goalToolNames.has(name)),
962
994
  send: (messages, opts) => operations.stream(opts?.signal, (signal) => (async function* () {
963
- // Renew a lapsed OAuth token before the turn runs (no-op for valid
964
- // tokens and non-subscription credentials).
965
- await prepareProviderCredential(signal);
966
- // namzu identity first (so it establishes who the agent is even when
967
- // the credential layer prepends whatever prefix its token requires),
968
- // then memory and per-turn extra context. Project instructions are a
969
- // separate retained user-context snapshot prepared by the controller;
970
- // that is what lets nested scopes be replaced and persisted safely.
971
- //
972
- // The environment block is read fresh every turn because both facts in it
973
- // can change WHILE the session
974
- // runs — midnight passes, and the agent checks out a branch itself.
975
- // Its text only changes when a fact changes, so it costs a prompt-cache
976
- // miss exactly when a hit would have been a stale claim.
995
+ const runId = opts?.runId ?? generateRunId();
996
+ const turnOpts = { ...opts, runId, signal };
997
+ const resumeHandler = makeResumeHandler(approval, opts?.onPermission, opts?.permissionMode ?? options.permissionMode, (name, input) => isPromptExempt(registry, name, input));
998
+ if (delegatedResumeHandlers.has(runId)) {
999
+ throw new Error(`Run ${runId} already owns a delegated review channel.`);
1000
+ }
1001
+ delegatedResumeHandlers.set(runId, resumeHandler);
1002
+ try {
1003
+ // Renew a lapsed OAuth token before the turn runs (no-op for valid
1004
+ // tokens and non-subscription credentials).
1005
+ await prepareProviderCredential(signal);
1006
+ // namzu identity first (so it establishes who the agent is even when
1007
+ // the credential layer prepends whatever prefix its token requires),
1008
+ // then memory and per-turn extra context. Project instructions are a
1009
+ // separate retained user-context snapshot prepared by the controller;
1010
+ // that is what lets nested scopes be replaced and persisted safely.
1011
+ //
1012
+ // The environment block is read fresh every turn because both facts in it
1013
+ // can change WHILE the session
1014
+ // runs — midnight passes, and the agent checks out a branch itself.
1015
+ // Its text only changes when a fact changes, so it costs a prompt-cache
1016
+ // miss exactly when a hit would have been a stale claim.
1017
+ const pluginSkills = pluginRuntime
1018
+ ? await currentPluginSkills(pluginRuntime.skills)
1019
+ : undefined;
1020
+ const memoryPrompt = composeMemoryPrompt(readMemory());
1021
+ const environmentPrompt = composeEnvironmentPrompt(await readEnvironmentFacts(cwd));
1022
+ const systemPrompt = [NAMZU_IDENTITY, environmentPrompt, memoryPrompt, opts?.extraSystem]
1023
+ .filter((s) => Boolean(s))
1024
+ .join('\n\n') || undefined;
1025
+ let capturedAuthority;
1026
+ if (opts?.goalRound) {
1027
+ if (!opts.runId)
1028
+ throw new Error('A goal round requires a caller-reserved runId.');
1029
+ if (!options.sessionGoals)
1030
+ throw new Error('This session has no durable goal store.');
1031
+ if (opts.goalRound.sessionId !== scope.sessionId ||
1032
+ opts.goalRound.tenantId !== scope.tenantId) {
1033
+ throw new Error('Goal-round authority does not belong to this agent session scope.');
1034
+ }
1035
+ const current = await options.sessionGoals.getGoal(scope.sessionId, scope.tenantId);
1036
+ if (!current ||
1037
+ current.phase !== 'active' ||
1038
+ current.id !== opts.goalRound.id ||
1039
+ current.revision !== opts.goalRound.revision ||
1040
+ current.objective !== opts.goalRound.objective ||
1041
+ current.roundsAdmitted !== opts.goalRound.round ||
1042
+ current.maxGoalRounds !== opts.goalRound.maxGoalRounds) {
1043
+ throw new Error('Goal-round authority is stale or does not match the durable goal.');
1044
+ }
1045
+ if (goalAuthorities.has(opts.runId)) {
1046
+ throw new Error(`Run ${opts.runId} already owns goal-round authority.`);
1047
+ }
1048
+ capturedAuthority = Object.freeze({ ...opts.goalRound });
1049
+ goalAuthorities.set(opts.runId, capturedAuthority);
1050
+ }
1051
+ try {
1052
+ yield* runTurn({
1053
+ provider,
1054
+ // Constructed HERE, per turn, and that is not an optimisation to
1055
+ // undo. `refreshTokenIfNeeded` above replaces the head's client
1056
+ // object when an OAuth token rotates, so a member list built once at
1057
+ // session creation would hand the kernel a client holding a token
1058
+ // that expired hours ago — and a chain whose own members are stale
1059
+ // is a fallback that fails for the reason the fallback exists to
1060
+ // survive. Building a driver is a client object, not a request.
1061
+ fallbackProviders: fallbackPlan.build(currentToken),
1062
+ model,
1063
+ tools: registry,
1064
+ pluginManager: pluginRuntime?.manager,
1065
+ skillRegistry: pluginRuntime?.skills,
1066
+ skills: pluginSkills,
1067
+ scope,
1068
+ workingDirectory: cwd,
1069
+ sandboxWorkspace,
1070
+ rules: options.rules,
1071
+ reviewAnswer: options.reviewAnswer,
1072
+ maxAnswerReviews: options.maxAnswerReviews,
1073
+ promoteMemory,
1074
+ taskStore,
1075
+ systemPrompt,
1076
+ messages,
1077
+ projectInstructionContext: projectInstructions.createRunContext(),
1078
+ opts: turnOpts,
1079
+ resumeHandler,
1080
+ taskGateway: subagentGateway,
1081
+ onRunEvent: options.onRunEvent,
1082
+ ...(sandbox.provider ? { sandboxProvider: sandbox.provider } : {}),
1083
+ ...(options.sandbox?.teardownTimeoutMs !== undefined
1084
+ ? {
1085
+ sandboxTeardownTimeoutMs: options.sandbox.teardownTimeoutMs,
1086
+ }
1087
+ : {}),
1088
+ });
1089
+ }
1090
+ finally {
1091
+ if (opts?.runId &&
1092
+ capturedAuthority &&
1093
+ goalAuthorities.get(opts.runId) === capturedAuthority) {
1094
+ goalAuthorities.delete(opts.runId);
1095
+ }
1096
+ }
1097
+ }
1098
+ finally {
1099
+ if (delegatedResumeHandlers.get(runId) === resumeHandler) {
1100
+ delegatedResumeHandlers.delete(runId);
1101
+ }
1102
+ }
1103
+ })()),
1104
+ resumeDurable: ({ entry, checkpointStore, claimFence, signal }) => operations.promise(signal, async (ownedSignal) => {
1105
+ // The same prelude a turn runs, and for the same reasons: a lapsed
1106
+ // OAuth token has to be renewed before the provider is used, and the
1107
+ // fallback chain has to be built AFTER that so its members do not
1108
+ // hold a client the refresh just replaced.
1109
+ await prepareProviderCredential(ownedSignal);
977
1110
  const pluginSkills = pluginRuntime
978
1111
  ? await currentPluginSkills(pluginRuntime.skills)
979
1112
  : undefined;
980
1113
  const memoryPrompt = composeMemoryPrompt(readMemory());
981
1114
  const environmentPrompt = composeEnvironmentPrompt(await readEnvironmentFacts(cwd));
982
- const systemPrompt = [NAMZU_IDENTITY, environmentPrompt, memoryPrompt, opts?.extraSystem]
1115
+ const systemPrompt = [NAMZU_IDENTITY, environmentPrompt, memoryPrompt]
983
1116
  .filter((s) => Boolean(s))
984
1117
  .join('\n\n') || undefined;
985
- let capturedAuthority;
986
- if (opts?.goalRound) {
987
- if (!opts.runId)
988
- throw new Error('A goal round requires a caller-reserved runId.');
989
- if (!options.sessionGoals)
990
- throw new Error('This session has no durable goal store.');
991
- if (opts.goalRound.sessionId !== scope.sessionId ||
992
- opts.goalRound.tenantId !== scope.tenantId) {
993
- throw new Error('Goal-round authority does not belong to this agent session scope.');
994
- }
995
- const current = await options.sessionGoals.getGoal(scope.sessionId, scope.tenantId);
996
- if (!current ||
997
- current.phase !== 'active' ||
998
- current.id !== opts.goalRound.id ||
999
- current.revision !== opts.goalRound.revision ||
1000
- current.objective !== opts.goalRound.objective ||
1001
- current.roundsAdmitted !== opts.goalRound.round ||
1002
- current.maxGoalRounds !== opts.goalRound.maxGoalRounds) {
1003
- throw new Error('Goal-round authority is stale or does not match the durable goal.');
1004
- }
1005
- if (goalAuthorities.has(opts.runId)) {
1006
- throw new Error(`Run ${opts.runId} already owns goal-round authority.`);
1007
- }
1008
- capturedAuthority = Object.freeze({ ...opts.goalRound });
1009
- goalAuthorities.set(opts.runId, capturedAuthority);
1118
+ const resumeHandler = makeResumeHandler(approval, undefined, options.permissionMode, (name, input) => isPromptExempt(registry, name, input));
1119
+ if (delegatedResumeHandlers.has(entry.runId)) {
1120
+ throw new Error(`Run ${entry.runId} already owns a delegated review channel.`);
1010
1121
  }
1122
+ delegatedResumeHandlers.set(entry.runId, resumeHandler);
1011
1123
  try {
1012
- yield* runTurn({
1124
+ return await resumeRun({
1013
1125
  provider,
1014
- // Constructed HERE, per turn, and that is not an optimisation to
1015
- // undo. `refreshTokenIfNeeded` above replaces the head's client
1016
- // object when an OAuth token rotates, so a member list built once at
1017
- // session creation would hand the kernel a client holding a token
1018
- // that expired hours ago — and a chain whose own members are stale
1019
- // is a fallback that fails for the reason the fallback exists to
1020
- // survive. Building a driver is a client object, not a request.
1021
1126
  fallbackProviders: fallbackPlan.build(currentToken),
1022
- model,
1023
1127
  tools: registry,
1024
1128
  pluginManager: pluginRuntime?.manager,
1025
1129
  skillRegistry: pluginRuntime?.skills,
1026
1130
  skills: pluginSkills,
1027
- scope,
1028
- workingDirectory: cwd,
1029
- rules: options.rules,
1030
- permissionMode: opts?.permissionMode ?? options.permissionMode,
1031
- reviewAnswer: options.reviewAnswer,
1032
- maxAnswerReviews: options.maxAnswerReviews,
1033
- promoteMemory,
1034
- approval,
1035
1131
  taskStore,
1036
- systemPrompt,
1037
- messages,
1132
+ ...(subagentGateway ? { taskGateway: subagentGateway } : {}),
1133
+ authorizationGate: gateFor(options.rules),
1134
+ compactionConfig: COMPACTION_CONFIG,
1038
1135
  projectInstructionContext: projectInstructions.createRunContext(),
1039
- opts: { ...opts, signal },
1040
- taskGateway: subagentGateway,
1041
- onRunEvent: options.onRunEvent,
1042
- childSteps,
1043
1136
  ...(sandbox.provider ? { sandboxProvider: sandbox.provider } : {}),
1044
1137
  ...(options.sandbox?.teardownTimeoutMs !== undefined
1045
- ? {
1046
- sandboxTeardownTimeoutMs: options.sandbox.teardownTimeoutMs,
1047
- }
1138
+ ? { sandboxTeardownTimeoutMs: options.sandbox.teardownTimeoutMs }
1048
1139
  : {}),
1140
+ // NOT `emergencySave`, unlike a turn. The manager is a singleton
1141
+ // whose `attach` detaches whoever held it before, so a caller
1142
+ // resuming several runs in one process would leave only the last
1143
+ // one covered — and would look covered. A turn owns its process
1144
+ // end to end; a drainer does not.
1145
+ runConfig: {
1146
+ model,
1147
+ ...(sandbox.provider ? { sandbox: { workspace: sandboxWorkspace } } : {}),
1148
+ timeoutMs: 600_000,
1149
+ tokenBudget: 1_000_000,
1150
+ maxIterations: 50,
1151
+ maxResponseTokens: 8192,
1152
+ permissionMode: 'auto',
1153
+ },
1154
+ agentId: 'namzu',
1155
+ agentName: 'namzu',
1156
+ ...(systemPrompt ? { systemPrompt } : {}),
1157
+ workingDirectory: cwd,
1158
+ // No `onPermission`: there is nobody at a drainer's terminal, so a
1159
+ // prompt would block the pass forever on a run nobody is watching.
1160
+ // The gate's deny rules still apply.
1161
+ // One presenter for the whole stream, built from the registry this
1162
+ // scope already holds. It was the absence of the registry HERE that
1163
+ // forced presentation to be name matching: `toAgentEvent` was pure
1164
+ // over a `RunEvent` and could not ask a tool anything.
1165
+ resumeHandler,
1166
+ signal: ownedSignal,
1167
+ // Attribution comes from the ENTRY, not from this session: the run
1168
+ // belongs to whoever started it, and stamping the drainer's ids onto
1169
+ // it would file another tenant's work under this one.
1170
+ tenantId: entry.tenantId,
1171
+ projectId: entry.projectId,
1172
+ sessionId: entry.sessionId,
1173
+ // …except the topic, which no checkpoint records — see
1174
+ // `RunStateScope`. This one is the drainer's, and honestly so:
1175
+ // supplied here rather than pretended to have been recovered.
1176
+ topicId: scope.topicId,
1177
+ scope: { ...entry, topicId: scope.topicId },
1178
+ checkpointStore,
1179
+ ...(claimFence !== undefined ? { claimFence } : {}),
1049
1180
  });
1050
1181
  }
1051
1182
  finally {
1052
- if (opts?.runId &&
1053
- capturedAuthority &&
1054
- goalAuthorities.get(opts.runId) === capturedAuthority) {
1055
- goalAuthorities.delete(opts.runId);
1183
+ if (delegatedResumeHandlers.get(entry.runId) === resumeHandler) {
1184
+ delegatedResumeHandlers.delete(entry.runId);
1056
1185
  }
1057
1186
  }
1058
- })()),
1059
- resumeDurable: ({ entry, checkpointStore, claimFence, signal }) => operations.promise(signal, async (ownedSignal) => {
1060
- // The same prelude a turn runs, and for the same reasons: a lapsed
1061
- // OAuth token has to be renewed before the provider is used, and the
1062
- // fallback chain has to be built AFTER that so its members do not
1063
- // hold a client the refresh just replaced.
1064
- await prepareProviderCredential(ownedSignal);
1065
- const pluginSkills = pluginRuntime
1066
- ? await currentPluginSkills(pluginRuntime.skills)
1067
- : undefined;
1068
- const memoryPrompt = composeMemoryPrompt(readMemory());
1069
- const environmentPrompt = composeEnvironmentPrompt(await readEnvironmentFacts(cwd));
1070
- const systemPrompt = [NAMZU_IDENTITY, environmentPrompt, memoryPrompt]
1071
- .filter((s) => Boolean(s))
1072
- .join('\n\n') || undefined;
1073
- return resumeRun({
1074
- provider,
1075
- fallbackProviders: fallbackPlan.build(currentToken),
1076
- tools: registry,
1077
- pluginManager: pluginRuntime?.manager,
1078
- skillRegistry: pluginRuntime?.skills,
1079
- skills: pluginSkills,
1080
- taskStore,
1081
- ...(subagentGateway ? { taskGateway: subagentGateway } : {}),
1082
- authorizationGate: gateFor(options.rules),
1083
- compactionConfig: COMPACTION_CONFIG,
1084
- projectInstructionContext: projectInstructions.createRunContext(),
1085
- ...(sandbox.provider ? { sandboxProvider: sandbox.provider } : {}),
1086
- ...(options.sandbox?.teardownTimeoutMs !== undefined
1087
- ? { sandboxTeardownTimeoutMs: options.sandbox.teardownTimeoutMs }
1088
- : {}),
1089
- // NOT `emergencySave`, unlike a turn. The manager is a singleton
1090
- // whose `attach` detaches whoever held it before, so a caller
1091
- // resuming several runs in one process would leave only the last
1092
- // one covered — and would look covered. A turn owns its process
1093
- // end to end; a drainer does not.
1094
- runConfig: {
1095
- model,
1096
- timeoutMs: 600_000,
1097
- tokenBudget: 1_000_000,
1098
- maxIterations: 50,
1099
- maxResponseTokens: 8192,
1100
- permissionMode: 'auto',
1101
- },
1102
- agentId: 'namzu',
1103
- agentName: 'namzu',
1104
- ...(systemPrompt ? { systemPrompt } : {}),
1105
- workingDirectory: cwd,
1106
- // No `onPermission`: there is nobody at a drainer's terminal, so a
1107
- // prompt would block the pass forever on a run nobody is watching.
1108
- // The gate's deny rules still apply.
1109
- // One presenter for the whole stream, built from the registry this
1110
- // scope already holds. It was the absence of the registry HERE that
1111
- // forced presentation to be name matching: `toAgentEvent` was pure
1112
- // over a `RunEvent` and could not ask a tool anything.
1113
- resumeHandler: makeResumeHandler(approval, undefined, options.permissionMode, (n, i) => isPromptExempt(registry, n, i)),
1114
- signal: ownedSignal,
1115
- // Attribution comes from the ENTRY, not from this session: the run
1116
- // belongs to whoever started it, and stamping the drainer's ids onto
1117
- // it would file another tenant's work under this one.
1118
- tenantId: entry.tenantId,
1119
- projectId: entry.projectId,
1120
- sessionId: entry.sessionId,
1121
- // …except the topic, which no checkpoint records — see
1122
- // `RunStateScope`. This one is the drainer's, and honestly so:
1123
- // supplied here rather than pretended to have been recovered.
1124
- topicId: scope.topicId,
1125
- scope: { ...entry, topicId: scope.topicId },
1126
- checkpointStore,
1127
- ...(claimFence !== undefined ? { claimFence } : {}),
1128
- });
1129
1187
  }),
1130
1188
  };
1131
1189
  }
@@ -1485,7 +1543,7 @@ const COMPACTION_CONFIG = {
1485
1543
  maxCharsPerRequirement: 300,
1486
1544
  maxCharsPerTask: 400,
1487
1545
  };
1488
- async function* runTurn({ provider, fallbackProviders, model, tools, pluginManager, skillRegistry, skills, scope, workingDirectory, rules, permissionMode, reviewAnswer, maxAnswerReviews, promoteMemory, approval, taskStore, systemPrompt, messages, projectInstructionContext, opts, taskGateway, childSteps, sandboxProvider, sandboxTeardownTimeoutMs, onRunEvent, }) {
1546
+ async function* runTurn({ provider, fallbackProviders, model, tools, pluginManager, skillRegistry, skills, scope, workingDirectory, sandboxWorkspace, rules, reviewAnswer, maxAnswerReviews, promoteMemory, resumeHandler, taskStore, systemPrompt, messages, projectInstructionContext, opts, taskGateway, sandboxProvider, sandboxTeardownTimeoutMs, onRunEvent, }) {
1489
1547
  const signal = opts?.signal;
1490
1548
  // One presenter for the whole stream, built from the registry this scope
1491
1549
  // already holds. Its absence HERE is what forced presentation to be name
@@ -1523,6 +1581,7 @@ async function* runTurn({ provider, fallbackProviders, model, tools, pluginManag
1523
1581
  emergencySave: true,
1524
1582
  runConfig: {
1525
1583
  model,
1584
+ ...(sandboxProvider ? { sandbox: { workspace: sandboxWorkspace } } : {}),
1526
1585
  ...(opts?.effort !== undefined ? { effort: opts.effort } : {}),
1527
1586
  timeoutMs: 600_000,
1528
1587
  tokenBudget: 1_000_000,
@@ -1550,7 +1609,7 @@ async function* runTurn({ provider, fallbackProviders, model, tools, pluginManag
1550
1609
  // The exemption reads `tools` at decision time, so it sees the task
1551
1610
  // tools `query()` registers deferred below and any tool server that
1552
1611
  // connected after this session was built.
1553
- resumeHandler: makeResumeHandler(approval, opts?.onPermission, permissionMode, (name, input) => isPromptExempt(tools, name, input)),
1612
+ resumeHandler,
1554
1613
  signal,
1555
1614
  ...scope,
1556
1615
  });
@@ -1581,22 +1640,6 @@ async function* runTurn({ provider, fallbackProviders, model, tools, pluginManag
1581
1640
  const mapped = toAgentEvent(event, presenter);
1582
1641
  if (!mapped)
1583
1642
  continue;
1584
- // On an `Agent` delegation finishing, attach the sub-agent's tool
1585
- // steps (collected via the gateway while the call blocked) as a
1586
- // `├─/└─` tree under its result, then reset for the next delegation.
1587
- // (Don't clear on tool_executing: the parent's events can be buffered
1588
- // and pulled only after the child already ran, which would wipe it.)
1589
- if (event.type === 'tool_completed' &&
1590
- event.toolName === 'Agent' &&
1591
- childSteps.length > 0 &&
1592
- mapped.kind === 'tool-end') {
1593
- yield {
1594
- ...mapped,
1595
- detail: [...(mapped.detail ?? []), ...asTree(childSteps)],
1596
- };
1597
- childSteps.length = 0;
1598
- continue;
1599
- }
1600
1643
  yield mapped;
1601
1644
  }
1602
1645
  }
@@ -2014,10 +2057,6 @@ function describeFallback(event) {
2014
2057
  `${status} ${why} (${event.code}). ` +
2015
2058
  `${name(event.toIndex, event.toProviderId, event.toModel)} — is serving the rest of this turn.`);
2016
2059
  }
2017
- /** Render a sub-agent's tool steps as a `├─/└─` tree for the Agent result. */
2018
- function asTree(steps) {
2019
- return steps.map((s, i) => `${i === steps.length - 1 ? '└─' : '├─'} ${s}`);
2020
- }
2021
2060
  /**
2022
2061
  * The one presentation function this host keeps.
2023
2062
  *