polygram 0.11.0-rc.8 → 0.12.0-rc.1

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.11.0-rc.8",
4
+ "version": "0.11.0-rc.15",
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",
@@ -100,7 +100,7 @@
100
100
  "isolateTopics": true,
101
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, isolateUserConfig}. 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.",
102
102
  "_comment_isolateUserConfig": "0.10.0, tmux backend only: isolateUserConfig:true spawns the topic's claude TUI cut off from the user-level ~/.claude config — passes --strict-mcp-config (zero MCP servers load) and --setting-sources project,local (drops ~/.claude/settings.json; the spawn cwd's own .claude/settings.json still loads). Use it when a topic's agent would otherwise inherit slow user-global MCP servers whose cold-start (tens of seconds) wedges the TUI before it can accept a prompt. Settable at chat OR topic level (topic wins). Default false.",
103
- "_comment_pm": "0.11.0: 'pm' selects the Process backend: 'sdk' (default; per-token Console API; full SDK features), 'tmux' (subscription-priced claude CLI in tmux; JSONL/pane parsing for IO), 'channels' (subscription-priced claude CLI in tmux; structured IO via the official Channels MCP protocol — see docs/0.11.0-channels-driver-plan.md). Settable at bot, chat, OR topic level (topic > chat > bot). Channels requires Pro/Max subscription, Claude Code v2.1.80+, and is in research preview — invokes --dangerously-load-development-channels.",
103
+ "_comment_pm": "0.12.0: 'pm' selects the Process backend. Two canonical values: 'sdk' (default; per-token Console API billing; full SDK features) and 'cli' (subscription-priced claude CLI in tmux + Channels MCP bridge + hooks ndjson observability — see docs/0.12.0-cli-driver-plan.md). Settable at bot, chat, OR topic level (topic > chat > bot). Aliases preserved for back-compat with 0.10/0.11 configs: 'channels' and 'tmux' both resolve to 'cli' with a once-at-boot deprecation warn. CLI requires Pro/Max subscription, Claude Code v2.1.80+, and uses --dangerously-load-development-channels (research preview flag).",
104
104
  "topics": {
105
105
  "100": "Customer A",
106
106
  "200": {
package/lib/claude-bin.js CHANGED
@@ -4,16 +4,20 @@ const os = require('os');
4
4
  const path = require('path');
5
5
  const fs = require('fs');
6
6
 
7
+ // 0.12 Phase 4: moved from lib/process/tmux-process.js into the helper module
8
+ // that consumes it, so the constant survives TmuxProcess deletion. CliProcess
9
+ // + spike scripts + polygram boot all import from here now.
10
+ const CLAUDE_CLI_PINNED_VERSION = '2.1.142';
11
+
7
12
  /**
8
- * Resolve + verify the pinned claude CLI binary for the tmux backend.
13
+ * Resolve + verify the pinned claude CLI binary.
9
14
  *
10
- * Why this exists: the tmux backend reads claude CLI INTERNAL
11
- * artefacts (JSONL events, queue-operation semantics, TUI banner
12
- * ASCII, READY hint strings, stop_reason values) — none a stable
13
- * public contract. polygram pins ONE version
14
- * (CLAUDE_CLI_PINNED_VERSION in lib/process/tmux-process.js) and
15
- * must spawn THAT binary, never whatever `claude` on $PATH happens
16
- * to resolve to.
15
+ * Why this exists: the tmux + CLI backends read claude CLI internal
16
+ * artefacts (TUI banner ASCII, READY hint strings, channel notification
17
+ * registration timing, MCP-init order) — none a stable public contract.
18
+ * polygram pins ONE version (`CLAUDE_CLI_PINNED_VERSION`) and must
19
+ * spawn THAT binary, never whatever `claude` on $PATH happens to
20
+ * resolve to.
17
21
  *
18
22
  * Before this module the tmux runner spawned the bare string
19
23
  * `claude`, resolved through $PATH. The claude CLI installs each
@@ -75,4 +79,4 @@ function verifyPinnedClaudeBin(version) {
75
79
  }
76
80
  }
77
81
 
78
- module.exports = { resolvePinnedClaudeBin, verifyPinnedClaudeBin };
82
+ module.exports = { resolvePinnedClaudeBin, verifyPinnedClaudeBin, CLAUDE_CLI_PINNED_VERSION };
@@ -90,6 +90,13 @@ function createAutoResumeTracker({ cooldownMs = DEFAULT_COOLDOWN_MS, now = Date.
90
90
  */
91
91
  function isAutoResumable({ error, aborted, replay, shuttingDown }) {
92
92
  if (aborted || replay || shuttingDown) return false;
93
+ // Review F#6: channels analog of the tmux 'idle with no Claude activity'
94
+ // pattern. The bridge socket dropped mid-turn (claude crashed, bridge
95
+ // process died) — that's a wedge, not a runaway. Same intent as the
96
+ // regex match below, just expressed via err.code because channels throws
97
+ // a different message string. TURN_TIMEOUT stays NON-resumable (it's
98
+ // the channels analog of the wall-clock ceiling — likely a runaway).
99
+ if (error?.code === 'BRIDGE_DISCONNECTED') return true;
93
100
  const msg = String(error?.message || error || '');
94
101
  return /idle with no Claude activity/i.test(msg);
95
102
  }
@@ -205,12 +205,20 @@ function resolveSessionForSpawn(db, sessionKey, resolved = {}) {
205
205
  // of THAT task; claude responded with music release info, inline,
206
206
  // never calling the reply tool. Every turn timed out at 3min.
207
207
  //
208
- // Rule: any transition TO or FROM channels drops the prior session.
209
- // XOR — flips between channels and {sdk,tmux} invalidate; sdk↔tmux
210
- // flips remain free.
211
- const wasChannels = before.pm_backend === 'channels';
212
- const willBeChannels = after.pm_backend === 'channels';
213
- if (after.pm_backend != null && wasChannels !== willBeChannels) {
208
+ // Rule: any transition TO or FROM the channels/CLI backend drops the
209
+ // prior session. XOR — flips between (channels|cli) and {sdk,tmux}
210
+ // invalidate; sdk↔tmux flips remain free (rc.32 reasoning).
211
+ //
212
+ // 0.12: 'cli' is the canonical name for what was 'channels' in 0.11.
213
+ // Treat both as the same "channels-class" backend for transition
214
+ // invalidation purposes — a row persisted with pm_backend='channels'
215
+ // before 0.12 and a row created today with pm_backend='cli' are
216
+ // semantically the same in terms of session-context invariants
217
+ // (bridge MCP server mounted, reply-tool contract enforced).
218
+ const CHANNELS_CLASS = new Set(['channels', 'cli']);
219
+ const wasChannelsClass = CHANNELS_CLASS.has(before.pm_backend);
220
+ const willBeChannelsClass = CHANNELS_CLASS.has(after.pm_backend);
221
+ if (after.pm_backend != null && wasChannelsClass !== willBeChannelsClass) {
214
222
  drifted.push('pm_backend');
215
223
  }
216
224
 
@@ -163,6 +163,47 @@ const CODES = {
163
163
  isTransient: false,
164
164
  autoRecover: null,
165
165
  },
166
+ // Review F#5: channels-specific error codes. Pre-fix these fell through
167
+ // to the generic 'unknown' kind (errorReplyText: "Hit a snag. Try
168
+ // resending.") which lies about what happened. Mirrors the rc.46→rc.47
169
+ // tmuxToolWedge fix where backend-specific codes needed their own kinds.
170
+ //
171
+ // BRIDGE_DISCONNECTED: thrown by CliProcess when the mcp-bridge
172
+ // socket drops mid-turn (claude crashed, bridge process died, etc).
173
+ // isTransient: true because the daemon retries spawning the backend.
174
+ BRIDGE_DISCONNECTED: {
175
+ kind: 'bridgeDisconnected',
176
+ userMessage: '🔌 Lost the bridge to Claude mid-turn. Retrying — please resend if I don\'t reply in 30s.',
177
+ isTransient: true,
178
+ autoRecover: null,
179
+ },
180
+ // CHANNELS_HANDSHAKE_TIMEOUT: bridge process never sent session_init
181
+ // within the handshake window during start(). Usually means the bridge
182
+ // crashed pre-init or the socket file is stale.
183
+ CHANNELS_HANDSHAKE_TIMEOUT: {
184
+ kind: 'channelsHandshakeTimeout',
185
+ userMessage: '⏳ Couldn\'t start a Claude session — the bridge didn\'t respond in time. Try again in a moment.',
186
+ isTransient: true,
187
+ autoRecover: null,
188
+ },
189
+ // CHANNELS_DIALOG_TIMEOUT: a permission / usage-limit / context-overflow
190
+ // dialog opened mid-turn and we couldn't auto-respond within the dialog
191
+ // window. The turn is dead; user needs to retry.
192
+ CHANNELS_DIALOG_TIMEOUT: {
193
+ kind: 'channelsDialogTimeout',
194
+ userMessage: '🚧 Claude hit a dialog (permission/usage-limit) mid-turn and I couldn\'t auto-respond in time. Please resend.',
195
+ isTransient: false,
196
+ autoRecover: null,
197
+ },
198
+ // TURN_TIMEOUT: 10-min wall-clock cap on a single channels turn. Mirror
199
+ // of the tmux wall-clock ceiling — typically a runaway, not a wedge.
200
+ // Not transient (auto-retry would just runaway again).
201
+ TURN_TIMEOUT: {
202
+ kind: 'turnTimeout',
203
+ userMessage: '⏱ The turn ran past the 10-minute cap. Resend if the answer still matters.',
204
+ isTransient: false,
205
+ autoRecover: null,
206
+ },
166
207
  };
167
208
 
168
209
  /**
@@ -199,7 +199,17 @@ function createSlashCommands({
199
199
  }), 'log model change');
200
200
  const { anyActive } = await applyConfigChange('model', newModel);
201
201
  const ver = (modelVersionsDesc && modelVersionsDesc[newModel]) || newModel;
202
- const suffix = anyActive ? ` — I'll switch when I finish` : '';
202
+ // Review F#10: channels backend can't apply model/effort changes
203
+ // live — its setModel/applyFlagSettings throw UNSUPPORTED_OPERATION,
204
+ // pm.setModel returns false → `anyActive` is true → user saw the
205
+ // misleading "I'll switch when I finish" message. Now we detect
206
+ // the channels backend explicitly and give an honest answer:
207
+ // settings are persisted to chatConfig and take effect on the next
208
+ // /reset or /new (channels lacks an in-place re-init path).
209
+ const backendName = typeof pm.getBackend === 'function' ? pm.getBackend(sessionKey) : null;
210
+ const suffix = backendName === 'channels'
211
+ ? ` — applies on next /reset (channels)`
212
+ : (anyActive ? ` — I'll switch when I finish` : '');
203
213
  await sendReply(`Model → ${newModel} (${ver})${suffix}`);
204
214
  } else {
205
215
  await sendReply(`Unknown model. Use: opus, sonnet, haiku`);
@@ -219,7 +229,17 @@ function createSlashCommands({
219
229
  user: cmdUser, user_id: cmdUserId, source: 'command',
220
230
  }), 'log effort change');
221
231
  const { anyActive } = await applyConfigChange('effort', newEffort);
222
- const suffix = anyActive ? ` — I'll switch when I finish` : '';
232
+ // Review F#10: channels backend can't apply model/effort changes
233
+ // live — its setModel/applyFlagSettings throw UNSUPPORTED_OPERATION,
234
+ // pm.setModel returns false → `anyActive` is true → user saw the
235
+ // misleading "I'll switch when I finish" message. Now we detect
236
+ // the channels backend explicitly and give an honest answer:
237
+ // settings are persisted to chatConfig and take effect on the next
238
+ // /reset or /new (channels lacks an in-place re-init path).
239
+ const backendName = typeof pm.getBackend === 'function' ? pm.getBackend(sessionKey) : null;
240
+ const suffix = backendName === 'channels'
241
+ ? ` — applies on next /reset (channels)`
242
+ : (anyActive ? ` — I'll switch when I finish` : '');
223
243
  await sendReply(`Effort → ${newEffort}${suffix}`);
224
244
  } else {
225
245
  await sendReply(`Unknown effort. Use: low, medium, high, xhigh, max`);
@@ -2,7 +2,7 @@
2
2
  * Bridge ↔ daemon socket protocol — typed schemas.
3
3
  *
4
4
  * Wire format: newline-delimited JSON over a unix socket per session.
5
- * Both endpoints (ChannelsProcess and channels-bridge.mjs) speak the same
5
+ * Both endpoints (CliProcess and channels-bridge.mjs) speak the same
6
6
  * message kinds. This module centralizes the shape so both sides safeParse
7
7
  * inbound messages with the same constraints — protecting against malformed
8
8
  * payloads silently corrupting pending-state Maps.
@@ -10,7 +10,7 @@
10
10
  * Adding a new message kind:
11
11
  * 1. Define its schema below as `<KindName>MessageSchema`
12
12
  * 2. Add it to `AnyDaemonToBridgeMessage` or `AnyBridgeToDaemonMessage`
13
- * 3. Handle it in the corresponding switch (channels-process.js
13
+ * 3. Handle it in the corresponding switch (cli-process.js
14
14
  * _onBridgeMsg or channels-bridge.mjs handleDaemonMessage)
15
15
  *
16
16
  * Validation policy:
@@ -67,12 +67,22 @@ const PongMessageSchema = z.object({
67
67
  kind: z.literal('pong'),
68
68
  }).passthrough();
69
69
 
70
+ // 0.12 Phase 1.6: bridge tells daemon when claude has finished registering
71
+ // the bridge as an MCP server (claude sent its first ListToolsRequest).
72
+ // Polygram's _waitForBridgeHandshake gates on this in addition to hello,
73
+ // eliminating the cold-spawn race (Finding 0.3.A).
74
+ const McpReadyMessageSchema = z.object({
75
+ kind: z.literal('mcp-ready'),
76
+ session: NonEmptyString,
77
+ }).passthrough();
78
+
70
79
  const AnyBridgeToDaemonMessage = z.discriminatedUnion('kind', [
71
80
  HelloSchema,
72
81
  SessionInitSchema,
73
82
  ToolCallMessageSchema,
74
83
  PermRequestMessageSchema,
75
84
  PongMessageSchema,
85
+ McpReadyMessageSchema,
76
86
  ]);
77
87
 
78
88
  // ─── daemon → bridge ───────────────────────────────────────────────
@@ -2,11 +2,11 @@
2
2
  * ChannelsBridgeServer — per-session unix-socket server for the bridge
3
3
  * subprocess to connect back to.
4
4
  *
5
- * Extracted from ChannelsProcess (M1 refactor) so the socket lifecycle —
5
+ * Extracted from CliProcess (M1 refactor) so the socket lifecycle —
6
6
  * listen with restrictive umask, accept ONE bridge, hello-handshake auth,
7
7
  * line-delimited JSON I/O, schema validation, single-bridge-per-session
8
8
  * enforcement, clean teardown — lives in one focused class instead of
9
- * sprawling across ChannelsProcess.
9
+ * sprawling across CliProcess.
10
10
  *
11
11
  * Owns:
12
12
  * - net.Server lifecycle (listen / close)
@@ -17,11 +17,14 @@
17
17
  *
18
18
  * Does NOT own:
19
19
  * - protocol semantics (tool routing, perm relay, turn lifecycle) — those
20
- * stay in ChannelsProcess, which subscribes to the events this class emits
20
+ * stay in CliProcess, which subscribes to the events this class emits
21
21
  * - claude/bridge process lifecycle
22
22
  *
23
23
  * Event surface (EventEmitter):
24
- * 'bridge-ready' — handshake complete; safe to send daemon→bridge msgs
24
+ * 'bridge-ready' — daemon-side handshake (hello + session_init) complete
25
+ * 'mcp-ready' — claude-side MCP-server registration complete (first
26
+ * ListToolsRequest received from claude). 0.12 P1.6
27
+ * cold-spawn race fix — see channels-bridge.mjs.
25
28
  * 'bridge-message', msg — every validated bridge→daemon message (post-auth)
26
29
  * 'bridge-disconnected' — single-bridge connection closed
27
30
  * 'error', err — socket-level errors (rare; non-fatal)
@@ -29,6 +32,7 @@
29
32
 
30
33
  'use strict';
31
34
 
35
+ const crypto = require('node:crypto');
32
36
  const EventEmitter = require('node:events');
33
37
  const fs = require('node:fs');
34
38
  const net = require('node:net');
@@ -162,14 +166,26 @@ class ChannelsBridgeServer extends EventEmitter {
162
166
  }
163
167
 
164
168
  if (!authenticated) {
165
- if (raw.kind === 'hello'
166
- && raw.session_key === this.sessionKey
167
- && raw.secret === this.sockSecret) {
169
+ // Review F#7: harden the hello-handshake.
170
+ // 1. timingSafeEqual for the secret compare so a same-uid
171
+ // attacker can't byte-by-byte probe via response-timing.
172
+ // 2. ROTATE the secret after first successful auth (set to
173
+ // null) so a stale POLYGRAM_SOCK_SECRET leaked via
174
+ // /proc/<pid>/environ can't replay against this
175
+ // CliProcess after the legit bridge disconnects.
176
+ // The bridge process is one-shot per spawn anyway (it
177
+ // exits on socket close — see channels-bridge.mjs:109),
178
+ // so legitimate re-auth within one CliProcess
179
+ // instance never happens — only a hijacker would.
180
+ const verdict = this._verifyHelloAuth(raw);
181
+ if (verdict.ok) {
168
182
  authenticated = true;
169
183
  this.authenticated = true;
184
+ this.sockSecret = null; // invalidate — single-shot per instance
170
185
  try { conn.write(JSON.stringify({ kind: 'hello_ack' }) + '\n'); } catch {}
171
186
  continue;
172
187
  }
188
+ this.logger.warn?.(`[${this.label}] hello rejected — reason=${verdict.reason}`);
173
189
  try { conn.write(JSON.stringify({ kind: 'hello_reject', reason: 'auth' }) + '\n'); } catch {}
174
190
  conn.end();
175
191
  this.conn = null;
@@ -193,6 +209,13 @@ class ChannelsBridgeServer extends EventEmitter {
193
209
  this.emit('bridge-ready');
194
210
  continue;
195
211
  }
212
+ if (parsed.msg.kind === 'mcp-ready') {
213
+ // 0.12 Phase 1.6: bridge signals that claude has finished
214
+ // registering it as an MCP server. Polygram gates send() on this
215
+ // (Finding 0.3.A — cold-spawn race).
216
+ this.emit('mcp-ready', parsed.msg);
217
+ continue;
218
+ }
196
219
  this.emit('bridge-message', parsed.msg);
197
220
  }
198
221
  });
@@ -209,6 +232,43 @@ class ChannelsBridgeServer extends EventEmitter {
209
232
  this.logger.warn?.(`[${this.label}] bridge conn error: ${err.message}`);
210
233
  });
211
234
  }
235
+
236
+ /**
237
+ * Review F#7: hello-handshake verification, extracted as a pure method so it
238
+ * can be exercised in isolation. Returns `{ ok: true }` on accept or
239
+ * `{ ok: false, reason }` on reject. Uses crypto.timingSafeEqual for the
240
+ * secret compare and refuses if this.sockSecret has already been consumed
241
+ * (post-auth rotation).
242
+ *
243
+ * @param {object} raw — parsed bridge→daemon hello payload
244
+ * @returns {{ ok: true } | { ok: false, reason: string }}
245
+ */
246
+ _verifyHelloAuth(raw) {
247
+ if (this.sockSecret == null) {
248
+ return { ok: false, reason: 'secret-consumed' };
249
+ }
250
+ if (!raw || raw.kind !== 'hello') {
251
+ return { ok: false, reason: 'not-hello' };
252
+ }
253
+ if (raw.session_key !== this.sessionKey) {
254
+ return { ok: false, reason: 'wrong-session-key' };
255
+ }
256
+ if (typeof raw.secret !== 'string' || raw.secret.length === 0) {
257
+ return { ok: false, reason: 'no-secret' };
258
+ }
259
+ const a = Buffer.from(raw.secret, 'utf8');
260
+ const b = Buffer.from(this.sockSecret, 'utf8');
261
+ if (a.length !== b.length) {
262
+ // timingSafeEqual requires equal-length inputs; length mismatch is a
263
+ // wrong-secret signal but constant-time compares MUST short-circuit
264
+ // here (otherwise we'd leak the secret's length).
265
+ return { ok: false, reason: 'wrong-secret' };
266
+ }
267
+ if (!crypto.timingSafeEqual(a, b)) {
268
+ return { ok: false, reason: 'wrong-secret' };
269
+ }
270
+ return { ok: true };
271
+ }
212
272
  }
213
273
 
214
274
  module.exports = { ChannelsBridgeServer };
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- // polygram-bridge — production Channels MCP bridge for ChannelsProcess.
2
+ // polygram-bridge — production Channels MCP bridge for CliProcess.
3
3
  //
4
4
  // Runs as stdio child of `claude --dangerously-load-development-channels server:polygram-bridge`.
5
- // Connects back to its parent ChannelsProcess (in the polygram daemon) over a per-session
5
+ // Connects back to its parent CliProcess (in the polygram daemon) over a per-session
6
6
  // unix socket whose path + auth secret are passed via env.
7
7
  //
8
8
  // Owns nothing semantic. Pure proxy:
@@ -23,9 +23,19 @@
23
23
  import { Server } from '@modelcontextprotocol/sdk/server/index.js'
24
24
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
25
25
  import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'
26
+ // Review F#15: validate daemon→bridge messages with the shared zod schema.
27
+ // Pre-fix handleDaemonMessage operated on raw JSON.parse output — a
28
+ // malformed user_msg (e.g. text=undefined) silently injected the literal
29
+ // string "undefined" into Claude's prompt; a malformed tool_ack with
30
+ // null tool_call_id silently no-op'd and the bridge timed out on
31
+ // awaitToolAck → isError → Claude retry.
32
+ import { parseDaemonToBridgeMessage } from './channels-bridge-protocol.js'
26
33
  import { z } from 'zod'
27
34
  import { connect } from 'node:net'
28
35
  import { randomUUID } from 'node:crypto'
36
+ import { appendFileSync, mkdirSync } from 'node:fs'
37
+ import { join } from 'node:path'
38
+ import { homedir } from 'node:os'
29
39
 
30
40
  const SESSION_KEY = process.env.POLYGRAM_SESSION_KEY
31
41
  const SOCK = process.env.POLYGRAM_SOCK
@@ -38,8 +48,23 @@ if (!SESSION_KEY || !SOCK || !SOCK_SECRET) {
38
48
  process.exit(2)
39
49
  }
40
50
 
41
- const log = (kind, payload = {}) =>
42
- process.stderr.write(`[polygram-bridge] ${JSON.stringify({ t: Date.now(), kind, ...payload })}\n`)
51
+ // rc.11 diagnostic: bridge stderr goes to claude's TUI which is a tiny
52
+ // scrollback. The Music-topic shumorobot live failure leaves no trace of
53
+ // whether user_msg ever reached the bridge or whether the MCP notification
54
+ // dispatched successfully. Mirror every log line to a per-session file so
55
+ // we can definitively pin the failure point.
56
+ const LOG_DIR = join(homedir(), '.polygram', 'bridge-logs')
57
+ try { mkdirSync(LOG_DIR, { recursive: true }) } catch {}
58
+ // Filename: session-key gets sanitized (`:` → `_`) for file safety.
59
+ const LOG_FILE = join(LOG_DIR, `${String(SESSION_KEY).replace(/[^a-zA-Z0-9_-]/g, '_')}.${process.pid}.log`)
60
+ const fileWrite = (line) => { try { appendFileSync(LOG_FILE, line + '\n') } catch {} }
61
+
62
+ const log = (kind, payload = {}) => {
63
+ const line = `[polygram-bridge] ${JSON.stringify({ t: Date.now(), kind, ...payload })}`
64
+ process.stderr.write(line + '\n')
65
+ fileWrite(line)
66
+ }
67
+ log('boot', { session_key: SESSION_KEY, log_file: LOG_FILE, pid: process.pid })
43
68
 
44
69
  // ─── Stdin EOF → claude crashed; we exit so the daemon notices via socket close ──
45
70
  process.stdin.on('end', () => { log('stdin', { event: 'end' }); process.exit(0) })
@@ -124,9 +149,23 @@ sock.on('data', chunk => {
124
149
  const line = buf.slice(0, nl)
125
150
  buf = buf.slice(nl + 1)
126
151
  if (!line.trim()) continue
127
- let msg
128
- try { msg = JSON.parse(line) } catch { log('parse-error', { line: line.slice(0, 200) }); continue }
129
- handleDaemonMessage(msg)
152
+ let raw
153
+ try { raw = JSON.parse(line) } catch { log('parse-error', { line: line.slice(0, 200) }); continue }
154
+ // Review F#15: zod-validate before dispatch. Malformed messages drop with
155
+ // a log instead of silently corrupting downstream state. hello_ack /
156
+ // hello_reject are skipped here because they're pre-auth and the
157
+ // discriminated union expects only post-auth shapes — handle them
158
+ // directly off the raw payload.
159
+ if (raw.kind === 'hello_ack' || raw.kind === 'hello_reject') {
160
+ handleDaemonMessage(raw)
161
+ continue
162
+ }
163
+ const parsed = parseDaemonToBridgeMessage(raw)
164
+ if (!parsed.ok) {
165
+ log('daemon-msg-schema-invalid', { kind: raw?.kind, error: parsed.error })
166
+ continue
167
+ }
168
+ handleDaemonMessage(parsed.msg)
130
169
  }
131
170
  })
132
171
 
@@ -142,6 +181,7 @@ function handleDaemonMessage(msg) {
142
181
  break
143
182
 
144
183
  case 'user_msg':
184
+ log('user_msg-rx', { text_len: msg.text?.length, turn_id: msg.turn_id, chat_id: msg.chat_id })
145
185
  mcp.notification({
146
186
  method: 'notifications/claude/channel',
147
187
  params: {
@@ -156,7 +196,10 @@ function handleDaemonMessage(msg) {
156
196
  turn_id: escapeChannelAttr(msg.turn_id ?? ''),
157
197
  },
158
198
  },
159
- }).catch(e => log('notify-error', { kind: 'user_msg', error: e.message }))
199
+ }).then(
200
+ () => log('user_msg-notify-ok', { turn_id: msg.turn_id }),
201
+ (e) => log('notify-error', { kind: 'user_msg', error: e.message }),
202
+ )
160
203
  break
