polygram 0.11.0-rc.9 → 0.12.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/config.example.json +1 -1
- package/lib/claude-bin.js +13 -9
- package/lib/db/auto-resume.js +7 -0
- package/lib/db/sessions.js +14 -6
- package/lib/error/classify.js +41 -0
- package/lib/handlers/slash-commands.js +22 -2
- package/lib/process/channels-bridge-protocol.js +12 -2
- package/lib/process/channels-bridge-server.js +67 -7
- package/lib/process/channels-bridge.mjs +85 -14
- package/lib/process/channels-tool-dispatcher.js +46 -20
- package/lib/process/{channels-process.js → cli-process.js} +1008 -86
- package/lib/process/factory.js +112 -47
- package/lib/process/hook-event-tail.js +1 -1
- package/lib/process/hook-settings.js +33 -3
- package/lib/process-manager.js +24 -1
- package/lib/sdk/callbacks.js +173 -1
- package/lib/telegram/process-agent-reply.js +233 -0
- package/lib/telegram/reactions.js +9 -0
- package/lib/tmux/log-tail.js +1 -1
- package/lib/tmux/startup-gate.js +1 -1
- package/lib/{tmux/session-log-parser.js → util/claude-session-jsonl.js} +20 -9
- package/package.json +3 -3
- package/polygram.js +74 -57
- package/lib/process/tmux-process.js +0 -3321
- package/lib/process/turn-phase.js +0 -150
- package/lib/telegram/heartbeat-reactor.js +0 -254
- package/lib/tmux/tui-tool-input.js +0 -62
|
@@ -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.
|
|
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",
|
package/config.example.json
CHANGED
|
@@ -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.
|
|
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
|
|
13
|
+
* Resolve + verify the pinned claude CLI binary.
|
|
9
14
|
*
|
|
10
|
-
* Why this exists: the tmux
|
|
11
|
-
* artefacts (
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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 };
|
package/lib/db/auto-resume.js
CHANGED
|
@@ -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
|
}
|
package/lib/db/sessions.js
CHANGED
|
@@ -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
|
|
209
|
-
// XOR — flips between channels and {sdk,tmux}
|
|
210
|
-
// flips remain free.
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
package/lib/error/classify.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
42
|
-
|
|
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
|
|
128
|
-
try {
|
|
129
|
-
|
|
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
|
-
}).
|
|
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
|
-
|
|
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
|
|
2
|
+
* channels-tool-dispatcher — adapter between CliProcess's reply tool
|
|
3
3
|
* callback and polygram's existing Telegram delivery primitives.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
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}` —
|
|
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 {
|
|
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
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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.
|