polygram 0.8.0-rc.16 → 0.8.0-rc.17

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.16",
4
+ "version": "0.8.0-rc.17",
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",
@@ -77,4 +77,55 @@ function createAutosteerBuffer() {
77
77
  return { append, drain, size, clear, formatForHook };
78
78
  }
79
79
 
80
- module.exports = { createAutosteerBuffer };
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
+ *
95
+ * @returns {async () => Promise<HookJSONOutput>}
96
+ */
97
+ function makePostToolBatchHook({ buffer, sessionKey, logEvent = null, chatId = null, logger = console } = {}) {
98
+ if (!buffer) throw new TypeError('buffer required');
99
+ if (!sessionKey) throw new TypeError('sessionKey required');
100
+ return async () => {
101
+ try {
102
+ const drained = buffer.drain(sessionKey);
103
+ if (drained.length === 0) return { continue: true };
104
+ const additionalContext = buffer.formatForHook(drained);
105
+ if (typeof logEvent === 'function') {
106
+ try {
107
+ logEvent('autosteer-hook-drained', {
108
+ chat_id: chatId,
109
+ session_key: sessionKey,
110
+ message_count: drained.length,
111
+ });
112
+ } catch { /* logger errors must not break the hook */ }
113
+ }
114
+ return {
115
+ continue: true,
116
+ hookSpecificOutput: {
117
+ hookEventName: 'PostToolBatch',
118
+ additionalContext,
119
+ },
120
+ };
121
+ } catch (err) {
122
+ logger?.error?.(`[${sessionKey}] PostToolBatch hook error: ${err?.message || err}`);
123
+ // Never throw out of a hook — the SDK may treat it as a hard
124
+ // fail (`stop_hook_prevented` result subtype). Drop the
125
+ // queued messages on the floor; the user can re-send.
126
+ return { continue: true };
127
+ }
128
+ };
129
+ }
130
+
131
+ module.exports = { createAutosteerBuffer, makePostToolBatchHook };
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Per-chat ProcessManager router (rc.6+).
3
+ *
4
+ * Daemon hosts up to TWO pm instances simultaneously — the
5
+ * stream-json-CLI ProcessManager and the @anthropic-ai/claude-agent-sdk
6
+ * ProcessManagerSdk. Each chat is assigned to one of them based on
7
+ * env config:
8
+ *
9
+ * POLYGRAM_USE_SDK=1 → all chats SDK pm
10
+ * POLYGRAM_SDK_CHATS=id1,id2,... → those chats SDK; others CLI
11
+ * neither set → all chats CLI
12
+ *
13
+ * The router exposes the same surface a single pm did, plus two
14
+ * introspection methods:
15
+ *
16
+ * pm.pickFor(sessionKey) → underlying pm instance (for feature
17
+ * detection at call sites)
18
+ * pm.isSdkFor(sessionKey) → boolean shortcut
19
+ *
20
+ * Lifecycle methods (`killChat`, `shutdown`) broadcast to BOTH pms
21
+ * when both are alive — a chat could have a session on either side
22
+ * (e.g. mid-config-change), so we don't risk leaking one.
23
+ *
24
+ * Optional methods (steer / setModel / applyFlagSettings /
25
+ * requestRespawn / drainQueue / interrupt / resetSession) forward
26
+ * when the routed pm has the method and return a sentinel otherwise.
27
+ * Sites that need to feature-detect should `pm.pickFor(sessionKey)`
28
+ * and check `typeof X === 'function'` directly.
29
+ *
30
+ * Used by `polygram.js` main() — Phase 5 + rc.6.
31
+ */
32
+
33
+ 'use strict';
34
+
35
+ /**
36
+ * Parse the SDK-chats env config into a router policy.
37
+ *
38
+ * @param {object} opts
39
+ * @param {boolean} opts.useSdkAll — POLYGRAM_USE_SDK=1
40
+ * @param {Iterable<string>} [opts.sdkChats] — POLYGRAM_SDK_CHATS list
41
+ * @param {(sessionKey: string) => string|null} opts.getChatIdFromKey
42
+ *
43
+ * @returns {object} { sdkAllChats, sdkSomeChats, sdkActive,
44
+ * sdkChatIdSet, pickPmKindFor }
45
+ */
46
+ function makeRouterPolicy({ useSdkAll = false, sdkChats = [], getChatIdFromKey } = {}) {
47
+ if (typeof getChatIdFromKey !== 'function') {
48
+ throw new TypeError('getChatIdFromKey function required');
49
+ }
50
+ const sdkChatIdSet = new Set(
51
+ [...sdkChats].map((s) => String(s).trim()).filter(Boolean),
52
+ );
53
+ const sdkAllChats = !!useSdkAll && sdkChatIdSet.size === 0;
54
+ const sdkSomeChats = sdkChatIdSet.size > 0;
55
+ const sdkActive = sdkAllChats || sdkSomeChats;
56
+
57
+ function pickPmKindFor(sessionKey) {
58
+ if (sdkAllChats) return 'sdk';
59
+ if (!sdkSomeChats) return 'cli';
60
+ const chatId = String(getChatIdFromKey(sessionKey) ?? '');
61
+ return sdkChatIdSet.has(chatId) ? 'sdk' : 'cli';
62
+ }
63
+
64
+ return { sdkAllChats, sdkSomeChats, sdkActive, sdkChatIdSet, pickPmKindFor };
65
+ }
66
+
67
+ /**
68
+ * Build a routing pm proxy. cliPm is required; sdkPm is optional
69
+ * (null when SDK isn't enabled for any chat).
70
+ *
71
+ * @param {object} opts
72
+ * @param {object} opts.cliPm
73
+ * @param {object|null} opts.sdkPm
74
+ * @param {(sessionKey: string) => 'sdk'|'cli'} opts.pickPmKindFor
75
+ */
76
+ function createPmRouter({ cliPm, sdkPm = null, pickPmKindFor } = {}) {
77
+ if (!cliPm) throw new TypeError('cliPm required');
78
+ if (typeof pickPmKindFor !== 'function') {
79
+ throw new TypeError('pickPmKindFor function required');
80
+ }
81
+
82
+ function routedPm(sessionKey) {
83
+ return pickPmKindFor(sessionKey) === 'sdk' && sdkPm ? sdkPm : cliPm;
84
+ }
85
+
86
+ return {
87
+ pickFor: routedPm,
88
+ isSdkFor(sessionKey) {
89
+ return pickPmKindFor(sessionKey) === 'sdk' && !!sdkPm;
90
+ },
91
+
92
+ // Methods that exist on every pm instance — direct routing.
93
+ has(sessionKey) { return routedPm(sessionKey).has(sessionKey); },
94
+ get(sessionKey) { return routedPm(sessionKey).get(sessionKey); },
95
+ getOrSpawn(sessionKey, ctx) { return routedPm(sessionKey).getOrSpawn(sessionKey, ctx); },
96
+ send(sessionKey, prompt, opts) { return routedPm(sessionKey).send(sessionKey, prompt, opts); },
97
+ kill(sessionKey) { return routedPm(sessionKey).kill(sessionKey); },
98
+
99
+ // Lifecycle methods broadcast to both pms because a chat may
100
+ // have spawned sessions on either side at different times.
101
+ async killChat(chatId) {
102
+ const tasks = [cliPm.killChat(chatId)];
103
+ if (sdkPm) tasks.push(sdkPm.killChat(chatId));
104
+ await Promise.all(tasks);
105
+ },
106
+ async shutdown() {
107
+ const tasks = [cliPm.shutdown()];
108
+ if (sdkPm) tasks.push(sdkPm.shutdown());
109
+ await Promise.all(tasks);
110
+ },
111
+
112
+ // Optional methods — forward when the routed pm implements
113
+ // them, return a documented sentinel otherwise. Use
114
+ // `pm.pickFor(sessionKey)` for proper feature detection at
115
+ // call sites that need to branch on capability.
116
+ steer(sessionKey, ...args) {
117
+ const target = routedPm(sessionKey);
118
+ return typeof target.steer === 'function' ? target.steer(sessionKey, ...args) : false;
119
+ },
120
+ resetSession(sessionKey, opts) {
121
+ const target = routedPm(sessionKey);
122
+ return typeof target.resetSession === 'function'
123
+ ? target.resetSession(sessionKey, opts)
124
+ : Promise.resolve({ closed: false, drainedPendings: 0 });
125
+ },
126
+ applyFlagSettings(sessionKey, settings) {
127
+ const target = routedPm(sessionKey);
128
+ return typeof target.applyFlagSettings === 'function'
129
+ ? target.applyFlagSettings(sessionKey, settings)
130
+ : Promise.resolve(false);
131
+ },
132
+ setModel(sessionKey, model) {
133
+ const target = routedPm(sessionKey);
134
+ return typeof target.setModel === 'function'
135
+ ? target.setModel(sessionKey, model)
136
+ : Promise.resolve(false);
137
+ },
138
+ requestRespawn(sessionKey, reason) {
139
+ const target = routedPm(sessionKey);
140
+ return typeof target.requestRespawn === 'function'
141
+ ? target.requestRespawn(sessionKey, reason)
142
+ : { killed: false, queued: 0 };
143
+ },
144
+ drainQueue(sessionKey, errCode) {
145
+ const target = routedPm(sessionKey);
146
+ return typeof target.drainQueue === 'function'
147
+ ? target.drainQueue(sessionKey, errCode)
148
+ : 0;
149
+ },
150
+ interrupt(sessionKey) {
151
+ const target = routedPm(sessionKey);
152
+ return typeof target.interrupt === 'function'
153
+ ? target.interrupt(sessionKey)
154
+ : Promise.resolve();
155
+ },
156
+ };
157
+ }
158
+
159
+ module.exports = { makeRouterPolicy, createPmRouter };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.8.0-rc.16",
3
+ "version": "0.8.0-rc.17",
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 } = require('./lib/autosteer-buffer');
34
+ const { createAutosteerBuffer, makePostToolBatchHook } = require('./lib/autosteer-buffer');
35
+ const { makeRouterPolicy, createPmRouter } = require('./lib/pm-router');
35
36
  const agentLoader = require('./lib/agent-loader');
