polygram 0.8.0-rc.47 → 0.8.0-rc.48

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.47",
4
+ "version": "0.8.0-rc.48",
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",
@@ -98,9 +98,17 @@
98
98
  "cwd": "/Users/you/admin-agent",
99
99
  "requireMention": true,
100
100
  "isolateTopics": true,
101
+ "_comment_topics": "rc.48: each topic entry is EITHER a string (legacy: just a label) OR an object with optional fields {name, agent, cwd, model, effort, permissionMode}. Object form lets a topic override chat-level config. Per-topic permissionMode overrides chat-level — typical use: scope one topic to permissionMode:'default' (so settings.json gates apply) while the rest of the chat stays on bypassPermissions. Object form requires isolateTopics: true (each topic gets its own SDK Query); polygram emits a startup warning otherwise.",
101
102
  "topics": {
102
103
  "100": "Customer A",
103
- "200": "Customer B"
104
+ "200": {
105
+ "name": "Customer B",
106
+ "agent": "customer-b-helper",
107
+ "cwd": "/Users/you/customer-b-projects",
108
+ "model": "opus",
109
+ "effort": "high",
110
+ "permissionMode": "default"
111
+ }
104
112
  }
105
113
  },
106
114
 
@@ -283,15 +283,27 @@ function loadAgent(agentName, { homeDir = process.env.HOME, cwd = null, logger =
283
283
 
284
284
  /**
285
285
  * Compose a chat's final SdkOptions from defaults + agent + per-chat
286
- * overrides. Precedence: chatConfig > agent > defaults.
286
+ * overrides + per-topic overrides. Precedence (highest to lowest):
287
+ * topicConfig > chatConfig > agent.raw > defaults
288
+ *
289
+ * rc.48 added the per-topic layer (`topicConfig` arg). Per-topic
290
+ * overrides are the principal rc.48 use case — typically loosening
291
+ * an agent's `bypassPermissions` default to `default` for a sensitive
292
+ * topic (so canUseTool prompts fire there) while keeping the rest of
293
+ * the chat in bypass mode. Per-topic permissionMode MUST override the
294
+ * chat-level one for this to work.
287
295
  *
288
296
  * @param {object} chatConfig — config.chats[chatId].
289
297
  * @param {AgentBundle|null} agentBundle — null if chat has no agent.
290
298
  * @param {object} defaults — config.defaults.
299
+ * @param {object} [topicConfig] — per-topic overrides from
300
+ * getTopicConfig(chatConfig, threadId). Empty object when there's
301
+ * no active topic, no override config, or topic uses legacy string
302
+ * form. Highest precedence — overrides chatConfig.
291
303
  *
292
304
  * @returns {object} SdkOptions for `query({ options: ... })`.
293
305
  */
294
- function composeSdkOptions(chatConfig = {}, agentBundle = null, defaults = {}) {
306
+ function composeSdkOptions(chatConfig = {}, agentBundle = null, defaults = {}, topicConfig = {}) {
295
307
  // Start with defaults — these are the lowest-priority.
296
308
  const opts = { ...defaults };
297
309
 
@@ -302,16 +314,18 @@ function composeSdkOptions(chatConfig = {}, agentBundle = null, defaults = {}) {
302
314
  if (agentBundle.mcpServers && Object.keys(agentBundle.mcpServers).length) {
303
315
  opts.mcpServers = { ...(opts.mcpServers || {}), ...agentBundle.mcpServers };
304
316
  }
305
- // Agent-level model/effort/etc — only if chatConfig doesn't
306
- // override.
317
+ // Agent-level model/effort/etc — only if chatConfig AND
318
+ // topicConfig don't override.
307
319
  for (const key of ['model', 'effort', 'thinking', 'permissionMode']) {
308
- if (agentBundle.raw?.[key] != null && chatConfig[key] == null) {
320
+ if (agentBundle.raw?.[key] != null
321
+ && chatConfig[key] == null
322
+ && topicConfig?.[key] == null) {
309
323
  opts[key] = agentBundle.raw[key];
310
324
  }
311
325
  }
312
326
  }
313
327
 
314
- // Chat-level overrides (highest priority).
328
+ // Chat-level overrides.
315
329
  for (const [k, v] of Object.entries(chatConfig)) {
316
330
  if (v == null) continue;
317
331
  // Don't override the spread system-prompt with `agent` config
@@ -320,6 +334,18 @@ function composeSdkOptions(chatConfig = {}, agentBundle = null, defaults = {}) {
320
334
  opts[k] = v;
321
335
  }
322
336
 
337
+ // rc.48: per-topic overrides (highest priority). Same `agent` exclusion
338
+ // — `agent` here is a polygram name reference, NOT an SdkOptions
339
+ // field. polygram's spawn flow resolves topicConfig.agent into the
340
+ // correct agentBundle BEFORE calling composeSdkOptions, so by the
341
+ // time we get here, agentBundle already reflects the topic's agent
342
+ // choice and the `agent` string itself shouldn't leak into opts.
343
+ for (const [k, v] of Object.entries(topicConfig || {})) {
344
+ if (v == null) continue;
345
+ if (k === 'agent') continue;
346
+ opts[k] = v;
347
+ }
348
+
323
349
  return opts;
324
350
  }
325
351
 
@@ -16,8 +16,25 @@
16
16
  * topic's conversation can't bleed into Billing topic's memory. This
17
17
  * matches OpenClaw's model and is the right call when topics represent
18
18
  * genuinely separate projects.
19
+ *
20
+ * rc.48: per-topic config overrides. `topics[threadId]` can be either a
21
+ * string (legacy: just a label) or an object with optional fields:
22
+ * { name, agent, cwd, model, effort, permissionMode }
23
+ * Object form lets a topic override chat-level config — typically used
24
+ * to scope a single topic to a different agent (e.g. music-curation),
25
+ * a different working dir, or to switch from `bypassPermissions` to
26
+ * `default` so canUseTool prompts fire for sensitive operations in
27
+ * that topic only. See getTopicConfig.
28
+ *
29
+ * Per-topic overrides only take effect when isolateTopics: true (each
30
+ * topic has its own SDK Query). With isolateTopics: false all topics
31
+ * share one Query; the Query's options are fixed at first-spawn time.
32
+ * polygram emits a one-time startup warning if topic overrides are
33
+ * configured on a non-isolating chat.
19
34
  */
20
35
 
36
+ 'use strict';
37
+
21
38
  function getSessionKey(chatId, threadId, chatConfig) {
22
39
  const isolate = chatConfig?.isolateTopics === true;
23
40
  if (threadId && isolate) return `${chatId}:${threadId}`;
@@ -44,4 +61,55 @@ function getThreadIdFromKey(sessionKey) {
44
61
  return thread || null;
45
62
  }
46
63
 
47
- module.exports = { getSessionKey, getChatIdFromKey, getThreadIdFromKey };
64
+ /**
65
+ * Resolve the human-readable name for a topic. Handles both the legacy
66
+ * string form (`topics["100"] = "Orders"`) and the rc.48 object form
67
+ * (`topics["200"] = { name: "Music", agent: "music-curation" }`). Falls
68
+ * back to the threadId itself if the topic has no name field.
69
+ */
70
+ function getTopicName(chatConfig, threadId) {
71
+ if (threadId == null || threadId === '') return null;
72
+ const t = chatConfig?.topics?.[threadId];
73
+ if (typeof t === 'string') return t;
74
+ if (t && typeof t === 'object' && typeof t.name === 'string' && t.name) {
75
+ return t.name;
76
+ }
77
+ return String(threadId);
78
+ }
79
+
80
+ /**
81
+ * rc.48: extract per-topic SdkOptions overrides. Returns the topic
82
+ * entry's overridable fields (model, effort, cwd, agent,
83
+ * permissionMode), excluding `name` (which is a polygram label, not
84
+ * an SdkOptions field — see getTopicName).
85
+ *
86
+ * Returns `{}` for:
87
+ * - missing threadId
88
+ * - missing chatConfig.topics
89
+ * - threadId not present in topics
90
+ * - legacy string topic entry (label-only, no overrides)
91
+ * - object with only `name`
92
+ *
93
+ * Callers (composeSdkOptions, polygram's spawn flow) merge this on
94
+ * top of chat-level config. Per-topic overrides take HIGHEST
95
+ * precedence — including overriding the chat-level permissionMode,
96
+ * which is the principal rc.48 use case (loosen one topic from the
97
+ * agent's `bypassPermissions` default to `default`).
98
+ */
99
+ function getTopicConfig(chatConfig, threadId) {
100
+ if (threadId == null || threadId === '') return {};
101
+ const t = chatConfig?.topics?.[threadId];
102
+ if (!t || typeof t !== 'object') return {};
103
+ // Strip `name` — that's the label, not an override. Everything else
104
+ // in the entry is an override candidate.
105
+ const { name, ...overrides } = t;
106
+ return overrides;
107
+ }
108
+
109
+ module.exports = {
110
+ getSessionKey,
111
+ getChatIdFromKey,
112
+ getThreadIdFromKey,
113
+ getTopicName,
114
+ getTopicConfig,
115
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.8.0-rc.47",
3
+ "version": "0.8.0-rc.48",
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
@@ -195,12 +195,13 @@ function isWellFormedMessage(msg) {
195
195
  }
196
196
 
197
197
  // ─── Session key — moved to lib/session-key.js so tests can import it. ─
198
- const { getSessionKey, getChatIdFromKey, getThreadIdFromKey } = require('./lib/session-key');
199
-
200
- function getTopicName(chatConfig, threadId) {
201
- if (!threadId) return null;
202
- return chatConfig.topics?.[threadId] || threadId;
203
- }
198
+ const {
199
+ getSessionKey,
200
+ getChatIdFromKey,
201
+ getThreadIdFromKey,
202
+ getTopicName,
203
+ getTopicConfig,
204
+ } = require('./lib/session-key');
204
205
 
205
206
  function getSessionLabel(chatConfig, threadId) {
206
207
  const topic = getTopicName(chatConfig, threadId);
@@ -826,30 +827,52 @@ function spawnClaude(sessionKey, ctx) {
826
827
  * mcpServers, optional resume sessionId for continuity.
827
828
  */
828
829
  function buildSdkOptions(sessionKey, ctx) {
829
- const { chatConfig, existingSessionId, label, chatId } = ctx;
830
+ const { chatConfig, existingSessionId, label, chatId, threadId } = ctx;
831
+
832
+ // rc.48: per-topic config overrides. When `topics[threadId]` is an
833
+ // object (not a legacy string label), these fields take precedence
834
+ // over chat-level config. Principal use case: scope a single topic
835
+ // to a different agent, cwd, or permissionMode (e.g. flip music
836
+ // topic from `bypassPermissions` to `default` so settings.json
837
+ // permissions.allow gates apply).
838
+ //
839
+ // Per-topic overrides only apply meaningfully when isolateTopics is
840
+ // true — each topic has its own SDK Query. With isolateTopics: false
841
+ // all topics share one Query whose options are fixed at first-spawn.
842
+ // Startup validation (validateTopicConfigs) emits a one-time warning
843
+ // when a chat has topic overrides + isolateTopics: false.
844
+ const topicConfig = getTopicConfig(chatConfig, threadId);
845
+ const effectiveAgent = topicConfig.agent || chatConfig.agent;
830
846
 
831
847
  // Per-chat agent (D14): if pinned, load & compose. Failure is
832
848
  // non-fatal — chat falls back to defaults; logged for ops.
833
849
  let agentBundle = null;
834
- if (chatConfig.agent) {
850
+ if (effectiveAgent) {
835
851
  try {
836
- agentBundle = agentLoader.loadAgent(chatConfig.agent, {
852
+ agentBundle = agentLoader.loadAgent(effectiveAgent, {
837
853
  homeDir: CHILD_HOME,
838
854
  // Pass cwd so the loader checks Claude Code's project-level
839
855
  // path (`<cwd>/.claude/agents/<name>.md`) before the
840
- // user-level path or polygram's directory convention.
841
- cwd: chatConfig.cwd,
856
+ // user-level path or polygram's directory convention. rc.48:
857
+ // topic-level cwd takes precedence so topic-scoped agents
858
+ // load from the topic's project dir.
859
+ cwd: topicConfig.cwd || chatConfig.cwd,
842
860
  logger: console,
843
861
  });
844
862
  } catch (err) {
845
863
  console.error(`[${label}] agent-loader: ${err.message}`);
846
864
  logEvent('agent-load-failed', {
847
- chat_id: chatId, agent: chatConfig.agent, error: err.message,
865
+ chat_id: chatId, agent: effectiveAgent, error: err.message,
866
+ topic: threadId || null,
848
867
  });
849
868
  }
850
869
  }
851
870
 
852
- console.log(`[${label}] Spawning SDK Query (${chatConfig.model}/${chatConfig.effort})`);
871
+ const effectiveModel = topicConfig.model || chatConfig.model;
872
+ const effectiveEffort = topicConfig.effort || chatConfig.effort;
873
+ const agentSuffix = effectiveAgent && effectiveAgent !== chatConfig.agent
874
+ ? ` agent=${effectiveAgent}` : '';
875
+ console.log(`[${label}] Spawning SDK Query (${effectiveModel}/${effectiveEffort}${agentSuffix})`);
853
876
 
854
877
  // Env: SHADOW semantics (gate 33) — must enumerate every var
855
878
  // pollygram needs in the spawned worker.
@@ -916,10 +939,11 @@ function buildSdkOptions(sessionKey, ctx) {
916
939
  }),
917
940
  };
918
941
 
919
- // Compose with agent overlay + chat-level config. agent-loader
920
- // precedence: chatConfig > agent > defaults. The chatConfig keys
921
- // we care about for SDK options are model/effort/cwd/thinking;
922
- // others (agent, chrome, isolateTopics) are polygram-only.
942
+ // Compose with agent overlay + chat-level config + per-topic overrides.
943
+ // agent-loader precedence: topicConfig > chatConfig > agent > defaults.
944
+ // The chatConfig keys we care about for SDK options are
945
+ // model/effort/cwd/thinking; others (agent, chrome, isolateTopics)
946
+ // are polygram-only and stripped by composeSdkOptions.
923
947
  const composed = agentLoader.composeSdkOptions(
924
948
  {
925
949
  // chat-level overrides — only the keys SDK understands.
@@ -930,8 +954,19 @@ function buildSdkOptions(sessionKey, ctx) {
930
954
  },
931
955
  agentBundle,
932
956
  baseOpts,
957
+ topicConfig, // rc.48: highest precedence — overrides chat-level fields.
933
958
  );
934
959
 
960
+ // rc.48: keep permissionMode + allowDangerouslySkipPermissions
961
+ // consistent. baseOpts sets allowDangerouslySkipPermissions=true when
962
+ // permissionMode='bypassPermissions'. If a topic flipped permissionMode
963
+ // to anything else (typically 'default' to gate via settings.json),
964
+ // also disable allowDangerouslySkipPermissions so the SDK actually
965
+ // honours the permission gates instead of skipping them.
966
+ if (composed.permissionMode && composed.permissionMode !== 'bypassPermissions') {
967
+ composed.allowDangerouslySkipPermissions = false;
968
+ }
969
+
935
970
  // Append polygram's display constraints to the systemPrompt.
936
971
  // Infrastructure-layer hint — the agent's own prompt covers
937
972
  // business logic; polygram adds "your output renders in Telegram,
@@ -3795,6 +3830,26 @@ async function main() {
3795
3830
  console.log(`polygram (LRU cap=${cap}, SQLite source of truth)`);
3796
3831
  console.log(`Chats: ${Object.entries(config.chats).map(([id, c]) => `${c.name} (${c.model}/${c.effort})`).join(', ')}`);
3797
3832
 
3833
+ // rc.48: validate per-topic config + isolateTopics relationship.
3834
+ // Per-topic SdkOptions overrides only take effect when each topic
3835
+ // gets its own SDK Query (isolateTopics: true). Without isolation
3836
+ // the Query is fixed at first-spawn time; subsequent topic-scoped
3837
+ // messages share that Query regardless of topic-level overrides.
3838
+ for (const [chatId, chatCfg] of Object.entries(config.chats)) {
3839
+ if (!chatCfg.topics || typeof chatCfg.topics !== 'object') continue;
3840
+ const overrideTopics = Object.entries(chatCfg.topics)
3841
+ .filter(([, t]) => t && typeof t === 'object'
3842
+ && Object.keys(t).some((k) => k !== 'name'));
3843
+ if (overrideTopics.length === 0) continue;
3844
+ if (chatCfg.isolateTopics !== true) {
3845
+ const ids = overrideTopics.map(([id]) => id).join(', ');
3846
+ console.warn(`[${BOT_NAME}] WARN: chat ${chatId} (${chatCfg.name}) has topic overrides on topic_ids=${ids} but isolateTopics is not true — overrides will be IGNORED. Set isolateTopics: true to make per-topic config take effect.`);
3847
+ logEvent('topic-override-without-isolation', {
3848
+ chat_id: chatId, name: chatCfg.name, topic_ids: ids,
3849
+ });
3850
+ }
3851
+ }
3852
+
3798
3853
  bot = createBot(config.bot.token);
3799
3854
 
3800
3855
  // Graceful shutdown: stop accepting new inbound, drain in-flight pendings