@namzu/cli 12.1.0 → 14.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 (237) hide show
  1. package/README.md +35 -2
  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/login.d.ts +2 -0
  13. package/dist/commands/login.d.ts.map +1 -1
  14. package/dist/commands/login.js +79 -21
  15. package/dist/commands/login.js.map +1 -1
  16. package/dist/commands/prior-messages.d.ts +11 -0
  17. package/dist/commands/prior-messages.d.ts.map +1 -0
  18. package/dist/commands/prior-messages.js +280 -0
  19. package/dist/commands/prior-messages.js.map +1 -0
  20. package/dist/commands/resume.d.ts.map +1 -1
  21. package/dist/commands/resume.js +21 -19
  22. package/dist/commands/resume.js.map +1 -1
  23. package/dist/commands/run-stream.d.ts +5 -3
  24. package/dist/commands/run-stream.d.ts.map +1 -1
  25. package/dist/commands/run-stream.js +66 -62
  26. package/dist/commands/run-stream.js.map +1 -1
  27. package/dist/commands/run.d.ts.map +1 -1
  28. package/dist/commands/run.js +50 -37
  29. package/dist/commands/run.js.map +1 -1
  30. package/dist/config/debug.d.ts +55 -0
  31. package/dist/config/debug.d.ts.map +1 -0
  32. package/dist/config/debug.js +126 -0
  33. package/dist/config/debug.js.map +1 -0
  34. package/dist/config/load.d.ts +88 -4
  35. package/dist/config/load.d.ts.map +1 -1
  36. package/dist/config/load.js +386 -56
  37. package/dist/config/load.js.map +1 -1
  38. package/dist/config/schema.d.ts +87 -5
  39. package/dist/config/schema.d.ts.map +1 -1
  40. package/dist/config/schema.js +5 -5
  41. package/dist/config/schema.js.map +1 -1
  42. package/dist/config/trusted-project-context.d.ts +11 -0
  43. package/dist/config/trusted-project-context.d.ts.map +1 -0
  44. package/dist/config/trusted-project-context.js +28 -0
  45. package/dist/config/trusted-project-context.js.map +1 -0
  46. package/dist/context/project-path.d.ts +3 -0
  47. package/dist/context/project-path.d.ts.map +1 -0
  48. package/dist/context/project-path.js +16 -0
  49. package/dist/context/project-path.js.map +1 -0
  50. package/dist/context/project-tracker.d.ts +30 -0
  51. package/dist/context/project-tracker.d.ts.map +1 -0
  52. package/dist/context/project-tracker.js +217 -0
  53. package/dist/context/project-tracker.js.map +1 -0
  54. package/dist/context/sandbox.d.ts +29 -9
  55. package/dist/context/sandbox.d.ts.map +1 -1
  56. package/dist/context/sandbox.js +8 -0
  57. package/dist/context/sandbox.js.map +1 -1
  58. package/dist/doctor/checks/credentials.d.ts.map +1 -1
  59. package/dist/doctor/checks/credentials.js +7 -1
  60. package/dist/doctor/checks/credentials.js.map +1 -1
  61. package/dist/exit-codes.d.ts +5 -4
  62. package/dist/exit-codes.d.ts.map +1 -1
  63. package/dist/exit-codes.js +5 -4
  64. package/dist/exit-codes.js.map +1 -1
  65. package/dist/index.d.ts +2 -2
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +2 -2
  68. package/dist/index.js.map +1 -1
  69. package/dist/integrations/clipboard/text.d.ts +43 -0
  70. package/dist/integrations/clipboard/text.d.ts.map +1 -0
  71. package/dist/integrations/clipboard/text.js +43 -0
  72. package/dist/integrations/clipboard/text.js.map +1 -0
  73. package/dist/integrations/mcp/servers.d.ts +9 -0
  74. package/dist/integrations/mcp/servers.d.ts.map +1 -1
  75. package/dist/integrations/mcp/servers.js +11 -4
  76. package/dist/integrations/mcp/servers.js.map +1 -1
  77. package/dist/integrations/notifications/terminal.d.ts +32 -0
  78. package/dist/integrations/notifications/terminal.d.ts.map +1 -0
  79. package/dist/integrations/notifications/terminal.js +46 -0
  80. package/dist/integrations/notifications/terminal.js.map +1 -0
  81. package/dist/integrations/plugins/runtime.d.ts +17 -0
  82. package/dist/integrations/plugins/runtime.d.ts.map +1 -0
  83. package/dist/integrations/plugins/runtime.js +100 -0
  84. package/dist/integrations/plugins/runtime.js.map +1 -0
  85. package/dist/integrations/providers/codex-device-login.d.ts +27 -0
  86. package/dist/integrations/providers/codex-device-login.d.ts.map +1 -0
  87. package/dist/integrations/providers/codex-device-login.js +195 -0
  88. package/dist/integrations/providers/codex-device-login.js.map +1 -0
  89. package/dist/integrations/providers/codex-oauth.d.ts +10 -0
  90. package/dist/integrations/providers/codex-oauth.d.ts.map +1 -0
  91. package/dist/integrations/providers/codex-oauth.js +95 -0
  92. package/dist/integrations/providers/codex-oauth.js.map +1 -0
  93. package/dist/integrations/providers/credential-store.d.ts +40 -4
  94. package/dist/integrations/providers/credential-store.d.ts.map +1 -1
  95. package/dist/integrations/providers/credential-store.js +236 -18
  96. package/dist/integrations/providers/credential-store.js.map +1 -1
  97. package/dist/integrations/providers/discover.d.ts +25 -13
  98. package/dist/integrations/providers/discover.d.ts.map +1 -1
  99. package/dist/integrations/providers/discover.js +98 -44
  100. package/dist/integrations/providers/discover.js.map +1 -1
  101. package/dist/integrations/providers/harness-credentials.d.ts +36 -0
  102. package/dist/integrations/providers/harness-credentials.d.ts.map +1 -0
  103. package/dist/integrations/providers/harness-credentials.js +146 -0
  104. package/dist/integrations/providers/harness-credentials.js.map +1 -0
  105. package/dist/integrations/providers/identity.d.ts +8 -15
  106. package/dist/integrations/providers/identity.d.ts.map +1 -1
  107. package/dist/integrations/providers/identity.js +8 -15
  108. package/dist/integrations/providers/identity.js.map +1 -1
  109. package/dist/integrations/providers/index.d.ts +7 -4
  110. package/dist/integrations/providers/index.d.ts.map +1 -1
  111. package/dist/integrations/providers/index.js +6 -3
  112. package/dist/integrations/providers/index.js.map +1 -1
  113. package/dist/integrations/providers/keychain.d.ts +0 -13
  114. package/dist/integrations/providers/keychain.d.ts.map +1 -1
  115. package/dist/integrations/providers/keychain.js +0 -76
  116. package/dist/integrations/providers/keychain.js.map +1 -1
  117. package/dist/integrations/providers/oauth.d.ts +46 -17
  118. package/dist/integrations/providers/oauth.d.ts.map +1 -1
  119. package/dist/integrations/providers/oauth.js +175 -45
  120. package/dist/integrations/providers/oauth.js.map +1 -1
  121. package/dist/integrations/providers/register.d.ts.map +1 -1
  122. package/dist/integrations/providers/register.js +9 -1
  123. package/dist/integrations/providers/register.js.map +1 -1
  124. package/dist/integrations/providers/registry.d.ts +8 -1
  125. package/dist/integrations/providers/registry.d.ts.map +1 -1
  126. package/dist/integrations/providers/registry.js +29 -0
  127. package/dist/integrations/providers/registry.js.map +1 -1
  128. package/dist/integrations/providers/subscription-login.d.ts +2 -0
  129. package/dist/integrations/providers/subscription-login.d.ts.map +1 -1
  130. package/dist/integrations/providers/subscription-login.js +117 -14
  131. package/dist/integrations/providers/subscription-login.js.map +1 -1
  132. package/dist/integrations/sessions/store.d.ts +111 -1
  133. package/dist/integrations/sessions/store.d.ts.map +1 -1
  134. package/dist/integrations/sessions/store.js +400 -7
  135. package/dist/integrations/sessions/store.js.map +1 -1
  136. package/dist/integrations/sessions/transcript-export.d.ts +25 -0
  137. package/dist/integrations/sessions/transcript-export.d.ts.map +1 -0
  138. package/dist/integrations/sessions/transcript-export.js +441 -0
  139. package/dist/integrations/sessions/transcript-export.js.map +1 -0
  140. package/dist/integrations/sessions/turn-evidence.d.ts +138 -0
  141. package/dist/integrations/sessions/turn-evidence.d.ts.map +1 -0
  142. package/dist/integrations/sessions/turn-evidence.js +506 -0
  143. package/dist/integrations/sessions/turn-evidence.js.map +1 -0
  144. package/dist/integrations/subagents/runtime.d.ts +7 -12
  145. package/dist/integrations/subagents/runtime.d.ts.map +1 -1
  146. package/dist/integrations/subagents/runtime.js +19 -10
  147. package/dist/integrations/subagents/runtime.js.map +1 -1
  148. package/dist/integrations/trust/store.d.ts.map +1 -1
  149. package/dist/integrations/trust/store.js +27 -13
  150. package/dist/integrations/trust/store.js.map +1 -1
  151. package/dist/permissions/canonical-project.d.ts +9 -0
  152. package/dist/permissions/canonical-project.d.ts.map +1 -0
  153. package/dist/permissions/canonical-project.js +13 -0
  154. package/dist/permissions/canonical-project.js.map +1 -0
  155. package/dist/permissions/checks.d.ts +65 -0
  156. package/dist/permissions/checks.d.ts.map +1 -0
  157. package/dist/permissions/checks.js +122 -0
  158. package/dist/permissions/checks.js.map +1 -0
  159. package/dist/permissions/headless-trust.d.ts +9 -5
  160. package/dist/permissions/headless-trust.d.ts.map +1 -1
  161. package/dist/permissions/headless-trust.js +18 -4
  162. package/dist/permissions/headless-trust.js.map +1 -1
  163. package/dist/permissions/rules.d.ts +16 -7
  164. package/dist/permissions/rules.d.ts.map +1 -1
  165. package/dist/permissions/rules.js +78 -13
  166. package/dist/permissions/rules.js.map +1 -1
  167. package/dist/tui/App.d.ts +5 -2
  168. package/dist/tui/App.d.ts.map +1 -1
  169. package/dist/tui/App.js +1621 -196
  170. package/dist/tui/App.js.map +1 -1
  171. package/dist/tui/Composer.d.ts +14 -3
  172. package/dist/tui/Composer.d.ts.map +1 -1
  173. package/dist/tui/Composer.js +53 -15
  174. package/dist/tui/Composer.js.map +1 -1
  175. package/dist/tui/EditPromptPicker.d.ts +8 -0
  176. package/dist/tui/EditPromptPicker.d.ts.map +1 -0
  177. package/dist/tui/EditPromptPicker.js +18 -0
  178. package/dist/tui/EditPromptPicker.js.map +1 -0
  179. package/dist/tui/LiveActivity.d.ts.map +1 -1
  180. package/dist/tui/LiveActivity.js +2 -1
  181. package/dist/tui/LiveActivity.js.map +1 -1
  182. package/dist/tui/Markdown.d.ts.map +1 -1
  183. package/dist/tui/Markdown.js +2 -1
  184. package/dist/tui/Markdown.js.map +1 -1
  185. package/dist/tui/PermissionOverlay.d.ts.map +1 -1
  186. package/dist/tui/PermissionOverlay.js +7 -1
  187. package/dist/tui/PermissionOverlay.js.map +1 -1
  188. package/dist/tui/Picker.d.ts +8 -6
  189. package/dist/tui/Picker.d.ts.map +1 -1
  190. package/dist/tui/Picker.js +145 -18
  191. package/dist/tui/Picker.js.map +1 -1
  192. package/dist/tui/ResumePicker.d.ts.map +1 -1
  193. package/dist/tui/ResumePicker.js +1 -1
  194. package/dist/tui/ResumePicker.js.map +1 -1
  195. package/dist/tui/Transcript.d.ts +4 -2
  196. package/dist/tui/Transcript.d.ts.map +1 -1
  197. package/dist/tui/Transcript.js +31 -5
  198. package/dist/tui/Transcript.js.map +1 -1
  199. package/dist/tui/agent.d.ts +123 -19
  200. package/dist/tui/agent.d.ts.map +1 -1
  201. package/dist/tui/agent.js +757 -144
  202. package/dist/tui/agent.js.map +1 -1
  203. package/dist/tui/compact-transcript.d.ts +24 -0
  204. package/dist/tui/compact-transcript.d.ts.map +1 -0
  205. package/dist/tui/compact-transcript.js +46 -0
  206. package/dist/tui/compact-transcript.js.map +1 -0
  207. package/dist/tui/conversation-history.d.ts +33 -0
  208. package/dist/tui/conversation-history.d.ts.map +1 -0
  209. package/dist/tui/conversation-history.js +42 -0
  210. package/dist/tui/conversation-history.js.map +1 -0
  211. package/dist/tui/edit-prompts.d.ts +23 -0
  212. package/dist/tui/edit-prompts.d.ts.map +1 -0
  213. package/dist/tui/edit-prompts.js +35 -0
  214. package/dist/tui/edit-prompts.js.map +1 -0
  215. package/dist/tui/live-window.d.ts +3 -1
  216. package/dist/tui/live-window.d.ts.map +1 -1
  217. package/dist/tui/live-window.js +20 -8
  218. package/dist/tui/live-window.js.map +1 -1
  219. package/dist/tui/login-prompt.d.ts +5 -0
  220. package/dist/tui/login-prompt.d.ts.map +1 -1
  221. package/dist/tui/login-prompt.js +14 -0
  222. package/dist/tui/login-prompt.js.map +1 -1
  223. package/dist/tui/slashCommands.d.ts +178 -9
  224. package/dist/tui/slashCommands.d.ts.map +1 -1
  225. package/dist/tui/slashCommands.js +340 -16
  226. package/dist/tui/slashCommands.js.map +1 -1
  227. package/dist/tui/terminal-display.d.ts +12 -0
  228. package/dist/tui/terminal-display.d.ts.map +1 -0
  229. package/dist/tui/terminal-display.js +65 -0
  230. package/dist/tui/terminal-display.js.map +1 -0
  231. package/dist/tui/types.d.ts +14 -2
  232. package/dist/tui/types.d.ts.map +1 -1
  233. package/dist/tui/workspace-diff.d.ts +40 -0
  234. package/dist/tui/workspace-diff.d.ts.map +1 -0
  235. package/dist/tui/workspace-diff.js +101 -0
  236. package/dist/tui/workspace-diff.js.map +1 -0
  237. package/package.json +8 -8
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, ensureFreshStoredCodexCredential, ensureRegistered, findDetected, isAnthropicOAuthToken, isRegistered, missingCredentialMessage, primaryProvider, readCodexCredentialFile, 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,70 @@ 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.
225
- const subscriptionRefresh = primary.id === 'anthropic' && Boolean(det?.oauth);
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.
439
+ const subscriptionRefresh = primary.id === 'anthropic' && det?.oauth?.origin === 'stored';
440
+ const borrowedAnthropic = primary.id === 'anthropic' &&
441
+ (det?.oauth?.origin === 'claude-file' || det?.oauth?.origin === 'keychain');
442
+ const borrowedCodexPath = primary.id === 'codex' && det?.source.kind === 'codex-file' ? det.source.path : undefined;
443
+ const storedCodex = primary.id === 'codex' && det?.codex?.origin === 'stored';
226
444
  const credentialOrigin = det?.oauth?.origin ?? 'keychain';
