@rynx-ai/runtime 0.1.0 → 0.1.9

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 (53) 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 +291 -39
  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 +78 -5
  9. package/dist/claude/native-integration.js +417 -26
  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 +3 -3
  14. package/dist/codex/rollout-synth.js +1 -1
  15. package/dist/codex-app-server/client.d.ts +26 -40
  16. package/dist/codex-app-server/client.js +1128 -99
  17. package/dist/codex-app-server/forwarder.d.ts +7 -7
  18. package/dist/codex-app-server/forwarder.js +11 -5
  19. package/dist/codex-app-server/mapping.d.ts +1 -1
  20. package/dist/codex-app-server/mapping.js +27 -2
  21. package/dist/codex-app-server/protocol.d.ts +238 -4
  22. package/dist/codex-app-server/transport.d.ts +20 -5
  23. package/dist/codex-app-server/transport.js +93 -40
  24. package/dist/codex-app-server/ws-channel.d.ts +3 -3
  25. package/dist/codex-app-server/ws-channel.js +23 -7
  26. package/dist/codex-child-env.js +33 -0
  27. package/dist/codex-home.d.ts +6 -6
  28. package/dist/codex-home.js +8 -9
  29. package/dist/codex-session-store.d.ts +2 -1
  30. package/dist/host.d.ts +34 -33
  31. package/dist/host.js +531 -91
  32. package/dist/index.d.ts +4 -3
  33. package/dist/index.js +1 -1
  34. package/dist/interactions.d.ts +61 -0
  35. package/dist/interactions.js +236 -0
  36. package/dist/models-catalog.d.ts +1 -1
  37. package/dist/models-catalog.js +1 -1
  38. package/dist/runner/child.d.ts +9 -1
  39. package/dist/runner/child.js +93 -15
  40. package/dist/runner/manager.d.ts +59 -10
  41. package/dist/runner/manager.js +385 -41
  42. package/dist/runner/protocol.d.ts +18 -7
  43. package/dist/runner-main.js +9 -6
  44. package/dist/runtime-status.js +1 -1
  45. package/dist/terminal/claude-tui.d.ts +8 -3
  46. package/dist/terminal/claude-tui.js +6 -2
  47. package/dist/terminal/codex-tui.d.ts +3 -3
  48. package/dist/terminal/codex-tui.js +1 -1
  49. package/dist/terminal/registry.d.ts +1 -1
  50. package/dist/terminal/registry.js +1 -1
  51. package/dist/terminal/tmux.d.ts +6 -6
  52. package/dist/terminal/tmux.js +10 -10
  53. package/package.json +3 -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,21 @@
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 INTERACTION_ACK_TIMEOUT_MS = 5_000;
26
+ const INTERACTION_LEASE_TIMEOUT_MS = 30_000;
27
+ function processIsAlive(pid) {
28
+ try {
29
+ process.kill(pid, 0);
30
+ return true;
31
+ }
32
+ catch (error) {
33
+ // A process that exists but belongs to another user is still alive.
34
+ return error.code === "EPERM";
35
+ }
36
+ }
23
37
  /** The trimmed string content of a `role:user` record, or undefined for
24
38
  * tool_result (array-content) records. Keeps XML-marker bookkeeping records
25
39
  * (`<command-name>…`, `<bash-input>…`, `<caveat>…`) — the caller classifies each. */
