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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/async-lock.js +41 -0
- package/lib/process-manager.js +190 -117
- package/lib/telegram.js +12 -1
- package/package.json +1 -1
- package/polygram.js +173 -80
|
@@ -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.
|
|
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 };
|
package/lib/process-manager.js
CHANGED
|
@@ -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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
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
|
|
57
|
-
onResult = null, // (sessionKey, event) → void
|
|
58
|
-
onClose = null, // (sessionKey, code) → void
|
|
59
|
-
onStreamChunk = null,// (sessionKey, partialText, entry) → void
|
|
60
|
-
onToolUse = null, // (sessionKey, toolName, entry) → void
|
|
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
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
197
|
-
? `${
|
|
223
|
+
head.streamText = head.streamText
|
|
224
|
+
? `${head.streamText}\n\n${added}`
|
|
198
225
|
: added;
|
|
199
|
-
try { this.onStreamChunk(sessionKey,
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
entry.
|
|
220
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
341
|
+
|
|
342
|
+
let idleTimer = null;
|
|
343
|
+
let maxTimer = null;
|
|
344
|
+
let activated = false;
|
|
283
345
|
|
|
284
346
|
const clearTimers = () => {
|
|
285
|
-
if (
|
|
286
|
-
if (
|
|
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
|
|
296
|
-
|
|
297
|
-
entry.pending
|
|
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
|
|
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
|
-
)
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
if (
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
337
|
-
entry.
|
|
410
|
+
entry.pendingQueue.push(pending);
|
|
411
|
+
entry.inFlight = true;
|
|
338
412
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
entry.
|
|
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
|
-
|
|
351
|
-
entry.
|
|
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
|
-
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
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
|
-
|
|
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
|
|
1156
|
-
//
|
|
1157
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
});
|