@rynx-ai/runtime 0.1.0 → 0.1.10-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/claude/executor.d.ts +3 -5
  2. package/dist/claude/executor.js +3 -5
  3. package/dist/claude/native-bridge.d.ts +74 -17
  4. package/dist/claude/native-bridge.js +225 -30
  5. package/dist/claude/native-hook-main.js +327 -38
  6. package/dist/claude/native-hooks.d.ts +3 -2
  7. package/dist/claude/native-hooks.js +15 -6
  8. package/dist/claude/native-integration.d.ts +123 -16
  9. package/dist/claude/native-integration.js +624 -81
  10. package/dist/claude/settings.d.ts +8 -0
  11. package/dist/claude/settings.js +50 -0
  12. package/dist/claude/transcript.d.ts +2 -2
  13. package/dist/claude/transcript.js +14 -3
  14. package/dist/codex/rollout-synth.d.ts +8 -3
  15. package/dist/codex/rollout-synth.js +65 -32
  16. package/dist/codex-app-server/client.d.ts +27 -40
  17. package/dist/codex-app-server/client.js +1134 -99
  18. package/dist/codex-app-server/forwarder.d.ts +36 -10
  19. package/dist/codex-app-server/forwarder.js +146 -28
  20. package/dist/codex-app-server/mapping.d.ts +1 -1
  21. package/dist/codex-app-server/mapping.js +64 -5
  22. package/dist/codex-app-server/protocol.d.ts +269 -4
  23. package/dist/codex-app-server/transport.d.ts +20 -5
  24. package/dist/codex-app-server/transport.js +93 -40
  25. package/dist/codex-app-server/ws-channel.d.ts +3 -3
  26. package/dist/codex-app-server/ws-channel.js +23 -7
  27. package/dist/codex-child-env.js +33 -0
  28. package/dist/codex-home.d.ts +16 -6
  29. package/dist/codex-home.js +46 -15
  30. package/dist/codex-session-store.d.ts +2 -1
  31. package/dist/host.d.ts +38 -38
  32. package/dist/host.js +626 -121
  33. package/dist/index.d.ts +4 -3
  34. package/dist/index.js +1 -1
  35. package/dist/input-resources.d.ts +13 -0
  36. package/dist/input-resources.js +67 -0
  37. package/dist/interactions.d.ts +61 -0
  38. package/dist/interactions.js +236 -0
  39. package/dist/models-catalog.d.ts +5 -13
  40. package/dist/models-catalog.js +60 -9
  41. package/dist/runner/child.d.ts +9 -1
  42. package/dist/runner/child.js +100 -19
  43. package/dist/runner/manager.d.ts +79 -11
  44. package/dist/runner/manager.js +423 -43
  45. package/dist/runner/protocol.d.ts +30 -11
  46. package/dist/runner-main.js +9 -6
  47. package/dist/runtime-status.js +1 -1
  48. package/dist/terminal/claude-tui.d.ts +8 -3
  49. package/dist/terminal/claude-tui.js +6 -2
  50. package/dist/terminal/codex-tui.d.ts +3 -3
  51. package/dist/terminal/codex-tui.js +1 -1
  52. package/dist/terminal/registry.d.ts +1 -1
  53. package/dist/terminal/registry.js +1 -1
  54. package/dist/terminal/tmux.d.ts +6 -6
  55. package/dist/terminal/tmux.js +10 -10
  56. package/package.json +8 -3
@@ -8,7 +8,7 @@
8
8
  * session id (discovery); `Stop`/`StopFailure` CLOSE the turn (authoritative).
9
9
  * - the transcript JSONL — the turn OPENS on a `role:user` prompt record and its
10
10
  * assistant/tool records become {@link AgentEvent}s. (Turn framing is
11
- * transcript-open + hook-close, matching the omnigent re-audit; `running` is
11
+ * transcript-open + hook-close, matching the reference implementation re-audit; `running` is
12
12
  * implicit between open and close, so no PTY watcher is needed.)
13
13
  *
14
14
  * The mapped events drive the SAME per-turn {@link SessionNormalizer} sink shape
@@ -19,7 +19,23 @@
19
19
  */
20
20
  import { statSync } from "node:fs";
21
21
  import { parseTerminalCommand, parseTranscriptRecord, readSubagentEvents, subagentTranscriptPath, transcriptHasForkedFrom, } from "./transcript.js";
22
- import { jsonlCursorFingerprint, readClaudeStatus, readForwardState, readHookEventsFrom, readJsonlFrom, readMessageDeltasFrom, resetForwardState, writeForwardState, } from "./native-bridge.js";
22
+ import { jsonlCursorFingerprint, interactionLeaseUpdatedAt, readClaimedInteractionResult, readClaudeStatus, readForwardState, readInteractionAcksFrom, readHookEventsFrom, readInteractionRequestsFrom, readJsonlFrom, readMessageDeltasFrom, resetForwardState, removeClaimedInteractionResult, removeInteractionLease, removeInteractionResult, scrubClaudeInteractionArtifacts, writeInteractionResult, writeForwardState, } from "./native-bridge.js";
23
+ import { boundInteractionRequest, redactInteractionResolution, validateInteractionResolution, } from "../interactions.js";
24
+ const MAX_SETTLED_INTERACTIONS = 512;
25
+ const MAX_MESSAGE_CORRELATION_BACKLOG = 64;
26
+ const MAX_SUBMISSION_OBSERVATIONS = 64;
27
+ const INTERACTION_ACK_TIMEOUT_MS = 5_000;
28
+ const INTERACTION_LEASE_TIMEOUT_MS = 30_000;
29
+ function processIsAlive(pid) {
30
+ try {
31
+ process.kill(pid, 0);
32
+ return true;
33
+ }
34
+ catch (error) {
35
+ // A process that exists but belongs to another user is still alive.
36
+ return error.code === "EPERM";
37
+ }
38
+ }
23
39
  /** The trimmed string content of a `role:user` record, or undefined for
24
40
  * tool_result (array-content) records. Keeps XML-marker bookkeeping records
25
41
  * (`<command-name>…`, `<bash-input>…`, `<caveat>…`) — the caller classifies each. */
