@aeondave/pi-persona 1.17.3 → 1.17.4

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 (84) hide show
  1. package/AGENTS.md +44 -36
  2. package/CHANGELOG.md +76 -1
  3. package/README.md +23 -13
  4. package/docs/ARCHITECTURE.md +97 -37
  5. package/docs/COLLABORATION.md +110 -0
  6. package/docs/EXPERIENCE.md +33 -8
  7. package/docs/README.md +12 -0
  8. package/docs/REFERENCE.md +65 -14
  9. package/package.json +12 -6
  10. package/src/agents/agent.ts +6 -0
  11. package/src/bridge.ts +25 -8
  12. package/src/bus/broker/client.ts +119 -21
  13. package/src/bus/broker/host.ts +123 -8
  14. package/src/bus/broker/messages.ts +31 -4
  15. package/src/bus/contact.ts +113 -38
  16. package/src/bus/peers.ts +15 -0
  17. package/src/bus/result-reader.ts +88 -0
  18. package/src/core/brief.ts +14 -4
  19. package/src/core/capabilities.ts +1 -1
  20. package/src/core/leg-thinking.ts +57 -0
  21. package/src/core/ownership.ts +1 -2
  22. package/src/core/pi-compat.ts +5 -12
  23. package/src/core/session-runs.ts +1 -1
  24. package/src/core/types.ts +1 -1
  25. package/src/engine/adapter.ts +83 -19
  26. package/src/engine/async.ts +49 -10
  27. package/src/engine/child.ts +10 -2
  28. package/src/engine/inproc.ts +87 -46
  29. package/src/engine/result-grant.ts +62 -0
  30. package/src/engine/session-tools.ts +6 -0
  31. package/src/engine/stream.ts +6 -0
  32. package/src/engine/worktree.ts +17 -7
  33. package/src/exocom/collaboration-delivery.ts +29 -0
  34. package/src/exocom/collaboration.ts +148 -0
  35. package/src/exocom/envelope.ts +43 -4
  36. package/src/exocom/gate.ts +7 -2
  37. package/src/exocom/install.ts +45 -17
  38. package/src/exocom/ledger.ts +58 -8
  39. package/src/exocom/plane.ts +32 -1
  40. package/src/exocom/profile.ts +40 -0
  41. package/src/exocom/registry.ts +5 -0
  42. package/src/exocom/status.ts +11 -1
  43. package/src/exocom/write-guard.ts +7 -0
  44. package/src/extension/broker-host.ts +61 -9
  45. package/src/extension/engine.ts +20 -21
  46. package/src/extension/hooks.ts +952 -938
  47. package/src/extension/identity.ts +41 -75
  48. package/src/extension/results.ts +136 -0
  49. package/src/extension/shared.ts +17 -0
  50. package/src/extension.ts +2963 -2873
  51. package/src/loader.ts +11 -3
  52. package/src/orchestration/flow-journal.ts +7 -1
  53. package/src/orchestration/flow-run.ts +122 -19
  54. package/src/orchestration/flow.ts +11 -0
  55. package/src/orchestration/roster.ts +71 -23
  56. package/src/orchestration/sdk.ts +44 -6
  57. package/src/orchestration/types.ts +10 -0
  58. package/src/persona/model-participants.ts +20 -7
  59. package/src/persona/orchestrate.ts +2 -0
  60. package/src/persona/persona.ts +1 -1
  61. package/src/results/archive.ts +281 -0
  62. package/src/results/record.ts +166 -0
  63. package/src/results/retrieval.ts +142 -0
  64. package/src/results/types.ts +128 -0
  65. package/src/telemetry/contract.ts +1 -1
  66. package/src/tools/council.ts +40 -23
  67. package/src/tools/delegate-tool.ts +50 -14
  68. package/src/tools/delegate.ts +13 -6
  69. package/src/tools/exocom-collaboration-input.ts +168 -0
  70. package/src/tools/exocom-collaboration.ts +315 -0
  71. package/src/tools/exocom-profile.ts +102 -0
  72. package/src/tools/exocom.ts +20 -3
  73. package/src/tools/flow.ts +24 -8
  74. package/src/tools/intercom-tool.ts +98 -14
  75. package/src/tools/models.ts +15 -0
  76. package/src/tools/monitor.ts +20 -1
  77. package/src/tools/read-result.ts +52 -0
  78. package/src/tools/timer.ts +23 -2
  79. package/src/ui/agent-overlay.ts +11 -3
  80. package/src/ui/agent-tree.ts +43 -1
  81. package/src/ui/literal-tool-result.ts +1 -1
  82. package/src/ui/model-picker.ts +1 -1
  83. package/src/ui/orchestration-launch.ts +49 -0
  84. package/src/ui/session-cards.ts +191 -14
@@ -5,12 +5,13 @@ import { failureDetails } from "../extension/shared.ts";
5
5
  import { Text } from "@earendil-works/pi-tui";
6
6
  import { compactInlineText, sanitizeTerminalText } from "../ui/presentation.ts";
7
7
  import { compactVisibleText } from "../ui/presentation.ts";
8
+ import { renderLiteralToolResult } from "../ui/literal-tool-result.ts";
8
9
  import { createReportBody } from "../ui/report-body.ts";
9
10
  import { coachingDisabledHint, expandDetailHint, reconcileAnsweredAsk } from "../extension/shared.ts";
10
11
  import {
11
12
  type AsyncRun, AsyncRunTracker, buildPeekDigest,
12
13
  dedupeRunsById, getFullRunOutput, IdleCoalescingNotifier,
13
- isAsyncRunStalled, runDisplayName, runDurationLabel,
14
+ isAsyncRunStalled, observedRunModel, runDisplayName, runDurationLabel,
14
15
  } from "../engine/async.ts";
15
16
  import type { PollGuard } from "../core/nudge.ts";
16
17
  import { fenceUntrusted } from "../core/fence.ts";
@@ -21,11 +22,14 @@ import type { InProcessBus } from "../bus/inproc.ts";
21
22
  import type { PersonaController } from "../persona/controller.ts";
22
23
  import { emptyUsage } from "../engine/stream.ts";
23
24
  import { toolUsageField, type ChildUsageLedger } from "../ui/usage.ts";
