polygram 0.8.0-rc.41 → 0.8.0-rc.43

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.41",
4
+ "version": "0.8.0-rc.43",
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",
@@ -67,7 +67,11 @@
67
67
  *
68
68
  * Optional (only one of the two pms implements these — feature-detect):
69
69
  * @property {((sessionKey: string, text: string, opts?: object) => boolean)=} steer
70
- * — SDK pm only (rc.9 PostToolBatch hook drains buffer).
70
+ * — SDK pm only (rc.9 priority='now' direct push, opt-in shouldQuery).
71
+ * @property {((sessionKey: string, opts: {content: string, priority?: 'now'|'next'|'later', shouldQuery?: boolean, parent_tool_use_id?: string|null}) => boolean)=} injectUserMessage
72
+ * — SDK pm only (rc.42 native autosteer / queue via SDKUserMessage
73
+ * priority hint). Returns false on CLI pm (no inputController
74
+ * surface) or when sessionKey not found.
71
75
  * @property {((sessionKey: string, model: string) => Promise<boolean>)=} setModel
72
76
  * — SDK pm only (Query.setModel live).
73
77
  * @property {((sessionKey: string, settings: {effortLevel?: string}) => Promise<boolean>)=} applyFlagSettings
package/lib/pm-router.js CHANGED
@@ -149,6 +149,16 @@ function createPmRouter({ cliPm, sdkPm = null, pickPmKindFor } = {}) {
149
149
  const target = routedPm(sessionKey);
150
150
  return typeof target.steer === 'function' ? target.steer(sessionKey, ...args) : false;
151
151
  },
152
+ // rc.42: native autosteer / queue. CLI pm doesn't have an
153
+ // input-controller push primitive (the binary's stream-json
154
+ // input is one-shot per pm.send), so it returns false. SDK pm
155
+ // forwards to its inject implementation.
156
+ injectUserMessage(sessionKey, opts) {
157
+ const target = routedPm(sessionKey);
158
+ return typeof target.injectUserMessage === 'function'
159
+ ? target.injectUserMessage(sessionKey, opts)
160
+ : false;
161
+ },
152
162
  resetSession(sessionKey, opts) {
153
163
  const target = routedPm(sessionKey);
154
164
  return typeof target.resetSession === 'function'
@@ -840,6 +840,61 @@ class ProcessManagerSdk {
840
840
  }
841
841
  }
842
842
 
