@aeondave/pi-persona 1.17.0 → 1.17.3

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 (109) hide show
  1. package/AGENTS.md +79 -29
  2. package/CHANGELOG.md +264 -0
  3. package/README.md +27 -12
  4. package/agents/architect.md +21 -0
  5. package/agents/balthasar.md +17 -22
  6. package/agents/casper.md +17 -23
  7. package/agents/evidence-verifier.md +31 -16
  8. package/agents/melchior.md +17 -22
  9. package/agents/operator.md +15 -55
  10. package/agents/research.md +14 -48
  11. package/agents/reviewer.md +20 -39
  12. package/agents/scout.md +14 -15
  13. package/agents/verifier.md +15 -16
  14. package/docs/ARCHITECTURE.md +223 -57
  15. package/docs/EXPERIENCE.md +19 -9
  16. package/docs/MONITORS.md +62 -3
  17. package/docs/REFERENCE.md +189 -35
  18. package/docs/SPINE.md +2 -2
  19. package/docs/STRATEGIES.md +23 -8
  20. package/docs/TELEMETRY.md +1 -0
  21. package/flows/deep-review.flow.json +1 -1
  22. package/package.json +9 -8
  23. package/personas/audit.md +28 -17
  24. package/personas/dev.md +30 -32
  25. package/personas/elite.md +223 -221
  26. package/personas/judge.md +25 -19
  27. package/personas/magi.md +26 -29
  28. package/personas/planner.md +31 -24
  29. package/personas/researcher.md +25 -26
  30. package/personas/swarm.md +35 -25
  31. package/personas/verify.md +30 -19
  32. package/src/agents/agent.ts +20 -0
  33. package/src/bridge.ts +3 -2
  34. package/src/core/brief.ts +429 -356
  35. package/src/core/config.ts +16 -11
  36. package/src/core/model-catalog.ts +171 -0
  37. package/src/core/model-tier.ts +89 -0
  38. package/src/core/models.ts +13 -4
  39. package/src/core/monitor-output.ts +85 -0
  40. package/src/core/monitor.ts +34 -2
  41. package/src/core/nudge.ts +176 -23
  42. package/src/core/seed.ts +99 -6
  43. package/src/core/session-runs.ts +87 -0
  44. package/src/core/settings.ts +251 -0
  45. package/src/core/timer.ts +34 -9
  46. package/src/engine/adapter.ts +12 -8
  47. package/src/engine/async.ts +195 -136
  48. package/src/engine/child.ts +1 -1
  49. package/src/engine/host-module.ts +12 -0
  50. package/src/engine/inproc.ts +11 -6
  51. package/src/engine/stream.ts +3 -0
  52. package/src/exocom/install.ts +2 -1
  53. package/src/extension/companion-settings.ts +29 -0
  54. package/src/extension/engine.ts +43 -16
  55. package/src/extension/hooks.ts +106 -36
  56. package/src/extension/persona-model-settings.ts +336 -0
  57. package/src/extension/persona-settings.ts +459 -0
  58. package/src/extension/shared.ts +15 -7
  59. package/src/extension.ts +642 -267
  60. package/src/monitor/archive.ts +235 -0
  61. package/src/monitor/session.ts +21 -1
  62. package/src/orchestration/flow-journal.ts +26 -5
  63. package/src/orchestration/flow-run.ts +38 -13
  64. package/src/orchestration/flow.ts +9 -2
  65. package/src/orchestration/reducers.ts +9 -5
  66. package/src/orchestration/render.ts +1 -1
  67. package/src/orchestration/sdk.ts +17 -3
  68. package/src/orchestration/strategies/compete.ts +5 -1
  69. package/src/orchestration/strategies/council-rounds.ts +2 -0
  70. package/src/orchestration/strategies/critic-loop.ts +3 -0
  71. package/src/orchestration/strategies/debate.ts +2 -0
  72. package/src/orchestration/strategies/fanout.ts +3 -2
  73. package/src/orchestration/strategies/judge.ts +5 -1
  74. package/src/orchestration/strategies/magi.ts +40 -23
  75. package/src/orchestration/strategies/map.ts +13 -3
  76. package/src/orchestration/strategies/pair.ts +2 -0
  77. package/src/orchestration/strategies/pipeline.ts +2 -0
  78. package/src/orchestration/strategies/synthesize.ts +27 -7
  79. package/src/orchestration/strategy.ts +31 -0
  80. package/src/persona/config-store.ts +6 -195
  81. package/src/persona/model-participants.ts +1 -1
  82. package/src/persona/persona.ts +7 -3
  83. package/src/persona/settings-store.ts +636 -0
  84. package/src/telemetry/contract.ts +1 -1
  85. package/src/tools/background-run.ts +151 -0
  86. package/src/tools/council.ts +77 -52
  87. package/src/tools/delegate-tool.ts +315 -285
  88. package/src/tools/delegate.ts +22 -210
  89. package/src/tools/exocom-work.ts +18 -53
  90. package/src/tools/exocom.ts +15 -19
  91. package/src/tools/flow.ts +85 -28
  92. package/src/tools/intercom-tool.ts +108 -118
  93. package/src/tools/intercom.ts +1 -1
  94. package/src/tools/models.ts +19 -9
  95. package/src/tools/monitor.ts +39 -6
  96. package/src/tools/timer.ts +11 -13
  97. package/src/ui/agent-overlay.ts +55 -14
  98. package/src/ui/agent-tree.ts +84 -18
  99. package/src/ui/compact-widget.ts +25 -0
  100. package/src/ui/failure-summary.ts +55 -0
  101. package/src/ui/literal-tool-result.ts +24 -0
  102. package/src/ui/model-picker.ts +158 -81
  103. package/src/ui/persona-settings.ts +328 -0
  104. package/src/ui/presentation.ts +24 -0
  105. package/src/ui/report-body.ts +63 -0
  106. package/src/ui/session-cards.ts +189 -0
  107. package/src/ui/wake-rows.ts +262 -0
  108. package/teams.yaml +5 -1
  109. package/src/persona/state.ts +0 -28