25
+ import type { TerminalResultArchive } from "../results/archive.ts";
26
+ import { pageLiveResult, projectResultPage, resultReceiptHint } from "../results/retrieval.ts";
24
27
 
25
28
  export interface IntercomToolDeps {
26
29
  get lastCtx(): ExtensionContext | undefined;
27
30
  set lastCtx(value: ExtensionContext | undefined);
28
31
  tracker: AsyncRunTracker;
32
+ archive(ctx: ExtensionContext): TerminalResultArchive;
29
33
  completionNotifier: IdleCoalescingNotifier<AsyncRun>;
30
34
  intercomNotifier: IdleCoalescingNotifier<PendingAsk>;
31
35
  controller: PersonaController;
@@ -131,6 +135,7 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
131
135
  [
132
136
  Type.Literal("peek"),
133
137
  Type.Literal("result"),
138
+ Type.Literal("results"),
134
139
  Type.Literal("steer"),
135
140
  Type.Literal("stop"),
136
141
  Type.Literal("list"),
@@ -141,10 +146,15 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
141
146
  ],
142
147
  {
143
148
  description:
144
- "peek = status of async sub-agents · result = retrieve one complete settled result by run id · steer = soft redirect into one by run id (it may ignore it) · stop = request engine cancellation by run id · message = retrieve one drained bus message by message id · list/inbox/reply/send = coaching messages (needs a coaching persona)",
149
+ "peek = status of async sub-agents · results = bounded archive summaries (cursor/limit) · result = page a settled live result by to:run-id or a saved report by ref:result-UUID · steer = soft redirect into one by run id (it may ignore it) · stop = request engine cancellation by run id · message = retrieve one drained bus message by message id · list/inbox/reply/send = coaching messages (needs a coaching persona)",
145
150
  },
146
151
  ),
147
152
  to: Type.Optional(Type.String({ maxLength: MAX_INTERCOM_REF_CHARS, description: "result/steer/stop/peek: the async run id (e.g. 'run-1'); stop: \"all\" cancels every running run · send: the child handle (from `list`)" })),
153
+ ref: Type.Optional(Type.String({ maxLength: 43, description: "result: opaque result:<UUID> archive reference; never a run id. Mutually exclusive with to." })),
154
+ offset: Type.Optional(Type.Integer({ minimum: 0, description: "result: stored-body UTF-16 offset (default 0); use nextOffset, never split a surrogate pair." })),
155
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 16000, description: "result: 2..16000 UTF-16 units (default 8000). results: 1..50 summaries (default 20)." })),
156
+ cursor: Type.Optional(Type.Integer({ minimum: 0, description: "results: nextCursor from the prior bounded summary page; omit for the first page." })),
157
+ collect: Type.Optional(Type.Boolean({ description: "result with live to only: true acknowledges the whole report and suppresses its pending completion. Paging alone does not collect." })),
148
158
  messageId: Type.Optional(Type.String({ maxLength: MAX_INTERCOM_REF_CHARS, description: "message: the drained bus message id shown by `inbox`" })),
149
159
  askId: Type.Optional(Type.String({ maxLength: MAX_INTERCOM_REF_CHARS, description: "reply: the message id of the child's pending question" })),
150
160
  message: Type.Optional(Type.String({ maxLength: MAX_INTERCOM_MESSAGE_CHARS, description: "steer/reply/send: the text to deliver" })),
@@ -154,7 +164,10 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
154
164
  label: "Intercom",
155
165
  description: [
156
166
  "Steer, stop and inspect your background sub-agents; a finished run or batch wakes you with a completion follow-up.",
157
- "Never `peek` healthy children; use it only for status you need. `result { to: runId }` retrieves one complete settled payload by run id.",
167
+ "Never `peek` healthy children; use it only for status you need. `result { to: runId }` pages a live settled payload by run id (8000 units default, 16000 maximum).",
168
+ "`results { cursor?, limit? }` lists saved terminal summaries across this session's branches (20 default, 50 maximum); pass nextCursor for the next page.",
169
+ "`result { ref: 'result:<UUID>', offset?, limit? }` reads a saved report, including after reload, without collecting, billing, restarting a worker or waking the model. Memory-only references last only for this activation. Run ids route only the current runtime; never guess a historical run by id.",
170
+ "Use nextOffset for more UTF-16 pages. Only a whole live body returned in one read, or explicit live collect:true, suppresses a pending completion. Archive ref reads reject collect.",
158
171
  "`steer` sends a soft course-correction to one (by run id); in-process async runs can receive it mid-run,",
159
172
  "while child-engine (MCP/worktree) async runs receive it as a brokered follow-up. After remaining tools return, Pi can consume steering; arbitrary active tool work is not promised to stop immediately.",
160
173
  "`message` retrieves a drained bus message by its message id; `list`/`inbox`/`reply`/`send` exchange coaching messages (a child reaches you via `contact_supervisor`)",
@@ -180,8 +193,29 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
180
193
  if (refusal) return refusal;
181
194
  return { content: [{ type: "text", text: buildPeekDigest(runs, { now: Date.now(), stallMs: d.STALL_FLAG_MS, ...(waiting ? { waitingForSupervisor: waiting } : {}) }) }], details: { action: "peek", ok: true, ...targetSnapshot }, isError: false };
182
195
  }