161
204
 
162
205
  case 'perm_verdict':
@@ -203,7 +246,27 @@ const mcp = new Server(
203
246
  },
204
247
  )
205
248
 
206
- mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
249
+ // 0.12 Phase 1.6 — MCP-ready signal (cold-spawn race fix, Finding 0.3.A).
250
+ // Claude's MCP client calls ListTools exactly once during server registration
251
+ // (after Initialize, before notifications can be routed). When that first
252
+ // call arrives here, we know claude has the bridge fully registered and
253
+ // will route incoming notifications to our 'claude/channel' capability.
254
+ // We tell the daemon by writing a single {kind:'mcp-ready'} message, and
255
+ // polygram's _waitForBridgeHandshake gates send() on this in addition to
256
+ // the existing daemon-side hello. Before this fix, polygram's handshake
257
+ // resolved when the bridge connected to the daemon socket — BEFORE claude
258
+ // finished MCP registration — and user_msg notifications were silently
259
+ // dropped 33% of the time (probe-cold-spawn.mjs).
260
+ let _mcpReadySent = false
261
+ mcp.setRequestHandler(ListToolsRequestSchema, async () => {
262
+ if (!_mcpReadySent) {
263
+ _mcpReadySent = true
264
+ log('mcp-ready', { trigger: 'first ListToolsRequest' })
265
+ try { sock.write(JSON.stringify({ kind: 'mcp-ready', session: SESSION_KEY }) + '\n') } catch (err) {
266
+ log('mcp-ready-write-fail', { error: err.message })
267
+ }
268
+ }
269
+ return {
207
270
  tools: [{
208
271
  name: 'reply',
209
272
  description: 'Send a message back to the originating Telegram chat. ' +
@@ -221,7 +284,8 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
221
284
  required: ['chat_id', 'text'],
222
285
  },
223
286
  }],
224
- }))
287
+ }
288
+ })
225
289
 
