polygram 0.4.2 → 0.4.8

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.4.2",
4
+ "version": "0.4.8",
5
5
  "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands and a history skill.",
6
6
  "keywords": [
7
7
  "telegram",
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Per-key chain lock. Each acquire() returns a release function; the next
3
+ * acquire() awaits the previous one's release.
4
+ *
5
+ * Used by polygram to serialise stdin writes per session. Pre-work
6
+ * (attachment download, voice transcription, prompt formatting) runs
7
+ * concurrently; only the stdin write itself is serialised so Claude
8
+ * reads messages in arrival order and replies come out in the same
9
+ * order.
10
+ *
11
+ * Deliberately minimal — no timeouts, no cancellation, no fairness
12
+ * guarantees beyond FIFO. Callers are expected to ALWAYS call release,
13
+ * even on error paths, or the lock leaks (blocks all future acquires
14
+ * for that key forever).
15
+ */
16
+
17
+ function createAsyncLock() {
18
+ const chains = new Map(); // key → Promise of last release
19
+
20
+ return {
21
+ async acquire(key) {
22
+ const prev = chains.get(key) || Promise.resolve();
23
+ let release;
24
+ const next = new Promise((resolve) => { release = resolve; });
25
+ chains.set(key, prev.then(() => next));
26
+ await prev;
27
+ // Return a wrapper that also clears the chain entry when this is
28
+ // the last holder — avoids the Map growing unbounded across the
29
+ // lifetime of the process.
30
+ return () => {
31
+ if (chains.get(key) === prev.then(() => next)) {
32
+ chains.delete(key);
33
+ }
34
+ release();
35
+ };
36
+ },
37
+ get size() { return chains.size; },
38
+ };
39
+ }
40
+
41
+ module.exports = { createAsyncLock };
@@ -1,11 +1,26 @@
1
1
  /**
2
- * LRU-bounded warm process pool.
2
+ * LRU-bounded warm process pool with FIFO pending queue per process.
3
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.
4
+ * Each `entry` owns ONE claude subprocess. Messages sent via `send()` are
5
+ * appended to `entry.pendingQueue` and their prompt is written to the
6
+ * subprocess stdin. Claude processes stdin in FIFO order and emits one
7
+ * `result` event per turn. Each result resolves the oldest pending
8
+ * (queue head).
9
+ *
10
+ * Timers (idle + wall-clock) are only armed for the HEAD of the queue —
11
+ * the turn Claude is currently working on. When the head is shifted,
12
+ * the next pending becomes head and its timers arm fresh. This avoids
13
+ * the footgun of "pending #2's timer started ticking when its stdin
14
+ * was written, but Claude spent 5 minutes on pending #1 first → #2
15
+ * times out before Claude sees it".
16
+ *
17
+ * Timer fire rejects ONLY that pending (policy: don't kill the whole
18
+ * subprocess, other in-flight work is probably fine). If the subprocess
19
+ * is truly stuck, its head pending will time out repeatedly.
20
+ *
21
+ * The `onStreamChunk` and `onToolUse` callbacks pass the live `entry` so
22
+ * callers can inspect `entry.pendingQueue[0]` to route output to the
23
+ * correct turn's streamer / reactor / source message.
9
24
  *
10
25
  * All I/O (spawn, db) is injected for testability.
11
26
  */
@@ -17,21 +32,7 @@ const DEFAULT_KILL_TIMEOUT_MS = 3000;
17
32
 
18
33
  /**
19
34
  * Pull user-visible text from a stream-json `assistant` event.
20
- * Claude Code emits one event per assistant step; each carries a
21
- * `message.content[]` of blocks. Only `text` blocks are returned —
22
- * `tool_use` blocks still trigger the idle-timer reset in the caller
23
- * (they count as Claude activity) but are NOT rendered to Telegram.
24
- * Streaming every tool call to chat produces a noisy "_Calling X_"
25
- * ladder that adds no information users can act on.
26
- *
27
- * Trailing-colon normalisation: Claude writes preambles like "Checking
28
- * this:" followed by a tool_use. Because we hide tool_use in the stream,
29
- * the colon becomes an orphan pointing at invisible work. Replace a
30
- * trailing `:` with `…` — the ellipsis reads as "doing it now" and
31
- * preserves the natural flow. Only the LAST colon in the joined text is
32
- * touched; mid-sentence colons ("Here's the plan: step 1, step 2")
33
- * stay intact. Also guards against `::` sequences (code / emoticons) by
34
- * requiring the preceding char to not also be `:`.
35
+ * See header for colon-normalisation / tool_use-filter rationale.
35
36
  */
36
37
  function extractAssistantText(event) {
37
38
  const blocks = event?.message?.content;
@@ -53,11 +54,11 @@ class ProcessManager {
53
54
  db = null,
54
55
  logger = console,
55
56
  killTimeoutMs = DEFAULT_KILL_TIMEOUT_MS,
56
- onInit = null, // (sessionKey, event) → void (system init)
57
- onResult = null, // (sessionKey, event) → void (turn result)
58
- onClose = null, // (sessionKey, code) → void
59
- onStreamChunk = null,// (sessionKey, partialText, entry) → void (per assistant event)
60
- onToolUse = null, // (sessionKey, toolName, entry) → void (per tool_use block)
57
+ onInit = null, // (sessionKey, event, entry) → void
58
+ onResult = null, // (sessionKey, event, entry, pending) → void
59
+ onClose = null, // (sessionKey, code, entry) → void
60
+ onStreamChunk = null,// (sessionKey, partialText, entry) → void routes to pendingQueue[0]
61
+ onToolUse = null, // (sessionKey, toolName, entry) → void routes to pendingQueue[0]
61
62
  } = {}) {
62
63
  if (!spawnFn) throw new Error('spawnFn required');
63
64
  this.cap = cap;
@@ -89,10 +90,6 @@ class ProcessManager {
89
90
  return Array.from(this.procs.keys());
90
91
  }
91
92
 
92
- /**
93
- * Return existing entry or spawn a new one. Evicts LRU if at capacity.
94
- * Throws if at capacity and all entries are in-flight.
95
- */
96
93
  async getOrSpawn(sessionKey, spawnContext) {
97
94
  const existing = this.procs.get(sessionKey);
98
95
  if (existing && !existing.closed) {
@@ -123,6 +120,30 @@ class ProcessManager {
123
120
  return true;
124
121
  }
125
122
 
123
+ /**
124
+ * Request a graceful respawn (e.g. because /model or /effort changed).
125
+ * If the queue is empty, kill now; otherwise mark the entry so it kills
126
+ * itself when the last pending resolves. Next send() respawns fresh
127
+ * with whatever config spawnFn reads at that moment.
128
+ */
129
+ requestRespawn(sessionKey, reason = 'config-change') {
130
+ const entry = this.procs.get(sessionKey);
131
+ if (!entry || entry.closed) return { killed: false, queued: 0 };
132
+ entry.needsRespawn = reason;
133
+ this._logEvent('respawn-requested', {
134
+ session_key: sessionKey,
135
+ chat_id: entry.chatId,
136
+ reason,
137
+ queued: entry.pendingQueue.length,
138
+ });
139
+ if (entry.pendingQueue.length === 0) {
140
+ // Fire-and-forget — caller doesn't need to await the kill.
141
+ this.kill(sessionKey).catch(() => {});
142
+ return { killed: true, queued: 0 };
143
+ }
144
+ return { killed: false, queued: entry.pendingQueue.length };
145
+ }
146
+
126
147
  async kill(sessionKey) {
127
148
  const entry = this.procs.get(sessionKey);
128
149
  if (!entry) return;
@@ -136,10 +157,11 @@ class ProcessManager {
136
157
  }, this.killTimeoutMs);
137
158
  entry.proc.once('close', () => { clearTimeout(timer); resolve(); });
138
159
  });
139
- if (entry.pending) {
140
- const { reject } = entry.pending;
141
- entry.pending = null;
142
- reject(new Error('Process killed'));
160
+ // Reject all pendings in the queue (if any survived the 'close' handler).
161
+ while (entry.pendingQueue.length > 0) {
162
+ const p = entry.pendingQueue.shift();
163
+ p.clearTimers?.();
164
+ p.reject(new Error('Process killed'));
143
165
  }
144
166
  }
145
167
 
@@ -164,16 +186,15 @@ class ProcessManager {
164
186
  sessionKey,
165
187
  proc,
166
188
  rl,
167
- pending: null,
189
+ pendingQueue: [],
168
190
  lastUsedTs: Date.now(),
169
191
  inFlight: false,
170
192
  closed: false,
193
+ needsRespawn: null,
171
194
  sessionId: ctx.existingSessionId || null,
172
195
  chatId: ctx.chatId || null,
173
196
  threadId: ctx.threadId || null,
174
197
  label: ctx.label || sessionKey,
175
- // Stream accumulator — cleared at each turn start (on send()).
176
- streamText: '',
177
198
  };
178
199
 
179
200
  rl.on('line', (line) => {
@@ -181,27 +202,31 @@ class ProcessManager {
181
202
  try { event = JSON.parse(line); }
182
203
  catch { this.logger.error(`[${entry.label}] non-JSON: ${line.slice(0, 200)}`); return; }
183
204
 
205
+ // Fix A: ANY stream-json event counts as Claude activity. Reset the
206
+ // idle timer on the HEAD pending (the turn Claude is working on),
207
+ // regardless of event type. Subagent runs emit `user`-type
208
+ // tool_result events between the parent's assistant events — those
209
+ // previously did NOT reset the timer, causing false timeouts during
210
+ // long subagent work.
211
+ const head = entry.pendingQueue[0];
212
+ if (head) head.resetIdleTimer?.();
213
+
184
214
  if (event.type === 'system' && event.subtype === 'init') {
185
215
  entry.sessionId = event.session_id;
186
216
  if (this.onInit) this.onInit(sessionKey, event, entry);
187
217
  }
188
- if (event.type === 'assistant' && entry.pending) {
189
- // Any assistant step (text block, tool_use, tool_result) counts as
190
- // Claude activity — reset the idle timeout so long turns don't
191
- // wall-clock out.
192
- entry.pending.resetIdleTimer?.();
218
+
219
+ if (event.type === 'assistant' && head) {
193
220
  if (this.onStreamChunk) {
194
221
  const added = extractAssistantText(event);
195
222
  if (added) {
196
- entry.streamText = entry.streamText
197
- ? `${entry.streamText}\n\n${added}`
223
+ head.streamText = head.streamText
224
+ ? `${head.streamText}\n\n${added}`
198
225
  : added;
199
- try { this.onStreamChunk(sessionKey, entry.streamText, entry); }
226
+ try { this.onStreamChunk(sessionKey, head.streamText, entry); }
200
227
  catch (err) { this.logger.error(`[${entry.label}] onStreamChunk: ${err.message}`); }
201
228
  }
202
229
  }
203
- // Emit tool_use blocks separately so callers (e.g. status reactions)
204
- // can react to each tool name without re-parsing stream text.
205
230
  if (this.onToolUse) {
206
231
  const blocks = event.message?.content;
207
232
  if (Array.isArray(blocks)) {
@@ -214,28 +239,46 @@ class ProcessManager {
214
239
  }
215
240
  }
216
241
  }
217
- if (event.type === 'result' && entry.pending) {
218
- const { resolve } = entry.pending;
219
- entry.pending = null;
220
- entry.inFlight = false;
221
- if (this.onResult) this.onResult(sessionKey, event, entry);
222
- resolve({
242
+
243
+ if (event.type === 'result' && head) {
244
+ entry.pendingQueue.shift();
245
+ head.clearTimers();
246
+ if (this.onResult) this.onResult(sessionKey, event, entry, head);
247
+ head.resolve({
223
248
  text: event.result || '',
224
249
  sessionId: event.session_id,
225
250
  cost: event.total_cost_usd,
226
251
  duration: event.duration_ms,
227
252
  error: event.subtype === 'success' ? null : (event.error || event.subtype),
228
253
  });
254
+ // Activate next head or settle idle state.
255
+ if (entry.pendingQueue.length > 0) {
256
+ entry.pendingQueue[0].activate();
257
+ } else {
258
+ entry.inFlight = false;
259
+ // Graceful drain-and-respawn: if caller asked for a respawn
260
+ // (e.g. /model change) and we just emptied the queue, kill now.
261
+ if (entry.needsRespawn) {
262
+ const reason = entry.needsRespawn;
263
+ entry.needsRespawn = null;
264
+ this._logEvent('respawn-draining', {
265
+ session_key: sessionKey,
266
+ chat_id: entry.chatId,
267
+ reason,
268
+ });
269
+ this.kill(sessionKey).catch(() => {});
270
+ }
271
+ }
229
272
  }
230
273
  });
231
274
 
232
275
  proc.on('close', (code) => {
233
276
  entry.closed = true;
234
- if (entry.pending) {
235
- const { reject } = entry.pending;
236
- entry.pending = null;
237
- entry.inFlight = false;
238
- reject(new Error(`Process exited (code ${code})`));
277
+ entry.inFlight = false;
278
+ while (entry.pendingQueue.length > 0) {
279
+ const p = entry.pendingQueue.shift();
280
+ p.clearTimers?.();
281
+ p.reject(new Error(`Process exited (code ${code})`));
239
282
  }
240
283
  this.procs.delete(sessionKey);
241
284
  if (code !== 0 && ctx.existingSessionId && this.db?.clearSessionId) {
@@ -250,11 +293,11 @@ class ProcessManager {
250
293
  proc.on('error', (err) => {
251
294
  this.logger.error(`[${entry.label}] proc error: ${err.message}`);
252
295
  entry.closed = true;
253
- if (entry.pending) {
254
- const { reject } = entry.pending;
255
- entry.pending = null;
256
- entry.inFlight = false;
257
- reject(err);
296
+ entry.inFlight = false;
297
+ while (entry.pendingQueue.length > 0) {
298
+ const p = entry.pendingQueue.shift();
299
+ p.clearTimers?.();
300
+ p.reject(err);
258
301
  }
259
302
  this.procs.delete(sessionKey);
260
303
  });
@@ -263,83 +306,113 @@ class ProcessManager {
263
306
  return entry;
264
307
  }
265
308
 
266
- send(sessionKey, prompt, { timeoutMs = 600_000, maxTurnMs = 30 * 60_000 } = {}) {
309
+ /**
310
+ * Append a turn to the queue. The returned promise resolves when Claude
311
+ * emits a `result` event for this turn (they emerge in stdin-write
312
+ * order). The underlying stdin write happens synchronously inside this
313
+ * call — the caller should have already serialised writes across
314
+ * sessions via an external lock if order matters.
315
+ *
316
+ * Options:
317
+ * timeoutMs — idle timer between Claude events (default 10min)
318
+ * maxTurnMs — wall-clock ceiling from "activate" time (default 30min)
319
+ * context — opaque object stored on the pending (polygram puts
320
+ * streamer, reactor, sourceMsgId here for its own use)
321
+ */
322
+ send(sessionKey, prompt, {
323
+ timeoutMs = 600_000,
324
+ maxTurnMs = 30 * 60_000,
325
+ context = {},
326
+ } = {}) {
267
327
  return new Promise((resolve, reject) => {
268
328
  const entry = this.procs.get(sessionKey);
269
329
  if (!entry || entry.closed) return reject(new Error('No process for session'));
270
- if (entry.pending) return reject(new Error('Process busy'));
271
- // Race: proc may have emitted 'close' between getOrSpawn and send, in
272
- // which case entry.closed is true but handlers could still be draining.
273
- // Also guard against a destroyed/ended stdin pipe explicitly — writing
274
- // to a closed pipe would either throw EPIPE or silently buffer.
275
330
  if (!entry.proc.stdin || entry.proc.stdin.destroyed || !entry.proc.stdin.writable) {
276
331
  return reject(new Error('Process stdin not writable'));
277
332
  }
333
+ // If this entry is awaiting respawn, refuse new sends — the caller
334
+ // should wait for the respawn to complete (which happens when the
335
+ // current queue drains).
336
+ if (entry.needsRespawn) {
337
+ return reject(new Error(`Session awaiting respawn (${entry.needsRespawn})`));
338
+ }
278
339
 
279
- entry.inFlight = true;
280
340
  entry.lastUsedTs = Date.now();
281
- entry.pending = { resolve, reject };
282
- entry.streamText = '';
341
+
342
+ let idleTimer = null;
343
+ let maxTimer = null;
344
+ let activated = false;
283
345
 
284
346
  const clearTimers = () => {
285
- if (entry.pending?.idleTimer) clearTimeout(entry.pending.idleTimer);
286
- if (entry.pending?.maxTimer) clearTimeout(entry.pending.maxTimer);
347
+ if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
348
+ if (maxTimer) { clearTimeout(maxTimer); maxTimer = null; }
349
+ };
350
+
351
+ const pending = {
352
+ resolve: (r) => { clearTimers(); resolve(r); },
353
+ reject: (e) => { clearTimers(); reject(e); },
354
+ clearTimers,
355
+ startedAt: null,
356
+ streamText: '',
357
+ context,
358
+ idleTimer: null,
359
+ maxTimer: null,
360
+ activated: false,
287
361
  };
288
362
 
289
- // Timer fire path. New in 0.3.9: after rejecting, SIGTERM the
290
- // subprocess. Previously we only rejected the promise and left the
291
- // stuck claude running — the next message would write stdin to a
292
- // zombie process. Killing fires the 'close' handler which cleans
293
- // up the LRU entry, so the next send() gets a fresh spawn.
294
363
  const fireTimeout = (reason) => {
295
- if (!entry.pending) return;
296
- clearTimers();
297
- entry.pending = null;
298
- entry.inFlight = false;
299
- try { entry.proc.kill('SIGTERM'); } catch {}
364
+ // Only act if we're still the head; if we've been shifted/killed
365
+ // already, this is a stale callback.
366
+ if (entry.pendingQueue[0] !== pending) return;
300
367
  this._logEvent('turn-timeout', {
301
368
  session_key: sessionKey,
302
369
  chat_id: entry.chatId,
303
370
  reason,
304
371
  });
305
- reject(new Error(reason));
372
+ // Remove from queue, reject. Per Q1 policy: don't kill the
373
+ // subprocess — later pendings might still be fine.
374
+ entry.pendingQueue.shift();
375
+ pending.reject(new Error(reason));
376
+ // Activate next head if any, else idle.
377
+ if (entry.pendingQueue.length > 0) {
378
+ entry.pendingQueue[0].activate();
379
+ } else {
380
+ entry.inFlight = false;
381
+ }
306
382
  };
307
383
 
308
- // Idle timeout: counts N seconds of SILENCE from Claude. Reset on
309
- // every assistant event so long productive turns (multi-tool
310
- // reasoning) don't falsely trip.
311
- // .unref() so these timers don't hold the node event loop open in
312
- // tests or when the parent process wants to exit. Real-world polygram
313
- // stays alive via grammy's poll loop + stdin/stdout pipes; the timers
314
- // don't need to keep it alive on their own.
315
384
  const armIdle = () => setTimeout(
316
385
  () => fireTimeout(`Timeout: ${timeoutMs / 1000}s idle with no Claude activity`),
317
386
  timeoutMs,
318
- ).unref();
319
- entry.pending.idleTimer = armIdle();
320
- entry.pending.resetIdleTimer = () => {
321
- if (!entry.pending) return;
322
- clearTimeout(entry.pending.idleTimer);
323
- entry.pending.idleTimer = armIdle();
387
+ );
388
+
389
+ pending.activate = () => {
390
+ if (activated) return;
391
+ activated = true;
392
+ pending.activated = true;
393
+ pending.startedAt = Date.now();
394
+ idleTimer = armIdle();
395
+ pending.idleTimer = idleTimer;
396
+ maxTimer = setTimeout(
397
+ () => fireTimeout(`Turn exceeded ${maxTurnMs / 1000}s wall-clock ceiling`),
398
+ maxTurnMs,
399
+ );
400
+ pending.maxTimer = maxTimer;
324
401
  };
325
402
 
326
- // Wall-clock ceiling: fires at maxTurnMs regardless of activity.
327
- // Catches stuck API calls that emit occasional events (keeping the
328
- // idle timer alive) but never produce a result. OpenClaw's only
329
- // timer was wall-clock; polygram's 0.3.5 change replaced it with
330
- // idle-reset, creating a gap this restores as a last-resort.
331
- entry.pending.maxTimer = setTimeout(
332
- () => fireTimeout(`Turn exceeded ${maxTurnMs / 1000}s wall-clock ceiling`),
333
- maxTurnMs,
334
- ).unref();
403
+ pending.resetIdleTimer = () => {
404
+ if (!activated) return;
405
+ if (idleTimer) clearTimeout(idleTimer);
406
+ idleTimer = armIdle();
407
+ pending.idleTimer = idleTimer;
408
+ };
335
409
 
336
- // Legacy alias: some callers / tests refer to entry.pending.timer.
337
- entry.pending.timer = entry.pending.idleTimer;
410
+ entry.pendingQueue.push(pending);
411
+ entry.inFlight = true;
338
412
 
339
- const wrappedResolve = entry.pending.resolve;
340
- const wrappedReject = entry.pending.reject;
341
- entry.pending.resolve = (r) => { clearTimers(); wrappedResolve(r); };
342
- entry.pending.reject = (e) => { clearTimers(); wrappedReject(e); };
413
+ // If we're the only pending, activate immediately. Otherwise wait
414
+ // until the preceding pending is shifted out.
415
+ if (entry.pendingQueue.length === 1) pending.activate();
343
416
 
344
417
  try {
345
418
  entry.proc.stdin.write(JSON.stringify({
@@ -347,10 +420,10 @@ class ProcessManager {
347
420
  message: { role: 'user', content: prompt },
348
421
  }) + '\n');
349
422
  } catch (err) {
350
- clearTimers();
351
- entry.pending = null;
352
- entry.inFlight = false;
353
- reject(err);
423
+ const idx = entry.pendingQueue.indexOf(pending);
424
+ if (idx !== -1) entry.pendingQueue.splice(idx, 1);
425
+ if (entry.pendingQueue.length === 0) entry.inFlight = false;
426
+ pending.reject(err);
354
427
  }
355
428
  });
356
429
  }
package/lib/telegram.js CHANGED
@@ -76,7 +76,18 @@ function nextPendingId() {
76
76
  return -(v + 1);
77
77
  }
78
78
 
79
- const METHODS_WITHOUT_MSG = new Set(['setMessageReaction', 'deleteMessage', 'editMessageReplyMarkup']);
79
+ // Methods we don't insert a `messages` row for. Reactions/deletes/markup
80
+ // edits never produced a chat message in the first place. editMessageText
81
+ // DOES modify a message, but creating a new DB row per edit collides with
82
+ // the UNIQUE(chat_id, msg_id) constraint on the 2nd edit — the stream
83
+ // edits one bubble N times in a single turn. The initial sendMessage
84
+ // already persisted the row; edits just update the live bubble.
85
+ const METHODS_WITHOUT_MSG = new Set([
86
+ 'setMessageReaction',
87
+ 'deleteMessage',
88
+ 'editMessageReplyMarkup',
89
+ 'editMessageText',
90
+ ]);
80
91
 
81
92
  // Derive the row's `text` column. sendSticker has no text/caption, so we
82
93
  // synthesize `[sticker:<name>]` (or file_id as fallback) — without this the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.4.2",
3
+ "version": "0.4.8",
4
4
  "description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
5
5
  "main": "lib/ipc-client.js",
6
6
  "bin": {
package/polygram.js CHANGED
@@ -26,7 +26,7 @@ const { buildPrompt } = require('./lib/prompt');
26
26
  const { filterAttachments, MAX_FILE_BYTES } = require('./lib/attachments');
27
27
  const { ProcessManager } = require('./lib/process-manager');
28
28
  const { createSender } = require('./lib/telegram');
29
- const { drainQueuesForChat: drainQueuesForChatImpl } = require('./lib/queue-utils');
29
+ const { createAsyncLock } = require('./lib/async-lock');
30
30
  const { sweepInbox } = require('./lib/inbox');
31
31
  const { parseBotArg, parseDbArg, filterConfigToBot } = require('./lib/config-scope');
32
32
  const { createStore: createPairingsStore, parseTtl: parsePairingTtl } = require('./lib/pairings');
@@ -82,8 +82,11 @@ let ipcCloser = null;
82
82
  // single-valued), we keep them as plain module-level variables — not a map.
83
83
  let BOT_NAME = null; // string, frozen after boot
84
84
  let bot = null; // grammy Bot for BOT_NAME
85
- let streamers = new Map(); // sessionKey -> active Streamer (while turn is in flight)
86
- let reactors = new Map(); // sessionKey -> active ReactionManager (while turn is in flight)
85
+ // 0.4.8 note: streamer + reactor are per-turn, not per-session. They live
86
+ // on the pending's `context` object in the pm pendingQueue, keyed to the
87
+ // specific turn (not the session). The old per-session Maps were a bug
88
+ // for concurrent pendings — the second send() would overwrite the first's
89
+ // streamer reference before the first turn finished.
87
90
 
88
91
  // Allowlist of env var names passed through to spawned Claude processes.
89
92
  // Anything not listed here is dropped to prevent leaked secrets/ssh agents
@@ -520,73 +523,103 @@ async function getOrSpawnForChat(sessionKey) {
520
523
  return pm.getOrSpawn(sessionKey, ctx);
521
524
  }
522
525
 
523
- async function sendToProcess(sessionKey, prompt) {
526
+ async function sendToProcess(sessionKey, prompt, context = {}) {
524
527
  const entry = await getOrSpawnForChat(sessionKey);
525
528
  if (!entry) throw new Error('No process for chat');
526
529
  const chatId = getChatIdFromKey(sessionKey);
527
530
  const chatConfig = config.chats[chatId];
528
531
  const timeoutMs = (chatConfig.timeout || config.defaults.timeout) * 1000;
529
- // Wall-clock ceiling (seconds). Overridable per-chat via chatConfig.maxTurn
530
- // or globally via config.defaults.maxTurn. 30 min default is generous for
531
- // long audits; stuck API calls rarely run that long without firing the
532
- // idle timer first. Unit: seconds → milliseconds.
533
532
  const maxTurnMs = (chatConfig.maxTurn || config.defaults?.maxTurn || 1800) * 1000;
534
- return pm.send(sessionKey, prompt, { timeoutMs, maxTurnMs });
533
+ // Per-session stdin lock orders the write step, not the result-wait.
534
+ // pm.send's Promise executor writes stdin synchronously, so as soon as
535
+ // pm.send returns (not resolves — returns), the stdin write has
536
+ // happened. We release the lock right after that and await the result
537
+ // OUTSIDE the lock — otherwise one long turn would serialise the whole
538
+ // session, which is what we're trying to escape.
539
+ const release = await stdinLock.acquire(sessionKey);
540
+ let resultPromise;
541
+ try {
542
+ resultPromise = pm.send(sessionKey, prompt, { timeoutMs, maxTurnMs, context });
543
+ } finally {
544
+ release();
545
+ }
546
+ return resultPromise;
535
547
  }
536
548
 
537
- // ─── Message queue (per-chat) ───────────────────────────────────────
538
-
539
- const queues = {};
540
- const processing = {};
541
- const MAX_QUEUE_DEPTH = 50; // per chat cron storm or spammer insurance
542
-
543
- async function enqueue(sessionKey, chatId, msg, bot) {
544
- if (!queues[sessionKey]) queues[sessionKey] = [];
545
- if (queues[sessionKey].length >= MAX_QUEUE_DEPTH) {
546
- // Drop oldest rather than rejecting newest the user's freshest
547
- // intent is more valuable than backlog. Emit an event so operators
548
- // see this rather than a queue silently degrading.
549
- queues[sessionKey].shift();
550
- dbWrite(() => db.logEvent('queue-overflow', {
551
- chat_id: chatId, session_key: sessionKey, cap: MAX_QUEUE_DEPTH,
552
- }), 'log queue-overflow');
553
- }
554
- queues[sessionKey].push({ msg, bot, chatId });
555
- if (!processing[sessionKey]) processQueue(sessionKey);
549
+ // ─── Message dispatch ───────────────────────────────────────────────
550
+
551
+ // 0.4.8: per-session concurrent dispatch. No FIFO polygram-level queue any
552
+ // more inbound messages immediately kick off handleMessage. Pre-work
553
+ // (attachment download, voice transcription) runs in parallel across
554
+ // messages; a per-session stdin lock (in handleMessage) orders the
555
+ // eventual pm.send writes so Claude reads user messages in arrival order
556
+ // and replies come out in the same order.
557
+ //
558
+ // We still track in-flight handleMessage calls per session so we can:
559
+ // - emit a `queue-depth-warning` event if the count ever exceeds a
560
+ // threshold (abnormal inbound rate, slow pre-work, stuck bot)
561
+ // - (future) drain on shutdown if we want clean exit
562
+ const CONCURRENT_WARN_THRESHOLD = 20;
563
+ const inFlightHandlers = new Map(); // sessionKey count
564
+
565
+ // Sessions the operator just /stop'd (or natural-language "стоп"). Entries
566
+ // suppress the generic "Sorry, I couldn't process" reply — the abort
567
+ // handler already sent its own "Остановлено." ack, and handleMessage
568
+ // rejections from the killed subprocess would otherwise spam a second
569
+ // contradictory message.
570
+ const abortedSessions = new Set();
571
+
572
+ function markSessionAborted(sessionKey) {
573
+ abortedSessions.add(sessionKey);
556
574
  }
557
575
 
558
- async function processQueue(sessionKey) {
559
- processing[sessionKey] = true;
560
- while (queues[sessionKey]?.length > 0) {
561
- const { msg, bot, chatId } = queues[sessionKey].shift();
562
- try {
563
- await handleMessage(sessionKey, chatId, msg, bot);
564
- } catch (err) {
565
- // Raw err.message can carry host paths, DB columns, internal state.
566
- // Surface a generic message to the user; log the detail to events
567
- // so operators can still debug.
568
- console.error(`[${sessionKey}] Error:`, err.message);
569
- dbWrite(() => db.logEvent('handler-error', {
570
- chat_id: chatId, session_key: sessionKey,
571
- msg_id: msg?.message_id,
572
- error: err.message?.slice(0, 500),
573
- stack: err.stack?.split('\n').slice(0, 5).join('\n'),
574
- }), 'log handler-error');
575
- try {
576
- await tg(bot, 'sendMessage', {
577
- chat_id: chatId,
578
- text: `Sorry, I couldn't process that message. The operator has been notified.`,
579
- reply_parameters: { message_id: msg.message_id },
580
- }, { source: 'error-reply', botName: BOT_NAME });
581
- } catch (replyErr) {
576
+ // Called by bot.on('message') for every regular (non-admin, non-pair)
577
+ // message. Runs handleMessage in a fire-and-forget manner with centralised
578
+ // error handling. Replaces the old processQueue loop.
579
+ function dispatchHandleMessage(sessionKey, chatId, msg, bot) {
580
+ const count = (inFlightHandlers.get(sessionKey) || 0) + 1;
581
+ inFlightHandlers.set(sessionKey, count);
582
+ if (count === CONCURRENT_WARN_THRESHOLD) {
583
+ dbWrite(() => db.logEvent('queue-depth-warning', {
584
+ chat_id: chatId, session_key: sessionKey,
585
+ in_flight: count, threshold: CONCURRENT_WARN_THRESHOLD,
586
+ }), 'log queue-depth-warning');
587
+ }
588
+ handleMessage(sessionKey, chatId, msg, bot).catch((err) => {
589
+ const wasAborted = abortedSessions.has(sessionKey);
590
+ if (wasAborted) abortedSessions.delete(sessionKey);
591
+ console.error(`[${sessionKey}] Error:`, err.message);
592
+ dbWrite(() => db.logEvent('handler-error', {
593
+ chat_id: chatId, session_key: sessionKey,
594
+ msg_id: msg?.message_id,
595
+ error: err.message?.slice(0, 500),
596
+ stack: err.stack?.split('\n').slice(0, 5).join('\n'),
597
+ aborted: wasAborted || undefined,
598
+ }), 'log handler-error');
599
+ if (!wasAborted) {
600
+ tg(bot, 'sendMessage', {
601
+ chat_id: chatId,
602
+ text: `Sorry, I couldn't process that message. The operator has been notified.`,
603
+ reply_parameters: { message_id: msg.message_id },
604
+ }, { source: 'error-reply', botName: BOT_NAME }).catch((replyErr) => {
582
605
  console.error(`[${sessionKey}] failed to send error reply: ${replyErr.message}`);
583
- }
606
+ });
584
607
  }
585
- }
586
- processing[sessionKey] = false;
608
+ }).finally(() => {
609
+ const n = (inFlightHandlers.get(sessionKey) || 1) - 1;
610
+ if (n <= 0) inFlightHandlers.delete(sessionKey);
611
+ else inFlightHandlers.set(sessionKey, n);
612
+ });
587
613
  }
588
614
 
589
- const drainQueuesForChat = (chatId) => drainQueuesForChatImpl(queues, chatId);
615
+ // drainQueuesForChat is retained as a no-op for backwards compat with
616
+ // call sites in /model, /effort, chat-migration, and abort handlers.
617
+ // Returns 0 always; a drain isn't meaningful in the concurrent model —
618
+ // callers that want to abort should rely on pm.killChat.
619
+ const drainQueuesForChat = (_chatId) => 0;
620
+
621
+ // Per-session lock ordering stdin writes. Module is I/O-pure.
622
+ const stdinLock = createAsyncLock();
590
623
 
591
624
  // Typing indicator is imported from lib/typing-indicator — it adds a
592
625
  // per-chat circuit breaker with exponential backoff so a chat that
@@ -958,6 +991,25 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
958
991
  await sendReply(info);
959
992
  return;
960
993
  }
994
+ // Helper: request respawn across ALL sessionKeys owned by this chat (one
995
+ // per topic if isolateTopics=true, otherwise just the single chat-level
996
+ // key). Graceful: in-flight turns drain on old settings, new turns use
997
+ // the new settings. Returns total pending turns across all keys so the
998
+ // reply can tell the user.
999
+ const requestRespawnForChat = (reason) => {
1000
+ const prefix = String(chatId);
1001
+ let totalQueued = 0;
1002
+ let anyActive = false;
1003
+ for (const key of pm.keys()) {
1004
+ if (key === prefix || key.startsWith(prefix + ':')) {
1005
+ const res = pm.requestRespawn(key, reason);
1006
+ totalQueued += res.queued;
1007
+ if (!res.killed) anyActive = true;
1008
+ }
1009
+ }
1010
+ return { queued: totalQueued, anyActive };
1011
+ };
1012
+
961
1013
  if (botAllowsCommands && text.startsWith('/model ')) {
962
1014
  const newModel = text.slice(7).trim();
963
1015
  if (['opus', 'sonnet', 'haiku'].includes(newModel)) {
@@ -969,11 +1021,12 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
969
1021
  old_value: oldModel, new_value: newModel,
970
1022
  user: cmdUser, user_id: cmdUserId, source: 'command',
971
1023
  }), 'log model change');
972
- const droppedModel = drainQueuesForChat(chatId);
973
- if (droppedModel) dbWrite(() => db.logEvent('queue-drained', { chat_id: chatId, reason: 'model-change', dropped: droppedModel }), 'log queue-drained');
974
- await pm.killChat(chatId);
1024
+ const { queued, anyActive } = requestRespawnForChat('model-change');
975
1025
  const ver = MODEL_VERSIONS[newModel] || newModel;
976
- await sendReply(`Model ${newModel} (${ver})`);
1026
+ const suffix = anyActive
1027
+ ? ` (applies after ${queued} in-flight turn${queued === 1 ? '' : 's'} complete${queued === 1 ? 's' : ''})`
1028
+ : '';
1029
+ await sendReply(`Model → ${newModel} (${ver})${suffix}`);
977
1030
  } else {
978
1031
  await sendReply(`Unknown model. Use: opus, sonnet, haiku`);
979
1032
  }
@@ -990,10 +1043,11 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
990
1043
  old_value: oldEffort, new_value: newEffort,
991
1044
  user: cmdUser, user_id: cmdUserId, source: 'command',
992
1045
  }), 'log effort change');
993
- const droppedEffort = drainQueuesForChat(chatId);
994
- if (droppedEffort) dbWrite(() => db.logEvent('queue-drained', { chat_id: chatId, reason: 'effort-change', dropped: droppedEffort }), 'log queue-drained');
995
- await pm.killChat(chatId);
996
- await sendReply(`Effort → ${newEffort}`);
1046
+ const { queued, anyActive } = requestRespawnForChat('effort-change');
1047
+ const suffix = anyActive
1048
+ ? ` (applies after ${queued} in-flight turn${queued === 1 ? '' : 's'} complete${queued === 1 ? 's' : ''})`
1049
+ : '';
1050
+ await sendReply(`Effort → ${newEffort}${suffix}`);
997
1051
  } else {
998
1052
  await sendReply(`Unknown effort. Use: low, medium, high, xhigh, max`);
999
1053
  }
@@ -1150,11 +1204,21 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1150
1204
  }, outMetaBase),
1151
1205
  edit: async (messageId, text) => {
1152
1206
  try {
1153
- return await bot.api.editMessageText(chatId, messageId, text);
1207
+ // Route edits through tg() so applyFormatting runs (MarkdownV2
1208
+ // + escape). Going direct to bot.api.editMessageText would
1209
+ // skip formatting and leave every edit rendering literal
1210
+ // **bold** / `code` in the bubble — which was the visible bug
1211
+ // in 0.4.2 where the initial send was formatted and every
1212
+ // subsequent edit overwrote it with plain text.
1213
+ return await tg(bot, 'editMessageText', {
1214
+ chat_id: chatId,
1215
+ message_id: messageId,
1216
+ text,
1217
+ }, { source: 'bot-reply-stream-edit', botName: BOT_NAME });
1154
1218
  } catch (err) {
1155
- // Stream-edit failures would otherwise be invisible — edits bypass
1156
- // tg() so there's no messages row reflecting the attempt. Log to
1157
- // events so stuck streams leave a forensic trail.
1219
+ // Stream-edit failures would otherwise be invisible — edits
1220
+ // don't insert a messages row by default (tg() does, but we
1221
+ // want the failure path specifically surfaced). Log to events.
1158
1222
  dbWrite(() => db.logEvent('telegram-edit-failed', {
1159
1223
  chat_id: chatId, msg_id: messageId,
1160
1224
  api_error: err.message?.slice(0, 200),
@@ -1167,7 +1231,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1167
1231
  throttleMs: botCfg.streamThrottleMs,
1168
1232
  logger: { error: (m) => console.error(`[${label}] ${m}`) },
1169
1233
  });
1170
- streamers.set(sessionKey, streamer);
1234
+ // streamer is registered with this turn via pm.send's context (below)
1171
1235
 
1172
1236
  // Status reactions on the user's message: 👀 queued → 🤔 thinking →
1173
1237
  // 👨‍💻 coding / ⚡ web / 🔥 tool → 👍 done / 🤯 error. Silent (no
@@ -1186,11 +1250,15 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1186
1250
  },
1187
1251
  logError: (m) => console.error(`[${label}] ${m}`),
1188
1252
  });
1189
- reactors.set(sessionKey, reactor);
1190
1253
  reactor.setState('THINKING');
1191
1254
 
1192
1255
  try {
1193
- const result = await sendToProcess(sessionKey, prompt);
1256
+ // Pass streamer + reactor as per-turn context. pm's callbacks pick
1257
+ // them off entry.pendingQueue[0].context so concurrent pendings each
1258
+ // get routed to their own streamer/reactor.
1259
+ const result = await sendToProcess(sessionKey, prompt, {
1260
+ streamer, reactor, sourceMsgId: msg.message_id,
1261
+ });
1194
1262
  const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
1195
1263
 
1196
1264
  stopTyping();
@@ -1278,12 +1346,12 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1278
1346
  throw err;
1279
1347
  } finally {
1280
1348
  stopTyping();
1281
- streamers.delete(sessionKey);
1349
+ // streamer is per-turn and not stored in any session Map in 0.4.8
1282
1350
  // Give the reactor a beat to flush the terminal state (DONE/ERROR/TIMEOUT
1283
1351
  // bypass throttle so this is instant in practice; the stop() below
1284
1352
  // guards against any late transition leaking after the turn ends).
1285
1353
  reactor.stop();
1286
- reactors.delete(sessionKey);
1354
+ // reactor is per-turn and not stored in any session Map in 0.4.8
1287
1355
  }
1288
1356
  }
1289
1357
 
@@ -1428,15 +1496,35 @@ function createBot(token) {
1428
1496
  const sessionKey = getSessionKey(chatId, threadId, chatConfig);
1429
1497
  const hadActive = pm.has(sessionKey) && !!pm.get(sessionKey)?.inFlight;
1430
1498
  const dropped = drainQueuesForChat(chatId);
1499
+ // Mark BEFORE killing: the 'close' event fires almost immediately
1500
+ // after SIGTERM, and processQueue's catch needs to see the flag to
1501
+ // skip the generic error-reply. If we marked after, there'd be a
1502
+ // race where the error-reply slips through.
1503
+ if (hadActive) markSessionAborted(sessionKey);
1431
1504
  await pm.killChat(chatId).catch(() => {});
1432
1505
  dbWrite(() => db.logEvent('abort-requested', {
1433
1506
  chat_id: chatId, user_id: msg.from?.id || null,
1434
1507
  had_active: hadActive, queued_dropped: dropped,
1435
1508
  trigger: cleanText.slice(0, 40),
1436
1509
  }), 'log abort-requested');
1510
+ // Reply in the same language the user aborted in. Cyrillic-detection
1511
+ // is crude but reliable for ru/en (the only two cue sets we ship).
1512
+ const lang = /[а-яё]/i.test(cleanText) ? 'ru' : 'en';
1513
+ const strs = {
1514
+ en: {
1515
+ stopped: 'Stopped.',
1516
+ withDropped: (n) => `Stopped. Cleared ${n} queued message${n === 1 ? '' : 's'}.`,
1517
+ nothing: 'Nothing to stop.',
1518
+ },
1519
+ ru: {
1520
+ stopped: 'Остановлено.',
1521
+ withDropped: (n) => `Остановлено. Очередь очищена (${n}).`,
1522
+ nothing: 'Нечего останавливать.',
1523
+ },
1524
+ }[lang];
1437
1525
  const reply = hadActive || dropped
1438
- ? (dropped ? `Остановлено. Очередь очищена (${dropped}).` : 'Остановлено.')
1439
- : 'Нечего останавливать.';
1526
+ ? (dropped ? strs.withDropped(dropped) : strs.stopped)
1527
+ : strs.nothing;
1440
1528
  try {
1441
1529
  await tg(bot, 'sendMessage', {
1442
1530
  chat_id: chatId, text: reply,
@@ -1466,7 +1554,7 @@ function createBot(token) {
1466
1554
 
1467
1555
  const threadId = msg.message_thread_id?.toString();
1468
1556
  const sessionKey = getSessionKey(chatId, threadId, chatConfig);
1469
- await enqueue(sessionKey, chatId, msg, bot);
1557
+ dispatchHandleMessage(sessionKey, chatId, msg, bot);
1470
1558
  };
1471
1559
 
1472
1560
  // Media-group buffer: coalesce multi-photo uploads (Telegram delivers
@@ -1807,12 +1895,17 @@ async function main() {
1807
1895
  console.log(`[${entry.label}] Process exited (code ${code})`);
1808
1896
  dbWrite(() => db.logEvent('process-close', { chat_id: entry.chatId, session_key: sessionKey, code }), 'log process-close');
1809
1897
  },
1810
- onStreamChunk: (sessionKey, partial) => {
1811
- const s = streamers.get(sessionKey);
1898
+ onStreamChunk: (sessionKey, partial, entry) => {
1899
+ // Route to the head pending's per-turn streamer. In the 0.4.8
1900
+ // concurrent-pending model, there can be N pendings queued — only
1901
+ // the HEAD is the turn Claude is actively emitting events for.
1902
+ const head = entry.pendingQueue?.[0];
1903
+ const s = head?.context?.streamer;
1812
1904
  if (s) s.onChunk(partial).catch(() => {});
1813
1905
  },
1814
- onToolUse: (sessionKey, toolName) => {
1815
- const r = reactors.get(sessionKey);
1906
+ onToolUse: (sessionKey, toolName, entry) => {
1907
+ const head = entry.pendingQueue?.[0];
1908
+ const r = head?.context?.reactor;
1816
1909
  if (r) r.setState(classifyToolName(toolName));
1817
1910
  },
1818
1911
  });