196
+ if (params.action === "results" || (params.action === "result" && params.ref !== undefined)) {
197
+ const invalid = params.to !== undefined || params.collect !== undefined || (params.action === "results" && (params.ref !== undefined || params.offset !== undefined)) || (params.action === "result" && params.cursor !== undefined);
198
+ if (invalid) return { content: [{ type: "text", text: "Archive reads accept ref/offset/limit; results accepts cursor/limit. to and collect are live-only." }], details: failureDetails({ action: params.action }), isError: true };
199
+ const archive = d.archive(ctx);
200
+ if (params.action === "results") {
201
+ const page = archive.list({ ...(params.cursor !== undefined ? { cursor: params.cursor } : {}), ...(params.limit !== undefined ? { limit: params.limit } : {}) });
202
+ const text = page.ok ? ["Saved terminal reports (not live workers):", ...page.results.map((r) => `${r.ref} · ${sanitizeDisplayLabel(r.label ?? r.agent)} · ${r.status} · ${r.storage}${r.truncated ? " · truncated" : ""}`), ...(page.nextCursor !== undefined ? [`nextCursor: ${page.nextCursor}`] : []), ...(page.warnings.length ? [`Storage warnings: ${page.warnings.join(", ")}`] : [])].join("\n") : page.message;
203
+ return { content: [{ type: "text", text }], details: { action: "results", ...page }, isError: !page.ok };
204
+ }
205
+ const page = archive.read({ ref: params.ref!, ...(params.offset !== undefined ? { offset: params.offset } : {}), ...(params.limit !== undefined ? { limit: params.limit } : {}) });
206
+ if (!page.ok) {
207
+ return { content: [{ type: "text", text: page.message }], details: { action: "result", ...page }, isError: true };
208
+ }
209
+ // Bounded-page rule: the fenced body is delivered once, in content; details stay
210
+ // bounded metadata exactly like the live result path (no whole page hidden there).
211
+ const { body: _pageBody, ...pageMeta } = page;
212
+ return {
213
+ content: [{ type: "text", text: `${page.summary.ref} · ${page.summary.status} · ${page.summary.storage}${page.summary.truncated ? " · saved body truncated" : ""}\n${projectResultPage(page, page.summary.error)}` }],
214
+ details: { action: "result", ...pageMeta }, isError: false,
215
+ };
216
+ }
183
217
  if (params.action === "result") {
184
- if (!params.to) {
218
+ if (!params.to || params.cursor !== undefined) {
185
219
  return { content: [{ type: "text", text: "intercom result needs { to: <run id> }." }], details: failureDetails({ action: "result", ok: false }), isError: true };
186
220
  }
187
221
  const run = d.tracker.peek(params.to);
@@ -199,14 +233,17 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
199
233
  // same result cannot appear again as a follow-up a moment later. Telling the tracker too
200
234
  // makes this retained copy the first thing retention evicts — the supervisor has read it,
201
235
  // so it is the cheapest payload in the map to lose.
202
- d.completionNotifier.discard((pending) => pending.id === run.id);
203
- d.tracker.markCollected(run.id);
204
236
  const full = getFullRunOutput(run);
205
- const body = full === "(no output)" ? full : fenceUntrusted(full);
206
- // The cause is engine/child-authored text too. Keep the run id/status as trusted compact
207
- // metadata, but put the diagnostic inside the same untrusted fence as the payload.
208
- const cause = run.error ? `\nFailure detail:\n${fenceUntrusted(run.error)}` : "";
209
- const displayRun = sanitizeDisplayLabel(run.label ?? run.agent);
237
+ const page = pageLiveResult(full, params.offset, params.limit);
238
+ if (!page) return { content: [{ type: "text", text: "Invalid result offset/limit; use nextOffset and 2..16000 units." }], details: failureDetails({ action: "result", ...targetSnapshot }), isError: true };
239
+ if (params.collect === true || (page.offset === 0 && page.endOfStoredBody)) {
240
+ d.completionNotifier.discard((pending) => pending.id === run.id);
241
+ d.tracker.markCollected(run.id);
242
+ }
243
+ const body = projectResultPage(page, run.error);
244
+ const receipt = resultReceiptHint(run.resultReceipt);
245
+ const displayRun = runDisplayName(run);
246
+ const modelUsed = observedRunModel(run);
210
247
  // The wall time this leg took: an explicit collection is one of the paths a completion reaches
211
248
  // the supervisor through, so it carries the same reading the passive completion report does.
212
249
  const took = runDurationLabel(run);
@@ -216,8 +253,8 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
216
253
  const billed = d.childUsage.accountMany([{ key: run.id, usage: run.result?.usage ?? emptyUsage() }]);
217
254
  d.publishPersonaCost();
218
255
  return {
219
- content: [{ type: "text", text: `${run.id} (${displayRun}) · ${run.status}${took ? ` · ${took}` : ""}${cause}\n${body}${note ? `\n\n${note}` : ""}` }],
220
- details: { action: "result", ok: true, status: run.status, ...targetSnapshot },
256
+ content: [{ type: "text", text: `${run.id} (${displayRun}) · ${run.status}${took ? ` · ${took}` : ""}${modelUsed ? `\nObserved model: ${modelUsed}` : ""}\n${body}${receipt ? `\n${receipt}` : ""}${note ? `\n\n${note}` : ""}` }],
257
+ details: { action: "result", ok: true, status: run.status, ...targetSnapshot, ...(modelUsed ? { modelUsed } : {}), page: { offset: page.offset, nextOffset: page.nextOffset, endOfStoredBody: page.endOfStoredBody }, ...(run.resultReceipt ? { resultReceipt: { ...run.resultReceipt } } : {}) },
221
258
  isError: false,
222
259
  ...toolUsageField(billed),
223
260
  };
@@ -317,7 +354,12 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
317
354
  return component;
318
355
  },
319
356
  renderResult(result, { expanded }, theme, context) {
320
- const details = (result.details ?? {}) as { action?: string; ok?: boolean; runId?: string; target?: string; message?: string };
357
+ const details = (result.details ?? {}) as {
358
+ action?: string; ok?: boolean; runId?: string; target?: string; message?: string;
359
+ results?: Array<{ truncated?: boolean }>;
360
+ summary?: { ref?: string; storage?: string; storedChars?: number; truncated?: boolean };
361
+ nextCursor?: number; nextOffset?: number; warnings?: readonly string[];
362
+ };
321
363
  const state = context?.state as IntercomRenderState | undefined;
322
364
  const action = details.action ?? context?.args?.action;
323
365
  const savedId = details.runId ?? context?.args?.to;
@@ -353,7 +395,20 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
353
395
  const showTitle = !state?.callText || !preview;
354
396
  return new Text(`${showTitle ? theme.fg("success", title) : ""}${preview ? `${showTitle ? "\n" : ""}${theme.fg("toolOutput", preview.text)}${preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : ""}` : ""}`, 0, 0);
355
397
  }
398
+ // Archive views are listings/diagnostics, never Markdown reports. A successful archive
399
+ // read carries no runId (live results do) and a summary (its own receipt); the invalid
400
+ // argument mix and failed reads fall through to the literal failure rendering below.
401
+ const listing = details.action === "results" && details.ok === true && Array.isArray(details.results) ? details.results : undefined;
402
+ const archivedPage = details.action === "result" && details.ok === true
403
+ && typeof details.summary === "object" && details.summary !== null && details.runId === undefined
404
+ ? details.summary
405
+ : undefined;
356
406
  if (expanded) {
407
+ // Archive pages/listings stay literal via the shared renderer — complete sanitized
408
+ // content, no Markdown re-render of a quote-fenced page or a ref listing.
409
+ if (!failed && (listing || archivedPage)) {
410
+ return renderLiteralToolResult(result, { expanded }, theme, context);
411
+ }
357
412
  // Explicit report/message actions only; missing/running diagnostics and control
358
413
  // receipts remain literal. Older history with no action is not guessed from prose.
359
414
  if (!failed && ["result", "message", "inbox"].includes(action ?? "")) {
@@ -361,6 +416,35 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
361
416
  }
362
417
  return new Text(`${prefix}${theme.fg("toolOutput", full)}`, 0, 0);
363
418
  }
419
+ if (!failed && listing) {
420
+ // Purpose line first (count + paging/truncation/storage honesty), then a bounded
421
+ // listing preview; the payload's own header line is replaced by the purpose line.
422
+ const truncated = listing.filter((entry) => entry?.truncated === true).length;
423
+ const flags = [
424
+ ...(details.nextCursor !== undefined ? [`next page: cursor ${details.nextCursor}`] : []),
425
+ ...(truncated > 0 ? [`${truncated} truncated`] : []),
426
+ ...(details.warnings?.length ? [`warnings: ${details.warnings.join(", ")}`] : []),
427
+ ];
428
+ const purpose = compactInlineText(`results · ${listing.length} retained${flags.length ? ` · ${flags.join(" · ")}` : ""}`, { maxChars: 100 });
429
+ const preview = compactVisibleText(full.split("\n").slice(1).join("\n"), { maxLines: 3, maxLineChars: 100 });
430
+ return new Text(`${theme.fg("success", purpose)}\n${theme.fg("toolOutput", preview.text)}${preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : ""}`, 0, 0);
431
+ }
432
+ if (!failed && archivedPage) {
433
+ // Page position reads against the STORED body (originals may be truncated); honesty
434
+ // flags surface collapsed so a memory-only or truncated receipt is never mistaken
435
+ // for a durable whole report without expanding.
436
+ const end = details.nextOffset ?? archivedPage.storedChars ?? 0;
437
+ const flags = [
438
+ ...(archivedPage.truncated ? ["truncated"] : []),
439
+ ...(archivedPage.storage === "memory-only" ? ["memory-only"] : []),
440
+ ...(details.warnings?.length ? [`warnings: ${details.warnings.join(", ")}`] : []),
441
+ ];
442
+ const purpose = compactInlineText(`result · ${archivedPage.ref ?? "?"} · page ${end}/${archivedPage.storedChars ?? "?"}${flags.length ? ` · ${flags.join(" · ")}` : ""}`, { maxChars: 100 });
443
+ // The payload's receipt header and paging diagnostic line are replaced by the purpose
444
+ // line; the fence wrapper is dropped by the preview, leaving the page body quoted.
445
+ const preview = compactVisibleText(full.split("\n").slice(2).join("\n"), { maxLines: 3, maxLineChars: 100 });
446
+ return new Text(`${theme.fg("success", purpose)}\n${theme.fg("toolOutput", preview.text)}${preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : ""}`, 0, 0);
447
+ }
364
448
  const preview = compactVisibleText(full, { maxLines: 4, maxLineChars: 100 });
365
449
  const hint = preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : "";
366
450
  return new Text(`${prefix}${theme.fg("toolOutput", preview.text)}${hint}`, 0, 0);
@@ -3,6 +3,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
3
3
  import { Type } from "typebox";
4
4
  import { Text } from "@earendil-works/pi-tui";
5
5
  import { compactInlineText } from "../ui/presentation.ts";
6
+ import { renderLiteralToolResult } from "../ui/literal-tool-result.ts";
6
7
 
7
8
  export interface ModelsToolDeps {
8
9
  get lastCtx(): ExtensionContext | undefined;
@@ -45,5 +46,19 @@ export function registerModelsTool(pi: ExtensionAPI, d: ModelsToolDeps): void {
45
46
  const query = compactInlineText(args.query ?? "(shortlist)", { maxChars: 96 }) || "(shortlist)";
46
47
  return new Text(`${theme.fg("toolTitle", theme.bold("models "))}${theme.fg("dim", query)}`, 0, 0);
47
48
  },
49
+ renderResult(result, { expanded }, theme, context) {
50
+ // The catalog itself is literal data: the shared renderer bounds it collapsed and
51
+ // shows the complete sanitized listing expanded.
52
+ if (expanded) return renderLiteralToolResult(result, { expanded }, theme, context);
53
+ const failed = context?.isError ?? result.isError ?? false;
54
+ if (failed) return renderLiteralToolResult(result, { expanded: false }, theme, context);
55
+ const details = (result.details ?? {}) as { total?: number; shown?: number; shortlist?: boolean };
56
+ const total = typeof details.total === "number" && Number.isFinite(details.total) ? details.total : 0;
57
+ const shown = typeof details.shown === "number" && Number.isFinite(details.shown) ? details.shown : 0;
58
+ const summary = shown === 0
59
+ ? "no catalog matches"
60
+ : `${total} catalog models · showing ${shown}${details.shortlist ? " nearby candidates" : " matches"}`;
61
+ return new Text(`${theme.fg("toolTitle", theme.bold("models · "))}${theme.fg("accent", compactInlineText(summary, { maxChars: 96 }))}`, 0, 0);
62
+ },
48
63
  });
