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.
- package/CHANGELOG.md +31 -0
- package/README.md +11 -6
- package/docs/agents.md +64 -0
- package/docs/app-backend.md +259 -0
- package/docs/architecture.md +93 -0
- package/docs/builder.md +15 -0
- package/docs/knowledge.md +35 -0
- package/docs/local-cli.md +31 -15
- package/docs/source-development.md +31 -0
- package/index.html +9 -0
- package/package.json +24 -5
- package/src/backend/accounts.ts +287 -0
- package/src/backend/app.ts +269 -0
- package/src/backend/files.ts +68 -0
- package/src/backend/http.ts +276 -0
- package/src/backend/index.ts +10 -0
- package/src/backend/jobs.ts +302 -0
- package/src/backend/jsonl.ts +87 -0
- package/src/backend/knowledge.ts +264 -0
- package/src/backend/model.ts +129 -0
- package/src/backend/rules.ts +53 -0
- package/src/backend/sqlite.ts +73 -0
- package/src/backend/views.ts +216 -0
- package/src/brain.ts +94 -0
- package/src/browser/adapters.ts +229 -53
- package/src/browser/ansi.ts +104 -0
- package/src/browser/app.d.ts +5 -2
- package/src/browser/app.tsx +167 -39
- package/src/browser/groups.tsx +29 -0
- package/src/browser/main.tsx +1 -0
- package/src/browser/panekeys.ts +34 -0
- package/src/browser/sources.tsx +113 -0
- package/src/browser/styles.css +36 -0
- package/src/browser/terminal.tsx +89 -0
- package/src/browser-build.ts +20 -7
- package/src/chat.ts +74 -0
- package/src/cli.ts +91 -17
- package/src/client.ts +205 -0
- package/src/config.ts +169 -0
- package/src/dev-server.ts +339 -38
- package/src/entry.mjs +19 -0
- package/src/eslint.mjs +55 -0
- package/src/operations.ts +169 -0
- package/src/runtime/assistant.ts +141 -0
- package/src/runtime/discovery.ts +13 -7
- package/src/runtime/harness/agent-status.js +388 -0
- package/src/runtime/harness/claude-tmux.js +573 -0
- package/src/runtime/harness/codex-notify.js +95 -0
- package/src/runtime/harness/codex-tmux.js +292 -0
- package/src/runtime/harness/fake.js +430 -0
- package/src/runtime/harness/package.json +1 -0
- package/src/runtime/harness/port.js +208 -0
- package/src/runtime/harness/tmux-session.js +556 -0
- package/src/runtime/harness/tmux.js +285 -0
- package/src/runtime/harness/turnend-hook.js +105 -0
- package/src/runtime/session.ts +171 -34
- package/src/runtime/tmux.ts +173 -0
- package/src/runtime/tool-names.ts +19 -0
- package/src/source-mode.ts +56 -0
- package/vite.config.ts +2 -4
- 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 };
|