@rol1n/multi-harness 0.1.0 → 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/README.md CHANGED
@@ -14,10 +14,10 @@ Also included in `@rol1n/pi-kit`. Enable one copy of the extension.
14
14
 
15
15
  ## Use
16
16
 
17
- Load `extensions/multi-harness` through the aggregate package, or run from this repository:
17
+ Load `multi-harness` through the aggregate package, or run from this repository:
18
18
 
19
19
  ```sh
20
- pi --no-extensions -e ./extensions/multi-harness/index.ts
20
+ pi --no-extensions -e ./multi-harness/index.ts
21
21
  ```
22
22
 
23
23
  Example tool calls:
@@ -60,4 +60,4 @@ Session entries record only worker artifact locations. On session restore/reload
60
60
  - Windows requires PowerShell 7 (`pwsh`) for external CLI invocation. Encoded PowerShell commands preserve Unicode, multiline tasks, JSON, and quoted paths. CLI hooks and `harness-done.cmd`/`.ps1` wrappers use the same native Windows Node installation. A mux must be available for visible TUIs; otherwise process fallback applies.
61
61
  - Existing worktrees, reports, and sessions created by the removed `worktree-studio` extension are left untouched. Its old `the_road_not_taken` tool and two-route manifests are not imported into this runtime. Synchronize platform resources/reload before using the new tool; old running workers should be finished before upgrading.
62
62
 
63
- Validation scripts are kept outside the repository. Check extension loading from this repository with `pi --no-extensions -e ./extensions/multi-harness/index.ts --list-models '<filter>'`.
63
+ Validation scripts are kept outside the repository. Check extension loading from this repository with `pi --no-extensions -e ./multi-harness/index.ts --list-models '<filter>'`.
@@ -6,7 +6,7 @@ import { delimiter, dirname, join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
8
  import { Type } from "typebox";
9
- import { createSurface, closeSurface, getMuxBackend, renderSubagentCommand, sendLongCommand, sendCommand, readScreenAsync, shellEscape, psEscape, zellijPaneId, type WorkerBackend } from "../subagent/mux.ts";
9
+ import { createSurface, closeSurface, getMuxBackend, renderSubagentCommand, sendLongCommand, sendCommand, readScreenAsync, shellEscape, psEscape, zellijPaneId, type WorkerBackend } from "@rol1n/subagent/mux";
10
10
  import { HARNESSES, commandSpec } from "./adapters.mjs";
11
11
  import { readJson, writeJson } from "./ipc.mjs";
12
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rol1n/multi-harness",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "multi-harness extension from pi-kit",
6
6
  "keywords": [
@@ -8,8 +8,8 @@
8
8
  ],
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "git+ssh://git@github.com/RolinShmily/pi-kit.git",
12
- "directory": "extensions/multi-harness"
11
+ "url": "git+https://github.com/RolinShmily/pi-kit.git",
12
+ "directory": "multi-harness"
13
13
  },
14
14
  "homepage": "https://github.com/RolinShmily/pi-kit#readme",
