pi-subagents 0.45.0 → 0.45.1
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 +9 -0
- package/package.json +1 -1
- package/src/extension/tool-description.ts +2 -2
- package/src/runs/background/control-channel.ts +4 -37
- package/src/runs/background/subagent-runner.ts +1 -1
- package/src/runs/foreground/async-steering-action.ts +1 -1
- package/src/runs/foreground/subagent-executor.ts +14 -6
- package/src/workflows/scripted-workflow.ts +19 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.45.1] - 2026-08-09
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- Simplified async workflow activity projection and its regression test to reuse canonical status types.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Add actionable guidance when Markdown fence backticks make a `workflowScript` invalid JavaScript.
|
|
12
|
+
- Prevent async interrupt requests from signaling unverified runner PIDs, including the shared host PID stored by workflows. Thanks to @kdasme for #925.
|
|
13
|
+
|
|
5
14
|
## [0.45.0] - 2026-08-09
|
|
6
15
|
|
|
7
16
|
### Added
|
package/package.json
CHANGED
|
@@ -18,7 +18,7 @@ export const FULL_SUBAGENT_TOOL_DESCRIPTION = `Run subagents only through { work
|
|
|
18
18
|
|
|
19
19
|
EXECUTION:
|
|
20
20
|
• Before executing, use { action: "list" } and run only executable/non-disabled configured agents.
|
|
21
|
-
• WORKFLOW SCRIPT: { workflowScript: "return runs.run('main', {agent:'worker', task:'...'})" }. Every execution is a workflow. Use stable-key runs.run for one child and runs.all for parallel children; ordinary JavaScript provides sequence, branching, filtering, retries, and aggregation. workflowScript is an ordinary JavaScript statement body, so use an explicit return for a useful result. Scripts start asynchronously by default; pass async:false only for a small foreground run. Same-repo foreground workflows default to a live in-chat card; set chatProgress to auto, off, or live-card to control that projection. Workflow-level child controls default onto each runs.run launch, and explicit child fields override them. Use {action:"children.list"} to list up to 10 completed retained children from this parent session, then continue one with runs.run(key, {resume:"run-id", task:"follow-up"}); resume and agent are mutually exclusive, and resume keeps the stored agent/model/tool contract. For repository mutation lanes, set worktree:true on the workflow or individual runs.run/runs.all item for managed isolation; each parallel child gets a separate worktree and handoff artifact. A workflow usageBudget is enforced once across the workflow. Available globals are runs.run, runs.all, runs.status, runs.ref/refs, emit, console, and standard JavaScript only. Workflows get async state.get(key) and state.set(key, JSONValue) through their automatic or explicit mission; mission:false workflows do not have a state global. Scripts cannot access filesystem, shell, arbitrary Pi tools, or host globals.
|
|
21
|
+
• WORKFLOW SCRIPT: { workflowScript: "return runs.run('main', {agent:'worker', task:'...'})" }. Every execution is a workflow. Use stable-key runs.run for one child and runs.all for parallel children; ordinary JavaScript provides sequence, branching, filtering, retries, and aggregation. workflowScript is an ordinary JavaScript statement body, so use an explicit return for a useful result. For task text with Markdown fences or shell blocks, build quoted lines instead of nesting raw template literals: \`const task=["Run:","\`\`\`bash","npm test","\`\`\`"].join("\\n")\`. Scripts start asynchronously by default; pass async:false only for a small foreground run. Same-repo foreground workflows default to a live in-chat card; set chatProgress to auto, off, or live-card to control that projection. Workflow-level child controls default onto each runs.run launch, and explicit child fields override them. Use {action:"children.list"} to list up to 10 completed retained children from this parent session, then continue one with runs.run(key, {resume:"run-id", task:"follow-up"}); resume and agent are mutually exclusive, and resume keeps the stored agent/model/tool contract. For repository mutation lanes, set worktree:true on the workflow or individual runs.run/runs.all item for managed isolation; each parallel child gets a separate worktree and handoff artifact. A workflow usageBudget is enforced once across the workflow. Available globals are runs.run, runs.all, runs.status, runs.ref/refs, emit, console, and standard JavaScript only. Workflows get async state.get(key) and state.set(key, JSONValue) through their automatic or explicit mission; mission:false workflows do not have a state global. Scripts cannot access filesystem, shell, arbitrary Pi tools, or host globals.
|
|
22
22
|
• Sequential example: { workflowScript: "const a = await runs.run('analyze', {agent:'agent-a', task:'Analyze the request'}); return (await runs.run('plan', {agent:'agent-b', task:'Plan from: '+a.output})).output" }
|
|
23
23
|
• Parallel example: { workflowScript: "const [a,b] = await runs.all([{key:'correctness',agent:'agent-a',task:'Review correctness'},{key:'tests',agent:'agent-b',task:'Review tests'}]); return {correctness:a.output,tests:b.output}" }
|
|
24
24
|
• Optional context is "fresh" or "fork". timeoutMs/maxRuntimeMs apply to foreground and async workflows; foreground workflows default to 30 minutes and async workflows have no default timeout. Omit acceptance for reviewer/read-only calls; evidence levels end at verified, and acceptance.review.required requests independent writer review.
|
|
@@ -37,7 +37,7 @@ export const COMPACT_SUBAGENT_TOOL_DESCRIPTION = `Run subagents only through { w
|
|
|
37
37
|
|
|
38
38
|
EXECUTE:
|
|
39
39
|
• Call { action:"list" } first and use only executable/non-disabled agents.
|
|
40
|
-
• SCRIPT {workflowScript:"return runs.run('main', {agent:'worker', task:'...'})"}. Use stable-key runs.run for one child and runs.all for parallel work. Use {action:"children.list"} for the last 10 retained children in this parent session, then runs.run(key,{resume:"run-id",task:"follow-up"}) to continue one with its stored contract. Workflows get async state.get/state.set through their automatic or explicit mission; mission:false does not. Scripts are ordinary JavaScript statement bodies; use explicit return for a useful result. Use JavaScript for sequence, branching, retries, and aggregation. For repository mutation lanes, use worktree:true on the workflow or runs.run/runs.all item for managed isolation. Scripts start async by default; async:false is the foreground escape hatch and auto-enables a same-repo live chat card unless chatProgress is off.
|
|
40
|
+
• SCRIPT {workflowScript:"return runs.run('main', {agent:'worker', task:'...'})"}. Use stable-key runs.run for one child and runs.all for parallel work. Use {action:"children.list"} for the last 10 retained children in this parent session, then runs.run(key,{resume:"run-id",task:"follow-up"}) to continue one with its stored contract. Workflows get async state.get/state.set through their automatic or explicit mission; mission:false does not. Scripts are ordinary JavaScript statement bodies; use explicit return for a useful result. For task text with Markdown fences or shell blocks, build quoted lines instead of nesting raw template literals: \`const task=["Run:","\`\`\`bash","npm test","\`\`\`"].join("\\n")\`. Use JavaScript for sequence, branching, retries, and aggregation. For repository mutation lanes, use worktree:true on the workflow or runs.run/runs.all item for managed isolation. Scripts start async by default; async:false is the foreground escape hatch and auto-enables a same-repo live chat card unless chatProgress is off.
|
|
41
41
|
• Example: {workflowScript:"const [a,b]=await runs.all([{key:'a',agent:'agent-a',task:'Implement A',worktree:true},{key:'b',agent:'agent-b',task:'Implement B',worktree:true}]); return [a.output,b.output]"}
|
|
42
42
|
• context can be fresh or fork. timeoutMs/maxRuntimeMs apply to foreground and async workflows; foreground workflows default to 30 minutes and async workflows have no default timeout. Omit acceptance for reviewer/read-only calls.
|
|
43
43
|
|
|
@@ -9,9 +9,8 @@
|
|
|
9
9
|
* This module adds a portable, file-based control inbox inside the run directory.
|
|
10
10
|
* The parent drops an interrupt request file; the runner watches the inbox and
|
|
11
11
|
* routes the request into its existing graceful `interruptRunner()` (pause +
|
|
12
|
-
* resumable), identically on every platform. The
|
|
13
|
-
*
|
|
14
|
-
* authoritative.
|
|
12
|
+
* resumable), identically on every platform. The file inbox is authoritative and
|
|
13
|
+
* avoids signaling a PID that the extension cannot prove belongs to the runner.
|
|
15
14
|
*/
|
|
16
15
|
|
|
17
16
|
import { randomUUID } from "node:crypto";
|
|
@@ -21,13 +20,6 @@ import { writeAtomicJson } from "../../shared/atomic-json.ts";
|
|
|
21
20
|
import { POLL_INTERVAL_MS } from "../../shared/types.ts";
|
|
22
21
|
import { resolveWatchPath } from "../../shared/utils.ts";
|
|
23
22
|
|
|
24
|
-
/**
|
|
25
|
-
* Opportunistic fast-path interrupt signal. On Unix `SIGUSR2` is trapped by the
|
|
26
|
-
* runner; on Windows `process.kill(pid, "SIGBREAK")` is not deliverable
|
|
27
|
-
* cross-process and throws `ENOSYS`, so the file inbox below is the real channel.
|
|
28
|
-
*/
|
|
29
|
-
export const INTERRUPT_SIGNAL: NodeJS.Signals = process.platform === "win32" ? "SIGBREAK" : "SIGUSR2";
|
|
30
|
-
|
|
31
23
|
export type ControlChannelFs = Pick<typeof fs, "mkdirSync" | "existsSync" | "rmSync" | "watch" | "readdirSync" | "readFileSync" | "realpathSync">;
|
|
32
24
|
export type ControlChannelTimers = { setInterval: typeof setInterval; clearInterval: typeof clearInterval };
|
|
33
25
|
type KillFn = (pid: number, signal?: NodeJS.Signals | 0) => unknown;
|
|
@@ -545,38 +537,13 @@ export function consumeCheckpointDecisionRequest(
|
|
|
545
537
|
return undefined;
|
|
546
538
|
}
|
|
547
539
|
|
|
548
|
-
/**
|
|
549
|
-
* Parent side: portable interrupt = authoritative file request + best-effort OS
|
|
550
|
-
* signal. The signal is only a latency optimization on Unix; ENOSYS on Windows
|
|
551
|
-
* is swallowed because the file inbox is authoritative there. Other signal
|
|
552
|
-
* failures are surfaced because they usually mean the runner is not alive to
|
|
553
|
-
* consume the request.
|
|
554
|
-
*/
|
|
540
|
+
/** Parent side: write the authoritative portable interrupt request. */
|
|
555
541
|
export function deliverInterruptRequest(input: {
|
|
556
542
|
asyncDir: string;
|
|
557
|
-
pid?: number;
|
|
558
|
-
kill?: KillFn;
|
|
559
|
-
signal?: NodeJS.Signals;
|
|
560
543
|
now?: () => number;
|
|
561
544
|
source?: string;
|
|
562
545
|
}): void {
|
|
563
|
-
|
|
564
|
-
if (typeof input.pid === "number" && input.pid > 0) {
|
|
565
|
-
try {
|
|
566
|
-
(input.kill ?? process.kill)(input.pid, input.signal ?? INTERRUPT_SIGNAL);
|
|
567
|
-
} catch (error) {
|
|
568
|
-
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOSYS") {
|
|
569
|
-
// File inbox is authoritative when custom cross-process signals are unavailable.
|
|
570
|
-
return;
|
|
571
|
-
}
|
|
572
|
-
try {
|
|
573
|
-
fs.rmSync(requestPath, { force: true });
|
|
574
|
-
} catch {
|
|
575
|
-
// Best effort cleanup; the caller still gets the signal failure.
|
|
576
|
-
}
|
|
577
|
-
throw error;
|
|
578
|
-
}
|
|
579
|
-
}
|
|
546
|
+
requestAsyncInterrupt(input.asyncDir, input.source ? { source: input.source } : {}, { now: input.now });
|
|
580
547
|
}
|
|
581
548
|
|
|
582
549
|
export function deliverTimeoutRequest(input: {
|
|
@@ -2287,7 +2287,7 @@ async function runSubagent(
|
|
|
2287
2287
|
const nestedAsyncDir = run.asyncDir ?? resolveNestedAsyncDir(config.nestedRoute.rootRunId, run);
|
|
2288
2288
|
if (!nestedAsyncDir) continue;
|
|
2289
2289
|
try {
|
|
2290
|
-
deliverInterruptRequest(
|
|
2290
|
+
deliverInterruptRequest({ asyncDir: nestedAsyncDir, source: "ancestor-interrupt" });
|
|
2291
2291
|
} catch (error) {
|
|
2292
2292
|
appendJsonl(eventsPath, JSON.stringify({
|
|
2293
2293
|
type: "subagent.nested.interrupt_failed",
|
|
@@ -173,7 +173,7 @@ export async function steerAsyncRun(input: {
|
|
|
173
173
|
return { content: [{ type: "text", text: `Steering delivered for async run ${status.runId} (request ${requestId}).` }], details: { mode: "management", results: [], steering: preCommitResult } };
|
|
174
174
|
}
|
|
175
175
|
try {
|
|
176
|
-
deliverInterruptRequest({ asyncDir,
|
|
176
|
+
deliverInterruptRequest({ asyncDir, source: "steering-recovery" });
|
|
177
177
|
} catch (error) {
|
|
178
178
|
fs.rmSync(markerPath, { force: true });
|
|
179
179
|
fs.rmSync(claimPath, { force: true });
|
|
@@ -868,8 +868,15 @@ function interruptAsyncRun(
|
|
|
868
868
|
details: { mode: "management", results: [] },
|
|
869
869
|
};
|
|
870
870
|
}
|
|
871
|
+
if (status.mode === "workflow") {
|
|
872
|
+
return {
|
|
873
|
+
content: [{ type: "text", text: `Interrupt is unsupported for async workflow ${target.asyncId}; use stop instead.` }],
|
|
874
|
+
isError: true,
|
|
875
|
+
details: { mode: "management", results: [] },
|
|
876
|
+
};
|
|
877
|
+
}
|
|
871
878
|
try {
|
|
872
|
-
deliverInterruptRequest(
|
|
879
|
+
deliverInterruptRequest({ asyncDir: target.asyncDir, source: "interrupt-action" });
|
|
873
880
|
const tracked = state.asyncJobs.get(target.asyncId);
|
|
874
881
|
if (tracked) {
|
|
875
882
|
delete tracked.activityState;
|
|
@@ -1178,7 +1185,7 @@ function directNestedAsyncInterrupt(target: ResolvedSubagentRunId & { kind: "nes
|
|
|
1178
1185
|
const pid = typeof status?.pid === "number" && status.pid > 0 ? status.pid : run.pid;
|
|
1179
1186
|
if (!status || status.state !== "running" || typeof pid !== "number" || pid <= 0) return undefined;
|
|
1180
1187
|
try {
|
|
1181
|
-
deliverInterruptRequest({ asyncDir,
|
|
1188
|
+
deliverInterruptRequest({ asyncDir, source: "nested-interrupt" });
|
|
1182
1189
|
return { content: [{ type: "text", text: `Interrupt requested for nested async run ${run.id}.` }], details: { mode: "management", results: [] } };
|
|
1183
1190
|
} catch (error) {
|
|
1184
1191
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -4273,7 +4280,8 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
|
|
|
4273
4280
|
}
|
|
4274
4281
|
};
|
|
4275
4282
|
const projectWorkflowActivity = () => {
|
|
4276
|
-
const
|
|
4283
|
+
const steps = status.steps ?? [];
|
|
4284
|
+
const runningSteps = steps.filter((step) => step.status === "running");
|
|
4277
4285
|
const lastActivityAt = runningSteps.reduce<number | undefined>((latest, step) => step.lastActivityAt === undefined ? latest : Math.max(latest ?? step.lastActivityAt, step.lastActivityAt), undefined);
|
|
4278
4286
|
const activeToolStep = runningSteps
|
|
4279
4287
|
.filter((step) => step.currentTool)
|
|
@@ -4286,11 +4294,11 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
|
|
|
4286
4294
|
status.currentTool = activeToolStep?.currentTool;
|
|
4287
4295
|
status.currentToolStartedAt = activeToolStep?.currentToolStartedAt;
|
|
4288
4296
|
status.currentPath = activeToolStep?.currentPath;
|
|
4289
|
-
const turnCounts =
|
|
4290
|
-
const toolCounts =
|
|
4297
|
+
const turnCounts = steps.flatMap((step) => step.turnCount === undefined ? [] : [step.turnCount]);
|
|
4298
|
+
const toolCounts = steps.flatMap((step) => step.toolCount === undefined ? [] : [step.toolCount]);
|
|
4291
4299
|
status.turnCount = turnCounts.length > 0 ? turnCounts.reduce((total, count) => total + count, 0) : undefined;
|
|
4292
4300
|
status.toolCount = toolCounts.length > 0 ? toolCounts.reduce((total, count) => total + count, 0) : undefined;
|
|
4293
|
-
status.currentStep = runningSteps.length === 1 ?
|
|
4301
|
+
status.currentStep = runningSteps.length === 1 ? steps.indexOf(runningSteps[0]!) : undefined;
|
|
4294
4302
|
};
|
|
4295
4303
|
const workflowJob: AsyncJobState = { asyncId: workflowRunId, asyncDir, cwd: workflowCwd, status: "running", sessionId: currentSessionId ?? undefined, mode: "workflow", agents: [], steps: [], startedAt, updatedAt: startedAt, ...(timeout !== undefined ? { timeoutMs: timeout, deadlineAt: startedAt + timeout } : {}), workflow: status.workflow };
|
|
4296
4304
|
deps.state.asyncJobs.set(workflowRunId, workflowJob);
|
|
@@ -105,6 +105,17 @@ const capturedConsole = Object.freeze(Object.fromEntries(
|
|
|
105
105
|
}]),
|
|
106
106
|
));
|
|
107
107
|
|
|
108
|
+
function formatWorkflowScriptSyntaxError(error) {
|
|
109
|
+
const details = error && error.stack ? error.stack : String(error);
|
|
110
|
+
return [
|
|
111
|
+
"workflowScript must be valid JavaScript.",
|
|
112
|
+
"If task text contains Markdown fences or backticks, use an array joined with \"\\n\" or escaped strings instead of a raw backtick template literal.",
|
|
113
|
+
"",
|
|
114
|
+
"Original SyntaxError:",
|
|
115
|
+
details,
|
|
116
|
+
].join("\n");
|
|
117
|
+
}
|
|
118
|
+
|
|
108
119
|
function assertJsonValue(value, path = "emit", seen = new Set()) {
|
|
109
120
|
if (value === null || typeof value === "string" || typeof value === "boolean") return;
|
|
110
121
|
if (typeof value === "number") {
|
|
@@ -143,7 +154,14 @@ parentPort.on("message", async (message) => {
|
|
|
143
154
|
if (message.stateEnabled) sandbox.state = state;
|
|
144
155
|
const context = vm.createContext(sandbox, { codeGeneration: { strings: false, wasm: false } });
|
|
145
156
|
contextObjectPrototype = vm.runInContext("Object.prototype", context);
|
|
146
|
-
|
|
157
|
+
let compiled;
|
|
158
|
+
try {
|
|
159
|
+
compiled = new vm.Script("(async () => {\n" + message.script + "\n})()", { filename: "workflow-script.js" });
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
162
|
+
parentPort.postMessage({ type: "error", error: formatWorkflowScriptSyntaxError(error) });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
147
165
|
const value = await compiled.runInContext(context);
|
|
148
166
|
const persistedValue = value === undefined ? null : value;
|
|
149
167
|
assertJsonValue(persistedValue, "return");
|