227
445
  let currentToken = det?.apiKey;
228
- const refreshTokenIfNeeded = async () => {
229
- if (!subscriptionRefresh)
230
- return;
446
+ let currentCodexAccount = det?.codex?.accountId;
447
+ let credentialTail = Promise.resolve();
448
+ let rejectedRefresh;
449
+ const performRefresh = async (signal) => {
450
+ signal?.throwIfAborted();
451
+ // Read only after this owner reaches the head. A sibling may have rotated
452
+ // the durable credential while we waited; reading before the queue would
453
+ // make its success invisible and permit a stale-token downgrade.
231
454
  const cred = readSubscriptionCredential(credentialOrigin);
455
+ // This store is the authority for the whole session. Its absence is a
456
+ // credential withdrawal, not permission to keep using the client object
457
+ // that happens to remain in memory after logout or another process's clear.
232
458
  if (!cred)
233
- return;
234
- const fresh = await ensureFreshAnthropicToken(cred.accessToken, {
235
- refreshToken: cred.refreshToken,
236
- expiresAt: cred.expiresAt,
237
- origin: credentialOrigin,
238
- });
459
+ throw new CredentialWithdrawnError();
460
+ if (rejectedRefresh && sameOAuthCredential(rejectedRefresh.credential, cred)) {
461
+ throw rejectedRefresh.error;
462
+ }
463
+ // A different non-null credential is an external winner (usually a fresh
464
+ // login). The old grant's refusal says nothing about this one.
465
+ rejectedRefresh = undefined;
466
+ let fresh;
467
+ try {
468
+ fresh = await ensureFreshAnthropicToken(cred.accessToken, {
469
+ refreshToken: cred.refreshToken,
470
+ expiresAt: cred.expiresAt,
471
+ scopes: cred.scopes,
472
+ origin: credentialOrigin,
473
+ }, signal);
474
+ }
475
+ catch (error) {
476
+ if (error instanceof CredentialRefreshRejectedError) {
477
+ // Cache only if the same credential is still authoritative after the
478
+ // network wait. A concurrent login must not inherit the old grant's
479
+ // permanent classification.
480
+ signal?.throwIfAborted();
481
+ const current = readSubscriptionCredential(credentialOrigin);
482
+ if (!current)
483
+ throw new CredentialWithdrawnError();
484
+ if (sameOAuthCredential(current, cred)) {
485
+ rejectedRefresh = { credential: cred, error };
486
+ }
487
+ }
488
+ throw error;
489
+ }
490
+ signal?.throwIfAborted();
239
491
  if (fresh === currentToken)
240
492
  return;
241
- currentToken = fresh;
242
493
  try {
243
- provider = constructProvider('anthropic', { ...det, apiKey: fresh }, model);
494
+ const refreshedProvider = constructProvider('anthropic', { ...det, apiKey: fresh }, model);
495
+ // Publish the pair together. If construction fails, both old values
496
+ // remain live and the next operation can retry against the stored token.
497
+ provider = refreshedProvider;
498
+ currentToken = fresh;
244
499
  }
245
500
  catch (err) {
246
501
  // Keep the previous client; the turn may still 401 but won't crash.
@@ -251,16 +506,93 @@ export async function createAgentSession(prefs, detected, options = {}) {
251
506
  cliLogger().warn('provider client rebuild after token refresh failed', exceptionAttributes(err));
252
507
  }
253
508
  };
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);
509
+ const rereadBorrowedCodex = (signal) => {
510
+ signal?.throwIfAborted();
511
+ if (!borrowedCodexPath)
512
+ return;
513
+ const credential = readCodexCredentialFile(borrowedCodexPath);
514
+ if (!credential) {
515
+ throw new CredentialWithdrawnError('The Codex session Namzu borrowed is no longer available. Run `codex login` or choose another provider.');
516
+ }
517
+ if (credential.expiresAt !== undefined && credential.expiresAt - Date.now() <= 60_000) {
518
+ throw new CredentialWithdrawnError('The Codex session on this device has expired. Run `codex login` to let its owner refresh the session, then retry in Namzu.');
519
+ }
520
+ if (credential.accessToken === currentToken && credential.accountId === currentCodexAccount) {
521
+ return;
522
+ }
523
+ const refreshedProvider = constructProvider('codex', {
524
+ ...det,
525
+ apiKey: credential.accessToken,
526
+ codex: {
527
+ accountId: credential.accountId,
528
+ expiresAt: credential.expiresAt,
529
+ origin: 'codex-file',
530
+ },
531
+ }, model);
532
+ provider = refreshedProvider;
533
+ currentToken = credential.accessToken;
534
+ currentCodexAccount = credential.accountId;
535
+ };
536
+ const rereadBorrowedAnthropic = (signal) => {
537
+ signal?.throwIfAborted();
538
+ if (!borrowedAnthropic)
539
+ return;
540
+ const credential = readSubscriptionCredential(credentialOrigin);
541
+ if (!credential) {
542
+ throw new CredentialWithdrawnError('The Claude session Namzu borrowed is no longer available. Run `claude login` or choose another provider.');
543
+ }
544
+ if (credential.expiresAt !== undefined && credential.expiresAt - Date.now() <= 60_000) {
545
+ throw new CredentialWithdrawnError('The Claude session on this device has expired. Run `claude login` to let its owner refresh the session, then retry in Namzu.');
546
+ }
547
+ if (credential.accessToken === currentToken)
548
+ return;
549
+ provider = constructProvider('anthropic', { ...det, apiKey: credential.accessToken }, model);
550
+ currentToken = credential.accessToken;
551
+ };
552
+ const refreshStoredCodex = async (signal) => {
553
+ const credential = await ensureFreshStoredCodexCredential(signal);
554
+ if (credential.accessToken === currentToken && credential.accountId === currentCodexAccount) {
555
+ return;
556
+ }
557
+ const refreshedProvider = constructProvider('codex', {
558
+ ...det,
559
+ apiKey: credential.accessToken,
560
+ codex: {
561
+ accountId: credential.accountId,
562
+ expiresAt: credential.expiresAt,
563
+ origin: 'stored',
564
+ },
565
+ }, model);
566
+ provider = refreshedProvider;
567
+ currentToken = credential.accessToken;
568
+ currentCodexAccount = credential.accountId;
569
+ };
570
+ const prepareProviderCredential = (signal) => {
571
+ if (!subscriptionRefresh && !borrowedAnthropic && !borrowedCodexPath && !storedCodex) {
572
+ signal?.throwIfAborted();
573
+ return Promise.resolve();
574
+ }
575
+ signal?.throwIfAborted();
576
+ const queued = credentialTail.then(async () => {
577
+ if (subscriptionRefresh)
578
+ await performRefresh(signal);
579
+ else if (borrowedAnthropic)
580
+ rereadBorrowedAnthropic(signal);
581
+ else if (storedCodex)
582
+ await refreshStoredCodex(signal);
583
+ else
584
+ rereadBorrowedCodex(signal);
585
+ });
586
+ // Keep later owners behind this slot even if its caller stops observing it.
587
+ // The catch makes the private tail non-rejecting without changing the
588
+ // exact outcome returned to the owner below.
589
+ credentialTail = queued.catch(() => { });
590
+ return signal ? observeWithSignal(queued, signal) : queued;
591
+ };
592
+ // Session-owned discovery with one drain cursor per run. A child shares the
593
+ // discovered scopes without being able to consume the parent's update, and
594
+ // an edit takes effect in this session rather than only after reconnecting.
595
+ const projectInstructions = new ProjectInstructionTracker(cwd);
264
596
  // Before the registry, because a `requireIsolation` this machine cannot
265
597
  // meet throws here — and failing before the session is built is the
266
598
  // difference between "namzu refused to start" and a half-constructed
@@ -302,17 +634,23 @@ export async function createAgentSession(prefs, detected, options = {}) {
302
634
  'namzu.sandbox.unconfined': sandbox.unconfined,
303
635
  });
