pi-subagents 0.17.4 → 0.17.5

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/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,12 @@ import {
24
25
  truncateOutput,
25
26
  getSubagentDepthEnv,
26
27
  } from "./types.ts";
28
+ import {
29
+ DEFAULT_CONTROL_CONFIG,
30
+ buildControlEvent,
31
+ deriveActivityState,
32
+ shouldEmitControlEvent,
33
+ } from "./subagent-control.ts";
27
34
  import {
28
35
  getFinalOutput,
29
36
  findLatestSessionFile,
@@ -86,6 +93,7 @@ function snapshotResult(result: SingleResult, progress: AgentProgress): SingleRe
86
93
  usage: attempt.usage ? { ...attempt.usage } : undefined,
87
94
  }))
88
95
  : undefined,
96
+ controlEvents: result.controlEvents ? result.controlEvents.map((event) => ({ ...event })) : undefined,
89
97
  progress,
90
98
  progressSummary: result.progressSummary ? { ...result.progressSummary } : undefined,
91
99
  artifactPaths: result.artifactPaths ? { ...result.artifactPaths } : undefined,
@@ -140,11 +148,19 @@ async function runSingleAttempt(
140
148
  skills: shared.resolvedSkillNames,
141
149
  skillsWarning: shared.skillsWarning,
142
150
  };
151
+ const startTime = Date.now();
152
+ const controlConfig = options.controlConfig ?? DEFAULT_CONTROL_CONFIG;
153
+ let hasSeenActivity = false;
154
+ let pausedByInterrupt = false;
155
+ let interruptedByControl = false;
156
+ const allControlEvents: ControlEvent[] = [];
157
+ let pendingControlEvents: ControlEvent[] = [];
143
158
 
