@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
@@ -7,7 +7,7 @@ import { compactInlineText } from "../ui/presentation.ts";
7
7
  export interface ModelsToolDeps {
8
8
  get lastCtx(): ExtensionContext | undefined;
9
9
  set lastCtx(value: ExtensionContext | undefined);
10
- searchModels(ctx: ExtensionContext, query?: string): { lines: string[]; total: number; capped: boolean };
10
+ searchModels(ctx: ExtensionContext, query?: string): { lines: string[]; total: number; capped: boolean; shortlist: boolean };
11
11
  }
12
12
 
13
13
  export function registerModelsTool(pi: ExtensionAPI, d: ModelsToolDeps): void {
@@ -16,23 +16,33 @@ export function registerModelsTool(pi: ExtensionAPI, d: ModelsToolDeps): void {
16
16
  name: "models",
17
17
  label: "Models",
18
18
  description: [
19
- "List or search the available model ids (provider/id). The same name exists under several",
20
- "providers — use an EXACT id from here as a delegate task's `model`. ★ marks your session",
21
- "provider; prefer it (it's the authenticated one).",
19
+ "Discover model candidates (provider/id). With no query: the current model first, then a compact",
20
+ "shortlist of nearby family/version candidates from the same provider, ordered cost:low → medium → high → unknown.",
21
+ "Family and version names are proximity hints, not verified release dates or account support. A query searches",
22
+ "the full eligible catalog, including older models and snapshots. Use exact refs; ★ marks your",
23
+ "session provider, not account support. Authentication does not guarantee access to every listed model.",
24
+ "cost:low/medium/high ranks advertised API prices relative to recent candidates of the same provider;",
25
+ "cost:unknown means insufficient price data. These are cost bands, not quality, speed or reasoning-effort ratings; choose task fit yourself.",
26
+ "Context, reasoning support and API prices are host catalog metadata; API prices do not describe subscription billing.",
27
+ "Prefer these nearby candidates before older or unrelated models merely because they are cheap. Omitting `model` preserves",
28
+ "saved per-persona role/agent assignments, then the agent frontmatter model and engine defaults; it does not necessarily select the supervisor model.",
29
+ "To intentionally choose the supervisor's current model over a configured choice, pass its exact provider/id as `model` only when explicitly authorized.",
30
+ "Never silently switch providers or billing routes.",
22
31
  ].join(" "),
23
32
  parameters: Type.Object({
24
- query: Type.Optional(Type.String({ description: "Filter by substring (provider or id), e.g. 'sonnet'" })),
33
+ query: Type.Optional(Type.String({ description: "Search the full catalog by substring (provider or id), e.g. 'sonnet'; omit for the current-first shortlist" })),
25
34
  }),
26
35
  async execute(_id, params, _signal, _onUpdate, ctx) {
27
36
  d.lastCtx = ctx;
28
- const { lines, total, capped } = d.searchModels(ctx, params.query);
37
+ const { lines, total, capped, shortlist } = d.searchModels(ctx, params.query);
38
+ const showing = shortlist || capped ? ` (showing ${lines.length} ${shortlist ? "nearby family/version candidates" : "matches"})` : "";
29
39
  const text = lines.length
30
- ? `${total} model(s)${capped ? " (showing 40 — refine with a query)" : ""}; ★ = session provider:\n${lines.join("\n")}`
40
+ ? `${total} catalog model(s)${showing}; ★ = session provider, (you) = current model. Account support may vary.${shortlist || capped ? " Query searches the full catalog." : ""}\n${lines.join("\n")}`
31
41
  : `no models match "${params.query ?? ""}"`;
32
- return { content: [{ type: "text", text }], details: { total }, isError: false };
42
+ return { content: [{ type: "text", text }], details: { total, shown: lines.length, shortlist }, isError: false };
33
43
  },
34
44
  renderCall(args, theme) {
35
- const query = compactInlineText(args.query ?? "(all)", { maxChars: 96 }) || "(all)";
45
+ const query = compactInlineText(args.query ?? "(shortlist)", { maxChars: 96 }) || "(shortlist)";
36
46
  return new Text(`${theme.fg("toolTitle", theme.bold("models "))}${theme.fg("dim", query)}`, 0, 0);
37
47
  },
38
48
  });
@@ -3,34 +3,67 @@ 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 type { MonitorManager } from "../core/monitor.ts";
6
- import { failureDetails } from "../extension/shared.ts";
6
+ import { neutralizeMonitorUnicode } from "../core/monitor-output.ts";
7
+ import { armWakeAdvice, failureDetails } from "../extension/shared.ts";
7
8
  import { compactInlineText } from "../ui/presentation.ts";
9
+ import { fenceUntrusted, stripTerminalControls } from "../core/fence.ts";
10
+ import type { MonitorArchive, MonitorLogRecord } from "../monitor/archive.ts";
11
+
12
+ /** A bounded, fenced read of a session-owned log; never adds a wake by itself. */
13
+ export function renderMonitorLog(record: MonitorLogRecord, full = false): string {
14
+ const limit = full ? 16_384 : 4_000;
15
+ const tail = (text: string) => {
16
+ // Match the live monitor projection's inert bidi/separator policy without rewriting captures.
17
+ const safe = neutralizeMonitorUnicode(stripTerminalControls(text));
18
+ return safe.length > limit ? `…[display truncated; use result with full=true for the retained capture]\n${safe.slice(-limit)}` : safe || "(no output)";
19
+ };
20
+ return [
21
+ `${compactInlineText(record.entry.id)} (${compactInlineText(record.entry.label)}) — ${record.outcome}`,
22
+ ...(record.truncated ? ["Log capture is bounded; older output was truncated."] : []),
23
+ ...(record.storageWarning ? [compactInlineText(record.storageWarning, { maxChars: 400 })] : []),
24
+ fenceUntrusted(`${tail(record.text)}\nstdout:\n${tail(record.stdout)}\nstderr:\n${tail(record.stderr)}`),
25
+ ].join("\n");
26
+ }
8
27
 
