@esso0428/pi-subagents 0.15.0

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 (98) hide show
  1. package/CHANGELOG.md +638 -0
  2. package/CONTRIBUTING.md +68 -0
  3. package/LICENSE +21 -0
  4. package/README.md +745 -0
  5. package/SECURITY.md +95 -0
  6. package/dist/agent-manager.d.ts +144 -0
  7. package/dist/agent-manager.js +542 -0
  8. package/dist/agent-runner.d.ts +212 -0
  9. package/dist/agent-runner.js +850 -0
  10. package/dist/agent-types.d.ts +67 -0
  11. package/dist/agent-types.js +168 -0
  12. package/dist/context.d.ts +12 -0
  13. package/dist/context.js +56 -0
  14. package/dist/cross-extension-rpc.d.ts +46 -0
  15. package/dist/cross-extension-rpc.js +76 -0
  16. package/dist/custom-agents.d.ts +17 -0
  17. package/dist/custom-agents.js +156 -0
  18. package/dist/default-agents.d.ts +7 -0
  19. package/dist/default-agents.js +122 -0
  20. package/dist/enabled-models.d.ts +49 -0
  21. package/dist/enabled-models.js +145 -0
  22. package/dist/env.d.ts +6 -0
  23. package/dist/env.js +28 -0
  24. package/dist/group-join.d.ts +32 -0
  25. package/dist/group-join.js +116 -0
  26. package/dist/index.d.ts +16 -0
  27. package/dist/index.js +2209 -0
  28. package/dist/invocation-config.d.ts +22 -0
  29. package/dist/invocation-config.js +15 -0
  30. package/dist/memory.d.ts +53 -0
  31. package/dist/memory.js +165 -0
  32. package/dist/model-resolver.d.ts +19 -0
  33. package/dist/model-resolver.js +80 -0
  34. package/dist/nico-overrides.d.ts +53 -0
  35. package/dist/nico-overrides.js +169 -0
  36. package/dist/output-file.d.ts +24 -0
  37. package/dist/output-file.js +101 -0
  38. package/dist/prompts.d.ts +32 -0
  39. package/dist/prompts.js +73 -0
  40. package/dist/schedule-store.d.ts +38 -0
  41. package/dist/schedule-store.js +155 -0
  42. package/dist/schedule.d.ts +109 -0
  43. package/dist/schedule.js +338 -0
  44. package/dist/settings.d.ts +141 -0
  45. package/dist/settings.js +162 -0
  46. package/dist/skill-loader.d.ts +24 -0
  47. package/dist/skill-loader.js +93 -0
  48. package/dist/status-note.d.ts +13 -0
  49. package/dist/status-note.js +24 -0
  50. package/dist/types.d.ts +197 -0
  51. package/dist/types.js +5 -0
  52. package/dist/ui/agent-widget.d.ts +160 -0
  53. package/dist/ui/agent-widget.js +484 -0
  54. package/dist/ui/conversation-viewer.d.ts +57 -0
  55. package/dist/ui/conversation-viewer.js +354 -0
  56. package/dist/ui/fleet-list.d.ts +106 -0
  57. package/dist/ui/fleet-list.js +345 -0
  58. package/dist/ui/schedule-menu.d.ts +16 -0
  59. package/dist/ui/schedule-menu.js +95 -0
  60. package/dist/ui/viewer-keys.d.ts +20 -0
  61. package/dist/ui/viewer-keys.js +17 -0
  62. package/dist/usage.d.ts +50 -0
  63. package/dist/usage.js +49 -0
  64. package/dist/worktree.d.ts +45 -0
  65. package/dist/worktree.js +160 -0
  66. package/examples/agent-tool-description.md +42 -0
  67. package/package.json +56 -0
  68. package/src/agent-manager.ts +631 -0
  69. package/src/agent-runner.ts +1014 -0
  70. package/src/agent-types.ts +202 -0
  71. package/src/context.ts +58 -0
  72. package/src/cross-extension-rpc.ts +122 -0
  73. package/src/custom-agents.ts +167 -0
  74. package/src/default-agents.ts +126 -0
  75. package/src/enabled-models.ts +180 -0
  76. package/src/env.ts +33 -0
  77. package/src/group-join.ts +141 -0
  78. package/src/index.ts +2400 -0
  79. package/src/invocation-config.ts +40 -0
  80. package/src/memory.ts +179 -0
  81. package/src/model-resolver.ts +100 -0
  82. package/src/nico-overrides.ts +235 -0
  83. package/src/output-file.ts +110 -0
  84. package/src/prompts.ts +99 -0
  85. package/src/schedule-store.ts +153 -0
  86. package/src/schedule.ts +365 -0
  87. package/src/settings.ts +288 -0
  88. package/src/skill-loader.ts +102 -0
  89. package/src/status-note.ts +25 -0
  90. package/src/types.ts +208 -0
  91. package/src/ui/agent-widget.ts +566 -0
  92. package/src/ui/conversation-viewer.ts +362 -0
  93. package/src/ui/fleet-list.ts +380 -0
  94. package/src/ui/schedule-menu.ts +104 -0
  95. package/src/ui/viewer-keys.ts +39 -0
  96. package/src/usage.ts +60 -0
  97. package/src/worktree.ts +191 -0
  98. package/vitest.config.ts +18 -0
