botmux 3.4.0 → 3.4.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.
Files changed (46) hide show
  1. package/dist/adapters/cli/claude-code.d.ts.map +1 -1
  2. package/dist/adapters/cli/claude-code.js +6 -7
  3. package/dist/adapters/cli/claude-code.js.map +1 -1
  4. package/dist/cli/create-group-resolver.d.ts +19 -0
  5. package/dist/cli/create-group-resolver.d.ts.map +1 -1
  6. package/dist/cli/create-group-resolver.js +30 -0
  7. package/dist/cli/create-group-resolver.js.map +1 -1
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +27 -6
  10. package/dist/cli.js.map +1 -1
  11. package/dist/core/session-ready-handshake.d.ts +11 -0
  12. package/dist/core/session-ready-handshake.d.ts.map +1 -0
  13. package/dist/core/session-ready-handshake.js +41 -0
  14. package/dist/core/session-ready-handshake.js.map +1 -0
  15. package/dist/core/worker-pool.d.ts.map +1 -1
  16. package/dist/core/worker-pool.js +9 -0
  17. package/dist/core/worker-pool.js.map +1 -1
  18. package/dist/daemon.d.ts.map +1 -1
  19. package/dist/daemon.js +10 -2
  20. package/dist/daemon.js.map +1 -1
  21. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  22. package/dist/im/lark/event-dispatcher.js +1 -3
  23. package/dist/im/lark/event-dispatcher.js.map +1 -1
  24. package/dist/services/group-creator.d.ts +8 -0
  25. package/dist/services/group-creator.d.ts.map +1 -1
  26. package/dist/services/group-creator.js +42 -0
  27. package/dist/services/group-creator.js.map +1 -1
  28. package/dist/setup/open-platform-automation.d.ts +1 -1
  29. package/dist/setup/open-platform-automation.d.ts.map +1 -1
  30. package/dist/setup/open-platform-automation.js +0 -1
  31. package/dist/setup/open-platform-automation.js.map +1 -1
  32. package/dist/types.d.ts +7 -0
  33. package/dist/types.d.ts.map +1 -1
  34. package/dist/utils/idle-detector.d.ts +9 -1
  35. package/dist/utils/idle-detector.d.ts.map +1 -1
  36. package/dist/utils/idle-detector.js +18 -5
  37. package/dist/utils/idle-detector.js.map +1 -1
  38. package/dist/utils/input-gate.d.ts +60 -0
  39. package/dist/utils/input-gate.d.ts.map +1 -1
  40. package/dist/utils/input-gate.js +60 -0
  41. package/dist/utils/input-gate.js.map +1 -1
  42. package/dist/utils/ready-gate.d.ts +6 -6
  43. package/dist/utils/ready-gate.js +6 -6
  44. package/dist/worker.js +114 -23
  45. package/dist/worker.js.map +1 -1
  46. package/package.json +1 -1
@@ -24,6 +24,24 @@ export function shouldWriteNow(state) {
24
24
  // Type-ahead is only safe after the TUI has booted at least once.
25
25
  return state.supportsTypeAhead && !state.awaitingFirstPrompt;
26
26
  }
