@henryqw/pi-subagent 25.0.0 → 25.0.2
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/README.md +4 -2
- package/dist/mcp-role.js +65 -23
- package/dist/runner.js +40 -15
- package/dist/schema.d.ts +72 -0
- package/dist/schema.js +12 -1
- package/extensions/isolated.ts +41 -23
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -126,7 +126,9 @@ An isolated request has one durable ID, one goal, and 1–8 typed tasks:
|
|
|
126
126
|
|
|
127
127
|
`dependsOn` controls scheduling. `contextFrom` also schedules dependencies and carries completed text outputs in the declared order. Do not repeat the same task in both fields. Changesets require focused task checks; a graph with changesets requires final checks. A canonical root `package.json` test script is required: the runner adds `pnpm test` to final checks if missing, and Main runs it on the combined integration checkout before promotion. Checks and judgments bind to exact Git identities; candidate drift, Main drift, mutation during validation, ambiguity, or conflicts stop promotion and retain evidence.
|
|
128
128
|
|
|
129
|
-
`delegate_task`, `subagent_resume`, and `subagent_integrate advance` return a durable request ID after the wave is saved; productive work continues while Main is free. Main can do other work or end its turn instead of sleeping or polling. Pi sends a follow-up in the launching session whenever a checked worker becomes ready or a worker needs attention, and again when the wave finishes. A worker whose exact turn ends without a changed clean commit—including a failed or aborted turn—needs attention, not an inferred candidate; use `subagent_status` for its saved evidence. If delivery is missed, inspect `subagent_status`.
|
|
129
|
+
`delegate_task`, `subagent_resume`, and `subagent_integrate advance` return a durable request ID after the wave is saved; productive work continues while Main is free. Main can do other work or end its turn instead of sleeping or polling. Pi sends a follow-up in the launching session whenever a checked worker becomes ready or a worker needs attention, and again when the wave finishes. A worker whose exact turn ends without a changed clean commit—including a failed or aborted turn—needs attention, not an inferred candidate; use `subagent_status` for its saved evidence. Session replacement or shutdown suppresses stale delivery and logs the request ID and reason, without prompts or credentials. Synchronous delivery failures are reported without automatic replay; Pi reports asynchronous `send_message` failures through its native runtime. If delivery is missed, inspect `subagent_status`.
|
|
130
|
+
|
|
131
|
+
Completed text answers are saved independently of cleanup. Status and terminal follow-ups include a bounded accepted-answer preview and cleanup evidence. Ignored automatic artifacts can retain a checkout without violating the read-only contract; no ignored files are deleted to make cleanup pass. Tracked or untracked changes, changed checkout identity, or unproved inspection still require attention: the completed answer remains saved but is not accepted as dependency context or retried automatically. Retained text checkouts require deliberate manual inspection and cleanup; a retained checkout alone is not evidence that the Role edited source.
|
|
130
132
|
|
|
131
133
|
Isolated workers can start while Main has staged, unstaged, or untracked changes: they use only Main's pinned committed branch, HEAD, and tree. Those local edits are neither copied into worker worktrees nor stashed, staged, reset, or merged automatically. Main must remain at that committed identity while workers run; branch or HEAD drift blocks new waves. The worker produces a committed candidate without tracked or untracked changes, then runs focused preliminary checks and optional judgment. Ignored generated files do not block candidacy, but prevent automatic worktree cleanup; inspect them before deleting retained work. The runner records each ready candidate immediately but **does not integrate it automatically**. Main can inspect `subagent_status` and use `subagent_stage` to select the exact candidate in an owned integration worktree while siblings are still working; conflicts are resolved there. Stage and resolve are available during an active wave, but rejection and revision wait for the wave to settle. `subagent_integrate` can advance dependent tasks from a staged snapshot, validate the combined tip with full root checks and optional final judgment after the wave settles, and promote it to Main only after exact validation. A definitive failed combined check/review allows one committed correction in the integration checkout followed by another validation. Main must explicitly promote; promotion and cleanup never silently discard retained work.
|
|
132
134
|
|
|
@@ -181,7 +183,7 @@ Role Markdown lives in `~/.pi/agent/config/pi-subagent/` and requires frontmatte
|
|
|
181
183
|
|
|
182
184
|
Roles describe responsibility and capabilities. They do not choose isolation; each request does. A same-named user Role overrides a built-in Role. The package ships `implementer`, `reviewer`, and `scout`.
|
|
183
185
|
|
|
184
|
-
Children run with `--no-extensions`, so a Role must list the extension that registers its route model's provider, and only declared resources plus required internal policy adapters load. `builtin:<name>` entries load Pi built-in extensions (`builtin:codemode`, `builtin:tool-search`, `builtin:llama.cpp`) explicitly; `builtin:mcp` is rejected because it would connect every configured server and bypass `mcps`. The servers named in `mcps` come from the global `~/.pi/agent/mcp.json` only; their tools load in the child with direct exposure and are declared to the model as `mcp__<server>__<tool>` once the server connects. Missing Skills, tools, MCP servers, Roles, or routes fail before productive work starts. Main-only delegation and recovery tools plus `ask_question` are excluded from children.
|
|
186
|
+
Children run with `--no-extensions`, so a Role must list the extension that registers its route model's provider, and only declared resources plus required internal policy adapters load. `builtin:<name>` entries load Pi built-in extensions (`builtin:codemode`, `builtin:tool-search`, `builtin:llama.cpp`) explicitly; `builtin:mcp` is rejected because it would connect every configured server and bypass `mcps`. The servers named in `mcps` come from the global `~/.pi/agent/mcp.json` only; their tools load in the child with direct exposure and are declared to the model as `mcp__<server>__<tool>` once the server connects. Pi replaces `-` in server and tool names with `_` and appends a hash suffix when two tool names collide or a name exceeds 64 characters. Each selected entry is checked against Pi's documented `mcpServers` rules before launch, in Main and again in the child: a disabled, malformed, or unknown-transport entry, an invalid server name, two selected names that differ only in `-` and `_`, `auth.provider` over plain HTTP outside `localhost`, `127.0.0.1`, or `[::1]`, or a non-HTTPS `oauth.authServerMetadataUrl` outside those hosts fails the launch with the file path and field. The file is never modified; fix the entry or choose another server. Valid `auth`, `oauth`, and `description` fields are passed through so Pi's native provider auth and OAuth apply. Missing Skills, tools, MCP servers, Roles, or routes fail before productive work starts. Main-only delegation and recovery tools plus `ask_question` are excluded from children.
|
|
185
187
|
|
|
186
188
|
## API
|
|
187
189
|
|
package/dist/mcp-role.js
CHANGED
|
@@ -22,17 +22,69 @@ export function parseRoleMcpAllowlist(value) {
|
|
|
22
22
|
function isRecord(value) {
|
|
23
23
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
24
24
|
}
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
25
|
+
const isString = (value) => typeof value === "string";
|
|
26
|
+
const isStringRecord = (value) => isRecord(value) && Object.values(value).every(isString);
|
|
27
|
+
const parseUrl = (value) => (typeof value === "string" && URL.canParse(value) ? new URL(value) : undefined);
|
|
28
|
+
// Pi's native MCP rules (core/mcp-servers.ts); `validateMcpServerConfig` is not exported, so they are mirrored here.
|
|
29
|
+
const SERVER_NAME = /^[A-Za-z0-9_-]+$/;
|
|
30
|
+
const LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
|
31
|
+
const isHttpsOrLoopback = (url) => url !== undefined && (url.protocol === "https:" || (url.protocol === "http:" && LOOPBACK_HOSTS.includes(url.hostname)));
|
|
32
|
+
/** Tool namespace `mcp__<server>` replaces `-` with `_`, so names differing only there collide. */
|
|
33
|
+
const mcpNamespace = (name) => name.replace(/-/g, "_");
|
|
34
|
+
/** Documented `mcpServers` fields (Pi's docs/mcp.md) and their shapes, by transport; dotted keys are nested. */
|
|
35
|
+
const COMMON_CHECKS = {
|
|
36
|
+
enabled: [(value) => typeof value === "boolean", "a boolean"],
|
|
37
|
+
timeout: [(value) => typeof value === "number" && Number.isFinite(value) && value > 0, "a positive number"],
|
|
38
|
+
description: [isString, "a string"],
|
|
39
|
+
};
|
|
40
|
+
const STDIO_CHECKS = {
|
|
41
|
+
args: [(value) => Array.isArray(value) && value.every(isString), "an array of strings"],
|
|
29
42
|
env: [isStringRecord, "an object of strings"],
|
|
43
|
+
cwd: [isString, "a string"],
|
|
44
|
+
};
|
|
45
|
+
const HTTP_CHECKS = {
|
|
46
|
+
url: [(value) => /^https?:$/.test(parseUrl(value)?.protocol ?? ""), "an http or https URL"],
|
|
30
47
|
headers: [isStringRecord, "an object of strings"],
|
|
31
|
-
cwd: [(value) => typeof value === "string", "a string"],
|
|
32
|
-
timeout: [(value) => typeof value === "number" && Number.isFinite(value) && value > 0, "a positive number"],
|
|
33
|
-
enabled: [(value) => typeof value === "boolean", "a boolean"],
|
|
34
48
|
oauth: [isRecord, "an object"],
|
|
49
|
+
"oauth.clientId": [isString, "a string"],
|
|
50
|
+
"oauth.clientSecret": [isString, "a string"],
|
|
51
|
+
"oauth.callbackPort": [(value) => Number.isInteger(value) && value >= 1 && value <= 65535, "a port number"],
|
|
52
|
+
"oauth.callbackUrl": [(value, server) => {
|
|
53
|
+
const url = parseUrl(value);
|
|
54
|
+
const port = server.oauth.callbackPort;
|
|
55
|
+
return url !== undefined && url.protocol === "http:" && LOOPBACK_HOSTS.includes(url.hostname) && url.search === "" && url.hash === ""
|
|
56
|
+
&& (url.port === "" || port === undefined || Number(url.port) === port);
|
|
57
|
+
}, "an http URL on localhost, 127.0.0.1, or [::1] without query or fragment, on the \"oauth.callbackPort\" port"],
|
|
58
|
+
"oauth.scope": [isString, "a string"],
|
|
59
|
+
"oauth.clientName": [(value) => typeof value === "string" && value.trim() !== "", "a non-empty string"],
|
|
60
|
+
"oauth.authServerMetadataUrl": [(value) => isHttpsOrLoopback(parseUrl(value)), "an https URL, or http on localhost, 127.0.0.1, or [::1]"],
|
|
61
|
+
auth: [(value) => isRecord(value) && typeof value.provider === "string" && value.provider !== "", "an object with a non-empty \"provider\" string"],
|
|
35
62
|
};
|
|
63
|
+
/** Validate one selected server against Pi's documented `mcpServers` rules; the native loader is bypassed by `loadConfig`. */
|
|
64
|
+
function validateRoleMcpServer(path, name, value) {
|
|
65
|
+
const fail = (message) => { throw new Error(`${path}: MCP server "${name}" ${message}`); };
|
|
66
|
+
if (!SERVER_NAME.test(name))
|
|
67
|
+
fail("has an invalid name (use letters, digits, \"_\" and \"-\").");
|
|
68
|
+
if (!isRecord(value))
|
|
69
|
+
fail("must be an object.");
|
|
70
|
+
if (value.enabled === false)
|
|
71
|
+
throw new Error(`${path}: Role MCP server "${name}" is disabled.`);
|
|
72
|
+
const stdio = typeof value.command === "string" && value.command.trim() !== "" && (value.type === undefined || value.type === "stdio");
|
|
73
|
+
const http = typeof value.url === "string" && value.url.trim() !== "" && (value.type === undefined || value.type === "http" || value.type === "streamable-http");
|
|
74
|
+
if (stdio === http)
|
|
75
|
+
fail("needs either a \"command\" (stdio) or a \"url\" (http or streamable-http).");
|
|
76
|
+
for (const [field, [valid, shape]] of Object.entries({ ...COMMON_CHECKS, ...(stdio ? STDIO_CHECKS : HTTP_CHECKS) })) {
|
|
77
|
+
const fieldValue = field.split(".").reduce((parent, key) => (isRecord(parent) ? parent[key] : undefined), value);
|
|
78
|
+
if (fieldValue !== undefined && !valid(fieldValue, value))
|
|
79
|
+
fail(`field "${field}" must be ${shape}.`);
|
|
80
|
+
}
|
|
81
|
+
// The native transport sends the provider's token to `url`, so Pi only allows it over https or loopback.
|
|
82
|
+
if (value.auth !== undefined && !isHttpsOrLoopback(parseUrl(value.url))) {
|
|
83
|
+
fail("field \"auth\" requires \"url\" to use https, or http on localhost, 127.0.0.1, or [::1].");
|
|
84
|
+
}
|
|
85
|
+
const { toolExposure: _toolExposure, ...config } = value;
|
|
86
|
+
return { ...config, exposure: "direct" };
|
|
87
|
+
}
|
|
36
88
|
/**
|
|
37
89
|
* Select the Role's allowlisted servers from the global `mcp.json` in Pi's native `mcpServers`
|
|
38
90
|
* shape; the project `.pi/mcp.json` is ignored. A `--no-extensions` child has no codemode tool, so
|
|
@@ -56,23 +108,13 @@ export function loadRoleMcpConfig(agentDir, allowlist) {
|
|
|
56
108
|
const missing = allowlist.filter((name) => !Object.hasOwn(configured, name));
|
|
57
109
|
if (missing.length)
|
|
58
110
|
throw new Error(`Role MCP servers are not configured: ${missing.join(", ")}.`);
|
|
111
|
+
const namespaces = new Map();
|
|
59
112
|
const servers = allowlist.map((name) => {
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
62
|
-
throw new Error(`${path}: MCP server "${name}"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const stdio = typeof value.command === "string" && value.command.trim() !== "" && (value.type === undefined || value.type === "stdio");
|
|
66
|
-
const http = typeof value.url === "string" && value.url.trim() !== "" && (value.type === undefined || value.type === "http" || value.type === "streamable-http");
|
|
67
|
-
if (stdio === http) {
|
|
68
|
-
throw new Error(`${path}: MCP server "${name}" needs either a "command" (stdio) or a "url" (http or streamable-http).`);
|
|
69
|
-
}
|
|
70
|
-
for (const [field, [valid, shape]] of Object.entries(FIELD_CHECKS)) {
|
|
71
|
-
if (value[field] !== undefined && !valid(value[field]))
|
|
72
|
-
throw new Error(`${path}: MCP server "${name}" field "${field}" must be ${shape}.`);
|
|
73
|
-
}
|
|
74
|
-
const { toolExposure: _toolExposure, ...config } = value;
|
|
75
|
-
return { name, config: { ...config, exposure: "direct" }, source: path, scope: "global" };
|
|
113
|
+
const clash = namespaces.get(mcpNamespace(name));
|
|
114
|
+
if (clash)
|
|
115
|
+
throw new Error(`${path}: Role MCP server "${name}" conflicts with "${clash}": names that differ only in "-" and "_" share one tool namespace.`);
|
|
116
|
+
namespaces.set(mcpNamespace(name), name);
|
|
117
|
+
return { name, config: validateRoleMcpServer(path, name, configured[name]), source: path, scope: "global" };
|
|
76
118
|
});
|
|
77
119
|
return { servers, autoEnableCodemode: false, errors: [] };
|
|
78
120
|
}
|
package/dist/runner.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync, realpathSync } from "node:fs";
|
|
2
|
-
import { createChildWorktree, finalizeChildWorktree } from "./worktree.js";
|
|
2
|
+
import { createChildWorktree, finalizeChildWorktree, inspectWorktreeDirty } from "./worktree.js";
|
|
3
|
+
import { runGit } from "./git-process.js";
|
|
3
4
|
import { IntegrationGit } from "./integration-git.js";
|
|
4
5
|
import { checkBatchPasses, CLEANUP_KINDS, isCleanCommitted, MAX_PERSISTED_RUNTIME_TEXT_BYTES, MAX_POSSIBLE_RESOURCES, parseExecuteRequest, parseIntegrationAction, integrationGenerationPasses, parseResumeRequest, parseStageRequest, reviewEvidencePasses, sameIdentity, RUN_STATE_VERSION, } from "./schema.js";
|
|
5
6
|
import { FileRunStore, ProductiveRunLeaseBusyError } from "./store.js";
|
|
@@ -526,7 +527,9 @@ export class IsolatedRunner {
|
|
|
526
527
|
const { states: entries, invalidIds } = await this.store.list(root);
|
|
527
528
|
return { invalidIds, states: entries.map(({ state }) => state), requests: entries.map(({ state }) => ({
|
|
528
529
|
id: state.request.id, name: state.request.goal,
|
|
529
|
-
status: ["completed", "aborted"].includes(state.status) && (state.tasks.some((task) => task.kind === "changeset"
|
|
530
|
+
status: ["completed", "aborted"].includes(state.status) && (state.tasks.some((task) => task.kind === "changeset"
|
|
531
|
+
? task.attempts.some((attempt) => attempt.cleanup.some((step) => step.status !== "completed"))
|
|
532
|
+
: task.attempts.some((attempt) => attempt.cleanup && attempt.cleanup.outcome !== "pruned"))
|
|
530
533
|
|| state.integration.generations.some((generation) => generation.cleanup?.some((step) => step.status !== "completed")
|
|
531
534
|
|| (generation.worktree && generation.cleanup?.every((step) => step.status === "completed") !== true)))
|
|
532
535
|
? `${state.status} · retained` : state.status,
|
|
@@ -2035,6 +2038,7 @@ export class IsolatedRunner {
|
|
|
2035
2038
|
if (!attempt || attempt.status !== "running")
|
|
2036
2039
|
throw new Error(`Text task ${task.taskId} has no running attempt.`);
|
|
2037
2040
|
const prompt = buildTextTaskPrompt(state.request.goal, request, resolveTextTaskContexts(state, request));
|
|
2041
|
+
let checkoutFailure;
|
|
2038
2042
|
const result = await this.callProductive(handle, scope, async (context) => {
|
|
2039
2043
|
const wave = state.waves.at(-1);
|
|
2040
2044
|
if (!wave?.taskIds.includes(task.taskId))
|
|
@@ -2071,12 +2075,19 @@ export class IsolatedRunner {
|
|
|
2071
2075
|
prepare: async () => ({ launch, task: prompt, cwd: isolated.cwd }),
|
|
2072
2076
|
});
|
|
2073
2077
|
});
|
|
2078
|
+
// Read-only evidence excludes ignored artifacts; deletion safety still includes them.
|
|
2079
|
+
const inspection = await inspectWorktreeDirty(isolated.path, runGit, false);
|
|
2080
|
+
const head = await runGit(["rev-parse", "--verify", "HEAD"], isolated.path);
|
|
2081
|
+
const branch = await runGit(["symbolic-ref", "--quiet", "HEAD"], isolated.path);
|
|
2082
|
+
checkoutFailure = inspection.failure ? `read-only checkout inspection failed: ${inspection.failure}`
|
|
2083
|
+
: inspection.dirty ? "read-only contract violated: tracked or untracked changes remain"
|
|
2084
|
+
: head.code !== 0 || branch.code !== 0 ? "read-only checkout identity could not be proved"
|
|
2085
|
+
: head.stdout.trim() !== isolated.baseCommit || branch.stdout.trim() !== `refs/heads/${isolated.branch}`
|
|
2086
|
+
? "read-only contract violated: checkout HEAD or branch changed" : undefined;
|
|
2074
2087
|
}
|
|
2075
2088
|
finally {
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
throw new Error(`Isolated text task changed its checkout; work was retained at ${cleanup.path} on ${cleanup.branch}.`);
|
|
2079
|
-
}
|
|
2089
|
+
// Retention is evidence, not proof of source edits, and must not mask an answer or executor error.
|
|
2090
|
+
attempt.cleanup = await finalizeChildWorktree(isolated);
|
|
2080
2091
|
}
|
|
2081
2092
|
return result;
|
|
2082
2093
|
});
|
|
@@ -2091,16 +2102,30 @@ export class IsolatedRunner {
|
|
|
2091
2102
|
if (Buffer.byteLength(output, "utf8") > MAX_PERSISTED_RUNTIME_TEXT_BYTES) {
|
|
2092
2103
|
throw new Error(`Text task executor output exceeds ${MAX_PERSISTED_RUNTIME_TEXT_BYTES} UTF-8 bytes.`);
|
|
2093
2104
|
}
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
const
|
|
2100
|
-
if (!
|
|
2101
|
-
throw new Error("
|
|
2105
|
+
try {
|
|
2106
|
+
if (checkoutFailure)
|
|
2107
|
+
throw new Error(`Isolated text task ${checkoutFailure}; inspect ${attempt.cleanup.path} on ${attempt.cleanup.branch}.`);
|
|
2108
|
+
if (attempt.cleanup?.outcome === "recovery")
|
|
2109
|
+
throw new Error(`Isolated text task cleanup requires recovery: ${attempt.cleanup.note}`);
|
|
2110
|
+
const actualMain = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectMainBase({ root: state.root }, context));
|
|
2111
|
+
if (!sameIdentity(actualMain, state.main))
|
|
2112
|
+
throw new Error("Main drifted during text task execution.");
|
|
2113
|
+
const wave = state.waves.at(-1);
|
|
2114
|
+
if (wave.base.head !== state.main.head) {
|
|
2115
|
+
const generation = state.integration.generations.at(-1);
|
|
2116
|
+
if (!generation?.worktree || generation.status === "superseded" || !sameIdentity(generation.combinedTip, wave.base)) {
|
|
2117
|
+
throw new Error("Staged dependency snapshot was superseded during text task execution.");
|
|
2118
|
+
}
|
|
2119
|
+
await this.requireExactIntegration(handle, generation, this.stageReceipts(generation), scope, true);
|
|
2102
2120
|
}
|
|
2103
|
-
|
|
2121
|
+
}
|
|
2122
|
+
catch (error) {
|
|
2123
|
+
this.rethrowStopped(error);
|
|
2124
|
+
// Retain the completed answer without admitting it as dependency context.
|
|
2125
|
+
attempt.status = "completed";
|
|
2126
|
+
attempt.output = { text: output };
|
|
2127
|
+
this.attention(task, `Task dispatch was interrupted: ${errorText(error)}`);
|
|
2128
|
+
throw error;
|
|
2104
2129
|
}
|
|
2105
2130
|
attempt.status = "completed";
|
|
2106
2131
|
attempt.failure = undefined;
|
package/dist/schema.d.ts
CHANGED
|
@@ -877,6 +877,24 @@ declare const TextTaskAttemptSchema: Type.TObject<{
|
|
|
877
877
|
output: Type.TOptional<Type.TObject<{
|
|
878
878
|
text: Type.TString;
|
|
879
879
|
}>>;
|
|
880
|
+
cleanup: Type.TOptional<Type.TUnion<[Type.TObject<{
|
|
881
|
+
outcome: Type.TLiteral<"pruned">;
|
|
882
|
+
path: Type.TString;
|
|
883
|
+
branch: Type.TString;
|
|
884
|
+
}>, Type.TObject<{
|
|
885
|
+
outcome: Type.TLiteral<"retained">;
|
|
886
|
+
path: Type.TString;
|
|
887
|
+
branch: Type.TString;
|
|
888
|
+
commits: Type.TInteger;
|
|
889
|
+
dirty: Type.TBoolean;
|
|
890
|
+
}>, Type.TObject<{
|
|
891
|
+
outcome: Type.TLiteral<"recovery">;
|
|
892
|
+
path: Type.TString;
|
|
893
|
+
branch: Type.TString;
|
|
894
|
+
note: Type.TString;
|
|
895
|
+
commits: Type.TOptional<Type.TInteger>;
|
|
896
|
+
dirty: Type.TOptional<Type.TBoolean>;
|
|
897
|
+
}>]>>;
|
|
880
898
|
}>;
|
|
881
899
|
declare const TextTaskStateSchema: Type.TObject<{
|
|
882
900
|
taskId: Type.TString;
|
|
@@ -889,6 +907,24 @@ declare const TextTaskStateSchema: Type.TObject<{
|
|
|
889
907
|
output: Type.TOptional<Type.TObject<{
|
|
890
908
|
text: Type.TString;
|
|
891
909
|
}>>;
|
|
910
|
+
cleanup: Type.TOptional<Type.TUnion<[Type.TObject<{
|
|
911
|
+
outcome: Type.TLiteral<"pruned">;
|
|
912
|
+
path: Type.TString;
|
|
913
|
+
branch: Type.TString;
|
|
914
|
+
}>, Type.TObject<{
|
|
915
|
+
outcome: Type.TLiteral<"retained">;
|
|
916
|
+
path: Type.TString;
|
|
917
|
+
branch: Type.TString;
|
|
918
|
+
commits: Type.TInteger;
|
|
919
|
+
dirty: Type.TBoolean;
|
|
920
|
+
}>, Type.TObject<{
|
|
921
|
+
outcome: Type.TLiteral<"recovery">;
|
|
922
|
+
path: Type.TString;
|
|
923
|
+
branch: Type.TString;
|
|
924
|
+
note: Type.TString;
|
|
925
|
+
commits: Type.TOptional<Type.TInteger>;
|
|
926
|
+
dirty: Type.TOptional<Type.TBoolean>;
|
|
927
|
+
}>]>>;
|
|
892
928
|
}>>;
|
|
893
929
|
failure: Type.TOptional<Type.TString>;
|
|
894
930
|
}>;
|
|
@@ -903,6 +939,24 @@ declare const TaskStateSchema: Type.TUnion<[Type.TObject<{
|
|
|
903
939
|
output: Type.TOptional<Type.TObject<{
|
|
904
940
|
text: Type.TString;
|
|
905
941
|
}>>;
|
|
942
|
+
cleanup: Type.TOptional<Type.TUnion<[Type.TObject<{
|
|
943
|
+
outcome: Type.TLiteral<"pruned">;
|
|
944
|
+
path: Type.TString;
|
|
945
|
+
branch: Type.TString;
|
|
946
|
+
}>, Type.TObject<{
|
|
947
|
+
outcome: Type.TLiteral<"retained">;
|
|
948
|
+
path: Type.TString;
|
|
949
|
+
branch: Type.TString;
|
|
950
|
+
commits: Type.TInteger;
|
|
951
|
+
dirty: Type.TBoolean;
|
|
952
|
+
}>, Type.TObject<{
|
|
953
|
+
outcome: Type.TLiteral<"recovery">;
|
|
954
|
+
path: Type.TString;
|
|
955
|
+
branch: Type.TString;
|
|
956
|
+
note: Type.TString;
|
|
957
|
+
commits: Type.TOptional<Type.TInteger>;
|
|
958
|
+
dirty: Type.TOptional<Type.TBoolean>;
|
|
959
|
+
}>]>>;
|
|
906
960
|
}>>;
|
|
907
961
|
failure: Type.TOptional<Type.TString>;
|
|
908
962
|
}>, Type.TObject<{
|
|
@@ -1717,6 +1771,24 @@ declare const RunStateSchema: Type.TObject<{
|
|
|
1717
1771
|
output: Type.TOptional<Type.TObject<{
|
|
1718
1772
|
text: Type.TString;
|
|
1719
1773
|
}>>;
|
|
1774
|
+
cleanup: Type.TOptional<Type.TUnion<[Type.TObject<{
|
|
1775
|
+
outcome: Type.TLiteral<"pruned">;
|
|
1776
|
+
path: Type.TString;
|
|
1777
|
+
branch: Type.TString;
|
|
1778
|
+
}>, Type.TObject<{
|
|
1779
|
+
outcome: Type.TLiteral<"retained">;
|
|
1780
|
+
path: Type.TString;
|
|
1781
|
+
branch: Type.TString;
|
|
1782
|
+
commits: Type.TInteger;
|
|
1783
|
+
dirty: Type.TBoolean;
|
|
1784
|
+
}>, Type.TObject<{
|
|
1785
|
+
outcome: Type.TLiteral<"recovery">;
|
|
1786
|
+
path: Type.TString;
|
|
1787
|
+
branch: Type.TString;
|
|
1788
|
+
note: Type.TString;
|
|
1789
|
+
commits: Type.TOptional<Type.TInteger>;
|
|
1790
|
+
dirty: Type.TOptional<Type.TBoolean>;
|
|
1791
|
+
}>]>>;
|
|
1720
1792
|
}>>;
|
|
1721
1793
|
failure: Type.TOptional<Type.TString>;
|
|
1722
1794
|
}>, Type.TObject<{
|
package/dist/schema.js
CHANGED
|
@@ -291,6 +291,17 @@ const TextTaskAttemptSchema = Type.Object({
|
|
|
291
291
|
status: Type.Union([Type.Literal("running"), Type.Literal("completed"), Type.Literal("failed"), Type.Literal("superseded")]),
|
|
292
292
|
failure: OptionalRuntimeTextSchema,
|
|
293
293
|
output: Type.Optional(TextTaskOutputSchema),
|
|
294
|
+
cleanup: Type.Optional(Type.Union([
|
|
295
|
+
Type.Object({ outcome: Type.Literal("pruned"), path: RuntimeTextSchema, branch: RuntimeTextSchema }, { additionalProperties: false }),
|
|
296
|
+
Type.Object({
|
|
297
|
+
outcome: Type.Literal("retained"), path: RuntimeTextSchema, branch: RuntimeTextSchema,
|
|
298
|
+
commits: Type.Integer({ minimum: 0 }), dirty: Type.Boolean(),
|
|
299
|
+
}, { additionalProperties: false }),
|
|
300
|
+
Type.Object({
|
|
301
|
+
outcome: Type.Literal("recovery"), path: RuntimeTextSchema, branch: RuntimeTextSchema, note: RuntimeTextSchema,
|
|
302
|
+
commits: Type.Optional(Type.Integer({ minimum: 0 })), dirty: Type.Optional(Type.Boolean()),
|
|
303
|
+
}, { additionalProperties: false }),
|
|
304
|
+
])),
|
|
294
305
|
}, { additionalProperties: false });
|
|
295
306
|
const TextTaskStateSchema = Type.Object({
|
|
296
307
|
taskId: IdSchema,
|
|
@@ -679,7 +690,7 @@ function validateTextTaskState(taskState) {
|
|
|
679
690
|
? "running"
|
|
680
691
|
: "completed";
|
|
681
692
|
if (taskState.status === "needs_attention"
|
|
682
|
-
? latest && !["failed", "superseded"].includes(latest.status)
|
|
693
|
+
? latest && !["failed", "superseded", "completed"].includes(latest.status)
|
|
683
694
|
: latest?.status !== expectedLatestStatus) {
|
|
684
695
|
throw new Error(`Text task ${taskState.taskId} status ${taskState.status} has an incompatible latest attempt.`);
|
|
685
696
|
}
|
package/extensions/isolated.ts
CHANGED
|
@@ -281,16 +281,24 @@ function publicState(state: RunState, preferredTaskId?: string) {
|
|
|
281
281
|
status: state.status,
|
|
282
282
|
accepted: state.accepted,
|
|
283
283
|
main: state.main,
|
|
284
|
-
tasks: state.tasks.map((task) =>
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
284
|
+
tasks: state.tasks.map((task) => {
|
|
285
|
+
const textAttempt = task.kind === "text" ? task.attempts.at(-1) : undefined;
|
|
286
|
+
return {
|
|
287
|
+
taskId: task.taskId, status: task.status,
|
|
288
|
+
...(task.status === "completed" && textAttempt?.output ? { output: boundedPublicText(textAttempt.output.text) } : {}),
|
|
289
|
+
...(textAttempt?.cleanup ? { cleanup: {
|
|
290
|
+
...textAttempt.cleanup,
|
|
291
|
+
...(textAttempt.cleanup.outcome === "recovery" ? { note: boundedPublicText(textAttempt.cleanup.note) } : {}),
|
|
292
|
+
} } : {}),
|
|
293
|
+
...(task.kind === "changeset" && state.integration.candidates.length ? { attempts: task.attempts.map((attempt) => ({
|
|
294
|
+
number: attempt.number, superseded: attempt.superseded === true,
|
|
295
|
+
workerId: attempt.allocations.flatMap((item) => item.kind === "agent" && item.status === "owned" ? [item.agentName] : [])[0],
|
|
296
|
+
termination: attempt.termination?.status,
|
|
297
|
+
worktree: attempt.allocations.flatMap((item) => item.kind === "worktree" && item.status === "owned" && item.worktree ? [item.worktree.path] : [])[0],
|
|
298
|
+
cleanup: attempt.cleanup.filter((step) => step.status !== "completed"),
|
|
299
|
+
})) } : {}),
|
|
300
|
+
};
|
|
301
|
+
}),
|
|
294
302
|
final: { status: state.final.status },
|
|
295
303
|
integration: {
|
|
296
304
|
...(state.integration.refresh ? { refresh: state.integration.refresh } : {}),
|
|
@@ -499,9 +507,12 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
499
507
|
const sessionId = ctx.sessionManager?.getSessionId();
|
|
500
508
|
const key = `${root}\0${id}`;
|
|
501
509
|
if (jobOwners.has(key)) throw new Error(`Pi Subagent request ${id} is already active in this session runtime.`);
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
510
|
+
const deliverySuppression = () => sessionClosed ? "session closed"
|
|
511
|
+
: sessionEpoch !== epoch ? "session generation changed"
|
|
512
|
+
: ctx.sessionManager?.getSessionId() !== sessionId ? "launching session changed"
|
|
513
|
+
: latestCtx?.sessionManager?.getSessionId() !== sessionId ? "current session changed"
|
|
514
|
+
: undefined;
|
|
515
|
+
const canDeliver = () => deliverySuppression() === undefined;
|
|
505
516
|
const controller = new AbortController();
|
|
506
517
|
activeJobs.add(controller);
|
|
507
518
|
jobOwners.set(key, canDeliver);
|
|
@@ -517,6 +528,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
517
528
|
if (signal?.aborted) abortBeforeAck();
|
|
518
529
|
else signal?.addEventListener("abort", abortBeforeAck, { once: true });
|
|
519
530
|
let latestState: RunState | undefined;
|
|
531
|
+
let durableAcknowledged = false;
|
|
520
532
|
let accept!: (state: RunState) => void;
|
|
521
533
|
let reject!: (error: unknown) => void;
|
|
522
534
|
const durable = new Promise<RunState>((resolve, fail) => { accept = resolve; reject = fail; });
|
|
@@ -525,14 +537,19 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
525
537
|
if (state.root !== root || state.request.id !== id) return;
|
|
526
538
|
latestState = state;
|
|
527
539
|
if (!acknowledged(state)) return;
|
|
540
|
+
durableAcknowledged = true;
|
|
528
541
|
for (const candidate of state.integration.candidates) notified.add(`${candidate.taskId}\0${candidate.attempt}\0${candidate.tip.head}`);
|
|
529
542
|
stateListeners.delete(listener);
|
|
530
543
|
removeTurnAbort();
|
|
531
544
|
accept(state);
|
|
532
545
|
};
|
|
533
546
|
stateListeners.add(listener);
|
|
534
|
-
const deliver = (text: string, response?: RunResponse) => {
|
|
535
|
-
|
|
547
|
+
const deliver = (kind: "candidate" | "attention" | "terminal", text: string, response?: RunResponse) => {
|
|
548
|
+
const suppression = deliverySuppression();
|
|
549
|
+
if (suppression) {
|
|
550
|
+
console.warn(`Pi Subagent ${id}: ${kind} delivery suppressed (${suppression}); durable request retained.`);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
536
553
|
const state = response?.state ?? latestState;
|
|
537
554
|
try {
|
|
538
555
|
const details = state ? {
|
|
@@ -547,13 +564,14 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
547
564
|
details,
|
|
548
565
|
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
549
566
|
} catch {
|
|
550
|
-
console.error(`Pi Subagent ${id}
|
|
567
|
+
console.error(`Pi Subagent ${id}: ${kind} delivery failed (sendMessage threw); use subagent_status to recover. No delivery replayed.`);
|
|
551
568
|
if (ctx.hasUI) ctx.ui.notify(`Pi Subagent ${id} result delivery failed; use subagent_status to recover.`, "error");
|
|
552
569
|
}
|
|
553
570
|
};
|
|
554
571
|
candidateListener = (state) => {
|
|
555
|
-
if (state.root !== root || state.request.id !== id
|
|
572
|
+
if (state.root !== root || state.request.id !== id) return;
|
|
556
573
|
latestState = state;
|
|
574
|
+
if (state.status !== "running") return;
|
|
557
575
|
for (const task of state.tasks) {
|
|
558
576
|
if (task.status !== "needs_attention") {
|
|
559
577
|
attentionNotified.delete(task.taskId);
|
|
@@ -561,13 +579,13 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
561
579
|
}
|
|
562
580
|
if (attentionNotified.has(task.taskId)) continue;
|
|
563
581
|
attentionNotified.add(task.taskId);
|
|
564
|
-
deliver(`Pi Subagent ${id}: ${task.taskId} needs attention. Inspect subagent_status for the saved worker evidence; do not replay an uncertain prompt.`);
|
|
582
|
+
deliver("attention", `Pi Subagent ${id}: ${task.taskId} needs attention. Inspect subagent_status for the saved worker evidence; do not replay an uncertain prompt.`);
|
|
565
583
|
}
|
|
566
584
|
for (const candidate of state.integration.candidates) {
|
|
567
585
|
const identity = `${candidate.taskId}\0${candidate.attempt}\0${candidate.tip.head}`;
|
|
568
586
|
if (candidate.decision || notified.has(identity)) continue;
|
|
569
587
|
notified.add(identity);
|
|
570
|
-
deliver(`Pi Subagent ${id}: ${candidate.taskId} ready to integrate. Inspect the exact candidate with subagent_status and stage it now; other workers may still be running.`);
|
|
588
|
+
deliver("candidate", `Pi Subagent ${id}: ${candidate.taskId} ready to integrate. Inspect the exact candidate with subagent_status and stage it now; other workers may still be running.`);
|
|
571
589
|
}
|
|
572
590
|
};
|
|
573
591
|
stateListeners.add(candidateListener);
|
|
@@ -576,21 +594,21 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
576
594
|
finish();
|
|
577
595
|
stateListeners.delete(listener);
|
|
578
596
|
removeTurnAbort();
|
|
579
|
-
if (!
|
|
597
|
+
if (!durableAcknowledged) {
|
|
580
598
|
reject(new Error(`Pi Subagent ${id} finished without a durable acknowledgement.`));
|
|
581
599
|
return;
|
|
582
600
|
}
|
|
583
|
-
deliver(`${response.text}\n\nState: ${JSON.stringify(publicState(response.state))}`, response);
|
|
601
|
+
deliver("terminal", `${response.text}\n\nState: ${JSON.stringify(publicState(response.state))}`, response);
|
|
584
602
|
},
|
|
585
603
|
(error: unknown) => {
|
|
586
604
|
finish();
|
|
587
605
|
stateListeners.delete(listener);
|
|
588
606
|
removeTurnAbort();
|
|
589
|
-
if (!
|
|
607
|
+
if (!durableAcknowledged) {
|
|
590
608
|
reject(error);
|
|
591
609
|
return;
|
|
592
610
|
}
|
|
593
|
-
deliver(`Pi Subagent ${id} stopped: ${boundedPublicText(error instanceof Error ? error.message : String(error))}. Use subagent_status to inspect the durable request and subagent_resume or subagent_abort for recovery.`);
|
|
611
|
+
deliver("terminal", `Pi Subagent ${id} stopped: ${boundedPublicText(error instanceof Error ? error.message : String(error))}. Use subagent_status to inspect the durable request and subagent_resume or subagent_abort for recovery.`);
|
|
594
612
|
},
|
|
595
613
|
);
|
|
596
614
|
const state = await durable;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@henryqw/pi-subagent",
|
|
3
|
-
"version": "25.0.
|
|
3
|
+
"version": "25.0.2",
|
|
4
4
|
"description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -40,9 +40,9 @@
|
|
|
40
40
|
"pack:check": "npm pack --dry-run"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
|
-
"@earendil-works/pi-ai": ">=0.99.1 <0.100.0",
|
|
44
|
-
"@earendil-works/pi-coding-agent": ">=0.99.1 <0.100.0",
|
|
45
|
-
"@earendil-works/pi-tui": ">=0.99.1 <0.100.0",
|
|
43
|
+
"@earendil-works/pi-ai": ">=0.99.1 <0.100.0 || ^1.0.0",
|
|
44
|
+
"@earendil-works/pi-coding-agent": ">=0.99.1 <0.100.0 || ^1.0.0",
|
|
45
|
+
"@earendil-works/pi-tui": ">=0.99.1 <0.100.0 || ^1.0.0",
|
|
46
46
|
"typebox": "^1.3.15"
|
|
47
47
|
},
|
|
48
48
|
"repository": {
|
|
@@ -74,9 +74,9 @@
|
|
|
74
74
|
"proper-lockfile": "^4.1.2"
|
|
75
75
|
},
|
|
76
76
|
"devDependencies": {
|
|
77
|
-
"@earendil-works/pi-ai": "0.
|
|
78
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
79
|
-
"@earendil-works/pi-tui": "0.
|
|
77
|
+
"@earendil-works/pi-ai": "1.0.0",
|
|
78
|
+
"@earendil-works/pi-coding-agent": "1.0.0",
|
|
79
|
+
"@earendil-works/pi-tui": "1.0.0",
|
|
80
80
|
"@types/proper-lockfile": "^4.1.4"
|
|
81
81
|
}
|
|
82
82
|
}
|