pi-subagents 0.13.2 → 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,26 @@
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
+
15
+ ## [0.13.3] - 2026-04-13
16
+
17
+ ### Added
18
+ - Added `intercomBridge.instructionFile` so subagent intercom guidance can be overridden from a Markdown template with `{orchestratorTarget}` interpolation.
19
+
20
+ ### Fixed
21
+ - Intercom-enabled delegated runs now detach only after the child actually starts the `intercom` tool, preserving clean sync behavior until coordination is needed.
22
+ - Graceful intercom coordination no longer leaves detached child runs vulnerable to later parent abort listeners, and reply confirmation follow-ups avoid unnecessary orchestrator aborts.
23
+ - Child process spawn failures now preserve the original error message instead of collapsing to a generic failure.
24
+
5
25
  ## [0.13.2] - 2026-04-13
6
26
 
7
27
  ### Changed
package/README.md CHANGED
@@ -14,12 +14,6 @@ https://github.com/user-attachments/assets/702554ec-faaf-4635-80aa-fb5d6e292fd1
14
14
  pi install npm:pi-subagents
15
15
  ```
16
16
 
17
- To remove:
18
-
19
- ```bash
20
- npx pi-subagents --remove
21
- ```
22
-
23
17
  If you use [pi-prompt-template-model](https://github.com/nicobailon/pi-prompt-template-model), you can wrap subagent delegation in a slash command:
24
18
 
25
19
  ```markdown
