@shumkov/orchestra 0.2.0

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