@resolveworks/fork 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +81 -2
- package/index.ts +899 -0
- package/package.json +45 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Johan Schuijt
|
|
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
CHANGED
|
@@ -1,3 +1,82 @@
|
|
|
1
|
-
#
|
|
1
|
+
# fork
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Spawn [pi](https://github.com/earendil-works/pi) subagents in tmux windows. Each child is a separate pi process you can watch and type into; it reports back over a Unix socket and stays alive for revision requests. Assumes fluency with pi, tmux, and git worktrees, and stays out of the way — including silently doing nothing outside tmux.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- pi 0.80.4 or newer (newest pi API used: `agent_settled`)
|
|
8
|
+
- tmux, with pi running inside it
|
|
9
|
+
- git, for `branch` worktree isolation
|
|
10
|
+
- Linux; macOS untested (nothing Linux-specific), Windows unsupported
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pi install npm:@resolveworks/fork
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or from git:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pi install git:github.com/resolveworks/fork
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or once, without installing:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pi -e npm:@resolveworks/fork
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Then restart pi, or `/reload`.
|
|
31
|
+
|
|
32
|
+
## What you get
|
|
33
|
+
|
|
34
|
+
The parent session gains three tools:
|
|
35
|
+
|
|
36
|
+
- `spawn_agent({ task, branch? })` — writes the task to a file and opens a new tmux window running `pi @<taskfile>`. Returns immediately. The child sees the task plus normal project context, not the parent's conversation, so the task must be self-contained.
|
|
37
|
+
- `message_agent({ id, text })` — steers a revision request into the child's current run, or starts a new turn when the child is idle.
|
|
38
|
+
- `close_agent({ id })` — mechanical shutdown; no model turn spent.
|
|
39
|
+
|
|
40
|
+
The child gains `report_result({ result })`. The report travels over a Unix socket and arrives in the parent as a steering notification: immediately if the parent is idle, or injected into the parent's current run after its pending tool calls. The child stays alive after reporting, awaiting the verdict: revisions via `message_agent`, or closure. Reports are outcomes, not progress updates. If delivery fails, the child is told unambiguously and retries with the same payload — a retry cannot double-deliver. Interrupting the parent would drop its queued notifications, so fork re-delivers them once the interrupted run settles — an escape can no longer lose a report.
|
|
41
|
+
|
|
42
|
+
Don't poll. Navigate windows with your usual tmux keys.
|
|
43
|
+
|
|
44
|
+
## Isolation
|
|
45
|
+
|
|
46
|
+
Without `branch`, the child shares the parent's working tree — fine for read-only or sequential work, a bad idea for concurrent editors.
|
|
47
|
+
|
|
48
|
+
With `branch: "kebab-name"`, fork runs `git worktree add ~/.pi/worktrees/<uuid> -b agent/<kebab-name> HEAD` and spawns the child there. The child commits its work; `close_agent` force-removes the worktree (uncommitted work dies with it) and retains the branch for normal review and merge.
|
|
49
|
+
|
|
50
|
+
Fork deliberately does no environment setup and runs no checks. `git worktree add` fires the repo's `post-checkout` hook — put pnpm/uv/whatever setup there. Commit hooks validate.
|
|
51
|
+
|
|
52
|
+
## Config
|
|
53
|
+
|
|
54
|
+
`~/.pi/agent/fork.json`, overridden per-project by `.pi/fork.json` (read only in trusted projects). All fields optional; passed through to the child as CLI flags:
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{ "model": "glm-5.2", "provider": "zai", "thinking": "high" }
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Runtime state
|
|
61
|
+
|
|
62
|
+
Under `~/.pi/agent/extensions/fork/` (sockets and task files are mode `0o600`):
|
|
63
|
+
|
|
64
|
+
- `sockets/<parent-session-id>.sock` — result channel, child → parent
|
|
65
|
+
- `agents/<uuid>.sock` — verdict channel, parent → child
|
|
66
|
+
- `tasks/<uuid>.md` — the task handed to the child; a present task file marks an open agent and is authoritative across extension reloads
|
|
67
|
+
|
|
68
|
+
## Development
|
|
69
|
+
|
|
70
|
+
Requires Node.js 22.18 or newer and pnpm 11.3.0.
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
pnpm install --frozen-lockfile
|
|
74
|
+
pnpm typecheck
|
|
75
|
+
pnpm format:check
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Use `pnpm format` to apply formatting.
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
package/index.ts
ADDED
|
@@ -0,0 +1,899 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fork — Spawn pi subagents in separate tmux windows.
|
|
3
|
+
*
|
|
4
|
+
* The parent registers `spawn_agent`, `message_agent`, and `close_agent`
|
|
5
|
+
* tools. Calling spawn_agent opens a new tmux window running pi with the
|
|
6
|
+
* given task and returns immediately. The child reports results through `report_result`;
|
|
7
|
+
* each report is delivered over a Unix socket as a notification that reaches
|
|
8
|
+
* the parent promptly: if the parent is idle the report starts a turn at
|
|
9
|
+
* once; if it is mid-run the report is steered into that run — delivered
|
|
10
|
+
* after its current tool calls, before the next LLM call. An interrupt
|
|
11
|
+
* clears the steering queue without delivering queued custom messages, so
|
|
12
|
+
* reports are tracked until delivery is confirmed and re-delivered when an
|
|
13
|
+
* interrupted run settles. The child stays alive after
|
|
14
|
+
* reporting: the parent reviews the report, sends revision requests with
|
|
15
|
+
* message_agent, and closes the child with close_agent.
|
|
16
|
+
*
|
|
17
|
+
* Configure the subagent model via ~/.pi/agent/fork.json (global) or
|
|
18
|
+
* .pi/fork.json (project, overrides global). Any field is optional:
|
|
19
|
+
* { "model": "glm-5.2", "provider": "zai", "thinking": "high" }
|
|
20
|
+
* Fork passes these settings to the child pi process as CLI flags, so pi
|
|
21
|
+
* resolves them using its normal startup rules.
|
|
22
|
+
*
|
|
23
|
+
* Requires: run pi inside tmux.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { spawnSync } from "node:child_process";
|
|
27
|
+
import { randomUUID } from "node:crypto";
|
|
28
|
+
import * as fs from "node:fs";
|
|
29
|
+
import * as net from "node:net";
|
|
30
|
+
import * as os from "node:os";
|
|
31
|
+
import * as path from "node:path";
|
|
32
|
+
import {
|
|
33
|
+
CONFIG_DIR_NAME,
|
|
34
|
+
type ExtensionAPI,
|
|
35
|
+
getAgentDir,
|
|
36
|
+
getMarkdownTheme,
|
|
37
|
+
keyHint,
|
|
38
|
+
type MessageRenderer,
|
|
39
|
+
} from "@earendil-works/pi-coding-agent";
|
|
40
|
+
import { Box, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
41
|
+
import { Type } from "typebox";
|
|
42
|
+
|
|
43
|
+
// ── tmux ────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
function inTmux(): boolean {
|
|
46
|
+
return !!process.env.TMUX;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Run tmux synchronously with argv passed directly (no shell), so paths and
|
|
50
|
+
* arguments with spaces/metacharacters are never shell-interpreted. */
|
|
51
|
+
function tmuxSync(args: string[], timeout = 3000): string {
|
|
52
|
+
const result = spawnSync("tmux", args, { encoding: "utf-8", timeout });
|
|
53
|
+
if (result.error) throw result.error;
|
|
54
|
+
if (result.status !== 0 || result.signal) {
|
|
55
|
+
const detail = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join(" / ");
|
|
56
|
+
const where =
|
|
57
|
+
result.signal !== null ? `signal ${result.signal}` : `exit ${result.status ?? "?"}`;
|
|
58
|
+
throw new Error(`fork: tmux ${args[0]} failed (${where})${detail ? `: ${detail}` : ""}`);
|
|
59
|
+
}
|
|
60
|
+
return result.stdout;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function tmuxSession(): string {
|
|
64
|
+
return tmuxSync(["display-message", "-p", "#S"]).trim();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── paths ───────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
const PI_DIR = path.join(os.homedir(), ".pi");
|
|
70
|
+
const ROOT = path.join(PI_DIR, "agent", "extensions", "fork");
|
|
71
|
+
const SOCKETS_DIR = path.join(ROOT, "sockets");
|
|
72
|
+
const AGENTS_DIR = path.join(ROOT, "agents");
|
|
73
|
+
const TASKS_DIR = path.join(ROOT, "tasks");
|
|
74
|
+
const WORKTREES_DIR = path.join(PI_DIR, "worktrees");
|
|
75
|
+
const BRANCH_PREFIX = "agent/";
|
|
76
|
+
const BRANCH_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
77
|
+
const RESULT_TYPE = "fork-result";
|
|
78
|
+
const MESSAGE_TYPE = "fork-message";
|
|
79
|
+
|
|
80
|
+
/** Number of markdown lines shown when the message row is collapsed (ctrl+o to expand). */
|
|
81
|
+
const PREVIEW_LINES = 10;
|
|
82
|
+
|
|
83
|
+
/** Render fork messages like pi's default custom-message rendering, plus a
|
|
84
|
+
* collapsed preview with an expand hint for long subagent reports. */
|
|
85
|
+
const renderForkMessage: MessageRenderer = (message, options, theme) => {
|
|
86
|
+
const text =
|
|
87
|
+
typeof message.content === "string"
|
|
88
|
+
? message.content
|
|
89
|
+
: message.content
|
|
90
|
+
.filter((item) => item.type === "text")
|
|
91
|
+
.map((item) => item.text ?? "")
|
|
92
|
+
.join("\n");
|
|
93
|
+
|
|
94
|
+
let body = text;
|
|
95
|
+
let hint = "";
|
|
96
|
+
if (!options.expanded) {
|
|
97
|
+
const lines = text.split("\n");
|
|
98
|
+
if (lines.length > PREVIEW_LINES) {
|
|
99
|
+
const remaining = lines.length - PREVIEW_LINES;
|
|
100
|
+
hint =
|
|
101
|
+
theme.fg("muted", `... (${remaining} more lines,`) +
|
|
102
|
+
" " +
|
|
103
|
+
keyHint("app.tools.expand", "to expand") +
|
|
104
|
+
theme.fg("muted", ")");
|
|
105
|
+
body = lines.slice(0, PREVIEW_LINES).join("\n");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
|
|
110
|
+
box.addChild(
|
|
111
|
+
new Text(theme.fg("customMessageLabel", theme.bold(`[${message.customType}]`)), 0, 0),
|
|
112
|
+
);
|
|
113
|
+
box.addChild(new Spacer(1));
|
|
114
|
+
box.addChild(
|
|
115
|
+
new Markdown(body, 0, 0, getMarkdownTheme(), {
|
|
116
|
+
color: (t) => theme.fg("customMessageText", t),
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
if (hint) {
|
|
120
|
+
box.addChild(new Spacer(1));
|
|
121
|
+
box.addChild(new Text(hint, 0, 0));
|
|
122
|
+
}
|
|
123
|
+
return box;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
function socketPathFor(sessionId: string): string {
|
|
127
|
+
return path.join(SOCKETS_DIR, `${sessionId}.sock`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function agentSocketPathFor(id: string): string {
|
|
131
|
+
return path.join(AGENTS_DIR, `${id}.sock`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function taskPathFor(id: string): string {
|
|
135
|
+
return path.join(TASKS_DIR, `${id}.md`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function worktreePathFor(id: string): string {
|
|
139
|
+
return path.join(WORKTREES_DIR, id);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Exact lowercase format emitted by randomUUID(); also blocks path traversal. */
|
|
143
|
+
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
144
|
+
|
|
145
|
+
// ── wire protocol (newline-delimited JSON) ──────────────────────────
|
|
146
|
+
|
|
147
|
+
/** Child → parent, over the parent's session socket. */
|
|
148
|
+
interface ResultPayload {
|
|
149
|
+
id: string;
|
|
150
|
+
summary: string;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Parent → child, over the child's agent socket. */
|
|
154
|
+
type AgentMessage = { type: "message"; text: string };
|
|
155
|
+
|
|
156
|
+
// ── config (~/.pi/agent/fork.json, .pi/fork.json) ───────────────────
|
|
157
|
+
|
|
158
|
+
interface ForkConfig {
|
|
159
|
+
/** Model id or pattern (supports `provider/id` and `:thinking`). */
|
|
160
|
+
model?: string;
|
|
161
|
+
provider?: string;
|
|
162
|
+
/** Thinking level passed to the child via `--thinking`. */
|
|
163
|
+
thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function readConfig(file: string): ForkConfig {
|
|
167
|
+
try {
|
|
168
|
+
if (!fs.existsSync(file)) return {};
|
|
169
|
+
return JSON.parse(fs.readFileSync(file, "utf-8")) as ForkConfig;
|
|
170
|
+
} catch (err) {
|
|
171
|
+
console.error(`fork: failed to load ${file}: ${err}`);
|
|
172
|
+
return {};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Merge global + project config. Project wins; only read if trusted. */
|
|
177
|
+
function loadConfig(cwd: string, projectTrusted: boolean): ForkConfig {
|
|
178
|
+
const globalCfg = readConfig(path.join(getAgentDir(), "fork.json"));
|
|
179
|
+
const projectCfg = projectTrusted ? readConfig(path.join(cwd, CONFIG_DIR_NAME, "fork.json")) : {};
|
|
180
|
+
return { ...globalCfg, ...projectCfg };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── git worktrees ───────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
interface WorktreeInfo {
|
|
186
|
+
path: string;
|
|
187
|
+
branch: string;
|
|
188
|
+
repoCwd: string;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Run git without a shell. The generous timeout allows repository-owned
|
|
192
|
+
* post-checkout hooks to perform normal worktree setup. */
|
|
193
|
+
async function git(
|
|
194
|
+
pi: ExtensionAPI,
|
|
195
|
+
args: string[],
|
|
196
|
+
cwd: string,
|
|
197
|
+
signal?: AbortSignal,
|
|
198
|
+
): Promise<string> {
|
|
199
|
+
const result = await pi.exec("git", args, {
|
|
200
|
+
cwd,
|
|
201
|
+
signal,
|
|
202
|
+
timeout: 10 * 60 * 1000,
|
|
203
|
+
});
|
|
204
|
+
if (result.code !== 0 || result.killed) {
|
|
205
|
+
const detail = [result.stderr.trim(), result.stdout.trim()]
|
|
206
|
+
.filter(Boolean)
|
|
207
|
+
.join(" / ")
|
|
208
|
+
.slice(-4000);
|
|
209
|
+
const where = result.killed ? "killed" : `exit ${result.code}`;
|
|
210
|
+
throw new Error(`fork: git ${args[0]} failed (${where})${detail ? `: ${detail}` : ""}`);
|
|
211
|
+
}
|
|
212
|
+
return result.stdout;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Remove a worktree created by a failed spawn, then delete its new branch.
|
|
216
|
+
* Cleanup is best-effort so the original spawn error remains authoritative. */
|
|
217
|
+
async function rollbackWorktree(pi: ExtensionAPI, info: WorktreeInfo): Promise<void> {
|
|
218
|
+
try {
|
|
219
|
+
await git(pi, ["worktree", "remove", "--force", info.path], info.repoCwd);
|
|
220
|
+
} catch (err) {
|
|
221
|
+
console.error(`fork: could not roll back worktree ${info.path}: ${err}`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
await git(pi, ["branch", "-D", info.branch], info.repoCwd);
|
|
227
|
+
} catch (err) {
|
|
228
|
+
console.error(`fork: could not roll back branch ${info.branch}: ${err}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function createWorktree(
|
|
233
|
+
pi: ExtensionAPI,
|
|
234
|
+
cwd: string,
|
|
235
|
+
id: string,
|
|
236
|
+
branchName: string,
|
|
237
|
+
signal?: AbortSignal,
|
|
238
|
+
): Promise<WorktreeInfo> {
|
|
239
|
+
if (!BRANCH_NAME.test(branchName)) {
|
|
240
|
+
throw new Error("fork: branch names must use lowercase letters, numbers, and single dashes");
|
|
241
|
+
}
|
|
242
|
+
const branch = `${BRANCH_PREFIX}${branchName}`;
|
|
243
|
+
|
|
244
|
+
// An explicit branch outside Git fails rather than degrading to shared mode.
|
|
245
|
+
await git(pi, ["rev-parse", "--show-toplevel"], cwd, signal);
|
|
246
|
+
fs.mkdirSync(WORKTREES_DIR, { recursive: true });
|
|
247
|
+
const info = { path: worktreePathFor(id), branch, repoCwd: cwd };
|
|
248
|
+
try {
|
|
249
|
+
await git(pi, ["worktree", "add", "--quiet", "-b", branch, info.path, "HEAD"], cwd, signal);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
// post-checkout can fail after creating both artifacts. Git rejects an
|
|
252
|
+
// existing branch before creating our path, so .git proves these are ours.
|
|
253
|
+
if (fs.existsSync(path.join(info.path, ".git"))) {
|
|
254
|
+
await rollbackWorktree(pi, info);
|
|
255
|
+
}
|
|
256
|
+
throw err;
|
|
257
|
+
}
|
|
258
|
+
return info;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── spawn (fire-and-forget) ─────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
function spawn(
|
|
264
|
+
session: string,
|
|
265
|
+
sessionId: string,
|
|
266
|
+
id: string,
|
|
267
|
+
taskPath: string,
|
|
268
|
+
cwd: string,
|
|
269
|
+
config: ForkConfig,
|
|
270
|
+
branch?: string,
|
|
271
|
+
): void {
|
|
272
|
+
// argv passed directly; tmux runs a multi-arg command without `sh -c`, so
|
|
273
|
+
// no shell quoting is needed. Let pi resolve model settings itself.
|
|
274
|
+
const cmdArgs = ["pi", "--subagent-socket", socketPathFor(sessionId), "--subagent-id", id];
|
|
275
|
+
if (config.provider) cmdArgs.push("--provider", config.provider);
|
|
276
|
+
if (config.model) cmdArgs.push("--model", config.model);
|
|
277
|
+
if (config.thinking) cmdArgs.push("--thinking", config.thinking);
|
|
278
|
+
if (branch) cmdArgs.push("--subagent-branch", branch);
|
|
279
|
+
cmdArgs.push(`@${taskPath}`);
|
|
280
|
+
|
|
281
|
+
tmuxSync(["new-window", "-d", "-t", `${session}:`, "-n", id, "-c", cwd, ...cmdArgs]);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── line servers and socket writes ──────────────────────────────────
|
|
285
|
+
|
|
286
|
+
/** Remove a file, tolerating an already-missing path. Never throws —
|
|
287
|
+
* callers (spawn rollback, socket teardown) must keep working. */
|
|
288
|
+
function safeUnlink(filePath: string, what: string): void {
|
|
289
|
+
try {
|
|
290
|
+
fs.unlinkSync(filePath);
|
|
291
|
+
} catch (err) {
|
|
292
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
293
|
+
console.error(`fork: could not remove ${what} ${filePath}: ${err}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Listen on sockPath and invoke onLine per newline-delimited payload. A
|
|
299
|
+
* stale socket file from a previous run is removed first. Server errors
|
|
300
|
+
* (e.g. unable to bind) are logged, not thrown: the session keeps running
|
|
301
|
+
* and the peer reports connection failures on its side. Mode 0o600 is
|
|
302
|
+
* enforced once listening starts.
|
|
303
|
+
*/
|
|
304
|
+
function serveLines(sockPath: string, what: string, onLine: (line: string) => void): net.Server {
|
|
305
|
+
safeUnlink(sockPath, `stale ${what}`);
|
|
306
|
+
const server = net.createServer((socket) => {
|
|
307
|
+
let buf = "";
|
|
308
|
+
socket.setEncoding("utf-8");
|
|
309
|
+
socket.on("error", (err) => {
|
|
310
|
+
console.error(`fork: ${what} connection error: ${err}`);
|
|
311
|
+
});
|
|
312
|
+
socket.on("data", (chunk: string) => {
|
|
313
|
+
buf += chunk;
|
|
314
|
+
for (let nl = buf.indexOf("\n"); nl >= 0; nl = buf.indexOf("\n")) {
|
|
315
|
+
const line = buf.slice(0, nl);
|
|
316
|
+
buf = buf.slice(nl + 1);
|
|
317
|
+
if (line.length === 0) continue;
|
|
318
|
+
onLine(line);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
server.on("error", (err) => {
|
|
323
|
+
console.error(`fork: ${what} error: ${err}`);
|
|
324
|
+
});
|
|
325
|
+
server.listen(sockPath, () => {
|
|
326
|
+
try {
|
|
327
|
+
fs.chmodSync(sockPath, 0o600);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
console.error(`fork: could not chmod ${what} ${sockPath}: ${err}`);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
return server;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function teardownServer(server: net.Server, sockPath: string, what: string) {
|
|
336
|
+
// close() with a callback absorbs ERR_SERVER_NOT_RUNNING instead of
|
|
337
|
+
// emitting an unhandled 'error'.
|
|
338
|
+
server.close((err) => {
|
|
339
|
+
if (err && (err as NodeJS.ErrnoException).code !== "ERR_SERVER_NOT_RUNNING") {
|
|
340
|
+
console.error(`fork: error closing ${what}: ${err}`);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
safeUnlink(sockPath, what);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Deliver one message to a live agent. Throws when the agent is unreachable
|
|
347
|
+
* or the write does not flush; callers report failure and keep their state. */
|
|
348
|
+
function sendToAgent(id: string, message: AgentMessage): Promise<void> {
|
|
349
|
+
return new Promise<void>((resolve, reject) => {
|
|
350
|
+
let settled = false;
|
|
351
|
+
const socket = net.connect(agentSocketPathFor(id));
|
|
352
|
+
const finish = (err?: Error) => {
|
|
353
|
+
if (settled) return;
|
|
354
|
+
settled = true;
|
|
355
|
+
if (err) {
|
|
356
|
+
socket.destroy();
|
|
357
|
+
reject(err);
|
|
358
|
+
} else {
|
|
359
|
+
resolve();
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
socket.setTimeout(5000);
|
|
363
|
+
socket.once("error", (err) => finish(err));
|
|
364
|
+
socket.once("timeout", () => finish(new Error("timed out after 5 seconds")));
|
|
365
|
+
socket.end(`${JSON.stringify(message)}\n`, () => finish());
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── result delivery ─────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
/** Deliver one report as a fork-result message, tracked in `undelivered`.
|
|
372
|
+
*
|
|
373
|
+
* A report is steered into the parent's current run: it is delivered after
|
|
374
|
+
* the current tool calls, before the next LLM call, so the parent can fold
|
|
375
|
+
* it into the work at hand rather than waiting for the run to finish (the
|
|
376
|
+
* follow-up idiom pi's examples use fits events that must run as fresh
|
|
377
|
+
* turns; a report is news the parent can use now). A report that arrives
|
|
378
|
+
* while the parent is idle starts a turn at once. The steering queue is
|
|
379
|
+
* cleared without delivering when the parent is interrupted (escape) — pi
|
|
380
|
+
* only restores typed text to the editor — so every report is tracked until
|
|
381
|
+
* its message_end confirms delivery; agent_settled re-delivers the rest
|
|
382
|
+
* (see setupParent). The nonce rides in `details`, which never reaches the
|
|
383
|
+
* LLM. */
|
|
384
|
+
function deliverResult(
|
|
385
|
+
pi: ExtensionAPI,
|
|
386
|
+
payload: ResultPayload,
|
|
387
|
+
undelivered: Map<string, ResultPayload>,
|
|
388
|
+
): void {
|
|
389
|
+
const summary = payload.summary.trim() || "(no output)";
|
|
390
|
+
const nonce = randomUUID();
|
|
391
|
+
undelivered.set(nonce, payload);
|
|
392
|
+
pi.sendMessage(
|
|
393
|
+
{
|
|
394
|
+
customType: RESULT_TYPE,
|
|
395
|
+
content: `Subagent ${payload.id} reported:\n\n${summary}`,
|
|
396
|
+
display: true,
|
|
397
|
+
details: { nonce },
|
|
398
|
+
},
|
|
399
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Parse and act on one newline-delimited result line. Malformed JSON or shape
|
|
405
|
+
* is logged and ignored without aborting the handler or dropping later lines.
|
|
406
|
+
* A result is delivered only while its agent is still open (task file present).
|
|
407
|
+
*/
|
|
408
|
+
function processResultLine(line: string, deliver: (payload: ResultPayload) => void): void {
|
|
409
|
+
let parsed: unknown;
|
|
410
|
+
try {
|
|
411
|
+
parsed = JSON.parse(line);
|
|
412
|
+
} catch (err) {
|
|
413
|
+
console.error(`fork: ignoring malformed result line (invalid JSON): ${err}`);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const candidate = parsed as Partial<ResultPayload>;
|
|
418
|
+
if (
|
|
419
|
+
typeof parsed !== "object" ||
|
|
420
|
+
parsed === null ||
|
|
421
|
+
typeof candidate.id !== "string" ||
|
|
422
|
+
candidate.id.length === 0 ||
|
|
423
|
+
typeof candidate.summary !== "string"
|
|
424
|
+
) {
|
|
425
|
+
console.error(`fork: ignoring malformed result payload: ${line}`);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const { id, summary } = candidate as ResultPayload;
|
|
430
|
+
if (!UUID_V4.test(id)) {
|
|
431
|
+
console.error(`fork: ignoring result with invalid subagent id (not a UUID v4): ${id}`);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (!fs.existsSync(taskPathFor(id))) {
|
|
436
|
+
console.error(`fork: ignoring result for unknown or closed id ${id}`);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
deliver({ id, summary });
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ── parent setup ────────────────────────────────────────────────────
|
|
444
|
+
|
|
445
|
+
/** Validate an agent id and that the agent is still open (task file present). */
|
|
446
|
+
function assertOpenAgent(id: string): void {
|
|
447
|
+
if (!UUID_V4.test(id)) {
|
|
448
|
+
throw new Error(`Invalid subagent id (not a UUID v4): ${id}`);
|
|
449
|
+
}
|
|
450
|
+
if (!fs.existsSync(taskPathFor(id))) {
|
|
451
|
+
throw new Error(`Unknown or already closed subagent ${id}`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function setupParent(
|
|
456
|
+
pi: ExtensionAPI,
|
|
457
|
+
ctx: { sessionManager: { getSessionId: () => string } },
|
|
458
|
+
): void {
|
|
459
|
+
fs.mkdirSync(SOCKETS_DIR, { recursive: true });
|
|
460
|
+
fs.mkdirSync(TASKS_DIR, { recursive: true });
|
|
461
|
+
|
|
462
|
+
const session = tmuxSession();
|
|
463
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
464
|
+
const sockPath = socketPathFor(sessionId);
|
|
465
|
+
|
|
466
|
+
// Reports are tracked from sendMessage until their message_end confirms
|
|
467
|
+
// they reached the transcript. pi drains its queues before a session
|
|
468
|
+
// settles, so anything still tracked on agent_settled was dropped by an
|
|
469
|
+
// interrupt. Re-delivering is safe then: the session is idle, so each
|
|
470
|
+
// report triggers a fresh turn exactly as it would have without the
|
|
471
|
+
// interrupt.
|
|
472
|
+
const undelivered = new Map<string, ResultPayload>();
|
|
473
|
+
const deliver = (payload: ResultPayload) => deliverResult(pi, payload, undelivered);
|
|
474
|
+
|
|
475
|
+
// Task files are authoritative, so running children survive extension reloads.
|
|
476
|
+
const server = serveLines(sockPath, "result socket", (line) => processResultLine(line, deliver));
|
|
477
|
+
|
|
478
|
+
pi.on("message_end", (event) => {
|
|
479
|
+
const message = event.message;
|
|
480
|
+
if (message.role !== "custom" || message.customType !== RESULT_TYPE) return;
|
|
481
|
+
const nonce = (message.details as { nonce?: string } | undefined)?.nonce;
|
|
482
|
+
if (nonce !== undefined) undelivered.delete(nonce);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
pi.on("agent_settled", () => {
|
|
486
|
+
const dropped = [...undelivered.values()];
|
|
487
|
+
undelivered.clear();
|
|
488
|
+
for (const payload of dropped) {
|
|
489
|
+
console.error(`fork: re-delivering report of subagent ${payload.id} dropped by an interrupt`);
|
|
490
|
+
deliver(payload);
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
pi.on("session_shutdown", () => {
|
|
495
|
+
teardownServer(server, sockPath, "result socket");
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
pi.registerMessageRenderer(RESULT_TYPE, renderForkMessage);
|
|
499
|
+
|
|
500
|
+
pi.registerTool({
|
|
501
|
+
name: "spawn_agent",
|
|
502
|
+
label: "Spawn Agent",
|
|
503
|
+
description:
|
|
504
|
+
"Delegate a self-contained task to a subagent: a fresh pi session in a " +
|
|
505
|
+
"new tmux window. By default it shares your working tree; provide branch " +
|
|
506
|
+
"to create an isolated Git worktree on a new agent/<name> branch. Use " +
|
|
507
|
+
"for independent work that benefits from isolated context; do simple, " +
|
|
508
|
+
"sequential, or context-dependent work yourself. The subagent sees the " +
|
|
509
|
+
"task plus normal project context (AGENTS.md, files), but not this " +
|
|
510
|
+
"conversation, so include needed context in the task. Avoid concurrent " +
|
|
511
|
+
"editing agents in shared mode. Returns immediately with a spawn " +
|
|
512
|
+
"confirmation. The report arrives later as a message in this " +
|
|
513
|
+
"session — steered into current work or a new turn when idle; do not " +
|
|
514
|
+
"poll or sleep. Continue other work or end your turn. Then " +
|
|
515
|
+
"request revisions with message_agent or close it with close_agent.",
|
|
516
|
+
parameters: Type.Object({
|
|
517
|
+
task: Type.String({
|
|
518
|
+
description:
|
|
519
|
+
"Self-contained instructions passed verbatim as the subagent's initial " +
|
|
520
|
+
"prompt. Include the goal, needed conversation-specific context, " +
|
|
521
|
+
"constraints, relevant paths or symbols, prior findings or errors, and " +
|
|
522
|
+
"the expected output. Do not reference context it cannot see or repeat " +
|
|
523
|
+
"project instructions from AGENTS.md.",
|
|
524
|
+
}),
|
|
525
|
+
branch: Type.Optional(
|
|
526
|
+
Type.String({
|
|
527
|
+
pattern: BRANCH_NAME.source,
|
|
528
|
+
description:
|
|
529
|
+
"Lowercase kebab-case name for a new isolated-worktree branch, " +
|
|
530
|
+
"without the agent/ prefix (for example, issue-4-worktree-isolation). " +
|
|
531
|
+
"Fork creates the exact branch agent/<name> from HEAD; it must not " +
|
|
532
|
+
"already exist. Omit to share the current working tree.",
|
|
533
|
+
}),
|
|
534
|
+
),
|
|
535
|
+
}),
|
|
536
|
+
execute: async (_id, params, signal, _onUpdate, toolCtx) => {
|
|
537
|
+
const { task, branch } = params as { task: string; branch?: string };
|
|
538
|
+
const id = randomUUID();
|
|
539
|
+
const taskPath = taskPathFor(id);
|
|
540
|
+
fs.writeFileSync(taskPath, task, { mode: 0o600 });
|
|
541
|
+
|
|
542
|
+
let worktree: WorktreeInfo | undefined;
|
|
543
|
+
try {
|
|
544
|
+
const config = loadConfig(toolCtx.cwd, toolCtx.isProjectTrusted());
|
|
545
|
+
if (branch !== undefined) {
|
|
546
|
+
worktree = await createWorktree(pi, toolCtx.cwd, id, branch, signal);
|
|
547
|
+
}
|
|
548
|
+
spawn(
|
|
549
|
+
session,
|
|
550
|
+
sessionId,
|
|
551
|
+
id,
|
|
552
|
+
taskPath,
|
|
553
|
+
worktree?.path ?? toolCtx.cwd,
|
|
554
|
+
config,
|
|
555
|
+
worktree?.branch,
|
|
556
|
+
);
|
|
557
|
+
} catch (err) {
|
|
558
|
+
if (worktree) await rollbackWorktree(pi, worktree);
|
|
559
|
+
// Roll back exactly this id's task file so pi reports the spawn error.
|
|
560
|
+
safeUnlink(taskPath, `task file for ${id}`);
|
|
561
|
+
throw err;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const location = worktree ? `${worktree.path} on branch ${worktree.branch}` : toolCtx.cwd;
|
|
565
|
+
return {
|
|
566
|
+
content: [
|
|
567
|
+
{
|
|
568
|
+
type: "text" as const,
|
|
569
|
+
text:
|
|
570
|
+
`Spawned subagent ${id} in ${location}. ` +
|
|
571
|
+
"Do not poll or sleep; continue other work or end your turn. Its " +
|
|
572
|
+
"report will arrive as a message in this session, after which it " +
|
|
573
|
+
"stays alive awaiting your verdict.",
|
|
574
|
+
},
|
|
575
|
+
],
|
|
576
|
+
details: {},
|
|
577
|
+
};
|
|
578
|
+
},
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
pi.registerTool({
|
|
582
|
+
name: "message_agent",
|
|
583
|
+
label: "Message Agent",
|
|
584
|
+
description:
|
|
585
|
+
"Send a revision request or follow-up instruction to a live subagent. " +
|
|
586
|
+
"It receives your text as a new turn, acts on it, and reports again. " +
|
|
587
|
+
"Use after reviewing its report when changes are needed; use " +
|
|
588
|
+
"close_agent when the work is accepted. The next report arrives as a " +
|
|
589
|
+
"message in this session; do not poll or sleep.",
|
|
590
|
+
parameters: Type.Object({
|
|
591
|
+
id: Type.String({
|
|
592
|
+
description: "The subagent's UUID, from its spawn result or report.",
|
|
593
|
+
}),
|
|
594
|
+
text: Type.String({
|
|
595
|
+
description:
|
|
596
|
+
"The feedback or instruction for the subagent. It sees this text " +
|
|
597
|
+
"plus its own session context, not your conversation — reference " +
|
|
598
|
+
"its work, not things you only said locally.",
|
|
599
|
+
}),
|
|
600
|
+
}),
|
|
601
|
+
execute: async (_toolId, params) => {
|
|
602
|
+
const { id, text } = params as { id: string; text: string };
|
|
603
|
+
assertOpenAgent(id);
|
|
604
|
+
try {
|
|
605
|
+
await sendToAgent(id, { type: "message", text });
|
|
606
|
+
} catch (err) {
|
|
607
|
+
throw new Error(
|
|
608
|
+
`Could not reach subagent ${id}: ${err}. It may still be starting ` +
|
|
609
|
+
"(retry shortly) or may have exited; if it stays unreachable, " +
|
|
610
|
+
"clean up its tmux window and task file manually.",
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
return {
|
|
614
|
+
content: [
|
|
615
|
+
{
|
|
616
|
+
type: "text" as const,
|
|
617
|
+
text:
|
|
618
|
+
`Message delivered to subagent ${id}. Do not poll or sleep; its ` +
|
|
619
|
+
"report will arrive as a message in this session.",
|
|
620
|
+
},
|
|
621
|
+
],
|
|
622
|
+
details: {},
|
|
623
|
+
};
|
|
624
|
+
},
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
pi.registerTool({
|
|
628
|
+
name: "close_agent",
|
|
629
|
+
label: "Close Agent",
|
|
630
|
+
description:
|
|
631
|
+
"Shut down a live subagent's session without spending a model turn. " +
|
|
632
|
+
"Use to accept its report or to stop one you no longer need. For an " +
|
|
633
|
+
"isolated agent, this removes its worktree but retains its agent/<name> " +
|
|
634
|
+
"branch for normal review, merge, or deletion. Subagents stay alive " +
|
|
635
|
+
"until closed; close every subagent when you are done with it.",
|
|
636
|
+
parameters: Type.Object({
|
|
637
|
+
id: Type.String({
|
|
638
|
+
description: "The subagent's UUID, from its spawn result or report.",
|
|
639
|
+
}),
|
|
640
|
+
}),
|
|
641
|
+
execute: async (_toolId, params) => {
|
|
642
|
+
const { id } = params as { id: string };
|
|
643
|
+
assertOpenAgent(id);
|
|
644
|
+
const worktreePath = worktreePathFor(id);
|
|
645
|
+
const hasWorktree = fs.existsSync(path.join(worktreePath, ".git"));
|
|
646
|
+
|
|
647
|
+
// Kill the window first: SIGHUP reaches the whole process group, so
|
|
648
|
+
// in-flight tool subprocesses die with the session, and pi's signal
|
|
649
|
+
// path runs session_shutdown teardown without waiting for a busy
|
|
650
|
+
// session to settle. A failure means the window is already gone;
|
|
651
|
+
// cleanup proceeds either way.
|
|
652
|
+
try {
|
|
653
|
+
tmuxSync(["kill-window", "-t", `${session}:=${id}`]);
|
|
654
|
+
} catch {
|
|
655
|
+
// Window already closed, renamed, or session gone — nothing to kill.
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
let cleanupWarning = "";
|
|
659
|
+
if (hasWorktree) {
|
|
660
|
+
try {
|
|
661
|
+
await git(pi, ["worktree", "remove", "--force", worktreePath], worktreePath);
|
|
662
|
+
} catch (err) {
|
|
663
|
+
cleanupWarning = ` Worktree cleanup failed: ${err}. Remove ${worktreePath} manually.`;
|
|
664
|
+
console.error(`fork:${cleanupWarning}`);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
safeUnlink(taskPathFor(id), `task file for ${id}`);
|
|
669
|
+
const retained = hasWorktree ? " Its branch was retained." : "";
|
|
670
|
+
return {
|
|
671
|
+
content: [
|
|
672
|
+
{
|
|
673
|
+
type: "text" as const,
|
|
674
|
+
text: `Closed subagent ${id}; killed its tmux window.${retained}${cleanupWarning}`,
|
|
675
|
+
},
|
|
676
|
+
],
|
|
677
|
+
details: {},
|
|
678
|
+
};
|
|
679
|
+
},
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// ── child setup ─────────────────────────────────────────────────────
|
|
684
|
+
|
|
685
|
+
function subagentSystemPrompt(branch?: string): string {
|
|
686
|
+
const workspace = branch
|
|
687
|
+
? `You work in an isolated Git worktree on branch ${branch}. Commit all ` +
|
|
688
|
+
"work before reporting so the parent can review and merge the branch; " +
|
|
689
|
+
"repository commit hooks provide its validation."
|
|
690
|
+
: "You share the parent's working tree, so your edits are immediately " +
|
|
691
|
+
"visible to the parent.";
|
|
692
|
+
|
|
693
|
+
return (
|
|
694
|
+
"# Role\n\n" +
|
|
695
|
+
"You are a subagent executing one task delegated by a parent pi session. " +
|
|
696
|
+
"You see the task and pi's normal project context, but not the parent's " +
|
|
697
|
+
"conversation. Focus only on the delegated task. " +
|
|
698
|
+
workspace +
|
|
699
|
+
"\n\n" +
|
|
700
|
+
"# Reporting to the parent\n\n" +
|
|
701
|
+
"report_result is the only channel that reaches the parent. It cannot " +
|
|
702
|
+
"see your text replies, your tool output, or anything else in this " +
|
|
703
|
+
"session — so a turn that ends in plain text leaves the parent waiting " +
|
|
704
|
+
"forever, with no way to know you responded.\n\n" +
|
|
705
|
+
"- Call report_result when the task is done, when you have acted on a " +
|
|
706
|
+
"revision request from the parent, and when you are blocked and need " +
|
|
707
|
+
"the parent's input.\n" +
|
|
708
|
+
"- Every message from the parent calls for a report_result response, " +
|
|
709
|
+
"even if the report is only a brief status update.\n" +
|
|
710
|
+
"- After reporting, end your turn and wait. The session stays alive: " +
|
|
711
|
+
"the parent reviews each report and either sends you a revision request " +
|
|
712
|
+
"as an ordinary message or closes the session."
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Parse and act on one newline-delimited parent message. Malformed JSON or
|
|
718
|
+
* shape is logged and ignored without aborting the handler or dropping later
|
|
719
|
+
* lines.
|
|
720
|
+
*/
|
|
721
|
+
function processAgentLine(pi: ExtensionAPI, line: string): void {
|
|
722
|
+
let parsed: unknown;
|
|
723
|
+
try {
|
|
724
|
+
parsed = JSON.parse(line);
|
|
725
|
+
} catch (err) {
|
|
726
|
+
console.error(`fork: ignoring malformed agent line (invalid JSON): ${err}`);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
731
|
+
console.error(`fork: ignoring malformed agent message: ${line}`);
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
const candidate = parsed as { type?: unknown; text?: unknown };
|
|
735
|
+
|
|
736
|
+
if (candidate.type === "message" && typeof candidate.text === "string") {
|
|
737
|
+
// steer: a revision request is news the child can fold into its current
|
|
738
|
+
// run; it is delivered after the child's pending tool calls, before its
|
|
739
|
+
// next LLM call.
|
|
740
|
+
// The tags and the trailing instruction matter: without an explicit cue
|
|
741
|
+
// to report back, a child may answer in plain text and end its turn,
|
|
742
|
+
// which never reaches the parent.
|
|
743
|
+
pi.sendMessage(
|
|
744
|
+
{
|
|
745
|
+
customType: MESSAGE_TYPE,
|
|
746
|
+
content:
|
|
747
|
+
`<message-from-parent>\n${candidate.text}\n</message-from-parent>\n\n` +
|
|
748
|
+
"Act on this message, then report the outcome with report_result. " +
|
|
749
|
+
"The parent receives only report_result output — it cannot see " +
|
|
750
|
+
"your text replies.",
|
|
751
|
+
display: true,
|
|
752
|
+
},
|
|
753
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
754
|
+
);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
console.error(`fork: ignoring malformed agent message: ${line}`);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function setupChild(
|
|
762
|
+
pi: ExtensionAPI,
|
|
763
|
+
socketPath: string,
|
|
764
|
+
subagentId: string,
|
|
765
|
+
branch?: string,
|
|
766
|
+
): void {
|
|
767
|
+
pi.on("before_agent_start", (event) => {
|
|
768
|
+
return {
|
|
769
|
+
systemPrompt: `${event.systemPrompt}\n\n${subagentSystemPrompt(branch)}`,
|
|
770
|
+
};
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
// ── result reporting (child → parent) ──────────────────────────────
|
|
774
|
+
//
|
|
775
|
+
// Delivery failures are reported to the model, which retries with the same
|
|
776
|
+
// result. Failures are unambiguous in practice: they mean the parent is
|
|
777
|
+
// gone or the write failed, so a retry cannot deliver a report twice.
|
|
778
|
+
let reportState: "open" | "sending" = "open";
|
|
779
|
+
|
|
780
|
+
pi.registerTool({
|
|
781
|
+
name: "report_result",
|
|
782
|
+
label: "Report Result",
|
|
783
|
+
description:
|
|
784
|
+
"Send a result report to the parent — the only channel that reaches " +
|
|
785
|
+
"it. The parent cannot see your text replies. Use when the task is " +
|
|
786
|
+
"done, when reporting a requested revision, or when you are " +
|
|
787
|
+
"blocked and need the parent's input. Report outcomes, not " +
|
|
788
|
+
"progress updates.",
|
|
789
|
+
parameters: Type.Object({
|
|
790
|
+
result: Type.String({
|
|
791
|
+
description:
|
|
792
|
+
"The complete result for the parent. Include the outcome and, when " +
|
|
793
|
+
"relevant, edits, verification, and blockers.",
|
|
794
|
+
}),
|
|
795
|
+
}),
|
|
796
|
+
execute: async (_id, params, _signal, _onUpdate, toolCtx) => {
|
|
797
|
+
if (reportState !== "open") {
|
|
798
|
+
throw new Error("report_result is already delivering a report");
|
|
799
|
+
}
|
|
800
|
+
reportState = "sending";
|
|
801
|
+
|
|
802
|
+
const { result } = params as { result: string };
|
|
803
|
+
const payload = `${JSON.stringify({ id: subagentId, summary: result })}\n`;
|
|
804
|
+
|
|
805
|
+
try {
|
|
806
|
+
await new Promise<void>((resolve, reject) => {
|
|
807
|
+
let settled = false;
|
|
808
|
+
const socket = net.connect(socketPath);
|
|
809
|
+
const finish = (err?: Error) => {
|
|
810
|
+
if (settled) return;
|
|
811
|
+
settled = true;
|
|
812
|
+
if (err) {
|
|
813
|
+
socket.destroy();
|
|
814
|
+
reject(err);
|
|
815
|
+
} else {
|
|
816
|
+
resolve();
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
socket.setTimeout(5000);
|
|
821
|
+
socket.once("error", (err) => finish(err));
|
|
822
|
+
socket.once("timeout", () => finish(new Error("timed out after 5 seconds")));
|
|
823
|
+
socket.end(payload, () => finish());
|
|
824
|
+
});
|
|
825
|
+
} catch (err) {
|
|
826
|
+
reportState = "open";
|
|
827
|
+
const message = `fork: could not deliver result to parent: ${err}`;
|
|
828
|
+
console.error(message);
|
|
829
|
+
toolCtx.ui.notify(
|
|
830
|
+
`${message}. The report was not sent; retry report_result with the same result.`,
|
|
831
|
+
"error",
|
|
832
|
+
);
|
|
833
|
+
throw new Error(
|
|
834
|
+
"Result delivery failed; the report was not sent. Retry " +
|
|
835
|
+
"report_result with the same result.",
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
reportState = "open";
|
|
840
|
+
return {
|
|
841
|
+
content: [
|
|
842
|
+
{
|
|
843
|
+
type: "text" as const,
|
|
844
|
+
text: "Report delivered. End your turn and await the parent's verdict.",
|
|
845
|
+
},
|
|
846
|
+
],
|
|
847
|
+
details: {},
|
|
848
|
+
};
|
|
849
|
+
},
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
// ── verdict channel (parent → child) ─────────────────────────────────
|
|
853
|
+
|
|
854
|
+
fs.mkdirSync(AGENTS_DIR, { recursive: true });
|
|
855
|
+
const agentSockPath = agentSocketPathFor(subagentId);
|
|
856
|
+
const server = serveLines(agentSockPath, "agent socket", (line) => processAgentLine(pi, line));
|
|
857
|
+
|
|
858
|
+
pi.on("session_shutdown", () => {
|
|
859
|
+
teardownServer(server, agentSockPath, "agent socket");
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
pi.registerMessageRenderer(MESSAGE_TYPE, renderForkMessage);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// ── extension entry ────────────────────────────────────────────────
|
|
866
|
+
|
|
867
|
+
export default function (pi: ExtensionAPI) {
|
|
868
|
+
if (!inTmux()) return;
|
|
869
|
+
|
|
870
|
+
pi.registerFlag("subagent-socket", {
|
|
871
|
+
description: "Parent socket path (internal)",
|
|
872
|
+
type: "string",
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
pi.registerFlag("subagent-id", {
|
|
876
|
+
description: "Subagent ID for result routing (internal)",
|
|
877
|
+
type: "string",
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
pi.registerFlag("subagent-branch", {
|
|
881
|
+
description: "Isolated worktree branch (internal)",
|
|
882
|
+
type: "string",
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
pi.on("session_start", (_event, ctx) => {
|
|
886
|
+
const socketPath = pi.getFlag("subagent-socket") as string | undefined;
|
|
887
|
+
|
|
888
|
+
if (socketPath === undefined) {
|
|
889
|
+
// Parent mode
|
|
890
|
+
setupParent(pi, ctx);
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Child mode
|
|
895
|
+
const subagentId = pi.getFlag("subagent-id") as string;
|
|
896
|
+
const branch = pi.getFlag("subagent-branch") as string | undefined;
|
|
897
|
+
setupChild(pi, socketPath, subagentId, branch);
|
|
898
|
+
});
|
|
899
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@resolveworks/fork",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Subagents for Pi — spawn pi sessions in tmux windows with task delegation and revision loops",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.18.0"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"pi-package"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
"index.ts"
|
|
14
|
+
],
|
|
15
|
+
"pi": {
|
|
16
|
+
"extensions": [
|
|
17
|
+
"./index.ts"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/resolveworks/fork.git"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/resolveworks/fork#readme",
|
|
25
|
+
"bugs": "https://github.com/resolveworks/fork/issues",
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@earendil-works/pi-coding-agent": ">=0.80.4",
|
|
28
|
+
"@earendil-works/pi-tui": "*",
|
|
29
|
+
"typebox": "*"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@biomejs/biome": "^2.5.8",
|
|
33
|
+
"@earendil-works/pi-coding-agent": "^0.84.2",
|
|
34
|
+
"@earendil-works/pi-tui": "^0.84.2",
|
|
35
|
+
"@types/node": "^26.4.0",
|
|
36
|
+
"husky": "^9.1.7",
|
|
37
|
+
"lint-staged": "^17.3.0",
|
|
38
|
+
"typescript": "^6.0.3"
|
|
39
|
+
},
|
|
40
|
+
"lint-staged": {
|
|
41
|
+
"*.ts": "biome check --write"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"typecheck": "tsc --noEmit",
|
|
45
|
+
"format": "biome check --write .",
|
|
46
|
+
"format:check": "biome check ."
|
|
47
|
+
}
|
|
6
48
|
}
|