27
+ /**
28
+ * Claude runs every matching SessionStart hook in parallel and waits for all of
29
+ * them before it renders the real input prompt. Botmux's own hook can therefore
30
+ * finish while a slower project hook is still running. During the first prompt,
31
+ * treat the signal as an outer-selector boundary only and wait for fresh prompt
32
+ * evidence emitted after that boundary.
33
+ *
34
+ * Other ready-integrated CLIs (notably Hermes) emit their signal only once their
35
+ * prompt is usable, so their established authoritative-signal behavior stays
36
+ * unchanged.
37
+ */
38
+ export function shouldWaitForPostSessionStartPromptEvidence(state) {
39
+ return state.isClaudeFamily
40
+ && state.hasReadyPattern
41
+ && state.awaitingFirstPrompt
42
+ && !state.isPromptReady
43
+ && !state.alreadyWaiting;
44
+ }
27
45
  export function shouldReleaseFirstPromptTimeout(state) {
28
46
  if (!state.deferFirstPromptTimeoutUntilReady)
29
47
  return true;
@@ -31,4 +49,46 @@ export function shouldReleaseFirstPromptTimeout(state) {
31
49
  return true;
32
50
  return state.elapsedMs >= state.hardTimeoutMs;
33
51
  }
52
+ /**
53
+ * After the ready-gate releases (SessionStart/direct-ready signal OR the timeout
54
+ * fallback), the worker settles for PTY quiescence and then decides whether to
55
+ * mark the prompt ready (which flushes for ALL adapters) vs. just calling
56
+ * flushPending() (which only flushes for type-ahead adapters). Marking ready is
57
+ * correct when ANY of these hold:
58
+ * - promptReadyAfterSettle — an authoritative direct ready command
59
+ * fired (Hermes). Claude passes false here
60
+ * and waits for post-hook PTY evidence.
61
+ * - promptReadyDetectedDuringSettle — the idle detector fired during the
62
+ * settle (a readyPattern/idle proved readiness).
63
+ * - readyPatternSeenDuringHold — a readyPattern fired WHILE the gate was
64
+ * holding (markPromptReady was blocked by
65
+ * readyGate.shouldHold()). The input box
66
+ * exists; the gate only deferred delivery.
67
+ *
68
+ * Pins the Hermes regression: a non-type-ahead adapter that renders its prompt
69
+ * (❯) during the hold but never fires the SessionStart signal must be marked
70
+ * ready at settle — otherwise settle calls flushPending(), which bails on
71
+ * !isPromptReady && !typeAheadAllowed and leaves the first message queued until
72
+ * the hard timeout (and, before the hard-timeout fix, forever).
73
+ */
74
+ export function decideSettleMarkReady(state) {
75
+ return state.promptReadyAfterSettle || state.promptReadyDetectedDuringSettle || state.readyPatternSeenDuringHold;
76
+ }
77
+ /**
78
+ * At the first-prompt hard timeout the worker has waited the full cap. For
79
+ * type-ahead adapters flushPending() drains the queue even while !isPromptReady
80
+ * (the TUI parks input in its own queue). For non-type-ahead adapters
81
+ * flushPending() bails on !isPromptReady && !typeAheadAllowed, so the worker
82
+ * must mark the prompt ready first (markPromptReady() then flushes).
83
+ * Returns the action the worker must take:
84
+ * - 'flush' — call flushPending() (type-ahead adapters).
85
+ * - 'mark-ready' — call markPromptReady() (non-type-ahead adapters).
86
+ *
87
+ * Pins the regression where non-type-ahead adapters only logged "forcing
88
+ * queued message flush" at the hard timeout without actually delivering the
89
+ * held first message.
90
+ */
91
+ export function decideHardTimeoutAction(supportsTypeAhead) {
92
+ return supportsTypeAhead ? 'flush' : 'mark-ready';
93
+ }
34
94
  //# sourceMappingURL=input-gate.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"input-gate.js","sourceRoot":"","sources":["../../src/utils/input-gate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,cAAc,CAAC,KAS9B;IACC,IAAI,KAAK,CAAC,aAAa,IAAI,KAAK,CAAC,UAAU;QAAE,OAAO,IAAI,CAAC;IACzD,kEAAkE;IAClE,OAAO,KAAK,CAAC,iBAAiB,IAAI,CAAC,KAAK,CAAC,mBAAmB,CAAC;AAC/D,CAAC;AAED,MAAM,UAAU,+BAA+B,CAAC,KAS/C;IACC,IAAI,CAAC,KAAK,CAAC,iCAAiC;QAAE,OAAO,IAAI,CAAC;IAC1D,IAAI,CAAC,KAAK,CAAC,eAAe;QAAE,OAAO,IAAI,CAAC;IACxC,OAAO,KAAK,CAAC,SAAS,IAAI,KAAK,CAAC,aAAa,CAAC;AAChD,CAAC"}
1
+ {"version":3,"file":"input-gate.js","sourceRoot":"","sources":["../../src/utils/input-gate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,cAAc,CAAC,KAS9B;IACC,IAAI,KAAK,CAAC,aAAa,IAAI,KAAK,CAAC,UAAU;QAAE,OAAO,IAAI,CAAC;IACzD,kEAAkE;IAClE,OAAO,KAAK,CAAC,iBAAiB,IAAI,CAAC,KAAK,CAAC,mBAAmB,CAAC;AAC/D,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,2CAA2C,CAAC,KAM3D;IACC,OAAO,KAAK,CAAC,cAAc;WACtB,KAAK,CAAC,eAAe;WACrB,KAAK,CAAC,mBAAmB;WACzB,CAAC,KAAK,CAAC,aAAa;WACpB,CAAC,KAAK,CAAC,cAAc,CAAC;AAC7B,CAAC;AAED,MAAM,UAAU,+BAA+B,CAAC,KAS/C;IACC,IAAI,CAAC,KAAK,CAAC,iCAAiC;QAAE,OAAO,IAAI,CAAC;IAC1D,IAAI,CAAC,KAAK,CAAC,eAAe;QAAE,OAAO,IAAI,CAAC;IACxC,OAAO,KAAK,CAAC,SAAS,IAAI,KAAK,CAAC,aAAa,CAAC;AAChD,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,UAAU,qBAAqB,CAAC,KAIrC;IACC,OAAO,KAAK,CAAC,sBAAsB,IAAI,KAAK,CAAC,+BAA+B,IAAI,KAAK,CAAC,0BAA0B,CAAC;AACnH,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,uBAAuB,CAAC,iBAA0B;IAChE,OAAO,iBAAiB,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,YAAY,CAAC;AACpD,CAAC"}
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Ready-gate state machine — holds the FIRST prompt for Claude-family CLIs until
3
- * a SessionStart hook fires a "真就绪" (true-ready) signal.
3
+ * a SessionStart hook proves the outer startup selector has been passed.
4
4
  *
5
5
  * Why this exists: a custom launcher (e.g. `cjadk claude`) shows an interactive
6
6
  * model/session selector at startup whose cursor is `❯` (U+276F). That glyph
@@ -10,11 +10,11 @@
10
10
  * trailing Enter mis-selects a menu item). The selector clears before Claude's
11
11
  * real input box renders, so the message is silently lost.
12
12
  *
13
- * Claude Code's `SessionStart` hook fires within ~3ms of the real input box
14
- * rendering, and crucially does NOT fire while the launcher is still on its
15
- * selector so it's an unambiguous "the CLI is genuinely ready" signal. This
16
- * gate suspends the first flush until that signal (or a fallback timeout) so the
17
- * selector can never eat the first message.
13
+ * Claude Code's `SessionStart` hook crucially does NOT fire while the launcher
14
+ * is still on its selector. Claude can run multiple matching hooks in parallel,
15
+ * though, and renders the real prompt only after all finish. This gate therefore
16
+ * establishes the anti-selector boundary; the worker separately waits for fresh
17
+ * post-signal prompt evidence before it flushes Claude input.
18
18
  *
19
19
  * Lifecycle (recreated per CLI spawn in the worker):
20
20
  * - `arm()` — call at spawn when the adapter injects the SessionStart
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Ready-gate state machine — holds the FIRST prompt for Claude-family CLIs until
3
- * a SessionStart hook fires a "真就绪" (true-ready) signal.
3
+ * a SessionStart hook proves the outer startup selector has been passed.
4
4
  *
5
5
  * Why this exists: a custom launcher (e.g. `cjadk claude`) shows an interactive
6
6
  * model/session selector at startup whose cursor is `❯` (U+276F). That glyph
@@ -10,11 +10,11 @@
10
10
  * trailing Enter mis-selects a menu item). The selector clears before Claude's
