polygram 0.9.0 → 0.10.0-rc.2

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