pi-subagents 0.21.0 → 0.21.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.21.1] - 2026-04-30
6
+
7
+ ### Changed
8
+ - Changed the `/agents` new-agent shortcut from `Alt+N` to `Shift+Ctrl+N`, and added `agentManager.newShortcut` config for overriding it.
9
+
10
+ ### Fixed
11
+ - Fall back to polling async result files when native result watching is unavailable due to `EMFILE` or `ENOSPC`.
12
+ - Treat forced final-drain termination after a valid final assistant output as cleanup success instead of failing the subagent run.
13
+ - Hide disabled builtin agents from `subagent({ action: "list" })` output so agent-facing choices match executable runtime discovery.
14
+ - Resolve intercom bridge default paths at runtime so tests and isolated environments that change `HOME` use the correct `pi-intercom` location.
15
+ - Made the tool-description source check tolerant of Windows line endings.
16
+
5
17
  ## [0.21.0] - 2026-04-29
6
18
 
7
19
  ### Changed
package/README.md CHANGED
@@ -374,7 +374,7 @@ Useful keys:
374
374
 
375
375
  - type to search the list
376
376
  - `Enter` opens detail screens
377
- - `Alt+N` creates an agent or chain from a template
377
+ - `Shift+Ctrl+N` creates an agent or chain from a template
378
378
  - `Ctrl+R` launches selected agents as a run or chain
379
379
  - `Ctrl+P` opens the parallel builder
380
380
  - `Tab` selects agents in the list or toggles skip-clarify in task input
@@ -432,7 +432,7 @@ Supported override fields are `model`, `fallbackModels`, `thinking`, `systemProm
432
432
 
433
433
  You can also manage builtin overrides from `/agents`. On a builtin detail screen, press `e`, choose user or project scope if needed, and save the fields you want to override.
434
434
 
435
- Set `disabled: true` to hide a builtin from runtime discovery while keeping it visible in `/agents`. For bulk control, set `subagents.disableBuiltins: true` in settings. Overridden builtins show badges like `[builtin+user]` or `[builtin+project]`; disabled builtins show `off` badges in the manager.
435
+ Set `disabled: true` to hide a builtin from runtime discovery and agent-facing `subagent({ action: "list" })` output while keeping it visible in `/agents`. For bulk control, set `subagents.disableBuiltins: true` in settings. Overridden builtins show badges like `[builtin+user]` or `[builtin+project]`; disabled builtins show `off` badges in the manager.
436
436
 
437
437
  ### Prompt assembly
438
438
 
@@ -833,6 +833,18 @@ Session directory precedence is: `params.sessionDir`, then `config.defaultSessio
833
833
 
834
834
  Controls nested delegation when no inherited `PI_SUBAGENT_MAX_DEPTH` is already in effect. Per-agent `maxSubagentDepth` can tighten the limit for that agent’s child runs, but cannot relax an inherited stricter limit.
835
835
 
836
+ ### `agentManager.newShortcut`
837
+
838
+ ```json
839
+ {
840
+ "agentManager": {
841
+ "newShortcut": "shift+ctrl+n"
842
+ }
843
+ }
844
+ ```
845
+
846
+ Sets the `/agents` list shortcut for opening the new agent/chain template picker. The default is `shift+ctrl+n`; use Pi key names such as `ctrl+n` if your terminal cannot distinguish Shift for control chords.
847
+
836
848
  ### `intercomBridge`
837
849
 
