larkin 0.5.4 → 0.5.5

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.
Files changed (31) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +7 -1
  3. package/artifacts/release/THIRD_PARTY_NOTICES.txt +1 -100
  4. package/dist/agent/context-prompt.cjs +16 -17
  5. package/dist/agent/context-prompt.mjs +15 -16
  6. package/dist/runtime/pi-state-dir.cjs +105 -0
  7. package/dist/runtime/pi-state-dir.mjs +94 -0
  8. package/dist/runtime/pi-tmux-extension.mjs +137 -0
  9. package/dist/runtime/pi-tmux-injection.mjs +49 -0
  10. package/dist/runtime/pi-tmux.bundle.js +600 -0
  11. package/dist/runtime/pi-tmux.mjs +408 -0
  12. package/dist/runtime/runtime-adapters.mjs +26 -113
  13. package/dist/runtime/runtime-host.cjs +3 -413
  14. package/dist/runtime/runtime-host.mjs +2 -412
  15. package/package.json +2 -8
  16. package/dist/runtime/pi-bash-timeout-extension.mjs +0 -60
  17. package/dist/runtime/pi-bash-timeout-injection.mjs +0 -54
  18. package/dist/runtime/pi-bash-timeout.bundle.js +0 -43
  19. package/dist/runtime/pi-extension-api.mjs +0 -1
  20. package/dist/runtime/pi-subagent-injection.mjs +0 -290
  21. package/dist/runtime/pi-subagent-ledger.cjs +0 -632
  22. package/dist/runtime/pi-subagent-ledger.mjs +0 -594
  23. package/dist/runtime/pi-subagent-record-watchdog-injection.mjs +0 -43
  24. package/dist/runtime/pi-subagent-record-watchdog.bundle.js +0 -183
  25. package/dist/runtime/pi-subagent-record-watchdog.mjs +0 -46
  26. package/dist/runtime/pi-subagents-notification.mjs +0 -175
  27. package/dist/runtime/pi-subagents.bundle.js +0 -9110
  28. package/dist/runtime/pi-supervised-command-extension.mjs +0 -72
  29. package/dist/runtime/pi-supervised-command.bundle.js +0 -4735
  30. package/dist/runtime/pi-supervised-command.mjs +0 -420
  31. package/patches/@tintinweb%2Fpi-subagents@0.14.3.patch +0 -945
