@os-eco/overstory-cli 0.8.6 → 0.8.7

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.
@@ -87,6 +87,7 @@ export async function createSession(
87
87
  cwd: string,
88
88
  command: string,
89
89
  env?: Record<string, string>,
90
+ maxRetries = 3,
90
91
  ): Promise<number> {
91
92
  // Build environment exports for the tmux session
92
93
  const exports: string[] = [];
@@ -110,7 +111,16 @@ export async function createSession(
110
111
  }
111
112
  }
112
113
 
113
- const wrappedCommand = exports.length > 0 ? `${exports.join(" && ")} && ${command}` : command;
114
+ // Build the startup script using bash syntax (export/unset).
115
+ // Then wrap it in `/bin/bash -c '...'` so it always runs in bash,
116
+ // regardless of the user's $SHELL. Without this, tmux uses the user's
117
+ // default shell (e.g. fish), which rejects bash export/unset syntax and
118
+ // causes the session to die instantly. Single-quote wrapping with escaped
119
+ // single quotes prevents any intermediate shell from expanding variables
120
+ // before bash receives them. (GitHub #86)
121
+ const startupScript = exports.length > 0 ? `${exports.join(" && ")} && ${command}` : command;
122
+ const wrappedCommand =
123
+ exports.length > 0 ? `/bin/bash -c '${startupScript.replace(/'/g, "'\\''")}'` : command;
114
124
 
115
125
  const { exitCode, stderr } = await runCommand(
116
126
  ["tmux", "new-session", "-d", "-s", name, "-c", cwd, wrappedCommand],
@@ -123,12 +133,19 @@ export async function createSession(
123
133
  });
124
134
  }
125
135
 
126
- // Retrieve the actual PID of the process running inside the tmux pane
127
- const pidResult = await runCommand(["tmux", "list-panes", "-t", name, "-F", "#{pane_pid}"]);
136
+ // Retrieve the actual PID of the process running inside the tmux pane.
137
+ // Retry up to maxRetries times with backoff for WSL2 race conditions where
138
+ // the session exists but the pane hasn't been registered yet (#73).
139
+ let pidResult: { stdout: string; stderr: string; exitCode: number } | undefined;
140
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
141
+ pidResult = await runCommand(["tmux", "list-panes", "-t", name, "-F", "#{pane_pid}"]);
142
+ if (pidResult.exitCode === 0) break;
143
+ await Bun.sleep(250 * (attempt + 1));
144
+ }
128
145
 
129
- if (pidResult.exitCode !== 0) {
146
+ if (!pidResult || pidResult.exitCode !== 0) {
130
147
  throw new AgentError(
131
- `Created tmux session "${name}" but failed to retrieve PID: ${pidResult.stderr.trim()}`,
148
+ `Created tmux session "${name}" but failed to retrieve PID: ${pidResult?.stderr.trim() ?? "unknown error"}`,
132
149
  { agentName: name },
133
150
  );
134
151
  }
@@ -477,7 +494,12 @@ export async function capturePaneContent(name: string, lines = 50): Promise<stri
477
494
  * Delegates all readiness detection to the provided `detectReady` callback,
478
495
  * making this function runtime-agnostic. The callback inspects pane content
479
496
  * and returns a ReadyState phase: "loading" (keep waiting), "dialog" (send
480
- * Enter to dismiss, then continue), or "ready" (return true).
497
+ * the requested action, then continue), or "ready" (return true).
498
+ *
499
+ * Dialog actions that type raw text (for example Claude Code's `type:2`
500
+ * bypass confirmation) are retried if the same dialog is still visible on
501
+ * later polls. This avoids one-shot startup flakes when tmux or the TUI drops
502
+ * the first keypress during initialization.
481
503
  *
482
504
  * @param name - Tmux session name to poll
483
505
  * @param detectReady - Callback that inspects pane content and returns ReadyState