49
64
  }
@@ -4,8 +4,10 @@ import { Type } from "typebox";
4
4
  import { Text } from "@earendil-works/pi-tui";
5
5
  import type { MonitorManager } from "../core/monitor.ts";
6
6
  import { neutralizeMonitorUnicode } from "../core/monitor-output.ts";
7
+ import { formatDuration } from "../core/time.ts";
7
8
  import { armWakeAdvice, failureDetails } from "../extension/shared.ts";
8
9
  import { compactInlineText } from "../ui/presentation.ts";
10
+ import { renderLiteralToolResult } from "../ui/literal-tool-result.ts";
9
11
  import { fenceUntrusted, stripTerminalControls } from "../core/fence.ts";
10
12
  import type { MonitorArchive, MonitorLogRecord } from "../monitor/archive.ts";
11
13
 
@@ -88,12 +90,29 @@ export function registerMonitorTool(pi: ExtensionAPI, d: MonitorToolDeps): void
88
90
  ...(params.maxEvents !== undefined ? { maxEvents: params.maxEvents } : {}),
89
91
  });
90
92
  if (!result.ok) return fail(result.error);
91
- return { content: [{ type: "text" as const, text: `Armed ${result.entry.id} (${compactInlineText(result.entry.label, { maxChars: 120 })}); ${params.mode === "exit" ? "wakes when this job exits" : "wakes on relevant stdout lines"}. Deadline ${new Date(result.entry.expiresAtEpochMs).toISOString()}. ${armWakeAdvice(ctx, "watch", "condition", "Keep this session open. Continue independent work, or end your turn; it wakes you by itself, so never sleep, poll, or arm a `timer` to wait for it.")}` }], details: { ok: true, id: result.entry.id, expiresAtEpochMs: result.entry.expiresAtEpochMs } };
93
+ return { content: [{ type: "text" as const, text: `Armed ${result.entry.id} (${compactInlineText(result.entry.label, { maxChars: 120 })}); ${params.mode === "exit" ? "wakes when this job exits" : "wakes on relevant stdout lines"}. Deadline ${new Date(result.entry.expiresAtEpochMs).toISOString()}. ${armWakeAdvice(ctx, "watch", "condition", "Keep this session open. Continue independent work, or end your turn; it wakes you by itself, so never sleep, poll, or arm a `timer` to wait for it.")}` }], details: { ok: true, id: result.entry.id, expiresAtEpochMs: result.entry.expiresAtEpochMs, label: compactInlineText(result.entry.label, { maxChars: 120 }), mode: params.mode } };
92
94
  },
