pi-subagents 0.32.0 → 0.33.0

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +30 -3
  2. package/README.md +147 -58
  3. package/install.mjs +2 -1
  4. package/package.json +3 -2
  5. package/skills/pi-subagents/SKILL.md +70 -14
  6. package/src/agents/agent-management.ts +177 -5
  7. package/src/agents/agent-memory.ts +254 -0
  8. package/src/agents/agent-serializer.ts +11 -0
  9. package/src/agents/agents.ts +142 -12
  10. package/src/agents/chain-serializer.ts +27 -2
  11. package/src/extension/doctor.ts +1 -9
  12. package/src/extension/fanout-child.ts +2 -2
  13. package/src/extension/index.ts +65 -90
  14. package/src/extension/rpc.ts +369 -0
  15. package/src/extension/schemas.ts +52 -8
  16. package/src/extension/tool-description.ts +200 -0
  17. package/src/intercom/intercom-bridge.ts +21 -253
  18. package/src/intercom/native-supervisor-channel.ts +510 -0
  19. package/src/runs/background/async-execution.ts +51 -7
  20. package/src/runs/background/async-job-tracker.ts +12 -2
  21. package/src/runs/background/async-status.ts +27 -2
  22. package/src/runs/background/completion-batcher.ts +166 -0
  23. package/src/runs/background/control-channel.ts +106 -1
  24. package/src/runs/background/fleet-view.ts +515 -0
  25. package/src/runs/background/notify.ts +161 -44
  26. package/src/runs/background/result-watcher.ts +1 -2
  27. package/src/runs/background/run-id-resolver.ts +3 -2
  28. package/src/runs/background/run-status.ts +166 -6
  29. package/src/runs/background/scheduled-runs.ts +514 -0
  30. package/src/runs/background/subagent-runner.ts +409 -35
  31. package/src/runs/background/wait.ts +353 -0
  32. package/src/runs/foreground/chain-execution.ts +95 -21
  33. package/src/runs/foreground/execution.ts +150 -21
  34. package/src/runs/foreground/subagent-executor.ts +378 -64
  35. package/src/runs/shared/dynamic-fanout.ts +1 -1
  36. package/src/runs/shared/model-fallback.ts +167 -20
  37. package/src/runs/shared/model-scope.ts +128 -0
  38. package/src/runs/shared/nested-events.ts +31 -0
  39. package/src/runs/shared/parallel-utils.ts +1 -0
  40. package/src/runs/shared/pi-args.ts +30 -1
  41. package/src/runs/shared/subagent-prompt-runtime.ts +108 -2
  42. package/src/runs/shared/tool-budget.ts +74 -0
  43. package/src/runs/shared/turn-budget.ts +52 -0
  44. package/src/shared/artifacts.ts +1 -0
  45. package/src/shared/atomic-json.ts +15 -2
  46. package/src/shared/child-transcript.ts +212 -0
  47. package/src/shared/settings.ts +3 -1
  48. package/src/shared/types.ts +134 -19
  49. package/src/slash/prompt-workflows.ts +330 -0
  50. package/src/slash/slash-commands.ts +16 -2
  51. package/src/tui/render.ts +16 -8
  52. package/src/extension/companion-suggestions.ts +0 -359
