pi-subagents 0.45.2 → 0.47.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 +47 -0
- package/README.md +2 -0
- package/docs/agents.md +342 -0
- package/docs/configuration.md +328 -0
- package/docs/extension-api.md +308 -0
- package/docs/missions.md +119 -0
- package/docs/models.md +192 -0
- package/docs/observability.md +174 -0
- package/docs/tool-reference.md +343 -0
- package/docs/watchdog.md +176 -0
- package/docs/workflows.md +163 -0
- package/package.json +4 -2
- package/skills/pi-subagents/references/execution-controls.md +6 -6
- package/skills/pi-subagents/references/management-authoring-rpc.md +1 -1
- package/src/agents/agents.ts +17 -8
- package/src/agents/frontmatter.ts +7 -3
- package/src/agents/skills.ts +2 -9
- package/src/api/project-panes.ts +30 -0
- package/src/extension/config.ts +18 -1
- package/src/extension/fanout-child.ts +5 -4
- package/src/extension/index.ts +66 -19
- package/src/extension/rpc.ts +3 -6
- package/src/extension/schemas.ts +28 -7
- package/src/extension/subagent-guide.ts +39 -0
- package/src/extension/tool-description.ts +30 -12
- package/src/inspectors/herdr/project-panes.ts +459 -63
- package/src/missions/actions.ts +25 -2
- package/src/missions/lifecycle.ts +21 -2
- package/src/missions/store.ts +79 -2
- package/src/missions/types.ts +33 -0
- package/src/missions/workflow-state.ts +19 -13
- package/src/runs/background/async-execution.ts +17 -6
- package/src/runs/background/async-job-tracker.ts +15 -0
- package/src/runs/background/async-resume.ts +19 -3
- package/src/runs/background/async-status.ts +6 -1
- package/src/runs/background/completion-replay.ts +267 -0
- package/src/runs/background/control-channel.ts +36 -0
- package/src/runs/background/result-watcher.ts +28 -6
- package/src/runs/background/scheduled-runs.ts +2 -1
- package/src/runs/background/stale-run-reconciler.ts +2 -21
- package/src/runs/background/subagent-runner.ts +47 -6
- package/src/runs/background/wait-completions.ts +39 -5
- package/src/runs/background/wait-subscriptions.ts +18 -3
- package/src/runs/foreground/async-steering-action.ts +1 -1
- package/src/runs/foreground/chain-execution.ts +3 -0
- package/src/runs/foreground/execution.ts +7 -0
- package/src/runs/foreground/foreground-history.ts +137 -0
- package/src/runs/foreground/subagent-executor.ts +403 -54
- 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/worktree.ts +3 -2
- package/src/shared/artifacts.ts +14 -14
- package/src/shared/display-text.ts +100 -0
- package/src/shared/fork-context.ts +13 -0
- package/src/shared/formatters.ts +4 -6
- package/src/shared/prompt-resources.ts +51 -0
- package/src/shared/settings.ts +15 -2
- package/src/shared/types.ts +41 -2
- package/src/shared/utf8.ts +11 -0
- package/src/shared/utils.ts +43 -33
- package/src/slash/prompt-workflows.ts +2 -15
- package/src/slash/slash-commands.ts +22 -2
- package/src/tui/fleet-status.ts +22 -12
- package/src/tui/fleet.ts +135 -25
- package/src/tui/render.ts +150 -33
- package/src/watchdog/change-signature.ts +4 -3
- package/src/workflows/scripted-workflow.ts +167 -10
|
@@ -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;
|
|
@@ -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 {
|
|
@@ -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
|
+
}
|
|
@@ -9,6 +9,7 @@ type SubagentExecutionContext = "fresh" | "fork";
|
|
|
9
9
|
interface BranchSessionEntry {
|
|
10
10
|
type: string;
|
|
11
11
|
id?: string;
|
|
12
|
+
cwd?: string;
|
|
12
13
|
parentId?: string | null;
|
|
13
14
|
timestamp?: string;
|
|
14
15
|
message?: {
|
|
@@ -128,6 +129,18 @@ function readSessionEntries(sessionFile: string): BranchSessionEntry[] {
|
|
|
128
129
|
});
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
/** Keep Pi from restoring a forked session into the parent's cwd instead of the child launch cwd. */
|
|
133
|
+
export function alignForkedSessionCwd(sessionFile: string, cwd: string): void {
|
|
134
|
+
const entries = readSessionEntries(sessionFile);
|
|
135
|
+
const header = entries[0];
|
|
136
|
+
if (header?.type !== "session") throw new Error(`Forked session ${sessionFile} does not start with a session header.`);
|
|
137
|
+
const resolvedCwd = path.resolve(cwd);
|
|
138
|
+
const effectiveCwd = fs.realpathSync.native(resolvedCwd);
|
|
139
|
+
if (header.cwd === effectiveCwd) return;
|
|
140
|
+
header.cwd = effectiveCwd;
|
|
141
|
+
fs.writeFileSync(sessionFile, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf-8");
|
|
142
|
+
}
|
|
143
|
+
|
|
131
144
|
export function createForkContextResolver(
|
|
132
145
|
sessionManager: ForkableSessionManager,
|
|
133
146
|
requestedContext: unknown,
|
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
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { parseFrontmatter } from "../agents/frontmatter.ts";
|
|
5
|
+
import { getAgentDir, getProjectConfigDir } from "./utils.ts";
|
|
6
|
+
|
|
7
|
+
const PROMPT_REF_PATTERN = /^(package|user|project):([A-Za-z0-9][A-Za-z0-9._-]{0,127})$/;
|
|
8
|
+
const PROMPT_VARIABLE_PATTERN = /\{\{(\w+)\}\}/g;
|
|
9
|
+
|
|
10
|
+
type PromptVariable = string | number | boolean;
|
|
11
|
+
|
|
12
|
+
export function getPromptDirectories(cwd: string) {
|
|
13
|
+
return {
|
|
14
|
+
package: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "prompts"),
|
|
15
|
+
user: path.join(getAgentDir(), "prompts"),
|
|
16
|
+
project: path.join(getProjectConfigDir(cwd), "prompts"),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function promptVariables(vars: unknown): Record<string, PromptVariable> {
|
|
21
|
+
if (vars === undefined) return {};
|
|
22
|
+
if (!vars || typeof vars !== "object" || Array.isArray(vars)) throw new Error("prompts.render vars must be a plain object.");
|
|
23
|
+
const prototype = Object.getPrototypeOf(vars);
|
|
24
|
+
if (prototype !== null && prototype !== Object.prototype) throw new Error("prompts.render vars must be a plain object.");
|
|
25
|
+
for (const [name, value] of Object.entries(vars)) {
|
|
26
|
+
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
|
|
27
|
+
throw new Error(`prompts.render variable '${name}' must be a string, number, or boolean.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return vars as Record<string, PromptVariable>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function renderWorkflowPrompt(ref: string, vars: unknown, cwd: string): string {
|
|
34
|
+
const match = ref.match(PROMPT_REF_PATTERN);
|
|
35
|
+
if (!match) throw new Error("prompts.render ref must use package:<name>, user:<name>, or project:<name>.");
|
|
36
|
+
const scope = match[1] as keyof ReturnType<typeof getPromptDirectories>;
|
|
37
|
+
const name = match[2]!;
|
|
38
|
+
const filePath = path.join(getPromptDirectories(cwd)[scope], `${name}.md`);
|
|
39
|
+
let content: string;
|
|
40
|
+
try {
|
|
41
|
+
if (!fs.lstatSync(filePath).isFile()) throw new Error("not a regular file");
|
|
42
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
43
|
+
} catch (error) {
|
|
44
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
45
|
+
throw new Error(`Could not read prompt fragment '${ref}': ${detail}`);
|
|
46
|
+
}
|
|
47
|
+
const variables = promptVariables(vars);
|
|
48
|
+
return parseFrontmatter(content).body.trim().replace(PROMPT_VARIABLE_PATTERN, (placeholder, variable: string) => {
|
|
49
|
+
return Object.hasOwn(variables, variable) ? String(variables[variable]) : placeholder;
|
|
50
|
+
});
|
|
51
|
+
}
|
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
|