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.
@@ -40,10 +40,13 @@ import {
40
40
  type WorktreeSetup,
41
41
  } from "./worktree.ts";
42
42
  import {
43
+ type ActivityState,
43
44
  type AgentProgress,
44
45
  type ArtifactConfig,
45
46
  type ArtifactPaths,
47
+ type ControlEvent,
46
48
  type Details,
49
+ type ResolvedControlConfig,
47
50
  type SingleResult,
48
51
  MAX_CONCURRENCY,
49
52
  resolveChildMaxSubagentDepth,
@@ -82,6 +85,19 @@ interface ParallelChainRunInput {
82
85
  artifactsDir: string;
83
86
  signal?: AbortSignal;
84
87
  onUpdate?: (r: AgentToolResult<Details>) => void;
88
+ onControlEvent?: (event: ControlEvent) => void;
89
+ controlConfig: ResolvedControlConfig;
90
+ childIntercomTarget?: (agent: string, index: number) => string | undefined;
91
+ foregroundControl?: {
92
+ updatedAt: number;
93
+ currentAgent?: string;
94
+ currentIndex?: number;
95
+ currentActivityState?: ActivityState;
96
+ lastActivityAt?: number;
97
+ currentTool?: string;
98
+ currentToolStartedAt?: number;
99
+ interrupt?: () => boolean;
100
+ };
85
101
  results: SingleResult[];
86
102
  allProgress: AgentProgress[];
87
103
  chainAgents: string[];
@@ -186,10 +202,25 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
186
202
  const outputPath = typeof behavior.output === "string"
187
203
  ? (path.isAbsolute(behavior.output) ? behavior.output : path.join(input.chainDir, behavior.output))
188
204
  : undefined;
205
+ const interruptController = new AbortController();
206
+ if (input.foregroundControl) {
207
+ input.foregroundControl.currentAgent = task.agent;
208
+ input.foregroundControl.currentIndex = input.globalTaskIndex + taskIndex;
209
+ input.foregroundControl.currentActivityState = undefined;
210
+ input.foregroundControl.updatedAt = Date.now();
211
+ input.foregroundControl.interrupt = () => {
212
+ if (interruptController.signal.aborted) return false;
213
+ interruptController.abort();
214
+ input.foregroundControl!.currentActivityState = undefined;
215
+ input.foregroundControl!.updatedAt = Date.now();
216
+ return true;
217
+ };
218
+ }
189
219
 
190
220
  const result = await runSync(input.ctx.cwd, input.agents, task.agent, taskStr, {
191
221
  cwd: taskCwd,
192
222
  signal: input.signal,
223
+ interruptSignal: interruptController.signal,
193
224
  runId: input.runId,
194
225
  index: input.globalTaskIndex + taskIndex,
195
226
  sessionDir: input.sessionDirForIndex(input.globalTaskIndex + taskIndex),
@@ -199,28 +230,46 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
199
230
  artifactConfig: input.artifactConfig,
200
231
  outputPath,
201
232
  maxSubagentDepth,
233
+ controlConfig: input.controlConfig,
234
+ onControlEvent: input.onControlEvent,
235
+ intercomSessionName: input.childIntercomTarget?.(task.agent, input.globalTaskIndex + taskIndex),
202
236
  modelOverride: effectiveModel,
203
237
  availableModels: input.availableModels,
204
238
  preferredModelProvider: input.ctx.model?.provider,
205
239
  skills: behavior.skills === false ? [] : behavior.skills,
206
240
  onUpdate: input.onUpdate
207
241
  ? (progressUpdate) => {
208
- const stepResults = progressUpdate.details?.results || [];
209
- const stepProgress = progressUpdate.details?.progress || [];
210
- input.onUpdate?.({
211
- ...progressUpdate,
212
- details: {
213
- mode: "chain",
214
- results: input.results.concat(stepResults),
215
- progress: input.allProgress.concat(stepProgress),
216
- chainAgents: input.chainAgents,
217
- totalSteps: input.totalSteps,
218
- currentStepIndex: input.stepIndex,
219
- },
220
- });
242
+ const stepResults = progressUpdate.details?.results || [];
243
+ const stepProgress = progressUpdate.details?.progress || [];
244
+ if (input.foregroundControl && stepProgress.length > 0) {
245
+ const current = stepProgress[0];
246
+ input.foregroundControl.currentAgent = task.agent;
247
+ input.foregroundControl.currentIndex = input.globalTaskIndex + taskIndex;
248
+ input.foregroundControl.currentActivityState = current?.activityState;
249
+ input.foregroundControl.lastActivityAt = current?.lastActivityAt;
250
+ input.foregroundControl.currentTool = current?.currentTool;
251
+ input.foregroundControl.currentToolStartedAt = current?.currentToolStartedAt;
252
+ input.foregroundControl.updatedAt = Date.now();
221
253
  }
254
+ input.onUpdate?.({
255
+ ...progressUpdate,
256
+ details: {
257
+ mode: "chain",
258
+ results: input.results.concat(stepResults),
259
+ progress: input.allProgress.concat(stepProgress),
260
+ controlEvents: progressUpdate.details?.controlEvents,
261
+ chainAgents: input.chainAgents,
262
+ totalSteps: input.totalSteps,
263
+ currentStepIndex: input.stepIndex,
264
+ },
265
+ });
266
+ }
222
267
  : undefined,
223
268
  });
269
+ if (input.foregroundControl?.currentIndex === input.globalTaskIndex + taskIndex) {
270
+ input.foregroundControl.interrupt = undefined;
271
+ input.foregroundControl.updatedAt = Date.now();
272
+ }
224
273
 
225
274
  if (result.exitCode !== 0 && failFast) {
226
275
  aborted = true;
@@ -249,6 +298,19 @@ export interface ChainExecutionParams {
249
298
  includeProgress?: boolean;
250
299
  clarify?: boolean;
251
300
  onUpdate?: (r: AgentToolResult<Details>) => void;
301
+ onControlEvent?: (event: ControlEvent) => void;
302
+ controlConfig: ResolvedControlConfig;
303
+ childIntercomTarget?: (agent: string, index: number) => string | undefined;
304
+ foregroundControl?: {
305
+ updatedAt: number;
306
+ currentAgent?: string;
307
+ currentIndex?: number;
308
+ currentActivityState?: ActivityState;
309
+ lastActivityAt?: number;
310
+ currentTool?: string;
311
+ currentToolStartedAt?: number;
312
+ interrupt?: () => boolean;
313
+ };
252
314
  chainSkills?: string[];
253
315
  chainDir?: string;
254
316
  maxSubagentDepth: number;
@@ -286,6 +348,10 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
286
348
  includeProgress,
287
349
  clarify,
288
350
  onUpdate,
351
+ onControlEvent,
352
+ controlConfig,
353
+ childIntercomTarget,
354
+ foregroundControl,
289
355
  chainSkills: chainSkillsParam,
290
356
  chainDir: chainDirBase,
291
357
  } = params;
@@ -484,6 +550,10 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
484
550
  allProgress,
485
551
  chainAgents,
486
552
  totalSteps,
553
+ controlConfig,
554
+ onControlEvent,
555
+ childIntercomTarget,
556
+ foregroundControl,
487
557
  worktreeSetup,
488
558
  maxSubagentDepth: params.maxSubagentDepth,
489
559
  });
@@ -495,6 +565,23 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
495
565
  if (result.artifactPaths) allArtifactPaths.push(result.artifactPaths);
496
566
  }
