@pi-unipi/unipi 2.6.3 → 2.7.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 (51) hide show
  1. package/package.json +16 -10
  2. package/packages/autocomplete/src/constants.ts +5 -0
  3. package/packages/core/tui-overlay.ts +1 -1
  4. package/packages/subagents/README.md +89 -78
  5. package/packages/subagents/package.json +14 -16
  6. package/packages/subagents/skills/council-mode/SKILL.md +230 -0
  7. package/packages/subagents/skills/subagents/SKILL.md +49 -0
  8. package/packages/subagents/skills/subagents/references/constraints-and-recipes.md +259 -0
  9. package/packages/subagents/skills/subagents/references/execution-controls.md +454 -0
  10. package/packages/subagents/skills/subagents/references/management-authoring-rpc.md +161 -0
  11. package/packages/subagents/skills/subagents/references/multi-lane-orchestration.md +39 -0
  12. package/packages/subagents/skills/subagents/references/prompting-and-roles.md +267 -0
  13. package/packages/subagents/src/acceptance.ts +331 -0
  14. package/packages/subagents/src/agent-manager.ts +89 -6
  15. package/packages/subagents/src/agent-memory.ts +300 -0
  16. package/packages/subagents/src/agent-overrides.ts +183 -0
  17. package/packages/subagents/src/agent-runner.ts +10 -0
  18. package/packages/subagents/src/async-runner.ts +417 -0
  19. package/packages/subagents/src/authority-policy.ts +47 -0
  20. package/packages/subagents/src/budgets.ts +285 -0
  21. package/packages/subagents/src/child-safety.ts +140 -0
  22. package/packages/subagents/src/config.ts +80 -1
  23. package/packages/subagents/src/custom-agents.ts +236 -38
  24. package/packages/subagents/src/file-system-retry.ts +50 -0
  25. package/packages/subagents/src/fleet-data.ts +61 -0
  26. package/packages/subagents/src/fleet-view.ts +316 -0
  27. package/packages/subagents/src/foreground-detach.ts +59 -0
  28. package/packages/subagents/src/fork-context.ts +285 -0
  29. package/packages/subagents/src/guide.ts +129 -0
  30. package/packages/subagents/src/index.ts +515 -197
  31. package/packages/subagents/src/mission-state.ts +133 -0
  32. package/packages/subagents/src/mission-store.ts +434 -0
  33. package/packages/subagents/src/output-limits.ts +142 -0
  34. package/packages/subagents/src/parity-types.ts +315 -0
  35. package/packages/subagents/src/pi-args.ts +161 -0
  36. package/packages/subagents/src/pi-spawn.ts +147 -0
  37. package/packages/subagents/src/result-files.ts +232 -0
  38. package/packages/subagents/src/result-watcher.ts +221 -0
  39. package/packages/subagents/src/retained-children.ts +176 -0
  40. package/packages/subagents/src/run-fanout-budget.ts +356 -0
  41. package/packages/subagents/src/scheduled-runs.ts +351 -0
  42. package/packages/subagents/src/schemas.ts +263 -0
  43. package/packages/subagents/src/slash-commands.ts +77 -0
  44. package/packages/subagents/src/supervisor-channel.ts +249 -0
  45. package/packages/subagents/src/tool-handler.ts +1144 -0
  46. package/packages/subagents/src/types.ts +103 -2
  47. package/packages/subagents/src/workflow-script.ts +737 -0
  48. package/packages/subagents/src/workflow-worker.ts +384 -0
  49. package/packages/subagents/src/worktree.ts +614 -0
  50. package/packages/unipi/bundled.js +6577 -1093
  51. package/packages/unipi/index.ts +3 -3
