@pugi/cli 0.1.0-beta.93 → 0.1.0-beta.94
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/commands/retro.js +210 -0
- package/dist/core/diagnostics/probes/sandbox.js +65 -33
- package/dist/core/engine/native-pugi.js +184 -10
- package/dist/core/engine/tool-bridge.js +35 -0
- package/dist/core/engine/verification-patterns.js +9 -9
- package/dist/core/mcp/orchestrator-config.js +192 -0
- package/dist/core/mcp/orchestrator-tools.js +147 -3
- package/dist/core/pugi-gitignore.js +52 -0
- package/dist/core/repl/engine-bridge.js +199 -0
- package/dist/core/repl/session.js +395 -6
- package/dist/core/repl/tool-route.js +382 -0
- package/dist/core/retro/git-collector.js +251 -0
- package/dist/core/retro/health-card.js +25 -0
- package/dist/core/retro/metrics.js +342 -0
- package/dist/core/retro/narrative.js +249 -0
- package/dist/core/retro/plane-collector.js +274 -0
- package/dist/core/retro/pr-issue-link.js +65 -0
- package/dist/core/retro/types.js +16 -0
- package/dist/core/sandboxing/adapter.js +29 -0
- package/dist/core/sandboxing/index.js +49 -0
- package/dist/core/sandboxing/none.js +19 -0
- package/dist/core/sandboxing/seatbelt.js +183 -0
- package/dist/core/session.js +27 -0
- package/dist/core/settings.js +22 -0
- package/dist/runtime/cli.js +167 -33
- package/dist/runtime/commands/mcp.js +64 -8
- package/dist/runtime/deprecation-warning.js +69 -0
- package/dist/runtime/headless.js +8 -3
- package/dist/runtime/stream-renderer.js +195 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tui/agent-tree.js +11 -0
- package/dist/tui/ask-user-question-chips.js +1 -1
- package/dist/tui/multi-file-diff-approval.js +3 -3
- package/dist/tui/repl-render.js +42 -0
- package/package.json +2 -2
|
@@ -40,6 +40,7 @@ import { applyRewindMask } from '../checkpoint/rewinder.js';
|
|
|
40
40
|
import { evaluateAutoCompact } from '../compact/auto-trigger.js';
|
|
41
41
|
import { estimateTokensInMany } from '../compact/token-counter.js';
|
|
42
42
|
import { extractAskTags, extractPlanReviewTags, signatureForAsk, } from './ask.js';
|
|
43
|
+
import { extractToolRouteTags, } from './tool-route.js';
|
|
43
44
|
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
44
45
|
import { resolve as resolvePath } from 'node:path';
|
|
45
46
|
import { CancellationToken } from './cancellation.js';
|
|
@@ -314,6 +315,30 @@ export class ReplSession {
|
|
|
314
315
|
* cleaned body). Codex triple-review P2 (PR).
|
|
315
316
|
*/
|
|
316
317
|
askBufferPending = new Set();
|
|
318
|
+
/**
|
|
319
|
+
* PUGI-538b () — pending `<pugi-tool-route>` envelope per
|
|
320
|
+
* coordinator taskId. Captured by `consumePugiToolRouteTag` when the
|
|
321
|
+
* envelope's close (or self-close) arrives in the running
|
|
322
|
+
* `agent.step.detail` buffer. The `agent.completed` handler reads
|
|
323
|
+
* the entry to decide whether to fire the engine bridge — firing
|
|
324
|
+
* mid-stream would race with the still-streaming coordinator turn.
|
|
325
|
+
* Cleared on terminal events (`completed` / `blocked` / `failed`).
|
|
326
|
+
*
|
|
327
|
+
* Only one envelope per coordinator turn is honoured (the prompt
|
|
328
|
+
* grammar refuses more than one); a second envelope on the same
|
|
329
|
+
* turn is dropped via the seen-signature rolling set so the dedupe
|
|
330
|
+
* lives in one place.
|
|
331
|
+
*/
|
|
332
|
+
pendingToolRoutes = new Map();
|
|
333
|
+
/**
|
|
334
|
+
* PUGI-538b () — abort controllers for in-flight engine
|
|
335
|
+
* bridges, keyed by `bridgeId`. When the REPL operator hits stop,
|
|
336
|
+
* `cancel()` walks this map and aborts every active bridge so the
|
|
337
|
+
* engine HTTP request closes promptly (the engine loop already
|
|
338
|
+
* honours `AbortSignal` via `EngineContext.signal`). Entries are
|
|
339
|
+
* deleted on bridge completion regardless of outcome.
|
|
340
|
+
*/
|
|
341
|
+
bridgeAborts = new Map();
|
|
317
342
|
constructor(options) {
|
|
318
343
|
this.options = options;
|
|
319
344
|
this.store = options.store ?? null;
|
|
@@ -533,6 +558,21 @@ export class ReplSession {
|
|
|
533
558
|
this.currentDispatchToken.abort();
|
|
534
559
|
this.currentDispatchToken = null;
|
|
535
560
|
}
|
|
561
|
+
// PUGI-538b () — abort every in-flight engine bridge on
|
|
562
|
+
// close() so the engine HTTP request closes promptly when the
|
|
563
|
+
// REPL itself shuts down (operator quit, process exit). Same
|
|
564
|
+
// defensive-try block as cancel() above; already-aborted
|
|
565
|
+
// controllers throw on some Node builds and close() must never
|
|
566
|
+
// crash the caller.
|
|
567
|
+
for (const controller of this.bridgeAborts.values()) {
|
|
568
|
+
try {
|
|
569
|
+
controller.abort();
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
// Best-effort.
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
this.bridgeAborts.clear();
|
|
536
576
|
if (this.streamHandle) {
|
|
537
577
|
this.streamHandle.close();
|
|
538
578
|
this.streamHandle = undefined;
|
|
@@ -583,13 +623,39 @@ export class ReplSession {
|
|
|
583
623
|
*/
|
|
584
624
|
cancel() {
|
|
585
625
|
const current = this.fsm.current;
|
|
586
|
-
|
|
587
|
-
|
|
626
|
+
const hasActiveBridge = this.bridgeAborts.size > 0;
|
|
627
|
+
// PUGI-538b () step 4 — bridge cancellation is allowed even
|
|
628
|
+
// when the FSM is terminal. The bridge runs ASYNC after the
|
|
629
|
+
// coordinator turn completes, so by the time the operator hits stop
|
|
630
|
+
// the FSM has already transitioned to `completed` and the legacy
|
|
631
|
+
// guard would short-circuit before the bridge-abort fan-out ran.
|
|
632
|
+
// Without this branch, Esc after the coordinator turn settles would
|
|
633
|
+
// not cancel an in-flight engine call.
|
|
634
|
+
if (this.fsm.isTerminal || current === 'idle') {
|
|
635
|
+
if (!hasActiveBridge)
|
|
636
|
+
return false;
|
|
637
|
+
// Fan out abort to every active bridge, then short-circuit before
|
|
638
|
+
// the FSM transitions (the FSM is already terminal; there is no
|
|
639
|
+
// dispatch token to fire, no SSE stream to tear down for the
|
|
640
|
+
// current dispatch). Map entries are cleared by the bridge
|
|
641
|
+
// promise's then/catch handlers when they unwind.
|
|
642
|
+
for (const controller of this.bridgeAborts.values()) {
|
|
643
|
+
try {
|
|
644
|
+
controller.abort();
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
// Defensive: already-aborted controllers throw on some Node
|
|
648
|
+
// builds. cancel() must never crash the caller.
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
this.appendSystemLine('Bridge aborted.');
|
|
652
|
+
return true;
|
|
653
|
+
}
|
|
588
654
|
// Step 2: transient state (UI sees `aborting` between abort signal
|
|
589
655
|
// and full shutdown).
|
|
590
656
|
this.fsm.transition('aborting', 'operator_abort');
|
|
591
657
|
// Step 3: fire the token so any mid-flight tool executor that
|
|
592
|
-
// polled `isAborted` shuts down. Token is single-use
|
|
658
|
+
// polled `isAborted` shuts down. Token is single-use; clear the
|
|
593
659
|
// ref AFTER both the abort fan-out AND the stream teardown so any
|
|
594
660
|
// onAbort listener calling getCurrentDispatchToken() during the
|
|
595
661
|
// teardown observes the (now-aborted) token rather than null.
|
|
@@ -618,6 +684,23 @@ export class ReplSession {
|
|
|
618
684
|
this.lastEventId = undefined;
|
|
619
685
|
// Null the token AFTER stream teardown (see step 3 comment).
|
|
620
686
|
this.currentDispatchToken = null;
|
|
687
|
+
// PUGI-538b () step 4 — abort every in-flight engine
|
|
688
|
+
// bridge. The bridge's AbortController is threaded through into
|
|
689
|
+
// the engine loop via EngineContext.signal (runEngineLoop already
|
|
690
|
+
// honours AbortSignal; see packages/pugi-sdk/src/engine-loop.ts
|
|
691
|
+
// around line 405). Aborting closes the engine HTTP request
|
|
692
|
+
// promptly so REPL stop cancels the bridge end-to-end, not just
|
|
693
|
+
// the local coordinator turn. Map entries are cleared by the
|
|
694
|
+
// bridge promise's then/catch handlers when they unwind.
|
|
695
|
+
for (const controller of this.bridgeAborts.values()) {
|
|
696
|
+
try {
|
|
697
|
+
controller.abort();
|
|
698
|
+
}
|
|
699
|
+
catch {
|
|
700
|
+
// Defensive: already-aborted controllers throw on some Node
|
|
701
|
+
// builds. cancel() must never crash the caller.
|
|
702
|
+
}
|
|
703
|
+
}
|
|
621
704
|
// Mark any agents that are still "running" as failed/aborted so
|
|
622
705
|
// the agent-tree pane reflects reality. We use the existing
|
|
623
706
|
// `failed` status (the tree pane already knows how to render it)
|
|
@@ -3222,6 +3305,20 @@ export class ReplSession {
|
|
|
3222
3305
|
// the green check + duration.
|
|
3223
3306
|
}
|
|
3224
3307
|
}
|
|
3308
|
+
// PUGI-538b () — after Pugi's coordinator turn settles,
|
|
3309
|
+
// fire the engine bridge for any pending `<pugi-tool-route>`
|
|
3310
|
+
// envelope stashed by `consumeAskAndPlanReviewTags`. The bridge
|
|
3311
|
+
// runs ASYNCHRONOUSLY (we deliberately do not await — the SSE
|
|
3312
|
+
// event handler must stay fast so the next frame is not
|
|
3313
|
+
// delayed). `runEngineBridge` is wrapped in its own try/catch
|
|
3314
|
+
// so a bridge failure cannot crash the REPL.
|
|
3315
|
+
const pendingRoute = this.pendingToolRoutes.get(event.taskId);
|
|
3316
|
+
if (pendingRoute) {
|
|
3317
|
+
this.pendingToolRoutes.delete(event.taskId);
|
|
3318
|
+
void this.runEngineBridge(pendingRoute).catch((err) => {
|
|
3319
|
+
this.appendSystemLine(`engine bridge crashed: ${this.errorMessage(err)}`);
|
|
3320
|
+
});
|
|
3321
|
+
}
|
|
3225
3322
|
return;
|
|
3226
3323
|
}
|
|
3227
3324
|
case 'agent.blocked': {
|
|
@@ -3232,6 +3329,11 @@ export class ReplSession {
|
|
|
3232
3329
|
}
|
|
3233
3330
|
this.askBuffer.delete(event.taskId);
|
|
3234
3331
|
this.askBufferPending.delete(event.taskId);
|
|
3332
|
+
// PUGI-538b () — drop any pending tool-route envelope on
|
|
3333
|
+
// an aborted coordinator turn. Firing the bridge after the
|
|
3334
|
+
// operator already stopped the dispatch would silently burn
|
|
3335
|
+
// engine tokens for work they cancelled.
|
|
3336
|
+
this.pendingToolRoutes.delete(event.taskId);
|
|
3235
3337
|
this.patch({
|
|
3236
3338
|
agents: this.state.agents.map((a) => a.taskId === event.taskId
|
|
3237
3339
|
? { ...a, status: 'blocked', detail: event.detail }
|
|
@@ -3259,6 +3361,9 @@ export class ReplSession {
|
|
|
3259
3361
|
}
|
|
3260
3362
|
this.askBuffer.delete(event.taskId);
|
|
3261
3363
|
this.askBufferPending.delete(event.taskId);
|
|
3364
|
+
// PUGI-538b () — drop any pending tool-route envelope on
|
|
3365
|
+
// an aborted/failed coordinator turn. See agent.blocked rationale.
|
|
3366
|
+
this.pendingToolRoutes.delete(event.taskId);
|
|
3262
3367
|
this.patch({
|
|
3263
3368
|
agents: this.state.agents.map((a) => a.taskId === event.taskId
|
|
3264
3369
|
? { ...a, status: 'failed', detail: event.error }
|
|
@@ -3776,12 +3881,39 @@ export class ReplSession {
|
|
|
3776
3881
|
if (planResult.hadMalformedTag) {
|
|
3777
3882
|
this.appendSystemLine('Malformed <pugi-plan-review> dropped (parser refusal).');
|
|
3778
3883
|
}
|
|
3884
|
+
// PUGI-538b () — third envelope family: `<pugi-tool-route>`.
|
|
3885
|
+
// Pugi emits it on the coordinator turn when the operator's brief
|
|
3886
|
+
// requires workspace tool use. We strip the raw XML from the
|
|
3887
|
+
// operator-visible body, dedupe via the seen-tag rolling set, and
|
|
3888
|
+
// STASH the parsed envelope keyed by taskId. The `agent.completed`
|
|
3889
|
+
// handler reads the stash and fires `bridgeToEngine` — firing
|
|
3890
|
+
// mid-stream would race with the still-streaming coordinator turn.
|
|
3891
|
+
const routeResult = extractToolRouteTags(working);
|
|
3892
|
+
working = routeResult.cleaned;
|
|
3893
|
+
for (const tag of routeResult.tags) {
|
|
3894
|
+
if (this.seenTagSignatures.includes(tag.signature))
|
|
3895
|
+
continue;
|
|
3896
|
+
this.recordSeenTag(tag.signature);
|
|
3897
|
+
if (this.pendingToolRoutes.has(taskId)) {
|
|
3898
|
+
// Grammar says one envelope per turn. A second on the same
|
|
3899
|
+
// taskId is dropped to a system line so the operator can see
|
|
3900
|
+
// why the bridge did not fire twice.
|
|
3901
|
+
this.appendSystemLine('Persona emitted a second <pugi-tool-route> while one was already pending. Dropped.');
|
|
3902
|
+
continue;
|
|
3903
|
+
}
|
|
3904
|
+
this.pendingToolRoutes.set(taskId, tag);
|
|
3905
|
+
}
|
|
3906
|
+
if (routeResult.hadMalformedTag) {
|
|
3907
|
+
this.appendSystemLine('Malformed <pugi-tool-route> dropped (parser refusal).');
|
|
3908
|
+
}
|
|
3779
3909
|
// Record / clear the "pending open tag" flag so agent.completed can
|
|
3780
3910
|
// emit a warning if the persona ends the turn with an unfinished
|
|
3781
|
-
// envelope. The flag flips OFF when
|
|
3782
|
-
// outstanding open tag - if
|
|
3911
|
+
// envelope. The flag flips OFF when ALL parsers report no
|
|
3912
|
+
// outstanding open tag - if any is still pending, we keep it on
|
|
3783
3913
|
// so the warning fires once at turn end.
|
|
3784
|
-
if (askResult.pendingOpenTag
|
|
3914
|
+
if (askResult.pendingOpenTag
|
|
3915
|
+
|| planResult.pendingOpenTag
|
|
3916
|
+
|| routeResult.pendingOpenTag) {
|
|
3785
3917
|
this.askBufferPending.add(taskId);
|
|
3786
3918
|
}
|
|
3787
3919
|
else {
|
|
@@ -3789,6 +3921,240 @@ export class ReplSession {
|
|
|
3789
3921
|
}
|
|
3790
3922
|
return working;
|
|
3791
3923
|
}
|
|
3924
|
+
/**
|
|
3925
|
+
* PUGI-538b () — public alias for the buffer-and-strip
|
|
3926
|
+
* routine, kept for test ergonomics and external callers that want
|
|
3927
|
+
* to invoke the parser without driving a full SSE replay. Mirrors
|
|
3928
|
+
* the per-task buffering contract the private method already obeys.
|
|
3929
|
+
*
|
|
3930
|
+
* Exposed for the new `repl-tool-route-bridge.spec.ts` so the spec
|
|
3931
|
+
* can assert that a streamed envelope is parsed and stripped without
|
|
3932
|
+
* needing to fabricate a full agent.step / agent.completed sequence.
|
|
3933
|
+
*/
|
|
3934
|
+
consumePugiToolRouteTag(taskId, detail) {
|
|
3935
|
+
return this.consumeAskAndPlanReviewTags(taskId, detail);
|
|
3936
|
+
}
|
|
3937
|
+
/**
|
|
3938
|
+
* PUGI-538b () — test-only inspector for the pending-tool-
|
|
3939
|
+
* route stash. Spec asserts that an envelope captured mid-stream
|
|
3940
|
+
* lands here and is cleared once the coordinator turn completes
|
|
3941
|
+
* (which fires the bridge).
|
|
3942
|
+
*/
|
|
3943
|
+
pendingToolRouteForTest(taskId) {
|
|
3944
|
+
return this.pendingToolRoutes.get(taskId);
|
|
3945
|
+
}
|
|
3946
|
+
/**
|
|
3947
|
+
* PUGI-538b () — fire the engine bridge for a parsed
|
|
3948
|
+
* `<pugi-tool-route>` envelope. This is the CLI half of
|
|
3949
|
+
* Path A: the coordinator turn's envelope routes the operational
|
|
3950
|
+
* brief through the production engine path (NativePugiEngineAdapter
|
|
3951
|
+
* → runEngineLoop → POST /api/pugi/engine) so workspace tool calls
|
|
3952
|
+
* actually write files instead of dumping prose-only heredocs.
|
|
3953
|
+
*
|
|
3954
|
+
* The actual engine adapter wiring lives in the REPL bootstrap
|
|
3955
|
+
* (`repl-render.tsx`); this method only:
|
|
3956
|
+
* 1. surfaces a "Routing to engine" system line so the operator
|
|
3957
|
+
* sees the handoff,
|
|
3958
|
+
* 2. mints a fresh AbortController and registers it in
|
|
3959
|
+
* `bridgeAborts` so REPL stop can cancel the bridge,
|
|
3960
|
+
* 3. inserts a synthetic `agent` row keyed off a `bridge-<uuid>`
|
|
3961
|
+
* taskId so the agent-tree pane renders the engine turn the
|
|
3962
|
+
* same way it renders a sub-agent,
|
|
3963
|
+
* 4. invokes `engineBridge` (the injected callback) and translates
|
|
3964
|
+
* every `BridgedEngineEvent` into a state patch on the synthetic
|
|
3965
|
+
* row,
|
|
3966
|
+
* 5. flips the synthetic row to its terminal status when the
|
|
3967
|
+
* bridge resolves, surfacing the engine's final reply text (if
|
|
3968
|
+
* any) on a persona row so the operator sees it in the
|
|
3969
|
+
* transcript.
|
|
3970
|
+
*
|
|
3971
|
+
* When no `engineBridge` is provided in `ReplSessionOptions` (e.g. a
|
|
3972
|
+
* test that opts out, or a CLI build that has not wired the adapter
|
|
3973
|
+
* yet) we surface a single system-line warning explaining why the
|
|
3974
|
+
* brief did not write files. This degradation preserves the pre-PR
|
|
3975
|
+
* "see code, no file" UX without adding the "see envelope, no file"
|
|
3976
|
+
* surprise on top.
|
|
3977
|
+
*/
|
|
3978
|
+
async runEngineBridge(tag) {
|
|
3979
|
+
const bridge = this.options.engineBridge;
|
|
3980
|
+
if (!bridge) {
|
|
3981
|
+
// No bridge wired — fall back to the pre-PR behaviour with one
|
|
3982
|
+
// additional honest sentence so the operator can see WHY no
|
|
3983
|
+
// files appeared. Triple-review surface: makes it obvious that
|
|
3984
|
+
// the regression mode is "bridge not wired in this build", not
|
|
3985
|
+
// "engine call failed". The brief is bounded by the parser at
|
|
3986
|
+
// 400 chars so this line cannot blow up the transcript.
|
|
3987
|
+
this.appendSystemLine(`Engine bridge not configured. Brief would have routed to ${tag.command}: "${tag.brief}".`);
|
|
3988
|
+
return;
|
|
3989
|
+
}
|
|
3990
|
+
const bridgeId = `bridge-${randomUUID()}`;
|
|
3991
|
+
const abort = new AbortController();
|
|
3992
|
+
this.bridgeAborts.set(bridgeId, abort);
|
|
3993
|
+
// Surface a system line so the operator sees the handoff before
|
|
3994
|
+
// engine events start flowing. The wording mirrors the prompt's
|
|
3995
|
+
// "Routing to engine" sentence so prompt + transcript stay in
|
|
3996
|
+
// lockstep.
|
|
3997
|
+
this.appendSystemLine(`Routing to engine (${tag.command} | ${tag.persona}): ${tag.brief}`);
|
|
3998
|
+
// PUGI-538b — insert a synthetic agent-tree node so the existing
|
|
3999
|
+
// pane renders the engine turn the same way it renders a
|
|
4000
|
+
// sub-agent. Role is `coder` because the engine path is the write
|
|
4001
|
+
// surface; the slug is the parsed persona hint so the pane shows
|
|
4002
|
+
// "Hiroshi" (or whichever Tier-1 the envelope asked for) instead
|
|
4003
|
+
// of a generic label.
|
|
4004
|
+
const startedAt = this.now();
|
|
4005
|
+
const personaName = this.resolveBridgePersonaName(tag.persona);
|
|
4006
|
+
const syntheticNode = {
|
|
4007
|
+
taskId: bridgeId,
|
|
4008
|
+
role: 'coder',
|
|
4009
|
+
personaSlug: tag.persona,
|
|
4010
|
+
personaName,
|
|
4011
|
+
status: 'thinking',
|
|
4012
|
+
detail: tag.brief,
|
|
4013
|
+
startedAtEpochMs: startedAt,
|
|
4014
|
+
tokensIn: 0,
|
|
4015
|
+
tokensOut: 0,
|
|
4016
|
+
};
|
|
4017
|
+
this.patch({ agents: [syntheticNode, ...this.state.agents] });
|
|
4018
|
+
const onEvent = (event) => {
|
|
4019
|
+
// Translate the bridge's typed events onto the synthetic
|
|
4020
|
+
// agent-tree node. We deliberately mirror the existing
|
|
4021
|
+
// agent.step / agent.tool / agent.tokens consumers above so the
|
|
4022
|
+
// UI surface stays uniform across delegate and bridge sub-agents.
|
|
4023
|
+
if (event.type === 'step') {
|
|
4024
|
+
this.patch({
|
|
4025
|
+
agents: this.state.agents.map((a) => a.taskId === bridgeId
|
|
4026
|
+
? { ...a, status: 'thinking', detail: event.detail }
|
|
4027
|
+
: a),
|
|
4028
|
+
});
|
|
4029
|
+
}
|
|
4030
|
+
else if (event.type === 'tool.start') {
|
|
4031
|
+
const mapped = normaliseBridgedToolName(event.tool);
|
|
4032
|
+
if (mapped !== null) {
|
|
4033
|
+
this.appendToolCall({
|
|
4034
|
+
id: `${bridgeId}-${mapped}-${this.now()}`,
|
|
4035
|
+
tool: mapped,
|
|
4036
|
+
args: (event.args ?? '').slice(0, 80),
|
|
4037
|
+
agent: tag.persona,
|
|
4038
|
+
status: 'running',
|
|
4039
|
+
startedAtEpochMs: this.now(),
|
|
4040
|
+
});
|
|
4041
|
+
}
|
|
4042
|
+
}
|
|
4043
|
+
else if (event.type === 'tool.result') {
|
|
4044
|
+
const mapped = normaliseBridgedToolName(event.tool);
|
|
4045
|
+
if (mapped !== null) {
|
|
4046
|
+
this.appendToolCall({
|
|
4047
|
+
id: `${bridgeId}-${mapped}-${this.now()}`,
|
|
4048
|
+
tool: mapped,
|
|
4049
|
+
args: '',
|
|
4050
|
+
agent: tag.persona,
|
|
4051
|
+
status: event.ok ? 'ok' : 'error',
|
|
4052
|
+
startedAtEpochMs: this.now(),
|
|
4053
|
+
resultPreview: (event.preview ?? '').slice(0, RESULT_PREVIEW_MAX_CHARS),
|
|
4054
|
+
});
|
|
4055
|
+
}
|
|
4056
|
+
}
|
|
4057
|
+
else if (event.type === 'tokens') {
|
|
4058
|
+
const deltaCostUsd = computeCostUsd(event.tokensIn, event.tokensOut, undefined);
|
|
4059
|
+
this.patch({
|
|
4060
|
+
tokensDownstreamTotal: this.state.tokensDownstreamTotal + event.tokensIn + event.tokensOut,
|
|
4061
|
+
sessionTokensIn: this.state.sessionTokensIn + event.tokensIn,
|
|
4062
|
+
sessionTokensOut: this.state.sessionTokensOut + event.tokensOut,
|
|
4063
|
+
sessionCostUsd: this.state.sessionCostUsd + deltaCostUsd,
|
|
4064
|
+
turnTokensIn: this.state.turnTokensIn + event.tokensIn,
|
|
4065
|
+
turnTokensOut: this.state.turnTokensOut + event.tokensOut,
|
|
4066
|
+
turnCostUsd: this.state.turnCostUsd + deltaCostUsd,
|
|
4067
|
+
agents: this.state.agents.map((a) => a.taskId === bridgeId
|
|
4068
|
+
? {
|
|
4069
|
+
...a,
|
|
4070
|
+
tokensIn: a.tokensIn + event.tokensIn,
|
|
4071
|
+
tokensOut: a.tokensOut + event.tokensOut,
|
|
4072
|
+
}
|
|
4073
|
+
: a),
|
|
4074
|
+
});
|
|
4075
|
+
}
|
|
4076
|
+
};
|
|
4077
|
+
let result;
|
|
4078
|
+
try {
|
|
4079
|
+
result = await bridge({
|
|
4080
|
+
command: tag.command,
|
|
4081
|
+
persona: tag.persona,
|
|
4082
|
+
brief: tag.brief,
|
|
4083
|
+
bridgeId,
|
|
4084
|
+
signal: abort.signal,
|
|
4085
|
+
onEvent,
|
|
4086
|
+
});
|
|
4087
|
+
}
|
|
4088
|
+
catch (err) {
|
|
4089
|
+
this.bridgeAborts.delete(bridgeId);
|
|
4090
|
+
const message = this.errorMessage(err);
|
|
4091
|
+
this.patch({
|
|
4092
|
+
agents: this.state.agents.map((a) => a.taskId === bridgeId
|
|
4093
|
+
? { ...a, status: 'failed', detail: message }
|
|
4094
|
+
: a),
|
|
4095
|
+
});
|
|
4096
|
+
this.appendSystemLine(`Engine bridge failed: ${message}`);
|
|
4097
|
+
return;
|
|
4098
|
+
}
|
|
4099
|
+
this.bridgeAborts.delete(bridgeId);
|
|
4100
|
+
// PUGI-538c-FU-OUTCOME (2026-06-05): the bridge outcome union now
|
|
4101
|
+
// carries `unverified`, which maps to the same-named agent-tree
|
|
4102
|
+
// status so a fresh customer repo with no test infra no longer
|
|
4103
|
+
// false-fails on the agent-tree pane. The verify-gate contract is
|
|
4104
|
+
// preserved: real `verification_command_failed` runs still surface
|
|
4105
|
+
// as `failed`; only `needs_verification` (no command detected)
|
|
4106
|
+
// downgrades to advisory.
|
|
4107
|
+
const terminalStatus = result.outcome === 'shipped'
|
|
4108
|
+
? 'shipped'
|
|
4109
|
+
: result.outcome === 'unverified'
|
|
4110
|
+
? 'unverified'
|
|
4111
|
+
: result.outcome === 'blocked'
|
|
4112
|
+
? 'blocked'
|
|
4113
|
+
: 'failed';
|
|
4114
|
+
this.patch({
|
|
4115
|
+
agents: this.state.agents.map((a) => a.taskId === bridgeId
|
|
4116
|
+
? { ...a, status: terminalStatus, detail: result.detail ?? terminalStatus }
|
|
4117
|
+
: a),
|
|
4118
|
+
});
|
|
4119
|
+
if (result.outcome === 'unverified') {
|
|
4120
|
+
// Operator-visible advisory: explain why the agent-tree node
|
|
4121
|
+
// landed in `unverified` rather than `shipped`. Files DID write
|
|
4122
|
+
// (the bridge proved that) but the gate could not certify the
|
|
4123
|
+
// run. Keep the wording neutral and actionable: avoid the word
|
|
4124
|
+
// "failed" so the operator does not lose trust in the engine.
|
|
4125
|
+
this.appendSystemLine('Pugi shipped files. No verification command detected; run your tests manually to confirm.');
|
|
4126
|
+
}
|
|
4127
|
+
if (result.finalText && result.finalText.trim().length > 0) {
|
|
4128
|
+
this.appendPersonaLine(tag.persona, result.finalText.trim());
|
|
4129
|
+
}
|
|
4130
|
+
}
|
|
4131
|
+
/**
|
|
4132
|
+
* PUGI-538b () — best-effort display-name lookup for a
|
|
4133
|
+
* bridge persona slug. The local frontend roster has the names; we
|
|
4134
|
+
* keep the resolver narrow (Tier-1 slugs only) so this method does
|
|
4135
|
+
* not pull in the full roster cycle. Unknown slugs fall back to a
|
|
4136
|
+
* title-cased version of the slug, which is the same fallback the
|
|
4137
|
+
* agent-tree pane uses for unrecognised persona slugs.
|
|
4138
|
+
*/
|
|
4139
|
+
resolveBridgePersonaName(slug) {
|
|
4140
|
+
const tier1 = {
|
|
4141
|
+
dev: 'Hiroshi',
|
|
4142
|
+
qa: 'Vera',
|
|
4143
|
+
pm: 'Olivia',
|
|
4144
|
+
devops: 'Diego',
|
|
4145
|
+
researcher: 'Anika',
|
|
4146
|
+
analyst: 'Liam',
|
|
4147
|
+
designer: 'Sofia',
|
|
4148
|
+
frontend: 'Mia',
|
|
4149
|
+
architect: 'Marcus',
|
|
4150
|
+
};
|
|
4151
|
+
const known = tier1[slug];
|
|
4152
|
+
if (known)
|
|
4153
|
+
return known;
|
|
4154
|
+
if (slug.length === 0)
|
|
4155
|
+
return 'Engine';
|
|
4156
|
+
return slug.charAt(0).toUpperCase() + slug.slice(1);
|
|
4157
|
+
}
|
|
3792
4158
|
recordSeenTag(signature) {
|
|
3793
4159
|
this.seenTagSignatures.push(signature);
|
|
3794
4160
|
while (this.seenTagSignatures.length > 32) {
|
|
@@ -3976,6 +4342,29 @@ function looksLikeMarkdown(text) {
|
|
|
3976
4342
|
// 2+ bullets OR 2+ numbered OR any heading = group atomically.
|
|
3977
4343
|
return bulletCount >= 2 || numberedCount >= 2 || headingCount >= 1;
|
|
3978
4344
|
}
|
|
4345
|
+
/**
|
|
4346
|
+
* PUGI-538b () — normalise a bridge-reported tool name onto
|
|
4347
|
+
* the REPL's closed `ToolCallEntry['tool']` set. The engine surface
|
|
4348
|
+
* has a wider tool registry (symbols.*, mcp_*, agent, …); the REPL
|
|
4349
|
+
* pane only renders the seven canonical names. Unknown names return
|
|
4350
|
+
* null so the bridge-event consumer skips the row instead of
|
|
4351
|
+
* crashing on an out-of-set string.
|
|
4352
|
+
*/
|
|
4353
|
+
function normaliseBridgedToolName(name) {
|
|
4354
|
+
const normalised = name.trim().toLowerCase();
|
|
4355
|
+
switch (normalised) {
|
|
4356
|
+
case 'read':
|
|
4357
|
+
case 'write':
|
|
4358
|
+
case 'edit':
|
|
4359
|
+
case 'bash':
|
|
4360
|
+
case 'grep':
|
|
4361
|
+
case 'glob':
|
|
4362
|
+
case 'web_fetch':
|
|
4363
|
+
return normalised;
|
|
4364
|
+
default:
|
|
4365
|
+
return null;
|
|
4366
|
+
}
|
|
4367
|
+
}
|
|
3979
4368
|
function safePersonaName(role) {
|
|
3980
4369
|
try {
|
|
3981
4370
|
return getPersonaForRole(role).name;
|