@@ -0,0 +1,49 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { ensureOwnedTree, writePrivateAtomic } from "./pi-state-dir.mjs";
5
+ import { tmuxAvailable } from "./pi-tmux.mjs";
6
+ const BUNDLE_NAME = "pi-tmux.bundle.js";
7
+ export function materializeEmbeddedPiTmuxBundle(configDir) {
8
+ const embedded = globalThis.__LARKIN_EMBEDDED_PI_TMUX_BUNDLE__;
9
+ if (!embedded || !configDir)
10
+ return null;
11
+ try {
12
+ const dir = ensureOwnedTree(path.resolve(configDir), ["providers", "pi", "extensions"]);
13
+ const target = path.join(dir, BUNDLE_NAME);
14
+ try {
15
+ if (fs.lstatSync(target).isSymbolicLink())
16
+ throw new Error("pi-tmux bundle must not be a symlink");
17
+ if (fs.readFileSync(target, "utf8") === embedded)
18
+ return target;
19
+ }
20
+ catch (error) {
21
+ if (error.code !== "ENOENT")
22
+ throw error;
23
+ }
24
+ writePrivateAtomic(target, embedded);
25
+ return target;
26
+ }
27
+ catch (error) {
28
+ throw new Error("Could not materialize the Larkin tmux extension", { cause: error });
29
+ }
30
+ }
31
+ export function bundledPiTmuxExtensionPath(configDir) {
32
+ try {
33
+ const resolved = fileURLToPath(new URL(`./${BUNDLE_NAME}`, import.meta.url));
34
+ if (fs.existsSync(resolved))
35
+ return resolved;
36
+ }
37
+ catch {
38
+ /* fall through to embedded */
39
+ }
40
+ return materializeEmbeddedPiTmuxBundle(configDir);
41
+ }
42
+ export function resolvePiTmuxExtensionArg(input, resolveBundle = () => bundledPiTmuxExtensionPath(input.env.LARKIN_CONFIG_DIR), available = tmuxAvailable) {
43
+ if (input.platform === "win32" || !available(input.env, input.platform))
44
+ return null;
45
+ const bundle = resolveBundle();
46
+ if (!bundle)
47
+ throw new Error("Larkin tmux extension bundle is missing; rebuild or reinstall Larkin");
48
+ return bundle;
49
+ }
@@ -0,0 +1,600 @@
1
+ // @bun
2
+ // src/runtime/pi-tmux-extension.ts
3
+ import crypto2 from "crypto";
4
+ import { Type } from "typebox";
5
+
6
+ // src/runtime/pi-tmux.ts
7
+ import { spawnSync } from "child_process";
8
+ import crypto from "crypto";
9
+ import fs2 from "fs";
10
+ import path2 from "path";
11
+
12
+ // src/runtime/pi-state-dir.ts
13
+ import fs from "fs";
14
+ import path from "path";
15
+ function ensureOwnedTree(root, parts = [], create = true) {
16
+ const resolved = path.resolve(root);
17
+ if (fs.existsSync(resolved)) {
18
+ const stat = fs.lstatSync(resolved);
19
+ if (stat.isSymbolicLink() || !stat.isDirectory())
20
+ throw new Error(`unsafe private directory: ${resolved}`);
21
+ } else if (create) {
22
+ fs.mkdirSync(resolved, { recursive: true, mode: 448 });
23
+ if (fs.lstatSync(resolved).isSymbolicLink() || !fs.lstatSync(resolved).isDirectory()) {
24
+ throw new Error(`unsafe private directory: ${resolved}`);
25
+ }
26
+ } else {
27
+ throw new Error(`unsafe private directory: ${resolved}`);
28
+ }
29
+ let dir = resolved;
30
+ for (const part of parts) {
31
+ if (!part || part === "." || part === ".." || /[\\/]/.test(part))
32
+ throw new Error(`unsafe owned path component: ${part}`);
33
+ dir = path.join(dir, part);
34
+ try {
35
+ const existing = fs.lstatSync(dir);
36
+ if (existing.isSymbolicLink() || !existing.isDirectory())
37
+ throw new Error(`unsafe private directory: ${dir}`);
38
+ } catch (error) {
39
+ if (error.code !== "ENOENT")
40
+ throw error;
41
+ if (!create)
42
+ throw new Error(`unsafe private directory: ${dir}`);
43
+ fs.mkdirSync(dir, { mode: 448 });
44
+ if (fs.lstatSync(dir).isSymbolicLink() || !fs.lstatSync(dir).isDirectory())
45
+ throw new Error(`unsafe private directory: ${dir}`);
46
+ }
47
+ if (process.platform !== "win32")
48
+ fs.chmodSync(dir, 448);
49
+ }
50
+ return dir;
51
+ }
52
+ function mkdirPrivateExclusive(directory) {
53
+ fs.mkdirSync(directory, { mode: 448 });
54
+ const created = fs.lstatSync(directory);
55
+ if (created.isSymbolicLink() || !created.isDirectory())
56
+ throw new Error(`unsafe private directory: ${directory}`);
57
+ if (process.platform !== "win32")
58
+ fs.chmodSync(directory, 448);
59
+ }
60
+ function writePrivateExclusive(file, content) {
61
+ fs.writeFileSync(file, content, { mode: 384, flag: "wx" });
62
+ if (process.platform !== "win32")
63
+ fs.chmodSync(file, 384);
64
+ }
65
+
66
+ // src/runtime/pi-tmux.ts
67
+ var DEFAULT_BASH_WAIT_SECONDS = 30;
68
+ var OUTPUT_TAIL_BYTES = 32 * 1024;
69
+ var LARKIN_TMUX_COMPLETION_TYPE = "larkin-tmux-completion";
70
+
71
+ class ForeignTmuxTaskError extends Error {
72
+ taskId;
73
+ constructor(taskId) {
74
+ super(`tmux task ${taskId} is not owned by this Larkin instance`);
75
+ this.name = "ForeignTmuxTaskError";
76
+ this.taskId = taskId;
77
+ }
78
+ }
79
+ function tmuxAvailable(env = process.env, platform = process.platform) {
80
+ if (platform === "win32")
81
+ return false;
82
+ const result = spawnSync("tmux", ["-V"], { env: tmuxClientEnv(env), encoding: "utf8", timeout: 5000 });
83
+ const version = /tmux\s+(\d+)\.(\d+)/i.exec(`${result.stdout || ""} ${result.stderr || ""}`);
84
+ return result.status === 0 && version !== null && (Number(version[1]) > 3 || Number(version[1]) === 3 && Number(version[2]) >= 2);
85
+ }
86
+ function posixQuote(value) {
87
+ return `'${value.replace(/'/g, `'\\''`)}'`;
88
+ }
89
+ function sanitizeName(value, fallback) {
90
+ const cleaned = value.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 24);
91
+ return cleaned || fallback;
92
+ }
93
+ function taskRoot(stateDir, agentId, instanceId) {
94
+ return path2.join(stateDir, "pi-tmux", sanitizeName(agentId, "agent"), sanitizeName(instanceId, "inst"));
95
+ }
96
+ function taskDir(stateDir, agentId, instanceId, taskId) {
97
+ return path2.join(taskRoot(stateDir, agentId, instanceId), taskId);
98
+ }
99
+ function sessionName(agentId, instanceId, taskId) {
100
+ return `lkn-${sanitizeName(agentId, "agent")}-${sanitizeName(instanceId, "inst")}-${taskId}`;
101
+ }
102
+ function readText(file) {
103
+ try {
104
+ return fs2.readFileSync(file, "utf8");
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+ function writePrivate(file, body) {
110
+ fs2.writeFileSync(file, body, { mode: 384 });
111
+ fs2.chmodSync(file, 384);
112
+ }
113
+ function isRegularFile(file) {
114
+ try {
115
+ const stat = fs2.lstatSync(file);
116
+ return stat.isFile() && !stat.isSymbolicLink();
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+ function isInside(root, candidate) {
122
+ const relative = path2.relative(root, candidate);
123
+ return relative === "" || !relative.startsWith("..") && !path2.isAbsolute(relative);
124
+ }
125
+ function tailBytes(file, maxBytes) {
126
+ try {
127
+ const fd = fs2.openSync(file, "r");
128
+ try {
129
+ const size = fs2.fstatSync(fd).size;
130
+ const start = size > maxBytes ? size - maxBytes : 0;
131
+ const buf = Buffer.alloc(size - start);
132
+ fs2.readSync(fd, buf, 0, buf.length, start);
133
+ return buf.toString("utf8");
134
+ } finally {
135
+ fs2.closeSync(fd);
136
+ }
137
+ } catch {
138
+ return "";
139
+ }
140
+ }
141
+ function writeEnvScript(file, env) {
142
+ const lines = ["#!/bin/bash", "set -eu"];
143
+ for (const [key, value] of Object.entries(env)) {
144
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
145
+ continue;
146
+ if (value === undefined)
147
+ continue;
148
+ if (key === "PWD" || key === "TMUX" || key === "TMUX_PANE")
149
+ continue;
150
+ lines.push(`export ${key}=${posixQuote(value)}`);
151
+ }
152
+ writePrivateExclusive(file, `${lines.join(`
153
+ `)}
154
+ `);
155
+ }
156
+ function tmuxClientEnv(env) {
157
+ const next = { ...env };
158
+ delete next.TMUX;
159
+ delete next.TMUX_PANE;
160
+ delete next.BASH_ENV;
161
+ return next;
162
+ }
163
+ function tmuxHasSession(name, env) {
164
+ return spawnSync("tmux", ["has-session", "-t", `=${name}`], { env: tmuxClientEnv(env), encoding: "utf8", timeout: 5000 }).status === 0;
165
+ }
166
+ function killSession(name, env) {
167
+ spawnSync("tmux", ["kill-session", "-t", `=${name}`], { env: tmuxClientEnv(env), encoding: "utf8", timeout: 5000 });
168
+ }
169
+ function parseExitCode(raw) {
170
+ if (raw === null)
171
+ return null;
172
+ const value = Number.parseInt(raw.trim(), 10);
173
+ return Number.isInteger(value) ? value : null;
174
+ }
175
+ function processTable() {
176
+ const listed = spawnSync("ps", ["-ax", "-o", "pid=,ppid=,pgid=,state="], { encoding: "utf8", timeout: 5000 });
177
+ if (listed.status !== 0)
178
+ return [];
179
+ return listed.stdout.split(`
180
+ `).flatMap((line) => {
181
+ const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(\S+)/);
182
+ if (!match)
183
+ return [];
184
+ return [{
185
+ pid: Number.parseInt(match[1], 10),
186
+ ppid: Number.parseInt(match[2], 10),
187
+ pgid: Number.parseInt(match[3], 10),
188
+ state: match[4]
189
+ }];
190
+ });
191
+ }
192
+ function liveOwnedPanePid(session, env) {
193
+ if (!tmuxHasSession(session, env))
194
+ return null;
195
+ const listed = spawnSync("tmux", ["list-panes", "-t", `=${session}`, "-F", "#{pane_pid}"], {
196
+ env: tmuxClientEnv(env),
197
+ encoding: "utf8",
198
+ timeout: 5000
199
+ });
200
+ if (listed.status !== 0)
201
+ return null;
202
+ const pid = Number.parseInt(listed.stdout.trim().split(/\s+/)[0] ?? "", 10);
203
+ if (!Number.isInteger(pid) || pid <= 1)
204
+ return null;
205
+ try {
206
+ process.kill(pid, 0);
207
+ } catch {
208
+ return null;
209
+ }
210
+ return pid;
211
+ }
212
+ function ownedPids(panePid, rows) {
213
+ const pane = rows.find((row) => row.pid === panePid);
214
+ const owned = new Set([panePid]);
215
+ if (pane) {
216
+ for (const row of rows)
217
+ if (row.pgid === pane.pgid)
218
+ owned.add(row.pid);
219
+ }
220
+ let grew = true;
221
+ while (grew) {
222
+ grew = false;
223
+ for (const row of rows) {
224
+ if (owned.has(row.ppid) && !owned.has(row.pid) && !row.state.startsWith("Z")) {
225
+ owned.add(row.pid);
226
+ grew = true;
227
+ }
228
+ }
229
+ }
230
+ return [...owned].filter((pid) => pid > 1 && pid !== process.pid);
231
+ }
232
+ function pidAlive(pid) {
233
+ const listed = spawnSync("ps", ["-p", String(pid), "-o", "stat="], { encoding: "utf8", timeout: 5000 });
234
+ const state = listed.stdout.trim();
235
+ return listed.status === 0 && Boolean(state) && !state.startsWith("Z");
236
+ }
237
+ function signalGroup(pgid, signal) {
238
+ try {
239
+ process.kill(-pgid, signal);
240
+ } catch {}
241
+ }
242
+ function terminateOwnedPane(session, env) {
243
+ const panePid = liveOwnedPanePid(session, env);
244
+ const rows = processTable();
245
+ const pane = rows.find((row) => row.pid === panePid);
246
+ const pids = panePid ? ownedPids(panePid, rows) : [];
247
+ if (pane)
248
+ signalGroup(pane.pgid, "SIGTERM");
249
+ killSession(session, env);
250
+ if (pane)
251
+ signalGroup(pane.pgid, "SIGKILL");
252
+ for (const pid of pids) {
253
+ try {
254
+ process.kill(pid, "SIGKILL");
255
+ } catch {}
256
+ }
257
+ if (tmuxHasSession(session, env))
258
+ killSession(session, env);
259
+ if (tmuxHasSession(session, env))
260
+ throw new Error(`tmux session ${session} still present after kill`);
261
+ const deadline = Date.now() + 250;
262
+ while (Date.now() < deadline && pids.some(pidAlive)) {
263
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
264
+ }
265
+ if (pids.some(pidAlive))
266
+ throw new Error(`tmux pane descendants still present after kill`);
267
+ }
268
+ function snapshotFromDir(dir, taskId, env, session) {
269
+ const live = tmuxHasSession(session, env);
270
+ const readExit = () => parseExitCode(readText(path2.join(dir, "exit_code")));
271
+ const cancelled = readText(path2.join(dir, "cancelled")) !== null;
272
+ let exitCode = readExit();
273
+ if (!live && exitCode === null)
274
+ exitCode = readExit();
275
+ const startedAt = readText(path2.join(dir, "started_at"))?.trim() || null;
276
+ const endedAt = readText(path2.join(dir, "ended_at"))?.trim() || null;
277
+ const output = tailBytes(path2.join(dir, "output"), OUTPUT_TAIL_BYTES);
278
+ let status;
279
+ if (exitCode === null) {
280
+ if (live)
281
+ status = cancelled ? "cancelled" : "running";
282
+ else
283
+ status = cancelled ? "cancelled" : "failed";
284
+ } else if (cancelled)
285
+ status = "cancelled";
286
+ else
287
+ status = exitCode === 0 ? "completed" : "failed";
288
+ return { taskId, status, exitCode, output, startedAt, endedAt };
289
+ }
290
+ function formatTmuxTaskText(snapshot) {
291
+ const exit = snapshot.exitCode === null ? "null" : String(snapshot.exitCode);
292
+ const body = snapshot.output ? `
293
+ ${snapshot.output}` : "";
294
+ return `taskId=${snapshot.taskId} status=${snapshot.status} exitCode=${exit}${body}`;
295
+ }
296
+ function createLarkinTmux(input) {
297
+ const env = input.env ?? process.env;
298
+ const agentId = input.agentId;
299
+ const instanceId = input.instanceId || crypto.randomUUID();
300
+ const stateDir = path2.resolve(input.stateDir);
301
+ const ownedParts = ["pi-tmux", sanitizeName(agentId, "agent"), sanitizeName(instanceId, "inst")];
302
+ const root = taskRoot(stateDir, agentId, instanceId);
303
+ const expectedSession = (taskId) => sessionName(agentId, instanceId, taskId);
304
+ const assertPrivateScripts = (dir) => {
305
+ for (const name of ["env.sh", "command.sh", "run.sh", "meta.json"]) {
306
+ const file = path2.join(dir, name);
307
+ if (!fs2.existsSync(file)) {
308
+ if (name === "meta.json")
309
+ throw new ForeignTmuxTaskError(path2.basename(dir));
310
+ continue;
311
+ }
312
+ if (!isRegularFile(file))
313
+ throw new ForeignTmuxTaskError(path2.basename(dir));
314
+ }
315
+ };
316
+ const requireOwned = (taskId) => {
317
+ if (!/^[A-Za-z0-9_-]+$/.test(taskId))
318
+ throw new ForeignTmuxTaskError(taskId);
319
+ let instanceRoot;
320
+ try {
321
+ instanceRoot = ensureOwnedTree(stateDir, ownedParts, false);
322
+ } catch {
323
+ throw new ForeignTmuxTaskError(taskId);
324
+ }
325
+ const dir = taskDir(stateDir, agentId, instanceId, taskId);
326
+ try {
327
+ const stat = fs2.lstatSync(dir);
328
+ if (stat.isSymbolicLink() || !stat.isDirectory() || !isInside(instanceRoot, dir))
329
+ throw new ForeignTmuxTaskError(taskId);
330
+ } catch (error) {
331
+ if (error instanceof ForeignTmuxTaskError)
332
+ throw error;
333
+ throw new ForeignTmuxTaskError(taskId);
334
+ }
335
+ const metaFile = path2.join(dir, "meta.json");
336
+ if (!isRegularFile(metaFile))
337
+ throw new ForeignTmuxTaskError(taskId);
338
+ let meta;
339
+ try {
340
+ meta = JSON.parse(fs2.readFileSync(metaFile, "utf8"));
341
+ } catch {
342
+ throw new ForeignTmuxTaskError(taskId);
343
+ }
344
+ if (meta.taskId !== taskId || meta.agentId !== agentId || meta.instanceId !== instanceId || meta.session !== expectedSession(taskId) || typeof meta.cwd !== "string")
345
+ throw new ForeignTmuxTaskError(taskId);
346
+ try {
347
+ assertPrivateScripts(dir);
348
+ } catch (error) {
349
+ if (error instanceof ForeignTmuxTaskError)
350
+ throw error;
351
+ throw new ForeignTmuxTaskError(taskId);
352
+ }
353
+ return dir;
354
+ };
355
+ const start = (command, cwd) => {
356
+ const taskId = crypto.randomBytes(8).toString("hex");
357
+ const parent = ensureOwnedTree(stateDir, ownedParts);
358
+ const dir = path2.join(parent, taskId);
359
+ mkdirPrivateExclusive(dir);
360
+ if (!tmuxAvailable(env))
361
+ throw new Error("tmux is not available");
362
+ const session = expectedSession(taskId);
363
+ writePrivateExclusive(path2.join(dir, "meta.json"), `${JSON.stringify({
364
+ taskId,
365
+ agentId,
366
+ instanceId,
367
+ session,
368
+ cwd
369
+ })}
370
+ `);
371
+ writeEnvScript(path2.join(dir, "env.sh"), env);
372
+ writePrivateExclusive(path2.join(dir, "command.sh"), command.endsWith(`
373
+ `) ? command : `${command}
374
+ `);
375
+ const envFile = path2.join(dir, "env.sh");
376
+ const commandFile = path2.join(dir, "command.sh");
377
+ const outputFile = path2.join(dir, "output");
378
+ const exitFile = path2.join(dir, "exit_code");
379
+ const startedFile = path2.join(dir, "started_at");
380
+ const endedFile = path2.join(dir, "ended_at");
381
+ writePrivateExclusive(path2.join(dir, "run.sh"), [
382
+ "#!/bin/bash",
383
+ `PANE_TMUX=\${TMUX-}`,
384
+ `PANE_TMUX_PANE=\${TMUX_PANE-}`,
385
+ `. ${posixQuote(envFile)}`,
386
+ "set +eu",
387
+ "unset TMUX TMUX_PANE",
388
+ `if [ -n "$PANE_TMUX" ]; then export TMUX="$PANE_TMUX"; fi`,
389
+ `if [ -n "$PANE_TMUX_PANE" ]; then export TMUX_PANE="$PANE_TMUX_PANE"; fi`,
390
+ `cd ${posixQuote(cwd)} || { date -u +%Y-%m-%dT%H:%M:%SZ > ${posixQuote(endedFile)}; printf '%s\\n' 127 > ${posixQuote(exitFile)}; exit 127; }`,
391
+ `date -u +%Y-%m-%dT%H:%M:%SZ > ${posixQuote(startedFile)}`,
392
+ `/bin/bash -o pipefail ${posixQuote(commandFile)} > ${posixQuote(outputFile)} 2>&1`,
393
+ `status=$?`,
394
+ `date -u +%Y-%m-%dT%H:%M:%SZ > ${posixQuote(endedFile)}`,
395
+ `printf '%s\\n' "$status" > ${posixQuote(exitFile)}`,
396
+ ""
397
+ ].join(`
398
+ `));
399
+ const created = spawnSync("tmux", ["new-session", "-d", "-s", session, "-n", "bash", "-e", "BASH_ENV=", "/usr/bin/env", "-u", "BASH_ENV", "/bin/bash", path2.join(dir, "run.sh")], {
400
+ env: tmuxClientEnv(env),
401
+ encoding: "utf8",
402
+ timeout: 5000
403
+ });
404
+ if (created.status !== 0) {
405
+ throw new Error(`tmux new-session failed: ${(created.stderr || created.stdout || "").trim() || created.status}`);
406
+ }
407
+ return snapshotFromDir(dir, taskId, env, session);
408
+ };
409
+ const peek = (taskId) => {
410
+ const dir = requireOwned(taskId);
411
+ return snapshotFromDir(dir, taskId, env, expectedSession(taskId));
412
+ };
413
+ const list = () => {
414
+ let instanceRoot;
415
+ try {
416
+ instanceRoot = ensureOwnedTree(stateDir, ownedParts, false);
417
+ } catch {
418
+ return [];
419
+ }
420
+ return fs2.readdirSync(instanceRoot).sort().flatMap((taskId) => {
421
+ try {
422
+ return [peek(taskId)];
423
+ } catch {
424
+ return [];
425
+ }
426
+ });
427
+ };
428
+ const wait = async (taskId, timeoutSeconds, signal) => {
429
+ const deadline = Date.now() + Math.max(0, timeoutSeconds) * 1000;
430
+ while (true) {
431
+ if (signal?.aborted) {
432
+ kill(taskId);
433
+ return peek(taskId);
434
+ }
435
+ const current = peek(taskId);
436
+ const flushing = current.status === "failed" && current.exitCode === null;
437
+ if (current.status !== "running" && !flushing)
438
+ return current;
439
+ if (Date.now() >= deadline)
440
+ return current;
441
+ await new Promise((resolve) => setTimeout(resolve, 50));
442
+ }
443
+ };
444
+ const kill = (taskId) => {
445
+ const dir = requireOwned(taskId);
446
+ const session = expectedSession(taskId);
447
+ if (parseExitCode(readText(path2.join(dir, "exit_code"))) !== null)
448
+ return snapshotFromDir(dir, taskId, env, session);
449
+ const live = tmuxHasSession(session, env) || liveOwnedPanePid(session, env) !== null;
450
+ if (live) {
451
+ writePrivate(path2.join(dir, "cancelled"), `1
452
+ `);
453
+ terminateOwnedPane(session, env);
454
+ if (!readText(path2.join(dir, "ended_at"))) {
455
+ writePrivate(path2.join(dir, "ended_at"), `${new Date().toISOString().slice(0, 19)}Z
456
+ `);
457
+ }
458
+ }
459
+ return snapshotFromDir(dir, taskId, env, session);
460
+ };
461
+ return { start, peek, list, wait, kill, root, instanceId };
462
+ }
463
+
464
+ // src/runtime/pi-tmux-extension.ts
465
+ function resultOf(snapshot) {
466
+ return {
467
+ content: [{ type: "text", text: formatTmuxTaskText(snapshot) }],
468
+ details: {
469
+ taskId: snapshot.taskId,
470
+ status: snapshot.status,
471
+ exitCode: snapshot.exitCode,
472
+ output: snapshot.output
473
+ }
474
+ };
475
+ }
476
+ function errorResult(message) {
477
+ return {
478
+ content: [{ type: "text", text: message }],
479
+ details: { taskId: "", status: "failed", exitCode: null, output: message }
480
+ };
481
+ }
482
+ function registerLarkinTmuxExtension(pi) {
483
+ if (!tmuxAvailable())
484
+ return false;
485
+ const stateDir = process.env.LARKIN_STATE_DIR || `${process.cwd()}/.larkin`;
486
+ const agentId = process.env.LARKIN_AGENT_ID || "agent";
487
+ const instanceId = process.env.LARKIN_TMUX_INSTANCE_ID || process.env.LARKIN_RUNTIME_OBSERVATION_GENERATION || crypto2.randomUUID();
488
+ const tmux = createLarkinTmux({ stateDir, agentId, instanceId, env: process.env });
489
+ const watchers = new Map;
490
+ const notified = new Set;
491
+ const stopWatchers = () => {
492
+ for (const timer of watchers.values())
493
+ clearInterval(timer);
494
+ watchers.clear();
495
+ };
496
+ const notifyOnce = (snapshot) => {
497
+ if (snapshot.status === "running" || notified.has(snapshot.taskId))
498
+ return;
499
+ notified.add(snapshot.taskId);
500
+ pi.sendMessage({
501
+ customType: LARKIN_TMUX_COMPLETION_TYPE,
502
+ content: formatTmuxTaskText(snapshot),
503
+ display: true,
504
+ details: {
505
+ taskId: snapshot.taskId,
506
+ exitCode: snapshot.exitCode,
507
+ output: snapshot.output
508
+ }
509
+ }, { triggerTurn: true, deliverAs: "followUp" });
510
+ };
511
+ const watch = (taskId) => {
512
+ if (watchers.has(taskId) || notified.has(taskId))
513
+ return;
514
+ const timer = setInterval(() => {
515
+ try {
516
+ const snapshot = tmux.peek(taskId);
517
+ if (snapshot.status === "running")
518
+ return;
519
+ clearInterval(timer);
520
+ watchers.delete(taskId);
521
+ notifyOnce(snapshot);
522
+ } catch {
523
+ clearInterval(timer);
524
+ watchers.delete(taskId);
525
+ }
526
+ }, 250);
527
+ watchers.set(taskId, timer);
528
+ };
529
+ pi.on("session_shutdown", () => {
530
+ stopWatchers();
531
+ });
532
+ pi.registerTool({
533
+ name: "bash",
534
+ label: "bash",
535
+ description: "Run a command in a detached tmux window. timeout is a wait window only and never hard-kills. background:true returns immediately.",
536
+ parameters: Type.Object({
537
+ command: Type.String(),
538
+ timeout: Type.Optional(Type.Number()),
539
+ background: Type.Optional(Type.Boolean())
540
+ }),
541
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
542
+ const snapshot = tmux.start(params.command, ctx.cwd);
543
+ if (params.background === true) {
544
+ watch(snapshot.taskId);
545
+ return resultOf(tmux.peek(snapshot.taskId));
546
+ }
547
+ const timeout = typeof params.timeout === "number" && params.timeout >= 0 ? params.timeout : DEFAULT_BASH_WAIT_SECONDS;
548
+ const waited = await tmux.wait(snapshot.taskId, timeout, signal);
549
+ if (waited.status === "running")
550
+ watch(waited.taskId);
551
+ return resultOf(waited);
552
+ }
553
+ });
554
+ pi.registerTool({
555
+ name: "tmux",
556
+ label: "tmux",
557
+ description: "List, peek, or kill Larkin-owned tmux tasks for this Agent. Foreign task IDs are rejected.",
558
+ parameters: Type.Object({
559
+ action: Type.Union([Type.Literal("list"), Type.Literal("peek"), Type.Literal("kill")]),
560
+ taskId: Type.Optional(Type.String())
561
+ }),
562
+ async execute(_toolCallId, params) {
563
+ try {
564
+ if (params.action === "list") {
565
+ const tasks = tmux.list();
566
+ return {
567
+ content: [{ type: "text", text: tasks.map(formatTmuxTaskText).join(`
568
+ ---
569
+ `) || "no owned tmux tasks" }],
570
+ details: { taskId: "", status: "completed", exitCode: 0, output: "" }
571
+ };
572
+ }
573
+ if (!params.taskId)
574
+ return errorResult("taskId is required for peek and kill");
575
+ if (params.action === "peek")
576
+ return resultOf(tmux.peek(params.taskId));
577
+ const killed = tmux.kill(params.taskId);
578
+ notified.add(params.taskId);
579
+ const timer = watchers.get(params.taskId);
580
+ if (timer) {
581
+ clearInterval(timer);
582
+ watchers.delete(params.taskId);
583
+ }
584
+ return resultOf(killed);
585
+ } catch (error) {
586
+ if (error instanceof ForeignTmuxTaskError)
587
+ return errorResult(error.message);
588
+ throw error;
589
+ }
590
+ }
591
+ });
592
+ return true;
593
+ }
594
+ function larkinTmuxExtension(pi) {
595
+ registerLarkinTmuxExtension(pi);
596
+ }
597
+ export {
598
+ registerLarkinTmuxExtension,
599
+ larkinTmuxExtension as default
600
+ };