pi-subagents 0.18.1 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/doctor.ts ADDED
@@ -0,0 +1,198 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { discoverAgentsAll, type AgentSource } from "./agents.ts";
4
+ import { isAsyncAvailable } from "./async-execution.ts";
5
+ import { diagnoseIntercomBridge, type IntercomBridgeDiagnostic } from "./intercom-bridge.ts";
6
+ import { discoverAvailableSkills, type SkillSource } from "./skills.ts";
7
+ import {
8
+ ASYNC_DIR,
9
+ CHAIN_RUNS_DIR,
10
+ RESULTS_DIR,
11
+ TEMP_ROOT_DIR,
12
+ type ExtensionConfig,
13
+ type SubagentState,
14
+ } from "./types.ts";
15
+
16
+ interface DoctorPaths {
17
+ tempRootDir: string;
18
+ asyncDir: string;
19
+ resultsDir: string;
20
+ chainRunsDir: string;
21
+ }
22
+
23
+ interface DoctorDeps {
24
+ isAsyncAvailable: () => boolean;
25
+ discoverAgentsAll: typeof discoverAgentsAll;
26
+ discoverAvailableSkills: typeof discoverAvailableSkills;
27
+ diagnoseIntercomBridge: typeof diagnoseIntercomBridge;
28
+ }
29
+
30
+ export interface DoctorReportInput {
31
+ cwd: string;
32
+ config: ExtensionConfig;
33
+ state: SubagentState;
34
+ context?: "fresh" | "fork";
35
+ requestedSessionDir?: string;
36
+ currentSessionFile?: string | null;
37
+ currentSessionId?: string | null;
38
+ orchestratorTarget?: string;
39
+ sessionError?: string;
40
+ expandTilde?: (value: string) => string;
41
+ paths?: DoctorPaths;
42
+ deps?: Partial<DoctorDeps>;
43
+ }
44
+
45
+ const DEFAULT_PATHS: DoctorPaths = {
46
+ tempRootDir: TEMP_ROOT_DIR,
47
+ asyncDir: ASYNC_DIR,
48
+ resultsDir: RESULTS_DIR,
49
+ chainRunsDir: CHAIN_RUNS_DIR,
50
+ };
51
+
52
+ const DEFAULT_DEPS: DoctorDeps = {
53
+ isAsyncAvailable,
54
+ discoverAgentsAll,
55
+ discoverAvailableSkills,
56
+ diagnoseIntercomBridge,
57
+ };
58
+
59
+ function errorText(error: unknown): string {
60
+ return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
61
+ }
62
+
63
+ function lineFromCheck(label: string, check: () => string): string {
64
+ try {
65
+ return check();
66
+ } catch (error) {
67
+ return `- ${label}: failed — ${errorText(error)}`;
68
+ }
69
+ }
70
+
71
+ function formatExistingDirectory(label: string, dirPath: string): string {
72
+ try {
73
+ if (!fs.existsSync(dirPath)) return `- ${label}: missing (${dirPath})`;
74
+ const stats = fs.statSync(dirPath);
75
+ if (!stats.isDirectory()) throw new Error(`not a directory: ${dirPath}`);
76
+ fs.accessSync(dirPath, fs.constants.R_OK | fs.constants.W_OK);
77
+ return `- ${label}: ok (${dirPath})`;
78
+ } catch (error) {
79
+ return `- ${label}: failed (${dirPath}) — ${errorText(error)}`;
80
+ }
81
+ }
82
+
83
+ function formatSourceCounts(counts: Record<AgentSource, number>): string {
84
+ return `builtin ${counts.builtin}, user ${counts.user}, project ${counts.project}`;
85
+ }
86
+
87
+ function formatSkillSourceCounts(skills: Array<{ source: SkillSource }>): string {
88
+ const counts = new Map<SkillSource, number>();
89
+ for (const skill of skills) counts.set(skill.source, (counts.get(skill.source) ?? 0) + 1);
90
+ const ordered: SkillSource[] = [
91
+ "project",
92
+ "project-settings",
93
+ "project-package",
94
+ "user",
95
+ "user-settings",
96
+ "user-package",
97
+ "extension",
98
+ "builtin",
99
+ "unknown",
100
+ ];
101
+ const parts = ordered
102
+ .map((source) => `${source} ${counts.get(source) ?? 0}`)
103
+ .filter((part) => !part.endsWith(" 0"));
104
+ return parts.length > 0 ? parts.join(", ") : "none";
105
+ }
106
+
107
+ function formatConfiguredSessionDir(input: DoctorReportInput): string {
108
+ if (input.requestedSessionDir) {
109
+ return path.resolve(input.expandTilde?.(input.requestedSessionDir) ?? input.requestedSessionDir);
110
+ }
111
+ if (input.config.defaultSessionDir) {
112
+ return path.resolve(input.expandTilde?.(input.config.defaultSessionDir) ?? input.config.defaultSessionDir);
113
+ }
114
+ return "not configured";
115
+ }
116
+
117
+ function formatSessionLines(input: DoctorReportInput): string[] {
118
+ const sessionFile = input.currentSessionFile ?? null;
119
+ const lines = [
120
+ lineFromCheck("configured session dir", () => `- configured session dir: ${formatConfiguredSessionDir(input)}`),
121
+ `- current session file: ${sessionFile ?? "not available"}`,
122
+ `- current session dir: ${sessionFile ? path.dirname(sessionFile) : "not available"}`,
123
+ `- current session id: ${input.currentSessionId ?? input.state.currentSessionId ?? "not available"}`,
124
+ ];
125
+ if (input.sessionError) lines.push(`- session manager: failed — ${input.sessionError}`);
126
+ return lines;
127
+ }
128
+
129
+ function formatDiscovery(input: DoctorReportInput, deps: DoctorDeps): string[] {
130
+ return [
131
+ lineFromCheck("agents/chains", () => {
132
+ const discovered = deps.discoverAgentsAll(input.cwd);
133
+ const agentCounts = {
134
+ builtin: discovered.builtin.length,
135
+ user: discovered.user.length,
136
+ project: discovered.project.length,
137
+ };
138
+ const chainCounts = discovered.chains.reduce<Record<AgentSource, number>>((counts, chain) => {
139
+ counts[chain.source] += 1;
140
+ return counts;
141
+ }, { builtin: 0, user: 0, project: 0 });
142
+ return [
143
+ `- agents: total ${agentCounts.builtin + agentCounts.user + agentCounts.project} (${formatSourceCounts(agentCounts)})`,
144
+ `- chains: total ${discovered.chains.length} (${formatSourceCounts(chainCounts)})`,
145
+ ].join("\n");
146
+ }),
147
+ lineFromCheck("skills", () => {
148
+ const skills = deps.discoverAvailableSkills(input.cwd);
149
+ return `- skills: total ${skills.length} (${formatSkillSourceCounts(skills)})`;
150
+ }),
151
+ ];
152
+ }
153
+
154
+ function formatIntercomDiagnostic(diagnostic: IntercomBridgeDiagnostic, context: "fresh" | "fork" | undefined): string[] {
155
+ const lines = [
156
+ `- bridge: ${diagnostic.active ? "active" : "inactive"}${diagnostic.reason ? ` (${diagnostic.reason})` : ""}`,
157
+ `- mode: ${diagnostic.mode}; context: ${context ?? "unspecified"}`,
158
+ `- orchestrator target: ${diagnostic.orchestratorTarget ?? "not available"}`,
159
+ `- pi-intercom: ${diagnostic.piIntercomAvailable ? "available" : "unavailable"} at ${diagnostic.extensionDir}`,
160
+ ];
161
+ if (diagnostic.configPath && diagnostic.intercomConfigEnabled !== undefined) {
162
+ lines.push(`- intercom config: ${diagnostic.intercomConfigEnabled === false ? "disabled" : "enabled or absent"} (${diagnostic.configPath})`);
163
+ }
164
+ if (diagnostic.intercomConfigError) {
165
+ lines.push(`- intercom config warning: ${diagnostic.intercomConfigError}; runtime assumes enabled`);
166
+ }
167
+ return lines;
168
+ }
169
+
170
+ export function buildDoctorReport(input: DoctorReportInput): string {
171
+ const paths = input.paths ?? DEFAULT_PATHS;
172
+ const deps = { ...DEFAULT_DEPS, ...input.deps };
173
+ const lines = [
174
+ "Subagents doctor report",
175
+ "",
176
+ "Runtime",
177
+ `- cwd: ${input.cwd}`,
178
+ lineFromCheck("async support", () => `- async support: ${deps.isAsyncAvailable() ? "available" : "unavailable"}`),
179
+ ...formatSessionLines(input),
180
+ "",
181
+ "Filesystem",
182
+ formatExistingDirectory("temp root", paths.tempRootDir),
183
+ formatExistingDirectory("async runs", paths.asyncDir),
184
+ formatExistingDirectory("results", paths.resultsDir),
185
+ formatExistingDirectory("chain runs", paths.chainRunsDir),
186
+ "",
187
+ "Discovery",
188
+ ...formatDiscovery(input, deps),
189
+ "",
190
+ "Intercom bridge",
191
+ ...lineFromCheck("intercom bridge", () => formatIntercomDiagnostic(deps.diagnoseIntercomBridge({
192
+ config: input.config.intercomBridge,
193
+ context: input.context,
194
+ orchestratorTarget: input.orchestratorTarget,
195
+ }), input.context).join("\n")).split("\n"),
196
+ ];
197
+ return lines.join("\n");
198
+ }
package/execution.ts CHANGED
@@ -243,13 +243,11 @@ async function runSingleAttempt(
243
243
  };