144
159
  const progress: AgentProgress = {
145
160
  index: options.index ?? 0,
146
161
  agent: agent.name,
147
162
  status: "running",
163
+ activityState: controlConfig.enabled ? "starting" : undefined,
148
164
  task,
149
165
  skills: shared.resolvedSkillNames,
150
166
  recentTools: [],
@@ -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,64 @@ 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 updateActivityState = (now: number): boolean => {
278
+ const next = deriveActivityState({
279
+ config: controlConfig,
280
+ startedAt: startTime,
281
+ lastActivityAt: progress.lastActivityAt,
282
+ hasSeenActivity,
283
+ paused: pausedByInterrupt,
284
+ now,
285
+ });
286
+ if (!next || next === progress.activityState) return false;
287
+ const previous = progress.activityState;
288
+ progress.activityState = next;
289
+ if (shouldEmitControlEvent(controlConfig, previous, next)) {
290
+ const event = buildControlEvent({
291
+ from: previous,
292
+ to: next,
293
+ runId: options.runId,
294
+ agent: agent.name,
295
+ index: options.index,
296
+ ts: now,
297
+ });
298
+ allControlEvents.push(event);
299
+ pendingControlEvents.push(event);
300
+ options.onControlEvent?.(event);
301
+ }
302
+ return true;
303
+ };
304
+
249
305
  const emitUpdateSnapshot = (text: string) => {
250
306
  if (!options.onUpdate || processClosed) return;
251
307
  const progressSnapshot = snapshotProgress(progress);
252
308
  const resultSnapshot = snapshotResult(result, progressSnapshot);
309
+ const controlEvents = drainPendingControlEvents();
253
310
  options.onUpdate({
254
311
  content: [{ type: "text", text }],
255
- details: { mode: "single", results: [resultSnapshot], progress: [progressSnapshot] },
312
+ details: {
313
+ mode: "single",
314
+ results: [resultSnapshot],
315
+ progress: [progressSnapshot],
316
+ controlEvents,
317
+ },
256
318
  });
257
319
  };
258
320
 
@@ -276,6 +338,8 @@ async function runSingleAttempt(
276
338
  const now = Date.now();
277
339
  progress.durationMs = now - startTime;
278
340
  progress.lastActivityAt = now;
341
+ hasSeenActivity = true;
342
+ updateActivityState(now);
279
343
 
280
344
  if (evt.type === "tool_execution_start") {
281
345
  if (options.allowIntercomDetach && evt.toolName === "intercom") {
@@ -336,6 +400,18 @@ async function runSingleAttempt(
336
400
  }
337
401
  };
338
402
 
403
+ if (controlConfig.enabled) {
404
+ activityTimer = setInterval(() => {
405
+ if (processClosed || settled || detached) return;
406
+ const now = Date.now();
407
+ if (updateActivityState(now)) {
408
+ progress.durationMs = now - startTime;
409
+ fireUpdate();
410
+ }
411
+ }, 1000);
412
+ activityTimer.unref?.();
413
+ }
414
+
339
415
  let stderrBuf = "";
340
416
 
341
417
  const clearStdioGuard = attachPostExitStdioGuard(proc, { idleMs: 2000, hardMs: 8000 });
@@ -400,8 +476,62 @@ async function runSingleAttempt(
400
476
  removeAbortListener = () => options.signal?.removeEventListener("abort", kill);
401
477
  }
402
478
  }
479
+
480
+ if (options.interruptSignal) {
481
+ const interrupt = () => {
482
+ if (processClosed || detached || settled) return;
483
+ interruptedByControl = true;
484
+ pausedByInterrupt = true;
485
+ progress.status = "running";
486
+ progress.durationMs = Date.now() - startTime;
487
+ result.interrupted = true;
488
+ result.finalOutput = "Interrupted. Waiting for explicit next action.";
489
+ const now = Date.now();
490
+ const previous = progress.activityState;
491
+ progress.activityState = "paused";
492
+ if (shouldEmitControlEvent(controlConfig, previous, "paused")) {
493
+ const event = buildControlEvent({
494
+ from: previous,
495
+ to: "paused",
496
+ runId: options.runId,
497
+ agent: agent.name,
498
+ index: options.index,
499
+ ts: now,
500
+ });
501
+ allControlEvents.push(event);
502
+ pendingControlEvents.push(event);
503
+ options.onControlEvent?.(event);
504
+ }
505
+ fireUpdate();
506
+ trySignalChild(proc, "SIGINT");
507
+ setTimeout(() => {
508
+ if (settled || processClosed || detached) return;
509
+ trySignalChild(proc, "SIGTERM");
510
+ }, 1000).unref?.();
511
+ };
512
+ if (options.interruptSignal.aborted) interrupt();
513
+ else {
514
+ options.interruptSignal.addEventListener("abort", interrupt, { once: true });
515
+ removeInterruptListener = () => options.interruptSignal?.removeEventListener("abort", interrupt);
516
+ }
517
+ }
403
518
  });
404
519
  result.exitCode = exitCode;
