golem-kit 0.1.0 → 0.2.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 (61) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +11 -6
  3. package/docs/agents.md +64 -0
  4. package/docs/app-backend.md +259 -0
  5. package/docs/architecture.md +93 -0
  6. package/docs/builder.md +15 -0
  7. package/docs/knowledge.md +35 -0
  8. package/docs/local-cli.md +31 -15
  9. package/docs/source-development.md +31 -0
  10. package/index.html +9 -0
  11. package/package.json +24 -5
  12. package/src/backend/accounts.ts +287 -0
  13. package/src/backend/app.ts +269 -0
  14. package/src/backend/files.ts +68 -0
  15. package/src/backend/http.ts +276 -0
  16. package/src/backend/index.ts +10 -0
  17. package/src/backend/jobs.ts +302 -0
  18. package/src/backend/jsonl.ts +87 -0
  19. package/src/backend/knowledge.ts +264 -0
  20. package/src/backend/model.ts +129 -0
  21. package/src/backend/rules.ts +53 -0
  22. package/src/backend/sqlite.ts +73 -0
  23. package/src/backend/views.ts +216 -0
  24. package/src/brain.ts +94 -0
  25. package/src/browser/adapters.ts +229 -53
  26. package/src/browser/ansi.ts +104 -0
  27. package/src/browser/app.d.ts +5 -2
  28. package/src/browser/app.tsx +167 -39
  29. package/src/browser/groups.tsx +29 -0
  30. package/src/browser/main.tsx +1 -0
  31. package/src/browser/panekeys.ts +34 -0
  32. package/src/browser/sources.tsx +113 -0
  33. package/src/browser/styles.css +36 -0
  34. package/src/browser/terminal.tsx +89 -0
  35. package/src/browser-build.ts +20 -7
  36. package/src/chat.ts +74 -0
  37. package/src/cli.ts +91 -17
  38. package/src/client.ts +205 -0
  39. package/src/config.ts +169 -0
  40. package/src/dev-server.ts +339 -38
  41. package/src/entry.mjs +19 -0
  42. package/src/eslint.mjs +55 -0
  43. package/src/operations.ts +169 -0
  44. package/src/runtime/assistant.ts +141 -0
  45. package/src/runtime/discovery.ts +13 -7
  46. package/src/runtime/harness/agent-status.js +388 -0
  47. package/src/runtime/harness/claude-tmux.js +573 -0
  48. package/src/runtime/harness/codex-notify.js +95 -0
  49. package/src/runtime/harness/codex-tmux.js +292 -0
  50. package/src/runtime/harness/fake.js +430 -0
  51. package/src/runtime/harness/package.json +1 -0
  52. package/src/runtime/harness/port.js +208 -0
  53. package/src/runtime/harness/tmux-session.js +556 -0
  54. package/src/runtime/harness/tmux.js +285 -0
  55. package/src/runtime/harness/turnend-hook.js +105 -0
  56. package/src/runtime/session.ts +171 -34
  57. package/src/runtime/tmux.ts +173 -0
  58. package/src/runtime/tool-names.ts +19 -0
  59. package/src/source-mode.ts +56 -0
  60. package/vite.config.ts +2 -4
  61. package/src/runtime/codex.ts +0 -119
