@rynx-ai/runtime 0.1.11-beta.3 → 0.1.11-beta.30
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 +19 -5
- package/dist/claude/executor.js +56 -12
- package/dist/claude/models.d.ts +0 -5
- package/dist/claude/models.js +1 -7
- package/dist/claude/native-bridge.d.ts +2 -0
- package/dist/claude/native-bridge.js +23 -0
- package/dist/claude/native-hook-main.js +62 -0
- package/dist/claude/native-integration.d.ts +50 -10
- package/dist/claude/native-integration.js +262 -37
- package/dist/claude/session-status.d.ts +39 -0
- package/dist/claude/session-status.js +163 -0
- package/dist/claude/transcript.js +27 -17
- package/dist/codex-app-server/client.d.ts +10 -6
- package/dist/codex-app-server/client.js +67 -15
- package/dist/codex-app-server/forwarder.d.ts +92 -3
- package/dist/codex-app-server/forwarder.js +509 -56
- package/dist/codex-app-server/mapping.d.ts +3 -6
- package/dist/codex-app-server/mapping.js +174 -28
- 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 +64 -7
- package/dist/codex-app-server/ws-channel.js +19 -19
- package/dist/codex-home.js +2 -4
- package/dist/host.d.ts +64 -21
- package/dist/host.js +1330 -441
- package/dist/index.d.ts +1 -1
- package/dist/input-resources.d.ts +4 -0
- package/dist/input-resources.js +21 -5
- package/dist/models-catalog.d.ts +2 -1
- package/dist/models-catalog.js +94 -6
- package/dist/runner/child.d.ts +48 -21
- package/dist/runner/child.js +550 -48
- package/dist/runner/manager.d.ts +54 -13
- package/dist/runner/manager.js +479 -114
- package/dist/runner/protocol.d.ts +62 -19
- package/dist/runner/protocol.js +5 -0
- package/dist/runner/startup-policy.d.ts +7 -0
- package/dist/runner/startup-policy.js +10 -0
- package/dist/terminal/claude-tui.d.ts +3 -1
- package/dist/terminal/claude-tui.js +3 -1
- package/dist/terminal/registry.js +3 -2
- package/dist/terminal/tmux.d.ts +50 -7
- package/dist/terminal/tmux.js +168 -47
- package/package.json +4 -3
|
@@ -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,10 +22,15 @@ 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;
|
|
27
29
|
const INTERACTION_ACK_TIMEOUT_MS = 5_000;
|
|
30
|
+
// Claude writes this synthetic user record after Escape (including a tool-use
|
|
31
|
+
// interruption). It is lifecycle, not a new prompt. Kept aligned with
|
|
32
|
+
// reference implementation's `_CLAUDE_INTERRUPT_RECORD_RE`.
|
|
33
|
+
const CLAUDE_INTERRUPT_RECORD_RE = /^\[Request interrupted by user(?: for tool use)?\]$/;
|
|
28
34
|
const INTERACTION_LEASE_TIMEOUT_MS = 30_000;
|
|
29
35
|
function processIsAlive(pid) {
|
|
30
36
|
try {
|
|
@@ -163,12 +169,26 @@ export class ClaudeLiveSession {
|
|
|
163
169
|
seenClaudeSessionIds = new Set();
|
|
164
170
|
turnOpen = false;
|
|
165
171
|
currentTurnId;
|
|
172
|
+
/** The open turn received an explicit Escape/Stop and must close cancelled. */
|
|
173
|
+
turnInterrupted = false;
|
|
166
174
|
/** The Turn opened from a pre-transcript interaction. Its unique provisional
|
|
167
175
|
* id keeps responses distinct until the real prompt uuid can be adopted. */
|
|
168
176
|
syntheticTurn = false;
|
|
169
177
|
lastActivityAt = 0;
|
|
170
178
|
/** When the Stop hook fired (null = not yet) — drives the grace-period close. */
|
|
171
179
|
stopPendingAt = null;
|
|
180
|
+
/** Authoritative live background-shell count from the pending Stop hook. */
|
|
181
|
+
stopBackgroundTaskCount;
|
|
182
|
+
/** Whether the pending Stop count already rode an immediate idle edge. */
|
|
183
|
+
stopBackgroundTaskCountDelivered = false;
|
|
184
|
+
/** An idle status from Claude's session metadata. The short delay keeps a
|
|
185
|
+
* final transcript record in the response without letting stale tool state
|
|
186
|
+
* override the provider's terminal status. */
|
|
187
|
+
providerIdleAt = null;
|
|
188
|
+
statusPoller;
|
|
189
|
+
/** A failed hook is authoritative until the next running edge. Claude writes
|
|
190
|
+
* idle after failures too, so that trailing file update must not erase it. */
|
|
191
|
+
providerFailureSticky = false;
|
|
172
192
|
/** Open tool-call ids (start seen, end not yet) — suppresses the idle backstop. */
|
|
173
193
|
openToolIds = new Set();
|
|
174
194
|
/** Transcript-backed acknowledgements for web→TUI input. A direct idle
|
|
@@ -328,6 +348,7 @@ export class ClaudeLiveSession {
|
|
|
328
348
|
if (this.stopped)
|
|
329
349
|
return;
|
|
330
350
|
this.stopped = true;
|
|
351
|
+
this.statusPoller?.retire();
|
|
331
352
|
// A hook may have appended its request just before shutdown but not yet
|
|
332
353
|
// reached a scheduled poll. Drain it so the subprocess receives cancellation
|
|
333
354
|
// instead of remaining blocked until Claude's one-day hook timeout.
|
|
@@ -365,9 +386,20 @@ export class ClaudeLiveSession {
|
|
|
365
386
|
this.pollAbandonedInteractions();
|
|
366
387
|
this.flushTurnFailure();
|
|
367
388
|
this.pollStatus();
|
|
389
|
+
this.pollSessionStatus();
|
|
368
390
|
this.flushStopSignal();
|
|
369
391
|
this.maybeIdleClose();
|
|
370
392
|
}
|
|
393
|
+
/** Bind Claude's per-process session metadata after the terminal exists. */
|
|
394
|
+
attachStatusSource({ panePid, configDir, }) {
|
|
395
|
+
this.statusPoller?.retire();
|
|
396
|
+
this.statusPoller = new ClaudeSessionStatusPoller({
|
|
397
|
+
panePid,
|
|
398
|
+
sessionId: () => this.currentClaudeSessionId,
|
|
399
|
+
onStatus: (status) => this.handleSessionStatus(status),
|
|
400
|
+
...(configDir ? { configDir } : {}),
|
|
401
|
+
});
|
|
402
|
+
}
|
|
371
403
|
async loop() {
|
|
372
404
|
while (!this.stopped) {
|
|
373
405
|
try {
|
|
@@ -408,11 +440,15 @@ export class ClaudeLiveSession {
|
|
|
408
440
|
if (ev.eventName === "StopFailure") {
|
|
409
441
|
this.stopSignalPending = false;
|
|
410
442
|
this.stopPendingAt = null;
|
|
443
|
+
this.stopBackgroundTaskCount = undefined;
|
|
444
|
+
this.stopBackgroundTaskCountDelivered = false;
|
|
411
445
|
this.turnFailurePending = new Error("claude turn failed");
|
|
412
446
|
}
|
|
413
447
|
else {
|
|
414
448
|
this.stopSignalPending = true;
|
|
415
449
|
this.stopPendingAt = this.now(); // close after a short grace (late assistant flush)
|
|
450
|
+
this.stopBackgroundTaskCount = ev.backgroundTaskCount ?? 0;
|
|
451
|
+
this.stopBackgroundTaskCountDelivered = false;
|
|
416
452
|
}
|
|
417
453
|
}
|
|
418
454
|
}
|
|
@@ -423,7 +459,11 @@ export class ClaudeLiveSession {
|
|
|
423
459
|
if (!error)
|
|
424
460
|
return;
|
|
425
461
|
this.turnFailurePending = null;
|
|
426
|
-
this.
|
|
462
|
+
this.providerFailureSticky = true;
|
|
463
|
+
if (this.turnOpen)
|
|
464
|
+
this.closeTurnError(error);
|
|
465
|
+
else
|
|
466
|
+
this.sink.onTurnError(error);
|
|
427
467
|
}
|
|
428
468
|
/** Emit the Stop idle signal only after this tick has discovered native
|
|
429
469
|
* interactions. A pending question/permission is active execution, not idle. */
|
|
@@ -431,8 +471,10 @@ export class ClaudeLiveSession {
|
|
|
431
471
|
if (!this.stopSignalPending)
|
|
432
472
|
return;
|
|
433
473
|
this.stopSignalPending = false;
|
|
434
|
-
if (this.pendingInteractions.size === 0)
|
|
435
|
-
this.sink.onIdle();
|
|
474
|
+
if (this.pendingInteractions.size === 0) {
|
|
475
|
+
this.sink.onIdle(this.stopBackgroundTaskCount);
|
|
476
|
+
this.stopBackgroundTaskCountDelivered = true;
|
|
477
|
+
}
|
|
436
478
|
}
|
|
437
479
|
/** SessionStart drives discovery (first) and rotation (a later one with a NEW
|
|
438
480
|
* session id + transcript). `/clear` → source="clear"; `/fork` → source="resume"
|
|
@@ -821,6 +863,12 @@ export class ClaudeLiveSession {
|
|
|
821
863
|
}
|
|
822
864
|
const content = userStringContent(rec);
|
|
823
865
|
if (content !== undefined) {
|
|
866
|
+
const firstLine = content.trim().split("\n", 1)[0] ?? "";
|
|
867
|
+
if (CLAUDE_INTERRUPT_RECORD_RE.test(firstLine)) {
|
|
868
|
+
this.noteInterrupted();
|
|
869
|
+
this.lastActivityAt = this.now();
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
824
872
|
const candidates = submissionCandidates(content);
|
|
825
873
|
const queuedPromotion = rec.promptSource !== "typed" &&
|
|
826
874
|
candidates.some((candidate) => this.consumeQueuedPromotion(candidate));
|
|
@@ -844,6 +892,15 @@ export class ClaudeLiveSession {
|
|
|
844
892
|
}
|
|
845
893
|
// A real prompt (not an XML-marker bookkeeping record) opens a new turn.
|
|
846
894
|
if (!content.startsWith("<")) {
|
|
895
|
+
const queuedIntoOpenTurn = this.turnOpen && (queuedPromotion || rec.promptSource === "queued" || rec.promptSource === "sdk");
|
|
896
|
+
if (queuedIntoOpenTurn) {
|
|
897
|
+
// Claude type-ahead is one native busy interval: enqueue now, promote
|
|
898
|
+
// later, and emit one final Stop. Do not close/re-open the canonical
|
|
899
|
+
// response when the promoted user record arrives mid-turn.
|
|
900
|
+
this.sink.onUserMessage(content);
|
|
901
|
+
this.lastActivityAt = this.now();
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
847
904
|
const turnId = typeof rec.uuid === "string" ? rec.uuid : undefined;
|
|
848
905
|
if (this.turnOpen && this.syntheticTurn) {
|
|
849
906
|
// A hook can flush just before the transcript's user record. The
|
|
@@ -856,9 +913,13 @@ export class ClaudeLiveSession {
|
|
|
856
913
|
this.closeTurn();
|
|
857
914
|
this.currentTurnId = turnId;
|
|
858
915
|
this.turnOpen = true;
|
|
916
|
+
this.turnInterrupted = false;
|
|
859
917
|
this.syntheticTurn = false;
|
|
918
|
+
this.providerIdleAt = null;
|
|
860
919
|
this.openToolIds.clear();
|
|
861
920
|
this.stopPendingAt = null;
|
|
921
|
+
this.stopBackgroundTaskCount = undefined;
|
|
922
|
+
this.stopBackgroundTaskCountDelivered = false;
|
|
862
923
|
this.sink.onTurnStart(this.currentTurnId);
|
|
863
924
|
}
|
|
864
925
|
this.sink.onUserMessage(content);
|
|
@@ -978,6 +1039,32 @@ export class ClaudeLiveSession {
|
|
|
978
1039
|
if (status)
|
|
979
1040
|
this.latestStatus = status;
|
|
980
1041
|
}
|
|
1042
|
+
pollSessionStatus() {
|
|
1043
|
+
const poller = this.statusPoller;
|
|
1044
|
+
if (!poller)
|
|
1045
|
+
return;
|
|
1046
|
+
const wasActive = poller.active;
|
|
1047
|
+
poller.tick();
|
|
1048
|
+
if (wasActive && !poller.active)
|
|
1049
|
+
this.providerIdleAt = null;
|
|
1050
|
+
}
|
|
1051
|
+
handleSessionStatus(status) {
|
|
1052
|
+
this.stopSignalPending = false;
|
|
1053
|
+
this.stopPendingAt = null;
|
|
1054
|
+
if (status.runnerStatus === "running") {
|
|
1055
|
+
this.providerFailureSticky = false;
|
|
1056
|
+
// A new native busy interval supersedes the prior Stop snapshot. The
|
|
1057
|
+
// Session running edge clears its sticky tally; do not re-emit that old
|
|
1058
|
+
// count if the previous transcript Turn closes a moment later.
|
|
1059
|
+
this.stopBackgroundTaskCount = undefined;
|
|
1060
|
+
this.stopBackgroundTaskCountDelivered = false;
|
|
1061
|
+
}
|
|
1062
|
+
else if (this.providerFailureSticky) {
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
this.sink.onStatus?.(status.runnerStatus, status.blockedOn);
|
|
1066
|
+
this.providerIdleAt = status.runnerStatus === "idle" ? this.now() : null;
|
|
1067
|
+
}
|
|
981
1068
|
/** A usage record from the latest statusLine snapshot (snake_case, the keys the
|
|
982
1069
|
* normalizer's `turn_completed` + the web `readUsageTokens` read), or undefined
|
|
983
1070
|
* if the statusLine hook has not fired yet. */
|
|
@@ -1042,7 +1129,11 @@ export class ClaudeLiveSession {
|
|
|
1042
1129
|
this.currentTurnId = provisionalTurnId;
|
|
1043
1130
|
this.syntheticTurn = true;
|
|
1044
1131
|
}
|
|
1132
|
+
this.providerIdleAt = null;
|
|
1045
1133
|
this.turnOpen = true;
|
|
1134
|
+
this.turnInterrupted = false;
|
|
1135
|
+
this.stopBackgroundTaskCount = undefined;
|
|
1136
|
+
this.stopBackgroundTaskCountDelivered = false;
|
|
1046
1137
|
this.sink.onTurnStart(this.currentTurnId);
|
|
1047
1138
|
}
|
|
1048
1139
|
/** Mirror a local `!` command as its own mini-turn: close any open turn, then
|
|
@@ -1063,10 +1154,14 @@ export class ClaudeLiveSession {
|
|
|
1063
1154
|
* turn's close via the same short grace. The Escape has stopped claude, so this
|
|
1064
1155
|
* does not race a still-running response. */
|
|
1065
1156
|
noteInterrupted() {
|
|
1066
|
-
if (!this.turnOpen)
|
|
1157
|
+
if (!this.turnOpen || this.turnInterrupted)
|
|
1067
1158
|
return;
|
|
1159
|
+
this.turnInterrupted = true;
|
|
1160
|
+
this.sink.onTurnInterruptRequested?.();
|
|
1068
1161
|
this.cancelPendingInteractions("turn_interrupted");
|
|
1069
1162
|
this.stopSignalPending = false;
|
|
1163
|
+
if (this.statusPoller?.active)
|
|
1164
|
+
return;
|
|
1070
1165
|
this.sink.onIdle();
|
|
1071
1166
|
this.stopPendingAt = this.now();
|
|
1072
1167
|
}
|
|
@@ -1077,6 +1172,21 @@ export class ClaudeLiveSession {
|
|
|
1077
1172
|
isTurnOpen() {
|
|
1078
1173
|
return this.turnOpen;
|
|
1079
1174
|
}
|
|
1175
|
+
/** Fail an open turn exactly once when its native terminal/runner disappears. */
|
|
1176
|
+
failOpenTurn(error) {
|
|
1177
|
+
if (!this.turnOpen)
|
|
1178
|
+
return false;
|
|
1179
|
+
this.closeTurnError(error);
|
|
1180
|
+
return true;
|
|
1181
|
+
}
|
|
1182
|
+
/** Retire process-scoped metadata before classifying a terminal exit. */
|
|
1183
|
+
noteTerminalExit(error) {
|
|
1184
|
+
this.statusPoller?.retire();
|
|
1185
|
+
const failedOpenTurn = this.failOpenTurn(error);
|
|
1186
|
+
if (!failedOpenTurn)
|
|
1187
|
+
this.sink.onTurnError(error);
|
|
1188
|
+
return failedOpenTurn;
|
|
1189
|
+
}
|
|
1080
1190
|
closeTurn() {
|
|
1081
1191
|
if (!this.turnOpen)
|
|
1082
1192
|
return;
|
|
@@ -1089,7 +1199,18 @@ export class ClaudeLiveSession {
|
|
|
1089
1199
|
this.stopSignalPending = false;
|
|
1090
1200
|
this.stopPendingAt = null;
|
|
1091
1201
|
this.resetMessageCorrelation();
|
|
1092
|
-
this.
|
|
1202
|
+
const interrupted = this.turnInterrupted;
|
|
1203
|
+
this.turnInterrupted = false;
|
|
1204
|
+
const usage = this.statusUsage();
|
|
1205
|
+
const backgroundTaskCount = this.stopBackgroundTaskCountDelivered
|
|
1206
|
+
? undefined
|
|
1207
|
+
: this.stopBackgroundTaskCount;
|
|
1208
|
+
this.stopBackgroundTaskCount = undefined;
|
|
1209
|
+
this.stopBackgroundTaskCountDelivered = false;
|
|
1210
|
+
if (interrupted && this.sink.onTurnInterrupted)
|
|
1211
|
+
this.sink.onTurnInterrupted(usage);
|
|
1212
|
+
else
|
|
1213
|
+
this.sink.onTurnEnd(usage, backgroundTaskCount);
|
|
1093
1214
|
}
|
|
1094
1215
|
closeTurnError(error) {
|
|
1095
1216
|
if (!this.turnOpen)
|
|
@@ -1100,8 +1221,11 @@ export class ClaudeLiveSession {
|
|
|
1100
1221
|
this.currentTurnId = undefined;
|
|
1101
1222
|
this.syntheticTurn = false;
|
|
1102
1223
|
this.openToolIds.clear();
|
|
1224
|
+
this.turnInterrupted = false;
|
|
1103
1225
|
this.stopSignalPending = false;
|
|
1104
1226
|
this.stopPendingAt = null;
|
|
1227
|
+
this.stopBackgroundTaskCount = undefined;
|
|
1228
|
+
this.stopBackgroundTaskCountDelivered = false;
|
|
1105
1229
|
this.resetMessageCorrelation();
|
|
1106
1230
|
this.sink.onTurnError(error);
|
|
1107
1231
|
}
|
|
@@ -1112,6 +1236,14 @@ export class ClaudeLiveSession {
|
|
|
1112
1236
|
this.streamedMessageText.clear();
|
|
1113
1237
|
}
|
|
1114
1238
|
maybeIdleClose() {
|
|
1239
|
+
if (this.statusPoller?.active) {
|
|
1240
|
+
if (this.turnOpen &&
|
|
1241
|
+
this.providerIdleAt !== null &&
|
|
1242
|
+
this.now() - this.providerIdleAt >= this.stopGraceMs) {
|
|
1243
|
+
this.closeTurn();
|
|
1244
|
+
}
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1115
1247
|
if (!this.turnOpen ||
|
|
1116
1248
|
this.openToolIds.size > 0 ||
|
|
1117
1249
|
this.pendingInteractions.size > 0)
|
|
@@ -1132,14 +1264,79 @@ export class ClaudeLiveSession {
|
|
|
1132
1264
|
/** Claude Code renders this glyph once the input box is mounted (ready-gate). */
|
|
1133
1265
|
const CLAUDE_PROMPT_GLYPH = "❯";
|
|
1134
1266
|
const CLAUDE_PROMPT_SCAN_TAIL_LINES = 5;
|
|
1267
|
+
const CLAUDE_BOX_RULE_CHARS = new Set([..."─━╭╮╰╯│┃╌╍"]);
|
|
1268
|
+
const CLAUDE_PASTED_PLACEHOLDER_PREFIX = "[Pasted text";
|
|
1269
|
+
const CLAUDE_DRAFT_NEEDLE_MAX_CHARS = 24;
|
|
1270
|
+
/** Claude-native readiness window: tmux may exist well before the
|
|
1271
|
+
* first interactive composer mounts on a cold start. */
|
|
1272
|
+
const CLAUDE_PROMPT_READY_TIMEOUT_MS = 30_000;
|
|
1273
|
+
const CLAUDE_PASTE_COMMIT_TIMEOUT_MS = 5_000;
|
|
1274
|
+
const CLAUDE_PASTE_SETTLE_MS = 100;
|
|
1275
|
+
const CLAUDE_SUBMIT_CONFIRM_TIMEOUT_MS = 10_000;
|
|
1276
|
+
const CLAUDE_SUBMIT_RETRY_MS = 1_000;
|
|
1277
|
+
function selectedMenuRow(line, glyph) {
|
|
1278
|
+
const index = line.indexOf(glyph);
|
|
1279
|
+
if (index < 0)
|
|
1280
|
+
return false;
|
|
1281
|
+
return /^\d+\.\s/.test(line.slice(index + glyph.length).trimStart());
|
|
1282
|
+
}
|
|
1283
|
+
function boxRule(line) {
|
|
1284
|
+
const text = line.trim();
|
|
1285
|
+
return text.length >= 3 && [...text].every((character) => CLAUDE_BOX_RULE_CHARS.has(character));
|
|
1286
|
+
}
|
|
1135
1287
|
/** The live composer sits at the bottom of the pane. Restricting readiness to
|
|
1136
1288
|
* its trailing non-empty lines prevents an old prompt glyph in scrollback from
|
|
1137
1289
|
* accepting input while Claude is still booting or showing another screen. */
|
|
1138
1290
|
function claudePromptRendered(pane, glyph) {
|
|
1139
1291
|
const nonEmpty = pane.split(/\r?\n/).filter((line) => line.trim());
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1292
|
+
const tailStart = Math.max(0, nonEmpty.length - CLAUDE_PROMPT_SCAN_TAIL_LINES);
|
|
1293
|
+
for (let index = tailStart; index < nonEmpty.length; index += 1) {
|
|
1294
|
+
const line = nonEmpty[index] ?? "";
|
|
1295
|
+
if (!line.includes(glyph))
|
|
1296
|
+
continue;
|
|
1297
|
+
const framed = nonEmpty.slice(index + 1).some(boxRule);
|
|
1298
|
+
if (framed)
|
|
1299
|
+
return true;
|
|
1300
|
+
if (!selectedMenuRow(line, glyph))
|
|
1301
|
+
return true;
|
|
1302
|
+
}
|
|
1303
|
+
// A fan-out of subagents can make the running footer arbitrarily tall. Above
|
|
1304
|
+
// the fixed tail, only a prompt framed by the composer's closing rule counts.
|
|
1305
|
+
for (let index = 0; index < tailStart; index += 1) {
|
|
1306
|
+
const line = nonEmpty[index] ?? "";
|
|
1307
|
+
if (line.includes(glyph) && nonEmpty.slice(index + 1).some(boxRule))
|
|
1308
|
+
return true;
|
|
1309
|
+
}
|
|
1310
|
+
return false;
|
|
1311
|
+
}
|
|
1312
|
+
function draftNeedle(content) {
|
|
1313
|
+
const normalized = content.replace(/\r\n?/g, "\n");
|
|
1314
|
+
for (const rawLine of normalized.split("\n")) {
|
|
1315
|
+
const control = rawLine.search(/[\x00-\x1f]/);
|
|
1316
|
+
const line = (control >= 0 ? rawLine.slice(0, control) : rawLine).trim();
|
|
1317
|
+
if (line)
|
|
1318
|
+
return line.slice(0, CLAUDE_DRAFT_NEEDLE_MAX_CHARS);
|
|
1319
|
+
}
|
|
1320
|
+
return "";
|
|
1321
|
+
}
|
|
1322
|
+
function draftInInputBox(pane, glyph, needle) {
|
|
1323
|
+
const lines = pane.split(/\r?\n/).filter((line) => line.includes(glyph));
|
|
1324
|
+
const line = lines.at(-1);
|
|
1325
|
+
if (!line)
|
|
1326
|
+
return false;
|
|
1327
|
+
const tail = line.slice(line.lastIndexOf(glyph) + glyph.length);
|
|
1328
|
+
if (tail.includes(CLAUDE_PASTED_PLACEHOLDER_PREFIX))
|
|
1329
|
+
return true;
|
|
1330
|
+
return Boolean(needle) && tail.includes(needle);
|
|
1331
|
+
}
|
|
1332
|
+
function terminalFailureTail(pane) {
|
|
1333
|
+
const lines = pane.split(/\r?\n/).filter((line) => line.trim()).slice(-12);
|
|
1334
|
+
if (lines.length === 0)
|
|
1335
|
+
return "";
|
|
1336
|
+
let tail = lines.join("\n");
|
|
1337
|
+
if (tail.length > 800)
|
|
1338
|
+
tail = `…${tail.slice(-800)}`;
|
|
1339
|
+
return `\nlast terminal output:\n${tail}`;
|
|
1143
1340
|
}
|
|
1144
1341
|
async function pollUntil(pred, timeoutMs, pollMs, now, sleep, signal) {
|
|
1145
1342
|
const deadline = now() + timeoutMs;
|
|
@@ -1159,9 +1356,9 @@ async function pollUntil(pred, timeoutMs, pollMs, now, sleep, signal) {
|
|
|
1159
1356
|
*
|
|
1160
1357
|
* THROWS if the prompt never appears within the ready-gate window (reference implementation
|
|
1161
1358
|
* `_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.
|
|
1359
|
+
* caller reports, NOT a signal to fall through to a second output path. Enter
|
|
1360
|
+
* is retried only while the exact draft remains visible and Claude has durably
|
|
1361
|
+
* recorded neither a direct user prompt nor a type-ahead enqueue.
|
|
1165
1362
|
*/
|
|
1166
1363
|
export async function injectViaTerminal(injector, text, opts = {}) {
|
|
1167
1364
|
const glyph = opts.promptGlyph ?? CLAUDE_PROMPT_GLYPH;
|
|
@@ -1180,12 +1377,23 @@ export async function injectViaTerminal(injector, text, opts = {}) {
|
|
|
1180
1377
|
};
|
|
1181
1378
|
// 1. Ready-gate. No prompt within the window → THROW (reference implementation RAISE): a
|
|
1182
1379
|
// not-ready pane is a hard error, never a fall-through-to-run signal.
|
|
1183
|
-
|
|
1380
|
+
let promptPolls = 0;
|
|
1381
|
+
let emptyPromptPolls = 0;
|
|
1382
|
+
let lastPromptPane = "";
|
|
1383
|
+
const ready = await pollUntil(() => {
|
|
1384
|
+
const pane = injector.capturePane();
|
|
1385
|
+
promptPolls += 1;
|
|
1386
|
+
if (pane.trim())
|
|
1387
|
+
lastPromptPane = pane;
|
|
1388
|
+
else
|
|
1389
|
+
emptyPromptPolls += 1;
|
|
1390
|
+
return claudePromptRendered(pane, glyph);
|
|
1391
|
+
}, opts.promptTimeoutMs ?? CLAUDE_PROMPT_READY_TIMEOUT_MS, pollMs, now, sleep, signal);
|
|
1184
1392
|
if (cancelled())
|
|
1185
1393
|
return false;
|
|
1186
1394
|
if (!ready) {
|
|
1187
|
-
|
|
1188
|
-
|
|
1395
|
+
throw new Error(`claude prompt not ready (no input composer within timeout; ${promptPolls} polls, ` +
|
|
1396
|
+
`${emptyPromptPolls} empty captures)${terminalFailureTail(lastPromptPane)}`);
|
|
1189
1397
|
}
|
|
1190
1398
|
// 2. Clear leftover, then bracketed-paste the draft. A final "\" is Claude's
|
|
1191
1399
|
// documented soft-newline escape: a bare submit Enter would consume it and
|
|
@@ -1193,30 +1401,47 @@ export async function injectViaTerminal(injector, text, opts = {}) {
|
|
|
1193
1401
|
// the bracketed paste; normal messages must not gain an empty input row.
|
|
1194
1402
|
injector.clearInputLine();
|
|
1195
1403
|
injector.paste(text.endsWith("\\") ? `${text}\n` : text);
|
|
1196
|
-
// 3. `paste-buffer`
|
|
1197
|
-
//
|
|
1198
|
-
//
|
|
1199
|
-
|
|
1200
|
-
await
|
|
1404
|
+
// 3. tmux accepting `paste-buffer` does not mean Claude consumed it. Wait for
|
|
1405
|
+
// the draft (or its large-paste placeholder) to appear in the live composer;
|
|
1406
|
+
// otherwise Enter can be coalesced into the paste as a newline.
|
|
1407
|
+
const needle = draftNeedle(text);
|
|
1408
|
+
const draftSeen = await pollUntil(() => draftInInputBox(injector.capturePane(), glyph, needle), opts.pasteCommitMs ?? CLAUDE_PASTE_COMMIT_TIMEOUT_MS, pollMs, now, sleep, signal);
|
|
1409
|
+
await sleep(opts.settleMs ?? CLAUDE_PASTE_SETTLE_MS);
|
|
1201
1410
|
if (cancelled())
|
|
1202
1411
|
return false; // Stop pressed mid-paste → don't submit
|
|
1203
|
-
// 4. Submit.
|
|
1204
|
-
//
|
|
1205
|
-
//
|
|
1206
|
-
// queue enqueue ends the loop immediately.
|
|
1412
|
+
// 4. Submit and verify. Retry only while this exact draft is still visible;
|
|
1413
|
+
// once it leaves the composer, another Enter could hit a permission dialog or
|
|
1414
|
+
// an empty prompt. Transcript acknowledgement is the strongest success proof.
|
|
1207
1415
|
const observed = opts.submissionObserved;
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1416
|
+
let attempts = 1;
|
|
1417
|
+
const maxAttempts = Math.max(1, Math.floor(opts.maxSubmitAttempts ?? Number.MAX_SAFE_INTEGER));
|
|
1418
|
+
injector.sendEnter();
|
|
1419
|
+
// Preserve the old blind-submit fallback when capture-pane cannot
|
|
1420
|
+
// identify the draft; its absence would otherwise "prove" success trivially.
|
|
1421
|
+
if (!draftSeen)
|
|
1422
|
+
return true;
|
|
1423
|
+
const deadline = now() + (opts.submitConfirmMs ?? CLAUDE_SUBMIT_CONFIRM_TIMEOUT_MS);
|
|
1424
|
+
const retryMs = opts.submitRetryMs ?? CLAUDE_SUBMIT_RETRY_MS;
|
|
1425
|
+
let lastEnterAt = now();
|
|
1426
|
+
while (now() < deadline) {
|
|
1427
|
+
if (cancelled())
|
|
1428
|
+
return false;
|
|
1429
|
+
if (observed?.())
|
|
1214
1430
|
return true;
|
|
1215
|
-
if (
|
|
1431
|
+
if (!draftInInputBox(injector.capturePane(), glyph, needle))
|
|
1216
1432
|
return true;
|
|
1433
|
+
if (now() - lastEnterAt >= retryMs && attempts < maxAttempts) {
|
|
1434
|
+
injector.sendEnter();
|
|
1435
|
+
attempts += 1;
|
|
1436
|
+
lastEnterAt = now();
|
|
1217
1437
|
}
|
|
1218
|
-
|
|
1219
|
-
return false;
|
|
1438
|
+
await sleep(pollMs);
|
|
1220
1439
|
}
|
|
1221
|
-
|
|
1440
|
+
if (observed?.())
|
|
1441
|
+
return true;
|
|
1442
|
+
if (!draftInInputBox(injector.capturePane(), glyph, needle))
|
|
1443
|
+
return true;
|
|
1444
|
+
throw new Error(`Claude Code did not accept the submitted message within ` +
|
|
1445
|
+
`${opts.submitConfirmMs ?? CLAUDE_SUBMIT_CONFIRM_TIMEOUT_MS}ms ` +
|
|
1446
|
+
"(the draft is still in the input box). The message was not delivered.");
|
|
1222
1447
|
}
|
|
@@ -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
|
+
}
|