pi-subagents-lite 0.2.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.
@@ -0,0 +1,71 @@
1
+ /**
2
+ * model-precedence.ts — Model resolution with explicit precedence.
3
+ *
4
+ * Pure function — no side effects, no file I/O, no pi SDK imports.
5
+ *
6
+ * Precedence chain (highest to lowest):
7
+ * 1. config.agent[subagentType] (per-type override)
8
+ * 2. config.agent["default"] (global default)
9
+ * 3. agentConfig?.model (agent config / frontmatter)
10
+ * 4. parentModelId (inherit from parent)
11
+ */
12
+
13
+ /** Shape of the subagents-lite.json config file. */
14
+ export interface SubagentsConfig {
15
+ agent: {
16
+ default: string | null;
17
+ [agentType: string]: string | null | undefined;
18
+ };
19
+ concurrency: {
20
+ default: number;
21
+ providers?: Record<string, number>;
22
+ models?: Record<string, number>;
23
+ };
24
+ }
25
+
26
+ /**
27
+ * Resolve the model for a subagent invocation.
28
+ *
29
+ * Returns the first non-null, non-undefined, non-empty-string value
30
+ * from the precedence chain. If all are empty/null, returns parentModelId.
31
+ *
32
+ * @param subagentType - The type of subagent being spawned
33
+ * @param agentConfig - The agent's config (from .md frontmatter or defaults)
34
+ * @param config - The global subagents-lite.json config (model overrides)
35
+ * @param parentModelId - The parent agent's model ID (final fallback)
36
+ * @returns The resolved model ID string
37
+ */
38
+ export function resolveModel(
39
+ subagentType: string,
40
+ agentConfig: { model?: string } | undefined,
41
+ config: SubagentsConfig,
42
+ parentModelId: string,
43
+ ): string {
44
+ // Level 1: per-type override
45
+ const perTypeOverride = config.agent[subagentType];
46
+ if (isValidValue(perTypeOverride)) {
47
+ return perTypeOverride;
48
+ }
49
+
50
+ // Level 2: global default override
51
+ const globalDefault = config.agent["default"];
52
+ if (isValidValue(globalDefault)) {
53
+ return globalDefault;
54
+ }
55
+
56
+ // Level 3: agent config/frontmatter model
57
+ if (agentConfig && isValidValue(agentConfig.model)) {
58
+ return agentConfig.model;
59
+ }
60
+
61
+ // Level 4: parent model (final fallback)
62
+ return parentModelId;
63
+ }
64
+
65
+ /**
66
+ * Check if a value is a valid non-empty model string.
67
+ * Returns true for non-null, non-undefined, non-empty strings.
68
+ */
69
+ function isValidValue(value: string | null | undefined): value is string {
70
+ return typeof value === "string" && value.length > 0;
71
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * model-selector.ts — TUI model selection dialog.
3
+ *
4
+ * Ported from subagent-lazy/model-selector.ts verbatim.
5
+ * Used by the /agents menu for model selection.
6
+ *
7
+ * Reuses the same building blocks as pi's ModelSelectorComponent but without
8
+ * the SettingsManager dependency — no side effects, just callbacks.
9
+ */
10
+
11
+ import {
12
+ Container,
13
+ type Component,
14
+ type Focusable,
15
+ fuzzyFilter,
16
+ getKeybindings,
17
+ Input,
18
+ Spacer,
19
+ Text,
20
+ } from "@earendil-works/pi-tui";
21
+ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
22
+
23
+ // Theme type from ctx.ui.custom() callback — avoid deep import that may not resolve
24
+ // in jiti extension loader. The constructor receives the theme instance directly.
25
+ type Theme = any;
26
+
27
+ /* ------------------------------------------------------------------ */
28
+ /* Types */
29
+ /* ------------------------------------------------------------------ */
30
+
31
+ export interface ModelOption {
32
+ /** "provider/model-id" — the value returned on selection */
33
+ value: string;
34
+ /** Display label (model-id without provider prefix) */
35
+ label: string;
36
+ /** Provider name for badge */
37
+ provider: string;
38
+ }
39
+
40
+ export interface ModelSelectorCallbacks {
41
+ onSelect: (value: string) => void;
42
+ onCancel: () => void;
43
+ }
44
+
45
+ /* ------------------------------------------------------------------ */
46
+ /* ModelSelectorDialog */
47
+ /* ------------------------------------------------------------------ */
48
+
49
+ const MAX_VISIBLE = 10;
50
+
51
+ /**
52
+ * A paginated, searchable model selector dialog.
53
+ *
54
+ * Rendering mirrors pi's ModelSelectorComponent:
55
+ * - Top border
56
+ * - Search input
57
+ * - Paginated model list (10 at a time, centered on selection)
58
+ * - Scroll indicator "(3/47)"
59
+ * - Bottom border
60
+ *
61
+ * Key bindings: up/down/pageup/pagedown/enter/escape + pass-through to search.
62
+ */
63
+ export class ModelSelectorDialog extends Container implements Focusable {
64
+ private searchInput: Input;
65
+ private listContainer: Container;
66
+ private items: ModelOption[];
67
+ private filteredItems: ModelOption[];
68
+ private selectedIndex: number;
69
+ private currentModel: string | null;
70
+ private callbacks: ModelSelectorCallbacks;
71
+ private theme: Theme;
72
+
73
+ // Focusable implementation — propagate to searchInput for IME cursor
74
+ private _focused = false;
75
+
76
+ get focused(): boolean {
77
+ return this._focused;
78
+ }
79
+
80
+ set focused(value: boolean) {
81
+ this._focused = value;
82
+ this.searchInput.focused = value;
83
+ }
84
+
85
+ constructor(
86
+ items: ModelOption[],
87
+ currentModel: string | null,
88
+ callbacks: ModelSelectorCallbacks,
89
+ theme: Theme,
90
+ ) {
91
+ super();
92
+
93
+ this.items = items;
94
+ this.currentModel = currentModel;
95
+ this.callbacks = callbacks;
96
+ this.theme = theme;
97
+ this.filteredItems = [...items];
98
+
99
+ // Pre-select current model if present
100
+ const currentIdx = items.findIndex((m) => m.value === currentModel);
101
+ this.selectedIndex = currentIdx >= 0 ? currentIdx : 0;
102
+
103
+ // Build UI
104
+ this.addChild(new DynamicBorder());
105
+ this.addChild(new Spacer(1));
106
+
107
+ this.searchInput = new Input();
108
+ this.searchInput.onSubmit = () => {
109
+ if (this.filteredItems[this.selectedIndex]) {
110
+ this.callbacks.onSelect(this.filteredItems[this.selectedIndex].value);
111
+ }
112
+ };
113
+ this.addChild(this.searchInput);
114
+ this.addChild(new Spacer(1));
115
+
116
+ this.listContainer = new Container();
117
+ this.addChild(this.listContainer);
118
+ this.addChild(new Spacer(1));
119
+
120
+ this.addChild(new DynamicBorder());
121
+
122
+ this.updateList();
123
+ }
124
+
125
+ /** Handle keyboard input. Delegates non-navigation keys to searchInput. */
126
+ handleInput(keyData: string): void {
127
+ const kb = getKeybindings();
128
+
129
+ // Up — wrap to bottom
130
+ if (kb.matches(keyData, "tui.select.up")) {
131
+ if (this.filteredItems.length === 0) return;
132
+ this.selectedIndex =
133
+ this.selectedIndex === 0
134
+ ? this.filteredItems.length - 1
135
+ : this.selectedIndex - 1;
136
+ this.updateList();
137
+ return;
138
+ }
139
+
140
+ // Down — wrap to top
141
+ if (kb.matches(keyData, "tui.select.down")) {
142
+ if (this.filteredItems.length === 0) return;
143
+ this.selectedIndex =
144
+ this.selectedIndex === this.filteredItems.length - 1
145
+ ? 0
146
+ : this.selectedIndex + 1;
147
+ this.updateList();
148
+ return;
149
+ }
150
+
151
+ // PageUp — jump up one page
152
+ if (kb.matches(keyData, "tui.select.pageUp")) {
153
+ if (this.filteredItems.length === 0) return;
154
+ this.selectedIndex = Math.max(0, this.selectedIndex - MAX_VISIBLE);
155
+ this.updateList();
156
+ return;
157
+ }
158
+
159
+ // PageDown — jump down one page
160
+ if (kb.matches(keyData, "tui.select.pageDown")) {
161
+ if (this.filteredItems.length === 0) return;
162
+ this.selectedIndex = Math.min(
163
+ this.filteredItems.length - 1,
164
+ this.selectedIndex + MAX_VISIBLE,
165
+ );
166
+ this.updateList();
167
+ return;
168
+ }
169
+
170
+ // Enter — confirm selection
171
+ if (kb.matches(keyData, "tui.select.confirm")) {
172
+ const selected = this.filteredItems[this.selectedIndex];
173
+ if (selected) {
174
+ this.callbacks.onSelect(selected.value);
175
+ }
176
+ return;
177
+ }
178
+
179
+ // Escape / Ctrl+C — cancel
180
+ if (kb.matches(keyData, "tui.select.cancel")) {
181
+ this.callbacks.onCancel();
182
+ return;
183
+ }
184
+
185
+ // Everything else → search input (triggers fuzzy filter)
186
+ this.searchInput.handleInput(keyData);
187
+ this.filterModels();
188
+ }
189
+
190
+ invalidate(): void {
191
+ // No cached state to invalidate
192
+ }
193
+
194
+ /* ------------------------------------------------------------------ */
195
+ /* Private helpers */
196
+ /* ------------------------------------------------------------------ */
197
+
198
+ private filterModels(): void {
199
+ const query = this.searchInput.getValue();
200
+ if (!query) {
201
+ this.filteredItems = [...this.items];
202
+ } else {
203
+ this.filteredItems = fuzzyFilter(
204
+ this.items,
205
+ query,
206
+ (item) => `${item.label} ${item.provider} ${item.value}`,
207
+ );
208
+ }
209
+ // Clamp selection index
210
+ this.selectedIndex = Math.min(
211
+ this.selectedIndex,
212
+ Math.max(0, this.filteredItems.length - 1),
213
+ );
214
+ this.updateList();
215
+ }
216
+
217
+ private updateList(): void {
218
+ this.listContainer.clear();
219
+
220
+ const { filteredItems } = this;
221
+ if (filteredItems.length === 0) {
222
+ this.listContainer.addChild(
223
+ new Text(this.theme.fg("muted", " No matching models"), 0, 0),
224
+ );
225
+ return;
226
+ }
227
+
228
+ // Centered scroll window
229
+ const startIndex = Math.max(
230
+ 0,
231
+ Math.min(
232
+ this.selectedIndex - Math.floor(MAX_VISIBLE / 2),
233
+ filteredItems.length - MAX_VISIBLE,
234
+ ),
235
+ );
236
+ const endIndex = Math.min(startIndex + MAX_VISIBLE, filteredItems.length);
237
+
238
+ for (let i = startIndex; i < endIndex; i++) {
239
+ const item = filteredItems[i];
240
+ if (!item) continue;
241
+
242
+ const isSelected = i === this.selectedIndex;
243
+ const isCurrent = item.value === this.currentModel;
244
+
245
+ let line: string;
246
+ if (isSelected) {
247
+ const prefix = this.theme.fg("accent", "→ ");
248
+ const modelText = this.theme.fg("accent", item.label);
249
+ const providerBadge = this.theme.fg("muted", `[${item.provider}]`);
250
+ const checkmark = isCurrent ? this.theme.fg("success", " ✓") : "";
251
+ line = `${prefix}${modelText} ${providerBadge}${checkmark}`;
252
+ } else {
253
+ const modelText = ` ${item.label}`;
254
+ const providerBadge = this.theme.fg("muted", `[${item.provider}]`);
255
+ const checkmark = isCurrent ? this.theme.fg("success", " ✓") : "";
256
+ line = `${modelText} ${providerBadge}${checkmark}`;
257
+ }
258
+
259
+ this.listContainer.addChild(new Text(line, 0, 0));
260
+ }
261
+
262
+ // Scroll indicator when not all items visible
263
+ if (startIndex > 0 || endIndex < filteredItems.length) {
264
+ const scrollInfo = this.theme.fg(
265
+ "muted",
266
+ ` (${this.selectedIndex + 1}/${filteredItems.length})`,
267
+ );
268
+ this.listContainer.addChild(new Text(scrollInfo, 0, 0));
269
+ }
270
+ }
271
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * output-file.ts — Human-readable output logging for agent transcripts.
3
+ *
4
+ * Forked from upstream pi-subagents. Key modifications:
5
+ * - Rewrote from JSONL to human-readable format
6
+ * - Path changed to /tmp/pi-agent-outputs/<agentId>.log (no CID-encoded nesting)
7
+ * - Directory created with 0o700 permissions
8
+ * - Append-only, human-readable, supports `tail -f`
9
+ * - Lines: [USER], [TOOL], [ASSISTANT], [DONE] with ISO timestamps
10
+ */
11
+
12
+ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent";
15
+
16
+ /** Get an ISO 8601 timestamp string suitable for log output. */
17
+ function timestamp(): string {
18
+ return new Date().toISOString();
19
+ }
20
+
21
+ /**
22
+ * Create the output file path for an agent.
23
+ * Path: /tmp/pi-agent-outputs/<agentId>.log
24
+ * Ensures the parent directory exists with 0o700 permissions.
25
+ */
26
+ export function createOutputFilePath(agentId: string): string {
27
+ const dir = "/tmp/pi-agent-outputs";
28
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
29
+ return join(dir, `${agentId}.log`);
30
+ }
31
+
32
+ /**
33
+ * Write the initial user prompt entry to the output file.
34
+ * Format: <ISO timestamp> [USER] <prompt>
35
+ */
36
+ export function writeInitialEntry(
37
+ path: string,
38
+ prompt: string,
39
+ ): void {
40
+ const line = `${timestamp()} [USER] ${prompt}\n`;
41
+ writeFileSync(path, line, "utf-8");
42
+ }
43
+
44
+ /** Split text into non-empty lines, prefixing each with a timestamp and role tag. */
45
+ function splitAndPrefix(text: string, role: string): string {
46
+ return text
47
+ .split("\n")
48
+ .filter(Boolean)
49
+ .map((l) => `${timestamp()} [${role}] ${l}\n`)
50
+ .join("");
51
+ }
52
+
53
+ /** Format a toolUse/toolCall content item as a single log line. */
54
+ function formatToolItem(item: Record<string, unknown>): string {
55
+ const name = item.name ?? item.toolName ?? "unknown";
56
+ // pi-ai ToolCall uses `arguments`, legacy/anthropic format uses `input`
57
+ const rawArgs = (item.arguments ?? item.input) as Record<string, unknown> | undefined;
58
+ let argsStr = "";
59
+ if (rawArgs && typeof rawArgs === "object" && Object.keys(rawArgs).length > 0) {
60
+ const keys = Object.keys(rawArgs);
61
+ if (keys.length === 1) {
62
+ // Single-arg shorthand: read("src/file.ts")
63
+ const val = rawArgs[keys[0]];
64
+ const display = typeof val === "string" && val.length > 200
65
+ ? JSON.stringify(val.slice(0, 200) + "...")
66
+ : JSON.stringify(val);
67
+ argsStr = `(${display})`;
68
+ } else {
69
+ argsStr = ` ${JSON.stringify(rawArgs)}`;
70
+ }
71
+ }
72
+ return `${timestamp()} [TOOL] ${name}${argsStr}\n`;
73
+ }
74
+
75
+ /** Extract text from a user message's content (string or array of items). */
76
+ function extractUserText(content: string | ReadonlyArray<Record<string, unknown>> | undefined): string {
77
+ if (typeof content === "string") return content;
78
+ if (Array.isArray(content)) {
79
+ return content.map((c) => String(c.text ?? "")).join("\n");
80
+ }
81
+ return "";
82
+ }
83
+
84
+ /**
85
+ * Format a single message content item as log lines.
86
+ * Handles text, toolUse/toolCall, and thinking content.
87
+ */
88
+ function formatMessageLine(
89
+ role: "ASSISTANT" | "TOOL" | "USER",
90
+ content: string | ReadonlyArray<Record<string, unknown>> | undefined,
91
+ ): string {
92
+ if (typeof content === "string") {
93
+ return splitAndPrefix(content, role);
94
+ }
95
+
96
+ if (Array.isArray(content)) {
97
+ return content
98
+ .map((item) => {
99
+ if (item.type === "text" && typeof item.text === "string") {
100
+ return splitAndPrefix(item.text, role);
101
+ }
102
+ if (item.type === "toolUse" || item.type === "toolCall") {
103
+ return formatToolItem(item);
104
+ }
105
+ if (item.type === "thinking" && typeof item.thinking === "string") {
106
+ const text = item.redacted ? "[redacted]" : item.thinking;
107
+ return splitAndPrefix(text, "THINKING");
108
+ }
109
+ return "";
110
+ })
111
+ .join("");
112
+ }
113
+
114
+ return "";
115
+ }
116
+
117
+ /**
118
+ * Subscribe to session events and flush new messages to the output file
119
+ * on each turn_end. Returns a cleanup function that writes the DONE line
120
+ * and unsubscribes.
121
+ *
122
+ * The optional stats parameter provides final summary data for the DONE line.
123
+ */
124
+ export function streamToOutputFile(
125
+ session: AgentSession,
126
+ path: string,
127
+ stats?: { turnCount: number; toolUseCount: number; totalTokens: number },
128
+ ): () => void {
129
+ let writtenCount = 1; // initial user prompt already written
130
+
131
+ const flush = () => {
132
+ const messages = session.messages;
133
+ while (writtenCount < messages.length) {
134
+ const msg = messages[writtenCount];
135
+ if (msg.role === "assistant") {
136
+ const lines = formatMessageLine("ASSISTANT", msg.content as any);
137
+ if (lines) {
138
+ try { appendFileSync(path, lines, "utf-8"); } catch { /* ignore write errors */ }
139
+ }
140
+ } else if (msg.role === "user") {
141
+ const text = extractUserText(msg.content as any);
142
+ if (text.trim()) {
143
+ try { appendFileSync(path, `${timestamp()} [USER] ${text}\n`, "utf-8"); } catch { /* ignore */ }
144
+ }
145
+ }
146
+ // NOTE: toolResult messages are enumerated as text content and already
147
+ // included in the assistant message content. No separate TOOL_RESULT role.
148
+ writtenCount++;
149
+ }
150
+ };
151
+
152
+ const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
153
+ if (event.type === "turn_end") flush();
154
+ });
155
+
156
+ return () => {
157
+ // Final flush
158
+ flush();
159
+
160
+ // Write DONE line
161
+ const { turnCount = 0, toolUseCount = 0, totalTokens = 0 } = stats ?? {};
162
+ const tokensStr = totalTokens >= 1000
163
+ ? `${(totalTokens / 1000).toFixed(1)}k tokens`
164
+ : `${totalTokens} tokens`;
165
+ try {
166
+ appendFileSync(
167
+ path,
168
+ `${timestamp()} [DONE] ${turnCount} turns, ${toolUseCount} tool uses, ${tokensStr}\n`,
169
+ "utf-8",
170
+ );
171
+ } catch { /* ignore write errors */ }
172
+
173
+ // Unsubscribe from session events
174
+ unsubscribe();
175
+ };
176
+ }
package/src/prompts.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * prompts.ts — System prompt builder for agents.
3
+ *
4
+ * Every agent gets a fresh context — no inherited parent identity.
5
+ * EnvInfo is imported from types.ts — branch is a string (empty when unknown).
6
+ */
7
+
8
+ import type { AgentConfig, EnvInfo } from "./types.js";
9
+
10
+ /** Extra sections to inject into the system prompt (skills only — no memoryBlock). */
11
+ export interface PromptExtras {
12
+ /** Preloaded skill contents to inject. */
13
+ skillBlocks?: { name: string; content: string }[];
14
+ }
15
+
16
+ /**
17
+ * Build the system prompt for an agent from its config.
18
+ *
19
+ * Always uses fresh-context mode: env header + config.systemPrompt.
20
+ * Prepends an `<active_agent name=""/>` tag so downstream extensions
21
+ * (e.g. permission/policy systems) can resolve per-agent policy.
22
+ *
23
+ * @param extras Optional extra sections to inject (preloaded skills).
24
+ */
25
+ export function buildAgentPrompt(
26
+ config: AgentConfig,
27
+ cwd: string,
28
+ env: EnvInfo,
29
+ extras?: PromptExtras,
30
+ ): string {
31
+ const activeAgentTag = `<active_agent name="${config.name}"/>\n\n`;
32
+
33
+ const envLines = [
34
+ "# Environment",
35
+ `Working directory: ${cwd}`,
36
+ env.isGitRepo ? "Git repository: yes" : "Not a git repository",
37
+ ];
38
+ if (env.isGitRepo && env.branch) {
39
+ envLines.push(`Branch: ${env.branch}`);
40
+ }
41
+ envLines.push(`Platform: ${env.platform}`);
42
+ const envBlock = envLines.join("\n");
43
+
44
+ // Build optional extras suffix (skills only — no memoryBlock)
45
+ const extraSections: string[] = [];
46
+ if (extras?.skillBlocks?.length) {
47
+ for (const skill of extras.skillBlocks) {
48
+ extraSections.push(`\n# Preloaded Skill: ${skill.name}\n${skill.content}`);
49
+ }
50
+ }
51
+ const extrasSuffix = extraSections.length > 0 ? "\n\n" + extraSections.join("\n") : "";
52
+
53
+ const header = `You are a pi coding agent sub-agent.
54
+ You have been invoked to handle a specific task autonomously.
55
+
56
+ ${envBlock}`;
57
+
58
+ return activeAgentTag + header + "\n\n" + config.systemPrompt + extrasSuffix;
59
+ }
60
+
61
+