838
850
  ```json
@@ -378,16 +378,12 @@ export function handleList(params: ManagementParams, ctx: ManagementContext): Ag
378
378
  const d = discoverAgentsAll(ctx.cwd);
379
379
  const scopedAgents = allAgents(d).filter((a) => scope === "both" || a.source === "builtin" || a.source === scope).sort((a, b) => a.name.localeCompare(b.name));
380
380
  const agents = scopedAgents.filter((a) => !a.disabled);
381
- const disabledBuiltins = scopedAgents.filter((a) => a.source === "builtin" && a.disabled);
382
381
  const chains = d.chains.filter((c) => scope === "both" || c.source === scope).sort((a, b) => a.name.localeCompare(b.name));
383
382
  const lines = [
384
383
  "Executable agents:",
385
384
  ...(agents.length
386
385
  ? agents.map((a) => `- ${a.name} (${a.source}${a.defaultContext ? `, context: ${a.defaultContext}` : ""}): ${a.description}`)
387
386
  : ["- (none)"]),
388
- ...(disabledBuiltins.length
389
- ? ["", "Disabled builtins:", ...disabledBuiltins.map((a) => `- ${a.name} (${a.source}${a.defaultContext ? `, context: ${a.defaultContext}` : ""}, disabled): ${a.description}`)]
390
- : []),
391
387
  "",
392
388
  "Chains:",
393
389
  ...(chains.length ? chains.map((c) => `- ${c.name} (${c.source}): ${c.description}`) : ["- (none)"]),
@@ -22,6 +22,12 @@ export interface ListState {
22
22
  selected: string[];
23
23
  }
24
24
 
25
+ export interface ListShortcuts {
26
+ newShortcut: string;
27
+ }
28
+
29
+ export const DEFAULT_AGENT_MANAGER_NEW_SHORTCUT = "shift+ctrl+n";
30
+
25
31
  export type ListAction =
26
32
  | { type: "open-detail"; id: string }
27
33
  | { type: "clone"; id: string }
@@ -57,7 +63,7 @@ function clampCursor(state: ListState, filtered: ListAgent[]): void {
57
63
  }
58
64
  }
59
65
 
60
- export function handleListInput(state: ListState, agents: ListAgent[], data: string): ListAction | undefined {
66
+ export function handleListInput(state: ListState, agents: ListAgent[], data: string, shortcuts: ListShortcuts = { newShortcut: DEFAULT_AGENT_MANAGER_NEW_SHORTCUT }): ListAction | undefined {
61
67
  const filtered = fuzzyFilter(agents, state.filterQuery);
62
68
 
63
69
  if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
@@ -98,7 +104,7 @@ export function handleListInput(state: ListState, agents: ListAgent[], data: str
98
104
  return;
99
105
  }
100
106
 
101
- if (matchesKey(data, "alt+n")) {
107
+ if (matchesKey(data, shortcuts.newShortcut)) {
102
108
  return { type: "new" };
103
109
  }
104
110
 
@@ -160,6 +166,7 @@ export function renderList(
160
166
  width: number,
161
167
  theme: Theme,
162
168
  statusMessage?: { text: string; type: "error" | "info" },
169
+ shortcuts: ListShortcuts = { newShortcut: DEFAULT_AGENT_MANAGER_NEW_SHORTCUT },
163
170
  ): string[] {
164
171
  const lines: string[] = [];
165
172
  const filtered = fuzzyFilter(agents, state.filterQuery);
@@ -269,7 +276,7 @@ export function renderList(
269
276
  ? ` [ctrl+r] chain [ctrl+p] parallel [tab] add [shift+tab] remove [esc] clear (${selCount}) `
270
277
  : selCount === 1
271
278
  ? " [ctrl+r] run [ctrl+p] parallel [tab] add more [shift+tab] remove [esc] clear "
272
- : " [enter] view [ctrl+r] run [tab] select [alt+n] new [esc] close ";
279
+ : ` [enter] view [ctrl+r] run [tab] select [${shortcuts.newShortcut}] new [esc] close `;
273
280
  lines.push(renderFooter(footerText, width, theme));
274
281
 
275
282
  return lines;
package/agent-manager.ts CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  import { serializeAgent } from "./agent-serializer.ts";
19
19
  import { TEMPLATE_ITEMS, type AgentTemplate, type TemplateItem } from "./agent-templates.ts";
20
20
  import { parseChain, serializeChain } from "./chain-serializer.ts";
21
- import { renderList, handleListInput, type ListAgent, type ListState, type ListAction } from "./agent-manager-list.ts";
21
+ import { DEFAULT_AGENT_MANAGER_NEW_SHORTCUT, renderList, handleListInput, type ListAgent, type ListShortcuts, type ListState, type ListAction } from "./agent-manager-list.ts";
22
22
  import { createParallelState, handleParallelInput, renderParallel, formatParallelTitle, type ParallelState, type AgentOption } from "./agent-manager-parallel.ts";
23
23
  import { renderDetail, handleDetailInput, renderTaskInput, type DetailState, type DetailAction, type LaunchToggleState } from "./agent-manager-detail.ts";
24
24
  import { renderChainDetail, handleChainDetailInput, type ChainDetailAction, type ChainDetailState } from "./agent-manager-chain-detail.ts";
@@ -43,6 +43,7 @@ interface ChainEntry { id: string; kind: "chain"; config: ChainConfig; }
43
43
  interface NameInputState { mode: "new-agent" | "clone-agent" | "clone-chain" | "new-chain"; editor: TextEditorState; scope: "user" | "project"; allowProject: boolean; sourceId?: string; template?: AgentTemplate; error?: string; }
44
44
  interface StatusMessage { text: string; type: "error" | "info"; }
45
45
  interface OverrideScopeState { selectedScope: "user" | "project"; allowProject: boolean; }
46
+ export interface AgentManagerOptions { newShortcut?: string; }
46
47
 
47
48
  const BUILTIN_OVERRIDE_FIELDS: EditField[] = ["model", "fallbackModels", "thinking", "systemPromptMode", "inheritProjectContext", "inheritSkills", "defaultContext", "disabled", "tools", "skills", "prompt"];
48
49
 
@@ -131,14 +132,16 @@ export class AgentManagerComponent implements Component {
131
132
  private models: ModelInfo[];
132
133
  private skills: SkillInfo[];
133
134
  private done: (result: ManagerResult) => void;
135
+ private shortcuts: ListShortcuts;
134
136
 
135
- constructor(tui: TUI, theme: Theme, agentData: AgentData, models: ModelInfo[], skills: SkillInfo[], done: (result: ManagerResult) => void) {
137
+ constructor(tui: TUI, theme: Theme, agentData: AgentData, models: ModelInfo[], skills: SkillInfo[], done: (result: ManagerResult) => void, options: AgentManagerOptions = {}) {
136
138
  this.tui = tui;
137
139
  this.theme = theme;
138
140
  this.agentData = agentData;
139
141
  this.models = models;
140
142
  this.skills = skills;
141
143
  this.done = done;
144
+ this.shortcuts = { newShortcut: options.newShortcut?.trim() || DEFAULT_AGENT_MANAGER_NEW_SHORTCUT };
142
145
  this.loadEntries();
143
146
  }
144
147
 
@@ -518,7 +521,7 @@ export class AgentManagerComponent implements Component {
518
521
  if (this.screen === "list" && this.statusMessage) this.clearStatus();
519
522
  if (this.screen.startsWith("edit") && this.editState?.error) this.editState.error = undefined;
520
523
  switch (this.screen) {
521
- case "list": { const action = handleListInput(this.listState, this.listAgents(), data); if (action) this.handleListAction(action); this.tui.requestRender(); return; }
524
+ case "list": { const action = handleListInput(this.listState, this.listAgents(), data, this.shortcuts); if (action) this.handleListAction(action); this.tui.requestRender(); return; }
522
525
  case "template-select": this.handleTemplateSelectInput(data); return;
523
526
  case "override-scope": this.handleOverrideScopeInput(data); return;
524
527
  case "detail": {
@@ -671,14 +674,14 @@ export class AgentManagerComponent implements Component {
671
674
  render(width: number): string[] {
672
675
  this.overlayWidth = width; const w = this.overlayWidth;
673
676
  switch (this.screen) {
674
- case "list": return renderList(this.listState, this.listAgents(), w, this.theme, this.statusMessage);
677
+ case "list": return renderList(this.listState, this.listAgents(), w, this.theme, this.statusMessage, this.shortcuts);
675
678
  case "template-select": return this.renderTemplateSelect(w);
676
679
  case "override-scope": return this.renderOverrideScope(w);
677
- case "detail": { const entry = this.getAgentEntry(this.currentAgentId); if (!entry) return renderList(this.listState, this.listAgents(), w, this.theme, this.statusMessage); return renderDetail(this.detailState, entry.config, this.agentData.cwd, w, this.theme); }
678
- case "chain-detail": { const entry = this.getChainEntry(this.currentChainId); if (!entry) return renderList(this.listState, this.listAgents(), w, this.theme, this.statusMessage); return renderChainDetail(this.chainDetailState, entry.config, w, this.theme); }
680
+ case "detail": { const entry = this.getAgentEntry(this.currentAgentId); if (!entry) return renderList(this.listState, this.listAgents(), w, this.theme, this.statusMessage, this.shortcuts); return renderDetail(this.detailState, entry.config, this.agentData.cwd, w, this.theme); }
681
+ case "chain-detail": { const entry = this.getChainEntry(this.currentChainId); if (!entry) return renderList(this.listState, this.listAgents(), w, this.theme, this.statusMessage, this.shortcuts); return renderChainDetail(this.chainDetailState, entry.config, w, this.theme); }
679
682
  case "edit": case "edit-field": case "edit-prompt": return this.editState ? renderEdit(this.screen as EditScreen, this.editState, w, this.theme) : [];
680
683
  case "parallel-builder": {
681
- if (!this.parallelState) return renderList(this.listState, this.listAgents(), w, this.theme, this.statusMessage);
684
+ if (!this.parallelState) return renderList(this.listState, this.listAgents(), w, this.theme, this.statusMessage, this.shortcuts);
682
685
  const agentOptions: AgentOption[] = this.agents.map((e) => ({ name: e.config.name, description: e.config.description, model: e.config.model }));
683
686
  return renderParallel(this.parallelState, agentOptions, w, this.theme);
684
687
  }
package/execution.ts CHANGED
@@ -232,6 +232,8 @@ async function runSingleAttempt(
232
232
  const HARD_KILL_MS = 3000;
233
233
  let childExited = false;
234
234
  let forcedTerminationSignal = false;
235
+ let finalAssistantDelivered = false;
236
+ let finalDrainCleanupWarning: string | undefined;
235
237
  let finalDrainTimer: NodeJS.Timeout | undefined;
236
238
  let finalHardKillTimer: NodeJS.Timeout | undefined;
237
239
  const clearFinalDrainTimers = () => {
@@ -251,8 +253,8 @@ async function runSingleAttempt(
251
253
  const termSent = trySignalChild(proc, "SIGTERM");
252
254
  if (!termSent) return;
253
255
  forcedTerminationSignal = true;
254
- result.error = result.error
255
- ?? `Subagent process did not exit within ${FINAL_DRAIN_MS}ms after its final message. Forcing termination.`;
256
+ finalDrainCleanupWarning = `Subagent process did not exit within ${FINAL_DRAIN_MS}ms after its final message. Forcing termination.`;
257
+ if (!finalAssistantDelivered) result.error = result.error ?? finalDrainCleanupWarning;
256
258
  finalHardKillTimer = setTimeout(() => {
257
259
  if (settled || processClosed || detached) return;
258
260
  forcedTerminationSignal = trySignalChild(proc, "SIGKILL") || forcedTerminationSignal;
@@ -462,7 +464,9 @@ async function runSingleAttempt(
462
464
  const stopReason = (evt.message as { stopReason?: string }).stopReason;
463
465
  const hasToolCall = Array.isArray(evt.message.content)
464
466
  && evt.message.content.some((part) => (part as { type?: string }).type === "toolCall");
467
+ const finalText = extractTextFromContent(evt.message.content).trim();
465
468
  if (stopReason === "stop" && !hasToolCall) {
469
+ finalAssistantDelivered ||= !evt.message.errorMessage && finalText.length > 0;
466
470
  startFinalDrain();
467
471
  }
468
472
  }
@@ -541,10 +545,14 @@ async function runSingleAttempt(
541
545
  }
542
546
  processClosed = true;
543
547
  if (buf.trim()) processLine(buf);
544
- if (code !== 0 && stderrBuf.trim() && !result.error) {
548
+ const forcedDrainAfterFinalSuccess = forcedTerminationSignal && finalAssistantDelivered;
549
+ if (code !== 0 && stderrBuf.trim() && !result.error && !forcedDrainAfterFinalSuccess) {
545
550
  result.error = stderrBuf.trim();
546
551
  }
547
- const finalCode = forcedTerminationSignal || signal ? (code ?? 1) : (code ?? 0);
552
+ if (forcedDrainAfterFinalSuccess && finalDrainCleanupWarning) {
553
+ appendRecentOutput(progress, [`Warning: ${finalDrainCleanupWarning}`]);
554
+ }
555
+ const finalCode = forcedDrainAfterFinalSuccess ? 0 : forcedTerminationSignal || signal ? (code ?? 1) : (code ?? 0);
548
556
  finish(finalCode);
549
557
  });
550
558
  proc.on("error", (error) => {
package/index.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * Toggle: async parameter (default: false, configurable via config.json)
10
10
  *
11
11
  * Config file: ~/.pi/agent/extensions/subagent/config.json
12
- * { "asyncByDefault": true, "forceTopLevelAsync": true, "maxSubagentDepth": 1, "intercomBridge": { "mode": "always", "instructionFile": "./intercom-bridge.md" }, "worktreeSetupHook": "./scripts/setup-worktree.mjs" }
12
+ * { "asyncByDefault": true, "forceTopLevelAsync": true, "maxSubagentDepth": 1, "agentManager": { "newShortcut": "shift+ctrl+n" }, "intercomBridge": { "mode": "always", "instructionFile": "./intercom-bridge.md" }, "worktreeSetupHook": "./scripts/setup-worktree.mjs" }
13
13
  */
14
14
 
15
15
  import * as fs from "node:fs";
@@ -418,7 +418,7 @@ CHAIN TEMPLATE VARIABLES (use in task strings):
418
418
  Example: { chain: [{agent:"agent-a", task:"Analyze {task}"}, {agent:"agent-b", task:"Plan based on {previous}"}] }
419
419
 
420
420
  MANAGEMENT (use action field, omit agent/task/chain/tasks):
421
- • { action: "list" } - discover executable agents/chains and any disabled builtins
421
+ • { action: "list" } - discover executable agents/chains
422
422
  • { action: "get", agent: "name" } - full detail
423
423
  • { action: "create", config: { name, systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, defaultContext, ... } }
424
424
  • { action: "update", agent: "name", config: { ... } } - merge
@@ -475,7 +475,7 @@ DIAGNOSTICS:
475
475
  };
476
476
 
477
477
  pi.registerTool(tool);
478
- registerSlashCommands(pi, state);
478
+ registerSlashCommands(pi, state, config);
479
479
 
480
480
  const eventUnsubscribeStoreKey = "__piSubagentEventUnsubscribes";
481
481
  const controlNoticeSeenStoreKey = "__piSubagentVisibleControlNotices";
@@ -4,9 +4,18 @@ import * as path from "node:path";
4
4
  import type { AgentConfig } from "./agents.ts";
5
5
  import type { ExtensionConfig, IntercomBridgeConfig, IntercomBridgeMode } from "./types.ts";
6
6
 
7
- const DEFAULT_INTERCOM_EXTENSION_DIR = path.join(os.homedir(), ".pi", "agent", "extensions", "pi-intercom");
8
- const DEFAULT_INTERCOM_CONFIG_PATH = path.join(os.homedir(), ".pi", "agent", "intercom", "config.json");
9
- const DEFAULT_SUBAGENT_CONFIG_DIR = path.join(os.homedir(), ".pi", "agent", "extensions", "subagent");
7
+ function defaultIntercomExtensionDir(): string {
8
+ return path.join(os.homedir(), ".pi", "agent", "extensions", "pi-intercom");
9
+ }
10
+
11
+ function defaultIntercomConfigPath(): string {
12
+ return path.join(os.homedir(), ".pi", "agent", "intercom", "config.json");
13
+ }
14
+
15
+ function defaultSubagentConfigDir(): string {
16
+ return path.join(os.homedir(), ".pi", "agent", "extensions", "subagent");
17
+ }
18
+
10
19
  const DEFAULT_INTERCOM_TARGET_PREFIX = "subagent-chat";
11
20
  export const INTERCOM_BRIDGE_MARKER = "Intercom orchestration channel:";
12
21
  const DEFAULT_INTERCOM_BRIDGE_TEMPLATE = `The inherited thread is reference-only. Do not continue that conversation or send questions, status updates, or completion handoffs to the orchestrator in normal assistant text.
