@resolveworks/fork 0.0.0-stage → 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.
@@ -0,0 +1,33 @@
1
+ name: release
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions:
8
+ contents: read
9
+ id-token: write # OIDC token for npm trusted publishing
10
+
11
+ jobs:
12
+ publish:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v7
16
+
17
+ - uses: pnpm/action-setup@v6 # version comes from packageManager in package.json
18
+
19
+ - uses: actions/setup-node@v7
20
+ with:
21
+ node-version: 24
22
+ registry-url: https://registry.npmjs.org
23
+
24
+ - run: pnpm install --frozen-lockfile
25
+
26
+ - run: pnpm typecheck
27
+ - run: pnpm format:check
28
+
29
+ - name: Check version matches release tag
30
+ run: test "${GITHUB_REF_NAME#v}" = "$(node -p "require('./package.json').version")"
31
+
32
+ # Authenticates via trusted publishing (OIDC); no npm token is used
33
+ - run: pnpm publish --access public --no-git-checks
@@ -0,0 +1,2 @@
1
+ pnpm exec lint-staged
2
+ pnpm run typecheck
package/AGENTS.md ADDED
@@ -0,0 +1,30 @@
1
+ # AGENTS.md
2
+
3
+ **fork** is a [pi](https://github.com/earendil-works/pi) extension that spawns subagents as pi sessions in separate tmux windows.
4
+
5
+ The extension exposes `spawn_agent`, `message_agent`, and `close_agent` tools to parent sessions. Calling `spawn_agent` opens a new tmux window running pi with the given task and returns immediately. It shares the parent's working tree by default; an optional `branch` creates an isolated Git worktree on a new `agent/<name>` branch. Child sessions get a `report_result` tool; its report payload is sent back over a Unix socket and delivered to the parent as a steering notification: a fresh turn immediately if the parent is idle, or injected into the parent's current run — after its pending tool calls, before the next LLM call — if it is mid-work. An interrupt (escape) clears that queue without delivering queued custom messages — so fork tracks every report until its `message_end` confirms delivery and re-delivers the rest on `agent_settled`; an interrupt can no longer lose a report. Children stay alive after reporting: the parent reviews, requests revisions with `message_agent`, and closes them with `close_agent`. Closing an isolated child removes its worktree and retains its branch.
6
+
7
+ Everything lives in `index.ts` — start at the default export.
8
+
9
+ ## Development
10
+
11
+ Requires Node.js 22.18 or newer and pnpm 11.3.0.
12
+
13
+ ```sh
14
+ pnpm install --frozen-lockfile
15
+ pnpm typecheck
16
+ pnpm format:check
17
+ ```
18
+
19
+ Use `pnpm format` to apply formatting. Run the typecheck and formatting check before finishing a change; the pre-commit hook enforces both.
20
+
21
+ ## Requirements
22
+
23
+ - **tmux.** The extension silently no-ops outside tmux.
24
+ - **Runtime state** lives under `~/.pi/agent/extensions/fork/`:
25
+ - `sockets/` — per-parent Unix sockets, mode `0o600`
26
+ - `agents/` — per-child Unix sockets (verdict channel), mode `0o600`
27
+ - `tasks/` — per-id task files handed to children at spawn via pi's `@<path>` argument, mode `0o600`; a present task file marks an open agent and persists until `close_agent`
28
+ - **Worktrees** live under `~/.pi/worktrees/<id>`; paths use the child UUID while branches use the requested `agent/<name>`
29
+ - **Repository policy:** fork only creates and removes worktrees. Repository-owned `post-checkout` and commit hooks handle environment setup and validation; fork has no setup runner or checks gate.
30
+ - **Orphan policy:** none. If a parent exits with children alive, their tmux windows and worktrees linger; kill the windows, run `git worktree remove` for isolated children, and remove their task files by hand. Agent branches are always retained for ordinary merge or deletion.
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
- # Temporary Holding Version
1
+ # fork
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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/biome.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/2.5.8/schema.json",
3
+ "vcs": {
4
+ "enabled": true,
5
+ "clientKind": "git",
6
+ "useIgnoreFile": true
7
+ },
8
+ "files": {
9
+ "ignoreUnknown": false
10
+ },
11
+ "formatter": {
12
+ "enabled": true,
13
+ "indentStyle": "space",
14
+ "indentWidth": 2,
15
+ "lineWidth": 100
16
+ },
17
+ "linter": {
18
+ "enabled": true,
19
+ "rules": {
20
+ "preset": "recommended"
21
+ }
22
+ },
23
+ "javascript": {
24
+ "formatter": {
25
+ "quoteStyle": "double"
26
+ }
27
+ },
28
+ "assist": {
29
+ "enabled": true,
30
+ "actions": {
31
+ "source": {
32
+ "organizeImports": "on"
33
+ }
34
+ }
35
+ }
36
+ }
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,45 @@
1
1
  {
2
2
  "name": "@resolveworks/fork",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
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
+ "pi": {
13
+ "extensions": [
14
+ "./index.ts"
15
+ ]
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/resolveworks/fork.git"
20
+ },
21
+ "homepage": "https://github.com/resolveworks/fork#readme",
22
+ "bugs": "https://github.com/resolveworks/fork/issues",
23
+ "peerDependencies": {
24
+ "@earendil-works/pi-coding-agent": ">=0.80.4",
25
+ "@earendil-works/pi-tui": "*",
26
+ "typebox": "*"
27
+ },
28
+ "devDependencies": {
29
+ "@biomejs/biome": "^2.5.8",
30
+ "@earendil-works/pi-coding-agent": "^0.84.2",
31
+ "@earendil-works/pi-tui": "^0.84.2",
32
+ "@types/node": "^26.4.0",
33
+ "husky": "^9.1.7",
34
+ "lint-staged": "^17.3.0",
35
+ "typescript": "^6.0.3"
36
+ },
37
+ "lint-staged": {
38
+ "*.ts": "biome check --write"
39
+ },
40
+ "scripts": {
41
+ "typecheck": "tsc --noEmit",
42
+ "format": "biome check --write .",
43
+ "format:check": "biome check ."
44
+ }
6
45
  }
@@ -0,0 +1,3 @@
1
+ allowBuilds:
2
+ "@google/genai": true
3
+ protobufjs: true
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ES2022",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "noEmit": true
10
+ },
11
+ "include": ["index.ts"]
12
+ }