244
244
 
245
245
  const unsubscribeIntercomDetach = options.intercomEvents?.on?.(INTERCOM_DETACH_REQUEST_EVENT, (payload) => {
246
- if (!options.allowIntercomDetach || detached || processClosed) return;
246
+ if (!options.allowIntercomDetach || detached || processClosed || !intercomStarted) return;
247
247
  if (!payload || typeof payload !== "object") return;
248
248
  const requestId = (payload as { requestId?: unknown }).requestId;
249
249
  if (typeof requestId !== "string" || requestId.length === 0) return;
250
- const accepted = intercomStarted;
251
- options.intercomEvents?.emit(INTERCOM_DETACH_RESPONSE_EVENT, { requestId, accepted });
252
- if (!accepted) return;
250
+ options.intercomEvents?.emit(INTERCOM_DETACH_RESPONSE_EVENT, { requestId, accepted: true });
253
251
  detachForIntercom();
254
252
  });
255
253
 
package/index.ts CHANGED
@@ -401,7 +401,7 @@ EXECUTION (use exactly ONE mode):
401
401
  • Before executing, use { action: "list" } to inspect configured agents/chains. Only execute agents listed as executable/non-disabled.
402
402
  • SINGLE: { agent, task? } - one task; omit task for self-contained agents
403
403
  • CHAIN: { chain: [{agent:"agent-a"}, {parallel:[{agent:"agent-b",count:3}]}] } - sequential pipeline with optional parallel fan-out
