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.
- package/CHANGELOG.md +31 -0
- package/README.md +8 -5
- 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 +19 -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 +20 -7
- package/src/chat.ts +74 -0
- package/src/cli.ts +85 -13
- package/src/client.ts +205 -0
- package/src/config.ts +139 -5
- package/src/dev-server.ts +336 -39
- 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,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));
|