@namzu/cli 12.1.0 → 13.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 (199) hide show
  1. package/README.md +25 -1
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +119 -60
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/acp.d.ts +24 -8
  6. package/dist/commands/acp.d.ts.map +1 -1
  7. package/dist/commands/acp.js +221 -80
  8. package/dist/commands/acp.js.map +1 -1
  9. package/dist/commands/drain.d.ts.map +1 -1
  10. package/dist/commands/drain.js +45 -9
  11. package/dist/commands/drain.js.map +1 -1
  12. package/dist/commands/prior-messages.d.ts +11 -0
  13. package/dist/commands/prior-messages.d.ts.map +1 -0
  14. package/dist/commands/prior-messages.js +280 -0
  15. package/dist/commands/prior-messages.js.map +1 -0
  16. package/dist/commands/resume.d.ts.map +1 -1
  17. package/dist/commands/resume.js +21 -19
  18. package/dist/commands/resume.js.map +1 -1
  19. package/dist/commands/run-stream.d.ts +5 -3
  20. package/dist/commands/run-stream.d.ts.map +1 -1
  21. package/dist/commands/run-stream.js +66 -62
  22. package/dist/commands/run-stream.js.map +1 -1
  23. package/dist/commands/run.d.ts.map +1 -1
  24. package/dist/commands/run.js +50 -37
  25. package/dist/commands/run.js.map +1 -1
  26. package/dist/config/debug.d.ts +55 -0
  27. package/dist/config/debug.d.ts.map +1 -0
  28. package/dist/config/debug.js +126 -0
  29. package/dist/config/debug.js.map +1 -0
  30. package/dist/config/load.d.ts +88 -4
  31. package/dist/config/load.d.ts.map +1 -1
  32. package/dist/config/load.js +386 -56
  33. package/dist/config/load.js.map +1 -1
  34. package/dist/config/schema.d.ts +87 -5
  35. package/dist/config/schema.d.ts.map +1 -1
  36. package/dist/config/schema.js +5 -5
  37. package/dist/config/schema.js.map +1 -1
  38. package/dist/config/trusted-project-context.d.ts +11 -0
  39. package/dist/config/trusted-project-context.d.ts.map +1 -0
  40. package/dist/config/trusted-project-context.js +28 -0
  41. package/dist/config/trusted-project-context.js.map +1 -0
  42. package/dist/context/project-path.d.ts +3 -0
  43. package/dist/context/project-path.d.ts.map +1 -0
  44. package/dist/context/project-path.js +16 -0
  45. package/dist/context/project-path.js.map +1 -0
  46. package/dist/context/project-tracker.d.ts +30 -0
  47. package/dist/context/project-tracker.d.ts.map +1 -0
  48. package/dist/context/project-tracker.js +217 -0
  49. package/dist/context/project-tracker.js.map +1 -0
  50. package/dist/context/sandbox.d.ts +29 -9
  51. package/dist/context/sandbox.d.ts.map +1 -1
  52. package/dist/context/sandbox.js +8 -0
  53. package/dist/context/sandbox.js.map +1 -1
  54. package/dist/exit-codes.d.ts +5 -4
  55. package/dist/exit-codes.d.ts.map +1 -1
  56. package/dist/exit-codes.js +5 -4
  57. package/dist/exit-codes.js.map +1 -1
  58. package/dist/index.d.ts +2 -2
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +2 -2
  61. package/dist/index.js.map +1 -1
  62. package/dist/integrations/clipboard/text.d.ts +43 -0
  63. package/dist/integrations/clipboard/text.d.ts.map +1 -0
  64. package/dist/integrations/clipboard/text.js +43 -0
  65. package/dist/integrations/clipboard/text.js.map +1 -0
  66. package/dist/integrations/mcp/servers.d.ts +9 -0
  67. package/dist/integrations/mcp/servers.d.ts.map +1 -1
  68. package/dist/integrations/mcp/servers.js +11 -4
  69. package/dist/integrations/mcp/servers.js.map +1 -1
  70. package/dist/integrations/notifications/terminal.d.ts +32 -0
  71. package/dist/integrations/notifications/terminal.d.ts.map +1 -0
  72. package/dist/integrations/notifications/terminal.js +46 -0
  73. package/dist/integrations/notifications/terminal.js.map +1 -0
  74. package/dist/integrations/plugins/runtime.d.ts +17 -0
  75. package/dist/integrations/plugins/runtime.d.ts.map +1 -0
  76. package/dist/integrations/plugins/runtime.js +100 -0
  77. package/dist/integrations/plugins/runtime.js.map +1 -0
  78. package/dist/integrations/providers/credential-store.d.ts +17 -0
  79. package/dist/integrations/providers/credential-store.d.ts.map +1 -1
  80. package/dist/integrations/providers/credential-store.js +114 -2
  81. package/dist/integrations/providers/credential-store.js.map +1 -1
  82. package/dist/integrations/providers/index.d.ts +2 -2
  83. package/dist/integrations/providers/index.d.ts.map +1 -1
  84. package/dist/integrations/providers/index.js +2 -2
  85. package/dist/integrations/providers/index.js.map +1 -1
  86. package/dist/integrations/providers/keychain.d.ts +0 -13
  87. package/dist/integrations/providers/keychain.d.ts.map +1 -1
  88. package/dist/integrations/providers/keychain.js +0 -76
  89. package/dist/integrations/providers/keychain.js.map +1 -1
  90. package/dist/integrations/providers/oauth.d.ts +45 -16
  91. package/dist/integrations/providers/oauth.d.ts.map +1 -1
  92. package/dist/integrations/providers/oauth.js +170 -43
  93. package/dist/integrations/providers/oauth.js.map +1 -1
  94. package/dist/integrations/providers/subscription-login.d.ts +2 -0
  95. package/dist/integrations/providers/subscription-login.d.ts.map +1 -1
  96. package/dist/integrations/providers/subscription-login.js +117 -14
  97. package/dist/integrations/providers/subscription-login.js.map +1 -1
  98. package/dist/integrations/sessions/store.d.ts +111 -1
  99. package/dist/integrations/sessions/store.d.ts.map +1 -1
  100. package/dist/integrations/sessions/store.js +400 -7
  101. package/dist/integrations/sessions/store.js.map +1 -1
  102. package/dist/integrations/sessions/transcript-export.d.ts +25 -0
  103. package/dist/integrations/sessions/transcript-export.d.ts.map +1 -0
  104. package/dist/integrations/sessions/transcript-export.js +441 -0
  105. package/dist/integrations/sessions/transcript-export.js.map +1 -0
  106. package/dist/integrations/sessions/turn-evidence.d.ts +138 -0
  107. package/dist/integrations/sessions/turn-evidence.d.ts.map +1 -0
  108. package/dist/integrations/sessions/turn-evidence.js +506 -0
  109. package/dist/integrations/sessions/turn-evidence.js.map +1 -0
  110. package/dist/integrations/subagents/runtime.d.ts +7 -12
  111. package/dist/integrations/subagents/runtime.d.ts.map +1 -1
  112. package/dist/integrations/subagents/runtime.js +19 -10
  113. package/dist/integrations/subagents/runtime.js.map +1 -1
  114. package/dist/integrations/trust/store.d.ts.map +1 -1
  115. package/dist/integrations/trust/store.js +27 -13
  116. package/dist/integrations/trust/store.js.map +1 -1
  117. package/dist/permissions/canonical-project.d.ts +9 -0
  118. package/dist/permissions/canonical-project.d.ts.map +1 -0
  119. package/dist/permissions/canonical-project.js +13 -0
  120. package/dist/permissions/canonical-project.js.map +1 -0
  121. package/dist/permissions/checks.d.ts +65 -0
  122. package/dist/permissions/checks.d.ts.map +1 -0
  123. package/dist/permissions/checks.js +122 -0
  124. package/dist/permissions/checks.js.map +1 -0
  125. package/dist/permissions/headless-trust.d.ts +9 -5
  126. package/dist/permissions/headless-trust.d.ts.map +1 -1
  127. package/dist/permissions/headless-trust.js +18 -4
  128. package/dist/permissions/headless-trust.js.map +1 -1
  129. package/dist/permissions/rules.d.ts +16 -7
  130. package/dist/permissions/rules.d.ts.map +1 -1
  131. package/dist/permissions/rules.js +78 -13
  132. package/dist/permissions/rules.js.map +1 -1
  133. package/dist/tui/App.d.ts +5 -2
  134. package/dist/tui/App.d.ts.map +1 -1
  135. package/dist/tui/App.js +1555 -143
  136. package/dist/tui/App.js.map +1 -1
  137. package/dist/tui/Composer.d.ts +14 -3
  138. package/dist/tui/Composer.d.ts.map +1 -1
  139. package/dist/tui/Composer.js +53 -15
  140. package/dist/tui/Composer.js.map +1 -1
  141. package/dist/tui/EditPromptPicker.d.ts +8 -0
  142. package/dist/tui/EditPromptPicker.d.ts.map +1 -0
  143. package/dist/tui/EditPromptPicker.js +18 -0
  144. package/dist/tui/EditPromptPicker.js.map +1 -0
  145. package/dist/tui/LiveActivity.d.ts.map +1 -1
  146. package/dist/tui/LiveActivity.js +2 -1
  147. package/dist/tui/LiveActivity.js.map +1 -1
  148. package/dist/tui/Markdown.d.ts.map +1 -1
  149. package/dist/tui/Markdown.js +2 -1
  150. package/dist/tui/Markdown.js.map +1 -1
  151. package/dist/tui/PermissionOverlay.d.ts.map +1 -1
  152. package/dist/tui/PermissionOverlay.js +7 -1
  153. package/dist/tui/PermissionOverlay.js.map +1 -1
  154. package/dist/tui/Picker.d.ts +4 -4
  155. package/dist/tui/Picker.d.ts.map +1 -1
  156. package/dist/tui/Picker.js +86 -7
  157. package/dist/tui/Picker.js.map +1 -1
  158. package/dist/tui/ResumePicker.d.ts.map +1 -1
  159. package/dist/tui/ResumePicker.js +1 -1
  160. package/dist/tui/ResumePicker.js.map +1 -1
  161. package/dist/tui/Transcript.d.ts +4 -2
  162. package/dist/tui/Transcript.d.ts.map +1 -1
  163. package/dist/tui/Transcript.js +31 -5
  164. package/dist/tui/Transcript.js.map +1 -1
  165. package/dist/tui/agent.d.ts +123 -19
  166. package/dist/tui/agent.d.ts.map +1 -1
  167. package/dist/tui/agent.js +665 -143
  168. package/dist/tui/agent.js.map +1 -1
  169. package/dist/tui/compact-transcript.d.ts +24 -0
  170. package/dist/tui/compact-transcript.d.ts.map +1 -0
  171. package/dist/tui/compact-transcript.js +46 -0
  172. package/dist/tui/compact-transcript.js.map +1 -0
  173. package/dist/tui/conversation-history.d.ts +33 -0
  174. package/dist/tui/conversation-history.d.ts.map +1 -0
  175. package/dist/tui/conversation-history.js +42 -0
  176. package/dist/tui/conversation-history.js.map +1 -0
  177. package/dist/tui/edit-prompts.d.ts +23 -0
  178. package/dist/tui/edit-prompts.d.ts.map +1 -0
  179. package/dist/tui/edit-prompts.js +35 -0
  180. package/dist/tui/edit-prompts.js.map +1 -0
  181. package/dist/tui/live-window.d.ts +3 -1
  182. package/dist/tui/live-window.d.ts.map +1 -1
  183. package/dist/tui/live-window.js +20 -8
  184. package/dist/tui/live-window.js.map +1 -1
  185. package/dist/tui/slashCommands.d.ts +178 -9
  186. package/dist/tui/slashCommands.d.ts.map +1 -1
  187. package/dist/tui/slashCommands.js +339 -15
  188. package/dist/tui/slashCommands.js.map +1 -1
  189. package/dist/tui/terminal-display.d.ts +12 -0
  190. package/dist/tui/terminal-display.d.ts.map +1 -0
  191. package/dist/tui/terminal-display.js +65 -0
  192. package/dist/tui/terminal-display.js.map +1 -0
  193. package/dist/tui/types.d.ts +14 -2
  194. package/dist/tui/types.d.ts.map +1 -1
  195. package/dist/tui/workspace-diff.d.ts +40 -0
  196. package/dist/tui/workspace-diff.d.ts.map +1 -0
  197. package/dist/tui/workspace-diff.js +101 -0
  198. package/dist/tui/workspace-diff.js.map +1 -0
  199. package/package.json +7 -7
