@aeondave/pi-persona 1.17.3 → 1.17.5

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 (93) hide show
  1. package/AGENTS.md +57 -36
  2. package/CHANGELOG.md +106 -1
  3. package/README.md +32 -16
  4. package/docs/ARCHITECTURE.md +199 -48
  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/docs/STRATEGIES.md +6 -0
  10. package/package.json +12 -6
  11. package/src/agents/agent.ts +6 -0
  12. package/src/bridge.ts +33 -23
  13. package/src/bus/broker/client.ts +151 -30
  14. package/src/bus/broker/host.ts +130 -11
  15. package/src/bus/broker/messages.ts +35 -5
  16. package/src/bus/contact-transport.ts +8 -0
  17. package/src/bus/contact.ts +118 -40
  18. package/src/bus/peers.ts +23 -6
  19. package/src/bus/result-reader.ts +88 -0
  20. package/src/core/brief.ts +14 -4
  21. package/src/core/capabilities.ts +1 -1
  22. package/src/core/companion.ts +64 -0
  23. package/src/core/leg-thinking.ts +57 -0
  24. package/src/core/ownership.ts +1 -2
  25. package/src/core/pi-compat.ts +5 -12
  26. package/src/core/session-runs.ts +1 -1
  27. package/src/core/types.ts +1 -1
  28. package/src/engine/adapter.ts +100 -20
  29. package/src/engine/async.ts +53 -11
  30. package/src/engine/child.ts +11 -2
  31. package/src/engine/inproc.ts +98 -47
  32. package/src/engine/result-grant.ts +62 -0
  33. package/src/engine/session-tools.ts +6 -0
  34. package/src/engine/stream.ts +6 -0
  35. package/src/engine/worktree.ts +17 -7
  36. package/src/exocom/collaboration-delivery.ts +29 -0
  37. package/src/exocom/collaboration.ts +148 -0
  38. package/src/exocom/envelope.ts +43 -4
  39. package/src/exocom/gate.ts +7 -2
  40. package/src/exocom/install.ts +45 -17
  41. package/src/exocom/ledger.ts +58 -8
  42. package/src/exocom/plane.ts +32 -1
  43. package/src/exocom/profile.ts +40 -0
  44. package/src/exocom/registry.ts +5 -0
  45. package/src/exocom/status.ts +11 -1
  46. package/src/exocom/write-guard.ts +7 -0
  47. package/src/extension/broker-host.ts +61 -9
  48. package/src/extension/companion.ts +38 -0
  49. package/src/extension/engine.ts +40 -29
  50. package/src/extension/hooks.ts +954 -931
  51. package/src/extension/identity.ts +41 -75
  52. package/src/extension/memory-owner.ts +54 -0
  53. package/src/extension/mind-worker.ts +129 -0
  54. package/src/extension/results.ts +138 -0
  55. package/src/extension/shared.ts +17 -0
  56. package/src/extension.ts +2922 -2849
  57. package/src/loader.ts +11 -3
  58. package/src/orchestration/agent-attempt.ts +15 -0
  59. package/src/orchestration/flow-journal.ts +7 -1
  60. package/src/orchestration/flow-run.ts +125 -19
  61. package/src/orchestration/flow.ts +11 -0
  62. package/src/orchestration/roster.ts +71 -23
  63. package/src/orchestration/sdk.ts +61 -19
  64. package/src/orchestration/types.ts +10 -0
  65. package/src/persona/model-participants.ts +20 -7
  66. package/src/persona/orchestrate.ts +6 -7
  67. package/src/persona/persona.ts +1 -1
  68. package/src/results/archive.ts +281 -0
  69. package/src/results/record.ts +166 -0
  70. package/src/results/retrieval.ts +142 -0
  71. package/src/results/types.ts +128 -0
  72. package/src/telemetry/contract.ts +1 -1
  73. package/src/tools/council.ts +40 -23
  74. package/src/tools/delegate-tool.ts +50 -14
  75. package/src/tools/delegate.ts +13 -6
  76. package/src/tools/exocom-collaboration-input.ts +168 -0
  77. package/src/tools/exocom-collaboration.ts +315 -0
  78. package/src/tools/exocom-profile.ts +102 -0
  79. package/src/tools/exocom.ts +20 -3
  80. package/src/tools/flow.ts +24 -8
  81. package/src/tools/intercom-tool.ts +98 -14
  82. package/src/tools/models.ts +15 -0
  83. package/src/tools/monitor.ts +20 -1
  84. package/src/tools/read-result.ts +52 -0
  85. package/src/tools/timer.ts +23 -2
  86. package/src/ui/activity-rows.ts +34 -0
  87. package/src/ui/agent-overlay.ts +23 -13
  88. package/src/ui/agent-tree.ts +67 -6
  89. package/src/ui/literal-tool-result.ts +1 -1
  90. package/src/ui/model-picker.ts +1 -1
  91. package/src/ui/orchestration-launch.ts +49 -0
  92. package/src/ui/session-cards.ts +191 -14
  93. package/src/ui/strategy-leg.ts +40 -0
