@rynx-ai/runtime 0.1.9 → 0.1.10-beta.3

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.
@@ -2,6 +2,7 @@
2
2
  /** Standalone Claude hook adapter. Observer hooks append framing events; blocking
3
3
  * interaction hooks rendezvous with the runner through the session bridge. */
4
4
  import { createHash, randomUUID } from "node:crypto";
5
+ import { realpathSync } from "node:fs";
5
6
  import { claimInteractionResult, recordInteractionAck, recordHookEvent, recordInteractionRequest, removeClaimedInteractionResult, removeInteractionLease, touchInteractionLease, } from "./native-bridge.js";
6
7
  import { boundInteractionRequest, redactInteractionResolution, } from "../interactions.js";
7
8
  function argValue(argv, flag) {
@@ -87,11 +88,48 @@ function questionRequest(id, toolInput) {
87
88
  };
88
89
  }
89
90
  function permissionSuggestions(payload) {
90
- return Array.isArray(payload.permission_suggestions)
91
+ const suggestions = Array.isArray(payload.permission_suggestions)
91
92
  ? payload.permission_suggestions
92
93
  .map(asRecord)
93
94
  .filter((suggestion) => Boolean(suggestion))
94
95
  : [];
96
+ const seenFilesystemTargets = new Set();
97
+ return suggestions.filter((suggestion) => {
98
+ const key = filesystemPermissionSuggestionKey(suggestion);
99
+ if (!key)
100
+ return true;
101
+ if (seenFilesystemTargets.has(key))
102
+ return false;
103
+ seenFilesystemTargets.add(key);
104
+ return true;
105
+ });
106
+ }
107
+ function filesystemPermissionSuggestionKey(suggestion) {
108
+ const rules = Array.isArray(suggestion.rules)
109
+ ? suggestion.rules.map(asRecord).filter((rule) => Boolean(rule))
110
+ : [];
111
+ if (rules.length !== 1)
112
+ return undefined;
113
+ const toolName = asString(rules[0]?.toolName);
114
+ if (toolName !== "Read" && toolName !== "Write" && toolName !== "Edit")
115
+ return undefined;
116
+ const ruleContent = asString(rules[0]?.ruleContent);
117
+ if (!ruleContent?.endsWith("/**"))
118
+ return undefined;
119
+ try {
120
+ const target = realpathSync(ruleContent.slice(0, -3));
121
+ return JSON.stringify([
122
+ asString(suggestion.type),
123
+ asString(suggestion.behavior),
124
+ asString(suggestion.destination),
125
+ asString(suggestion.mode),
126
+ toolName,
127
+ target,
128
+ ]);
129
+ }
130
+ catch {
131
+ return undefined;
132
+ }
95
133
  }
