@rol1n/subagent 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/README.md +34 -2
- package/bridge.ts +171 -0
- package/index.ts +590 -0
- package/mux.ts +621 -0
- package/package.json +41 -4
- package/session.ts +166 -0
- package/ui.ts +100 -0
package/mux.ts
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rol1n/subagent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "subagent extension from pi-kit",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package"
|
|
8
|
+
],
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/RolinShmily/pi-kit.git",
|
|
12
|
+
"directory": "subagent"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/RolinShmily/pi-kit#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/RolinShmily/pi-kit/issues"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=22.19.0"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./index.ts",
|
|
26
|
+
"./mux": "./mux.ts"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@earendil-works/pi-ai": "*",
|
|
30
|
+
"@earendil-works/pi-agent-core": "*",
|
|
31
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
32
|
+
"@earendil-works/pi-tui": "*",
|
|
33
|
+
"typebox": "*"
|
|
34
|
+
},
|
|
35
|
+
"pi": {
|
|
36
|
+
"extensions": [
|
|
37
|
+
"./index.ts"
|
|
38
|
+
],
|
|
39
|
+
"skills": [],
|
|
40
|
+
"prompts": [],
|
|
41
|
+
"themes": []
|
|
42
|
+
}
|
|
43
|
+
}
|