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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/autosteered-refs.js +20 -2
- package/lib/claude-bin.js +78 -0
- package/lib/db/sessions.js +97 -1
- package/lib/handlers/autosteer.js +6 -0
- package/lib/process/tmux-process.js +967 -216
- package/lib/process-manager.js +56 -2
- package/lib/sdk/callbacks.js +219 -0
- package/lib/tmux/session-log-parser.js +302 -61
- package/lib/tmux/tmux-runner.js +59 -8
- package/package.json +1 -1
- package/polygram.js +150 -29
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
387
|
+
command: binCheck.path,
|
|
225
388
|
args,
|
|
226
389
|
envExtras: ctx.envExtras || {},
|
|
227
390
|
});
|
|
228
391
|
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
//
|
|
339
|
-
//
|
|
340
|
-
|
|
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
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
|
|
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
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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 =
|
|
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
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
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 =
|
|
656
|
+
text = turn.text;
|
|
387
657
|
} else {
|
|
388
658
|
const lateGraceMs = this.lateGraceMs ?? 1500;
|
|
389
659
|
const late = await Promise.race([
|
|
390
|
-
|
|
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 =
|
|
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
|
-
|
|
401
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
438
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
|
480
|
-
*
|
|
481
|
-
*
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
if (
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
531
|
-
//
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
|
538
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
592
|
-
//
|
|
593
|
-
//
|
|
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
|
-
//
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
|
|
599
|
-
|
|
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:
|
|
1073
|
+
text: group.text,
|
|
603
1074
|
stopReason: 'last-prompt',
|
|
604
1075
|
sessionId: this.claudeSessionId,
|
|
605
|
-
};
|
|
606
|
-
|
|
607
|
-
|
|
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
|
-
|
|
634
|
-
if (READY_HINTS_RE.test(
|
|
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
|
-
|
|
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
|
-
|
|
782
|
-
|
|
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
|
|
937
|
-
*
|
|
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
|
-
|
|
941
|
-
|
|
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
|
-
|
|
944
|
-
const
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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.
|
|
955
|
-
|
|
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
|
|
960
|
-
* surface via 'inject-fail'
|
|
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
|
-
//
|
|
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
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
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
|
};
|