pi-subagents 0.46.0 → 0.47.1
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 +37 -0
- package/agents/reviewer.md +3 -4
- package/docs/agents.md +1 -1
- package/docs/configuration.md +14 -6
- package/docs/extension-api.md +1 -1
- package/docs/missions.md +5 -3
- package/docs/models.md +3 -1
- package/docs/observability.md +3 -3
- package/docs/tool-reference.md +2 -2
- package/docs/watchdog.md +1 -1
- package/package.json +1 -1
- package/skills/pi-subagents/references/execution-controls.md +4 -4
- package/skills/pi-subagents/references/management-authoring-rpc.md +1 -1
- package/src/extension/config.ts +13 -0
- package/src/extension/fanout-child.ts +5 -4
- package/src/extension/index.ts +39 -14
- package/src/extension/rpc.ts +7 -6
- package/src/extension/schemas.ts +27 -7
- package/src/extension/tool-description.ts +26 -8
- package/src/inspectors/herdr/project-panes.ts +2 -1
- package/src/intercom/native-supervisor-channel.ts +102 -4
- package/src/missions/store.ts +2 -1
- package/src/missions/workflow-state.ts +19 -13
- package/src/runs/background/active-run-index.ts +42 -0
- package/src/runs/background/async-execution.ts +10 -5
- package/src/runs/background/async-job-tracker.ts +293 -133
- package/src/runs/background/async-resume.ts +19 -3
- package/src/runs/background/async-status.ts +33 -5
- package/src/runs/background/control-channel.ts +91 -17
- package/src/runs/background/result-watcher.ts +102 -7
- package/src/runs/background/run-status.ts +18 -0
- package/src/runs/background/scheduled-runs.ts +25 -1
- package/src/runs/background/stale-run-reconciler.ts +12 -24
- package/src/runs/background/subagent-runner.ts +68 -10
- package/src/runs/foreground/async-dismiss-action.ts +85 -0
- package/src/runs/foreground/async-steering-action.ts +6 -7
- package/src/runs/foreground/chain-execution.ts +3 -0
- package/src/runs/foreground/execution.ts +3 -0
- package/src/runs/foreground/subagent-executor.ts +129 -20
- package/src/runs/foreground/workflow-foreground-steering.ts +187 -0
- package/src/runs/shared/dynamic-fanout.ts +1 -1
- package/src/runs/shared/model-fallback.ts +8 -4
- package/src/runs/shared/model-scope.ts +12 -2
- package/src/runs/shared/parallel-utils.ts +1 -0
- package/src/runs/shared/subagent-prompt-runtime.ts +24 -5
- package/src/runs/shared/worktree.ts +3 -2
- package/src/shared/artifacts.ts +15 -15
- package/src/shared/display-text.ts +100 -0
- package/src/shared/file-coalescer.ts +9 -0
- package/src/shared/formatters.ts +4 -6
- package/src/shared/settings.ts +15 -2
- package/src/shared/types.ts +16 -2
- package/src/shared/utils.ts +43 -33
- package/src/slash/slash-commands.ts +3 -1
- package/src/tui/fleet-status.ts +14 -10
- package/src/tui/render.ts +30 -26
- package/src/watchdog/change-signature.ts +4 -3
- package/src/workflows/scripted-workflow.ts +4 -1
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
5
|
+
import type { Details, ForegroundRunControl, SubagentState } from "../../shared/types.ts";
|
|
6
|
+
import { readStatus } from "../../shared/utils.ts";
|
|
7
|
+
import {
|
|
8
|
+
consumeSteerAckFromDir,
|
|
9
|
+
readSteerCapability,
|
|
10
|
+
steerAcksDir,
|
|
11
|
+
steerCapabilityPath,
|
|
12
|
+
stepSteerInboxDir,
|
|
13
|
+
writeSteerRequestToExistingDir,
|
|
14
|
+
type SteerDeliveryMode,
|
|
15
|
+
type SteerRequest,
|
|
16
|
+
} from "../background/control-channel.ts";
|
|
17
|
+
|
|
18
|
+
export interface WorkflowForegroundSteeringTarget {
|
|
19
|
+
control: ForegroundRunControl;
|
|
20
|
+
workflowRunId: string;
|
|
21
|
+
sourceRunId: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type WorkflowForegroundSteeringResolution =
|
|
25
|
+
| { ok: true; target: WorkflowForegroundSteeringTarget }
|
|
26
|
+
| { ok: false; message: string };
|
|
27
|
+
|
|
28
|
+
function activeWorkflowError(state: SubagentState, workflowRunId: string, asyncDirRoot: string): string | undefined {
|
|
29
|
+
if (!state.currentSessionId) return "Workflow steering requires an active parent session.";
|
|
30
|
+
if (!state.workflowControllers?.has(workflowRunId)) return `Workflow '${workflowRunId}' has no live foreground child.`;
|
|
31
|
+
const status = readStatus(path.join(asyncDirRoot, workflowRunId));
|
|
32
|
+
if (!status || status.mode !== "workflow" || (status.state !== "running" && status.state !== "queued")) {
|
|
33
|
+
return `Workflow '${workflowRunId}' has no live foreground child.`;
|
|
34
|
+
}
|
|
35
|
+
if (status.sessionId !== state.currentSessionId) return `Workflow '${workflowRunId}' was not found in the active session.`;
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function controlIsLiveInWorkflow(control: ForegroundRunControl, workflowRunId: string, sessionId: string): boolean {
|
|
40
|
+
return control.parentWorkflowRunId === workflowRunId
|
|
41
|
+
&& control.sessionId === sessionId
|
|
42
|
+
&& Boolean(control.workflowSteeringDir)
|
|
43
|
+
&& (control.activeChildren?.size ?? 0) > 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resolveWorkflowForegroundSteeringTarget(input: {
|
|
47
|
+
state: SubagentState;
|
|
48
|
+
childRunId?: string;
|
|
49
|
+
workflowRunId?: string;
|
|
50
|
+
asyncDirRoot: string;
|
|
51
|
+
}): WorkflowForegroundSteeringResolution {
|
|
52
|
+
const { state, childRunId, asyncDirRoot } = input;
|
|
53
|
+
if (childRunId) {
|
|
54
|
+
const control = state.foregroundControls.get(childRunId);
|
|
55
|
+
if (!control?.parentWorkflowRunId) return { ok: false, message: `Foreground run '${childRunId}' is not a live workflow-owned child.` };
|
|
56
|
+
const workflowRunId = control.parentWorkflowRunId;
|
|
57
|
+
const workflowError = activeWorkflowError(state, workflowRunId, asyncDirRoot);
|
|
58
|
+
if (workflowError) return { ok: false, message: workflowError };
|
|
59
|
+
if (!controlIsLiveInWorkflow(control, workflowRunId, state.currentSessionId!)) {
|
|
60
|
+
return { ok: false, message: `Foreground run '${childRunId}' is not a live workflow-owned child in the active session.` };
|
|
61
|
+
}
|
|
62
|
+
return { ok: true, target: { control, workflowRunId, sourceRunId: childRunId } };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const workflowRunId = input.workflowRunId;
|
|
66
|
+
if (!workflowRunId) return { ok: false, message: "Workflow steering requires a workflow or child run id." };
|
|
67
|
+
const workflowError = activeWorkflowError(state, workflowRunId, asyncDirRoot);
|
|
68
|
+
if (workflowError) return { ok: false, message: workflowError };
|
|
69
|
+
const controls = [...state.foregroundControls.values()].filter((control) => controlIsLiveInWorkflow(control, workflowRunId, state.currentSessionId!));
|
|
70
|
+
if (controls.length === 0) return { ok: false, message: `Workflow '${workflowRunId}' has no live foreground child.` };
|
|
71
|
+
if (controls.length > 1) return { ok: false, message: `Workflow '${workflowRunId}' has ${controls.length} live foreground children; steer a child run id instead.` };
|
|
72
|
+
return { ok: true, target: { control: controls[0]!, workflowRunId, sourceRunId: workflowRunId } };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function managementError(message: string): AgentToolResult<Details> {
|
|
76
|
+
return { content: [{ type: "text", text: message }], isError: true, details: { mode: "management", results: [] } };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function steerWorkflowForegroundTarget(input: {
|
|
80
|
+
target: WorkflowForegroundSteeringTarget;
|
|
81
|
+
message: string;
|
|
82
|
+
mode?: SteerDeliveryMode;
|
|
83
|
+
index?: number;
|
|
84
|
+
signal?: AbortSignal;
|
|
85
|
+
ackTimeoutMs?: number;
|
|
86
|
+
}): Promise<AgentToolResult<Details>> {
|
|
87
|
+
const { control, sourceRunId } = input.target;
|
|
88
|
+
const routeDir = control.workflowSteeringDir;
|
|
89
|
+
if (!routeDir || !fs.existsSync(routeDir)) return managementError(`Foreground run '${control.runId}' has no live workflow steering route.`);
|
|
90
|
+
const activeIndexes = [...(control.activeChildren?.keys() ?? [])].sort((left, right) => left - right);
|
|
91
|
+
const index = input.index ?? (activeIndexes.length === 1 ? activeIndexes[0] : undefined);
|
|
92
|
+
if (index === undefined) {
|
|
93
|
+
return managementError(activeIndexes.length === 0
|
|
94
|
+
? `Foreground run '${control.runId}' has no live child session.`
|
|
95
|
+
: `Foreground run '${control.runId}' has ${activeIndexes.length} live child sessions; provide index.`);
|
|
96
|
+
}
|
|
97
|
+
if (!activeIndexes.includes(index)) return managementError(`Foreground run '${control.runId}' child ${index} is not live.`);
|
|
98
|
+
const capability = readSteerCapability(routeDir, index);
|
|
99
|
+
if (capability?.supported === false) return managementError(`Foreground run '${control.runId}' child ${index} does not support steering.`);
|
|
100
|
+
|
|
101
|
+
const request: SteerRequest = {
|
|
102
|
+
type: "steer",
|
|
103
|
+
id: randomUUID(),
|
|
104
|
+
ts: Date.now(),
|
|
105
|
+
message: input.message.trim(),
|
|
106
|
+
...(input.mode && input.mode !== "steer" ? { mode: input.mode } : {}),
|
|
107
|
+
targetIndex: index,
|
|
108
|
+
source: "steer-action",
|
|
109
|
+
};
|
|
110
|
+
try {
|
|
111
|
+
writeSteerRequestToExistingDir(stepSteerInboxDir(routeDir, index), request);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (typeof error === "object" && error !== null && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
114
|
+
return managementError(`Foreground run '${control.runId}' has no live workflow steering route.`);
|
|
115
|
+
}
|
|
116
|
+
return managementError(`Failed to queue steering for foreground run ${control.runId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const deadline = Date.now() + (input.ackTimeoutMs ?? 3_000);
|
|
120
|
+
let ack;
|
|
121
|
+
let routeRemoved = false;
|
|
122
|
+
while (Date.now() <= deadline) {
|
|
123
|
+
ack = consumeSteerAckFromDir(steerAcksDir(routeDir, index), request.id);
|
|
124
|
+
if (ack || input.signal?.aborted) break;
|
|
125
|
+
if (!fs.existsSync(routeDir)) {
|
|
126
|
+
routeRemoved = true;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
await new Promise<void>((resolve) => setTimeout(resolve, Math.min(50, Math.max(1, deadline - Date.now()))));
|
|
130
|
+
}
|
|
131
|
+
if (routeRemoved || (!ack && !input.signal?.aborted && !fs.existsSync(routeDir))) {
|
|
132
|
+
return managementError(`Foreground run '${control.runId}' has no live child session.`);
|
|
133
|
+
}
|
|
134
|
+
const target = ack?.state === "delivered"
|
|
135
|
+
? { index, state: "delivered" as const, deliveredAt: ack.ts }
|
|
136
|
+
: ack?.state === "queued"
|
|
137
|
+
? { index, state: "queued" as const }
|
|
138
|
+
: ack?.state === "failed"
|
|
139
|
+
? { index, state: "failed" as const, reason: ack.message }
|
|
140
|
+
: { index, state: "pending" as const };
|
|
141
|
+
const steering = {
|
|
142
|
+
requestId: request.id,
|
|
143
|
+
state: ack?.state === "delivered" ? "delivered" as const : ack?.state === "failed" ? "failed" as const : "pending" as const,
|
|
144
|
+
deliveryStatus: ack?.state === "delivered" ? "delivered" as const : "queued" as const,
|
|
145
|
+
sourceRunId,
|
|
146
|
+
targets: [target],
|
|
147
|
+
};
|
|
148
|
+
if (input.signal?.aborted) {
|
|
149
|
+
return { content: [{ type: "text", text: `Steering pending for foreground run ${control.runId} (request ${request.id}); caller aborted before acknowledgment.` }], details: { mode: "management", results: [], steering } };
|
|
150
|
+
}
|
|
151
|
+
if (ack?.state === "delivered") {
|
|
152
|
+
return { content: [{ type: "text", text: `Steering delivered for foreground run ${control.runId} (request ${request.id}).` }], details: { mode: "management", results: [], steering } };
|
|
153
|
+
}
|
|
154
|
+
if (ack?.state === "queued") {
|
|
155
|
+
return { content: [{ type: "text", text: `Steering queued for foreground run ${control.runId} (request ${request.id}).` }], details: { mode: "management", results: [], steering } };
|
|
156
|
+
}
|
|
157
|
+
if (ack?.state === "failed") {
|
|
158
|
+
return { content: [{ type: "text", text: `Steering failed for foreground run ${control.runId} (request ${request.id}): ${ack.message}` }], isError: true, details: { mode: "management", results: [], steering } };
|
|
159
|
+
}
|
|
160
|
+
return { content: [{ type: "text", text: `Steering pending for foreground run ${control.runId} (request ${request.id}); no acknowledgment was received.` }], details: { mode: "management", results: [], steering } };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function workflowForegroundSteeringDir(asyncDirRoot: string, workflowRunId: string, childRunId: string): string {
|
|
164
|
+
return path.join(asyncDirRoot, workflowRunId, "control", "workflow-foreground", childRunId);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function removeWorkflowForegroundSteeringRoute(control: ForegroundRunControl): void {
|
|
168
|
+
if (!control.workflowSteeringDir) return;
|
|
169
|
+
try {
|
|
170
|
+
fs.rmSync(control.workflowSteeringDir, { recursive: true, force: true });
|
|
171
|
+
} catch (error) {
|
|
172
|
+
console.warn(`[pi-subagents] Failed to remove workflow foreground steering route '${control.workflowSteeringDir}': ${error instanceof Error ? error.message : String(error)}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function workflowForegroundSteeringLaunchOptions(control: ForegroundRunControl | undefined, index: number): Pick<import("../../shared/types.ts").RunSyncOptions, "steerInboxDir" | "steerCapabilityPath" | "steerAckDir"> {
|
|
177
|
+
if (!control?.workflowSteeringDir) return {};
|
|
178
|
+
const steerInboxDir = stepSteerInboxDir(control.workflowSteeringDir, index);
|
|
179
|
+
const steerAckDir = steerAcksDir(control.workflowSteeringDir, index);
|
|
180
|
+
fs.mkdirSync(steerInboxDir, { recursive: true });
|
|
181
|
+
fs.mkdirSync(steerAckDir, { recursive: true });
|
|
182
|
+
return {
|
|
183
|
+
steerInboxDir,
|
|
184
|
+
steerCapabilityPath: steerCapabilityPath(control.workflowSteeringDir, index),
|
|
185
|
+
steerAckDir,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -49,7 +49,7 @@ const DYNAMIC_EXPAND_FROM_KEYS = new Set(["output", "path"]);
|
|
|
49
49
|
const DYNAMIC_PARALLEL_KEYS = new Set(["agent", "task", "phase", "label", "outputSchema", "cwd", "output", "outputMode", "reads", "progress", "skill", "model", "toolBudget", "acceptance", "agentContract", "gateOn"]);
|
|
50
50
|
const RUNNER_DYNAMIC_PARALLEL_KEYS = new Set([
|
|
51
51
|
...DYNAMIC_PARALLEL_KEYS,
|
|
52
|
-
"outputName", "structured", "inheritProjectContext", "inheritSkills", "skills", "outputPath", "namespaceOutputPath", "maxSubagentDepth", "waitToolEnabled",
|
|
52
|
+
"outputName", "structured", "inheritProjectContext", "inheritSkills", "skills", "outputPath", "namespaceOutputPath", "maxSubagentDepth", "timeoutMs", "waitToolEnabled",
|
|
53
53
|
"structuredOutput", "structuredOutputSchema", "tools", "extensions", "subagentOnlyExtensions", "mcpDirectTools", "capabilityCeiling", "completionGuard", "systemPrompt",
|
|
54
54
|
"systemPromptMode", "thinking", "modelCandidates", "sessionFile", "effectiveAcceptance", "acceptanceInput", "acceptanceRole", "parentSessionId", "launchResolvedExtensions",
|
|
55
55
|
]);
|
|
@@ -191,7 +191,8 @@ function defaultScopeWarn(violation: ModelScopeViolation): void {
|
|
|
191
191
|
*
|
|
192
192
|
* An explicitly requested model string is resolved via {@link resolveModelCandidate}.
|
|
193
193
|
* When `options.scope.enforce` is on, an out-of-scope resolved model throws for
|
|
194
|
-
* an explicit (`source: "explicit"`) request and warns for an inherited one
|
|
194
|
+
* an explicit (`source: "explicit"`) request and warns for an inherited one,
|
|
195
|
+
* unless strict scope enforcement makes inherited violations hard errors.
|
|
195
196
|
*/
|
|
196
197
|
export function resolveSubagentModelOverride(
|
|
197
198
|
requestedModel: string | boolean | undefined,
|
|
@@ -245,7 +246,7 @@ export function resolveEffectiveSubagentModel(
|
|
|
245
246
|
}
|
|
246
247
|
|
|
247
248
|
export interface BuildModelCandidatesOptions {
|
|
248
|
-
/** Fallback models
|
|
249
|
+
/** Fallback models warn by default and throw when strict scope enforcement is enabled. */
|
|
249
250
|
scope?: ModelScopeConfig;
|
|
250
251
|
onWarn?: (violation: ModelScopeViolation) => void;
|
|
251
252
|
}
|
|
@@ -265,9 +266,12 @@ export function buildModelCandidates(
|
|
|
265
266
|
if (!raw) continue;
|
|
266
267
|
const normalized = resolveModelCandidate(raw.trim(), availableModels, preferredProvider);
|
|
267
268
|
if (!normalized || seen.has(normalized)) continue;
|
|
268
|
-
if (index > 0 && options?.scope?.enforce) {
|
|
269
|
+
if ((index > 0 || options?.scope?.strict === true) && options?.scope?.enforce) {
|
|
269
270
|
const violation = checkModelScope(normalized, options.scope, "inherited");
|
|
270
|
-
if (violation)
|
|
271
|
+
if (violation) {
|
|
272
|
+
if (violation.severity === "error") throw new Error(violation.message);
|
|
273
|
+
(options.onWarn ?? defaultScopeWarn)(violation);
|
|
274
|
+
}
|
|
271
275
|
}
|
|
272
276
|
seen.add(normalized);
|
|
273
277
|
candidates.push(normalized);
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* where the model came from: an explicit caller-supplied model (`--model`,
|
|
7
7
|
* tool-call `model`, or a TUI clarify pick) is a hard error, while a model
|
|
8
8
|
* inherited from agent frontmatter / `defaultModel` / the parent session only
|
|
9
|
-
* emits a warning so existing configurations keep working.
|
|
9
|
+
* emits a warning so existing configurations keep working. Optional strict
|
|
10
|
+
* enforcement makes inherited models hard errors too.
|
|
10
11
|
*
|
|
11
12
|
* The decision logic ({@link checkModelScope}) is a pure function of its
|
|
12
13
|
* inputs so it can be unit-tested without touching the filesystem or config.
|
|
@@ -16,6 +17,8 @@ import { splitKnownThinkingSuffix } from "../../shared/model-info.ts";
|
|
|
16
17
|
|
|
17
18
|
export interface ModelScopeConfig {
|
|
18
19
|
enforce?: boolean;
|
|
20
|
+
/** Reject inherited and fallback models outside the allowlist instead of warning. */
|
|
21
|
+
strict?: boolean;
|
|
19
22
|
/** Glob-style allow patterns (only `*` is special), matched against `provider/id`. */
|
|
20
23
|
allow?: string[];
|
|
21
24
|
}
|
|
@@ -67,7 +70,7 @@ export function checkModelScope(
|
|
|
67
70
|
if (allow.some((pattern) => matchesScopePattern(model, pattern))) return undefined;
|
|
68
71
|
|
|
69
72
|
const baseModel = stripThinkingSuffix(model);
|
|
70
|
-
const severity: ModelScopeViolation["severity"] = source === "explicit" ? "error" : "warn";
|
|
73
|
+
const severity: ModelScopeViolation["severity"] = source === "explicit" || scope.strict === true ? "error" : "warn";
|
|
71
74
|
return {
|
|
72
75
|
model: baseModel,
|
|
73
76
|
severity,
|
|
@@ -102,6 +105,13 @@ export function parseModelScopeConfig(
|
|
|
102
105
|
config.enforce = input.enforce;
|
|
103
106
|
}
|
|
104
107
|
|
|
108
|
+
if ("strict" in input) {
|
|
109
|
+
if (typeof input.strict !== "boolean") {
|
|
110
|
+
throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.strict'; expected a boolean.`);
|
|
111
|
+
}
|
|
112
|
+
config.strict = input.strict;
|
|
113
|
+
}
|
|
114
|
+
|
|
105
115
|
if ("allow" in input) {
|
|
106
116
|
if (!Array.isArray(input.allow)) {
|
|
107
117
|
throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.allow'; expected an array of strings.`);
|
|
@@ -40,6 +40,7 @@ export interface RunnerSubagentStep {
|
|
|
40
40
|
outputMode?: "inline" | "file-only";
|
|
41
41
|
sessionFile?: string;
|
|
42
42
|
maxSubagentDepth?: number;
|
|
43
|
+
timeoutMs?: number;
|
|
43
44
|
waitToolEnabled?: boolean;
|
|
44
45
|
structuredOutput?: {
|
|
45
46
|
schema: import("../../shared/types.ts").JsonSchemaObject;
|
|
@@ -31,6 +31,7 @@ const SUBAGENT_INHERIT_PROJECT_CONTEXT_ENV = "PI_SUBAGENT_INHERIT_PROJECT_CONTEX
|
|
|
31
31
|
const SUBAGENT_INHERIT_SKILLS_ENV = "PI_SUBAGENT_INHERIT_SKILLS";
|
|
32
32
|
export const SUBAGENT_INTERCOM_SESSION_NAME_ENV = "PI_SUBAGENT_INTERCOM_SESSION_NAME";
|
|
33
33
|
const STEERING_LEGACY_SETTLE_FALLBACK_MS = 1000;
|
|
34
|
+
const STEERING_SAFETY_POLL_INTERVAL_MS = 5000;
|
|
34
35
|
|
|
35
36
|
const STRUCTURED_OUTPUT_INSTRUCTIONS = [
|
|
36
37
|
"This subagent step has a strict structured output contract.",
|
|
@@ -331,7 +332,13 @@ function registerToolBudget(pi: ExtensionAPI, budget: ResolvedToolBudget | undef
|
|
|
331
332
|
|
|
332
333
|
export function registerSteeringInbox(
|
|
333
334
|
pi: ExtensionAPI,
|
|
334
|
-
deps: {
|
|
335
|
+
deps: {
|
|
336
|
+
watch?: typeof fs.watch;
|
|
337
|
+
nativeRealpath?: (filePath: string) => string;
|
|
338
|
+
legacySettleFallbackMs?: number;
|
|
339
|
+
safetyPollIntervalMs?: number;
|
|
340
|
+
timers?: Pick<typeof globalThis, "setInterval" | "clearInterval">;
|
|
341
|
+
} = {},
|
|
335
342
|
): void {
|
|
336
343
|
const steerInbox = process.env[SUBAGENT_STEER_INBOX_ENV]?.trim();
|
|
337
344
|
if (!steerInbox) return;
|
|
@@ -350,6 +357,7 @@ export function registerSteeringInbox(
|
|
|
350
357
|
let canSteer = typeof sendUserMessage === "function";
|
|
351
358
|
let watcher: fs.FSWatcher | undefined;
|
|
352
359
|
let interval: NodeJS.Timeout | undefined;
|
|
360
|
+
let safetyInterval: NodeJS.Timeout | undefined;
|
|
353
361
|
let settleFallback: NodeJS.Timeout | undefined;
|
|
354
362
|
const legacySettleFallbackMs = deps.legacySettleFallbackMs ?? STEERING_LEGACY_SETTLE_FALLBACK_MS;
|
|
355
363
|
const acknowledge = (request: SteerRequest, state: "delivered" | "queued" | "failed", message: string, deliveryStatus?: SteerDeliveryStatus): void => {
|
|
@@ -431,14 +439,24 @@ export function registerSteeringInbox(
|
|
|
431
439
|
return;
|
|
432
440
|
}
|
|
433
441
|
started = true;
|
|
442
|
+
const startPolling = (): void => {
|
|
443
|
+
if (interval || disposed) return;
|
|
444
|
+
interval = (deps.timers?.setInterval ?? setInterval)(flush, 250) as NodeJS.Timeout;
|
|
445
|
+
interval.unref?.();
|
|
446
|
+
};
|
|
447
|
+
const startSafetyPolling = (): void => {
|
|
448
|
+
if (safetyInterval || disposed) return;
|
|
449
|
+
safetyInterval = (deps.timers?.setInterval ?? setInterval)(flush, deps.safetyPollIntervalMs ?? STEERING_SAFETY_POLL_INTERVAL_MS) as NodeJS.Timeout;
|
|
450
|
+
safetyInterval.unref?.();
|
|
451
|
+
};
|
|
434
452
|
try {
|
|
435
453
|
watcher = (deps.watch ?? fs.watch)(resolveWatchPath(steerInbox, deps.nativeRealpath), () => flush());
|
|
436
|
-
watcher.on("error",
|
|
454
|
+
watcher.on("error", startPolling);
|
|
455
|
+
startSafetyPolling();
|
|
437
456
|
} catch {
|
|
438
457
|
watcher = undefined;
|
|
458
|
+
startPolling();
|
|
439
459
|
}
|
|
440
|
-
interval = setInterval(flush, 250);
|
|
441
|
-
interval.unref?.();
|
|
442
460
|
};
|
|
443
461
|
const activate = (): undefined => {
|
|
444
462
|
start();
|
|
@@ -534,7 +552,8 @@ export function registerSteeringInbox(
|
|
|
534
552
|
disposed = true;
|
|
535
553
|
clearSettleFallback();
|
|
536
554
|
try { watcher?.close(); } catch {}
|
|
537
|
-
if (interval) clearInterval(interval);
|
|
555
|
+
if (interval) (deps.timers?.clearInterval ?? clearInterval)(interval);
|
|
556
|
+
if (safetyInterval) (deps.timers?.clearInterval ?? clearInterval)(safetyInterval);
|
|
538
557
|
});
|
|
539
558
|
}
|
|
540
559
|
|
|
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
|
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { resolveAuthorityDecision, type AuthorityPolicyConfig } from "../../policy/authority.ts";
|
|
6
|
+
import { PROJECT_SUBAGENTS_RELATIVE_DIR } from "../../shared/artifacts.ts";
|
|
6
7
|
|
|
7
8
|
export interface WorktreeSetup {
|
|
8
9
|
cwd: string;
|
|
@@ -135,9 +136,9 @@ function resolveRepoState(cwd: string): RepoState {
|
|
|
135
136
|
const cwdRelative = resolveRepoCwdRelative(cwd);
|
|
136
137
|
const toplevel = runGitChecked(cwd, ["rev-parse", "--show-toplevel"]).trim();
|
|
137
138
|
|
|
138
|
-
// pi-subagents writes durable runtime state under .pi
|
|
139
|
+
// pi-subagents writes durable runtime state under .pi/subagents/ by default;
|
|
139
140
|
// that state must not make managed isolation unusable for later runs.
|
|
140
|
-
const status = runGitChecked(toplevel, ["status", "--porcelain", "--",
|
|
141
|
+
const status = runGitChecked(toplevel, ["status", "--porcelain", "--", `:!${PROJECT_SUBAGENTS_RELATIVE_DIR}`]);
|
|
141
142
|
if (status.trim().length > 0) {
|
|
142
143
|
throw new Error("worktree isolation requires a clean git working tree. Commit or stash changes first.");
|
|
143
144
|
}
|
package/src/shared/artifacts.ts
CHANGED
|
@@ -3,19 +3,19 @@ import * as path from "node:path";
|
|
|
3
3
|
import { CHAIN_RUNS_DIR, TEMP_ARTIFACTS_DIR, type ArtifactPaths, type ArtifactDirPreference } from "./types.ts";
|
|
4
4
|
import { getAgentDir } from "./utils.ts";
|
|
5
5
|
const CLEANUP_MARKER_FILE = ".last-cleanup";
|
|
6
|
-
const
|
|
6
|
+
export const PROJECT_SUBAGENTS_RELATIVE_DIR = ".pi/subagents";
|
|
7
7
|
|
|
8
8
|
const PROJECT_ARTIFACT_PATHS = [
|
|
9
|
-
`${
|
|
10
|
-
`${
|
|
11
|
-
`${
|
|
12
|
-
`${
|
|
13
|
-
`${
|
|
14
|
-
`${
|
|
15
|
-
`${
|
|
16
|
-
`${
|
|
17
|
-
`${
|
|
18
|
-
`${
|
|
9
|
+
`${PROJECT_SUBAGENTS_RELATIVE_DIR}/artifacts/output.md`,
|
|
10
|
+
`${PROJECT_SUBAGENTS_RELATIVE_DIR}/artifacts/run_worker_input.md`,
|
|
11
|
+
`${PROJECT_SUBAGENTS_RELATIVE_DIR}/artifacts/run_worker_output.md`,
|
|
12
|
+
`${PROJECT_SUBAGENTS_RELATIVE_DIR}/artifacts/run_worker.jsonl`,
|
|
13
|
+
`${PROJECT_SUBAGENTS_RELATIVE_DIR}/artifacts/run_worker_transcript.jsonl`,
|
|
14
|
+
`${PROJECT_SUBAGENTS_RELATIVE_DIR}/artifacts/run_worker_meta.json`,
|
|
15
|
+
`${PROJECT_SUBAGENTS_RELATIVE_DIR}/artifacts/progress/run/progress.md`,
|
|
16
|
+
`${PROJECT_SUBAGENTS_RELATIVE_DIR}/artifacts/outputs/output.md`,
|
|
17
|
+
`${PROJECT_SUBAGENTS_RELATIVE_DIR}/artifacts/outputs/run/output.md`,
|
|
18
|
+
`${PROJECT_SUBAGENTS_RELATIVE_DIR}/chain-runs/run.json`,
|
|
19
19
|
];
|
|
20
20
|
|
|
21
21
|
function globMatchesPath(pattern: string, filePath: string): boolean {
|
|
@@ -59,7 +59,7 @@ function normalizePattern(pattern: string): string {
|
|
|
59
59
|
|
|
60
60
|
function patternMatchesArtifactPath(pattern: string, artifactPath: string): boolean {
|
|
61
61
|
const normalized = normalizePattern(pattern);
|
|
62
|
-
return normalized ===
|
|
62
|
+
return normalized === PROJECT_SUBAGENTS_RELATIVE_DIR
|
|
63
63
|
|| normalized === "*"
|
|
64
64
|
|| artifactPath.startsWith(`${normalized}/`)
|
|
65
65
|
|| globMatchesPath(normalized, artifactPath);
|
|
@@ -127,11 +127,11 @@ export function getProjectArtifactPackagingWarning(cwd: string): string | undefi
|
|
|
127
127
|
const ignorePath = fs.existsSync(npmIgnorePath) ? npmIgnorePath : path.join(cwd, ".gitignore");
|
|
128
128
|
if (filesIncludeArtifacts === undefined && ignoreFileExcludesProjectArtifacts(ignorePath)) return undefined;
|
|
129
129
|
|
|
130
|
-
return "Project-scoped subagent artifacts can be included when this package is published. Add '.pi
|
|
130
|
+
return "Project-scoped subagent artifacts can be included when this package is published. Add '.pi/subagents/' to .npmignore, restrict package.json files, or set artifactDir to 'session' or 'temp'.";
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
export function getProjectSubagentsDir(cwd: string): string {
|
|
134
|
-
return path.join(cwd,
|
|
134
|
+
return path.join(cwd, PROJECT_SUBAGENTS_RELATIVE_DIR);
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
export function getProjectArtifactsDir(cwd: string): string {
|
|
@@ -228,7 +228,7 @@ export function appendJsonl(filePath: string, line: string): void {
|
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
export function cleanupOldArtifacts(dir: string, maxAgeDays: number): void {
|
|
231
|
-
if (!fs.existsSync(dir)) return;
|
|
231
|
+
if (maxAgeDays <= 0 || !fs.existsSync(dir)) return;
|
|
232
232
|
|
|
233
233
|
const markerPath = path.join(dir, CLEANUP_MARKER_FILE);
|
|
234
234
|
const now = Date.now();
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
function codePointWidth(codePoint: number): 1 | 2 {
|
|
2
|
+
return codePoint > 0xffff ? 2 : 1;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function isWhitespaceOrControl(codePoint: number): boolean {
|
|
6
|
+
if (codePoint <= 0x20 || (codePoint >= 0x7f && codePoint <= 0x9f)) return true;
|
|
7
|
+
if (codePoint >= 0xd800 && codePoint <= 0xdfff) return true;
|
|
8
|
+
return String.fromCodePoint(codePoint).trim().length === 0;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function consumeControlString(value: string, index: number, osc: boolean): number {
|
|
12
|
+
while (index < value.length) {
|
|
13
|
+
const codePoint = value.codePointAt(index)!;
|
|
14
|
+
const width = codePointWidth(codePoint);
|
|
15
|
+
if (osc && codePoint === 0x07) return index + width;
|
|
16
|
+
if (codePoint === 0x9c) return index + width;
|
|
17
|
+
if (codePoint === 0x1b && value.charCodeAt(index + 1) === 0x5c) return index + 2;
|
|
18
|
+
index += width;
|
|
19
|
+
}
|
|
20
|
+
return value.length;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function consumeCsi(value: string, index: number): number {
|
|
24
|
+
while (index < value.length) {
|
|
25
|
+
const codePoint = value.codePointAt(index)!;
|
|
26
|
+
const width = codePointWidth(codePoint);
|
|
27
|
+
if (codePoint >= 0x40 && codePoint <= 0x7e) return index + width;
|
|
28
|
+
index += width;
|
|
29
|
+
}
|
|
30
|
+
return value.length;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function sanitizeDisplayText(value: string): string {
|
|
34
|
+
const output: string[] = [];
|
|
35
|
+
let pendingSpace = false;
|
|
36
|
+
|
|
37
|
+
const appendSpace = (): void => {
|
|
38
|
+
if (output.length > 0) pendingSpace = true;
|
|
39
|
+
};
|
|
40
|
+
const appendText = (text: string): void => {
|
|
41
|
+
if (pendingSpace) output.push(" ");
|
|
42
|
+
output.push(text);
|
|
43
|
+
pendingSpace = false;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
for (let index = 0; index < value.length;) {
|
|
47
|
+
const codePoint = value.codePointAt(index)!;
|
|
48
|
+
const width = codePointWidth(codePoint);
|
|
49
|
+
|
|
50
|
+
if (codePoint === 0x1b) {
|
|
51
|
+
const next = value.charCodeAt(index + 1);
|
|
52
|
+
appendSpace();
|
|
53
|
+
if (next === 0x5b) {
|
|
54
|
+
index = consumeCsi(value, index + 2);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (next === 0x5d || next === 0x50 || next === 0x58 || next === 0x5e || next === 0x5f) {
|
|
58
|
+
index = consumeControlString(value, index + 2, next === 0x5d);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
index += next ? 2 : 1;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (codePoint === 0x9b) {
|
|
66
|
+
appendSpace();
|
|
67
|
+
index = consumeCsi(value, index + width);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (codePoint === 0x90 || codePoint === 0x98 || codePoint === 0x9d || codePoint === 0x9e || codePoint === 0x9f) {
|
|
71
|
+
appendSpace();
|
|
72
|
+
index = consumeControlString(value, index + width, codePoint === 0x9d);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (isWhitespaceOrControl(codePoint)) appendSpace();
|
|
77
|
+
else appendText(String.fromCodePoint(codePoint));
|
|
78
|
+
index += width;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return output.join("");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function truncateDisplayText(value: string, maxLength: number): string {
|
|
85
|
+
if (maxLength <= 0) return "";
|
|
86
|
+
if (value.length <= maxLength) return value;
|
|
87
|
+
let output = "";
|
|
88
|
+
for (const char of value) {
|
|
89
|
+
if (output.length + char.length > maxLength) break;
|
|
90
|
+
output += char;
|
|
91
|
+
}
|
|
92
|
+
return output;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function previewDisplayText(value: string, maxLength: number): string {
|
|
96
|
+
const normalized = sanitizeDisplayText(value);
|
|
97
|
+
if (normalized.length <= maxLength) return normalized;
|
|
98
|
+
if (maxLength <= 3) return truncateDisplayText(normalized, maxLength);
|
|
99
|
+
return `${truncateDisplayText(normalized, maxLength - 3)}...`;
|
|
100
|
+
}
|
|
@@ -5,6 +5,7 @@ interface TimerApi {
|
|
|
5
5
|
|
|
6
6
|
interface FileCoalescer {
|
|
7
7
|
schedule(file: string, delayMs?: number): boolean;
|
|
8
|
+
flush(file: string): boolean;
|
|
8
9
|
clear(): void;
|
|
9
10
|
}
|
|
10
11
|
|
|
@@ -30,6 +31,14 @@ export function createFileCoalescer(
|
|
|
30
31
|
pending.set(file, timer);
|
|
31
32
|
return true;
|
|
32
33
|
},
|
|
34
|
+
flush(file: string): boolean {
|
|
35
|
+
const timer = pending.get(file);
|
|
36
|
+
if (timer === undefined) return false;
|
|
37
|
+
timerApi.clearTimeout(timer);
|
|
38
|
+
pending.delete(file);
|
|
39
|
+
handler(file);
|
|
40
|
+
return true;
|
|
41
|
+
},
|
|
33
42
|
clear(): void {
|
|
34
43
|
for (const timer of pending.values()) {
|
|
35
44
|
timerApi.clearTimeout(timer);
|
package/src/shared/formatters.ts
CHANGED
|
@@ -7,6 +7,7 @@ import * as path from "node:path";
|
|
|
7
7
|
import type { Usage, SingleResult } from "./types.ts";
|
|
8
8
|
import type { ChainStep } from "./settings.ts";
|
|
9
9
|
import { isDynamicParallelStep, isParallelStep } from "./settings.ts";
|
|
10
|
+
import { previewDisplayText, sanitizeDisplayText } from "./display-text.ts";
|
|
10
11
|
import { splitKnownThinkingSuffix, THINKING_LEVELS } from "./model-info.ts";
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -100,8 +101,7 @@ export function formatToolCall(name: string, args: Record<string, unknown>, expa
|
|
|
100
101
|
switch (name) {
|
|
101
102
|
case "bash": {
|
|
102
103
|
const command = typeof args.command === "string" ? args.command : "";
|
|
103
|
-
|
|
104
|
-
return `$ ${command.slice(0, maxLength)}${command.length > maxLength ? "..." : ""}`;
|
|
104
|
+
return `$ ${previewDisplayText(command, expanded ? 240 : 60)}`;
|
|
105
105
|
}
|
|
106
106
|
case "read":
|
|
107
107
|
case "write":
|
|
@@ -111,12 +111,10 @@ export function formatToolCall(name: string, args: Record<string, unknown>, expa
|
|
|
111
111
|
: typeof args.file_path === "string"
|
|
112
112
|
? args.file_path
|
|
113
113
|
: "";
|
|
114
|
-
return `${name} ${shortenPath(target)}`;
|
|
114
|
+
return `${name} ${sanitizeDisplayText(shortenPath(target))}`;
|
|
115
115
|
}
|
|
116
116
|
default: {
|
|
117
|
-
|
|
118
|
-
const maxLength = expanded ? 160 : 40;
|
|
119
|
-
return `${name} ${s.slice(0, maxLength)}${s.length > maxLength ? "..." : ""}`;
|
|
117
|
+
return `${name} ${previewDisplayText(JSON.stringify(args), expanded ? 160 : 40)}`;
|
|
120
118
|
}
|
|
121
119
|
}
|
|
122
120
|
}
|
package/src/shared/settings.ts
CHANGED
|
@@ -353,6 +353,18 @@ export function resolveChainPath(filePath: string, chainDir: string): string {
|
|
|
353
353
|
return path.isAbsolute(expanded) ? expanded : path.join(chainDir, expanded);
|
|
354
354
|
}
|
|
355
355
|
|
|
356
|
+
export function resolveExistingReadInstructionPaths(reads: readonly string[], instructionCwd: string, existenceCwd = instructionCwd): string[] {
|
|
357
|
+
return reads.flatMap((filePath) => {
|
|
358
|
+
const instructionPath = resolveChainPath(filePath, instructionCwd);
|
|
359
|
+
const existencePath = resolveChainPath(filePath, existenceCwd);
|
|
360
|
+
return fs.existsSync(existencePath) ? [instructionPath] : [];
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function resolveExistingReadPaths(reads: readonly string[], cwd: string): string[] {
|
|
365
|
+
return resolveExistingReadInstructionPaths(reads, cwd);
|
|
366
|
+
}
|
|
367
|
+
|
|
356
368
|
/**
|
|
357
369
|
* Build chain instructions from resolved behavior.
|
|
358
370
|
* These are appended to the task to tell the agent what to read/write.
|
|
@@ -367,14 +379,15 @@ export function buildChainInstructions(
|
|
|
367
379
|
chainDir: string,
|
|
368
380
|
isFirstProgressAgent: boolean,
|
|
369
381
|
previousSummary?: string,
|
|
382
|
+
readExistenceDir = chainDir,
|
|
370
383
|
): { prefix: string; suffix: string } {
|
|
371
384
|
const prefixParts: string[] = [];
|
|
372
385
|
const suffixParts: string[] = [];
|
|
373
386
|
|
|
374
387
|
// READS - prepend to override any hardcoded filenames in task text
|
|
375
388
|
if (behavior.reads && behavior.reads.length > 0) {
|
|
376
|
-
const files = behavior.reads
|
|
377
|
-
prefixParts.push(`[Read from: ${files.join(", ")}]`);
|
|
389
|
+
const files = resolveExistingReadInstructionPaths(behavior.reads, chainDir, readExistenceDir);
|
|
390
|
+
if (files.length > 0) prefixParts.push(`[Read from: ${files.join(", ")}]`);
|
|
378
391
|
}
|
|
379
392
|
|
|
380
393
|
// OUTPUT - prepend so agent knows where to write
|