pi-subagents-lite 1.4.2 → 1.4.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents-lite",
3
- "version": "1.4.2",
3
+ "version": "1.4.4",
4
4
  "description": "Lightweight sub-agents for pi — spawn specialized agents with isolated sessions, tools, and models.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -257,7 +257,6 @@ function compactDefined<T extends Record<string, unknown>>(obj: T): Partial<T> {
257
257
  */
258
258
  export function parseAgentFile(
259
259
  content: string,
260
- _filename: string,
261
260
  source: "user" | "project",
262
261
  ): AgentConfigFromMd {
263
262
  const { frontmatter, body } = parseFrontmatter(content);
@@ -310,7 +309,7 @@ export async function scanAgentFilesInDir(
310
309
  const filePath = path.join(dirPath, entry.name);
311
310
  try {
312
311
  const content = await fs.promises.readFile(filePath, "utf-8");
313
- const info = parseAgentFile(content, entry.name, source);
312
+ const info = parseAgentFile(content, source);
314
313
  if (info.name) {
315
314
  agents.push(info);
316
315
  }
@@ -21,21 +21,14 @@ import {
21
21
  import { getAgentConfig, getConfig, getToolNamesForType, resolveVisibleTools } from "./agent-types.js";
22
22
  import { extractText } from "../prompt/context.js";
23
23
  import type { LifetimeUsage } from "./usage.js";
24
- import { findModelInRegistry } from "../utils.js";
24
+ import { findModelInRegistry, GIT_EXEC_TIMEOUT_MS } from "../utils.js";
25
25
  import { DEFAULT_AGENTS } from "./default-agents.js";
26
26
  import { buildAgentPrompt, type PromptExtras } from "../prompt/prompts.js";
27
27
  import { preloadSkills, loadSkillMeta, type SkillMeta } from "../prompt/skill-loader.js";
28
28
  import { type EnvInfo, type RunCallbacks, type RunTunables, SHORT_ID_LENGTH } from "../types.js";
29
29
  import type { SubagentType, SystemPromptMode } from "./types.js";
30
30
  import { getStore } from "../shell.js";
31
- import { DEFAULT_GRACE_TURNS } from "../config/config-io.js";
32
-
33
- /** Path to custom prompt file. Exported for use in menus.ts. */
34
- export const CUSTOM_PROMPT_PATH = path.join(process.env.HOME || "", ".pi", "agent", "subagents-lite-prompt.md");
35
-
36
-
37
- /** Timeout for quick git commands (branch detection, repo check). */
38
- const GIT_EXEC_TIMEOUT_MS = 5000;
31
+ import { DEFAULT_GRACE_TURNS, CUSTOM_PROMPT_PATH } from "../config/config-io.js";
39
32
 
40
33
  /** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
41
34
  function normalizeMaxTurns(n: number | undefined): number | undefined {
@@ -66,6 +66,15 @@ export function setAgentScanDirs(userDir: string, projectDir: string): void {
66
66
  projectAgentDir = projectDir;
67
67
  }
68
68
 
69
+ /** Scan user and project agent directories, merge with defaults. Returns the merged Map. */
70
+ async function scanAndMerge(options?: { disableDefaultAgents?: boolean }): Promise<Map<string, AgentConfig>> {
71
+ const [userAgents, projectAgents] = await Promise.all([
72
+ scanAgentFilesInDir(userAgentDir, "user"),
73
+ scanAgentFilesInDir(projectAgentDir, "project"),
74
+ ]);
75
+ const defaults = options?.disableDefaultAgents ? new Map<string, AgentConfig>() : DEFAULT_AGENTS;
76
+ return mergeAgents(defaults, userAgents, projectAgents);
77
+ }
69
78
  /**
70
79
  * Scan the known agent directories and register any newly discovered agents
71
80
  * that aren't already in the registry. Returns the number of new agents added.
@@ -77,13 +86,7 @@ export function setAgentScanDirs(userDir: string, projectDir: string): void {
77
86
  * @param options - Optional settings. disableDefaultAgents skips DEFAULT_AGENTS in the merge.
78
87
  */
79
88
  export async function discoverNewAgents(worktreeDir?: string, options?: { disableDefaultAgents?: boolean }): Promise<number> {
80
- const [userAgents, projectAgents] = await Promise.all([
81
- scanAgentFilesInDir(userAgentDir, "user"),
82
- scanAgentFilesInDir(projectAgentDir, "project"),
83
- ]);
84
-
85
- const defaults = options?.disableDefaultAgents ? new Map<string, AgentConfig>() : DEFAULT_AGENTS;
86
- const merged = mergeAgents(defaults, userAgents, projectAgents);
89
+ const merged = await scanAndMerge(options);
87
90
 
88
91
  let count = 0;
89
92
  for (const [name, config] of merged) {
@@ -96,8 +99,6 @@ export async function discoverNewAgents(worktreeDir?: string, options?: { disabl
96
99
  // Scan worktree-local agents (only when worktreeDir is provided)
97
100
  if (worktreeDir) {
98
101
  const worktreeAgents = await scanAgentFilesInDir(worktreeDir, "project");
99
- // Use mergeAgents to convert AgentConfigFromMd to AgentConfig (applies fromMd
100
- // and BASE_DEFAULTS), then add only names not already in the registry.
101
102
  const wtMerged = mergeAgents(new Map(), [], worktreeAgents);
102
103
  for (const [name, config] of wtMerged) {
103
104
  if (!agents.has(name)) {
@@ -302,22 +303,23 @@ function applyGlobalDefaults(
302
303
  };
303
304
  }
304
305
 
306
+ /** Find the first non-hidden config: resolved type, then general-purpose, then undefined. */
307
+ function findActiveConfig(type: string): AgentConfig | undefined {
308
+ const key = resolveType(type);
309
+ const config = key ? agents.get(key) : undefined;
310
+ if (config?.hidden !== true) return config;
311
+ return agents.get("general-purpose");
312
+ }
313
+
305
314
  /** Get config for a type (case-insensitive). Falls back to general-purpose. */
306
315
  export function getConfig(
307
316
  type: string,
308
317
  loadSkillsImplicitly: boolean = true,
309
318
  loadExtensionsImplicitly: boolean = true,
310
319
  ): ResolvedAgentConfig {
311
- const resolvedKey = resolveType(type);
312
- const config = resolvedKey ? agents.get(resolvedKey) : undefined;
313
-
314
- // If config exists and is not hidden, use it; otherwise fall back to general-purpose
315
- const activeConfig = config?.hidden !== true
316
- ? config
317
- : agents.get("general-purpose");
318
-
319
- if (activeConfig && activeConfig.hidden !== true) {
320
- const { skills, extensions, ...rest } = activeConfig;
320
+ const config = findActiveConfig(type);
321
+ if (config) {
322
+ const { skills, extensions, ...rest } = config;
321
323
  const defaults = applyGlobalDefaults(skills, extensions, loadSkillsImplicitly, loadExtensionsImplicitly);
322
324
  return {
323
325
  displayName: rest.displayName ?? rest.name,
@@ -328,7 +330,7 @@ export function getConfig(
328
330
  };
329
331
  }
330
332
 
331
- // Absolute fallback — general-purpose was hidden or missing
333
+ // Absolute fallback — no config found at all
332
334
  const defaults = applyGlobalDefaults(undefined, undefined, loadSkillsImplicitly, loadExtensionsImplicitly);
333
335
  return {
334
336
  displayName: "Agent",
@@ -25,6 +25,13 @@ function findLastSentenceBoundary(text: string): number {
25
25
  }
26
26
  return -1;
27
27
  }
28
+
29
+ /** Format the [DONE] summary line with final stats. */
30
+ function formatDoneLine(stats: { turnCount: number; toolUseCount: number; totalTokens: number; cost: number }): string {
31
+ const tokensStr = `${formatTokens(stats.totalTokens)} tokens`;
32
+ const costStr = `$${stats.cost.toFixed(3)}`;
33
+ return `${timestamp()} [DONE] ${stats.turnCount} turns, ${stats.toolUseCount} tool uses, ${tokensStr}, ${costStr}\n`;
34
+ }
28
35
  /** Max content length for full tool result display — longer results get a summary line. */
29
36
  const MAX_TOOL_RESULT_DISPLAY_LENGTH = 500;
30
37
 
@@ -176,7 +183,7 @@ export function streamToOutputFile(
176
183
 
177
184
  const flushThinkingBuffer = () => {
178
185
  if (thinkingBuffer.length > 0) {
179
- safeAppend(path, splitAndPrefix(thinkingBuffer, "THINKING"));
186
+ safeAppend(path, `${timestamp()} [THINKING] ${thinkingBuffer}\n`);
180
187
  streamedThinkingChars += thinkingBuffer.length;
181
188
  thinkingBuffer = "";
182
189
  }
@@ -225,21 +232,16 @@ export function streamToOutputFile(
225
232
  thinkingBlockInProgress = true;
226
233
  } else if (assistantEvent.type === "thinking_delta") {
227
234
  thinkingBuffer += assistantEvent.delta;
228
- if (thinkingBuffer.length >= bufferSize || thinkingBuffer.includes("\n")) {
235
+ if (thinkingBuffer.length >= bufferSize) {
229
236
  // Round down to nearest sentence boundary when possible
230
- if (thinkingBuffer.length >= bufferSize) {
231
- const boundary = findLastSentenceBoundary(thinkingBuffer);
232
- if (boundary >= 0) {
233
- const flushText = thinkingBuffer.slice(0, boundary + 1);
234
- thinkingBuffer = thinkingBuffer.slice(boundary + 1);
235
- safeAppend(path, splitAndPrefix(flushText, "THINKING"));
236
- streamedThinkingChars += flushText.length;
237
- } else {
238
- // No sentence boundary found, flush at buffer limit
239
- flushThinkingBuffer();
240
- }
237
+ const boundary = findLastSentenceBoundary(thinkingBuffer);
238
+ if (boundary >= 0) {
239
+ const flushText = thinkingBuffer.slice(0, boundary + 1);
240
+ thinkingBuffer = thinkingBuffer.slice(boundary + 1);
241
+ safeAppend(path, `${timestamp()} [THINKING] ${flushText}\n`);
242
+ streamedThinkingChars += flushText.length;
241
243
  } else {
242
- // Newline found before buffer limit
244
+ // No sentence boundary found, flush at buffer limit
243
245
  flushThinkingBuffer();
244
246
  }
245
247
  }
@@ -249,7 +251,7 @@ export function streamToOutputFile(
249
251
  flushThinkingBuffer();
250
252
  if (assistantEvent.content && assistantEvent.content.length > streamedThinkingChars) {
251
253
  const remaining = assistantEvent.content.slice(streamedThinkingChars);
252
- safeAppend(path, splitAndPrefix(remaining, "THINKING"));
254
+ safeAppend(path, `${timestamp()} [THINKING] ${remaining}\n`);
253
255
  streamedThinkingChars = assistantEvent.content.length;
254
256
  }
255
257
  streamedThinkingBlocks++;
@@ -264,10 +266,8 @@ export function streamToOutputFile(
264
266
  flush();
265
267
 
266
268
  // Write DONE line
267
- const { turnCount = 0, toolUseCount = 0, totalTokens = 0, cost = 0 } = stats ?? {};
268
- const tokensStr = `${formatTokens(totalTokens)} tokens`;
269
- const costStr = `$${cost.toFixed(3)}`;
270
- safeAppend(path, `${timestamp()} [DONE] ${turnCount} turns, ${toolUseCount} tool uses, ${tokensStr}, ${costStr}\n`);
269
+ const doneStats = stats ?? { turnCount: 0, toolUseCount: 0, totalTokens: 0, cost: 0 };
270
+ safeAppend(path, formatDoneLine(doneStats));
271
271
 
272
272
  // Unsubscribe from session events
273
273
  unsubscribe();
@@ -336,9 +336,7 @@ export class AgentOutputLog {
336
336
  this.statsRef = undefined;
337
337
  } else {
338
338
  // No attach was called — write DONE directly
339
- const tokensStr = `${formatTokens(stats.totalTokens)} tokens`;
340
- const costStr = `$${stats.cost.toFixed(3)}`;
341
- safeAppend(this.path, `${timestamp()} [DONE] ${stats.turnCount} turns, ${stats.toolUseCount} tool uses, ${tokensStr}, ${costStr}\n`);
339
+ safeAppend(this.path, formatDoneLine(stats));
342
340
  }
343
341
  }
344
342
  }
@@ -52,16 +52,9 @@ function errorResult(text: string, details?: Record<string, unknown>) {
52
52
  * Consolidates the identical field-selection logic previously duplicated
53
53
  * across emitIndividualNudge, executeSpawnForeground, and executeSpawnBackground.
54
54
  */
55
- interface AgentDetailsOptions {
56
- /** Include full stats (turns, tokens, context%, compactions, cost). Default: false. */
57
- includeStats?: boolean;
58
- /** Include status and outputFile. Default: false. */
59
- includeStatus?: boolean;
60
- }
61
-
62
55
  export function buildAgentDetails(
63
56
  record: AgentRecord,
64
- options?: AgentDetailsOptions,
57
+ opts?: { includeStats?: boolean; includeStatus?: boolean },
65
58
  ): Record<string, unknown> {
66
59
  const details: Record<string, unknown> = {
67
60
  type: record.display.type,
@@ -72,12 +65,12 @@ export function buildAgentDetails(
72
65
  details.worktreePath = record.display.worktreePath;
73
66
  }
74
67
 
75
- if (options?.includeStatus) {
68
+ if (opts?.includeStatus) {
76
69
  details.status = record.lifecycle.status;
77
70
  details.outputFile = record.display.outputFile;
78
71
  }
79
72
 
80
- if (options?.includeStats) {
73
+ if (opts?.includeStats) {
81
74
  const elapsedMs = record.lifecycle.completedAt ? record.lifecycle.completedAt - record.lifecycle.startedAt : 0;
82
75
 
83
76
  details.turnCount = record.stats.turnCount;
@@ -29,17 +29,10 @@ type SessionStatsLike = {
29
29
  };
30
30
  export type SessionLike = { getSessionStats(): SessionStatsLike };
31
31
 
32
- /** Format a token count compactly: "12.3k", "1.2M", or raw number. */
33
- export function formatTokens(count: number): string {
32
+ /** Format a token count compactly: "12.3k", "1.2M", or raw number. When compact is true, thousands round to whole numbers. */
33
+ export function formatTokens(count: number, compact = false): string {
34
34
  if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
35
- if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
36
- return `${count}`;
37
- }
38
-
39
- /** Format token count for widget display: rounded to whole number for k. */
40
- export function formatTokensCompact(count: number): string {
41
- if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
42
- if (count >= 1_000) return `${Math.round(count / 1_000)}k`;
35
+ if (count >= 1_000) return compact ? `${Math.round(count / 1_000)}k` : `${(count / 1_000).toFixed(1)}k`;
43
36
  return `${count}`;
44
37
  }
45
38
 
@@ -11,46 +11,57 @@ import type { SubagentsConfig } from "../models/model-precedence.js";
11
11
 
12
12
  const CONFIG_DIR = path.join(process.env.HOME || "", ".pi", "agent");
13
13
  const CONFIG_PATH = path.join(CONFIG_DIR, "subagents-lite.json");
14
-
14
+ /** Path to custom prompt file for subagent system prompts. */
15
+ export const CUSTOM_PROMPT_PATH = path.join(CONFIG_DIR, "subagents-lite-prompt.md");
15
16
  /** Default number of grace turns before an agent is force-stopped. */
16
17
  export const DEFAULT_GRACE_TURNS = 6;
17
18
 
18
- /** Default configuration used when config file doesn't exist or is invalid. */
19
- export const DEFAULT_CONFIG: SubagentsConfig = {
20
- agent: {
21
- default: null,
22
- forceBackground: false,
23
- graceTurns: DEFAULT_GRACE_TURNS,
24
- widgetMaxLines: 12,
25
- // widgetMaxLinesCompact intentionally omitted — derives from widgetMaxLines
26
- widgetDescLengthFull: 50,
27
- widgetDescLengthCompact: 30,
28
- widgetCompact: false,
29
- widgetShortcut: false,
30
- systemPromptMode: "replace",
31
- includeContextFiles: true,
32
- disableDefaultAgents: false,
33
- showTools: true,
34
- showTurns: true,
35
- showInput: true,
36
- showOutput: true,
37
- showContext: true,
38
- showCost: false,
39
- showTime: true,
40
- },
41
- concurrency: { default: 4 },
19
+ /** Valid system prompt modes. */
20
+ export const VALID_SYSTEM_PROMPT_MODES = new Set<string>(["replace", "inherit", "custom"]);
21
+
22
+ /** Default concurrency config — used for resets. */
23
+ export const DEFAULT_CONCURRENCY: SubagentsConfig["concurrency"] = { default: 4 };
24
+
25
+ /** Default agent settings — merged into loaded config so callers get a complete shape. */
26
+ const DEFAULT_AGENT: SubagentsConfig["agent"] = {
27
+ default: null,
28
+ forceBackground: false,
29
+ graceTurns: DEFAULT_GRACE_TURNS,
30
+ widgetMaxLines: 12,
31
+ widgetDescLengthFull: 50,
32
+ widgetDescLengthCompact: 30,
33
+ widgetCompact: false,
34
+ widgetShortcut: false,
35
+ systemPromptMode: "replace",
36
+ includeContextFiles: true,
37
+ disableDefaultAgents: false,
38
+ showTools: true,
39
+ showTurns: true,
40
+ showInput: true,
41
+ showOutput: true,
42
+ showContext: true,
43
+ showCost: false,
44
+ showTime: true,
42
45
  };
43
46
 
44
47
  /**
45
- * Read config from disk. Returns defaults if file doesn't exist or is invalid.
48
+ * Read config from disk. Merges loaded values over defaults so the result
49
+ * is always a complete SubagentsConfig — no partial shapes for callers to handle.
46
50
  */
47
51
  export function loadConfig(): SubagentsConfig {
52
+ let raw: SubagentsConfig;
48
53
  try {
49
- const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
50
- return JSON.parse(raw) as SubagentsConfig;
54
+ raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")) as SubagentsConfig;
51
55
  } catch {
52
- return { ...DEFAULT_CONFIG, agent: { ...DEFAULT_CONFIG.agent }, concurrency: { ...DEFAULT_CONFIG.concurrency } };
56
+ raw = {} as SubagentsConfig;
53
57
  }
58
+
59
+ // @ts-expect-error TS2783: spread may override 'default', which is intentional (loaded value wins)
60
+ const concurrency = { default: 4, ...(raw.concurrency ?? {}) } as SubagentsConfig["concurrency"];
61
+ return {
62
+ agent: { ...DEFAULT_AGENT, ...raw.agent },
63
+ concurrency,
64
+ };
54
65
  }
55
66
 
56
67
  /** Write config to disk with atomic rename. */
@@ -20,10 +20,8 @@ import type { AgentManager } from "../agents/agent-manager.js";
20
20
  import { CONFIG_AGENT_NON_MODEL_KEYS } from "./types.js";
21
21
  import type { SystemPromptMode } from "../agents/types.js";
22
22
  import type { ThinkingLevel } from "../types.js";
23
- import { DEFAULT_CONFIG, loadConfig, saveConfigAtomic } from "./config-io.js";
23
+ import { VALID_SYSTEM_PROMPT_MODES, DEFAULT_CONCURRENCY, loadConfig, saveConfigAtomic } from "./config-io.js";
24
24
 
25
- /** Valid values for systemPromptMode — checked once at module load. */
26
- const VALID_SYSTEM_PROMPT_MODES = new Set<string>(["replace", "inherit", "custom"]);
27
25
 
28
26
  /** Injected persistence adapter. Swap for an in-memory adapter in tests. */
29
27
  export interface ConfigIO {
@@ -108,29 +106,22 @@ export class ConfigStore {
108
106
 
109
107
  get agent(): ResolvedAgentSettings {
110
108
  const a = this.config.agent;
111
- const widgetMaxLines = a.widgetMaxLines ?? DEFAULT_CONFIG.agent.widgetMaxLines ?? 12;
109
+ const widgetMaxLines = a.widgetMaxLines!; // guaranteed by loadConfig default merge
112
110
  const widgetMaxLinesCompact = a.widgetMaxLinesCompact ?? Math.floor(widgetMaxLines / 2);
113
- const widgetCompact = a.widgetCompact === true;
114
- const widgetShortcut = a.widgetShortcut === true;
115
- const widgetDescLengthFull = a.widgetDescLengthFull ?? DEFAULT_CONFIG.agent.widgetDescLengthFull ?? 50;
116
- const widgetDescLengthCompact = a.widgetDescLengthCompact ?? DEFAULT_CONFIG.agent.widgetDescLengthCompact ?? 30;
117
- const rawMode = a.systemPromptMode;
118
- const systemPromptMode = VALID_SYSTEM_PROMPT_MODES.has(rawMode as string) ? rawMode as SystemPromptMode : "replace";
119
- const includeContextFiles = a.includeContextFiles ?? DEFAULT_CONFIG.agent.includeContextFiles ?? true;
120
111
 
121
112
  return {
122
113
  defaultModel: a.default ?? null,
123
114
  forceBackground: a.forceBackground === true,
124
115
  showCost: this.sessionShowCost ?? (a.showCost === true),
125
- graceTurns: a.graceTurns ?? DEFAULT_CONFIG.agent.graceTurns ?? 6,
116
+ graceTurns: a.graceTurns ?? 6,
126
117
  widgetMaxLines,
127
118
  widgetMaxLinesCompact,
128
- widgetCompact,
129
- widgetShortcut,
130
- widgetDescLengthFull,
131
- widgetDescLengthCompact,
132
- systemPromptMode,
133
- includeContextFiles,
119
+ widgetCompact: a.widgetCompact === true,
120
+ widgetShortcut: a.widgetShortcut === true,
121
+ widgetDescLengthFull: a.widgetDescLengthFull ?? 50,
122
+ widgetDescLengthCompact: a.widgetDescLengthCompact ?? 30,
123
+ systemPromptMode: VALID_SYSTEM_PROMPT_MODES.has(a.systemPromptMode as string) ? (a.systemPromptMode as SystemPromptMode) : "replace",
124
+ includeContextFiles: a.includeContextFiles ?? true,
134
125
  defaultThinking: a.defaultThinking as ThinkingLevel | undefined,
135
126
  defaultMaxTurns: a.defaultMaxTurns,
136
127
  loadSkillsImplicitly: a.loadSkillsImplicitly !== false,
@@ -344,7 +335,7 @@ export class ConfigStore {
344
335
  this.applyConcurrency();
345
336
  },
346
337
  reset: (): void => {
347
- this.config.concurrency = { ...DEFAULT_CONFIG.concurrency };
338
+ this.config.concurrency = { ...DEFAULT_CONCURRENCY };
348
339
  this.persist();
349
340
  this.applyConcurrency();
350
341
  },
@@ -1,5 +1,5 @@
1
1
  import { getStatusNote } from "../status-note.js";
2
- import { getWidget } from "../shell.js";
2
+ import { getPiInstance, getSessionCtx, getWidget } from "../shell.js";
3
3
  /**
4
4
  * spawn-coordinator.ts — Spawn-and-track coordination for subagents.
5
5
  *
@@ -58,15 +58,15 @@ export class SpawnCoordinator {
58
58
  /** Agent IDs spawned as background — only these trigger a nudge on completion. */
59
59
  private backgroundAgentIds = new Set<string>();
60
60
 
61
+ /** Captured ExtensionContext per background agent, bound to the spawning session. */
62
+ private backgroundContexts = new Map<string, ExtensionContext>();
63
+
61
64
  /** Pending nudge agent IDs, batched within the delay window. */
62
65
  private pendingNudges = new Set<string>();
63
66
 
64
67
  /** Active nudge timer. */
65
68
  private nudgeTimer: ReturnType<typeof setTimeout> | null = null;
66
69
 
67
- /** Latest ExtensionAPI reference, refreshed on each spawn() call. */
68
- private pi: ExtensionAPI | null = null;
69
-
70
70
  /** Set during dispose to prevent nudge emission after session replacement. */
71
71
  private disposed = false;
72
72
 
@@ -109,14 +109,12 @@ export class SpawnCoordinator {
109
109
  widget.ensureTimer();
110
110
  }
111
111
 
112
- // Track background agents
112
+ // Track background agents + capture ctx for fallback notification
113
113
  if (intent.runInBackground) {
114
114
  this.backgroundAgentIds.add(agentId);
115
+ this.backgroundContexts.set(agentId, ctx);
115
116
  }
116
117
 
117
- // Store latest pi reference for nudge delivery
118
- this.pi = pi;
119
-
120
118
  const record = this.manager.getRecord(agentId)!;
121
119
 
122
120
  if (!intent.runInBackground) {
@@ -184,6 +182,7 @@ export class SpawnCoordinator {
184
182
  this.pendingNudges.clear();
185
183
  this.liveViews.clear();
186
184
  this.backgroundAgentIds.clear();
185
+ this.backgroundContexts.clear();
187
186
  this.disposed = true;
188
187
  }
189
188
 
@@ -212,11 +211,9 @@ export class SpawnCoordinator {
212
211
  // Skip if disposed — prevents stale pi usage after session replacement
213
212
  if (this.disposed) return;
214
213
 
215
- // Skip if no pi instance yet (no spawn has occurred)
216
- if (!this.pi) {
217
- console.warn(`subagent nudge skipped for ${agentId}: no pi instance available (no spawn has occurred yet)`);
218
- return;
219
- }
214
+ // Read pi from shell at call time so we get a fresh reference after reload.
215
+ const pi = getPiInstance();
216
+ if (!pi) return;
220
217
 
221
218
  const record = this.manager.getRecord(agentId);
222
219
  if (!record) return;
@@ -227,7 +224,14 @@ export class SpawnCoordinator {
227
224
  });
228
225
 
229
226
  try {
230
- this.pi.sendMessage(
227
+ // Pick delivery mode based on parent session state:
228
+ // - steer: queues while running, delivers before next LLM call
229
+ // - followUp: waits for agent to finish, then delivers
230
+ const ctx = getSessionCtx();
231
+ const parentIdle = ctx?.isIdle?.() ?? true;
232
+ const deliverAs = parentIdle ? "followUp" : "steer";
233
+
234
+ pi.sendMessage(
231
235
  {
232
236
  customType: "subagent-result",
233
237
  content: `[Subagent "${record.display.type}" ${record.lifecycle.status}]\n\n${record.result ?? ""} ${getStatusNote(record.lifecycle.status)}`,
@@ -235,13 +239,26 @@ export class SpawnCoordinator {
235
239
  display: true,
236
240
  },
237
241
  {
238
- deliverAs: "steer",
242
+ deliverAs,
239
243
  triggerTurn: true,
240
244
  },
241
245
  );
242
246
  } catch (error) {
243
- // Defense-in-depth: pi may be stale after session replacement/reload.
244
- console.warn(`subagent nudge failed for ${agentId}:`, error);
247
+ // sendMessage failed (shared runtime overwritten by subagent bindCore).
248
+ // Fall back to UI notification using the captured spawning-session context.
249
+ const spawnCtx = this.backgroundContexts.get(agentId);
250
+ if (spawnCtx?.ui?.notify) {
251
+ try {
252
+ spawnCtx.ui.notify(
253
+ `[Subagent "${record.display.type}" ${record.lifecycle.status}] Result available`,
254
+ "info",
255
+ );
256
+ } catch {
257
+ // ctx may also be stale if session was replaced
258
+ }
259
+ }
260
+ } finally {
261
+ this.backgroundContexts.delete(agentId);
245
262
  }
246
263
  }
247
264
  }
@@ -10,10 +10,7 @@
10
10
 
11
11
  import * as path from "node:path";
12
12
  import { existsSync, statSync, realpathSync } from "node:fs";
13
-
14
- /** Timeout for git commands (ms). */
15
- const GIT_EXEC_TIMEOUT_MS = 5000;
16
-
13
+ import { GIT_EXEC_TIMEOUT_MS } from "../utils.js";
17
14
  /** Specific error messages returned to the LLM for self-correction. */
18
15
  export const WORKTREE_VALIDATION_ERRORS = {
19
16
  PATH_DOES_NOT_EXIST: "worktree_path does not exist: the specified path was not found on disk",
@@ -56,7 +56,6 @@ const TOOL_DISPLAY: Record<string, string> = {
56
56
  write: "writing",
57
57
  grep: "searching",
58
58
  find: "finding files",
59
- ls: "listing",
60
59
  };
61
60
 
62
61
  // ---- Types ----
@@ -136,6 +135,14 @@ function describeActivity(activeTools: Map<string, string>, responseText?: strin
136
135
  return THINKING_TEXT;
137
136
  }
138
137
 
138
+ /** Build the worktree/output continuation line parts for an agent record. */
139
+ function buildWorktreeOutputParts(a: AgentRecord): string[] {
140
+ const parts: string[] = [];
141
+ if (a.display.worktreeLabel) parts.push(`@${a.display.worktreeLabel}`);
142
+ if (a.display.outputFile) parts.push(`tail -f ${a.display.outputFile}`);
143
+ return parts;
144
+ }
145
+
139
146
  // ---- Widget manager ----
140
147
 
141
148
  export class AgentWidget {
@@ -375,10 +382,8 @@ export class AgentWidget {
375
382
  for (const a of finished) {
376
383
  const continuations: string[] = [];
377
384
  if (!this.isCompact()) {
378
- if (a.display.outputFile || a.display.worktreeLabel) {
379
- const parts: string[] = [];
380
- if (a.display.worktreeLabel) parts.push(`@${a.display.worktreeLabel}`);
381
- if (a.display.outputFile) parts.push(`tail -f ${a.display.outputFile}`);
385
+ const parts = buildWorktreeOutputParts(a);
386
+ if (parts.length > 0) {
382
387
  continuations.push(truncate(theme.fg("dim", `${VLINE} ${parts.join(" ")}`)));
383
388
  }
384
389
  }
@@ -418,10 +423,8 @@ export class AgentWidget {
418
423
  const fullDesc = truncateDesc(a.display.description, this.descLengthFull);
419
424
  const headerLine = `${BRANCH} ${theme.fg("accent", frame)} ${theme.bold(name)} ${fullDesc} ${statsLine}`;
420
425
  const continuations: string[] = [];
421
- if (a.display.outputFile || a.display.worktreeLabel) {
422
- const parts: string[] = [];
423
- if (a.display.worktreeLabel) parts.push(`@${a.display.worktreeLabel}`);
424
- if (a.display.outputFile) parts.push(`tail -f ${a.display.outputFile}`);
426
+ const parts = buildWorktreeOutputParts(a);
427
+ if (parts.length > 0) {
425
428
  continuations.push(truncate(`${VLINE} ` + theme.fg("dim", `${VLINE} ${parts.join(" ")}`)));
426
429
  }
427
430
  continuations.push(truncate(`${VLINE} ` + theme.fg("dim", `└ ${activity}`)));
@@ -571,15 +574,14 @@ export class AgentWidget {
571
574
  }
572
575
 
573
576
  // Overflow summary line
574
- const overflowLine = hiddenRunning + hiddenFinished > 0
575
- ? (() => {
576
- const parts: string[] = [];
577
- if (hiddenRunning > 0) parts.push(`${hiddenRunning} running`);
578
- if (hiddenFinished > 0) parts.push(`${hiddenFinished} finished`);
579
- const summary = `+${hiddenRunning + hiddenFinished} more (${parts.join(", ")})`;
580
- return `${theme.fg("dim", CORNER)} ${theme.fg("dim", summary)}`;
581
- })()
582
- : undefined;
577
+ let overflowLine: string | undefined;
578
+ if (hiddenRunning + hiddenFinished > 0) {
579
+ const parts: string[] = [];
580
+ if (hiddenRunning > 0) parts.push(`${hiddenRunning} running`);
581
+ if (hiddenFinished > 0) parts.push(`${hiddenFinished} finished`);
582
+ const summary = `+${hiddenRunning + hiddenFinished} more (${parts.join(", ")})`;
583
+ overflowLine = `${theme.fg("dim", CORNER)} ${theme.fg("dim", summary)}`;
584
+ }
583
585
 
584
586
  return { visible, overflowLine };
585
587
  }
package/src/ui/format.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  import { getConfig } from "../agents/agent-types.js";
12
12
  import type { SubagentType } from "../agents/types.js";
13
13
  import type { Theme } from "./types.js";
14
- import { formatTokens, formatTokensCompact, formatCost } from "../agents/usage.js";
14
+ import { formatTokens, formatCost } from "../agents/usage.js";
15
15
 
16
16
  /** Truncate a description string to `maxLen` characters, appending "..." if truncated. */
17
17
  export function truncateDesc(text: string, maxLen: number): string {
@@ -44,8 +44,8 @@ function formatSessionTokens(
44
44
  compactions = 0,
45
45
  ): string {
46
46
  const tokenParts: string[] = [];
47
- if (inputTokens > 0) tokenParts.push(`↑${formatTokensCompact(inputTokens)}`);
48
- if (outputTokens > 0) tokenParts.push(`↓${formatTokensCompact(outputTokens)}`);
47
+ if (inputTokens > 0) tokenParts.push(`↑${formatTokens(inputTokens, true)}`);
48
+ if (outputTokens > 0) tokenParts.push(`↓${formatTokens(outputTokens, true)}`);
49
49
  const tokenStr = tokenParts.join("");
50
50
  const annot: string[] = [];
51
51
  if (percent !== null) {
@@ -5,9 +5,9 @@
5
5
  * searchable pick-list submenu factory.
6
6
  */
7
7
  import type { Component, SettingsListTheme } from "@earendil-works/pi-tui";
8
+ import type { Theme } from "../types.js";
8
9
  import { SearchableSelectDialog, type SelectOption } from "../searchable-select.js";
9
10
  import { parseModelKey } from "../../utils.js";
10
-
11
11
  /**
12
12
  * Build SelectOption[] from raw "provider/model-id" strings.
13
13
  * Includes "(inherits parent)" as the first option.
@@ -103,7 +103,7 @@ export function buildSelectListTheme(theme: { fg(color: string, text: string): s
103
103
  export function createSearchableSelect(
104
104
  items: SelectOption[],
105
105
  callbacks: { onSelect: (value: string) => Component | void; onCancel: () => void },
106
- theme: any,
106
+ theme: Theme,
107
107
  ): Component {
108
108
  let delegator: ReturnType<typeof createDelegatingComponent>;
109
109
  const selector = new SearchableSelectDialog(
@@ -23,13 +23,14 @@ import { createConfirmSubmenu } from "./submenus/confirm.js";
23
23
  import { SettingsListWrapper } from "./wrappers/settings-list.js";
24
24
  import { getStore } from "../../shell.js";
25
25
  import type { SelectOption } from "../searchable-select.js";
26
+ import type { Theme } from "../types.js";
26
27
 
27
28
  export async function showConcurrencySettingsMenu(
28
29
  ctx: ExtensionCommandContext,
29
30
  modelOptions: string[],
30
31
  ): Promise<void> {
31
32
  // Build menu items from current store state.
32
- const buildItems = (store: ReturnType<typeof getStore>, theme: any, modelOptions: string[], onRebuild?: () => void): SettingItem[] => {
33
+ const buildItems = (store: ReturnType<typeof getStore>, theme: Theme, modelOptions: string[], onRebuild?: () => void): SettingItem[] => {
33
34
  const providers = [...new Set(modelOptions.map((m) => m.split("/")[0]))].sort();
34
35
  const items: SettingItem[] = [];
35
36
 
@@ -12,6 +12,7 @@
12
12
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
13
13
  import { SettingsList, type SettingItem } from "@earendil-works/pi-tui";
14
14
  import { getAgentConfig, getAllTypes } from "../../agents/agent-types.js";
15
+ import type { Theme } from "../types.js";
15
16
  import { CONFIG_AGENT_NON_MODEL_KEYS } from "../../config/types.js";
16
17
  import { buildSettingsListTheme, createSearchableSelect } from "./helpers.js";
17
18
  import { createModelSelectSubmenu } from "./submenus/model-select.js";
@@ -24,7 +25,7 @@ export async function showModelSettingsMenu(
24
25
  modelOptions: string[],
25
26
  ): Promise<void> {
26
27
  // Build menu items from current store state.
27
- const buildItems = (store: ReturnType<typeof getStore>, theme: any): SettingItem[] => {
28
+ const buildItems = (store: ReturnType<typeof getStore>, theme: Theme): SettingItem[] => {
28
29
  const items: SettingItem[] = [];
29
30
 
30
31
  // Shared onSelect for model override submenus: applies session/permanent/clear
@@ -22,6 +22,7 @@ import { getDisplayName, truncateDesc } from "../format.js";
22
22
  import { buildSnapshotMarkdown } from "../../prompt/context.js";
23
23
  import { buildSelectListTheme, createDelegatingComponent } from "./helpers.js";
24
24
  import { getManager, getStore } from "../../shell.js";
25
+ import type { Theme } from "../types.js";
25
26
 
26
27
  /**
27
28
  * Show a ResultViewer for an agent's result, error, or snapshot.
@@ -73,7 +74,7 @@ async function showResultViewer(
73
74
  export function buildAgentActionsList(
74
75
  ctx: ExtensionCommandContext,
75
76
  record: AgentRecord,
76
- theme: any,
77
+ theme: Theme,
77
78
  done: () => void,
78
79
  setActive: (c: import("@earendil-works/pi-tui").Component) => void,
79
80
  onClose: () => void,
@@ -11,8 +11,9 @@
11
11
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
12
12
  import { SettingsList, SelectList, type SettingItem } from "@earendil-works/pi-tui";
13
13
  import type { ThinkingLevel } from "../../types.js";
14
+ import type { Theme } from "../types.js";
14
15
  import { getAgentConfig, getAvailableTypes, resolveType, discoverNewAgents } from "../../agents/agent-types.js";
15
- import { findModelInRegistry } from "../../utils.js";
16
+ import { findModelInRegistry, VALID_THINKING_LEVELS } from "../../utils.js";
16
17
  import { buildSettingsListTheme, buildSelectListTheme, createSearchableSelect } from "./helpers.js";
17
18
  import { DEFAULT_GRACE_TURNS } from "../../config/config-io.js";
18
19
  import { createModelSelectSubmenu } from "./submenus/model-select.js";
@@ -120,7 +121,6 @@ async function isInGitRepo(cwd: string): Promise<boolean> {
120
121
  // Spawn agent wizard
121
122
  // ============================================================================
122
123
 
123
- const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
124
124
 
125
125
  /**
126
126
  * Show the spawn agent flow as a multi-step wizard:
@@ -363,7 +363,7 @@ export async function showSpawnAgentMenu(
363
363
  label: "Thinking level",
364
364
  currentValue: currentThinking ?? "inherit",
365
365
  description: "Set the reasoning effort level",
366
- values: [...THINKING_LEVELS, "inherit"],
366
+ values: [...VALID_THINKING_LEVELS, "inherit"],
367
367
  },
368
368
  {
369
369
  id: "maxTokens",
@@ -406,7 +406,7 @@ export async function showSpawnAgentMenu(
406
406
  return items;
407
407
  };
408
408
 
409
- let theme: any;
409
+ let theme: Theme;
410
410
  let doneRef: () => void;
411
411
 
412
412
  await ctx.ui.custom((_tui, t, _kb, done) => {
@@ -17,7 +17,7 @@ import { buildSettingsListTheme } from "./helpers.js";
17
17
  import { SettingsListWrapper } from "./wrappers/settings-list.js";
18
18
  import type { SystemPromptMode } from "../../agents/types.js";
19
19
  import { getStore } from "../../shell.js";
20
- import { CUSTOM_PROMPT_PATH } from "../../agents/agent-runner.js";
20
+ import { CUSTOM_PROMPT_PATH } from "../../config/config-io.js";
21
21
 
22
22
  export async function showSystemPromptMenu(ctx: ExtensionCommandContext): Promise<void> {
23
23
  const store = getStore();
@@ -6,13 +6,14 @@
6
6
  */
7
7
 
8
8
  import { SelectList, type Component } from "@earendil-works/pi-tui";
9
+ import type { Theme } from "../../types.js";
9
10
  import { buildSelectListTheme } from "../helpers.js";
10
11
 
11
12
  export interface ConfirmSubmenuOptions {
12
13
  /** Message shown to the user */
13
14
  message: string;
14
15
  /** Theme from pi-coding-agent (fg, bold, italic) */
15
- theme: any;
16
+ theme: Theme;
16
17
  /** Called when user confirms (selects Yes) */
17
18
  onConfirm: () => void;
18
19
  }
@@ -8,13 +8,14 @@
8
8
  */
9
9
 
10
10
  import { SelectList, type Component } from "@earendil-works/pi-tui";
11
+ import type { Theme } from "../../types.js";
11
12
  import { SearchableSelectDialog, type SelectOption } from "../../../ui/searchable-select.js";
12
13
  import { buildModelOptions, buildSelectListTheme, createDelegatingComponent } from "../helpers.js";
13
14
 
14
15
  export interface ModelSelectSubmenuOptions {
15
16
  modelOptions: string[];
16
17
  showClear: boolean;
17
- theme: any; // Theme from pi-coding-agent (fg, bold, italic)
18
+ theme: Theme;
18
19
  onSelect: (mode: "session" | "permanent" | "clear", model: string | null) => void;
19
20
  }
20
21
 
@@ -17,7 +17,7 @@ import {
17
17
  visibleWidth,
18
18
  } from "@earendil-works/pi-tui";
19
19
  import { type LifetimeUsage, formatTokens } from "../agents/usage.js";
20
- import type { Theme } from "./agent-widget.js";
20
+ import type { Theme } from "./types.js";
21
21
  import { formatMs } from "./format.js";
22
22
 
23
23
  /* ------------------------------------------------------------------ */
@@ -15,7 +15,7 @@ import {
15
15
  Text,
16
16
  } from "@earendil-works/pi-tui";
17
17
  import { DynamicBorder } from "@earendil-works/pi-coding-agent";
18
- import type { Theme } from "./agent-widget.js";
18
+ import type { Theme } from "./types.js";
19
19
 
20
20
  /* ------------------------------------------------------------------ */
21
21
  /* Types */
package/src/utils.ts CHANGED
@@ -86,3 +86,5 @@ export function findModelInRegistry(
86
86
  if (!parsed) return fallback;
87
87
  return registry.find(parsed.provider, parsed.modelId) ?? fallback;
88
88
  }
89
+ /** Timeout for git commands (ms). Shared by agent-runner and worktree-validator. */
90
+ export const GIT_EXEC_TIMEOUT_MS = 5000;