11
11
  * real input box renders, so the message is silently lost.
12
12
  *
13
- * Claude Code's `SessionStart` hook fires within ~3ms of the real input box
14
- * rendering, and crucially does NOT fire while the launcher is still on its
15
- * selector so it's an unambiguous "the CLI is genuinely ready" signal. This
16
- * gate suspends the first flush until that signal (or a fallback timeout) so the
17
- * selector can never eat the first message.
13
+ * Claude Code's `SessionStart` hook crucially does NOT fire while the launcher
14
+ * is still on its selector. Claude can run multiple matching hooks in parallel,
15
+ * though, and renders the real prompt only after all finish. This gate therefore
16
+ * establishes the anti-selector boundary; the worker separately waits for fresh
17
+ * post-signal prompt evidence before it flushes Claude input.
18
18
  *
19
19
  * Lifecycle (recreated per CLI spawn in the worker):
20
20
  * - `arm()` — call at spawn when the adapter injects the SessionStart
package/dist/worker.js CHANGED
@@ -24,7 +24,7 @@ import { readProcessStartIdentity } from './core/session-marker.js';
24
24
  import { drainTranscript, joinAssistantText, trailingAssistantText, findJsonlContainingFingerprint, findJsonlsContainingExactContent, findLatestJsonl, extractLastAssistantTurn, extractTurnStartText, splitTranscriptEventsByCutoff } from './services/claude-transcript.js';
