polygram 0.17.10 → 0.17.12

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