pi-subagents 0.17.5 → 0.18.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.18.0] - 2026-04-23
6
+
7
+ ### Added
8
+ - Added subagent control notifications so `needs_attention` signals push structured parent events, persist async control events to `events.jsonl`, show visible transcript notices for the user and parent agent, include proactive `nudge`/`status`/`interrupt` commands when a child appears blocked, and show each visible notice at most once per child run and attention state.
9
+ - Added stable child intercom session names for controlled subagents so needs-attention pings can tell the orchestrator which agent needs attention and how to message it when intercom is available.
10
+
11
+ ### Changed
12
+ - Replaced the unreleased `starting`/`active`/`quiet`/`stalled`/`paused` activity labels with factual activity reporting and a single `needs_attention` control signal, keeping `paused` as lifecycle state only.
13
+ - Added `subagent({ action: "status", id })` and `subagent({ action: "status" })` as the control-surface status checks, replacing the separate `subagent_status(...)` tool.
14
+ - Adjusted bundled agent defaults: most builtins now use `openai-codex/gpt-5.5`, while `scout` uses `openai-codex/gpt-5.4-mini`.
15
+ - Removed the incomplete e2e suite and stale `@marcfargas/pi-test-harness` dev dependency; `test:all` now runs the maintained unit and integration suites.
16
+
17
+ ### Fixed
18
+ - Paused async runs now render `Background task paused` notifications instead of failed/completed copy, including after extension reloads with stale legacy listeners still present.
19
+ - Async status output no longer shows stale activity-age lines for paused or completed runs.
20
+
5
21
  ## [0.17.5] - 2026-04-23
6
22
 
7
23
  ### Added
package/README.md CHANGED
@@ -48,8 +48,8 @@ Use `agentScope` parameter to control discovery: `"user"`, `"project"`, or `"bot
48
48
 
49
49
  **Builtin agents:** The extension ships with ready-to-use agents — `scout`, `planner`, `worker`, `reviewer`, `context-builder`, `researcher`, `delegate`, `oracle`, and `oracle-executor`. They load at lowest priority so any user or project agent with the same name overrides them.
50
50
 
51
- - `oracle` is a high-context advisory reviewer on `openai-codex/gpt-5.4:high`. It critiques direction, surfaces hidden risks, and proposes a concrete execution prompt, but it does not edit files directly.
52
- - `oracle-executor` is a high-context implementation escalator on `openai-codex/gpt-5.3-codex:high`. It is intended to run only after the main agent explicitly approves a course of action.
51
+ - `oracle` is a high-context advisory reviewer on `openai-codex/gpt-5.5`. It critiques direction, surfaces hidden risks, and proposes a concrete execution prompt, but it does not edit files directly.
52
+ - `oracle-executor` is a high-context implementation escalator on `openai-codex/gpt-5.5`. It is intended to run only after the main agent explicitly approves a course of action.
53
53
 
54
54
  You can also override selected builtin fields without copying the whole agent. Builtin overrides are stored in settings under `subagents.agentOverrides`:
55
55
 
@@ -285,7 +285,7 @@ Add `--bg` at the end of any slash command to run in the background:
285
285
  /parallel scout "scan frontend" -> scout "scan backend" -> scout "scan infra" --bg
286
286
  ```
287
287
 
288
- Without `--bg`, the run is foreground: the tool call stays active and streams progress until completion. With `--bg`, the run is launched asynchronously: control returns immediately, and completion arrives later via notification. In both cases subagents run as separate processes. Check status with the `subagent_status` tool, or open the `/subagents-status` slash command for a read-only overlay listing active runs and recent completions.
288
+ Without `--bg`, the run is foreground: the tool call stays active and streams progress until completion. With `--bg`, the run is launched asynchronously: control returns immediately, and completion arrives later via notification. In both cases subagents run as separate processes. Check status with `subagent({ action: "status", id })`, or open the `/subagents-status` slash command for a read-only overlay listing active runs and recent completions.
289
289
 
290
290
  ### Forked Context Execution
291
291
 
@@ -649,16 +649,16 @@ These are the parameters the **LLM agent** passes when it calls the `subagent` t
649
649
  ], clarify: false, async: true }
650
650
  ```
651
651
 
652
- **subagent_status tool:**
652
+ **Programmatic status:**
653
653
  ```typescript
