@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.
Files changed (44) hide show
  1. package/dist/claude/executor.d.ts +19 -5
  2. package/dist/claude/executor.js +56 -12
  3. package/dist/claude/models.d.ts +0 -5
  4. package/dist/claude/models.js +1 -7
  5. package/dist/claude/native-bridge.d.ts +2 -0
  6. package/dist/claude/native-bridge.js +23 -0
  7. package/dist/claude/native-hook-main.js +62 -0
  8. package/dist/claude/native-integration.d.ts +50 -10
  9. package/dist/claude/native-integration.js +262 -37
  10. package/dist/claude/session-status.d.ts +39 -0
  11. package/dist/claude/session-status.js +163 -0
  12. package/dist/claude/transcript.js +27 -17
  13. package/dist/codex-app-server/client.d.ts +10 -6
  14. package/dist/codex-app-server/client.js +67 -15
  15. package/dist/codex-app-server/forwarder.d.ts +92 -3
  16. package/dist/codex-app-server/forwarder.js +509 -56
  17. package/dist/codex-app-server/mapping.d.ts +3 -6
  18. package/dist/codex-app-server/mapping.js +174 -28
  19. package/dist/codex-app-server/mcp-startup.d.ts +13 -0
  20. package/dist/codex-app-server/mcp-startup.js +63 -0
  21. package/dist/codex-app-server/protocol.d.ts +64 -7
  22. package/dist/codex-app-server/ws-channel.js +19 -19
  23. package/dist/codex-home.js +2 -4
  24. package/dist/host.d.ts +64 -21
  25. package/dist/host.js +1330 -441
  26. package/dist/index.d.ts +1 -1
  27. package/dist/input-resources.d.ts +4 -0
  28. package/dist/input-resources.js +21 -5
  29. package/dist/models-catalog.d.ts +2 -1
  30. package/dist/models-catalog.js +94 -6
  31. package/dist/runner/child.d.ts +48 -21
  32. package/dist/runner/child.js +550 -48
  33. package/dist/runner/manager.d.ts +54 -13
  34. package/dist/runner/manager.js +479 -114
  35. package/dist/runner/protocol.d.ts +62 -19
  36. package/dist/runner/protocol.js +5 -0
  37. package/dist/runner/startup-policy.d.ts +7 -0
  38. package/dist/runner/startup-policy.js +10 -0
  39. package/dist/terminal/claude-tui.d.ts +3 -1
  40. package/dist/terminal/claude-tui.js +3 -1
  41. package/dist/terminal/registry.js +3 -2
  42. package/dist/terminal/tmux.d.ts +50 -7
  43. package/dist/terminal/tmux.js +168 -47
  44. 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 (discovery); `Stop`/`StopFailure` CLOSE the turn (authoritative).
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. (Turn framing is
11
- * transcript-open + hook-close, matching the reference implementation re-audit; `running` is
12
- * implicit between open and close, so no PTY watcher is needed.)
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.closeTurnError(error);
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.sink.onTurnEnd(this.statusUsage());
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
- return nonEmpty
1141
- .slice(-CLAUDE_PROMPT_SCAN_TAIL_LINES)
1142
- .some((line) => line.includes(glyph));
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. With a
1163
- * transcript observer, Enter is retried only when Claude has durably recorded
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
- const ready = await pollUntil(() => claudePromptRendered(injector.capturePane(), glyph), opts.promptTimeoutMs ?? 20_000, pollMs, now, sleep, signal);
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
- const tail = injector.capturePane().split("\n").slice(-5).join("\n");
1188
- throw new Error(`claude prompt not ready (no "${glyph}" within timeout)\npane tail:\n${tail}`);
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` returns after tmux wrote the bracketed-paste frame. Give
1197
- // Claude a short, fixed settle window before the separate submit key; screen
1198
- // text is not used as an acknowledgement because submitted prompts remain in
1199
- // scrollback and are indistinguishable from an editable draft.
1200
- await sleep(opts.settleMs ?? 500);
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. Without a transcript observer this remains a one-Enter
1204
- // best-effort operation. With one, a bounded retry handles an Enter swallowed
1205
- // by the TUI without ever consulting stale pane text. A direct prompt or
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
- const attempts = observed
1209
- ? Math.max(1, Math.floor(opts.maxSubmitAttempts ?? 2))
1210
- : 1;
1211
- for (let attempt = 0; attempt < attempts; attempt += 1) {
1212
- injector.sendEnter();
1213
- if (!observed)
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 (await pollUntil(observed, opts.submitConfirmMs ?? 2_000, pollMs, now, sleep, signal)) {
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
- if (cancelled())
1219
- return false;
1438
+ await sleep(pollMs);
1220
1439
  }
1221
- return false;
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
+ }