@cmgmyr/hive 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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/claude-plugin/.claude-plugin/plugin.json +7 -0
  4. package/claude-plugin/README.md +47 -0
  5. package/claude-plugin/hooks/hooks.json +14 -0
  6. package/claude-plugin/kickoff.mjs +41 -0
  7. package/claude-plugin/skills/cleanup/SKILL.md +60 -0
  8. package/dist/abi.js +220 -0
  9. package/dist/abiProbe.js +9 -0
  10. package/dist/backgroundTasks.js +51 -0
  11. package/dist/backup.js +378 -0
  12. package/dist/brief.js +113 -0
  13. package/dist/build-info.json +1 -0
  14. package/dist/claudeDir.js +5 -0
  15. package/dist/cli.js +2901 -0
  16. package/dist/codexHome.js +297 -0
  17. package/dist/config.js +72 -0
  18. package/dist/context.js +256 -0
  19. package/dist/contextCheckpoint.js +55 -0
  20. package/dist/dashboard.js +1158 -0
  21. package/dist/dataDir.js +89 -0
  22. package/dist/db.js +421 -0
  23. package/dist/dispatcher.js +150 -0
  24. package/dist/firstPrompt.js +6 -0
  25. package/dist/harnesses.js +173 -0
  26. package/dist/help.js +353 -0
  27. package/dist/hook.js +197 -0
  28. package/dist/hooks.js +50 -0
  29. package/dist/index.js +34 -0
  30. package/dist/kickoff.js +191 -0
  31. package/dist/leadMessage.js +98 -0
  32. package/dist/mcpConfig.js +121 -0
  33. package/dist/processes.js +161 -0
  34. package/dist/profiles.js +278 -0
  35. package/dist/projectYml.js +254 -0
  36. package/dist/prompts.js +18 -0
  37. package/dist/ptys.js +146 -0
  38. package/dist/result.js +53 -0
  39. package/dist/scheduler.js +1622 -0
  40. package/dist/sessionProbe.js +131 -0
  41. package/dist/slug.js +29 -0
  42. package/dist/spawn.js +370 -0
  43. package/dist/stateProvenance.js +97 -0
  44. package/dist/statusline.js +82 -0
  45. package/dist/strictInput.js +52 -0
  46. package/dist/teardown.js +89 -0
  47. package/dist/tmux.js +1295 -0
  48. package/dist/tools/agents.js +1246 -0
  49. package/dist/tools/kv.js +89 -0
  50. package/dist/tools/leases.js +87 -0
  51. package/dist/tools/meta.js +183 -0
  52. package/dist/tools/pads.js +279 -0
  53. package/dist/tools/params.js +14 -0
  54. package/dist/tools/todos.js +382 -0
  55. package/dist/tools/wakes.js +466 -0
  56. package/dist/transcript.js +123 -0
  57. package/dist/version.js +59 -0
  58. package/package.json +62 -0
  59. package/profiles/orchestration/posture.md +27 -0
  60. package/profiles/orchestration/runbook.md +72 -0
  61. package/profiles/orchestration/worker.md +38 -0
  62. package/profiles/simple/posture.md +14 -0
