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