@aeondave/pi-persona 1.15.2

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 (165) hide show
  1. package/AGENTS.md +249 -0
  2. package/CHANGELOG.md +260 -0
  3. package/LICENSE +21 -0
  4. package/README.md +172 -0
  5. package/agents/balthasar.md +32 -0
  6. package/agents/casper.md +33 -0
  7. package/agents/evidence-verifier.md +23 -0
  8. package/agents/melchior.md +32 -0
  9. package/agents/operator.md +63 -0
  10. package/agents/research.md +58 -0
  11. package/agents/reviewer.md +49 -0
  12. package/agents/scout.md +23 -0
  13. package/agents/verifier.md +21 -0
  14. package/assets/banner.png +0 -0
  15. package/assets/demo1.gif +0 -0
  16. package/assets/demo1.mp4 +0 -0
  17. package/assets/workflow.png +0 -0
  18. package/contracts/finding.contract.json +9 -0
  19. package/docs/ARCHITECTURE.md +727 -0
  20. package/docs/EXPERIENCE.md +96 -0
  21. package/docs/MONITORS.md +84 -0
  22. package/docs/README.md +53 -0
  23. package/docs/REFERENCE.md +408 -0
  24. package/docs/SPINE.md +152 -0
  25. package/docs/STRATEGIES.md +215 -0
  26. package/docs/TELEMETRY.md +9 -0
  27. package/flows/deep-review.flow.json +8 -0
  28. package/flows/gated-build.flow.json +9 -0
  29. package/package.json +106 -0
  30. package/personas/audit.md +29 -0
  31. package/personas/dev.md +52 -0
  32. package/personas/elite.md +256 -0
  33. package/personas/judge.md +32 -0
  34. package/personas/magi.md +41 -0
  35. package/personas/planner.md +37 -0
  36. package/personas/researcher.md +37 -0
  37. package/personas/swarm.md +38 -0
  38. package/personas/verify.md +31 -0
  39. package/presets/magi-rounds.preset.json +5 -0
  40. package/prompts/spine.md +11 -0
  41. package/prompts/spine.worker.md +9 -0
  42. package/src/agents/agent.ts +78 -0
  43. package/src/bridge.ts +209 -0
  44. package/src/bus/broker/client.ts +379 -0
  45. package/src/bus/broker/framing.ts +43 -0
  46. package/src/bus/broker/host.ts +407 -0
  47. package/src/bus/broker/messages.ts +87 -0
  48. package/src/bus/broker/paths.ts +31 -0
  49. package/src/bus/contact.ts +145 -0
  50. package/src/bus/inproc.ts +279 -0
  51. package/src/bus/peers.ts +114 -0
  52. package/src/core/brief.ts +356 -0
  53. package/src/core/capabilities.ts +114 -0
  54. package/src/core/config.ts +228 -0
  55. package/src/core/contract.ts +391 -0
  56. package/src/core/data-root.ts +260 -0
  57. package/src/core/discovery.ts +33 -0
  58. package/src/core/display-label.ts +47 -0
  59. package/src/core/fence.ts +151 -0
  60. package/src/core/format.ts +13 -0
  61. package/src/core/frontmatter.ts +202 -0
  62. package/src/core/models.ts +192 -0
  63. package/src/core/monitor.ts +415 -0
  64. package/src/core/naming.ts +42 -0
  65. package/src/core/nudge.ts +238 -0
  66. package/src/core/ownership.ts +102 -0
  67. package/src/core/permissions.ts +56 -0
  68. package/src/core/pi-compat.ts +50 -0
  69. package/src/core/seed.ts +770 -0
  70. package/src/core/session-identity.ts +89 -0
  71. package/src/core/time.ts +202 -0
  72. package/src/core/timer.ts +221 -0
  73. package/src/core/types.ts +28 -0
  74. package/src/engine/adapter.ts +257 -0
  75. package/src/engine/async.ts +898 -0
  76. package/src/engine/change-report.ts +143 -0
  77. package/src/engine/child.ts +532 -0
  78. package/src/engine/errors.ts +30 -0
  79. package/src/engine/fallback.ts +57 -0
  80. package/src/engine/handles.ts +21 -0
  81. package/src/engine/inproc.ts +857 -0
  82. package/src/engine/signals.ts +11 -0
  83. package/src/engine/spec-preflight.ts +33 -0
  84. package/src/engine/stream.ts +369 -0
  85. package/src/engine/worktree.ts +144 -0
  86. package/src/exocom/activation.ts +31 -0
  87. package/src/exocom/codes.ts +261 -0
  88. package/src/exocom/envelope.ts +180 -0
  89. package/src/exocom/gate.ts +6 -0
  90. package/src/exocom/guards.ts +45 -0
  91. package/src/exocom/inbound.ts +141 -0
  92. package/src/exocom/install.ts +970 -0
  93. package/src/exocom/ledger.ts +603 -0
  94. package/src/exocom/limits.ts +20 -0
  95. package/src/exocom/paths.ts +35 -0
  96. package/src/exocom/plane.ts +1116 -0
  97. package/src/exocom/registry.ts +307 -0
  98. package/src/exocom/scope.ts +42 -0
  99. package/src/exocom/status.ts +86 -0
  100. package/src/exocom/untrusted.ts +40 -0
  101. package/src/exocom/wait.ts +23 -0
  102. package/src/exocom/write-guard.ts +89 -0
  103. package/src/extension/broker-host.ts +181 -0
  104. package/src/extension/clock.ts +14 -0
  105. package/src/extension/engine.ts +321 -0
  106. package/src/extension/hooks.ts +796 -0
  107. package/src/extension/identity.ts +244 -0
  108. package/src/extension/shared.ts +175 -0
  109. package/src/extension.ts +2221 -0
  110. package/src/loader.ts +218 -0
  111. package/src/monitor/process.ts +116 -0
  112. package/src/monitor/session.ts +61 -0
  113. package/src/orchestration/flow-journal.ts +86 -0
  114. package/src/orchestration/flow-run.ts +301 -0
  115. package/src/orchestration/flow.ts +125 -0
  116. package/src/orchestration/judge.ts +78 -0
  117. package/src/orchestration/model-retry.ts +85 -0
  118. package/src/orchestration/parallel.ts +107 -0
  119. package/src/orchestration/params.ts +23 -0
  120. package/src/orchestration/reducers.ts +123 -0
  121. package/src/orchestration/render.ts +127 -0
  122. package/src/orchestration/roster.ts +133 -0
  123. package/src/orchestration/sdk.ts +326 -0
  124. package/src/orchestration/strategies/compete.ts +182 -0
  125. package/src/orchestration/strategies/council-rounds.ts +124 -0
  126. package/src/orchestration/strategies/critic-loop.ts +168 -0
  127. package/src/orchestration/strategies/debate.ts +126 -0
  128. package/src/orchestration/strategies/fanout.ts +20 -0
  129. package/src/orchestration/strategies/judge.ts +117 -0
  130. package/src/orchestration/strategies/magi.ts +239 -0
  131. package/src/orchestration/strategies/map.ts +327 -0
  132. package/src/orchestration/strategies/pair.ts +72 -0
  133. package/src/orchestration/strategies/pipeline.ts +91 -0
  134. package/src/orchestration/strategies/synthesize.ts +99 -0
  135. package/src/orchestration/strategy.ts +49 -0
  136. package/src/orchestration/types.ts +31 -0
  137. package/src/orchestration/voting.ts +126 -0
  138. package/src/persona/config-store.ts +215 -0
  139. package/src/persona/controller.ts +173 -0
  140. package/src/persona/gating.ts +82 -0
  141. package/src/persona/orchestrate.ts +83 -0
  142. package/src/persona/persona.ts +294 -0
  143. package/src/persona/spine.ts +221 -0
  144. package/src/persona/state.ts +28 -0
  145. package/src/telemetry/contract.ts +244 -0
  146. package/src/telemetry/paths.ts +14 -0
  147. package/src/telemetry/producer.ts +390 -0
  148. package/src/tools/council.ts +200 -0
  149. package/src/tools/delegate-tool.ts +637 -0
  150. package/src/tools/delegate.ts +661 -0
  151. package/src/tools/exocom-work.ts +328 -0
  152. package/src/tools/exocom.ts +476 -0
  153. package/src/tools/flow.ts +117 -0
  154. package/src/tools/intercom-tool.ts +292 -0
  155. package/src/tools/intercom.ts +181 -0
  156. package/src/tools/models.ts +39 -0
  157. package/src/tools/monitor.ts +66 -0
  158. package/src/tools/timer.ts +105 -0
  159. package/src/ui/agent-overlay.ts +526 -0
  160. package/src/ui/agent-tree.ts +307 -0
  161. package/src/ui/live-clock.ts +42 -0
  162. package/src/ui/model-picker.ts +160 -0
  163. package/src/ui/presentation.ts +170 -0
  164. package/src/ui/usage.ts +133 -0
  165. package/teams.yaml +27 -0
