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.
Files changed (98) hide show
  1. package/CHANGELOG.md +78 -9
  2. package/README.md +213 -32
  3. package/index.ts +1 -0
  4. package/install.mjs +1 -1
  5. package/package.json +23 -8
  6. package/prompts/review-loop.md +3 -1
  7. package/skills/pi-subagents/SKILL.md +87 -25
  8. package/src/agents/agent-management.ts +82 -15
  9. package/src/agents/agent-serializer.ts +19 -0
  10. package/src/agents/agents.ts +91 -49
  11. package/src/agents/frontmatter.ts +67 -13
  12. package/src/agents/skills.ts +25 -12
  13. package/src/api/background-work.ts +197 -0
  14. package/src/api/delegation.ts +158 -0
  15. package/src/extension/chain-validation.ts +165 -0
  16. package/src/extension/doctor.ts +15 -0
  17. package/src/extension/fanout-child.ts +3 -1
  18. package/src/extension/index.ts +65 -124
  19. package/src/extension/rpc.ts +10 -2
  20. package/src/extension/schemas.ts +18 -14
  21. package/src/extension/steering-notices.ts +35 -0
  22. package/src/extension/tool-description.ts +20 -9
  23. package/src/intercom/intercom-bridge.ts +3 -2
  24. package/src/intercom/native-supervisor-channel.ts +9 -1
  25. package/src/intercom/result-intercom.ts +4 -0
  26. package/src/runs/background/async-execution.ts +293 -44
  27. package/src/runs/background/async-job-tracker.ts +56 -9
  28. package/src/runs/background/async-resume.ts +159 -52
  29. package/src/runs/background/async-status.ts +25 -18
  30. package/src/runs/background/auto-drain.ts +67 -0
  31. package/src/runs/background/chain-root-attachment.ts +16 -8
  32. package/src/runs/background/control-channel.ts +260 -13
  33. package/src/runs/background/fleet-view.ts +23 -2
  34. package/src/runs/background/notify.ts +79 -10
  35. package/src/runs/background/result-watcher.ts +12 -9
  36. package/src/runs/background/run-id-resolver.ts +14 -2
  37. package/src/runs/background/run-status.ts +23 -15
  38. package/src/runs/background/scheduled-runs.ts +3 -0
  39. package/src/runs/background/stale-run-reconciler.ts +32 -10
  40. package/src/runs/background/steering.ts +237 -0
  41. package/src/runs/background/subagent-runner.ts +898 -236
  42. package/src/runs/background/subagent-wait.ts +484 -0
  43. package/src/runs/background/top-level-async.ts +2 -1
  44. package/src/runs/background/wait-config.ts +36 -0
  45. package/src/runs/background/wait-tool.ts +26 -0
  46. package/src/runs/foreground/async-steering-action.ts +230 -0
  47. package/src/runs/foreground/chain-clarify.ts +22 -6
  48. package/src/runs/foreground/chain-execution.ts +50 -32
  49. package/src/runs/foreground/execution.ts +308 -94
  50. package/src/runs/foreground/subagent-executor.ts +592 -268
  51. package/src/runs/shared/acceptance.ts +355 -97
  52. package/src/runs/shared/child-protocol.ts +121 -0
  53. package/src/runs/shared/completion-guard.ts +8 -127
  54. package/src/runs/shared/dynamic-fanout.ts +6 -4
  55. package/src/runs/shared/model-fallback.ts +36 -0
  56. package/src/runs/shared/nested-events.ts +9 -4
  57. package/src/runs/shared/nested-render.ts +4 -1
  58. package/src/runs/shared/parallel-utils.ts +7 -0
  59. package/src/runs/shared/pi-args.ts +34 -7
  60. package/src/runs/shared/pi-spawn.ts +18 -12
  61. package/src/runs/shared/session-lease.ts +279 -0
  62. package/src/runs/shared/single-output.ts +61 -6
  63. package/src/runs/shared/spawn-budget.ts +128 -0
  64. package/src/runs/shared/subagent-control.ts +10 -6
  65. package/src/runs/shared/subagent-prompt-runtime.ts +127 -26
  66. package/src/runs/shared/task-intent.ts +176 -0
  67. package/src/runs/shared/tool-availability.ts +65 -0
  68. package/src/runs/shared/turn-budget.ts +49 -4
  69. package/src/shared/atomic-json.ts +4 -1
  70. package/src/shared/fork-context.ts +28 -3
  71. package/src/shared/model-info.ts +7 -4
  72. package/src/shared/status-format.ts +7 -1
  73. package/src/shared/types.ts +203 -25
  74. package/src/shared/utils.ts +35 -7
  75. package/src/slash/delegation-adapters.ts +457 -0
  76. package/src/slash/delegation-request.ts +103 -0
  77. package/src/slash/prompt-template-bridge.ts +167 -344
  78. package/src/slash/slash-commands.ts +239 -6
  79. package/src/slash/subagents-admin.ts +428 -0
  80. package/src/slash/subagents-editor.ts +86 -0
  81. package/src/tui/fleet.ts +405 -0
  82. package/src/tui/render.ts +90 -16
  83. package/src/watchdog/change-signature.ts +127 -0
  84. package/src/watchdog/child-status.ts +205 -0
  85. package/src/watchdog/emission-guard.ts +123 -0
  86. package/src/watchdog/lsp-diagnostics.ts +532 -0
  87. package/src/watchdog/model-selection.ts +167 -0
  88. package/src/watchdog/register-child.ts +117 -0
  89. package/src/watchdog/register-main.ts +433 -0
  90. package/src/watchdog/render.ts +54 -0
  91. package/src/watchdog/review.ts +293 -0
  92. package/src/watchdog/runtime.ts +712 -0
  93. package/src/watchdog/settings.ts +528 -0
  94. package/src/watchdog/tool-actions.ts +155 -0
  95. package/src/watchdog/turn-delta.ts +161 -0
  96. package/src/watchdog/types.ts +188 -0
  97. package/src/watchdog/warning-format.ts +73 -0
  98. 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(/^([ \t]+)/m);
51
- const prefix = leadingSpaces?.[1] ?? "";
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
- let value = match[2].trim();
64
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
65
- value = value.slice(1, -1);
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 — might start a block; defer storing until we see indent
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(/^([ \t]+)/m);
85
- const prefix = leadingSpaces?.[1] ?? "";
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 };
@@ -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 location = resolveSkillPath(trimmed, cwd);
624
- if (!location) {
625
- missing.push(trimmed);
626
- continue;
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 {}