pi-subagents 0.21.4 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +12 -11
  3. package/agents/context-builder.md +4 -5
  4. package/agents/delegate.md +2 -1
  5. package/agents/oracle.md +5 -8
  6. package/agents/planner.md +2 -3
  7. package/agents/researcher.md +2 -3
  8. package/agents/reviewer.md +4 -21
  9. package/agents/scout.md +2 -3
  10. package/agents/worker.md +7 -7
  11. package/package.json +3 -2
  12. package/prompts/parallel-context-build.md +1 -1
  13. package/prompts/parallel-handoff-plan.md +2 -2
  14. package/skills/pi-subagents/SKILL.md +30 -27
  15. package/src/extension/index.ts +4 -1
  16. package/src/intercom/intercom-bridge.ts +10 -8
  17. package/src/intercom/result-intercom.ts +4 -3
  18. package/src/manager-ui/agent-manager-edit.ts +43 -19
  19. package/src/manager-ui/agent-manager.ts +5 -2
  20. package/src/runs/background/async-execution.ts +16 -10
  21. package/src/runs/background/async-job-tracker.ts +12 -19
  22. package/src/runs/background/async-resume.ts +1 -0
  23. package/src/runs/background/async-status.ts +35 -49
  24. package/src/runs/background/parallel-groups.ts +45 -0
  25. package/src/runs/background/result-watcher.ts +2 -2
  26. package/src/runs/background/run-status.ts +26 -7
  27. package/src/runs/background/stale-run-reconciler.ts +15 -2
  28. package/src/runs/background/subagent-runner.ts +12 -3
  29. package/src/runs/foreground/chain-clarify.ts +35 -30
  30. package/src/runs/foreground/chain-execution.ts +9 -6
  31. package/src/runs/foreground/execution.ts +4 -0
  32. package/src/runs/foreground/subagent-executor.ts +18 -30
  33. package/src/runs/shared/model-fallback.ts +2 -5
  34. package/src/runs/shared/pi-args.ts +20 -0
  35. package/src/shared/model-info.ts +68 -0
  36. package/src/shared/session-identity.ts +10 -0
  37. package/src/shared/types.ts +14 -5
  38. package/src/slash/slash-commands.ts +8 -7
  39. package/src/tui/render.ts +67 -2
  40. package/src/tui/subagents-status.ts +129 -15
@@ -6,6 +6,7 @@ import {
6
6
  type SubagentResultIntercomChild,
7
7
  type SubagentResultIntercomPayload,
8
8
  type SubagentResultStatus,
9
+ type SubagentRunMode,
9
10
  SUBAGENT_RESULT_INTERCOM_DELIVERY_EVENT,
10
11
  SUBAGENT_RESULT_INTERCOM_EVENT,
11
12
  } from "../shared/types.ts";