@@ -1,28 +1,13 @@
1
1
  /**
2
- * The `delegate` tool's core logic — pure, engine-injected, testable. Maps params
3
- * to engine runs (single + bounded parallel), formats the result, and emits live
4
- * per-task "views" (running → done/failed) so the UI can show each sub-agent as
5
- * it runs. A task may carry `skills`/`model`/`tools` to spawn a *dynamic* sub-agent
6
- * (e.g. the generic `operator` specialised by the skills it loads). Capability
7
- * gating happens upstream in the `tool_call` hook.
2
+ * The `delegate` tool's pure core: parameter coercion and validation, the task → run-spec mapping,
3
+ * and the anti-loop ledger. A task may carry `skills`/`model`/`tools` to spawn a *dynamic* sub-agent
4
+ * (e.g. the generic `operator` specialised by the skills it loads). Capability gating happens
5
+ * upstream in the `tool_call` hook; launching and settling live in `delegate-tool.ts`.
8
6
  */
9
7
 
10
- import { type ChildUsage, emptyUsage, type ToolEvent } from "../engine/stream.ts";
11
- import { mapWithConcurrency } from "../orchestration/parallel.ts";
12
8
  import { cappedList } from "../core/format.ts";
13
9
  import { sanitizeDisplayLabel } from "../core/display-label.ts";
14
- import { validateParallelWriteSets } from "../core/ownership.ts";
15
- import { aggregateResults } from "../orchestration/reducers.ts";
16
- import {
17
- type AgentRunSpec,
18
- type InfrastructureFailure,
19
- isInfrastructureFailure,
20
- isPositiveFiniteMs,
21
- legFailure,
22
- type SteerFn,
23
- type StrategyEngine,
24
- } from "../orchestration/sdk.ts";
25
- import type { AgentResult } from "../orchestration/types.ts";
10
+ import { type AgentRunSpec, type InfrastructureFailure, isInfrastructureFailure, isPositiveFiniteMs } from "../orchestration/sdk.ts";
26
11
 
