@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.
- package/README.md +11 -8
- package/package.json +1 -1
- package/src/agents/hooks-deployer.test.ts +185 -12
- package/src/agents/hooks-deployer.ts +57 -1
- package/src/commands/coordinator.test.ts +74 -5
- package/src/commands/coordinator.ts +27 -3
- package/src/commands/dashboard.ts +84 -18
- package/src/commands/ecosystem.test.ts +101 -0
- package/src/commands/init.test.ts +74 -0
- package/src/commands/init.ts +36 -14
- package/src/commands/sling.test.ts +33 -0
- package/src/commands/sling.ts +106 -38
- package/src/commands/supervisor.ts +2 -0
- package/src/index.ts +1 -1
- package/src/merge/resolver.test.ts +141 -7
- package/src/merge/resolver.ts +61 -8
- package/src/runtimes/claude.test.ts +32 -7
- package/src/runtimes/claude.ts +19 -4
- package/src/runtimes/codex.test.ts +13 -0
- package/src/runtimes/codex.ts +18 -2
- package/src/runtimes/copilot.ts +3 -0
- package/src/runtimes/cursor.test.ts +497 -0
- package/src/runtimes/cursor.ts +205 -0
- package/src/runtimes/gemini.ts +3 -0
- package/src/runtimes/opencode.ts +3 -0
- package/src/runtimes/pi.test.ts +1 -1
- package/src/runtimes/pi.ts +3 -0
- package/src/runtimes/registry.test.ts +21 -1
- package/src/runtimes/registry.ts +3 -0
- package/src/runtimes/sapling.ts +3 -0
- package/src/runtimes/types.ts +5 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.test.ts +178 -0
- package/src/sessions/store.ts +44 -8
- package/src/types.ts +8 -1
- package/src/worktree/tmux.test.ts +150 -0
- package/src/worktree/tmux.ts +126 -23
package/src/worktree/tmux.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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"
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
* @
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
+
}
|