@@ -0,0 +1,316 @@
1
+ /**
2
+ * @pi-unipi/subagents — FleetView (persistent fleet panel)
3
+ *
4
+ * pi-subagents parity built on OUR panel system: a persistent setWidget slot
5
+ * (belowEditor/aboveEditor placement from config) summarizing active work —
6
+ * in-process agents from the AgentManager plus async process runs from the
7
+ * run dirs. Collapsed by default; ↓/← activates selection, j/k navigate,
8
+ * enter opens the inspector, esc deactivates.
9
+ */
10
+
11
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
12
+ import { matchesKey, Key, truncateToWidth } from "@earendil-works/pi-tui";
13
+ import type { AgentManager } from "./agent-manager.js";
14
+ import type { AgentActivity } from "./types.js";
15
+ import { listAsyncRunSummaries, type AsyncRunSummary } from "./fleet-data.js";
16
+
17
+ const REFRESH_MS = 500;
18
+ const MAX_AGENT_ROWS = 6;
19
+
20
+ export interface FleetEntry {
21
+ key: string;
22
+ source: "inprocess" | "async";
23
+ agent: string;
24
+ description?: string;
25
+ startedAt: number;
26
+ state: string;
27
+ /** Run dir for async entries (inspector target). */
28
+ runDir?: string;
29
+ }
30
+
31
+ export interface FleetViewOptions {
32
+ refreshMs?: number;
33
+ maxAgentRows?: number;
34
+ placement?: "belowEditor" | "aboveEditor";
35
+ /** Open the inspector for an entry (wired to ConversationViewer overlay). */
36
+ openInspector?: (entry: FleetEntry) => Promise<void> | void;
37
+ }
38
+
39
+ function isActiveState(state: string): boolean {
40
+ return state === "running" || state === "queued" || state === "pending";
41
+ }
42
+
43
+ /** Collect active entries across both transports. */
44
+ export function collectFleetEntries(
45
+ manager: AgentManager,
46
+ activity: Map<string, AgentActivity>,
47
+ asyncDirRoot: string,
48
+ ): FleetEntry[] {
49
+ const entries: FleetEntry[] = [];
50
+
51
+ for (const record of manager.listAgents()) {
52
+ if (!isActiveState(record.status)) continue;
53
+ const act = activity.get(record.id);
54
+ entries.push({
55
+ key: `inprocess:${record.id}`,
56
+ source: "inprocess",
57
+ agent: record.type,
58
+ description: record.description,
59
+ startedAt: record.startedAt,
60
+ state: record.status,
61
+ });
62
+ void act;
63
+ }
64
+
65
+ for (const run of listAsyncRunSummaries(asyncDirRoot)) {
66
+ if (!isActiveState(run.state)) continue;
67
+ entries.push({
68
+ key: `async:${run.runId}`,
69
+ source: "async",
70
+ agent: run.agent,
71
+ description: run.taskSummary,
72
+ startedAt: run.startedAt,
73
+ state: run.state,
74
+ runDir: run.runDir,
75
+ });
76
+ }
77
+
78
+ return entries.sort((left, right) => left.startedAt - right.startedAt || left.key.localeCompare(right.key));
79
+ }
80
+
81
+ export class FleetView {
82
+ private uiCtx: ExtensionUIContext | undefined;
83
+ private tui: { requestRender(): void } | undefined;
84
+ private timer: ReturnType<typeof setInterval> | undefined;
85
+ private widgetRegistered = false;
86
+ private lastRenderKey = "";
87
+ private entries: FleetEntry[] = [];
88
+ private active = false;
89
+ private selectedKey = "main";
90
+ private inspectorOpen = false;
91
+
92
+ constructor(
93
+ private manager: AgentManager,
94
+ private activity: Map<string, AgentActivity>,
95
+ private asyncDirRoot: string,
96
+ private options: FleetViewOptions = {},
97
+ ) {}
98
+
99
+ setUICtx(ctx: ExtensionUIContext): void {
100
+ if (ctx === this.uiCtx) return;
101
+ this.clearRegistration();
102
+ this.uiCtx = ctx;
103
+ this.ensureTimer();
104
+ this.refresh();
105
+ }
106
+
107
+ dispose(): void {
108
+ if (this.timer) clearInterval(this.timer);
109
+ this.timer = undefined;
110
+ this.clearRegistration();
111
+ this.uiCtx = undefined;
112
+ this.entries = [];
113
+ this.active = false;
114
+ this.selectedKey = "main";
115
+ }
116
+
117
+ /** Called when work starts/finishes to trigger a refresh. */
118
+ refresh(): void {
119
+ const ctx = this.uiCtx;
120
+ if (!ctx) return;
121
+ this.entries = collectFleetEntries(this.manager, this.activity, this.asyncDirRoot);
122
+ this.clampSelection();
123
+
124
+ if (this.inspectorOpen) {
125
+ this.lastRenderKey = "";
126
+ this.clearWidget();
127
+ return;
128
+ }
129
+ if (this.entries.length === 0) {
130
+ this.active = false;
131
+ this.selectedKey = "main";
132
+ this.lastRenderKey = "";
133
+ this.clearWidget();
134
+ return;
135
+ }
136
+
137
+ const renderKey = JSON.stringify({
138
+ active: this.active,
139
+ selected: this.selectedKey,
140
+ entries: this.entries.map((e) => [e.key, e.state]),
141
+ });
142
+ if (!this.widgetRegistered) {
143
+ ctx.setWidget(
144
+ "unipi-fleet-status",
145
+ (tui: unknown, _theme: unknown) => {
146
+ this.tui = tui as { requestRender(): void };
147
+ return {
148
+ render: (width: number) => this.render(width),
149
+ invalidate: () => {
150
+ this.lastRenderKey = "";
151
+ },
152
+ dispose: () => {
153
+ this.widgetRegistered = false;
154
+ this.tui = undefined;
155
+ },
156
+ };
157
+ },
158
+ { placement: this.options.placement ?? "belowEditor" },
159
+ );
160
+ this.widgetRegistered = true;
161
+ this.lastRenderKey = renderKey;
162
+ return;
163
+ }
164
+ if (renderKey === this.lastRenderKey) return;
165
+ this.lastRenderKey = renderKey;
166
+ this.tui?.requestRender();
167
+ }
168
+
169
+ /**
170
+ * Key handling for the collapsed/active roster. Returns consume intent.
171
+ * Wired from index.ts's terminal input hook.
172
+ */
173
+ handleKey(data: string, editorHasFocus: () => boolean): { consume?: boolean } | undefined {
174
+ const ctx = this.uiCtx;
175
+ if (!ctx || this.entries.length === 0) return undefined;
176
+ if (this.inspectorOpen) return undefined;
177
+ if (!editorHasFocus()) {
178
+ if (this.active) this.deactivate();
179
+ return undefined;
180
+ }
181
+
182
+ if (!this.active) {
183
+ const activates = matchesKey(data, "down") || matchesKey(data, "left");
184
+ if (!activates || ctx.getEditorText?.() !== "") return undefined;
185
+ this.active = true;
186
+ this.selectedKey = "main";
187
+ this.refresh();
188
+ return { consume: true };
189
+ }
190
+
191
+ const roster = this.rosterKeys();
192
+ const selectedIndex = Math.max(0, roster.indexOf(this.selectedKey));
193
+ if (matchesKey(data, "down") || matchesKey(data, "j")) {
194
+ this.selectedKey = roster[Math.min(roster.length - 1, selectedIndex + 1)] ?? "main";
195
+ this.refresh();
196
+ return { consume: true };
197
+ }
198
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
199
+ if (selectedIndex === 0) {
200
+ this.deactivate();
201
+ return { consume: true };
202
+ }
203
+ this.selectedKey = roster[selectedIndex - 1] ?? "main";
204
+ this.refresh();
205
+ return { consume: true };
206
+ }
207
+ if (matchesKey(data, "escape")) {
208
+ this.deactivate();
209
+ return { consume: true };
210
+ }
211
+ if (matchesKey(data, Key.enter)) {
212
+ if (this.selectedKey === "main") {
213
+ this.deactivate();
214
+ return { consume: true };
215
+ }
216
+ const entry = this.entries.find((e) => e.key === this.selectedKey);
217
+ if (!entry) return undefined;
218
+ this.inspectorOpen = true;
219
+ this.refresh();
220
+ void Promise.resolve()
221
+ .then(() => this.options.openInspector?.(entry))
222
+ .catch(() => {})
223
+ .finally(() => {
224
+ this.inspectorOpen = false;
225
+ this.refresh();
226
+ });
227
+ return { consume: true };
228
+ }
229
+ return undefined;
230
+ }
231
+
232
+ render(width: number): string[] {
233
+ if (this.entries.length === 0) return [];
234
+ if (!this.active) {
235
+ // Collapsed summary row.
236
+ const count = this.entries.length;
237
+ const noun = count === 1 ? "agent" : "agents";
238
+ const states = new Map<string, number>();
239
+ for (const entry of this.entries) states.set(entry.state, (states.get(entry.state) ?? 0) + 1);
240
+ const breakdown = [...states.entries()].map(([state, n]) => `${n} ${state}`).join(", ");
241
+ return [
242
+ truncateToWidth(` ${count} active ${noun} (${breakdown}) · ↓ to inspect`, width),
243
+ ];
244
+ }
245
+
246
+ const roster = this.rosterKeys();
247
+ const selectedIndex = Math.max(0, roster.indexOf(this.selectedKey));
248
+ const lines = [truncateToWidth(` ↑↓/jk select · enter inspect · esc back`, width), ""];
249
+ lines.push(truncateToWidth(` ${this.bullet(0, selectedIndex)} main`, width));
250
+
251
+ const visibleCount = Math.min(this.options.maxAgentRows ?? MAX_AGENT_ROWS, this.entries.length);
252
+ const start = selectedIndex < visibleCount ? 0 : selectedIndex - visibleCount + 1;
253
+ const hiddenBelow = this.entries.length - (start + visibleCount);
254
+ if (start > 0) lines.push(truncateToWidth(` ↑ ${start} more`, width));
255
+ for (let index = start; index < start + visibleCount; index++) {
256
+ const entry = this.entries[index];
257
+ if (!entry) break;
258
+ lines.push(...this.renderEntry(index + 1, selectedIndex, entry, width));
259
+ }
260
+ if (hiddenBelow > 0) lines.push(truncateToWidth(` ↓ ${hiddenBelow} more`, width));
261
+ return lines;
262
+ }
263
+
264
+ private renderEntry(rosterIndex: number, selectedIndex: number, entry: FleetEntry, width: number): string[] {
265
+ void rosterIndex;
266
+ const elapsed = formatElapsed(Date.now() - entry.startedAt);
267
+ const sourceTag = entry.source === "async" ? "proc" : "local";
268
+ const left = ` ${this.bullet(-1, selectedIndex)} ${entry.agent} · ${entry.state}`;
269
+ const right = `${sourceTag} · ${elapsed}`;
270
+ return [truncateToWidth(`${left} ${right}`, width)];
271
+ }
272
+
273
+ private bullet(_index: number, selectedIndex: number): string {
274
+ return selectedIndex > 0 ? ">" : " ";
275
+ }
276
+
277
+ private rosterKeys(): string[] {
278
+ return ["main", ...this.entries.map((entry) => entry.key)];
279
+ }
280
+
281
+ private clampSelection(): void {
282
+ if (!this.rosterKeys().includes(this.selectedKey)) this.selectedKey = "main";
283
+ }
284
+
285
+ private deactivate(): void {
286
+ this.active = false;
287
+ this.selectedKey = "main";
288
+ this.refresh();
289
+ }
290
+
291
+ private clearWidget(): void {
292
+ if (this.widgetRegistered && this.uiCtx) {
293
+ this.uiCtx.setWidget("unipi-fleet-status", undefined);
294
+ this.widgetRegistered = false;
295
+ this.tui = undefined;
296
+ }
297
+ }
298
+
299
+ private clearRegistration(): void {
300
+ this.clearWidget();
301
+ }
302
+
303
+ private ensureTimer(): void {
304
+ if (this.timer) return;
305
+ this.timer = setInterval(() => this.refresh(), this.options.refreshMs ?? REFRESH_MS);
306
+ this.timer.unref?.();
307
+ }
308
+ }
309
+
310
+ function formatElapsed(ms: number): string {
311
+ const seconds = Math.floor(ms / 1000);
312
+ if (seconds < 60) return `${seconds}s`;
313
+ const minutes = Math.floor(seconds / 60);
314
+ if (minutes < 60) return `${minutes}m${seconds % 60}s`;
315
+ return `${Math.floor(minutes / 60)}h${minutes % 60}m`;
316
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @pi-unipi/subagents — Foreground detach shortcut
3
+ *
4
+ * Ported from pi-subagents foregroundDetachShortcut (config) +
5
+ * foregroundSingleHintText (render.ts). Optionally binds a shortcut that
6
+ * detaches the active foreground single-agent run WITHOUT terminating it:
7
+ * the parent stops waiting, the child keeps running, and the completion
8
+ * arrives later as a normal task-notification.
9
+ */
10
+
11
+ export interface DetachShortcutConfig {
12
+ /** Shortcut string like "ctrl+b". Empty/unset disables the feature. */
13
+ shortcut?: string;
14
+ }
15
+
16
+ /** Parse a shortcut string into normalized parts ("ctrl+b" → ["ctrl","b"]). */
17
+ export function parseDetachShortcut(raw: string | undefined): string[] | undefined {
18
+ const trimmed = raw?.trim().toLowerCase();
19
+ if (!trimmed) return undefined;
20
+ const parts = trimmed.split("+").map((part) => part.trim()).filter(Boolean);
21
+ if (parts.length === 0 || parts.some((part) => !part)) return undefined;
22
+ // Must include at least one modifier to avoid stealing plain keys.
23
+ const modifiers = new Set(["ctrl", "alt", "shift", "meta", "super"]);
24
+ if (!parts.slice(0, -1).some((part) => modifiers.has(part))) return undefined;
25
+ return parts;
26
+ }
27
+
28
+ /** Human-readable label ("ctrl+b" → "Ctrl+B"). */
29
+ export function formatDetachHint(parts: string[] | undefined): string | undefined {
30
+ if (!parts) return undefined;
31
+ const label = parts
32
+ .map((part) => {
33
+ if (part === "ctrl") return "Ctrl";
34
+ if (part === "alt") return "Alt";
35
+ if (part === "shift") return "Shift";
36
+ if (part === "escape") return "Esc";
37
+ if (part.length === 1) return part.toUpperCase();
38
+ return part.charAt(0).toUpperCase() + part.slice(1);
39
+ })
40
+ .join("+");
41
+ return `${label} detaches (run continues in background)`;
42
+ }
43
+
44
+ /**
45
+ * Whether a raw terminal input matches the configured shortcut.
46
+ * Matches pi-tui-style control sequences for ctrl+<letter> plus literal text
47
+ * for other combos.
48
+ */
49
+ export function matchesDetachInput(data: string, parts: string[] | undefined): boolean {
50
+ if (!parts) return false;
51
+ const hasCtrl = parts.includes("ctrl");
52
+ const key = parts[parts.length - 1]!;
53
+ if (hasCtrl && parts.length === 2 && key.length === 1) {
54
+ // Control sequence: C0 codes 0x01–0x1a map to ctrl+a–z.
55
+ const expected = String.fromCharCode(key.charCodeAt(0) - 96);
56
+ return data === expected;
57
+ }
58
+ return data === parts.join("+");
59
+ }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * @pi-unipi/subagents — Fork context resolution
3
+ *
4
+ * Ported from pi-subagents src/shared/fork-context.ts. Creates branched child
5
+ * sessions from the parent session file, sanitizes signed Anthropic thinking
6
+ * blocks (redacted_thinking always; signed thinking on Anthropic models),
7
+ * appends a thinking_level_change:off entry when sanitization occurred, and
8
+ * aligns the forked session cwd to the child launch cwd. Fork files nest
9
+ * under <parent-session-dir>/<parent-stem>/forks/ so top-level session
10
+ * discovery never picks them up.
11
+ */
12
+
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ import { randomUUID } from "node:crypto";
16
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
17
+
18
+ export type SubagentExecutionContext = "fresh" | "fork";
19
+
20
+ interface BranchSessionEntry {
21
+ type: string;
22
+ id?: string;
23
+ parentId?: string | null;
24
+ timestamp?: string;
25
+ message?: {
26
+ role?: string;
27
+ content?: unknown;
28
+ provider?: string;
29
+ api?: string;
30
+ model?: string;
31
+ };
32
+ thinkingLevel?: string;
33
+ cwd?: string;
34
+ [extra: string]: unknown;
35
+ }
36
+
37
+ export interface PreferredForkAvailability {
38
+ getSessionFile(): string | undefined;
39
+ getLeafId(): string | null;
40
+ }
41
+
42
+ export interface PreferredForkSnapshot {
43
+ parentSessionFile?: string;
44
+ leafId?: string | null;
45
+ }
46
+
47
+ export function resolveSubagentContext(value: unknown): SubagentExecutionContext {
48
+ return value === "fork" ? "fork" : "fresh";
49
+ }
50
+
51
+ export function canPreferFork(sessionManager: PreferredForkAvailability): boolean {
52
+ const parentSessionFile = sessionManager.getSessionFile();
53
+ const leafId = sessionManager.getLeafId();
54
+ if (!parentSessionFile || !leafId) return false;
55
+ try {
56
+ return fs.existsSync(parentSessionFile);
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ export function canPreferForkFromSnapshot(input: PreferredForkSnapshot): boolean {
63
+ if (!input.parentSessionFile || !input.leafId) return false;
64
+ try {
65
+ return fs.existsSync(input.parentSessionFile);
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ /** Anthropic provider/api models require thinking off on a sanitized fork. */
72
+ export function forkedChildRequiresThinkingOff(
73
+ model: string | undefined,
74
+ modelInfoProvider?: string,
75
+ modelInfoApi?: string,
76
+ ): boolean {
77
+ if (!model) return true;
78
+ if (modelInfoProvider?.toLowerCase() === "anthropic") return true;
79
+ if (modelInfoApi?.toLowerCase() === "anthropic-messages") return true;
80
+ // Unknown models stay conservative (reference rule).
81
+ if (modelInfoProvider === undefined && modelInfoApi === undefined) return true;
82
+ return false;
83
+ }
84
+
85
+ function isUnsafeAnthropicThinkingBlock(message: BranchSessionEntry["message"], block: unknown): boolean {
86
+ if (!message || !block || typeof block !== "object" || !("type" in block)) return false;
87
+ const provider = typeof message.provider === "string" ? message.provider.toLowerCase() : "";
88
+ const api = typeof message.api === "string" ? message.api.toLowerCase() : "";
89
+ const model = typeof message.model === "string" ? message.model.toLowerCase() : "";
90
+ const isAnthropic = provider === "anthropic" || api === "anthropic-messages" || model.startsWith("anthropic/");
91
+ const b = block as Record<string, unknown>;
92
+ if (b.type === "redacted_thinking") return true;
93
+ if (b.type !== "thinking" || !isAnthropic) return false;
94
+ const signature =
95
+ "thinkingSignature" in b ? b.thinkingSignature : "signature" in b ? b.signature : undefined;
96
+ return b.redacted === true || (typeof signature === "string" && (signature as string).length > 0);
97
+ }
98
+
99
+ function createEntryId(entries: BranchSessionEntry[]): string {
100
+ const ids = new Set(entries.map((entry) => entry.id).filter((id): id is string => typeof id === "string"));
101
+ for (let attempt = 0; attempt < 100; attempt++) {
102
+ const id = randomUUID().slice(0, 8);
103
+ if (!ids.has(id)) return id;
104
+ }
105
+ return randomUUID();
106
+ }
107
+
108
+ function appendThinkingOffEntry(entries: BranchSessionEntry[]): void {
109
+ const last = entries[entries.length - 1];
110
+ if (last?.type === "thinking_level_change" && last.thinkingLevel === "off") return;
111
+ const parent = [...entries].reverse().find((entry) => typeof entry.id === "string");
112
+ entries.push({
113
+ type: "thinking_level_change",
114
+ id: createEntryId(entries),
115
+ parentId: parent?.id ?? null,
116
+ timestamp: new Date().toISOString(),
117
+ thinkingLevel: "off",
118
+ });
119
+ }
120
+
121
+ /** Strip signed/redacted Anthropic thinking blocks from forked entries. */
122
+ export function sanitizeUnsafeThinkingBlocks(entries: BranchSessionEntry[]): boolean {
123
+ let sanitized = false;
124
+ for (const entry of entries) {
125
+ if (entry.type !== "message" || entry.message?.role !== "assistant" || !Array.isArray(entry.message.content)) continue;
126
+ const filtered = (entry.message.content as unknown[]).filter(
127
+ (block) => !isUnsafeAnthropicThinkingBlock(entry.message, block),
128
+ );
129
+ if (filtered.length === (entry.message.content as unknown[]).length) continue;
130
+ entry.message.content = filtered;
131
+ sanitized = true;
132
+ }
133
+ return sanitized;
134
+ }
135
+
136
+ function readSessionEntries(sessionFile: string): BranchSessionEntry[] {
137
+ const lines = fs.readFileSync(sessionFile, "utf-8").split("\n").filter((line) => line.trim().length > 0);
138
+ return lines.map((line, index) => {
139
+ try {
140
+ return JSON.parse(line) as BranchSessionEntry;
141
+ } catch (error) {
142
+ const cause = error instanceof Error ? error : new Error(String(error));
143
+ throw new Error(
144
+ `Unable to inspect forked session ${sessionFile}: invalid JSONL on line ${index + 1}: ${cause.message}`,
145
+ { cause },
146
+ );
147
+ }
148
+ });
149
+ }
150
+
151
+ /** Align a forked session's cwd to the child launch cwd. */
152
+ export function alignForkedSessionCwd(sessionFile: string, cwd: string): void {
153
+ const entries = readSessionEntries(sessionFile);
154
+ const header = entries[0];
155
+ if (header?.type !== "session") {
156
+ throw new Error(`Forked session ${sessionFile} does not start with a session header.`);
157
+ }
158
+ const resolvedCwd = path.resolve(cwd);
159
+ const effectiveCwd = fs.realpathSync.native(resolvedCwd);
160
+ if (header.cwd === effectiveCwd) return;
161
+ header.cwd = effectiveCwd;
162
+ fs.writeFileSync(sessionFile, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf-8");
163
+ }
164
+
165
+ export interface ForkContextResolution {
166
+ sessionFile: string;
167
+ thinkingOverride?: "off";
168
+ }
169
+
170
+ export interface ForkContextResolver {
171
+ sessionFileForIndex(index?: number): string | undefined;
172
+ thinkingOverrideForIndex(index?: number): "off" | undefined;
173
+ }
174
+
175
+ interface ForkableSessionManager extends PreferredForkAvailability {
176
+ openSession?: (file: string, dir?: string) => {
177
+ createBranchedSession(leafId: string): string | undefined;
178
+ getHeader?: () => BranchSessionEntry | undefined;
179
+ getEntries?: () => BranchSessionEntry[] | undefined;
180
+ };
181
+ }
182
+
183
+ export interface ForkContextResolverOptions {
184
+ openSession?: ForkableSessionManager["openSession"];
185
+ forceThinkingOffForIndex?: (index: number) => boolean;
186
+ }
187
+
188
+ /**
189
+ * Create the fork resolver. Errors follow reference semantics: an explicit
190
+ * fork requires a persisted parent session + current leaf, failing fast
191
+ * rather than silently downgrading to fresh.
192
+ */
193
+ export function createForkContextResolver(
194
+ sessionManager: ForkableSessionManager,
195
+ requestedContext: unknown,
196
+ options: ForkContextResolverOptions = {},
197
+ ): ForkContextResolver {
198
+ if (resolveSubagentContext(requestedContext) !== "fork") {
199
+ return {
200
+ sessionFileForIndex: () => undefined,
201
+ thinkingOverrideForIndex: () => undefined,
202
+ };
203
+ }
204
+
205
+ const parentSessionFile = sessionManager.getSessionFile();
206
+ if (!parentSessionFile) {
207
+ throw new Error("Forked subagent context requires a persisted parent session.");
208
+ }
209
+
210
+ const leafId = sessionManager.getLeafId();
211
+ if (!leafId) {
212
+ throw new Error("Forked subagent context requires a current leaf to fork from.");
213
+ }
214
+
215
+ const openSession =
216
+ options.openSession ?? sessionManager.openSession ?? ((file: string, dir?: string) => SessionManager.open(file, dir));
217
+
218
+ // Fork files nest under <parent-dir>/<parent-stem>/forks/ so top-level
219
+ // session discovery (largest-mtime *.jsonl, non-recursive) never hijacks.
220
+ const sessionDir = path.join(
221
+ path.dirname(parentSessionFile),
222
+ path.basename(parentSessionFile, ".jsonl"),
223
+ "forks",
224
+ );
225
+ const cachedResolutions = new Map<number, ForkContextResolution>();
226
+
227
+ const resolveFork = (index = 0): ForkContextResolution => {
228
+ const cached = cachedResolutions.get(index);
229
+ if (cached) return cached;
230
+ try {
231
+ if (!fs.existsSync(parentSessionFile)) {
232
+ throw new Error(
233
+ `Parent session file does not exist: ${parentSessionFile}. Pi has not persisted enough history to fork yet.`,
234
+ );
235
+ }
236
+ const sourceManager = openSession(parentSessionFile, sessionDir);
237
+ const sessionFile = sourceManager.createBranchedSession(leafId);
238
+ if (!sessionFile) {
239
+ throw new Error("Session manager did not return a forked session file.");
240
+ }
241
+ const forceThinkingOff = (sanitized: boolean): boolean =>
242
+ sanitized && (options.forceThinkingOffForIndex?.(index) ?? true);
243
+ let thinkingOverride: "off" | undefined;
244
+ if (!fs.existsSync(sessionFile)) {
245
+ const header = sourceManager.getHeader?.();
246
+ const entries = sourceManager.getEntries?.() as BranchSessionEntry[] | undefined;
247
+ if (!header || !entries) {
248
+ throw new Error(
249
+ `Session manager returned a forked session file that does not exist and cannot be persisted by fallback: ${sessionFile}`,
250
+ );
251
+ }
252
+ if (forceThinkingOff(sanitizeUnsafeThinkingBlocks(entries as BranchSessionEntry[]))) {
253
+ appendThinkingOffEntry(entries);
254
+ thinkingOverride = "off";
255
+ }
256
+ fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
257
+ fs.writeFileSync(sessionFile, `${[header, ...entries].map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf-8");
258
+ } else {
259
+ const entries = readSessionEntries(sessionFile);
260
+ if (sanitizeUnsafeThinkingBlocks(entries)) {
261
+ if (forceThinkingOff(true)) {
262
+ appendThinkingOffEntry(entries);
263
+ thinkingOverride = "off";
264
+ }
265
+ fs.writeFileSync(sessionFile, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf-8");
266
+ }
267
+ }
268
+ const resolution = { sessionFile, ...(thinkingOverride ? { thinkingOverride } : {}) };
269
+ cachedResolutions.set(index, resolution);
270
+ return resolution;
271
+ } catch (error) {
272
+ const cause = error instanceof Error ? error : new Error(String(error));
273
+ throw new Error(`Failed to create forked subagent session: ${cause.message}`, { cause });
274
+ }
275
+ };
276
+
277
+ return {
278
+ sessionFileForIndex(index = 0): string | undefined {
279
+ return resolveFork(index).sessionFile;
280
+ },
281
+ thinkingOverrideForIndex(index = 0): "off" | undefined {
282
+ return resolveFork(index).thinkingOverride;
283
+ },
284
+ };
285
+ }