pi-subagents 0.55.0 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +81 -3
- package/agents/claude-code-writer.md +15 -0
- package/agents/claude-code.md +15 -0
- package/agents/codex-exec-writer.md +15 -0
- package/agents/codex-exec.md +15 -0
- package/agents/cursor-agent-writer.md +14 -0
- package/agents/cursor-agent.md +14 -0
- package/docs/agents.md +111 -15
- package/docs/configuration.md +37 -0
- package/docs/extension-api.md +2 -2
- package/docs/models.md +6 -0
- package/docs/observability.md +8 -7
- package/docs/tool-reference.md +11 -1
- package/docs/workflows.md +20 -0
- package/package.json +1 -1
- package/skills/pi-subagents/references/execution-controls.md +1 -1
- package/src/agents/agent-management.ts +13 -5
- package/src/agents/agent-refinements.ts +4 -4
- package/src/agents/agent-serializer.ts +2 -0
- package/src/agents/agents.ts +242 -49
- package/src/agents/builtin-names.ts +6 -0
- package/src/agents/runtime-agent-registry.ts +8 -2
- package/src/api/preflight.ts +22 -3
- package/src/extension/config.ts +70 -0
- package/src/extension/doctor.ts +3 -3
- package/src/extension/index.ts +18 -4
- package/src/extension/public-execution.ts +29 -13
- package/src/extension/rpc.ts +20 -5
- package/src/extension/schemas.ts +9 -3
- package/src/extension/tool-description.ts +11 -9
- package/src/inspectors/herdr/actions.ts +2 -1
- package/src/inspectors/herdr/inspector-runner.ts +2 -10
- package/src/inspectors/herdr/session-roots-codec.ts +42 -0
- package/src/integrations/herdr-status.ts +46 -3
- package/src/runs/background/active-async-capacity.ts +77 -10
- package/src/runs/background/async-execution.ts +148 -27
- package/src/runs/background/async-job-tracker.ts +5 -0
- package/src/runs/background/async-resume.ts +3 -0
- package/src/runs/background/async-retention.ts +20 -3
- package/src/runs/background/async-status.ts +5 -0
- package/src/runs/background/chain-root-attachment.ts +15 -1
- package/src/runs/background/fleet-view.ts +16 -10
- package/src/runs/background/inspect-rpc.ts +8 -8
- package/src/runs/background/result-files.ts +27 -14
- package/src/runs/background/result-watcher.ts +9 -5
- package/src/runs/background/run-status.ts +33 -6
- package/src/runs/background/scheduled-runs.ts +7 -1
- package/src/runs/background/subagent-runner.ts +254 -49
- package/src/runs/background/wait-completions.ts +4 -0
- package/src/runs/foreground/execution.ts +107 -16
- package/src/runs/foreground/foreground-control.ts +6 -0
- package/src/runs/foreground/foreground-history.ts +25 -3
- package/src/runs/foreground/subagent-executor.ts +324 -84
- package/src/runs/foreground/workflow-detach-reconcile.ts +31 -7
- package/src/runs/shared/acceptance.ts +10 -5
- package/src/runs/shared/agent-contract.ts +1 -1
- package/src/runs/shared/child-protocol.ts +21 -7
- package/src/runs/shared/claude-code-adapter.ts +129 -0
- package/src/runs/shared/codex-exec-adapter.ts +129 -0
- package/src/runs/shared/completion-guard.ts +3 -2
- package/src/runs/shared/cursor-agent-adapter.ts +114 -0
- package/src/runs/shared/dynamic-fanout.ts +1 -1
- package/src/runs/shared/extension-bindings.ts +78 -0
- package/src/runs/shared/external-cli-contract.ts +167 -0
- package/src/runs/shared/external-cli-preflight.ts +122 -0
- package/src/runs/shared/external-cli-runner.ts +349 -54
- package/src/runs/shared/fast-mode-extension.ts +10 -0
- package/src/runs/shared/model-exclusions.ts +59 -8
- package/src/runs/shared/model-fallback.ts +25 -8
- package/src/runs/shared/mutation-evidence.ts +150 -0
- package/src/runs/shared/nested-events.ts +3 -1
- package/src/runs/shared/nested-render.ts +2 -2
- package/src/runs/shared/parallel-utils.ts +3 -0
- package/src/runs/shared/pi-args.ts +47 -0
- package/src/runs/shared/process-signal.ts +13 -0
- package/src/runs/shared/run-history.ts +21 -1
- package/src/runs/shared/structured-output.ts +18 -4
- package/src/runs/shared/subagent-prompt-runtime.ts +9 -5
- package/src/shared/fork-context.ts +21 -0
- package/src/shared/formatters.ts +7 -1
- package/src/shared/launch-contract.ts +6 -0
- package/src/shared/pruned-fork.ts +450 -0
- package/src/shared/session-file-trust.ts +19 -0
- package/src/shared/session-tokens.ts +14 -3
- package/src/shared/settings.ts +16 -3
- package/src/shared/shortcuts.ts +17 -0
- package/src/shared/types.ts +166 -9
- package/src/shared/workflow-child-permit.ts +116 -0
- package/src/slash/delegation-adapters.ts +0 -1
- package/src/slash/slash-commands.ts +8 -6
- package/src/tui/fleet-status.ts +27 -10
- package/src/tui/fleet-transcript.ts +11 -5
- package/src/tui/fleet.ts +28 -13
- package/src/tui/render.ts +47 -21
- package/src/workflows/scripted-workflow.ts +298 -30
- package/src/workflows/workflow-child-summary.ts +117 -0
- package/src/workflows/workflow-receipt.ts +119 -4
|
@@ -6,7 +6,7 @@ import { getAgentDir, getProjectConfigDir } from "../shared/utils.ts";
|
|
|
6
6
|
const CUSTOM_TOOL_DESCRIPTION_FILE = "subagent-tool-description.md";
|
|
7
7
|
const CUSTOM_TOOL_DESCRIPTION_MAX_BYTES = 50 * 1024;
|
|
8
8
|
|
|
9
|
-
export const DEFAULT_SUBAGENT_TOOL_DESCRIPTION = `Delegate to configured subagents. For execution, omit action and use {agent, task?} for one child
|
|
9
|
+
export const DEFAULT_SUBAGENT_TOOL_DESCRIPTION = `Delegate to configured subagents. For execution, omit action and use {agent, task?} for one child, workflowScript for inline orchestration, or workflowScriptPath to load a script from the request cwd. The script inputs are mutually exclusive. Use action:'validate' with either script input to check it without launching children. For multi-step or parallel work, make exactly one top-level subagent call with async:true; launch children only inside that workflow and do not make another top-level call for them. Use runs.run('key',{agent,task}) for one child, await runs.all([{key:'a',agent:'reviewer',task:'...'},{key:'b',agent:'reviewer',task:'...'}]) for ordinary parallel children, and read its ordered array result with indexes, destructuring, or .map(...), not by key property. Use action only for management/control. Use guide or the pi-subagents skill for advanced workflow details.`;
|
|
10
10
|
|
|
11
11
|
export const SUBAGENT_TOOL_PROMPT_SNIPPET = "Delegate to subagents; orchestrate in one workflowScript call.";
|
|
12
12
|
|
|
@@ -29,34 +29,36 @@ export const SUBAGENT_SAFETY_GUIDANCE = `SAFETY-CRITICAL SUBAGENT GUIDANCE:
|
|
|
29
29
|
• Keep one writer for the same cwd/worktree. Use fresh-context read-only reviewers for independent review, then have the parent synthesize and apply fixes.
|
|
30
30
|
• Async runs expose asyncId/asyncDir with status.json, events.jsonl, output logs, status via { action: "status", id }, and lifecycle diagnostics via { action: "debug.run", id }. Include output paths and residual risks when reporting results.`;
|
|
31
31
|
|
|
32
|
-
export const FULL_SUBAGENT_TOOL_DESCRIPTION = `Run one child with { agent, task? }; use { workflowScript } for orchestration. Omit action for execution. Use action only for management/control actions.
|
|
32
|
+
export const FULL_SUBAGENT_TOOL_DESCRIPTION = `Run one child with { agent, task? }; use { workflowScript } for inline orchestration or { workflowScriptPath } to load it from the request cwd. The script inputs are mutually exclusive. Omit action for execution. Use action only for management/control actions.
|
|
33
33
|
|
|
34
34
|
EXECUTION:
|
|
35
35
|
• Before executing, use { action: "list" } and run only executable/non-disabled configured agents.
|
|
36
36
|
• When passing an explicit model to a child (on the call or a runs.run/runs.all item), first call { action: "models" } and copy an exact provider/id; bare ids resolve only when unique in the registry, and agent names (e.g. gpt-pro, advisor) are not model ids.
|
|
37
|
-
• SINGLE CHILD: { agent:"worker", task:"..." }. This structured form starts exactly one direct child. Fields such as model, context, cwd, worktree, output, budgets, acceptance, and async apply to that child. Do not combine agent/task with action or
|
|
37
|
+
• SINGLE CHILD: { agent:"worker", task:"..." }. This structured form starts exactly one direct child. Fields such as model, context, cwd, worktree, output, budgets, acceptance, and async apply to that child. Do not combine agent/task with action, workflowScript, or workflowScriptPath.
|
|
38
38
|
• WORKFLOW SCRIPT: { workflowScript: "return runs.run('main', {agent:'worker', task:'...'})" }. Use stable-key runs.run for one child and await runs.all([{key,agent,task}, ...]) for ordinary parallel children. runs.all resolves to an ordered array, not a key map, so use results[0], array destructuring, or results.map((result) => result.output), not results.<key>. Do not read .output from unawaited runs.run launches. Stored runs.run promises are only for advanced rolling fanout and each must later be observed with direct await, Promise.race, or Promise.all. 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. Use top-level await, plain helper functions, or explicit Promise chains; nested async function, arrow, and method helpers are rejected. 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 normally start async unless config sets asyncByDefault:false; set async:true explicitly when async behavior matters. Pass async:false only when the parent must block until completion, never for final reviews or gates. Same-repo blocking workflows default to a live in-chat card; explicit live-card requires same-repository async:false, so async workflows should omit chatProgress or use auto/off. Workflow-level child controls default onto each runs.run launch, and explicit child fields override them. Use {action:"children.list"} to list recent retained workflow children with resumable/not-resumable reasons. Resume only rows reported resumable. For a simple follow-up or implementation challenge, use {action:"resume", id:"run-id", message:"..."}. Resume keeps the stored agent/model/tool contract. If no resumable child is listed, launch a same-role fallback challenge and label it as fallback. Inside workflowScript, continue one with runs.run(key, {resume:"run-id", task:"follow-up"}); workflow resumes wait for completed output, and loops must continue from each latest returned runId. Await runs.steer(key, message, {mode?, index?, ackTimeoutMs?}) to guide a prior keyed child without exposing its run id; receipts are queued, delivered, missed, or failed. Always await or return runs.steer. 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.steer, 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.
|
|
39
|
+
• FILE SCRIPT: { workflowScriptPath:"workflows/review.js" }. Relative paths resolve against the request cwd. The host reads the file before the filesystem-free workflow sandbox starts. Do not combine this field with workflowScript.
|
|
39
40
|
• 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" }
|
|
40
41
|
• 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}" }
|
|
41
|
-
• Optional context is "fresh", "fork", or "profile". profile requires the selected agent's declared defaultContext and ignores config defaultSubagentContext. Explicit fresh/fork wins. When omitted, config defaultSubagentContext wins over agent defaultContext. 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.
|
|
42
|
+
• Optional context is "fresh", "fork", or "profile". profile requires the selected agent's declared defaultContext and ignores config defaultSubagentContext. Explicit fresh/fork wins. When omitted, config defaultSubagentContext wins over agent defaultContext. Config forkContext can summarize transcript overflow with stable recovery refs before spawn without adding another public context value. 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.
|
|
42
43
|
• Durable mission attachment is automatic by default. Use missionId to attach an existing mission, mission:{...} to override auto-create, or mission:false for ephemeral work. A mission object needs exactly one non-empty title or summary; objective and labels are optional. goal may only be true and requires budget:{tokens}.
|
|
43
44
|
|
|
44
45
|
MANAGEMENT / CONTROL (use action; omit execution fields):
|
|
45
|
-
• list, get, models, guide, children.list, create, update, delete, eject, disable, enable, reset, status, debug.run, doctor, grant-spawn-budget, worktree.discard, refine/refine.show/refine.rollback, mission.create/list/show/update/resolve-decision/attach-run/close, inspector.open/status/close, project.open/status/close, and watchdog actions remain available. Use {action:"guide", topic:"overview"} for packaged current-version help; topics are overview, workflows, agents, missions, observability, tool-reference, configuration, models, watchdog, and extension-api.
|
|
46
|
+
• validate checks workflowScript or workflowScriptPath syntax and statically decidable structure without launching children. list, get, models, guide, children.list, create, update, delete, eject, disable, enable, reset, status, debug.run, doctor, grant-spawn-budget, worktree.discard, refine/refine.show/refine.rollback, mission.create/list/show/update/resolve-decision/attach-run/close, inspector.open/status/close, project.open/status/close, and watchdog actions remain available. Use {action:"guide", topic:"overview"} for packaged current-version help; topics are overview, workflows, agents, missions, observability, tool-reference, configuration, models, watchdog, and extension-api.
|
|
46
47
|
• status, interrupt, stop, resume, and steer manage live or persisted runs. Use status view:"fleet" for an overview or view:"transcript" with id and optional index to tail output.
|
|
47
|
-
• Create durable project schedules with { action:"schedule.create", id?, name?, at:"+10m" | ISO, workflowScript:"return runs.run('main', {agent:'worker', task:'...'})" } or
|
|
48
|
+
• Create durable project schedules with { action:"schedule.create", id?, name?, at:"+10m" | ISO, workflowScript:"return runs.run('main', {agent:'worker', task:'...'})" }, or use workflowScriptPath instead. Manage them with schedule.list/show/history/pause/resume/run/run-due/delete. This first slice supports fixed intervals; calendar schedules and schedule mission attachment are deferred.
|
|
48
49
|
|
|
49
50
|
${SUBAGENT_SAFETY_GUIDANCE}`;
|
|
50
51
|
|
|
51
|
-
export const COMPACT_SUBAGENT_TOOL_DESCRIPTION = `Run one child with { agent, task? }; use { workflowScript } for orchestration. Omit action for execution. Use action only for management/control actions.
|
|
52
|
+
export const COMPACT_SUBAGENT_TOOL_DESCRIPTION = `Run one child with { agent, task? }; use { workflowScript } for inline orchestration or { workflowScriptPath } to load it from the request cwd. The script inputs are mutually exclusive. Omit action for execution. Use action only for management/control actions.
|
|
52
53
|
|
|
53
54
|
EXECUTE:
|
|
54
55
|
• Call { action:"list" } first and use only executable/non-disabled agents.
|
|
55
56
|
• Passing an explicit model? Call {action:"models"} first and copy an exact provider/id; bare ids resolve only when unique in the registry; agent names (e.g. gpt-pro, advisor) are not model ids.
|
|
56
|
-
• SINGLE {agent:"worker",task:"..."} starts exactly one direct child. Fields apply to that child. Do not combine agent/task with action or
|
|
57
|
+
• SINGLE {agent:"worker",task:"..."} starts exactly one direct child. Fields apply to that child. Do not combine agent/task with action, workflowScript, or workflowScriptPath.
|
|
57
58
|
• SCRIPT {workflowScript:"return runs.run('main', {agent:'worker', task:'...'})"}. Use stable-key runs.run for one child and await runs.all([{key,agent,task}, ...]) for ordinary parallel work. runs.all resolves to an ordered array, not a key map; use results[0], destructuring, or results.map(...), not results.<key>. Do not read .output from unawaited runs.run launches. Stored runs.run promises are only for advanced rolling fanout and each must later be observed with direct await, Promise.race, or Promise.all. Await runs.steer(key,message,options?) to guide a prior keyed child; it returns queued, delivered, missed, or failed and never accepts a raw run id. Always await or return steering calls. Use {action:"children.list"} for recent retained workflow children and resume only rows reported resumable. Use {action:"resume",id:"run-id",message:"..."} for a simple follow-up or challenge; resume keeps the stored agent/model/tool contract. If none is resumable, launch a same-role fallback challenge and label it as fallback. Inside workflowScript use runs.run(key,{resume:"run-id",task:"follow-up"}) when the script must wait for completion and continue from the latest returned runId. 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 top-level await, plain helper functions, or explicit Promise chains; nested async function, arrow, and method helpers are rejected. 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 normally start async unless config sets asyncByDefault:false; set async:true explicitly when async behavior matters. async:false blocks the parent until completion and auto-enables a same-repo live chat card unless chatProgress is off; explicit live-card requires same-repository async:false, so async workflows should omit chatProgress or use auto/off.
|
|
59
|
+
• FILE SCRIPT {workflowScriptPath:"workflows/review.js"} loads the script on the host relative to the request cwd before sandbox execution. Do not combine it with workflowScript.
|
|
58
60
|
• 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]"}
|
|
59
|
-
• context can be fresh, fork, or profile. profile requires the selected agent's declared defaultContext and ignores defaultSubagentContext. Explicit fresh/fork wins; omitted context follows defaultSubagentContext before agent defaultContext. 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.
|
|
61
|
+
• context can be fresh, fork, or profile. profile requires the selected agent's declared defaultContext and ignores defaultSubagentContext. Explicit fresh/fork wins; omitted context follows defaultSubagentContext before agent defaultContext. Config forkContext can summarize transcript overflow with stable recovery refs before spawn without adding another public context value. 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.
|
|
60
62
|
|
|
61
63
|
MANAGE / CONTROL:
|
|
62
64
|
• Use action without execution fields for list/get/models/guide/authoring, refine/refine.show/refine.rollback, mission, watchdog, status, interrupt, stop, resume, steer, script-only scheduling, diagnostics, and other management actions. guide reads shipped current-version docs by topic.
|
|
@@ -12,6 +12,7 @@ import { readStatus } from "../../shared/utils.ts";
|
|
|
12
12
|
import { resolveSubagentRunId } from "../../runs/background/run-id-resolver.ts";
|
|
13
13
|
import { resolveNodeExecutable } from "../../shared/node-executable.ts";
|
|
14
14
|
import { createHerdrClient, detectHerdr, type HerdrClient, type HerdrErrorCode, type HerdrResult } from "./client.ts";
|
|
15
|
+
import { encodeSessionRoots } from "./session-roots-codec.ts";
|
|
15
16
|
import { formatShellCommand } from "./shell-command.ts";
|
|
16
17
|
|
|
17
18
|
export const HERDR_INSPECTOR_ACTIONS = ["inspector.open", "inspector.status", "inspector.close"] as const;
|
|
@@ -88,7 +89,7 @@ function extractPaneId(value: unknown): string | undefined {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
function inspectorCommand(input: { runnerPath: string; asyncDir: string; runId: string; index?: number; missionPath?: string; allowSteer: boolean; allowStop: boolean; sessionRoots: string[] }): string {
|
|
91
|
-
const args = [input.runnerPath, "--async-dir", input.asyncDir, "--run-id", input.runId, "--allow-steer", String(input.allowSteer), "--allow-stop", String(input.allowStop), "--session-roots",
|
|
92
|
+
const args = [input.runnerPath, "--async-dir", input.asyncDir, "--run-id", input.runId, "--allow-steer", String(input.allowSteer), "--allow-stop", String(input.allowStop), "--session-roots", encodeSessionRoots(input.sessionRoots)];
|
|
92
93
|
if (input.index !== undefined) args.push("--index", String(input.index));
|
|
93
94
|
if (input.missionPath) args.push("--mission-path", input.missionPath);
|
|
94
95
|
return formatShellCommand(resolveNodeExecutable(), args);
|
|
@@ -9,6 +9,7 @@ import { formatAsyncRunTranscript } from "../../runs/background/fleet-view.ts";
|
|
|
9
9
|
import { steeringReceipt } from "../../runs/background/steering.ts";
|
|
10
10
|
import type { AsyncStatus } from "../../shared/types.ts";
|
|
11
11
|
import { readStatus } from "../../shared/utils.ts";
|
|
12
|
+
import { decodeSessionRoots } from "./session-roots-codec.ts";
|
|
12
13
|
|
|
13
14
|
export interface RunnerOptions {
|
|
14
15
|
asyncDir: string;
|
|
@@ -60,16 +61,7 @@ function parseArgs(argv: string[]): RunnerOptions {
|
|
|
60
61
|
const childIndex = indexRaw === undefined ? undefined : Number(indexRaw);
|
|
61
62
|
if (childIndex !== undefined && (!Number.isInteger(childIndex) || childIndex < 0)) throw new Error("--index must be a non-negative integer.");
|
|
62
63
|
const sessionRootsRaw = values.get("--session-roots");
|
|
63
|
-
|
|
64
|
-
if (sessionRootsRaw !== undefined) {
|
|
65
|
-
try {
|
|
66
|
-
const parsed = JSON.parse(sessionRootsRaw) as unknown;
|
|
67
|
-
if (!Array.isArray(parsed) || parsed.some((root) => typeof root !== "string")) throw new Error();
|
|
68
|
-
sessionRoots = parsed;
|
|
69
|
-
} catch {
|
|
70
|
-
throw new Error("--session-roots must be a JSON array of strings.");
|
|
71
|
-
}
|
|
72
|
-
}
|
|
64
|
+
const sessionRoots = sessionRootsRaw === undefined ? [] : decodeSessionRoots(sessionRootsRaw);
|
|
73
65
|
const refreshRaw = values.get("--refresh-ms");
|
|
74
66
|
const refreshMs = refreshRaw === undefined ? 1_500 : Number(refreshRaw);
|
|
75
67
|
if (!Number.isInteger(refreshMs) || refreshMs < 250) throw new Error("--refresh-ms must be an integer >= 250.");
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows PowerShell has no backslash-escape for embedded double quotes: a
|
|
3
|
+
* double-quoted string literal only recognizes `` `" `` or `""` to embed a
|
|
4
|
+
* literal quote, so a naively-quoted JSON array (which is full of `"` and
|
|
5
|
+
* `\` characters) gets truncated or split into multiple argv tokens the
|
|
6
|
+
* moment PowerShell tokenizes the `pane run` command line.
|
|
7
|
+
*
|
|
8
|
+
* Base64 has no quotes, backslashes, or spaces for any shell to mangle, so
|
|
9
|
+
* encoding the `--session-roots` payload sidesteps quoting entirely. It is
|
|
10
|
+
* also plain ASCII, so it never hits shellQuote's Windows quoting branch in
|
|
11
|
+
* a way that could still fail as new characters are added upstream.
|
|
12
|
+
*/
|
|
13
|
+
export function encodeSessionRoots(roots: readonly string[]): string {
|
|
14
|
+
return Buffer.from(JSON.stringify(roots), "utf-8").toString("base64");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseStringArray(value: unknown): string[] | undefined {
|
|
18
|
+
if (!Array.isArray(value) || value.some((root) => typeof root !== "string")) return undefined;
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Decodes a `--session-roots` argument produced by {@link encodeSessionRoots}.
|
|
24
|
+
* Falls back to parsing the value as raw JSON so any externally-launched
|
|
25
|
+
* inspector runner (a cached copy, or a manual invocation) that still passes
|
|
26
|
+
* the legacy unencoded form keeps working.
|
|
27
|
+
*/
|
|
28
|
+
export function decodeSessionRoots(raw: string): string[] {
|
|
29
|
+
try {
|
|
30
|
+
const decoded = parseStringArray(JSON.parse(Buffer.from(raw, "base64").toString("utf-8")));
|
|
31
|
+
if (decoded) return decoded;
|
|
32
|
+
} catch {
|
|
33
|
+
// fall through to legacy raw-JSON parsing below
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const parsed = parseStringArray(JSON.parse(raw));
|
|
37
|
+
if (parsed) return parsed;
|
|
38
|
+
} catch {
|
|
39
|
+
// fall through to the shared error below
|
|
40
|
+
}
|
|
41
|
+
throw new Error("--session-roots must be a base64-encoded or raw JSON array of strings.");
|
|
42
|
+
}
|
|
@@ -3,10 +3,15 @@ import {
|
|
|
3
3
|
SUBAGENT_ASYNC_STARTED_EVENT,
|
|
4
4
|
SUBAGENT_CONTROL_EVENT,
|
|
5
5
|
} from "../shared/types.ts";
|
|
6
|
+
import { previewDisplayText, sanitizeDisplayText } from "../shared/display-text.ts";
|
|
6
7
|
|
|
7
8
|
const DEFAULT_SOURCE = "pi-subagents:herdr";
|
|
8
9
|
const DEFAULT_TTL_MS = 120_000;
|
|
9
10
|
const DEFAULT_REFRESH_MS = 45_000;
|
|
11
|
+
const MAX_TASK_LABEL_CHARS = 80;
|
|
12
|
+
const MAX_TITLE_TASK_CHARS = 42;
|
|
13
|
+
const MAX_WORKFLOW_LABEL_NODES = 128;
|
|
14
|
+
const MAX_WORKFLOW_LABEL_DEPTH = 8;
|
|
10
15
|
|
|
11
16
|
let metadataReportSeq = Date.now() * 1000;
|
|
12
17
|
|
|
@@ -24,6 +29,8 @@ export interface HerdrStatusRun {
|
|
|
24
29
|
id: string;
|
|
25
30
|
agent?: string;
|
|
26
31
|
agents?: string[];
|
|
32
|
+
/** Explicit launch/workflow label only; raw prompts never enter pane metadata. */
|
|
33
|
+
taskLabel?: string;
|
|
27
34
|
needsAttention?: boolean;
|
|
28
35
|
attentionLabel?: string;
|
|
29
36
|
}
|
|
@@ -61,12 +68,42 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
61
68
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
62
69
|
}
|
|
63
70
|
|
|
71
|
+
function boundedTaskLabel(value: unknown, maxChars = MAX_TASK_LABEL_CHARS): string | undefined {
|
|
72
|
+
if (typeof value !== "string") return undefined;
|
|
73
|
+
const normalized = sanitizeDisplayText(value).trim();
|
|
74
|
+
if (!normalized) return undefined;
|
|
75
|
+
return previewDisplayText(normalized, maxChars);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function workflowTaskLabel(data: Record<string, unknown>): string | undefined {
|
|
79
|
+
const explicit = boundedTaskLabel(data.taskLabel);
|
|
80
|
+
if (explicit) return explicit;
|
|
81
|
+
if (!isRecord(data.workflowGraph) || !Array.isArray(data.workflowGraph.nodes)) return undefined;
|
|
82
|
+
const currentNodeId = typeof data.workflowGraph.currentNodeId === "string" ? data.workflowGraph.currentNodeId : undefined;
|
|
83
|
+
const nodes: Record<string, unknown>[] = [];
|
|
84
|
+
const collect = (values: unknown[], depth: number): void => {
|
|
85
|
+
if (depth > MAX_WORKFLOW_LABEL_DEPTH || nodes.length >= MAX_WORKFLOW_LABEL_NODES) return;
|
|
86
|
+
for (const value of values) {
|
|
87
|
+
if (nodes.length >= MAX_WORKFLOW_LABEL_NODES) return;
|
|
88
|
+
if (!isRecord(value)) continue;
|
|
89
|
+
nodes.push(value);
|
|
90
|
+
if (Array.isArray(value.children)) collect(value.children, depth + 1);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
collect(data.workflowGraph.nodes, 0);
|
|
94
|
+
const current = currentNodeId ? nodes.find((node) => node.id === currentNodeId) : undefined;
|
|
95
|
+
const active = current ?? nodes.find((node) => node.status === "running") ?? nodes.find((node) => node.status === "pending");
|
|
96
|
+
return boundedTaskLabel(active?.label);
|
|
97
|
+
}
|
|
98
|
+
|
|
64
99
|
function startedRun(data: unknown): HerdrStatusRun | undefined {
|
|
65
100
|
if (!isRecord(data) || typeof data.id !== "string" || !data.id) return undefined;
|
|
101
|
+
const taskLabel = workflowTaskLabel(data);
|
|
66
102
|
return {
|
|
67
103
|
id: data.id,
|
|
68
104
|
...(typeof data.agent === "string" ? { agent: data.agent } : {}),
|
|
69
105
|
...(Array.isArray(data.agents) && data.agents.every((agent) => typeof agent === "string") ? { agents: data.agents as string[] } : {}),
|
|
106
|
+
...(taskLabel ? { taskLabel } : {}),
|
|
70
107
|
};
|
|
71
108
|
}
|
|
72
109
|
|
|
@@ -113,6 +150,7 @@ export function registerHerdrStatusBridge(options: HerdrStatusBridgeOptions): He
|
|
|
113
150
|
|
|
114
151
|
const activeAgentNames = (): string[] => [...new Set([...runs.values()].flatMap((run) => run.agents?.length ? run.agents : run.agent ? [run.agent] : []))];
|
|
115
152
|
const activeSubagentCount = (): number => [...runs.values()].reduce((total, run) => total + Math.max(1, run.agents?.length ?? (run.agent ? 1 : 0)), 0);
|
|
153
|
+
const activeTaskLabel = (): string | undefined => [...runs.values()].reverse().find((run) => run.taskLabel)?.taskLabel;
|
|
116
154
|
|
|
117
155
|
const label = (includeAttention = false): string => {
|
|
118
156
|
const agents = activeAgentNames();
|
|
@@ -122,15 +160,18 @@ export function registerHerdrStatusBridge(options: HerdrStatusBridgeOptions): He
|
|
|
122
160
|
: "";
|
|
123
161
|
const panes = Math.max(0, options.getProjectPaneCount?.() ?? 0);
|
|
124
162
|
const paneText = panes > 0 ? ` · ${panes} pane${panes === 1 ? "" : "s"}` : "";
|
|
163
|
+
const task = activeTaskLabel();
|
|
164
|
+
const taskText = task ? ` · ${task}` : "";
|
|
125
165
|
const attention = includeAttention && attentionLabels.size > 0 ? " ⚠" : "";
|
|
126
|
-
return `⏳ ${activeCount} subagent${activeCount === 1 ? "" : "s"}${who}${paneText}${attention}`;
|
|
166
|
+
return `⏳ ${activeCount} subagent${activeCount === 1 ? "" : "s"}${who}${paneText}${taskText}${attention}`;
|
|
127
167
|
};
|
|
128
168
|
|
|
129
169
|
const titleSuffix = (): string | undefined => {
|
|
130
170
|
if (runs.size === 0) return undefined;
|
|
131
171
|
const agentNames = activeAgentNames();
|
|
132
172
|
const activeCount = activeSubagentCount();
|
|
133
|
-
const
|
|
173
|
+
const task = boundedTaskLabel(activeTaskLabel(), MAX_TITLE_TASK_CHARS);
|
|
174
|
+
const target = task ?? (activeCount === 1 && agentNames.length === 1 ? agentNames[0]! : String(activeCount));
|
|
134
175
|
return `⏳${target}${attentionLabels.size > 0 ? "⚠" : ""}`;
|
|
135
176
|
};
|
|
136
177
|
|
|
@@ -259,7 +300,9 @@ export function registerHerdrStatusBridge(options: HerdrStatusBridgeOptions): He
|
|
|
259
300
|
for (const run of nextRuns) {
|
|
260
301
|
if (!run || typeof run.id !== "string" || !run.id) continue;
|
|
261
302
|
activeIds.add(run.id);
|
|
262
|
-
|
|
303
|
+
const taskLabel = boundedTaskLabel(run.taskLabel);
|
|
304
|
+
const { taskLabel: _rawTaskLabel, ...sanitizedRun } = run;
|
|
305
|
+
runs.set(run.id, { ...sanitizedRun, ...(taskLabel ? { taskLabel } : {}) });
|
|
263
306
|
if (!run.needsAttention) {
|
|
264
307
|
acknowledgedAttention.delete(run.id);
|
|
265
308
|
} else if (!acknowledgedAttention.has(run.id)) {
|
|
@@ -4,9 +4,13 @@ import * as path from "node:path";
|
|
|
4
4
|
import { writePrivateAtomicJson } from "../../shared/atomic-json.ts";
|
|
5
5
|
import { TEMP_ROOT_DIR, type ActiveAsyncCapacitySnapshot, type AsyncStatus } from "../../shared/types.ts";
|
|
6
6
|
import { readStatus } from "../../shared/utils.ts";
|
|
7
|
+
import { checkPidLiveness, type PidLiveness } from "./stale-run-reconciler.ts";
|
|
7
8
|
import { readProcessTerminal } from "./process-terminal.ts";
|
|
8
9
|
|
|
9
10
|
export const ACTIVE_ASYNC_CAPACITY_DIR = path.join(TEMP_ROOT_DIR, "session-active-async-capacity");
|
|
11
|
+
export const DEFAULT_ABANDONED_SLOT_RELEASE_AFTER_MS = 20 * 60 * 1000;
|
|
12
|
+
export const MIN_ABANDONED_SLOT_RELEASE_AFTER_MS = 5 * 60 * 1000;
|
|
13
|
+
export const MAX_ABANDONED_SLOT_RELEASE_AFTER_MS = 24 * 60 * 60 * 1000;
|
|
10
14
|
|
|
11
15
|
export interface ActiveAsyncCapacityOwnerV1 {
|
|
12
16
|
version: 1;
|
|
@@ -36,11 +40,21 @@ interface CapacityOptions {
|
|
|
36
40
|
rootDir?: string;
|
|
37
41
|
now?: () => number;
|
|
38
42
|
token?: () => string;
|
|
43
|
+
abandonedSlotReleaseAfterMs?: number | false;
|
|
44
|
+
pidLiveness?: (pid: number) => PidLiveness;
|
|
39
45
|
afterSlotRename?: (releasedDir: string) => void;
|
|
40
46
|
}
|
|
41
47
|
|
|
48
|
+
export interface ActiveAsyncCapacityReleaseEvidence {
|
|
49
|
+
releasedBy: "abandoned-timeout";
|
|
50
|
+
processProof: "unknown";
|
|
51
|
+
runnerPid: "gone";
|
|
52
|
+
lastActivityAgeMs: number;
|
|
53
|
+
abandonedSlotReleaseAfterMs: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
42
56
|
export type ActiveAsyncCapacityReleaseVerdict =
|
|
43
|
-
| { state: "releasable"; reason: string }
|
|
57
|
+
| { state: "releasable"; reason: string; evidence?: ActiveAsyncCapacityReleaseEvidence }
|
|
44
58
|
| { state: "retained"; reason: string }
|
|
45
59
|
| { state: "not-owned"; reason: string };
|
|
46
60
|
|
|
@@ -66,6 +80,12 @@ export function resolveMaxActiveAsyncRunsPerSession(value: unknown): number | un
|
|
|
66
80
|
return value === 0 ? undefined : value;
|
|
67
81
|
}
|
|
68
82
|
|
|
83
|
+
export function resolveAbandonedSlotReleaseAfterMs(value: unknown): number | false {
|
|
84
|
+
if (value === false) return false;
|
|
85
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) return DEFAULT_ABANDONED_SLOT_RELEASE_AFTER_MS;
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
|
|
69
89
|
export function activeAsyncCapacitySessionKey(sessionId: string): string {
|
|
70
90
|
return createHash("sha256").update(sessionId).digest("hex");
|
|
71
91
|
}
|
|
@@ -154,7 +174,7 @@ function withSlotClaim<T>(dir: string, operation: () => T): { acquired: true; va
|
|
|
154
174
|
}
|
|
155
175
|
}
|
|
156
176
|
|
|
157
|
-
function removeOwnedSlot(dir: string, expected: ActiveAsyncCapacityOwnerV1, options: CapacityOptions, requireUnstarted = false): boolean {
|
|
177
|
+
function removeOwnedSlot(dir: string, expected: ActiveAsyncCapacityOwnerV1, options: CapacityOptions, requireUnstarted = false, release?: ActiveAsyncCapacityReleaseVerdict): boolean {
|
|
158
178
|
if (requireUnstarted && (expected.runnerProcessInstanceId || expected.runnerStartedAt)) return false;
|
|
159
179
|
const claimed = withSlotClaim(dir, () => {
|
|
160
180
|
const current = matchingOwner(dir, expected);
|
|
@@ -162,17 +182,36 @@ function removeOwnedSlot(dir: string, expected: ActiveAsyncCapacityOwnerV1, opti
|
|
|
162
182
|
const releasedDir = path.join(path.dirname(dir), `.${path.basename(dir)}.released-${randomUUID()}`);
|
|
163
183
|
fs.renameSync(dir, releasedDir);
|
|
164
184
|
options.afterSlotRename?.(releasedDir);
|
|
185
|
+
if (release?.state === "releasable" && release.evidence) {
|
|
186
|
+
appendAbandonedReleaseEvent(expected.asyncDir, expected, release.evidence, options.now?.() ?? Date.now());
|
|
187
|
+
}
|
|
165
188
|
fs.rmSync(releasedDir, { recursive: true, force: true });
|
|
166
189
|
return true;
|
|
167
190
|
});
|
|
168
191
|
return claimed.acquired && claimed.value;
|
|
169
192
|
}
|
|
170
193
|
|
|
194
|
+
function appendAbandonedReleaseEvent(asyncDir: string, owner: ActiveAsyncCapacityOwnerV1, evidence: ActiveAsyncCapacityReleaseEvidence, now: number): void {
|
|
195
|
+
try {
|
|
196
|
+
const eventsPath = path.join(asyncDir, "events.jsonl");
|
|
197
|
+
fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
|
|
198
|
+
fs.appendFileSync(eventsPath, `${JSON.stringify({
|
|
199
|
+
type: "subagent.capacity.released",
|
|
200
|
+
ts: now,
|
|
201
|
+
runId: owner.runId,
|
|
202
|
+
sessionId: owner.ownerSessionId,
|
|
203
|
+
...evidence,
|
|
204
|
+
})}\n`, "utf-8");
|
|
205
|
+
} catch {
|
|
206
|
+
// Capacity release must not fail because its diagnostic event cannot be written.
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
171
210
|
function terminalState(state: AsyncStatus["state"]): boolean {
|
|
172
211
|
return state !== "queued" && state !== "running" && state !== "paused";
|
|
173
212
|
}
|
|
174
213
|
|
|
175
|
-
function runnerReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus | null): ActiveAsyncCapacityReleaseVerdict {
|
|
214
|
+
function runnerReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus | null, options: CapacityOptions): ActiveAsyncCapacityReleaseVerdict {
|
|
176
215
|
if (!status) return { state: "retained", reason: "status file is missing or unreadable" };
|
|
177
216
|
if (!owner.runnerProcessInstanceId) return { state: "retained", reason: "runner process identity has not been recorded" };
|
|
178
217
|
if (status.sessionId !== owner.ownerSessionId) return { state: "retained", reason: `status session ${status.sessionId ?? "unknown"} does not match owner session ${owner.ownerSessionId}` };
|
|
@@ -191,7 +230,34 @@ function runnerReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, status: AsyncSt
|
|
|
191
230
|
&& proof.runId === owner.runId
|
|
192
231
|
&& proof.runnerProcessInstanceId === owner.runnerProcessInstanceId
|
|
193
232
|
? { state: "releasable", reason: "matching observed process-terminal proof is present" }
|
|
194
|
-
:
|
|
233
|
+
: abandonedRunnerReleaseVerdict(owner, status, proof?.state ?? "missing", options);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function abandonedRunnerReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus, proofState: string, options: CapacityOptions): ActiveAsyncCapacityReleaseVerdict {
|
|
237
|
+
const proofReason = `process-terminal proof is ${proofState}`;
|
|
238
|
+
const thresholdMs = resolveAbandonedSlotReleaseAfterMs(options.abandonedSlotReleaseAfterMs);
|
|
239
|
+
if (thresholdMs === false) return { state: "retained", reason: `${proofReason}; abandoned-timeout policy is disabled` };
|
|
240
|
+
if (status.state !== "failed") return { state: "retained", reason: `${proofReason}; abandoned-timeout policy requires a failed run, not ${status.state}` };
|
|
241
|
+
if (typeof status.pid !== "number" || !Number.isInteger(status.pid) || status.pid < 1) return { state: "retained", reason: `${proofReason}; runner PID is missing or invalid` };
|
|
242
|
+
const liveness = (options.pidLiveness ?? checkPidLiveness)(status.pid);
|
|
243
|
+
if (liveness !== "dead") return { state: "retained", reason: `${proofReason}; runner PID liveness is ${liveness}` };
|
|
244
|
+
const lastActivityAt = status.lastActivityAt ?? status.lastUpdate ?? status.endedAt;
|
|
245
|
+
if (typeof lastActivityAt !== "number" || !Number.isFinite(lastActivityAt)) return { state: "retained", reason: `${proofReason}; last activity timestamp is missing or invalid` };
|
|
246
|
+
const now = options.now?.() ?? Date.now();
|
|
247
|
+
const lastActivityAgeMs = Math.max(0, now - lastActivityAt);
|
|
248
|
+
if (lastActivityAgeMs <= thresholdMs) return { state: "retained", reason: `${proofReason}; last activity age ${lastActivityAgeMs}ms has not exceeded abandoned-timeout ${thresholdMs}ms` };
|
|
249
|
+
const evidence: ActiveAsyncCapacityReleaseEvidence = {
|
|
250
|
+
releasedBy: "abandoned-timeout",
|
|
251
|
+
processProof: "unknown",
|
|
252
|
+
runnerPid: "gone",
|
|
253
|
+
lastActivityAgeMs,
|
|
254
|
+
abandonedSlotReleaseAfterMs: thresholdMs,
|
|
255
|
+
};
|
|
256
|
+
return {
|
|
257
|
+
state: "releasable",
|
|
258
|
+
reason: `${evidence.releasedBy}: ${proofReason}; runner PID is gone; process proof unknown; last activity age ${lastActivityAgeMs}ms exceeds ${thresholdMs}ms`,
|
|
259
|
+
evidence,
|
|
260
|
+
};
|
|
195
261
|
}
|
|
196
262
|
|
|
197
263
|
function workflowReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus | null, liveWorkflowRunIds: ReadonlySet<string>): ActiveAsyncCapacityReleaseVerdict {
|
|
@@ -222,10 +288,10 @@ function workflowReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, status: Async
|
|
|
222
288
|
return { state: "releasable", reason: "workflow is terminal, controller is gone, and async children have observed proof" };
|
|
223
289
|
}
|
|
224
290
|
|
|
225
|
-
function ownerReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, liveWorkflowRunIds: ReadonlySet<string
|
|
291
|
+
function ownerReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, liveWorkflowRunIds: ReadonlySet<string>, options: CapacityOptions): ActiveAsyncCapacityReleaseVerdict {
|
|
226
292
|
const status = readStatus(owner.asyncDir);
|
|
227
293
|
return owner.kind === "runner"
|
|
228
|
-
? runnerReleaseVerdict(owner, status)
|
|
294
|
+
? runnerReleaseVerdict(owner, status, options)
|
|
229
295
|
: workflowReleaseVerdict(owner, status, liveWorkflowRunIds);
|
|
230
296
|
}
|
|
231
297
|
|
|
@@ -262,7 +328,7 @@ export function inspectActiveAsyncCapacityOwner(
|
|
|
262
328
|
release: { state: "not-owned", reason: `slot was transferred to ${owner.runId}` },
|
|
263
329
|
};
|
|
264
330
|
}
|
|
265
|
-
return { owner, relation: "current", slotDir: dir, release: ownerReleaseVerdict(owner, liveWorkflowRunIds) };
|
|
331
|
+
return { owner, relation: "current", slotDir: dir, release: ownerReleaseVerdict(owner, liveWorkflowRunIds, options) };
|
|
266
332
|
}
|
|
267
333
|
}
|
|
268
334
|
return { relation: "none", release: { state: "not-owned", reason: "no active-capacity slot records this run" } };
|
|
@@ -281,9 +347,10 @@ export function reconcileActiveAsyncCapacity(
|
|
|
281
347
|
if (!owner
|
|
282
348
|
|| owner.ownerSessionId !== sessionId
|
|
283
349
|
|| owner.ownerSessionKey !== activeAsyncCapacitySessionKey(sessionId)
|
|
284
|
-
|| path.basename(dir) !== `slot-${owner.slot}`
|
|
285
|
-
|
|
286
|
-
|
|
350
|
+
|| path.basename(dir) !== `slot-${owner.slot}`) continue;
|
|
351
|
+
const release = ownerReleaseVerdict(owner, liveWorkflowRunIds, options);
|
|
352
|
+
if (release.state !== "releasable") continue;
|
|
353
|
+
removeOwnedSlot(dir, owner, options, false, release);
|
|
287
354
|
}
|
|
288
355
|
return snapshotFor(sessionId, limit, rootDir);
|
|
289
356
|
}
|