497
567
 
568
+ const interrupted = parallelResults.find((result) => result.interrupted);
569
+ if (interrupted) {
570
+ return {
571
+ content: [{ type: "text", text: `Chain paused after interrupt at step ${stepIndex + 1} (${interrupted.agent}). Waiting for explicit next action.` }],
572
+ details: buildChainExecutionDetails({
573
+ results,
574
+ includeProgress,
575
+ allProgress,
576
+ allArtifactPaths,
577
+ artifactsDir,
578
+ chainAgents,
579
+ totalSteps,
580
+ currentStepIndex: stepIndex,
581
+ }),
582
+ };
583
+ }
584
+
498
585
  const failures = parallelResults
499
586
  .map((result, originalIndex) => ({ ...result, originalIndex }))
500
587
  .filter((result) => result.exitCode !== 0 && result.exitCode !== -1);
@@ -603,10 +690,25 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
603
690
  ? (path.isAbsolute(behavior.output) ? behavior.output : path.join(chainDir, behavior.output))
604
691
  : undefined;
605
692
  const maxSubagentDepth = resolveChildMaxSubagentDepth(params.maxSubagentDepth, agentConfig.maxSubagentDepth);
693
+ const interruptController = new AbortController();
694
+ if (foregroundControl) {
695
+ foregroundControl.currentAgent = seqStep.agent;
696
+ foregroundControl.currentIndex = globalTaskIndex;
697
+ foregroundControl.currentActivityState = undefined;
698
+ foregroundControl.updatedAt = Date.now();
699
+ foregroundControl.interrupt = () => {
700
+ if (interruptController.signal.aborted) return false;
701
+ interruptController.abort();
702
+ foregroundControl.currentActivityState = undefined;
703
+ foregroundControl.updatedAt = Date.now();
704
+ return true;
705
+ };
706
+ }
606
707
 
