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,296 +0,0 @@
1
- /**
2
- * rc.50: process-guard helpers — orphan-detection PID file + safety
3
- * handlers for uncaughtException / unhandledRejection that don't
4
- * re-enter on broken stdout.
5
- *
6
- * Background — the rc.50 incident:
7
- * PID 6335 (rc.48) was orphaned when its tmux pane was destroyed
8
- * during `launchctl kickstart -k`. polygram's existing SIGHUP
9
- * handler should have drained cleanly, but during the drain
10
- * `console.error` inside the uncaughtException handler itself
11
- * threw EIO (stdout was wired to a now-destroyed pty). That fired
12
- * the same handler, which logged again, which threw EIO again — a
13
- * tight re-entrant loop that hijacked the event loop and prevented
14
- * shutdown from completing. The orphan ran for 3+ hours writing
15
- * 3.59M+ uncaught-exception rows to the DB at ~12k/sec, and
16
- * polled the same Telegram bot token in parallel with the new
17
- * daemon.
18
- *
19
- * This module provides three primitives. polygram.js wires them
20
- * together at boot.
21
- */
22
-
23
- 'use strict';
24
-
25
- const fs = require('fs');
26
-
27
- /**
28
- * Boot-time orphan detection. Writes our PID to `pidPath`. If the
29
- * file already exists with a different live PID, kill it before
30
- * proceeding (SIGTERM, then SIGKILL after `sigtermWaitMs`). Without
31
- * this, two daemons can end up sharing the same Telegram bot token
32
- * and SQLite DB — the cascade that made the rc.50 incident
33
- * production-visible.
34
- *
35
- * @returns {{ priorPid: number|null, priorAction: string }}
36
- */
37
- function claimPidFile(pidPath, { logger = console, sigtermWaitMs = 2000 } = {}) {
38
- const ownPid = process.pid;
39
- let priorPid = null;
40
- let priorAction = 'no-prior';
41
-
42
- if (fs.existsSync(pidPath)) {
43
- const raw = (() => {
44
- try { return fs.readFileSync(pidPath, 'utf8').trim(); }
45
- catch { return ''; }
46
- })();
47
- const parsed = /^\d+$/.test(raw) ? parseInt(raw, 10) : null;
48
- if (!parsed) {
49
- priorAction = 'malformed-overwritten';
50
- } else if (parsed === ownPid) {
51
- // Re-entrant call from same process — write but don't kill self.
52
- priorPid = parsed;
53
- priorAction = 'self-skip';
54
- } else {
55
- priorPid = parsed;
56
- const alive = isAlive(parsed);
57
- if (!alive) {
58
- priorAction = 'stale-overwritten';
59
- } else {
60
- logger.log?.(`[orphan-guard] prior daemon PID ${parsed} still alive — sending SIGTERM`);
61
- try { process.kill(parsed, 'SIGTERM'); } catch {}
62
- const start = Date.now();
63
- while (Date.now() - start < sigtermWaitMs && isAlive(parsed)) {
64
- // Busy-wait. Boot is single-threaded; we have nothing else to do
65
- // until the orphan is gone, and we don't want to bind the bot
66
- // token while it's still polling. sigtermWaitMs is configurable
67
- // (default 2s; tests override to 100ms).
68
- sleepSync(50);
69
- }
70
- if (isAlive(parsed)) {
71
- logger.log?.(`[orphan-guard] PID ${parsed} ignored SIGTERM — escalating to SIGKILL`);
72
- try { process.kill(parsed, 'SIGKILL'); } catch {}
73
- // Poll for actual death — SIGKILL is delivered async, the
74
- // kernel may take a tick to reap (esp. for detached children).
75
- const killStart = Date.now();
76
- while (Date.now() - killStart < 1000 && isAlive(parsed)) {
77
- sleepSync(20);
78
- }
79
- priorAction = 'sigkill-killed';
80
- } else {
81
- priorAction = 'sigterm-killed';
82
- }
83
- }
84
- }
85
- }
86
-
87
- fs.writeFileSync(pidPath, String(ownPid) + '\n', { mode: 0o600 });
88
- return { priorPid, priorAction };
89
- }
90
-
91
- /**
92
- * Delete the PID file on clean shutdown. Only deletes if the file
93
- * still contains OUR PID — protects against the race where a new
94
- * daemon already claimed the file and rewrote it before we got here.
95
- */
96
- function releasePidFile(pidPath) {
97
- if (!fs.existsSync(pidPath)) return;
98
- try {
99
- const content = fs.readFileSync(pidPath, 'utf8').trim();
100
- if (content === String(process.pid)) {
101
- fs.unlinkSync(pidPath);
102
- }
103
- // Else: another daemon owns it now. Leaving alone is correct.
104
- } catch {}
105
- }
106
-
107
- /**
108
- * Build an uncaughtException handler that:
109
- * 1. Wraps `logger.error` AND `logEvent` in try/catch — neither
110
- * can re-throw out of the handler. (Pre-rc.50 the bare
111
- * console.error threw EIO and re-fired this same handler in
112
- * an event-loop-hijacking loop.)
113
- * 2. Tracks repetitions of the same exception message in a sliding
114
- * window. If the same message fires `eioThreshold` times within
115
- * `eioWindowMs`, calls `panicExit(2)` so launchd restarts us
116
- * cleanly. Without the circuit breaker, a stuck-stdout EIO
117
- * cascade just keeps writing rows forever.
118
- *
119
- * @param {object} opts
120
- * @param {object} opts.logger - { error(msg) } sink for human-readable logs.
121
- * @param {function(string, object)} opts.logEvent - DB persist sink.
122
- * @param {string} opts.botName
123
- * @param {number} [opts.eioThreshold=100]
124
- * @param {number} [opts.eioWindowMs=5000]
125
- * @param {function(number)} [opts.panicExit=process.exit]
126
- * @param {function(): number} [opts.now=Date.now]
127
- * @returns {function(Error)}
128
- */
129
- function _makeUncaughtHandler({
130
- logger,
131
- logEvent,
132
- botName,
133
- eioThreshold = 100,
134
- eioWindowMs = 5000,
135
- panicExit = (code) => process.exit(code),
136
- now = Date.now,
137
- } = {}) {
138
- // Per-message sliding-window timestamps. Map<message, number[]>.
139
- const recent = new Map();
140
- let panicked = false;
141
-
142
- return function uncaughtHandler(err) {
143
- if (panicked) return; // bail — we're on our way out
144
- const msg = String(err?.message || err || 'unknown').slice(0, 500);
145
- const stack = err?.stack?.split('\n').slice(0, 5).join('\n') || '';
146
-
147
- // 1. Log defensively. Stdout may be broken (the original incident);
148
- // must not re-throw out of this handler.
149
- try {
150
- logger?.error?.(`[polygram] uncaughtException: ${msg}\n${stack}`);
151
- } catch { /* swallow — broken stdout */ }
152
-
153
- // 2. Persist defensively. DB might be closing during shutdown.
154
- try {
155
- logEvent?.('uncaught-exception', { message: msg, bot_name: botName });
156
- } catch { /* swallow */ }
157
-
158
- // 3. Storm circuit breaker: same message N times in window → exit.
159
- const t = now();
160
- let timestamps = recent.get(msg);
161
- if (!timestamps) { timestamps = []; recent.set(msg, timestamps); }
162
- timestamps.push(t);
163
- // Drop expired.
164
- while (timestamps.length && t - timestamps[0] > eioWindowMs) timestamps.shift();
165
- if (timestamps.length >= eioThreshold) {
166
- panicked = true;
167
- try {
168
- logger?.error?.(`[polygram] uncaughtException circuit breaker: ${timestamps.length}× "${msg}" in ${eioWindowMs}ms — exit(2)`);
169
- } catch {}
170
- try {
171
- logEvent?.('panic-exit', { message: msg, count: timestamps.length, window_ms: eioWindowMs, bot_name: botName });
172
- } catch {}
173
- panicExit(2);
174
- }
175
- };
176
- }
177
-
178
- // Build a parallel handler for unhandledRejection: same defensive
179
- // posture, separate counter (rejections and exceptions can come
180
- // from different code paths and shouldn't share a budget).
181
- function _makeUnhandledRejectionHandler(opts) {
182
- const inner = _makeUncaughtHandler({
183
- ...opts,
184
- // Override the 'kind' written to events table.
185
- logEvent: opts.logEvent
186
- ? (kind, detail) => opts.logEvent(kind === 'panic-exit' ? 'panic-exit' : 'unhandled-rejection', detail)
187
- : undefined,
188
- });
189
- return (reason /* , promise */) => {
190
- const err = reason instanceof Error ? reason : new Error(String(reason));
191
- inner(err);
192
- };
193
- }
194
-
195
- /**
196
- * Swallow EPIPE/EIO on the process's own stdout/stderr so a broken pipe during
197
- * shutdown can't become an uncaughtException.
198
- *
199
- * rc.50 stopped the re-entrant LOOP (the handler no longer re-throws), but the
200
- * write errors themselves still surface: when `launchctl kickstart` destroys the
201
- * tmux pane, in-flight `console.log`/`console.error` calls hit a now-dead pty.
202
- * Because stdout/stderr are TTYs, those writes throw EIO **synchronously** — each
203
- * unguarded throw becomes an uncaughtException. Observed live on the rc.29→rc.30
204
- * restart (2026-06-08): 100 `write EIO` rows then a circuit-breaker panic-exit on
205
- * every deploy, interrupting the graceful drain.
206
- *
207
- * This guards BOTH delivery paths:
208
- * (a) sync: wraps `write()` to drop EPIPE/EIO throws (TTY case — the real one);
209
- * (b) async: attaches an `error` listener for the pipe case (errors arrive as
210
- * events). Genuine, non-EPIPE/EIO errors still surface unchanged.
211
- *
212
- * @returns {{ uninstall: function() }}
213
- */
214
- function guardStdio({ streams = [process.stdout, process.stderr] } = {}) {
215
- const guarded = streams.filter(Boolean);
216
- const isBrokenPipe = (err) => err && (err.code === 'EPIPE' || err.code === 'EIO');
217
- const onError = (err) => { if (isBrokenPipe(err)) return; throw err; };
218
- const restores = [];
219
-
220
- for (const s of guarded) {
221
- s.on?.('error', onError);
222
- restores.push(() => s.off?.('error', onError));
223
-
224
- if (typeof s.write === 'function' && !s.__polygramStdioGuarded) {
225
- const origWrite = s.write;
226
- s.write = function guardedWrite(...args) {
227
- try {
228
- return origWrite.apply(this, args);
229
- } catch (err) {
230
- if (!isBrokenPipe(err)) throw err;
231
- // Pane is gone — nothing to write to. Invoke the write callback (the
232
- // last arg, if any) so callers awaiting it don't hang, and report
233
- // backpressure (false) instead of throwing to uncaughtException.
234
- const cb = args[args.length - 1];
235
- if (typeof cb === 'function') { try { cb(err); } catch { /* ignore */ } }
236
- return false;
237
- }
238
- };
239
- s.__polygramStdioGuarded = true;
240
- restores.push(() => { s.write = origWrite; delete s.__polygramStdioGuarded; });
241
- }
242
- }
243
-
244
- return { uninstall() { for (const r of restores) r(); } };
245
- }
246
-
247
- /**
248
- * Convenience: install both handlers in one call (plus the stdio guard, so the
249
- * shutdown broken-pipe writes never reach the uncaughtException handler).
250
- * @returns {{ uninstall: function() }}
251
- */
252
- function installSafetyHandlers(opts) {
253
- const onException = _makeUncaughtHandler(opts);
254
- const onRejection = _makeUnhandledRejectionHandler(opts);
255
- process.on('uncaughtException', onException);
256
- process.on('unhandledRejection', onRejection);
257
- const stdio = guardStdio();
258
- return {
259
- uninstall() {
260
- process.off('uncaughtException', onException);
261
- process.off('unhandledRejection', onRejection);
262
- stdio.uninstall();
263
- },
264
- };
265
- }
266
-
267
- // ─── helpers ─────────────────────────────────────────────────────────
268
-
269
- function isAlive(pid) {
270
- try {
271
- process.kill(pid, 0);
272
- return true;
273
- } catch (err) {
274
- // ESRCH = no such process. EPERM = exists but we lack rights
275
- // (treat as alive — same UID typically; we will fail to kill it
276
- // but at least we know it's there).
277
- if (err.code === 'EPERM') return true;
278
- return false;
279
- }
280
- }
281
-
282
- function sleepSync(ms) {
283
- // Atomics-based busy-wait. 50ms granularity is fine for boot
284
- // orphan-killing; we're not in a hot path.
285
- const buf = new Int32Array(new SharedArrayBuffer(4));
286
- Atomics.wait(buf, 0, 0, ms);
287
- }
288
-
289
- module.exports = {
290
- claimPidFile,
291
- releasePidFile,
292
- installSafetyHandlers,
293
- guardStdio,
294
- _makeUncaughtHandler,
295
- _makeUnhandledRejectionHandler,
296
- };