96
134
  function permissionSuggestionLabel(suggestion, index, total) {
97
135
  const rules = Array.isArray(suggestion.rules)
@@ -134,11 +172,10 @@ function permissionSuggestionLabel(suggestion, index, total) {
134
172
  function permissionSuggestionActionId(index) {
135
173
  return `allow_suggestion_${index}`;
136
174
  }
137
- function permissionRequest(id, payload, toolInput) {
175
+ function permissionRequest(id, payload, toolInput, suggestions) {
138
176
  const toolName = asString(payload.tool_name) ?? "tool";
139
177
  const command = asString(toolInput.command) ?? asString(toolInput.file_path);
140
178
  const cwd = asString(payload.cwd);
141
- const suggestions = permissionSuggestions(payload);
142
179
  return {
143
180
  interactionId: id,
144
181
  kind: "permission",
@@ -178,7 +215,7 @@ async function waitForResult(bridgeDir, id) {
178
215
  function answerText(value) {
179
216
  return Array.isArray(value) ? value.join(", ") : value ?? "";
180
217
  }
181
- function nativeVerdict(hookKind, payload, result) {
218
+ function nativeVerdict(hookKind, payload, result, suggestions) {
182
219
  const toolInput = asRecord(payload.tool_input) ?? {};
183
220
  const toolName = asString(payload.tool_name) ?? "tool";
184
221
  if (result.status === "cancelled") {
@@ -224,7 +261,6 @@ function nativeVerdict(hookKind, payload, result) {
224
261
  },
225
262
  };
226
263
  }
227
- const suggestions = permissionSuggestions(payload);
228
264
  const suggestionMatch = /^allow_suggestion_(\d+)$/.exec(resolution.actionId);
229
265
  const suggestionIndex = suggestionMatch ? Number(suggestionMatch[1]) : -1;
230
266
  const selectedSuggestion = Number.isSafeInteger(suggestionIndex)
@@ -274,16 +310,17 @@ async function main() {
274
310
  }
275
311
  const id = interactionId(payload);
276
312
  const toolInput = asRecord(payload.tool_input) ?? {};
313
+ const suggestions = permissionSuggestions(payload);
277
314
  const request = asString(payload.tool_name) === "AskUserQuestion"
278
315
  ? questionRequest(id, toolInput)
279
- : permissionRequest(id, payload, toolInput);
316
+ : permissionRequest(id, payload, toolInput, suggestions);
280
317
  try {
281
318
  const bounded = boundInteractionRequest(request);
282
319
  if (!bounded.ok) {
283
320
  await writeStdout(nativeVerdict(hookKind, payload, {
284
321
  status: "cancelled",
285
322
  reason: bounded.reason,
286
- }));
323
+ }, suggestions));
287
324
  return;
288
325
  }
289
326
  touchInteractionLease(bridgeDir, id);
@@ -295,7 +332,7 @@ async function main() {
295
332
  request: bounded.request,
296
333
  });
297
334
  const result = await waitForResult(bridgeDir, id);
298
- const verdict = nativeVerdict(hookKind, payload, result);
335
+ const verdict = nativeVerdict(hookKind, payload, result, suggestions);
299
336
  // Claiming the response is the interaction commit point: it records what
300
337
  // the user answered, while the later tool/Turn events record whether Claude
301
338
  // could act on it. ACK before stdout is required because Claude may reap a
@@ -96,6 +96,17 @@ export declare class ClaudeLiveSession {
96
96
  private stopPendingAt;
97
97
  /** Open tool-call ids (start seen, end not yet) — suppresses the idle backstop. */
98
98
  private readonly openToolIds;
99
+ /** Transcript-backed acknowledgements for web→TUI input. A direct idle
100
+ * submit is recorded as `promptSource:"typed"`; a submit while Claude is busy
101
+ * is recorded immediately as `queue-operation:enqueue` and only later
102
+ * promoted to `promptSource:"queued"`. The promotion is intentionally not a
103
+ * second acknowledgement. */
104
+ private submissionSequence;
105
+ private readonly submissionObservations;
106
+ /** Enqueued type-ahead inputs awaiting their eventual role:user promotion.
107
+ * Claude has used both `promptSource:"queued"` and `"sdk"` for that later
108
+ * record, so content correlation is the stable discriminator. */
109
+ private readonly pendingQueuedSubmissions;
99
110
  /** Blocking native questions/permissions. Their hook subprocess is still
100
111
  * executing, so the Turn remains running and cannot be idle-closed. */
101
112
  private readonly pendingInteractions;
@@ -120,11 +131,16 @@ export declare class ClaudeLiveSession {
120
131
  * a turn's usage on close. undefined until the statusLine hook first fires. */
121
132
  private latestStatus?;
122
133
  private deltasOffset;
123
- /** MessageDisplay message ids in first-seen order, FIFO-mapped onto the
134
+ /** Finalized MessageDisplay ids in completion order, FIFO-mapped onto the
124
135
  * transcript's assistant-text records so a streamed message and its final
125
136
  * item share an itemId (message_id is absent from the transcript). */
126
137
  private readonly messageIdQueue;
127
- private readonly seenMessageIds;
138
+ private readonly finalizedMessageIds;
139
+ /** Transcript messages that arrived before their MessageDisplay stream. A
140
+ * matching late final consumes both sides so its id cannot shift the FIFO
141
+ * used by the next assistant message. */
142
+ private readonly unmatchedTranscriptMessages;
143
+ private readonly streamedMessageText;
128
144
  constructor(opts: ClaudeLiveSessionOptions);
129
145
  /**
130
146
  * Restore the durable forwarder cursor for `transcriptPath` (reference implementation's
@@ -139,6 +155,17 @@ export declare class ClaudeLiveSession {
139
155
  private persistForwardState;
140
156
  /** True once SessionStart bound the transcript (or a resume path was given). */
141
157
  isReady(): boolean;
158
+ /** Drain records that predate a new injection, then return a monotonic
159
+ * checkpoint. This prevents a lagging, older identical prompt from
160
+ * acknowledging the new submit. */
161
+ beginSubmissionObservation(): number;
162
+ /** Whether Claude durably accepted `text` after `checkpoint`. Polling the
163
+ * transcript here makes submit confirmation independent of the background
164
+ * forwarder interval and covers both idle and type-ahead submissions. */
165
+ hasObservedSubmissionAfter(checkpoint: number, text: string): boolean;
166
+ private rememberSubmission;
167
+ private rememberQueuedSubmission;
168
+ private consumeQueuedPromotion;
142
169
  start(): void;
143
170
  stop(): void;
144
171
  /** Phase two of runner shutdown. The caller must stop the Claude terminal
@@ -213,6 +240,7 @@ export declare class ClaudeLiveSession {
213
240
  * events. Guarded on an open turn — deltas belong to the turn the user record
214
241
  * opened; the offset advances only once processed. */
215
242
  private pollDeltas;
243
+ private finalizeMessageDisplay;
216
244
  /** Refresh the latest statusLine context/cost snapshot (a last-writer-wins file
217
245
  * the statusLine hook overwrites on every TUI render). */
218
246
  private pollStatus;
@@ -222,7 +250,8 @@ export declare class ClaudeLiveSession {
222
250
  private statusUsage;
223
251
  /** Remap an assistant-text `message_completed` onto the FIFO-matched
224
252
  * MessageDisplay message_id, so its streamed deltas and this final item share
225
- * an itemId. No queued delta (MessageDisplay didn't fire) → keep the transcript id. */
253
+ * an itemId. No queued delta yet → keep the transcript id and remember its
254
+ * text so a late MessageDisplay final can be reconciled backward. */
226
255
  private remapMessageItem;
227
256
  private trackTool;
228
257
  private ensureTurn;
@@ -245,6 +274,7 @@ export declare class ClaudeLiveSession {
245
274
  isTurnOpen(): boolean;
246
275
  private closeTurn;
247
276
  private closeTurnError;
277
+ private resetMessageCorrelation;
248
278
  private maybeIdleClose;
249
279
  }
250
280
  /** The tmux-pane operations claude-native injection needs (a subset of
@@ -261,10 +291,14 @@ export interface TerminalInjector {
261
291
  export interface InjectViaTerminalOptions {
262
292
  promptGlyph?: string;
263
293
  promptTimeoutMs?: number;
264
- pasteCommitMs?: number;
265
294
  settleMs?: number;
266
- submitVerifyMs?: number;
267
- submitRetryMs?: number;
295
+ /** Transcript-backed proof that Claude accepted this exact input. When
296
+ * absent, injection keeps the legacy one-Enter best-effort contract. */
297
+ submissionObserved?: () => boolean;
298
+ /** Per-attempt wait for a transcript acknowledgement. */
299
+ submitConfirmMs?: number;
300
+ /** Total Enter attempts when transcript confirmation is available. */
301
+ maxSubmitAttempts?: number;
268
302
  pollMs?: number;
269
303
  now?: () => number;
270
304
  sleep?: (ms: number) => Promise<void>;
@@ -274,13 +308,13 @@ export interface InjectViaTerminalOptions {
274
308
  }
275
309
  /**
276
310
  * Deliver `text` into a claude TUI pane, the reference implementation recipe:
277
- * ready-gate (poll for `❯`) → clear leftover → bracketed paste (+ trailing
278
- * newline) wait for the draft to land → settle → submit Enter → verify the
279
- * draft left the box (re-send Enter while it hasn't).
311
+ * ready-gate (poll for `❯`) → clear leftover → bracketed paste the draft
312
+ * → settle → submit Enter → confirm from Claude's transcript.
280
313
  *
281
314
  * THROWS if the prompt never appears within the ready-gate window (reference implementation
282
315
  * `_wait_for_claude_prompt_ready` RAISE) — a not-ready pane is a hard error the
283
- * caller reports, NOT a signal to fall through to a second output path. Returns
284
- * `true` once the message is submitted (best-effort even if submit-verify times out).
316
+ * caller reports, NOT a signal to fall through to a second output path. With a
317
+ * transcript observer, Enter is retried only when Claude has durably recorded
318
+ * neither a direct user prompt nor a type-ahead enqueue.
285
319
  */
286
320
  export declare function injectViaTerminal(injector: TerminalInjector, text: string, opts?: InjectViaTerminalOptions): Promise<boolean>;
@@ -22,6 +22,8 @@ import { parseTerminalCommand, parseTranscriptRecord, readSubagentEvents, subage
22
22
  import { jsonlCursorFingerprint, interactionLeaseUpdatedAt, readClaimedInteractionResult, readClaudeStatus, readForwardState, readInteractionAcksFrom, readHookEventsFrom, readInteractionRequestsFrom, readJsonlFrom, readMessageDeltasFrom, resetForwardState, removeClaimedInteractionResult, removeInteractionLease, removeInteractionResult, scrubClaudeInteractionArtifacts, writeInteractionResult, writeForwardState, } from "./native-bridge.js";
23
23
  import { boundInteractionRequest, redactInteractionResolution, validateInteractionResolution, } from "../interactions.js";
24
24
  const MAX_SETTLED_INTERACTIONS = 512;
25
+ const MAX_MESSAGE_CORRELATION_BACKLOG = 64;
26
+ const MAX_SUBMISSION_OBSERVATIONS = 64;
25
27
  const INTERACTION_ACK_TIMEOUT_MS = 5_000;
26
28
  const INTERACTION_LEASE_TIMEOUT_MS = 30_000;
27
29
  function processIsAlive(pid) {
@@ -45,6 +47,27 @@ function userStringContent(rec) {
45
47
  return undefined; // tool_result records are arrays
46
48
  return content.trim() || undefined;
47
49
  }
50
+ function submissionText(value) {
51
+ if (typeof value !== "string")
52
+ return undefined;
53
+ return value.trim() || undefined;
54
+ }
55
+ const COMMAND_NAME_RE = /<command-name>([\s\S]*?)<\/command-name>/;
56
+ const COMMAND_ARGS_RE = /<command-args>([\s\S]*?)<\/command-args>/;
57
+ /** Claude persists slash commands as XML bookkeeping rather than as the text
58
+ * typed into the TUI. Keep the raw record (for literal XML prompts) and add the
59
+ * reconstructed command as a submission-confirmation alias. */
60
+ function submissionCandidates(content) {
61
+ const candidates = [content];
62
+ const name = COMMAND_NAME_RE.exec(content)?.[1]?.trim();
63
+ if (!name)
64
+ return candidates;
65
+ const args = COMMAND_ARGS_RE.exec(content)?.[1]?.trim();
66
+ const command = args ? `${name} ${args}` : name;
67
+ if (!candidates.includes(command))
68
+ candidates.push(command);
69
+ return candidates;
70
+ }
48
71
  function isRecord(value) {
49
72
  return typeof value === "object" && value !== null && !Array.isArray(value);
50
73
  }
@@ -145,6 +168,17 @@ export class ClaudeLiveSession {
145
168
  stopPendingAt = null;
146
169
  /** Open tool-call ids (start seen, end not yet) — suppresses the idle backstop. */
147
170
  openToolIds = new Set();
171
+ /** Transcript-backed acknowledgements for web→TUI input. A direct idle
172
+ * submit is recorded as `promptSource:"typed"`; a submit while Claude is busy
173
+ * is recorded immediately as `queue-operation:enqueue` and only later
174
+ * promoted to `promptSource:"queued"`. The promotion is intentionally not a
175
+ * second acknowledgement. */
176
+ submissionSequence = 0;
177
+ submissionObservations = [];
178
+ /** Enqueued type-ahead inputs awaiting their eventual role:user promotion.
179
+ * Claude has used both `promptSource:"queued"` and `"sdk"` for that later
180
+ * record, so content correlation is the stable discriminator. */
181
+ pendingQueuedSubmissions = [];
148
182
  /** Blocking native questions/permissions. Their hook subprocess is still
149
183
  * executing, so the Turn remains running and cannot be idle-closed. */
150
184
  pendingInteractions = new Map();
@@ -169,11 +203,16 @@ export class ClaudeLiveSession {
169
203
  * a turn's usage on close. undefined until the statusLine hook first fires. */
170
204
  latestStatus;
171
205
  deltasOffset = 0;
172
- /** MessageDisplay message ids in first-seen order, FIFO-mapped onto the
206
+ /** Finalized MessageDisplay ids in completion order, FIFO-mapped onto the
173
207
  * transcript's assistant-text records so a streamed message and its final
174
208
  * item share an itemId (message_id is absent from the transcript). */
175
209
  messageIdQueue = [];
176
- seenMessageIds = new Set();
210
+ finalizedMessageIds = new Set();
211
+ /** Transcript messages that arrived before their MessageDisplay stream. A
212
+ * matching late final consumes both sides so its id cannot shift the FIFO
213
+ * used by the next assistant message. */
214
+ unmatchedTranscriptMessages = [];
215
+ streamedMessageText = new Map();
177
216
  constructor(opts) {
178
217
  this.bridgeDir = opts.bridgeDir;
179
218
  this.sink = opts.sink;
@@ -231,6 +270,48 @@ export class ClaudeLiveSession {
231
270
  isReady() {
232
271
  return this.transcriptPath !== undefined;
233
272
  }
273
+ /** Drain records that predate a new injection, then return a monotonic
274
+ * checkpoint. This prevents a lagging, older identical prompt from
275
+ * acknowledging the new submit. */
276
+ beginSubmissionObservation() {
277
+ this.pollTranscript();
278
+ return this.submissionSequence;
279
+ }
280
+ /** Whether Claude durably accepted `text` after `checkpoint`. Polling the
281
+ * transcript here makes submit confirmation independent of the background
282
+ * forwarder interval and covers both idle and type-ahead submissions. */
283
+ hasObservedSubmissionAfter(checkpoint, text) {
284
+ this.pollTranscript();
285
+ const expected = submissionText(text);
286
+ return expected !== undefined && this.submissionObservations.some((observation) => observation.sequence > checkpoint && observation.text === expected);
287
+ }
288
+ rememberSubmission(value) {
289
+ const text = submissionText(value);
290
+ if (!text)
291
+ return;
292
+ this.submissionSequence += 1;
293
+ this.submissionObservations.push({ sequence: this.submissionSequence, text });
294
+ if (this.submissionObservations.length > MAX_SUBMISSION_OBSERVATIONS) {
295
+ this.submissionObservations.shift();
296
+ }
297
+ }
298
+ rememberQueuedSubmission(value) {
299
+ const text = submissionText(value);
300
+ if (!text)
301
+ return;
302
+ this.rememberSubmission(text);
303
+ this.pendingQueuedSubmissions.push(text);
304
+ if (this.pendingQueuedSubmissions.length > MAX_SUBMISSION_OBSERVATIONS) {
305
+ this.pendingQueuedSubmissions.shift();
306
+ }
307
+ }
308
+ consumeQueuedPromotion(text) {
309
+ const index = this.pendingQueuedSubmissions.indexOf(text);
310
+ if (index < 0)
311
+ return false;
312
+ this.pendingQueuedSubmissions.splice(index, 1);
313
+ return true;
314
+ }
234
315
  start() {
235
316
  if (this.started)
236
317
  return;
@@ -266,12 +347,13 @@ export class ClaudeLiveSession {
266
347
  /** One poll cycle (hooks → transcript → idle backstop). Exposed for tests to
267
348
  * drive deterministically; the async {@link loop} just calls it on an interval. */
268
349
  tick() {
269
- // Order matters: pollDeltas PUSHes a message_id onto the FIFO queue before
270
- // pollTranscript SHIFTs it for the final item — else a same-tick delta+record
271
- // (claude flushing a short message as one chunk) would miss the correlation.
350
+ // Read the transcript first so a user record opens its Turn before any
351
+ // same-tick MessageDisplay chunks are handled. The correlation layer
352
+ // supports either completion order; this also lets pollDeltas safely drain
353
+ // late chunks while no Turn is open instead of leaking them into the next.
272
354
  this.pollHooks();
273
- this.pollDeltas();
274
355
  this.pollTranscript();
356
+ this.pollDeltas();
275
357
  this.pollInteractions();
276
358
  this.drainInteractionCommits();
277
359
  this.pollAbandonedInteractions();
@@ -369,6 +451,11 @@ export class ClaudeLiveSession {
369
451
  }
370
452
  return;
371
453
  }
454
+ // Drain the old transcript before a SessionStart switches paths so a slash
455
+ // command recorded immediately before /clear or /fork can still
456
+ // acknowledge the web submission.
457
+ if (ev.transcriptPath !== this.transcriptPath)
458
+ this.pollTranscript();
372
459
  // Same session id (a plain re-announce) → nothing to do.
373
460
  if (!ev.sessionId || ev.sessionId === this.currentClaudeSessionId)
374
461
  return;
@@ -396,8 +483,8 @@ export class ClaudeLiveSession {
396
483
  this.transcriptOffset = atEof ? fileSize(newPath) : 0;
397
484
  this.seenSubagents.clear();
398
485
  this.todos.clear();
399
- this.seenMessageIds.clear();
400
- this.messageIdQueue.length = 0;
486
+ this.pendingQueuedSubmissions.length = 0;
487
+ this.resetMessageCorrelation();
401
488
  this.latestStatus = undefined;
402
489
  // Fresh transcript → drop the old cursor + seen ids (a new session has fresh
403
490
  // record uuids, so no collision); the next poll seeds a new forwarder state.
@@ -689,6 +776,25 @@ export class ClaudeLiveSession {
689
776
  handleRecord(rec) {
690
777
  if (rec.isSidechain === true)
691
778
  return; // sub-agent turns (Phase 9 forwards them)
779
+ if (rec.isMeta === true)
780
+ return; // Claude-generated UI metadata, not a human message
781
+ // Type-ahead is acknowledged at enqueue time. Its later
782
+ // `promptSource:"queued"` user record must not count again: another
783
+ // identical web message may already be awaiting its own acknowledgement.
784
+ if (rec.type === "queue-operation") {
785
+ if (rec.operation === "enqueue")
786
+ this.rememberQueuedSubmission(rec.content);
787
+ // `dequeue` has no content and is followed by the promoted user record,
788
+ // so it must retain the correlation. `remove(content)` is cancellation:
789
+ // remove exactly one matching pending entry so a later identical prompt
790
+ // cannot be mistaken for that cancelled promotion.
791
+ else if (rec.operation === "remove") {
792
+ const removed = submissionText(rec.content);
793
+ if (removed)
794
+ this.consumeQueuedPromotion(removed);
795
+ }
796
+ return;
797
+ }
692
798
  // Secondary dedup: skip a record whose source id was already forwarded (a
693
799
  // re-read after a fingerprint reset). rynx emits a record's items atomically,
694
800
  // so the record uuid is the source key (vs reference implementation's per-block key for its
@@ -701,6 +807,19 @@ export class ClaudeLiveSession {
701
807
  }
702
808
  const content = userStringContent(rec);
703
809
  if (content !== undefined) {
810
+ const candidates = submissionCandidates(content);
811
+ const queuedPromotion = rec.promptSource !== "typed" &&
812
+ candidates.some((candidate) => this.consumeQueuedPromotion(candidate));
813
+ // Submission acknowledgement is independent of UI classification. A
814
+ // legitimate web prompt can begin with "<" (for example HTML) even
815
+ // though the mirror below treats provider-generated XML markers as
816
+ // bookkeeping. Exact content + checkpoint matching keeps this safe.
817
+ if (!queuedPromotion &&
818
+ rec.promptSource !== "queued" &&
819
+ rec.promptSource !== "sdk") {
820
+ for (const candidate of candidates)
821
+ this.rememberSubmission(candidate);
822
+ }
704
823
  // A local `!` command records its input+output as `<bash-*>` markers — mirror
705
824
  // it as a self-contained terminal_command turn (before the prompt check, as
706
825
  // it too is `<`-prefixed).
@@ -801,24 +920,43 @@ export class ClaudeLiveSession {
801
920
  * events. Guarded on an open turn — deltas belong to the turn the user record
802
921
  * opened; the offset advances only once processed. */
803
922
  pollDeltas() {
804
- if (!this.turnOpen)
805
- return;
806
923
  const { deltas, nextOffset } = readMessageDeltasFrom(this.bridgeDir, this.deltasOffset);
807
924
  this.deltasOffset = nextOffset;
925
+ // MessageDisplay has no Turn id. Chunks observed while idle belong to the
926
+ // Turn that just closed and must never be replayed into a later Turn.
927
+ if (!this.turnOpen)
928
+ return;
808
929
  for (const d of deltas) {
809
- if (!this.seenMessageIds.has(d.messageId)) {
810
- this.seenMessageIds.add(d.messageId);
811
- this.messageIdQueue.push(d.messageId);
812
- }
930
+ this.streamedMessageText.set(d.messageId, (this.streamedMessageText.get(d.messageId) ?? "") + d.delta);
813
931
  this.sink.onEvent({
814
932
  type: "token",
815
933
  text: d.delta,
816
- metadata: { itemId: d.messageId, dedupeAgainstCompleted: true },
934
+ metadata: {
935
+ itemId: d.messageId,
936
+ dedupeAgainstCompleted: true,
937
+ final: d.final,
938
+ },
817
939
  });
940
+ if (d.final && !this.finalizedMessageIds.has(d.messageId)) {
941
+ this.finalizedMessageIds.add(d.messageId);
942
+ this.finalizeMessageDisplay(d.messageId);
943
+ }
818
944
  }
819
945
  if (deltas.length)
820
946
  this.lastActivityAt = this.now();
821
947
  }
948
+ finalizeMessageDisplay(messageId) {
949
+ const text = this.streamedMessageText.get(messageId);
950
+ this.streamedMessageText.delete(messageId);
951
+ if (text === undefined)
952
+ return;
953
+ const transcriptIndex = this.unmatchedTranscriptMessages.indexOf(text);
954
+ if (transcriptIndex >= 0) {
955
+ this.unmatchedTranscriptMessages.splice(transcriptIndex, 1);
956
+ return;
957
+ }
958
+ this.messageIdQueue.push(messageId);
959
+ }
822
960
  /** Refresh the latest statusLine context/cost snapshot (a last-writer-wins file
823
961
  * the statusLine hook overwrites on every TUI render). */
824
962
  pollStatus() {
@@ -854,12 +992,19 @@ export class ClaudeLiveSession {
854
992
  }
855
993
  /** Remap an assistant-text `message_completed` onto the FIFO-matched
856
994
  * MessageDisplay message_id, so its streamed deltas and this final item share
857
- * an itemId. No queued delta (MessageDisplay didn't fire) → keep the transcript id. */
995
+ * an itemId. No queued delta yet → keep the transcript id and remember its
996
+ * text so a late MessageDisplay final can be reconciled backward. */
858
997
  remapMessageItem(event) {
859
998
  if (event.type !== "message_completed")
860
999
  return event;
861
1000
  const messageId = this.messageIdQueue.shift();
862
- return messageId ? { ...event, itemId: messageId } : event;
1001
+ if (messageId)
1002
+ return { ...event, itemId: messageId };
1003
+ this.unmatchedTranscriptMessages.push(event.text);
1004
+ if (this.unmatchedTranscriptMessages.length > MAX_MESSAGE_CORRELATION_BACKLOG) {
1005
+ this.unmatchedTranscriptMessages.shift();
1006
+ }
1007
+ return event;
863
1008
  }
864
1009
  trackTool(event) {
865
1010
  if (event.type !== "tool")
@@ -929,6 +1074,7 @@ export class ClaudeLiveSession {
929
1074
  this.openToolIds.clear();
930
1075
  this.stopSignalPending = false;
931
1076
  this.stopPendingAt = null;
1077
+ this.resetMessageCorrelation();
932
1078
  this.sink.onTurnEnd(this.statusUsage());
933
1079
  }
934
1080
  closeTurnError(error) {
@@ -942,8 +1088,15 @@ export class ClaudeLiveSession {
942
1088
  this.openToolIds.clear();
943
1089
  this.stopSignalPending = false;
944
1090
  this.stopPendingAt = null;
1091
+ this.resetMessageCorrelation();
945
1092
  this.sink.onTurnError(error);
946
1093
  }
1094
+ resetMessageCorrelation() {
1095
+ this.finalizedMessageIds.clear();
1096
+ this.messageIdQueue.length = 0;
1097
+ this.unmatchedTranscriptMessages.length = 0;
1098
+ this.streamedMessageText.clear();
1099
+ }
947
1100
  maybeIdleClose() {
948
1101
  if (!this.turnOpen ||
949
1102
  this.openToolIds.size > 0 ||
@@ -964,20 +1117,15 @@ export class ClaudeLiveSession {
964
1117
  }
965
1118
  /** Claude Code renders this glyph once the input box is mounted (ready-gate). */
966
1119
  const CLAUDE_PROMPT_GLYPH = "❯";
967
- const DRAFT_NEEDLE_MAX = 24;
968
- function submitNeedle(text) {
969
- return (text.split("\n")[0] ?? "").slice(0, DRAFT_NEEDLE_MAX);
970
- }
971
- /** Whether the pasted draft is visibly sitting in the input box (the last line
972
- * carrying the prompt glyph). Mirrors reference implementation's `_draft_in_input_box`. */
973
- function draftInBox(pane, glyph, needle) {
974
- const glyphLines = pane.split("\n").filter((l) => l.includes(glyph));
975
- if (glyphLines.length === 0)
976
- return false;
977
- const tail = glyphLines[glyphLines.length - 1].split(glyph).pop() ?? "";
978
- if (tail.includes("[Pasted text"))
979
- return true; // claude's large-paste placeholder
980
- return needle.length > 0 && tail.includes(needle);
1120
+ const CLAUDE_PROMPT_SCAN_TAIL_LINES = 5;
1121
+ /** The live composer sits at the bottom of the pane. Restricting readiness to
1122
+ * its trailing non-empty lines prevents an old prompt glyph in scrollback from
1123
+ * accepting input while Claude is still booting or showing another screen. */
1124
+ function claudePromptRendered(pane, glyph) {
1125
+ const nonEmpty = pane.split(/\r?\n/).filter((line) => line.trim());
1126
+ return nonEmpty
1127
+ .slice(-CLAUDE_PROMPT_SCAN_TAIL_LINES)
1128
+ .some((line) => line.includes(glyph));
981
1129
  }
982
1130
  async function pollUntil(pred, timeoutMs, pollMs, now, sleep, signal) {
983
1131
  const deadline = now() + timeoutMs;
@@ -992,14 +1140,14 @@ async function pollUntil(pred, timeoutMs, pollMs, now, sleep, signal) {
992
1140
  }
993
1141
  /**
994
1142
  * Deliver `text` into a claude TUI pane, the reference implementation recipe:
995
- * ready-gate (poll for `❯`) → clear leftover → bracketed paste (+ trailing
996
- * newline) wait for the draft to land → settle → submit Enter → verify the
997
- * draft left the box (re-send Enter while it hasn't).
1143
+ * ready-gate (poll for `❯`) → clear leftover → bracketed paste the draft
1144
+ * → settle → submit Enter → confirm from Claude's transcript.
998
1145
  *
999
1146
  * THROWS if the prompt never appears within the ready-gate window (reference implementation
1000
1147
  * `_wait_for_claude_prompt_ready` RAISE) — a not-ready pane is a hard error the
1001
- * caller reports, NOT a signal to fall through to a second output path. Returns
1002
- * `true` once the message is submitted (best-effort even if submit-verify times out).
1148
+ * caller reports, NOT a signal to fall through to a second output path. With a
1149
+ * transcript observer, Enter is retried only when Claude has durably recorded
1150
+ * neither a direct user prompt nor a type-ahead enqueue.
1003
1151
  */
1004
1152
  export async function injectViaTerminal(injector, text, opts = {}) {
1005
1153
  const glyph = opts.promptGlyph ?? CLAUDE_PROMPT_GLYPH;
@@ -1018,39 +1166,43 @@ export async function injectViaTerminal(injector, text, opts = {}) {
1018
1166
  };
1019
1167
  // 1. Ready-gate. No prompt within the window → THROW (reference implementation RAISE): a
1020
1168
  // not-ready pane is a hard error, never a fall-through-to-run signal.
1021
- const ready = await pollUntil(() => injector.capturePane().includes(glyph), opts.promptTimeoutMs ?? 20_000, pollMs, now, sleep, signal);
1169
+ const ready = await pollUntil(() => claudePromptRendered(injector.capturePane(), glyph), opts.promptTimeoutMs ?? 20_000, pollMs, now, sleep, signal);
1022
1170
  if (cancelled())
1023
1171
  return false;
1024
1172
  if (!ready) {
1025
1173
  const tail = injector.capturePane().split("\n").slice(-5).join("\n");
1026
1174
  throw new Error(`claude prompt not ready (no "${glyph}" within timeout)\npane tail:\n${tail}`);
1027
1175
  }
1028
- // 2. Clear leftover, then bracketed-paste the draft (+\n absorbs a trailing "\").
1176
+ // 2. Clear leftover, then bracketed-paste the draft. A final "\" is Claude's
1177
+ // documented soft-newline escape: a bare submit Enter would consume it and
1178
+ // mutate/strand the message. Only that edge gets one sentinel newline inside
1179
+ // the bracketed paste; normal messages must not gain an empty input row.
1029
1180
  injector.clearInputLine();
1030
- injector.paste(`${text}\n`);
1031
- // 3. Wait for the draft to visibly land, then a short settle.
1032
- const needle = submitNeedle(text);
1033
- const draftSeen = await pollUntil(() => draftInBox(injector.capturePane(), glyph, needle), opts.pasteCommitMs ?? 5_000, pollMs, now, sleep, signal);
1034
- await sleep(opts.settleMs ?? 100);
1181
+ injector.paste(text.endsWith("\\") ? `${text}\n` : text);
1182
+ // 3. `paste-buffer` returns after tmux wrote the bracketed-paste frame. Give
1183
+ // Claude a short, fixed settle window before the separate submit key; screen
1184
+ // text is not used as an acknowledgement because submitted prompts remain in
1185
+ // scrollback and are indistinguishable from an editable draft.
1186
+ await sleep(opts.settleMs ?? 500);
1035
1187
  if (cancelled())
1036
1188
  return false; // Stop pressed mid-paste → don't submit
1037
- // 4. Submit.
1038
- injector.sendEnter();
1039
- if (!draftSeen)
1040
- return true; // draft never identifiable submit blind (reference implementation)
1041
- // 5. Verify the submit took; re-send Enter while the draft is still boxed.
1042
- const deadline = now() + (opts.submitVerifyMs ?? 10_000);
1043
- let lastEnter = now();
1044
- while (now() < deadline) {
1045
- if (cancelled())
1046
- return false; // Stop pressed → stop re-submitting, clear the box
1047
- await sleep(pollMs);
1048
- if (!draftInBox(injector.capturePane(), glyph, needle))
1189
+ // 4. Submit. Without a transcript observer this remains a one-Enter
1190
+ // best-effort operation. With one, a bounded retry handles an Enter swallowed
1191
+ // by the TUI without ever consulting stale pane text. A direct prompt or
1192
+ // queue enqueue ends the loop immediately.
1193
+ const observed = opts.submissionObserved;
1194
+ const attempts = observed
1195
+ ? Math.max(1, Math.floor(opts.maxSubmitAttempts ?? 2))
1196
+ : 1;
1197
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
1198
+ injector.sendEnter();
1199
+ if (!observed)
1200
+ return true;
1201
+ if (await pollUntil(observed, opts.submitConfirmMs ?? 2_000, pollMs, now, sleep, signal)) {
1049
1202
  return true;
1050
- if (now() - lastEnter >= (opts.submitRetryMs ?? 1_000)) {
1051
- injector.sendEnter();
1052
- lastEnter = now();
1053
1203
  }
1204
+ if (cancelled())
1205
+ return false;
1054
1206
  }
1055
- return true;
1207
+ return false;
1056
1208
  }