@pugi/cli 0.1.0-beta.93 → 0.1.0-beta.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/commands/retro.js +210 -0
  2. package/dist/core/diagnostics/probes/sandbox.js +65 -33
  3. package/dist/core/engine/native-pugi.js +184 -10
  4. package/dist/core/engine/tool-bridge.js +35 -0
  5. package/dist/core/engine/verification-patterns.js +9 -9
  6. package/dist/core/mcp/orchestrator-config.js +192 -0
  7. package/dist/core/mcp/orchestrator-tools.js +147 -3
  8. package/dist/core/pugi-gitignore.js +52 -0
  9. package/dist/core/repl/engine-bridge.js +199 -0
  10. package/dist/core/repl/session.js +395 -6
  11. package/dist/core/repl/tool-route.js +382 -0
  12. package/dist/core/retro/git-collector.js +251 -0
  13. package/dist/core/retro/health-card.js +25 -0
  14. package/dist/core/retro/metrics.js +342 -0
  15. package/dist/core/retro/narrative.js +249 -0
  16. package/dist/core/retro/plane-collector.js +274 -0
  17. package/dist/core/retro/pr-issue-link.js +65 -0
  18. package/dist/core/retro/types.js +16 -0
  19. package/dist/core/sandboxing/adapter.js +29 -0
  20. package/dist/core/sandboxing/index.js +49 -0
  21. package/dist/core/sandboxing/none.js +19 -0
  22. package/dist/core/sandboxing/seatbelt.js +183 -0
  23. package/dist/core/session.js +27 -0
  24. package/dist/core/settings.js +22 -0
  25. package/dist/runtime/cli.js +167 -33
  26. package/dist/runtime/commands/mcp.js +64 -8
  27. package/dist/runtime/deprecation-warning.js +69 -0
  28. package/dist/runtime/headless.js +8 -3
  29. package/dist/runtime/stream-renderer.js +195 -0
  30. package/dist/runtime/version.js +1 -1
  31. package/dist/tui/agent-tree.js +11 -0
  32. package/dist/tui/ask-user-question-chips.js +1 -1
  33. package/dist/tui/multi-file-diff-approval.js +3 -3
  34. package/dist/tui/repl-render.js +42 -0
  35. 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
- if (this.fsm.isTerminal || current === 'idle')
587
- return false;
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 clear the
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 both parsers report no
3782
- // outstanding open tag - if either is still pending, we keep it on
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 || planResult.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;