@rynx-ai/runtime 0.1.11-beta.2 → 0.1.11-beta.21
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/models.d.ts +0 -5
- package/dist/claude/models.js +1 -7
- package/dist/claude/native-integration.d.ts +35 -7
- package/dist/claude/native-integration.js +204 -32
- package/dist/claude/session-status.d.ts +39 -0
- package/dist/claude/session-status.js +163 -0
- package/dist/codex-app-server/forwarder.d.ts +66 -2
- package/dist/codex-app-server/forwarder.js +329 -11
- package/dist/codex-app-server/mapping.d.ts +2 -0
- package/dist/codex-app-server/mapping.js +97 -23
- package/dist/codex-app-server/mcp-startup.d.ts +13 -0
- package/dist/codex-app-server/mcp-startup.js +63 -0
- package/dist/codex-app-server/protocol.d.ts +8 -5
- package/dist/codex-app-server/ws-channel.js +19 -19
- package/dist/codex-home.js +2 -4
- package/dist/host.d.ts +28 -7
- package/dist/host.js +356 -92
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/models-catalog.d.ts +2 -1
- package/dist/models-catalog.js +82 -3
- package/dist/runner/child.d.ts +22 -6
- package/dist/runner/child.js +237 -30
- package/dist/runner/manager.d.ts +87 -4
- package/dist/runner/manager.js +610 -56
- package/dist/runner/protocol.d.ts +11 -15
- package/dist/runner/startup-policy.d.ts +4 -0
- package/dist/runner/startup-policy.js +5 -0
- package/dist/terminal/claude-tui.d.ts +3 -1
- package/dist/terminal/claude-tui.js +3 -1
- package/dist/terminal/tmux.d.ts +29 -2
- package/dist/terminal/tmux.js +122 -14
- package/package.json +4 -3
package/dist/claude/models.d.ts
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
1
|
import type { ModelInfo, ModelListResponse } from "../codex-app-server/protocol.js";
|
|
2
|
-
/**
|
|
3
|
-
* Default model for the claude runtime when `CLAUDE_MODEL` is unset. Kept in
|
|
4
|
-
* sync with the `CLAUDE_MODEL` default in {@link import("../config.js")}.
|
|
5
|
-
*/
|
|
6
|
-
export declare const CLAUDE_DEFAULT_MODEL = "claude-sonnet-4-6";
|
|
7
2
|
/** Build the `ModelInfo[]` for the claude runtime. */
|
|
8
3
|
export declare function claudeModelInfos(): ModelInfo[];
|
|
9
4
|
/** {@link ModelListResponse} for the claude runtime (broker `listModels`). */
|
package/dist/claude/models.js
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Default model for the claude runtime when `CLAUDE_MODEL` is unset. Kept in
|
|
3
|
-
* sync with the `CLAUDE_MODEL` default in {@link import("../config.js")}.
|
|
4
|
-
*/
|
|
5
|
-
export const CLAUDE_DEFAULT_MODEL = "claude-sonnet-4-6";
|
|
6
1
|
/**
|
|
7
2
|
* Static catalogue of Claude models the `/model` card and `/models` reply offer
|
|
8
3
|
* for the claude runtime. Unlike codex/traex (which query a live app-server),
|
|
@@ -12,7 +7,7 @@ export const CLAUDE_DEFAULT_MODEL = "claude-sonnet-4-6";
|
|
|
12
7
|
*/
|
|
13
8
|
const CLAUDE_MODEL_IDS = [
|
|
14
9
|
{ id: "claude-opus-4-8", displayName: "Claude Opus 4.8 · 最强" },
|
|
15
|
-
{ id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6 ·
|
|
10
|
+
{ id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6 · 均衡" },
|
|
16
11
|
{ id: "claude-haiku-4-5", displayName: "Claude Haiku 4.5 · 最快" },
|
|
17
12
|
{ id: "claude-opus-4-7", displayName: "Claude Opus 4.7" },
|
|
18
13
|
{ id: "claude-opus-4-6", displayName: "Claude Opus 4.6" },
|
|
@@ -24,7 +19,6 @@ export function claudeModelInfos() {
|
|
|
24
19
|
id,
|
|
25
20
|
model: id,
|
|
26
21
|
displayName,
|
|
27
|
-
isDefault: id === CLAUDE_DEFAULT_MODEL,
|
|
28
22
|
}));
|
|
29
23
|
}
|
|
30
24
|
/** {@link ModelListResponse} for the claude runtime (broker `listModels`). */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AgentEvent, SessionInteractionResolution, TerminalCommandData, TodoItem } from "@rynx-ai/core";
|
|
2
2
|
import type { ResolveInteractionResult, RuntimeInteractionEvent } from "../interactions.js";
|
|
3
|
+
import { type ClaudeRunnerStatus } from "./session-status.js";
|
|
3
4
|
/** Mirror sink — the same shape as `CodexForwarderSink` so the host reuses one
|
|
4
5
|
* per-turn normalizer wiring for both runtimes. */
|
|
5
6
|
export interface ClaudeForwarderSink {
|
|
@@ -21,7 +22,9 @@ export interface ClaudeForwarderSink {
|
|
|
21
22
|
/** The current turn finished (a new user prompt, or the inactivity backstop).
|
|
22
23
|
* `usage` carries the latest statusLine context/cost snapshot, when captured. */
|
|
23
24
|
onTurnEnd(usage?: Record<string, unknown>): void;
|
|
24
|
-
/** The runtime
|
|
25
|
+
/** The runtime status changed according to Claude's session metadata. */
|
|
26
|
+
onStatus?(status: ClaudeRunnerStatus, blockedOn?: string): void;
|
|
27
|
+
/** The runtime went idle on the legacy hook fallback — surface idle WITHOUT finalizing
|
|
25
28
|
* the turn. claude fires Stop around the same time it flushes the final
|
|
26
29
|
* assistant record and the two orderings race; finalizing here would split a
|
|
27
30
|
* late assistant record into its own turn. The turn is finalized by the next
|
|
@@ -105,6 +108,14 @@ export declare class ClaudeLiveSession {
|
|
|
105
108
|
private lastActivityAt;
|
|
106
109
|
/** When the Stop hook fired (null = not yet) — drives the grace-period close. */
|
|
107
110
|
private stopPendingAt;
|
|
111
|
+
/** An idle status from Claude's session metadata. The short delay keeps a
|
|
112
|
+
* final transcript record in the response without letting stale tool state
|
|
113
|
+
* override the provider's terminal status. */
|
|
114
|
+
private providerIdleAt;
|
|
115
|
+
private statusPoller?;
|
|
116
|
+
/** A failed hook is authoritative until the next running edge. Claude writes
|
|
117
|
+
* idle after failures too, so that trailing file update must not erase it. */
|
|
118
|
+
private providerFailureSticky;
|
|
108
119
|
/** Open tool-call ids (start seen, end not yet) — suppresses the idle backstop. */
|
|
109
120
|
private readonly openToolIds;
|
|
110
121
|
/** Transcript-backed acknowledgements for web→TUI input. A direct idle
|
|
@@ -186,6 +197,11 @@ export declare class ClaudeLiveSession {
|
|
|
186
197
|
/** One poll cycle (hooks → transcript → idle backstop). Exposed for tests to
|
|
187
198
|
* drive deterministically; the async {@link loop} just calls it on an interval. */
|
|
188
199
|
tick(): void;
|
|
200
|
+
/** Bind Claude's per-process session metadata after the terminal exists. */
|
|
201
|
+
attachStatusSource({ panePid, configDir, }: {
|
|
202
|
+
panePid: () => number | undefined;
|
|
203
|
+
configDir?: string;
|
|
204
|
+
}): void;
|
|
189
205
|
private loop;
|
|
190
206
|
private pollHooks;
|
|
191
207
|
private handleHook;
|
|
@@ -255,6 +271,8 @@ export declare class ClaudeLiveSession {
|
|
|
255
271
|
/** Refresh the latest statusLine context/cost snapshot (a last-writer-wins file
|
|
256
272
|
* the statusLine hook overwrites on every TUI render). */
|
|
257
273
|
private pollStatus;
|
|
274
|
+
private pollSessionStatus;
|
|
275
|
+
private handleSessionStatus;
|
|
258
276
|
/** A usage record from the latest statusLine snapshot (snake_case, the keys the
|
|
259
277
|
* normalizer's `turn_completed` + the web `readUsageTokens` read), or undefined
|
|
260
278
|
* if the statusLine hook has not fired yet. */
|
|
@@ -283,6 +301,10 @@ export declare class ClaudeLiveSession {
|
|
|
283
301
|
* sent an Escape — an Escape into idle claude submits an empty turn, which
|
|
284
302
|
* claude answers with a stray "No response requested." bubble. */
|
|
285
303
|
isTurnOpen(): boolean;
|
|
304
|
+
/** Fail an open turn exactly once when its native terminal/runner disappears. */
|
|
305
|
+
failOpenTurn(error: Error): boolean;
|
|
306
|
+
/** Retire process-scoped metadata before classifying a terminal exit. */
|
|
307
|
+
noteTerminalExit(error: Error): boolean;
|
|
286
308
|
private closeTurn;
|
|
287
309
|
private closeTurnError;
|
|
288
310
|
private resetMessageCorrelation;
|
|
@@ -292,6 +314,8 @@ export declare class ClaudeLiveSession {
|
|
|
292
314
|
* {@link import("../terminal/tmux.js").TmuxTerminal}). The runner-child hands
|
|
293
315
|
* this to the host after launching the pane, since the host doesn't own tmux. */
|
|
294
316
|
export interface TerminalInjector {
|
|
317
|
+
/** PID of the process owning the tmux pane, when available. */
|
|
318
|
+
panePid?(): number | undefined;
|
|
295
319
|
capturePane(): string;
|
|
296
320
|
clearInputLine(): void;
|
|
297
321
|
paste(text: string): void;
|
|
@@ -303,12 +327,16 @@ export interface InjectViaTerminalOptions {
|
|
|
303
327
|
promptGlyph?: string;
|
|
304
328
|
promptTimeoutMs?: number;
|
|
305
329
|
settleMs?: number;
|
|
330
|
+
/** Wait for the pasted draft to become visible before the first Enter. */
|
|
331
|
+
pasteCommitMs?: number;
|
|
306
332
|
/** Transcript-backed proof that Claude accepted this exact input. When
|
|
307
|
-
*
|
|
333
|
+
* present it takes precedence over pane heuristics. */
|
|
308
334
|
submissionObserved?: () => boolean;
|
|
309
|
-
/**
|
|
335
|
+
/** Total post-Enter verification budget. */
|
|
310
336
|
submitConfirmMs?: number;
|
|
311
|
-
/**
|
|
337
|
+
/** Minimum delay between Enter retries while the draft remains visible. */
|
|
338
|
+
submitRetryMs?: number;
|
|
339
|
+
/** Optional safety cap for tests/callers; the time budget remains authoritative. */
|
|
312
340
|
maxSubmitAttempts?: number;
|
|
313
341
|
pollMs?: number;
|
|
314
342
|
now?: () => number;
|
|
@@ -324,8 +352,8 @@ export interface InjectViaTerminalOptions {
|
|
|
324
352
|
*
|
|
325
353
|
* THROWS if the prompt never appears within the ready-gate window (reference implementation
|
|
326
354
|
* `_wait_for_claude_prompt_ready` RAISE) — a not-ready pane is a hard error the
|
|
327
|
-
* caller reports, NOT a signal to fall through to a second output path.
|
|
328
|
-
*
|
|
329
|
-
* neither a direct user prompt nor a type-ahead enqueue.
|
|
355
|
+
* caller reports, NOT a signal to fall through to a second output path. Enter
|
|
356
|
+
* is retried only while the exact draft remains visible and Claude has durably
|
|
357
|
+
* recorded neither a direct user prompt nor a type-ahead enqueue.
|
|
330
358
|
*/
|
|
331
359
|
export declare function injectViaTerminal(injector: TerminalInjector, text: string, opts?: InjectViaTerminalOptions): Promise<boolean>;
|
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
* the Claude Code TUI feeds through its hooks + transcript:
|
|
6
6
|
*
|
|
7
7
|
* - `hooks.jsonl` (bridge) — `SessionStart` reveals the transcript path + claude
|
|
8
|
-
* session id
|
|
8
|
+
* session id; `StopFailure` supplies the failure edge and `Stop` carries the
|
|
9
|
+
* legacy fallback boundary.
|
|
9
10
|
* - the transcript JSONL — the turn OPENS on a `role:user` prompt record and its
|
|
10
|
-
* assistant/tool records become {@link AgentEvent}s.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* assistant/tool records become {@link AgentEvent}s.
|
|
12
|
+
* - Claude's per-process session file — while readable, it is the source of
|
|
13
|
+
* truth for running/idle and supersedes the hook/pane heuristics.
|
|
13
14
|
*
|
|
14
15
|
* The mapped events drive the SAME per-turn {@link SessionNormalizer} sink shape
|
|
15
16
|
* the codex forwarder uses (see the host), so mirroring is identical downstream.
|
|
@@ -21,6 +22,7 @@ import { statSync } from "node:fs";
|
|
|
21
22
|
import { parseTerminalCommand, parseTranscriptRecord, readSubagentEvents, subagentTranscriptPath, transcriptHasForkedFrom, } from "./transcript.js";
|
|
22
23
|
import { jsonlCursorFingerprint, interactionLeaseUpdatedAt, readClaimedInteractionResult, readClaudeStatus, readForwardState, readInteractionAcksFrom, readHookEventsFrom, readInteractionRequestsFrom, readJsonlFrom, readMessageDeltasFrom, resetForwardState, removeClaimedInteractionResult, removeInteractionLease, removeInteractionResult, scrubClaudeInteractionArtifacts, writeInteractionResult, writeForwardState, } from "./native-bridge.js";
|
|
23
24
|
import { boundInteractionRequest, redactInteractionResolution, validateInteractionResolution, } from "../interactions.js";
|
|
25
|
+
import { ClaudeSessionStatusPoller, } from "./session-status.js";
|
|
24
26
|
const MAX_SETTLED_INTERACTIONS = 512;
|
|
25
27
|
const MAX_MESSAGE_CORRELATION_BACKLOG = 64;
|
|
26
28
|
const MAX_SUBMISSION_OBSERVATIONS = 64;
|
|
@@ -169,6 +171,14 @@ export class ClaudeLiveSession {
|
|
|
169
171
|
lastActivityAt = 0;
|
|
170
172
|
/** When the Stop hook fired (null = not yet) — drives the grace-period close. */
|
|
171
173
|
stopPendingAt = null;
|
|
174
|
+
/** An idle status from Claude's session metadata. The short delay keeps a
|
|
175
|
+
* final transcript record in the response without letting stale tool state
|
|
176
|
+
* override the provider's terminal status. */
|
|
177
|
+
providerIdleAt = null;
|
|
178
|
+
statusPoller;
|
|
179
|
+
/** A failed hook is authoritative until the next running edge. Claude writes
|
|
180
|
+
* idle after failures too, so that trailing file update must not erase it. */
|
|
181
|
+
providerFailureSticky = false;
|
|
172
182
|
/** Open tool-call ids (start seen, end not yet) — suppresses the idle backstop. */
|
|
173
183
|
openToolIds = new Set();
|
|
174
184
|
/** Transcript-backed acknowledgements for web→TUI input. A direct idle
|
|
@@ -328,6 +338,7 @@ export class ClaudeLiveSession {
|
|
|
328
338
|
if (this.stopped)
|
|
329
339
|
return;
|
|
330
340
|
this.stopped = true;
|
|
341
|
+
this.statusPoller?.retire();
|
|
331
342
|
// A hook may have appended its request just before shutdown but not yet
|
|
332
343
|
// reached a scheduled poll. Drain it so the subprocess receives cancellation
|
|
333
344
|
// instead of remaining blocked until Claude's one-day hook timeout.
|
|
@@ -365,9 +376,20 @@ export class ClaudeLiveSession {
|
|
|
365
376
|
this.pollAbandonedInteractions();
|
|
366
377
|
this.flushTurnFailure();
|
|
367
378
|
this.pollStatus();
|
|
379
|
+
this.pollSessionStatus();
|
|
368
380
|
this.flushStopSignal();
|
|
369
381
|
this.maybeIdleClose();
|
|
370
382
|
}
|
|
383
|
+
/** Bind Claude's per-process session metadata after the terminal exists. */
|
|
384
|
+
attachStatusSource({ panePid, configDir, }) {
|
|
385
|
+
this.statusPoller?.retire();
|
|
386
|
+
this.statusPoller = new ClaudeSessionStatusPoller({
|
|
387
|
+
panePid,
|
|
388
|
+
sessionId: () => this.currentClaudeSessionId,
|
|
389
|
+
onStatus: (status) => this.handleSessionStatus(status),
|
|
390
|
+
...(configDir ? { configDir } : {}),
|
|
391
|
+
});
|
|
392
|
+
}
|
|
371
393
|
async loop() {
|
|
372
394
|
while (!this.stopped) {
|
|
373
395
|
try {
|
|
@@ -423,6 +445,7 @@ export class ClaudeLiveSession {
|
|
|
423
445
|
if (!error)
|
|
424
446
|
return;
|
|
425
447
|
this.turnFailurePending = null;
|
|
448
|
+
this.providerFailureSticky = true;
|
|
426
449
|
this.closeTurnError(error);
|
|
427
450
|
}
|
|
428
451
|
/** Emit the Stop idle signal only after this tick has discovered native
|
|
@@ -431,6 +454,8 @@ export class ClaudeLiveSession {
|
|
|
431
454
|
if (!this.stopSignalPending)
|
|
432
455
|
return;
|
|
433
456
|
this.stopSignalPending = false;
|
|
457
|
+
if (this.statusPoller?.active)
|
|
458
|
+
return;
|
|
434
459
|
if (this.pendingInteractions.size === 0)
|
|
435
460
|
this.sink.onIdle();
|
|
436
461
|
}
|
|
@@ -844,6 +869,15 @@ export class ClaudeLiveSession {
|
|
|
844
869
|
}
|
|
845
870
|
// A real prompt (not an XML-marker bookkeeping record) opens a new turn.
|
|
846
871
|
if (!content.startsWith("<")) {
|
|
872
|
+
const queuedIntoOpenTurn = this.turnOpen && (queuedPromotion || rec.promptSource === "queued" || rec.promptSource === "sdk");
|
|
873
|
+
if (queuedIntoOpenTurn) {
|
|
874
|
+
// Claude type-ahead is one native busy interval: enqueue now, promote
|
|
875
|
+
// later, and emit one final Stop. Do not close/re-open the canonical
|
|
876
|
+
// response when the promoted user record arrives mid-turn.
|
|
877
|
+
this.sink.onUserMessage(content);
|
|
878
|
+
this.lastActivityAt = this.now();
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
847
881
|
const turnId = typeof rec.uuid === "string" ? rec.uuid : undefined;
|
|
848
882
|
if (this.turnOpen && this.syntheticTurn) {
|
|
849
883
|
// A hook can flush just before the transcript's user record. The
|
|
@@ -857,6 +891,7 @@ export class ClaudeLiveSession {
|
|
|
857
891
|
this.currentTurnId = turnId;
|
|
858
892
|
this.turnOpen = true;
|
|
859
893
|
this.syntheticTurn = false;
|
|
894
|
+
this.providerIdleAt = null;
|
|
860
895
|
this.openToolIds.clear();
|
|
861
896
|
this.stopPendingAt = null;
|
|
862
897
|
this.sink.onTurnStart(this.currentTurnId);
|
|
@@ -978,6 +1013,27 @@ export class ClaudeLiveSession {
|
|
|
978
1013
|
if (status)
|
|
979
1014
|
this.latestStatus = status;
|
|
980
1015
|
}
|
|
1016
|
+
pollSessionStatus() {
|
|
1017
|
+
const poller = this.statusPoller;
|
|
1018
|
+
if (!poller)
|
|
1019
|
+
return;
|
|
1020
|
+
const wasActive = poller.active;
|
|
1021
|
+
poller.tick();
|
|
1022
|
+
if (wasActive && !poller.active)
|
|
1023
|
+
this.providerIdleAt = null;
|
|
1024
|
+
}
|
|
1025
|
+
handleSessionStatus(status) {
|
|
1026
|
+
this.stopSignalPending = false;
|
|
1027
|
+
this.stopPendingAt = null;
|
|
1028
|
+
if (status.runnerStatus === "running") {
|
|
1029
|
+
this.providerFailureSticky = false;
|
|
1030
|
+
}
|
|
1031
|
+
else if (this.providerFailureSticky) {
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
this.sink.onStatus?.(status.runnerStatus, status.blockedOn);
|
|
1035
|
+
this.providerIdleAt = status.runnerStatus === "idle" ? this.now() : null;
|
|
1036
|
+
}
|
|
981
1037
|
/** A usage record from the latest statusLine snapshot (snake_case, the keys the
|
|
982
1038
|
* normalizer's `turn_completed` + the web `readUsageTokens` read), or undefined
|
|
983
1039
|
* if the statusLine hook has not fired yet. */
|
|
@@ -1042,6 +1098,7 @@ export class ClaudeLiveSession {
|
|
|
1042
1098
|
this.currentTurnId = provisionalTurnId;
|
|
1043
1099
|
this.syntheticTurn = true;
|
|
1044
1100
|
}
|
|
1101
|
+
this.providerIdleAt = null;
|
|
1045
1102
|
this.turnOpen = true;
|
|
1046
1103
|
this.sink.onTurnStart(this.currentTurnId);
|
|
1047
1104
|
}
|
|
@@ -1067,6 +1124,8 @@ export class ClaudeLiveSession {
|
|
|
1067
1124
|
return;
|
|
1068
1125
|
this.cancelPendingInteractions("turn_interrupted");
|
|
1069
1126
|
this.stopSignalPending = false;
|
|
1127
|
+
if (this.statusPoller?.active)
|
|
1128
|
+
return;
|
|
1070
1129
|
this.sink.onIdle();
|
|
1071
1130
|
this.stopPendingAt = this.now();
|
|
1072
1131
|
}
|
|
@@ -1077,6 +1136,18 @@ export class ClaudeLiveSession {
|
|
|
1077
1136
|
isTurnOpen() {
|
|
1078
1137
|
return this.turnOpen;
|
|
1079
1138
|
}
|
|
1139
|
+
/** Fail an open turn exactly once when its native terminal/runner disappears. */
|
|
1140
|
+
failOpenTurn(error) {
|
|
1141
|
+
if (!this.turnOpen)
|
|
1142
|
+
return false;
|
|
1143
|
+
this.closeTurnError(error);
|
|
1144
|
+
return true;
|
|
1145
|
+
}
|
|
1146
|
+
/** Retire process-scoped metadata before classifying a terminal exit. */
|
|
1147
|
+
noteTerminalExit(error) {
|
|
1148
|
+
this.statusPoller?.retire();
|
|
1149
|
+
return this.failOpenTurn(error);
|
|
1150
|
+
}
|
|
1080
1151
|
closeTurn() {
|
|
1081
1152
|
if (!this.turnOpen)
|
|
1082
1153
|
return;
|
|
@@ -1112,6 +1183,14 @@ export class ClaudeLiveSession {
|
|
|
1112
1183
|
this.streamedMessageText.clear();
|
|
1113
1184
|
}
|
|
1114
1185
|
maybeIdleClose() {
|
|
1186
|
+
if (this.statusPoller?.active) {
|
|
1187
|
+
if (this.turnOpen &&
|
|
1188
|
+
this.providerIdleAt !== null &&
|
|
1189
|
+
this.now() - this.providerIdleAt >= this.stopGraceMs) {
|
|
1190
|
+
this.closeTurn();
|
|
1191
|
+
}
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1115
1194
|
if (!this.turnOpen ||
|
|
1116
1195
|
this.openToolIds.size > 0 ||
|
|
1117
1196
|
this.pendingInteractions.size > 0)
|
|
@@ -1132,14 +1211,79 @@ export class ClaudeLiveSession {
|
|
|
1132
1211
|
/** Claude Code renders this glyph once the input box is mounted (ready-gate). */
|
|
1133
1212
|
const CLAUDE_PROMPT_GLYPH = "❯";
|
|
1134
1213
|
const CLAUDE_PROMPT_SCAN_TAIL_LINES = 5;
|
|
1214
|
+
const CLAUDE_BOX_RULE_CHARS = new Set([..."─━╭╮╰╯│┃╌╍"]);
|
|
1215
|
+
const CLAUDE_PASTED_PLACEHOLDER_PREFIX = "[Pasted text";
|
|
1216
|
+
const CLAUDE_DRAFT_NEEDLE_MAX_CHARS = 24;
|
|
1217
|
+
/** Claude-native readiness window: tmux may exist well before the
|
|
1218
|
+
* first interactive composer mounts on a cold start. */
|
|
1219
|
+
const CLAUDE_PROMPT_READY_TIMEOUT_MS = 30_000;
|
|
1220
|
+
const CLAUDE_PASTE_COMMIT_TIMEOUT_MS = 5_000;
|
|
1221
|
+
const CLAUDE_PASTE_SETTLE_MS = 100;
|
|
1222
|
+
const CLAUDE_SUBMIT_CONFIRM_TIMEOUT_MS = 10_000;
|
|
1223
|
+
const CLAUDE_SUBMIT_RETRY_MS = 1_000;
|
|
1224
|
+
function selectedMenuRow(line, glyph) {
|
|
1225
|
+
const index = line.indexOf(glyph);
|
|
1226
|
+
if (index < 0)
|
|
1227
|
+
return false;
|
|
1228
|
+
return /^\d+\.\s/.test(line.slice(index + glyph.length).trimStart());
|
|
1229
|
+
}
|
|
1230
|
+
function boxRule(line) {
|
|
1231
|
+
const text = line.trim();
|
|
1232
|
+
return text.length >= 3 && [...text].every((character) => CLAUDE_BOX_RULE_CHARS.has(character));
|
|
1233
|
+
}
|
|
1135
1234
|
/** The live composer sits at the bottom of the pane. Restricting readiness to
|
|
1136
1235
|
* its trailing non-empty lines prevents an old prompt glyph in scrollback from
|
|
1137
1236
|
* accepting input while Claude is still booting or showing another screen. */
|
|
1138
1237
|
function claudePromptRendered(pane, glyph) {
|
|
1139
1238
|
const nonEmpty = pane.split(/\r?\n/).filter((line) => line.trim());
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1239
|
+
const tailStart = Math.max(0, nonEmpty.length - CLAUDE_PROMPT_SCAN_TAIL_LINES);
|
|
1240
|
+
for (let index = tailStart; index < nonEmpty.length; index += 1) {
|
|
1241
|
+
const line = nonEmpty[index] ?? "";
|
|
1242
|
+
if (!line.includes(glyph))
|
|
1243
|
+
continue;
|
|
1244
|
+
const framed = nonEmpty.slice(index + 1).some(boxRule);
|
|
1245
|
+
if (framed)
|
|
1246
|
+
return true;
|
|
1247
|
+
if (!selectedMenuRow(line, glyph))
|
|
1248
|
+
return true;
|
|
1249
|
+
}
|
|
1250
|
+
// A fan-out of subagents can make the running footer arbitrarily tall. Above
|
|
1251
|
+
// the fixed tail, only a prompt framed by the composer's closing rule counts.
|
|
1252
|
+
for (let index = 0; index < tailStart; index += 1) {
|
|
1253
|
+
const line = nonEmpty[index] ?? "";
|
|
1254
|
+
if (line.includes(glyph) && nonEmpty.slice(index + 1).some(boxRule))
|
|
1255
|
+
return true;
|
|
1256
|
+
}
|
|
1257
|
+
return false;
|
|
1258
|
+
}
|
|
1259
|
+
function draftNeedle(content) {
|
|
1260
|
+
const normalized = content.replace(/\r\n?/g, "\n");
|
|
1261
|
+
for (const rawLine of normalized.split("\n")) {
|
|
1262
|
+
const control = rawLine.search(/[\x00-\x1f]/);
|
|
1263
|
+
const line = (control >= 0 ? rawLine.slice(0, control) : rawLine).trim();
|
|
1264
|
+
if (line)
|
|
1265
|
+
return line.slice(0, CLAUDE_DRAFT_NEEDLE_MAX_CHARS);
|
|
1266
|
+
}
|
|
1267
|
+
return "";
|
|
1268
|
+
}
|
|
1269
|
+
function draftInInputBox(pane, glyph, needle) {
|
|
1270
|
+
const lines = pane.split(/\r?\n/).filter((line) => line.includes(glyph));
|
|
1271
|
+
const line = lines.at(-1);
|
|
1272
|
+
if (!line)
|
|
1273
|
+
return false;
|
|
1274
|
+
const tail = line.slice(line.lastIndexOf(glyph) + glyph.length);
|
|
1275
|
+
if (tail.includes(CLAUDE_PASTED_PLACEHOLDER_PREFIX))
|
|
1276
|
+
return true;
|
|
1277
|
+
return Boolean(needle) && tail.includes(needle);
|
|
1278
|
+
}
|
|
1279
|
+
function terminalFailureTail(pane) {
|
|
1280
|
+
const lines = pane.split(/\r?\n/).filter((line) => line.trim()).slice(-12);
|
|
1281
|
+
if (lines.length === 0)
|
|
1282
|
+
return "";
|
|
1283
|
+
let tail = lines.join("\n");
|
|
1284
|
+
if (tail.length > 800)
|
|
1285
|
+
tail = `…${tail.slice(-800)}`;
|
|
1286
|
+
return `\nlast terminal output:\n${tail}`;
|
|
1143
1287
|
}
|
|
1144
1288
|
async function pollUntil(pred, timeoutMs, pollMs, now, sleep, signal) {
|
|
1145
1289
|
const deadline = now() + timeoutMs;
|
|
@@ -1159,9 +1303,9 @@ async function pollUntil(pred, timeoutMs, pollMs, now, sleep, signal) {
|
|
|
1159
1303
|
*
|
|
1160
1304
|
* THROWS if the prompt never appears within the ready-gate window (reference implementation
|
|
1161
1305
|
* `_wait_for_claude_prompt_ready` RAISE) — a not-ready pane is a hard error the
|
|
1162
|
-
* caller reports, NOT a signal to fall through to a second output path.
|
|
1163
|
-
*
|
|
1164
|
-
* neither a direct user prompt nor a type-ahead enqueue.
|
|
1306
|
+
* caller reports, NOT a signal to fall through to a second output path. Enter
|
|
1307
|
+
* is retried only while the exact draft remains visible and Claude has durably
|
|
1308
|
+
* recorded neither a direct user prompt nor a type-ahead enqueue.
|
|
1165
1309
|
*/
|
|
1166
1310
|
export async function injectViaTerminal(injector, text, opts = {}) {
|
|
1167
1311
|
const glyph = opts.promptGlyph ?? CLAUDE_PROMPT_GLYPH;
|
|
@@ -1180,12 +1324,23 @@ export async function injectViaTerminal(injector, text, opts = {}) {
|
|
|
1180
1324
|
};
|
|
1181
1325
|
// 1. Ready-gate. No prompt within the window → THROW (reference implementation RAISE): a
|
|
1182
1326
|
// not-ready pane is a hard error, never a fall-through-to-run signal.
|
|
1183
|
-
|
|
1327
|
+
let promptPolls = 0;
|
|
1328
|
+
let emptyPromptPolls = 0;
|
|
1329
|
+
let lastPromptPane = "";
|
|
1330
|
+
const ready = await pollUntil(() => {
|
|
1331
|
+
const pane = injector.capturePane();
|
|
1332
|
+
promptPolls += 1;
|
|
1333
|
+
if (pane.trim())
|
|
1334
|
+
lastPromptPane = pane;
|
|
1335
|
+
else
|
|
1336
|
+
emptyPromptPolls += 1;
|
|
1337
|
+
return claudePromptRendered(pane, glyph);
|
|
1338
|
+
}, opts.promptTimeoutMs ?? CLAUDE_PROMPT_READY_TIMEOUT_MS, pollMs, now, sleep, signal);
|
|
1184
1339
|
if (cancelled())
|
|
1185
1340
|
return false;
|
|
1186
1341
|
if (!ready) {
|
|
1187
|
-
|
|
1188
|
-
|
|
1342
|
+
throw new Error(`claude prompt not ready (no input composer within timeout; ${promptPolls} polls, ` +
|
|
1343
|
+
`${emptyPromptPolls} empty captures)${terminalFailureTail(lastPromptPane)}`);
|
|
1189
1344
|
}
|
|
1190
1345
|
// 2. Clear leftover, then bracketed-paste the draft. A final "\" is Claude's
|
|
1191
1346
|
// documented soft-newline escape: a bare submit Enter would consume it and
|
|
@@ -1193,30 +1348,47 @@ export async function injectViaTerminal(injector, text, opts = {}) {
|
|
|
1193
1348
|
// the bracketed paste; normal messages must not gain an empty input row.
|
|
1194
1349
|
injector.clearInputLine();
|
|
1195
1350
|
injector.paste(text.endsWith("\\") ? `${text}\n` : text);
|
|
1196
|
-
// 3. `paste-buffer`
|
|
1197
|
-
//
|
|
1198
|
-
//
|
|
1199
|
-
|
|
1200
|
-
await
|
|
1351
|
+
// 3. tmux accepting `paste-buffer` does not mean Claude consumed it. Wait for
|
|
1352
|
+
// the draft (or its large-paste placeholder) to appear in the live composer;
|
|
1353
|
+
// otherwise Enter can be coalesced into the paste as a newline.
|
|
1354
|
+
const needle = draftNeedle(text);
|
|
1355
|
+
const draftSeen = await pollUntil(() => draftInInputBox(injector.capturePane(), glyph, needle), opts.pasteCommitMs ?? CLAUDE_PASTE_COMMIT_TIMEOUT_MS, pollMs, now, sleep, signal);
|
|
1356
|
+
await sleep(opts.settleMs ?? CLAUDE_PASTE_SETTLE_MS);
|
|
1201
1357
|
if (cancelled())
|
|
1202
1358
|
return false; // Stop pressed mid-paste → don't submit
|
|
1203
|
-
// 4. Submit.
|
|
1204
|
-
//
|
|
1205
|
-
//
|
|
1206
|
-
// queue enqueue ends the loop immediately.
|
|
1359
|
+
// 4. Submit and verify. Retry only while this exact draft is still visible;
|
|
1360
|
+
// once it leaves the composer, another Enter could hit a permission dialog or
|
|
1361
|
+
// an empty prompt. Transcript acknowledgement is the strongest success proof.
|
|
1207
1362
|
const observed = opts.submissionObserved;
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1363
|
+
let attempts = 1;
|
|
1364
|
+
const maxAttempts = Math.max(1, Math.floor(opts.maxSubmitAttempts ?? Number.MAX_SAFE_INTEGER));
|
|
1365
|
+
injector.sendEnter();
|
|
1366
|
+
// Preserve the old blind-submit fallback when capture-pane cannot
|
|
1367
|
+
// identify the draft; its absence would otherwise "prove" success trivially.
|
|
1368
|
+
if (!draftSeen)
|
|
1369
|
+
return true;
|
|
1370
|
+
const deadline = now() + (opts.submitConfirmMs ?? CLAUDE_SUBMIT_CONFIRM_TIMEOUT_MS);
|
|
1371
|
+
const retryMs = opts.submitRetryMs ?? CLAUDE_SUBMIT_RETRY_MS;
|
|
1372
|
+
let lastEnterAt = now();
|
|
1373
|
+
while (now() < deadline) {
|
|
1374
|
+
if (cancelled())
|
|
1375
|
+
return false;
|
|
1376
|
+
if (observed?.())
|
|
1214
1377
|
return true;
|
|
1215
|
-
if (
|
|
1378
|
+
if (!draftInInputBox(injector.capturePane(), glyph, needle))
|
|
1216
1379
|
return true;
|
|
1380
|
+
if (now() - lastEnterAt >= retryMs && attempts < maxAttempts) {
|
|
1381
|
+
injector.sendEnter();
|
|
1382
|
+
attempts += 1;
|
|
1383
|
+
lastEnterAt = now();
|
|
1217
1384
|
}
|
|
1218
|
-
|
|
1219
|
-
return false;
|
|
1385
|
+
await sleep(pollMs);
|
|
1220
1386
|
}
|
|
1221
|
-
|
|
1387
|
+
if (observed?.())
|
|
1388
|
+
return true;
|
|
1389
|
+
if (!draftInInputBox(injector.capturePane(), glyph, needle))
|
|
1390
|
+
return true;
|
|
1391
|
+
throw new Error(`Claude Code did not accept the submitted message within ` +
|
|
1392
|
+
`${opts.submitConfirmMs ?? CLAUDE_SUBMIT_CONFIRM_TIMEOUT_MS}ms ` +
|
|
1393
|
+
"(the draft is still in the input box). The message was not delivered.");
|
|
1222
1394
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type ClaudeRunnerStatus = "running" | "idle";
|
|
2
|
+
export interface ClaudeSessionStatus {
|
|
3
|
+
runnerStatus: ClaudeRunnerStatus;
|
|
4
|
+
rawStatus: string;
|
|
5
|
+
statusUpdatedAt?: number;
|
|
6
|
+
blockedOn?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function claudeSessionsDir(configDir?: string): string;
|
|
9
|
+
export declare function resolveClaudeSessionStatusFile({ panePid, expectedSessionId, configDir, now, }: {
|
|
10
|
+
panePid?: number;
|
|
11
|
+
expectedSessionId?: string;
|
|
12
|
+
configDir?: string;
|
|
13
|
+
now?: number;
|
|
14
|
+
}): string | undefined;
|
|
15
|
+
export declare function readClaudeSessionStatus(path: string): ClaudeSessionStatus | undefined;
|
|
16
|
+
export declare class ClaudeSessionStatusPoller {
|
|
17
|
+
private readonly options;
|
|
18
|
+
private path?;
|
|
19
|
+
private attempts;
|
|
20
|
+
private exhausted;
|
|
21
|
+
private lastMtime?;
|
|
22
|
+
private lastEdge?;
|
|
23
|
+
private lastStatus?;
|
|
24
|
+
constructor(options: {
|
|
25
|
+
onStatus: (status: ClaudeSessionStatus) => void;
|
|
26
|
+
panePid: () => number | undefined;
|
|
27
|
+
sessionId: () => string | undefined;
|
|
28
|
+
configDir?: string;
|
|
29
|
+
maxResolveAttempts?: number;
|
|
30
|
+
now?: () => number;
|
|
31
|
+
});
|
|
32
|
+
get active(): boolean;
|
|
33
|
+
get status(): ClaudeSessionStatus | undefined;
|
|
34
|
+
tick(): void;
|
|
35
|
+
retire(): void;
|
|
36
|
+
resync(): void;
|
|
37
|
+
private tryResolve;
|
|
38
|
+
private readAndPublish;
|
|
39
|
+
}
|