607
708
  const r = await runSync(ctx.cwd, agents, seqStep.agent, stepTask, {
608
709
  cwd: resolveChildCwd(cwd ?? ctx.cwd, seqStep.cwd),
609
710
  signal,
711
+ interruptSignal: interruptController.signal,
610
712
  runId,
611
713
  index: globalTaskIndex,
612
714
  sessionDir: sessionDirForIndex(globalTaskIndex),
@@ -616,28 +718,46 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
616
718
  artifactConfig,
617
719
  outputPath,
618
720
  maxSubagentDepth,
721
+ controlConfig,
722
+ onControlEvent,
723
+ intercomSessionName: childIntercomTarget?.(seqStep.agent, globalTaskIndex),
619
724
  modelOverride: effectiveModel,
620
725
  availableModels,
621
726
  preferredModelProvider: ctx.model?.provider,
622
727
  skills: behavior.skills === false ? [] : behavior.skills,
623
728
  onUpdate: onUpdate
624
729
  ? (p) => {
625
- const stepResults = p.details?.results || [];
626
- const stepProgress = p.details?.progress || [];
627
- onUpdate({
628
- ...p,
629
- details: {
630
- mode: "chain",
631
- results: results.concat(stepResults),
632
- progress: allProgress.concat(stepProgress),
633
- chainAgents,
634
- totalSteps,
635
- currentStepIndex: stepIndex,
636
- },
637
- });
730
+ const stepResults = p.details?.results || [];
731
+ const stepProgress = p.details?.progress || [];
732
+ if (foregroundControl && stepProgress.length > 0) {
733
+ const current = stepProgress[0];
734
+ foregroundControl.currentAgent = seqStep.agent;
735
+ foregroundControl.currentIndex = globalTaskIndex;
736
+ foregroundControl.currentActivityState = current?.activityState;
737
+ foregroundControl.lastActivityAt = current?.lastActivityAt;
738
+ foregroundControl.currentTool = current?.currentTool;
739
+ foregroundControl.currentToolStartedAt = current?.currentToolStartedAt;
740
+ foregroundControl.updatedAt = Date.now();
638
741
  }
742
+ onUpdate({
743
+ ...p,
744
+ details: {
745
+ mode: "chain",
746
+ results: results.concat(stepResults),
747
+ progress: allProgress.concat(stepProgress),
748
+ controlEvents: p.details?.controlEvents,
749
+ chainAgents,
750
+ totalSteps,
751
+ currentStepIndex: stepIndex,
752
+ },
753
+ });
754
+ }
639
755
  : undefined,
640
756
  });
757
+ if (foregroundControl?.currentIndex === globalTaskIndex) {
758
+ foregroundControl.interrupt = undefined;
759
+ foregroundControl.updatedAt = Date.now();
760
+ }
641
761
  recordRun(seqStep.agent, cleanTask, r.exitCode, r.progressSummary?.durationMs ?? 0);
642
762
 
643
763
  globalTaskIndex++;
@@ -663,6 +783,22 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
663
783
  }
664
784
  }
665
785
 