654
- { action: "list" } // active async runs only
655
- { id: "a53ebe46" } // inspect one run
656
- { dir: "<tmpdir>/pi-subagents-<scope>/async-subagent-runs/a53ebe46-..." }
654
+ subagent({ action: "status" }) // active async runs only
655
+ subagent({ action: "status", id: "a53ebe46" }) // inspect one run
656
+ subagent({ action: "status", dir: "<tmpdir>/pi-subagents-<scope>/async-subagent-runs/a53ebe46-..." })
657
657
  ```
658
658
 
659
659
  **/subagents-status slash command:**
660
660
 
661
- Opens a small read-only overlay that shows active async runs plus recent completed/failed runs. It auto-refreshes every 2 seconds while open, keeps the current run selected when possible, and uses `↑↓` to select a run plus `Esc` to close.
661
+ Opens a small read-only overlay that shows active async runs plus recent completed, failed, and paused runs. It auto-refreshes every 2 seconds while open, keeps the current run selected when possible, and uses `↑↓` to select a run plus `Esc` to close.
662
662
 
663
663
  ## Management Actions
664
664
 
@@ -798,11 +798,12 @@ Fallbacks are inherited from the selected agent for that step. There is no per-s
798
798
 
799
799
  Fallbacks are inherited from the selected agent for that task. There is no per-task `fallbackModels` override in v1.
800
800
 
801
- Status tool:
801
+ Status commands:
802
802
 
803
- | Tool | Description |
803
+ | Command | Description |
804
804
  |------|-------------|
805
- | `subagent_status` | List active async runs or inspect one run by id or dir |
805
+ | `subagent({ action: "status" })` | List active async runs |
806
+ | `subagent({ action: "status", id })` | Inspect a foreground or async run by id or prefix |
806
807
 
807
808
  ## Worktree Isolation
808
809
 
@@ -1167,20 +1168,20 @@ When fallback is used in async/background mode, `status.json` and the final resu
1167
1168
  For programmatic access:
1168
1169
 
1169
1170
  ```typescript
1170
- subagent_status({ action: "list" })
1171
- subagent_status({ id: "<id>" })
1172
- subagent_status({ dir: "<tmpdir>/pi-subagents-<scope>/async-subagent-runs/<id>" })
1171
+ subagent({ action: "status" })
1172
+ subagent({ action: "status", id: "<id>" })
1173
+ subagent({ action: "status", dir: "<tmpdir>/pi-subagents-<scope>/async-subagent-runs/<id>" })
1173
1174
  ```
1174
1175
 
1175
- For an interactive overview, run the `/subagents-status` slash command to open the overlay listing active runs and recent completed/failed runs. The overlay auto-refreshes every 2 seconds while it is open and focuses on summary/status information, including the current output/session paths when available.
1176
+ For an interactive overview, run the `/subagents-status` slash command to open the overlay listing active runs and recent completed, failed, and paused runs. The overlay auto-refreshes every 2 seconds while it is open and focuses on summary/status information, including the current output/session paths when available.
1176
1177
 
1177
1178
  ## Events
1178
1179
 
1179
1180
  Async events:
1180
- - `subagent:started`
1181
- - `subagent:complete`
1181
+ - `subagent:async-started`
1182
+ - `subagent:async-complete`
1182
1183
 
1183
- `notify.ts` consumes `subagent:complete` as the canonical completion channel.
1184
+ The result watcher emits `subagent:async-complete`; `index.ts` registers the notification handler that consumes it.
1184
1185
 
1185
1186
  ## Files
1186
1187
 
@@ -1227,7 +1228,6 @@ Async events:
1227
1228
  ├── run-history.ts # Per-agent run recording (JSONL)
1228
1229
  ├── test/unit/ # Fast unit tests for pure modules
1229
1230
  ├── test/integration/ # Loader-based execution/integration tests
1230
- ├── test/e2e/ # End-to-end sandbox tests
1231
1231
  ├── test/support/ # Shared test loader, helpers, and mock pi harness
1232
1232
  └── text-editor.ts # Shared text editor (word nav, paste)
1233
1233
  ```
@@ -2,7 +2,7 @@
2
2
  name: oracle-executor
