@gotgenes/pi-subagents 17.4.0 → 18.0.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.
@@ -1,331 +0,0 @@
1
- /* eslint-disable @typescript-eslint/no-unsafe-assignment -- Pi SDK types are not fully exported; see upstream Pi SDK for type improvements */
2
- import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
- import { AgentTypeRegistry } from "#src/config/agent-types";
4
- import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
5
- import { type ModelRegistry, resolveModel } from "#src/session/model-resolver";
6
- import { getModelLabelFromConfig } from "#src/tools/helpers";
7
- import type { AgentConfig, Subagent } from "#src/types";
8
- import { AgentConfigEditor } from "#src/ui/agent-config-editor";
9
- import { AgentCreationWizard } from "#src/ui/agent-creation-wizard";
10
- import type { AgentFileOps } from "#src/ui/agent-file-ops";
11
- import { formatDuration, getDisplayName } from "#src/ui/display";
12
-
13
- // ---- Narrow interfaces ----
14
-
15
- /** Narrow manager interface for menu operations. */
16
- export interface AgentMenuManager {
17
- listAgents: () => Subagent[];
18
- getRecord: (id: string) => Subagent | undefined;
19
- /** Used by generate wizard to spawn an agent that writes the .md file. */
20
- spawnAndWait: (
21
- parentSnapshot: ParentSnapshot,
22
- type: string,
23
- prompt: string,
24
- opts: { description: string; maxTurns: number },
25
- ) => Promise<Subagent>;
26
- }
27
-
28
- /** Narrow settings interface required by the agent menu. */
29
- export interface AgentMenuSettings {
30
- readonly maxConcurrent: number;
31
- readonly defaultMaxTurns: number | undefined;
32
- readonly graceTurns: number;
33
- applyMaxConcurrent(n: number): { message: string; level: "info" | "warning" };
34
- applyDefaultMaxTurns(n: number): { message: string; level: "info" | "warning" };
35
- applyGraceTurns(n: number): { message: string; level: "info" | "warning" };
36
- }
37
-
38
- // ---- Narrow UI context types ----
39
-
40
- /** Narrow UI interface — only the ctx.ui methods menu handlers actually call. */
41
- export interface MenuUI {
42
- select(title: string, options: string[]): Promise<string | undefined>;
43
- confirm(title: string, message: string): Promise<boolean>;
44
- input(title: string, defaultValue?: string): Promise<string | undefined>;
45
- notify(message: string, level: "info" | "warning" | "error"): void;
46
- editor(title: string, content: string): Promise<string | undefined>;
47
- custom<R>(component: any, options?: any): Promise<R>;
48
- }
49
-
50
- // ---- Class ----
51
-
52
- /**
53
- * Handler for the `/agents` slash command.
54
- *
55
- * Call `handle(ctx)` from the Pi command registration to open the interactive menu.
56
- */
57
- export class AgentsMenuHandler {
58
- private readonly editor: AgentConfigEditor;
59
- private readonly wizard: AgentCreationWizard;
60
-
61
- constructor(
62
- private readonly manager: AgentMenuManager,
63
- private readonly registry: AgentTypeRegistry,
64
- private readonly settings: AgentMenuSettings,
65
- fileOps: AgentFileOps,
66
- personalAgentsDir: string,
67
- projectAgentsDir: string,
68
- ) {
69
- this.editor = new AgentConfigEditor(
70
- fileOps,
71
- registry,
72
- personalAgentsDir,
73
- projectAgentsDir,
74
- );
75
- this.wizard = new AgentCreationWizard(
76
- fileOps,
77
- manager,
78
- registry,
79
- personalAgentsDir,
80
- projectAgentsDir,
81
- );
82
- }
83
-
84
- async handle({
85
- ui,
86
- modelRegistry,
87
- parentSnapshot,
88
- }: {
89
- ui: MenuUI;
90
- modelRegistry: ModelRegistry;
91
- parentSnapshot: ParentSnapshot;
92
- }): Promise<void> {
93
- await this.showAgentsMenu(ui, modelRegistry, parentSnapshot);
94
- }
95
-
96
- private getModelLabel(type: string, modelRegistry?: ModelRegistry): string {
97
- const cfg = this.registry.resolveAgentConfig(type);
98
- if (!cfg.model) return "inherit";
99
- if (modelRegistry) {
100
- const resolved = resolveModel(cfg.model, modelRegistry);
101
- if (typeof resolved === "string") return "inherit";
102
- }
103
- return getModelLabelFromConfig(cfg.model);
104
- }
105
-
106
- private async showAgentsMenu(
107
- ui: MenuUI,
108
- modelRegistry: ModelRegistry,
109
- parentSnapshot: ParentSnapshot,
110
- ): Promise<void> {
111
- this.registry.reload();
112
- const allNames = this.registry.getAllTypes();
113
-
114
- const options: string[] = [];
115
-
116
- const agents = this.manager.listAgents();
117
- if (agents.length > 0) {
118
- const running = agents.filter(
119
- (a) => a.status === "running" || a.status === "queued",
120
- ).length;
121
- const done = agents.filter(
122
- (a) => a.status === "completed" || a.status === "steered",
123
- ).length;
124
- options.push(
125
- `Running agents (${agents.length}) — ${running} running, ${done} done`,
126
- );
127
- }
128
-
129
- if (allNames.length > 0) {
130
- options.push(`Agent types (${allNames.length})`);
131
- }
132
-
133
- options.push("Create new agent");
134
- options.push("Settings");
135
-
136
- const noAgentsMsg =
137
- allNames.length === 0 && agents.length === 0
138
- ? "No agents found. Create specialized subagents that can be delegated to.\n\n" +
139
- "Each subagent has its own context window, custom system prompt, and specific tools.\n\n" +
140
- "Try creating: Code Reviewer, Security Auditor, Test Writer, or Documentation Writer.\n\n"
141
- : "";
142
-
143
- if (noAgentsMsg) {
144
- ui.notify(noAgentsMsg, "info");
145
- }
146
-
147
- const choice = await ui.select("Agents", options);
148
- if (!choice) return;
149
-
150
- if (choice.startsWith("Running agents (")) {
151
- await this.showRunningAgents(ui);
152
- await this.showAgentsMenu(ui, modelRegistry, parentSnapshot);
153
- } else if (choice.startsWith("Agent types (")) {
154
- await this.showAllAgentsList(ui, modelRegistry);
155
- await this.showAgentsMenu(ui, modelRegistry, parentSnapshot);
156
- } else if (choice === "Create new agent") {
157
- await this.wizard.showCreateWizard(ui, parentSnapshot);
158
- } else if (choice === "Settings") {
159
- await this.showSettings(ui);
160
- await this.showAgentsMenu(ui, modelRegistry, parentSnapshot);
161
- }
162
- }
163
-
164
- private async showAllAgentsList(ui: MenuUI, modelRegistry: ModelRegistry): Promise<void> {
165
- const allNames = this.registry.getAllTypes();
166
- if (allNames.length === 0) {
167
- ui.notify("No agents.", "info");
168
- return;
169
- }
170
-
171
- const sourceIndicator = (cfg: AgentConfig | undefined) => {
172
- const disabled = cfg?.enabled === false;
173
- if (cfg?.source === "project") return disabled ? "✕• " : "• ";
174
- if (cfg?.source === "global") return disabled ? "✕◦ " : "◦ ";
175
- if (disabled) return "✕ ";
176
- return " ";
177
- };
178
-
179
- const entries = allNames.map((name) => {
180
- const cfg = this.registry.resolveAgentConfig(name);
181
- const disabled = cfg.enabled === false;
182
- const model = this.getModelLabel(name, modelRegistry);
183
- const indicator = sourceIndicator(cfg);
184
- const prefix = `${indicator}${name} · ${model}`;
185
- const desc = disabled ? "(disabled)" : cfg.description;
186
- return { name, prefix, desc };
187
- });
188
- const maxPrefix = Math.max(...entries.map((e) => e.prefix.length));
189
-
190
- const hasCustom = allNames.some((n) => {
191
- const c = this.registry.resolveAgentConfig(n);
192
- return !c.isDefault && c.enabled !== false;
193
- });
194
- const hasDisabled = allNames.some(
195
- (n) => this.registry.resolveAgentConfig(n).enabled === false,
196
- );
197
- const legendParts: string[] = [];
198
- if (hasCustom) legendParts.push("• = project ◦ = global");
199
- if (hasDisabled) legendParts.push("✕ = disabled");
200
- const legend = legendParts.length ? "\n" + legendParts.join(" ") : "";
201
-
202
- const options = entries.map(
203
- ({ prefix, desc }) => `${prefix.padEnd(maxPrefix)} — ${desc}`,
204
- );
205
- if (legend) options.push(legend);
206
-
207
- const choice = await ui.select("Agent types", options);
208
- if (!choice) return;
209
-
210
- const agentName = choice
211
- .split(" · ")[0]
212
- .replace(/^[•◦✕\s]+/, "")
213
- .trim();
214
- if (this.registry.resolveType(agentName) != null) {
215
- await this.editor.showAgentDetail(ui, agentName);
216
- await this.showAllAgentsList(ui, modelRegistry);
217
- }
218
- }
219
-
220
- private async showRunningAgents(ui: MenuUI): Promise<void> {
221
- const agents = this.manager.listAgents();
222
- if (agents.length === 0) {
223
- ui.notify("No agents.", "info");
224
- return;
225
- }
226
-
227
- const options = agents.map((a) => {
228
- const dn = getDisplayName(a.type, this.registry);
229
- const dur = formatDuration(a.startedAt, a.completedAt);
230
- return `${dn} (${a.description}) · ${a.toolUses} tools · ${a.status} · ${dur}`;
231
- });
232
-
233
- const choice = await ui.select("Running agents", options);
234
- if (!choice) return;
235
-
236
- const idx = options.indexOf(choice);
237
- if (idx < 0) return;
238
- const record = agents[idx];
239
-
240
- await this.viewAgentConversation(ui, record);
241
- await this.showRunningAgents(ui);
242
- }
243
-
244
- private async viewAgentConversation(ui: MenuUI, record: Subagent): Promise<void> {
245
- if (!record.isSessionReady()) {
246
- ui.notify(
247
- `Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`,
248
- "info",
249
- );
250
- return;
251
- }
252
-
253
- const { ConversationViewer, VIEWPORT_HEIGHT_PCT } = await import(
254
- "./conversation-viewer"
255
- );
256
-
257
- await ui.custom<undefined>(
258
- (tui: any, theme: any, _keybindings: any, done: any) => {
259
- return new ConversationViewer({
260
- tui,
261
- record,
262
- theme,
263
- done,
264
- registry: this.registry,
265
- wrapText: wrapTextWithAnsi,
266
- });
267
- },
268
- {
269
- overlay: true,
270
- overlayOptions: {
271
- anchor: "center",
272
- width: "90%",
273
- maxHeight: `${VIEWPORT_HEIGHT_PCT}%`,
274
- },
275
- },
276
- );
277
- }
278
-
279
- private async showSettings(ui: MenuUI): Promise<void> {
280
- const choice = await ui.select("Settings", [
281
- `Max concurrency (current: ${this.settings.maxConcurrent})`,
282
- `Default max turns (current: ${this.settings.defaultMaxTurns ?? "unlimited"})`,
283
- `Grace turns (current: ${this.settings.graceTurns})`,
284
- ]);
285
- if (!choice) return;
286
-
287
- if (choice.startsWith("Max concurrency")) {
288
- const val = await ui.input(
289
- "Max concurrent background agents",
290
- String(this.settings.maxConcurrent),
291
- );
292
- if (val) {
293
- const n = parseInt(val, 10);
294
- if (n >= 1) {
295
- const toast = this.settings.applyMaxConcurrent(n);
296
- ui.notify(toast.message, toast.level);
297
- } else {
298
- ui.notify("Must be a positive integer.", "warning");
299
- }
300
- }
301
- } else if (choice.startsWith("Default max turns")) {
302
- const val = await ui.input(
303
- "Default max turns before wrap-up (0 = unlimited)",
304
- String(this.settings.defaultMaxTurns ?? 0),
305
- );
306
- if (val) {
307
- const n = parseInt(val, 10);
308
- if (n >= 0) {
309
- const toast = this.settings.applyDefaultMaxTurns(n);
310
- ui.notify(toast.message, toast.level);
311
- } else {
312
- ui.notify("Must be 0 (unlimited) or a positive integer.", "warning");
313
- }
314
- }
315
- } else if (choice.startsWith("Grace turns")) {
316
- const val = await ui.input(
317
- "Grace turns after wrap-up steer",
318
- String(this.settings.graceTurns),
319
- );
320
- if (val) {
321
- const n = parseInt(val, 10);
322
- if (n >= 1) {
323
- const toast = this.settings.applyGraceTurns(n);
324
- ui.notify(toast.message, toast.level);
325
- } else {
326
- ui.notify("Must be a positive integer.", "warning");
327
- }
328
- }
329
- }
330
- }
331
- }
@@ -1,241 +0,0 @@
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 Component, matchesKey, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
- import type { AgentConfigLookup } from "#src/config/agent-types";
10
- import { getLifetimeTotal } from "#src/lifecycle/usage";
11
- import type { Subagent } from "#src/types";
12
- import { buildInvocationTags, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel, type Theme } from "#src/ui/display";
13
- import { formatMessage, formatStreamingIndicator } from "#src/ui/message-formatters";
14
-
15
- // ─────────────────────────────────────────────────────────────────────────────
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 interface ConversationViewerOptions {
24
- tui: TUI;
25
- record: Subagent;
26
- theme: Theme;
27
- done: (result: undefined) => void;
28
- registry: AgentConfigLookup;
29
- wrapText: (text: string, width: number) => string[];
30
- }
31
-
32
- export class ConversationViewer implements Component {
33
- private scrollOffset = 0;
34
- private autoScroll = true;
35
- private unsubscribe: (() => void) | undefined;
36
- private lastInnerW = 0;
37
- private closed = false;
38
-
39
- private tui: TUI;
40
- private record: Subagent;
41
- private theme: Theme;
42
- private done: (result: undefined) => void;
43
- private registry: AgentConfigLookup;
44
- private wrapText: (text: string, width: number) => string[];
45
-
46
- constructor({
47
- tui,
48
- record,
49
- theme,
50
- done,
51
- registry,
52
- wrapText,
53
- }: ConversationViewerOptions) {
54
- this.tui = tui;
55
- this.record = record;
56
- this.theme = theme;
57
- this.done = done;
58
- this.registry = registry;
59
- this.wrapText = wrapText;
60
- this.unsubscribe = record.subscribeToUpdates(() => {
61
- if (this.closed) return;
62
- this.tui.requestRender();
63
- });
64
- }
65
-
66
- // fallow-ignore-next-line unused-class-member
67
- handleInput(data: string): void {
68
- if (matchesKey(data, "escape") || matchesKey(data, "q")) {
69
- this.closed = true;
70
- this.done(undefined);
71
- return;
72
- }
73
-
74
- const totalLines = this.buildContentLines(this.lastInnerW).length;
75
- const viewportHeight = this.viewportHeight();
76
- const maxScroll = Math.max(0, totalLines - viewportHeight);
77
-
78
- if (matchesKey(data, "up") || matchesKey(data, "k")) {
79
- this.scrollOffset = Math.max(0, this.scrollOffset - 1);
80
- this.autoScroll = this.scrollOffset >= maxScroll;
81
- } else if (matchesKey(data, "down") || matchesKey(data, "j")) {
82
- this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
83
- this.autoScroll = this.scrollOffset >= maxScroll;
84
- } else if (matchesKey(data, "pageUp") || matchesKey(data, "shift+up")) {
85
- this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
86
- this.autoScroll = false;
87
- } else if (matchesKey(data, "pageDown") || matchesKey(data, "shift+down")) {
88
- this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
89
- this.autoScroll = this.scrollOffset >= maxScroll;
90
- } else if (matchesKey(data, "home")) {
91
- this.scrollOffset = 0;
92
- this.autoScroll = false;
93
- } else if (matchesKey(data, "end")) {
94
- this.scrollOffset = maxScroll;
95
- this.autoScroll = true;
96
- }
97
- }
98
-
99
- render(width: number): string[] {
100
- if (width < 6) return []; // too narrow for any meaningful rendering
101
- const th = this.theme;
102
- const innerW = width - 4; // border + padding
103
- this.lastInnerW = innerW;
104
- const lines: string[] = [];
105
-
106
- const pad = (s: string, len: number) => {
107
- const vis = visibleWidth(s);
108
- return s + " ".repeat(Math.max(0, len - vis));
109
- };
110
- const row = (content: string) =>
111
- th.fg("border", "│") + " " + truncateToWidth(pad(content, innerW), innerW) + " " + th.fg("border", "│");
112
- const hrTop = th.fg("border", `╭${"─".repeat(width - 2)}╮`);
113
- const hrBot = th.fg("border", `╰${"─".repeat(width - 2)}╯`);
114
- const hrMid = row(th.fg("dim", "─".repeat(innerW)));
115
-
116
- // Header
117
- lines.push(hrTop);
118
- const name = getDisplayName(this.record.type, this.registry);
119
- const modeLabel = getPromptModeLabel(this.record.type, this.registry);
120
- const modeTag = modeLabel ? ` ${th.fg("dim", `(${modeLabel})`)}` : "";
121
- const statusIcon = this.record.status === "running"
122
- ? th.fg("accent", "●")
123
- : this.record.status === "completed"
124
- ? th.fg("success", "✓")
125
- : this.record.status === "error"
126
- ? th.fg("error", "✗")
127
- : th.fg("dim", "○");
128
- const duration = formatDuration(this.record.startedAt, this.record.completedAt);
129
-
130
- const headerParts: string[] = [duration];
131
- const toolUses = this.record.toolUses;
132
- if (toolUses > 0) headerParts.unshift(`${toolUses} tool${toolUses === 1 ? "" : "s"}`);
133
- const tokens = getLifetimeTotal(this.record.lifetimeUsage);
134
- if (tokens > 0) {
135
- const percent = this.record.getContextPercent();
136
- headerParts.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
137
- }
138
-
139
- lines.push(row(
140
- `${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${th.fg("dim", headerParts.join(" · "))}`,
141
- ));
142
- const invocationLine = this.invocationLine();
143
- if (invocationLine) lines.push(row(invocationLine));
144
- lines.push(hrMid);
145
-
146
- // Content area — rebuild every render (live data, no cache needed)
147
- const contentLines = this.buildContentLines(innerW);
148
- const viewportHeight = this.viewportHeight();
149
- const maxScroll = Math.max(0, contentLines.length - viewportHeight);
150
-
151
- if (this.autoScroll) {
152
- this.scrollOffset = maxScroll;
153
- }
154
-
155
- const visibleStart = Math.min(this.scrollOffset, maxScroll);
156
- const visible = contentLines.slice(visibleStart, visibleStart + viewportHeight);
157
-
158
- for (let i = 0; i < viewportHeight; i++) {
159
- lines.push(row(visible[i] ?? ""));
160
- }
161
-
162
- // Footer
163
- lines.push(hrMid);
164
- const scrollPct = contentLines.length <= viewportHeight
165
- ? "100%"
166
- : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
167
- const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
168
- const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close");
169
- const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
170
- lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
171
- lines.push(hrBot);
172
-
173
- return lines;
174
- }
175
-
176
- // fallow-ignore-next-line unused-class-member
177
- invalidate(): void { /* no cached state to clear */ }
178
-
179
- // fallow-ignore-next-line unused-class-member
180
- dispose(): void {
181
- this.closed = true;
182
- if (this.unsubscribe) {
183
- this.unsubscribe();
184
- this.unsubscribe = undefined;
185
- }
186
- }
187
-
188
- // ---- Private ----
189
-
190
- private viewportHeight(): number {
191
- // Cap mirrors the overlay's maxHeight — otherwise the viewer would render
192
- // more lines than the overlay shows and clip the footer.
193
- const maxRows = Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100);
194
- return Math.max(MIN_VIEWPORT, maxRows - this.chromeLines());
195
- }
196
-
197
- private chromeLines(): number {
198
- return CHROME_LINES_BASE + (this.invocationLine() ? 1 : 0);
199
- }
200
-
201
- private invocationLine(): string | undefined {
202
- const { modelName, tags } = buildInvocationTags(this.record.invocation);
203
- const parts = modelName ? [modelName, ...tags] : tags;
204
- if (parts.length === 0) return undefined;
205
- return this.theme.fg("dim", ` ↳ ${parts.join(" · ")}`);
206
- }
207
-
208
- private buildContentLines(width: number): string[] {
209
- if (width <= 0) return [];
210
-
211
- const th = this.theme;
212
- const ctx = { theme: th, wrapText: this.wrapText };
213
- const messages = this.record.messages;
214
-
215
- if (messages.length === 0) {
216
- return [th.fg("dim", "(waiting for first message...)")];
217
- }
218
-
219
- const lines: string[] = [];
220
- let needsSeparator = false;
221
- for (const msg of messages) {
222
- const formatted = formatMessage(msg as { role: string; [key: string]: unknown }, width, ctx);
223
- if (!formatted) continue;
224
- if (needsSeparator) lines.push(th.fg("dim", "───"));
225
- lines.push(...formatted);
226
- needsSeparator = true;
227
- }
228
-
229
- // Streaming indicator for running agents — read activity off the record
230
- if (this.record.status === "running") {
231
- lines.push(...formatStreamingIndicator(
232
- this.record.activeTools,
233
- this.record.responseText,
234
- width,
235
- th,
236
- ));
237
- }
238
-
239
- return lines.map(l => truncateToWidth(l, width));
240
- }
241
- }