polygram 0.17.10 → 0.17.12

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.
Files changed (39) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/lib/config.js +21 -0
  3. package/lib/error/classify.js +14 -0
  4. package/lib/handlers/dispatcher.js +54 -1
  5. package/lib/handlers/drop-redeliver.js +19 -0
  6. package/lib/handlers/should-handle.js +52 -0
  7. package/lib/media-group-buffer.js +29 -0
  8. package/lib/ops/auth-disabled-gate.js +43 -0
  9. package/lib/ops/heartbeat.js +56 -0
  10. package/lib/process/channels-tool-dispatcher.js +12 -1
  11. package/lib/prompt.js +22 -7
  12. package/lib/sdk/callbacks.js +2 -1
  13. package/lib/secret-detect.js +13 -1
  14. package/lib/telegram/chunk.js +20 -6
  15. package/lib/telegram/process-agent-reply.js +5 -3
  16. package/lib/telegram/streamer.js +6 -1
  17. package/package.json +2 -1
  18. package/polygram.js +202 -45
  19. package/lib/async-lock.js +0 -49
  20. package/lib/claude-bin.js +0 -246
  21. package/lib/compaction-warn.js +0 -59
  22. package/lib/context-usage.js +0 -93
  23. package/lib/process/channels-bridge-protocol.js +0 -199
  24. package/lib/process/channels-bridge-server.js +0 -274
  25. package/lib/process/channels-bridge.mjs +0 -477
  26. package/lib/process/cli-process.js +0 -4029
  27. package/lib/process/factory.js +0 -215
  28. package/lib/process/hook-event-tail.js +0 -162
  29. package/lib/process/hook-settings.js +0 -181
  30. package/lib/process/polygram-hook-append.js +0 -71
  31. package/lib/process/process.js +0 -215
  32. package/lib/process/sdk-process.js +0 -880
  33. package/lib/process-guard.js +0 -296
  34. package/lib/process-manager.js +0 -628
  35. package/lib/tmux/log-tail.js +0 -334
  36. package/lib/tmux/orphan-sweep.js +0 -79
  37. package/lib/tmux/poll-scheduler.js +0 -110
  38. package/lib/tmux/startup-gate.js +0 -250
  39. package/lib/tmux/tmux-runner.js +0 -412