93
95
  renderCall(args, theme) {
94
96
  const action = compactInlineText(args.action ?? "", { maxChars: 12 });
95
97
  const label = compactInlineText(args.label ?? args.id ?? "", { maxChars: 96 });
96
98
  return new Text(`${theme.fg("toolTitle", theme.bold(`monitor ${action}`))}${label ? ` ${theme.fg("dim", label)}` : ""}`, 0, 0);
97
99
  },
100
+ renderResult(result, { expanded }, theme, context) {
101
+ const details = (result.details ?? {}) as { ok?: boolean; label?: string; mode?: string; expiresAtEpochMs?: number };
102
+ const failed = context?.isError ?? result.isError ?? false;
103
+ const isArm = !failed && typeof details.label === "string" && (details.mode === "output" || details.mode === "exit");
104
+ if (!isArm) {
105
+ // Failures and every list/cancel/result receipt share the one literal renderer:
106
+ // error heading, bounded sanitized preview, expansion hint.
107
+ return renderLiteralToolResult(result, { expanded }, theme, context);
108
+ }
109
+ if (expanded) return renderLiteralToolResult(result, { expanded }, theme, context);
110
+ const left = typeof details.expiresAtEpochMs === "number" && Number.isFinite(details.expiresAtEpochMs)
111
+ ? details.expiresAtEpochMs - Date.now()
112
+ : undefined;
113
+ const deadline = left === undefined ? "" : left > 0 ? ` · deadline in ${formatDuration(left)}` : " · deadline reached";
114
+ const summary = compactInlineText(`${details.label} · ${details.mode}${deadline}`, { maxChars: 140 });
115
+ return new Text(`${theme.fg("toolTitle", theme.bold("armed "))}${theme.fg("accent", summary)}`, 0, 0);
116
+ },
98
117
  });
99
118
  }
@@ -0,0 +1,52 @@
1
+ import { defineTool } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { Type } from "typebox";
4
+ import { fenceUntrusted } from "../core/fence.ts";
5
+ import { RESULT_READ_DEFAULT, RESULT_READ_MAX } from "../results/types.ts";
6
+ import { isReadRequest, isReadReplyFor, readFailure, type ReadReply, type ScopedReader } from "../bus/result-reader.ts";
7
+ import { compactInlineText } from "../ui/presentation.ts";
8
+ import { renderLiteralToolResult } from "../ui/literal-tool-result.ts";
9
+
10
+ const Params = Type.Object({
11
+ ref: Type.String({ minLength: 43, maxLength: 43, description: "Granted opaque result:<UUID> reference, not a file path or run id." }),
12
+ offset: Type.Optional(Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER, description: "UTF-16 offset; use nextOffset from the previous page." })),
13
+ limit: Type.Optional(Type.Integer({ minimum: 2, maximum: RESULT_READ_MAX, default: RESULT_READ_DEFAULT, description: "Maximum UTF-16 characters to read." })),
14
+ }, { additionalProperties: false });
15
+ type PageDetails = Omit<Extract<ReadReply, { ok: true }>, "body">;
16
+
17
+ /** Available only when the parent explicitly grants a scoped reader. No archive browsing,
18
+ * implicit fallback, polling, messages or model wakes. Fencing happens exactly once here.
19
+ * Wrapped in `defineTool` so callers that store the definition in a wider-typed variable
20
+ * (engine registration sites) keep renderer parameter inference. */
21
+ export function makeReadResultTool(reader: Pick<ScopedReader, "read">) {
22
+ return defineTool<typeof Params, PageDetails>({
23
+ name: "read_result",
24
+ label: "Read result",
25
+ description: "Read one bounded page of a terminal result explicitly granted by your supervisor. Use only a supplied result:<UUID> reference. Follow nextOffset for another page; endOfStoredBody ends the retained report, which may be truncated. No search, paths, live runs or polling.",
26
+ parameters: Params,
27
+ async execute(_id, request, signal) {
28
+ if (signal?.aborted) throw new Error(readFailure("cancelled").message);
29
+ if (!isReadRequest(request)) throw new Error(readFailure("invalid").message);
30
+ let result: ReadReply;
31
+ try { result = await reader.read(request, signal); }
32
+ catch { throw new Error(readFailure(signal?.aborted ? "cancelled" : "unavailable").message); }
33
+ if (signal?.aborted) throw new Error(readFailure("cancelled").message);
34
+ try {
35
+ if (!isReadReplyFor(result, request)) throw new Error();
36
+ } catch { throw new Error(readFailure("protocol").message); }
37
+ if (!result.ok) throw new Error(result.message);
38
+ const { body, ...details } = result;
39
+ return { content: [{ type: "text", text: `${JSON.stringify(details)}\n${fenceUntrusted(body)}` }], details };
40
+ },
41
+ renderCall(args, theme) {
42
+ const ref = compactInlineText(args.ref ?? "", { maxChars: 48 });
43
+ return new Text(`${theme.fg("toolTitle", theme.bold("read_result"))}${ref ? ` ${theme.fg("dim", ref)}` : ""}`, 0, 0);
44
+ },
45
+ renderResult(result, { expanded }, theme, context) {
46
+ // The granted page IS literal content: the shared renderer bounds it collapsed with an
47
+ // expansion hint and shows the complete sanitized receipt (details line + fenced body)
48
+ // expanded; native context.isError drives the failure heading.
49
+ return renderLiteralToolResult(result, { expanded }, theme, context);
50
+ },
51
+ });
52
+ }
@@ -1,9 +1,12 @@
1
1
  /** Session timer tool — arm a wall-clock wake. */
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { Text } from "@earendil-works/pi-tui";
3
4
  import { Type } from "typebox";
