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/CHANGELOG.md +12 -0
- package/agents/context-builder.md +1 -1
- package/agents/oracle-executor.md +1 -1
- package/agents/oracle.md +1 -1
- package/agents/planner.md +1 -1
- package/agents/researcher.md +1 -1
- package/agents/reviewer.md +1 -1
- package/agents/scout.md +1 -1
- package/agents/worker.md +1 -1
- package/async-execution.ts +7 -0
- package/async-job-tracker.ts +5 -1
- package/async-status.ts +53 -18
- package/chain-execution.ts +137 -26
- package/execution.ts +134 -4
- package/index.ts +12 -3
- package/package.json +1 -1
- package/pi-spawn.ts +9 -6
- package/render.ts +19 -4
- package/schemas.ts +12 -1
- package/skills/pi-subagents/SKILL.md +40 -0
- package/slash-live-state.ts +4 -0
- package/subagent-control.ts +106 -0
- package/subagent-executor.ts +236 -5
- package/subagent-runner.ts +110 -25
- package/subagents-status.ts +4 -1
- package/types.ts +54 -2
- package/utils.ts +1 -0
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:
|
|
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: {
|
|
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
|
|
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
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
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
:
|
|
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: "
|
|
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.
|
package/slash-live-state.ts
CHANGED
|
@@ -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
|
+
}
|