@@ -1,334 +0,0 @@
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/util/claude-session-jsonl.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
- const { StringDecoder } = require('string_decoder');
46
-
47
- const DEFAULT_INTERVAL_MS = 100;
48
- // Slow safety-net poll when fs.watch is active. Catches any events
49
- // the watcher missed (rare on Linux/macOS, more common on networked
50
- // or fuse filesystems). 1s is more than enough for backstop.
51
- const WATCH_SAFETY_NET_MS = 1000;
52
- const DEFAULT_CHUNK_BYTES = 64 * 1024;
53
- // Safety cap: a single line with no \n must not grow `_buf` without
54
- // bound. claude TUI doesn't emit lines this big in normal operation;
55
- // hitting this is a sign of corruption or a hostile tool result that
56
- // could OOM the daemon and stall the event loop with a sync JSON.parse.
57
- const MAX_BUF_BYTES = 16 * 1024 * 1024;
58
-
59
- class LogTail extends EventEmitter {
60
- /**
61
- * @param {object} opts
62
- * @param {string} opts.path — log file path
63
- * @param {number} [opts.intervalMs=100] — poll interval when in
64
- * polling mode (also used as the initial-tick delay in watch mode).
65
- * @param {boolean} [opts.skipExisting] — start at current file size,
66
- * only emit lines added AFTER start(). Used for `--resume` on the
67
- * tmux backend so historic JSONL events aren't replayed.
68
- * @param {'auto'|true|false} [opts.useWatch='auto']
69
- * - 'auto' (default): try fs.watch; fall back to polling on error.
70
- * - true: require fs.watch to work; throw on failure.
71
- * - false: force polling.
72
- * @param {object} [opts.fs] — test seam (override fs)
73
- * @param {object} [opts.logger=console]
74
- */
75
- constructor({
76
- path: filePath,
77
- intervalMs = DEFAULT_INTERVAL_MS,
78
- skipExisting = false,
79
- useWatch = 'auto',
80
- fs: fsOverride,
81
- logger = console,
82
- } = {}) {
83
- super();
84
- if (typeof filePath !== 'string' || !filePath) {
85
- throw new TypeError('LogTail: path required');
86
- }
87
- this.path = filePath;
88
- this.intervalMs = intervalMs;
89
- this.skipExisting = skipExisting;
90
- this.useWatch = useWatch;
91
- this.logger = logger;
92
- this.fs = fsOverride || fs;
93
- this._offset = 0;
94
- this._buf = '';
95
- // L8: decode bytes through a StringDecoder so a multibyte UTF-8 char
96
- // split across two read chunks (the 64KB DEFAULT_CHUNK_BYTES boundary)
97
- // isn't corrupted into U+FFFD. The decoder holds an incomplete trailing
98
- // sequence until the continuation bytes arrive on the next read. The
99
- // hook ndjson carries large non-ASCII tool payloads, so this is
100
- // load-bearing on the CliProcess observability path.
101
- this._decoder = new StringDecoder('utf8');
102
- this._closed = false;
103
- this._timer = null;
104
- this._watcher = null;
105
- this._mode = null; // 'watch' | 'poll' after start()
106
- this._initialised = false;
107
- this._readInFlight = false; // debounce concurrent _readNew triggers
108
- this._readPending = false;
109
- }
110
-
111
- start() {
112
- if (this._closed) throw new Error('LogTail: closed');
113
- if (this._mode) return; // idempotent
114
- // Snapshot offset at start() time when skipExisting is requested.
115
- // Doing this on first read instead would race: if content is
116
- // appended between start() and the first read, the offset jump
117
- // would skip those bytes too.
118
- if (this.skipExisting) {
119
- try {
120
- const stat = this.fs.statSync(this.path);
121
- this._offset = stat.size;
122
- } catch (err) {
123
- if (err.code !== 'ENOENT') throw err;
124
- // File doesn't exist yet — offset stays 0, all future content
125
- // is "new" by definition.
126
- }
127
- this._initialised = true;
128
- }
129
- // Decide watch vs poll. In 'auto' mode we attempt fs.watch and
130
- // silently fall back; in 'true' mode we throw on failure; in
131
- // 'false' mode we skip the attempt entirely.
132
- if (this.useWatch !== false) {
133
- if (this._tryStartWatch()) {
134
- this._mode = 'watch';
135
- // Trigger an immediate first read (existing content + warmup),
136
- // then add a slow safety-net poll on top of the watcher to
137
- // catch any missed events.
138
- setImmediate(() => this._triggerRead());
139
- this._startSafetyNetPoll();
140
- return;
141
- }
142
- if (this.useWatch === true) {
143
- throw new Error('LogTail: useWatch:true requested but fs.watch failed');
144
- }
145
- this.logger.log?.(`[log-tail] fs.watch unavailable for ${this.path}; falling back to polling`);
146
- }
147
- this._mode = 'poll';
148
- this._startPolling();
149
- }
150
-
151
- /**
152
- * Try to install fs.watch on the parent directory. We watch the dir
153
- * (not the file) because the file may not exist yet — claude TUI
154
- * creates it a moment after spawn. Returns true on success.
155
- */
156
- _tryStartWatch() {
157
- try {
158
- const dir = path.dirname(this.path);
159
- const base = path.basename(this.path);
160
- // Ensure the parent exists so fs.watch can attach. If the
161
- // ~/.claude/projects/<cwd> dir hasn't been created yet, claude
162
- // will create it on first turn; we make it now so the watcher
163
- // can attach immediately.
164
- this.fs.mkdirSync(dir, { recursive: true });
165
- this._watcher = this.fs.watch(dir, { persistent: false }, (eventType, filename) => {
166
- if (this._closed) return;
167
- if (filename !== base) return;
168
- this._triggerRead();
169
- });
170
- this._watcher.on('error', (err) => {
171
- // Watcher errored mid-flight (e.g. dir removed). Fall back to
172
- // polling instead of stopping entirely.
173
- this.logger.warn?.(`[log-tail] watcher error for ${this.path}: ${err.message}; falling back to polling`);
174
- try { this._watcher.close(); } catch {}
175
- this._watcher = null;
176
- if (!this._closed) {
177
- this._mode = 'poll';
178
- this._startPolling();
179
- }
180
- });
181
- return true;
182
- } catch (err) {
183
- // EPERM (sandbox), ENOSYS (unsupported), ENOENT (path gone) — all fall back.
184
- this.logger.log?.(`[log-tail] fs.watch attempt failed: ${err.message}`);
185
- return false;
186
- }
187
- }
188
-
189
- /**
190
- * Schedule a `_readNew()` call. Multiple triggers between reads are
191
- * coalesced into a single read — debounces watcher event storms when
192
- * claude writes many lines in quick succession.
193
- */
194
- _triggerRead() {
195
- if (this._closed) return;
196
- if (this._readInFlight) {
197
- this._readPending = true;
198
- return;
199
- }
200
- this._readInFlight = true;
201
- this._readNew()
202
- .catch((err) => this.emit('error', err))
203
- .finally(() => {
204
- this._readInFlight = false;
205
- if (this._readPending && !this._closed) {
206
- this._readPending = false;
207
- // Re-enter once more to catch anything that arrived during
208
- // the previous read.
209
- setImmediate(() => this._triggerRead());
210
- }
211
- });
212
- }
213
-
214
- _startSafetyNetPoll() {
215
- if (this._closed) return;
216
- const tick = () => {
217
- if (this._closed) return;
218
- this._triggerRead();
219
- this._timer = setTimeout(tick, WATCH_SAFETY_NET_MS);
220
- this._timer.unref?.();
221
- };
222
- this._timer = setTimeout(tick, WATCH_SAFETY_NET_MS);
223
- this._timer.unref?.();
224
- }
225
-
226
- _startPolling() {
227
- const tick = () => {
228
- if (this._closed) return;
229
- this._triggerRead();
230
- if (!this._closed) {
231
- this._timer = setTimeout(tick, this.intervalMs);
232
- // Don't keep the event loop alive solely for tailing. In
233
- // production the polygram daemon has many other refs (Telegram
234
- // polling, IPC, the tmux session itself) keeping it up.
235
- this._timer.unref?.();
236
- }
237
- };
238
- // Fire the first tick immediately so existing content (if any)
239
- // is consumed without waiting `intervalMs`. setImmediate is NOT
240
- // unref'd here — we want at least one read of existing content to
241
- // complete before the loop is allowed to exit.
242
- this._timer = setImmediate(tick);
243
- }
244
-
245
- async _readNew() {
246
- let stat;
247
- try {
248
- stat = await this.fs.promises.stat(this.path);
249
- } catch (err) {
250
- if (err.code === 'ENOENT') return; // not created yet
251
- throw err;
252
- }
253
- if (stat.size < this._offset) {
254
- // File truncated (rare for claude debug-file but possible on log
255
- // rotation). Reset offset and re-read from the beginning.
256
- this.emit('truncated', { previous: this._offset, current: stat.size });
257
- this._offset = 0;
258
- this._buf = '';
259
- }
260
- if (stat.size <= this._offset) return; // unchanged
261
- const fd = await this.fs.promises.open(this.path, 'r');
262
- try {
263
- const bytesToRead = stat.size - this._offset;
264
- const buffer = Buffer.alloc(Math.min(bytesToRead, DEFAULT_CHUNK_BYTES));
265
- let totalRead = 0;
266
- while (totalRead < bytesToRead && !this._closed) {
267
- const remaining = bytesToRead - totalRead;
268
- const readSize = Math.min(remaining, buffer.length);
269
- const { bytesRead } = await fd.read(buffer, 0, readSize, this._offset + totalRead);
270
- if (bytesRead === 0) break;
271
- // L8: StringDecoder.write instead of per-chunk toString('utf8') so a
272
- // multibyte char straddling the read boundary survives intact.
273
- this._buf += this._decoder.write(buffer.subarray(0, bytesRead));
274
- totalRead += bytesRead;
275
- }
276
- this._offset += totalRead;
277
- } finally {
278
- await fd.close();
279
- }
280
- // Split on newlines, keeping any trailing partial line in _buf.
281
- const parts = this._buf.split(/\r?\n/);
282
- this._buf = parts.pop() ?? '';
283
- // Safety: drop the trailing partial line if it grew past
284
- // MAX_BUF_BYTES without a newline. claude TUI doesn't write lines
285
- // this large in normal operation; continuing would risk OOM.
286
- if (this._buf.length > MAX_BUF_BYTES) {
287
- this.emit('line-too-long', {
288
- bytes: this._buf.length,
289
- max: MAX_BUF_BYTES,
290
- location: 'trailing-partial',
291
- });
292
- this._buf = '';
293
- }
294
- for (const line of parts) {
295
- if (this._closed) return;
296
- // Skip empty lines (common in debug logs).
297
- if (line.length === 0) continue;
298
- // Safety: drop completed lines that exceed the cap. JSON.parse
299
- // on a 100MB line synchronously blocks the event loop.
300
- if (line.length > MAX_BUF_BYTES) {
301
- this.emit('line-too-long', {
302
- bytes: line.length,
303
- max: MAX_BUF_BYTES,
304
- location: 'completed-line',
305
- });
306
- continue;
307
- }
308
- this.emit('line', line);
309
- }
310
- }
311
-
312
- close() {
313
- if (this._closed) return;
314
- this._closed = true;
315
- if (this._timer) {
316
- clearTimeout(this._timer);
317
- clearImmediate(this._timer);
318
- this._timer = null;
319
- }
320
- if (this._watcher) {
321
- try { this._watcher.close(); } catch {}
322
- this._watcher = null;
323
- }
324
- // Flush any trailing buffered partial line as a final 'line' so
325
- // consumers don't lose data on shutdown.
326
- if (this._buf.length > 0) {
327
- this.emit('line', this._buf);
328
- this._buf = '';
329
- }
330
- this.emit('close');
331
- }
332
- }
333
-
334
- module.exports = { LogTail };
@@ -1,79 +0,0 @@
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 };
@@ -1,110 +0,0 @@
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 };