package/dist/tui/agent.js CHANGED
@@ -20,17 +20,214 @@
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, SearchToolsTool, ToolRegistry, asProjectId, asRunId, asSessionId, asTenantId, asTopicId, buildMemoryTools, createMemoryPromoter, createToolPresenter, genericLabel, getBuiltinTools, isTrustedReadOnly, query, resumeRun, } 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, createMemoryPromoter, createToolPresenter, genericLabel, getBuiltinTools, isTrustedReadOnly, query, resumeRun, withProviderFallback, } from '@namzu/sdk';
24
24
  import { join } from 'node:path';
25
25
  import { probeCapabilities } from '../context/capabilities.js';
26
26
  import { composeEnvironmentPrompt, readEnvironmentFacts } from '../context/environment.js';
27
- import { loadProjectInstructions } from '../context/project.js';
27
+ import { ProjectInstructionTracker } from '../context/project-tracker.js';
28
28
  import { resolveSandbox, sandboxResolvedSeverity, } from '../context/sandbox.js';
29
29
  import { connectMcpServers, } from '../integrations/mcp/servers.js';
30
- import { PROVIDER_REGISTRY, chainCapabilityDisagreements, chainPositionName, describeAcceptedMismatch, describeCapabilityRefusal, discoverProviders, ensureFreshAnthropicToken, ensureRegistered, findDetected, isAnthropicOAuthToken, isRegistered, missingCredentialMessage, primaryProvider, readPreferences, readSubscriptionCredential, resolveChainCapabilities, unresolvedMembers, unsupportedProviderMessage, } from '../integrations/providers/index.js';
30
+ import { createCliPluginRuntime } from '../integrations/plugins/runtime.js';
31
+ import { CredentialRefreshRejectedError, CredentialWithdrawnError, PROVIDER_REGISTRY, chainCapabilityDisagreements, chainPositionName, describeAcceptedMismatch, describeCapabilityRefusal, discoverProviders, ensureFreshAnthropicToken, ensureRegistered, findDetected, isAnthropicOAuthToken, isRegistered, missingCredentialMessage, primaryProvider, readPreferences, readSubscriptionCredential, resolveChainCapabilities, sameOAuthCredential, unresolvedMembers, unsupportedProviderMessage, } from '../integrations/providers/index.js';
31
32
  import { createSubagentRuntime } from '../integrations/subagents/runtime.js';
32
33
  import { cliLogger } from '../logging.js';
33
34
  import { composeMemoryPrompt, readMemory } from '../memory/store.js';
