pi-subagents 0.11.7 → 0.11.9

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,27 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.11.9] - 2026-03-21
6
+
7
+ ### Fixed
8
+ - `/agents` overlay launches (single, chain, parallel) and slash commands (`/run`, `/chain`, `/parallel`) now render an inline result card in chat instead of relaying through `sendUserMessage`.
9
+ - `/agents` overlay chain launches no longer bypass the executor for async fallback, fixing a path where async chain errors were silently swallowed.
10
+
11
+ ### Changed
12
+ - All slash and overlay subagent execution now routes through an event bus request/response protocol (`slash-bridge.ts`), matching the pattern used by pi-prompt-template-model. This replaces both the old `sendUserMessage` relay and the direct `executeChain` call in the overlay handler.
13
+ - Slash launches show a live inline card immediately on start that streams current tool, recent tools, and output in real time, rather than appearing only after completion.
14
+ - `/parallel` now uses the native `tasks` parameter directly instead of wrapping through `{ chain: [{ parallel: tasks }] }`.
15
+
16
+ ### Added
17
+ - `slash-bridge.ts` — event bus bridge for slash command execution. Manages AbortController lifecycle, cancel-before-start races, and progress streaming via `subagent:slash:*` events.
18
+ - `slash-live-state.ts` — request-id keyed snapshot store that drives live inline card rendering during execution and restores finalized results from session entries on reload.
19
+ - Clarified README Usage section to distinguish LLM tool parameters from user-facing slash commands.
20
+
21
+ ## [0.11.8] - 2026-03-21
22
+
23
+ ### Added
24
+ - Prompt-template delegation bridge now supports parallel task execution: accepts `tasks` array payloads, emits per-task `parallelResults` with individual error/success states, and streams per-task progress updates with `taskProgress` entries.
25
+
5
26
  ## [0.11.7] - 2026-03-20
6
27
 
7
28
  ### Changed
package/README.md CHANGED
@@ -97,9 +97,11 @@ Semantics:
97
97
 
98
98
  When `extensions` is present, it takes precedence over extension paths implied by `tools` entries.
99
99
 
100
- **MCP Tools**
100
+ **MCP Tools (optional)**
101
101
 