9
28
  export interface MonitorToolDeps {
10
29
  get lastCtx(): ExtensionContext | undefined;
11
30
  set lastCtx(value: ExtensionContext | undefined);
12
31
  canRun(): boolean;
13
32
  monitors: Pick<MonitorManager, "arm" | "list" | "cancel">;
33
+ archive?: Pick<MonitorArchive, "result" | "list">;
14
34
  }
15
35
 
16
36
  export function registerMonitorTool(pi: ExtensionAPI, d: MonitorToolDeps): void {
17
37
  pi.registerTool({
18
38
  name: "monitor",
19
39
  label: "Monitor",
20
- description: "Watch a job, file, log or system event using a background program. arm starts command (an executable) with args in the current workspace; no implicit shell. The argument list allows up to 64 literal arguments, each up to 8192 characters and 24,000 characters in total. mode=output wakes on nonempty stdout lines; write an adapter that prints only relevant changes. mode=exit wakes once when the launched job finishes. Returns immediately; continue independent work, then end your turn to receive events automatically. Requires monitor AND bash permission. Default: one event, ten-minute deadline; max eight monitors, twenty events each, twenty-four hours. list shows active watches; cancel stops the owned process tree and queued wakes. Keep this Pi session open; watches stop on session shutdown/reload. Do not wrap delegate or Exocom in a monitor: their notifications are already automatic.",
40
+ description: "Watch a job, file, log or system event using a background program. arm starts command (an executable) with args in the current workspace; no implicit shell. The argument list allows up to 64 literal arguments, each up to 8192 characters and 24,000 characters in total. mode=output wakes on nonempty stdout lines; write an adapter that prints only relevant changes. mode=exit wakes once when the launched job finishes. Returns immediately; continue independent work, then end your turn: events wake you automatically. Requires monitor AND bash permission. Default: one event, ten-minute deadline; max eight monitors, twenty events each, twenty-four hours. list shows active watches; result without id lists retained session logs, result with id reads a bounded log and outcome (also after reload/resume), full=true reads the complete retained capture; do not poll result to wait. Logs span the whole session tree, including sibling conversation branches; branch navigation does not hide captures. They are bounded session artifacts, not complete unlimited transcripts. cancel stops the owned process tree and queued wakes. Keep this Pi session open; watches stop on session shutdown/reload, and a headless `pi -p` run exits after its turn. A wake source, not a wait: for a fixed time use `timer`; sub-agents and Exocom wake you on their own.",
21
41
  parameters: Type.Object({
22
- action: Type.Union([Type.Literal("arm"), Type.Literal("list"), Type.Literal("cancel")]),
42
+ action: Type.Union([Type.Literal("arm"), Type.Literal("list"), Type.Literal("cancel"), Type.Literal("result")]),
23
43
  command: Type.Optional(Type.String({ minLength: 1, maxLength: 4096, description: "arm: executable, e.g. node, python or powershell.exe. Use args for script paths and arguments." })),
24
44
  args: Type.Optional(Type.Array(Type.String({ maxLength: 8192 }), { maxItems: 64, description: "arm: literal argv (up to 64 arguments, each up to 8192 characters; combined argument strings maximum 24000 characters). For shell syntax, explicitly launch the appropriate shell and pass a script file." })),
25
45
  label: Type.Optional(Type.String({ minLength: 1, maxLength: 120, description: "arm: short purpose, e.g. build finished or new application error." })),
26
46
  mode: Type.Optional(Type.Union([Type.Literal("output"), Type.Literal("exit")], { description: "arm: output for event-producing watchers; exit for job completion. Required." })),
27
47
  timeoutSeconds: Type.Optional(Type.Number({ minimum: 1, maximum: 86400, description: "arm: lifetime deadline, default 600 seconds. Expiry stops the process and reports a timeout." })),
28
48
  maxEvents: Type.Optional(Type.Integer({ minimum: 1, maximum: 20, description: "arm/output: stop after this many distinct event lines, default 1." })),
29
- id: Type.Optional(Type.String({ minLength: 1, description: "cancel: monitor id returned by arm." })),
49
+ id: Type.Optional(Type.String({ minLength: 1, description: "cancel/result: monitor id returned by arm; omit for result to list retained logs." })),
50
+ full: Type.Optional(Type.Boolean({ description: "result: show the complete bounded capture instead of a short tail. Never recovers output already truncated by retention." })),
30
51
  }),
31
52
  async execute(_id, params, signal, _onUpdate, ctx) {
32
53
  d.lastCtx = ctx;
33
54
  const fail = (text: string) => ({ content: [{ type: "text" as const, text }], details: failureDetails({ ok: false }), isError: true });
55
+ if (params.action === "result") {
56
+ if (!d.archive) return fail("Monitor logs are unavailable in this session.");
57
+ if (params.id === undefined) {
58
+ const records = d.archive.list();
59
+ return { content: [{ type: "text" as const, text: records.length
60
+ ? records.map((r) => `${compactInlineText(r.entry.id)} (${compactInlineText(r.entry.label)}) — ${r.outcome}${r.storageWarning ? "; storage limited" : ""}`).join("\n")
61
+ : "No retained monitor logs in this session." }], details: { ok: true, count: records.length } };
62
+ }
63
+ const record = d.archive.result(params.id);
64
+ if (!record) return fail(`Monitor log unavailable for ${compactInlineText(params.id)}; it may predate logging or have exceeded session retention. Use result without id to list available logs.`);
65
+ return { content: [{ type: "text" as const, text: renderMonitorLog(record, params.full === true) }], details: { ok: true, id: record.entry.id, outcome: record.outcome, truncated: record.truncated } };
66
+ }
34
67
  if (params.action === "list") {
35
68
  const entries = d.monitors.list();
36
69
  return { content: [{ type: "text" as const, text: entries.length ? entries.map((e) => `${e.id} (${compactInlineText(e.label, { maxChars: 120 })}) — ${e.mode}; deadline ${new Date(e.expiresAtEpochMs).toISOString()}`).join("\n") : "No active monitors." }], details: { ok: true, count: entries.length } };
@@ -40,7 +73,7 @@ export function registerMonitorTool(pi: ExtensionAPI, d: MonitorToolDeps): void
40
73
  const cancelled = d.monitors.cancel(params.id);
41
74
  return { content: [{ type: "text" as const, text: cancelled ? `Cancelled ${params.id}; its queued events were removed.` : `No active monitor or queued event for ${params.id}.` }], details: { ok: true, cancelled } };
42
75
  }
43
- if (params.action !== "arm") return fail("monitor action must be arm, list or cancel.");
76
+ if (params.action !== "arm") return fail("monitor action must be arm, list, result or cancel.");
44
77
  if (!d.canRun()) return fail("monitor arm requires both monitor and bash permission for the active persona.");
45
78
  if (signal?.aborted) return fail("monitor arm cancelled before launch.");
46
79
  if (typeof params.command !== "string" || !params.command.trim() || typeof params.label !== "string" || !params.label.trim()) return fail("monitor arm needs { command, label, mode }.");
@@ -55,7 +88,7 @@ export function registerMonitorTool(pi: ExtensionAPI, d: MonitorToolDeps): void
55
88
  ...(params.maxEvents !== undefined ? { maxEvents: params.maxEvents } : {}),
56
89
  });
57
90
  if (!result.ok) return fail(result.error);
58
- 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()}. Keep this session open. Continue independent work, or end your turn and wait for the automatic notice.` }], details: { ok: true, id: result.entry.id, expiresAtEpochMs: result.entry.expiresAtEpochMs } };
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 } };
59
92
  },
60
93
  renderCall(args, theme) {
61
94
  const action = compactInlineText(args.action ?? "", { maxChars: 12 });
@@ -1,7 +1,7 @@
1
1
  /** Session timer tool — arm a wall-clock wake. */
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { Type } from "typebox";
4
- import { failureDetails } from "../extension/shared.ts";
4
+ import { armWakeAdvice, failureDetails } from "../extension/shared.ts";
5
5
  import { formatRemaining, type TimerScheduler } from "../core/timer.ts";
6
6
  import { parseInstant } from "../core/time.ts";
7
7
  import { readClockSnapshot } from "../extension/clock.ts";
@@ -13,11 +13,8 @@ export interface TimerToolDeps {
13
13
  }
14
14
 
15
15
  export function registerTimerTool(pi: ExtensionAPI, d: TimerToolDeps): void {
16
- // ── timer tool (arm a wall-clock alarm that WAKES the session when it fires) ───
17
- // Solves the "wait N minutes for a scheduled event or service window" problem WITHOUT a poll loop:
18
- // arm an alarm, end your turn, and when it expires the extension injects a follow-up that
19
- // resumes you (idle-gated so it starts a fresh turn instead of stranding). Up to 32 alarms may be
20
- // armed; cancel/list them. In-memory per session (a reload/new session clears armed alarms).
16
+ // ── timer tool: a wall-clock alarm that WAKES the idle session when it fires (see core/timer.ts) ───
17
+ // Up to 32 alarms; in-memory per session (a reload/new session clears them).
21
18
  const TimerToolParams = Type.Object({
22
19
  action: Type.Union([Type.Literal("arm"), Type.Literal("cancel"), Type.Literal("list"), Type.Literal("now")], {
23
20
  description: "arm = schedule a wakeup · cancel = drop one by id · list = show armed alarms · now = read the fresh clock",
@@ -27,18 +24,19 @@ export function registerTimerTool(pi: ExtensionAPI, d: TimerToolDeps): void {
27
24
  ),
28
25
  delaySeconds: Type.Optional(Type.Number({ description: "arm: fire this many seconds from now; it is rounded to milliseconds and must produce a delay from 1 second through 86400 seconds. Give this OR atIso, not both." })),
29
26
  atIso: Type.Optional(Type.String({ description: "arm: fire at this absolute ISO-8601 instant (date-time must include Z or a numeric timezone offset, e.g. '2026-07-11T19:00:00Z'). Give this OR delaySeconds, not both." })),
30
- label: Type.Optional(Type.String({ description: "arm: a short human label for the alarm (e.g. 'status review')." })),
27
+ label: Type.Optional(Type.String({ description: "arm: a short human label for the alarm (e.g. 'status review'). Labels longer than 120 characters are clipped." })),
31
28
  id: Type.Optional(Type.String({ description: "cancel: the timer id to cancel (e.g. 'timer-1')." })),
32
29
  });
33
30
  pi.registerTool({
34
31
  name: "timer",
35
32
  label: "Timer",
36
33
  description: [
37
- "Arm a wall-clock ALARM that wakes you when it fires — the token-cheap way to wait for a",
38
- "fixed moment (a scheduled event, a service window, a scheduled re-check) instead of a",
39
- "poll loop. `arm` with { message, delaySeconds } or { message, atIso }; end your turn; when",
40
- "it expires a follow-up carrying your message is injected and you resume. `cancel` by id,",
41
- "`list` the armed ones. Up to 32 alarms may be armed at once. The session must remain open;",
34
+ "Wake yourself at a wall-clock moment the world decides (an external deadline, a service window,",
35
+ "a reminder at a fixed time). `arm` with { message, delaySeconds } or { message, atIso }; continue",
36
+ "independent work, then end your turn when nothing else remains; when it fires, a follow-up",
37
+ "carrying your message arrives once you are idle. `cancel` by id, `list` the armed ones.",
38
+ "Never use it to wait for a sub-agent or monitor (they wake you on their own) or to poll: if something can report the event, use `monitor`.",
39
+ "Up to 32 alarms may be armed at once. The session must remain open;",
42
40
  "alarms are in-memory and cleared on shutdown or reload. `now` returns a fresh UTC time plus",
43
41
  "the local timezone and numeric offset.",
44
42
  ].join(" "),
@@ -98,7 +96,7 @@ export function registerTimerTool(pi: ExtensionAPI, d: TimerToolDeps): void {
98
96
  return { content: [{ type: "text", text: r.error ?? "timer arm failed." }], details: failureDetails({ action: "arm", ok: false }), isError: true };
99
97
  }
100
98
  const e = r.entry;
101
- const text = `Armed ${e.id} (${e.label}) — fires in ${formatRemaining(r.remainingMs ?? e.fireAtEpochMs - e.armedAtEpochMs)} [${new Date(e.fireAtEpochMs).toISOString()}]. On fire I'll be woken with: "${e.message}". You can end this turn now; keep the session open for the alarm.`;
99
+ 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.")}`;
102
100
  return { content: [{ type: "text", text }], details: { action: "arm", id: e.id, fireAtEpochMs: e.fireAtEpochMs, ok: true }, isError: false };
103
101
  },