package/dist/tmux.js ADDED
@@ -0,0 +1,1295 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { readdirSync, realpathSync, statSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, dirname, join } from "node:path";
5
+ import { attachMode, resolvedAutoAttach } from "./config.js";
6
+ import { DEFAULT_DATA_DIR, dataDirTag, isDefaultStore, isProductEntryPoint, storeDir, underTestRunner, } from "./dataDir.js";
7
+ export class TmuxError extends Error {
8
+ stderr;
9
+ notInstalled;
10
+ constructor(message, stderr, notInstalled = false) {
11
+ super(message);
12
+ this.stderr = stderr;
13
+ this.notInstalled = notInstalled;
14
+ this.name = "TmuxError";
15
+ }
16
+ }
17
+ export class TmuxTimeoutError extends TmuxError {
18
+ timeoutMs;
19
+ constructor(message, stderr, timeoutMs) {
20
+ super(message, stderr);
21
+ this.timeoutMs = timeoutMs;
22
+ this.name = "TmuxTimeoutError";
23
+ }
24
+ }
25
+ const TMUX_MAX_BUFFER = 16 * 1024 * 1024;
26
+ const TMUX_TIMEOUT_MS = 10_000;
27
+ function tmuxTimeoutMs() {
28
+ const raw = process.env.HIVE_TMUX_TIMEOUT_MS;
29
+ if (raw === undefined)
30
+ return TMUX_TIMEOUT_MS;
31
+ const parsed = Number(raw);
32
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : TMUX_TIMEOUT_MS;
33
+ }
34
+ export function tmuxTimeoutOverride() {
35
+ return process.env.HIVE_TMUX_TIMEOUT_MS === undefined ? null : tmuxTimeoutMs();
36
+ }
37
+ // A third question from untrustedTmuxServer() (private socket + default store) and ensureAttached()
38
+ // (socket alone): refuses a NON-DEFAULT store paired with the SHARED socket, the shape produced once
39
+ // TMUX_TMPDIR has gone unreachable. See .claude/rules/store-and-datadir.md.
40
+ //
41
+ // A non-default HIVE_DATA_DIR is a documented user setting (docs/configuration.md), not evidence of a test or a
42
+ // hand-rolled driver on its own - namespacing session names by data-dir tag exists precisely so a
43
+ // custom store can share the real server. So this only fires for the shapes todo 368 is actually
44
+ // about: a test runner, or a process that is not hive's own CLI/MCP/hooks entry point at all. A real
45
+ // `hive lead` (or any product entry point) with a custom store is exempt, matching storeDir()'s own
46
+ // defaultStoreRefusal() reasoning one door over.
47
+ export function scratchStoreOnSharedSocket() {
48
+ if (privateTmuxSocket(process.env.TMUX, process.env.TMUX_TMPDIR))
49
+ return false;
50
+ if (!underTestRunner() && isProductEntryPoint())
51
+ return false;
52
+ try {
53
+ return !isDefaultStore(storeDir());
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ }
59
+ // Names whichever input tmuxSocketPath() actually decided on, not just TMUX_TMPDIR: TMUX's first
60
+ // field wins whenever it is set, so a TMUX_TMPDIR that is itself fine can be entirely irrelevant to
61
+ // why the socket resolved to the shared one, and blaming it anyway leaves the advice unable to clear
62
+ // the refusal.
63
+ function decidingTmuxInput() {
64
+ const inherited = process.env.TMUX?.split(",")[0];
65
+ if (inherited) {
66
+ return {
67
+ because: `an inherited TMUX names the shared socket directly (TMUX=${process.env.TMUX})`,
68
+ remedy: "Unset TMUX - it wins over TMUX_TMPDIR here, so changing TMUX_TMPDIR cannot clear this",
69
+ };
70
+ }
71
+ if (process.env.TMUX_TMPDIR) {
72
+ const dir = process.env.TMUX_TMPDIR;
73
+ return realpathOr(dir, null) === null
74
+ ? {
75
+ because: `TMUX_TMPDIR (${dir}) is set but unreachable`,
76
+ remedy: "Recreate that directory, or point TMUX_TMPDIR at a private one this process can reach",
77
+ }
78
+ : {
79
+ because: `TMUX_TMPDIR (${dir}) is reachable but resolves to the shared socket`,
80
+ remedy: "Point TMUX_TMPDIR at a private directory instead - it exists, so recreating it changes nothing",
81
+ };
82
+ }
83
+ return {
84
+ because: "TMUX_TMPDIR is unset",
85
+ remedy: "Set TMUX_TMPDIR to a private directory this process can reach",
86
+ };
87
+ }
88
+ function refuseIfSharedSocketFromScratchStore() {
89
+ if (!scratchStoreOnSharedSocket())
90
+ return;
91
+ const resolved = tmuxSocketPath(process.env.TMUX, process.env.TMUX_TMPDIR);
92
+ const { because, remedy } = decidingTmuxInput();
93
+ throw new Error(`Refusing to run tmux against the shared socket ${resolved}: HIVE_DATA_DIR is a scratch store ` +
94
+ `(${storeDir()}) and the resolved socket is the shared one because ${because}. ${remedy} - ` +
95
+ "rather than letting this call fall through to the server every other lead and worker on this " +
96
+ "machine depends on.");
97
+ }
98
+ export function tmux(...args) {
99
+ refuseIfSharedSocketFromScratchStore();
100
+ return tmuxWithin(tmuxTimeoutMs(), ...args);
101
+ }
102
+ function tmuxWithin(timeoutMs, ...args) {
103
+ try {
104
+ return execFileSync("tmux", args, {
105
+ encoding: "utf8",
106
+ stdio: ["ignore", "pipe", "pipe"],
107
+ maxBuffer: TMUX_MAX_BUFFER,
108
+ timeout: timeoutMs,
109
+ killSignal: "SIGKILL",
110
+ }).replace(/\n$/, "");
111
+ }
112
+ catch (e) {
113
+ const err = e;
114
+ if (err.code === "ENOENT") {
115
+ throw new TmuxError("tmux is not installed. Install it (brew install tmux) to use agent tools.", "", true);
116
+ }
117
+ const detail = typeof err.stderr === "string" ? err.stderr.trim() : err.stderr?.toString().trim();
118
+ if (err.code === "ETIMEDOUT") {
119
+ throw new TmuxTimeoutError(`tmux ${args[0]} did not answer within ${timeoutMs}ms and was killed. The tmux server may be ` +
120
+ "wedged; this says NOTHING about whether the target exists. Check for orphaned servers with " +
121
+ "`hive doctor`.", detail ?? "", timeoutMs);
122
+ }
123
+ throw new TmuxError(`tmux ${args[0]} failed${detail ? `: ${detail}` : ""}`, detail ?? "");
124
+ }
125
+ }
126
+ const NOTHING_THERE = /no server running|error connecting to|no current target|can't find (pane|window|session)/;
127
+ // "no server running" is tmux ANSWERING that the socket is empty. A missing binary is nobody
128
+ // answering at all, and the two must never read the same to anything that reports a death.
129
+ export function tmuxNotInstalled(e) {
130
+ return e instanceof TmuxError && e.notInstalled;
131
+ }
132
+ export function tmuxSaysNothingThere(e) {
133
+ if (e instanceof TmuxTimeoutError)
134
+ return false;
135
+ if (!(e instanceof TmuxError))
136
+ return false;
137
+ if (e.notInstalled)
138
+ return true;
139
+ return NOTHING_THERE.test(e.stderr);
140
+ }
141
+ function quietTmux(...args) {
142
+ try {
143
+ tmux(...args);
144
+ return true;
145
+ }
146
+ catch (e) {
147
+ if (e instanceof TmuxTimeoutError)
148
+ throw e;
149
+ return false;
150
+ }
151
+ }
152
+ export function crossServerRefusal(action) {
153
+ return new Error(`Refusing to ${action}: this process is talking to the tmux server at ` +
154
+ `${tmuxSocketPath(process.env.TMUX, process.env.TMUX_TMPDIR)}, but hive is using its default ` +
155
+ `store at ${DEFAULT_DATA_DIR}, whose sessions and panes live on ${defaultTmuxSocketPath()}. ` +
156
+ "Anything written here records a pane id from the wrong server, where it can name an " +
157
+ "unrelated live pane that agent_send would type into and agent_close would kill. Unset TMUX " +
158
+ "and TMUX_TMPDIR to use the shared server, or set HIVE_DATA_DIR to a scratch store to go " +
159
+ "with this one.");
160
+ }
161
+ export function envFlagKeys(envFlags) {
162
+ return envFlags.filter((_, i) => i % 2 === 1).map((pair) => pair.split("=")[0]);
163
+ }
164
+ // `new-session -e` sets the SESSION's environment, not the new pane's, and this session is shared by
165
+ // every project on the machine: see .claude/rules/tmux-and-panes.md.
166
+ function unsetAtSessionScope(name, envFlags) {
167
+ const keys = envFlagKeys(envFlags);
168
+ if (keys.length === 0)
169
+ return;
170
+ const argv = keys.flatMap((key, i) => (i === 0 ? [] : [";"]).concat(["set-environment", "-t", `=${name}`, "-u", key]));
171
+ try {
172
+ tmux(...argv);
173
+ }
174
+ catch {
175
+ // Swallowed because the session and its pane are already live: throwing here strands a running
176
+ // worker to tidy an environment. The cost is real - a failure leaves HIVE_PROJECT_LOCK and
177
+ // HIVE_PROJECT_PATH at session scope, silently, for that session's whole life.
178
+ }
179
+ }
180
+ export function ensureSession(name, cwd, initial) {
181
+ if (quietTmux("has-session", "-t", `=${name}`))
182
+ return { created: false };
183
+ if (untrustedTmuxServer())
184
+ throw crossServerRefusal("create a tmux session");
185
+ try {
186
+ const [pane, window] = tmux("new-session", "-d", "-P", "-F", "#{pane_id}\t#{session_name}:#{window_id}", "-s", name, "-c", cwd, ...(initial && "command" in initial ? [...initial.envFlags, initial.command] : [])).split("\t");
187
+ if (initial && "command" in initial)
188
+ unsetAtSessionScope(name, initial.envFlags);
189
+ return { created: true, pane, window };
190
+ }
191
+ catch (e) {
192
+ if (!isDuplicateSession(e))
193
+ throw e;
194
+ return { created: false };
195
+ }
196
+ }
197
+ export function isDuplicateSession(e) {
198
+ return e instanceof TmuxError && /duplicate session/.test(e.stderr);
199
+ }
200
+ export function claimInitialWindow(start, windowName, projectId) {
201
+ const { pane, window } = start;
202
+ configureHiveWindow(window, true, projectId);
203
+ tmux("rename-window", "-t", window, windowName);
204
+ return { pane, window };
205
+ }
206
+ export const SESSION_PREFIX = "hive-";
207
+ export const sessionName = () => `${SESSION_PREFIX}${dataDirTag()}main`;
208
+ export const viewSessionName = () => `${SESSION_PREFIX}${dataDirTag()}view-${process.pid}`;
209
+ function freeViewSessionName() {
210
+ const base = viewSessionName();
211
+ for (let n = 1; n < 1000; n++) {
212
+ const candidate = n === 1 ? base : `${base}-${n}`;
213
+ if (!quietTmux("has-session", "-t", `=${candidate}`))
214
+ return candidate;
215
+ }
216
+ throw new Error(`could not find a free view session name based on ${base} after 1000 attempts`);
217
+ }
218
+ export const isViewSessionName = (name) => /view-\d+(-\d+)?$/.test(name);
219
+ export const windowTitle = (projectName, name) => `${projectName} - ${name}`;
220
+ export const isPaneTarget = (target) => target.startsWith("%");
221
+ const DEFAULT_TMUX_TMPDIR = "/tmp";
222
+ const realpathOr = (path, fallback) => {
223
+ try {
224
+ return realpathSync(path);
225
+ }
226
+ catch {
227
+ return fallback;
228
+ }
229
+ };
230
+ const canonical = (path) => realpathOr(path, path) ?? path;
231
+ const socketUnder = (base) => join(canonical(base), `tmux-${process.getuid?.() ?? 0}`, "default");
232
+ function canonicalSocketPath(path) {
233
+ const uidDir = dirname(path);
234
+ const base = dirname(uidDir);
235
+ return join(canonical(base), basename(uidDir), basename(path));
236
+ }
237
+ export function tmuxSocketPath(tmux, tmuxTmpDir) {
238
+ const inherited = tmux?.split(",")[0];
239
+ if (inherited)
240
+ return canonicalSocketPath(inherited);
241
+ const reachable = tmuxTmpDir ? realpathOr(tmuxTmpDir, null) : null;
242
+ return socketUnder(reachable ?? DEFAULT_TMUX_TMPDIR);
243
+ }
244
+ export const defaultTmuxSocketPath = () => socketUnder(DEFAULT_TMUX_TMPDIR);
245
+ export const SCRATCH_SOCKET_PREFIX = "hive-tmux-";
246
+ export const ORPHAN_MIN_AGE_MS = 60 * 60 * 1000;
247
+ const ORPHAN_WARN_COUNT = 5;
248
+ export function orphansWorthWarningAbout(orphans) {
249
+ return orphans.wedged > 0 || orphans.live >= ORPHAN_WARN_COUNT;
250
+ }
251
+ const ORPHAN_PROBE_BUDGET_MS = 5000;
252
+ const ORPHAN_PROBE_TIMEOUT_MS = 2000;
253
+ export function orphanScratchServers(options = {}) {
254
+ try {
255
+ if (process.env.HIVE_ORPHAN_SCRATCH_JSON) {
256
+ return JSON.parse(process.env.HIVE_ORPHAN_SCRATCH_JSON);
257
+ }
258
+ }
259
+ catch {
260
+ }
261
+ const minAgeMs = options.minAgeMs ?? ORPHAN_MIN_AGE_MS;
262
+ const budgetMs = options.budgetMs ?? ORPHAN_PROBE_BUDGET_MS;
263
+ const now = Date.now();
264
+ let entries;
265
+ try {
266
+ entries = readdirSync(tmpdir());
267
+ }
268
+ catch {
269
+ return null;
270
+ }
271
+ const liveSocket = tmuxSocketPath(process.env.TMUX, process.env.TMUX_TMPDIR);
272
+ const result = {
273
+ candidates: 0,
274
+ aged: 0,
275
+ probed: 0,
276
+ live: 0,
277
+ wedged: 0,
278
+ oldestMs: null,
279
+ sockets: [],
280
+ entries: [],
281
+ };
282
+ const deadline = now + budgetMs;
283
+ const aged = [];
284
+ const base = canonical(tmpdir());
285
+ const uidDir = `tmux-${process.getuid?.() ?? 0}`;
286
+ for (const entry of entries) {
287
+ if (!entry.startsWith(SCRATCH_SOCKET_PREFIX))
288
+ continue;
289
+ const socket = join(base, entry, uidDir, "default");
290
+ if (socket === liveSocket)
291
+ continue;
292
+ let mtimeMs;
293
+ try {
294
+ mtimeMs = statSync(socket).mtimeMs;
295
+ }
296
+ catch {
297
+ continue;
298
+ }
299
+ result.candidates += 1;
300
+ const ageMs = now - mtimeMs;
301
+ if (ageMs < minAgeMs)
302
+ continue;
303
+ aged.push({ socket, ageMs });
304
+ }
305
+ result.aged = aged.length;
306
+ aged.sort((a, b) => b.ageMs - a.ageMs);
307
+ for (const candidate of aged) {
308
+ if (Date.now() > deadline)
309
+ break;
310
+ result.probed += 1;
311
+ let state;
312
+ try {
313
+ tmuxWithin(Math.min(ORPHAN_PROBE_TIMEOUT_MS, tmuxTimeoutMs()), "-S", candidate.socket, "list-sessions", "-F", "#{session_name}");
314
+ state = "live";
315
+ }
316
+ catch (e) {
317
+ state = e instanceof TmuxTimeoutError ? "wedged" : "gone";
318
+ }
319
+ if (state === "gone")
320
+ continue;
321
+ if (state === "live")
322
+ result.live += 1;
323
+ else
324
+ result.wedged += 1;
325
+ result.sockets.push(candidate.socket);
326
+ result.entries.push({ socket: candidate.socket, state, ageMs: candidate.ageMs });
327
+ result.oldestMs = Math.max(result.oldestMs ?? 0, candidate.ageMs);
328
+ }
329
+ return result;
330
+ }
331
+ export function privateTmuxSocket(tmux, tmuxTmpDir) {
332
+ return tmuxSocketPath(tmux, tmuxTmpDir) !== defaultTmuxSocketPath();
333
+ }
334
+ export function untrustedTmuxServer() {
335
+ if (!privateTmuxSocket(process.env.TMUX, process.env.TMUX_TMPDIR))
336
+ return false;
337
+ try {
338
+ return isDefaultStore(storeDir());
339
+ }
340
+ catch {
341
+ return true;
342
+ }
343
+ }
344
+ export function targetLive(target) {
345
+ return targetLiveProbe(target).live;
346
+ }
347
+ export function targetLiveProbe(target) {
348
+ if (target === "")
349
+ return { live: false, pid: null };
350
+ if (untrustedTmuxServer())
351
+ return { live: null, pid: null };
352
+ try {
353
+ const rows = tmux("list-panes", "-t", target, "-F", "#{pane_id} #{pane_pid}")
354
+ .split("\n")
355
+ .map((line) => line.split(" "));
356
+ const pid = isPaneTarget(target) ? (rows.find(([id]) => id === target)?.[1] ?? null) : null;
357
+ return { live: true, pid };
358
+ }
359
+ catch (e) {
360
+ return { live: tmuxSaysNothingThere(e) ? false : null, pid: null };
361
+ }
362
+ }
363
+ // Distinct from targetLiveProbe: a remain-on-exit pane the caller is deliberately holding open
364
+ // still LISTS (targetLiveProbe would call it live), but its process has exited and #{pane_dead}
365
+ // says so. Same classify-rather-than-guess shape as targetLiveProbe: a genuinely unreadable probe
366
+ // (a timeout, an untrusted server) answers null, never a guessed true or false.
367
+ //
368
+ // `list-panes -t <pane-id>` lists every pane in that pane's WINDOW, not just the one named - the
369
+ // same reason targetLiveProbe below filters rows by id instead of reading the first line.
370
+ export function paneProcessExited(target) {
371
+ if (untrustedTmuxServer())
372
+ return null;
373
+ try {
374
+ const rows = tmux("list-panes", "-t", target, "-F", "#{pane_id} #{pane_dead}")
375
+ .split("\n")
376
+ .map((line) => line.split(" "));
377
+ const match = rows.find(([id]) => id === target);
378
+ return match ? match[1] === "1" : true;
379
+ }
380
+ catch (e) {
381
+ return tmuxSaysNothingThere(e) ? true : null;
382
+ }
383
+ }
384
+ export function liveTargets() {
385
+ if (untrustedTmuxServer())
386
+ return null;
387
+ const snapshot = { panes: new Set(), windows: new Set(), pids: new Map(), serverAnswered: true };
388
+ try {
389
+ for (const line of tmux("list-panes", "-a", "-F", "#{pane_id} #{pane_pid} #{session_name}:#{window_id}").split("\n")) {
390
+ const [pane, pid, window] = line.split(" ");
391
+ if (pane)
392
+ snapshot.panes.add(pane);
393
+ if (pane && pid)
394
+ snapshot.pids.set(pane, pid);
395
+ if (window)
396
+ snapshot.windows.add(window);
397
+ }
398
+ }
399
+ catch (e) {
400
+ if (!tmuxSaysNothingThere(e))
401
+ return null;
402
+ snapshot.serverAnswered = !tmuxNotInstalled(e);
403
+ return snapshot;
404
+ }
405
+ return snapshot;
406
+ }
407
+ export function targetAlive(target, snapshot) {
408
+ return isPaneTarget(target) ? snapshot.panes.has(target) : snapshot.windows.has(target);
409
+ }
410
+ export function targetPid(target, snapshot) {
411
+ return snapshot.pids?.get(target) ?? null;
412
+ }
413
+ export function panePid(target) {
414
+ return targetLiveProbe(target).pid ?? "";
415
+ }
416
+ const DESTROY_READINESS_BOUND_MS = 200;
417
+ function paneHasEstablishedProcess(pid) {
418
+ try {
419
+ const status = execFileSync("ps", ["-o", "pgid=", "-o", "tty=", "-p", pid], {
420
+ encoding: "utf8",
421
+ stdio: ["ignore", "pipe", "ignore"],
422
+ timeout: 50,
423
+ killSignal: "SIGKILL",
424
+ }).trim();
425
+ const [pgid, tty] = status.split(/\s+/, 2);
426
+ return pgid === pid && !!tty && tty !== "?";
427
+ }
428
+ catch {
429
+ return false;
430
+ }
431
+ }
432
+ export function waitForPaneEstablished(target, timeoutMs = DESTROY_READINESS_BOUND_MS, probe = paneHasEstablishedProcess, pidLookup = panePid) {
433
+ let pid;
434
+ try {
435
+ pid = pidLookup(target).trim();
436
+ }
437
+ catch {
438
+ return;
439
+ }
440
+ if (!/^\d+$/.test(pid))
441
+ return;
442
+ const deadline = Date.now() + timeoutMs;
443
+ while (Date.now() < deadline) {
444
+ if (probe(pid))
445
+ return;
446
+ const remaining = deadline - Date.now();
447
+ if (remaining <= 0)
448
+ break;
449
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.min(10, remaining));
450
+ }
451
+ }
452
+ export function foreignSocket(recorded) {
453
+ return recorded !== "" && recorded !== tmuxSocketPath(process.env.TMUX, process.env.TMUX_TMPDIR);
454
+ }
455
+ export function rowLive(recordedSocket, target) {
456
+ return foreignSocket(recordedSocket) ? null : targetLive(target);
457
+ }
458
+ export function rowAlive(recordedSocket, target, snapshot) {
459
+ return foreignSocket(recordedSocket) ? null : targetAlive(target, snapshot);
460
+ }
461
+ export function rowLiveProbe(recordedSocket, target) {
462
+ return foreignSocket(recordedSocket) ? { live: null, pid: null } : targetLiveProbe(target);
463
+ }
464
+ export function rowAliveProbe(recordedSocket, target, snapshot) {
465
+ if (foreignSocket(recordedSocket))
466
+ return { live: null, pid: null };
467
+ const live = targetAlive(target, snapshot);
468
+ return { live, pid: live ? targetPid(target, snapshot) : null };
469
+ }
470
+ export function paneReissued(recordedPid, probe) {
471
+ return probe.live === true && recordedPid !== "" && probe.pid !== null && probe.pid !== recordedPid;
472
+ }
473
+ export const RAW_ATTACH_TMUX_CONFIG = [
474
+ "set -g allow-passthrough all",
475
+ ];
476
+ export const TMUX_DOC = "docs/tmux.md";
477
+ export function configureHiveWindow(window, created, projectId) {
478
+ try {
479
+ if (!created) {
480
+ const owned = tmux("list-panes", "-t", window, "-F", "#{@hive-owned}").split("\n")[0];
481
+ if (owned !== "1")
482
+ return;
483
+ }
484
+ tmux(...(created ? ["set-window-option", "-t", window, "@hive-owned", "1", ";"] : []), ...(created && projectId != null
485
+ ? ["set-window-option", "-t", window, "@hive-project-id", String(projectId), ";"]
486
+ : []), "set-window-option", "-t", window, "allow-passthrough", "all", ";", "set-window-option", "-t", window, "pane-border-status", "top", ";", "set-window-option", "-t", window, "pane-border-format", " #{pane_index} #{pane_title} ", ";", "set-window-option", "-t", window, "monitor-bell", "on", ";", "set-window-option", "-t", window, "window-size", "smallest");
487
+ }
488
+ catch {
489
+ }
490
+ }
491
+ // Both stamps in one list-windows: a window is found by a stamp and never by its name, and every
492
+ // caller that wants one of these wants to know about the other in the same breath.
493
+ export function listOwnedWindows(session) {
494
+ return tmux("list-windows", "-t", `=${session}`, "-F", `#{session_name}:#{window_id}\t#{@hive-project-id}\t#{${PROCESSES_WINDOW_OPTION}}`)
495
+ .split("\n")
496
+ .map((row) => {
497
+ const [window, projectId = "", processesOf = ""] = row.split("\t");
498
+ return { window, projectId, processesOf };
499
+ });
500
+ }
501
+ const ownsProject = (w, projectId) => Number(w.projectId) === projectId;
502
+ const holdsProcesses = (w, projectId) => w.processesOf !== "" && Number(w.processesOf) === projectId;
503
+ export function findProjectWindow(session, projectId) {
504
+ return listOwnedWindows(session).find((w) => ownsProject(w, projectId))?.window;
505
+ }
506
+ export function windowOwner(window) {
507
+ try {
508
+ const value = tmux("show-options", "-w", "-v", "-t", window, "@hive-project-id");
509
+ return value === "" ? null : Number(value);
510
+ }
511
+ catch {
512
+ return null;
513
+ }
514
+ }
515
+ // Its own stamp, never @hive-project-id: findProjectWindow takes the FIRST window carrying that one,
516
+ // so a processes window wearing it would be handed out as the project's window.
517
+ export const PROCESSES_WINDOW_OPTION = "@hive-processes-of";
518
+ export const processesWindowName = (projectName) => `${projectName}/processes`;
519
+ export const processesPaneTitle = (projectName, name) => `${processesWindowName(projectName)} · ${name}`;
520
+ export const PROCESSES_LAYOUT = "tiled";
521
+ export function findProcessesWindow(session, projectId) {
522
+ return listOwnedWindows(session).find((w) => holdsProcesses(w, projectId))?.window;
523
+ }
524
+ // The whole definition of a processes window, so the spawn path and hide's recreate path cannot
525
+ // drift: hive's own window options, its stamp, and the rename lock that keeps its name.
526
+ export function makeProcessesWindow(window, projectId) {
527
+ configureHiveWindow(window, true, null);
528
+ try {
529
+ tmux("set-window-option", "-t", window, PROCESSES_WINDOW_OPTION, String(projectId), ";", "set-window-option", "-t", window, "automatic-rename", "off");
530
+ }
531
+ catch {
532
+ }
533
+ }
534
+ // GLOBAL, appended: every narrower scope dies with its window (.claude/rules/tmux-and-panes.md).
535
+ export function appendGlobalHook(hook, command) {
536
+ try {
537
+ tmux("set-hook", "-ga", hook, command);
538
+ }
539
+ catch {
540
+ }
541
+ }
542
+ // tmux can only unset a hook's whole array, so replacing one entry means writing them all back.
543
+ export function replaceGlobalHooks(hook, commands) {
544
+ try {
545
+ tmux("set-hook", "-gu", hook);
546
+ for (const command of commands)
547
+ tmux("set-hook", "-ga", hook, command);
548
+ }
549
+ catch {
550
+ }
551
+ }
552
+ // Hooks live in the options table and read back as `pane-exited[0] <command>`, never from show-hooks.
553
+ export function globalHooks(hook) {
554
+ try {
555
+ const prefix = new RegExp(`^${hook}(\\[\\d+\\])? `);
556
+ return tmux("show-options", "-g", hook)
557
+ .split("\n")
558
+ .filter((row) => prefix.test(row))
559
+ .map((row) => row.replace(prefix, ""));
560
+ }
561
+ catch {
562
+ return [];
563
+ }
564
+ }
565
+ export const shownPaneTitle = (projectName, name) => `${projectName}/${name}`;
566
+ // Read once per command or dashboard tick, not once per process: the two lookups are the whole cost
567
+ // of deriving visibility, and paneVisibility below then costs one call per pane.
568
+ export function projectWindows(session, projectId) {
569
+ try {
570
+ const windows = listOwnedWindows(session);
571
+ return {
572
+ processes: windows.find((w) => holdsProcesses(w, projectId))?.window,
573
+ project: windows.find((w) => ownsProject(w, projectId))?.window,
574
+ };
575
+ }
576
+ catch (e) {
577
+ // Two different answers, and collapsing them makes a failed read look like a located pane: tmux
578
+ // saying there is no session is "no windows", undefined per window. A timeout, a wedged server
579
+ // or no tmux at all is nobody answering, and the caller must say it cannot tell.
580
+ return tmuxSaysNothingThere(e) && !tmuxNotInstalled(e) ? { processes: undefined, project: undefined } : null;
581
+ }
582
+ }
583
+ // Compared on the WINDOW ID alone: paneWindow answers with whichever grouped session tmux picks,
584
+ // so once a human is attached its session half is the view's name and never the base session's.
585
+ const sameWindow = (a, b) => a !== undefined && b !== undefined && a.split(":")[1] === b.split(":")[1];
586
+ export function paneVisibility(pane, windows) {
587
+ const current = paneWindow(pane);
588
+ if (!current)
589
+ return null;
590
+ if (sameWindow(current, windows.processes))
591
+ return "hidden";
592
+ if (sameWindow(current, windows.project))
593
+ return "shown";
594
+ return "window";
595
+ }
596
+ // `#{window_panes}` off the pane itself, so the caller needs no window id: break-pane on a lone pane
597
+ // is a rename in place, not a move, and hands back the window it started in.
598
+ export function paneIsAloneInWindow(pane) {
599
+ try {
600
+ const count = Number(tmux("display-message", "-p", "-t", pane, "#{window_panes}"));
601
+ return Number.isFinite(count) ? count <= 1 : null;
602
+ }
603
+ catch {
604
+ return null;
605
+ }
606
+ }
607
+ export function setPaneTitle(target, title) {
608
+ try {
609
+ tmux("select-pane", "-t", target, "-T", title);
610
+ }
611
+ catch {
612
+ }
613
+ }
614
+ export function adoptableWindow(session, projectId, pane) {
615
+ const paneWin = paneWindow(pane);
616
+ if (!paneWin)
617
+ return null;
618
+ const windowId = paneWin.split(":")[1];
619
+ const match = listOwnedWindows(session).find((w) => w.window.split(":")[1] === windowId);
620
+ if (!match)
621
+ return null;
622
+ const owner = match.projectId === "" ? null : Number(match.projectId);
623
+ if (owner !== null && owner !== projectId)
624
+ return null;
625
+ return `${session}:${windowId}`;
626
+ }
627
+ // Chained into the SAME tmux invocation that creates the pane, never issued as a follow-up call:
628
+ // a command that errors and exits can be gone before a second, separate call would run, and the
629
+ // only fact this option needs to reach is the first tick of the process's own life. See
630
+ // .claude/skills/hive-internals/references/tmux-and-panes.md for the measurement.
631
+ export function retainOnExitArgs(target) {
632
+ return [";", "set-window-option", "-t", target, "remain-on-exit", "on"];
633
+ }
634
+ export function setRemainOnExit(target, on) {
635
+ try {
636
+ tmux("set-window-option", "-t", target, "remain-on-exit", on ? "on" : "off");
637
+ }
638
+ catch {
639
+ // The pane may already be gone; nothing left to toggle.
640
+ }
641
+ }
642
+ // Captures a dead (remain-on-exit) pane's final content. capturePane's trailing-blank trim is the
643
+ // wrong shape here: a short-lived command's output sits near the top of a full-height pane, with
644
+ // tmux's own "Pane is dead" trailer as the last, non-blank line - so a plain tail-of-N reads as
645
+ // blank padding plus the trailer. Filtering ALL blank lines first, not just trailing ones, recovers
646
+ // the real content.
647
+ export function captureFinalScreen(target, lines) {
648
+ try {
649
+ const rows = captureRawPane(target, Math.max(lines * 10, 200))
650
+ .split("\n")
651
+ .filter((row) => row.trim() !== "");
652
+ return rows.slice(-lines).join("\n");
653
+ }
654
+ catch {
655
+ return "";
656
+ }
657
+ }
658
+ export function createWindow(session, windowName, cwd, envFlags, command, projectId, detach = false, retainOnExit = false) {
659
+ const created = tmux("new-window", ...(detach ? ["-d"] : []), "-P", "-F", "#{pane_id}\t#{session_name}:#{window_id}", "-t", `=${session}`, "-n", windowName, "-c", cwd, ...envFlags, command, ...(retainOnExit ? retainOnExitArgs(`=${session}:${windowName}`) : []));
660
+ const [pane, window] = created.split("\t");
661
+ configureHiveWindow(window, true, projectId);
662
+ return { pane, window };
663
+ }
664
+ function viewSessionChain(session, view) {
665
+ return [
666
+ "new-session", "-t", `=${session}`, "-s", view,
667
+ ";", "set-option", "-t", view, "destroy-unattached", "on",
668
+ ];
669
+ }
670
+ export function resolveAttachTarget(session, projectId, controlMode, known) {
671
+ const cc = controlMode ? ["-CC"] : [];
672
+ const window = known ?? findProjectWindow(session, projectId);
673
+ const view = freeViewSessionName();
674
+ return [
675
+ ...cc,
676
+ ...viewSessionChain(session, view),
677
+ ...(window ? [";", "select-window", "-t", `${view}:${window.split(":")[1]}`] : []),
678
+ ];
679
+ }
680
+ export function callerSession() {
681
+ try {
682
+ const attached = tmux("display-message", "-p", "#{client_session}");
683
+ if (attached)
684
+ return attached;
685
+ }
686
+ catch {
687
+ }
688
+ const sessionId = process.env.TMUX?.split(",")[2];
689
+ if (!sessionId)
690
+ return null;
691
+ try {
692
+ return tmux("display-message", "-p", "-t", `$${sessionId}`, "#{session_name}") || null;
693
+ }
694
+ catch {
695
+ return null;
696
+ }
697
+ }
698
+ export function windowIdsIn(session) {
699
+ try {
700
+ return tmux("list-windows", "-t", `=${session}`, "-F", "#{window_id}").split("\n").filter(Boolean);
701
+ }
702
+ catch {
703
+ return [];
704
+ }
705
+ }
706
+ export function resolveInTmuxTarget(session, window) {
707
+ const windowId = window?.split(":")[1];
708
+ if (!windowId)
709
+ return null;
710
+ const caller = callerSession();
711
+ if (caller && windowIdsIn(caller).includes(windowId)) {
712
+ return ["select-window", "-t", `${caller}:${windowId}`];
713
+ }
714
+ const view = freeViewSessionName();
715
+ return [
716
+ "new-session", "-d", "-t", `=${session}`, "-s", view,
717
+ ";", "select-window", "-t", `${view}:${windowId}`,
718
+ ";", "switch-client", "-t", view,
719
+ ";", "set-option", "-t", view, "destroy-unattached", "on",
720
+ ];
721
+ }
722
+ export function renderAttachCommand(argv) {
723
+ return argv
724
+ .map((token) => (token === ";" ? "';'" : token.startsWith("=") ? `'${token}'` : shellQuote(token)))
725
+ .join(" ");
726
+ }
727
+ export const WINDOW_LAYOUTS = [
728
+ "tiled",
729
+ "main-vertical",
730
+ "main-horizontal",
731
+ "even-horizontal",
732
+ "even-vertical",
733
+ ];
734
+ export const DEFAULT_LAYOUT = "tiled";
735
+ export const isWindowLayout = (value) => typeof value === "string" && WINDOW_LAYOUTS.includes(value);
736
+ const MAIN_PANE_OPTION = {
737
+ "main-vertical": { option: "main-pane-width", dimension: "#{window_width}" },
738
+ "main-horizontal": { option: "main-pane-height", dimension: "#{window_height}" },
739
+ };
740
+ function sizeMainPane(window, spec) {
741
+ try {
742
+ tmux("set-window-option", "-t", window, spec.option, "50%");
743
+ return;
744
+ }
745
+ catch {
746
+ }
747
+ const cells = Math.floor(Number(tmux("list-panes", "-t", window, "-F", spec.dimension).split("\n")[0]) / 2);
748
+ if (Number.isFinite(cells) && cells > 0) {
749
+ tmux("set-window-option", "-t", window, spec.option, String(cells));
750
+ }
751
+ }
752
+ export function applyLayout(window, layout) {
753
+ try {
754
+ const main = MAIN_PANE_OPTION[layout];
755
+ if (main)
756
+ sizeMainPane(window, main);
757
+ tmux("select-layout", "-t", window, layout);
758
+ tmux("set-window-option", "-t", window, "@hive-layout", layout);
759
+ }
760
+ catch {
761
+ }
762
+ }
763
+ export function windowLayout(window) {
764
+ try {
765
+ const value = tmux("list-panes", "-t", window, "-F", "#{@hive-layout}").split("\n")[0];
766
+ return isWindowLayout(value) ? value : null;
767
+ }
768
+ catch {
769
+ return null;
770
+ }
771
+ }
772
+ export function paneWindow(pane) {
773
+ try {
774
+ return tmux("list-panes", "-t", pane, "-F", "#{session_name}:#{window_id}").split("\n")[0] || null;
775
+ }
776
+ catch {
777
+ return null;
778
+ }
779
+ }
780
+ export function paneCurrentCommand(target) {
781
+ try {
782
+ return tmux("display-message", "-p", "-t", target, "#{pane_current_command}");
783
+ }
784
+ catch {
785
+ return null;
786
+ }
787
+ }
788
+ export function paneTitle(target) {
789
+ try {
790
+ return tmux("display-message", "-p", "-t", target, "#{pane_title}");
791
+ }
792
+ catch {
793
+ return null;
794
+ }
795
+ }
796
+ export function captureRawPane(target, lines) {
797
+ return tmux("capture-pane", "-p", "-t", target, "-S", `-${lines}`);
798
+ }
799
+ export function tailWindow(raw, lines) {
800
+ const rows = raw.split("\n");
801
+ while (rows.length > 0 && rows[rows.length - 1].trim() === "")
802
+ rows.pop();
803
+ return rows.slice(-lines).join("\n");
804
+ }
805
+ export function capturePane(target, lines) {
806
+ return tailWindow(captureRawPane(target, lines), lines);
807
+ }
808
+ const PROMPT_GLYPH_NBSP = "❯ ";
809
+ const SGR_ESCAPE = /\x1b\[[0-9;]*m/g;
810
+ const stripSgr = (s) => s.replace(SGR_ESCAPE, "");
811
+ function leadingRunIsFaint(s) {
812
+ const leadingRun = /^(?:\x1b\[[0-9;]*m)*/.exec(s)?.[0] ?? "";
813
+ let faint = false;
814
+ for (const seq of leadingRun.match(SGR_ESCAPE) ?? []) {
815
+ const params = seq.slice(2, -1).split(";").filter((p) => p !== "");
816
+ if (params.length === 0) {
817
+ faint = false;
818
+ continue;
819
+ }
820
+ for (const p of params) {
821
+ if (p === "0")
822
+ faint = false;
823
+ else if (p === "2")
824
+ faint = true;
825
+ else if (p === "22")
826
+ faint = false;
827
+ }
828
+ }
829
+ return faint;
830
+ }
831
+ export const holdsHumanInput = (box) => box?.state === "pending";
832
+ const BOX_BORDER = /^─+$/;
833
+ const BOX_TOP_BORDER = /^─{4,}/;
834
+ const BOX_TAIL_ROWS = 8;
835
+ const BOX_MAX_ROWS = 24;
836
+ function findInputBox(rows) {
837
+ const text = rows.map((row) => stripControlBytes(stripSgr(row)).trim());
838
+ let end = text.length - 1;
839
+ while (end >= 0 && text[end] === "")
840
+ end -= 1;
841
+ if (end < 0)
842
+ return null;
843
+ let bottom = -1;
844
+ for (let i = end; i >= 0 && end - i <= BOX_TAIL_ROWS; i--) {
845
+ if (BOX_BORDER.test(text[i])) {
846
+ bottom = i;
847
+ break;
848
+ }
849
+ }
850
+ if (bottom < 0)
851
+ return null;
852
+ while (bottom > 0 && BOX_BORDER.test(text[bottom - 1]))
853
+ bottom -= 1;
854
+ let top = -1;
855
+ for (let i = bottom - 1; i >= 0 && bottom - i <= BOX_MAX_ROWS; i--) {
856
+ if (BOX_TOP_BORDER.test(text[i])) {
857
+ top = i;
858
+ break;
859
+ }
860
+ }
861
+ if (top < 0)
862
+ return null;
863
+ let prompt = null;
864
+ for (let i = bottom - 1; i > top; i--) {
865
+ if (rows[i].includes(PROMPT_GLYPH_NBSP)) {
866
+ prompt = i;
867
+ break;
868
+ }
869
+ }
870
+ return { top, bottom, prompt };
871
+ }
872
+ const inputBoxOnScreen = (screen) => findInputBox(screen.split("\n"))?.prompt != null;
873
+ function classifyInputBox(rows, promptRowIndex, boxBottom) {
874
+ const promptRow = rows[promptRowIndex];
875
+ const after = promptRow.slice(promptRow.indexOf(PROMPT_GLYPH_NBSP) + PROMPT_GLYPH_NBSP.length);
876
+ const dim = leadingRunIsFaint(after);
877
+ const firstLine = stripControlBytes(stripSgr(after)).trim();
878
+ const continuation = [];
879
+ for (let i = promptRowIndex + 1; i < boxBottom; i++) {
880
+ continuation.push(stripControlBytes(stripSgr(rows[i])).trim());
881
+ }
882
+ const text = [firstLine, ...continuation]
883
+ .filter((line) => line !== "")
884
+ .join(" ")
885
+ .slice(0, TAIL_LINE_CHARS);
886
+ return { state: text === "" ? "empty" : dim ? "ghost" : "pending", text };
887
+ }
888
+ export function inputBoxState(target) {
889
+ try {
890
+ const raw = tmux("capture-pane", "-p", "-e", "-t", target, "-S", `-${tailCaptureLines()}`);
891
+ const rows = raw.split("\n");
892
+ const box = findInputBox(rows);
893
+ if (box === null)
894
+ return null;
895
+ return box.prompt === null ? { state: "unknown", text: "" } : classifyInputBox(rows, box.prompt, box.bottom);
896
+ }
897
+ catch {
898
+ return null;
899
+ }
900
+ }
901
+ const CHOICE_DIALOG = /Esc to cancel|ctrl\+g to edit in/;
902
+ const isAwaitingChoiceScreen = (tail, wide) => CHOICE_DIALOG.test(tail) && !inputBoxOnScreen(wide);
903
+ export const paneHasInputBox = (target) => {
904
+ try {
905
+ return inputBoxOnScreen(capturePane(target, tailCaptureLines()));
906
+ }
907
+ catch {
908
+ return null;
909
+ }
910
+ };
911
+ export const screenAwaitingChoice = (screen) => isAwaitingChoiceScreen(screen, screen);
912
+ // tmux clears the pane's bracketed-paste flag in copy mode, so paste-buffer -p
913
+ // sends no markers there and the Enter after it never reaches the application.
914
+ export function paneInCopyMode(target) {
915
+ try {
916
+ return tmux("display-message", "-p", "-t", target, "#{pane_in_mode}").trim() === "1";
917
+ }
918
+ catch {
919
+ return null;
920
+ }
921
+ }
922
+ // `-X` must come before `-t`, never after a `--`, which would type it as a key.
923
+ export function cancelCopyMode(target) {
924
+ if (paneInCopyMode(target) !== true)
925
+ return false;
926
+ try {
927
+ tmux("send-keys", "-X", "-t", target, "cancel");
928
+ return true;
929
+ }
930
+ catch {
931
+ return false;
932
+ }
933
+ }
934
+ export function paneAwaitingChoice(target) {
935
+ try {
936
+ const raw = captureRawPane(target, tailCaptureLines());
937
+ return isAwaitingChoiceScreen(tailWindow(raw, tailCaptureLines()), raw);
938
+ }
939
+ catch {
940
+ return null;
941
+ }
942
+ }
943
+ const TERMINAL_CONTROL_BYTES = /[\u0000-\u0009\u000B-\u001F\u007F]/g;
944
+ export const stripControlBytes = (s) => s.replace(TERMINAL_CONTROL_BYTES, "");
945
+ const TAIL_LINES = 6;
946
+ const TAIL_LINE_CHARS = 160;
947
+ export function sanitizeTail(raw) {
948
+ return raw
949
+ .split("\n")
950
+ .map((line) => stripControlBytes(line).trimEnd().slice(0, TAIL_LINE_CHARS))
951
+ .filter((line) => line !== "")
952
+ .slice(-TAIL_LINES)
953
+ .join("\n");
954
+ }
955
+ const EVENT_DISPLAY_CHARS = 20;
956
+ export function sanitizeEventForDisplay(event) {
957
+ const clean = stripControlBytes(event).replace(/[\r\n]+/g, " ");
958
+ return clean.length > EVENT_DISPLAY_CHARS ? `${clean.slice(0, EVENT_DISPLAY_CHARS)}[truncated]` : clean;
959
+ }
960
+ export const tailCaptureLines = () => TAIL_LINES * 3;
961
+ export function paneChoiceCheck(target) {
962
+ try {
963
+ const raw = captureRawPane(target, tailCaptureLines());
964
+ const tail = tailWindow(raw, tailCaptureLines());
965
+ return { awaitingChoice: isAwaitingChoiceScreen(tail, raw), tail: sanitizeTail(tail) };
966
+ }
967
+ catch {
968
+ return { awaitingChoice: null, tail: "" };
969
+ }
970
+ }
971
+ // codex's chrome, mirroring the claude section above but never sharing its regexes (see hive-internals).
972
+ // codex has no box border to anchor on, so the box's bottom edge used to be inferred from the literal
973
+ // text of its footer status line - but that text is not stable (todo 524: two different compositions
974
+ // seen on one codex version, one with no shared substring at all). The anchor below reads structure
975
+ // instead of content: the pane's own last non-blank row IS the bottom edge, whatever it says.
976
+ // codex's own choice-menu shape (see hive-internals), not claude's CHOICE_DIALOG wording. Declared
977
+ // above findCodexPromptBox because the box search itself now needs it to reject a choice row rather
978
+ // than mistake it for a live prompt.
979
+ const CODEX_CHOICE_LINE = /^›\s*\d+\.\s/m;
980
+ // codex wraps the arrow in its own SGR reset, so its content's real styling doesn't start at offset 0;
981
+ // the arrow and its one trailing space have to be walked off before leadingRunIsFaint can read it.
982
+ function codexPromptContentStart(promptRow) {
983
+ const arrow = promptRow.indexOf("›");
984
+ if (arrow < 0)
985
+ return promptRow.length;
986
+ let i = arrow + 1;
987
+ let escape;
988
+ while ((escape = /^\x1b\[[0-9;]*m/.exec(promptRow.slice(i))) !== null)
989
+ i += escape[0].length;
990
+ if (promptRow[i] === " ")
991
+ i += 1;
992
+ return i;
993
+ }
994
+ function findCodexPromptBox(rows) {
995
+ const text = rows.map((row) => stripControlBytes(stripSgr(row)).trim());
996
+ let footer = text.length - 1;
997
+ while (footer >= 0 && text[footer] === "")
998
+ footer -= 1;
999
+ if (footer < 0)
1000
+ return null;
1001
+ let prompt = -1;
1002
+ for (let i = footer - 1; i >= 0 && footer - i <= BOX_MAX_ROWS; i--) {
1003
+ // The arrow ALONE, against the trimmed row: capture-pane strips a trailing space, and a box with
1004
+ // nothing typed is exactly "› " with nothing after it - "› " (with the space) would never match
1005
+ // its own genuinely-empty case, only the ones with real content following the arrow.
1006
+ if (text[i].startsWith("›")) {
1007
+ prompt = i;
1008
+ break;
1009
+ }
1010
+ }
1011
+ if (prompt < 0)
1012
+ return null;
1013
+ // A choice row also starts with "›" - stop here rather than skip past it and keep climbing, or an
1014
+ // older, already-submitted prompt sitting further up scrollback gets mistaken for the live box
1015
+ // (measured against codex-sandbox-approval-dialog.txt: its own choice row sits 21 lines below a
1016
+ // stale submitted prompt that also starts with "›").
1017
+ if (CODEX_CHOICE_LINE.test(text[prompt]))
1018
+ return null;
1019
+ // Nothing but the prompt's own continuation, then one blank gap, may sit between the prompt row and
1020
+ // the footer. Real content in that gap means the "›" found above is stale scrollback, not the live
1021
+ // box - a busy/mid-turn screen can have an old prompt sitting within BOX_MAX_ROWS of whatever text
1022
+ // is currently at the bottom, and only this check tells the two apart (see hive-internals).
1023
+ let i = prompt + 1;
1024
+ while (i <= footer && text[i] !== "")
1025
+ i += 1;
1026
+ while (i <= footer && text[i] === "")
1027
+ i += 1;
1028
+ if (i !== footer)
1029
+ return null;
1030
+ return { footer, prompt };
1031
+ }
1032
+ const codexInputBoxOnScreen = (screen) => findCodexPromptBox(screen.split("\n")) !== null;
1033
+ function classifyCodexInputBox(rows, promptRowIndex, footerRowIndex) {
1034
+ const promptRow = rows[promptRowIndex];
1035
+ const after = promptRow.slice(codexPromptContentStart(promptRow));
1036
+ const dim = leadingRunIsFaint(after);
1037
+ const firstLine = stripControlBytes(stripSgr(after)).trim();
1038
+ const continuation = [];
1039
+ for (let i = promptRowIndex + 1; i < footerRowIndex; i++) {
1040
+ continuation.push(stripControlBytes(stripSgr(rows[i])).trim());
1041
+ }
1042
+ const text = [firstLine, ...continuation]
1043
+ .filter((line) => line !== "")
1044
+ .join(" ")
1045
+ .slice(0, TAIL_LINE_CHARS);
1046
+ return { state: text === "" ? "empty" : dim ? "ghost" : "pending", text };
1047
+ }
1048
+ export function codexInputBoxState(target) {
1049
+ try {
1050
+ const raw = tmux("capture-pane", "-p", "-e", "-t", target, "-S", `-${tailCaptureLines()}`);
1051
+ const rows = raw.split("\n");
1052
+ const box = findCodexPromptBox(rows);
1053
+ return box === null ? null : classifyCodexInputBox(rows, box.prompt, box.footer);
1054
+ }
1055
+ catch {
1056
+ return null;
1057
+ }
1058
+ }
1059
+ export const codexPaneHasInputBox = (target) => {
1060
+ try {
1061
+ return codexInputBoxOnScreen(capturePane(target, tailCaptureLines()));
1062
+ }
1063
+ catch {
1064
+ return null;
1065
+ }
1066
+ };
1067
+ // codexInputBoxOnScreen used to be broken (todo 524); this line still read correctly, but only because
1068
+ // CODEX_CHOICE_LINE was independently false on the pane that exposed the bug - correct by coincidence,
1069
+ // not by construction. See hive-internals for why that distinction matters to a later refactor.
1070
+ const codexScreenAwaitingChoice = (screen) => CODEX_CHOICE_LINE.test(screen) && !codexInputBoxOnScreen(screen);
1071
+ const CODEX_SPINNER = /^[⠀-⣿]/;
1072
+ // The bracket marker is measured-unstable (see hive-internals) - match the substring alone.
1073
+ const CODEX_ACTION_REQUIRED = "Action Required";
1074
+ // A silent title (codex hasn't written one yet, e.g. a startup dialog) is not "idle" - see hive-internals.
1075
+ function codexAwaitingChoiceFromTitleAndScreen(title, screenTail) {
1076
+ if (title !== null) {
1077
+ // Untested combination, so pick the cheap-to-be-wrong direction: a false "blocked" costs a
1078
+ // delay, a false "busy, safe to type" is the thing this whole predicate exists to prevent.
1079
+ if (title.includes(CODEX_ACTION_REQUIRED))
1080
+ return true;
1081
+ if (CODEX_SPINNER.test(title))
1082
+ return false;
1083
+ }
1084
+ return codexScreenAwaitingChoice(screenTail);
1085
+ }
1086
+ export function codexPaneChoiceCheck(target) {
1087
+ try {
1088
+ const raw = captureRawPane(target, tailCaptureLines());
1089
+ const tail = tailWindow(raw, tailCaptureLines());
1090
+ return {
1091
+ awaitingChoice: codexAwaitingChoiceFromTitleAndScreen(paneTitle(target), tail),
1092
+ tail: sanitizeTail(tail),
1093
+ };
1094
+ }
1095
+ catch {
1096
+ return { awaitingChoice: null, tail: "" };
1097
+ }
1098
+ }
1099
+ export function describePaneChoice(awaitingChoice) {
1100
+ if (awaitingChoice === true)
1101
+ return "awaiting a choice (dialog)";
1102
+ if (awaitingChoice === false)
1103
+ return "no dialog";
1104
+ return "could not be read";
1105
+ }
1106
+ export function withGlobalFlag(re) {
1107
+ const flags = re.flags.includes("g") ? re.flags : `${re.flags}g`;
1108
+ return new RegExp(re.source, flags);
1109
+ }
1110
+ export function maskChoiceMarker(text) {
1111
+ return text.replace(withGlobalFlag(CHOICE_DIALOG), "[dialog marker masked]");
1112
+ }
1113
+ export function shellQuote(s) {
1114
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(s))
1115
+ return s;
1116
+ return `'${s.replaceAll("'", `'\\''`)}'`;
1117
+ }
1118
+ export function controlModeFor(iTermDetected) {
1119
+ const mode = attachMode();
1120
+ if (mode === "raw")
1121
+ return false;
1122
+ if (mode === "control")
1123
+ return true;
1124
+ return iTermDetected;
1125
+ }
1126
+ export function attachScripts(tmuxPath, session) {
1127
+ const cc = controlModeFor(true) ? ["-CC"] : [];
1128
+ const argv = viewSessionChain(session, freeViewSessionName());
1129
+ const quotedTmuxPath = shellQuote(tmuxPath);
1130
+ return [
1131
+ `tell application "iTerm" to create window with default profile command "${quotedTmuxPath} ${renderAttachCommand([...cc, ...argv])}"`,
1132
+ `tell application "Terminal" to do script "${quotedTmuxPath} ${renderAttachCommand(argv)}"`,
1133
+ ];
1134
+ }
1135
+ export function autoAttachProbe(value, session) {
1136
+ return value === "on" ? ["list-clients", "-t", `=${session}`] : ["list-clients"];
1137
+ }
1138
+ export function ensureAttached(session) {
1139
+ if (process.platform !== "darwin")
1140
+ return;
1141
+ if (privateTmuxSocket(process.env.TMUX, process.env.TMUX_TMPDIR))
1142
+ return;
1143
+ const { value } = resolvedAutoAttach();
1144
+ if (value === "off")
1145
+ return;
1146
+ try {
1147
+ if (tmux(...autoAttachProbe(value, session)).trim() !== "")
1148
+ return;
1149
+ }
1150
+ catch {
1151
+ return;
1152
+ }
1153
+ let tmuxPath;
1154
+ try {
1155
+ tmuxPath = execFileSync("which", ["tmux"], { encoding: "utf8" }).trim();
1156
+ }
1157
+ catch {
1158
+ return;
1159
+ }
1160
+ let scripts;
1161
+ try {
1162
+ scripts = attachScripts(tmuxPath, session);
1163
+ }
1164
+ catch {
1165
+ return;
1166
+ }
1167
+ for (const script of scripts) {
1168
+ try {
1169
+ execFileSync("osascript", ["-e", script], { stdio: "ignore", timeout: 8000 });
1170
+ return;
1171
+ }
1172
+ catch {
1173
+ }
1174
+ }
1175
+ }
1176
+ export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1177
+ function defaultHasInputBox(target) {
1178
+ try {
1179
+ return inputBoxOnScreen(capturePane(target, 30));
1180
+ }
1181
+ catch {
1182
+ return null;
1183
+ }
1184
+ }
1185
+ export async function pollPaneReadiness(target, timeoutMs, hasInputBox = defaultHasInputBox) {
1186
+ const start = Date.now();
1187
+ const deadline = start + timeoutMs;
1188
+ const interval = () => (Date.now() - start < 1000 ? 200 : 500);
1189
+ while (Date.now() < deadline) {
1190
+ // paneProcessExited is the SOLE source of truth for "gone". hasInputBox's own null (its
1191
+ // contract: a genuinely unreadable probe - a timeout, an untrusted server - never a guessed
1192
+ // true or false) must never be read as "exited" on its own: a caller retaining the pane on
1193
+ // exit (remain-on-exit) keeps it LISTED, so a pane that is merely hard to read right now
1194
+ // still answers false here, not true, and falls through to another poll like any other
1195
+ // not-ready tick. A pane genuinely destroyed with no remain-on-exit in play is already caught
1196
+ // above, on this same iteration, before hasInputBox is even consulted.
1197
+ if (paneProcessExited(target) === true)
1198
+ return "gone";
1199
+ if (hasInputBox(target) === true) {
1200
+ await sleep(250);
1201
+ return "ready";
1202
+ }
1203
+ await sleep(interval());
1204
+ }
1205
+ return "timeout";
1206
+ }
1207
+ export async function waitForPaneInput(target, timeoutMs, hasInputBox = defaultHasInputBox) {
1208
+ return (await pollPaneReadiness(target, timeoutMs, hasInputBox)) === "ready";
1209
+ }
1210
+ let bufferSeq = 0;
1211
+ const nextBufferName = () => `hive-input-${process.pid}-${++bufferSeq}`;
1212
+ export const ENTER_DELAY_MS = 300;
1213
+ export const TEXT_ALLOWED_CONTROL_CHARS = new Set(["\t", "\n"]);
1214
+ const CONTROL_CHAR_NAMES = {
1215
+ 0: "NUL",
1216
+ 1: "SOH",
1217
+ 2: "STX",
1218
+ 3: "ETX (Ctrl-C)",
1219
+ 4: "EOT (Ctrl-D)",
1220
+ 5: "ENQ",
1221
+ 6: "ACK",
1222
+ 7: "BEL",
1223
+ 8: "BS",
1224
+ 11: "VT",
1225
+ 12: "FF",
1226
+ 13: "CR",
1227
+ 14: "SO",
1228
+ 15: "SI",
1229
+ 16: "DLE",
1230
+ 17: "DC1",
1231
+ 18: "DC2",
1232
+ 19: "DC3",
1233
+ 20: "DC4",
1234
+ 21: "NAK",
1235
+ 22: "SYN",
1236
+ 23: "ETB",
1237
+ 24: "CAN",
1238
+ 25: "EM",
1239
+ 26: "SUB (Ctrl-Z)",
1240
+ 27: "ESC",
1241
+ 28: "FS",
1242
+ 29: "GS",
1243
+ 30: "RS",
1244
+ 31: "US",
1245
+ 127: "DEL",
1246
+ };
1247
+ export function findUnsafeControlChar(text, allowed) {
1248
+ for (let i = 0; i < text.length; i++) {
1249
+ const ch = text[i];
1250
+ if (allowed.has(ch))
1251
+ continue;
1252
+ const code = text.charCodeAt(i);
1253
+ if (code <= 0x1f || code === 0x7f) {
1254
+ const name = CONTROL_CHAR_NAMES[code];
1255
+ const hex = `0x${code.toString(16).padStart(2, "0").toUpperCase()}`;
1256
+ return { code, index: i, label: name ? `${name}, ${hex}` : hex };
1257
+ }
1258
+ }
1259
+ return null;
1260
+ }
1261
+ export async function sendText(target, text, submit = true, onPasted, onBuffered) {
1262
+ // tmux hands a pane its input in 1022-byte writes, and only paste-buffer -p
1263
+ // brackets them, so send-keys -l loses everything before the last write.
1264
+ // An empty text skips the pair: set-buffer creates no buffer for it, so
1265
+ // paste-buffer would then fail and the Enter would never be sent.
1266
+ if (text !== "") {
1267
+ const buffer = nextBufferName();
1268
+ // set-buffer names no pane, so its failure cannot have put anything on a
1269
+ // screen. Callers tell that from a failed paste by whether onBuffered ran.
1270
+ tmux("set-buffer", "-b", buffer, "--", text);
1271
+ onBuffered?.();
1272
+ try {
1273
+ tmux("paste-buffer", "-d", "-p", "-b", buffer, "-t", target);
1274
+ }
1275
+ catch (err) {
1276
+ // paste-buffer's -d never ran, and nothing ever reclaims a NAMED buffer:
1277
+ // buffer-limit trims only automatic ones.
1278
+ try {
1279
+ tmux("delete-buffer", "-b", buffer);
1280
+ }
1281
+ catch {
1282
+ }
1283
+ throw err;
1284
+ }
1285
+ }
1286
+ try {
1287
+ onPasted?.();
1288
+ }
1289
+ catch {
1290
+ }
1291
+ if (submit) {
1292
+ await sleep(ENTER_DELAY_MS);
1293
+ tmux("send-keys", "-t", target, "Enter");
1294
+ }
1295
+ }