15
15
  "bugs": {
@@ -21,11 +21,9 @@
21
21
  "publishConfig": {
22
22
  "access": "public"
23
23
  },
24
- "files": [
25
- "extensions/multi-harness",
26
- "extensions/subagent/mux.ts",
27
- "extensions/multi-harness/README.md"
28
- ],
24
+ "dependencies": {
25
+ "@rol1n/subagent": "^0.1.1"
26
+ },
29
27
  "peerDependencies": {
30
28
  "@earendil-works/pi-ai": "*",
31
29
  "@earendil-works/pi-agent-core": "*",
@@ -35,7 +33,7 @@
35
33
  },
36
34
  "pi": {
37
35
  "extensions": [
38
- "./extensions/multi-harness/index.ts"
36
+ "./index.ts"
39
37
  ],
40
38
  "skills": [],
41
39
  "prompts": [],
@@ -1,63 +0,0 @@
1
- # Multi Harness
2
-
3
- A small delegation runtime with one tool, `multi_harness`. `pi`, `codex`, `claude`, and `opencode` are equal CLI peers. The original `subagent` extension remains a separate, lean Pi-only tool.
4
-
5
- The runtime accepts **one named worker and an existing cwd**. Those decisions belong to the caller. An independently installed `worktree-studio` skill can coordinate isolated implementations and their acceptance review.
6
-
7
- ## Install
8
-
9
- ```sh
10
- pi install npm:@rol1n/multi-harness
11
- ```
12
-
13
- Also included in `@rol1n/pi-kit`. Enable one copy of the extension.
14
-
15
- ## Use
16
-
17
- Load `extensions/multi-harness` through the aggregate package, or run from this repository:
18
-
19
- ```sh
20
- pi --no-extensions -e ./extensions/multi-harness/index.ts
21
- ```
22
-
23
- Example tool calls:
24
-
25
- ```json
26
- {"action":"spawn","name":"reviewer","harness":"claude","cwd":"/existing/project","task":"Review error handling without changing files. Report concrete findings with file paths."}
27
- {"action":"spawn","name":"implementer","harness":"pi","cwd":"/existing/other-directory","task":"Implement the approved fix and run the specified checks.","model":"provider/model"}
28
- {"action":"spawn","name":"opencode-reviewer","harness":"opencode","cwd":"/existing/project","task":"Review the changes and report evidence without editing files."}
29
- {"action":"send","target":"reviewer","task":"Review the revised implementation in the same session."}
30
- {"action":"status"}
31
- {"action":"stop","target":"implementer"}
32
- {"action":"close","target":"reviewer"}
33
- ```
34
-
35
- - `spawn` is the default action; `name`, `harness`, and `task` are required. `cwd` defaults to the parent directory and must already exist. Names are reserved for the session, including after closing.
36
- - `model` is optional and passed to the selected CLI. Pi defaults to the parent model; external CLIs use their installed defaults. Only the selected executable is checked on demand.
37
- - `spawn` and `send` return after dispatch. **Turn completion is pushed to the real parent Pi session via `pi.sendMessage`**, not by a background wrapper writing a log. Do not poll terminals or run waiting shell loops.
38
- - With tmux/Zellij, each worker runs its **native interactive TUI**. Turn settlement leaves the application open and idle. `send` continues that same application; `close` exits idle TUIs. `stop` terminates active work and its runtime, preserving files.
39
- - Without a supported mux, workers use silent child processes. `send` starts another invocation that resumes the same persisted CLI session. Set `PI_MULTI_HARNESS_BACKEND=process` to force this fallback. This is not a live TUI in headless mode.
40
- - Pi follow-ups use a file inbox and native `pi.sendUserMessage`. OpenCode uses a local server plugin, its native SDK inbox, and the same visible TUI session (no key injection). Codex uses its reported thread ID with `codex queue`; when no ID is available, mux input uses bracketed paste. Claude uses idle-only bracketed paste. Avoid simultaneous human typing and coordinator dispatch; startup trust/permission dialogs may require manual attention. Zellij input transport still requires validation with your installed Zellij/terminal build.
41
-
42
- ## Notifications and state
43
-
44
- Artifacts live in `~/.pi/agent/multi-harness/<worker-id>/`: task prompts, CLI session data/IDs, console/event logs in process mode, completion records, and short TUI screen captures. Working files remain in the caller's `cwd`. No automatic Git report is generated.
45
-
46
- `harness-done "summary"` is injected into the worker PATH; `studio-done` is a compatibility alias. It records a proposed summary, **not an early completion event**. Native Codex/Claude hooks, OpenCode's root-session idle events, Pi's `agent_settled`, or the headless process exit publish the authoritative completion. This avoids racing a still-running CLI or notifying twice. Every dispatch has a task ID; stale and duplicate completions are ignored.
47
-
48
- The parent subscribes to directory `fs.watch` events, with no periodic terminal capture or completion polling. Screen capture happens once per completed turn. Watcher failures are surfaced rather than silently claiming delivery. A worker's final text is a claim: independently review its files and test results.
49
-
50
- Default turn timeout: 15 minutes (`maxMinutes`: 1–120). Headless workers are stopped on timeout. A native TUI stays open and cannot receive a new dispatch until it settles or is stopped. Timeouts do not cap tokens or spending; missing usage/pricing is not zero cost.
51
-
52
- Session entries record only worker artifact locations. On session restore/reload, the extension reattaches notification watchers for saved workers. A `controller.lock` prevents another session from controlling the same worker concurrently. After an unclean crash, verify the recorded owner PID is no longer running before manually removing that lock. Normal shutdown releases watchers/locks and stops active tasks; idle TUIs remain open and can be recovered by resuming the parent session.
53
-
54
- ## Permissions and platform boundaries
55
-
56
- - This tool dispatches directly; user confirmation for expensive or risky workflows belongs to the caller/skill. It is not a sandbox.
57
- - Per the configured workflow, Codex runs with `--sandbox danger-full-access -a never`, and Claude with `--permission-mode bypassPermissions`. Workers have the invoking user's filesystem/network privileges. Headless Claude has a $0.50 CLI API budget; interactive mode does not impose that limit. Use only in trusted environments.
58
- - OpenCode uses `--auto`: automatically approve requests not explicitly denied by its configuration. Existing deny rules remain effective. Native mode injects the dependency-free local `opencode-plugin.mjs` through a merged `OPENCODE_CONFIG_CONTENT`; it never edits project/global config files or installs packages. Existing content must be valid JSON. Headless mode uses `opencode run --format json` and resumes by the exact reported `--session` ID. The adapter targets the OpenCode 1.x CLI/plugin API (source-checked against v1.19.3); 2.x API compatibility is not assumed. Install and authenticate the CLI yourself. DeepSeek Harness (`dsh`) is not integrated.
59
- - Pi's project-resource trust flags apply only to loading project resources, not filesystem access. A different cwd does not inherit the parent's project trust automatically.
60
- - Windows requires PowerShell 7 (`pwsh`) for external CLI invocation. Encoded PowerShell commands preserve Unicode, multiline tasks, JSON, and quoted paths. CLI hooks and `harness-done.cmd`/`.ps1` wrappers use the same native Windows Node installation. A mux must be available for visible TUIs; otherwise process fallback applies.
61
- - Existing worktrees, reports, and sessions created by the removed `worktree-studio` extension are left untouched. Its old `the_road_not_taken` tool and two-route manifests are not imported into this runtime. Synchronize platform resources/reload before using the new tool; old running workers should be finished before upgrading.
62
-
63
- Validation scripts are kept outside the repository. Check extension loading from this repository with `pi --no-extensions -e ./extensions/multi-harness/index.ts --list-models '<filter>'`.
@@ -1,621 +0,0 @@
1
- /**
2
- * subagent/mux.ts — Cross-platform Terminal Multiplexer Layer (tmux & Zellij)
3
- *
4
- * Provides:
5
- * - Surface creation (split pane, tab, or grid layout with scaling for multiple workers)
6
- * - Cross-platform command rendering (POSIX bash & Windows native PowerShell)
7
- * - Long command dispatch via wrapper scripts (.sh / .ps1 with UTF-8 BOM)
8
- * - Screen capture, surface termination, and exit polling
9
- */
10
-
11
- import { type ChildProcess, execFile, execFileSync, spawn, spawnSync } from "node:child_process";
12
- import { promisify } from "node:util";
13
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, watch, writeFileSync, type FSWatcher } from "node:fs";
14
- import { tmpdir } from "node:os";
15
- import { basename, dirname, join } from "node:path";
16
-
17
- const execFileAsync = promisify(execFile);
18
-
19
- export type MuxBackend = "tmux" | "zellij";
20
- export type WorkerBackend = "tmux" | "zellij" | "process";
21
-
22
- // ── Availability & Detection ──
23
-
24
- const commandAvailability = new Map<string, boolean>();
25
-
26
- function hasCommand(command: string): boolean {
27
- if (commandAvailability.has(command)) {
28
- return commandAvailability.get(command)!;
29
- }
30
-
31
- let available = false;
32
- try {
33
- if (process.platform === "win32") {
34
- execFileSync("where.exe", [command], { stdio: "ignore" });
35
- } else {
36
- execFileSync("sh", ["-c", `command -v ${command}`], { stdio: "ignore" });
37
- }
38
- available = true;
39
- } catch {
40
- available = false;
41
- }
42
-
43
- commandAvailability.set(command, available);
44
- return available;
45
- }
46
-
47
- export function isTmuxAvailable(): boolean {
48
- return !!process.env.TMUX && hasCommand("tmux");
49
- }
50
-
51
- export function isZellijAvailable(): boolean {
52
- return !!(process.env.ZELLIJ || process.env.ZELLIJ_SESSION_NAME) && hasCommand("zellij");
53
- }
54
-
55
- export function getMuxBackend(): MuxBackend | null {
56
- const pref = (process.env.PI_SUBAGENT_MUX ?? "").trim().toLowerCase();
57
- if (pref === "tmux") return isTmuxAvailable() ? "tmux" : null;
58
- if (pref === "zellij") return isZellijAvailable() ? "zellij" : null;
59
- if (isTmuxAvailable()) return "tmux";
60
- if (isZellijAvailable()) return "zellij";
61
- return null;
62
- }
63
-
64
- export function getWorkerBackend(): WorkerBackend {
65
- const pref = (process.env.PI_SUBAGENT_BACKEND ?? process.env.PI_SUBAGENT_MUX ?? "").trim().toLowerCase();
66
- if (pref === "process" || pref === "headless" || pref === "daemon") return "process";
67
- if (pref === "tmux" && isTmuxAvailable()) return "tmux";
68
- if (pref === "zellij" && isZellijAvailable()) return "zellij";
69
- if (isTmuxAvailable()) return "tmux";
70
- if (isZellijAvailable()) return "zellij";
71
- return "process";
72
- }
73
-
74
- export function getPowerShellCommand(): string {
75
- if (hasCommand("pwsh")) return "pwsh";
76
- return "powershell";
77
- }
78
-
79
- export function isProcessSurface(surface: string): boolean {
80
- return surface.startsWith("process:");
81
- }
82
-
83
- export function isMuxAvailable(): boolean {
84
- return getMuxBackend() !== null;
85
- }
86
-
87
- export function muxSetupHint(): string {
88
- if (process.platform === "win32") {
89
- return "Start pi inside Zellij (`zellij --session pi`, then run `pi`).";
90
- }
91
- return "Start pi inside tmux (`tmux new -A -s pi 'pi'`) or Zellij (`zellij --session pi`, then run `pi`).";
92
- }
93
-
94
- export function requireMuxBackend(): MuxBackend {
95
- const backend = getMuxBackend();
96
- if (!backend) {
97
- throw new Error(`No supported terminal multiplexer found. ${muxSetupHint()}`);
98
- }
99
- return backend;
100
- }
101
-
102
- // ── Shell Escaping & Command Rendering ──
103
-
104
- export function shellEscape(s: string): string {
105
- return "'" + s.replace(/'/g, "'\\''") + "'";
106
- }
107
-
108
- export function psEscape(s: string): string {
109
- return "'" + s.replace(/'/g, "''") + "'";
110
- }
111
-
112
- const PS_SAFE_TOKEN = /^[A-Za-z0-9_.:/\\+\-]+$/;
113
- function psToken(s: string): string {
114
- return s !== "" && PS_SAFE_TOKEN.test(s) ? s : psEscape(s);
115
- }
116
-
117
- export interface SubagentCommandSpec {
118
- cwd: string | null;
119
- env: Array<[string, string]>;
120
- args: string[];
121
- }
122
-
123
- export function renderSubagentCommand(spec: SubagentCommandSpec, options?: { persistent?: boolean }): {
124
- script: string;
125
- scriptExt: ".sh" | ".ps1";
126
- } {
127
- const renderArgs = (escape: (s: string) => string): string =>
128
- spec.args.map(escape).join(" ");
129
-
130
- if (process.platform === "win32") {
131
- const lines: string[] = [
132
- `$OutputEncoding = [System.Text.Encoding]::UTF8`,
133
- `[Console]::OutputEncoding = [System.Text.Encoding]::UTF8`,
134
- ];
135
- if (spec.cwd) {
136
- lines.push(
137
- `try { Set-Location -LiteralPath ${psEscape(spec.cwd)} -ErrorAction Stop } catch { Write-Output '__SUBAGENT_DONE_1__'; exit 1 }`,
138
- );
139
- }
140
- for (const [key, value] of spec.env) {
141
- lines.push(`$env:${key} = ${psEscape(value)}`);
142
- }
143
- lines.push(`& ${renderArgs(psToken)}`);
144
- lines.push(
145
- `if ($null -eq $LASTEXITCODE) { Write-Output '__SUBAGENT_DONE_127__' } else { Write-Output "__SUBAGENT_DONE_\${LASTEXITCODE}__" }`,
146
- );
147
- return { script: lines.join("\r\n"), scriptExt: ".ps1" };
148
- }
149
-
150
- const envPrefix = spec.env.map(([key, value]) => `${key}=${shellEscape(value)}`).join(" ");
151
- const cdPrefix = spec.cwd ? `cd ${shellEscape(spec.cwd)} && ` : "";
152
- const script = options?.persistent
153
- ? `${cdPrefix}exec ${envPrefix ? `env ${envPrefix} ` : ""}${renderArgs(shellEscape)}`
154
- : `${cdPrefix}${envPrefix ? envPrefix + " " : ""}${renderArgs(shellEscape)}; echo '__SUBAGENT_DONE_'$?'__'`;
155
- return { script, scriptExt: ".sh" };
156
- }
157
-
158
- // ── Long Command Execution via Scripts ──
159
-
160
- export function sendLongCommand(
161
- surface: string,
162
- command: string,
163
- options?: { scriptPath?: string; scriptPreamble?: string },
164
- ): string {
165
- const isWin = process.platform === "win32";
166
- const ext = isWin ? ".ps1" : ".sh";
167
- const scriptPath =
168
- options?.scriptPath ??
169
- join(
170
- tmpdir(),
171
- "pi-subagent-scripts",
172
- `worker-${Date.now()}-${Math.random().toString(16).slice(2, 8)}${ext}`,
173
- );
174
- mkdirSync(dirname(scriptPath), { recursive: true });
175
-
176
- const isSh = scriptPath.endsWith(".sh");
177
- const parts: string[] = [];
178
- if (isSh) parts.push("#!/usr/bin/env bash");
179
- if (options?.scriptPreamble) parts.push(options.scriptPreamble.trimEnd());
180
- parts.push(command);
181
-
182
- let content = parts.join(isSh ? "\n" : "\r\n") + "\n";
183
- if (isSh) {
184
- content = content.replace(/\r\n/g, "\n");
185
- } else if (isWin) {
186
- // Add UTF-8 BOM so PowerShell 5.1/7 parses non-ASCII reliably
187
- content = "\uFEFF" + content;
188
- }
189
- writeFileSync(scriptPath, content, { mode: 0o755 });
190
-
191
- if (isSh) {
192
- if (isWin) {
193
- sendCommand(surface, `bash "${scriptPath.replace(/\\/g, "/")}"`);
194
- } else {
195
- sendCommand(surface, `bash ${shellEscape(scriptPath)}`);
196
- }
197
- } else {
198
- const ps = getPowerShellCommand();
199
- sendCommand(
200
- surface,
201
- `${ps} -NoProfile -ExecutionPolicy Bypass -Command "& ${psEscape(scriptPath)}; exit $LASTEXITCODE"`,
202
- );
203
- }
204
-
205
- return scriptPath;
206
- }
207
-
208
- export function sendCommand(surface: string, command: string): void {
209
- const backend = requireMuxBackend();
210
- if (backend === "tmux") {
211
- execFileSync("tmux", ["send-keys", "-t", surface, "-l", command], { encoding: "utf8" });
212
- execFileSync("tmux", ["send-keys", "-t", surface, "Enter"], { encoding: "utf8" });
213
- return;
214
- }
215
-
216
- // Zellij: write characters and press return (code 13)
217
- const paneId = zellijPaneId(surface);
218
- execFileSync("zellij", ["action", "write-chars", "--pane-id", paneId, command], {
219
- encoding: "utf8",
220
- });
221
- execFileSync("zellij", ["action", "write", "--pane-id", paneId, "13"], { encoding: "utf8" });
222
- }
223
-
224
- // ── Zellij Plumbing ──
225
-
226
- export function zellijPaneId(surface: string): string {
227
- return surface.startsWith("pane:") ? surface.slice("pane:".length) : surface;
228
- }
229
-
230
- function parseZellijPaneSurface(rawId: string): string {
231
- const idMatch = rawId.match(/(\d+)/);
232
- if (!idMatch) {
233
- throw new Error(`Unexpected zellij pane id: ${rawId || "(empty)"}`);
234
- }
235
- return `pane:${idMatch[1]}`;
236
- }
237
-
238
- function createZellijSurface(name: string): string {
239
- // Check available terminal dimensions
240
- try {
241
- const stdout = execFileSync("zellij", ["action", "list-clients"], { encoding: "utf8" });
242
- // Attempt standard right split with no focus
243
- const args = [
244
- "action",
245
- "new-pane",
246
- "--direction",
247
- "right",
248
- "--near-current-pane",
249
- "--no-focus",
250
- "--name",
251
- name,
252
- "--cwd",
253
- process.cwd(),
254
- ];
255
- const rawId = execFileSync("zellij", args, { encoding: "utf8" }).trim();
256
- return parseZellijPaneSurface(rawId);
257
- } catch {
258
- // Fallback: stacked pane
259
- const args = [
260
- "action",
261
- "new-pane",
262
- "--stacked",
263
- "--near-current-pane",
264
- "--no-focus",
265
- "--name",
266
- name,
267
- "--cwd",
268
- process.cwd(),
269
- ];
270
- const rawId = execFileSync("zellij", args, { encoding: "utf8" }).trim();
271
- return parseZellijPaneSurface(rawId);
272
- }
273
- }
274
-
275
- // ── tmux Layout & Balancing ──
276
-
277
- let rebalanceTimer: ReturnType<typeof setTimeout> | null = null;
278
-
279
- function rebalanceTmuxLayout(targetPane?: string): void {
280
- const target = process.env.TMUX_PANE ?? targetPane;
281
- if (!target) return;
282
- if (rebalanceTimer) clearTimeout(rebalanceTimer);
283
-
284
- rebalanceTimer = setTimeout(() => {
285
- rebalanceTimer = null;
286
- try {
287
- // Check total panes in the current window
288
- const countOut = execFileSync(
289
- "tmux",
290
- ["list-panes", "-t", target, "-F", "#{pane_id}"],
291
- { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
292
- ).trim();
293
- const paneCount = countOut.split("\n").filter(Boolean).length;
294
-
295
- // <= 3 panes: even-horizontal; > 3 panes: tiled grid so each pane has space
296
- const layout = paneCount <= 3 ? "even-horizontal" : "tiled";
297
- execFileSync("tmux", ["select-layout", "-t", target, layout], { encoding: "utf8", stdio: "ignore" });
298
- } catch {
299
- // Best effort layout balancing
300
- }
301
- }, 100);
302
- }
303
-
304
- function createTmuxSurface(name: string): string {
305
- const parentPane = process.env.TMUX_PANE;
306
-
307
- // Query window width to see if we can safely split horizontally
308
- let paneCount = 1;
309
- let windowWidth = 120;
310
- if (parentPane) {
311
- try {
312
- const info = execFileSync(
313
- "tmux",
314
- ["display-message", "-p", "-t", parentPane, "#{window_width}:#{window_panes}"],
315
- { encoding: "utf8" },
316
- ).trim().split(":");
317
- windowWidth = parseInt(info[0], 10) || 120;
318
- paneCount = parseInt(info[1], 10) || 1;
319
- } catch {}
320
- }
321
-
322
- // If already overcrowded (> 5 panes or width per pane would be < 35 cols),
323
- // create a new window in the background to avoid crushing the terminal
324
- if (paneCount >= 6 || windowWidth / (paneCount + 1) < 35) {
325
- const out = execFileSync(
326
- "tmux",
327
- ["new-window", "-d", "-n", `sub-${name}`, "-c", process.cwd(), "-P", "-F", "#{pane_id}"],
328
- { encoding: "utf8" },
329
- ).trim();
330
- if (out.startsWith("%")) return out;
331
- }
332
-
333
- // Otherwise, split horizontally to the right of the parent pane
334
- const splitArgs = ["split-window", "-d", "-h", "-c", process.cwd(), "-P", "-F", "#{pane_id}"];
335
- if (parentPane) {
336
- splitArgs.push("-t", parentPane);
337
- }
338
-
339
- const newPane = execFileSync("tmux", splitArgs, { encoding: "utf8" }).trim();
340
- if (!newPane.startsWith("%")) {
341
- throw new Error(`Unexpected tmux split-window output: ${newPane}`);
342
- }
343
-
344
- rebalanceTmuxLayout(newPane);
345
- return newPane;
346
- }
347
-
348
- // ── Surface Management (Unified) ──
349
-
350
- export function createSurface(name: string, backend?: WorkerBackend): string {
351
- const effective = backend ?? getWorkerBackend();
352
- if (effective === "process") {
353
- return `process:${name}-${Date.now().toString(36)}`;
354
- }
355
- if (effective === "zellij") {
356
- return createZellijSurface(name);
357
- }
358
- return createTmuxSurface(name);
359
- }
360
-
361
- export function spawnProcessWorker(spec: {
362
- cwd?: string | null;
363
- env: Array<[string, string]>;
364
- args: string[];
365
- logPath: string;
366
- useProcessEntrypoint?: boolean;
367
- }): ChildProcess {
368
- const logFd = openSync(spec.logPath, "a");
369
- const envObj = {
370
- ...process.env,
371
- ...Object.fromEntries(spec.env),
372
- };
373
-
374
- const rawCommand = spec.args[0] || "pi";
375
- const userArgs = spec.args.slice(1);
376
-
377
- // On Windows, if process.argv[1] is valid, run Node directly with the script
378
- // to avoid cmd.exe / shell:true quoting pitfalls and ENOENT on pi.cmd.
379
- const cliScript = process.argv[1];
380
- let command = rawCommand;
381
- let args = userArgs;
382
-
383
- if (spec.useProcessEntrypoint !== false && cliScript && existsSync(cliScript) && (rawCommand === "pi" || rawCommand === process.execPath)) {
384
- command = process.execPath;
385
- args = [cliScript, ...userArgs];
386
- }
387
-
388
- const child = spawn(command, args, {
389
- cwd: spec.cwd ?? process.cwd(),
390
- env: envObj,
391
- stdio: ["ignore", logFd, logFd],
392
- shell: process.platform === "win32" && command === "pi",
393
- detached: false,
394
- });
395
-
396
- child.on("close", () => {
397
- try {
398
- closeSync(logFd);
399
- } catch {}
400
- });
401
-
402
- return child;
403
- }
404
-
405
- export function closeSurface(surface: string, childProcess?: ChildProcess): void {
406
- if (isProcessSurface(surface)) {
407
- if (childProcess && childProcess.exitCode === null && !childProcess.killed) {
408
- if (process.platform === "win32" && childProcess.pid) {
409
- try {
410
- execFileSync("taskkill", ["/pid", String(childProcess.pid), "/T", "/F"], { stdio: "ignore" });
411
- } catch {
412
- try {
413
- childProcess.kill();
414
- } catch {}
415
- }
416
- } else {
417
- try {
418
- childProcess.kill("SIGTERM");
419
- setTimeout(() => {
420
- if (childProcess.exitCode === null && !childProcess.killed) {
421
- try {
422
- childProcess.kill("SIGKILL");
423
- } catch {}
424
- }
425
- }, 1000).unref();
426
- } catch {}
427
- }
428
- }
429
- return;
430
- }
431
-
432
- const backend = getMuxBackend();
433
- if (backend === "zellij") {
434
- try {
435
- execFileSync("zellij", ["action", "close-pane", "--pane-id", zellijPaneId(surface)], {
436
- encoding: "utf8",
437
- stdio: "ignore",
438
- });
439
- } catch {}
440
- return;
441
- }
442
-
443
- if (backend === "tmux") {
444
- try {
445
- execFileSync("tmux", ["kill-pane", "-t", surface], { encoding: "utf8", stdio: "ignore" });
446
- } catch {}
447
- rebalanceTmuxLayout();
448
- }
449
- }
450
-
451
- export async function readScreenAsync(surface: string, lines = 50): Promise<string> {
452
- const backend = requireMuxBackend();
453
- if (backend === "zellij") {
454
- try {
455
- const paneId = zellijPaneId(surface);
456
- const { stdout } = await execFileAsync(
457
- "zellij",
458
- ["action", "dump-screen", "--pane-id", paneId, "--full"],
459
- { encoding: "utf8" },
460
- );
461
- const split = stdout.split("\n");
462
- return split.slice(-lines).join("\n");
463
- } catch {
464
- return "";
465
- }
466
- }
467
-
468
- try {
469
- const { stdout } = await execFileAsync(
470
- "tmux",
471
- ["capture-pane", "-p", "-t", surface, "-S", `-${Math.max(1, lines)}`],
472
- { encoding: "utf8" },
473
- );
474
- return stdout;
475
- } catch {
476
- return "";
477
- }
478
- }
479
-
480
- // ── Exit Polling ──
481
-
482
- export interface PollResult {
483
- reason: "done" | "sentinel" | "error";
484
- exitCode: number;
485
- errorMessage?: string;
486
- }
487
-
488
- export async function pollForExit(
489
- surface: string,
490
- signal: AbortSignal,
491
- options: {
492
- interval: number;
493
- sessionFile?: string;
494
- childProcess?: ChildProcess;
495
- onTick?: (elapsed: number) => void;
496
- },
497
- ): Promise<PollResult> {
498
- const start = Date.now();
499
-
500
- for (;;) {
501
- if (signal.aborted) {
502
- throw new Error("Aborted while waiting for subagent worker to finish");
503
- }
504
-
505
- // 1. Check for .exit sidecar file (written by bridge on completion/error)
506
- if (options.sessionFile) {
507
- try {
508
- const exitFile = `${options.sessionFile}.exit`;
509
- if (existsSync(exitFile)) {
510
- const raw = readFileSync(exitFile, "utf-8");
511
- rmSync(exitFile, { force: true });
512
- const data = JSON.parse(raw);
513
- if (data?.type === "error") {
514
- return {
515
- reason: "error",
516
- exitCode: 1,
517
- errorMessage: data.errorMessage || "Worker ended with error",
518
- };
519
- }
520
- return { reason: "done", exitCode: 0 };
521
- }
522
- } catch {}
523
- }
524
-
525
- // 2. If running as a headless child process, check process exit
526
- if (options.childProcess && options.childProcess.exitCode !== null) {
527
- const code = options.childProcess.exitCode;
528
- await new Promise((r) => setTimeout(r, 100));
529
- if (options.sessionFile) {
530
- try {
531
- const exitFile = `${options.sessionFile}.exit`;
532
- if (existsSync(exitFile)) {
533
- const raw = readFileSync(exitFile, "utf-8");
534
- rmSync(exitFile, { force: true });
535
- const data = JSON.parse(raw);
536
- return {
537
- reason: data?.type === "error" ? "error" : "done",
538
- exitCode: data?.type === "error" ? 1 : 0,
539
- errorMessage: data?.errorMessage,
540
- };
541
- }
542
- } catch {}
543
- }
544
- return {
545
- reason: code === 0 ? "done" : "error",
546
- exitCode: code,
547
- errorMessage: code !== 0 ? `Process exited with code ${code}` : undefined,
548
- };
549
- }
550
-
551
- // 3. Fallback for mux mode: check screen for sentinel output __SUBAGENT_DONE_<code >__
552
- if (!isProcessSurface(surface)) {
553
- try {
554
- const screen = await readScreenAsync(surface, 5);
555
- const match = screen.match(/__SUBAGENT_DONE_(\d+)__/);
556
- if (match) {
557
- const code = parseInt(match[1], 10);
558
- return { reason: "sentinel", exitCode: code };
559
- }
560
- } catch {
561
- // Surface may be destroyed
562
- if (options.sessionFile) {
563
- try {
564
- const exitFile = `${options.sessionFile}.exit`;
565
- if (existsSync(exitFile)) {
566
- const raw = readFileSync(exitFile, "utf-8");
567
- rmSync(exitFile, { force: true });
568
- const data = JSON.parse(raw);
569
- return {
570
- reason: data?.type === "error" ? "error" : "done",
571
- exitCode: data?.type === "error" ? 1 : 0,
572
- errorMessage: data?.errorMessage,
573
- };
574
- }
575
- } catch {}
576
- }
577
- }
578
- }
579
-
580
- const elapsed = Math.floor((Date.now() - start) / 1000);
581
- options.onTick?.(elapsed);
582
-
583
- await new Promise<void>((resolve, reject) => {
584
- if (signal.aborted) return reject(new Error("Aborted"));
585
- let watcher: FSWatcher | undefined;
586
- let settled = false;
587
- const done = () => {
588
- if (settled) return;
589
- settled = true;
590
- clearTimeout(timer);
591
- signal.removeEventListener("abort", onAbort);
592
- if (watcher) {
593
- try { watcher.close(); } catch {}
594
- watcher = undefined;
595
- }
596
- resolve();
597
- };
598
- const onAbort = () => {
599
- if (settled) return;
600
- settled = true;
601
- clearTimeout(timer);
602
- if (watcher) {
603
- try { watcher.close(); } catch {}
604
- watcher = undefined;
605
- }
606
- reject(new Error("Aborted"));
607
- };
608
- signal.addEventListener("abort", onAbort, { once: true });
609
- const timer = setTimeout(done, options.interval);
610
- if (options.sessionFile) {
611
- try {
612
- const dir = dirname(options.sessionFile);
613
- const target = basename(`${options.sessionFile}.exit`);
614
- watcher = watch(dir, (_event, filename) => {
615
- if (filename === target || existsSync(`${options.sessionFile}.exit`)) done();
616
- });
617
- } catch {}
618
- }
619
- });
620
- }
621
- }
File without changes