polygram 0.10.0-rc.2 → 0.10.0-rc.21

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.
@@ -40,6 +40,29 @@ const { LogTail } = require('../tmux/log-tail');
40
40
  const { sessionLogPath, pipeToParser } = require('../tmux/session-log-parser');
41
41
  const { computeCostUsd } = require('../model-costs');
42
42
  const { getTopicConfig } = require('../session-key');
43
+ const { POLYGRAM_DISPLAY_HINT } = require('../telegram/display-hint');
44
+ const { verifyPinnedClaudeBin } = require('../claude-bin');
45
+ const { createAsyncLock } = require('../async-lock');
46
+
47
+ // ─── Pinned claude CLI version ───────────────────────────────────────
48
+ //
49
+ // The tmux backend reads claude CLI INTERNAL artefacts (JSONL events,
50
+ // queue-operation semantics, TUI banner ASCII art, READY hint
51
+ // strings, MULTILINE_SEPARATOR convention, stop_reason values). None
52
+ // of these are a stable public contract — they can change in any
53
+ // new CLI version. The rc.7→rc.15 saga (May 2026) is direct
54
+ // evidence: every rc shipped fixes for behaviour that drifted in the
55
+ // CLI without polygram logic changing.
56
+ //
57
+ // So polygram pins ONE specific CLI version. Production daemon must
58
+ // run that exact version. Upgrading the pin is a SEPARATE, deliberate
59
+ // process — see AGENTS.md "Pinned claude CLI version" for the full
60
+ // procedure (read release notes → bump → run all spikes → diff
61
+ // JSONL → 24h staging on shumorobot → roll to umi-assistant).
62
+ //
63
+ // Bumping this constant WITHOUT following that procedure is how the
64
+ // rc.7→rc.15 saga got triggered. Don't.
65
+ const CLAUDE_CLI_PINNED_VERSION = '2.1.142';
43
66
 
44
67
  // Context window per model. All Claude 4.x models are 200k. If
45
68
  // Anthropic ships a model with a different window, promote this to
@@ -53,36 +76,91 @@ const DEFAULT_CONTEXT_WINDOW = 200_000;
53
76
  // for the next prompt. Under `--permission-mode acceptEdits` (our
54
77
  // default), the bottom-of-pane indicator can also read "accept edits
55
78
  // on" instead; treat either as ready.
56
- const READY_HINTS_RE = /\?\s+for shortcuts|accept edits on/;
79
+ // claude TUI shows a different ready hint depending on permission
80
+ // mode:
81
+ // - default: "? for shortcuts"
82
+ // - acceptEdits: "accept edits on"
83
+ // - bypassPermissions: "bypass permissions on (shift+tab to cycle)"
84
+ // All three are valid ready states. Polygram production uses
85
+ // 'default', but the spike harness + tests exercising
86
+ // bypassPermissions need the third matcher (caught by the rc.14
87
+ // autosteer-tui-real.mjs spike).
88
+ const READY_HINTS_RE = /\?\s+for shortcuts|accept edits on|bypass permissions on/;
57
89
  const STREAMING_HINT_RE = /esc to interrupt/;
58
90
 
91
+ // L1 fix (spike leftover): the claude TUI shows its welcome banner
92
+ // WITH a ready hint at the bottom during startup — before the user's
93
+ // prompt has been processed:
94
+ //
95
+ // ▐▛███▜▌ Claude Code v2.1.142
96
+ // ▝▜█████▛▘ Sonnet 4.6 with low effort · Claude Max
97
+ // ▘▘ ▝▝ ~/Projects/shumkov/polygram
98
+ // ...
99
+ // ? for shortcuts ← ready hint already present
100
+ //
101
+ // _awaitTurnComplete's poll sees the ready hint and resolves
102
+ // captureCompleteP. If the agent hasn't emitted any text yet,
103
+ // _extractTurnReply returns the banner text → pm.send returns the
104
+ // banner as result.text → polygram delivers the banner as a Telegram
105
+ // reply. Caught in the 50-scenario spike's baseline-tool-call.
106
+ //
107
+ // Distinctive banner marker — the box-drawing characters in the
108
+ // claude logo. Polygram's prompts / agents never legitimately
109
+ // contain these. While these characters appear in the pane,
110
+ // treat the TUI as NOT YET ready regardless of READY_HINTS_RE.
111
+ const TUI_BANNER_RE = /▐▛███▜▌|▝▜█████▛▘/;
112
+
59
113
  // TUI approval-prompt indicators. When a chat is spawned WITHOUT
60
114
  // --permission-mode acceptEdits, claude pauses on risky tools and
61
115
  // draws a prompt like:
62
116
  //
63
117
  // ⏺ Bash(rm foo.txt)
64
118
  // ⎿ Do you want to do this?
65
- // 1. Yes
119
+ //1. Yes
66
120
  // 2. Yes, allow always for similar commands
67
121
  // 3. No, and tell Claude what to do differently
68
122
  //
123
+ // The TUI renders a `❯` selection cursor inline before the
124
+ // highlighted option (always option 1 at first paint). Earlier
125
+ // rc.1-rc.4 regex assumed no inline cursor and silently failed to
126
+ // match every approval-gated tool call in production, hanging the
127
+ // session in the TUI until orphan-sweep killed it (see
128
+ // tests/tmux-process-approval.test.js inline-cursor regression).
129
+ //
69
130
  // SECURITY (audit H1 fix): require BOTH the question text AND a
70
131
  // following numbered menu line ("1. ...") so a malicious assistant
71
132
  // message text like "Do you want to proceed?" can't trigger a fake
72
133
  // approval card by itself. The menu is part of the TUI's pause
73
134
  // state; the assistant can't render it without actually being paused.
74
- const APPROVAL_PROMPT_RE = /Do you want to (?:proceed|do this|continue)\??[\s\S]{0,400}?(?:^|\n)\s*1\.\s+/im;
135
+ // The optional `❯` cursor in [^\S\n]*(?:❯[^\S\n]+)?1\. is still
136
+ // bounded to the line containing `1.`, so the security property
137
+ // holds — only a real menu line satisfies it.
138
+ const APPROVAL_PROMPT_RE = /Do you want to (?:proceed|do this|continue)\??[\s\S]{0,400}?(?:^|\n)[^\S\n]*(?:❯[^\S\n]+)?1\.\s+/im;
75
139
  // Pull the tool name + raw arg snippet from the line preceding the
76
140
  // approval prompt. Capture-pane preserves the ⏺ marker.
77
141
  const TOOL_INVOCATION_RE = /⏺\s+([A-Za-z_]\w*)\s*\((.*?)\)\s*$/m;
78
142
 
79
143
  // ─── Defaults — overridable per construction for tests ───────────────
80
144
 
81
- const DEFAULT_READY_TIMEOUT_MS = 30_000;
145
+ // Cold-spawn budget for claude TUI to reach "? for shortcuts" / "accept
146
+ // edits on" state. 30s was enough in dev (interactive shell, warm
147
+ // keychain) but consistently timed out under launchd in production:
148
+ // MCP server starts each have a 30s connection timeout, and the
149
+ // keychain/Aqua context warm-up is slower outside an attached terminal.
150
+ // 120s is generous; the only cost is waiting longer when something is
151
+ // genuinely stuck (we kill + retry in that case anyway).
152
+ const DEFAULT_READY_TIMEOUT_MS = 120_000;
82
153
  const DEFAULT_TURN_TIMEOUT_MS = 5 * 60_000;
83
154
  const DEFAULT_POLL_MS = 250;
84
155
  const DEFAULT_QUIESCE_MS = 500; // require READY for this long before declaring done
85
156
 
