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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/pm-interface.js +5 -1
- package/lib/pm-router.js +10 -0
- package/lib/process-manager-sdk.js +55 -0
- package/package.json +1 -1
- package/polygram.js +74 -142
- package/lib/autosteer-buffer.js +0 -155
|
@@ -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.
|
|
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",
|
package/lib/pm-interface.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
//
|
|
720
|
-
// the
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
//
|
|
724
|
-
//
|
|
725
|
-
//
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
//
|
|
729
|
-
//
|
|
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
|
-
//
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
//
|
|
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.
|
|
2517
|
-
//
|
|
2518
|
-
//
|
|
2519
|
-
//
|
|
2520
|
-
//
|
|
2521
|
-
//
|
|
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
|
-
//
|
|
2524
|
-
//
|
|
2525
|
-
//
|
|
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
|
|
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
|
-
//
|
|
2542
|
-
//
|
|
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/
|
|
2556
|
-
//
|
|
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.
|
|
2652
|
-
//
|
|
2653
|
-
//
|
|
2654
|
-
//
|
|
2655
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
3133
|
-
//
|
|
3134
|
-
// (
|
|
3135
|
-
//
|
|
3136
|
-
|
|
3137
|
-
//
|
|
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,
|
package/lib/autosteer-buffer.js
DELETED
|
@@ -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 };
|