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.
Files changed (70) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +2 -0
  3. package/docs/agents.md +342 -0
  4. package/docs/configuration.md +328 -0
  5. package/docs/extension-api.md +308 -0
  6. package/docs/missions.md +119 -0
  7. package/docs/models.md +192 -0
  8. package/docs/observability.md +174 -0
  9. package/docs/tool-reference.md +343 -0
  10. package/docs/watchdog.md +176 -0
  11. package/docs/workflows.md +163 -0
  12. package/package.json +4 -2
  13. package/skills/pi-subagents/references/execution-controls.md +6 -6
  14. package/skills/pi-subagents/references/management-authoring-rpc.md +1 -1
  15. package/src/agents/agents.ts +17 -8
  16. package/src/agents/frontmatter.ts +7 -3
  17. package/src/agents/skills.ts +2 -9
  18. package/src/api/project-panes.ts +30 -0
  19. package/src/extension/config.ts +18 -1
  20. package/src/extension/fanout-child.ts +5 -4
  21. package/src/extension/index.ts +66 -19
  22. package/src/extension/rpc.ts +3 -6
  23. package/src/extension/schemas.ts +28 -7
  24. package/src/extension/subagent-guide.ts +39 -0
  25. package/src/extension/tool-description.ts +30 -12
  26. package/src/inspectors/herdr/project-panes.ts +459 -63
  27. package/src/missions/actions.ts +25 -2
  28. package/src/missions/lifecycle.ts +21 -2
  29. package/src/missions/store.ts +79 -2
  30. package/src/missions/types.ts +33 -0
  31. package/src/missions/workflow-state.ts +19 -13
  32. package/src/runs/background/async-execution.ts +17 -6
  33. package/src/runs/background/async-job-tracker.ts +15 -0
  34. package/src/runs/background/async-resume.ts +19 -3
  35. package/src/runs/background/async-status.ts +6 -1
  36. package/src/runs/background/completion-replay.ts +267 -0
  37. package/src/runs/background/control-channel.ts +36 -0
  38. package/src/runs/background/result-watcher.ts +28 -6
  39. package/src/runs/background/scheduled-runs.ts +2 -1
  40. package/src/runs/background/stale-run-reconciler.ts +2 -21
  41. package/src/runs/background/subagent-runner.ts +47 -6
  42. package/src/runs/background/wait-completions.ts +39 -5
  43. package/src/runs/background/wait-subscriptions.ts +18 -3
  44. package/src/runs/foreground/async-steering-action.ts +1 -1
  45. package/src/runs/foreground/chain-execution.ts +3 -0
  46. package/src/runs/foreground/execution.ts +7 -0
  47. package/src/runs/foreground/foreground-history.ts +137 -0
  48. package/src/runs/foreground/subagent-executor.ts +403 -54
  49. package/src/runs/foreground/workflow-foreground-steering.ts +187 -0
  50. package/src/runs/shared/dynamic-fanout.ts +1 -1
  51. package/src/runs/shared/model-fallback.ts +8 -4
  52. package/src/runs/shared/model-scope.ts +12 -2
  53. package/src/runs/shared/parallel-utils.ts +1 -0
  54. package/src/runs/shared/worktree.ts +3 -2
  55. package/src/shared/artifacts.ts +14 -14
  56. package/src/shared/display-text.ts +100 -0
  57. package/src/shared/fork-context.ts +13 -0
  58. package/src/shared/formatters.ts +4 -6
  59. package/src/shared/prompt-resources.ts +51 -0
  60. package/src/shared/settings.ts +15 -2
  61. package/src/shared/types.ts +41 -2
  62. package/src/shared/utf8.ts +11 -0
  63. package/src/shared/utils.ts +43 -33
  64. package/src/slash/prompt-workflows.ts +2 -15
  65. package/src/slash/slash-commands.ts +22 -2
  66. package/src/tui/fleet-status.ts +22 -12
  67. package/src/tui/fleet.ts +135 -25
  68. package/src/tui/render.ts +150 -33
  69. package/src/watchdog/change-signature.ts +4 -3
  70. 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 are inherited agent config and warn, rather than error, when out of scope. */
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) (options.onWarn ?? defaultScopeWarn)(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-subagents/ by default;
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", "--", ":!.pi-subagents"]);
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
  }
@@ -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 PROJECT_ARTIFACT_ROOT = ".pi-subagents";
6
+ export const PROJECT_SUBAGENTS_RELATIVE_DIR = ".pi/subagents";
7
7
 
8
8
  const PROJECT_ARTIFACT_PATHS = [
9
- `${PROJECT_ARTIFACT_ROOT}/artifacts/output.md`,
10
- `${PROJECT_ARTIFACT_ROOT}/artifacts/run_worker_input.md`,
11
- `${PROJECT_ARTIFACT_ROOT}/artifacts/run_worker_output.md`,
12
- `${PROJECT_ARTIFACT_ROOT}/artifacts/run_worker.jsonl`,
13
- `${PROJECT_ARTIFACT_ROOT}/artifacts/run_worker_transcript.jsonl`,
14
- `${PROJECT_ARTIFACT_ROOT}/artifacts/run_worker_meta.json`,
15
- `${PROJECT_ARTIFACT_ROOT}/artifacts/progress/run/progress.md`,
16
- `${PROJECT_ARTIFACT_ROOT}/artifacts/outputs/output.md`,
17
- `${PROJECT_ARTIFACT_ROOT}/artifacts/outputs/run/output.md`,
18
- `${PROJECT_ARTIFACT_ROOT}/chain-runs/run.json`,
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 === PROJECT_ARTIFACT_ROOT
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-subagents/' to .npmignore, restrict package.json files, or set artifactDir to 'session' or 'temp'.";
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, PROJECT_ARTIFACT_ROOT);
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,
@@ -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
- const maxLength = expanded ? 240 : 60;
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
- const s = JSON.stringify(args);
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
+ }
@@ -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.map((f) => resolveChainPath(f, chainDir));
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