157
+ // R7: sentinel returned by _awaitTurnComplete when its poll loop is
158
+ // stopped by the caller's absolute-deadline abort (rather than by a
159
+ // real READY quiescence or its own internal timeout). _runTurn maps
160
+ // this to "the capture race did not win" — the absolute turnDeadlineP
161
+ // reject is what fails the turn.
162
+ const ABORT_SENTINEL = Symbol('tmux-await-turn-aborted');
163
+
86
164
  class TmuxProcess extends Process {
87
165
  /**
88
166
  * @param {object} opts
@@ -111,6 +189,7 @@ class TmuxProcess extends Process {
111
189
  lateGraceMs = 1500,
112
190
  queueCap = 50, // P0.1 parity: SDK enforces queueCap=50 too
113
191
  pollScheduler = null, // O1 optimization: shared cross-process tick
192
+ pasteConfirmMs = 2500, // Phase 3 §5: paste-gating JSONL-confirm timeout
114
193
  } = {}) {
115
194
  super({ sessionKey, chatId, threadId, label });
116
195
  if (!runner) throw new TypeError('TmuxProcess: runner required');
@@ -149,6 +228,67 @@ class TmuxProcess extends Process {
149
228
  // getContextUsage() so polygram's post-turn auto-hint works on
150
229
  // the tmux backend just like SDK.
151
230
  this._lastUsage = null;
231
+
232
+ // ─── 0.10.0 Phase 2: correlation token + turn ledger ──────────
233
+ //
234
+ // The four legacy accumulators (_turnState / pendingQueue /
235
+ // _pendingAutosteers / _extraTurnState) were four partial
236
+ // projections of ONE missing object: an ordered ledger of turns,
237
+ // each with a stable id, an owning msgId set, and a state.
238
+ // Attribution used to be RECONSTRUCTED by content-matching JSONL
239
+ // — lossy under paste concatenation, substring collisions, and
240
+ // event reorder. It is now RECORDED: every paste embeds a unique
241
+ // correlation token in its <polygram-info> block; the JSONL
242
+ // user-message reproduces the token verbatim; routing is an exact
243
+ // token→Turn lookup.
244
+ //
245
+ // `_ledger` — every Turn, append-ordered. A Turn:
246
+ // { turnId, token, msgIds:[], kind:'primary'|'autosteer',
247
+ // state:'queued'|'pasted'|'streaming'|'done'|'failed',
248
+ // text, toolUses, toolUsedThisTurn, stopReason, resultEvent,
249
+ // context, opts, prompt, startedAt, via,
250
+ // resolve/reject (primary send() promise),
251
+ // settleResult/resultPromise (internal turn-done signal) }
252
+ //
253
+ // `pendingQueue` (from the base Process) is kept as the external
254
+ // contract surface — lib/sdk/callbacks.js reads
255
+ // pendingQueue[0].context.{streamer,reactor}. It holds the SAME
256
+ // primary Turn objects as `_ledger`, head-first; `_ledger` is the
257
+ // authority, `pendingQueue` its primary-only projection.
258
+ this._ledger = [];
259
+ this._turnSeq = 0;
260
+ // The turns currently receiving assistant events. Driven purely
261
+ // by `user-message` token matches — never by "which accumulator
262
+ // is non-null". A group with >1 turn is an explicit fold.
263
+ // `primaryTurnId` records the turnId of the primary turn that owns
264
+ // this group (null for a NEW-TURN autosteer group, which has no
265
+ // primary). R11: `_finishTurn` uses it to retire ONLY the group
266
+ // its finishing primary owns — never a fresh NEW-TURN autosteer
267
+ // group that started after that primary already flushed.
268
+ this._activeGroup = {
269
+ turns: [], text: '', pendingSteerCausesNewBubble: false,
270
+ primaryTurnId: null,
271
+ };
272
+ // Turns whose paste the TUI has parked in its input queue
273
+ // (`queue-operation enqueue`), oldest first — a FIFO mirror of the
274
+ // TUI's own queue. `remove`/`dequeue` are bare (no token), so they
275
+ // are resolved positionally; the list must therefore mirror EVERY
276
+ // queued paste, primary OR autosteer (R2 — a primary paste that
277
+ // gets queued must be tracked too, or the FIFO desyncs). A `remove`
278
+ // folds the head autosteer into the running turn; a `dequeue`
279
+ // releases the head to run as a fresh turn (its user-message
280
+ // follows and _routeUserMessage handles it).
281
+ this._enqueuedTurns = [];
282
+
283
+ // ─── 0.10.0 Phase 3 §5: paste gating ──────────────────────────
284
+ // A paste does not release the next paste until the JSONL tail
285
+ // confirms it landed (its token surfaced in a user-message /
286
+ // queue-operation), bounded by `pasteConfirmMs`. Converts the
287
+ // 50ms post-Enter drain guess into a real barrier — two pastes
288
+ // can no longer concatenate into one TUI input.
289
+ this._pasteLock = createAsyncLock();
290
+ this._pasteConfirms = new Map(); // token → resolve fn
291
+ this.pasteConfirmMs = pasteConfirmMs;
152
292
  }
153
293
 
154
294
  get cost() { return 3; }
@@ -216,31 +356,93 @@ class TmuxProcess extends Process {
216
356
  }
217
357
  args.push('--debug-file', this.debugLogPath);
218
358
  if (agent) args.push('--agent', agent);
359
+ // Cross-backend parity: SDK appends polygram's Telegram display
360
+ // hint to every agent's systemPrompt (lib/sdk/build-options.js).
361
+ // Without this, the spawned claude session has no idea it's
362
+ // replying through a Telegram bot — shumorobot 2026-05-15 caught
363
+ // the model emitting shell-style canned strings ("No response
364
+ // requested.") as actual Telegram replies for that reason.
365
+ // `--append-system-prompt` is preserved by claude CLI in
366
+ // addition to (not in place of) the agent's own prompt.
367
+ args.push('--append-system-prompt', POLYGRAM_DISPLAY_HINT);
368
+
369
+ // Pin: spawn the ABSOLUTE pinned-version binary, never the
370
+ // bare `claude` on $PATH. The claude CLI auto-updater
371
+ // re-points the ~/.local/bin/claude symlink whenever a new
372
+ // version lands, so a $PATH spawn silently drifts off the
373
+ // pinned version the tmux backend was validated against
374
+ // (shumorobot 2026-05-16: CLI drifted 2.1.142 → 2.1.143
375
+ // between deploys). The versioned binary at
376
+ // ~/.local/share/claude/versions/<v> is immutable — the
377
+ // updater only adds new files, never overwrites.
378
+ const binCheck = verifyPinnedClaudeBin(CLAUDE_CLI_PINNED_VERSION);
379
+ if (!binCheck.ok) {
380
+ throw Object.assign(new Error(binCheck.reason), { code: 'CLAUDE_BIN_MISSING' });
381
+ }
219
382
 
220
383
  // R2-F8: spawn errors must fail loud, not silent-catch.
221
384
  await this.runner.spawn({
222
385
  name: this.tmuxName,
223
386
  cwd,
224
- command: 'claude',
387
+ command: binCheck.path,
225
388
  args,
226
389
  envExtras: ctx.envExtras || {},
227
390
  });
228
391
 
229
- // v9: tail the per-session JSONL file (the REAL structured-event
230
- // channel v9 probe showed --debug-file emits only infra noise).
231
- // Path is deterministic once we have cwd + sessionId. The file
232
- // may not exist for ~100ms after spawn; LogTail tolerates ENOENT.
233
- this._cwd = cwd;
234
- this._armSessionLogTail({ resuming: Boolean(ctx.existingSessionId) });
235
-
236
- // G6 block until TUI is responsive.
237
- await this._waitForReady();
238
- this.emit('init', {
239
- session_id: this.claudeSessionId,
240
- label: this.label,
241
- backend: 'tmux',
242
- tmux_name: this.tmuxName,
243
- });
392
+ // SPAWN-LIFECYCLE FIX (shumorobot 2026-05-17 22:03, topic :3):
393
+ // `spawn()` resolving means the tmux session NAME now exists on
394
+ // the host. From here on, ANY failure readiness timeout, a
395
+ // wedged capture-pane, an `init` listener throwing must tear
396
+ // that session down before propagating, or the orphan lingers
397
+ // and every retry's `tmux new-session -s <same-name>` fails
398
+ // "duplicate session". A transient first-spawn failure would
399
+ // otherwise become a PERMANENT wedge for the chat/topic until a
400
+ // human kills the orphan. `_sessionCreated` is the seam that
401
+ // distinguishes "spawn() itself failed (no session — nothing to
402
+ // kill)" from "session created, a later step failed (must
403
+ // kill)". This is a spawn-lifecycle bug, independent of the
404
+ // turn-ledger concurrency rewrite.
405
+ const sessionCreated = true;
406
+
407
+ try {
408
+ // v9: tail the per-session JSONL file (the REAL structured-
409
+ // event channel — v9 probe showed --debug-file emits only
410
+ // infra noise). Path is deterministic once we have cwd +
411
+ // sessionId. The file may not exist for ~100ms after spawn;
412
+ // LogTail tolerates ENOENT.
413
+ this._cwd = cwd;
414
+ this._armSessionLogTail({ resuming: Boolean(ctx.existingSessionId) });
415
+
416
+ // G6 — block until TUI is responsive.
417
+ await this._waitForReady();
418
+ this.emit('init', {
419
+ session_id: this.claudeSessionId,
420
+ label: this.label,
421
+ backend: 'tmux',
422
+ tmux_name: this.tmuxName,
423
+ });
424
+ } catch (err) {
425
+ // Post-spawn failure — the session exists but is unusable.
426
+ // Kill it so a retry gets a clean name. Best-effort: the
427
+ // runner's killSession already swallows its own errors, but
428
+ // guard anyway so a kill failure can never mask the real
429
+ // spawn error. Also tear down the just-armed JSONL tail so it
430
+ // doesn't leak a watcher against a dead session.
431
+ if (sessionCreated) {
432
+ if (this._sessionLogTail) {
433
+ try { this._sessionLogTail.close(); } catch { /* swallow */ }
434
+ this._sessionLogTail = null;
435
+ }
436
+ try {
437
+ await this.runner.killSession(this.tmuxName);
438
+ } catch (killErr) {
439
+ this.logger.warn?.(
440
+ `[${this.label}] start() cleanup killSession failed: ${killErr.message}`,
441
+ );
442
+ }
443
+ }
444
+ throw err;
445
+ }
244
446
  })();
245
447
 