4
5
  import { armWakeAdvice, failureDetails } from "../extension/shared.ts";
5
6
  import { formatRemaining, type TimerScheduler } from "../core/timer.ts";
6
- import { parseInstant } from "../core/time.ts";
7
+ import { formatDuration, parseInstant } from "../core/time.ts";
8
+ import { compactInlineText } from "../ui/presentation.ts";
9
+ import { renderLiteralToolResult } from "../ui/literal-tool-result.ts";
7
10
  import { readClockSnapshot } from "../extension/clock.ts";
8
11
 
9
12
  export interface TimerToolDeps {
@@ -97,7 +100,25 @@ export function registerTimerTool(pi: ExtensionAPI, d: TimerToolDeps): void {
97
100
  }
98
101
  const e = r.entry;
99
102
  const text = `Armed ${e.id} (${e.label}) — fires in ${formatRemaining(r.remainingMs ?? e.fireAtEpochMs - e.armedAtEpochMs)} [${new Date(e.fireAtEpochMs).toISOString()}]. ${armWakeAdvice(ctx, "alarm", "time-gated step", "You can end this turn now; keep the session open for the alarm.")}`;
100
- return { content: [{ type: "text", text }], details: { action: "arm", id: e.id, fireAtEpochMs: e.fireAtEpochMs, ok: true }, isError: false };
103
+ return { content: [{ type: "text", text }], details: { action: "arm", id: e.id, fireAtEpochMs: e.fireAtEpochMs, label: compactInlineText(e.label, { maxChars: 120 }), ok: true }, isError: false };
104
+ },
105
+ renderCall(args, theme) {
106
+ const action = compactInlineText(args.action ?? "", { maxChars: 12 });
107
+ const label = compactInlineText(args.label ?? args.id ?? "", { maxChars: 96 });
108
+ return new Text(`${theme.fg("toolTitle", theme.bold(`timer ${action}`))}${label ? ` ${theme.fg("dim", label)}` : ""}`, 0, 0);
109
+ },
110
+ renderResult(result, { expanded }, theme, context) {
111
+ const details = (result.details ?? {}) as { action?: string; ok?: boolean; id?: string; label?: string; fireAtEpochMs?: number };
112
+ const failed = context?.isError ?? result.isError ?? false;
113
+ const fireAt = typeof details.fireAtEpochMs === "number" ? details.fireAtEpochMs : undefined;
114
+ const isArm = !failed && details.action === "arm" && details.ok === true && fireAt !== undefined;
115
+ // Failures and every now/list/cancel receipt share the one literal renderer:
116
+ // error heading, bounded sanitized preview, expansion hint.
117
+ if (!isArm || expanded) return renderLiteralToolResult(result, { expanded }, theme, context);
118
+ const left = fireAt - Date.now();
119
+ const due = left > 0 ? `fires in ${formatDuration(left)}` : "due now";
120
+ const summary = compactInlineText(`${details.label ?? details.id ?? "alarm"} · ${due}`, { maxChars: 140 });
121
+ return new Text(`${theme.fg("toolTitle", theme.bold("armed "))}${theme.fg("accent", summary)}`, 0, 0);
101
122
  },
102
123
  });
103
124
  }
@@ -23,7 +23,7 @@ import {
23
23
  wrapTextWithAnsi,
24
24
  } from "@earendil-works/pi-tui";
25
25
 
26
- import { type AgentNode, type AgentTree, type FlatRow, deriveWaitingNodeIds, flattenTree, isWakeSource, nodeGlyph, runningAnnotation } from "./agent-tree.ts";
26
+ import { type AgentNode, type AgentTree, type FlatRow, deriveWaitingNodeIds, effortTag, flattenTree, isWakeSource, nodeGlyph, runningAnnotation, thinkingDetailLine } from "./agent-tree.ts";
27
27
  import { LiveClock } from "./live-clock.ts";
28
28
  import { visibleWindow } from "./model-picker.ts";
29
29
  import { compactInlineText, OPEN_SEQUENCE_TAIL, sanitizeTerminalText } from "./presentation.ts";