404
- • PARALLEL: { tasks: [{agent,task,count?}, ...], concurrency?: number, worktree?: true } - concurrent execution (worktree: isolate each task in a git worktree)
404
+ • PARALLEL: { tasks: [{agent,task,count?,output?,reads?,progress?}, ...], concurrency?: number, worktree?: true } - concurrent execution (worktree: isolate each task in a git worktree)
405
405
  • Optional context: { context: "fresh" | "fork" } (default: "fresh")
406
406
 
407
407
  CHAIN TEMPLATE VARIABLES (use in task strings):
@@ -421,7 +421,10 @@ MANAGEMENT (use action field, omit agent/task/chain/tasks):
421
421
 
422
422
  CONTROL:
423
423
  • { action: "status", id: "..." } - inspect an async/background run by id or prefix
424
- • { action: "interrupt", id?: "..." } - soft-interrupt the current child turn and leave the run paused`,
424
+ • { action: "interrupt", id?: "..." } - soft-interrupt the current child turn and leave the run paused
425
+
426
+ DIAGNOSTICS:
427
+ • { action: "doctor" } - read-only report for runtime paths, discovery, sessions, and intercom`,
425
428
  parameters: SubagentParams,
426
429
 
427
430
  execute(id, params, signal, onUpdate, ctx) {
@@ -8,14 +8,14 @@ const DEFAULT_INTERCOM_EXTENSION_DIR = path.join(os.homedir(), ".pi", "agent", "
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
10
  const DEFAULT_INTERCOM_TARGET_PREFIX = "subagent-chat";
11
- const INTERCOM_BRIDGE_MARKER = "Intercom orchestration channel:";
11
+ export const INTERCOM_BRIDGE_MARKER = "Intercom orchestration channel:";
12
12
  const DEFAULT_INTERCOM_BRIDGE_TEMPLATE = `The inherited thread is reference-only. Do not continue that conversation or send questions, status updates, or completion handoffs to the orchestrator in normal assistant text.
13
13
 
14
14
  Use intercom only for coordination with the orchestrator session "{orchestratorTarget}".
15
15
  - Need a decision or blocked: intercom({ action: "ask", to: "{orchestratorTarget}", message: "<question>" })
16
- - Need to report progress or a completion handoff: intercom({ action: "send", to: "{orchestratorTarget}", message: "DONE: <summary>" })
16
+ - Blocked or explicitly asked to send progress: intercom({ action: "send", to: "{orchestratorTarget}", message: "UPDATE: <summary>" })
17
17
 
18
- If no upstream coordination is needed, continue the task normally and return a focused task result.`;
18
+ Do not send routine completion handoffs through intercom. If no coordination is needed, return a focused task result.`;
19
19
 
20
20
  export interface IntercomBridgeState {
21
21
  active: boolean;
@@ -25,6 +25,19 @@ export interface IntercomBridgeState {
25
25
  instruction: string;
26
26
  }
27
27
 
28
+ export interface IntercomBridgeDiagnostic {
29
+ active: boolean;
30
+ mode: IntercomBridgeMode;
31
+ wantsIntercom: boolean;
32
+ piIntercomAvailable: boolean;
33
+ extensionDir: string;
34
+ configPath?: string;
35
+ orchestratorTarget?: string;
36
+ reason?: string;
37
+ intercomConfigEnabled?: boolean;
38
+ intercomConfigError?: string;
39
+ }
40
+
28
41
  interface ResolveIntercomBridgeInput {
29
42
  config: ExtensionConfig["intercomBridge"];
30
43
  context: "fresh" | "fork" | undefined;
@@ -68,14 +81,13 @@ function resolveIntercomBridgeConfig(value: ExtensionConfig["intercomBridge"]):
68
81
  };
69
82
  }
70
83
 
71
- function intercomEnabled(configPath: string): boolean {
72
- if (!fs.existsSync(configPath)) return true;
84
+ function intercomConfigStatus(configPath: string): { enabled: boolean; error?: unknown } {
85
+ if (!fs.existsSync(configPath)) return { enabled: true };
73
86
  try {
74
87
  const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8")) as { enabled?: unknown };
75
- return parsed.enabled !== false;
88
+ return { enabled: parsed.enabled !== false };
76
89
  } catch (error) {
77
- console.warn(`Failed to parse intercom config at '${configPath}'. Assuming enabled.`, error);
78
- return true;
90
+ return { enabled: true, error };
79
91
  }
80
92
  }
81
93
 
@@ -119,6 +131,44 @@ function buildIntercomBridgeInstruction(orchestratorTarget: string, template: st
119
131
  ${instruction}`;
120
132
  }
121
133
 
134
+ export function diagnoseIntercomBridge(input: ResolveIntercomBridgeInput): IntercomBridgeDiagnostic {
135
+ const config = resolveIntercomBridgeConfig(input.config);
136
+ const mode = config.mode;
137
+ const extensionDir = path.resolve(input.extensionDir ?? DEFAULT_INTERCOM_EXTENSION_DIR);
138
+ const orchestratorTarget = input.orchestratorTarget?.trim();
139
+ const configPath = path.resolve(input.configPath ?? DEFAULT_INTERCOM_CONFIG_PATH);
140
+ const wantsIntercom = mode !== "off" && !(mode === "fork-only" && input.context !== "fork");
141
+ const piIntercomAvailable = fs.existsSync(extensionDir);
142
+ let configStatus: ReturnType<typeof intercomConfigStatus> | undefined;
143
+ let reason: string | undefined;
144
+ if (mode === "off") reason = "bridge mode is off";
145
+ else if (mode === "fork-only" && input.context !== "fork") reason = "bridge mode is fork-only and context is not fork";
146
+ else if (!orchestratorTarget) reason = "orchestrator target is not available";
147
+ else if (!piIntercomAvailable) reason = "pi-intercom extension was not found";
148
+ else {
149
+ configStatus = intercomConfigStatus(configPath);
150
+ if (!configStatus.enabled) reason = "intercom config is disabled";
151
+ }
152
+ let intercomConfigError: string | undefined;
153
+ if (configStatus?.error) {
154
+ const error = configStatus.error;
155
+ intercomConfigError = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
156
+ }
157
+
158
+ return {
159
+ active: reason === undefined,
160
+ mode,
161
+ wantsIntercom,
162
+ piIntercomAvailable,
163
+ extensionDir,
164
+ configPath,
165
+ ...(orchestratorTarget ? { orchestratorTarget } : {}),
166
+ ...(reason ? { reason } : {}),
167
+ ...(configStatus ? { intercomConfigEnabled: configStatus.enabled } : {}),
168
+ ...(intercomConfigError ? { intercomConfigError } : {}),
169
+ };
170
+ }
171
+
122
172
  export function resolveIntercomBridge(input: ResolveIntercomBridgeInput): IntercomBridgeState {
123
173
  const config = resolveIntercomBridgeConfig(input.config);
124
174
  const mode = config.mode;
@@ -144,7 +194,9 @@ export function resolveIntercomBridge(input: ResolveIntercomBridgeInput): Interc
144
194
  }
145
195
 
146
196
  const configPath = path.resolve(input.configPath ?? DEFAULT_INTERCOM_CONFIG_PATH);
147
- if (!intercomEnabled(configPath)) {
197
+ const intercomStatus = intercomConfigStatus(configPath);
198
+ if (intercomStatus.error) console.warn(`Failed to parse intercom config at '${configPath}'. Assuming enabled.`, intercomStatus.error);
199
+ if (!intercomStatus.enabled) {
148
200
  return { active: false, mode, extensionDir, instruction: defaultInstruction };
149
201
  }
150
202
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.18.1",
3
+ "version": "0.19.1",
4
4
  "description": "Pi extension for delegating tasks to subagents with chains, parallel execution, and TUI clarification",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
@@ -31,6 +31,7 @@
31
31
  "*.mjs",
32
32
  "agents/",
33
33
  "skills/**/*",
34
+ "prompts/**/*",
34
35
  "README.md",
35
36
  "CHANGELOG.md"
36
37
  ],
@@ -46,6 +47,9 @@
46
47
  ],
