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.
- package/CHANGELOG.md +38 -0
- package/README.md +8 -5
- package/docs/agents.md +64 -0
- package/docs/app-backend.md +261 -0
- package/docs/architecture.md +93 -0
- package/docs/builder.md +15 -0
- package/docs/knowledge.md +35 -0
- package/docs/local-cli.md +22 -12
- 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 +25 -7
- package/src/chat.ts +74 -0
- package/src/cli.ts +103 -14
- package/src/client.ts +205 -0
- package/src/config.ts +139 -5
- package/src/dev-server.ts +336 -39
- package/src/entry.mjs +23 -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,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));
|