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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/db.js +14 -3
- package/lib/handlers/slash-commands.js +22 -12
- package/lib/model-costs.js +60 -0
- package/lib/process/factory.js +102 -0
- package/lib/process/process.js +193 -0
- package/lib/process/sdk-process.js +880 -0
- package/lib/process/tmux-process.js +1022 -0
- package/lib/process-manager.js +391 -0
- package/lib/sdk/callbacks.js +13 -5
- package/lib/tmux/log-tail.js +324 -0
- package/lib/tmux/orphan-sweep.js +79 -0
- package/lib/tmux/poll-scheduler.js +110 -0
- package/lib/tmux/session-log-parser.js +173 -0
- package/lib/tmux/tmux-runner.js +303 -0
- package/lib/tmux/tui-tool-input.js +62 -0
- package/migrations/011-pm-backend.sql +17 -0
- package/package.json +1 -1
- package/polygram.js +122 -33
- package/lib/sdk/process-manager.js +0 -1178
|
@@ -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
|
+
};
|