@@ -102,9 +116,13 @@ export class ClaudeLiveSession {
102
116
  idleCloseMs;
103
117
  stopGraceMs;
104
118
  now;
119
+ isProcessAlive;
120
+ leaseUpdatedAt;
105
121
  started = false;
106
122
  stopped = false;
107
123
  hooksOffset = 0;
124
+ interactionsOffset = 0;
125
+ interactionAcksOffset = 0;
108
126
  transcriptOffset = 0;
109
127
  /** Secondary dedup: source ids (record uuids) already forwarded, so a re-read
110
128
  * (fingerprint reset / mid-poll death) doesn't re-emit. Persisted in the
@@ -119,11 +137,29 @@ export class ClaudeLiveSession {
119
137
  seenClaudeSessionIds = new Set();
120
138
  turnOpen = false;
121
139
  currentTurnId;
140
+ /** The Turn opened from a pre-transcript interaction. Its unique provisional
141
+ * id keeps responses distinct until the real prompt uuid can be adopted. */
142
+ syntheticTurn = false;
122
143
  lastActivityAt = 0;
123
144
  /** When the Stop hook fired (null = not yet) — drives the grace-period close. */
124
145
  stopPendingAt = null;
125
146
  /** Open tool-call ids (start seen, end not yet) — suppresses the idle backstop. */
126
147
  openToolIds = new Set();
148
+ /** Blocking native questions/permissions. Their hook subprocess is still
149
+ * executing, so the Turn remains running and cannot be idle-closed. */
150
+ pendingInteractions = new Map();
151
+ settledInteractions = new Set();
152
+ /** Claimed response files may still contain the provider-bound, unredacted
153
+ * answer. Keep tracking them after canonical settlement until ACK/hook cleanup. */
154
+ claimedInteractions = new Map();
155
+ scheduledClaimScrubs = new Set();
156
+ /** Stop is flushed after interactions in the same poll, preventing a
157
+ * request+Stop race from briefly reporting this executing Turn as idle. */
158
+ stopSignalPending = false;
159
+ /** StopFailure is likewise flushed after interactions. A blocking hook can
160
+ * append its request immediately before the failure hook lands; closing here
161
+ * would let the later interaction poll reopen an already-failed Turn. */
162
+ turnFailurePending = null;
127
163
  /** Sub-agent (Task) ids already forwarded — a Task tool_result is processed once. */
128
164
  seenSubagents = new Set();
129
165
  /** The agent's task list, keyed by task id in creation order (claude
@@ -145,6 +181,9 @@ export class ClaudeLiveSession {
145
181
  this.idleCloseMs = opts.idleCloseMs ?? 30_000;
146
182
  this.stopGraceMs = opts.stopGraceMs ?? 4_000;
147
183
  this.now = opts.now ?? (() => Date.now());
184
+ this.isProcessAlive = opts.isProcessAlive ?? processIsAlive;
185
+ this.leaseUpdatedAt = opts.interactionLeaseUpdatedAt ??
186
+ ((interactionId) => interactionLeaseUpdatedAt(this.bridgeDir, interactionId));
148
187
  this.transcriptPath = opts.transcriptPath;
149
188
  // Resume path: bind the session id + restore the persisted forwarder cursor so
150
189
  // we continue from where a prior forwarder left off (no re-mirror on relaunch).
@@ -157,7 +196,7 @@ export class ClaudeLiveSession {
157
196
  }
158
197
  }
159
198
  /**
160
- * Restore the durable forwarder cursor for `transcriptPath` (omnigent's
199
+ * Restore the durable forwarder cursor for `transcriptPath` (reference implementation's
161
200
  * `_validated_transcript_state`). A cursor for a DIFFERENT file is ignored (a new
162
201
  * session starts at 0). A matching cursor whose fingerprint still validates
163
202
  * resumes at its `byteOffset`; a MISMATCH (the file was truncated/replaced) skips
@@ -199,7 +238,30 @@ export class ClaudeLiveSession {
199
238
  void this.loop();
200
239
  }
201
240
  stop() {
241
+ if (this.stopped)
242
+ return;
202
243
  this.stopped = true;
244
+ // A hook may have appended its request just before shutdown but not yet
245
+ // reached a scheduled poll. Drain it so the subprocess receives cancellation
246
+ // instead of remaining blocked until Claude's one-day hook timeout.
247
+ this.pollInteractions();
248
+ this.drainInteractionCommits();
249
+ this.cancelPendingInteractions("session_stopped");
250
+ // Runner shutdown closes its mirror transport immediately after this method
251
+ // returns, so the terminal event must be emitted synchronously here.
252
+ this.closeTurn();
253
+ this.scheduleClaimedInteractionScrubs();
254
+ }
255
+ /** Phase two of runner shutdown. The caller must stop the Claude terminal
256
+ * first so no hook can still be opening an atomically claimed answer. This is
257
+ * synchronous because the runner process exits immediately afterwards. */
258
+ finalizeStop() {
259
+ if (!this.stopped)
260
+ this.stop();
261
+ scrubClaudeInteractionArtifacts(this.bridgeDir);
262
+ this.pendingInteractions.clear();
263
+ this.claimedInteractions.clear();
264
+ this.scheduledClaimScrubs.clear();
203
265
  }
204
266
  /** One poll cycle (hooks → transcript → idle backstop). Exposed for tests to
205
267
  * drive deterministically; the async {@link loop} just calls it on an interval. */
@@ -210,7 +272,12 @@ export class ClaudeLiveSession {
210
272
  this.pollHooks();
211
273
  this.pollDeltas();
212
274
  this.pollTranscript();
275
+ this.pollInteractions();
276
+ this.drainInteractionCommits();
277
+ this.pollAbandonedInteractions();
278
+ this.flushTurnFailure();
213
279
  this.pollStatus();
280
+ this.flushStopSignal();
214
281
  this.maybeIdleClose();
215
282
  }
216
283
  async loop() {
@@ -220,16 +287,18 @@ export class ClaudeLiveSession {
220
287
  }
221
288
  catch (err) {
222
289
  // A single failing poll must NOT kill the whole forwarder — that would
223
- // desync chat↔terminal permanently. Log + continue, per omnigent's
290
+ // desync chat↔terminal permanently. Log + continue, per reference implementation's
224
291
  // per-iteration `except Exception` in the transcript forwarder loop.
225
292
  console.error("[claude-forwarder] tick failed; continuing:", err);
226
293
  }
227
294
  await new Promise((r) => setTimeout(r, this.pollMs));
228
295
  }
229
- if (this.turnOpen) {
230
- this.turnOpen = false;
231
- this.sink.onTurnEnd(this.statusUsage());
232
- }
296
+ // Catch a request appended while the loop was waking up to observe stopped.
297
+ this.pollInteractions();
298
+ this.drainInteractionCommits();
299
+ this.cancelPendingInteractions("session_stopped");
300
+ this.closeTurn();
301
+ this.scheduleClaimedInteractionScrubs();
233
302
  }
