pi-subagents 0.11.8 → 0.11.10

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.10] - 2026-03-21
6
+
7
+ ### Changed
8
+ - Trimmed tool schema and description to reduce per-turn token cost by ~166 tokens (13%). Removed `maxOutput` from the LLM-facing schema (still accepted internally), shortened `context` and `output` descriptions, removed redundant CHAIN DATA FLOW section from tool description, condensed MANAGEMENT bullet points.
9
+
10
+ ## [0.11.9] - 2026-03-21
11
+
12
+ ### Fixed
13
+ - `/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`.
14
+ - `/agents` overlay chain launches no longer bypass the executor for async fallback, fixing a path where async chain errors were silently swallowed.
15
+
16
+ ### Changed
17
+ - 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.
18
+ - 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.
19
+ - `/parallel` now uses the native `tasks` parameter directly instead of wrapping through `{ chain: [{ parallel: tasks }] }`.
20
+
21
+ ### Added
22
+ - `slash-bridge.ts` — event bus bridge for slash command execution. Manages AbortController lifecycle, cancel-before-start races, and progress streaming via `subagent:slash:*` events.
23
+ - `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.
24
+ - Clarified README Usage section to distinguish LLM tool parameters from user-facing slash commands.
25
+
5
26
  ## [0.11.8] - 2026-03-21
6
27
 
7
28
  ### Added
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,6 +185,19 @@ 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,
@@ -192,20 +251,15 @@ CHAIN TEMPLATE VARIABLES (use in task strings):
192
251
  • {previous} - Text response from the previous step (empty for first step)
193
252
  • {chain_dir} - Shared directory for chain files (e.g., <tmpdir>/pi-chain-runs/abc123/)
194
253
 
195
- CHAIN DATA FLOW:
196
- 1. Each step's text response automatically becomes {previous} for the next step
197
- 2. Steps can also write files to {chain_dir} (via agent's "output" config)
198
- 3. Later steps can read those files (via agent's "reads" config)
199
-
200
254
  Example: { chain: [{agent:"scout", task:"Analyze {task}"}, {agent:"planner", task:"Plan based on {previous}"}] }
201
255
 
202
- MANAGEMENT (use action field omit agent/task/chain/tasks):
203
- • { action: "list" } - discover available agents and chains
204
- • { action: "get", agent: "name" } - full agent detail with system prompt
205
- • { action: "create", config: { name, description, systemPrompt, ... } } - create agent/chain
206
- • { action: "update", agent: "name", config: { ... } } - modify fields (merge)
207
- • { action: "delete", agent: "name" } - remove definition
208
- • Use chainName instead of agent for chain operations`,
256
+ MANAGEMENT (use action field, omit agent/task/chain/tasks):
257
+ • { action: "list" } - discover agents/chains
258
+ • { action: "get", agent: "name" } - full agent detail
259
+ • { action: "create", config: { name, systemPrompt, ... } }
260
+ • { action: "update", agent: "name", config: { ... } } - merge
261
+ • { action: "delete", agent: "name" }
262
+ • Use chainName for chain operations`,
209
263
  parameters: SubagentParams,
210
264
 
211
265
  execute(id, params, signal, onUpdate, ctx) {
@@ -340,7 +394,7 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
340
394
 
341
395
  pi.registerTool(tool);
342
396
  pi.registerTool(statusTool);
343
- registerSlashCommands(pi, state, getSubagentSessionRoot);
397
+ registerSlashCommands(pi, state);
344
398
 
345
399
  pi.events.on("subagent:started", handleStarted);
346
400
  pi.events.on("subagent:complete", handleComplete);
@@ -372,6 +426,7 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
372
426
  state.lastUiContext = ctx;
373
427
  cleanupSessionArtifacts(ctx);
374
428
  resetJobs(ctx);
429
+ restoreSlashFinalSnapshots(ctx.sessionManager.getEntries());
375
430
  };
376
431
 
377
432
  pi.on("session_start", (_event, ctx) => {
@@ -392,6 +447,9 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
392
447
  }
393
448
  state.cleanupTimers.clear();
394
449
  state.asyncJobs.clear();
450
+ clearSlashSnapshots();
451
+ slashBridge.cancelAll();
452
+ slashBridge.dispose();
395
453
  promptTemplateBridge.cancelAll();
396
454
  promptTemplateBridge.dispose();
397
455
  if (state.lastUiContext?.hasUI) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.11.8",
3
+ "version": "0.11.10",
4
4
  "description": "Pi extension for delegating tasks to subagents with chains, parallel execution, and TUI clarification",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
package/render.ts CHANGED
@@ -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")
package/schemas.ts CHANGED
@@ -52,13 +52,6 @@ export const ParallelStepSchema = Type.Object({
52
52
  // Note: Using Type.Any() for Google API compatibility (doesn't support anyOf)
53
53
  export const ChainItem = Type.Any({ description: "Chain step: either {agent, task?, ...} for sequential or {parallel: [...]} for concurrent execution" });
54
54
 
55
- export const MaxOutputSchema = Type.Optional(
56
- Type.Object({
57
- bytes: Type.Optional(Type.Number({ description: "Max bytes (default: 204800)" })),
58
- lines: Type.Optional(Type.Number({ description: "Max lines (default: 5000)" })),
59
- }),
60
- );
61
-
62
55
  export const SubagentParams = Type.Object({
63
56
  agent: Type.Optional(Type.String({ description: "Agent name (SINGLE mode) or target for management get/update/delete" })),
64
57
  task: Type.Optional(Type.String({ description: "Task (SINGLE mode)" })),
@@ -78,13 +71,12 @@ export const SubagentParams = Type.Object({
78
71
  chain: Type.Optional(Type.Array(ChainItem, { description: "CHAIN mode: sequential pipeline where each step's response becomes {previous} for the next. Use {task}, {previous}, {chain_dir} in task templates." })),
79
72
  context: Type.Optional(Type.String({
80
73
  enum: ["fresh", "fork"],
81
- description: "Execution context mode: 'fresh' (default) starts clean, 'fork' starts from a real fork of the parent session leaf",
74
+ description: "'fresh' (default) or 'fork' to branch from parent session",
82
75
  })),
83
76
  chainDir: Type.Optional(Type.String({ description: "Persistent directory for chain artifacts. Default: <tmpdir>/pi-chain-runs/ (auto-cleaned after 24h)" })),
84
77
  async: Type.Optional(Type.Boolean({ description: "Run in background (default: false, or per config)" })),
85
78
  agentScope: Type.Optional(Type.String({ description: "Agent discovery scope: 'user', 'project', or 'both' (default: 'both'; project wins on name collisions)" })),
86
79
  cwd: Type.Optional(Type.String()),
87
- maxOutput: MaxOutputSchema,
88
80
  artifacts: Type.Optional(Type.Boolean({ description: "Write debug artifacts (default: true)" })),
89
81
  includeProgress: Type.Optional(Type.Boolean({ description: "Include full progress in result (default: false)" })),
90
82
  share: Type.Optional(Type.Boolean({ description: "Upload session to GitHub Gist for sharing (default: false)" })),
@@ -94,7 +86,7 @@ export const SubagentParams = Type.Object({
94
86
  // Clarification TUI
95
87
  clarify: Type.Optional(Type.Boolean({ description: "Show TUI to preview/edit before execution (default: true for chains, false for single/parallel). Implies sync mode." })),
96
88
  // Solo agent overrides
97
- output: Type.Optional(Type.Any({ description: "Override output file for single agent (string), or false to disable (uses agent default if omitted). Absolute paths are used as-is; relative paths resolve against cwd." })),
89
+ output: Type.Optional(Type.Any({ description: "Output file for single agent (string), or false to disable. Relative paths resolve against cwd." })),
98
90
  skill: Type.Optional(SkillOverride),
99
91
  model: Type.Optional(Type.String({ description: "Override model for single agent (e.g. 'anthropic/claude-sonnet-4')" })),
100
92
  });
@@ -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;