520
+ if (interruptedByControl) {
521
+ result.exitCode = 0;
522
+ result.interrupted = true;
523
+ result.error = undefined;
524
+ result.finalOutput = result.finalOutput || "Interrupted. Waiting for explicit next action.";
525
+ result.controlEvents = allControlEvents.length ? allControlEvents : undefined;
526
+ progress.activityState = "paused";
527
+ progress.durationMs = Date.now() - startTime;
528
+ result.progressSummary = {
529
+ toolCount: progress.toolCount,
530
+ tokens: progress.tokens,
531
+ durationMs: progress.durationMs,
532
+ };
533
+ return result;
534
+ }
405
535
  if (result.detached) {
406
536
  result.exitCode = 0;
407
537
  result.finalOutput = "Detached for intercom coordination.";
package/index.ts CHANGED
@@ -153,6 +153,8 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
153
153
  baseCwd: process.cwd(),
154
154
  currentSessionId: null,
155
155
  asyncJobs: new Map(),
156
+ foregroundControls: new Map(),
157
+ lastForegroundControlId: null,
156
158
  cleanupTimers: new Map(),
157
159
  lastUiContext: null,
158
160
  poller: null,
@@ -265,11 +267,14 @@ Example: { chain: [{agent:"scout", task:"Analyze {task}"}, {agent:"planner", tas
265
267
 
266
268
  MANAGEMENT (use action field, omit agent/task/chain/tasks):
267
269
  • { action: "list" } - discover agents/chains
268
- • { action: "get", agent: "name" } - full agent detail
270
+ • { action: "get", agent: "name" } - full detail
269
271
  • { action: "create", config: { name, systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, ... } }
270
272
  • { action: "update", agent: "name", config: { ... } } - merge
271
273
  • { action: "delete", agent: "name" }
272
- • Use chainName for chain operations`,
274
+ • Use chainName for chain operations
275
+
276
+ CONTROL:
277
+ • { action: "interrupt", runId?: "..." } - soft-interrupt the current child turn and leave the run paused`,
273
278
  parameters: SubagentParams,
274
279
 
275
280
  execute(id, params, signal, onUpdate, ctx) {
@@ -389,13 +394,17 @@ MANAGEMENT (use action field, omit agent/task/chain/tasks):
389
394
 
390
395
  const lines = [
391
396
  `Run: ${status.runId}`,
392
- `State: ${status.state}`,
397
+ `State: ${status.activityState ? `${status.state}/${status.activityState}` : status.state}`,
393
398
  `Mode: ${status.mode}`,
394
399
  stepLine,
395
400
  `Started: ${started}`,
396
401
  `Updated: ${updated}`,
397
402
  `Dir: ${asyncDir}`,
398
403
  ];
404
+ for (const [index, step] of (status.steps ?? []).entries()) {
405
+ const stepState = step.activityState ? `${step.status}/${step.activityState}` : step.status;
406
+ lines.push(`Step ${index + 1}: ${step.agent} ${stepState}`);
407
+ }
399
408
  if (status.sessionFile) lines.push(`Session: ${status.sessionFile}`);
400
409
  if (fs.existsSync(logPath)) lines.push(`Log: ${logPath}`);
401
410
  if (fs.existsSync(eventsPath)) lines.push(`Events: ${eventsPath}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.17.4",
3
+ "version": "0.17.5",
4
4
  "description": "Pi extension for delegating tasks to subagents with chains, parallel execution, and TUI clarification",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
package/pi-spawn.ts CHANGED
@@ -83,12 +83,15 @@ export function resolveWindowsPiCliScript(deps: PiSpawnDeps = {}): string | unde
83
83
  }
84
84
 
85
85
  export function getPiSpawnCommand(args: string[], deps: PiSpawnDeps = {}): PiSpawnCommand {
86
- const piCliPath = resolveWindowsPiCliScript(deps);
87
- if (piCliPath) {
88
- return {
89
- command: deps.execPath ?? process.execPath,
90
- args: [piCliPath, ...args],
91
- };
86
+ const platform = deps.platform ?? process.platform;
87
+ if (platform === "win32") {
88
+ const piCliPath = resolveWindowsPiCliScript(deps);
89
+ if (piCliPath) {
90
+ return {
91
+ command: deps.execPath ?? process.execPath,
92
+ args: [piCliPath, ...args],
93
+ };
94
+ }
92
95
  }
93
96
 
94
97
  return { command: "pi", args };
package/render.ts CHANGED
@@ -83,7 +83,7 @@ let lastWidgetHash = "";
83
83
 
84
84
  function computeWidgetHash(jobs: AsyncJobState[]): string {
85
85
  return jobs.slice(0, MAX_WIDGET_JOBS).map(job =>
86
- `${job.asyncId}:${job.status}:${job.currentStep}:${job.updatedAt}:${job.totalTokens?.total ?? 0}`
86
+ `${job.asyncId}:${job.status}:${job.activityState}:${job.currentStep}:${job.updatedAt}:${job.totalTokens?.total ?? 0}`
87
87
  ).join("|");
88
88
  }
89
89
 
@@ -142,6 +142,11 @@ function buildLiveStatusLine(progress: Pick<AgentProgress, "lastActivityAt">): s
142
142
  return formatActivityLabel(progress.lastActivityAt);
143
143
  }
144
144
 
145
+ function formatActivityState(state: AgentProgress["activityState"]): string | undefined {
146
+ if (!state) return undefined;
147
+ return `activity: ${state}`;
148
+ }
149
+
145
150
  /**
146
151
  * Render the async jobs widget
147
152
  */
@@ -175,7 +180,9 @@ export function renderWidget(ctx: ExtensionContext, jobs: AsyncJobState[]): void
175
180
  ? theme.fg("success", "complete")
176
181
  : job.status === "failed"
177
182
  ? theme.fg("error", "failed")
178
- : theme.fg("warning", "running");
183
+ : job.status === "paused"
184
+ ? theme.fg("warning", "paused")
185
+ : theme.fg("warning", "running");
179
186
 
180
187
  const stepsTotal = job.stepsTotal ?? (job.agents?.length ?? 1);
181
188
  const stepIndex = job.currentStep !== undefined ? job.currentStep + 1 : undefined;
@@ -185,12 +192,12 @@ export function renderWidget(ctx: ExtensionContext, jobs: AsyncJobState[]): void
185
192
  const agentLabel = job.agents ? job.agents.join(" -> ") : (job.mode ?? "single");
186
193
 
187
194
  const tokenText = job.totalTokens ? ` | ${formatTokens(job.totalTokens.total)} tok` : "";
188
- const activityText = job.status === "running" ? getLastActivity(job.outputFile) : "";
195
+ const activityText = job.activityState ?? (job.status === "running" ? getLastActivity(job.outputFile) : "");
189
196
  const activitySuffix = activityText ? ` | ${theme.fg("dim", activityText)}` : "";
190
197
 
191
198
  lines.push(truncLine(`- ${id} ${status} | ${agentLabel} | ${stepText}${elapsed ? ` | ${elapsed}` : ""}${tokenText}${activitySuffix}`, w));
192
199
 
193
- if (job.status === "running" && job.outputFile) {
200
+ if ((job.status === "running" || job.status === "paused") && job.outputFile) {
194
201
  const tail = getOutputTail(job.outputFile, 3);
195
202
  for (const line of tail) {
196
203
  lines.push(truncLine(theme.fg("dim", ` > ${line}`), w));
@@ -259,6 +266,10 @@ export function renderSubagentResult(
259
266
  if (toolLine) {
260
267
  c.addChild(new Text(fit(theme.fg("warning", `> ${toolLine}`)), 0, 0));
261
268
  }
269
+ const activityStateLine = formatActivityState(r.progress.activityState);
270
+ if (activityStateLine) {
271
+ c.addChild(new Text(fit(theme.fg("accent", activityStateLine)), 0, 0));
272
+ }
262
273
  const liveStatusLine = buildLiveStatusLine(r.progress);
263
274
  if (liveStatusLine) {
264
275
  c.addChild(new Text(fit(theme.fg("accent", liveStatusLine)), 0, 0));
@@ -467,6 +478,10 @@ export function renderSubagentResult(
467
478
  if (toolLine) {
468
479
  c.addChild(new Text(fit(theme.fg("warning", ` > ${toolLine}`)), 0, 0));
469
480
  }
481
+ const activityStateLine = formatActivityState(rProg.activityState);
482
+ if (activityStateLine) {
483
+ c.addChild(new Text(fit(theme.fg("accent", ` ${activityStateLine}`)), 0, 0));
484
+ }
470
485
  const liveStatusLine = buildLiveStatusLine(rProg);
471
486
  if (liveStatusLine) {
472
487
  c.addChild(new Text(fit(theme.fg("accent", ` ${liveStatusLine}`)), 0, 0));
package/schemas.ts CHANGED
@@ -57,12 +57,22 @@ export const ParallelStepSchema = Type.Object({
57
57
  // Note: Using Type.Any() for Google API compatibility (doesn't support anyOf)
58
58
  export const ChainItem = Type.Any({ description: "Chain step: either {agent, task?, ...} for sequential or {parallel: [...]} for concurrent execution" });
59
59
 
60
+ export const ControlOverrides = Type.Object({
61
+ enabled: Type.Optional(Type.Boolean({ description: "Enable/disable subagent control activity tracking for this run" })),
62
+ quietAfterMs: Type.Optional(Type.Integer({ minimum: 1, description: "Idle window before activity moves from active to quiet" })),
63
+ stalledAfterMs: Type.Optional(Type.Integer({ minimum: 1, description: "Idle window before activity moves from quiet to stalled" })),
64
+ parentMode: Type.Optional(Type.String({ enum: ["transitions", "verbose"], description: "Parent-visible control event mode" })),
65
+ });
66
+
60
67
  export const SubagentParams = Type.Object({
61
68
  agent: Type.Optional(Type.String({ description: "Agent name (SINGLE mode) or target for management get/update/delete" })),
62
69
  task: Type.Optional(Type.String({ description: "Task (SINGLE mode)" })),
63
70
  // Management action (when present, tool operates in management mode)
64
71
  action: Type.Optional(Type.String({
65
- description: "Management action: 'list' (discover agents/chains), 'get' (full detail), 'create', 'update', 'delete'. Omit for execution mode."
72
+ description: "Action: management ('list','get','create','update','delete') or control ('interrupt'). Omit for execution mode."
73
+ })),
74
+ runId: Type.Optional(Type.String({
75
+ description: "Target run ID for action='interrupt'. Defaults to the most recently active controllable run in this session."
66
76
  })),
67
77
  // Chain identifier for management (can't reuse 'chain' — that's the execution array)
68
78
  chainName: Type.Optional(Type.String({
@@ -96,6 +106,7 @@ export const SubagentParams = Type.Object({
96
106
  ),
97
107
  // Clarification TUI
98
108
  clarify: Type.Optional(Type.Boolean({ description: "Show TUI to preview/edit before execution (default: true for chains, false for single/parallel). Implies sync mode." })),
109
+ control: Type.Optional(ControlOverrides),
99
110
  // Solo agent overrides
100
111
  output: Type.Optional(Type.Any({ description: "Output file for single agent (string), or false to disable. Relative paths resolve against cwd." })),
101
112
  skill: Type.Optional(SkillOverride),
@@ -20,6 +20,7 @@ agents into a workflow, or create/edit agents and chains on demand.
20
20
  - **Recon and planning**: use `scout` or `context-builder`, then `planner`
21
21
  - **Parallel exploration**: run multiple non-conflicting tasks concurrently
22
22
  - **Long-running work**: launch async/background runs and inspect them later
23
+ - **Subagent control**: watch clear stalled/paused signals and soft-interrupt only when a delegated run is genuinely blocked
23
24
  - **Agent authoring**: create, update, or override agents and chains for a project
24
25
 
25
26
  ## Tool vs Slash Commands
@@ -147,6 +148,40 @@ subagent({
147
148
  Inspect async runs with the `subagent_status(...)` tool or the
148
149
  `/subagents-status` slash command.
149
150
 
151
+ ### Subagent control
152
+
153
+ Subagent control is the runtime visibility and intervention layer for delegated runs. It is separate from lifecycle status. Lifecycle status says whether a child is `running`, `completed`, `failed`, or detached. Activity state says what the child appears to be doing right now: `starting`, `active`, `quiet`, `stalled`, or `paused`.
154
+
155
+ Default behavior is intentionally conservative. Routine `active` and `quiet` updates are mostly UI/status information. Clear transitions such as `stalled`, `recovered`, and `paused` are the signals worth acting on.
156
+
157
+ Use soft interrupt when a child is clearly blocked or drifting and the parent needs to regain control:
158
+
159
+ ```typescript
160
+ subagent({ action: "interrupt" })
161
+ ```
162
+
163
+ Pass `runId` when targeting a specific controllable run:
164
+
165
+ ```typescript
166
+ subagent({ action: "interrupt", runId: "abc123" })
167
+ ```
168
+
169
+ A soft interrupt cancels the current child turn and leaves the run paused. It does not mean the delegated task succeeded or failed. After an interrupt, decide the next explicit action: resume with clearer instructions, replace the task, ask the user, or stop the workflow.
170
+
171
+ Per-run control thresholds can be overridden when a task legitimately runs quiet for longer than usual:
172
+
173
+ ```typescript
174
+ subagent({
175
+ agent: "worker",
176
+ task: "Run the slow migration test suite",
177
+ control: {
178
+ quietAfterMs: 60000,
179
+ stalledAfterMs: 300000,
180
+ parentMode: "transitions"
181
+ }
182
+ })
183
+ ```
184
+
150
185
  ## Clarify TUI
151
186
 
152
187
  Single and parallel runs support a clarification TUI when you want to preview or
@@ -334,6 +369,7 @@ particular agent or with forked context.
334
369
  filtered contexts.
335
370
  - **Default subagent nesting depth is 2.** Deeper recursive delegation is blocked
336
371
  unless configured otherwise.
372
+ - **Control state is not lifecycle state.** `paused` means the child turn was intentionally interrupted or is awaiting direction; it is not the same as `failed`.
337
373
  - **Intercom asks are blocking.** A session can only maintain one pending outbound
338
374
  ask wait state at a time.
339
375
  - **Keep conversational authority clear.** Advisory subagents should not silently
@@ -362,6 +398,10 @@ Give subagents specific tasks rather than vague mandates.
362
398
  If a subagent encounters an unapproved product, architecture, or scope choice,
363
399
  it should coordinate back via `intercom` instead of deciding alone.
364
400
 
401
+ ### Intervene only on clear control signals
402
+
403
+ Use subagent control proactively when a delegated run is explicitly `stalled` or `paused`, or when a human asks you to regain control. Do not interrupt just because a child is briefly `quiet`. Quiet can be normal during long tool calls, test runs, or model reasoning.
404
+
365
405
  ### Name sessions meaningfully
366
406
 
367
407
  Use `/name` so intercom targeting stays stable.
@@ -63,6 +63,7 @@ function createPlaceholderResult(
63
63
  ...(index !== undefined ? { index } : {}),
64
64
  agent,
65
65
  status,
66
+ activityState: status === "running" ? "starting" : undefined,
66
67
  task,
67
68
  recentTools: [],
68
69
  recentOutput: [],
@@ -85,6 +86,7 @@ function buildParallelInitialResult(params: SubagentParamsLike): AgentToolResult
85
86
  index,
86
87
  agent: task.agent,
87
88
  status: "running" as const,
89
+ activityState: "starting" as const,
88
90
  task: task.task,
89
91
  recentTools: [],
90
92
  recentOutput: [],
@@ -140,6 +142,7 @@ function buildChainInitialResult(params: SubagentParamsLike): AgentToolResult<De
140
142
  index,
141
143
  agent: result.agent,
142
144
  status: index === 0 ? "running" as const : "pending" as const,
145
+ activityState: index === 0 ? "starting" as const : undefined,
143
146
  task: result.task,
144
147
  recentTools: [],
145
148
  recentOutput: [],
@@ -166,6 +169,7 @@ function buildSingleInitialResult(params: SubagentParamsLike): AgentToolResult<D
166
169
  progress: [{
167
170
  agent,
168
171
  status: "running",
172
+ activityState: "starting",
169
173
  task,
170
174
  recentTools: [],
171
175
  recentOutput: [],
@@ -0,0 +1,106 @@
1
+ import {
2
+ type ActivityState,
3
+ type ControlConfig,
4
+ type ControlEvent,
5
+ type ControlEventType,
6
+ type ResolvedControlConfig,
7
+ } from "./types.ts";
8
+
9
+ export const DEFAULT_CONTROL_CONFIG: ResolvedControlConfig = {
10
+ enabled: true,
11
+ quietAfterMs: 15_000,
12
+ stalledAfterMs: 60_000,
13
+ parentMode: "transitions",
14
+ };
15
+
16
+ function parsePositiveInt(value: unknown): number | undefined {
17
+ if (typeof value !== "number") return undefined;
18
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) return undefined;
19
+ return value;
20
+ }
21
+
22
+ export function resolveControlConfig(
23
+ globalConfig?: ControlConfig,
24
+ override?: ControlConfig,
25
+ ): ResolvedControlConfig {
26
+ const enabled = override?.enabled ?? globalConfig?.enabled ?? DEFAULT_CONTROL_CONFIG.enabled;
27
+ const quietAfterMs = parsePositiveInt(override?.quietAfterMs)
28
+ ?? parsePositiveInt(globalConfig?.quietAfterMs)
29
+ ?? DEFAULT_CONTROL_CONFIG.quietAfterMs;
30
+ const stalledAfterRaw = parsePositiveInt(override?.stalledAfterMs)
31
+ ?? parsePositiveInt(globalConfig?.stalledAfterMs)
32
+ ?? DEFAULT_CONTROL_CONFIG.stalledAfterMs;
33
+ const parentMode = override?.parentMode ?? globalConfig?.parentMode ?? DEFAULT_CONTROL_CONFIG.parentMode;
34
+ const stalledAfterMs = Math.max(stalledAfterRaw, quietAfterMs + 1);
35
+ return {
36
+ enabled,
37
+ quietAfterMs,
38
+ stalledAfterMs,
39
+ parentMode: parentMode === "verbose" ? "verbose" : "transitions",
40
+ };
41
+ }
42
+
43
+ export function deriveActivityState(input: {
44
+ config: ResolvedControlConfig;
45
+ startedAt: number;
46
+ lastActivityAt?: number;
47
+ hasSeenActivity: boolean;
48
+ paused: boolean;
49
+ now?: number;
50
+ }): ActivityState | undefined {
51
+ if (!input.config.enabled) return undefined;
52
+ if (input.paused) return "paused";
53
+ if (!input.hasSeenActivity) return "starting";
54
+ const now = input.now ?? Date.now();
55
+ const lastActivity = input.lastActivityAt ?? input.startedAt;
56
+ const ageMs = Math.max(0, now - lastActivity);
57
+ if (ageMs <= input.config.quietAfterMs) return "active";
58
+ if (ageMs <= input.config.stalledAfterMs) return "quiet";
59
+ return "stalled";
60
+ }
61
+
62
+ function controlEventType(from: ActivityState | undefined, to: ActivityState): ControlEventType {
63
+ if (to === "stalled") return "stalled";
64
+ if (to === "paused") return "paused";
65
+ if (from === "stalled" && to !== "stalled") return "recovered";
66
+ if (from === "paused" && to !== "paused") return "resumed";
67
+ return "activity";
68
+ }
69
+
70
+ export function shouldEmitControlEvent(
71
+ config: ResolvedControlConfig,
72
+ from: ActivityState | undefined,
73
+ to: ActivityState,
74
+ ): boolean {
75
+ if (!config.enabled || from === to) return false;
76
+ if (config.parentMode === "verbose") return true;
77
+ if (to === "stalled" || to === "paused") return true;
78
+ if (from === "stalled" && to !== "stalled") return true;
79
+ if (from === "paused" && to !== "paused") return true;
80
+ return false;
81
+ }
82
+
83
+ export function buildControlEvent(input: {
84
+ from: ActivityState | undefined;
85
+ to: ActivityState;
86
+ runId: string;
87
+ agent: string;
88
+ index?: number;
89
+ ts?: number;
90
+ }): ControlEvent {
91
+ const ts = input.ts ?? Date.now();
92
+ const type = controlEventType(input.from, input.to);
93
+ const message = input.from
94
+ ? `${input.agent} ${type} (${input.from} -> ${input.to})`
95
+ : `${input.agent} ${type} (${input.to})`;
96
+ return {
97
+ type,
98
+ from: input.from,
99
+ to: input.to,
100
+ ts,
101
+ runId: input.runId,
102
+ agent: input.agent,
103
+ index: input.index,
104
+ message,
105
+ };
106
+ }