@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,4135 @@
1
+ // provenance: polygram@0.17.11 lib/process/cli-process.js (git 746bca6) — adapt: env prefix WATER_, bridge name water-bridge, vendor path (SHARED-LIB.md).
2
+ /**
3
+ * CliProcess — Claude session backed by `claude` CLI in tmux,
4
+ * with IO over the official Channels MCP protocol via a stdio bridge
5
+ * AND observability via hook ndjson (--settings injection, Phase 1.4).
6
+ *
7
+ * Cost profile: subscription-priced (claude CLI uses Pro/Max) AND structured
8
+ * IO (no JSONL tailing, no pane scraping). The post-0.12 successor to
9
+ * the 0.11 CliProcess + TmuxProcess pair — three layers, one mechanism
10
+ * per concern: tmux=lifecycle, channels-bridge=IO, hooks=observability.
11
+ *
12
+ * Architecture:
13
+ * CliProcess.start() creates a per-session unix socket (mode 0600
14
+ * + per-socket secret), spawns claude in tmux with --channels pointing
15
+ * at lib/process/channels-bridge.mjs registered via inline --mcp-config.
16
+ * The bridge connects back over the socket, authenticates via the
17
+ * shared secret, and proxies MCP traffic in both directions.
18
+ *
19
+ * Inbound user msgs: daemon → CliProcess.send() → bridge socket →
20
+ * bridge → mcp.notification(claude/channel)
21
+ * Outbound replies: Claude calls mcp__water-bridge__reply →
22
+ * bridge → socket → CliProcess.onBridgeMsg →
23
+ * toolDispatcher(chatId, text, files) → daemon
24
+ *
25
+ * Permission relay: Claude needs Bash → Claude Code emits
26
+ * permission_request → bridge → socket →
27
+ * CliProcess emits 'approval-required' → polygram
28
+ * renders inline-keyboard buttons → user taps →
29
+ * CliProcess.respondToPermission(id, verdict)
30
+ *
31
+ * Phase 0 (2026-05-24) findings baked in:
32
+ * - In dev mode use --dangerously-load-development-channels server:NAME
33
+ * by itself; mixing with --channels makes claude reject the next arg.
34
+ * - --no-session-persistence is --print-mode only — do NOT pass.
35
+ * - --mcp-config is variadic; must come last.
36
+ * - Trust + dev-channel confirmation dialogs both need Enter at startup.
37
+ *
38
+ * See docs/0.11.0-channels-driver-plan.md for the full design.
39
+ */
40
+
41
+ 'use strict';
42
+
43
+ const crypto = require('node:crypto');
44
+ const fs = require('node:fs');
45
+ const os = require('node:os');
46
+ const path = require('node:path');
47
+
48
+ const { Process, UnsupportedOperationError } = require('./process');
49
+ const { ChannelsBridgeServer } = require('./channels-bridge-server');
50
+ const { writeHookFiles, removeHookFiles } = require('./hook-settings');
51
+ const { createHookTail } = require('./hook-event-tail');
52
+ // Single source of truth for the question wait: the daemon owns the question
53
+ // lifecycle (answer or {timedout} sweep), and we pass this to the bridge so its
54
+ // last-resort `ask` backstop sits ABOVE it instead of undercutting it.
55
+ const { DEFAULT_TIMEOUT_MS: QUESTION_TIMEOUT_MS } = require('../questions/store');
56
+ // File-send staging: reuse the dispatcher's allowlist root so the dir we
57
+ // create exactly matches the realpath the validator accepts (no /tmp vs
58
+ // /private/tmp drift — one of the original Music-topic failures).
59
+ const { DEFAULT_ATTACHMENT_BASE } = require('./attachment-base');
60
+ const { resolveCompactionWarnConfig } = require('../compaction-warn');
61
+ const { readContextTokens, contextPct } = require('../context-usage');
62
+ const { runStartupGate } = require('../tmux/startup-gate');
63
+
64
+ const BRIDGE_PATH = path.resolve(__dirname, 'channels-bridge.mjs');
65
+ const DEFAULT_HANDSHAKE_TIMEOUT_MS = 15_000;
66
+ // 0.12 Phase 1.6: claude-side MCP-init can lag behind daemon-side bridge
67
+ // handshake by 100ms in normal conditions, up to a few seconds on cold
68
+ // machines or fresh git worktrees. 5s default tolerates that without
69
+ // being so long that a genuinely-stuck bridge wedges the chat unnoticed.
70
+ const DEFAULT_MCP_READY_TIMEOUT_MS = 5_000;
71
+ // 0.12 Phase 1.7 (Finding 0.1.A): Stop hook fires AFTER the channel result
72
+ // event. We wait this long after a channel result before finalizing the
73
+ // turn, so the Stop hook can land and we can use its last_assistant_message
74
+ // as a text fallback when the channel result delivered empty replies.
75
+ // Mirrors rc.41 H4 stopGraceMs from tmux backend. 2s default = same as tmux.
76
+ const DEFAULT_STOP_GRACE_MS = 2_000;
77
+ const DEFAULT_TURN_QUIET_MS = 2_000; // after first reply, wait this long for more before resolving turn
78
+ // 0.13 D1 rung 2 (docs/0.13-channels-lifecycle-design.md §3 D1): once a turn has
79
+ // ≥1 delivered reply AND the hook stream is live, the turn finalizes when the
80
+ // session's whole ACTIVITY surface (hook events + the pane "esc to interrupt"
81
+ // thinking heartbeat + bridge tool calls + replies) goes quiet for this long.
82
+ // Calibrated against the busy-phase inter-activity gap: the pane heartbeat fires
83
+ // on the 5s pong tick while a turn is pending, so a live claude can never be
84
+ // "activity-quiet" — only a truly ended (or hook-and-pane-dead) tail is.
85
+ const DEFAULT_ACTIVITY_QUIET_MS = 18_000;
86
+ // 0.13 D2 (P3): InputLedger windows. dropConfirm = how long after the trigger
87
+ // cycle's end an unseen/unacked non-primary entry may still be picked up as a
88
+ // claude-side next cycle before it is declared dropped (late seen/ack cancels).
89
+ // deliveryWatchdog = the primary pickup window: a dispatched primary with no
90
+ // UPS and ZERO session activity gets one idempotent re-write, then (still
91
+ // nothing) a bridge teardown onto the existing recovery path.
92
+ const DEFAULT_DROP_CONFIRM_MS = 20_000;
93
+ const DEFAULT_DELIVERY_WATCHDOG_MS = 10_000;
94
+ const INPUT_LEDGER_CAP = 64;
95
+ // 0.13 D1 P1 seen-slice: parse the pickup turn_id out of the UserPromptSubmit
96
+ // prompt. Anchored on the RAW `<channel ` tag prefix — the bridge body-escape
97
+ // (channels-bridge.mjs escapeChannelBody) turns every user-authored `<` into
98
+ // `&lt;`, so a raw tag prefix is bridge-authored by construction and a pasted/
99
+ // spoofed `turn_id="…"` in message body text can never mark a pending seen.
100
+ // (Envelope shape verified from prod JSONL + the P0 spike — Q1.)
101
+ const UPS_ENVELOPE_TURN_ID_RE = /<channel\s[^>]*turn_id="([0-9a-f-]{36})"/g;
102
+ const DEFAULT_TURN_TIMEOUT_MS = 600_000; // 10 min idle cap (resets on each reply — Review F#13)
103
+ const DEFAULT_TURN_ABSOLUTE_MS = 1_800_000; // 30 min busy-aware checkpoint interval (0.16: re-arms while working)
104
+ const DEFAULT_TURN_HARD_MAX_MS = 5_400_000; // 90 min hard wall-clock backstop (0.16: extension can't exceed this)
105
+ const DEFAULT_INTERRUPT_GRACE_MS = 5_000; // after Ctrl-C, wait this long for Claude to ack before synthesizing 'interrupted'
106
+ const DEFAULT_MAX_REPLIES_PER_TURN = 20; // P1 #12: cap on quiet-window resets to prevent chatty-Claude hang
107
+ const PING_INTERVAL_MS = 10_000;
108
+ const PONG_TIMEOUT_MS = 30_000; // P1 #6: declare bridge dead if no pong in 30s
109
+ const PONG_CHECK_INTERVAL_MS = 5_000;
110
+ const RECENT_TOOL_CALL_LIMIT = 256; // P1 #7: cap on idempotency cache
111
+ const DEFAULT_TOOL_RATE_LIMIT_PER_SEC = 5; // P2 ADV-6: cap on reply tool calls per second
112
+ const DEFAULT_TOOL_RATE_BURST = 20; // ADV-6: token bucket capacity
113
+ const DEFAULT_QUEUE_CAP = 50; // Parity P2: match SDK/tmux pendingQueue cap
114
+
115
+ // Review F#17: mid-turn dialog watchdog. Even though channels uses MCP for IO,
116
+ // the underlying claude TUI can still pop interactive prompts mid-turn that
117
+ // don't surface as MCP notifications (session-age, future "approaching usage
118
+ // limit" menus, etc.). Without polling the pane we'd only catch them when the
119
+ // idle-ceiling fires (F#13, ~10 min). Reusing the pong watchdog's 5s tick:
120
+ // every check, if any pending turns exist AND the tmux session is live, we
121
+ // capture-pane and match against this catalog. Action 'enter' dismisses with
122
+ // sendControl(Enter); 'emit-only' surfaces telemetry without auto-action.
123
+ //
124
+ // Pattern matching is intentionally conservative — distinctive substrings
125
+ // only — to avoid false positives during normal turn output. Extend the
126
+ // catalog when new dialogs are observed in production.
127
+ const SESSION_AGE_PROMPT_RE = /Resuming the full session[\s\S]*Resume from summary/i;
128
+ const MID_TURN_PROMPTS = [
129
+ // Review F2 (resume-dialog fix): bare Enter selects the pre-selected
130
+ // "Resume from summary" — which literally runs /compact. Navigate to
131
+ // "Resume full session as-is" instead, same as the startup-gate trigger.
132
+ { name: 'session-age', regex: SESSION_AGE_PROMPT_RE, action: 'keys', keys: ['Down', 'Enter'] },
133
+ ];
134
+
135
+ // 0.12 Phase 3.2 (Finding 0.1.A): rc.45 esc-to-interrupt liveness heartbeat.
136
+ // During pure-thinking turns (no tool calls), NO hook events fire between
137
+ // UserPromptSubmit (start) and Stop (end). Production thinking turns regularly
138
+ // exceed 45s on heavy prompts, which would trip STALL (🥱) before claude
139
+ // finishes. Claude's TUI prints "esc to interrupt" continuously throughout
140
+ // any busy phase — capture-pane sees it, we emit 'thinking', the reactor
141
+ // heartbeats, the cascade stays at THINKING_DEEPEST (🤓) instead of STALL.
142
+ // TODO(0.13): polish heartbeat strategy. Future replacement candidates:
143
+ // a richer hook event from Anthropic, or a periodic ping from a long-lived
144
+ // hook process.
145
+ const STREAMING_HINT_RE = /esc to interrupt/i;
146
+
147
+ // 0.12.0 background-work lifecycle: claude's TUI mode line shows a live
148
+ // background-shell COUNT while a `run_in_background:true` Bash outlives its turn,
149
+ // e.g. `⏵⏵ bypass permissions on · 1 shell · ← for agents · ↓ to manage`.
150
+ // Confirmed on claude 2.1.158 (P0 spike — docs/0.12.0-background-work-lifecycle-
151
+ // plan.md): the count is always-present in the viewport mode line while shells run
152
+ // and clears IN-PLACE within ~3s when they exit (no stale scrollback).
153
+ //
154
+ // MODE-INDEPENDENT (prod regression fix, 2026-06-04): the original regex anchored
155
+ // on "auto mode on", but EVERY shumorobot session runs "⏵⏵ bypass permissions on"
156
+ // — the spike happened to be captured in auto mode. So the detector never matched
157
+ // in prod and bg-work-status fired zero times. Anchor instead on the `⏵⏵` mode-
158
+ // line glyph (present in auto / bypass / accept-edits modes alike); only the mode
159
+ // label between it and `· N shell` varies. Still matched only against the captured
160
+ // TAIL so a scrolled-off history line never trips it. R1: re-validate on each
161
+ // pinned-claude bump (glyph + `N shell` wording).
162
+ const BACKGROUND_SHELL_RE = /⏵⏵[^\n]*·\s*(\d+)\s+shells?\b/i;
163
+ // How long a detached background shell may run AFTER its turn resolved (claude
164
+ // idle) before the stall-watchdog fires one read-only self-check. Override via
165
+ // the constructor (tests use a small value).
166
+ const DEFAULT_BG_WORK_STALL_MS = 600_000; // 10 min
167
+
168
+ // 0.12 Phase 3.3 (Q1 resolution): heuristic for "looks like an unknown
169
+ // interactive prompt." Match common prompt shapes that don't appear in
170
+ // MID_TURN_PROMPTS — operator gets a telemetry event so they can decide
171
+ // whether to add the prompt to the catalog or respond manually. Conservative
172
+ // — false positives surface as no-op telemetry, false negatives surface
173
+ // as the idle-ceiling timeout (~10min).
174
+ const UNKNOWN_PROMPT_HEURISTIC_RE = /(\?\s*$|\(y\/N\)|Yes\/No|❯\s|^\s*[12345]\.\s)/im;
175
+ // rc.14: a previous rc (rc.11) had a BRIDGE_DEAD_RE here that matched the pane
176
+ // line "server:water-bridge no MCP server configured with that name" and
177
+ // treated it as a dead bridge to recover from. That was a MISDIAGNOSIS: this
178
+ // line is a BENIGN, persistent banner that `--dangerously-load-development-
179
+ // channels` + `--strict-mcp-config` prints on EVERY healthy session — the
180
+ // channel still delivers messages and the reply tool still works (reproduced
181
+ // 2026-06-01 with a test MCP server that demonstrably functions). The pane
182
+ // matcher therefore false-fired ~5s into every channels turn and KILLED
183
+ // healthy sessions (the Music-topic "mid-turn detach" regression). Real bridge
184
+ // loss is caught by the socket-close path (bridgeServer 'bridge-disconnected'
185
+ // → _handleBridgeDisconnected). There is no reliable pane signal — removed.
186
+ // Per-pattern rate limit so a dialog that lingers across multiple polls
187
+ // doesn't spam sendControl/event emissions. Aligned with the 5s poll cadence.
188
+ const MID_TURN_DEDUP_WINDOW_MS = 30_000;
189
+
190
+ // Parity with TmuxProcess (R2-F1 / G5b) and SdkProcess: strip C0 control
191
+ // chars + DEL before injecting. Allows \t (0x09) and \n (0x0a) through.
192
+ // Same regex as `lib/tmux/tmux-runner.js` CONTROL_CHAR_RE — keep in sync.
193
+ const INJECT_CONTROL_CHAR_RE = /[\x00-\x08\x0b-\x1f\x7f]/g;
194
+ function sanitizeInjectControlChars(text) {
195
+ return typeof text === 'string' ? text.replace(INJECT_CONTROL_CHAR_RE, '') : text;
196
+ }
197
+
198
+ class CliProcess extends Process {
199
+ /**
200
+ * @param {object} opts
201
+ * @param {string} opts.sessionKey
202
+ * @param {string|null} [opts.chatId]
203
+ * @param {string|null} [opts.threadId]
204
+ * @param {string} [opts.label]
205
+ * @param {object} opts.tmuxRunner — polygram's existing tmuxRunner (for spawn/kill/send-keys)
206
+ * @param {string} opts.botName — for tmux session naming
207
+ * @param {string} [opts.claudeBin] — absolute path to pinned claude binary; defaults to env-resolved
208
+ * @param {Function} opts.toolDispatcher — async ({sessionKey, chatId, text, files, toolName}) => {ok, error?}
209
+ * Called when Claude's reply (or react/edit_message) tool fires.
210
+ * @param {object} [opts.logger]
211
+ * @param {number} [opts.handshakeTimeoutMs]
212
+ * @param {number} [opts.turnQuietMs]
213
+ * @param {number} [opts.turnTimeoutMs]
214
+ */
215
+ constructor({
216
+ sessionKey, chatId, threadId, label,
217
+ tmuxRunner, botName,
218
+ claudeBin = null,
219
+ toolDispatcher,
220
+ // Extraction-time API (orchestra): the consumer injects its surface-rendering
221
+ // hint (appended to the system prompt) and outbound file-size cap, so the engine
222
+ // is transport-agnostic. Were hard `require`s in the polygram/water copy.
223
+ displayHint = '',
224
+ maxOutboundFileBytes = 100 * 1024 * 1024,
225
+ // orchestra identity options — the consumer's names, so the engine is neutral.
226
+ // sessionPrefix: tmux session + socket + mcp-config file prefix (MUST match the
227
+ // tmuxRunner/orphan-sweep prefix or restarts leak sessions).
228
+ // bridgeServerName: the MCP channels server name → mcp__<name>__reply tool.
229
+ // appDataDir: base for per-session hook + log files.
230
+ // attachmentBase: per-session file-staging root.
231
+ sessionPrefix = 'orchestra',
232
+ bridgeServerName = 'orchestra-bridge',
233
+ appDataDir = require('node:path').join(require('node:os').homedir(), '.orchestra'),
234
+ attachmentBase = DEFAULT_ATTACHMENT_BASE,
235
+ // Consumer identity woven into the append-system-prompt HARD CONTRACT so the agent
236
+ // is told the right product name, chat surface, and reply-tool name.
237
+ productName = 'orchestra',
238
+ surfaceName = 'the chat',
239
+ logger = console,
240
+ handshakeTimeoutMs = DEFAULT_HANDSHAKE_TIMEOUT_MS,
241
+ mcpReadyTimeoutMs = DEFAULT_MCP_READY_TIMEOUT_MS,
242
+ stopGraceMs = DEFAULT_STOP_GRACE_MS,
243
+ turnQuietMs = DEFAULT_TURN_QUIET_MS,
244
+ activityQuietMs = DEFAULT_ACTIVITY_QUIET_MS,
245
+ dropConfirmMs = DEFAULT_DROP_CONFIRM_MS,
246
+ deliveryWatchdogMs = DEFAULT_DELIVERY_WATCHDOG_MS,
247
+ turnTimeoutMs = DEFAULT_TURN_TIMEOUT_MS,
248
+ turnAbsoluteMs = DEFAULT_TURN_ABSOLUTE_MS,
249
+ turnHardMaxMs = DEFAULT_TURN_HARD_MAX_MS,
250
+ bgWorkStallMs = DEFAULT_BG_WORK_STALL_MS,
251
+ interruptGraceMs = DEFAULT_INTERRUPT_GRACE_MS,
252
+ maxRepliesPerTurn = DEFAULT_MAX_REPLIES_PER_TURN,
253
+ queueCap = DEFAULT_QUEUE_CAP, // Parity P2
254
+ db = null, // Parity P1: db for _logEvent
255
+ } = {}) {
256
+ super({ sessionKey, chatId, threadId, label });
257
+ this.backend = 'cli';
258
+
259
+ if (!tmuxRunner) throw new TypeError('CliProcess: tmuxRunner required');
260
+ if (!botName) throw new TypeError('CliProcess: botName required');
261
+ if (typeof toolDispatcher !== 'function') {
262
+ throw new TypeError('CliProcess: toolDispatcher (function) required');
263
+ }
264
+
265
+ this.runner = tmuxRunner;
266
+ this.botName = botName;
267
+ // claudeBin MUST be supplied — factory enforces this. We don't lazy-resolve
268
+ // because there's no sensible default and silent null would surface as a
269
+ // far-from-cause tmuxRunner.spawn failure.
270
+ if (!claudeBin && !process.env.ORCHESTRA_CLAUDE_BIN) {
271
+ throw new TypeError('CliProcess: claudeBin required (or ORCHESTRA_CLAUDE_BIN env)');
272
+ }
273
+ this.claudeBin = claudeBin || process.env.ORCHESTRA_CLAUDE_BIN;
274
+ this.toolDispatcher = toolDispatcher;
275
+ this.displayHint = displayHint;
276
+ this._injectedMaxOutboundFileBytes = maxOutboundFileBytes;
277
+ this.sessionPrefix = sessionPrefix;
278
+ this.bridgeServerName = bridgeServerName;
279
+ this.appDataDir = appDataDir;
280
+ this.attachmentBase = attachmentBase;
281
+ this.productName = productName;
282
+ this.surfaceName = surfaceName;
283
+ // Precompute the per-session hooks dir ONCE so writeHookFiles and both
284
+ // removeHookFiles calls agree — otherwise remove falls back to the neutral
285
+ // ~/.orchestra default and never reaps files written under appDataDir.
286
+ this._hooksDir = require('node:path').join(this.appDataDir, String(this.botName).replace(/[^\w.-]/g, '_'), 'hooks');
287
+ this.logger = logger;
288
+ this.handshakeTimeoutMs = handshakeTimeoutMs;
289
+ this.mcpReadyTimeoutMs = mcpReadyTimeoutMs;
290
+ this.stopGraceMs = stopGraceMs;
291
+ this.turnQuietMs = turnQuietMs;
292
+ this.activityQuietMs = activityQuietMs;
293
+ this.dropConfirmMs = dropConfirmMs;
294
+ this.deliveryWatchdogMs = deliveryWatchdogMs;
295
+ this.turnTimeoutMs = turnTimeoutMs;
296
+ this.turnAbsoluteMs = turnAbsoluteMs;
297
+ this.turnHardMaxMs = turnHardMaxMs;
298
+ this.bgWorkStallMs = bgWorkStallMs;
299
+ this.interruptGraceMs = interruptGraceMs;
300
+ this.maxRepliesPerTurn = maxRepliesPerTurn;
301
+ this.queueCap = queueCap;
302
+ this.db = db;
303
+
304
+ // populated by start()
305
+ this.sockPath = null;
306
+ this.sockSecret = null;
307
+ this.bridgeServer = null; // M1: ChannelsBridgeServer instance (socket + auth + protocol validation)
308
+ this.mcpConfigPath = null; // P0 #1: 0o600 tmp file holding bridge env (no argv leak)
309
+ this.tmuxSession = null; // tmux session name
310
+ this.bridgeReady = false;
311
+ // 0.12 Phase 1.6: claude-side MCP-server registration completion flag.
312
+ // Set true when bridge writes {kind:'mcp-ready'} after claude's first
313
+ // ListToolsRequest (Finding 0.3.A — cold-spawn race fix).
314
+ this.mcpReady = false;
315
+ this.pingTimer = null;
316
+ // Review P1 #6: daemon-side pong tracking. Without it, a half-open socket
317
+ // (bridge frozen but TCP alive) is invisible to the daemon. We record the
318
+ // last pong timestamp on each 'pong' bridge message and a separate watchdog
319
+ // interval fires bridge-disconnected if too much time elapses.
320
+ this.lastPongAt = 0;
321
+ this.pongWatchdog = null;
322
+ // 0.12.0 background-work stall-watchdog state. `_bgWorkSince` = when a live
323
+ // background shell was first observed while idle (null = none); reset only
324
+ // when the shell count returns to 0. `_bgWorkEscalations` caps the watchdog
325
+ // at one read-only self-check per continuous background-work window.
326
+ this._bgWorkSince = null;
327
+ this._bgWorkEscalations = 0;
328
+ // Visibility (Use 3): whether a "⏳ working in background" status message is
329
+ // currently shown, so we emit exactly one running→cleared pair per window.
330
+ this._bgWorkStatusShown = false;
331
+ // Review P2 ADV-6: token-bucket rate limit on Claude's reply tool calls.
332
+ // Without this, a prompt-injected or runaway Claude can fire reply() 1000×
333
+ // in a tight loop, flooding TG + saturating the daemon event loop.
334
+ this.toolRateTokens = DEFAULT_TOOL_RATE_BURST;
335
+ this.toolRateLastRefillAt = Date.now();
336
+ this.toolRatePerSec = DEFAULT_TOOL_RATE_LIMIT_PER_SEC;
337
+ this.toolRateBurst = DEFAULT_TOOL_RATE_BURST;
338
+ // Review P3 ADV-11: rate-limit the chat_id-mismatch log so a 1000×
339
+ // mismatch storm doesn't fill stderr/logs at warn level.
340
+ this._lastChatIdMismatchLogAt = 0;
341
+ // Review P3 C8: track the most recent interrupt so the grace window can
342
+ // resolve pending turns with subtype 'interrupted' if Claude doesn't
343
+ // reply after Ctrl-C.
344
+ this._interruptedAt = 0;
345
+ this._interruptGraceTimer = null;
346
+ // Review P3 C5/HeartbeatReactor stop race: monotonic token for
347
+ // setReaction calls. Stale completions discarded by token mismatch.
348
+ this._reactionToken = 0;
349
+ // Review P1 #7: idempotency for tool_ack — track tool_call_ids we've
350
+ // already ACK'd so a duplicate 'tool' message (Claude retry on isError)
351
+ // doesn't re-invoke the dispatcher → duplicate TG send. Set is bounded
352
+ // to RECENT_TOOL_CALL_LIMIT entries via FIFO eviction.
353
+ this.recentToolCallIds = new Set();
354
+ this.recentToolCallResults = new Map(); // tool_call_id → message_id (0.13: replay on re-ACK)
355
+ this.recentToolCallOrder = []; // FIFO bound
356
+ // Review F#17: per-pattern last-fired timestamp for the mid-turn dialog
357
+ // watchdog. Dedups within MID_TURN_DEDUP_WINDOW_MS so a lingering dialog
358
+ // doesn't trigger sendControl/emit on every 5s poll.
359
+ this.midTurnDialogLastFiredAt = new Map(); // patternName → ts
360
+ // Review F#16: secondary content-hash dedup. The tool_call_id cache above
361
+ // catches retries that reuse the same id, but Claude's bridge generates a
362
+ // NEW tool_call_id per retry (channels-bridge.mjs:230). If the daemon's
363
+ // first dispatch took longer than TOOL_ACK_TIMEOUT_MS (30s — slow TG, big
364
+ // attachment), the bridge times out → isError → Claude retries with new
365
+ // id → daemon dispatches the same (chat_id, text) again → duplicate user
366
+ // message. Track (chat_id, sha256(text)) for 60s to catch this even when
367
+ // the tool_call_id changes. TTL is tight enough that legitimate repeated
368
+ // sends ("ok" twice in a row) eventually pass.
369
+ this.recentContentHashes = new Map(); // key → expiryTs
370
+ this.contentDedupWindowMs = 60_000;
371
+ // Content-dedup key → Promise<dispatch result> for a reply dispatch that
372
+ // is STILL AWAITING the dispatcher (e.g. a large file upload running past
373
+ // the bridge's 30s ack timeout). Both success caches above populate only
374
+ // AFTER the dispatch returns, so without this a retried reply (fresh
375
+ // tool_call_id, same content) re-delivers text + file. A duplicate
376
+ // arriving in that window joins the in-flight promise instead.
377
+ this._inFlightDispatches = new Map();
378
+
379
+ // pending turn(s): turn_id → { resolve, reject, replies: [], seen, quietTimer,
380
+ // hardTimer, absoluteTimer, _activityQuietTimer, startedAt }
381
+ this.pendingTurns = new Map();
382
+ // 0.13 D1: activity bookkeeping for the finalizer ladder. _lastHookEventAt
383
+ // feeds the rung-2 telemetry (hook-stalled discrimination); _lastActivityAt
384
+ // is the broader surface (hooks + pane heartbeat + bridge tool calls).
385
+ this._lastHookEventAt = 0;
386
+ this._lastActivityAt = 0;
387
+ // Monotonic count of work hooks (all but the terminal Stop) — the rung-2
388
+ // no-reply backstop snapshots it at Stop capture to detect a later resume.
389
+ this._workHookSeq = 0;
390
+ // In-flight sub-agent starts (pushed on Agent PreToolUse, spliced on SubagentStop).
391
+ // Initialized here so a SubagentStop arriving before any Agent start (a lagged/orphan
392
+ // teardown on a fresh proc) reads a length safely instead of throwing.
393
+ this._pendingSubagentStarts = [];
394
+ // 0.13 D2: the InputLedger — every user-shaped input written to the bridge
395
+ // gets an observable lifecycle: written → seen → resolved | dropped |
396
+ // superseded | fold-suspected. Pre-P3, injectUserMessage minted a turn_id
397
+ // that never escaped the function (fold/new-turn/drop indistinguishable —
398
+ // seam S4; the #14 msg-2385 drop was invisible by construction).
399
+ // turn_id → { turnId, source, msgId, chatId, writtenAt, state, _dropTimer,
400
+ // _watchdogTimer, _rewritten }
401
+ this.inputLedger = new Map();
402
+ // Set whenever a reply carried the consumed_turn_ids contract field —
403
+ // the Tier 2C "contract observed" discriminator (P0 spike: incidental
404
+ // echo is trigger-only; without the contract a fold is indistinguishable
405
+ // from a drop, and auto-redelivering folds double-answers the common case).
406
+ this._lastAckFieldAt = 0;
407
+ // 0.12 interactive questions: tool_call_ids of `ask` calls awaiting an answer.
408
+ // While non-empty, the keep-alive interval resets the turn's idle ceiling (an
409
+ // idle `ask` fires no tool hooks, so _extendQuietOnToolActivity wouldn't run).
410
+ this._openQuestions = new Set();
411
+ this._questionKeepAliveTimer = null;
412
+
413
+ // File-send outbound cap (bot → user). Safe cloud default; overwritten in
414
+ // _spawnTmuxClaude with the backend/chat-resolved value before any turn.
415
+ this.maxOutboundFileBytes = this._injectedMaxOutboundFileBytes;
416
+
417
+ // P1 security (review #8): track resolved permission request_ids so a
418
+ // double-fire of respond() can't write a second perm_verdict for the same
419
+ // request. TmuxProcess gates on _pendingApprovalId; this is the channels
420
+ // analog.
421
+ this.respondedPermissions = new Set();
422
+ }
423
+
424
+ /**
425
+ * TmuxProcess uses cost=3 because each pane holds the full claude binary.
426
+ * CliProcess does the same (it's a tmux'd claude + a thin bridge subprocess).
427
+ */
428
+ get cost() {
429
+ return 3;
430
+ }
431
+
432
+ /**
433
+ * Parity P1: telemetry helper. Mirrors SdkProcess._logEvent so channels
434
+ * chats produce the same cross-backend ops rows. No-ops when db is unset
435
+ * (e.g. test fixtures). Defensive try/catch — telemetry must NEVER fail
436
+ * a turn.
437
+ */
438
+ _logEvent(kind, detail = {}) {
439
+ if (!this.db) return;
440
+ try {
441
+ this.db.logEvent?.(kind, {
442
+ chat_id: this.chatId,
443
+ thread_id: this.threadId,
444
+ session_key: this.sessionKey,
445
+ backend: this.backend,
446
+ ...detail,
447
+ });
448
+ } catch (err) {
449
+ this.logger.warn?.(`[${this.label}] channels: logEvent ${kind} failed: ${err.message}`);
450
+ }
451
+ }
452
+
453
+ // ─── start ─────────────────────────────────────────────────────────
454
+
455
+ async start(opts = {}) {
456
+ if (this.closed) throw new Error('CliProcess: cannot start a closed instance');
457
+
458
+ this.claudeSessionId = opts.existingSessionId || crypto.randomUUID();
459
+ // Save cwd so the tool dispatcher's file-attachment allowlist (P0 #2) can
460
+ // permit files under the agent's workspace.
461
+ this.sessionCwd = opts.cwd || null;
462
+
463
+ // File-send staging dir (2026-06 file-send feature). The dispatcher
464
+ // allowlist always permits <DEFAULT_ATTACHMENT_BASE>/<sessionKey>/, but
465
+ // nothing ever CREATED it — so claude's reply(files) attempts at
466
+ // /tmp/polygram-attachments failed (dir absent / realpath mismatch) and
467
+ // it flailed across other paths. Create it here and surface it to the
468
+ // prompt so claude has one blessed, always-allowed place to stage a file
469
+ // before sending. realpathSync so the stored path matches what the
470
+ // validator resolves (the /tmp ↔ /private/tmp fix).
471
+ try {
472
+ const dir = path.join(this.attachmentBase || DEFAULT_ATTACHMENT_BASE, String(this.sessionKey));
473
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
474
+ this.attachmentStagingDir = fs.realpathSync(dir);
475
+ } catch (err) {
476
+ this.attachmentStagingDir = null;
477
+ this.logger.warn?.(`[${this.label}] channels: staging dir create failed: ${err.message}`);
478
+ }
479
+
480
+ // Opaque random token for socket filename — do NOT leak sessionKey to /tmp.
481
+ const socketToken = crypto.randomBytes(16).toString('hex');
482
+ this.sockPath = path.join(os.tmpdir(), `${this.sessionPrefix}-${socketToken}.sock`);
483
+ this.sockSecret = crypto.randomBytes(32).toString('hex');
484
+
485
+ // Review #11: tmux session name MUST share the `water-${botName}-` prefix (Part 0: was polygram-, mismatched orphan-sweep — orchestra internal-consistency fix)
486
+ // used by lib/tmux/orphan-sweep.js + listPolygramSessions, otherwise daemon-
487
+ // boot orphan-sweep won't see channels sessions and leaks claude+bridge
488
+ // pairs on every restart.
489
+ const tmuxName = `${this.sessionPrefix}-${this.botName}-channels-${socketToken.slice(0, 8)}`;
490
+ this.tmuxSession = tmuxName;
491
+
492
+ // Review R6+R7+R10: any throw after _createSocketServer leaks the socket
493
+ // file + listener + (after _spawnTmuxClaude) the tmux session. Wrap in
494
+ // try/catch that runs the same teardown kill() does.
495
+ try {
496
+ await this._createSocketServer();
497
+ await this._spawnTmuxClaude({ tmuxName, opts });
498
+ await this._waitForBridgeHandshake();
499
+ // Phase 1.3: arm hook tail AFTER spawn so this._hookNdjsonPath is
500
+ // populated (set inside _spawnTmuxClaude when writeHookFiles runs).
501
+ // Mirrors tmux-process.js:_armHookTail timing.
502
+ this._armHookTail();
503
+ this._startPingLoop();
504
+ } catch (err) {
505
+ await this._teardownOnStartFailure();
506
+ throw err;
507
+ }
508
+
509
+ // Parity P17: init payload matches TmuxProcess shape (snake_case key for
510
+ // session_id) so polygram's onInit consumer doesn't need three-shape branching.
511
+ this.emit('init', {
512
+ session_id: this.claudeSessionId,
513
+ label: this.label,
514
+ backend: this.backend,
515
+ tmux_name: this.tmuxSession,
516
+ });
517
+ }
518
+
519
+ /**
520
+ * Best-effort cleanup when start() fails partway through. Mirrors kill()
521
+ * but doesn't mark the instance closed (caller may retry with a new
522
+ * instance).
523
+ *
524
+ * Review F#4: pre-fix this referenced `this.sockClient` / `this.sockServer`,
525
+ * neither of which is assigned anywhere post-M1 refactor (socket lifecycle
526
+ * moved into ChannelsBridgeServer = `this.bridgeServer`). The dead checks
527
+ * meant the net.Server listener was NEVER closed on start-failure → FD
528
+ * leak compounding across every spawn retry. Now closes `this.bridgeServer`
529
+ * the way `_doKill` already does (line ~1105). Defensive try/catch
530
+ * preserves the calling pattern (teardown must not mask the start() error).
531
+ */
532
+ async _teardownOnStartFailure() {
533
+ if (this.bridgeServer) {
534
+ try { await this.bridgeServer.close(); } catch {}
535
+ this.bridgeServer = null;
536
+ }
537
+ if (this.sockPath) {
538
+ try { fs.unlinkSync(this.sockPath); } catch {}
539
+ }
540
+ // P0 #1: unlink the secret-bearing mcp-config file on every teardown path
541
+ if (this.mcpConfigPath) {
542
+ try { fs.unlinkSync(this.mcpConfigPath); } catch {}
543
+ }
544
+ if (this.tmuxSession) {
545
+ try { await this.runner.killSession(this.tmuxSession); } catch {}
546
+ }
547
+ // Phase 1.3: tear down hook tail + clean per-session hook files on
548
+ // start-failure path. Tail may not be armed yet (start ordering puts
549
+ // _armHookTail after _spawnTmuxClaude); guard with the field check.
550
+ if (this._hookTail) {
551
+ try { this._hookTail.close(); } catch {}
552
+ this._hookTail = null;
553
+ }
554
+ if (this.botName && this.claudeSessionId) {
555
+ try { removeHookFiles({ botName: this.botName, sessionId: this.claudeSessionId, hooksDir: this._hooksDir }); } catch {}
556
+ }
557
+ }
558
+
559
+ /**
560
+ * M1 refactor: socket-server lifecycle delegated to ChannelsBridgeServer.
561
+ * This class wires the event surface (bridge-ready, bridge-message,
562
+ * bridge-disconnected) and keeps protocol semantics in _handleBridgeMessage.
563
+ */
564
+ async _createSocketServer() {
565
+ this.bridgeServer = new ChannelsBridgeServer({
566
+ sockPath: this.sockPath,
567
+ sessionKey: this.sessionKey,
568
+ sockSecret: this.sockSecret,
569
+ logger: this.logger,
570
+ label: `${this.label}:bridge-server`,
571
+ });
572
+
573
+ this.bridgeServer.on('session-init', msg => {
574
+ // Adopt the canonical claude session_id the bridge reports (claude may
575
+ // have ignored our --session-id; the bridge tells us what claude is
576
+ // actually running).
577
+ if (msg.claude_session_id && msg.claude_session_id !== this.claudeSessionId) {
578
+ this.claudeSessionId = msg.claude_session_id;
579
+ this.emit('session-id-refreshed', this.claudeSessionId);
580
+ }
581
+ });
582
+
583
+ this.bridgeServer.on('bridge-ready', () => {
584
+ this.bridgeReady = true;
585
+ this.emit('bridge-ready');
586
+ });
587
+
588
+ // 0.12 Phase 1.6: claude has finished registering water-bridge as
589
+ // an MCP server (first ListToolsRequest landed at the bridge).
590
+ // _waitForBridgeHandshake gates send() on this in addition to
591
+ // bridge-ready — the cold-spawn race fix.
592
+ this.bridgeServer.on('mcp-ready', () => {
593
+ this.mcpReady = true;
594
+ this.emit('mcp-ready');
595
+ });
596
+
597
+ this.bridgeServer.on('bridge-message', msg => this._handleBridgeMessage(msg));
598
+
599
+ this.bridgeServer.on('bridge-disconnected', () => this._handleBridgeDisconnected());
600
+
601
+ await this.bridgeServer.listen();
602
+ }
603
+
604
+ /**
605
+ * Env for the spawned channels-bridge MCP subprocess. ORCHESTRA_QUESTION_TIMEOUT_MS
606
+ * tells the bridge our question wait so its last-resort `ask` backstop sits ABOVE
607
+ * it — without it the bridge fell back to a hardcoded 32min that fired long before
608
+ * the daemon's 24h wait, so a question the user answered an hour later was already
609
+ * resolved {timedout}. Extracted (pure) so the alignment is unit-testable.
610
+ */
611
+ _bridgeEnv() {
612
+ return {
613
+ ORCHESTRA_SESSION_KEY: this.sessionKey,
614
+ ORCHESTRA_SOCK: this.sockPath,
615
+ ORCHESTRA_SOCK_SECRET: this.sockSecret,
616
+ ORCHESTRA_CLAUDE_SESSION_ID: this.claudeSessionId,
617
+ ORCHESTRA_QUESTION_TIMEOUT_MS: String(QUESTION_TIMEOUT_MS),
618
+ };
619
+ }
620
+
621
+ async _spawnTmuxClaude({ tmuxName, opts }) {
622
+ const bridgeEnv = this._bridgeEnv();
623
+ const mcpConfig = {
624
+ mcpServers: {
625
+ [this.bridgeServerName]: {
626
+ command: 'node',
627
+ args: [BRIDGE_PATH],
628
+ env: bridgeEnv,
629
+ },
630
+ },
631
+ };
632
+
633
+ // Review P0 #1: write mcp-config to a 0o600 tmp file and pass the FILE
634
+ // PATH in argv. Inline JSON in argv would expose ORCHESTRA_SOCK_SECRET +
635
+ // ORCHESTRA_SESSION_KEY in /proc/<pid>/cmdline + `ps -ef` to any local
636
+ // process (defeats the 0o600 socket). The file path itself reveals
637
+ // nothing. claude's `--mcp-config <configs...>` accepts JSON files or
638
+ // strings (per `--help`).
639
+ //
640
+ // The path stays alongside the socket so cleanup is symmetric.
641
+ const socketToken = path.basename(this.sockPath, '.sock').replace(new RegExp(`^${this.sessionPrefix}-`), '');
642
+ this.mcpConfigPath = path.join(os.tmpdir(), `${this.sessionPrefix}-${socketToken}-mcp.json`);
643
+ fs.writeFileSync(this.mcpConfigPath, JSON.stringify(mcpConfig), { mode: 0o600 });
644
+ // Defensive re-chmod in case umask interfered with the open-mode flag.
645
+ try { fs.chmodSync(this.mcpConfigPath, 0o600); } catch {}
646
+
647
+ // ARG ORDER MATTERS (Phase 0 finding):
648
+ // --mcp-config is variadic <configs...> — must come LAST.
649
+ // In dev mode use --dangerously-load-development-channels server:NAME
650
+ // by itself; do NOT also pass --channels (it makes claude reject the
651
+ // next arg as a malformed channel entry).
652
+ // --no-session-persistence is --print-mode only.
653
+ const claudeArgs = [
654
+ '--strict-mcp-config',
655
+ '--dangerously-load-development-channels', `server:${this.bridgeServerName}`,
656
+ ];
657
+
658
+ // Resolve config FIRST so the --resume file-check below has the correct
659
+ // cwd (it picks up topic precedence). Other flags get pushed in order
660
+ // after this.
661
+ const topicConfig = opts.threadId && opts.chatConfig?.topics?.[opts.threadId];
662
+ const agent = topicConfig?.agent || opts.chatConfig?.agent || opts.agent;
663
+ const model = this._resolveModel(opts);
664
+ const effort = this._resolveEffort(opts);
665
+ const resolvedCwd = topicConfig?.cwd || opts.chatConfig?.cwd || opts.cwd;
666
+ // Record the spawn-time model/effort. cli has no live model/effort swap
667
+ // (they are spawn-time --model / --effort flags), so getOrSpawn detects a
668
+ // /model or /effort drift against these and reloads — --resume preserves
669
+ // the conversation, the new flag takes effect. See wouldReloadFor.
670
+ this.model = model;
671
+ this.effort = effort;
672
+
673
+ // File-send outbound cap (bot → user). Backend-derived (cloud 50MB vs
674
+ // local Bot API server 2GB via opts.localApi) with the per-file override
675
+ // (topic → chat → bot → default), clamped to the backend ceiling. Stored
676
+ // for the dispatcher (live size-check) and the system prompt (so claude
677
+ // states the right limit). opts.outboundCapOverride is pre-resolved by
678
+ // buildSpawnContext via the shared resolver so this matches actual send()
679
+ // enforcement; the inline fallback keeps legacy/test callers working.
680
+ const _capOverride = opts.outboundCapOverride
681
+ ?? topicConfig?.maxFileBytes ?? opts.chatConfig?.maxFileBytes ?? null;
682
+ this.maxOutboundFileBytes = _capOverride != null ? _capOverride : this._injectedMaxOutboundFileBytes;
683
+
684
+ // 0.12.0-rc.13: per-chat/topic compaction warning (default OFF). Same
685
+ // topic→chat precedence as the file cap above. When enabled, the channels
686
+ // backend warns the chat as context fills (propose /compact at a break)
687
+ // and on auto-compaction (the event that detaches the bridge mid-turn).
688
+ const _compactionWarnRaw = topicConfig?.compactionWarnings ?? opts.chatConfig?.compactionWarnings;
689
+ this.compactionWarn = resolveCompactionWarnConfig({ compactionWarnings: _compactionWarnRaw });
690
+ this._compactionWarned = false; // proactive warn-once per climb; reset on PostCompact
691
+
692
+ // Parity audit P8 + rc.8 fs-guard (2026-05-26 shumorobot Music topic):
693
+ // `--session-id <id>` creates a NEW claude session with that id;
694
+ // `--resume <id>` resumes the EXISTING conversation. Lazy-respawn after
695
+ // bridge-disconnect must use --resume so conversation history is
696
+ // preserved. Mirrors tmux-process.js:514-518.
697
+ //
698
+ // rc.8 ghost-session guard: polygram persists claude_session_id to its
699
+ // DB as soon as the bridge handshakes (onInit), but claude only writes
700
+ // the JSONL after a successful turn. If an early channels attempt fails
701
+ // before claude completes any turn, polygram's DB ends up with a
702
+ // claude_session_id that has NO corresponding file under claude's
703
+ // projects dir. Subsequent `--resume <ghost-id>` makes claude exit
704
+ // clean with "No conversation found" — exactly the Music topic stall
705
+ // observed at 04:04:29 (session_id=567c72db never persisted; rc.4
706
+ // pane snapshot proved it).
707
+ //
708
+ // Fix: before passing --resume, verify the session JSONL actually
709
+ // exists under the launch cwd. If not, drop the ghost id and use
710
+ // --session-id with the freshly-generated uuid — claude creates a
711
+ // fresh session and onInit re-upserts the DB row.
712
+ //
713
+ // Resume cases preserved:
714
+ // - in-daemon lazy respawn (file written after first successful turn)
715
+ // - daemon restart on a chat that completed at least one turn
716
+ // Resume cases correctly dropped:
717
+ // - cross-backend stale ids (different cwd → different projects dir)
718
+ // - ghost ids from failed-before-first-turn attempts
719
+ let canResume = false;
720
+ let resumePath = null;
721
+ if (opts.existingSessionId && resolvedCwd) {
722
+ // claude's projects dir naming: cwd with '/' → '-'.
723
+ // Verified live at ~/.claude/projects/-Users-ivanshumkov-Music-rekordbox/
724
+ const cwdMangled = resolvedCwd.replace(/\//g, '-');
725
+ resumePath = path.join(os.homedir(), '.claude', 'projects', cwdMangled, `${opts.existingSessionId}.jsonl`);
726
+ try { canResume = fs.statSync(resumePath).isFile(); } catch { canResume = false; }
727
+ }
728
+ if (canResume) {
729
+ claudeArgs.push('--resume', opts.existingSessionId);
730
+ } else {
731
+ claudeArgs.push('--session-id', this.claudeSessionId);
732
+ if (opts.existingSessionId) {
733
+ this.logger.warn?.(
734
+ `[${this.label}] channels: dropping DB session ${opts.existingSessionId} — ` +
735
+ `no local file at ${resumePath || '<unknown cwd>'}. Starting fresh with ${this.claudeSessionId}.`,
736
+ );
737
+ }
738
+ }
739
+ // Finding 0.12-M2: record the resume decision so _armHookTail (run
740
+ // after spawn) skips the prior session's still-on-disk hook ndjson.
741
+ this._resumedSession = canResume;
742
+ if (agent) claudeArgs.push('--agent', agent);
743
+ if (model) claudeArgs.unshift('--model', model);
744
+ if (effort) claudeArgs.push('--effort', effort);
745
+
746
+ // rc.9 (2026-05-26 shumorobot first-turn-dead-zone diagnosis): channels
747
+ // backend defaults to permissionMode='bypassPermissions'. Without it,
748
+ // claude TUI shows the canonical interactive permission prompt for
749
+ // every `mcp__water-bridge__reply` call:
750
+ //
751
+ // water-bridge - reply(...) (MCP)
752
+ // Do you want to proceed?
753
+ // ❯ 1. Yes 2. Yes, and don't ask again 3. No
754
+ //
755
+ // Channels mode has no interactive surface — there's no human at the
756
+ // tmux pane to press a number — so every first-turn hangs until the
757
+ // 30-min turn timeout fires. Reproduced + fixed live via
758
+ // `scripts/spikes/channels-first-turn.mjs`: without bypassPermissions
759
+ // the spike times out at 60s with claude "Marinating" forever; with
760
+ // bypassPermissions it replies in ~5s.
761
+ //
762
+ // The bridge DOES relay `notifications/claude/channel/permission_request`
763
+ // (channels-bridge.mjs:258) for the EXPERIMENTAL channel-permission
764
+ // API, but claude TUI doesn't route ordinary MCP tool calls through
765
+ // that channel — it shows the regular TUI prompt. So the relay path
766
+ // isn't reachable from a fresh-spawn channels turn.
767
+ //
768
+ // Config can still override (e.g., chats that genuinely want a
769
+ // different mode set `permissionMode` in chat/topic config); the
770
+ // default ensures bots actually reply out of the box.
771
+ const permissionMode = topicConfig?.permissionMode
772
+ || opts.chatConfig?.permissionMode
773
+ || opts.permissionMode
774
+ || 'bypassPermissions';
775
+ // 0.12 Phase 4.5: stash for the hook Notification handler — it gates
776
+ // approval-card emit on permissionMode !== 'bypassPermissions'. Under
777
+ // bypass, claude doesn't fire Notification for permission requests
778
+ // anyway (no UI prompt), so the gate is belt-and-braces.
779
+ this.permissionMode = permissionMode;
780
+ claudeArgs.push('--permission-mode', permissionMode);
781
+ if (permissionMode === 'bypassPermissions') {
782
+ claudeArgs.push('--dangerously-skip-permissions');
783
+ }
784
+
785
+ // Parity audit P3 + rc.7 (2026-05-26 shumorobot diagnosis): combined
786
+ // system-prompt suffix carrying BOTH the Telegram display rules AND the
787
+ // channels-mode reply-tool contract. Merged into a single
788
+ // --append-system-prompt block — passing two separate
789
+ // --append-system-prompt flags caused MCP server registration to fail
790
+ // (live shumorobot tmux banner: "server:water-bridge · no MCP server
791
+ // configured with that name"; claude received no channel messages).
792
+ // Suspected: --append-system-prompt is variadic in claude's CLI and the
793
+ // second flag was eating the subsequent --setting-sources / --mcp-config
794
+ // arguments. Single combined block sidesteps the issue.
795
+ const b = this.bridgeServerName; // reply-tool name: mcp__<b>__reply
796
+ const product = this.productName; // consumer product name in the prose
797
+ const surface = this.surfaceName; // chat surface (Telegram / WhatsApp / …)
798
+ claudeArgs.push('--append-system-prompt', [
799
+ this.displayHint,
800
+ '',
801
+ `## ${product} channels mode — HARD CONTRACT`,
802
+ '',
803
+ `You are running inside ${product} with the channels backend. Your stdout/TUI`,
804
+ `output is NOT seen by the user. The user is on ${surface}.`,
805
+ '',
806
+ 'To deliver ANY message to the user, you MUST call the MCP tool',
807
+ `\`mcp__${b}__reply\` with \`chat_id\` and \`text\` arguments.`,
808
+ 'Pass the chat_id verbatim from the channel message you received.',
809
+ '',
810
+ 'Do NOT respond conversationally in-line. Do NOT assume any inline',
811
+ 'text will reach the user. If you have ANYTHING to say to the user —',
812
+ 'an answer, a question, a status update, an acknowledgement — it goes',
813
+ `through \`mcp__${b}__reply\`. Period.`,
814
+ '',
815
+ 'This applies to every turn, including the first message after',
816
+ '`/new` or `/reset`. Even a single-line "Hi" must be sent via the tool.',
817
+ '',
818
+ 'Internal tool calls (Bash, Edit, Write, Read, etc.) are fine to use',
819
+ 'as normal — only the FINAL user-visible message needs to go through',
820
+ 'the reply tool.',
821
+ '',
822
+ 'When you call `reply`, ALWAYS set `consumed_turn_ids` to the turn_id of',
823
+ 'EVERY <channel> message this reply answers or folds in — every mid-turn',
824
+ 'follow-up you absorbed since your last reply. This applies to EVERY reply,',
825
+ 'including SHORT one-line ones: if two messages arrived and you answered both',
826
+ 'in one reply, list BOTH turn_ids (e.g. consumed_turn_ids: ["<original-id>",',
827
+ `"<follow-up-id>"]). Omitting a folded follow-up makes ${product} read it as`,
828
+ `DROPPED — it gets re-sent to you or flagged as a lost message. When unsure,`,
829
+ 'include the id.',
830
+ '',
831
+ '### Staying responsive on a long task — show progress, never go silent',
832
+ '',
833
+ 'The user sees NOTHING while you work — no inline text, no tool output reaches',
834
+ 'them. A turn that runs long with no reply looks BROKEN (they see only silence)',
835
+ 'and can hit the turn time-cap before you answer.',
836
+ '',
837
+ 'So once you are clearly into multi-step work — you have run a couple of tool',
838
+ 'calls without replying, or the request plainly needs research / several steps —',
839
+ 'send a SHORT one-line status via `reply` WITH `interim: true` (it returns a',
840
+ `\`message_id\`), then use \`mcp__${b}__edit_message\` on that SAME`,
841
+ '`message_id` to update the bubble as you progress. `edit_message` is for',
842
+ 'INTERIM status ONLY.',
843
+ '',
844
+ 'HARD RULE — a status is a MID-TURN update, NOT the end of work. After an',
845
+ 'interim reply you MUST keep working in the SAME turn and deliver the real',
846
+ 'result. NEVER end your turn on a status / "give me a couple min" / "looking',
847
+ 'into it" reply with no result behind it — that leaves the user staring at a',
848
+ 'promise with nothing delivered. Do the work, then answer.',
849
+ '',
850
+ 'Deliver the FINAL answer as a fresh `reply` with interim omitted/false, never',
851
+ 'as an edit: a fresh reply notifies the user and carries `consumed_turn_ids`; an',
852
+ 'edit does neither. If you no longer have the status bubble\'s message_id, just',
853
+ 'send a fresh `reply` — never guess an id.',
854
+ '',
855
+ 'If you will finish in one or two tool calls, just answer — no status bubble.',
856
+ 'Status is for work that takes time, not for quick answers (do not spam it).',
857
+ '',
858
+ 'Write status in PLAIN language about what you are doing FOR THE USER — never',
859
+ 'tool names. Say "Checking your config now…", not "Running Bash".',
860
+ '',
861
+ // TEMPORARY mitigation (2026-06-08 Shumabit@UMI wedge): AskUserQuestion opens
862
+ // a blocking TUI selection widget the channel can't answer → the session
863
+ // parks until manually Esc'd. REMOVE this whole rule when the rich
864
+ // question→Telegram-keyboard feature ships (see docs design); claude should
865
+ // then use the native question tool again. Tracked so it isn't forgotten.
866
+ '### Asking the user a question / offering choices — HARD RULE',
867
+ '',
868
+ 'NEVER use the AskUserQuestion tool or any interactive menu / selection',
869
+ `widget. They open a blocking terminal prompt the user on ${surface} CANNOT`,
870
+ 'see or navigate — it silently wedges the entire session until it is manually',
871
+ 'cleared. (Rich tap-to-answer choices are coming; until then this is a hard rule.)',
872
+ '',
873
+ 'To ask a multiple-choice question, a confirmation, or yes/no, call the',
874
+ `\`mcp__${b}__ask\` tool — it renders tap-to-answer inline buttons`,
875
+ '(supports multiSelect via `multiSelect:true` and a free-text answer via',
876
+ '`allowOther:true`) and returns the user\'s selection(s) as the tool result.',
877
+ 'Prefer `ask` over a typed numbered list whenever you are offering choices.',
878
+ '',
879
+ '### Sending FILES (tracks, images, docs) to the user',
880
+ '',
881
+ `The \`mcp__${b}__reply\` tool takes an optional \`files\` array of`,
882
+ 'absolute paths. This is the ONLY correct way to send a file: reply delivers',
883
+ "it to the user's CURRENT topic/thread automatically. Do NOT use Bash, curl,",
884
+ `the ${surface} Bot API, or ${product}-ipc to send files: they do NOT know your`,
885
+ 'current thread, so they deliver to the WRONG topic (and skip size/safety',
886
+ 'checks). A raw Bot API call may LOOK like it worked — the upload returns 200 —',
887
+ "but it lands in the wrong topic the user isn't looking at. Always use reply(files).",
888
+ '',
889
+ ...(this.attachmentStagingDir ? [
890
+ `To send a file: COPY it into the staging dir \`${this.attachmentStagingDir}\`,`,
891
+ 'then call reply with its absolute path, e.g.:',
892
+ ` reply(chat_id="<id>", text="Here's the track", files=["${this.attachmentStagingDir}/track.flac"])`,
893
+ 'polygram auto-deletes staged files after the turn — you do not need to clean up.',
894
+ 'You may also send directly from the agent workspace (cwd); other paths are rejected.',
895
+ ] : [
896
+ 'Copy the file somewhere under your workspace (cwd) and pass its absolute',
897
+ 'path in `files`. Paths outside the workspace are rejected for safety.',
898
+ ]),
899
+ '',
900
+ `Max file size for sending: ${Math.round(this.maxOutboundFileBytes / (1024 * 1024))} MB. ` +
901
+ 'For larger lossless audio, convert to FLAC/MP3 under the limit first, ' +
902
+ 'or tell the user it exceeds the limit. Images go as photos; everything ' +
903
+ 'else as documents.',
904
+ ].join('\n'));
905
+
906
+ // Parity audit P6: honor isolateUserConfig — mirrors tmux pattern at
907
+ // lib/process/tmux-process.js:502-505,543-546. Channels ALWAYS uses
908
+ // --strict-mcp-config (the bridge requires it), so the MCP half is
909
+ // already isolated. The settings half (--setting-sources project,local)
910
+ // drops ~/.claude/settings.json — only useful when explicitly requested.
911
+ const isolateUserConfig = topicConfig?.isolateUserConfig
912
+ || opts.chatConfig?.isolateUserConfig
913
+ || opts.isolateUserConfig;
914
+ if (isolateUserConfig) {
915
+ claudeArgs.push('--setting-sources', 'project,local');
916
+ }
917
+
918
+ // 0.12 Phase 1.2: hook ndjson injection via --settings. writeHookFiles
919
+ // returns paths under ~/.polygram/<bot>/hooks/<sessionId>.{settings.json,ndjson}.
920
+ // Both files created with mode 0o600 (SEC-04). The command string in
921
+ // settings.json shell-quotes both paths (SEC-03 — handles HOME with spaces).
922
+ // _hookNdjsonPath stored for _armHookTail (Phase 1.3) and removeHookFiles
923
+ // on kill.
924
+ const { settingsPath: hookSettingsPath, ndjsonPath: hookNdjsonPath } = writeHookFiles({
925
+ botName: this.botName,
926
+ sessionId: this.claudeSessionId,
927
+ hooksDir: this._hooksDir,
928
+ });
929
+ this._hookNdjsonPath = hookNdjsonPath;
930
+ this._hookSettingsPath = hookSettingsPath;
931
+ claudeArgs.push('--settings', hookSettingsPath);
932
+
933
+ // --mcp-config MUST be last (variadic flag)
934
+ claudeArgs.push('--mcp-config', this.mcpConfigPath); // P0 #1: file path, not inline JSON
935
+
936
+ // resolvedCwd was computed above (line ~375) for the --resume file-check.
937
+ if (resolvedCwd) claudeArgs.unshift('--add-dir', resolvedCwd);
938
+
939
+ // rc.5 (2026-05-25 shumorobot diagnosis): the launch cwd MUST be the
940
+ // resolved topic/chat cwd, not just opts.cwd. claude's TUI indexes
941
+ // session storage by current working directory — its session files
942
+ // live under ~/.claude/projects/<cwd-with-dashes>/<session-id>.jsonl.
943
+ // When polygram launched claude with cwd=process.cwd() (the daemon's
944
+ // own working dir, e.g. ~/.polygram), `--resume <id>` looked in the
945
+ // wrong projects dir and printed "No conversation found with session
946
+ // ID: <id>" then exited clean — surfacing as "Process exited (code 0)"
947
+ // immediately after the bridge briefly connected.
948
+ //
949
+ // Mirrors lib/process/tmux-process.js:488 + :659 — same resolution,
950
+ // same fallback chain. The `--add-dir` flag is independent: it
951
+ // declares an additional trusted-roots entry, NOT the launch dir.
952
+ //
953
+ // Real tmuxRunner.spawn signature: {name, cwd, command, args, envExtras, paneWidth}
954
+ await this.runner.spawn({
955
+ name: tmuxName,
956
+ cwd: resolvedCwd || opts.cwd || process.cwd(),
957
+ command: this.claudeBin,
958
+ args: claudeArgs,
959
+ envExtras: {
960
+ // Resume-dialog suppression (docs/0.13-resume-dialog-fix-spec.md B1):
961
+ // claude's session-age "resume-return" dialog fires when sessionAge ≥
962
+ // this many minutes AND est. tokens ≥ CLAUDE_CODE_RESUME_TOKEN_THRESHOLD
963
+ // (defaults 70 / 1e5, binary-verified on 2.1.158). Its pre-selected
964
+ // option literally runs /compact — silently compacting every aged
965
+ // --resume (and breaking the /model "conversation kept" guarantee).
966
+ // A huge threshold (1 year) means the dialog never triggers and resume
967
+ // is always full-session-as-is. Per-process env — the operator's own
968
+ // interactive claude is untouched. Belt-and-braces: the session-age
969
+ // gate trigger below still navigates to "full" if a future binary bump
970
+ // renames this var.
971
+ CLAUDE_CODE_RESUME_THRESHOLD_MINUTES: '525600',
972
+ },
973
+ });
974
+
975
+ // Dialog handling (Phase 0 finding) — poll capture-pane and Enter through:
976
+ // 1. workspace trust prompt (first-time cwd)
977
+ // 2. dev-channel confirmation ("WARNING: Loading development channels")
978
+ // Both fire before the channel is actually listening. We loop with a
979
+ // bounded timeout, send Enter when we see the trigger string.
980
+ await this._handleStartupDialogs(tmuxName);
981
+ }
982
+
983
+ /**
984
+ * Dialog-handling extracted to lib/tmux/startup-gate.js (M1 follow-on).
985
+ * Channels-specific triggers + ready signal are declared here; the loop
986
+ * lives in the shared helper.
987
+ */
988
+ async _handleStartupDialogs(tmuxName) {
989
+ const gateResult = await runStartupGate({
990
+ runner: this.runner,
991
+ tmuxName,
992
+ triggers: [
993
+ // Dev-channels confirmation — always fires under
994
+ // --dangerously-load-development-channels.
995
+ { name: 'dev-channels', regex: /WARNING: Loading development channels/i, key: 'Enter' },
996
+ // Workspace trust prompt — fires on first-time cwd or untrusted. claude
997
+ // 2.1.158 renders "Quick safety check: Is this a project you created or
998
+ // one you trust? … ❯ 1. Yes, I trust this folder" (Enter confirms the
999
+ // pre-selected "trust" option). The older "trust the files in this folder"
1000
+ // wording is kept for back-compat; both anchor on "trust … this folder".
1001
+ { name: 'trust', regex: /trust (?:the files in )?this folder/i, key: 'Enter' },
1002
+ // Review F#12 + 2026-06-11 resume-dialog fix: session-age
1003
+ // "resume-return" prompt on aged sessions. Bare Enter selects the
1004
+ // pre-selected "Resume from summary" — which literally runs /compact
1005
+ // on the resumed session (silent context degradation; the original
1006
+ // F#12 dismissal compacted every aged resume). Navigate to option 2
1007
+ // "Resume full session as-is" instead. This is the FALLBACK path:
1008
+ // spawn env (CLAUDE_CODE_RESUME_THRESHOLD_MINUTES above) suppresses
1009
+ // the dialog entirely; this trigger firing at all means suppression
1010
+ // failed (upstream renamed the env var?) — surfaced via the
1011
+ // session-age-dialog-fallback event below.
1012
+ { name: 'session-age', regex: SESSION_AGE_PROMPT_RE, keys: ['Down', 'Enter'] },
1013
+ ],
1014
+ // 2.1.173 reworked the channels UI banner (live-captured 2026-06-11):
1015
+ // "Channels (experimental) messages from server:water-bridge inject
1016
+ // directly in this session · …". Keep the 2.1.158 text too so a
1017
+ // ORCHESTRA_CLAUDE_BIN override to an older binary still gates correctly.
1018
+ //
1019
+ // 2026-06-12 (caught by the cancel-cheap E2E before prod): in 2.1.173
1020
+ // the banner lives in a COLLAPSIBLE notice list — with ≥3 notices the
1021
+ // pane shows "+N more · /status" and the banner is hidden, stalling a
1022
+ // banner-only gate into a false CHANNELS_DIALOG_TIMEOUT. An interactive
1023
+ // prompt footer ("(shift+tab to cycle)" / "? for shortcuts") with no
1024
+ // pending dialog is equally READY: the gate's job is dialog navigation;
1025
+ // channel liveness is separately guaranteed by mcp-ready (send() gate)
1026
+ // + the delivery watchdog. Dialog panes render "Enter to confirm"
1027
+ // instead of the footer, so the footer can't match mid-dialog.
1028
+ readySignal: new RegExp(`(?:Listening for channel messages from:|Channels \\(experimental\\) messages from) server:${this.bridgeServerName}|shift\\+tab to cycle|\\? for shortcuts`, 'i'),
1029
+ timeoutCode: 'CHANNELS_DIALOG_TIMEOUT',
1030
+ // Progress-aware gate (shumorobot General incident 2026-05-30): a
1031
+ // cold spawn that's mid-download (runtime fetch, "24%" progress bar)
1032
+ // is genuinely working and must NOT be killed by the blind 30s
1033
+ // wall-clock. stallMs fails fast only when the pane is FROZEN; an
1034
+ // actively-changing pane (download bar, dialog nav) keeps resetting
1035
+ // the stall clock and rides out to the ready signal. deadlineMs stays
1036
+ // the absolute backstop. 30s of zero pane activity = genuinely wedged.
1037
+ // Stall = pane rendered then went static (genuinely wedged). 60s, not
1038
+ // 30s: some topics' TUIs cold-render slowly (Music ~45s, slow MCP
1039
+ // startup) — 30s was too tight and false-aborted them. Blank panes
1040
+ // don't arm the stall timer at all now (see runStartupGate), so this
1041
+ // only bounds a TUI that rendered and then truly hung.
1042
+ stallMs: this.startupGateStallMs ?? 60_000,
1043
+ deadlineMs: this.startupGateDeadlineMs ?? 180_000,
1044
+ // Review F4: fire-time, NOT gate-resolution — the 2026-06-10 incident
1045
+ // matched session-age and THEN died (TMUX_SESSION_GONE), which a
1046
+ // success-path check would miss. The dialog appearing AT ALL means the
1047
+ // env suppression (CLAUDE_CODE_RESUME_THRESHOLD_MINUTES in
1048
+ // _spawnTmuxClaude) stopped working — almost certainly an upstream
1049
+ // rename on a binary bump. The gate handles it (full resume picked);
1050
+ // this makes the regression visible.
1051
+ onTrigger: (name) => {
1052
+ if (name !== 'session-age') return;
1053
+ this.logger.warn?.(
1054
+ `[${this.label}] channels: session-age resume dialog appeared despite env suppression — ` +
1055
+ 'check CLAUDE_CODE_RESUME_THRESHOLD_MINUTES against the pinned claude binary',
1056
+ );
1057
+ this._logEvent('session-age-dialog-fallback', { tmux_name: tmuxName, phase: 'startup-gate' });
1058
+ },
1059
+ logger: this.logger,
1060
+ label: `${this.label}:startup-gate`,
1061
+ });
1062
+ return gateResult;
1063
+ }
1064
+
1065
+ // 0.12 Phase 1.6: TWO-handshake gate. The original implementation only
1066
+ // waited for `bridge-ready` (daemon-side: bridge subprocess connected to
1067
+ // daemon unix socket + sent hello + session_init). That left a ~50ms
1068
+ // window where the bridge was ready but claude hadn't finished
1069
+ // registering it as an MCP server, so push notifications (user_msg)
1070
+ // could be silently dropped by claude. Phase 0 cold-spawn probe measured
1071
+ // a 33% first-turn flake. Fix: also wait for `mcp-ready`, which the
1072
+ // bridge emits when claude sends its first ListToolsRequest — the
1073
+ // first signal claude has fully registered the bridge as an MCP server.
1074
+ //
1075
+ // Both waits resolve race-safe via state flags set in _createSocketServer
1076
+ // listeners (bridgeReady, mcpReady).
1077
+ _waitForBridgeHandshake() {
1078
+ const waitBridge = this.bridgeReady ? Promise.resolve() : new Promise((resolve, reject) => {
1079
+ const timer = setTimeout(() => {
1080
+ const err = new Error(`bridge handshake timeout (${this.handshakeTimeoutMs}ms)`);
1081
+ err.code = 'CHANNELS_HANDSHAKE_TIMEOUT';
1082
+ reject(err);
1083
+ }, this.handshakeTimeoutMs);
1084
+ this.once('bridge-ready', () => { clearTimeout(timer); resolve(); });
1085
+ });
1086
+ const waitMcp = this.mcpReady ? Promise.resolve() : new Promise((resolve, reject) => {
1087
+ const timer = setTimeout(() => {
1088
+ const err = new Error(`mcp-ready timeout (${this.mcpReadyTimeoutMs}ms) — bridge connected but claude never sent first ListToolsRequest`);
1089
+ err.code = 'CHANNELS_MCP_READY_TIMEOUT';
1090
+ reject(err);
1091
+ }, this.mcpReadyTimeoutMs);
1092
+ this.once('mcp-ready', () => { clearTimeout(timer); resolve(); });
1093
+ });
1094
+ return Promise.all([waitBridge, waitMcp]).then(() => undefined);
1095
+ }
1096
+
1097
+ // ─── bridge protocol semantics ─────────────────────────────────────
1098
+ // Socket lifecycle + auth + schema validation are owned by ChannelsBridgeServer
1099
+ // (M1 refactor). This method handles ONLY the validated, post-auth messages
1100
+ // that the server emits as 'bridge-message'. session_init + bridge-ready are
1101
+ // handled by the server-event subscribers wired in _createSocketServer().
1102
+
1103
+ _handleBridgeMessage(msg) {
1104
+ switch (msg.kind) {
1105
+ case 'tool':
1106
+ // Phase 1.5: hook PreToolUse is now the canonical 'tool-use' event
1107
+ // source (covers ALL tools, not just bridge-exposed ones). Previously
1108
+ // this emit fired for bridge tools (reply / react / edit_message)
1109
+ // ONLY, leaving internal tools (Bash / Read / Edit / Agent) invisible
1110
+ // to the reactor on the channels path — that's why HeartbeatReactor
1111
+ // existed. With hooks in place, drop this emit; the reactor sees
1112
+ // the bridge reply tool via PreToolUse(mcp__water-bridge__reply)
1113
+ // and treats it uniformly with other tool calls. The dispatch path
1114
+ // below still runs — it's how the reply actually reaches Telegram.
1115
+ this._dispatchToolCall(msg).catch(err => {
1116
+ this.logger.error?.(`[${this.label}] channels: tool dispatch failed: ${err.message}`);
1117
+ });
1118
+ break;
1119
+
1120
+ case 'perm_req':
1121
+ // Canonical 'approval-required' shape — matches TmuxProcess emit signature
1122
+ // (lib/process/tmux-process.js:2877). polygram.js's existing onApprovalRequired
1123
+ // handler (lib/sdk/callbacks.js wired in polygram.js:2217) consumes this
1124
+ // shape unchanged and gets canUseTool + admin-card flow for free.
1125
+ //
1126
+ // Review P1 #13: toolInput MUST be a string for compatibility with
1127
+ // lib/tmux/tui-tool-input.js#normalizeTuiToolInput — that function coerces
1128
+ // non-string input to '' which produces a silently empty approval card.
1129
+ // We pass input_preview (the tool args as JSON truncated to 200 chars by
1130
+ // Claude Code) since it's the most useful single-line representation.
1131
+ // The `description` from the perm_req notification is folded into the
1132
+ // toolInput when distinct from the preview, so operators see both.
1133
+ this.emit('approval-required', {
1134
+ id: msg.request_id,
1135
+ toolName: msg.tool_name,
1136
+ toolInput: this._formatToolInputForApproval(msg.description, msg.input_preview),
1137
+ sessionId: this.claudeSessionId,
1138
+ backend: this.backend,
1139
+ // respond closure adapts the canonical 'allow'/'deny' verdict back to
1140
+ // the Channels protocol's permission notification. The `message` arg
1141
+ // (used by tmux's "deny with feedback") is dropped — Channels protocol
1142
+ // verdicts carry no feedback string.
1143
+ respond: (decision, _message) => {
1144
+ const behavior = decision === 'allow' ? 'allow' : 'deny';
1145
+ return this.respondToPermission(msg.request_id, behavior);
1146
+ },
1147
+ });
1148
+ break;
1149
+
1150
+ case 'pong':
1151
+ // Review P1 #6: record pong timestamp; the watchdog (started in
1152
+ // _startPingLoop) checks this every 5s and declares the bridge dead
1153
+ // if 30s have passed without one.
1154
+ this.lastPongAt = Date.now();
1155
+ break;
1156
+
1157
+ default:
1158
+ this.logger.warn?.(`[${this.label}] channels: unknown bridge msg.kind=${msg.kind}`);
1159
+ }
1160
+ }
1161
+
1162
+ /**
1163
+ * Produce a STRING toolInput for the canonical 'approval-required' payload.
1164
+ * normalizeTuiToolInput (consumed by polygram.js's canUseTool plumbing)
1165
+ * expects a string and coerces objects to '' — which makes the admin-card
1166
+ * empty. We prefer `input_preview` (Claude's truncated tool-args JSON), and
1167
+ * if `description` adds information not already in the preview, append it
1168
+ * after a separator for the operator's benefit.
1169
+ *
1170
+ * @param {string} description
1171
+ * @param {string} inputPreview
1172
+ * @returns {string}
1173
+ */
1174
+ _formatToolInputForApproval(description, inputPreview) {
1175
+ const desc = typeof description === 'string' ? description.trim() : '';
1176
+ const prev = typeof inputPreview === 'string' ? inputPreview.trim() : '';
1177
+ if (desc && prev && desc !== prev && !prev.includes(desc) && !desc.includes(prev)) {
1178
+ return `${prev}\n— ${desc}`;
1179
+ }
1180
+ return prev || desc || '';
1181
+ }
1182
+
1183
+ /**
1184
+ * Review F#16: stable dedup key for content-based deduplication of reply
1185
+ * tool calls. We hash (chat_id || text) so identical text to different chats
1186
+ * never collides. SHA-256 truncated to 32 hex chars is plenty for collision
1187
+ * resistance within the 60s window.
1188
+ */
1189
+ _buildContentDedupKey(chatId, text) {
1190
+ return `${chatId}::${crypto
1191
+ .createHash('sha256')
1192
+ .update(text, 'utf8')
1193
+ .digest('hex')
1194
+ .slice(0, 32)}`;
1195
+ }
1196
+
1197
+ async _dispatchToolCall(msg) {
1198
+ const args = msg.args || {};
1199
+
1200
+ // rc.10 diagnostic: surface every inbound tool call BEFORE any dedup /
1201
+ // rate-limit / chat-id-mismatch path. Live shumorobot 2026-05-26 23:44
1202
+ // observed 3+ "Called water-bridge" entries in the TUI pane with
1203
+ // ZERO OUT messages delivered to TG and zero warn-level diagnostics —
1204
+ // need to see args.chat_id / args.turn_id to know whether claude is
1205
+ // calling reply with empty text, wrong chat_id, or something else.
1206
+ // L13: root-caused — demoted to debug and DROPPED text_head. Logging
1207
+ // the first 80 chars of every reply at warn level leaked private chat
1208
+ // content / file excerpts / secrets into the default log sink,
1209
+ // unconditionally. name/chat_id/turn_id/text_len diagnose dispatch
1210
+ // without exposing message content.
1211
+ this.logger.debug?.(
1212
+ `[${this.label}] channels: tool-call name=${msg.name} ` +
1213
+ `chat_id=${JSON.stringify(args.chat_id)} ` +
1214
+ `turn_id=${JSON.stringify(args.turn_id)} ` +
1215
+ `text_len=${typeof args.text === 'string' ? args.text.length : 'non-string'}`,
1216
+ );
1217
+
1218
+ // Review P1 #7: idempotency. If we've already ACK'd this tool_call_id,
1219
+ // re-ACK with the cached result rather than re-dispatching to Telegram.
1220
+ // Without this, Claude's reply-retry on isError (which can fire after a
1221
+ // slow ack timeout) → double-send of the same TG message.
1222
+ if (msg.tool_call_id && this.recentToolCallIds.has(msg.tool_call_id)) {
1223
+ this.logger.warn?.(
1224
+ `[${this.label}] channels: duplicate tool_call_id=${msg.tool_call_id} — re-ACKing without dispatch`,
1225
+ );
1226
+ // 0.13: replay the cached message_id so a retried reply keeps its edit handle
1227
+ // (re-ACKing without it would null the handle → progressive status silently breaks).
1228
+ this._writeToBridge({ kind: 'tool_ack', tool_call_id: msg.tool_call_id, ok: true, message_id: this.recentToolCallResults.get(msg.tool_call_id) ?? null });
1229
+ return;
1230
+ }
1231
+
1232
+ // 0.13 D1: any bridge tool call is same-session activity (the reply tool's
1233
+ // own delivery additionally notes activity via _recordReplyForPendingTurn,
1234
+ // but Pre/PostToolUse hook lag is 250ms–5s — the socket message is the
1235
+ // earliest truthful signal claude is working).
1236
+ this._noteActivity('bridge-tool');
1237
+
1238
+ // 0.13 D2 Tier 2C: the consumed_turn_ids contract field — claude
1239
+ // acknowledges every <channel> message this reply covers (incl. folds the
1240
+ // incidental turn_id echo can't express; the reply schema carries ONE
1241
+ // turn_id). Acked entries can never be declared dropped.
1242
+ //
1243
+ // SECURITY (review 2026-06-12): gate the ack on chat_id matching this
1244
+ // session. The chat_id check lives further down (after dedup/rate-limit);
1245
+ // without this guard a reply carrying a FOREIGN chat_id but naming the live
1246
+ // turn here would mark it resolved/_consumedAcked + arm the finalizer —
1247
+ // "delivered" though nothing reached this chat. The actual reject still
1248
+ // happens at the chat_id guard below.
1249
+ const chatIdMatches = this.chatId == null || String(args.chat_id) === String(this.chatId);
1250
+ if (chatIdMatches && Array.isArray(args.consumed_turn_ids) && args.consumed_turn_ids.length) {
1251
+ this._ledgerAckConsumed(
1252
+ args.consumed_turn_ids.filter((x) => typeof x === 'string'),
1253
+ typeof args.text === 'string' ? args.text : '',
1254
+ );
1255
+ } else if (chatIdMatches && msg.name === 'reply' && 'consumed_turn_ids' in args) {
1256
+ this._lastAckFieldAt = Date.now(); // field present but empty — contract observed
1257
+ }
1258
+
1259
+ // 0.12 interactive questions: `ask` is a BLOCKING tool whose answer rides back
1260
+ // on a `question_answer` message (NOT tool_ack). Skip the reply-only paths
1261
+ // (content-dedup, rate-limit, the reply dispatcher) — just guard chat_id and
1262
+ // emit so polygram renders the keyboard; the answer is written later via
1263
+ // writeQuestionAnswer(). claude is now idle waiting on the result, so start a
1264
+ // keep-alive that resets the turn's idle ceiling (no tool hooks fire meanwhile).
1265
+ if (msg.name === 'ask') {
1266
+ if (this.chatId != null && args.chat_id != null && String(args.chat_id) !== String(this.chatId)) {
1267
+ this._writeToBridge({ kind: 'question_answer', tool_call_id: msg.tool_call_id, result: { cancelled: true, error: 'chat_id mismatch' } });
1268
+ return;
1269
+ }
1270
+ this._openQuestions.add(msg.tool_call_id);
1271
+ this._startQuestionKeepAlive();
1272
+ // 0.13 D1: waiting-on-user — claude is legitimately silent, so the
1273
+ // activity-quiet finalize must not run down while the keyboard is up.
1274
+ this._suspendActivityQuiet();
1275
+ this.emit('question-asked', {
1276
+ sessionKey: this.sessionKey,
1277
+ chatId: this.chatId,
1278
+ threadId: this.threadId,
1279
+ turnId: args.turn_id || null,
1280
+ toolCallId: msg.tool_call_id,
1281
+ questions: Array.isArray(args.questions) ? args.questions : [],
1282
+ backend: this.backend,
1283
+ });
1284
+ return;
1285
+ }
1286
+
1287
+ // Review F#16: secondary content-hash dedup catches retries that come in
1288
+ // with a NEW tool_call_id (Claude regenerates the id on each retry after
1289
+ // an isError ack). Window-based so legit repeat sends eventually pass.
1290
+ if (msg.name === 'reply' && typeof args.text === 'string' && args.chat_id != null) {
1291
+ const dedupKey = this._buildContentDedupKey(args.chat_id, args.text);
1292
+ const entry = this.recentContentHashes.get(dedupKey); // { expiry, message_id }
1293
+ const nowDedup = Date.now();
1294
+ // Evict stale entries opportunistically (avoids unbounded growth).
1295
+ if (this.recentContentHashes.size > 64) {
1296
+ for (const [k, e] of this.recentContentHashes) {
1297
+ if (e.expiry < nowDedup) this.recentContentHashes.delete(k);
1298
+ }
1299
+ }
1300
+ if (entry && entry.expiry > nowDedup) {
1301
+ this.logger.warn?.(
1302
+ `[${this.label}] channels: duplicate content within ${this.contentDedupWindowMs}ms ` +
1303
+ `(new tool_call_id=${msg.tool_call_id}, hash=${dedupKey.slice(-12)}) — re-ACKing without dispatch`,
1304
+ );
1305
+ this._logEvent('channels-content-dedup-hit', {
1306
+ tool_call_id: msg.tool_call_id,
1307
+ chat_id: args.chat_id,
1308
+ window_ms: this.contentDedupWindowMs,
1309
+ });
1310
+ // 0.13: replay the ORIGINAL bubble's message_id so a retried identical reply
1311
+ // keeps its edit handle (the slow-ack-retry case progressive status targets).
1312
+ this._writeToBridge({ kind: 'tool_ack', tool_call_id: msg.tool_call_id, ok: true, message_id: entry.message_id ?? null });
1313
+ return;
1314
+ }
1315
+ // The same content may be dispatching RIGHT NOW (slow upload past the
1316
+ // bridge ack timeout → claude already retried). Join the in-flight
1317
+ // dispatch and re-ACK its eventual result — a second dispatch here is
1318
+ // exactly the text+file double-delivery the caches can't catch yet.
1319
+ const inflight = this._inFlightDispatches.get(dedupKey);
1320
+ if (inflight) {
1321
+ this.logger.warn?.(
1322
+ `[${this.label}] channels: duplicate of an IN-FLIGHT dispatch ` +
1323
+ `(new tool_call_id=${msg.tool_call_id}, hash=${dedupKey.slice(-12)}) — joining, no re-dispatch`,
1324
+ );
1325
+ this._logEvent('channels-inflight-dedup-join', {
1326
+ tool_call_id: msg.tool_call_id,
1327
+ chat_id: args.chat_id,
1328
+ });
1329
+ const prior = await inflight;
1330
+ this._writeToBridge({
1331
+ kind: 'tool_ack', tool_call_id: msg.tool_call_id,
1332
+ ok: !!prior?.ok,
1333
+ error: prior?.ok ? undefined : (prior?.error || 'in-flight dispatch failed'),
1334
+ message_id: prior?.message_id ?? null,
1335
+ });
1336
+ return;
1337
+ }
1338
+ }
1339
+
1340
+ // Review P2 ADV-6: token-bucket rate limit. Refill tokens based on time
1341
+ // since last refill (1 token per (1000/rate) ms, capped at burst size).
1342
+ // If no token available, NACK the tool call so Claude sees the failure
1343
+ // and (hopefully) backs off.
1344
+ const now = Date.now();
1345
+ const refill = ((now - this.toolRateLastRefillAt) / 1000) * this.toolRatePerSec;
1346
+ if (refill >= 1) {
1347
+ this.toolRateTokens = Math.min(this.toolRateBurst, this.toolRateTokens + Math.floor(refill));
1348
+ this.toolRateLastRefillAt = now;
1349
+ }
1350
+ if (this.toolRateTokens < 1) {
1351
+ this.logger.warn?.(
1352
+ `[${this.label}] channels: tool rate limit exceeded (${this.toolRatePerSec}/s burst=${this.toolRateBurst}) — NACKing`,
1353
+ );
1354
+ this._writeToBridge({
1355
+ kind: 'tool_ack', tool_call_id: msg.tool_call_id, ok: false,
1356
+ error: `rate limit exceeded (${this.toolRatePerSec}/s)`,
1357
+ });
1358
+ return;
1359
+ }
1360
+ this.toolRateTokens -= 1;
1361
+
1362
+ // P1 security: chat_id MUST match the session's registered chatId.
1363
+ if (this.chatId != null && String(args.chat_id) !== String(this.chatId)) {
1364
+ // Review P3 ADV-11: rate-limit the log (1 line per second per session)
1365
+ // so a 1000× mismatch storm doesn't fill warn logs. The NACK still fires
1366
+ // on every mismatched call — only the log is throttled.
1367
+ const now = Date.now();
1368
+ if (now - this._lastChatIdMismatchLogAt > 1000) {
1369
+ this._lastChatIdMismatchLogAt = now;
1370
+ this.logger.warn?.(
1371
+ `[${this.label}] channels: tool chat_id mismatch (got ${args.chat_id}, expected ${this.chatId}) — dropping`,
1372
+ );
1373
+ }
1374
+ this._writeToBridge({ kind: 'tool_ack', tool_call_id: msg.tool_call_id, ok: false, error: 'chat_id mismatch' });
1375
+ return;
1376
+ }
1377
+
1378
+ // Dropped-"4" fix A2 (docs/0.13-resume-dialog-fix-spec.md): resolve the
1379
+ // reply's originating TG message so the dispatcher has a target for solo
1380
+ // reactions (and reply-quoting). Resolution order strictly mirrors
1381
+ // _recordReplyForPendingTurn so quote/reaction attribution can never
1382
+ // disagree with reply attribution: echoed turn_id → InputLedger entry's
1383
+ // msgId (registered at send/inject time); no echo → the single pending
1384
+ // turn's ledger entry. Anything else stays null — an unattributable
1385
+ // reply must never react to / quote an unrelated message.
1386
+ //
1387
+ // Review F1: quote only the FIRST delivered reply per turn. On SDK,
1388
+ // deliverReplies fires once per turn → one quote; the channels dispatcher
1389
+ // fires per reply tool call, and an N-reply turn must not produce N
1390
+ // bubbles all quoting the same user message.
1391
+ let sourceMsgId = null;
1392
+ let sourceEntry = null;
1393
+ if (args.turn_id && this.inputLedger.has(args.turn_id)) {
1394
+ sourceEntry = this.inputLedger.get(args.turn_id);
1395
+ } else if (this.pendingTurns.size === 1) {
1396
+ const [[onlyTurnId]] = this.pendingTurns;
1397
+ sourceEntry = this.inputLedger.get(onlyTurnId) || null;
1398
+ }
1399
+ if (sourceEntry && !sourceEntry._quoteUsed) {
1400
+ // Message ids may be numeric (Telegram) OR opaque strings (WhatsApp stanza ids).
1401
+ // A purely-numeric id coerces to Number so number-based quote/reaction call sites
1402
+ // match (Telegram); an opaque string id (WhatsApp) stays a string. A blind Number()
1403
+ // dropped WhatsApp targets to null; blindly keeping strings broke Telegram's
1404
+ // numeric reaction path — this handles both.
1405
+ const raw = sourceEntry.msgId;
1406
+ if (typeof raw === 'string' && raw.length > 0) {
1407
+ const n = Number(raw);
1408
+ sourceMsgId = Number.isFinite(n) && n > 0 && String(n) === raw ? n : raw;
1409
+ } else if (raw != null) {
1410
+ const n = Number(raw);
1411
+ sourceMsgId = Number.isFinite(n) && n > 0 ? n : null;
1412
+ }
1413
+ }
1414
+
1415
+ // Mark this reply's content in-flight BEFORE awaiting the dispatcher so
1416
+ // a retry arriving mid-dispatch joins it (see the in-flight check above).
1417
+ let inflightKey = null;
1418
+ let inflightSettle = null;
1419
+ if (msg.name === 'reply' && typeof args.text === 'string' && args.chat_id != null) {
1420
+ inflightKey = this._buildContentDedupKey(args.chat_id, args.text);
1421
+ this._inFlightDispatches.set(inflightKey, new Promise(res => { inflightSettle = res; }));
1422
+ }
1423
+
1424
+ let result;
1425
+ try {
1426
+ result = await this.toolDispatcher({
1427
+ sessionKey: this.sessionKey,
1428
+ chatId: this.chatId,
1429
+ threadId: this.threadId,
1430
+ toolName: msg.name,
1431
+ text: args.text,
1432
+ interim: args.interim === true, // status/progress reply — not the turn's answer
1433
+ files: args.files,
1434
+ messageId: args.message_id, // 0.13: edit_message target bubble
1435
+ sourceMsgId, // reaction/quote target (A2)
1436
+ sessionCwd: this.sessionCwd, // P0 #2: dispatcher uses this to allowlist file roots
1437
+ maxOutboundFileBytes: this.maxOutboundFileBytes, // backend/chat-derived upload cap
1438
+ });
1439
+ } catch (err) {
1440
+ this._writeToBridge({ kind: 'tool_ack', tool_call_id: msg.tool_call_id, ok: false, error: err.message });
1441
+ return;
1442
+ } finally {
1443
+ if (inflightKey) {
1444
+ this._inFlightDispatches.delete(inflightKey);
1445
+ inflightSettle(result); // undefined on throw → joiners ack ok:false
1446
+ }
1447
+ }
1448
+
1449
+ // Review F1: the quote target is spent once a reply actually delivered
1450
+ // with it. A FAILED delivery doesn't consume it — the retry still quotes.
1451
+ if (msg.name === 'reply' && result?.ok && sourceMsgId != null && sourceEntry) {
1452
+ sourceEntry._quoteUsed = true;
1453
+ }
1454
+
1455
+ // 0.13: carry the delivered message_id back so the bridge hands it to claude
1456
+ // (reply → edit_message progressive status).
1457
+ this._writeToBridge({ kind: 'tool_ack', tool_call_id: msg.tool_call_id, ok: !!result?.ok, error: result?.error, message_id: result?.message_id });
1458
+
1459
+ // P1 #7: remember the tool_call_id so duplicates re-ACK without dispatch.
1460
+ // Only cache on SUCCESS — failed calls should be retryable (transient TG
1461
+ // outage etc).
1462
+ if (result?.ok && msg.tool_call_id) {
1463
+ this.recentToolCallIds.add(msg.tool_call_id);
1464
+ this.recentToolCallResults.set(msg.tool_call_id, result.message_id ?? null); // 0.13: for re-ACK replay
1465
+ this.recentToolCallOrder.push(msg.tool_call_id);
1466
+ // FIFO eviction at cap
1467
+ while (this.recentToolCallOrder.length > RECENT_TOOL_CALL_LIMIT) {
1468
+ const evicted = this.recentToolCallOrder.shift();
1469
+ this.recentToolCallIds.delete(evicted);
1470
+ this.recentToolCallResults.delete(evicted);
1471
+ }
1472
+ }
1473
+
1474
+ // Review F#16: also record the (chat_id, content-hash) so a retry with a
1475
+ // NEW tool_call_id still dedups. TTL-based via expiry timestamp.
1476
+ if (result?.ok && msg.name === 'reply' && typeof args.text === 'string' && args.chat_id != null) {
1477
+ const dedupKey = this._buildContentDedupKey(args.chat_id, args.text);
1478
+ // 0.13: store the delivered message_id alongside the expiry so a deduped retry
1479
+ // can replay it (keeps claude's edit handle for progressive status).
1480
+ this.recentContentHashes.set(dedupKey, { expiry: Date.now() + this.contentDedupWindowMs, message_id: result.message_id ?? null });
1481
+ }
1482
+
1483
+ // Review #16 + C9: only record the reply for pending-turn resolution when
1484
+ // the dispatcher actually delivered AND the text is a non-empty string.
1485
+ // Review P1 #4: route by turn_id (echoed from inbound <channel> meta) so
1486
+ // concurrent turns don't cross-attribute their replies. If Claude echoed a
1487
+ // turn_id, target that turn specifically; if not (older Claude / forgot),
1488
+ // fall back to the SINGLE pending turn if exactly one exists, else the
1489
+ // oldest pending — log a warning either way so we can audit drift.
1490
+ if (msg.name === 'reply' && result?.ok && typeof args.text === 'string' && args.text.length > 0) {
1491
+ this._recordReplyForPendingTurn(args.text, args.turn_id, args.interim === true);
1492
+ }
1493
+ }
1494
+
1495
+ /**
1496
+ * Route a reply text to the pending turn it belongs to.
1497
+ *
1498
+ * @param {string} text
1499
+ * @param {string|undefined} replyTurnId — echoed from Claude's reply tool args
1500
+ * @param {boolean} interim — true for a status/progress reply (`interim:true`),
1501
+ * which is NOT the turn's answer. A reply is FINAL by default (fail-safe).
1502
+ */
1503
+ _recordReplyForPendingTurn(text, replyTurnId, interim = false) {
1504
+ // 0.13 D2 (S5 tightening): a reply echoing a KNOWN ledgered turn_id that is
1505
+ // NOT the current pending is a LATE reply from an earlier cycle (post-
1506
+ // finalize tails, fireUserMessage cycles, ask wrap-ups). Pre-P3 the
1507
+ // ==1 fallback below bound it into whatever pending exists now — the live
1508
+ // misattribution path the design's §1.4 corollary names. Correlate it,
1509
+ // resolve its entry, and route it as already-delivered instead.
1510
+ if (replyTurnId && !this.pendingTurns.has(replyTurnId) && this.inputLedger.has(replyTurnId)) {
1511
+ const lEntry = this.inputLedger.get(replyTurnId);
1512
+ this._ledgerTransition(replyTurnId, 'resolved');
1513
+ this._logEvent('cli-late-reply-correlated', { turn_id: replyTurnId, source: lEntry.source });
1514
+ this.emit('autonomous-assistant-message', {
1515
+ text,
1516
+ sessionId: this.claudeSessionId,
1517
+ backend: this.backend,
1518
+ alreadyDelivered: true,
1519
+ });
1520
+ return;
1521
+ }
1522
+ let target = null;
1523
+ if (replyTurnId && this.pendingTurns.has(replyTurnId)) {
1524
+ // Canonical path: Claude echoed the turn_id we sent.
1525
+ target = this.pendingTurns.get(replyTurnId);
1526
+ target._turnId = replyTurnId;
1527
+ } else if (this.pendingTurns.size === 1) {
1528
+ // Single in-flight turn — unambiguous fallback.
1529
+ const [[onlyId, only]] = this.pendingTurns;
1530
+ target = only;
1531
+ target._turnId = onlyId;
1532
+ if (replyTurnId) {
1533
+ this.logger.warn?.(
1534
+ `[${this.label}] channels: reply turn_id=${replyTurnId} unknown but exactly 1 pending turn; routing to ${onlyId}`,
1535
+ );
1536
+ }
1537
+ } else if (this.pendingTurns.size > 1) {
1538
+ // Review F#3: multi-turn misattribution. Pre-fix this fell back to the
1539
+ // OLDEST pending turn, which cross-attributes Q2's answer to Q1's
1540
+ // source-msg whenever Claude omits turn_id (legitimate per the
1541
+ // inputSchema — `turn_id` is NOT in `required: [...]`). Q2 then hangs
1542
+ // until hardTimer (10 min) with no reply ever bound to it.
1543
+ //
1544
+ // Post-fix: DROP the orphan reply at the binding layer. The dispatcher
1545
+ // has ALREADY delivered the text to Telegram (the user sees it), so
1546
+ // dropping just means neither pending turn absorbs it spuriously.
1547
+ // Both pending turns then time out at their own quiet/hard timers with
1548
+ // no cross-attribution. Log + event so the orphan is visible in
1549
+ // forensics (we want to know if Claude is reliably echoing turn_id).
1550
+ const pendingIds = Array.from(this.pendingTurns.keys());
1551
+ this.logger.warn?.(
1552
+ `[${this.label}] channels: orphan reply has no/unknown turn_id ` +
1553
+ `(got ${JSON.stringify(replyTurnId)}); ${pendingIds.length} pending turns ` +
1554
+ `[${pendingIds.join(',')}]; dropping rather than misattributing (was: route to oldest)`,
1555
+ );
1556
+ this._logEvent('channels-orphan-reply-dropped', {
1557
+ reply_turn_id: replyTurnId || null,
1558
+ pending_count: pendingIds.length,
1559
+ pending_turn_ids: pendingIds,
1560
+ reply_len: text.length,
1561
+ });
1562
+ return;
1563
+ }
1564
+ // No pending turns at all → emit canonical 'autonomous-assistant-message'
1565
+ // event so polygram's autonomous-msg path (sdk/callbacks.js
1566
+ // onAutonomousAssistantMessage handler) routes it correctly. This is what
1567
+ // ScheduleWakeup / unsolicited replies look like on channels. Matches
1568
+ // SdkProcess emit shape (lib/process/sdk-process.js:304).
1569
+ //
1570
+ // Review F#22: the dispatcher has ALREADY delivered this text to Telegram
1571
+ // (the reply tool call ran in _dispatchToolCall before this fired).
1572
+ // polygram's onAutonomousAssistantMessage handler is backend-agnostic and
1573
+ // would re-deliver via tg(sendMessage). Flag it so the handler skips the
1574
+ // second send. Mirrors F#2's in-turn `result.alreadyDelivered` pattern;
1575
+ // missing the autonomous path was a regression caught in production
1576
+ // (OUT 1148 + 1149 identical-text double-delivery, same timestamp).
1577
+ if (!target) {
1578
+ this.emit('autonomous-assistant-message', {
1579
+ text,
1580
+ sessionId: this.claudeSessionId,
1581
+ backend: this.backend,
1582
+ alreadyDelivered: true,
1583
+ });
1584
+ return;
1585
+ }
1586
+
1587
+ target.replies.push(text);
1588
+ target.replyCount = (target.replyCount || 0) + 1;
1589
+ // A status/progress reply (`interim:true`) is delivered but is NOT the turn's
1590
+ // answer — track it so the finalizer can tell an interim-only turn (a promise
1591
+ // like "give me a couple min") from a delivered result, and so the ceilings
1592
+ // keep extending it as still-working rather than resolving it as done.
1593
+ // docs/progress-is-not-turn-end-spec.md
1594
+ if (interim) target._interimReplyCount = (target._interimReplyCount || 0) + 1;
1595
+
1596
+ if (this._sawHookStream) {
1597
+ // 0.13 D1: a delivered reply is ACTIVITY — rung 2 (activity-quiet) owns
1598
+ // the finalize; the reply-quiet window never arms on hooks-live sessions.
1599
+ // The chatty-claude cap (Review P1 #12) no longer instant-resolves a turn
1600
+ // claude may still be working (that was seam S1's third premature-finalize
1601
+ // trigger); past the cap, rung 2 + the ceilings govern — and a ceiling on
1602
+ // a replied turn now RESOLVES with its replies (see fireTimeout).
1603
+ if (target.replyCount === this.maxRepliesPerTurn) {
1604
+ this.logger.warn?.(
1605
+ `[${this.label}] cli: ${target.replyCount} replies in single turn — deferring to activity-quiet (cap=${this.maxRepliesPerTurn})`,
1606
+ );
1607
+ this._logEvent('cli-reply-cap-noted', { reply_count: target.replyCount });
1608
+ }
1609
+ this._noteActivity('reply');
1610
+ return;
1611
+ }
1612
+
1613
+ // ── Legacy (rung 3, hook stream never came up): pre-D1 path, byte-identical ──
1614
+ // Review F#13: each reply is "activity" — reset the idle ceiling so a
1615
+ // 15-min legit turn (PDF analysis, multi-file refactor) replying every
1616
+ // minute doesn't get killed at the 10-min wall-clock. The absoluteTimer
1617
+ // still bounds runaways at 30 min.
1618
+ if (target.hardTimer) {
1619
+ clearTimeout(target.hardTimer);
1620
+ target.hardTimer = setTimeout(
1621
+ () => target._fireTimeout?.('idle'),
1622
+ target._idleCeilingMs || this.turnTimeoutMs,
1623
+ );
1624
+ }
1625
+ // Review P1 #12: quiet-window resets forever when Claude streams chatty
1626
+ // progress replies (`reading…`, `analyzing…`) every ~1s → user sees 10min
1627
+ // hang. After N reply tool calls in a single turn, resolve immediately on
1628
+ // the NEXT reply without waiting for the quiet window. N defaults to 20
1629
+ // which is plenty for normal multi-message replies but caps runaway chains.
1630
+ if (target.quietTimer) clearTimeout(target.quietTimer);
1631
+ if (target.replyCount >= this.maxRepliesPerTurn) {
1632
+ // Skip the quiet-window — resolve right away with whatever we've got.
1633
+ this.logger.warn?.(
1634
+ `[${this.label}] channels: ${target.replyCount} replies in single turn — resolving immediately (cap=${this.maxRepliesPerTurn})`,
1635
+ );
1636
+ this._resolveTurn(target._turnId);
1637
+ } else {
1638
+ target.quietTimer = setTimeout(() => this._resolveTurn(target._turnId), this.turnQuietMs);
1639
+ }
1640
+ }
1641
+
1642
+ // ─── 0.13 D2: InputLedger ──────────────────────────────────────────
1643
+
1644
+ _ledgerAdd(turnId, { source, msgId = null } = {}) {
1645
+ this.inputLedger.set(turnId, {
1646
+ turnId,
1647
+ source,
1648
+ msgId: msgId != null ? String(msgId) : null,
1649
+ chatId: this.chatId,
1650
+ writtenAt: Date.now(),
1651
+ state: 'written',
1652
+ _dropTimer: null,
1653
+ _watchdogTimer: null,
1654
+ _rewritten: false,
1655
+ });
1656
+ // Bounded: prune terminal entries first, then the oldest.
1657
+ if (this.inputLedger.size > INPUT_LEDGER_CAP) {
1658
+ let victim = null;
1659
+ for (const [id, e] of this.inputLedger) {
1660
+ if (e.state !== 'written' && e.state !== 'seen') { victim = id; break; }
1661
+ if (!victim) victim = id;
1662
+ }
1663
+ if (victim) this._ledgerDelete(victim);
1664
+ }
1665
+ }
1666
+
1667
+ _ledgerDelete(turnId) {
1668
+ const e = this.inputLedger.get(turnId);
1669
+ if (!e) return;
1670
+ if (e._dropTimer) clearTimeout(e._dropTimer);
1671
+ if (e._watchdogTimer) clearTimeout(e._watchdogTimer);
1672
+ this.inputLedger.delete(turnId);
1673
+ }
1674
+
1675
+ /** Transition + cancel the entry's timers (a seen/resolved entry can never drop or re-write). */
1676
+ _ledgerTransition(turnId, state) {
1677
+ const e = this.inputLedger.get(turnId);
1678
+ if (!e) return;
1679
+ e.state = state;
1680
+ if (e._dropTimer) { clearTimeout(e._dropTimer); e._dropTimer = null; }
1681
+ if (e._watchdogTimer) { clearTimeout(e._watchdogTimer); e._watchdogTimer = null; }
1682
+ }
1683
+
1684
+ /**
1685
+ * Tier 2C: a reply carried consumed_turn_ids — acknowledge every known id.
1686
+ * `consumingText` is the text the consuming reply actually delivered; it is
1687
+ * recorded on each consumed pending so _finalizeTurn can tell a genuine fold
1688
+ * (the answer rode the sibling reply) from an early ack that DIDN'T carry the
1689
+ * answer (prod 2026-06-13: a 294-char "Researching now…" ack, then the real
1690
+ * answer arrived as Stop-fallback text — which must NOT be suppressed).
1691
+ * See docs/0.13-consumed-ack-stop-fallback-drop-spec.md.
1692
+ */
1693
+ _ledgerAckConsumed(ids, consumingText = '') {
1694
+ this._lastAckFieldAt = Date.now();
1695
+ for (const id of ids) {
1696
+ const e = this.inputLedger.get(id);
1697
+ if (e && e.state !== 'resolved') {
1698
+ this._ledgerTransition(id, 'resolved');
1699
+ this._logEvent('cli-input-acked', { turn_id: id, source: e.source });
1700
+ }
1701
+ // UMI 2026-06-11 19:49 false ⏱ timeout: when claude answers a
1702
+ // primary+fold in ONE reply but echoes the FOLD's turn_id, the reply
1703
+ // routes via late-reply correlation and the PRIMARY pending absorbs
1704
+ // nothing — yet this ack names the primary. Mark it consumed so the
1705
+ // finalizer rungs treat it as replied (resolve already-delivered)
1706
+ // instead of rejecting it at a ceiling AFTER the user got the answer.
1707
+ const pending = this.pendingTurns.get(id);
1708
+ if (pending) {
1709
+ pending._consumedAcked = true;
1710
+ // Remember WHAT the consuming reply delivered for this turn. The last
1711
+ // ack wins; a longer subsequent ack is the safer record (more likely to
1712
+ // actually contain the answer). _finalizeTurn only suppresses a
1713
+ // Stop-fallback finalize when its text matches this.
1714
+ if (typeof consumingText === 'string'
1715
+ && consumingText.length > (pending._consumedByText?.length || 0)) {
1716
+ pending._consumedByText = consumingText;
1717
+ }
1718
+ // The ack itself flips rung-2 eligibility on — arm now. (The turn's
1719
+ // last _noteActivity ran BEFORE this flag was set, so without this
1720
+ // a quiet tail would never re-arm and the turn would sit until a
1721
+ // ceiling.)
1722
+ this._armActivityQuiet(id, pending);
1723
+ }
1724
+ }
1725
+ }
1726
+
1727
+ _clearLedgerTimers() {
1728
+ for (const e of this.inputLedger.values()) {
1729
+ if (e._dropTimer) { clearTimeout(e._dropTimer); e._dropTimer = null; }
1730
+ if (e._watchdogTimer) { clearTimeout(e._watchdogTimer); e._watchdogTimer = null; }
1731
+ }
1732
+ }
1733
+
1734
+ /**
1735
+ * D2 drop detection, armed at every cycle end for non-primary entries still
1736
+ * 'written'. The confirm window exists because a non-folded inject legally
1737
+ * queues claude-side and is picked up as the NEXT cycle (its UPS then
1738
+ * cancels this); only entries nobody ever picked up or acknowledged drop.
1739
+ */
1740
+ _armDropConfirmSweep() {
1741
+ for (const [id, entry] of this.inputLedger) {
1742
+ if (entry.state !== 'written') continue;
1743
+ if (entry.source === 'primary') continue; // pending lifecycle + delivery watchdog govern primaries
1744
+ if (entry._dropTimer) continue;
1745
+ entry._dropTimer = setTimeout(() => this._dropConfirmFire(id), this.dropConfirmMs);
1746
+ entry._dropTimer.unref?.();
1747
+ }
1748
+ }
1749
+
1750
+ _dropConfirmFire(turnId) {
1751
+ const entry = this.inputLedger.get(turnId);
1752
+ if (!entry || entry.state !== 'written') return;
1753
+ entry._dropTimer = null;
1754
+ // System/anonymous pushes are never auto-redelivered — resolve quietly.
1755
+ if (entry.source === 'system' || entry.source === 'inject') {
1756
+ this._ledgerTransition(turnId, 'resolved');
1757
+ this._logEvent('cli-input-unconfirmed', { turn_id: turnId, source: entry.source });
1758
+ return;
1759
+ }
1760
+ // Supersession: the user re-sent / moved on — a newer primary was picked
1761
+ // up after this entry was written. Redelivering the stale one would
1762
+ // double-answer the same intent.
1763
+ for (const e of this.inputLedger.values()) {
1764
+ if (e.source === 'primary' && e.writtenAt > entry.writtenAt
1765
+ && (e.state === 'seen' || e.state === 'resolved')) {
1766
+ this._ledgerTransition(turnId, 'superseded');
1767
+ this._logEvent('input-superseded', { turn_id: turnId, msg_id: entry.msgId });
1768
+ return;
1769
+ }
1770
+ }
1771
+ // Contract discriminator: if NO reply since this entry carried the
1772
+ // consumed_turn_ids field, the model ignored the contract this cycle — a
1773
+ // fold is then indistinguishable from a drop, and redelivering folds
1774
+ // double-answers the COMMON case (the inversion that killed the A1 spec).
1775
+ // Park as fold-suspected (telemetry; the soak's anomaly signal).
1776
+ if (!(this._lastAckFieldAt >= entry.writtenAt)) { // >= : same-ms ack still proves the contract mode
1777
+ this._ledgerTransition(turnId, 'fold-suspected');
1778
+ this._logEvent('input-fold-suspected', { turn_id: turnId, msg_id: entry.msgId, source: entry.source });
1779
+ return;
1780
+ }
1781
+ this._ledgerTransition(turnId, 'dropped');
1782
+ this._logEvent('input-dropped', { turn_id: turnId, msg_id: entry.msgId, source: entry.source });
1783
+ this.emit('input-dropped', {
1784
+ turnId, msgId: entry.msgId, chatId: entry.chatId, source: entry.source,
1785
+ });
1786
+ }
1787
+
1788
+ /**
1789
+ * D2 primary-delivery watchdog (KI-drop's missing half — the channel-bind
1790
+ * race drops a user_msg before claude's subscription is live). Fire logic:
1791
+ * - entry seen / turn settled → done (timer was already cancelled).
1792
+ * - ANY session activity since dispatch (hooks, pane heartbeat, bridge
1793
+ * tool calls) → claude is busy (likely a foreign cycle; the queued
1794
+ * pickup is legitimately deferred) → extend, NEVER re-write (round-2
1795
+ * panel: re-writes against a busy session double-prompt it).
1796
+ * - total silence → ONE re-write of the SAME envelope (idempotent:
1797
+ * never seen + zero activity ⇒ claude never had it — the rc.25
1798
+ * argument, properly scoped); still silence after that → bridge
1799
+ * teardown onto the existing bridge-disconnected recovery path.
1800
+ */
1801
+ _armDeliveryWatchdog(turnId, pending) {
1802
+ const entry = this.inputLedger.get(turnId);
1803
+ if (!entry) return;
1804
+ entry._watchdogTimer = setTimeout(() => this._deliveryWatchdogFire(turnId, pending), this.deliveryWatchdogMs);
1805
+ entry._watchdogTimer.unref?.();
1806
+ }
1807
+
1808
+ _deliveryWatchdogFire(turnId, pending) {
1809
+ const entry = this.inputLedger.get(turnId);
1810
+ if (!entry || entry.state !== 'written') return;
1811
+ if (!this.pendingTurns.has(turnId)) return; // settled some other way
1812
+ entry._watchdogTimer = null;
1813
+ const activitySince = Math.max(this._lastActivityAt, this._lastHookEventAt) >= entry.writtenAt
1814
+ && Math.max(this._lastActivityAt, this._lastHookEventAt) > 0;
1815
+ if (activitySince) {
1816
+ this._armDeliveryWatchdog(turnId, pending); // busy — extend the window
1817
+ return;
1818
+ }
1819
+ if (!entry._rewritten) {
1820
+ entry._rewritten = true;
1821
+ this._logEvent('cli-delivery-rewrite', { turn_id: turnId });
1822
+ if (pending._userMsgPayload) this._writeToBridge(pending._userMsgPayload);
1823
+ this._armDeliveryWatchdog(turnId, pending);
1824
+ return;
1825
+ }
1826
+ this._logEvent('cli-delivery-watchdog-escalate', { turn_id: turnId });
1827
+ if (this.bridgeServer?.destroyConnection) this.bridgeServer.destroyConnection();
1828
+ }
1829
+
1830
+ /**
1831
+ * 0.13 D1: note same-session activity — the heartbeat of the finalizer ladder
1832
+ * (docs/0.13-channels-lifecycle-design.md §3 D1). Supersedes the 0.12
1833
+ * `_extendQuietOnToolActivity` (the WA-topic point fix): instead of pushing a
1834
+ * 2s reply-quiet window around, activity now drives three things per pending:
1835
+ *
1836
+ * 1. The idle ceiling resets (pre-D1 semantics preserved — a long
1837
+ * tool-heavy turn isn't idle-killed).
1838
+ * 2. HOOKS-LIVE sessions: an attributed-Stop grace in flight is CANCELLED —
1839
+ * Stop arrives via the ndjson tail with 250ms–5s lag, so a foreign
1840
+ * cycle's lagged Stop can land after this turn's fast first pickup;
1841
+ * activity proves claude is still working and the Stop was stale. The
1842
+ * legacy reply-quiet timer (rung 3) is likewise superseded the moment
1843
+ * hooks go live mid-turn. The activity-quiet window (rung 2) re-arms.
1844
+ * 3. HOOK-NEVER-ALIVE sessions (rung 3): the pre-D1 reply-quiet re-arm,
1845
+ * byte-identical.
1846
+ *
1847
+ * Callers: every hook event except Stop, the pane "esc to interrupt"
1848
+ * thinking heartbeat, bridge tool calls, delivered replies, the question
1849
+ * keep-alive, and question answers.
1850
+ */
1851
+ _noteActivity(source = 'activity') {
1852
+ this._lastActivityAt = Date.now();
1853
+ for (const [turnId, pending] of this.pendingTurns) {
1854
+ // Idle ceiling: activity IS activity.
1855
+ if (pending.hardTimer) {
1856
+ clearTimeout(pending.hardTimer);
1857
+ pending.hardTimer = setTimeout(
1858
+ () => pending._fireTimeout?.('idle'),
1859
+ pending._idleCeilingMs || this.turnTimeoutMs,
1860
+ );
1861
+ }
1862
+ if (this._sawHookStream) {
1863
+ if (pending._stopGracePending) this._cancelStopGrace(turnId, pending, source);
1864
+ if (pending.quietTimer) { clearTimeout(pending.quietTimer); pending.quietTimer = null; }
1865
+ this._armActivityQuiet(turnId, pending);
1866
+ } else if (pending._stopGracePending) {
1867
+ // Legacy grace (resolveTurn's wait-for-Stop) — never revived/cancelled
1868
+ // by activity; identical to pre-D1.
1869
+ continue;
1870
+ } else if (pending.quietTimer) {
1871
+ clearTimeout(pending.quietTimer);
1872
+ pending.quietTimer = setTimeout(() => this._resolveTurn(turnId), this.turnQuietMs);
1873
+ }
1874
+ }
1875
+ }
1876
+
1877
+ /**
1878
+ * Is this turn eligible for the rung-2 activity-quiet finalize? Eligible when the
1879
+ * answer is already captured where a finalize can deliver it:
1880
+ * - a delivered FINAL reply (it went out incrementally), OR
1881
+ * - seen + consumed-acked (the answer rode a sibling turn_id — fold-id echo;
1882
+ * see _ledgerAckConsumed), OR
1883
+ * - an attributed Stop captured the answer AND no work hook has fired since
1884
+ * (_workHookSeq unchanged from the capture) — i.e. claude is genuinely done,
1885
+ * not resumed into more work. A reply-less turn's only finalizer is its Stop grace;
1886
+ * when a pane-thinking heartbeat cancels that grace (the turn's own residual
1887
+ * "esc to interrupt"), this is the backstop that still delivers the captured
1888
+ * last_assistant_message instead of orphaning to the idle ceiling. The
1889
+ * hook-recency check withdraws eligibility the moment claude resumes (a resume
1890
+ * emits PreToolUse/etc. that increments _workHookSeq past the capture), so a
1891
+ * stale early Stop can't finalize over a still-working turn — that also covers
1892
+ * an in-flight sub-agent, which emits work hooks after any boundary Stop.
1893
+ * An interim-only turn with no captured answer stays ineligible (it must keep working).
1894
+ */
1895
+ _activityQuietEligible(pending) {
1896
+ if (this._turnHasFinalReply(pending)) return true;
1897
+ if (pending.seen === true && pending._consumedAcked === true) return true;
1898
+ if (pending._stopHookData
1899
+ && (this._workHookSeq || 0) === (pending._stopHookDataSeq || 0)) return true;
1900
+ return false;
1901
+ }
1902
+
1903
+ /**
1904
+ * D1 rung 2: arm/refresh the activity-quiet finalize for one pending.
1905
+ * Preconditions: hooks live, the answer is captured (see _activityQuietEligible),
1906
+ * no open question (waiting-on-user suspends the clock — claude is legitimately
1907
+ * silent), and no rung-1 grace in flight.
1908
+ */
1909
+ _armActivityQuiet(turnId, pending) {
1910
+ if (!this._sawHookStream) return;
1911
+ if (!this._activityQuietEligible(pending)) return;
1912
+ if (this._openQuestions.size > 0) return;
1913
+ if (pending._stopGracePending) return;
1914
+ if (pending._activityQuietTimer) clearTimeout(pending._activityQuietTimer);
1915
+ pending._activityQuietTimer = setTimeout(() => this._activityQuietFinalize(turnId), this.activityQuietMs);
1916
+ pending._activityQuietTimer.unref?.();
1917
+ }
1918
+
1919
+ /** D1: suspend rung 2 for all pendings (an `ask` just opened — waiting on the user). */
1920
+ _suspendActivityQuiet() {
1921
+ for (const [, pending] of this.pendingTurns) {
1922
+ if (pending._activityQuietTimer) {
1923
+ clearTimeout(pending._activityQuietTimer);
1924
+ pending._activityQuietTimer = null;
1925
+ }
1926
+ }
1927
+ }
1928
+
1929
+ /**
1930
+ * D1 rung 2 fire: the whole activity surface (hooks + pane heartbeat + bridge
1931
+ * tool calls) has been quiet for activityQuietMs and the answer is captured (a
1932
+ * delivered reply, a consumed-ack, or an attributed Stop — see
1933
+ * _activityQuietEligible). The tail is over (Stop was lost, foreign, the hook
1934
+ * stream died mid-session, or — the no-reply case — the Stop grace was cancelled
1935
+ * by a pane-thinking heartbeat racing the Stop's own residual streaming hint).
1936
+ */
1937
+ _activityQuietFinalize(turnId) {
1938
+ const pending = this.pendingTurns.get(turnId);
1939
+ if (!pending) return;
1940
+ if (pending._stopGracePending) return;
1941
+ if (this._openQuestions.size > 0) return; // re-check at fire time
1942
+ if (!this._activityQuietEligible(pending)) return;
1943
+ const consumedAcked = pending.seen === true && pending._consumedAcked === true;
1944
+ const lastHookAgeMs = this._lastHookEventAt ? Date.now() - this._lastHookEventAt : null;
1945
+ this._logEvent('cli-activity-quiet-finalize', {
1946
+ turn_id: turnId,
1947
+ reply_count: pending.replies.length,
1948
+ consumed_acked: consumedAcked,
1949
+ last_hook_age_ms: lastHookAgeMs,
1950
+ had_stop: !!pending._stopHookData,
1951
+ });
1952
+ // The no-reply rescue: a reply-less, not-consumed-acked turn finalizing here
1953
+ // qualified ONLY via its captured Stop — i.e. it would have orphaned to the idle
1954
+ // ceiling before this backstop existed. Distinct event so the soak can count it.
1955
+ if (!this._turnHasFinalReply(pending) && !consumedAcked) {
1956
+ this._logEvent('cli-noreply-stop-rescued', {
1957
+ turn_id: turnId,
1958
+ last_hook_age_ms: lastHookAgeMs,
1959
+ text_len: (pending._stopHookData?.lastAssistantMessage || '').length,
1960
+ });
1961
+ }
1962
+ if (lastHookAgeMs != null && lastHookAgeMs >= this.activityQuietMs) {
1963
+ // A previously-live hook stream went quiet enough that rung 2 (not an
1964
+ // attributed Stop) ended the turn — the soak's mid-session-death signal.
1965
+ this._logEvent('cli-hook-stream-stalled', { turn_id: turnId, last_hook_age_ms: lastHookAgeMs });
1966
+ }
1967
+ this._finalizeTurn(turnId);
1968
+ }
1969
+
1970
+ /**
1971
+ * Capture a Stop hook's data on a pending, recording the work-hook count AT capture.
1972
+ * The rung-2 no-reply backstop (_activityQuietEligible) compares the live _workHookSeq
1973
+ * against this snapshot to tell "claude is done" (no work hook since the Stop) from
1974
+ * "claude resumed" (a later work hook bumped the count). A monotonic counter — not a
1975
+ * timestamp — so a Stop and a resume hook landing in the same millisecond still differ.
1976
+ */
1977
+ _captureStopHookData(pending, info) {
1978
+ pending._stopHookData = info;
1979
+ pending._stopHookDataSeq = this._workHookSeq || 0;
1980
+ }
1981
+
1982
+ /**
1983
+ * D1 rung 1: an attributed Stop (the pending was `seen` at pickup, or has
1984
+ * ≥1 turn_id-bound reply) finalizes through a short grace that any
1985
+ * subsequent same-session activity cancels (see _noteActivity #2).
1986
+ */
1987
+ _beginAttributedStopGrace(turnId, pending, info) {
1988
+ this._captureStopHookData(pending, info);
1989
+ pending._stopGracePending = true;
1990
+ if (pending._activityQuietTimer) {
1991
+ clearTimeout(pending._activityQuietTimer);
1992
+ pending._activityQuietTimer = null;
1993
+ }
1994
+ const fire = () => {
1995
+ pending._stopGraceTimer = null;
1996
+ // Don't finalize a turn while a sub-agent is provably still in flight — a Stop
1997
+ // that fired at a sub-agent boundary (or during a quiet sub-agent stretch)
1998
+ // would otherwise CLEAR THE REACTION and end the turn mid-work, with the result
1999
+ // arriving later as a detached cycle. Defer: keep the turn (and its 👾 reaction,
2000
+ // held by B3) alive and re-check. Single-pending only — _pendingSubagentStarts
2001
+ // is proc-wide, so don't cross-attribute. The idle/absolute ceilings are
2002
+ // untouched (we don't reset them), so a lost SubagentStop can't hang — the
2003
+ // ceiling backstops it. docs/progress-is-not-turn-end-spec.md
2004
+ if (this.pendingTurns.has(turnId)
2005
+ && this.pendingTurns.size === 1
2006
+ && (this._pendingSubagentStarts?.length || 0) > 0) {
2007
+ if (!pending._stopGraceDeferred) {
2008
+ pending._stopGraceDeferred = true;
2009
+ this._logEvent('cli-stop-grace-deferred-subagent', {
2010
+ turn_id: turnId, in_flight: this._pendingSubagentStarts.length,
2011
+ session_id: this.claudeSessionId,
2012
+ });
2013
+ }
2014
+ pending._stopGracePending = true;
2015
+ pending._stopGraceTimer = setTimeout(fire, this.stopGraceMs);
2016
+ pending._stopGraceTimer.unref?.();
2017
+ return;
2018
+ }
2019
+ pending._stopGracePending = false;
2020
+ this._logEvent('cli-turn-resolved-by-stop', {
2021
+ turn_id: turnId,
2022
+ reply_count: pending.replies?.length || 0,
2023
+ via_text_fallback: (pending.replies?.length || 0) === 0,
2024
+ attributed: pending.seen === true ? 'seen' : 'reply-bound',
2025
+ deferred_for_subagent: pending._stopGraceDeferred === true,
2026
+ session_id: this.claudeSessionId,
2027
+ });
2028
+ this._finalizeTurn(turnId);
2029
+ };
2030
+ pending._stopGraceTimer = setTimeout(fire, this.stopGraceMs);
2031
+ pending._stopGraceTimer.unref?.();
2032
+ }
2033
+
2034
+ /** D1: cancel a stop-grace (rung 1 stale-Stop, or a superseded legacy grace). */
2035
+ _cancelStopGrace(turnId, pending, source) {
2036
+ if (pending._stopGraceTimer) { clearTimeout(pending._stopGraceTimer); pending._stopGraceTimer = null; }
2037
+ if (pending._onStop) { this.off('stop-hook', pending._onStop); pending._onStop = null; }
2038
+ pending._stopGracePending = false;
2039
+ this._logEvent('cli-stop-grace-cancelled', { turn_id: turnId, source });
2040
+ }
2041
+
2042
+ // 0.12 Phase 1.7 (Finding 0.1.A): two-step turn resolution.
2043
+ // _resolveTurn — entry point called by channel-result OR quiet-window
2044
+ // expiry. Schedules a stopGraceMs window during which
2045
+ // we wait for the Stop hook to land, then calls
2046
+ // _finalizeTurn. If Stop's last_assistant_message is
2047
+ // non-empty AND the turn has no reply-tool text, we
2048
+ // use it as fallback (rc.41 H4 pattern from tmux).
2049
+ // _finalizeTurn — synchronous finalize. Same body the old
2050
+ // _resolveTurn had; no behavior change.
2051
+ _resolveTurn(turnId) {
2052
+ const pending = this.pendingTurns.get(turnId);
2053
+ if (!pending) return;
2054
+ // Re-entrancy guard: a quiet-window expiry can fire alongside a Stop
2055
+ // hook; both call _resolveTurn. The second call no-ops.
2056
+ if (pending._stopGracePending) return;
2057
+ pending._stopGracePending = true;
2058
+
2059
+ // 0.12 Phase 1.7: the turn is conceptually DONE — we're just waiting
2060
+ // for Stop hook to land for the text-fallback rescue. Cancel the
2061
+ // turn-timeout / quiet / absolute timers so they can't reject the
2062
+ // pending while we're in grace. The grace timer itself caps the
2063
+ // wait at stopGraceMs.
2064
+ if (pending.quietTimer) { clearTimeout(pending.quietTimer); pending.quietTimer = null; }
2065
+ if (pending.hardTimer) { clearTimeout(pending.hardTimer); pending.hardTimer = null; }
2066
+ if (pending.absoluteTimer) { clearTimeout(pending.absoluteTimer); pending.absoluteTimer = null; }
2067
+
2068
+ const finalize = () => {
2069
+ this.off('stop-hook', onStop);
2070
+ pending._stopGracePending = false;
2071
+ this._finalizeTurn(turnId);
2072
+ };
2073
+ const onStop = (info) => {
2074
+ // Finding 0.12-M1: the Stop hook carries NO turn_id, and a single
2075
+ // global 'stop-hook' emission fires EVERY per-turn onStop listener.
2076
+ // When more than one turn is in stop-grace we cannot attribute this
2077
+ // Stop (or its last_assistant_message) to a specific turn — the
2078
+ // pre-fix code let one Stop finalize all grace-pending turns and
2079
+ // cross-attribute one turn's text to another (the exact class the
2080
+ // F#3 reply routing prevents). Mirror that drop-rather-than-
2081
+ // misattribute discipline: only consume the Stop when exactly ONE
2082
+ // turn is in grace; otherwise ignore it and let each turn finalize
2083
+ // on its own grace timer (each keeps its own reply text).
2084
+ let graceCount = 0;
2085
+ for (const p of this.pendingTurns.values()) if (p._stopGracePending) graceCount++;
2086
+ if (graceCount !== 1) return;
2087
+ this._captureStopHookData(pending, info);
2088
+ clearTimeout(pending._stopGraceTimer);
2089
+ pending._stopGraceTimer = null;
2090
+ finalize();
2091
+ };
2092
+ // L5: stash the closure so teardown paths that bypass Process.kill()'s
2093
+ // removeAllListeners (bridge-disconnect drain, resetSession) can off it.
2094
+ pending._onStop = onStop;
2095
+ pending._stopGraceTimer = setTimeout(finalize, this.stopGraceMs);
2096
+ // unref so a never-fired grace doesn't pin the event loop. In tests
2097
+ // where a CliProcess is created, send() is called, then the test
2098
+ // exits without explicitly killing the proc, the grace timer's
2099
+ // 2s wait would otherwise keep the node:test runner alive and
2100
+ // surface as "Promise resolution is still pending but the event
2101
+ // loop has already resolved" at the file-suite level. Production
2102
+ // never reaches this code path without a corresponding kill or
2103
+ // turn completion, so unref is safe.
2104
+ pending._stopGraceTimer.unref?.();
2105
+ this.on('stop-hook', onStop);
2106
+ }
2107
+
2108
+ /**
2109
+ * Has this turn delivered a FINAL (non-interim) reply? A reply is final by
2110
+ * default; only `interim:true` status replies don't count. A turn whose only
2111
+ * output is a status promise has NOT delivered its answer. Used by the
2112
+ * finalizer and the absolute checkpoint so an interim-only turn is treated as
2113
+ * still-working (keep extending / deliver the produced result), not as done.
2114
+ */
2115
+ _turnHasFinalReply(pending) {
2116
+ return (pending?.replies?.length || 0) > (pending?._interimReplyCount || 0);
2117
+ }
2118
+
2119
+ /**
2120
+ * Compute the {text, alreadyDelivered} a resolving turn delivers, honoring the
2121
+ * interim-reply rules. Shared by BOTH resolve paths — `_finalizeTurn` (Stop /
2122
+ * activity-quiet) AND the `fireTimeout` ceiling-resolve — so neither drops the
2123
+ * produced answer of an interim-only turn. docs/progress-is-not-turn-end-spec.md
2124
+ *
2125
+ * - a FINAL reply landed → its text was already delivered incrementally
2126
+ * (polygram.js short-circuits) → alreadyDelivered.
2127
+ * - zero replies → 0.12/0.13 Stop-fallback: deliver last_assistant_message
2128
+ * unless a consuming sibling already carried it (consumed-ack).
2129
+ * - interim-only (status promise, no final) → deliver the produced final answer
2130
+ * (last_assistant_message) if it exists and is distinct from the status / a
2131
+ * sibling's text; otherwise leave the status (nothing more to send).
2132
+ */
2133
+ _resolveTurnDelivery(pending, turnId) {
2134
+ const out = this._computeTurnDelivery(pending, turnId);
2135
+ // 0.17.8 characterize-first: the channels double-delivery (shumorobot Music,
2136
+ // 2026-06-28 — reply tool sent #2147, then the daemon re-sent result.text as
2137
+ // #2149) is a turn that resolved with alreadyDelivered=false despite a reply tool
2138
+ // delivery. Log the chosen branch + counts so the next occurrence pins WHY (the
2139
+ // leading hypothesis: an interrupted turn loses its recorded reply → the
2140
+ // zero-reply Stop-fallback re-delivers last_assistant_message).
2141
+ this._logEvent('cli-resolve-delivery', {
2142
+ turn_id: turnId, session_key: this.sessionKey, backend: this.backend,
2143
+ branch: out.branch,
2144
+ already_delivered: out.alreadyDelivered,
2145
+ reply_count: pending.replies.length,
2146
+ interim_count: pending._interimReplyCount || 0,
2147
+ has_stop_data: !!pending._stopHookData,
2148
+ text_len: (out.text || '').length,
2149
+ });
2150
+ return { text: out.text, alreadyDelivered: out.alreadyDelivered };
2151
+ }
2152
+
2153
+ _computeTurnDelivery(pending, turnId) {
2154
+ const norm = (s) => (s || '').trim();
2155
+ const interimText = pending.replies.join('\n\n');
2156
+ const fallbackText = pending._stopHookData?.lastAssistantMessage || '';
2157
+
2158
+ if (this._turnHasFinalReply(pending)) {
2159
+ return { text: interimText, alreadyDelivered: true, branch: 'final-reply' };
2160
+ }
2161
+ if (pending.replies.length === 0) {
2162
+ // 0.12 Phase 1.7 fallback: no reply tool call landed — use the Stop hook's
2163
+ // last_assistant_message so the user isn't left with silence (rc.41 H4).
2164
+ const usedStopFallback = !!fallbackText;
2165
+ const text = usedStopFallback ? fallbackText : '';
2166
+ if (usedStopFallback) {
2167
+ this.logger.warn?.(`[${this.label}] cli: turn finalized via stop-hook fallback (no reply tool call); text_len=${text.length}`);
2168
+ }
2169
+ // A _consumedAcked turn is "already delivered" ONLY when the consuming sibling
2170
+ // reply actually carried THIS text — not merely an ack (prod 2026-06-13: a
2171
+ // "Researching now…" ack then the real answer as Stop-fallback was suppressed
2172
+ // and dropped for 5h20m). docs/0.13-consumed-ack-stop-fallback-drop-spec.md
2173
+ const consumedCoversFallback = !usedStopFallback || norm(text) === norm(pending._consumedByText);
2174
+ const alreadyDelivered = pending._consumedAcked === true && consumedCoversFallback;
2175
+ if (usedStopFallback && pending._consumedAcked === true && !consumedCoversFallback) {
2176
+ this.logger.warn?.(`[${this.label}] cli: consumed-ack did NOT cover the Stop-fallback answer — delivering rescued text (len=${text.length})`);
2177
+ this._logEvent('cli-consumed-ack-fallback-rescued', {
2178
+ turn_id: turnId, session_key: this.sessionKey, backend: this.backend,
2179
+ rescued_len: text.length, ack_len: norm(pending._consumedByText).length,
2180
+ });
2181
+ }
2182
+ return { text, alreadyDelivered, branch: 'zero-reply' };
2183
+ }
2184
+ // Interim-only: the turn delivered ONLY status/progress promises ("give me a
2185
+ // couple min") and never a final reply. If claude produced a substantive final
2186
+ // answer as its last assistant message — distinct from the status, and not text a
2187
+ // consuming sibling already delivered — DELIVER it (the status bubbles are already
2188
+ // on screen, so send the FINAL only). Else leave the status; don't re-deliver it.
2189
+ const interimRescue = !!fallbackText
2190
+ && norm(fallbackText) !== norm(interimText)
2191
+ && norm(fallbackText) !== norm(pending._consumedByText);
2192
+ if (interimRescue) {
2193
+ this.logger.warn?.(`[${this.label}] cli: interim-only turn — delivering the produced final answer the status promise didn't (len=${fallbackText.length})`);
2194
+ this._logEvent('cli-interim-only-final-rescued', {
2195
+ turn_id: turnId, session_key: this.sessionKey, backend: this.backend,
2196
+ rescued_len: fallbackText.length, interim_count: pending.replies.length,
2197
+ });
2198
+ return { text: fallbackText, alreadyDelivered: false, branch: 'interim-rescue' };
2199
+ }
2200
+ return { text: interimText, alreadyDelivered: true, branch: 'interim-noop' };
2201
+ }
2202
+
2203
+ _finalizeTurn(turnId) {
2204
+ const pending = this.pendingTurns.get(turnId);
2205
+ if (!pending) return;
2206
+ this.pendingTurns.delete(turnId);
2207
+ // Review P1 #14: pop the matching pendingQueue entry too so downstream
2208
+ // pm callbacks (sdk/callbacks.js context lookup) see a clean queue.
2209
+ const qIdx = this.pendingQueue.findIndex(e => e.turnId === turnId);
2210
+ if (qIdx >= 0) this.pendingQueue.splice(qIdx, 1);
2211
+ if (pending.quietTimer) clearTimeout(pending.quietTimer);
2212
+ if (pending.hardTimer) clearTimeout(pending.hardTimer);
2213
+ if (pending.absoluteTimer) clearTimeout(pending.absoluteTimer);
2214
+ if (pending._stopGraceTimer) clearTimeout(pending._stopGraceTimer);
2215
+ if (pending._activityQuietTimer) clearTimeout(pending._activityQuietTimer); // 0.13 D1
2216
+ if (pending._onStop) { this.off('stop-hook', pending._onStop); pending._onStop = null; }
2217
+ const { text, alreadyDelivered } = this._resolveTurnDelivery(pending, turnId);
2218
+ const duration = Date.now() - pending.startedAt;
2219
+ // Review AC4: cost=null + metrics-tokens=null signal "unmeasured-subscription"
2220
+ // (channels protocol doesn't expose per-turn cost or token breakdowns).
2221
+ // Downstream billing aggregations should SKIP null entries rather than
2222
+ // averaging them as $0. The plain 0 we used before caused channels traffic
2223
+ // to appear free in dashboards.
2224
+ const result = {
2225
+ text,
2226
+ // Review F#2: when claude used reply tool calls, the dispatcher ALREADY
2227
+ // delivered that text to Telegram incrementally — polygram.js must
2228
+ // short-circuit its deliverReplies branch or every turn delivers twice.
2229
+ // BUT a turn finalized via the Stop fallback (no reply tool calls — the
2230
+ // stuck-turn case) has delivered NOTHING; marking it alreadyDelivered
2231
+ // would resolve the turn silently and the user still sees nothing. So
2232
+ // only claim already-delivered when reply tool calls actually fired —
2233
+ // or when claude ACKED consuming this turn in a sibling reply
2234
+ // (consumed_turn_ids; the fold-id-echo case) AND that sibling actually
2235
+ // delivered this text — re-sending the Stop fallback there would
2236
+ // duplicate. A consumed-ack whose ack did NOT carry the Stop-fallback
2237
+ // answer must still deliver (see consumedCoversFallback above).
2238
+ alreadyDelivered,
2239
+ sessionId: this.claudeSessionId,
2240
+ cost: null, // Channels protocol doesn't expose per-turn cost
2241
+ duration,
2242
+ error: null,
2243
+ metrics: {
2244
+ inputTokens: null,
2245
+ outputTokens: null,
2246
+ cacheCreationTokens: null,
2247
+ cacheReadTokens: null,
2248
+ numAssistantMessages: pending.replies.length,
2249
+ numToolUses: null,
2250
+ resultSubtype: 'success',
2251
+ },
2252
+ };
2253
+ this.inFlight = this.pendingTurns.size > 0;
2254
+ // 0.13 D2: the finalized cycle resolves its own ledger entry; any
2255
+ // non-primary entries still 'written' enter the drop-confirm window
2256
+ // (a late next-cycle pickup or ack cancels; otherwise dropped /
2257
+ // fold-suspected / superseded — see _dropConfirmFire).
2258
+ this._ledgerTransition(turnId, 'resolved');
2259
+ this._armDropConfirmSweep();
2260
+ pending.resolve(result);
2261
+ this.emit('result', { subtype: 'success' }, { streamText: text });
2262
+ this.emit('idle');
2263
+ // File-send staging auto-purge (your choice — no "claude must delete").
2264
+ // Once the LAST turn settles, wipe the staging dir's contents so files
2265
+ // claude copied in to send don't accumulate on disk across turns. Only
2266
+ // when fully idle, so a file staged for a still-pending concurrent turn
2267
+ // isn't yanked mid-send.
2268
+ if (this.pendingTurns.size === 0) {
2269
+ this._purgeStagingDir();
2270
+ // B3: fully idle — drop any in-flight sub-agent bookkeeping so a lost
2271
+ // SubagentStop can't leak a stale count (a stuck "working" hold) into the
2272
+ // next turn. Safe only when no turn is pending (it's proc-wide state).
2273
+ this._pendingSubagentStarts = [];
2274
+ }
2275
+ }
2276
+
2277
+ /**
2278
+ * Empty the per-session file-send staging dir (keep the dir itself).
2279
+ * Best-effort; never throws. Called when the session goes idle and on kill.
2280
+ */
2281
+ _purgeStagingDir() {
2282
+ if (!this.attachmentStagingDir) return;
2283
+ let entries;
2284
+ try { entries = fs.readdirSync(this.attachmentStagingDir); }
2285
+ catch { return; }
2286
+ for (const name of entries) {
2287
+ try { fs.rmSync(path.join(this.attachmentStagingDir, name), { recursive: true, force: true }); }
2288
+ catch { /* best-effort */ }
2289
+ }
2290
+ }
2291
+
2292
+ // ─── public Process API ──────────────────────────────────────────
2293
+
2294
+ async send(prompt, opts = {}) {
2295
+ if (this.closed) {
2296
+ // Parity P21: PROCESS_CLOSED code for cross-backend symmetry.
2297
+ const err = new Error('CliProcess: send on closed instance');
2298
+ err.code = 'PROCESS_CLOSED';
2299
+ throw err;
2300
+ }
2301
+ if (!this.bridgeReady) throw new Error('CliProcess: bridge not ready');
2302
+ if (typeof prompt !== 'string') throw new TypeError('CliProcess.send: prompt must be string');
2303
+
2304
+ // Parity P2 + P14: queueCap enforcement. Same cap as SDK/tmux (50).
2305
+ // Drop oldest pending and emit 'queue-drop' so observers see overflow,
2306
+ // mirroring sdk-process.js:594 + tmux-process.js:3176.
2307
+ if (this.pendingTurns.size >= this.queueCap) {
2308
+ const [oldestId, oldest] = this.pendingTurns.entries().next().value;
2309
+ this.pendingTurns.delete(oldestId);
2310
+ const qIdx = this.pendingQueue.findIndex(e => e.turnId === oldestId);
2311
+ if (qIdx >= 0) this.pendingQueue.splice(qIdx, 1);
2312
+ if (oldest.quietTimer) clearTimeout(oldest.quietTimer);
2313
+ if (oldest.hardTimer) clearTimeout(oldest.hardTimer);
2314
+ if (oldest.absoluteTimer) clearTimeout(oldest.absoluteTimer);
2315
+ if (oldest._stopGraceTimer) clearTimeout(oldest._stopGraceTimer);
2316
+ if (oldest._activityQuietTimer) clearTimeout(oldest._activityQuietTimer); // 0.13 D1
2317
+ if (oldest._onStop) this.off('stop-hook', oldest._onStop);
2318
+ const dropErr = new Error('queue overflow — oldest pending evicted');
2319
+ dropErr.code = 'QUEUE_OVERFLOW';
2320
+ try { oldest.reject(dropErr); } catch {}
2321
+ this.emit('queue-drop', { reason: 'overflow', queueCap: this.queueCap, sessionId: this.claudeSessionId, backend: this.backend });
2322
+ this._logEvent('queue-overflow-drop', { queueCap: this.queueCap });
2323
+ }
2324
+
2325
+ const turnId = crypto.randomUUID();
2326
+ this.inFlight = true;
2327
+
2328
+ // Review P1 #14: populate pendingQueue with the per-turn context so
2329
+ // polygram's SDK callback path (lib/sdk/callbacks.js:145+) can find the
2330
+ // streamer/reactor/sourceMsgId via `entry.pendingQueue[0].context`. Without
2331
+ // this, channels chats have no Telegram live-edit, no per-msg reactor
2332
+ // chains, no subagent announce — silent UX regression vs SDK/tmux.
2333
+ //
2334
+ // pendingQueue is the Process base-class array (lib/process/process.js:70).
2335
+ // SdkProcess reads context.{streamer,reactor,sourceMsgId} per-turn from
2336
+ // this array, then shifts it on turn-end. We mirror that lifecycle.
2337
+ const queueEntry = {
2338
+ turnId, // ours — for matching on _resolveTurn
2339
+ context: opts.context || {},
2340
+ // pm-interface PmSpawnContext shape — defensive defaults; the only
2341
+ // consumers (sdk/callbacks.js) read .context.* so the rest is fine.
2342
+ };
2343
+ this.pendingQueue.push(queueEntry);
2344
+
2345
+ this.emit('thinking');
2346
+
2347
+ return new Promise((resolve, reject) => {
2348
+ // Review F#13: hardTimer is now idle-resetting (resets on each reply
2349
+ // in _recordReplyForPendingTurn — was: fixed 10-min wall-clock).
2350
+ // Added absoluteTimer as the true wall-clock ceiling at 30 min so a
2351
+ // legitimate 15-min "replies every 60s" turn isn't killed mid-stream
2352
+ // while still bounding runaways.
2353
+ // 0.16: `reason` ∈ {'idle','absolute','hard-max'}. The absolute checkpoint
2354
+ // (_checkpointAbsolute) passes its already-captured `probeResult` so we
2355
+ // don't double capture-pane on the give-up path. err.code is mapped from
2356
+ // reason: 'hard-max' → TURN_MAX_EXCEEDED (ran long while working), else
2357
+ // → TURN_TIMEOUT (went quiet / idle).
2358
+ const fireTimeout = (reason, probeResult = null) => {
2359
+ if (!this.pendingTurns.has(turnId)) return;
2360
+ const pending = this.pendingTurns.get(turnId);
2361
+ // A question waits for the user: while an `ask` is open the turn must NOT
2362
+ // time out and die mid-question. Defer — re-arm the absolute checkpoint and
2363
+ // keep waiting; the question store's long safety backstop is the only bound
2364
+ // (a truly-abandoned question eventually expires {timedout}). Pre-0.17.4 this
2365
+ // force-answered {timedout} at the ~30-min ceiling and killed the turn.
2366
+ // docs/progress-is-not-turn-end-spec.md
2367
+ if (this._openQuestions.size > 0) {
2368
+ this._logEvent('cli-question-wait-extended', { reason, open_count: this._openQuestions.size });
2369
+ // Reached via the idle hardTimer too — clear any still-armed absoluteTimer
2370
+ // before re-arming so we don't orphan a ref-holding handle teardown can't see.
2371
+ if (pending.absoluteTimer) clearTimeout(pending.absoluteTimer);
2372
+ pending.absoluteTimer = setTimeout(() => this._checkpointAbsolute(turnId), this.turnAbsoluteMs);
2373
+ pending.absoluteTimer.unref?.();
2374
+ return;
2375
+ }
2376
+ this.pendingTurns.delete(turnId);
2377
+ const idx = this.pendingQueue.findIndex(e => e.turnId === turnId);
2378
+ if (idx >= 0) this.pendingQueue.splice(idx, 1);
2379
+ if (pending.quietTimer) clearTimeout(pending.quietTimer);
2380
+ if (pending.hardTimer) clearTimeout(pending.hardTimer);
2381
+ if (pending.absoluteTimer) clearTimeout(pending.absoluteTimer);
2382
+ if (pending._stopGraceTimer) clearTimeout(pending._stopGraceTimer);
2383
+ if (pending._activityQuietTimer) clearTimeout(pending._activityQuietTimer);
2384
+ if (pending._onStop) this.off('stop-hook', pending._onStop);
2385
+ this.inFlight = this.pendingTurns.size > 0;
2386
+ const turnTimeoutMs = reason === 'hard-max'
2387
+ ? (pending._turnHardMaxMs || this.turnHardMaxMs)
2388
+ : reason === 'absolute'
2389
+ ? this.turnAbsoluteMs
2390
+ : (opts.maxTurnMs || this.turnTimeoutMs);
2391
+
2392
+ // 0.13 D1 ceiling-resolve: a ceiling expiring on a turn with delivered
2393
+ // replies RESOLVES it — the user already has their answer; rejecting
2394
+ // would send a scary timeout error AFTER a successful reply (round-2
2395
+ // panel finding: the v2 soak gate contradicted the design's own
2396
+ // ask-timeout-then-ceiling path). TURN_TIMEOUT rejection is reserved
2397
+ // for turns with ZERO delivered replies. Consumed-acked counts as
2398
+ // replied: the answer rode a sibling turn_id (fold-id echo — the UMI
2399
+ // 2026-06-11 19:49 false ⏱; see _ledgerAckConsumed).
2400
+ if ((pending.replies?.length || 0) > 0
2401
+ || (pending.seen === true && pending._consumedAcked === true)) {
2402
+ // Interim-aware: an interim-only turn delivers its PRODUCED final answer
2403
+ // here too (not the status promise) — the same rescue as _finalizeTurn, so
2404
+ // the answer isn't dropped when the turn resolves at a ceiling rather than
2405
+ // via Stop. docs/progress-is-not-turn-end-spec.md
2406
+ const { text, alreadyDelivered } = this._resolveTurnDelivery(pending, turnId);
2407
+ this._logEvent('cli-turn-ceiling-resolved', {
2408
+ reason, turnTimeoutMs, reply_count: pending.replies?.length || 0,
2409
+ consumed_acked: pending._consumedAcked === true,
2410
+ interim_only: !this._turnHasFinalReply(pending),
2411
+ });
2412
+ this.emit('idle');
2413
+ resolve({
2414
+ text,
2415
+ alreadyDelivered,
2416
+ sessionId: this.claudeSessionId,
2417
+ cost: null,
2418
+ duration: Date.now() - pending.startedAt,
2419
+ error: null,
2420
+ metrics: {
2421
+ inputTokens: null, outputTokens: null,
2422
+ cacheCreationTokens: null, cacheReadTokens: null,
2423
+ numAssistantMessages: pending.replies.length,
2424
+ numToolUses: null,
2425
+ resultSubtype: 'success',
2426
+ },
2427
+ });
2428
+ return;
2429
+ }
2430
+
2431
+ this.emit('turn-timeout', {
2432
+ reason,
2433
+ turnTimeoutMs,
2434
+ sessionId: this.claudeSessionId,
2435
+ backend: this.backend,
2436
+ });
2437
+ this._logEvent('turn-timeout', { turnTimeoutMs, reason });
2438
+ // 0.12.3 wedge characterization (docs/0.13-turn-wedge-autorecovery-spec.md):
2439
+ // a zero-reply turn hit the ceiling = claude wedged (no hooks AND no
2440
+ // "esc to interrupt" the whole window). Capture the TUI pane tail + busy
2441
+ // flags to learn WHAT state claude is stuck in. 0.16: reuse the probe the
2442
+ // absolute checkpoint already captured (probeResult) to avoid a second
2443
+ // capture-pane; only probe fresh on the idle-timer path (no prior probe).
2444
+ const logProbe = (probe) => {
2445
+ this._logEvent('turn-timeout-pane', {
2446
+ reason,
2447
+ streaming: probe.streaming,
2448
+ background_shell: probe.backgroundShell,
2449
+ shell_count: probe.shellCount,
2450
+ captured: probe.captured,
2451
+ pane_tail: probe.paneTail,
2452
+ });
2453
+ };
2454
+ if (probeResult) { try { logProbe(probeResult); } catch { /* best-effort */ } }
2455
+ else this.probeBusyState().then(logProbe).catch(() => { /* telemetry best-effort */ });
2456
+ this.emit('idle');
2457
+ const err = new Error(`turn timeout (${turnTimeoutMs}ms, reason=${reason})`);
2458
+ err.code = reason === 'hard-max' ? 'TURN_MAX_EXCEEDED' : 'TURN_TIMEOUT';
2459
+ reject(err);
2460
+ };
2461
+ const pending = {
2462
+ resolve, reject,
2463
+ replies: [],
2464
+ // 0.13 D1: pickup marker — set when a UserPromptSubmit prompt carries
2465
+ // this turn's envelope (the seen-slice). Rung 1's Stop attribution.
2466
+ seen: false,
2467
+ quietTimer: null,
2468
+ _activityQuietTimer: null,
2469
+ // hardTimer = idle ceiling. Resets on any activity (_noteActivity)
2470
+ // so a chatty or tool-heavy turn isn't killed at 10 min wall-clock.
2471
+ hardTimer: setTimeout(() => fireTimeout('idle'), opts.maxTurnMs || this.turnTimeoutMs),
2472
+ // Per-turn idle ceiling — every reset (activity, reply) must re-arm
2473
+ // with THIS, not the instance default, or the chat's maxTurn config
2474
+ // holds only until the first activity.
2475
+ _idleCeilingMs: opts.maxTurnMs || this.turnTimeoutMs,
2476
+ // absoluteTimer = busy-aware checkpoint (0.16). Fires every
2477
+ // turnAbsoluteMs (30min) as a LIVENESS CHECK: if the turn is provably
2478
+ // working (streaming/active shell + progress since last checkpoint) and
2479
+ // under the hard backstop, re-arm; else give up. Replaces the old
2480
+ // one-shot 30-min guillotine that cut actively-working turns.
2481
+ absoluteTimer: setTimeout(() => this._checkpointAbsolute(turnId), this.turnAbsoluteMs),
2482
+ // Review F#13: attach fireTimeout so activity can reset the idle
2483
+ // timer (creates a fresh setTimeout with the same closure).
2484
+ _fireTimeout: fireTimeout,
2485
+ startedAt: Date.now(),
2486
+ // 0.16: hard wall-clock backstop for this turn (per-send override →
2487
+ // instance default). The checkpoint never extends past this.
2488
+ _turnHardMaxMs: opts.maxTurnHardMs || this.turnHardMaxMs,
2489
+ // 0.16: checkpoint progress-tracking (MF-A) — extend only if activity
2490
+ // advanced since the previous checkpoint, not just "a shell exists".
2491
+ _lastCheckpointActivityAt: Date.now(),
2492
+ _lastCheckpointPaneTail: null,
2493
+ _extended: false,
2494
+ };
2495
+ this.pendingTurns.set(turnId, pending);
2496
+
2497
+ // 0.13 D1 (§1.4): the single-active-cycle invariant is enforced by the
2498
+ // daemon's stdinLock (held across the full turn) — CliProcess can't see
2499
+ // the lock, so a second concurrent pending means a caller bypassed the
2500
+ // contract. Loud assertion telemetry; the drop-rather-than-misattribute
2501
+ // defenses (reply routing, Stop attribution) remain the failure mode.
2502
+ if (this.pendingTurns.size > 1) {
2503
+ this.logger.warn?.(
2504
+ `[${this.label}] cli: ${this.pendingTurns.size} concurrent pending turns — stdinLock contract violated upstream`,
2505
+ );
2506
+ this._logEvent('cli-multi-pending-assert', { pending_count: this.pendingTurns.size });
2507
+ }
2508
+
2509
+ // 0.13 D2: ledger the primary + keep the exact envelope for the delivery
2510
+ // watchdog's idempotent re-write (the pending owns it — no text in the
2511
+ // ledger, events stay content-free per L13).
2512
+ this._ledgerAdd(turnId, { source: 'primary', msgId: opts.context?.sourceMsgId });
2513
+
2514
+ // Review F#18: bridge-disconnect TOCTOU. The bridgeReady check at
2515
+ // top of send() can race the bridge socket close. If the bridge
2516
+ // dies between check and write, _writeToBridge silently no-ops (it
2517
+ // returns early on !this.bridgeServer). Without this guard, the
2518
+ // pending entry sits with no live bridge until hardTimer (10 min).
2519
+ // Pass the actual write result back and reject immediately on
2520
+ // failure so the caller sees a fast, code-tagged error.
2521
+ pending._userMsgPayload = {
2522
+ kind: 'user_msg',
2523
+ turn_id: turnId,
2524
+ text: prompt,
2525
+ chat_id: this.chatId,
2526
+ user: opts.context?.user || '',
2527
+ msg_id: opts.context?.sourceMsgId || '',
2528
+ };
2529
+ const wrote = this._writeToBridge(pending._userMsgPayload);
2530
+ if (wrote) this._armDeliveryWatchdog(turnId, pending);
2531
+ if (!wrote) {
2532
+ this.pendingTurns.delete(turnId);
2533
+ const qIdx = this.pendingQueue.findIndex(e => e.turnId === turnId);
2534
+ if (qIdx >= 0) this.pendingQueue.splice(qIdx, 1);
2535
+ if (pending.hardTimer) clearTimeout(pending.hardTimer);
2536
+ if (pending.absoluteTimer) clearTimeout(pending.absoluteTimer);
2537
+ this.inFlight = this.pendingTurns.size > 0;
2538
+ const err = new Error('bridge disconnected during send');
2539
+ err.code = 'BRIDGE_DISCONNECTED';
2540
+ this._logEvent('channels-send-toctou-disconnect', { turnId });
2541
+ reject(err);
2542
+ }
2543
+ });
2544
+ }
2545
+
2546
+ async interrupt() {
2547
+ if (this.closed) return;
2548
+ if (!this.tmuxSession) return;
2549
+ // Cancel-cheap C2 (spec Finding 7): a cancel is already in flight — a
2550
+ // SECOND C-c would land at the now-idle prompt, which is claude's exit
2551
+ // chord ("press ctrl+c again to exit") and would convert the cheap cancel
2552
+ // into an accidental process exit. Also: resetting the grace timer would
2553
+ // DELAY the synthetic resolution for a user double-tapping "stop".
2554
+ // Idempotent no-op instead.
2555
+ if (this._interruptGraceTimer) return;
2556
+ // tmux SIGINT — hard interrupt for the running turn.
2557
+ try {
2558
+ await this.runner.sendControl(this.tmuxSession, 'C-c');
2559
+ } catch (err) {
2560
+ this.logger.warn?.(`[${this.label}] channels: interrupt sendControl failed: ${err.message}`);
2561
+ }
2562
+ this._interruptedAt = Date.now();
2563
+ this.emit('interrupt-applied', { backend: this.backend });
2564
+ this._logEvent('interrupt-applied', {});
2565
+
2566
+ // Cancel-cheap C1 — the spec's O2 BLOCKER: the cancelled work's inputs
2567
+ // must never re-deliver. The grace below synthesizes the resolution
2568
+ // WITHOUT _finalizeTurn, so without this, an autosteer/fold entry stays
2569
+ // 'written' and a LATER cycle-end sweep declares it dropped →
2570
+ // drop-redeliver re-injects the user's CANCELLED message minutes later.
2571
+ // 'cancelled' is terminal: the sweep only targets 'written', and
2572
+ // _ledgerTransition clears the entry's drop/watchdog timers.
2573
+ for (const [id, e] of this.inputLedger) {
2574
+ if (e.state === 'written' || e.state === 'seen') {
2575
+ this._ledgerTransition(id, 'cancelled');
2576
+ this._logEvent('cli-input-cancelled', { turn_id: id, source: e.source });
2577
+ }
2578
+ }
2579
+
2580
+ // Review P3 C8: after Ctrl-C, Claude may or may not call reply with an
2581
+ // "I was interrupted" message. If it doesn't (5s grace), resolve pending
2582
+ // turns with subtype 'interrupted' instead of letting them wait the full
2583
+ // 10-min hardTimer.
2584
+ //
2585
+ // C4 BLOCKER (review 2026-06-12): SNAPSHOT the turns that were in flight at
2586
+ // C-c time and resolve ONLY those. The cancelled turn often finalizes
2587
+ // cleanly DURING the grace (claude acks the C-c) and the user then starts a
2588
+ // NEW turn — the "stop, then redirect" flow cheap-cancel exists for. Without
2589
+ // the snapshot the stale grace iterated pendingTurns LIVE and silently
2590
+ // resolved that fresh turn as 'interrupted' (lost). send() doesn't clear the
2591
+ // grace, so the snapshot is the fix.
2592
+ const interruptedTurnIds = new Set(this.pendingTurns.keys());
2593
+ this._interruptGraceTimer = setTimeout(() => {
2594
+ let resolvedAny = false;
2595
+ for (const [turnId, pending] of this.pendingTurns) {
2596
+ if (!interruptedTurnIds.has(turnId)) continue; // only the turns in flight at C-c
2597
+ // Synthesize an interrupted resolution: empty text, 'interrupted' subtype.
2598
+ // Cancel-cheap C3: clear ALL per-pending machinery (mirrors
2599
+ // _finalizeTurn) — stray timers/listeners on the kept-warm proc are
2600
+ // exactly what the cheap-cancel design must not leak.
2601
+ if (pending.quietTimer) clearTimeout(pending.quietTimer);
2602
+ if (pending.hardTimer) clearTimeout(pending.hardTimer);
2603
+ if (pending.absoluteTimer) clearTimeout(pending.absoluteTimer);
2604
+ if (pending._stopGraceTimer) clearTimeout(pending._stopGraceTimer);
2605
+ if (pending._activityQuietTimer) clearTimeout(pending._activityQuietTimer);
2606
+ if (pending._onStop) { this.off('stop-hook', pending._onStop); pending._onStop = null; }
2607
+ this.pendingTurns.delete(turnId);
2608
+ const qIdx = this.pendingQueue.findIndex(e => e.turnId === turnId);
2609
+ if (qIdx >= 0) this.pendingQueue.splice(qIdx, 1);
2610
+ // Every entry in pending.replies was already delivered to Telegram by
2611
+ // the reply tool during the turn — without alreadyDelivered the daemon's
2612
+ // success path falls through the channels short-circuit and re-delivers
2613
+ // the same text after a /stop (the 0.17.8-characterized double-delivery).
2614
+ const interruptAlreadyDelivered = pending.replies.length > 0;
2615
+ this._logEvent('cli-resolve-delivery', {
2616
+ turn_id: turnId, session_key: this.sessionKey, backend: this.backend,
2617
+ branch: 'interrupt-grace',
2618
+ already_delivered: interruptAlreadyDelivered,
2619
+ reply_count: pending.replies.length,
2620
+ interim_count: pending._interimReplyCount || 0,
2621
+ has_stop_data: !!pending._stopHookData,
2622
+ text_len: pending.replies.join('\n\n').length,
2623
+ });
2624
+ try {
2625
+ pending.resolve({
2626
+ text: pending.replies.join('\n\n'),
2627
+ sessionId: this.claudeSessionId,
2628
+ cost: null,
2629
+ duration: Date.now() - pending.startedAt,
2630
+ error: null,
2631
+ alreadyDelivered: interruptAlreadyDelivered,
2632
+ metrics: {
2633
+ inputTokens: null, outputTokens: null,
2634
+ cacheCreationTokens: null, cacheReadTokens: null,
2635
+ numAssistantMessages: pending.replies.length,
2636
+ numToolUses: null,
2637
+ resultSubtype: 'interrupted',
2638
+ },
2639
+ });
2640
+ resolvedAny = true;
2641
+ } catch {}
2642
+ }
2643
+ this.inFlight = this.pendingTurns.size > 0;
2644
+ this._interruptGraceTimer = null;
2645
+ // Step E: emit 'idle' so reaction-cyclers stop. The synthetic resolve
2646
+ // above doesn't take the _resolveTurn path, so without this emit a
2647
+ // HeartbeatReactor would keep cycling after the interrupt completed.
2648
+ if (resolvedAny) this.emit('idle');
2649
+ }, this.interruptGraceMs);
2650
+ this._interruptGraceTimer.unref?.();
2651
+ }
2652
+
2653
+ /**
2654
+ * 0.16 busy-aware ceiling checkpoint. Armed by the per-turn absoluteTimer
2655
+ * every `turnAbsoluteMs` (30min). Decides whether to EXTEND a still-working
2656
+ * turn or give up:
2657
+ *
2658
+ * - replied turn → resolve gracefully (delegate to fireTimeout, which takes
2659
+ * the line-2118 ceiling-resolve branch).
2660
+ * - probe says working AND progress advanced since last checkpoint AND
2661
+ * elapsed < hard backstop → re-arm (turn stays pending, /stop keeps
2662
+ * working, the live reply lands in the same bubble). Ping once.
2663
+ * - not working / no progress → give up as 'idle' → TURN_TIMEOUT (went quiet).
2664
+ * - elapsed ≥ hard backstop → give up as 'hard-max' → TURN_MAX_EXCEEDED.
2665
+ *
2666
+ * MF-A: "working" requires evidence of PROGRESS (streaming now, or activity /
2667
+ * pane changed since the last checkpoint), not merely a shell's existence — a
2668
+ * hung/zombie background shell would otherwise extend to the hard max.
2669
+ * MF-C: re-check pendingTurns AFTER the async probe (the turn can resolve /
2670
+ * abort / kill during the capture-pane round-trip — TOCTOU), and reassign
2671
+ * pending.absoluteTimer so teardown sites clear the live handle.
2672
+ */
2673
+ async _checkpointAbsolute(turnId) {
2674
+ if (!this.pendingTurns.has(turnId)) return;
2675
+ let pending = this.pendingTurns.get(turnId);
2676
+ // A question is open → the turn is waiting on the USER, not stalled. Don't probe
2677
+ // or time out: re-arm and keep waiting (the question store's long backstop is the
2678
+ // bound). docs/progress-is-not-turn-end-spec.md
2679
+ if (this._openQuestions.size > 0) {
2680
+ this._logEvent('cli-question-wait-extended', { reason: 'absolute-checkpoint', open_count: this._openQuestions.size });
2681
+ pending.absoluteTimer = setTimeout(() => this._checkpointAbsolute(turnId), this.turnAbsoluteMs);
2682
+ pending.absoluteTimer.unref?.();
2683
+ return;
2684
+ }
2685
+ // Turn with a FINAL reply (or consumed-acked): the ceiling RESOLVES it, never
2686
+ // extends. An interim-only turn (status promise, no final reply) is still
2687
+ // working — fall through to the busy-aware probe so it extends, not resolves.
2688
+ // docs/progress-is-not-turn-end-spec.md
2689
+ if (this._turnHasFinalReply(pending)
2690
+ || (pending.seen === true && pending._consumedAcked === true)) {
2691
+ pending._fireTimeout('absolute');
2692
+ return;
2693
+ }
2694
+ let probe = null;
2695
+ try { probe = await this.probeBusyState(); } catch { probe = null; }
2696
+ // MF-C TOCTOU: the turn may have settled during the capture-pane await.
2697
+ if (!this.pendingTurns.has(turnId)) return;
2698
+ pending = this.pendingTurns.get(turnId);
2699
+ // Also bail if the turn entered finalization DURING the probe — a reply
2700
+ // landed, or it's in stop-grace, or it consumed-acked. Re-arming or pinging
2701
+ // now would resurrect a settling turn (spurious "still working" right as the
2702
+ // real answer lands). It will finalize through its own quiet/grace path.
2703
+ if (pending._stopGracePending
2704
+ || this._turnHasFinalReply(pending)
2705
+ || (pending.seen === true && pending._consumedAcked === true)) return;
2706
+ const now = Date.now();
2707
+ const elapsed = now - pending.startedAt;
2708
+ const maxMs = pending._turnHardMaxMs || this.turnHardMaxMs;
2709
+ const streaming = !!(probe && probe.streaming);
2710
+ const hasShell = !!(probe && (probe.backgroundShell || probe.shellCount > 0));
2711
+ const lastAct = Math.max(this._lastActivityAt || 0, this._lastHookEventAt || 0);
2712
+ // MF-A progress delta: streaming NOW is live proof; otherwise require that
2713
+ // activity advanced OR the pane changed since the previous checkpoint.
2714
+ const progressed = streaming
2715
+ || (lastAct > (pending._lastCheckpointActivityAt || pending.startedAt))
2716
+ || (!!probe && probe.paneTail != null && probe.paneTail !== pending._lastCheckpointPaneTail);
2717
+ const working = (streaming || hasShell) && progressed;
2718
+
2719
+ if (working && elapsed < maxMs) {
2720
+ pending._lastCheckpointActivityAt = lastAct || pending._lastCheckpointActivityAt;
2721
+ pending._lastCheckpointPaneTail = (probe && probe.paneTail) || pending._lastCheckpointPaneTail;
2722
+ // MF-C: reassign the live handle so cleanup sites clear THIS timer.
2723
+ pending.absoluteTimer = setTimeout(() => this._checkpointAbsolute(turnId), this.turnAbsoluteMs);
2724
+ this._logEvent('turn-extended', {
2725
+ turn_id: turnId, elapsed_ms: elapsed, streaming, shell_count: probe ? probe.shellCount : 0,
2726
+ });
2727
+ // Progress ping ONCE per turn (first extension) — emits an event polygram
2728
+ // turns into a single "still working" message (honest: probe-confirmed).
2729
+ if (!pending._extended) {
2730
+ pending._extended = true;
2731
+ this.emit('turn-extended', { sessionKey: this.sessionKey, turnId, elapsedMs: elapsed });
2732
+ }
2733
+ return;
2734
+ }
2735
+ // Give up: hard-max (was working but ran too long) vs idle (went quiet).
2736
+ const reason = elapsed >= maxMs ? 'hard-max' : 'idle';
2737
+ pending._fireTimeout(reason, probe);
2738
+ }
2739
+
2740
+ /**
2741
+ * Is claude actually still working, regardless of the resolved-turn flag?
2742
+ *
2743
+ * "Stop" incident (shumorobot Music, 2026-05-31 13:08): the channels
2744
+ * backend resolves a turn on the quiet-window after claude's last reply
2745
+ * tool call (inFlight → false), but claude can keep working afterwards
2746
+ * (a subagent, a long Bash). The abort handler keyed its ack on inFlight
2747
+ * alone, so "Stop" said "Nothing to stop" one second after the bot said
2748
+ * "On it — downloading…" while a subagent churned.
2749
+ *
2750
+ * The TUI prints "esc to interrupt" (STREAMING_HINT_RE) continuously
2751
+ * whenever claude is busy — capture-pane is the truthful signal, the
2752
+ * channels analog of the (deleted) tmux hasBackgroundShell() probe.
2753
+ *
2754
+ * Returns a STRUCTURED probe (not just a boolean) so the abort path can
2755
+ * log the raw signals — pane tail + flags — to the events DB. That lets
2756
+ * us later characterize which states the heuristic gets right/wrong and
2757
+ * refine it (e.g. add signals beyond the esc-hint) without guessing.
2758
+ *
2759
+ * Never throws — a failed capture returns captured:false, busy:false.
2760
+ *
2761
+ * @returns {Promise<{busy:boolean, streaming:boolean, inFlight:boolean,
2762
+ * pendingTurns:number, captured:boolean, paneTail:(string|null)}>}
2763
+ */
2764
+ async probeBusyState() {
2765
+ const base = {
2766
+ busy: false, streaming: false, backgroundShell: false, shellCount: 0,
2767
+ inFlight: this.inFlight, pendingTurns: this.pendingTurns.size,
2768
+ captured: false, paneTail: null,
2769
+ };
2770
+ if (this.closed || !this.tmuxSession || typeof this.runner?.captureWide !== 'function') {
2771
+ return base;
2772
+ }
2773
+ let pane;
2774
+ try {
2775
+ pane = await this.runner.captureWide(this.tmuxSession);
2776
+ } catch (err) {
2777
+ this.logger.warn?.(`[${this.label}] channels: probeBusyState captureWide failed: ${err.message}`);
2778
+ return base;
2779
+ }
2780
+ if (!pane) return base;
2781
+ const streaming = STREAMING_HINT_RE.test(pane);
2782
+ // Background-shell count from the TUI mode line. Match only the captured
2783
+ // TAIL (the mode line lives at the bottom of the viewport) so a `· N shell ·`
2784
+ // string scrolled off into history can't trip a stale false-positive — see
2785
+ // BACKGROUND_SHELL_RE. A detached `run_in_background` Bash that outlived its
2786
+ // turn shows here even while claude is idle and not streaming.
2787
+ const m = pane.slice(-400).match(BACKGROUND_SHELL_RE);
2788
+ const shellCount = m ? parseInt(m[1], 10) : 0;
2789
+ const backgroundShell = shellCount > 0;
2790
+ return {
2791
+ ...base,
2792
+ // `busy` stays streaming-only — it is the abort path's "is claude working a
2793
+ // turn" signal and must not change behaviour. Background-shell liveness is a
2794
+ // separate axis the stall-watchdog reads via `backgroundShell`/`shellCount`.
2795
+ busy: streaming,
2796
+ streaming,
2797
+ backgroundShell,
2798
+ shellCount,
2799
+ captured: true,
2800
+ paneTail: pane.slice(-200),
2801
+ };
2802
+ }
2803
+
2804
+ /** Boolean shorthand for probeBusyState().busy (abort-path convenience). */
2805
+ async isBusy() {
2806
+ const { busy } = await this.probeBusyState();
2807
+ return busy;
2808
+ }
2809
+
2810
+ /**
2811
+ * Does this session have a detached background shell running RIGHT NOW — a
2812
+ * `run_in_background` Bash that may have outlived its turn? Thin probe over
2813
+ * probeBusyState's background-shell signal; the stall-watchdog's input.
2814
+ * @returns {Promise<{live:boolean, count:number}>}
2815
+ */
2816
+ async hasLiveBackgroundWork() {
2817
+ const { backgroundShell, shellCount } = await this.probeBusyState();
2818
+ return { live: backgroundShell, count: shellCount };
2819
+ }
2820
+
2821
+ /**
2822
+ * LRU eviction pin (0.12.0 spec). Cached read of `_bgWorkSince` — the idle bg-work
2823
+ * watchdog state maintained by `_pollBackgroundWork` on the ≤5s pong tick. Non-null ⟺ a
2824
+ * detached background shell has been observed while idle. No time cap: a job that runs for
2825
+ * hours stays pinned (elapsed time can't tell "slow-but-progressing" from "stuck"). Cheap,
2826
+ * sync — safe to call from `_evictLRU`.
2827
+ * @returns {boolean}
2828
+ */
2829
+ hasActiveBackgroundWork() {
2830
+ return this._bgWorkSince !== null;
2831
+ }
2832
+
2833
+ /**
2834
+ * 0.16 (MF-B): does any in-flight turn have a busy-aware ceiling EXTENSION
2835
+ * active? Such a turn can hold its slot up to the hard backstop, so the LRU
2836
+ * treats it as a durable pin (soft-overflow) rather than a transient turn.
2837
+ */
2838
+ hasExtendedTurn() {
2839
+ for (const p of this.pendingTurns.values()) if (p._extended) return true;
2840
+ return false;
2841
+ }
2842
+
2843
+ /**
2844
+ * Resolve the model / effort for a spawn context using the topic→chat→
2845
+ * fallback precedence (mirrors the spawn path). Single source of truth shared
2846
+ * by start() (which records this.model / this.effort) and wouldReloadFor()
2847
+ * (which compares the current config to those spawn-time values).
2848
+ */
2849
+ _resolveModel(opts) {
2850
+ const topicConfig = opts.threadId && opts.chatConfig?.topics?.[opts.threadId];
2851
+ return topicConfig?.model || opts.chatConfig?.model || opts.model;
2852
+ }
2853
+
2854
+ _resolveEffort(opts) {
2855
+ const topicConfig = opts.threadId && opts.chatConfig?.topics?.[opts.threadId];
2856
+ return topicConfig?.effort || opts.chatConfig?.effort || opts.effort;
2857
+ }
2858
+
2859
+ /**
2860
+ * getOrSpawn calls this before reusing a warm proc. cli can't hot-swap model
2861
+ * or effort (spawn-time flags), so when the resolved config has drifted from
2862
+ * what we spawned with AND we are idle, the proc must be killed + cold-
2863
+ * respawned (--resume keeps the conversation; the new --model / --effort takes
2864
+ * effect). In-flight → false: fold the message into the running turn; the
2865
+ * drift reloads on the next idle dispatch. SDK procs apply model live and do
2866
+ * NOT implement this method, so process-manager only reloads when it exists.
2867
+ * @returns {boolean}
2868
+ */
2869
+ wouldReloadFor(spawnContext) {
2870
+ if (this.inFlight || this.closed) return false;
2871
+ return this._resolveModel(spawnContext) !== this.model
2872
+ || this._resolveEffort(spawnContext) !== this.effort;
2873
+ }
2874
+
2875
+ /**
2876
+ * 0.13 D1 (S9): LRU eviction pin — a session blocked on an open `ask` must
2877
+ * not be evicted (the question, and claude's blocked cycle, would die with
2878
+ * it). Belt-and-braces: with D1 the turn stays inFlight through the wait.
2879
+ */
2880
+ hasOpenQuestions() {
2881
+ return this._openQuestions.size > 0;
2882
+ }
2883
+
2884
+ /**
2885
+ * Stall-watchdog for detached background work (0.12.0 background-work
2886
+ * lifecycle, shumorobot Music 7h frozen-Chrome download). Runs on the
2887
+ * pongWatchdog 5s tick but ONLY while the session is IDLE (pendingTurns===0) —
2888
+ * the mirror of _pollMidTurnDialogs, which only runs DURING turns. When a
2889
+ * `run_in_background` Bash outlives its turn and keeps running while claude is
2890
+ * idle for > bgWorkStallMs, nothing tells the agent or user whether it's
2891
+ * progressing or stuck. One read-only self-check re-invokes the agent to
2892
+ * diagnose — via `fireUserMessage`, NOT `injectUserMessage` (which no-ops when
2893
+ * !inFlight, the exact idle state here). Read-only framing matters: the agent
2894
+ * runs bypassPermissions, so an open-ended "fix it" could background another
2895
+ * hung shell unattended.
2896
+ *
2897
+ * Exactly one self-check per continuous background-work window (capped by
2898
+ * `_bgWorkEscalations`); the window resets only when the shell count returns to
2899
+ * 0. Never throws — swallows its own errors so the pong watchdog stays clean.
2900
+ */
2901
+ async _pollBackgroundWork() {
2902
+ if (this.closed || !this.bridgeReady) return;
2903
+ // Only watch while idle. An active turn means the agent is engaged
2904
+ // (_pollMidTurnDialogs owns that path). Crucially we do NOT reset the clock
2905
+ // here — the same shell is still running, so the window persists across a
2906
+ // brief self-check turn rather than restarting and re-pinging every window.
2907
+ if (this.pendingTurns.size > 0) return;
2908
+ let live = false;
2909
+ let count = 0;
2910
+ try {
2911
+ ({ live, count } = await this.hasLiveBackgroundWork());
2912
+ } catch (err) {
2913
+ this.logger.warn?.(`[${this.label}] channels: bg-work probe failed: ${err.message}`);
2914
+ return;
2915
+ }
2916
+ if (!live) {
2917
+ if (this._bgWorkSince !== null) {
2918
+ this._logEvent('cli-bg-work-cleared', { idle_ms: Date.now() - this._bgWorkSince });
2919
+ // Visibility: tear down the status indicator once work clears.
2920
+ if (this._bgWorkStatusShown) {
2921
+ this.emit('bg-work-status', { state: 'cleared' });
2922
+ this._bgWorkStatusShown = false;
2923
+ }
2924
+ }
2925
+ this._bgWorkSince = null;
2926
+ this._bgWorkEscalations = 0;
2927
+ return;
2928
+ }
2929
+ if (this._bgWorkSince === null) {
2930
+ // First idle observation of a live background shell — start the clock AND
2931
+ // raise the visibility indicator so a long job reads as working, not stuck.
2932
+ this._bgWorkSince = Date.now();
2933
+ this._bgWorkEscalations = 0;
2934
+ this._logEvent('cli-bg-work-detected', { shell_count: count });
2935
+ this.emit('bg-work-status', { state: 'running', count });
2936
+ this._bgWorkStatusShown = true;
2937
+ return;
2938
+ }
2939
+ const idleMs = Date.now() - this._bgWorkSince;
2940
+ if (idleMs < this.bgWorkStallMs || this._bgWorkEscalations >= 1) return;
2941
+ const mins = Math.max(1, Math.round(idleMs / 60000));
2942
+ const prompt =
2943
+ `⏳ A background job has been running ~${mins} min with no update. `
2944
+ + `Check its status and report whether it's progressing or stuck. `
2945
+ + `Do NOT start new work, re-run it, or kill anything — report only.`;
2946
+ const fired = this.fireUserMessage(prompt);
2947
+ this._bgWorkEscalations = 1;
2948
+ this._logEvent('cli-bg-work-stall-selfcheck', { idle_ms: idleMs, shell_count: count, fired });
2949
+ }
2950
+
2951
+ async kill(reason = 'kill') {
2952
+ if (this.closed) return;
2953
+ // Parity P19: re-entry guard for concurrent kill() calls. Mirrors
2954
+ // tmux-process.js `_killing` Promise — second caller awaits the first's
2955
+ // teardown instead of early-returning into a half-cleaned state.
2956
+ if (this._killing) return this._killing;
2957
+ this._killing = this._doKill(reason);
2958
+ return this._killing;
2959
+ }
2960
+
2961
+ // ─── Phase 1.3: hook ndjson tail wiring ────────────────────────────
2962
+ //
2963
+ // Hook events fire from claude (per --settings injection from Phase 1.2)
2964
+ // into ~/.polygram/<bot>/hooks/<sessionId>.ndjson via the
2965
+ // hook-append helper. We tail that file with LogTail's 50ms
2966
+ // poll + fs.watch hybrid (see lib/tmux/log-tail.js), parse each line via
2967
+ // hook-event-tail's normalizeHookEvent, and route to _handleHookEvent
2968
+ // which translates hook events into the Process EventEmitter surface
2969
+ // (tool-use / tool-result / subagent-start / subagent-done / result).
2970
+ //
2971
+ // Hook-tail is the canonical source of tool observability in CliProcess.
2972
+ // The bridge's tool-call message path (Phase 1.5) NO LONGER emits
2973
+ // 'tool-use' — hook PreToolUse is the sole source.
2974
+
2975
+ _armHookTail() {
2976
+ if (!this._hookNdjsonPath) {
2977
+ this.logger.warn?.(`[${this.label}] _armHookTail: _hookNdjsonPath unset; hooks disabled. Phase 1.2 may have failed.`);
2978
+ return;
2979
+ }
2980
+ // Finding 0.12-M2: writeHookFiles opens the ndjson in APPEND mode
2981
+ // ('a') and never truncates, so on a --resume respawn the prior
2982
+ // session's hook lines are still on disk under the same path. Replaying
2983
+ // them re-drives the turn state machine from stale Stop/PreToolUse
2984
+ // events (a stale Stop can finalize the fresh turn). So skip existing
2985
+ // content when (and only when) this is a resumed session — the same
2986
+ // discipline the JSONL tail uses on --resume. A fresh spawn's ndjson is
2987
+ // empty, so skipExisting:false is correct there.
2988
+ this._hookTail = createHookTail({
2989
+ path: this._hookNdjsonPath,
2990
+ logger: this.logger,
2991
+ skipExisting: this._resumedSession === true,
2992
+ });
2993
+ this._hookTail.on('event', (ev) => {
2994
+ try {
2995
+ this._handleHookEvent(ev);
2996
+ } catch (err) {
2997
+ // rc.42 #2 mirror: never let a hook-event throw cascade into the
2998
+ // LogTail's line dispatcher and drop the rest of the batch.
2999
+ this.logger.error?.(`[${this.label}] _handleHookEvent threw: ${err.message}`);
3000
+ }
3001
+ });
3002
+ this._hookTail.on('error', (err) => {
3003
+ this.logger.warn?.(`[${this.label}] hook tail error: ${err.message}`);
3004
+ this.emit('hook-tail-error', { err: err.message });
3005
+ });
3006
+ this._hookTail.start();
3007
+ }
3008
+
3009
+ /**
3010
+ * Hook event → Process event translation. See the plan's Reaction model
3011
+ * section for the canonical mapping.
3012
+ *
3013
+ * Subagent identity: PreToolUse with name='Agent' is synthesized into a
3014
+ * 'subagent-start' event carrying agent_type from tool_input. Paired
3015
+ * with SubagentStop → 'subagent-done' for full lifecycle.
3016
+ *
3017
+ * Unknown hook types pass through unchanged — reactor wiring (Phase 2)
3018
+ * decides what to do with them. Anchor 50 finding from 0.12 design
3019
+ * review: never silent-drop on schema drift.
3020
+ */
3021
+ _handleHookEvent(ev) {
3022
+ if (!ev || typeof ev !== 'object') return;
3023
+
3024
+ // rc.16 observability: emit once when the FIRST hook event arrives for
3025
+ // this session, confirming the claude→ndjson→tail pipeline is actually
3026
+ // flowing. The 2026-06-02 stuck turn had a session whose hook ndjson was
3027
+ // 0 bytes — claude emitted no hooks polygram could see, so no Stop ever
3028
+ // arrived to finalize the turn. Without this signal that's invisible: a
3029
+ // turn that hangs with NO `cli-hook-stream-live` for its session means the
3030
+ // hook pipeline is dead for it (distinct from "Stop fired but wasn't
3031
+ // acted on", which `cli-turn-resolved-by-stop` now covers).
3032
+ if (!this._sawHookStream) {
3033
+ this._sawHookStream = true;
3034
+ this._logEvent('cli-hook-stream-live', {
3035
+ session_id: this.claudeSessionId,
3036
+ first_event: ev.type,
3037
+ });
3038
+ }
3039
+
3040
+ // 0.12 Phase 1.8 (Finding 0.4.A): per-event lag measurement.
3041
+ // polygram_received_at_ms is stamped by the helper subprocess at write
3042
+ // time; subtracting from Date.now() gives the helper-write → tail-emit
3043
+ // round-trip we couldn't isolate from a single spike. Soak Phase 5
3044
+ // gates tag-out on median < 2s and p99 < 5s across the events DB.
3045
+ if (Number.isFinite(ev.receivedAtMs)) {
3046
+ const lagMs = Date.now() - ev.receivedAtMs;
3047
+ // L10: emit ONLY — the onHookLagSample callback owns the DB write
3048
+ // (CALLBACK_TO_EVENT → callbacks.js). Previously this ALSO wrote
3049
+ // directly via this.db.logEvent, double-persisting every sample and
3050
+ // inflating the Phase 1.8 soak-gate row count. Consistent with how
3051
+ // tool-result / subagent-start / subagent-done are handled (emit,
3052
+ // don't double-write).
3053
+ this.emit('hook-lag-sample', {
3054
+ hookEventName: ev.type,
3055
+ lagMs,
3056
+ toolName: ev.toolName || null,
3057
+ backend: this.backend,
3058
+ });
3059
+ }
3060
+
3061
+ // 0.13 D1: every hook event is same-session ACTIVITY for the finalizer
3062
+ // ladder (generalizes the 2026-06-08 WA-topic fix, which only extended on
3063
+ // Pre/PostToolUse) — EXCEPT terminal signals, which are not work: noting them
3064
+ // as activity would cancel a live attribution grace. parse-error and unknown
3065
+ // are excluded too (stream noise is not evidence of work).
3066
+ //
3067
+ // Stop is always terminal. SubagentStop is terminal ONLY when it's an ORPHAN —
3068
+ // a late/lagged/foreign teardown hook with no matching in-flight sub-agent start.
3069
+ // Such an orphan trails the main Stop (or arrives from a prior cycle) and must not
3070
+ // bump the work-hook counter (the rung-2 no-reply backstop reads a bump as "claude
3071
+ // resumed", withdrawing the captured Stop's delivery) nor count as activity. A
3072
+ // MATCHED SubagentStop (a sub-agent this cycle actually started is finishing) IS
3073
+ // work-relevant — it keeps withdrawing rung-2 eligibility on a boundary Stop, incl.
3074
+ // a tool-less sub-agent whose only post-boundary signal is its SubagentStop.
3075
+ const orphanSubagentStop = ev.type === 'SubagentStop'
3076
+ && !(this._pendingSubagentStarts && this._pendingSubagentStarts.length);
3077
+ if (ev.type === 'Stop' || orphanSubagentStop) {
3078
+ this._lastHookEventAt = Date.now();
3079
+ } else if (ev.type && ev.type !== 'parse-error' && ev.type !== 'unknown') {
3080
+ this._lastHookEventAt = Date.now();
3081
+ // Monotonic count of WORK hooks (all but terminal Stop / orphan SubagentStop). The
3082
+ // rung-2 no-reply backstop snapshots this at Stop capture; a later increment means
3083
+ // claude resumed work, withdrawing the stale Stop's finalize eligibility.
3084
+ this._workHookSeq = (this._workHookSeq || 0) + 1;
3085
+ this._noteActivity(`hook:${ev.type}`);
3086
+ }
3087
+
3088
+ switch (ev.type) {
3089
+ case 'UserPromptSubmit':
3090
+ // 0.13 D1 seen-slice: the UPS prompt carries the bridge-authored
3091
+ // <channel turn_id="…"> envelope (P0 spike Q1) — parse it (anchored on
3092
+ // the raw tag prefix, see UPS_ENVELOPE_TURN_ID_RE) and mark the
3093
+ // matching pending as picked-up. `seen` is what lets rung 1 tell this
3094
+ // cycle's Stop from a foreign cycle's. Never log prompt content (L13).
3095
+ let anchorMsgId = null;
3096
+ if (typeof ev.prompt === 'string' && ev.prompt) {
3097
+ for (const m of ev.prompt.matchAll(UPS_ENVELOPE_TURN_ID_RE)) {
3098
+ const seenPending = this.pendingTurns.get(m[1]);
3099
+ if (seenPending && seenPending.seen !== true) {
3100
+ seenPending.seen = true;
3101
+ this._logEvent('cli-ups-seen', { turn_id: m[1] });
3102
+ }
3103
+ // 0.13 D2: pickup transitions the ledger entry too — for injected
3104
+ // (no-pending) inputs this is THE fold/next-cycle signal that
3105
+ // cancels drop detection; for primaries it cancels the delivery
3106
+ // watchdog. A late pickup (queued inject becoming the next cycle)
3107
+ // landing inside the drop-confirm window cancels it here.
3108
+ const lEntry = this.inputLedger.get(m[1]);
3109
+ if (lEntry) {
3110
+ if (lEntry.state === 'written' || lEntry.state === 'fold-suspected') {
3111
+ this._ledgerTransition(m[1], 'seen');
3112
+ if (!seenPending) this._logEvent('cli-ups-seen', { turn_id: m[1] });
3113
+ }
3114
+ // 0.13 D3: the picked-up message anchors the cycle's visuals.
3115
+ if (anchorMsgId == null && lEntry.msgId != null) anchorMsgId = lEntry.msgId;
3116
+ }
3117
+ }
3118
+ }
3119
+ this.emit('turn-start', {
3120
+ backend: this.backend,
3121
+ sessionId: this.claudeSessionId,
3122
+ // 0.13 D3: lets the session feedback controller distinguish a
3123
+ // normal turn (has pending — per-turn visuals own it) from an
3124
+ // autonomous/injected cycle (no pending — the controller's job).
3125
+ hasPending: this.pendingTurns.size > 0,
3126
+ anchorMsgId,
3127
+ });
3128
+ return;
3129
+
3130
+ case 'PreToolUse': {
3131
+ // Phase 1.3: synthesize subagent-start from Agent PreToolUse so the
3132
+ // reactor can show a distinct subagent-specific reaction during long
3133
+ // subagent runs. agent_type lives in tool_input.subagent_type per
3134
+ // claude's Task-tool schema.
3135
+ if (ev.toolName === 'Agent') {
3136
+ const subagentType = ev.toolInput?.subagent_type
3137
+ || ev.toolInput?.agent_type
3138
+ || 'general-purpose';
3139
+ // Finding 0.12-M4: SubagentStop carries agent_id/agent_type but
3140
+ // NOT the originating Agent tool_use_id, so without help the
3141
+ // subagent-start/subagent-done rows share no JOIN key (the
3142
+ // documented soak query on $.tool_use_id returns zero rows).
3143
+ // Track the in-flight Agent tool_use_id keyed by subagent type so
3144
+ // the paired SubagentStop below can stamp it onto subagent-done.
3145
+ (this._pendingSubagentStarts ||= []).push({
3146
+ agentType: subagentType,
3147
+ toolUseId: ev.toolUseId,
3148
+ });
3149
+ this.emit('subagent-start', {
3150
+ agentType: subagentType,
3151
+ // PreToolUse for Agent carries no agent_id (set later on
3152
+ // SubagentStop). We still emit the start event so the reactor
3153
+ // can transition into SUBAGENT state immediately.
3154
+ toolUseId: ev.toolUseId,
3155
+ // B3: in-flight sub-agent count so the reactor holds a "working" face
3156
+ // (suppresses the 🥱/😨 decay) until the LAST sub-agent finishes.
3157
+ inFlight: this._pendingSubagentStarts.length,
3158
+ backend: this.backend,
3159
+ });
3160
+ return;
3161
+ }
3162
+ // Process-contract emit shape: (toolName) only — matches TmuxProcess
3163
+ // and the SDK callback signature (sessionKey, toolName, entry). A
3164
+ // separate 'tool-use-detail' event carries the richer payload for
3165
+ // downstream consumers that want input/agentId/etc.
3166
+ this.emit('tool-use', ev.toolName);
3167
+ this.emit('tool-use-detail', {
3168
+ name: ev.toolName,
3169
+ input: ev.toolInput,
3170
+ agentId: ev.agentId,
3171
+ agentType: ev.agentType,
3172
+ toolUseId: ev.toolUseId,
3173
+ backend: this.backend,
3174
+ });
3175
+ return;
3176
+ }
3177
+
3178
+ case 'PostToolUse':
3179
+ this.emit('tool-result', {
3180
+ name: ev.toolName,
3181
+ durationMs: ev.durationMs,
3182
+ agentId: ev.agentId,
3183
+ agentType: ev.agentType,
3184
+ toolUseId: ev.toolUseId,
3185
+ isError: ev.toolResponse?.isError === true,
3186
+ backend: this.backend,
3187
+ });
3188
+ return;
3189
+
3190
+ case 'SubagentStop': {
3191
+ // Finding 0.12-M4: recover the originating Agent tool_use_id so the
3192
+ // subagent-start/subagent-done pair is JOINable. Prefer a match on
3193
+ // agent type (correct for parallel subagents of different types);
3194
+ // fall back to the oldest pending start when types don't line up.
3195
+ let subagentToolUseId = null;
3196
+ const pendingStarts = this._pendingSubagentStarts;
3197
+ if (pendingStarts && pendingStarts.length) {
3198
+ let idx = pendingStarts.findIndex(s => s.agentType === ev.agentType);
3199
+ if (idx < 0) idx = 0;
3200
+ subagentToolUseId = pendingStarts.splice(idx, 1)[0]?.toolUseId ?? null;
3201
+ }
3202
+ this.emit('subagent-done', {
3203
+ agentType: ev.agentType,
3204
+ agentId: ev.agentId,
3205
+ durationMs: ev.durationMs,
3206
+ toolUseId: subagentToolUseId,
3207
+ // B3: remaining in-flight sub-agents (post-decrement). 0 ⇒ the reactor
3208
+ // resumes the normal stall/freeze cascade.
3209
+ inFlight: this._pendingSubagentStarts.length,
3210
+ backend: this.backend,
3211
+ });
3212
+ return;
3213
+ }
3214
+
3215
+ case 'Stop': {
3216
+ // 0.13 D1 rung 1: Stop ends the turn ONLY when the ending cycle is
3217
+ // attributable to it. Stop carries no turn_id, and claude-side cycles
3218
+ // polygram never registered a pending for are routine (/compact +
3219
+ // bg-work self-checks via fireUserMessage, ScheduleWakeup cycles, a
3220
+ // non-folded inject running as its own cycle — the P0 spike confirmed
3221
+ // such cycles DO fire Stop). Pre-D1 the rc.16 branch finalized the
3222
+ // single pending on ANY Stop — a foreign cycle's Stop could close a
3223
+ // queued, never-picked-up user turn and deliver the FOREIGN cycle's
3224
+ // last_assistant_message as its answer (seam S5's Stop-identity gap).
3225
+ const info = {
3226
+ stopHookActive: ev.stopHookActive,
3227
+ lastAssistantMessage: ev.lastAssistantMessage,
3228
+ backend: this.backend,
3229
+ };
3230
+ // Legacy (rung 3) turns already resolving via a reply quiet-window
3231
+ // consume this via their per-turn onStop listener (the text-fallback
3232
+ // rescue inside _resolveTurn). Emit first so that path runs
3233
+ // synchronously before the attribution branch below.
3234
+ this.emit('stop-hook', info);
3235
+
3236
+ // A stop-hook-forced continuation means the cycle is, by definition,
3237
+ // NOT over — never finalize on it. (Unobserved in 30d of prod data;
3238
+ // cheap insurance per the design's round-2 review.)
3239
+ if (ev.stopHookActive === true) {
3240
+ this._logEvent('cli-stop-hook-active-ignored', { pending_count: this.pendingTurns.size });
3241
+ return;
3242
+ }
3243
+
3244
+ if (this.pendingTurns.size === 1) {
3245
+ const [turnId, p] = [...this.pendingTurns.entries()][0];
3246
+ if (!p._stopGracePending) {
3247
+ const attributed = p.seen === true || (p.replies?.length || 0) > 0;
3248
+ if (attributed) {
3249
+ // Finalize through a short grace; any same-session activity
3250
+ // inside it proves this Stop was stale/foreign (lagged ndjson
3251
+ // delivery) and cancels — the turn falls back to rung 2.
3252
+ this._beginAttributedStopGrace(turnId, p, info);
3253
+ } else {
3254
+ // Never picked up (no UPS-seen) and never replied — this Stop
3255
+ // belongs to a foreign cycle. Ignore it loudly; the pending
3256
+ // ends via its own pickup→Stop, rung 2, or the ceilings.
3257
+ this._logEvent('cli-stop-foreign', {
3258
+ turn_id: turnId,
3259
+ session_id: this.claudeSessionId,
3260
+ });
3261
+ }
3262
+ } else if (p._stopGraceDeferred === true) {
3263
+ // A Stop landed while we're deferring finalize for an in-flight
3264
+ // sub-agent: refresh the captured last_assistant_message so the
3265
+ // eventual finalize delivers the LATEST produced answer (claude's real
3266
+ // end-of-work text), not the boundary Stop's stale/partial text.
3267
+ this._captureStopHookData(p, info);
3268
+ }
3269
+ } else if (this.pendingTurns.size > 1) {
3270
+ // Can't attribute Stop to one of several concurrent turns — surface
3271
+ // it so a turn that waited for its grace timer (instead of resolving
3272
+ // on Stop) is explained in the events DB.
3273
+ this._logEvent('cli-stop-unattributed', { pending_count: this.pendingTurns.size });
3274
+ }
3275
+
3276
+ // 0.12.0-rc.13 proactive compaction warning: on turn-end, if enabled
3277
+ // for this chat and not already warned this climb, sample context
3278
+ // occupancy from the transcript and warn (propose /compact) BEFORE
3279
+ // claude auto-compacts mid-turn and detaches the bridge. Fire-and-
3280
+ // forget — transcript IO must never block the stop path.
3281
+ if (this.compactionWarn?.enabled && !this._compactionWarned && ev.transcriptPath) {
3282
+ this._maybeProactiveCompactionWarn(ev.transcriptPath);
3283
+ }
3284
+ return;
3285
+ }
3286
+
3287
+ case 'PreCompact':
3288
+ // 0.12.0-rc.13: auto-compaction is the event that detaches the
3289
+ // channels MCP bridge mid-turn. Record it; and on the dangerous AUTO
3290
+ // case (manual /compact is the user's own deliberate action — never
3291
+ // nag), emit a reactive warning the chat layer posts. The proactive
3292
+ // warning (on Stop) tries to PREVENT this; this is the backstop.
3293
+ this._logEvent('cli-compaction-imminent', { trigger: ev.trigger });
3294
+ if (this.compactionWarn?.enabled && ev.trigger === 'auto') {
3295
+ this.emit('compaction-warn', {
3296
+ kind: 'reactive',
3297
+ trigger: 'auto',
3298
+ sessionId: this.claudeSessionId,
3299
+ backend: this.backend,
3300
+ });
3301
+ }
3302
+ return;
3303
+
3304
+ case 'PostCompact':
3305
+ // Context just dropped — re-arm the proactive warn-once so the next
3306
+ // climb can warn again.
3307
+ this._compactionWarned = false;
3308
+ this._logEvent('cli-compaction-done', { trigger: ev.trigger });
3309
+ return;
3310
+
3311
+ case 'Notification':
3312
+ // 0.12 Phase 4.5: hook Notification → admin approval card on
3313
+ // chats with non-bypass permissionMode. Anthropic documents
3314
+ // Notification as firing for two cases:
3315
+ // (a) a tool needs operator permission, OR
3316
+ // (b) claude has been idle long enough that the user has
3317
+ // stopped paying attention.
3318
+ // We can only respond to (a). The Notification hook payload
3319
+ // carries `tool_name`/`tool_input` when it's a permission
3320
+ // request; both are null for (b). Gate on toolName presence.
3321
+ //
3322
+ // Under bypassPermissions (the default), claude doesn't fire
3323
+ // Notification for tool permissions at all — so this branch
3324
+ // is effectively unreachable for default chats. We add the
3325
+ // permissionMode guard belt-and-braces in case claude ever
3326
+ // changes that behavior. R11 (the send-keys response race
3327
+ // window) is documented in the risk register; soak metric
3328
+ // `permission-response-mismatch` tracks it.
3329
+ if (this.permissionMode === 'bypassPermissions') {
3330
+ this.emit('notification', { raw: ev.raw, backend: this.backend });
3331
+ return;
3332
+ }
3333
+ if (!ev.toolName) {
3334
+ // Idle-attention Notification; nothing for polygram to do.
3335
+ this.emit('notification', { raw: ev.raw, backend: this.backend });
3336
+ return;
3337
+ }
3338
+ // Hook Notification for permission has no native request_id — use
3339
+ // tool_use_id when present, else synthesize. The respond callback
3340
+ // sends "1" then Enter (approve) or "2" then Enter (deny) into
3341
+ // the tmux pane. tmux-runner's input lock guarantees atomicity
3342
+ // per session, but the race between hook fire and operator click
3343
+ // is documented R11 — best-effort.
3344
+ {
3345
+ const requestId = ev.toolUseId || `hook-notification-${Date.now()}`;
3346
+ const toolName = ev.toolName;
3347
+ // Finding #11 fix: pass the STRUCTURED tool_input through. makeCanUseTool
3348
+ // matches gated patterns via matchesAnyPattern, which reads
3349
+ // input.command (Bash) / input.url (WebFetch) — a formatted STRING
3350
+ // makes those undefined so a gated `Bash(rm *)` never matches and the
3351
+ // tool is allowed with NO approval card (silent gating bypass). The
3352
+ // hook Notification payload carries structured tool_input, so forward
3353
+ // it as-is; the approval card (approvalCardText) renders a structured
3354
+ // object fine — same shape the SDK canUseTool path already uses. Fall
3355
+ // back to the formatted-string preview only if claude sent no
3356
+ // structured tool_input (degenerate — tool needs perm but no input).
3357
+ const toolInput = (ev.toolInput && typeof ev.toolInput === 'object')
3358
+ ? ev.toolInput
3359
+ : this._formatToolInputForApproval(
3360
+ ev.prompt || null,
3361
+ typeof ev.toolInput === 'string' ? ev.toolInput : JSON.stringify(ev.toolInput || {}),
3362
+ );
3363
+ this.emit('approval-required', {
3364
+ id: requestId,
3365
+ toolName,
3366
+ toolInput,
3367
+ sessionId: this.claudeSessionId,
3368
+ backend: this.backend,
3369
+ // respond closure pipes the verdict back to claude via
3370
+ // tmux send-keys. claude's TUI permission prompt uses
3371
+ // "1" for accept, "2" for accept-always, "3" for deny.
3372
+ // We map verdicts: 'allow' → "1", 'deny' → "3".
3373
+ // Skipping "2" (always-allow) is deliberate — polygram
3374
+ // never wants per-session always-approve since that would
3375
+ // bypass future approval cards within the same session.
3376
+ respond: async (decision, _message) => {
3377
+ const key = decision === 'allow' ? '1' : '3';
3378
+ if (!this.tmuxSession || !this.runner?.sendControl) {
3379
+ this.logger.warn?.(
3380
+ `[${this.label}] cli: respond cannot send-keys — tmuxSession=${!!this.tmuxSession} sendControl=${!!this.runner?.sendControl}`,
3381
+ );
3382
+ return;
3383
+ }
3384
+ try {
3385
+ await this.runner.sendControl(this.tmuxSession, key);
3386
+ await this.runner.sendControl(this.tmuxSession, 'Enter');
3387
+ } catch (err) {
3388
+ this.logger.warn?.(
3389
+ `[${this.label}] cli: respond send-keys failed (${key}+Enter): ${err.message}`,
3390
+ );
3391
+ this._logEvent('cli-permission-respond-failed', {
3392
+ request_id: requestId,
3393
+ decision,
3394
+ error: err.message,
3395
+ });
3396
+ }
3397
+ },
3398
+ });
3399
+ }
3400
+ return;
3401
+
3402
+ case 'parse-error':
3403
+ // rc.42 #8 mirror: surface persistent hook-stream parse failures
3404
+ // so the soak can count them. Channel-protocol equivalent of the
3405
+ // tmux backend's hook-tail-error.
3406
+ this.emit('hook-parse-error', { error: ev.error, raw: ev.raw });
3407
+ return;
3408
+
3409
+ case 'unknown':
3410
+ // 2.1.143-style schema drift would land here. Log + continue.
3411
+ this.logger.warn?.(`[${this.label}] unknown hook event: ${ev.raw?.hook_event_name}`);
3412
+ return;
3413
+
3414
+ default:
3415
+ // Future event types added to KNOWN_EVENT_NAMES but not yet wired
3416
+ // here. Forward generically so callers can subscribe.
3417
+ this.emit('hook-event', ev);
3418
+ return;
3419
+ }
3420
+ }
3421
+
3422
+ /**
3423
+ * Drain on unexpected bridge socket loss (claude crash, bridge crash,
3424
+ * EOF). Extracted from the inline 'bridge-disconnected' handler so the
3425
+ * teardown is testable and consistent with _doKill.
3426
+ *
3427
+ * Findings 0.12-L5 + L6: in addition to clearing the per-turn timers
3428
+ * and rejecting pendings (the original P1 #5 behavior), this now also
3429
+ * (L5) removes each turn's stop-hook listener — this drain does NOT go
3430
+ * through Process.kill()'s blanket removeAllListeners, so a turn torn
3431
+ * down mid-stop-grace would otherwise leak its onStop closure — and
3432
+ * (L6) clears _interruptGraceTimer, matching _doKill (a /stop verdict
3433
+ * landing just before the disconnect would otherwise leave a stray
3434
+ * timer on the dead instance).
3435
+ */
3436
+ _handleBridgeDisconnected(reason = 'socket-close') {
3437
+ this.bridgeReady = false;
3438
+ this.mcpReady = false;
3439
+ if (this.closed) return;
3440
+ this.logger.warn?.(`[${this.label}] channels: bridge disconnected unexpectedly (${reason})`);
3441
+ // L6: clear the interrupt grace timer alongside the rest of the lifecycle.
3442
+ if (this._interruptGraceTimer) {
3443
+ clearTimeout(this._interruptGraceTimer);
3444
+ this._interruptGraceTimer = null;
3445
+ }
3446
+ // P1 #5: drain pendingTurns immediately so hardTimers don't run 10min.
3447
+ for (const [, pending] of this.pendingTurns) {
3448
+ if (pending.quietTimer) clearTimeout(pending.quietTimer);
3449
+ if (pending.hardTimer) clearTimeout(pending.hardTimer);
3450
+ if (pending.absoluteTimer) clearTimeout(pending.absoluteTimer);
3451
+ if (pending._stopGraceTimer) clearTimeout(pending._stopGraceTimer);
3452
+ if (pending._activityQuietTimer) clearTimeout(pending._activityQuietTimer); // 0.13 D1
3453
+ // L5: remove the per-turn stop-hook listener (this path bypasses
3454
+ // Process.kill()'s removeAllListeners).
3455
+ if (pending._onStop) this.off('stop-hook', pending._onStop);
3456
+ const err = new Error('bridge disconnected');
3457
+ err.code = 'BRIDGE_DISCONNECTED';
3458
+ try { pending.reject(err); } catch {}
3459
+ }
3460
+ this.pendingTurns.clear();
3461
+ this.pendingQueue.length = 0;
3462
+ this.inFlight = false;
3463
+ // 0.12: drop the interactive-question keep-alive here too, for parity with
3464
+ // _doKill — pm reacts to 'bridge-disconnected' by killing us anyway, but don't
3465
+ // depend on that ordering to stop the 60s interval / clear the open set.
3466
+ this._stopQuestionKeepAlive();
3467
+ this._openQuestions.clear();
3468
+ this._clearLedgerTimers(); // 0.13 D2
3469
+ this.emit('bridge-disconnected');
3470
+ this._logEvent('bridge-disconnected', { reason });
3471
+ }
3472
+
3473
+ async _doKill(reason) {
3474
+ this.closed = true;
3475
+ this.inFlight = false;
3476
+
3477
+ this._stopQuestionKeepAlive(); // 0.12: drop the interactive-question keep-alive
3478
+ this._openQuestions.clear();
3479
+ this._clearLedgerTimers(); // 0.13 D2
3480
+
3481
+ if (this.pingTimer) {
3482
+ clearInterval(this.pingTimer);
3483
+ this.pingTimer = null;
3484
+ }
3485
+ if (this.pongWatchdog) {
3486
+ clearInterval(this.pongWatchdog);
3487
+ this.pongWatchdog = null;
3488
+ }
3489
+ if (this._interruptGraceTimer) {
3490
+ clearTimeout(this._interruptGraceTimer);
3491
+ this._interruptGraceTimer = null;
3492
+ }
3493
+
3494
+ // Drain pending turns — error code 'KILLED' matches the SDK/tmux contract
3495
+ for (const [, pending] of this.pendingTurns) {
3496
+ if (pending.quietTimer) clearTimeout(pending.quietTimer);
3497
+ if (pending.hardTimer) clearTimeout(pending.hardTimer);
3498
+ if (pending.absoluteTimer) clearTimeout(pending.absoluteTimer);
3499
+ if (pending._stopGraceTimer) clearTimeout(pending._stopGraceTimer);
3500
+ if (pending._activityQuietTimer) clearTimeout(pending._activityQuietTimer); // 0.13 D1
3501
+ if (pending._onStop) this.off('stop-hook', pending._onStop); // L5
3502
+ const err = new Error(`session killed: ${reason}`);
3503
+ err.code = 'KILLED';
3504
+ pending.reject(err);
3505
+ }
3506
+ this.pendingTurns.clear();
3507
+
3508
+ // Also drain anything sitting in the inherited pendingQueue (Process base class
3509
+ // surface — Process contract C5 requires this even though channels normally
3510
+ // routes through pendingTurns).
3511
+ while (this.pendingQueue.length) {
3512
+ const item = this.pendingQueue.shift();
3513
+ try { item.clearTimers?.(); } catch {}
3514
+ try {
3515
+ const err = new Error(`session killed: ${reason}`);
3516
+ err.code = 'KILLED';
3517
+ item.reject?.(err);
3518
+ } catch {}
3519
+ }
3520
+
3521
+ // Tear down tmux (graceful via runner).
3522
+ if (this.tmuxSession) {
3523
+ try {
3524
+ await this.runner.killSession(this.tmuxSession);
3525
+ } catch (err) {
3526
+ this.logger.warn?.(`[${this.label}] channels: tmux kill failed: ${err.message}`);
3527
+ }
3528
+ }
3529
+
3530
+ // M1: bridge server owns socket + bridge connection teardown
3531
+ if (this.bridgeServer) {
3532
+ try { await this.bridgeServer.close(); } catch {}
3533
+ this.bridgeServer = null;
3534
+ }
3535
+ // P0 #1: unlink the secret-bearing mcp-config file too
3536
+ if (this.mcpConfigPath) {
3537
+ try { fs.unlinkSync(this.mcpConfigPath); } catch {}
3538
+ }
3539
+
3540
+ // Phase 1.3: tear down hook tail + clean per-session hook files.
3541
+ if (this._hookTail) {
3542
+ try { this._hookTail.close(); } catch {}
3543
+ this._hookTail = null;
3544
+ }
3545
+ if (this.botName && this.claudeSessionId) {
3546
+ try { removeHookFiles({ botName: this.botName, sessionId: this.claudeSessionId, hooksDir: this._hooksDir }); } catch {}
3547
+ }
3548
+ // File-send staging: remove the whole per-session dir on kill (purge only
3549
+ // empties it between turns; kill is end-of-life so drop it entirely).
3550
+ if (this.attachmentStagingDir) {
3551
+ try { fs.rmSync(this.attachmentStagingDir, { recursive: true, force: true }); } catch {}
3552
+ this.attachmentStagingDir = null;
3553
+ }
3554
+
3555
+ this.emit('close', 0);
3556
+ }
3557
+
3558
+ /**
3559
+ * F#24: implement injectUserMessage for the autosteer / mid-turn fold UX.
3560
+ * Polygram's autosteer flow (lib/handlers/autosteer.js) calls
3561
+ * pm.injectUserMessage when a follow-up message arrives while a turn is
3562
+ * in flight. Pre-fix the channels backend inherited the base-class
3563
+ * default (returns false) → caller's tryAutosteer reported false → no
3564
+ * AUTOSTEERED (✍) reaction was set → the follow-up msg sat with no
3565
+ * visible reactor state until the stdin-lock released. Now: write the
3566
+ * follow-up as a fresh user_msg to the bridge. claude receives it as
3567
+ * the next channel notification, typically absorbing it into the
3568
+ * current turn's context (OpenClaw-style "merge into active" UX
3569
+ * preserved on channels).
3570
+ *
3571
+ * Note on the channels semantic vs SDK/tmux:
3572
+ * - SDK pushes onto an inputController stream that claude reads
3573
+ * interleaved with model output (true mid-stream merge).
3574
+ * - Tmux pastes into the TUI prompt buffer (TUI fold mechanic).
3575
+ * - Channels: each user_msg notification queues in claude's input
3576
+ * list. With a turn in flight, the second notification queues
3577
+ * behind the active one. claude usually processes it in the same
3578
+ * response cycle (the canonical "fold") but may treat it as a
3579
+ * distinct next turn — the bridge protocol doesn't expose a
3580
+ * true mid-stream merge primitive. Best available with current
3581
+ * Channels protocol; UX-equivalent for most cases.
3582
+ *
3583
+ * Contract (matches SDK/tmux):
3584
+ * - Returns false when: !inFlight, closed, !bridgeReady, empty
3585
+ * content, sanitizes-to-empty.
3586
+ * - Returns true on successful queue.
3587
+ * - Emits 'inject-user-message' on success, 'inject-fail' on
3588
+ * transport failure.
3589
+ * - Sanitizes C0 control chars + DEL (parity with SDK/tmux); emits
3590
+ * 'prompt-sanitized' if any stripped.
3591
+ * - NEVER throws (hot path; matches the cross-backend contract).
3592
+ *
3593
+ * @param {object} opts
3594
+ * @param {string} opts.content — follow-up user text
3595
+ * @param {string} [opts.priority] — 'next' | 'later' (advisory; channels can't enforce per-message scheduling)
3596
+ * @param {boolean} [opts.shouldQuery] — advisory; channels ignores (no inputController)
3597
+ * @param {string|number} [opts.msgId] — inbound Telegram msg_id, passed through to the bridge so claude's next reply can echo it via turn_id
3598
+ * @returns {boolean}
3599
+ */
3600
+ injectUserMessage({ content, priority = 'next', shouldQuery, msgId, source = 'inject' } = {}) {
3601
+ if (this.closed) return false;
3602
+ if (!this.inFlight) return false; // base contract: no live turn → caller falls through
3603
+ // C5 (review 2026-06-12): a cancel is in flight (interrupt grace armed) —
3604
+ // inFlight is still true until the grace fires, but merging a follow-up into
3605
+ // work the user just stopped is wrong AND leaks a fresh 'written' ledger
3606
+ // entry the cancel-loop already passed (later re-delivery). Refuse so the
3607
+ // caller queues it as a fresh primary turn instead.
3608
+ if (this._interruptGraceTimer) return false;
3609
+ if (!this.bridgeReady) return false;
3610
+ if (typeof content !== 'string' || !content) return false;
3611
+
3612
+ const safeContent = sanitizeInjectControlChars(content);
3613
+ if (!safeContent) return false;
3614
+ if (safeContent.length !== content.length) {
3615
+ this.emit('prompt-sanitized', {
3616
+ stripped: content.length - safeContent.length,
3617
+ source: 'inject',
3618
+ });
3619
+ }
3620
+
3621
+ const turnId = crypto.randomUUID();
3622
+ const wrote = this._writeToBridge({
3623
+ kind: 'user_msg',
3624
+ turn_id: turnId,
3625
+ text: safeContent,
3626
+ chat_id: this.chatId,
3627
+ user: '',
3628
+ msg_id: msgId != null ? String(msgId) : '',
3629
+ });
3630
+ if (!wrote) {
3631
+ // Mirrors the tmux event shape (TmuxProcess.emit('inject-fail',
3632
+ // {err, source}) when pasteText rejects). C23 contract test depends
3633
+ // on err being a non-empty string.
3634
+ this.emit('inject-fail', { err: 'bridge write failed', source: 'inject' });
3635
+ return false;
3636
+ }
3637
+ // 0.13 D2: the injected turn_id is LEDGERED — pre-P3 it never escaped this
3638
+ // function, making fold/new-turn/drop indistinguishable (seam S4).
3639
+ this._ledgerAdd(turnId, { source, msgId });
3640
+ this._logEvent('inject-user-message', {
3641
+ session_key: this.sessionKey,
3642
+ chat_id: this.chatId,
3643
+ turn_id: turnId,
3644
+ source,
3645
+ priority: priority ?? null,
3646
+ should_query: shouldQuery ?? null,
3647
+ text_len: safeContent.length,
3648
+ msg_id: msgId != null ? String(msgId) : null,
3649
+ });
3650
+ this.emit('inject-user-message', {
3651
+ text_len: safeContent.length,
3652
+ priority: priority ?? null,
3653
+ shouldQuery: shouldQuery ?? null,
3654
+ msgId: msgId != null ? String(msgId) : null,
3655
+ });
3656
+ return true;
3657
+ }
3658
+
3659
+ /**
3660
+ * Review AC7: fire-and-forget user-message into the bridge. Polygram's
3661
+ * /compact path, the boot-time compact-replay, and the bg-work stall
3662
+ * self-check use this to push a user-shaped
3663
+ * prompt without registering a pending turn. SDK/tmux implement this
3664
+ * differently per backend; channels just writes a user_msg to the bridge
3665
+ * with a fresh turn_id (which has no listener — so any reply Claude sends
3666
+ * falls into the autonomous-assistant-message path via
3667
+ * _recordReplyForPendingTurn's no-pending fallback).
3668
+ *
3669
+ * @param {string} text
3670
+ * @returns {boolean} true if queued, false on invalid input / no bridge
3671
+ */
3672
+ fireUserMessage(text) {
3673
+ if (typeof text !== 'string' || text.length === 0) return false;
3674
+ if (this.closed || !this.bridgeReady) return false;
3675
+ const turnId = crypto.randomUUID();
3676
+ this._ledgerAdd(turnId, { source: 'system' }); // 0.13 D2: visible, never redelivered
3677
+ this._writeToBridge({
3678
+ kind: 'user_msg',
3679
+ turn_id: turnId,
3680
+ text,
3681
+ chat_id: this.chatId,
3682
+ user: '',
3683
+ msg_id: '',
3684
+ });
3685
+ return true;
3686
+ }
3687
+
3688
+ /**
3689
+ * Review AC8: clear session state so the NEXT send() starts fresh. Used
3690
+ * by /new and /reset slash commands. Does NOT kill the underlying claude
3691
+ * (would require a heavier teardown + respawn); only drops pending turns
3692
+ * + clears the claudeSessionId so the next send() starts a new claude
3693
+ * conversation (via the bridge's session_init flow on next user_msg).
3694
+ *
3695
+ * @returns {Promise<{closed: boolean, drainedPendings: number}>}
3696
+ */
3697
+ async resetSession({ reason = 'reset' } = {}) {
3698
+ let drained = 0;
3699
+ // First drain pendingTurns (channels-native bookkeeping). Each entry
3700
+ // ALSO has a matching pendingQueue row pushed at send(); we remove the
3701
+ // matched queue rows here so the queue drain below doesn't double-count.
3702
+ const channelsTurnIds = new Set();
3703
+ for (const [turnId, pending] of this.pendingTurns) {
3704
+ channelsTurnIds.add(turnId);
3705
+ drained++;
3706
+ if (pending.quietTimer) clearTimeout(pending.quietTimer);
3707
+ if (pending.hardTimer) clearTimeout(pending.hardTimer);
3708
+ if (pending.absoluteTimer) clearTimeout(pending.absoluteTimer);
3709
+ if (pending._stopGraceTimer) clearTimeout(pending._stopGraceTimer); // L5
3710
+ if (pending._activityQuietTimer) clearTimeout(pending._activityQuietTimer); // 0.13 D1
3711
+ if (pending._onStop) this.off('stop-hook', pending._onStop); // L5
3712
+ const err = new Error(`session reset: ${reason}`);
3713
+ err.code = 'RESET';
3714
+ try { pending.reject(err); } catch {}
3715
+ }
3716
+ this.pendingTurns.clear();
3717
+ // Drop interactive-question state too (parity with _doKill /
3718
+ // _handleBridgeDisconnected) — else the 60s keep-alive interval leaks and
3719
+ // _openQuestions is left stale on the reset session.
3720
+ this._stopQuestionKeepAlive();
3721
+ this._openQuestions.clear();
3722
+ // Now drain pendingQueue. Skip matching turnIds (already counted), reject
3723
+ // the rest (entries pushed by callers other than this.send — contract
3724
+ // test, tmux/sdk pm callback path).
3725
+ const remaining = [];
3726
+ for (const item of this.pendingQueue) {
3727
+ if (item.turnId && channelsTurnIds.has(item.turnId)) continue;
3728
+ remaining.push(item);
3729
+ }
3730
+ this.pendingQueue.length = 0;
3731
+ for (const item of remaining) {
3732
+ drained++;
3733
+ try { item.clearTimers?.(); } catch {}
3734
+ try {
3735
+ const err = new Error(`session reset: ${reason}`);
3736
+ err.code = 'RESET';
3737
+ item.reject?.(err);
3738
+ } catch {}
3739
+ }
3740
+ this.inFlight = false;
3741
+ // Clear claudeSessionId so getClaudeSessionId() in polygram doesn't
3742
+ // resume the same conversation on next send. The bridge will surface a
3743
+ // fresh id via session_init when claude re-initializes.
3744
+ this.claudeSessionId = null;
3745
+ // Step E: emit 'idle' BEFORE 'session-reset' so reaction-cyclers stop.
3746
+ // resetSession rejects pending turns with code=RESET without taking the
3747
+ // _resolveTurn path; without this emit, a HeartbeatReactor wired to this
3748
+ // CliProcess would keep cycling until the next 'thinking' (next send)
3749
+ // overwrote the state. Subscribers that care about the distinction can
3750
+ // still listen to 'session-reset' for the reason payload.
3751
+ if (drained > 0) this.emit('idle');
3752
+ this.emit('session-reset', { reason });
3753
+ this._logEvent('session-reset', { reason, drainedPendings: drained });
3754
+
3755
+ // Review F#9: pm.resetSession removes this proc from procs map regardless
3756
+ // of the returned `closed` field, so leaving the underlying tmux session,
3757
+ // bridge socket server, and secret-bearing mcp-config tmp file alive is a
3758
+ // straight resource leak that compounds over /new /reset usage. Tear
3759
+ // those down here and return closed:true so the contract is honest.
3760
+ // (SDK/tmux backends can keep the underlying process alive across
3761
+ // resetSession; channels cannot because there's no in-place re-init
3762
+ // path — claude needs a fresh spawn to reset its conversation state.)
3763
+ this.closed = true;
3764
+ if (this.pingTimer) {
3765
+ clearInterval(this.pingTimer);
3766
+ this.pingTimer = null;
3767
+ }
3768
+ if (this.pongWatchdog) {
3769
+ clearInterval(this.pongWatchdog);
3770
+ this.pongWatchdog = null;
3771
+ }
3772
+ if (this._interruptGraceTimer) {
3773
+ clearTimeout(this._interruptGraceTimer);
3774
+ this._interruptGraceTimer = null;
3775
+ }
3776
+ if (this.tmuxSession) {
3777
+ try { await this.runner.killSession(this.tmuxSession); }
3778
+ catch (err) { this.logger.warn?.(`[${this.label}] channels: tmux kill on reset failed: ${err.message}`); }
3779
+ this.tmuxSession = null;
3780
+ }
3781
+ if (this.bridgeServer) {
3782
+ try { await this.bridgeServer.close(); } catch {}
3783
+ this.bridgeServer = null;
3784
+ }
3785
+ if (this.mcpConfigPath) {
3786
+ try { fs.unlinkSync(this.mcpConfigPath); } catch {}
3787
+ this.mcpConfigPath = null;
3788
+ }
3789
+ this.emit('close', 0);
3790
+ return { closed: true, drainedPendings: drained };
3791
+ }
3792
+
3793
+ /**
3794
+ * Drain pendingQueue (Process base class surface — C6 contract).
3795
+ * Channels normally routes through pendingTurns; pendingQueue exists
3796
+ * for cross-backend symmetry on /stop, daemon shutdown, /new.
3797
+ */
3798
+ drainQueue(_code = 'INTERRUPTED') {
3799
+ let n = 0;
3800
+ while (this.pendingQueue.length) {
3801
+ const item = this.pendingQueue.shift();
3802
+ n++;
3803
+ try { item.clearTimers?.(); } catch {}
3804
+ try {
3805
+ // Review C6: error must carry the supplied code for parity with kill()'s
3806
+ // err.code='KILLED' (see kill() above). Callers branch on err.code.
3807
+ const err = new Error('drained');
3808
+ err.code = _code;
3809
+ item.reject?.(err);
3810
+ } catch {}
3811
+ }
3812
+ return n;
3813
+ }
3814
+
3815
+ // ─── permission relay ─────────────────────────────────────────────
3816
+
3817
+ /**
3818
+ * Called by polygram after the user taps an approve/deny button.
3819
+ * Sender allowlist + per-session binding MUST be enforced UPSTREAM
3820
+ * (in the daemon's TG button handler) — CliProcess assumes
3821
+ * any verdict reaching here is already authorized.
3822
+ */
3823
+ async respondToPermission(requestId, behavior) {
3824
+ if (behavior !== 'allow' && behavior !== 'deny') {
3825
+ throw new TypeError(`respondToPermission: behavior must be 'allow' or 'deny' (got ${behavior})`);
3826
+ }
3827
+ // Review F#8: stale-instance guard. The respond() closure captured at
3828
+ // 'approval-required' emit time is bound to THIS CliProcess. If the
3829
+ // user taps Approve/Deny 11+ min later, the original turn may have timed
3830
+ // out, the proc killed, and a new CliProcess spawned for the same
3831
+ // sessionKey. The closure still points to the dead instance; _writeToBridge
3832
+ // would silently no-op because bridgeServer is null post-kill. Without
3833
+ // this guard, the user sees nothing. Now we log + emit forensics + return
3834
+ // false so the caller (polygram's onApprovalRequired) can surface "your
3835
+ // approval came too late" to the operator if desired.
3836
+ if (this.closed) {
3837
+ this.logger.warn?.(
3838
+ `[${this.label}] channels: late perm verdict for request_id=${requestId} ` +
3839
+ `behavior=${behavior} — instance closed, dropping (was: silent no-op)`,
3840
+ );
3841
+ this._logEvent('channels-late-perm-verdict-dropped', {
3842
+ request_id: requestId,
3843
+ behavior,
3844
+ });
3845
+ return false;
3846
+ }
3847
+ // Review #8 (P1 security): idempotency. Double-fire writes two perm_verdict
3848
+ // messages for the same request_id, undefined Claude behavior. Tracking
3849
+ // resolved ids in a Set prevents the second write. Mirrors TmuxProcess's
3850
+ // _pendingApprovalId single-shot gate.
3851
+ if (this.respondedPermissions.has(requestId)) {
3852
+ this.logger.warn?.(
3853
+ `[${this.label}] channels: respondToPermission duplicate for request_id=${requestId} — dropped`,
3854
+ );
3855
+ return;
3856
+ }
3857
+ this.respondedPermissions.add(requestId);
3858
+ this._writeToBridge({ kind: 'perm_verdict', request_id: requestId, behavior });
3859
+ }
3860
+
3861
+ // ─── interactive questions (0.12 ask) ─────────────────────────────
3862
+
3863
+ /**
3864
+ * Hand a question's answer back to the blocking `ask` tool call. `result` is
3865
+ * {answers:[...]} | {cancelled:true} | {timedout:true}. Stops the keep-alive
3866
+ * once no questions remain open. Called by pm.answerQuestion (from the handler).
3867
+ */
3868
+ writeQuestionAnswer(toolCallId, result) {
3869
+ this._openQuestions.delete(toolCallId);
3870
+ const noneLeft = this._openQuestions.size === 0;
3871
+ if (noneLeft) this._stopQuestionKeepAlive();
3872
+ const wrote = this._writeToBridge({ kind: 'question_answer', tool_call_id: toolCallId, result: result ?? {} });
3873
+ // Re-light progress: claude is about to resume working on the answer. The per-turn reactor
3874
+ // cleared when claude posted its reply + asked, and no tool hooks fired during the wait, so
3875
+ // it stayed cleared — the post-answer work was invisible ("why don't I see it working after
3876
+ // submit?", hire topic 2026-06-09). On a REAL answer (cancelled/timeout END the turn → let
3877
+ // the normal teardown clear), signal polygram to re-arm the turn's working reaction.
3878
+ if (noneLeft && result && !result.cancelled && !result.timedout) {
3879
+ this.emit('question-resumed');
3880
+ }
3881
+ // 0.13 D1: the wait is over either way — restart the activity clock so a
3882
+ // replied turn's rung-2 finalize resumes (real answer: claude works on;
3883
+ // cancelled/timedout: claude wraps up — rung 2 then ends the tail cleanly).
3884
+ if (noneLeft) this._noteActivity('question-answered');
3885
+ return wrote;
3886
+ }
3887
+
3888
+ _startQuestionKeepAlive() {
3889
+ if (this._questionKeepAliveTimer) return;
3890
+ this._questionKeepAliveTimer = setInterval(() => {
3891
+ if (this._openQuestions.size === 0) { this._stopQuestionKeepAlive(); return; }
3892
+ // claude is idle waiting on the answer → no tool hooks → reset the idle
3893
+ // ceiling so the turn isn't killed mid-question. (Rung 2 is suspended
3894
+ // while a question is open, so this only feeds the hardTimer.)
3895
+ this._noteActivity('question-keepalive');
3896
+ }, 60_000);
3897
+ this._questionKeepAliveTimer.unref?.();
3898
+ }
3899
+
3900
+ _stopQuestionKeepAlive() {
3901
+ if (this._questionKeepAliveTimer) { clearInterval(this._questionKeepAliveTimer); this._questionKeepAliveTimer = null; }
3902
+ }
3903
+
3904
+ // ─── socket plumbing ──────────────────────────────────────────────
3905
+
3906
+ _writeToBridge(obj) {
3907
+ // M1: delegate to ChannelsBridgeServer.writeMessage which handles
3908
+ // "no live connection" warn + write try/catch uniformly.
3909
+ // Review F#18: return boolean so callers (notably send()) can detect
3910
+ // a no-bridge condition and reject the pending immediately instead
3911
+ // of waiting for hardTimer.
3912
+ if (!this.bridgeServer) return false;
3913
+ try {
3914
+ this.bridgeServer.writeMessage(obj);
3915
+ return true;
3916
+ } catch (err) {
3917
+ this.logger.warn?.(`[${this.label}] channels: _writeToBridge failed: ${err.message}`);
3918
+ return false;
3919
+ }
3920
+ }
3921
+
3922
+ _startPingLoop() {
3923
+ // P1 #6: seed lastPongAt so the watchdog has a fresh baseline.
3924
+ this.lastPongAt = Date.now();
3925
+ this.pingTimer = setInterval(() => {
3926
+ this._writeToBridge({ kind: 'ping' });
3927
+ }, PING_INTERVAL_MS);
3928
+ this.pingTimer.unref?.();
3929
+ this.pongWatchdog = setInterval(() => {
3930
+ if (this.closed || !this.bridgeReady) return;
3931
+ const elapsed = Date.now() - this.lastPongAt;
3932
+ if (elapsed > PONG_TIMEOUT_MS) {
3933
+ this.logger.warn?.(
3934
+ `[${this.label}] channels: pong watchdog tripped after ${elapsed}ms — declaring bridge dead`,
3935
+ );
3936
+ // Trigger the same recovery path as a socket-close: forcibly destroy
3937
+ // the bridge connection so 'bridge-disconnected' fires (drains
3938
+ // pendingTurns, ProcessManager kills dead instance for lazy respawn).
3939
+ if (this.bridgeServer) this.bridgeServer.destroyConnection();
3940
+ }
3941
+ // Review F#17: piggyback mid-turn dialog detection on the same 5s tick.
3942
+ // Fire-and-forget — _pollMidTurnDialogs swallows its own errors so the
3943
+ // pong watchdog stays clean.
3944
+ this._pollMidTurnDialogs().catch((err) => {
3945
+ this.logger.warn?.(`[${this.label}] channels: mid-turn poll failed: ${err.message}`);
3946
+ });
3947
+ // 0.12.0 background-work lifecycle: idle-side stall-watchdog, the mirror of
3948
+ // _pollMidTurnDialogs (which only runs during turns). Fire-and-forget.
3949
+ this._pollBackgroundWork().catch((err) => {
3950
+ this.logger.warn?.(`[${this.label}] channels: bg-work poll failed: ${err.message}`);
3951
+ });
3952
+ }, PONG_CHECK_INTERVAL_MS);
3953
+ this.pongWatchdog.unref?.();
3954
+ }
3955
+
3956
+ /**
3957
+ * Review F#17: capture-pane scan for known interactive prompts that can
3958
+ * fire mid-turn without surfacing as MCP notifications. Examples:
3959
+ * - Session-age "Resume from summary?" menu (if claude renders it
3960
+ * after the turn started, post-startup-gate)
3961
+ * - Future usage-limit / context-overflow menus that Anthropic might
3962
+ * add to the TUI
3963
+ *
3964
+ * Action per pattern is declared in MID_TURN_PROMPTS. Defaults to 'enter'
3965
+ * (dismiss with sendControl(Enter)); 'emit-only' surfaces telemetry
3966
+ * without auto-action — use when the right response depends on operator
3967
+ * judgment.
3968
+ *
3969
+ * Gated on `pendingTurns.size > 0` so we don't poll during idle —
3970
+ * matches the rationale "only check during turns, not all the time."
3971
+ * Rate-limited per-pattern so a dialog lingering across polls doesn't
3972
+ * spam telemetry / Enter keystrokes.
3973
+ *
3974
+ * Extracted as a separate async method so unit tests can drive it
3975
+ * directly without waiting for the setInterval tick.
3976
+ */
3977
+ /**
3978
+ * 0.12.0-rc.13: proactive compaction warning. Read the transcript's current
3979
+ * context occupancy and, if past the per-chat threshold, emit a
3980
+ * 'compaction-warn' the chat layer turns into "you're ~N% full, run
3981
+ * /compact" — giving the user a window to compact on their terms BEFORE
3982
+ * claude auto-compacts mid-turn (which detaches the channels bridge). Warns
3983
+ * once per climb (this._compactionWarned), re-armed on PostCompact.
3984
+ * Fire-and-forget: swallows its own errors so transcript IO never breaks
3985
+ * the turn-end path.
3986
+ */
3987
+ async _maybeProactiveCompactionWarn(transcriptPath) {
3988
+ try {
3989
+ if (!this.compactionWarn?.enabled || this._compactionWarned) return;
3990
+ const usage = await readContextTokens(transcriptPath);
3991
+ if (!usage) return;
3992
+ const pct = contextPct(usage.total) * 100;
3993
+ if (pct < this.compactionWarn.thresholdPct) return;
3994
+ if (this._compactionWarned) return; // re-check after the async gap
3995
+ this._compactionWarned = true;
3996
+ this.emit('compaction-warn', {
3997
+ kind: 'proactive',
3998
+ pct: Math.round(pct),
3999
+ totalTokens: usage.total,
4000
+ sessionId: this.claudeSessionId,
4001
+ backend: this.backend,
4002
+ });
4003
+ } catch (err) {
4004
+ this.logger.warn?.(`[${this.label}] compaction-warn sample failed: ${err.message}`);
4005
+ }
4006
+ }
4007
+
4008
+ async _pollMidTurnDialogs() {
4009
+ if (this.closed) return;
4010
+ if (this.pendingTurns.size === 0) return; // no work to do when idle
4011
+ // 0.12 interactive questions: while an `ask` is open claude sits idle at the
4012
+ // prompt waiting on the tool result — so the pane shows no "esc to interrupt"
4013
+ // and the question's own echoed text (a "?"/numbered list/"Yes/No") would
4014
+ // false-trip the unknown-prompt heuristic + starve the STALL heartbeat. The
4015
+ // keyboard lives on Telegram; suppress the pane watchdog while a question is open.
4016
+ if (this._openQuestions.size > 0) return;
4017
+ if (!this.tmuxSession) return; // pre-spawn / post-kill
4018
+ if (typeof this.runner?.captureWide !== 'function') return;
4019
+
4020
+ let pane;
4021
+ try {
4022
+ pane = await this.runner.captureWide(this.tmuxSession);
4023
+ } catch (err) {
4024
+ // captureWide can fail if tmux died, session got renamed, etc. Log
4025
+ // once at warn (rate-limited by the outer pong loop's cadence) and
4026
+ // return — pong watchdog will eventually trip on the real symptom.
4027
+ this.logger.warn?.(`[${this.label}] channels: mid-turn captureWide failed: ${err.message}`);
4028
+ return;
4029
+ }
4030
+ if (!pane) return;
4031
+
4032
+ // rc.14: removed the rc.11 pane-based "dead bridge" detection here. It
4033
+ // matched the BENIGN banner "server:water-bridge no MCP server
4034
+ // configured with that name" — a cosmetic line that
4035
+ // `--dangerously-load-development-channels` + `--strict-mcp-config` prints
4036
+ // on EVERY healthy session (channel still delivers; reply tool still
4037
+ // works). The matcher false-fired ~5s into every channels turn and killed
4038
+ // healthy sessions. Real bridge loss is the socket-close path
4039
+ // (_handleBridgeDisconnected), not anything observable in the pane.
4040
+
4041
+ const now = Date.now();
4042
+
4043
+ // 0.12 Phase 3.2: liveness heartbeat. The TUI prints "esc to interrupt"
4044
+ // throughout any busy phase, including pure-thinking turns where no
4045
+ // hook events fire. Emit 'thinking' so sdkCallbacks.onThinking calls
4046
+ // reactor.heartbeat() — keeps the cascade at THINKING_DEEPEST (🤓)
4047
+ // instead of escalating to STALL (🥱). Idempotent — heartbeat just
4048
+ // resets a timer; safe to fire on every poll while claude is busy.
4049
+ if (STREAMING_HINT_RE.test(pane)) {
4050
+ this.emit('thinking');
4051
+ // 0.13 D1: the pane heartbeat is ACTIVITY for the finalizer ladder —
4052
+ // pure-thinking stretches fire ZERO hooks for 45s+ (that is this
4053
+ // heartbeat's whole reason to exist), so a hook-only quiet clock would
4054
+ // finalize a replied turn mid-thought (round-2 panel finding).
4055
+ this._noteActivity('pane-thinking');
4056
+ }
4057
+
4058
+ let matchedKnownPrompt = false;
4059
+ for (const prompt of MID_TURN_PROMPTS) {
4060
+ if (!prompt.regex.test(pane)) continue;
4061
+ matchedKnownPrompt = true;
4062
+ const lastFiredAt = this.midTurnDialogLastFiredAt.get(prompt.name) || 0;
4063
+ if ((now - lastFiredAt) < MID_TURN_DEDUP_WINDOW_MS) continue; // dedup
4064
+ this.midTurnDialogLastFiredAt.set(prompt.name, now);
4065
+
4066
+ this.logger.warn?.(
4067
+ `[${this.label}] cli: mid-turn dialog detected name=${prompt.name} ` +
4068
+ `action=${prompt.action} pendingTurns=${this.pendingTurns.size}`,
4069
+ );
4070
+ this.emit('mid-turn-dialog-detected', {
4071
+ name: prompt.name,
4072
+ action: prompt.action,
4073
+ sessionId: this.claudeSessionId,
4074
+ backend: this.backend,
4075
+ });
4076
+ this._logEvent('cli-mid-turn-dialog-detected', {
4077
+ name: prompt.name,
4078
+ action: prompt.action,
4079
+ pending_count: this.pendingTurns.size,
4080
+ });
4081
+
4082
+ if (prompt.action === 'enter' || prompt.action === 'keys') {
4083
+ // 'keys' sends a navigation sequence (e.g. Down,Enter to pick a
4084
+ // non-default dialog option); 'enter' stays the single-key dismissal.
4085
+ const keySeq = prompt.action === 'keys' ? prompt.keys : ['Enter'];
4086
+ for (let ki = 0; ki < keySeq.length; ki++) {
4087
+ if (ki > 0) await new Promise(r => setTimeout(r, 120)); // Ink can swallow same-batch keys
4088
+ try {
4089
+ await this.runner.sendControl(this.tmuxSession, keySeq[ki]);
4090
+ } catch (err) {
4091
+ this.logger.warn?.(
4092
+ `[${this.label}] cli: mid-turn ${keySeq[ki]} failed for ${prompt.name}: ${err.message}`,
4093
+ );
4094
+ }
4095
+ }
4096
+ }
4097
+ // 'emit-only': telemetry-only; operator decides next step.
4098
+ // Resume-dialog fix: the session-age dialog escaping to MID-TURN means
4099
+ // env suppression failed AND the startup gate didn't see it — same
4100
+ // soak-queryable event kind as the startup-gate fallback.
4101
+ if (prompt.name === 'session-age') {
4102
+ this._logEvent('session-age-dialog-fallback', { tmux_name: this.tmuxSession, phase: 'mid-turn' });
4103
+ }
4104
+ }
4105
+
4106
+ // 0.12 Phase 3.3 (Q1 resolution): unknown-prompt heuristic. If the pane
4107
+ // doesn't match any known catalog entry but DOES look like a prompt
4108
+ // ("?" trailing, "(y/N)", "Yes/No", numeric option markers, "❯" cursor),
4109
+ // emit a telemetry event with the pane excerpt so polygram.js can surface
4110
+ // an admin card. We don't auto-dismiss — operator decides. Dedup window
4111
+ // applies the same as for known prompts to avoid spamming during a
4112
+ // lingering unknown dialog.
4113
+ if (!matchedKnownPrompt
4114
+ && !STREAMING_HINT_RE.test(pane)
4115
+ && UNKNOWN_PROMPT_HEURISTIC_RE.test(pane)) {
4116
+ const lastFiredAt = this.midTurnDialogLastFiredAt.get('__unknown__') || 0;
4117
+ if ((now - lastFiredAt) >= MID_TURN_DEDUP_WINDOW_MS) {
4118
+ this.midTurnDialogLastFiredAt.set('__unknown__', now);
4119
+ // Last ~10 lines as the excerpt — enough context for the operator.
4120
+ const excerpt = pane.split('\n').slice(-12).join('\n');
4121
+ this.emit('mid-turn-unknown-prompt', {
4122
+ excerpt,
4123
+ sessionId: this.claudeSessionId,
4124
+ backend: this.backend,
4125
+ });
4126
+ this._logEvent('cli-mid-turn-unknown-prompt', {
4127
+ pending_count: this.pendingTurns.size,
4128
+ excerpt_head: excerpt.slice(0, 200),
4129
+ });
4130
+ }
4131
+ }
4132
+ }
4133
+ }
4134
+
4135
+ module.exports = { CliProcess };