@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.
- 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 +327 -38
- package/dist/claude/native-hooks.d.ts +3 -2
- package/dist/claude/native-hooks.js +15 -6
- package/dist/claude/native-integration.d.ts +123 -16
- package/dist/claude/native-integration.js +624 -81
- 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 +14 -3
- package/dist/codex/rollout-synth.d.ts +8 -3
- package/dist/codex/rollout-synth.js +65 -32
- package/dist/codex-app-server/client.d.ts +27 -40
- package/dist/codex-app-server/client.js +1134 -99
- package/dist/codex-app-server/forwarder.d.ts +36 -10
- package/dist/codex-app-server/forwarder.js +146 -28
- package/dist/codex-app-server/mapping.d.ts +1 -1
- package/dist/codex-app-server/mapping.js +64 -5
- package/dist/codex-app-server/protocol.d.ts +269 -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 +16 -6
- package/dist/codex-home.js +46 -15
- package/dist/codex-session-store.d.ts +2 -1
- package/dist/host.d.ts +38 -38
- package/dist/host.js +626 -121
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1 -1
- package/dist/input-resources.d.ts +13 -0
- package/dist/input-resources.js +67 -0
- package/dist/interactions.d.ts +61 -0
- package/dist/interactions.js +236 -0
- package/dist/models-catalog.d.ts +5 -13
- package/dist/models-catalog.js +60 -9
- package/dist/runner/child.d.ts +9 -1
- package/dist/runner/child.js +100 -19
- package/dist/runner/manager.d.ts +79 -11
- package/dist/runner/manager.js +423 -43
- package/dist/runner/protocol.d.ts +30 -11
- 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 +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
|
|
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
|
|
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
|
-
|
|
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` (
|
|
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
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
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
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
310
|
-
this.
|
|
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
|
|
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
|
-
|
|
355
|
-
this.
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
|
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
|
-
|
|
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 ||
|
|
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
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
|
604
|
-
* ready-gate (poll for `❯`) → clear leftover → bracketed paste
|
|
605
|
-
*
|
|
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 (
|
|
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.
|
|
611
|
-
*
|
|
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 (
|
|
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()
|
|
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
|
|
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.
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
const
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
|
1207
|
+
return false;
|
|
665
1208
|
}
|