@@ -31,6 +47,27 @@ function userStringContent(rec) {
31
47
  return undefined; // tool_result records are arrays
32
48
  return content.trim() || undefined;
33
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
+ }
34
71
  function isRecord(value) {
35
72
  return typeof value === "object" && value !== null && !Array.isArray(value);
36
73
  }
@@ -102,9 +139,13 @@ export class ClaudeLiveSession {
102
139
  idleCloseMs;
103
140
  stopGraceMs;
104
141
  now;
142
+ isProcessAlive;
143
+ leaseUpdatedAt;
105
144
  started = false;
106
145
  stopped = false;
107
146
  hooksOffset = 0;
147
+ interactionsOffset = 0;
148
+ interactionAcksOffset = 0;
108
149
  transcriptOffset = 0;
109
150
  /** Secondary dedup: source ids (record uuids) already forwarded, so a re-read
110
151
  * (fingerprint reset / mid-poll death) doesn't re-emit. Persisted in the
@@ -119,11 +160,40 @@ export class ClaudeLiveSession {
119
160
  seenClaudeSessionIds = new Set();
120
161
  turnOpen = false;
121
162
  currentTurnId;
163
+ /** The Turn opened from a pre-transcript interaction. Its unique provisional
164
+ * id keeps responses distinct until the real prompt uuid can be adopted. */
165
+ syntheticTurn = false;
122
166
  lastActivityAt = 0;
123
167
  /** When the Stop hook fired (null = not yet) — drives the grace-period close. */
124
168
  stopPendingAt = null;
125
169
  /** Open tool-call ids (start seen, end not yet) — suppresses the idle backstop. */
126
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 = [];
182
+ /** Blocking native questions/permissions. Their hook subprocess is still
183
+ * executing, so the Turn remains running and cannot be idle-closed. */
184
+ pendingInteractions = new Map();
185
+ settledInteractions = new Set();
186
+ /** Claimed response files may still contain the provider-bound, unredacted
187
+ * answer. Keep tracking them after canonical settlement until ACK/hook cleanup. */
188
+ claimedInteractions = new Map();
189
+ scheduledClaimScrubs = new Set();
190
+ /** Stop is flushed after interactions in the same poll, preventing a
191
+ * request+Stop race from briefly reporting this executing Turn as idle. */
192
+ stopSignalPending = false;
193
+ /** StopFailure is likewise flushed after interactions. A blocking hook can
194
+ * append its request immediately before the failure hook lands; closing here
195
+ * would let the later interaction poll reopen an already-failed Turn. */
196
+ turnFailurePending = null;
127
197
  /** Sub-agent (Task) ids already forwarded — a Task tool_result is processed once. */
128
198
  seenSubagents = new Set();
129
199
  /** The agent's task list, keyed by task id in creation order (claude
@@ -133,11 +203,16 @@ export class ClaudeLiveSession {
133
203
  * a turn's usage on close. undefined until the statusLine hook first fires. */
134
204
  latestStatus;
135
205
  deltasOffset = 0;
136
- /** MessageDisplay message ids in first-seen order, FIFO-mapped onto the
206
+ /** Finalized MessageDisplay ids in completion order, FIFO-mapped onto the
137
207
  * transcript's assistant-text records so a streamed message and its final
138
208
  * item share an itemId (message_id is absent from the transcript). */
139
209
  messageIdQueue = [];
140
- 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();
141
216
  constructor(opts) {
142
217
  this.bridgeDir = opts.bridgeDir;
143
218
  this.sink = opts.sink;
@@ -145,6 +220,9 @@ export class ClaudeLiveSession {
145
220
  this.idleCloseMs = opts.idleCloseMs ?? 30_000;
146
221
  this.stopGraceMs = opts.stopGraceMs ?? 4_000;
147
222
  this.now = opts.now ?? (() => Date.now());
223
+ this.isProcessAlive = opts.isProcessAlive ?? processIsAlive;
224
+ this.leaseUpdatedAt = opts.interactionLeaseUpdatedAt ??
225
+ ((interactionId) => interactionLeaseUpdatedAt(this.bridgeDir, interactionId));
148
226
  this.transcriptPath = opts.transcriptPath;
149
227
  // Resume path: bind the session id + restore the persisted forwarder cursor so
150
228
  // we continue from where a prior forwarder left off (no re-mirror on relaunch).
@@ -157,7 +235,7 @@ export class ClaudeLiveSession {
157
235
  }
158
236
  }
159
237
  /**
160
- * Restore the durable forwarder cursor for `transcriptPath` (omnigent's
238
+ * Restore the durable forwarder cursor for `transcriptPath` (reference implementation's
161
239
  * `_validated_transcript_state`). A cursor for a DIFFERENT file is ignored (a new
162
240
  * session starts at 0). A matching cursor whose fingerprint still validates
163
241
  * resumes at its `byteOffset`; a MISMATCH (the file was truncated/replaced) skips
@@ -192,6 +270,48 @@ export class ClaudeLiveSession {
192
270
  isReady() {
193
271
  return this.transcriptPath !== undefined;
194
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
+ }
195
315
  start() {
196
316
  if (this.started)
197
317
  return;
@@ -199,18 +319,47 @@ export class ClaudeLiveSession {
199
319
  void this.loop();
200
320
  }
201
321
  stop() {
322
+ if (this.stopped)
323
+ return;
202
324
  this.stopped = true;
325
+ // A hook may have appended its request just before shutdown but not yet
326
+ // reached a scheduled poll. Drain it so the subprocess receives cancellation
327
+ // instead of remaining blocked until Claude's one-day hook timeout.
328
+ this.pollInteractions();
329
+ this.drainInteractionCommits();
330
+ this.cancelPendingInteractions("session_stopped");
331
+ // Runner shutdown closes its mirror transport immediately after this method
332
+ // returns, so the terminal event must be emitted synchronously here.
333
+ this.closeTurn();
334
+ this.scheduleClaimedInteractionScrubs();
335
+ }
336
+ /** Phase two of runner shutdown. The caller must stop the Claude terminal
337
+ * first so no hook can still be opening an atomically claimed answer. This is
338
+ * synchronous because the runner process exits immediately afterwards. */
339
+ finalizeStop() {
340
+ if (!this.stopped)
341
+ this.stop();
342
+ scrubClaudeInteractionArtifacts(this.bridgeDir);
343
+ this.pendingInteractions.clear();
344
+ this.claimedInteractions.clear();
345
+ this.scheduledClaimScrubs.clear();
203
346
  }
204
347
  /** One poll cycle (hooks → transcript → idle backstop). Exposed for tests to
205
348
  * drive deterministically; the async {@link loop} just calls it on an interval. */
206
349
  tick() {
207
- // Order matters: pollDeltas PUSHes a message_id onto the FIFO queue before
208
- // pollTranscript SHIFTs it for the final item — else a same-tick delta+record
209
- // (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.
210
354
  this.pollHooks();
211
- this.pollDeltas();
212
355
  this.pollTranscript();
356
+ this.pollDeltas();
357
+ this.pollInteractions();
358
+ this.drainInteractionCommits();
359
+ this.pollAbandonedInteractions();
360
+ this.flushTurnFailure();
213
361
  this.pollStatus();
362
+ this.flushStopSignal();
214
363
  this.maybeIdleClose();
215
364
  }
216
365
  async loop() {
@@ -220,16 +369,18 @@ export class ClaudeLiveSession {
220
369
  }
221
370
  catch (err) {
222
371
  // A single failing poll must NOT kill the whole forwarder — that would
223
- // desync chat↔terminal permanently. Log + continue, per omnigent's
372
+ // desync chat↔terminal permanently. Log + continue, per reference implementation's
224
373
  // per-iteration `except Exception` in the transcript forwarder loop.
225
374
  console.error("[claude-forwarder] tick failed; continuing:", err);
226
375
  }
227
376
  await new Promise((r) => setTimeout(r, this.pollMs));
228
377
  }
229
- if (this.turnOpen) {
230
- this.turnOpen = false;
231
- this.sink.onTurnEnd(this.statusUsage());
232
- }
378
+ // Catch a request appended while the loop was waking up to observe stopped.
379
+ this.pollInteractions();
380
+ this.drainInteractionCommits();
381
+ this.cancelPendingInteractions("session_stopped");
382
+ this.closeTurn();
383
+ this.scheduleClaimedInteractionScrubs();
233
384
  }
234
385
  pollHooks() {
235
386
  const { events, nextOffset } = readHookEventsFrom(this.bridgeDir, this.hooksOffset);
@@ -248,14 +399,35 @@ export class ClaudeLiveSession {
248
399
  return;
249
400
  // Stop only signals idle — it does NOT close the turn (see onIdle): closing
250
401
  // here would split a late-flushing assistant record into its own turn.
251
- if (ev.eventName === "StopFailure")
252
- this.closeTurnError(new Error("claude turn failed"));
402
+ if (ev.eventName === "StopFailure") {
403
+ this.stopSignalPending = false;
404
+ this.stopPendingAt = null;
405
+ this.turnFailurePending = new Error("claude turn failed");
406
+ }
253
407
  else {
254
- this.sink.onIdle();
408
+ this.stopSignalPending = true;
255
409
  this.stopPendingAt = this.now(); // close after a short grace (late assistant flush)
256
410
  }
257
411
  }
258
412
  }
413
+ /** Process a provider failure only after this tick has discovered every
414
+ * blocking request already appended by its hook subprocess. */
415
+ flushTurnFailure() {
416
+ const error = this.turnFailurePending;
417
+ if (!error)
418
+ return;
419
+ this.turnFailurePending = null;
420
+ this.closeTurnError(error);
421
+ }
422
+ /** Emit the Stop idle signal only after this tick has discovered native
423
+ * interactions. A pending question/permission is active execution, not idle. */
424
+ flushStopSignal() {
425
+ if (!this.stopSignalPending)
426
+ return;
427
+ this.stopSignalPending = false;
428
+ if (this.pendingInteractions.size === 0)
429
+ this.sink.onIdle();
430
+ }
259
431
  /** SessionStart drives discovery (first) and rotation (a later one with a NEW
260
432
  * session id + transcript). `/clear` → source="clear"; `/fork` → source="resume"
261
433
  * into an unseen id with a forkedFrom marker. Any other new-transcript
@@ -279,6 +451,11 @@ export class ClaudeLiveSession {
279
451
  }
280
452
  return;
281
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();
282
459
  // Same session id (a plain re-announce) → nothing to do.
283
460
  if (!ev.sessionId || ev.sessionId === this.currentClaudeSessionId)
284
461
  return;
@@ -306,8 +483,8 @@ export class ClaudeLiveSession {
306
483
  this.transcriptOffset = atEof ? fileSize(newPath) : 0;
307
484
  this.seenSubagents.clear();
308
485
  this.todos.clear();
309
- this.seenMessageIds.clear();
310
- this.messageIdQueue.length = 0;
486
+ this.pendingQueuedSubmissions.length = 0;
487
+ this.resetMessageCorrelation();
311
488
  this.latestStatus = undefined;
312
489
  // Fresh transcript → drop the old cursor + seen ids (a new session has fresh
313
490
  // record uuids, so no collision); the next poll seeds a new forwarder state.
@@ -326,12 +503,301 @@ export class ClaudeLiveSession {
326
503
  if (records.length > 0)
327
504
  this.persistForwardState();
328
505
  }
506
+ /** Tail blocking hook requests after transcript records, so an interaction
507
+ * emitted in the same poll attaches to the user Turn that caused it. */
508
+ pollInteractions() {
509
+ const { requests, nextOffset } = readInteractionRequestsFrom(this.bridgeDir, this.interactionsOffset);
510
+ this.interactionsOffset = nextOffset;
511
+ for (const entry of requests) {
512
+ const interactionId = entry.interactionId;
513
+ if (this.pendingInteractions.has(interactionId) ||
514
+ this.settledInteractions.has(interactionId)) {
515
+ continue;
516
+ }
517
+ const bounded = boundInteractionRequest(entry.request);
518
+ if (!bounded.ok) {
519
+ writeInteractionResult(this.bridgeDir, interactionId, {
520
+ status: "cancelled",
521
+ reason: bounded.reason,
522
+ });
523
+ this.rememberSettled(interactionId);
524
+ continue;
525
+ }
526
+ this.ensureTurn(`interaction_${interactionId}`);
527
+ const pending = {
528
+ request: bounded.request,
529
+ observedAt: this.now(),
530
+ ...(entry.waiterPid ? { waiterPid: entry.waiterPid } : {}),
531
+ ...(this.currentTurnId ? { turnId: this.currentTurnId } : {}),
532
+ };
533
+ this.pendingInteractions.set(interactionId, pending);
534
+ // A native hook is blocked waiting for the answer. Any Stop observed in
535
+ // this same poll cannot make the Session idle or close its Turn.
536
+ this.stopSignalPending = false;
537
+ this.stopPendingAt = null;
538
+ this.lastActivityAt = this.now();
539
+ this.sink.onInteraction({
540
+ type: "requested",
541
+ request: bounded.request,
542
+ ...(pending.turnId ? { turnId: pending.turnId } : {}),
543
+ });
544
+ }
545
+ }
546
+ drainInteractionCommits() {
547
+ this.pollInteractionAcks();
548
+ this.pollInteractionClaims();
549
+ this.pollClaimedInteractionCleanup();
550
+ }
551
+ /** A hook acknowledgement is the commit point: it is appended after the hook
552
+ * consumes the one-shot response and before it returns the native verdict. */
553
+ pollInteractionAcks() {
554
+ const { acks, nextOffset } = readInteractionAcksFrom(this.bridgeDir, this.interactionAcksOffset);
555
+ this.interactionAcksOffset = nextOffset;
556
+ for (const ack of acks) {
557
+ removeInteractionResult(this.bridgeDir, ack.interactionId);
558
+ removeClaimedInteractionResult(this.bridgeDir, ack.interactionId);
559
+ removeInteractionLease(this.bridgeDir, ack.interactionId);
560
+ this.claimedInteractions.delete(ack.interactionId);
561
+ const pending = this.pendingInteractions.get(ack.interactionId);
562
+ if (!pending || this.settledInteractions.has(ack.interactionId))
563
+ continue;
564
+ if (ack.status === "resolved") {
565
+ const invalid = validateInteractionResolution(pending.request, ack.resolution);
566
+ if (invalid) {
567
+ this.settleCancelledInteraction(ack.interactionId, pending, "native_invalid_acknowledgement");
568
+ continue;
569
+ }
570
+ this.pendingInteractions.delete(ack.interactionId);
571
+ this.rememberSettled(ack.interactionId);
572
+ this.stopSignalPending = false;
573
+ this.stopPendingAt = null;
574
+ this.lastActivityAt = this.now();
575
+ this.sink.onInteraction({
576
+ type: "resolved",
577
+ interactionId: ack.interactionId,
578
+ resolution: redactInteractionResolution(pending.request, ack.resolution),
579
+ ...(pending.turnId ? { turnId: pending.turnId } : {}),
580
+ });
581
+ continue;
582
+ }
583
+ this.pendingInteractions.delete(ack.interactionId);
584
+ this.rememberSettled(ack.interactionId);
585
+ this.sink.onInteraction({
586
+ type: "cancelled",
587
+ interactionId: ack.interactionId,
588
+ ...(ack.reason ? { reason: ack.reason } : {}),
589
+ ...(pending.turnId ? { turnId: pending.turnId } : {}),
590
+ });
591
+ }
592
+ }
593
+ /** A claimed response is already consumed by the native hook. This fallback
594
+ * converges the interaction even if the hook exits before appending its
595
+ * redacted ACK. */
596
+ pollInteractionClaims() {
597
+ for (const [interactionId, pending] of [...this.pendingInteractions]) {
598
+ const claimed = readClaimedInteractionResult(this.bridgeDir, interactionId);
599
+ if (!claimed)
600
+ continue;
601
+ removeInteractionResult(this.bridgeDir, interactionId);
602
+ this.settleClaimedInteraction(interactionId, pending, claimed);
603
+ }
604
+ }
605
+ /** A request cannot remain actionable after its blocking hook exits. A short
606
+ * post-submit timeout also converges requests created by older hooks that did
607
+ * not publish a waiter pid, and scrubs any unacknowledged secret response. */
608
+ pollAbandonedInteractions() {
609
+ const now = this.now();
610
+ for (const [interactionId, pending] of [...this.pendingInteractions]) {
611
+ const waiterExited = pending.waiterPid !== undefined &&
612
+ !this.isProcessAlive(pending.waiterPid);
613
+ const leaseAt = this.leaseUpdatedAt(interactionId) ?? pending.observedAt;
614
+ const leaseExpired = pending.waiterPid !== undefined &&
615
+ now - leaseAt >= INTERACTION_LEASE_TIMEOUT_MS;
616
+ // New hooks publish both a waiter pid and a heartbeat lease. Let their
617
+ // atomic claim decide delivery; the short ACK timeout is only a convergence
618
+ // fallback for bridge requests produced by older hook versions.
619
+ const responseUnacknowledged = pending.waiterPid === undefined &&
620
+ pending.submittedAt !== undefined &&
621
+ now - pending.submittedAt >= INTERACTION_ACK_TIMEOUT_MS;
622
+ if (!waiterExited && !leaseExpired && !responseUnacknowledged)
623
+ continue;
624
+ const reason = waiterExited
625
+ ? "native_waiter_exited"
626
+ : leaseExpired
627
+ ? "native_waiter_lease_expired"
628
+ : "native_response_unacknowledged";
629
+ this.cancelOrSettlePendingInteraction(interactionId, pending, reason,
630
+ // A dead process cannot consume a cancellation verdict; scrub its
631
+ // one-shot result instead of leaving stale response material on disk.
632
+ !waiterExited);
633
+ }
634
+ }
635
+ /** Deliver an answer to the blocked hook. The bridge file contains the real
636
+ * answer; only the canonical resolved event receives a secret-redacted copy. */
637
+ resolveInteraction(interactionId, resolution) {
638
+ const pending = this.pendingInteractions.get(interactionId);
639
+ if (!pending) {
640
+ return this.settledInteractions.has(interactionId)
641
+ ? { disposition: "already_resolved" }
642
+ : { disposition: "not_found" };
643
+ }
644
+ if (pending.waiterPid !== undefined) {
645
+ const waiterExited = !this.isProcessAlive(pending.waiterPid);
646
+ const leaseAt = this.leaseUpdatedAt(interactionId) ?? pending.observedAt;
647
+ const leaseExpired = this.now() - leaseAt >= INTERACTION_LEASE_TIMEOUT_MS;
648
+ if (waiterExited || leaseExpired) {
649
+ this.cancelOrSettlePendingInteraction(interactionId, pending, waiterExited ? "native_waiter_exited" : "native_waiter_lease_expired");
650
+ return { disposition: "already_resolved" };
651
+ }
652
+ }
653
+ if (pending.submitted)
654
+ return { disposition: "already_resolved" };
655
+ const invalid = validateInteractionResolution(pending.request, resolution);
656
+ if (invalid)
657
+ return { disposition: "invalid", message: invalid };
658
+ if (!writeInteractionResult(this.bridgeDir, interactionId, { status: "resolved", resolution })) {
659
+ return { disposition: "invalid", message: "invalid interaction id" };
660
+ }
661
+ pending.submitted = true;
662
+ pending.submittedAt = this.now();
663
+ return { disposition: "applied" };
664
+ }
665
+ cancelPendingInteractions(reason) {
666
+ for (const [interactionId, pending] of [...this.pendingInteractions]) {
667
+ this.cancelOrSettlePendingInteraction(interactionId, pending, reason);
668
+ }
669
+ }
670
+ /** Terminal cancellation may race the hook's atomic result claim. A claim
671
+ * always wins; otherwise the cancellation file becomes the hook's verdict. */
672
+ cancelOrSettlePendingInteraction(interactionId, pending, reason, deliverCancellation = true) {
673
+ const alreadyClaimed = readClaimedInteractionResult(this.bridgeDir, interactionId);
674
+ if (alreadyClaimed) {
675
+ removeInteractionResult(this.bridgeDir, interactionId);
676
+ this.settleClaimedInteraction(interactionId, pending, alreadyClaimed);
677
+ return;
678
+ }
679
+ if (deliverCancellation) {
680
+ writeInteractionResult(this.bridgeDir, interactionId, { status: "cancelled", reason });
681
+ }
682
+ else {
683
+ removeInteractionResult(this.bridgeDir, interactionId);
684
+ }
685
+ const racedClaim = readClaimedInteractionResult(this.bridgeDir, interactionId);
686
+ if (racedClaim) {
687
+ removeInteractionResult(this.bridgeDir, interactionId);
688
+ this.settleClaimedInteraction(interactionId, pending, racedClaim);
689
+ return;
690
+ }
691
+ this.settleCancelledInteraction(interactionId, pending, reason);
692
+ }
693
+ settleClaimedInteraction(interactionId, pending, result) {
694
+ this.claimedInteractions.set(interactionId, {
695
+ observedAt: this.now(),
696
+ ...(pending.waiterPid ? { waiterPid: pending.waiterPid } : {}),
697
+ });
698
+ if (result.status === "resolved") {
699
+ const invalid = validateInteractionResolution(pending.request, result.resolution);
700
+ if (!invalid) {
701
+ this.pendingInteractions.delete(interactionId);
702
+ this.rememberSettled(interactionId);
703
+ this.stopSignalPending = false;
704
+ this.stopPendingAt = null;
705
+ this.lastActivityAt = this.now();
706
+ this.sink.onInteraction({
707
+ type: "resolved",
708
+ interactionId,
709
+ resolution: redactInteractionResolution(pending.request, result.resolution),
710
+ ...(pending.turnId ? { turnId: pending.turnId } : {}),
711
+ });
712
+ return;
713
+ }
714
+ }
715
+ this.settleCancelledInteraction(interactionId, pending, result.status === "cancelled"
716
+ ? result.reason ?? "native_interaction_cancelled"
717
+ : "native_invalid_claim");
718
+ }
719
+ /** A claim is the hook's private handoff file and can contain secret answers.
720
+ * ACK/finally normally removes it; process death or an expired lease is the
721
+ * crash fallback after the canonical interaction has already settled. */
722
+ pollClaimedInteractionCleanup() {
723
+ const now = this.now();
724
+ for (const [interactionId, claimed] of [...this.claimedInteractions]) {
725
+ const result = readClaimedInteractionResult(this.bridgeDir, interactionId);
726
+ if (!result) {
727
+ this.claimedInteractions.delete(interactionId);
728
+ continue;
729
+ }
730
+ const waiterExited = claimed.waiterPid !== undefined &&
731
+ !this.isProcessAlive(claimed.waiterPid);
732
+ const leaseAt = this.leaseUpdatedAt(interactionId) ?? claimed.observedAt;
733
+ const leaseExpired = now - leaseAt >= INTERACTION_LEASE_TIMEOUT_MS;
734
+ if (!waiterExited && !leaseExpired)
735
+ continue;
736
+ removeClaimedInteractionResult(this.bridgeDir, interactionId);
737
+ removeInteractionLease(this.bridgeDir, interactionId);
738
+ this.claimedInteractions.delete(interactionId);
739
+ }
740
+ }
741
+ /** Once this forwarder stops it can no longer poll a later hook crash. Keep a
742
+ * short unref'd scrub deadline so unredacted claim material cannot persist for
743
+ * the lifetime of the daemon. */
744
+ scheduleClaimedInteractionScrubs() {
745
+ for (const interactionId of this.claimedInteractions.keys()) {
746
+ if (this.scheduledClaimScrubs.has(interactionId))
747
+ continue;
748
+ this.scheduledClaimScrubs.add(interactionId);
749
+ const timer = setTimeout(() => {
750
+ removeClaimedInteractionResult(this.bridgeDir, interactionId);
751
+ removeInteractionLease(this.bridgeDir, interactionId);
752
+ this.claimedInteractions.delete(interactionId);
753
+ this.scheduledClaimScrubs.delete(interactionId);
754
+ }, INTERACTION_LEASE_TIMEOUT_MS);
755
+ timer.unref?.();
756
+ }
757
+ }
758
+ settleCancelledInteraction(interactionId, pending, reason) {
759
+ this.pendingInteractions.delete(interactionId);
760
+ this.rememberSettled(interactionId);
761
+ this.sink.onInteraction({
762
+ type: "cancelled",
763
+ interactionId,
764
+ reason,
765
+ ...(pending.turnId ? { turnId: pending.turnId } : {}),
766
+ });
767
+ }
768
+ rememberSettled(interactionId) {
769
+ this.settledInteractions.add(interactionId);
770
+ if (this.settledInteractions.size <= MAX_SETTLED_INTERACTIONS)
771
+ return;
772
+ const oldest = this.settledInteractions.values().next().value;
773
+ if (oldest)
774
+ this.settledInteractions.delete(oldest);
775
+ }
329
776
  handleRecord(rec) {
330
777
  if (rec.isSidechain === true)
331
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
+ }
332
798
  // Secondary dedup: skip a record whose source id was already forwarded (a
333
799
  // re-read after a fingerprint reset). rynx emits a record's items atomically,
334
- // so the record uuid is the source key (vs omnigent's per-block key for its
800
+ // so the record uuid is the source key (vs reference implementation's per-block key for its
335
801
  // per-item POST). Records without a uuid fall through undeduped (rare).
336
802
  const sourceId = typeof rec.uuid === "string" && rec.uuid ? rec.uuid : undefined;
337
803
  if (sourceId) {
@@ -341,6 +807,19 @@ export class ClaudeLiveSession {
341
807
  }
342
808
  const content = userStringContent(rec);
343
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
+ }
344
823
  // A local `!` command records its input+output as `<bash-*>` markers — mirror
345
824
  // it as a self-contained terminal_command turn (before the prompt check, as
346
825
  // it too is `<`-prefixed).
@@ -351,12 +830,23 @@ export class ClaudeLiveSession {
351
830
  }
352
831
  // A real prompt (not an XML-marker bookkeeping record) opens a new turn.
353
832
  if (!content.startsWith("<")) {
354
- this.closeTurn();
355
- this.currentTurnId = typeof rec.uuid === "string" ? rec.uuid : undefined;
356
- this.turnOpen = true;
357
- this.openToolIds.clear();
358
- this.stopPendingAt = null;
359
- this.sink.onTurnStart(this.currentTurnId);
833
+ const turnId = typeof rec.uuid === "string" ? rec.uuid : undefined;
834
+ if (this.turnOpen && this.syntheticTurn) {
835
+ // A hook can flush just before the transcript's user record. The
836
+ // interaction already opened a unique provisional response; adopt the
837
+ // real turn id internally without closing/cancelling that same Turn.
838
+ this.currentTurnId = turnId;
839
+ this.syntheticTurn = false;
840
+ }
841
+ else {
842
+ this.closeTurn();
843
+ this.currentTurnId = turnId;
844
+ this.turnOpen = true;
845
+ this.syntheticTurn = false;
846
+ this.openToolIds.clear();
847
+ this.stopPendingAt = null;
848
+ this.sink.onTurnStart(this.currentTurnId);
849
+ }
360
850
  this.sink.onUserMessage(content);
361
851
  this.lastActivityAt = this.now();
362
852
  }
@@ -430,20 +920,43 @@ export class ClaudeLiveSession {
430
920
  * events. Guarded on an open turn — deltas belong to the turn the user record
431
921
  * opened; the offset advances only once processed. */
432
922
  pollDeltas() {
433
- if (!this.turnOpen)
434
- return;
435
923
  const { deltas, nextOffset } = readMessageDeltasFrom(this.bridgeDir, this.deltasOffset);
436
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;
437
929
  for (const d of deltas) {
438
- if (!this.seenMessageIds.has(d.messageId)) {
439
- this.seenMessageIds.add(d.messageId);
440
- this.messageIdQueue.push(d.messageId);
930
+ this.streamedMessageText.set(d.messageId, (this.streamedMessageText.get(d.messageId) ?? "") + d.delta);
931
+ this.sink.onEvent({
932
+ type: "token",
933
+ text: d.delta,
934
+ metadata: {
935
+ itemId: d.messageId,
936
+ dedupeAgainstCompleted: true,
937
+ final: d.final,
938
+ },
939
+ });
940
+ if (d.final && !this.finalizedMessageIds.has(d.messageId)) {
941
+ this.finalizedMessageIds.add(d.messageId);
942
+ this.finalizeMessageDisplay(d.messageId);
441
943
  }
442
- this.sink.onEvent({ type: "token", text: d.delta, metadata: { itemId: d.messageId } });
443
944
  }
444
945
  if (deltas.length)
445
946
  this.lastActivityAt = this.now();
446
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
+ }
447
960
  /** Refresh the latest statusLine context/cost snapshot (a last-writer-wins file
448
961
  * the statusLine hook overwrites on every TUI render). */
449
962
  pollStatus() {
@@ -479,12 +992,19 @@ export class ClaudeLiveSession {
479
992
  }
480
993
  /** Remap an assistant-text `message_completed` onto the FIFO-matched
481
994
  * MessageDisplay message_id, so its streamed deltas and this final item share
482
- * 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. */
483
997
  remapMessageItem(event) {
484
998
  if (event.type !== "message_completed")
485
999
  return event;
486
1000
  const messageId = this.messageIdQueue.shift();
487
- 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;
488
1008
  }
489
1009
  trackTool(event) {
490
1010
  if (event.type !== "tool")
@@ -501,9 +1021,13 @@ export class ClaudeLiveSession {
501
1021
  this.openToolIds.delete(id);
502
1022
  }
503
1023
  }
504
- ensureTurn() {
1024
+ ensureTurn(provisionalTurnId) {
505
1025
  if (this.turnOpen)
506
1026
  return;
1027
+ if (!this.currentTurnId && provisionalTurnId) {
1028
+ this.currentTurnId = provisionalTurnId;
1029
+ this.syntheticTurn = true;
1030
+ }
507
1031
  this.turnOpen = true;
508
1032
  this.sink.onTurnStart(this.currentTurnId);
509
1033
  }
@@ -527,6 +1051,8 @@ export class ClaudeLiveSession {
527
1051
  noteInterrupted() {
528
1052
  if (!this.turnOpen)
529
1053
  return;
1054
+ this.cancelPendingInteractions("turn_interrupted");
1055
+ this.stopSignalPending = false;
530
1056
  this.sink.onIdle();
531
1057
  this.stopPendingAt = this.now();
532
1058
  }
@@ -540,23 +1066,41 @@ export class ClaudeLiveSession {
540
1066
  closeTurn() {
541
1067
  if (!this.turnOpen)
542
1068
  return;
1069
+ this.drainInteractionCommits();
1070
+ this.cancelPendingInteractions("turn_completed");
543
1071
  this.turnOpen = false;
544
1072
  this.currentTurnId = undefined;
1073
+ this.syntheticTurn = false;
545
1074
  this.openToolIds.clear();
1075
+ this.stopSignalPending = false;
546
1076
  this.stopPendingAt = null;
1077
+ this.resetMessageCorrelation();
547
1078
  this.sink.onTurnEnd(this.statusUsage());
548
1079
  }
549
1080
  closeTurnError(error) {
550
1081
  if (!this.turnOpen)
551
1082
  return;
1083
+ this.drainInteractionCommits();
1084
+ this.cancelPendingInteractions("turn_failed");
552
1085
  this.turnOpen = false;
553
1086
  this.currentTurnId = undefined;
1087
+ this.syntheticTurn = false;
554
1088
  this.openToolIds.clear();
1089
+ this.stopSignalPending = false;
555
1090
  this.stopPendingAt = null;
1091
+ this.resetMessageCorrelation();
556
1092
  this.sink.onTurnError(error);
557
1093
  }
1094
+ resetMessageCorrelation() {
1095
+ this.finalizedMessageIds.clear();
1096
+ this.messageIdQueue.length = 0;
1097
+ this.unmatchedTranscriptMessages.length = 0;
1098
+ this.streamedMessageText.clear();
1099
+ }
558
1100
  maybeIdleClose() {
559
- if (!this.turnOpen || this.openToolIds.size > 0)
1101
+ if (!this.turnOpen ||
1102
+ this.openToolIds.size > 0 ||
1103
+ this.pendingInteractions.size > 0)
560
1104
  return;
561
1105
  const now = this.now();
562
1106
  // Primary close: a short grace after the Stop hook, so a late assistant record
@@ -573,20 +1117,15 @@ export class ClaudeLiveSession {
573
1117
  }
574
1118
  /** Claude Code renders this glyph once the input box is mounted (ready-gate). */
575
1119
  const CLAUDE_PROMPT_GLYPH = "❯";
576
- const DRAFT_NEEDLE_MAX = 24;
577
- function submitNeedle(text) {
578
- return (text.split("\n")[0] ?? "").slice(0, DRAFT_NEEDLE_MAX);
579
- }
580
- /** Whether the pasted draft is visibly sitting in the input box (the last line
581
- * carrying the prompt glyph). Mirrors omnigent's `_draft_in_input_box`. */
582
- function draftInBox(pane, glyph, needle) {
583
- const glyphLines = pane.split("\n").filter((l) => l.includes(glyph));
584
- if (glyphLines.length === 0)
585
- return false;
586
- const tail = glyphLines[glyphLines.length - 1].split(glyph).pop() ?? "";
587
- if (tail.includes("[Pasted text"))
588
- return true; // claude's large-paste placeholder
589
- 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));
590
1129
  }
591
1130
  async function pollUntil(pred, timeoutMs, pollMs, now, sleep, signal) {
592
1131
  const deadline = now() + timeoutMs;
@@ -600,15 +1139,15 @@ async function pollUntil(pred, timeoutMs, pollMs, now, sleep, signal) {
600
1139
  return pred();
601
1140
  }
602
1141
  /**
603
- * Deliver `text` into a claude TUI pane, the omnigent recipe:
604
- * ready-gate (poll for `❯`) → clear leftover → bracketed paste (+ trailing
605
- * newline) wait for the draft to land → settle → submit Enter → verify the
606
- * draft left the box (re-send Enter while it hasn't).
1142
+ * Deliver `text` into a claude TUI pane, the reference implementation recipe:
1143
+ * ready-gate (poll for `❯`) → clear leftover → bracketed paste the draft
1144
+ * → settle → submit Enter → confirm from Claude's transcript.
607
1145
  *
608
- * THROWS if the prompt never appears within the ready-gate window (omnigent
1146
+ * THROWS if the prompt never appears within the ready-gate window (reference implementation
609
1147
  * `_wait_for_claude_prompt_ready` RAISE) — a not-ready pane is a hard error the
610
- * caller reports, NOT a signal to fall through to a second output path. Returns
611
- * `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.
612
1151
  */
613
1152
  export async function injectViaTerminal(injector, text, opts = {}) {
614
1153
  const glyph = opts.promptGlyph ?? CLAUDE_PROMPT_GLYPH;
@@ -625,41 +1164,45 @@ export async function injectViaTerminal(injector, text, opts = {}) {
625
1164
  injector.clearInputLine();
626
1165
  return true;
627
1166
  };
628
- // 1. Ready-gate. No prompt within the window → THROW (omnigent RAISE): a
1167
+ // 1. Ready-gate. No prompt within the window → THROW (reference implementation RAISE): a
629
1168
  // not-ready pane is a hard error, never a fall-through-to-run signal.
630
- 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);
631
1170
  if (cancelled())
632
1171
  return false;
633
1172
  if (!ready) {
634
1173
  const tail = injector.capturePane().split("\n").slice(-5).join("\n");
635
1174
  throw new Error(`claude prompt not ready (no "${glyph}" within timeout)\npane tail:\n${tail}`);
636
1175
  }
637
- // 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.
638
1180
  injector.clearInputLine();
639
- injector.paste(`${text}\n`);
640
- // 3. Wait for the draft to visibly land, then a short settle.
641
- const needle = submitNeedle(text);
642
- const draftSeen = await pollUntil(() => draftInBox(injector.capturePane(), glyph, needle), opts.pasteCommitMs ?? 5_000, pollMs, now, sleep, signal);
643
- 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);
644
1187
  if (cancelled())
645
1188
  return false; // Stop pressed mid-paste → don't submit
646
- // 4. Submit.
647
- injector.sendEnter();
648
- if (!draftSeen)
649
- return true; // draft never identifiable submit blind (omnigent)
650
- // 5. Verify the submit took; re-send Enter while the draft is still boxed.
651
- const deadline = now() + (opts.submitVerifyMs ?? 10_000);
652
- let lastEnter = now();
653
- while (now() < deadline) {
654
- if (cancelled())
655
- return false; // Stop pressed → stop re-submitting, clear the box
656
- await sleep(pollMs);
657
- 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)) {
658
1202
  return true;
659
- if (now() - lastEnter >= (opts.submitRetryMs ?? 1_000)) {
660
- injector.sendEnter();
661
- lastEnter = now();
662
1203
  }
1204
+ if (cancelled())
1205
+ return false;
663
1206
  }
664
- return true;
1207
+ return false;
665
1208
  }