polygram 0.8.0-rc.2 → 0.8.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
@@ -31,6 +31,16 @@ const { ProcessManager } = require('./lib/process-manager');
31
31
  // pick-at-startup. Phase 4 deletes the CLI version after Phase 5
32
32
  // soak proves SDK stable. See docs/0.8.0-architecture-decisions.md.
33
33
  const { ProcessManagerSdk } = require('./lib/process-manager-sdk');
34
+ const { createAutosteerBuffer, makePostToolBatchHook } = require('./lib/autosteer-buffer');
35
+ const { makeRouterPolicy, createPmRouter } = require('./lib/pm-router');
36
+ const { canonicalizeToolInput } = require('./lib/canonical-json');
37
+ const {
38
+ buildApprovalKeyboard,
39
+ buildApprovalKeyboardWithAlways,
40
+ formatToolInputForCard,
41
+ approvalCardText,
42
+ } = require('./lib/approval-ui');
43
+ const { makeSessionStartHook } = require('./lib/history-preload');
34
44
  const agentLoader = require('./lib/agent-loader');
35
45
  const USE_SDK = process.env.POLYGRAM_USE_SDK === '1';
36
46
  const { createSender } = require('./lib/telegram');
@@ -698,6 +708,91 @@ function formatPrompt(msg, sessionCtx, attachments = []) {
698
708
 
699
709
  let pm = null; // ProcessManager, created in main()
700
710
 
711
+ // 0.8.0-rc.9: per-session autosteer buffer. Holds user follow-ups
712
+ // that arrive mid-turn so the SDK pm's PostToolBatch hook can drain
713
+ // them into `additionalContext` on each tool boundary. Replaces the
714
+ // rc.6/rc.7 approach of pushing priority:'now' SDKUserMessages
715
+ // directly (which violated the SDK's m87 transcript-shape gate when
716
+ // the assistant was mid-tool-use).
717
+ const autosteerBuffer = createAutosteerBuffer();
718
+
719
+ // 0.8.0-rc.14: track msg_ids that received the AUTOSTEERED ✍ ack, per
720
+ // session, so we can clear those reactions when the in-flight turn
721
+ // finishes. Pre-rc.14 the ✍ persisted forever because each autosteer
722
+ // invocation runs in its OWN handleMessage scope (own reactor), and
723
+ // the TRIGGER message's reactor.clear() at turn-end couldn't reach
724
+ // across to other messages. Without this map, users see ✍ stuck on
725
+ // every follow-up and don't know whether the bot incorporated them.
726
+ const autosteeredMsgRefs = new Map(); // sessionKey → [{chatId, msgId}]
727
+
728
+ async function clearAutosteeredReactions(sessionKey) {
729
+ const list = autosteeredMsgRefs.get(sessionKey);
730
+ if (!list || list.length === 0) return;
731
+ autosteeredMsgRefs.delete(sessionKey);
732
+ if (!bot) return;
733
+ for (const { chatId: cid, msgId } of list) {
734
+ try {
735
+ await tg(bot, 'setMessageReaction', {
736
+ chat_id: cid, message_id: msgId, reaction: [],
737
+ }, { source: 'autosteer-clear', botName: BOT_NAME });
738
+ } catch (err) {
739
+ // Ack-clear failures are silent — the ✍ stays on screen
740
+ // but doesn't block the in-flight turn's reply UX.
741
+ }
742
+ }
743
+ }
744
+
745
+ // 0.8.0-rc.14: tool-less-turn drain. PostToolBatch hook only fires
746
+ // on tool boundaries — when a Query produces a turn that uses ZERO
747
+ // tools (just a text answer), the autosteerBuffer never gets
748
+ // drained and any user follow-ups buffered during that turn
749
+ // disappear silently into the next tool-using turn (or never, if
750
+ // the chat is purely conversational).
751
+ //
752
+ // Workaround: at every success exit in handleMessage, check if
753
+ // the buffer still has items and dispatch them as a synthetic
754
+ // next turn via pm.send. The bot replies to the drained content
755
+ // in a fresh turn — UX-wise the user sees TWO replies (one to
756
+ // the trigger message, one to "B + C") which is the same as if
757
+ // they'd sent the messages without autosteer. Better than losing.
758
+ async function drainStaleAutosteerBuffer(sessionKey, chatId, threadId) {
759
+ const stale = autosteerBuffer.drain(sessionKey);
760
+ if (stale.length === 0) return;
761
+ const followUpPrompt = stale.join('\n\n');
762
+ logEvent('autosteer-stale-drain', {
763
+ chat_id: chatId,
764
+ session_key: sessionKey,
765
+ message_count: stale.length,
766
+ text_len: followUpPrompt.length,
767
+ });
768
+ // Dispatch as a fresh pm.send via setImmediate so we don't
769
+ // block the current handleMessage's success-path return. No
770
+ // streamer / reactor — the synthetic turn gets a plain bubble
771
+ // reply (no streaming preview, no progress reactions). User
772
+ // already saw their ✍ ack on the original follow-up; this
773
+ // turn's existence is the substantive response.
774
+ setImmediate(async () => {
775
+ try {
776
+ const chatConfig = config.chats[chatId];
777
+ if (!chatConfig) return;
778
+ const result = await sendToProcess(sessionKey, followUpPrompt, {
779
+ streamer: null, reactor: null, sourceMsgId: null,
780
+ });
781
+ if (result?.text && bot) {
782
+ await tg(bot, 'sendMessage', {
783
+ chat_id: chatId,
784
+ text: result.text,
785
+ ...(threadId ? { message_thread_id: threadId } : {}),
786
+ }, { source: 'autosteer-stale-reply', botName: BOT_NAME }).catch((err) => {
787
+ console.error(`[${BOT_NAME}] autosteer-stale-reply send: ${err.message}`);
788
+ });
789
+ }
790
+ } catch (err) {
791
+ console.error(`[${BOT_NAME}] autosteer-stale-drain dispatch: ${err.message}`);
792
+ }
793
+ });
794
+ }
795
+
701
796
  function spawnClaude(sessionKey, ctx) {
702
797
  const { chatConfig, existingSessionId, label, chatId } = ctx;
703
798
  // 0.7.3: Claude Code's Chrome-extension integration (browser
@@ -784,6 +879,10 @@ function buildSdkOptions(sessionKey, ctx) {
784
879
  try {
785
880
  agentBundle = agentLoader.loadAgent(chatConfig.agent, {
786
881
  homeDir: CHILD_HOME,
882
+ // Pass cwd so the loader checks Claude Code's project-level
883
+ // path (`<cwd>/.claude/agents/<name>.md`) before the
884
+ // user-level path or polygram's directory convention.
885
+ cwd: chatConfig.cwd,
787
886
  logger: console,
788
887
  });
789
888
  } catch (err) {
@@ -817,6 +916,35 @@ function buildSdkOptions(sessionKey, ctx) {
817
916
  const useCanUseTool = apprCfg && apprCfg.adminChatId
818
917
  && Array.isArray(apprCfg.gatedTools) && apprCfg.gatedTools.length > 0;
819
918
 
919
+ // 0.8.0-rc.9 (factored to lib/autosteer-buffer.js in rc.17): the
920
+ // PostToolBatch hook drains the autosteer buffer for THIS session
921
+ // and injects queued user follow-ups as `additionalContext` on
922
+ // each tool boundary, wrapped in `<channel source="user-followup">`
923
+ // which Claude is trained to trust as legitimate out-of-band user
924
+ // context.
925
+ const postToolBatchHook = makePostToolBatchHook({
926
+ buffer: autosteerBuffer,
927
+ sessionKey,
928
+ chatId: ctx?.chatId ?? null,
929
+ logEvent,
930
+ logger: console,
931
+ });
932
+
933
+ // 0.8.0-rc.21: SessionStart hook preloads recent polygram-DB
934
+ // history into a fresh Query (no resume). Without this, every
935
+ // /new or daemon-boot starts the agent blank — even though the
936
+ // chat has been running for weeks. Skips when source is
937
+ // 'resume' or 'compact' (transcript already populated).
938
+ const sessionStartHook = ctx?.chatId
939
+ ? makeSessionStartHook({
940
+ db,
941
+ chatId: ctx.chatId,
942
+ threadId: ctx.threadId ?? null,
943
+ logEvent,
944
+ logger: console,
945
+ })
946
+ : null;
947
+
820
948
  const baseOpts = {
821
949
  model: chatConfig.model || config.defaults.model,
822
950
  effort: chatConfig.effort || config.defaults.effort,
@@ -828,6 +956,12 @@ function buildSdkOptions(sessionKey, ctx) {
828
956
  permissionMode: useCanUseTool ? 'default' : 'bypassPermissions',
829
957
  allowDangerouslySkipPermissions: !useCanUseTool,
830
958
  ...(useCanUseTool && { canUseTool: makeCanUseTool(sessionKey) }),
959
+ hooks: {
960
+ PostToolBatch: [{ hooks: [postToolBatchHook] }],
961
+ ...(sessionStartHook && {
962
+ SessionStart: [{ hooks: [sessionStartHook] }],
963
+ }),
964
+ },
831
965
  executable: 'node',
832
966
  ...(existingSessionId && { resume: existingSessionId }),
833
967
  ...(process.env.POLYGRAM_CLAUDE_BIN && {
@@ -1123,51 +1257,9 @@ async function handleSendOverIpc(req) {
1123
1257
  }
1124
1258
 
1125
1259
  // ─── Approvals ─────────────────────────────────────────────────────
1126
-
1127
- // Format a tool_input for the inline keyboard card. Clip aggressively so
1128
- // the card doesn't exceed Telegram's 4096-char limit.
1129
- function formatToolInputForCard(input) {
1130
- let s;
1131
- try { s = typeof input === 'string' ? input : JSON.stringify(input, null, 2); }
1132
- catch { s = String(input); }
1133
- if (s.length <= 1200) return s;
1134
- return s.slice(0, 900) + '\n…[clipped]…\n' + s.slice(-200);
1135
- }
1136
-
1137
- function buildApprovalKeyboard(approvalId, token) {
1138
- return {
1139
- inline_keyboard: [[
1140
- { text: '✅ Approve', callback_data: `approve:${approvalId}:${token}` },
1141
- { text: '❌ Deny', callback_data: `deny:${approvalId}:${token}` },
1142
- ]],
1143
- };
1144
- }
1145
-
1146
- // 0.8.0 Phase 2 step 6: 4-button approval keyboard for SDK canUseTool
1147
- // flow. Adds "Always allow" and "Always deny" rows that persist the
1148
- // decision into chat_tool_decisions (via callback_query handler),
1149
- // so subsequent invocations of the same tool with the same input
1150
- // short-circuit without prompting.
1151
- //
1152
- // Callback_data conventions:
1153
- // approve:<id>:<token> — one-time allow
1154
- // deny:<id>:<token> — one-time deny
1155
- // approve-always:<id>:<token> — allow + persist
1156
- // deny-always:<id>:<token> — deny + persist
1157
- function buildApprovalKeyboardWithAlways(approvalId, token) {
1158
- return {
1159
- inline_keyboard: [
1160
- [
1161
- { text: '✅ Approve', callback_data: `approve:${approvalId}:${token}` },
1162
- { text: '❌ Deny', callback_data: `deny:${approvalId}:${token}` },
1163
- ],
1164
- [
1165
- { text: '🔁 Always allow', callback_data: `approve-always:${approvalId}:${token}` },
1166
- { text: '🚫 Always deny', callback_data: `deny-always:${approvalId}:${token}` },
1167
- ],
1168
- ],
1169
- };
1170
- }
1260
+ // rc.20: pure UI builders moved to lib/approval-ui.js for testability.
1261
+ // Imported above (buildApprovalKeyboard, buildApprovalKeyboardWithAlways,
1262
+ // approvalCardText, formatToolInputForCard).
1171
1263
 
1172
1264
  // /model and /effort inline keyboard. `show` controls which row(s) appear:
1173
1265
  // 'model', 'effort', or 'all'. The current value gets a ✓ marker so the
@@ -1236,54 +1328,10 @@ function formatConfigInfoText(chatConfig, show, sessionKey) {
1236
1328
  return body;
1237
1329
  }
1238
1330
 
1239
- function approvalCardText(row, opts = {}) {
1240
- // No parse_mode is used on this card — tool_name/turn_id/tool_input
1241
- // originate from the Claude subprocess and could contain Markdown special
1242
- // chars or tg:// links crafted for phishing. Plain text renders as-is.
1243
- const heading = opts.resolvedBy
1244
- ? opts.resolvedBy
1245
- : `Approval needed — ${row.tool_name}`;
1246
- const body = formatToolInputForCard(
1247
- typeof row.tool_input_json === 'string'
1248
- ? safeParse(row.tool_input_json)
1249
- : row.tool_input_json,
1250
- );
1251
- const ttl = Math.max(0, Math.round((row.timeout_ts - Date.now()) / 1000));
1252
- const footer = opts.resolvedBy
1253
- ? ''
1254
- : `\n\n⏱ expires in ${ttl}s`;
1255
- return `${heading}\nChat: ${row.requester_chat_id}\nTurn: ${row.turn_id || '-'}\n\n${body}${footer}`;
1256
- }
1331
+ // rc.20: approvalCardText + safeParse moved to lib/approval-ui.js.
1257
1332
 
1258
- function safeParse(s) {
1259
- try { return JSON.parse(s); } catch { return s; }
1260
- }
1261
-
1262
- /**
1263
- * 0.8.0 Phase 2 step 6: canonical-JSON-stringify of a tool input
1264
- * object. Keys sorted alphabetically; no whitespace. Used as the
1265
- * dedup key for chat_tool_decisions match_type='exact' lookups
1266
- * and as the input_pattern stored on "Always allow" clicks.
1267
- *
1268
- * Why canonical: Claude can reorder JSON keys between retries of
1269
- * the same tool call (different SDK versions, different temperature
1270
- * sampling). Without canonicalisation, the dedup digest would
1271
- * differ for semantically-identical calls and the user would see
1272
- * the same approval card twice (ship-breaker M8 mitigation).
1273
- */
1274
- function canonicalizeToolInput(input) {
1275
- if (input == null || typeof input !== 'object') {
1276
- return JSON.stringify(input);
1277
- }
1278
- const sortRec = (v) => {
1279
- if (Array.isArray(v)) return v.map(sortRec);
1280
- if (v == null || typeof v !== 'object') return v;
1281
- const out = {};
1282
- for (const k of Object.keys(v).sort()) out[k] = sortRec(v[k]);
1283
- return out;
1284
- };
1285
- return JSON.stringify(sortRec(input));
1286
- }
1333
+ // 0.8.0-rc.18+: canonicalizeToolInput moved to lib/canonical-json.js
1334
+ // for testability. Same function, no behavior change.
1287
1335
 
1288
1336
  /**
1289
1337
  * 0.8.0 Phase 2 step 6: SDK canUseTool callback. Hands back to the
@@ -1709,16 +1757,38 @@ async function handleConfigCallback(ctx) {
1709
1757
  user: cmdUser, user_id: cmdUserId, source: 'inline-button',
1710
1758
  }), `log ${setting} change`);
1711
1759
 
1712
- // Graceful respawn of the topic's session that the card is in. With
1760
+ // Graceful application of the change to the topic's session. With
1713
1761
  // isolateTopics=false sessionKey is the chat (one shared session). With
1714
1762
  // isolateTopics=true sessionKey carries the topic, so other topics'
1715
1763
  // in-flight turns are not disturbed and the card update + button toast
1716
- // only affect the user's own context. Mirrors the text-command flow in
1717
- // handleMessage's requestRespawnForSession.
1764
+ // only affect the user's own context.
1765
+ //
1766
+ // CLI pm: requestRespawn drains pending turns then kills the process;
1767
+ // the next user message spawns fresh with the updated chatConfig.
1768
+ // SDK pm: applies live to the running Query via setModel /
1769
+ // applyFlagSettings — no respawn needed, change takes effect for the
1770
+ // rest of the in-flight turn AND all future ones. Falls back to
1771
+ // {killed: false} if neither method is available, leaving the new
1772
+ // chatConfig value to be picked up by the next cold spawn.
1718
1773
  const callbackThreadId = ctx.callbackQuery.message?.message_thread_id?.toString() || null;
1719
1774
  const callbackSessionKey = getSessionKey(chatId, callbackThreadId, chatConfig);
1720
1775
  const reason = setting === 'model' ? 'model-change' : 'effort-change';
1721
- const respawn = pm.requestRespawn(callbackSessionKey, reason);
1776
+ // Feature-detect on the routed pm for this specific session, not on
1777
+ // the router itself (the router exposes every method as a forwarding
1778
+ // shim so `typeof pm.X` is always 'function').
1779
+ const pmForCb = pm.pickFor(callbackSessionKey);
1780
+ let respawn;
1781
+ if (typeof pmForCb.requestRespawn === 'function') {
1782
+ respawn = pmForCb.requestRespawn(callbackSessionKey, reason);
1783
+ } else if (setting === 'effort' && typeof pmForCb.applyFlagSettings === 'function') {
1784
+ const ok = await pmForCb.applyFlagSettings(callbackSessionKey, { effortLevel: value });
1785
+ respawn = { killed: ok };
1786
+ } else if (setting === 'model' && typeof pmForCb.setModel === 'function') {
1787
+ const ok = await pmForCb.setModel(callbackSessionKey, value);
1788
+ respawn = { killed: ok };
1789
+ } else {
1790
+ respawn = { killed: false };
1791
+ }
1722
1792
  const anyActive = !respawn.killed;
1723
1793
 
1724
1794
  // Re-render the card with updated ✓ + the same help text shown initially.
@@ -1873,8 +1943,8 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1873
1943
  // usage report. Only meaningful under SDK pm (CLI pm has no
1874
1944
  // getContextUsage equivalent); CLI path replies with a hint.
1875
1945
  if (botAllowsCommands && text === '/context') {
1876
- if (!USE_SDK) {
1877
- await sendReply('📚 /context requires the SDK pm (set POLYGRAM_USE_SDK=1 to enable).');
1946
+ if (!pm.isSdkFor(sessionKey)) {
1947
+ await sendReply('📚 /context requires the SDK pm. This chat is on the CLI pm path.');
1878
1948
  return;
1879
1949
  }
1880
1950
  const entry = pm.get(sessionKey);
@@ -1885,13 +1955,18 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1885
1955
  }
1886
1956
  try {
1887
1957
  const u = await q.getContextUsage();
1888
- const pct = ((u?.percentage ?? 0) * 100).toFixed(0);
1958
+ // SDK returns percentage in 0-100 scale (verified rc.3 prod
1959
+ // — saw "77" for a 77%-used context). Display directly.
1960
+ const pct = (u?.percentage ?? 0).toFixed(0);
1889
1961
  const total = (u?.totalTokens ?? 0).toLocaleString();
1890
1962
  const max = (u?.maxTokens ?? 0).toLocaleString();
1891
1963
  const lines = [`📚 Context: ${total} / ${max} tokens (${pct}%)`];
1892
1964
  if (u?.model) lines.push(`Model: ${u.model}`);
1893
1965
  if (u?.isAutoCompactEnabled && u?.autoCompactThreshold) {
1894
- const thrPct = (u.autoCompactThreshold * 100).toFixed(0);
1966
+ // autoCompactThreshold scale is currently unverified; assume
1967
+ // matches percentage (0-100). If it turns out to be 0-1 we'll
1968
+ // see something like "Auto-compact at 0%" and can flip back.
1969
+ const thrPct = u.autoCompactThreshold.toFixed(0);
1895
1970
  lines.push(`Auto-compact at ${thrPct}%.`);
1896
1971
  }
1897
1972
  // Top-3 categories by token cost so the user knows where the
@@ -1914,9 +1989,10 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1914
1989
  }
1915
1990
  if (botAllowsCommands && (text === '/new' || text === '/reset')) {
1916
1991
  let drained = 0;
1917
- if (typeof pm.resetSession === 'function') {
1992
+ const target = pm.pickFor(sessionKey);
1993
+ if (typeof target.resetSession === 'function') {
1918
1994
  try {
1919
- const r = await pm.resetSession(sessionKey, { reason: text.slice(1) });
1995
+ const r = await target.resetSession(sessionKey, { reason: text.slice(1) });
1920
1996
  drained = r?.drainedPendings ?? 0;
1921
1997
  } catch (err) {
1922
1998
  console.error(`[${label}] resetSession ${text}: ${err.message}`);
@@ -1938,48 +2014,39 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1938
2014
  await sendReply('✨ Started a fresh session.');
1939
2015
  return;
1940
2016
  }
1941
- // 0.8.0 Phase 2 step 1: /steer <text> mid-turn steering. Pushes
1942
- // a priority:'now' user message onto the active Query so Claude
1943
- // sees it without waiting for the in-flight turn to fully
1944
- // complete. SDK pm only CLI pm has no steer primitive (its
1945
- // stream-json transport is request-response, not interruptible
1946
- // mid-turn). Falls back to /new under CLI pm.
1947
- if (botAllowsCommands && text.startsWith('/steer ')) {
1948
- const steerText = text.slice(7).trim();
1949
- if (!steerText) { await sendReply('Usage: /steer <text>'); return; }
1950
- if (!USE_SDK || typeof pm.steer !== 'function') {
1951
- await sendReply('🛞 /steer requires the SDK pm (set POLYGRAM_USE_SDK=1 to enable).');
1952
- return;
1953
- }
1954
- if (!pm.has(sessionKey)) {
1955
- await sendReply('🛞 No active session — /steer only works mid-turn. Send a message first, then /steer while it\'s thinking.');
1956
- return;
1957
- }
1958
- const ok = pm.steer(sessionKey, steerText);
1959
- if (ok) {
1960
- logEvent('steer-command', {
1961
- chat_id: chatId, text_len: steerText.length,
1962
- user: cmdUser, user_id: cmdUserId,
1963
- });
1964
- // Quiet ack so user knows it landed; the actual response will
1965
- // arrive as the in-flight turn's continuation.
1966
- await sendReply('🛞 Steering applied. Watching for the response.');
1967
- } else {
1968
- await sendReply('🛞 Couldn\'t apply steer — session may have just closed.');
1969
- }
1970
- return;
1971
- }
1972
- // Graceful respawn of the user's CURRENT session only. With
1973
- // isolateTopics=false the sessionKey is just the chat (one shared
1974
- // session for the whole chat — every topic respawns implicitly).
1975
- // With isolateTopics=true each topic is a separate session, and a
1976
- // /model in topic A should NOT disturb topic B's in-flight turn or
1977
- // post a phantom "✓ Using sonnet now" in a topic that didn't ask.
1978
- // Pre-0.6.5 this iterated pm.keys() by chat prefix and incorrectly
1979
- // fanned out across all topics under isolateTopics=true.
1980
- const requestRespawnForSession = (reason) => {
1981
- const res = pm.requestRespawn(sessionKey, reason);
1982
- return { queued: res.queued, anyActive: !res.killed };
2017
+ // 0.8.0-rc.9: /steer command removed. Mid-turn user input is
2018
+ // handled implicitly by autosteer any follow-up message during
2019
+ // an in-flight SDK turn flows through autosteerBuffer +
2020
+ // PostToolBatch hook. No explicit command needed; matches Claude
2021
+ // Code interactive UX where you just keep typing.
2022
+ // Graceful application of a model/effort change to the user's CURRENT
2023
+ // session only. With isolateTopics=false the sessionKey is just the
2024
+ // chat (one shared session for the whole chat — every topic
2025
+ // respawns implicitly). With isolateTopics=true each topic is a
2026
+ // separate session, and a /model in topic A should NOT disturb
2027
+ // topic B's in-flight turn or post a phantom "✓ Using sonnet now"
2028
+ // in a topic that didn't ask.
2029
+ //
2030
+ // CLI pm: requestRespawn drains pending turns then kills the process;
2031
+ // the next user message spawns fresh with the updated chatConfig.
2032
+ // SDK pm: applies live to the running Query via setModel /
2033
+ // applyFlagSettings — no respawn needed, change takes effect for
2034
+ // the rest of the in-flight turn AND all future ones.
2035
+ const applyConfigChange = async (reason, setting, value) => {
2036
+ const target = pm.pickFor(sessionKey);
2037
+ if (typeof target.requestRespawn === 'function') {
2038
+ const res = target.requestRespawn(sessionKey, reason);
2039
+ return { queued: res.queued, anyActive: !res.killed };
2040
+ }
2041
+ if (setting === 'effort' && typeof target.applyFlagSettings === 'function') {
2042
+ const ok = await target.applyFlagSettings(sessionKey, { effortLevel: value });
2043
+ return { queued: 0, anyActive: !ok };
2044
+ }
2045
+ if (setting === 'model' && typeof target.setModel === 'function') {
2046
+ const ok = await target.setModel(sessionKey, value);
2047
+ return { queued: 0, anyActive: !ok };
2048
+ }
2049
+ return { queued: 0, anyActive: false };
1983
2050
  };
1984
2051
 
1985
2052
  if (botAllowsCommands && text.startsWith('/model ')) {
@@ -1993,7 +2060,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1993
2060
  old_value: oldModel, new_value: newModel,
1994
2061
  user: cmdUser, user_id: cmdUserId, source: 'command',
1995
2062
  }), 'log model change');
1996
- const { anyActive } = requestRespawnForSession('model-change');
2063
+ const { anyActive } = await applyConfigChange('model-change', 'model', newModel);
1997
2064
  const ver = MODEL_VERSIONS[newModel] || newModel;
1998
2065
  const suffix = anyActive ? ` — I'll switch when I finish` : '';
1999
2066
  await sendReply(`Model → ${newModel} (${ver})${suffix}`);
@@ -2013,7 +2080,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2013
2080
  old_value: oldEffort, new_value: newEffort,
2014
2081
  user: cmdUser, user_id: cmdUserId, source: 'command',
2015
2082
  }), 'log effort change');
2016
- const { anyActive } = requestRespawnForSession('effort-change');
2083
+ const { anyActive } = await applyConfigChange('effort-change', 'effort', newEffort);
2017
2084
  const suffix = anyActive ? ` — I'll switch when I finish` : '';
2018
2085
  await sendReply(`Effort → ${newEffort}${suffix}`);
2019
2086
  } else {
@@ -2366,34 +2433,59 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2366
2433
  // chatConfig.autosteer === false). CLI pm always falls through
2367
2434
  // to the queue-FIFO path (no steer primitive on stream-json).
2368
2435
  //
2369
- // The steered message gets a 🛞 reaction so the user knows it
2436
+ // The steered message gets a reaction so the user knows it
2370
2437
  // landed; no separate reply is generated (the in-flight turn's
2371
2438
  // response covers both messages, OpenClaw-style).
2439
+ //
2440
+ // Reaction emoji must be from Telegram's curated allowlist
2441
+ // (~60 standard emoji per core.telegram.org/bots/api#availablereactions).
2442
+ // 🛞 (steering wheel) is NOT on it — Telegram returns
2443
+ // 400: REACTION_INVALID. ✍ ("writing/noting") is on the list and
2444
+ // conveys "incorporating this".
2372
2445
  const chatAutosteer = chatConfig.autosteer != null
2373
2446
  ? chatConfig.autosteer
2374
2447
  : config.bot?.autosteer;
2375
- const autosteerEnabled = USE_SDK && chatAutosteer !== false;
2376
- if (autosteerEnabled && typeof pm.steer === 'function' && pm.has(sessionKey)) {
2448
+ // 0.8.0-rc.9: autosteer now drives through autosteerBuffer +
2449
+ // PostToolBatch hook (in buildSdkOptions), not pm.steer's direct
2450
+ // inputController push. The hook fires on every tool boundary
2451
+ // and injects queued follow-ups as <channel source="user-followup">
2452
+ // additionalContext — the SDK-trusted framing that survives the
2453
+ // m87 transcript-shape gate.
2454
+ //
2455
+ // We still gate on the SDK pm path: under CLI pm there's no
2456
+ // PostToolBatch hook surface, so autosteer falls through to the
2457
+ // regular FIFO send (same UX as 0.7.x).
2458
+ const autosteerEnabled = chatAutosteer !== false
2459
+ && pm.isSdkFor(sessionKey);
2460
+ if (autosteerEnabled && pm.has(sessionKey)) {
2377
2461
  const entry = pm.get(sessionKey);
2378
2462
  if (entry?.inFlight) {
2379
- const ok = pm.steer(sessionKey, prompt);
2463
+ const ok = autosteerBuffer.append(sessionKey, prompt);
2380
2464
  if (ok) {
2381
- // Quiet ack no chat-bubble reply, just a reaction so the
2382
- // user sees their message was incorporated. The in-flight
2383
- // turn's response will address both questions.
2384
- tg(bot, 'setMessageReaction', {
2385
- chat_id: chatId,
2386
- message_id: msg.message_id,
2387
- reaction: [{ type: 'emoji', emoji: '🛞' }],
2388
- }, { source: 'autosteer-ack', botName: BOT_NAME }).catch((err) => {
2389
- console.error(`[${label}] autosteer reaction: ${err.message}`);
2390
- });
2465
+ // Track this msg_id so the in-flight turn's success / abort
2466
+ // / error path can clear the reaction at turn-end.
2467
+ const refs = autosteeredMsgRefs.get(sessionKey) || [];
2468
+ refs.push({ chatId, msgId: msg.message_id });
2469
+ autosteeredMsgRefs.set(sessionKey, refs);
2391
2470
  logEvent('autosteer', {
2392
2471
  chat_id: chatId, msg_id: msg.message_id,
2393
2472
  text_len: prompt?.length ?? 0,
2394
2473
  });
2395
2474
  stopTyping();
2396
- reactor.stop();
2475
+ // 0.8.0-rc.11: route the ✍ ack through the reactor's
2476
+ // serialized apply chain. Pre-rc.11 we used a direct
2477
+ // setMessageReaction(✍) racing with the reactor's
2478
+ // QUEUED→👀 apply AND a follow-up reactor.clear() — three
2479
+ // concurrent network calls, final state was whichever
2480
+ // landed last at Telegram. Symptom: 👀 sometimes stuck,
2481
+ // ✍ sometimes vanished, reactions disappeared "almost
2482
+ // immediately" or got stuck arbitrarily.
2483
+ //
2484
+ // setState('AUTOSTEERED') is terminal so it bypasses the
2485
+ // 800ms throttle and flushes synchronously through
2486
+ // applyChain — so it serializes after any in-flight
2487
+ // QUEUED apply and lands as the final visible reaction.
2488
+ await reactor.setState('AUTOSTEERED');
2397
2489
  markReplied();
2398
2490
  return;
2399
2491
  }
@@ -2454,8 +2546,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2454
2546
  // Only fires when pm.resetSession is available (SDK pm
2455
2547
  // path); CLI pm doesn't have the method.
2456
2548
  const cls = classifyError(result.error);
2457
- if (cls.autoRecover === 'reset_session' && typeof pm.resetSession === 'function') {
2458
- pm.resetSession(sessionKey, { reason: cls.kind })
2549
+ const recoverTarget = pm.pickFor(sessionKey);
2550
+ if (cls.autoRecover === 'reset_session' && typeof recoverTarget.resetSession === 'function') {
2551
+ recoverTarget.resetSession(sessionKey, { reason: cls.kind })
2459
2552
  .catch((err) => console.error(`[${label}] auto-reset failed: ${err.message}`));
2460
2553
  logEvent('auto-recover', {
2461
2554
  chat_id: chatId, kind: cls.kind, action: 'reset_session',
@@ -2477,23 +2570,42 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2477
2570
  // every answered message is chat noise (plus triggers reaction
2478
2571
  // notifications for other group members).
2479
2572
  reactor.clear().catch(() => {});
2573
+ // 0.8.0-rc.14: also clear ✍ reactions on every follow-up
2574
+ // message that was autosteered into THIS turn — they live in
2575
+ // separate handleMessage scopes whose reactors are already GC'd.
2576
+ clearAutosteeredReactions(sessionKey).catch(() => {});
2577
+ // rc.14: tool-less-turn drain. PostToolBatch hook fires only
2578
+ // on tool boundaries; if this turn produced ZERO tools, the
2579
+ // hook never fired and the autosteer buffer still has the
2580
+ // user's follow-ups. Dispatch them as a synthetic next turn
2581
+ // so the bot at least addresses them (better than losing).
2582
+ drainStaleAutosteerBuffer(sessionKey, chatId, threadId).catch(() => {});
2480
2583
 
2481
2584
  // 0.8.0 Phase 2 step 4: 85%-context-full live hint. After a
2482
2585
  // successful turn, peek at SDK's getContextUsage(); if past
2483
2586
  // 85%, post a quiet hint so the user knows /new will help.
2484
2587
  // SDK pm only — CLI pm has no equivalent (no Query object,
2485
- // no getContextUsage). Per-bot opt-out via
2486
- // config.bot.contextHint = false.
2487
- if (USE_SDK && config.bot?.contextHint !== false) {
2588
+ // no getContextUsage). OPT-IN per-chat or per-bot
2589
+ // (rc.12+) — most chats don't want the noise. Per-chat takes
2590
+ // precedence over per-bot so admins (Ivan DM) can opt in
2591
+ // without forcing it on every other chat.
2592
+ const chatCtxHint = chatConfig.contextHint != null
2593
+ ? chatConfig.contextHint
2594
+ : config.bot?.contextHint;
2595
+ if (pm.isSdkFor(sessionKey) && chatCtxHint === true) {
2488
2596
  const entry = pm.get(sessionKey);
2489
2597
  const q = entry?.query;
2490
2598
  if (q && typeof q.getContextUsage === 'function') {
2491
2599
  q.getContextUsage().then((usage) => {
2600
+ // SDK returns percentage in 0-100 scale, not 0-1.
2601
+ // Pre-rc.4 we treated it as a 0-1 ratio and multiplied
2602
+ // by 100, which displayed "7700% full" for a 77%-used
2603
+ // context (and fired below the intended 85% threshold).
2492
2604
  const pct = usage?.percentage ?? 0;
2493
- if (pct < 0.85) return;
2605
+ if (pct < 85) return;
2494
2606
  return tg(bot, 'sendMessage', {
2495
2607
  chat_id: chatId,
2496
- text: `📚 Context window ${(pct * 100).toFixed(0)}% full. Send /new to start fresh — older messages will start dropping soon.`,
2608
+ text: `📚 Context window ${pct.toFixed(0)}% full. Send /new to start fresh — older messages will start dropping soon.`,
2497
2609
  ...(threadId ? { message_thread_id: threadId } : {}),
2498
2610
  }, { source: 'context-full-hint', botName: BOT_NAME });
2499
2611
  }).catch((err) => {
@@ -2512,6 +2624,31 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2512
2624
  // those still markReplied silently.
2513
2625
  if (result.text === 'NO_REPLY') { markReplied(); return; }
2514
2626
  if (!result.text) {
2627
+ // 0.8.0-rc.7: tool-only completion is NOT an error. Under SDK
2628
+ // pm, a turn that ends after running tools (no closing text
2629
+ // block) leaves result.text empty even though the bot DID
2630
+ // respond — via tool side effects the user already saw. Don't
2631
+ // post a "No response generated" apology in that case; it's
2632
+ // confusing and it spams the chat. Just clear the reactor
2633
+ // (otherwise 👀 stays stuck — reactor.stop() doesn't remove
2634
+ // the emoji visually) and silently mark replied.
2635
+ const toolOnlyTurn = (result.metrics?.numToolUses ?? 0) > 0
2636
+ && (result.metrics?.numAssistantMessages ?? 0) > 0;
2637
+ if (toolOnlyTurn) {
2638
+ await reactor.clear().catch(() => {});
2639
+ clearAutosteeredReactions(sessionKey).catch(() => {});
2640
+ // Tool-only turns DID fire PostToolBatch — buffer was drained
2641
+ // — but autosteers received AFTER the last tool-result still
2642
+ // wouldn't be merged. Defensive drain here too.
2643
+ drainStaleAutosteerBuffer(sessionKey, chatId, threadId).catch(() => {});
2644
+ logEvent('tool-only-completion', {
2645
+ chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
2646
+ num_tool_uses: result.metrics?.numToolUses,
2647
+ num_assistant_messages: result.metrics?.numAssistantMessages,
2648
+ });
2649
+ markReplied();
2650
+ return;
2651
+ }
2515
2652
  // 0.7.1: if the fallback send itself fails, throw rather than
2516
2653
  // silently markReplied — the user gets nothing AND the inbound
2517
2654
  // is marked replied so boot replay won't redispatch. Same
@@ -2537,6 +2674,12 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2537
2674
  logEvent('telegram-empty-response-fallback', {
2538
2675
  chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
2539
2676
  });
2677
+ // 0.8.0-rc.7: clear the THINKING/QUEUED emoji on the user's
2678
+ // message so 👀 doesn't stay stuck after the apology lands.
2679
+ // reactor.stop() (in the finally block) only kills timers; it
2680
+ // does NOT remove the visible emoji. Without this clear, the
2681
+ // user sees 👀 next to their message indefinitely.
2682
+ await reactor.clear().catch(() => {});
2540
2683
  markReplied();
2541
2684
  return;
2542
2685
  }
@@ -2661,7 +2804,16 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2661
2804
  const abortedByUser = isSessionRecentlyAborted(sessionKey);
2662
2805
  if (abortedByUser) {
2663
2806
  await streamer.finalize('').catch(() => {});
2664
- // Leave reaction as-is no 🤯 / 😨; user asked for stop.
2807
+ // 0.8.0-rc.13: clear the in-flight emoji on abort so the user
2808
+ // sees a clean message after their /stop ack — pre-rc.13 the
2809
+ // last 👀 / 🤔 / ✍ stayed stuck on the message indefinitely
2810
+ // because reactor.stop() (in finally) only kills timers, not
2811
+ // the visible reaction. We DON'T set 🤯/😨 (those are for
2812
+ // unexpected errors); the user just wants their stop honored.
2813
+ await reactor.clear().catch(() => {});
2814
+ // rc.14: clear ✍ on autosteered followups too (per-msg
2815
+ // reactors are already GC'd in their own handleMessage scopes).
2816
+ await clearAutosteeredReactions(sessionKey).catch(() => {});
2665
2817
  } else {
2666
2818
  await streamer.finalize('', { errorSuffix: 'stream interrupted' }).catch(() => {});
2667
2819
  if (/wall-clock ceiling|idle with no Claude activity/i.test(err?.message || '')) {
@@ -2716,7 +2868,7 @@ function createBot(token) {
2716
2868
  // Cached once @botUsername is known — was recompiling per inbound msg.
2717
2869
  let mentionRe = null;
2718
2870
  // Hoisted admin-command matcher; was re-allocated per message.
2719
- const ADMIN_CMD_RE = /^\/(model|effort|config|pair-code|pairings|unpair|new|reset|context|steer)(\s|$)/;
2871
+ const ADMIN_CMD_RE = /^\/(model|effort|config|pair-code|pairings|unpair|new|reset|context)(\s|$)/;
2720
2872
  const PAIR_CLAIM_RE = /^\/pair\s+\S+/;
2721
2873
 
2722
2874
  // The filter in main() guarantees config.chats only contains chats owned
@@ -2860,16 +3012,25 @@ function createBot(token) {
2860
3012
  // sessionKey is the chat itself, so killing one session is
2861
3013
  // the same as killing the chat — behavior unchanged for the
2862
3014
  // common case.
2863
- if (USE_SDK && typeof pm.interrupt === 'function') {
2864
- await pm.interrupt(sessionKey).catch((err) =>
3015
+ const stopTarget = pm.pickFor(sessionKey);
3016
+ if (typeof stopTarget.interrupt === 'function') {
3017
+ await stopTarget.interrupt(sessionKey).catch((err) =>
2865
3018
  console.error(`[${BOT_NAME}] interrupt failed: ${err.message}`));
2866
- if (typeof pm.drainQueue === 'function') {
2867
- pm.drainQueue(sessionKey, 'INTERRUPTED');
3019
+ if (typeof stopTarget.drainQueue === 'function') {
3020
+ stopTarget.drainQueue(sessionKey, 'INTERRUPTED');
2868
3021
  }
2869
3022
  } else {
2870
- await pm.kill(sessionKey).catch((err) =>
3023
+ await stopTarget.kill(sessionKey).catch((err) =>
2871
3024
  console.error(`[${BOT_NAME}] abort kill failed: ${err.message}`));
2872
3025
  }
3026
+ // 0.8.0-rc.13: drop any buffered autosteer follow-ups for this
3027
+ // session — otherwise they'd be injected into the NEXT turn
3028
+ // (stale steer leak across abort boundary, which is what the
3029
+ // user just asked us not to do).
3030
+ autosteerBuffer.clear(sessionKey);
3031
+ // rc.14: also clear ✍ reactions on already-autosteered
3032
+ // messages from this aborted turn — they're now dead context.
3033
+ clearAutosteeredReactions(sessionKey).catch(() => {});
2873
3034
  logEvent('abort-requested', {
2874
3035
  chat_id: chatId, user_id: msg.from?.id || null,
2875
3036
  had_active: hadActive,
@@ -3308,17 +3469,32 @@ async function main() {
3308
3469
  });
3309
3470
 
3310
3471
  const cap = config.maxWarmProcesses || DEFAULT_MAX_WARM_PROCS;
3311
- // 0.8.0 Phase 3: pick pm implementation via env flag. Default
3312
- // (POLYGRAM_USE_SDK unset) keeps the CLI-based pm same as 0.7.x.
3313
- // Set POLYGRAM_USE_SDK=1 to switch to the SDK-backed pm.
3314
- // Phase 5 soak: enable on umi-assistant first, watch for
3315
- // regressions, then enable on shumabit.
3316
- const PMClass = USE_SDK ? ProcessManagerSdk : ProcessManager;
3317
- const spawnFn = USE_SDK ? buildSdkOptions : spawnClaude;
3318
- console.log(`[polygram] using ${USE_SDK ? 'SDK' : 'CLI'} ProcessManager`);
3319
- pm = new PMClass({
3472
+
3473
+ // 0.8.0-rc.6: per-chat pm selection. Three modes:
3474
+ // 1. POLYGRAM_USE_SDK=1 with no POLYGRAM_SDK_CHATS list → all chats SDK
3475
+ // 2. POLYGRAM_SDK_CHATS=id1,id2,... → those chats
3476
+ // use SDK; everyone else uses CLI (both pms live in the daemon)
3477
+ // 3. neither set → all chats CLI
3478
+ // The per-chat mode lets us soak SDK pm against real traffic in one
3479
+ // chat (Ivan's DM) while keeping partner-facing chats on the
3480
+ // battle-tested CLI path. When both pms run, killChat /shutdown
3481
+ // broadcast to both; everything else routes per-sessionKey via
3482
+ // pickPmFor() based on the chat's set membership.
3483
+ // rc.17: router policy + proxy live in lib/pm-router.js for
3484
+ // testability. Policy parses env config and produces
3485
+ // pickPmKindFor; createPmRouter wraps the cli/sdk pms with the
3486
+ // routed surface.
3487
+ const { sdkActive, sdkAllChats, sdkSomeChats, sdkChatIdSet, pickPmKindFor } = makeRouterPolicy({
3488
+ useSdkAll: USE_SDK,
3489
+ sdkChats: String(process.env.POLYGRAM_SDK_CHATS || '').split(','),
3490
+ getChatIdFromKey,
3491
+ });
3492
+
3493
+ // Shared callbacks: identical instance passed to both pms so a
3494
+ // chat's lifecycle events look the same regardless of which pm
3495
+ // is handling it.
3496
+ const pmOpts = {
3320
3497
  cap,
3321
- spawnFn,
3322
3498
  db,
3323
3499
  logger: console,
3324
3500
  onInit: (sessionKey, event, entry) => {
@@ -3344,6 +3520,14 @@ async function main() {
3344
3520
  const head = entry.pendingQueue?.[0];
3345
3521
  const s = head?.context?.streamer;
3346
3522
  if (s) s.onChunk(partial).catch(() => {});
3523
+ // 0.8.0-rc.16: heartbeat the reactor so long text generation
3524
+ // doesn't trip the 10s STALL → 🥱 / 30s TIMEOUT → 😨 promotion.
3525
+ // Pre-rc.16 the reactor only got setState calls at turn start
3526
+ // (THINKING) and per-tool (CODING/TOOL/...); pure text turns
3527
+ // hit STALL within 10s of streaming. heartbeat() re-arms the
3528
+ // stall timers without changing the visible emoji.
3529
+ const r = head?.context?.reactor;
3530
+ if (r && typeof r.heartbeat === 'function') r.heartbeat();
3347
3531
  },
3348
3532
  onToolUse: (sessionKey, toolName, entry) => {
3349
3533
  const head = entry.pendingQueue?.[0];
@@ -3352,14 +3536,15 @@ async function main() {
3352
3536
  // 0.7.0 (Phase J): opt-in subagent announce. When Claude uses
3353
3537
  // the Task tool to spawn a subagent, post a brief informational
3354
3538
  // message to the chat so the user knows a heavier turn is in
3355
- // progress. Off by default (per-bot or per-chat
3356
- // `announceSubagents: true` opts in). Per-chat debounce 30s
3357
- // prevents announce-storms in tool-heavy turns.
3539
+ // progress. ON by default (rc.9+) set per-chat
3540
+ // `announceSubagents: false` (or per-bot) to silence.
3541
+ // Per-chat debounce 30s prevents announce-storms in tool-heavy
3542
+ // turns.
3358
3543
  const chatCfg = config.chats[entry.chatId] || {};
3359
- const optIn = chatCfg.announceSubagents != null
3360
- ? chatCfg.announceSubagents
3361
- : config.bot?.announceSubagents;
3362
- if (toolName === 'Task' && optIn === true) {
3544
+ const optOut = chatCfg.announceSubagents != null
3545
+ ? chatCfg.announceSubagents === false
3546
+ : config.bot?.announceSubagents === false;
3547
+ if (toolName === 'Task' && !optOut) {
3363
3548
  if (shouldAnnounce(entry.chatId)) {
3364
3549
  announce({
3365
3550
  send: (b, method, params, m) => tg(b, method, params, m),
@@ -3385,24 +3570,26 @@ async function main() {
3385
3570
  // 0.8.0 Phase 2 step 5: SDK auto-compaction observability. Fires
3386
3571
  // when SDK emits SDKCompactBoundaryMessage (between turns or
3387
3572
  // mid-turn — see Phase 0 gate 8.5). Surfaces a quiet system
3388
- // status note to the chat so the user knows context was
3389
- // reorganised. Off by default per-bot (announceCompact !== true).
3573
+ // status note to the chat so the user knows the bot is busy
3574
+ // reorganising context (compaction can take seconds, during
3575
+ // which the bot looks unresponsive). ON by default (rc.12+) —
3576
+ // set per-chat or per-bot `announceCompact: false` to silence.
3390
3577
  // Only fires under SDK pm — the CLI pm has no equivalent event.
3578
+ //
3579
+ // Wording is intentionally non-technical — the user doesn't
3580
+ // care about "compaction" or "tokens"; they just want to know
3581
+ // the bot didn't hang.
3391
3582
  onCompactBoundary: async (sessionKey, msg, entry) => {
3392
3583
  const chatCfg = config.chats[entry.chatId] || {};
3393
- const optIn = chatCfg.announceCompact != null
3394
- ? chatCfg.announceCompact
3395
- : config.bot?.announceCompact;
3396
- if (optIn !== true) return;
3397
- const meta = msg.compact_metadata || {};
3398
- const summary = meta.pre_tokens && meta.post_tokens
3399
- ? ` (${(meta.pre_tokens / 1000).toFixed(0)}K → ${(meta.post_tokens / 1000).toFixed(0)}K tokens)`
3400
- : '';
3584
+ const optOut = chatCfg.announceCompact != null
3585
+ ? chatCfg.announceCompact === false
3586
+ : config.bot?.announceCompact === false;
3587
+ if (optOut) return;
3401
3588
  const threadId = entry.threadId || undefined;
3402
3589
  try {
3403
3590
  await tg(bot, 'sendMessage', {
3404
3591
  chat_id: entry.chatId,
3405
- text: `🗜️ Memory compacted${summary} earlier context summarised.`,
3592
+ text: '💭 Catching up on history, one moment…',
3406
3593
  ...(threadId ? { message_thread_id: threadId } : {}),
3407
3594
  }, { source: 'compact-boundary', botName: BOT_NAME });
3408
3595
  } catch (err) {
@@ -3433,7 +3620,28 @@ async function main() {
3433
3620
  ...(threadId && { message_thread_id: threadId }),
3434
3621
  }, { source: 'respawn-confirm', botName: BOT_NAME }).catch(() => {});
3435
3622
  },
3436
- });
3623
+ };
3624
+
3625
+ // Instantiate the actual pm(s). When sdkActive is false we still
3626
+ // build a CLI pm; SDK pm is null. When sdkActive is true we always
3627
+ // build BOTH so chats outside the SDK list still get the CLI path.
3628
+ const cliPm = new ProcessManager({ ...pmOpts, spawnFn: spawnClaude });
3629
+ const sdkPm = sdkActive
3630
+ ? new ProcessManagerSdk({ ...pmOpts, spawnFn: buildSdkOptions })
3631
+ : null;
3632
+
3633
+ // Routing pm: same surface as a single pm, but per-method routing
3634
+ // through pickPmKindFor(sessionKey). Per-method semantics
3635
+ // documented in lib/pm-router.js.
3636
+ pm = createPmRouter({ cliPm, sdkPm, pickPmKindFor });
3637
+
3638
+ if (sdkAllChats) {
3639
+ console.log('[polygram] using SDK ProcessManager (all chats)');
3640
+ } else if (sdkSomeChats) {
3641
+ console.log(`[polygram] router active: SDK pm for chats {${Array.from(sdkChatIdSet).join(',')}}, CLI pm for everyone else`);
3642
+ } else {
3643
+ console.log('[polygram] using CLI ProcessManager');
3644
+ }
3437
3645
 
3438
3646
  console.log(`polygram (LRU cap=${cap}, SQLite source of truth)`);
3439
3647
  console.log(`Chats: ${Object.entries(config.chats).map(([id, c]) => `${c.name} (${c.model}/${c.effort})`).join(', ')}`);