pi-subagents 0.40.0 → 0.41.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 +76 -0
- package/README.md +246 -525
- package/agents/oracle.md +1 -0
- package/package.json +12 -4
- package/prompts/parallel-context-build.md +1 -1
- package/prompts/parallel-handoff-plan.md +1 -1
- package/prompts/review-loop.md +1 -1
- package/skills/pi-subagents/SKILL.md +6 -6
- package/skills/pi-subagents/references/constraints-and-recipes.md +19 -26
- package/skills/pi-subagents/references/execution-controls.md +98 -97
- package/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
- package/skills/pi-subagents/references/prompting-and-roles.md +18 -27
- package/src/agents/agent-management.ts +155 -65
- package/src/agents/agent-serializer.ts +19 -0
- package/src/agents/agents.ts +154 -71
- package/src/agents/chain-serializer.ts +10 -7
- package/src/agents/frontmatter.ts +5 -3
- package/src/agents/identity.ts +1 -1
- package/src/agents/proactive-skills.ts +13 -10
- package/src/agents/skills.ts +23 -6
- package/src/api/control-channel.ts +4 -0
- package/src/api/delegation.ts +26 -194
- package/src/api/external-runs.ts +129 -0
- package/src/api/intercom-bridge.ts +3 -0
- package/src/api/pi-args.ts +5 -0
- package/src/api/preflight.ts +3 -3
- package/src/api/shared-types.ts +19 -0
- package/src/extension/config.ts +10 -0
- package/src/extension/control-notices.ts +5 -39
- package/src/extension/doctor.ts +10 -9
- package/src/extension/fanout-child.ts +7 -4
- package/src/extension/index.ts +232 -68
- package/src/extension/rpc.ts +18 -12
- package/src/extension/schemas.ts +48 -37
- package/src/extension/tool-description.ts +36 -86
- package/src/inspectors/herdr/actions.ts +229 -0
- package/src/inspectors/herdr/client.ts +130 -0
- package/src/inspectors/herdr/inspector-runner.ts +141 -0
- package/src/inspectors/herdr/project-panes.ts +154 -0
- package/src/integrations/herdr-status.ts +330 -0
- package/src/intercom/intercom-bridge.ts +3 -2
- package/src/intercom/result-intercom.ts +5 -1
- package/src/missions/actions.ts +372 -0
- package/src/missions/lifecycle.ts +314 -0
- package/src/missions/store.ts +442 -0
- package/src/missions/types.ts +135 -0
- package/src/policy/authority.ts +46 -0
- package/src/profiles/profiles.ts +29 -3
- package/src/runs/background/async-execution.ts +98 -49
- package/src/runs/background/async-job-tracker.ts +10 -2
- package/src/runs/background/async-resume.ts +6 -6
- package/src/runs/background/async-status.ts +29 -1
- package/src/runs/background/auto-drain.ts +3 -3
- package/src/runs/background/chain-append.ts +3 -2
- package/src/runs/background/control-channel.ts +9 -7
- package/src/runs/background/fleet-view.ts +3 -4
- package/src/runs/background/notify.ts +2 -1
- package/src/runs/background/process-terminal.ts +5 -5
- package/src/runs/background/result-watcher.ts +13 -5
- package/src/runs/background/run-id-resolver.ts +3 -3
- package/src/runs/background/run-status.ts +35 -8
- package/src/runs/background/scheduled-runs.ts +602 -375
- package/src/runs/background/stale-run-reconciler.ts +3 -3
- package/src/runs/background/subagent-runner.ts +608 -445
- package/src/runs/background/subagent-wait.ts +50 -9
- package/src/runs/background/wait-subscriptions.ts +253 -0
- package/src/runs/background/wait-tool.ts +12 -4
- package/src/runs/foreground/async-steering-action.ts +3 -3
- package/src/runs/foreground/chain-clarify.ts +8 -4
- package/src/runs/foreground/chain-execution.ts +56 -30
- package/src/runs/foreground/execution.ts +15 -2
- package/src/runs/foreground/subagent-executor.ts +1023 -273
- package/src/runs/shared/acceptance.ts +28 -6
- package/src/runs/shared/child-protocol.ts +302 -22
- package/src/runs/shared/dynamic-fanout.ts +1 -1
- package/src/runs/shared/external-cli-runner.ts +130 -0
- package/src/runs/shared/long-running-guard.ts +42 -1
- package/src/runs/shared/nested-events.ts +59 -5
- package/src/runs/shared/nested-render.ts +9 -4
- package/src/runs/shared/parallel-handoff.ts +86 -2
- package/src/runs/shared/parallel-utils.ts +11 -2
- package/src/runs/shared/permissions.ts +95 -0
- package/src/runs/shared/pi-args.ts +11 -1
- package/src/runs/shared/pi-spawn.ts +11 -1
- package/src/runs/shared/run-history.ts +1 -1
- package/src/runs/shared/subagent-prompt-runtime.ts +36 -5
- package/src/runs/shared/subagent-startup-retry.ts +5 -2
- package/src/runs/shared/turn-budget.ts +6 -6
- package/src/runs/shared/worktree.ts +122 -12
- package/src/shared/accessible-dir.ts +29 -7
- package/src/shared/artifacts.ts +18 -1
- package/src/shared/fork-context.ts +3 -2
- package/src/shared/launch-contract.ts +1 -0
- package/src/shared/settings.ts +10 -0
- package/src/shared/types.ts +156 -14
- package/src/shared/utils.ts +8 -6
- package/src/slash/delegation-adapters.ts +32 -194
- package/src/slash/delegation-request.ts +43 -126
- package/src/slash/prompt-template-bridge.ts +158 -205
- package/src/slash/prompt-workflows.ts +21 -57
- package/src/slash/slash-bridge.ts +14 -0
- package/src/slash/slash-commands.ts +31 -632
- package/src/slash/subagents-admin.ts +18 -14
- package/src/tui/fleet-status.ts +156 -21
- package/src/tui/fleet-transcript.ts +110 -5
- package/src/tui/fleet.ts +56 -24
- package/src/tui/render.ts +291 -109
- package/src/types/pi-runtime-compat.d.ts +14 -0
- package/src/watchdog/lsp-diagnostics.ts +12 -7
- package/src/watchdog/model-selection.ts +2 -2
- package/src/watchdog/permission-arbiter.ts +145 -0
- package/src/watchdog/register-child.ts +1 -1
- package/src/watchdog/register-main.ts +1 -1
- package/src/watchdog/review.ts +4 -1
- package/src/watchdog/runtime.ts +3 -2
- package/src/workflows/chat-progress.ts +140 -0
- package/src/workflows/scripted-workflow.ts +415 -0
- package/agents/advisor.md +0 -73
- package/src/extension/chain-validation.ts +0 -181
|
@@ -2,8 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import {
|
|
5
|
-
|
|
6
|
-
RESULTS_DIR,
|
|
5
|
+
DIRS,
|
|
7
6
|
TEMP_ROOT_DIR,
|
|
8
7
|
type AsyncJobState,
|
|
9
8
|
type AsyncStatus,
|
|
@@ -30,6 +29,7 @@ import {
|
|
|
30
29
|
} from "./pi-args.ts";
|
|
31
30
|
import { writeAtomicJson } from "../../shared/atomic-json.ts";
|
|
32
31
|
import { sanitizeProcessTerminal } from "../background/process-terminal.ts";
|
|
32
|
+
import { THINKING_LEVELS } from "../../shared/model-info.ts";
|
|
33
33
|
|
|
34
34
|
export const NESTED_EVENTS_DIR = path.join(TEMP_ROOT_DIR, "nested-subagent-events");
|
|
35
35
|
const ROUTE_FILE = "route.json";
|
|
@@ -281,9 +281,13 @@ function sanitizeStep(input: unknown, depth: number): NestedStepSummary | undefi
|
|
|
281
281
|
const status = raw.status === "pending" || raw.status === "running" || raw.status === "complete" || raw.status === "completed" || raw.status === "failed" || raw.status === "paused" || raw.status === "stopped"
|
|
282
282
|
? raw.status
|
|
283
283
|
: "pending";
|
|
284
|
+
const model = stringValue(raw.model);
|
|
285
|
+
const thinking = THINKING_LEVELS.find((level) => level === raw.thinking);
|
|
284
286
|
return {
|
|
285
287
|
agent,
|
|
286
288
|
status,
|
|
289
|
+
...(model ? { model } : {}),
|
|
290
|
+
...(thinking ? { thinking } : {}),
|
|
287
291
|
...(stringValue(raw.sessionFile, 2048) ? { sessionFile: stringValue(raw.sessionFile, 2048) } : {}),
|
|
288
292
|
...(raw.activityState === "active_long_running" || raw.activityState === "needs_attention" ? { activityState: raw.activityState } : {}),
|
|
289
293
|
...(clampNumber(raw.lastActivityAt) !== undefined ? { lastActivityAt: clampNumber(raw.lastActivityAt) } : {}),
|
|
@@ -324,6 +328,8 @@ export function sanitizeSummary(input: unknown, depth = 0): NestedRunSummary | u
|
|
|
324
328
|
depth: Math.min(Math.max(0, clampNumber(raw.depth) ?? 0), MAX_DEPTH),
|
|
325
329
|
path: pathParts,
|
|
326
330
|
state: sanitizeState(raw.state, "running"),
|
|
331
|
+
...(stringValue(raw.model) ? { model: stringValue(raw.model) } : {}),
|
|
332
|
+
...(THINKING_LEVELS.find((level) => level === raw.thinking) ? { thinking: THINKING_LEVELS.find((level) => level === raw.thinking) } : {}),
|
|
327
333
|
...(stringValue(raw.asyncDir, 2048) ? { asyncDir: stringValue(raw.asyncDir, 2048) } : {}),
|
|
328
334
|
...(clampNumber(raw.pid) !== undefined && clampNumber(raw.pid)! > 0 && Number.isInteger(clampNumber(raw.pid)) ? { pid: clampNumber(raw.pid) } : {}),
|
|
329
335
|
...(stringValue(raw.sessionId, 256) ? { sessionId: stringValue(raw.sessionId, 256) } : {}),
|
|
@@ -415,6 +421,36 @@ function terminal(state: NestedRunState): boolean {
|
|
|
415
421
|
return state === "complete" || state === "failed" || state === "paused" || state === "stopped";
|
|
416
422
|
}
|
|
417
423
|
|
|
424
|
+
function mergeBoundedChildren(existing: NestedRunSummary[] | undefined, incoming: NestedRunSummary[] | undefined): NestedRunSummary[] | undefined {
|
|
425
|
+
if (incoming === undefined) return existing?.slice(0, MAX_CHILDREN);
|
|
426
|
+
const incomingById = new Map(incoming.map((child) => [child.id, child]));
|
|
427
|
+
const merged = (existing ?? []).map((child) => incomingById.get(child.id) ?? child);
|
|
428
|
+
for (const child of incoming) {
|
|
429
|
+
if (!existing?.some((prior) => prior.id === child.id)) merged.push(child);
|
|
430
|
+
}
|
|
431
|
+
return merged.slice(0, MAX_CHILDREN);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function mergeStepSummary(existing: NestedStepSummary | undefined, incoming: NestedStepSummary): NestedStepSummary {
|
|
435
|
+
if (!existing || existing.agent !== incoming.agent) return { ...incoming, ...(incoming.children ? { children: incoming.children.slice(0, MAX_CHILDREN) } : {}) };
|
|
436
|
+
const metadata = { ...existing } as Partial<NestedStepSummary>;
|
|
437
|
+
delete metadata.status;
|
|
438
|
+
delete metadata.activityState;
|
|
439
|
+
delete metadata.lastActivityAt;
|
|
440
|
+
delete metadata.currentTool;
|
|
441
|
+
delete metadata.currentToolStartedAt;
|
|
442
|
+
delete metadata.currentPath;
|
|
443
|
+
delete metadata.turnCount;
|
|
444
|
+
delete metadata.toolCount;
|
|
445
|
+
delete metadata.children;
|
|
446
|
+
const children = mergeBoundedChildren(existing.children, incoming.children);
|
|
447
|
+
return {
|
|
448
|
+
...metadata,
|
|
449
|
+
...incoming,
|
|
450
|
+
...(children ? { children } : {}),
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
418
454
|
function mergeSummary(existing: NestedRunSummary | undefined, event: NestedEventRecord): NestedRunSummary {
|
|
419
455
|
const incomingState = event.type === "subagent.nested.completed" && event.child.state === "running" ? "complete" : event.child.state;
|
|
420
456
|
const incoming = { ...event.child, state: incomingState, lastUpdate: event.child.lastUpdate ?? event.ts };
|
|
@@ -424,7 +460,21 @@ function mergeSummary(existing: NestedRunSummary | undefined, event: NestedEvent
|
|
|
424
460
|
if (incomingUpdate < existingUpdate) return existing;
|
|
425
461
|
if (terminal(existing.state) && !terminal(incoming.state)) return existing;
|
|
426
462
|
if (terminal(existing.state) && terminal(incoming.state) && incomingUpdate === existingUpdate) return existing;
|
|
427
|
-
|
|
463
|
+
const existingSteps = existing.steps ?? [];
|
|
464
|
+
const incomingSteps = incoming.steps;
|
|
465
|
+
const steps = incomingSteps === undefined
|
|
466
|
+
? existing.steps
|
|
467
|
+
: Array.from({ length: Math.min(MAX_STEPS, Math.max(existingSteps.length, incomingSteps.length)) }, (_, index) =>
|
|
468
|
+
incomingSteps[index] ? mergeStepSummary(existingSteps[index], incomingSteps[index]!) : existingSteps[index]!).filter((step): step is NestedStepSummary => Boolean(step));
|
|
469
|
+
const children = mergeBoundedChildren(existing.children, incoming.children);
|
|
470
|
+
return {
|
|
471
|
+
...existing,
|
|
472
|
+
...incoming,
|
|
473
|
+
...(steps ? { steps } : {}),
|
|
474
|
+
...(children ? { children } : {}),
|
|
475
|
+
state: incoming.state,
|
|
476
|
+
lastUpdate: Math.max(existingUpdate, incomingUpdate),
|
|
477
|
+
};
|
|
428
478
|
}
|
|
429
479
|
|
|
430
480
|
function attachChild(children: NestedRunSummary[], event: NestedEventRecord): NestedRunSummary[] {
|
|
@@ -947,6 +997,8 @@ export function nestedSummaryFromAsyncStatus(status: AsyncStatus, asyncDir: stri
|
|
|
947
997
|
...(status.pid ? { pid: status.pid } : {}),
|
|
948
998
|
...(status.sessionId ? { sessionId: status.sessionId } : {}),
|
|
949
999
|
mode: status.mode ?? fallback.mode,
|
|
1000
|
+
...(status.steps?.length === 1 && status.steps[0]?.model ? { model: status.steps[0].model } : {}),
|
|
1001
|
+
...(status.steps?.length === 1 && status.steps[0]?.thinking ? { thinking: status.steps[0].thinking } : {}),
|
|
950
1002
|
...(status.processTerminal ? { processTerminal: sanitizeProcessTerminal(status.processTerminal, { runId: status.runId || fallback.id, runnerProcessInstanceId: status.processTerminal.runnerProcessInstanceId }, `${asyncDir}/status.json`) } : {}),
|
|
951
1003
|
...(status.launchResolvedExtensions ? { launchResolvedExtensions: status.launchResolvedExtensions } : {}),
|
|
952
1004
|
...runtimeAcknowledgedEntry(status.runtimeAcknowledgedExtensions),
|
|
@@ -978,6 +1030,8 @@ export function nestedSummaryFromAsyncStatus(status: AsyncStatus, asyncDir: stri
|
|
|
978
1030
|
...(status.steps?.length ? { steps: status.steps.map((step, index) => ({
|
|
979
1031
|
agent: step.agent,
|
|
980
1032
|
status: step.status,
|
|
1033
|
+
...(step.model ? { model: step.model } : {}),
|
|
1034
|
+
...(step.thinking ? { thinking: step.thinking } : {}),
|
|
981
1035
|
...(step.sessionFile ? { sessionFile: step.sessionFile } : {}),
|
|
982
1036
|
...(step.activityState ? { activityState: step.activityState } : {}),
|
|
983
1037
|
...(step.lastActivityAt !== undefined ? { lastActivityAt: step.lastActivityAt } : {}),
|
|
@@ -1012,11 +1066,11 @@ export function nestedArtifactEnv(rootRunId: string, parentRunId: string): Recor
|
|
|
1012
1066
|
|
|
1013
1067
|
export function isTopLevelAsyncDir(asyncDir: string): boolean {
|
|
1014
1068
|
const resolved = path.resolve(asyncDir);
|
|
1015
|
-
return containedPath(
|
|
1069
|
+
return containedPath(DIRS.async, resolved) && !containedPath(path.join(TEMP_ROOT_DIR, "nested-subagent-runs"), resolved);
|
|
1016
1070
|
}
|
|
1017
1071
|
|
|
1018
1072
|
export function nestedResultsPath(rootRunId: string, id: string): string {
|
|
1019
1073
|
assertSafeId("rootRunId", rootRunId);
|
|
1020
1074
|
assertSafeId("id", id);
|
|
1021
|
-
return path.join(
|
|
1075
|
+
return path.join(DIRS.results, "nested", rootRunId, `${id}.json`);
|
|
1022
1076
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatDuration, formatTokens, shortenPath } from "../../shared/formatters.ts";
|
|
1
|
+
import { formatDuration, formatModelThinking, formatTokens, shortenPath } from "../../shared/formatters.ts";
|
|
2
2
|
import { formatActivityLabel } from "../../shared/status-format.ts";
|
|
3
3
|
import type { ActivityState, NestedRunSummary, NestedStepSummary } from "../../shared/types.ts";
|
|
4
4
|
|
|
@@ -8,12 +8,13 @@ export interface NestedRunCounts {
|
|
|
8
8
|
paused: number;
|
|
9
9
|
complete: number;
|
|
10
10
|
failed: number;
|
|
11
|
+
rejected: number;
|
|
11
12
|
stopped: number;
|
|
12
13
|
queued: number;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
export function countNestedRuns(children: NestedRunSummary[] | undefined): NestedRunCounts {
|
|
16
|
-
const counts: NestedRunCounts = { total: 0, running: 0, paused: 0, complete: 0, failed: 0, stopped: 0, queued: 0 };
|
|
17
|
+
const counts: NestedRunCounts = { total: 0, running: 0, paused: 0, complete: 0, failed: 0, rejected: 0, stopped: 0, queued: 0 };
|
|
17
18
|
for (const child of children ?? []) {
|
|
18
19
|
counts.total++;
|
|
19
20
|
counts[child.state]++;
|
|
@@ -23,6 +24,7 @@ export function countNestedRuns(children: NestedRunSummary[] | undefined): Neste
|
|
|
23
24
|
counts.paused += nested.paused;
|
|
24
25
|
counts.complete += nested.complete;
|
|
25
26
|
counts.failed += nested.failed;
|
|
27
|
+
counts.rejected += nested.rejected;
|
|
26
28
|
counts.stopped += nested.stopped;
|
|
27
29
|
counts.queued += nested.queued;
|
|
28
30
|
}
|
|
@@ -36,6 +38,7 @@ export function formatNestedAggregate(children: NestedRunSummary[] | undefined):
|
|
|
36
38
|
counts.running > 0 ? `${counts.running} running` : "",
|
|
37
39
|
counts.paused > 0 ? `${counts.paused} paused` : "",
|
|
38
40
|
counts.failed > 0 ? `${counts.failed} failed` : "",
|
|
41
|
+
counts.rejected > 0 ? `${counts.rejected} rejected` : "",
|
|
39
42
|
counts.stopped > 0 ? `${counts.stopped} stopped` : "",
|
|
40
43
|
counts.complete > 0 ? `${counts.complete} complete` : "",
|
|
41
44
|
counts.queued > 0 ? `${counts.queued} queued` : "",
|
|
@@ -88,7 +91,8 @@ function formatNestedRunLines(children: NestedRunSummary[] | undefined, options:
|
|
|
88
91
|
}
|
|
89
92
|
const activity = child.state === "running" ? formatNestedActivity(child) : undefined;
|
|
90
93
|
const error = child.error ? ` | error: ${child.error}` : "";
|
|
91
|
-
|
|
94
|
+
const modelThinking = formatModelThinking(child.model, child.thinking);
|
|
95
|
+
lines.push(`${indent}↳ ${nestedRunLabel(child)} [${child.id}] ${child.state}${modelThinking ? ` | ${modelThinking}` : ""}${activity ? ` | ${activity}` : ""}${error}`);
|
|
92
96
|
if (options.commandHints && lines.length < options.maxLines) lines.push(`${indent} Status: subagent({ action: "status", id: "${child.id}" })`);
|
|
93
97
|
if (depth === options.maxDepth) {
|
|
94
98
|
const aggregate = formatNestedAggregate([...(child.steps?.flatMap((step) => step.children ?? []) ?? []), ...(child.children ?? [])]);
|
|
@@ -98,7 +102,8 @@ function formatNestedRunLines(children: NestedRunSummary[] | undefined, options:
|
|
|
98
102
|
for (const [stepIndex, step] of (child.steps ?? []).entries()) {
|
|
99
103
|
if (lines.length >= options.maxLines) return;
|
|
100
104
|
const stepActivity = step.status === "running" ? formatNestedActivity(step) : undefined;
|
|
101
|
-
|
|
105
|
+
const stepModelThinking = formatModelThinking(step.model, step.thinking);
|
|
106
|
+
lines.push(`${indent} ${stepIndex + 1}. ${step.agent} ${step.status}${stepModelThinking ? ` | ${stepModelThinking}` : ""}${stepActivity ? ` | ${stepActivity}` : ""}${step.error ? ` | error: ${step.error}` : ""}`);
|
|
102
107
|
append(step.children, depth + 1, `${indent} `);
|
|
103
108
|
}
|
|
104
109
|
append(child.children, depth + 1, `${indent} `);
|
|
@@ -11,7 +11,9 @@ import type {
|
|
|
11
11
|
WorktreeCleanupReport,
|
|
12
12
|
WorktreeDiff,
|
|
13
13
|
WorktreeSetup,
|
|
14
|
+
WorktreeCleanupIntent,
|
|
14
15
|
} from "./worktree.ts";
|
|
16
|
+
import { cleanupWorktrees } from "./worktree.ts";
|
|
15
17
|
|
|
16
18
|
export interface ParallelHandoffResult {
|
|
17
19
|
agent: string;
|
|
@@ -79,7 +81,7 @@ export function writeParallelHandoffGroup(input: {
|
|
|
79
81
|
flatStartIndex: number;
|
|
80
82
|
setup: WorktreeSetup;
|
|
81
83
|
diffs: WorktreeDiff[];
|
|
82
|
-
cleanup
|
|
84
|
+
cleanup?: WorktreeCleanupReport;
|
|
83
85
|
results: ParallelHandoffResult[];
|
|
84
86
|
now?: number;
|
|
85
87
|
}): ParallelHandoffReference {
|
|
@@ -122,7 +124,19 @@ export function writeParallelHandoffGroup(input: {
|
|
|
122
124
|
},
|
|
123
125
|
};
|
|
124
126
|
}),
|
|
125
|
-
cleanup: input.cleanup
|
|
127
|
+
cleanup: input.cleanup ?? {
|
|
128
|
+
state: "partial",
|
|
129
|
+
pruned: false,
|
|
130
|
+
tasks: input.setup.worktrees.map((worktree) => ({
|
|
131
|
+
index: worktree.index,
|
|
132
|
+
path: worktree.path,
|
|
133
|
+
branch: worktree.branch,
|
|
134
|
+
worktreeRemoved: false,
|
|
135
|
+
branchRemoved: false,
|
|
136
|
+
preserved: true,
|
|
137
|
+
reason: "cleanup pending durable handoff capture",
|
|
138
|
+
})),
|
|
139
|
+
},
|
|
126
140
|
};
|
|
127
141
|
const groups = existing?.groups.filter((candidate) => candidate.stepIndex !== input.stepIndex) ?? [];
|
|
128
142
|
groups.push(group);
|
|
@@ -145,6 +159,19 @@ export function parallelHandoffPath(baseDir: string, runId?: string): string {
|
|
|
145
159
|
return runId ? path.join(baseDir, "handoffs", `${runId}.json`) : path.join(baseDir, "handoff.json");
|
|
146
160
|
}
|
|
147
161
|
|
|
162
|
+
export function writePendingParallelHandoff(input: {
|
|
163
|
+
manifestPath: string;
|
|
164
|
+
runId: string;
|
|
165
|
+
mode: "parallel" | "chain";
|
|
166
|
+
source: "foreground" | "async";
|
|
167
|
+
cwd: string;
|
|
168
|
+
stepIndex: number;
|
|
169
|
+
flatStartIndex: number;
|
|
170
|
+
setup: WorktreeSetup;
|
|
171
|
+
}): ParallelHandoffReference {
|
|
172
|
+
return writeParallelHandoffGroup({ ...input, diffs: [], results: [] });
|
|
173
|
+
}
|
|
174
|
+
|
|
148
175
|
export function formatParallelHandoffReference(reference: ParallelHandoffReference): string {
|
|
149
176
|
return `Parallel handoff: ${reference.path} (${reference.childCount} children, ${reference.changedPatches} changed patches, cleanup ${reference.cleanupState})`;
|
|
150
177
|
}
|
|
@@ -152,3 +179,60 @@ export function formatParallelHandoffReference(reference: ParallelHandoffReferen
|
|
|
152
179
|
export function formatParallelHandoffError(error: unknown): string {
|
|
153
180
|
return `Parallel handoff unavailable: ${error instanceof Error ? error.message : String(error)}`;
|
|
154
181
|
}
|
|
182
|
+
|
|
183
|
+
export function discardPreservedWorktrees(
|
|
184
|
+
manifestPath: string,
|
|
185
|
+
authorization: Extract<WorktreeCleanupIntent, { kind: "discard" }>["authorization"],
|
|
186
|
+
): { manifest: ParallelHandoffManifest; text: string } {
|
|
187
|
+
const resolvedPath = path.resolve(manifestPath);
|
|
188
|
+
const manifest = readManifest(resolvedPath);
|
|
189
|
+
if (!manifest) throw new Error(`Parallel handoff manifest not found: ${resolvedPath}`);
|
|
190
|
+
let attempted = 0;
|
|
191
|
+
for (const group of manifest.groups) {
|
|
192
|
+
const pending = group.cleanup.tasks.filter((task) => task.preserved && (!task.worktreeRemoved || !task.branchRemoved));
|
|
193
|
+
if (pending.length === 0) continue;
|
|
194
|
+
attempted += pending.length;
|
|
195
|
+
const report = cleanupWorktrees({
|
|
196
|
+
cwd: group.repoRoot,
|
|
197
|
+
baseCommit: group.baseCommit,
|
|
198
|
+
worktrees: pending.map((task) => ({
|
|
199
|
+
path: task.path,
|
|
200
|
+
agentCwd: task.path,
|
|
201
|
+
branch: task.branch,
|
|
202
|
+
index: task.index,
|
|
203
|
+
nodeModulesLinked: false,
|
|
204
|
+
syntheticPaths: [],
|
|
205
|
+
})),
|
|
206
|
+
}, { kind: "discard", authorization });
|
|
207
|
+
const updates = new Map(report.tasks.map((task) => [task.index, task.worktreeRemoved && task.branchRemoved
|
|
208
|
+
? task
|
|
209
|
+
: { ...task, preserved: true, reason: task.reason ?? "discard cleanup remains incomplete" }]));
|
|
210
|
+
group.cleanup = {
|
|
211
|
+
state: group.cleanup.tasks.every((task) => {
|
|
212
|
+
const next = updates.get(task.index) ?? task;
|
|
213
|
+
return next.worktreeRemoved && next.branchRemoved;
|
|
214
|
+
}) && report.pruned ? "complete" : "partial",
|
|
215
|
+
tasks: group.cleanup.tasks.map((task) => updates.get(task.index) ?? task),
|
|
216
|
+
pruned: report.pruned,
|
|
217
|
+
...(report.errors ? { errors: report.errors } : {}),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
manifest.updatedAt = Date.now();
|
|
221
|
+
writeAtomicJson(resolvedPath, manifest);
|
|
222
|
+
const remaining = manifest.groups.flatMap((group) => group.cleanup.tasks.map((task) => ({ group, task })))
|
|
223
|
+
.filter(({ task }) => task.preserved && (!task.worktreeRemoved || !task.branchRemoved));
|
|
224
|
+
const lines = attempted === 0
|
|
225
|
+
? [`No preserved worktrees remain in ${resolvedPath}.`]
|
|
226
|
+
: [`Discard processed ${attempted} preserved worktree${attempted === 1 ? "" : "s"}.`, `Manifest: ${resolvedPath}`];
|
|
227
|
+
if (remaining.length > 0) {
|
|
228
|
+
lines.push("", "Some worktrees remain. Inspect and remove them manually if appropriate:");
|
|
229
|
+
for (const { group, task } of remaining) {
|
|
230
|
+
lines.push(
|
|
231
|
+
` git -C ${JSON.stringify(group.repoRoot)} status --short`,
|
|
232
|
+
` git -C ${JSON.stringify(group.repoRoot)} worktree remove --force ${JSON.stringify(task.path)}`,
|
|
233
|
+
` git -C ${JSON.stringify(group.repoRoot)} branch -D ${JSON.stringify(task.branch)}`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return { manifest, text: lines.join("\n") };
|
|
238
|
+
}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
export type ResolvedRunnerConfig = import("../../shared/types.ts").AgentRunnerConfig;
|
|
2
|
+
|
|
1
3
|
export interface RunnerSubagentStep {
|
|
2
4
|
/** Session id of the direct parent session for permission-system ask forwarding. */
|
|
3
5
|
parentSessionId?: string;
|
|
6
|
+
/** Resolved opt-in rules for native Pi child tool calls. */
|
|
7
|
+
permissionRules?: import("./permissions.ts").PermissionRules;
|
|
4
8
|
agent: string;
|
|
5
9
|
task: string;
|
|
10
|
+
runner?: ResolvedRunnerConfig;
|
|
6
11
|
/** Resolved launch context for this child. */
|
|
7
12
|
context?: "fresh" | "fork";
|
|
8
13
|
importAsyncRoot?: {
|
|
@@ -86,6 +91,8 @@ export interface DynamicRunnerGroup {
|
|
|
86
91
|
acceptanceRole?: import("../../shared/types.ts").AcceptanceRole;
|
|
87
92
|
agentContract?: import("../../shared/types.ts").AgentContract;
|
|
88
93
|
gateOn?: import("../../shared/types.ts").ChainGateLayer;
|
|
94
|
+
capabilityCeiling?: import("./capability-ceiling.ts").ResolvedSubagentCapabilityCeiling;
|
|
95
|
+
capabilityAudit?: import("./capability-ceiling.ts").SubagentCapabilityAudit;
|
|
89
96
|
}
|
|
90
97
|
|
|
91
98
|
export type RunnerStep = RunnerSubagentStep | ParallelStepGroup | DynamicRunnerGroup | RunnerCheckpointStep;
|
|
@@ -178,15 +185,17 @@ export async function mapConcurrent<T, R>(
|
|
|
178
185
|
try {
|
|
179
186
|
while (next < items.length) {
|
|
180
187
|
const i = next++;
|
|
188
|
+
if (!(i in items)) throw new Error(`Missing parallel item at index ${i}`);
|
|
189
|
+
const item = items[i] as T;
|
|
181
190
|
if (globalSemaphore) {
|
|
182
191
|
await globalSemaphore.acquire();
|
|
183
192
|
try {
|
|
184
|
-
results[i] = await fn(
|
|
193
|
+
results[i] = await fn(item, i);
|
|
185
194
|
} finally {
|
|
186
195
|
globalSemaphore.release();
|
|
187
196
|
}
|
|
188
197
|
} else {
|
|
189
|
-
results[i] = await fn(
|
|
198
|
+
results[i] = await fn(item, i);
|
|
190
199
|
}
|
|
191
200
|
}
|
|
192
201
|
} finally {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
export type PermissionDecision = "allow" | "ask" | "deny";
|
|
5
|
+
export type PermissionRules = Record<string, PermissionDecision>;
|
|
6
|
+
export interface PermissionConfig { rules?: PermissionRules }
|
|
7
|
+
|
|
8
|
+
export const PERMISSION_POLICY_ENV = "PI_SUBAGENT_PERMISSION_POLICY";
|
|
9
|
+
export const PERMISSION_AUDIT_PATH_ENV = "PI_SUBAGENT_PERMISSION_AUDIT_PATH";
|
|
10
|
+
const INTERNAL_TOOLS = new Set(["contact_supervisor", "intercom", "subagent_wait", "structured_output"]);
|
|
11
|
+
const DECISIONS = new Set<PermissionDecision>(["allow", "ask", "deny"]);
|
|
12
|
+
const MAX_POLICY_BYTES = 16 * 1024;
|
|
13
|
+
const MAX_PREVIEW_BYTES = 2048;
|
|
14
|
+
const SECRET_KEY = /(?:authorization|cookie|credential|password|secret|token|api[-_]?key)/i;
|
|
15
|
+
const SECRET_VALUE = /\b(?:Bearer\s+\S+|(?:sk|ghp|github_pat|xox[baprs])[-_A-Za-z0-9]{8,})\b/gi;
|
|
16
|
+
|
|
17
|
+
export function validatePermissionRules(value: unknown, label: string): PermissionRules | undefined {
|
|
18
|
+
if (value === undefined) return undefined;
|
|
19
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object mapping tool names to allow, ask, or deny.`);
|
|
20
|
+
const result: PermissionRules = {};
|
|
21
|
+
for (const [tool, decision] of Object.entries(value)) {
|
|
22
|
+
if (!tool.trim()) throw new Error(`${label} contains an empty tool name.`);
|
|
23
|
+
if (tool === "bash") throw new Error(`${label}.bash is unsupported; pi-subagents leaves bash policy to pi-guard.`);
|
|
24
|
+
if (INTERNAL_TOOLS.has(tool)) throw new Error(`${label}.${tool} is reserved for child coordination and cannot be gated.`);
|
|
25
|
+
if (!DECISIONS.has(decision as PermissionDecision)) throw new Error(`${label}.${tool} must be allow, ask, or deny.`);
|
|
26
|
+
result[tool] = decision as PermissionDecision;
|
|
27
|
+
}
|
|
28
|
+
return Object.keys(result).length ? result : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function validatePermissionConfig(value: unknown, label = "config.permissions"): PermissionConfig | undefined {
|
|
32
|
+
if (value === undefined) return undefined;
|
|
33
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object.`);
|
|
34
|
+
const object = value as Record<string, unknown>;
|
|
35
|
+
const unknown = Object.keys(object).filter((key) => key !== "rules");
|
|
36
|
+
if (unknown.length) throw new Error(`${label} has unsupported fields: ${unknown.join(", ")}.`);
|
|
37
|
+
return { rules: validatePermissionRules(object.rules, `${label}.rules`) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function resolvePermissionRules(globalConfig?: PermissionConfig, agentRules?: PermissionRules): PermissionRules | undefined {
|
|
41
|
+
const merged = { ...(globalConfig?.rules ?? {}), ...(agentRules ?? {}) };
|
|
42
|
+
for (const [tool, decision] of Object.entries(merged)) if (decision === "allow") delete merged[tool];
|
|
43
|
+
return Object.keys(merged).length ? merged : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function permissionDecision(rules: PermissionRules | undefined, toolName: string): PermissionDecision {
|
|
47
|
+
if (toolName === "bash" || INTERNAL_TOOLS.has(toolName)) return "allow";
|
|
48
|
+
return rules?.[toolName] ?? "allow";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function encodePermissionRules(rules: PermissionRules | undefined): string | undefined {
|
|
52
|
+
if (!rules || Object.keys(rules).length === 0) return undefined;
|
|
53
|
+
const encoded = JSON.stringify(rules);
|
|
54
|
+
if (Buffer.byteLength(encoded, "utf-8") > MAX_POLICY_BYTES) throw new Error("Resolved permission policy is too large.");
|
|
55
|
+
return encoded;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function decodePermissionRules(encoded: string | undefined): PermissionRules | undefined {
|
|
59
|
+
if (!encoded?.trim()) return undefined;
|
|
60
|
+
return validatePermissionRules(JSON.parse(encoded), PERMISSION_POLICY_ENV);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function redact(value: unknown, key = "", depth = 0): unknown {
|
|
64
|
+
if (SECRET_KEY.test(key)) return "[redacted]";
|
|
65
|
+
if (depth >= 3) return "[truncated]";
|
|
66
|
+
if (Array.isArray(value)) return value.slice(0, 10).map((item) => redact(item, "", depth + 1));
|
|
67
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value as Record<string, unknown>).slice(0, 20).map(([entryKey, entryValue]) => [entryKey, redact(entryValue, entryKey, depth + 1)]));
|
|
68
|
+
if (typeof value === "string") {
|
|
69
|
+
const redacted = value.replace(SECRET_VALUE, "[redacted]");
|
|
70
|
+
return redacted.length > 500 ? `${redacted.slice(0, 500)}…` : redacted;
|
|
71
|
+
}
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function permissionArgsPreview(input: unknown): string {
|
|
76
|
+
const serialized = JSON.stringify(redact(input));
|
|
77
|
+
if (!serialized) return "{}";
|
|
78
|
+
if (Buffer.byteLength(serialized, "utf-8") <= MAX_PREVIEW_BYTES) return serialized;
|
|
79
|
+
const maxContentBytes = MAX_PREVIEW_BYTES - Buffer.byteLength("…", "utf-8");
|
|
80
|
+
let preview = "";
|
|
81
|
+
let previewBytes = 0;
|
|
82
|
+
for (const character of serialized) {
|
|
83
|
+
const characterBytes = Buffer.byteLength(character, "utf-8");
|
|
84
|
+
if (previewBytes + characterBytes > maxContentBytes) break;
|
|
85
|
+
preview += character;
|
|
86
|
+
previewBytes += characterBytes;
|
|
87
|
+
}
|
|
88
|
+
return `${preview}…`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function appendPermissionAudit(filePath: string | undefined, record: Record<string, unknown>): void {
|
|
92
|
+
if (!filePath) return;
|
|
93
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
94
|
+
fs.appendFileSync(filePath, `${JSON.stringify(record)}\n`, { encoding: "utf-8", mode: 0o600 });
|
|
95
|
+
}
|
|
@@ -15,6 +15,7 @@ import { CHILD_TOOL_DIAGNOSTIC_PATH_ENV, MCP_DIRECT_CHILD_TOOLS_ENV, REQUIRED_CH
|
|
|
15
15
|
import { CHILD_WATCHDOG_CONFIG_ENV, encodeChildWatchdogConfig, type ChildWatchdogConfig } from "../../watchdog/child-status.ts";
|
|
16
16
|
import { WAIT_TOOL_ENABLED_ENV } from "../background/wait-config.ts";
|
|
17
17
|
import { PI_CODING_AGENT_PACKAGE_ROOT_ENV } from "../../shared/utils.ts";
|
|
18
|
+
import { encodePermissionRules, PERMISSION_AUDIT_PATH_ENV, PERMISSION_POLICY_ENV, type PermissionRules } from "./permissions.ts";
|
|
18
19
|
import { SUBAGENT_CAPABILITY_CEILING_ENV, capabilityCeilingAgentRestrictionSources, decodeSubagentCapabilityCeiling, encodeSubagentCapabilityCeiling, intersectSubagentCapabilityCeilings, isAgentAllowedByCapabilityCeiling, type ResolvedSubagentCapabilityCeiling, type SubagentCapabilityAudit } from "./capability-ceiling.ts";
|
|
19
20
|
|
|
20
21
|
const TASK_ARG_LIMIT = 8000;
|
|
@@ -87,6 +88,8 @@ export interface BuildPiArgsInput {
|
|
|
87
88
|
};
|
|
88
89
|
toolBudget?: ResolvedToolBudget;
|
|
89
90
|
allowZeroToolBudget?: boolean;
|
|
91
|
+
permissionRules?: PermissionRules;
|
|
92
|
+
permissionAuditPath?: string;
|
|
90
93
|
childWatchdog?: ChildWatchdogConfig;
|
|
91
94
|
waitToolEnabled?: boolean;
|
|
92
95
|
capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
|
|
@@ -125,7 +128,11 @@ export interface ResolvePiLaunchToolPlanInput {
|
|
|
125
128
|
mcpDirectTools?: string[];
|
|
126
129
|
cwd?: string;
|
|
127
130
|
requireReadTool?: boolean;
|
|
128
|
-
structuredOutput?: boolean
|
|
131
|
+
structuredOutput?: boolean | {
|
|
132
|
+
schema: JsonSchemaObject;
|
|
133
|
+
schemaPath: string;
|
|
134
|
+
outputPath: string;
|
|
135
|
+
};
|
|
129
136
|
capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
|
|
130
137
|
inheritedCapabilityCeiling?: ResolvedSubagentCapabilityCeiling;
|
|
131
138
|
agentName?: string;
|
|
@@ -386,6 +393,7 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
|
|
|
386
393
|
if (input.parentSessionId) {
|
|
387
394
|
env[SUBAGENT_ORCHESTRATOR_SESSION_ID_ENV] = input.parentSessionId;
|
|
388
395
|
}
|
|
396
|
+
const encodedPermissionRules = encodePermissionRules(input.permissionRules);
|
|
389
397
|
if (input.orchestratorIntercomTarget && input.parentSessionId && input.runId && input.childAgentName) {
|
|
390
398
|
const childIndex = input.childIndex ?? 0;
|
|
391
399
|
const channelDir = supervisorChannelDir(input.runId, input.childAgentName, childIndex);
|
|
@@ -393,6 +401,7 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
|
|
|
393
401
|
fs.mkdirSync(path.join(channelDir, "replies"), { recursive: true });
|
|
394
402
|
env[SUBAGENT_SUPERVISOR_CHANNEL_DIR_ENV] = channelDir;
|
|
395
403
|
}
|
|
404
|
+
if (encodedPermissionRules) env[PERMISSION_AUDIT_PATH_ENV] = input.permissionAuditPath ?? path.join(tempDir, "permission-audit.jsonl");
|
|
396
405
|
if (input.runId) {
|
|
397
406
|
env[SUBAGENT_RUN_ID_ENV] = input.runId;
|
|
398
407
|
}
|
|
@@ -407,6 +416,7 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
|
|
|
407
416
|
else env.MCP_DIRECT_TOOLS = "__none__";
|
|
408
417
|
const encodedCapabilityCeiling = encodeSubagentCapabilityCeiling(toolPlan.capabilityCeiling);
|
|
409
418
|
if (encodedCapabilityCeiling) env[SUBAGENT_CAPABILITY_CEILING_ENV] = encodedCapabilityCeiling;
|
|
419
|
+
if (encodedPermissionRules) env[PERMISSION_POLICY_ENV] = encodedPermissionRules;
|
|
410
420
|
if (input.structuredOutput) {
|
|
411
421
|
env[STRUCTURED_OUTPUT_CAPTURE_ENV] = input.structuredOutput.outputPath;
|
|
412
422
|
env[STRUCTURED_OUTPUT_SCHEMA_ENV] = input.structuredOutput.schemaPath;
|
|
@@ -70,6 +70,11 @@ function normalizePath(filePath: string): string {
|
|
|
70
70
|
return path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
function isStandalonePiExecutable(execPath: string): boolean {
|
|
74
|
+
const executableName = execPath.split(/[\\/]/).pop();
|
|
75
|
+
return /^pi(?:\.exe)?$/i.test(executableName ?? "");
|
|
76
|
+
}
|
|
77
|
+
|
|
73
78
|
export function resolvePiCliScript(
|
|
74
79
|
deps: PiSpawnDeps = {},
|
|
75
80
|
): string | undefined {
|
|
@@ -141,10 +146,15 @@ export function getPiSpawnCommand(
|
|
|
141
146
|
return { command: piBinary, args };
|
|
142
147
|
}
|
|
143
148
|
|
|
149
|
+
const execPath = deps.execPath ?? process.execPath;
|
|
150
|
+
if (isStandalonePiExecutable(execPath)) {
|
|
151
|
+
return { command: execPath, args };
|
|
152
|
+
}
|
|
153
|
+
|
|
144
154
|
const piCliPath = resolvePiCliScript(deps);
|
|
145
155
|
if (piCliPath) {
|
|
146
156
|
return {
|
|
147
|
-
command:
|
|
157
|
+
command: execPath,
|
|
148
158
|
args: [piCliPath, ...args],
|
|
149
159
|
};
|
|
150
160
|
}
|
|
@@ -176,6 +176,6 @@ export function loadRunsForAgent(agent: string): RunEntry[] {
|
|
|
176
176
|
|
|
177
177
|
return lines
|
|
178
178
|
.map((line) => { try { return JSON.parse(line) as RunEntry; } catch { return undefined; } })
|
|
179
|
-
.filter((entry): entry is RunEntry =>
|
|
179
|
+
.filter((entry): entry is RunEntry => entry !== undefined && entry.agent === agent)
|
|
180
180
|
.reverse();
|
|
181
181
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { registerNativeSupervisorClient } from "../../intercom/native-supervisor-channel.ts";
|
|
5
|
+
import { decodePermissionRules, permissionDecision, PERMISSION_AUDIT_PATH_ENV, PERMISSION_POLICY_ENV } from "./permissions.ts";
|
|
5
6
|
import { consumeSteerRequestsFromDir, steerAckPathFromDir, writeSteerAckAt, writeSteerCapabilityAt, writeSteerRequestToDir, type SteerRequest } from "../background/control-channel.ts";
|
|
6
7
|
import { SUBAGENT_CHILD_AGENT_ENV, SUBAGENT_CHILD_INDEX_ENV, SUBAGENT_FANOUT_CHILD_ENV, SUBAGENT_STEER_ACK_DIR_ENV, SUBAGENT_STEER_CAPABILITY_ENV, SUBAGENT_STEER_INBOX_ENV } from "./pi-args.ts";
|
|
7
8
|
import { RUNTIME_EXTENSION_ACK_EVENT, RUNTIME_EXTENSION_ACK_PATH_ENV, isRuntimeAcknowledgedExtensionId, writeRuntimeAcknowledgedExtensions } from "./runtime-acknowledged-extensions.ts";
|
|
@@ -18,6 +19,8 @@ import type { JsonSchemaObject, ResolvedToolBudget, SubagentState } from "../../
|
|
|
18
19
|
import { resolveCurrentSessionId } from "../../shared/session-identity.ts";
|
|
19
20
|
import { resolveWatchPath } from "../../shared/utils.ts";
|
|
20
21
|
import { registerChildWatchdog } from "../../watchdog/register-child.ts";
|
|
22
|
+
import { CHILD_WATCHDOG_CONFIG_ENV } from "../../watchdog/child-status.ts";
|
|
23
|
+
import { requestWatchdogPermission, type WatchdogPermissionRequest, type WatchdogPermissionResult } from "../../watchdog/permission-arbiter.ts";
|
|
21
24
|
import { SUBAGENT_WATCHDOG_WARNING_TYPE } from "../../watchdog/types.ts";
|
|
22
25
|
import { resolveWaitToolConfig } from "../background/wait-config.ts";
|
|
23
26
|
import { registerWaitTool } from "../background/wait-tool.ts";
|
|
@@ -241,6 +244,31 @@ export function formatSteerMessage(request: SteerRequest): string {
|
|
|
241
244
|
].join("\n");
|
|
242
245
|
}
|
|
243
246
|
|
|
247
|
+
export function registerPermissionGate(
|
|
248
|
+
pi: ExtensionAPI,
|
|
249
|
+
requestPermission: (request: WatchdogPermissionRequest) => Promise<WatchdogPermissionResult> = requestWatchdogPermission,
|
|
250
|
+
): void {
|
|
251
|
+
const rules = decodePermissionRules(process.env[PERMISSION_POLICY_ENV]);
|
|
252
|
+
if (!rules) return;
|
|
253
|
+
const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: { toolName?: string; input?: unknown }, ctx: ExtensionContext) => unknown) => void;
|
|
254
|
+
onRuntimeEvent("tool_call", async (event, ctx) => {
|
|
255
|
+
const toolName = typeof event.toolName === "string" ? event.toolName : "tool";
|
|
256
|
+
const decision = permissionDecision(rules, toolName);
|
|
257
|
+
if (decision === "allow") return undefined;
|
|
258
|
+
if (decision === "deny") return { block: true, reason: `Blocked by pi-subagents permission rule: '${toolName}' is denied.` };
|
|
259
|
+
const result = await requestPermission({
|
|
260
|
+
ctx,
|
|
261
|
+
toolName,
|
|
262
|
+
args: event.input ?? {},
|
|
263
|
+
rawWatchdogConfig: process.env[CHILD_WATCHDOG_CONFIG_ENV],
|
|
264
|
+
auditPath: process.env[PERMISSION_AUDIT_PATH_ENV],
|
|
265
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
266
|
+
});
|
|
267
|
+
if (result.approved) return undefined;
|
|
268
|
+
return { block: true, reason: `Blocked by pi-subagents permission rule: ${result.reason}` };
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
244
272
|
function registerToolBudget(pi: ExtensionAPI, budget: ResolvedToolBudget | undefined): void {
|
|
245
273
|
if (!budget) return;
|
|
246
274
|
let toolCount = 0;
|
|
@@ -360,7 +388,7 @@ export function registerSteeringInbox(
|
|
|
360
388
|
return undefined;
|
|
361
389
|
};
|
|
362
390
|
|
|
363
|
-
const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: unknown) => unknown) => void;
|
|
391
|
+
const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: unknown, ctx?: unknown) => unknown) => void;
|
|
364
392
|
// Register input before the watcher so an accepted extension input cannot race request dispatch.
|
|
365
393
|
onRuntimeEvent("input", onInput);
|
|
366
394
|
onRuntimeEvent("session_start", () => start());
|
|
@@ -377,6 +405,7 @@ export function registerSteeringInbox(
|
|
|
377
405
|
export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
|
|
378
406
|
registerRuntimeExtensionAcknowledgements(pi);
|
|
379
407
|
registerSteeringInbox(pi);
|
|
408
|
+
registerPermissionGate(pi);
|
|
380
409
|
registerToolBudget(pi, decodeToolBudgetEnv(process.env[TOOL_BUDGET_ENV], { allowZero: process.env[TOOL_BUDGET_ZERO_AUTH_ENV] === "1" }));
|
|
381
410
|
registerChildWatchdog(pi);
|
|
382
411
|
const waitToolEnabled = resolveWaitToolConfig().enabled;
|
|
@@ -408,7 +437,7 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
|
|
|
408
437
|
nativeSupervisorFallbackRegistered = true;
|
|
409
438
|
registerNativeSupervisorClient(pi);
|
|
410
439
|
};
|
|
411
|
-
const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: unknown) => unknown) => void;
|
|
440
|
+
const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: unknown, ctx?: unknown) => unknown) => void;
|
|
412
441
|
onRuntimeEvent("session_start", (_event: unknown, ctx: unknown) => {
|
|
413
442
|
const sessionManager = (ctx as { sessionManager?: Parameters<typeof resolveCurrentSessionId>[0] } | undefined)?.sessionManager;
|
|
414
443
|
waitState.currentSessionId = sessionManager ? resolveCurrentSessionId(sessionManager) : null;
|
|
@@ -455,13 +484,15 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
|
|
|
455
484
|
});
|
|
456
485
|
}
|
|
457
486
|
|
|
458
|
-
onRuntimeEvent("context", (event:
|
|
487
|
+
onRuntimeEvent("context", (event: unknown) => {
|
|
488
|
+
if (!event || typeof event !== "object" || !("messages" in event) || !Array.isArray(event.messages)) return undefined;
|
|
459
489
|
const messages = stripParentOnlySubagentMessages(event.messages);
|
|
460
490
|
if (messages === event.messages) return undefined;
|
|
461
491
|
return { messages };
|
|
462
492
|
});
|
|
463
493
|
|
|
464
|
-
onRuntimeEvent("before_agent_start", async (event:
|
|
494
|
+
onRuntimeEvent("before_agent_start", async (event: unknown) => {
|
|
495
|
+
if (!event || typeof event !== "object" || !("systemPrompt" in event) || typeof event.systemPrompt !== "string") return undefined;
|
|
465
496
|
registerNativeSupervisorFallbackOnce();
|
|
466
497
|
const intercomSessionName = process.env[SUBAGENT_INTERCOM_SESSION_NAME_ENV]?.trim();
|
|
467
498
|
if (intercomSessionName && typeof pi.setSessionName === "function") {
|