@@ -0,0 +1,105 @@
1
+ /** Session timer tool — arm a wall-clock wake. */
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { Type } from "typebox";
4
+ import { failureDetails } from "../extension/shared.ts";
5
+ import { formatRemaining, type TimerScheduler } from "../core/timer.ts";
6
+ import { parseInstant } from "../core/time.ts";
7
+ import { readClockSnapshot } from "../extension/clock.ts";
8
+
9
+ export interface TimerToolDeps {
10
+ get lastCtx(): ExtensionContext | undefined;
11
+ set lastCtx(value: ExtensionContext | undefined);
12
+ timerScheduler: TimerScheduler;
13
+ }
14
+
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).
21
+ const TimerToolParams = Type.Object({
22
+ action: Type.Union([Type.Literal("arm"), Type.Literal("cancel"), Type.Literal("list"), Type.Literal("now")], {
23
+ description: "arm = schedule a wakeup · cancel = drop one by id · list = show armed alarms · now = read the fresh clock",
24
+ }),
25
+ message: Type.Optional(
26
+ Type.String({ description: "arm: the non-empty follow-up injected into the session when the timer fires (what to do on wake, e.g. 'check the status and summarize changes'). After trimming surrounding whitespace, maximum 4000 characters. Required for arm." }),
27
+ ),
28
+ 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
+ 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')." })),
31
+ id: Type.Optional(Type.String({ description: "cancel: the timer id to cancel (e.g. 'timer-1')." })),
32
+ });
33
+ pi.registerTool({
34
+ name: "timer",
35
+ label: "Timer",
36
+ 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;",
42
+ "alarms are in-memory and cleared on shutdown or reload. `now` returns a fresh UTC time plus",
43
+ "the local timezone and numeric offset.",
44
+ ].join(" "),
45
+ parameters: TimerToolParams,
46
+ async execute(_id, params, _signal, _onUpdate, ctx) {
47
+ d.lastCtx = ctx;
48
+ if (params.action === "now") {
49
+ const now = d.timerScheduler.now();
50
+ const reading = readClockSnapshot(now);
51
+ if (!reading) {
52
+ return { content: [{ type: "text", text: "timer now is unavailable because the scheduler clock is invalid." }], details: failureDetails({ action: "now", ok: false }), isError: true };
53
+ }
54
+ return { content: [{ type: "text", text: reading }], details: { action: "now", ok: true }, isError: false };
55
+ }
56
+ if (params.action === "list") {
57
+ const timers = d.timerScheduler.list();
58
+ const text =
59
+ timers.length === 0
60
+ ? "(no armed timers)"
61
+ : `Armed timers:\n${timers
62
+ .map((t) => `• ${t.id} (${t.label}) — fires in ${formatRemaining(t.remainingMs)} [${new Date(t.fireAtEpochMs).toISOString()}]: ${t.message}`)
63
+ .join("\n")}`;
64
+ return { content: [{ type: "text", text }], details: { action: "list", count: timers.length, ok: true }, isError: false };
65
+ }
66
+ if (params.action === "cancel") {
67
+ if (!params.id) {
68
+ return { content: [{ type: "text", text: "timer cancel needs { id } (see `list`)." }], details: failureDetails({ action: "cancel", ok: false }), isError: true };
69
+ }
70
+ const cancelled = d.timerScheduler.cancel(params.id);
71
+ return cancelled
72
+ ? { content: [{ type: "text", text: `Cancelled ${params.id}.` }], details: { action: "cancel", ok: true }, isError: false }
73
+ : { content: [{ type: "text", text: `No armed timer with id "${params.id}" (it may have already fired or been cancelled).` }], details: failureDetails({ action: "cancel", ok: false }), isError: true };
74
+ }
75
+ // action === "arm"
76
+ const message = params.message?.trim() ?? "";
77
+ if (!message) {
78
+ return { content: [{ type: "text", text: "timer arm needs { message } — the follow-up text injected on wake." }], details: failureDetails({ action: "arm", ok: false }), isError: true };
79
+ }
80
+ if (params.delaySeconds === undefined && params.atIso === undefined) {
81
+ return { content: [{ type: "text", text: "timer arm needs { delaySeconds } or { atIso }, not neither." }], details: failureDetails({ action: "arm", ok: false }), isError: true };
82
+ }
83
+ if (params.delaySeconds !== undefined && params.atIso !== undefined) {
84
+ return { content: [{ type: "text", text: "timer arm needs { delaySeconds } or { atIso }, not both." }], details: failureDetails({ action: "arm", ok: false }), isError: true };
85
+ }
86
+ const arm: { message: string; label?: string; delayMs?: number; atEpochMs?: number } = { message };
87
+ if (params.label !== undefined) arm.label = params.label;
88
+ if (params.atIso !== undefined) {
89
+ const at = parseInstant(params.atIso);
90
+ if (!Number.isFinite(at)) {
91
+ return { content: [{ type: "text", text: `timer atIso "${params.atIso}" is not a valid unambiguous ISO-8601 time; date-times must include Z or a numeric timezone offset.` }], details: failureDetails({ action: "arm", ok: false }), isError: true };
92
+ }
93
+ arm.atEpochMs = at;
94
+ }
95
+ if (params.delaySeconds !== undefined) arm.delayMs = Math.round(params.delaySeconds * 1000);
96
+ const r = d.timerScheduler.arm(arm);
97
+ if (!r.ok || !r.entry) {
98
+ return { content: [{ type: "text", text: r.error ?? "timer arm failed." }], details: failureDetails({ action: "arm", ok: false }), isError: true };
99
+ }
100
+ 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.`;
102
+ return { content: [{ type: "text", text }], details: { action: "arm", id: e.id, fireAtEpochMs: e.fireAtEpochMs, ok: true }, isError: false };
103
+ },
104
+ });
105
+ }
@@ -0,0 +1,526 @@
1
+ /**
2
+ * A focusable overlay over the unified agent tree, opened with ctx.ui.custom
3
+ * (overlay: true). ↑↓ navigate the *agents* (leaf rows; a parent like "delegate"
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
7
+ * re-renders live as agents stream.
8
+ *
9
+ * The pure parts (flatten/order, glyphs, scroll window) live in agent-tree.ts /
10
+ * model-picker.ts and are unit-tested; this file is the thin pi-tui glue
11
+ * (focus + keyboard + framing).
12
+ */
13
+
14
+ import type { Theme } from "@earendil-works/pi-coding-agent";
15
+ import {
16
+ Container,
17
+ getKeybindings,
18
+ Spacer,
19
+ Text,
20
+ truncateToWidth,
21
+ type TUI,
22
+ visibleWidth,
23
+ wrapTextWithAnsi,
24
+ } from "@earendil-works/pi-tui";
25
+
26
+ import { type AgentNode, type AgentTree, type FlatRow, flattenTree, GLYPH, runningAnnotation } from "./agent-tree.ts";
27
+ import { LiveClock } from "./live-clock.ts";
28
+ import { visibleWindow } from "./model-picker.ts";
29
+ import { compactInlineText, OPEN_SEQUENCE_TAIL, sanitizeTerminalText } from "./presentation.ts";
30
+
31
+ function safeInline(value: string, maxChars = 96): string {
32
+ return compactInlineText(value, { maxChars });
33
+ }
34
+
35
+ /** Rows the terminal has (fallback for exotic hosts). */
36
+ function termRows(): number {
37
+ return process.stdout.rows && process.stdout.rows > 0 ? process.stdout.rows : 24;
38
+ }
39
+
40
+ /** Is this keystroke a single printable character (a steer-input keystroke)? */
41
+ function isPrintable(key: string): boolean {
42
+ if (key.length !== 1) return false;
43
+ const c = key.charCodeAt(0);
44
+ return c >= 0x20 && c !== 0x7f;
45
+ }
46
+
47
+ /** Pure inputs for one list row: everything {@link composeAgentRow} needs and nothing it can
48
+ * reach into the overlay for. Keeping this a plain function (not a method) means the frame-width
49
+ * invariant is testable directly, without constructing a whole overlay. */
50
+ export interface AgentRowInput {
51
+ node: AgentNode;
52
+ depth: number;
53
+ selected: boolean;
54
+ inner: number;
55
+ now: number;
56
+ stallMs: number;
57
+ theme: Theme;
58
+ }
59
+
60
+ /**
61
+ * One list row, guaranteed to fit within `inner` columns. The clock suffix (elapsed/stall badge)
62
+ * is reserved and appended *after* the label+detail body is clamped, so a tight budget always
63
+ * eats into the label/detail — truncating a stall badge would hide the one signal it exists to
64
+ * show. `safeInline` has its own internal floor (never truncates below ~16 columns), so the
65
+ * computed label/detail budgets are best-effort; the final clamp on the body is what makes the
66
+ * overall width hold regardless.
67
+ */
68
+ export function composeAgentRow(input: AgentRowInput): string {
69
+ const { node, depth, selected, inner, now, stallMs, theme: t } = input;
70
+ const indent = " ".repeat(depth);
71
+ const clock = runningAnnotation(node, now, stallMs);
72
+ // Reserve the clock's exact width plus its widest separator (" · ") up front — this portion
73
+ // of the row is never truncated.
74
+ const clockCols = clock ? visibleWidth(clock) + visibleWidth(" · ") : 0;
75
+ const bodyMax = Math.max(0, inner - clockCols);
76
+
77
+ const prefixCols = 2 + visibleWidth(indent); // "▸ " or " ", then the indent
78
+ const avail = Math.max(0, bodyMax - prefixCols - 2); // 2 = glyph + space
79
+ const labelMax = Math.max(8, Math.floor(avail * 0.62));
80
+ const label = safeInline(node.label, labelMax) || "agent";
81
+ const detailMax = node.detail ? Math.max(0, avail - labelMax - 2) : 0;
82
+ const detail = detailMax > 0 ? safeInline(node.detail ?? "", detailMax) : "";
83
+ const detailText = detail ? t.fg("dim", ` ${detail}`) : "";
84
+
85
+ const labelLine = `${indent}${GLYPH[node.status]} ${label}`;
86
+ const prefixed = selected ? t.fg("accent", `▸ ${labelLine}`) : ` ${labelLine}`;
87
+ let body = `${prefixed}${detailText}`;
88
+ if (visibleWidth(body) > bodyMax) body = truncateToWidth(body, bodyMax, "");
89
+
90
+ const clockText = clock ? t.fg("dim", `${detail ? " · " : " "}${clock}`) : "";
91
+ return `${body}${clockText}`;
92
+ }
93
+
94
+ /** Trailing callbacks and clock config for {@link AgentOverlay}, grouped as one object so a
95
+ * new capability (stall threshold, injected clock) does not grow the constructor's arity. */
96
+ export interface AgentOverlayActions {
97
+ onStop?: (nodeId: string) => boolean;
98
+ onSteer?: (nodeId: string, text: string) => boolean;
99
+ canSteer?: (nodeId: string) => boolean;
100
+ canStop?: (nodeId: string) => boolean;
101
+ /** Stall threshold for the ⚠ badge; 0/omitted ⇒ no badge. */
102
+ stallMs?: number;
103
+ /** Clock; tests inject a fixed one. */
104
+ now?: () => number;
105
+ }
106
+
107
+ export class AgentOverlay extends Container {
108
+ private tree: AgentTree;
109
+ private tui: TUI;
110
+ private theme: Theme;
111
+ private done: () => void;
112
+ private readonly actions: AgentOverlayActions;
113
+ private readonly clock: LiveClock;
114
+ private readonly now: () => number;
115
+ private notice: string | undefined;
116
+ private unsubscribe: () => void;
117
+ private selectedId: string | undefined; // the selected agent's *id*, never its row index
118
+ private aimLost = false; // the aimed-at agent settled: swallow the next directed keystroke
119
+ private listScroll = 0; // list rows scrolled off the top (keeps the selection visible)
120
+ private detailId: string | undefined;
121
+ private detailScroll = 0; // output lines scrolled up from the bottom (0 = latest)
122
+ private steering = false; // typing a steer message into the drilled-in agent
123
+ private steerBuffer = "";
124
+ private lastWidth = 100;
125
+ /** State changed since the last host paint. Rebuilding here (instead of in the tree listener)
126
+ * lets Pi-TUI's own 16ms frame coalescer absorb a burst of streamed progress snapshots. */
127
+ private dirty = false;
128
+ /** A host frame has already been requested for the current invalidation burst. */
129
+ private frameRequested = false;
130
+ private displayCache: { source: string; width: number; rows: string[] } | undefined;
131
+
132
+ constructor(tree: AgentTree, tui: TUI, theme: Theme, done: () => void, actions: AgentOverlayActions = {}) {
133
+ super();
134
+ this.tree = tree;
135
+ this.tui = tui;
136
+ this.theme = theme;
137
+ this.done = done;
138
+ this.actions = actions;
139
+ this.now = actions.now ?? Date.now;
140
+ this.clock = new LiveClock({ intervalMs: 1_000, isLive: () => tree.hasRunning(), onTick: () => this.refresh() });
141
+ this.unsubscribe = tree.onChange(() => {
142
+ // A tree change can arrive while a compose is open. If its live handle disappeared,
143
+ // retire the compose state now; the next key must not be interpreted with stale text
144
+ // while the lazy rebuild waits for the host's paint.
145
+ if (this.steering && this.detailId && !(this.actions.canSteer?.(this.detailId) ?? false)) {
146
+ this.steering = false;
147
+ this.steerBuffer = "";
148
+ }
149
+ this.scheduleRefresh();
150
+ this.clock.start(); // new work may have appeared while the clock was stopped
151
+ });
152
+ this.rebuild();
153
+ this.clock.start();
154
+ }
155
+
156
+ private refresh(): void {
157
+ this.scheduleRefresh();
158
+ }
159
+
160
+ /** Mark the component stale and ask the host for one paint. The actual rebuild happens from
161
+ * render(), so several tree changes before that paint do not repeatedly clear/recompose all
162
+ * children (or re-wrap a streamed detail buffer). */
163
+ private scheduleRefresh(): void {
164
+ this.dirty = true;
165
+ if (this.frameRequested) return;
166
+ this.frameRequested = true;
167
+ this.tui.requestRender();
168
+ }
169
+
170
+ /** Frame the panel in a box so it stands out from the chat background. */
171
+ override render(width: number): string[] {
172
+ this.frameRequested = false;
173
+ if (this.lastWidth !== width) this.dirty = true;
174
+ this.lastWidth = width;
175
+ if (this.dirty) {
176
+ this.rebuild();
177
+ this.dirty = false;
178
+ }
179
+ const inner = this.inner();
180
+ const t = this.theme;
181
+ const b = (s: string): string => t.fg("accent", s);
182
+ const framed = super.render(inner).map((line) => {
183
+ const pad = " ".repeat(Math.max(0, inner - visibleWidth(line)));
184
+ return `${b("│")} ${line}${pad} ${b("│")}`;
185
+ });
186
+ return [b(`┌${"─".repeat(inner + 2)}┐`), ...framed, b(`└${"─".repeat(inner + 2)}┘`)];
187
+ }
188
+
189
+ private inner(): number {
190
+ // Use the whole width the host gives us (the overlay itself is sized ~90% of the
191
+ // terminal) — a hard 100-column cap made wide terminals waste most of the screen.
192
+ return Math.max(24, this.lastWidth - 4);
193
+ }
194
+
195
+ /** Detail-output rows that fit: terminal height minus the overlay chrome. */
196
+ private detailViewport(): number {
197
+ return Math.max(8, termRows() - 12);
198
+ }
199
+
200
+ /** List rows that fit: terminal height minus the overlay chrome. */
201
+ private listViewport(): number {
202
+ return Math.max(6, termRows() - 10);
203
+ }
204
+
205
+ private leafRows(): FlatRow[] {
206
+ const snap = this.tree.snapshot();
207
+ const parents = new Set(snap.map((n) => n.parentId).filter((p): p is string => p !== undefined));
208
+ return flattenTree(snap).filter((r) => !parents.has(r.node.id));
209
+ }
210
+
211
+ /**
212
+ * The selected leaf, re-anchored by id on every use. The list is recomputed from the
213
+ * live tree, and agents are pruned from it as they settle: an index would slide `x`
214
+ * (stop) and `s` (steer) onto a *different* agent whenever an earlier sibling went
215
+ * away. When the aimed-at agent is the one that vanishes, fall back to the head of
216
+ * the list, where the ▸ marker visibly shows the user their target changed — and
217
+ * record that the aim was lost, for the directed keys (see `aimedLeaf`).
218
+ */
219
+ private selectedLeaf(leaves: FlatRow[]): FlatRow | undefined {
220
+ const anchored = this.selectedId !== undefined ? leaves.find((r) => r.node.id === this.selectedId) : undefined;
221
+ const leaf = anchored ?? leaves[0];
222
+ if (this.selectedId !== undefined && anchored === undefined && leaf !== undefined) this.aimLost = true;
223
+ this.selectedId = leaf?.node.id;
224
+ return leaf;
225
+ }
226
+
227
+ /**
228
+ * The leaf a *directed* key (`x` stop, `s` steer) may act on. A keystroke is always in flight
229
+ * while runs settle underneath it, and stopping an agent cannot be undone — so the first such
230
+ * key after the aimed-at agent vanished only re-anchors and re-renders. The user then acts on
231
+ * the target the ▸ marker actually shows, rather than on whichever agent inherited the aim.
232
+ */
233
+ private aimedLeaf(leaves: FlatRow[]): FlatRow | undefined {
234
+ const leaf = this.selectedLeaf(leaves);
235
+ if (!this.aimLost) return leaf;
236
+ this.aimLost = false;
237
+ this.refresh();
238
+ return undefined;
239
+ }
240
+
241
+ /** Move the selection by whole rows, keeping it anchored on an id. */
242
+ private moveSelection(leaves: FlatRow[], delta: number): void {
243
+ const current = this.selectedLeaf(leaves);
244
+ if (!current) return;
245
+ const index = leaves.findIndex((r) => r.node.id === current.node.id);
246
+ this.selectedId = leaves[Math.min(leaves.length - 1, Math.max(0, index + delta))]?.node.id;
247
+ this.aimLost = false; // moving the marker by hand *is* taking aim
248
+ }
249
+
250
+ private rebuild(): void {
251
+ this.clear();
252
+ if (this.detailId) this.renderDetail();
253
+ else this.renderList();
254
+ this.dirty = false;
255
+ }
256
+
257
+ private renderList(): void {
258
+ const t = this.theme;
259
+ const rows = flattenTree(this.tree.snapshot());
260
+ const selected = this.selectedLeaf(this.leafRows())?.node;
261
+ this.addChild(new Text(t.fg("accent", t.bold("Agents")), 1, 0));
262
+ this.addChild(new Spacer(1));
263
+ if (rows.length === 0) {
264
+ this.addChild(new Text(t.fg("dim", "(no agents running)"), 1, 0));
265
+ } else {
266
+ // Scroll window over the rows (a big fan-out must not overflow the screen);
267
+ // the window follows the selection, ▲/▼ markers show what's off-screen.
268
+ const vp = this.listViewport();
269
+ const selIdx = Math.max(0, rows.findIndex((r) => r.node.id === selected?.id));
270
+ this.listScroll = visibleWindow(selIdx, vp, rows.length, this.listScroll);
271
+ const end = Math.min(rows.length, this.listScroll + vp);
272
+ if (this.listScroll > 0) this.addChild(new Text(t.fg("dim", `▲ ${this.listScroll} above`), 1, 0));
273
+ for (const row of rows.slice(this.listScroll, end)) {
274
+ const composed = composeAgentRow({
275
+ node: row.node,
276
+ depth: row.depth,
277
+ selected: row.node.id === selected?.id,
278
+ inner: this.inner(),
279
+ now: this.now(),
280
+ stallMs: this.actions.stallMs ?? 0,
281
+ theme: t,
282
+ });
283
+ this.addChild(new Text(composed, 1, 0));
284
+ }
285
+ if (end < rows.length) this.addChild(new Text(t.fg("dim", `▼ ${rows.length - end} below`), 1, 0));
286
+ }
287
+ this.addChild(new Spacer(1));
288
+ const stoppable = selected !== undefined && this.canStopNode(selected);
289
+ const steerable = selected !== undefined && selected.status === "running" && (this.actions.canSteer?.(selected.id) ?? false);
290
+ this.addChild(new Text(t.fg("dim", `↑↓ navigate ⏎ open${this.hintLine("list", stoppable, steerable)} esc close`), 1, 0));
291
+ this.renderNotice();
292
+ }
293
+
294
+ /**
295
+ * Wrap long lines instead of truncating — the user must be able to read the full text.
296
+ * The viewport + scroll then operate over the wrapped display lines, so a long message
297
+ * spans several rows rather than being cut off with an ellipsis.
298
+ */
299
+ private wrapRows(text: string, width: number): string[] {
300
+ return text.split("\n").flatMap((line) => {
301
+ const wrapped = wrapTextWithAnsi(line, width);
302
+ return wrapped.length > 0 ? wrapped : [""]; // keep blank lines (paragraph spacing)
303
+ });
304
+ }
305
+
306
+ /** Rows for a run of *completed* lines: the empty string after the final \n is not a row. */
307
+ private settledRows(chunk: string, width: number): string[] {
308
+ if (!chunk) return [];
309
+ const lines = sanitizeTerminalText(chunk).split("\n");
310
+ if (lines.at(-1) === "") lines.pop();
311
+ return lines.flatMap((line) => this.wrapRows(line, width));
312
+ }
313
+
314
+ /**
315
+ * Sanitized, wrapped display rows for the drilled-in buffer. A progress snapshot carries
316
+ * the whole accumulated output, and one fires per streamed chunk, so redoing the entire
317
+ * buffer every tick is quadratic in the report size — slow enough at a few hundred KB
318
+ * that `x` (stop) and the scroll keys queue behind the rebuild. Completed lines never
319
+ * change once written, so only the unfinished tail is redone.
320
+ *
321
+ * The seam has to fall *outside* a control sequence, or the sequence would be erased in
322
+ * two halves and leak its payload. Bailing on any ESC is not enough of a rule: agents
323
+ * colour their output, so a single `ESC[32m` would put every tick back on the whole
324
+ * buffer. Instead the seam retreats to the line an unterminated sequence opened on, and
325
+ * only a chunk that is still open after that gives up on reuse.
326
+ */
327
+ private displayRows(raw: string, width: number): string[] {
328
+ const cache = this.displayCache;
329
+ let settled = raw.slice(0, raw.lastIndexOf("\n") + 1);
330
+ const reusable = cache && cache.width === width && settled.startsWith(cache.source) ? cache : undefined;
331
+ let added = reusable ? settled.slice(reusable.source.length) : settled;
332
+ if (OPEN_SEQUENCE_TAIL.test(added)) {
333
+ const opened = added.lastIndexOf("\u001B");
334
+ added = added.slice(0, added.lastIndexOf("\n", opened) + 1);
335
+ settled = (reusable?.source ?? "") + added;
336
+ }
337
+ if (OPEN_SEQUENCE_TAIL.test(added)) {
338
+ // An earlier sequence spans the whole chunk: the only sound seam is no seam.
339
+ this.displayCache = undefined;
340
+ const whole = raw.slice(0, raw.lastIndexOf("\n") + 1);
341
+ return this.settledRows(whole, width).concat(this.wrapRows(sanitizeTerminalText(raw.slice(whole.length)), width));
342
+ }
343
+ const rows = reusable ? reusable.rows : [];
344
+ for (const row of this.settledRows(added, width)) rows.push(row);
345
+ this.displayCache = { source: settled, width, rows };
346
+ return rows.concat(this.wrapRows(sanitizeTerminalText(raw.slice(settled.length)), width));
347
+ }
348
+
349
+ private renderDetail(): void {
350
+ const t = this.theme;
351
+ const node = this.tree.snapshot().find((n) => n.id === this.detailId);
352
+ if (!node) {
353
+ this.detailId = undefined;
354
+ // The compose belonged to *that* agent — drop it, or the next drill-in would
355
+ // re-open it pre-filled and send it to a different sub-agent. Re-rendering the
356
+ // list also loses the anchor (the selection *is* the drilled-in agent), so an
357
+ // `x` still in flight for it is refused instead of landing on the list beneath.
358
+ this.steering = false;
359
+ this.steerBuffer = "";
360
+ this.renderList();
361
+ return;
362
+ }
363
+ const live = node.status === "running";
364
+ this.addChild(
365
+ new Text(`${GLYPH[node.status]} ${t.fg("accent", t.bold(safeInline(node.label) || "agent"))}${live ? t.fg("success", " ● live") : ""}`, 1, 0),
366
+ );
367
+ if (node.detail) this.addChild(new Text(t.fg("dim", safeInline(node.detail)), 1, 0));
368
+ this.addChild(new Spacer(1));
369
+
370
+ const raw = node.output?.trim()
371
+ ? node.output
372
+ : live
373
+ ? "(working… the report appears here when the agent writes text — see its current tool above)"
374
+ : "(no output)";
375
+ const w = this.inner() - 1;
376
+ const all = this.displayRows(raw, w);
377
+ const viewport = this.detailViewport();
378
+ const maxScroll = Math.max(0, all.length - viewport);
379
+ if (this.detailScroll > maxScroll) this.detailScroll = maxScroll;
380
+ const end = all.length - this.detailScroll;
381
+ const start = Math.max(0, end - viewport);
382
+ if (start > 0) this.addChild(new Text(t.fg("dim", `▲ ${start} earlier`), 1, 0));
383
+ for (const line of all.slice(start, end)) this.addChild(new Text(t.fg("toolOutput", line), 1, 0));
384
+ if (this.detailScroll > 0) this.addChild(new Text(t.fg("dim", `▼ ${this.detailScroll} newer`), 1, 0));
385
+
386
+ const steerable = live && (this.actions.canSteer?.(node.id) ?? false);
387
+ const stoppable = this.canStopNode(node);
388
+ if (this.steering && !steerable) this.steering = false; // agent finished mid-compose
389
+ this.addChild(new Spacer(1));
390
+ if (this.steering) {
391
+ // Wrap the input so a long steer message stays fully visible while typing.
392
+ const composed = `${t.fg("accent", "steer ▸ ")}${this.steerBuffer}${t.fg("dim", "▌")}`;
393
+ for (const line of wrapTextWithAnsi(composed, w)) this.addChild(new Text(line, 1, 0));
394
+ this.addChild(new Text(t.fg("dim", "⏎ send · esc cancel"), 1, 0));
395
+ } else {
396
+ this.addChild(new Text(t.fg("dim", `esc back · ↑↓ scroll${this.hintLine("detail", stoppable, steerable)}`), 1, 0));
397
+ this.renderNotice();
398
+ if (live && !steerable) {
399
+ this.addChild(new Text(t.fg("dim", "(steer unavailable: no live handle yet, or this engine/broker does not expose one)"), 1, 0));
400
+ }
401
+ }
402
+ }
403
+
404
+ handleInput(keyData: string): void {
405
+ const hadNotice = this.notice !== undefined;
406
+ this.notice = undefined; // any key clears a previous refusal notice
407
+ const kb = getKeybindings();
408
+ if (this.detailId) {
409
+ // Steer mode: type a message into the running agent. Capture all printable keys
410
+ // (so j/k/x type normally); ⏎ sends, esc cancels.
411
+ if (this.steering) {
412
+ if (kb.matches(keyData, "tui.select.cancel")) {
413
+ this.steering = false;
414
+ this.steerBuffer = "";
415
+ this.refresh();
416
+ } else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n" || keyData === "\r") {
417
+ const text = this.steerBuffer.trim();
418
+ this.steering = false;
419
+ this.steerBuffer = "";
420
+ if (text) this.actions.onSteer?.(this.detailId, text);
421
+ this.refresh();
422
+ } else if (keyData === "\x7f" || keyData === "\b") {
423
+ this.steerBuffer = this.steerBuffer.slice(0, -1);
424
+ this.refresh();
425
+ } else if (isPrintable(keyData)) {
426
+ this.steerBuffer += keyData;
427
+ this.refresh();
428
+ } else if (hadNotice) this.refresh();
429
+ return;
430
+ }
431
+ if (keyData === "s" && (this.actions.canSteer?.(this.detailId) ?? false)) {
432
+ this.steering = true;
433
+ this.steerBuffer = "";
434
+ this.refresh();
435
+ } else if (keyData === "x") this.tryStop(this.detailId);
436
+ else if (kb.matches(keyData, "tui.select.up") || keyData === "k") {
437
+ this.detailScroll += 1;
438
+ this.refresh();
439
+ } else if (kb.matches(keyData, "tui.select.down") || keyData === "j") {
440
+ this.detailScroll = Math.max(0, this.detailScroll - 1);
441
+ this.refresh();
442
+ } else if (kb.matches(keyData, "tui.select.cancel")) {
443
+ this.detailId = undefined;
444
+ this.refresh();
445
+ } else if (hadNotice) this.refresh(); // unhandled key: still repaint so a cleared notice disappears
446
+ return;
447
+ }
448
+ const leaves = this.leafRows();
449
+ if (kb.matches(keyData, "tui.select.up") || keyData === "k") {
450
+ this.moveSelection(leaves, -1);
451
+ this.refresh();
452
+ } else if (kb.matches(keyData, "tui.select.down") || keyData === "j") {
453
+ this.moveSelection(leaves, 1);
454
+ this.refresh();
455
+ } else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") {
456
+ const leaf = this.selectedLeaf(leaves);
457
+ if (leaf) {
458
+ this.detailId = leaf.node.id;
459
+ this.detailScroll = 0; // open at the latest output (auto-scroll to bottom)
460
+ this.aimLost = false; // reading a row is harmless, and it re-takes aim on it
461
+ this.refresh();
462
+ }
463
+ } else if (keyData === "s") {
464
+ // Steer straight from the list: drill into the selected agent with the
465
+ // compose line already open (same gate as the detail view's `s`).
466
+ const leaf = this.aimedLeaf(leaves);
467
+ if (leaf && leaf.node.status === "running" && (this.actions.canSteer?.(leaf.node.id) ?? false)) {
468
+ this.detailId = leaf.node.id;
469
+ this.detailScroll = 0;
470
+ this.steering = true;
471
+ this.steerBuffer = "";
472
+ this.refresh();
473
+ }
474
+ } else if (keyData === "x") {
475
+ const leaf = this.aimedLeaf(leaves);
476
+ if (leaf) this.tryStop(leaf.node.id);
477
+ } else if (kb.matches(keyData, "tui.select.cancel")) {
478
+ this.close();
479
+ } else if (hadNotice) this.refresh(); // unhandled key: still repaint so a cleared notice disappears
480
+ }
481
+
482
+ /** Whether an agent can be stopped right now: it must be running, and the caller's `canStop`
483
+ * gate (defaulting to "yes" when omitted) must agree. Shared by both hints and `tryStop`, so
484
+ * the hint that advertises "x stop" and the handler that actually gates it never diverge. */
485
+ private canStopNode(node: Pick<AgentNode, "id" | "status">): boolean {
486
+ return node.status === "running" && (this.actions.canStop?.(node.id) ?? true);
487
+ }
488
+
489
+ /** The trailing "x stop"/"s steer" hint fragment for an action line — the list's three-space
490
+ * style or the detail view's " · " style, the two views' only difference here. */
491
+ private hintLine(kind: "list" | "detail", stoppable: boolean, steerable: boolean): string {
492
+ const sep = kind === "list" ? " " : " · ";
493
+ return `${stoppable ? `${sep}x stop` : ""}${steerable ? `${sep}s steer` : ""}`;
494
+ }
495
+
496
+ /** The one-line refusal notice, shown identically under the list and the detail view. */
497
+ private renderNotice(): void {
498
+ if (this.notice) this.addChild(new Text(this.theme.fg("dim", this.notice), 1, 0));
499
+ }
500
+
501
+ /** Stop (abort) one agent by id. A refusal — the node is gone, not stoppable, or the caller
502
+ * declines — surfaces as a one-line notice rather than doing nothing: the user pressed a key
503
+ * and deserves feedback, in the list view and the detail view alike. */
504
+ private tryStop(nodeId: string): void {
505
+ const node = this.tree.snapshot().find((n) => n.id === nodeId);
506
+ if (!node || !this.canStopNode(node) || !this.actions.onStop?.(nodeId)) {
507
+ this.notice = `nothing to stop for ${node?.label ?? nodeId}`;
508
+ }
509
+ this.refresh();
510
+ }
511
+
512
+ private close(): void {
513
+ this.dispose();
514
+ this.done();
515
+ }
516
+
517
+ dispose(): void {
518
+ this.clock.stop();
519
+ this.unsubscribe();
520
+ }
521
+
522
+ /** Read-only for tests: whether the live-clock tick is still armed. */
523
+ get clockRunning(): boolean {
524
+ return this.clock.running;
525
+ }
526
+ }