polygram 0.17.10 → 0.17.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/config.js +21 -0
- package/lib/error/classify.js +14 -0
- package/lib/handlers/dispatcher.js +54 -1
- package/lib/handlers/drop-redeliver.js +19 -0
- package/lib/handlers/should-handle.js +52 -0
- package/lib/media-group-buffer.js +29 -0
- package/lib/ops/auth-disabled-gate.js +43 -0
- package/lib/ops/heartbeat.js +56 -0
- package/lib/process/channels-tool-dispatcher.js +12 -1
- package/lib/prompt.js +22 -7
- package/lib/sdk/callbacks.js +2 -1
- package/lib/secret-detect.js +13 -1
- package/lib/telegram/chunk.js +20 -6
- package/lib/telegram/process-agent-reply.js +5 -3
- package/lib/telegram/streamer.js +6 -1
- package/package.json +2 -1
- package/polygram.js +202 -45
- package/lib/async-lock.js +0 -49
- package/lib/claude-bin.js +0 -246
- package/lib/compaction-warn.js +0 -59
- package/lib/context-usage.js +0 -93
- package/lib/process/channels-bridge-protocol.js +0 -199
- package/lib/process/channels-bridge-server.js +0 -274
- package/lib/process/channels-bridge.mjs +0 -477
- package/lib/process/cli-process.js +0 -4029
- package/lib/process/factory.js +0 -215
- package/lib/process/hook-event-tail.js +0 -162
- package/lib/process/hook-settings.js +0 -181
- package/lib/process/polygram-hook-append.js +0 -71
- package/lib/process/process.js +0 -215
- package/lib/process/sdk-process.js +0 -880
- package/lib/process-guard.js +0 -296
- package/lib/process-manager.js +0 -628
- package/lib/tmux/log-tail.js +0 -334
- package/lib/tmux/orphan-sweep.js +0 -79
- package/lib/tmux/poll-scheduler.js +0 -110
- package/lib/tmux/startup-gate.js +0 -250
- package/lib/tmux/tmux-runner.js +0 -412
|
@@ -1,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
|
-
// `<`, 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 };
|