polygram 0.33.4 → 0.34.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.33.4",
4
+ "version": "0.34.0",
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 plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
6
6
  "keywords": [
7
7
  "telegram",
@@ -152,6 +152,10 @@ function listUnresolvedCodexAttempts(rawDb, input = {}) {
152
152
  location.telegram_message_id,
153
153
  unresolved.telegram_source_message_id
154
154
  )
155
+ WHERE COALESCE(
156
+ location.telegram_message_id,
157
+ unresolved.telegram_source_message_id
158
+ ) IS NOT NULL
155
159
  ORDER BY unresolved.ambiguous_ts,
156
160
  unresolved.attempt_id
157
161
  `).all(sessionKey, sessionKey);
@@ -59,7 +59,7 @@ function createDispatcher({
59
59
  // the historic 4096 for back-compat in synthetic test runs that pass
60
60
  // pre-formatted text.
61
61
  chunkBudget = 4096,
62
- // Delay before a silent startup auto-retry re-dispatches (TMUX_SESSION_GONE).
62
+ // Delay before a silent startup auto-retry re-dispatches a pre-delivery failure.
63
63
  // Injected so tests can drive it to 0; production uses STARTUP_RETRY_DELAY_MS.
64
64
  startupRetryDelayMs = STARTUP_RETRY_DELAY_MS,
65
65
  // AUTH_DISABLED dedupe/re-arm gate (docs/AUTH_DISABLED_HANDLING_SPEC.md).
@@ -306,6 +306,11 @@ function createDispatcher({
306
306
  chat_id: chatId, session_key: sessionKey,
307
307
  msg_id: msg?.message_id,
308
308
  error: err.message?.slice(0, 500),
309
+ code: err.code || undefined,
310
+ cause_code: err.cause?.code || undefined,
311
+ stderr_tail: err.code === 'TMUX_SPAWN_FAILED' && typeof err.stderr === 'string'
312
+ ? err.stderr.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '').slice(-500)
313
+ : undefined,
309
314
  stack: err.stack?.split('\n').slice(0, 5).join('\n'),
310
315
  aborted: wasAborted || undefined,
311
316
  replay: isReplay || undefined,
@@ -442,23 +447,17 @@ function createDispatcher({
442
447
  }
443
448
  }
444
449
  const provider = providerRecovery?.provider;
445
- // Startup auto-retry (option a, 2026-06-04). TMUX_SESSION_GONE = claude
446
- // exited INSIDE the startup gate, before the dev-channels channel went
447
- // live — so the user's message was NEVER delivered to claude. That makes
448
- // a re-send idempotent BY CONSTRUCTION (unlike a mid-turn drop, where
449
- // claude might still be slowly processing). The session_id was just
450
- // poison-cleared above, so re-dispatching the SAME message spawns a FRESH
451
- // session and delivers it. Silent: a transient startup flake (recurs
452
- // ~once/9h on the channels backend) never reaches the user — instead of
453
- // the "🔄 reset it, resend" papercut, polygram just retries. One-shot
454
- // (_startupRetried) so a host that genuinely can't start claude surfaces
455
- // the friendly reset reply (below) after EXACTLY one retry, never a loop.
456
- // Scoped to TMUX_SESSION_GONE only: CHANNELS_DIALOG_TIMEOUT is a real
457
- // blocking dialog (usage-limit / permission) a retry would just re-hit,
458
- // so it keeps its "please resend" copy.
459
- if (err.code === 'TMUX_SESSION_GONE' && !msg._startupRetried) {
450
+ // Retry once when startup fails before the bridge can deliver the user's
451
+ // message. TMUX_SESSION_GONE is poison-cleared above; TMUX_SPAWN_FAILED
452
+ // is already torn down by CliProcess's start-failure cleanup. Mid-turn
453
+ // failures and blocking dialogs are excluded because replay could
454
+ // duplicate work or immediately re-hit the same user decision.
455
+ const startupRetryable = err.code === 'TMUX_SESSION_GONE'
456
+ || err.code === 'TMUX_SPAWN_FAILED';
457
+ if (startupRetryable && !msg._startupRetried) {
460
458
  logEvent('startup-auto-retry', {
461
459
  chat_id: chatId, session_key: sessionKey, msg_id: msg?.message_id,
460
+ code: err.code,
462
461
  });
463
462
  // Re-dispatch a COPY carrying the one-shot marker — never mutate the
464
463
  // caller's msg (the boot-replay path shares/re-reads it). unref the
@@ -0,0 +1,22 @@
1
+ 'use strict';
2
+
3
+ function parseSystemdInvocationId(env = process.env) {
4
+ const value = env.INVOCATION_ID;
5
+ if (value == null || value === '') return null;
6
+ if (!/^[0-9a-f]{32}$/.test(value)) {
7
+ throw new Error('invalid systemd invocation identity');
8
+ }
9
+ return value;
10
+ }
11
+
12
+ function lifecycleDetail(detail, invocationId) {
13
+ const value = detail && typeof detail === 'object' ? detail : {};
14
+ return invocationId == null
15
+ ? value
16
+ : { ...value, invocation_id: invocationId };
17
+ }
18
+
19
+ module.exports = {
20
+ parseSystemdInvocationId,
21
+ lifecycleDetail,
22
+ };
package/ops/README.md CHANGED
@@ -109,6 +109,19 @@ canonical absolute directory. Temporary paths and unsafe aliases are rejected;
109
109
  there is no `/tmp` fallback. If the data-directory path is too long for a
110
110
  portable Unix socket, set `POLYGRAM_IPC_DIR` to a shorter owner-only path.
111
111
 
112
+ Deployment automation can query the aggregate in-flight handler count without
113
+ printing session identifiers or the socket path:
114
+
115
+ ```sh
116
+ POLYGRAM_IPC_DIR=/absolute/data-dir/.ipc polygram-ipc admin-bot busy
117
+ # {"bot":"admin-bot","in_flight":0}
118
+ ```
119
+
120
+ This is an advisory quiet-window check. The daemon's shutdown admission,
121
+ delivery, and handler-settlement barriers remain the correctness boundary.
122
+ Invoking `polygram-ipc admin-bot` without a subcommand retains the existing
123
+ path-and-ping health output.
124
+
112
125
  Allowed methods: `sendMessage`, `sendPhoto`, `sendDocument`, `sendSticker`,
113
126
  `sendChatAction`, `editMessageText`, `setMessageReaction`. Other methods
114
127
  are rejected server-side.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.33.4",
3
+ "version": "0.34.0",
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
@@ -32,6 +32,10 @@ const { buildPrompt, resolvePromptBackend } = require('./lib/prompt');
32
32
  const { countInFlight } = require('./lib/queue-utils');
33
33
  const { createIpcHandlers } = require('./lib/ipc/handlers');
34
34
  const { classifyOrphanSweep } = require('./lib/ops/tmux-preflight');
35
+ const {
36
+ parseSystemdInvocationId,
37
+ lifecycleDetail,
38
+ } = require('./lib/ops/systemd-invocation');
35
39
  const { filterAttachments, resolveFileCaps, resolveMaxFileOverride, MAX_TOTAL_BYTES } = require('./lib/attachments');
36
40
  // 0.9.0: SDK ProcessManager is the only pm. CLI pm
37
41
  // (lib/process-manager.js) deleted in commit 6.
@@ -3391,6 +3395,7 @@ let startPollWatchdog = null;
3391
3395
 
3392
3396
  async function main() {
3393
3397
  const cgroupOomObserver = createCgroupOomObserver();
3398
+ const invocationId = parseSystemdInvocationId();
3394
3399
  let codexManagerOptions = Object.freeze({
3395
3400
  codexHostIdentity: null,
3396
3401
  codexBootSessionIdentity: null,
@@ -3628,7 +3633,10 @@ async function main() {
3628
3633
  console.log(`[inbox] swept ${swept.swept} files (${(swept.bytes / 1_048_576).toFixed(1)} MiB) older than ${inboxRetentionMs / 86_400_000}d`);
3629
3634
  db.logEvent('inbox-swept', { files: swept.swept, bytes: swept.bytes, retention_days: inboxRetentionMs / 86_400_000 });
3630
3635
  }
3631
- db.logEvent('polygram-start', { migration: migration.reason, imported: migration.imported });
3636
+ db.logEvent('polygram-start', lifecycleDetail({
3637
+ migration: migration.reason,
3638
+ imported: migration.imported,
3639
+ }, invocationId));
3632
3640
  if (cgroupOomObserver.startup.status === 'unavailable') {
3633
3641
  console.warn(`[oom-observer] unavailable (${cgroupOomObserver.startup.reason}); handled shutdown signals will retain clean-restart behavior`);
3634
3642
  db.logEvent('oom-observer-unavailable', { reason: cgroupOomObserver.startup.reason });
@@ -4534,7 +4542,7 @@ async function main() {
4534
4542
  });
4535
4543
  }
4536
4544
  }
4537
- logEvent('shutdown-drain', {
4545
+ logEvent('shutdown-drain', lifecycleDetail({
4538
4546
  bot: BOT_NAME,
4539
4547
  in_flight: remaining,
4540
4548
  in_flight_at_signal: inFlightAtSignal,
@@ -4543,7 +4551,7 @@ async function main() {
4543
4551
  clean: res.clean,
4544
4552
  shutdown_reason: res.shutdownReason,
4545
4553
  ...(res.oomKillDelta != null ? { oom_kill_delta: res.oomKillDelta } : {}),
4546
- });
4554
+ }, invocationId));
4547
4555
  console.log(`[shutdown] ${res.clean ? 'clean' : 'crash-like'} shutdown recorded (${res.shutdownReason}); ${inFlightAtSignal} in-flight at signal, drained ${drainElapsed}ms, ${remaining} still in-flight, ${res.replayMarked} marked replay-pending`);
4548
4556
  } catch (err) {
4549
4557
  console.error(`[shutdown] persistence failed: ${err.message}`);
@@ -4576,7 +4584,10 @@ async function main() {
4576
4584
  if (cancelAllWaiters) cancelAllWaiters('cancelled', 'polygram shutting down');
4577
4585
  if (pm && !pmRetired) await pm.shutdown().catch(() => {});
4578
4586
  if (db) {
4579
- try { db.logEvent('polygram-stop'); db.raw.close(); } catch {}
4587
+ try {
4588
+ db.logEvent('polygram-stop', lifecycleDetail({}, invocationId));
4589
+ db.raw.close();
4590
+ } catch {}
4580
4591
  }
4581
4592
  // rc.50: release our PID file claim so the next boot doesn't try
4582
4593
  // to kill us. releasePidFile is idempotent and only deletes the
@@ -1,19 +1,44 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * Quick IPC round-trip probe.
4
- * Usage: node scripts/ipc-smoke.js <bot-name>
4
+ * Usage: node scripts/ipc-smoke.js <bot-name> [busy]
5
5
  */
6
6
 
7
- const { call, socketPathFor } = require('../lib/ipc/client');
7
+ const { call, readSecret, socketPathFor } = require('../lib/ipc/client');
8
8
 
9
9
  (async () => {
10
10
  const bot = process.argv[2] || 'shumabit';
11
+ const command = process.argv[3] || 'ping';
11
12
  const path = socketPathFor(bot);
12
13
 
14
+ if (command === 'busy') {
15
+ const result = await call({
16
+ path,
17
+ op: 'busy',
18
+ secret: readSecret(bot),
19
+ });
20
+ if (
21
+ result.ok !== true
22
+ || result.bot !== bot
23
+ || !Number.isSafeInteger(result.in_flight)
24
+ || result.in_flight < 0
25
+ ) {
26
+ throw new Error('invalid busy response');
27
+ }
28
+ console.log(JSON.stringify({
29
+ bot: result.bot,
30
+ in_flight: result.in_flight,
31
+ }));
32
+ return;
33
+ }
34
+
13
35
  console.log('path:', path);
14
36
  console.log('ping:', JSON.stringify(await call({ path, op: 'ping' })));
15
37
  console.log('DONE');
16
38
  })().catch((err) => {
17
- console.error('ERR:', err.message);
39
+ const message = process.argv[3] === 'busy'
40
+ ? 'busy request failed'
41
+ : err.message;
42
+ console.error('ERR:', message);
18
43
  process.exit(1);
19
44
  });