3
3
  description: High-context implementation agent that executes only after main-agent approval
4
4
  tools: read, grep, find, ls, bash, edit, write, intercom
5
- model: openai-codex/gpt-5.5:xhigh
5
+ model: openai-codex/gpt-5.5
6
6
  thinking: high
7
7
  systemPromptMode: replace
8
8
  inheritProjectContext: true
package/agents/scout.md CHANGED
@@ -2,7 +2,7 @@
2
2
  name: scout
3
3
  description: Fast codebase recon that returns compressed context for handoff
4
4
  tools: read, grep, find, ls, bash, write
5
- model: openai-codex/gpt-5.5-mini
5
+ model: openai-codex/gpt-5.4-mini
6
6
  systemPromptMode: replace
7
7
  inheritProjectContext: true
8
8
  inheritSkills: false
@@ -25,6 +25,7 @@ import {
25
25
  type ResolvedControlConfig,
26
26
  ASYNC_DIR,
27
27
  RESULTS_DIR,
28
+ SUBAGENT_ASYNC_STARTED_EVENT,
28
29
  TEMP_ROOT_DIR,
29
30
  getAsyncConfigPath,
30
31
  resolveChildMaxSubagentDepth,
@@ -77,6 +78,8 @@ export interface AsyncChainParams {
77
78
  worktreeSetupHook?: string;
78
79
  worktreeSetupHookTimeoutMs?: number;
79
80
  controlConfig?: ResolvedControlConfig;
81
+ controlIntercomTarget?: string;
82
+ childIntercomTarget?: (agent: string, index: number) => string | undefined;
80
83
  }
81
84
 
82
85
  export interface AsyncSingleParams {
@@ -99,6 +102,8 @@ export interface AsyncSingleParams {
99
102
  worktreeSetupHook?: string;
100
103
  worktreeSetupHookTimeoutMs?: number;
101
104
  controlConfig?: ResolvedControlConfig;
105
+ controlIntercomTarget?: string;
106
+ childIntercomTarget?: (agent: string, index: number) => string | undefined;
102
107
  }
103
108
 
104
109
  export interface AsyncExecutionResult {
@@ -182,6 +187,8 @@ export function executeAsyncChain(
182
187
  worktreeSetupHook,
183
188
  worktreeSetupHookTimeoutMs,
184
189
  controlConfig,
190
+ controlIntercomTarget,
191
+ childIntercomTarget,
185
192
  } = params;
186
193
  const chainSkills = params.chainSkills ?? [];
187
194
  const availableModels = params.availableModels;
@@ -280,6 +287,13 @@ export function executeAsyncChain(
280
287
  }
281
288
  return buildSeqStep(s as SequentialStep, nextSessionFile());
282
289
  });
290
+ let childTargetIndex = 0;
291
+ const childIntercomTargets = childIntercomTarget ? steps.flatMap((step) => {
292
+ if ("parallel" in step) {
293
+ return step.parallel.map((task) => childIntercomTarget(task.agent, childTargetIndex++));
294
+ }
295
+ return [childIntercomTarget(step.agent, childTargetIndex++)];
296
+ }) : undefined;
283
297
 
284
298
  let spawnResult: { pid?: number; error?: string } = {};
285
299
  try {
@@ -302,6 +316,8 @@ export function executeAsyncChain(
302
316
  worktreeSetupHook,
303
317
  worktreeSetupHookTimeoutMs,
304
318
  controlConfig,
319
+ controlIntercomTarget,
320
+ childIntercomTargets,
305
321
  },
306
322
  id,
307
323
  runnerCwd,
@@ -320,7 +336,7 @@ export function executeAsyncChain(
320
336
  const firstAgents = isParallelStep(firstStep)
321
337
  ? firstStep.parallel.map((t) => t.agent)
322
338
  : [(firstStep as SequentialStep).agent];
323
- ctx.pi.events.emit("subagent:started", {
339
+ ctx.pi.events.emit(SUBAGENT_ASYNC_STARTED_EVENT, {
324
340
  id,
325
341
  pid: spawnResult.pid,
326
342
  agent: firstAgents[0],
@@ -370,6 +386,8 @@ export function executeAsyncSingle(
370
386
  worktreeSetupHook,
371
387
  worktreeSetupHookTimeoutMs,
372
388
  controlConfig,
389
+ controlIntercomTarget,
390
+ childIntercomTarget,
373
391
  } = params;
374
392
  const runnerCwd = resolveChildCwd(ctx.cwd, cwd);
375
393
  const skillNames = params.skills ?? agentConfig.skills ?? [];
@@ -437,6 +455,8 @@ export function executeAsyncSingle(
437
455
  worktreeSetupHook,
438
456
  worktreeSetupHookTimeoutMs,
439
457
  controlConfig,
458
+ controlIntercomTarget,
459
+ childIntercomTargets: childIntercomTarget ? [childIntercomTarget(agent, 0)] : undefined,
440
460
  },
441
461
  id,
442
462
  runnerCwd,
@@ -451,7 +471,7 @@ export function executeAsyncSingle(
451
471
  }
452
472
 
453
473
  if (spawnResult.pid) {
454
- ctx.pi.events.emit("subagent:started", {
474
+ ctx.pi.events.emit(SUBAGENT_ASYNC_STARTED_EVENT, {
455
475
  id,
456
476
  pid: spawnResult.pid,
457
477
  agent,
@@ -1,9 +1,15 @@
1
- import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
4
  import { renderWidget } from "./render.ts";
5
+ import { formatControlNoticeMessage } from "./subagent-control.ts";
4
6
  import {
7
+ type AsyncJobState,
8
+ type ControlEvent,
5
9
  type SubagentState,
6
10
  POLL_INTERVAL_MS,
11
+ SUBAGENT_CONTROL_EVENT,
12
+ SUBAGENT_CONTROL_INTERCOM_EVENT,
7
13
  } from "./types.ts";
8
14
  import { readStatus } from "./utils.ts";
9
15
 
@@ -12,7 +18,7 @@ interface AsyncJobTrackerOptions {
12
18
  pollIntervalMs?: number;
13
19
  }
14
20
 
15
- export function createAsyncJobTracker(state: SubagentState, asyncDirRoot: string, options: AsyncJobTrackerOptions = {}): {
21
+ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: SubagentState, asyncDirRoot: string, options: AsyncJobTrackerOptions = {}): {
16
22
  ensurePoller: () => void;
17
23
  handleStarted: (data: unknown) => void;
18
24
  handleComplete: (data: unknown) => void;
@@ -36,13 +42,67 @@ export function createAsyncJobTracker(state: SubagentState, asyncDirRoot: string
36
42
  }, completionRetentionMs);
37
43
  state.cleanupTimers.set(asyncId, timer);
38
44
  };
45
+ const emitNewControlEvents = (job: AsyncJobState) => {
46
+ const eventsPath = path.join(job.asyncDir, "events.jsonl");
47
+ let fd: number;
48
+ try {
49
+ fd = fs.openSync(eventsPath, "r");
50
+ } catch (error) {
51
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
52
+ console.error(`Failed to open async control events for '${job.asyncDir}':`, error);
53
+ return;
54
+ }
55
+ try {
56
+ const stat = fs.fstatSync(fd);
57
+ const cursor = stat.size < (job.controlEventCursor ?? 0) ? 0 : (job.controlEventCursor ?? 0);
58
+ if (stat.size <= cursor) return;
59
+ const buffer = Buffer.alloc(stat.size - cursor);
60
+ fs.readSync(fd, buffer, 0, buffer.length, cursor);
61
+ const lastNewline = buffer.lastIndexOf(0x0a);
62
+ if (lastNewline === -1) return;
63
+ job.controlEventCursor = cursor + lastNewline + 1;
64
+ for (const line of buffer.subarray(0, lastNewline).toString("utf-8").split("\n")) {
65
+ if (!line.trim()) continue;
66
+ let parsed: unknown;
67
+ try {
68
+ parsed = JSON.parse(line);
69
+ } catch {
70
+ // Ignore malformed completed records but keep the poller alive for later events.
71
+ continue;
72
+ }
73
+ if (!parsed || typeof parsed !== "object" || (parsed as { type?: unknown }).type !== "subagent.control") continue;
74
+ const record = parsed as { event?: ControlEvent; channels?: string[]; childIntercomTarget?: string; noticeText?: string; intercom?: { to?: string; message?: string } };
75
+ if (!record.event || !Array.isArray(record.channels)) continue;
76
+ const payload = {
77
+ event: record.event,
78
+ source: "async" as const,
79
+ asyncDir: job.asyncDir,
80
+ childIntercomTarget: record.childIntercomTarget,
81
+ noticeText: record.noticeText ?? formatControlNoticeMessage(record.event, record.childIntercomTarget),
82
+ };
83
+ if (record.channels.includes("event")) {
84
+ pi.events.emit(SUBAGENT_CONTROL_EVENT, payload);
85
+ }
86
+ if (record.channels.includes("intercom") && record.intercom?.to && record.intercom.message) {
87
+ pi.events.emit(SUBAGENT_CONTROL_INTERCOM_EVENT, {
88
+ ...payload,
89
+ to: record.intercom.to,
90
+ message: record.intercom.message,
91
+ });
92
+ }
93
+ }
94
+ } catch (error) {
95
+ console.error(`Failed to read async control events for '${job.asyncDir}':`, error);
96
+ } finally {
97
+ fs.closeSync(fd);
98
+ }
99
+ };
39
100
 
40
101
  const ensurePoller = () => {
41
102
  if (state.poller) return;
42
103
  state.poller = setInterval(() => {
43
- if (!state.lastUiContext || !state.lastUiContext.hasUI) return;
44
104
  if (state.asyncJobs.size === 0) {
45
- rerenderWidget(state.lastUiContext, []);
105
+ if (state.lastUiContext?.hasUI) rerenderWidget(state.lastUiContext, []);
46
106
  if (state.poller) {
47
107
  clearInterval(state.poller);
48
108
  state.poller = null;
@@ -52,11 +112,15 @@ export function createAsyncJobTracker(state: SubagentState, asyncDirRoot: string
52
112
 
53
113
  for (const job of state.asyncJobs.values()) {
54
114
  try {
115
+ emitNewControlEvents(job);
55
116
  const status = readStatus(job.asyncDir);
56
117
  if (status) {
57
118
  const previousStatus = job.status;
58
119
  job.status = status.state;
59
120
  job.activityState = status.activityState;
121
+ job.lastActivityAt = status.lastActivityAt ?? job.lastActivityAt;
122
+ job.currentTool = status.currentTool ?? job.currentTool;
123
+ job.currentToolStartedAt = status.currentToolStartedAt ?? job.currentToolStartedAt;
60
124
  job.mode = status.mode;
61
125
  job.currentStep = status.currentStep ?? job.currentStep;
62
126
  job.stepsTotal = status.steps?.length ?? job.stepsTotal;
@@ -83,7 +147,7 @@ export function createAsyncJobTracker(state: SubagentState, asyncDirRoot: string
83
147
  }
84
148
  }
85
149
 
86
- rerenderWidget(state.lastUiContext);
150
+ if (state.lastUiContext?.hasUI) rerenderWidget(state.lastUiContext);
87
151
  }, pollIntervalMs);
88
152
  state.poller.unref?.();
89
153
  };
@@ -103,16 +167,15 @@ export function createAsyncJobTracker(state: SubagentState, asyncDirRoot: string
103
167
  asyncId: info.id,
104
168
  asyncDir,
105
169
  status: "queued",
106
- activityState: "starting",
107
170
  mode: info.chain ? "chain" : "single",
108
171
  agents,
109
172
  stepsTotal: agents?.length,
110
173
  startedAt: now,
111
174
  updatedAt: now,
112
175
  });
176
+ ensurePoller();
113
177
  if (state.lastUiContext) {
114
178
  rerenderWidget(state.lastUiContext);
115
- ensurePoller();
116
179
  }
117
180
  };
118
181
 
package/async-status.ts CHANGED
@@ -10,6 +10,9 @@ export interface AsyncRunStepSummary {
10
10
  agent: string;
11
11
  status: string;
12
12
  activityState?: ActivityState;
13
+ lastActivityAt?: number;
14
+ currentTool?: string;
15
+ currentToolStartedAt?: number;
13
16
  durationMs?: number;
14
17
  tokens?: TokenUsage;
15
18
  skills?: string[];
@@ -23,6 +26,9 @@ export interface AsyncRunSummary {
23
26
  asyncDir: string;
24
27
  state: "queued" | "running" | "complete" | "failed" | "paused";
25
28
  activityState?: ActivityState;
29
+ lastActivityAt?: number;
30
+ currentTool?: string;
31
+ currentToolStartedAt?: number;
26
32
  mode: "single" | "chain";
27
33
  cwd?: string;
28
34
  startedAt: number;
@@ -78,27 +84,31 @@ function outputFileMtime(outputFile: string | undefined): number | undefined {
78
84
  }
79
85
  }
80
86
 
81
- function deriveAsyncActivityState(asyncDir: string, status: AsyncStatus): ActivityState | undefined {
82
- if (status.state === "paused") return "paused";
83
- if (status.state !== "running") return status.activityState;
87
+ function deriveAsyncActivityState(asyncDir: string, status: AsyncStatus): { activityState?: ActivityState; lastActivityAt?: number } {
88
+ if (status.state !== "running") return { activityState: status.activityState, lastActivityAt: status.lastActivityAt };
84
89
  const outputPath = status.outputFile ? (path.isAbsolute(status.outputFile) ? status.outputFile : path.join(asyncDir, status.outputFile)) : undefined;
85
- const lastActivityAt = outputFileMtime(outputPath) ?? status.lastUpdate;
86
- return deriveActivityState({
87
- config: DEFAULT_CONTROL_CONFIG,
88
- startedAt: status.startedAt,
90
+ const currentStep = typeof status.currentStep === "number" ? status.steps?.[status.currentStep] : undefined;
91
+ const lastActivityAt = status.lastActivityAt ?? outputFileMtime(outputPath) ?? currentStep?.lastActivityAt ?? currentStep?.startedAt ?? status.startedAt;
92
+ return {
89
93
  lastActivityAt,
90
- hasSeenActivity: Boolean(lastActivityAt),
91
- paused: false,
92
- });
94
+ activityState: status.activityState ?? deriveActivityState({
95
+ config: DEFAULT_CONTROL_CONFIG,
96
+ startedAt: status.startedAt,
97
+ lastActivityAt,
98
+ }),
99
+ };
93
100
  }
94
101
 
95
102
  function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string }): AsyncRunSummary {
96
- const activityState = deriveAsyncActivityState(asyncDir, status);
103
+ const { activityState, lastActivityAt } = deriveAsyncActivityState(asyncDir, status);
97
104
  return {
98
105
  id: status.runId || path.basename(asyncDir),
99
106
  asyncDir,
100
107
  state: status.state,
101
108
  activityState,
109
+ lastActivityAt,
110
+ currentTool: status.currentTool,
111
+ currentToolStartedAt: status.currentToolStartedAt,
102
112
  mode: status.mode,
103
113
  cwd: status.cwd,
104
114
  startedAt: status.startedAt,
@@ -107,11 +117,15 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
107
117
  currentStep: status.currentStep,
108
118
  steps: (status.steps ?? []).map((step, index) => {
109
119
  const stepActivityState = step.activityState ?? (step.status === "running" ? activityState : undefined);
120
+ const stepLastActivityAt = step.lastActivityAt ?? (step.status === "running" ? lastActivityAt : undefined);
110
121
  return {
111
122
  index,
112
123
  agent: step.agent,
113
124
  status: step.status,
114
125
  ...(stepActivityState ? { activityState: stepActivityState } : {}),
126
+ ...(stepLastActivityAt ? { lastActivityAt: stepLastActivityAt } : {}),
127
+ ...(step.currentTool ? { currentTool: step.currentTool } : {}),
128
+ ...(step.currentToolStartedAt ? { currentToolStartedAt: step.currentToolStartedAt } : {}),
115
129
  ...(step.durationMs !== undefined ? { durationMs: step.durationMs } : {}),
116
130
  ...(step.tokens ? { tokens: step.tokens } : {}),
117
131
  ...(step.skills ? { skills: step.skills } : {}),
@@ -184,9 +198,17 @@ export function listAsyncRunsForOverlay(asyncDirRoot: string, recentLimit = 5):
184
198
  };
185
199
  }
186
200
 
201
+ function formatActivityFacts(input: { activityState?: ActivityState; lastActivityAt?: number; currentTool?: string; currentToolStartedAt?: number }): string | undefined {
202
+ if (input.currentTool && input.currentToolStartedAt) return `tool ${input.currentTool} ${formatDuration(Math.max(0, Date.now() - input.currentToolStartedAt))}`;
203
+ if (!input.lastActivityAt) return input.activityState === "needs_attention" ? "needs attention" : undefined;
204
+ const elapsed = formatDuration(Math.max(0, Date.now() - input.lastActivityAt));
205
+ return input.activityState === "needs_attention" ? `no activity for ${elapsed}` : `active ${elapsed} ago`;
206
+ }
207
+
187
208
  function formatStepLine(step: AsyncRunStepSummary): string {
188
- const state = step.activityState ? `${step.status}/${step.activityState}` : step.status;
189
- const parts = [`${step.index + 1}. ${step.agent}`, state];
209
+ const parts = [`${step.index + 1}. ${step.agent}`, step.status];
210
+ const activity = formatActivityFacts(step);
211
+ if (activity) parts.push(activity);
190
212
  if (step.model) parts.push(step.model);
191
213
  if (step.durationMs !== undefined) parts.push(formatDuration(step.durationMs));
192
214
  if (step.tokens) parts.push(`${formatTokens(step.tokens.total)} tok`);
@@ -197,8 +219,8 @@ function formatRunHeader(run: AsyncRunSummary): string {
197
219
  const stepCount = run.steps.length || 1;
198
220
  const stepLabel = run.currentStep !== undefined ? `step ${run.currentStep + 1}/${stepCount}` : `steps ${stepCount}`;
199
221
  const cwd = run.cwd ? shortenPath(run.cwd) : shortenPath(run.asyncDir);
200
- const state = run.activityState ? `${run.state}/${run.activityState}` : run.state;
201
- return `${run.id} | ${state} | ${run.mode} | ${stepLabel} | ${cwd}`;
222
+ const activity = formatActivityFacts(run);
223
+ return `${run.id} | ${run.state}${activity ? ` | ${activity}` : ""} | ${run.mode} | ${stepLabel} | ${cwd}`;
202
224
  }
203
225
 
204
226
  export function formatAsyncRunList(runs: AsyncRunSummary[], heading = "Active async runs"): string {
@@ -44,6 +44,7 @@ import {
44
44
  type AgentProgress,
45
45
  type ArtifactConfig,
46
46
  type ArtifactPaths,
47
+ type ControlEvent,
47
48
  type Details,
48
49
  type ResolvedControlConfig,
49
50
  type SingleResult,
@@ -84,12 +85,17 @@ interface ParallelChainRunInput {
84
85
  artifactsDir: string;
85
86
  signal?: AbortSignal;
86
87
  onUpdate?: (r: AgentToolResult<Details>) => void;
88
+ onControlEvent?: (event: ControlEvent) => void;
87
89
  controlConfig: ResolvedControlConfig;
90
+ childIntercomTarget?: (agent: string, index: number) => string | undefined;
88
91
  foregroundControl?: {
89
92
  updatedAt: number;
90
93
  currentAgent?: string;
91
94
  currentIndex?: number;
92
95
  currentActivityState?: ActivityState;
96
+ lastActivityAt?: number;
97
+ currentTool?: string;
98
+ currentToolStartedAt?: number;
93
99
  interrupt?: () => boolean;
94
100
  };
95
101
  results: SingleResult[];
@@ -200,12 +206,12 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
200
206
  if (input.foregroundControl) {
201
207
  input.foregroundControl.currentAgent = task.agent;
202
208
  input.foregroundControl.currentIndex = input.globalTaskIndex + taskIndex;
203
- input.foregroundControl.currentActivityState = "starting";
209
+ input.foregroundControl.currentActivityState = undefined;
204
210
  input.foregroundControl.updatedAt = Date.now();
205
211
  input.foregroundControl.interrupt = () => {
206
212
  if (interruptController.signal.aborted) return false;
207
213
  interruptController.abort();
208
- input.foregroundControl!.currentActivityState = "paused";
214
+ input.foregroundControl!.currentActivityState = undefined;
209
215
  input.foregroundControl!.updatedAt = Date.now();
210
216
  return true;
211
217
  };
@@ -225,6 +231,8 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
225
231
  outputPath,
226
232
  maxSubagentDepth,
227
233
  controlConfig: input.controlConfig,
234
+ onControlEvent: input.onControlEvent,
235
+ intercomSessionName: input.childIntercomTarget?.(task.agent, input.globalTaskIndex + taskIndex),
228
236
  modelOverride: effectiveModel,
229
237
  availableModels: input.availableModels,
230
238
  preferredModelProvider: input.ctx.model?.provider,
@@ -238,6 +246,9 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
238
246
  input.foregroundControl.currentAgent = task.agent;
239
247
  input.foregroundControl.currentIndex = input.globalTaskIndex + taskIndex;
240
248
  input.foregroundControl.currentActivityState = current?.activityState;
249
+ input.foregroundControl.lastActivityAt = current?.lastActivityAt;
250
+ input.foregroundControl.currentTool = current?.currentTool;
251
+ input.foregroundControl.currentToolStartedAt = current?.currentToolStartedAt;
241
252
  input.foregroundControl.updatedAt = Date.now();
242
253
  }
243
254
  input.onUpdate?.({
@@ -287,12 +298,17 @@ export interface ChainExecutionParams {
287
298
  includeProgress?: boolean;
288
299
  clarify?: boolean;
289
300
  onUpdate?: (r: AgentToolResult<Details>) => void;
301
+ onControlEvent?: (event: ControlEvent) => void;
290
302
  controlConfig: ResolvedControlConfig;
303
+ childIntercomTarget?: (agent: string, index: number) => string | undefined;
291
304
  foregroundControl?: {
292
305
  updatedAt: number;
293
306
  currentAgent?: string;
294
307
  currentIndex?: number;
295
308
  currentActivityState?: ActivityState;
309
+ lastActivityAt?: number;
310
+ currentTool?: string;
311
+ currentToolStartedAt?: number;
296
312
  interrupt?: () => boolean;
297
313
  };
298
314
  chainSkills?: string[];
@@ -332,7 +348,9 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
332
348
  includeProgress,
333
349
  clarify,
334
350
  onUpdate,
351
+ onControlEvent,
335
352
  controlConfig,
353
+ childIntercomTarget,
336
354
  foregroundControl,
337
355
  chainSkills: chainSkillsParam,
338
356
  chainDir: chainDirBase,
@@ -533,6 +551,8 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
533
551
  chainAgents,
534
552
  totalSteps,
535
553
  controlConfig,
554
+ onControlEvent,
555
+ childIntercomTarget,
536
556
  foregroundControl,
537
557
  worktreeSetup,
538
558
  maxSubagentDepth: params.maxSubagentDepth,
@@ -674,12 +694,12 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
674
694
  if (foregroundControl) {
675
695
  foregroundControl.currentAgent = seqStep.agent;
676
696
  foregroundControl.currentIndex = globalTaskIndex;
677
- foregroundControl.currentActivityState = "starting";
697
+ foregroundControl.currentActivityState = undefined;
678
698
  foregroundControl.updatedAt = Date.now();
679
699
  foregroundControl.interrupt = () => {
680
700
  if (interruptController.signal.aborted) return false;
681
701
  interruptController.abort();
682
- foregroundControl.currentActivityState = "paused";
702
+ foregroundControl.currentActivityState = undefined;
683
703
  foregroundControl.updatedAt = Date.now();
684
704
  return true;
685
705
  };
@@ -699,6 +719,8 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
699
719
  outputPath,
700
720
  maxSubagentDepth,
701
721
  controlConfig,
722
+ onControlEvent,
723
+ intercomSessionName: childIntercomTarget?.(seqStep.agent, globalTaskIndex),
702
724
  modelOverride: effectiveModel,
703
725
  availableModels,
704
726
  preferredModelProvider: ctx.model?.provider,
@@ -712,6 +734,9 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
712
734
  foregroundControl.currentAgent = seqStep.agent;
713
735
  foregroundControl.currentIndex = globalTaskIndex;
714
736
  foregroundControl.currentActivityState = current?.activityState;
737
+ foregroundControl.lastActivityAt = current?.lastActivityAt;
738
+ foregroundControl.currentTool = current?.currentTool;
739
+ foregroundControl.currentToolStartedAt = current?.currentToolStartedAt;
715
740
  foregroundControl.updatedAt = Date.now();
716
741
  }
717
742
  onUpdate({