@pugi/cli 0.1.0-beta.11 → 0.1.0-beta.13

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 (51) hide show
  1. package/dist/core/consensus/diff-capture.js +73 -0
  2. package/dist/core/context/index.js +7 -0
  3. package/dist/core/context/markdown-traverse.js +255 -0
  4. package/dist/core/edits/dispatch.js +218 -2
  5. package/dist/core/edits/journal.js +199 -0
  6. package/dist/core/edits/layer-d-ast.js +557 -14
  7. package/dist/core/edits/verify-hook.js +273 -0
  8. package/dist/core/engine/anvil-client.js +80 -5
  9. package/dist/core/engine/context-prefix.js +155 -0
  10. package/dist/core/engine/intent.js +260 -0
  11. package/dist/core/engine/native-pugi.js +663 -249
  12. package/dist/core/engine/prompts.js +52 -2
  13. package/dist/core/engine/tool-bridge.js +311 -9
  14. package/dist/core/lsp/client.js +57 -0
  15. package/dist/core/mcp/client.js +9 -0
  16. package/dist/core/mcp/http-server.js +553 -0
  17. package/dist/core/mcp/permission.js +190 -0
  18. package/dist/core/mcp/server-tools.js +219 -0
  19. package/dist/core/mcp/server.js +397 -0
  20. package/dist/core/repl/history.js +11 -1
  21. package/dist/core/repl/model-pricing.js +135 -0
  22. package/dist/core/repl/session.js +328 -12
  23. package/dist/core/repl/slash-commands.js +18 -4
  24. package/dist/core/settings.js +43 -0
  25. package/dist/core/subagents/dispatcher-real.js +600 -0
  26. package/dist/core/subagents/dispatcher.js +113 -24
  27. package/dist/core/subagents/index.js +18 -5
  28. package/dist/core/subagents/isolation-matrix.js +213 -0
  29. package/dist/core/subagents/spawn.js +19 -4
  30. package/dist/core/transport/version-interceptor.js +166 -0
  31. package/dist/index.js +28 -0
  32. package/dist/runtime/bootstrap.js +190 -0
  33. package/dist/runtime/cli.js +534 -268
  34. package/dist/runtime/commands/lsp.js +165 -5
  35. package/dist/runtime/commands/mcp.js +537 -0
  36. package/dist/runtime/headless.js +543 -0
  37. package/dist/runtime/load-hooks-or-exit.js +71 -0
  38. package/dist/runtime/version.js +65 -0
  39. package/dist/tools/agent-tool.js +192 -0
  40. package/dist/tools/apply-patch.js +62 -1
  41. package/dist/tools/mcp-tool.js +260 -0
  42. package/dist/tools/multi-edit.js +361 -0
  43. package/dist/tools/registry.js +5 -0
  44. package/dist/tools/web-fetch.js +147 -2
  45. package/dist/tools/web-search.js +458 -0
  46. package/dist/tui/agent-tree.js +10 -0
  47. package/dist/tui/repl-render.js +109 -1
  48. package/dist/tui/repl.js +7 -1
  49. package/dist/tui/status-bar.js +94 -16
  50. package/dist/tui/update-banner.js +20 -2
  51. package/package.json +5 -4
@@ -1,12 +1,22 @@
1
1
  import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { resolve } from 'node:path';
3
- import { runEngineLoop, } from '@pugi/sdk';
4
+ import { AsyncEventQueue, EngineEventEmitter, modelSupportsThinking, runEngineLoop, splitThinkingBlocks, } from '@pugi/sdk';
4
5
  import { FileReadCache } from '../file-cache.js';
5
6
  import { loadSettings } from '../settings.js';
6
7
  import { openSession, recordToolCall, recordToolResult } from '../session.js';
8
+ import { prewarmRealDispatch } from '../subagents/dispatcher.js';
7
9
  import { resolveBudget } from './budgets.js';
8
10
  import { buildExecutor, buildToolsSchema } from './tool-bridge.js';
9
11
  import { personaSlugFor, systemPromptFor } from './prompts.js';
12
+ import { CancellationToken } from '../repl/cancellation.js';
13
+ // β5a R5+R6 + P1 (2026-05-26): per-turn `<context>` prefix + intent
14
+ // classifier marker. Both pure functions, no fs cost at adapter init.
15
+ // Per-dir markdown traverse fires once per `run()`; budget capped so
16
+ // it never dominates the prompt budget.
17
+ import { buildContextPrefix, spliceContextPrefix } from './context-prefix.js';
18
+ import { applyIntentMarker, classifyIntent } from './intent.js';
19
+ import { loadTraversedMarkdown } from '../context/markdown-traverse.js';
10
20
  /**
11
21
  * Real `NativePugiEngineAdapter`. Drives the Pugi CLI's tool-use loop:
12
22
  *
@@ -51,8 +61,30 @@ export class NativePugiEngineAdapter {
51
61
  * to a single `run()` invocation.
52
62
  */
53
63
  engineToolCallIds = new Map();
64
+ /**
65
+ * β3 streaming additive: optional typed event emitter that mirrors
66
+ * every async-queue event so external consumers (admin-api SSE
67
+ * controller, future cabinet WebSocket relay) can attach without
68
+ * holding the async iterator. The CLI itself only consumes the
69
+ * `AsyncIterable<EngineEvent>` returned by `run()`; the emitter is
70
+ * a fan-out point for additional subscribers.
71
+ */
72
+ streamEmitter = new EngineEventEmitter();
54
73
  constructor(options) {
55
74
  this.options = options;
75
+ // β2a r1 (Backend Architect P1, 2026-05-26): kick off the real
76
+ // dispatcher's module import at adapter init so the first
77
+ // `agent` tool call does not pay 50-200ms cold-start. We fire
78
+ // the promise without awaiting — by the time the engine loop
79
+ // runs and the model issues an `agent` call, the import has
80
+ // resolved. The promise is swallowed because a failed prewarm
81
+ // would surface again at dispatch time with the real error.
82
+ void prewarmRealDispatch().catch(() => {
83
+ // Intentional no-op: the actual dispatch call will surface
84
+ // the import failure (if any) with the right call stack. A
85
+ // prewarm-time failure is just a missed optimization, not a
86
+ // correctness issue.
87
+ });
56
88
  }
