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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/config.js +21 -0
- package/lib/error/classify.js +14 -0
- package/lib/handlers/dispatcher.js +54 -1
- package/lib/handlers/drop-redeliver.js +19 -0
- package/lib/handlers/should-handle.js +52 -0
- package/lib/media-group-buffer.js +29 -0
- package/lib/ops/auth-disabled-gate.js +43 -0
- package/lib/ops/heartbeat.js +56 -0
- package/lib/process/channels-tool-dispatcher.js +12 -1
- package/lib/prompt.js +22 -7
- package/lib/sdk/callbacks.js +2 -1
- package/lib/secret-detect.js +13 -1
- package/lib/telegram/chunk.js +20 -6
- package/lib/telegram/process-agent-reply.js +5 -3
- package/lib/telegram/streamer.js +6 -1
- package/package.json +2 -1
- package/polygram.js +202 -45
- package/lib/async-lock.js +0 -49
- package/lib/claude-bin.js +0 -246
- package/lib/compaction-warn.js +0 -59
- package/lib/context-usage.js +0 -93
- package/lib/process/channels-bridge-protocol.js +0 -199
- package/lib/process/channels-bridge-server.js +0 -274
- package/lib/process/channels-bridge.mjs +0 -477
- package/lib/process/cli-process.js +0 -4029
- package/lib/process/factory.js +0 -215
- package/lib/process/hook-event-tail.js +0 -162
- package/lib/process/hook-settings.js +0 -181
- package/lib/process/polygram-hook-append.js +0 -71
- package/lib/process/process.js +0 -215
- package/lib/process/sdk-process.js +0 -880
- package/lib/process-guard.js +0 -296
- package/lib/process-manager.js +0 -628
- package/lib/tmux/log-tail.js +0 -334
- package/lib/tmux/orphan-sweep.js +0 -79
- package/lib/tmux/poll-scheduler.js +0 -110
- package/lib/tmux/startup-gate.js +0 -250
- package/lib/tmux/tmux-runner.js +0 -412
package/lib/tmux/log-tail.js
DELETED
|
@@ -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 };
|
package/lib/tmux/orphan-sweep.js
DELETED
|
@@ -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 };
|