104
102
  });
@@ -2,8 +2,8 @@
2
2
  * A focusable overlay over the unified agent tree, opened with ctx.ui.custom
3
3
  * (overlay: true). ↑↓ navigate the *agents* (leaf rows; a parent like "delegate"
4
4
  * is a non-selectable header), ⏎ drills into one — its live output in an
5
- * auto-scrolling viewport sized to the terminal — `x` stops it, `s` steers it
6
- * (from the list too), esc backs out / closes. Subscribes to the tree, so it
5
+ * auto-scrolling viewport sized to the terminal — `x` stops it, `X` (twice) stops every
6
+ * running agent, `s` steers it (from the list too), esc backs out / closes. Subscribes to the tree, so it
7
7
  * re-renders live as agents stream.
8
8
  *
9
9
  * The pure parts (flatten/order, glyphs, scroll window) live in agent-tree.ts /
@@ -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, GLYPH, runningAnnotation } from "./agent-tree.ts";
26
+ import { type AgentNode, type AgentTree, type FlatRow, deriveWaitingNodeIds, flattenTree, isWakeSource, nodeGlyph, runningAnnotation } 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";
@@ -83,7 +83,7 @@ export function composeAgentRow(input: AgentRowInput): string {
83
83
  const detail = detailMax > 0 ? safeInline(node.detail ?? "", detailMax) : "";
84
84
  const detailText = detail ? t.fg("dim", ` ${detail}`) : "";
85
85
 
86
- const labelLine = `${indent}${GLYPH[node.status]} ${label}`;
86
+ const labelLine = `${indent}${nodeGlyph(node)} ${label}`;
87
87
  const prefixed = selected ? t.fg("accent", `▸ ${labelLine}`) : ` ${labelLine}`;
88
88
  let body = `${prefixed}${detailText}`;
89
89
  if (visibleWidth(body) > bodyMax) body = truncateToWidth(body, bodyMax, "");
@@ -119,6 +119,7 @@ export class AgentOverlay extends Container {
119
119
  private unsubscribe: () => void;
120
120
  private selectedId: string | undefined; // the selected agent's *id*, never its row index
121
121
  private aimLost = false; // the aimed-at agent settled: swallow the next directed keystroke
122
+ private stopAllArmed: string | undefined; // the stoppable set the "press X again" prompt armed on
122
123
  private listScroll = 0; // list rows scrolled off the top (keeps the selection visible)
123
124
  private detailId: string | undefined;
124
125
  private detailScroll = 0; // output lines scrolled up from the bottom (0 = latest)
@@ -209,9 +210,11 @@ export class AgentOverlay extends Container {
209
210
  return deriveWaitingNodeIds(nodes, (id) => this.actions.isWaitingForSupervisor?.(id) === true);
210
211
  }
211
212
 
213
+ /** The selectable rows: every leaf, plus a container that carries a stop of its own (a council/flow run
214
+ * root), so `x` reaches the whole run instead of one leg. Other containers stay headers. */
212
215
  private leafRows(snap: AgentNode[] = this.tree.snapshot()): FlatRow[] {
213
216
  const parents = new Set(snap.map((n) => n.parentId).filter((p): p is string => p !== undefined));
214
- return flattenTree(snap).filter((r) => !parents.has(r.node.id));
217
+ return flattenTree(snap).filter((r) => !parents.has(r.node.id) || this.actions.canStop?.(r.node.id) === true);
215
218
  }
216
219
 
217
220
  /**
@@ -220,11 +223,14 @@ export class AgentOverlay extends Container {
220
223
  * (stop) and `s` (steer) onto a *different* agent whenever an earlier sibling went
221
224
  * away. When the aimed-at agent is the one that vanishes, fall back to the head of
222
225
  * the list, where the ▸ marker visibly shows the user their target changed — and
223
- * record that the aim was lost, for the directed keys (see `aimedLeaf`).
226
+ * record that the aim was lost, for the directed keys (see `aimedLeaf`). The (re-)anchor
227
+ * prefers an agent row: a monitor or timer that merely happens to be listed first must not
228
+ * inherit the marker, or the next `x` could cancel a two-hour watch. A wake row takes it only
229
+ * when no agent is left.
224
230
  */
225
231
  private selectedLeaf(leaves: FlatRow[]): FlatRow | undefined {
226
232
  const anchored = this.selectedId !== undefined ? leaves.find((r) => r.node.id === this.selectedId) : undefined;
227
- const leaf = anchored ?? leaves[0];
233
+ const leaf = anchored ?? leaves.find((r) => !isWakeSource(r.node)) ?? leaves[0];
228
234
  if (this.selectedId !== undefined && anchored === undefined && leaf !== undefined) this.aimLost = true;
229
235
  this.selectedId = leaf?.node.id;
230
236
  return leaf;
@@ -296,7 +302,7 @@ export class AgentOverlay extends Container {
296
302
  this.addChild(new Spacer(1));
297
303
  const stoppable = selected !== undefined && this.canStopNode(selected);
298
304
  const steerable = selected !== undefined && selected.status === "running" && (this.actions.canSteer?.(selected.id) ?? false);
299
- this.addChild(new Text(t.fg("dim", `↑↓ navigate ⏎ open${this.hintLine("list", stoppable, steerable)} esc close`), 1, 0));
305
+ this.addChild(new Text(t.fg("dim", `↑↓ navigate ⏎ open${this.hintLine("list", stoppable, steerable, this.stoppableIds(nodes).length)} esc close`), 1, 0));
300
306
  this.renderNotice();
301
307
  }
302
308
 
@@ -370,8 +376,11 @@ export class AgentOverlay extends Container {
370
376
  return;
371
377
  }
372
378
  const live = node.status === "running";
379
+ // A wake source is armed, not a live agent: it has no steer handle, so the agent-only "● live"
380
+ // badge and the steer-unavailable hint below would both be wrong for it.
381
+ const wake = isWakeSource(node);
373
382
  this.addChild(
374
- new Text(`${GLYPH[node.status]} ${t.fg("accent", t.bold(safeInline(node.label) || "agent"))}${live ? t.fg("success", " ● live") : ""}`, 1, 0),
383
+ new Text(`${nodeGlyph(node)} ${t.fg("accent", t.bold(safeInline(node.label) || "agent"))}${live ? t.fg("success", wake ? " ● armed" : " ● live") : ""}`, 1, 0),
375
384
  );
376
385
  if (node.detail) this.addChild(new Text(t.fg("dim", safeInline(node.detail)), 1, 0));
377
386
  this.addChild(new Spacer(1));
@@ -404,7 +413,7 @@ export class AgentOverlay extends Container {
404
413
  } else {
405
414
  this.addChild(new Text(t.fg("dim", `esc back · ↑↓ scroll${this.hintLine("detail", stoppable, steerable)}`), 1, 0));
406
415
  this.renderNotice();
407
- if (live && !steerable) {
416
+ if (live && !steerable && !wake) {
408
417
  this.addChild(new Text(t.fg("dim", "(steer unavailable: no live handle yet, or this engine/broker does not expose one)"), 1, 0));
409
418
  }
410
419
  }
@@ -413,6 +422,8 @@ export class AgentOverlay extends Container {
413
422
  handleInput(keyData: string): void {
414
423
  const hadNotice = this.notice !== undefined;
415
424
  this.notice = undefined; // any key clears a previous refusal notice
425
+ const armed = this.stopAllArmed;
426
+ this.stopAllArmed = undefined; // only an immediately repeated X confirms
416
427
  const kb = getKeybindings();
417
428
  if (this.detailId) {
418
429
  // Steer mode: type a message into the running agent. Capture all printable keys
@@ -483,6 +494,8 @@ export class AgentOverlay extends Container {
483
494
  } else if (keyData === "x") {
484
495
  const leaf = this.aimedLeaf(leaves);
485
496
  if (leaf) this.tryStop(leaf.node.id);
497
+ } else if (keyData === "X") {
498
+ this.stopAll(armed);
486
499
  } else if (kb.matches(keyData, "tui.select.cancel")) {
487
500
  this.close();
488
501
  } else if (hadNotice) this.refresh(); // unhandled key: still repaint so a cleared notice disappears
@@ -495,11 +508,20 @@ export class AgentOverlay extends Container {
495
508
  return node.status === "running" && (this.actions.canStop?.(node.id) ?? true);
496
509
  }
497
510
 
498
- /** The trailing "x stop"/"s steer" hint fragment for an action line — the list's three-space
499
- * style or the detail view's " · " style, the two views' only difference here. */
500
- private hintLine(kind: "list" | "detail", stoppable: boolean, steerable: boolean): string {
511
+ /** The agents `X` would stop: every leaf that `x` could stop, wake sources excluded. `X` means
512
+ * "stop every agent"; a monitor's job (it may be a two-hour batch) or a timer's alarm is cancelled
513
+ * only by an explicit `x` on its own row. One list feeds the hint count, the confirmation prompt
514
+ * and the bulk stop, so the three always agree. */
515
+ 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);
517
+ }
518
+
519
+ /** The trailing "x stop"/"X stop all"/"s steer" hint fragment for an action line — the list's
520
+ * three-space style or the detail view's " · " style, the two views' only difference here.
521
+ * `stopAll` (list only) is the stoppable count; one agent is what plain `x` is for. */
522
+ private hintLine(kind: "list" | "detail", stoppable: boolean, steerable: boolean, stopAll = 0): string {
501
523
  const sep = kind === "list" ? " " : " · ";
502
- return `${stoppable ? `${sep}x stop` : ""}${steerable ? `${sep}s steer` : ""}`;
524
+ return `${stoppable ? `${sep}x stop` : ""}${stopAll >= 2 ? `${sep}X stop all (${stopAll})` : ""}${steerable ? `${sep}s steer` : ""}`;
503
525
  }
504
526
 
505
527
  /** The one-line refusal notice, shown identically under the list and the detail view. */
@@ -518,6 +540,25 @@ export class AgentOverlay extends Container {
518
540
  this.refresh();
519
541
  }
520
542
 
543
+ /** `X`: stop every stoppable agent after one confirming press. The prompt states how many agents
544
+ * it covers and arms on the exact set it saw, so a set that changed since (or any key in
545
+ * between) asks again — a bulk, irreversible stop is never fired on a stale confirmation. */
546
+ private stopAll(armed: string | undefined): void {
547
+ const ids = this.stoppableIds();
548
+ const key = ids.join("|");
549
+ if (ids.length >= 2) {
550
+ if (armed === key) {
551
+ let stopped = 0;
552
+ for (const id of ids) if (this.actions.onStop?.(id)) stopped++;
553
+ this.notice = `stop requested for ${stopped} agent${stopped === 1 ? "" : "s"}; queued or later stages may still start`;
554
+ } else {
555
+ this.stopAllArmed = key;
556
+ this.notice = `press X again to stop ${ids.length} running agents`;
557
+ }
558
+ }
559
+ this.refresh();
560
+ }
561
+
521
562
  private close(): void {
522
563
  this.dispose();
523
564
  this.done();
@@ -1,6 +1,8 @@
1
1
  import { compactTokens } from "../core/format.ts";
2
2
  import { formatDuration } from "../core/time.ts";
3
+ import type { ChildUsage } from "../engine/stream.ts";
3
4
  import { compactInlineText } from "./presentation.ts";
5
+ import { formatUsage } from "./usage.ts";
4
6
 
5
7
  /**
6
8
  * The unified agent tree — a single live model of *every* agent in flight,
@@ -11,7 +13,7 @@ import { compactInlineText } from "./presentation.ts";
11
13
  */
12
14
 
13
15
  export type AgentNodeStatus = "running" | "done" | "failed" | "stopped";
14
- export type AgentNodeKind = "subagent" | "delegate" | "council" | "flow" | "phase";
16
+ export type AgentNodeKind = "subagent" | "delegate" | "council" | "flow" | "phase" | "monitor" | "timer";
15
17
 
16
18
  export interface AgentNode {
17
19
  id: string;
@@ -31,6 +33,8 @@ export interface AgentNode {
31
33
  * Excluded from change notifications — the live clock paints them. */
32
34
  startedAt?: number;
33
35
  lastAdvanceAt?: number;
36
+ /** Epoch ms when a wake source is due: a monitor's deadline or a timer's fire time. Its countdown replaces the elapsed/stall clock. Set once by the source; a change notifies (it is not a clock key). */
37
+ dueAt?: number;
34
38
  }
35
39
 
36
40
  export type AgentTreeChange =
@@ -60,6 +64,21 @@ export function flattenTree(nodes: AgentNode[]): FlatRow[] {
60
64
  /** Status → glyph, shared by every agent surface (tree, overlay). */
61
65
  export const GLYPH: Record<AgentNodeStatus, string> = { running: "⏳", done: "✓", failed: "✗", stopped: "■" };
62
66
 
67
+ /** A wake source is a row that is not an agent (never counted, steered, or stopped in bulk). */
68
+ export function isWakeSource(node: Pick<AgentNode, "kind">): boolean {
69
+ return node.kind === "monitor" || node.kind === "timer";
70
+ }
71
+
72
+ /** The kind-aware glyph shared by every surface (tree, overlay): a running timer is ⏰, a running
73
+ * monitor ◉; everything else, settled wake rows included, uses the ordinary status glyph. */
74
+ export function nodeGlyph(node: Pick<AgentNode, "status" | "kind">): string {
75
+ if (node.status === "running") {
76
+ if (node.kind === "timer") return "⏰";
77
+ if (node.kind === "monitor") return "◉";
78
+ }
79
+ return GLYPH[node.status];
80
+ }
81
+
63
82
  export interface RenderOptions {
64
83
  /** Current time; omitted ⇒ no elapsed/stall annotation (pure renders stay byte-identical). */
65
84
  now?: number;
@@ -79,11 +98,20 @@ export function isQueuedMarker(detail: string | undefined): boolean {
79
98
  /** "1m 15s" while a running node keeps advancing; "⚠ stalled 1m 30s" once it has been quiet
80
99
  * for `stallMs`. Undefined for settled nodes, nodes without clock data, or a node still carrying
81
100
  * the "queued" marker — it was seeded ahead of the concurrency gate and has not started running
82
- * yet, so its clock (stamped at seed time) has nothing true to report. Pure. */
83
- export function runningAnnotation(node: Pick<AgentNode, "status" | "startedAt" | "lastAdvanceAt" | "detail">, now: number, stallMs: number, waitingForSupervisor = false): string | undefined {
101
+ * yet, so its clock (stamped at seed time) has nothing true to report. A wake-source row (one with
102
+ * a `dueAt`) counts down instead — "fires in 49m 12s" for a timer, "12m 3s · deadline in 1h 47m"
103
+ * for a monitor — and NEVER shows the stall badge: a quiet monitor is waiting for its event,
104
+ * not stalled. Pure. */
105
+ export function runningAnnotation(node: Pick<AgentNode, "status" | "startedAt" | "lastAdvanceAt" | "detail" | "kind" | "dueAt">, now: number, stallMs: number, waitingForSupervisor = false): string | undefined {
84
106
  if (node.status !== "running" || node.startedAt === undefined) return undefined;
85
107
  if (isQueuedMarker(node.detail)) return undefined;
86
108
  if (waitingForSupervisor) return "waiting for supervisor";
109
+ if (node.dueAt !== undefined) {
110
+ const left = node.dueAt - now;
111
+ if (node.kind === "timer") return left > 0 ? `fires in ${formatDuration(left)}` : "firing…";
112
+ const elapsed = formatDuration(now - node.startedAt);
113
+ return left > 0 ? `${elapsed} · deadline in ${formatDuration(left)}` : `${elapsed} · deadline reached`;
114
+ }
87
115
  const quietSince = node.lastAdvanceAt ?? node.startedAt;
88
116
  if (stallMs > 0 && now - quietSince >= stallMs) return `⚠ stalled ${formatDuration(now - quietSince)}`;
89
117
  return formatDuration(now - node.startedAt);
@@ -118,12 +146,13 @@ export function deriveWaitingNodeIds(nodes: readonly AgentNode[], isWaiting: (id
118
146
  }
119
147
 
120
148
  /** The tree patch for one progress snapshot: every snapshot is an advance; activity wins over
121
- * a bare token count as the visible detail. One helper for every progress site. */
122
- export function progressPatch(snap: { output?: string; activity?: string; tokens?: number }, now: number): AgentNodePatch {
149
+ * usage, formatted like a completed row. Legacy aggregate-only progress stays a neutral count. */
150
+ export function progressPatch(snap: { output?: string; activity?: string; tokens?: number; usage?: ChildUsage }, now: number): AgentNodePatch {
123
151
  const patch: AgentNodePatch = { lastAdvanceAt: now };
124
152
  if (snap.output) patch.output = snap.output;
125
153
  if (snap.activity) patch.detail = snap.activity;
126
- else if (snap.tokens) patch.detail = `${compactTokens(snap.tokens)} tok`;
154
+ else if (snap.usage) patch.detail = formatUsage(snap.usage);
155
+ else if (snap.tokens) patch.detail = compactTokens(snap.tokens);
127
156
  return patch;
128
157
  }
129
158
 
@@ -155,7 +184,7 @@ export function renderAgentTree(nodes: AgentNode[], opts: RenderOptions = {}): s
155
184
  const clock = opts.now !== undefined ? runningAnnotation(node, opts.now, opts.stallMs ?? 0, opts.waitingForSupervisor?.has(node.id) ?? false) : undefined;
156
185
  const tail = [detailText, clock].filter((s): s is string => Boolean(s)).join(" · ");
157
186
  const detail = tail ? ` ${tail}` : "";
158
- lines.push(`${prefix}${branch}${GLYPH[node.status]} ${label}${detail}`);
187
+ lines.push(`${prefix}${branch}${nodeGlyph(node)} ${label}${detail}`);
159
188
  const childPrefix = isRoot ? "" : `${prefix}${isLast ? " " : "│ "}`;
160
189
  walk(node.id, childPrefix);
161
190
  });
@@ -165,14 +194,41 @@ export function renderAgentTree(nodes: AgentNode[], opts: RenderOptions = {}): s
165
194
  return lines;
166
195
  }
167
196
 
197
+ /** One digest line for the running wake sources (monitors, timers) that fell below the summary
198
+ * fold: counts by kind ("◉ 1 monitor · ⏰ 2 timers"), plus the countdown to the soonest finite
199
+ * `dueAt` when a clock is given. A monitor armed after a fan-out would otherwise hide behind the
200
+ * "… +N more" line — exactly the "armed but nothing visible" case wake rows exist to fix. Pure. */
201
+ function wakeDigestLine(hidden: readonly AgentNode[], now: number | undefined): string {
202
+ const monitors = hidden.filter((node) => node.kind === "monitor").length;
203
+ const timers = hidden.filter((node) => node.kind === "timer").length;
204
+ const parts: string[] = [];
205
+ if (monitors > 0) parts.push(`${nodeGlyph({ status: "running", kind: "monitor" })} ${monitors} monitor${monitors === 1 ? "" : "s"}`);
206
+ if (timers > 0) parts.push(`${nodeGlyph({ status: "running", kind: "timer" })} ${timers} timer${timers === 1 ? "" : "s"}`);
207
+ if (now !== undefined) {
208
+ const due = hidden.map((node) => node.dueAt).filter((at): at is number => at !== undefined && Number.isFinite(at));
209
+ if (due.length > 0) {
210
+ const left = Math.min(...due) - now;
211
+ parts.push(left > 0 ? `next due in ${formatDuration(left)}` : "next due now");
212
+ }
213
+ }
214
+ return parts.join(" · ");
215
+ }
216
+
168
217
  /**
169
218
  * Bounded above-editor digest. Large fan-outs belong in the scrollable F9
170
219
  * overlay; they must not push the editor and conversation off the screen.
220
+ *
221
+ * Below the fold, two kinds of row stay visible as one line each, because an operator must not
222
+ * have to open F9 to learn them: failures (`✗ N failed: …`) and RUNNING wake sources (`◉ 1 monitor
223
+ * · ⏰ 2 timers · next due in 4m 12s`). The layout when overflowing is
224
+ * `[...rows, failureLine?, wakeDigest?, overflowLine]`, every line inside `maxRows`; when the budget
225
+ * cannot hold them all the lowest-priority line goes first (failure > wake digest > overflow).
171
226
  */
172
227
  export function renderAgentTreeSummary(nodes: AgentNode[], maxRows = 8, opts: RenderOptions = {}): string[] {
173
228
  const rows = renderAgentTree(nodes, opts);
174
229
  const limit = Math.max(1, Math.floor(maxRows));
175
230
  if (rows.length <= limit) return rows;
231
+ let failureLine: string | undefined;
176
232
  const failed = nodes.filter((node) => node.status === "failed");
177
233
  if (failed.length > 0) {
178
234
  const names = failed.slice(0, 2).map((node) => {
@@ -181,20 +237,26 @@ export function renderAgentTreeSummary(nodes: AgentNode[], maxRows = 8, opts: Re
181
237
  return detail ? `${label} (${detail})` : label;
182
238
  });
183
239
  const extra = failed.length - names.length;
184
- const failureLine = `✗ ${failed.length} failed: ${names.join(", ")}${extra > 0 ? `, +${extra} more` : ""}`;
185
- if (limit === 1) return [failureLine];
186
- const visibleCount = Math.max(0, limit - 2);
187
- return [
188
- ...rows.slice(0, visibleCount),
189
- failureLine,
190
- `… +${rows.length - visibleCount} more · F9 or /agents for the full live tree`,
191
- ];
240
+ failureLine = `✗ ${failed.length} failed: ${names.join(", ")}${extra > 0 ? `, +${extra} more` : ""}`;
241
+ }
242
+ // `flattenTree(nodes)[i]` is the node behind `rows[i]`: `renderAgentTree` and `flattenTree` walk the
243
+ // same parent→children order, so a row's flat index doubles as its position in the rendered list.
244
+ const flat = flattenTree(nodes);
245
+ const hiddenWake = (from: number): AgentNode[] =>
246
+ flat.slice(from).map((row) => row.node).filter((node) => isWakeSource(node) && node.status === "running");
247
+ let visibleCount = Math.max(0, limit - (failureLine !== undefined ? 2 : 1));
248
+ let hidden = hiddenWake(visibleCount);
249
+ if (hidden.length > 0) {
250
+ // The digest takes a slot of its own; one fewer visible row can only hide more wake rows.
251
+ visibleCount = Math.max(0, visibleCount - 1);
252
+ hidden = hiddenWake(visibleCount);
192
253
  }
193
- const visibleCount = Math.max(0, limit - 1);
194
- return [
195
- ...rows.slice(0, visibleCount),
254
+ const tail = [
255
+ ...(failureLine !== undefined ? [failureLine] : []),
256
+ ...(hidden.length > 0 ? [wakeDigestLine(hidden, opts.now)] : []),
196
257
  `… +${rows.length - visibleCount} more · F9 or /agents for the full live tree`,
197
258
  ];
259
+ return [...rows.slice(0, visibleCount), ...tail.slice(0, Math.max(0, limit - visibleCount))];
198
260
  }
199
261
 
200
262
  export interface AddNodeInput {
@@ -209,6 +271,7 @@ export interface AddNodeInput {
209
271
  output?: string;
210
272
  startedAt?: number;
211
273
  lastAdvanceAt?: number;
274
+ dueAt?: number;
212
275
  }
213
276
 
214
277
  export interface AgentNodePatch {
@@ -220,6 +283,7 @@ export interface AgentNodePatch {
220
283
  output?: string;
221
284
  startedAt?: number;
222
285
  lastAdvanceAt?: number;
286
+ dueAt?: number;
223
287
  }
224
288
 
225
289
  /** Apply the fields `add`'s upsert branch and `update` both accept, in place — the one place
@@ -233,6 +297,7 @@ function applyPatch(node: AgentNode, patch: AgentNodePatch): void {
233
297
  if (patch.output !== undefined) node.output = patch.output;
234
298
  if (patch.startedAt !== undefined) node.startedAt = patch.startedAt;
235
299
  if (patch.lastAdvanceAt !== undefined) node.lastAdvanceAt = patch.lastAdvanceAt;
300
+ if (patch.dueAt !== undefined) node.dueAt = patch.dueAt;
236
301
  }
237
302
 
238
303
  /** A small mutable registry with change notification. The extension owns one. */
@@ -269,6 +334,7 @@ export class AgentTree {
269
334
  ...(input.agent !== undefined ? { agent: input.agent } : {}),
270
335
  ...(input.model !== undefined ? { model: input.model } : {}),
271
336
  ...(input.output !== undefined ? { output: input.output } : {}),
337
+ ...(input.dueAt !== undefined ? { dueAt: input.dueAt } : {}),
272
338
  };
273
339
  this.nodes.push(node);
274
340
  this.emit({ type: "added", node: { ...node } });
@@ -0,0 +1,25 @@
1
+ import { type Component, sliceByColumn, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { sanitizeTerminalText } from "./presentation.ts";
3
+
4
+ /**
5
+ * Public setWidget factory for already-budgeted, raw-text rows. Unlike the host's
6
+ * string[]/Text path this never wraps: one source row occupies at most one terminal
7
+ * row. Right-edge clipping preserves tree indentation and leading semantic markers;
8
+ * F9/exocom_list remain the full-detail surfaces.
9
+ *
10
+ * Snapshot and sanitize once per publication, without trimming/collapsing indentation.
11
+ * No theme colors are captured; each render uses its supplied width (including resize).
12
+ */
13
+ export function compactWidget(rows: readonly string[]): () => Component {
14
+ const lines = rows.map((row) => sanitizeTerminalText(row.replace(/\t/g, " ")).replace(/[\n\u2028\u2029]/g, " "));
15
+ return () => ({
16
+ render(width: number): string[] {
17
+ const columns = Math.max(0, Math.floor(width));
18
+ if (columns === 0) return [];
19
+ return lines.map((line) => visibleWidth(line) <= columns
20
+ ? line
21
+ : `${sliceByColumn(line, 0, columns - 1, true)}…`);
22
+ },
23
+ invalidate(): void { /* No width/theme-dependent cache. */ },
24
+ });
25
+ }