234
303
  pollHooks() {
235
304
  const { events, nextOffset } = readHookEventsFrom(this.bridgeDir, this.hooksOffset);
@@ -248,14 +317,35 @@ export class ClaudeLiveSession {
248
317
  return;
249
318
  // Stop only signals idle — it does NOT close the turn (see onIdle): closing
250
319
  // 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"));
320
+ if (ev.eventName === "StopFailure") {
321
+ this.stopSignalPending = false;
322
+ this.stopPendingAt = null;
323
+ this.turnFailurePending = new Error("claude turn failed");
324
+ }
253
325
  else {
254
- this.sink.onIdle();
326
+ this.stopSignalPending = true;
255
327
  this.stopPendingAt = this.now(); // close after a short grace (late assistant flush)
256
328
  }
257
329
  }
258
330
  }
331
+ /** Process a provider failure only after this tick has discovered every
332
+ * blocking request already appended by its hook subprocess. */
333
+ flushTurnFailure() {
334
+ const error = this.turnFailurePending;
335
+ if (!error)
336
+ return;
337
+ this.turnFailurePending = null;
338
+ this.closeTurnError(error);
339
+ }
340
+ /** Emit the Stop idle signal only after this tick has discovered native
341
+ * interactions. A pending question/permission is active execution, not idle. */
342
+ flushStopSignal() {
343
+ if (!this.stopSignalPending)
344
+ return;
345
+ this.stopSignalPending = false;
346
+ if (this.pendingInteractions.size === 0)
347
+ this.sink.onIdle();
348
+ }
259
349
  /** SessionStart drives discovery (first) and rotation (a later one with a NEW
260
350
  * session id + transcript). `/clear` → source="clear"; `/fork` → source="resume"
261
351
  * into an unseen id with a forkedFrom marker. Any other new-transcript
@@ -326,12 +416,282 @@ export class ClaudeLiveSession {
326
416
  if (records.length > 0)
327
417
  this.persistForwardState();
328
418
  }
419
+ /** Tail blocking hook requests after transcript records, so an interaction
420
+ * emitted in the same poll attaches to the user Turn that caused it. */
421
+ pollInteractions() {
422
+ const { requests, nextOffset } = readInteractionRequestsFrom(this.bridgeDir, this.interactionsOffset);
423
+ this.interactionsOffset = nextOffset;
424
+ for (const entry of requests) {
425
+ const interactionId = entry.interactionId;
426
+ if (this.pendingInteractions.has(interactionId) ||
427
+ this.settledInteractions.has(interactionId)) {
428
+ continue;
429
+ }
430
+ const bounded = boundInteractionRequest(entry.request);
431
+ if (!bounded.ok) {
432
+ writeInteractionResult(this.bridgeDir, interactionId, {
433
+ status: "cancelled",
434
+ reason: bounded.reason,
435
+ });
436
+ this.rememberSettled(interactionId);
437
+ continue;
438
+ }
439
+ this.ensureTurn(`interaction_${interactionId}`);
440
+ const pending = {
441
+ request: bounded.request,
442
+ observedAt: this.now(),
443
+ ...(entry.waiterPid ? { waiterPid: entry.waiterPid } : {}),
444
+ ...(this.currentTurnId ? { turnId: this.currentTurnId } : {}),
445
+ };
446
+ this.pendingInteractions.set(interactionId, pending);
447
+ // A native hook is blocked waiting for the answer. Any Stop observed in
448
+ // this same poll cannot make the Session idle or close its Turn.
449
+ this.stopSignalPending = false;
450
+ this.stopPendingAt = null;
451
+ this.lastActivityAt = this.now();
452
+ this.sink.onInteraction({
453
+ type: "requested",
454
+ request: bounded.request,
455
+ ...(pending.turnId ? { turnId: pending.turnId } : {}),
456
+ });
457
+ }
458
+ }
459
+ drainInteractionCommits() {
460
+ this.pollInteractionAcks();
461
+ this.pollInteractionClaims();
462
+ this.pollClaimedInteractionCleanup();
463
+ }
464
+ /** A hook acknowledgement is the commit point: it is appended after the hook
465
+ * consumes the one-shot response and before it returns the native verdict. */
466
+ pollInteractionAcks() {
467
+ const { acks, nextOffset } = readInteractionAcksFrom(this.bridgeDir, this.interactionAcksOffset);
468
+ this.interactionAcksOffset = nextOffset;
469
+ for (const ack of acks) {
470
+ removeInteractionResult(this.bridgeDir, ack.interactionId);
471
+ removeClaimedInteractionResult(this.bridgeDir, ack.interactionId);
472
+ removeInteractionLease(this.bridgeDir, ack.interactionId);
473
+ this.claimedInteractions.delete(ack.interactionId);
474
+ const pending = this.pendingInteractions.get(ack.interactionId);
475
+ if (!pending || this.settledInteractions.has(ack.interactionId))
476
+ continue;
477
+ if (ack.status === "resolved") {
478
+ const invalid = validateInteractionResolution(pending.request, ack.resolution);
479
+ if (invalid) {
480
+ this.settleCancelledInteraction(ack.interactionId, pending, "native_invalid_acknowledgement");
481
+ continue;
482
+ }
483
+ this.pendingInteractions.delete(ack.interactionId);
484
+ this.rememberSettled(ack.interactionId);
485
+ this.stopSignalPending = false;
486
+ this.stopPendingAt = null;
487
+ this.lastActivityAt = this.now();
488
+ this.sink.onInteraction({
489
+ type: "resolved",
490
+ interactionId: ack.interactionId,
491
+ resolution: redactInteractionResolution(pending.request, ack.resolution),
492
+ ...(pending.turnId ? { turnId: pending.turnId } : {}),
493
+ });
494
+ continue;
495
+ }
496
+ this.pendingInteractions.delete(ack.interactionId);
497
+ this.rememberSettled(ack.interactionId);
498
+ this.sink.onInteraction({
499
+ type: "cancelled",
500
+ interactionId: ack.interactionId,
501
+ ...(ack.reason ? { reason: ack.reason } : {}),
502
+ ...(pending.turnId ? { turnId: pending.turnId } : {}),
503
+ });
504
+ }
505
+ }
506
+ /** A claimed response is already consumed by the native hook. This fallback
507
+ * converges the interaction even if the hook exits before appending its
508
+ * redacted ACK. */
509
+ pollInteractionClaims() {
510
+ for (const [interactionId, pending] of [...this.pendingInteractions]) {
511
+ const claimed = readClaimedInteractionResult(this.bridgeDir, interactionId);
512
+ if (!claimed)
513
+ continue;
514
+ removeInteractionResult(this.bridgeDir, interactionId);
515
+ this.settleClaimedInteraction(interactionId, pending, claimed);
516
+ }
517
+ }
518
+ /** A request cannot remain actionable after its blocking hook exits. A short
519
+ * post-submit timeout also converges requests created by older hooks that did
520
+ * not publish a waiter pid, and scrubs any unacknowledged secret response. */
521
+ pollAbandonedInteractions() {
522
+ const now = this.now();
523
+ for (const [interactionId, pending] of [...this.pendingInteractions]) {
524
+ const waiterExited = pending.waiterPid !== undefined &&
525
+ !this.isProcessAlive(pending.waiterPid);
526
+ const leaseAt = this.leaseUpdatedAt(interactionId) ?? pending.observedAt;
527
+ const leaseExpired = pending.waiterPid !== undefined &&
528
+ now - leaseAt >= INTERACTION_LEASE_TIMEOUT_MS;
529
+ // New hooks publish both a waiter pid and a heartbeat lease. Let their
530
+ // atomic claim decide delivery; the short ACK timeout is only a convergence
531
+ // fallback for bridge requests produced by older hook versions.
532
+ const responseUnacknowledged = pending.waiterPid === undefined &&
533
+ pending.submittedAt !== undefined &&
534
+ now - pending.submittedAt >= INTERACTION_ACK_TIMEOUT_MS;
535
+ if (!waiterExited && !leaseExpired && !responseUnacknowledged)
536
+ continue;
537
+ const reason = waiterExited
538
+ ? "native_waiter_exited"
539
+ : leaseExpired
540
+ ? "native_waiter_lease_expired"
541
+ : "native_response_unacknowledged";
542
+ this.cancelOrSettlePendingInteraction(interactionId, pending, reason,
543
+ // A dead process cannot consume a cancellation verdict; scrub its
544
+ // one-shot result instead of leaving stale response material on disk.
545
+ !waiterExited);
546
+ }
547
+ }
548
+ /** Deliver an answer to the blocked hook. The bridge file contains the real
549
+ * answer; only the canonical resolved event receives a secret-redacted copy. */
550
+ resolveInteraction(interactionId, resolution) {
551
+ const pending = this.pendingInteractions.get(interactionId);
552
+ if (!pending) {
553
+ return this.settledInteractions.has(interactionId)
554
+ ? { disposition: "already_resolved" }
555
+ : { disposition: "not_found" };
556
+ }
557
+ if (pending.waiterPid !== undefined) {
558
+ const waiterExited = !this.isProcessAlive(pending.waiterPid);
559
+ const leaseAt = this.leaseUpdatedAt(interactionId) ?? pending.observedAt;
560
+ const leaseExpired = this.now() - leaseAt >= INTERACTION_LEASE_TIMEOUT_MS;
561
+ if (waiterExited || leaseExpired) {
562
+ this.cancelOrSettlePendingInteraction(interactionId, pending, waiterExited ? "native_waiter_exited" : "native_waiter_lease_expired");
563
+ return { disposition: "already_resolved" };
564
+ }
565
+ }
566
+ if (pending.submitted)
567
+ return { disposition: "already_resolved" };
568
+ const invalid = validateInteractionResolution(pending.request, resolution);
569
+ if (invalid)
570
+ return { disposition: "invalid", message: invalid };
571
+ if (!writeInteractionResult(this.bridgeDir, interactionId, { status: "resolved", resolution })) {
572
+ return { disposition: "invalid", message: "invalid interaction id" };
573
+ }
574
+ pending.submitted = true;
575
+ pending.submittedAt = this.now();
576
+ return { disposition: "applied" };
577
+ }
578
+ cancelPendingInteractions(reason) {
579
+ for (const [interactionId, pending] of [...this.pendingInteractions]) {
580
+ this.cancelOrSettlePendingInteraction(interactionId, pending, reason);
581
+ }
582
+ }
583
+ /** Terminal cancellation may race the hook's atomic result claim. A claim
584
+ * always wins; otherwise the cancellation file becomes the hook's verdict. */
585
+ cancelOrSettlePendingInteraction(interactionId, pending, reason, deliverCancellation = true) {
586
+ const alreadyClaimed = readClaimedInteractionResult(this.bridgeDir, interactionId);
587
+ if (alreadyClaimed) {
588
+ removeInteractionResult(this.bridgeDir, interactionId);
589
+ this.settleClaimedInteraction(interactionId, pending, alreadyClaimed);
590
+ return;
591
+ }
592
+ if (deliverCancellation) {
593
+ writeInteractionResult(this.bridgeDir, interactionId, { status: "cancelled", reason });
594
+ }
595
+ else {
596
+ removeInteractionResult(this.bridgeDir, interactionId);
597
+ }
598
+ const racedClaim = readClaimedInteractionResult(this.bridgeDir, interactionId);
599
+ if (racedClaim) {
600
+ removeInteractionResult(this.bridgeDir, interactionId);
601
+ this.settleClaimedInteraction(interactionId, pending, racedClaim);
602
+ return;
603
+ }
604
+ this.settleCancelledInteraction(interactionId, pending, reason);
605
+ }
606
+ settleClaimedInteraction(interactionId, pending, result) {
607
+ this.claimedInteractions.set(interactionId, {
608
+ observedAt: this.now(),
609
+ ...(pending.waiterPid ? { waiterPid: pending.waiterPid } : {}),
610
+ });
611
+ if (result.status === "resolved") {
612
+ const invalid = validateInteractionResolution(pending.request, result.resolution);
613
+ if (!invalid) {
614
+ this.pendingInteractions.delete(interactionId);
615
+ this.rememberSettled(interactionId);
616
+ this.stopSignalPending = false;
617
+ this.stopPendingAt = null;
618
+ this.lastActivityAt = this.now();
619
+ this.sink.onInteraction({
620
+ type: "resolved",
621
+ interactionId,
622
+ resolution: redactInteractionResolution(pending.request, result.resolution),
623
+ ...(pending.turnId ? { turnId: pending.turnId } : {}),
624
+ });
625
+ return;
626
+ }
627
+ }
628
+ this.settleCancelledInteraction(interactionId, pending, result.status === "cancelled"
629
+ ? result.reason ?? "native_interaction_cancelled"
630
+ : "native_invalid_claim");
631
+ }
632
+ /** A claim is the hook's private handoff file and can contain secret answers.
633
+ * ACK/finally normally removes it; process death or an expired lease is the
634
+ * crash fallback after the canonical interaction has already settled. */
635
+ pollClaimedInteractionCleanup() {
636
+ const now = this.now();
637
+ for (const [interactionId, claimed] of [...this.claimedInteractions]) {
638
+ const result = readClaimedInteractionResult(this.bridgeDir, interactionId);
639
+ if (!result) {
640
+ this.claimedInteractions.delete(interactionId);
641
+ continue;
642
+ }
643
+ const waiterExited = claimed.waiterPid !== undefined &&
644
+ !this.isProcessAlive(claimed.waiterPid);
645
+ const leaseAt = this.leaseUpdatedAt(interactionId) ?? claimed.observedAt;
646
+ const leaseExpired = now - leaseAt >= INTERACTION_LEASE_TIMEOUT_MS;
647
+ if (!waiterExited && !leaseExpired)
648
+ continue;
649
+ removeClaimedInteractionResult(this.bridgeDir, interactionId);
650
+ removeInteractionLease(this.bridgeDir, interactionId);
651
+ this.claimedInteractions.delete(interactionId);
652
+ }
653
+ }
654
+ /** Once this forwarder stops it can no longer poll a later hook crash. Keep a
655
+ * short unref'd scrub deadline so unredacted claim material cannot persist for
656
+ * the lifetime of the daemon. */
657
+ scheduleClaimedInteractionScrubs() {
658
+ for (const interactionId of this.claimedInteractions.keys()) {
659
+ if (this.scheduledClaimScrubs.has(interactionId))
660
+ continue;
661
+ this.scheduledClaimScrubs.add(interactionId);
662
+ const timer = setTimeout(() => {
663
+ removeClaimedInteractionResult(this.bridgeDir, interactionId);
664
+ removeInteractionLease(this.bridgeDir, interactionId);
665
+ this.claimedInteractions.delete(interactionId);
666
+ this.scheduledClaimScrubs.delete(interactionId);
667
+ }, INTERACTION_LEASE_TIMEOUT_MS);
668
+ timer.unref?.();
669
+ }
670
+ }
671
+ settleCancelledInteraction(interactionId, pending, reason) {
672
+ this.pendingInteractions.delete(interactionId);
673
+ this.rememberSettled(interactionId);
674
+ this.sink.onInteraction({
675
+ type: "cancelled",
676
+ interactionId,
677
+ reason,
678
+ ...(pending.turnId ? { turnId: pending.turnId } : {}),
679
+ });
680
+ }
681
+ rememberSettled(interactionId) {
682
+ this.settledInteractions.add(interactionId);
683
+ if (this.settledInteractions.size <= MAX_SETTLED_INTERACTIONS)
684
+ return;
685
+ const oldest = this.settledInteractions.values().next().value;
686
+ if (oldest)
687
+ this.settledInteractions.delete(oldest);
688
+ }
329
689
  handleRecord(rec) {
330
690
  if (rec.isSidechain === true)
331
691
  return; // sub-agent turns (Phase 9 forwards them)
332
692
  // Secondary dedup: skip a record whose source id was already forwarded (a
333
693
  // 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
694
+ // so the record uuid is the source key (vs reference implementation's per-block key for its
335
695
  // per-item POST). Records without a uuid fall through undeduped (rare).
336
696
  const sourceId = typeof rec.uuid === "string" && rec.uuid ? rec.uuid : undefined;
337
697
  if (sourceId) {
@@ -351,12 +711,23 @@ export class ClaudeLiveSession {
351
711
  }
352
712
  // A real prompt (not an XML-marker bookkeeping record) opens a new turn.
353
713
  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);
714
+ const turnId = typeof rec.uuid === "string" ? rec.uuid : undefined;
715
+ if (this.turnOpen && this.syntheticTurn) {
716
+ // A hook can flush just before the transcript's user record. The
717
+ // interaction already opened a unique provisional response; adopt the
718
+ // real turn id internally without closing/cancelling that same Turn.
719
+ this.currentTurnId = turnId;
720
+ this.syntheticTurn = false;
721
+ }
722
+ else {
723
+ this.closeTurn();
724
+ this.currentTurnId = turnId;
725
+ this.turnOpen = true;
726
+ this.syntheticTurn = false;
727
+ this.openToolIds.clear();
728
+ this.stopPendingAt = null;
729
+ this.sink.onTurnStart(this.currentTurnId);
730
+ }
360
731
  this.sink.onUserMessage(content);
361
732
  this.lastActivityAt = this.now();
362
733
  }