25
25
  import { BridgeTurnQueue, makeFingerprint, normaliseForFingerprint } from './services/bridge-turn-queue.js';
26
26
  import { shouldSuppressBridgeEmit } from './services/bridge-fallback-gate.js';
27
- import { shouldReleaseFirstPromptTimeout, shouldWriteNow } from './utils/input-gate.js';
27
+ import { decideHardTimeoutAction, decideSettleMarkReady, shouldReleaseFirstPromptTimeout, shouldWaitForPostSessionStartPromptEvidence, shouldWriteNow, } from './utils/input-gate.js';
28
28
  import { canStartInjectionFlush, shouldDeferUserFlush, shouldFlushInjectionsFirst } from './core/inject-queue-policy.js';
29
29
  import { stripAnsiForLog, tailChars } from './utils/crash-log.js';
30
30
  import { CodexUpdateDialogGuard } from './utils/codex-update-dialog.js';
@@ -794,17 +794,16 @@ let bareShellLaunchBlocked = false;
794
794
  * shell. Reset per spawn in spawnCli. */
795
795
  let bareShellChecked = false;
796
796
  /** Ready-gate (Claude-family): holds the first prompt until the SessionStart
797
- * hook fires a true-ready signal, so a cjadk-style startup selector's (which
798
- * falsely matches readyPattern) can't eat the first message. Recreated + armed
799
- * per spawn in spawnCli; disarmed on signal or fallback timeout. */
797
+ * hook proves a cjadk-style startup selector is behind us. Claude then needs
798
+ * fresh post-hook prompt evidence because sibling hooks may still be running.
799
+ * Recreated + armed per spawn; disarmed on signal or fallback timeout. */
800
800
  let readyGate = new ReadyGate();
801
801
  /** Fallback timer: if the SessionStart signal never arrives (hook injection
802
802
  * failed / old CLI / launcher didn't pass --settings / adopt) release the gate
803
803
  * and fall back to readyPattern + quiescence. */
804
804
  let readySignalTimer = null;
805
805
  /** How long the ready-gate waits for the SessionStart signal before falling
806
- * back. The real signal lands within ~ms of the input box rendering, so this is
807
- * pure insurance against a missing/failed hook — generous but bounded. */
806
+ * back. This is insurance against a missing/failed hook generous but bounded. */
808
807
  const READY_SIGNAL_TIMEOUT_MS = 45_000;
809
808
  /** Soft fallback for CLIs that never emit an idle/ready signal during startup.
810
809
  * Legacy adapters release queued first input here. Adapters that opt into
@@ -817,13 +816,13 @@ const FIRST_PROMPT_HARD_TIMEOUT_MS = 90_000;
817
816
  /** Epoch ms of the most recent PTY output — used to settle for quiescence
818
817
  * before the first flush (see settleThenFlush). */
