polygram 0.17.10 → 0.17.12
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/lib/config.js +21 -0
- package/lib/error/classify.js +14 -0
- package/lib/handlers/dispatcher.js +54 -1
- package/lib/handlers/drop-redeliver.js +19 -0
- package/lib/handlers/should-handle.js +52 -0
- package/lib/media-group-buffer.js +29 -0
- package/lib/ops/auth-disabled-gate.js +43 -0
- package/lib/ops/heartbeat.js +56 -0
- package/lib/process/channels-tool-dispatcher.js +12 -1
- package/lib/prompt.js +22 -7
- package/lib/sdk/callbacks.js +2 -1
- package/lib/secret-detect.js +13 -1
- package/lib/telegram/chunk.js +20 -6
- package/lib/telegram/process-agent-reply.js +5 -3
- package/lib/telegram/streamer.js +6 -1
- package/package.json +2 -1
- package/polygram.js +202 -45
- package/lib/async-lock.js +0 -49
- package/lib/claude-bin.js +0 -246
- package/lib/compaction-warn.js +0 -59
- package/lib/context-usage.js +0 -93
- package/lib/process/channels-bridge-protocol.js +0 -199
- package/lib/process/channels-bridge-server.js +0 -274
- package/lib/process/channels-bridge.mjs +0 -477
- package/lib/process/cli-process.js +0 -4029
- package/lib/process/factory.js +0 -215
- package/lib/process/hook-event-tail.js +0 -162
- package/lib/process/hook-settings.js +0 -181
- package/lib/process/polygram-hook-append.js +0 -71
- package/lib/process/process.js +0 -215
- package/lib/process/sdk-process.js +0 -880
- package/lib/process-guard.js +0 -296
- package/lib/process-manager.js +0 -628
- package/lib/tmux/log-tail.js +0 -334
- package/lib/tmux/orphan-sweep.js +0 -79
- package/lib/tmux/poll-scheduler.js +0 -110
- package/lib/tmux/startup-gate.js +0 -250
- package/lib/tmux/tmux-runner.js +0 -412
|
@@ -1,477 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// polygram-bridge — production Channels MCP bridge for CliProcess.
|
|
3
|
-
//
|
|
4
|
-
// Runs as stdio child of `claude --dangerously-load-development-channels server:polygram-bridge`.
|
|
5
|
-
// Connects back to its parent CliProcess (in the polygram daemon) over a per-session
|
|
6
|
-
// unix socket whose path + auth secret are passed via env.
|
|
7
|
-
//
|
|
8
|
-
// Owns nothing semantic. Pure proxy:
|
|
9
|
-
// daemon → bridge: user_msg, perm_verdict, tool_ack, ping
|
|
10
|
-
// bridge → daemon: hello, session_init, tool, perm_req, pong
|
|
11
|
-
//
|
|
12
|
-
// The bridge process exits on any of:
|
|
13
|
-
// - stdin EOF/close (claude crashed or shutdown)
|
|
14
|
-
// - no ping from daemon for 30s (daemon stalled or crashed)
|
|
15
|
-
// - hello handshake rejected by daemon
|
|
16
|
-
// - unix socket disconnect
|
|
17
|
-
//
|
|
18
|
-
// All inbound user content is XML-escaped before placement into the
|
|
19
|
-
// <channel> body — prompt-injection defense (P1 security finding).
|
|
20
|
-
//
|
|
21
|
-
// See docs/0.11.0-channels-driver-plan.md for the full design.
|
|
22
|
-
|
|
23
|
-
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
24
|
-
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
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'
|
|
33
|
-
import { z } from 'zod'
|
|
34
|
-
import { connect } from 'node:net'
|
|
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'
|
|
39
|
-
|
|
40
|
-
const SESSION_KEY = process.env.POLYGRAM_SESSION_KEY
|
|
41
|
-
const SOCK = process.env.POLYGRAM_SOCK
|
|
42
|
-
const SOCK_SECRET = process.env.POLYGRAM_SOCK_SECRET
|
|
43
|
-
// P3 naming: align internal variable with the env-var name + wire-format field.
|
|
44
|
-
const CLAUDE_SESSION_ID = process.env.POLYGRAM_CLAUDE_SESSION_ID
|
|
45
|
-
|
|
46
|
-
if (!SESSION_KEY || !SOCK || !SOCK_SECRET) {
|
|
47
|
-
process.stderr.write('[polygram-bridge] missing required env (POLYGRAM_SESSION_KEY/SOCK/SOCK_SECRET)\n')
|
|
48
|
-
process.exit(2)
|
|
49
|
-
}
|
|
50
|
-
|
|
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 })
|
|
68
|
-
|
|
69
|
-
// ─── Stdin EOF → claude crashed; we exit so the daemon notices via socket close ──
|
|
70
|
-
process.stdin.on('end', () => { log('stdin', { event: 'end' }); process.exit(0) })
|
|
71
|
-
process.stdin.on('close', () => { log('stdin', { event: 'close' }); process.exit(0) })
|
|
72
|
-
|
|
73
|
-
// ─── Watchdog: exit if daemon stops pinging ──
|
|
74
|
-
let lastPing = Date.now()
|
|
75
|
-
setInterval(() => {
|
|
76
|
-
if (Date.now() - lastPing > 30_000) {
|
|
77
|
-
log('watchdog', { event: 'ping-timeout' })
|
|
78
|
-
process.exit(3)
|
|
79
|
-
}
|
|
80
|
-
}, 5_000).unref()
|
|
81
|
-
|
|
82
|
-
// ─── XML-escape inbound user content (prompt-injection defense) ──
|
|
83
|
-
// Body escape: covers &, <, > so user text can't open/close <channel> tags
|
|
84
|
-
// or inject entity references.
|
|
85
|
-
const escapeChannelBody = s =>
|
|
86
|
-
String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
87
|
-
|
|
88
|
-
// Attribute escape: review #10. Meta values (chat_id, user, msg_id, turn_id)
|
|
89
|
-
// end up inside <channel ... key="value"> attributes. Telegram first_name is
|
|
90
|
-
// fully user-controlled and can contain double-quote, single-quote, &, <, >.
|
|
91
|
-
// Without escaping, a display name like `" injected="...</channel><system>...`
|
|
92
|
-
// breaks out of the attribute and injects into Claude's prompt.
|
|
93
|
-
const escapeChannelAttr = s =>
|
|
94
|
-
String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
95
|
-
.replace(/"/g, '"').replace(/'/g, ''')
|
|
96
|
-
|
|
97
|
-
// ─── Per-call pending map: tool calls wait for daemon tool_ack ──
|
|
98
|
-
const pendingToolCalls = new Map() // tool_call_id → { resolve, reject, timer }
|
|
99
|
-
const TOOL_ACK_TIMEOUT_MS = 30_000
|
|
100
|
-
|
|
101
|
-
function awaitToolAck(toolCallId) {
|
|
102
|
-
return new Promise((resolve, reject) => {
|
|
103
|
-
const timer = setTimeout(() => {
|
|
104
|
-
pendingToolCalls.delete(toolCallId)
|
|
105
|
-
reject(new Error('daemon ack timeout'))
|
|
106
|
-
}, TOOL_ACK_TIMEOUT_MS)
|
|
107
|
-
pendingToolCalls.set(toolCallId, { resolve, reject, timer })
|
|
108
|
-
})
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function resolveToolAck(toolCallId, ok, error, messageId) {
|
|
112
|
-
const p = pendingToolCalls.get(toolCallId)
|
|
113
|
-
if (!p) return
|
|
114
|
-
pendingToolCalls.delete(toolCallId)
|
|
115
|
-
clearTimeout(p.timer)
|
|
116
|
-
// 0.13: resolve with the delivered message_id so the CallTool handler can hand
|
|
117
|
-
// it back to claude (for edit_message). null when the daemon didn't carry one.
|
|
118
|
-
ok ? p.resolve({ message_id: messageId ?? null }) : p.reject(new Error(error || 'daemon rejected delivery'))
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// ─── 0.12 interactive questions: `ask` blocks for the user's answer ──
|
|
122
|
-
// Separate from tool_ack: a question waits for the user, possibly for hours. The
|
|
123
|
-
// DAEMON owns the lifecycle — it resolves the ask with the user's answer, or sweeps
|
|
124
|
-
// it {timedout} at its configured question timeout (POLYGRAM_QUESTION_TIMEOUT_MS,
|
|
125
|
-
// default 24h). This local timer is ONLY a last-resort backstop for the narrow case
|
|
126
|
-
// where the daemon stays connected but never calls back; it sits a margin ABOVE the
|
|
127
|
-
// daemon timeout so the daemon always resolves first (with the proper user-facing
|
|
128
|
-
// message). It must track the daemon value — a hardcoded 32min here once fired long
|
|
129
|
-
// before the 24h wait, resolving {timedout} on a question the user answered an hour
|
|
130
|
-
// later (0.17.5).
|
|
131
|
-
const pendingQuestions = new Map() // tool_call_id → { resolve, timer }
|
|
132
|
-
const QUESTION_BACKSTOP_MARGIN_MS = 5 * 60 * 1000
|
|
133
|
-
const DAEMON_QUESTION_TIMEOUT_MS = Number(process.env.POLYGRAM_QUESTION_TIMEOUT_MS) || (24 * 60 * 60 * 1000)
|
|
134
|
-
const QUESTION_ANSWER_TIMEOUT_MS = DAEMON_QUESTION_TIMEOUT_MS + QUESTION_BACKSTOP_MARGIN_MS
|
|
135
|
-
|
|
136
|
-
function awaitQuestionAnswer(toolCallId) {
|
|
137
|
-
return new Promise((resolve) => {
|
|
138
|
-
const timer = setTimeout(() => {
|
|
139
|
-
pendingQuestions.delete(toolCallId)
|
|
140
|
-
resolve({ timedout: true }) // never reject — the agent gets a clean result
|
|
141
|
-
}, QUESTION_ANSWER_TIMEOUT_MS)
|
|
142
|
-
timer.unref?.() // a pending answer must not, by itself, hold the event loop open
|
|
143
|
-
pendingQuestions.set(toolCallId, { resolve, timer })
|
|
144
|
-
})
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function resolveQuestionAnswer(toolCallId, result) {
|
|
148
|
-
const p = pendingQuestions.get(toolCallId)
|
|
149
|
-
if (!p) return
|
|
150
|
-
pendingQuestions.delete(toolCallId)
|
|
151
|
-
clearTimeout(p.timer)
|
|
152
|
-
p.resolve(result ?? { cancelled: true })
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// ─── Socket: connect, handshake, then bidirectional JSON-lines ──
|
|
156
|
-
const sock = connect(SOCK)
|
|
157
|
-
|
|
158
|
-
sock.on('connect', () => {
|
|
159
|
-
log('socket', { event: 'connect' })
|
|
160
|
-
// hello + announce session_id in the same flush; daemon validates secret
|
|
161
|
-
sock.write(JSON.stringify({ kind: 'hello', session_key: SESSION_KEY, secret: SOCK_SECRET }) + '\n')
|
|
162
|
-
sock.write(JSON.stringify({ kind: 'session_init', claude_session_id: CLAUDE_SESSION_ID }) + '\n')
|
|
163
|
-
})
|
|
164
|
-
|
|
165
|
-
sock.on('error', err => {
|
|
166
|
-
log('socket', { event: 'error', message: err.message })
|
|
167
|
-
process.exit(4)
|
|
168
|
-
})
|
|
169
|
-
|
|
170
|
-
sock.on('close', () => {
|
|
171
|
-
log('socket', { event: 'close' })
|
|
172
|
-
process.exit(5)
|
|
173
|
-
})
|
|
174
|
-
|
|
175
|
-
// ─── Inbound from daemon → forward into Claude as MCP notifications ──
|
|
176
|
-
let buf = ''
|
|
177
|
-
sock.on('data', chunk => {
|
|
178
|
-
// Review R5: only `ping` resets the watchdog. Non-ping noise (user_msg
|
|
179
|
-
// bursts, tool_acks, perm_verdicts) used to satisfy the liveness check
|
|
180
|
-
// even when the daemon's ping loop had silently died. lastPing is now
|
|
181
|
-
// updated ONLY in the case 'ping' branch below.
|
|
182
|
-
buf += chunk
|
|
183
|
-
let nl
|
|
184
|
-
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
185
|
-
const line = buf.slice(0, nl)
|
|
186
|
-
buf = buf.slice(nl + 1)
|
|
187
|
-
if (!line.trim()) continue
|
|
188
|
-
let raw
|
|
189
|
-
try { raw = JSON.parse(line) } catch { log('parse-error', { line: line.slice(0, 200) }); continue }
|
|
190
|
-
// Review F#15: zod-validate before dispatch. Malformed messages drop with
|
|
191
|
-
// a log instead of silently corrupting downstream state. hello_ack /
|
|
192
|
-
// hello_reject are skipped here because they're pre-auth and the
|
|
193
|
-
// discriminated union expects only post-auth shapes — handle them
|
|
194
|
-
// directly off the raw payload.
|
|
195
|
-
if (raw.kind === 'hello_ack' || raw.kind === 'hello_reject') {
|
|
196
|
-
handleDaemonMessage(raw)
|
|
197
|
-
continue
|
|
198
|
-
}
|
|
199
|
-
const parsed = parseDaemonToBridgeMessage(raw)
|
|
200
|
-
if (!parsed.ok) {
|
|
201
|
-
log('daemon-msg-schema-invalid', { kind: raw?.kind, error: parsed.error })
|
|
202
|
-
continue
|
|
203
|
-
}
|
|
204
|
-
handleDaemonMessage(parsed.msg)
|
|
205
|
-
}
|
|
206
|
-
})
|
|
207
|
-
|
|
208
|
-
function handleDaemonMessage(msg) {
|
|
209
|
-
switch (msg.kind) {
|
|
210
|
-
case 'hello_ack':
|
|
211
|
-
log('handshake', { event: 'ack' })
|
|
212
|
-
break
|
|
213
|
-
|
|
214
|
-
case 'hello_reject':
|
|
215
|
-
log('handshake', { event: 'reject', reason: msg.reason })
|
|
216
|
-
process.exit(6)
|
|
217
|
-
break
|
|
218
|
-
|
|
219
|
-
case 'user_msg':
|
|
220
|
-
log('user_msg-rx', { text_len: msg.text?.length, turn_id: msg.turn_id, chat_id: msg.chat_id })
|
|
221
|
-
mcp.notification({
|
|
222
|
-
method: 'notifications/claude/channel',
|
|
223
|
-
params: {
|
|
224
|
-
content: escapeChannelBody(msg.text),
|
|
225
|
-
meta: {
|
|
226
|
-
// Review #10: attribute-safe escape for ALL meta values, not just
|
|
227
|
-
// content. Telegram first_name is user-controlled and was previously
|
|
228
|
-
// raw — could break out of the attribute via quote injection.
|
|
229
|
-
chat_id: escapeChannelAttr(msg.chat_id ?? ''),
|
|
230
|
-
user: escapeChannelAttr(msg.user ?? ''),
|
|
231
|
-
msg_id: escapeChannelAttr(msg.msg_id ?? ''),
|
|
232
|
-
turn_id: escapeChannelAttr(msg.turn_id ?? ''),
|
|
233
|
-
},
|
|
234
|
-
},
|
|
235
|
-
}).then(
|
|
236
|
-
() => log('user_msg-notify-ok', { turn_id: msg.turn_id }),
|
|
237
|
-
(e) => log('notify-error', { kind: 'user_msg', error: e.message }),
|
|
238
|
-
)
|
|
239
|
-
break
|
|
240
|
-
|
|
241
|
-
case 'perm_verdict':
|
|
242
|
-
mcp.notification({
|
|
243
|
-
method: 'notifications/claude/channel/permission',
|
|
244
|
-
params: { request_id: msg.request_id, behavior: msg.behavior },
|
|
245
|
-
}).catch(e => log('notify-error', { kind: 'perm_verdict', error: e.message }))
|
|
246
|
-
break
|
|
247
|
-
|
|
248
|
-
case 'tool_ack':
|
|
249
|
-
resolveToolAck(msg.tool_call_id, msg.ok, msg.error, msg.message_id)
|
|
250
|
-
break
|
|
251
|
-
|
|
252
|
-
case 'question_answer':
|
|
253
|
-
resolveQuestionAnswer(msg.tool_call_id, msg.result)
|
|
254
|
-
break
|
|
255
|
-
|
|
256
|
-
case 'ping':
|
|
257
|
-
// R5: ping is the ONLY signal that proves the daemon's ping-loop is
|
|
258
|
-
// healthy. Update watchdog timestamp here, not on the generic 'data'
|
|
259
|
-
// event — otherwise unrelated traffic could mask a dead ping-loop.
|
|
260
|
-
lastPing = Date.now()
|
|
261
|
-
sock.write(JSON.stringify({ kind: 'pong' }) + '\n')
|
|
262
|
-
break
|
|
263
|
-
|
|
264
|
-
default:
|
|
265
|
-
log('unknown-kind', { kind: msg.kind })
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// ─── MCP server: capabilities + reply tool ──
|
|
270
|
-
const mcp = new Server(
|
|
271
|
-
{ name: 'polygram-bridge', version: '0.1.0' },
|
|
272
|
-
{
|
|
273
|
-
capabilities: {
|
|
274
|
-
experimental: {
|
|
275
|
-
'claude/channel': {},
|
|
276
|
-
'claude/channel/permission': {},
|
|
277
|
-
},
|
|
278
|
-
tools: {},
|
|
279
|
-
},
|
|
280
|
-
// Phase 0 finding: Claude refers to tools by their prefixed MCP name.
|
|
281
|
-
// Mention the prefixed form explicitly so reasoning doesn't drift.
|
|
282
|
-
instructions:
|
|
283
|
-
'Inbound user messages arrive as <channel source="polygram-bridge" chat_id="..." user="..."> tags. ' +
|
|
284
|
-
'Always reply via the `mcp__polygram-bridge__reply` tool — passing chat_id verbatim — before ending a turn. ' +
|
|
285
|
-
'For long tool calls, send a brief progress reply first so the user is not waiting in silence.',
|
|
286
|
-
},
|
|
287
|
-
)
|
|
288
|
-
|
|
289
|
-
// 0.12 Phase 1.6 — MCP-ready signal (cold-spawn race fix, Finding 0.3.A).
|
|
290
|
-
// Claude's MCP client calls ListTools exactly once during server registration
|
|
291
|
-
// (after Initialize, before notifications can be routed). When that first
|
|
292
|
-
// call arrives here, we know claude has the bridge fully registered and
|
|
293
|
-
// will route incoming notifications to our 'claude/channel' capability.
|
|
294
|
-
// We tell the daemon by writing a single {kind:'mcp-ready'} message, and
|
|
295
|
-
// polygram's _waitForBridgeHandshake gates send() on this in addition to
|
|
296
|
-
// the existing daemon-side hello. Before this fix, polygram's handshake
|
|
297
|
-
// resolved when the bridge connected to the daemon socket — BEFORE claude
|
|
298
|
-
// finished MCP registration — and user_msg notifications were silently
|
|
299
|
-
// dropped 33% of the time (probe-cold-spawn.mjs).
|
|
300
|
-
let _mcpReadySent = false
|
|
301
|
-
mcp.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
302
|
-
if (!_mcpReadySent) {
|
|
303
|
-
_mcpReadySent = true
|
|
304
|
-
log('mcp-ready', { trigger: 'first ListToolsRequest' })
|
|
305
|
-
try { sock.write(JSON.stringify({ kind: 'mcp-ready', session: SESSION_KEY }) + '\n') } catch (err) {
|
|
306
|
-
log('mcp-ready-write-fail', { error: err.message })
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
return {
|
|
310
|
-
tools: [{
|
|
311
|
-
name: 'reply',
|
|
312
|
-
description: 'Send a message back to the originating Telegram chat. ' +
|
|
313
|
-
'chat_id MUST match the chat_id from the inbound <channel> tag. ' +
|
|
314
|
-
'turn_id MUST echo the turn_id from the inbound <channel> tag (when present) ' +
|
|
315
|
-
'so concurrent turns route their replies correctly. ' +
|
|
316
|
-
'ALWAYS set consumed_turn_ids to the turn_id of EVERY <channel> message this ' +
|
|
317
|
-
'reply answers or absorbs (including mid-turn follow-ups) — it is how polygram ' +
|
|
318
|
-
'confirms delivery of follow-ups. ' +
|
|
319
|
-
'Returns {ok, message_id}: keep the message_id to update that bubble in place ' +
|
|
320
|
-
'with `edit_message` (progressive status on long tasks).',
|
|
321
|
-
inputSchema: {
|
|
322
|
-
type: 'object',
|
|
323
|
-
properties: {
|
|
324
|
-
chat_id: { type: 'string', description: 'Echo of chat_id from inbound channel meta.' },
|
|
325
|
-
turn_id: { type: 'string', description: 'Echo of turn_id from inbound channel meta (required for correct turn routing).' },
|
|
326
|
-
text: { type: 'string', description: 'Message body (markdown ok).' },
|
|
327
|
-
files: { type: 'array', items: { type: 'string' }, description: 'Optional absolute file paths to attach.' },
|
|
328
|
-
interim: {
|
|
329
|
-
type: 'boolean',
|
|
330
|
-
description: 'Set true ONLY for a short status/progress update on a long task '
|
|
331
|
-
+ '(e.g. "Looking into that now…"). An interim reply is shown to the user but is '
|
|
332
|
-
+ 'NOT the turn\'s answer — you MUST still deliver the real result as a later reply '
|
|
333
|
-
+ 'with interim omitted/false in the SAME turn. NEVER end a turn on an interim reply.',
|
|
334
|
-
},
|
|
335
|
-
// 0.13 D2 Tier 2C: the fold-acknowledgment contract. The single turn_id
|
|
336
|
-
// field can't express a combined reply that covers a mid-turn follow-up
|
|
337
|
-
// (P0 spike Q-B: claude echoes only the trigger id) — this array can.
|
|
338
|
-
consumed_turn_ids: {
|
|
339
|
-
type: 'array', items: { type: 'string' },
|
|
340
|
-
description: 'turn_id of EVERY <channel> message this reply answers or has absorbed since your last reply, including mid-turn follow-ups. Set it on EVERY reply, even short one-line ones; if you answered two messages in one reply, list BOTH turn_ids. Omitting a folded follow-up makes polygram treat it as dropped.',
|
|
341
|
-
},
|
|
342
|
-
},
|
|
343
|
-
required: ['chat_id', 'text'],
|
|
344
|
-
},
|
|
345
|
-
}, {
|
|
346
|
-
// 0.13: edit a message already sent via `reply`, in place — the progressive-
|
|
347
|
-
// status primitive. Update one bubble instead of sending several.
|
|
348
|
-
name: 'edit_message',
|
|
349
|
-
description: 'Edit a message you previously sent via `reply`, in place. Use this for ' +
|
|
350
|
-
'progressive status on a long task: send a short status with `reply`, take the ' +
|
|
351
|
-
'returned message_id, then `edit_message` it as you make progress (ending with ' +
|
|
352
|
-
'the final answer). Keep status in PLAIN LANGUAGE — never tool names like Bash/Edit. ' +
|
|
353
|
-
'One bubble only (no chunking); for long content use `reply` instead.',
|
|
354
|
-
inputSchema: {
|
|
355
|
-
type: 'object',
|
|
356
|
-
properties: {
|
|
357
|
-
chat_id: { type: 'string', description: 'Echo of chat_id from inbound channel meta.' },
|
|
358
|
-
turn_id: { type: 'string', description: 'Echo of turn_id from inbound channel meta.' },
|
|
359
|
-
message_id: { type: 'number', description: 'The message_id returned by the `reply` tool call you want to update.' },
|
|
360
|
-
text: { type: 'string', description: 'New full message body (markdown ok) — replaces the old text.' },
|
|
361
|
-
},
|
|
362
|
-
required: ['chat_id', 'message_id', 'text'],
|
|
363
|
-
},
|
|
364
|
-
}, {
|
|
365
|
-
// 0.12 interactive questions: ask the Telegram user a multiple-choice question
|
|
366
|
-
// as tap-to-answer inline buttons. USE THIS instead of any interactive menu —
|
|
367
|
-
// it returns the user's selection(s) as the tool result. Blocks until answered.
|
|
368
|
-
name: 'ask',
|
|
369
|
-
description: 'Ask the Telegram user a multiple-choice question (rendered as inline ' +
|
|
370
|
-
'keyboard buttons; supports multiSelect + a free-text "Other"). Use this ' +
|
|
371
|
-
'for ANY choice/confirmation — never present a numbered list and wait, and ' +
|
|
372
|
-
'never use a terminal selection menu. Blocks until the user answers; returns ' +
|
|
373
|
-
'{answers:[{header,selected:[label...],other?}]} (or {cancelled}/{timedout}).',
|
|
374
|
-
inputSchema: {
|
|
375
|
-
type: 'object',
|
|
376
|
-
properties: {
|
|
377
|
-
chat_id: { type: 'string', description: 'Echo of chat_id from inbound channel meta.' },
|
|
378
|
-
turn_id: { type: 'string', description: 'Echo of turn_id from inbound channel meta.' },
|
|
379
|
-
questions: {
|
|
380
|
-
type: 'array', description: 'Up to 4 questions, asked one at a time.',
|
|
381
|
-
items: {
|
|
382
|
-
type: 'object',
|
|
383
|
-
properties: {
|
|
384
|
-
header: { type: 'string', description: 'Short chip label (≤12 chars).' },
|
|
385
|
-
question: { type: 'string', description: 'The question text.' },
|
|
386
|
-
multiSelect: { type: 'boolean', description: 'Allow selecting several options (checkboxes).' },
|
|
387
|
-
allowOther: { type: 'boolean', description: 'Offer a free-text "type my own" answer (default true).' },
|
|
388
|
-
options: {
|
|
389
|
-
type: 'array', description: '2–4 options.',
|
|
390
|
-
items: { type: 'object', properties: {
|
|
391
|
-
label: { type: 'string', description: 'Button label (≤40 chars).' },
|
|
392
|
-
description: { type: 'string', description: 'Shown in the message body.' },
|
|
393
|
-
} },
|
|
394
|
-
},
|
|
395
|
-
},
|
|
396
|
-
required: ['question', 'options'],
|
|
397
|
-
},
|
|
398
|
-
},
|
|
399
|
-
},
|
|
400
|
-
required: ['chat_id', 'questions'],
|
|
401
|
-
},
|
|
402
|
-
}],
|
|
403
|
-
}
|
|
404
|
-
})
|
|
405
|
-
|
|
406
|
-
mcp.setRequestHandler(CallToolRequestSchema, async req => {
|
|
407
|
-
if (req.params.name !== 'reply' && req.params.name !== 'ask' && req.params.name !== 'edit_message') {
|
|
408
|
-
return { content: [{ type: 'text', text: `unknown tool: ${req.params.name}` }], isError: true }
|
|
409
|
-
}
|
|
410
|
-
const toolCallId = randomUUID()
|
|
411
|
-
|
|
412
|
-
// `ask` blocks for the user's answer (question_answer), NOT a fast tool_ack.
|
|
413
|
-
if (req.params.name === 'ask') {
|
|
414
|
-
const answerP = awaitQuestionAnswer(toolCallId)
|
|
415
|
-
try {
|
|
416
|
-
sock.write(JSON.stringify({
|
|
417
|
-
kind: 'tool', session: SESSION_KEY, tool_call_id: toolCallId, name: 'ask', args: req.params.arguments,
|
|
418
|
-
}) + '\n')
|
|
419
|
-
} catch (e) {
|
|
420
|
-
// The daemon never received the ask → no row, no sweep. Resolve the awaiter
|
|
421
|
-
// now (clears the 20-min timer) instead of stranding the agent on it.
|
|
422
|
-
resolveQuestionAnswer(toolCallId, { cancelled: true, error: `bridge write failed: ${e?.message || e}` })
|
|
423
|
-
}
|
|
424
|
-
const result = await answerP
|
|
425
|
-
return { content: [{ type: 'text', text: JSON.stringify(result) }] }
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
const ackP = awaitToolAck(toolCallId)
|
|
429
|
-
sock.write(JSON.stringify({
|
|
430
|
-
kind: 'tool',
|
|
431
|
-
session: SESSION_KEY,
|
|
432
|
-
tool_call_id: toolCallId,
|
|
433
|
-
name: req.params.name,
|
|
434
|
-
args: req.params.arguments,
|
|
435
|
-
}) + '\n')
|
|
436
|
-
try {
|
|
437
|
-
const ack = await ackP
|
|
438
|
-
// Return {ok, message_id} as JSON so claude can read the delivered bubble's
|
|
439
|
-
// id and `edit_message` it later (progressive status). For a plain reply with
|
|
440
|
-
// no id (solo sticker/reaction) message_id is null.
|
|
441
|
-
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, message_id: ack?.message_id ?? null }) }] }
|
|
442
|
-
} catch (err) {
|
|
443
|
-
return { content: [{ type: 'text', text: `delivery failed: ${err.message}` }], isError: true }
|
|
444
|
-
}
|
|
445
|
-
})
|
|
446
|
-
|
|
447
|
-
// ─── Permission relay: Claude Code → bridge → daemon → human → verdict back ──
|
|
448
|
-
// Review F#14: only request_id + tool_name are required. description /
|
|
449
|
-
// input_preview MAY be empty (Bash with no args, future tool variants, slim
|
|
450
|
-
// tools that don't carry a preview). Pre-fix any of those four being absent
|
|
451
|
-
// or empty rejected the whole notification — MCP silently dropped the perm
|
|
452
|
-
// request, no approval card surfaced, Claude blocked forever waiting for a
|
|
453
|
-
// verdict that never came. Now those two are optional+defaulted to '' so
|
|
454
|
-
// the perm request always relays.
|
|
455
|
-
const PermissionRequestSchema = z.object({
|
|
456
|
-
method: z.literal('notifications/claude/channel/permission_request'),
|
|
457
|
-
params: z.object({
|
|
458
|
-
request_id: z.string().min(1),
|
|
459
|
-
tool_name: z.string().min(1),
|
|
460
|
-
description: z.string().optional().default(''),
|
|
461
|
-
input_preview: z.string().optional().default(''),
|
|
462
|
-
}).passthrough(),
|
|
463
|
-
})
|
|
464
|
-
|
|
465
|
-
mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
|
|
466
|
-
sock.write(JSON.stringify({
|
|
467
|
-
kind: 'perm_req',
|
|
468
|
-
session: SESSION_KEY,
|
|
469
|
-
request_id: params.request_id,
|
|
470
|
-
tool_name: params.tool_name,
|
|
471
|
-
description: params.description,
|
|
472
|
-
input_preview: params.input_preview,
|
|
473
|
-
}) + '\n')
|
|
474
|
-
})
|
|
475
|
-
|
|
476
|
-
await mcp.connect(new StdioServerTransport())
|
|
477
|
-
log('startup', { pid: process.pid, node: process.version, session_key: SESSION_KEY })
|