@pugi/cli 0.1.0-alpha.19 → 0.1.0-alpha.20
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/dist/core/engine/native-pugi.js +6 -1
- package/dist/core/engine/tool-bridge.js +33 -1
- package/dist/core/repl/dispatch-fsm.js +24 -8
- package/dist/core/repl/session.js +466 -0
- package/dist/runtime/cli.js +1 -1
- package/dist/tools/file-tools.js +90 -0
- package/dist/tui/input-box.js +48 -5
- package/dist/tui/repl.js +24 -2
- package/dist/tui/status-bar.js +63 -3
- package/package.json +2 -2
|
@@ -241,6 +241,9 @@ export class NativePugiEngineAdapter {
|
|
|
241
241
|
for (const event of buffer)
|
|
242
242
|
yield event;
|
|
243
243
|
// Translate the loop outcome into an EngineResult.
|
|
244
|
+
// `aborted` (α6.9: operator cancelled mid-tool) maps to `blocked`
|
|
245
|
+
// because the operator chose the outcome, same shape as
|
|
246
|
+
// budget_exhausted / tool_refused.
|
|
244
247
|
const status = outcome.status === 'completed'
|
|
245
248
|
? 'done'
|
|
246
249
|
: outcome.status === 'failed'
|
|
@@ -252,7 +255,9 @@ export class NativePugiEngineAdapter {
|
|
|
252
255
|
? '[budget_exhausted] '
|
|
253
256
|
: outcome.status === 'tool_refused'
|
|
254
257
|
? '[plan_mode_refused] '
|
|
255
|
-
:
|
|
258
|
+
: outcome.status === 'aborted'
|
|
259
|
+
? '[operator_aborted] '
|
|
260
|
+
: '[failed] ';
|
|
256
261
|
const filesChangedList = Array.from(filesChanged).sort();
|
|
257
262
|
appendSessionMirror(sessionEventsPath, {
|
|
258
263
|
type: 'outcome',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { editTool, globTool, grepTool, readTool, writeTool, } from '../../tools/file-tools.js';
|
|
1
|
+
import { editTool, globTool, grepTool, OperatorAbortedError, readTool, writeTool, } from '../../tools/file-tools.js';
|
|
2
2
|
import { bashToolSync } from '../../tools/bash.js';
|
|
3
3
|
/**
|
|
4
4
|
* Tool-bridge: turns the abstract tool registry into:
|
|
@@ -147,6 +147,14 @@ export function buildExecutor(input) {
|
|
|
147
147
|
// outcome, not a failure, because plan mode is doing its job.
|
|
148
148
|
throw new Error(`PLAN_MODE_REFUSED: ${name} is not allowed in plan mode`);
|
|
149
149
|
}
|
|
150
|
+
// α6.9: refuse cancelled-token tool dispatch BEFORE PreToolUse
|
|
151
|
+
// hooks fire so a cancelled brief never reaches user-defined
|
|
152
|
+
// hook scripts. Sentinel `OPERATOR_ABORTED:<tool>` is recognised
|
|
153
|
+
// by `runEngineLoop` as a terminal-cancel signal so the loop
|
|
154
|
+
// returns control to the caller rather than retrying the model.
|
|
155
|
+
if (ctx.cancellation && ctx.cancellation.isAborted) {
|
|
156
|
+
throw new Error(`OPERATOR_ABORTED: ${name} refused — operator cancelled the dispatch.`);
|
|
157
|
+
}
|
|
150
158
|
// Fire PreToolUse hooks. The match grammar takes the tool name and
|
|
151
159
|
// (when extractable) the target path. Each new tool dispatch starts a
|
|
152
160
|
// fresh dedup batch so a hook fires once per dispatch, not once per
|
|
@@ -194,6 +202,30 @@ export function buildExecutor(input) {
|
|
|
194
202
|
return result;
|
|
195
203
|
}
|
|
196
204
|
catch (error) {
|
|
205
|
+
// α6.9: re-shape OperatorAbortedError throws from the
|
|
206
|
+
// file-tools layer into the same `OPERATOR_ABORTED:` sentinel
|
|
207
|
+
// the upstream cancellation gate uses so `runEngineLoop` sees
|
|
208
|
+
// a consistent terminal-cancel signal regardless of whether
|
|
209
|
+
// the abort landed pre-dispatch or mid-tool (e.g. inside the
|
|
210
|
+
// grep file-loop).
|
|
211
|
+
if (error instanceof OperatorAbortedError) {
|
|
212
|
+
if (hooks && sessionId) {
|
|
213
|
+
const path = extractToolPath(name, argsRaw);
|
|
214
|
+
await hooks.fire({
|
|
215
|
+
sessionId,
|
|
216
|
+
event: 'PostToolUseFailure',
|
|
217
|
+
tool: name,
|
|
218
|
+
path,
|
|
219
|
+
payload: {
|
|
220
|
+
tool: name,
|
|
221
|
+
arguments: argsRaw,
|
|
222
|
+
ok: false,
|
|
223
|
+
error: `OPERATOR_ABORTED: ${name}`,
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
throw new Error(`OPERATOR_ABORTED: ${name} aborted mid-execution.`);
|
|
228
|
+
}
|
|
197
229
|
if (hooks && sessionId) {
|
|
198
230
|
const path = extractToolPath(name, argsRaw);
|
|
199
231
|
await hooks.fire({
|
|
@@ -133,7 +133,7 @@ export class DispatchFSM {
|
|
|
133
133
|
* listener still fires.
|
|
134
134
|
*
|
|
135
135
|
* Re-entrant calls (a listener calls `transition()` again) are
|
|
136
|
-
* queued
|
|
136
|
+
* queued - the inner transition runs after the outer listener drain
|
|
137
137
|
* completes, in FIFO order. See `firing` field comment.
|
|
138
138
|
*/
|
|
139
139
|
transition(next, reason) {
|
|
@@ -143,12 +143,28 @@ export class DispatchFSM {
|
|
|
143
143
|
}
|
|
144
144
|
this.firing = true;
|
|
145
145
|
try {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
146
|
+
// R2 P2 fix (Codex triple-review 2026-05-25): if `commit` throws -
|
|
147
|
+
// either the first move or any queued nested move - we MUST flush
|
|
148
|
+
// the pending queue before propagating the error. Otherwise a
|
|
149
|
+
// later `transition()` call would drain stale entries left over
|
|
150
|
+
// from the failed turn, firing onEnter listeners under a wholly
|
|
151
|
+
// different state context. The queue is "poisoned" the moment any
|
|
152
|
+
// entry throws; the safest move is to drop the rest and surface
|
|
153
|
+
// the failure to the caller.
|
|
154
|
+
try {
|
|
155
|
+
this.commit(next, reason);
|
|
156
|
+
while (this.pending.length > 0) {
|
|
157
|
+
const queued = this.pending.shift();
|
|
158
|
+
if (!queued)
|
|
159
|
+
break;
|
|
160
|
+
this.commit(queued.next, queued.reason);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
// Drop every still-queued entry so the next external transition
|
|
165
|
+
// starts on a clean queue. See the inline rationale above.
|
|
166
|
+
this.pending.length = 0;
|
|
167
|
+
throw err;
|
|
152
168
|
}
|
|
153
169
|
}
|
|
154
170
|
finally {
|
|
@@ -156,7 +172,7 @@ export class DispatchFSM {
|
|
|
156
172
|
}
|
|
157
173
|
}
|
|
158
174
|
/**
|
|
159
|
-
* Inner commit step
|
|
175
|
+
* Inner commit step - legality check + state mutation + listener
|
|
160
176
|
* drain. Called by `transition()` directly for the first move and
|
|
161
177
|
* for every queued nested move thereafter. Errors propagate to the
|
|
162
178
|
* outer `transition()` caller (illegal transitions still throw).
|
|
@@ -37,6 +37,8 @@ import { getJobRegistry } from '../jobs/registry.js';
|
|
|
37
37
|
import { extractAskTags, extractPlanReviewTags, signatureForAsk, } from './ask.js';
|
|
38
38
|
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
39
39
|
import { resolve as resolvePath } from 'node:path';
|
|
40
|
+
import { CancellationToken } from './cancellation.js';
|
|
41
|
+
import { DispatchFSM } from './dispatch-fsm.js';
|
|
40
42
|
const MAX_TRANSCRIPT_ROWS = 500;
|
|
41
43
|
const MAX_TOOL_CALLS = 200;
|
|
42
44
|
const MAX_RECONNECT_ATTEMPTS = 10;
|
|
@@ -220,6 +222,67 @@ export class ReplSession {
|
|
|
220
222
|
* complete envelope lands OR the turn ends. (α6.3.)
|
|
221
223
|
*/
|
|
222
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;
|
|
223
286
|
/**
|
|
224
287
|
* Tracks taskIds that had an `<pugi-ask>` or `<pugi-plan-review>`
|
|
225
288
|
* envelope mid-stream the last time the parser ran on the buffer. If
|
|
@@ -257,7 +320,20 @@ export class ReplSession {
|
|
|
257
320
|
pendingAskSource: null,
|
|
258
321
|
pendingPlanReview: null,
|
|
259
322
|
pendingPlanReviewSource: null,
|
|
323
|
+
dispatchState: 'idle',
|
|
324
|
+
dispatchToolLabel: null,
|
|
260
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 }));
|
|
261
337
|
}
|
|
262
338
|
/* ------------- subscribe / state -------------- */
|
|
263
339
|
subscribe(callback) {
|
|
@@ -344,6 +420,14 @@ export class ReplSession {
|
|
|
344
420
|
*/
|
|
345
421
|
close() {
|
|
346
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
|
+
}
|
|
347
431
|
if (this.streamHandle) {
|
|
348
432
|
this.streamHandle.close();
|
|
349
433
|
this.streamHandle = undefined;
|
|
@@ -352,6 +436,11 @@ export class ReplSession {
|
|
|
352
436
|
clearTimeout(this.reconnectTimer);
|
|
353
437
|
this.reconnectTimer = undefined;
|
|
354
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();
|
|
355
444
|
// Detach watcher listeners so any chokidar event landing between
|
|
356
445
|
// session.close() and the bootstrap-owned watcher.close() does NOT
|
|
357
446
|
// run a handler on a dead session. The handlers themselves also
|
|
@@ -363,6 +452,101 @@ export class ReplSession {
|
|
|
363
452
|
this.watcher.off('capExceeded', this.filewatchCapHandler);
|
|
364
453
|
}
|
|
365
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;
|
|
549
|
+
}
|
|
366
550
|
/* ------------- input handling -------------- */
|
|
367
551
|
/**
|
|
368
552
|
* Run one line of operator input through the slash command parser
|
|
@@ -953,6 +1137,92 @@ export class ReplSession {
|
|
|
953
1137
|
}
|
|
954
1138
|
this.appendOperatorLine(brief);
|
|
955
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
|
+
}
|
|
956
1226
|
try {
|
|
957
1227
|
await this.options.transport.postBrief({
|
|
958
1228
|
apiUrl: this.options.apiUrl,
|
|
@@ -963,8 +1233,79 @@ export class ReplSession {
|
|
|
963
1233
|
}
|
|
964
1234
|
catch (error) {
|
|
965
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');
|
|
966
1240
|
}
|
|
967
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;
|
|
1306
|
+
}
|
|
1307
|
+
this.currentDispatchToken = null;
|
|
1308
|
+
}
|
|
968
1309
|
async dispatchStop(persona) {
|
|
969
1310
|
const sessionId = this.state.sessionId;
|
|
970
1311
|
if (!sessionId) {
|
|
@@ -1140,6 +1481,31 @@ export class ReplSession {
|
|
|
1140
1481
|
}
|
|
1141
1482
|
/* ------------- event reducer -------------- */
|
|
1142
1483
|
handleServerEvent(event) {
|
|
1484
|
+
// R3 P1 fix (Codex triple-review 2026-05-25): wall-clock gate that
|
|
1485
|
+
// drops events from a SUPERSEDED dispatch. The R2 seq gate alone
|
|
1486
|
+
// could not catch a LATE `agent.spawned` for an old taskId arriving
|
|
1487
|
+
// AFTER `dispatchBrief` already bumped `dispatchSeq`. The late
|
|
1488
|
+
// spawn would stamp the OLD taskId with the NEW seq, so the
|
|
1489
|
+
// subsequent terminal event for that task looked current and
|
|
1490
|
+
// nulled the freshly minted token. Comparing `event.timestamp`
|
|
1491
|
+
// against `currentDispatchStartTime` (recorded BEFORE the seq
|
|
1492
|
+
// bump) catches the late event before it can corrupt the seq map
|
|
1493
|
+
// or drive the live FSM.
|
|
1494
|
+
//
|
|
1495
|
+
// `Date.parse` returns NaN on malformed input; we treat NaN as
|
|
1496
|
+
// "unknown timestamp, do not drop" so a transport bug never
|
|
1497
|
+
// silently swallows events. Zero `currentDispatchStartTime` means
|
|
1498
|
+
// no dispatch has started yet (start() path) — same fail-open.
|
|
1499
|
+
const eventTs = event.timestamp ? Date.parse(event.timestamp) : 0;
|
|
1500
|
+
if (Number.isFinite(eventTs)
|
|
1501
|
+
&& eventTs > 0
|
|
1502
|
+
&& this.currentDispatchStartTime > 0
|
|
1503
|
+
&& eventTs < this.currentDispatchStartTime) {
|
|
1504
|
+
// Late event from a superseded dispatch. Drop silently — the
|
|
1505
|
+
// operator already saw the new brief land, and the new dispatch
|
|
1506
|
+
// owns the surface now.
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1143
1509
|
switch (event.type) {
|
|
1144
1510
|
case 'agent.spawned': {
|
|
1145
1511
|
const persona = safePersonaName(event.role);
|
|
@@ -1176,6 +1542,12 @@ export class ReplSession {
|
|
|
1176
1542
|
else {
|
|
1177
1543
|
this.patch({ agents: [node, ...this.state.agents] });
|
|
1178
1544
|
}
|
|
1545
|
+
// R2 P1 fix (Codex triple-review 2026-05-25): stamp the live
|
|
1546
|
+
// dispatch sequence onto this taskId so terminal handlers can
|
|
1547
|
+
// tell apart a "current dispatch" event from a "superseded
|
|
1548
|
+
// dispatch" event. See `dispatchSeq` + `taskDispatchSeq`
|
|
1549
|
+
// field comments.
|
|
1550
|
+
this.taskDispatchSeq.set(event.taskId, this.dispatchSeq);
|
|
1179
1551
|
// The conversation pane already prefixes persona rows with the
|
|
1180
1552
|
// persona name in the persona's hue colour. Skip embedding the
|
|
1181
1553
|
// name in the body text to avoid the `Marcus Marcus dispatched`
|
|
@@ -1218,6 +1590,12 @@ export class ReplSession {
|
|
|
1218
1590
|
});
|
|
1219
1591
|
if (synthesised) {
|
|
1220
1592
|
this.appendToolCall(synthesised);
|
|
1593
|
+
// α6.9: a fresh tool call moves the FSM to `tool_running`
|
|
1594
|
+
// when the dispatch is still active. The status-bar surface
|
|
1595
|
+
// also gets a short label (`tool: read`, `tool: bash`, etc).
|
|
1596
|
+
// Aborting / terminal states are not allowed to transition
|
|
1597
|
+
// here — we silently skip rather than throw.
|
|
1598
|
+
this.advanceFsmOnToolStart(synthesised.tool);
|
|
1221
1599
|
}
|
|
1222
1600
|
this.patch({
|
|
1223
1601
|
agents: this.state.agents.map((a) => a.taskId === event.taskId
|
|
@@ -1254,6 +1632,12 @@ export class ReplSession {
|
|
|
1254
1632
|
? { ...a, status: 'shipped', detail: 'shipped' }
|
|
1255
1633
|
: a),
|
|
1256
1634
|
});
|
|
1635
|
+
// α6.9: transition the FSM to `completed` when no other
|
|
1636
|
+
// dispatch is still in flight. The check uses the agents list
|
|
1637
|
+
// POST-patch so any sibling task in `queued` / `thinking` keeps
|
|
1638
|
+
// the dispatch alive; the FSM only goes terminal when the last
|
|
1639
|
+
// agent ships.
|
|
1640
|
+
this.advanceFsmOnDispatchEnd('completed', 'agent_completed', event.taskId);
|
|
1257
1641
|
if (target) {
|
|
1258
1642
|
// If the persona actually produced a reply via incremental
|
|
1259
1643
|
// agent.step events, render that reply in the transcript so
|
|
@@ -1314,6 +1698,11 @@ export class ReplSession {
|
|
|
1314
1698
|
if (target) {
|
|
1315
1699
|
this.appendPersonaLine(target.personaSlug, `blocked: ${event.detail}`);
|
|
1316
1700
|
}
|
|
1701
|
+
// α6.9: `blocked` is a graceful refusal, not a crash — treat it
|
|
1702
|
+
// as a `completed` outcome from the FSM's perspective so the
|
|
1703
|
+
// operator sees the bottom-bar settle back to `idle` after the
|
|
1704
|
+
// last block clears.
|
|
1705
|
+
this.advanceFsmOnDispatchEnd('completed', 'agent_blocked', event.taskId);
|
|
1317
1706
|
return;
|
|
1318
1707
|
}
|
|
1319
1708
|
case 'agent.failed': {
|
|
@@ -1332,9 +1721,86 @@ export class ReplSession {
|
|
|
1332
1721
|
if (target) {
|
|
1333
1722
|
this.appendPersonaLine(target.personaSlug, `failed: ${event.error}`);
|
|
1334
1723
|
}
|
|
1724
|
+
// α6.9: terminal `failed` transition when no sibling task
|
|
1725
|
+
// remains. Same defer-until-last-agent semantics as
|
|
1726
|
+
// `completed` so the bottom-bar surface tracks the dispatch
|
|
1727
|
+
// collectively.
|
|
1728
|
+
this.advanceFsmOnDispatchEnd('failed', 'agent_failed', event.taskId);
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
/**
|
|
1734
|
+
* α6.9 helper: advance the FSM to `tool_running` when a tool call
|
|
1735
|
+
* lands mid-dispatch. Guarded against terminal / aborting states so
|
|
1736
|
+
* a late tool event after `cancel()` does not throw on an illegal
|
|
1737
|
+
* transition. The `tool` label drives the bottom-bar's
|
|
1738
|
+
* `tool: <kind>` granularity.
|
|
1739
|
+
*/
|
|
1740
|
+
advanceFsmOnToolStart(tool) {
|
|
1741
|
+
const current = this.fsm.current;
|
|
1742
|
+
if (this.fsm.isTerminal)
|
|
1743
|
+
return;
|
|
1744
|
+
if (current === 'aborting')
|
|
1745
|
+
return;
|
|
1746
|
+
if (current === 'idle')
|
|
1747
|
+
return;
|
|
1748
|
+
// Only `awaiting_response -> tool_running` is a hard move. From
|
|
1749
|
+
// `tool_running` we patch the label without a state transition so
|
|
1750
|
+
// a multi-tool turn shows the latest tool's label without churning
|
|
1751
|
+
// the FSM through transient idle states.
|
|
1752
|
+
if (current === 'awaiting_response') {
|
|
1753
|
+
this.fsm.transition('tool_running', `tool_${tool}`);
|
|
1754
|
+
}
|
|
1755
|
+
this.patch({ dispatchToolLabel: `tool: ${tool}` });
|
|
1756
|
+
}
|
|
1757
|
+
/**
|
|
1758
|
+
* α6.9 helper: advance the FSM toward a terminal outcome when the
|
|
1759
|
+
* LAST in-flight agent's lifecycle ends. The dispatch is "still
|
|
1760
|
+
* running" when any other agent in the tree is in `queued` /
|
|
1761
|
+
* `thinking`; the FSM only goes terminal when the last one settles.
|
|
1762
|
+
*
|
|
1763
|
+
* Idempotent + guarded against illegal transitions: a late event
|
|
1764
|
+
* after a manual `cancel()` finds the FSM already in `aborted` and
|
|
1765
|
+
* is silently dropped.
|
|
1766
|
+
*/
|
|
1767
|
+
advanceFsmOnDispatchEnd(outcome, reason, taskId) {
|
|
1768
|
+
// R2 P1 fix (Codex triple-review 2026-05-25): a terminal event
|
|
1769
|
+
// for a SUPERSEDED dispatch must NOT advance the live FSM or null
|
|
1770
|
+
// the live token. If the event carries a taskId and the stamped
|
|
1771
|
+
// dispatchSeq for that task is older than the current dispatchSeq,
|
|
1772
|
+
// the event belongs to a prior dispatch that was replaced by a
|
|
1773
|
+
// newer `dispatchBrief()`. Silently drop the FSM advance.
|
|
1774
|
+
if (taskId !== undefined) {
|
|
1775
|
+
const taskSeq = this.taskDispatchSeq.get(taskId);
|
|
1776
|
+
if (taskSeq !== undefined && taskSeq < this.dispatchSeq) {
|
|
1777
|
+
// Stale dispatch event - the live dispatch is a newer turn.
|
|
1778
|
+
// Skip the FSM advance + token null so brief #N+1 stays
|
|
1779
|
+
// cancellable and the new turn's lifecycle is not corrupted.
|
|
1335
1780
|
return;
|
|
1336
1781
|
}
|
|
1337
1782
|
}
|
|
1783
|
+
if (this.fsm.isTerminal)
|
|
1784
|
+
return;
|
|
1785
|
+
if (this.fsm.current === 'aborting')
|
|
1786
|
+
return;
|
|
1787
|
+
if (this.fsm.current === 'idle')
|
|
1788
|
+
return;
|
|
1789
|
+
// Defer until every agent has settled so a multi-agent dispatch
|
|
1790
|
+
// collectively transitions once on the LAST settle.
|
|
1791
|
+
const stillActive = this.state.agents.some((a) => a.status === 'queued' || a.status === 'thinking');
|
|
1792
|
+
if (stillActive)
|
|
1793
|
+
return;
|
|
1794
|
+
// From `tool_running` we must walk through `awaiting_response`
|
|
1795
|
+
// first (the legal-transition matrix forbids
|
|
1796
|
+
// tool_running -> completed). The intermediate step is a one-tick
|
|
1797
|
+
// pass through and immediately settles to terminal.
|
|
1798
|
+
if (this.fsm.current === 'tool_running') {
|
|
1799
|
+
this.fsm.transition('awaiting_response', `${reason}_drained`);
|
|
1800
|
+
}
|
|
1801
|
+
this.fsm.transition(outcome, reason);
|
|
1802
|
+
this.currentDispatchToken = null;
|
|
1803
|
+
this.patch({ briefStartedAtEpochMs: undefined });
|
|
1338
1804
|
}
|
|
1339
1805
|
/* ------------- transcript helpers -------------- */
|
|
1340
1806
|
/**
|
package/dist/runtime/cli.js
CHANGED
|
@@ -44,7 +44,7 @@ import { dispatchEdit, } from '../core/edits/index.js';
|
|
|
44
44
|
* packages/pugi-sdk/package.json); the publish workflow validates the
|
|
45
45
|
* three are in lockstep.
|
|
46
46
|
*/
|
|
47
|
-
const PUGI_CLI_VERSION = "0.1.0-alpha.
|
|
47
|
+
const PUGI_CLI_VERSION = "0.1.0-alpha.20";
|
|
48
48
|
const handlers = {
|
|
49
49
|
accounts,
|
|
50
50
|
agents: dispatchAgents,
|
package/dist/tools/file-tools.js
CHANGED
|
@@ -6,6 +6,35 @@ import { decidePermission } from '../core/permission.js';
|
|
|
6
6
|
import { createReadRecord, hashContent } from '../core/file-cache.js';
|
|
7
7
|
import { resolveWorkspacePath } from '../core/path-security.js';
|
|
8
8
|
import { recordFileMutation, recordToolCall, recordToolResult } from '../core/session.js';
|
|
9
|
+
/**
|
|
10
|
+
* α6.9 WriteGate marker — thrown by `gateOnCancellation` when the
|
|
11
|
+
* caller supplied a cancellation token that has already aborted. The
|
|
12
|
+
* tool dispatch loop in `tool-bridge.ts` recognises the name and folds
|
|
13
|
+
* the throw into a `status: 'aborted'` tool result rather than a hard
|
|
14
|
+
* error so the loop terminates cleanly.
|
|
15
|
+
*/
|
|
16
|
+
export class OperatorAbortedError extends Error {
|
|
17
|
+
constructor(toolName) {
|
|
18
|
+
super(`operator_aborted: ${toolName} refused — operator cancelled the dispatch.`);
|
|
19
|
+
this.name = 'OperatorAbortedError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* α6.9 WriteGate: refuse the tool dispatch when the active
|
|
24
|
+
* cancellation token has aborted. Idempotent (the token's `isAborted`
|
|
25
|
+
* is a getter, no side effects). Returns void on the happy path so the
|
|
26
|
+
* tool can proceed; throws `OperatorAbortedError` when cancelled.
|
|
27
|
+
*
|
|
28
|
+
* The audit trail still gets the call: `recordToolCall` already fired
|
|
29
|
+
* upstream of this guard so the abort + reason are persisted. The
|
|
30
|
+
* matching `recordToolResult` is fired by the caller in its catch
|
|
31
|
+
* block with `status: 'cancelled'` (see existing path for `error`).
|
|
32
|
+
*/
|
|
33
|
+
export function gateOnCancellation(ctx, toolName) {
|
|
34
|
+
if (ctx.cancellation && ctx.cancellation.isAborted) {
|
|
35
|
+
throw new OperatorAbortedError(toolName);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
9
38
|
/**
|
|
10
39
|
* Re-check the permission decision against the *resolved* real path so
|
|
11
40
|
* a workspace-local symlink (`alias -> .env`) cannot bypass the protected
|
|
@@ -42,6 +71,13 @@ function permissionGatedResolve(ctx, inputPath, action, toolName) {
|
|
|
42
71
|
}
|
|
43
72
|
export function readTool(ctx, path) {
|
|
44
73
|
const toolCallId = recordToolCall(ctx.session, 'read', path);
|
|
74
|
+
// α6.9 WriteGate: fail fast on operator cancel BEFORE permission
|
|
75
|
+
// decision so a half-second post-cancel race never lands the read.
|
|
76
|
+
if (ctx.cancellation && ctx.cancellation.isAborted) {
|
|
77
|
+
const reason = 'operator_aborted: read refused';
|
|
78
|
+
recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
|
|
79
|
+
throw new OperatorAbortedError('read');
|
|
80
|
+
}
|
|
45
81
|
const decision = decidePermission({ tool: 'read', kind: 'read', target: path }, ctx.settings, ctx.root);
|
|
46
82
|
if (decision.decision !== 'allow') {
|
|
47
83
|
const reason = `Permission ${decision.decision} for read ${path}: ${decision.reason}`;
|
|
@@ -64,6 +100,14 @@ export function readTool(ctx, path) {
|
|
|
64
100
|
}
|
|
65
101
|
export function writeTool(ctx, path, content) {
|
|
66
102
|
const toolCallId = recordToolCall(ctx.session, 'write', path);
|
|
103
|
+
// α6.9 WriteGate: refuse the write when the operator has cancelled
|
|
104
|
+
// the dispatch. The audit log captures the cancellation reason so a
|
|
105
|
+
// post-mortem can distinguish operator_aborted from settings-deny.
|
|
106
|
+
if (ctx.cancellation && ctx.cancellation.isAborted) {
|
|
107
|
+
const reason = 'operator_aborted: write refused';
|
|
108
|
+
recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
|
|
109
|
+
throw new OperatorAbortedError('write');
|
|
110
|
+
}
|
|
67
111
|
const decision = decidePermission({ tool: 'write', kind: 'edit', target: path }, ctx.settings, ctx.root);
|
|
68
112
|
if (decision.decision !== 'allow') {
|
|
69
113
|
const reason = `Permission ${decision.decision} for write ${path}: ${decision.reason}`;
|
|
@@ -95,6 +139,15 @@ export function writeTool(ctx, path, content) {
|
|
|
95
139
|
}
|
|
96
140
|
export function editTool(ctx, path, oldString, newString) {
|
|
97
141
|
const toolCallId = recordToolCall(ctx.session, 'edit', path);
|
|
142
|
+
// α6.9 WriteGate: refuse the edit when the operator has cancelled
|
|
143
|
+
// the dispatch. Edits are higher-risk than reads — surface the abort
|
|
144
|
+
// BEFORE we even consult permissions so a cancel-during-tool-loop
|
|
145
|
+
// never partially mutates the workspace.
|
|
146
|
+
if (ctx.cancellation && ctx.cancellation.isAborted) {
|
|
147
|
+
const reason = 'operator_aborted: edit refused';
|
|
148
|
+
recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
|
|
149
|
+
throw new OperatorAbortedError('edit');
|
|
150
|
+
}
|
|
98
151
|
const decision = decidePermission({ tool: 'edit', kind: 'edit', target: path }, ctx.settings, ctx.root);
|
|
99
152
|
if (decision.decision !== 'allow') {
|
|
100
153
|
const reason = `Permission ${decision.decision} for edit ${path}: ${decision.reason}`;
|
|
@@ -140,6 +193,14 @@ export function editTool(ctx, path, oldString, newString) {
|
|
|
140
193
|
}
|
|
141
194
|
export function globTool(ctx, pattern) {
|
|
142
195
|
const toolCallId = recordToolCall(ctx.session, 'glob', pattern);
|
|
196
|
+
// α6.9 WriteGate: cancel-aware short-circuit. Glob is read-only but
|
|
197
|
+
// can be expensive on large trees; respecting the abort here keeps
|
|
198
|
+
// the tool loop responsive when the operator hits Ctrl+C mid-scan.
|
|
199
|
+
if (ctx.cancellation && ctx.cancellation.isAborted) {
|
|
200
|
+
const reason = 'operator_aborted: glob refused';
|
|
201
|
+
recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
|
|
202
|
+
throw new OperatorAbortedError('glob');
|
|
203
|
+
}
|
|
143
204
|
// Pugi globs are workspace-scoped. Reject any pattern that could enumerate
|
|
144
205
|
// outside the workspace:
|
|
145
206
|
// 1. absolute paths (`/etc/**/*`) — globSync resolves these against `/`
|
|
@@ -169,11 +230,28 @@ export function globTool(ctx, pattern) {
|
|
|
169
230
|
}
|
|
170
231
|
export function grepTool(ctx, query) {
|
|
171
232
|
const toolCallId = recordToolCall(ctx.session, 'grep', query);
|
|
233
|
+
// α6.9 WriteGate: refuse before scanning. Grep walks the whole
|
|
234
|
+
// workspace and can take seconds on a large repo; check abort first
|
|
235
|
+
// so a cancel mid-scan returns immediately rather than after the
|
|
236
|
+
// full walk completes.
|
|
237
|
+
if (ctx.cancellation && ctx.cancellation.isAborted) {
|
|
238
|
+
const reason = 'operator_aborted: grep refused';
|
|
239
|
+
recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
|
|
240
|
+
throw new OperatorAbortedError('grep');
|
|
241
|
+
}
|
|
172
242
|
const files = globTool(ctx, '**/*').filter((path) => !path.endsWith('/'));
|
|
173
243
|
const matches = [];
|
|
174
244
|
for (const path of files) {
|
|
175
245
|
if (matches.length >= 200)
|
|
176
246
|
break;
|
|
247
|
+
// α6.9 WriteGate: poll abort inside the file loop so a cancel
|
|
248
|
+
// arriving mid-scan terminates early. The per-file branch keeps
|
|
249
|
+
// the responsiveness bounded by the slowest single-file read.
|
|
250
|
+
if (ctx.cancellation && ctx.cancellation.isAborted) {
|
|
251
|
+
const reason = `operator_aborted: grep stopped mid-scan after ${matches.length} matches`;
|
|
252
|
+
recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
|
|
253
|
+
throw new OperatorAbortedError('grep');
|
|
254
|
+
}
|
|
177
255
|
// Permission gate every file read individually — grep used to bypass
|
|
178
256
|
// `decidePermission` and could surface lines from protected files
|
|
179
257
|
// (.env, *.sql, *.pem, ~/.ssh/**) when invoked from a directory walk.
|
|
@@ -241,6 +319,18 @@ export const BASH_DEFAULT_TIMEOUT_MS = 30_000;
|
|
|
241
319
|
export const BASH_CHILD_MAXBUFFER = 10 * 1024 * 1024;
|
|
242
320
|
export function bashTool(ctx, command, options = {}) {
|
|
243
321
|
const toolCallId = recordToolCall(ctx.session, 'bash', command);
|
|
322
|
+
// α6.9 WriteGate: bash is the highest-risk tool surface. Refuse
|
|
323
|
+
// before the destructive-pattern classifier even runs so a
|
|
324
|
+
// cancelled dispatch never spawns a child process. Note: this is
|
|
325
|
+
// pre-spawn cancellation only; once the /bin/sh -c process is
|
|
326
|
+
// running, the synchronous spawnSync wait blocks until it exits or
|
|
327
|
+
// the 30s timeout fires. Phase 2 will wire SIGTERM forwarding via
|
|
328
|
+
// an async wrapper.
|
|
329
|
+
if (ctx.cancellation && ctx.cancellation.isAborted) {
|
|
330
|
+
const reason = 'operator_aborted: bash refused';
|
|
331
|
+
recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
|
|
332
|
+
throw new OperatorAbortedError('bash');
|
|
333
|
+
}
|
|
244
334
|
const decision = decidePermission({ tool: 'bash', kind: 'bash', target: command }, ctx.settings, ctx.root);
|
|
245
335
|
if (decision.decision !== 'allow') {
|
|
246
336
|
const reason = `Permission ${decision.decision} for bash: ${decision.reason}`;
|
package/dist/tui/input-box.js
CHANGED
|
@@ -131,14 +131,57 @@ export function InputBox(props) {
|
|
|
131
131
|
useInput((input, key) => {
|
|
132
132
|
if (key.ctrl && input === 'c') {
|
|
133
133
|
const t = now();
|
|
134
|
-
|
|
134
|
+
// α6.9: Claude Code-style double-press semantics. First Ctrl+C
|
|
135
|
+
// ALWAYS attempts to cancel an in-flight dispatch (when the
|
|
136
|
+
// session reports non-idle); second Ctrl+C within 1s exits the
|
|
137
|
+
// process. If onCancel is omitted (legacy callers, tests), the
|
|
138
|
+
// old behaviour is preserved: first Ctrl+C clears the buffer +
|
|
139
|
+
// arms the exit timer, second Ctrl+C exits.
|
|
140
|
+
const withinDoubleTapWindow = typeof lastCtrlCAt === 'number' && t - lastCtrlCAt <= CTRL_C_DOUBLE_TAP_MS;
|
|
141
|
+
if (withinDoubleTapWindow) {
|
|
142
|
+
// Second press inside the window — always exit. This matches
|
|
143
|
+
// Claude Code: even mid-dispatch, the second Ctrl+C wins so
|
|
144
|
+
// the operator can always escape a stuck REPL.
|
|
135
145
|
props.onExit();
|
|
136
146
|
return;
|
|
137
147
|
}
|
|
148
|
+
// First press in a fresh window. If the host wired a cancel
|
|
149
|
+
// surface and there is something to cancel, abort the dispatch.
|
|
150
|
+
// The buffer is left untouched on a cancel (the operator's
|
|
151
|
+
// current input is NOT trashed by an accidental Ctrl+C while a
|
|
152
|
+
// tool is running).
|
|
153
|
+
//
|
|
154
|
+
// Three-valued onCancel return (see prop docstring):
|
|
155
|
+
// - true → dispatch cancelled, keep buffer, arm exit timer
|
|
156
|
+
// - false → idle, clear buffer (legacy), arm exit timer
|
|
157
|
+
// - undefined → handler bypassed (modal owns input); NO state
|
|
158
|
+
// change at all. Buffer stays, exit timer NOT
|
|
159
|
+
// armed (otherwise the modal would silently
|
|
160
|
+
// promote a Ctrl+C to "press again to exit",
|
|
161
|
+
// which is wrong context for a modal cancel).
|
|
162
|
+
let cancelResult;
|
|
163
|
+
if (props.onCancel) {
|
|
164
|
+
cancelResult = props.onCancel();
|
|
165
|
+
}
|
|
166
|
+
if (cancelResult === undefined && props.onCancel) {
|
|
167
|
+
// Bypass path - modal owns the input. Drop the press silently
|
|
168
|
+
// so the modal's own cancel surface (Esc / its own Ctrl+C
|
|
169
|
+
// binding inside the modal component) takes effect on its own
|
|
170
|
+
// terms. P2 fix: previously this fell through to the
|
|
171
|
+
// legacy buffer-clear + setLastCtrlCAt path and wiped modal
|
|
172
|
+
// draft text on first Ctrl+C.
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
138
175
|
setLastCtrlCAt(t);
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
176
|
+
// Legacy behaviour: on idle (or no onCancel wired), clear the
|
|
177
|
+
// buffer + reset search so the operator's screen is calm before
|
|
178
|
+
// they confirm exit. When we DID cancel a live dispatch, keep
|
|
179
|
+
// the buffer so a half-typed brief is not lost.
|
|
180
|
+
if (cancelResult !== true) {
|
|
181
|
+
setLine('');
|
|
182
|
+
setCursor(0);
|
|
183
|
+
setSearch(undefined);
|
|
184
|
+
}
|
|
142
185
|
return;
|
|
143
186
|
}
|
|
144
187
|
// Search-mode key handling. Ctrl+R / Ctrl+S cycle, Enter accepts,
|
|
@@ -456,7 +499,7 @@ export function InputBox(props) {
|
|
|
456
499
|
: Math.min(paletteIndex, paletteView.rows.length - 1);
|
|
457
500
|
const divider = '─'.repeat(innerWidth);
|
|
458
501
|
const focusedMatch = search ? search.matches[search.focusedIndex] : undefined;
|
|
459
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "cyan", dimColor: true, children: divider }), _jsx(Box, { paddingX: 1, flexDirection: "column", children: search ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '(reverse-i-search) ' }), _jsx(Text, { children: `\`${search.query}\`: ` }), _jsx(Text, { color: "yellow", children: focusedMatch ? focusedMatch.brief : '(no match)' })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `Ctrl+R next · Ctrl+S prev · Enter accept · Esc cancel · ${search.matches.length} match${search.matches.length === 1 ? '' : 'es'}` }) })] })) : (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '› ' }), _jsx(Text, { children: renderLineWithCursor(line, cursor, cursorVisible) })] })) }), _jsx(Text, { color: "cyan", dimColor: true, children: divider }), line.length > innerWidth - 4 ? (_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: '┊ ' }), _jsx(Text, { dimColor: true, children: 'line wraps - Enter still submits' })] })) : null, _jsx(SlashPalette, { rows: paletteView.rows, focusedIndex: clampedPaletteIndex, totalBeforeLimit: paletteView.totalBeforeLimit }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: '↑/↓ history · Ctrl+R search · / commands · Enter brief · Esc cancel · Ctrl+C ×2 exit' }) })] }));
|
|
502
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "cyan", dimColor: true, children: divider }), _jsx(Box, { paddingX: 1, flexDirection: "column", children: search ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '(reverse-i-search) ' }), _jsx(Text, { children: `\`${search.query}\`: ` }), _jsx(Text, { color: "yellow", children: focusedMatch ? focusedMatch.brief : '(no match)' })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `Ctrl+R next · Ctrl+S prev · Enter accept · Esc cancel · ${search.matches.length} match${search.matches.length === 1 ? '' : 'es'}` }) })] })) : (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '› ' }), _jsx(Text, { children: renderLineWithCursor(line, cursor, cursorVisible) })] })) }), _jsx(Text, { color: "cyan", dimColor: true, children: divider }), line.length > innerWidth - 4 ? (_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: '┊ ' }), _jsx(Text, { dimColor: true, children: 'line wraps - Enter still submits' })] })) : null, _jsx(SlashPalette, { rows: paletteView.rows, focusedIndex: clampedPaletteIndex, totalBeforeLimit: paletteView.totalBeforeLimit }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: '↑/↓ history · Ctrl+R search · / commands · Enter brief · Esc cancel · Ctrl+C abort / ×2 exit' }) })] }));
|
|
460
503
|
}
|
|
461
504
|
/**
|
|
462
505
|
* Render the line with the cursor glyph inserted at `cursor`. The cursor
|
package/dist/tui/repl.js
CHANGED
|
@@ -156,11 +156,33 @@ export function Repl(props) {
|
|
|
156
156
|
const handlePlanReviewResolve = useCallback((result) => {
|
|
157
157
|
void props.session.resolvePlanReview(result);
|
|
158
158
|
}, [props.session]);
|
|
159
|
-
|
|
159
|
+
// α6.9: Ctrl+C abort handler. Forwards to ReplSession.cancel() which
|
|
160
|
+
// aborts the in-flight dispatch, closes the SSE stream, and surfaces
|
|
161
|
+
// "Aborted." in the transcript.
|
|
162
|
+
//
|
|
163
|
+
// Return contract (consumed by InputBox):
|
|
164
|
+
// - true - dispatch was cancelled (keep the buffer + DO arm
|
|
165
|
+
// the press-again-to-exit timer; second Ctrl+C in
|
|
166
|
+
// the window exits).
|
|
167
|
+
// - false - idle / nothing to cancel (legacy: clear buffer +
|
|
168
|
+
// arm the exit timer so the operator sees the hint
|
|
169
|
+
// and can confirm exit on the next press).
|
|
170
|
+
// - undefined - bypassed entirely (e.g. a modal owns the input).
|
|
171
|
+
// InputBox MUST NOT arm the exit timer and MUST
|
|
172
|
+
// NOT clear the buffer. P2 fix: previously this
|
|
173
|
+
// returned `false` and the buffer-clear path wiped
|
|
174
|
+
// the operator's mid-typed modal text on the first
|
|
175
|
+
// Ctrl+C, costing a press of work.
|
|
176
|
+
const handleCancel = useCallback(() => {
|
|
177
|
+
if (modalActive)
|
|
178
|
+
return undefined;
|
|
179
|
+
return props.session.cancel();
|
|
180
|
+
}, [props.session, modalActive]);
|
|
181
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [props.updateBanner ? _jsx(UpdateBanner, { result: props.updateBanner }) : null, splashVisible ? (_jsx(ReplSplash, { cliVersion: state.cliVersion, workspaceLabel: state.workspaceLabel, plan: props.splashPlan, model: props.splashModel, tenant: props.splashTenant, onDismiss: dismissSplash, mascotPrePrinted: props.mascotPrePrinted === true })) : null, _jsx(Header, { state: state }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: overlay === 'help' ? (_jsx(HelpOverlay, {})) : overlay === 'roster' ? (_jsx(RosterOverlay, {})) : overlay === 'farewell' ? (_jsx(FarewellOverlay, {})) : (_jsx(MainArea, { state: state, personaNames: personaNames, nowEpochMs: tickNow, hideToolStream: props.hideToolStream === true, toolStreamCollapsed: toolStreamCollapsed })) }), state.pendingAsk ? (_jsx(Box, { marginTop: 1, children: _jsx(AskModal, { tag: state.pendingAsk, onResolve: handleAskResolve }) })) : null, state.pendingPlanReview ? (_jsx(Box, { marginTop: 1, children: _jsx(PlanReviewModal, { tag: state.pendingPlanReview, onResolve: handlePlanReviewResolve }) })) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [overlay === 'farewell' || modalActive ? null : (_jsx(InputBox, { onSubmit: handleSubmit, onExit: handleExit, onCancel: handleCancel, now: props.now,
|
|
160
182
|
// Slug from process.cwd() (full path) so two workspaces with
|
|
161
183
|
// the same basename do not share history. state.workspaceLabel
|
|
162
184
|
// is the basename only. Codex review P2.
|
|
163
|
-
workspaceSlug: slugForCwd(process.cwd()) })), _jsx(StatusBar, { connection: state.connection, activeAgentCount: countActive(state), tokensDownstreamTotal: state.tokensDownstreamTotal, briefStartedAtEpochMs: state.briefStartedAtEpochMs, nowEpochMs: tickNow, pulsePhase: pulsePhase, pugiMdCount: workspaceContext.pugiMdCount, mcpServerCount: workspaceContext.mcpServerCount, skillCount: workspaceContext.skillCount, quotaPct: props.quotaPct })] })] }));
|
|
185
|
+
workspaceSlug: slugForCwd(process.cwd()) })), _jsx(StatusBar, { connection: state.connection, activeAgentCount: countActive(state), tokensDownstreamTotal: state.tokensDownstreamTotal, briefStartedAtEpochMs: state.briefStartedAtEpochMs, nowEpochMs: tickNow, pulsePhase: pulsePhase, pugiMdCount: workspaceContext.pugiMdCount, mcpServerCount: workspaceContext.mcpServerCount, skillCount: workspaceContext.skillCount, quotaPct: props.quotaPct, dispatchState: state.dispatchState, dispatchToolLabel: state.dispatchToolLabel })] })] }));
|
|
164
186
|
}
|
|
165
187
|
function Header({ state }) {
|
|
166
188
|
return (_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Pugi" }), _jsx(Text, { bold: true, color: "cyan", children: ".io" }), _jsx(Text, { dimColor: true, children: ` · workspace: ${state.workspaceLabel} · v${state.cliVersion} · ` }), _jsx(Text, { color: "cyan", children: state.connection === 'on_watch' ? 'on watch' : state.connection.replace('_', ' ') })] }));
|
package/dist/tui/status-bar.js
CHANGED
|
@@ -12,7 +12,12 @@ export function StatusBar(props) {
|
|
|
12
12
|
const tokenLabel = formatTokens(props.tokensDownstreamTotal);
|
|
13
13
|
const phase = clampPhase(props.pulsePhase);
|
|
14
14
|
const glyph = PULSE_DOTS[Math.min(phase, PULSE_DOTS.length - 1)] ?? PULSE_DOTS[0];
|
|
15
|
-
|
|
15
|
+
// α6.9: composite status label — connection problems trump dispatch
|
|
16
|
+
// state because the operator needs to know about a dropped admin-api
|
|
17
|
+
// first. When the connection is healthy (`on_watch` / `connecting`),
|
|
18
|
+
// the FSM dispatch state takes over to show the dispatch lifecycle
|
|
19
|
+
// (`dispatching` / `tool: read` / `aborting` / etc.).
|
|
20
|
+
const status = composeStatusLabel(props.connection, props.dispatchState, props.dispatchToolLabel);
|
|
16
21
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: status.color, children: `${glyph ?? '●'} ${status.label}` }), _jsx(Text, { dimColor: true, children: ` · ${props.activeAgentCount} agents · ` }), _jsx(Text, { children: `↓ ${tokenLabel} tokens` }), _jsx(Text, { dimColor: true, children: ` · ${elapsedLabel}` })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `${formatCount(props.pugiMdCount)} PUGI.md · ${formatCount(props.mcpServerCount)} MCP · ${formatCount(props.skillCount)} skills · ${formatQuota(props.quotaPct)} quota` }) })] }));
|
|
17
22
|
}
|
|
18
23
|
/**
|
|
@@ -28,10 +33,17 @@ function formatQuota(pct) {
|
|
|
28
33
|
return '—';
|
|
29
34
|
return `${Math.round(pct)}%`;
|
|
30
35
|
}
|
|
31
|
-
|
|
36
|
+
// Exported for test introspection (status-bar-fsm.spec.tsx asserts
|
|
37
|
+
// connecting vs on_watch render in distinct colors; ink-testing-library
|
|
38
|
+
// strips ANSI from lastFrame() so we read the resolved color directly).
|
|
39
|
+
export function connectionLabel(connection) {
|
|
32
40
|
switch (connection) {
|
|
33
41
|
case 'connecting':
|
|
34
|
-
|
|
42
|
+
// P2 fix: was 'cyan', same as 'on_watch' - operator could not
|
|
43
|
+
// tell boot from stable. Magenta is distinct from every other
|
|
44
|
+
// state in this palette (cyan steady, yellow reconnect, gray
|
|
45
|
+
// offline) so a brief flicker through this state stands out.
|
|
46
|
+
return { label: 'connecting', color: 'magenta' };
|
|
35
47
|
case 'on_watch':
|
|
36
48
|
return { label: 'on watch', color: 'cyan' };
|
|
37
49
|
case 'reconnecting':
|
|
@@ -40,6 +52,54 @@ function connectionLabel(connection) {
|
|
|
40
52
|
return { label: 'offline', color: 'gray' };
|
|
41
53
|
}
|
|
42
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* α6.9: compose the visible status label from connection + FSM state.
|
|
57
|
+
*
|
|
58
|
+
* Priority order:
|
|
59
|
+
*
|
|
60
|
+
* 1. `offline` / `reconnecting` — transport health wins; the
|
|
61
|
+
* operator needs to know about a dropped stream before anything
|
|
62
|
+
* about the dispatch.
|
|
63
|
+
* 2. `aborting` / `aborted` / `failed` — operator-visible terminal
|
|
64
|
+
* states the FSM reached; the colour shifts to amber/red so the
|
|
65
|
+
* anomaly stands out vs the calm cyan baseline.
|
|
66
|
+
* 3. `tool_running` — surfaces the tool label when available
|
|
67
|
+
* (`tool: read`), falls back to `tool` when not.
|
|
68
|
+
* 4. `awaiting_response` — `dispatching` (matches Codex CLI's verb
|
|
69
|
+
* for the same state).
|
|
70
|
+
* 5. `completed` — `shipped` (matches the agent tree status glyph
|
|
71
|
+
* so the operator's eye links the two surfaces).
|
|
72
|
+
* 6. `idle` / unknown — connection label (`on watch` / `connecting`).
|
|
73
|
+
*
|
|
74
|
+
* The dispatch label `dispatchToolLabel` is already shaped as
|
|
75
|
+
* `tool: <kind>` upstream so we just concatenate; null falls through
|
|
76
|
+
* to the bare `tool` placeholder.
|
|
77
|
+
*/
|
|
78
|
+
function composeStatusLabel(connection, dispatchState, toolLabel) {
|
|
79
|
+
// Transport health wins.
|
|
80
|
+
if (connection === 'offline' || connection === 'reconnecting') {
|
|
81
|
+
return connectionLabel(connection);
|
|
82
|
+
}
|
|
83
|
+
// FSM dispatch state overlay (only when the FSM was wired).
|
|
84
|
+
switch (dispatchState) {
|
|
85
|
+
case 'aborting':
|
|
86
|
+
return { label: 'aborting', color: 'yellow' };
|
|
87
|
+
case 'aborted':
|
|
88
|
+
return { label: 'aborted', color: 'gray' };
|
|
89
|
+
case 'failed':
|
|
90
|
+
return { label: 'failed', color: 'red' };
|
|
91
|
+
case 'tool_running':
|
|
92
|
+
return { label: toolLabel ?? 'tool', color: 'cyan' };
|
|
93
|
+
case 'awaiting_response':
|
|
94
|
+
return { label: 'dispatching', color: 'cyan' };
|
|
95
|
+
case 'completed':
|
|
96
|
+
return { label: 'shipped', color: 'green' };
|
|
97
|
+
case 'idle':
|
|
98
|
+
case undefined:
|
|
99
|
+
default:
|
|
100
|
+
return connectionLabel(connection);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
43
103
|
function formatElapsed(startedAt, now) {
|
|
44
104
|
if (typeof startedAt !== 'number')
|
|
45
105
|
return 'idle';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pugi/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.20",
|
|
4
4
|
"description": "Pugi CLI - terminal-native software execution system",
|
|
5
5
|
"homepage": "https://pugi.io",
|
|
6
6
|
"repository": {
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"undici": "^8.3.0",
|
|
53
53
|
"zod": "^3.23.0",
|
|
54
54
|
"@pugi/personas": "0.1.1",
|
|
55
|
-
"@pugi/sdk": "0.1.0-alpha.
|
|
55
|
+
"@pugi/sdk": "0.1.0-alpha.20"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@types/node": "^22.0.0",
|