pi-subagents 0.13.3 → 0.13.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/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.13.4] - 2026-04-13
6
+
7
+ ### Fixed
8
+ - Intercom orchestration now uses a runtime-only `subagent-chat-<id>` fallback target for unnamed sessions instead of persisting a generic session title, so `pi --resume` keeps showing transcript snippets while delegated intercom routing still works.
9
+ - GitHub Actions test workflow now uses `actions/checkout@v5` and `actions/setup-node@v5`, removing Node 20 action-runtime deprecation warnings ahead of the enforced Node 24 transition.
10
+ - Worktree cwd mapping now derives repo-relative prefixes from `git rev-parse --show-prefix` instead of `path.relative(realpath, realpath)`, fixing Windows 8.3/canonical-path mismatches that could map `agentCwd` back to the source repo instead of the created worktree.
11
+ - Async background runs now pass the parent process `argv[1]` through to the detached runner, so Windows child spawning keeps targeting the intended `pi` CLI entry point instead of accidentally treating the runner's `jiti` bootstrap script as `pi`.
12
+ - Intercom detach listeners now guard optional event-bus subscriptions with optional-call semantics, so delegated runs no longer fail when host event buses expose `emit` without `on`.
13
+ - Skill discovery no longer depends on runtime imports from `@mariozechner/pi-coding-agent`; it now resolves skills directly from configured filesystem paths, preventing `ERR_MODULE_NOT_FOUND` crashes in local/integration test environments.
14
+
5
15
  ## [0.13.3] - 2026-04-13
6
16
 
7
17
  ### Added
package/README.md CHANGED
@@ -808,9 +808,11 @@ If intercom is unavailable in this run, continue the task normally.
808
808
  Bridge activation also requires all of the following:
809
809
  - [pi-intercom](https://github.com/nicobailon/pi-intercom) is installed (`pi install npm:pi-intercom`)
810
810
  - `~/.pi/agent/intercom/config.json` is not set to `"enabled": false`
811
- - the current session has a target name (existing `/name`, or auto-assigned `session-<id>` when unnamed)
811
+ - the current session can be targeted by intercom (existing `/name`, or the runtime-only fallback alias `subagent-chat-<id>` when unnamed)
812
812
  - if agent `extensions` is an explicit allowlist, it must include `pi-intercom`
813
813
 
814
+ When an unnamed session falls back to `subagent-chat-<id>`, that alias is used only for the live intercom broker. It is not persisted as the Pi session title, so `pi --resume` can still show the transcript snippet.
815
+
814
816
  ### `worktreeSetupHook`
815
817
 
816
818
  `worktreeSetupHook` configures an optional setup hook for worktree-isolated parallel runs. The hook runs once per created worktree, after `git worktree add` succeeds and before the agent starts.
@@ -264,6 +264,7 @@ export function executeAsyncChain(
264
264
  asyncDir,
265
265
  sessionId: ctx.currentSessionId,
266
266
  piPackageRoot,
267
+ piArgv1: process.argv[1],
267
268
  worktreeSetupHook,
268
269
  worktreeSetupHookTimeoutMs,
269
270
  },
@@ -384,6 +385,7 @@ export function executeAsyncSingle(
384
385
  asyncDir,
385
386
  sessionId: ctx.currentSessionId,
386
387
  piPackageRoot,
388
+ piArgv1: process.argv[1],
387
389
  worktreeSetupHook,
388
390
  worktreeSetupHookTimeoutMs,
389
391
  },
package/execution.ts CHANGED
@@ -155,7 +155,7 @@ async function runSingleAttempt(
155
155
  finish(-2);
156
156
  };
157
157
 
158
- const unsubscribeIntercomDetach = options.intercomEvents?.on(INTERCOM_DETACH_REQUEST_EVENT, (payload) => {
158
+ const unsubscribeIntercomDetach = options.intercomEvents?.on?.(INTERCOM_DETACH_REQUEST_EVENT, (payload) => {
159
159
  if (!options.allowIntercomDetach || detached || processClosed) return;
160
160
  if (!payload || typeof payload !== "object") return;
161
161
  const requestId = (payload as { requestId?: unknown }).requestId;
@@ -7,6 +7,7 @@ import type { ExtensionConfig, IntercomBridgeConfig, IntercomBridgeMode } from "
7
7
  const DEFAULT_INTERCOM_EXTENSION_DIR = path.join(os.homedir(), ".pi", "agent", "extensions", "pi-intercom");
8
8
  const DEFAULT_INTERCOM_CONFIG_PATH = path.join(os.homedir(), ".pi", "agent", "intercom", "config.json");
9
9
  const DEFAULT_SUBAGENT_CONFIG_DIR = path.join(os.homedir(), ".pi", "agent", "extensions", "subagent");
10
+ const DEFAULT_INTERCOM_TARGET_PREFIX = "subagent-chat";
10
11
  const INTERCOM_BRIDGE_MARKER = "Intercom orchestration channel:";
11
12
  const DEFAULT_INTERCOM_BRIDGE_TEMPLATE = `Use intercom only for coordination with the orchestrator session:
12
13
  - Need a decision or blocked: intercom({ action: "ask", to: "{orchestratorTarget}", message: "<question>" })
@@ -30,6 +31,13 @@ interface ResolveIntercomBridgeInput {
30
31
  settingsDir?: string;
31
32
  }
32
33
 
34
+ export function resolveIntercomSessionTarget(sessionName: string | undefined, sessionId: string): string {
35
+ const trimmedName = sessionName?.trim();
36
+ if (trimmedName) return trimmedName;
37
+ const normalizedSessionId = sessionId.startsWith("session-") ? sessionId.slice("session-".length) : sessionId;
38
+ return `${DEFAULT_INTERCOM_TARGET_PREFIX}-${normalizedSessionId.slice(0, 8)}`;
39
+ }
40
+
33
41
  export function resolveIntercomBridgeMode(value: unknown): IntercomBridgeMode {
34
42
  if (value === "off" || value === "always" || value === "fork-only") return value;
35
43
  return "always";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.13.3",
3
+ "version": "0.13.4",
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/skills.ts CHANGED
@@ -6,7 +6,6 @@ import { execSync } from "node:child_process";
6
6
  import * as fs from "node:fs";
7
7
  import * as os from "node:os";
8
8
  import * as path from "node:path";
9
- import { loadSkills, type Skill } from "@mariozechner/pi-coding-agent";
10
9
 
11
10
  export type SkillSource =
12
11
  | "project"
@@ -86,6 +85,7 @@ function getPackageSkillPaths(packageRoot: string): string[] {
86
85
  .filter((s: unknown) => typeof s === "string")
87
86
  .map((s: string) => path.resolve(packageRoot, s));
88
87
  } catch {
88
+ // Package scanning is opportunistic; ignore malformed/missing package metadata.
89
89
  return [];
90
90
  }
91
91
  }
@@ -98,6 +98,7 @@ function getGlobalNpmRoot(): string | null {
98
98
  cachedGlobalNpmRoot = execSync("npm root -g", { encoding: "utf-8", timeout: 5000 }).trim();
99
99
  return cachedGlobalNpmRoot;
100
100
  } catch {
101
+ // Global npm root is optional in constrained environments.
101
102
  cachedGlobalNpmRoot = ""; // Empty string means "tried but failed"
102
103
  return null;
103
104
  }
@@ -123,6 +124,7 @@ function collectPackageSkillPaths(cwd: string): string[] {
123
124
  try {
124
125
  entries = fs.readdirSync(dir, { withFileTypes: true });
125
126
  } catch {
127
+ // Ignore unreadable package roots and continue scanning other roots.
126
128
  continue;
127
129
  }
128
130
 
@@ -136,6 +138,7 @@ function collectPackageSkillPaths(cwd: string): string[] {
136
138
  try {
137
139
  scopeEntries = fs.readdirSync(scopeDir, { withFileTypes: true });
138
140
  } catch {
141
+ // Ignore unreadable scoped package directories and continue.
139
142
  continue;
140
143
  }
141
144
  for (const scopeEntry of scopeEntries) {
@@ -178,7 +181,9 @@ function collectSettingsSkillPaths(cwd: string): string[] {
178
181
  }
179
182
  results.push(resolved);
180
183
  }
181
- } catch {}
184
+ } catch {
185
+ // Settings-provided skills are optional; ignore malformed or missing settings files.
186
+ }
182
187
  }
183
188
 
184
189
  return results;
@@ -196,21 +201,22 @@ function buildSkillPaths(cwd: string): string[] {
196
201
  return [...new Set([...defaultSkillPaths, ...packagePaths, ...settingsPaths])];
197
202
  }
198
203
 
199
- function inferSkillSource(sourceInfo: { source: string; scope: string }, filePath: string, cwd: string): SkillSource {
200
- const { scope, source } = sourceInfo;
201
-
202
- if (scope === "project" && source === "local") return "project";
203
- if (scope === "user" && source === "local") return "user";
204
+ function inferSkillSource(filePath: string, cwd: string): SkillSource {
205
+ const projectConfigRoot = path.resolve(cwd, CONFIG_DIR);
206
+ const projectSkillsRoot = path.resolve(cwd, CONFIG_DIR, "skills");
207
+ const projectPackagesRoot = path.resolve(cwd, CONFIG_DIR, "npm", "node_modules");
208
+ const projectAgentsRoot = path.resolve(cwd, ".agents");
209
+ const userSkillsRoot = path.resolve(AGENT_DIR, "skills");
210
+ const userPackagesRoot = path.resolve(AGENT_DIR, "npm", "node_modules");
211
+ const userAgentsRoot = path.resolve(os.homedir(), ".agents");
204
212
 
205
- // Fallback: infer from file path when sourceInfo isn't specific enough
206
- // (e.g. scope === "temporary" for skills loaded via explicit skillPaths)
207
- const projectRoot = path.resolve(cwd, CONFIG_DIR);
208
- const altProjectRoot = path.resolve(cwd, ".agents");
209
- const isProjectScoped = isWithinPath(filePath, projectRoot) || isWithinPath(filePath, altProjectRoot);
210
- if (isProjectScoped) return "project";
213
+ if (isWithinPath(filePath, projectPackagesRoot)) return "project-package";
214
+ if (isWithinPath(filePath, projectSkillsRoot) || isWithinPath(filePath, projectAgentsRoot)) return "project";
215
+ if (isWithinPath(filePath, projectConfigRoot)) return "project-settings";
211
216
 
212
- const isUserScoped = isWithinPath(filePath, AGENT_DIR) || isWithinPath(filePath, path.join(os.homedir(), ".agents"));
213
- if (isUserScoped) return "user";
217
+ if (isWithinPath(filePath, userPackagesRoot)) return "user-package";
218
+ if (isWithinPath(filePath, userSkillsRoot) || isWithinPath(filePath, userAgentsRoot)) return "user";
219
+ if (isWithinPath(filePath, AGENT_DIR)) return "user-settings";
214
220
 
215
221
  const globalRoot = getGlobalNpmRoot();
216
222
  if (globalRoot && isWithinPath(filePath, globalRoot)) return "user-package";
@@ -227,6 +233,99 @@ function chooseHigherPrioritySkill(existing: CachedSkillEntry | undefined, candi
227
233
  return candidate.order < existing.order ? candidate : existing;
228
234
  }
229
235
 
236
+ function maybeReadSkillDescription(filePath: string): string | undefined {
237
+ try {
238
+ const content = fs.readFileSync(filePath, "utf-8");
239
+ const normalized = content.replace(/\r\n/g, "\n");
240
+ if (!normalized.startsWith("---")) return undefined;
241
+
242
+ const endIndex = normalized.indexOf("\n---", 3);
243
+ if (endIndex === -1) return undefined;
244
+
245
+ const frontmatter = normalized.slice(3, endIndex).trim();
246
+ const match = frontmatter.match(/^description:\s*(.+)$/m);
247
+ if (!match) return undefined;
248
+ return match[1]?.trim().replace(/^['\"]|['\"]$/g, "");
249
+ } catch {
250
+ // Description parsing is best-effort metadata extraction.
251
+ return undefined;
252
+ }
253
+ }
254
+
255
+ function collectFilesystemSkills(cwd: string, skillPaths: string[]): CachedSkillEntry[] {
256
+ const entries: CachedSkillEntry[] = [];
257
+ const seen = new Set<string>();
258
+ let order = 0;
259
+
260
+ const pushEntry = (name: string, filePath: string) => {
261
+ const resolvedFile = path.resolve(filePath);
262
+ if (seen.has(resolvedFile)) return;
263
+ if (!fs.existsSync(resolvedFile)) return;
264
+ seen.add(resolvedFile);
265
+ entries.push({
266
+ name,
267
+ filePath: resolvedFile,
268
+ source: inferSkillSource(resolvedFile, cwd),
269
+ description: maybeReadSkillDescription(resolvedFile),
270
+ order: order++,
271
+ });
272
+ };
273
+
274
+ for (const skillPath of skillPaths) {
275
+ if (!fs.existsSync(skillPath)) continue;
276
+
277
+ let stat: fs.Stats;
278
+ try {
279
+ stat = fs.statSync(skillPath);
280
+ } catch {
281
+ // Ignore paths that disappear or become unreadable during discovery.
282
+ continue;
283
+ }
284
+
285
+ if (stat.isFile()) {
286
+ const fileName = path.basename(skillPath);
287
+ if (!fileName.toLowerCase().endsWith(".md")) continue;
288
+ const skillName = fileName.toLowerCase() === "skill.md"
289
+ ? path.basename(path.dirname(skillPath))
290
+ : path.basename(fileName, path.extname(fileName));
291
+ pushEntry(skillName, skillPath);
292
+ continue;
293
+ }
294
+
295
+ if (!stat.isDirectory()) continue;
296
+
297
+ const rootSkillFile = path.join(skillPath, "SKILL.md");
298
+ if (fs.existsSync(rootSkillFile)) {
299
+ pushEntry(path.basename(skillPath), rootSkillFile);
300
+ }
301
+
302
+ let childEntries: fs.Dirent[];
303
+ try {
304
+ childEntries = fs.readdirSync(skillPath, { withFileTypes: true });
305
+ } catch {
306
+ // Ignore unreadable skill directories and continue scanning.
307
+ continue;
308
+ }
309
+
310
+ for (const child of childEntries) {
311
+ if (child.name.startsWith(".")) continue;
312
+ const childPath = path.join(skillPath, child.name);
313
+ if (child.isDirectory() || child.isSymbolicLink()) {
314
+ const nestedSkillPath = path.join(childPath, "SKILL.md");
315
+ if (fs.existsSync(nestedSkillPath)) {
316
+ pushEntry(child.name, nestedSkillPath);
317
+ }
318
+ continue;
319
+ }
320
+ if (child.isFile() && child.name.toLowerCase().endsWith(".md")) {
321
+ pushEntry(path.basename(child.name, path.extname(child.name)), childPath);
322
+ }
323
+ }
324
+ }
325
+
326
+ return entries;
327
+ }
328
+
230
329
  function getCachedSkills(cwd: string): CachedSkillEntry[] {
231
330
  const now = Date.now();
232
331
  if (loadSkillsCache && loadSkillsCache.cwd === cwd && now - loadSkillsCache.timestamp < LOAD_SKILLS_CACHE_TTL_MS) {
@@ -234,18 +333,10 @@ function getCachedSkills(cwd: string): CachedSkillEntry[] {
234
333
  }
235
334
 
236
335
  const skillPaths = buildSkillPaths(cwd);
237
- const loaded = loadSkills({ cwd, skillPaths, includeDefaults: false });
336
+ const loaded = collectFilesystemSkills(cwd, skillPaths);
238
337
  const dedupedByName = new Map<string, CachedSkillEntry>();
239
338
 
240
- for (let i = 0; i < loaded.skills.length; i++) {
241
- const skill = loaded.skills[i] as Skill;
242
- const entry: CachedSkillEntry = {
243
- name: skill.name,
244
- filePath: skill.filePath,
245
- source: inferSkillSource(skill.sourceInfo, skill.filePath, cwd),
246
- description: skill.description,
247
- order: i,
248
- };
339
+ for (const entry of loaded) {
249
340
  const current = dedupedByName.get(entry.name);
250
341
  dedupedByName.set(entry.name, chooseHigherPrioritySkill(current, entry));
251
342
  }
@@ -294,6 +385,7 @@ export function readSkill(
294
385
 
295
386
  return skill;
296
387
  } catch {
388
+ // Treat unreadable skill files as unresolved so callers can surface as missing.
297
389
  return undefined;
298
390
  }
299
391
  }
@@ -22,7 +22,7 @@ import {
22
22
  import { discoverAvailableSkills, normalizeSkillInput } from "./skills.ts";
23
23
  import { executeAsyncChain, executeAsyncSingle, isAsyncAvailable } from "./async-execution.ts";
24
24
  import { createForkContextResolver } from "./fork-context.ts";
25
- import { applyIntercomBridgeToAgent, resolveIntercomBridge } from "./intercom-bridge.ts";
25
+ import { applyIntercomBridgeToAgent, resolveIntercomBridge, resolveIntercomSessionTarget } from "./intercom-bridge.ts";
26
26
  import { finalizeSingleOutput, injectSingleOutputInstruction, resolveSingleOutputPath } from "./single-output.ts";
27
27
  import { getSingleResultOutput, mapConcurrent } from "./utils.ts";
28
28
  import {
@@ -1117,11 +1117,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
1117
1117
  const parentSessionFile = ctx.sessionManager.getSessionFile() ?? null;
1118
1118
  deps.state.currentSessionId = parentSessionFile ?? `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1119
1119
  const discoveredAgents = deps.discoverAgents(ctx.cwd, scope).agents;
1120
- let sessionName = deps.pi.getSessionName()?.trim();
1121
- if (!sessionName) {
1122
- sessionName = `session-${ctx.sessionManager.getSessionId().slice(0, 8)}`;
1123
- deps.pi.setSessionName(sessionName);
1124
- }
1120
+ const sessionName = resolveIntercomSessionTarget(deps.pi.getSessionName(), ctx.sessionManager.getSessionId());
1125
1121
  const intercomBridge = resolveIntercomBridge({
1126
1122
  config: deps.config.intercomBridge,
1127
1123
  context: normalizedParams.context,
@@ -53,6 +53,7 @@ interface SubagentRunConfig {
53
53
  asyncDir: string;
54
54
  sessionId?: string | null;
55
55
  piPackageRoot?: string;
56
+ piArgv1?: string;
56
57
  worktreeSetupHook?: string;
57
58
  worktreeSetupHookTimeoutMs?: number;
58
59
  }
@@ -156,12 +157,16 @@ function runPiStreaming(
156
157
  outputFile: string,
157
158
  env?: Record<string, string | undefined>,
158
159
  piPackageRoot?: string,
160
+ piArgv1?: string,
159
161
  maxSubagentDepth?: number,
160
162
  ): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
161
163
  return new Promise((resolve) => {
162
164
  const outputStream = fs.createWriteStream(outputFile, { flags: "w" });
163
165
  const spawnEnv = { ...process.env, ...(env ?? {}), ...getSubagentDepthEnv(maxSubagentDepth) };
164
- const spawnSpec = getPiSpawnCommand(args, piPackageRoot ? { piPackageRoot } : undefined);
166
+ const spawnSpec = getPiSpawnCommand(args, {
167
+ ...(piPackageRoot ? { piPackageRoot } : {}),
168
+ ...(piArgv1 ? { argv1: piArgv1 } : {}),
169
+ });
165
170
  const child = spawn(spawnSpec.command, spawnSpec.args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: spawnEnv });
166
171
  let stdout = "";
167
172
  let stderr = "";
@@ -340,6 +345,7 @@ interface SingleStepContext {
340
345
  flatStepCount: number;
341
346
  outputFile: string;
342
347
  piPackageRoot?: string;
348
+ piArgv1?: string;
343
349
  }
344
350
 
345
351
  /** Run a single pi agent step, returning output and metadata */
@@ -401,7 +407,7 @@ async function runSingleStep(
401
407
  promptFileStem: step.agent,
402
408
  });
403
409
  const outputFile = index === 0 ? ctx.outputFile : `${ctx.outputFile}.attempt-${index + 1}`;
404
- const run = await runPiStreaming(args, step.cwd ?? ctx.cwd, outputFile, env, ctx.piPackageRoot, step.maxSubagentDepth);
410
+ const run = await runPiStreaming(args, step.cwd ?? ctx.cwd, outputFile, env, ctx.piPackageRoot, ctx.piArgv1, step.maxSubagentDepth);
405
411
  cleanupTempDir(tempDir);
406
412
 
407
413
  const parsed = parseRunOutput(run.stdout);
@@ -759,6 +765,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
759
765
  flatIndex: fi, flatStepCount: flatSteps.length,
760
766
  outputFile: path.join(asyncDir, `output-${fi}.log`),
761
767
  piPackageRoot: config.piPackageRoot,
768
+ piArgv1: config.piArgv1,
762
769
  });
763
770
  if (task.sessionFile) {
764
771
  latestSessionFile = task.sessionFile;
@@ -879,6 +886,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
879
886
  flatIndex, flatStepCount: flatSteps.length,
880
887
  outputFile: path.join(asyncDir, `output-${flatIndex}.log`),
881
888
  piPackageRoot: config.piPackageRoot,
889
+ piArgv1: config.piArgv1,
882
890
  });
883
891
  if (seqStep.sessionFile) {
884
892
  latestSessionFile = seqStep.sessionFile;
package/worktree.ts CHANGED
@@ -106,9 +106,11 @@ function resolveRepoState(cwd: string): RepoState {
106
106
  }
107
107
 
108
108
  const toplevel = runGitChecked(cwd, ["rev-parse", "--show-toplevel"]).trim();
109
- const realCwd = fs.realpathSync(cwd);
110
- const realToplevel = fs.realpathSync(toplevel);
111
- const cwdRelative = path.relative(realToplevel, realCwd);
109
+ const rawPrefix = runGitChecked(cwd, ["rev-parse", "--show-prefix"]).trim();
110
+ const normalizedPrefix = rawPrefix
111
+ ? path.normalize(rawPrefix.replace(/[\\/]+$/, ""))
112
+ : "";
113
+ const cwdRelative = normalizedPrefix === "." ? "" : normalizedPrefix;
112
114
 
113
115
  const status = runGitChecked(toplevel, ["status", "--porcelain"]);
114
116
  if (status.trim().length > 0) {
@@ -124,6 +126,7 @@ function normalizeComparableCwd(cwd: string): string {
124
126
  try {
125
127
  return fs.realpathSync(resolved);
126
128
  } catch {
129
+ // Use the unresolved absolute path when realpath resolution is unavailable.
127
130
  return resolved;
128
131
  }
129
132
  }
@@ -169,6 +172,7 @@ function linkNodeModulesIfPresent(toplevel: string, worktreePath: string): boole
169
172
  fs.symlinkSync(nodeModulesPath, nodeModulesLinkPath);
170
173
  return true;
171
174
  } catch {
175
+ // Symlink creation is optional (e.g., unsupported filesystems on CI runners).
172
176
  return false;
173
177
  }
174
178
  }
@@ -344,8 +348,12 @@ function createSingleWorktree(
344
348
  syntheticPaths,
345
349
  };
346
350
  } catch (error) {
347
- try { runGitChecked(toplevel, ["worktree", "remove", "--force", worktreePath]); } catch {}
348
- try { runGitChecked(toplevel, ["branch", "-D", branch]); } catch {}
351
+ try { runGitChecked(toplevel, ["worktree", "remove", "--force", worktreePath]); } catch {
352
+ // Best-effort rollback; preserve the original setup failure.
353
+ }
354
+ try { runGitChecked(toplevel, ["branch", "-D", branch]); } catch {
355
+ // Best-effort rollback; preserve the original setup failure.
356
+ }
349
357
  throw error;
350
358
  }
351
359
  }
@@ -453,12 +461,18 @@ function captureWorktreeDiff(
453
461
  function writeEmptyPatch(patchPath: string): void {
454
462
  try {
455
463
  fs.writeFileSync(patchPath, "", "utf-8");
456
- } catch {}
464
+ } catch {
465
+ // Diff artifact writing is best-effort in error paths.
466
+ }
457
467
  }
458
468
 
459
469
  function cleanupSingleWorktree(repoCwd: string, worktree: WorktreeInfo): void {
460
- try { runGitChecked(repoCwd, ["worktree", "remove", "--force", worktree.path]); } catch {}
461
- try { runGitChecked(repoCwd, ["branch", "-D", worktree.branch]); } catch {}
470
+ try { runGitChecked(repoCwd, ["worktree", "remove", "--force", worktree.path]); } catch {
471
+ // Cleanup is best-effort to avoid masking caller errors.
472
+ }
473
+ try { runGitChecked(repoCwd, ["branch", "-D", worktree.branch]); } catch {
474
+ // Cleanup is best-effort to avoid masking caller errors.
475
+ }
462
476
  }
463
477
 
464
478
  function hasWorktreeChanges(diff: WorktreeDiff): boolean {
@@ -502,6 +516,7 @@ export function diffWorktrees(setup: WorktreeSetup, agents: string[], diffsDir:
502
516
  try {
503
517
  fs.mkdirSync(diffsDir, { recursive: true });
504
518
  } catch {
519
+ // Returning no diffs is safer than failing the whole command on artifact-dir issues.
505
520
  return [];
506
521
  }
507
522
 
@@ -513,6 +528,7 @@ export function diffWorktrees(setup: WorktreeSetup, agents: string[], diffsDir:
513
528
  try {
514
529
  diffs.push(captureWorktreeDiff(setup, worktree, agent, patchPath));
515
530
  } catch {
531
+ // Preserve execution flow; failed diff capture maps to an empty per-task patch.
516
532
  writeEmptyPatch(patchPath);
517
533
  diffs.push(emptyDiff(index, agent, worktree.branch, patchPath));
518
534
  }
@@ -525,7 +541,9 @@ export function cleanupWorktrees(setup: WorktreeSetup): void {
525
541
  for (let index = setup.worktrees.length - 1; index >= 0; index--) {
526
542
  cleanupSingleWorktree(setup.cwd, setup.worktrees[index]!);
527
543
  }
528
- try { runGitChecked(setup.cwd, ["worktree", "prune"]); } catch {}
544
+ try { runGitChecked(setup.cwd, ["worktree", "prune"]); } catch {
545
+ // Pruning is best-effort cleanup.
546
+ }
529
547
  }
530
548
 
531
549
  export function formatWorktreeDiffSummary(diffs: WorktreeDiff[]): string {