@@ -0,0 +1,556 @@
1
+ // Vendored from bridge-commander 5bf87e4b harness/tmux-session.js (zero-dependency). Local patches are marked 'golem:'.
2
+ 'use strict';
3
+ // tmux-session — session/window/pane plumbing SHARED by the tmux-TUI harness
4
+ // adapters (claude-tmux.js, codex-tmux.js). Everything here is harness-agnostic:
5
+ // pane lifecycle, naming/validation, state-dir resolution, the launch-and-settle
6
+ // skeleton (the adapter supplies its trust-prompt and UI-ready signatures), the
7
+ // turn-end file tail, and the optional pane-viewing verbs. An adapter differs
8
+ // only in its launch line, screen signatures, resume semantics, and turn-end
9
+ // relay wiring.
10
+ //
11
+ // Extracted verbatim from claude-tmux.js (the reference implementation) — the
12
+ // comments below carry that provenance where behavior was learned the hard way.
13
+
14
+ const fs = require('node:fs');
15
+ const os = require('node:os');
16
+ const path = require('node:path');
17
+ const crypto = require('node:crypto');
18
+ const t = require('./tmux.js');
19
+ const { validatePaneInput } = require('./port.js');
20
+
21
+ // A pane sitting back at a bare shell means the agent process exited.
22
+ const SHELLS = new Set(['bash', 'zsh', 'sh', 'fish', 'dash', 'ksh']);
23
+
24
+ // State dir resolution: opts.stateDir (the server/CLI always pass the
25
+ // workspace's .bridge-commander/harness), then BC_HARNESS_STATE, then a global
26
+ // last-resort for bare embedders only — shared across workspaces, so never
27
+ // rely on it from workspace-aware callers.
28
+ function stateDirOf(opts = {}) {
29
+ const dir = opts.stateDir || process.env.BC_HARNESS_STATE
30
+ || path.join(os.homedir(), '.bridge-commander', 'harness');
31
+ fs.mkdirSync(dir, { recursive: true });
32
+ return dir;
33
+ }
34
+
35
+ // The extra launch flags a spawn was given, kept next to the other per-agent
36
+ // state files (<key>.prompt, <key>.session-id, <key>.turnend.jsonl) so a RESUME
37
+ // can replay them.
38
+ //
39
+ // Those flags are not decoration. The server pins a worker's --model/--effort
40
+ // from its playbook at spawn, and every resume path built its own launch line
41
+ // without them — so a worker pinned to a model came back on the default one,
42
+ // silently, with nobody told. The oldest of those paths is `card start --resume`
43
+ // after a death, which is exactly the moment the pinning matters most.
44
+ //
45
+ // Recorded here rather than in each adapter because both tmux adapters take the
46
+ // same `extraArgs` and both rebuild a launch line on resume; one copy of the
47
+ // decision is one place for it to stay true.
48
+ //
49
+ // A missing, unreadable or corrupt record reads as "no extra flags" — today's
50
+ // behaviour — and never throws: a resume that cannot read a hint must still
51
+ // resume. A spawn with no extra flags REMOVES any stale record, so a card
52
+ // restarted onto a different model does not inherit the last run's.
53
+ function spawnArgsFile(stateDir, key) {
54
+ return path.join(stateDir, `${key}.spawn-args`);
55
+ }
56
+ // The launch facts a resume has to replay, taken straight off the spawn's opts:
57
+ // the extra flags (--model/--effort, pinned by the card's playbook) and the
58
+ // caller's allowRoot consent (the IS_SANDBOX=1 prefix without which claude
59
+ // refuses to come back as uid 0). Written as an object; a bare array is the
60
+ // older record's shape and still reads as flags-only.
61
+ function recordSpawnArgs(stateDir, key, opts = {}) {
62
+ const file = spawnArgsFile(stateDir, key);
63
+ try {
64
+ const rec = { args: (opts.extraArgs || []).map(String) };
65
+ if (opts.allowRoot) rec.allowRoot = true;
66
+ if (opts.permissions) rec.permissions = String(opts.permissions); // golem: launch profile, replayed on resume
67
+ if (rec.args.length || rec.allowRoot || rec.permissions) fs.writeFileSync(file, JSON.stringify(rec) + '\n');
68
+ else fs.rmSync(file, { force: true });
69
+ } catch {
70
+ // best-effort: the record is an optimisation, never a precondition
71
+ }
72
+ }
73
+ // -> { args: string[], allowRoot: boolean }. Missing, unreadable or corrupt
74
+ // reads as "nothing extra" and never throws: a resume that cannot read a hint
75
+ // must still resume.
76
+ function recordedSpawnArgs(stateDir, key) {
77
+ try {
78
+ const v = JSON.parse(fs.readFileSync(spawnArgsFile(stateDir, key), 'utf8'));
79
+ if (Array.isArray(v)) return { args: v.filter((a) => typeof a === 'string'), allowRoot: false };
80
+ if (v && typeof v === 'object') {
81
+ return {
82
+ args: Array.isArray(v.args) ? v.args.filter((a) => typeof a === 'string') : [],
83
+ allowRoot: !!v.allowRoot,
84
+ permissions: typeof v.permissions === 'string' ? v.permissions : undefined, // golem
85
+ };
86
+ }
87
+ } catch {
88
+ // fall through to the empty record
89
+ }
90
+ return { args: [], allowRoot: false };
91
+ }
92
+
93
+ function shellQuote(s) {
94
+ return `'` + String(s).replace(/'/g, `'\\''`) + `'`;
95
+ }
96
+
97
+ // golem: `opts.env` rides the launch line as `K='v' ` prefixes, so the agent
98
+ // (and everything it runs) sees e.g. GOLEM_SESSION/GOLEM_API. Passed again on resume.
99
+ function envPrefix(opts = {}) {
100
+ return Object.entries(opts.env || {}).map(([k, v]) => `${k}=${shellQuote(v)} `).join('');
101
+ }
102
+
103
+ function newSessionName() {
104
+ return 'bc-' + crypto.randomBytes(3).toString('hex');
105
+ }
106
+
107
+ // stateKey — the per-agent key for prompt/turnend/session-id state files and
108
+ // the turn-end relay's `session` argument. Window-granular agents share their
109
+ // tmux session name with the lieutenant (and sibling workers), so the bare
110
+ // session would collide; the `session:window` form is unique — tmux session
111
+ // names can never contain ':'.
112
+ function stateKey(session, window) {
113
+ return window ? `${session}:${window}` : session;
114
+ }
115
+
116
+ // paneTarget — exact-match tmux target for an agent's pane.
117
+ // Session-granular: the bare `=name` exact-match form resolves for
118
+ // session-level commands but NOT for pane-level ones (verified tmux 3.4:
119
+ // `send-keys -t =name` fails with "can't find pane"); the trailing colon
120
+ // (`=name:`) resolves for both. Window-granular: `=session:=window`, exact on
121
+ // both halves, so tmux never pattern-matches or reads the window as an index.
122
+ function paneTarget(session, window) {
123
+ return window ? `=${session}:=${window}` : `=${session}:`;
124
+ }
125
+
126
+ // opts.strict — read through tmuxRead instead of tryTmux, so a tmux that could
127
+ // not be READ throws with the reason rather than answering "not there". Only a
128
+ // caller whose decision turns on knowing asks for it (alive(), whose false is
129
+ // the board's proof that a pane is gone); everything else keeps reading an
130
+ // unreadable tmux as absence, which is what it has always done.
131
+ function reader(opts) {
132
+ return (opts && opts.strict) ? t.tmuxRead : t.tryTmux;
133
+ }
134
+
135
+ async function paneCommand(target, opts) {
136
+ const out = await reader(opts)('display-message', '-p', '-t', target, '#{pane_current_command}');
137
+ return out === null ? null : out.trim();
138
+ }
139
+
140
+ async function hasSession(session, opts) {
141
+ return (await reader(opts)('has-session', '-t', `=${session}:`)) !== null;
142
+ }
143
+
144
+ // hasWindow — strict window existence. `display-message -t =ses:=missing`
145
+ // does NOT error — tmux silently falls back to another pane (verified tmux
146
+ // 3.4) — so existence is checked against the session's actual window list.
147
+ async function hasWindow(session, window, opts) {
148
+ const out = await reader(opts)('list-windows', '-t', `=${session}:`, '-F', '#{window_name}');
149
+ return out !== null && out.split('\n').includes(window);
150
+ }
151
+
152
+ function paneExists(session, window, opts) {
153
+ return window ? hasWindow(session, window, opts) : hasSession(session, opts);
154
+ }
155
+
156
+ // claimPaneNames — resolve + validate the session/window names for a spawn and
157
+ // refuse names already in use. Window names must start with a letter — a
158
+ // numeric name would be parsed by tmux as a window INDEX (papercut #8).
159
+ async function claimPaneNames(opts = {}) {
160
+ const session = opts.session || newSessionName();
161
+ if (!/^(bc|golem)-[A-Za-z0-9_-]+$/.test(session)) { // golem: golem-<id> sessions too
162
+ throw new Error(`invalid session name "${session}" (must match bc-<id> or golem-<id>)`);
163
+ }
164
+ const window = opts.window === undefined || opts.window === null ? undefined : String(opts.window);
165
+ if (window !== undefined && !/^[A-Za-z][A-Za-z0-9_-]*$/.test(window)) {
166
+ throw new Error(`invalid window name "${window}" (must start with a letter — tmux parses numeric names as window indexes)`);
167
+ }
168
+ if (window) {
169
+ if (await hasWindow(session, window)) throw new Error(`tmux window ${session}:${window} already exists`);
170
+ } else if (await hasSession(session)) {
171
+ throw new Error(`tmux session ${session} already exists`);
172
+ }
173
+ return { session, window };
174
+ }
175
+
176
+ // createPane — bring the agent's pane into existence. Session-granular: a
177
+ // fresh detached session. Window-granular: a new window appended to the
178
+ // session, created with -d so a worker spawn never steals the lieutenant's
179
+ // focus; when the session is not up yet it is created with this window as its
180
+ // first (the accepted lifecycle coupling — no separate fleet session).
181
+ async function createPane(session, window, cwd) {
182
+ if (!window) {
183
+ await t.tmux('new-session', '-d', '-s', session, '-c', cwd);
184
+ } else if (await hasSession(session)) {
185
+ await t.tmux('new-window', '-d', '-t', `=${session}:`, '-n', window, '-c', cwd);
186
+ } else {
187
+ await t.tmux('new-session', '-d', '-s', session, '-n', window, '-c', cwd);
188
+ }
189
+ }
190
+
191
+ // killPane — session-granular kills the session; window-granular kills ONLY
192
+ // the window (the lieutenant and sibling workers cohabit the session).
193
+ // Killing a session's last window ends the session — tmux's own semantics.
194
+ async function killPane(session, window) {
195
+ if (window) {
196
+ if (await hasWindow(session, window)) await t.tryTmux('kill-window', '-t', paneTarget(session, window));
197
+ } else if (await hasSession(session)) {
198
+ await t.tryTmux('kill-session', '-t', paneTarget(session));
199
+ }
200
+ }
201
+
202
+ // adoptWindow(ref, window, taken?) -> ref | null — migrate a SESSION-granular
203
+ // ref to window granularity without restarting the agent it addresses. Born
204
+ // session-granular, an agent that cohabits its session (a lieutenant with its
205
+ // worker windows) is addressed as `=session:`, which resolves to whatever
206
+ // window has FOCUS: killPane takes the whole session and paneCommand reads a
207
+ // sibling's pane. Pinning the ref to the agent's own window fixes both.
208
+ //
209
+ // The agent's window is the session's FIRST (lowest-index) one — it was born
210
+ // with the session, every sibling was appended after it. `taken` names windows
211
+ // that provably belong to someone else (the caller's worker windows), so a
212
+ // session whose original window is gone is never mis-adopted.
213
+ //
214
+ // rename-window also turns tmux's automatic-rename off for that window, so the
215
+ // name sticks. Nothing live to rename (session gone) still returns the
216
+ // window-granular ref — the next spawn/resume creates the window. null means
217
+ // "cannot tell which window is the agent's": the ref is left alone.
218
+ async function adoptWindow(ref, window, taken = []) {
219
+ if (ref.window) return ref;
220
+ if (!(await hasSession(ref.session))) return { ...ref, window };
221
+ if (await hasWindow(ref.session, window)) return { ...ref, window };
222
+ const out = await t.tryTmux('list-windows', '-t', `=${ref.session}:`, '-F', '#{window_index}\t#{window_name}');
223
+ const first = out === null ? null : out.split('\n').find((l) => l.trim());
224
+ if (!first) return null;
225
+ const [index, name] = first.trim().split('\t');
226
+ if (!index || taken.includes(name)) return null;
227
+ if (await t.tryTmux('rename-window', '-t', `=${ref.session}:${index}`, window) === null) return null;
228
+ return { ...ref, window };
229
+ }
230
+
231
+ // launchAndSettle — send the launch command into the pane, wait for the agent
232
+ // process and its main UI, auto-accepting the harness's trust dialog if it
233
+ // appears (a fresh cwd shows one even in bypass mode; the accept option is
234
+ // preselected, so Enter accepts). The adapter supplies:
235
+ // sig.trustRe — matches the trust screen (checked FIRST: a trust screen may
236
+ // contain composer-like glyphs, so it must win over readyRe)
237
+ // sig.readyRe — matches signatures only the main UI renders
238
+ // sig.label — the agent name for error messages ('claude', 'codex')
239
+ //
240
+ // Both signatures are tested against the TAIL of the pane — the current
241
+ // interaction always sits at the bottom. Matching the whole capture broke on
242
+ // inline-scrolling TUIs (codex renders in the primary screen, not the
243
+ // alternate one): the ACCEPTED trust prompt lingers in scrollback, so a
244
+ // full-capture trustRe kept re-matching forever and starved readyRe. claude's
245
+ // alternate-screen dialogs keep their signatures bottom-anchored anyway
246
+ // (composer + footer are the screen's last rows), so the tail is behavior-
247
+ // preserving there.
248
+ const SETTLE_TAIL_LINES = 15;
249
+
250
+ function paneTail(pane) {
251
+ return pane.replace(/\s+$/, '').split('\n').slice(-SETTLE_TAIL_LINES).join('\n');
252
+ }
253
+
254
+ async function launchAndSettle(target, launchCmd, sig) {
255
+ await t.sendLiteral(target, launchCmd);
256
+ await t.sleep(300);
257
+ await t.sendKey(target, 'Enter');
258
+
259
+ // Screens that stand between the launch and the UI, each one a menu whose
260
+ // PRESELECTED option is the one we want, so Enter answers all of them. They
261
+ // are checked before readyRe because a dialog can carry composer-like glyphs
262
+ // and would otherwise be mistaken for the main UI.
263
+ const menus = [sig.trustRe, sig.resumeRe].filter(Boolean);
264
+
265
+ const deadline = Date.now() + 45000;
266
+ while (Date.now() < deadline) {
267
+ await t.sleep(500);
268
+ const cmd = await paneCommand(target);
269
+ if (cmd === null) throw new Error(`tmux pane ${target} vanished during launch`);
270
+ const tail = paneTail(await t.capture(target, 40));
271
+ // sig.fatalRe — screens and exits that will NEVER become a running agent
272
+ // (a launch line the CLI refuses outright, a first-run setup wizard, a
273
+ // missing binary). Waiting the full 45s for one of those buys nothing and
274
+ // hands the caller a timeout to misdiagnose; the pane tail says what
275
+ // happened, so it is thrown immediately with the tail attached.
276
+ //
277
+ // Checked BEFORE the shell skip on purpose: the interesting failures print
278
+ // their line and exit, which puts the pane back on a shell prompt.
279
+ if (sig.fatalRe && sig.fatalRe.test(tail)) {
280
+ throw new Error(`${sig.label} could not start at ${target}; pane tail:\n${tail}`);
281
+ }
282
+ if (SHELLS.has(cmd)) continue; // agent not up yet (or it already exited — captured by timeout)
283
+ if (menus.some((re) => re.test(tail))) {
284
+ await t.sendKey(target, 'Enter');
285
+ await t.sleep(1000);
286
+ continue;
287
+ }
288
+ if (sig.readyRe.test(tail)) return;
289
+ }
290
+ const tail = await t.capture(target, 20);
291
+ throw new Error(`${sig.label} did not start at ${target} within 45s; pane tail:\n${tail}`);
292
+ }
293
+
294
+ // verifyLive — the last look before a spawn is allowed to say it worked.
295
+ //
296
+ // launchAndSettle answers "did a UI appear?", which is not the same question as
297
+ // "is there a session here now". A signature can match a screen that merely
298
+ // CONTAINS the words (the bypass-permissions consent modal literally says
299
+ // "Bypass Permissions mode", which the ready signature matched for months), and
300
+ // the brief then goes into a menu while the caller is told the session started.
301
+ //
302
+ // A caller reporting success it did not check is worse than any honest failure,
303
+ // because it is the one line nobody re-reads. So: after the brief is delivered,
304
+ // look again — a fatal screen fails immediately, and a pane that does not look
305
+ // like a running agent within `ms` fails with what it did look like.
306
+ async function verifyLive(target, sig, ms = 6000) {
307
+ const deadline = Date.now() + ms;
308
+ let tail = '';
309
+ for (;;) {
310
+ tail = paneTail(await t.capture(target, 40));
311
+ if (sig.fatalRe && sig.fatalRe.test(tail)) {
312
+ throw new Error(`${sig.label} is not running at ${target} — it is on a screen that needs a person; pane tail:\n${tail}`);
313
+ }
314
+ if (sig.readyRe.test(tail)) return;
315
+ if (Date.now() >= deadline) break;
316
+ await t.sleep(500);
317
+ }
318
+ throw new Error(`${sig.label} did not settle into a running session at ${target}; pane tail:\n${tail}`);
319
+ }
320
+
321
+ // onTurnEnd(ref, hook) -> unsubscribe()
322
+ // hook(event, ref) fires once per turn boundary; event is the JSON line the
323
+ // harness's relay appended ({ ts, session, event, session_id, cwd }). Only
324
+ // events appended AFTER registration are delivered. fs.watch push with a
325
+ // polling backstop, so no boundary is missed on filesystems with flaky watch.
326
+ function onTurnEnd(ref, hook, opts = {}) {
327
+ const stateDir = stateDirOf(opts);
328
+ const file = path.join(stateDir, `${stateKey(ref.session, ref.window)}.turnend.jsonl`);
329
+ let offset = 0;
330
+ try {
331
+ offset = fs.statSync(file).size;
332
+ } catch {
333
+ offset = 0;
334
+ }
335
+ let closed = false;
336
+
337
+ function drain() {
338
+ if (closed) return;
339
+ let size;
340
+ try {
341
+ size = fs.statSync(file).size;
342
+ } catch {
343
+ return;
344
+ }
345
+ if (size < offset) offset = 0; // truncated/rotated
346
+ if (size === offset) return;
347
+ const fd = fs.openSync(file, 'r');
348
+ try {
349
+ const buf = Buffer.alloc(size - offset);
350
+ fs.readSync(fd, buf, 0, buf.length, offset);
351
+ offset = size;
352
+ for (const line of buf.toString('utf8').split('\n')) {
353
+ if (!line.trim()) continue;
354
+ let event;
355
+ try {
356
+ event = JSON.parse(line);
357
+ } catch {
358
+ continue;
359
+ }
360
+ try {
361
+ hook(event, ref);
362
+ } catch {
363
+ // a throwing hook must not kill the watcher
364
+ }
365
+ }
366
+ } finally {
367
+ fs.closeSync(fd);
368
+ }
369
+ }
370
+
371
+ let watcher = null;
372
+ try {
373
+ watcher = fs.watch(stateDir, (_type, name) => {
374
+ if (name === path.basename(file)) drain();
375
+ });
376
+ } catch {
377
+ watcher = null;
378
+ }
379
+ const poll = setInterval(drain, 1000);
380
+ poll.unref?.();
381
+
382
+ return function unsubscribe() {
383
+ closed = true;
384
+ clearInterval(poll);
385
+ watcher?.close();
386
+ };
387
+ }
388
+
389
+ // ---------- pane viewing (OPTIONAL capability verbs — see port.js) ----------
390
+ // Feeds opened by openPane, keyed by tmux target — the ONLY place a burst can
391
+ // be registered, so a burst can never outlive the feed it speeds up (see
392
+ // paneInput below). One entry per open feed; the entry is the feed's own
393
+ // object, so a close only ever removes ITS OWN entry (the server's pane hub
394
+ // refcounts N viewers onto ONE feed per target, but identity beats assuming it).
395
+ const feeds = new Map(); // tmux target -> { until: ms deadline for fast polling }
396
+ const PANE_BURST_MS = 120; // fast poll while a burst is live
397
+ const PANE_BURST_WINDOW_MS = 1500; // how long one keystroke keeps the feed fast
398
+
399
+ // openPane(ref, { onFrame, intervalMs?, lines?, burstMs?, burstWindowMs? }) -> { close() }
400
+ // Streams the pane's CURRENT RENDERED SCREEN as successive frames: every
401
+ // intervalMs the pane is captured with ANSI styling and scrollback, and
402
+ // onFrame(frame) fires only when the content changed since the last frame.
403
+ // close() stops delivery and releases the timer.
404
+ //
405
+ // Deliberately rendered frames via capture-pane, NOT a pipe-pane byte stream:
406
+ // the target is a full-screen TUI that repaints in place, so raw pty bytes
407
+ // would need a client-side terminal emulator (xterm.js — a dependency we
408
+ // will not add). capture-pane returns the already-composed screen, works for
409
+ // any TUI, and keeps the client a plain <pre>.
410
+ //
411
+ // The timer is a self-rescheduling setTimeout, not a setInterval: the next
412
+ // capture is scheduled only after the current one finished (so a slow tmux can
413
+ // never stack children — no busy flag needed) and each hop re-reads the delay,
414
+ // which is what lets paneInput burst a live feed down to burstMs for a moment
415
+ // so typing does not feel dead behind the 1s baseline.
416
+ function openPane(ref, opts = {}) {
417
+ const onFrame = typeof opts.onFrame === 'function' ? opts.onFrame : () => {};
418
+ const intervalMs = opts.intervalMs > 0 ? opts.intervalMs : 1000;
419
+ const lines = opts.lines > 0 ? opts.lines : 200;
420
+ const burstMs = opts.burstMs > 0 ? opts.burstMs : PANE_BURST_MS;
421
+ const burstWindowMs = opts.burstWindowMs > 0 ? opts.burstWindowMs : PANE_BURST_WINDOW_MS;
422
+ const target = paneTarget(ref.session, ref.window);
423
+ let last = null;
424
+ let closed = false;
425
+ let ticking = false;
426
+ let timer = null;
427
+ // bump() — a keystroke just landed: go fast for burstWindowMs. Rescheduling
428
+ // NOW matters as much as the deadline: the next hop was already pending at
429
+ // the 1s baseline, and leaving it alone would make the FIRST key of a burst
430
+ // (the one the typist is actually watching for) the slowest of all. Mid-tick
431
+ // is the one case to leave alone — loop() schedules the moment it returns and
432
+ // reads the fresh deadline then; racing it here would leave two live timers.
433
+ const feed = {
434
+ until: 0,
435
+ bump() {
436
+ feed.until = Date.now() + burstWindowMs;
437
+ if (closed || ticking) return;
438
+ clearTimeout(timer);
439
+ schedule();
440
+ },
441
+ };
442
+ feeds.set(target, feed); // this pane now has a feed a keystroke can burst
443
+
444
+ async function tick() {
445
+ if (closed) return;
446
+ if (!(await paneExists(ref.session, ref.window))) {
447
+ close();
448
+ try { onFrame('\n[pane gone]'); } catch { /* subscriber's problem */ }
449
+ return;
450
+ }
451
+ const frame = await t.captureStyled(target, lines);
452
+ if (closed || frame === null || frame === last) return;
453
+ last = frame;
454
+ try { onFrame(frame); } catch { /* a throwing subscriber must not kill the feed */ }
455
+ }
456
+
457
+ // schedule(spentMs) — the next hop, measured from when the LAST one STARTED.
458
+ // Subtracting the time the capture already burned keeps the period at
459
+ // max(interval, capture) instead of interval + capture; without it a 60ms
460
+ // tmux would turn the advertised 120ms burst into 180ms and the 1s baseline
461
+ // into 1.06s. Still strictly sequential, so captures cannot overlap.
462
+ function schedule(spentMs = 0) {
463
+ if (closed) return;
464
+ const base = feed.until > Date.now() ? Math.min(burstMs, intervalMs) : intervalMs;
465
+ timer = setTimeout(loop, Math.max(0, base - spentMs));
466
+ timer.unref?.();
467
+ }
468
+ async function loop() {
469
+ const started = Date.now();
470
+ ticking = true;
471
+ try { await tick(); } finally { ticking = false; }
472
+ schedule(Date.now() - started);
473
+ }
474
+
475
+ function close() {
476
+ closed = true;
477
+ clearTimeout(timer);
478
+ if (feeds.get(target) === feed) feeds.delete(target); // burst dies with the feed
479
+ }
480
+
481
+ // immediate first frame — the subscriber paints without waiting a tick
482
+ loop();
483
+ return { close };
484
+ }
485
+
486
+ // paneSnapshot(ref, { lines? }) -> Promise<string> — one-shot styled capture
487
+ // (initial paint / non-streaming fallback). Empty string when unreadable.
488
+ async function paneSnapshot(ref, opts = {}) {
489
+ const lines = opts.lines > 0 ? opts.lines : 200;
490
+ const out = await t.captureStyled(paneTarget(ref.session, ref.window), lines);
491
+ return out === null ? '' : out;
492
+ }
493
+
494
+ // ---------- pane input (OPTIONAL capability verb — see port.js) ----------
495
+ // paneInput(ref, { text? | key? }) -> Promise<void>
496
+ // Forward RAW input to the pane: `text` is typed literally (sendLiteral, which
497
+ // switches to a bracketed paste when it spans lines), `key` is one tmux key
498
+ // name ('Enter', 'BSpace', 'Up', 'BTab', 'C-c', …).
499
+ //
500
+ // Deliberately NOT the agent `send` verb: send() types, settles, presses Enter
501
+ // and retries until the composer verifies empty. That is right for delivering
502
+ // a brief and wrong for a keystroke — an arrow key has no composer state to
503
+ // verify and a retried Enter would submit twice. Raw passthrough bypasses all
504
+ // of it: one keystroke in, one keystroke out.
505
+ //
506
+ // The payload contract (key XOR text, the key grammar, the size cap) lives in
507
+ // port.js so the fake enforces the SAME rules — two copies of a validation
508
+ // regex are two regexes that drift. Text needs no pattern of its own: tmux.js's
509
+ // sendLiteral passes `--` so a flag-shaped payload can never be read as flags,
510
+ // which is the guarantee every caller of that primitive now gets.
511
+ async function paneInput(ref, input = {}) {
512
+ const { key, text } = validatePaneInput(input);
513
+ if (!(await paneExists(ref.session, ref.window))) {
514
+ throw new Error(`pane ${stateKey(ref.session, ref.window)} is gone`);
515
+ }
516
+ const target = paneTarget(ref.session, ref.window);
517
+ if (key) await t.sendKey(target, key);
518
+ else await t.sendLiteral(target, text);
519
+ // Burst the WATCHING feed so the echo lands in ~a frame instead of ~a second.
520
+ // No feed open (nobody watching) → nothing to record, nothing to leak.
521
+ const feed = feeds.get(target);
522
+ if (feed) feed.bump();
523
+ }
524
+
525
+ // openFeedCount() — how many pane feeds are registered right now. A test hook,
526
+ // not a port verb: the "a burst cannot outlive its feed" claim rests on the map
527
+ // being emptied on close, and without a way to look at the map that claim is
528
+ // untestable — deleting the delete kept every other assertion green.
529
+ function openFeedCount() { return feeds.size; }
530
+
531
+ module.exports = {
532
+ SHELLS,
533
+ stateDirOf,
534
+ recordSpawnArgs,
535
+ recordedSpawnArgs,
536
+ shellQuote,
537
+ envPrefix,
538
+ newSessionName,
539
+ stateKey,
540
+ paneTarget,
541
+ paneCommand,
542
+ hasSession,
543
+ hasWindow,
544
+ paneExists,
545
+ claimPaneNames,
546
+ createPane,
547
+ killPane,
548
+ adoptWindow,
549
+ launchAndSettle,
550
+ verifyLive,
551
+ onTurnEnd,
552
+ openPane,
553
+ paneSnapshot,
554
+ paneInput,
555
+ openFeedCount,
556
+ };