27
12
  export interface DelegateTask {
28
13
  agent: string;
@@ -73,8 +58,6 @@ export interface DelegateParams {
73
58
  outputContract?: string;
74
59
  tasks?: DelegateTask[];
75
60
  concurrency?: number;
76
- async?: boolean;
77
- sync?: boolean;
78
61
  }
79
62
 
80
63
  /** Structured cold-start context that any persona policy may require. */
@@ -299,21 +282,6 @@ export function coerceDelegateParams(raw: unknown): { ok: true; params: Delegate
299
282
  return { ok: true, params: bag as DelegateParams };
300
283
  }
301
284
 
302
- /** A live per-sub-agent view for the UI (running → done/failed). */
303
- export interface DelegateView {
304
- agent: string;
305
- /** Display label: `name · model` (e.g. "kiln-port · sonnet-4-6"). */
306
- label: string;
307
- running: boolean;
308
- ok: boolean;
309
- /** Terminal failure classification; `abort` projects as stopped rather than failed in the UI. */
310
- failureKind?: AgentResult["failureKind"];
311
- output: string;
312
- /** The tool the leg is currently running (e.g. "grep src/…"), "" if none. */
313
- activity: string;
314
- usage: ChildUsage;
315
- }
316
-
317
285
  /** Short model name for display: drop provider + `claude-` + any `:thinking`. */
318
286
  export function shortModel(ref: string | undefined): string {
319
287
  if (!ref) return "";
@@ -323,8 +291,7 @@ export function shortModel(ref: string | undefined): string {
323
291
 
324
292
  /** The bare display NAME for a sub-agent (no model suffix): the loader's name, or a codename
325
293
  * for a generic operator, else the agent's own name. Exported so a caller that folds in the
326
- * model itself (e.g. the async tree node, `AsyncRun.label`) doesn't have to strip it back out
327
- * of {@link labelFor}'s composite string. */
294
+ * model itself (e.g. the async tree node, `AsyncRun.label`). */
328
295
  export function nameFor(t: { agent: string; name?: string }, index: number): string {
329
296
  // No catalog of call-signs: an unnamed generic operator stays the agent name, disambiguated
330
297
  // by index. The model is supposed to invent `<call-sign>-<purpose>` itself; this is only the
@@ -333,28 +300,8 @@ export function nameFor(t: { agent: string; name?: string }, index: number): str
333
300
  return sanitizeDisplayLabel(t.name?.trim() || fallback, fallback);
334
301
  }
335
302
 
336
- /** Display label for a sub-agent: its bare {@link nameFor} name plus its short model. */
337
- export function labelFor(t: { agent: string; name?: string; model?: string }, index: number): string {
338
- const name = nameFor(t, index);
339
- const m = shortModel(t.model);
340
- return m ? `${name} · ${m}` : name;
341
- }
342
-
343
- export interface DelegateOutcome {
344
- text: string;
345
- results: AgentResult[];
346
- views: DelegateView[];
347
- ok: boolean;
348
- }
349
-
350
- /** Hard ceilings the engine enforces regardless of what the LLM asks for. */
351
- export interface DelegateLimits {
352
- maxConcurrency: number;
353
- maxChildren: number;
354
- }
355
-
356
- /** Normalize an LLM-authored concurrency request once for sync scheduling, async scheduling, and
357
- * policy gates. Fractional/non-finite inputs fail toward serialization instead of rounding up. */
303
+ /** Normalize an LLM-authored concurrency request once for scheduling and policy gates.
304
+ * Fractional/non-finite inputs fail toward serialization instead of rounding up. */
358
305
  export function normalizeDelegateConcurrency(value: number | undefined, maximum: number): number {
359
306
  const ceiling = Math.max(1, Number.isFinite(maximum) ? Math.floor(maximum) : 1);
360
307
  if (value === undefined) return ceiling;
@@ -363,9 +310,8 @@ export function normalizeDelegateConcurrency(value: number | undefined, maximum:
363
310
  }
364
311
 
365
312
  /** Map a task to the run-spec fields the engine accepts — the single source of truth for the
366
- * delegate path's field mapping. Exported so the async launch path (extension.ts) routes
367
- * through the SAME mapping instead of duplicating it (a duplicated copy is how NP2's
368
- * `timeoutMs` could silently miss the async fan-out, which is the interactive default). */
313
+ * delegate path's field mapping (a second copy is how a per-leg knob like `timeoutMs` silently
314
+ * misses one launch route). */
369
315
  export function specOf(t: DelegateTask, index = 0): AgentRunSpec {
370
316
  const renderedBrief = renderDelegationBrief(t.brief);
371
317
  const spec: AgentRunSpec = {
@@ -406,6 +352,18 @@ export interface DelegationKeyInput {
406
352
  isolation?: string;
407
353
  }
408
354
 
355
+ /** The ledger's view of a task or run spec: just the key fields, absent when unset. */
356
+ export function keyInputOf(t: Pick<DelegateTask, "agent" | "task" | "model" | "role" | "tools" | "isolation">): DelegationKeyInput {
357
+ return {
358
+ agent: t.agent,
359
+ task: t.task,
360
+ ...(t.model ? { model: t.model } : {}),
361
+ ...(t.role ? { role: t.role } : {}),
362
+ ...(t.tools !== undefined ? { tools: t.tools } : {}),
363
+ ...(t.isolation ? { isolation: t.isolation } : {}),
364
+ };
365
+ }
366
+
409
367
  export class DelegationLedger {
410
368
  private readonly failures = new Map<string, number>();
411
369
  private readonly maxKeys = 200;
@@ -469,9 +427,6 @@ export class DelegationLedger {
469
427
  * an operator abort is cancellation, and an engine REJECTION is the harness breaking (see
470
428
  * {@link isInfrastructureFailure}) — a full disk or a crashed adapter says nothing about whether the
471
429
  * delegation was a good idea, and two of them would blacklist a task that was never really attempted.
472
- * Lives beside the ledger so every path that records feeds it the same notion of evidence: the sync
473
- * fan-out settles a rejection as `ok:false` while the single and async paths let it propagate, and
474
- * without this the identical infrastructure crash would count in one path and vanish in the others.
475
430
  */
476
431
  export function shouldRecordDelegationOutcome(result: Partial<InfrastructureFailure>): boolean {
477
432
  return result.failureKind !== "abort" && !isInfrastructureFailure(result);
@@ -518,146 +473,3 @@ export function unknownContractError(requested: Array<string | undefined>, insta
518
473
  "`outputContract` takes a contract NAME; omit it for free-form output and describe the report shape in `requiredArtifacts`."
519
474
  );
520
475
  }
521
-
522
- /**
523
- * Should this `delegate` call run in the BACKGROUND (async)? An explicit `async` always wins;
524
- * otherwise an interactive session (`hasUI`) defaults to background — the supervisor stays free and
525
- * results return as follow-ups — unless the call opts out with `sync: true`. A headless run (no UI,
526
- * e.g. `pi -p`) defaults to SYNC: the single turn must carry the result, and nothing drains a
527
- * follow-up after the process exits. `renderCall` passes `hasUI: true` (it only ever fires in a UI),
528
- * so the async tag it shows tracks the exact same default the execute path takes.
529
- */
530
- export function wantsAsyncRun(params: { async?: boolean; sync?: boolean }, hasUI: boolean): boolean {
531
- return params.async ?? (hasUI && params.sync !== true);
532
- }
533
-
534
- function viewOf(agent: string, label: string, r: AgentResult): DelegateView {
535
- return {
536
- agent,
537
- label,
538
- running: false,
539
- ok: r.ok,
540
- ...(r.failureKind !== undefined ? { failureKind: r.failureKind } : {}),
541
- output: r.ok ? r.output || "(no output)" : r.error ?? "(failed)",
542
- activity: "",
543
- usage: r.usage,
544
- };
545
- }
546
-
547
- export async function runDelegate(
548
- params: DelegateParams,
549
- engine: StrategyEngine,
550
- limits: DelegateLimits = { maxConcurrency: 4, maxChildren: 8 },
551
- onProgress?: (views: DelegateView[]) => void,
552
- onLegStart?: (index: number, abort: () => void) => void,
553
- onLegSteerable?: (index: number, steer: SteerFn) => void,
554
- /** The RUN-level abort signal — the same one the caller baked into its engine. A whole-run
555
- * cancellation reaches a leg through THIS, never through the per-leg controller below, so
556
- * without it a cancelled leg whose engine rejects is filed as an agent failure and the user's
557
- * own stop renders as FAILED. Mirrors `SDKDeps.signal`, which classifies the same way. */
558
- signal?: AbortSignal,
559
- /** Authoritative per-leg tool lifecycle; arguments and output are intentionally absent. */
560
- onLegToolEvent?: (index: number, event: ToolEvent) => void,
561
- ): Promise<DelegateOutcome> {
562
- if (params.tasks && params.tasks.length > 0) {
563
- // Enforce the hard ceilings: cap the fan-out and clamp the concurrency the
564
- // LLM requested — a strategy/tool call cannot exceed the declared limits.
565
- const tasks = params.tasks.slice(0, limits.maxChildren);
566
- const dropped = params.tasks.length - tasks.length;
567
- const concurrency = normalizeDelegateConcurrency(params.concurrency, limits.maxConcurrency);
568
- if (concurrency > 1) {
569
- const writeSetError = validateParallelWriteSets(tasks);
570
- if (writeSetError) return { text: writeSetError, results: [], views: [], ok: false };
571
- }
572
- const labels = tasks.map((t, i) => labelFor(t, i));
573
- const views: DelegateView[] = tasks.map((t, i) => ({
574
- agent: t.agent,
575
- label: labels[i] as string,
576
- running: true,
577
- ok: false,
578
- output: "",
579
- activity: "",
580
- usage: emptyUsage(),
581
- }));
582
- onProgress?.(views.map((v) => ({ ...v })));
583
-
584
- const results = await mapWithConcurrency(tasks, concurrency, async (t, i) => {
585
- const ac = new AbortController();
586
- onLegStart?.(i, () => ac.abort());
587
- // An engine that REJECTS becomes this leg's own ok:false result: the fan-out's completed
588
- // siblings (already spawned and billed) must still reach the supervisor. Nothing in here
589
- // is run-fatal — the max-children ceiling was applied to `tasks` before anything spawned.
590
- let r: AgentResult;
591
- try {
592
- r = await engine.run(
593
- specOf(t, i),
594
- (p) => {
595
- // Stream the leg's rolling output + current tool activity.
596
- if (p.toolEvent) onLegToolEvent?.(i, p.toolEvent);
597
- const cur = views[i] as DelegateView;
598
- views[i] = { ...cur, output: p.output || cur.output, activity: p.activity ?? "" };
599
- onProgress?.(views.map((v) => ({ ...v })));
600
- },
601
- ac.signal,
602
- (steer) => onLegSteerable?.(i, steer),
603
- );
604
- } catch (err) {
605
- r = legFailure(t.agent, err, ac.signal.aborted || (signal?.aborted ?? false));
606
- }
607
- views[i] = viewOf(t.agent, labels[i] as string, r);
608
- onProgress?.(views.map((v) => ({ ...v })));
609
- return r;
610
- });
611
- const agg = aggregateResults(results);
612
- const text =
613
- dropped > 0
614
- ? `${agg.output}\n\n[pi-persona] ${dropped} task(s) beyond the max-children limit (${limits.maxChildren}) were dropped.`
615
- : agg.output;
616
- return { text, results, views, ok: agg.ok };
617
- }
618
-
619
- if (params.agent && params.task) {
620
- const single: DelegateTask = { agent: params.agent, task: params.task };
621
- if (params.name) single.name = params.name;
622
- if (params.skills && params.skills.length > 0) single.skills = params.skills;
623
- if (params.model) single.model = params.model;
624
- if (params.tools !== undefined) single.tools = params.tools;
625
- if (params.role?.trim()) single.role = params.role.trim();
626
- if (params.isolation !== undefined) single.isolation = params.isolation;
627
- if (params.mcp !== undefined) single.mcp = params.mcp;
628
- if (params.timeoutMs !== undefined) single.timeoutMs = params.timeoutMs;
629
- if (params.brief !== undefined) single.brief = params.brief;
630
- if (params.writeSet !== undefined) single.writeSet = params.writeSet;
631
- if (params.outputContract !== undefined) single.outputContract = params.outputContract;
632
- const label = labelFor(single, 0);
633
- const view: DelegateView = {
634
- agent: params.agent,
635
- label,
636
- running: true,
637
- ok: false,
638
- output: "",
639
- activity: "",
640
- usage: emptyUsage(),
641
- };
642
- onProgress?.([{ ...view }]);
643
- const ac = new AbortController();
644
- onLegStart?.(0, () => ac.abort());
645
- const r = await engine.run(
646
- specOf(single),
647
- (p) => {
648
- if (p.toolEvent) onLegToolEvent?.(0, p.toolEvent);
649
- if (p.output) view.output = p.output;
650
- view.activity = p.activity ?? "";
651
- onProgress?.([{ ...view }]);
652
- },
653
- ac.signal,
654
- (steer) => onLegSteerable?.(0, steer),
655
- );
656
- const text = r.ok ? r.output || "(no output)" : `Agent "${params.agent}" failed: ${r.error ?? "(no detail)"}`;
657
- const terminalView = viewOf(params.agent, label, r);
658
- onProgress?.([{ ...terminalView }]);
659
- return { text, results: [r], views: [terminalView], ok: r.ok };
660
- }
661
-
662
- return { text: "delegate: provide either { agent, task } or { tasks: [{ agent, task }, ...] }", results: [], views: [], ok: false };
663
- }
@@ -5,7 +5,7 @@
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { type Static, Type } from "typebox";
7
7
 
8
- import { type ExtensionAPI, keyHint } from "@earendil-works/pi-coding-agent";
8
+ import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
9
  import { Text } from "@earendil-works/pi-tui";
10
10
 
11
11
  import { attributePeer } from "../core/fence.ts";
@@ -15,6 +15,8 @@ import type { ExocomPlane } from "../exocom/plane.ts";
15
15
  import type { ExocomSemanticFrame } from "../exocom/envelope.ts";
16
16
  import { EXOCOM_WAIT_MAX_MS, waitTimeoutMs } from "../exocom/wait.ts";
17
17
  import { normalizeMetadataText } from "../exocom/registry.ts";
18
+ import { renderLiteralToolResult } from "../ui/literal-tool-result.ts";
19
+ import { createReportBody } from "../ui/report-body.ts";
18
20
 
19
21
  const TOKEN = { minLength: 1, maxLength: 128, pattern: "^[A-Za-z0-9._:-]+$" } as const;
20
22
 
@@ -90,11 +92,6 @@ function result(text: string, details: Record<string, unknown>) {
90
92
  return { content: [{ type: "text" as const, text }], details };
91
93
  }
92
94
 
93
- function workToolFailed(renderedResult: unknown): boolean {
94
- return !!renderedResult && typeof renderedResult === "object"
95
- && (renderedResult as { isError?: unknown }).isError === true;
96
- }
97
-
98
95
  export function registerExocomWorkTools(pi: ExtensionAPI, deps: ExocomWorkDeps): void {
99
96
  const identity = () => {
100
97
  const plane = deps.getPlane();
@@ -122,12 +119,7 @@ export function registerExocomWorkTools(pi: ExtensionAPI, deps: ExocomWorkDeps):
122
119
  renderCall(args, theme) {
123
120
  return new Text(`${theme.fg("toolTitle", theme.bold("Exocom Claim "))}${theme.fg("accent", normalizeMetadataText(args.work_key, 80, "work"))}`, 0, 0);
124
121
  },
125
- renderResult(res, { expanded }, theme) {
126
- const first = res.content.find((item) => item.type === "text");
127
- const text = first?.type === "text" ? first.text : "Exocom claim failed";
128
- if (workToolFailed(res)) return new Text(theme.fg("error", text), 0, 0);
129
- return new Text(theme.fg(expanded ? "toolOutput" : "accent", expanded ? text : `${text} (${keyHint("app.tools.expand", "to expand")})`), 0, 0);
130
- },
122
+ renderResult: renderLiteralToolResult,
131
123
  });
132
124
 
133
125
  pi.registerTool({
@@ -161,12 +153,7 @@ export function registerExocomWorkTools(pi: ExtensionAPI, deps: ExocomWorkDeps):
161
153
  renderCall(args, theme) {
162
154
  return new Text(`${theme.fg("toolTitle", theme.bold("Exocom Ask "))}${theme.fg("accent", normalizeMetadataText(args.target, 80, "peer"))}`, 0, 0);
163
155
  },
164
- renderResult(res, { expanded }, theme) {
165
- const first = res.content.find((item) => item.type === "text");
166
- const text = first?.type === "text" ? first.text : "Exocom ask failed";
167
- if (workToolFailed(res)) return new Text(theme.fg("error", text), 0, 0);
168
- return new Text(theme.fg(expanded ? "toolOutput" : "accent", expanded ? text : `${text} (${keyHint("app.tools.expand", "to expand")})`), 0, 0);
169
- },
156
+ renderResult: renderLiteralToolResult,
170
157
  });
171
158
 
172
159
  pi.registerTool({
@@ -191,12 +178,7 @@ export function registerExocomWorkTools(pi: ExtensionAPI, deps: ExocomWorkDeps):
191
178
  renderCall(args, theme) {
192
179
  return new Text(`${theme.fg("toolTitle", theme.bold("Exocom Answer "))}${theme.fg("accent", normalizeMetadataText(args.ask_id, 80, "ask"))}`, 0, 0);
193
180
  },
194
- renderResult(res, { expanded }, theme) {
195
- const first = res.content.find((item) => item.type === "text");
196
- const text = first?.type === "text" ? first.text : "Exocom answer failed";
197
- if (workToolFailed(res)) return new Text(theme.fg("error", text), 0, 0);
198
- return new Text(theme.fg(expanded ? "toolOutput" : "accent", expanded ? text : `${text} (${keyHint("app.tools.expand", "to expand")})`), 0, 0);
199
- },
181
+ renderResult: renderLiteralToolResult,
200
182
  });
201
183
 
202
184
  pi.registerTool({
@@ -221,12 +203,7 @@ export function registerExocomWorkTools(pi: ExtensionAPI, deps: ExocomWorkDeps):
221
203
  renderCall(args, theme) {
222
204
  return new Text(`${theme.fg("toolTitle", theme.bold("Exocom Decline "))}${theme.fg("accent", normalizeMetadataText(args.ask_id, 80, "ask"))}`, 0, 0);
223
205
  },
224
- renderResult(res, { expanded }, theme) {
225
- const first = res.content.find((item) => item.type === "text");
226
- const text = first?.type === "text" ? first.text : "Exocom decline failed";
227
- if (workToolFailed(res)) return new Text(theme.fg("error", text), 0, 0);
228
- return new Text(theme.fg(expanded ? "toolOutput" : "accent", expanded ? text : `${text} (${keyHint("app.tools.expand", "to expand")})`), 0, 0);
229
- },
206
+ renderResult: renderLiteralToolResult,
230
207
  });
231
208
 
232
209
  pi.registerTool({
@@ -250,11 +227,14 @@ export function registerExocomWorkTools(pi: ExtensionAPI, deps: ExocomWorkDeps):
250
227
  renderCall(args, theme) {
251
228
  return new Text(`${theme.fg("toolTitle", theme.bold("Exocom Wait "))}${theme.fg("accent", normalizeMetadataText(args.work_key, 80, "work"))}`, 0, 0);
252
229
  },
253
- renderResult(res, { expanded }, theme) {
254
- const first = res.content.find((item) => item.type === "text");
255
- const text = first?.type === "text" ? first.text : "Exocom wait failed";
256
- if (workToolFailed(res)) return new Text(theme.fg("error", text), 0, 0);
257
- return new Text(theme.fg(expanded ? "toolOutput" : "accent", expanded ? text : `${text} (${keyHint("app.tools.expand", "to expand")})`), 0, 0);
230
+ renderResult(res, options, theme, context) {
231
+ // A retained answer is peer prose, not the literal receipt for arming a wait.
232
+ // Its ok=false can be a valid negative answer; only the host error flag is failure.
233
+ if (options.expanded && !(context?.isError ?? res.isError) && res.details?.status === "answered") {
234
+ const text = res.content.filter((part) => part.type === "text").map((part) => part.text).join("\n");
235
+ return createReportBody(text, theme, {}, context?.lastComponent);
236
+ }
237
+ return renderLiteralToolResult(res, options, theme, context);
258
238
  },
259
239
  });
260
240
 
@@ -274,12 +254,7 @@ export function registerExocomWorkTools(pi: ExtensionAPI, deps: ExocomWorkDeps):
274
254
  renderCall(args, theme) {
275
255
  return new Text(`${theme.fg("toolTitle", theme.bold("Exocom Release "))}${theme.fg("accent", normalizeMetadataText(args.work_key, 80, "work"))}`, 0, 0);
276
256
  },
277
- renderResult(res, { expanded }, theme) {
278
- const first = res.content.find((item) => item.type === "text");
279
- const text = first?.type === "text" ? first.text : "Exocom release failed";
280
- if (workToolFailed(res)) return new Text(theme.fg("error", text), 0, 0);
281
- return new Text(theme.fg(expanded ? "toolOutput" : "accent", expanded ? text : `${text} (${keyHint("app.tools.expand", "to expand")})`), 0, 0);
282
- },
257
+ renderResult: renderLiteralToolResult,
283
258
  });
284
259
 
285
260
  pi.registerTool({
@@ -298,12 +273,7 @@ export function registerExocomWorkTools(pi: ExtensionAPI, deps: ExocomWorkDeps):
298
273
  renderCall(args, theme) {
299
274
  return new Text(`${theme.fg("toolTitle", theme.bold("Exocom Progress "))}${theme.fg("accent", normalizeMetadataText(args.work_key, 80, "work"))}`, 0, 0);
300
275
  },
301
- renderResult(res, { expanded }, theme) {
302
- const first = res.content.find((item) => item.type === "text");
303
- const text = first?.type === "text" ? first.text : "Exocom progress failed";
304
- if (workToolFailed(res)) return new Text(theme.fg("error", text), 0, 0);
305
- return new Text(theme.fg(expanded ? "toolOutput" : "accent", expanded ? text : `${text} (${keyHint("app.tools.expand", "to expand")})`), 0, 0);
306
- },
276
+ renderResult: renderLiteralToolResult,
307
277
  });
308
278
 
309
279
  pi.registerTool({
@@ -318,11 +288,6 @@ export function registerExocomWorkTools(pi: ExtensionAPI, deps: ExocomWorkDeps):
318
288
  renderCall(_args, theme) {
319
289
  return new Text(theme.fg("toolTitle", theme.bold("Exocom Status")), 0, 0);
320
290
  },
321
- renderResult(res, { expanded }, theme) {
322
- const first = res.content.find((item) => item.type === "text");
323
- const text = first?.type === "text" ? first.text : "Exocom status failed";
324
- if (workToolFailed(res)) return new Text(theme.fg("error", text), 0, 0);
325
- return new Text(theme.fg(expanded ? "toolOutput" : "accent", expanded ? text : `${text} (${keyHint("app.tools.expand", "to expand")})`), 0, 0);
326
- },
291
+ renderResult: renderLiteralToolResult,
327
292
  });
328
293
  }
@@ -19,6 +19,7 @@ import { type Static, Type } from "typebox";
19
19
 
20
20
  import { keyHint, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
21
  import { Text } from "@earendil-works/pi-tui";
22
+ import { renderLiteralToolResult } from "../ui/literal-tool-result.ts";
22
23
 
23
24
  import { fencePeer } from "../core/fence.ts";
24
25
  import { inventedExocomNameHint } from "../core/naming.ts";
@@ -227,12 +228,11 @@ function isSendDetails(details: unknown): details is ExocomSendDetails {
227
228
  return Array.isArray(shape.failed) || typeof shape.msg_id === "string";
228
229
  }
229
230
 
230
- function renderSendResult(details: ExocomSendDetails, expanded: boolean): string {
231
+ function renderSendResult(details: ExocomSendDetails): string {
231
232
  if ("failed" in details) {
232
233
  const failed = details.failedCount ?? details.failed.length;
233
234
  const queued = details.queuedCount ?? details.msg_ids.length;
234
- const summary = `${queued > 0 ? "✓" : "✗"} queued ${queued} peer${queued === 1 ? "" : "s"}${failed > 0 ? ` · ${failed} failed` : ""}`;
235
- if (!expanded) return `${summary} (${keyHint("app.tools.expand", "to expand")})`;
235
+ const summary = `queued ${queued} peer${queued === 1 ? "" : "s"}${failed > 0 ? ` · ${failed} failed` : ""}`;
236
236
  const lines = [summary];
237
237
  if (details.msg_ids.length > 0) lines.push(`msg_ids: ${details.msg_ids.join(", ")}`);
238
238
  for (const failure of details.failed) {
@@ -242,8 +242,7 @@ function renderSendResult(details: ExocomSendDetails, expanded: boolean): string
242
242
  if ((details.omittedFailures ?? 0) > 0) lines.push(`…and ${details.omittedFailures} more failures omitted; retry only the failed targets shown above.`);
243
243
  return lines.join("\n");
244
244
  }
245
- const summary = `✓ queued to ${details.target} · msg_id=${details.msg_id}`;
246
- return expanded ? summary : `${summary} (${keyHint("app.tools.expand", "to expand")})`;
245
+ return `queued to ${details.target} · msg_id=${details.msg_id}`;
247
246
  }
248
247
 
249
248
  /** `getPlane` is a LIVE accessor, not a captured value: a `canUseBus` downgrade (`stopExocom`,
@@ -294,7 +293,7 @@ export function registerExocomTools(
294
293
  renderCall(_args, theme) {
295
294
  return new Text(theme.fg("toolTitle", theme.bold("Exocom List")), 0, 0);
296
295
  },
297
- renderResult(result, { expanded }, theme) {
296
+ renderResult(result, { expanded }, theme, context) {
298
297
  const details = result.details as unknown as {
299
298
  peers?: ExocomPeerSummary[];
300
299
  total?: number;
@@ -303,9 +302,8 @@ export function registerExocomTools(
303
302
  scopeCode?: string;
304
303
  homeWorkspace?: { label?: string; code?: string; sameAsScope?: boolean };
305
304
  } | undefined;
306
- if (!details || !Array.isArray(details.peers)) {
307
- const first = result.content.find((item) => item.type === "text");
308
- return new Text(theme.fg("error", first?.type === "text" ? first.text : "Exocom list failed"), 0, 0);
305
+ if (context?.isError || !details || !Array.isArray(details.peers)) {
306
+ return renderLiteralToolResult(result, { expanded }, theme, { isError: true });
309
307
  }
310
308
  const peers = details.peers;
311
309
  const total = typeof details.total === "number" ? details.total : peers.length;
@@ -448,15 +446,14 @@ export function registerExocomTools(
448
446
  const suffix = preview.length < message.length ? "…" : "";
449
447
  return new Text(`${theme.fg("toolTitle", theme.bold("Exocom Send "))}${theme.fg("accent", target)}${preview ? theme.fg("dim", ` ${preview}${suffix}`) : ""}`, 0, 0);
450
448
  },
451
- renderResult(result, { expanded }, theme) {
449
+ renderResult(result, { expanded }, theme, context) {
452
450
  const details = result.details as unknown as ExocomSendDetails | undefined;
453
451
  // pi renders this for a FAILED call too, handing us the error's empty details object — an
454
452
  // unrecognised shape is a failure to show, not a send to congratulate the user on.
455
- if (!isSendDetails(details)) {
456
- const text = result.content.find((item) => item.type === "text");
457
- return new Text(theme.fg("error", text?.type === "text" ? text.text : "(no result)"), 0, 0);
453
+ if (context?.isError || !isSendDetails(details)) {
454
+ return renderLiteralToolResult(result, { expanded }, theme, { isError: true });
458
455
  }
459
- return new Text(theme.fg(expanded ? "toolOutput" : "accent", renderSendResult(details, expanded)), 0, 0);
456
+ return renderLiteralToolResult({ ...result, content: [{ type: "text", text: renderSendResult(details) }] }, { expanded }, theme, context);
460
457
  },
461
458
  });
462
459
 
@@ -475,15 +472,14 @@ export function registerExocomTools(
475
472
  renderCall(args, theme) {
476
473
  return new Text(`${theme.fg("toolTitle", theme.bold("Exocom Name "))}${theme.fg("accent", normalizeMetadataText(args.name, 96, "peer"))}`, 0, 0);
477
474
  },
478
- renderResult(result, { expanded }, theme) {
475
+ renderResult(result, { expanded }, theme, context) {
479
476
  const details = result.details as unknown as { name?: string } | undefined;
480
- if (!details?.name) {
481
- const first = result.content.find((item) => item.type === "text");
482
- return new Text(theme.fg("error", first?.type === "text" ? first.text : "Exocom name failed"), 0, 0);
477
+ if (context?.isError || !details?.name) {
478
+ return renderLiteralToolResult(result, { expanded }, theme, { isError: true });
483
479
  }
484
480
  const name = normalizeMetadataText(details.name, 96, "(unchanged)");
485
481
  const text = `exocom: you are now "${name}"`;
486
- return new Text(theme.fg(expanded ? "toolOutput" : "accent", expanded ? text : `${text} (${keyHint("app.tools.expand", "to expand")})`), 0, 0);
482
+ return renderLiteralToolResult({ ...result, content: [{ type: "text", text }] }, { expanded }, theme, context);
487
483
  },
488
484
  });
489
485
  }