@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.
- package/dist/claude/executor.d.ts +3 -5
- package/dist/claude/executor.js +3 -5
- package/dist/claude/native-bridge.d.ts +74 -17
- package/dist/claude/native-bridge.js +225 -30
- package/dist/claude/native-hook-main.js +291 -39
- package/dist/claude/native-hooks.d.ts +3 -2
- package/dist/claude/native-hooks.js +15 -6
- package/dist/claude/native-integration.d.ts +78 -5
- package/dist/claude/native-integration.js +417 -26
- package/dist/claude/settings.d.ts +8 -0
- package/dist/claude/settings.js +50 -0
- package/dist/claude/transcript.d.ts +2 -2
- package/dist/claude/transcript.js +3 -3
- package/dist/codex/rollout-synth.js +1 -1
- package/dist/codex-app-server/client.d.ts +26 -40
- package/dist/codex-app-server/client.js +1128 -99
- package/dist/codex-app-server/forwarder.d.ts +7 -7
- package/dist/codex-app-server/forwarder.js +11 -5
- package/dist/codex-app-server/mapping.d.ts +1 -1
- package/dist/codex-app-server/mapping.js +27 -2
- package/dist/codex-app-server/protocol.d.ts +238 -4
- package/dist/codex-app-server/transport.d.ts +20 -5
- package/dist/codex-app-server/transport.js +93 -40
- package/dist/codex-app-server/ws-channel.d.ts +3 -3
- package/dist/codex-app-server/ws-channel.js +23 -7
- package/dist/codex-child-env.js +33 -0
- package/dist/codex-home.d.ts +6 -6
- package/dist/codex-home.js +8 -9
- package/dist/codex-session-store.d.ts +2 -1
- package/dist/host.d.ts +34 -33
- package/dist/host.js +531 -91
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1 -1
- package/dist/interactions.d.ts +61 -0
- package/dist/interactions.js +236 -0
- package/dist/models-catalog.d.ts +1 -1
- package/dist/models-catalog.js +1 -1
- package/dist/runner/child.d.ts +9 -1
- package/dist/runner/child.js +93 -15
- package/dist/runner/manager.d.ts +59 -10
- package/dist/runner/manager.js +385 -41
- package/dist/runner/protocol.d.ts +18 -7
- package/dist/runner-main.js +9 -6
- package/dist/runtime-status.js +1 -1
- package/dist/terminal/claude-tui.d.ts +8 -3
- package/dist/terminal/claude-tui.js +6 -2
- package/dist/terminal/codex-tui.d.ts +3 -3
- package/dist/terminal/codex-tui.js +1 -1
- package/dist/terminal/registry.d.ts +1 -1
- package/dist/terminal/registry.js +1 -1
- package/dist/terminal/tmux.d.ts +6 -6
- package/dist/terminal/tmux.js +10 -10
- 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
|
|
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` (
|
|
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
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
355
|
-
this.
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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({
|
|
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 ||
|
|
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
|
|
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
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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>;
|