polygram 0.17.11 → 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.
Files changed (37) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/lib/error/classify.js +14 -0
  3. package/lib/handlers/dispatcher.js +54 -1
  4. package/lib/handlers/drop-redeliver.js +19 -0
  5. package/lib/handlers/should-handle.js +52 -0
  6. package/lib/media-group-buffer.js +29 -0
  7. package/lib/ops/auth-disabled-gate.js +43 -0
  8. package/lib/ops/heartbeat.js +56 -0
  9. package/lib/process/channels-tool-dispatcher.js +12 -1
  10. package/lib/sdk/callbacks.js +2 -1
  11. package/lib/secret-detect.js +13 -1
  12. package/lib/telegram/chunk.js +20 -6
  13. package/lib/telegram/process-agent-reply.js +5 -3
  14. package/lib/telegram/streamer.js +6 -1
  15. package/package.json +2 -1
  16. package/polygram.js +188 -41
  17. package/lib/async-lock.js +0 -49
  18. package/lib/claude-bin.js +0 -246
  19. package/lib/compaction-warn.js +0 -59
  20. package/lib/context-usage.js +0 -93
  21. package/lib/process/channels-bridge-protocol.js +0 -199
  22. package/lib/process/channels-bridge-server.js +0 -274
  23. package/lib/process/channels-bridge.mjs +0 -477
  24. package/lib/process/cli-process.js +0 -4029
  25. package/lib/process/factory.js +0 -215
  26. package/lib/process/hook-event-tail.js +0 -162
  27. package/lib/process/hook-settings.js +0 -181
  28. package/lib/process/polygram-hook-append.js +0 -71
  29. package/lib/process/process.js +0 -215
  30. package/lib/process/sdk-process.js +0 -880
  31. package/lib/process-guard.js +0 -296
  32. package/lib/process-manager.js +0 -628
  33. package/lib/tmux/log-tail.js +0 -334
  34. package/lib/tmux/orphan-sweep.js +0 -79
  35. package/lib/tmux/poll-scheduler.js +0 -110
  36. package/lib/tmux/startup-gate.js +0 -250
  37. 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
95
- .replace(/"/g, '&quot;').replace(/'/g, '&apos;')
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 })