@@ -439,7 +810,11 @@ export class ClaudeLiveSession {
439
810
  this.seenMessageIds.add(d.messageId);
440
811
  this.messageIdQueue.push(d.messageId);
441
812
  }
442
- this.sink.onEvent({ type: "token", text: d.delta, metadata: { itemId: d.messageId } });
813
+ this.sink.onEvent({
814
+ type: "token",
815
+ text: d.delta,
816
+ metadata: { itemId: d.messageId, dedupeAgainstCompleted: true },
817
+ });
443
818
  }
444
819
  if (deltas.length)
445
820
  this.lastActivityAt = this.now();
@@ -501,9 +876,13 @@ export class ClaudeLiveSession {
501
876
  this.openToolIds.delete(id);
502
877
  }
503
878
  }
504
- ensureTurn() {
879
+ ensureTurn(provisionalTurnId) {
505
880
  if (this.turnOpen)
506
881
  return;
882
+ if (!this.currentTurnId && provisionalTurnId) {
883
+ this.currentTurnId = provisionalTurnId;
884
+ this.syntheticTurn = true;
885
+ }
507
886
  this.turnOpen = true;
508
887
  this.sink.onTurnStart(this.currentTurnId);
509
888
  }
@@ -527,6 +906,8 @@ export class ClaudeLiveSession {
527
906
  noteInterrupted() {
528
907
  if (!this.turnOpen)
529
908
  return;
909
+ this.cancelPendingInteractions("turn_interrupted");
910
+ this.stopSignalPending = false;
530
911
  this.sink.onIdle();
531
912
  this.stopPendingAt = this.now();
532
913
  }
@@ -540,23 +921,33 @@ export class ClaudeLiveSession {
540
921
  closeTurn() {
541
922
  if (!this.turnOpen)
542
923
  return;
924
+ this.drainInteractionCommits();
925
+ this.cancelPendingInteractions("turn_completed");
543
926
  this.turnOpen = false;
544
927
  this.currentTurnId = undefined;
928
+ this.syntheticTurn = false;
545
929
  this.openToolIds.clear();
930
+ this.stopSignalPending = false;
546
931
  this.stopPendingAt = null;
547
932
  this.sink.onTurnEnd(this.statusUsage());
548
933
  }
549
934
  closeTurnError(error) {
550
935
  if (!this.turnOpen)
551
936
  return;
937
+ this.drainInteractionCommits();
938
+ this.cancelPendingInteractions("turn_failed");
552
939
  this.turnOpen = false;
553
940
  this.currentTurnId = undefined;
941
+ this.syntheticTurn = false;
554
942
  this.openToolIds.clear();
943
+ this.stopSignalPending = false;
555
944
  this.stopPendingAt = null;
556
945
  this.sink.onTurnError(error);
557
946
  }
558
947
  maybeIdleClose() {
559
- if (!this.turnOpen || this.openToolIds.size > 0)
948
+ if (!this.turnOpen ||
949
+ this.openToolIds.size > 0 ||
950
+ this.pendingInteractions.size > 0)
560
951
  return;
561
952
  const now = this.now();
562
953
  // Primary close: a short grace after the Stop hook, so a late assistant record
@@ -578,7 +969,7 @@ function submitNeedle(text) {
578
969
  return (text.split("\n")[0] ?? "").slice(0, DRAFT_NEEDLE_MAX);
579
970
  }
580
971
  /** 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`. */
972
+ * carrying the prompt glyph). Mirrors reference implementation's `_draft_in_input_box`. */
582
973
  function draftInBox(pane, glyph, needle) {
583
974
  const glyphLines = pane.split("\n").filter((l) => l.includes(glyph));
584
975
  if (glyphLines.length === 0)
@@ -600,12 +991,12 @@ async function pollUntil(pred, timeoutMs, pollMs, now, sleep, signal) {
600
991
  return pred();
601
992
  }
602
993
  /**
603
- * Deliver `text` into a claude TUI pane, the omnigent recipe:
994
+ * Deliver `text` into a claude TUI pane, the reference implementation recipe:
604
995
  * ready-gate (poll for `❯`) → clear leftover → bracketed paste (+ trailing
605
996
  * newline) → wait for the draft to land → settle → submit Enter → verify the
606
997
  * draft left the box (re-send Enter while it hasn't).
607
998
  *
608
- * THROWS if the prompt never appears within the ready-gate window (omnigent
999
+ * THROWS if the prompt never appears within the ready-gate window (reference implementation
609
1000
  * `_wait_for_claude_prompt_ready` RAISE) — a not-ready pane is a hard error the
610
1001
  * caller reports, NOT a signal to fall through to a second output path. Returns
611
1002
  * `true` once the message is submitted (best-effort even if submit-verify times out).
@@ -625,7 +1016,7 @@ export async function injectViaTerminal(injector, text, opts = {}) {
625
1016
  injector.clearInputLine();
626
1017
  return true;
627
1018
  };
628
- // 1. Ready-gate. No prompt within the window → THROW (omnigent RAISE): a
1019
+ // 1. Ready-gate. No prompt within the window → THROW (reference implementation RAISE): a
629
1020
  // not-ready pane is a hard error, never a fall-through-to-run signal.
630
1021
  const ready = await pollUntil(() => injector.capturePane().includes(glyph), opts.promptTimeoutMs ?? 20_000, pollMs, now, sleep, signal);
631
1022
  if (cancelled())
@@ -646,7 +1037,7 @@ export async function injectViaTerminal(injector, text, opts = {}) {
646
1037
  // 4. Submit.
647
1038
  injector.sendEnter();
648
1039
  if (!draftSeen)
649
- return true; // draft never identifiable → submit blind (omnigent)
1040
+ return true; // draft never identifiable → submit blind (reference implementation)
650
1041
  // 5. Verify the submit took; re-send Enter while the draft is still boxed.
651
1042
  const deadline = now() + (opts.submitVerifyMs ?? 10_000);
652
1043
  let lastEnter = now();
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Build the single explicit settings document for a rynx-managed Claude
3
+ * Session. Host/project settings still provide auth, env and permission policy,
4
+ * but their plugins and hooks are excluded: rynx must be the only owner of
5
+ * PermissionRequest and AskUserQuestion, and Agent Skills arrive only through
6
+ * the selected `--plugin-dir`.
7
+ */
8
+ export declare function buildManagedClaudeSettings(cwd: string, rynxSettings: object, configDir?: string): Record<string, unknown>;