@@ -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
  }
@@ -0,0 +1,34 @@
1
+ /** Bounded local-work rows. A staged plan is waiting data, never a running agent. */
2
+ import type { CompanionActivity } from "../core/companion.ts";
3
+ import type { AddNodeInput } from "./agent-tree.ts";
4
+
5
+ export const MAX_COMPANION_ACTIVITIES = 8;
6
+ interface ActivityRecord { event: CompanionActivity; startedAt: number }
7
+
8
+ function description(event: CompanionActivity): string {
9
+ const scope = `${event.target} memory`;
10
+ if (event.state === "staged") return `${scope} · proposal prepared; explicit apply required`;
11
+ if (event.state === "failed") return `${scope} · ${event.phase} failed; see the memory result`;
12
+ if (event.state === "done") return `${scope} · ${event.phase === "inspect" ? "inspection complete; ready for review" : event.phase === "apply" ? "staged plan applied" : "plan validation complete"}`;
13
+ return `${scope} · ${event.phase === "inspect" ? "inspecting" : event.phase === "stage" ? "validating consolidation" : "applying staged plan"}`;
14
+ }
15
+
16
+ export class ActivityRows {
17
+ private readonly records = new Map<string, ActivityRecord>();
18
+ accept(event: CompanionActivity, now: number): { node: AddNodeInput; removed: string[] } | undefined {
19
+ const id = `activity:pi-persona-mind:${event.activityId}`;
20
+ const existing = this.records.get(id);
21
+ if (existing && (existing.event.phase !== event.phase || existing.event.target !== event.target || existing.event.state !== "running")) return undefined;
22
+ const removed: string[] = [];
23
+ if (!existing && this.records.size >= MAX_COMPANION_ACTIVITIES) {
24
+ const victim = [...this.records].find(([, record]) => record.event.state !== "running");
25
+ if (!victim) return undefined; // never silently evict active local work
26
+ this.records.delete(victim[0]); removed.push(victim[0]);
27
+ }
28
+ const record = { event, startedAt: existing?.startedAt ?? now };
29
+ this.records.set(id, record);
30
+ const detail = description(event);
31
+ return { removed, node: { id, label: "Mind", kind: "activity", status: event.state === "running" ? "running" : event.state === "failed" ? "failed" : "done", detail, output: `${detail}.\nLocal memory operation; no sub-agent or model execution is implied.\nInspect the memory tool or command result for the full receipt.`, startedAt: record.startedAt, lastAdvanceAt: now } };
32
+ }
33
+ clear(): string[] { const ids = [...this.records.keys()]; this.records.clear(); return ids; }
34
+ }
@@ -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, isNonAgentNode, 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}`) : "";
@@ -230,7 +234,7 @@ export class AgentOverlay extends Container {
230
234
  */
231
235
  private selectedLeaf(leaves: FlatRow[]): FlatRow | undefined {
232
236
  const anchored = this.selectedId !== undefined ? leaves.find((r) => r.node.id === this.selectedId) : undefined;
233
- const leaf = anchored ?? leaves.find((r) => !isWakeSource(r.node)) ?? leaves[0];
237
+ const leaf = anchored ?? leaves.find((r) => !isNonAgentNode(r.node)) ?? leaves[0];
234
238
  if (this.selectedId !== undefined && anchored === undefined && leaf !== undefined) this.aimLost = true;
235
239
  this.selectedId = leaf?.node.id;
236
240
  return leaf;
@@ -301,7 +305,7 @@ export class AgentOverlay extends Container {
301
305
  }
302
306
  this.addChild(new Spacer(1));
303
307
  const stoppable = selected !== undefined && this.canStopNode(selected);
304
- const steerable = selected !== undefined && selected.status === "running" && (this.actions.canSteer?.(selected.id) ?? false);
308
+ const steerable = selected !== undefined && !isNonAgentNode(selected) && selected.status === "running" && (this.actions.canSteer?.(selected.id) ?? false);
305
309
  this.addChild(new Text(t.fg("dim", `↑↓ navigate ⏎ open${this.hintLine("list", stoppable, steerable, this.stoppableIds(nodes).length)} esc close`), 1, 0));
306
310
  this.renderNotice();
307
311
  }
@@ -379,10 +383,15 @@ export class AgentOverlay extends Container {
379
383
  // A wake source is armed, not a live agent: it has no steer handle, so the agent-only "● live"
380
384
  // badge and the steer-unavailable hint below would both be wrong for it.
381
385
  const wake = isWakeSource(node);
386
+ const localActivity = node.kind === "activity";
382
387
  this.addChild(
383
- new Text(`${nodeGlyph(node)} ${t.fg("accent", t.bold(safeInline(node.label) || "agent"))}${live ? t.fg("success", wake ? " ● armed" : " ● live") : ""}`, 1, 0),
388
+ new Text(`${nodeGlyph(node)} ${t.fg("accent", t.bold(safeInline(node.label) || "agent"))}${live ? t.fg("success", localActivity ? " ● local work" : wake ? " ● armed" : " ● live") : ""}`, 1, 0),
384
389
  );
385
390
  if (node.detail) this.addChild(new Text(t.fg("dim", safeInline(node.detail)), 1, 0));
391
+ // The drill-in has room for FULL effort names (the rows stay compact): an observed level
392
+ // appears only once a terminal result actually reported one.
393
+ const effortLine = thinkingDetailLine(node.thinking, node.thinkingEffective);
394
+ if (effortLine) this.addChild(new Text(t.fg("dim", effortLine), 1, 0));
386
395
  this.addChild(new Spacer(1));
387
396
 
388
397
  const raw = node.output?.trim()
@@ -401,7 +410,7 @@ export class AgentOverlay extends Container {
401
410
  for (const line of all.slice(start, end)) this.addChild(new Text(t.fg("toolOutput", line), 1, 0));
402
411
  if (this.detailScroll > 0) this.addChild(new Text(t.fg("dim", `▼ ${this.detailScroll} newer`), 1, 0));
403
412
 
404
- const steerable = live && (this.actions.canSteer?.(node.id) ?? false);
413
+ const steerable = live && !isNonAgentNode(node) && (this.actions.canSteer?.(node.id) ?? false);
405
414
  const stoppable = this.canStopNode(node);
406
415
  if (this.steering && !steerable) this.steering = false; // agent finished mid-compose
407
416
  this.addChild(new Spacer(1));
@@ -413,7 +422,7 @@ export class AgentOverlay extends Container {
413
422
  } else {
414
423
  this.addChild(new Text(t.fg("dim", `esc back · ↑↓ scroll${this.hintLine("detail", stoppable, steerable)}`), 1, 0));
415
424
  this.renderNotice();
416
- if (live && !steerable && !wake) {
425
+ if (live && !steerable && !isNonAgentNode(node)) {
417
426
  this.addChild(new Text(t.fg("dim", "(steer unavailable: no live handle yet, or this engine/broker does not expose one)"), 1, 0));
418
427
  }
419
428
  }
@@ -448,7 +457,8 @@ export class AgentOverlay extends Container {
448
457
  } else if (hadNotice) this.refresh();
449
458
  return;
450
459
  }
451
- if (keyData === "s" && (this.actions.canSteer?.(this.detailId) ?? false)) {
460
+ const detailNode = this.tree.snapshot().find((node) => node.id === this.detailId);
461
+ if (keyData === "s" && detailNode && !isNonAgentNode(detailNode) && (this.actions.canSteer?.(this.detailId) ?? false)) {
452
462
  this.steering = true;
453
463
  this.steerBuffer = "";
454
464
  this.refresh();
@@ -484,7 +494,7 @@ export class AgentOverlay extends Container {
484
494
  // Steer straight from the list: drill into the selected agent with the
485
495
  // compose line already open (same gate as the detail view's `s`).
486
496
  const leaf = this.aimedLeaf(leaves);
487
- if (leaf && leaf.node.status === "running" && (this.actions.canSteer?.(leaf.node.id) ?? false)) {
497
+ if (leaf && !isNonAgentNode(leaf.node) && leaf.node.status === "running" && (this.actions.canSteer?.(leaf.node.id) ?? false)) {
488
498
  this.detailId = leaf.node.id;
489
499
  this.detailScroll = 0;
490
500
  this.steering = true;
@@ -504,8 +514,8 @@ export class AgentOverlay extends Container {
504
514
  /** Whether an agent can be stopped right now: it must be running, and the caller's `canStop`
505
515
  * gate (defaulting to "yes" when omitted) must agree. Shared by both hints and `tryStop`, so
506
516
  * the hint that advertises "x stop" and the handler that actually gates it never diverge. */
507
- private canStopNode(node: Pick<AgentNode, "id" | "status">): boolean {
508
- return node.status === "running" && (this.actions.canStop?.(node.id) ?? true);
517
+ private canStopNode(node: Pick<AgentNode, "id" | "status" | "kind">): boolean {
518
+ return node.kind !== "activity" && node.status === "running" && (this.actions.canStop?.(node.id) ?? true);
509
519
  }
510
520
 
511
521
  /** The agents `X` would stop: every leaf that `x` could stop, wake sources excluded. `X` means
@@ -513,7 +523,7 @@ export class AgentOverlay extends Container {
513
523
  * only by an explicit `x` on its own row. One list feeds the hint count, the confirmation prompt
514
524
  * and the bulk stop, so the three always agree. */
515
525
  private stoppableIds(snap: AgentNode[] = this.tree.snapshot()): string[] {
516
- return this.leafRows(snap).filter((r) => !isWakeSource(r.node) && this.canStopNode(r.node)).map((r) => r.node.id);
526
+ return this.leafRows(snap).filter((r) => !isNonAgentNode(r.node) && this.canStopNode(r.node)).map((r) => r.node.id);
517
527
  }
518
528
 
519
529
  /** The trailing "x stop"/"X stop all"/"s steer" hint fragment for an action line — the list's