57
89
  async capabilities() {
58
90
  return {
@@ -60,7 +92,13 @@ export class NativePugiEngineAdapter {
60
92
  supportsFileEdits: true,
61
93
  supportsShell: true,
62
94
  supportsLsp: false,
63
- supportsSubagents: false,
95
+ // β2 S2 (2026-05-26): real subagent dispatch shipped via the
96
+ // `agent` tool (apps/pugi-cli/src/tools/agent-tool.ts) plus the
97
+ // genuine `runEngineLoop`-backed dispatcher
98
+ // (apps/pugi-cli/src/core/subagents/dispatcher-real.ts). The
99
+ // capability flag flips after S1 + S3 + S4 land so cabinet UI +
100
+ // remote orchestrators can rely on the advertised contract.
101
+ supportsSubagents: true,
64
102
  };
65
103
  }
66
104
  async *run(task, ctx) {
@@ -68,274 +106,634 @@ export class NativePugiEngineAdapter {
68
106
  const root = task.workspaceRoot;
69
107
  const session = this.options.session ?? openSession(root);
70
108
  const settings = loadSettings(root);
71
- const toolCtx = {
72
- root,
73
- settings,
74
- session,
75
- readCache: new FileReadCache(),
76
- };
77
- // β1a r1 (budget wiring, 2026-05-26): swap the legacy SDK per-
78
- // command budget lookup for the Pl9 `resolveBudget()` pipeline so
79
- // `.pugi/settings.json::budgets.<command>` overrides actually take
80
- // effect at runtime + the HARD_MAX_* caps guard misconfigured
81
- // envelopes pre-flight. Before this fix the β1 Pl9 module
82
- // (`core/engine/budgets.ts`) was dead code — the adapter still
83
- // read the per-command defaults from the SDK, so operators who
84
- // set `budgets.code.maxTokens = 50000` in settings.json got the
85
- // legacy 30k anyway and `assertBudgetWithinTier` never ran.
109
+ // P1 fix (deep audit 2026-05-26): wire ctx.signal (AbortSignal) into
110
+ // a CancellationToken so the tool-bridge cancellation gate
111
+ // (`ctx.cancellation?.isAborted` check at tool-bridge.ts:656 +
112
+ // file-tools `gateOnCancellation` calls) fires when the operator
113
+ // aborts mid-tool. Before this fix `toolCtx` carried no cancellation
114
+ // field — only the next runEngineLoop iteration via `ctx.signal`
115
+ // aborted at the turn boundary, so a long-running tool (a sleeping
116
+ // bash command, a slow grep across the repo) could not be cancelled
117
+ // mid-call.
86
118
  //
87
- // Task-level token override (e.g. CLI `--max-tokens`) keeps
88
- // precedence; tool-call ceiling falls through to the resolved
89
- // budget so a careless caller cannot disable the call-count
90
- // guard by setting only token count.
91
- const budget = resolveBudget(kind, settings, task.budget?.tokens ? { maxTokens: task.budget.tokens } : undefined);
92
- yield {
93
- type: 'status',
94
- message: `Pugi engine starting: kind=${kind} budget=${budget.maxToolCalls} calls / ${budget.maxTokens} tokens`,
95
- };
96
- // Buffer status events emitted from inside the loop hooks. Async
97
- // generators cannot yield from synchronous callbacks, so we collect
98
- // them in a queue and drain after the loop call completes. The loop
99
- // is short enough (≤ ~30 turns) that latency-to-stdout is acceptable
100
- // a follow-up PR can switch to an event emitter for true streaming.
101
- const buffer = [];
102
- // Track files mutated by the loop. We extract the path from the JSON
103
- // arguments of every successful write/edit tool call; `bash` is left
104
- // out because its filesystem footprint is opaque (a single command
105
- // can touch dozens of paths via `make`, `pnpm build`, etc). The
106
- // per-session events.jsonl already carries every file_mutation event
107
- // for replay; this set is only the headline summary the CLI prints.
108
- const filesChanged = new Set();
109
- // Pending lookup: call.id path extracted from arguments. We only
110
- // commit to `filesChanged` when the corresponding onToolResult fires
111
- // with `ok: true`, so a refused or failed edit does not surface as
112
- // a phantom change in the operator summary.
113
- const pendingMutations = new Map();
114
- // Per-session events mirror — `.pugi/sessions/<id>/events.jsonl`.
115
- // The existing global log at `.pugi/events.jsonl` is preserved as
116
- // the audit-replay source of truth; this mirror is the easy-to-find
117
- // per-run log for operators and the cabinet UI (Sprint 2B).
118
- const sessionEventsPath = openSessionMirror(root, session.id);
119
- const hooks = {
120
- onTurnStart: (turnIndex, messageCount) => {
121
- const msg = `turn ${turnIndex + 1}: requesting model (transcript=${messageCount} messages)`;
122
- buffer.push({ type: 'status', message: msg });
123
- appendSessionMirror(sessionEventsPath, { type: 'turn_start', turn: turnIndex + 1, transcript: messageCount });
124
- },
125
- onTurnComplete: (turnIndex, response) => {
126
- if (response.stop === 'tool_use') {
127
- const calls = response.assistantMessage.toolCalls ?? [];
128
- buffer.push({
119
+ // The token is wired one-way: ctx.signal -> token. Aborting the
120
+ // token directly does NOT propagate back to the AbortSignal; the
121
+ // engine's own cancellation already lives upstream via the signal
122
+ // so the back-edge is unnecessary.
123
+ //
124
+ // r2 fix (triple-review 2026-05-26 P1): the abort listener was
125
+ // registered with `{ once: true }` — on actual abort it auto-detaches
126
+ // and disappears, but on the (common) NON-abort path where `run()`
127
+ // completes cleanly the listener stays attached to `ctx.signal`
128
+ // forever. Over a long REPL session (one shared AbortController per
129
+ // session, many run() invocations) listeners accumulate one per
130
+ // run, leaking memory and CPU on `dispatchEvent`. We now track the
131
+ // detach handle and call it unconditionally in the run()'s finally
132
+ // block so cleanup happens on both the success and abort paths.
133
+ const cancellation = new CancellationToken();
134
+ let detachAbortListener;
135
+ if (ctx.signal) {
136
+ if (ctx.signal.aborted) {
137
+ cancellation.abort();
138
+ }
139
+ else {
140
+ const handler = () => cancellation.abort();
141
+ ctx.signal.addEventListener('abort', handler, { once: true });
142
+ detachAbortListener = () => {
143
+ ctx.signal.removeEventListener('abort', handler);
144
+ };
145
+ }
146
+ }
147
+ // r2 (triple-review 2026-05-26 P1): everything below runs inside a
148
+ // try/finally so the AbortSignal listener detaches on BOTH the
149
+ // success and abort paths. Without this wrap a long REPL session
150
+ // (one persistent AbortController, many run() invocations) leaked
151
+ // one abort listener per non-aborted run.
152
+ try {
153
+ const toolCtx = {
154
+ root,
155
+ settings,
156
+ session,
157
+ readCache: new FileReadCache(),
158
+ cancellation,
159
+ };
160
+ // β1a r1 (budget wiring, 2026-05-26): swap the legacy SDK per-
161
+ // command budget lookup for the Pl9 `resolveBudget()` pipeline so
162
+ // `.pugi/settings.json::budgets.<command>` overrides actually take
163
+ // effect at runtime + the HARD_MAX_* caps guard misconfigured
164
+ // envelopes pre-flight. Before this fix the β1 Pl9 module
165
+ // (`core/engine/budgets.ts`) was dead code — the adapter still
166
+ // read the per-command defaults from the SDK, so operators who
167
+ // set `budgets.code.maxTokens = 50000` in settings.json got the
168
+ // legacy 30k anyway and `assertBudgetWithinTier` never ran.
169
+ //
170
+ // Task-level token override (e.g. CLI `--max-tokens`) keeps
171
+ // precedence; tool-call ceiling falls through to the resolved
172
+ // budget so a careless caller cannot disable the call-count
173
+ // guard by setting only token count.
174
+ const budget = resolveBudget(kind, settings, task.budget?.tokens ? { maxTokens: task.budget.tokens } : undefined);
175
+ // β3 streaming: pre-build the typed stream event queue so the hook
176
+ // callbacks below can push live events that this async generator
177
+ // yields IMMEDIATELY (instead of buffering until `runEngineLoop`
178
+ // completes). Operator now sees the first `tool.start` within
179
+ // ~tens of ms of the model emitting it, not 30+ s after the loop
180
+ // settles.
181
+ const streamQueue = new AsyncEventQueue();
182
+ const emitter = this.streamEmitter;
183
+ const supportsThinking = modelSupportsThinking(this.options.model);
184
+ /**
185
+ * Push one typed stream event into BOTH the per-run async queue
186
+ * (the CLI's iterator) and the long-lived emitter (the multiplex
187
+ * fan-out for admin-api SSE / cabinet WebSocket subscribers).
188
+ * The function stamps `timestamp` once so both consumers see the
189
+ * same wall clock.
190
+ */
191
+ const emitStream = (event) => {
192
+ const stamped = {
193
+ ...event,
194
+ timestamp: new Date().toISOString(),
195
+ };
196
+ streamQueue.push(stamped);
197
+ emitter.emit('event', stamped);
198
+ };
199
+ // r1 fix per triple-review Backend Architect P1: unify yield path via
200
+ // emitStream + streamQueue drain so the iterator consumer does NOT
201
+ // see this status frame twice. Pre-fix did both bare yield + emitStream
202
+ // → iterator got 2 copies, emitter got 1.
203
+ emitStream({
204
+ type: 'status',
205
+ message: `Pugi engine starting: kind=${kind} budget=${budget.maxToolCalls} calls / ${budget.maxTokens} tokens`,
206
+ });
207
+ // β5a R1+R4+R5+R6+P1 (2026-05-26): build the per-turn `<context>`
208
+ // prefix and apply the intent marker so the model sees:
209
+ // 1. cwd + open-files + per-dir-conventions block (R5+R6)
210
+ // 2. a `<intent kind="definitional">` wrapper when the operator
211
+ // asked a knowledge question (P1) — fixes the "What is grep?
212
+ // → bash man grep" loss mode flagged by the α7.X eval.
213
+ //
214
+ // All caps enforced inside the builders (5 KB block + 50 entries
215
+ // + top-3 markdown). Worst-case prompt growth is ~5 KB, well
216
+ // inside any per-command token budget.
217
+ //
218
+ // cwd is sourced from `process.cwd()` — the operator's shell pwd
219
+ // when they invoked `pugi`. For non-REPL CLI paths this is
220
+ // accurate; the REPL session retains the launch cwd for the
221
+ // lifetime of the session which is what the operator expects.
222
+ const cwdForTraverse = process.cwd();
223
+ let traverseResult;
224
+ try {
225
+ traverseResult = await loadTraversedMarkdown({
226
+ cwd: cwdForTraverse,
227
+ workspaceRoot: root,
228
+ });
229
+ }
230
+ catch {
231
+ // Per-dir markdown is a NICE-TO-HAVE; a fs error here must
232
+ // never break the engine loop. Fall back to an empty result
233
+ // so the prefix block still surfaces cwd + working set.
234
+ traverseResult = { loaded: [], warnings: [], totalBytes: 0 };
235
+ }
236
+ const intentClassification = classifyIntent(task.prompt);
237
+ const intentHint = intentClassification.intent !== 'ambiguous' ? intentClassification.intent : undefined;
238
+ const cwdRelative = relativeOrAbsolute(root, cwdForTraverse);
239
+ const prefix = buildContextPrefix({
240
+ cwdRelative,
241
+ // β5a defers wiring the live WorkingSet snapshot to the REPL
242
+ // session integration (R5+R6 here only covers the engine-side
243
+ // builder). When the REPL passes its working set down, the
244
+ // engine surface fills in. For now the prefix carries cwd +
245
+ // per-dir conventions + intent which are the two biggest
246
+ // win-rate moves per the α7.X eval.
247
+ traversedMarkdown: traverseResult.loaded,
248
+ intentHint,
249
+ });
250
+ if (prefix.bytes > 0 || intentClassification.intent === 'definitional') {
251
+ emitStream({
252
+ type: 'status',
253
+ message: `context: cwd=${cwdRelative} per-dir-md=${prefix.counts.markdownIncluded}/${prefix.counts.markdownTotal} intent=${intentClassification.intent}`,
254
+ });
255
+ }
256
+ const decoratedPrompt = applyIntentMarker(task.prompt, intentClassification.intent);
257
+ const finalUserPrompt = spliceContextPrefix(prefix.block, decoratedPrompt);
258
+ // Track files mutated by the loop. We extract the path from the JSON
259
+ // arguments of every successful write/edit tool call; `bash` is left
260
+ // out because its filesystem footprint is opaque (a single command
261
+ // can touch dozens of paths via `make`, `pnpm build`, etc). The
262
+ // per-session events.jsonl already carries every file_mutation event
263
+ // for replay; this set is only the headline summary the CLI prints.
264
+ const filesChanged = new Set();
265
+ // Pending lookup: call.id → path extracted from arguments. We only
266
+ // commit to `filesChanged` when the corresponding onToolResult fires
267
+ // with `ok: true`, so a refused or failed edit does not surface as
268
+ // a phantom change in the operator summary.
269
+ const pendingMutations = new Map();
270
+ // Per-session events mirror — `.pugi/sessions/<id>/events.jsonl`.
271
+ // The existing global log at `.pugi/events.jsonl` is preserved as
272
+ // the audit-replay source of truth; this mirror is the easy-to-find
273
+ // per-run log for operators and the cabinet UI (Sprint 2B).
274
+ const sessionEventsPath = openSessionMirror(root, session.id);
275
+ const hooks = {
276
+ onTurnStart: (turnIndex, messageCount) => {
277
+ const msg = `turn ${turnIndex + 1}: requesting model (transcript=${messageCount} messages)`;
278
+ emitStream({ type: 'status', message: msg });
279
+ appendSessionMirror(sessionEventsPath, { type: 'turn_start', turn: turnIndex + 1, transcript: messageCount });
280
+ },
281
+ onTurnComplete: (turnIndex, response) => {
282
+ if (response.stop === 'tool_use') {
283
+ const calls = response.assistantMessage.toolCalls ?? [];
284
+ emitStream({
285
+ type: 'status',
286
+ message: `turn ${turnIndex + 1}: model requested ${calls.length} tool call(s)`,
287
+ });
288
+ appendSessionMirror(sessionEventsPath, {
289
+ type: 'turn_complete',
290
+ turn: turnIndex + 1,
291
+ stop: 'tool_use',
292
+ toolCalls: calls.length,
293
+ tokensUsed: response.tokensUsed,
294
+ });
295
+ }
296
+ else if (response.stop === 'text') {
297
+ emitStream({
298
+ type: 'status',
299
+ message: `turn ${turnIndex + 1}: model returned final text (${response.content.length} chars)`,
300
+ });
301
+ appendSessionMirror(sessionEventsPath, {
302
+ type: 'turn_complete',
303
+ turn: turnIndex + 1,
304
+ stop: 'text',
305
+ contentLength: response.content.length,
306
+ tokensUsed: response.tokensUsed,
307
+ });
308
+ // β3 E4 thinking-block surface: only Claude / Gemini families
309
+ // advertise structured thinking today. The model resolver may
310
+ // return a slug we don't recognise; in that case we skip the
311
+ // split silently. When we DO recognise it, every `<thinking>`
312
+ // / `<thought>` block becomes a separate `thinking.start`/
313
+ // `thinking.delta`/`thinking.end` triplet so the TUI can
314
+ // render one collapsed pane row per block. The visible text
315
+ // (post-strip) flows to the regular `text.delta` channel so
316
+ // the conversation pane never shows raw <thinking> markup.
317
+ if (supportsThinking && response.content.length > 0) {
318
+ const split = splitThinkingBlocks(response.content);
319
+ for (const block of split.thinkingBlocks) {
320
+ const blockId = `think-${randomUUID().slice(0, 8)}`;
321
+ emitStream({ type: 'thinking.start', blockId });
322
+ emitStream({ type: 'thinking.delta', blockId, chunk: block });
323
+ emitStream({ type: 'thinking.end', blockId });
324
+ }
325
+ if (split.visibleText.length > 0) {
326
+ emitStream({ type: 'text.delta', chunk: split.visibleText });
327
+ }
328
+ }
329
+ else if (response.content.length > 0) {
330
+ emitStream({ type: 'text.delta', chunk: response.content });
331
+ }
332
+ }
333
+ },
334
+ onToolCall: (call) => {
335
+ // Record under an `engine_tool` prefix so the audit log can
336
+ // distinguish loop-driven calls from direct CLI tool calls.
337
+ const id = recordToolCall(session, `engine:${call.name}`, call.arguments.slice(0, 200));
338
+ // Stash the audit id on the call for `onToolResult` to close.
339
+ this.engineToolCallIds.set(call.id, id);
340
+ // Extract a candidate path for write/edit so we can build the
341
+ // filesChanged summary if (and only if) the call succeeds. Bad
342
+ // JSON is harmless here — we ignore it and the executor surfaces
343
+ // the actual parse error to the model.
344
+ if (call.name === 'write' || call.name === 'edit') {
345
+ const path = extractPathArg(call.arguments);
346
+ if (path)
347
+ pendingMutations.set(call.id, path);
348
+ }
349
+ emitStream({
350
+ type: 'tool.start',
351
+ callId: call.id,
352
+ name: call.name,
353
+ arguments: call.arguments,
354
+ });
355
+ emitStream({
129
356
  type: 'status',
130
- message: `turn ${turnIndex + 1}: model requested ${calls.length} tool call(s)`,
357
+ message: `tool_call: ${call.name}(${call.arguments.slice(0, 80)}${call.arguments.length > 80 ? '...' : ''})`,
131
358
  });
132
359
  appendSessionMirror(sessionEventsPath, {
133
- type: 'turn_complete',
134
- turn: turnIndex + 1,
135
- stop: 'tool_use',
136
- toolCalls: calls.length,
137
- tokensUsed: response.tokensUsed,
360
+ type: 'tool_call',
361
+ tool: call.name,
362
+ callId: call.id,
363
+ argsPreview: call.arguments.slice(0, 200),
138
364
  });
139
- }
140
- else if (response.stop === 'text') {
141
- buffer.push({
365
+ },
366
+ onToolResult: (call, result) => {
367
+ const auditId = this.engineToolCallIds.get(call.id);
368
+ if (auditId) {
369
+ if (result.ok) {
370
+ recordToolResult(session, auditId, 'success', result.content.slice(0, 200));
371
+ }
372
+ else {
373
+ recordToolResult(session, auditId, 'error', result.error.slice(0, 200));
374
+ }
375
+ this.engineToolCallIds.delete(call.id);
376
+ }
377
+ const pendingPath = pendingMutations.get(call.id);
378
+ if (pendingPath) {
379
+ if (result.ok)
380
+ filesChanged.add(pendingPath);
381
+ pendingMutations.delete(call.id);
382
+ }
383
+ emitStream({
384
+ type: 'tool.end',
385
+ callId: call.id,
386
+ ok: result.ok,
387
+ summary: result.ok
388
+ ? result.content.slice(0, 200)
389
+ : result.error.slice(0, 200),
390
+ });
391
+ emitStream({
142
392
  type: 'status',
143
- message: `turn ${turnIndex + 1}: model returned final text (${response.content.length} chars)`,
393
+ message: result.ok
394
+ ? `tool_result: ${call.name} ok`
395
+ : `tool_result: ${call.name} error: ${result.error.slice(0, 120)}`,
144
396
  });
145
397
  appendSessionMirror(sessionEventsPath, {
146
- type: 'turn_complete',
147
- turn: turnIndex + 1,
148
- stop: 'text',
149
- contentLength: response.content.length,
150
- tokensUsed: response.tokensUsed,
398
+ type: 'tool_result',
399
+ tool: call.name,
400
+ callId: call.id,
401
+ ok: result.ok,
402
+ summary: result.ok ? result.content.slice(0, 200) : result.error.slice(0, 200),
403
+ });
404
+ },
405
+ };
406
+ // β1b r1 (--allow-fetch / --allow-search wiring, 2026-05-26):
407
+ // compute the effective gate as OR of (a) the persisted
408
+ // settings.json opt-in and (b) the runtime CLI flag passed via
409
+ // the constructor. Before this fix the adapter only honored (a),
410
+ // so `pugi code --allow-fetch` against a default-privacy workspace
411
+ // silently fell back to "tool not advertised" even though the
412
+ // operator opted in for one invocation. The CLI flag was wired
413
+ // through to the legacy `pugi web` sub-command but not to the
414
+ // engine adapter — Backend Architect review (PR #425 r1) caught
415
+ // the gap.
416
+ const allowFetchEffective = this.options.allowFetch === true || settings.web?.fetch?.enabled === true;
417
+ const allowSearchEffective = this.options.allowSearch === true || settings.web?.search?.enabled === true;
418
+ // β2 S3 (2026-05-26) → β2a r1 (Backend Architect P1, 2026-05-26):
419
+ // expose the `agent` tool to the parent loop ONLY for non-plan
420
+ // commands. `buildToolsSchema` also strips the agent tool from
421
+ // plan-mode schemas, but a model that fabricates an `agent` call
422
+ // would still hit the executor with `agentDispatch` wired and
423
+ // could spawn a coder that mutates the workspace — breaking the
424
+ // plan-mode read-only contract. Hard-gate `allowAgent` on the
425
+ // command kind so plan mode never wires the dispatch block in
426
+ // the first place; tool-bridge.ts also throws ToolRefused on a
427
+ // fabricated `agent` call in plan mode as defense in depth.
428
+ //
429
+ // Why only the top-level parent and not children: the dispatcher-
430
+ // real.ts module builds the CHILD's executor without an
431
+ // `agentDispatch` block so children cannot recursively spawn
432
+ // grandchildren. The isolation-matrix capability set then refuses
433
+ // the `agent` tool for every non-orchestrator role anyway, but
434
+ // the executor-level gate is the load-bearing chokepoint.
435
+ const allowAgent = kind !== 'plan';
436
+ // β3 streaming: kick off `runEngineLoop` IN PARALLEL with the queue
437
+ // drain. The loop's hook callbacks push events onto `streamQueue`
438
+ // synchronously; this generator yields them live by awaiting the
439
+ // queue's iterator. When the loop settles (success or crash) we
440
+ // close the queue, which lets the iterator return cleanly and the
441
+ // generator falls through to the terminal `result` frame.
442
+ //
443
+ // Why concurrent instead of serial:
444
+ //
445
+ // The β1 adapter awaited `runEngineLoop` to completion, then
446
+ // drained an in-memory `EngineEvent[]` buffer. Operator saw
447
+ // nothing for 30+ seconds (the full LLM round-trip + tool exec
448
+ // wall time), then the entire log dumped at once. The TUI tool-
449
+ // stream pane was a no-op because no event ever reached it
450
+ // before the loop completed.
451
+ //
452
+ // `Promise.race`-based interleaving lets us yield the next queue
453
+ // event OR detect loop settlement on each tick. The settlement
454
+ // flag (`loopSettled`) gates the final drain so we never miss
455
+ // tail events that the hooks pushed in the same microtask as
456
+ // the loop's terminal `return`.
457
+ // Boxed via single-element tuple so TypeScript does not narrow the
458
+ // outer `outcome` binding to `null` after the closure mutation.
459
+ // Async-closure mutations are invisible to TS control-flow analysis;
460
+ // wrapping in a tuple defeats the narrowing without an unsafe cast.
461
+ const outcomeBox = [null];
462
+ let loopError = null;
463
+ const loopPromise = (async () => {
464
+ try {
465
+ outcomeBox[0] = await runEngineLoop({
466
+ client: this.options.client,
467
+ executor: buildExecutor({
468
+ kind,
469
+ ctx: toolCtx,
470
+ sessionId: session.id,
471
+ workspaceRoot: root,
472
+ // P1 fix (deep audit 2026-05-26): forward optional REPL
473
+ // ask-modal bridge. Default `interactive: false` preserves
474
+ // backward compat — non-TTY callers (CI, pipes, scripted
475
+ // CLI runs) keep the `[user_input_required]` envelope path.
476
+ // The REPL layer passes `interactive: true` + a real
477
+ // `askUserBridge` so model-initiated `ask_user_question`
478
+ // calls round-trip to the ink modal and return the
479
+ // operator's choice as a tool result.
480
+ interactive: this.options.interactive === true,
481
+ ...(this.options.askUserBridge
482
+ ? { askUserBridge: this.options.askUserBridge }
483
+ : {}),
484
+ // P1 fix (deep audit 2026-05-26): forward the workspace
485
+ // HookRegistry so `.pugi/hooks/` lifecycle hooks fire for
486
+ // model-initiated tool calls. SECURITY: a `PreToolUse
487
+ // onFailure: 'block'` hook that refuses bash containing
488
+ // `rm` now applies to model dispatch — before this fix
489
+ // such a hook only applied to direct CLI tool calls.
490
+ ...(this.options.hooks ? { hooks: this.options.hooks } : {}),
491
+ // β1a r1 (web_fetch gating) + β1b r1 (--allow-fetch wiring):
492
+ // executor allowFetch matches the schema-advertise gate so a
493
+ // settings.json opt-in OR a --allow-fetch flag enables the
494
+ // call. Without this the model would not even see the
495
+ // `web_fetch` tool. `allowSearch` covers the new T4
496
+ // `web_search` tool with the same OR semantics.
497
+ allowFetch: allowFetchEffective,
498
+ allowSearch: allowSearchEffective,
499
+ // β2 S3 → β2a r1 (2026-05-26): parent-level agentDispatch
500
+ // wiring. When the model emits a `tool_call: agent(role,
501
+ // brief)`, the executor forwards it to dispatcher-real.ts
502
+ // which spawns a child engine loop against the same Anvil
503
+ // client. Gated by `allowAgent` so plan mode does not even
504
+ // wire the dispatch block — defense in depth on top of the
505
+ // schema-filter and the tool-bridge plan-mode refusal.
506
+ ...(allowAgent
507
+ ? {
508
+ agentDispatch: {
509
+ parentSession: session,
510
+ engineClient: this.options.client,
511
+ },
512
+ }
513
+ : {}),
514
+ // β4 M1/M3/M5: pass the loaded MCP registry through so the
515
+ // executor can route `mcp__server__tool` calls + run the
516
+ // first-call permission prompt before dispatching upstream.
517
+ ...(this.options.mcpRegistry ? { mcpRegistry: this.options.mcpRegistry } : {}),
518
+ ...(this.options.mcpPrompt ? { mcpPrompt: this.options.mcpPrompt } : {}),
519
+ }),
520
+ systemPrompt: systemPromptFor(kind),
521
+ // β5a R5+R6+P1: per-turn `<context>` prefix + intent marker
522
+ // applied above. Falls back to verbatim `task.prompt` when
523
+ // both the prefix block is empty AND the intent classifier
524
+ // returned ambiguous (the splice + apply functions handle
525
+ // that case as identity).
526
+ userPrompt: finalUserPrompt,
527
+ // β1a r1 (web_fetch gating) + β1b r1 (--allow-fetch wiring):
528
+ // pass the OR of `.pugi/settings.json::web.fetch.enabled` and
529
+ // the runtime `--allow-fetch` flag. When neither is true the
530
+ // `web_fetch` tool is not advertised to the model at all.
531
+ // `allowSearch` does the same for the new `web_search` tool.
532
+ // β2 S3: allowAgent surfaces the `agent` tool in the schema
533
+ // so the model sees it as a valid tool call option; the
534
+ // capability-matrix layer (S4) still gates which roles can
535
+ // actually USE it. Plan mode strips it via β2a r1 gate.
536
+ tools: buildToolsSchema(kind, {
537
+ allowFetch: allowFetchEffective,
538
+ allowSearch: allowSearchEffective,
539
+ allowAgent,
540
+ // β4 M1/M3: same registry the executor saw. Schema +
541
+ // dispatcher must agree on which MCP names are advertised
542
+ // and which are dispatchable; passing identical references
543
+ // makes that invariant impossible to break.
544
+ ...(this.options.mcpRegistry ? { mcpRegistry: this.options.mcpRegistry } : {}),
545
+ }),
546
+ budget,
547
+ personaSlug: personaSlugFor(kind),
548
+ hooks,
549
+ temperature: this.options.temperature ?? 0.2,
550
+ signal: ctx.signal,
551
+ // β1 (audit E2): forward CLI sub-command + α6.10 routing tag +
552
+ // operator-pinned model so the runtime controller's DTO sees
553
+ // all three. `tag` derives 1:1 from `command` for now
554
+ // (`code → code`, `build → build_task`, etc.); future routing
555
+ // changes flip the mapping table without touching the call
556
+ // site. `model` is left undefined here — operator-pinned model
557
+ // pinning ships in β6 with persona routing.
558
+ command: kind,
559
+ tag: dispatchTagFor(kind),
560
+ model: this.options.model,
151
561
  });
152
562
  }
153
- },
154
- onToolCall: (call) => {
155
- // Record under an `engine_tool` prefix so the audit log can
156
- // distinguish loop-driven calls from direct CLI tool calls.
157
- const id = recordToolCall(session, `engine:${call.name}`, call.arguments.slice(0, 200));
158
- // Stash the audit id on the call for `onToolResult` to close.
159
- this.engineToolCallIds.set(call.id, id);
160
- // Extract a candidate path for write/edit so we can build the
161
- // filesChanged summary if (and only if) the call succeeds. Bad
162
- // JSON is harmless here — we ignore it and the executor surfaces
163
- // the actual parse error to the model.
164
- if (call.name === 'write' || call.name === 'edit') {
165
- const path = extractPathArg(call.arguments);
166
- if (path)
167
- pendingMutations.set(call.id, path);
168
- }
169
- buffer.push({
170
- type: 'status',
171
- message: `tool_call: ${call.name}(${call.arguments.slice(0, 80)}${call.arguments.length > 80 ? '...' : ''})`,
172
- });
173
- appendSessionMirror(sessionEventsPath, {
174
- type: 'tool_call',
175
- tool: call.name,
176
- callId: call.id,
177
- argsPreview: call.arguments.slice(0, 200),
178
- });
179
- },
180
- onToolResult: (call, result) => {
181
- const auditId = this.engineToolCallIds.get(call.id);
182
- if (auditId) {
183
- if (result.ok) {
184
- recordToolResult(session, auditId, 'success', result.content.slice(0, 200));
185
- }
186
- else {
187
- recordToolResult(session, auditId, 'error', result.error.slice(0, 200));
188
- }
189
- this.engineToolCallIds.delete(call.id);
563
+ catch (err) {
564
+ loopError = err;
190
565
  }
191
- const pendingPath = pendingMutations.get(call.id);
192
- if (pendingPath) {
193
- if (result.ok)
194
- filesChanged.add(pendingPath);
195
- pendingMutations.delete(call.id);
566
+ finally {
567
+ // Close the queue so the iterator below returns `done: true`.
568
+ // Any tail events the hooks pushed in the same microtask still
569
+ // drain because `AsyncEventQueue.close()` only resolves
570
+ // PENDING awaiters — buffered items stay readable.
571
+ streamQueue.close();
196
572
  }
197
- buffer.push({
198
- type: 'status',
199
- message: result.ok
200
- ? `tool_result: ${call.name} ok`
201
- : `tool_result: ${call.name} error: ${result.error.slice(0, 120)}`,
202
- });
203
- appendSessionMirror(sessionEventsPath, {
204
- type: 'tool_result',
205
- tool: call.name,
206
- callId: call.id,
207
- ok: result.ok,
208
- summary: result.ok ? result.content.slice(0, 200) : result.error.slice(0, 200),
209
- });
210
- },
211
- };
212
- let outcome;
213
- try {
214
- outcome = await runEngineLoop({
215
- client: this.options.client,
216
- executor: buildExecutor({
217
- kind,
218
- ctx: toolCtx,
219
- sessionId: session.id,
220
- workspaceRoot: root,
221
- // Conservatively false unless the caller explicitly opted in
222
- // via constructor. Interactive ask-modal bridge is wired by
223
- // the REPL layer in β2; for now non-TTY envelope is the path.
224
- interactive: false,
225
- // β1a r1 (web_fetch gating): executor allowFetch matches the
226
- // schema-advertise gate so a settings.json opt-in actually
227
- // enables the call. Without this the model would not even
228
- // see the `web_fetch` tool, but a `pugi web` CLI dispatch
229
- // through the executor would still be allowed because the
230
- // tool registry is independent.
231
- allowFetch: settings.web?.fetch?.enabled === true,
232
- }),
233
- systemPrompt: systemPromptFor(kind),
234
- userPrompt: task.prompt,
235
- // β1a r1 (web_fetch gating): pass the OR of
236
- // `.pugi/settings.json::web.fetch.enabled` and the runtime
237
- // `allowFetch` flag (today the adapter is conservative — see
238
- // `buildExecutor` call below). When neither is true the
239
- // `web_fetch` tool is not advertised to the model at all.
240
- tools: buildToolsSchema(kind, {
241
- allowFetch: settings.web?.fetch?.enabled === true,
242
- }),
243
- budget,
244
- personaSlug: personaSlugFor(kind),
245
- hooks,
246
- temperature: this.options.temperature ?? 0.2,
247
- signal: ctx.signal,
248
- // β1 (audit E2): forward CLI sub-command + α6.10 routing tag +
249
- // operator-pinned model so the runtime controller's DTO sees
250
- // all three. `tag` derives 1:1 from `command` for now
251
- // (`code code`, `build → build_task`, etc.); future routing
252
- // changes flip the mapping table without touching the call
253
- // site. `model` is left undefined here — operator-pinned model
254
- // pinning ships in β6 with persona routing.
255
- command: kind,
256
- tag: dispatchTagFor(kind),
257
- model: this.options.model,
573
+ })();
574
+ // Drain the queue live. Each iteration yields one EngineEvent the
575
+ // moment its hook fired. Operator sees `tool.start` within tens of
576
+ // ms of the model emitting it.
577
+ for await (const event of streamQueue) {
578
+ yield streamEventToEngineEvent(event);
579
+ }
580
+ // Loop has settled (queue closed). Surface its outcome — either an
581
+ // unhandled crash from the (rare) executor exception path or the
582
+ // structured EngineLoopOutcome.
583
+ await loopPromise;
584
+ if (loopError !== null) {
585
+ const message = loopError instanceof Error ? loopError.message : String(loopError);
586
+ yield {
587
+ type: 'result',
588
+ result: {
589
+ status: 'failed',
590
+ summary: `engine loop crashed: ${message}`,
591
+ filesChanged: [],
592
+ patchRefs: [],
593
+ testsRun: [],
594
+ risks: [`unhandled error in engine adapter: ${message}`],
595
+ eventRefs: [],
596
+ },
597
+ };
598
+ return;
599
+ }
600
+ const finalOutcome = outcomeBox[0];
601
+ if (finalOutcome === null) {
602
+ // Defensive should never hit. `runEngineLoop` always either
603
+ // resolves with an outcome or throws (and we catch that above).
604
+ yield {
605
+ type: 'result',
606
+ result: {
607
+ status: 'failed',
608
+ summary: 'engine loop returned no outcome',
609
+ filesChanged: [],
610
+ patchRefs: [],
611
+ testsRun: [],
612
+ risks: ['runEngineLoop resolved without an outcome value'],
613
+ eventRefs: [],
614
+ },
615
+ };
616
+ return;
617
+ }
618
+ // Translate the loop outcome into an EngineResult.
619
+ // `aborted` (α6.9: operator cancelled mid-tool) maps to `blocked`
620
+ // because the operator chose the outcome, same shape as
621
+ // budget_exhausted / tool_refused.
622
+ const status = finalOutcome.status === 'completed'
623
+ ? 'done'
624
+ : finalOutcome.status === 'failed'
625
+ ? 'failed'
626
+ : 'blocked';
627
+ const summaryPrefix = finalOutcome.status === 'completed'
628
+ ? ''
629
+ : finalOutcome.status === 'budget_exhausted'
630
+ ? '[budget_exhausted] '
631
+ : finalOutcome.status === 'tool_refused'
632
+ ? '[plan_mode_refused] '
633
+ : finalOutcome.status === 'aborted'
634
+ ? '[operator_aborted] '
635
+ : '[failed] ';
636
+ const filesChangedList = Array.from(filesChanged).sort();
637
+ appendSessionMirror(sessionEventsPath, {
638
+ type: 'outcome',
639
+ status: finalOutcome.status,
640
+ toolCallCount: finalOutcome.toolCallCount,
641
+ turnsUsed: finalOutcome.turnsUsed,
642
+ tokensUsed: finalOutcome.tokensUsed,
643
+ filesChanged: filesChangedList,
644
+ reason: finalOutcome.reason,
258
645
  });
259
- }
260
- catch (error) {
261
- // Defensive — runEngineLoop wraps errors into status: failed, so
262
- // this branch is only hit if the executor or hooks themselves
263
- // throw uncaught. Surface as a failed result so the CLI exits
264
- // non-zero rather than hanging.
265
- const message = error instanceof Error ? error.message : String(error);
266
646
  yield {
267
647
  type: 'result',
268
648
  result: {
269
- status: 'failed',
270
- summary: `engine loop crashed: ${message}`,
271
- filesChanged: [],
649
+ status,
650
+ summary: `${summaryPrefix}${finalOutcome.finalText || finalOutcome.reason || 'no answer returned'}`,
651
+ filesChanged: filesChangedList,
272
652
  patchRefs: [],
273
653
  testsRun: [],
274
- risks: [`unhandled error in engine adapter: ${message}`],
275
- eventRefs: [],
654
+ risks: finalOutcome.status === 'completed'
655
+ ? []
656
+ : [finalOutcome.reason ?? `outcome=${finalOutcome.status}`],
657
+ eventRefs: [
658
+ `tool_calls=${finalOutcome.toolCallCount}`,
659
+ `turns=${finalOutcome.turnsUsed}`,
660
+ `tokens=${finalOutcome.tokensUsed}`,
661
+ // `outcome=<status>` is a machine-readable echo so callers
662
+ // (cli.ts plan exit code, cabinet UI) can distinguish
663
+ // `budget_exhausted` from `tool_refused` without parsing
664
+ // the human-readable summary prefix. Code Reviewer P2
665
+ // retro 2026-05-23: plan exit code previously collapsed
666
+ // both blocked reasons into 0, which masked budget hits.
667
+ `outcome=${finalOutcome.status}`,
668
+ `session=${session.id}`,
669
+ `ctx=${ctx.sessionId}`,
670
+ `mirror=${sessionEventsPath}`,
671
+ ],
276
672
  },
277
673
  };
278
- return;
279
674
  }
280
- // Drain status buffer first so consumers see the chronological order.
281
- for (const event of buffer)
282
- yield event;
283
- // Translate the loop outcome into an EngineResult.
284
- // `aborted` (α6.9: operator cancelled mid-tool) maps to `blocked`
285
- // because the operator chose the outcome, same shape as
286
- // budget_exhausted / tool_refused.
287
- const status = outcome.status === 'completed'
288
- ? 'done'
289
- : outcome.status === 'failed'
290
- ? 'failed'
291
- : 'blocked';
292
- const summaryPrefix = outcome.status === 'completed'
293
- ? ''
294
- : outcome.status === 'budget_exhausted'
295
- ? '[budget_exhausted] '
296
- : outcome.status === 'tool_refused'
297
- ? '[plan_mode_refused] '
298
- : outcome.status === 'aborted'
299
- ? '[operator_aborted] '
300
- : '[failed] ';
301
- const filesChangedList = Array.from(filesChanged).sort();
302
- appendSessionMirror(sessionEventsPath, {
303
- type: 'outcome',
304
- status: outcome.status,
305
- toolCallCount: outcome.toolCallCount,
306
- turnsUsed: outcome.turnsUsed,
307
- tokensUsed: outcome.tokensUsed,
308
- filesChanged: filesChangedList,
309
- reason: outcome.reason,
310
- });
311
- yield {
312
- type: 'result',
313
- result: {
314
- status,
315
- summary: `${summaryPrefix}${outcome.finalText || outcome.reason || 'no answer returned'}`,
316
- filesChanged: filesChangedList,
317
- patchRefs: [],
318
- testsRun: [],
319
- risks: outcome.status === 'completed'
320
- ? []
321
- : [outcome.reason ?? `outcome=${outcome.status}`],
322
- eventRefs: [
323
- `tool_calls=${outcome.toolCallCount}`,
324
- `turns=${outcome.turnsUsed}`,
325
- `tokens=${outcome.tokensUsed}`,
326
- // `outcome=<status>` is a machine-readable echo so callers
327
- // (cli.ts plan exit code, cabinet UI) can distinguish
328
- // `budget_exhausted` from `tool_refused` without parsing
329
- // the human-readable summary prefix. Code Reviewer P2
330
- // retro 2026-05-23: plan exit code previously collapsed
331
- // both blocked reasons into 0, which masked budget hits.
332
- `outcome=${outcome.status}`,
333
- `session=${session.id}`,
334
- `ctx=${ctx.sessionId}`,
335
- `mirror=${sessionEventsPath}`,
336
- ],
337
- },
338
- };
675
+ finally {
676
+ // r2 (triple-review 2026-05-26 P1): detach the abort listener so
677
+ // long REPL sessions sharing one AbortController across many
678
+ // run() invocations do not accumulate one listener per run on
679
+ // `ctx.signal`. Called on success, abort, and uncaught throw.
680
+ detachAbortListener?.();
681
+ }
682
+ }
683
+ }
684
+ /**
685
+ * β3 streaming: translate one typed `EngineStreamEvent` from the
686
+ * adapter's internal queue into the SDK's lossier `EngineEvent` shape
687
+ * the public adapter contract exposes. The SDK contract only declares
688
+ * `status | result` today; richer events (`tool.start`, `thinking.delta`,
689
+ * etc.) collapse to a structured `status` message until the SDK widens
690
+ * the discriminated union (β3b — paired with an admin-api SSE schema
691
+ * bump so the wire format stays stable).
692
+ *
693
+ * The full typed payload is still available to richer consumers via
694
+ * `adapter.streamEmitter.on('event', ...)`. The CLI's TUI tool-stream
695
+ * pane consumes that emitter directly; this function is the safe
696
+ * bridge for legacy SDK consumers that only know `EngineEvent`.
697
+ */
698
+ function streamEventToEngineEvent(stream) {
699
+ switch (stream.type) {
700
+ case 'status':
701
+ return { type: 'status', message: stream.message };
702
+ case 'tool.start':
703
+ return {
704
+ type: 'status',
705
+ message: `tool.start ${stream.name} call=${stream.callId} args=${stream.arguments.slice(0, 80)}${stream.arguments.length > 80 ? '...' : ''}`,
706
+ };
707
+ case 'tool.delta':
708
+ return {
709
+ type: 'status',
710
+ message: `tool.delta call=${stream.callId} chunk=${stream.chunk.slice(0, 120)}`,
711
+ };
712
+ case 'tool.end':
713
+ return {
714
+ type: 'status',
715
+ message: `tool.end call=${stream.callId} ok=${stream.ok} summary=${stream.summary.slice(0, 120)}`,
716
+ };
717
+ case 'thinking.start':
718
+ return { type: 'status', message: `thinking.start block=${stream.blockId}` };
719
+ case 'thinking.delta':
720
+ return {
721
+ type: 'status',
722
+ message: `thinking.delta block=${stream.blockId} chunk=${stream.chunk.slice(0, 120)}`,
723
+ };
724
+ case 'thinking.end':
725
+ return { type: 'status', message: `thinking.end block=${stream.blockId}` };
726
+ case 'text.delta':
727
+ return {
728
+ type: 'status',
729
+ message: `text.delta chunk=${stream.chunk.slice(0, 200)}`,
730
+ };
731
+ default: {
732
+ // Exhaustiveness — TS catches a missing variant at compile time.
733
+ const exhaustive = stream;
734
+ void exhaustive;
735
+ return { type: 'status', message: 'unknown stream event' };
736
+ }
339
737
  }
340
738
  }
341
739
  /**
@@ -471,4 +869,20 @@ export function dispatchTagFor(kind) {
471
869
  // `NativePugiEngineAdapter` instance above — Code Reviewer P2 retro
472
870
  // 2026-05-23 lifted it off the module scope to prevent collisions
473
871
  // under parallel adapter runs (cabinet UI + CLI sharing one process).
872
+ /**
873
+ * β5a R5+R6: render a cwd path as either a workspace-root-relative
874
+ * string (when cwd is inside the workspace) or a `.` token (when cwd
875
+ * equals workspaceRoot). Falls back to the absolute cwd if it lives
876
+ * outside the workspace — the traverse loader already refuses to
877
+ * read off-tree files so the abs path is purely a breadcrumb for
878
+ * the SSE status line.
879
+ */
880
+ function relativeOrAbsolute(workspaceRoot, cwd) {
881
+ const absRoot = resolve(workspaceRoot);
882
+ const absCwd = resolve(cwd);
883
+ if (absCwd === absRoot)
884
+ return '.';
885
+ const rel = absCwd.startsWith(absRoot + '/') ? absCwd.slice(absRoot.length + 1) : null;
886
+ return rel ?? absCwd;
887
+ }
474
888
  //# sourceMappingURL=native-pugi.js.map