polygram 0.34.0 → 0.35.0

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.34.0",
4
+ "version": "0.35.0",
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 plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
6
6
  "keywords": [
7
7
  "telegram",
@@ -49,7 +49,9 @@ const BLOCKED_RUNTIME_STATES = new Set([
49
49
  ]);
50
50
 
51
51
  function runtimeLabel(runtime) {
52
- return RUNTIME_OPTION_BY_BACKEND.get(runtime)?.label ?? 'Claude SDK';
52
+ // Only ever called for a runtime that has a button — the callback rejects
53
+ // anything else as an invalid runtime before this point.
54
+ return RUNTIME_OPTION_BY_BACKEND.get(runtime).label;
53
55
  }
54
56
 
55
57
  function runtimeSwitchBlocked(proc) {
@@ -485,10 +487,15 @@ function createHandleConfigCallback({
485
487
  // (so Music ≠ General), a chat-level card the chat root. Resolving the
486
488
  // thread BEFORE the already-set check so "Already X" compares against the
487
489
  // topic's effective value, not the chat root (2026-06-12 bug).
490
+ //
491
+ // Rich text follows session isolation like every other setting. Delivery
492
+ // resolves it per message with the real thread, but the agent's authoring
493
+ // hint is spawn-time state resolved with the SESSION's thread — which a
494
+ // chat without isolateTopics collapses to null. A topic-level write there
495
+ // would flip delivery while the hint never drifts: no respawn, and an ack
496
+ // promising a change that never arrives.
488
497
  const { scope: writeScope, threadId: writeThreadId } =
489
- getConfigWriteScope(chatConfig, callbackThreadIdEarly, {
490
- allowNonIsolatedTopic: setting === 'richtext',
491
- });
498
+ getConfigWriteScope(chatConfig, callbackThreadIdEarly);
492
499
  // The persisted camel-case key differs from the lowercase callback
493
500
  // label used alongside model and effort.
494
501
  const configField = isRichText
@@ -543,13 +550,78 @@ function createHandleConfigCallback({
543
550
  effortAdjustment = { oldValue: oldEffort, newValue: nextEffort };
544
551
  }
545
552
  }
553
+ // Rich text has no per-topic scope in a chat whose topics share one
554
+ // session: there is a single hint, so there can be a single value. Any
555
+ // topics[tid].richText there was written by the pre-fix behavior and can
556
+ // never be honored by the agent — but delivery resolves topic-first, so
557
+ // it still shadows the chat value in its own topic. Clearing every one of
558
+ // them is part of writing at chat level, not a separate cleanup.
559
+ const clearStaleTopicRichText = () => {
560
+ if (!isRichText || chatConfig?.isolateTopics === true) return [];
561
+ const cleared = [];
562
+ for (const [tid, topic] of Object.entries(chatConfig.topics || {})) {
563
+ if (
564
+ topic
565
+ && typeof topic === 'object'
566
+ && Object.hasOwn(topic, configField)
567
+ ) {
568
+ cleared.push({ threadId: String(tid), scope: topic, value: topic[configField] });
569
+ delete topic[configField];
570
+ }
571
+ }
572
+ return cleared;
573
+ };
574
+ const restoreTopicRichText = (cleared) => {
575
+ for (const { scope, value } of cleared) scope[configField] = value;
576
+ };
577
+ // The user tapped one card; topics they never touched can change value
578
+ // with it. Record which ones and what they held, so a config that moved
579
+ // on its own is still explicable afterwards. Logged only once the sweep
580
+ // is persisted — an event for a rolled-back sweep is worse than none.
581
+ const logTopicRichTextSweep = (cleared) => {
582
+ if (!cleared.length) return;
583
+ dbWrite(() => {
584
+ db.logEvent('config-topic-override-swept', {
585
+ chat_id: chatId,
586
+ field: configField,
587
+ topics: cleared.map(({ threadId, value }) => ({
588
+ thread_id: threadId,
589
+ old_value: value,
590
+ })),
591
+ });
592
+ }, 'log topic-override sweep');
593
+ };
594
+
546
595
  if (oldValue === parsedValue) {
596
+ // Nothing to write — but a stale override is exactly why this tap can
597
+ // be a no-op, and leaving it keeps delivery and the session's hint
598
+ // disagreeing. Normalize the scope and keep the value the ack states:
599
+ // the chat level is the only place the shared session reads.
600
+ const cleared = clearStaleTopicRichText();
601
+ if (cleared.length) {
602
+ const hadChatValue = Object.hasOwn(chatConfig, configField);
603
+ const previousChatValue = chatConfig[configField];
604
+ chatConfig[configField] = parsedValue;
605
+ try {
606
+ await saveConfig();
607
+ logTopicRichTextSweep(cleared);
608
+ } catch (error) {
609
+ restoreTopicRichText(cleared);
610
+ if (hadChatValue) chatConfig[configField] = previousChatValue;
611
+ else delete chatConfig[configField];
612
+ logger.error?.(
613
+ `[${botName}] config-callback scope normalization failed: `
614
+ + error.message,
615
+ );
616
+ }
617
+ }
547
618
  releaseConfigIntent();
548
619
  await ctx.answerCallbackQuery({ text: `Already ${value}` })
549
620
  .catch(() => {});
550
621
  return;
551
622
  }
552
623
 
624
+ const clearedTopicRichText = clearStaleTopicRichText();
553
625
  writeScope[configField] = parsedValue;
554
626
  if (effortAdjustment) {
555
627
  writeScope.codexEffort = effortAdjustment.newValue;
@@ -561,6 +633,7 @@ function createHandleConfigCallback({
561
633
  if (snapshot.present) writeScope[field] = snapshot.value;
562
634
  else delete writeScope[field];
563
635
  }
636
+ restoreTopicRichText(clearedTopicRichText);
564
637
  logger.error?.(
565
638
  `[${botName}] config-callback saveConfig failed: ${error.message}`,
566
639
  );
@@ -571,6 +644,7 @@ function createHandleConfigCallback({
571
644
  }).catch(() => {});
572
645
  return;
573
646
  }
647
+ logTopicRichTextSweep(clearedTopicRichText);
574
648
  const cmdUserId = ctx.callbackQuery.from?.id || null;
575
649
  const cmdUser = ctx.callbackQuery.from?.first_name
576
650
  || ctx.callbackQuery.from?.username
@@ -707,14 +781,11 @@ function createHandleConfigCallback({
707
781
  logger.error?.(`[${botName}] config-card edit failed: ${err.message}`);
708
782
  }
709
783
 
710
- // Rich delivery changes immediately, but its authoring hint changes
711
- // on the next session spawn; surface both timings in the acknowledgement.
784
+ // Delivery changes immediately and the authoring hint follows on the
785
+ // user's next message: a hint drift reloads the session (conversation
786
+ // preserved), so there is no lag left worth a caveat.
712
787
  const ackText = isRichText
713
- ? (
714
- parsedValue
715
- ? 'Rich text → on (delivery live; agent authors for it next session)'
716
- : 'Rich text → off'
717
- )
788
+ ? `Rich text → ${parsedValue ? 'on' : 'off'}`
718
789
  : codex
719
790
  ? (
720
791
  `${setting} → ${value}`
@@ -4,23 +4,25 @@
4
4
  * the runtime/model/effort callback re-render paths
5
5
  * (lib/handlers/config-callback.js).
6
6
  *
7
- * Pure functions (no DB / fs) but `formatConfigInfoText` needs
8
- * runtime context (pm to check warm/cold, db + getClaudeSessionId
9
- * to fetch session id) → factory wraps it. Keyboard builder is a
10
- * top-level export.
7
+ * Pure functions (no DB / fs). `formatConfigInfoText` is factory-wrapped
8
+ * only so a caller can supply the async runtime-view resolver; the
9
+ * keyboard builder is a top-level export.
11
10
  *
12
11
  * MODEL_VERSIONS_DESC bumps with each Claude release — see polygram's
13
12
  * release notes for the verification step (`claude --model <alias>`
14
- * + check the system:init event's `model` field).
13
+ * + check the system:init event's `model` field). Surfaced by the
14
+ * /model reply, not by the card.
15
15
  */
16
16
 
17
17
  'use strict';
18
18
 
19
19
  const MODEL_OPTIONS = ['opus', 'sonnet', 'haiku'];
20
20
  const EFFORT_OPTIONS = ['low', 'medium', 'high', 'xhigh', 'max'];
21
+ // Which runtimes the card offers. The sdk backend stays fully supported in
22
+ // code and config files — it is per-token and nobody picks it from a chat, so
23
+ // it has no button. A chat configured on it simply shows no ✓ on this row.
21
24
  const RUNTIME_OPTIONS = Object.freeze([
22
- Object.freeze({ backend: 'sdk', label: 'Claude SDK' }),
23
- Object.freeze({ backend: 'cli', label: 'Claude CLI' }),
25
+ Object.freeze({ backend: 'cli', label: 'Claude' }),
24
26
  Object.freeze({ backend: 'codex', label: 'Codex' }),
25
27
  ]);
26
28
 
@@ -44,22 +46,6 @@ function canonicalRuntimeBackend(runtimeView) {
44
46
  return 'sdk';
45
47
  }
46
48
 
47
- function runtimeSelectionSource(runtimeView) {
48
- const source = runtimeView?.selectionSource
49
- ?? runtimeView?.source
50
- ?? runtimeView?.sources?.pm;
51
- return typeof source === 'string' && source.length > 0 ? source : null;
52
- }
53
-
54
- function formatRuntimeLine(runtimeView) {
55
- const backend = canonicalRuntimeBackend(runtimeView);
56
- const label = backend === 'codex'
57
- ? 'Codex app-server'
58
- : backend === 'cli' ? 'Claude CLI' : 'Claude SDK';
59
- const source = runtimeSelectionSource(runtimeView);
60
- return `Runtime: ${label}${source ? ` (source: ${source})` : ''}`;
61
- }
62
-
63
49
  function uniqueStrings(values) {
64
50
  if (!Array.isArray(values)) return [];
65
51
  return [...new Set(values.filter(
@@ -118,13 +104,6 @@ function formatSettingsPair(settings) {
118
104
  return `${settings.model}/${settings.effort}`;
119
105
  }
120
106
 
121
- function sameSettingsPair(left, right) {
122
- return (
123
- left?.model === right?.model
124
- && left?.effort === right?.effort
125
- );
126
- }
127
-
128
107
  function formatCodexSettingsOutcome(result) {
129
108
  const next = formatSettingsPair(result?.nextTurn);
130
109
  switch (result?.outcome) {
@@ -301,18 +280,14 @@ function buildConfigKeyboard(
301
280
  }
302
281
 
303
282
  /**
304
- * Factory for the card-body formatter. Needs runtime pm + db + a
305
- * getClaudeSessionId fetcher.
283
+ * Factory for the card-body formatter.
306
284
  *
307
- * @param {object} deps
308
- * @param {object} deps.pm
309
- * @param {object} deps.db
310
- * @param {(db, sessionKey) => string|null} deps.getClaudeSessionId
285
+ * @param {object} [deps]
286
+ * @param {(ctx: {sessionKey: string, chatId: string, threadId: ?string}) => object} [deps.resolveRuntimeView]
287
+ * — resolves the runtime view for callers that don't pass one; the view
288
+ * decides which runtime's help sections the card carries.
311
289
  */
312
290
  function createFormatConfigInfoText({
313
- pm,
314
- db,
315
- getClaudeSessionId,
316
291
  resolveRuntimeView = null,
317
292
  } = {}) {
318
293
  function formatWithRuntimeView(
@@ -323,148 +298,18 @@ function createFormatConfigInfoText({
323
298
  effectiveRichText,
324
299
  runtimeView,
325
300
  ) {
326
- const alive = pm.has(sessionKey) && !pm.get(sessionKey).closed;
327
301
  const codex = isCodexRuntimeView(runtimeView);
328
- // Per-topic overrides win over chat-level for the displayed values,
329
- // mirroring the spawn path (polygram.js: topicConfig.agent ||
330
- // chatConfig.agent). Pre-fix the card always read chat-level, so a topic's
331
- // /model showed the WRONG agent — shumorobot Music topic (thread 3) showed
332
- // "Agent: shumabit" instead of its music-curation:music-curator override
333
- // (2026-06-03). topicConfig defaults to null (chat-level) for callers with
334
- // no active topic.
335
- const model = configuredRuntimeValue(
336
- chatConfig,
337
- topicConfig,
338
- runtimeView,
339
- 'model',
340
- );
341
- const effort = configuredRuntimeValue(
342
- chatConfig,
343
- topicConfig,
344
- runtimeView,
345
- 'effort',
346
- );
302
+ // The selected Codex model decides which reasoning efforts its help section
303
+ // may list — per-topic overrides win over chat-level, mirroring the spawn
304
+ // path. Nothing else about the configuration is read here: the buttons under
305
+ // the card carry it, each with its own ✓.
347
306
  const codexModels = codex ? getCodexCatalogModels(runtimeView) : [];
307
+ const codexModel = codex
308
+ ? configuredRuntimeValue(chatConfig, topicConfig, runtimeView, 'model')
309
+ : null;
348
310
  const codexEfforts = codex
349
- ? getCodexCatalogEfforts(runtimeView, model)
311
+ ? getCodexCatalogEfforts(runtimeView, codexModel)
350
312
  : [];
351
- const agent = (topicConfig && topicConfig.agent) || chatConfig.agent;
352
- const codexModelEntry = codex
353
- ? codexModels
354
- .find((entry) => entry.model === model)
355
- : null;
356
- const codexDisplayName = codexModelEntry?.displayName;
357
- const ver = codex
358
- ? (
359
- typeof codexDisplayName === 'string'
360
- && codexDisplayName.length > 0
361
- && codexDisplayName !== model
362
- ? codexDisplayName
363
- : model
364
- )
365
- : MODEL_VERSIONS_DESC[model] || model;
366
- const sess = codex
367
- ? 'managed by Codex'
368
- : getClaudeSessionId(db, sessionKey)?.slice(0, 8) || 'new';
369
- // Running vs configured: cli can't hot-swap model/effort, so a /model or
370
- // /effort change is PENDING until the session reloads (on the next message).
371
- // Show the truth — the live proc's spawn-time value (proc.model/proc.effort)
372
- // vs the configured one — so the card never claims a model the session
373
- // isn't actually running (the "says opus, runs sonnet" confusion). SDK
374
- // applies live (its proc value tracks config) so no drift line ever shows.
375
- const proc = alive ? pm.get(sessionKey) : null;
376
- const runModel = proc && proc.model;
377
- const runEffort = proc && proc.effort;
378
- const modelLine = (runModel && runModel !== model)
379
- ? `Model: ${runModel} (running) → ${model} (pending — applies on your next message)`
380
- : `Model: ${model} (${ver})`;
381
- const effortLine = (runEffort && runEffort !== effort)
382
- ? `Effort: ${runEffort} (running) → ${effort} (pending — applies on your next message)`
383
- : `Effort: ${effort}`;
384
- // Delivery reads richText live, while the authoring hint is fixed at
385
- // session spawn. Explain that lag separately from model/effort,
386
- // which have concrete running and configured values to compare.
387
- const richText = resolveDisplayRichText(chatConfig, topicConfig, effectiveRichText);
388
- const richTextLine = richText
389
- ? 'Rich text: on (headings/tables/checklists on qualifying replies; agent authors for it once this session next (re)spawns)'
390
- : 'Rich text: off';
391
- const runtimeLine = formatRuntimeLine(runtimeView);
392
- let head;
393
- if (codex) {
394
- const desired = runtimeView.desiredSettings ?? { model, effort };
395
- const desiredPair = formatSettingsPair(desired)
396
- ?? 'incomplete Codex selection';
397
- const activePair = formatSettingsPair(runtimeView.activeTurnSettings);
398
- const nextTurn = runtimeView.nextTurnSettings;
399
- const nextTurnPair = formatSettingsPair(nextTurn);
400
- const observedPair = formatSettingsPair(
401
- runtimeView.observedThreadSettings,
402
- );
403
- const processStatus = runtimeView.processStatus
404
- ?? (alive ? 'loaded' : 'not-loaded');
405
- const settingsLines = [];
406
- if (processStatus === 'loaded') {
407
- if (activePair) {
408
- settingsLines.push(`Current turn: ${activePair}`);
409
- settingsLines.push(`Next turn: ${nextTurnPair ?? desiredPair}`);
410
- } else {
411
- settingsLines.push(
412
- `Selected for next turn: ${nextTurnPair ?? desiredPair}`,
413
- );
414
- }
415
- if (
416
- nextTurnPair
417
- && !sameSettingsPair(nextTurn, desired)
418
- ) {
419
- settingsLines.push(
420
- `Saved selection awaiting live reconciliation: ${desiredPair}`,
421
- );
422
- }
423
- } else if (processStatus === 'not-loaded') {
424
- settingsLines.push(
425
- `Selected for this chat's next session: ${desiredPair}`,
426
- );
427
- } else {
428
- settingsLines.push(`Selected: ${desiredPair}`);
429
- }
430
- if (observedPair) {
431
- settingsLines.push(`Observed thread: ${observedPair}`);
432
- }
433
- let processLine;
434
- if (processStatus === 'daemon-busy') {
435
- processLine = 'Process: not loaded; its next message may be busy';
436
- } else if (processStatus === 'unavailable') {
437
- processLine = 'Process: unavailable'
438
- + (
439
- runtimeView.unavailableReason
440
- ? ` (${runtimeView.unavailableReason})`
441
- : ''
442
- );
443
- } else if (processStatus === 'unknown') {
444
- processLine = 'Process: live status unknown';
445
- } else {
446
- processLine = `Process: ${
447
- processStatus === 'loaded' ? 'warm' : 'not loaded'
448
- }`;
449
- }
450
- head = [
451
- ...settingsLines,
452
- richTextLine,
453
- runtimeLine,
454
- processLine,
455
- `Session: ${sess}`,
456
- ].join('\n');
457
- } else {
458
- head =
459
- `${modelLine}\n` +
460
- `${effortLine}\n` +
461
- `${richTextLine}\n` +
462
- `${runtimeLine}\n` +
463
- `Agent: ${agent}\n` +
464
- `Process: ${alive ? 'warm' : 'cold'}\n` +
465
- `Session: ${sess}`;
466
- }
467
-
468
313
  const modelHelp = codex
469
314
  ? codexModels.length > 0 ? [
470
315
  '',
@@ -493,7 +338,7 @@ function createFormatConfigInfoText({
493
338
  const effortHelp = codex
494
339
  ? codexEfforts.length > 0 ? [
495
340
  '',
496
- `**Effort** — authenticated options for ${model}:`,
341
+ `**Effort** — authenticated options for ${codexModel}:`,
497
342
  ...codexEfforts.map((value) => `• **${value}**`),
498
343
  'Model and effort changes apply to the next turn; an active turn is unchanged.',
499
344
  ].join('\n') : [
@@ -510,7 +355,12 @@ function createFormatConfigInfoText({
510
355
  '• **xhigh** / **max** — heaviest. Hard reasoning, edge cases.',
511
356
  ].join('\n');
512
357
 
513
- let body = head;
358
+ // The card is its buttons. Everything the body used to state — model,
359
+ // effort, rich text, runtime, agent, process, session — is already on a
360
+ // button below it, with a ✓ on the current value. What is left is the
361
+ // header (Telegram rejects an empty message) and the help that has no
362
+ // button to live on.
363
+ let body = '⚙️ Settings';
514
364
  if (codex) {
515
365
  body += '\n\nNative Codex beta: command network and web search are disabled. '
516
366
  + 'Product MCP tools and interactive approvals are unavailable. '
@@ -1,25 +1,97 @@
1
1
  'use strict';
2
2
 
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+
3
8
  const CODEX_TEMP_SELECTOR = 'POLYGRAM_CODEX_TMPDIR';
4
9
  const ENV_BIN = '/usr/bin/env';
10
+ const APPEND_PROMPT_FLAG = '--append-system-prompt';
11
+ const APPEND_PROMPT_FILE_FLAG = '--append-system-prompt-file';
12
+
13
+ function removePromptFile(file) {
14
+ if (!file) return;
15
+ try { fs.unlinkSync(file); } catch {}
16
+ }
17
+
18
+ function stageAppendPrompt(args) {
19
+ const rewritten = [...(args ?? [])];
20
+ const promptIndex = rewritten.indexOf(APPEND_PROMPT_FLAG);
21
+ if (promptIndex === -1) return { args: rewritten, promptFile: null };
22
+
23
+ const prompt = rewritten[promptIndex + 1];
24
+ if (typeof prompt !== 'string') {
25
+ throw new TypeError(`${APPEND_PROMPT_FLAG} requires a string value`);
26
+ }
27
+
28
+ const promptFile = path.join(
29
+ os.tmpdir(),
30
+ `polygram-claude-prompt-${crypto.randomUUID()}.txt`,
31
+ );
32
+ try {
33
+ fs.writeFileSync(promptFile, prompt, {
34
+ encoding: 'utf8',
35
+ flag: 'wx',
36
+ mode: 0o600,
37
+ });
38
+ fs.chmodSync(promptFile, 0o600);
39
+ } catch (error) {
40
+ removePromptFile(promptFile);
41
+ throw error;
42
+ }
43
+
44
+ rewritten.splice(
45
+ promptIndex,
46
+ 2,
47
+ APPEND_PROMPT_FILE_FLAG,
48
+ promptFile,
49
+ );
50
+ return { args: rewritten, promptFile };
51
+ }
5
52
 
6
53
  function createClaudeTmuxRunner(runner) {
7
54
  if (!runner || typeof runner.spawn !== 'function') {
8
55
  throw new TypeError('Claude tmux runner requires spawn()');
9
56
  }
57
+ const promptFiles = new Map();
58
+
10
59
  return {
11
60
  ...runner,
12
- spawn(options) {
13
- return runner.spawn({
14
- ...options,
15
- command: ENV_BIN,
16
- args: [
17
- '-u',
18
- CODEX_TEMP_SELECTOR,
19
- options.command,
20
- ...(options.args ?? []),
21
- ],
22
- });
61
+ async spawn(options) {
62
+ const staged = stageAppendPrompt(options.args);
63
+ if (staged.promptFile) {
64
+ removePromptFile(promptFiles.get(options.name));
65
+ promptFiles.set(options.name, staged.promptFile);
66
+ }
67
+
68
+ try {
69
+ return await runner.spawn({
70
+ ...options,
71
+ command: ENV_BIN,
72
+ args: [
73
+ '-u',
74
+ CODEX_TEMP_SELECTOR,
75
+ options.command,
76
+ ...staged.args,
77
+ ],
78
+ });
79
+ } catch (error) {
80
+ if (promptFiles.get(options.name) === staged.promptFile) {
81
+ promptFiles.delete(options.name);
82
+ }
83
+ removePromptFile(staged.promptFile);
84
+ throw error;
85
+ }
86
+ },
87
+ async killSession(name, ...args) {
88
+ try {
89
+ return await runner.killSession?.(name, ...args);
90
+ } finally {
91
+ const promptFile = promptFiles.get(name);
92
+ promptFiles.delete(name);
93
+ removePromptFile(promptFile);
94
+ }
23
95
  },
24
96
  };
25
97
  }
@@ -107,17 +107,15 @@ function getTopicConfig(chatConfig, threadId) {
107
107
  }
108
108
 
109
109
  /**
110
- * Resolve the config object a per-chat/topic setting should be written to.
111
- * Process-level settings use topic scope only for isolated sessions. A live
112
- * delivery setting can opt into topic scope even when sessions are shared.
110
+ * Resolve the config object a per-chat/topic setting should be written to:
111
+ * topic scope only where the session itself is topic-scoped, so a setting can
112
+ * never be stored somewhere the session that must honor it cannot see.
113
113
  *
114
114
  * @returns {{ scope: object, threadId: (string|null) }}
115
115
  */
116
- function getConfigWriteScope(chatConfig, threadId, { allowNonIsolatedTopic = false } = {}) {
116
+ function getConfigWriteScope(chatConfig, threadId) {
117
117
  const tid = (threadId == null || threadId === '') ? null : String(threadId);
118
- // Process-scoped settings follow session isolation. Live delivery settings
119
- // may explicitly keep topic scope because they are resolved per message.
120
- if (tid && (chatConfig?.isolateTopics === true || allowNonIsolatedTopic)) {
118
+ if (tid && chatConfig?.isolateTopics === true) {
121
119
  chatConfig.topics = chatConfig.topics || {};
122
120
  // A topic can be stored in the legacy / hand-edited form where the entry
123
121
  // is the bare name string (topics["329"] = "Advertising") rather than the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
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": {
@@ -63,7 +63,7 @@
63
63
  "dependencies": {
64
64
  "@anthropic-ai/claude-agent-sdk": "0.2.123",
65
65
  "@modelcontextprotocol/sdk": "^1.29.0",
66
- "@shumkov/orchestra": "0.10.10",
66
+ "@shumkov/orchestra": "0.10.11",
67
67
  "better-sqlite3": "^12.9.0",
68
68
  "grammy": "^1.42.0",
69
69
  "markdown-it": "^14.1.1",
package/polygram.js CHANGED
@@ -896,6 +896,16 @@ async function buildSpawnContext(
896
896
  // the inbound filter and the send() choke point use, so CliProcess's
897
897
  // pre-check + system-prompt line can't drift from actual enforcement.
898
898
  localApi: !!config.bot?.apiRoot,
899
+ // The display hint this chat should be running under, resolved fresh on
900
+ // every context build. It is spawn-time state (woven into the appended
901
+ // system prompt), so a warm session only picks up a rich-text toggle if the
902
+ // context carries the new hint for getOrSpawn to notice the drift against.
903
+ // MUST be built exactly like the factory-side resolver below — a difference
904
+ // in either argument is permanent drift, i.e. a respawn per message.
905
+ displayHint: buildPolygramDisplayHint(
906
+ resolveRichTextEnabled(config, chatId, threadId || null),
907
+ { inlineMedia: true },
908
+ ),
899
909
  // Pre-resolve through the SAME backend-aware resolver origin's CliProcess used, so
900
910
  // orchestra (which now uses opts.outboundCapOverride verbatim, without its own
901
911
  // resolveFileCaps clamp) gets the backend default (2GB local / 50MB cloud) AND the
@@ -1113,9 +1123,9 @@ let handleSendOverIpc = null;
1113
1123
  // formatToolInputForCard).
1114
1124
 
1115
1125
  // Config card UI moved to lib/handlers/config-ui.js. polygram.js
1116
- // keeps a thin formatConfigInfoText wrapper since it needs the
1117
- // runtime pm + db + getClaudeSessionId; buildConfigKeyboard is
1118
- // pure and re-exported.
1126
+ // keeps a thin formatConfigInfoText wrapper since it supplies the
1127
+ // async runtime-view resolver; buildConfigKeyboard is pure and
1128
+ // re-exported.
1119
1129
  const {
1120
1130
  buildConfigKeyboard,
1121
1131
  createFormatConfigInfoText,
@@ -4188,7 +4198,6 @@ async function main() {
4188
4198
  // call time and captures the value (closure-by-value). v4 reviewer
4189
4199
  // caught this as the same class as the v3 BLOCKER.
4190
4200
  formatConfigInfoText = createFormatConfigInfoText({
4191
- pm, db, getClaudeSessionId,
4192
4201
  resolveRuntimeView: resolveSessionRuntimeView,
4193
4202
  });
4194
4203
  handleConfigCallback = createHandleConfigCallback({
@@ -5037,7 +5046,10 @@ async function main() {
5037
5046
  // (rc.65+) AND a session_id to resume into.
5038
5047
  if (o.text && savedSessionId) {
5039
5048
  try {
5040
- const entry = await pm.getOrSpawn(o.session_key, buildSpawnContext(o.session_key));
5049
+ const entry = await pm.getOrSpawn(
5050
+ o.session_key,
5051
+ await buildSpawnContext(o.session_key),
5052
+ );
5041
5053
  // 0.10.0 P0.4: route through Process.fireUserMessage so both
5042
5054
  // SDK and tmux backends work. Pre-0.10.0-P0.4 reached into
5043
5055
  // entry.inputController.push directly — broken on tmux.