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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.45.0",
3
+ "version": "0.45.1",
4
4
  "description": "Pi extension for single-agent delegation and scripted multi-agent workflows",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
@@ -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 OS signal is kept only as an
13
- * opportunistic fast-path; its failure is non-fatal because the file inbox is
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
- const requestPath = requestAsyncInterrupt(input.asyncDir, input.source ? { source: input.source } : {}, { now: input.now });
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(omitUndefinedProperties({ asyncDir: nestedAsyncDir, pid: run.pid, source: "ancestor-interrupt" }));
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, pid: latest?.pid ?? status.pid, kill: input.kill, source: "steering-recovery" });
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(omitUndefinedProperties({ asyncDir: target.asyncDir, pid: status.pid, kill, source: "interrupt-action" }));
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, pid, source: "nested-interrupt" });
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 runningSteps = (status.steps ?? []).filter((step) => step.status === "running");
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 = (status.steps ?? []).flatMap((step) => step.turnCount === undefined ? [] : [step.turnCount]);
4290
- const toolCounts = (status.steps ?? []).flatMap((step) => step.toolCount === undefined ? [] : [step.toolCount]);
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 ? status.steps?.indexOf(runningSteps[0]!) : undefined;
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
- const compiled = new vm.Script("(async () => {\n" + message.script + "\n})()", { filename: "workflow-script.js" });
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");