golem-kit 0.1.1 → 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 +8 -5
  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 +19 -12
  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 +85 -13
  38. package/src/client.ts +205 -0
  39. package/src/config.ts +139 -5
  40. package/src/dev-server.ts +336 -39
  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,285 @@
1
+ // Vendored from bridge-commander 5bf87e4b harness/tmux.js (zero-dependency). Local patches are marked 'golem:'.
2
+ 'use strict';
3
+ // tmux primitives for harness implementations.
4
+ //
5
+ // Ported from firstmate's battle-tested bin/fm-tmux-lib.sh (ideas, not code):
6
+ // - ghost-text stripping: TUI harnesses render dim/faint (SGR 2) predicted-prompt
7
+ // "ghost" text inside an otherwise-empty composer; a plain capture cannot tell
8
+ // it from typed input, so the composer line is captured WITH ANSI styling and
9
+ // dim runs are dropped before classification.
10
+ // - composer state: classify the cursor line as empty | pending | unknown after
11
+ // stripping ghost text, box-drawing borders, prompt glyphs, and busy footers.
12
+ // - verified submit: type text ONCE, then send Enter and retry Enter ONLY
13
+ // (never retype — a swallowed Enter leaves the text in the composer and a
14
+ // retype would duplicate it) until the composer reads empty.
15
+ //
16
+ // Zero dependencies; child_process + tmux only.
17
+ //
18
+ // Everything here is async: these primitives run inside the bridge-commander
19
+ // server, whose event loop must never block on a subprocess (a sync tmux
20
+ // call per session per supervision tick froze the whole server — UI, SSE,
21
+ // every request — for the duration).
22
+
23
+ const { execFile } = require('node:child_process');
24
+
25
+ const BUSY_RE = /esc (to )?interrupt|Working\.\.\./i;
26
+ // '›' (U+203A) is codex's composer prompt; without it a cleared codex composer
27
+ // would classify as pending and verified-submit would read every send as stuck.
28
+ const PROMPT_GLYPHS = new Set(['>', '❯' /* ❯ */, '›' /* U+203A, codex */, '$', '%', '#']);
29
+
30
+ function sleep(ms) {
31
+ return new Promise((r) => setTimeout(r, ms));
32
+ }
33
+
34
+ // execFile builds its "Command failed: ..." message out of the FULL argv, so a
35
+ // failed send-keys carries the entire payload — and that message travels all
36
+ // the way out as an HTTP 502 body. A 20 KB paste produced a 20 KB error. The
37
+ // command name and tmux's own stderr are the diagnostic; the payload never was,
38
+ // so the head is kept and the tail is dropped — with stderr re-appended, since
39
+ // execFile puts it AFTER the argv and truncation would otherwise eat the one
40
+ // line that says why ("failed to send command").
41
+ const ERR_MAX = 200;
42
+
43
+ // tmuxRun(args, input?) -> Promise<stdout>; rejects on tmux error. input, when
44
+ // given, is piped to stdin (load-buffer); otherwise stdin is closed immediately.
45
+ function tmuxRun(args, input) {
46
+ return new Promise((resolve, reject) => {
47
+ const child = execFile('tmux', args, { encoding: 'utf8' }, (err, stdout, stderr) => {
48
+ if (err) {
49
+ err.stderr = stderr;
50
+ if (err.message.length > ERR_MAX) {
51
+ err.message = err.message.slice(0, ERR_MAX) + '… ' + String(stderr || '').trim().slice(0, ERR_MAX);
52
+ }
53
+ reject(err);
54
+ } else {
55
+ resolve(stdout);
56
+ }
57
+ });
58
+ child.stdin.on('error', () => {}); // EPIPE when tmux exits before reading
59
+ if (input === undefined) child.stdin.end();
60
+ else child.stdin.end(input);
61
+ });
62
+ }
63
+
64
+ // tmux(...args) -> Promise<stdout string>; rejects on tmux error.
65
+ function tmux(...args) {
66
+ return tmuxRun(args);
67
+ }
68
+
69
+ // tryTmux(...args) -> Promise<stdout string or null on error>.
70
+ async function tryTmux(...args) {
71
+ try {
72
+ return await tmuxRun(args);
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ // tmuxRead(...args) -> stdout, or null when tmux ANSWERS that the thing asked
79
+ // about is not there. Every OTHER failure throws with the reason.
80
+ //
81
+ // tryTmux above cannot tell those apart: it maps "the window is gone" and "I
82
+ // could not run tmux at all" onto the same null, and a caller that reads that
83
+ // null as absence then acts on a fact it never established. The port's contract
84
+ // is the other way round — a verb a harness cannot honor throws with the reason,
85
+ // never silently succeeds — so anything that has to KNOW reads through here.
86
+ //
87
+ // The absence answers are tmux's own words: a session or window it cannot find,
88
+ // and a socket with no server behind it (nothing is running there either way).
89
+ const TMUX_ABSENT_RE = /can't find (session|window|pane)|(session|window|pane) not found|no server running/i;
90
+ async function tmuxRead(...args) {
91
+ try {
92
+ return await tmuxRun(args);
93
+ } catch (e) {
94
+ const said = String((e && e.stderr) || '') + ' ' + String((e && e.message) || e);
95
+ if (TMUX_ABSENT_RE.test(said)) return null;
96
+ throw e;
97
+ }
98
+ }
99
+
100
+ // stripGhost(line) — remove dim/faint (SGR 2) styled runs from one styled
101
+ // capture line, drop all remaining escape sequences, return plain text.
102
+ // A reset (SGR 0) or normal-intensity (SGR 22) ends a dim run; codes are
103
+ // processed left-to-right so "ESC[0;2m" (reset then dim) reads as dim.
104
+ // 38/48/58 extended-color payloads are skipped so their "2" (RGB mode)
105
+ // never reads as the dim code.
106
+ function stripGhost(line) {
107
+ let out = '';
108
+ let dim = false;
109
+ let i = 0;
110
+ const n = line.length;
111
+ while (i < n) {
112
+ const c = line[i];
113
+ if (c === '\x1b') {
114
+ if (line[i + 1] === '[') {
115
+ let j = i + 2;
116
+ let params = '';
117
+ while (j < n && !/[@-~]/.test(line[j])) {
118
+ params += line[j];
119
+ j++;
120
+ }
121
+ if (j < n && line[j] === 'm') {
122
+ const parts = (params === '' ? '0' : params).split(';');
123
+ for (let p = 0; p < parts.length; p++) {
124
+ const v = parts[p];
125
+ const code = (v.split(':')[0] || '0');
126
+ if (code === '38' || code === '48' || code === '58') {
127
+ if (v.includes(':')) continue; // colon form: payload self-contained
128
+ const mode = parts[p + 1] || '';
129
+ if (mode.includes(':')) p += 1;
130
+ else if (mode.split(':')[0] === '5') p += 2;
131
+ else if (mode.split(':')[0] === '2') p += 4;
132
+ else p += 1;
133
+ } else if (code === '2') dim = true;
134
+ else if (code === '0' || code === '22') dim = false;
135
+ }
136
+ }
137
+ i = j < n ? j + 1 : n;
138
+ continue;
139
+ }
140
+ i++; // lone ESC: drop it
141
+ continue;
142
+ }
143
+ if (!dim) out += c;
144
+ i++;
145
+ }
146
+ return out;
147
+ }
148
+
149
+ // classifyComposerLine(raw) -> 'empty' | 'pending' — the pure half of
150
+ // composerState: classify one styled cursor-line capture after stripping
151
+ // ghost text, box borders, prompt glyphs, and busy footers.
152
+ function classifyComposerLine(raw) {
153
+ let s = stripGhost(raw.replace(/\n$/, ''));
154
+ // Strip composer box borders (claude/codex draw "│ … │"; some TUIs use ┃ or |).
155
+ s = s.replace(/[│┃|]/g, '').trim();
156
+ if (s === '') return 'empty';
157
+ if (PROMPT_GLYPHS.has(s)) return 'empty';
158
+ if (BUSY_RE.test(s)) return 'empty'; // busy footer landing on the cursor line
159
+ return 'pending';
160
+ }
161
+
162
+ // composerState(target) -> 'empty' | 'pending' | 'unknown'
163
+ // empty — no pending input (blank, bare prompt glyph, busy footer, or only
164
+ // ghost text). Safe to inject; also the positive ack that a submit landed.
165
+ // pending — real unsubmitted text on the cursor line.
166
+ // unknown — the pane could not be read.
167
+ async function composerState(target) {
168
+ const cy = await tryTmux('display-message', '-p', '-t', target, '#{cursor_y}');
169
+ if (cy === null || !/^\d+$/.test(cy.trim())) return 'unknown';
170
+ const row = cy.trim();
171
+ const raw = await tryTmux('capture-pane', '-e', '-p', '-t', target, '-S', row, '-E', row);
172
+ if (raw === null) return 'unknown';
173
+ const state = classifyComposerLine(raw);
174
+ if (state !== 'empty') return state;
175
+ // golem: codex 0.155 folds a long paste into "[Pasted Content N chars]" and turns the first
176
+ // Enter into a newline INSIDE it, leaving the cursor on an empty line under a full composer.
177
+ // The last prompt-glyph line in the tail is the truth: text after the glyph = still pending.
178
+ const tail = await tryTmux('capture-pane', '-e', '-p', '-t', target, '-S', '-8');
179
+ if (tail === null) return state;
180
+ const lines = tail.split('\n').map((l) => stripGhost(l).replace(/[│┃|]/g, '').trim()).filter(Boolean);
181
+ const glyphLine = lines.findLast((l) => PROMPT_GLYPHS.has(l[0]));
182
+ if (glyphLine && glyphLine.length > 1 && !BUSY_RE.test(glyphLine)) return 'pending';
183
+ return state;
184
+ }
185
+
186
+ // paneIsBusy(target) — do the last few non-blank lines of the pane show a
187
+ // busy footer (agent mid-turn)?
188
+ async function paneIsBusy(target) {
189
+ const tail = await tryTmux('capture-pane', '-p', '-t', target, '-S', '-40');
190
+ if (tail === null) return false;
191
+ const lines = tail.split('\n').filter((l) => l.trim() !== '').slice(-6);
192
+ return BUSY_RE.test(lines.join('\n'));
193
+ }
194
+
195
+ // capture(target, lines) — bounded plain-text pane capture (default 60 lines).
196
+ async function capture(target, lines = 60) {
197
+ const out = await tryTmux('capture-pane', '-p', '-t', target, '-S', `-${lines}`);
198
+ return out === null ? '' : out;
199
+ }
200
+
201
+ // captureStyled(target, lines) — bounded pane capture WITH ANSI styling (-e
202
+ // keeps SGR colors/bold) and scrollback depth (-S -N): the raw material for
203
+ // pane frames (openPane / paneSnapshot). Unlike capture(), an unreadable pane
204
+ // returns null — callers must tell "pane gone" from "pane blank".
205
+ function captureStyled(target, lines = 200) {
206
+ return tryTmux('capture-pane', '-e', '-p', '-t', target, '-S', `-${lines}`);
207
+ }
208
+
209
+ // sendLiteral(target, text) — put text into the composer WITHOUT submitting.
210
+ // Single-line text goes via `send-keys -l`. Multi-line text goes via a tmux
211
+ // buffer paste in bracketed-paste mode (-p) so embedded newlines land as part
212
+ // of the paste instead of acting as Enter presses that submit mid-text.
213
+ //
214
+ // `--` before the text is LOad-bearing, not decoration. execFile passes an argv
215
+ // array, so there is no shell — but tmux still runs the trailing operand
216
+ // through getopt, which permutes: text beginning with '-' is read as FLAGS, not
217
+ // typed. Unguarded, `{text:'-t=<other-session>:'}` retargets send-keys at a
218
+ // pane the caller was never authorised to touch, and `-R`/`-N5`/`--` are
219
+ // swallowed instead of typed. Verified against tmux 3.4 both ways: without `--`
220
+ // the retarget lands, with it every flag-shaped payload arrives as literal
221
+ // text. The multi-line branch was never exposed — that text rides load-buffer's
222
+ // STDIN, never argv — which is why `-R` was dangerous while `-R\n` was not.
223
+ async function sendLiteral(target, text) {
224
+ if (text.includes('\n')) {
225
+ await tmuxRun(['load-buffer', '-b', 'bc-harness', '-'], text);
226
+ await tmux('paste-buffer', '-p', '-d', '-b', 'bc-harness', '-t', target);
227
+ } else {
228
+ await tmux('send-keys', '-t', target, '-l', '--', text);
229
+ }
230
+ }
231
+
232
+ // sendKey(target, key) — one named tmux key ('Enter', 'Escape', 'C-c', ...).
233
+ // Same `--` terminator and the same reason: the key name is an operand too, and
234
+ // this primitive must be safe for every caller rather than only for the ones
235
+ // that happen to validate first.
236
+ function sendKey(target, key) {
237
+ return tmux('send-keys', '-t', target, '--', key);
238
+ }
239
+
240
+ // submit(target, text, opts) — type text once, then Enter with verification.
241
+ // Enter is retried (never the text) until the composer reads empty or retries
242
+ // run out. Returns the final verdict: 'empty' | 'pending' | 'unknown' | 'send-failed'.
243
+ // opts.retries Enter attempts (default 3)
244
+ // opts.enterSleep ms after each Enter before re-checking (default 400)
245
+ // opts.settle ms between typing and the first Enter (default 300; slash
246
+ // commands get 1200 — completion popups swallow a fast Enter)
247
+ async function submit(target, text, opts = {}) {
248
+ const retries = opts.retries ?? 3;
249
+ const enterSleep = opts.enterSleep ?? 400;
250
+ const settle = opts.settle ?? (text.startsWith('/') ? 1200 : 300);
251
+ try {
252
+ await sendLiteral(target, text);
253
+ } catch {
254
+ return 'send-failed';
255
+ }
256
+ await sleep(settle);
257
+ for (let i = 0; i < retries; i++) {
258
+ try {
259
+ await sendKey(target, 'Enter');
260
+ } catch {
261
+ // fall through to state check
262
+ }
263
+ await sleep(enterSleep);
264
+ const state = await composerState(target);
265
+ if (state !== 'pending') return state; // 'empty' (landed) or 'unknown' (inconclusive)
266
+ }
267
+ return 'pending';
268
+ }
269
+
270
+ module.exports = {
271
+ tmux,
272
+ tryTmux,
273
+ tmuxRead,
274
+ sleep,
275
+ stripGhost,
276
+ classifyComposerLine,
277
+ composerState,
278
+ paneIsBusy,
279
+ capture,
280
+ captureStyled,
281
+ sendLiteral,
282
+ sendKey,
283
+ submit,
284
+ BUSY_RE,
285
+ };
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env node
2
+ // Vendored from bridge-commander 5bf87e4b harness/turnend-hook.js (zero-dependency). Local patches are marked 'golem:'.
3
+ 'use strict';
4
+ // turnend-hook.js — the Claude Code Stop-hook relay.
5
+ //
6
+ // Registered by claude-tmux.js in the spawned session's worktree
7
+ // .claude/settings.local.json. Claude Code runs it at every turn boundary
8
+ // with a JSON payload on stdin ({ session_id, hook_event_name, cwd, ... }).
9
+ //
10
+ // It does three things, all best-effort and always exiting 0 fast so it can
11
+ // never wedge the agent:
12
+ // 1. records the claude session id at <stateDir>/<session>.session-id
13
+ // (ground truth for harness.resume, refreshed on every event)
14
+ // 2. appends one JSON line to <stateDir>/<session>.turnend.jsonl —
15
+ // the marker file harness.onTurnEnd() watches
16
+ // 3. optionally POSTs the event to a callback URL (argv[4] or
17
+ // BC_TURNEND_URL) so a server can learn turn boundaries without polling
18
+ //
19
+ // Usage (as a hook command): node turnend-hook.js <stateDir> <session> [url]
20
+
21
+ const fs = require('node:fs');
22
+ const path = require('node:path');
23
+ const { execFileSync } = require('node:child_process');
24
+
25
+ // The hook runs inside the agent's own pane, so its tmux session identifies
26
+ // the session exactly (the server attributes lieutenant turn-ends by it).
27
+ // Empty when not under tmux; never fails the hook when tmux is absent.
28
+ function tmuxSession() {
29
+ if (!process.env.TMUX) return '';
30
+ try {
31
+ return execFileSync('tmux', ['display-message', '-p', '#S'], { encoding: 'utf8' }).trim();
32
+ } catch {
33
+ return '';
34
+ }
35
+ }
36
+
37
+ function readStdin() {
38
+ return new Promise((resolve) => {
39
+ let data = '';
40
+ const timer = setTimeout(() => resolve(data), 3000);
41
+ process.stdin.setEncoding('utf8');
42
+ process.stdin.on('data', (c) => (data += c));
43
+ process.stdin.on('end', () => {
44
+ clearTimeout(timer);
45
+ resolve(data);
46
+ });
47
+ process.stdin.on('error', () => {
48
+ clearTimeout(timer);
49
+ resolve(data);
50
+ });
51
+ });
52
+ }
53
+
54
+ async function main() {
55
+ const stateDir = process.argv[2];
56
+ const session = process.argv[3];
57
+ const url = process.argv[4] || process.env.BC_TURNEND_URL || '';
58
+ if (!stateDir || !session) return;
59
+
60
+ let payload = {};
61
+ try {
62
+ payload = JSON.parse(await readStdin());
63
+ } catch {
64
+ // no/bad payload: still record the turn boundary
65
+ }
66
+
67
+ const event = {
68
+ ts: new Date().toISOString(),
69
+ session,
70
+ event: payload.hook_event_name || 'Stop',
71
+ session_id: payload.session_id || null,
72
+ cwd: payload.cwd || null,
73
+ tmux_session: tmuxSession(),
74
+ };
75
+ // What the agent last said, when the harness hands it over — the stall
76
+ // alert quotes it so the board knows what a silent worker was waiting on.
77
+ if (typeof payload.last_assistant_message === 'string' && payload.last_assistant_message.trim()) {
78
+ event.text = payload.last_assistant_message.trim().slice(0, 300);
79
+ }
80
+
81
+ try {
82
+ fs.mkdirSync(stateDir, { recursive: true });
83
+ if (event.session_id) {
84
+ fs.writeFileSync(path.join(stateDir, `${session}.session-id`), event.session_id + '\n');
85
+ }
86
+ fs.appendFileSync(path.join(stateDir, `${session}.turnend.jsonl`), JSON.stringify(event) + '\n');
87
+ } catch {
88
+ // never fail the hook
89
+ }
90
+
91
+ if (url) {
92
+ try {
93
+ await fetch(url, {
94
+ method: 'POST',
95
+ headers: { 'content-type': 'application/json' },
96
+ body: JSON.stringify(event),
97
+ signal: AbortSignal.timeout(3000),
98
+ });
99
+ } catch {
100
+ // callback is best-effort; the marker file is the reliable channel
101
+ }
102
+ }
103
+ }
104
+
105
+ main().then(() => process.exit(0), () => process.exit(0));