@@ -61,7 +62,7 @@ function resolveGroupedStatus(children: SubagentResultIntercomChild[]): Subagent
61
62
  interface GroupedResultIntercomMessageInput {
62
63
  to: string;
63
64
  runId: string;
64
- mode: "single" | "parallel" | "chain";
65
+ mode: SubagentRunMode;
65
66
  source: "foreground" | "async";
66
67
  children: SubagentResultIntercomChild[];
67
68
  asyncId?: string;
@@ -84,7 +85,7 @@ function asyncResumeGuidance(input: {
84
85
 
85
86
  function formatSubagentResultIntercomMessage(input: {
86
87
  runId: string;
87
- mode: "single" | "parallel" | "chain";
88
+ mode: SubagentRunMode;
88
89
  status: SubagentResultStatus;
89
90
  source: "foreground" | "async";
90
91
  children: SubagentResultIntercomChild[];
@@ -218,7 +219,7 @@ export function stripDetailsOutputsForIntercomReceipt(details: Details): Details
218
219
  }
219
220
 
220
221
  export function formatSubagentResultReceipt(input: {
221
- mode: "single" | "parallel" | "chain";
222
+ mode: SubagentRunMode;
222
223
  runId: string;
223
224
  payload: SubagentResultIntercomPayload;
224
225
  }): string {
@@ -4,30 +4,31 @@ import { buildRuntimeName, defaultSystemPromptMode, frontmatterNameForConfig, pa
4
4
  import { createEditorState, ensureCursorVisible, getCursorDisplayPos, handleEditorInput, renderEditor, wrapText } from "../tui/text-editor.ts";
5
5
  import type { TextEditorState } from "../tui/text-editor.ts";
6
6
  import { pad, row, renderHeader, renderFooter, formatScrollInfo } from "../tui/render-helpers.ts";
7
+ import { findModelInfo, getSupportedThinkingLevels, type ModelInfo, type ThinkingLevel } from "../shared/model-info.ts";
7
8
 
8
- export interface ModelInfo { provider: string; id: string; fullId: string; }
9
+ export type { ModelInfo };
9
10
  export interface SkillInfo { name: string; source: string; description?: string; }
10
11
  export type EditScreen = "edit" | "edit-field" | "edit-prompt";
11
12
  export type EditField = typeof FIELD_ORDER[number];
12
13
 
13
14
  export interface EditState {
14
15
  draft: AgentConfig; isNew: boolean; fieldIndex: number; fieldMode: "text" | "model" | "thinking" | "skills" | null;
15
- fieldEditor: TextEditorState; promptEditor: TextEditorState; modelSearchQuery: string; modelCursor: number; filteredModels: ModelInfo[];
16
+ fieldEditor: TextEditorState; promptEditor: TextEditorState; modelSearchQuery: string; modelCursor: number; models: ModelInfo[]; filteredModels: ModelInfo[];
16
17
  thinkingCursor: number; skillSearchQuery: string; skillCursor: number; filteredSkills: SkillInfo[]; skillSelected: Set<string>; error?: string;
17
18
  fields: EditField[];
18
19
  title?: string;
19
20
  overrideBase?: BuiltinAgentOverrideBase;
21
+ preferredProvider?: string;
20
22
  }
21
23
  interface EditInputResult { action?: "save" | "discard" | "delete"; nextScreen?: EditScreen; }
22
24
  interface CreateEditStateOptions {
23
25
  fields?: EditField[];
24
26
  title?: string;
25
27
  overrideBase?: BuiltinAgentOverrideBase;
28
+ preferredProvider?: string;
26
29
  }
27
30
 
28
- const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
29
31
  const FIELD_ORDER = ["name", "package", "description", "model", "fallbackModels", "thinking", "systemPromptMode", "inheritProjectContext", "inheritSkills", "defaultContext", "tools", "extensions", "skills", "output", "reads", "progress", "interactive", "prompt"] as const;
30
- type ThinkingLevel = typeof THINKING_LEVELS[number];
31
32
  const PROMPT_VIEWPORT_HEIGHT = 16;
32
33
  const MODEL_SELECTOR_HEIGHT = 10;
33
34
  const SKILL_SELECTOR_HEIGHT = 10;
@@ -86,8 +87,8 @@ export function createEditState(draft: AgentConfig, isNew: boolean, models: Mode
86
87
  return {
87
88
  draft: { ...draft, tools: draft.tools ? [...draft.tools] : undefined, mcpDirectTools: draft.mcpDirectTools ? [...draft.mcpDirectTools] : undefined, skills: draft.skills ? [...draft.skills] : undefined, fallbackModels: draft.fallbackModels ? [...draft.fallbackModels] : undefined, extensions: draft.extensions ? [...draft.extensions] : draft.extensions, defaultReads: draft.defaultReads ? [...draft.defaultReads] : undefined, extraFields: draft.extraFields ? { ...draft.extraFields } : undefined },
88
89
  isNew, fieldIndex: 0, fieldMode: null, fieldEditor: createEditorState(), promptEditor: createEditorState(draft.systemPrompt ?? ""),
89
- modelSearchQuery: "", modelCursor: 0, filteredModels: [...models], thinkingCursor: 0, skillSearchQuery: "", skillCursor: 0, filteredSkills: [...skills], skillSelected: new Set(draft.skills ?? []),
90
- fields: options.fields ?? [...FIELD_ORDER], title: options.title, overrideBase: options.overrideBase,
90
+ modelSearchQuery: "", modelCursor: 0, models: [...models], filteredModels: [...models], thinkingCursor: 0, skillSearchQuery: "", skillCursor: 0, filteredSkills: [...skills], skillSelected: new Set(draft.skills ?? []),
91
+ fields: options.fields ?? [...FIELD_ORDER], title: options.title, overrideBase: options.overrideBase, preferredProvider: options.preferredProvider,
91
92
  };
92
93
  }
93
94
 
@@ -180,9 +181,16 @@ function openModelPicker(state: EditState, models: ModelInfo[]): void {
180
181
  state.fieldIndex = state.fields.indexOf("model"); state.fieldMode = "model"; state.modelSearchQuery = ""; state.filteredModels = [...models];
181
182
  const idx = state.filteredModels.findIndex((m) => m.fullId === state.draft.model || m.id === state.draft.model); state.modelCursor = idx >= 0 ? idx : 0;
182
183
  }
184
+ function getDraftThinkingLevels(state: EditState): ThinkingLevel[] {
185
+ return getSupportedThinkingLevels(findModelInfo(state.draft.model, state.models, state.preferredProvider));
186
+ }
187
+
183
188
  function openThinkingPicker(state: EditState): void {
184
189
  state.fieldIndex = state.fields.indexOf("thinking"); state.fieldMode = "thinking";
185
- const idx = THINKING_LEVELS.indexOf((state.draft.thinking ?? "off") as ThinkingLevel); state.thinkingCursor = idx >= 0 ? idx : 0;
190
+ const levels = getDraftThinkingLevels(state);
191
+ const currentLevel = state.draft.thinking ?? "off";
192
+ const idx = levels.findIndex((level) => level === currentLevel);
193
+ state.thinkingCursor = idx >= 0 ? idx : Math.max(0, levels.indexOf("off"));
186
194
  }
187
195
  function openSkillPicker(state: EditState, skills: SkillInfo[]): void {
188
196
  state.fieldIndex = state.fields.indexOf("skills"); state.fieldMode = "skills"; state.skillSearchQuery = ""; state.filteredSkills = [...skills]; state.skillSelected = new Set(state.draft.skills ?? []); state.skillCursor = 0;
@@ -230,16 +238,22 @@ function renderThinkingPicker(state: EditState, width: number, theme: Theme): st
230
238
  high: "Deep reasoning",
231
239
  xhigh: "Maximum reasoning (ultrathink)",
232
240
  };
233
- for (let i = 0; i < THINKING_LEVELS.length; i++) {
234
- const level = THINKING_LEVELS[i]!;
235
- const isSelected = i === state.thinkingCursor;
236
- const prefix = isSelected ? theme.fg("accent", "→ ") : " ";
237
- const levelText = isSelected ? theme.fg("accent", level) : level;
238
- const desc = theme.fg("dim", ` - ${descriptions[level]}`);
239
- lines.push(row(` ${prefix}${levelText}${desc}`, width, theme));
241
+ const levels = getDraftThinkingLevels(state);
242
+ if (levels.length === 0) {
243
+ lines.push(row(` ${theme.fg("dim", "No supported thinking levels")}`, width, theme));
244
+ } else {
245
+ for (let i = 0; i < levels.length; i++) {
246
+ const level = levels[i]!;
247
+ const isSelected = i === state.thinkingCursor;
248
+ const prefix = isSelected ? theme.fg("accent", "→ ") : " ";
249
+ const levelText = isSelected ? theme.fg("accent", level) : level;
250
+ const desc = theme.fg("dim", ` - ${descriptions[level]}`);
251
+ lines.push(row(` ${prefix}${levelText}${desc}`, width, theme));
252
+ }
240
253
  }
241
254
  while (lines.length < 19) lines.push(row("", width, theme));
242
- lines.push(renderFooter(" [enter] select [esc] cancel [↑↓] navigate ", width, theme));
255
+ const footer = levels.length === 0 ? " [esc] cancel " : " [enter] select [esc] cancel [↑↓] navigate ";
256
+ lines.push(renderFooter(footer, width, theme));
243
257
  return lines;
244
258
  }
245
259
 
@@ -323,7 +337,15 @@ export function handleEditInput(screen: EditScreen, state: EditState, data: stri
323
337
  if (screen === "edit-field") {
324
338
  if (state.fieldMode === "model") {
325
339
  if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { state.fieldMode = null; return { nextScreen: "edit" }; }
326
- if (matchesKey(data, "return")) { const selected = state.filteredModels[state.modelCursor]; if (selected) state.draft.model = selected.fullId; state.fieldMode = null; return { nextScreen: "edit" }; }
340
+ if (matchesKey(data, "return")) {
341
+ const selected = state.filteredModels[state.modelCursor];
342
+ if (selected) {
343
+ state.draft.model = selected.fullId;
344
+ if (state.draft.thinking && !getDraftThinkingLevels(state).some((level) => level === state.draft.thinking)) state.draft.thinking = undefined;
345
+ }
346
+ state.fieldMode = null;
347
+ return { nextScreen: "edit" };
348
+ }
327
349
  if (matchesKey(data, "up")) { if (state.filteredModels.length > 0) state.modelCursor = state.modelCursor === 0 ? state.filteredModels.length - 1 : state.modelCursor - 1; return; }
328
350
  if (matchesKey(data, "down")) { if (state.filteredModels.length > 0) state.modelCursor = state.modelCursor === state.filteredModels.length - 1 ? 0 : state.modelCursor + 1; return; }
329
351
  if (matchesKey(data, "backspace")) { if (state.modelSearchQuery.length > 0) state.modelSearchQuery = state.modelSearchQuery.slice(0, -1); }
@@ -334,10 +356,12 @@ export function handleEditInput(screen: EditScreen, state: EditState, data: stri
334
356
  return;
335
357
  }
336
358
  if (state.fieldMode === "thinking") {
359
+ const levels = getDraftThinkingLevels(state);
337
360
  if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { state.fieldMode = null; return { nextScreen: "edit" }; }
338
- if (matchesKey(data, "return")) { const selected = THINKING_LEVELS[state.thinkingCursor]; state.draft.thinking = selected === "off" ? undefined : selected; state.fieldMode = null; return { nextScreen: "edit" }; }
339
- if (matchesKey(data, "up")) { state.thinkingCursor = state.thinkingCursor === 0 ? THINKING_LEVELS.length - 1 : state.thinkingCursor - 1; return; }
340
- if (matchesKey(data, "down")) { state.thinkingCursor = state.thinkingCursor === THINKING_LEVELS.length - 1 ? 0 : state.thinkingCursor + 1; return; }
361
+ if (levels.length === 0) return;
362
+ if (matchesKey(data, "return")) { const selected = levels[state.thinkingCursor] ?? "off"; state.draft.thinking = selected === "off" ? undefined : selected; state.fieldMode = null; return { nextScreen: "edit" }; }
363
+ if (matchesKey(data, "up")) { state.thinkingCursor = state.thinkingCursor === 0 ? levels.length - 1 : state.thinkingCursor - 1; return; }
364
+ if (matchesKey(data, "down")) { state.thinkingCursor = state.thinkingCursor === levels.length - 1 ? 0 : state.thinkingCursor + 1; return; }
341
365
  return;
342
366
  }
343
367
  if (state.fieldMode === "skills") {
@@ -45,7 +45,7 @@ interface ChainEntry { id: string; kind: "chain"; config: ChainConfig; }
45
45
  interface NameInputState { mode: "new-agent" | "clone-agent" | "clone-chain" | "new-chain"; editor: TextEditorState; scope: "user" | "project"; allowProject: boolean; sourceId?: string; template?: AgentTemplate; error?: string; }
46
46
  interface StatusMessage { text: string; type: "error" | "info"; }
47
47
  interface OverrideScopeState { selectedScope: "user" | "project"; allowProject: boolean; }
48
- export interface AgentManagerOptions { newShortcut?: string; }
48
+ export interface AgentManagerOptions { newShortcut?: string; preferredModelProvider?: string; }
49
49
 
50
50
  const BUILTIN_OVERRIDE_FIELDS: EditField[] = ["model", "fallbackModels", "thinking", "systemPromptMode", "inheritProjectContext", "inheritSkills", "defaultContext", "disabled", "tools", "skills", "prompt"];
51
51
 
@@ -135,6 +135,7 @@ export class AgentManagerComponent implements Component {
135
135
  private skills: SkillInfo[];
136
136
  private done: (result: ManagerResult) => void;
137
137
  private shortcuts: ListShortcuts;
138
+ private preferredModelProvider: string | undefined;
138
139
 
139
140
  constructor(tui: TUI, theme: Theme, agentData: AgentData, models: ModelInfo[], skills: SkillInfo[], done: (result: ManagerResult) => void, options: AgentManagerOptions = {}) {
140
141
  this.tui = tui;
@@ -144,6 +145,7 @@ export class AgentManagerComponent implements Component {
144
145
  this.skills = skills;
145
146
  this.done = done;
146
147
  this.shortcuts = { newShortcut: options.newShortcut?.trim() || DEFAULT_AGENT_MANAGER_NEW_SHORTCUT };
148
+ this.preferredModelProvider = options.preferredModelProvider;
147
149
  this.loadEntries();
148
150
  }
149
151
 
@@ -196,7 +198,7 @@ export class AgentManagerComponent implements Component {
196
198
 
197
199
  private enterDetail(entry: AgentEntry): void { this.currentAgentId = entry.id; this.detailState = { resolved: true, scrollOffset: 0, recentRuns: loadRunsForAgent(entry.config.name).slice(0, 5) }; this.screen = "detail"; }
198
200
  private enterChainDetail(entry: ChainEntry): void { this.currentChainId = entry.id; this.chainDetailState = { scrollOffset: 0 }; this.screen = "chain-detail"; }
199
- private enterEdit(entry: AgentEntry): void { this.currentAgentId = entry.id; this.builtinOverrideScope = null; this.editState = createEditState(entry.config, entry.isNew, this.models, this.skills); this.screen = "edit"; }
201
+ private enterEdit(entry: AgentEntry): void { this.currentAgentId = entry.id; this.builtinOverrideScope = null; this.editState = createEditState(entry.config, entry.isNew, this.models, this.skills, { preferredProvider: this.preferredModelProvider }); this.screen = "edit"; }
200
202
  private enterBuiltinOverrideScope(entry: AgentEntry): void {
201
203
  this.currentAgentId = entry.id;
202
204
  this.overrideScopeState = { selectedScope: this.agentData.projectSettingsPath ? "project" : "user", allowProject: Boolean(this.agentData.projectSettingsPath) };
@@ -209,6 +211,7 @@ export class AgentManagerComponent implements Component {
209
211
  fields: BUILTIN_OVERRIDE_FIELDS,
210
212
  title: `Builtin Override: ${entry.config.name} [${scope}]`,
211
213
  overrideBase: this.resolveBuiltinOverrideBase(entry),
214
+ preferredProvider: this.preferredModelProvider,
212
215
  });
213
216
  this.screen = "edit";
214
217
  }
@@ -24,6 +24,7 @@ import {
24
24
  type Details,
25
25
  type MaxOutputConfig,
26
26
  type ResolvedControlConfig,
27
+ type SubagentRunMode,
27
28
  ASYNC_DIR,
28
29
  RESULTS_DIR,
29
30
  SUBAGENT_ASYNC_STARTED_EVENT,
@@ -64,7 +65,7 @@ interface AsyncExecutionContext {
64
65
 
65
66
  interface AsyncChainParams {
66
67
  chain: ChainStep[];
67
- resultMode?: "parallel" | "chain";
68
+ resultMode?: Exclude<SubagentRunMode, "single">;
68
69
  agents: AgentConfig[];
69
70
  ctx: AsyncExecutionContext;
70
71
  availableModels?: AvailableModelInfo[];
@@ -160,7 +161,7 @@ function spawnRunner(cfg: object, suffix: string, cwd: string): { pid?: number;
160
161
  return { pid: proc.pid };
161
162
  }
162
163
 
163
- function formatAsyncStartError(mode: "single" | "chain", message: string): AsyncExecutionResult {
164
+ function formatAsyncStartError(mode: SubagentRunMode, message: string): AsyncExecutionResult {
164
165
  return {
165
166
  content: [{ type: "text", text: message }],
166
167
  isError: true,
@@ -198,6 +199,7 @@ export function executeAsyncChain(
198
199
  controlIntercomTarget,
199
200
  childIntercomTarget,
200
201
  } = params;
202
+ const resultMode = params.resultMode ?? "chain";
201
203
  const chainSkills = params.chainSkills ?? [];
202
204
  const availableModels = params.availableModels;
203
205
  const runnerCwd = resolveChildCwd(ctx.cwd, cwd);
@@ -211,7 +213,7 @@ export function executeAsyncChain(
211
213
  return {
212
214
  content: [{ type: "text", text: `Unknown agent: ${agentName}` }],
213
215
  isError: true,
214
- details: { mode: "chain" as const, results: [] },
216
+ details: { mode: resultMode, results: [] },
215
217
  };
216
218
  }
217
219
  }
@@ -225,7 +227,7 @@ export function executeAsyncChain(
225
227
  return {
226
228
  content: [{ type: "text", text: `Failed to create async run directory '${asyncDir}': ${message}` }],
227
229
  isError: true,
228
- details: { mode: "chain" as const, results: [] },
230
+ details: { mode: resultMode, results: [] },
229
231
  };
230
232
  }
231
233
 
@@ -329,7 +331,7 @@ export function executeAsyncChain(
329
331
  return buildSeqStep(s as SequentialStep, nextSessionFile());
330
332
  });
331
333
  } catch (error) {
332
- if (error instanceof UnavailableSubagentSkillError || error instanceof AsyncStartValidationError) return formatAsyncStartError("chain", error.message);
334
+ if (error instanceof UnavailableSubagentSkillError || error instanceof AsyncStartValidationError) return formatAsyncStartError(resultMode, error.message);
333
335
  throw error;
334
336
  }
335
337
  let childTargetIndex = 0;
@@ -363,18 +365,18 @@ export function executeAsyncChain(
363
365
  controlConfig,
364
366
  controlIntercomTarget,
365
367
  childIntercomTargets,
366
- resultMode: params.resultMode ?? "chain",
368
+ resultMode,
367
369
  },
368
370
  id,
369
371
  runnerCwd,
370
372
  );
371
373
  } catch (error) {
372
374
  const message = error instanceof Error ? error.message : String(error);
373
- return formatAsyncStartError("chain", `Failed to start async chain '${id}': ${message}`);
375
+ return formatAsyncStartError(resultMode, `Failed to start async ${resultMode} '${id}': ${message}`);
374
376
  }
375
377
 
376
378
  if (spawnResult.error) {
377
- return formatAsyncStartError("chain", `Failed to start async chain '${id}': ${spawnResult.error}`);
379
+ return formatAsyncStartError(resultMode, `Failed to start async ${resultMode} '${id}': ${spawnResult.error}`);
378
380
  }
379
381
 
380
382
  if (spawnResult.pid) {
@@ -399,6 +401,8 @@ export function executeAsyncChain(
399
401
  ctx.pi.events.emit(SUBAGENT_ASYNC_STARTED_EVENT, {
400
402
  id,
401
403
  pid: spawnResult.pid,
404
+ sessionId: ctx.currentSessionId,
405
+ mode: resultMode,
402
406
  agent: firstAgents[0],
403
407
  agents: flatAgents,
404
408
  task: isParallelStep(firstStep)
@@ -421,8 +425,8 @@ export function executeAsyncChain(
421
425
  .join(" -> ");
422
426
 
423
427
  return {
424
- content: [{ type: "text", text: `Async chain: ${chainDesc} [${id}]` }],
425
- details: { mode: "chain", results: [], asyncId: id, asyncDir },
428
+ content: [{ type: "text", text: `Async ${resultMode}: ${chainDesc} [${id}]` }],
429
+ details: { mode: resultMode, results: [], asyncId: id, asyncDir },
426
430
  };
427
431
  }
428
432
 
@@ -543,6 +547,8 @@ export function executeAsyncSingle(
543
547
  ctx.pi.events.emit(SUBAGENT_ASYNC_STARTED_EVENT, {
544
548
  id,
545
549
  pid: spawnResult.pid,
550
+ sessionId: ctx.currentSessionId,
551
+ mode: "single",
546
552
  agent,
547
553
  task: task?.slice(0, 50),
548
554
  cwd: runnerCwd,
@@ -5,7 +5,6 @@ import { renderWidget } from "../../tui/render.ts";
5
5
  import { formatControlNoticeMessage } from "../shared/subagent-control.ts";
6
6
  import {
7
7
  type AsyncJobState,
8
- type AsyncParallelGroupStatus,
9
8
  type AsyncStartedEvent,
10
9
  type ControlEvent,
11
10
  type SubagentState,
@@ -15,25 +14,9 @@ import {
15
14
  SUBAGENT_CONTROL_INTERCOM_EVENT,
16
15
  } from "../../shared/types.ts";
17
16
  import { readStatus } from "../../shared/utils.ts";
17
+ import { normalizeParallelGroups } from "./parallel-groups.ts";
18
18
  import { reconcileAsyncRun } from "./stale-run-reconciler.ts";
19
19
 
20
-
21
- function isValidParallelGroup(group: AsyncParallelGroupStatus, stepCount: number, chainStepCount: number): boolean {
22
- return Number.isInteger(group.start)
23
- && Number.isInteger(group.count)
24
- && Number.isInteger(group.stepIndex)
25
- && group.start >= 0
26
- && group.count > 0
27
- && group.stepIndex >= 0
28
- && group.stepIndex < chainStepCount
29
- && group.start + group.count <= stepCount;
30
- }
31
-
32
- function normalizeParallelGroups(groups: AsyncParallelGroupStatus[] | undefined, stepCount: number, chainStepCount: number): AsyncParallelGroupStatus[] {
33
- if (!groups?.length) return [];
34
- return groups.filter((group) => isValidParallelGroup(group, stepCount, chainStepCount));
35
- }
36
-
37
20
  interface AsyncJobTrackerOptions {
38
21
  completionRetentionMs?: number;
39
22
  pollIntervalMs?: number;
@@ -145,8 +128,11 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
145
128
  startedRun: {
146
129
  runId: job.asyncId,
147
130
  pid: job.pid,
131
+ sessionId: job.sessionId,
148
132
  mode: job.mode,
149
133
  agents: job.agents,
134
+ chainStepCount: job.chainStepCount,
135
+ parallelGroups: job.parallelGroups,
150
136
  startedAt: job.startedAt,
151
137
  sessionFile: job.sessionFile,
152
138
  },
@@ -155,6 +141,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
155
141
  if (status) {
156
142
  const previousStatus = job.status;
157
143
  job.status = status.state;
144
+ job.sessionId = status.sessionId ?? job.sessionId;
158
145
  job.activityState = status.activityState;
159
146
  job.lastActivityAt = status.lastActivityAt ?? job.lastActivityAt;
160
147
  job.currentTool = status.currentTool;
@@ -164,10 +151,12 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
164
151
  job.toolCount = status.toolCount ?? job.toolCount;
165
152
  job.mode = status.mode;
166
153
  job.currentStep = status.currentStep ?? job.currentStep;
154
+ job.chainStepCount = status.chainStepCount ?? job.chainStepCount;
167
155
  job.startedAt = status.startedAt ?? job.startedAt;
168
156
  job.updatedAt = status.lastUpdate ?? Date.now();
169
157
  if (status.steps?.length) {
170
158
  const groups = normalizeParallelGroups(status.parallelGroups, status.steps.length, status.chainStepCount ?? status.steps.length);
159
+ job.parallelGroups = groups.length ? groups : job.parallelGroups;
171
160
  job.hasParallelGroups = groups.length > 0 || job.hasParallelGroups;
172
161
  const activeGroup = status.currentStep !== undefined
173
162
  ? groups.find((group) => status.currentStep! >= group.start && status.currentStep! < group.start + group.count)
@@ -177,6 +166,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
177
166
  : status.steps;
178
167
  job.activeParallelGroup = Boolean(activeGroup);
179
168
  job.agents = visibleSteps.map((step) => step.agent);
169
+ job.steps = visibleSteps;
180
170
  job.stepsTotal = visibleSteps.length;
181
171
  job.runningSteps = visibleSteps.filter((step) => step.status === "running").length;
182
172
  job.completedSteps = visibleSteps.filter((step) => step.status === "complete" || step.status === "completed").length;
@@ -225,8 +215,11 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
225
215
  asyncDir,
226
216
  status: "queued",
227
217
  pid: typeof info.pid === "number" ? info.pid : undefined,
228
- mode: info.chain ? "chain" : "single",
218
+ ...(typeof info.sessionId === "string" ? { sessionId: info.sessionId } : {}),
219
+ mode: info.mode ?? (info.chain ? "chain" : "single"),
229
220
  agents,
221
+ chainStepCount: info.chainStepCount,
222
+ parallelGroups: validParallelGroups,
230
223
  stepsTotal: firstGroupCount ?? agents?.length,
231
224
  hasParallelGroups: validParallelGroups.length > 0,
232
225
  activeParallelGroup: Boolean(firstGroupCount && firstGroupCount > 0),
@@ -202,6 +202,7 @@ function resultState(result: AsyncResultFile): AsyncStatus["state"] {
202
202
  function validateStatusForResume(status: AsyncStatus | null, source: string): void {
203
203
  if (!status) return;
204
204
  if (typeof status.runId !== "string") throw new Error(`Invalid async status '${source}': runId must be a string.`);
205
+ if (status.sessionId !== undefined && typeof status.sessionId !== "string") throw new Error(`Invalid async status '${source}': sessionId must be a string.`);
205
206
  if (status.cwd !== undefined && typeof status.cwd !== "string") throw new Error(`Invalid async status '${source}': cwd must be a string.`);
206
207
  if (status.sessionFile !== undefined && typeof status.sessionFile !== "string") throw new Error(`Invalid async status '${source}': sessionFile must be a string.`);
207
208
  if (status.steps !== undefined) {
@@ -1,8 +1,9 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { formatDuration, formatTokens, shortenPath } from "../../shared/formatters.ts";
4
- import { type ActivityState, type AsyncParallelGroupStatus, type AsyncStatus, type TokenUsage } from "../../shared/types.ts";
4
+ import { type ActivityState, type AsyncParallelGroupStatus, type AsyncStatus, type SubagentRunMode, type TokenUsage } from "../../shared/types.ts";
5
5
  import { readStatus } from "../../shared/utils.ts";
6
+ import { flatToLogicalStepIndex, normalizeParallelGroups } from "./parallel-groups.ts";
6
7
  import { reconcileAsyncRun } from "./stale-run-reconciler.ts";
7
8
 
8
9
  interface AsyncRunStepSummary {
@@ -27,6 +28,7 @@ interface AsyncRunStepSummary {
27
28
  export interface AsyncRunSummary {
28
29
  id: string;
29
30
  asyncDir: string;
31
+ sessionId?: string;
30
32
  state: "queued" | "running" | "complete" | "failed" | "paused";
31
33
  activityState?: ActivityState;
32
34
  lastActivityAt?: number;
@@ -35,7 +37,7 @@ export interface AsyncRunSummary {
35
37
  currentPath?: string;
36
38
  turnCount?: number;
37
39
  toolCount?: number;
38
- mode: "single" | "chain";
40
+ mode: SubagentRunMode;
39
41
  cwd?: string;
40
42
  startedAt: number;
41
43
  lastUpdate?: number;
@@ -50,45 +52,9 @@ export interface AsyncRunSummary {
50
52
  sessionFile?: string;
51
53
  }
52
54
 
53
- function isValidParallelGroup(group: AsyncParallelGroupStatus, stepCount: number, chainStepCount: number): boolean {
54
- return Number.isInteger(group.start)
55
- && Number.isInteger(group.count)
56
- && Number.isInteger(group.stepIndex)
57
- && group.start >= 0
58
- && group.count > 0
59
- && group.stepIndex >= 0
60
- && group.stepIndex < chainStepCount
61
- && group.start + group.count <= stepCount;
62
- }
63
-
64
- function normalizeParallelGroups(groups: AsyncParallelGroupStatus[] | undefined, stepCount: number, chainStepCount: number): AsyncParallelGroupStatus[] {
65
- if (!groups?.length) return [];
66
- return groups.filter((group) => isValidParallelGroup(group, stepCount, chainStepCount));
67
- }
68
-
69
- function flatToLogicalStepIndex(flatIndex: number, chainStepCount: number, parallelGroups: AsyncParallelGroupStatus[]): number {
70
- let logicalIndex = 0;
71
- let cursor = 0;
72
- for (const group of parallelGroups) {
73
- while (logicalIndex < chainStepCount && cursor < group.start) {
74
- if (flatIndex === cursor) return logicalIndex;
75
- logicalIndex++;
76
- cursor++;
77
- }
78
- if (flatIndex >= group.start && flatIndex < group.start + group.count) return group.stepIndex;
79
- logicalIndex = Math.max(logicalIndex, group.stepIndex + 1);
80
- cursor = group.start + group.count;
81
- }
82
- while (logicalIndex < chainStepCount) {
83
- if (flatIndex === cursor) return logicalIndex;
84
- logicalIndex++;
85
- cursor++;
86
- }
87
- return Math.max(0, chainStepCount - 1);
88
- }
89
-
90
55
  interface AsyncRunListOptions {
91
56
  states?: Array<AsyncRunSummary["state"]>;
57
+ sessionId?: string;
92
58
  limit?: number;
93
59
  resultsDir?: string;
94
60
  kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean;
@@ -101,6 +67,11 @@ export interface AsyncRunOverlayData {
101
67
  recent: AsyncRunSummary[];
102
68
  }
103
69
 
70
+ export interface AsyncRunOverlayOptions {
71
+ recentLimit?: number;
72
+ sessionId?: string;
73
+ }
74
+
104
75
  function getErrorMessage(error: unknown): string {
105
76
  return error instanceof Error ? error.message : String(error);
106
77
  }
@@ -147,6 +118,9 @@ function deriveAsyncActivityState(asyncDir: string, status: AsyncStatus): { acti
147
118
  }
148
119
 
149
120
  function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string }): AsyncRunSummary {
121
+ if (status.sessionId !== undefined && typeof status.sessionId !== "string") {
122
+ throw new Error(`Invalid async status '${path.join(asyncDir, "status.json")}': sessionId must be a string.`);
123
+ }
150
124
  const { activityState, lastActivityAt } = deriveAsyncActivityState(asyncDir, status);
151
125
  const steps = status.steps ?? [];
152
126
  const chainStepCount = status.chainStepCount ?? steps.length;
@@ -154,6 +128,7 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
154
128
  return {
155
129
  id: status.runId || path.basename(asyncDir),
156
130
  asyncDir,
131
+ ...(status.sessionId ? { sessionId: status.sessionId } : {}),
157
132
  state: status.state,
158
133
  activityState,
159
134
  lastActivityAt,
@@ -240,6 +215,7 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
240
215
  if (!status) continue;
241
216
  const summary = statusToSummary(asyncDir, status);
242
217
  if (allowedStates && !allowedStates.has(summary.state)) continue;
218
+ if (options.sessionId && summary.sessionId !== options.sessionId) continue;
243
219
  runs.push(summary);
244
220
  }
245
221
 
@@ -247,8 +223,9 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
247
223
  return options.limit !== undefined ? sorted.slice(0, options.limit) : sorted;
248
224
  }
249
225
 
250
- export function listAsyncRunsForOverlay(asyncDirRoot: string, recentLimit = 5): AsyncRunOverlayData {
251
- const all = listAsyncRuns(asyncDirRoot);
226
+ export function listAsyncRunsForOverlay(asyncDirRoot: string, options: AsyncRunOverlayOptions = {}): AsyncRunOverlayData {
227
+ const recentLimit = options.recentLimit ?? 5;
228
+ const all = listAsyncRuns(asyncDirRoot, { sessionId: options.sessionId });
252
229
  const recent = all
253
230
  .filter((run) => run.state === "complete" || run.state === "failed" || run.state === "paused")
254
231
  .sort((a, b) => (b.lastUpdate ?? b.endedAt ?? b.startedAt) - (a.lastUpdate ?? a.endedAt ?? a.startedAt))
@@ -287,6 +264,18 @@ function formatStepLine(step: AsyncRunStepSummary): string {
287
264
  return parts.join(" | ");
288
265
  }
289
266
 
267
+ function formatParallelProgress(steps: Pick<AsyncRunStepSummary, "status">[], total: number, showRunning: boolean): string {
268
+ const running = steps.filter((step) => step.status === "running").length;
269
+ const done = steps.filter((step) => step.status === "complete" || step.status === "completed").length;
270
+ const failed = steps.filter((step) => step.status === "failed").length;
271
+ const paused = steps.filter((step) => step.status === "paused").length;
272
+ const parts = [`${done}/${total} done`];
273
+ if (showRunning) parts.unshift(running === 1 ? "1 agent running" : `${running} agents running`);
274
+ if (failed > 0) parts.push(`${failed} failed`);
275
+ if (paused > 0) parts.push(`${paused} paused`);
276
+ return parts.join(" · ");
277
+ }
278
+
290
279
  export function formatAsyncRunProgressLabel(run: Pick<AsyncRunSummary, "mode" | "state" | "currentStep" | "chainStepCount" | "parallelGroups" | "steps">): string {
291
280
  const stepCount = run.steps.length || 1;
292
281
  const chainStepCount = run.chainStepCount ?? stepCount;
@@ -294,16 +283,13 @@ export function formatAsyncRunProgressLabel(run: Pick<AsyncRunSummary, "mode" |
294
283
  const activeGroup = run.currentStep !== undefined
295
284
  ? groups.find((group) => run.currentStep! >= group.start && run.currentStep! < group.start + group.count)
296
285
  : undefined;
297
- if (run.mode === "chain" && activeGroup) {
286
+ if (activeGroup) {
298
287
  const groupSteps = run.steps.slice(activeGroup.start, activeGroup.start + activeGroup.count);
299
- const running = groupSteps.filter((step) => step.status === "running").length;
300
- const done = groupSteps.filter((step) => step.status === "complete" || step.status === "completed").length;
301
- const runningLabel = running === 1 ? "1 agent running" : `${running} agents running`;
302
- const groupLabel = run.state === "running"
303
- ? `parallel group: ${runningLabel} · ${done}/${activeGroup.count} done`
304
- : `parallel group: ${done}/${activeGroup.count} done`;
305
- return `step ${activeGroup.stepIndex + 1}/${chainStepCount} · ${groupLabel}`;
288
+ const groupLabel = formatParallelProgress(groupSteps, activeGroup.count, run.state === "running");
289
+ if (run.mode === "parallel") return groupLabel;
290
+ return `step ${activeGroup.stepIndex + 1}/${chainStepCount} · parallel group: ${groupLabel}`;
306
291
  }
292
+ if (run.mode === "parallel") return formatParallelProgress(run.steps, stepCount, run.state === "running");
307
293
  if (run.mode === "chain" && run.currentStep !== undefined && groups.length > 0) {
308
294
  const logicalStep = flatToLogicalStepIndex(run.currentStep, chainStepCount, groups);
309
295
  return `step ${logicalStep + 1}/${chainStepCount}`;
@@ -0,0 +1,45 @@
1
+ import type { AsyncParallelGroupStatus } from "../../shared/types.ts";
2
+
3
+ function isValidParallelGroup(group: unknown, stepCount: number, chainStepCount: number): group is AsyncParallelGroupStatus {
4
+ if (typeof group !== "object" || group === null) return false;
5
+ const { start, count, stepIndex } = group as Partial<AsyncParallelGroupStatus>;
6
+ return typeof start === "number"
7
+ && typeof count === "number"
8
+ && typeof stepIndex === "number"
9
+ && Number.isInteger(start)
10
+ && Number.isInteger(count)
11
+ && Number.isInteger(stepIndex)
12
+ && start >= 0
13
+ && count > 0
14
+ && stepIndex >= 0
15
+ && stepIndex < chainStepCount
16
+ && start + count <= stepCount;
17
+ }
18
+
19
+ export function normalizeParallelGroups(groups: unknown, stepCount: number, chainStepCount: number): AsyncParallelGroupStatus[] {
20
+ if (!Array.isArray(groups)) return [];
21
+ return groups
22
+ .filter((group): group is AsyncParallelGroupStatus => isValidParallelGroup(group, stepCount, chainStepCount))
23
+ .sort((left, right) => left.stepIndex - right.stepIndex || left.start - right.start);
24
+ }
25
+
26
+ export function flatToLogicalStepIndex(flatIndex: number, chainStepCount: number, groups: AsyncParallelGroupStatus[]): number {
27
+ let logicalIndex = 0;
28
+ let cursor = 0;
29
+ for (const group of groups) {
30
+ while (cursor < group.start && logicalIndex < chainStepCount) {
31
+ if (cursor === flatIndex) return logicalIndex;
32
+ cursor++;
33
+ logicalIndex++;
34
+ }
35
+ if (flatIndex >= group.start && flatIndex < group.start + group.count) return group.stepIndex;
36
+ cursor = group.start + group.count;
37
+ logicalIndex = group.stepIndex + 1;
38
+ }
39
+ while (cursor <= flatIndex && logicalIndex < chainStepCount) {
40
+ if (cursor === flatIndex) return logicalIndex;
41
+ cursor++;
42
+ logicalIndex++;
43
+ }
44
+ return Math.max(0, chainStepCount - 1);
45
+ }
@@ -1,10 +1,10 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
3
  import { buildCompletionKey, markSeenWithTtl } from "./completion-dedupe.ts";
5
4
  import { createFileCoalescer } from "../../shared/file-coalescer.ts";
6
5
  import {
7
6
  SUBAGENT_ASYNC_COMPLETE_EVENT,
7
+ type IntercomEventBus,
8
8
  type SubagentState,
9
9
  } from "../../shared/types.ts";
10
10
  import {
@@ -46,7 +46,7 @@ function shouldFallBackToPolling(error: unknown): boolean {
46
46
  }
47
47
 
48
48
  export function createResultWatcher(
49
- pi: ExtensionAPI,
49
+ pi: { events: IntercomEventBus },
50
50
  state: SubagentState,
51
51
  resultsDir: string,
52
52
  completionTtlMs: number,