pi-subagents 0.17.4 → 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.
@@ -8,15 +8,26 @@ import { appendJsonl, getArtifactPaths } from "./artifacts.ts";
8
8
  import { getPiSpawnCommand } from "./pi-spawn.ts";
9
9
  import { captureSingleOutputSnapshot, resolveSingleOutput } from "./single-output.ts";
10
10
  import {
11
+ type ActivityState,
11
12
  type ArtifactConfig,
12
13
  type ArtifactPaths,
13
14
  type ModelAttempt,
15
+ type ResolvedControlConfig,
14
16
  type Usage,
15
17
  DEFAULT_MAX_OUTPUT,
16
18
  type MaxOutputConfig,
17
19
  truncateOutput,
18
20
  getSubagentDepthEnv,
19
21
  } from "./types.ts";
22
+ import {
23
+ DEFAULT_CONTROL_CONFIG,
24
+ buildControlEvent,
25
+ deriveActivityState,
26
+ claimControlNotification,
27
+ formatControlIntercomMessage,
28
+ formatControlNoticeMessage,
29
+ shouldEmitControlEvent,
30
+ } from "./subagent-control.ts";
20
31
  import {
21
32
  type RunnerSubagentStep as SubagentStep,
22
33
  type RunnerStep,
@@ -60,6 +71,9 @@ interface SubagentRunConfig {
60
71
  piArgv1?: string;
61
72
  worktreeSetupHook?: string;
62
73
  worktreeSetupHookTimeoutMs?: number;
74
+ controlConfig?: ResolvedControlConfig;
75
+ controlIntercomTarget?: string;
76
+ childIntercomTargets?: Array<string | undefined>;
63
77
  }
64
78
 
65
79
  interface StepResult {
@@ -75,6 +89,7 @@ interface StepResult {
75
89
  }
76
90
 
77
91
  const require = createRequire(import.meta.url);
92
+ const ASYNC_INTERRUPT_SIGNAL: NodeJS.Signals = process.platform === "win32" ? "SIGBREAK" : "SIGUSR2";
78
93
 
79
94
  function findLatestSessionFile(sessionDir: string): string | null {
80
95
  try {
@@ -95,6 +110,18 @@ function emptyUsage(): Usage {
95
110
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
96
111
  }
97
112
 
113
+ function tokenUsageFromAttempts(attempts: ModelAttempt[] | undefined): TokenUsage | null {
114
+ if (!attempts || attempts.length === 0) return null;
115
+ let input = 0;
116
+ let output = 0;
117
+ for (const attempt of attempts) {
118
+ input += attempt.usage?.input ?? 0;
119
+ output += attempt.usage?.output ?? 0;
120
+ }
121
+ const total = input + output;
122
+ return total > 0 ? { input, output, total } : null;
123
+ }
124
+
98
125
  interface ChildEventContext {
99
126
  eventsPath: string;
100
127
  runId: string;
@@ -133,6 +160,7 @@ interface RunPiStreamingResult {
133
160
  model?: string;
134
161
  error?: string;
135
162
  finalOutput: string;
163
+ interrupted?: boolean;
136
164
  }
137
165
 
138
166
  function runPiStreaming(
@@ -144,6 +172,7 @@ function runPiStreaming(
144
172
  piArgv1?: string,
145
173
  maxSubagentDepth?: number,
146
174
  childEventContext?: ChildEventContext,
175
+ registerInterrupt?: (interrupt: (() => void) | undefined) => void,
147
176
  ): Promise<RunPiStreamingResult> {
148
177
  return new Promise((resolve) => {
149
178
  const outputStream = fs.createWriteStream(outputFile, { flags: "w" });
@@ -160,6 +189,7 @@ function runPiStreaming(
160
189
  const usage = emptyUsage();
161
190
  let model: string | undefined;
162
191
  let error: string | undefined;
192
+ let interrupted = false;
163
193
  const rawStdoutLines: string[] = [];
164
194
 
165
195
  const writeOutputLine = (line: string) => {
@@ -267,6 +297,15 @@ function runPiStreaming(
267
297
  child.stderr.on("data", (chunk: Buffer) => {
268
298
  processStderrText(chunk.toString());
269
299
  });
300
+ registerInterrupt?.(() => {
301
+ if (settled) return;
302
+ interrupted = true;
303
+ if (!error) error = "Interrupted. Waiting for explicit next action.";
304
+ trySignalChild(child, "SIGINT");
305
+ setTimeout(() => {
306
+ if (!settled) trySignalChild(child, "SIGTERM");
307
+ }, 1000).unref?.();
308
+ });
270
309
  const clearDrainTimers = () => {
271
310
  if (finalDrainTimer) {
272
311
  clearTimeout(finalDrainTimer);
@@ -301,6 +340,7 @@ function runPiStreaming(
301
340
  });
302
341
  child.on("close", (exitCode, signal) => {
303
342
  settled = true;
343
+ registerInterrupt?.(undefined);
304
344
  clearDrainTimers();
305
345
  clearStdioGuard();
306
346
  if (stdoutBuf.trim()) processStdoutLine(stdoutBuf);
@@ -309,17 +349,19 @@ function runPiStreaming(
309
349
  const finalOutput = getFinalOutput(messages) || rawStdoutLines.join("\n").trim();
310
350
  resolve({
311
351
  stderr,
312
- exitCode: forcedTerminationSignal || signal ? (exitCode ?? 1) : exitCode,
352
+ exitCode: interrupted ? 0 : forcedTerminationSignal || signal ? (exitCode ?? 1) : exitCode,
313
353
  messages,
314
354
  usage,
315
355
  model,
316
- error,
356
+ error: interrupted ? undefined : error,
317
357
  finalOutput,
358
+ interrupted,
318
359
  });
319
360
  });
320
361
 
321
362
  child.on("error", (spawnError) => {
322
363
  settled = true;
364
+ registerInterrupt?.(undefined);
323
365
  clearDrainTimers();
324
366
  clearStdioGuard();
325
367
  outputStream.end();
@@ -481,6 +523,8 @@ interface SingleStepContext {
481
523
  outputFile: string;
482
524
  piPackageRoot?: string;
483
525
  piArgv1?: string;
526
+ registerInterrupt?: (interrupt: (() => void) | undefined) => void;
527
+ childIntercomTarget?: string;
484
528
  }
485
529
 
486
530
  /** Run a single pi agent step, returning output and metadata */
@@ -496,6 +540,7 @@ async function runSingleStep(
496
540
  attemptedModels?: string[];
497
541
  modelAttempts?: ModelAttempt[];
498
542
  artifactPaths?: ArtifactPaths;
543
+ interrupted?: boolean;
499
544
  }> {
500
545
  const placeholderRegex = new RegExp(ctx.placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g");
501
546
  const task = step.task.replace(placeholderRegex, () => ctx.previousOutput);
@@ -541,6 +586,7 @@ async function runSingleStep(
541
586
  systemPromptMode: step.systemPromptMode,
542
587
  mcpDirectTools: step.mcpDirectTools,
543
588
  promptFileStem: step.agent,
589
+ intercomSessionName: ctx.childIntercomTarget,
544
590
  });
545
591
  const run = await runPiStreaming(
546
592
  args,
@@ -551,6 +597,7 @@ async function runSingleStep(
551
597
  ctx.piArgv1,
552
598
  step.maxSubagentDepth,
553
599
  { eventsPath, runId: ctx.id, stepIndex: ctx.flatIndex, agent: step.agent },
600
+ ctx.registerInterrupt,
554
601
  );
555
602
  cleanupTempDir(tempDir);
556
603
 
@@ -627,13 +674,18 @@ async function runSingleStep(
627
674
  attemptedModels: attemptedModels.length > 0 ? attemptedModels : undefined,
628
675
  modelAttempts,
629
676
  artifactPaths,
677
+ interrupted: finalResult?.interrupted,
630
678
  };
631
679
  }
632
680
 
633
681
  type RunnerStatusPayload = {
634
682
  runId: string;
635
683
  mode: "single" | "chain";
636
- state: "queued" | "running" | "complete" | "failed";
684
+ state: "queued" | "running" | "complete" | "failed" | "paused";
685
+ activityState?: ActivityState;
686
+ lastActivityAt?: number;
687
+ currentTool?: string;
688
+ currentToolStartedAt?: number;
637
689
  startedAt: number;
638
690
  endedAt?: number;
639
691
  lastUpdate: number;
@@ -643,6 +695,10 @@ type RunnerStatusPayload = {
643
695
  steps: Array<{
644
696
  agent: string;
645
697
  status: "pending" | "running" | "complete" | "failed";
698
+ activityState?: ActivityState;
699
+ lastActivityAt?: number;
700
+ currentTool?: string;
701
+ currentToolStartedAt?: number;
646
702
  startedAt?: number;
647
703
  endedAt?: number;
648
704
  durationMs?: number;
@@ -715,8 +771,11 @@ function markParallelGroupRunning(input: {
715
771
  const flatTaskIndex = input.groupStartFlatIndex + taskIndex;
716
772
  input.statusPayload.steps[flatTaskIndex].status = "running";
717
773
  input.statusPayload.steps[flatTaskIndex].startedAt = input.groupStartTime;
774
+ input.statusPayload.steps[flatTaskIndex].lastActivityAt = input.groupStartTime;
718
775
  }
719
776
  input.statusPayload.currentStep = input.groupStartFlatIndex;
777
+ input.statusPayload.activityState = undefined;
778
+ input.statusPayload.lastActivityAt = input.groupStartTime;
720
779
  input.statusPayload.lastUpdate = input.groupStartTime;
721
780
  input.statusPayload.outputFile = path.join(input.asyncDir, `output-${input.groupStartFlatIndex}.log`);
722
781
  writeJson(input.statusPath, input.statusPayload);
@@ -769,6 +828,11 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
769
828
  const statusPath = path.join(asyncDir, "status.json");
770
829
  const eventsPath = path.join(asyncDir, "events.jsonl");
771
830
  const logPath = path.join(asyncDir, `subagent-log-${id}.md`);
831
+ const controlConfig = config.controlConfig ?? DEFAULT_CONTROL_CONFIG;
832
+ let activeChildInterrupt: (() => void) | undefined;
833
+ let interrupted = false;
834
+ let currentActivityState: ActivityState | undefined;
835
+ let activityTimer: NodeJS.Timeout | undefined;
772
836
  let previousCumulativeTokens: TokenUsage = { input: 0, output: 0, total: 0 };
773
837
  let latestSessionFile: string | undefined;
774
838
 
@@ -780,6 +844,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
780
844
  runId: id,
781
845
  mode: flatSteps.length > 1 ? "chain" : "single",
782
846
  state: "running",
847
+ lastActivityAt: overallStartTime,
783
848
  startedAt: overallStartTime,
784
849
  lastUpdate: overallStartTime,
785
850
  pid: process.pid,
@@ -799,6 +864,107 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
799
864
 
800
865
  fs.mkdirSync(asyncDir, { recursive: true });
801
866
  writeJson(statusPath, statusPayload);
867
+
868
+ const currentStepAgent = () => statusPayload.steps[statusPayload.currentStep]?.agent ?? flatSteps[statusPayload.currentStep]?.agent ?? "subagent";
869
+ const currentOutputActivityAt = (): number => {
870
+ const runningIndexes = statusPayload.steps
871
+ .map((step, index) => step.status === "running" ? index : -1)
872
+ .filter((index) => index >= 0);
873
+ let lastActivityAt = statusPayload.steps[statusPayload.currentStep]?.startedAt ?? overallStartTime;
874
+ for (const index of runningIndexes.length > 0 ? runningIndexes : [statusPayload.currentStep]) {
875
+ try {
876
+ lastActivityAt = Math.max(lastActivityAt, fs.statSync(path.join(asyncDir, `output-${index}.log`)).mtimeMs);
877
+ } catch {
878
+ // Missing output files are normal before a child writes its first line.
879
+ }
880
+ }
881
+ return lastActivityAt;
882
+ };
883
+ const emittedControlEventKeys = new Set<string>();
884
+ const appendControlEvent = (event: ReturnType<typeof buildControlEvent>) => {
885
+ const childIntercomTarget = config.childIntercomTargets?.[statusPayload.currentStep];
886
+ if (controlConfig.notifyChannels.length === 0 || !claimControlNotification(controlConfig, event, emittedControlEventKeys, childIntercomTarget)) return;
887
+ appendJsonl(eventsPath, JSON.stringify({
888
+ type: "subagent.control",
889
+ event,
890
+ channels: controlConfig.notifyChannels,
891
+ childIntercomTarget,
892
+ noticeText: formatControlNoticeMessage(event, childIntercomTarget),
893
+ ...(config.controlIntercomTarget && controlConfig.notifyChannels.includes("intercom") ? {
894
+ intercom: {
895
+ to: config.controlIntercomTarget,
896
+ message: formatControlIntercomMessage(event, childIntercomTarget),
897
+ },
898
+ } : {}),
899
+ }));
900
+ };
901
+ const updateRunnerActivityState = (now: number): boolean => {
902
+ const lastActivityAt = currentOutputActivityAt();
903
+ const next = deriveActivityState({
904
+ config: controlConfig,
905
+ startedAt: overallStartTime,
906
+ lastActivityAt,
907
+ now,
908
+ });
909
+ if (next === currentActivityState && statusPayload.lastActivityAt === lastActivityAt) return false;
910
+ const previous = currentActivityState;
911
+ currentActivityState = next;
912
+ statusPayload.activityState = next;
913
+ statusPayload.lastActivityAt = lastActivityAt;
914
+ for (const step of statusPayload.steps) {
915
+ if (step.status === "running") {
916
+ step.activityState = next;
917
+ step.lastActivityAt = lastActivityAt;
918
+ }
919
+ }
920
+ statusPayload.lastUpdate = now;
921
+ if (shouldEmitControlEvent(controlConfig, previous, next)) {
922
+ const event = buildControlEvent({
923
+ from: previous,
924
+ to: next,
925
+ runId: id,
926
+ agent: currentStepAgent(),
927
+ index: statusPayload.currentStep,
928
+ ts: now,
929
+ lastActivityAt,
930
+ });
931
+ appendControlEvent(event);
932
+ }
933
+ writeJson(statusPath, statusPayload);
934
+ return true;
935
+ };
936
+ if (controlConfig.enabled) {
937
+ activityTimer = setInterval(() => {
938
+ if (statusPayload.state !== "running") return;
939
+ const now = Date.now();
940
+ updateRunnerActivityState(now);
941
+ }, 1000);
942
+ activityTimer.unref?.();
943
+ }
944
+
945
+ const interruptRunner = () => {
946
+ if (interrupted || statusPayload.state !== "running") return;
947
+ interrupted = true;
948
+ const now = Date.now();
949
+ statusPayload.state = "paused";
950
+ currentActivityState = undefined;
951
+ statusPayload.activityState = undefined;
952
+ statusPayload.lastUpdate = now;
953
+ const current = statusPayload.steps[statusPayload.currentStep];
954
+ if (current?.status === "running") {
955
+ current.activityState = undefined;
956
+ current.endedAt = now;
957
+ current.durationMs = current.startedAt ? now - current.startedAt : undefined;
958
+ }
959
+ writeJson(statusPath, statusPayload);
960
+ appendJsonl(eventsPath, JSON.stringify({
961
+ type: "subagent.run.paused",
962
+ ts: now,
963
+ runId: id,
964
+ }));
965
+ activeChildInterrupt?.();
966
+ };
967
+ process.on(ASYNC_INTERRUPT_SIGNAL, interruptRunner);
802
968
  appendJsonl(
803
969
  eventsPath,
804
970
  JSON.stringify({
@@ -814,6 +980,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
814
980
  let flatIndex = 0;
815
981
 
816
982
  for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) {
983
+ if (interrupted) break;
817
984
  const step = steps[stepIndex];
818
985
 
819
986
  if (isParallelGroup(step)) {
@@ -912,6 +1079,10 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
912
1079
  outputFile: path.join(asyncDir, `output-${fi}.log`),
913
1080
  piPackageRoot: config.piPackageRoot,
914
1081
  piArgv1: config.piArgv1,
1082
+ childIntercomTarget: config.childIntercomTargets?.[fi],
1083
+ registerInterrupt: (interrupt) => {
1084
+ activeChildInterrupt = interrupt;
1085
+ },
915
1086
  });
916
1087
  if (task.sessionFile) {
917
1088
  latestSessionFile = task.sessionFile;
@@ -944,24 +1115,23 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
944
1115
 
945
1116
  flatIndex += group.parallel.length;
946
1117
 
947
- if (config.sessionDir) {
948
- for (let t = 0; t < group.parallel.length; t++) {
949
- const taskSessionDir = path.join(config.sessionDir, `parallel-${t}`);
950
- const taskTokens = parseSessionTokens(taskSessionDir);
951
- if (taskTokens) {
952
- const fi = groupStartFlatIndex + t;
953
- statusPayload.steps[fi].tokens = taskTokens;
954
- previousCumulativeTokens = {
955
- input: previousCumulativeTokens.input + taskTokens.input,
956
- output: previousCumulativeTokens.output + taskTokens.output,
957
- total: previousCumulativeTokens.total + taskTokens.total,
958
- };
959
- }
960
- }
961
- statusPayload.totalTokens = { ...previousCumulativeTokens };
962
- statusPayload.lastUpdate = Date.now();
963
- writeJson(statusPath, statusPayload);
1118
+ for (let t = 0; t < group.parallel.length; t++) {
1119
+ const fi = groupStartFlatIndex + t;
1120
+ const sessionTokens = config.sessionDir
1121
+ ? parseSessionTokens(path.join(config.sessionDir, `parallel-${t}`))
1122
+ : null;
1123
+ const taskTokens = sessionTokens ?? tokenUsageFromAttempts(parallelResults[t]?.modelAttempts);
1124
+ if (!taskTokens) continue;
1125
+ statusPayload.steps[fi].tokens = taskTokens;
1126
+ previousCumulativeTokens = {
1127
+ input: previousCumulativeTokens.input + taskTokens.input,
1128
+ output: previousCumulativeTokens.output + taskTokens.output,
1129
+ total: previousCumulativeTokens.total + taskTokens.total,
1130
+ };
964
1131
  }
1132
+ statusPayload.totalTokens = { ...previousCumulativeTokens };
1133
+ statusPayload.lastUpdate = Date.now();
1134
+ writeJson(statusPath, statusPayload);
965
1135
 
966
1136
  for (const pr of parallelResults) {
967
1137
  results.push({
@@ -1007,8 +1177,12 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1007
1177
  const stepStartTime = Date.now();
1008
1178
  statusPayload.currentStep = flatIndex;
1009
1179
  statusPayload.steps[flatIndex].status = "running";
1180
+ statusPayload.steps[flatIndex].activityState = undefined;
1181
+ statusPayload.activityState = undefined;
1010
1182
  statusPayload.steps[flatIndex].skills = seqStep.skills;
1011
1183
  statusPayload.steps[flatIndex].startedAt = stepStartTime;
1184
+ statusPayload.steps[flatIndex].lastActivityAt = stepStartTime;
1185
+ statusPayload.lastActivityAt = stepStartTime;
1012
1186
  statusPayload.lastUpdate = stepStartTime;
1013
1187
  statusPayload.outputFile = path.join(asyncDir, `output-${flatIndex}.log`);
1014
1188
  writeJson(statusPath, statusPayload);
@@ -1029,6 +1203,10 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1029
1203
  outputFile: path.join(asyncDir, `output-${flatIndex}.log`),
1030
1204
  piPackageRoot: config.piPackageRoot,
1031
1205
  piArgv1: config.piArgv1,
1206
+ childIntercomTarget: config.childIntercomTargets?.[flatIndex],
1207
+ registerInterrupt: (interrupt) => {
1208
+ activeChildInterrupt = interrupt;
1209
+ },
1032
1210
  });
1033
1211
  if (seqStep.sessionFile) {
1034
1212
  latestSessionFile = seqStep.sessionFile;
@@ -1046,7 +1224,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1046
1224
  });
1047
1225
 
1048
1226
  const cumulativeTokens = config.sessionDir ? parseSessionTokens(config.sessionDir) : null;
1049
- const stepTokens: TokenUsage | null = cumulativeTokens
1227
+ let stepTokens: TokenUsage | null = cumulativeTokens
1050
1228
  ? {
1051
1229
  input: cumulativeTokens.input - previousCumulativeTokens.input,
1052
1230
  output: cumulativeTokens.output - previousCumulativeTokens.output,
@@ -1055,6 +1233,15 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1055
1233
  : null;
1056
1234
  if (cumulativeTokens) {
1057
1235
  previousCumulativeTokens = cumulativeTokens;
1236
+ } else {
1237
+ stepTokens = tokenUsageFromAttempts(singleResult.modelAttempts);
1238
+ if (stepTokens) {
1239
+ previousCumulativeTokens = {
1240
+ input: previousCumulativeTokens.input + stepTokens.input,
1241
+ output: previousCumulativeTokens.output + stepTokens.output,
1242
+ total: previousCumulativeTokens.total + stepTokens.total,
1243
+ };
1244
+ }
1058
1245
  }
1059
1246
 
1060
1247
  const stepEndTime = Date.now();
@@ -1137,9 +1324,14 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1137
1324
  }
1138
1325
  }
1139
1326
 
1327
+ if (activityTimer) {
1328
+ clearInterval(activityTimer);
1329
+ activityTimer = undefined;
1330
+ }
1140
1331
  const effectiveSessionFile = sessionFile ?? latestSessionFile;
1141
1332
  const runEndedAt = Date.now();
1142
- statusPayload.state = results.every((r) => r.success) ? "complete" : "failed";
1333
+ statusPayload.state = interrupted ? "paused" : results.every((r) => r.success) ? "complete" : "failed";
1334
+ statusPayload.activityState = undefined;
1143
1335
  statusPayload.endedAt = runEndedAt;
1144
1336
  statusPayload.lastUpdate = runEndedAt;
1145
1337
  statusPayload.sessionFile = effectiveSessionFile;
@@ -1186,8 +1378,9 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1186
1378
  writeJson(resultPath, {
1187
1379
  id,
1188
1380
  agent: agentName,
1189
- success: results.every((r) => r.success),
1190
- summary,
1381
+ success: !interrupted && results.every((r) => r.success),
1382
+ state: interrupted ? "paused" : results.every((r) => r.success) ? "complete" : "failed",
1383
+ summary: interrupted ? "Paused after interrupt. Waiting for explicit next action." : summary,
1191
1384
  results: results.map((r) => ({
1192
1385
  agent: r.agent,
1193
1386
  output: r.output,
@@ -1199,7 +1392,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1199
1392
  artifactPaths: r.artifactPaths,
1200
1393
  truncated: r.truncated,
1201
1394
  })),
1202
- exitCode: results.every((r) => r.success) ? 0 : 1,
1395
+ exitCode: interrupted || results.every((r) => r.success) ? 0 : 1,
1203
1396
  timestamp: runEndedAt,
1204
1397
  durationMs: runEndedAt - overallStartTime,
1205
1398
  truncated,
@@ -25,6 +25,7 @@ function statusColor(theme: Theme, status: AsyncRunSummary["state"]): string {
25
25
  case "queued": return theme.fg("accent", status);
26
26
  case "complete": return theme.fg("success", status);
27
27
  case "failed": return theme.fg("error", status);
28
+ case "paused": return theme.fg("warning", status);
28
29
  }
29
30
  }
30
31
 
@@ -33,6 +34,7 @@ function stepStatusColor(theme: Theme, status: string): string {
33
34
  if (status === "pending") return theme.fg("dim", status);
34
35
  if (status === "complete" || status === "completed") return theme.fg("success", status);
35
36
  if (status === "failed") return theme.fg("error", status);
37
+ if (status === "paused") return theme.fg("warning", status);
36
38
  return status;
37
39
  }
38
40
 
@@ -170,7 +172,12 @@ export class SubagentsStatusComponent implements Component {
170
172
  : "";
171
173
  const duration = step.durationMs !== undefined ? ` | ${formatDuration(step.durationMs)}` : "";
172
174
  const tokens = step.tokens ? ` | ${formatTokens(step.tokens.total)} tok` : "";
173
- const line = ` ${step.index + 1}. ${step.agent} | ${stepStatusColor(this.theme, step.status)}${model}${attempts}${duration}${tokens}`;
175
+ const activity = step.lastActivityAt
176
+ ? step.activityState === "needs_attention"
177
+ ? ` | no activity for ${formatDuration(Math.max(0, Date.now() - step.lastActivityAt))}`
178
+ : ` | active ${formatDuration(Math.max(0, Date.now() - step.lastActivityAt))} ago`
179
+ : "";
180
+ const line = ` ${step.index + 1}. ${step.agent} | ${stepStatusColor(this.theme, step.status)}${activity}${model}${attempts}${duration}${tokens}`;
174
181
  lines.push(row(truncateToWidth(line, innerW), width, this.theme));
175
182
  if (step.error) {
176
183
  lines.push(row(truncateToWidth(` ${step.error}`, innerW), width, this.theme));
package/types.ts CHANGED
@@ -40,6 +40,35 @@ export interface TokenUsage {
40
40
  total: number;
41
41
  }
42
42
 
43
+ export type ActivityState = "needs_attention";
44
+ export type ControlEventType = "needs_attention";
45
+ export type ControlNotificationChannel = "event" | "async" | "intercom";
46
+
47
+ export interface ControlConfig {
48
+ enabled?: boolean;
49
+ needsAttentionAfterMs?: number;
50
+ notifyOn?: ControlEventType[];
51
+ notifyChannels?: ControlNotificationChannel[];
52
+ }
53
+
54
+ export interface ResolvedControlConfig {
55
+ enabled: boolean;
56
+ needsAttentionAfterMs: number;
57
+ notifyOn: ControlEventType[];
58
+ notifyChannels: ControlNotificationChannel[];
59
+ }
60
+
61
+ export interface ControlEvent {
62
+ type: ControlEventType;
63
+ from?: ActivityState;
64
+ to: ActivityState;
65
+ ts: number;
66
+ agent: string;
67
+ index?: number;
68
+ runId: string;
69
+ message: string;
70
+ }
71
+
43
72
  // ============================================================================
44
73
  // Progress Tracking
45
74
  // ============================================================================
@@ -48,6 +77,7 @@ export interface AgentProgress {
48
77
  index: number;
49
78
  agent: string;
50
79
  status: "pending" | "running" | "completed" | "failed" | "detached";
80
+ activityState?: ActivityState;
51
81
  task: string;
52
82
  skills?: string[];
53
83
  lastActivityAt?: number;
@@ -92,11 +122,13 @@ export interface SingleResult {
92
122
  exitCode: number;
93
123
  detached?: boolean;
94
124
  detachedReason?: string;
125
+ interrupted?: boolean;
95
126
  messages?: Message[];
96
127
  usage: Usage;
97
128
  model?: string;
98
129
  attemptedModels?: string[];
99
130
  modelAttempts?: ModelAttempt[];
131
+ controlEvents?: ControlEvent[];
100
132
  error?: string;
101
133
  sessionFile?: string;
102
134
  skills?: string[];
@@ -115,6 +147,7 @@ export interface Details {
115
147
  mode: "single" | "parallel" | "chain" | "management";
116
148
  context?: "fresh" | "fork";
117
149
  results: SingleResult[];
150
+ controlEvents?: ControlEvent[];
118
151
  asyncId?: string;
119
152
  asyncDir?: string;
120
153
  progress?: AgentProgress[];
@@ -162,15 +195,26 @@ export interface ArtifactConfig {
162
195
  export interface AsyncStatus {
163
196
  runId: string;
164
197
  mode: "single" | "chain";
165
- state: "queued" | "running" | "complete" | "failed";
198
+ state: "queued" | "running" | "complete" | "failed" | "paused";
199
+ activityState?: ActivityState;
200
+ lastActivityAt?: number;
201
+ currentTool?: string;
202
+ currentToolStartedAt?: number;
166
203
  startedAt: number;
167
204
  endedAt?: number;
168
205
  lastUpdate?: number;
206
+ pid?: number;
169
207
  cwd?: string;
170
208
  currentStep?: number;
171
209
  steps?: Array<{
172
210
  agent: string;
173
211
  status: string;
212
+ activityState?: ActivityState;
213
+ lastActivityAt?: number;
214
+ currentTool?: string;
215
+ currentToolStartedAt?: number;
216
+ startedAt?: number;
217
+ endedAt?: number;
174
218
  durationMs?: number;
175
219
  tokens?: TokenUsage;
176
220
  skills?: string[];
@@ -188,7 +232,11 @@ export interface AsyncStatus {
188
232
  export interface AsyncJobState {
189
233
  asyncId: string;
190
234
  asyncDir: string;
191
- status: "queued" | "running" | "complete" | "failed";
235
+ status: "queued" | "running" | "complete" | "failed" | "paused";
236
+ activityState?: ActivityState;
237
+ lastActivityAt?: number;
238
+ currentTool?: string;
239
+ currentToolStartedAt?: number;
192
240
  mode?: "single" | "chain";
193
241
  agents?: string[];
194
242
  currentStep?: number;
@@ -199,12 +247,27 @@ export interface AsyncJobState {
199
247
  outputFile?: string;
200
248
  totalTokens?: TokenUsage;
201
249
  sessionFile?: string;
250
+ controlEventCursor?: number;
202
251
  }
203
252
 
204
253
  export interface SubagentState {
205
254
  baseCwd: string;
206
255
  currentSessionId: string | null;
207
256
  asyncJobs: Map<string, AsyncJobState>;
257
+ foregroundControls: Map<string, {
258
+ runId: string;
259
+ mode: "single" | "parallel" | "chain";
260
+ startedAt: number;
261
+ updatedAt: number;
262
+ currentAgent?: string;
263
+ currentIndex?: number;
264
+ currentActivityState?: ActivityState;
265
+ lastActivityAt?: number;
266
+ currentTool?: string;
267
+ currentToolStartedAt?: number;
268
+ interrupt?: () => boolean;
269
+ }>;
270
+ lastForegroundControlId: string | null;
208
271
  cleanupTimers: Map<string, ReturnType<typeof setTimeout>>;
209
272
  lastUiContext: ExtensionContext | null;
210
273
  poller: NodeJS.Timeout | null;
@@ -243,6 +306,10 @@ export interface IntercomEventBus {
243
306
 
244
307
  export const INTERCOM_DETACH_REQUEST_EVENT = "pi-intercom:detach-request";
245
308
  export const INTERCOM_DETACH_RESPONSE_EVENT = "pi-intercom:detach-response";
309
+ export const SUBAGENT_ASYNC_STARTED_EVENT = "subagent:async-started";
310
+ export const SUBAGENT_ASYNC_COMPLETE_EVENT = "subagent:async-complete";
311
+ export const SUBAGENT_CONTROL_EVENT = "subagent:control-event";
312
+ export const SUBAGENT_CONTROL_INTERCOM_EVENT = "subagent:control-intercom";
246
313
 
247
314
  // ============================================================================
248
315
  // Execution Options
@@ -251,9 +318,13 @@ export const INTERCOM_DETACH_RESPONSE_EVENT = "pi-intercom:detach-response";
251
318
  export interface RunSyncOptions {
252
319
  cwd?: string;
253
320
  signal?: AbortSignal;
321
+ interruptSignal?: AbortSignal;
254
322
  allowIntercomDetach?: boolean;
255
323
  intercomEvents?: IntercomEventBus;
256
324
  onUpdate?: (r: import("@mariozechner/pi-agent-core").AgentToolResult<Details>) => void;
325
+ onControlEvent?: (event: ControlEvent) => void;
326
+ controlConfig?: ResolvedControlConfig;
327
+ intercomSessionName?: string;
257
328
  maxOutput?: MaxOutputConfig;
258
329
  artifactsDir?: string;
259
330
  artifactConfig?: ArtifactConfig;
@@ -291,6 +362,7 @@ export interface ExtensionConfig {
291
362
  forceTopLevelAsync?: boolean;
292
363
  defaultSessionDir?: string;
293
364
  maxSubagentDepth?: number;
365
+ control?: ControlConfig;
294
366
  parallel?: TopLevelParallelConfig;
295
367
  worktreeSetupHook?: string;
296
368
  worktreeSetupHookTimeoutMs?: number;
package/utils.ts CHANGED
@@ -231,6 +231,7 @@ function compactCompletedProgress(progress: AgentProgress): AgentProgress {
231
231
  index: progress.index,
232
232
  agent: progress.agent,
233
233
  status: progress.status,
234
+ activityState: progress.activityState,
234
235
  task: progress.task,
235
236
  skills: progress.skills,
236
237
  toolCount: progress.toolCount,