@@ -135,9 +144,9 @@ ${instruction}`;
135
144
  export function diagnoseIntercomBridge(input: ResolveIntercomBridgeInput): IntercomBridgeDiagnostic {
136
145
  const config = resolveIntercomBridgeConfig(input.config);
137
146
  const mode = config.mode;
138
- const extensionDir = path.resolve(input.extensionDir ?? DEFAULT_INTERCOM_EXTENSION_DIR);
147
+ const extensionDir = path.resolve(input.extensionDir ?? defaultIntercomExtensionDir());
139
148
  const orchestratorTarget = input.orchestratorTarget?.trim();
140
- const configPath = path.resolve(input.configPath ?? DEFAULT_INTERCOM_CONFIG_PATH);
149
+ const configPath = path.resolve(input.configPath ?? defaultIntercomConfigPath());
141
150
  const wantsIntercom = mode !== "off" && !(mode === "fork-only" && input.context !== "fork");
142
151
  const piIntercomAvailable = fs.existsSync(extensionDir);
143
152
  let configStatus: ReturnType<typeof intercomConfigStatus> | undefined;
@@ -173,9 +182,9 @@ export function diagnoseIntercomBridge(input: ResolveIntercomBridgeInput): Inter
173
182
  export function resolveIntercomBridge(input: ResolveIntercomBridgeInput): IntercomBridgeState {
174
183
  const config = resolveIntercomBridgeConfig(input.config);
175
184
  const mode = config.mode;
176
- const extensionDir = path.resolve(input.extensionDir ?? DEFAULT_INTERCOM_EXTENSION_DIR);
185
+ const extensionDir = path.resolve(input.extensionDir ?? defaultIntercomExtensionDir());
177
186
  const orchestratorTarget = input.orchestratorTarget?.trim();
178
- const settingsDir = path.resolve(input.settingsDir ?? DEFAULT_SUBAGENT_CONFIG_DIR);
187
+ const settingsDir = path.resolve(input.settingsDir ?? defaultSubagentConfigDir());
179
188
  const defaultInstruction = buildIntercomBridgeInstruction(
180
189
  orchestratorTarget || "{orchestratorTarget}",
181
190
  DEFAULT_INTERCOM_BRIDGE_TEMPLATE,
@@ -194,7 +203,7 @@ export function resolveIntercomBridge(input: ResolveIntercomBridgeInput): Interc
194
203
  return { active: false, mode, extensionDir, instruction: defaultInstruction };
195
204
  }
196
205
 
197
- const configPath = path.resolve(input.configPath ?? DEFAULT_INTERCOM_CONFIG_PATH);
206
+ const configPath = path.resolve(input.configPath ?? defaultIntercomConfigPath());
198
207
  const intercomStatus = intercomConfigStatus(configPath);
199
208
  if (intercomStatus.error) console.warn(`Failed to parse intercom config at '${configPath}'. Assuming enabled.`, intercomStatus.error);
200
209
  if (!intercomStatus.enabled) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.21.0",
3
+ "version": "0.21.1",
4
4
  "description": "Pi extension for delegating tasks to subagents with chains, parallel execution, and TUI clarification",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
package/result-watcher.ts CHANGED
@@ -13,11 +13,36 @@ import {
13
13
  resolveSubagentResultStatus,
14
14
  } from "./result-intercom.ts";
15
15
 
16
+ const WATCHER_RESTART_DELAY_MS = 3000;
17
+ const POLL_INTERVAL_MS = 3000;
18
+
19
+ type ResultWatcherFs = Pick<typeof fs, "existsSync" | "readFileSync" | "unlinkSync" | "readdirSync" | "mkdirSync" | "watch">;
20
+
21
+ type ResultWatcherTimers = {
22
+ setTimeout: typeof setTimeout;
23
+ clearTimeout: typeof clearTimeout;
24
+ setInterval: typeof setInterval;
25
+ clearInterval: typeof clearInterval;
26
+ };
27
+
28
+ type ResultWatcherDeps = {
29
+ fs?: ResultWatcherFs;
30
+ timers?: ResultWatcherTimers;
31
+ };
32
+
33
+ function getErrorCode(error: unknown): string | undefined {
34
+ return typeof error === "object" && error !== null && "code" in error
35
+ ? (error as NodeJS.ErrnoException).code
36
+ : undefined;
37
+ }
38
+
16
39
  function isNotFoundError(error: unknown): boolean {
17
- return typeof error === "object"
18
- && error !== null
19
- && "code" in error
20
- && (error as NodeJS.ErrnoException).code === "ENOENT";
40
+ return getErrorCode(error) === "ENOENT";
41
+ }
42
+
43
+ function shouldFallBackToPolling(error: unknown): boolean {
44
+ const code = getErrorCode(error);
45
+ return code === "EMFILE" || code === "ENOSPC";
21
46
  }
22
47
 
23
48
  export function createResultWatcher(
@@ -25,16 +50,20 @@ export function createResultWatcher(
25
50
  state: SubagentState,
26
51
  resultsDir: string,
27
52
  completionTtlMs: number,
53
+ deps: ResultWatcherDeps = {},
28
54
  ): {
29
55
  startResultWatcher: () => void;
30
56
  primeExistingResults: () => void;
31
57
  stopResultWatcher: () => void;
32
58
  } {
59
+ const fsApi = deps.fs ?? fs;
60
+ const timers = deps.timers ?? { setTimeout, clearTimeout, setInterval, clearInterval };
61
+
33
62
  const handleResult = async (file: string) => {
34
63
  const resultPath = path.join(resultsDir, file);
35
- if (!fs.existsSync(resultPath)) return;
64
+ if (!fsApi.existsSync(resultPath)) return;
36
65
  try {
37
- const data = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as {
66
+ const data = JSON.parse(fsApi.readFileSync(resultPath, "utf-8")) as {
38
67
  id?: string;
39
68
  runId?: string;
40
69
  agent?: string;
@@ -62,7 +91,7 @@ export function createResultWatcher(
62
91
  const now = Date.now();
63
92
  const completionKey = buildCompletionKey(data, `result:${file}`);
64
93
  if (markSeenWithTtl(state.completionSeen, completionKey, now, completionTtlMs)) {
65
- fs.unlinkSync(resultPath);
94
+ fsApi.unlinkSync(resultPath);
66
95
  return;
67
96
  }
68
97
 
@@ -114,7 +143,7 @@ export function createResultWatcher(
114
143
  }
115
144
 
116
145
  pi.events.emit(SUBAGENT_ASYNC_COMPLETE_EVENT, data);
117
- fs.unlinkSync(resultPath);
146
+ fsApi.unlinkSync(resultPath);
118
147
  } catch (error) {
119
148
  if (isNotFoundError(error)) return;
120
149
  console.error(`Failed to process subagent result file '${resultPath}':`, error);
@@ -125,50 +154,91 @@ export function createResultWatcher(
125
154
  void handleResult(file);
126
155
  }, 50);
127
156
 
157
+ const primeExistingResults = () => {
158
+ try {
159
+ fsApi.readdirSync(resultsDir)
160
+ .filter((f) => f.endsWith(".json"))
161
+ .forEach((file) => state.resultFileCoalescer.schedule(file, 0));
162
+ } catch (error) {
163
+ if (isNotFoundError(error)) return;
164
+ console.error(`Failed to scan subagent result directory '${resultsDir}':`, error);
165
+ }
166
+ };
167
+
168
+ const startPollingFallback = (reason: unknown) => {
169
+ state.watcher?.close();
170
+ state.watcher = null;
171
+ if (state.watcherRestartTimer) return;
172
+
173
+ console.error(
174
+ `Subagent result watcher for '${resultsDir}' fell back to polling because native fs.watch is unavailable (${getErrorCode(reason) ?? "unknown error"}).`,
175
+ );
176
+ primeExistingResults();
177
+ state.watcherRestartTimer = timers.setInterval(primeExistingResults, POLL_INTERVAL_MS);
178
+ state.watcherRestartTimer.unref?.();
179
+ };
180
+
128
181
  const scheduleRestart = () => {
129
- state.watcherRestartTimer = setTimeout(() => {
182
+ if (state.watcherRestartTimer) return;
183
+ state.watcherRestartTimer = timers.setTimeout(() => {
184
+ state.watcherRestartTimer = null;
130
185
  try {
131
- fs.mkdirSync(resultsDir, { recursive: true });
186
+ fsApi.mkdirSync(resultsDir, { recursive: true });
132
187
  startResultWatcher();
133
188
  } catch (error) {
189
+ if (shouldFallBackToPolling(error)) {
190
+ startPollingFallback(error);
191
+ return;
192
+ }
134
193
  console.error(`Failed to restart subagent result watcher for '${resultsDir}':`, error);
194
+ scheduleRestart();
135
195
  }
136
- }, 3000);
196
+ }, WATCHER_RESTART_DELAY_MS);
197
+ state.watcherRestartTimer.unref?.();
137
198
  };
138
199
 
139
200
  const startResultWatcher = () => {
140
- state.watcherRestartTimer = null;
201
+ if (state.watcher) return;
202
+ if (state.watcherRestartTimer) {
203
+ timers.clearTimeout(state.watcherRestartTimer);
204
+ timers.clearInterval(state.watcherRestartTimer);
205
+ state.watcherRestartTimer = null;
206
+ }
141
207
  try {
142
- state.watcher = fs.watch(resultsDir, (ev, file) => {
208
+ state.watcher = fsApi.watch(resultsDir, (ev, file) => {
143
209
  if (ev !== "rename" || !file) return;
144
210
  const fileName = file.toString();
145
211
  if (!fileName.endsWith(".json")) return;
146
212
  state.resultFileCoalescer.schedule(fileName);
147
213
  });
148
214
  state.watcher.on("error", (error) => {
215
+ if (shouldFallBackToPolling(error)) {
216
+ startPollingFallback(error);
217
+ return;
218
+ }
149
219
  console.error(`Subagent result watcher failed for '${resultsDir}':`, error);
220
+ state.watcher?.close();
150
221
  state.watcher = null;
151
222
  scheduleRestart();
152
223
  });
153
224
  state.watcher.unref?.();
154
225
  } catch (error) {
226
+ if (shouldFallBackToPolling(error)) {
227
+ startPollingFallback(error);
228
+ return;
229
+ }
155
230
  console.error(`Failed to start subagent result watcher for '${resultsDir}':`, error);
156
231
  state.watcher = null;
157
232
  scheduleRestart();
158
233
  }
159
234
  };
160
235
 
161
- const primeExistingResults = () => {
162
- fs.readdirSync(resultsDir)
163
- .filter((f) => f.endsWith(".json"))
164
- .forEach((file) => state.resultFileCoalescer.schedule(file, 0));
165
- };
166
-
167
236
  const stopResultWatcher = () => {
168
237
  state.watcher?.close();
169
238
  state.watcher = null;
170
239
  if (state.watcherRestartTimer) {
171
- clearTimeout(state.watcherRestartTimer);
240
+ timers.clearTimeout(state.watcherRestartTimer);
241
+ timers.clearInterval(state.watcherRestartTimer);
172
242
  }
173
243
  state.watcherRestartTimer = null;
174
244
  state.resultFileCoalescer.clear();
@@ -44,6 +44,7 @@ Packaged prompt shortcuts are also available for repeatable workflows:
44
44
  - `/parallel-review` — fresh-context reviewers with distinct review angles, then synthesis
45
45
  - `/parallel-research` — combine `researcher` and `scout` for external evidence plus local code context
46
46
  - `/gather-context-and-clarify` — scout/research first, then ask the user clarifying questions with `interview`
47
+ - `/parallel-cleanup` — two fresh-context reviewers (deslop + verbosity passes) for an adversarial cleanup review of the current diff
47
48
 
48
49
  ## Builtin Agents
49
50
 
@@ -439,8 +440,9 @@ copying a full builtin file.
439
440
  ## Prompt Template Integration
440
441
 
441
442
  The package includes prompt shortcuts for common workflows: `/parallel-review`,
442
- `/parallel-research`, and `/gather-context-and-clarify`. Use them when the user
443
- wants repeatable review, research, or clarification patterns.
443
+ `/parallel-research`, `/gather-context-and-clarify`, and `/parallel-cleanup`.
444
+ Use them when the user wants repeatable review, research, clarification, or
445
+ cleanup-review patterns.
444
446
 
445
447
  If `pi-prompt-template-model` is installed, additional user prompt templates can delegate into
446
448
  `pi-subagents`. This is useful when a slash command should always run through a
package/slash-commands.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  SLASH_SUBAGENT_RESPONSE_EVENT,
24
24
  SLASH_SUBAGENT_STARTED_EVENT,
25
25
  SLASH_SUBAGENT_UPDATE_EVENT,
26
+ type ExtensionConfig,
26
27
  type SingleResult,
27
28
  type SubagentState,
28
29
  } from "./types.ts";
@@ -324,6 +325,7 @@ async function runSlashSubagent(
324
325
  async function openAgentManager(
325
326
  pi: ExtensionAPI,
326
327
  ctx: ExtensionContext,
328
+ config: ExtensionConfig = {},
327
329
  ): Promise<void> {
328
330
  const agentData = { ...discoverAgentsAll(ctx.cwd), cwd: ctx.cwd };
329
331
  const models = ctx.modelRegistry.getAvailable().map((m) => ({
@@ -334,7 +336,7 @@ async function openAgentManager(
334
336
  const skills = discoverAvailableSkills(ctx.cwd);
335
337
 
336
338
  const result = await ctx.ui.custom<ManagerResult>(
337
- (tui, theme, _kb, done) => new AgentManagerComponent(tui, theme, agentData, models, skills, done),
339
+ (tui, theme, _kb, done) => new AgentManagerComponent(tui, theme, agentData, models, skills, done, { newShortcut: config.agentManager?.newShortcut }),
338
340
  { overlay: true, overlayOptions: { anchor: "center", width: 84, maxHeight: "80%" } },
339
341
  );
340
342
  if (!result) return;
@@ -448,11 +450,12 @@ const parseAgentArgs = (
448
450
  export function registerSlashCommands(
449
451
  pi: ExtensionAPI,
450
452
  state: SubagentState,
453
+ config: ExtensionConfig = {},
451
454
  ): void {
452
455
  pi.registerCommand("agents", {
453
456
  description: "Open the Agents Manager",
454
457
  handler: async (_args, ctx) => {
455
- await openAgentManager(pi, ctx);
458
+ await openAgentManager(pi, ctx, config);
456
459
  },
457
460
  });
458
461
 
@@ -578,7 +581,7 @@ export function registerSlashCommands(
578
581
 
579
582
  pi.registerShortcut("ctrl+shift+a", {
580
583
  handler: async (ctx) => {
581
- await openAgentManager(pi, ctx);
584
+ await openAgentManager(pi, ctx, config);
582
585
  },
583
586
  });
584
587
  }
@@ -282,7 +282,11 @@ function runPiStreaming(
282
282
  const stopReason = (event.message as { stopReason?: string }).stopReason;
283
283
  const hasToolCall = Array.isArray(event.message.content)
284
284
  && event.message.content.some((part) => (part as { type?: string }).type === "toolCall");
285
- if (stopReason === "stop" && !hasToolCall) startFinalDrain();
285
+ const finalText = extractTextFromContent(event.message.content).trim();
286
+ if (stopReason === "stop" && !hasToolCall) {
287
+ finalAssistantDelivered ||= !event.message.errorMessage && finalText.length > 0;
288
+ startFinalDrain();
289
+ }
286
290
  }
287
291
  };
288
292
 
@@ -305,6 +309,8 @@ function runPiStreaming(
305
309
  const HARD_KILL_MS = 3000;
306
310
  let childExited = false;
307
311
  let forcedTerminationSignal = false;
312
+ let finalAssistantDelivered = false;
313
+ let finalDrainCleanupWarning: string | undefined;
308
314
  let finalDrainTimer: NodeJS.Timeout | undefined;
309
315
  let finalHardKillTimer: NodeJS.Timeout | undefined;
310
316
  let settled = false;
@@ -346,8 +352,9 @@ function runPiStreaming(
346
352
  const termSent = trySignalChild(child, "SIGTERM");
347
353
  if (!termSent) return;
348
354
  forcedTerminationSignal = true;
349
- if (!error) {
350
- error = `Subagent process did not exit within ${FINAL_DRAIN_MS}ms after its final message. Forcing termination.`;
355
+ finalDrainCleanupWarning = `Subagent process did not exit within ${FINAL_DRAIN_MS}ms after its final message. Forcing termination.`;
356
+ if (!finalAssistantDelivered && !error) {
357
+ error = finalDrainCleanupWarning;
351
358
  }
352
359
  finalHardKillTimer = setTimeout(() => {
353
360
  if (settled) return;
@@ -370,13 +377,14 @@ function runPiStreaming(
370
377
  if (stderrBuf.trim()) appendChildLine("subagent.child.stderr", stderrBuf);
371
378
  outputStream.end();
372
379
  const finalOutput = getFinalOutput(messages) || rawStdoutLines.join("\n").trim();
380
+ const forcedDrainAfterFinalSuccess = forcedTerminationSignal && finalAssistantDelivered;
373
381
  resolve({
374
382
  stderr,
375
- exitCode: interrupted ? 0 : forcedTerminationSignal || signal ? (exitCode ?? 1) : exitCode,
383
+ exitCode: interrupted || forcedDrainAfterFinalSuccess ? 0 : forcedTerminationSignal || signal ? (exitCode ?? 1) : exitCode,
376
384
  messages,
377
385
  usage,
378
386
  model,
379
- error: interrupted ? undefined : error,
387
+ error: interrupted || forcedDrainAfterFinalSuccess ? undefined : error,
380
388
  finalOutput,
381
389
  interrupted,
382
390
  observedMutationAttempt,
package/types.ts CHANGED
@@ -445,6 +445,10 @@ interface TopLevelParallelConfig {
445
445
  concurrency?: number;
446
446
  }
447
447
 
448
+ interface AgentManagerConfig {
449
+ newShortcut?: string;
450
+ }
451
+
448
452
  export interface ExtensionConfig {
449
453
  asyncByDefault?: boolean;
450
454
  forceTopLevelAsync?: boolean;
@@ -452,6 +456,7 @@ export interface ExtensionConfig {
452
456
  maxSubagentDepth?: number;
453
457
  control?: ControlConfig;
454
458
  parallel?: TopLevelParallelConfig;
459
+ agentManager?: AgentManagerConfig;
455
460
  worktreeSetupHook?: string;
456
461
  worktreeSetupHookTimeoutMs?: number;
457
462
  intercomBridge?: IntercomBridgeConfig;