@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,307 @@
1
+ import { compactTokens } from "../core/format.ts";
2
+ import { formatDuration } from "../core/time.ts";
3
+ import { compactInlineText } from "./presentation.ts";
4
+
5
+ /**
6
+ * The unified agent tree — a single live model of *every* agent in flight,
7
+ * regardless of how it was spawned: strategy cores (magi/fanout/critic), the
8
+ * `delegate` tool's sub-agents, and dynamic specialists. The UI layer renders
9
+ * one sticky tree above the input from this model, and (later) lets the user
10
+ * drill into a node. Sources only push status/detail; rendering is pure.
11
+ */
12
+
13
+ export type AgentNodeStatus = "running" | "done" | "failed" | "stopped";
14
+ export type AgentNodeKind = "subagent" | "delegate" | "council" | "flow" | "phase";
15
+
16
+ export interface AgentNode {
17
+ id: string;
18
+ label: string;
19
+ parentId: string | undefined;
20
+ status: AgentNodeStatus;
21
+ /** Semantic role in the orchestration graph; omitted by legacy callers. */
22
+ kind?: AgentNodeKind;
23
+ /** Configured agent definition (operator/reviewer/…), if known. */
24
+ agent?: string;
25
+ model?: string;
26
+ /** A short trailing annotation (usage, current activity), shown after the label. */
27
+ detail: string | undefined;
28
+ /** The agent's output buffer, shown when the user drills into the node. */
29
+ output?: string;
30
+ /** Clock data (ms since epoch). Set by the tree on add; `lastAdvanceAt` moves on real progress.
31
+ * Excluded from change notifications — the live clock paints them. */
32
+ startedAt?: number;
33
+ lastAdvanceAt?: number;
34
+ }
35
+
36
+ export type AgentTreeChange =
37
+ | { type: "added"; node: AgentNode }
38
+ | { type: "updated"; node: AgentNode }
39
+ | { type: "removed"; nodes: AgentNode[] }
40
+ | { type: "cleared"; nodes: AgentNode[] };
41
+
42
+ export interface FlatRow {
43
+ node: AgentNode;
44
+ depth: number;
45
+ }
46
+
47
+ /** Flatten the tree into display order (root, then its children…) with depth. Pure. */
48
+ export function flattenTree(nodes: AgentNode[]): FlatRow[] {
49
+ const rows: FlatRow[] = [];
50
+ const walk = (parentId: string | undefined, depth: number): void => {
51
+ for (const node of nodes.filter((n) => n.parentId === parentId)) {
52
+ rows.push({ node, depth });
53
+ walk(node.id, depth + 1);
54
+ }
55
+ };
56
+ walk(undefined, 0);
57
+ return rows;
58
+ }
59
+
60
+ /** Status → glyph, shared by every agent surface (tree, overlay). */
61
+ export const GLYPH: Record<AgentNodeStatus, string> = { running: "⏳", done: "✓", failed: "✗", stopped: "■" };
62
+
63
+ export interface RenderOptions {
64
+ /** Current time; omitted ⇒ no elapsed/stall annotation (pure renders stay byte-identical). */
65
+ now?: number;
66
+ /** Quiet time after which a running row shows the stall badge; 0/omitted ⇒ never. */
67
+ stallMs?: number;
68
+ }
69
+
70
+ /** Whether `detail` is the literal "queued" marker a seeded-but-not-yet-live leg carries — shared
71
+ * by `runningAnnotation` (suppresses the clock) and `telemetryStatus` (`extension.ts`, reports the
72
+ * `queued` state). One predicate so the two readings of the marker cannot drift apart. */
73
+ export function isQueuedMarker(detail: string | undefined): boolean {
74
+ return detail?.trim().toLowerCase() === "queued";
75
+ }
76
+
77
+ /** "1m 15s" while a running node keeps advancing; "⚠ stalled 1m 30s" once it has been quiet
78
+ * for `stallMs`. Undefined for settled nodes, nodes without clock data, or a node still carrying
79
+ * the "queued" marker — it was seeded ahead of the concurrency gate and has not started running
80
+ * yet, so its clock (stamped at seed time) has nothing true to report. Pure. */
81
+ export function runningAnnotation(node: Pick<AgentNode, "status" | "startedAt" | "lastAdvanceAt" | "detail">, now: number, stallMs: number): string | undefined {
82
+ if (node.status !== "running" || node.startedAt === undefined) return undefined;
83
+ if (isQueuedMarker(node.detail)) return undefined;
84
+ const quietSince = node.lastAdvanceAt ?? node.startedAt;
85
+ if (stallMs > 0 && now - quietSince >= stallMs) return `⚠ stalled ${formatDuration(now - quietSince)}`;
86
+ return formatDuration(now - node.startedAt);
87
+ }
88
+
89
+ /** The tree patch for one progress snapshot: every snapshot is an advance; activity wins over
90
+ * a bare token count as the visible detail. One helper for every progress site. */
91
+ export function progressPatch(snap: { output?: string; activity?: string; tokens?: number }, now: number): AgentNodePatch {
92
+ const patch: AgentNodePatch = { lastAdvanceAt: now };
93
+ if (snap.output) patch.output = snap.output;
94
+ if (snap.activity) patch.detail = snap.activity;
95
+ else if (snap.tokens) patch.detail = `${compactTokens(snap.tokens)} tok`;
96
+ return patch;
97
+ }
98
+
99
+ function safeInline(value: string): string {
100
+ return compactInlineText(value, { maxChars: 96 });
101
+ }
102
+
103
+ /** Render the tree as plain lines with ├─/└─ branches and status glyphs. Pure. */
104
+ export function renderAgentTree(nodes: AgentNode[], opts: RenderOptions = {}): string[] {
105
+ const lines: string[] = [];
106
+ // Build the hierarchy once. Filtering the complete node array at every recursive step made
107
+ // wide fan-outs quadratic, exactly where progress snapshots cause the most UI activity.
108
+ const childrenByParent = new Map<string | undefined, AgentNode[]>();
109
+ for (const node of nodes) {
110
+ const siblings = childrenByParent.get(node.parentId);
111
+ if (siblings) siblings.push(node);
112
+ else childrenByParent.set(node.parentId, [node]);
113
+ }
114
+
115
+ const walk = (parentId: string | undefined, prefix: string): void => {
116
+ const kids = childrenByParent.get(parentId);
117
+ if (!kids) return;
118
+ kids.forEach((node, i) => {
119
+ const isRoot = parentId === undefined;
120
+ const isLast = i === kids.length - 1;
121
+ const branch = isRoot ? "" : isLast ? "└─ " : "├─ ";
122
+ const label = safeInline(node.label) || safeInline(node.id) || "agent";
123
+ const detailText = node.detail ? safeInline(node.detail) : "";
124
+ const clock = opts.now !== undefined ? runningAnnotation(node, opts.now, opts.stallMs ?? 0) : undefined;
125
+ const tail = [detailText, clock].filter((s): s is string => Boolean(s)).join(" · ");
126
+ const detail = tail ? ` ${tail}` : "";
127
+ lines.push(`${prefix}${branch}${GLYPH[node.status]} ${label}${detail}`);
128
+ const childPrefix = isRoot ? "" : `${prefix}${isLast ? " " : "│ "}`;
129
+ walk(node.id, childPrefix);
130
+ });
131
+ };
132
+
133
+ walk(undefined, "");
134
+ return lines;
135
+ }
136
+
137
+ /**
138
+ * Bounded above-editor digest. Large fan-outs belong in the scrollable F9
139
+ * overlay; they must not push the editor and conversation off the screen.
140
+ */
141
+ export function renderAgentTreeSummary(nodes: AgentNode[], maxRows = 8, opts: RenderOptions = {}): string[] {
142
+ const rows = renderAgentTree(nodes, opts);
143
+ const limit = Math.max(1, Math.floor(maxRows));
144
+ if (rows.length <= limit) return rows;
145
+ const failed = nodes.filter((node) => node.status === "failed");
146
+ if (failed.length > 0) {
147
+ const names = failed.slice(0, 2).map((node) => {
148
+ const label = safeInline(node.label) || "agent";
149
+ const detail = node.detail ? safeInline(node.detail) : "";
150
+ return detail ? `${label} (${detail})` : label;
151
+ });
152
+ const extra = failed.length - names.length;
153
+ const failureLine = `✗ ${failed.length} failed: ${names.join(", ")}${extra > 0 ? `, +${extra} more` : ""}`;
154
+ if (limit === 1) return [failureLine];
155
+ const visibleCount = Math.max(0, limit - 2);
156
+ return [
157
+ ...rows.slice(0, visibleCount),
158
+ failureLine,
159
+ `… +${rows.length - visibleCount} more · F9 or /agents for the full live tree`,
160
+ ];
161
+ }
162
+ const visibleCount = Math.max(0, limit - 1);
163
+ return [
164
+ ...rows.slice(0, visibleCount),
165
+ `… +${rows.length - visibleCount} more · F9 or /agents for the full live tree`,
166
+ ];
167
+ }
168
+
169
+ export interface AddNodeInput {
170
+ id: string;
171
+ label: string;
172
+ parentId?: string;
173
+ status?: AgentNodeStatus;
174
+ kind?: AgentNodeKind;
175
+ agent?: string;
176
+ model?: string;
177
+ detail?: string;
178
+ output?: string;
179
+ startedAt?: number;
180
+ lastAdvanceAt?: number;
181
+ }
182
+
183
+ export interface AgentNodePatch {
184
+ status?: AgentNodeStatus;
185
+ kind?: AgentNodeKind;
186
+ agent?: string;
187
+ model?: string;
188
+ detail?: string;
189
+ output?: string;
190
+ startedAt?: number;
191
+ lastAdvanceAt?: number;
192
+ }
193
+
194
+ /** Apply the fields `add`'s upsert branch and `update` both accept, in place — the one place
195
+ * that lists them, so the two callers can never let a field drift out of sync between them. */
196
+ function applyPatch(node: AgentNode, patch: AgentNodePatch): void {
197
+ if (patch.status) node.status = patch.status;
198
+ if (patch.kind !== undefined) node.kind = patch.kind;
199
+ if (patch.agent !== undefined) node.agent = patch.agent;
200
+ if (patch.model !== undefined) node.model = patch.model;
201
+ if (patch.detail !== undefined) node.detail = patch.detail;
202
+ if (patch.output !== undefined) node.output = patch.output;
203
+ if (patch.startedAt !== undefined) node.startedAt = patch.startedAt;
204
+ if (patch.lastAdvanceAt !== undefined) node.lastAdvanceAt = patch.lastAdvanceAt;
205
+ }
206
+
207
+ /** A small mutable registry with change notification. The extension owns one. */
208
+ export class AgentTree {
209
+ private nodes: AgentNode[] = [];
210
+ private listeners = new Set<(change: AgentTreeChange) => void>();
211
+ private readonly now: () => number;
212
+
213
+ constructor(now: () => number = Date.now) {
214
+ this.now = now;
215
+ }
216
+
217
+ /** Insert a node, or upsert (relabel / restatus / reparent) when the id already exists. */
218
+ add(input: AddNodeInput): void {
219
+ const existing = this.nodes.find((n) => n.id === input.id);
220
+ if (existing) {
221
+ const before = { ...existing };
222
+ existing.label = input.label;
223
+ if (input.parentId !== undefined) existing.parentId = input.parentId;
224
+ applyPatch(existing, input);
225
+ if (!sameNode(before, existing)) this.emit({ type: "updated", node: { ...existing } });
226
+ return;
227
+ }
228
+ const t = this.now();
229
+ const node: AgentNode = {
230
+ id: input.id,
231
+ label: input.label,
232
+ parentId: input.parentId,
233
+ status: input.status ?? "running",
234
+ detail: input.detail,
235
+ startedAt: input.startedAt ?? t,
236
+ lastAdvanceAt: input.lastAdvanceAt ?? input.startedAt ?? t,
237
+ ...(input.kind !== undefined ? { kind: input.kind } : {}),
238
+ ...(input.agent !== undefined ? { agent: input.agent } : {}),
239
+ ...(input.model !== undefined ? { model: input.model } : {}),
240
+ ...(input.output !== undefined ? { output: input.output } : {}),
241
+ };
242
+ this.nodes.push(node);
243
+ this.emit({ type: "added", node: { ...node } });
244
+ }
245
+
246
+ update(id: string, patch: AgentNodePatch): void {
247
+ const node = this.nodes.find((n) => n.id === id);
248
+ if (!node) return;
249
+ const before = { ...node };
250
+ applyPatch(node, patch);
251
+ if (!sameNode(before, node)) this.emit({ type: "updated", node: { ...node } });
252
+ }
253
+
254
+ /** Remove a node and all its descendants. */
255
+ remove(id: string): void {
256
+ const doomed = new Set<string>([id]);
257
+ for (let grew = true; grew; ) {
258
+ grew = false;
259
+ for (const n of this.nodes) {
260
+ if (n.parentId !== undefined && doomed.has(n.parentId) && !doomed.has(n.id)) {
261
+ doomed.add(n.id);
262
+ grew = true;
263
+ }
264
+ }
265
+ }
266
+ const removed = this.nodes.filter((n) => doomed.has(n.id)).map((n) => ({ ...n }));
267
+ if (removed.length === 0) return;
268
+ this.nodes = this.nodes.filter((n) => !doomed.has(n.id));
269
+ this.emit({ type: "removed", nodes: removed });
270
+ }
271
+
272
+ clear(): void {
273
+ if (this.nodes.length === 0) return;
274
+ const removed = this.snapshot();
275
+ this.nodes = [];
276
+ this.emit({ type: "cleared", nodes: removed });
277
+ }
278
+
279
+ snapshot(): AgentNode[] {
280
+ return this.nodes.map((n) => ({ ...n }));
281
+ }
282
+
283
+ isEmpty(): boolean {
284
+ return this.nodes.length === 0;
285
+ }
286
+
287
+ hasRunning(): boolean {
288
+ return this.nodes.some((n) => n.status === "running");
289
+ }
290
+
291
+ onChange(fn: (change: AgentTreeChange) => void): () => void {
292
+ this.listeners.add(fn);
293
+ return () => {
294
+ this.listeners.delete(fn);
295
+ };
296
+ }
297
+
298
+ private emit(change: AgentTreeChange): void {
299
+ for (const fn of this.listeners) fn(change);
300
+ }
301
+ }
302
+
303
+ const CLOCK_KEYS = new Set<keyof AgentNode>(["startedAt", "lastAdvanceAt"]);
304
+ function sameNode(a: AgentNode, b: AgentNode): boolean {
305
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)] as Array<keyof AgentNode>);
306
+ return [...keys].every((key) => CLOCK_KEYS.has(key) || a[key] === b[key]);
307
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * A repeating tick that exists only while something is live. Surfaces that show a running clock
3
+ * (the agent widget, the F9 overlay) start it when work appears; it stops itself the first time
4
+ * `isLive()` is false, so an idle session owns no timer. `unref`'d — never keeps the process alive.
5
+ */
6
+ export interface LiveClockOptions {
7
+ intervalMs: number;
8
+ isLive: () => boolean;
9
+ onTick: () => void;
10
+ }
11
+
12
+ export class LiveClock {
13
+ private readonly opts: LiveClockOptions;
14
+ private timer: ReturnType<typeof setInterval> | undefined;
15
+
16
+ constructor(opts: LiveClockOptions) {
17
+ this.opts = opts;
18
+ }
19
+
20
+ /** Idempotent; a no-op while nothing is live. */
21
+ start(): void {
22
+ if (this.timer || !this.opts.isLive()) return;
23
+ this.timer = setInterval(() => {
24
+ if (!this.opts.isLive()) {
25
+ this.stop();
26
+ return;
27
+ }
28
+ this.opts.onTick();
29
+ }, this.opts.intervalMs);
30
+ this.timer.unref?.();
31
+ }
32
+
33
+ stop(): void {
34
+ if (!this.timer) return;
35
+ clearInterval(this.timer);
36
+ this.timer = undefined;
37
+ }
38
+
39
+ get running(): boolean {
40
+ return this.timer !== undefined;
41
+ }
42
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * A focusable, searchable model picker opened with ctx.ui.custom (overlay: true).
3
+ * Type to filter, ↑↓ to move, ⏎ to choose, esc to keep the default. Unlike the
4
+ * built-in `ui.select`, the list is filterable (essential when a provider exposes
5
+ * hundreds of models) and the viewport *follows the selection* — the highlighted
6
+ * row is always on screen, so picking never jumps off the top.
7
+ *
8
+ * The pure parts (filter / viewport window / ordering) are exported and unit-tested;
9
+ * this file's component is the thin pi-tui glue (focus + keyboard + framing).
10
+ */
11
+
12
+ import type { Theme } from "@earendil-works/pi-coding-agent";
13
+ import { Container, getKeybindings, Spacer, Text, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
14
+
15
+ const VIEWPORT = 12; // rows shown at once (the rest scrolls)
16
+
17
+ /** Case-insensitive substring filter over model refs; order preserved, empty query = all. */
18
+ export function filterModels(options: string[], query: string): string[] {
19
+ const q = query.trim().toLowerCase();
20
+ if (!q) return options;
21
+ return options.filter((o) => o.toLowerCase().includes(q));
22
+ }
23
+
24
+ /** Scroll offset that keeps `selected` visible in a `size`-row window over `total`
25
+ * rows, moving as little as possible from `prevScroll`. Clamped to [0, total-size]. */
26
+ export function visibleWindow(selected: number, size: number, total: number, prevScroll: number): number {
27
+ if (total <= size) return 0;
28
+ let scroll = prevScroll;
29
+ if (selected < scroll) scroll = selected; // selection above the window → pull up
30
+ else if (selected >= scroll + size) scroll = selected - size + 1; // below → push down
31
+ const maxScroll = Math.max(0, total - size);
32
+ return Math.max(0, Math.min(scroll, maxScroll));
33
+ }
34
+
35
+ /** Order refs with the session provider's models first (then alphabetical). Pure. */
36
+ export function orderModelRefs(refs: string[], sessionProvider?: string): string[] {
37
+ const isSession = (r: string): boolean => sessionProvider !== undefined && r.startsWith(`${sessionProvider}/`);
38
+ return [...refs].sort((a, b) => Number(!isSession(a)) - Number(!isSession(b)) || a.localeCompare(b));
39
+ }
40
+
41
+ /** Is this keystroke a single printable character (a filter keystroke)? */
42
+ function isPrintable(key: string): boolean {
43
+ if (key.length !== 1) return false;
44
+ const c = key.charCodeAt(0);
45
+ return c >= 0x20 && c !== 0x7f;
46
+ }
47
+
48
+ export class ModelPicker extends Container {
49
+ private theme: Theme;
50
+ private title: string;
51
+ private options: string[];
52
+ private sessionProvider: string | undefined;
53
+ private done: (ref: string | undefined) => void;
54
+ private tui: TUI;
55
+ private query = "";
56
+ private selected = 0;
57
+ private scroll = 0;
58
+ private lastWidth = 100;
59
+
60
+ constructor(
61
+ tui: TUI,
62
+ theme: Theme,
63
+ title: string,
64
+ options: string[],
65
+ sessionProvider: string | undefined,
66
+ done: (ref: string | undefined) => void,
67
+ ) {
68
+ super();
69
+ this.tui = tui;
70
+ this.theme = theme;
71
+ this.title = title;
72
+ this.options = options;
73
+ this.sessionProvider = sessionProvider;
74
+ this.done = done;
75
+ this.rebuild();
76
+ }
77
+
78
+ private refresh(): void {
79
+ this.rebuild();
80
+ this.tui.requestRender();
81
+ }
82
+
83
+ private filtered(): string[] {
84
+ return filterModels(this.options, this.query);
85
+ }
86
+
87
+ override render(width: number): string[] {
88
+ this.lastWidth = width;
89
+ const inner = this.inner();
90
+ const b = (s: string): string => this.theme.fg("accent", s);
91
+ const framed = super.render(inner).map((line) => {
92
+ const pad = " ".repeat(Math.max(0, inner - visibleWidth(line)));
93
+ return `${b("│")} ${line}${pad} ${b("│")}`;
94
+ });
95
+ return [b(`┌${"─".repeat(inner + 2)}┐`), ...framed, b(`└${"─".repeat(inner + 2)}┘`)];
96
+ }
97
+
98
+ private inner(): number {
99
+ return Math.max(24, Math.min(this.lastWidth - 4, 100));
100
+ }
101
+
102
+ private rebuild(): void {
103
+ this.clear();
104
+ const t = this.theme;
105
+ const list = this.filtered();
106
+ if (this.selected >= list.length) this.selected = Math.max(0, list.length - 1);
107
+ this.scroll = visibleWindow(this.selected, VIEWPORT, list.length, this.scroll);
108
+ const w = this.inner() - 1;
109
+
110
+ this.addChild(new Text(t.fg("accent", t.bold(this.title)), 1, 0));
111
+ const counter = t.fg("dim", `type to filter · ${list.length}/${this.options.length}`);
112
+ const q = this.query ? t.fg("accent", ` «${this.query}»`) : "";
113
+ this.addChild(new Text(`${counter}${q}`, 1, 0));
114
+ this.addChild(new Spacer(1));
115
+
116
+ if (list.length === 0) {
117
+ this.addChild(new Text(t.fg("dim", "(no model matches — backspace to widen)"), 1, 0));
118
+ } else {
119
+ const end = Math.min(list.length, this.scroll + VIEWPORT);
120
+ if (this.scroll > 0) this.addChild(new Text(t.fg("dim", `▲ ${this.scroll} above`), 1, 0));
121
+ for (let i = this.scroll; i < end; i++) {
122
+ const opt = list[i] as string;
123
+ const star = this.sessionProvider && opt.startsWith(`${this.sessionProvider}/`) ? "★ " : " ";
124
+ const text = truncateToWidth(`${star}${opt}`, w);
125
+ const row = i === this.selected ? t.fg("accent", `▸ ${text}`) : ` ${text}`;
126
+ this.addChild(new Text(row, 1, 0));
127
+ }
128
+ if (end < list.length) this.addChild(new Text(t.fg("dim", `▼ ${list.length - end} below`), 1, 0));
129
+ }
130
+
131
+ this.addChild(new Spacer(1));
132
+ this.addChild(new Text(t.fg("dim", "↑↓ move · ⏎ select · esc keep default"), 1, 0));
133
+ }
134
+
135
+ handleInput(keyData: string): void {
136
+ const kb = getKeybindings();
137
+ const list = this.filtered();
138
+ if (kb.matches(keyData, "tui.select.up")) {
139
+ this.selected = Math.max(0, this.selected - 1);
140
+ this.refresh();
141
+ } else if (kb.matches(keyData, "tui.select.down")) {
142
+ this.selected = Math.min(Math.max(0, list.length - 1), this.selected + 1);
143
+ this.refresh();
144
+ } else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n" || keyData === "\r") {
145
+ this.done(list[this.selected]);
146
+ } else if (kb.matches(keyData, "tui.select.cancel")) {
147
+ this.done(undefined);
148
+ } else if (keyData === "\x7f" || keyData === "\b") {
149
+ if (this.query) {
150
+ this.query = this.query.slice(0, -1);
151
+ this.selected = 0;
152
+ this.refresh();
153
+ }
154
+ } else if (isPrintable(keyData)) {
155
+ this.query += keyData;
156
+ this.selected = 0;
157
+ this.refresh();
158
+ }
159
+ }
160
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Shared terminal-presentation helpers. These functions shape what a human sees;
3
+ * they never mutate the semantic payload delivered to the supervisor.
4
+ */
5
+
6
+ import { sliceByColumn, visibleWidth } from "@earendil-works/pi-tui";
7
+
8
+ // Every alternative is bounded, in two ways that matter.
9
+ //
10
+ // The OSC payload stops at the next ESC as well as at BEL: without that an `ESC ]` a child
11
+ // never terminates makes the class run to end-of-string and backtrack from *every* such
12
+ // position — quadratic, on the synchronous render path. The length cap below does not make
13
+ // that exclusion redundant: it is also what keeps each introducer's erase region disjoint, so
14
+ // a child cannot fold an arbitrary run of its report — sequences included — behind one
15
+ // introducer by delaying the BEL.
16
+ //
17
+ // It also stops after OSC_MAX_PAYLOAD characters, and the CSI parameter run after its own
18
+ // cap. Erasing an OSC payload is what makes `ESC ]0;` a way to *hide* text, so an unbounded
19
+ // one lets a child fold its entire report behind one introducer. A bound also makes "might
20
+ // this still be opening?" a question with a finite answer — the property AgentOverlay's row
21
+ // cache needs to reuse wrapped rows across a chunk boundary. Past the cap the sequence is
22
+ // not a sequence: the introducer is stripped as a C1 byte and the body stays visible text.
23
+ const OSC_MAX_PAYLOAD = 4096;
24
+ const CSI_MAX_PARAMS = 255;
25
+ const CSI_MAX_INTERMEDIATES = 15;
26
+ const ANSI_SEQUENCE = new RegExp(
27
+ `\\u001B(?:\\][^\\u0007\\u001B]{0,${OSC_MAX_PAYLOAD}}(?:\\u0007|\\u001B\\\\)` +
28
+ `|\\[[0-?]{0,${CSI_MAX_PARAMS}}[ -/]{0,${CSI_MAX_INTERMEDIATES}}[@-~]|[@-_])`,
29
+ "g",
30
+ );
31
+
32
+ /**
33
+ * The proper prefixes of ANSI_SEQUENCE: text that may still turn into a control sequence once
34
+ * more of the stream arrives. Exported for incremental consumers (AgentOverlay) that split a
35
+ * growing buffer and must not put the split inside a sequence. Because the bodies above are
36
+ * capped, this stops being true a bounded distance after the introducer.
37
+ */
38
+ export const OPEN_SEQUENCE_TAIL = new RegExp(
39
+ `\\u001B(?:\\][^\\u0007\\u001B]{0,${OSC_MAX_PAYLOAD}}` +
40
+ `|\\[[0-?]{0,${CSI_MAX_PARAMS}}[ -/]{0,${CSI_MAX_INTERMEDIATES}})?$`,
41
+ );
42
+ const TERMINAL_CONTROLS = /[\u0000-\u0009\u000B-\u001F\u007F-\u009F]/g;
43
+
44
+ const PRESENTATION_WRAPPERS = new Set([
45
+ "Sub-agent output (untrusted data):",
46
+ "Peer message (untrusted data; equal-status collaborator, not your supervisor):",
47
+ "Peer data · untrusted equal-status collaborator:",
48
+ ]);
49
+
50
+ export interface CompactVisibleTextOptions {
51
+ maxLines?: number;
52
+ /** Historical option name; the bound is terminal display columns, not JS string length. */
53
+ maxLineChars?: number;
54
+ }
55
+
56
+ export interface CompactVisibleTextResult {
57
+ text: string;
58
+ truncated: boolean;
59
+ omittedLines: number;
60
+ originalChars: number;
61
+ }
62
+
63
+ export interface CompactInlineTextOptions {
64
+ maxChars?: number;
65
+ }
66
+
67
+ /** Hard ceiling for one-line collapsed card chrome and previews. */
68
+ export const MAX_COLLAPSED_CARD_COLUMNS = 100;
69
+
70
+ /**
71
+ * Clip to at most `units` code units without cutting an astral character in half. A lone
72
+ * surrogate draws as a replacement glyph, and every clip here puts it directly before the
73
+ * ellipsis — the most visible position in the line.
74
+ */
75
+ function clipCodeUnits(text: string, units: number): string {
76
+ if (units >= text.length) return text;
77
+ const last = text.charCodeAt(units - 1);
78
+ return text.slice(0, last >= 0xd800 && last <= 0xdbff ? units - 1 : units);
79
+ }
80
+
81
+ /**
82
+ * Sanitize and bound trusted UI chrome such as labels, model names, and error causes.
83
+ * `maxChars` bounds both code units and *display columns*: a CJK label passes a character
84
+ * count at twice the width, and a widget row "bounded" by characters alone then wraps
85
+ * over several terminal rows and pushes the conversation off the screen.
86
+ */
87
+ export function compactInlineText(input: string, opts: CompactInlineTextOptions = {}): string {
88
+ const maxChars = Math.max(16, Math.floor(opts.maxChars ?? 160));
89
+ const compact = sanitizeTerminalText(input).replace(/\s+/g, " ").trim();
90
+ // Clip by code units first, so the column measurement below never walks a whole report.
91
+ const clipped = compact.length > maxChars ? `${clipCodeUnits(compact, maxChars - 1)}…` : compact;
92
+ if (visibleWidth(clipped) <= maxChars) return clipped;
93
+ // pi-tui's truncateToWidth would splice in ANSI resets, and chrome must stay escape-free.
94
+ return `${sliceByColumn(clipped, 0, maxChars - 1, true)}…`;
95
+ }
96
+
97
+ /** Clip an already-composed terminal row without counting wide glyphs as one column. */
98
+ export function compactVisibleLine(input: string, maxColumns = MAX_COLLAPSED_CARD_COLUMNS): string {
99
+ const limit = Math.max(16, Math.floor(maxColumns));
100
+ if (visibleWidth(input) <= limit) return input;
101
+ return `${sliceByColumn(input, 0, limit - visibleWidth("…"), true)}…`;
102
+ }
103
+
104
+ /** Bound a persistent widget: one stable header, a fair prefix of rows, then one drill-down row. */
105
+ export function boundDisplayRows(
106
+ header: string,
107
+ rows: readonly string[],
108
+ maxRows: number,
109
+ detailHint: string,
110
+ ): string[] {
111
+ const limit = Math.max(2, Math.floor(maxRows));
112
+ if (rows.length + 1 <= limit) return [header, ...rows];
113
+ const visibleCount = limit - 2;
114
+ const omitted = rows.length - visibleCount;
115
+ return [
116
+ header,
117
+ ...rows.slice(0, visibleCount),
118
+ `… +${omitted} more · ${detailHint}`,
119
+ ];
120
+ }
121
+
122
+ /** Strip control sequences a child could otherwise use to repaint or spoof terminal chrome. */
123
+ export function sanitizeTerminalText(text: string): string {
124
+ return text
125
+ .replace(/\r\n?/g, "\n")
126
+ .replace(ANSI_SEQUENCE, "")
127
+ .replace(TERMINAL_CONTROLS, "");
128
+ }
129
+
130
+ /**
131
+ * Produce a small, semantic preview for a collapsed Pi card. Boilerplate fence
132
+ * headings are omitted, while quoted payload lines remain visibly quoted.
133
+ */
134
+ export function compactVisibleText(
135
+ input: string,
136
+ opts: CompactVisibleTextOptions = {},
137
+ ): CompactVisibleTextResult {
138
+ const maxLines = Math.max(1, Math.floor(opts.maxLines ?? 4));
139
+ const maxLineColumns = Math.max(16, Math.floor(opts.maxLineChars ?? 160));
140
+ const sanitized = sanitizeTerminalText(input);
141
+ const source = sanitized
142
+ .split("\n")
143
+ .map((line) => line.trim())
144
+ .filter((line) => line.length > 0 && !PRESENTATION_WRAPPERS.has(line));
145
+
146
+ let lineTruncated = false;
147
+ const clamp = (line: string): string => {
148
+ if (visibleWidth(line) <= maxLineColumns) return line;
149
+ lineTruncated = true;
150
+ return `${sliceByColumn(line, 0, maxLineColumns - visibleWidth("…"), true)}…`;
151
+ };
152
+
153
+ let omittedLines = 0;
154
+ let visible: string[];
155
+ if (source.length > maxLines) {
156
+ const contentLines = Math.max(0, maxLines - 1);
157
+ omittedLines = source.length - contentLines;
158
+ visible = source.slice(0, contentLines).map(clamp);
159
+ visible.push(clamp(`… +${omittedLines} more line${omittedLines === 1 ? "" : "s"}`));
160
+ } else {
161
+ visible = source.map(clamp);
162
+ }
163
+
164
+ return {
165
+ text: visible.join("\n") || "(no output)",
166
+ truncated: lineTruncated || omittedLines > 0,
167
+ omittedLines,
168
+ originalChars: input.length,
169
+ };
170
+ }