@pugi/cli 0.1.0-alpha.9 → 0.1.0-beta.2
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.
- package/README.md +33 -0
- package/assets/pugi-mascot.ansi +41 -0
- package/dist/commands/deploy.js +439 -0
- package/dist/core/agents/loader.js +104 -0
- package/dist/core/agents/registry.js +1 -1
- package/dist/core/consensus/anvil-fanout.js +276 -0
- package/dist/core/consensus/diff-capture.js +382 -0
- package/dist/core/consensus/rubric.js +233 -0
- package/dist/core/context/index.js +21 -0
- package/dist/core/context/pugiignore.js +316 -0
- package/dist/core/context/repo-skeleton.js +533 -0
- package/dist/core/context/watcher.js +342 -0
- package/dist/core/context/working-set.js +165 -0
- package/dist/core/edits/dispatch.js +185 -0
- package/dist/core/edits/index.js +15 -0
- package/dist/core/edits/layer-a-apply.js +217 -0
- package/dist/core/edits/layer-b-apply.js +211 -0
- package/dist/core/edits/layer-c-apply.js +160 -0
- package/dist/core/edits/layer-d-ast.js +29 -0
- package/dist/core/edits/marker-parser.js +401 -0
- package/dist/core/edits/security-gate.js +223 -0
- package/dist/core/edits/worktree.js +229 -0
- package/dist/core/engine/native-pugi.js +6 -1
- package/dist/core/engine/prompts.js +4 -1
- package/dist/core/engine/tool-bridge.js +33 -1
- package/dist/core/lsp/client.js +631 -0
- package/dist/core/repl/ask.js +512 -0
- package/dist/core/repl/cancellation.js +98 -0
- package/dist/core/repl/dispatch-fsm.js +220 -0
- package/dist/core/repl/privacy-banner.js +71 -0
- package/dist/core/repl/session.js +1896 -13
- package/dist/core/repl/slash-commands.js +59 -32
- package/dist/core/repl/store/index.js +12 -0
- package/dist/core/repl/store/jsonl-log.js +321 -0
- package/dist/core/repl/store/lockfile.js +155 -0
- package/dist/core/repl/store/session-store.js +792 -0
- package/dist/core/repl/store/types.js +44 -0
- package/dist/core/repl/store/uuid-v7.js +68 -0
- package/dist/core/repl/workspace-context.js +72 -1
- package/dist/core/skills/loader.js +454 -0
- package/dist/core/skills/sources.js +480 -0
- package/dist/core/skills/trust.js +172 -0
- package/dist/runtime/cli.js +767 -10
- package/dist/runtime/commands/agents.js +385 -0
- package/dist/runtime/commands/config.js +338 -8
- package/dist/runtime/commands/lsp.js +184 -0
- package/dist/runtime/commands/patch.js +111 -0
- package/dist/runtime/commands/review-consensus.js +399 -0
- package/dist/runtime/commands/skills.js +401 -0
- package/dist/runtime/commands/worktree.js +133 -0
- package/dist/tools/apply-patch.js +314 -0
- package/dist/tools/file-tools.js +90 -0
- package/dist/tools/lsp-tools.js +189 -0
- package/dist/tools/registry.js +18 -0
- package/dist/tools/web-fetch.js +1 -1
- package/dist/tui/agent-tree-pane.js +9 -0
- package/dist/tui/ask-cli.js +52 -0
- package/dist/tui/ask-modal.js +211 -0
- package/dist/tui/conversation-pane.js +48 -3
- package/dist/tui/input-box.js +48 -5
- package/dist/tui/markdown-render.js +266 -0
- package/dist/tui/repl-render.js +185 -0
- package/dist/tui/repl-splash-mascot.js +130 -0
- package/dist/tui/repl-splash.js +7 -1
- package/dist/tui/repl.js +82 -11
- package/dist/tui/status-bar.js +63 -3
- package/dist/tui/tool-stream-pane.js +91 -0
- package/package.json +11 -5
|
@@ -27,18 +27,53 @@
|
|
|
27
27
|
* verbatim - the brand gate on those happens at the controller.
|
|
28
28
|
*/
|
|
29
29
|
import { randomUUID } from 'node:crypto';
|
|
30
|
+
import { getPersona } from '@pugi/personas';
|
|
30
31
|
import { listRoles, getPersonaForRole } from '../agents/registry.js';
|
|
31
32
|
import { evaluateCap, describeVerdict } from './cap-warning.js';
|
|
32
33
|
import { parseSlashCommand } from './slash-commands.js';
|
|
33
34
|
import { webFetchTool } from '../../tools/web-fetch.js';
|
|
34
35
|
import { loadSettings } from '../settings.js';
|
|
35
36
|
import { getJobRegistry } from '../jobs/registry.js';
|
|
37
|
+
import { extractAskTags, extractPlanReviewTags, signatureForAsk, } from './ask.js';
|
|
36
38
|
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
37
39
|
import { resolve as resolvePath } from 'node:path';
|
|
40
|
+
import { CancellationToken } from './cancellation.js';
|
|
41
|
+
import { DispatchFSM } from './dispatch-fsm.js';
|
|
38
42
|
const MAX_TRANSCRIPT_ROWS = 500;
|
|
43
|
+
const MAX_TOOL_CALLS = 200;
|
|
39
44
|
const MAX_RECONNECT_ATTEMPTS = 10;
|
|
40
45
|
const RECONNECT_BASE_MS = 250;
|
|
41
46
|
const RECONNECT_MAX_MS = 5_000;
|
|
47
|
+
/**
|
|
48
|
+
* α6.5 filewatch throttle: minimum gap between two file-change
|
|
49
|
+
* system lines surfaced in the conversation pane. Per the sprint
|
|
50
|
+
* spec, a noisy save burst should not flood the transcript - we
|
|
51
|
+
* coalesce all chokidar batches that arrive inside the window into
|
|
52
|
+
* a single "file changed: ..." line.
|
|
53
|
+
*/
|
|
54
|
+
const FILEWATCH_SYSTEM_LINE_GAP_MS = 5_000;
|
|
55
|
+
/**
|
|
56
|
+
* Hard cap on the size of `pendingFilewatchBatches`. The throttle window
|
|
57
|
+
* is 5s, but a `tsc --watch` style tool can fire dozens of `change`
|
|
58
|
+
* events per second for hours on end if the operator leaves the REPL
|
|
59
|
+
* up. Without a cap, every batch arriving inside the throttle window
|
|
60
|
+
* would accumulate forever, holding refs to thousands of FilewatchBatch
|
|
61
|
+
* objects (each carrying its own events array). On overflow we drop
|
|
62
|
+
* the OLDEST batch and surface a one-shot system warning so the
|
|
63
|
+
* operator knows the buffer is shedding. triple-review P1 (PR #380).
|
|
64
|
+
*/
|
|
65
|
+
const PENDING_FILEWATCH_BATCH_CAP = 100;
|
|
66
|
+
/**
|
|
67
|
+
* Cap on silent session-recreate attempts on HTTP 404 from the SSE
|
|
68
|
+
* stream. When admin-api restarts it drops its in-memory session
|
|
69
|
+
* store, so the saved sessionId returns 404 on every subscribe. The
|
|
70
|
+
* CLI mints a fresh server session, swaps the consumer over, and
|
|
71
|
+
* keeps running - but we cap the recovery to 3 attempts inside 60s
|
|
72
|
+
* so a truly down admin-api fails loud instead of spinning forever.
|
|
73
|
+
* (α6.14.2 wave 5 - CEO dogfood fix.)
|
|
74
|
+
*/
|
|
75
|
+
const MAX_SESSION_RECREATE_ATTEMPTS = 3;
|
|
76
|
+
const SESSION_RECREATE_WINDOW_MS = 60_000;
|
|
42
77
|
export class ReplSession {
|
|
43
78
|
options;
|
|
44
79
|
subscribers = new Set();
|
|
@@ -48,30 +83,257 @@ export class ReplSession {
|
|
|
48
83
|
reconnectAttempt = 0;
|
|
49
84
|
reconnectTimer;
|
|
50
85
|
closed = false;
|
|
86
|
+
/**
|
|
87
|
+
* Rolling window of recent silent-recreate timestamps (epoch ms).
|
|
88
|
+
* The SSE stream returns HTTP 404 when admin-api has restarted and
|
|
89
|
+
* lost its in-memory session store; rather than spam the operator
|
|
90
|
+
* with "Stream interrupted (HTTP 404)" loops, we mint a fresh
|
|
91
|
+
* session and swap the consumer. Capped at MAX_SESSION_RECREATE_*
|
|
92
|
+
* inside SESSION_RECREATE_WINDOW_MS so a permanently down admin-api
|
|
93
|
+
* fails loud instead of looping silently. (α6.14.2 wave 5.)
|
|
94
|
+
*/
|
|
95
|
+
recentRecreateAtMs = [];
|
|
96
|
+
/**
|
|
97
|
+
* True while a session-recreate POST is in flight. Guards against
|
|
98
|
+
* the SSE stream firing multiple `onError(404)` callbacks racing
|
|
99
|
+
* the in-flight createSession promise. (α6.14.2 wave 5.)
|
|
100
|
+
*/
|
|
101
|
+
recreatingSession = false;
|
|
51
102
|
/**
|
|
52
103
|
* Last non-trivial step.detail recorded per taskId. The server streams
|
|
53
104
|
* the persona reply incrementally via `agent.step` events whose
|
|
54
105
|
* `detail` field carries the cumulative model output. `agent.completed`
|
|
55
106
|
* arrives last and previously overwrote the visible detail to the
|
|
56
107
|
* literal string `'shipped'` while the transcript line said only
|
|
57
|
-
* `shipped.`
|
|
108
|
+
* `shipped.` - the actual reply text was lost. By caching the last
|
|
58
109
|
* non-trivial detail here, we can flush it into the transcript when
|
|
59
110
|
* the agent completes so the operator sees what the persona actually
|
|
60
111
|
* said. CEO wave-2 fix 2026-05-25.
|
|
61
112
|
*/
|
|
62
113
|
lastStepDetail = new Map();
|
|
114
|
+
/**
|
|
115
|
+
* Optional local SessionStore - α6.4. When non-null, every
|
|
116
|
+
* appendRow() call mirrors the row into the JSONL log so the
|
|
117
|
+
* conversation can be restored via `/resume`. Errors from the store
|
|
118
|
+
* are swallowed to a single system line (degradation, not crash).
|
|
119
|
+
* The store is opened by the CLI bootstrap and closed via
|
|
120
|
+
* `ReplSession.close()`. The store ownership is shared - the
|
|
121
|
+
* SqliteSessionStore is process-wide singleton-ish under the
|
|
122
|
+
* lockfile, so close-on-quit is safe.
|
|
123
|
+
*/
|
|
124
|
+
store;
|
|
125
|
+
/**
|
|
126
|
+
* Local session id used as the persistence key. Distinct from the
|
|
127
|
+
* server-side sessionId issued by admin-api in state.sessionId.
|
|
128
|
+
* When the operator runs `pugi resume <id>`, the CLI passes the id
|
|
129
|
+
* via `localSessionId` so the JSONL log keeps growing under the
|
|
130
|
+
* original id rather than fragmenting into a new one.
|
|
131
|
+
*/
|
|
132
|
+
localSessionId;
|
|
133
|
+
/**
|
|
134
|
+
* One-shot guard so a store error only emits ONE system line per
|
|
135
|
+
* session - without this, a stuck filesystem would spam the operator
|
|
136
|
+
* with `[store]` errors on every keystroke.
|
|
137
|
+
*/
|
|
138
|
+
storeErrorEmitted = false;
|
|
139
|
+
/**
|
|
140
|
+
* Privacy mode fetched on bootstrap from /api/admin/privacy/mode and
|
|
141
|
+
* surfaced via `renderPrivacyBanner` (one-line system message after
|
|
142
|
+
* splash). Cached on the session so the in-REPL `/privacy` slash
|
|
143
|
+
* command can render the live mode without a second round-trip on
|
|
144
|
+
* the input box's thread. Null means "not yet fetched" (still
|
|
145
|
+
* connecting) OR "fetch failed" (offline / unauthenticated). The
|
|
146
|
+
* `/privacy` slash falls back to the contract doc with an "unknown"
|
|
147
|
+
* banner when null.
|
|
148
|
+
*
|
|
149
|
+
* Triple-review P1 fix (2026-05-25): the prior build defined
|
|
150
|
+
* `renderPrivacyBanner` but never called it, and `/privacy` always
|
|
151
|
+
* rendered with `null` mode. The contract was advertised but the
|
|
152
|
+
* operator had no mode visibility.
|
|
153
|
+
*/
|
|
154
|
+
privacyMode = null;
|
|
155
|
+
/**
|
|
156
|
+
* α6.5 Tier 0 / Tier 1 / chokidar wiring. The bootstrap builds the
|
|
157
|
+
* skeleton + working set + watcher once and hands them to the
|
|
158
|
+
* session. The session uses them to:
|
|
159
|
+
*
|
|
160
|
+
* - render `/context` (count + cap + total bytes + skeleton size).
|
|
161
|
+
* - emit throttled "file changed" system lines on watcher batches.
|
|
162
|
+
* - forget removed files from the working set on `unlink`.
|
|
163
|
+
*
|
|
164
|
+
* All three are optional - tests and minimal callers pass null /
|
|
165
|
+
* undefined and the session degrades to "no three-tier integration"
|
|
166
|
+
* silently. The watcher's own lifecycle is owned by the bootstrap
|
|
167
|
+
* (we do NOT close it in `close()`).
|
|
168
|
+
*/
|
|
169
|
+
repoSkeleton;
|
|
170
|
+
workingSet;
|
|
171
|
+
watcher;
|
|
172
|
+
/**
|
|
173
|
+
* Epoch ms of the last filewatch system line. Initialised to 0 so
|
|
174
|
+
* the FIRST batch always emits; subsequent batches inside the gap
|
|
175
|
+
* are coalesced into the next emit window.
|
|
176
|
+
*/
|
|
177
|
+
lastFilewatchLineAtEpochMs = 0;
|
|
178
|
+
/**
|
|
179
|
+
* Buffer of batches whose emission was throttled. Drained on the
|
|
180
|
+
* next within-window batch by overwriting the throttled line with
|
|
181
|
+
* a summary that mentions how many additional files were touched.
|
|
182
|
+
* Capped at PENDING_FILEWATCH_BATCH_CAP to bound memory growth
|
|
183
|
+
* under long-running noisy filewatch sources (tsc --watch on a
|
|
184
|
+
* 200-file project hammering for hours). triple-review P1 (PR #380).
|
|
185
|
+
*/
|
|
186
|
+
pendingFilewatchBatches = [];
|
|
187
|
+
/**
|
|
188
|
+
* One-shot guard so the overflow-warning system line emits only once
|
|
189
|
+
* per session rather than spamming the operator with `[filewatch]
|
|
190
|
+
* shedding` on every dropped batch.
|
|
191
|
+
*/
|
|
192
|
+
pendingFilewatchOverflowWarned = false;
|
|
193
|
+
/**
|
|
194
|
+
* Bound subscriber refs so close() can detach the listeners from the
|
|
195
|
+
* shared watcher. The bootstrap owns the watcher lifecycle (it calls
|
|
196
|
+
* watcher.close() on REPL teardown), but the session MUST detach its
|
|
197
|
+
* own listeners on close() so any chokidar event landing between
|
|
198
|
+
* session.close() and watcher.close() does not run handlers on a
|
|
199
|
+
* dead session. Without detachment, recordFilewatchBatch would
|
|
200
|
+
* touch this.workingSet / this.transcript on a closed session.
|
|
201
|
+
* triple-review P1 (PR #380).
|
|
202
|
+
*/
|
|
203
|
+
filewatchBatchHandler = (batch) => {
|
|
204
|
+
this.recordFilewatchBatch(batch);
|
|
205
|
+
};
|
|
206
|
+
filewatchCapHandler = (info) => {
|
|
207
|
+
this.recordFilewatchCapExceeded(info);
|
|
208
|
+
};
|
|
209
|
+
/**
|
|
210
|
+
* Rolling dedupe set for `<pugi-ask>` and `<pugi-plan-review>`
|
|
211
|
+
* signatures. The persona may emit the same envelope twice on network
|
|
212
|
+
* retry; we suppress the duplicate so the operator does not see two
|
|
213
|
+
* stacked modals. Capped at 32 entries - generous for a real session,
|
|
214
|
+
* defensive against a hostile flood. (α6.3.)
|
|
215
|
+
*/
|
|
216
|
+
seenTagSignatures = [];
|
|
217
|
+
/**
|
|
218
|
+
* Per-task buffer for streaming tag detection. The persona's
|
|
219
|
+
* `<pugi-ask>` open and close tags may arrive in separate
|
|
220
|
+
* `agent.step` events when the upstream LLM token-streams output
|
|
221
|
+
* char-by-char. We accumulate the running detail per taskId until a
|
|
222
|
+
* complete envelope lands OR the turn ends. (α6.3.)
|
|
223
|
+
*/
|
|
224
|
+
askBuffer = new Map();
|
|
225
|
+
/**
|
|
226
|
+
* α6.9 dispatch FSM. One instance owned by the session; transitions
|
|
227
|
+
* are mirrored into `state.dispatchState` via an onEnter listener so
|
|
228
|
+
* subscribers see every change. Resets to `idle` after a terminal
|
|
229
|
+
* transition (`completed` / `failed` / `aborted`) so the next brief
|
|
230
|
+
* starts clean. Idle is the start state.
|
|
231
|
+
*/
|
|
232
|
+
// Not readonly: resetFsmToIdle swaps the instance in place on the
|
|
233
|
+
// next brief after a terminal transition (the FSM does not allow
|
|
234
|
+
// direct `completed -> awaiting_response`, so we mint a fresh
|
|
235
|
+
// machine). External read-only access stays via `getDispatchState()`
|
|
236
|
+
// accessor - callers cannot reach into this private field.
|
|
237
|
+
fsm = new DispatchFSM();
|
|
238
|
+
/**
|
|
239
|
+
* α6.9 cancellation token for the currently in-flight dispatch.
|
|
240
|
+
* Minted on `dispatchBrief()` and released on terminal transitions.
|
|
241
|
+
* When non-null, calling `cancel()` aborts the token, closes the SSE
|
|
242
|
+
* stream, and transitions the FSM to `aborting` → `aborted`.
|
|
243
|
+
*
|
|
244
|
+
* Null when `dispatchState === 'idle'` — no dispatch to cancel.
|
|
245
|
+
*/
|
|
246
|
+
currentDispatchToken = null;
|
|
247
|
+
/**
|
|
248
|
+
* R2 P1 fix (Codex triple-review 2026-05-25): monotonic dispatch
|
|
249
|
+
* sequence id. Incremented on every `dispatchBrief()`. The
|
|
250
|
+
* agent.spawned handler stamps the current value into
|
|
251
|
+
* `taskDispatchSeq[event.taskId]`. Terminal handlers
|
|
252
|
+
* (agent.completed / blocked / failed) check the stamped value
|
|
253
|
+
* against `dispatchSeq` and short-circuit if the event belongs to
|
|
254
|
+
* a SUPERSEDED dispatch. Without this gate a stale terminal event
|
|
255
|
+
* from a prior dispatch would drive the new FSM to terminal and
|
|
256
|
+
* null the new dispatch's token, leaving brief #2 uncancellable.
|
|
257
|
+
*/
|
|
258
|
+
dispatchSeq = 0;
|
|
259
|
+
/**
|
|
260
|
+
* Per-task dispatch-sequence stamp. Populated on agent.spawned with
|
|
261
|
+
* the live `dispatchSeq` value. Consulted by terminal handlers to
|
|
262
|
+
* decide whether the event belongs to the current dispatch (seq
|
|
263
|
+
* matches) or a superseded one (seq is stale). Entries are NOT
|
|
264
|
+
* removed on terminal events because a late event from the same
|
|
265
|
+
* task should still be classified correctly; the map is small
|
|
266
|
+
* (one entry per spawned subagent in the session lifetime) and
|
|
267
|
+
* cleared on close().
|
|
268
|
+
*/
|
|
269
|
+
taskDispatchSeq = new Map();
|
|
270
|
+
/**
|
|
271
|
+
* R3 P1 fix (Codex triple-review 2026-05-25): wall-clock guard used to
|
|
272
|
+
* drop SSE events whose `event.timestamp` predates the current
|
|
273
|
+
* dispatch. The R2 seq gate alone fails when a LATE `agent.spawned`
|
|
274
|
+
* from brief #1 arrives AFTER brief #2 mints a new dispatch token:
|
|
275
|
+
* the late spawn would stamp the OLD taskId with the NEW dispatchSeq
|
|
276
|
+
* (because `this.dispatchSeq` already advanced), so any subsequent
|
|
277
|
+
* terminal event for that task would look like it belongs to the new
|
|
278
|
+
* dispatch and would null the new token.
|
|
279
|
+
*
|
|
280
|
+
* Recorded inside `dispatchBrief()` BEFORE bumping `dispatchSeq`, so
|
|
281
|
+
* any event with `event.timestamp` strictly before this value is
|
|
282
|
+
* guaranteed to belong to a superseded dispatch and is dropped silently.
|
|
283
|
+
* Zero means "no dispatch has started yet" and disables the gate.
|
|
284
|
+
*/
|
|
285
|
+
currentDispatchStartTime = 0;
|
|
286
|
+
/**
|
|
287
|
+
* Tracks taskIds that had an `<pugi-ask>` or `<pugi-plan-review>`
|
|
288
|
+
* envelope mid-stream the last time the parser ran on the buffer. If
|
|
289
|
+
* the turn ends with this flag still set, we emit a system-line
|
|
290
|
+
* warning that the persona produced an incomplete tag - the partial
|
|
291
|
+
* XML is silently dropped (the parser already withheld it from the
|
|
292
|
+
* cleaned body). Codex triple-review P2 (PR #375).
|
|
293
|
+
*/
|
|
294
|
+
askBufferPending = new Set();
|
|
63
295
|
constructor(options) {
|
|
64
296
|
this.options = options;
|
|
297
|
+
this.store = options.store ?? null;
|
|
298
|
+
this.localSessionId = options.localSessionId;
|
|
299
|
+
this.repoSkeleton = options.repoSkeleton ?? null;
|
|
300
|
+
this.workingSet = options.workingSet ?? null;
|
|
301
|
+
this.watcher = options.watcher ?? null;
|
|
302
|
+
// Subscribe to the chokidar watcher when present. Late-binding
|
|
303
|
+
// happens here so the bootstrap can construct the session and
|
|
304
|
+
// attach the watcher in one pass without re-validating shape.
|
|
305
|
+
if (this.watcher) {
|
|
306
|
+
this.watcher.on('batch', this.filewatchBatchHandler);
|
|
307
|
+
this.watcher.on('capExceeded', this.filewatchCapHandler);
|
|
308
|
+
}
|
|
65
309
|
this.state = {
|
|
66
310
|
sessionId: undefined,
|
|
67
311
|
workspaceLabel: options.workspaceLabel,
|
|
68
312
|
cliVersion: options.cliVersion,
|
|
69
313
|
connection: 'connecting',
|
|
70
314
|
agents: [],
|
|
315
|
+
toolCalls: [],
|
|
71
316
|
transcript: [],
|
|
72
317
|
tokensDownstreamTotal: 0,
|
|
73
318
|
briefStartedAtEpochMs: undefined,
|
|
319
|
+
pendingAsk: null,
|
|
320
|
+
pendingAskSource: null,
|
|
321
|
+
pendingPlanReview: null,
|
|
322
|
+
pendingPlanReviewSource: null,
|
|
323
|
+
dispatchState: 'idle',
|
|
324
|
+
dispatchToolLabel: null,
|
|
74
325
|
};
|
|
326
|
+
// α6.9: mirror every FSM transition into the public state so the
|
|
327
|
+
// status-bar surface can rerender on the next frame. Local listener
|
|
328
|
+
// is intentionally cheap — just a patch + clear the per-state tool
|
|
329
|
+
// label when leaving `tool_running`.
|
|
330
|
+
this.fsm.onEnter('idle', () => this.patch({ dispatchState: 'idle', dispatchToolLabel: null }));
|
|
331
|
+
this.fsm.onEnter('awaiting_response', () => this.patch({ dispatchState: 'awaiting_response', dispatchToolLabel: null }));
|
|
332
|
+
this.fsm.onEnter('tool_running', () => this.patch({ dispatchState: 'tool_running' }));
|
|
333
|
+
this.fsm.onEnter('aborting', () => this.patch({ dispatchState: 'aborting', dispatchToolLabel: null }));
|
|
334
|
+
this.fsm.onEnter('aborted', () => this.patch({ dispatchState: 'aborted', dispatchToolLabel: null }));
|
|
335
|
+
this.fsm.onEnter('completed', () => this.patch({ dispatchState: 'completed', dispatchToolLabel: null }));
|
|
336
|
+
this.fsm.onEnter('failed', () => this.patch({ dispatchState: 'failed', dispatchToolLabel: null }));
|
|
75
337
|
}
|
|
76
338
|
/* ------------- subscribe / state -------------- */
|
|
77
339
|
subscribe(callback) {
|
|
@@ -100,18 +362,72 @@ export class ReplSession {
|
|
|
100
362
|
});
|
|
101
363
|
this.patch({ sessionId, connection: 'connecting' });
|
|
102
364
|
this.openStream();
|
|
365
|
+
// alpha 6.13 privacy banner. Fire-and-forget - never blocks the
|
|
366
|
+
// input box on the network round-trip. The banner is a single
|
|
367
|
+
// system-line so the operator sees the active mode under the
|
|
368
|
+
// splash without an extra slash command. Mode is cached on the
|
|
369
|
+
// session so `/privacy` later renders the live value without a
|
|
370
|
+
// second fetch. Failure to fetch (offline, unauthenticated,
|
|
371
|
+
// admin-api down) is silent - the operator can still type
|
|
372
|
+
// `/privacy` to see the contract.
|
|
373
|
+
void this.fetchAndAnnouncePrivacyMode().catch(() => undefined);
|
|
103
374
|
}
|
|
104
375
|
catch (error) {
|
|
105
376
|
this.appendSystemLine(`Could not open Pugi session: ${this.errorMessage(error)}`);
|
|
106
377
|
this.patch({ connection: 'offline' });
|
|
107
378
|
}
|
|
108
379
|
}
|
|
380
|
+
/**
|
|
381
|
+
* Fetch the tenant's current privacy mode from
|
|
382
|
+
* `GET /api/admin/privacy/mode`, cache it on the session, and emit
|
|
383
|
+
* a one-line system banner so the operator sees their active mode
|
|
384
|
+
* right under the bootstrap splash. Failure is silent - missing
|
|
385
|
+
* banner is preferable to a noisy "could not fetch privacy mode"
|
|
386
|
+
* line on every login.
|
|
387
|
+
*
|
|
388
|
+
* Triple-review P1 fix (2026-05-25): without this call,
|
|
389
|
+
* `renderPrivacyBanner` was defined but never reached the wire, and
|
|
390
|
+
* `/privacy` always rendered with `null` mode.
|
|
391
|
+
*/
|
|
392
|
+
async fetchAndAnnouncePrivacyMode() {
|
|
393
|
+
const { renderPrivacyBanner, isPrivacyMode } = await import('./privacy-banner.js');
|
|
394
|
+
try {
|
|
395
|
+
const url = `${this.options.apiUrl.replace(/\/+$/, '')}/api/admin/privacy/mode`;
|
|
396
|
+
const res = await fetch(url, {
|
|
397
|
+
headers: {
|
|
398
|
+
authorization: `Bearer ${this.options.apiKey}`,
|
|
399
|
+
accept: 'application/json',
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
if (!res.ok) {
|
|
403
|
+
// Silent fail - banner is decoration, not a blocking surface.
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const payload = (await res.json());
|
|
407
|
+
const mode = payload.mode;
|
|
408
|
+
if (typeof mode === 'string' && isPrivacyMode(mode)) {
|
|
409
|
+
this.privacyMode = mode;
|
|
410
|
+
this.appendSystemLine(renderPrivacyBanner(mode));
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
// Silent fail - offline / DNS / unauth all collapse to no banner.
|
|
415
|
+
}
|
|
416
|
+
}
|
|
109
417
|
/**
|
|
110
418
|
* Tear down the SSE stream and stop the reconnect timer. The session
|
|
111
419
|
* id stays valid server-side; `pugi resume <id>` reopens later.
|
|
112
420
|
*/
|
|
113
421
|
close() {
|
|
114
422
|
this.closed = true;
|
|
423
|
+
// α6.9: fire the cancellation token before tearing down the stream
|
|
424
|
+
// so any in-flight tool sees the abort signal AND any pending
|
|
425
|
+
// PostBrief promise can short-circuit. Idempotent — token.abort()
|
|
426
|
+
// is a no-op when already aborted.
|
|
427
|
+
if (this.currentDispatchToken) {
|
|
428
|
+
this.currentDispatchToken.abort();
|
|
429
|
+
this.currentDispatchToken = null;
|
|
430
|
+
}
|
|
115
431
|
if (this.streamHandle) {
|
|
116
432
|
this.streamHandle.close();
|
|
117
433
|
this.streamHandle = undefined;
|
|
@@ -120,6 +436,116 @@ export class ReplSession {
|
|
|
120
436
|
clearTimeout(this.reconnectTimer);
|
|
121
437
|
this.reconnectTimer = undefined;
|
|
122
438
|
}
|
|
439
|
+
// R2 P1: drop the per-task seq stamps on close(). The map is
|
|
440
|
+
// small but clearing prevents accidental seq-comparison drift if
|
|
441
|
+
// a resurrected session reuses taskIds (admin-api currently mints
|
|
442
|
+
// unique ids, but the gate stays robust on either contract).
|
|
443
|
+
this.taskDispatchSeq.clear();
|
|
444
|
+
// Detach watcher listeners so any chokidar event landing between
|
|
445
|
+
// session.close() and the bootstrap-owned watcher.close() does NOT
|
|
446
|
+
// run a handler on a dead session. The handlers themselves also
|
|
447
|
+
// hard-guard on `this.closed`, but detaching is the load-bearing
|
|
448
|
+
// fix - it severs the strong reference the watcher held on the
|
|
449
|
+
// session callback, which otherwise blocks GC. triple-review P1 (PR #380).
|
|
450
|
+
if (this.watcher) {
|
|
451
|
+
this.watcher.off('batch', this.filewatchBatchHandler);
|
|
452
|
+
this.watcher.off('capExceeded', this.filewatchCapHandler);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
/* ------------- α6.9 cancellation surface -------------- */
|
|
456
|
+
/**
|
|
457
|
+
* Operator-driven abort for the in-flight dispatch. Idempotent — a
|
|
458
|
+
* second call while already in `aborting` / `aborted` is a no-op.
|
|
459
|
+
*
|
|
460
|
+
* Steps (in order):
|
|
461
|
+
*
|
|
462
|
+
* 1. Snapshot the current state. If terminal or idle, no-op.
|
|
463
|
+
* 2. Transition the FSM to `aborting` so the bottom-bar shows the
|
|
464
|
+
* pending shutdown immediately (the operator gets feedback
|
|
465
|
+
* before any IO completes).
|
|
466
|
+
* 3. Abort the cancellation token. This fans out to every listener
|
|
467
|
+
* that was attached during the dispatch — chiefly the SSE
|
|
468
|
+
* stream wrapper (which calls `streamHandle.close()`) and any
|
|
469
|
+
* mid-flight tool executor that polled `isAborted`.
|
|
470
|
+
* 4. Append a system line so the conversation reads "Aborted." at
|
|
471
|
+
* the operator's last input position.
|
|
472
|
+
* 5. Transition to `aborted` (terminal). The next operator brief
|
|
473
|
+
* mints a fresh token + transitions back to
|
|
474
|
+
* `awaiting_response`.
|
|
475
|
+
*
|
|
476
|
+
* Returns `true` when an abort was actually issued (state was
|
|
477
|
+
* non-terminal + non-idle), `false` otherwise.
|
|
478
|
+
*/
|
|
479
|
+
cancel() {
|
|
480
|
+
const current = this.fsm.current;
|
|
481
|
+
if (this.fsm.isTerminal || current === 'idle')
|
|
482
|
+
return false;
|
|
483
|
+
// Step 2: transient state (UI sees `aborting` between abort signal
|
|
484
|
+
// and full shutdown).
|
|
485
|
+
this.fsm.transition('aborting', 'operator_abort');
|
|
486
|
+
// Step 3: fire the token so any mid-flight tool executor that
|
|
487
|
+
// polled `isAborted` shuts down. Token is single-use — clear the
|
|
488
|
+
// ref AFTER both the abort fan-out AND the stream teardown so any
|
|
489
|
+
// onAbort listener calling getCurrentDispatchToken() during the
|
|
490
|
+
// teardown observes the (now-aborted) token rather than null.
|
|
491
|
+
// Contract: listeners fire first, stream closes second, token
|
|
492
|
+
// field nulls third. (P2 cancel-ordering fix.)
|
|
493
|
+
const token = this.currentDispatchToken;
|
|
494
|
+
if (token) {
|
|
495
|
+
token.abort();
|
|
496
|
+
}
|
|
497
|
+
// Step 3b: tear down the SSE stream so no further `agent.step`
|
|
498
|
+
// events update the agent tree under the aborted dispatch. The
|
|
499
|
+
// stream reopens on the NEXT brief via `openStream()` (driven by
|
|
500
|
+
// dispatchBrief's reset-to-idle path). The session id stays valid
|
|
501
|
+
// server-side; admin-api keeps the dispatch around for forensic
|
|
502
|
+
// replay but the client stops listening.
|
|
503
|
+
if (this.streamHandle) {
|
|
504
|
+
this.streamHandle.close();
|
|
505
|
+
this.streamHandle = undefined;
|
|
506
|
+
}
|
|
507
|
+
// P2 Codex: clear the SSE cursor on deliberate cancel. The
|
|
508
|
+
// admin-api Last-Event-ID replay would otherwise re-feed any
|
|
509
|
+
// buffered events from the cancelled dispatch into the next
|
|
510
|
+
// brief's stream, polluting the agent tree with ghosts of the
|
|
511
|
+
// aborted turn. The new brief mints a fresh server-side cursor
|
|
512
|
+
// anyway, so `undefined` is the correct subscribe arg.
|
|
513
|
+
this.lastEventId = undefined;
|
|
514
|
+
// Null the token AFTER stream teardown (see step 3 comment).
|
|
515
|
+
this.currentDispatchToken = null;
|
|
516
|
+
// Mark any agents that are still "running" as failed/aborted so
|
|
517
|
+
// the agent-tree pane reflects reality. We use the existing
|
|
518
|
+
// `failed` status (the tree pane already knows how to render it)
|
|
519
|
+
// with a clear detail string so the operator sees WHY.
|
|
520
|
+
this.patch({
|
|
521
|
+
agents: this.state.agents.map((a) => a.status === 'queued' || a.status === 'thinking'
|
|
522
|
+
? { ...a, status: 'failed', detail: 'aborted by operator' }
|
|
523
|
+
: a),
|
|
524
|
+
});
|
|
525
|
+
// Step 4: visible operator-facing confirmation. Brand voice gate:
|
|
526
|
+
// single ASCII line, no em-dashes, no emoji.
|
|
527
|
+
this.appendSystemLine('Aborted.');
|
|
528
|
+
// Step 5: terminal transition. The FSM will accept a fresh
|
|
529
|
+
// dispatch on the next brief.
|
|
530
|
+
this.fsm.transition('aborted', 'operator_abort');
|
|
531
|
+
// Clear briefStartedAtEpochMs so the status-bar clock resets.
|
|
532
|
+
this.patch({ briefStartedAtEpochMs: undefined });
|
|
533
|
+
return true;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Current FSM state. Surfaced for the REPL UI to read on first paint
|
|
537
|
+
* (subscribe + initial state are decoupled).
|
|
538
|
+
*/
|
|
539
|
+
getDispatchState() {
|
|
540
|
+
return this.fsm.current;
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Current cancellation token. Returned for the tool execution path
|
|
544
|
+
* (file-tools.ts) so it can pass the token down into a ToolContext
|
|
545
|
+
* extension. Null when no dispatch is in flight.
|
|
546
|
+
*/
|
|
547
|
+
getCurrentDispatchToken() {
|
|
548
|
+
return this.currentDispatchToken;
|
|
123
549
|
}
|
|
124
550
|
/* ------------- input handling -------------- */
|
|
125
551
|
/**
|
|
@@ -183,12 +609,109 @@ export class ReplSession {
|
|
|
183
609
|
this.dispatchStatus();
|
|
184
610
|
return verdict;
|
|
185
611
|
}
|
|
612
|
+
case 'consensus': {
|
|
613
|
+
// alpha 6.7: surface a deterministic deep-link so the operator
|
|
614
|
+
// knows the command exists in the REPL palette even though the
|
|
615
|
+
// full SSE renderer ships outside the Ink frame. Running the
|
|
616
|
+
// gate live inside the REPL needs the non-TTY emit path; for
|
|
617
|
+
// M1 we point the operator at the shell command.
|
|
618
|
+
const tail = verdict.ref ? ` ${verdict.ref}` : '';
|
|
619
|
+
this.appendSystemLine(`Run \`pugi review --consensus${tail}\` from a fresh shell to dispatch the 3-model gate.`);
|
|
620
|
+
return verdict;
|
|
621
|
+
}
|
|
622
|
+
case 'resume': {
|
|
623
|
+
await this.dispatchResume();
|
|
624
|
+
return verdict;
|
|
625
|
+
}
|
|
626
|
+
case 'context': {
|
|
627
|
+
this.dispatchContext();
|
|
628
|
+
return verdict;
|
|
629
|
+
}
|
|
630
|
+
case 'ask': {
|
|
631
|
+
// α6.3: synthesise a local yes/no `<pugi-ask>` modal so the
|
|
632
|
+
// operator can exercise the question UI without a persona-side
|
|
633
|
+
// round trip. The REPL UI mounts the modal from the resulting
|
|
634
|
+
// `pendingAsk` state; on resolution the encoded verdict lands
|
|
635
|
+
// in the transcript as a system line (no admin-api dispatch
|
|
636
|
+
// because the question is local).
|
|
637
|
+
const askTag = synthesiseLocalAskTag(verdict.question);
|
|
638
|
+
if (!askTag) {
|
|
639
|
+
this.appendSystemLine('Could not synthesise local ask (question too long?). Cap is 80 chars.');
|
|
640
|
+
return verdict;
|
|
641
|
+
}
|
|
642
|
+
this.patch({ pendingAsk: askTag, pendingAskSource: 'local' });
|
|
643
|
+
return verdict;
|
|
644
|
+
}
|
|
645
|
+
case 'privacy': {
|
|
646
|
+
// alpha 6.13: print the full mode contract + current banner
|
|
647
|
+
// inline. The current mode is resolved lazily by the helper -
|
|
648
|
+
// when unauthenticated or offline the banner falls back to
|
|
649
|
+
// "(unknown - mode lookup pending)" and the contract doc still
|
|
650
|
+
// renders so the operator can read the alternatives.
|
|
651
|
+
await this.dispatchPrivacy();
|
|
652
|
+
return verdict;
|
|
653
|
+
}
|
|
186
654
|
case 'stub': {
|
|
187
655
|
this.appendSystemLine(verdict.message);
|
|
188
656
|
return verdict;
|
|
189
657
|
}
|
|
190
658
|
}
|
|
191
659
|
}
|
|
660
|
+
/**
|
|
661
|
+
* In-REPL `/privacy` - alpha 6.13. Prints the full 3-mode contract
|
|
662
|
+
* doc + the current mode banner inline. The current mode is fetched
|
|
663
|
+
* via the admin-api /api/admin/privacy/mode endpoint when the
|
|
664
|
+
* operator is authenticated; otherwise the banner falls back to
|
|
665
|
+
* "(unknown)" and the contract doc still renders so the operator
|
|
666
|
+
* can compare modes without leaving the REPL.
|
|
667
|
+
*/
|
|
668
|
+
async dispatchPrivacy() {
|
|
669
|
+
const { renderPrivacyContractDoc } = await import('./privacy-banner.js');
|
|
670
|
+
// Triple-review P1 fix (2026-05-25): use the bootstrap-cached mode
|
|
671
|
+
// so the operator sees the LIVE current mode in the banner header
|
|
672
|
+
// instead of "(unknown)". The fetch happens once on session start;
|
|
673
|
+
// if it failed (offline / unauth) the cache stays null and the
|
|
674
|
+
// banner falls back to "(unknown)" - same UX as before, just with
|
|
675
|
+
// the happy path actually delivering the mode.
|
|
676
|
+
const doc = renderPrivacyContractDoc(this.privacyMode);
|
|
677
|
+
this.appendSystemLine(doc);
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* In-REPL `/resume` - α6.4. Lists the 10 most recent sessions from
|
|
681
|
+
* the local SessionStore and prints them as a numbered system menu.
|
|
682
|
+
* The Ink-side picker UI is deferred to the next sprint; today the
|
|
683
|
+
* operator gets a deterministic list + the exact command to relaunch
|
|
684
|
+
* with: `pugi resume <id>`. Keeping the picker out of the REPL
|
|
685
|
+
* frame avoids re-architecting the conversation pane mid-sprint and
|
|
686
|
+
* keeps `/resume` testable without an Ink runtime.
|
|
687
|
+
*/
|
|
688
|
+
async dispatchResume() {
|
|
689
|
+
if (!this.store) {
|
|
690
|
+
this.appendSystemLine('Local session store is disabled - /resume is unavailable.');
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
let rows;
|
|
694
|
+
try {
|
|
695
|
+
rows = await this.store.listSessions({ limit: 10 });
|
|
696
|
+
}
|
|
697
|
+
catch (error) {
|
|
698
|
+
this.appendSystemLine(`Could not list sessions: ${this.errorMessage(error)}`);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
if (rows.length === 0) {
|
|
702
|
+
this.appendSystemLine('No stored sessions yet - keep dispatching to build history.');
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
this.appendSystemLine(`Recent sessions (${rows.length}):`);
|
|
706
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
707
|
+
const row = rows[i];
|
|
708
|
+
const title = (row.title ?? '(untitled)').slice(0, 64);
|
|
709
|
+
const idShort = row.id.slice(0, 13);
|
|
710
|
+
const branch = row.branch ?? 'no-branch';
|
|
711
|
+
this.appendSystemLine(` ${(i + 1).toString().padStart(2)}. ${idShort} ${branch.padEnd(16)} ${title}`);
|
|
712
|
+
}
|
|
713
|
+
this.appendSystemLine('Pick one with: pugi resume <id> (paste the 13-char id from above).');
|
|
714
|
+
}
|
|
192
715
|
/**
|
|
193
716
|
* Reset the conversation transcript. The agent registry stays intact
|
|
194
717
|
* so the operator can `/clear` to declutter the chat pane without
|
|
@@ -197,6 +720,173 @@ export class ReplSession {
|
|
|
197
720
|
clearTranscript() {
|
|
198
721
|
this.patch({ transcript: [] });
|
|
199
722
|
}
|
|
723
|
+
/* ------------- α6.3 office-hours surface -------------- */
|
|
724
|
+
/**
|
|
725
|
+
* Surface an `<pugi-ask>` modal manually. Returned promise resolves
|
|
726
|
+
* with the operator's verdict - used by the `pugi ask "<q>"` shell
|
|
727
|
+
* command and by the `/ask` slash. The resolver is wired into the
|
|
728
|
+
* session state via `pendingAsk` so the REPL UI can render the modal
|
|
729
|
+
* and forward `onResolve` back through `resolveAsk()`.
|
|
730
|
+
*
|
|
731
|
+
* NOTE: idempotent on a duplicate signature - a second presentAsk
|
|
732
|
+
* with the same question + option values returns the first
|
|
733
|
+
* outstanding promise rather than stacking two modals.
|
|
734
|
+
*/
|
|
735
|
+
presentAsk(tag) {
|
|
736
|
+
if (this.outstandingAskPromise
|
|
737
|
+
&& this.state.pendingAsk?.signature === tag.signature) {
|
|
738
|
+
// The operator is already looking at this exact ask; reuse the
|
|
739
|
+
// outstanding promise so the second caller sees the same answer
|
|
740
|
+
// when it eventually arrives.
|
|
741
|
+
return this.outstandingAskPromise;
|
|
742
|
+
}
|
|
743
|
+
// If a DIFFERENT ask is open, reject the new one with a clear
|
|
744
|
+
// error rather than silently queueing - the persona should never
|
|
745
|
+
// emit two concurrent asks, and surfacing the bug fails loud.
|
|
746
|
+
if (this.outstandingAskPromise) {
|
|
747
|
+
return Promise.reject(new Error('presentAsk: another ask is already pending. Resolve it first.'));
|
|
748
|
+
}
|
|
749
|
+
this.patch({ pendingAsk: tag, pendingAskSource: 'persona' });
|
|
750
|
+
const promise = new Promise((resolve) => {
|
|
751
|
+
this.outstandingAskResolver = resolve;
|
|
752
|
+
});
|
|
753
|
+
this.outstandingAskPromise = promise;
|
|
754
|
+
return promise;
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Resolve the currently pending `<pugi-ask>` modal. Called by the
|
|
758
|
+
* REPL UI when the operator submits the modal. Appends an operator
|
|
759
|
+
* line to the transcript carrying the verdict, dispatches the
|
|
760
|
+
* verdict-encoded brief to admin-api as the next user turn (when the
|
|
761
|
+
* modal originated from a persona stream), and clears `pendingAsk`.
|
|
762
|
+
* Idempotent: a second call without a fresh `pendingAsk` is a no-op.
|
|
763
|
+
*
|
|
764
|
+
* The verdict is also forwarded to the resolver returned by
|
|
765
|
+
* `presentAsk()` if there is one outstanding, so the CLI's `pugi ask`
|
|
766
|
+
* command can await the answer.
|
|
767
|
+
*
|
|
768
|
+
* Cancellation contract (Claude triple-review P1): when the modal
|
|
769
|
+
* came from a persona stream, cancel ALSO dispatches a literal
|
|
770
|
+
* `[ASK-RESPONSE:cancelled]` to admin-api so the persona observes the
|
|
771
|
+
* cancellation rather than hanging indefinitely on the missing
|
|
772
|
+
* follow-up. The matching documentation in the Mira system prompt
|
|
773
|
+
* teaches the persona to acknowledge cancellation and offer a
|
|
774
|
+
* different path. Local-origin modals (synthesised via `/ask`) skip
|
|
775
|
+
* the dispatch entirely - the persona never saw the question.
|
|
776
|
+
*
|
|
777
|
+
* Free-text sanitisation (Claude triple-review P1): the operator's
|
|
778
|
+
* customInput is stripped of any leading `[ASK-RESPONSE:...]` /
|
|
779
|
+
* `[PLAN-VERDICT:...]` prefix before being encoded, so a malicious
|
|
780
|
+
* (or accidental) operator string cannot forge a verdict header that
|
|
781
|
+
* a prefix-greedy persona would misinterpret as a different choice.
|
|
782
|
+
*/
|
|
783
|
+
async resolveAsk(verdict) {
|
|
784
|
+
if (!this.state.pendingAsk)
|
|
785
|
+
return;
|
|
786
|
+
const tag = this.state.pendingAsk;
|
|
787
|
+
const source = this.state.pendingAskSource;
|
|
788
|
+
const sanitisedVerdict = sanitiseAskVerdict(verdict);
|
|
789
|
+
this.patch({ pendingAsk: null, pendingAskSource: null });
|
|
790
|
+
const encoded = encodeAskVerdictLocal(sanitisedVerdict);
|
|
791
|
+
// Tell the outstanding presentAsk caller, if any. The sanitised
|
|
792
|
+
// verdict is forwarded so downstream consumers cannot be tricked
|
|
793
|
+
// by a forged verdict header either.
|
|
794
|
+
if (this.outstandingAskResolver) {
|
|
795
|
+
const resolver = this.outstandingAskResolver;
|
|
796
|
+
this.outstandingAskResolver = undefined;
|
|
797
|
+
this.outstandingAskPromise = undefined;
|
|
798
|
+
resolver(sanitisedVerdict);
|
|
799
|
+
}
|
|
800
|
+
// Surface the operator's choice as a transcript row so the
|
|
801
|
+
// conversation reads linearly. The label of the chosen option
|
|
802
|
+
// (or the literal custom input) is more readable than the bare
|
|
803
|
+
// value - Codex CLI's "you chose: Vercel" pattern.
|
|
804
|
+
const humanLabel = humanLabelForVerdict(tag, sanitisedVerdict);
|
|
805
|
+
this.appendOperatorLine(humanLabel);
|
|
806
|
+
// Local-origin modals (operator typed `/ask`) never need an
|
|
807
|
+
// admin-api round trip - the persona never observed the question.
|
|
808
|
+
// Codex triple-review P2.
|
|
809
|
+
if (source !== 'persona')
|
|
810
|
+
return;
|
|
811
|
+
// Persona-origin modals always dispatch, including on cancel:
|
|
812
|
+
// without the cancellation echo the persona's last turn stays open
|
|
813
|
+
// and the agent hangs. Claude triple-review P1.
|
|
814
|
+
await this.dispatchAskFollowup(encoded);
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Same shape as `presentAsk` for the plan-review modal.
|
|
818
|
+
*/
|
|
819
|
+
presentPlanReview(tag) {
|
|
820
|
+
if (this.outstandingPlanReviewPromise
|
|
821
|
+
&& this.state.pendingPlanReview?.signature === tag.signature) {
|
|
822
|
+
return this.outstandingPlanReviewPromise;
|
|
823
|
+
}
|
|
824
|
+
if (this.outstandingPlanReviewPromise) {
|
|
825
|
+
return Promise.reject(new Error('presentPlanReview: another plan review is already pending.'));
|
|
826
|
+
}
|
|
827
|
+
this.patch({ pendingPlanReview: tag, pendingPlanReviewSource: 'persona' });
|
|
828
|
+
const promise = new Promise((resolve) => {
|
|
829
|
+
this.outstandingPlanReviewResolver = resolve;
|
|
830
|
+
});
|
|
831
|
+
this.outstandingPlanReviewPromise = promise;
|
|
832
|
+
return promise;
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Resolve the currently pending `<pugi-plan-review>` modal. Same
|
|
836
|
+
* mechanics as `resolveAsk` - including the cancel-dispatch contract
|
|
837
|
+
* and modifyText sanitisation. The persona always sees a
|
|
838
|
+
* `[PLAN-VERDICT:...]` echo (even on cancel) so it never hangs
|
|
839
|
+
* waiting for the verdict that the operator declined to send.
|
|
840
|
+
*/
|
|
841
|
+
async resolvePlanReview(result) {
|
|
842
|
+
if (!this.state.pendingPlanReview)
|
|
843
|
+
return;
|
|
844
|
+
const source = this.state.pendingPlanReviewSource;
|
|
845
|
+
const sanitisedResult = sanitisePlanReviewResult(result);
|
|
846
|
+
this.patch({ pendingPlanReview: null, pendingPlanReviewSource: null });
|
|
847
|
+
const encoded = encodePlanReviewVerdictLocal(sanitisedResult);
|
|
848
|
+
if (this.outstandingPlanReviewResolver) {
|
|
849
|
+
const resolver = this.outstandingPlanReviewResolver;
|
|
850
|
+
this.outstandingPlanReviewResolver = undefined;
|
|
851
|
+
this.outstandingPlanReviewPromise = undefined;
|
|
852
|
+
resolver(sanitisedResult);
|
|
853
|
+
}
|
|
854
|
+
this.appendOperatorLine(humanLabelForPlanReviewVerdict(sanitisedResult));
|
|
855
|
+
// Local-origin plan reviews skip the dispatch (Codex P2). Persona
|
|
856
|
+
// origin always dispatches, including on cancel (Claude P1).
|
|
857
|
+
if (source !== 'persona')
|
|
858
|
+
return;
|
|
859
|
+
await this.dispatchAskFollowup(encoded);
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Internal: post the verdict-encoded brief WITHOUT going through the
|
|
863
|
+
* cap-warning gate. The follow-up is the natural continuation of the
|
|
864
|
+
* same conversation the persona started, so blocking it on capacity
|
|
865
|
+
* would strand the operator with no way to answer.
|
|
866
|
+
*/
|
|
867
|
+
async dispatchAskFollowup(encodedBrief) {
|
|
868
|
+
const sessionId = this.state.sessionId;
|
|
869
|
+
if (!sessionId) {
|
|
870
|
+
this.appendSystemLine('No server session - response queued locally.');
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
try {
|
|
874
|
+
await this.options.transport.postBrief({
|
|
875
|
+
apiUrl: this.options.apiUrl,
|
|
876
|
+
apiKey: this.options.apiKey,
|
|
877
|
+
sessionId,
|
|
878
|
+
brief: encodedBrief,
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
catch (error) {
|
|
882
|
+
this.appendSystemLine(`Could not forward response: ${this.errorMessage(error)}`);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
// ----- Outstanding resolver bookkeeping for presentAsk / presentPlanReview -----
|
|
886
|
+
outstandingAskResolver;
|
|
887
|
+
outstandingAskPromise;
|
|
888
|
+
outstandingPlanReviewResolver;
|
|
889
|
+
outstandingPlanReviewPromise;
|
|
200
890
|
/* ------------- Tier 1 / Tier 2 wired handlers -------------- */
|
|
201
891
|
async dispatchJobs() {
|
|
202
892
|
try {
|
|
@@ -265,6 +955,129 @@ export class ReplSession {
|
|
|
265
955
|
this.appendSystemLine(`Workspace: ${this.state.workspaceLabel}.`);
|
|
266
956
|
this.appendSystemLine(`CLI: pugi ${this.state.cliVersion}.`);
|
|
267
957
|
}
|
|
958
|
+
/**
|
|
959
|
+
* α6.5 `/context` slash handler. Surfaces the three-tier context
|
|
960
|
+
* summary as a stack of system lines. Sections (in order):
|
|
961
|
+
*
|
|
962
|
+
* 1. Tier 0 (repo skeleton) - size in bytes, branch, package
|
|
963
|
+
* manager, languages. Skipped when no skeleton was injected
|
|
964
|
+
* (REPL launched outside a workspace or with --no-context).
|
|
965
|
+
*
|
|
966
|
+
* 2. Tier 1 (working set) - `count / capacity` plus the total
|
|
967
|
+
* size in bytes plus the oldest entry's age in seconds.
|
|
968
|
+
* Always emits even when empty so the operator can confirm
|
|
969
|
+
* the tier is wired.
|
|
970
|
+
*
|
|
971
|
+
* 3. Tier 2 (RAG) - one-line heads-up that the Anvil-side
|
|
972
|
+
* workspace lands in α6.5b.
|
|
973
|
+
*
|
|
974
|
+
* The renderer never mutates state.
|
|
975
|
+
*/
|
|
976
|
+
dispatchContext() {
|
|
977
|
+
if (this.repoSkeleton) {
|
|
978
|
+
const parts = [`Tier 0 skeleton: ${this.repoSkeleton.totalSize} bytes`];
|
|
979
|
+
if (this.repoSkeleton.branch)
|
|
980
|
+
parts.push(`branch ${this.repoSkeleton.branch}`);
|
|
981
|
+
if (this.repoSkeleton.packageManager)
|
|
982
|
+
parts.push(this.repoSkeleton.packageManager);
|
|
983
|
+
if (this.repoSkeleton.primaryLanguages.length > 0) {
|
|
984
|
+
parts.push(`langs ${this.repoSkeleton.primaryLanguages.slice(0, 3).join('/')}`);
|
|
985
|
+
}
|
|
986
|
+
this.appendSystemLine(parts.join(' - '));
|
|
987
|
+
}
|
|
988
|
+
else {
|
|
989
|
+
this.appendSystemLine('Tier 0 skeleton: not loaded (run pugi init or launch in a workspace).');
|
|
990
|
+
}
|
|
991
|
+
if (this.workingSet) {
|
|
992
|
+
const summary = this.workingSet.summary();
|
|
993
|
+
const ageLine = summary.oldestTouchedAtEpochMs !== null
|
|
994
|
+
? ` - oldest touch ${formatAgeSeconds(this.now() - summary.oldestTouchedAtEpochMs)} ago`
|
|
995
|
+
: '';
|
|
996
|
+
this.appendSystemLine(`Tier 1 working set: ${summary.count}/${summary.capacity} files, ${summary.totalSizeBytes} bytes${ageLine}.`);
|
|
997
|
+
}
|
|
998
|
+
else {
|
|
999
|
+
this.appendSystemLine('Tier 1 working set: not wired.');
|
|
1000
|
+
}
|
|
1001
|
+
this.appendSystemLine('Tier 2 RAG: deferred to α6.5b (Anvil-side per-tenant workspace).');
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* α6.5 chokidar batch handler. Forwards each event to the working
|
|
1005
|
+
* set tracker (so `unlink` evicts and `add`/`change` bump the
|
|
1006
|
+
* recency) and emits at most one throttled system line per
|
|
1007
|
+
* `FILEWATCH_SYSTEM_LINE_GAP_MS` window.
|
|
1008
|
+
*
|
|
1009
|
+
* The transcript surface intentionally shows ONE filename + the
|
|
1010
|
+
* count of additional changes (`file changed: src/foo.ts (+3 more)`).
|
|
1011
|
+
* The full event list is preserved in the buffer for future
|
|
1012
|
+
* `/context --files` deep-dive (not in α6.5 Phase 1).
|
|
1013
|
+
*/
|
|
1014
|
+
recordFilewatchBatch(batch) {
|
|
1015
|
+
// Hard-guard against post-close invocation. close() detaches the
|
|
1016
|
+
// watcher listeners, but the EventEmitter contract allows the
|
|
1017
|
+
// currently-dispatching emit() call to finish delivering to every
|
|
1018
|
+
// listener captured at the start of emit(). If the session closes
|
|
1019
|
+
// mid-emit, the handler can still fire on a dead session. Returning
|
|
1020
|
+
// early keeps the working set + transcript untouched.
|
|
1021
|
+
// triple-review P1 (PR #380).
|
|
1022
|
+
if (this.closed)
|
|
1023
|
+
return;
|
|
1024
|
+
if (this.workingSet) {
|
|
1025
|
+
for (const event of batch.events) {
|
|
1026
|
+
if (event.kind === 'unlink') {
|
|
1027
|
+
this.workingSet.forget(event.absPath);
|
|
1028
|
+
}
|
|
1029
|
+
// Note: we do NOT auto-track add/change here. The working set
|
|
1030
|
+
// reflects files the AGENT touched - filewatch is informational.
|
|
1031
|
+
// Future wiring: bash/read/edit tools will call track() directly.
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
const nowMs = this.now();
|
|
1035
|
+
const sinceLast = nowMs - this.lastFilewatchLineAtEpochMs;
|
|
1036
|
+
if (sinceLast < FILEWATCH_SYSTEM_LINE_GAP_MS && this.lastFilewatchLineAtEpochMs !== 0) {
|
|
1037
|
+
// Inside the throttle window - buffer for future deep-dive but
|
|
1038
|
+
// do not emit a system line. Cap the buffer at
|
|
1039
|
+
// PENDING_FILEWATCH_BATCH_CAP and drop the oldest on overflow so
|
|
1040
|
+
// a noisy filewatch source cannot drive unbounded memory growth
|
|
1041
|
+
// across a long REPL session. triple-review P1 (PR #380).
|
|
1042
|
+
if (this.pendingFilewatchBatches.length >= PENDING_FILEWATCH_BATCH_CAP) {
|
|
1043
|
+
this.pendingFilewatchBatches.shift();
|
|
1044
|
+
if (!this.pendingFilewatchOverflowWarned) {
|
|
1045
|
+
this.pendingFilewatchOverflowWarned = true;
|
|
1046
|
+
this.appendSystemLine(`Filewatch buffer at cap (${PENDING_FILEWATCH_BATCH_CAP} batches) - shedding oldest. Source may be a build watcher in a tight loop.`);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
this.pendingFilewatchBatches.push(batch);
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
const totalEvents = this.pendingFilewatchBatches.reduce((acc, b) => acc + b.events.length, 0) + batch.events.length;
|
|
1053
|
+
const head = batch.events[0];
|
|
1054
|
+
if (!head) {
|
|
1055
|
+
// Empty batch - should not happen given the watcher guards,
|
|
1056
|
+
// but defensive.
|
|
1057
|
+
this.pendingFilewatchBatches = [];
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
const wsLine = this.workingSet
|
|
1061
|
+
? ` (working set: ${this.workingSet.size()}/${this.workingSet.capacityLimit()})`
|
|
1062
|
+
: '';
|
|
1063
|
+
const tail = totalEvents > 1 ? ` (+${totalEvents - 1} more)` : '';
|
|
1064
|
+
this.appendSystemLine(`file ${head.kind}: ${head.path}${tail}${wsLine}`);
|
|
1065
|
+
this.lastFilewatchLineAtEpochMs = nowMs;
|
|
1066
|
+
this.pendingFilewatchBatches = [];
|
|
1067
|
+
}
|
|
1068
|
+
/**
|
|
1069
|
+
* α6.5 chokidar cap-exceeded handler. The watcher closes itself
|
|
1070
|
+
* when it crosses the watched-paths cap; the session surfaces a
|
|
1071
|
+
* single system line so the operator knows live updates are off.
|
|
1072
|
+
* The conversation stays usable - we just lose the file-changed
|
|
1073
|
+
* badge for the rest of the session.
|
|
1074
|
+
*/
|
|
1075
|
+
recordFilewatchCapExceeded(info) {
|
|
1076
|
+
// Same post-close guard as recordFilewatchBatch. triple-review P1 (PR #380).
|
|
1077
|
+
if (this.closed)
|
|
1078
|
+
return;
|
|
1079
|
+
this.appendSystemLine(`Filewatch off: ${info.watchedCount} watched paths exceeded cap (${info.cap}). Falling back to manual stat-on-read.`);
|
|
1080
|
+
}
|
|
268
1081
|
/**
|
|
269
1082
|
* Fetch one URL via the web_fetch tool and inject the resulting
|
|
270
1083
|
* Markdown into the transcript as an operator-attributed brief. The
|
|
@@ -324,6 +1137,92 @@ export class ReplSession {
|
|
|
324
1137
|
}
|
|
325
1138
|
this.appendOperatorLine(brief);
|
|
326
1139
|
this.patch({ briefStartedAtEpochMs: this.now() });
|
|
1140
|
+
// α6.9 + R3 P1 (Codex triple-review 2026-05-25): supersede the
|
|
1141
|
+
// prior dispatch when one is in flight. Steps in order:
|
|
1142
|
+
//
|
|
1143
|
+
// 1. Abort the old CancellationToken so any in-flight tool
|
|
1144
|
+
// holding `ctx.cancellation` sees `isAborted = true` and bails
|
|
1145
|
+
// (the R2 fix; preserves the file-tools cancellation gate).
|
|
1146
|
+
// 2. Drive the OLD FSM through `aborting -> aborted` terminal.
|
|
1147
|
+
// This is load-bearing for the R3 race: a LATE event arriving
|
|
1148
|
+
// on the old FSM (`agent.spawned`, `agent.step`, terminal,
|
|
1149
|
+
// etc.) before the timestamp gate trips would otherwise still
|
|
1150
|
+
// attempt to transition the new FSM. Driving the old FSM to a
|
|
1151
|
+
// terminal state means the FSM check in
|
|
1152
|
+
// `advanceFsmOnDispatchEnd` (`isTerminal`) short-circuits as a
|
|
1153
|
+
// defense-in-depth layer.
|
|
1154
|
+
// 3. `resetFsmToIdle()` mints a fresh FSM so the new dispatch
|
|
1155
|
+
// starts clean. The FSM legal-transition matrix forbids
|
|
1156
|
+
// `aborted -> awaiting_response`, so the reset is required.
|
|
1157
|
+
// 4. Record `currentDispatchStartTime` BEFORE bumping
|
|
1158
|
+
// `dispatchSeq` + clearing `taskDispatchSeq`. The timestamp
|
|
1159
|
+
// gate in `handleServerEvent` checks
|
|
1160
|
+
// `event.timestamp < currentDispatchStartTime` to drop late
|
|
1161
|
+
// events from any superseded dispatch (including the late
|
|
1162
|
+
// `agent.spawned` that the R2 seq gate could not catch).
|
|
1163
|
+
// 5. Clear `taskDispatchSeq` so any stamp left over from the old
|
|
1164
|
+
// dispatch cannot influence seq comparisons for the new turn.
|
|
1165
|
+
// 6. Bump `dispatchSeq` and mint a fresh `CancellationToken`.
|
|
1166
|
+
//
|
|
1167
|
+
// If no prior dispatch is in flight (clean idle / terminal entry),
|
|
1168
|
+
// the supersede block is skipped; we only reset the FSM if it sits
|
|
1169
|
+
// in a terminal state from the prior turn.
|
|
1170
|
+
if (this.currentDispatchToken) {
|
|
1171
|
+
// Step 1: abort the old token. Listeners (including the
|
|
1172
|
+
// file-tools cancellation gate) fan out before we replace the
|
|
1173
|
+
// field below.
|
|
1174
|
+
this.currentDispatchToken.abort();
|
|
1175
|
+
// Step 2: walk the old FSM to terminal. Guard against an FSM
|
|
1176
|
+
// that already sits in `aborting` or a terminal state - the
|
|
1177
|
+
// legal-transition matrix forbids `aborting -> aborting` and
|
|
1178
|
+
// forbids any outgoing transition from terminal states.
|
|
1179
|
+
if (!this.fsm.isTerminal) {
|
|
1180
|
+
if (this.fsm.current !== 'aborting') {
|
|
1181
|
+
this.fsm.transition('aborting', 'superseded_by_new_brief');
|
|
1182
|
+
}
|
|
1183
|
+
this.fsm.transition('aborted', 'superseded_by_new_brief');
|
|
1184
|
+
}
|
|
1185
|
+
// Step 3: fresh FSM so the new dispatch can walk
|
|
1186
|
+
// `idle -> awaiting_response` cleanly.
|
|
1187
|
+
this.resetFsmToIdle();
|
|
1188
|
+
}
|
|
1189
|
+
else if (this.fsm.isTerminal) {
|
|
1190
|
+
// Prior turn ended naturally (completed / failed / aborted) with
|
|
1191
|
+
// no live token left around. Reset only the FSM.
|
|
1192
|
+
this.resetFsmToIdle();
|
|
1193
|
+
}
|
|
1194
|
+
// Step 4: record the dispatch start time BEFORE bumping the seq.
|
|
1195
|
+
// The timestamp gate in `handleServerEvent` reads this value to
|
|
1196
|
+
// decide whether an inbound event predates the live dispatch and
|
|
1197
|
+
// should be dropped. Recording before the seq bump is critical:
|
|
1198
|
+
// any concurrent SSE event landing between this line and the seq
|
|
1199
|
+
// bump must still see a strictly-monotonic timestamp boundary.
|
|
1200
|
+
this.currentDispatchStartTime = this.now();
|
|
1201
|
+
// Step 5: clear the per-task seq stamps. Any leftover stamp from a
|
|
1202
|
+
// superseded dispatch would otherwise look like it matched the new
|
|
1203
|
+
// dispatchSeq (because the late `agent.spawned` for that taskId
|
|
1204
|
+
// could stamp the OLD taskId with the NEW seq), nulling the new
|
|
1205
|
+
// token via a stale terminal event. The timestamp gate is the
|
|
1206
|
+
// primary defense; the clear is belt-and-braces.
|
|
1207
|
+
this.taskDispatchSeq.clear();
|
|
1208
|
+
// Step 6: bump seq + mint fresh token.
|
|
1209
|
+
this.dispatchSeq += 1;
|
|
1210
|
+
this.currentDispatchToken = new CancellationToken();
|
|
1211
|
+
// The FSM is now `idle` (either fresh-start or post-reset). Walk
|
|
1212
|
+
// to `awaiting_response` so the bottom-bar surface picks up the
|
|
1213
|
+
// new dispatch state immediately.
|
|
1214
|
+
if (this.fsm.current === 'idle') {
|
|
1215
|
+
this.fsm.transition('awaiting_response', 'brief_dispatched');
|
|
1216
|
+
}
|
|
1217
|
+
// α6.9: re-open the SSE stream if a prior `cancel()` tore it
|
|
1218
|
+
// down. Without this, the new brief would dispatch on admin-api
|
|
1219
|
+
// but the client would never observe `agent.spawned` / `step` /
|
|
1220
|
+
// `completed` — the operator would see a stalled status bar
|
|
1221
|
+
// forever. Idempotent: openStream() short-circuits when a handle
|
|
1222
|
+
// already exists or the session is closed.
|
|
1223
|
+
if (!this.streamHandle && !this.closed) {
|
|
1224
|
+
this.openStream();
|
|
1225
|
+
}
|
|
327
1226
|
try {
|
|
328
1227
|
await this.options.transport.postBrief({
|
|
329
1228
|
apiUrl: this.options.apiUrl,
|
|
@@ -334,7 +1233,78 @@ export class ReplSession {
|
|
|
334
1233
|
}
|
|
335
1234
|
catch (error) {
|
|
336
1235
|
this.appendSystemLine(`Brief dispatch refused: ${this.errorMessage(error)}`);
|
|
1236
|
+
// α6.9: a failed brief POST never produced a turn, so we move
|
|
1237
|
+
// the FSM straight to `failed` so the bottom-bar surfaces the
|
|
1238
|
+
// outcome and the next brief can mint a fresh token.
|
|
1239
|
+
this.markDispatchFailed('post_brief_failed');
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
/**
|
|
1243
|
+
* α6.9: reset the FSM to `idle` after a terminal transition so the
|
|
1244
|
+
* next brief can start. The FSM does not allow direct
|
|
1245
|
+
* `completed -> awaiting_response`, so we mint a fresh FSM by
|
|
1246
|
+
* overwriting the field. Listeners on the old FSM are dropped (they
|
|
1247
|
+
* cannot fire again — terminal states have no outgoing transitions).
|
|
1248
|
+
* The state.dispatchState patch happens via the new FSM's listeners
|
|
1249
|
+
* which we re-attach immediately.
|
|
1250
|
+
*/
|
|
1251
|
+
resetFsmToIdle() {
|
|
1252
|
+
// Re-attach the same listeners on a fresh FSM instance. We cannot
|
|
1253
|
+
// mutate `this.fsm` because it is `readonly`; but we mark it as
|
|
1254
|
+
// mutable for this single reset path.
|
|
1255
|
+
const next = new DispatchFSM();
|
|
1256
|
+
next.onEnter('idle', () => this.patch({ dispatchState: 'idle', dispatchToolLabel: null }));
|
|
1257
|
+
next.onEnter('awaiting_response', () => this.patch({ dispatchState: 'awaiting_response', dispatchToolLabel: null }));
|
|
1258
|
+
next.onEnter('tool_running', () => this.patch({ dispatchState: 'tool_running' }));
|
|
1259
|
+
next.onEnter('aborting', () => this.patch({ dispatchState: 'aborting', dispatchToolLabel: null }));
|
|
1260
|
+
next.onEnter('aborted', () => this.patch({ dispatchState: 'aborted', dispatchToolLabel: null }));
|
|
1261
|
+
next.onEnter('completed', () => this.patch({ dispatchState: 'completed', dispatchToolLabel: null }));
|
|
1262
|
+
next.onEnter('failed', () => this.patch({ dispatchState: 'failed', dispatchToolLabel: null }));
|
|
1263
|
+
// Swap the instance - the FSM does not allow direct
|
|
1264
|
+
// `<terminal> -> awaiting_response`, so the next brief needs a
|
|
1265
|
+
// fresh machine to walk from `idle`. Clean assignment (no cast)
|
|
1266
|
+
// because `fsm` is no longer declared readonly.
|
|
1267
|
+
this.fsm = next;
|
|
1268
|
+
// State patch so subscribers see the idle transition immediately.
|
|
1269
|
+
this.patch({ dispatchState: 'idle', dispatchToolLabel: null });
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* α6.9: short-circuit the FSM to `failed` on a non-recoverable
|
|
1273
|
+
* dispatch error (network refusal, malformed event, etc). Idempotent
|
|
1274
|
+
* — a second call from a terminal state is a no-op.
|
|
1275
|
+
*/
|
|
1276
|
+
markDispatchFailed(reason) {
|
|
1277
|
+
if (this.fsm.isTerminal)
|
|
1278
|
+
return;
|
|
1279
|
+
if (this.fsm.current === 'idle')
|
|
1280
|
+
return;
|
|
1281
|
+
// From `awaiting_response` or `tool_running` we can transition to
|
|
1282
|
+
// `failed` directly per the legal-transition matrix. From `aborting`
|
|
1283
|
+
// the only legal move is `aborted`, so skip — the abort path is
|
|
1284
|
+
// already in motion.
|
|
1285
|
+
if (this.fsm.current === 'aborting')
|
|
1286
|
+
return;
|
|
1287
|
+
this.fsm.transition('failed', reason);
|
|
1288
|
+
// α6.9 P1 fix (Claude triple-review): postBrief threw between
|
|
1289
|
+
// openStream() and dispatch registration server-side. The local
|
|
1290
|
+
// SSE handle is open but listening for events under a dispatchId
|
|
1291
|
+
// the admin-api never created. If we leave it open, any inbound
|
|
1292
|
+
// event for a future dispatch on the same session would drive
|
|
1293
|
+
// the FSM from terminal `failed` -> illegal target and throw
|
|
1294
|
+
// IllegalDispatchTransitionError. Tear down so the next brief
|
|
1295
|
+
// re-opens cleanly via dispatchBrief's openStream() gate.
|
|
1296
|
+
//
|
|
1297
|
+
// R2 P2 fix (Claude triple-review 2026-05-25): tear down the
|
|
1298
|
+
// stream BEFORE nulling the token. Same ordering contract as
|
|
1299
|
+
// `cancel()`: any onAbort listener fired during teardown should
|
|
1300
|
+
// observe the (now-aborted) token via getCurrentDispatchToken()
|
|
1301
|
+
// rather than null. Nulling the token first would race the
|
|
1302
|
+
// teardown's listener fan-out against a stale null read.
|
|
1303
|
+
if (this.streamHandle) {
|
|
1304
|
+
this.streamHandle.close();
|
|
1305
|
+
this.streamHandle = undefined;
|
|
337
1306
|
}
|
|
1307
|
+
this.currentDispatchToken = null;
|
|
338
1308
|
}
|
|
339
1309
|
async dispatchStop(persona) {
|
|
340
1310
|
const sessionId = this.state.sessionId;
|
|
@@ -382,12 +1352,131 @@ export class ReplSession {
|
|
|
382
1352
|
onError: (error) => {
|
|
383
1353
|
if (this.closed)
|
|
384
1354
|
return;
|
|
1355
|
+
// α6.14.2 wave 5: when admin-api restarts it drops the in-memory
|
|
1356
|
+
// session store, so subscribe returns HTTP 404 forever on the
|
|
1357
|
+
// saved sessionId. Detect that case and mint a fresh server
|
|
1358
|
+
// session silently rather than spamming the operator with
|
|
1359
|
+
// "Stream interrupted (HTTP 404)" reconnect lines. Capped to
|
|
1360
|
+
// MAX_SESSION_RECREATE_ATTEMPTS inside SESSION_RECREATE_WINDOW_MS
|
|
1361
|
+
// so a permanently down admin-api fails loud.
|
|
1362
|
+
//
|
|
1363
|
+
// Race guard (triple-review P2 follow-up): the SSE transport can
|
|
1364
|
+
// fire onError synchronously a second time while we are tearing
|
|
1365
|
+
// down the dead stream inside recreateSessionSilently (the
|
|
1366
|
+
// streamHandle.close() call there can flush a pending error
|
|
1367
|
+
// synchronously in some transports). If that second 404 arrives
|
|
1368
|
+
// with recreatingSession === true, we must SHORT-CIRCUIT it too
|
|
1369
|
+
// rather than fall through to the legacy "Stream interrupted"
|
|
1370
|
+
// path - otherwise the operator sees the exact 404 line the
|
|
1371
|
+
// recreate is trying to suppress.
|
|
1372
|
+
if (this.isSessionNotFoundError(error)) {
|
|
1373
|
+
if (this.recreatingSession) {
|
|
1374
|
+
// Recreate already in flight - drop the duplicate 404 on the
|
|
1375
|
+
// floor. The first recreate will either succeed (new stream
|
|
1376
|
+
// opens, this dead handle is gone) or fall through to the
|
|
1377
|
+
// loud "keeps dropping" / "session recreate refused" paths
|
|
1378
|
+
// already defined in recreateSessionSilently.
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
this.patch({ connection: 'reconnecting' });
|
|
1382
|
+
void this.recreateSessionSilently();
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
// α6.14.4 CEO dogfood 2026-05-25 (parity with Claude Code):
|
|
1386
|
+
// collapse the repeated "Stream interrupted (fetch failed).
|
|
1387
|
+
// Reconnecting." spam. The status bar already shows
|
|
1388
|
+
// connection='reconnecting' AND the attempt counter; pushing
|
|
1389
|
+
// a fresh transcript row per attempt fills the screen with
|
|
1390
|
+
// noise. Only emit the system line for the FIRST drop of a
|
|
1391
|
+
// run; subsequent reconnects update the status bar silently
|
|
1392
|
+
// until either success (clears the connection state) or the
|
|
1393
|
+
// give-up path in scheduleReconnect prints the final hint.
|
|
1394
|
+
const wasOnline = this.state.connection === 'on_watch'
|
|
1395
|
+
|| this.state.connection === 'connecting';
|
|
385
1396
|
this.patch({ connection: 'reconnecting' });
|
|
386
|
-
|
|
1397
|
+
if (wasOnline) {
|
|
1398
|
+
this.appendSystemLine(`Stream interrupted (${this.errorMessage(error)}). Reconnecting...`);
|
|
1399
|
+
}
|
|
387
1400
|
this.scheduleReconnect();
|
|
388
1401
|
},
|
|
389
1402
|
});
|
|
390
1403
|
}
|
|
1404
|
+
/**
|
|
1405
|
+
* Detect "session not found" from the SSE transport. The production
|
|
1406
|
+
* transport in `repl-render.tsx` wraps non-2xx responses as
|
|
1407
|
+
* `Error("HTTP 404 on SSE stream")`. We pattern-match on the status
|
|
1408
|
+
* 404 so a different transport (e.g. a test fake or a future polling
|
|
1409
|
+
* fallback) can surface the same intent with the same shape.
|
|
1410
|
+
* (α6.14.2 wave 5.)
|
|
1411
|
+
*/
|
|
1412
|
+
isSessionNotFoundError(error) {
|
|
1413
|
+
const msg = this.errorMessage(error);
|
|
1414
|
+
return /\b404\b/.test(msg);
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Mint a fresh server-side session, swap the consumer to the new
|
|
1418
|
+
* stream URL, keep the conversation flowing. Caps at
|
|
1419
|
+
* MAX_SESSION_RECREATE_ATTEMPTS inside SESSION_RECREATE_WINDOW_MS so
|
|
1420
|
+
* a permanently down admin-api fails loud after a few seconds of
|
|
1421
|
+
* trying. Logged once per attempt at debug level (we surface a
|
|
1422
|
+
* single visible line on first auto-recreate so the operator knows
|
|
1423
|
+
* what happened, then stay quiet). (α6.14.2 wave 5.)
|
|
1424
|
+
*/
|
|
1425
|
+
async recreateSessionSilently() {
|
|
1426
|
+
if (this.closed)
|
|
1427
|
+
return;
|
|
1428
|
+
if (this.recreatingSession)
|
|
1429
|
+
return;
|
|
1430
|
+
const nowMs = this.now();
|
|
1431
|
+
// Drop stale window entries so the cap is rolling, not cumulative.
|
|
1432
|
+
while (this.recentRecreateAtMs.length > 0
|
|
1433
|
+
&& nowMs - (this.recentRecreateAtMs[0] ?? 0) > SESSION_RECREATE_WINDOW_MS) {
|
|
1434
|
+
this.recentRecreateAtMs.shift();
|
|
1435
|
+
}
|
|
1436
|
+
if (this.recentRecreateAtMs.length >= MAX_SESSION_RECREATE_ATTEMPTS) {
|
|
1437
|
+
// Cap exceeded - fall back to the loud "give up" path so the
|
|
1438
|
+
// operator sees something is actually wrong.
|
|
1439
|
+
this.appendSystemLine('Admin API session keeps dropping (HTTP 404 x3). Type /quit and `pugi resume` to retry.');
|
|
1440
|
+
this.patch({ connection: 'offline' });
|
|
1441
|
+
return;
|
|
1442
|
+
}
|
|
1443
|
+
this.recreatingSession = true;
|
|
1444
|
+
this.recentRecreateAtMs.push(nowMs);
|
|
1445
|
+
// Tear down the dead SSE handle so the next openStream() does not
|
|
1446
|
+
// close-over the stale sessionId.
|
|
1447
|
+
if (this.streamHandle) {
|
|
1448
|
+
this.streamHandle.close();
|
|
1449
|
+
this.streamHandle = undefined;
|
|
1450
|
+
}
|
|
1451
|
+
// Reset reconnect attempt + lastEventId - the new session is a
|
|
1452
|
+
// fresh stream, not a continuation of the dead one.
|
|
1453
|
+
this.reconnectAttempt = 0;
|
|
1454
|
+
this.lastEventId = undefined;
|
|
1455
|
+
// Single visible line on the FIRST auto-recreate of the window so
|
|
1456
|
+
// the operator knows the CLI is recovering; later recreates in
|
|
1457
|
+
// the same window stay silent.
|
|
1458
|
+
if (this.recentRecreateAtMs.length === 1) {
|
|
1459
|
+
this.appendSystemLine('Admin API restarted - minting a fresh session.');
|
|
1460
|
+
}
|
|
1461
|
+
try {
|
|
1462
|
+
const { sessionId } = await this.options.transport.createSession({
|
|
1463
|
+
apiUrl: this.options.apiUrl,
|
|
1464
|
+
apiKey: this.options.apiKey,
|
|
1465
|
+
workspace: this.options.workspace,
|
|
1466
|
+
});
|
|
1467
|
+
this.patch({ sessionId, connection: 'connecting' });
|
|
1468
|
+
this.openStream();
|
|
1469
|
+
}
|
|
1470
|
+
catch (error) {
|
|
1471
|
+
// The recreate POST itself failed - fall back to the existing
|
|
1472
|
+
// backoff reconnect so the operator still sees retry progress.
|
|
1473
|
+
this.appendSystemLine(`Session recreate refused (${this.errorMessage(error)}). Reconnecting.`);
|
|
1474
|
+
this.scheduleReconnect();
|
|
1475
|
+
}
|
|
1476
|
+
finally {
|
|
1477
|
+
this.recreatingSession = false;
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
391
1480
|
scheduleReconnect() {
|
|
392
1481
|
if (this.closed)
|
|
393
1482
|
return;
|
|
@@ -405,13 +1494,38 @@ export class ReplSession {
|
|
|
405
1494
|
}
|
|
406
1495
|
/* ------------- event reducer -------------- */
|
|
407
1496
|
handleServerEvent(event) {
|
|
1497
|
+
// R3 P1 fix (Codex triple-review 2026-05-25): wall-clock gate that
|
|
1498
|
+
// drops events from a SUPERSEDED dispatch. The R2 seq gate alone
|
|
1499
|
+
// could not catch a LATE `agent.spawned` for an old taskId arriving
|
|
1500
|
+
// AFTER `dispatchBrief` already bumped `dispatchSeq`. The late
|
|
1501
|
+
// spawn would stamp the OLD taskId with the NEW seq, so the
|
|
1502
|
+
// subsequent terminal event for that task looked current and
|
|
1503
|
+
// nulled the freshly minted token. Comparing `event.timestamp`
|
|
1504
|
+
// against `currentDispatchStartTime` (recorded BEFORE the seq
|
|
1505
|
+
// bump) catches the late event before it can corrupt the seq map
|
|
1506
|
+
// or drive the live FSM.
|
|
1507
|
+
//
|
|
1508
|
+
// `Date.parse` returns NaN on malformed input; we treat NaN as
|
|
1509
|
+
// "unknown timestamp, do not drop" so a transport bug never
|
|
1510
|
+
// silently swallows events. Zero `currentDispatchStartTime` means
|
|
1511
|
+
// no dispatch has started yet (start() path) — same fail-open.
|
|
1512
|
+
const eventTs = event.timestamp ? Date.parse(event.timestamp) : 0;
|
|
1513
|
+
if (Number.isFinite(eventTs)
|
|
1514
|
+
&& eventTs > 0
|
|
1515
|
+
&& this.currentDispatchStartTime > 0
|
|
1516
|
+
&& eventTs < this.currentDispatchStartTime) {
|
|
1517
|
+
// Late event from a superseded dispatch. Drop silently — the
|
|
1518
|
+
// operator already saw the new brief land, and the new dispatch
|
|
1519
|
+
// owns the surface now.
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
408
1522
|
switch (event.type) {
|
|
409
1523
|
case 'agent.spawned': {
|
|
410
1524
|
const persona = safePersonaName(event.role);
|
|
411
1525
|
// Wave 4 fix 2026-05-25: the roster collapses to one row per
|
|
412
1526
|
// persona slug. The α5.7 reducer pushed a fresh row on every
|
|
413
1527
|
// spawn, so after three turns the bottom panel stacked
|
|
414
|
-
// "
|
|
1528
|
+
// "Pugi orchestrator shipped" three times. The new contract:
|
|
415
1529
|
// - If a row already exists for this personaSlug, REUSE it.
|
|
416
1530
|
// Replace its taskId, reset status to 'queued', clear the
|
|
417
1531
|
// detail line, restart the duration clock, zero the token
|
|
@@ -441,26 +1555,64 @@ export class ReplSession {
|
|
|
441
1555
|
else {
|
|
442
1556
|
this.patch({ agents: [node, ...this.state.agents] });
|
|
443
1557
|
}
|
|
1558
|
+
// R2 P1 fix (Codex triple-review 2026-05-25): stamp the live
|
|
1559
|
+
// dispatch sequence onto this taskId so terminal handlers can
|
|
1560
|
+
// tell apart a "current dispatch" event from a "superseded
|
|
1561
|
+
// dispatch" event. See `dispatchSeq` + `taskDispatchSeq`
|
|
1562
|
+
// field comments.
|
|
1563
|
+
this.taskDispatchSeq.set(event.taskId, this.dispatchSeq);
|
|
444
1564
|
// The conversation pane already prefixes persona rows with the
|
|
445
1565
|
// persona name in the persona's hue colour. Skip embedding the
|
|
446
1566
|
// name in the body text to avoid the `Marcus Marcus dispatched`
|
|
447
1567
|
// double-print. `void persona` keeps the resolved name in scope
|
|
448
1568
|
// for the agent tree node above without leaking it into the
|
|
449
1569
|
// transcript body.
|
|
1570
|
+
// α6.14.3 CEO dogfood 2026-05-25: drop the "dispatched (X)"
|
|
1571
|
+
// transcript echo. The agent tree pane already shows the
|
|
1572
|
+
// spawned state; printing it as a persona row is pure noise
|
|
1573
|
+
// between the operator's brief and the persona's real reply.
|
|
450
1574
|
void persona;
|
|
451
|
-
this.appendPersonaLine(event.personaSlug, `dispatched (${event.role}).`);
|
|
452
1575
|
return;
|
|
453
1576
|
}
|
|
454
1577
|
case 'agent.step': {
|
|
1578
|
+
// α6.3 office-hours: scan the running buffer for `<pugi-ask>` /
|
|
1579
|
+
// `<pugi-plan-review>` envelopes BEFORE we cache the detail.
|
|
1580
|
+
// The parser returns the cleaned remainder with the raw XML
|
|
1581
|
+
// stripped, so the operator never sees the envelope as prose.
|
|
1582
|
+
// Streaming partial tags (open seen, close not yet streamed)
|
|
1583
|
+
// are kept in the buffer; the next step event extends it.
|
|
1584
|
+
const sanitised = this.consumeAskAndPlanReviewTags(event.taskId, event.detail);
|
|
455
1585
|
// Cache the running detail per task so we can surface the
|
|
456
1586
|
// model's actual reply when agent.completed lands (otherwise
|
|
457
1587
|
// the reply disappears under the literal 'shipped' patch).
|
|
458
|
-
if (
|
|
459
|
-
this.lastStepDetail.set(event.taskId,
|
|
1588
|
+
if (sanitised && sanitised.trim().length > 0) {
|
|
1589
|
+
this.lastStepDetail.set(event.taskId, sanitised);
|
|
1590
|
+
}
|
|
1591
|
+
// α6.12: synthesise a tool call entry when the step detail
|
|
1592
|
+
// matches a tool-invocation grammar. The pattern is generous
|
|
1593
|
+
// (Read(path) / Edit(path:lines) / Bash(cmd) / Grep(pat) /
|
|
1594
|
+
// Glob(pat) / WebFetch(url)) so the pane has rows to render
|
|
1595
|
+
// before the admin-api side ships the proper tool.* SSE events.
|
|
1596
|
+
// Use the sanitised detail (post-tag-strip) so a `<pugi-ask>`
|
|
1597
|
+
// envelope never produces a phantom tool row.
|
|
1598
|
+
const synthesised = synthesiseToolCall({
|
|
1599
|
+
taskId: event.taskId,
|
|
1600
|
+
detail: sanitised,
|
|
1601
|
+
agent: this.personaSlugForTask(event.taskId),
|
|
1602
|
+
now: this.now(),
|
|
1603
|
+
});
|
|
1604
|
+
if (synthesised) {
|
|
1605
|
+
this.appendToolCall(synthesised);
|
|
1606
|
+
// α6.9: a fresh tool call moves the FSM to `tool_running`
|
|
1607
|
+
// when the dispatch is still active. The status-bar surface
|
|
1608
|
+
// also gets a short label (`tool: read`, `tool: bash`, etc).
|
|
1609
|
+
// Aborting / terminal states are not allowed to transition
|
|
1610
|
+
// here — we silently skip rather than throw.
|
|
1611
|
+
this.advanceFsmOnToolStart(synthesised.tool);
|
|
460
1612
|
}
|
|
461
1613
|
this.patch({
|
|
462
1614
|
agents: this.state.agents.map((a) => a.taskId === event.taskId
|
|
463
|
-
? { ...a, status: 'thinking', detail: event.detail }
|
|
1615
|
+
? { ...a, status: 'thinking', detail: sanitised || event.detail }
|
|
464
1616
|
: a),
|
|
465
1617
|
});
|
|
466
1618
|
return;
|
|
@@ -483,11 +1635,22 @@ export class ReplSession {
|
|
|
483
1635
|
const target = this.state.agents.find((a) => a.taskId === event.taskId);
|
|
484
1636
|
const finalDetail = this.lastStepDetail.get(event.taskId);
|
|
485
1637
|
this.lastStepDetail.delete(event.taskId);
|
|
1638
|
+
if (this.askBufferPending.has(event.taskId)) {
|
|
1639
|
+
this.appendSystemLine('Persona emitted incomplete <pugi-ask> or <pugi-plan-review> tag; dropped.');
|
|
1640
|
+
}
|
|
1641
|
+
this.askBuffer.delete(event.taskId);
|
|
1642
|
+
this.askBufferPending.delete(event.taskId);
|
|
486
1643
|
this.patch({
|
|
487
1644
|
agents: this.state.agents.map((a) => a.taskId === event.taskId
|
|
488
1645
|
? { ...a, status: 'shipped', detail: 'shipped' }
|
|
489
1646
|
: a),
|
|
490
1647
|
});
|
|
1648
|
+
// α6.9: transition the FSM to `completed` when no other
|
|
1649
|
+
// dispatch is still in flight. The check uses the agents list
|
|
1650
|
+
// POST-patch so any sibling task in `queued` / `thinking` keeps
|
|
1651
|
+
// the dispatch alive; the FSM only goes terminal when the last
|
|
1652
|
+
// agent ships.
|
|
1653
|
+
this.advanceFsmOnDispatchEnd('completed', 'agent_completed', event.taskId);
|
|
491
1654
|
if (target) {
|
|
492
1655
|
// If the persona actually produced a reply via incremental
|
|
493
1656
|
// agent.step events, render that reply in the transcript so
|
|
@@ -500,15 +1663,34 @@ export class ReplSession {
|
|
|
500
1663
|
if (finalDetail
|
|
501
1664
|
&& finalDetail !== 'queued for dispatch'
|
|
502
1665
|
&& finalDetail.trim().length > 4) {
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
1666
|
+
// α6.12: ship the WHOLE body as one transcript row when the
|
|
1667
|
+
// reply contains ANY Markdown structure (code fence, bullet
|
|
1668
|
+
// list, numbered list, headings). The conversation pane
|
|
1669
|
+
// routes it through Markdown renderer в one pass, preserving
|
|
1670
|
+
// grouped bullets + heading hierarchy. Plain prose still
|
|
1671
|
+
// splits per line so word-wrap stays correct.
|
|
1672
|
+
//
|
|
1673
|
+
// Claude triple-review P1 (PR #369): the prior `includes('```')`
|
|
1674
|
+
// gate only caught fences - multi-line bullets fragmented
|
|
1675
|
+
// per row showed as `▸ Mira • read PUGI.md / ▸ Mira • patched
|
|
1676
|
+
// bug / ...` instead of a single grouped bullet block.
|
|
1677
|
+
if (looksLikeMarkdown(finalDetail)) {
|
|
1678
|
+
this.appendPersonaLine(target.personaSlug, finalDetail);
|
|
1679
|
+
}
|
|
1680
|
+
else {
|
|
1681
|
+
for (const line of finalDetail.split('\n')) {
|
|
1682
|
+
const trimmed = line.trim();
|
|
1683
|
+
if (trimmed.length > 0) {
|
|
1684
|
+
this.appendPersonaLine(target.personaSlug, trimmed);
|
|
1685
|
+
}
|
|
507
1686
|
}
|
|
508
1687
|
}
|
|
509
1688
|
}
|
|
510
1689
|
else {
|
|
511
|
-
|
|
1690
|
+
// α6.14.3 CEO dogfood 2026-05-25: drop the literal
|
|
1691
|
+
// "shipped." fallback row. If we have no cached detail to
|
|
1692
|
+
// surface, stay silent. The agent tree pane already shows
|
|
1693
|
+
// the green check + duration.
|
|
512
1694
|
}
|
|
513
1695
|
}
|
|
514
1696
|
return;
|
|
@@ -516,6 +1698,11 @@ export class ReplSession {
|
|
|
516
1698
|
case 'agent.blocked': {
|
|
517
1699
|
const target = this.state.agents.find((a) => a.taskId === event.taskId);
|
|
518
1700
|
this.lastStepDetail.delete(event.taskId);
|
|
1701
|
+
if (this.askBufferPending.has(event.taskId)) {
|
|
1702
|
+
this.appendSystemLine('Persona emitted incomplete <pugi-ask> or <pugi-plan-review> tag; dropped.');
|
|
1703
|
+
}
|
|
1704
|
+
this.askBuffer.delete(event.taskId);
|
|
1705
|
+
this.askBufferPending.delete(event.taskId);
|
|
519
1706
|
this.patch({
|
|
520
1707
|
agents: this.state.agents.map((a) => a.taskId === event.taskId
|
|
521
1708
|
? { ...a, status: 'blocked', detail: event.detail }
|
|
@@ -524,11 +1711,21 @@ export class ReplSession {
|
|
|
524
1711
|
if (target) {
|
|
525
1712
|
this.appendPersonaLine(target.personaSlug, `blocked: ${event.detail}`);
|
|
526
1713
|
}
|
|
1714
|
+
// α6.9: `blocked` is a graceful refusal, not a crash — treat it
|
|
1715
|
+
// as a `completed` outcome from the FSM's perspective so the
|
|
1716
|
+
// operator sees the bottom-bar settle back to `idle` after the
|
|
1717
|
+
// last block clears.
|
|
1718
|
+
this.advanceFsmOnDispatchEnd('completed', 'agent_blocked', event.taskId);
|
|
527
1719
|
return;
|
|
528
1720
|
}
|
|
529
1721
|
case 'agent.failed': {
|
|
530
1722
|
const target = this.state.agents.find((a) => a.taskId === event.taskId);
|
|
531
1723
|
this.lastStepDetail.delete(event.taskId);
|
|
1724
|
+
if (this.askBufferPending.has(event.taskId)) {
|
|
1725
|
+
this.appendSystemLine('Persona emitted incomplete <pugi-ask> or <pugi-plan-review> tag; dropped.');
|
|
1726
|
+
}
|
|
1727
|
+
this.askBuffer.delete(event.taskId);
|
|
1728
|
+
this.askBufferPending.delete(event.taskId);
|
|
532
1729
|
this.patch({
|
|
533
1730
|
agents: this.state.agents.map((a) => a.taskId === event.taskId
|
|
534
1731
|
? { ...a, status: 'failed', detail: event.error }
|
|
@@ -537,11 +1734,121 @@ export class ReplSession {
|
|
|
537
1734
|
if (target) {
|
|
538
1735
|
this.appendPersonaLine(target.personaSlug, `failed: ${event.error}`);
|
|
539
1736
|
}
|
|
1737
|
+
// α6.9: terminal `failed` transition when no sibling task
|
|
1738
|
+
// remains. Same defer-until-last-agent semantics as
|
|
1739
|
+
// `completed` so the bottom-bar surface tracks the dispatch
|
|
1740
|
+
// collectively.
|
|
1741
|
+
this.advanceFsmOnDispatchEnd('failed', 'agent_failed', event.taskId);
|
|
540
1742
|
return;
|
|
541
1743
|
}
|
|
542
1744
|
}
|
|
543
1745
|
}
|
|
1746
|
+
/**
|
|
1747
|
+
* α6.9 helper: advance the FSM to `tool_running` when a tool call
|
|
1748
|
+
* lands mid-dispatch. Guarded against terminal / aborting states so
|
|
1749
|
+
* a late tool event after `cancel()` does not throw on an illegal
|
|
1750
|
+
* transition. The `tool` label drives the bottom-bar's
|
|
1751
|
+
* `tool: <kind>` granularity.
|
|
1752
|
+
*/
|
|
1753
|
+
advanceFsmOnToolStart(tool) {
|
|
1754
|
+
const current = this.fsm.current;
|
|
1755
|
+
if (this.fsm.isTerminal)
|
|
1756
|
+
return;
|
|
1757
|
+
if (current === 'aborting')
|
|
1758
|
+
return;
|
|
1759
|
+
if (current === 'idle')
|
|
1760
|
+
return;
|
|
1761
|
+
// Only `awaiting_response -> tool_running` is a hard move. From
|
|
1762
|
+
// `tool_running` we patch the label without a state transition so
|
|
1763
|
+
// a multi-tool turn shows the latest tool's label without churning
|
|
1764
|
+
// the FSM through transient idle states.
|
|
1765
|
+
if (current === 'awaiting_response') {
|
|
1766
|
+
this.fsm.transition('tool_running', `tool_${tool}`);
|
|
1767
|
+
}
|
|
1768
|
+
this.patch({ dispatchToolLabel: `tool: ${tool}` });
|
|
1769
|
+
}
|
|
1770
|
+
/**
|
|
1771
|
+
* α6.9 helper: advance the FSM toward a terminal outcome when the
|
|
1772
|
+
* LAST in-flight agent's lifecycle ends. The dispatch is "still
|
|
1773
|
+
* running" when any other agent in the tree is in `queued` /
|
|
1774
|
+
* `thinking`; the FSM only goes terminal when the last one settles.
|
|
1775
|
+
*
|
|
1776
|
+
* Idempotent + guarded against illegal transitions: a late event
|
|
1777
|
+
* after a manual `cancel()` finds the FSM already in `aborted` and
|
|
1778
|
+
* is silently dropped.
|
|
1779
|
+
*/
|
|
1780
|
+
advanceFsmOnDispatchEnd(outcome, reason, taskId) {
|
|
1781
|
+
// R2 P1 fix (Codex triple-review 2026-05-25): a terminal event
|
|
1782
|
+
// for a SUPERSEDED dispatch must NOT advance the live FSM or null
|
|
1783
|
+
// the live token. If the event carries a taskId and the stamped
|
|
1784
|
+
// dispatchSeq for that task is older than the current dispatchSeq,
|
|
1785
|
+
// the event belongs to a prior dispatch that was replaced by a
|
|
1786
|
+
// newer `dispatchBrief()`. Silently drop the FSM advance.
|
|
1787
|
+
if (taskId !== undefined) {
|
|
1788
|
+
const taskSeq = this.taskDispatchSeq.get(taskId);
|
|
1789
|
+
if (taskSeq !== undefined && taskSeq < this.dispatchSeq) {
|
|
1790
|
+
// Stale dispatch event - the live dispatch is a newer turn.
|
|
1791
|
+
// Skip the FSM advance + token null so brief #N+1 stays
|
|
1792
|
+
// cancellable and the new turn's lifecycle is not corrupted.
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
if (this.fsm.isTerminal)
|
|
1797
|
+
return;
|
|
1798
|
+
if (this.fsm.current === 'aborting')
|
|
1799
|
+
return;
|
|
1800
|
+
if (this.fsm.current === 'idle')
|
|
1801
|
+
return;
|
|
1802
|
+
// Defer until every agent has settled so a multi-agent dispatch
|
|
1803
|
+
// collectively transitions once on the LAST settle.
|
|
1804
|
+
const stillActive = this.state.agents.some((a) => a.status === 'queued' || a.status === 'thinking');
|
|
1805
|
+
if (stillActive)
|
|
1806
|
+
return;
|
|
1807
|
+
// From `tool_running` we must walk through `awaiting_response`
|
|
1808
|
+
// first (the legal-transition matrix forbids
|
|
1809
|
+
// tool_running -> completed). The intermediate step is a one-tick
|
|
1810
|
+
// pass through and immediately settles to terminal.
|
|
1811
|
+
if (this.fsm.current === 'tool_running') {
|
|
1812
|
+
this.fsm.transition('awaiting_response', `${reason}_drained`);
|
|
1813
|
+
}
|
|
1814
|
+
this.fsm.transition(outcome, reason);
|
|
1815
|
+
this.currentDispatchToken = null;
|
|
1816
|
+
this.patch({ briefStartedAtEpochMs: undefined });
|
|
1817
|
+
}
|
|
544
1818
|
/* ------------- transcript helpers -------------- */
|
|
1819
|
+
/**
|
|
1820
|
+
* Look up the persona slug for a running task. Used by the tool call
|
|
1821
|
+
* synthesiser to colour the tool stream rows correctly. Falls back to
|
|
1822
|
+
* the literal `unknown` slug when the task is not in the agent tree
|
|
1823
|
+
* (the SSE wire can emit a step before the matching spawn under heavy
|
|
1824
|
+
* load - rare in practice but the pane stays robust).
|
|
1825
|
+
*/
|
|
1826
|
+
personaSlugForTask(taskId) {
|
|
1827
|
+
const agent = this.state.agents.find((a) => a.taskId === taskId);
|
|
1828
|
+
return agent?.personaSlug ?? 'unknown';
|
|
1829
|
+
}
|
|
1830
|
+
/**
|
|
1831
|
+
* Fold a tool call entry into the rolling list. If the entry id
|
|
1832
|
+
* already exists, replace it in-place (so a synthesised `running` →
|
|
1833
|
+
* `ok` transition reuses the same row). Otherwise append. The list
|
|
1834
|
+
* is capped at `MAX_TOOL_CALLS` so a long-running session does not
|
|
1835
|
+
* leak memory.
|
|
1836
|
+
*/
|
|
1837
|
+
appendToolCall(entry) {
|
|
1838
|
+
const existingIndex = this.state.toolCalls.findIndex((c) => c.id === entry.id);
|
|
1839
|
+
let next;
|
|
1840
|
+
if (existingIndex >= 0) {
|
|
1841
|
+
next = this.state.toolCalls.slice();
|
|
1842
|
+
next[existingIndex] = entry;
|
|
1843
|
+
}
|
|
1844
|
+
else {
|
|
1845
|
+
next = this.state.toolCalls.concat(entry);
|
|
1846
|
+
}
|
|
1847
|
+
if (next.length > MAX_TOOL_CALLS) {
|
|
1848
|
+
next = next.slice(-MAX_TOOL_CALLS);
|
|
1849
|
+
}
|
|
1850
|
+
this.patch({ toolCalls: next });
|
|
1851
|
+
}
|
|
545
1852
|
appendOperatorLine(text) {
|
|
546
1853
|
this.appendRow({ source: 'operator', text });
|
|
547
1854
|
}
|
|
@@ -549,7 +1856,19 @@ export class ReplSession {
|
|
|
549
1856
|
this.appendRow({ source: 'system', text });
|
|
550
1857
|
}
|
|
551
1858
|
appendPersonaLine(personaSlug, text) {
|
|
552
|
-
|
|
1859
|
+
// α6.14.2 wave 5: dedup the persona display-name prefix. The
|
|
1860
|
+
// conversation pane already renders `▸ <DisplayName> <text>` from
|
|
1861
|
+
// the slug → name map; when the model's own reply begins with
|
|
1862
|
+
// the same display name (CEO 2026-05-25 screenshot: "Pugi Pugi,
|
|
1863
|
+
// координатор Pugi"), the operator sees the name twice. Strip
|
|
1864
|
+
// the leading display-name token (with optional trailing comma /
|
|
1865
|
+
// colon / whitespace) so the prefix the pane adds is the only one
|
|
1866
|
+
// visible. We also drop any leaked `<workspace-context-NONCE>`
|
|
1867
|
+
// wrapper the model sometimes echoes back at the head of its
|
|
1868
|
+
// first turn - that envelope is for prompt scaffolding, not for
|
|
1869
|
+
// the operator's eyes.
|
|
1870
|
+
const stripped = stripPersonaPrefixEcho(personaSlug, text);
|
|
1871
|
+
this.appendRow({ source: 'persona', text: stripped, personaSlug });
|
|
553
1872
|
}
|
|
554
1873
|
appendRow(input) {
|
|
555
1874
|
if (input.text.length === 0)
|
|
@@ -563,6 +1882,161 @@ export class ReplSession {
|
|
|
563
1882
|
};
|
|
564
1883
|
const next = this.state.transcript.concat(row).slice(-MAX_TRANSCRIPT_ROWS);
|
|
565
1884
|
this.patch({ transcript: next });
|
|
1885
|
+
// Mirror into the local SessionStore so `/resume` can replay.
|
|
1886
|
+
// Persistence is fail-safe: a single error becomes one system
|
|
1887
|
+
// line, subsequent errors are silent so a stuck disk does not
|
|
1888
|
+
// flood the operator. The mapping from row.source -> store kind:
|
|
1889
|
+
// operator -> 'user' (drives turn_count + title)
|
|
1890
|
+
// persona -> 'persona'
|
|
1891
|
+
// system -> 'system'
|
|
1892
|
+
this.persistRow(row);
|
|
1893
|
+
}
|
|
1894
|
+
/**
|
|
1895
|
+
* Best-effort write of one transcript row into the local
|
|
1896
|
+
* SessionStore. Swallows errors after emitting one system line so a
|
|
1897
|
+
* broken store never blocks the conversation. Public callers go
|
|
1898
|
+
* through `appendRow` - this method is private on purpose.
|
|
1899
|
+
*/
|
|
1900
|
+
persistRow(row) {
|
|
1901
|
+
if (!this.store)
|
|
1902
|
+
return;
|
|
1903
|
+
const kind = row.source === 'operator' ? 'user'
|
|
1904
|
+
: row.source === 'persona' ? 'persona'
|
|
1905
|
+
: 'system';
|
|
1906
|
+
const payload = row.source === 'persona'
|
|
1907
|
+
? { text: row.text, personaSlug: row.personaSlug }
|
|
1908
|
+
: row.source === 'operator'
|
|
1909
|
+
? { brief: row.text }
|
|
1910
|
+
: { text: row.text };
|
|
1911
|
+
const event = { t: row.timestampEpochMs, kind, payload };
|
|
1912
|
+
void this.store.appendEvent(event).catch((error) => {
|
|
1913
|
+
if (this.storeErrorEmitted)
|
|
1914
|
+
return;
|
|
1915
|
+
this.storeErrorEmitted = true;
|
|
1916
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1917
|
+
// Use appendRow directly via state patch so we don't recurse
|
|
1918
|
+
// into persistRow (which would loop on a stuck store).
|
|
1919
|
+
const errRow = {
|
|
1920
|
+
id: randomUUID(),
|
|
1921
|
+
source: 'system',
|
|
1922
|
+
text: `Local session persistence failed: ${msg}. Conversation continues in-memory only.`,
|
|
1923
|
+
timestampEpochMs: this.now(),
|
|
1924
|
+
};
|
|
1925
|
+
const next = this.state.transcript.concat(errRow).slice(-MAX_TRANSCRIPT_ROWS);
|
|
1926
|
+
this.patch({ transcript: next });
|
|
1927
|
+
});
|
|
1928
|
+
}
|
|
1929
|
+
/**
|
|
1930
|
+
* Restore a transcript from a stored event log - α6.4. Called by
|
|
1931
|
+
* the CLI bootstrap when the operator runs `pugi resume <id>` or
|
|
1932
|
+
* picks an entry from the `/resume` picker. Replays each event into
|
|
1933
|
+
* the local transcript WITHOUT writing back to the store so the
|
|
1934
|
+
* restore is idempotent.
|
|
1935
|
+
*
|
|
1936
|
+
* Implementation note: we briefly disable persistence by setting
|
|
1937
|
+
* `storeErrorEmitted` BEFORE the replay and clearing it after - but
|
|
1938
|
+
* the cleaner path is to bypass `appendRow` entirely and patch
|
|
1939
|
+
* state directly. We do the latter so persistRow does not double-
|
|
1940
|
+
* write the restored events.
|
|
1941
|
+
*/
|
|
1942
|
+
restoreTranscript(events) {
|
|
1943
|
+
const rows = [];
|
|
1944
|
+
for (const event of events) {
|
|
1945
|
+
const row = eventToTranscriptRow(event);
|
|
1946
|
+
if (row)
|
|
1947
|
+
rows.push(row);
|
|
1948
|
+
}
|
|
1949
|
+
// Cap at MAX_TRANSCRIPT_ROWS - the same cap appendRow uses so the
|
|
1950
|
+
// window math stays consistent post-restore.
|
|
1951
|
+
const capped = rows.slice(-MAX_TRANSCRIPT_ROWS);
|
|
1952
|
+
this.patch({ transcript: capped });
|
|
1953
|
+
}
|
|
1954
|
+
/**
|
|
1955
|
+
* Local session id used as the persistence key. Surfaced to the
|
|
1956
|
+
* CLI bootstrap so `pugi resume` listings can pin the id without
|
|
1957
|
+
* pulling it out of internal state.
|
|
1958
|
+
*/
|
|
1959
|
+
getLocalSessionId() {
|
|
1960
|
+
return this.localSessionId;
|
|
1961
|
+
}
|
|
1962
|
+
/* ------------- α6.3 buffered tag detection -------------- */
|
|
1963
|
+
/**
|
|
1964
|
+
* Scan the running `agent.step.detail` buffer for `<pugi-ask>` /
|
|
1965
|
+
* `<pugi-plan-review>` envelopes. If a complete envelope is found,
|
|
1966
|
+
* the parser strips it from the visible body and sets the matching
|
|
1967
|
+
* `pendingAsk` / `pendingPlanReview` state so the REPL can render
|
|
1968
|
+
* the modal. Streaming partial tags (open observed, close not yet
|
|
1969
|
+
* arrived) are kept in the buffer so the next step event can extend
|
|
1970
|
+
* them.
|
|
1971
|
+
*
|
|
1972
|
+
* Returns the sanitised body the caller should treat as the
|
|
1973
|
+
* persona's prose. May be empty when the entire body was tag XML;
|
|
1974
|
+
* the caller then leaves `lastStepDetail` untouched and the
|
|
1975
|
+
* `agent.completed` fallback ("shipped.") fires.
|
|
1976
|
+
*/
|
|
1977
|
+
consumeAskAndPlanReviewTags(taskId, detail) {
|
|
1978
|
+
if (!detail || detail.length === 0) {
|
|
1979
|
+
return this.askBuffer.get(taskId) ?? '';
|
|
1980
|
+
}
|
|
1981
|
+
// The persona emits the running detail as a cumulative string, so
|
|
1982
|
+
// a fresh `agent.step` carries the full body up to the current
|
|
1983
|
+
// token (matches the wave-2 caching contract above). We pass the
|
|
1984
|
+
// raw detail through the extractors directly rather than keeping a
|
|
1985
|
+
// separate buffer - but we still record the pre-extraction body so
|
|
1986
|
+
// a partial open tag is preserved when the next chunk arrives.
|
|
1987
|
+
this.askBuffer.set(taskId, detail);
|
|
1988
|
+
const askResult = extractAskTags(detail);
|
|
1989
|
+
let working = askResult.cleaned;
|
|
1990
|
+
for (const tag of askResult.tags) {
|
|
1991
|
+
if (this.seenTagSignatures.includes(tag.signature))
|
|
1992
|
+
continue;
|
|
1993
|
+
this.recordSeenTag(tag.signature);
|
|
1994
|
+
// Only one pending ask at a time - drop additional tags in the
|
|
1995
|
+
// same step into the cleaned body as a system warning. The
|
|
1996
|
+
// persona's prompt forbids concurrent asks, so this branch is a
|
|
1997
|
+
// defensive guard against a misbehaving model.
|
|
1998
|
+
if (this.state.pendingAsk) {
|
|
1999
|
+
this.appendSystemLine('Persona emitted a second <pugi-ask> while one was already open. Dropped.');
|
|
2000
|
+
continue;
|
|
2001
|
+
}
|
|
2002
|
+
this.patch({ pendingAsk: tag, pendingAskSource: 'persona' });
|
|
2003
|
+
}
|
|
2004
|
+
if (askResult.hadMalformedTag) {
|
|
2005
|
+
this.appendSystemLine('Malformed <pugi-ask> dropped (parser refusal).');
|
|
2006
|
+
}
|
|
2007
|
+
const planResult = extractPlanReviewTags(working);
|
|
2008
|
+
working = planResult.cleaned;
|
|
2009
|
+
for (const tag of planResult.tags) {
|
|
2010
|
+
if (this.seenTagSignatures.includes(tag.signature))
|
|
2011
|
+
continue;
|
|
2012
|
+
this.recordSeenTag(tag.signature);
|
|
2013
|
+
if (this.state.pendingPlanReview) {
|
|
2014
|
+
this.appendSystemLine('Persona emitted a second <pugi-plan-review> while one was already open. Dropped.');
|
|
2015
|
+
continue;
|
|
2016
|
+
}
|
|
2017
|
+
this.patch({ pendingPlanReview: tag, pendingPlanReviewSource: 'persona' });
|
|
2018
|
+
}
|
|
2019
|
+
if (planResult.hadMalformedTag) {
|
|
2020
|
+
this.appendSystemLine('Malformed <pugi-plan-review> dropped (parser refusal).');
|
|
2021
|
+
}
|
|
2022
|
+
// Record / clear the "pending open tag" flag so agent.completed can
|
|
2023
|
+
// emit a warning if the persona ends the turn with an unfinished
|
|
2024
|
+
// envelope. The flag flips OFF when both parsers report no
|
|
2025
|
+
// outstanding open tag - if either is still pending, we keep it on
|
|
2026
|
+
// so the warning fires once at turn end.
|
|
2027
|
+
if (askResult.pendingOpenTag || planResult.pendingOpenTag) {
|
|
2028
|
+
this.askBufferPending.add(taskId);
|
|
2029
|
+
}
|
|
2030
|
+
else {
|
|
2031
|
+
this.askBufferPending.delete(taskId);
|
|
2032
|
+
}
|
|
2033
|
+
return working;
|
|
2034
|
+
}
|
|
2035
|
+
recordSeenTag(signature) {
|
|
2036
|
+
this.seenTagSignatures.push(signature);
|
|
2037
|
+
while (this.seenTagSignatures.length > 32) {
|
|
2038
|
+
this.seenTagSignatures.shift();
|
|
2039
|
+
}
|
|
566
2040
|
}
|
|
567
2041
|
/* ------------- agent count + clock -------------- */
|
|
568
2042
|
activeAgentCount() {
|
|
@@ -599,6 +2073,89 @@ export class ReplSession {
|
|
|
599
2073
|
* does not recognise, the REPL still renders something usable rather
|
|
600
2074
|
* than crashing mid-frame.
|
|
601
2075
|
*/
|
|
2076
|
+
/**
|
|
2077
|
+
* Map a stored SessionEvent back into a TranscriptRow for `/resume`
|
|
2078
|
+
* replay. Returns null when the event has no operator-visible body
|
|
2079
|
+
* (e.g. tool.start without a text payload - those land back as
|
|
2080
|
+
* tool stream rows, not transcript rows). The shape mirrors the
|
|
2081
|
+
* `persistRow` mapping in reverse:
|
|
2082
|
+
*
|
|
2083
|
+
* 'user' -> operator (brief)
|
|
2084
|
+
* 'persona' -> persona (text + personaSlug)
|
|
2085
|
+
* 'system' -> system (text)
|
|
2086
|
+
*
|
|
2087
|
+
* Exported indirectly via `restoreTranscript`.
|
|
2088
|
+
*/
|
|
2089
|
+
function eventToTranscriptRow(event) {
|
|
2090
|
+
const payload = (event.payload ?? null);
|
|
2091
|
+
if (event.kind === 'user') {
|
|
2092
|
+
const text = typeof payload?.brief === 'string'
|
|
2093
|
+
? payload.brief
|
|
2094
|
+
: typeof payload?.text === 'string'
|
|
2095
|
+
? payload.text
|
|
2096
|
+
: '';
|
|
2097
|
+
if (text.length === 0)
|
|
2098
|
+
return null;
|
|
2099
|
+
return {
|
|
2100
|
+
id: randomUUID(),
|
|
2101
|
+
source: 'operator',
|
|
2102
|
+
text,
|
|
2103
|
+
timestampEpochMs: event.t,
|
|
2104
|
+
};
|
|
2105
|
+
}
|
|
2106
|
+
if (event.kind === 'persona') {
|
|
2107
|
+
const text = typeof payload?.text === 'string' ? payload.text : '';
|
|
2108
|
+
if (text.length === 0)
|
|
2109
|
+
return null;
|
|
2110
|
+
const personaSlug = typeof payload?.personaSlug === 'string'
|
|
2111
|
+
? payload.personaSlug
|
|
2112
|
+
: undefined;
|
|
2113
|
+
return {
|
|
2114
|
+
id: randomUUID(),
|
|
2115
|
+
source: 'persona',
|
|
2116
|
+
text,
|
|
2117
|
+
personaSlug,
|
|
2118
|
+
timestampEpochMs: event.t,
|
|
2119
|
+
};
|
|
2120
|
+
}
|
|
2121
|
+
if (event.kind === 'system') {
|
|
2122
|
+
const text = typeof payload?.text === 'string' ? payload.text : '';
|
|
2123
|
+
if (text.length === 0)
|
|
2124
|
+
return null;
|
|
2125
|
+
return {
|
|
2126
|
+
id: randomUUID(),
|
|
2127
|
+
source: 'system',
|
|
2128
|
+
text,
|
|
2129
|
+
timestampEpochMs: event.t,
|
|
2130
|
+
};
|
|
2131
|
+
}
|
|
2132
|
+
return null;
|
|
2133
|
+
}
|
|
2134
|
+
/**
|
|
2135
|
+
* Heuristic: does this text contain Markdown structures that benefit
|
|
2136
|
+
* from atomic grouping? Code fences, bullet lists, numbered lists,
|
|
2137
|
+
* headings - anything where per-line splitting would fragment visual
|
|
2138
|
+
* grouping (Claude triple-review P1 PR #369).
|
|
2139
|
+
*/
|
|
2140
|
+
function looksLikeMarkdown(text) {
|
|
2141
|
+
if (text.includes('```'))
|
|
2142
|
+
return true;
|
|
2143
|
+
const lines = text.split('\n');
|
|
2144
|
+
let bulletCount = 0;
|
|
2145
|
+
let numberedCount = 0;
|
|
2146
|
+
let headingCount = 0;
|
|
2147
|
+
for (const raw of lines) {
|
|
2148
|
+
const line = raw.trim();
|
|
2149
|
+
if (/^[-*+]\s+\S/.test(line))
|
|
2150
|
+
bulletCount += 1;
|
|
2151
|
+
if (/^\d+\.\s+\S/.test(line))
|
|
2152
|
+
numberedCount += 1;
|
|
2153
|
+
if (/^#{1,6}\s+\S/.test(line))
|
|
2154
|
+
headingCount += 1;
|
|
2155
|
+
}
|
|
2156
|
+
// 2+ bullets OR 2+ numbered OR any heading = group atomically.
|
|
2157
|
+
return bulletCount >= 2 || numberedCount >= 2 || headingCount >= 1;
|
|
2158
|
+
}
|
|
602
2159
|
function safePersonaName(role) {
|
|
603
2160
|
try {
|
|
604
2161
|
return getPersonaForRole(role).name;
|
|
@@ -607,6 +2164,31 @@ function safePersonaName(role) {
|
|
|
607
2164
|
return role;
|
|
608
2165
|
}
|
|
609
2166
|
}
|
|
2167
|
+
/**
|
|
2168
|
+
* Render a millisecond delta as a compact human-readable age. Used by
|
|
2169
|
+
* `/context` to surface the oldest working-set entry's age:
|
|
2170
|
+
*
|
|
2171
|
+
* < 60s -> `45s`
|
|
2172
|
+
* < 1h -> `4m`
|
|
2173
|
+
* < 24h -> `2h`
|
|
2174
|
+
* >= 24h -> `3d`
|
|
2175
|
+
*
|
|
2176
|
+
* Negative deltas (clock skew) clamp to `0s`.
|
|
2177
|
+
*/
|
|
2178
|
+
function formatAgeSeconds(deltaMs) {
|
|
2179
|
+
const ms = Math.max(0, deltaMs);
|
|
2180
|
+
const seconds = Math.floor(ms / 1000);
|
|
2181
|
+
if (seconds < 60)
|
|
2182
|
+
return `${seconds}s`;
|
|
2183
|
+
const minutes = Math.floor(seconds / 60);
|
|
2184
|
+
if (minutes < 60)
|
|
2185
|
+
return `${minutes}m`;
|
|
2186
|
+
const hours = Math.floor(minutes / 60);
|
|
2187
|
+
if (hours < 24)
|
|
2188
|
+
return `${hours}h`;
|
|
2189
|
+
const days = Math.floor(hours / 24);
|
|
2190
|
+
return `${days}d`;
|
|
2191
|
+
}
|
|
610
2192
|
/**
|
|
611
2193
|
* Convenience: list the legal role slugs the operator can target with
|
|
612
2194
|
* `/stop`. Surfaced in the slash command help overlay and in the
|
|
@@ -615,4 +2197,305 @@ function safePersonaName(role) {
|
|
|
615
2197
|
export function knownRoles() {
|
|
616
2198
|
return listRoles();
|
|
617
2199
|
}
|
|
2200
|
+
/* ------------------------------------------------------------------ */
|
|
2201
|
+
/* Tool call synthesiser - α6.12 */
|
|
2202
|
+
/* ------------------------------------------------------------------ */
|
|
2203
|
+
/**
|
|
2204
|
+
* Match canonical tool invocation grammar in an `agent.step.detail`
|
|
2205
|
+
* string and emit a synthesised `ToolCallEntry`. Returns null when no
|
|
2206
|
+
* known tool pattern matches.
|
|
2207
|
+
*
|
|
2208
|
+
* The grammar mirrors the way Claude Code, Codex CLI, and Gemini CLI
|
|
2209
|
+
* display tool calls in their tool stream panes:
|
|
2210
|
+
*
|
|
2211
|
+
* Read(path)
|
|
2212
|
+
* Edit(path[:lines])
|
|
2213
|
+
* Bash(command)
|
|
2214
|
+
* Grep("pattern" [in path])
|
|
2215
|
+
* Glob(pattern)
|
|
2216
|
+
* WebFetch(url)
|
|
2217
|
+
*
|
|
2218
|
+
* The matcher is case-insensitive on the tool name so a persona that
|
|
2219
|
+
* spells the tool as `READ(...)` or `web_fetch(...)` still lands in
|
|
2220
|
+
* the pane. Args are capped at 80 characters; the pane will further
|
|
2221
|
+
* truncate to 60 on render so the row stays single-line on a narrow
|
|
2222
|
+
* terminal.
|
|
2223
|
+
*
|
|
2224
|
+
* Exported for unit testing - production code path is internal.
|
|
2225
|
+
*/
|
|
2226
|
+
export function synthesiseToolCall(input) {
|
|
2227
|
+
const detail = input.detail.trim();
|
|
2228
|
+
if (detail.length === 0)
|
|
2229
|
+
return null;
|
|
2230
|
+
// Pattern: ToolName(args) optionally suffixed with a result hint.
|
|
2231
|
+
// We allow the canonical Claude Code casing AND the snake_case
|
|
2232
|
+
// alias `web_fetch` so the synthesiser matches what personas write.
|
|
2233
|
+
const match = /^(Read|Edit|Bash|Grep|Glob|WebFetch|web_fetch)\s*\(\s*([^)]*)\s*\)\s*(.*)$/i
|
|
2234
|
+
.exec(detail);
|
|
2235
|
+
if (!match)
|
|
2236
|
+
return null;
|
|
2237
|
+
const toolName = normaliseToolName(match[1]);
|
|
2238
|
+
const args = (match[2] ?? '').trim().slice(0, 80);
|
|
2239
|
+
const tail = (match[3] ?? '').trim();
|
|
2240
|
+
const status = parseStatusFromTail(tail);
|
|
2241
|
+
return {
|
|
2242
|
+
id: `${input.taskId}:${toolName}:${args}`,
|
|
2243
|
+
agent: input.agent,
|
|
2244
|
+
tool: toolName,
|
|
2245
|
+
args,
|
|
2246
|
+
status: status.status,
|
|
2247
|
+
detail: status.detail,
|
|
2248
|
+
startedAtEpochMs: input.now,
|
|
2249
|
+
};
|
|
2250
|
+
}
|
|
2251
|
+
function normaliseToolName(raw) {
|
|
2252
|
+
const lower = raw.toLowerCase();
|
|
2253
|
+
if (lower === 'webfetch' || lower === 'web_fetch')
|
|
2254
|
+
return 'web_fetch';
|
|
2255
|
+
if (lower === 'read')
|
|
2256
|
+
return 'read';
|
|
2257
|
+
if (lower === 'edit')
|
|
2258
|
+
return 'edit';
|
|
2259
|
+
if (lower === 'bash')
|
|
2260
|
+
return 'bash';
|
|
2261
|
+
if (lower === 'grep')
|
|
2262
|
+
return 'grep';
|
|
2263
|
+
if (lower === 'glob')
|
|
2264
|
+
return 'glob';
|
|
2265
|
+
// Unreachable - regex constrains the input. Fallback keeps types happy.
|
|
2266
|
+
return 'read';
|
|
2267
|
+
}
|
|
2268
|
+
/**
|
|
2269
|
+
* Cheap status inference from the tail string. We honour explicit
|
|
2270
|
+
* `OK` / `error` / `running` prefixes the dispatcher may write, plus
|
|
2271
|
+
* a `+/-` diff hint (treated as `ok`) and a `no match` (treated as
|
|
2272
|
+
* `ok` because grep with no result is not an error condition).
|
|
2273
|
+
*/
|
|
2274
|
+
function parseStatusFromTail(tail) {
|
|
2275
|
+
if (tail.length === 0)
|
|
2276
|
+
return { status: 'running' };
|
|
2277
|
+
const lower = tail.toLowerCase();
|
|
2278
|
+
if (lower.startsWith('error') || lower.startsWith('failed')) {
|
|
2279
|
+
return { status: 'error', detail: tail };
|
|
2280
|
+
}
|
|
2281
|
+
if (lower.startsWith('running')) {
|
|
2282
|
+
return { status: 'running', detail: tail };
|
|
2283
|
+
}
|
|
2284
|
+
return { status: 'ok', detail: tail };
|
|
2285
|
+
}
|
|
2286
|
+
/* ------------------------------------------------------------------ */
|
|
2287
|
+
/* α6.3 office-hours encoders */
|
|
2288
|
+
/* */
|
|
2289
|
+
/* Mirrors `tui/ask-modal.tsx#encodeAskVerdict` so the session can */
|
|
2290
|
+
/* synthesise the operator-side echo without dragging an Ink module */
|
|
2291
|
+
/* into the test surface. The two encoders MUST agree byte-for-byte - */
|
|
2292
|
+
/* a divergence would silently mis-prefix the persona's follow-up. */
|
|
2293
|
+
/* ------------------------------------------------------------------ */
|
|
2294
|
+
function encodeAskVerdictLocal(verdict) {
|
|
2295
|
+
if (verdict.cancelled)
|
|
2296
|
+
return '[ASK-RESPONSE:cancelled]';
|
|
2297
|
+
if (verdict.value.length > 0)
|
|
2298
|
+
return `[ASK-RESPONSE:${verdict.value}]`;
|
|
2299
|
+
if (verdict.customInput && verdict.customInput.length > 0) {
|
|
2300
|
+
return `[ASK-RESPONSE:other] ${verdict.customInput}`;
|
|
2301
|
+
}
|
|
2302
|
+
return '[ASK-RESPONSE:cancelled]';
|
|
2303
|
+
}
|
|
2304
|
+
/**
|
|
2305
|
+
* Strip any leading `[ASK-RESPONSE:...]` or `[PLAN-VERDICT:...]`
|
|
2306
|
+
* pattern from free-text operator input so a malicious or accidental
|
|
2307
|
+
* operator string cannot forge a verdict header. Stripping iterates
|
|
2308
|
+
* because the operator could prepend several forged headers in a row;
|
|
2309
|
+
* we keep peeling until the head is clean.
|
|
2310
|
+
*
|
|
2311
|
+
* Example: operator types `[ASK-RESPONSE:vercel] my real answer` -
|
|
2312
|
+
* the leading `[ASK-RESPONSE:vercel] ` is stripped, leaving
|
|
2313
|
+
* `my real answer`, so the encoded wire string becomes
|
|
2314
|
+
* `[ASK-RESPONSE:other] my real answer` rather than
|
|
2315
|
+
* `[ASK-RESPONSE:other] [ASK-RESPONSE:vercel] my real answer` which
|
|
2316
|
+
* a prefix-greedy persona could read as "operator chose vercel".
|
|
2317
|
+
*
|
|
2318
|
+
* Claude triple-review P1 (PR #375).
|
|
2319
|
+
*/
|
|
2320
|
+
function sanitiseVerdictText(raw) {
|
|
2321
|
+
let cleaned = raw;
|
|
2322
|
+
// Bounded loop: each iteration must strip a non-empty pattern, so it
|
|
2323
|
+
// terminates in O(input length). Hard cap as defence-in-depth in case
|
|
2324
|
+
// the regex ever matches an empty span.
|
|
2325
|
+
for (let i = 0; i < raw.length + 4; i += 1) {
|
|
2326
|
+
const stripped = cleaned.replace(/^\s*\[(?:ASK-RESPONSE|PLAN-VERDICT):[^\]]*\]\s*/u, '');
|
|
2327
|
+
if (stripped === cleaned)
|
|
2328
|
+
break;
|
|
2329
|
+
cleaned = stripped;
|
|
2330
|
+
}
|
|
2331
|
+
return cleaned.trim();
|
|
2332
|
+
}
|
|
2333
|
+
function sanitiseAskVerdict(verdict) {
|
|
2334
|
+
if (verdict.customInput === undefined)
|
|
2335
|
+
return verdict;
|
|
2336
|
+
const sanitisedCustom = sanitiseVerdictText(verdict.customInput);
|
|
2337
|
+
if (sanitisedCustom === verdict.customInput)
|
|
2338
|
+
return verdict;
|
|
2339
|
+
// If sanitisation emptied the buffer, treat the verdict as a
|
|
2340
|
+
// cancellation rather than dispatching a meaningless "other" with no
|
|
2341
|
+
// body. Preserves the dispatch invariant (no empty bodies) and
|
|
2342
|
+
// matches the encoder's fallback.
|
|
2343
|
+
if (sanitisedCustom.length === 0) {
|
|
2344
|
+
return { value: '', cancelled: true };
|
|
2345
|
+
}
|
|
2346
|
+
return { ...verdict, customInput: sanitisedCustom };
|
|
2347
|
+
}
|
|
2348
|
+
function sanitisePlanReviewResult(result) {
|
|
2349
|
+
if (result.verdict !== 'modify')
|
|
2350
|
+
return result;
|
|
2351
|
+
if (result.modifyText === undefined)
|
|
2352
|
+
return result;
|
|
2353
|
+
const sanitisedText = sanitiseVerdictText(result.modifyText);
|
|
2354
|
+
if (sanitisedText === result.modifyText)
|
|
2355
|
+
return result;
|
|
2356
|
+
if (sanitisedText.length === 0) {
|
|
2357
|
+
return { verdict: 'cancel' };
|
|
2358
|
+
}
|
|
2359
|
+
return { verdict: 'modify', modifyText: sanitisedText };
|
|
2360
|
+
}
|
|
2361
|
+
function encodePlanReviewVerdictLocal(result) {
|
|
2362
|
+
switch (result.verdict) {
|
|
2363
|
+
case 'approve':
|
|
2364
|
+
return '[PLAN-VERDICT:approve]';
|
|
2365
|
+
case 'cancel':
|
|
2366
|
+
return '[PLAN-VERDICT:cancel]';
|
|
2367
|
+
case 'modify':
|
|
2368
|
+
if (result.modifyText && result.modifyText.length > 0) {
|
|
2369
|
+
return `[PLAN-VERDICT:modify] ${result.modifyText}`;
|
|
2370
|
+
}
|
|
2371
|
+
return '[PLAN-VERDICT:cancel]';
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
/**
|
|
2375
|
+
* Compose the human-readable transcript line that records the
|
|
2376
|
+
* operator's ask verdict. Mirrors Codex CLI's "you chose: <label>"
|
|
2377
|
+
* pattern so the conversation reads linearly.
|
|
2378
|
+
*/
|
|
2379
|
+
function humanLabelForVerdict(tag, verdict) {
|
|
2380
|
+
if (verdict.cancelled)
|
|
2381
|
+
return '(cancelled the question)';
|
|
2382
|
+
if (verdict.value.length > 0) {
|
|
2383
|
+
const opt = tag.options.find((o) => o.value === verdict.value);
|
|
2384
|
+
return opt ? `chose: ${opt.label}` : `chose: ${verdict.value}`;
|
|
2385
|
+
}
|
|
2386
|
+
if (verdict.customInput && verdict.customInput.length > 0) {
|
|
2387
|
+
return `chose: other - ${verdict.customInput}`;
|
|
2388
|
+
}
|
|
2389
|
+
return '(cancelled the question)';
|
|
2390
|
+
}
|
|
2391
|
+
function humanLabelForPlanReviewVerdict(result) {
|
|
2392
|
+
switch (result.verdict) {
|
|
2393
|
+
case 'approve':
|
|
2394
|
+
return 'approved the plan';
|
|
2395
|
+
case 'cancel':
|
|
2396
|
+
return 'cancelled the plan';
|
|
2397
|
+
case 'modify':
|
|
2398
|
+
if (result.modifyText && result.modifyText.length > 0) {
|
|
2399
|
+
return `modified the plan: ${result.modifyText}`;
|
|
2400
|
+
}
|
|
2401
|
+
return 'cancelled the plan';
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
/**
|
|
2405
|
+
* Synthesise a 2-option yes/no `<pugi-ask>` tag from a raw question
|
|
2406
|
+
* string. Used by the `/ask` slash command and by `pugi ask <question>`
|
|
2407
|
+
* to give the operator a manual entrypoint into the office-hours UI
|
|
2408
|
+
* without needing a persona-side emission.
|
|
2409
|
+
*
|
|
2410
|
+
* Returns null when the question fails the parser's length cap so the
|
|
2411
|
+
* caller can surface a clear error rather than crashing the modal.
|
|
2412
|
+
*/
|
|
2413
|
+
export function synthesiseLocalAskTag(question) {
|
|
2414
|
+
const trimmed = question.trim();
|
|
2415
|
+
if (trimmed.length === 0 || trimmed.length > 80)
|
|
2416
|
+
return null;
|
|
2417
|
+
const options = [
|
|
2418
|
+
{ value: 'yes', label: 'Yes' },
|
|
2419
|
+
{ value: 'no', label: 'No' },
|
|
2420
|
+
];
|
|
2421
|
+
// Use the single-source signature helper so a persona-emitted ask
|
|
2422
|
+
// with the same question + same option values does not collide with
|
|
2423
|
+
// this synthesised one under a divergent algorithm. Claude
|
|
2424
|
+
// triple-review P1 (PR #375).
|
|
2425
|
+
const signature = signatureForAsk(trimmed, options);
|
|
2426
|
+
return {
|
|
2427
|
+
question: trimmed,
|
|
2428
|
+
options,
|
|
2429
|
+
signature,
|
|
2430
|
+
start: 0,
|
|
2431
|
+
end: 0,
|
|
2432
|
+
};
|
|
2433
|
+
}
|
|
2434
|
+
/**
|
|
2435
|
+
* Strip the persona's own display name from the head of a streamed
|
|
2436
|
+
* reply, plus any leaked `<workspace-context-...>` envelope the model
|
|
2437
|
+
* may echo on its first turn. Exported for direct unit testing —
|
|
2438
|
+
* production callers go through `appendPersonaLine`.
|
|
2439
|
+
*
|
|
2440
|
+
* Examples (display name = "Pugi"):
|
|
2441
|
+
* "Pugi, координатор Pugi. Брифую..." -> "координатор Pugi. Брифую..."
|
|
2442
|
+
* "Pugi: вот результат" -> "вот результат"
|
|
2443
|
+
* "<workspace-context-abc>Pugi, привет" -> "привет"
|
|
2444
|
+
* "обычный ответ без префикса" -> "обычный ответ без префикса"
|
|
2445
|
+
*
|
|
2446
|
+
* The strip is conservative - we only remove the display name when it
|
|
2447
|
+
* is followed by a separator (comma, colon, dash, space) so a sentence
|
|
2448
|
+
* that legitimately contains the name mid-text ("спроси у Pugi") is
|
|
2449
|
+
* not mangled. (α6.14.2 wave 5 - CEO dogfood fix.)
|
|
2450
|
+
*/
|
|
2451
|
+
export function stripPersonaPrefixEcho(personaSlug, text) {
|
|
2452
|
+
let working = text.trimStart();
|
|
2453
|
+
// Drop any leaked `<workspace-context-...>` / `</workspace-context-...>`
|
|
2454
|
+
// wrapper at the head. The Mira prompt v1.1 sometimes echoes the
|
|
2455
|
+
// scaffolding envelope back when the model is warm-starting the
|
|
2456
|
+
// first turn; cosmetic noise the operator never needs to see.
|
|
2457
|
+
// We strip both opening tag and any text up to (and including) the
|
|
2458
|
+
// matching closing tag if present, else just the opening tag.
|
|
2459
|
+
const openMatch = /^<workspace-context[^>]*>/i.exec(working);
|
|
2460
|
+
if (openMatch) {
|
|
2461
|
+
working = working.slice(openMatch[0].length).trimStart();
|
|
2462
|
+
const closeMatch = /^([\s\S]*?)<\/workspace-context[^>]*>/i.exec(working);
|
|
2463
|
+
if (closeMatch) {
|
|
2464
|
+
working = working.slice(closeMatch[0].length).trimStart();
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
// Resolve the display name from the canonical roster. Unknown slugs
|
|
2468
|
+
// (forward-compat with future personas streamed by a newer server)
|
|
2469
|
+
// skip the strip - better to leave the text alone than to mis-strip.
|
|
2470
|
+
const persona = getPersona(personaSlug);
|
|
2471
|
+
if (!persona)
|
|
2472
|
+
return working;
|
|
2473
|
+
const display = persona.name;
|
|
2474
|
+
if (!display || display.length === 0)
|
|
2475
|
+
return working;
|
|
2476
|
+
// Match `<DisplayName>` followed by an end-of-string, or by a
|
|
2477
|
+
// separator (comma, colon, dash, period followed by space, single
|
|
2478
|
+
// space). The match is case-insensitive so "pugi" also strips.
|
|
2479
|
+
// Escape regex specials in the display name even though THE_TEN
|
|
2480
|
+
// names are alpha-only today (forward-defense).
|
|
2481
|
+
const escaped = display.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2482
|
+
const re = new RegExp(`^${escaped}(?:[\\s,:;\\-—–]+|$)`, 'i');
|
|
2483
|
+
// Loop the strip so cascading echoes ("Pugi Pugi Pugi, координатор ...")
|
|
2484
|
+
// collapse to a single name. The model occasionally emits the display
|
|
2485
|
+
// name two or three times back-to-back when the pane prefix also
|
|
2486
|
+
// injects "▸ Pugi"; without the loop, only the first token would be
|
|
2487
|
+
// peeled and the operator would still see "▸ Pugi Pugi, координатор".
|
|
2488
|
+
// Cap at 3 iterations - beyond that the text is either pathological
|
|
2489
|
+
// or unrelated and we should not keep chewing it. Bail when an
|
|
2490
|
+
// iteration makes no progress to avoid infinite loops on a regex that
|
|
2491
|
+
// matches an empty string (defence-in-depth even though the current
|
|
2492
|
+
// pattern guarantees at least one consumed char).
|
|
2493
|
+
for (let i = 0; i < 3; i += 1) {
|
|
2494
|
+
const m = re.exec(working);
|
|
2495
|
+
if (!m || m[0].length === 0)
|
|
2496
|
+
break;
|
|
2497
|
+
working = working.slice(m[0].length).trimStart();
|
|
2498
|
+
}
|
|
2499
|
+
return working;
|
|
2500
|
+
}
|
|
618
2501
|
//# sourceMappingURL=session.js.map
|