@@ -787,21 +781,38 @@ Controls whether subagents receive runtime intercom coordination instructions (a
787
781
 
788
782
  ```json
789
783
  {
790
- "intercomBridge": "always"
784
+ "intercomBridge": {
785
+ "mode": "always",
786
+ "instructionFile": "./intercom-bridge.md"
787
+ }
791
788
  }
792
789
  ```
793
790
 
794
- Values:
795
- - `"always"` (default): inject bridge in both `fresh` and `fork`
796
- - `"fork-only"`: inject bridge only when `context: "fork"`
797
- - `"off"`: disable bridge entirely
791
+ Fields:
792
+ - `"mode"` (default: `"always"`): inject bridge in both `fresh` and `fork`, only in `fork`, or disable it entirely
793
+ - `"instructionFile"` (optional): path to a Markdown template that replaces the default injected subagent intercom instructions. Supports `{orchestratorTarget}` placeholder interpolation. Relative paths resolve from `~/.pi/agent/extensions/subagent/`.
794
+
795
+ Example `instructionFile`:
796
+
797
+ ```md
798
+ Intercom orchestration channel:
799
+
800
+ Use `intercom` only to coordinate with the orchestrator session `{orchestratorTarget}`.
801
+
802
+ - Need a decision or you're blocked: `intercom({ action: "ask", to: "{orchestratorTarget}", message: "<question>" })`
803
+ - Need to report progress or completion: `intercom({ action: "send", to: "{orchestratorTarget}", message: "DONE: <summary>" })`
804
+
805
+ If intercom is unavailable in this run, continue the task normally.
806
+ ```
798
807
 
799
808
  Bridge activation also requires all of the following:
800
809
  - [pi-intercom](https://github.com/nicobailon/pi-intercom) is installed (`pi install npm:pi-intercom`)
801
810
  - `~/.pi/agent/intercom/config.json` is not set to `"enabled": false`
802
- - the current session has a name to target as orchestrator
811
+ - the current session can be targeted by intercom (existing `/name`, or the runtime-only fallback alias `subagent-chat-<id>` when unnamed)
803
812
  - if agent `extensions` is an explicit allowlist, it must include `pi-intercom`
804
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
+
805
816
  ### `worktreeSetupHook`
806
817
 
807
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
@@ -19,6 +19,8 @@ import {
19
19
  type SingleResult,
20
20
  type Usage,
21
21
  DEFAULT_MAX_OUTPUT,
22
+ INTERCOM_DETACH_REQUEST_EVENT,
23
+ INTERCOM_DETACH_RESPONSE_EVENT,
22
24
  truncateOutput,
23
25
  getSubagentDepthEnv,
24
26
  } from "./types.ts";
@@ -123,7 +125,6 @@ async function runSingleAttempt(
123
125
  const startTime = Date.now();
124
126
  const spawnEnv = { ...process.env, ...sharedEnv, ...getSubagentDepthEnv(options.maxSubagentDepth) };
125
127
 
126
- let closeJsonlWriter: (() => Promise<void>) | undefined;
127
128
  const exitCode = await new Promise<number>((resolve) => {
128
129
  const spawnSpec = getPiSpawnCommand(args);
129
130
  const proc = spawn(spawnSpec.command, spawnSpec.args, {
@@ -132,9 +133,46 @@ async function runSingleAttempt(
132
133
  stdio: ["ignore", "pipe", "pipe"],
133
134
  });
134
135
  const jsonlWriter = createJsonlWriter(shared.jsonlPath, proc.stdout);
135
- closeJsonlWriter = () => jsonlWriter.close();
136
136
  let buf = "";
137
137
  let processClosed = false;
138
+ let settled = false;
139
+ let detached = false;
140
+ let intercomStarted = false;
141
+ let removeAbortListener: (() => void) | undefined;
142
+
143
+ const detachForIntercom = () => {
144
+ detached = true;
145
+ processClosed = true;
146
+ result.detached = true;
147
+ result.detachedReason = "intercom coordination";
148
+ progress.status = "detached";
149
+ progress.durationMs = Date.now() - startTime;
150
+ result.progressSummary = {
151
+ toolCount: progress.toolCount,
152
+ tokens: progress.tokens,
153
+ durationMs: progress.durationMs,
154
+ };
155
+ finish(-2);
156
+ };
157
+
158
+ const unsubscribeIntercomDetach = options.intercomEvents?.on?.(INTERCOM_DETACH_REQUEST_EVENT, (payload) => {
159
+ if (!options.allowIntercomDetach || detached || processClosed) return;
160
+ if (!payload || typeof payload !== "object") return;
161
+ const requestId = (payload as { requestId?: unknown }).requestId;
162
+ if (typeof requestId !== "string" || requestId.length === 0) return;
163
+ const accepted = intercomStarted;
164
+ options.intercomEvents?.emit(INTERCOM_DETACH_RESPONSE_EVENT, { requestId, accepted });
165
+ if (!accepted) return;
166
+ detachForIntercom();
167
+ });
168
+
169
+ const finish = (code: number) => {
170
+ if (settled) return;
171
+ settled = true;
172
+ unsubscribeIntercomDetach?.();
173
+ removeAbortListener?.();
174
+ resolve(code);
175
+ };
138
176
 
139
177
  const fireUpdate = () => {
140
178
  if (!options.onUpdate || processClosed) return;
@@ -154,6 +192,9 @@ async function runSingleAttempt(
154
192
  progress.durationMs = now - startTime;
155
193
 
156
194
  if (evt.type === "tool_execution_start") {
195
+ if (options.allowIntercomDetach && evt.toolName === "intercom") {
196
+ intercomStarted = true;
197
+ }
157
198
  progress.toolCount++;
158
199
  progress.currentTool = evt.toolName;
159
200
  progress.currentToolArgs = extractToolArgsPreview((evt.args || {}) as Record<string, unknown>);
@@ -215,35 +256,55 @@ async function runSingleAttempt(
215
256
  stderrBuf += d.toString();
216
257
  });
217
258
  proc.on("close", (code) => {
259
+ void jsonlWriter.close().catch(() => {
260
+ // JSONL artifact flush is best effort.
261
+ });
262
+ cleanupTempDir(tempDir);
263
+ if (detached) {
264
+ finish(-2);
265
+ return;
266
+ }
218
267
  processClosed = true;
219
268
  if (buf.trim()) processLine(buf);
220
269
  if (code !== 0 && stderrBuf.trim() && !result.error) {
221
270
  result.error = stderrBuf.trim();
222
271
  }
223
- resolve(code ?? 0);
272
+ finish(code ?? 0);
273
+ });
274
+ proc.on("error", (error) => {
275
+ void jsonlWriter.close().catch(() => {
276
+ // JSONL artifact flush is best effort.
277
+ });
278
+ cleanupTempDir(tempDir);
279
+ if (!result.error) {
280
+ result.error = error instanceof Error ? error.message : String(error);
281
+ }
282
+ finish(1);
224
283
  });
225
- proc.on("error", () => resolve(1));
226
284
 
227
285
  if (options.signal) {
228
286
  const kill = () => {
287
+ if (processClosed || detached) return;
288
+ if (options.allowIntercomDetach && intercomStarted && !detached) {
289
+ detachForIntercom();
290
+ return;
291
+ }
229
292
  proc.kill("SIGTERM");
230
293
  setTimeout(() => !proc.killed && proc.kill("SIGKILL"), 3000);
231
294
  };
232
295
  if (options.signal.aborted) kill();
233
- else options.signal.addEventListener("abort", kill, { once: true });
296
+ else {
297
+ options.signal.addEventListener("abort", kill, { once: true });
298
+ removeAbortListener = () => options.signal?.removeEventListener("abort", kill);
299
+ }
234
300
  }
235
301
  });
236
-
237
- if (closeJsonlWriter) {
238
- try {
239
- await closeJsonlWriter();
240
- } catch {
241
- // JSONL artifact flush is best effort.
242
- }
243
- }
244
-
245
- cleanupTempDir(tempDir);
246
302
  result.exitCode = exitCode;
303
+ if (result.detached) {
304
+ result.exitCode = 0;
305
+ result.finalOutput = "Detached for intercom coordination.";
306
+ return result;
307
+ }
247
308
 
248
309
  if (exitCode === 0 && !result.error) {
249
310
  const errInfo = detectSubagentError(result.messages);
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, "maxSubagentDepth": 1, "intercomBridge": "always", "worktreeSetupHook": "./scripts/setup-worktree.mjs" }
12
+ * { "asyncByDefault": true, "maxSubagentDepth": 1, "intercomBridge": { "mode": "always", "instructionFile": "./intercom-bridge.md" }, "worktreeSetupHook": "./scripts/setup-worktree.mjs" }
13
13
  */
14
14
 
15
15
  import * as fs from "node:fs";
@@ -2,27 +2,40 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import type { AgentConfig } from "./agents.ts";
5
- import type { ExtensionConfig } from "./types.ts";
5
+ import type { ExtensionConfig, IntercomBridgeConfig, IntercomBridgeMode } from "./types.ts";
6
6
 
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
+ const DEFAULT_SUBAGENT_CONFIG_DIR = path.join(os.homedir(), ".pi", "agent", "extensions", "subagent");
10
+ const DEFAULT_INTERCOM_TARGET_PREFIX = "subagent-chat";
9
11
  const INTERCOM_BRIDGE_MARKER = "Intercom orchestration channel:";
10
-
11
- export type IntercomBridgeMode = NonNullable<ExtensionConfig["intercomBridge"]>;
12
+ const DEFAULT_INTERCOM_BRIDGE_TEMPLATE = `Use intercom only for coordination with the orchestrator session:
13
+ - Need a decision or blocked: intercom({ action: "ask", to: "{orchestratorTarget}", message: "<question>" })
14
+ - Completion/update: intercom({ action: "send", to: "{orchestratorTarget}", message: "DONE: <summary>" })
15
+ If intercom is unavailable in this run, continue the task normally.`;
12
16
 
13
17
  export interface IntercomBridgeState {
14
18
  active: boolean;
15
19
  mode: IntercomBridgeMode;
16
20
  orchestratorTarget?: string;
17
21
  extensionDir: string;
22
+ instruction: string;
18
23
  }
19
24
 
20
25
  interface ResolveIntercomBridgeInput {
21
- mode: unknown;
26
+ config: ExtensionConfig["intercomBridge"];
22
27
  context: "fresh" | "fork" | undefined;
23
28
  orchestratorTarget?: string;
24
29
  extensionDir?: string;
25
30
  configPath?: string;
31
+ settingsDir?: string;
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)}`;
26
39
  }
27
40
 
28
41
  export function resolveIntercomBridgeMode(value: unknown): IntercomBridgeMode {
@@ -30,6 +43,19 @@ export function resolveIntercomBridgeMode(value: unknown): IntercomBridgeMode {
30
43
  return "always";
31
44
  }
32
45
 
46
+ function resolveIntercomBridgeConfig(value: ExtensionConfig["intercomBridge"]): Required<IntercomBridgeConfig> {
47
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
48
+ return {
49
+ mode: "always",
50
+ instructionFile: "",
51
+ };
52
+ }
53
+ return {
54
+ mode: resolveIntercomBridgeMode(value.mode),
55
+ instructionFile: typeof value.instructionFile === "string" ? value.instructionFile : "",
56
+ };
57
+ }
58
+
33
59
  function intercomEnabled(configPath: string): boolean {
34
60
  if (!fs.existsSync(configPath)) return true;
35
61
  try {
@@ -56,43 +82,71 @@ function extensionSandboxAllowsIntercom(extensions: string[] | undefined, extens
56
82
  return false;
57
83
  }
58
84
 
59
- function buildIntercomBridgeInstruction(orchestratorTarget: string): string {
60
- const escapedTarget = JSON.stringify(orchestratorTarget);
85
+ function expandTilde(filePath: string): string {
86
+ return filePath.startsWith("~/") ? path.join(os.homedir(), filePath.slice(2)) : filePath;
87
+ }
88
+
89
+ function resolveInstructionTemplate(instructionFile: string, settingsDir: string): string {
90
+ if (!instructionFile) return DEFAULT_INTERCOM_BRIDGE_TEMPLATE;
91
+ const expandedPath = expandTilde(instructionFile);
92
+ const resolvedPath = path.isAbsolute(expandedPath)
93
+ ? expandedPath
94
+ : path.resolve(settingsDir, expandedPath);
95
+ try {
96
+ return fs.readFileSync(resolvedPath, "utf-8");
97
+ } catch (error) {
98
+ console.warn(`Failed to read intercom bridge instructionFile at '${resolvedPath}'. Using default instructions.`, error);
99
+ return DEFAULT_INTERCOM_BRIDGE_TEMPLATE;
100
+ }
101
+ }
102
+
103
+ function buildIntercomBridgeInstruction(orchestratorTarget: string, template: string): string {
104
+ const instruction = template.replaceAll("{orchestratorTarget}", orchestratorTarget).trim();
105
+ if (instruction.startsWith(INTERCOM_BRIDGE_MARKER)) return instruction;
61
106
  return `${INTERCOM_BRIDGE_MARKER}
62
- Use intercom only for coordination with the orchestrator session:
63
- - Need a decision or blocked: intercom({ action: "ask", to: ${escapedTarget}, message: "<question>" })
64
- - Completion/update: intercom({ action: "send", to: ${escapedTarget}, message: "DONE: <summary>" })
65
- If intercom is unavailable in this run, continue the task normally.`;
107
+ ${instruction}`;
66
108
  }
67
109
 
68
110
  export function resolveIntercomBridge(input: ResolveIntercomBridgeInput): IntercomBridgeState {
69
- const mode = resolveIntercomBridgeMode(input.mode);
111
+ const config = resolveIntercomBridgeConfig(input.config);
112
+ const mode = config.mode;
70
113
  const extensionDir = path.resolve(input.extensionDir ?? DEFAULT_INTERCOM_EXTENSION_DIR);
71
114
  const orchestratorTarget = input.orchestratorTarget?.trim();
115
+ const settingsDir = path.resolve(input.settingsDir ?? DEFAULT_SUBAGENT_CONFIG_DIR);
116
+ const defaultInstruction = buildIntercomBridgeInstruction(
117
+ orchestratorTarget || "{orchestratorTarget}",
118
+ DEFAULT_INTERCOM_BRIDGE_TEMPLATE,
119
+ );
72
120
 
73
121
  if (mode === "off") {
74
- return { active: false, mode, extensionDir };
122
+ return { active: false, mode, extensionDir, instruction: defaultInstruction };
75
123
  }
76
124
  if (mode === "fork-only" && input.context !== "fork") {
77
- return { active: false, mode, extensionDir };
125
+ return { active: false, mode, extensionDir, instruction: defaultInstruction };
78
126
  }
79
127
  if (!orchestratorTarget) {
80
- return { active: false, mode, extensionDir };
128
+ return { active: false, mode, extensionDir, instruction: defaultInstruction };
81
129
  }
82
130
  if (!fs.existsSync(extensionDir)) {
83
- return { active: false, mode, extensionDir };
131
+ return { active: false, mode, extensionDir, instruction: defaultInstruction };
84
132
  }
85
133
 
86
134
  const configPath = path.resolve(input.configPath ?? DEFAULT_INTERCOM_CONFIG_PATH);
87
135
  if (!intercomEnabled(configPath)) {
88
- return { active: false, mode, extensionDir };
136
+ return { active: false, mode, extensionDir, instruction: defaultInstruction };
89
137
  }
90
138
 
139
+ const instruction = buildIntercomBridgeInstruction(
140
+ orchestratorTarget,
141
+ resolveInstructionTemplate(config.instructionFile, settingsDir),
142
+ );
143
+
91
144
  return {
92
145
  active: true,
93
146
  mode,
94
147
  orchestratorTarget,
95
148
  extensionDir,
149
+ instruction,
96
150
  };
97
151
  }
98
152
 
@@ -103,7 +157,7 @@ export function applyIntercomBridgeToAgent(agent: AgentConfig, bridge: IntercomB
103
157
  const tools = agent.tools && !agent.tools.includes("intercom")
104
158
  ? [...agent.tools, "intercom"]
105
159
  : agent.tools;
106
- const instruction = buildIntercomBridgeInstruction(bridge.orchestratorTarget);
160
+ const instruction = bridge.instruction;
107
161
  const trimmedPrompt = agent.systemPrompt?.trim() || "";
108
162
  const systemPrompt = trimmedPrompt.includes(INTERCOM_BRIDGE_MARKER)
109
163
  ? trimmedPrompt
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.13.2",
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/render.ts CHANGED
@@ -195,9 +195,11 @@ export function renderSubagentResult(
195
195
  const isRunning = r.progress?.status === "running";
196
196
  const icon = isRunning
197
197
  ? theme.fg("warning", "...")
198
- : r.exitCode === 0
199
- ? theme.fg("success", "ok")
200
- : theme.fg("error", "X");
198
+ : r.detached
199
+ ? theme.fg("warning", "")
200
+ : r.exitCode === 0
201
+ ? theme.fg("success", "ok")
202
+ : theme.fg("error", "X");
201
203
  const contextBadge = d.context === "fork" ? theme.fg("warning", " [fork]") : "";
202
204
  const output = r.truncation?.text || getSingleResultOutput(r);
203
205
 
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 {
@@ -995,6 +995,8 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
995
995
  const r = await runSync(ctx.cwd, agents, params.agent!, task, {
996
996
  cwd: params.cwd,
997
997
  signal,
998
+ allowIntercomDetach: agentConfig.systemPrompt?.includes("Intercom orchestration channel:") === true,
999
+ intercomEvents: deps.pi.events,
998
1000
  runId,
999
1001
  sessionDir: sessionDirForIndex(0),
1000
1002
  sessionFile: sessionFileForIndex(0),
@@ -1024,6 +1026,19 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
1024
1026
  saveError: r.outputSaveError,
1025
1027
  });
1026
1028
 
1029
+ if (r.detached) {
1030
+ return {
1031
+ content: [{ type: "text", text: `Detached for intercom coordination: ${params.agent}` }],
1032
+ details: {
1033
+ mode: "single",
1034
+ results: [r],
1035
+ progress: params.includeProgress ? allProgress : undefined,
1036
+ artifacts: allArtifactPaths.length ? { dir: artifactsDir, files: allArtifactPaths } : undefined,
1037
+ truncation: r.truncation,
1038
+ },
1039
+ };
1040
+ }
1041
+
1027
1042
  if (r.exitCode !== 0)
1028
1043
  return {
1029
1044
  content: [{ type: "text", text: r.error || "Failed" }],
@@ -1102,12 +1117,11 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
1102
1117
  const parentSessionFile = ctx.sessionManager.getSessionFile() ?? null;
1103
1118
  deps.state.currentSessionId = parentSessionFile ?? `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1104
1119
  const discoveredAgents = deps.discoverAgents(ctx.cwd, scope).agents;
1105
- const piWithSessionName = deps.pi as ExtensionAPI & { getSessionName?: () => string | undefined };
1106
- const orchestratorTarget = piWithSessionName.getSessionName?.();
1120
+ const sessionName = resolveIntercomSessionTarget(deps.pi.getSessionName(), ctx.sessionManager.getSessionId());
1107
1121
  const intercomBridge = resolveIntercomBridge({
1108
- mode: deps.config.intercomBridge,
1122
+ config: deps.config.intercomBridge,
1109
1123
  context: normalizedParams.context,
1110
- orchestratorTarget,
1124
+ orchestratorTarget: sessionName,
1111
1125
  });
1112
1126
  const agents = intercomBridge.active
1113
1127
  ? discoveredAgents.map((agent) => applyIntercomBridgeToAgent(agent, intercomBridge))
@@ -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/types.ts CHANGED
@@ -58,7 +58,7 @@ export interface ResolvedSkill {
58
58
  export interface AgentProgress {
59
59
  index: number;
60
60
  agent: string;
61
- status: "pending" | "running" | "completed" | "failed";
61
+ status: "pending" | "running" | "completed" | "failed" | "detached";
62
62
  task: string;
63
63
  skills?: string[];
64
64
  currentTool?: string;
@@ -94,6 +94,8 @@ export interface SingleResult {
94
94
  agent: string;
95
95
  task: string;
96
96
  exitCode: number;
97
+ detached?: boolean;
98
+ detachedReason?: string;
97
99
  messages: Message[];
98
100
  usage: Usage;
99
101
  model?: string;
@@ -237,6 +239,14 @@ export interface ErrorInfo {
237
239
  details?: string;
238
240
  }
239
241
 
242
+ export interface IntercomEventBus {
243
+ on(channel: string, handler: (data: unknown) => void): () => void;
244
+ emit(channel: string, data: unknown): void;
245
+ }
246
+
247
+ export const INTERCOM_DETACH_REQUEST_EVENT = "pi-intercom:detach-request";
248
+ export const INTERCOM_DETACH_RESPONSE_EVENT = "pi-intercom:detach-response";
249
+
240
250
  // ============================================================================
241
251
  // Execution Options
242
252
  // ============================================================================
@@ -244,6 +254,8 @@ export interface ErrorInfo {
244
254
  export interface RunSyncOptions {
245
255
  cwd?: string;
246
256
  signal?: AbortSignal;
257
+ allowIntercomDetach?: boolean;
258
+ intercomEvents?: IntercomEventBus;
247
259
  onUpdate?: (r: import("@mariozechner/pi-agent-core").AgentToolResult<Details>) => void;
248
260
  maxOutput?: MaxOutputConfig;
249
261
  artifactsDir?: string;
@@ -263,13 +275,20 @@ export interface RunSyncOptions {
263
275
  skills?: string[];
264
276
  }
265
277
 
278
+ export type IntercomBridgeMode = "off" | "fork-only" | "always";
279
+
280
+ export interface IntercomBridgeConfig {
281
+ mode?: IntercomBridgeMode;
282
+ instructionFile?: string;
283
+ }
284
+
266
285
  export interface ExtensionConfig {
267
286
  asyncByDefault?: boolean;
268
287
  defaultSessionDir?: string;
269
288
  maxSubagentDepth?: number;
270
289
  worktreeSetupHook?: string;
271
290
  worktreeSetupHookTimeoutMs?: number;
272
- intercomBridge?: "off" | "fork-only" | "always";
291
+ intercomBridge?: IntercomBridgeConfig;
273
292
  }
274
293
 
275
294
  // ============================================================================
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 {