pi-fork-join 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pi-fork-join contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,191 @@
1
+ # pi-fork-join
2
+
3
+ In-process **shared-prefix concurrent fork/join** for the Pi coding agent.
4
+
5
+ Keep your main agent's context window clean while running several independent
6
+ investigations in parallel. Each fork is a child `AgentSession` **in the same
7
+ process** that inherits the current active branch as its shared prefix, runs a
8
+ bounded task, and returns only a dense structured report. The parent's context
9
+ window never receives the forks' intermediate tool logs or thinking.
10
+
11
+ `pi-fork-join` is the in-process realization of the "shared-prefix concurrent
12
+ fork" idea: the execution engine is brand new (no child `pi` process, no JSONL
13
+ snapshot round-trip), while the configuration surface is adapted from the
14
+ `pi-fork` extension (MIT).
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pi install git:github.com/InertialG/pi-fork-join
20
+ ```
21
+
22
+ Or run from source:
23
+
24
+ ```bash
25
+ pi -e ./src/index.ts
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ The extension registers one tool, `fork_join`:
31
+
32
+ ```json
33
+ {
34
+ "tasks": [
35
+ { "id": "data", "task": "Inspect the data layer; return evidence and risk." },
36
+ { "id": "concurrency", "task": "Audit the concurrency/state model; return risks." },
37
+ { "id": "impl", "task": "Implement step 3 and land the patch.", "write": true }
38
+ ],
39
+ "maxConcurrency": 4
40
+ }
41
+ ```
42
+
43
+ Each fork inherits the parent conversation **up to, but excluding, the assistant
44
+ message that contains the current `fork_join` call** — the call itself, its
45
+ sibling task descriptions, and any parallel-status text are not visible inside a
46
+ branch. `fork_join` blocks until all forks finish and returns their reports in
47
+ input order. The parent is responsible for synthesis and any final serial edits.
48
+
49
+ ## Context semantics
50
+
51
+ `fork_join` locates the current call in the active branch and uses the entry
52
+ *before* it as the shared fork base:
53
+
54
+ ```text
55
+ C0: existing conversation and tool results
56
+ C1: current assistant message
57
+ ├─ optional text
58
+ └─ fork_join toolCall
59
+
60
+ child_i = C0 + private_task_i
61
+ ```
62
+
63
+ This means:
64
+
65
+ - Every child sees the same history that existed before the current call.
66
+ - No child sees the `fork_join` call, sibling tasks, or parallel-status text.
67
+ - Prior tool results and earlier fork reports that already entered the parent
68
+ history are treated exactly like any serial result — nothing is deleted or
69
+ semantically pruned.
70
+ - Locating the call is a hard requirement: if it cannot be found, or is
71
+ ambiguous, the whole `fork_join` fails rather than silently falling back to
72
+ the current leaf (which would leak the running call into children).
73
+
74
+ A single task failing does not discard the batch; the parent still receives the
75
+ status of every task.
76
+
77
+ ## Isolation model
78
+
79
+ Isolation is enforced by **tool policy**, not by filesystem copying — and it is
80
+ **best-effort, not a sandbox**. A read-only fork excludes the `edit`/`write`
81
+ tools, which is what prevents the classic parallel-edit clobber race. But both
82
+ read-only and write forks still have `bash`, which can create, modify, and
83
+ delete files. Treat `read-only` as "no edit/write tools" — not as "cannot touch
84
+ the filesystem". The parent agent retains final authority and should not
85
+ delegate destructive or security-sensitive work to any fork without awareness.
86
+
87
+ | Fork kind | Tools | Working directory | When |
88
+ |---|---|---|---|
89
+ | **Read-only (default)** | `read`, `bash`, `grep`, `find`, `ls` — **no `edit`/`write`** | shared parent `cwd` | investigation, review, evidence collection |
90
+ | **Write (`write: true`)** | above **plus** `edit`, `write` | its **own git worktree** | a fork that must call `edit`/`write` |
91
+
92
+ Design rationale:
93
+
94
+ - **Read-only is the default and needs no worktree.** Because no fork has
95
+ `edit`/`write`, concurrent forks cannot clobber each other through those tools
96
+ regardless of scheduling order. Read-only forks also see the parent's
97
+ **current dirty working tree** (uncommitted changes), which is exactly what an
98
+ investigating agent needs. They still have `bash`, so they *can* write files
99
+ if they try — that is an accepted limitation, surfaced honestly here and in
100
+ the tool description, not silently hidden.
101
+ - **Worktree only for write forks.** A write fork is isolated in its own git
102
+ worktree so its `edit`/`write` cannot race the parent or other forks. A
103
+ worktree forks from the **last commit**: the parent's uncommitted changes are
104
+ deliberately **not** visible inside it. Worktree isolation is also best-effort
105
+ against a cooperative model; a fork that deliberately writes to an absolute
106
+ parent path can escape it. Write forks require a git repository; if none
107
+ exists, that fork fails with a clear error.
108
+ - **Non-git directories** are fine for read-only forks (they use the shared
109
+ `cwd`). Only `write: true` needs git.
110
+
111
+ ## Concurrency
112
+
113
+ - Forks run on the **single event loop** of the parent process. LLM calls, tool
114
+ subprocesses, and file I/O are all async I/O, so several forks genuinely
115
+ overlap (wall-clock ≈ slowest fork, not the sum). There is no CPU parallelism;
116
+ that is a deliberate and correct trade for this I/O-bound workload.
117
+ - Concurrency is bounded by `maxConcurrency` (default `4`, max `8`) via a
118
+ worker pool. This keeps provider calls and worktrees within a sane budget.
119
+ - **Cancellation:** when the parent tool call is aborted, every running fork's
120
+ session is aborted. Fork results report `cancelled`.
121
+ - **Failure isolation:** each fork runs in its own `AgentSession`; one fork
122
+ failing does not cancel the others. Failures are collected per-fork.
123
+
124
+ ## Context isolation
125
+
126
+ - Only the final assistant report of each fork is returned to the parent.
127
+ - Fork transcripts, tool calls, and thinking never enter the parent context.
128
+ - The shared prefix is rebuilt from the current **active branch only**
129
+ (`sessionManager.getBranch()`); sibling/abandoned branches are excluded.
130
+ - Messages are deep-cloned before seeding, so a fork can never mutate the
131
+ parent's history.
132
+
133
+ ## Settings
134
+
135
+ Configure under the `pi-fork-join` key in `~/.pi/agent/settings.json`
136
+ (global) or `.pi/settings.json` (project). Project overrides global.
137
+
138
+ ```json
139
+ {
140
+ "pi-fork-join": {
141
+ "defaultMaxConcurrency": 4,
142
+ "costFooter": true,
143
+ "leanChildren": true
144
+ }
145
+ }
146
+ ```
147
+
148
+ - `defaultMaxConcurrency` — used when a `fork_join` call omits `maxConcurrency`.
149
+ - `costFooter` — show fork cost as a dim footer status line.
150
+ - `leanChildren` — children load only the base system prompt + project
151
+ `AGENTS.md`, skipping extensions/skills/prompts/themes. Default `true`
152
+ (prevents child extension bloat and accidental recursive `fork_join`).
153
+
154
+ ## Development
155
+
156
+ ```bash
157
+ npm install # peer deps for typechecking
158
+ npm test # node --test unit tests (pure modules + real git worktrees)
159
+ npm run typecheck # tsc --noEmit against the installed pi packages
160
+ ```
161
+
162
+ The unit tests exercise the pure modules (scheduler, tool policy, report
163
+ assembly/extraction, config, and git worktree create/remove round-trips).
164
+ `src/fork-runner.ts` and `src/index.ts` are type-checked against the installed
165
+ pi SDK; integration against a live model/provider requires running inside pi
166
+ (`pi -e ./src/index.ts` and calling `fork_join`).
167
+
168
+ ## Important caveats
169
+
170
+ - **Single-threaded:** forked CPU-bound work still serializes on the event
171
+ loop. This extension is tuned for I/O-bound agent investigation.
172
+ - **Read-only is not a sandbox:** a read-only fork still has `bash`, which can
173
+ create/delete files. It only lacks `edit`/`write` tools. The parent agent
174
+ must not rely on a read-only fork being unable to touch the filesystem.
175
+ - **Context pollution on repeated calls:** each fork inherits the shared prefix.
176
+ If you call `fork_join` repeatedly in one session, later forks inherit the
177
+ earlier fork reports and may drift into meta-commentary. Prefer a single
178
+ `fork_join` call for all parallel tasks, and run clean experiments in a fresh
179
+ session.
180
+ - **Worktree sees only committed state:** a `write: true` fork cannot see your
181
+ uncommitted parent changes. Use read-only forks (shared `cwd`) when a fork
182
+ must inspect the dirty working tree.
183
+ - **Report-only contract:** only the final report returns to the parent; fork
184
+ tool logs and thinking never enter the parent context.
185
+
186
+ ## License
187
+
188
+ MIT. The configuration surface and early design were adapted from
189
+ [`pi-fork`](https://github.com/elpapi42/pi-fork) (MIT); the in-process
190
+ execution engine and shared-prefix context implementation are original to this
191
+ package.
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "pi-fork-join",
3
+ "version": "0.1.0",
4
+ "description": "In-process shared-prefix concurrent fork/join for the Pi coding agent. Read-only forks by default; write forks are isolated in their own git worktrees. The parent keeps its context window; children return dense structured reports.",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "files": [
8
+ "src",
9
+ "README.md",
10
+ "LICENSE"
11
+ ],
12
+ "pi": {
13
+ "extensions": [
14
+ "./src/index.ts"
15
+ ]
16
+ },
17
+ "scripts": {
18
+ "test": "node --test test/**/*.test.mjs",
19
+ "typecheck": "tsc --noEmit"
20
+ },
21
+ "keywords": [
22
+ "pi",
23
+ "fork",
24
+ "join",
25
+ "subagent",
26
+ "concurrency",
27
+ "parallel",
28
+ "pi-package"
29
+ ],
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/InertialG/pi-fork-join.git"
34
+ },
35
+ "homepage": "https://github.com/InertialG/pi-fork-join#readme",
36
+ "bugs": {
37
+ "url": "https://github.com/InertialG/pi-fork-join/issues"
38
+ },
39
+ "peerDependencies": {
40
+ "@earendil-works/pi-agent-core": ">=0.75.0",
41
+ "@earendil-works/pi-ai": ">=0.75.0",
42
+ "@earendil-works/pi-coding-agent": ">=0.75.0",
43
+ "@earendil-works/pi-tui": ">=0.75.0",
44
+ "@sinclair/typebox": ">=0.34.0"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "@earendil-works/pi-agent-core": {
48
+ "optional": true
49
+ },
50
+ "@earendil-works/pi-ai": {
51
+ "optional": true
52
+ }
53
+ },
54
+ "devDependencies": {
55
+ "@earendil-works/pi-agent-core": ">=0.75.0",
56
+ "@earendil-works/pi-ai": ">=0.75.0",
57
+ "@earendil-works/pi-coding-agent": ">=0.75.0",
58
+ "@earendil-works/pi-tui": ">=0.75.0",
59
+ "@sinclair/typebox": ">=0.34.0",
60
+ "typescript": "^5.5.0"
61
+ }
62
+ }
package/src/config.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
3
+
4
+ const SETTINGS_KEY = "pi-fork-join";
5
+
6
+ export interface ForkJoinConfig {
7
+ /** Default maxConcurrency when a call omits it. */
8
+ defaultMaxConcurrency: number;
9
+ /** Show fork cost as an extra dimmed footer status line. */
10
+ costFooter: boolean;
11
+ /** When true, children never load extensions/skills/prompts/themes. */
12
+ leanChildren: boolean;
13
+ }
14
+
15
+ export const DEFAULT_CONFIG: ForkJoinConfig = {
16
+ defaultMaxConcurrency: 4,
17
+ costFooter: true,
18
+ leanChildren: true,
19
+ };
20
+
21
+ function readNamespacedConfig(settingsPath: string): Partial<ForkJoinConfig> {
22
+ if (!existsSync(settingsPath)) return {};
23
+ try {
24
+ const raw = JSON.parse(readFileSync(settingsPath, "utf-8")) as Record<string, unknown>;
25
+ const nested = raw[SETTINGS_KEY];
26
+ if (!nested || typeof nested !== "object" || Array.isArray(nested)) return {};
27
+ const cfg = nested as Record<string, unknown>;
28
+ const parsed: Partial<ForkJoinConfig> = {};
29
+ if (typeof cfg.defaultMaxConcurrency === "number" && Number.isInteger(cfg.defaultMaxConcurrency)) {
30
+ parsed.defaultMaxConcurrency = cfg.defaultMaxConcurrency;
31
+ }
32
+ if (typeof cfg.costFooter === "boolean") parsed.costFooter = cfg.costFooter;
33
+ if (typeof cfg.leanChildren === "boolean") parsed.leanChildren = cfg.leanChildren;
34
+ return parsed;
35
+ } catch {
36
+ return {};
37
+ }
38
+ }
39
+
40
+ /** Load the `pi-fork-join` settings namespace, merging global + project (project wins). */
41
+ export function loadConfig(cwd: string): ForkJoinConfig {
42
+ const agentDir = getAgentDir();
43
+ const global = readNamespacedConfig(agentDir + "/settings.json");
44
+ const project = readNamespacedConfig(cwd + "/.pi/settings.json");
45
+ return { ...DEFAULT_CONFIG, ...global, ...project };
46
+ }
package/src/context.ts ADDED
@@ -0,0 +1,110 @@
1
+ import { buildSessionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
2
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
+
4
+ /**
5
+ * Minimal structural view of a session manager needed to resolve the fork base.
6
+ *
7
+ * `ctx.sessionManager` exposes more, but typing against a small structural
8
+ * interface keeps this module decoupled and unit-testable.
9
+ */
10
+ export interface ForkBaseSession {
11
+ getBranch(): readonly SessionEntry[];
12
+ getEntries(): SessionEntry[];
13
+ }
14
+
15
+ /**
16
+ * True when a session entry is an assistant message containing a tool call
17
+ * whose id matches `toolCallId`. Used to locate the current `fork_join` call.
18
+ */
19
+ export function containsToolCall(entry: SessionEntry, toolCallId: string): boolean {
20
+ if (entry.type !== "message") return false;
21
+ const msg = entry.message;
22
+ if (msg.role !== "assistant") return false;
23
+ const content = (msg as { content?: unknown }).content;
24
+ if (!Array.isArray(content)) return false;
25
+ return content.some((block) => {
26
+ if (!block || typeof block !== "object") return false;
27
+ const b = block as { type?: unknown; id?: unknown };
28
+ return b.type === "toolCall" && b.id === toolCallId;
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Locate the assistant message entry containing the current `fork_join` tool
34
+ * call and return its `parentId` — the shared fork base for every branch.
35
+ *
36
+ * Walking the active branch from the end means the returned base is the entry
37
+ * immediately *before* the `fork_join` call, so the call itself, its sibling
38
+ * tasks, and any parallel-status text never enter a child context.
39
+ *
40
+ * Returns `null` when the call is the very first message (empty history), which
41
+ * callers must interpret as "no prior context". Throws instead of silently
42
+ * falling back to the current leaf when the call cannot be located or when the
43
+ * id is ambiguous.
44
+ */
45
+ export function findForkBaseEntryId(
46
+ sessionManager: ForkBaseSession,
47
+ toolCallId: string,
48
+ ): string | null {
49
+ const branch = sessionManager.getBranch();
50
+ let parentId: string | null | undefined;
51
+ let matches = 0;
52
+
53
+ for (let i = branch.length - 1; i >= 0; i--) {
54
+ const entry = branch[i];
55
+ if (!containsToolCall(entry, toolCallId)) continue;
56
+ matches += 1;
57
+ parentId = entry.parentId;
58
+ }
59
+
60
+ if (matches === 0) {
61
+ throw new Error(`Cannot locate fork_join call entry: ${toolCallId}`);
62
+ }
63
+ if (matches > 1) {
64
+ throw new Error(`Multiple fork_join call entries found for id: ${toolCallId}`);
65
+ }
66
+ return parentId ?? null;
67
+ }
68
+
69
+ /**
70
+ * Resolve the shared-prefix context for all forks: every session entry from the
71
+ * root up to (and including) the fork base, i.e. everything before the current
72
+ * `fork_join` call.
73
+ *
74
+ * Uses Pi's own `buildSessionContext` so compaction, branch summaries, custom
75
+ * messages, and entry-to-message conversion follow Pi's original rules — this
76
+ * module never decides what history is "trustworthy" or drops prior fork
77
+ * results. It only converts entries on the base path into LLM `AgentMessage`s.
78
+ *
79
+ * The base is validated to be on the active branch because `buildSessionContext`
80
+ * silently falls back to the current leaf for an unknown id; that fallback would
81
+ * re-leak the running `fork_join` call into children and is explicitly rejected.
82
+ */
83
+ export function resolveForkBaseContext(
84
+ sessionManager: ForkBaseSession,
85
+ forkBaseId: string | null,
86
+ ): AgentMessage[] {
87
+ if (forkBaseId !== null) {
88
+ const onBranch = sessionManager.getBranch().some((e) => e.id === forkBaseId);
89
+ if (!onBranch) {
90
+ throw new Error(`Fork base entry not on the active branch: ${forkBaseId}`);
91
+ }
92
+ }
93
+ const context = buildSessionContext(sessionManager.getEntries(), forkBaseId);
94
+ return context.messages;
95
+ }
96
+
97
+ /**
98
+ * Deep-clone a message so a child session can never mutate the parent's
99
+ * history (or a sibling's seeded copy).
100
+ */
101
+ export function cloneMessage<T>(value: T): T {
102
+ if (typeof structuredClone === "function") {
103
+ try {
104
+ return structuredClone(value);
105
+ } catch {
106
+ /* fall through to JSON clone */
107
+ }
108
+ }
109
+ return JSON.parse(JSON.stringify(value)) as T;
110
+ }
@@ -0,0 +1,132 @@
1
+ import {
2
+ createAgentSession,
3
+ DefaultResourceLoader,
4
+ getAgentDir,
5
+ SessionManager,
6
+ SettingsManager,
7
+ type AgentSession,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
10
+ import { cloneMessage } from "./context.js";
11
+ import { buildForkTaskPrompt, extractFinalReport } from "./report.js";
12
+ import { resolveTools } from "./tool-policy.js";
13
+ import { MAX_REPORT_CHARS, type ForkResult } from "./types.js";
14
+
15
+ export interface RunForkOptions {
16
+ id: string;
17
+ task: string;
18
+ write: boolean;
19
+ /** Effective working directory: shared parent cwd, or a worktree path. */
20
+ cwd: string;
21
+ /**
22
+ * Shared-prefix context (root → fork base) already resolved from the parent
23
+ * session. Every branch starts from the same array; each fork gets its own
24
+ * deep-cloned copy so children can never mutate each other or the parent.
25
+ */
26
+ baseMessages: readonly AgentMessage[];
27
+ model?: unknown;
28
+ thinkingLevel?: unknown;
29
+ modelRuntime: unknown;
30
+ /** Lean children: load only the base system prompt + project AGENTS.md. */
31
+ leanChildren: boolean;
32
+ signal?: AbortSignal;
33
+ }
34
+
35
+ /**
36
+ * Run one fork as an in-process AgentSession.
37
+ *
38
+ * The child is a fresh, in-memory session. We seed `agent.state.messages` with
39
+ * the parent's shared prefix (zero-copy context, no JSONL round-trip, no
40
+ * process spawn), then run `prompt()` with the task. Only the final assistant
41
+ * report is returned to the caller.
42
+ *
43
+ * Concurrency note: LLM/tool work here is all async I/O, so several such child
44
+ * sessions genuinely overlap on the single event loop. The parent's context
45
+ * window only ever receives each child's final report.
46
+ */
47
+ export async function runFork(opts: RunForkOptions): Promise<ForkResult> {
48
+ const started = Date.now();
49
+ let session: AgentSession | undefined;
50
+ let unsubscribe: (() => void) | undefined;
51
+
52
+ try {
53
+ const loader = new DefaultResourceLoader({
54
+ cwd: opts.cwd,
55
+ agentDir: getAgentDir(),
56
+ noExtensions: opts.leanChildren,
57
+ noSkills: opts.leanChildren,
58
+ noPromptTemplates: opts.leanChildren,
59
+ noThemes: opts.leanChildren,
60
+ });
61
+ await loader.reload();
62
+
63
+ const settingsManager = SettingsManager.inMemory({ compaction: { enabled: false } });
64
+
65
+ const created = await createAgentSession({
66
+ cwd: opts.cwd,
67
+ model: opts.model as never,
68
+ thinkingLevel: opts.thinkingLevel as never,
69
+ tools: resolveTools(opts.write),
70
+ modelRuntime: opts.modelRuntime as never,
71
+ resourceLoader: loader,
72
+ settingsManager,
73
+ sessionManager: SessionManager.inMemory(opts.cwd),
74
+ });
75
+ session = created.session;
76
+
77
+ // Seed the shared prefix. Clone per fork so concurrent children and the
78
+ // parent each own independent message objects.
79
+ session.agent.state.messages = opts.baseMessages.map(cloneMessage);
80
+
81
+ // Propagate cancellation to the child.
82
+ const signal = opts.signal;
83
+ const onAbort = (): void => {
84
+ void session?.abort();
85
+ };
86
+ if (signal) {
87
+ if (signal.aborted) void session.abort();
88
+ else signal.addEventListener("abort", onAbort, { once: true });
89
+ }
90
+
91
+ let toolCalls = 0;
92
+ let turns = 0;
93
+ unsubscribe = session.subscribe((ev) => {
94
+ if (ev.type === "tool_execution_start") toolCalls += 1;
95
+ else if (ev.type === "turn_end") turns += 1;
96
+ });
97
+
98
+ await session.prompt(buildForkTaskPrompt(opts.task));
99
+
100
+ if (signal) signal.removeEventListener("abort", onAbort);
101
+
102
+ const report = extractFinalReport(session.messages as AgentMessage[], MAX_REPORT_CHARS);
103
+ const status = signal?.aborted ? "cancelled" : "completed";
104
+
105
+ return {
106
+ id: opts.id,
107
+ task: opts.task,
108
+ write: opts.write,
109
+ status,
110
+ report,
111
+ toolCalls,
112
+ turns,
113
+ durationMs: Date.now() - started,
114
+ worktreeUsed: opts.write,
115
+ };
116
+ } catch (err) {
117
+ return {
118
+ id: opts.id,
119
+ task: opts.task,
120
+ write: opts.write,
121
+ status: "failed",
122
+ toolCalls: 0,
123
+ turns: 0,
124
+ durationMs: Date.now() - started,
125
+ error: err instanceof Error ? err.message : String(err),
126
+ worktreeUsed: opts.write,
127
+ };
128
+ } finally {
129
+ unsubscribe?.();
130
+ session?.dispose();
131
+ }
132
+ }
package/src/index.ts ADDED
@@ -0,0 +1,183 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { ModelRuntime, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { loadConfig } from "./config.js";
4
+ import { findForkBaseEntryId, resolveForkBaseContext } from "./context.js";
5
+ import { runFork } from "./fork-runner.js";
6
+ import { renderForkJoinCall, renderForkJoinResult } from "./render.js";
7
+ import { mapLimit } from "./scheduler.js";
8
+ import {
9
+ DEFAULT_MAX_CONCURRENCY,
10
+ MAX_MAX_CONCURRENCY,
11
+ MAX_TASKS,
12
+ type ForkJoinToolDetails,
13
+ type ForkResult,
14
+ type ForkTaskSpec,
15
+ } from "./types.js";
16
+ import { createWorktree, detectRepoRoot, removeWorktree, type Worktree } from "./worktree.js";
17
+
18
+ const TaskParams = Type.Object({
19
+ id: Type.String({
20
+ description:
21
+ "Unique id for this task, used to correlate results. Keep it short and meaningful (e.g. 'data', 'concurrency').",
22
+ }),
23
+ task: Type.String({
24
+ description:
25
+ "The bounded task for the fork. Make the local goal self-contained but do NOT re-explain the shared context the fork already inherits. Say where the fork's decision authority ends; ambiguities should be surfaced back rather than resolved unilaterally.",
26
+ }),
27
+ write: Type.Optional(
28
+ Type.Boolean({
29
+ description:
30
+ "If true the fork may call edit/write and runs isolated in its own git worktree. If false (default) the fork has no edit/write tools and runs in the shared working directory. NOTE: read-only still includes bash, which can create/delete files; read-only prevents edit/write-tool races between parallel forks, it is NOT a filesystem sandbox. The parent retains final authority and must not rely on a read-only fork being unable to touch files.",
31
+ }),
32
+ ),
33
+ });
34
+
35
+ const ForkJoinParams = Type.Object({
36
+ tasks: Type.Array(TaskParams, {
37
+ minItems: 1,
38
+ maxItems: MAX_TASKS,
39
+ description: "Independent tasks to run in parallel forks. Tasks MUST be mutually independent; do not batch sequentially dependent steps.",
40
+ }),
41
+ maxConcurrency: Type.Optional(
42
+ Type.Integer({
43
+ minimum: 1,
44
+ maximum: MAX_MAX_CONCURRENCY,
45
+ description: `Concurrent fork limit (default ${DEFAULT_MAX_CONCURRENCY}, max ${MAX_MAX_CONCURRENCY}).`,
46
+ }),
47
+ ),
48
+ write: Type.Optional(
49
+ Type.Boolean({
50
+ description:
51
+ "Default write flag inherited by tasks that do not set one explicitly. Default false.",
52
+ }),
53
+ ),
54
+ });
55
+
56
+ function formatJoinText(details: ForkJoinToolDetails): string {
57
+ const lines: string[] = [`fork_join: ${details.results.length} task(s), ${details.durationMs}ms`];
58
+ for (const r of details.results) {
59
+ if (r.status === "completed" && r.report) {
60
+ lines.push(`\n=== [${r.id}]${r.write ? " (worktree)" : ""} ===\n${r.report}`);
61
+ } else {
62
+ lines.push(`\n=== [${r.id}] ${r.status}${r.error ? `: ${r.error}` : ""} ===`);
63
+ }
64
+ }
65
+ return lines.join("\n");
66
+ }
67
+
68
+ export default function (pi: ExtensionAPI) {
69
+ pi.registerTool({
70
+ name: "fork_join",
71
+ label: "Fork/Join",
72
+ description:
73
+ "Run several independent stages concurrently from the parent context before the current call. Spawn forks that share the conversation up to, but excluding, the assistant message containing the current fork_join call; the current call and sibling task descriptions are not visible inside a branch. Each fork runs its task and returns only its final dense report; the parent remains responsible for synthesis and final edits. Use it to offload context-heavy independent investigations (explore separate subsystems, compare competing explanations, run independent reviews, collect evidence in parallel). IMPORTANT: forks still run bash (read-only forks only drop edit/write), so a fork can create/delete files; this is not a sandbox. Do not delegate destructive or security-sensitive work to a fork without awareness.",
74
+ promptSnippet: "Run independent stages concurrently from the parent context before the current call",
75
+ promptGuidelines: [
76
+ "Use fork_join when two or more stages can be completed independently.",
77
+ "Put all independent stages in one fork_join call.",
78
+ "Each fork_join task must state its own local objective.",
79
+ "Do not tell one fork_join task that another task is currently running.",
80
+ "Do not use fork_join when one task requires another task's result.",
81
+ "Each task inherits the parent conversation up to, but excluding, the assistant message containing the current fork_join call; the current call and sibling task descriptions are not visible inside a branch.",
82
+ ],
83
+ parameters: ForkJoinParams,
84
+ renderCall: renderForkJoinCall,
85
+ renderResult: renderForkJoinResult,
86
+ executionMode: "sequential",
87
+
88
+ async execute(toolCallId, params, signal, _onUpdate, ctx) {
89
+ const started = Date.now();
90
+ const config = loadConfig(ctx.cwd);
91
+
92
+ // Locate the current fork_join call and resolve the shared prefix once.
93
+ // Every branch starts from the entry before this call; the call itself,
94
+ // sibling tasks, and any parallel-status text never enter a child.
95
+ // Location failure is a hard error, never a silent fallback to the leaf.
96
+ const forkBaseId = findForkBaseEntryId(ctx.sessionManager, toolCallId);
97
+ const baseMessages = resolveForkBaseContext(ctx.sessionManager, forkBaseId);
98
+ const maxConcurrency = Math.min(
99
+ params.maxConcurrency ?? config.defaultMaxConcurrency,
100
+ MAX_MAX_CONCURRENCY,
101
+ );
102
+ const defaultWrite = params.write ?? false;
103
+
104
+ const tasks: ForkTaskSpec[] = params.tasks.map((t) => ({
105
+ id: t.id,
106
+ task: t.task,
107
+ write: t.write ?? defaultWrite,
108
+ }));
109
+
110
+ const modelRuntime = await ModelRuntime.create({ signal });
111
+ const worktrees: Worktree[] = [];
112
+
113
+ const results = await mapLimit(tasks, maxConcurrency, async (task): Promise<ForkResult> => {
114
+ let cwd = ctx.cwd;
115
+ let worktree: Worktree | undefined;
116
+
117
+ if (task.write) {
118
+ const root = await detectRepoRoot(ctx.cwd);
119
+ if (!root) {
120
+ return {
121
+ id: task.id,
122
+ task: task.task,
123
+ write: true,
124
+ status: "failed",
125
+ toolCalls: 0,
126
+ turns: 0,
127
+ durationMs: 0,
128
+ error:
129
+ "write fork requires a git repository for worktree isolation; none found at the current directory.",
130
+ worktreeUsed: false,
131
+ };
132
+ }
133
+ try {
134
+ worktree = await createWorktree(root);
135
+ } catch (err) {
136
+ return {
137
+ id: task.id,
138
+ task: task.task,
139
+ write: true,
140
+ status: "failed",
141
+ toolCalls: 0,
142
+ turns: 0,
143
+ durationMs: 0,
144
+ error: `worktree isolation failed: ${err instanceof Error ? err.message : String(err)}`,
145
+ worktreeUsed: false,
146
+ };
147
+ }
148
+ worktrees.push(worktree);
149
+ cwd = worktree.path;
150
+ }
151
+
152
+ try {
153
+ return await runFork({
154
+ id: task.id,
155
+ task: task.task,
156
+ write: task.write,
157
+ cwd,
158
+ baseMessages,
159
+ model: ctx.model,
160
+ thinkingLevel: ctx.thinkingLevel,
161
+ modelRuntime,
162
+ leanChildren: config.leanChildren,
163
+ signal,
164
+ });
165
+ } finally {
166
+ if (worktree) await removeWorktree(worktree);
167
+ }
168
+ });
169
+
170
+ const details: ForkJoinToolDetails = {
171
+ status: "completed",
172
+ results,
173
+ durationMs: Date.now() - started,
174
+ maxConcurrency,
175
+ };
176
+
177
+ return {
178
+ content: [{ type: "text", text: formatJoinText(details) }],
179
+ details,
180
+ };
181
+ },
182
+ });
183
+ }
package/src/render.ts ADDED
@@ -0,0 +1,57 @@
1
+ import { Text } from "@earendil-works/pi-tui";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import type { ForkJoinToolDetails, ForkResult } from "./types.js";
4
+
5
+ /** Compact one-line header for the fork_join tool call. */
6
+ export function renderForkJoinCall(
7
+ args: { tasks?: { id?: string; write?: boolean }[] },
8
+ theme: Theme,
9
+ _context: unknown,
10
+ ): Text {
11
+ const n = args.tasks?.length ?? 0;
12
+ const writes = (args.tasks ?? []).filter((t) => t.write).length;
13
+ const text = new Text("", 0, 0);
14
+ let content = theme.fg("toolTitle", theme.bold("fork_join "));
15
+ content += theme.fg("accent", `${n}`);
16
+ content += " forks";
17
+ if (writes > 0) content += theme.fg("dim", ` (${writes} write/worktree)`);
18
+ text.setText(content);
19
+ return text;
20
+ }
21
+
22
+ /** Renders fork results with per-fork status lines; expanded shows reports. */
23
+ export function renderForkJoinResult(
24
+ result: { details?: ForkJoinToolDetails; isError?: boolean },
25
+ options: { expanded: boolean },
26
+ theme: Theme,
27
+ _context: unknown,
28
+ ): Text {
29
+ const text = new Text("", 0, 0);
30
+ const details = result.details;
31
+ if (!details) {
32
+ text.setText(theme.fg("dim", "fork_join: no details"));
33
+ return text;
34
+ }
35
+ const lines: string[] = [];
36
+ for (const r of details.results) {
37
+ lines.push(renderResultLine(r, theme));
38
+ if (options.expanded && r.report) {
39
+ lines.push(...r.report.split("\n").slice(0, 20).map((l) => ` ${l}`));
40
+ }
41
+ }
42
+ const total = details.results.length;
43
+ const done = details.results.filter((r) => r.status === "completed").length;
44
+ lines.push(theme.fg("dim", `${done}/${total} completed in ${details.durationMs}ms`));
45
+ text.setText(lines.join("\n"));
46
+ return text;
47
+ }
48
+
49
+ function renderResultLine(r: ForkResult, theme: Theme): string {
50
+ const color: "success" | "warning" | "error" =
51
+ r.status === "completed" ? "success" : r.status === "cancelled" ? "warning" : "error";
52
+ let line = `${theme.fg("accent", r.id)} ${theme.fg(color, r.status)}`;
53
+ if (r.write) line += theme.fg("dim", " (worktree)");
54
+ line += theme.fg("dim", ` · ${r.turns}t ${r.toolCalls}tc ${r.durationMs}ms`);
55
+ if (r.error) line += theme.fg("error", ` ${r.error}`);
56
+ return line;
57
+ }
package/src/report.ts ADDED
@@ -0,0 +1,51 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import { MAX_REPORT_CHARS } from "./types.js";
3
+
4
+ /**
5
+ * Build the prompt a fork child receives: the caller's bounded task wrapped in
6
+ * a short execution-boundary instruction. The fork runs in the shared parent
7
+ * context, but must not continue pending parent workflow instructions from
8
+ * that history. It is not told that sibling branches exist and is not forced
9
+ * into a fixed report skeleton.
10
+ */
11
+ export function buildForkTaskPrompt(task: string): string {
12
+ return `You are an isolated fork_join child session. Complete only the task below.
13
+ Use the inherited conversation as background, but do not continue pending workflow instructions from it.
14
+ fork_join is intentionally unavailable in this session; do not simulate, delegate, or wait for other tasks.
15
+
16
+ Task:
17
+ ${task}
18
+
19
+ Return the result and concrete supporting evidence needed by the parent agent. Stay within the assigned scope.`;
20
+ }
21
+
22
+ /**
23
+ * Extract the final assistant text from a child session's message history.
24
+ * This is the ONLY thing returned to the parent: the child's intermediate tool
25
+ * logs and thinking never enter the parent context.
26
+ */
27
+ export function extractFinalReport(
28
+ messages: readonly AgentMessage[],
29
+ maxChars: number = MAX_REPORT_CHARS,
30
+ ): string {
31
+ for (let i = messages.length - 1; i >= 0; i--) {
32
+ const msg = messages[i];
33
+ if (msg.role !== "assistant") continue;
34
+ const text = assistantText(msg);
35
+ if (text.trim().length > 0) {
36
+ return text.length > maxChars ? text.slice(0, maxChars) : text;
37
+ }
38
+ }
39
+ return "";
40
+ }
41
+
42
+ function assistantText(msg: AgentMessage): string {
43
+ const content = (msg as { content?: unknown }).content;
44
+ if (typeof content === "string") return content;
45
+ if (Array.isArray(content)) {
46
+ return content
47
+ .map((block) => (block && typeof block === "object" && (block as { text?: string }).text) || "")
48
+ .join("\n");
49
+ }
50
+ return "";
51
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * A bounded async worker pool (mapLimit).
3
+ *
4
+ * Runs `fn` over `items` with at most `limit` concurrent executions. Unlike an
5
+ * unbounded `Promise.all`, this keeps the number of in-flight forks bounded and
6
+ * guarantees strict concurrency limiting for deterministic behavior.
7
+ */
8
+ export async function mapLimit<T, R>(
9
+ items: readonly T[],
10
+ limit: number,
11
+ fn: (item: T, index: number) => Promise<R>,
12
+ ): Promise<R[]> {
13
+ if (items.length === 0) return [];
14
+ const safeLimit = Math.max(1, Math.min(limit, items.length));
15
+ const results = new Array<R>(items.length);
16
+ let next = 0;
17
+
18
+ const worker = async (): Promise<void> => {
19
+ while (true) {
20
+ const idx = next;
21
+ if (idx >= items.length) return;
22
+ next += 1;
23
+ results[idx] = await fn(items[idx], idx);
24
+ }
25
+ };
26
+
27
+ await Promise.all(Array.from({ length: safeLimit }, worker));
28
+ return results;
29
+ }
@@ -0,0 +1,13 @@
1
+ import { FULL_TOOLS, READ_ONLY_TOOLS } from "./types.js";
2
+
3
+ /**
4
+ * Resolve the tool allowlist for a fork.
5
+ *
6
+ * Read-only forks (the default) deliberately exclude `edit`/`write`. Since no
7
+ * fork can mutate the shared working tree, concurrent forks cannot clobber each
8
+ * other regardless of scheduling. Write forks gain `edit`/`write`, but only run
9
+ * against their own isolated git worktree.
10
+ */
11
+ export function resolveTools(write: boolean): string[] {
12
+ return write ? [...FULL_TOOLS] : [...READ_ONLY_TOOLS];
13
+ }
package/src/types.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Shared types for the pi-fork-join extension.
3
+ */
4
+
5
+ export type ForkStatus = "completed" | "failed" | "cancelled";
6
+
7
+ /** A single task to run in an isolated fork. */
8
+ export interface ForkTaskSpec {
9
+ id: string;
10
+ task: string;
11
+ /** If true, the fork may edit/write files and is isolated in a git worktree. */
12
+ write: boolean;
13
+ }
14
+
15
+ /** Per-fork runtime state while it runs. */
16
+ export interface ForkTaskRuntime extends ForkTaskSpec {
17
+ /** Effective working directory (shared parent cwd, or a git worktree path for write forks). */
18
+ cwd: string;
19
+ worktreePath?: string;
20
+ }
21
+
22
+ /** Result collected from a completed/failed fork. */
23
+ export interface ForkResult {
24
+ id: string;
25
+ task: string;
26
+ write: boolean;
27
+ status: ForkStatus;
28
+ /** Final assistant report text (truncated). Absent on failure. */
29
+ report?: string;
30
+ toolCalls: number;
31
+ turns: number;
32
+ durationMs: number;
33
+ error?: string;
34
+ /** True when the fork ran inside its own git worktree. */
35
+ worktreeUsed: boolean;
36
+ /** Non-fatal note, e.g. worktree fallback happened. */
37
+ isolationWarning?: string;
38
+ }
39
+
40
+ export interface ForkJoinToolDetails {
41
+ status: "completed";
42
+ results: ForkResult[];
43
+ durationMs: number;
44
+ maxConcurrency: number;
45
+ }
46
+
47
+ export const DEFAULT_MAX_CONCURRENCY = 4;
48
+ export const MAX_MAX_CONCURRENCY = 8;
49
+ export const MAX_TASKS = 8;
50
+ export const MAX_REPORT_CHARS = 16_000;
51
+
52
+ /** Tools available to a read-only fork. No edit/write, so no concurrent-clobber race. */
53
+ export const READ_ONLY_TOOLS = ["read", "bash", "grep", "find", "ls"] as const;
54
+ /** Tools additionally enabled for a write fork (isolated in a git worktree). */
55
+ export const WRITE_TOOLS = ["edit", "write"] as const;
56
+ export const FULL_TOOLS = [...READ_ONLY_TOOLS, ...WRITE_TOOLS];
@@ -0,0 +1,75 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import * as fs from "node:fs";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+
7
+ const execFileAsync = promisify(execFile);
8
+
9
+ export interface Worktree {
10
+ /** Repository top-level directory (from `git rev-parse --show-toplevel`). */
11
+ root: string;
12
+ /** The worktree's working directory. */
13
+ path: string;
14
+ }
15
+
16
+ /**
17
+ * Detect the git repository top-level for `cwd`. Returns null when not inside
18
+ * a git repository (or git is unavailable). Used to decide whether write-fork
19
+ * worktree isolation is possible.
20
+ */
21
+ export async function detectRepoRoot(cwd: string): Promise<string | null> {
22
+ try {
23
+ const { stdout } = await execFileAsync(
24
+ "git",
25
+ ["-C", cwd, "rev-parse", "--show-toplevel"],
26
+ { timeout: 5000 },
27
+ );
28
+ const trimmed = stdout.trim();
29
+ return trimmed.length > 0 ? trimmed : null;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Create a detached git worktree at HEAD in a fresh temp directory.
37
+ *
38
+ * A worktree shares the repository's git objects but has its own working tree,
39
+ * so a write fork can edit files without racing the parent (or other forks).
40
+ * It intentionally forks from the last commit: uncommitted parent changes are
41
+ * NOT carried in. This is the documented isolation contract for write forks.
42
+ *
43
+ * The worktree is created on a detached HEAD (no new branch ref is needed
44
+ * because forks report changes back rather than pushing a branch).
45
+ */
46
+ export async function createWorktree(root: string): Promise<Worktree> {
47
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-fork-join-"));
48
+ await execFileAsync(
49
+ "git",
50
+ ["-C", root, "worktree", "add", "--detach", dir],
51
+ { timeout: 15_000 },
52
+ );
53
+ return { root, path: dir };
54
+ }
55
+
56
+ /**
57
+ * Remove a worktree, then best-effort delete the temp dir.
58
+ * Swallows cleanup errors so a failed join does not mask real fork failures.
59
+ */
60
+ export async function removeWorktree(wt: Worktree): Promise<void> {
61
+ try {
62
+ await execFileAsync(
63
+ "git",
64
+ ["-C", wt.root, "worktree", "remove", "--force", wt.path],
65
+ { timeout: 15_000 },
66
+ );
67
+ } catch {
68
+ /* ignore */
69
+ }
70
+ try {
71
+ await fs.promises.rm(wt.path, { recursive: true, force: true });
72
+ } catch {
73
+ /* ignore */
74
+ }
75
+ }