@@ -492,18 +514,28 @@ export async function waitForTuiReady(
492
514
  pollIntervalMs = 500,
493
515
  ): Promise<boolean> {
494
516
  const maxAttempts = Math.ceil(timeoutMs / pollIntervalMs);
495
- let dialogHandled = false;
517
+ const handledDialogs = new Map<string, number>();
518
+ const typedDialogRetryPolls = Math.max(2, Math.ceil(1_000 / pollIntervalMs));
496
519
 
497
520
  for (let i = 0; i < maxAttempts; i++) {
498
521
  const content = await capturePaneContent(name);
499
522
  if (content !== null) {
500
523
  const state = detectReady(content);
501
524
 
502
- if (state.phase === "dialog" && !dialogHandled) {
503
- await sendKeys(name, "");
504
- dialogHandled = true;
505
- await Bun.sleep(pollIntervalMs);
506
- continue;
525
+ if (state.phase === "dialog") {
526
+ const lastHandledAttempt = handledDialogs.get(state.action);
527
+ const shouldRetryTypedDialog =
528
+ state.action.startsWith("type:") &&
529
+ lastHandledAttempt !== undefined &&
530
+ i - lastHandledAttempt >= typedDialogRetryPolls;
531
+ const shouldHandleDialog = lastHandledAttempt === undefined || shouldRetryTypedDialog;
532
+
533
+ if (shouldHandleDialog) {
534
+ await handleDialogAction(name, state.action, pollIntervalMs);
535
+ handledDialogs.set(state.action, i);
536
+ await Bun.sleep(pollIntervalMs);
537
+ continue;
538
+ }
507
539
  }
508
540
 
509
541
  if (state.phase === "ready") {
@@ -534,25 +566,81 @@ export async function ensureTmuxAvailable(): Promise<void> {
534
566
  }
535
567
 
536
568
  /**
537
- * Send keys to a tmux session.
569
+ * Send keys to a tmux session, with retry for WSL2 pane registration race.
570
+ *
571
+ * On WSL2, tmux occasionally reports "can't find pane" immediately after session
572
+ * creation even though the session exists. This is a timing issue where the pane
573
+ * hasn't been fully registered yet. We retry with backoff to handle this.
538
574
  *
539
575
  * @param name - Session name to send keys to
540
576
  * @param keys - The keys/text to send
541
- * @throws AgentError if the session does not exist or send fails
577
+ * @param maxRetries - Maximum retry attempts for transient pane errors (default 3)
578
+ * @throws AgentError if the session does not exist or send fails after retries
542
579
  */
543
- export async function sendKeys(name: string, keys: string): Promise<void> {
580
+ export async function sendKeys(name: string, keys: string, maxRetries = 3): Promise<void> {
544
581
  // Flatten newlines to spaces — multiline text via tmux send-keys causes
545
582
  // Claude Code's TUI to receive embedded Enter keystrokes which prevent
546
583
  // the final "Enter" from triggering message submission (overstory-y2ob).
547
584
  const flatKeys = keys.replace(/\n/g, " ");
548
- const { exitCode, stderr } = await runCommand([
549
- "tmux",
550
- "send-keys",
551
- "-t",
552
- name,
553
- flatKeys,
554
- "Enter",
555
- ]);
585
+
586
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
587
+ const { exitCode, stderr } = await runCommand([
588
+ "tmux",
589
+ "send-keys",
590
+ "-t",
591
+ name,
592
+ flatKeys,
593
+ "Enter",
594
+ ]);
595
+
596
+ if (exitCode === 0) {
597
+ return;
598
+ }
599
+
600
+ const trimmedStderr = stderr.trim();
601
+
602
+ if (trimmedStderr.includes("no server running")) {
603
+ throw new AgentError(
604
+ `Tmux server is not running (cannot reach session "${name}"). This often happens when running as root (UID 0) or when tmux crashed. Original error: ${trimmedStderr}`,
605
+ { agentName: name },
606
+ );
607
+ }
608
+
609
+ // "can't find pane" is a transient race condition on WSL2 — the session
610
+ // exists but the pane hasn't been fully registered yet. Retry with backoff.
611
+ if (trimmedStderr.includes("can't find pane") || trimmedStderr.includes("cant find pane")) {
612
+ if (attempt < maxRetries) {
613
+ const delayMs = 250 * (attempt + 1);
614
+ await Bun.sleep(delayMs);
615
+ continue;
616
+ }
617
+ // Exhausted retries — report as pane-specific error
618
+ throw new AgentError(
619
+ `Tmux pane for session "${name}" not found after ${maxRetries + 1} attempts. On WSL2, this can indicate a tmux startup race condition. Try increasing the retry count or adding a delay after session creation.`,
620
+ { agentName: name },
621
+ );
622
+ }
623
+
624
+ if (
625
+ trimmedStderr.includes("session not found") ||
626
+ trimmedStderr.includes("can't find session") ||
627
+ trimmedStderr.includes("cant find session")
628
+ ) {
629
+ throw new AgentError(
630
+ `Tmux session "${name}" does not exist. The agent may have crashed or been killed before receiving input.`,
631
+ { agentName: name },
632
+ );
633
+ }
634
+
635
+ throw new AgentError(`Failed to send keys to tmux session "${name}": ${trimmedStderr}`, {
636
+ agentName: name,
637
+ });
638
+ }
639
+ }
640
+
641
+ async function sendRawKeys(name: string, keys: string): Promise<void> {
642
+ const flatKeys = keys.replace(/\n/g, " ");
643
+ const { exitCode, stderr } = await runCommand(["tmux", "send-keys", "-t", name, flatKeys]);
556
644
 
557
645
  if (exitCode !== 0) {
558
646
  const trimmedStderr = stderr.trim();
@@ -580,3 +668,18 @@ export async function sendKeys(name: string, keys: string): Promise<void> {
580
668
  });
581
669
  }
582
670
  }
671
+
672
+ async function handleDialogAction(
673
+ name: string,
674
+ action: string,
675
+ pollIntervalMs: number,
676
+ ): Promise<void> {
677
+ if (action.startsWith("type:")) {
678
+ await sendRawKeys(name, action.slice("type:".length));
679
+ await Bun.sleep(Math.min(pollIntervalMs, 250));
680
+ await sendKeys(name, "");
681
+ return;
682
+ }
683
+
684
+ await sendKeys(name, action === "Enter" ? "" : action);
685
+ }