polygram 0.8.0-rc.1 → 0.8.0-rc.11

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.8.0-rc.1",
4
+ "version": "0.8.0-rc.11",
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",
@@ -1,10 +1,13 @@
1
1
  /**
2
- * Attachment filter — caps count + total size + MIME allowlist.
2
+ * Attachment filter — caps total size + per-file size + MIME allowlist.
3
3
  * Rejected items return a human-readable reason that we surface to the
4
4
  * user and log to the events table.
5
+ *
6
+ * No count cap: per-file (10 MB) and total-size (20 MB) bound resource
7
+ * usage already; an additional count limit just produces "skipped: max
8
+ * count" surprises on Telegram albums (up to 10 photos in one send).
5
9
  */
6
10
 
7
- const MAX_COUNT = 5;
8
11
  const MAX_FILE_BYTES = 10 * 1024 * 1024;
9
12
  const MAX_TOTAL_BYTES = 20 * 1024 * 1024;
10
13
  const MIME_ALLOW = [
@@ -16,7 +19,6 @@ const MIME_ALLOW = [
16
19
  ];
17
20
 
18
21
  function filterAttachments(attachments, opts = {}) {
19
- const maxCount = opts.maxCount ?? MAX_COUNT;
20
22
  const maxFileBytes = opts.maxFileBytes ?? MAX_FILE_BYTES;
21
23
  const maxTotalBytes = opts.maxTotalBytes ?? MAX_TOTAL_BYTES;
22
24
  const mimeAllow = opts.mimeAllow ?? MIME_ALLOW;
@@ -26,10 +28,6 @@ function filterAttachments(attachments, opts = {}) {
26
28
  let totalBytes = 0;
27
29
 
28
30
  for (const a of attachments || []) {
29
- if (accepted.length >= maxCount) {
30
- rejected.push({ att: a, reason: `exceeds max count (${maxCount})` });
31
- continue;
32
- }
33
31
  const mime = a.mime_type || '';
34
32
  if (!mimeAllow.some((re) => re.test(mime))) {
35
33
  rejected.push({ att: a, reason: `mime not allowed (${mime || 'unknown'})` });
@@ -38,7 +36,7 @@ function filterAttachments(attachments, opts = {}) {
38
36
  const reported = a.size || 0;
39
37
  // Telegram sometimes reports file_size=0 or omits it. Pre-0.6.14
40
38
  // those bypassed the cumulative cap entirely (totalBytes + 0 always
41
- // ≤ maxTotalBytes), so 5 size-0 attachments could blow through the
39
+ // ≤ maxTotalBytes), so unsized attachments could blow through the
42
40
  // 20 MB total cap. Treat unknown sizes as worst-case (= per-file
43
41
  // cap) for budgeting; the per-file cap is still enforced live by
44
42
  // the streaming download in polygram.js.
@@ -57,4 +55,4 @@ function filterAttachments(attachments, opts = {}) {
57
55
  return { accepted, rejected, totalBytes };
58
56
  }
59
57
 
60
- module.exports = { filterAttachments, MAX_COUNT, MAX_FILE_BYTES, MAX_TOTAL_BYTES, MIME_ALLOW };
58
+ module.exports = { filterAttachments, MAX_FILE_BYTES, MAX_TOTAL_BYTES, MIME_ALLOW };
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Per-session buffer for mid-turn user follow-ups (autosteer + /steer).
3
+ *
4
+ * 0.8.0-rc.9: lands the steer mechanism that survived production. Earlier
5
+ * rcs pushed `priority:'now'` SDKUserMessages onto the SDK input
6
+ * iterable mid-tool-use; the CLI binary's `m87` gate rejected them with
7
+ * `result.subtype = error_during_execution` because the transcript shape
8
+ * (assistant ending with tool_use → next user message NOT being a
9
+ * tool_result) is malformed per Anthropic's API contract.
10
+ *
11
+ * The mechanism we landed on: append the follow-up to a per-session
12
+ * buffer; on every PostToolBatch hook fire, drain the buffer into the
13
+ * hook's `additionalContext` field wrapped in a `<channel
14
+ * source="user-followup">…</channel>` tag — the same framing Channels
15
+ * MCP uses, which Claude is trained to trust as legitimate
16
+ * out-of-band user context (vs. prompt-injection inside tool output,
17
+ * which the model defends against by refusing to follow).
18
+ *
19
+ * Spike result (post-tool-batch-spike-v2.mjs): with this framing, the
20
+ * marker "spike-marker-9d3e" injected via additionalContext was
21
+ * incorporated verbatim into the assistant's final answer. With the
22
+ * earlier `<user_message_during_turn>` framing, the model recognised
23
+ * it as prompt-injection-shaped and refused.
24
+ *
25
+ * Why a buffer module instead of inlining: per-sessionKey state lives
26
+ * outside the pm and outside polygram.js's handleMessage so both
27
+ * autosteer (handleMessage line ~2418) and /steer (line ~1975) can
28
+ * share it. pm-sdk binds a hook callback per spawn that closes over
29
+ * its sessionKey and drains this buffer.
30
+ *
31
+ * Edge: tool-less turns (Claude answers without firing a tool). The
32
+ * hook never fires, so a queued message would be lost. pm-sdk's
33
+ * onResult handler MUST drain the buffer at turn-end and push the
34
+ * remainder via `inputController.push(..., { shouldQuery: false })`
35
+ * for next-turn injection — no m87 risk because the previous turn
36
+ * ended cleanly with text/end_turn before the push lands.
37
+ */
38
+
39
+ 'use strict';
40
+
41
+ function createAutosteerBuffer() {
42
+ // sessionKey → array of strings (in order of arrival)
43
+ const queues = new Map();
44
+
45
+ function append(sessionKey, text) {
46
+ if (!sessionKey || typeof text !== 'string' || text.length === 0) return false;
47
+ let q = queues.get(sessionKey);
48
+ if (!q) { q = []; queues.set(sessionKey, q); }
49
+ q.push(text);
50
+ return true;
51
+ }
52
+
53
+ function drain(sessionKey) {
54
+ const q = queues.get(sessionKey);
55
+ if (!q || q.length === 0) return [];
56
+ queues.delete(sessionKey);
57
+ return q;
58
+ }
59
+
60
+ function size(sessionKey) {
61
+ return queues.get(sessionKey)?.length ?? 0;
62
+ }
63
+
64
+ function clear(sessionKey) {
65
+ queues.delete(sessionKey);
66
+ }
67
+
68
+ // Format the drained messages as the additionalContext payload that
69
+ // Claude trusts. Multiple messages are joined with a blank line so
70
+ // the model sees them as a sequence within a single channel tag.
71
+ function formatForHook(messages) {
72
+ if (!messages || messages.length === 0) return null;
73
+ const body = messages.join('\n\n');
74
+ return `<channel source="user-followup">\n${body}\n</channel>`;
75
+ }
76
+
77
+ return { append, drain, size, clear, formatForHook };
78
+ }
79
+
80
+ module.exports = { createAutosteerBuffer };
@@ -97,7 +97,10 @@ const USER_MESSAGES = {
97
97
  missingToolInput: '⚠️ Session history looks corrupted. Try /new.',
98
98
  timeout: '⏳ I went quiet too long without finishing. Try resending or simplifying.',
99
99
  format: '⚠️ Invalid request format. Try rephrasing or /new.',
100
- transient5xx: '☁️ Anthropic is temporarily unavailable. Retrying once…',
100
+ // Used both for in-flight retry attempts AND for the post-retry-failed
101
+ // bubble-up message. Avoid promising "retrying once" since by the
102
+ // time the user reads it pm has already retried and given up.
103
+ transient5xx: '☁️ Server hiccup — please try again in a moment.',
101
104
  };
102
105
 
103
106
  // Auto-recovery actions for kinds where the session is irrecoverable
@@ -183,15 +186,16 @@ function classify(err) {
183
186
  }
184
187
 
185
188
  // SDKAssistantMessage.error is a short string code from a fixed
186
- // union — match those directly, not via regex.
189
+ // union — match those directly, not via regex. Result subtypes
190
+ // are checked LATER (after pattern matching) so a more-specific
191
+ // pattern in the message text (e.g. 'HTTP 401' inside an
192
+ // error_during_execution subtype) wins over the generic subtype
193
+ // mapping that defaults the entire error_during_execution class
194
+ // to transient.
187
195
  if (typeof err === 'string') {
188
196
  const sdkMessageError = matchSdkMessageError(err);
189
197
  if (sdkMessageError) return sdkMessageError;
190
198
  }
191
- if (err?.subtype && typeof err.subtype === 'string') {
192
- const sdkResultSubtype = matchSdkResultSubtype(err.subtype);
193
- if (sdkResultSubtype) return sdkResultSubtype;
194
- }
195
199
 
196
200
  const msg = extractMessage(err);
197
201
  for (const [kind, re] of Object.entries(PATTERNS)) {
@@ -205,6 +209,20 @@ function classify(err) {
205
209
  }
206
210
  }
207
211
 
212
+ // After pattern matching: try SDK result subtypes. A bare string
213
+ // like 'error_during_execution' (no message context) lands here
214
+ // and gets the friendly transient5xx kind. Object inputs with a
215
+ // subtype field also land here when their message text didn't
216
+ // match a more specific pattern.
217
+ if (typeof err === 'string') {
218
+ const sdkResultSubtype = matchSdkResultSubtype(err);
219
+ if (sdkResultSubtype) return sdkResultSubtype;
220
+ }
221
+ if (err?.subtype && typeof err.subtype === 'string') {
222
+ const sdkResultSubtype = matchSdkResultSubtype(err.subtype);
223
+ if (sdkResultSubtype) return sdkResultSubtype;
224
+ }
225
+
208
226
  // Fall-through: surface a snippet of the raw error so users at
209
227
  // least know SOMETHING happened. Same shape as before, just
210
228
  // routed through the classifier so callers get a uniform return.
@@ -252,8 +270,15 @@ function matchSdkMessageError(s) {
252
270
 
253
271
  // SDKResultMessage.subtype values (sdk.d.ts:3121). Most are
254
272
  // terminal-error indicators that don't have a clean pattern equivalent.
273
+ //
274
+ // `error_during_execution` is the SDK's catch-all for "something went
275
+ // wrong mid-turn" — could be a transient stream/network blip OR a
276
+ // systemic model issue. We treat it as transient (1 retry is cheap;
277
+ // if it's systemic the second attempt fails fast). Pre-rc.5 this was
278
+ // mapped to 'unknown' which fell through to the default "Hit a snag:
279
+ // error_during_execution" template — leaking the SDK enum to users.
255
280
  const SDK_RESULT_SUBTYPE_MAP = {
256
- error_during_execution: 'unknown',
281
+ error_during_execution: 'transient5xx',
257
282
  error_max_turns: 'format',
258
283
  error_max_budget_usd: 'billing',
259
284
  error_max_structured_output_retries: 'format',
@@ -265,8 +290,12 @@ function matchSdkResultSubtype(s) {
265
290
  return {
266
291
  kind,
267
292
  userMessage: USER_MESSAGES[kind] ?? null,
268
- isTransient: false, // result subtypes don't auto-retry; the
269
- // turn already burned its budget.
293
+ // Derive transience from the kind so error_during_execution →
294
+ // transient5xx isTransient=true, matching the pattern-match
295
+ // branch's behaviour. pm guards retry with firstAssistantSeen=
296
+ // false, which prevents budget waste when the turn already had
297
+ // billable assistant output.
298
+ isTransient: kind === 'transient5xx' || kind === 'rateLimit',
270
299
  autoRecover: AUTO_RECOVER[kind] ?? null,
271
300
  };
272
301
  }
@@ -470,6 +470,7 @@ class ProcessManagerSdk {
470
470
  entry.inputController.push({
471
471
  type: 'user',
472
472
  message: { role: 'user', content: head.prompt },
473
+ parent_tool_use_id: null,
473
474
  });
474
475
  } catch (err) {
475
476
  entry.pendingQueue.shift();
@@ -655,6 +656,7 @@ class ProcessManagerSdk {
655
656
  entry.inputController.push({
656
657
  type: 'user',
657
658
  message: { role: 'user', content: prompt },
659
+ parent_tool_use_id: null,
658
660
  });
659
661
  } catch (err) {
660
662
  const idx = entry.pendingQueue.indexOf(pending);
@@ -754,13 +756,30 @@ class ProcessManagerSdk {
754
756
  * Returns true if push succeeded; false if session not found or
755
757
  * input controller closed.
756
758
  */
757
- steer(sessionKey, text, { shouldQuery = true } = {}) {
759
+ steer(sessionKey, text, { shouldQuery = false } = {}) {
758
760
  const entry = this.procs.get(sessionKey);
759
761
  if (!entry || entry.closed) return false;
760
762
  try {
763
+ // 0.8.0-rc.7 (per v4 plan §0 row 9 + Phase 2 step 1's original
764
+ // shape): push with `shouldQuery: false` so the SDK appends to
765
+ // the transcript without trying to terminate the in-flight turn.
766
+ // The previous default `shouldQuery: true` triggered the CLI
767
+ // binary's `m87` gate (transcript well-formedness check) which
768
+ // emitted `result.subtype = error_during_execution` whenever a
769
+ // plain-text user message arrived while the assistant was mid-
770
+ // tool-use. With shouldQuery=false the message merges into the
771
+ // next natural user turn — the in-flight tools complete first,
772
+ // then the assistant sees the steered context.
773
+ //
774
+ // parent_tool_use_id is required by SDKUserMessage type
775
+ // (sdk.d.ts:3479-3498). The SDK runtime checks `!== null` in
776
+ // multiple places; omitting it falls through to wrong handling
777
+ // branches. The SDK's own `mz.send()` and `pz` replay set it
778
+ // to null explicitly.
761
779
  entry.inputController.push({
762
780
  type: 'user',
763
781
  message: { role: 'user', content: text },
782
+ parent_tool_use_id: null,
764
783
  priority: 'now',
765
784
  shouldQuery,
766
785
  });
@@ -29,19 +29,30 @@
29
29
  // are progressively safer. All endings in this list are in Telegram's
30
30
  // default available reactions as of 2026-04.
31
31
  const STATES = {
32
- QUEUED: { label: 'queued', chain: ['👀', '🤔'] },
33
- THINKING: { label: 'thinking', chain: ['🤔'] },
34
- CODING: { label: 'coding', chain: ['👨‍💻', '✍', '🤔'] },
35
- WEB: { label: 'web', chain: ['⚡', '🔥', '🤔'] },
36
- TOOL: { label: 'tool', chain: ['🔥', '🤔'] },
37
- WRITING: { label: 'writing', chain: ['✍', '🤔'] },
38
- DONE: { label: 'done', chain: ['👍'] },
39
- ERROR: { label: 'error', chain: ['🤯', '🤔'] },
40
- STALL: { label: 'stall', chain: ['🥱', '🤔'] },
41
- TIMEOUT: { label: 'timeout', chain: ['😨', '🤯'] },
32
+ QUEUED: { label: 'queued', chain: ['👀', '🤔'] },
33
+ THINKING: { label: 'thinking', chain: ['🤔'] },
34
+ CODING: { label: 'coding', chain: ['👨‍💻', '✍', '🤔'] },
35
+ WEB: { label: 'web', chain: ['⚡', '🔥', '🤔'] },
36
+ TOOL: { label: 'tool', chain: ['🔥', '🤔'] },
37
+ WRITING: { label: 'writing', chain: ['✍', '🤔'] },
38
+ // 0.8.0-rc.11: terminal "your follow-up was incorporated into the
39
+ // in-flight turn" state. Used by polygram's autosteer block when a
40
+ // mid-turn user message is buffered for the next PostToolBatch
41
+ // injection.
42
+ AUTOSTEERED: { label: 'autosteered', chain: ['✍', '👀'] },
43
+ DONE: { label: 'done', chain: ['👍'] },
44
+ ERROR: { label: 'error', chain: ['🤯', '🤔'] },
45
+ STALL: { label: 'stall', chain: ['🥱', '🤔'] },
46
+ TIMEOUT: { label: 'timeout', chain: ['😨', '🤯'] },
42
47
  };
43
48
 
44
- const TERMINAL_STATES = new Set(['DONE', 'ERROR', 'TIMEOUT']);
49
+ // Terminal states bypass throttle, disarm stall promotion, and the
50
+ // reactor stays at this emoji until explicitly cleared. AUTOSTEERED
51
+ // is included so setState('AUTOSTEERED') flushes immediately
52
+ // (matters because the autosteer code path returns from
53
+ // handleMessage right after — we don't want the apply to be
54
+ // scheduled-and-cancelled by reactor.stop in the outer finally).
55
+ const TERMINAL_STATES = new Set(['DONE', 'ERROR', 'TIMEOUT', 'AUTOSTEERED']);
45
56
  const DEFAULT_THROTTLE_MS = 800;
46
57
  // 0.7.4 (item A): after this long with no setState() call (Claude is
47
58
  // silently chugging on a long tool / model latency), auto-flip to STALL
@@ -132,24 +143,40 @@ function createReactionManager({
132
143
  let stallTimer = null;
133
144
  let freezeTimer = null;
134
145
  let stopped = false;
146
+ // 0.8.0-rc.11: serialize Telegram setMessageReaction calls. Without
147
+ // this, multiple flush()es race at the network layer because each
148
+ // calls `await apply(emoji)` from a separate stack — Telegram
149
+ // processes them in arbitrary order and the FINAL visible state is
150
+ // whichever apply landed last. Symptom: 👀 stuck on autosteered
151
+ // messages when the QUEUED apply landed AFTER our explicit ✍ apply.
152
+ // Chaining all applies through `applyChain` guarantees they're sent
153
+ // to Telegram in setState() invocation order.
154
+ let applyChain = Promise.resolve();
135
155
  // States the auto-stall path may transition to. Once we've already
136
156
  // shown STALL or TIMEOUT we don't downgrade or rearm — only an
137
157
  // explicit setState() call (Claude resumed) can move us forward.
138
158
  const STALL_PROMOTABLE = new Set(['THINKING', 'CODING', 'WEB', 'TOOL', 'WRITING']);
139
159
 
140
160
  const flush = async (stateName) => {
141
- if (stopped) return;
161
+ if (stopped && !TERMINAL_STATES.has(stateName)) return;
142
162
  const spec = STATES[stateName];
143
163
  if (!spec) return;
144
164
  const emoji = resolveEmoji(spec.chain, availableEmojis);
145
165
  if (emoji === currentEmoji) return;
146
166
  currentEmoji = emoji;
147
167
  lastFlushTs = Date.now();
148
- try {
149
- await apply(emoji);
150
- } catch (err) {
151
- logError(`reaction apply failed (${stateName} → ${emoji}): ${err?.message || err}`);
152
- }
168
+ // Chain through applyChain so concurrent flushes are sent to
169
+ // Telegram serially in invocation order. Returning the chain
170
+ // promise lets callers await this specific flush completing.
171
+ const myApply = applyChain.then(async () => {
172
+ try {
173
+ await apply(emoji);
174
+ } catch (err) {
175
+ logError(`reaction apply failed (${stateName} → ${emoji}): ${err?.message || err}`);
176
+ }
177
+ });
178
+ applyChain = myApply;
179
+ return myApply;
153
180
  };
154
181
 
155
182
  const clearStallTimers = () => {
@@ -217,8 +244,16 @@ function createReactionManager({
217
244
  clearStallTimers();
218
245
  if (currentEmoji == null) return;
219
246
  currentEmoji = null;
220
- try { await apply(null); }
221
- catch (err) { logError(`reaction clear failed: ${err?.message || err}`); }
247
+ // Same applyChain serialization as flush — clear() is a state
248
+ // transition, just to "no emoji". Without chaining, a clear()
249
+ // racing with a pending apply (e.g. THINKING flush in flight)
250
+ // could land BEFORE that apply, leaving the emoji visible.
251
+ const myApply = applyChain.then(async () => {
252
+ try { await apply(null); }
253
+ catch (err) { logError(`reaction clear failed: ${err?.message || err}`); }
254
+ });
255
+ applyChain = myApply;
256
+ return myApply;
222
257
  };
223
258
 
224
259
  const stop = () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.8.0-rc.1",
3
+ "version": "0.8.0-rc.11",
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
@@ -31,6 +31,7 @@ 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 } = require('./lib/autosteer-buffer');
34
35
  const agentLoader = require('./lib/agent-loader');
35
36
  const USE_SDK = process.env.POLYGRAM_USE_SDK === '1';
36
37
  const { createSender } = require('./lib/telegram');
@@ -698,6 +699,14 @@ function formatPrompt(msg, sessionCtx, attachments = []) {
698
699
 
699
700
  let pm = null; // ProcessManager, created in main()
700
701
 
702
+ // 0.8.0-rc.9: per-session autosteer buffer. Holds user follow-ups
703
+ // that arrive mid-turn so the SDK pm's PostToolBatch hook can drain
704
+ // them into `additionalContext` on each tool boundary. Replaces the
705
+ // rc.6/rc.7 approach of pushing priority:'now' SDKUserMessages
706
+ // directly (which violated the SDK's m87 transcript-shape gate when
707
+ // the assistant was mid-tool-use).
708
+ const autosteerBuffer = createAutosteerBuffer();
709
+
701
710
  function spawnClaude(sessionKey, ctx) {
702
711
  const { chatConfig, existingSessionId, label, chatId } = ctx;
703
712
  // 0.7.3: Claude Code's Chrome-extension integration (browser
@@ -817,6 +826,40 @@ function buildSdkOptions(sessionKey, ctx) {
817
826
  const useCanUseTool = apprCfg && apprCfg.adminChatId
818
827
  && Array.isArray(apprCfg.gatedTools) && apprCfg.gatedTools.length > 0;
819
828
 
829
+ // 0.8.0-rc.9: PostToolBatch hook drains the autosteer buffer for
830
+ // this session and injects queued user follow-ups as
831
+ // `additionalContext` on each tool boundary. Framing matters:
832
+ // wrapping in `<channel source="user-followup">…</channel>` is
833
+ // what Claude is trained to trust as legitimate out-of-band user
834
+ // context (verified live via post-tool-batch-spike-v2.mjs); the
835
+ // earlier `<user_message_during_turn>` framing tripped the
836
+ // model's prompt-injection defense and got refused.
837
+ const postToolBatchHook = async () => {
838
+ try {
839
+ const drained = autosteerBuffer.drain(sessionKey);
840
+ if (drained.length === 0) return { continue: true };
841
+ const additionalContext = autosteerBuffer.formatForHook(drained);
842
+ logEvent('autosteer-hook-drained', {
843
+ chat_id: ctx?.chatId ?? null,
844
+ session_key: sessionKey,
845
+ message_count: drained.length,
846
+ });
847
+ return {
848
+ continue: true,
849
+ hookSpecificOutput: {
850
+ hookEventName: 'PostToolBatch',
851
+ additionalContext,
852
+ },
853
+ };
854
+ } catch (err) {
855
+ console.error(`[${sessionKey}] PostToolBatch hook error: ${err.message}`);
856
+ // Never throw out of a hook — the SDK may treat it as a hard
857
+ // fail (`stop_hook_prevented` result subtype). Drop the
858
+ // queued messages on the floor; the user can re-send.
859
+ return { continue: true };
860
+ }
861
+ };
862
+
820
863
  const baseOpts = {
821
864
  model: chatConfig.model || config.defaults.model,
822
865
  effort: chatConfig.effort || config.defaults.effort,
@@ -828,6 +871,9 @@ function buildSdkOptions(sessionKey, ctx) {
828
871
  permissionMode: useCanUseTool ? 'default' : 'bypassPermissions',
829
872
  allowDangerouslySkipPermissions: !useCanUseTool,
830
873
  ...(useCanUseTool && { canUseTool: makeCanUseTool(sessionKey) }),
874
+ hooks: {
875
+ PostToolBatch: [{ hooks: [postToolBatchHook] }],
876
+ },
831
877
  executable: 'node',
832
878
  ...(existingSessionId && { resume: existingSessionId }),
833
879
  ...(process.env.POLYGRAM_CLAUDE_BIN && {
@@ -1709,16 +1755,38 @@ async function handleConfigCallback(ctx) {
1709
1755
  user: cmdUser, user_id: cmdUserId, source: 'inline-button',
1710
1756
  }), `log ${setting} change`);
1711
1757
 
1712
- // Graceful respawn of the topic's session that the card is in. With
1758
+ // Graceful application of the change to the topic's session. With
1713
1759
  // isolateTopics=false sessionKey is the chat (one shared session). With
1714
1760
  // isolateTopics=true sessionKey carries the topic, so other topics'
1715
1761
  // 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.
1762
+ // only affect the user's own context.
1763
+ //
1764
+ // CLI pm: requestRespawn drains pending turns then kills the process;
1765
+ // the next user message spawns fresh with the updated chatConfig.
1766
+ // SDK pm: applies live to the running Query via setModel /
1767
+ // applyFlagSettings — no respawn needed, change takes effect for the
1768
+ // rest of the in-flight turn AND all future ones. Falls back to
1769
+ // {killed: false} if neither method is available, leaving the new
1770
+ // chatConfig value to be picked up by the next cold spawn.
1718
1771
  const callbackThreadId = ctx.callbackQuery.message?.message_thread_id?.toString() || null;
1719
1772
  const callbackSessionKey = getSessionKey(chatId, callbackThreadId, chatConfig);
1720
1773
  const reason = setting === 'model' ? 'model-change' : 'effort-change';
1721
- const respawn = pm.requestRespawn(callbackSessionKey, reason);
1774
+ // Feature-detect on the routed pm for this specific session, not on
1775
+ // the router itself (the router exposes every method as a forwarding
1776
+ // shim so `typeof pm.X` is always 'function').
1777
+ const pmForCb = pm.pickFor(callbackSessionKey);
1778
+ let respawn;
1779
+ if (typeof pmForCb.requestRespawn === 'function') {
1780
+ respawn = pmForCb.requestRespawn(callbackSessionKey, reason);
1781
+ } else if (setting === 'effort' && typeof pmForCb.applyFlagSettings === 'function') {
1782
+ const ok = await pmForCb.applyFlagSettings(callbackSessionKey, { effortLevel: value });
1783
+ respawn = { killed: ok };
1784
+ } else if (setting === 'model' && typeof pmForCb.setModel === 'function') {
1785
+ const ok = await pmForCb.setModel(callbackSessionKey, value);
1786
+ respawn = { killed: ok };
1787
+ } else {
1788
+ respawn = { killed: false };
1789
+ }
1722
1790
  const anyActive = !respawn.killed;
1723
1791
 
1724
1792
  // Re-render the card with updated ✓ + the same help text shown initially.
@@ -1873,8 +1941,8 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1873
1941
  // usage report. Only meaningful under SDK pm (CLI pm has no
1874
1942
  // getContextUsage equivalent); CLI path replies with a hint.
1875
1943
  if (botAllowsCommands && text === '/context') {
1876
- if (!USE_SDK) {
1877
- await sendReply('📚 /context requires the SDK pm (set POLYGRAM_USE_SDK=1 to enable).');
1944
+ if (!pm.isSdkFor(sessionKey)) {
1945
+ await sendReply('📚 /context requires the SDK pm. This chat is on the CLI pm path.');
1878
1946
  return;
1879
1947
  }
1880
1948
  const entry = pm.get(sessionKey);
@@ -1885,13 +1953,18 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1885
1953
  }
1886
1954
  try {
1887
1955
  const u = await q.getContextUsage();
1888
- const pct = ((u?.percentage ?? 0) * 100).toFixed(0);
1956
+ // SDK returns percentage in 0-100 scale (verified rc.3 prod
1957
+ // — saw "77" for a 77%-used context). Display directly.
1958
+ const pct = (u?.percentage ?? 0).toFixed(0);
1889
1959
  const total = (u?.totalTokens ?? 0).toLocaleString();
1890
1960
  const max = (u?.maxTokens ?? 0).toLocaleString();
1891
1961
  const lines = [`📚 Context: ${total} / ${max} tokens (${pct}%)`];
1892
1962
  if (u?.model) lines.push(`Model: ${u.model}`);
1893
1963
  if (u?.isAutoCompactEnabled && u?.autoCompactThreshold) {
1894
- const thrPct = (u.autoCompactThreshold * 100).toFixed(0);
1964
+ // autoCompactThreshold scale is currently unverified; assume
1965
+ // matches percentage (0-100). If it turns out to be 0-1 we'll
1966
+ // see something like "Auto-compact at 0%" and can flip back.
1967
+ const thrPct = u.autoCompactThreshold.toFixed(0);
1895
1968
  lines.push(`Auto-compact at ${thrPct}%.`);
1896
1969
  }
1897
1970
  // Top-3 categories by token cost so the user knows where the
@@ -1914,9 +1987,10 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1914
1987
  }
1915
1988
  if (botAllowsCommands && (text === '/new' || text === '/reset')) {
1916
1989
  let drained = 0;
1917
- if (typeof pm.resetSession === 'function') {
1990
+ const target = pm.pickFor(sessionKey);
1991
+ if (typeof target.resetSession === 'function') {
1918
1992
  try {
1919
- const r = await pm.resetSession(sessionKey, { reason: text.slice(1) });
1993
+ const r = await target.resetSession(sessionKey, { reason: text.slice(1) });
1920
1994
  drained = r?.drainedPendings ?? 0;
1921
1995
  } catch (err) {
1922
1996
  console.error(`[${label}] resetSession ${text}: ${err.message}`);
@@ -1938,48 +2012,39 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1938
2012
  await sendReply('✨ Started a fresh session.');
1939
2013
  return;
1940
2014
  }
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 };
2015
+ // 0.8.0-rc.9: /steer command removed. Mid-turn user input is
2016
+ // handled implicitly by autosteer any follow-up message during
2017
+ // an in-flight SDK turn flows through autosteerBuffer +
2018
+ // PostToolBatch hook. No explicit command needed; matches Claude
2019
+ // Code interactive UX where you just keep typing.
2020
+ // Graceful application of a model/effort change to the user's CURRENT
2021
+ // session only. With isolateTopics=false the sessionKey is just the
2022
+ // chat (one shared session for the whole chat — every topic
2023
+ // respawns implicitly). With isolateTopics=true each topic is a
2024
+ // separate session, and a /model in topic A should NOT disturb
2025
+ // topic B's in-flight turn or post a phantom "✓ Using sonnet now"
2026
+ // in a topic that didn't ask.
2027
+ //
2028
+ // CLI pm: requestRespawn drains pending turns then kills the process;
2029
+ // the next user message spawns fresh with the updated chatConfig.
2030
+ // SDK pm: applies live to the running Query via setModel /
2031
+ // applyFlagSettings — no respawn needed, change takes effect for
2032
+ // the rest of the in-flight turn AND all future ones.
2033
+ const applyConfigChange = async (reason, setting, value) => {
2034
+ const target = pm.pickFor(sessionKey);
2035
+ if (typeof target.requestRespawn === 'function') {
2036
+ const res = target.requestRespawn(sessionKey, reason);
2037
+ return { queued: res.queued, anyActive: !res.killed };
2038
+ }
2039
+ if (setting === 'effort' && typeof target.applyFlagSettings === 'function') {
2040
+ const ok = await target.applyFlagSettings(sessionKey, { effortLevel: value });
2041
+ return { queued: 0, anyActive: !ok };
2042
+ }
2043
+ if (setting === 'model' && typeof target.setModel === 'function') {
2044
+ const ok = await target.setModel(sessionKey, value);
2045
+ return { queued: 0, anyActive: !ok };
2046
+ }
2047
+ return { queued: 0, anyActive: false };
1983
2048
  };
1984
2049
 
1985
2050
  if (botAllowsCommands && text.startsWith('/model ')) {
@@ -1993,7 +2058,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1993
2058
  old_value: oldModel, new_value: newModel,
1994
2059
  user: cmdUser, user_id: cmdUserId, source: 'command',
1995
2060
  }), 'log model change');
1996
- const { anyActive } = requestRespawnForSession('model-change');
2061
+ const { anyActive } = await applyConfigChange('model-change', 'model', newModel);
1997
2062
  const ver = MODEL_VERSIONS[newModel] || newModel;
1998
2063
  const suffix = anyActive ? ` — I'll switch when I finish` : '';
1999
2064
  await sendReply(`Model → ${newModel} (${ver})${suffix}`);
@@ -2013,7 +2078,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2013
2078
  old_value: oldEffort, new_value: newEffort,
2014
2079
  user: cmdUser, user_id: cmdUserId, source: 'command',
2015
2080
  }), 'log effort change');
2016
- const { anyActive } = requestRespawnForSession('effort-change');
2081
+ const { anyActive } = await applyConfigChange('effort-change', 'effort', newEffort);
2017
2082
  const suffix = anyActive ? ` — I'll switch when I finish` : '';
2018
2083
  await sendReply(`Effort → ${newEffort}${suffix}`);
2019
2084
  } else {
@@ -2366,34 +2431,54 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2366
2431
  // chatConfig.autosteer === false). CLI pm always falls through
2367
2432
  // to the queue-FIFO path (no steer primitive on stream-json).
2368
2433
  //
2369
- // The steered message gets a 🛞 reaction so the user knows it
2434
+ // The steered message gets a reaction so the user knows it
2370
2435
  // landed; no separate reply is generated (the in-flight turn's
2371
2436
  // response covers both messages, OpenClaw-style).
2437
+ //
2438
+ // Reaction emoji must be from Telegram's curated allowlist
2439
+ // (~60 standard emoji per core.telegram.org/bots/api#availablereactions).
2440
+ // 🛞 (steering wheel) is NOT on it — Telegram returns
2441
+ // 400: REACTION_INVALID. ✍ ("writing/noting") is on the list and
2442
+ // conveys "incorporating this".
2372
2443
  const chatAutosteer = chatConfig.autosteer != null
2373
2444
  ? chatConfig.autosteer
2374
2445
  : config.bot?.autosteer;
2375
- const autosteerEnabled = USE_SDK && chatAutosteer !== false;
2376
- if (autosteerEnabled && typeof pm.steer === 'function' && pm.has(sessionKey)) {
2446
+ // 0.8.0-rc.9: autosteer now drives through autosteerBuffer +
2447
+ // PostToolBatch hook (in buildSdkOptions), not pm.steer's direct
2448
+ // inputController push. The hook fires on every tool boundary
2449
+ // and injects queued follow-ups as <channel source="user-followup">
2450
+ // additionalContext — the SDK-trusted framing that survives the
2451
+ // m87 transcript-shape gate.
2452
+ //
2453
+ // We still gate on the SDK pm path: under CLI pm there's no
2454
+ // PostToolBatch hook surface, so autosteer falls through to the
2455
+ // regular FIFO send (same UX as 0.7.x).
2456
+ const autosteerEnabled = chatAutosteer !== false
2457
+ && pm.isSdkFor(sessionKey);
2458
+ if (autosteerEnabled && pm.has(sessionKey)) {
2377
2459
  const entry = pm.get(sessionKey);
2378
2460
  if (entry?.inFlight) {
2379
- const ok = pm.steer(sessionKey, prompt);
2461
+ const ok = autosteerBuffer.append(sessionKey, prompt);
2380
2462
  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
- });
2391
2463
  logEvent('autosteer', {
2392
2464
  chat_id: chatId, msg_id: msg.message_id,
2393
2465
  text_len: prompt?.length ?? 0,
2394
2466
  });
2395
2467
  stopTyping();
2396
- reactor.stop();
2468
+ // 0.8.0-rc.11: route the ✍ ack through the reactor's
2469
+ // serialized apply chain. Pre-rc.11 we used a direct
2470
+ // setMessageReaction(✍) racing with the reactor's
2471
+ // QUEUED→👀 apply AND a follow-up reactor.clear() — three
2472
+ // concurrent network calls, final state was whichever
2473
+ // landed last at Telegram. Symptom: 👀 sometimes stuck,
2474
+ // ✍ sometimes vanished, reactions disappeared "almost
2475
+ // immediately" or got stuck arbitrarily.
2476
+ //
2477
+ // setState('AUTOSTEERED') is terminal so it bypasses the
2478
+ // 800ms throttle and flushes synchronously through
2479
+ // applyChain — so it serializes after any in-flight
2480
+ // QUEUED apply and lands as the final visible reaction.
2481
+ await reactor.setState('AUTOSTEERED');
2397
2482
  markReplied();
2398
2483
  return;
2399
2484
  }
@@ -2454,8 +2539,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2454
2539
  // Only fires when pm.resetSession is available (SDK pm
2455
2540
  // path); CLI pm doesn't have the method.
2456
2541
  const cls = classifyError(result.error);
2457
- if (cls.autoRecover === 'reset_session' && typeof pm.resetSession === 'function') {
2458
- pm.resetSession(sessionKey, { reason: cls.kind })
2542
+ const recoverTarget = pm.pickFor(sessionKey);
2543
+ if (cls.autoRecover === 'reset_session' && typeof recoverTarget.resetSession === 'function') {
2544
+ recoverTarget.resetSession(sessionKey, { reason: cls.kind })
2459
2545
  .catch((err) => console.error(`[${label}] auto-reset failed: ${err.message}`));
2460
2546
  logEvent('auto-recover', {
2461
2547
  chat_id: chatId, kind: cls.kind, action: 'reset_session',
@@ -2484,16 +2570,20 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2484
2570
  // SDK pm only — CLI pm has no equivalent (no Query object,
2485
2571
  // no getContextUsage). Per-bot opt-out via
2486
2572
  // config.bot.contextHint = false.
2487
- if (USE_SDK && config.bot?.contextHint !== false) {
2573
+ if (pm.isSdkFor(sessionKey) && config.bot?.contextHint !== false) {
2488
2574
  const entry = pm.get(sessionKey);
2489
2575
  const q = entry?.query;
2490
2576
  if (q && typeof q.getContextUsage === 'function') {
2491
2577
  q.getContextUsage().then((usage) => {
2578
+ // SDK returns percentage in 0-100 scale, not 0-1.
2579
+ // Pre-rc.4 we treated it as a 0-1 ratio and multiplied
2580
+ // by 100, which displayed "7700% full" for a 77%-used
2581
+ // context (and fired below the intended 85% threshold).
2492
2582
  const pct = usage?.percentage ?? 0;
2493
- if (pct < 0.85) return;
2583
+ if (pct < 85) return;
2494
2584
  return tg(bot, 'sendMessage', {
2495
2585
  chat_id: chatId,
2496
- text: `📚 Context window ${(pct * 100).toFixed(0)}% full. Send /new to start fresh — older messages will start dropping soon.`,
2586
+ text: `📚 Context window ${pct.toFixed(0)}% full. Send /new to start fresh — older messages will start dropping soon.`,
2497
2587
  ...(threadId ? { message_thread_id: threadId } : {}),
2498
2588
  }, { source: 'context-full-hint', botName: BOT_NAME });
2499
2589
  }).catch((err) => {
@@ -2512,6 +2602,26 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2512
2602
  // those still markReplied silently.
2513
2603
  if (result.text === 'NO_REPLY') { markReplied(); return; }
2514
2604
  if (!result.text) {
2605
+ // 0.8.0-rc.7: tool-only completion is NOT an error. Under SDK
2606
+ // pm, a turn that ends after running tools (no closing text
2607
+ // block) leaves result.text empty even though the bot DID
2608
+ // respond — via tool side effects the user already saw. Don't
2609
+ // post a "No response generated" apology in that case; it's
2610
+ // confusing and it spams the chat. Just clear the reactor
2611
+ // (otherwise 👀 stays stuck — reactor.stop() doesn't remove
2612
+ // the emoji visually) and silently mark replied.
2613
+ const toolOnlyTurn = (result.metrics?.numToolUses ?? 0) > 0
2614
+ && (result.metrics?.numAssistantMessages ?? 0) > 0;
2615
+ if (toolOnlyTurn) {
2616
+ await reactor.clear().catch(() => {});
2617
+ logEvent('tool-only-completion', {
2618
+ chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
2619
+ num_tool_uses: result.metrics?.numToolUses,
2620
+ num_assistant_messages: result.metrics?.numAssistantMessages,
2621
+ });
2622
+ markReplied();
2623
+ return;
2624
+ }
2515
2625
  // 0.7.1: if the fallback send itself fails, throw rather than
2516
2626
  // silently markReplied — the user gets nothing AND the inbound
2517
2627
  // is marked replied so boot replay won't redispatch. Same
@@ -2537,6 +2647,12 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2537
2647
  logEvent('telegram-empty-response-fallback', {
2538
2648
  chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
2539
2649
  });
2650
+ // 0.8.0-rc.7: clear the THINKING/QUEUED emoji on the user's
2651
+ // message so 👀 doesn't stay stuck after the apology lands.
2652
+ // reactor.stop() (in the finally block) only kills timers; it
2653
+ // does NOT remove the visible emoji. Without this clear, the
2654
+ // user sees 👀 next to their message indefinitely.
2655
+ await reactor.clear().catch(() => {});
2540
2656
  markReplied();
2541
2657
  return;
2542
2658
  }
@@ -2716,7 +2832,7 @@ function createBot(token) {
2716
2832
  // Cached once @botUsername is known — was recompiling per inbound msg.
2717
2833
  let mentionRe = null;
2718
2834
  // 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|$)/;
2835
+ const ADMIN_CMD_RE = /^\/(model|effort|config|pair-code|pairings|unpair|new|reset|context)(\s|$)/;
2720
2836
  const PAIR_CLAIM_RE = /^\/pair\s+\S+/;
2721
2837
 
2722
2838
  // The filter in main() guarantees config.chats only contains chats owned
@@ -2860,14 +2976,15 @@ function createBot(token) {
2860
2976
  // sessionKey is the chat itself, so killing one session is
2861
2977
  // the same as killing the chat — behavior unchanged for the
2862
2978
  // common case.
2863
- if (USE_SDK && typeof pm.interrupt === 'function') {
2864
- await pm.interrupt(sessionKey).catch((err) =>
2979
+ const stopTarget = pm.pickFor(sessionKey);
2980
+ if (typeof stopTarget.interrupt === 'function') {
2981
+ await stopTarget.interrupt(sessionKey).catch((err) =>
2865
2982
  console.error(`[${BOT_NAME}] interrupt failed: ${err.message}`));
2866
- if (typeof pm.drainQueue === 'function') {
2867
- pm.drainQueue(sessionKey, 'INTERRUPTED');
2983
+ if (typeof stopTarget.drainQueue === 'function') {
2984
+ stopTarget.drainQueue(sessionKey, 'INTERRUPTED');
2868
2985
  }
2869
2986
  } else {
2870
- await pm.kill(sessionKey).catch((err) =>
2987
+ await stopTarget.kill(sessionKey).catch((err) =>
2871
2988
  console.error(`[${BOT_NAME}] abort kill failed: ${err.message}`));
2872
2989
  }
2873
2990
  logEvent('abort-requested', {
@@ -3308,17 +3425,37 @@ async function main() {
3308
3425
  });
3309
3426
 
3310
3427
  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({
3428
+
3429
+ // 0.8.0-rc.6: per-chat pm selection. Three modes:
3430
+ // 1. POLYGRAM_USE_SDK=1 with no POLYGRAM_SDK_CHATS list → all chats SDK
3431
+ // 2. POLYGRAM_SDK_CHATS=id1,id2,... → those chats
3432
+ // use SDK; everyone else uses CLI (both pms live in the daemon)
3433
+ // 3. neither set → all chats CLI
3434
+ // The per-chat mode lets us soak SDK pm against real traffic in one
3435
+ // chat (Ivan's DM) while keeping partner-facing chats on the
3436
+ // battle-tested CLI path. When both pms run, killChat /shutdown
3437
+ // broadcast to both; everything else routes per-sessionKey via
3438
+ // pickPmFor() based on the chat's set membership.
3439
+ const sdkChatIdSet = new Set(
3440
+ String(process.env.POLYGRAM_SDK_CHATS || '')
3441
+ .split(',').map((s) => s.trim()).filter(Boolean)
3442
+ );
3443
+ const sdkAllChats = USE_SDK && sdkChatIdSet.size === 0;
3444
+ const sdkSomeChats = sdkChatIdSet.size > 0;
3445
+ const sdkActive = sdkAllChats || sdkSomeChats;
3446
+
3447
+ function pickPmKindFor(sessionKey) {
3448
+ if (sdkAllChats) return 'sdk';
3449
+ if (!sdkSomeChats) return 'cli';
3450
+ const chatId = String(getChatIdFromKey(sessionKey) ?? '');
3451
+ return sdkChatIdSet.has(chatId) ? 'sdk' : 'cli';
3452
+ }
3453
+
3454
+ // Shared callbacks: identical instance passed to both pms so a
3455
+ // chat's lifecycle events look the same regardless of which pm
3456
+ // is handling it.
3457
+ const pmOpts = {
3320
3458
  cap,
3321
- spawnFn,
3322
3459
  db,
3323
3460
  logger: console,
3324
3461
  onInit: (sessionKey, event, entry) => {
@@ -3352,14 +3489,15 @@ async function main() {
3352
3489
  // 0.7.0 (Phase J): opt-in subagent announce. When Claude uses
3353
3490
  // the Task tool to spawn a subagent, post a brief informational
3354
3491
  // 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.
3492
+ // progress. ON by default (rc.9+) set per-chat
3493
+ // `announceSubagents: false` (or per-bot) to silence.
3494
+ // Per-chat debounce 30s prevents announce-storms in tool-heavy
3495
+ // turns.
3358
3496
  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) {
3497
+ const optOut = chatCfg.announceSubagents != null
3498
+ ? chatCfg.announceSubagents === false
3499
+ : config.bot?.announceSubagents === false;
3500
+ if (toolName === 'Task' && !optOut) {
3363
3501
  if (shouldAnnounce(entry.chatId)) {
3364
3502
  announce({
3365
3503
  send: (b, method, params, m) => tg(b, method, params, m),
@@ -3433,7 +3571,104 @@ async function main() {
3433
3571
  ...(threadId && { message_thread_id: threadId }),
3434
3572
  }, { source: 'respawn-confirm', botName: BOT_NAME }).catch(() => {});
3435
3573
  },
3436
- });
3574
+ };
3575
+
3576
+ // Instantiate the actual pm(s). When sdkActive is false we still
3577
+ // build a CLI pm; SDK pm is null. When sdkActive is true we always
3578
+ // build BOTH so chats outside the SDK list still get the CLI path.
3579
+ const cliPm = new ProcessManager({ ...pmOpts, spawnFn: spawnClaude });
3580
+ const sdkPm = sdkActive
3581
+ ? new ProcessManagerSdk({ ...pmOpts, spawnFn: buildSdkOptions })
3582
+ : null;
3583
+
3584
+ // Routing pm: same surface as a single pm, but per-method routing
3585
+ // through pickPmKindFor(sessionKey). Methods that don't take a
3586
+ // sessionKey (killChat by chatId, shutdown) broadcast to both.
3587
+ // For optional methods (steer / setModel / applyFlagSettings /
3588
+ // requestRespawn / drainQueue / interrupt / resetSession) we
3589
+ // forward when the routed pm has the method and return a
3590
+ // sentinel otherwise — so feature-detection at the call site
3591
+ // still works via `typeof pm.pickFor(sessionKey).X === 'function'`.
3592
+ pm = (() => {
3593
+ function routedPm(sessionKey) {
3594
+ return pickPmKindFor(sessionKey) === 'sdk' && sdkPm ? sdkPm : cliPm;
3595
+ }
3596
+ const router = {
3597
+ pickFor: routedPm,
3598
+ isSdkFor(sessionKey) {
3599
+ return pickPmKindFor(sessionKey) === 'sdk' && !!sdkPm;
3600
+ },
3601
+ has(sessionKey) { return routedPm(sessionKey).has(sessionKey); },
3602
+ get(sessionKey) { return routedPm(sessionKey).get(sessionKey); },
3603
+ getOrSpawn(sessionKey, ctx) { return routedPm(sessionKey).getOrSpawn(sessionKey, ctx); },
3604
+ send(sessionKey, prompt, opts) { return routedPm(sessionKey).send(sessionKey, prompt, opts); },
3605
+ kill(sessionKey) { return routedPm(sessionKey).kill(sessionKey); },
3606
+ async killChat(chatId) {
3607
+ const tasks = [cliPm.killChat(chatId)];
3608
+ if (sdkPm) tasks.push(sdkPm.killChat(chatId));
3609
+ await Promise.all(tasks);
3610
+ },
3611
+ async shutdown() {
3612
+ const tasks = [cliPm.shutdown()];
3613
+ if (sdkPm) tasks.push(sdkPm.shutdown());
3614
+ await Promise.all(tasks);
3615
+ },
3616
+ // Optional methods. The router returns a function — but the
3617
+ // function returns a sentinel if the routed pm doesn't have
3618
+ // the method. Sites that want feature-detection should use
3619
+ // `pm.pickFor(sessionKey)` and check `typeof X === 'function'`
3620
+ // there instead of probing `pm.X` directly.
3621
+ steer(sessionKey, ...args) {
3622
+ const target = routedPm(sessionKey);
3623
+ return typeof target.steer === 'function' ? target.steer(sessionKey, ...args) : false;
3624
+ },
3625
+ resetSession(sessionKey, opts) {
3626
+ const target = routedPm(sessionKey);
3627
+ return typeof target.resetSession === 'function'
3628
+ ? target.resetSession(sessionKey, opts)
3629
+ : Promise.resolve({ closed: false, drainedPendings: 0 });
3630
+ },
3631
+ applyFlagSettings(sessionKey, settings) {
3632
+ const target = routedPm(sessionKey);
3633
+ return typeof target.applyFlagSettings === 'function'
3634
+ ? target.applyFlagSettings(sessionKey, settings)
3635
+ : Promise.resolve(false);
3636
+ },
3637
+ setModel(sessionKey, model) {
3638
+ const target = routedPm(sessionKey);
3639
+ return typeof target.setModel === 'function'
3640
+ ? target.setModel(sessionKey, model)
3641
+ : Promise.resolve(false);
3642
+ },
3643
+ requestRespawn(sessionKey, reason) {
3644
+ const target = routedPm(sessionKey);
3645
+ return typeof target.requestRespawn === 'function'
3646
+ ? target.requestRespawn(sessionKey, reason)
3647
+ : { killed: false, queued: 0 };
3648
+ },
3649
+ drainQueue(sessionKey, errCode) {
3650
+ const target = routedPm(sessionKey);
3651
+ return typeof target.drainQueue === 'function'
3652
+ ? target.drainQueue(sessionKey, errCode)
3653
+ : 0;
3654
+ },
3655
+ interrupt(sessionKey) {
3656
+ const target = routedPm(sessionKey);
3657
+ return typeof target.interrupt === 'function'
3658
+ ? target.interrupt(sessionKey)
3659
+ : Promise.resolve();
3660
+ },
3661
+ };
3662
+ return router;
3663
+ })();
3664
+
3665
+ if (sdkAllChats) {
3666
+ console.log('[polygram] using SDK ProcessManager (all chats)');
3667
+ } else if (sdkSomeChats) {
3668
+ console.log(`[polygram] router active: SDK pm for chats {${Array.from(sdkChatIdSet).join(',')}}, CLI pm for everyone else`);
3669
+ } else {
3670
+ console.log('[polygram] using CLI ProcessManager');
3671
+ }
3437
3672
 
3438
3673
  console.log(`polygram (LRU cap=${cap}, SQLite source of truth)`);
3439
3674
  console.log(`Chats: ${Object.entries(config.chats).map(([id, c]) => `${c.name} (${c.model}/${c.effort})`).join(', ')}`);