226
290
  mcp.setRequestHandler(CallToolRequestSchema, async req => {
227
291
  if (req.params.name !== 'reply') {
@@ -245,13 +309,20 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
245
309
  })
246
310
 
247
311
  // ─── Permission relay: Claude Code → bridge → daemon → human → verdict back ──
312
+ // Review F#14: only request_id + tool_name are required. description /
313
+ // input_preview MAY be empty (Bash with no args, future tool variants, slim
314
+ // tools that don't carry a preview). Pre-fix any of those four being absent
315
+ // or empty rejected the whole notification — MCP silently dropped the perm
316
+ // request, no approval card surfaced, Claude blocked forever waiting for a
317
+ // verdict that never came. Now those two are optional+defaulted to '' so
318
+ // the perm request always relays.
248
319
  const PermissionRequestSchema = z.object({
249
320
  method: z.literal('notifications/claude/channel/permission_request'),
250
321
  params: z.object({
251
- request_id: z.string(),
252
- tool_name: z.string(),
253
- description: z.string(),
254
- input_preview: z.string(),
322
+ request_id: z.string().min(1),
323
+ tool_name: z.string().min(1),
324
+ description: z.string().optional().default(''),
325
+ input_preview: z.string().optional().default(''),
255
326
  }).passthrough(),
256
327
  })
257
328
 
@@ -1,15 +1,15 @@
1
1
  /**
2
- * channels-tool-dispatcher — adapter between ChannelsProcess's reply tool
2
+ * channels-tool-dispatcher — adapter between CliProcess's reply tool
3
3
  * callback and polygram's existing Telegram delivery primitives.
4
4
  *
5
- * ChannelsProcess calls `toolDispatcher({sessionKey, chatId, threadId,
5
+ * CliProcess calls `toolDispatcher({sessionKey, chatId, threadId,
6
6
  * toolName, text, files})` whenever Claude invokes the reply tool over
7
7
  * the Channels protocol. This module wires that into:
8
8
  * - lib/telegram/chunk.js for size-aware splitting
9
9
  * - lib/telegram/deliver.js for the actual sendMessage loop
10
10
  * - bot.api.sendPhoto/Document for file attachments
11
11
  *
12
- * The dispatcher returns `{ok: boolean, error?: string}` — ChannelsProcess
12
+ * The dispatcher returns `{ok: boolean, error?: string}` — CliProcess
13
13
  * relays this to the bridge as tool_ack, which surfaces to Claude as the
14
14
  * tool's return value (`'sent'` on ok, error message on failure).
15
15
  *
@@ -84,8 +84,12 @@ function validateAttachmentPath(filePath, allowedRoots) {
84
84
  * @param {object} deps
85
85
  * @param {object} deps.bot — grammy Bot instance
86
86
  * @param {Function} deps.send — tg(bot, method, params, meta) sender wrapper
87
- * @param {Function} deps.chunkText — (text, maxLen?) → string[] chunks
88
- * @param {object} [deps.deliverReplies]optional pre-bound deliverReplies; defaults to lib/telegram/deliver.deliverReplies
87
+ * @param {Function} deps.chunkText — (text, maxLen?) → string[] chunks (chunkMarkdownText)
88
+ * @param {Function} deps.deliverReplies — async ({ bot, send, chatId, threadId, chunks, replyToMessageId, meta, logger }) → { sent, failed }
89
+ * @param {Function} deps.parseResponse — Review F#1: text → { text, sticker, stickers[], reaction, reactions[], ... }; required so [sticker:NAME] / [react:EMOJI] don't leak as literal text
90
+ * @param {Function} deps.sanitizeAssistantReply — Review F#1: text → { text, replaced, original? }; required so CLI canned strings (`No response requested.`) are intercepted
91
+ * @param {Function} [deps.processAndDeliverAgentText] — Review F#1: defaults to lib/telegram/process-agent-reply.js helper; DI-overridable for tests
92
+ * @param {Function} [deps.logEvent] — (kind, detail) → void; piped into the helper for canned-reply-suppressed forensics
89
93
  * @param {object} [deps.logger=console]
90
94
  * @param {number} [deps.maxChunkLen=4000] — TG hard cap is 4096; leave headroom for HTML wrapping
91
95
  * @param {string[]} [deps.attachmentAllowlist] — additional absolute-path roots files may live under (extends defaults)
@@ -96,6 +100,10 @@ function createChannelsToolDispatcher({
96
100
  send,
97
101
  chunkText,
98
102
  deliverReplies,
103
+ parseResponse,
104
+ sanitizeAssistantReply,
105
+ processAndDeliverAgentText,
106
+ logEvent = null,
99
107
  logger = console,
100
108
  maxChunkLen = 4000,
101
109
  attachmentAllowlist = [],
@@ -104,15 +112,20 @@ function createChannelsToolDispatcher({
104
112
  if (typeof send !== 'function') throw new TypeError('channels-tool-dispatcher: send required');
105
113
  if (typeof chunkText !== 'function') throw new TypeError('channels-tool-dispatcher: chunkText required');
106
114
  if (typeof deliverReplies !== 'function') throw new TypeError('channels-tool-dispatcher: deliverReplies required');
115
+ if (typeof parseResponse !== 'function') throw new TypeError('channels-tool-dispatcher: parseResponse required (Review F#1)');
116
+ if (typeof sanitizeAssistantReply !== 'function') throw new TypeError('channels-tool-dispatcher: sanitizeAssistantReply required (Review F#1)');
107
117
 
108
- // Review M3: deliverReplies is required now (was optional with lazy
109
- // require fallback). The lazy fallback meant two code paths reached the
110
- // same destination, which would silently drift if lib/telegram/deliver
111
- // renamed its export. Single explicit DI is cleaner.
112
- const deliver = deliverReplies;
118
+ // Review F#1: route reply text through the shared agent-reply pipeline so
119
+ // parseResponse + sanitizeAssistantReply + chunkMarkdownText + deliverReplies
120
+ // + inline sticker/reaction handling fire uniformly with SDK/tmux callers.
121
+ // Pre-fix the dispatcher did raw `chunkText` + `deliver()`, leaking
122
+ // [sticker:NAME], [react:EMOJI], and `No response requested.` as literal
123
+ // text into Telegram.
124
+ const deliverAgent = processAndDeliverAgentText
125
+ || require('../telegram/process-agent-reply').processAndDeliverAgentText;
113
126
 
114
127
  return async function channelsToolDispatcher(call) {
115
- const { sessionKey, chatId, threadId, toolName, text, files } = call;
128
+ const { sessionKey, chatId, threadId, toolName, text, files, sourceMsgId } = call;
116
129
 
117
130
  if (toolName !== 'reply') {
118
131
  // 0.11.0 Phase 1 ships `reply` only — react and edit_message are
@@ -128,21 +141,34 @@ function createChannelsToolDispatcher({
128
141
  }
129
142
 
130
143
  try {
131
- const chunks = chunkText(text, maxChunkLen);
132
- const result = await deliver({
144
+ // Review F#1: helper does parse → sanitize → chunk → deliver →
145
+ // inline-sticker reaction in one place. summary.deliverResult is
146
+ // null if the post-parse text was empty (solo sticker/reaction).
147
+ const summary = await deliverAgent({
148
+ text,
133
149
  bot,
134
- send,
150
+ tg: send,
135
151
  chatId,
136
152
  threadId,
137
- chunks,
138
- replyToMessageId: null, // ChannelsProcess doesn't track source-msg per-reply yet
139
- meta: { source: 'channels-tool-dispatcher', sessionKey, toolName },
153
+ replyToMessageId: sourceMsgId || null,
154
+ applyReactions: sourceMsgId != null,
155
+ source: 'channels-tool-dispatcher',
156
+ meta: { sessionKey, toolName },
157
+ parseResponse,
158
+ sanitizeAssistantReply,
159
+ chunkMarkdownText: chunkText,
160
+ deliverReplies,
161
+ chunkBudget: maxChunkLen,
162
+ logEvent,
163
+ sessionKey,
140
164
  logger,
141
165
  });
142
166
 
143
- if (result.failed?.length > 0) {
144
- const failedDetail = result.failed.map(f => f.error?.message || 'unknown').join(', ');
145
- return { ok: false, error: `delivered ${result.sent.length} of ${chunks.length} chunks; failed: ${failedDetail}` };
167
+ const dr = summary.deliverResult;
168
+ if (dr && dr.failed?.length > 0) {
169
+ const failedDetail = dr.failed.map(f => f.error?.message || 'unknown').join(', ');
170
+ const totalChunks = (dr.sent?.length || 0) + dr.failed.length;
171
+ return { ok: false, error: `delivered ${dr.sent?.length || 0} of ${totalChunks} chunks; failed: ${failedDetail}` };
146
172
  }
147
173
 
148
174
  // File attachments — sent as separate messages AFTER the text.