36
37
  const USE_SDK = process.env.POLYGRAM_USE_SDK === '1';
37
38
  const { createSender } = require('./lib/telegram');
@@ -907,39 +908,19 @@ function buildSdkOptions(sessionKey, ctx) {
907
908
  const useCanUseTool = apprCfg && apprCfg.adminChatId
908
909
  && Array.isArray(apprCfg.gatedTools) && apprCfg.gatedTools.length > 0;
909
910
 
910
- // 0.8.0-rc.9: PostToolBatch hook drains the autosteer buffer for
911
- // this session and injects queued user follow-ups as
912
- // `additionalContext` on each tool boundary. Framing matters:
913
- // wrapping in `<channel source="user-followup">…</channel>` is
914
- // what Claude is trained to trust as legitimate out-of-band user
915
- // context (verified live via post-tool-batch-spike-v2.mjs); the
916
- // earlier `<user_message_during_turn>` framing tripped the
917
- // model's prompt-injection defense and got refused.
918
- const postToolBatchHook = async () => {
919
- try {
920
- const drained = autosteerBuffer.drain(sessionKey);
921
- if (drained.length === 0) return { continue: true };
922
- const additionalContext = autosteerBuffer.formatForHook(drained);
923
- logEvent('autosteer-hook-drained', {
924
- chat_id: ctx?.chatId ?? null,
925
- session_key: sessionKey,
926
- message_count: drained.length,
927
- });
928
- return {
929
- continue: true,
930
- hookSpecificOutput: {
931
- hookEventName: 'PostToolBatch',
932
- additionalContext,
933
- },
934
- };
935
- } catch (err) {
936
- console.error(`[${sessionKey}] PostToolBatch hook error: ${err.message}`);
937
- // Never throw out of a hook — the SDK may treat it as a hard
938
- // fail (`stop_hook_prevented` result subtype). Drop the
939
- // queued messages on the floor; the user can re-send.
940
- return { continue: true };
941
- }
942
- };
911
+ // 0.8.0-rc.9 (factored to lib/autosteer-buffer.js in rc.17): the
912
+ // PostToolBatch hook drains the autosteer buffer for THIS session
913
+ // and injects queued user follow-ups as `additionalContext` on
914
+ // each tool boundary, wrapped in `<channel source="user-followup">`
915
+ // which Claude is trained to trust as legitimate out-of-band user
916
+ // context.
917
+ const postToolBatchHook = makePostToolBatchHook({
918
+ buffer: autosteerBuffer,
919
+ sessionKey,
920
+ chatId: ctx?.chatId ?? null,
921
+ logEvent,
922
+ logger: console,
923
+ });
943
924
 
944
925
  const baseOpts = {
945
926
  model: chatConfig.model || config.defaults.model,
@@ -3559,20 +3540,15 @@ async function main() {
3559
3540
  // battle-tested CLI path. When both pms run, killChat /shutdown
3560
3541
  // broadcast to both; everything else routes per-sessionKey via
3561
3542
  // pickPmFor() based on the chat's set membership.
3562
- const sdkChatIdSet = new Set(
3563
- String(process.env.POLYGRAM_SDK_CHATS || '')
3564
- .split(',').map((s) => s.trim()).filter(Boolean)
3565
- );
3566
- const sdkAllChats = USE_SDK && sdkChatIdSet.size === 0;
3567
- const sdkSomeChats = sdkChatIdSet.size > 0;
3568
- const sdkActive = sdkAllChats || sdkSomeChats;
3569
-
3570
- function pickPmKindFor(sessionKey) {
3571
- if (sdkAllChats) return 'sdk';
3572
- if (!sdkSomeChats) return 'cli';
3573
- const chatId = String(getChatIdFromKey(sessionKey) ?? '');
3574
- return sdkChatIdSet.has(chatId) ? 'sdk' : 'cli';
3575
- }
3543
+ // rc.17: router policy + proxy live in lib/pm-router.js for
3544
+ // testability. Policy parses env config and produces
3545
+ // pickPmKindFor; createPmRouter wraps the cli/sdk pms with the
3546
+ // routed surface.
3547
+ const { sdkActive, sdkAllChats, sdkSomeChats, sdkChatIdSet, pickPmKindFor } = makeRouterPolicy({
3548
+ useSdkAll: USE_SDK,
3549
+ sdkChats: String(process.env.POLYGRAM_SDK_CHATS || '').split(','),
3550
+ getChatIdFromKey,
3551
+ });
3576
3552
 
3577
3553
  // Shared callbacks: identical instance passed to both pms so a
3578
3554
  // chat's lifecycle events look the same regardless of which pm
@@ -3715,85 +3691,9 @@ async function main() {
3715
3691
  : null;
3716
3692
 
3717
3693
  // Routing pm: same surface as a single pm, but per-method routing
3718
- // through pickPmKindFor(sessionKey). Methods that don't take a
3719
- // sessionKey (killChat by chatId, shutdown) broadcast to both.
3720
- // For optional methods (steer / setModel / applyFlagSettings /
3721
- // requestRespawn / drainQueue / interrupt / resetSession) we
3722
- // forward when the routed pm has the method and return a
3723
- // sentinel otherwise — so feature-detection at the call site
3724
- // still works via `typeof pm.pickFor(sessionKey).X === 'function'`.
3725
- pm = (() => {
3726
- function routedPm(sessionKey) {
3727
- return pickPmKindFor(sessionKey) === 'sdk' && sdkPm ? sdkPm : cliPm;
3728
- }
3729
- const router = {
3730
- pickFor: routedPm,
3731
- isSdkFor(sessionKey) {
3732
- return pickPmKindFor(sessionKey) === 'sdk' && !!sdkPm;
3733
- },
3734
- has(sessionKey) { return routedPm(sessionKey).has(sessionKey); },
3735
- get(sessionKey) { return routedPm(sessionKey).get(sessionKey); },
3736
- getOrSpawn(sessionKey, ctx) { return routedPm(sessionKey).getOrSpawn(sessionKey, ctx); },
3737
- send(sessionKey, prompt, opts) { return routedPm(sessionKey).send(sessionKey, prompt, opts); },
3738
- kill(sessionKey) { return routedPm(sessionKey).kill(sessionKey); },
3739
- async killChat(chatId) {
3740
- const tasks = [cliPm.killChat(chatId)];
3741
- if (sdkPm) tasks.push(sdkPm.killChat(chatId));
3742
- await Promise.all(tasks);
3743
- },
3744
- async shutdown() {
3745
- const tasks = [cliPm.shutdown()];
3746
- if (sdkPm) tasks.push(sdkPm.shutdown());
3747
- await Promise.all(tasks);
3748
- },
3749
- // Optional methods. The router returns a function — but the
3750
- // function returns a sentinel if the routed pm doesn't have
3751
- // the method. Sites that want feature-detection should use
3752
- // `pm.pickFor(sessionKey)` and check `typeof X === 'function'`
3753
- // there instead of probing `pm.X` directly.
3754
- steer(sessionKey, ...args) {
3755
- const target = routedPm(sessionKey);
3756
- return typeof target.steer === 'function' ? target.steer(sessionKey, ...args) : false;
3757
- },
3758
- resetSession(sessionKey, opts) {
3759
- const target = routedPm(sessionKey);
3760
- return typeof target.resetSession === 'function'
3761
- ? target.resetSession(sessionKey, opts)
3762
- : Promise.resolve({ closed: false, drainedPendings: 0 });
3763
- },
3764
- applyFlagSettings(sessionKey, settings) {
3765
- const target = routedPm(sessionKey);
3766
- return typeof target.applyFlagSettings === 'function'
3767
- ? target.applyFlagSettings(sessionKey, settings)
3768
- : Promise.resolve(false);
3769
- },
3770
- setModel(sessionKey, model) {
3771
- const target = routedPm(sessionKey);
3772
- return typeof target.setModel === 'function'
3773
- ? target.setModel(sessionKey, model)
3774
- : Promise.resolve(false);
3775
- },
3776
- requestRespawn(sessionKey, reason) {
3777
- const target = routedPm(sessionKey);
3778
- return typeof target.requestRespawn === 'function'
3779
- ? target.requestRespawn(sessionKey, reason)
3780
- : { killed: false, queued: 0 };
3781
- },
3782
- drainQueue(sessionKey, errCode) {
3783
- const target = routedPm(sessionKey);
3784
- return typeof target.drainQueue === 'function'
3785
- ? target.drainQueue(sessionKey, errCode)
3786
- : 0;
3787
- },
3788
- interrupt(sessionKey) {
3789
- const target = routedPm(sessionKey);
3790
- return typeof target.interrupt === 'function'
3791
- ? target.interrupt(sessionKey)
3792
- : Promise.resolve();
3793
- },
3794
- };
3795
- return router;
3796
- })();
3694
+ // through pickPmKindFor(sessionKey). Per-method semantics
3695
+ // documented in lib/pm-router.js.
3696
+ pm = createPmRouter({ cliPm, sdkPm, pickPmKindFor });
3797
3697
 
3798
3698
  if (sdkAllChats) {
3799
3699
  console.log('[polygram] using SDK ProcessManager (all chats)');