@shumkov/orchestra 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.
@@ -0,0 +1,339 @@
1
+ // provenance: polygram@0.17.11 lib/tmux/log-tail.js (git 746bca6) — verbatim: env prefix WATER_, bridge name water-bridge, vendor path (SHARED-LIB.md).
2
+ /**
3
+ * LogTail — generic append-only file tailer. Emits 'line' events as
4
+ * new lines arrive.
5
+ *
6
+ * Used by TmuxProcess to follow claude's per-session JSONL conversation
7
+ * file (`~/.claude/projects/<cwd-encoded>/<sessionId>.jsonl`) so we can
8
+ * surface structured assistant + tool + usage + stop_reason events on
9
+ * the tmux backend. The class itself is backend-agnostic — it just
10
+ * tails a file.
11
+ *
12
+ * (Originally named DebugLogTail when the design assumed we'd parse
13
+ * `--debug-file` output. The v9 probe showed that channel carries only
14
+ * MDM/MCP infra messages and zero conversation events; the JSONL
15
+ * session file is the real channel. Class renamed to match what it
16
+ * actually does.)
17
+ *
18
+ * Design:
19
+ * - Default mode `useWatch: 'auto'` uses `fs.watch` on the parent
20
+ * directory + filename filter — near-zero steady-state IO. Falls
21
+ * back to polling automatically if `fs.watch` fails (sandboxed
22
+ * environment, unsupported FS). A slow 1s safety-net poll runs
23
+ * alongside the watcher to catch any missed events.
24
+ * - `useWatch: false` forces polling — for environments where
25
+ * fs.watch is known broken.
26
+ * - `useWatch: true` requires fs.watch to work — throws on failure.
27
+ * Use for testing the watch path deterministically.
28
+ * - Tolerates the file not existing yet (claude may take ~100ms to
29
+ * create it after spawn). The directory watcher fires once it
30
+ * appears.
31
+ * - Carries a partial-line buffer across reads so a line split
32
+ * across two reads still emits exactly once.
33
+ * - Safety cap on per-line size (MAX_BUF_BYTES) so a hostile or
34
+ * corrupted multi-MB single-line write can't OOM the daemon or
35
+ * stall the event loop on a sync JSON.parse.
36
+ * - Idempotent .close().
37
+ *
38
+ * @see lib/util/claude-session-jsonl.js — JSONL line → typed event
39
+ */
40
+
41
+ 'use strict';
42
+
43
+ const EventEmitter = require('events');
44
+ const fs = require('fs');
45
+ const path = require('path');
46
+ const { StringDecoder } = require('string_decoder');
47
+
48
+ const DEFAULT_INTERVAL_MS = 100;
49
+ // Slow safety-net poll when fs.watch is active. Catches any events
50
+ // the watcher missed (rare on Linux/macOS, more common on networked
51
+ // or fuse filesystems). 1s is more than enough for backstop.
52
+ const WATCH_SAFETY_NET_MS = 1000;
53
+ const DEFAULT_CHUNK_BYTES = 64 * 1024;
54
+ // Safety cap: a single line with no \n must not grow `_buf` without
55
+ // bound. claude TUI doesn't emit lines this big in normal operation;
56
+ // hitting this is a sign of corruption or a hostile tool result that
57
+ // could OOM the daemon and stall the event loop with a sync JSON.parse.
58
+ const MAX_BUF_BYTES = 16 * 1024 * 1024;
59
+
60
+ class LogTail extends EventEmitter {
61
+ /**
62
+ * @param {object} opts
63
+ * @param {string} opts.path — log file path
64
+ * @param {number} [opts.intervalMs=100] — poll interval when in
65
+ * polling mode (also used as the initial-tick delay in watch mode).
66
+ * @param {boolean} [opts.skipExisting] — start at current file size,
67
+ * only emit lines added AFTER start(). Used for `--resume` on the
68
+ * tmux backend so historic JSONL events aren't replayed.
69
+ * @param {'auto'|true|false} [opts.useWatch='auto']
70
+ * - 'auto' (default): try fs.watch; fall back to polling on error.
71
+ * - true: require fs.watch to work; throw on failure.
72
+ * - false: force polling.
73
+ * @param {object} [opts.fs] — test seam (override fs)
74
+ * @param {object} [opts.logger=console]
75
+ */
76
+ constructor({
77
+ path: filePath,
78
+ intervalMs = DEFAULT_INTERVAL_MS,
79
+ skipExisting = false,
80
+ useWatch = 'auto',
81
+ fs: fsOverride,
82
+ logger = console,
83
+ } = {}) {
84
+ super();
85
+ if (typeof filePath !== 'string' || !filePath) {
86
+ throw new TypeError('LogTail: path required');
87
+ }
88
+ this.path = filePath;
89
+ this.intervalMs = intervalMs;
90
+ this.skipExisting = skipExisting;
91
+ this.useWatch = useWatch;
92
+ this.logger = logger;
93
+ this.fs = fsOverride || fs;
94
+ this._offset = 0;
95
+ this._buf = '';
96
+ // L8: decode bytes through a StringDecoder so a multibyte UTF-8 char
97
+ // split across two read chunks (the 64KB DEFAULT_CHUNK_BYTES boundary)
98
+ // isn't corrupted into U+FFFD. The decoder holds an incomplete trailing
99
+ // sequence until the continuation bytes arrive on the next read. The
100
+ // hook ndjson carries large non-ASCII tool payloads, so this is
101
+ // load-bearing on the CliProcess observability path.
102
+ this._decoder = new StringDecoder('utf8');
103
+ this._closed = false;
104
+ this._timer = null;
105
+ this._watcher = null;
106
+ this._mode = null; // 'watch' | 'poll' after start()
107
+ this._initialised = false;
108
+ this._readInFlight = false; // debounce concurrent _readNew triggers
109
+ this._readPending = false;
110
+ }
111
+
112
+ start() {
113
+ if (this._closed) throw new Error('LogTail: closed');
114
+ if (this._mode) return; // idempotent
115
+ // Snapshot offset at start() time when skipExisting is requested.
116
+ // Doing this on first read instead would race: if content is
117
+ // appended between start() and the first read, the offset jump
118
+ // would skip those bytes too.
119
+ if (this.skipExisting) {
120
+ try {
121
+ const stat = this.fs.statSync(this.path);
122
+ this._offset = stat.size;
123
+ } catch (err) {
124
+ if (err.code !== 'ENOENT') throw err;
125
+ // File doesn't exist yet — offset stays 0, all future content
126
+ // is "new" by definition.
127
+ }
128
+ this._initialised = true;
129
+ }
130
+ // Decide watch vs poll. In 'auto' mode we attempt fs.watch and
131
+ // silently fall back; in 'true' mode we throw on failure; in
132
+ // 'false' mode we skip the attempt entirely.
133
+ if (this.useWatch !== false) {
134
+ if (this._tryStartWatch()) {
135
+ this._mode = 'watch';
136
+ // Trigger an immediate first read (existing content + warmup),
137
+ // then add a slow safety-net poll on top of the watcher to
138
+ // catch any missed events.
139
+ setImmediate(() => this._triggerRead());
140
+ this._startSafetyNetPoll();
141
+ return;
142
+ }
143
+ if (this.useWatch === true) {
144
+ throw new Error('LogTail: useWatch:true requested but fs.watch failed');
145
+ }
146
+ this.logger.log?.(`[log-tail] fs.watch unavailable for ${this.path}; falling back to polling`);
147
+ }
148
+ this._mode = 'poll';
149
+ this._startPolling();
150
+ }
151
+
152
+ /**
153
+ * Try to install fs.watch on the parent directory. We watch the dir
154
+ * (not the file) because the file may not exist yet — claude TUI
155
+ * creates it a moment after spawn. Returns true on success.
156
+ */
157
+ _tryStartWatch() {
158
+ try {
159
+ const dir = path.dirname(this.path);
160
+ const base = path.basename(this.path);
161
+ // Ensure the parent exists so fs.watch can attach. If the
162
+ // ~/.claude/projects/<cwd> dir hasn't been created yet, claude
163
+ // will create it on first turn; we make it now so the watcher
164
+ // can attach immediately.
165
+ this.fs.mkdirSync(dir, { recursive: true });
166
+ this._watcher = this.fs.watch(dir, { persistent: false }, (eventType, filename) => {
167
+ if (this._closed) return;
168
+ if (filename !== base) return;
169
+ this._triggerRead();
170
+ });
171
+ this._watcher.on('error', (err) => {
172
+ // Watcher errored mid-flight (e.g. dir removed). Fall back to
173
+ // polling instead of stopping entirely.
174
+ this.logger.warn?.(`[log-tail] watcher error for ${this.path}: ${err.message}; falling back to polling`);
175
+ try { this._watcher.close(); } catch {}
176
+ this._watcher = null;
177
+ if (!this._closed) {
178
+ this._mode = 'poll';
179
+ this._startPolling();
180
+ }
181
+ });
182
+ return true;
183
+ } catch (err) {
184
+ // EPERM (sandbox), ENOSYS (unsupported), ENOENT (path gone) — all fall back.
185
+ this.logger.log?.(`[log-tail] fs.watch attempt failed: ${err.message}`);
186
+ return false;
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Schedule a `_readNew()` call. Multiple triggers between reads are
192
+ * coalesced into a single read — debounces watcher event storms when
193
+ * claude writes many lines in quick succession.
194
+ */
195
+ _triggerRead() {
196
+ if (this._closed) return;
197
+ if (this._readInFlight) {
198
+ this._readPending = true;
199
+ return;
200
+ }
201
+ this._readInFlight = true;
202
+ this._readNew()
203
+ .catch((err) => this.emit('error', err))
204
+ .finally(() => {
205
+ this._readInFlight = false;
206
+ if (this._readPending && !this._closed) {
207
+ this._readPending = false;
208
+ // Re-enter once more to catch anything that arrived during
209
+ // the previous read.
210
+ setImmediate(() => this._triggerRead());
211
+ }
212
+ });
213
+ }
214
+
215
+ _startSafetyNetPoll() {
216
+ if (this._closed) return;
217
+ const tick = () => {
218
+ if (this._closed) return;
219
+ this._triggerRead();
220
+ this._timer = setTimeout(tick, WATCH_SAFETY_NET_MS);
221
+ this._timer.unref?.();
222
+ };
223
+ this._timer = setTimeout(tick, WATCH_SAFETY_NET_MS);
224
+ this._timer.unref?.();
225
+ }
226
+
227
+ _startPolling() {
228
+ const tick = () => {
229
+ if (this._closed) return;
230
+ this._triggerRead();
231
+ if (!this._closed) {
232
+ this._timer = setTimeout(tick, this.intervalMs);
233
+ // Don't keep the event loop alive solely for tailing. In
234
+ // production the polygram daemon has many other refs (Telegram
235
+ // polling, IPC, the tmux session itself) keeping it up.
236
+ this._timer.unref?.();
237
+ }
238
+ };
239
+ // Fire the first tick immediately so existing content (if any)
240
+ // is consumed without waiting `intervalMs`. setImmediate is NOT
241
+ // unref'd here — we want at least one read of existing content to
242
+ // complete before the loop is allowed to exit.
243
+ this._timer = setImmediate(tick);
244
+ }
245
+
246
+ async _readNew() {
247
+ let stat;
248
+ try {
249
+ stat = await this.fs.promises.stat(this.path);
250
+ } catch (err) {
251
+ if (err.code === 'ENOENT') return; // not created yet
252
+ throw err;
253
+ }
254
+ if (stat.size < this._offset) {
255
+ // File truncated (rare for claude debug-file but possible on log
256
+ // rotation). Reset offset and re-read from the beginning.
257
+ this.emit('truncated', { previous: this._offset, current: stat.size });
258
+ this._offset = 0;
259
+ this._buf = '';
260
+ // Drop any partial multibyte sequence buffered inside the decoder —
261
+ // it belongs to the pre-truncation file and would corrupt the first
262
+ // post-truncation read.
263
+ this._decoder = new StringDecoder('utf8');
264
+ }
265
+ if (stat.size <= this._offset) return; // unchanged
266
+ const fd = await this.fs.promises.open(this.path, 'r');
267
+ try {
268
+ const bytesToRead = stat.size - this._offset;
269
+ const buffer = Buffer.alloc(Math.min(bytesToRead, DEFAULT_CHUNK_BYTES));
270
+ let totalRead = 0;
271
+ while (totalRead < bytesToRead && !this._closed) {
272
+ const remaining = bytesToRead - totalRead;
273
+ const readSize = Math.min(remaining, buffer.length);
274
+ const { bytesRead } = await fd.read(buffer, 0, readSize, this._offset + totalRead);
275
+ if (bytesRead === 0) break;
276
+ // L8: StringDecoder.write instead of per-chunk toString('utf8') so a
277
+ // multibyte char straddling the read boundary survives intact.
278
+ this._buf += this._decoder.write(buffer.subarray(0, bytesRead));
279
+ totalRead += bytesRead;
280
+ }
281
+ this._offset += totalRead;
282
+ } finally {
283
+ await fd.close();
284
+ }
285
+ // Split on newlines, keeping any trailing partial line in _buf.
286
+ const parts = this._buf.split(/\r?\n/);
287
+ this._buf = parts.pop() ?? '';
288
+ // Safety: drop the trailing partial line if it grew past
289
+ // MAX_BUF_BYTES without a newline. claude TUI doesn't write lines
290
+ // this large in normal operation; continuing would risk OOM.
291
+ if (this._buf.length > MAX_BUF_BYTES) {
292
+ this.emit('line-too-long', {
293
+ bytes: this._buf.length,
294
+ max: MAX_BUF_BYTES,
295
+ location: 'trailing-partial',
296
+ });
297
+ this._buf = '';
298
+ }
299
+ for (const line of parts) {
300
+ if (this._closed) return;
301
+ // Skip empty lines (common in debug logs).
302
+ if (line.length === 0) continue;
303
+ // Safety: drop completed lines that exceed the cap. JSON.parse
304
+ // on a 100MB line synchronously blocks the event loop.
305
+ if (line.length > MAX_BUF_BYTES) {
306
+ this.emit('line-too-long', {
307
+ bytes: line.length,
308
+ max: MAX_BUF_BYTES,
309
+ location: 'completed-line',
310
+ });
311
+ continue;
312
+ }
313
+ this.emit('line', line);
314
+ }
315
+ }
316
+
317
+ close() {
318
+ if (this._closed) return;
319
+ this._closed = true;
320
+ if (this._timer) {
321
+ clearTimeout(this._timer);
322
+ clearImmediate(this._timer);
323
+ this._timer = null;
324
+ }
325
+ if (this._watcher) {
326
+ try { this._watcher.close(); } catch {}
327
+ this._watcher = null;
328
+ }
329
+ // Flush any trailing buffered partial line as a final 'line' so
330
+ // consumers don't lose data on shutdown.
331
+ if (this._buf.length > 0) {
332
+ this.emit('line', this._buf);
333
+ this._buf = '';
334
+ }
335
+ this.emit('close');
336
+ }
337
+ }
338
+
339
+ module.exports = { LogTail };
@@ -0,0 +1,80 @@
1
+ // provenance: polygram@0.17.11 lib/tmux/orphan-sweep.js (git 746bca6) — verbatim*: env prefix WATER_, bridge name water-bridge, vendor path (SHARED-LIB.md).
2
+ /**
3
+ * Boot-time tmux orphan sweep — kill any `<sessionPrefix>-<botName>-*` tmux
4
+ * sessions left over from a prior daemon.
5
+ *
6
+ * Why this exists:
7
+ * - `lib/process-guard.js#claimPidFile` (rc.50) kills the prior
8
+ * polygram daemon at boot, but tmux sessions OUTLIVE their parent
9
+ * process — they're owned by the tmux server, not by polygram.
10
+ * - When the new daemon's TmuxProcess.start() tries to spawn a
11
+ * session with the bot-prefixed name, `tmux new-session` fails
12
+ * with EEXIST because the old session is still there.
13
+ * - The old session is unrecoverable: claudeSessionId is fresh per
14
+ * turn, the daemon writing to JSONL was SIGKILLed mid-turn, and
15
+ * any user-visible reply was already lost to the dead daemon.
16
+ *
17
+ * Strategy: list, kill, log. Best-effort — if tmux isn't running or
18
+ * the kill races a concurrent operator, swallow the error and proceed.
19
+ *
20
+ * @see lib/process-guard.js (claimPidFile)
21
+ * @see lib/tmux/tmux-runner.js (listPolygramSessions, killSession)
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ const { createTmuxRunner } = require('./tmux-runner');
27
+
28
+ /**
29
+ * Sweep all `<sessionPrefix>-<botName>-*` tmux sessions on the host (prefix from the runner).
30
+ *
31
+ * @param {object} opts
32
+ * @param {string} opts.botName — only sweep sessions for THIS bot
33
+ * @param {object} [opts.runner] — injected TmuxRunner (for tests)
34
+ * @param {object} [opts.logger=console]
35
+ * @returns {Promise<{ swept: string[], errors: Array<{name:string, error:string}> }>}
36
+ */
37
+ async function sweepTmuxOrphans({ botName, runner, logger = console } = {}) {
38
+ if (!botName) throw new TypeError('sweepTmuxOrphans: botName required');
39
+ // SECURITY (audit M2): dashes in bot names risk prefix-match
40
+ // collision when two bots share a prefix (e.g. `shumabit` matches
41
+ // `water-shumabit-prod-*` too). Warn so the operator can rename.
42
+ // The trailing `-` in the listPolygramSessions filter prevents an
43
+ // exact-prefix collision but DOES NOT prevent `shumabit` vs
44
+ // `shumabit-prod`. Defense-in-depth: surface it.
45
+ if (typeof botName === 'string' && botName.includes('-')) {
46
+ logger.warn?.(
47
+ `[orphan-sweep] bot name "${botName}" contains '-'; orphan-sweep `
48
+ + `prefix matching could collide with other bot names sharing a `
49
+ + `prefix. Consider renaming (e.g. use _ instead).`,
50
+ );
51
+ }
52
+ const r = runner || createTmuxRunner({ logger });
53
+ let names;
54
+ try {
55
+ names = await r.listPolygramSessions(botName);
56
+ } catch (err) {
57
+ // Most common: tmux not running. Best-effort = no-op.
58
+ logger.log?.(`[orphan-sweep] list-sessions failed (${err.message}); assuming no orphans`);
59
+ return { swept: [], errors: [] };
60
+ }
61
+ if (names.length === 0) {
62
+ logger.log?.(`[orphan-sweep] no orphan tmux sessions for ${botName}`);
63
+ return { swept: [], errors: [] };
64
+ }
65
+ logger.log?.(`[orphan-sweep] killing ${names.length} orphan tmux session(s): ${names.join(', ')}`);
66
+ const errors = [];
67
+ const swept = [];
68
+ for (const name of names) {
69
+ try {
70
+ await r.killSession(name);
71
+ swept.push(name);
72
+ } catch (err) {
73
+ errors.push({ name, error: err.message });
74
+ logger.warn?.(`[orphan-sweep] kill ${name} failed: ${err.message}`);
75
+ }
76
+ }
77
+ return { swept, errors };
78
+ }
79
+
80
+ module.exports = { sweepTmuxOrphans };
@@ -0,0 +1,111 @@
1
+ // provenance: polygram@0.17.11 lib/tmux/poll-scheduler.js (git 746bca6) — verbatim: env prefix WATER_, bridge name water-bridge, vendor path (SHARED-LIB.md).
2
+ /**
3
+ * PollScheduler — shared tick generator for TmuxProcess polling loops.
4
+ *
5
+ * Each in-flight tmux turn polls `tmux capture-pane` every ~250ms to
6
+ * detect READY / STREAMING / approval-prompt state changes. Without
7
+ * coordination, N concurrent in-flight chats run N independent
8
+ * `setTimeout` chains. PollScheduler collapses these into a SINGLE
9
+ * `setInterval` whose firing wakes all registered waiters at once.
10
+ *
11
+ * Wins:
12
+ * - One timer regardless of how many tmux chats are running.
13
+ * - Tick-aligned bursts: all capture-pane subprocess spawns happen
14
+ * in the same JS turn, then the loop idles until the next tick.
15
+ * Linux/macOS handle bursty fork+exec better than smeared.
16
+ * - Single shutdown point — `release()` from each process cleanly
17
+ * stops the timer when nothing is in flight.
18
+ *
19
+ * Usage:
20
+ * const sched = new PollScheduler({ intervalMs: 250 });
21
+ * await proc.send(...); // internally calls:
22
+ * // sched.acquire();
23
+ * // while (not done) { ...; await sched.waitTick(); }
24
+ * // sched.release();
25
+ *
26
+ * Each `waitTick()` returns a Promise that resolves at the NEXT tick.
27
+ * Multiple waiters on the same tick all resolve simultaneously.
28
+ */
29
+
30
+ 'use strict';
31
+
32
+ class PollScheduler {
33
+ /**
34
+ * @param {object} [opts]
35
+ * @param {number} [opts.intervalMs=250] — global poll cadence
36
+ */
37
+ constructor({ intervalMs = 250 } = {}) {
38
+ this.intervalMs = intervalMs;
39
+ this._timer = null;
40
+ this._refCount = 0;
41
+ this._waiters = new Set();
42
+ }
43
+
44
+ /**
45
+ * Register a polling lifetime. Increments refCount and starts the
46
+ * shared interval if not already running. Pair every acquire() with
47
+ * a release() in a try/finally.
48
+ */
49
+ acquire() {
50
+ this._refCount++;
51
+ if (!this._timer) {
52
+ this._timer = setInterval(() => this._tick(), this.intervalMs);
53
+ // Don't keep the event loop alive solely for polling. The
54
+ // polygram daemon has many other refs (Telegram, IPC, the tmux
55
+ // sessions themselves) keeping it up.
56
+ this._timer.unref?.();
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Drop a polling lifetime. When refCount hits zero we stop the
62
+ * interval AND resolve any lingering waiters so their loops can
63
+ * exit cleanly (e.g. process killed mid-tick).
64
+ */
65
+ release() {
66
+ if (this._refCount <= 0) return;
67
+ this._refCount--;
68
+ if (this._refCount === 0 && this._timer) {
69
+ clearInterval(this._timer);
70
+ this._timer = null;
71
+ // Wake any leftover waiters so their polling loops can observe
72
+ // closed state and exit.
73
+ this._drainWaiters();
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Resolves at the next scheduler tick. Cheap — no setTimeout
79
+ * allocation per call, just a Set insertion. Caller MUST have
80
+ * called acquire() before its first waitTick() and call release()
81
+ * after its last.
82
+ */
83
+ waitTick() {
84
+ return new Promise((resolve) => {
85
+ this._waiters.add(resolve);
86
+ });
87
+ }
88
+
89
+ /**
90
+ * Number of registered polling lifetimes (active in-flight turns).
91
+ * Useful for observability + tests.
92
+ */
93
+ get activeCount() {
94
+ return this._refCount;
95
+ }
96
+
97
+ _tick() {
98
+ this._drainWaiters();
99
+ }
100
+
101
+ _drainWaiters() {
102
+ if (this._waiters.size === 0) return;
103
+ const fns = [...this._waiters];
104
+ this._waiters.clear();
105
+ for (const fn of fns) {
106
+ try { fn(); } catch { /* swallow */ }
107
+ }
108
+ }
109
+ }
110
+
111
+ module.exports = { PollScheduler };