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,430 @@
1
+ // Vendored from bridge-commander 5bf87e4b harness/fake.js (zero-dependency). Local patches are marked 'golem:'.
2
+ 'use strict';
3
+ // fake — in-memory harness implementing the same seven verbs, for unit tests
4
+ // of server code. No tmux, no claude, no filesystem.
5
+ //
6
+ // Refs look like the real thing: { harness: 'fake', session: 'bc-<id>', window?, cwd, resumeId }.
7
+ // A window-granular ref (opts.window at spawn — workers as windows in their
8
+ // lieutenant's session) is keyed as `session:window` everywhere the plain
9
+ // session name would be: the in-memory map, marker files, sends log, and the
10
+ // emitted turn-end event's `session` field.
11
+ //
12
+ // Behavior model:
13
+ // spawn — creates a live session, records the prompt as transcript[0],
14
+ // emits one turn-end event asynchronously (the "reply" turn).
15
+ // send — throws on a dead session; records the text; emits a turn-end.
16
+ // alive — session exists and is not killed.
17
+ // resumable — would resume restore memory? true iff this process holds the
18
+ // session's transcript under a matching resumeId.
19
+ // resume — revives a dead session; transcript (memory) survives iff the
20
+ // resumeId matches the recorded one.
21
+ // kill — ends a session for good (idempotent); in file-backed mode also
22
+ // removes the marker, so cross-process alive() flips false.
23
+ // onTurnEnd — hooks fire once per emitted turn, in registration order,
24
+ // only for events after registration. Returns unsubscribe().
25
+ //
26
+ // Test helpers (not part of the port contract): transcript(ref), reset().
27
+ //
28
+ // File-backed mode (cross-process observability): when BC_FAKE_STATE names a
29
+ // directory, spawn/send also persist there —
30
+ // <session>.json spawn record { cwd, resumeId, prompt, stateDir }
31
+ // <session>.sends.jsonl one JSON line per send { ts, session, text }
32
+ // and a session unknown to THIS process counts as alive (and accepts sends)
33
+ // iff its <session>.json marker exists AND does not say `exited: true` — the
34
+ // window whose agent ended by itself, still standing until something kills it.
35
+ // That lets a test process watch what a server process sent, and pre-register
36
+ // "live" (or exited) fake sessions by dropping a marker file. A marker saying
37
+ // `unreadable: true` is the third state: alive() THROWS, the way a harness
38
+ // answers a question it could not ask. Without BC_FAKE_STATE the fake stays
39
+ // purely in-memory.
40
+ //
41
+ // spawn also writes opts.stateDir/<key>.prompt (the SAME source-of-truth file
42
+ // the real tmux adapters persist) whenever opts.stateDir is given — distinct
43
+ // from BC_FAKE_STATE, and honored even without it, mirroring the real
44
+ // harnesses closely enough for callers (card.start's brief-artifact
45
+ // auto-attach) to be exercised under test without tmux.
46
+
47
+ const crypto = require('node:crypto');
48
+ const fs = require('node:fs');
49
+ const path = require('node:path');
50
+ const { SLASH_COMMANDS, helpText, formatStatus } = require('./agent-status.js');
51
+ const { validatePaneInput } = require('./port.js');
52
+
53
+ const sessions = new Map(); // key (session or session:window) -> { alive, cwd, resumeId, transcript, hooks, turns }
54
+
55
+ function keyOf(session, window) {
56
+ return window ? session + ':' + window : session;
57
+ }
58
+ function refKey(ref) {
59
+ return keyOf(ref.session, ref.window);
60
+ }
61
+
62
+ function fakeStateDir() {
63
+ const dir = process.env.BC_FAKE_STATE;
64
+ if (!dir) return null;
65
+ fs.mkdirSync(dir, { recursive: true });
66
+ return dir;
67
+ }
68
+ function markerFile(session) {
69
+ const dir = fakeStateDir();
70
+ return dir ? path.join(dir, session + '.json') : null;
71
+ }
72
+ function logSend(session, text) {
73
+ const dir = fakeStateDir();
74
+ if (!dir) return;
75
+ fs.appendFileSync(path.join(dir, session + '.sends.jsonl'),
76
+ JSON.stringify({ ts: new Date().toISOString(), session, text }) + '\n');
77
+ }
78
+
79
+ function get(ref) {
80
+ const s = sessions.get(refKey(ref));
81
+ if (!s) throw new Error(`fake: unknown session ${refKey(ref)}`);
82
+ return s;
83
+ }
84
+
85
+ // live(key) — known to THIS process, else the file-backed marker.
86
+ //
87
+ // A marker carrying `exited: true` is the state both tmux harnesses read as NOT
88
+ // alive while the WINDOW is still standing: the agent process ended by itself
89
+ // and its pane fell back to a shell. The marker is that window, so only kill()
90
+ // takes it away — which is why alive() answering false never means there is
91
+ // nothing left to kill.
92
+ function live(key) {
93
+ const s = sessions.get(key);
94
+ if (s) return s.alive;
95
+ const marker = markerFile(key);
96
+ if (!marker || !fs.existsSync(marker)) return false;
97
+ try { return !JSON.parse(fs.readFileSync(marker, 'utf8')).exited; }
98
+ catch (e) { return true; } // unreadable marker: the window is there, that is all we know
99
+ }
100
+
101
+ // siblings(session) — every key living in that tmux session: the session
102
+ // itself plus its `session:window` windows (in-process and marker-backed).
103
+ // The two verbs below need it because a SESSION-granular ref addresses a whole
104
+ // tmux session, windows and all — the fidelity that makes the lieutenant's
105
+ // window-granular ref testable without tmux.
106
+ function siblings(session) {
107
+ const keys = new Set();
108
+ const mine = (k) => k === session || k.startsWith(session + ':');
109
+ for (const k of sessions.keys()) if (mine(k)) keys.add(k);
110
+ const dir = fakeStateDir();
111
+ if (dir) {
112
+ for (const f of fs.readdirSync(dir)) {
113
+ if (!f.endsWith('.json')) continue;
114
+ const k = f.slice(0, -5);
115
+ if (mine(k)) keys.add(k);
116
+ }
117
+ }
118
+ return [...keys];
119
+ }
120
+
121
+ function emitTurnEnd(name) {
122
+ const s = sessions.get(name);
123
+ if (!s || !s.alive) return;
124
+ s.turns += 1;
125
+ const event = {
126
+ ts: new Date().toISOString(),
127
+ session: name,
128
+ event: 'Stop',
129
+ session_id: s.resumeId,
130
+ cwd: s.cwd,
131
+ turn: s.turns,
132
+ };
133
+ const hooks = [...s.hooks];
134
+ setImmediate(() => {
135
+ for (const h of hooks) {
136
+ try {
137
+ h.fn(event, h.ref);
138
+ } catch {
139
+ // hooks must not break the fake
140
+ }
141
+ }
142
+ });
143
+ }
144
+
145
+ // BC_FAKE_SPAWN_MS holds spawn open for that long before it returns. A real
146
+ // spawn is seconds — createPane, then launch-settle blocking until the composer
147
+ // is up — and everything racing that window (supervision landing on a session
148
+ // that is legitimately down mid-restart) is invisible against a fake that
149
+ // returns instantly.
150
+ const SPAWN_MS = parseInt(process.env.BC_FAKE_SPAWN_MS, 10) > 0
151
+ ? parseInt(process.env.BC_FAKE_SPAWN_MS, 10) : 0;
152
+
153
+ // BC_FAKE_ALIVE_MS does the same for alive(). A real liveness read is two tmux
154
+ // subprocess round-trips, and what a caller does with the answer can be decided
155
+ // while the registry moves underneath it — invisible against a fake that
156
+ // answers in the same tick.
157
+ const ALIVE_MS = parseInt(process.env.BC_FAKE_ALIVE_MS, 10) > 0
158
+ ? parseInt(process.env.BC_FAKE_ALIVE_MS, 10) : 0;
159
+
160
+ async function spawn(cwd, prompt, opts = {}) {
161
+ const session = opts.session || 'bc-' + crypto.randomBytes(3).toString('hex');
162
+ const window = opts.window === undefined || opts.window === null ? undefined : String(opts.window);
163
+ const key = keyOf(session, window);
164
+ if (sessions.has(key) && sessions.get(key).alive) {
165
+ throw new Error(`fake: session ${key} already exists`);
166
+ }
167
+ // tmux refuses a window whose name is already taken, and a window whose agent
168
+ // EXITED is still a window. The marker is that window in file-backed mode, so
169
+ // a spawn over one nobody killed fails here exactly as the real harness does.
170
+ if (markerFile(key) && fs.existsSync(markerFile(key))) {
171
+ throw new Error(`fake: session ${key} already exists`);
172
+ }
173
+ if (SPAWN_MS) await new Promise((r) => setTimeout(r, SPAWN_MS));
174
+ const resumeId = crypto.randomUUID();
175
+ sessions.set(key, {
176
+ alive: true,
177
+ cwd,
178
+ resumeId,
179
+ transcript: [prompt],
180
+ hooks: [],
181
+ turns: 0,
182
+ });
183
+ const marker = markerFile(key);
184
+ if (marker) {
185
+ // stateDir rides along so a watching test can verify what dir the caller
186
+ // plumbed through the port (the fake itself never writes state there).
187
+ fs.writeFileSync(marker,
188
+ JSON.stringify({ cwd, resumeId, prompt, stateDir: opts.stateDir || null }, null, 2) + '\n');
189
+ }
190
+ if (opts.stateDir) {
191
+ fs.mkdirSync(opts.stateDir, { recursive: true });
192
+ fs.writeFileSync(path.join(opts.stateDir, `${key}.prompt`), prompt);
193
+ }
194
+ emitTurnEnd(key);
195
+ const ref = { harness: 'fake', session, cwd, resumeId };
196
+ if (window) ref.window = window;
197
+ return ref;
198
+ }
199
+
200
+ async function send(ref, text) {
201
+ const key = refKey(ref);
202
+ const s = sessions.get(key);
203
+ if (!s) {
204
+ // Cross-process fake session: alive iff its marker file exists.
205
+ const marker = markerFile(key);
206
+ if (marker && fs.existsSync(marker)) return logSend(key, text);
207
+ throw new Error(`fake: unknown session ${key}`);
208
+ }
209
+ if (!s.alive) throw new Error(`session ${key} is not alive`);
210
+ s.transcript.push(text);
211
+ logSend(key, text);
212
+ emitTurnEnd(key);
213
+ }
214
+
215
+ // alive(ref) — window-granular refs answer for their own window only.
216
+ // A session-granular ref is read the way tmux reads `=session:`: off whichever
217
+ // window has FOCUS, so ANY live window in the session makes it read alive —
218
+ // including a busy worker masking a dead lieutenant beside it.
219
+ // A marker carrying `unreadable: true` is the tmux nobody could READ — the verb
220
+ // cannot be honored, so it throws with the reason instead of answering "gone".
221
+ // Absence and an unanswered question are different facts, and the board drops
222
+ // worker records on the difference.
223
+ function assertReadable(key) {
224
+ const marker = markerFile(key);
225
+ if (!marker || !fs.existsSync(marker)) return;
226
+ let doc = null;
227
+ try { doc = JSON.parse(fs.readFileSync(marker, 'utf8')); } catch (e) { return; }
228
+ if (doc && doc.unreadable) throw new Error(`fake: cannot read session ${key}`);
229
+ }
230
+
231
+ async function alive(ref) {
232
+ if (ALIVE_MS) await new Promise((r) => setTimeout(r, ALIVE_MS));
233
+ if (ref.window) { assertReadable(refKey(ref)); return live(refKey(ref)); }
234
+ const keys = siblings(ref.session);
235
+ for (const k of keys) assertReadable(k);
236
+ return keys.some(live);
237
+ }
238
+
239
+ // resumable — introspection only: memory survives a resume iff this process
240
+ // still holds the session's transcript under the same resumeId.
241
+ async function resumable(ref) {
242
+ const s = sessions.get(refKey(ref));
243
+ return !!(s && ref.resumeId && ref.resumeId === s.resumeId);
244
+ }
245
+
246
+ async function resume(ref) {
247
+ const key = refKey(ref);
248
+ const s = sessions.get(key);
249
+ if (s && s.alive) return { ...ref };
250
+ const out = { harness: 'fake', session: ref.session, cwd: ref.cwd, resumeId: ref.resumeId };
251
+ if (ref.window) out.window = ref.window;
252
+ if (s && ref.resumeId === s.resumeId) {
253
+ s.alive = true; // memory (transcript) preserved
254
+ return { ...out, cwd: s.cwd };
255
+ }
256
+ // No matching memory: fresh session under the same name (transcript lost).
257
+ // golem: `claude --resume <id>` keeps that id even when its transcript is gone, so a ref's id survives.
258
+ out.resumeId = ref.resumeId || crypto.randomUUID();
259
+ sessions.set(key, {
260
+ alive: true,
261
+ cwd: ref.cwd,
262
+ resumeId: out.resumeId,
263
+ transcript: [],
264
+ hooks: s ? s.hooks : [],
265
+ turns: 0,
266
+ });
267
+ return out;
268
+ }
269
+
270
+ function onTurnEnd(ref, hook) {
271
+ const s = get(ref);
272
+ const entry = { fn: hook, ref };
273
+ s.hooks.push(entry);
274
+ return function unsubscribe() {
275
+ const i = s.hooks.indexOf(entry);
276
+ if (i !== -1) s.hooks.splice(i, 1);
277
+ };
278
+ }
279
+
280
+ // kill(ref) — port verb: end the session for good. Idempotent (unknown or
281
+ // already-dead sessions are a no-op). File-backed mode also removes the
282
+ // marker so a WATCHING process sees alive() flip false.
283
+ // tmux semantics: a window-granular ref takes ONLY its window; a
284
+ // session-granular one takes the whole session — every sibling window with it.
285
+ function kill(ref) {
286
+ for (const key of (ref.window ? [refKey(ref)] : siblings(ref.session))) {
287
+ const s = sessions.get(key);
288
+ if (s) s.alive = false;
289
+ const marker = markerFile(key);
290
+ if (marker) { try { fs.unlinkSync(marker); } catch { /* already gone */ } }
291
+ }
292
+ }
293
+
294
+ // adoptWindow(ref, window, taken?) -> ref — OPTIONAL capability verb; the real
295
+ // contract is in tmux-session.js. The fake has one pane per key, so there is
296
+ // no window to mis-adopt (`taken` never applies): the live session is simply
297
+ // re-keyed, and the SAME agent (transcript, hooks, marker) answers to
298
+ // `session:window` from now on.
299
+ async function adoptWindow(ref, window) {
300
+ if (ref.window) return ref;
301
+ const key = keyOf(ref.session, window);
302
+ const s = sessions.get(ref.session);
303
+ if (s) {
304
+ sessions.delete(ref.session);
305
+ sessions.set(key, s);
306
+ }
307
+ const from = markerFile(ref.session);
308
+ const to = markerFile(key);
309
+ if (from && to && fs.existsSync(from)) fs.renameSync(from, to);
310
+ return { ...ref, window };
311
+ }
312
+
313
+ // ---------- pane viewing (OPTIONAL capability verbs — see port.js) ----------
314
+ // openPane emits deterministic counter frames on the interval — each frame
315
+ // differs from the last, so change-detecting consumers always deliver — letting
316
+ // server tests assert subscribe → frames → teardown without tmux. In
317
+ // file-backed mode every open/close also appends to <key>.pane.jsonl, so a
318
+ // WATCHING test process can assert refcounting (one open, one close) across
319
+ // the process boundary.
320
+ // BC_FAKE_PANE_MS default frame interval (callers' intervalMs still wins)
321
+ // BC_FAKE_NO_PANE hides both verbs — the "harness without pane support"
322
+ function logPane(session, event, extra) {
323
+ const dir = fakeStateDir();
324
+ if (!dir) return;
325
+ fs.appendFileSync(path.join(dir, session + '.pane.jsonl'),
326
+ JSON.stringify({ ts: new Date().toISOString(), session, event, ...extra }) + '\n');
327
+ }
328
+
329
+ function openPane(ref, opts = {}) {
330
+ const key = refKey(ref);
331
+ const onFrame = typeof opts.onFrame === 'function' ? opts.onFrame : () => {};
332
+ const intervalMs = opts.intervalMs > 0 ? opts.intervalMs
333
+ : (parseInt(process.env.BC_FAKE_PANE_MS, 10) > 0 ? parseInt(process.env.BC_FAKE_PANE_MS, 10) : 1000);
334
+ let n = 0;
335
+ let closed = false;
336
+ const emit = () => {
337
+ if (closed) return;
338
+ n += 1;
339
+ try { onFrame('fake pane ' + key + ' — frame ' + n + '\n'); } catch { /* subscriber's problem */ }
340
+ };
341
+ logPane(key, 'open');
342
+ const timer = setInterval(emit, intervalMs);
343
+ timer.unref?.();
344
+ emit(); // immediate first frame
345
+ return {
346
+ close() {
347
+ if (closed) return;
348
+ closed = true;
349
+ clearInterval(timer);
350
+ logPane(key, 'close');
351
+ },
352
+ };
353
+ }
354
+
355
+ async function paneSnapshot(ref) {
356
+ return 'fake pane ' + refKey(ref) + ' — snapshot\n';
357
+ }
358
+
359
+ // paneInput records the keystroke into the same <key>.pane.jsonl the open/close
360
+ // events land in, so a watching test process can assert what the server
361
+ // forwarded. Validation is the SHARED one from port.js, not a copy: a fake that
362
+ // is laxer than the real harness turns route tests green against payloads tmux
363
+ // would choke on, and two copies of a regex are two regexes that drift.
364
+ async function paneInput(ref, input = {}) {
365
+ const { key, text } = validatePaneInput(input);
366
+ logPane(refKey(ref), 'input', key ? { key } : { text });
367
+ }
368
+
369
+ // ---------- slash commands + status (OPTIONAL capability verbs — see port.js) ----------
370
+ // Canned, deterministic, filesystem-free — the whole slash/status stack (server
371
+ // routing, /api/commands, the composer autocomplete, context bars) tests
372
+ // without tmux. A session counts for status the same way alive() counts it:
373
+ // known to this process OR marked live via a BC_FAKE_STATE marker file.
374
+ // BC_FAKE_NO_COMMANDS hides all three verbs — the "harness without slash
375
+ // commands" (capability-absent degradation under test)
376
+ const FAKE_STATUS = { model: 'fake-model', contextUsed: 50000, contextWindow: 200000 };
377
+
378
+ function commands() {
379
+ return SLASH_COMMANDS.map((c) => ({ ...c }));
380
+ }
381
+
382
+ async function status(ref) {
383
+ const s = sessions.get(refKey(ref));
384
+ if (s) return s.alive ? { ...FAKE_STATUS } : null;
385
+ const marker = markerFile(refKey(ref));
386
+ return marker && fs.existsSync(marker) ? { ...FAKE_STATUS } : null;
387
+ }
388
+
389
+ async function runCommand(ref, command) {
390
+ const line = String(command || '').trim();
391
+ const name = line.split(/\s+/)[0];
392
+ if (name === '/help') return helpText(commands());
393
+ if (name === '/status') {
394
+ const st = await status(ref);
395
+ if (!st) throw new Error('fake: no status for ' + refKey(ref));
396
+ return formatStatus(st);
397
+ }
398
+ if (name === '/compact') {
399
+ await send(ref, line); // same path a real adapter uses: the send machinery
400
+ return '"' + line + '" submitted to ' + refKey(ref) + ' — the session runs it in-place';
401
+ }
402
+ throw new Error('fake: unknown command ' + name + ' (see /help)');
403
+ }
404
+
405
+ // --- test helpers ---
406
+
407
+ function transcript(ref) {
408
+ return [...get(ref).transcript];
409
+ }
410
+
411
+ function reset() {
412
+ sessions.clear();
413
+ }
414
+
415
+ const impl = { spawn, send, alive, resumable, resume, onTurnEnd, kill, adoptWindow, transcript, reset };
416
+ // Pane verbs are OPTIONAL by contract; BC_FAKE_NO_PANE simulates a harness
417
+ // that never implemented them (capability-absent degradation under test).
418
+ if (!process.env.BC_FAKE_NO_PANE) {
419
+ impl.openPane = openPane;
420
+ impl.paneSnapshot = paneSnapshot;
421
+ impl.paneInput = paneInput;
422
+ }
423
+ // Slash commands + status are OPTIONAL too; BC_FAKE_NO_COMMANDS simulates a
424
+ // harness that never implemented them.
425
+ if (!process.env.BC_FAKE_NO_COMMANDS) {
426
+ impl.commands = commands;
427
+ impl.runCommand = runCommand;
428
+ impl.status = status;
429
+ }
430
+ module.exports = impl;
@@ -0,0 +1 @@
1
+ { "type": "commonjs" }
@@ -0,0 +1,208 @@
1
+ // Vendored from bridge-commander 5bf87e4b harness/port.js (zero-dependency). Local patches are marked 'golem:'.
2
+ 'use strict';
3
+ // harness port — the multi-harness contract (docs/api/overview.md, "harness port").
4
+ //
5
+ // The server speaks ONLY this port. An implementation is a module exposing
6
+ // exactly these seven verbs (all may be async):
7
+ //
8
+ // spawn(cwd, prompt, opts?) -> HarnessRef birth an agent session
9
+ // send(ref, text) type a message into a session (verified submit)
10
+ // alive(ref) -> bool liveness
11
+ // resumable(ref, opts?) -> bool would resume(ref) restore memory? (introspection only)
12
+ // resume(ref) -> HarnessRef reincarnate a dead session with memory when possible
13
+ // kill(ref) end a session for good (idempotent; dead ref is a no-op)
14
+ // onTurnEnd(ref, hook) -> unsubscribe() turn-boundary detection
15
+ //
16
+ // A HarnessRef is a plain, JSON-serializable object; `harness` names the
17
+ // implementation and the rest is that implementation's opaque address:
18
+ // { harness: 'claude', session: 'bc-<id>', window?: 'w-<id>', cwd: '/abs/path', resumeId?: '<uuid>' }
19
+ // `window` marks a window-granular ref: the agent lives in a named window of
20
+ // a shared session (workers inside their lieutenant's session) instead of
21
+ // owning the whole session.
22
+ //
23
+ // Adding a harness = implementing the seven verbs and registering it here
24
+ // (or shipping it as a builtin module). Nothing else.
25
+ //
26
+ // All seven must EXIST; one that cannot be honored must THROW with the reason
27
+ // rather than pretend — a caller that learns why beats one watching text vanish
28
+ // into a verb that quietly did nothing.
29
+ //
30
+ // OPTIONAL capability verbs: beyond the seven REQUIRED verbs a harness MAY
31
+ // expose extra verbs for features not every harness can honor. They are
32
+ // deliberately NOT validated here — adding one to VERBS would force every
33
+ // harness (the fake included) to implement it and break validation. The
34
+ // server capability-checks at the call site (`typeof impl.openPane ===
35
+ // 'function'`) and degrades gracefully when the verb is absent. Current
36
+ // optional verbs — pane viewing (the UI's 👁 peek):
37
+ // openPane(ref, { onFrame, intervalMs?, lines? }) -> { close() }
38
+ // deliver the pane's CURRENT RENDERED SCREEN as successive frames:
39
+ // onFrame(frameString) fires whenever the content changes (identical
40
+ // frames are skipped); a frame MAY carry ANSI SGR escapes. close()
41
+ // stops delivery and releases resources. All async-safe.
42
+ // paneSnapshot(ref, { lines? }) -> Promise<string>
43
+ // one-shot capture — the initial paint / non-streaming fallback.
44
+ // paneInput(ref, { text? | key? }) -> Promise<void>
45
+ // forward RAW input to the pane: `text` typed literally (multi-line
46
+ // rides a bracketed paste), `key` ONE tmux key name ('Enter', 'BSpace',
47
+ // 'Up', 'BTab', 'C-c', …). Exactly one of the two; anything else throws,
48
+ // as does an unusable key name, a pane that is gone, or text past
49
+ // PANE_INPUT_MAX. Validate with the SHARED validatePaneInput() below —
50
+ // a harness with its own copy of the rules is a harness that drifts from
51
+ // them. Deliberately NOT
52
+ // send(): that one types, settles, Enters and retries until the composer
53
+ // verifies empty — right for delivering a brief, wrong for a keystroke.
54
+ // A harness MAY offer paneInput while send() throws: "no composer for a
55
+ // brief" and "no way to press a key" are different claims.
56
+ // Implementations that also stream SHOULD speed their feed up briefly
57
+ // after input, so the echo is not stuck behind the poll.
58
+ // — migration of a session-granular ref to window granularity (the lieutenant
59
+ // whose session it turned out to cohabit with its worker windows):
60
+ // adoptWindow(ref, window, taken?) -> Promise<HarnessRef|null>
61
+ // make the SAME running agent addressable as `session:window` without
62
+ // restarting it. `taken` names windows that belong to someone else and
63
+ // must never be adopted. null = the agent's window cannot be identified;
64
+ // the caller keeps the old ref. Idempotent: a ref that already carries a
65
+ // window comes back unchanged.
66
+ // — and slash commands + session status (the UI composer's "/" and the
67
+ // context bars; agent-status.js holds the shared machinery):
68
+ // commands(ref?) -> [{ name, description, args? }]
69
+ // the slash commands this harness answers (/status /compact /help
70
+ // where applicable; claude adds /autocompact and /output-style — verified
71
+ // against the binary, the public docs lag behind).
72
+ // `ref`, when given, scopes the answer to that session — claude's style
73
+ // list includes the ones installed in the session's own cwd.
74
+ // `args` is OPTIONAL metadata: [{ value, description }], the values this
75
+ // command accepts as its single argument, for a composer that wants to
76
+ // keep completing AFTER the command name (ui/js/slash.js). A harness that
77
+ // does not send it behaves exactly as before — the picker closes on the
78
+ // space, as it always did — so this is additive for every existing
79
+ // implementation. Everything a caller types after the command name is ONE
80
+ // argument: a `value` may contain spaces, and runCommand must not tokenize
81
+ // it. The server passes the field through untouched.
82
+ // runCommand(ref, command, opts?) -> Promise<string>
83
+ // execute one command line against the session (first token names the
84
+ // command; arguments ride along); resolves to the reply text. opts is
85
+ // the same bag spawn/resume take — `stateDir` is the one field that
86
+ // matters here, since /status reads from it.
87
+ // Pass-through commands (/compact, claude's /autocompact) type the
88
+ // LITERAL line through the verified-submit send path — the harness's
89
+ // own implementation runs in-session; /status formats status(); /help
90
+ // renders commands(). Unknown names throw — and so does a command whose
91
+ // argument is missing or unrecognised, BEFORE it does anything: claude's
92
+ // /output-style writes a setting to disk, and a typo must not sit there
93
+ // waiting to surprise the next conversation. A command that changes
94
+ // something the session only reads at STARTUP says WHEN it applies in
95
+ // its reply (/output-style: the next time this session starts) without
96
+ // naming a command to get there, which a harness cannot know exists —
97
+ // no verb here restarts a session on the caller's behalf.
98
+ // status(ref, opts?) -> Promise<{ model, contextUsed, contextWindow, rateLimits? } | null>
99
+ // model + context usage read from the files the harness already
100
+ // writes (transcript / rollout log); null — never a throw — when
101
+ // nothing is readable. opts.stateDir points at the board's harness
102
+ // state (codex resolves its thread-id from the session-id file there);
103
+ // omitting it falls back to whatever the ref alone can answer. rateLimits only where the harness persists
104
+ // them (codex); claude omits the field.
105
+
106
+ const VERBS = ['spawn', 'send', 'alive', 'resumable', 'resume', 'kill', 'onTurnEnd'];
107
+
108
+ // ---------- paneInput payload validation (the port contract, in one place) ----------
109
+ // Lives HERE, not in an implementation, because every harness that offers
110
+ // paneInput must enforce the SAME contract: a fake that is laxer than the real
111
+ // thing turns route tests green against payloads tmux would choke on. port.js
112
+ // has no dependencies, so both the tmux adapters and the fake can require it.
113
+ //
114
+ // KEY_RE — tmux's key-name grammar. Anchored, and no branch can begin with '-':
115
+ // tmux is spawned via execFile (an argv array, so no shell) and sendKey passes
116
+ // `--`, but a name that looks like a flag has no business reaching argv at all.
117
+ // The punctuation branch is the five control keys that are not letters — C-[
118
+ // (Escape on a lot of muscle memory), C-\, C-], C-^, C-_ — every one verified
119
+ // accepted by tmux 3.4. The client emits them, so the grammar must too.
120
+ const KEY_RE = /^(C-|M-|S-)*([A-Za-z0-9]+|[[\\\]^_])$/;
121
+ // One POST must not be able to shove a whole file into a live agent's pane —
122
+ // and, more sharply, must not hand tmux more than tmux can take. Single-line
123
+ // text rides `send-keys -l -- <text>` in ARGV, and a tmux client packs one
124
+ // command into a single imsg: MAX_IMSGSIZE 16384 minus the 16-byte header, so
125
+ // the whole NUL-packed argv must fit in 16368 bytes. Measured against tmux 3.4:
126
+ // `send-keys -t <target> -l -- <text>` succeeds while
127
+ // target.length + text.length <= 16343 and fails at 16344 with "failed to send
128
+ // command" — the same total for an 8-char target and a 49-char one, which is
129
+ // how we know the budget is the command, not the payload. Multi-line text is
130
+ // unconstrained (it rides load-buffer's STDIN), but one cap is honest and does
131
+ // not drift; 16 KB less 512 bytes leaves room for the longest pane target plus
132
+ // the fixed argv words.
133
+ const PANE_INPUT_MAX = 16 * 1024 - 512;
134
+
135
+ // validatePaneInput(input) -> { key, text } — exactly one of the two is
136
+ // non-empty. Throws with the reason otherwise; callers let it propagate.
137
+ function validatePaneInput(input) {
138
+ const key = input && input.key != null ? String(input.key) : '';
139
+ const text = input && input.text != null ? String(input.text) : '';
140
+ if (key && text) throw new Error('paneInput: pass key or text, not both');
141
+ if (!key && !text) throw new Error('paneInput: nothing to send (pass key or text)');
142
+ if (key && !KEY_RE.test(key)) throw new Error(`paneInput: invalid tmux key name "${key}"`);
143
+ // BYTES, not String.length: argv is UTF-8, so 16384 emoji is 65536 bytes and
144
+ // would sail past a UTF-16-unit check to die as `spawn E2BIG`.
145
+ const bytes = Buffer.byteLength(text, 'utf8');
146
+ if (bytes > PANE_INPUT_MAX) {
147
+ throw new Error(`paneInput: text too long (${bytes} > ${PANE_INPUT_MAX} bytes)`);
148
+ }
149
+ return { key, text };
150
+ }
151
+
152
+ // Builtins are lazy-required so requiring port.js never drags in tmux/claude
153
+ // machinery for callers that only use the fake.
154
+ const BUILTINS = {
155
+ claude: './claude-tmux.js',
156
+ codex: './codex-tmux.js',
157
+ fake: './fake.js',
158
+ };
159
+
160
+ const registry = new Map();
161
+
162
+ function validateImpl(name, impl) {
163
+ if (!impl || typeof impl !== 'object') {
164
+ throw new TypeError(`harness "${name}": implementation must be an object`);
165
+ }
166
+ for (const verb of VERBS) {
167
+ if (typeof impl[verb] !== 'function') {
168
+ throw new TypeError(`harness "${name}": missing verb ${verb}()`);
169
+ }
170
+ }
171
+ return impl;
172
+ }
173
+
174
+ function registerHarness(name, impl) {
175
+ if (!name || typeof name !== 'string') throw new TypeError('harness name must be a non-empty string');
176
+ registry.set(name, validateImpl(name, impl));
177
+ return impl;
178
+ }
179
+
180
+ function getHarness(name) {
181
+ if (registry.has(name)) return registry.get(name);
182
+ if (Object.prototype.hasOwnProperty.call(BUILTINS, name)) {
183
+ const impl = validateImpl(name, require(BUILTINS[name]));
184
+ registry.set(name, impl);
185
+ return impl;
186
+ }
187
+ throw new Error(`unknown harness "${name}" (known: ${[...new Set([...registry.keys(), ...Object.keys(BUILTINS)])].join(', ')})`);
188
+ }
189
+
190
+ // isHarnessRef — structural check for a persisted/deserialized ref.
191
+ function isHarnessRef(ref) {
192
+ return !!ref
193
+ && typeof ref === 'object'
194
+ && typeof ref.harness === 'string' && ref.harness.length > 0
195
+ && typeof ref.session === 'string' && ref.session.length > 0
196
+ && (ref.window === undefined || (typeof ref.window === 'string' && ref.window.length > 0))
197
+ && typeof ref.cwd === 'string' && ref.cwd.length > 0
198
+ && (ref.resumeId === undefined || typeof ref.resumeId === 'string');
199
+ }
200
+
201
+ // harnessFor(ref) — dispatch helper: the implementation a ref belongs to.
202
+ function harnessFor(ref) {
203
+ if (!isHarnessRef(ref)) throw new TypeError('not a HarnessRef: ' + JSON.stringify(ref));
204
+ return getHarness(ref.harness);
205
+ }
206
+
207
+ module.exports = { VERBS, registerHarness, getHarness, isHarnessRef, harnessFor,
208
+ validatePaneInput, KEY_RE, PANE_INPUT_MAX };