304
636
  const { registry, memoryStore } = buildToolRegistry(cwd);
637
+ // Registered only on the main session path. Sub-agents call
638
+ // `buildToolRegistry` directly below, so they never receive these tools.
639
+ // Per-send denial further keeps the schemas out of ordinary human turns.
640
+ const goalAuthorities = new Map();
641
+ const goalToolNames = new Set(SESSION_GOAL_TOOL_NAMES);
642
+ if (options.sessionGoals) {
643
+ registry.register(buildSessionGoalTools(options.sessionGoals, (runId) => goalAuthorities.get(runId)));
644
+ }
305
645
  // External tool servers, before the roster is counted, so `toolNames` and
306
646
  // the `/tools` list a user reads include what they configured. Connecting
307
647
  // after the count would report a session smaller than the one that runs.
308
648
  const mcp = await connectMcpServers(options.mcpServers, { cwd });
309
649
  if (mcp.tools.length > 0)
310
650
  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.
651
+ // External connector discovery is reported separately from executable
652
+ // plugin discovery. Folding both counts together would make a failed server
653
+ // indistinguishable from a plugin that never enabled.
316
654
  cliLogger().info('discovery complete', {
317
655
  [EVENT_NAME_ATTRIBUTE]: BOOT_EVENT_NAMES.DISCOVERY_COMPLETED,
318
656
  'namzu.discovery.kind': 'connector',
@@ -362,12 +700,16 @@ export async function createAgentSession(prefs, detected, options = {}) {
362
700
  const sub = await createSubagentRuntime({
363
701
  cwd,
364
702
  model,
703
+ ...(sandbox.provider ? { sandboxProvider: sandbox.provider } : {}),
704
+ ...(options.sandbox?.teardownTimeoutMs !== undefined
705
+ ? { sandboxTeardownTimeoutMs: options.sandbox.teardownTimeoutMs }
706
+ : {}),
365
707
  // A sub-agent works in the same repository and writes the same code,
366
708
  // so it is bound by the same instructions. Without this the parent
367
709
  // honours the project's rules and every task it delegates quietly
368
710
  // does not — the worse half of the feature, because the delegating
369
711
  // turn reports success either way.
370
- ...(projectInstructions.prompt ? { projectInstructions: projectInstructions.prompt } : {}),
712
+ projectInstructionContext: () => projectInstructions.createRunContext(),
371
713
  // Same argument as the instructions, one step further: a sub-agent that
372
714
  // does not know what day it is dates a changelog entry from a training
373
715
  // cut-off, and the parent reports the delegation as successful.
@@ -447,6 +789,27 @@ export async function createAgentSession(prefs, detected, options = {}) {
447
789
  // settle — is what this repository has been doing all along by accident.
448
790
  // A run that learned nothing still writes nothing.
449
791
  const promoteMemory = createMemoryPromoter({ store: memoryStore });
792
+ // Plugins are the last fallible startup resource. The ordering is ownership:
793
+ // a malformed MCP entry cannot strand imported plugin hooks, and a plugin
794
+ // refusal closes the MCP processes already opened for this candidate before
795
+ // returning an inert session. Sub-agents were built above from their own
796
+ // registries, so executable plugins remain a top-level-session capability.
797
+ let pluginRuntime;
798
+ try {
799
+ pluginRuntime = await createCliPluginRuntime(options.plugins, registry, cwd);
800
+ }
801
+ catch (error) {
802
+ await mcp.close();
803
+ return emptySession(describeError(error));
804
+ }
805
+ if (pluginRuntime) {
806
+ cliLogger().info('discovery complete', {
807
+ [EVENT_NAME_ATTRIBUTE]: BOOT_EVENT_NAMES.DISCOVERY_COMPLETED,
808
+ 'namzu.discovery.kind': 'plugin',
809
+ 'namzu.discovery.count': pluginRuntime.pluginCount,
810
+ 'namzu.discovery.skill_count': pluginRuntime.skills.size,
811
+ });
812
+ }
450
813
  // The one terminal POSITIVE event on this path, emitted exactly once —
451
814
  // every early return above goes through `emptySession`, which emits
452
815
  // `namzu.boot.refused` instead, and the `resolveSandbox` throw path above
@@ -457,109 +820,208 @@ export async function createAgentSession(prefs, detected, options = {}) {
457
820
  cliLogger().info('agent session ready', {
458
821
  [EVENT_NAME_ATTRIBUTE]: BOOT_EVENT_NAMES.BOOT_READY,
459
822
  });
823
+ const operations = new SessionOperationOwner(async () => {
824
+ const results = await Promise.allSettled([pluginRuntime?.close(), mcp.close()]);
825
+ const failures = results
826
+ .filter((result) => result.status === 'rejected')
827
+ .map((result) => result.reason);
828
+ if (failures.length > 0)
829
+ throw new AggregateError(failures, 'Session cleanup failed.');
830
+ });
831
+ let reasoningEffortLevels;
832
+ let effortNotice;
833
+ try {
834
+ const capabilityView = withProviderFallback([
835
+ { provider, model },
836
+ ...fallbackPlan.build(currentToken),
837
+ ]);
838
+ const offered = capabilityView.reasoningEffortLevelsFor
839
+ ? capabilityView.reasoningEffortLevelsFor(model)
840
+ : capabilityView.effortLevelsFor?.(model);
841
+ reasoningEffortLevels = offered === undefined ? undefined : Object.freeze([...offered]);
842
+ }
843
+ catch (error) {
844
+ effortNotice = `Reasoning effort levels could not be established for this session: ${describeError(error)}.`;
845
+ }
460
846
  return {
461
847
  hasProvider: true,
848
+ sandbox: {
849
+ unconfined: sandbox.unconfined,
850
+ ...(sandbox.environment ? { environment: sandbox.environment } : {}),
851
+ enforced: sandbox.enforced,
852
+ required: sandbox.required,
853
+ },
462
854
  providerSummary: entry.label,
463
855
  modelSummary: model,
856
+ reasoningEffortLevels,
857
+ compact: (messages) => operations.promise(undefined, async (signal) => {
858
+ await prepareProviderCredential(signal);
859
+ return compactNow({
860
+ messages,
861
+ config: COMPACTION_CONFIG,
862
+ provider,
863
+ model,
864
+ signal,
865
+ });
866
+ }),
464
867
  // Reads the same registry object the deferred registration mutates, at
465
868
  // call time — the pair of `promptExemptTools` below, and for the same
466
869
  // reason.
467
- toolNames: () => registry.getCallableTools().map((t) => t.name),
870
+ toolNames: () => registry
871
+ .getCallableTools()
872
+ .map((t) => t.name)
873
+ .filter((name) => !goalToolNames.has(name)),
468
874
  agentIds: allowedAgentIds,
469
- instructionFiles: projectInstructions.files.map((f) => f.path),
470
- skippedInstructionFiles: projectInstructions.skipped,
875
+ get instructionFiles() {
876
+ return projectInstructions.instructionFiles;
877
+ },
878
+ get skippedInstructionFiles() {
879
+ return projectInstructions.skippedInstructionFiles;
880
+ },
471
881
  mcpConnected: mcp.connected,
472
882
  mcpFailed: mcp.failed,
473
883
  configNotices: [
474
884
  ...(capabilityNotice ? [capabilityNotice] : []),
885
+ ...(effortNotice ? [effortNotice] : []),
475
886
  ...unresolvedNotice.map((line) => `Provider chain: capabilities could not be established for ${line}.`),
476
887
  ...fallbackPlan.notices,
477
888
  ],
478
- close: () => mcp.close(),
889
+ close: () => operations.close(),
479
890
  errorHint: null,
480
891
  errorKind: null,
481
892
  // Reads the same object the handler mutates, at call time.
482
893
  approvalLatched: () => approval.all,
483
- promptExemptTools: () => promptExemptToolNames(registry),
484
- send: async function* (messages, opts) {
894
+ resetApprovalLatch: () => {
895
+ approval.all = false;
896
+ },
897
+ promptExemptTools: () => promptExemptToolNames(registry).filter((name) => !goalToolNames.has(name)),
898
+ send: (messages, opts) => operations.stream(opts?.signal, (signal) => (async function* () {
485
899
  // Renew a lapsed OAuth token before the turn runs (no-op for valid
486
- // tokens and non-keychain credentials).
487
- await refreshTokenIfNeeded();
900
+ // tokens and non-subscription credentials).
901
+ await prepareProviderCredential(signal);
488
902
  // namzu identity first (so it establishes who the agent is even when
489
903
  // 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).
904
+ // then memory and per-turn extra context. Project instructions are a
905
+ // separate retained user-context snapshot prepared by the controller;
906
+ // that is what lets nested scopes be replaced and persisted safely.
492
907
  //
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
908
+ // The environment block is read fresh every turn because both facts in it
909
+ // can change WHILE the session
500
910
  // runs — midnight passes, and the agent checks out a branch itself.
501
911
  // Its text only changes when a fact changes, so it costs a prompt-cache
502
912
  // miss exactly when a hit would have been a stale claim.
913
+ const pluginSkills = pluginRuntime
914
+ ? await currentPluginSkills(pluginRuntime.skills)
915
+ : undefined;
503
916
  const memoryPrompt = composeMemoryPrompt(readMemory());
504
917
  const environmentPrompt = composeEnvironmentPrompt(await readEnvironmentFacts(cwd));
505
- const systemPrompt = [
506
- NAMZU_IDENTITY,
507
- environmentPrompt,
508
- memoryPrompt,
509
- projectInstructions.prompt,
510
- opts?.extraSystem,
511
- ]
918
+ const systemPrompt = [NAMZU_IDENTITY, environmentPrompt, memoryPrompt, opts?.extraSystem]
512
919
  .filter((s) => Boolean(s))
513
920
  .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 }) => {
921
+ let capturedAuthority;
922
+ if (opts?.goalRound) {
923
+ if (!opts.runId)
924
+ throw new Error('A goal round requires a caller-reserved runId.');
925
+ if (!options.sessionGoals)
926
+ throw new Error('This session has no durable goal store.');
927
+ if (opts.goalRound.sessionId !== scope.sessionId ||
928
+ opts.goalRound.tenantId !== scope.tenantId) {
929
+ throw new Error('Goal-round authority does not belong to this agent session scope.');
930
+ }
931
+ const current = await options.sessionGoals.getGoal(scope.sessionId, scope.tenantId);
932
+ if (!current ||
933
+ current.phase !== 'active' ||
934
+ current.id !== opts.goalRound.id ||
935
+ current.revision !== opts.goalRound.revision ||
936
+ current.objective !== opts.goalRound.objective ||
937
+ current.roundsAdmitted !== opts.goalRound.round ||
938
+ current.maxGoalRounds !== opts.goalRound.maxGoalRounds) {
939
+ throw new Error('Goal-round authority is stale or does not match the durable goal.');
940
+ }
941
+ if (goalAuthorities.has(opts.runId)) {
942
+ throw new Error(`Run ${opts.runId} already owns goal-round authority.`);
943
+ }
944
+ capturedAuthority = Object.freeze({ ...opts.goalRound });
945
+ goalAuthorities.set(opts.runId, capturedAuthority);
946
+ }
947
+ try {
948
+ yield* runTurn({
949
+ provider,
950
+ // Constructed HERE, per turn, and that is not an optimisation to
951
+ // undo. `refreshTokenIfNeeded` above replaces the head's client
952
+ // object when an OAuth token rotates, so a member list built once at
953
+ // session creation would hand the kernel a client holding a token
954
+ // that expired hours ago — and a chain whose own members are stale
955
+ // is a fallback that fails for the reason the fallback exists to
956
+ // survive. Building a driver is a client object, not a request.
957
+ fallbackProviders: fallbackPlan.build(currentToken),
958
+ model,
959
+ tools: registry,
960
+ pluginManager: pluginRuntime?.manager,
961
+ skillRegistry: pluginRuntime?.skills,
962
+ skills: pluginSkills,
963
+ scope,
964
+ workingDirectory: cwd,
965
+ rules: options.rules,
966
+ permissionMode: opts?.permissionMode ?? options.permissionMode,
967
+ reviewAnswer: options.reviewAnswer,
968
+ maxAnswerReviews: options.maxAnswerReviews,
969
+ promoteMemory,
970
+ approval,
971
+ taskStore,
972
+ systemPrompt,
973
+ messages,
974
+ projectInstructionContext: projectInstructions.createRunContext(),
975
+ opts: { ...opts, signal },
976
+ taskGateway: subagentGateway,
977
+ onRunEvent: options.onRunEvent,
978
+ childSteps,
979
+ ...(sandbox.provider ? { sandboxProvider: sandbox.provider } : {}),
980
+ ...(options.sandbox?.teardownTimeoutMs !== undefined
981
+ ? {
982
+ sandboxTeardownTimeoutMs: options.sandbox.teardownTimeoutMs,
983
+ }
984
+ : {}),
985
+ });
986
+ }
987
+ finally {
988
+ if (opts?.runId &&
989
+ capturedAuthority &&
990
+ goalAuthorities.get(opts.runId) === capturedAuthority) {
991
+ goalAuthorities.delete(opts.runId);
992
+ }
993
+ }
994
+ })()),
995
+ resumeDurable: ({ entry, checkpointStore, claimFence, signal }) => operations.promise(signal, async (ownedSignal) => {
545
996
  // The same prelude a turn runs, and for the same reasons: a lapsed
546
997
  // OAuth token has to be renewed before the provider is used, and the
547
998
  // fallback chain has to be built AFTER that so its members do not
548
999
  // hold a client the refresh just replaced.
549
- await refreshTokenIfNeeded();
1000
+ await prepareProviderCredential(ownedSignal);
1001
+ const pluginSkills = pluginRuntime
1002
+ ? await currentPluginSkills(pluginRuntime.skills)
1003
+ : undefined;
550
1004
  const memoryPrompt = composeMemoryPrompt(readMemory());
551
1005
  const environmentPrompt = composeEnvironmentPrompt(await readEnvironmentFacts(cwd));
552
- const systemPrompt = [NAMZU_IDENTITY, environmentPrompt, memoryPrompt, projectInstructions.prompt]
1006
+ const systemPrompt = [NAMZU_IDENTITY, environmentPrompt, memoryPrompt]
553
1007
  .filter((s) => Boolean(s))
554
1008
  .join('\n\n') || undefined;
555
1009
  return resumeRun({
556
1010
  provider,
557
1011
  fallbackProviders: fallbackPlan.build(currentToken),
558
1012
  tools: registry,
1013
+ pluginManager: pluginRuntime?.manager,
1014
+ skillRegistry: pluginRuntime?.skills,
1015
+ skills: pluginSkills,
559
1016
  taskStore,
560
1017
  ...(subagentGateway ? { taskGateway: subagentGateway } : {}),
561
1018
  authorizationGate: gateFor(options.rules),
562
1019
  compactionConfig: COMPACTION_CONFIG,
1020
+ projectInstructionContext: projectInstructions.createRunContext(),
1021
+ ...(sandbox.provider ? { sandboxProvider: sandbox.provider } : {}),
1022
+ ...(options.sandbox?.teardownTimeoutMs !== undefined
1023
+ ? { sandboxTeardownTimeoutMs: options.sandbox.teardownTimeoutMs }
1024
+ : {}),
563
1025
  // NOT `emergencySave`, unlike a turn. The manager is a singleton
564
1026
  // whose `attach` detaches whoever held it before, so a caller
565
1027
  // resuming several runs in one process would leave only the last
@@ -585,7 +1047,7 @@ export async function createAgentSession(prefs, detected, options = {}) {
585
1047
  // forced presentation to be name matching: `toAgentEvent` was pure
586
1048
  // over a `RunEvent` and could not ask a tool anything.
587
1049
  resumeHandler: makeResumeHandler(approval, undefined, options.permissionMode, (n, i) => isPromptExempt(registry, n, i)),
588
- ...(signal ? { signal } : {}),
1050
+ signal: ownedSignal,
589
1051
  // Attribution comes from the ENTRY, not from this session: the run
590
1052
  // belongs to whoever started it, and stamping the drainer's ids onto
591
1053
  // it would file another tenant's work under this one.
@@ -600,7 +1062,7 @@ export async function createAgentSession(prefs, detected, options = {}) {
600
1062
  checkpointStore,
601
1063
  ...(claimFence !== undefined ? { claimFence } : {}),
602
1064
  });
603
- },
1065
+ }),
604
1066
  };
605
1067
  }
606
1068
  function planFallbacks(members, detected) {
@@ -682,6 +1144,19 @@ export function constructProvider(id, det, model) {
682
1144
  });
683
1145
  return provider;
684
1146
  }
1147
+ case 'codex': {
1148
+ if (!det?.codex?.accountId) {
1149
+ throw new Error('Codex subscription credentials require ChatGPT account routing. Sign in with Codex again or choose the OpenAI API-key provider.');
1150
+ }
1151
+ const { provider } = ProviderRegistry.create({
1152
+ type: 'codex',
1153
+ accessToken: det.apiKey ?? '',
1154
+ accountId: det.codex.accountId,
1155
+ baseURL: det.baseUrl,
1156
+ model,
1157
+ });
1158
+ return provider;
1159
+ }
685
1160
  case 'deepseek': {
686
1161
  const { provider } = ProviderRegistry.create({
687
1162
  type: 'deepseek',
@@ -711,50 +1186,101 @@ export function constructProvider(id, det, model) {
711
1186
  throw new Error(unsupportedProviderMessage(id));
712
1187
  }
713
1188
  }
1189
+ const PICKER_PROVIDER_DEADLINE_MS = 3_000;
1190
+ class PickerProviderTimeoutError extends Error {
1191
+ constructor() {
1192
+ super(`The provider did not answer within ${PICKER_PROVIDER_DEADLINE_MS}ms.`);
1193
+ this.name = 'PickerProviderTimeoutError';
1194
+ }
1195
+ }
1196
+ /** Bound a picker side-call even when a third-party provider ignores abort. */
1197
+ async function runPickerProviderOperation(signal, operation) {
1198
+ signal?.throwIfAborted();
1199
+ const controller = new AbortController();
1200
+ const timeoutCause = new PickerProviderTimeoutError();
1201
+ let rejectBoundary = () => { };
1202
+ const boundary = new Promise((_resolve, reject) => {
1203
+ rejectBoundary = reject;
1204
+ });
1205
+ const onCallerAbort = () => {
1206
+ controller.abort(signal?.reason);
1207
+ rejectBoundary(signal?.reason);
1208
+ };
1209
+ signal?.addEventListener('abort', onCallerAbort, { once: true });
1210
+ const timer = setTimeout(() => {
1211
+ controller.abort(timeoutCause);
1212
+ rejectBoundary(timeoutCause);
1213
+ }, PICKER_PROVIDER_DEADLINE_MS);
1214
+ try {
1215
+ return await Promise.race([operation(controller.signal), boundary]);
1216
+ }
1217
+ catch (error) {
1218
+ // Cooperative transports may replace the owner cause with AbortError.
1219
+ if (signal?.aborted)
1220
+ throw signal.reason;
1221
+ if (controller.signal.aborted && controller.signal.reason === timeoutCause)
1222
+ throw timeoutCause;
1223
+ throw error;
1224
+ }
1225
+ finally {
1226
+ clearTimeout(timer);
1227
+ signal?.removeEventListener('abort', onCallerAbort);
1228
+ }
1229
+ }
714
1230
  /**
715
1231
  * Ask a detected provider what models it has.
716
1232
  *
717
1233
  * Instantiates the provider and calls its optional `listModels()`, inside a 3s
718
1234
  * race so a wedged local server or a slow catalog cannot stall the UI.
719
1235
  */
720
- export async function describeProviderModels(id, det) {
1236
+ export async function describeProviderModels(id, det, signal) {
721
1237
  try {
1238
+ signal?.throwIfAborted();
722
1239
  // constructProvider calls ProviderRegistry.create, which throws
723
1240
  // "Unsupported provider type" until the vendor package has registered
724
1241
  // itself. The run path registers lazily via ensureRegistered; the
725
1242
  // listing path must do the same or every provider returns nothing.
726
1243
  await ensureRegistered(id);
1244
+ signal?.throwIfAborted();
727
1245
  const provider = constructProvider(id, det, det.entry.defaultModel);
728
1246
  if (typeof provider.listModels !== 'function')
729
1247
  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 })) };
1248
+ const models = await runPickerProviderOperation(signal, (operationSignal) => provider.listModels?.(operationSignal) ?? Promise.resolve([]));
1249
+ return {
1250
+ kind: 'ok',
1251
+ models: models.map((m) => ({ id: m.id, name: m.name || m.id })),
1252
+ };
736
1253
  }
737
1254
  catch (err) {
738
- return { kind: 'failed', reason: err instanceof Error ? err.message : String(err) };
1255
+ if (signal?.aborted)
1256
+ throw signal.reason;
1257
+ if (err instanceof PickerProviderTimeoutError)
1258
+ return { kind: 'timeout' };
1259
+ return {
1260
+ kind: 'failed',
1261
+ reason: err instanceof Error ? err.message : String(err),
1262
+ };
739
1263
  }
740
1264
  }
741
1265
  /**
742
1266
  * Check a key the operator just typed, without spending a turn.
743
1267
  *
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
1268
+ * Uses the provider's declared `probeCredential` operation. A driver without
746
1269
  * one cannot be checked cheaply, and that is reported as `unverifiable` rather
747
1270
  * than dressed up as success — claiming a check that did not happen is the
748
- * failure this whole surface is built to avoid.
1271
+ * failure this whole surface is built to avoid. A model catalogue is
1272
+ * deliberately not substituted: a menu is not evidence that a key worked.
749
1273
  *
750
1274
  * The key never appears in the returned reason. Provider errors are passed
751
1275
  * through, and a driver that echoes a credential into its own error message
752
1276
  * would defeat this; that is a driver bug and not one this can paper over, so
753
1277
  * the reason is also truncated.
754
1278
  */
755
- export async function verifyCredential(id, det) {
1279
+ export async function verifyCredential(id, det, signal) {
756
1280
  try {
1281
+ signal?.throwIfAborted();
757
1282
  await ensureRegistered(id);
1283
+ signal?.throwIfAborted();
758
1284
  const provider = constructProvider(id, det, det.entry.defaultModel);
759
1285
  // Declared, never inferred. A driver without a probe is unverifiable —
760
1286
  // including one added years from now by someone who never reads this.
@@ -764,10 +1290,14 @@ export async function verifyCredential(id, det) {
764
1290
  // not authenticate at all.
765
1291
  if (typeof provider.probeCredential !== 'function')
766
1292
  return { kind: 'unverifiable' };
767
- await provider.probeCredential();
1293
+ await runPickerProviderOperation(signal, (operationSignal) => provider.probeCredential?.(operationSignal) ?? Promise.resolve());
768
1294
  return { kind: 'verified' };
769
1295
  }
770
1296
  catch (err) {
1297
+ if (signal?.aborted)
1298
+ throw signal.reason;
1299
+ if (err instanceof PickerProviderTimeoutError)
1300
+ return { kind: 'unverifiable' };
771
1301
  // The server answered and said no, versus nothing was learned. Collapsing
772
1302
  // these would tell an operator on broken wifi to rotate a key that is fine.
773
1303
  if (isCredentialRejection(err)) {
@@ -887,7 +1417,7 @@ const COMPACTION_CONFIG = {
887
1417
  maxCharsPerRequirement: 300,
888
1418
  maxCharsPerTask: 400,
889
1419
  };
890
- async function* runTurn({ provider, fallbackProviders, model, tools, scope, workingDirectory, rules, permissionMode, reviewAnswer, maxAnswerReviews, promoteMemory, approval, taskStore, systemPrompt, messages, opts, taskGateway, childSteps, sandboxProvider, onRunEvent, }) {
1420
+ 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
1421
  const signal = opts?.signal;
892
1422
  // One presenter for the whole stream, built from the registry this scope
893
1423
  // already holds. Its absence HERE is what forced presentation to be name
@@ -897,11 +1427,18 @@ async function* runTurn({ provider, fallbackProviders, model, tools, scope, work
897
1427
  try {
898
1428
  const events = query({
899
1429
  provider,
1430
+ ...(opts?.runId ? { runId: opts.runId } : {}),
900
1431
  // Omitted rather than empty when there is no tail. `query` treats the
901
1432
  // two the same, but an absent option reads as "this run has no chain"
902
1433
  // where `[]` reads as "this run has a chain with nothing in it".
903
1434
  ...(fallbackProviders.length > 0 ? { fallbackProviders } : {}),
904
1435
  tools,
1436
+ ...(pluginManager ? { pluginManager } : {}),
1437
+ ...(skillRegistry ? { skillRegistry } : {}),
1438
+ ...(skills ? { skills } : {}),
1439
+ // Withheld at both provider and executor boundaries on every ordinary
1440
+ // turn. An admitted send owns the exact run-scoped authority above.
1441
+ ...(!opts?.goalRound ? { deniedTools: SESSION_GOAL_TOOL_NAMES } : {}),
905
1442
  taskStore,
906
1443
  ...(taskGateway ? { taskGateway } : {}),
907
1444
  // `gateFor`, not the bare default: the default's `rules` is a hardcoded
@@ -909,6 +1446,7 @@ async function* runTurn({ provider, fallbackProviders, model, tools, scope, work
909
1446
  // path that runs every top-level turn. The sub-agent path called
910
1447
  // `gateFor` and this one did not.
911
1448
  ...(sandboxProvider ? { sandboxProvider } : {}),
1449
+ ...(sandboxTeardownTimeoutMs !== undefined ? { sandboxTeardownTimeoutMs } : {}),
912
1450
  authorizationGate: gateFor(rules),
913
1451
  compactionConfig: COMPACTION_CONFIG,
914
1452
  // The CLI owns its process end to end, so it can safely hand the
@@ -917,6 +1455,7 @@ async function* runTurn({ provider, fallbackProviders, model, tools, scope, work
917
1455
  emergencySave: true,
918
1456
  runConfig: {
919
1457
  model,
1458
+ ...(opts?.effort !== undefined ? { effort: opts.effort } : {}),
920
1459
  timeoutMs: 600_000,
921
1460
  tokenBudget: 1_000_000,
922
1461
  maxIterations: 50,
@@ -936,6 +1475,7 @@ async function* runTurn({ provider, fallbackProviders, model, tools, scope, work
936
1475
  agentId: 'namzu',
937
1476
  agentName: 'namzu',
938
1477
  ...(systemPrompt ? { systemPrompt } : {}),
1478
+ projectInstructionContext,
939
1479
  messages: [...messages],
940
1480
  workingDirectory,
941
1481
  // The exemption reads `tools` at decision time, so it sees the task
@@ -945,34 +1485,58 @@ async function* runTurn({ provider, fallbackProviders, model, tools, scope, work
945
1485
  signal,
946
1486
  ...scope,
947
1487
  });
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;
1488
+ let settled = false;
1489
+ try {
1490
+ while (true) {
1491
+ const next = await events.next();
1492
+ if (next.done) {
1493
+ settled = true;
1494
+ // A Run contains its fresh static/dynamic system floor as well as the
1495
+ // conversation. Only the latter crosses this host seam. Compaction
1496
+ // summaries survive because they ARE conversation state; arbitrary
1497
+ // system prompts are rebuilt fresh on every send and stay private to it.
1498
+ opts?.onConversationMessages?.(projectRunConversation(next.value.messages));
1499
+ return;
1500
+ }
1501
+ const event = next.value;
1502
+ // Before the abort check and before `toAgentEvent`: a session
1503
+ // cancelled mid-turn still produced the events up to that point, and
1504
+ // they are the interesting ones. Every event, not just the ones the
1505
+ // TUI renders — an export that only saw what the screen showed would
1506
+ // be a recording of the interface rather than of the session.
1507
+ onRunEvent?.(event);
1508
+ if (signal?.aborted) {
1509
+ yield { kind: 'error', message: 'aborted' };
1510
+ return;
1511
+ }
1512
+ const mapped = toAgentEvent(event, presenter);
1513
+ if (!mapped)
1514
+ continue;
1515
+ // On an `Agent` delegation finishing, attach the sub-agent's tool
1516
+ // steps (collected via the gateway while the call blocked) as a
1517
+ // `├─/└─` tree under its result, then reset for the next delegation.
1518
+ // (Don't clear on tool_executing: the parent's events can be buffered
1519
+ // and pulled only after the child already ran, which would wipe it.)
1520
+ if (event.type === 'tool_completed' &&
1521
+ event.toolName === 'Agent' &&
1522
+ childSteps.length > 0 &&
1523
+ mapped.kind === 'tool-end') {
1524
+ yield {
1525
+ ...mapped,
1526
+ detail: [...(mapped.detail ?? []), ...asTree(childSteps)],
1527
+ };
1528
+ childSteps.length = 0;
1529
+ continue;
1530
+ }
1531
+ yield mapped;
974
1532
  }
975
- yield mapped;
1533
+ }
1534
+ finally {
1535
+ // Manual iteration is what exposes the generator's Run return value.
1536
+ // Preserve `for await`'s other guarantee too: a consumer that stops
1537
+ // early must close the live query instead of abandoning its transport.
1538
+ if (!settled)
1539
+ await drainIterator(events);
976
1540
  }
977
1541
  }
978
1542
  catch (err) {
@@ -1080,7 +1644,7 @@ presenter = GENERIC_PRESENTER) {
1080
1644
  * survives the process, into the user's own repository, is not read-only under
1081
1645
  * any reading, and it now prompts.
1082
1646
  */
1083
- const PROMPT_EXEMPT_WRITES = new Set(['task_create', 'task_update']);
1647
+ const PROMPT_EXEMPT_WRITES = new Set(['task_create', 'task_update', 'update_goal']);
1084
1648
  /**
1085
1649
  * Whether a call runs without asking: it declares itself read-only, or it is a
1086
1650
  * named exemption above.
@@ -1190,6 +1754,30 @@ export function toAgentEvent(event, presenter) {
1190
1754
  };
1191
1755
  case 'provider_fallback':
1192
1756
  return { kind: 'provider-fallback', text: describeFallback(event) };
1757
+ case 'capability_warning':
1758
+ return {
1759
+ kind: 'capability-warning',
1760
+ capability: event.capability,
1761
+ text: event.message,
1762
+ };
1763
+ case 'message_history_repaired': {
1764
+ const changes = [
1765
+ event.duplicateToolResultsRemoved > 0
1766
+ ? `${event.duplicateToolResultsRemoved} duplicate result${event.duplicateToolResultsRemoved === 1 ? '' : 's'} removed`
1767
+ : null,
1768
+ event.orphanedToolResultsRemoved > 0
1769
+ ? `${event.orphanedToolResultsRemoved} orphaned result${event.orphanedToolResultsRemoved === 1 ? '' : 's'} removed`
1770
+ : null,
1771
+ event.syntheticToolResultsInserted > 0
1772
+ ? `${event.syntheticToolResultsInserted} interrupted call${event.syntheticToolResultsInserted === 1 ? '' : 's'} closed with unknown outcome`
1773
+ : null,
1774
+ ].filter((part) => part !== null);
1775
+ return {
1776
+ kind: 'history-repair',
1777
+ source: event.source,
1778
+ text: `Tool history repaired before the model call: ${changes.join('; ')}. Verify external state before retrying non-idempotent tools.`,
1779
+ };
1780
+ }
1193
1781
  case 'task_created':
1194
1782
  return { kind: 'task', subject: event.subject, status: event.status };
1195
1783
  case 'task_updated':
@@ -1204,7 +1792,10 @@ export function toAgentEvent(event, presenter) {
1204
1792
  // timeout, a cancellation and a blocked output guardrail arrive. A
1205
1793
  // consumer that reads this as success reports one for a run whose
1206
1794
  // answer was refused.
1207
- return { kind: 'done', ...(event.stopReason ? { stopReason: event.stopReason } : {}) };
1795
+ return {
1796
+ kind: 'done',
1797
+ ...(event.stopReason ? { stopReason: event.stopReason } : {}),
1798
+ };
1208
1799
  case 'run_failed':
1209
1800
  // The classification is now carried on the event rather than
1210
1801
  // having been flattened away upstream. Shown because "rate
@@ -1229,7 +1820,11 @@ export function toAgentEvent(event, presenter) {
1229
1820
  shed: true,
1230
1821
  };
1231
1822
  case 'compaction_failed':
1232
- return { kind: 'context', text: describeCompactionFailure(event), shed: false };
1823
+ return {
1824
+ kind: 'context',
1825
+ text: describeCompactionFailure(event),
1826
+ shed: false,
1827
+ };
1233
1828
  default:
1234
1829
  return null;
1235
1830
  }
@@ -1333,8 +1928,14 @@ function asTree(steps) {
1333
1928
  * the four deleted functions did for every tool.
1334
1929
  */
1335
1930
  const GENERIC_PRESENTER = {
1336
- presentCall: (_name, input) => ({ kind: 'generic', label: genericLabel(input) }),
1337
- presentResult: (_name, _input, result) => ({ kind: 'terminal', output: result.output ?? '' }),
1931
+ presentCall: (_name, input) => ({
1932
+ kind: 'generic',
1933
+ label: genericLabel(input),
1934
+ }),
1935
+ presentResult: (_name, _input, result) => ({
1936
+ kind: 'terminal',
1937
+ output: result.output ?? '',
1938
+ }),
1338
1939
  };
1339
1940
  /**
1340
1941
  * The one presentation function this host keeps.
@@ -1513,7 +2114,9 @@ function logCapabilities(probes) {
1513
2114
  const summary = probes
1514
2115
  .map((p) => `${p.specifier.split('/').pop()} ${p.state === 'present' ? 'yes' : 'no'}`)
1515
2116
  .join(' · ');
1516
- log.info(summary, { [EVENT_NAME_ATTRIBUTE]: BOOT_EVENT_NAMES.CAPABILITY_DETECTED });
2117
+ log.info(summary, {
2118
+ [EVENT_NAME_ATTRIBUTE]: BOOT_EVENT_NAMES.CAPABILITY_DETECTED,
2119
+ });
1517
2120
  for (const probe of probes) {
1518
2121
  if (probe.state === 'broken') {
1519
2122
  log.error('Capability probe failed to load', {
@@ -1545,6 +2148,15 @@ function emptySession(errorHint, errorKind = 'environment') {
1545
2148
  });
1546
2149
  return {
1547
2150
  hasProvider: false,
2151
+ // A refused session ran nothing, so there is nothing confining anything.
2152
+ // Reporting an enforced control here would be a claim about a sandbox
2153
+ // this path never built.
2154
+ sandbox: { unconfined: true, enforced: [], required: [] },
2155
+ // No provider, so no summary can be built. Refusing is the honest
2156
+ // answer; returning `null` would say "nothing to shed".
2157
+ compact: async () => {
2158
+ throw new Error('No provider: pick one with /model before compacting.');
2159
+ },
1548
2160
  errorKind,
1549
2161
  providerSummary: null,
1550
2162
  modelSummary: null,
@@ -1563,6 +2175,7 @@ function emptySession(errorHint, errorKind = 'environment') {
1563
2175
  errorHint,
1564
2176
  // No turn can run here, so no prompt can have been answered.
1565
2177
  approvalLatched: () => false,
2178
+ resetApprovalLatch: () => { },
1566
2179
  // No registry was built, so there is no roster to report on.
1567
2180
  promptExemptTools: () => [],
1568
2181
  send: async function* () {