@@ -77,8 +77,12 @@ export function composeAgentRow(input: AgentRowInput): string {
77
77
 
78
78
  const prefixCols = 2 + visibleWidth(indent); // "▸ " or " ", then the indent
79
79
  const avail = Math.max(0, bodyMax - prefixCols - 2); // 2 = glyph + space
80
- const labelMax = Math.max(8, Math.floor(avail * 0.62));
81
- const label = safeInline(node.label, labelMax) || "agent";
80
+ // The compact effort sigla rides the label (` · xh` / ` · xh→h`); its width comes out of the
81
+ // label budget so the appended field never becomes the part a tight row eats first. The final
82
+ // clamp below still guarantees the row fits either way.
83
+ const effort = effortTag(node.thinking, node.thinkingEffective);
84
+ const labelMax = Math.max(8, Math.floor(avail * 0.62) - visibleWidth(effort));
85
+ const label = (safeInline(node.label, labelMax) || "agent") + effort;
82
86
  const detailMax = node.detail ? Math.max(0, avail - labelMax - 2) : 0;
83
87
  const detail = detailMax > 0 ? safeInline(node.detail ?? "", detailMax) : "";
84
88
  const detailText = detail ? t.fg("dim", ` ${detail}`) : "";
@@ -383,6 +387,10 @@ export class AgentOverlay extends Container {
383
387
  new Text(`${nodeGlyph(node)} ${t.fg("accent", t.bold(safeInline(node.label) || "agent"))}${live ? t.fg("success", wake ? " ● armed" : " ● live") : ""}`, 1, 0),
384
388
  );
385
389
  if (node.detail) this.addChild(new Text(t.fg("dim", safeInline(node.detail)), 1, 0));
390
+ // The drill-in has room for FULL effort names (the rows stay compact): an observed level
391
+ // appears only once a terminal result actually reported one.
392
+ const effortLine = thinkingDetailLine(node.thinking, node.thinkingEffective);
393
+ if (effortLine) this.addChild(new Text(t.fg("dim", effortLine), 1, 0));
386
394
  this.addChild(new Spacer(1));
387
395
 
388
396
  const raw = node.output?.trim()
@@ -1,5 +1,7 @@
1
1
  import { compactTokens } from "../core/format.ts";
2
+ import { effortFullName, effortSigla } from "../core/leg-thinking.ts";
2
3
  import { formatDuration } from "../core/time.ts";
4
+ import type { ThinkingLevel } from "../core/types.ts";
3
5
  import type { ChildUsage } from "../engine/stream.ts";
4
6
  import { compactInlineText } from "./presentation.ts";
5
7
  import { formatUsage } from "./usage.ts";
@@ -25,6 +27,13 @@ export interface AgentNode {
25
27
  /** Configured agent definition (operator/reviewer/…), if known. */
26
28
  agent?: string;
27
29
  model?: string;
30
+ /** Requested per-leg thinking effort, known at launch (the spec-level value the launch row
31
+ * carried). Rendered as the compact sigla right after the model; absent/off render nothing. */
32
+ thinking?: ThinkingLevel;
33
+ /** Observed effective Pi effort from the terminal result. Set ONLY from a real
34
+ * AgentResult.thinkingEffective (never invented); when it diverges from `thinking`, the row
35
+ * renders the honest `xh→h` form. */
36
+ thinkingEffective?: ThinkingLevel;
28
37
  /** A short trailing annotation (usage, current activity), shown after the label. */
29
38
  detail: string | undefined;
30
39
  /** The agent's output buffer, shown when the user drills into the node. */
@@ -88,6 +97,28 @@ export interface RenderOptions {
88
97
  waitingForSupervisor?: ReadonlySet<string>;
89
98
  }
90
99
 
100
+ /** The frozen compact effort tag for a tree/overlay row: ` · xh` after the model, or ` · xh→h`
101
+ * when the terminal result reported a DIFFERENT observed effective level. `off`/absent render
102
+ * nothing — absence means off, and an observation is never invented: the divergence form shows
103
+ * only when both sides are known and genuinely differ (an observed `off` never leaves a dangling
104
+ * `→`). Pure. */
105
+ export function effortTag(requested: ThinkingLevel | undefined, effective?: ThinkingLevel): string {
106
+ const req = requested !== undefined ? effortSigla(requested) : "";
107
+ if (!req) return "";
108
+ const obs = effective !== undefined ? effortSigla(effective) : "";
109
+ return obs && obs !== req ? ` · ${req}→${obs}` : ` · ${req}`;
110
+ }
111
+
112
+ /** The F9 drill-in line for a leg's effort — FULL names, the drill-in has the room (the compact
113
+ * siglas live only on the rows): `thinking: requested xhigh → observed high`. Same honesty rules
114
+ * as {@link effortTag}; the line is omitted entirely when nothing was requested. Pure. */
115
+ export function thinkingDetailLine(requested: ThinkingLevel | undefined, effective?: ThinkingLevel): string {
116
+ const req = requested !== undefined ? effortFullName(requested) : "";
117
+ if (!req) return "";
118
+ const obs = effective !== undefined && effective !== requested ? effortFullName(effective) : "";
119
+ return obs ? `thinking: requested ${req} → observed ${obs}` : `thinking: requested ${req}`;
120
+ }
121
+
91
122
  /** Whether `detail` is the literal "queued" marker a seeded-but-not-yet-live leg carries — shared
92
123
  * by `runningAnnotation` (suppresses the clock) and `telemetryStatus` (`extension.ts`, reports the
93
124
  * `queued` state). One predicate so the two readings of the marker cannot drift apart. */
@@ -179,7 +210,10 @@ export function renderAgentTree(nodes: AgentNode[], opts: RenderOptions = {}): s
179
210
  const isRoot = parentId === undefined;
180
211
  const isLast = i === kids.length - 1;
181
212
  const branch = isRoot ? "" : isLast ? "└─ " : "├─ ";
182
- const label = safeInline(node.label) || safeInline(node.id) || "agent";
213
+ const base = safeInline(node.label) || safeInline(node.id) || "agent";
214
+ // The compact effort sigla rides AFTER the label clamp — the existing per-part clipping is
215
+ // untouched; the appended field is at most ` · xh→h` wide.
216
+ const label = `${base}${effortTag(node.thinking, node.thinkingEffective)}`;
183
217
  const detailText = node.detail ? safeInline(node.detail) : "";
184
218
  const clock = opts.now !== undefined ? runningAnnotation(node, opts.now, opts.stallMs ?? 0, opts.waitingForSupervisor?.has(node.id) ?? false) : undefined;
185
219
  const tail = [detailText, clock].filter((s): s is string => Boolean(s)).join(" · ");
@@ -267,6 +301,8 @@ export interface AddNodeInput {
267
301
  kind?: AgentNodeKind;
268
302
  agent?: string;
269
303
  model?: string;
304
+ thinking?: ThinkingLevel;
305
+ thinkingEffective?: ThinkingLevel;
270
306
  detail?: string;
271
307
  output?: string;
272
308
  startedAt?: number;
@@ -279,6 +315,8 @@ export interface AgentNodePatch {
279
315
  kind?: AgentNodeKind;
280
316
  agent?: string;
281
317
  model?: string;
318
+ thinking?: ThinkingLevel;
319
+ thinkingEffective?: ThinkingLevel;
282
320
  detail?: string;
283
321
  output?: string;
284
322
  startedAt?: number;
@@ -293,6 +331,8 @@ function applyPatch(node: AgentNode, patch: AgentNodePatch): void {
293
331
  if (patch.kind !== undefined) node.kind = patch.kind;
294
332
  if (patch.agent !== undefined) node.agent = patch.agent;
295
333
  if (patch.model !== undefined) node.model = patch.model;
334
+ if (patch.thinking !== undefined) node.thinking = patch.thinking;
335
+ if (patch.thinkingEffective !== undefined) node.thinkingEffective = patch.thinkingEffective;
296
336
  if (patch.detail !== undefined) node.detail = patch.detail;
297
337
  if (patch.output !== undefined) node.output = patch.output;
298
338
  if (patch.startedAt !== undefined) node.startedAt = patch.startedAt;
@@ -333,6 +373,8 @@ export class AgentTree {
333
373
  ...(input.kind !== undefined ? { kind: input.kind } : {}),
334
374
  ...(input.agent !== undefined ? { agent: input.agent } : {}),
335
375
  ...(input.model !== undefined ? { model: input.model } : {}),
376
+ ...(input.thinking !== undefined ? { thinking: input.thinking } : {}),
377
+ ...(input.thinkingEffective !== undefined ? { thinkingEffective: input.thinkingEffective } : {}),
336
378
  ...(input.output !== undefined ? { output: input.output } : {}),
337
379
  ...(input.dueAt !== undefined ? { dueAt: input.dueAt } : {}),
338
380
  };
@@ -5,7 +5,7 @@ import { compactVisibleText, sanitizeTerminalText } from "./presentation.ts";
5
5
 
6
6
  interface LiteralResult {
7
7
  content: readonly { type: string; text?: string }[];
8
- /** Legacy direct renderer calls may carry this; the native host passes it in context. */
8
+ /** Direct internal renderer calls carry this; Pi passes it in the render context. */
9
9
  isError?: boolean;
10
10
  }
11
11
 
@@ -217,7 +217,7 @@ export class ModelPicker extends Container implements Focusable {
217
217
  return;
218
218
  } else {
219
219
  const before = this.input.getValue();
220
- // Input has no per-instance keybindings API (Pi 1.3/1.4). Scope its global
220
+ // Input has no per-instance keybindings API (Pi 1.4). Scope its global
221
221
  // lookup to this synchronous call and restore it even if handling throws.
222
222
  const global = getKeybindings();
223
223
  try {
@@ -0,0 +1,49 @@
1
+ /** Persisted orchestration launch requests. Rendering never consults live persona or model settings. */
2
+ import { resolveLegThinking } from "../core/leg-thinking.ts";
3
+ import type { ThinkingLevel } from "../core/types.ts";
4
+ import type { ModelParticipant, ModelSource } from "../persona/model-participants.ts";
5
+ import { compactInlineText, sanitizeTerminalText } from "./presentation.ts";
6
+
7
+ export interface OrchestrationLaunchParticipant {
8
+ key: string;
9
+ label: string;
10
+ agent: string;
11
+ model?: string;
12
+ modelSource: ModelSource;
13
+ thinkingRequested?: ThinkingLevel;
14
+ setupPending: boolean;
15
+ param?: string;
16
+ phase?: string;
17
+ }
18
+
19
+ export interface OrchestrationLaunchSnapshot {
20
+ participants: OrchestrationLaunchParticipant[];
21
+ unavailable?: true;
22
+ }
23
+
24
+ export function snapshotOrchestrationParticipants(
25
+ participants: readonly ModelParticipant[],
26
+ opts: { agentThinking(agent: string): ThinkingLevel | undefined; childThinking: unknown; pendingKeys: ReadonlySet<string> },
27
+ ): OrchestrationLaunchSnapshot {
28
+ return { participants: participants.map((p) => {
29
+ const agentThinking = opts.agentThinking(p.agent);
30
+ const thinkingRequested = resolveLegThinking(p, agentThinking ? { thinking: agentThinking } : {}, opts.childThinking);
31
+ return { key: p.key, label: p.label, agent: p.agent, modelSource: p.source, setupPending: !p.assigned && opts.pendingKeys.has(p.key),
32
+ ...(p.model ? { model: p.model } : {}), ...(thinkingRequested ? { thinkingRequested } : {}), ...(p.param ? { param: p.param } : {}) };
33
+ }) };
34
+ }
35
+
36
+ /** Three individually bounded rows collapsed; expansion retains every participant and full model ref. */
37
+ export function orchestrationLaunchLines(snapshot: OrchestrationLaunchSnapshot | undefined, expanded: boolean): string[] {
38
+ if (!snapshot || snapshot.unavailable) return ["Launch metadata unavailable"];
39
+ const rows = expanded ? snapshot.participants : snapshot.participants.slice(0, 3);
40
+ const lines = rows.map((p) => {
41
+ const clean = (value: string, maxChars: number): string => expanded ? sanitizeTerminalText(value) : compactInlineText(value, { maxChars });
42
+ const label = clean(`${p.phase ? `${p.phase} / ` : ""}${p.label}${p.param ? ` [${p.param}]` : ""}`, 48);
43
+ const model = p.model ? clean(p.model, 64) : "unknown";
44
+ const modelText = p.setupPending ? `model pending (current default ${model}; setup may change)` : `requested model ${model}`;
45
+ return ` ${label} · ${modelText} · requested effort ${p.thinkingRequested ?? "unknown"}`;
46
+ });
47
+ if (!expanded && snapshot.participants.length > rows.length) lines.push(` +${snapshot.participants.length - rows.length} more · expand for launch details`);
48
+ return lines;
49
+ }