102
- Agents can use MCP server tools directly (requires the [pi-mcp-adapter](https://github.com/nicobailon/pi-mcp-adapter) extension). Add `mcp:` prefixed entries to the `tools` field:
102
+ If you have the [pi-mcp-adapter](https://github.com/nicobailon/pi-mcp-adapter) extension installed, subagents can use MCP server tools directly. Without that extension, everything below is ignored — MCP integration is entirely optional.
103
+
104
+ Add `mcp:` prefixed entries to the `tools` field in agent frontmatter:
103
105
 
104
106
  ```yaml
105
107
  # All tools from a server
@@ -118,7 +120,7 @@ The `mcp:` items are additive — they don't affect which builtins the agent get
118
120
 
119
121
  Subagents only get direct MCP tools when `mcp:` items are explicitly listed. Even if your `mcp.json` has `directTools: true` globally, a subagent without `mcp:` in its frontmatter won't get any direct tools — keeping it lean. The `mcp` proxy tool is still available for discovery if needed.
120
122
 
121
- The MCP adapter's metadata cache must be populated for direct tools to work. On the first session with a new MCP server, tools will only be available through the `mcp` proxy. Restart Pi after the first session and direct tools become available.
123
+ > **First-run caveat:** The MCP adapter caches tool metadata at startup. The first time you connect to a new MCP server, that cache is empty, so tools are only available through the generic `mcp` proxy. After that first session, restart pi and direct tools become available.
122
124
 
123
125
  **Resolution priority:** step override > agent frontmatter > disabled
124
126
 
@@ -425,7 +427,9 @@ Skills are specialized instructions loaded from SKILL.md files and injected into
425
427
 
426
428
  ## Usage
427
429
 
428
- **subagent tool:**
430
+ These are the parameters the **LLM agent** passes when it calls the `subagent` tool — not something you type directly. The agent decides to use these based on your conversation. For user-facing commands, see [Quick Commands](#quick-commands) above.
431
+
432
+ **subagent tool parameters:**
429
433
  ```typescript
430
434
  // Single agent
431
435
  { agent: "worker", task: "refactor auth" }
package/index.ts CHANGED
@@ -15,8 +15,9 @@
15
15
  import * as fs from "node:fs";
16
16
  import * as os from "node:os";
17
17
  import * as path from "node:path";
18
+ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
18
19
  import { type ExtensionAPI, type ExtensionContext, type ToolDefinition } from "@mariozechner/pi-coding-agent";
19
- import { Text } from "@mariozechner/pi-tui";
20
+ import { Box, Container, Spacer, Text } from "@mariozechner/pi-tui";
20
21
  import { discoverAgents } from "./agents.js";
21
22
  import { cleanupAllArtifactDirs, cleanupOldArtifacts, getArtifactsDir } from "./artifacts.js";
22
23
  import { cleanupOldChainDirs } from "./settings.js";
@@ -28,6 +29,8 @@ import { createAsyncJobTracker } from "./async-job-tracker.js";
28
29
  import { createResultWatcher } from "./result-watcher.js";
29
30
  import { registerSlashCommands } from "./slash-commands.js";
30
31
  import { registerPromptTemplateDelegationBridge } from "./prompt-template-bridge.js";
32
+ import { registerSlashSubagentBridge } from "./slash-bridge.js";
33
+ import { clearSlashSnapshots, getSlashRenderableSnapshot, resolveSlashMessageDetails, restoreSlashFinalSnapshots, type SlashMessageDetails } from "./slash-live-state.js";
31
34
  import {
32
35
  type Details,
33
36
  type ExtensionConfig,
@@ -35,6 +38,7 @@ import {
35
38
  ASYNC_DIR,
36
39
  DEFAULT_ARTIFACT_CONFIG,
37
40
  RESULTS_DIR,
41
+ SLASH_RESULT_TYPE,
38
42
  WIDGET_KEY,
39
43
  } from "./types.js";
40
44
 
@@ -92,6 +96,48 @@ function ensureAccessibleDir(dirPath: string): void {
92
96
  }
93
97
  }
94
98
 
99
+ function isSlashResultRunning(result: { details?: Details }): boolean {
100
+ return result.details?.progress?.some((entry) => entry.status === "running")
101
+ || result.details?.results.some((entry) => entry.progress?.status === "running")
102
+ || false;
103
+ }
104
+
105
+ function isSlashResultError(result: { details?: Details }): boolean {
106
+ return result.details?.results.some((entry) => entry.exitCode !== 0 && entry.progress?.status !== "running") || false;
107
+ }
108
+
109
+ function rebuildSlashResultContainer(
110
+ container: Container,
111
+ result: AgentToolResult<Details>,
112
+ options: { expanded: boolean },
113
+ theme: ExtensionContext["ui"]["theme"],
114
+ ): void {
115
+ container.clear();
116
+ container.addChild(new Spacer(1));
117
+ const boxTheme = isSlashResultRunning(result) ? "toolPendingBg" : isSlashResultError(result) ? "toolErrorBg" : "toolSuccessBg";
118
+ const box = new Box(1, 1, (text: string) => theme.bg(boxTheme, text));
119
+ box.addChild(renderSubagentResult(result, options, theme));
120
+ container.addChild(box);
121
+ }
122
+
123
+ function createSlashResultComponent(
124
+ details: SlashMessageDetails,
125
+ options: { expanded: boolean },
126
+ theme: ExtensionContext["ui"]["theme"],
127
+ ): Container {
128
+ const container = new Container();
129
+ let lastVersion = -1;
130
+ container.render = (width: number): string[] => {
131
+ const snapshot = getSlashRenderableSnapshot(details);
132
+ if (snapshot.version !== lastVersion) {
133
+ lastVersion = snapshot.version;
134
+ rebuildSlashResultContainer(container, snapshot.result, options, theme);
135
+ }
136
+ return Container.prototype.render.call(container, width);
137
+ };
138
+ return container;
139
+ }
140
+
95
141
  export default function registerSubagentExtension(pi: ExtensionAPI): void {
96
142
  ensureAccessibleDir(RESULTS_DIR);
97
143
  ensureAccessibleDir(ASYNC_DIR);
@@ -139,11 +185,39 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
139
185
  discoverAgents,
140
186
  });
141
187
 
188
+ pi.registerMessageRenderer<SlashMessageDetails>(SLASH_RESULT_TYPE, (message, options, theme) => {
189
+ const details = resolveSlashMessageDetails(message.details);
190
+ if (!details) return undefined;
191
+ return createSlashResultComponent(details, options, theme);
192
+ });
193
+
194
+ const slashBridge = registerSlashSubagentBridge({
195
+ events: pi.events,
196
+ getContext: () => state.lastUiContext,
197
+ execute: (id, params, signal, onUpdate, ctx) =>
198
+ executor.execute(id, params, signal, onUpdate, ctx),
199
+ });
200
+
142
201
  const promptTemplateBridge = registerPromptTemplateDelegationBridge({
143
202
  events: pi.events,
144
203
  getContext: () => state.lastUiContext,
145
- execute: async (requestId, request, signal, ctx, onUpdate) =>
146
- executor.execute(
204
+ execute: async (requestId, request, signal, ctx, onUpdate) => {
205
+ if (request.tasks && request.tasks.length > 0) {
206
+ return executor.execute(
207
+ requestId,
208
+ {
209
+ tasks: request.tasks,
210
+ context: request.context,
211
+ cwd: request.cwd,
212
+ async: false,
213
+ clarify: false,
214
+ },
215
+ signal,
216
+ onUpdate,
217
+ ctx,
218
+ );
219
+ }
220
+ return executor.execute(
147
221
  requestId,
148
222
  {
149
223
  agent: request.agent,
@@ -157,7 +231,8 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
157
231
  signal,
158
232
  onUpdate,
159
233
  ctx,
160
- ),
234
+ );
235
+ },
161
236
  });
162
237
 
163
238
  const tool: ToolDefinition<typeof SubagentParams, Details> = {
@@ -324,7 +399,7 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
324
399
 
325
400
  pi.registerTool(tool);
326
401
  pi.registerTool(statusTool);
327
- registerSlashCommands(pi, state, getSubagentSessionRoot);
402
+ registerSlashCommands(pi, state);
328
403
 
329
404
  pi.events.on("subagent:started", handleStarted);
330
405
  pi.events.on("subagent:complete", handleComplete);
@@ -356,6 +431,7 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
356
431
  state.lastUiContext = ctx;
357
432
  cleanupSessionArtifacts(ctx);
358
433
  resetJobs(ctx);
434
+ restoreSlashFinalSnapshots(ctx.sessionManager.getEntries());
359
435
  };
360
436
 
361
437
  pi.on("session_start", (_event, ctx) => {
@@ -376,6 +452,9 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
376
452
  }
377
453
  state.cleanupTimers.clear();
378
454
  state.asyncJobs.clear();
455
+ clearSlashSnapshots();
456
+ slashBridge.cancelAll();
457
+ slashBridge.dispose();
379
458
  promptTemplateBridge.cancelAll();
380
459
  promptTemplateBridge.dispose();
381
460
  if (state.lastUiContext?.hasUI) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.11.7",
3
+ "version": "0.11.9",
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",
@@ -4,10 +4,24 @@ export const PROMPT_TEMPLATE_SUBAGENT_RESPONSE_EVENT = "prompt-template:subagent
4
4
  export const PROMPT_TEMPLATE_SUBAGENT_UPDATE_EVENT = "prompt-template:subagent:update";
5
5
  export const PROMPT_TEMPLATE_SUBAGENT_CANCEL_EVENT = "prompt-template:subagent:cancel";
6
6
 
7
+ export interface PromptTemplateDelegationTask {
8
+ agent: string;
9
+ task: string;
10
+ model?: string;
11
+ }
12
+
13
+ export interface PromptTemplateDelegationParallelResult {
14
+ agent: string;
15
+ messages: unknown[];
16
+ isError: boolean;
17
+ errorText?: string;
18
+ }
19
+
7
20
  export interface PromptTemplateDelegationRequest {
8
21
  requestId: string;
9
22
  agent: string;
10
23
  task: string;
24
+ tasks?: PromptTemplateDelegationTask[];
11
25
  context: "fresh" | "fork";
12
26
  model: string;
13
27
  cwd: string;
@@ -15,10 +29,23 @@ export interface PromptTemplateDelegationRequest {
15
29
 
16
30
  export interface PromptTemplateDelegationResponse extends PromptTemplateDelegationRequest {
17
31
  messages: unknown[];
32
+ parallelResults?: PromptTemplateDelegationParallelResult[];
18
33
  isError: boolean;
19
34
  errorText?: string;
20
35
  }
21
36
 
37
+ export interface PromptTemplateDelegationTaskProgress {
38
+ index?: number;
39
+ agent: string;
40
+ status?: string;
41
+ currentTool?: string;
42
+ currentToolArgs?: string;
43
+ recentOutput?: string;
44
+ toolCount?: number;
45
+ durationMs?: number;
46
+ tokens?: number;
47
+ }
48
+
22
49
  export interface PromptTemplateDelegationUpdate {
23
50
  requestId: string;
24
51
  currentTool?: string;
@@ -27,6 +54,7 @@ export interface PromptTemplateDelegationUpdate {
27
54
  toolCount?: number;
28
55
  durationMs?: number;
29
56
  tokens?: number;
57
+ taskProgress?: PromptTemplateDelegationTaskProgress[];
30
58
  }
31
59
 
32
60
  export interface PromptTemplateBridgeEvents {
@@ -39,13 +67,21 @@ interface PromptTemplateBridgeResult {
39
67
  content?: unknown;
40
68
  details?: {
41
69
  results?: Array<{
70
+ agent?: string;
42
71
  messages?: unknown[];
72
+ exitCode?: number;
73
+ error?: string;
43
74
  }>;
44
75
  progress?: Array<{
76
+ index?: number;
77
+ agent?: string;
78
+ status?: string;
45
79
  currentTool?: string;
46
80
  currentToolArgs?: string;
47
81
  recentOutput?: string[];
48
82
  toolCount?: number;
83
+ durationMs?: number;
84
+ tokens?: number;
49
85
  }>;
50
86
  };
51
87
  }
@@ -62,15 +98,45 @@ export interface PromptTemplateBridgeOptions<Ctx extends { cwd?: string }> {
62
98
  ) => Promise<PromptTemplateBridgeResult>;
63
99
  }
64
100
 
101
+ function parseDelegationTasks(tasks: unknown): PromptTemplateDelegationTask[] {
102
+ if (!Array.isArray(tasks)) return [];
103
+ const parsed: PromptTemplateDelegationTask[] = [];
104
+ for (const item of tasks) {
105
+ if (!item || typeof item !== "object") return [];
106
+ const value = item as Partial<PromptTemplateDelegationTask>;
107
+ if (typeof value.agent !== "string" || !value.agent.trim()) return [];
108
+ if (typeof value.task !== "string" || !value.task.trim()) return [];
109
+ const model = typeof value.model === "string" && value.model.trim().length > 0 ? value.model : undefined;
110
+ parsed.push({
111
+ agent: value.agent,
112
+ task: value.task,
113
+ ...(model ? { model } : {}),
114
+ });
115
+ }
116
+ return parsed;
117
+ }
118
+
65
119
  export function parsePromptTemplateRequest(data: unknown): PromptTemplateDelegationRequest | undefined {
66
120
  if (!data || typeof data !== "object") return undefined;
67
- const value = data as Partial<PromptTemplateDelegationRequest>;
68
- if (!value.requestId || !value.agent || !value.task || !value.model || !value.cwd) return undefined;
121
+ const value = data as Partial<PromptTemplateDelegationRequest> & { tasks?: unknown };
122
+ if (typeof value.requestId !== "string" || !value.requestId) return undefined;
123
+ if (typeof value.model !== "string" || !value.model) return undefined;
124
+ if (typeof value.cwd !== "string" || !value.cwd) return undefined;
69
125
  if (value.context !== "fresh" && value.context !== "fork") return undefined;
126
+ const tasks = parseDelegationTasks(value.tasks);
127
+ const hasSingle =
128
+ typeof value.agent === "string" &&
129
+ value.agent.length > 0 &&
130
+ typeof value.task === "string" &&
131
+ value.task.length > 0;
132
+ if (!hasSingle && tasks.length === 0) return undefined;
133
+
134
+ const fallbackTask = tasks[0];
70
135
  return {
71
136
  requestId: value.requestId,
72
- agent: value.agent,
73
- task: value.task,
137
+ agent: hasSingle ? value.agent : fallbackTask!.agent,
138
+ task: hasSingle ? value.task : fallbackTask!.task,
139
+ ...(tasks.length > 0 ? { tasks } : {}),
74
140
  context: value.context,
75
141
  model: value.model,
76
142
  cwd: value.cwd,
@@ -90,16 +156,31 @@ export function firstTextContent(content: unknown): string | undefined {
90
156
 
91
157
  function toDelegationUpdate(requestId: string, update: PromptTemplateBridgeResult): PromptTemplateDelegationUpdate | undefined {
92
158
  const progress = update.details?.progress?.[0];
93
- if (!progress) return undefined;
94
- const lastOutput = progress.recentOutput?.[progress.recentOutput.length - 1];
159
+ const taskProgress = update.details?.progress?.map((entry) => {
160
+ const lastOutput = entry.recentOutput?.[entry.recentOutput.length - 1];
161
+ return {
162
+ index: entry.index,
163
+ agent: entry.agent ?? "delegate",
164
+ status: entry.status,
165
+ currentTool: entry.currentTool,
166
+ currentToolArgs: entry.currentToolArgs,
167
+ recentOutput: lastOutput && lastOutput !== "(running...)" ? lastOutput : undefined,
168
+ toolCount: entry.toolCount,
169
+ durationMs: entry.durationMs,
170
+ tokens: entry.tokens,
171
+ };
172
+ });
173
+ if (!progress && (!taskProgress || taskProgress.length === 0)) return undefined;
174
+ const lastOutput = progress?.recentOutput?.[progress.recentOutput.length - 1];
95
175
  return {
96
176
  requestId,
97
- currentTool: progress.currentTool,
98
- currentToolArgs: progress.currentToolArgs,
177
+ currentTool: progress?.currentTool,
178
+ currentToolArgs: progress?.currentToolArgs,
99
179
  recentOutput: lastOutput && lastOutput !== "(running...)" ? lastOutput : undefined,
100
- toolCount: progress.toolCount,
101
- durationMs: (progress as { durationMs?: number }).durationMs,
102
- tokens: (progress as { tokens?: number }).tokens,
180
+ toolCount: progress?.toolCount,
181
+ durationMs: progress?.durationMs,
182
+ tokens: progress?.tokens,
183
+ taskProgress,
103
184
  };
104
185
  }
105
186
 
@@ -177,9 +258,31 @@ export function registerPromptTemplateDelegationBridge<Ctx extends { cwd?: strin
177
258
  },
178
259
  );
179
260
  const messages = result.details?.results?.[0]?.messages ?? [];
261
+ const parallelResults = request.tasks
262
+ ? request.tasks.map<PromptTemplateDelegationParallelResult>((task, index) => {
263
+ const step = result.details?.results?.[index];
264
+ if (!step) {
265
+ return {
266
+ agent: task.agent,
267
+ messages: [],
268
+ isError: true,
269
+ errorText: "Missing result for delegated parallel task.",
270
+ };
271
+ }
272
+ const exitCode = typeof step.exitCode === "number" ? step.exitCode : undefined;
273
+ const errorText = step.error;
274
+ return {
275
+ agent: step.agent ?? task.agent,
276
+ messages: step.messages ?? [],
277
+ isError: (exitCode !== undefined && exitCode !== 0) || !!errorText,
278
+ errorText: errorText || undefined,
279
+ };
280
+ })
281
+ : undefined;
180
282
  const response: PromptTemplateDelegationResponse = {
181
283
  ...request,
182
284
  messages,
285
+ ...(parallelResults ? { parallelResults } : {}),
183
286
  isError: result.isError === true,
184
287
  errorText: result.isError ? firstTextContent(result.content) : undefined,
185
288
  };
package/render.ts CHANGED
@@ -220,6 +220,36 @@ export function renderSubagentResult(
220
220
  );
221
221
  c.addChild(new Spacer(1));
222
222
 
223
+ if (isRunning && r.progress) {
224
+ if (r.progress.currentTool) {
225
+ const maxToolArgsLen = Math.max(50, w - 20);
226
+ const toolArgsPreview = r.progress.currentToolArgs
227
+ ? (r.progress.currentToolArgs.length > maxToolArgsLen
228
+ ? `${r.progress.currentToolArgs.slice(0, maxToolArgsLen)}...`
229
+ : r.progress.currentToolArgs)
230
+ : "";
231
+ const toolLine = toolArgsPreview
232
+ ? `${r.progress.currentTool}: ${toolArgsPreview}`
233
+ : r.progress.currentTool;
234
+ c.addChild(new Text(truncLine(theme.fg("warning", `> ${toolLine}`), w), 0, 0));
235
+ }
236
+ if (r.progress.recentTools?.length) {
237
+ for (const t of r.progress.recentTools.slice(-3)) {
238
+ const maxArgsLen = Math.max(40, w - 24);
239
+ const argsPreview = t.args.length > maxArgsLen
240
+ ? `${t.args.slice(0, maxArgsLen)}...`
241
+ : t.args;
242
+ c.addChild(new Text(truncLine(theme.fg("dim", `${t.tool}: ${argsPreview}`), w), 0, 0));
243
+ }
244
+ }
245
+ for (const line of (r.progress.recentOutput ?? []).slice(-5)) {
246
+ c.addChild(new Text(truncLine(theme.fg("dim", ` ${line}`), w), 0, 0));
247
+ }
248
+ if (r.progress.currentTool || r.progress.recentTools?.length || r.progress.recentOutput?.length) {
249
+ c.addChild(new Spacer(1));
250
+ }
251
+ }
252
+
223
253
  const items = getDisplayItems(r.messages);
224
254
  for (const item of items) {
225
255
  if (item.type === "tool")
@@ -0,0 +1,174 @@
1
+ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
2
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
3
+ import type { SubagentParamsLike } from "./subagent-executor.js";
4
+ import {
5
+ SLASH_SUBAGENT_CANCEL_EVENT,
6
+ SLASH_SUBAGENT_REQUEST_EVENT,
7
+ SLASH_SUBAGENT_RESPONSE_EVENT,
8
+ SLASH_SUBAGENT_STARTED_EVENT,
9
+ SLASH_SUBAGENT_UPDATE_EVENT,
10
+ type Details,
11
+ } from "./types.js";
12
+
13
+ export interface SlashSubagentRequest {
14
+ requestId: string;
15
+ params: SubagentParamsLike;
16
+ }
17
+
18
+ export interface SlashSubagentResponse {
19
+ requestId: string;
20
+ result: AgentToolResult<Details>;
21
+ isError: boolean;
22
+ errorText?: string;
23
+ }
24
+
25
+ export interface SlashSubagentUpdate {
26
+ requestId: string;
27
+ progress?: Details["progress"];
28
+ currentTool?: string;
29
+ toolCount?: number;
30
+ }
31
+
32
+ interface EventBus {
33
+ on(event: string, handler: (data: unknown) => void): (() => void) | void;
34
+ emit(event: string, data: unknown): void;
35
+ }
36
+
37
+ interface SlashBridgeOptions {
38
+ events: EventBus;
39
+ getContext: () => ExtensionContext | null;
40
+ execute: (
41
+ id: string,
42
+ params: SubagentParamsLike,
43
+ signal: AbortSignal,
44
+ onUpdate: ((r: AgentToolResult<Details>) => void) | undefined,
45
+ ctx: ExtensionContext,
46
+ ) => Promise<AgentToolResult<Details>>;
47
+ }
48
+
49
+ export function registerSlashSubagentBridge(options: SlashBridgeOptions): {
50
+ cancelAll: () => void;
51
+ dispose: () => void;
52
+ } {
53
+ const controllers = new Map<string, AbortController>();
54
+ const pendingCancels = new Set<string>();
55
+ const subscriptions: Array<() => void> = [];
56
+
57
+ const subscribe = (event: string, handler: (data: unknown) => void): void => {
58
+ const unsubscribe = options.events.on(event, handler);
59
+ if (typeof unsubscribe === "function") subscriptions.push(unsubscribe);
60
+ };
61
+
62
+ subscribe(SLASH_SUBAGENT_CANCEL_EVENT, (data) => {
63
+ if (!data || typeof data !== "object") return;
64
+ const requestId = (data as { requestId?: unknown }).requestId;
65
+ if (typeof requestId !== "string") return;
66
+ const controller = controllers.get(requestId);
67
+ if (controller) {
68
+ controller.abort();
69
+ return;
70
+ }
71
+ pendingCancels.add(requestId);
72
+ });
73
+
74
+ subscribe(SLASH_SUBAGENT_REQUEST_EVENT, async (data) => {
75
+ if (!data || typeof data !== "object") return;
76
+ const request = data as Partial<SlashSubagentRequest>;
77
+ if (typeof request.requestId !== "string" || !request.params) return;
78
+ const { requestId, params } = request as SlashSubagentRequest;
79
+
80
+ const ctx = options.getContext();
81
+ if (!ctx) {
82
+ const response: SlashSubagentResponse = {
83
+ requestId,
84
+ result: {
85
+ content: [{ type: "text", text: "No active extension context for slash subagent execution." }],
86
+ details: { mode: "single" as const, results: [] },
87
+ },
88
+ isError: true,
89
+ errorText: "No active extension context.",
90
+ };
91
+ options.events.emit(SLASH_SUBAGENT_RESPONSE_EVENT, response);
92
+ return;
93
+ }
94
+
95
+ const controller = new AbortController();
96
+ controllers.set(requestId, controller);
97
+
98
+ if (pendingCancels.delete(requestId)) {
99
+ controller.abort();
100
+ const response: SlashSubagentResponse = {
101
+ requestId,
102
+ result: {
103
+ content: [{ type: "text", text: "Cancelled." }],
104
+ details: { mode: "single" as const, results: [] },
105
+ },
106
+ isError: true,
107
+ errorText: "Cancelled before start.",
108
+ };
109
+ options.events.emit(SLASH_SUBAGENT_RESPONSE_EVENT, response);
110
+ controllers.delete(requestId);
111
+ return;
112
+ }
113
+
114
+ options.events.emit(SLASH_SUBAGENT_STARTED_EVENT, { requestId });
115
+
116
+ try {
117
+ const result = await options.execute(
118
+ requestId,
119
+ params,
120
+ controller.signal,
121
+ (update) => {
122
+ const progress = update.details?.progress;
123
+ const first = progress?.[0];
124
+ const payload: SlashSubagentUpdate = {
125
+ requestId,
126
+ progress,
127
+ currentTool: first?.currentTool,
128
+ toolCount: first?.toolCount,
129
+ };
130
+ options.events.emit(SLASH_SUBAGENT_UPDATE_EVENT, payload);
131
+ },
132
+ ctx,
133
+ );
134
+
135
+ const response: SlashSubagentResponse = {
136
+ requestId,
137
+ result,
138
+ isError: (result as { isError?: boolean }).isError === true,
139
+ errorText: (result as { isError?: boolean }).isError
140
+ ? result.content.find((c) => c.type === "text")?.text
141
+ : undefined,
142
+ };
143
+ options.events.emit(SLASH_SUBAGENT_RESPONSE_EVENT, response);
144
+ } catch (error) {
145
+ const response: SlashSubagentResponse = {
146
+ requestId,
147
+ result: {
148
+ content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
149
+ details: { mode: "single" as const, results: [] },
150
+ },
151
+ isError: true,
152
+ errorText: error instanceof Error ? error.message : String(error),
153
+ };
154
+ options.events.emit(SLASH_SUBAGENT_RESPONSE_EVENT, response);
155
+ } finally {
156
+ controllers.delete(requestId);
157
+ }
158
+ });
159
+
160
+ return {
161
+ cancelAll: () => {
162
+ for (const controller of controllers.values()) {
163
+ controller.abort();
164
+ }
165
+ controllers.clear();
166
+ pendingCancels.clear();
167
+ },
168
+ dispose: () => {
169
+ for (const unsubscribe of subscriptions) unsubscribe();
170
+ subscriptions.length = 0;
171
+ pendingCancels.clear();
172
+ },
173
+ };
174
+ }
package/slash-commands.ts CHANGED
@@ -1,15 +1,27 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import * as fs from "node:fs";
3
- import * as path from "node:path";
4
2
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
3
+ import { Key, matchesKey } from "@mariozechner/pi-tui";
5
4
  import { discoverAgents, discoverAgentsAll } from "./agents.js";
6
- import { executeAsyncChain, isAsyncAvailable } from "./async-execution.js";
7
- import { executeChain } from "./chain-execution.js";
8
5
  import { AgentManagerComponent, type ManagerResult } from "./agent-manager.js";
9
6
  import { discoverAvailableSkills } from "./skills.js";
10
- import { getArtifactsDir } from "./artifacts.js";
11
- import { DEFAULT_ARTIFACT_CONFIG, MAX_PARALLEL, type SubagentState, type ArtifactConfig } from "./types.js";
12
- import type { SequentialStep } from "./settings.js";
7
+ import type { SubagentParamsLike } from "./subagent-executor.js";
8
+ import type { SlashSubagentResponse, SlashSubagentUpdate } from "./slash-bridge.js";
9
+ import {
10
+ applySlashUpdate,
11
+ buildSlashInitialResult,
12
+ failSlashResult,
13
+ finalizeSlashResult,
14
+ } from "./slash-live-state.js";
15
+ import {
16
+ MAX_PARALLEL,
17
+ SLASH_RESULT_TYPE,
18
+ SLASH_SUBAGENT_CANCEL_EVENT,
19
+ SLASH_SUBAGENT_REQUEST_EVENT,
20
+ SLASH_SUBAGENT_RESPONSE_EVENT,
21
+ SLASH_SUBAGENT_STARTED_EVENT,
22
+ SLASH_SUBAGENT_UPDATE_EVENT,
23
+ type SubagentState,
24
+ } from "./types.js";
13
25
 
14
26
  interface InlineConfig {
15
27
  output?: string | false;
@@ -71,25 +83,6 @@ const extractExecutionFlags = (rawArgs: string): { args: string; bg: boolean; fo
71
83
  return { args, bg, fork };
72
84
  };
73
85
 
74
- function setupDirectRun(ctx: ExtensionContext, getSubagentSessionRoot: (parentSessionFile: string | null) => string) {
75
- const runId = randomUUID().slice(0, 8);
76
- const parentSessionFile = ctx.sessionManager.getSessionFile() ?? null;
77
- const sessionRoot = path.join(getSubagentSessionRoot(parentSessionFile), runId);
78
- try {
79
- fs.mkdirSync(sessionRoot, { recursive: true });
80
- } catch (error) {
81
- const message = error instanceof Error ? error.message : String(error);
82
- throw new Error(`Failed to create session directory '${sessionRoot}': ${message}`);
83
- }
84
- return {
85
- runId,
86
- shareEnabled: false,
87
- sessionDirForIndex: (idx?: number) => path.join(sessionRoot, `run-${idx ?? 0}`),
88
- artifactsDir: getArtifactsDir(parentSessionFile),
89
- artifactConfig: { ...DEFAULT_ARTIFACT_CONFIG } as ArtifactConfig,
90
- };
91
- }
92
-
93
86
  const makeAgentCompletions = (state: SubagentState, multiAgent: boolean) => (prefix: string) => {
94
87
  const agents = discoverAgents(state.baseCwd, "both").agents;
95
88
  if (!multiAgent) {
@@ -111,11 +104,145 @@ const makeAgentCompletions = (state: SubagentState, multiAgent: boolean) => (pre
111
104
  return agents.filter((a) => a.name.startsWith(lastWord)).map((a) => ({ value: `${beforeLastWord}${a.name}`, label: a.name }));
112
105
  };
113
106
 
107
+ async function requestSlashRun(
108
+ pi: ExtensionAPI,
109
+ ctx: ExtensionContext,
110
+ requestId: string,
111
+ params: SubagentParamsLike,
112
+ ): Promise<SlashSubagentResponse> {
113
+ return new Promise((resolve, reject) => {
114
+ let done = false;
115
+ let started = false;
116
+
117
+ const startTimeoutMs = 15_000;
118
+ const startTimeout = setTimeout(() => {
119
+ finish(() => reject(new Error(
120
+ "Slash subagent bridge did not start within 15s. Ensure the extension is loaded correctly.",
121
+ )));
122
+ }, startTimeoutMs);
123
+
124
+ const onStarted = (data: unknown) => {
125
+ if (done || !data || typeof data !== "object") return;
126
+ if ((data as { requestId?: unknown }).requestId !== requestId) return;
127
+ started = true;
128
+ clearTimeout(startTimeout);
129
+ if (ctx.hasUI) ctx.ui.setStatus("subagent-slash", "running...");
130
+ };
131
+
132
+ const onResponse = (data: unknown) => {
133
+ if (done || !data || typeof data !== "object") return;
134
+ const response = data as Partial<SlashSubagentResponse>;
135
+ if (response.requestId !== requestId) return;
136
+ clearTimeout(startTimeout);
137
+ finish(() => resolve(response as SlashSubagentResponse));
138
+ };
139
+
140
+ const onUpdate = (data: unknown) => {
141
+ if (done || !data || typeof data !== "object") return;
142
+ const update = data as SlashSubagentUpdate;
143
+ if (update.requestId !== requestId) return;
144
+ applySlashUpdate(requestId, update);
145
+ if (!ctx.hasUI) return;
146
+ const tool = update.currentTool ? ` ${update.currentTool}` : "";
147
+ const count = update.toolCount ?? 0;
148
+ ctx.ui.setStatus("subagent-slash", `${count} tools${tool}`);
149
+ };
150
+
151
+ const onTerminalInput = ctx.hasUI
152
+ ? ctx.ui.onTerminalInput((input) => {
153
+ if (!matchesKey(input, Key.escape)) return undefined;
154
+ pi.events.emit(SLASH_SUBAGENT_CANCEL_EVENT, { requestId });
155
+ finish(() => reject(new Error("Cancelled")));
156
+ return { consume: true };
157
+ })
158
+ : undefined;
159
+
160
+ const unsubStarted = pi.events.on(SLASH_SUBAGENT_STARTED_EVENT, onStarted);
161
+ const unsubResponse = pi.events.on(SLASH_SUBAGENT_RESPONSE_EVENT, onResponse);
162
+ const unsubUpdate = pi.events.on(SLASH_SUBAGENT_UPDATE_EVENT, onUpdate);
163
+
164
+ const finish = (next: () => void) => {
165
+ if (done) return;
166
+ done = true;
167
+ clearTimeout(startTimeout);
168
+ unsubStarted();
169
+ unsubResponse();
170
+ unsubUpdate();
171
+ onTerminalInput?.();
172
+ if (ctx.hasUI) ctx.ui.setStatus("subagent-slash", undefined);
173
+ next();
174
+ };
175
+
176
+ pi.events.emit(SLASH_SUBAGENT_REQUEST_EVENT, { requestId, params });
177
+
178
+ // Bridge emits STARTED synchronously during REQUEST emit.
179
+ // If not started, no bridge received the request.
180
+ if (!started && done) return;
181
+ if (!started) {
182
+ finish(() => reject(new Error(
183
+ "No slash subagent bridge responded. Ensure the subagent extension is loaded correctly.",
184
+ )));
185
+ }
186
+ });
187
+ }
188
+
189
+ function extractSlashMessageText(content: string | Array<{ type?: string; text?: string }>): string {
190
+ if (typeof content === "string") return content;
191
+ if (!Array.isArray(content)) return "";
192
+ return content
193
+ .filter((part): part is { type: "text"; text: string } => part?.type === "text" && typeof part.text === "string")
194
+ .map((part) => part.text)
195
+ .join("\n");
196
+ }
197
+
198
+ async function runSlashSubagent(
199
+ pi: ExtensionAPI,
200
+ ctx: ExtensionContext,
201
+ params: SubagentParamsLike,
202
+ ): Promise<void> {
203
+ const requestId = randomUUID();
204
+ const initialDetails = buildSlashInitialResult(requestId, params);
205
+ const initialText = extractSlashMessageText(initialDetails.result.content) || "Running subagent...";
206
+ pi.sendMessage({
207
+ customType: SLASH_RESULT_TYPE,
208
+ content: initialText,
209
+ display: true,
210
+ details: initialDetails,
211
+ });
212
+
213
+ try {
214
+ const response = await requestSlashRun(pi, ctx, requestId, params);
215
+ const finalDetails = finalizeSlashResult(response);
216
+ const text = extractSlashMessageText(response.result.content) || response.errorText || "(no output)";
217
+ pi.sendMessage({
218
+ customType: SLASH_RESULT_TYPE,
219
+ content: text,
220
+ display: false,
221
+ details: finalDetails,
222
+ });
223
+ if (response.isError && ctx.hasUI) {
224
+ ctx.ui.notify(response.errorText || "Subagent failed", "error");
225
+ }
226
+ } catch (error) {
227
+ const message = error instanceof Error ? error.message : String(error);
228
+ const failedDetails = failSlashResult(requestId, params, message === "Cancelled" ? "Cancelled" : message);
229
+ pi.sendMessage({
230
+ customType: SLASH_RESULT_TYPE,
231
+ content: message,
232
+ display: false,
233
+ details: failedDetails,
234
+ });
235
+ if (message === "Cancelled") {
236
+ if (ctx.hasUI) ctx.ui.notify("Cancelled", "warning");
237
+ return;
238
+ }
239
+ if (ctx.hasUI) ctx.ui.notify(message, "error");
240
+ }
241
+ }
242
+
114
243
  async function openAgentManager(
115
244
  pi: ExtensionAPI,
116
- state: SubagentState,
117
245
  ctx: ExtensionContext,
118
- getSubagentSessionRoot: (parentSessionFile: string | null) => string,
119
246
  ): Promise<void> {
120
247
  const agentData = { ...discoverAgentsAll(ctx.cwd), cwd: ctx.cwd };
121
248
  const models = ctx.modelRegistry.getAvailable().map((m) => ({
@@ -132,54 +259,26 @@ async function openAgentManager(
132
259
  if (!result) return;
133
260
 
134
261
  if (result.action === "chain") {
135
- const agents = discoverAgents(state.baseCwd, "both").agents;
136
- const exec = setupDirectRun(ctx, getSubagentSessionRoot);
137
- const chain: SequentialStep[] = result.agents.map((name, i) => ({
262
+ const chain = result.agents.map((name, i) => ({
138
263
  agent: name,
139
264
  ...(i === 0 ? { task: result.task } : {}),
140
265
  }));
141
- executeChain({ chain, task: result.task, agents, ctx, ...exec, clarify: true })
142
- .then((r) => {
143
- if (r.requestedAsync) {
144
- if (!isAsyncAvailable()) {
145
- pi.sendUserMessage("Background mode requires jiti for TypeScript execution but it could not be found.");
146
- return;
147
- }
148
- const id = randomUUID();
149
- const asyncCtx = { pi, cwd: ctx.cwd, currentSessionId: ctx.sessionManager.getSessionId() ?? id };
150
- const asyncSessionRoot = getSubagentSessionRoot(ctx.sessionManager.getSessionFile() ?? null);
151
- fs.mkdirSync(asyncSessionRoot, { recursive: true });
152
- executeAsyncChain(id, {
153
- chain: r.requestedAsync.chain,
154
- agents,
155
- ctx: asyncCtx,
156
- maxOutput: undefined,
157
- artifactsDir: exec.artifactsDir,
158
- artifactConfig: exec.artifactConfig,
159
- shareEnabled: false,
160
- sessionRoot: asyncSessionRoot,
161
- chainSkills: r.requestedAsync.chainSkills,
162
- }).then((asyncResult) => {
163
- pi.sendUserMessage(asyncResult.content[0]?.text || "(launched in background)");
164
- }).catch((err) => {
165
- pi.sendUserMessage(`Async launch failed: ${err instanceof Error ? err.message : String(err)}`);
166
- });
167
- return;
168
- }
169
- pi.sendUserMessage(r.content[0]?.text || "(no output)");
170
- })
171
- .catch((err) => pi.sendUserMessage(`Chain failed: ${err instanceof Error ? err.message : String(err)}`));
266
+ await runSlashSubagent(pi, ctx, {
267
+ chain,
268
+ task: result.task,
269
+ clarify: true,
270
+ agentScope: "both",
271
+ });
172
272
  return;
173
273
  }
174
274
 
175
- const sendToolCall = (params: Record<string, unknown>) => {
176
- pi.sendUserMessage(
177
- `Call the subagent tool with these exact parameters: ${JSON.stringify({ ...params, agentScope: "both" })}`,
178
- );
179
- };
180
-
181
275
  if (result.action === "launch") {
182
- sendToolCall({ agent: result.agent, task: result.task, clarify: !result.skipClarify });
276
+ await runSlashSubagent(pi, ctx, {
277
+ agent: result.agent,
278
+ task: result.task,
279
+ clarify: !result.skipClarify,
280
+ agentScope: "both",
281
+ });
183
282
  } else if (result.action === "launch-chain") {
184
283
  const chainParam = result.chain.steps.map((step) => ({
185
284
  agent: step.agent,
@@ -190,9 +289,18 @@ async function openAgentManager(
190
289
  skill: step.skills,
191
290
  model: step.model,
192
291
  }));
193
- sendToolCall({ chain: chainParam, task: result.task, clarify: !result.skipClarify });
292
+ await runSlashSubagent(pi, ctx, {
293
+ chain: chainParam,
294
+ task: result.task,
295
+ clarify: !result.skipClarify,
296
+ agentScope: "both",
297
+ });
194
298
  } else if (result.action === "parallel") {
195
- sendToolCall({ tasks: result.tasks, clarify: !result.skipClarify });
299
+ await runSlashSubagent(pi, ctx, {
300
+ tasks: result.tasks,
301
+ clarify: !result.skipClarify,
302
+ agentScope: "both",
303
+ });
196
304
  }
197
305
  }
198
306
 
@@ -276,12 +384,11 @@ const parseAgentArgs = (
276
384
  export function registerSlashCommands(
277
385
  pi: ExtensionAPI,
278
386
  state: SubagentState,
279
- getSubagentSessionRoot: (parentSessionFile: string | null) => string,
280
387
  ): void {
281
388
  pi.registerCommand("agents", {
282
389
  description: "Open the Agents Manager",
283
390
  handler: async (_args, ctx) => {
284
- await openAgentManager(pi, state, ctx, getSubagentSessionRoot);
391
+ await openAgentManager(pi, ctx);
285
392
  },
286
393
  });
287
394
 
@@ -304,13 +411,13 @@ export function registerSlashCommands(
304
411
  if (inline.reads && Array.isArray(inline.reads) && inline.reads.length > 0) {
305
412
  finalTask = `[Read from: ${inline.reads.join(", ")}]\n\n${finalTask}`;
306
413
  }
307
- const params: Record<string, unknown> = { agent: agentName, task: finalTask, clarify: false };
414
+ const params: SubagentParamsLike = { agent: agentName, task: finalTask, clarify: false, agentScope: "both" };
308
415
  if (inline.output !== undefined) params.output = inline.output;
309
416
  if (inline.skill !== undefined) params.skill = inline.skill;
310
417
  if (inline.model) params.model = inline.model;
311
418
  if (bg) params.async = true;
312
419
  if (fork) params.context = "fork";
313
- pi.sendUserMessage(`Call the subagent tool with these exact parameters: ${JSON.stringify({ ...params, agentScope: "both" })}`);
420
+ await runSlashSubagent(pi, ctx, params);
314
421
  },
315
422
  });
316
423
 
@@ -330,10 +437,10 @@ export function registerSlashCommands(
330
437
  ...(config.skill !== undefined ? { skill: config.skill } : {}),
331
438
  ...(config.progress !== undefined ? { progress: config.progress } : {}),
332
439
  }));
333
- const params: Record<string, unknown> = { chain, task: parsed.task, clarify: false, agentScope: "both" };
440
+ const params: SubagentParamsLike = { chain, task: parsed.task, clarify: false, agentScope: "both" };
334
441
  if (bg) params.async = true;
335
442
  if (fork) params.context = "fork";
336
- pi.sendUserMessage(`Call the subagent tool with these exact parameters: ${JSON.stringify(params)}`);
443
+ await runSlashSubagent(pi, ctx, params);
337
444
  },
338
445
  });
339
446
 
@@ -354,16 +461,16 @@ export function registerSlashCommands(
354
461
  ...(config.skill !== undefined ? { skill: config.skill } : {}),
355
462
  ...(config.progress !== undefined ? { progress: config.progress } : {}),
356
463
  }));
357
- const params: Record<string, unknown> = { chain: [{ parallel: tasks }], task: parsed.task, clarify: false, agentScope: "both" };
464
+ const params: SubagentParamsLike = { tasks, clarify: false, agentScope: "both" };
358
465
  if (bg) params.async = true;
359
466
  if (fork) params.context = "fork";
360
- pi.sendUserMessage(`Call the subagent tool with these exact parameters: ${JSON.stringify(params)}`);
467
+ await runSlashSubagent(pi, ctx, params);
361
468
  },
362
469
  });
363
470
 
364
471
  pi.registerShortcut("ctrl+shift+a", {
365
472
  handler: async (ctx) => {
366
- await openAgentManager(pi, state, ctx, getSubagentSessionRoot);
473
+ await openAgentManager(pi, ctx);
367
474
  },
368
475
  });
369
476
  }
@@ -0,0 +1,294 @@
1
+ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
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";
6
+
7
+ export interface SlashMessageDetails {
8
+ requestId: string;
9
+ result: AgentToolResult<Details>;
10
+ }
11
+
12
+ interface SlashSnapshot {
13
+ result: AgentToolResult<Details>;
14
+ version: number;
15
+ }
16
+
17
+ interface SequentialChainStepLike {
18
+ agent: string;
19
+ task?: string;
20
+ }
21
+
22
+ interface ParallelChainStepLike {
23
+ parallel: Array<{ agent: string; task?: string }>;
24
+ }
25
+
26
+ type ChainStepLike = SequentialChainStepLike | ParallelChainStepLike;
27
+
28
+ const liveSnapshots = new Map<string, SlashSnapshot>();
29
+ const finalSnapshots = new Map<string, SlashSnapshot>();
30
+ let versionCounter = 1;
31
+
32
+ const EMPTY_MESSAGES: Message[] = [];
33
+ const EMPTY_USAGE: Usage = {
34
+ input: 0,
35
+ output: 0,
36
+ cacheRead: 0,
37
+ cacheWrite: 0,
38
+ cost: 0,
39
+ turns: 0,
40
+ };
41
+
42
+ function nextVersion(): number {
43
+ return versionCounter++;
44
+ }
45
+
46
+ function cloneUsage(): Usage {
47
+ return { ...EMPTY_USAGE };
48
+ }
49
+
50
+ function createPlaceholderResult(
51
+ agent: string,
52
+ task: string,
53
+ status: "pending" | "running",
54
+ index?: number,
55
+ ): SingleResult {
56
+ return {
57
+ agent,
58
+ task,
59
+ exitCode: 0,
60
+ messages: EMPTY_MESSAGES,
61
+ usage: cloneUsage(),
62
+ progress: {
63
+ ...(index !== undefined ? { index } : {}),
64
+ agent,
65
+ status,
66
+ task,
67
+ recentTools: [],
68
+ recentOutput: [],
69
+ toolCount: 0,
70
+ tokens: 0,
71
+ durationMs: 0,
72
+ },
73
+ };
74
+ }
75
+
76
+ function buildParallelInitialResult(params: SubagentParamsLike): AgentToolResult<Details> {
77
+ const tasks = params.tasks ?? [];
78
+ return {
79
+ content: [{ type: "text", text: tasks.map((task) => `${task.agent}: ${task.task}`).join("\n\n") }],
80
+ details: {
81
+ mode: "parallel",
82
+ ...(params.context ? { context: params.context } : {}),
83
+ results: tasks.map((task, index) => createPlaceholderResult(task.agent, task.task, "running", index)),
84
+ progress: tasks.map((task, index) => ({
85
+ index,
86
+ agent: task.agent,
87
+ status: "running" as const,
88
+ task: task.task,
89
+ recentTools: [],
90
+ recentOutput: [],
91
+ toolCount: 0,
92
+ tokens: 0,
93
+ durationMs: 0,
94
+ })),
95
+ },
96
+ };
97
+ }
98
+
99
+ function isParallelChainStep(step: ChainStepLike): step is ParallelChainStepLike {
100
+ return "parallel" in step && Array.isArray(step.parallel);
101
+ }
102
+
103
+ function chainStepLabel(step: ChainStepLike): string {
104
+ if (isParallelChainStep(step)) {
105
+ return `[${step.parallel.map((entry) => entry.agent).join("+")}]`;
106
+ }
107
+ return step.agent;
108
+ }
109
+
110
+ function flattenChainResults(chain: ChainStepLike[], fallbackTask: string | undefined): SingleResult[] {
111
+ const results: SingleResult[] = [];
112
+ let flatIndex = 0;
113
+ for (const step of chain) {
114
+ if (isParallelChainStep(step)) {
115
+ for (const task of step.parallel) {
116
+ results.push(createPlaceholderResult(task.agent, task.task ?? fallbackTask ?? "", results.length === 0 ? "running" : "pending", flatIndex));
117
+ flatIndex++;
118
+ }
119
+ continue;
120
+ }
121
+ results.push(createPlaceholderResult(step.agent, step.task ?? fallbackTask ?? "", results.length === 0 ? "running" : "pending", flatIndex));
122
+ flatIndex++;
123
+ }
124
+ return results;
125
+ }
126
+
127
+ function buildChainInitialResult(params: SubagentParamsLike): AgentToolResult<Details> {
128
+ const chain = (params.chain ?? []) as ChainStepLike[];
129
+ const results = flattenChainResults(chain, params.task);
130
+ return {
131
+ content: [{
132
+ type: "text",
133
+ text: results.map((result, index) => `Step ${index + 1}: ${result.agent}\n${result.task}`).join("\n\n"),
134
+ }],
135
+ details: {
136
+ mode: "chain",
137
+ ...(params.context ? { context: params.context } : {}),
138
+ results,
139
+ progress: results.map((result, index) => ({
140
+ index,
141
+ agent: result.agent,
142
+ status: index === 0 ? "running" as const : "pending" as const,
143
+ task: result.task,
144
+ recentTools: [],
145
+ recentOutput: [],
146
+ toolCount: 0,
147
+ tokens: 0,
148
+ durationMs: 0,
149
+ })),
150
+ chainAgents: chain.map((step) => chainStepLabel(step)),
151
+ totalSteps: chain.length,
152
+ currentStepIndex: 0,
153
+ },
154
+ };
155
+ }
156
+
157
+ function buildSingleInitialResult(params: SubagentParamsLike): AgentToolResult<Details> {
158
+ const agent = params.agent ?? "subagent";
159
+ const task = params.task ?? "";
160
+ return {
161
+ content: [{ type: "text", text: task }],
162
+ details: {
163
+ mode: "single",
164
+ ...(params.context ? { context: params.context } : {}),
165
+ results: [createPlaceholderResult(agent, task, "running")],
166
+ progress: [{
167
+ agent,
168
+ status: "running",
169
+ task,
170
+ recentTools: [],
171
+ recentOutput: [],
172
+ toolCount: 0,
173
+ tokens: 0,
174
+ durationMs: 0,
175
+ }],
176
+ },
177
+ };
178
+ }
179
+
180
+ export function buildSlashInitialResult(requestId: string, params: SubagentParamsLike): SlashMessageDetails {
181
+ const result = (params.tasks?.length ?? 0) > 0
182
+ ? buildParallelInitialResult(params)
183
+ : (params.chain?.length ?? 0) > 0
184
+ ? buildChainInitialResult(params)
185
+ : buildSingleInitialResult(params);
186
+ liveSnapshots.set(requestId, { result, version: nextVersion() });
187
+ finalSnapshots.delete(requestId);
188
+ return { requestId, result };
189
+ }
190
+
191
+ function cloneResultsWithProgress(
192
+ results: SingleResult[],
193
+ progress: NonNullable<Details["progress"]> | undefined,
194
+ ): SingleResult[] {
195
+ return results.map((result, index) => {
196
+ const nextProgress = progress?.find((entry) => entry.index === index)
197
+ ?? progress?.[index]
198
+ ?? result.progress;
199
+ return nextProgress ? { ...result, progress: nextProgress } : result;
200
+ });
201
+ }
202
+
203
+ export function applySlashUpdate(requestId: string, update: SlashSubagentUpdate): void {
204
+ const snapshot = liveSnapshots.get(requestId);
205
+ if (!snapshot) return;
206
+ const progress = update.progress;
207
+ if (!progress || !snapshot.result.details) return;
208
+ const currentStepIndex = progress.findIndex((entry) => entry.status === "running");
209
+ const nextDetails: Details = {
210
+ ...snapshot.result.details,
211
+ progress,
212
+ results: cloneResultsWithProgress(snapshot.result.details.results, progress),
213
+ ...(snapshot.result.details.mode === "chain" && currentStepIndex >= 0 ? { currentStepIndex } : {}),
214
+ };
215
+ liveSnapshots.set(requestId, {
216
+ result: {
217
+ ...snapshot.result,
218
+ details: nextDetails,
219
+ },
220
+ version: nextVersion(),
221
+ });
222
+ }
223
+
224
+ export function finalizeSlashResult(response: SlashSubagentResponse): SlashMessageDetails {
225
+ const snapshot = {
226
+ result: response.result,
227
+ version: nextVersion(),
228
+ };
229
+ finalSnapshots.set(response.requestId, snapshot);
230
+ liveSnapshots.delete(response.requestId);
231
+ return {
232
+ requestId: response.requestId,
233
+ result: response.result,
234
+ };
235
+ }
236
+
237
+ export function failSlashResult(requestId: string, params: SubagentParamsLike, message: string): SlashMessageDetails {
238
+ const initial = buildSlashInitialResult(requestId, params).result;
239
+ const failedResults = initial.details.results.map((result) => ({
240
+ ...result,
241
+ exitCode: 1,
242
+ error: message,
243
+ progress: result.progress ? { ...result.progress, status: "failed" as const } : result.progress,
244
+ }));
245
+ const result: AgentToolResult<Details> = {
246
+ content: [{ type: "text", text: message }],
247
+ details: {
248
+ ...initial.details,
249
+ results: failedResults,
250
+ progress: failedResults.map((entry) => entry.progress!).filter(Boolean),
251
+ },
252
+ };
253
+ const snapshot = { result, version: nextVersion() };
254
+ finalSnapshots.set(requestId, snapshot);
255
+ liveSnapshots.delete(requestId);
256
+ return { requestId, result };
257
+ }
258
+
259
+ function isSlashMessageDetails(value: unknown): value is SlashMessageDetails {
260
+ if (!value || typeof value !== "object") return false;
261
+ const v = value as { requestId?: string; result?: { content?: unknown; details?: { results?: unknown } } };
262
+ if (typeof v.requestId !== "string" || !v.requestId) return false;
263
+ if (!v.result || !Array.isArray(v.result.content)) return false;
264
+ return !!v.result.details && Array.isArray(v.result.details.results);
265
+ }
266
+
267
+ export function resolveSlashMessageDetails(value: unknown): SlashMessageDetails | undefined {
268
+ return isSlashMessageDetails(value) ? value : undefined;
269
+ }
270
+
271
+ export function getSlashRenderableSnapshot(details: SlashMessageDetails): SlashSnapshot {
272
+ return finalSnapshots.get(details.requestId)
273
+ ?? liveSnapshots.get(details.requestId)
274
+ ?? { result: details.result, version: 0 };
275
+ }
276
+
277
+ export function restoreSlashFinalSnapshots(entries: unknown[]): void {
278
+ liveSnapshots.clear();
279
+ finalSnapshots.clear();
280
+ for (const entry of entries) {
281
+ const e = entry as { type?: string; message?: { role?: string; customType?: string; display?: boolean; details?: unknown } };
282
+ if (e?.type !== "message") continue;
283
+ const m = e.message;
284
+ if (!m || m.role !== "custom" || m.customType !== SLASH_RESULT_TYPE || m.display !== false) continue;
285
+ const details = resolveSlashMessageDetails(m.details);
286
+ if (!details) continue;
287
+ finalSnapshots.set(details.requestId, { result: details.result, version: nextVersion() });
288
+ }
289
+ }
290
+
291
+ export function clearSlashSnapshots(): void {
292
+ liveSnapshots.clear();
293
+ finalSnapshots.clear();
294
+ }
@@ -51,7 +51,7 @@ interface TaskParam {
51
51
  progress?: boolean;
52
52
  }
53
53
 
54
- interface SubagentParamsLike {
54
+ export interface SubagentParamsLike {
55
55
  action?: string;
56
56
  agent?: string;
57
57
  task?: string;
@@ -107,6 +107,7 @@ function validateExecutionInput(
107
107
  hasChain: boolean,
108
108
  hasTasks: boolean,
109
109
  hasSingle: boolean,
110
+ allowClarifyTaskPrompt: boolean,
110
111
  ): AgentToolResult<Details> | null {
111
112
  if (Number(hasChain) + Number(hasTasks) + Number(hasSingle) !== 1) {
112
113
  return {
@@ -139,7 +140,7 @@ function validateExecutionInput(
139
140
  details: { mode: "chain" as const, results: [] },
140
141
  };
141
142
  }
142
- } else if (!(firstStep as SequentialStep).task && !params.task) {
143
+ } else if (!(firstStep as SequentialStep).task && !params.task && !allowClarifyTaskPrompt) {
143
144
  return {
144
145
  content: [{ type: "text", text: "First step in chain must have a task" }],
145
146
  isError: true,
@@ -806,8 +807,19 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
806
807
  const hasChain = (params.chain?.length ?? 0) > 0;
807
808
  const hasTasks = (params.tasks?.length ?? 0) > 0;
808
809
  const hasSingle = Boolean(params.agent && params.task);
810
+ const allowClarifyTaskPrompt = hasChain
811
+ && params.clarify === true
812
+ && ctx.hasUI
813
+ && !(params.chain?.some(isParallelStep) ?? false);
809
814
 
810
- const validationError = validateExecutionInput(params, agents, hasChain, hasTasks, hasSingle);
815
+ const validationError = validateExecutionInput(
816
+ params,
817
+ agents,
818
+ hasChain,
819
+ hasTasks,
820
+ hasSingle,
821
+ allowClarifyTaskPrompt,
822
+ );
811
823
  if (validationError) return validationError;
812
824
 
813
825
  let sessionFileForIndex: (idx?: number) => string | undefined = () => undefined;
package/types.ts CHANGED
@@ -263,6 +263,12 @@ export const MAX_CONCURRENCY = 4;
263
263
  export const RESULTS_DIR = path.join(os.tmpdir(), "pi-async-subagent-results");
264
264
  export const ASYNC_DIR = path.join(os.tmpdir(), "pi-async-subagent-runs");
265
265
  export const WIDGET_KEY = "subagent-async";
266
+ export const SLASH_RESULT_TYPE = "subagent-slash-result";
267
+ export const SLASH_SUBAGENT_REQUEST_EVENT = "subagent:slash:request";
268
+ export const SLASH_SUBAGENT_STARTED_EVENT = "subagent:slash:started";
269
+ export const SLASH_SUBAGENT_RESPONSE_EVENT = "subagent:slash:response";
270
+ export const SLASH_SUBAGENT_UPDATE_EVENT = "subagent:slash:update";
271
+ export const SLASH_SUBAGENT_CANCEL_EVENT = "subagent:slash:cancel";
266
272
  export const POLL_INTERVAL_MS = 250;
267
273
  export const MAX_WIDGET_JOBS = 4;
268
274
  export const DEFAULT_SUBAGENT_MAX_DEPTH = 2;