golem-kit 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +11 -6
- package/docs/agents.md +64 -0
- package/docs/app-backend.md +259 -0
- package/docs/architecture.md +93 -0
- package/docs/builder.md +15 -0
- package/docs/knowledge.md +35 -0
- package/docs/local-cli.md +31 -15
- package/docs/source-development.md +31 -0
- package/index.html +9 -0
- package/package.json +24 -5
- package/src/backend/accounts.ts +287 -0
- package/src/backend/app.ts +269 -0
- package/src/backend/files.ts +68 -0
- package/src/backend/http.ts +276 -0
- package/src/backend/index.ts +10 -0
- package/src/backend/jobs.ts +302 -0
- package/src/backend/jsonl.ts +87 -0
- package/src/backend/knowledge.ts +264 -0
- package/src/backend/model.ts +129 -0
- package/src/backend/rules.ts +53 -0
- package/src/backend/sqlite.ts +73 -0
- package/src/backend/views.ts +216 -0
- package/src/brain.ts +94 -0
- package/src/browser/adapters.ts +229 -53
- package/src/browser/ansi.ts +104 -0
- package/src/browser/app.d.ts +5 -2
- package/src/browser/app.tsx +167 -39
- package/src/browser/groups.tsx +29 -0
- package/src/browser/main.tsx +1 -0
- package/src/browser/panekeys.ts +34 -0
- package/src/browser/sources.tsx +113 -0
- package/src/browser/styles.css +36 -0
- package/src/browser/terminal.tsx +89 -0
- package/src/browser-build.ts +20 -7
- package/src/chat.ts +74 -0
- package/src/cli.ts +91 -17
- package/src/client.ts +205 -0
- package/src/config.ts +169 -0
- package/src/dev-server.ts +339 -38
- package/src/entry.mjs +19 -0
- package/src/eslint.mjs +55 -0
- package/src/operations.ts +169 -0
- package/src/runtime/assistant.ts +141 -0
- package/src/runtime/discovery.ts +13 -7
- package/src/runtime/harness/agent-status.js +388 -0
- package/src/runtime/harness/claude-tmux.js +573 -0
- package/src/runtime/harness/codex-notify.js +95 -0
- package/src/runtime/harness/codex-tmux.js +292 -0
- package/src/runtime/harness/fake.js +430 -0
- package/src/runtime/harness/package.json +1 -0
- package/src/runtime/harness/port.js +208 -0
- package/src/runtime/harness/tmux-session.js +556 -0
- package/src/runtime/harness/tmux.js +285 -0
- package/src/runtime/harness/turnend-hook.js +105 -0
- package/src/runtime/session.ts +171 -34
- package/src/runtime/tmux.ts +173 -0
- package/src/runtime/tool-names.ts +19 -0
- package/src/source-mode.ts +56 -0
- package/vite.config.ts +2 -4
- package/src/runtime/codex.ts +0 -119
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
// Vendored from bridge-commander 5bf87e4b harness/tmux-session.js (zero-dependency). Local patches are marked 'golem:'.
|
|
2
|
+
'use strict';
|
|
3
|
+
// tmux-session — session/window/pane plumbing SHARED by the tmux-TUI harness
|
|
4
|
+
// adapters (claude-tmux.js, codex-tmux.js). Everything here is harness-agnostic:
|
|
5
|
+
// pane lifecycle, naming/validation, state-dir resolution, the launch-and-settle
|
|
6
|
+
// skeleton (the adapter supplies its trust-prompt and UI-ready signatures), the
|
|
7
|
+
// turn-end file tail, and the optional pane-viewing verbs. An adapter differs
|
|
8
|
+
// only in its launch line, screen signatures, resume semantics, and turn-end
|
|
9
|
+
// relay wiring.
|
|
10
|
+
//
|
|
11
|
+
// Extracted verbatim from claude-tmux.js (the reference implementation) — the
|
|
12
|
+
// comments below carry that provenance where behavior was learned the hard way.
|
|
13
|
+
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const os = require('node:os');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
const crypto = require('node:crypto');
|
|
18
|
+
const t = require('./tmux.js');
|
|
19
|
+
const { validatePaneInput } = require('./port.js');
|
|
20
|
+
|
|
21
|
+
// A pane sitting back at a bare shell means the agent process exited.
|
|
22
|
+
const SHELLS = new Set(['bash', 'zsh', 'sh', 'fish', 'dash', 'ksh']);
|
|
23
|
+
|
|
24
|
+
// State dir resolution: opts.stateDir (the server/CLI always pass the
|
|
25
|
+
// workspace's .bridge-commander/harness), then BC_HARNESS_STATE, then a global
|
|
26
|
+
// last-resort for bare embedders only — shared across workspaces, so never
|
|
27
|
+
// rely on it from workspace-aware callers.
|
|
28
|
+
function stateDirOf(opts = {}) {
|
|
29
|
+
const dir = opts.stateDir || process.env.BC_HARNESS_STATE
|
|
30
|
+
|| path.join(os.homedir(), '.bridge-commander', 'harness');
|
|
31
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
32
|
+
return dir;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The extra launch flags a spawn was given, kept next to the other per-agent
|
|
36
|
+
// state files (<key>.prompt, <key>.session-id, <key>.turnend.jsonl) so a RESUME
|
|
37
|
+
// can replay them.
|
|
38
|
+
//
|
|
39
|
+
// Those flags are not decoration. The server pins a worker's --model/--effort
|
|
40
|
+
// from its playbook at spawn, and every resume path built its own launch line
|
|
41
|
+
// without them — so a worker pinned to a model came back on the default one,
|
|
42
|
+
// silently, with nobody told. The oldest of those paths is `card start --resume`
|
|
43
|
+
// after a death, which is exactly the moment the pinning matters most.
|
|
44
|
+
//
|
|
45
|
+
// Recorded here rather than in each adapter because both tmux adapters take the
|
|
46
|
+
// same `extraArgs` and both rebuild a launch line on resume; one copy of the
|
|
47
|
+
// decision is one place for it to stay true.
|
|
48
|
+
//
|
|
49
|
+
// A missing, unreadable or corrupt record reads as "no extra flags" — today's
|
|
50
|
+
// behaviour — and never throws: a resume that cannot read a hint must still
|
|
51
|
+
// resume. A spawn with no extra flags REMOVES any stale record, so a card
|
|
52
|
+
// restarted onto a different model does not inherit the last run's.
|
|
53
|
+
function spawnArgsFile(stateDir, key) {
|
|
54
|
+
return path.join(stateDir, `${key}.spawn-args`);
|
|
55
|
+
}
|
|
56
|
+
// The launch facts a resume has to replay, taken straight off the spawn's opts:
|
|
57
|
+
// the extra flags (--model/--effort, pinned by the card's playbook) and the
|
|
58
|
+
// caller's allowRoot consent (the IS_SANDBOX=1 prefix without which claude
|
|
59
|
+
// refuses to come back as uid 0). Written as an object; a bare array is the
|
|
60
|
+
// older record's shape and still reads as flags-only.
|
|
61
|
+
function recordSpawnArgs(stateDir, key, opts = {}) {
|
|
62
|
+
const file = spawnArgsFile(stateDir, key);
|
|
63
|
+
try {
|
|
64
|
+
const rec = { args: (opts.extraArgs || []).map(String) };
|
|
65
|
+
if (opts.allowRoot) rec.allowRoot = true;
|
|
66
|
+
if (opts.permissions) rec.permissions = String(opts.permissions); // golem: launch profile, replayed on resume
|
|
67
|
+
if (rec.args.length || rec.allowRoot || rec.permissions) fs.writeFileSync(file, JSON.stringify(rec) + '\n');
|
|
68
|
+
else fs.rmSync(file, { force: true });
|
|
69
|
+
} catch {
|
|
70
|
+
// best-effort: the record is an optimisation, never a precondition
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// -> { args: string[], allowRoot: boolean }. Missing, unreadable or corrupt
|
|
74
|
+
// reads as "nothing extra" and never throws: a resume that cannot read a hint
|
|
75
|
+
// must still resume.
|
|
76
|
+
function recordedSpawnArgs(stateDir, key) {
|
|
77
|
+
try {
|
|
78
|
+
const v = JSON.parse(fs.readFileSync(spawnArgsFile(stateDir, key), 'utf8'));
|
|
79
|
+
if (Array.isArray(v)) return { args: v.filter((a) => typeof a === 'string'), allowRoot: false };
|
|
80
|
+
if (v && typeof v === 'object') {
|
|
81
|
+
return {
|
|
82
|
+
args: Array.isArray(v.args) ? v.args.filter((a) => typeof a === 'string') : [],
|
|
83
|
+
allowRoot: !!v.allowRoot,
|
|
84
|
+
permissions: typeof v.permissions === 'string' ? v.permissions : undefined, // golem
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// fall through to the empty record
|
|
89
|
+
}
|
|
90
|
+
return { args: [], allowRoot: false };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function shellQuote(s) {
|
|
94
|
+
return `'` + String(s).replace(/'/g, `'\\''`) + `'`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// golem: `opts.env` rides the launch line as `K='v' ` prefixes, so the agent
|
|
98
|
+
// (and everything it runs) sees e.g. GOLEM_SESSION/GOLEM_API. Passed again on resume.
|
|
99
|
+
function envPrefix(opts = {}) {
|
|
100
|
+
return Object.entries(opts.env || {}).map(([k, v]) => `${k}=${shellQuote(v)} `).join('');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function newSessionName() {
|
|
104
|
+
return 'bc-' + crypto.randomBytes(3).toString('hex');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// stateKey — the per-agent key for prompt/turnend/session-id state files and
|
|
108
|
+
// the turn-end relay's `session` argument. Window-granular agents share their
|
|
109
|
+
// tmux session name with the lieutenant (and sibling workers), so the bare
|
|
110
|
+
// session would collide; the `session:window` form is unique — tmux session
|
|
111
|
+
// names can never contain ':'.
|
|
112
|
+
function stateKey(session, window) {
|
|
113
|
+
return window ? `${session}:${window}` : session;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// paneTarget — exact-match tmux target for an agent's pane.
|
|
117
|
+
// Session-granular: the bare `=name` exact-match form resolves for
|
|
118
|
+
// session-level commands but NOT for pane-level ones (verified tmux 3.4:
|
|
119
|
+
// `send-keys -t =name` fails with "can't find pane"); the trailing colon
|
|
120
|
+
// (`=name:`) resolves for both. Window-granular: `=session:=window`, exact on
|
|
121
|
+
// both halves, so tmux never pattern-matches or reads the window as an index.
|
|
122
|
+
function paneTarget(session, window) {
|
|
123
|
+
return window ? `=${session}:=${window}` : `=${session}:`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// opts.strict — read through tmuxRead instead of tryTmux, so a tmux that could
|
|
127
|
+
// not be READ throws with the reason rather than answering "not there". Only a
|
|
128
|
+
// caller whose decision turns on knowing asks for it (alive(), whose false is
|
|
129
|
+
// the board's proof that a pane is gone); everything else keeps reading an
|
|
130
|
+
// unreadable tmux as absence, which is what it has always done.
|
|
131
|
+
function reader(opts) {
|
|
132
|
+
return (opts && opts.strict) ? t.tmuxRead : t.tryTmux;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function paneCommand(target, opts) {
|
|
136
|
+
const out = await reader(opts)('display-message', '-p', '-t', target, '#{pane_current_command}');
|
|
137
|
+
return out === null ? null : out.trim();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function hasSession(session, opts) {
|
|
141
|
+
return (await reader(opts)('has-session', '-t', `=${session}:`)) !== null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// hasWindow — strict window existence. `display-message -t =ses:=missing`
|
|
145
|
+
// does NOT error — tmux silently falls back to another pane (verified tmux
|
|
146
|
+
// 3.4) — so existence is checked against the session's actual window list.
|
|
147
|
+
async function hasWindow(session, window, opts) {
|
|
148
|
+
const out = await reader(opts)('list-windows', '-t', `=${session}:`, '-F', '#{window_name}');
|
|
149
|
+
return out !== null && out.split('\n').includes(window);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function paneExists(session, window, opts) {
|
|
153
|
+
return window ? hasWindow(session, window, opts) : hasSession(session, opts);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// claimPaneNames — resolve + validate the session/window names for a spawn and
|
|
157
|
+
// refuse names already in use. Window names must start with a letter — a
|
|
158
|
+
// numeric name would be parsed by tmux as a window INDEX (papercut #8).
|
|
159
|
+
async function claimPaneNames(opts = {}) {
|
|
160
|
+
const session = opts.session || newSessionName();
|
|
161
|
+
if (!/^(bc|golem)-[A-Za-z0-9_-]+$/.test(session)) { // golem: golem-<id> sessions too
|
|
162
|
+
throw new Error(`invalid session name "${session}" (must match bc-<id> or golem-<id>)`);
|
|
163
|
+
}
|
|
164
|
+
const window = opts.window === undefined || opts.window === null ? undefined : String(opts.window);
|
|
165
|
+
if (window !== undefined && !/^[A-Za-z][A-Za-z0-9_-]*$/.test(window)) {
|
|
166
|
+
throw new Error(`invalid window name "${window}" (must start with a letter — tmux parses numeric names as window indexes)`);
|
|
167
|
+
}
|
|
168
|
+
if (window) {
|
|
169
|
+
if (await hasWindow(session, window)) throw new Error(`tmux window ${session}:${window} already exists`);
|
|
170
|
+
} else if (await hasSession(session)) {
|
|
171
|
+
throw new Error(`tmux session ${session} already exists`);
|
|
172
|
+
}
|
|
173
|
+
return { session, window };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// createPane — bring the agent's pane into existence. Session-granular: a
|
|
177
|
+
// fresh detached session. Window-granular: a new window appended to the
|
|
178
|
+
// session, created with -d so a worker spawn never steals the lieutenant's
|
|
179
|
+
// focus; when the session is not up yet it is created with this window as its
|
|
180
|
+
// first (the accepted lifecycle coupling — no separate fleet session).
|
|
181
|
+
async function createPane(session, window, cwd) {
|
|
182
|
+
if (!window) {
|
|
183
|
+
await t.tmux('new-session', '-d', '-s', session, '-c', cwd);
|
|
184
|
+
} else if (await hasSession(session)) {
|
|
185
|
+
await t.tmux('new-window', '-d', '-t', `=${session}:`, '-n', window, '-c', cwd);
|
|
186
|
+
} else {
|
|
187
|
+
await t.tmux('new-session', '-d', '-s', session, '-n', window, '-c', cwd);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// killPane — session-granular kills the session; window-granular kills ONLY
|
|
192
|
+
// the window (the lieutenant and sibling workers cohabit the session).
|
|
193
|
+
// Killing a session's last window ends the session — tmux's own semantics.
|
|
194
|
+
async function killPane(session, window) {
|
|
195
|
+
if (window) {
|
|
196
|
+
if (await hasWindow(session, window)) await t.tryTmux('kill-window', '-t', paneTarget(session, window));
|
|
197
|
+
} else if (await hasSession(session)) {
|
|
198
|
+
await t.tryTmux('kill-session', '-t', paneTarget(session));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// adoptWindow(ref, window, taken?) -> ref | null — migrate a SESSION-granular
|
|
203
|
+
// ref to window granularity without restarting the agent it addresses. Born
|
|
204
|
+
// session-granular, an agent that cohabits its session (a lieutenant with its
|
|
205
|
+
// worker windows) is addressed as `=session:`, which resolves to whatever
|
|
206
|
+
// window has FOCUS: killPane takes the whole session and paneCommand reads a
|
|
207
|
+
// sibling's pane. Pinning the ref to the agent's own window fixes both.
|
|
208
|
+
//
|
|
209
|
+
// The agent's window is the session's FIRST (lowest-index) one — it was born
|
|
210
|
+
// with the session, every sibling was appended after it. `taken` names windows
|
|
211
|
+
// that provably belong to someone else (the caller's worker windows), so a
|
|
212
|
+
// session whose original window is gone is never mis-adopted.
|
|
213
|
+
//
|
|
214
|
+
// rename-window also turns tmux's automatic-rename off for that window, so the
|
|
215
|
+
// name sticks. Nothing live to rename (session gone) still returns the
|
|
216
|
+
// window-granular ref — the next spawn/resume creates the window. null means
|
|
217
|
+
// "cannot tell which window is the agent's": the ref is left alone.
|
|
218
|
+
async function adoptWindow(ref, window, taken = []) {
|
|
219
|
+
if (ref.window) return ref;
|
|
220
|
+
if (!(await hasSession(ref.session))) return { ...ref, window };
|
|
221
|
+
if (await hasWindow(ref.session, window)) return { ...ref, window };
|
|
222
|
+
const out = await t.tryTmux('list-windows', '-t', `=${ref.session}:`, '-F', '#{window_index}\t#{window_name}');
|
|
223
|
+
const first = out === null ? null : out.split('\n').find((l) => l.trim());
|
|
224
|
+
if (!first) return null;
|
|
225
|
+
const [index, name] = first.trim().split('\t');
|
|
226
|
+
if (!index || taken.includes(name)) return null;
|
|
227
|
+
if (await t.tryTmux('rename-window', '-t', `=${ref.session}:${index}`, window) === null) return null;
|
|
228
|
+
return { ...ref, window };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// launchAndSettle — send the launch command into the pane, wait for the agent
|
|
232
|
+
// process and its main UI, auto-accepting the harness's trust dialog if it
|
|
233
|
+
// appears (a fresh cwd shows one even in bypass mode; the accept option is
|
|
234
|
+
// preselected, so Enter accepts). The adapter supplies:
|
|
235
|
+
// sig.trustRe — matches the trust screen (checked FIRST: a trust screen may
|
|
236
|
+
// contain composer-like glyphs, so it must win over readyRe)
|
|
237
|
+
// sig.readyRe — matches signatures only the main UI renders
|
|
238
|
+
// sig.label — the agent name for error messages ('claude', 'codex')
|
|
239
|
+
//
|
|
240
|
+
// Both signatures are tested against the TAIL of the pane — the current
|
|
241
|
+
// interaction always sits at the bottom. Matching the whole capture broke on
|
|
242
|
+
// inline-scrolling TUIs (codex renders in the primary screen, not the
|
|
243
|
+
// alternate one): the ACCEPTED trust prompt lingers in scrollback, so a
|
|
244
|
+
// full-capture trustRe kept re-matching forever and starved readyRe. claude's
|
|
245
|
+
// alternate-screen dialogs keep their signatures bottom-anchored anyway
|
|
246
|
+
// (composer + footer are the screen's last rows), so the tail is behavior-
|
|
247
|
+
// preserving there.
|
|
248
|
+
const SETTLE_TAIL_LINES = 15;
|
|
249
|
+
|
|
250
|
+
function paneTail(pane) {
|
|
251
|
+
return pane.replace(/\s+$/, '').split('\n').slice(-SETTLE_TAIL_LINES).join('\n');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function launchAndSettle(target, launchCmd, sig) {
|
|
255
|
+
await t.sendLiteral(target, launchCmd);
|
|
256
|
+
await t.sleep(300);
|
|
257
|
+
await t.sendKey(target, 'Enter');
|
|
258
|
+
|
|
259
|
+
// Screens that stand between the launch and the UI, each one a menu whose
|
|
260
|
+
// PRESELECTED option is the one we want, so Enter answers all of them. They
|
|
261
|
+
// are checked before readyRe because a dialog can carry composer-like glyphs
|
|
262
|
+
// and would otherwise be mistaken for the main UI.
|
|
263
|
+
const menus = [sig.trustRe, sig.resumeRe].filter(Boolean);
|
|
264
|
+
|
|
265
|
+
const deadline = Date.now() + 45000;
|
|
266
|
+
while (Date.now() < deadline) {
|
|
267
|
+
await t.sleep(500);
|
|
268
|
+
const cmd = await paneCommand(target);
|
|
269
|
+
if (cmd === null) throw new Error(`tmux pane ${target} vanished during launch`);
|
|
270
|
+
const tail = paneTail(await t.capture(target, 40));
|
|
271
|
+
// sig.fatalRe — screens and exits that will NEVER become a running agent
|
|
272
|
+
// (a launch line the CLI refuses outright, a first-run setup wizard, a
|
|
273
|
+
// missing binary). Waiting the full 45s for one of those buys nothing and
|
|
274
|
+
// hands the caller a timeout to misdiagnose; the pane tail says what
|
|
275
|
+
// happened, so it is thrown immediately with the tail attached.
|
|
276
|
+
//
|
|
277
|
+
// Checked BEFORE the shell skip on purpose: the interesting failures print
|
|
278
|
+
// their line and exit, which puts the pane back on a shell prompt.
|
|
279
|
+
if (sig.fatalRe && sig.fatalRe.test(tail)) {
|
|
280
|
+
throw new Error(`${sig.label} could not start at ${target}; pane tail:\n${tail}`);
|
|
281
|
+
}
|
|
282
|
+
if (SHELLS.has(cmd)) continue; // agent not up yet (or it already exited — captured by timeout)
|
|
283
|
+
if (menus.some((re) => re.test(tail))) {
|
|
284
|
+
await t.sendKey(target, 'Enter');
|
|
285
|
+
await t.sleep(1000);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (sig.readyRe.test(tail)) return;
|
|
289
|
+
}
|
|
290
|
+
const tail = await t.capture(target, 20);
|
|
291
|
+
throw new Error(`${sig.label} did not start at ${target} within 45s; pane tail:\n${tail}`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// verifyLive — the last look before a spawn is allowed to say it worked.
|
|
295
|
+
//
|
|
296
|
+
// launchAndSettle answers "did a UI appear?", which is not the same question as
|
|
297
|
+
// "is there a session here now". A signature can match a screen that merely
|
|
298
|
+
// CONTAINS the words (the bypass-permissions consent modal literally says
|
|
299
|
+
// "Bypass Permissions mode", which the ready signature matched for months), and
|
|
300
|
+
// the brief then goes into a menu while the caller is told the session started.
|
|
301
|
+
//
|
|
302
|
+
// A caller reporting success it did not check is worse than any honest failure,
|
|
303
|
+
// because it is the one line nobody re-reads. So: after the brief is delivered,
|
|
304
|
+
// look again — a fatal screen fails immediately, and a pane that does not look
|
|
305
|
+
// like a running agent within `ms` fails with what it did look like.
|
|
306
|
+
async function verifyLive(target, sig, ms = 6000) {
|
|
307
|
+
const deadline = Date.now() + ms;
|
|
308
|
+
let tail = '';
|
|
309
|
+
for (;;) {
|
|
310
|
+
tail = paneTail(await t.capture(target, 40));
|
|
311
|
+
if (sig.fatalRe && sig.fatalRe.test(tail)) {
|
|
312
|
+
throw new Error(`${sig.label} is not running at ${target} — it is on a screen that needs a person; pane tail:\n${tail}`);
|
|
313
|
+
}
|
|
314
|
+
if (sig.readyRe.test(tail)) return;
|
|
315
|
+
if (Date.now() >= deadline) break;
|
|
316
|
+
await t.sleep(500);
|
|
317
|
+
}
|
|
318
|
+
throw new Error(`${sig.label} did not settle into a running session at ${target}; pane tail:\n${tail}`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// onTurnEnd(ref, hook) -> unsubscribe()
|
|
322
|
+
// hook(event, ref) fires once per turn boundary; event is the JSON line the
|
|
323
|
+
// harness's relay appended ({ ts, session, event, session_id, cwd }). Only
|
|
324
|
+
// events appended AFTER registration are delivered. fs.watch push with a
|
|
325
|
+
// polling backstop, so no boundary is missed on filesystems with flaky watch.
|
|
326
|
+
function onTurnEnd(ref, hook, opts = {}) {
|
|
327
|
+
const stateDir = stateDirOf(opts);
|
|
328
|
+
const file = path.join(stateDir, `${stateKey(ref.session, ref.window)}.turnend.jsonl`);
|
|
329
|
+
let offset = 0;
|
|
330
|
+
try {
|
|
331
|
+
offset = fs.statSync(file).size;
|
|
332
|
+
} catch {
|
|
333
|
+
offset = 0;
|
|
334
|
+
}
|
|
335
|
+
let closed = false;
|
|
336
|
+
|
|
337
|
+
function drain() {
|
|
338
|
+
if (closed) return;
|
|
339
|
+
let size;
|
|
340
|
+
try {
|
|
341
|
+
size = fs.statSync(file).size;
|
|
342
|
+
} catch {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (size < offset) offset = 0; // truncated/rotated
|
|
346
|
+
if (size === offset) return;
|
|
347
|
+
const fd = fs.openSync(file, 'r');
|
|
348
|
+
try {
|
|
349
|
+
const buf = Buffer.alloc(size - offset);
|
|
350
|
+
fs.readSync(fd, buf, 0, buf.length, offset);
|
|
351
|
+
offset = size;
|
|
352
|
+
for (const line of buf.toString('utf8').split('\n')) {
|
|
353
|
+
if (!line.trim()) continue;
|
|
354
|
+
let event;
|
|
355
|
+
try {
|
|
356
|
+
event = JSON.parse(line);
|
|
357
|
+
} catch {
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
hook(event, ref);
|
|
362
|
+
} catch {
|
|
363
|
+
// a throwing hook must not kill the watcher
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
} finally {
|
|
367
|
+
fs.closeSync(fd);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
let watcher = null;
|
|
372
|
+
try {
|
|
373
|
+
watcher = fs.watch(stateDir, (_type, name) => {
|
|
374
|
+
if (name === path.basename(file)) drain();
|
|
375
|
+
});
|
|
376
|
+
} catch {
|
|
377
|
+
watcher = null;
|
|
378
|
+
}
|
|
379
|
+
const poll = setInterval(drain, 1000);
|
|
380
|
+
poll.unref?.();
|
|
381
|
+
|
|
382
|
+
return function unsubscribe() {
|
|
383
|
+
closed = true;
|
|
384
|
+
clearInterval(poll);
|
|
385
|
+
watcher?.close();
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ---------- pane viewing (OPTIONAL capability verbs — see port.js) ----------
|
|
390
|
+
// Feeds opened by openPane, keyed by tmux target — the ONLY place a burst can
|
|
391
|
+
// be registered, so a burst can never outlive the feed it speeds up (see
|
|
392
|
+
// paneInput below). One entry per open feed; the entry is the feed's own
|
|
393
|
+
// object, so a close only ever removes ITS OWN entry (the server's pane hub
|
|
394
|
+
// refcounts N viewers onto ONE feed per target, but identity beats assuming it).
|
|
395
|
+
const feeds = new Map(); // tmux target -> { until: ms deadline for fast polling }
|
|
396
|
+
const PANE_BURST_MS = 120; // fast poll while a burst is live
|
|
397
|
+
const PANE_BURST_WINDOW_MS = 1500; // how long one keystroke keeps the feed fast
|
|
398
|
+
|
|
399
|
+
// openPane(ref, { onFrame, intervalMs?, lines?, burstMs?, burstWindowMs? }) -> { close() }
|
|
400
|
+
// Streams the pane's CURRENT RENDERED SCREEN as successive frames: every
|
|
401
|
+
// intervalMs the pane is captured with ANSI styling and scrollback, and
|
|
402
|
+
// onFrame(frame) fires only when the content changed since the last frame.
|
|
403
|
+
// close() stops delivery and releases the timer.
|
|
404
|
+
//
|
|
405
|
+
// Deliberately rendered frames via capture-pane, NOT a pipe-pane byte stream:
|
|
406
|
+
// the target is a full-screen TUI that repaints in place, so raw pty bytes
|
|
407
|
+
// would need a client-side terminal emulator (xterm.js — a dependency we
|
|
408
|
+
// will not add). capture-pane returns the already-composed screen, works for
|
|
409
|
+
// any TUI, and keeps the client a plain <pre>.
|
|
410
|
+
//
|
|
411
|
+
// The timer is a self-rescheduling setTimeout, not a setInterval: the next
|
|
412
|
+
// capture is scheduled only after the current one finished (so a slow tmux can
|
|
413
|
+
// never stack children — no busy flag needed) and each hop re-reads the delay,
|
|
414
|
+
// which is what lets paneInput burst a live feed down to burstMs for a moment
|
|
415
|
+
// so typing does not feel dead behind the 1s baseline.
|
|
416
|
+
function openPane(ref, opts = {}) {
|
|
417
|
+
const onFrame = typeof opts.onFrame === 'function' ? opts.onFrame : () => {};
|
|
418
|
+
const intervalMs = opts.intervalMs > 0 ? opts.intervalMs : 1000;
|
|
419
|
+
const lines = opts.lines > 0 ? opts.lines : 200;
|
|
420
|
+
const burstMs = opts.burstMs > 0 ? opts.burstMs : PANE_BURST_MS;
|
|
421
|
+
const burstWindowMs = opts.burstWindowMs > 0 ? opts.burstWindowMs : PANE_BURST_WINDOW_MS;
|
|
422
|
+
const target = paneTarget(ref.session, ref.window);
|
|
423
|
+
let last = null;
|
|
424
|
+
let closed = false;
|
|
425
|
+
let ticking = false;
|
|
426
|
+
let timer = null;
|
|
427
|
+
// bump() — a keystroke just landed: go fast for burstWindowMs. Rescheduling
|
|
428
|
+
// NOW matters as much as the deadline: the next hop was already pending at
|
|
429
|
+
// the 1s baseline, and leaving it alone would make the FIRST key of a burst
|
|
430
|
+
// (the one the typist is actually watching for) the slowest of all. Mid-tick
|
|
431
|
+
// is the one case to leave alone — loop() schedules the moment it returns and
|
|
432
|
+
// reads the fresh deadline then; racing it here would leave two live timers.
|
|
433
|
+
const feed = {
|
|
434
|
+
until: 0,
|
|
435
|
+
bump() {
|
|
436
|
+
feed.until = Date.now() + burstWindowMs;
|
|
437
|
+
if (closed || ticking) return;
|
|
438
|
+
clearTimeout(timer);
|
|
439
|
+
schedule();
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
feeds.set(target, feed); // this pane now has a feed a keystroke can burst
|
|
443
|
+
|
|
444
|
+
async function tick() {
|
|
445
|
+
if (closed) return;
|
|
446
|
+
if (!(await paneExists(ref.session, ref.window))) {
|
|
447
|
+
close();
|
|
448
|
+
try { onFrame('\n[pane gone]'); } catch { /* subscriber's problem */ }
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const frame = await t.captureStyled(target, lines);
|
|
452
|
+
if (closed || frame === null || frame === last) return;
|
|
453
|
+
last = frame;
|
|
454
|
+
try { onFrame(frame); } catch { /* a throwing subscriber must not kill the feed */ }
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// schedule(spentMs) — the next hop, measured from when the LAST one STARTED.
|
|
458
|
+
// Subtracting the time the capture already burned keeps the period at
|
|
459
|
+
// max(interval, capture) instead of interval + capture; without it a 60ms
|
|
460
|
+
// tmux would turn the advertised 120ms burst into 180ms and the 1s baseline
|
|
461
|
+
// into 1.06s. Still strictly sequential, so captures cannot overlap.
|
|
462
|
+
function schedule(spentMs = 0) {
|
|
463
|
+
if (closed) return;
|
|
464
|
+
const base = feed.until > Date.now() ? Math.min(burstMs, intervalMs) : intervalMs;
|
|
465
|
+
timer = setTimeout(loop, Math.max(0, base - spentMs));
|
|
466
|
+
timer.unref?.();
|
|
467
|
+
}
|
|
468
|
+
async function loop() {
|
|
469
|
+
const started = Date.now();
|
|
470
|
+
ticking = true;
|
|
471
|
+
try { await tick(); } finally { ticking = false; }
|
|
472
|
+
schedule(Date.now() - started);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function close() {
|
|
476
|
+
closed = true;
|
|
477
|
+
clearTimeout(timer);
|
|
478
|
+
if (feeds.get(target) === feed) feeds.delete(target); // burst dies with the feed
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// immediate first frame — the subscriber paints without waiting a tick
|
|
482
|
+
loop();
|
|
483
|
+
return { close };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// paneSnapshot(ref, { lines? }) -> Promise<string> — one-shot styled capture
|
|
487
|
+
// (initial paint / non-streaming fallback). Empty string when unreadable.
|
|
488
|
+
async function paneSnapshot(ref, opts = {}) {
|
|
489
|
+
const lines = opts.lines > 0 ? opts.lines : 200;
|
|
490
|
+
const out = await t.captureStyled(paneTarget(ref.session, ref.window), lines);
|
|
491
|
+
return out === null ? '' : out;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ---------- pane input (OPTIONAL capability verb — see port.js) ----------
|
|
495
|
+
// paneInput(ref, { text? | key? }) -> Promise<void>
|
|
496
|
+
// Forward RAW input to the pane: `text` is typed literally (sendLiteral, which
|
|
497
|
+
// switches to a bracketed paste when it spans lines), `key` is one tmux key
|
|
498
|
+
// name ('Enter', 'BSpace', 'Up', 'BTab', 'C-c', …).
|
|
499
|
+
//
|
|
500
|
+
// Deliberately NOT the agent `send` verb: send() types, settles, presses Enter
|
|
501
|
+
// and retries until the composer verifies empty. That is right for delivering
|
|
502
|
+
// a brief and wrong for a keystroke — an arrow key has no composer state to
|
|
503
|
+
// verify and a retried Enter would submit twice. Raw passthrough bypasses all
|
|
504
|
+
// of it: one keystroke in, one keystroke out.
|
|
505
|
+
//
|
|
506
|
+
// The payload contract (key XOR text, the key grammar, the size cap) lives in
|
|
507
|
+
// port.js so the fake enforces the SAME rules — two copies of a validation
|
|
508
|
+
// regex are two regexes that drift. Text needs no pattern of its own: tmux.js's
|
|
509
|
+
// sendLiteral passes `--` so a flag-shaped payload can never be read as flags,
|
|
510
|
+
// which is the guarantee every caller of that primitive now gets.
|
|
511
|
+
async function paneInput(ref, input = {}) {
|
|
512
|
+
const { key, text } = validatePaneInput(input);
|
|
513
|
+
if (!(await paneExists(ref.session, ref.window))) {
|
|
514
|
+
throw new Error(`pane ${stateKey(ref.session, ref.window)} is gone`);
|
|
515
|
+
}
|
|
516
|
+
const target = paneTarget(ref.session, ref.window);
|
|
517
|
+
if (key) await t.sendKey(target, key);
|
|
518
|
+
else await t.sendLiteral(target, text);
|
|
519
|
+
// Burst the WATCHING feed so the echo lands in ~a frame instead of ~a second.
|
|
520
|
+
// No feed open (nobody watching) → nothing to record, nothing to leak.
|
|
521
|
+
const feed = feeds.get(target);
|
|
522
|
+
if (feed) feed.bump();
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// openFeedCount() — how many pane feeds are registered right now. A test hook,
|
|
526
|
+
// not a port verb: the "a burst cannot outlive its feed" claim rests on the map
|
|
527
|
+
// being emptied on close, and without a way to look at the map that claim is
|
|
528
|
+
// untestable — deleting the delete kept every other assertion green.
|
|
529
|
+
function openFeedCount() { return feeds.size; }
|
|
530
|
+
|
|
531
|
+
module.exports = {
|
|
532
|
+
SHELLS,
|
|
533
|
+
stateDirOf,
|
|
534
|
+
recordSpawnArgs,
|
|
535
|
+
recordedSpawnArgs,
|
|
536
|
+
shellQuote,
|
|
537
|
+
envPrefix,
|
|
538
|
+
newSessionName,
|
|
539
|
+
stateKey,
|
|
540
|
+
paneTarget,
|
|
541
|
+
paneCommand,
|
|
542
|
+
hasSession,
|
|
543
|
+
hasWindow,
|
|
544
|
+
paneExists,
|
|
545
|
+
claimPaneNames,
|
|
546
|
+
createPane,
|
|
547
|
+
killPane,
|
|
548
|
+
adoptWindow,
|
|
549
|
+
launchAndSettle,
|
|
550
|
+
verifyLive,
|
|
551
|
+
onTurnEnd,
|
|
552
|
+
openPane,
|
|
553
|
+
paneSnapshot,
|
|
554
|
+
paneInput,
|
|
555
|
+
openFeedCount,
|
|
556
|
+
};
|