47
48
  "skills": [
48
49
  "./skills"
50
+ ],
51
+ "prompts": [
52
+ "./prompts"
49
53
  ]
50
54
  },
51
55
  "peerDependencies": {
@@ -0,0 +1,8 @@
1
+ ---
2
+ description: Parallel subagents review
3
+ ---
4
+ Great. Now let's launch parallel reviewers to conduct an adversarial review.
5
+
6
+ Important: launch reviewers with fresh context, not forked context. Reviewers should inspect the repository and current diff directly from files and commands, without inheriting the main agent chat. Use forked context only if I explicitly ask for it.
7
+
8
+ $@
package/schemas.ts CHANGED
@@ -26,6 +26,9 @@ export const TaskItem = Type.Object({
26
26
  task: Type.String(),
27
27
  cwd: Type.Optional(Type.String()),
28
28
  count: Type.Optional(Type.Integer({ minimum: 1, description: "Repeat this parallel task N times with the same settings." })),
29
+ output: Type.Optional(OutputOverride),
30
+ reads: Type.Optional(ReadsOverride),
31
+ progress: Type.Optional(Type.Boolean({ description: "Enable progress.md tracking for this task" })),
29
32
  model: Type.Optional(Type.String({ description: "Override model for this task (e.g. 'google/gemini-3-pro')" })),
30
33
  skill: Type.Optional(SkillOverride),
31
34
  });
@@ -103,7 +106,7 @@ export const SubagentParams = Type.Object({
103
106
  task: Type.Optional(Type.String({ description: "Task (SINGLE mode, optional for self-contained agents)" })),
104
107
  // Management action (when present, tool operates in management mode)
105
108
  action: Type.Optional(Type.String({
106
- description: "Action: management ('list','get','create','update','delete') or control ('status','interrupt'). Omit for execution mode."
109
+ description: "Action: 'list', 'get', 'create', 'update', 'delete', 'status', 'interrupt', or 'doctor'. Omit for execution mode."
107
110
  })),
108
111
  id: Type.Optional(Type.String({
109
112
  description: "Run id or prefix for action='status' or action='interrupt'."
@@ -124,7 +127,7 @@ export const SubagentParams = Type.Object({
124
127
  additionalProperties: true,
125
128
  description: "Agent or chain config for create/update. Agent: name, description, scope ('user'|'project', default 'user'), systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, model, tools (comma-separated), extensions (comma-separated), skills (comma-separated), thinking, output, reads, progress, maxSubagentDepth. Chain: name, description, scope, steps (array of {agent, task?, output?, reads?, model?, skills?, progress?}). Presence of 'steps' creates a chain instead of an agent. String values must be valid JSON."
126
129
  })),
127
- tasks: Type.Optional(Type.Array(TaskItem, { description: "PARALLEL mode: [{agent, task, count?}, ...]" })),
130
+ tasks: Type.Optional(Type.Array(TaskItem, { description: "PARALLEL mode: [{agent, task, count?, output?, reads?, progress?}, ...]" })),
128
131
  concurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Top-level PARALLEL mode only: max concurrent tasks. Defaults to config.parallel.concurrency or 4." })),
129
132
  worktree: Type.Optional(Type.Boolean({
130
133
  description: "Create isolated git worktrees for each parallel task. " +
package/settings.ts CHANGED
@@ -8,6 +8,7 @@ import type { AgentConfig } from "./agents.ts";
8
8
  import { normalizeSkillInput } from "./skills.ts";
9
9
  import { CHAIN_RUNS_DIR } from "./types.ts";
10
10
  const CHAIN_DIR_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
11
+ const INITIAL_PROGRESS_CONTENT = "# Progress\n\n## Status\nIn Progress\n\n## Tasks\n\n## Files Changed\n\n## Notes\n";
11
12
 
12
13
  // =============================================================================
13
14
  // Behavior Resolution Types
@@ -224,6 +225,10 @@ function resolveChainPath(filePath: string, chainDir: string): string {
224
225
  * Build chain instructions from resolved behavior.
225
226
  * These are appended to the task to tell the agent what to read/write.
226
227
  */
228
+ export function writeInitialProgressFile(progressDir: string): void {
229
+ fs.writeFileSync(path.join(progressDir, "progress.md"), INITIAL_PROGRESS_CONTENT);
230
+ }
231
+
227
232
  export function buildChainInstructions(
228
233
  behavior: ResolvedStepBehavior,
229
234
  chainDir: string,
package/slash-commands.ts CHANGED
@@ -3,11 +3,12 @@ import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
5
5
  import { Key, matchesKey } from "@mariozechner/pi-tui";
6
- import { discoverAgents, discoverAgentsAll } from "./agents.ts";
6
+ import { discoverAgents, discoverAgentsAll, type ChainConfig } from "./agents.ts";
7
7
  import { AgentManagerComponent, type ManagerResult } from "./agent-manager.ts";
8
8
  import { SubagentsStatusComponent } from "./subagents-status.ts";
9
9
  import { discoverAvailableSkills } from "./skills.ts";
10
10
  import type { SubagentParamsLike } from "./subagent-executor.ts";
11
+ import { isParallelStep, type ChainStep } from "./settings.ts";
11
12
  import type { SlashSubagentResponse, SlashSubagentUpdate } from "./slash-bridge.ts";
12
13
  import {
13
14
  applySlashUpdate,
@@ -107,6 +108,36 @@ const makeAgentCompletions = (state: SubagentState, multiAgent: boolean) => (pre
107
108
  return agents.filter((a) => a.name.startsWith(lastWord)).map((a) => ({ value: `${beforeLastWord}${a.name}`, label: a.name }));
108
109
  };
109
110
 
111
+ const discoverSavedChains = (cwd: string): ChainConfig[] => {
112
+ const chainsByName = new Map<string, ChainConfig>();
113
+ for (const chain of discoverAgentsAll(cwd).chains) {
114
+ chainsByName.set(chain.name, chain);
115
+ }
116
+ return Array.from(chainsByName.values());
117
+ };
118
+
119
+ const makeChainCompletions = (state: SubagentState) => (prefix: string) => {
120
+ if (prefix.includes(" ")) return null;
121
+ return discoverSavedChains(state.baseCwd)
122
+ .filter((chain) => chain.name.startsWith(prefix))
123
+ .map((chain) => ({ value: chain.name, label: chain.name }));
124
+ };
125
+
126
+ const mapSavedChainSteps = (chain: ChainConfig, worktree = false): ChainStep[] => {
127
+ return (chain.steps as Array<ChainStep & { skills?: string[] | false }>).map((step) => {
128
+ if (isParallelStep(step)) return worktree ? { ...step, worktree: true } : { ...step };
129
+ return {
130
+ agent: step.agent,
131
+ task: step.task || undefined,
132
+ output: step.output,
133
+ reads: step.reads,
134
+ progress: step.progress,
135
+ skill: step.skill ?? step.skills,
136
+ model: step.model,
137
+ };
138
+ });
139
+ };
140
+
110
141
  async function requestSlashRun(
111
142
  pi: ExtensionAPI,
112
143
  ctx: ExtensionContext,
@@ -308,48 +339,31 @@ async function openAgentManager(
308
339
  );
309
340
  if (!result) return;
310
341
 
342
+ const launchOptions: SubagentParamsLike = {
343
+ clarify: !result.skipClarify && !result.background,
344
+ agentScope: "both",
345
+ ...(result.fork ? { context: "fork" as const } : {}),
346
+ ...(result.background ? { async: true } : {}),
347
+ };
348
+
311
349
  if (result.action === "chain") {
312
350
  const chain = result.agents.map((name, i) => ({
313
351
  agent: name,
314
352
  ...(i === 0 ? { task: result.task } : {}),
315
353
  }));
316
- await runSlashSubagent(pi, ctx, {
317
- chain,
318
- task: result.task,
319
- clarify: true,
320
- agentScope: "both",
321
- });
354
+ await runSlashSubagent(pi, ctx, { chain, task: result.task, ...launchOptions });
322
355
  return;
323
356
  }
324
357
 
325
358
  if (result.action === "launch") {
326
- await runSlashSubagent(pi, ctx, {
327
- agent: result.agent,
328
- task: result.task,
329
- clarify: !result.skipClarify,
330
- agentScope: "both",
331
- });
359
+ await runSlashSubagent(pi, ctx, { agent: result.agent, task: result.task, ...launchOptions });
332
360
  } else if (result.action === "launch-chain") {
333
- const chainParam = result.chain.steps.map((step) => ({
334
- agent: step.agent,
335
- task: step.task || undefined,
336
- output: step.output,
337
- reads: step.reads,
338
- progress: step.progress,
339
- skill: step.skills,
340
- model: step.model,
341
- }));
342
- await runSlashSubagent(pi, ctx, {
343
- chain: chainParam,
344
- task: result.task,
345
- clarify: !result.skipClarify,
346
- agentScope: "both",
347
- });
361
+ await runSlashSubagent(pi, ctx, { chain: mapSavedChainSteps(result.chain, result.worktree), task: result.task, ...launchOptions });
348
362
  } else if (result.action === "parallel") {
349
363
  await runSlashSubagent(pi, ctx, {
350
364
  tasks: result.tasks,
351
- clarify: !result.skipClarify,
352
- agentScope: "both",
365
+ ...launchOptions,
366
+ ...(result.worktree ? { worktree: true } : {}),
353
367
  });
354
368
  }
355
369
  }
@@ -493,21 +507,53 @@ export function registerSlashCommands(
493
507
  },
494
508
  });
495
509
 
510
+ pi.registerCommand("run-chain", {
511
+ description: "Run a saved chain: /run-chain chainName -- task [--bg] [--fork]",
512
+ getArgumentCompletions: makeChainCompletions(state),
513
+ handler: async (args, ctx) => {
514
+ const { args: cleanedArgs, bg, fork } = extractExecutionFlags(args);
515
+ const delimiterIndex = cleanedArgs.indexOf(" -- ");
516
+ const usage = "Usage: /run-chain <chainName> -- <task> [--bg] [--fork]";
517
+ if (delimiterIndex === -1) {
518
+ ctx.ui.notify(usage, "error");
519
+ return;
520
+ }
521
+ const chainName = cleanedArgs.slice(0, delimiterIndex).trim();
522
+ const task = cleanedArgs.slice(delimiterIndex + 4).trim();
523
+ if (!chainName || !task) {
524
+ ctx.ui.notify(usage, "error");
525
+ return;
526
+ }
527
+ const chain = discoverSavedChains(state.baseCwd).find((candidate) => candidate.name === chainName);
528
+ if (!chain) {
529
+ ctx.ui.notify(`Unknown chain: ${chainName}`, "error");
530
+ return;
531
+ }
532
+ const params: SubagentParamsLike = { chain: mapSavedChainSteps(chain), task, clarify: false, agentScope: "both" };
533
+ if (bg) params.async = true;
534
+ if (fork) params.context = "fork";
535
+ await runSlashSubagent(pi, ctx, params);
536
+ },
537
+ });
538
+
496
539
  pi.registerCommand("parallel", {
497
540
  description: "Run agents in parallel: /parallel scout \"task1\" -> reviewer \"task2\" [--bg] [--fork]",
498
541
  getArgumentCompletions: makeAgentCompletions(state, true),
499
- handler: async (args, ctx) => {
500
- const { args: cleanedArgs, bg, fork } = extractExecutionFlags(args);
501
- const parsed = parseAgentArgs(state, cleanedArgs, "parallel", ctx);
502
- if (!parsed) return;
503
- const tasks = parsed.steps.map(({ name, config, task: stepTask }) => ({
504
- agent: name,
505
- task: stepTask ?? parsed.task,
506
- ...(config.model ? { model: config.model } : {}),
507
- ...(config.skill !== undefined ? { skill: config.skill } : {}),
508
- }));
509
- const params: SubagentParamsLike = { tasks, clarify: false, agentScope: "both" };
510
- if (bg) params.async = true;
542
+ handler: async (args, ctx) => {
543
+ const { args: cleanedArgs, bg, fork } = extractExecutionFlags(args);
544
+ const parsed = parseAgentArgs(state, cleanedArgs, "parallel", ctx);
545
+ if (!parsed) return;
546
+ const tasks = parsed.steps.map(({ name, config, task: stepTask }) => ({
547
+ agent: name,
548
+ task: stepTask ?? parsed.task,
549
+ ...(config.output !== undefined ? { output: config.output } : {}),
550
+ ...(config.reads !== undefined ? { reads: config.reads } : {}),
551
+ ...(config.model ? { model: config.model } : {}),
552
+ ...(config.skill !== undefined ? { skill: config.skill } : {}),
553
+ ...(config.progress !== undefined ? { progress: config.progress } : {}),
554
+ }));
555
+ const params: SubagentParamsLike = { tasks, clarify: false, agentScope: "both" };
556
+ if (bg) params.async = true;
511
557
  if (fork) params.context = "fork";
512
558
  await runSlashSubagent(pi, ctx, params);
513
559
  },
@@ -523,6 +569,13 @@ export function registerSlashCommands(
523
569
  },
524
570
  });
525
571
 
572
+ pi.registerCommand("subagents-doctor", {
573
+ description: "Show subagent diagnostics",
574
+ handler: async (_args, ctx) => {
575
+ await runSlashSubagent(pi, ctx, { action: "doctor" });
576
+ },
577
+ });
578
+
526
579
  pi.registerShortcut("ctrl+shift+a", {
527
580
  handler: async (ctx) => {
528
581
  await openAgentManager(pi, ctx);
@@ -1,8 +1,8 @@
1
1
  import type { AgentToolResult } from "@mariozechner/pi-agent-core";
2
2
  import type { Message } from "@mariozechner/pi-ai";
3
- import type { SubagentParamsLike } from "./subagent-executor.js";
4
- import type { SlashSubagentResponse, SlashSubagentUpdate } from "./slash-bridge.js";
5
- import { type Details, type SingleResult, type Usage, SLASH_RESULT_TYPE } from "./types.js";
3
+ import type { SubagentParamsLike } from "./subagent-executor.ts";
4
+ import type { SlashSubagentResponse, SlashSubagentUpdate } from "./slash-bridge.ts";
5
+ import { type Details, type SingleResult, type Usage, SLASH_RESULT_TYPE } from "./types.ts";
6
6
 
7
7
  export interface SlashMessageDetails {
8
8
  requestId: string;