polygram 0.10.0-rc.2 → 0.10.0-rc.21

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/polygram.js CHANGED
@@ -24,7 +24,9 @@ const fs = require('fs');
24
24
  const path = require('path');
25
25
  const processGuard = require('./lib/process-guard');
26
26
  const dbClient = require('./lib/db');
27
- const { migrateJsonToDb, getClaudeSessionId } = require('./lib/db/sessions');
27
+ const {
28
+ migrateJsonToDb, getClaudeSessionId, resolveSessionForSpawn,
29
+ } = require('./lib/db/sessions');
28
30
  const { buildPrompt } = require('./lib/prompt');
29
31
  const { filterAttachments } = require('./lib/attachments');
30
32
  // 0.9.0: SDK ProcessManager is the only pm. CLI pm
@@ -38,7 +40,7 @@ const { filterAttachments } = require('./lib/attachments');
38
40
  // per-session mechanics. The pre-0.10.0 monolithic ProcessManagerSdk
39
41
  // is deleted; SdkProcess inherits its per-entry guts.
40
42
  const { ProcessManager } = require('./lib/process-manager');
41
- const { createProcessFactory } = require('./lib/process/factory');
43
+ const { createProcessFactory, pickBackend } = require('./lib/process/factory');
42
44
  const { extractAssistantText } = require('./lib/process/sdk-process');
43
45
  const { createTmuxRunner } = require('./lib/tmux/tmux-runner');
44
46
  const { sweepTmuxOrphans } = require('./lib/tmux/orphan-sweep');
@@ -396,12 +398,56 @@ function buildSpawnContext(sessionKey) {
396
398
  const chatConfig = config.chats[chatId];
397
399
  if (!chatConfig) return null;
398
400
  const threadId = sessionKey.includes(':') ? sessionKey.split(':')[1] : null;
401
+
402
+ // S2: a stored session is valid ONLY for the config it was spawned
403
+ // under. agent / cwd / pm_backend are spawn-identity — baked into
404
+ // the process at spawn time, never mutable on a live session.
405
+ // Resolve them the same way the backends do (topic override merged
406
+ // over chat-level) and compare to the stored `sessions` row. On
407
+ // drift, resolveSessionForSpawn drops the stale row and returns
408
+ // existingSessionId:null → the spawn starts fresh under the correct
409
+ // config instead of `--resume`-ing a stale one. This self-heals the
410
+ // pre-per-topic-config rows (e.g. shumorobot's Music topic :3,
411
+ // stored agent=shumabit / cwd=$HOME / sdk vs the current
412
+ // music-curation:music-curator / .../Music/rekordbox / tmux).
413
+ // model/effort are NOT compared — they apply live via setModel /
414
+ // applyFlagSettings with no respawn.
415
+ //
416
+ // The drift check runs only at COLD spawn (no warm process). A warm
417
+ // process already runs under its spawn-time config; getOrSpawn
418
+ // returns it without using this context, so dropping its row here
419
+ // would be premature — defer to the next cold spawn.
420
+ const isColdSpawn = !pm || !pm.has(sessionKey) || pm.get(sessionKey)?.closed;
421
+ let existingSessionId;
422
+ if (isColdSpawn) {
423
+ const topicConfig = getTopicConfig(chatConfig, threadId || null);
424
+ const resolved = {
425
+ agent: topicConfig.agent || chatConfig.agent || null,
426
+ cwd: topicConfig.cwd || chatConfig.cwd || null,
427
+ backend: pickBackend({ config, chatId, threadId: threadId || null }),
428
+ };
429
+ const r = resolveSessionForSpawn(db, sessionKey, resolved);
430
+ existingSessionId = r.existingSessionId;
431
+ if (r.drift) {
432
+ logEvent('session-config-drift', {
433
+ chat_id: chatId,
434
+ thread_id: threadId || null,
435
+ session_key: sessionKey,
436
+ fields: r.drift.fields,
437
+ before: r.drift.before,
438
+ after: r.drift.after,
439
+ });
440
+ }
441
+ } else {
442
+ existingSessionId = getClaudeSessionId(db, sessionKey);
443
+ }
444
+
399
445
  return {
400
446
  chatConfig,
401
447
  chatId,
402
448
  threadId: threadId || null,
403
449
  label: getSessionLabel(chatConfig, threadId),
404
- existingSessionId: getClaudeSessionId(db, sessionKey),
450
+ existingSessionId,
405
451
  };
406
452
  }