35
+ import { projectRunConversation } from './conversation-history.js';
36
+ /**
37
+ * Let one caller stop waiting without cutting a shared queue in the middle.
38
+ *
39
+ * The queued operation remains chained and owns its own signal. This observer
40
+ * only settles the caller promptly; releasing a queue slot here would allow a
41
+ * later owner to overlap the operation still ahead of it.
42
+ */
43
+ async function observeWithSignal(operation, signal) {
44
+ signal.throwIfAborted();
45
+ let rejectAbort = () => { };
46
+ const aborted = new Promise((_resolve, reject) => {
47
+ rejectAbort = reject;
48
+ });
49
+ const onAbort = () => rejectAbort(signal.reason);
50
+ signal.addEventListener('abort', onAbort, { once: true });
51
+ if (signal.aborted)
52
+ onAbort();
53
+ try {
54
+ return await Promise.race([operation, aborted]);
55
+ }
56
+ finally {
57
+ signal.removeEventListener('abort', onAbort);
58
+ }
59
+ }
60
+ /**
61
+ * Own every asynchronous operation that can still touch a session resource.
62
+ *
63
+ * A caller-owned signal remains caller-owned: closing the session aborts the
64
+ * fused operation signal, never the controller the caller passed. Streams are
65
+ * registered when handed out rather than when first pulled, so a consumer that
66
+ * abandons one between yields cannot leave the session's provider/tool owners
67
+ * alive past `close()`.
68
+ */
69
+ export class SessionOperationOwner {
70
+ cleanup;
71
+ lifetime = new AbortController();
72
+ active = new Set();
73
+ streamClosers = new Set();
74
+ closeReason = new DOMException('Agent session closed.', 'AbortError');
75
+ closed = false;
76
+ closePromise;
77
+ constructor(cleanup) {
78
+ this.cleanup = cleanup;
79
+ }
80
+ promise(callerSignal, start) {
81
+ let signal;
82
+ try {
83
+ signal = this.operationSignal(callerSignal);
84
+ }
85
+ catch (error) {
86
+ return Promise.reject(error);
87
+ }
88
+ const operation = Promise.resolve().then(() => {
89
+ signal.throwIfAborted();
90
+ return start(signal);
91
+ });
92
+ this.track(operation);
93
+ return operation;
94
+ }
95
+ stream(callerSignal, start) {
96
+ let signal;
97
+ try {
98
+ signal = this.operationSignal(callerSignal);
99
+ }
100
+ catch (error) {
101
+ return {
102
+ [Symbol.asyncIterator]() {
103
+ return {
104
+ next: () => Promise.reject(error),
105
+ };
106
+ },
107
+ };
108
+ }
109
+ const source = (async function* () {
110
+ signal.throwIfAborted();
111
+ yield* start(signal);
112
+ })()[Symbol.asyncIterator]();
113
+ let settled = false;
114
+ let settle;
115
+ const settlement = new Promise((resolve) => {
116
+ settle = resolve;
117
+ });
118
+ let operationTail = Promise.resolve();
119
+ let returnStarted = false;
120
+ let closeStreamPromise;
121
+ const finish = () => {
122
+ if (settled)
123
+ return;
124
+ settled = true;
125
+ this.streamClosers.delete(closeStream);
126
+ settle();
127
+ };
128
+ const enqueue = (operation) => {
129
+ const result = operationTail.then(operation);
130
+ operationTail = result.then(() => { }, () => { });
131
+ return result;
132
+ };
133
+ const observe = async (operation) => {
134
+ try {
135
+ const result = await enqueue(operation);
136
+ if (result.done)
137
+ finish();
138
+ return result;
139
+ }
140
+ catch (error) {
141
+ finish();
142
+ throw error;
143
+ }
144
+ };
145
+ const requestReturn = (value) => {
146
+ return observe(async () => {
147
+ returnStarted = true;
148
+ return source.return
149
+ ? await source.return(value)
150
+ : { done: true, value };
151
+ });
152
+ };
153
+ const closeStream = () => {
154
+ if (closeStreamPromise)
155
+ return closeStreamPromise;
156
+ closeStreamPromise = (async () => {
157
+ try {
158
+ await enqueue(async () => {
159
+ if (settled)
160
+ return;
161
+ let result;
162
+ if (returnStarted) {
163
+ result = await source.next();
164
+ }
165
+ else if (source.return) {
166
+ returnStarted = true;
167
+ result = await source.return(undefined);
168
+ }
169
+ else {
170
+ return;
171
+ }
172
+ while (!result.done)
173
+ result = await source.next();
174
+ });
175
+ }
176
+ finally {
177
+ finish();
178
+ }
179
+ })();
180
+ return closeStreamPromise;
181
+ };
182
+ const managed = {
183
+ next: (value) => observe(() => source.next(value)),
184
+ return: requestReturn,
185
+ throw: (error) => observe(async () => {
186
+ if (source.throw)
187
+ return await source.throw(error);
188
+ throw error;
189
+ }),
190
+ [Symbol.asyncIterator]() {
191
+ return this;
192
+ },
193
+ };
194
+ this.streamClosers.add(closeStream);
195
+ this.track(settlement);
196
+ return managed;
197
+ }
198
+ close() {
199
+ if (this.closePromise)
200
+ return this.closePromise;
201
+ this.closed = true;
202
+ this.lifetime.abort(this.closeReason);
203
+ this.closePromise = this.finishClose();
204
+ return this.closePromise;
205
+ }
206
+ operationSignal(callerSignal) {
207
+ if (this.closed)
208
+ throw this.closeReason;
209
+ return callerSignal
210
+ ? AbortSignal.any([callerSignal, this.lifetime.signal])
211
+ : this.lifetime.signal;
212
+ }
213
+ track(operation) {
214
+ const settlement = Promise.resolve(operation).then(() => { }, () => { });
215
+ this.active.add(settlement);
216
+ void settlement.then(() => this.active.delete(settlement));
217
+ }
218
+ async finishClose() {
219
+ await Promise.allSettled([...this.streamClosers].map((close) => close()));
220
+ await Promise.allSettled([...this.active]);
221
+ await this.cleanup();
222
+ }
223
+ }
224
+ async function drainIterator(iterator) {
225
+ if (!iterator.return)
226
+ return;
227
+ let result = await iterator.return(undefined);
228
+ while (!result.done)
229
+ result = await iterator.next();
230
+ }
34
231
  /**
35
232
  * Read preferences + run discovery once. Returned context drives the
36
233
  * App's lifecycle decision: ready / picker / unhealthy.
@@ -47,7 +244,12 @@ export async function probeAgentSession() {
47
244
  credentialGap: credentialGap(read.prefs, detected),
48
245
  };
49
246
  case 'missing':
50
- return { preferences: null, needsRepickReason: null, detected, credentialGap: null };
247
+ return {
248
+ preferences: null,
249
+ needsRepickReason: null,
250
+ detected,
251
+ credentialGap: null,
252
+ };
51
253
  case 'needs-repick':
52
254
  return {
53
255
  preferences: null,
@@ -124,7 +326,10 @@ function buildToolRegistry(cwd) {
124
326
  // why it sat on the no-prompt list; it writes into the user's project.
125
327
  // Separate from the user-curated MEMORY.md that is injected into the prompt.
126
328
  const memoryStore = new DiskMemoryStore({ baseDir: join(cwd, '.namzu') });
127
- registry.register(buildMemoryTools(memoryStore, memoryStore.getIndex()));
329
+ // Search through the store's async boundary. Its concrete index is lazy:
330
+ // handing `getIndex()` to the synchronous overload before the first store
331
+ // read makes a new process report every persisted memory as absent.
332
+ registry.register(buildMemoryTools(memoryStore));
128
333
  // `search_tools` is deliberately NOT registered here. It is only useful
129
334
  // where deferred tools exist, and that differs between this function's two
130
335
  // callers: the session below passes a `taskStore`, so query() registers the
@@ -135,6 +340,13 @@ function buildToolRegistry(cwd) {
135
340
  // caller's decision, made where the roster is known.
136
341
  return { registry, memoryStore };
137
342
  }
343
+ /** Refresh plugin skill metadata before each provider operation. */
344
+ async function currentPluginSkills(registry) {
345
+ const names = registry.list().map((skill) => skill.metadata.name);
346
+ for (const name of names)
347
+ await registry.load(name, 'metadata');
348
+ return registry.list();
349
+ }
138
350
  export async function createAgentSession(prefs, detected, options = {}) {
139
351
  const scope = options.scope ?? mintScope();
140
352
  const cwd = options.cwd ?? process.cwd();
@@ -201,7 +413,9 @@ export async function createAgentSession(prefs, detected, options = {}) {
201
413
  'namzu.provider.skipped_count': fallbackPlan.notices.length,
202
414
  });
203
415
  for (const notice of fallbackPlan.notices) {
204
- cliLogger().warn(notice, { [EVENT_NAME_ATTRIBUTE]: BOOT_EVENT_NAMES.PROVIDER_RESOLVED });
416
+ cliLogger().warn(notice, {
417
+ [EVENT_NAME_ATTRIBUTE]: BOOT_EVENT_NAMES.PROVIDER_RESOLVED,
418
+ });
205
419
  }
206
420
  let provider;