786
+ if (r.interrupted) {
787
+ return {
788
+ content: [{ type: "text", text: `Chain paused after interrupt at step ${stepIndex + 1} (${r.agent}). Waiting for explicit next action.` }],
789
+ details: buildChainExecutionDetails({
790
+ results,
791
+ includeProgress,
792
+ allProgress,
793
+ allArtifactPaths,
794
+ artifactsDir,
795
+ chainAgents,
796
+ totalSteps,
797
+ currentStepIndex: stepIndex,
798
+ }),
799
+ };
800
+ }
801
+
666
802
  if (r.exitCode !== 0) {
667
803
  const summary = buildChainSummary(chainSteps, results, chainDir, "failed", {
668
804
  index: stepIndex,
package/execution.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  import {
15
15
  type AgentProgress,
16
16
  type ArtifactPaths,
17
+ type ControlEvent,
17
18
  type ModelAttempt,
18
19
  type RunSyncOptions,
19
20
  type SingleResult,
@@ -24,6 +25,14 @@ import {
24
25
  truncateOutput,
25
26
  getSubagentDepthEnv,
26
27
  } from "./types.ts";
28
+ import {
29
+ DEFAULT_CONTROL_CONFIG,
30
+ buildControlEvent,
31
+ claimControlNotification,
32
+ deriveActivityState,
33
+ shouldEmitControlEvent,
34
+ shouldNotifyControlEvent,
35
+ } from "./subagent-control.ts";
27
36
  import {
28
37
  getFinalOutput,
29
38
  findLatestSessionFile,
@@ -86,6 +95,7 @@ function snapshotResult(result: SingleResult, progress: AgentProgress): SingleRe
86
95
  usage: attempt.usage ? { ...attempt.usage } : undefined,
87
96
  }))
88
97
  : undefined,
98
+ controlEvents: result.controlEvents ? result.controlEvents.map((event) => ({ ...event })) : undefined,
89
99
  progress,
90
100
  progressSummary: result.progressSummary ? { ...result.progressSummary } : undefined,
91
101
  artifactPaths: result.artifactPaths ? { ...result.artifactPaths } : undefined,
@@ -127,6 +137,7 @@ async function runSingleAttempt(
127
137
  systemPrompt: shared.systemPrompt,
128
138
  mcpDirectTools: agent.mcpDirectTools,
129
139
  promptFileStem: agent.name,
140
+ intercomSessionName: options.intercomSessionName,
130
141
  });
131
142
 
132
143
  const result: SingleResult = {
@@ -140,6 +151,11 @@ async function runSingleAttempt(
140
151
  skills: shared.resolvedSkillNames,
141
152
  skillsWarning: shared.skillsWarning,
142
153
  };
154
+ const startTime = Date.now();
155
+ const controlConfig = options.controlConfig ?? DEFAULT_CONTROL_CONFIG;
156
+ let interruptedByControl = false;
157
+ const allControlEvents: ControlEvent[] = [];
158
+ let pendingControlEvents: ControlEvent[] = [];
143
159
 
144
160
  const progress: AgentProgress = {
145
161
  index: options.index ?? 0,
@@ -152,11 +168,9 @@ async function runSingleAttempt(
152
168
  toolCount: 0,
153
169
  tokens: 0,
154
170
  durationMs: 0,
155
- lastActivityAt: Date.now(),
171
+ lastActivityAt: startTime,
156
172
  };
157
173
  result.progress = progress;
158
-
159
- const startTime = Date.now();
160
174
  const spawnEnv = { ...process.env, ...sharedEnv, ...getSubagentDepthEnv(options.maxSubagentDepth) };
161
175
 
162
176
  const exitCode = await new Promise<number>((resolve) => {
@@ -173,6 +187,8 @@ async function runSingleAttempt(
173
187
  let detached = false;
174
188
  let intercomStarted = false;
175
189
  let removeAbortListener: (() => void) | undefined;
190
+ let removeInterruptListener: (() => void) | undefined;
191
+ let activityTimer: NodeJS.Timeout | undefined;
176
192
 
177
193
  const detachForIntercom = () => {
178
194
  detached = true;
@@ -241,18 +257,69 @@ async function runSingleAttempt(
241
257
  settled = true;
242
258
  clearFinalDrainTimers();
243
259
  clearStdioGuard();
260
+ if (activityTimer) {
261
+ clearInterval(activityTimer);
262
+ activityTimer = undefined;
263
+ }
244
264
  unsubscribeIntercomDetach?.();
245
265
  removeAbortListener?.();
266
+ removeInterruptListener?.();
246
267
  resolve(code);
247
268
  };
248
269
 
270
+ const drainPendingControlEvents = (): ControlEvent[] | undefined => {
271
+ if (pendingControlEvents.length === 0) return undefined;
272
+ const events = pendingControlEvents;
273
+ pendingControlEvents = [];
274
+ return events;
275
+ };
276
+
277
+ const emittedControlEventKeys = new Set<string>();
278
+ const emitControlEvent = (event: ControlEvent) => {
279
+ if (shouldNotifyControlEvent(controlConfig, event) && !claimControlNotification(controlConfig, event, emittedControlEventKeys)) return;
280
+ allControlEvents.push(event);
281
+ pendingControlEvents.push(event);
282
+ options.onControlEvent?.(event);
283
+ };
284
+
285
+ const updateActivityState = (now: number): boolean => {
286
+ const next = deriveActivityState({
287
+ config: controlConfig,
288
+ startedAt: startTime,
289
+ lastActivityAt: progress.lastActivityAt,
290
+ now,
291
+ });
292
+ if (next === progress.activityState) return false;
293
+ const previous = progress.activityState;
294
+ progress.activityState = next;
295
+ if (shouldEmitControlEvent(controlConfig, previous, next)) {
296
+ emitControlEvent(buildControlEvent({
297
+ from: previous,
298
+ to: next,
299
+ runId: options.runId,
300
+ agent: agent.name,
301
+ index: options.index,
302
+ ts: now,
303
+ lastActivityAt: progress.lastActivityAt,
304
+ }));
305
+ }
306
+ return true;
307
+ };
308
+
309
+
249
310
  const emitUpdateSnapshot = (text: string) => {
250
311
  if (!options.onUpdate || processClosed) return;
251
312
  const progressSnapshot = snapshotProgress(progress);
252
313
  const resultSnapshot = snapshotResult(result, progressSnapshot);
314
+ const controlEvents = drainPendingControlEvents();
253
315
  options.onUpdate({
254
316
  content: [{ type: "text", text }],
255
- details: { mode: "single", results: [resultSnapshot], progress: [progressSnapshot] },
317
+ details: {
318
+ mode: "single",
319
+ results: [resultSnapshot],
320
+ progress: [progressSnapshot],
321
+ controlEvents,
322
+ },
256
323
  });
257
324
  };
258
325
 
@@ -276,6 +343,7 @@ async function runSingleAttempt(
276
343
  const now = Date.now();
277
344
  progress.durationMs = now - startTime;
278
345
  progress.lastActivityAt = now;
346
+ updateActivityState(now);
279
347
 
280
348
  if (evt.type === "tool_execution_start") {
281
349
  if (options.allowIntercomDetach && evt.toolName === "intercom") {
@@ -336,6 +404,18 @@ async function runSingleAttempt(
336
404
  }
337
405
  };
338
406
 
407
+ if (controlConfig.enabled) {
408
+ activityTimer = setInterval(() => {
409
+ if (processClosed || settled || detached) return;
410
+ const now = Date.now();
411
+ if (updateActivityState(now)) {
412
+ progress.durationMs = now - startTime;
413
+ fireUpdate();
414
+ }
415
+ }, 1000);
416
+ activityTimer.unref?.();
417
+ }
418
+
339
419
  let stderrBuf = "";
340
420
 
341
421
  const clearStdioGuard = attachPostExitStdioGuard(proc, { idleMs: 2000, hardMs: 8000 });
@@ -400,8 +480,46 @@ async function runSingleAttempt(
400
480
  removeAbortListener = () => options.signal?.removeEventListener("abort", kill);
401
481
  }
402
482
  }
483
+
484
+ if (options.interruptSignal) {
485
+ const interrupt = () => {
486
+ if (processClosed || detached || settled) return;
487
+ interruptedByControl = true;
488
+ progress.status = "running";
489
+ progress.durationMs = Date.now() - startTime;
490
+ result.interrupted = true;
491
+ result.finalOutput = "Interrupted. Waiting for explicit next action.";
492
+ progress.activityState = undefined;
493
+ fireUpdate();
494
+ trySignalChild(proc, "SIGINT");
495
+ setTimeout(() => {
496
+ if (settled || processClosed || detached) return;
497
+ trySignalChild(proc, "SIGTERM");
498
+ }, 1000).unref?.();
499
+ };
500
+ if (options.interruptSignal.aborted) interrupt();
501
+ else {
502
+ options.interruptSignal.addEventListener("abort", interrupt, { once: true });
503
+ removeInterruptListener = () => options.interruptSignal?.removeEventListener("abort", interrupt);
504
+ }
505
+ }
403
506
  });
404
507
  result.exitCode = exitCode;
508
+ if (interruptedByControl) {
509
+ result.exitCode = 0;
510
+ result.interrupted = true;
511
+ result.error = undefined;
512
+ result.finalOutput = result.finalOutput || "Interrupted. Waiting for explicit next action.";
513
+ result.controlEvents = allControlEvents.length ? allControlEvents : undefined;
514
+ progress.activityState = undefined;
515
+ progress.durationMs = Date.now() - startTime;
516
+ result.progressSummary = {
517
+ toolCount: progress.toolCount,
518
+ tokens: progress.tokens,
519
+ durationMs: progress.durationMs,
520
+ };
521
+ return result;
522
+ }
405
523
  if (result.detached) {
406
524
  result.exitCode = 0;
407
525
  result.finalOutput = "Detached for intercom coordination.";