@@ -0,0 +1,362 @@
1
+ /**
2
+ * conversation-viewer.ts — Live conversation overlay for viewing agent sessions.
3
+ *
4
+ * Displays a scrollable, live-updating view of an agent's conversation.
5
+ * Subscribes to session events for real-time streaming updates.
6
+ */
7
+
8
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
9
+ import { type Component, Input, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
10
+ import { extractText } from "../context.js";
11
+ import type { AgentRecord } from "../types.js";
12
+ import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
13
+ import type { Theme } from "./agent-widget.js";
14
+ import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
15
+ import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
16
+
17
+ /** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
18
+ const CHROME_LINES_BASE = 6;
19
+ const MIN_VIEWPORT = 3;
20
+ /** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
21
+ export const VIEWPORT_HEIGHT_PCT = 70;
22
+
23
+ export class ConversationViewer implements Component {
24
+ private scrollOffset = 0;
25
+ private autoScroll = true;
26
+ private unsubscribe: (() => void) | undefined;
27
+ private lastInnerW = 0;
28
+ private closed = false;
29
+ /** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */
30
+ private stopArmed = false;
31
+ private keys: ViewerKeys;
32
+ /** Steering composer — present while the user is typing a message to the agent. */
33
+ private composer: Input | undefined;
34
+
35
+ constructor(
36
+ private tui: TUI,
37
+ private session: AgentSession,
38
+ private record: AgentRecord,
39
+ private activity: AgentActivity | undefined,
40
+ private theme: Theme,
41
+ private done: (result: undefined) => void,
42
+ /** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */
43
+ private onStop?: () => void,
44
+ /** User keybindings from `ctx.ui.custom()`. Omitted → hardcoded defaults. */
45
+ keybindings?: ViewerKeybindings,
46
+ /** Send a steering message to the agent. Omitted → no compose affordance. */
47
+ private onSteer?: (message: string) => void,
48
+ ) {
49
+ this.keys = createViewerKeys(keybindings);
50
+ this.unsubscribe = session.subscribe(() => {
51
+ if (this.closed) return;
52
+ this.tui.requestRender();
53
+ });
54
+ }
55
+
56
+ handleInput(data: string): void {
57
+ // While composing a steer message, the input owns all keys (Enter sends,
58
+ // Esc cancels — both wired in openComposer()). Editing keys flow through.
59
+ if (this.composer) {
60
+ this.composer.handleInput(data);
61
+ this.tui.requestRender();
62
+ return;
63
+ }
64
+
65
+ if (matchesKey(data, "escape") || matchesKey(data, "q")) {
66
+ this.closed = true;
67
+ this.done(undefined);
68
+ return;
69
+ }
70
+
71
+ // Enter opens the steering composer (only while the agent can still be
72
+ // steered) — then type + Enter sends, Esc or an empty submit returns. When
73
+ // not steerable, fall through so the key still disarms a pending stop.
74
+ if (matchesKey(data, "enter") && this.canSteer()) {
75
+ this.stopArmed = false;
76
+ this.openComposer();
77
+ return;
78
+ }
79
+
80
+ // Stop/abort the agent (only while it can still be stopped). Two-press:
81
+ // first "x" arms, second confirms — any other key disarms.
82
+ if (matchesKey(data, "x")) {
83
+ if (this.isStoppable()) {
84
+ if (this.stopArmed) {
85
+ this.stopArmed = false;
86
+ this.onStop?.();
87
+ } else {
88
+ this.stopArmed = true;
89
+ }
90
+ this.tui.requestRender();
91
+ }
92
+ return;
93
+ }
94
+ if (this.stopArmed) this.stopArmed = false;
95
+
96
+ const totalLines = this.buildContentLines(this.lastInnerW).length;
97
+ const viewportHeight = this.viewportHeight();
98
+ const maxScroll = Math.max(0, totalLines - viewportHeight);
99
+
100
+ if (this.keys.scrollUp(data)) {
101
+ this.scrollOffset = Math.max(0, this.scrollOffset - 1);
102
+ this.autoScroll = this.scrollOffset >= maxScroll;
103
+ } else if (this.keys.scrollDown(data)) {
104
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
105
+ this.autoScroll = this.scrollOffset >= maxScroll;
106
+ } else if (this.keys.pageUp(data)) {
107
+ this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
108
+ this.autoScroll = false;
109
+ } else if (this.keys.pageDown(data)) {
110
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
111
+ this.autoScroll = this.scrollOffset >= maxScroll;
112
+ } else if (matchesKey(data, "home")) {
113
+ this.scrollOffset = 0;
114
+ this.autoScroll = false;
115
+ } else if (matchesKey(data, "end")) {
116
+ this.scrollOffset = maxScroll;
117
+ this.autoScroll = true;
118
+ }
119
+ }
120
+
121
+ render(width: number): string[] {
122
+ if (width < 6) return []; // too narrow for any meaningful rendering
123
+ const th = this.theme;
124
+ const innerW = width - 4; // border + padding
125
+ this.lastInnerW = innerW;
126
+ const lines: string[] = [];
127
+
128
+ const pad = (s: string, len: number) => {
129
+ const vis = visibleWidth(s);
130
+ return s + " ".repeat(Math.max(0, len - vis));
131
+ };
132
+ const row = (content: string) =>
133
+ th.fg("border", "│") + " " + truncateToWidth(pad(content, innerW), innerW, "...", true) + " " + th.fg("border", "│");
134
+ const hrTop = th.fg("border", `╭${"─".repeat(width - 2)}╮`);
135
+ const hrBot = th.fg("border", `╰${"─".repeat(width - 2)}╯`);
136
+ const hrMid = row(th.fg("dim", "─".repeat(innerW)));
137
+
138
+ // Header
139
+ lines.push(hrTop);
140
+ const name = getDisplayName(this.record.type);
141
+ const modeLabel = getPromptModeLabel(this.record.type);
142
+ const modeTag = modeLabel ? ` ${th.fg("dim", `(${modeLabel})`)}` : "";
143
+ const statusIcon = this.record.status === "running"
144
+ ? th.fg("accent", "●")
145
+ : this.record.status === "completed"
146
+ ? th.fg("success", "✓")
147
+ : this.record.status === "error"
148
+ ? th.fg("error", "✗")
149
+ : th.fg("dim", "○");
150
+ const duration = formatDuration(this.record.startedAt, this.record.completedAt);
151
+
152
+ const headerParts: string[] = [duration];
153
+ const toolUses = this.activity?.toolUses ?? this.record.toolUses;
154
+ if (toolUses > 0) headerParts.unshift(`${toolUses} tool${toolUses === 1 ? "" : "s"}`);
155
+ const tokens = getLifetimeTotal(this.activity?.lifetimeUsage);
156
+ if (tokens > 0) {
157
+ const percent = getSessionContextPercent(this.activity?.session);
158
+ headerParts.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
159
+ }
160
+
161
+ lines.push(row(
162
+ `${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${fgPreservingNestedStyles(th, "dim", headerParts.join(" · "))}`,
163
+ ));
164
+ const invocationLine = this.invocationLine();
165
+ if (invocationLine) lines.push(row(invocationLine));
166
+ lines.push(hrMid);
167
+
168
+ // Content area — rebuild every render (live data, no cache needed)
169
+ const contentLines = this.buildContentLines(innerW);
170
+ const viewportHeight = this.viewportHeight();
171
+ const maxScroll = Math.max(0, contentLines.length - viewportHeight);
172
+
173
+ if (this.autoScroll) {
174
+ this.scrollOffset = maxScroll;
175
+ }
176
+
177
+ const visibleStart = Math.min(this.scrollOffset, maxScroll);
178
+ const visible = contentLines.slice(visibleStart, visibleStart + viewportHeight);
179
+
180
+ for (let i = 0; i < viewportHeight; i++) {
181
+ lines.push(row(visible[i] ?? ""));
182
+ }
183
+
184
+ // Footer
185
+ lines.push(hrMid);
186
+ if (this.composer) {
187
+ // Composer row: the Input renders its own `> ` prompt and cursor.
188
+ lines.push(row(this.composer.render(innerW)[0] ?? ""));
189
+ const composeHint = th.fg("dim", "Enter send · Esc cancel");
190
+ const composeLeft = th.fg("accent", "✎ steer");
191
+ const composeGap = Math.max(1, innerW - visibleWidth(composeLeft) - visibleWidth(composeHint));
192
+ lines.push(row(composeLeft + " ".repeat(composeGap) + composeHint));
193
+ } else {
194
+ // Actions on the left, navigation on the right. The scroll hint keeps its
195
+ // full key list so the less-obvious bindings stay discoverable; it leads
196
+ // the right group so "Esc close" is the only part that truncates first.
197
+ const sep = th.fg("dim", " · ");
198
+ const actions: string[] = [];
199
+ if (this.canSteer()) actions.push(th.fg("dim", "Enter steer"));
200
+ if (this.isStoppable()) {
201
+ actions.push(this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop"));
202
+ }
203
+ const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close");
204
+
205
+ // Prepend the line-count/scroll-% readout only when there's spare width —
206
+ // it's the first thing dropped so it never crowds out the hints.
207
+ const scrollPct = contentLines.length <= viewportHeight
208
+ ? "100%"
209
+ : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
210
+ const count = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
211
+ const withCount = [count, ...actions].join(sep);
212
+ const footerLeft = visibleWidth(withCount) + visibleWidth(footerRight) + 1 <= innerW
213
+ ? withCount
214
+ : actions.join(sep);
215
+
216
+ const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
217
+ lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
218
+ }
219
+ lines.push(hrBot);
220
+
221
+ return lines;
222
+ }
223
+
224
+ /** Stoppable only when a stop handler exists and the agent is still active. */
225
+ private isStoppable(): boolean {
226
+ return !!this.onStop && (this.record.status === "running" || this.record.status === "queued");
227
+ }
228
+
229
+ /** Steerable only when a steer handler exists and the agent is still active. */
230
+ private canSteer(): boolean {
231
+ return !!this.onSteer && (this.record.status === "running" || this.record.status === "queued");
232
+ }
233
+
234
+ /** Open the inline steering composer and route subsequent input to it. */
235
+ private openComposer(): void {
236
+ const input = new Input();
237
+ input.focused = true;
238
+ input.onSubmit = (value: string) => {
239
+ const message = value.trim();
240
+ this.composer = undefined;
241
+ if (message) this.onSteer?.(message);
242
+ this.tui.requestRender();
243
+ };
244
+ input.onEscape = () => {
245
+ this.composer = undefined;
246
+ this.tui.requestRender();
247
+ };
248
+ this.composer = input;
249
+ this.tui.requestRender();
250
+ }
251
+
252
+ invalidate(): void { /* no cached state to clear */ }
253
+
254
+ dispose(): void {
255
+ this.closed = true;
256
+ if (this.unsubscribe) {
257
+ this.unsubscribe();
258
+ this.unsubscribe = undefined;
259
+ }
260
+ }
261
+
262
+ // ---- Private ----
263
+
264
+ private viewportHeight(): number {
265
+ // Cap mirrors the overlay's maxHeight — otherwise the viewer would render
266
+ // more lines than the overlay shows and clip the footer.
267
+ const maxRows = Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100);
268
+ return Math.max(MIN_VIEWPORT, maxRows - this.chromeLines());
269
+ }
270
+
271
+ private chromeLines(): number {
272
+ // The composer adds one row above the footer hint while it's open.
273
+ return CHROME_LINES_BASE + (this.invocationLine() ? 1 : 0) + (this.composer ? 1 : 0);
274
+ }
275
+
276
+ private invocationLine(): string | undefined {
277
+ const { modelName, tags } = buildInvocationTags(this.record.invocation);
278
+ const parts = modelName ? [modelName, ...tags] : tags;
279
+ if (parts.length === 0) return undefined;
280
+ return this.theme.fg("dim", ` ↳ ${parts.join(" · ")}`);
281
+ }
282
+
283
+ private buildContentLines(width: number): string[] {
284
+ if (width <= 0) return [];
285
+
286
+ const th = this.theme;
287
+ const messages = this.session.messages;
288
+ const lines: string[] = [];
289
+
290
+ if (messages.length === 0) {
291
+ lines.push(th.fg("dim", "(waiting for first message...)"));
292
+ return lines;
293
+ }
294
+
295
+ let needsSeparator = false;
296
+ for (const msg of messages) {
297
+ if (msg.role === "user") {
298
+ const text = typeof msg.content === "string"
299
+ ? msg.content
300
+ : extractText(msg.content);
301
+ if (!text.trim()) continue;
302
+ if (needsSeparator) lines.push(th.fg("dim", "───"));
303
+ lines.push(th.fg("accent", "[User]"));
304
+ for (const line of wrapTextWithAnsi(text.trim(), width)) {
305
+ lines.push(line);
306
+ }
307
+ } else if (msg.role === "assistant") {
308
+ const textParts: string[] = [];
309
+ const toolCalls: string[] = [];
310
+ for (const c of msg.content) {
311
+ if (c.type === "text" && c.text) textParts.push(c.text);
312
+ else if (c.type === "toolCall") {
313
+ toolCalls.push((c as any).name ?? (c as any).toolName ?? "unknown");
314
+ }
315
+ }
316
+ if (needsSeparator) lines.push(th.fg("dim", "───"));
317
+ lines.push(th.bold("[Assistant]"));
318
+ if (textParts.length > 0) {
319
+ for (const line of wrapTextWithAnsi(textParts.join("\n").trim(), width)) {
320
+ lines.push(line);
321
+ }
322
+ }
323
+ for (const name of toolCalls) {
324
+ lines.push(truncateToWidth(th.fg("muted", ` [Tool: ${name}]`), width));
325
+ }
326
+ } else if (msg.role === "toolResult") {
327
+ const text = extractText(msg.content);
328
+ const truncated = text.length > 500 ? text.slice(0, 500) + "... (truncated)" : text;
329
+ if (!truncated.trim()) continue;
330
+ if (needsSeparator) lines.push(th.fg("dim", "───"));
331
+ lines.push(th.fg("dim", "[Result]"));
332
+ for (const line of wrapTextWithAnsi(truncated.trim(), width)) {
333
+ lines.push(th.fg("dim", line));
334
+ }
335
+ } else if ((msg as any).role === "bashExecution") {
336
+ const bash = msg as any;
337
+ if (needsSeparator) lines.push(th.fg("dim", "───"));
338
+ lines.push(truncateToWidth(th.fg("muted", ` $ ${bash.command}`), width));
339
+ if (bash.output?.trim()) {
340
+ const out = bash.output.length > 500
341
+ ? bash.output.slice(0, 500) + "... (truncated)"
342
+ : bash.output;
343
+ for (const line of wrapTextWithAnsi(out.trim(), width)) {
344
+ lines.push(th.fg("dim", line));
345
+ }
346
+ }
347
+ } else {
348
+ continue;
349
+ }
350
+ needsSeparator = true;
351
+ }
352
+
353
+ // Streaming indicator for running agents
354
+ if (this.record.status === "running" && this.activity) {
355
+ const act = describeActivity(this.activity.activeTools, this.activity.responseText);
356
+ lines.push("");
357
+ lines.push(truncateToWidth(th.fg("accent", "▍ ") + th.fg("dim", act), width));
358
+ }
359
+
360
+ return lines.map(l => truncateToWidth(l, width));
361
+ }
362
+ }