207
421
  try {
@@ -218,29 +432,65 @@ export async function createAgentSession(prefs, detected, options = {}) {
218
432
  // changed. Gated on `det.oauth` so env / secrets credentials are never
219
433
  // touched.
220
434
  //
221
- // `origin` decides WHICH store, and travels with the credential from
222
- // discovery rather than being assumed here: namzu's own store and the
223
- // borrowed Keychain entry both produce a refreshable credential, and reading
224
- // one while writing the other would refresh forever without ever landing.
435
+ // `origin` decides WHICH publication rule applies, and travels with the
436
+ // credential from discovery rather than being assumed here: namzu's own
437
+ // store can be conditionally replaced; the borrowed Keychain entry is
438
+ // read-only and a refreshed value may live only in this session.
225
439
  const subscriptionRefresh = primary.id === 'anthropic' && Boolean(det?.oauth);
226
440
  const credentialOrigin = det?.oauth?.origin ?? 'keychain';
227
441
  let currentToken = det?.apiKey;
228
- const refreshTokenIfNeeded = async () => {
229
- if (!subscriptionRefresh)
230
- return;
442
+ let refreshTail = Promise.resolve();
443
+ let rejectedRefresh;
444
+ const performRefresh = async (signal) => {
445
+ signal?.throwIfAborted();
446
+ // Read only after this owner reaches the head. A sibling may have rotated
447
+ // the durable credential while we waited; reading before the queue would
448
+ // make its success invisible and permit a stale-token downgrade.
231
449
  const cred = readSubscriptionCredential(credentialOrigin);
450
+ // This store is the authority for the whole session. Its absence is a
451
+ // credential withdrawal, not permission to keep using the client object
452
+ // that happens to remain in memory after logout or another process's clear.
232
453
  if (!cred)
233
- return;
234
- const fresh = await ensureFreshAnthropicToken(cred.accessToken, {
235
- refreshToken: cred.refreshToken,
236
- expiresAt: cred.expiresAt,
237
- origin: credentialOrigin,
238
- });
454
+ throw new CredentialWithdrawnError();
455
+ if (rejectedRefresh && sameOAuthCredential(rejectedRefresh.credential, cred)) {
456
+ throw rejectedRefresh.error;
457
+ }
458
+ // A different non-null credential is an external winner (usually a fresh
459
+ // login). The old grant's refusal says nothing about this one.
460
+ rejectedRefresh = undefined;
461
+ let fresh;
462
+ try {
463
+ fresh = await ensureFreshAnthropicToken(cred.accessToken, {
464
+ refreshToken: cred.refreshToken,
465
+ expiresAt: cred.expiresAt,
466
+ scopes: cred.scopes,
467
+ origin: credentialOrigin,
468
+ }, signal);
469
+ }
470
+ catch (error) {
471
+ if (error instanceof CredentialRefreshRejectedError) {
472
+ // Cache only if the same credential is still authoritative after the
473
+ // network wait. A concurrent login must not inherit the old grant's
474
+ // permanent classification.
475
+ signal?.throwIfAborted();
476
+ const current = readSubscriptionCredential(credentialOrigin);
477
+ if (!current)
478
+ throw new CredentialWithdrawnError();
479
+ if (sameOAuthCredential(current, cred)) {
480
+ rejectedRefresh = { credential: cred, error };
481
+ }
482
+ }
483
+ throw error;
484
+ }
485
+ signal?.throwIfAborted();
239
486
  if (fresh === currentToken)
240
487
  return;
241
- currentToken = fresh;
242
488
  try {
243
- provider = constructProvider('anthropic', { ...det, apiKey: fresh }, model);
489
+ const refreshedProvider = constructProvider('anthropic', { ...det, apiKey: fresh }, model);
490
+ // Publish the pair together. If construction fails, both old values
491
+ // remain live and the next operation can retry against the stored token.
492
+ provider = refreshedProvider;
493
+ currentToken = fresh;
244
494
  }
245
495
  catch (err) {
246
496
  // Keep the previous client; the turn may still 401 but won't crash.
@@ -251,16 +501,23 @@ export async function createAgentSession(prefs, detected, options = {}) {
251
501
  cliLogger().warn('provider client rebuild after token refresh failed', exceptionAttributes(err));
252
502
  }
253
503
  };
254
- // Read ONCE, here, rather than per turn the way memory is.
255
- //
256
- // Memory is re-read every turn because `/remember` writes to it mid-session,
257
- // so a stale read would drop a fact the user just saved. Nothing in namzu
258
- // writes an instructions file, and reading once buys a property worth more
259
- // than the freshness: `instructionFiles` is then exactly the set whose text
260
- // went into the prompt, for the whole life of the session. Re-reading per
261
- // turn would make the line a surface prints at connect time a claim about
262
- // the past. An edited file takes effect on the next session.
263
- const projectInstructions = loadProjectInstructions(cwd);
504
+ const refreshTokenIfNeeded = (signal) => {
505
+ if (!subscriptionRefresh) {
506
+ signal?.throwIfAborted();
507
+ return Promise.resolve();
508
+ }
509
+ signal?.throwIfAborted();
510
+ const queued = refreshTail.then(() => performRefresh(signal));
511
+ // Keep later owners behind this slot even if its caller stops observing it.
512
+ // The catch makes the private tail non-rejecting without changing the
513
+ // exact outcome returned to the owner below.
514
+ refreshTail = queued.catch(() => { });
515
+ return signal ? observeWithSignal(queued, signal) : queued;
516
+ };
517
+ // Session-owned discovery with one drain cursor per run. A child shares the
518
+ // discovered scopes without being able to consume the parent's update, and
519
+ // an edit takes effect in this session rather than only after reconnecting.
520
+ const projectInstructions = new ProjectInstructionTracker(cwd);
264
521
  // Before the registry, because a `requireIsolation` this machine cannot
265
522
  // meet throws here — and failing before the session is built is the
266
523
  // difference between "namzu refused to start" and a half-constructed
@@ -302,17 +559,23 @@ export async function createAgentSession(prefs, detected, options = {}) {
302
559
  'namzu.sandbox.unconfined': sandbox.unconfined,
303
560
  });
304
561
  const { registry, memoryStore } = buildToolRegistry(cwd);
562
+ // Registered only on the main session path. Sub-agents call
563
+ // `buildToolRegistry` directly below, so they never receive these tools.
564
+ // Per-send denial further keeps the schemas out of ordinary human turns.
565
+ const goalAuthorities = new Map();
566
+ const goalToolNames = new Set(SESSION_GOAL_TOOL_NAMES);
567
+ if (options.sessionGoals) {
568
+ registry.register(buildSessionGoalTools(options.sessionGoals, (runId) => goalAuthorities.get(runId)));
569
+ }
305
570
  // External tool servers, before the roster is counted, so `toolNames` and
306
571
  // the `/tools` list a user reads include what they configured. Connecting
307
572
  // after the count would report a session smaller than the one that runs.
308
573
  const mcp = await connectMcpServers(options.mcpServers, { cwd });
309
574
  if (mcp.tools.length > 0)
310
575
  registry.register([...mcp.tools]);
311
- // Connectors are the one discovery source THIS function performs —
312
- // plugins and skills are loaded elsewhere (`run-flags.ts`'s
313
- // `loadSkillsContext`, per turn) and neither is wired to the boot path
314
- // yet, so a fabricated "plugins 0 · skills 0" here would claim a
315
- // measurement that was never taken. This reports only what was.
576
+ // External connector discovery is reported separately from executable
577
+ // plugin discovery. Folding both counts together would make a failed server
578
+ // indistinguishable from a plugin that never enabled.
316
579
  cliLogger().info('discovery complete', {
317
580
  [EVENT_NAME_ATTRIBUTE]: BOOT_EVENT_NAMES.DISCOVERY_COMPLETED,
318
581
  'namzu.discovery.kind': 'connector',
@@ -362,12 +625,16 @@ export async function createAgentSession(prefs, detected, options = {}) {
362
625
  const sub = await createSubagentRuntime({
363
626
  cwd,
364
627
  model,
628
+ ...(sandbox.provider ? { sandboxProvider: sandbox.provider } : {}),
629
+ ...(options.sandbox?.teardownTimeoutMs !== undefined
630
+ ? { sandboxTeardownTimeoutMs: options.sandbox.teardownTimeoutMs }
631
+ : {}),
365
632
  // A sub-agent works in the same repository and writes the same code,
366
633
  // so it is bound by the same instructions. Without this the parent
367
634
  // honours the project's rules and every task it delegates quietly
368
635
  // does not — the worse half of the feature, because the delegating
369
636
  // turn reports success either way.
370
- ...(projectInstructions.prompt ? { projectInstructions: projectInstructions.prompt } : {}),
637
+ projectInstructionContext: () => projectInstructions.createRunContext(),
371
638
  // Same argument as the instructions, one step further: a sub-agent that
372
639
  // does not know what day it is dates a changelog entry from a training
373
640
  // cut-off, and the parent reports the delegation as successful.
@@ -447,6 +714,27 @@ export async function createAgentSession(prefs, detected, options = {}) {
447
714
  // settle — is what this repository has been doing all along by accident.
448
715
  // A run that learned nothing still writes nothing.
449
716
  const promoteMemory = createMemoryPromoter({ store: memoryStore });
717
+ // Plugins are the last fallible startup resource. The ordering is ownership:
718
+ // a malformed MCP entry cannot strand imported plugin hooks, and a plugin
719
+ // refusal closes the MCP processes already opened for this candidate before
720
+ // returning an inert session. Sub-agents were built above from their own
721
+ // registries, so executable plugins remain a top-level-session capability.
722
+ let pluginRuntime;
723
+ try {
724
+ pluginRuntime = await createCliPluginRuntime(options.plugins, registry, cwd);
725
+ }
726
+ catch (error) {
727
+ await mcp.close();
728
+ return emptySession(describeError(error));
729
+ }
730
+ if (pluginRuntime) {
731
+ cliLogger().info('discovery complete', {
732
+ [EVENT_NAME_ATTRIBUTE]: BOOT_EVENT_NAMES.DISCOVERY_COMPLETED,
733
+ 'namzu.discovery.kind': 'plugin',
734
+ 'namzu.discovery.count': pluginRuntime.pluginCount,
735
+ 'namzu.discovery.skill_count': pluginRuntime.skills.size,
736
+ });
737
+ }
450
738
  // The one terminal POSITIVE event on this path, emitted exactly once —
451
739
  // every early return above goes through `emptySession`, which emits
452
740
  // `namzu.boot.refused` instead, and the `resolveSandbox` throw path above
@@ -457,109 +745,205 @@ export async function createAgentSession(prefs, detected, options = {}) {
457
745
  cliLogger().info('agent session ready', {
458
746
  [EVENT_NAME_ATTRIBUTE]: BOOT_EVENT_NAMES.BOOT_READY,
459
747
  });
748
+ const operations = new SessionOperationOwner(async () => {
749
+ const results = await Promise.allSettled([pluginRuntime?.close(), mcp.close()]);
750
+ const failures = results
751
+ .filter((result) => result.status === 'rejected')
752
+ .map((result) => result.reason);
753
+ if (failures.length > 0)
754
+ throw new AggregateError(failures, 'Session cleanup failed.');
755
+ });
756
+ let reasoningEffortLevels;
757
+ let effortNotice;
758
+ try {
759
+ const capabilityView = withProviderFallback([
760
+ { provider, model },
761
+ ...fallbackPlan.build(currentToken),
762
+ ]);
763
+ const offered = capabilityView.reasoningEffortLevelsFor
764
+ ? capabilityView.reasoningEffortLevelsFor(model)
765
+ : capabilityView.effortLevelsFor?.(model);
766
+ reasoningEffortLevels = offered === undefined ? undefined : Object.freeze([...offered]);
767
+ }
768
+ catch (error) {
769
+ effortNotice = `Reasoning effort levels could not be established for this session: ${describeError(error)}.`;
770
+ }
460
771
  return {
461
772
  hasProvider: true,
773
+ sandbox: {
774
+ unconfined: sandbox.unconfined,
775
+ ...(sandbox.environment ? { environment: sandbox.environment } : {}),
776
+ enforced: sandbox.enforced,
777
+ required: sandbox.required,
778
+ },
462
779
  providerSummary: entry.label,
463
780
  modelSummary: model,
781
+ reasoningEffortLevels,
782
+ compact: (messages) => operations.promise(undefined, (signal) => compactNow({
783
+ messages,
784
+ config: COMPACTION_CONFIG,
785
+ provider,
786
+ model,
787
+ signal,
788
+ })),
464
789
  // Reads the same registry object the deferred registration mutates, at
465
790
  // call time — the pair of `promptExemptTools` below, and for the same
466
791
  // reason.
467
- toolNames: () => registry.getCallableTools().map((t) => t.name),
792
+ toolNames: () => registry
793
+ .getCallableTools()
794
+ .map((t) => t.name)
795
+ .filter((name) => !goalToolNames.has(name)),
468
796
  agentIds: allowedAgentIds,
469
- instructionFiles: projectInstructions.files.map((f) => f.path),
470
- skippedInstructionFiles: projectInstructions.skipped,
797
+ get instructionFiles() {
798
+ return projectInstructions.instructionFiles;
799
+ },
800
+ get skippedInstructionFiles() {
801
+ return projectInstructions.skippedInstructionFiles;
802
+ },
471
803
  mcpConnected: mcp.connected,
472
804
  mcpFailed: mcp.failed,
473
805
  configNotices: [
474
806
  ...(capabilityNotice ? [capabilityNotice] : []),
807
+ ...(effortNotice ? [effortNotice] : []),
475
808
  ...unresolvedNotice.map((line) => `Provider chain: capabilities could not be established for ${line}.`),
476
809
  ...fallbackPlan.notices,
477
810
  ],
478
- close: () => mcp.close(),
811
+ close: () => operations.close(),
479
812
  errorHint: null,
480
813
  errorKind: null,
481
814
  // Reads the same object the handler mutates, at call time.
482
815
  approvalLatched: () => approval.all,
483
- promptExemptTools: () => promptExemptToolNames(registry),
484
- send: async function* (messages, opts) {
816
+ resetApprovalLatch: () => {
817
+ approval.all = false;
818
+ },
819
+ promptExemptTools: () => promptExemptToolNames(registry).filter((name) => !goalToolNames.has(name)),
820
+ send: (messages, opts) => operations.stream(opts?.signal, (signal) => (async function* () {
485
821
  // Renew a lapsed OAuth token before the turn runs (no-op for valid
486
- // tokens and non-keychain credentials).
487
- await refreshTokenIfNeeded();
822
+ // tokens and non-subscription credentials).
823
+ await refreshTokenIfNeeded(signal);
488
824
  // namzu identity first (so it establishes who the agent is even when
489
825
  // the credential layer prepends whatever prefix its token requires),
490
- // then memory read fresh each turn, then the project's own
491
- // instructions, then per-turn extra (active skills).
826
+ // then memory and per-turn extra context. Project instructions are a
827
+ // separate retained user-context snapshot prepared by the controller;
828
+ // that is what lets nested scopes be replaced and persisted safely.
492
829
  //
493
- // Project instructions sit AFTER the identity block deliberately, and
494
- // the order is the containment: they are text off the disk of
495
- // whatever directory the agent was pointed at, so the rules they must
496
- // not be able to rewrite are established before they are read.
497
- //
498
- // The environment block is read fresh every turn, unlike the project
499
- // instructions, because both facts in it can change WHILE the session
830
+ // The environment block is read fresh every turn because both facts in it
831
+ // can change WHILE the session
500
832
  // runs — midnight passes, and the agent checks out a branch itself.
501
833
  // Its text only changes when a fact changes, so it costs a prompt-cache
502
834
  // miss exactly when a hit would have been a stale claim.
835
+ const pluginSkills = pluginRuntime
836
+ ? await currentPluginSkills(pluginRuntime.skills)
837
+ : undefined;
503
838
  const memoryPrompt = composeMemoryPrompt(readMemory());
504
839
  const environmentPrompt = composeEnvironmentPrompt(await readEnvironmentFacts(cwd));
505
- const systemPrompt = [
506
- NAMZU_IDENTITY,
507
- environmentPrompt,
508
- memoryPrompt,
509
- projectInstructions.prompt,
510
- opts?.extraSystem,
511
- ]
840
+ const systemPrompt = [NAMZU_IDENTITY, environmentPrompt, memoryPrompt, opts?.extraSystem]
512
841
  .filter((s) => Boolean(s))
513
842
  .join('\n\n') || undefined;
514
- yield* runTurn({
515
- provider,
516
- // Constructed HERE, per turn, and that is not an optimisation to
517
- // undo. `refreshTokenIfNeeded` above replaces the head's client
518
- // object when an OAuth token rotates, so a member list built once at
519
- // session creation would hand the kernel a client holding a token
520
- // that expired hours ago — and a chain whose own members are stale
521
- // is a fallback that fails for the reason the fallback exists to
522
- // survive. Building a driver is a client object, not a request.
523
- fallbackProviders: fallbackPlan.build(currentToken),
524
- model,
525
- tools: registry,
526
- scope,
527
- workingDirectory: cwd,
528
- rules: options.rules,
529
- permissionMode: options.permissionMode,
530
- reviewAnswer: options.reviewAnswer,
531
- maxAnswerReviews: options.maxAnswerReviews,
532
- promoteMemory,
533
- approval,
534
- taskStore,
535
- systemPrompt,
536
- messages,
537
- opts,
538
- taskGateway: subagentGateway,
539
- onRunEvent: options.onRunEvent,
540
- childSteps,
541
- ...(sandbox.provider ? { sandboxProvider: sandbox.provider } : {}),
542
- });
543
- },
544
- resumeDurable: async ({ entry, checkpointStore, claimFence, signal }) => {
843
+ let capturedAuthority;
844
+ if (opts?.goalRound) {
845
+ if (!opts.runId)
846
+ throw new Error('A goal round requires a caller-reserved runId.');
847
+ if (!options.sessionGoals)
848
+ throw new Error('This session has no durable goal store.');
849
+ if (opts.goalRound.sessionId !== scope.sessionId ||
850
+ opts.goalRound.tenantId !== scope.tenantId) {
851
+ throw new Error('Goal-round authority does not belong to this agent session scope.');
852
+ }
853
+ const current = await options.sessionGoals.getGoal(scope.sessionId, scope.tenantId);
854
+ if (!current ||
855
+ current.phase !== 'active' ||
856
+ current.id !== opts.goalRound.id ||
857
+ current.revision !== opts.goalRound.revision ||
858
+ current.objective !== opts.goalRound.objective ||
859
+ current.roundsAdmitted !== opts.goalRound.round ||
860
+ current.maxGoalRounds !== opts.goalRound.maxGoalRounds) {
861
+ throw new Error('Goal-round authority is stale or does not match the durable goal.');
862
+ }
863
+ if (goalAuthorities.has(opts.runId)) {
864
+ throw new Error(`Run ${opts.runId} already owns goal-round authority.`);
865
+ }
866
+ capturedAuthority = Object.freeze({ ...opts.goalRound });
867
+ goalAuthorities.set(opts.runId, capturedAuthority);
868
+ }
869
+ try {
870
+ yield* runTurn({
871
+ provider,
872
+ // Constructed HERE, per turn, and that is not an optimisation to
873
+ // undo. `refreshTokenIfNeeded` above replaces the head's client
874
+ // object when an OAuth token rotates, so a member list built once at
875
+ // session creation would hand the kernel a client holding a token
876
+ // that expired hours ago — and a chain whose own members are stale
877
+ // is a fallback that fails for the reason the fallback exists to
878
+ // survive. Building a driver is a client object, not a request.
879
+ fallbackProviders: fallbackPlan.build(currentToken),
880
+ model,
881
+ tools: registry,
882
+ pluginManager: pluginRuntime?.manager,
883
+ skillRegistry: pluginRuntime?.skills,
884
+ skills: pluginSkills,
885
+ scope,
886
+ workingDirectory: cwd,
887
+ rules: options.rules,
888
+ permissionMode: opts?.permissionMode ?? options.permissionMode,
889
+ reviewAnswer: options.reviewAnswer,
890
+ maxAnswerReviews: options.maxAnswerReviews,
891
+ promoteMemory,
892
+ approval,
893
+ taskStore,
894
+ systemPrompt,
895
+ messages,
896
+ projectInstructionContext: projectInstructions.createRunContext(),
897
+ opts: { ...opts, signal },
898
+ taskGateway: subagentGateway,
899
+ onRunEvent: options.onRunEvent,
900
+ childSteps,
901
+ ...(sandbox.provider ? { sandboxProvider: sandbox.provider } : {}),
902
+ ...(options.sandbox?.teardownTimeoutMs !== undefined
903
+ ? {
904
+ sandboxTeardownTimeoutMs: options.sandbox.teardownTimeoutMs,
905
+ }
906
+ : {}),
907
+ });
908
+ }
909
+ finally {
910
+ if (opts?.runId &&
911
+ capturedAuthority &&
912
+ goalAuthorities.get(opts.runId) === capturedAuthority) {
913
+ goalAuthorities.delete(opts.runId);
914
+ }
915
+ }
916
+ })()),
917
+ resumeDurable: ({ entry, checkpointStore, claimFence, signal }) => operations.promise(signal, async (ownedSignal) => {
545
918
  // The same prelude a turn runs, and for the same reasons: a lapsed
546
919
  // OAuth token has to be renewed before the provider is used, and the
547
920
  // fallback chain has to be built AFTER that so its members do not
548
921
  // hold a client the refresh just replaced.
549
- await refreshTokenIfNeeded();
922
+ await refreshTokenIfNeeded(ownedSignal);
923
+ const pluginSkills = pluginRuntime
924
+ ? await currentPluginSkills(pluginRuntime.skills)
925
+ : undefined;
550
926
  const memoryPrompt = composeMemoryPrompt(readMemory());
551
927
  const environmentPrompt = composeEnvironmentPrompt(await readEnvironmentFacts(cwd));
552
- const systemPrompt = [NAMZU_IDENTITY, environmentPrompt, memoryPrompt, projectInstructions.prompt]
928
+ const systemPrompt = [NAMZU_IDENTITY, environmentPrompt, memoryPrompt]
553
929
  .filter((s) => Boolean(s))
554
930
  .join('\n\n') || undefined;
555
931
  return resumeRun({
556
932
  provider,
557
933
  fallbackProviders: fallbackPlan.build(currentToken),
558
934
  tools: registry,
935
+ pluginManager: pluginRuntime?.manager,
936
+ skillRegistry: pluginRuntime?.skills,
937
+ skills: pluginSkills,
559
938
  taskStore,
560
939
  ...(subagentGateway ? { taskGateway: subagentGateway } : {}),
561
940
  authorizationGate: gateFor(options.rules),
562
941
  compactionConfig: COMPACTION_CONFIG,
942
+ projectInstructionContext: projectInstructions.createRunContext(),
943
+ ...(sandbox.provider ? { sandboxProvider: sandbox.provider } : {}),
944
+ ...(options.sandbox?.teardownTimeoutMs !== undefined
945
+ ? { sandboxTeardownTimeoutMs: options.sandbox.teardownTimeoutMs }
946
+ : {}),
563
947
  // NOT `emergencySave`, unlike a turn. The manager is a singleton
564
948
  // whose `attach` detaches whoever held it before, so a caller
565
949
  // resuming several runs in one process would leave only the last
@@ -585,7 +969,7 @@ export async function createAgentSession(prefs, detected, options = {}) {
585
969
  // forced presentation to be name matching: `toAgentEvent` was pure
586
970
  // over a `RunEvent` and could not ask a tool anything.
587
971
  resumeHandler: makeResumeHandler(approval, undefined, options.permissionMode, (n, i) => isPromptExempt(registry, n, i)),
588
- ...(signal ? { signal } : {}),
972
+ signal: ownedSignal,
589
973
  // Attribution comes from the ENTRY, not from this session: the run
590
974
  // belongs to whoever started it, and stamping the drainer's ids onto
591
975
  // it would file another tenant's work under this one.
@@ -600,7 +984,7 @@ export async function createAgentSession(prefs, detected, options = {}) {
600
984
  checkpointStore,
601
985
  ...(claimFence !== undefined ? { claimFence } : {}),
602
986
  });
603
- },
987
+ }),
604
988
  };
605
989
  }
606
990
  function planFallbacks(members, detected) {
@@ -711,50 +1095,101 @@ export function constructProvider(id, det, model) {
711
1095
  throw new Error(unsupportedProviderMessage(id));
712
1096
  }
713
1097
  }
1098
+ const PICKER_PROVIDER_DEADLINE_MS = 3_000;
1099
+ class PickerProviderTimeoutError extends Error {
1100
+ constructor() {
1101
+ super(`The provider did not answer within ${PICKER_PROVIDER_DEADLINE_MS}ms.`);
1102
+ this.name = 'PickerProviderTimeoutError';
1103
+ }
1104
+ }
1105
+ /** Bound a picker side-call even when a third-party provider ignores abort. */
1106
+ async function runPickerProviderOperation(signal, operation) {
1107
+ signal?.throwIfAborted();
1108
+ const controller = new AbortController();
1109
+ const timeoutCause = new PickerProviderTimeoutError();
1110
+ let rejectBoundary = () => { };
1111
+ const boundary = new Promise((_resolve, reject) => {
1112
+ rejectBoundary = reject;
1113
+ });
1114
+ const onCallerAbort = () => {
1115
+ controller.abort(signal?.reason);
1116
+ rejectBoundary(signal?.reason);
1117
+ };
1118
+ signal?.addEventListener('abort', onCallerAbort, { once: true });
1119
+ const timer = setTimeout(() => {
1120
+ controller.abort(timeoutCause);
1121
+ rejectBoundary(timeoutCause);
1122
+ }, PICKER_PROVIDER_DEADLINE_MS);
1123
+ try {
1124
+ return await Promise.race([operation(controller.signal), boundary]);
1125
+ }
1126
+ catch (error) {
1127
+ // Cooperative transports may replace the owner cause with AbortError.
1128
+ if (signal?.aborted)
1129
+ throw signal.reason;
1130
+ if (controller.signal.aborted && controller.signal.reason === timeoutCause)
1131
+ throw timeoutCause;
1132
+ throw error;
1133
+ }
1134
+ finally {
1135
+ clearTimeout(timer);
1136
+ signal?.removeEventListener('abort', onCallerAbort);
1137
+ }
1138
+ }
714
1139
  /**
715
1140
  * Ask a detected provider what models it has.
716
1141
  *
717
1142
  * Instantiates the provider and calls its optional `listModels()`, inside a 3s
718
1143
  * race so a wedged local server or a slow catalog cannot stall the UI.
719
1144
  */
720
- export async function describeProviderModels(id, det) {
1145
+ export async function describeProviderModels(id, det, signal) {
721
1146
  try {
1147
+ signal?.throwIfAborted();
722
1148
  // constructProvider calls ProviderRegistry.create, which throws
723
1149
  // "Unsupported provider type" until the vendor package has registered
724
1150
  // itself. The run path registers lazily via ensureRegistered; the
725
1151
  // listing path must do the same or every provider returns nothing.
726
1152
  await ensureRegistered(id);
1153
+ signal?.throwIfAborted();
727
1154
  const provider = constructProvider(id, det, det.entry.defaultModel);
728
1155
  if (typeof provider.listModels !== 'function')
729
1156
  return { kind: 'unsupported' };
730
- const TIMEOUT = Symbol('timeout');
731
- const timeout = new Promise((resolve) => setTimeout(() => resolve(TIMEOUT), 3000));
732
- const models = await Promise.race([provider.listModels(), timeout]);
733
- if (models === TIMEOUT)
734
- return { kind: 'timeout' };
735
- return { kind: 'ok', models: models.map((m) => ({ id: m.id, name: m.name || m.id })) };
1157
+ const models = await runPickerProviderOperation(signal, (operationSignal) => provider.listModels?.(operationSignal) ?? Promise.resolve([]));
1158
+ return {
1159
+ kind: 'ok',
1160
+ models: models.map((m) => ({ id: m.id, name: m.name || m.id })),
1161
+ };
736
1162
  }
737
1163
  catch (err) {
738
- return { kind: 'failed', reason: err instanceof Error ? err.message : String(err) };
1164
+ if (signal?.aborted)
1165
+ throw signal.reason;
1166
+ if (err instanceof PickerProviderTimeoutError)
1167
+ return { kind: 'timeout' };
1168
+ return {
1169
+ kind: 'failed',
1170
+ reason: err instanceof Error ? err.message : String(err),
1171
+ };
739
1172
  }
740
1173
  }
741
1174
  /**
742
1175
  * Check a key the operator just typed, without spending a turn.
743
1176
  *
744
- * Uses the provider's own `listModels` where it has one: a successful listing
745
- * is proof the credential authenticates, and it costs nothing. A driver without
1177
+ * Uses the provider's declared `probeCredential` operation. A driver without
746
1178
  * one cannot be checked cheaply, and that is reported as `unverifiable` rather
747
1179
  * than dressed up as success — claiming a check that did not happen is the
748
- * failure this whole surface is built to avoid.
1180
+ * failure this whole surface is built to avoid. A model catalogue is
1181
+ * deliberately not substituted: a menu is not evidence that a key worked.
749
1182
  *
750
1183
  * The key never appears in the returned reason. Provider errors are passed
751
1184
  * through, and a driver that echoes a credential into its own error message
752
1185
  * would defeat this; that is a driver bug and not one this can paper over, so
753
1186
  * the reason is also truncated.
754
1187
  */
755
- export async function verifyCredential(id, det) {
1188
+ export async function verifyCredential(id, det, signal) {
756
1189
  try {
1190
+ signal?.throwIfAborted();
757
1191
  await ensureRegistered(id);
1192
+ signal?.throwIfAborted();
758
1193
  const provider = constructProvider(id, det, det.entry.defaultModel);
759
1194
  // Declared, never inferred. A driver without a probe is unverifiable —
760
1195
  // including one added years from now by someone who never reads this.
@@ -764,10 +1199,14 @@ export async function verifyCredential(id, det) {
764
1199
  // not authenticate at all.
765
1200
  if (typeof provider.probeCredential !== 'function')
766
1201
  return { kind: 'unverifiable' };
767
- await provider.probeCredential();
1202
+ await runPickerProviderOperation(signal, (operationSignal) => provider.probeCredential?.(operationSignal) ?? Promise.resolve());
768
1203
  return { kind: 'verified' };
769
1204
  }
770
1205
  catch (err) {
1206
+ if (signal?.aborted)
1207
+ throw signal.reason;
1208
+ if (err instanceof PickerProviderTimeoutError)
1209
+ return { kind: 'unverifiable' };
771
1210
  // The server answered and said no, versus nothing was learned. Collapsing
772
1211
  // these would tell an operator on broken wifi to rotate a key that is fine.
773
1212
  if (isCredentialRejection(err)) {
@@ -887,7 +1326,7 @@ const COMPACTION_CONFIG = {
887
1326
  maxCharsPerRequirement: 300,
888
1327
  maxCharsPerTask: 400,
889
1328
  };
890
- async function* runTurn({ provider, fallbackProviders, model, tools, scope, workingDirectory, rules, permissionMode, reviewAnswer, maxAnswerReviews, promoteMemory, approval, taskStore, systemPrompt, messages, opts, taskGateway, childSteps, sandboxProvider, onRunEvent, }) {
1329
+ 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, }) {
891
1330
  const signal = opts?.signal;
892
1331
  // One presenter for the whole stream, built from the registry this scope
893
1332
  // already holds. Its absence HERE is what forced presentation to be name
@@ -897,11 +1336,18 @@ async function* runTurn({ provider, fallbackProviders, model, tools, scope, work
897
1336
  try {
898
1337
  const events = query({
899
1338
  provider,
1339
+ ...(opts?.runId ? { runId: opts.runId } : {}),
900
1340
  // Omitted rather than empty when there is no tail. `query` treats the
901
1341
  // two the same, but an absent option reads as "this run has no chain"
902
1342
  // where `[]` reads as "this run has a chain with nothing in it".
903
1343
  ...(fallbackProviders.length > 0 ? { fallbackProviders } : {}),
904
1344
  tools,
1345
+ ...(pluginManager ? { pluginManager } : {}),
1346
+ ...(skillRegistry ? { skillRegistry } : {}),
1347
+ ...(skills ? { skills } : {}),
1348
+ // Withheld at both provider and executor boundaries on every ordinary
1349
+ // turn. An admitted send owns the exact run-scoped authority above.
1350
+ ...(!opts?.goalRound ? { deniedTools: SESSION_GOAL_TOOL_NAMES } : {}),
905
1351
  taskStore,
906
1352
  ...(taskGateway ? { taskGateway } : {}),
907
1353
  // `gateFor`, not the bare default: the default's `rules` is a hardcoded
@@ -909,6 +1355,7 @@ async function* runTurn({ provider, fallbackProviders, model, tools, scope, work
909
1355
  // path that runs every top-level turn. The sub-agent path called
910
1356
  // `gateFor` and this one did not.
911
1357
  ...(sandboxProvider ? { sandboxProvider } : {}),
1358
+ ...(sandboxTeardownTimeoutMs !== undefined ? { sandboxTeardownTimeoutMs } : {}),
912
1359
  authorizationGate: gateFor(rules),
913
1360
  compactionConfig: COMPACTION_CONFIG,
914
1361
  // The CLI owns its process end to end, so it can safely hand the
@@ -917,6 +1364,7 @@ async function* runTurn({ provider, fallbackProviders, model, tools, scope, work
917
1364
  emergencySave: true,
918
1365
  runConfig: {
919
1366
  model,
1367
+ ...(opts?.effort !== undefined ? { effort: opts.effort } : {}),
920
1368
  timeoutMs: 600_000,
921
1369
  tokenBudget: 1_000_000,
922
1370
  maxIterations: 50,
@@ -936,6 +1384,7 @@ async function* runTurn({ provider, fallbackProviders, model, tools, scope, work
936
1384
  agentId: 'namzu',
937
1385
  agentName: 'namzu',
938
1386
  ...(systemPrompt ? { systemPrompt } : {}),
1387
+ projectInstructionContext,
939
1388
  messages: [...messages],
940
1389
  workingDirectory,
941
1390
  // The exemption reads `tools` at decision time, so it sees the task
@@ -945,34 +1394,58 @@ async function* runTurn({ provider, fallbackProviders, model, tools, scope, work
945
1394
  signal,
946
1395
  ...scope,
947
1396
  });
948
- for await (const event of events) {
949
- // Before the abort check and before `toAgentEvent`: a session
950
- // cancelled mid-turn still produced the events up to that point, and
951
- // they are the interesting ones. Every event, not just the ones the
952
- // TUI renders — an export that only saw what the screen showed would
953
- // be a recording of the interface rather than of the session.
954
- onRunEvent?.(event);
955
- if (signal?.aborted) {
956
- yield { kind: 'error', message: 'aborted' };
957
- return;
958
- }
959
- const mapped = toAgentEvent(event, presenter);
960
- if (!mapped)
961
- continue;
962
- // On an `Agent` delegation finishing, attach the sub-agent's tool
963
- // steps (collected via the gateway while the call blocked) as a
964
- // `├─/└─` tree under its result, then reset for the next delegation.
965
- // (Don't clear on tool_executing: the parent's events can be buffered
966
- // and pulled only after the child already ran, which would wipe it.)
967
- if (event.type === 'tool_completed' &&
968
- event.toolName === 'Agent' &&
969
- childSteps.length > 0 &&
970
- mapped.kind === 'tool-end') {
971
- yield { ...mapped, detail: [...(mapped.detail ?? []), ...asTree(childSteps)] };
972
- childSteps.length = 0;
973
- continue;
1397
+ let settled = false;
1398
+ try {
1399
+ while (true) {
1400
+ const next = await events.next();
1401
+ if (next.done) {
1402
+ settled = true;
1403
+ // A Run contains its fresh static/dynamic system floor as well as the
1404
+ // conversation. Only the latter crosses this host seam. Compaction
1405
+ // summaries survive because they ARE conversation state; arbitrary
1406
+ // system prompts are rebuilt fresh on every send and stay private to it.
1407
+ opts?.onConversationMessages?.(projectRunConversation(next.value.messages));
1408
+ return;
1409
+ }
1410
+ const event = next.value;
1411
+ // Before the abort check and before `toAgentEvent`: a session
1412
+ // cancelled mid-turn still produced the events up to that point, and
1413
+ // they are the interesting ones. Every event, not just the ones the
1414
+ // TUI renders — an export that only saw what the screen showed would
1415
+ // be a recording of the interface rather than of the session.
1416
+ onRunEvent?.(event);
1417
+ if (signal?.aborted) {
1418
+ yield { kind: 'error', message: 'aborted' };
1419
+ return;
1420
+ }
1421
+ const mapped = toAgentEvent(event, presenter);
1422
+ if (!mapped)
1423
+ continue;
1424
+ // On an `Agent` delegation finishing, attach the sub-agent's tool
1425
+ // steps (collected via the gateway while the call blocked) as a
1426
+ // `├─/└─` tree under its result, then reset for the next delegation.
1427
+ // (Don't clear on tool_executing: the parent's events can be buffered
1428
+ // and pulled only after the child already ran, which would wipe it.)
1429
+ if (event.type === 'tool_completed' &&
1430
+ event.toolName === 'Agent' &&
1431
+ childSteps.length > 0 &&
1432
+ mapped.kind === 'tool-end') {
1433
+ yield {
1434
+ ...mapped,
1435
+ detail: [...(mapped.detail ?? []), ...asTree(childSteps)],
1436
+ };
1437
+ childSteps.length = 0;
1438
+ continue;
1439
+ }
1440
+ yield mapped;
974
1441
  }
975
- yield mapped;
1442
+ }
1443
+ finally {
1444
+ // Manual iteration is what exposes the generator's Run return value.
1445
+ // Preserve `for await`'s other guarantee too: a consumer that stops
1446
+ // early must close the live query instead of abandoning its transport.
1447
+ if (!settled)
1448
+ await drainIterator(events);
976
1449
  }
977
1450
  }
978
1451
  catch (err) {
@@ -1080,7 +1553,7 @@ presenter = GENERIC_PRESENTER) {
1080
1553
  * survives the process, into the user's own repository, is not read-only under
1081
1554
  * any reading, and it now prompts.
1082
1555
  */
1083
- const PROMPT_EXEMPT_WRITES = new Set(['task_create', 'task_update']);
1556
+ const PROMPT_EXEMPT_WRITES = new Set(['task_create', 'task_update', 'update_goal']);
1084
1557
  /**
1085
1558
  * Whether a call runs without asking: it declares itself read-only, or it is a
1086
1559
  * named exemption above.
@@ -1190,6 +1663,30 @@ export function toAgentEvent(event, presenter) {
1190
1663
  };
1191
1664
  case 'provider_fallback':
1192
1665
  return { kind: 'provider-fallback', text: describeFallback(event) };
1666
+ case 'capability_warning':
1667
+ return {
1668
+ kind: 'capability-warning',
1669
+ capability: event.capability,
1670
+ text: event.message,
1671
+ };
1672
+ case 'message_history_repaired': {
1673
+ const changes = [
1674
+ event.duplicateToolResultsRemoved > 0
1675
+ ? `${event.duplicateToolResultsRemoved} duplicate result${event.duplicateToolResultsRemoved === 1 ? '' : 's'} removed`
1676
+ : null,
1677
+ event.orphanedToolResultsRemoved > 0
1678
+ ? `${event.orphanedToolResultsRemoved} orphaned result${event.orphanedToolResultsRemoved === 1 ? '' : 's'} removed`
1679
+ : null,
1680
+ event.syntheticToolResultsInserted > 0
1681
+ ? `${event.syntheticToolResultsInserted} interrupted call${event.syntheticToolResultsInserted === 1 ? '' : 's'} closed with unknown outcome`
1682
+ : null,
1683
+ ].filter((part) => part !== null);
1684
+ return {
1685
+ kind: 'history-repair',
1686
+ source: event.source,
1687
+ text: `Tool history repaired before the model call: ${changes.join('; ')}. Verify external state before retrying non-idempotent tools.`,
1688
+ };
1689
+ }
1193
1690
  case 'task_created':
1194
1691
  return { kind: 'task', subject: event.subject, status: event.status };
1195
1692
  case 'task_updated':
@@ -1204,7 +1701,10 @@ export function toAgentEvent(event, presenter) {
1204
1701
  // timeout, a cancellation and a blocked output guardrail arrive. A
1205
1702
  // consumer that reads this as success reports one for a run whose
1206
1703
  // answer was refused.
1207
- return { kind: 'done', ...(event.stopReason ? { stopReason: event.stopReason } : {}) };
1704
+ return {
1705
+ kind: 'done',
1706
+ ...(event.stopReason ? { stopReason: event.stopReason } : {}),
1707
+ };
1208
1708
  case 'run_failed':
1209
1709
  // The classification is now carried on the event rather than
1210
1710
  // having been flattened away upstream. Shown because "rate
@@ -1229,7 +1729,11 @@ export function toAgentEvent(event, presenter) {
1229
1729
  shed: true,
1230
1730
  };
1231
1731
  case 'compaction_failed':
1232
- return { kind: 'context', text: describeCompactionFailure(event), shed: false };
1732
+ return {
1733
+ kind: 'context',
1734
+ text: describeCompactionFailure(event),
1735
+ shed: false,
1736
+ };
1233
1737
  default:
1234
1738
  return null;
1235
1739
  }
@@ -1333,8 +1837,14 @@ function asTree(steps) {
1333
1837
  * the four deleted functions did for every tool.
1334
1838
  */
1335
1839
  const GENERIC_PRESENTER = {
1336
- presentCall: (_name, input) => ({ kind: 'generic', label: genericLabel(input) }),
1337
- presentResult: (_name, _input, result) => ({ kind: 'terminal', output: result.output ?? '' }),
1840
+ presentCall: (_name, input) => ({
1841
+ kind: 'generic',
1842
+ label: genericLabel(input),
1843
+ }),
1844
+ presentResult: (_name, _input, result) => ({
1845
+ kind: 'terminal',
1846
+ output: result.output ?? '',
1847
+ }),
1338
1848
  };
1339
1849
  /**
1340
1850
  * The one presentation function this host keeps.
@@ -1513,7 +2023,9 @@ function logCapabilities(probes) {
1513
2023
  const summary = probes
1514
2024
  .map((p) => `${p.specifier.split('/').pop()} ${p.state === 'present' ? 'yes' : 'no'}`)
1515
2025
  .join(' · ');
1516
- log.info(summary, { [EVENT_NAME_ATTRIBUTE]: BOOT_EVENT_NAMES.CAPABILITY_DETECTED });
2026
+ log.info(summary, {
2027
+ [EVENT_NAME_ATTRIBUTE]: BOOT_EVENT_NAMES.CAPABILITY_DETECTED,
2028
+ });
1517
2029
  for (const probe of probes) {
1518
2030
  if (probe.state === 'broken') {
1519
2031
  log.error('Capability probe failed to load', {
@@ -1545,6 +2057,15 @@ function emptySession(errorHint, errorKind = 'environment') {
1545
2057
  });
1546
2058
  return {
1547
2059
  hasProvider: false,
2060
+ // A refused session ran nothing, so there is nothing confining anything.
2061
+ // Reporting an enforced control here would be a claim about a sandbox
2062
+ // this path never built.
2063
+ sandbox: { unconfined: true, enforced: [], required: [] },
2064
+ // No provider, so no summary can be built. Refusing is the honest
2065
+ // answer; returning `null` would say "nothing to shed".
2066
+ compact: async () => {
2067
+ throw new Error('No provider: pick one with /model before compacting.');
2068
+ },
1548
2069
  errorKind,
1549
2070
  providerSummary: null,
1550
2071
  modelSummary: null,
@@ -1563,6 +2084,7 @@ function emptySession(errorHint, errorKind = 'environment') {
1563
2084
  errorHint,
1564
2085
  // No turn can run here, so no prompt can have been answered.
1565
2086
  approvalLatched: () => false,
2087
+ resetApprovalLatch: () => { },
1566
2088
  // No registry was built, so there is no roster to report on.
1567
2089
  promptExemptTools: () => [],
1568
2090
  send: async function* () {