golem-kit 0.1.1 → 0.2.1

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 +38 -0
  2. package/README.md +8 -5
  3. package/docs/agents.md +64 -0
  4. package/docs/app-backend.md +261 -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 +22 -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 +25 -7
  36. package/src/chat.ts +74 -0
  37. package/src/cli.ts +103 -14
  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 +23 -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,573 @@
1
+ // Vendored from bridge-commander 5bf87e4b harness/claude-tmux.js (zero-dependency). Local patches are marked 'golem:'.
2
+ 'use strict';
3
+ // claude-tmux — the claude implementation of the harness port, over tmux.
4
+ //
5
+ // HarnessRef: { harness: 'claude', session: 'bc-<id>', window?, cwd, resumeId? }
6
+ // session — tmux session name (predictable `bc-*`, the captain's attach escape hatch)
7
+ // window — when present, the agent lives in a named WINDOW of that session
8
+ // instead of owning the whole session (papercut #8: workers as
9
+ // windows inside their lieutenant's session). Window names must
10
+ // start with a letter — a numeric name would be parsed by tmux as
11
+ // a window INDEX — and every tmux call addresses the pane with the
12
+ // exact-match `=session:=window` form. Lifecycle coupling is
13
+ // accepted design: the session dying takes its windows with it.
14
+ // resumeId — the claude session uuid. Set deterministically at spawn via
15
+ // `--session-id <uuid>` (verified claude 2.1.202), refreshed from
16
+ // Stop-hook payloads. `claude --resume <resumeId>` keeps the SAME
17
+ // id (no fork by default), so the ref survives any number of
18
+ // death/resume cycles.
19
+ //
20
+ // Session/window/pane plumbing is shared with the other tmux adapters —
21
+ // see tmux-session.js. This module owns only what is claude-specific:
22
+ // launch line, screen signatures, the Stop-hook install, and resume.
23
+ //
24
+ // Verified launch template (mined from firstmate's fm-spawn.sh):
25
+ // CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions \
26
+ // --session-id <uuid>
27
+ // - CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false kills the dim "ghost text"
28
+ // prompt suggestion that otherwise reads as pending composer input.
29
+ // - the prompt is NEVER passed on the command line — claude launches bare,
30
+ // and once launch-settle confirms the composer is up, the prompt is typed
31
+ // into it via the same verified-submit machinery send() uses (t.submit).
32
+ // A prompt riding in argv would sit in that process's command line for
33
+ // the life of the session — visible to `ps`/`pgrep -f`, and a broad
34
+ // pattern-kill run BY that very agent (matching its own argv) could
35
+ // freeze or kill itself. The prompt file in stateDir stays the source of
36
+ // truth; only the delivery mechanism changed.
37
+ // - a fresh cwd triggers claude's folder-trust dialog even with
38
+ // --dangerously-skip-permissions (verified); spawn auto-accepts it.
39
+ //
40
+ // Turn boundaries: spawn installs a Stop hook in <cwd>/.claude/settings.local.json
41
+ // running harness/turnend-hook.js, which appends to <stateDir>/<session>.turnend.jsonl
42
+ // (and optionally POSTs to a callback URL). onTurnEnd() tails that file.
43
+
44
+ const fs = require('node:fs');
45
+ const os = require('node:os');
46
+ const path = require('node:path');
47
+ const crypto = require('node:crypto');
48
+ const { execFile } = require('node:child_process');
49
+ const t = require('./tmux.js');
50
+ const s = require('./tmux-session.js');
51
+ const { claudeStatus, SLASH_COMMANDS, helpText, formatStatus } = require('./agent-status.js');
52
+
53
+ const HOOK_SCRIPT = path.join(__dirname, 'turnend-hook.js');
54
+ const TRUST_RE = /Yes, I trust this folder|Quick safety check/;
55
+
56
+ // RESUME_RE — the picker `claude --resume` shows when the transcript is big
57
+ // enough to be worth warning about:
58
+ //
59
+ // Resuming the full session will consume a substantial portion of
60
+ // your usage limits. We recommend resuming from a summary.
61
+ // ❯ 1. Resume from summary (recommended)
62
+ // 2. Resume full session as-is
63
+ //
64
+ // It cost three lieutenants a morning. Supervision found them dead, called
65
+ // resume, hit this screen, waited 45s for a UI that was never coming, and gave
66
+ // up after three tries — and the ones it hit were exactly the ones worth saving,
67
+ // because the picker only appears when there is a lot to lose.
68
+ //
69
+ // Enter takes option 1, the preselected one, and summary is the right default
70
+ // for an UNATTENDED revival: option 2 is spending a substantial slice of the
71
+ // captain's usage limit, and nothing should do that while nobody is watching.
72
+ // He can always resume one by hand and choose otherwise.
73
+ const RESUME_RE = /Resume from summary|Resume full session as-is/;
74
+
75
+ // UI_READY_RE matches signatures only the main UI renders (composer prompt,
76
+ // busy footer, permission-mode footer) and the trust screen does not.
77
+ //
78
+ // ⚠ It is nearly wrong on the resume picker, which draws its own `❯` — and is
79
+ // saved only by `\n❯` demanding column zero while the picker indents. Do not
80
+ // relax that anchor: the picker would then read as READY and every unattended
81
+ // revival would leave a lieutenant sitting on an unanswered menu forever.
82
+ const UI_READY_RE = /bypass permissions|esc (to )?interrupt|\n❯/i;
83
+
84
+ // FATAL_RE — what a pane shows when this launch is never going to come up, so
85
+ // waiting the remaining 44 seconds only delays a wrong guess:
86
+ //
87
+ // root claude refuses --dangerously-skip-permissions as uid 0 (unless
88
+ // IS_SANDBOX=1 / bubblewrap) and exits — verified in the binary.
89
+ // first run a claude nobody has ever run parks on its own setup wizard
90
+ // (theme picker) BEFORE it asks about credentials. Enter is NOT
91
+ // sent at it: answering a stranger's setup wizard blind is how you
92
+ // pick their theme, their login method and their telemetry answer
93
+ // for them.
94
+ // missing the shell answering "command not found" — no binary at all.
95
+ // bypass the one-time "WARNING: Claude Code running in Bypass Permissions
96
+ // mode" consent modal, raised BY --dangerously-skip-permissions.
97
+ // Its preselected option is `1. No, exit`, so it is emphatically
98
+ // not one to answer with a blind Enter, and it is not ours to
99
+ // accept on anyone's behalf: it is a person saying yes to an agent
100
+ // that skips permission prompts on their machine.
101
+ const FATAL_RE = /cannot be used with root\/sudo privileges|Choose the text style|To change this later, run \/theme|claude: command not found|command not found: claude|Bypass Permissions mode|Yes, I accept/;
102
+ const SETTLE = { trustRe: TRUST_RE, resumeRe: RESUME_RE, readyRe: UI_READY_RE, fatalRe: FATAL_RE, label: 'claude' };
103
+
104
+ // mergeLocalSettings(cwd, mutate) — the read-modify-write of
105
+ // <cwd>/.claude/settings.local.json, in ONE place.
106
+ //
107
+ // Two writers own this file: installHooks (the Stop hook every turn boundary on
108
+ // the board rides on) and writeOutputStyle. Neither may clobber the other, so
109
+ // both read first and write the whole object back — and every decision about
110
+ // HOW that is done has to be the same on both sides. Kept apart, the second
111
+ // copy is free to drift: a different indent, or a corrupt file that one hand
112
+ // recovers from and the other throws on, and the drift shows up as a lieutenant
113
+ // that stopped reporting turn ends.
114
+ //
115
+ // A file that is missing, unparseable, or not a JSON object is replaced by {}:
116
+ // there is nothing to preserve in bytes nothing can read, and refusing to write
117
+ // would leave the caller with no hook and no style either.
118
+ function mergeLocalSettings(cwd, mutate) {
119
+ const dir = path.join(cwd, '.claude');
120
+ const file = path.join(dir, 'settings.local.json');
121
+ fs.mkdirSync(dir, { recursive: true });
122
+ let settings;
123
+ try {
124
+ settings = JSON.parse(fs.readFileSync(file, 'utf8'));
125
+ } catch {
126
+ settings = null;
127
+ }
128
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) settings = {};
129
+ mutate(settings);
130
+ fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
131
+ return file;
132
+ }
133
+
134
+ // excludeLocalSettings(cwd) — hide .claude/settings.local.json from git
135
+ // (info/exclude) when cwd is a repo, so a file we wrote never dirties someone's
136
+ // worktree. Sits next to mergeLocalSettings for the same reason: every writer of
137
+ // that file has to make the same decisions about it, and a writer that skipped
138
+ // this step left the untracked file this step exists to prevent. Best-effort —
139
+ // not a repo, no permission, nothing to exclude, and the write still stands.
140
+ async function excludeLocalSettings(cwd) {
141
+ try {
142
+ const gitDir = (await new Promise((resolve, reject) => {
143
+ execFile('git', ['-C', cwd, 'rev-parse', '--git-path', 'info/exclude'],
144
+ { encoding: 'utf8' }, (err, stdout) => (err ? reject(err) : resolve(stdout)));
145
+ })).trim();
146
+ const excl = path.isAbsolute(gitDir) ? gitDir : path.join(cwd, gitDir);
147
+ fs.mkdirSync(path.dirname(excl), { recursive: true });
148
+ const cur = fs.existsSync(excl) ? fs.readFileSync(excl, 'utf8') : '';
149
+ if (!cur.split('\n').includes('.claude/settings.local.json')) {
150
+ fs.appendFileSync(excl, '.claude/settings.local.json\n');
151
+ }
152
+ } catch {
153
+ // not a git repo — nothing to exclude
154
+ }
155
+ }
156
+
157
+ // installHooks — write/merge the Stop hook into <cwd>/.claude/settings.local.json.
158
+ // Idempotent; preserves any existing settings/hooks. Also hides the file from
159
+ // git (info/exclude) when cwd is a repo, so it never dirties a worktree.
160
+ async function installHooks(cwd, session, stateDir, callbackUrl) {
161
+ const command = ['node', s.shellQuote(HOOK_SCRIPT), s.shellQuote(stateDir), s.shellQuote(session)]
162
+ .concat(callbackUrl ? [s.shellQuote(callbackUrl)] : [])
163
+ .join(' ');
164
+ mergeLocalSettings(cwd, (settings) => {
165
+ if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
166
+ if (!Array.isArray(settings.hooks.Stop)) settings.hooks.Stop = [];
167
+ const ours = settings.hooks.Stop.some((m) =>
168
+ Array.isArray(m.hooks) && m.hooks.some((h) => h.command === command));
169
+ if (!ours) {
170
+ // Drop stale bc hook entries (e.g. a previous session in this cwd) first.
171
+ settings.hooks.Stop = settings.hooks.Stop.filter((m) =>
172
+ !(Array.isArray(m.hooks) && m.hooks.some((h) =>
173
+ typeof h.command === 'string' && h.command.includes(HOOK_SCRIPT))));
174
+ settings.hooks.Stop.push({ hooks: [{ type: 'command', command }] });
175
+ }
176
+ });
177
+ await excludeLocalSettings(cwd);
178
+ }
179
+
180
+ // sandboxPrefix(allowRoot) — claude refuses --dangerously-skip-permissions as
181
+ // uid 0 and exits, so as root there is no session to have unless the caller has
182
+ // said, in as many words, that this box is a throwaway. IS_SANDBOX=1 is the
183
+ // escape claude itself checks; it is never set on our own initiative. Off root
184
+ // the consent is inert, so spawn and resume both ask here rather than each
185
+ // deciding for themselves.
186
+ // golem: permissionFlags(profile) — 'bypass' (default) is the builder's YOLO launch; 'readonly'
187
+ // is the chat window's: dontAsk refuses instead of prompting (a prompt in a headless pane hangs
188
+ // the chat forever), reads stay open, and the only Bash allowed is `./golem say`, the agent's
189
+ // one way to answer the user. --allowedTools/--disallowedTools are variadic: --permission-mode
190
+ // closes them so whatever follows on the launch line is never eaten as a tool name.
191
+ const PERMISSION_FLAGS = {
192
+ bypass: '--dangerously-skip-permissions',
193
+ readonly: "--allowedTools 'Read,Grep,Glob,Bash(./golem say:*)' --disallowedTools 'Edit,Write,NotebookEdit' --permission-mode dontAsk",
194
+ };
195
+ function permissionFlags(profile) {
196
+ const flags = PERMISSION_FLAGS[profile || 'bypass'];
197
+ if (!flags) throw new Error(`unknown permission profile: ${profile}`);
198
+ return flags;
199
+ }
200
+
201
+ function sandboxPrefix(allowRoot) {
202
+ const asRoot = allowRoot && typeof process.getuid === 'function' && process.getuid() === 0;
203
+ return asRoot ? 'IS_SANDBOX=1 ' : '';
204
+ }
205
+
206
+ // spawn(cwd, prompt, opts?) -> HarnessRef
207
+ // opts: { session?, window?, stateDir?, callbackUrl?, extraArgs?: string[], installHooks?: boolean }
208
+ // window: birth the agent as a named window inside `session` (which must then
209
+ // be given too) instead of owning a whole session; the session is created on
210
+ // demand when it is not up yet.
211
+ // installHooks: false skips the per-spawn Stop-hook install — for sessions born
212
+ // into a cwd that already carries a workspace-level hook (installing another
213
+ // would clobber it: installHooks keeps ONE bc entry per settings file).
214
+ async function spawn(cwd, prompt, opts = {}) {
215
+ const cwdAbs = path.resolve(cwd);
216
+ if (!fs.existsSync(cwdAbs)) throw new Error(`spawn cwd does not exist: ${cwdAbs}`);
217
+ const { session, window } = await s.claimPaneNames(opts);
218
+ const stateDir = s.stateDirOf(opts);
219
+ const resumeId = crypto.randomUUID();
220
+ const key = s.stateKey(session, window);
221
+
222
+ if (opts.installHooks !== false) {
223
+ await installHooks(cwdAbs, key, stateDir, opts.callbackUrl || process.env.BC_TURNEND_URL || '');
224
+ }
225
+
226
+ const promptFile = path.join(stateDir, `${key}.prompt`);
227
+ fs.writeFileSync(promptFile, prompt);
228
+ // Recorded so resume() can replay them — a worker pinned to a model by its
229
+ // playbook must not come back on the default one (tmux-session.js).
230
+ s.recordSpawnArgs(stateDir, key, opts);
231
+
232
+ await s.createPane(session, window, cwdAbs);
233
+ try {
234
+ const extra = (opts.extraArgs || []).map(s.shellQuote).join(' ');
235
+ const launchCmd = sandboxPrefix(opts.allowRoot)
236
+ + s.envPrefix(opts) /* golem */ + 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false '
237
+ + `claude ${permissionFlags(opts.permissions)} --session-id ${resumeId}`
238
+ + (extra ? ' ' + extra : '');
239
+ await s.launchAndSettle(s.paneTarget(session, window), launchCmd, SETTLE);
240
+ await deliverPrompt(s.paneTarget(session, window), prompt);
241
+ // Returning is a claim that there is a session here. Check it, once, against
242
+ // the pane — a settle that matched a modal's own wording is exactly how a
243
+ // spawn came to report success over a consent screen nobody had answered.
244
+ await s.verifyLive(s.paneTarget(session, window), SETTLE);
245
+ } catch (err) {
246
+ await s.killPane(session, window);
247
+ try { fs.unlinkSync(promptFile); } catch { /* best-effort */ }
248
+ throw err;
249
+ }
250
+
251
+ const ref = { harness: 'claude', session, cwd: cwdAbs, resumeId };
252
+ if (window) ref.window = window;
253
+ return ref;
254
+ }
255
+
256
+ // deliverPrompt(target, prompt) — type the brief into the just-settled
257
+ // composer with verified submission (t.submit — same mechanism send() uses:
258
+ // type once, retry only Enter, never retype). Runs once, right after
259
+ // launchAndSettle confirms the main UI is up, so the brief never rides in
260
+ // argv (see the file-header note on why that matters).
261
+ async function deliverPrompt(target, prompt) {
262
+ const verdict = await t.submit(target, prompt, {
263
+ retries: Number(process.env.BC_SEND_RETRIES || 3),
264
+ enterSleep: Number(process.env.BC_SEND_SLEEP_MS || 400),
265
+ });
266
+ if (verdict === 'pending' || verdict === 'send-failed') {
267
+ // The pane rides on THIS failure too. A launch that settles and then will
268
+ // not take the brief is the interesting case — the screen underneath is
269
+ // usually a login prompt or a trust dialog wearing a composer's clothes —
270
+ // and without the tail the caller is left with nothing to diagnose from.
271
+ throw new Error((verdict === 'pending'
272
+ ? 'brief not submitted at spawn (Enter swallowed; text left in composer)'
273
+ : 'brief not sent at spawn (tmux send failed)') + '; pane tail:\n' + (await paneTailSafe(target)));
274
+ }
275
+ }
276
+ async function paneTailSafe(target) {
277
+ try { return (await t.capture(target, 20)) || ''; } catch (e) { return ''; }
278
+ }
279
+
280
+ // send(ref, text) — type into the session with verified submission.
281
+ // Enter is retried, never the text. Throws when the submit provably failed.
282
+ async function send(ref, text) {
283
+ const name = s.stateKey(ref.session, ref.window);
284
+ if (!(await alive(ref))) throw new Error(`session ${name} is not alive`);
285
+ const verdict = await t.submit(s.paneTarget(ref.session, ref.window), text, {
286
+ retries: Number(process.env.BC_SEND_RETRIES || 3),
287
+ enterSleep: Number(process.env.BC_SEND_SLEEP_MS || 400),
288
+ });
289
+ if (verdict === 'pending') {
290
+ throw new Error(`text not submitted to ${name} (Enter swallowed; text left in composer)`);
291
+ }
292
+ if (verdict === 'send-failed') {
293
+ throw new Error(`text not sent to ${name} (tmux send failed)`);
294
+ }
295
+ // 'empty' = confirmed; 'unknown' = pane unreadable, assume sent (lenient —
296
+ // an unreadable pane must not turn a normal send into a false error).
297
+ await t.sleep(1000); // let the turn spin up so an immediate capture sees it working
298
+ }
299
+
300
+ // alive(ref) — the ref's session (and window, for window-granular refs) exists
301
+ // AND its pane is still running the agent (a pane sitting back at a bare shell
302
+ // means claude exited).
303
+ async function alive(ref) {
304
+ // STRICT: this answer is what the board drops worker records on, so a tmux it
305
+ // could not read must throw rather than pass for "the pane is gone".
306
+ if (!(await s.paneExists(ref.session, ref.window, { strict: true }))) return false;
307
+ const cmd = await s.paneCommand(s.paneTarget(ref.session, ref.window), { strict: true });
308
+ return cmd !== null && !s.SHELLS.has(cmd);
309
+ }
310
+
311
+ // resumable(ref, opts?) -> bool — would resume(ref) restore memory? True when a
312
+ // resume id is recoverable: ref.resumeId, or the hook-recorded session-id file
313
+ // in the state dir. Introspection only, no side effects beyond ensuring the
314
+ // state dir exists — the server uses it to pick resume vs relaunch-with-charter.
315
+ async function resumable(ref, opts = {}) {
316
+ if (ref.resumeId) return true;
317
+ try {
318
+ return !!fs.readFileSync(path.join(s.stateDirOf(opts), `${s.stateKey(ref.session, ref.window)}.session-id`), 'utf8').trim();
319
+ } catch {
320
+ return false;
321
+ }
322
+ }
323
+
324
+ // resume(ref) -> HarnessRef — reincarnate a dead session with memory when possible.
325
+ // Prefers the hook-recorded session id (ground truth) over ref.resumeId, kills
326
+ // any leftover dead tmux session, relaunches `claude --resume <id>` in a fresh
327
+ // session under the same name. Without any resume id, launches fresh (memory lost).
328
+ async function resume(ref, opts = {}) {
329
+ if (await alive(ref)) return { ...ref };
330
+ const stateDir = s.stateDirOf(opts);
331
+ const key = s.stateKey(ref.session, ref.window);
332
+ let resumeId = ref.resumeId;
333
+ // golem: every conversation of an app shares one tmux name, so the recorded id is whichever
334
+ // agent ran there last; a ref that knows its own id wins over the file.
335
+ if (!resumeId) {
336
+ try {
337
+ const rec = fs.readFileSync(path.join(stateDir, `${key}.session-id`), 'utf8').trim();
338
+ if (rec) resumeId = rec;
339
+ } catch {
340
+ // no recorded id — fall back to the ref's
341
+ }
342
+ }
343
+ await s.killPane(ref.session, ref.window); // clear any dead pane still holding the name
344
+
345
+ if (opts.installHooks !== false) {
346
+ await installHooks(ref.cwd, key, stateDir, opts.callbackUrl || process.env.BC_TURNEND_URL || '');
347
+ }
348
+ await s.createPane(ref.session, ref.window, ref.cwd);
349
+ try {
350
+ // The spawn's launch facts are replayed, not rebuilt: --model/--effort came
351
+ // from the card's playbook and a resume that drops them is a worker quietly
352
+ // moved to another model, and a root session that comes back without
353
+ // IS_SANDBOX=1 does not come back at all. opts, when given, wins over the
354
+ // record. A missing or corrupt record is no flags and no prefix, never a throw.
355
+ const rec = s.recordedSpawnArgs(stateDir, key);
356
+ const extra = (opts.extraArgs || rec.args).map(String);
357
+ const parts = ['claude', permissionFlags(opts.permissions || rec.permissions)];
358
+ if (resumeId) parts.push('--resume', resumeId);
359
+ for (const a of extra) parts.push(s.shellQuote(a));
360
+ const launchCmd = sandboxPrefix(opts.allowRoot || rec.allowRoot)
361
+ + s.envPrefix(opts) /* golem */ + 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false ' + parts.join(' ');
362
+ await s.launchAndSettle(s.paneTarget(ref.session, ref.window), launchCmd, SETTLE);
363
+ } catch (err) {
364
+ await s.killPane(ref.session, ref.window);
365
+ throw err;
366
+ }
367
+ const out = { harness: 'claude', session: ref.session, cwd: ref.cwd, resumeId };
368
+ if (ref.window) out.window = ref.window;
369
+ return out;
370
+ }
371
+
372
+ // kill(ref) — end the agent's pane for good. Idempotent: killing a dead or
373
+ // missing one is a no-op. Session-granular refs take the whole session;
374
+ // window-granular refs take ONLY their window (the lieutenant and sibling
375
+ // workers cohabit the session). Harness state files are left behind on
376
+ // purpose — resumeId and the turn-end log are cheap, and a later resume(ref)
377
+ // can still reincarnate the conversation if the kill turns out premature.
378
+ async function kill(ref) {
379
+ await s.killPane(ref.session, ref.window);
380
+ }
381
+
382
+ // ---------- slash commands + status (OPTIONAL capability verbs — port.js) ----------
383
+ // status(ref) reads the session transcript claude already writes
384
+ // (~/.claude/projects/<slug(cwd)>/<resumeId>.jsonl — agent-status.js); no
385
+ // resumeId yet or no transcript → null, never a throw.
386
+ // /autocompact is claude-specific (verified against the 2.1.207 binary — the
387
+ // public docs lag behind); like /compact it is a PASS-THROUGH: the literal
388
+ // command line (args included) is typed into the session via verified submit
389
+ // and claude's own implementation runs in-place.
390
+ const PASSTHROUGH = new Set(['/compact', '/autocompact']);
391
+
392
+ // ---------- /output-style (claude only, and NOT a pass-through) ----------
393
+ // claude USED to answer `/output-style`; it does not any more. Verified against
394
+ // the 2.1.239 binary in a live pane: the composer answers "No commands match
395
+ // /output-style" and submitting the line comes back "Unknown command:
396
+ // /output-style". The binary's own migration table says it outright — "/output-
397
+ // style | Open /config → Output style. Output styles still exist as a feature;
398
+ // only the dedicated command was removed". /config is an INTERACTIVE dialog, so
399
+ // a pass-through here would park a worker on exactly the menu this command
400
+ // exists to keep it off.
401
+ //
402
+ // So the board does what claude itself does with the setting: it WRITES it, and
403
+ // says when it lands. outputStyle goes into <ref.cwd>/.claude/settings.local.json
404
+ // — the session's OWN cwd (a worker's worktree, a lieutenant's workspace), never
405
+ // ~/.claude/settings.json, which would repaint every claude on the machine.
406
+ //
407
+ // The setting is read when a session STARTS, so the running conversation keeps
408
+ // the style it was born with and the reply says WHEN the new one lands, without
409
+ // naming a command to get there. It cannot: /reset is a board command that only
410
+ // exists for lieutenant targets, so on a card thread the same reply would send
411
+ // the captain at a command the worker session refuses as unknown — a reply that
412
+ // teaches the board is broken is worse than one that says nothing. (Appending
413
+ // the hint server-side, where the target kind IS known, was considered and
414
+ // rejected: it would park knowledge of one harness command in the server
415
+ // forever to decorate one parenthetical.) The board does not restart a session
416
+ // on the captain's behalf — a kill takes that session's background work with it.
417
+ const OUTPUT_STYLE = '/output-style';
418
+
419
+ // The built-ins, pinned against the 2.1.239 binary's own style table (name and
420
+ // description lifted verbatim) rather than against memory — an earlier list
421
+ // that "everyone knows" was already wrong by two entries. `default` is the
422
+ // no-style entry; the other four are claude's built-in styles.
423
+ const BUILTIN_OUTPUT_STYLES = [
424
+ { value: 'default', description: 'Claude completes coding tasks efficiently and provides concise responses' },
425
+ { value: 'Proactive', description: 'Claude executes immediately, minimizes interruptions, and prefers action over planning' },
426
+ { value: 'Concise', description: 'Claude responds tersely, leading with results and skipping preamble and narration' },
427
+ { value: 'Explanatory', description: 'Claude explains its implementation choices and codebase patterns' },
428
+ { value: 'Learning', description: 'Claude pauses and asks you to write small pieces of code for hands-on practice' },
429
+ ];
430
+
431
+ // The `name:`/`description:` front matter of a style file. Deliberately a
432
+ // couple of lines and not a YAML dependency: these two scalars are the whole
433
+ // contract, and a file that does not have them still has a basename.
434
+ function frontMatter(text) {
435
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
436
+ if (!m) return {};
437
+ const out = {};
438
+ for (const line of m[1].split(/\r?\n/)) {
439
+ const kv = /^([A-Za-z][A-Za-z0-9_-]*)[ \t]*:[ \t]*(.*)$/.exec(line);
440
+ if (kv) out[kv[1].toLowerCase()] = kv[2].trim().replace(/^["']|["']$/g, '');
441
+ }
442
+ return out;
443
+ }
444
+
445
+ // outputStyles(opts?) -> [{ value, description }] — what `/output-style` accepts
446
+ // HERE: the built-ins, plus every *.md under the SESSION's own
447
+ // <cwd>/.claude/output-styles/ (opts.cwd), plus every *.md under the user's
448
+ // ~/.claude/output-styles/. A style is named by its front-matter `name:` (the
449
+ // string the setting takes), and falls back to its basename when the file has
450
+ // none. An unreadable directory or file is not an error — it just means there
451
+ // are fewer custom styles to offer, and the command still works for the rest.
452
+ //
453
+ // The project directory is scanned because we WRITE the setting into that very
454
+ // .claude/ — a style file sitting next to the settings file we are editing, and
455
+ // being told it is unknown, was our own inconsistency and not a missing feature.
456
+ // Verified in a live pane: a style present only in <cwd>/.claude/output-styles/
457
+ // is honoured by the binary (the session reported `# Output Style: ProjOnly`).
458
+ //
459
+ // PRECEDENCE, also verified against the binary rather than inferred — the same
460
+ // `name:` in both directories with different bodies, and the session emitted the
461
+ // PROJECT one. So the project entry shadows the user entry, and the project
462
+ // directory is scanned first (first name in wins). Built-ins are seeded into
463
+ // `taken` before either, so no custom file can shadow a built-in name — the
464
+ // existing rule, unchanged.
465
+ function outputStyles(opts = {}) {
466
+ const out = BUILTIN_OUTPUT_STYLES.map((st) => ({ ...st }));
467
+ const userDir = opts.stylesDir || process.env.BC_CLAUDE_OUTPUT_STYLES_DIR
468
+ || path.join(os.homedir(), '.claude', 'output-styles');
469
+ const dirs = [];
470
+ if (opts.cwd) dirs.push(path.join(opts.cwd, '.claude', 'output-styles'));
471
+ dirs.push(userDir);
472
+ const taken = new Set(out.map((st) => st.value.toLowerCase()));
473
+ for (const dir of dirs) {
474
+ let files;
475
+ try {
476
+ files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort();
477
+ } catch {
478
+ continue; // no directory, no permission — never a throw, just fewer styles
479
+ }
480
+ for (const f of files) {
481
+ let fm;
482
+ try {
483
+ fm = frontMatter(fs.readFileSync(path.join(dir, f), 'utf8'));
484
+ } catch {
485
+ continue; // unreadable file — skip it, the rest of the list still stands
486
+ }
487
+ const value = fm.name || path.basename(f, '.md');
488
+ if (!value || taken.has(value.toLowerCase())) continue;
489
+ taken.add(value.toLowerCase());
490
+ out.push({ value, description: fm.description || 'custom output style (' + f + ')' });
491
+ }
492
+ }
493
+ return out;
494
+ }
495
+
496
+ // writeOutputStyle — one key, through the shared merge, because installHooks
497
+ // writes its Stop hook into this very file and must survive the write.
498
+ async function writeOutputStyle(cwd, style) {
499
+ mergeLocalSettings(cwd, (settings) => { settings.outputStyle = style; });
500
+ await excludeLocalSettings(cwd);
501
+ }
502
+
503
+ // commands(ref?) — the ref is what makes the style list this SESSION's list: a
504
+ // style installed in the worker's own worktree is offered to that worker and to
505
+ // nobody else. Without a ref (a bare /help, a caller with no session in hand)
506
+ // only the user-level directory is scanned, as before.
507
+ function commands(ref) {
508
+ return SLASH_COMMANDS.map((c) => ({ ...c })).concat([
509
+ { name: '/autocompact', description: 'set how full the context gets before auto-compaction' },
510
+ {
511
+ name: OUTPUT_STYLE,
512
+ description: 'set this session\'s output style (applies on its next conversation)',
513
+ args: outputStyles({ cwd: ref && ref.cwd }),
514
+ },
515
+ ]);
516
+ }
517
+ async function status(ref) {
518
+ return claudeStatus(ref);
519
+ }
520
+ async function runCommand(ref, command, opts = {}) {
521
+ const line = String(command || '').trim();
522
+ const name = line.split(/\s+/)[0];
523
+ const key = s.stateKey(ref.session, ref.window);
524
+ if (name === '/help') return helpText(commands(ref));
525
+ if (name === '/status') {
526
+ const st = await status(ref);
527
+ if (!st) throw new Error('no status for ' + key + ' — session transcript not found');
528
+ return formatStatus(st);
529
+ }
530
+ if (name === OUTPUT_STYLE) {
531
+ // Everything after the command name is ONE style name — a style file may
532
+ // carry spaces in its `name:`, so the argument is not tokenized.
533
+ const want = line.slice(name.length).trim();
534
+ const styles = outputStyles({ stylesDir: opts.stylesDir, cwd: ref.cwd });
535
+ const available = styles.map((st) => st.value).join(', ');
536
+ // The bare form is refused rather than typed: claude has no /output-style
537
+ // to answer it, and the whole point is that nobody has to remember the list.
538
+ if (!want) throw new Error(OUTPUT_STYLE + ' needs a style name — available: ' + available);
539
+ const hit = styles.find((st) => st.value.toLowerCase() === want.toLowerCase());
540
+ // Refused before anything is written: a bad name must not silently sit in
541
+ // the settings file waiting to surprise the next conversation.
542
+ if (!hit) throw new Error('unknown output style "' + want + '" — available: ' + available);
543
+ await writeOutputStyle(ref.cwd, hit.value);
544
+ return 'output style set to ' + hit.value
545
+ + ' — it applies the next time this session starts';
546
+ }
547
+ if (PASSTHROUGH.has(name)) {
548
+ await send(ref, line); // verified submit; claude's own command runs in-session
549
+ return '"' + line + '" submitted to ' + key + ' — the session runs it in-place';
550
+ }
551
+ throw new Error('unknown command ' + name + ' (see /help)');
552
+ }
553
+
554
+ // onTurnEnd / openPane / paneSnapshot / paneInput / adoptWindow — the shared
555
+ // implementations verbatim (tmux-session.js): the Stop-hook relay writes the
556
+ // same turnend.jsonl shape every tmux adapter tails, pane viewing is pure
557
+ // capture-pane, pane input is pure send-keys, and adoption is pure
558
+ // rename-window.
559
+ const { onTurnEnd, openPane, paneSnapshot, paneInput, adoptWindow } = s;
560
+
561
+ // installHooks is exported beyond the seven port verbs so `bc-axi init` can
562
+ // install the workspace-level Stop hook (session-agnostic; the server dedupes
563
+ // turn-end POSTs by session_id). openPane/paneSnapshot/paneInput and
564
+ // commands/runCommand/status are OPTIONAL capability verbs (port.js).
565
+ module.exports = { spawn, send, alive, resumable, resume, kill, onTurnEnd, installHooks, permissionFlags,
566
+ openPane, paneSnapshot, paneInput, commands, runCommand, status, adoptWindow,
567
+ // Exported for the tests that pin the style list against a temp directory and
568
+ // the built-ins against the binary.
569
+ outputStyles, BUILTIN_OUTPUT_STYLES,
570
+ // Exported for the test that pins them against REAL captured screens. These
571
+ // regexes decide whether an unattended revival works or sits on a menu until
572
+ // it is given up on, and that is not a judgement to make by reading them.
573
+ SETTLE };
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ // Vendored from bridge-commander 5bf87e4b harness/codex-notify.js (zero-dependency). Local patches are marked 'golem:'.
3
+ 'use strict';
4
+ // codex-notify.js — the codex turn-end relay (analog of turnend-hook.js).
5
+ //
6
+ // Wired at launch by codex-tmux.js via
7
+ // -c notify='["node","<this script>","<stateDir>","<key>","<url>"]'
8
+ // codex invokes the program at every turn boundary with its payload JSON
9
+ // APPENDED AS THE FINAL ARGV (not stdin):
10
+ // { "type": "agent-turn-complete", "thread-id": "<uuid>", "turn-id": "...",
11
+ // "cwd": "/abs/worktree", "input-messages": [...], "last-assistant-message": "..." }
12
+ //
13
+ // It normalizes that payload into the EXACT event shape the claude Stop-hook
14
+ // relay emits, so the server's /api/turn-end and the harness onTurnEnd() tail
15
+ // consume codex turn boundaries unchanged:
16
+ // { ts, session: <key>, event: 'turn-end', session_id: <thread-id>, cwd, tmux_session }
17
+ //
18
+ // It does three things, all best-effort and always exiting 0 fast so it can
19
+ // never wedge the agent:
20
+ // 1. records the codex thread-id at <stateDir>/<key>.session-id
21
+ // (ground truth for harness.resume, refreshed on every turn)
22
+ // 2. appends one JSON line to <stateDir>/<key>.turnend.jsonl —
23
+ // the marker file harness.onTurnEnd() watches
24
+ // 3. optionally POSTs the event to a callback URL so a server can learn
25
+ // turn boundaries without polling
26
+ //
27
+ // Usage (as the notify program): node codex-notify.js <stateDir> <key> [url] <payloadJSON>
28
+
29
+ const fs = require('node:fs');
30
+ const path = require('node:path');
31
+ const { execFileSync } = require('node:child_process');
32
+
33
+ // The relay runs inside the agent's own pane, so its tmux session identifies
34
+ // the session exactly (the server attributes lieutenant turn-ends by it).
35
+ // Empty when not under tmux; never fails the relay when tmux is absent.
36
+ function tmuxSession() {
37
+ if (!process.env.TMUX) return '';
38
+ try {
39
+ return execFileSync('tmux', ['display-message', '-p', '#S'], { encoding: 'utf8' }).trim();
40
+ } catch {
41
+ return '';
42
+ }
43
+ }
44
+
45
+ async function main() {
46
+ const argv = process.argv;
47
+ const stateDir = argv[2];
48
+ const key = argv[3];
49
+ // codex appends the payload as the LAST argv; with a url wired the argv is
50
+ // [node, script, stateDir, key, url, payload], without it one shorter.
51
+ if (!stateDir || !key || argv.length < 5) return;
52
+ const url = (argv.length >= 6 ? argv[4] : '') || process.env.BC_TURNEND_URL || '';
53
+
54
+ let payload = {};
55
+ try {
56
+ payload = JSON.parse(argv[argv.length - 1]);
57
+ } catch {
58
+ return; // junk payload: nothing to relay
59
+ }
60
+ if (!payload || payload.type !== 'agent-turn-complete') return; // other notify kinds are not turn boundaries
61
+
62
+ const event = {
63
+ ts: new Date().toISOString(),
64
+ session: key,
65
+ event: 'turn-end',
66
+ session_id: payload['thread-id'] || null,
67
+ cwd: payload.cwd || null,
68
+ tmux_session: tmuxSession(),
69
+ };
70
+
71
+ try {
72
+ fs.mkdirSync(stateDir, { recursive: true });
73
+ if (event.session_id) {
74
+ fs.writeFileSync(path.join(stateDir, `${key}.session-id`), event.session_id + '\n');
75
+ }
76
+ fs.appendFileSync(path.join(stateDir, `${key}.turnend.jsonl`), JSON.stringify(event) + '\n');
77
+ } catch {
78
+ // never fail the relay
79
+ }
80
+
81
+ if (url) {
82
+ try {
83
+ await fetch(url, {
84
+ method: 'POST',
85
+ headers: { 'content-type': 'application/json' },
86
+ body: JSON.stringify(event),
87
+ signal: AbortSignal.timeout(3000),
88
+ });
89
+ } catch {
90
+ // callback is best-effort; the marker file is the reliable channel
91
+ }
92
+ }
93
+ }
94
+
95
+ main().then(() => process.exit(0), () => process.exit(0));