246
448
  try {
@@ -277,143 +479,216 @@ class TmuxProcess extends Process {
277
479
  // Match SdkProcess contract: send() on closed Process REJECTS
278
480
  // rather than returning an error result. Callers (polygram
279
481
  // dispatch) already wrap pm.send in try/catch for this case.
280
- // Runtime turn errors (paste fail, timeout) still surface as
281
- // an error-shaped PmSendResult — that's the other path below.
282
482
  throw Object.assign(new Error('No process for session'), { code: 'PROCESS_CLOSED' });
283
483
  }
284
- // P0.1 fix: enforce queueCap (parity with SDK). Without this a
285
- // misbehaving caller could grow pendingQueue unbounded.
484
+ // queueCap parity with SDK bound the ledger's queued primaries.
286
485
  if (this.inFlight && this.pendingQueue.length >= this.queueCap) {
287
486
  throw Object.assign(
288
487
  new Error(`queue overflow: queueCap ${this.queueCap}`),
289
488
  { code: 'QUEUE_OVERFLOW' },
290
489
  );
291
490
  }
292
- if (this.inFlight) {
293
- // For Phase 2 MVP we serialize: queue the prompt locally and
294
- // await the in-flight turn. Include `context` so polygram's
295
- // streamer/reactor lookups via pendingQueue[N].context work
296
- // when this pending becomes the head.
297
- return new Promise((resolve, reject) => {
298
- this.pendingQueue.push({
299
- prompt, opts,
300
- context: opts.context || {},
301
- resolve, reject,
302
- });
303
- });
304
- }
305
491
 
306
- this.inFlight = true;
307
- const turnTimeoutMs = opts.timeoutMs || this.turnTimeoutMs;
308
- const startedAt = this._now();
309
-
310
- // P0.1 fix: push a HEAD pending with the caller's `context` so
311
- // polygram's onStreamChunk / onToolUse / onAssistantMessageStart
312
- // callbacks (which read entry.pendingQueue[0].context.streamer
313
- // and entry.pendingQueue[0].context.reactor) work for tmux too.
314
- // Without this, live bubble updates and reactor heartbeats
315
- // silently no-op on tmux. Shape mirrors SdkProcess pendings.
316
- const headPending = {
317
- prompt, opts,
492
+ // Register a primary Turn. Its correlation token is embedded into
493
+ // the paste; the JSONL `user-message` reproduces it verbatim; the
494
+ // event router then attributes by exact token lookup.
495
+ const turn = this._makeTurn({
496
+ kind: 'primary',
497
+ prompt: String(prompt ?? ''),
498
+ opts,
318
499
  context: opts.context || {},
319
- streamText: '',
320
- };
321
- this.pendingQueue.unshift(headPending);
322
-
323
- // v9: prime turn-scoped event collection. Assistant chunks and
324
- // tool-uses arriving via the JSONL tail accumulate into _turnState;
325
- // the 'result' event resolves the turn.
326
- this._turnState = {
327
- text: '',
328
- toolUses: 0,
329
- resolveResult: null,
330
- resultEvent: null,
331
- pendingSteerCausesNewBubble: false,
332
- };
333
- const turnResultP = new Promise((resolve) => {
334
- this._turnState.resolveResult = resolve;
500
+ msgIds: opts.context && opts.context.sourceMsgId != null
501
+ ? [opts.context.sourceMsgId] : [],
502
+ });
503
+ turn.callerPromise = new Promise((resolve, reject) => {
504
+ turn.resolve = resolve;
505
+ turn.reject = reject;
335
506
  });
507
+ this._ledger.push(turn);
508
+ // pendingQueue holds primary Turn objects head-first — it is the
509
+ // external contract surface (lib/sdk/callbacks.js reads
510
+ // pendingQueue[0].context.{streamer,reactor}). The running turn
511
+ // is always pendingQueue[0].
512
+ this.pendingQueue.push(turn);
513
+ if (this.pendingQueue[0] === turn) {
514
+ // Nothing ahead — run immediately. (Not awaited; _runTurn
515
+ // settles turn.callerPromise and drains the next queued turn.)
516
+ this._runTurn(turn);
517
+ }
518
+ // else: queued. _finishTurn() runs it when the head completes.
519
+ return turn.callerPromise;
520
+ }
521
+
522
+ /**
523
+ * Run one primary Turn end-to-end: embed its token, paste, race the
524
+ * JSONL terminal `result` against capture-pane quiescence, build the
525
+ * PmSendResult, settle the caller, and drain the next queued turn.
526
+ */
527
+ async _runTurn(turn) {
528
+ this.inFlight = true;
529
+ turn.state = 'pasted';
530
+ turn.startedAt = this._now();
531
+ const turnTimeoutMs = turn.opts.timeoutMs || this.turnTimeoutMs;
532
+ // Internal turn-done signal — settled by _flushActiveGroup when
533
+ // this turn's group is flushed on a terminal `result`.
534
+ turn.resultPromise = new Promise((resolve) => { turn.settleResult = resolve; });
336
535
 
337
536
  try {
338
- // R2-F1: sanitization happens inside runner.pasteText; we also
339
- // log when chars get stripped.
340
- const result = await this.runner.pasteText(this.tmuxName, prompt);
537
+ // rc.13.1: pasteAndEnter holds a per-session async lock around
538
+ // paste + Enter so a concurrent injectUserMessage paste cannot
539
+ // interleave keystrokes with this primary prompt.
540
+ const result = await this._pasteAndEnter(this._embedToken(turn.prompt, turn.token));
341
541
  if (result.stripped > 0) {
342
542
  this.logger.warn?.(
343
543
  `[${this.label}] stripped ${result.stripped} control chars from prompt`,
344
544
  );
345
545
  this.emit('prompt-sanitized', { stripped: result.stripped, source: 'send' });
346
546
  }
347
- await this.runner.sendControl(this.tmuxName, 'Enter');
348
547
 
349
- // Race: JSONL result event vs capture-pane quiescence fallback
350
- // vs hard timeout. JSONL is the primary signal (carries structured
351
- // text); capture-pane wins for old claude versions or if JSONL
352
- // file write lags behind UI quiescence.
353
- const captureAtStart = await this.runner.captureWide(this.tmuxName);
548
+ // R7: an ABSOLUTE timeout wrapping the whole race. The
549
+ // capture-pane completion detector re-checks its deadline only
550
+ // BETWEEN `captureWide` subprocess calls if a single `tmux
551
+ // capture-pane` wedges (its promise never resolves), the poll
552
+ // loop is parked on the await and neither the capture-complete
553
+ // promise nor the JSONL `resultPromise` ever settle, so `send()`
554
+ // hangs forever and starves the turn queue.
555
+ //
556
+ // One setTimeout drives two things: an `abortP` (resolves — fed
557
+ // to _awaitTurnComplete so its poll loop can break out of a
558
+ // wedged capture and release the scheduler), and a
559
+ // `turnDeadlineP` (rejects — the third racer below, guaranteeing
560
+ // _runTurn ALWAYS settles within turnTimeoutMs). `unref` so the
561
+ // timer never keeps the process alive on its own; cleared in the
562
+ // `finally` so a turn that ends early leaves no dangling timer.
563
+ let turnDeadlineTimer = null;
564
+ let signalAbort = null;
565
+ const abortP = new Promise((resolve) => { signalAbort = resolve; });
566
+ const turnDeadlineP = new Promise((_resolve, reject) => {
567
+ turnDeadlineTimer = setTimeout(() => {
568
+ signalAbort();
569
+ reject(Object.assign(
570
+ new Error('TmuxProcess: turn did not complete in time'),
571
+ { code: 'TMUX_TURN_TIMEOUT', tmuxName: this.tmuxName },
572
+ ));
573
+ }, turnTimeoutMs);
574
+ turnDeadlineTimer.unref?.();
575
+ });
576
+
577
+ // Race: JSONL terminal result vs capture-pane quiescence vs the
578
+ // hard turn timeout. 0.10.0 Phase 4 §6: JSONL is the SOLE source
579
+ // of reply text. capture-pane is a LIVENESS signal only — it
580
+ // detects "the turn is done" so we never wait forever for a
581
+ // `result` that never arrives, but it NEVER delivers text.
354
582
  const captureCompleteP = this._awaitTurnComplete({
355
- captureAtStart, timeoutMs: turnTimeoutMs,
583
+ timeoutMs: turnTimeoutMs, abortP,
356
584
  });
585
+ // The capture-pane loop may end via the absolute-deadline abort
586
+ // (it returns the abort sentinel). Swallow that branch — the
587
+ // turnDeadlineP reject below is what actually fails the turn.
588
+ const captureRaceP = captureCompleteP.then((buf) => (
589
+ buf === ABORT_SENTINEL ? new Promise(() => {}) : { kind: 'capture' }
590
+ ));
357
591
 
358
- // Whichever resolves first wins.
359
592
  let resolvedVia = 'jsonl';
360
- const winner = await Promise.race([
361
- turnResultP.then((ev) => ({ kind: 'jsonl', ev })),
362
- captureCompleteP.then((cap) => ({ kind: 'capture', cap })),
363
- ]);
593
+ let winner;
594
+ try {
595
+ winner = await Promise.race([
596
+ turn.resultPromise.then((ev) => ({ kind: 'jsonl', ev })),
597
+ captureRaceP,
598
+ turnDeadlineP,
599
+ ]);
600
+
601
+ // If capture-pane won but the turn used a tool, the agent is
602
+ // still working — the "ready" hint was a transient idle between
603
+ // tool calls. Wait for the real terminal result from JSONL, but
604
+ // keep the absolute deadline armed so a JSONL `result` that
605
+ // never arrives still fails the turn rather than hanging it.
606
+ if (winner.kind === 'capture' && turn.toolUsedThisTurn) {
607
+ winner = await Promise.race([
608
+ turn.resultPromise.then((ev) => ({ kind: 'jsonl', ev })),
609
+ turnDeadlineP,
610
+ ]);
611
+ }
612
+ } finally {
613
+ if (turnDeadlineTimer) clearTimeout(turnDeadlineTimer);
614
+ // Ensure the capture-pane loop is released even when the JSONL
615
+ // race won — otherwise it would poll on until its own internal
616
+ // deadline (and, with a shared PollScheduler, hold a refcount).
617
+ signalAbort();
618
+ }
364
619
 
365
620
  let text;
366
621
  let resultSubtype = 'success';
367
622
  let stopReason = null;
368
623
  if (winner.kind === 'jsonl') {
369
- text = this._turnState.text || winner.ev.text || '';
624
+ text = turn.text || winner.ev.text || '';
370
625
  resultSubtype = winner.ev.subtype || 'success';
371
626
  stopReason = winner.ev.stopReason || null;
372
- // Update sessionId from the result if claude assigned a fresh one
373
627
  if (winner.ev.sessionId) this.claudeSessionId = winner.ev.sessionId;
628
+ // R10: a genuinely-empty terminal `result` — end_turn, no
629
+ // reply text, AND no tool ran this turn — is the agent
630
+ // producing literally nothing (a thinking-only terminal
631
+ // message). Pre-fix this resolved as { error:null, text:'' },
632
+ // a silent empty SUCCESS, and polygram delivered the canned
633
+ // "No response generated." apology classified as a successful
634
+ // turn. Match the §6 fail-loud contract (capture-won/no-JSONL
635
+ // also fails loud): surface a real error with an explicit
636
+ // subtype. NOT triggered for a tool-only completion (a tool
637
+ // ran → side effects the user saw → polygram's
638
+ // numToolUses>0 branch handles it) nor for a non-success
639
+ // terminal stop (max_tokens / stop_sequence / refusal carry
640
+ // their own already-surfaced subtype).
641
+ if (text.trim() === ''
642
+ && turn.toolUses === 0
643
+ && resultSubtype === 'success') {
644
+ throw Object.assign(
645
+ new Error('turn produced an empty JSONL result (end_turn, no text, no tools)'),
646
+ { code: 'TMUX_EMPTY_JSONL_RESULT' },
647
+ );
648
+ }
374
649
  } else {
375
- // Capture-pane won, but for short turns claude may flush JSONL
376
- // AFTER the TUI shows ready. Wait briefly for the structured
377
- // event so we can use its (clean) text over capture-pane diff.
378
- //
379
- // OPTIMIZATION: if JSONL has ALREADY delivered assistant text
380
- // by the time capture-pane resolves, we already have the
381
- // structured text — skip the late-grace wait entirely. Saves
382
- // ~1.5s on every short reply where the JSONL streamed in
383
- // during the turn.
384
- if (this._turnState.text) {
650
+ // Capture-pane quiescence judged the turn complete. Force the
651
+ // JSONL aggregator to finalize any buffered message so the
652
+ // structured `result` settles turn.resultPromise now.
653
+ this._sessionLogTail?.flushParser?.();
654
+ if (turn.text) {
385
655
  resolvedVia = 'jsonl-streamed';
386
- text = this._turnState.text;
656
+ text = turn.text;
387
657
  } else {
388
658
  const lateGraceMs = this.lateGraceMs ?? 1500;
389
659
  const late = await Promise.race([
390
- turnResultP.then((ev) => ({ kind: 'jsonl-late', ev })),
660
+ turn.resultPromise.then((ev) => ({ kind: 'jsonl-late', ev })),
391
661
  new Promise((r) => setTimeout(() => r({ kind: 'no-jsonl' }), lateGraceMs)),
392
662
  ]);
393
663
  if (late.kind === 'jsonl-late') {
394
664
  resolvedVia = 'jsonl-late';
395
- text = this._turnState.text || late.ev.text || '';
665
+ text = turn.text || late.ev.text || '';
396
666
  resultSubtype = late.ev.subtype || 'success';
397
667
  stopReason = late.ev.stopReason || null;
398
668
  if (late.ev.sessionId) this.claudeSessionId = late.ev.sessionId;
399
669
  } else {
400
- resolvedVia = 'capture-pane';
401
- text = this._turnState.text || this._extractTurnReply(captureAtStart, winner.cap);
670
+ // §6: capture-pane judged the turn done, but JSONL
671
+ // produced NO reply text within the grace window. FAIL
672
+ // LOUD — never fall back to capture-pane diff text. That
673
+ // fallback WAS the echoed-input failure (the pane diff
674
+ // returned the user's own echoed prompt) and the
675
+ // banner-as-reply failure (L1). The error result clears
676
+ // the reactor explicitly instead of delivering garbage.
677
+ throw Object.assign(
678
+ new Error('turn produced no JSONL reply text within grace window'),
679
+ { code: 'TMUX_NO_JSONL_TEXT' },
680
+ );
402
681
  }
403
682
  }
404
683
  }
405
684
 
406
- const duration = this._now() - startedAt;
685
+ const duration = this._now() - turn.startedAt;
407
686
  this.emit('result', { subtype: resultSubtype, resolvedVia }, { streamText: text, stopReason });
408
687
 
409
- // Token + cost telemetry from the latest JSONL usage snapshot.
410
- // claude doesn't write cost into JSONL; we compute from token
411
- // counts × `lib/model-costs.js` rate table. The result populates
412
- // turn_metrics so cost dashboards work the same as SDK.
413
688
  const u = this._lastUsage;
414
689
  const cost = u ? computeCostUsd(u, u.model) : null;
415
-
416
- const pmResult = {
690
+ turn.state = 'done';
691
+ turn.resolve({
417
692
  text,
418
693
  sessionId: this.claudeSessionId,
419
694
  cost,
@@ -425,35 +700,170 @@ class TmuxProcess extends Process {
425
700
  cacheCreationTokens: u?.cacheCreationTokens ?? null,
426
701
  cacheReadTokens: u?.cacheReadTokens ?? null,
427
702
  numAssistantMessages: 1,
428
- numToolUses: this._turnState.toolUses,
703
+ numToolUses: turn.toolUses,
429
704
  resultSubtype,
430
705
  stopReason,
431
706
  resolvedVia,
432
707
  },
433
- };
434
- this._completeTurn();
435
- return pmResult;
708
+ });
436
709
  } catch (err) {
437
- this._completeTurn();
438
- return this._errorResult(err.code || 'tmux_send_error', err.message || String(err));
710
+ turn.state = 'failed';
711
+ turn.resolve(this._errorResult(err.code || 'tmux_send_error', err.message || String(err)));
712
+ } finally {
713
+ this._finishTurn(turn);
439
714
  }
440
715
  }
441
716
 
442
- _completeTurn() {
443
- this.inFlight = false;
444
- // Shift the HEAD pending (just-completed turn). After this, the
445
- // queue contains only items queued while inFlight (each carrying
446
- // their own resolve/reject pair). If any, re-enter send() on the
447
- // next one send() will push its own fresh head pending.
448
- this.pendingQueue.shift();
449
- const next = this.pendingQueue.shift();
450
- if (next && next.resolve) {
451
- this.send(next.prompt, next.opts).then(next.resolve, next.reject);
717
+ /**
718
+ * Retire a finished primary turn and drain the next queued one.
719
+ */
720
+ _finishTurn(turn) {
721
+ // Finalize any assistant message still buffered in the JSONL
722
+ // aggregator BEFORE the next turn starts, so a turn that ended
723
+ // without a terminal `result` (e.g. turnTimeoutMs) cannot leak
724
+ // its buffered message into turn N+1.
725
+ this._sessionLogTail?.flushParser?.();
726
+ const qi = this.pendingQueue.indexOf(turn);
727
+ if (qi >= 0) this.pendingQueue.splice(qi, 1);
728
+ this._dropFromActiveGroup(turn);
729
+ // R1: if this primary turn ended WITHOUT its active group being
730
+ // flushed by a terminal `result` (it timed out or errored), the
731
+ // autosteer turns folded into that group would otherwise strand
732
+ // as `streaming` forever — leaking in the ledger AND keeping
733
+ // `_activeGroup` non-empty, which silently swallows the next
734
+ // autonomous assistant message. Retire the leftovers + reset the
735
+ // group.
736
+ //
737
+ // R11: retire the group ONLY when it is the group THIS finishing
738
+ // primary owns (`primaryTurnId === turn.turnId`). The old code
739
+ // retired whatever was in `_activeGroup` unconditionally — but in
740
+ // the window between the primary's `result` (which
741
+ // `_flushActiveGroup` used to reset the group) and `_runTurn`
742
+ // reaching `_finishTurn`, a NEW-TURN autosteer's dequeued
743
+ // `user-message` can route a FRESH group into place. That group
744
+ // belongs to the autosteer, not this primary; retiring it marked
745
+ // the autosteer `failed` and its chunks then leaked as an
746
+ // `autonomous-assistant-message` instead of an `extra-turn-reply`.
747
+ // A NEW-TURN autosteer group has `primaryTurnId: null`, so the
748
+ // guard leaves it untouched. (On a successful primary turn
749
+ // `_flushActiveGroup` already reset the group, so `turns` is empty
750
+ // and this is a no-op either way.)
751
+ if (this._activeGroup.turns.length > 0
752
+ && this._activeGroup.primaryTurnId === turn.turnId) {
753
+ for (const t of this._activeGroup.turns) {
754
+ if (t.state !== 'done' && t.state !== 'failed') t.state = 'failed';
755
+ }
756
+ this._activeGroup = {
757
+ turns: [], text: '', pendingSteerCausesNewBubble: false,
758
+ primaryTurnId: null,
759
+ };
760
+ }
761
+ this._sweepStaleTurns();
762
+ const next = this.pendingQueue[0];
763
+ if (next && next.state === 'queued') {
764
+ this._runTurn(next);
452
765
  } else {
766
+ this.inFlight = false;
453
767
  this.emit('idle');
454
768
  }
455
769
  }
456
770
 
771
+ // ─── token + ledger helpers (0.10.0 Phase 2) ─────────────────────
772
+
773
+ /** Mint a unique correlation token. `[a-z0-9-]` only — newline-free,
774
+ * no XML metacharacters — so it survives paste -> MULTILINE_SEPARATOR
775
+ * -> JSONL verbatim (validated by scripts/spikes/correlation-token-tui). */
776
+ _mintToken() {
777
+ return `pgm-corr-${crypto.randomBytes(12).toString('hex')}`;
778
+ }
779
+
780
+ /** Build a fresh Turn record. */
781
+ _makeTurn({ kind, prompt = '', opts = {}, context = {}, msgIds = [] }) {
782
+ this._turnSeq += 1;
783
+ return {
784
+ turnId: this._turnSeq,
785
+ token: this._mintToken(),
786
+ msgIds: [...msgIds],
787
+ kind, // 'primary' | 'autosteer'
788
+ state: 'queued', // queued | pasted | streaming | done | failed
789
+ text: '',
790
+ toolUses: 0,
791
+ toolUsedThisTurn: false,
792
+ stopReason: null,
793
+ resultEvent: null,
794
+ via: null, // autosteer: 'fold' | 'new-turn'
795
+ context, opts, prompt,
796
+ startedAt: 0,
797
+ resolve: null, reject: null, callerPromise: null,
798
+ settleResult: null, resultPromise: null,
799
+ };
800
+ }
801
+
802
+ /**
803
+ * Embed a correlation token into a prompt's <polygram-info> block as
804
+ * a `corr-id` attribute. The agent is already instructed to ignore
805
+ * that block, so the token is invisible to the conversation. If the
806
+ * prompt has no <polygram-info> block (non-standard paste), prepend
807
+ * a minimal carrier — the agent ignores empty <polygram-info> too.
808
+ */
809
+ _embedToken(prompt, token) {
810
+ const p = String(prompt ?? '');
811
+ if (/<polygram-info[\s>]/.test(p)) {
812
+ return p.replace(/<polygram-info([\s>])/, `<polygram-info corr-id="${token}"$1`);
813
+ }
814
+ return `<polygram-info corr-id="${token}"></polygram-info>\n\n${p}`;
815
+ }
816
+
817
+ /** Extract every correlation token present in a JSONL line.
818
+ * Matches the EXACT minted shape (`pgm-corr-` + 24 hex from
819
+ * randomBytes(12)) — `{24}` instead of `+` so a token followed by
820
+ * adjacent hex still extracts exactly the 24-char token (R6). */
821
+ _extractTokens(text) {
822
+ if (typeof text !== 'string') return [];
823
+ const m = text.match(/pgm-corr-[0-9a-f]{24}/g);
824
+ return m ? [...new Set(m)] : [];
825
+ }
826
+
827
+ /** Drop done/failed turns — token uniqueness means a stale match is
828
+ * impossible, so retired turns are pure memory overhead. */
829
+ _pruneLedger() {
830
+ this._ledger = this._ledger.filter(
831
+ (t) => t.state !== 'done' && t.state !== 'failed',
832
+ );
833
+ }
834
+
835
+ /**
836
+ * 0.10.0 Phase 4 §7: stale-turn sweep. An autosteer turn whose
837
+ * paste the TUI never correlated (no enqueue / remove / dequeue /
838
+ * user-message) within a grace window is dead — fail it loud so it
839
+ * cannot leak in the ledger. A primary turn that produces no JSONL
840
+ * text already fails loud via §6 in `_runTurn`, so the sweep only
841
+ * targets stuck autosteers. Runs at every turn completion.
842
+ */
843
+ _sweepStaleTurns() {
844
+ const now = this._now();
845
+ const graceMs = this.turnTimeoutMs;
846
+ for (const turn of this._ledger) {
847
+ if (turn.kind !== 'autosteer' || turn.state !== 'pasted') continue;
848
+ if (!turn.startedAt || (now - turn.startedAt) < graceMs) continue;
849
+ turn.state = 'failed';
850
+ this._enqueuedTurns = this._enqueuedTurns.filter((t) => t !== turn);
851
+ this.emit('autosteer-match-miss', {
852
+ phase: 'stale-sweep',
853
+ msgId: turn.msgIds[0] ?? null,
854
+ turnId: turn.turnId,
855
+ ageMs: now - turn.startedAt,
856
+ sessionId: this.claudeSessionId,
857
+ backend: 'tmux',
858
+ });
859
+ }
860
+ this._pruneLedger();
861
+ }
862
+
863
+ _dropFromActiveGroup(turn) {
864
+ this._activeGroup.turns = this._activeGroup.turns.filter((t) => t !== turn);
865
+ }
866
+
457
867
  _errorResult(code, message) {
458
868
  return {
459
869
  text: '',
@@ -476,11 +886,9 @@ class TmuxProcess extends Process {
476
886
  * Open a tail on `~/.claude/projects/<cwd-encoded>/<sessionId>.jsonl`
477
887
  * and forward parsed events to Process listeners.
478
888
  *
479
- * Events forwarded:
480
- * - assistant-chunk emit 'stream-chunk' (matches SdkProcess shape)
481
- * - tool-use → emit 'tool-use'
482
- * - result → resolve current turn's _turnState.resolveResult
483
- * - last-prompt → fallback turn-complete signal
889
+ * Events are routed by `_handleSessionEvent` through the Phase 2
890
+ * turn ledger `user-message` tokens attribute each event to its
891
+ * Turn; `result` flushes the active group.
484
892
  */
485
893
  _armSessionLogTail({ resuming = false } = {}) {
486
894
  if (this._sessionLogTail) return; // idempotent
@@ -514,30 +922,33 @@ class TmuxProcess extends Process {
514
922
 
515
923
  _handleSessionEvent(ev) {
516
924
  if (ev.type === 'assistant-chunk') {
517
- if (this._turnState) {
518
- // If a mid-turn steer just happened, the NEXT assistant message
519
- // should start a fresh Telegram bubble otherwise the post-steer
520
- // reply visually appends to the pre-steer text bubble, making the
521
- // user's follow-up look unanswered. Mirror SdkProcess's logic:
522
- // emit 'assistant-message-start', reset the accumulator, clear
523
- // the flag. Subsequent chunks within THIS new assistant message
524
- // continue to accumulate in the fresh bubble.
525
- if (this._turnState.pendingSteerCausesNewBubble) {
526
- this._turnState.pendingSteerCausesNewBubble = false;
527
- this._turnState.text = '';
925
+ // Assistant text belongs to whatever turn(s) the latest
926
+ // `user-message` token routed into the active group. The
927
+ // handler never guesses from "which accumulator is non-null".
928
+ const group = this._activeGroup;
929
+ if (group.turns.length > 0) {
930
+ // A mid-turn steer asks the NEXT assistant message to open a
931
+ // fresh Telegram bubble so the post-steer reply doesn't
932
+ // visually append to the pre-steer text.
933
+ if (group.pendingSteerCausesNewBubble) {
934
+ group.pendingSteerCausesNewBubble = false;
935
+ group.text = '';
528
936
  this.emit('assistant-message-start');
529
937
  }
530
- // In-flight turn: accumulate text + forward as stream-chunk so
531
- // pm consumers can render incremental output.
532
- this._turnState.text = this._turnState.text
533
- ? `${this._turnState.text}\n\n${ev.text}`
534
- : ev.text;
535
- this.emit('stream-chunk', this._turnState.text);
938
+ group.text = group.text ? `${group.text}\n\n${ev.text}` : ev.text;
939
+ // Keep every turn in the group current — a fold shares one
940
+ // reply across its members.
941
+ for (const t of group.turns) t.text = group.text;
942
+ // stream-chunk drives the live Telegram bubble via
943
+ // pendingQueue[0].context.streamer — only the primary turn
944
+ // owns that surface. Autosteer NEW-TURN replies are delivered
945
+ // whole via extra-turn-reply, never streamed.
946
+ if (group.turns.some((t) => t.kind === 'primary')) {
947
+ this.emit('stream-chunk', group.text);
948
+ }
536
949
  } else {
537
- // No turn in flight this is an autonomous assistant message
538
- // (claude self-initiated; typically ScheduleWakeup firing).
539
- // Mirror SdkProcess.onAutonomousAssistantMessage routing so
540
- // pm consumers receive these the same way regardless of backend.
950
+ // No active turn — autonomous assistant message (claude
951
+ // self-initiated; typically ScheduleWakeup firing).
541
952
  this.emit('autonomous-assistant-message', {
542
953
  text: ev.text,
543
954
  sessionId: this.claudeSessionId,
@@ -545,7 +956,13 @@ class TmuxProcess extends Process {
545
956
  });
546
957
  }
547
958
  } else if (ev.type === 'tool-use') {
548
- if (this._turnState) this._turnState.toolUses++;
959
+ for (const t of this._activeGroup.turns) {
960
+ t.toolUses++;
961
+ // The `tool-use` event is the earliest proof a tool ran — set
962
+ // the flag here so a transient capture-pane "ready" between
963
+ // tool calls cannot resolve a still-working turn.
964
+ t.toolUsedThisTurn = true;
965
+ }
549
966
  this.emit('tool-use', ev.name);
550
967
  } else if (ev.type === 'usage') {
551
968
  // Token-usage snapshot from JSONL. Cache for getContextUsage().
@@ -584,31 +1001,293 @@ class TmuxProcess extends Process {
584
1001
  }
585
1002
  this._lastUsage = ev;
586
1003
  } else if (ev.type === 'result') {
587
- if (this._turnState && this._turnState.resolveResult) {
588
- this._turnState.resultEvent = ev;
589
- this._turnState.resolveResult(ev);
1004
+ // Only a TERMINAL stop_reason ends a turn. The JSONL aggregator
1005
+ // emits a `result` per assistant message — `tool_use` is
1006
+ // NON-terminal (the agent paused for a tool; more messages
1007
+ // follow). Terminal: 'success' (end_turn), 'max_tokens',
1008
+ // 'stop_sequence', 'refusal'.
1009
+ if (ev.subtype === 'tool_use') return;
1010
+ this._flushActiveGroup(ev);
1011
+ } else if (ev.type === 'user-message') {
1012
+ this._routeUserMessage(ev);
1013
+ } else if (ev.type === 'queue-operation') {
1014
+ // The live queue-activity signal — the real fold mechanism
1015
+ // (verified against claude 2.1.142 JSONL):
1016
+ // enqueue {content} — a paste was parked in the TUI's input
1017
+ // queue; content carries the correlation token.
1018
+ // remove — the oldest queued paste was FOLDED into
1019
+ // the running turn. NO user-message follows — the autosteer
1020
+ // MUST be resolved here or it leaks forever.
1021
+ // dequeue — the oldest queued paste was released to
1022
+ // run as a fresh turn; its user-message follows and
1023
+ // _routeUserMessage resolves it (NEW-TURN).
1024
+ if (ev.operation === 'enqueue' && ev.content) {
1025
+ const tokens = this._extractTokens(ev.content);
1026
+ this._confirmPaste(tokens); // §5: enqueue proves the paste landed
1027
+ for (const tok of tokens) {
1028
+ // R2: track ANY queued turn — primary OR autosteer. A
1029
+ // primary paste that lands while the TUI is busy is also
1030
+ // queued; if it is not mirrored here, the later positional
1031
+ // `remove`/`dequeue` shift pops the wrong turn and the FIFO
1032
+ // desyncs.
1033
+ const turn = this._ledger.find(
1034
+ (t) => t.token === tok
1035
+ && t.state !== 'done' && t.state !== 'failed',
1036
+ );
1037
+ if (turn && !this._enqueuedTurns.includes(turn)) {
1038
+ this._enqueuedTurns.push(turn);
1039
+ }
1040
+ }
1041
+ } else if (ev.operation === 'remove') {
1042
+ // The head queued turn was FOLDED into the running turn. Only
1043
+ // an autosteer folds — a primary never does; if a primary is
1044
+ // somehow at the head it is just dropped from tracking.
1045
+ const turn = this._enqueuedTurns.shift();
1046
+ if (turn && turn.kind === 'autosteer'
1047
+ && turn.state !== 'done' && turn.state !== 'failed') {
1048
+ this._foldAutosteer(turn);
1049
+ }
1050
+ } else if (ev.operation === 'dequeue') {
1051
+ // The head queued turn was released to run as a fresh turn —
1052
+ // drop tracking; its user-message follows and _routeUserMessage
1053
+ // resolves it (primary → its own turn; autosteer → NEW-TURN).
1054
+ this._enqueuedTurns.shift();
590
1055
  }
591
- // If no turn in flight, the result event simply marks the end of
592
- // an autonomous message segment already handled by the
593
- // assistant-chunk branch above.
1056
+ } else if (ev.type === 'queue-folded') {
1057
+ // Dead path: `attachment.queued_command` does not appear in
1058
+ // real claude 2.1.142 JSONL. Kept inert for parser parity.
594
1059
  } else if (ev.type === 'last-prompt') {
595
- // Fallback complete signal. If 'result' didn't fire (rare; some
596
- // claude versions may write last-prompt instead of stop_reason),
597
- // synthesize a success result.
598
- if (this._turnState && this._turnState.resolveResult && !this._turnState.resultEvent) {
599
- const synthetic = {
1060
+ // `last-prompt` fires when a prompt is REGISTERED, not when a
1061
+ // turn ends. The JSONL aggregator already fires a proper
1062
+ // `result` on the message's terminal stop_reason; this is only
1063
+ // a safety net for a turn whose terminal stop_reason never
1064
+ // wrote. Fire it ONLY when the active group has a primary turn
1065
+ // with accumulated text — never hijack a turn that has not
1066
+ // started replying.
1067
+ const group = this._activeGroup;
1068
+ if (group.turns.some((t) => t.kind === 'primary')
1069
+ && group.text && group.text.length > 0) {
1070
+ this._flushActiveGroup({
600
1071
  type: 'result',
601
1072
  subtype: 'success',
602
- text: this._turnState.text,
1073
+ text: group.text,
603
1074
  stopReason: 'last-prompt',
604
1075
  sessionId: this.claudeSessionId,
605
- };
606
- this._turnState.resultEvent = synthetic;
607
- this._turnState.resolveResult(synthetic);
1076
+ });
1077
+ }
1078
+ }
1079
+ }
1080
+
1081
+ /**
1082
+ * Route a JSONL `user-message` to the turn(s) its correlation
1083
+ * token(s) identify. This is the heart of recorded (not
1084
+ * reconstructed) attribution: each token is an exact id lookup.
1085
+ *
1086
+ * A user-message's matched turns join the active group:
1087
+ * - if the group will contain a primary turn, an autosteer turn
1088
+ * is a FOLD — the primary's reply covers it (autosteer-
1089
+ * resolution via:fold; no separate delivery);
1090
+ * - otherwise the autosteer is a NEW-TURN — it gets its own
1091
+ * reply via extra-turn-reply on the group's terminal result.
1092
+ * N tokens in ONE user-message (a concatenated paste) is an
1093
+ * explicit, unambiguous fold of N turns.
1094
+ */
1095
+ _routeUserMessage(ev) {
1096
+ const tokens = this._extractTokens(ev.text);
1097
+ // Phase 3 §5: a user-message proves its pastes landed — release
1098
+ // the paste gate for those tokens.
1099
+ this._confirmPaste(tokens);
1100
+ let matched = [];
1101
+ for (const tok of tokens) {
1102
+ const t = this._ledger.find(
1103
+ (x) => x.token === tok && x.state !== 'done' && x.state !== 'failed',
1104
+ );
1105
+ if (t && !matched.includes(t)) matched.push(t);
1106
+ }
1107
+ if (matched.length === 0) {
1108
+ // No token matched. A token-less user-message can still be a
1109
+ // primary turn we pasted whose token failed to round-trip —
1110
+ // claim the oldest such 'pasted' primary as a fallback.
1111
+ if (tokens.length === 0) {
1112
+ const orphan = this._ledger.find(
1113
+ (x) => x.kind === 'primary' && x.state === 'pasted',
1114
+ );
1115
+ if (orphan) matched = [orphan];
1116
+ }
1117
+ if (matched.length === 0) {
1118
+ // Genuinely unrecognised — diagnostic, then ignore. (A
1119
+ // resumed-session historic user-message lands here; harmless.)
1120
+ this.emit('autosteer-match-miss', {
1121
+ phase: 'user-message',
1122
+ text_head: (ev.text || '').slice(0, 80),
1123
+ token_count: tokens.length,
1124
+ ledger_size: this._ledger.length,
1125
+ sessionId: this.claudeSessionId,
1126
+ backend: 'tmux',
1127
+ });
1128
+ return;
1129
+ }
1130
+ }
1131
+ const group = this._activeGroup;
1132
+ const willHavePrimary = group.turns.some((t) => t.kind === 'primary')
1133
+ || matched.some((t) => t.kind === 'primary');
1134
+ for (const t of matched) {
1135
+ if (group.turns.includes(t)) continue;
1136
+ // R4: idempotency guard — a turn that already has a `via` was
1137
+ // already classified (e.g. folded via `queue-operation remove`).
1138
+ // Never route or resolve it twice.
1139
+ if (t.via) continue;
1140
+ t.state = 'streaming';
1141
+ group.turns.push(t);
1142
+ // R11: a primary turn joining the group makes it that primary's
1143
+ // group — record the owner so `_finishTurn` retires only the
1144
+ // group its finishing primary owns. A NEW-TURN autosteer never
1145
+ // sets this, so its fresh group keeps `primaryTurnId: null` and
1146
+ // survives an unrelated primary's `_finishTurn`.
1147
+ if (t.kind === 'primary') group.primaryTurnId = t.turnId;
1148
+ if (t.kind !== 'autosteer') continue;
1149
+ t.via = willHavePrimary ? 'fold' : 'new-turn';
1150
+ if (t.via === 'new-turn') {
1151
+ for (const msgId of t.msgIds) {
1152
+ this.emit('extra-turn-started', {
1153
+ msgId, sessionId: this.claudeSessionId, backend: 'tmux',
1154
+ });
1155
+ }
1156
+ }
1157
+ for (const msgId of t.msgIds) {
1158
+ this.emit('autosteer-resolution', {
1159
+ msgId, via: t.via, sessionId: this.claudeSessionId, backend: 'tmux',
1160
+ });
1161
+ }
1162
+ }
1163
+ }
1164
+
1165
+ /**
1166
+ * Resolve an autosteer turn the TUI folded into the running turn
1167
+ * (a `queue-operation remove`). A fold shares the primary turn's
1168
+ * reply — autosteer-resolution(via:fold) tells polygram the message
1169
+ * is covered; no separate extra-turn-reply is delivered. The turn
1170
+ * joins the active group so the terminal-result flush retires it.
1171
+ */
1172
+ _foldAutosteer(turn) {
1173
+ turn.via = 'fold';
1174
+ turn.state = 'streaming';
1175
+ if (!this._activeGroup.turns.includes(turn)) {
1176
+ this._activeGroup.turns.push(turn);
1177
+ }
1178
+ for (const msgId of turn.msgIds) {
1179
+ this.emit('autosteer-resolution', {
1180
+ msgId, via: 'fold', sessionId: this.claudeSessionId, backend: 'tmux',
1181
+ });
1182
+ }
1183
+ }
1184
+
1185
+ /**
1186
+ * Flush the active turn group on a terminal `result`: settle the
1187
+ * primary turn's send() race, deliver each NEW-TURN autosteer's
1188
+ * reply, and clear the group.
1189
+ */
1190
+ _flushActiveGroup(ev) {
1191
+ const group = this._activeGroup;
1192
+ if (group.turns.length === 0) return; // autonomous segment end
1193
+ const turns = group.turns;
1194
+ const text = group.text;
1195
+ this._activeGroup = {
1196
+ turns: [], text: '', pendingSteerCausesNewBubble: false,
1197
+ primaryTurnId: null,
1198
+ };
1199
+ if (ev.sessionId) this.claudeSessionId = ev.sessionId;
1200
+ for (const t of turns) {
1201
+ t.state = 'done';
1202
+ t.text = text;
1203
+ t.stopReason = ev.stopReason || null;
1204
+ t.resultEvent = ev;
1205
+ }
1206
+ // Primary turn — settle its _runTurn race so send() resolves.
1207
+ const primary = turns.find((t) => t.kind === 'primary');
1208
+ if (primary && typeof primary.settleResult === 'function') {
1209
+ primary.settleResult(ev);
1210
+ }
1211
+ // Autosteer NEW-TURN turns — deliver the shared reply once per
1212
+ // owning msgId. FOLD autosteers already emitted autosteer-
1213
+ // resolution(via:fold); the primary's reply covers them.
1214
+ for (const t of turns) {
1215
+ if (t.kind !== 'autosteer' || t.via !== 'new-turn') continue;
1216
+ for (const msgId of t.msgIds) {
1217
+ this.emit('extra-turn-reply', {
1218
+ msgId, text, sessionId: this.claudeSessionId, backend: 'tmux',
1219
+ });
608
1220
  }
609
1221
  }
610
1222
  }
611
1223
 
1224
+ /**
1225
+ * Paste a prompt body + press Enter, then GATE the next paste on
1226
+ * JSONL confirmation (0.10.0 Phase 3 §5).
1227
+ *
1228
+ * rc.13.1: the runner's `pasteAndEnter` holds a per-session lock so
1229
+ * the paste+Enter pair is atomic (no interleaved keystrokes).
1230
+ *
1231
+ * Phase 3 §5 adds the outer barrier: `_pasteLock` is held until the
1232
+ * JSONL tail confirms THIS paste landed — its correlation token
1233
+ * surfaced in a `user-message` or `queue-operation` event — bounded
1234
+ * by `pasteConfirmMs`. The next `_pasteAndEnter` therefore cannot
1235
+ * start until the previous paste is a distinct TUI input, so two
1236
+ * pastes can no longer concatenate. The lock is released
1237
+ * asynchronously (on confirm/timeout) so it gates only the NEXT
1238
+ * paste, never delays this caller.
1239
+ */
1240
+ async _pasteAndEnter(text) {
1241
+ const token = this._extractTokens(text)[0] || null;
1242
+ const release = await this._pasteLock.acquire(this.tmuxName);
1243
+ let result;
1244
+ try {
1245
+ if (typeof this.runner.pasteAndEnter === 'function') {
1246
+ result = await this.runner.pasteAndEnter(this.tmuxName, text);
1247
+ } else {
1248
+ result = await this.runner.pasteText(this.tmuxName, text);
1249
+ await this.runner.sendControl(this.tmuxName, 'Enter');
1250
+ }
1251
+ } catch (err) {
1252
+ release();
1253
+ throw err;
1254
+ }
1255
+ // Hold the paste lock until JSONL confirms this paste — gating
1256
+ // the NEXT paste, not this caller (which gets `result` now).
1257
+ if (token) {
1258
+ this._awaitPasteConfirm(token).then(release, release);
1259
+ } else {
1260
+ release();
1261
+ }
1262
+ return result;
1263
+ }
1264
+
1265
+ /**
1266
+ * Resolve once `token` surfaces in a JSONL user-message /
1267
+ * queue-operation, or after `pasteConfirmMs` (bounded barrier).
1268
+ */
1269
+ _awaitPasteConfirm(token) {
1270
+ return new Promise((resolve) => {
1271
+ let done = false;
1272
+ const finish = () => {
1273
+ if (done) return;
1274
+ done = true;
1275
+ this._pasteConfirms.delete(token);
1276
+ resolve();
1277
+ };
1278
+ this._pasteConfirms.set(token, finish);
1279
+ setTimeout(finish, this.pasteConfirmMs).unref?.();
1280
+ });
1281
+ }
1282
+
1283
+ /** Mark the given correlation tokens as JSONL-confirmed (Phase 3 §5). */
1284
+ _confirmPaste(tokens) {
1285
+ for (const t of tokens) {
1286
+ const finish = this._pasteConfirms.get(t);
1287
+ if (finish) finish();
1288
+ }
1289
+ }
1290
+
612
1291
  // ─── completion detection (§4.A capture-pane diff path — fallback) ──
613
1292
 
614
1293
  /**
@@ -624,22 +1303,32 @@ class TmuxProcess extends Process {
624
1303
 
625
1304
  async _waitForReady() {
626
1305
  const deadline = this._now() + this.readyTimeoutMs;
1306
+ let lastBuf = '';
627
1307
  if (this.pollScheduler) this.pollScheduler.acquire();
628
1308
  try {
629
1309
  while (this._now() < deadline) {
630
1310
  // OPTIMIZATION: ready hint lives in the bottom ~5 lines of the
631
1311
  // pane. Polling 1000 lines each tick is wasteful — cap at 80
632
1312
  // for a ~12× cheaper tmux subprocess.
633
- const buf = await this.runner.captureWide(this.tmuxName, { lines: 80 });
634
- if (READY_HINTS_RE.test(buf)) return;
1313
+ lastBuf = await this.runner.captureWide(this.tmuxName, { lines: 80 });
1314
+ if (READY_HINTS_RE.test(lastBuf)) return;
635
1315
  await this._waitForNextTick();
636
1316
  }
637
1317
  } finally {
638
1318
  if (this.pollScheduler) this.pollScheduler.release();
639
1319
  }
1320
+ // On timeout, surface what the TUI was actually showing so the
1321
+ // operator can diagnose whether it was hung, on a setup prompt,
1322
+ // or just slow to render the ready hint. capture the last ~40
1323
+ // lines for the error event payload + log.
1324
+ const tail = lastBuf.split('\n').slice(-40).join('\n');
1325
+ this.logger.warn?.(
1326
+ `[${this.label}] TUI did not signal ready in ${this.readyTimeoutMs}ms; last pane tail:\n${tail}`,
1327
+ );
640
1328
  throw Object.assign(new Error('TmuxProcess: TUI did not signal ready'), {
641
1329
  code: 'TMUX_READY_TIMEOUT',
642
1330
  tmuxName: this.tmuxName,
1331
+ paneTail: tail,
643
1332
  });
644
1333
  }
645
1334
 
@@ -652,17 +1341,29 @@ class TmuxProcess extends Process {
652
1341
  * hint at the bottom). For the FINAL capture used to extract reply
653
1342
  * text, we fall back to the default 1000-line wide capture.
654
1343
  */
655
- async _awaitTurnComplete({ timeoutMs }) {
1344
+ async _awaitTurnComplete({ timeoutMs, abortP = null }) {
656
1345
  const deadline = this._now() + timeoutMs;
657
1346
  let firstReadyAt = null;
658
1347
  let lastBuf = '';
659
1348
  let prevBufLen = -1;
660
1349
  let cachedReady = false;
661
1350
  let cachedStreaming = false;
1351
+ // R7: when the caller supplies `abortP`, every blocking await in
1352
+ // the loop (the `captureWide` subprocess AND the inter-tick wait)
1353
+ // is raced against it. A wedged `tmux capture-pane` would
1354
+ // otherwise park the loop forever — neither completing the turn
1355
+ // nor releasing the PollScheduler refcount. `abortP` resolves
1356
+ // with ABORT_SENTINEL; any await that loses the race to it yields
1357
+ // the sentinel, and the loop exits via the `finally` (releasing
1358
+ // the scheduler) so _runTurn's absolute deadline fails the turn.
1359
+ const raceAbort = abortP
1360
+ ? (p) => Promise.race([p, abortP.then(() => ABORT_SENTINEL)])
1361
+ : (p) => p;
662
1362
  if (this.pollScheduler) this.pollScheduler.acquire();
663
1363
  try {
664
1364
  while (this._now() < deadline) {
665
- lastBuf = await this.runner.captureWide(this.tmuxName, { lines: 200 });
1365
+ lastBuf = await raceAbort(this.runner.captureWide(this.tmuxName, { lines: 200 }));
1366
+ if (lastBuf === ABORT_SENTINEL) return ABORT_SENTINEL;
666
1367
 
667
1368
  // OPTIMIZATION: skip the three regex tests when the capture
668
1369
  // buffer is identical (by length) to the previous tick. claude
@@ -675,7 +1376,16 @@ class TmuxProcess extends Process {
675
1376
  const bufLenChanged = lastBuf.length !== prevBufLen;
676
1377
  if (bufLenChanged) {
677
1378
  prevBufLen = lastBuf.length;
678
- cachedReady = READY_HINTS_RE.test(lastBuf);
1379
+ // L1 fix: ignore READY hint while the startup banner is
1380
+ // still the LATEST thing on the pane. After the agent has
1381
+ // emitted content, the banner ends up in scrollback far
1382
+ // above the bottom — at that point we DO want READY to
1383
+ // count. Check only the last ~10 lines for the banner:
1384
+ // if the bottom of the pane is still banner+ready, the
1385
+ // agent hasn't produced output yet, so the ready hint is
1386
+ // a startup artifact, not the end of a real turn.
1387
+ const bottomTail = lastBuf.slice(-2000); // ~10-20 lines of pane bottom
1388
+ cachedReady = READY_HINTS_RE.test(lastBuf) && !TUI_BANNER_RE.test(bottomTail);
679
1389
  cachedStreaming = STREAMING_HINT_RE.test(lastBuf);
680
1390
  // Approval-prompt detection ONLY runs on changed captures.
681
1391
  // It's the heaviest regex (`[\s\S]{0,400}?` non-greedy) so
@@ -683,7 +1393,9 @@ class TmuxProcess extends Process {
683
1393
  if (APPROVAL_PROMPT_RE.test(lastBuf)) {
684
1394
  await this._handleApprovalPrompt(lastBuf);
685
1395
  firstReadyAt = null; // approval pause resets ready clock
686
- await this._waitForNextTick();
1396
+ if (await raceAbort(this._waitForNextTick()) === ABORT_SENTINEL) {
1397
+ return ABORT_SENTINEL;
1398
+ }
687
1399
  continue;
688
1400
  }
689
1401
  }
@@ -696,7 +1408,9 @@ class TmuxProcess extends Process {
696
1408
  } else {
697
1409
  firstReadyAt = null;
698
1410
  }
699
- await this._waitForNextTick();
1411
+ if (await raceAbort(this._waitForNextTick()) === ABORT_SENTINEL) {
1412
+ return ABORT_SENTINEL;
1413
+ }
700
1414
  }
701
1415
  throw Object.assign(new Error('TmuxProcess: turn did not complete in time'), {
702
1416
  code: 'TMUX_TURN_TIMEOUT',
@@ -777,20 +1491,9 @@ class TmuxProcess extends Process {
777
1491
  }
778
1492
  }
779
1493
 
780
- /**
781
- * Best-effort: text between the start-of-turn snapshot and the
782
- * post-completion snapshot. The capture-pane diff strategy is
783
- * intentionally crude in MVP — Phase 3 will switch to --debug-file
784
- * for structured assistant-message extraction.
785
- */
786
- _extractTurnReply(captureAtStart, captureAtEnd) {
787
- if (!captureAtEnd) return '';
788
- if (captureAtStart && captureAtEnd.startsWith(captureAtStart)) {
789
- return captureAtEnd.slice(captureAtStart.length).trim();
790
- }
791
- // Fallback: return whatever's after the user's last prompt marker.
792
- return captureAtEnd.trim();
793
- }
1494
+ // 0.10.0 Phase 4 §6: `_extractTurnReply` (capture-pane diff text
1495
+ // extraction) is GONE. Reply text comes exclusively from JSONL
1496
+ // keyed to a turn. capture-pane is liveness-only — see `_runTurn`.
794
1497
 
795
1498
  // ─── interrupts / control ────────────────────────────────────────
796
1499
 
@@ -933,57 +1636,97 @@ class TmuxProcess extends Process {
933
1636
  // ─── HOT-PATH sync — must NOT throw (R1-F1) ──────────────────────
934
1637
 
935
1638
  /**
936
- * Reject all local pendings with the supplied code. Returns count.
937
- * No-throw contract autosteer's call site has no try/catch.
1639
+ * Reject every QUEUED primary turn with the supplied code. The
1640
+ * running head turn (pendingQueue[0], state 'pasted'/'streaming')
1641
+ * is left alone — it settles normally via its own _runTurn flow.
1642
+ * Returns the count drained. No-throw contract — autosteer's call
1643
+ * site has no try/catch.
938
1644
  */
939
1645
  drainQueue(code = 'INTERRUPTED') {
940
- const drained = this.pendingQueue.length;
941
- if (drained === 0) return 0;
1646
+ // Drain every pendingQueue entry EXCEPT the running head (a Turn
1647
+ // in state 'pasted'/'streaming' it settles via its own _runTurn
1648
+ // flow). Queued turns ('queued') are drained; so are stateless
1649
+ // entries (test fakes), which are never the running head.
1650
+ const toDrain = this.pendingQueue.filter(
1651
+ (t) => t.state !== 'pasted' && t.state !== 'streaming',
1652
+ );
1653
+ if (toDrain.length === 0) return 0;
942
1654
  const err = Object.assign(new Error(`drained:${code}`), { code });
943
- while (this.pendingQueue.length > 0) {
944
- const p = this.pendingQueue.shift();
945
- // Head pending (currently-running turn) has no resolve/reject —
946
- // it returns directly via send()'s promise chain. Skip rejection
947
- // for those; the send() flow handles errors via _errorResult.
948
- if (p && typeof p.reject === 'function') {
949
- try { p.reject(err); } catch (e) {
1655
+ for (const t of toDrain) {
1656
+ const qi = this.pendingQueue.indexOf(t);
1657
+ if (qi >= 0) this.pendingQueue.splice(qi, 1);
1658
+ if (t.state) t.state = 'failed';
1659
+ if (typeof t.reject === 'function') {
1660
+ try { t.reject(err); } catch (e) {
950
1661
  this.logger.error?.(`[${this.label}] drainQueue reject: ${e.message}`);
951
1662
  }
952
1663
  }
953
1664
  }
954
- this.emit('queue-drop', drained);
955
- return drained;
1665
+ this._pruneLedger();
1666
+ this.emit('queue-drop', toDrain.length);
1667
+ return toDrain.length;
956
1668
  }
957
1669
 
958
1670
  /**
959
- * Inject text into the in-flight turn. Fire-and-forget paste; errors
960
- * surface via 'inject-fail' event, never as a thrown exception.
1671
+ * Inject text into the in-flight turn (autosteer). Fire-and-forget
1672
+ * paste; errors surface via 'inject-fail', never as a throw.
1673
+ *
1674
+ * Registers an autosteer Turn in the ledger with a fresh
1675
+ * correlation token embedded in the paste. When the TUI surfaces
1676
+ * the paste as a JSONL `user-message`, the token routes it — FOLD
1677
+ * (a primary turn shares the group) or NEW-TURN (it does not).
961
1678
  *
1679
+ * @param {object} [opts.msgId] — Telegram msgId; when present, a
1680
+ * NEW-TURN autosteer's reply is routed back via 'extra-turn-reply'.
962
1681
  * @returns {boolean} false if no live turn (caller falls through to
963
- * pm.send queue path) OR if content sanitized to empty.
1682
+ * the pm.send queue path) OR if content sanitized to empty.
964
1683
  */
965
- injectUserMessage({ content, priority = 'next', shouldQuery } = {}) {
1684
+ injectUserMessage({ content, priority = 'next', shouldQuery, msgId } = {}) {
966
1685
  if (!this.inFlight || this.closed) return false;
967
- // Mirror R2-F1: sanitize even though pasteText also sanitizes.
968
- // We need to detect empty-after-sanitize here so caller can fall
969
- // through (pasteText would happily send the empty string).
1686
+ // Detect empty-after-sanitize here so the caller can fall through.
970
1687
  const safe = String(content || '').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
971
1688
  if (!safe) return false;
972
1689
 
973
- Promise.resolve()
974
- .then(() => this.runner.pasteText(this.tmuxName, safe))
975
- .then(() => this.runner.sendControl(this.tmuxName, 'Enter'))
976
- .catch((err) => this.emit('inject-fail', { err: err.message }));
977
-
978
- // Tell the next assistant-chunk to open a fresh Telegram bubble
979
- // so the post-steer reply visually follows the user's mid-turn
980
- // message instead of appending to the pre-steer bubble. Mirrors
981
- // SdkProcess's pendingSteerCausesNewBubble flag.
982
- if (this._turnState) {
983
- this._turnState.pendingSteerCausesNewBubble = true;
984
- }
1690
+ const turn = this._makeTurn({
1691
+ kind: 'autosteer',
1692
+ prompt: safe,
1693
+ msgIds: msgId != null ? [msgId] : [],
1694
+ });
1695
+ turn.state = 'pasted';
1696
+ turn.startedAt = this._now(); // Phase 4 §7: staleness clock
1697
+ this._ledger.push(turn);
1698
+
1699
+ // The next assistant message after a mid-turn steer opens a fresh
1700
+ // Telegram bubble so the post-steer reply doesn't append to the
1701
+ // pre-steer text.
1702
+ this._activeGroup.pendingSteerCausesNewBubble = true;
1703
+
1704
+ // pasteAndEnter holds the per-session lock so this autosteer's
1705
+ // paste+Enter cannot interleave keystrokes with an in-flight
1706
+ // primary paste.
1707
+ //
1708
+ // R8: if the paste rejects the autosteer never reached the TUI —
1709
+ // it will never correlate to a JSONL user-message, so the
1710
+ // stale-turn sweep would otherwise be the ONLY thing that catches
1711
+ // it, `turnTimeoutMs` later. Fail the ledger turn immediately and
1712
+ // emit `inject-fail` WITH the msgId so the wired onInjectFail
1713
+ // handler can clear the ✍ reaction promptly instead of leaving it
1714
+ // stuck for minutes.
1715
+ this._pasteAndEnter(this._embedToken(safe, turn.token))
1716
+ .catch((err) => {
1717
+ if (turn.state !== 'done' && turn.state !== 'failed') {
1718
+ turn.state = 'failed';
1719
+ }
1720
+ this._enqueuedTurns = this._enqueuedTurns.filter((t) => t !== turn);
1721
+ this.emit('inject-fail', {
1722
+ err: err.message,
1723
+ msgId: msgId ?? null,
1724
+ turnId: turn.turnId,
1725
+ backend: 'tmux',
1726
+ });
1727
+ });
985
1728
 
986
- this.emit('inject-user-message', { text_len: safe.length, priority, shouldQuery });
1729
+ this.emit('inject-user-message', { text_len: safe.length, priority, shouldQuery, msgId });
987
1730
  return true;
988
1731
  }
989
1732
 
@@ -1003,6 +1746,13 @@ class TmuxProcess extends Process {
1003
1746
  this._killing = true;
1004
1747
  this.closed = true;
1005
1748
  this.drainQueue('KILLED');
1749
+ // R5: release any pending paste-confirm waiters so a `_pasteAndEnter`
1750
+ // blocked on JSONL confirmation settles instead of waiting out
1751
+ // pasteConfirmMs against a dead session. Each `finish` resolves
1752
+ // its promise and deletes its own Map entry.
1753
+ for (const finish of [...this._pasteConfirms.values()]) {
1754
+ try { finish(); } catch { /* swallow */ }
1755
+ }
1006
1756
  if (this._sessionLogTail) {
1007
1757
  try { this._sessionLogTail.close(); } catch { /* swallow */ }
1008
1758
  this._sessionLogTail = null;
@@ -1019,4 +1769,5 @@ class TmuxProcess extends Process {
1019
1769
 
1020
1770
  module.exports = {
1021
1771
  TmuxProcess,
1772
+ CLAUDE_CLI_PINNED_VERSION,
1022
1773
  };