843
+ /**
844
+ * 0.8.0-rc.42 — native autosteer / queue. Push a user message
845
+ * directly onto the SDK's input controller. The SDK manages
846
+ * absorption / queueing per the `priority` hint:
847
+ * - 'now': abort current turn (terminal_reason='aborted_streaming')
848
+ * and start a fresh turn for this message (verified U7
849
+ * spike 2026-05-01).
850
+ * - 'next': absorb into current turn at next natural pause
851
+ * (between tool calls / after subagent return / etc.)
852
+ * — same UX as the deleted autosteer-buffer + PostToolBatch
853
+ * flow, but the SDK manages the queue. ONE result event
854
+ * for the whole chain.
855
+ * - 'later': queue for after current turn ends. SEPARATE result
856
+ * event per absorbed message. Clean per-msg lifecycle.
857
+ * - undefined: same as 'next'.
858
+ *
859
+ * Returns true on push success, false if no entry / closed.
860
+ *
861
+ * NOTE: this does NOT push a polygram pending into pendingQueue.
862
+ * The message bypasses pm's per-pending bookkeeping (cost-row,
863
+ * idle-timer, wall-clock cap) — those still attach to the
864
+ * trigger pending of the in-flight turn. For 'later' priority,
865
+ * the SDK will fire its own SDKResultMessage for the followup;
866
+ * polygram's onResult only sees one of these per active pending.
867
+ * Callers wanting per-msg accounting must use pm.send() instead.
868
+ */
869
+ injectUserMessage(sessionKey, { content, priority = 'next', shouldQuery, parent_tool_use_id = null } = {}) {
870
+ const entry = this.procs.get(sessionKey);
871
+ if (!entry || entry.closed) return false;
872
+ if (typeof content !== 'string' || !content) {
873
+ throw new TypeError('injectUserMessage: content (string) required');
874
+ }
875
+ try {
876
+ const msg = {
877
+ type: 'user',
878
+ message: { role: 'user', content },
879
+ parent_tool_use_id,
880
+ };
881
+ if (priority !== undefined) msg.priority = priority;
882
+ if (shouldQuery !== undefined) msg.shouldQuery = shouldQuery;
883
+ entry.inputController.push(msg);
884
+ this._logEvent('inject-user-message', {
885
+ session_key: sessionKey,
886
+ chat_id: entry.chatId,
887
+ priority: priority ?? null,
888
+ should_query: shouldQuery ?? null,
889
+ text_len: content.length,
890
+ });
891
+ return true;
892
+ } catch (err) {
893
+ this.logger.error?.(`[${entry.label}] injectUserMessage: ${err.message}`);
894
+ return false;
895
+ }
896
+ }
897
+
843
898
  /**
844
899
  * Forcibly reset a session: drain pendings, close Query, clear
845
900
  * sessionId in DB. Per v4 plan §6.5.2.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.8.0-rc.41",
3
+ "version": "0.8.0-rc.43",
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,7 +31,8 @@ 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');
34
+ // rc.42: autosteer-buffer module deleted. Native SDK priority push
35
+ // (pm.injectUserMessage) replaces the buffer + PostToolBatch detour.
35
36
  const { createAutosteeredRefs } = require('./lib/autosteered-refs');
36
37
  const { makeRouterPolicy, createPmRouter } = require('./lib/pm-router');
37
38
  const { canonicalizeToolInput } = require('./lib/canonical-json');
@@ -712,21 +713,24 @@ function formatPrompt(msg, sessionCtx, attachments = []) {
712
713
 
713
714
  let pm = null; // ProcessManager, created in main()
714
715
 
715
- // 0.8.0-rc.9: per-session autosteer buffer. Holds user follow-ups
716
- // that arrive mid-turn so the SDK pm's PostToolBatch hook can drain
717
- // them into `additionalContext` on each tool boundary. Replaces the
718
- // rc.6/rc.7 approach of pushing priority:'now' SDKUserMessages
719
- // directly (which violated the SDK's m87 transcript-shape gate when
720
- // the assistant was mid-tool-use).
721
- const autosteerBuffer = createAutosteerBuffer();
722
-
723
- // 0.8.0-rc.14: track msg_ids that received the AUTOSTEERED ✍ ack, per
724
- // session, so we can clear those reactions when the in-flight turn
725
- // finishes. Pre-rc.14 the persisted forever because each autosteer
726
- // invocation runs in its OWN handleMessage scope (own reactor), and
727
- // the TRIGGER message's reactor.clear() at turn-end couldn't reach
728
- // across to other messages. Without this map, users see ✍ stuck on
729
- // every follow-up and don't know whether the bot incorporated them.
716
+ // 0.8.0-rc.42: autosteer buffer + stale-drain DELETED. Replaced by
717
+ // pm.injectUserMessage() with native SDK priority hints. The U7 spike
718
+ // (scripts/spikes/native-queue.mjs, 2026-05-01) verified all three
719
+ // SDK priorities ('now' / 'next' / 'later') work cleanly without
720
+ // m87 rejection. The buffer/hook detour was a workaround for a
721
+ // problem the SDK no longer has.
722
+ //
723
+ // What used to live here: createAutosteerBuffer + makePostToolBatchHook
724
+ // + drainStaleAutosteerBuffer. The buffer kept user follow-ups that
725
+ // arrived mid-turn; the PostToolBatch hook drained them into
726
+ // `additionalContext` (with the <channel source="user-followup"> Channels-
727
+ // MCP framing) on each tool boundary; the stale-drain handled the
728
+ // edge case where a turn ended with zero tool calls (no hook fire,
729
+ // followups would otherwise be lost). All three are obsolete with
730
+ // native priority push.
731
+ //
732
+ // Kept: autosteeredRefs — still tracks msg_ids that received the ✍
733
+ // AUTOSTEERED ack so the trigger turn's success path can clear them.
730
734
  const autosteeredRefs = createAutosteeredRefs({
731
735
  applyClear: async ({ chatId, msgId }) => {
732
736
  if (!bot) return;
@@ -741,57 +745,6 @@ async function clearAutosteeredReactions(sessionKey) {
741
745
  return autosteeredRefs.clear(sessionKey);
742
746
  }
743
747
 
744
- // 0.8.0-rc.14: tool-less-turn drain. PostToolBatch hook only fires
745
- // on tool boundaries — when a Query produces a turn that uses ZERO
746
- // tools (just a text answer), the autosteerBuffer never gets
747
- // drained and any user follow-ups buffered during that turn
748
- // disappear silently into the next tool-using turn (or never, if
749
- // the chat is purely conversational).
750
- //
751
- // Workaround: at every success exit in handleMessage, check if
752
- // the buffer still has items and dispatch them as a synthetic
753
- // next turn via pm.send. The bot replies to the drained content
754
- // in a fresh turn — UX-wise the user sees TWO replies (one to
755
- // the trigger message, one to "B + C") which is the same as if
756
- // they'd sent the messages without autosteer. Better than losing.
757
- async function drainStaleAutosteerBuffer(sessionKey, chatId, threadId) {
758
- const stale = autosteerBuffer.drain(sessionKey);
759
- if (stale.length === 0) return;
760
- const followUpPrompt = stale.join('\n\n');
761
- logEvent('autosteer-stale-drain', {
762
- chat_id: chatId,
763
- session_key: sessionKey,
764
- message_count: stale.length,
765
- text_len: followUpPrompt.length,
766
- });
767
- // Dispatch as a fresh pm.send via setImmediate so we don't
768
- // block the current handleMessage's success-path return. No
769
- // streamer / reactor — the synthetic turn gets a plain bubble
770
- // reply (no streaming preview, no progress reactions). User
771
- // already saw their ✍ ack on the original follow-up; this
772
- // turn's existence is the substantive response.
773
- setImmediate(async () => {
774
- try {
775
- const chatConfig = config.chats[chatId];
776
- if (!chatConfig) return;
777
- const result = await sendToProcess(sessionKey, followUpPrompt, {
778
- streamer: null, reactor: null, sourceMsgId: null,
779
- });
780
- if (result?.text && bot) {
781
- await tg(bot, 'sendMessage', {
782
- chat_id: chatId,
783
- text: result.text,
784
- ...(threadId ? { message_thread_id: threadId } : {}),
785
- }, { source: 'autosteer-stale-reply', botName: BOT_NAME }).catch((err) => {
786
- console.error(`[${BOT_NAME}] autosteer-stale-reply send: ${err.message}`);
787
- });
788
- }
789
- } catch (err) {
790
- console.error(`[${BOT_NAME}] autosteer-stale-drain dispatch: ${err.message}`);
791
- }
792
- });
793
- }
794
-
795
748
  function spawnClaude(sessionKey, ctx) {
796
749
  const { chatConfig, existingSessionId, label, chatId } = ctx;
797
750
  // 0.7.3: Claude Code's Chrome-extension integration (browser
@@ -919,31 +872,11 @@ function buildSdkOptions(sessionKey, ctx) {
919
872
  const useCanUseTool = apprCfg && apprCfg.adminChatId
920
873
  && Array.isArray(apprCfg.gatedTools) && apprCfg.gatedTools.length > 0;
921
874
 
922
- // 0.8.0-rc.9 (factored to lib/autosteer-buffer.js in rc.17): the
923
- // PostToolBatch hook drains the autosteer buffer for THIS session
924
- // and injects queued user follow-ups as `additionalContext` on
925
- // each tool boundary, wrapped in `<channel source="user-followup">`
926
- // which Claude is trained to trust as legitimate out-of-band user
927
- // context.
928
- const postToolBatchHook = makePostToolBatchHook({
929
- buffer: autosteerBuffer,
930
- sessionKey,
931
- chatId: ctx?.chatId ?? null,
932
- logEvent,
933
- logger: console,
934
- // rc.37: clear ✍ reactions when the hook ABSORBS follow-ups, not
935
- // at SDK turn-end. Under autosteer one SDK "turn" can stretch
936
- // tens of minutes — every drain feeds more user text via
937
- // additionalContext, the agent keeps reasoning, no `result` event
938
- // fires, inFlight stays true, ✍ stays stuck on every drained
939
- // follow-up. Clearing at drain time matches user perception
940
- // ("the bot got my message → ✍ goes away").
941
- onDrained: (key) => {
942
- clearAutosteeredReactions(key).catch((err) => {
943
- console.error(`[${BOT_NAME}] autosteer-hook clearReactions: ${err.message}`);
944
- });
945
- },
946
- });
875
+ // rc.42: PostToolBatch hook removed. Native SDK priority push
876
+ // (pm.injectUserMessage) replaces the absorb-via-additionalContext
877
+ // detour. The autosteered reaction now clears via the regular
878
+ // turn-end path (handleMessage finally + success branches —
879
+ // existing rc.38 cleanup).
947
880
 
948
881
  // 0.8.0-rc.21: SessionStart hook preloads recent polygram-DB
949
882
  // history into a fresh Query (no resume). Without this, every
@@ -972,7 +905,6 @@ function buildSdkOptions(sessionKey, ctx) {
972
905
  allowDangerouslySkipPermissions: !useCanUseTool,
973
906
  ...(useCanUseTool && { canUseTool: makeCanUseTool(sessionKey) }),
974
907
  hooks: {
975
- PostToolBatch: [{ hooks: [postToolBatchHook] }],
976
908
  ...(sessionStartHook && {
977
909
  SessionStart: [{ hooks: [sessionStartHook] }],
978
910
  }),
@@ -2513,52 +2445,57 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2513
2445
  const chatAutosteer = chatConfig.autosteer != null
2514
2446
  ? chatConfig.autosteer
2515
2447
  : config.bot?.autosteer;
2516
- // 0.8.0-rc.9: autosteer now drives through autosteerBuffer +
2517
- // PostToolBatch hook (in buildSdkOptions), not pm.steer's direct
2518
- // inputController push. The hook fires on every tool boundary
2519
- // and injects queued follow-ups as <channel source="user-followup">
2520
- // additionalContext — the SDK-trusted framing that survives the
2521
- // m87 transcript-shape gate.
2448
+ // 0.8.0-rc.42: autosteer is now native push the user message
2449
+ // directly onto the SDK's input controller with a priority hint.
2450
+ // The SDK manages absorption / queueing per the U7 spike findings
2451
+ // (scripts/spikes/native-queue.mjs, 2026-05-01):
2452
+ //
2453
+ // priority='next' (default): absorb into current turn at the next
2454
+ // natural pause (between tool calls / after subagent return).
2455
+ // ONE result event for the whole chain — same UX as the
2456
+ // deleted autosteer-buffer + PostToolBatch flow.
2457
+ // priority='later': queue for after current turn ends. SEPARATE
2458
+ // result event per absorbed message. Cleaner per-msg lifecycle
2459
+ // for chats that prefer accurate cost-row attribution.
2522
2460
  //
2523
- // We still gate on the SDK pm path: under CLI pm there's no
2524
- // PostToolBatch hook surface, so autosteer falls through to the
2525
- // regular FIFO send (same UX as 0.7.x).
2461
+ // Per-chat opt-in via `chatConfig.autosteerMode: 'merge' | 'queue'`.
2462
+ // 'merge' priority='next' (default). 'queue' priority='later'.
2463
+ //
2464
+ // Pre-rc.42 this used a custom autosteerBuffer + PostToolBatch hook
2465
+ // returning <channel source="user-followup"> additionalContext. The
2466
+ // SDK at the time rejected priority='now' mid-tool-use (m87 gate);
2467
+ // 'next'/'later' were never tested. The U7 spike confirmed all
2468
+ // three priorities now work cleanly — so the buffer/hook detour is
2469
+ // gone. ~250 LOC + 2 test files deleted.
2470
+ //
2471
+ // CLI pm has no inputController push primitive, so it falls
2472
+ // through to FIFO pm.send (same UX as 0.7.x — queued behind active).
2526
2473
  const autosteerEnabled = chatAutosteer !== false
2527
2474
  && pm.isSdkFor(sessionKey);
2528
2475
  if (autosteerEnabled && pm.has(sessionKey)) {
2529
2476
  const entry = pm.get(sessionKey);
2530
2477
  if (entry?.inFlight) {
2531
- const ok = autosteerBuffer.append(sessionKey, prompt);
2478
+ const autosteerMode = chatConfig.autosteerMode != null
2479
+ ? chatConfig.autosteerMode
2480
+ : config.bot?.autosteerMode;
2481
+ const priority = autosteerMode === 'queue' ? 'later' : 'next';
2482
+ const ok = pm.injectUserMessage(sessionKey, {
2483
+ content: prompt,
2484
+ priority,
2485
+ });
2532
2486
  if (ok) {
2533
- // Track this msg_id so the in-flight turn's success / abort
2534
- // / error path can clear the ✍ reaction at turn-end.
2535
2487
  autosteeredRefs.add(sessionKey, { chatId, msgId: msg.message_id });
2536
2488
  logEvent('autosteer', {
2537
2489
  chat_id: chatId, msg_id: msg.message_id,
2538
2490
  text_len: prompt?.length ?? 0,
2491
+ priority,
2539
2492
  });
2540
2493
  stopTyping();
2541
- // 0.8.0-rc.11: route the ack through the reactor's
2542
- // serialized apply chain. Pre-rc.11 we used a direct
2543
- // setMessageReaction(✍) racing with the reactor's
2544
- // QUEUED→👀 apply AND a follow-up reactor.clear() — three
2545
- // concurrent network calls, final state was whichever
2546
- // landed last at Telegram. Symptom: 👀 sometimes stuck,
2547
- // ✍ sometimes vanished, reactions disappeared "almost
2548
- // immediately" or got stuck arbitrarily.
2549
- //
2550
- // setState('AUTOSTEERED') is terminal so it bypasses the
2551
- // 800ms throttle and flushes synchronously through
2552
- // applyChain — so it serializes after any in-flight
2553
- // QUEUED apply and lands as the final visible reaction.
2494
+ // setState('AUTOSTEERED') is terminal bypasses the throttle,
2495
+ // serializes after any in-flight QUEUED apply via applyChain.
2554
2496
  await reactor.setState('AUTOSTEERED');
2555
- // rc.38: stop the reactor's STALL/TIMEOUT timers. Pre-rc.38
2556
- // the timers stayed armed, holding setTimeout handles for
2557
- // up to 30s and pinning the closure (and the bot/chatId
2558
- // captures) until they fired. AUTOSTEERED is terminal — no
2559
- // further state changes — so the timers serve no purpose
2560
- // and just delay GC. One-line patch; small steady-state
2561
- // heap relief in busy chats.
2497
+ // rc.38: AUTOSTEERED is terminal; stop the reactor's STALL /
2498
+ // TIMEOUT timers so they don't pin the closure for up to 30s.
2562
2499
  reactor.stop();
2563
2500
  markReplied();
2564
2501
  return;
@@ -2648,12 +2585,11 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2648
2585
  // message that was autosteered into THIS turn — they live in
2649
2586
  // separate handleMessage scopes whose reactors are already GC'd.
2650
2587
  clearAutosteeredReactions(sessionKey).catch(() => {});
2651
- // rc.14: tool-less-turn drain. PostToolBatch hook fires only
2652
- // on tool boundaries; if this turn produced ZERO tools, the
2653
- // hook never fired and the autosteer buffer still has the
2654
- // user's follow-ups. Dispatch them as a synthetic next turn
2655
- // so the bot at least addresses them (better than losing).
2656
- drainStaleAutosteerBuffer(sessionKey, chatId, threadId).catch(() => {});
2588
+ // rc.42: tool-less-turn stale-drain DELETED. With native priority
2589
+ // push, the SDK's input controller has the followups directly
2590
+ // there's no buffer for us to drain. Tool-less turns just emit
2591
+ // result, the followup messages (if any) get their own SDK
2592
+ // pause to absorb at, no special handling needed.
2657
2593
 
2658
2594
  // 0.8.0 Phase 2 step 4: 85%-context-full live hint. After a
2659
2595
  // successful turn, peek at SDK's getContextUsage(); if past
@@ -2707,10 +2643,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2707
2643
  if (toolOnlyTurn) {
2708
2644
  await reactor.clear().catch(() => {});
2709
2645
  clearAutosteeredReactions(sessionKey).catch(() => {});
2710
- // Tool-only turns DID fire PostToolBatch — buffer was drained
2711
- // — but autosteers received AFTER the last tool-result still
2712
- // wouldn't be merged. Defensive drain here too.
2713
- drainStaleAutosteerBuffer(sessionKey, chatId, threadId).catch(() => {});
2646
+ // rc.42: stale-drain removed. SDK manages absorption directly.
2714
2647
  logEvent('tool-only-completion', {
2715
2648
  chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
2716
2649
  num_tool_uses: result.metrics?.numToolUses,
@@ -3129,13 +3062,12 @@ function createBot(token) {
3129
3062
  await stopTarget.kill(sessionKey).catch((err) =>
3130
3063
  console.error(`[${BOT_NAME}] abort kill failed: ${err.message}`));
3131
3064
  }
3132
- // 0.8.0-rc.13: drop any buffered autosteer follow-ups for this
3133
- // session otherwise they'd be injected into the NEXT turn
3134
- // (stale steer leak across abort boundary, which is what the
3135
- // user just asked us not to do).
3136
- autosteerBuffer.clear(sessionKey);
3137
- // rc.14: also clear reactions on already-autosteered
3138
- // messages from this aborted turn — they're now dead context.
3065
+ // rc.42: autosteer buffer is gone (native SDK priority push).
3066
+ // Followups already pushed onto the SDK's input controller will
3067
+ // be drained by drainQueue() / kill() on the entry no separate
3068
+ // buffer to clear.
3069
+ // Clear ✍ reactions on already-autosteered messages from this
3070
+ // aborted turn they're now dead context.
3139
3071
  clearAutosteeredReactions(sessionKey).catch(() => {});
3140
3072
  logEvent('abort-requested', {
3141
3073
  chat_id: chatId, user_id: msg.from?.id || null,
@@ -1,155 +0,0 @@
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
- /**
81
- * Build the PostToolBatch hook callback that drains the buffer for
82
- * a specific sessionKey on each tool boundary. The callback shape
83
- * matches `@anthropic-ai/claude-agent-sdk`'s HookCallback contract
84
- * (sdk.d.ts:726-728): returns a HookJSONOutput; never throws.
85
- *
86
- * @param {object} opts
87
- * @param {object} opts.buffer — the per-session buffer instance
88
- * @param {string} opts.sessionKey — closure-bound at Query spawn time
89
- * @param {(kind: string, detail: object) => void} [opts.logEvent]
90
- * — optional events.table emitter; called when a drain produces
91
- * non-empty output, with kind='autosteer-hook-drained'.
92
- * @param {string|null} [opts.chatId] — for the logEvent payload only.
93
- * @param {object} [opts.logger] — for error logging (must have .error).
94
- * @param {(sessionKey: string, drainedCount: number) => void} [opts.onDrained]
95
- * — fired AFTER the hook successfully injects additionalContext.
96
- * rc.37 wires this to clearAutosteeredReactions so the ✍ reaction
97
- * fades the moment the agent absorbs the follow-up — not at SDK
98
- * turn-end, which under autosteer can stretch tens of minutes
99
- * (one SDK turn keeps absorbing follow-ups via additionalContext
100
- * and never emits result, so the old turn-end-only cleanup left
101
- * ✍ stuck across many user messages).
102
- *
103
- * @returns {async () => Promise<HookJSONOutput>}
104
- */
105
- function makePostToolBatchHook({ buffer, sessionKey, logEvent = null, chatId = null, logger = console, onDrained = null } = {}) {
106
- if (!buffer) throw new TypeError('buffer required');
107
- if (!sessionKey) throw new TypeError('sessionKey required');
108
- return async () => {
109
- try {
110
- const drained = buffer.drain(sessionKey);
111
- if (drained.length === 0) return { continue: true };
112
- const additionalContext = buffer.formatForHook(drained);
113
- if (typeof logEvent === 'function') {
114
- try {
115
- logEvent('autosteer-hook-drained', {
116
- chat_id: chatId,
117
- session_key: sessionKey,
118
- message_count: drained.length,
119
- });
120
- } catch { /* logger errors must not break the hook */ }
121
- }
122
- if (typeof onDrained === 'function') {
123
- // rc.38: async-safe. onDrained may return a Promise (it does
124
- // today — clearAutosteeredReactions is async). A bare
125
- // synchronous try/catch only catches throws, not rejections;
126
- // an unhandled rejection escaping the hook would land on the
127
- // process-level handler as misleading noise. Detect a
128
- // thenable and attach .catch so async failures are logged at
129
- // the same site, not as out-of-band unhandledRejection.
130
- try {
131
- const r = onDrained(sessionKey, drained.length);
132
- if (r && typeof r.then === 'function') {
133
- r.catch((err) => logger?.error?.(`[${sessionKey}] onDrained async: ${err?.message || err}`));
134
- }
135
- }
136
- catch (err) { logger?.error?.(`[${sessionKey}] onDrained: ${err?.message || err}`); }
137
- }
138
- return {
139
- continue: true,
140
- hookSpecificOutput: {
141
- hookEventName: 'PostToolBatch',
142
- additionalContext,
143
- },
144
- };
145
- } catch (err) {
146
- logger?.error?.(`[${sessionKey}] PostToolBatch hook error: ${err?.message || err}`);
147
- // Never throw out of a hook — the SDK may treat it as a hard
148
- // fail (`stop_hook_prevented` result subtype). Drop the
149
- // queued messages on the floor; the user can re-send.
150
- return { continue: true };
151
- }
152
- };
153
- }
154
-
155
- module.exports = { createAutosteerBuffer, makePostToolBatchHook };