819
818
  let lastPtyOutputAtMs = 0;
820
- /** After the SessionStart signal fires, the input box has appeared but Ink's
821
- * startup render isn't fully drained yet — typing immediately trips Claude's
819
+ /** After the SessionStart signal fires, Ink's startup rendering or sibling
820
+ * hooks may still be active — typing immediately can trip Claude's
822
821
  * paste-burst heuristic and the `\` soft-newline markers (claude-code
823
822
  * writeInput) get kept literally. This is pronounced under wrapperCli launchers
824
823
  * (e.g. `aiden x claude`) whose Claude renders more at startup. So we wait for
825
- * the PTY to fall quiet for SETTLE_MS before the first flush — the signal still
826
- * gates readiness (anti-selector), the settle just lets the render drain. */
824
+ * PTY quiescence, while Claude additionally requires fresh prompt evidence
825
+ * after the SessionStart boundary. */
827
826
  const READY_FLUSH_SETTLE_MS = 1_000;
828
827
  /** Upper bound on the settle so a chatty startup (spinners, periodic redraw)
829
828
  * can't stall the first prompt indefinitely. */
@@ -837,19 +836,40 @@ let isSettlingFirstFlush = false;
837
836
  * ready yet, or flushPending will be blocked by isSettlingFirstFlush and a
838
837
  * later markPromptReady call would return early with the first prompt stranded. */
839
838
  let promptReadyDetectedDuringSettle = false;