407
453
 
@@ -411,7 +457,7 @@ async function getOrSpawnForChat(sessionKey) {
411
457
  return pm.getOrSpawn(sessionKey, ctx);
412
458
  }
413
459
 
414
- async function sendToProcess(sessionKey, prompt, context = {}) {
460
+ async function sendToProcess(sessionKey, prompt, context = {}, { onDispatched } = {}) {
415
461
  const entry = await getOrSpawnForChat(sessionKey);
416
462
  if (!entry) throw new Error('No process for chat');
417
463
  const chatId = getChatIdFromKey(sessionKey);
@@ -437,7 +483,13 @@ async function sendToProcess(sessionKey, prompt, context = {}) {
437
483
  // starts, which is the correct UX (and what the user already expects).
438
484
  const release = await stdinLock.acquire(sessionKey);
439
485
  try {
440
- return await pm.send(sessionKey, prompt, { timeoutMs, maxTurnMs, context });
486
+ const turnP = pm.send(sessionKey, prompt, { timeoutMs, maxTurnMs, context });
487
+ // Phase 3 §4: pm.send synchronously kicks off the turn — the
488
+ // process is now inFlight. Signal the committed-intent latch so
489
+ // it can release; a concurrent handler will then correctly see
490
+ // the live turn and autosteer instead of racing into a 2nd send.
491
+ if (typeof onDispatched === 'function') onDispatched();
492
+ return await turnP;
441
493
  } finally {
442
494
  release();
443
495
  }
@@ -481,6 +533,15 @@ let inFlightHandlers = null;
481
533
  // Per-session lock ordering stdin writes. Module is I/O-pure.
482
534
  const stdinLock = createAsyncLock();
483
535
 
536
+ // 0.10.0 Phase 3 §4: committed-intent latch. Serialises the
537
+ // autosteer-vs-primary decision per session so a burst of concurrent
538
+ // handleMessage calls cannot each independently mis-read `inFlight`
539
+ // and all classify themselves as primary. The first to acquire it
540
+ // for an idle session commits the primary turn and holds the latch
541
+ // until the process is inFlight; later acquirers see the live turn
542
+ // and autosteer.
543
+ const intentLock = createAsyncLock();
544
+
484
545
  // Typing indicator is imported from lib/typing-indicator — it adds a
485
546
  // per-chat circuit breaker with exponential backoff so a chat that
486
547
  // permanently 401s (bot blocked, chat deleted) doesn't have us
@@ -971,9 +1032,39 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
971
1032
  // standard emoji per core.telegram.org/bots/api#availablereactions).
972
1033
  // 🛞 is NOT on it (400: REACTION_INVALID). ✍ ("writing/noting")
973
1034
  // is on the list and conveys "incorporating this".
974
- const steered = autosteer.tryAutosteer({
975
- sessionKey, chatConfig, chatId, msg, prompt,
976
- });
1035
+ // 0.10.0 Phase 3 §4: committed-intent latch. The autosteer-vs-
1036
+ // primary decision AND the turn dispatch happen inside one
1037
+ // per-session critical section. tryAutosteer's `inFlight` read is
1038
+ // now reliable: the previous primary held this latch until its
1039
+ // pm.send made the process inFlight, so a concurrent burst can no
1040
+ // longer mis-classify followups as primary turns.
1041
+ const releaseIntent = await intentLock.acquire(sessionKey);
1042
+ let steered = { autosteered: false };
1043
+ let sendPromise = null;
1044
+ try {
1045
+ steered = autosteer.tryAutosteer({ sessionKey, chatConfig, chatId, msg, prompt });
1046
+ if (!steered.autosteered) {
1047
+ // Primary turn. Kick off the dispatch and hold the latch until
1048
+ // pm.send has made the process inFlight (onDispatched). The
1049
+ // turn RESULT is awaited only AFTER the latch is released — the
1050
+ // latch covers the decision + commitment, never the whole turn
1051
+ // (that would block every autosteer).
1052
+ // Pass streamer + reactor as per-turn context; pm's callbacks
1053
+ // pick them off entry.pendingQueue[0].context.
1054
+ await new Promise((dispatched) => {
1055
+ sendPromise = sendToProcess(sessionKey, prompt, {
1056
+ streamer, reactor, sourceMsgId: msg.message_id,
1057
+ // 0.7.4 (item B): fire THINKING when Claude actually starts
1058
+ // emitting — not the moment we wrote stdin.
1059
+ onFirstStream: () => reactor.setState('THINKING'),
1060
+ }, { onDispatched: dispatched })
1061
+ .catch((e) => ({ __sendError: e }))
1062
+ .finally(dispatched);
1063
+ });
1064
+ }
1065
+ } finally {
1066
+ releaseIntent();
1067
+ }
977
1068
  if (steered.autosteered) {
978
1069
  stopTyping();
979
1070
  // setState('AUTOSTEERED') is terminal — bypasses throttle,
@@ -987,18 +1078,10 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
987
1078
  }
988
1079
 
989
1080
  try {
990
- // Pass streamer + reactor as per-turn context. pm's callbacks pick
991
- // them off entry.pendingQueue[0].context so concurrent pendings each
992
- // get routed to their own streamer/reactor.
993
- const result = await sendToProcess(sessionKey, prompt, {
994
- streamer, reactor, sourceMsgId: msg.message_id,
995
- // 0.7.4 (item B): fire THINKING when Claude actually starts
996
- // emitting (first assistant text or tool_use). Pre-fix, onActivate
997
- // (queue-head transition) flipped to THINKING the moment we wrote
998
- // stdin, even though Claude could spend hundreds of ms loading.
999
- // Result: long flat 🤔 with nothing happening; users assumed stall.
1000
- onFirstStream: () => reactor.setState('THINKING'),
1001
- });
1081
+ const result = await sendPromise;
1082
+ // sendToProcess failures are captured (not thrown) so the latch
1083
+ // always releases; re-throw here into the existing handler.
1084
+ if (result && result.__sendError) throw result.__sendError;
1002
1085
  const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
1003
1086
 
1004
1087
  // 0.7.6 (item F): persist per-turn telemetry. Stream-json result
@@ -1083,15 +1166,16 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1083
1166
  // (→ 'failed', user gets an apology with retry hint).
1084
1167
  if (!result.text) throw new Error(result.error);
1085
1168
  } else {
1086
- // Clear the progress reaction instead of stamping 👍 — the reply
1087
- // bubble itself is the "done" signal and a permanent thumbs-up on
1088
- // every answered message is chat noise (plus triggers reaction
1089
- // notifications for other group members).
1090
- reactor.clear().catch(() => {});
1091
- // 0.8.0-rc.14: also clear reactions on every follow-up
1092
- // message that was autosteered into THIS turn they live in
1093
- // separate handleMessage scopes whose reactors are already GC'd.
1094
- clearAutosteeredReactions(sessionKey).catch(() => {});
1169
+ // rc.10: reactor.clear() and clearAutosteeredReactions() moved
1170
+ // to AFTER deliverReplies completes (see just before
1171
+ // markReplied() below). Pre-rc.10 they fired the moment pm.send
1172
+ // returned (JSONL result event), which was ~1-3s BEFORE the
1173
+ // Telegram reply actually landed via the streamer / chunked
1174
+ // delivery path. User saw: 🤔/✍ visible reactions cleared
1175
+ // ~1-3s of nothing reply bubble lands. Ivan caught this on
1176
+ // shumorobot 2026-05-15 ("both reactions disappeared, typing
1177
+ // disappeared, at some point he responded"). Deferring the
1178
+ // clears closes the visual gap.
1095
1179
  // rc.42: tool-less-turn stale-drain DELETED. With native priority
1096
1180
  // push, the SDK's input controller has the followups directly —
1097
1181
  // there's no buffer for us to drain. Tool-less turns just emit
@@ -1385,6 +1469,23 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1385
1469
 
1386
1470
  await sendInlineStickers();
1387
1471
  await sendInlineReactions();
1472
+ // rc.10: clear progress reactions AFTER the reply has been
1473
+ // delivered so the user doesn't see a "reactions cleared, then
1474
+ // ~1-3s of nothing, then reply bubble" gap. The reply bubble
1475
+ // itself is the "done" signal; clearing the emoji simultaneously
1476
+ // with the delivery completion is the smooth UX path. Both
1477
+ // fire-and-forget — these are best-effort cleanups, not part of
1478
+ // the reply contract.
1479
+ reactor.clear().catch(() => {});
1480
+ // 0.8.0-rc.14: also clear ✍ reactions on every follow-up
1481
+ // message that was autosteered into THIS turn — they live in
1482
+ // separate handleMessage scopes whose reactors are already GC'd.
1483
+ // rc.9 caveat: TmuxProcess.extra-turn-started re-applies ✍ if
1484
+ // there's a pending autosteer dequeue happening (NEW-TURN case),
1485
+ // and extra-turn-reply clears it again when the second reply
1486
+ // lands. So the FOLD path benefits from this deferred clear
1487
+ // without breaking NEW-TURN.
1488
+ clearAutosteeredReactions(sessionKey).catch(() => {});
1388
1489
  console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
1389
1490
  markReplied();
1390
1491
  } catch (err) {
@@ -2027,6 +2128,26 @@ async function main() {
2027
2128
  // instances. Construction is cheap (no system call until first
2028
2129
  // spawn/send). Only used if any chat in config has pm:'tmux'.
2029
2130
  const tmuxRunner = createTmuxRunner({ logger: console });
2131
+ // Verify the pinned claude CLI binary is present. The tmux
2132
+ // backend spawns this exact binary by absolute path (see
2133
+ // lib/claude-bin.js + TmuxProcess.start) — it never resolves
2134
+ // `claude` through $PATH, so the CLI auto-updater can't drift
2135
+ // it. This boot check is informational: it tells the operator
2136
+ // up-front which binary the tmux backend will use, and warns
2137
+ // (non-fatal — SDK-backed chats don't need it) if it's missing.
2138
+ // A missing binary still hard-fails per-chat at TmuxProcess.start.
2139
+ {
2140
+ const { CLAUDE_CLI_PINNED_VERSION } = require('./lib/process/tmux-process');
2141
+ const { verifyPinnedClaudeBin } = require('./lib/claude-bin');
2142
+ const binCheck = verifyPinnedClaudeBin(CLAUDE_CLI_PINNED_VERSION);
2143
+ if (binCheck.ok) {
2144
+ console.log(
2145
+ `[polygram] tmux backend pinned to claude CLI v${CLAUDE_CLI_PINNED_VERSION}: ${binCheck.path}`,
2146
+ );
2147
+ } else {
2148
+ console.warn(`[polygram] WARNING: ${binCheck.reason}`);
2149
+ }
2150
+ }
2030
2151
  // O1 optimization: shared poll-tick scheduler. N TmuxProcess
2031
2152
  // instances share ONE setInterval instead of spawning N independent
2032
2153
  // setTimeout chains. Idle when no chats are in flight (zero timers