pi-subagents 0.34.0 → 0.35.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 +78 -9
- package/README.md +213 -32
- package/index.ts +1 -0
- package/install.mjs +1 -1
- package/package.json +23 -8
- package/prompts/review-loop.md +3 -1
- package/skills/pi-subagents/SKILL.md +87 -25
- package/src/agents/agent-management.ts +82 -15
- package/src/agents/agent-serializer.ts +19 -0
- package/src/agents/agents.ts +91 -49
- package/src/agents/frontmatter.ts +67 -13
- package/src/agents/skills.ts +25 -12
- package/src/api/background-work.ts +197 -0
- package/src/api/delegation.ts +158 -0
- package/src/extension/chain-validation.ts +165 -0
- package/src/extension/doctor.ts +15 -0
- package/src/extension/fanout-child.ts +3 -1
- package/src/extension/index.ts +65 -124
- package/src/extension/rpc.ts +10 -2
- package/src/extension/schemas.ts +18 -14
- package/src/extension/steering-notices.ts +35 -0
- package/src/extension/tool-description.ts +20 -9
- package/src/intercom/intercom-bridge.ts +3 -2
- package/src/intercom/native-supervisor-channel.ts +9 -1
- package/src/intercom/result-intercom.ts +4 -0
- package/src/runs/background/async-execution.ts +293 -44
- package/src/runs/background/async-job-tracker.ts +56 -9
- package/src/runs/background/async-resume.ts +159 -52
- package/src/runs/background/async-status.ts +25 -18
- package/src/runs/background/auto-drain.ts +67 -0
- package/src/runs/background/chain-root-attachment.ts +16 -8
- package/src/runs/background/control-channel.ts +260 -13
- package/src/runs/background/fleet-view.ts +23 -2
- package/src/runs/background/notify.ts +79 -10
- package/src/runs/background/result-watcher.ts +12 -9
- package/src/runs/background/run-id-resolver.ts +14 -2
- package/src/runs/background/run-status.ts +23 -15
- package/src/runs/background/scheduled-runs.ts +3 -0
- package/src/runs/background/stale-run-reconciler.ts +32 -10
- package/src/runs/background/steering.ts +237 -0
- package/src/runs/background/subagent-runner.ts +898 -236
- package/src/runs/background/subagent-wait.ts +484 -0
- package/src/runs/background/top-level-async.ts +2 -1
- package/src/runs/background/wait-config.ts +36 -0
- package/src/runs/background/wait-tool.ts +26 -0
- package/src/runs/foreground/async-steering-action.ts +230 -0
- package/src/runs/foreground/chain-clarify.ts +22 -6
- package/src/runs/foreground/chain-execution.ts +50 -32
- package/src/runs/foreground/execution.ts +308 -94
- package/src/runs/foreground/subagent-executor.ts +592 -268
- package/src/runs/shared/acceptance.ts +355 -97
- package/src/runs/shared/child-protocol.ts +121 -0
- package/src/runs/shared/completion-guard.ts +8 -127
- package/src/runs/shared/dynamic-fanout.ts +6 -4
- package/src/runs/shared/model-fallback.ts +36 -0
- package/src/runs/shared/nested-events.ts +9 -4
- package/src/runs/shared/nested-render.ts +4 -1
- package/src/runs/shared/parallel-utils.ts +7 -0
- package/src/runs/shared/pi-args.ts +34 -7
- package/src/runs/shared/pi-spawn.ts +18 -12
- package/src/runs/shared/session-lease.ts +279 -0
- package/src/runs/shared/single-output.ts +61 -6
- package/src/runs/shared/spawn-budget.ts +128 -0
- package/src/runs/shared/subagent-control.ts +10 -6
- package/src/runs/shared/subagent-prompt-runtime.ts +127 -26
- package/src/runs/shared/task-intent.ts +176 -0
- package/src/runs/shared/tool-availability.ts +65 -0
- package/src/runs/shared/turn-budget.ts +49 -4
- package/src/shared/atomic-json.ts +4 -1
- package/src/shared/fork-context.ts +28 -3
- package/src/shared/model-info.ts +7 -4
- package/src/shared/status-format.ts +7 -1
- package/src/shared/types.ts +203 -25
- package/src/shared/utils.ts +35 -7
- package/src/slash/delegation-adapters.ts +457 -0
- package/src/slash/delegation-request.ts +103 -0
- package/src/slash/prompt-template-bridge.ts +167 -344
- package/src/slash/slash-commands.ts +239 -6
- package/src/slash/subagents-admin.ts +428 -0
- package/src/slash/subagents-editor.ts +86 -0
- package/src/tui/fleet.ts +405 -0
- package/src/tui/render.ts +90 -16
- package/src/watchdog/change-signature.ts +127 -0
- package/src/watchdog/child-status.ts +205 -0
- package/src/watchdog/emission-guard.ts +123 -0
- package/src/watchdog/lsp-diagnostics.ts +532 -0
- package/src/watchdog/model-selection.ts +167 -0
- package/src/watchdog/register-child.ts +117 -0
- package/src/watchdog/register-main.ts +433 -0
- package/src/watchdog/render.ts +54 -0
- package/src/watchdog/review.ts +293 -0
- package/src/watchdog/runtime.ts +712 -0
- package/src/watchdog/settings.ts +528 -0
- package/src/watchdog/tool-actions.ts +155 -0
- package/src/watchdog/turn-delta.ts +161 -0
- package/src/watchdog/types.ts +188 -0
- package/src/watchdog/warning-format.ts +73 -0
- package/src/runs/background/wait.ts +0 -394
|
@@ -5,6 +5,57 @@ function escapeRegex(s: string): string {
|
|
|
5
5
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Fold a YAML folded block scalar while preserving more-indented lines and
|
|
10
|
+
* every blank-line separator. Trailing whitespace is trimmed.
|
|
11
|
+
*/
|
|
12
|
+
function foldBlock(block: string): string {
|
|
13
|
+
let folded = "";
|
|
14
|
+
let hasContent = false;
|
|
15
|
+
let previousIsMoreIndented = false;
|
|
16
|
+
let blankLines = 0;
|
|
17
|
+
|
|
18
|
+
for (const line of block.split("\n")) {
|
|
19
|
+
const current = line.trimEnd();
|
|
20
|
+
if (current.trim() === "") {
|
|
21
|
+
if (hasContent) blankLines++;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const currentIsMoreIndented = current.length > current.trimStart().length;
|
|
26
|
+
if (hasContent) {
|
|
27
|
+
if (blankLines > 0) {
|
|
28
|
+
folded += "\n".repeat(blankLines + (previousIsMoreIndented || currentIsMoreIndented ? 1 : 0));
|
|
29
|
+
} else {
|
|
30
|
+
folded += previousIsMoreIndented || currentIsMoreIndented ? "\n" : " ";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
folded += current;
|
|
34
|
+
hasContent = true;
|
|
35
|
+
previousIsMoreIndented = currentIsMoreIndented;
|
|
36
|
+
blankLines = 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return folded.trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Normalize a simple-scalar frontmatter list from comma-separated or block-list syntax.
|
|
44
|
+
* Only the standard `- item` marker is removed; ordinary hyphenated values stay intact.
|
|
45
|
+
*/
|
|
46
|
+
export function parseFrontmatterList(raw: string | undefined): string[] | undefined {
|
|
47
|
+
if (raw === undefined) return undefined;
|
|
48
|
+
return raw
|
|
49
|
+
.split("\n")
|
|
50
|
+
.flatMap((line) => {
|
|
51
|
+
const value = line.trim();
|
|
52
|
+
const listItem = value.match(/^-\s+(.+)$/);
|
|
53
|
+
return (listItem?.[1] ?? value).split(",");
|
|
54
|
+
})
|
|
55
|
+
.map((value) => value.trim())
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
}
|
|
58
|
+
|
|
8
59
|
/**
|
|
9
60
|
* Parse YAML frontmatter from agent/chain files.
|
|
10
61
|
* Handles both flat (key: value) and nested block (key: \n sub: val) values.
|
|
@@ -31,12 +82,13 @@ export function parseFrontmatter(content: string): { frontmatter: Record<string,
|
|
|
31
82
|
let currentKey: string | null = null;
|
|
32
83
|
let currentBlockLines: string[] | null = null;
|
|
33
84
|
let currentIndent: number | null = null;
|
|
85
|
+
let currentFolded = false;
|
|
34
86
|
|
|
35
87
|
for (const line of lines) {
|
|
36
88
|
const indent = line.search(/\S|$/); // position of first non-whitespace char
|
|
37
89
|
const trimmed = line.trim();
|
|
38
90
|
|
|
39
|
-
if (currentKey !== null && currentBlockLines !== null && indent > (currentIndent ?? 0)) {
|
|
91
|
+
if (currentKey !== null && currentBlockLines !== null && (indent > (currentIndent ?? 0) || (currentFolded && trimmed === ""))) {
|
|
40
92
|
// This line is part of the current block value
|
|
41
93
|
currentBlockLines.push(line);
|
|
42
94
|
continue;
|
|
@@ -47,29 +99,31 @@ export function parseFrontmatter(content: string): { frontmatter: Record<string,
|
|
|
47
99
|
// Strip the common leading whitespace from the block so the
|
|
48
100
|
// serializer can add its own indentation level.
|
|
49
101
|
const rawBlock = currentBlockLines.join("\n");
|
|
50
|
-
const leadingSpaces = rawBlock.match(/^
|
|
51
|
-
const prefix = leadingSpaces?.[
|
|
102
|
+
const leadingSpaces = rawBlock.match(/^[ \t]+(?=\S)/m);
|
|
103
|
+
const prefix = leadingSpaces?.[0] ?? "";
|
|
52
104
|
const stripped = prefix
|
|
53
105
|
? rawBlock.replace(new RegExp(`^${escapeRegex(prefix)}`, "gm"), "").replace(/^\n/, "")
|
|
54
106
|
: rawBlock;
|
|
55
|
-
frontmatter[currentKey] = stripped;
|
|
107
|
+
frontmatter[currentKey] = currentFolded ? foldBlock(stripped) : stripped;
|
|
56
108
|
currentKey = null;
|
|
57
109
|
currentBlockLines = null;
|
|
58
110
|
currentIndent = null;
|
|
111
|
+
currentFolded = false;
|
|
59
112
|
}
|
|
60
113
|
|
|
61
114
|
const match = line.match(/^([\w-]+):\s*(.*)$/);
|
|
62
115
|
if (match) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
116
|
+
const rawValue = match[2].trim();
|
|
117
|
+
const isQuoted = (rawValue.startsWith('"') && rawValue.endsWith('"')) || (rawValue.startsWith("'") && rawValue.endsWith("'"));
|
|
118
|
+
const value = isQuoted ? rawValue.slice(1, -1) : rawValue;
|
|
119
|
+
const isFolded = !isQuoted && (rawValue === ">" || rawValue === ">-");
|
|
67
120
|
|
|
68
|
-
if (value === "") {
|
|
69
|
-
// Key with empty value
|
|
121
|
+
if (value === "" || isFolded) {
|
|
122
|
+
// Key with empty value or folded block indicator — defer storing until we see indent
|
|
70
123
|
currentKey = match[1];
|
|
71
124
|
currentBlockLines = [];
|
|
72
125
|
currentIndent = indent;
|
|
126
|
+
currentFolded = isFolded;
|
|
73
127
|
} else {
|
|
74
128
|
// Simple key: value
|
|
75
129
|
frontmatter[match[1]] = value;
|
|
@@ -81,12 +135,12 @@ export function parseFrontmatter(content: string): { frontmatter: Record<string,
|
|
|
81
135
|
// Flush final block value
|
|
82
136
|
if (currentKey !== null && currentBlockLines !== null) {
|
|
83
137
|
const rawBlock = currentBlockLines.join("\n");
|
|
84
|
-
const leadingSpaces = rawBlock.match(/^
|
|
85
|
-
const prefix = leadingSpaces?.[
|
|
138
|
+
const leadingSpaces = rawBlock.match(/^[ \t]+(?=\S)/m);
|
|
139
|
+
const prefix = leadingSpaces?.[0] ?? "";
|
|
86
140
|
const stripped = prefix
|
|
87
141
|
? rawBlock.replace(new RegExp(`^${escapeRegex(prefix)}`, "gm"), "").replace(/^\n/, "")
|
|
88
142
|
: rawBlock;
|
|
89
|
-
frontmatter[currentKey] = stripped;
|
|
143
|
+
frontmatter[currentKey] = currentFolded ? foldBlock(stripped) : stripped;
|
|
90
144
|
}
|
|
91
145
|
|
|
92
146
|
return { frontmatter, body };
|
package/src/agents/skills.ts
CHANGED
|
@@ -122,6 +122,8 @@ function extractSkillPathsFromPackageRoot(packageRoot: string, source: SkillSour
|
|
|
122
122
|
let cachedGlobalNpmRoot: string | null = null;
|
|
123
123
|
|
|
124
124
|
function getGlobalNpmRoot(): string | null {
|
|
125
|
+
const offline = process.env.PI_OFFLINE?.toLowerCase();
|
|
126
|
+
if (offline === "1" || offline === "true" || offline === "yes") return null;
|
|
125
127
|
if (cachedGlobalNpmRoot !== null) return cachedGlobalNpmRoot;
|
|
126
128
|
try {
|
|
127
129
|
cachedGlobalNpmRoot = execSync("npm root -g", { encoding: "utf-8", timeout: 5000 }).trim();
|
|
@@ -608,9 +610,22 @@ function readSkill(
|
|
|
608
610
|
export function resolveSkills(
|
|
609
611
|
skillNames: string[],
|
|
610
612
|
cwd: string,
|
|
613
|
+
localSkillPaths?: string[],
|
|
614
|
+
localBaseDir?: string,
|
|
611
615
|
): { resolved: ResolvedSkill[]; missing: string[] } {
|
|
612
616
|
const resolved: ResolvedSkill[] = [];
|
|
613
617
|
const missing: string[] = [];
|
|
618
|
+
const localByName = new Map<string, CachedSkillEntry>();
|
|
619
|
+
if (localSkillPaths?.length) {
|
|
620
|
+
const agentDir = getAgentDir();
|
|
621
|
+
const localEntries = collectFilesystemSkills(cwd, agentDir, localSkillPaths.map((entry) => ({
|
|
622
|
+
path: path.resolve(localBaseDir ?? cwd, entry),
|
|
623
|
+
source: "unknown" as const,
|
|
624
|
+
})));
|
|
625
|
+
for (const entry of localEntries) {
|
|
626
|
+
if (!localByName.has(entry.name)) localByName.set(entry.name, entry);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
614
629
|
|
|
615
630
|
for (const name of skillNames) {
|
|
616
631
|
const trimmed = name.trim();
|
|
@@ -620,18 +635,14 @@ export function resolveSkills(
|
|
|
620
635
|
continue;
|
|
621
636
|
}
|
|
622
637
|
|
|
623
|
-
const
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
const skill = readSkill(trimmed, location.path, location.source);
|
|
630
|
-
if (skill) {
|
|
631
|
-
resolved.push(skill);
|
|
632
|
-
} else {
|
|
633
|
-
missing.push(trimmed);
|
|
638
|
+
const local = localByName.get(trimmed);
|
|
639
|
+
let skill = local ? readSkill(trimmed, local.filePath, local.source) : undefined;
|
|
640
|
+
if (!skill) {
|
|
641
|
+
const location = resolveSkillPath(trimmed, cwd);
|
|
642
|
+
if (location) skill = readSkill(trimmed, location.path, location.source);
|
|
634
643
|
}
|
|
644
|
+
if (skill) resolved.push(skill);
|
|
645
|
+
else missing.push(trimmed);
|
|
635
646
|
}
|
|
636
647
|
|
|
637
648
|
return { resolved, missing };
|
|
@@ -641,8 +652,10 @@ export function resolveSkillsWithFallback(
|
|
|
641
652
|
skillNames: string[],
|
|
642
653
|
primaryCwd: string,
|
|
643
654
|
fallbackCwd?: string,
|
|
655
|
+
localSkillPaths?: string[],
|
|
656
|
+
localBaseDir?: string,
|
|
644
657
|
): { resolved: ResolvedSkill[]; missing: string[] } {
|
|
645
|
-
const primary = resolveSkills(skillNames, primaryCwd);
|
|
658
|
+
const primary = resolveSkills(skillNames, primaryCwd, localSkillPaths, localBaseDir);
|
|
646
659
|
if (!fallbackCwd || primary.missing.length === 0) return primary;
|
|
647
660
|
if (path.resolve(primaryCwd) === path.resolve(fallbackCwd)) return primary;
|
|
648
661
|
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
export const BACKGROUND_WORK_PROTOCOL_VERSION = 1;
|
|
2
|
+
export const BACKGROUND_WORK_REGISTRY_KEY = "pi-subagents.background-work.v1";
|
|
3
|
+
|
|
4
|
+
const MAX_PROVIDER_NAME_LENGTH = 128;
|
|
5
|
+
const MAX_PROVIDERS = 100;
|
|
6
|
+
const MAX_ITEM_ID_LENGTH = 256;
|
|
7
|
+
const MAX_SESSION_ID_LENGTH = 256;
|
|
8
|
+
const MAX_WAKE_CHANNEL_LENGTH = 256;
|
|
9
|
+
const MAX_ITEMS_PER_PROVIDER = 10_000;
|
|
10
|
+
|
|
11
|
+
export interface BackgroundWorkItem {
|
|
12
|
+
id: string;
|
|
13
|
+
sessionId: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface BackgroundWorkReconcileContext {
|
|
17
|
+
sessionId: string;
|
|
18
|
+
nowMs: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface BackgroundWorkProvider {
|
|
22
|
+
name: string;
|
|
23
|
+
listActiveWork(): readonly BackgroundWorkItem[];
|
|
24
|
+
wakeChannels?: readonly string[];
|
|
25
|
+
reconcile?(context: BackgroundWorkReconcileContext): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RegisteredBackgroundWorkItem extends BackgroundWorkItem {
|
|
29
|
+
provider: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface BackgroundWorkSnapshot {
|
|
33
|
+
providers: readonly string[];
|
|
34
|
+
items: readonly RegisteredBackgroundWorkItem[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface BackgroundWorkRegistry {
|
|
38
|
+
version: typeof BACKGROUND_WORK_PROTOCOL_VERSION;
|
|
39
|
+
providers: Map<string, BackgroundWorkProvider>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function registry(): BackgroundWorkRegistry {
|
|
43
|
+
const key = Symbol.for(BACKGROUND_WORK_REGISTRY_KEY);
|
|
44
|
+
const globalObject = globalThis as Record<PropertyKey, unknown>;
|
|
45
|
+
const existing = globalObject[key];
|
|
46
|
+
if (existing === undefined) {
|
|
47
|
+
const created: BackgroundWorkRegistry = {
|
|
48
|
+
version: BACKGROUND_WORK_PROTOCOL_VERSION,
|
|
49
|
+
providers: new Map(),
|
|
50
|
+
};
|
|
51
|
+
globalObject[key] = created;
|
|
52
|
+
return created;
|
|
53
|
+
}
|
|
54
|
+
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
|
|
55
|
+
throw new Error(`Malformed background-work registry at Symbol.for("${BACKGROUND_WORK_REGISTRY_KEY}").`);
|
|
56
|
+
}
|
|
57
|
+
const candidate = existing as Partial<BackgroundWorkRegistry>;
|
|
58
|
+
if (candidate.version !== BACKGROUND_WORK_PROTOCOL_VERSION || !(candidate.providers instanceof Map)) {
|
|
59
|
+
throw new Error(`Unsupported background-work registry at Symbol.for("${BACKGROUND_WORK_REGISTRY_KEY}").`);
|
|
60
|
+
}
|
|
61
|
+
return candidate as BackgroundWorkRegistry;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function validateString(value: unknown, field: string, maxLength: number): string {
|
|
65
|
+
if (typeof value !== "string" || value.length === 0 || value.trim() !== value) {
|
|
66
|
+
throw new Error(`${field} must be a non-empty string without leading or trailing whitespace.`);
|
|
67
|
+
}
|
|
68
|
+
if (value.length > maxLength) throw new Error(`${field} must be at most ${maxLength} characters.`);
|
|
69
|
+
if (value.includes("\0")) throw new Error(`${field} must not contain NUL characters.`);
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function validateProvider(value: unknown): BackgroundWorkProvider {
|
|
74
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
75
|
+
throw new Error("Background-work provider must be an object.");
|
|
76
|
+
}
|
|
77
|
+
const provider = value as Record<string, unknown>;
|
|
78
|
+
const unknownFields = Object.keys(provider).filter((key) => !["name", "listActiveWork", "wakeChannels", "reconcile"].includes(key));
|
|
79
|
+
if (unknownFields.length > 0) throw new Error(`Background-work provider has unknown fields: ${unknownFields.join(", ")}.`);
|
|
80
|
+
const name = validateString(provider.name, "Background-work provider name", MAX_PROVIDER_NAME_LENGTH);
|
|
81
|
+
if (typeof provider.listActiveWork !== "function") {
|
|
82
|
+
throw new Error(`Background-work provider '${name}' must expose listActiveWork().`);
|
|
83
|
+
}
|
|
84
|
+
if (provider.reconcile !== undefined && typeof provider.reconcile !== "function") {
|
|
85
|
+
throw new Error(`Background-work provider '${name}' reconcile must be a function when provided.`);
|
|
86
|
+
}
|
|
87
|
+
if (provider.wakeChannels !== undefined) {
|
|
88
|
+
if (!Array.isArray(provider.wakeChannels)) {
|
|
89
|
+
throw new Error(`Background-work provider '${name}' wakeChannels must be an array when provided.`);
|
|
90
|
+
}
|
|
91
|
+
const channels = provider.wakeChannels.map((channel, index) =>
|
|
92
|
+
validateString(channel, `Background-work provider '${name}' wakeChannels[${index}]`, MAX_WAKE_CHANNEL_LENGTH));
|
|
93
|
+
if (new Set(channels).size !== channels.length) {
|
|
94
|
+
throw new Error(`Background-work provider '${name}' wakeChannels must not contain duplicates.`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return value as BackgroundWorkProvider;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function validateItem(provider: string, value: unknown, index: number): BackgroundWorkItem {
|
|
101
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
102
|
+
throw new Error(`Background-work provider '${provider}' item ${index} must be an object.`);
|
|
103
|
+
}
|
|
104
|
+
const item = value as Record<string, unknown>;
|
|
105
|
+
const unknownFields = Object.keys(item).filter((key) => key !== "id" && key !== "sessionId");
|
|
106
|
+
if (unknownFields.length > 0) {
|
|
107
|
+
throw new Error(`Background-work provider '${provider}' item ${index} has unknown fields: ${unknownFields.join(", ")}.`);
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
id: validateString(item.id, `Background-work provider '${provider}' item ${index} id`, MAX_ITEM_ID_LENGTH),
|
|
111
|
+
sessionId: validateString(item.sessionId, `Background-work provider '${provider}' item ${index} sessionId`, MAX_SESSION_ID_LENGTH),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Register or replace one process-local background-work provider. The returned
|
|
117
|
+
* disposer only removes this exact registration, so an old extension reload
|
|
118
|
+
* cannot unregister its replacement.
|
|
119
|
+
*/
|
|
120
|
+
export function registerBackgroundWorkProvider(provider: BackgroundWorkProvider): () => void {
|
|
121
|
+
const validated = validateProvider(provider);
|
|
122
|
+
const current = registry();
|
|
123
|
+
if (!current.providers.has(validated.name) && current.providers.size >= MAX_PROVIDERS) {
|
|
124
|
+
throw new Error(`Background-work registry supports at most ${MAX_PROVIDERS} providers.`);
|
|
125
|
+
}
|
|
126
|
+
current.providers.set(validated.name, validated);
|
|
127
|
+
return () => {
|
|
128
|
+
if (current.providers.get(validated.name) === validated) current.providers.delete(validated.name);
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function listBackgroundWorkProviders(): readonly BackgroundWorkProvider[] {
|
|
133
|
+
const current = registry();
|
|
134
|
+
if (current.providers.size > MAX_PROVIDERS) throw new Error(`Background-work registry contains more than ${MAX_PROVIDERS} providers.`);
|
|
135
|
+
const providers: BackgroundWorkProvider[] = [];
|
|
136
|
+
for (const [key, value] of current.providers) {
|
|
137
|
+
const provider = validateProvider(value);
|
|
138
|
+
if (key !== provider.name) throw new Error(`Background-work registry key '${key}' does not match provider name '${provider.name}'.`);
|
|
139
|
+
providers.push(provider);
|
|
140
|
+
}
|
|
141
|
+
return providers;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Read validated provider wake channels without reconciling or listing work. */
|
|
145
|
+
export function listBackgroundWorkWakeChannels(): readonly string[] {
|
|
146
|
+
const channels = new Set<string>();
|
|
147
|
+
for (const provider of listBackgroundWorkProviders()) {
|
|
148
|
+
for (const channel of provider.wakeChannels ?? []) channels.add(channel);
|
|
149
|
+
}
|
|
150
|
+
return [...channels];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Reconcile and snapshot active provider work owned by one exact Pi session. */
|
|
154
|
+
export function snapshotBackgroundWork(sessionId: string, nowMs = Date.now()): BackgroundWorkSnapshot {
|
|
155
|
+
validateString(sessionId, "Background-work snapshot sessionId", MAX_SESSION_ID_LENGTH);
|
|
156
|
+
const providers = listBackgroundWorkProviders();
|
|
157
|
+
const items: RegisteredBackgroundWorkItem[] = [];
|
|
158
|
+
const identities = new Set<string>();
|
|
159
|
+
for (const provider of providers) {
|
|
160
|
+
try {
|
|
161
|
+
provider.reconcile?.({ sessionId, nowMs });
|
|
162
|
+
} catch (error) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`Background-work provider '${provider.name}' reconcile failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
165
|
+
{ cause: error },
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
let active: readonly BackgroundWorkItem[];
|
|
169
|
+
try {
|
|
170
|
+
active = provider.listActiveWork();
|
|
171
|
+
} catch (error) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`Background-work provider '${provider.name}' listActiveWork failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
174
|
+
{ cause: error },
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (!Array.isArray(active)) {
|
|
178
|
+
throw new Error(`Background-work provider '${provider.name}' listActiveWork() must return an array.`);
|
|
179
|
+
}
|
|
180
|
+
if (active.length > MAX_ITEMS_PER_PROVIDER) {
|
|
181
|
+
throw new Error(`Background-work provider '${provider.name}' returned ${active.length} items; maximum is ${MAX_ITEMS_PER_PROVIDER}.`);
|
|
182
|
+
}
|
|
183
|
+
active.forEach((value, index) => {
|
|
184
|
+
const item = validateItem(provider.name, value, index);
|
|
185
|
+
const identity = `${provider.name}\0${item.sessionId}\0${item.id}`;
|
|
186
|
+
if (identities.has(identity)) {
|
|
187
|
+
throw new Error(`Background-work provider '${provider.name}' returned duplicate item '${item.id}' for session '${item.sessionId}'.`);
|
|
188
|
+
}
|
|
189
|
+
identities.add(identity);
|
|
190
|
+
if (item.sessionId === sessionId) items.push({ provider: provider.name, ...item });
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
providers: providers.map((provider) => provider.name),
|
|
195
|
+
items,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
export const SUBAGENT_DELEGATION_PROTOCOL_VERSION = 1 as const;
|
|
2
|
+
|
|
3
|
+
// This is the established extension-to-extension transport. The public API
|
|
4
|
+
// intentionally reuses it instead of adding a second event protocol.
|
|
5
|
+
export const SUBAGENT_DELEGATION_REQUEST_EVENT = "prompt-template:subagent:request";
|
|
6
|
+
export const SUBAGENT_DELEGATION_STARTED_EVENT = "prompt-template:subagent:started";
|
|
7
|
+
export const SUBAGENT_DELEGATION_UPDATE_EVENT = "prompt-template:subagent:update";
|
|
8
|
+
export const SUBAGENT_DELEGATION_RESPONSE_EVENT = "prompt-template:subagent:response";
|
|
9
|
+
export const SUBAGENT_DELEGATION_CANCEL_EVENT = "prompt-template:subagent:cancel";
|
|
10
|
+
|
|
11
|
+
export interface SubagentDelegationTurnBudget {
|
|
12
|
+
maxTurns: number;
|
|
13
|
+
graceTurns?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SubagentDelegationToolBudget {
|
|
17
|
+
soft?: number;
|
|
18
|
+
hard: number;
|
|
19
|
+
block?: string[] | "*";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type SubagentDelegationAcceptanceEvidence =
|
|
23
|
+
| "changed-files"
|
|
24
|
+
| "tests-added"
|
|
25
|
+
| "commands-run"
|
|
26
|
+
| "validation-output"
|
|
27
|
+
| "residual-risks"
|
|
28
|
+
| "no-staged-files"
|
|
29
|
+
| "diff-summary"
|
|
30
|
+
| "review-findings"
|
|
31
|
+
| "manual-notes";
|
|
32
|
+
|
|
33
|
+
export interface SubagentDelegationAcceptanceCriterion {
|
|
34
|
+
id: string;
|
|
35
|
+
must: string;
|
|
36
|
+
evidence?: SubagentDelegationAcceptanceEvidence[];
|
|
37
|
+
severity?: "required" | "recommended";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SubagentDelegationAcceptanceVerifyCommand {
|
|
41
|
+
id: string;
|
|
42
|
+
command: string;
|
|
43
|
+
timeoutMs?: number;
|
|
44
|
+
cwd?: string;
|
|
45
|
+
env?: Record<string, string>;
|
|
46
|
+
allowFailure?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SubagentDelegationAcceptanceReview {
|
|
50
|
+
agent?: string;
|
|
51
|
+
focus?: string;
|
|
52
|
+
required?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface SubagentDelegationAcceptanceFields {
|
|
56
|
+
criteria?: Array<string | SubagentDelegationAcceptanceCriterion>;
|
|
57
|
+
evidence?: SubagentDelegationAcceptanceEvidence[];
|
|
58
|
+
verify?: SubagentDelegationAcceptanceVerifyCommand[];
|
|
59
|
+
review?: SubagentDelegationAcceptanceReview | false;
|
|
60
|
+
stopRules?: string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type SubagentDelegationAcceptanceConfig = SubagentDelegationAcceptanceFields & (
|
|
64
|
+
| { level: "none"; reason: string }
|
|
65
|
+
| { level?: "auto" | "attested" | "checked" | "verified"; reason?: string }
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
export type SubagentDelegationAcceptance =
|
|
69
|
+
| "auto"
|
|
70
|
+
| "attested"
|
|
71
|
+
| "checked"
|
|
72
|
+
| "verified"
|
|
73
|
+
| false
|
|
74
|
+
| SubagentDelegationAcceptanceConfig;
|
|
75
|
+
|
|
76
|
+
export interface SubagentDelegationRequest {
|
|
77
|
+
version: typeof SUBAGENT_DELEGATION_PROTOCOL_VERSION;
|
|
78
|
+
requestId: string;
|
|
79
|
+
agent: string;
|
|
80
|
+
task: string;
|
|
81
|
+
context: "fresh" | "fork";
|
|
82
|
+
cwd: string;
|
|
83
|
+
model?: string;
|
|
84
|
+
timeoutMs?: number;
|
|
85
|
+
turnBudget?: SubagentDelegationTurnBudget;
|
|
86
|
+
toolBudget?: SubagentDelegationToolBudget;
|
|
87
|
+
skill?: string | string[] | boolean;
|
|
88
|
+
output?: string | boolean;
|
|
89
|
+
outputMode?: "inline" | "file-only";
|
|
90
|
+
acceptance?: SubagentDelegationAcceptance;
|
|
91
|
+
artifacts?: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface SubagentDelegationStarted {
|
|
95
|
+
version: typeof SUBAGENT_DELEGATION_PROTOCOL_VERSION;
|
|
96
|
+
requestId: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface SubagentDelegationUpdate extends SubagentDelegationStarted {
|
|
100
|
+
currentTool?: string;
|
|
101
|
+
currentToolArgs?: string;
|
|
102
|
+
recentOutput?: string;
|
|
103
|
+
recentOutputLines?: string[];
|
|
104
|
+
recentTools?: Array<{ tool: string; args: string }>;
|
|
105
|
+
model?: string;
|
|
106
|
+
toolCount?: number;
|
|
107
|
+
durationMs?: number;
|
|
108
|
+
tokens?: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type SubagentDelegationStatus =
|
|
112
|
+
| "completed"
|
|
113
|
+
| "failed"
|
|
114
|
+
| "timed_out"
|
|
115
|
+
| "cancelled"
|
|
116
|
+
| "interrupted"
|
|
117
|
+
| "turn_budget_exhausted"
|
|
118
|
+
| "tool_budget_exhausted"
|
|
119
|
+
| "acceptance_failed"
|
|
120
|
+
| "invalid_request"
|
|
121
|
+
| "unavailable_context";
|
|
122
|
+
|
|
123
|
+
export type SubagentDelegationAcceptanceStatus =
|
|
124
|
+
| "pending"
|
|
125
|
+
| "not-required"
|
|
126
|
+
| "claimed"
|
|
127
|
+
| "attested"
|
|
128
|
+
| "checked"
|
|
129
|
+
| "verified"
|
|
130
|
+
| "reviewed"
|
|
131
|
+
| "accepted"
|
|
132
|
+
| "rejected";
|
|
133
|
+
|
|
134
|
+
export interface SubagentDelegationAcceptanceResult {
|
|
135
|
+
status: SubagentDelegationAcceptanceStatus;
|
|
136
|
+
explicit: boolean;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface SubagentDelegationResponse extends SubagentDelegationStarted {
|
|
140
|
+
status: SubagentDelegationStatus;
|
|
141
|
+
error?: string;
|
|
142
|
+
runId?: string;
|
|
143
|
+
childIndex?: number;
|
|
144
|
+
agent?: string;
|
|
145
|
+
model?: string;
|
|
146
|
+
exitCode?: number;
|
|
147
|
+
output?: string;
|
|
148
|
+
outputPath?: string;
|
|
149
|
+
sessionFile?: string;
|
|
150
|
+
acceptance?: SubagentDelegationAcceptanceResult;
|
|
151
|
+
turns?: number;
|
|
152
|
+
toolCount?: number;
|
|
153
|
+
durationMs?: number;
|
|
154
|
+
tokens?: number;
|
|
155
|
+
warnings?: string[];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface SubagentDelegationCancel extends SubagentDelegationStarted {}
|