pi-subagents-lite 0.2.0 → 0.3.1

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.
@@ -4,17 +4,20 @@
4
4
  * Pure function — no side effects, no file I/O, no pi SDK imports.
5
5
  *
6
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)
7
+ * 1. sessionOverrides[subagentType] (session per-type override)
8
+ * 2. sessionOverrides["default"] (session global default)
9
+ * 3. config.agent[subagentType] (config per-type override)
10
+ * 4. config.agent["default"] (config global default)
11
+ * 5. agentConfig?.model (agent config / frontmatter)
12
+ * 6. parentModelId (inherit from parent)
11
13
  */
12
14
 
13
15
  /** Shape of the subagents-lite.json config file. */
14
16
  export interface SubagentsConfig {
15
17
  agent: {
16
18
  default: string | null;
17
- [agentType: string]: string | null | undefined;
19
+ forceBackground: boolean;
20
+ [agentType: string]: string | null | undefined | boolean;
18
21
  };
19
22
  concurrency: {
20
23
  default: number;
@@ -23,49 +26,56 @@ export interface SubagentsConfig {
23
26
  };
24
27
  }
25
28
 
29
+ /**
30
+ * Shape of session-only model overrides.
31
+ * Same as config.agent but without the forceBackground flag.
32
+ * Not persisted — cleared on session_start.
33
+ */
34
+ export interface SessionModelOverrides {
35
+ default: string | null;
36
+ [agentType: string]: string | null | undefined;
37
+ }
38
+
39
+ /**
40
+ /** Options for resolveModel. */
41
+ export interface ResolveModelOptions {
42
+ /** The type of subagent being spawned. */
43
+ subagentType: string;
44
+ /** The agent's config (from .md frontmatter or defaults). */
45
+ agentConfig?: { model?: string };
46
+ /** The global subagents-lite.json config (model overrides). */
47
+ config: SubagentsConfig;
48
+ /** The parent agent's model ID (final fallback). */
49
+ parentModelId: string;
50
+ /** Session-only overrides (checked first). */
51
+ sessionOverrides?: SessionModelOverrides;
52
+ }
53
+
26
54
  /**
27
55
  * Resolve the model for a subagent invocation.
28
56
  *
29
57
  * Returns the first non-null, non-undefined, non-empty-string value
30
58
  * 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
59
  */
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
+ export function resolveModel(options: ResolveModelOptions): string {
61
+ const { subagentType, agentConfig, config, parentModelId, sessionOverrides } = options;
60
62
 
61
- // Level 4: parent model (final fallback)
62
- return parentModelId;
63
+ // Precedence chain: session > config > frontmatter > parent
64
+ const candidates: Array<string | boolean | null | undefined> = [
65
+ sessionOverrides?.[subagentType],
66
+ sessionOverrides?.["default"],
67
+ config.agent[subagentType],
68
+ config.agent["default"],
69
+ agentConfig?.model,
70
+ parentModelId, // final fallback (always a valid string)
71
+ ];
72
+ return candidates.find(isValidValue) ?? parentModelId;
63
73
  }
64
74
 
65
75
  /**
66
76
  * Check if a value is a valid non-empty model string.
67
77
  * Returns true for non-null, non-undefined, non-empty strings.
68
78
  */
69
- function isValidValue(value: string | null | undefined): value is string {
79
+ function isValidValue(value: string | boolean | null | undefined): value is string {
70
80
  return typeof value === "string" && value.length > 0;
71
81
  }
@@ -10,7 +10,6 @@
10
10
 
11
11
  import {
12
12
  Container,
13
- type Component,
14
13
  type Focusable,
15
14
  fuzzyFilter,
16
15
  getKeybindings,
@@ -37,7 +36,7 @@ export interface ModelOption {
37
36
  provider: string;
38
37
  }
39
38
 
40
- export interface ModelSelectorCallbacks {
39
+ interface ModelSelectorCallbacks {
41
40
  onSelect: (value: string) => void;
42
41
  onCancel: () => void;
43
42
  }
@@ -126,9 +125,20 @@ export class ModelSelectorDialog extends Container implements Focusable {
126
125
  handleInput(keyData: string): void {
127
126
  const kb = getKeybindings();
128
127
 
128
+ // Navigation keys — no-op when list is empty
129
+ if (this.filteredItems.length === 0) {
130
+ if (
131
+ kb.matches(keyData, "tui.select.up") ||
132
+ kb.matches(keyData, "tui.select.down") ||
133
+ kb.matches(keyData, "tui.select.pageUp") ||
134
+ kb.matches(keyData, "tui.select.pageDown")
135
+ ) {
136
+ return;
137
+ }
138
+ }
139
+
129
140
  // Up — wrap to bottom
130
141
  if (kb.matches(keyData, "tui.select.up")) {
131
- if (this.filteredItems.length === 0) return;
132
142
  this.selectedIndex =
133
143
  this.selectedIndex === 0
134
144
  ? this.filteredItems.length - 1
@@ -139,7 +149,6 @@ export class ModelSelectorDialog extends Container implements Focusable {
139
149
 
140
150
  // Down — wrap to top
141
151
  if (kb.matches(keyData, "tui.select.down")) {
142
- if (this.filteredItems.length === 0) return;
143
152
  this.selectedIndex =
144
153
  this.selectedIndex === this.filteredItems.length - 1
145
154
  ? 0
@@ -150,7 +159,6 @@ export class ModelSelectorDialog extends Container implements Focusable {
150
159
 
151
160
  // PageUp — jump up one page
152
161
  if (kb.matches(keyData, "tui.select.pageUp")) {
153
- if (this.filteredItems.length === 0) return;
154
162
  this.selectedIndex = Math.max(0, this.selectedIndex - MAX_VISIBLE);
155
163
  this.updateList();
156
164
  return;
@@ -158,7 +166,6 @@ export class ModelSelectorDialog extends Container implements Focusable {
158
166
 
159
167
  // PageDown — jump down one page
160
168
  if (kb.matches(keyData, "tui.select.pageDown")) {
161
- if (this.filteredItems.length === 0) return;
162
169
  this.selectedIndex = Math.min(
163
170
  this.filteredItems.length - 1,
164
171
  this.selectedIndex + MAX_VISIBLE,
@@ -242,19 +249,12 @@ export class ModelSelectorDialog extends Container implements Focusable {
242
249
  const isSelected = i === this.selectedIndex;
243
250
  const isCurrent = item.value === this.currentModel;
244
251
 
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
- }
252
+ const modelText = isSelected
253
+ ? this.theme.fg("accent", "→ ") + this.theme.fg("accent", item.label)
254
+ : ` ${item.label}`;
255
+ const providerBadge = this.theme.fg("muted", `[${item.provider}]`);
256
+ const checkmark = isCurrent ? this.theme.fg("success", " ✓") : "";
257
+ const line = `${modelText} ${providerBadge}${checkmark}`;
258
258
 
259
259
  this.listContainer.addChild(new Text(line, 0, 0));
260
260
  }
@@ -13,6 +13,15 @@ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
  import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent";
15
15
 
16
+ /** Max length for a truncated command in tool arg summaries. */
17
+ const MAX_COMMAND_DISPLAY_LENGTH = 100;
18
+
19
+ /** Max length for a truncated string value in default tool arg summaries. */
20
+ const MAX_DEFAULT_STRING_DISPLAY_LENGTH = 200;
21
+
22
+ /** Max content length for full tool result display — longer results get a summary line. */
23
+ const MAX_TOOL_RESULT_DISPLAY_LENGTH = 500;
24
+
16
25
  /** Get an ISO 8601 timestamp string suitable for log output. */
17
26
  function timestamp(): string {
18
27
  return new Date().toISOString();
@@ -20,11 +29,14 @@ function timestamp(): string {
20
29
 
21
30
  /**
22
31
  * Create the output file path for an agent.
23
- * Path: /tmp/pi-agent-outputs/<agentId>.log
32
+ * Default path: /tmp/pi-agent-outputs/<agentId>.log
24
33
  * Ensures the parent directory exists with 0o700 permissions.
34
+ *
35
+ * @param baseDir - Optional base directory (defaults to /tmp/pi-agent-outputs).
36
+ * Provided for testability; production callers omit it.
25
37
  */
26
- export function createOutputFilePath(agentId: string): string {
27
- const dir = "/tmp/pi-agent-outputs";
38
+ export function createOutputFilePath(agentId: string, baseDir?: string): string {
39
+ const dir = baseDir ?? "/tmp/pi-agent-outputs";
28
40
  mkdirSync(dir, { recursive: true, mode: 0o700 });
29
41
  return join(dir, `${agentId}.log`);
30
42
  }
@@ -41,6 +53,14 @@ export function writeInitialEntry(
41
53
  writeFileSync(path, line, "utf-8");
42
54
  }
43
55
 
56
+ /**
57
+ * Safe append — silently ignores write errors.
58
+ * Used for best-effort output file writes that must never throw.
59
+ */
60
+ function safeAppend(path: string, content: string): void {
61
+ try { appendFileSync(path, content, "utf-8"); } catch { /* ignore write errors */ }
62
+ }
63
+
44
64
  /** Split text into non-empty lines, prefixing each with a timestamp and role tag. */
45
65
  function splitAndPrefix(text: string, role: string): string {
46
66
  return text
@@ -50,25 +70,74 @@ function splitAndPrefix(text: string, role: string): string {
50
70
  .join("");
51
71
  }
52
72
 
73
+ /**
74
+ * Summarize tool arguments for log-friendly display.
75
+ *
76
+ * Heavy tools (read, write, edit, bash, grep, rg) get compact summaries.
77
+ * Other tools fall back to the default JSON formatting.
78
+ */
79
+ export function summarizeToolArgs(name: string, rawArgs: Record<string, unknown> | undefined): string {
80
+ if (!rawArgs || typeof rawArgs !== "object" || Object.keys(rawArgs).length === 0) return "";
81
+
82
+ switch (name) {
83
+ case "read": {
84
+ // read("/path/to/file") — just the path
85
+ const path = typeof rawArgs.path === "string" ? rawArgs.path : "";
86
+ return `(${JSON.stringify(path)})`;
87
+ }
88
+ case "write": {
89
+ // write("/path/to/file", <N> chars) — path + content size
90
+ const path = typeof rawArgs.file_path === "string" ? rawArgs.file_path : "";
91
+ const content = rawArgs.content;
92
+ const size = typeof content === "string" ? content.length : 0;
93
+ return `(${JSON.stringify(path)}, ${size} chars)`;
94
+ }
95
+ case "edit": {
96
+ // edit("/path/to/file", <N> edits) — path + edit count
97
+ const path = typeof rawArgs.path === "string" ? rawArgs.path : "";
98
+ const edits = rawArgs.edits;
99
+ const editCount = Array.isArray(edits) ? edits.length : 0;
100
+ return `(${JSON.stringify(path)}, ${editCount} edits)`;
101
+ }
102
+ case "bash": {
103
+ // bash("command") — just the command, strip heredoc, truncate long
104
+ const cmd = typeof rawArgs.command === "string" ? rawArgs.command : "";
105
+ // Strip heredoc: truncate at << followed by delimiter
106
+ const heredocIdx = cmd.search(/<<\s*['"]?\w+['"]?/);
107
+ const cleanCmd = heredocIdx >= 0 ? cmd.slice(0, heredocIdx).trim() : cmd.trim();
108
+ // Truncate long commands
109
+ const display = cleanCmd.length > MAX_COMMAND_DISPLAY_LENGTH
110
+ ? cleanCmd.slice(0, MAX_COMMAND_DISPLAY_LENGTH) + "…" : cleanCmd;
111
+ return `(${JSON.stringify(display)})`;
112
+ }
113
+ case "grep":
114
+ case "rg": {
115
+ // grep("pattern", "/path") — pattern + path
116
+ const pattern = typeof rawArgs.pattern === "string" ? rawArgs.pattern : "";
117
+ const path = typeof rawArgs.path === "string" ? rawArgs.path : "";
118
+ return `(${JSON.stringify(pattern)}, ${JSON.stringify(path)})`;
119
+ }
120
+ default: {
121
+ // Default behavior for other tools: single-arg shorthand or JSON dump
122
+ const keys = Object.keys(rawArgs);
123
+ if (keys.length === 1) {
124
+ const val = rawArgs[keys[0]];
125
+ const display = typeof val === "string" && val.length > MAX_DEFAULT_STRING_DISPLAY_LENGTH
126
+ ? JSON.stringify(val.slice(0, MAX_DEFAULT_STRING_DISPLAY_LENGTH) + "...")
127
+ : JSON.stringify(val);
128
+ return `(${display})`;
129
+ }
130
+ return ` ${JSON.stringify(rawArgs)}`;
131
+ }
132
+ }
133
+ }
134
+
53
135
  /** Format a toolUse/toolCall content item as a single log line. */
54
136
  function formatToolItem(item: Record<string, unknown>): string {
55
- const name = item.name ?? item.toolName ?? "unknown";
137
+ const name = (item.name ?? item.toolName ?? "unknown") as string;
56
138
  // pi-ai ToolCall uses `arguments`, legacy/anthropic format uses `input`
57
139
  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
- }
140
+ const argsStr = summarizeToolArgs(name, rawArgs);
72
141
  return `${timestamp()} [TOOL] ${name}${argsStr}\n`;
73
142
  }
74
143
 
@@ -81,6 +150,29 @@ function extractUserText(content: string | ReadonlyArray<Record<string, unknown>
81
150
  return "";
82
151
  }
83
152
 
153
+ /**
154
+ * Format a tool result message as log line(s), truncating if content is too long.
155
+ *
156
+ * - If content length ≤ MAX_TOOL_RESULT_DISPLAY_LENGTH chars: each line is prefixed with [TOOL_RESULT]
157
+ * - If content length > MAX_TOOL_RESULT_DISPLAY_LENGTH chars: single summary line `[TOOL_RESULT] <toolName>: <N> chars`
158
+ */
159
+ function formatToolResult(toolName: string, content: ReadonlyArray<Record<string, unknown>> | undefined): string {
160
+ if (!content || !Array.isArray(content)) return "";
161
+
162
+ const text = content
163
+ .filter((c): c is { type: "text"; text: string } => c.type === "text" && typeof c.text === "string")
164
+ .map((c) => c.text)
165
+ .join("\n");
166
+
167
+ if (text.length > MAX_TOOL_RESULT_DISPLAY_LENGTH) {
168
+ return `${timestamp()} [TOOL_RESULT] ${toolName}: ${text.length} chars\n`;
169
+ }
170
+
171
+ if (!text.trim()) return "";
172
+
173
+ return splitAndPrefix(text, "TOOL_RESULT");
174
+ }
175
+
84
176
  /**
85
177
  * Format a single message content item as log lines.
86
178
  * Handles text, toolUse/toolCall, and thinking content.
@@ -124,7 +216,7 @@ function formatMessageLine(
124
216
  export function streamToOutputFile(
125
217
  session: AgentSession,
126
218
  path: string,
127
- stats?: { turnCount: number; toolUseCount: number; totalTokens: number },
219
+ stats?: { turnCount: number; toolUseCount: number; totalTokens: number; cost: number },
128
220
  ): () => void {
129
221
  let writtenCount = 1; // initial user prompt already written
130
222
 
@@ -134,17 +226,20 @@ export function streamToOutputFile(
134
226
  const msg = messages[writtenCount];
135
227
  if (msg.role === "assistant") {
136
228
  const lines = formatMessageLine("ASSISTANT", msg.content as any);
137
- if (lines) {
138
- try { appendFileSync(path, lines, "utf-8"); } catch { /* ignore write errors */ }
139
- }
229
+ if (lines) safeAppend(path, lines);
140
230
  } else if (msg.role === "user") {
141
231
  const text = extractUserText(msg.content as any);
142
232
  if (text.trim()) {
143
- try { appendFileSync(path, `${timestamp()} [USER] ${text}\n`, "utf-8"); } catch { /* ignore */ }
233
+ safeAppend(path, `${timestamp()} [USER] ${text}\n`);
144
234
  }
235
+ } else if (msg.role === "toolResult") {
236
+ const msgAny = msg as unknown as Record<string, unknown>;
237
+ const lines = formatToolResult(
238
+ (msgAny.toolName ?? "unknown") as string,
239
+ msgAny.content as ReadonlyArray<Record<string, unknown>> | undefined,
240
+ );
241
+ if (lines) safeAppend(path, lines);
145
242
  }
146
- // NOTE: toolResult messages are enumerated as text content and already
147
- // included in the assistant message content. No separate TOOL_RESULT role.
148
243
  writtenCount++;
149
244
  }
150
245
  };
@@ -158,17 +253,12 @@ export function streamToOutputFile(
158
253
  flush();
159
254
 
160
255
  // Write DONE line
161
- const { turnCount = 0, toolUseCount = 0, totalTokens = 0 } = stats ?? {};
256
+ const { turnCount = 0, toolUseCount = 0, totalTokens = 0, cost = 0 } = stats ?? {};
162
257
  const tokensStr = totalTokens >= 1000
163
258
  ? `${(totalTokens / 1000).toFixed(1)}k tokens`
164
259
  : `${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 */ }
260
+ const costStr = `$${cost.toFixed(3)}`;
261
+ safeAppend(path, `${timestamp()} [DONE] ${turnCount} turns, ${toolUseCount} tool uses, ${tokensStr}, ${costStr}\n`);
172
262
 
173
263
  // Unsubscribe from session events
174
264
  unsubscribe();
package/src/prompts.ts CHANGED
@@ -48,14 +48,14 @@ export function buildAgentPrompt(
48
48
  extraSections.push(`\n# Preloaded Skill: ${skill.name}\n${skill.content}`);
49
49
  }
50
50
  }
51
- const extrasSuffix = extraSections.length > 0 ? "\n\n" + extraSections.join("\n") : "";
51
+ const extrasSuffix = extraSections.length > 0 ? `\n\n${extraSections.join("\n")}` : "";
52
52
 
53
53
  const header = `You are a pi coding agent sub-agent.
54
54
  You have been invoked to handle a specific task autonomously.
55
55
 
56
56
  ${envBlock}`;
57
57
 
58
- return activeAgentTag + header + "\n\n" + config.systemPrompt + extrasSuffix;
58
+ return `${activeAgentTag}${header}\n\n${config.systemPrompt}${extrasSuffix}`;
59
59
  }
60
60
 
61
61