839
+ /** While the ready-gate is holding, the IdleDetector may still fire on a real
840
+ * readyPattern (e.g. Hermes's ❯) — proving the input box exists — but
841
+ * markPromptReady() returns early because the gate is armed. Record that the
842
+ * pattern was seen so the gate's timeout-fallback settle can mark the prompt
843
+ * ready immediately instead of delivering into a !isPromptReady state that
844
+ * flushPending() rejects for non-type-ahead adapters. Without this, a Hermes
845
+ * spawn that renders ❯ but never fires BOTMUX_READY_COMMAND waits the full
846
+ * hard timeout (and previously never delivered at all). */
847
+ let readyPatternSeenDuringHold = false;
848
+ /** Claude's SessionStart hooks run in parallel. Its botmux hook proves the
849
+ * startup selector is behind us, but sibling project hooks may still be
850
+ * running. Hold type-ahead until a fresh PTY prompt is observed after the
851
+ * SessionStart signal. */
852
+ let awaitingPostSessionStartPromptEvidence = false;
853
+ /** Scoped marker set only by IdleDetector's screen-driven callback. */
854
+ let postSessionStartPromptEvidenceInFlight = false;
840
855
  /** Wait until the PTY has been quiet for READY_FLUSH_SETTLE_MS (Ink render
841
856
  * drained), capped at READY_FLUSH_SETTLE_CAP_MS, then flush the held prompt.
842
- * A real SessionStart/BOTMUX_READY_COMMAND signal is itself authoritative
843
- * prompt readiness; the timeout fallback only opens the gate and lets the
844
- * regular readyPattern/idle path prove readiness later. */
857
+ * An authoritative direct ready command (Hermes) can mark prompt readiness;
858
+ * Claude's SessionStart only opens the anti-selector boundary and its regular
859
+ * readyPattern/idle path must prove readiness afterward. */
845
860
  function settleThenFlush(startedAtMs, promptReadyAfterSettle) {
846
861
  readyFlushSettleTimer = null;
847
862
  const now = Date.now();
848
863
  const quietForMs = now - lastPtyOutputAtMs;
849
864
  if (quietForMs >= READY_FLUSH_SETTLE_MS || now - startedAtMs >= READY_FLUSH_SETTLE_CAP_MS) {
850
865
  isSettlingFirstFlush = false;
851
- const shouldMarkPromptReady = promptReadyAfterSettle || promptReadyDetectedDuringSettle;
866
+ const shouldMarkPromptReady = decideSettleMarkReady({
867
+ promptReadyAfterSettle,
868
+ promptReadyDetectedDuringSettle,
869
+ readyPatternSeenDuringHold,
870
+ });
852
871
  promptReadyDetectedDuringSettle = false;
872
+ readyPatternSeenDuringHold = false;
853
873
  log(`Ready-gate settle done (quiet ${quietForMs}ms); ${shouldMarkPromptReady ? 'marking prompt ready' : 'delivering held first prompt'}`);
854
874
  if (shouldMarkPromptReady) {
855
875
  markPromptReady();
@@ -4513,6 +4533,15 @@ function releaseRawInputRestartGate() {
4513
4533
  rawInputRestartGate = false;
4514
4534
  log('Replacement CLI prompt ready — releasing deferred passthrough commands');
4515
4535
  }
4536
+ function markPromptReadyFromPty() {
4537
+ postSessionStartPromptEvidenceInFlight = true;
4538
+ try {
4539
+ markPromptReady();
4540
+ }
4541
+ finally {
4542
+ postSessionStartPromptEvidenceInFlight = false;
4543
+ }
4544
+ }
4516
4545
  function markPromptReady() {
4517
4546
  if (isPromptReady)
4518
4547
  return; // guard against duplicate calls
@@ -4524,9 +4553,22 @@ function markPromptReady() {
4524
4553
  // releaseReadyGate() drives flushPending() once the real signal lands, and a
4525
4554
  // later genuine idle then runs this fully. No-op for non-armed gates.
4526
4555
  if (readyGate.shouldHold()) {
4556
+ // A real readyPattern fired while the gate was holding — the input box
4557
+ // exists. Remember it so the gate's timeout-fallback settle can mark the
4558
+ // prompt ready (see settleThenFlush) instead of letting flushPending()
4559
+ // reject the held message for non-type-ahead adapters.
4560
+ readyPatternSeenDuringHold = true;
4527
4561
  log('Idle detected but holding for SessionStart ready signal (startup selector guard)');
4528
4562
  return;
4529
4563
  }
4564
+ if (awaitingPostSessionStartPromptEvidence) {
4565
+ if (!postSessionStartPromptEvidenceInFlight) {
4566
+ log('Ignoring non-PTY ready source while waiting for post-SessionStart prompt evidence');
4567
+ return;
4568
+ }
4569
+ awaitingPostSessionStartPromptEvidence = false;
4570
+ log('Fresh prompt evidence observed after SessionStart hooks');
4571
+ }
4530
4572
  if (isSettlingFirstFlush) {
4531
4573
  promptReadyDetectedDuringSettle = true;
4532
4574
  log('Idle detected during ready-gate settle; deferring prompt-ready until settle completes');
@@ -4558,6 +4600,7 @@ function markPromptReady() {
4558
4600
  maybeEmitWorkflowTranscriptOutput();
4559
4601
  if (awaitingFirstPrompt) {
4560
4602
  awaitingFirstPrompt = false;
4603
+ awaitingPostSessionStartPromptEvidence = false;
4561
4604
  renderer?.markNewTurn(); // exclude history replay from streaming card
4562
4605
  }
4563
4606
  send({ type: 'prompt_ready' });
@@ -4897,6 +4940,10 @@ async function flushPending() {
4897
4940
  log(`Holding ${pendingMessages.length} pending message(s) until ready-gate settle completes`);
4898
4941
  return;
4899
4942
  }
4943
+ if (awaitingPostSessionStartPromptEvidence) {
4944
+ log(`Holding ${pendingMessages.length} pending message(s) until post-SessionStart prompt evidence`);
4945
+ return;
4946
+ }
4900
4947
  // Type-ahead adapters flush even while the CLI is busy; others wait for
4901
4948
  // idle. Claude bridge fallback used to also disable type-ahead because
4902
4949
  // BridgeTurnQueue.ingest didn't recognise the `attachment(queued_command)`
@@ -7349,6 +7396,8 @@ async function spawnCli(cfg, opts = {}) {
7349
7396
  }
7350
7397
  isSettlingFirstFlush = false;
7351
7398
  promptReadyDetectedDuringSettle = false;
7399
+ readyPatternSeenDuringHold = false;
7400
+ awaitingPostSessionStartPromptEvidence = false;
7352
7401
  // Reset quiescence baseline so the settle measures silence from THIS spawn.
7353
7402
  lastPtyOutputAtMs = Date.now();
7354
7403
  const readyHookAvailable = effectiveReadyHookInstall
@@ -7391,7 +7440,7 @@ async function spawnCli(cfg, opts = {}) {
7391
7440
  // quiescence, repeatedly triggering markPromptReady() and duplicate cards.
7392
7441
  if (effectiveBackendType !== 'riff') {
7393
7442
  idleDetector = new IdleDetector(cliAdapter);
7394
- idleDetector.onIdle(async () => {
7443
+ idleDetector.onIdle(async (evidenceSource) => {
7395
7444
  log('Prompt detected (idle)');
7396
7445
  // Bridge drain MUST run before markPromptReady() — the latter calls
7397
7446
  // flushPending() which can immediately fire the next queued message
@@ -7413,7 +7462,12 @@ async function spawnCli(cfg, opts = {}) {
7413
7462
  log(`Codex bridge emit error: ${err.message}`);
7414
7463
  }
7415
7464
  }
7416
- markPromptReady();
7465
+ if (evidenceSource === 'screen') {
7466
+ markPromptReadyFromPty();
7467
+ }
7468
+ else {
7469
+ markPromptReady();
7470
+ }
7417
7471
  });
7418
7472
  }
7419
7473
  backend.onData(onPtyData);
@@ -7562,6 +7616,7 @@ async function spawnCli(cfg, opts = {}) {
7562
7616
  return;
7563
7617
  }
7564
7618
  awaitingFirstPrompt = false;
7619
+ awaitingPostSessionStartPromptEvidence = false;
7565
7620
  renderer?.markNewTurn();
7566
7621
  log(forced
7567
7622
  ? `WARN First prompt hard timeout — ${cliName()} readyPattern did not arrive; forcing queued message flush`
@@ -7574,8 +7629,23 @@ async function spawnCli(cfg, opts = {}) {
7574
7629
  // invoking markPromptReady() would claim the CLI is idle while it's still
7575
7630
  // mid-boot, so flushPending() alone is safer — it respects typeAheadAllowed
7576
7631
  // and drains pendingMessages now.
7577
- if (cliAdapter?.supportsTypeAhead)
7632
+ //
7633
+ // Non-type-ahead adapters (Hermes etc.) flushPending() rejects the held
7634
+ // message while isPromptReady is false — it bails on
7635
+ // `!isPromptReady && !typeAheadAllowed`. The hard cap means we've waited
7636
+ // long enough. By now the ready gate's 45s fallback has already released
7637
+ // the gate (READY_SIGNAL_TIMEOUT_MS < this 90s hard cap) and the post-
7638
+ // release settle has drained, so markPromptReady() proceeds: it sets
7639
+ // isPromptReady and drains the held first prompt. Without this, a spawn
7640
+ // that never fires the ready signal (and whose readyPattern the idle
7641
+ // detector never matched) would hold the first queued message forever —
7642
+ // the previous code only logged "forcing flush" without actually flushing
7643
+ // for non-type-ahead adapters.
7644
+ if (decideHardTimeoutAction(cliAdapter?.supportsTypeAhead === true) === 'flush') {
7578
7645
  flushPending();
7646
+ return;
7647
+ }
7648
+ markPromptReady();
7579
7649
  };
7580
7650
  setTimeout(() => releaseFirstPromptTimeout(FIRST_PROMPT_TIMEOUT_MS, false), FIRST_PROMPT_TIMEOUT_MS);
7581
7651
  // Riff (and other remote HTTP backends) have no local boot process — the
@@ -7607,6 +7677,8 @@ function killCli(opts = {}) {
7607
7677
  }
7608
7678
  isSettlingFirstFlush = false;
7609
7679
  promptReadyDetectedDuringSettle = false;
7680
+ readyPatternSeenDuringHold = false;
7681
+ awaitingPostSessionStartPromptEvidence = false;
7610
7682
  stopScreenAnalyzer();
7611
7683
  stopScreenUpdates();
7612
7684
  backend?.kill();
@@ -9418,24 +9490,43 @@ process.on('message', async (raw) => {
9418
9490
  break;
9419
9491
  }
9420
9492
  case 'session_ready': {
9421
- // Claude-family SessionStart hook fired (via `botmux session-ready`
9422
- // daemon). The CLI's input box is genuinely rendered release the
9423
- // ready-gate and deliver any held first prompt. Idempotent: a later
9424
- // duplicate (clear/compact source) is a no-op.
9493
+ // Claude-family SessionStart hooks run in parallel. This signal proves
9494
+ // the startup selector is behind us, but a slower project hook can still
9495
+ // be running and Claude does not render its real prompt until ALL hooks
9496
+ // finish. Clear selector-era evidence and require a fresh PTY prompt after
9497
+ // the signal. Hermes keeps its authoritative ready-command behavior.
9425
9498
  log(`SessionStart ready signal received (source=${msg.source ?? '?'})`);
9499
+ const waitForPostHookPrompt = shouldWaitForPostSessionStartPromptEvidence({
9500
+ isClaudeFamily: !!cliAdapter?.claudeDataDir,
9501
+ hasReadyPattern: !!cliAdapter?.readyPattern,
9502
+ awaitingFirstPrompt,
9503
+ isPromptReady,
9504
+ alreadyWaiting: awaitingPostSessionStartPromptEvidence,
9505
+ });
9506
+ if (waitForPostHookPrompt) {
9507
+ awaitingPostSessionStartPromptEvidence = true;
9508
+ promptReadyDetectedDuringSettle = false;
9509
+ readyPatternSeenDuringHold = false;
9510
+ idleDetector?.resetReadyEvidence();
9511
+ lastPtyOutputAtMs = Date.now();
9512
+ log('SessionStart boundary recorded — waiting for fresh post-hook prompt evidence');
9513
+ }
9426
9514
  // 先记下 gate 是否已被 45s fallback 释放:ReadyGate.receive() 是一次性
9427
9515
  // 语义,fallback 抢先后 releaseReadyGate 会整块跳过迟到的真信号。
9428
9516
  const lateAfterFallback = readyGate.isArmed && readyGate.isReceived;
9429
- releaseReadyGate('SessionStart hook', { promptReadyAfterSettle: true });
9517
+ releaseReadyGate('SessionStart hook', { promptReadyAfterSettle: !waitForPostHookPrompt });
9430
9518
  // 冷启动超过 READY_SIGNAL_TIMEOUT_MS 的 CLI(Hermes 常态是 2-3 分钟)恰好
9431
9519
  // 总落在 fallback 之后:fallback 只开闸不投递(非 type-ahead 的
9432
9520
  // flushPending 是 no-op),真信号依然是权威就绪,这里直接兑现。仅限首轮
9433
9521
  // (awaitingFirstPrompt)——首条 prompt 交付后 clear/compact 来源的
9434
9522
  // SessionStart 保持原有 no-op 语义,绝不在会话中途误标就绪。
9435
- if (lateAfterFallback && awaitingFirstPrompt && !isPromptReady) {
9523
+ if (lateAfterFallback && awaitingFirstPrompt && !isPromptReady && !waitForPostHookPrompt) {
9436
9524
  log('Late ready signal after timeout fallback — marking prompt ready now');
9437
9525
  markPromptReady();
9438
9526
  }
9527
+ if (msg.requestId) {
9528
+ send({ type: 'session_ready_ack', requestId: msg.requestId });
9529
+ }
9439
9530
  break;
9440
9531
  }
9441
9532
  case 'set_display_mode': {