polygram 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Unix socket IPC server for the bridge daemon.
3
+ *
4
+ * One socket per bot process at `/tmp/polygram-<bot>.sock`. Clients
5
+ * (Claude Code approval hooks, future cron bridge) send newline-delimited
6
+ * JSON requests; the server invokes a registered handler and writes back a
7
+ * newline-delimited JSON response.
8
+ *
9
+ * Each TCP-equivalent connection is one request / one response — no pooling,
10
+ * no long-lived sessions. Simpler and matches the hook's one-shot lifecycle.
11
+ */
12
+
13
+ const net = require('net');
14
+ const fs = require('fs');
15
+ const crypto = require('crypto');
16
+
17
+ function socketPathFor(botName) {
18
+ return `/tmp/polygram-${botName}.sock`;
19
+ }
20
+
21
+ function secretPathFor(botName) {
22
+ return `/tmp/polygram-${botName}.secret`;
23
+ }
24
+
25
+ /**
26
+ * Generate and persist a fresh IPC secret. Written 0600 so only same-UID
27
+ * processes can read it; same-UID is already the trust boundary (they can
28
+ * also connect to the socket), but requiring knowledge of this secret gives
29
+ * us a cheap way to reject stray processes that stumbled onto the socket
30
+ * without intent — and makes the auth model explicit rather than implicit.
31
+ */
32
+ function writeSecret(botName) {
33
+ const secret = crypto.randomBytes(32).toString('base64url');
34
+ const p = secretPathFor(botName);
35
+ fs.writeFileSync(p, secret, { mode: 0o600 });
36
+ // Re-chmod in case umask overrode our mode on create.
37
+ try { fs.chmodSync(p, 0o600); } catch {}
38
+ return secret;
39
+ }
40
+
41
+ function timingSafeEqual(a, b) {
42
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
43
+ const ab = Buffer.from(a);
44
+ const bb = Buffer.from(b);
45
+ if (ab.length !== bb.length) return false;
46
+ return crypto.timingSafeEqual(ab, bb);
47
+ }
48
+
49
+ /**
50
+ * Start a unix-socket server. Returns a close() function.
51
+ *
52
+ * @param {Object} opts
53
+ * @param {string} opts.path - socket path
54
+ * @param {Object} opts.handlers - { op_name: async (req) => res }
55
+ * @param {Object} [opts.logger] - console-like logger
56
+ * @param {number} [opts.mode] - chmod on the socket (default 0o600)
57
+ */
58
+ function start({ path, handlers, logger = console, mode = 0o600, secret = null }) {
59
+ // Stale socket cleanup — a crashed predecessor leaves the file.
60
+ try { fs.unlinkSync(path); } catch {}
61
+
62
+ const server = net.createServer((sock) => {
63
+ let buf = '';
64
+ sock.on('data', (chunk) => {
65
+ buf += chunk.toString('utf8');
66
+ // Drain as many complete lines as the chunk contains. The earlier
67
+ // version handled only the first newline and silently dropped the
68
+ // rest — that was a correctness bug under cron storms.
69
+ let nl;
70
+ while ((nl = buf.indexOf('\n')) !== -1) {
71
+ const line = buf.slice(0, nl);
72
+ buf = buf.slice(nl + 1);
73
+ handleLine(sock, line, handlers, logger, secret);
74
+ }
75
+ });
76
+ sock.on('error', (err) => {
77
+ if (err.code !== 'EPIPE' && err.code !== 'ECONNRESET') {
78
+ logger.error(`[ipc] socket error: ${err.message}`);
79
+ }
80
+ });
81
+ });
82
+
83
+ server.on('error', (err) => {
84
+ logger.error(`[ipc] server error: ${err.message}`);
85
+ });
86
+
87
+ return new Promise((resolve, reject) => {
88
+ server.listen(path, () => {
89
+ try { fs.chmodSync(path, mode); } catch (err) {
90
+ logger.error(`[ipc] chmod ${path}: ${err.message}`);
91
+ }
92
+ logger.log(`[ipc] listening on ${path}`);
93
+ resolve({
94
+ close() {
95
+ return new Promise((res) => {
96
+ server.close(() => {
97
+ try { fs.unlinkSync(path); } catch {}
98
+ res();
99
+ });
100
+ });
101
+ },
102
+ });
103
+ });
104
+ server.once('error', reject);
105
+ });
106
+ }
107
+
108
+ async function handleLine(sock, line, handlers, logger, secret) {
109
+ let req;
110
+ try {
111
+ req = JSON.parse(line);
112
+ } catch (err) {
113
+ writeReply(sock, { ok: false, error: `bad json: ${err.message}` });
114
+ return;
115
+ }
116
+ const op = req.op;
117
+ const id = req.id || null;
118
+ // Validate the shared secret (unless the server was started without one,
119
+ // in which case we're in a test or back-compat mode). The 'ping' op is
120
+ // exempt so liveness probes work without needing the secret.
121
+ if (secret && op !== 'ping') {
122
+ if (!timingSafeEqual(req.secret || '', secret)) {
123
+ logger.error(`[ipc] missing/bad secret on op=${op}`);
124
+ writeReply(sock, { id, ok: false, error: 'auth' });
125
+ return;
126
+ }
127
+ }
128
+ const handler = handlers[op];
129
+ if (!handler) {
130
+ writeReply(sock, { id, ok: false, error: `unknown op: ${op}` });
131
+ return;
132
+ }
133
+ try {
134
+ const res = await handler(req);
135
+ writeReply(sock, { id, ok: true, ...res });
136
+ } catch (err) {
137
+ logger.error(`[ipc] handler ${op} failed: ${err.message}`);
138
+ writeReply(sock, { id, ok: false, error: err.message });
139
+ }
140
+ }
141
+
142
+ function writeReply(sock, obj) {
143
+ try {
144
+ sock.write(JSON.stringify(obj) + '\n');
145
+ sock.end();
146
+ } catch {}
147
+ }
148
+
149
+ module.exports = { start, socketPathFor, secretPathFor, writeSecret };
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Pairing codes - live onboarding without bridge restarts.
3
+ *
4
+ * Admin: /pair-code issues a single-use code. Short TTL (default 10 min).
5
+ * Guest: /pair <CODE> claims it, gets an ACL row in `pairings`.
6
+ *
7
+ * Trust model: a code is bound to one bot and optionally one chat. Claiming
8
+ * creates a pairing row the address-detector consults. Revocation is a soft
9
+ * delete so audit trails survive.
10
+ */
11
+
12
+ const crypto = require('crypto');
13
+
14
+ // Crockford-ish base32: no 0/O/1/I/L to avoid hand-entry confusion.
15
+ const ALPHA = '23456789ABCDEFGHJKMNPQRSTVWXYZ';
16
+ const CODE_LEN = 8;
17
+
18
+ const DEFAULT_TTL_MS = 10 * 60 * 1000;
19
+ const MAX_TTL_MS = 7 * 24 * 3600 * 1000;
20
+ const MIN_TTL_MS = 60 * 1000;
21
+
22
+ const ISSUE_RATE_PER_OPERATOR_PER_HOUR = 10;
23
+ const CLAIM_RATE_PER_USER_PER_HOUR = 5;
24
+
25
+ function generateCode(len = CODE_LEN) {
26
+ const buf = crypto.randomBytes(len);
27
+ let out = '';
28
+ for (let i = 0; i < len; i++) out += ALPHA[buf[i] % ALPHA.length];
29
+ return out;
30
+ }
31
+
32
+ function normalizeCode(input) {
33
+ return String(input || '')
34
+ .toUpperCase()
35
+ .replace(/[\s-]/g, '');
36
+ }
37
+
38
+ function parseTtl(input) {
39
+ if (input == null) return DEFAULT_TTL_MS;
40
+ if (typeof input === 'number') return Math.max(MIN_TTL_MS, Math.min(MAX_TTL_MS, input));
41
+ // `s` was historically accepted but always rejected by MIN_TTL_MS; dropped.
42
+ const m = String(input).trim().match(/^(\d+)(m|h|d)$/);
43
+ if (!m) throw new Error(`invalid ttl: ${input} (use 10m, 1h, 1d)`);
44
+ const n = parseInt(m[1], 10);
45
+ const mult = { m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]];
46
+ const ms = n * mult;
47
+ if (ms < MIN_TTL_MS) throw new Error(`ttl too short: ${input} (min 1m)`);
48
+ if (ms > MAX_TTL_MS) throw new Error(`ttl too long: ${input} (max 7d)`);
49
+ return ms;
50
+ }
51
+
52
+ function createStore(rawDb, now = () => Date.now()) {
53
+ const issueStmt = rawDb.prepare(`
54
+ INSERT INTO pair_codes
55
+ (code, bot_name, chat_id, scope, issued_by_user_id, issued_ts, expires_ts, note)
56
+ VALUES
57
+ (@code, @bot_name, @chat_id, @scope, @issued_by_user_id, @issued_ts, @expires_ts, @note)
58
+ `);
59
+ const findCodeStmt = rawDb.prepare(`SELECT * FROM pair_codes WHERE code = ?`);
60
+ const markCodeUsedStmt = rawDb.prepare(`
61
+ UPDATE pair_codes
62
+ SET used_by_user_id = @user_id, used_ts = @ts
63
+ WHERE code = @code AND used_ts IS NULL
64
+ `);
65
+ const insertPairingStmt = rawDb.prepare(`
66
+ INSERT INTO pairings
67
+ (bot_name, user_id, chat_id, granted_ts, granted_by_user_id, note)
68
+ VALUES
69
+ (@bot_name, @user_id, @chat_id, @granted_ts, @granted_by_user_id, @note)
70
+ ON CONFLICT(bot_name, user_id, chat_id) DO UPDATE SET
71
+ revoked_ts = NULL,
72
+ granted_ts = excluded.granted_ts,
73
+ granted_by_user_id = excluded.granted_by_user_id,
74
+ note = excluded.note
75
+ `);
76
+ const livePairingAnyChatStmt = rawDb.prepare(`
77
+ SELECT 1 FROM pairings
78
+ WHERE bot_name = ? AND user_id = ? AND chat_id IS NULL
79
+ AND revoked_ts IS NULL
80
+ LIMIT 1
81
+ `);
82
+ const livePairingChatStmt = rawDb.prepare(`
83
+ SELECT 1 FROM pairings
84
+ WHERE bot_name = ? AND user_id = ?
85
+ AND (chat_id IS NULL OR chat_id = ?)
86
+ AND revoked_ts IS NULL
87
+ LIMIT 1
88
+ `);
89
+ const revokeByUserStmt = rawDb.prepare(`
90
+ UPDATE pairings SET revoked_ts = ?
91
+ WHERE bot_name = ? AND user_id = ? AND revoked_ts IS NULL
92
+ `);
93
+ const listActiveStmt = rawDb.prepare(`
94
+ SELECT id, bot_name, user_id, chat_id, granted_ts, granted_by_user_id, note
95
+ FROM pairings
96
+ WHERE bot_name = ? AND revoked_ts IS NULL
97
+ ORDER BY granted_ts DESC
98
+ `);
99
+ const recentIssuesByOperatorStmt = rawDb.prepare(`
100
+ SELECT COUNT(*) AS n FROM pair_codes
101
+ WHERE bot_name = ? AND issued_by_user_id = ? AND issued_ts > ?
102
+ `);
103
+ const recentClaimsByUserStmt = rawDb.prepare(`
104
+ SELECT COUNT(*) AS n FROM pair_codes
105
+ WHERE used_by_user_id = ? AND used_ts > ?
106
+ `);
107
+
108
+ return {
109
+ issueCode({
110
+ bot_name, chat_id = null, scope = 'user',
111
+ issued_by_user_id, ttlMs, note = null,
112
+ }) {
113
+ if (!bot_name) throw new Error('bot_name required');
114
+ if (!Number.isFinite(issued_by_user_id)) throw new Error('issued_by_user_id required');
115
+ if (!['user', 'chat'].includes(scope)) throw new Error(`bad scope: ${scope}`);
116
+
117
+ const recent = recentIssuesByOperatorStmt.get(
118
+ bot_name, issued_by_user_id, now() - 3_600_000,
119
+ ).n;
120
+ if (recent >= ISSUE_RATE_PER_OPERATOR_PER_HOUR) {
121
+ throw new Error(`rate limit: ${recent} codes issued in last hour (max ${ISSUE_RATE_PER_OPERATOR_PER_HOUR})`);
122
+ }
123
+
124
+ const issued_ts = now();
125
+ const expires_ts = issued_ts + (ttlMs || DEFAULT_TTL_MS);
126
+ // Single attempt: the Crockford alphabet × 8 chars gives ~6.5×10¹¹
127
+ // codes. Combined with 10/hr/operator rate-limit, collision is
128
+ // astronomically unlikely. A retry loop would only swallow real DB
129
+ // errors (disk full, schema mismatch) as "just collided, try again".
130
+ const code = generateCode();
131
+ issueStmt.run({
132
+ code,
133
+ bot_name,
134
+ chat_id: chat_id ? String(chat_id) : null,
135
+ scope,
136
+ issued_by_user_id,
137
+ issued_ts,
138
+ expires_ts,
139
+ note,
140
+ });
141
+ return { code, issued_ts, expires_ts, bot_name, chat_id, scope, note };
142
+ },
143
+
144
+ claimCode({ code, claimer_user_id, chat_id, bot_name }) {
145
+ const norm = normalizeCode(code);
146
+
147
+ const recent = recentClaimsByUserStmt.get(claimer_user_id, now() - 3_600_000).n;
148
+ if (recent >= CLAIM_RATE_PER_USER_PER_HOUR) {
149
+ return { ok: false, reason: 'rate-limited' };
150
+ }
151
+
152
+ const row = findCodeStmt.get(norm);
153
+ if (!row) return { ok: false, reason: 'not-found' };
154
+ if (row.used_ts) return { ok: false, reason: 'already-used' };
155
+ if (row.expires_ts < now()) return { ok: false, reason: 'expired' };
156
+ if (row.bot_name !== bot_name) return { ok: false, reason: 'wrong-bot' };
157
+ if (row.chat_id && String(row.chat_id) !== String(chat_id)) {
158
+ return { ok: false, reason: 'wrong-chat' };
159
+ }
160
+
161
+ const tx = rawDb.transaction(() => {
162
+ const upd = markCodeUsedStmt.run({ code: norm, user_id: claimer_user_id, ts: now() });
163
+ if (upd.changes === 0) throw new Error('race: code claimed by another user');
164
+ insertPairingStmt.run({
165
+ bot_name: row.bot_name,
166
+ user_id: claimer_user_id,
167
+ chat_id: row.chat_id || null,
168
+ granted_ts: now(),
169
+ granted_by_user_id: row.issued_by_user_id,
170
+ note: row.note,
171
+ });
172
+ });
173
+ try {
174
+ tx();
175
+ } catch (err) {
176
+ if (/race: code claimed/.test(err.message)) return { ok: false, reason: 'race' };
177
+ throw err;
178
+ }
179
+ return {
180
+ ok: true,
181
+ bot_name: row.bot_name,
182
+ chat_id: row.chat_id,
183
+ scope: row.scope,
184
+ note: row.note,
185
+ };
186
+ },
187
+
188
+ hasLivePairing({ bot_name, user_id, chat_id }) {
189
+ if (chat_id == null) {
190
+ return !!livePairingAnyChatStmt.get(bot_name, user_id);
191
+ }
192
+ return !!livePairingChatStmt.get(bot_name, user_id, String(chat_id));
193
+ },
194
+
195
+ revokeByUser({ bot_name, user_id }) {
196
+ return revokeByUserStmt.run(now(), bot_name, user_id).changes;
197
+ },
198
+
199
+ listActive(bot_name) {
200
+ return listActiveStmt.all(bot_name);
201
+ },
202
+ };
203
+ }
204
+
205
+ module.exports = {
206
+ createStore,
207
+ generateCode,
208
+ normalizeCode,
209
+ parseTtl,
210
+ DEFAULT_TTL_MS,
211
+ MAX_TTL_MS,
212
+ MIN_TTL_MS,
213
+ ISSUE_RATE_PER_OPERATOR_PER_HOUR,
214
+ CLAIM_RATE_PER_USER_PER_HOUR,
215
+ };
@@ -0,0 +1,287 @@
1
+ /**
2
+ * LRU-bounded warm process pool.
3
+ *
4
+ * - No idle timeout: processes die only via eviction or graceful kill.
5
+ * - Never evict an in-flight process.
6
+ * - Graceful SIGTERM, then SIGKILL after 3 s fallback.
7
+ * - If `--resume <id>` fails on spawn, clear the session_id so the next
8
+ * message spawns fresh.
9
+ *
10
+ * All I/O (spawn, db) is injected for testability.
11
+ */
12
+
13
+ const { createInterface } = require('readline');
14
+
15
+ const DEFAULT_CAP = 10;
16
+ const DEFAULT_KILL_TIMEOUT_MS = 3000;
17
+
18
+ /**
19
+ * Pull text from a stream-json `assistant` event.
20
+ * Claude Code emits one event per assistant step; each carries a
21
+ * `message.content[]` of blocks. Text blocks have `{type:'text', text:'…'}`;
22
+ * tool_use blocks we summarise inline so the user sees what Claude is doing.
23
+ */
24
+ function extractAssistantText(event) {
25
+ const blocks = event?.message?.content;
26
+ if (!Array.isArray(blocks)) return '';
27
+ const parts = [];
28
+ for (const b of blocks) {
29
+ if (!b) continue;
30
+ if (b.type === 'text' && typeof b.text === 'string') {
31
+ parts.push(b.text);
32
+ } else if (b.type === 'tool_use' && b.name) {
33
+ parts.push(`_Calling \`${b.name}\`…_`);
34
+ }
35
+ }
36
+ return parts.join('\n\n').trim();
37
+ }
38
+
39
+ class ProcessManager {
40
+ constructor({
41
+ cap = DEFAULT_CAP,
42
+ spawnFn,
43
+ db = null,
44
+ logger = console,
45
+ killTimeoutMs = DEFAULT_KILL_TIMEOUT_MS,
46
+ onInit = null, // (sessionKey, event) → void (system init)
47
+ onResult = null, // (sessionKey, event) → void (turn result)
48
+ onClose = null, // (sessionKey, code) → void
49
+ onStreamChunk = null,// (sessionKey, partialText, entry) → void (per assistant event)
50
+ } = {}) {
51
+ if (!spawnFn) throw new Error('spawnFn required');
52
+ this.cap = cap;
53
+ this.spawnFn = spawnFn;
54
+ this.db = db;
55
+ this.logger = logger;
56
+ this.killTimeoutMs = killTimeoutMs;
57
+ this.onInit = onInit;
58
+ this.onResult = onResult;
59
+ this.onClose = onClose;
60
+ this.onStreamChunk = onStreamChunk;
61
+ this.procs = new Map();
62
+ }
63
+
64
+ has(sessionKey) {
65
+ return this.procs.has(sessionKey);
66
+ }
67
+
68
+ get(sessionKey) {
69
+ return this.procs.get(sessionKey);
70
+ }
71
+
72
+ size() {
73
+ return this.procs.size;
74
+ }
75
+
76
+ keys() {
77
+ return Array.from(this.procs.keys());
78
+ }
79
+
80
+ /**
81
+ * Return existing entry or spawn a new one. Evicts LRU if at capacity.
82
+ * Throws if at capacity and all entries are in-flight.
83
+ */
84
+ async getOrSpawn(sessionKey, spawnContext) {
85
+ const existing = this.procs.get(sessionKey);
86
+ if (existing && !existing.closed) {
87
+ existing.lastUsedTs = Date.now();
88
+ return existing;
89
+ }
90
+ if (this.procs.size >= this.cap) {
91
+ const evicted = await this.evictLRU();
92
+ if (!evicted) throw new Error('LRU full: all processes in-flight');
93
+ }
94
+ return this._spawn(sessionKey, spawnContext);
95
+ }
96
+
97
+ async evictLRU() {
98
+ let victim = null;
99
+ for (const [k, v] of this.procs) {
100
+ if (v.inFlight) continue;
101
+ if (!victim || v.lastUsedTs < victim.entry.lastUsedTs) {
102
+ victim = { key: k, entry: v };
103
+ }
104
+ }
105
+ if (!victim) {
106
+ this._logEvent('lru-full', { cap: this.cap });
107
+ return false;
108
+ }
109
+ this._logEvent('evict', { session_key: victim.key, chat_id: victim.entry.chatId });
110
+ await this.kill(victim.key);
111
+ return true;
112
+ }
113
+
114
+ async kill(sessionKey) {
115
+ const entry = this.procs.get(sessionKey);
116
+ if (!entry) return;
117
+ this.procs.delete(sessionKey);
118
+ try { entry.proc.kill('SIGTERM'); } catch {}
119
+ await new Promise((resolve) => {
120
+ if (entry.closed) return resolve();
121
+ const timer = setTimeout(() => {
122
+ try { entry.proc.kill('SIGKILL'); } catch {}
123
+ resolve();
124
+ }, this.killTimeoutMs);
125
+ entry.proc.once('close', () => { clearTimeout(timer); resolve(); });
126
+ });
127
+ if (entry.pending) {
128
+ const { reject } = entry.pending;
129
+ entry.pending = null;
130
+ reject(new Error('Process killed'));
131
+ }
132
+ }
133
+
134
+ async killChat(chatId) {
135
+ const prefix = String(chatId);
136
+ const targets = [];
137
+ for (const key of this.procs.keys()) {
138
+ if (key === prefix || key.startsWith(prefix + ':')) targets.push(key);
139
+ }
140
+ for (const key of targets) await this.kill(key);
141
+ }
142
+
143
+ async shutdown() {
144
+ const keys = Array.from(this.procs.keys());
145
+ for (const key of keys) await this.kill(key);
146
+ }
147
+
148
+ _spawn(sessionKey, ctx = {}) {
149
+ const proc = this.spawnFn(sessionKey, ctx);
150
+ const rl = createInterface({ input: proc.stdout });
151
+ const entry = {
152
+ sessionKey,
153
+ proc,
154
+ rl,
155
+ pending: null,
156
+ lastUsedTs: Date.now(),
157
+ inFlight: false,
158
+ closed: false,
159
+ sessionId: ctx.existingSessionId || null,
160
+ chatId: ctx.chatId || null,
161
+ threadId: ctx.threadId || null,
162
+ label: ctx.label || sessionKey,
163
+ // Stream accumulator — cleared at each turn start (on send()).
164
+ streamText: '',
165
+ };
166
+
167
+ rl.on('line', (line) => {
168
+ let event;
169
+ try { event = JSON.parse(line); }
170
+ catch { this.logger.error(`[${entry.label}] non-JSON: ${line.slice(0, 200)}`); return; }
171
+
172
+ if (event.type === 'system' && event.subtype === 'init') {
173
+ entry.sessionId = event.session_id;
174
+ if (this.onInit) this.onInit(sessionKey, event, entry);
175
+ }
176
+ if (event.type === 'assistant' && this.onStreamChunk && entry.pending) {
177
+ const added = extractAssistantText(event);
178
+ if (added) {
179
+ entry.streamText = entry.streamText
180
+ ? `${entry.streamText}\n\n${added}`
181
+ : added;
182
+ try { this.onStreamChunk(sessionKey, entry.streamText, entry); }
183
+ catch (err) { this.logger.error(`[${entry.label}] onStreamChunk: ${err.message}`); }
184
+ }
185
+ }
186
+ if (event.type === 'result' && entry.pending) {
187
+ const { resolve } = entry.pending;
188
+ entry.pending = null;
189
+ entry.inFlight = false;
190
+ if (this.onResult) this.onResult(sessionKey, event, entry);
191
+ resolve({
192
+ text: event.result || '',
193
+ sessionId: event.session_id,
194
+ cost: event.total_cost_usd,
195
+ duration: event.duration_ms,
196
+ error: event.subtype === 'success' ? null : (event.error || event.subtype),
197
+ });
198
+ }
199
+ });
200
+
201
+ proc.on('close', (code) => {
202
+ entry.closed = true;
203
+ if (entry.pending) {
204
+ const { reject } = entry.pending;
205
+ entry.pending = null;
206
+ entry.inFlight = false;
207
+ reject(new Error(`Process exited (code ${code})`));
208
+ }
209
+ this.procs.delete(sessionKey);
210
+ if (code !== 0 && ctx.existingSessionId && this.db?.clearSessionId) {
211
+ this._logEvent('resume-fail', { session_key: sessionKey, session_id: ctx.existingSessionId, code });
212
+ try { this.db.clearSessionId(sessionKey); } catch (err) {
213
+ this.logger.error(`[${entry.label}] clearSessionId failed: ${err.message}`);
214
+ }
215
+ }
216
+ if (this.onClose) this.onClose(sessionKey, code, entry);
217
+ });
218
+
219
+ proc.on('error', (err) => {
220
+ this.logger.error(`[${entry.label}] proc error: ${err.message}`);
221
+ entry.closed = true;
222
+ if (entry.pending) {
223
+ const { reject } = entry.pending;
224
+ entry.pending = null;
225
+ entry.inFlight = false;
226
+ reject(err);
227
+ }
228
+ this.procs.delete(sessionKey);
229
+ });
230
+
231
+ this.procs.set(sessionKey, entry);
232
+ return entry;
233
+ }
234
+
235
+ send(sessionKey, prompt, { timeoutMs = 600_000 } = {}) {
236
+ return new Promise((resolve, reject) => {
237
+ const entry = this.procs.get(sessionKey);
238
+ if (!entry || entry.closed) return reject(new Error('No process for session'));
239
+ if (entry.pending) return reject(new Error('Process busy'));
240
+ // Race: proc may have emitted 'close' between getOrSpawn and send, in
241
+ // which case entry.closed is true but handlers could still be draining.
242
+ // Also guard against a destroyed/ended stdin pipe explicitly — writing
243
+ // to a closed pipe would either throw EPIPE or silently buffer.
244
+ if (!entry.proc.stdin || entry.proc.stdin.destroyed || !entry.proc.stdin.writable) {
245
+ return reject(new Error('Process stdin not writable'));
246
+ }
247
+
248
+ entry.inFlight = true;
249
+ entry.lastUsedTs = Date.now();
250
+ entry.pending = { resolve, reject };
251
+ entry.streamText = '';
252
+
253
+ const timer = setTimeout(() => {
254
+ if (entry.pending) {
255
+ entry.pending = null;
256
+ entry.inFlight = false;
257
+ reject(new Error(`Timeout after ${timeoutMs / 1000}s`));
258
+ }
259
+ }, timeoutMs);
260
+
261
+ const wrappedResolve = entry.pending.resolve;
262
+ const wrappedReject = entry.pending.reject;
263
+ entry.pending.resolve = (r) => { clearTimeout(timer); wrappedResolve(r); };
264
+ entry.pending.reject = (e) => { clearTimeout(timer); wrappedReject(e); };
265
+
266
+ try {
267
+ entry.proc.stdin.write(JSON.stringify({
268
+ type: 'user',
269
+ message: { role: 'user', content: prompt },
270
+ }) + '\n');
271
+ } catch (err) {
272
+ entry.pending = null;
273
+ entry.inFlight = false;
274
+ clearTimeout(timer);
275
+ reject(err);
276
+ }
277
+ });
278
+ }
279
+
280
+ _logEvent(kind, detail) {
281
+ if (!this.db?.logEvent) return;
282
+ try { this.db.logEvent(kind, detail); }
283
+ catch (err) { this.logger.error(`[pm] logEvent ${kind} failed: ${err.message}`); }
284
+ }
285
+ }
286
+
287
+ module.exports = { ProcessManager, DEFAULT_CAP, extractAssistantText };