@@ -0,0 +1,200 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { ExtensionConfig, ToolDescriptionMode } from "../shared/types.ts";
4
+ import { getAgentDir, getProjectConfigDir } from "../shared/utils.ts";
5
+
6
+ const CUSTOM_TOOL_DESCRIPTION_FILE = "subagent-tool-description.md";
7
+ const CUSTOM_TOOL_DESCRIPTION_MAX_BYTES = 50 * 1024;
8
+
9
+ export const SUBAGENT_SAFETY_GUIDANCE = `SAFETY-CRITICAL SUBAGENT GUIDANCE:
10
+ • Use { action: "list" } before execution and only run executable/non-disabled agents or chains.
11
+ • Keep execution and management separate: omit action for SINGLE/PARALLEL/CHAIN execution; use action only for list/get/models/create/update/delete/status/interrupt/resume/append-step/doctor.
12
+ • Async/background runs: launch with async:true only when work can proceed independently. Do not sleep or poll status just to wait; if this turn must block, use the wait tool. Otherwise continue useful work or respond and let completion notifications arrive.
13
+ • Child-safety boundary: ordinary child subagents are not orchestrators and must not run subagents. Only explicitly configured fanout children may use the child-safe subagent tool, still bounded by depth/session limits.
14
+ • Writing/review safety: keep one writer for the same cwd/worktree. Use fresh-context read-only reviewers/validators for independent review, then have the parent synthesize and apply fixes as the sole writer unless an isolated worktree was intentionally requested.
15
+ • Artifacts/status essentials: chain outputs live under {chain_dir}; async runs expose asyncId/asyncDir with status.json, events.jsonl, output logs, and status via { action: "status", id }. Include output paths and residual risks when reporting results.`;
16
+
17
+ export const FULL_SUBAGENT_TOOL_DESCRIPTION = `Delegate to subagents or manage agent definitions.
18
+
19
+ EXECUTION (use exactly ONE mode):
20
+ • Before executing, use { action: "list" } to inspect configured agents/chains. Only execute agents listed as executable/non-disabled.
21
+ • SINGLE: { agent, task? } - one task; omit task for self-contained agents
22
+ • CHAIN: { chain: [{agent:"agent-a"}, {parallel:[{agent:"agent-b",count:3}]}] } - sequential pipeline with optional parallel fan-out
23
+ • PARALLEL: { tasks: [{agent,task,count?,output?,reads?,progress?}, ...], concurrency?: number, worktree?: true } - concurrent execution (worktree: isolate each task in a git worktree)
24
+ • Optional context: { context: "fresh" | "fork" } (explicit value overrides every child; when omitted, each requested agent uses its own defaultContext, otherwise "fresh"; inspect agent defaults via { action: "list" })
25
+ • Optional timeout: { timeoutMs } or { maxRuntimeMs } sets a run-level max runtime for foreground and async/background runs
26
+ • If { action: "list" } shows proactive skill subagent suggestions, consider a small fresh-context fanout for broad tasks where one of those skills would materially help
27
+
28
+ CHAIN TEMPLATE VARIABLES (use in task strings):
29
+ • {task} - The original task/request from the user
30
+ • {previous} - Text response from the previous step (empty for first step)
31
+ • {chain_dir} - Shared directory for chain files (e.g., <tmpdir>/pi-subagents-<scope>/chain-runs/abc123/)
32
+
33
+ Example: { chain: [{agent:"agent-a", task:"Analyze {task}"}, {agent:"agent-b", task:"Plan based on {previous}"}] }
34
+
35
+ MANAGEMENT (use action field, omit agent/task/chain/tasks):
36
+ • { action: "list" } - discover executable agents/chains
37
+ • { action: "get", agent: "name" } - full detail; packaged agents use dotted runtime names like "package.agent"
38
+ • { action: "models", agent?: "name" } - show the runtime-loaded builtin subagent model mapping, optionally filtered to one builtin
39
+ • { action: "create", config: { name: "custom-agent", package: "code-analysis", systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, defaultContext, ... } }
40
+ • { action: "update", agent: "code-analysis.custom-agent", config: { package: "analysis", ... } } - merge
41
+ • { action: "delete", agent: "code-analysis.custom-agent" }
42
+ • { action: "eject", agent: "reviewer", agentScope?: "user" | "project" } - copy a bundled/package agent to user/project scope as an editable custom file that shadows the original (default scope: user)
43
+ • { action: "disable", agent: "reviewer", agentScope?: "user" | "project" } - hide any agent from runtime discovery via a reversible settings override (default scope: user)
44
+ • { action: "enable", agent: "reviewer", agentScope?: "user" | "project" } - remove a disabled override and restore discovery
45
+ • { action: "reset", agent: "reviewer", agentScope?: "user" | "project" } - delete the scope's custom agent file and/or settings override, restoring the bundled default
46
+ • Use chainName for chain operations; packaged chains also use dotted runtime names
47
+
48
+ CONTROL:
49
+ • { action: "status", id: "..." } - inspect an async/background run by id or prefix
50
+ • { action: "status", view: "fleet" } - read-only active foreground/async fleet view with transcript commands
51
+ • { action: "status", id: "...", view: "transcript", index?: 0, lines?: 80 } - tail a run or child output/session transcript
52
+ • { action: "interrupt", id?: "..." } - soft-interrupt the current child turn and leave the run paused
53
+ • { action: "resume", id: "...", message: "...", index?: 0 } - interrupt then follow up with a live async child, or revive a completed async/foreground child from its session
54
+ • { action: "steer", id: "...", message: "...", index?: 0 } - queue non-terminal guidance for a live/queued async Pi child when supported
55
+ • { action: "append-step", id: "...", chain: [{agent:"agent-c", task:"Use {previous}"}] } - append one step to the tail of a running async chain
56
+
57
+ SCHEDULE (opt-in; requires { "scheduledRuns": { "enabled": true } } in config.json):
58
+ • { action: "schedule", agent, task?, schedule: "+10m" | "2030-01-01T09:00:00Z", scheduleName? } - defer a subagent launch until a future time. Also accepts tasks[] or chain[]. Scheduled runs always launch async with fresh context; they become normal tracked async runs once they fire. Only schedule explicit delayed runs the user asked for.
59
+ • { action: "schedule-list" } - list scheduled runs for this session
60
+ • { action: "schedule-status", id: "..." } - inspect one scheduled run
61
+ • { action: "schedule-cancel", id: "..." } - cancel a scheduled run before it fires
62
+
63
+ DIAGNOSTICS:
64
+ • { action: "doctor" } - read-only report for runtime paths, discovery, sessions, and intercom
65
+
66
+ ${SUBAGENT_SAFETY_GUIDANCE}`;
67
+
68
+ export const COMPACT_SUBAGENT_TOOL_DESCRIPTION = `Delegate to subagents or manage definitions. Use exactly one mode per call.
69
+
70
+ EXECUTE:
71
+ • Before execution, call { action: "list" }; run only executable/non-disabled configured agents/chains.
72
+ • SINGLE {agent, task?}; PARALLEL {tasks:[{agent,task,count?,output?,reads?,progress?}], concurrency?, worktree?}; CHAIN {chain:[{agent,task?},{parallel:[...]}]}.
73
+ • context can be "fresh" or "fork"; omitted uses each agent defaultContext, otherwise fresh. timeoutMs/maxRuntimeMs apply to foreground and async/background runs.
74
+ • Chain templates may use {task}, {previous}, {chain_dir}, and named outputs. Parallel worktree isolation requires a clean git repo.
75
+ • If list shows proactive skill subagent suggestions, use a small fresh-context fanout only when the task is broad enough.
76
+
77
+ MANAGE / CONTROL:
78
+ • Use action without execution fields: list, get, models, create, update, delete, eject, disable, enable, reset, doctor.
79
+ • Async control actions: status, interrupt, resume, steer, append-step. Use status view:"fleet" for active-run overview, view:"transcript" to tail child output, and steer for non-terminal live guidance. Use id/runId prefixes carefully; use index for a specific child.
80
+ • Opt-in schedule actions: schedule, schedule-list, schedule-status, schedule-cancel. Schedule only explicit delayed runs the user asked for.
81
+
82
+ ASYNC / WAIT:
83
+ • async:true detaches background work. Do not sleep or poll just to wait; use the wait tool only when this turn must block. Otherwise continue useful work or respond and let completion notifications arrive.
84
+ • Status and artifacts live under asyncId/asyncDir with status.json, events.jsonl, output logs, session files, and { action:"status", id:"..." }.
85
+
86
+ SAFETY:
87
+ • Ordinary child subagents are not orchestrators and must not run subagents. Only explicit fanout children may use child-safe subagent, still bounded by depth/session limits.
88
+ • Keep one writer per cwd/worktree. Use fresh read-only review/validation fanout, then synthesize and apply fixes from the parent unless isolated worktrees were intentionally requested.`;
89
+
90
+ function isToolDescriptionMode(value: unknown): value is ToolDescriptionMode {
91
+ return value === "full" || value === "compact" || value === "custom";
92
+ }
93
+
94
+ function warn(options: ToolDescriptionOptions | undefined, message: string): void {
95
+ (options?.warn ?? console.warn)(`[pi-subagents] ${message}`);
96
+ }
97
+
98
+ export interface ToolDescriptionOptions {
99
+ cwd?: string;
100
+ agentDir?: string;
101
+ warn?: (message: string) => void;
102
+ }
103
+
104
+ export function resolveToolDescriptionMode(config: Pick<ExtensionConfig, "toolDescriptionMode">, options?: ToolDescriptionOptions): ToolDescriptionMode {
105
+ const mode = config.toolDescriptionMode;
106
+ if (mode === undefined) return "full";
107
+ if (isToolDescriptionMode(mode)) return mode;
108
+ warn(options, `Ignoring invalid toolDescriptionMode ${JSON.stringify(mode)}; expected "full", "compact", or "custom".`);
109
+ return "full";
110
+ }
111
+
112
+ function customDescriptionPaths(options?: ToolDescriptionOptions): string[] {
113
+ const cwd = options?.cwd ?? process.cwd();
114
+ const agentDir = options?.agentDir ?? getAgentDir();
115
+ return [
116
+ path.join(getProjectConfigDir(cwd), CUSTOM_TOOL_DESCRIPTION_FILE),
117
+ path.join(agentDir, CUSTOM_TOOL_DESCRIPTION_FILE),
118
+ ];
119
+ }
120
+
121
+ function renderCustomTemplate(template: string, options?: ToolDescriptionOptions): string {
122
+ const cwd = options?.cwd ?? process.cwd();
123
+ const agentDir = options?.agentDir ?? getAgentDir();
124
+ const projectConfigDir = getProjectConfigDir(cwd);
125
+ const variables: Record<string, () => string> = {
126
+ fullDescription: () => FULL_SUBAGENT_TOOL_DESCRIPTION,
127
+ full: () => FULL_SUBAGENT_TOOL_DESCRIPTION,
128
+ compactDescription: () => COMPACT_SUBAGENT_TOOL_DESCRIPTION,
129
+ compact: () => COMPACT_SUBAGENT_TOOL_DESCRIPTION,
130
+ safetyGuidance: () => SUBAGENT_SAFETY_GUIDANCE,
131
+ safety: () => SUBAGENT_SAFETY_GUIDANCE,
132
+ agentDir: () => agentDir,
133
+ projectConfigDir: () => projectConfigDir,
134
+ };
135
+ return template.replace(/\{\{(\w+)\}\}/g, (raw, name: string) => {
136
+ const replacement = variables[name];
137
+ if (replacement) return replacement();
138
+ warn(options, `${CUSTOM_TOOL_DESCRIPTION_FILE}: unknown placeholder ${raw} left unchanged.`);
139
+ return raw;
140
+ });
141
+ }
142
+
143
+ function loadCustomToolDescription(options?: ToolDescriptionOptions): string | undefined {
144
+ for (const filePath of customDescriptionPaths(options)) {
145
+ let stat: fs.Stats;
146
+ try {
147
+ stat = fs.statSync(filePath);
148
+ } catch (error) {
149
+ if (typeof error === "object" && error !== null && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") continue;
150
+ warn(options, `Failed to inspect custom tool description '${filePath}': ${error instanceof Error ? error.message : String(error)}`);
151
+ continue;
152
+ }
153
+ if (!stat.isFile()) {
154
+ warn(options, `Ignoring custom tool description '${filePath}' because it is not a file.`);
155
+ continue;
156
+ }
157
+ if (stat.size > CUSTOM_TOOL_DESCRIPTION_MAX_BYTES) {
158
+ warn(options, `Ignoring custom tool description '${filePath}' because it is larger than ${CUSTOM_TOOL_DESCRIPTION_MAX_BYTES} bytes.`);
159
+ continue;
160
+ }
161
+ try {
162
+ const template = fs.readFileSync(filePath, "utf-8").trim();
163
+ if (!template) {
164
+ warn(options, `Ignoring empty custom tool description '${filePath}'.`);
165
+ continue;
166
+ }
167
+ const rendered = renderCustomTemplate(template, options).trim();
168
+ if (!rendered) {
169
+ warn(options, `Ignoring custom tool description '${filePath}' because it rendered empty.`);
170
+ continue;
171
+ }
172
+ return rendered;
173
+ } catch (error) {
174
+ warn(options, `Failed to read custom tool description '${filePath}': ${error instanceof Error ? error.message : String(error)}`);
175
+ }
176
+ }
177
+ return undefined;
178
+ }
179
+
180
+ function withMandatorySafetyGuidance(description: string): string {
181
+ const customDescription = description
182
+ .split(SUBAGENT_SAFETY_GUIDANCE)
183
+ .map((part) => part.trim())
184
+ .filter(Boolean)
185
+ .join("\n\n");
186
+ return customDescription
187
+ ? `${customDescription}\n\n${SUBAGENT_SAFETY_GUIDANCE}`
188
+ : SUBAGENT_SAFETY_GUIDANCE;
189
+ }
190
+
191
+ export function buildSubagentToolDescription(config: Pick<ExtensionConfig, "toolDescriptionMode"> = {}, options?: ToolDescriptionOptions): string {
192
+ const mode = resolveToolDescriptionMode(config, options);
193
+ if (mode === "compact") return COMPACT_SUBAGENT_TOOL_DESCRIPTION;
194
+ if (mode === "custom") {
195
+ const custom = loadCustomToolDescription(options);
196
+ if (custom) return withMandatorySafetyGuidance(custom);
197
+ warn(options, `${CUSTOM_TOOL_DESCRIPTION_FILE} was not found or valid for toolDescriptionMode "custom"; using full description.`);
198
+ }
199
+ return FULL_SUBAGENT_TOOL_DESCRIPTION;
200
+ }
@@ -1,35 +1,16 @@
1
- import { execSync } from "node:child_process";
2
1
  import * as fs from "node:fs";
3
2
  import * as os from "node:os";
4
3
  import * as path from "node:path";
5
4
  import type { AgentConfig } from "../agents/agents.ts";
6
5
  import type { ExtensionConfig, IntercomBridgeConfig, IntercomBridgeMode } from "../shared/types.ts";
7
- import { getAgentDir, getProjectConfigDir } from "../shared/utils.ts";
6
+ import { getAgentDir } from "../shared/utils.ts";
8
7
 
9
- const PI_INTERCOM_PACKAGE_NAME = "pi-intercom";
8
+ export const NATIVE_INTERCOM_EXTENSION_DIR = "native:pi-subagents-supervisor-channel";
10
9
 
11
10
  function defaultAgentDir(): string {
12
11
  return getAgentDir();
13
12
  }
14
13
 
15
- function defaultIntercomExtensionDir(agentDir = defaultAgentDir()): string {
16
- return path.join(agentDir, "extensions", PI_INTERCOM_PACKAGE_NAME);
17
- }
18
-
19
- export const INTERCOM_EXTENSION_DIR_ENV = "PI_INTERCOM_EXTENSION_DIR";
20
-
21
- // Launcher-provided override for the pi-intercom package directory. Lets a hermetic
22
- // wrapper point the subagent intercom bridge at a read-only install (e.g. a Nix-store
23
- // path) instead of seeding the package into the writable agent dir.
24
- function envIntercomExtensionDir(): string | undefined {
25
- const dir = process.env[INTERCOM_EXTENSION_DIR_ENV]?.trim();
26
- return dir ? dir : undefined;
27
- }
28
-
29
- function defaultIntercomConfigPath(agentDir = defaultAgentDir()): string {
30
- return path.join(agentDir, "intercom", "config.json");
31
- }
32
-
33
14
  function defaultSubagentConfigDir(agentDir = defaultAgentDir()): string {
34
15
  return path.join(agentDir, "extensions", "subagent");
35
16
  }
@@ -59,25 +40,18 @@ export interface IntercomBridgeDiagnostic {
59
40
  active: boolean;
60
41
  mode: IntercomBridgeMode;
61
42
  wantsIntercom: boolean;
62
- piIntercomAvailable: boolean;
43
+ supervisorChannelAvailable: boolean;
63
44
  extensionDir: string;
64
- configPath?: string;
65
45
  orchestratorTarget?: string;
66
46
  reason?: string;
67
- intercomConfigEnabled?: boolean;
68
- intercomConfigError?: string;
69
47
  }
70
48
 
71
49
  interface ResolveIntercomBridgeInput {
72
50
  config: ExtensionConfig["intercomBridge"];
73
51
  context: "fresh" | "fork" | undefined;
74
52
  orchestratorTarget?: string;
75
- extensionDir?: string;
76
- configPath?: string;
77
53
  settingsDir?: string;
78
- cwd?: string;
79
54
  agentDir?: string;
80
- globalNpmRoot?: string | null;
81
55
  }
82
56
 
83
57
  export function resolveIntercomSessionTarget(sessionName: string | undefined, sessionId: string): string {
@@ -103,10 +77,7 @@ export function resolveIntercomBridgeMode(value: unknown): IntercomBridgeMode {
103
77
 
104
78
  function resolveIntercomBridgeConfig(value: ExtensionConfig["intercomBridge"]): Required<IntercomBridgeConfig> {
105
79
  if (!value || typeof value !== "object" || Array.isArray(value)) {
106
- return {
107
- mode: "always",
108
- instructionFile: "",
109
- };
80
+ return { mode: "always", instructionFile: "" };
110
81
  }
111
82
  return {
112
83
  mode: resolveIntercomBridgeMode(value.mode),
@@ -114,169 +85,6 @@ function resolveIntercomBridgeConfig(value: ExtensionConfig["intercomBridge"]):
114
85
  };
115
86
  }
116
87
 
117
- function intercomConfigStatus(configPath: string): { enabled: boolean; error?: unknown } {
118
- if (!fs.existsSync(configPath)) return { enabled: true };
119
- try {
120
- const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8")) as { enabled?: unknown };
121
- return { enabled: parsed.enabled !== false };
122
- } catch (error) {
123
- return { enabled: true, error };
124
- }
125
- }
126
-
127
- function readJsonBestEffort(filePath: string): unknown {
128
- try {
129
- return JSON.parse(fs.readFileSync(filePath, "utf-8"));
130
- } catch (error) {
131
- const code = error && typeof error === "object" && "code" in error ? (error as NodeJS.ErrnoException).code : undefined;
132
- if (code !== "ENOENT") console.warn(`Failed to read JSON from '${filePath}'.`, error);
133
- return null;
134
- }
135
- }
136
-
137
- function packageHasPiExtension(packageRoot: string): boolean {
138
- if (!fs.existsSync(packageRoot)) return false;
139
- const pkg = readJsonBestEffort(path.join(packageRoot, "package.json"));
140
- if (pkg && typeof pkg === "object" && !Array.isArray(pkg)) {
141
- const pi = (pkg as { pi?: unknown }).pi;
142
- if (pi && typeof pi === "object" && !Array.isArray(pi)) {
143
- const extensions = (pi as { extensions?: unknown }).extensions;
144
- return Array.isArray(extensions) && extensions.some((entry) => typeof entry === "string" && entry.trim() !== "");
145
- }
146
- }
147
- return fs.existsSync(path.join(packageRoot, "extensions"));
148
- }
149
-
150
- function isSafePackagePath(value: string): boolean {
151
- return value.length > 0
152
- && !path.isAbsolute(value)
153
- && value.split(/[\\/]/).every((part) => part.length > 0 && part !== "." && part !== "..");
154
- }
155
-
156
- function parseNpmPackageName(source: string): string | undefined {
157
- const spec = source.slice(4).trim();
158
- if (!spec) return undefined;
159
- const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@(.+))?$/);
160
- const packageName = match?.[1] ?? spec;
161
- return isSafePackagePath(packageName) ? packageName : undefined;
162
- }
163
-
164
- function packageEntrySource(entry: unknown): string | undefined {
165
- if (typeof entry === "string") return entry;
166
- if (entry && typeof entry === "object" && !Array.isArray(entry) && typeof (entry as { source?: unknown }).source === "string") {
167
- return (entry as { source: string }).source;
168
- }
169
- return undefined;
170
- }
171
-
172
- function packageEntryAllowsExtensions(entry: unknown): boolean {
173
- if (!entry || typeof entry !== "object" || Array.isArray(entry)) return true;
174
- const extensions = (entry as { extensions?: unknown }).extensions;
175
- return !Array.isArray(extensions) || extensions.length > 0;
176
- }
177
-
178
- function findNearestProjectConfigDir(cwd: string): string | undefined {
179
- let current = path.resolve(cwd);
180
- while (true) {
181
- const configDir = getProjectConfigDir(current);
182
- if (fs.existsSync(path.join(configDir, "settings.json"))) return configDir;
183
- const parent = path.dirname(current);
184
- if (parent === current) return undefined;
185
- current = parent;
186
- }
187
- }
188
-
189
- let cachedGlobalNpmRoot: string | null | undefined;
190
-
191
- function getGlobalNpmRoot(): string | null {
192
- if (cachedGlobalNpmRoot !== undefined) return cachedGlobalNpmRoot;
193
- try {
194
- cachedGlobalNpmRoot = execSync("npm root -g", { encoding: "utf-8", timeout: 5000 }).trim();
195
- return cachedGlobalNpmRoot;
196
- } catch {
197
- cachedGlobalNpmRoot = null;
198
- return null;
199
- }
200
- }
201
-
202
- function tmpNpmIntercomPackageDir(agentDir: string): string | undefined {
203
- const tmpNpmDir = path.join(agentDir, "tmp", "extensions", "npm");
204
- try {
205
- const entries = fs.readdirSync(tmpNpmDir, { withFileTypes: true });
206
- for (const entry of entries) {
207
- if (!entry.isDirectory()) continue;
208
- const pkgDir = path.join(tmpNpmDir, entry.name, "node_modules", PI_INTERCOM_PACKAGE_NAME);
209
- if (fs.existsSync(pkgDir) && packageHasPiExtension(pkgDir)) {
210
- return path.resolve(pkgDir);
211
- }
212
- }
213
- } catch {
214
- // ignore ENOTDIR, ENOENT, permission errors
215
- }
216
- return undefined;
217
- }
218
-
219
- function configuredPiIntercomPackageDir(input: ResolveIntercomBridgeInput, agentDir: string): string | undefined {
220
- const projectConfigDir = input.cwd ? findNearestProjectConfigDir(path.resolve(input.cwd)) : undefined;
221
- const settingsFiles = [
222
- ...(projectConfigDir ? [{ file: path.join(projectConfigDir, "settings.json"), configDir: projectConfigDir, scope: "project" as const }] : []),
223
- { file: path.join(agentDir, "settings.json"), configDir: agentDir, scope: "user" as const },
224
- ];
225
- const globalNpmRoot = input.globalNpmRoot === undefined ? getGlobalNpmRoot() : input.globalNpmRoot;
226
-
227
- for (const { file, configDir, scope } of settingsFiles) {
228
- const settings = readJsonBestEffort(file);
229
- if (!settings || typeof settings !== "object" || Array.isArray(settings)) continue;
230
- const packages = (settings as { packages?: unknown }).packages;
231
- if (!Array.isArray(packages)) continue;
232
-
233
- for (const entry of packages) {
234
- if (!packageEntryAllowsExtensions(entry)) continue;
235
- const source = packageEntrySource(entry)?.trim();
236
- if (!source?.startsWith("npm:")) continue;
237
- const packageName = parseNpmPackageName(source);
238
- if (packageName !== PI_INTERCOM_PACKAGE_NAME) continue;
239
- const candidates = scope === "project"
240
- ? [path.join(configDir, "npm", "node_modules", packageName)]
241
- : [
242
- ...(globalNpmRoot ? [path.join(globalNpmRoot, packageName)] : []),
243
- path.join(agentDir, "npm", "node_modules", packageName),
244
- ];
245
- const packageRoot = candidates.find(packageHasPiExtension);
246
- if (packageRoot) return path.resolve(packageRoot);
247
- }
248
- }
249
- return undefined;
250
- }
251
-
252
- function resolveIntercomExtensionDir(input: ResolveIntercomBridgeInput, agentDir: string): string {
253
- const legacyDir = path.resolve(input.extensionDir ?? envIntercomExtensionDir() ?? defaultIntercomExtensionDir(agentDir));
254
- if (fs.existsSync(legacyDir)) return legacyDir;
255
-
256
- const configured = configuredPiIntercomPackageDir(input, agentDir);
257
- if (configured) return configured;
258
-
259
- const tmpDir = tmpNpmIntercomPackageDir(agentDir);
260
- if (tmpDir) return tmpDir;
261
-
262
- return legacyDir;
263
- }
264
-
265
- function extensionSandboxAllowsIntercom(extensions: string[] | undefined, extensionDir: string): boolean {
266
- if (extensions === undefined) return true;
267
-
268
- const intercomDir = path.resolve(extensionDir).replaceAll("\\", "/").toLowerCase();
269
- for (const entry of extensions) {
270
- const normalized = entry.trim().replaceAll("\\", "/").toLowerCase();
271
- if (normalized === "pi-intercom") return true;
272
- if (normalized === intercomDir) return true;
273
- if (normalized.startsWith(`${intercomDir}/`)) return true;
274
- if (normalized.endsWith("/pi-intercom")) return true;
275
- if (normalized.includes("/pi-intercom/")) return true;
276
- }
277
- return false;
278
- }
279
-
280
88
  function expandTilde(filePath: string): string {
281
89
  return filePath.startsWith("~/") ? path.join(os.homedir(), filePath.slice(2)) : filePath;
282
90
  }
@@ -298,98 +106,58 @@ function resolveInstructionTemplate(instructionFile: string, settingsDir: string
298
106
  function buildIntercomBridgeInstruction(orchestratorTarget: string, template: string): string {
299
107
  const instruction = template.replaceAll("{orchestratorTarget}", orchestratorTarget).trim();
300
108
  if (instruction.startsWith(INTERCOM_BRIDGE_MARKER)) return instruction;
301
- return `${INTERCOM_BRIDGE_MARKER}
302
- ${instruction}`;
109
+ return `${INTERCOM_BRIDGE_MARKER}\n${instruction}`;
110
+ }
111
+
112
+ function inactiveReason(mode: IntercomBridgeMode, context: "fresh" | "fork" | undefined, orchestratorTarget: string | undefined): string | undefined {
113
+ if (mode === "off") return "bridge mode is off";
114
+ if (mode === "fork-only" && context !== "fork") return "bridge mode is fork-only and context is not fork";
115
+ if (!orchestratorTarget) return "orchestrator target is not available";
116
+ return undefined;
303
117
  }
304
118
 
305
119
  export function diagnoseIntercomBridge(input: ResolveIntercomBridgeInput): IntercomBridgeDiagnostic {
306
120
  const config = resolveIntercomBridgeConfig(input.config);
307
121
  const mode = config.mode;
308
- const agentDir = path.resolve(input.agentDir ?? defaultAgentDir());
309
- const extensionDir = resolveIntercomExtensionDir(input, agentDir);
310
122
  const orchestratorTarget = input.orchestratorTarget?.trim();
311
- const configPath = path.resolve(input.configPath ?? defaultIntercomConfigPath(agentDir));
312
123
  const wantsIntercom = mode !== "off" && !(mode === "fork-only" && input.context !== "fork");
313
- const piIntercomAvailable = fs.existsSync(extensionDir);
314
- let configStatus: ReturnType<typeof intercomConfigStatus> | undefined;
315
- let reason: string | undefined;
316
- if (mode === "off") reason = "bridge mode is off";
317
- else if (mode === "fork-only" && input.context !== "fork") reason = "bridge mode is fork-only and context is not fork";
318
- else if (!orchestratorTarget) reason = "orchestrator target is not available";
319
- else if (!piIntercomAvailable) reason = "pi-intercom extension was not found";
320
- else {
321
- configStatus = intercomConfigStatus(configPath);
322
- if (!configStatus.enabled) reason = "intercom config is disabled";
323
- }
324
- let intercomConfigError: string | undefined;
325
- if (configStatus?.error) {
326
- const error = configStatus.error;
327
- intercomConfigError = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
328
- }
329
-
124
+ const reason = inactiveReason(mode, input.context, orchestratorTarget);
330
125
  return {
331
126
  active: reason === undefined,
332
127
  mode,
333
128
  wantsIntercom,
334
- piIntercomAvailable,
335
- extensionDir,
336
- configPath,
129
+ supervisorChannelAvailable: true,
130
+ extensionDir: NATIVE_INTERCOM_EXTENSION_DIR,
337
131
  ...(orchestratorTarget ? { orchestratorTarget } : {}),
338
132
  ...(reason ? { reason } : {}),
339
- ...(configStatus ? { intercomConfigEnabled: configStatus.enabled } : {}),
340
- ...(intercomConfigError ? { intercomConfigError } : {}),
341
133
  };
342
134
  }
343
135
 
344
136
  export function resolveIntercomBridge(input: ResolveIntercomBridgeInput): IntercomBridgeState {
345
137
  const config = resolveIntercomBridgeConfig(input.config);
346
138
  const mode = config.mode;
347
- const agentDir = path.resolve(input.agentDir ?? defaultAgentDir());
348
- const extensionDir = resolveIntercomExtensionDir(input, agentDir);
349
139
  const orchestratorTarget = input.orchestratorTarget?.trim();
140
+ const agentDir = path.resolve(input.agentDir ?? defaultAgentDir());
350
141
  const settingsDir = path.resolve(input.settingsDir ?? defaultSubagentConfigDir(agentDir));
351
142
  const defaultInstruction = buildIntercomBridgeInstruction(
352
143
  orchestratorTarget || "{orchestratorTarget}",
353
144
  DEFAULT_INTERCOM_BRIDGE_TEMPLATE,
354
145
  );
355
-
356
- if (mode === "off") {
357
- return { active: false, mode, extensionDir, instruction: defaultInstruction };
146
+ const reason = inactiveReason(mode, input.context, orchestratorTarget);
147
+ if (reason || !orchestratorTarget) {
148
+ return { active: false, mode, extensionDir: NATIVE_INTERCOM_EXTENSION_DIR, instruction: defaultInstruction };
358
149
  }
359
- if (mode === "fork-only" && input.context !== "fork") {
360
- return { active: false, mode, extensionDir, instruction: defaultInstruction };
361
- }
362
- if (!orchestratorTarget) {
363
- return { active: false, mode, extensionDir, instruction: defaultInstruction };
364
- }
365
- if (!fs.existsSync(extensionDir)) {
366
- return { active: false, mode, extensionDir, instruction: defaultInstruction };
367
- }
368
-
369
- const configPath = path.resolve(input.configPath ?? defaultIntercomConfigPath(agentDir));
370
- const intercomStatus = intercomConfigStatus(configPath);
371
- if (intercomStatus.error) console.warn(`Failed to parse intercom config at '${configPath}'. Assuming enabled.`, intercomStatus.error);
372
- if (!intercomStatus.enabled) {
373
- return { active: false, mode, extensionDir, instruction: defaultInstruction };
374
- }
375
-
376
- const instruction = buildIntercomBridgeInstruction(
377
- orchestratorTarget,
378
- resolveInstructionTemplate(config.instructionFile, settingsDir),
379
- );
380
-
381
150
  return {
382
151
  active: true,
383
152
  mode,
384
153
  orchestratorTarget,
385
- extensionDir,
386
- instruction,
154
+ extensionDir: NATIVE_INTERCOM_EXTENSION_DIR,
155
+ instruction: buildIntercomBridgeInstruction(orchestratorTarget, resolveInstructionTemplate(config.instructionFile, settingsDir)),
387
156
  };
388
157
  }
389
158
 
390
159
  export function applyIntercomBridgeToAgent(agent: AgentConfig, bridge: IntercomBridgeState): AgentConfig {
391
160
  if (!bridge.active || !bridge.orchestratorTarget) return agent;
392
- if (!extensionSandboxAllowsIntercom(agent.extensions, bridge.extensionDir)) return agent;
393
161
 
394
162
  const bridgeTools = ["intercom", "contact_supervisor"];
395
163
  const tools = agent.tools