@os-eco/overstory-cli 0.9.2 → 0.9.4
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 +3 -0
- package/agents/coordinator.md +4 -4
- package/package.json +1 -1
- package/src/agents/copilot-hooks-deployer.test.ts +162 -0
- package/src/agents/copilot-hooks-deployer.ts +93 -0
- package/src/beads/client.ts +31 -3
- package/src/commands/clean.ts +2 -1
- package/src/commands/completions.test.ts +4 -1
- package/src/commands/coordinator.ts +4 -2
- package/src/commands/dashboard.ts +4 -1
- package/src/commands/doctor.ts +91 -76
- package/src/commands/init.ts +42 -0
- package/src/commands/inspect.ts +8 -4
- package/src/commands/monitor.ts +10 -3
- package/src/commands/sling.test.ts +12 -0
- package/src/commands/sling.ts +10 -1
- package/src/commands/supervisor.ts +2 -1
- package/src/commands/watch.test.ts +6 -4
- package/src/commands/worktree.test.ts +319 -3
- package/src/commands/worktree.ts +86 -0
- package/src/config.test.ts +78 -0
- package/src/config.ts +20 -1
- package/src/doctor/consistency.ts +2 -2
- package/src/index.ts +1 -1
- package/src/runtimes/codex.test.ts +38 -1
- package/src/runtimes/codex.ts +22 -3
- package/src/runtimes/copilot.test.ts +213 -13
- package/src/runtimes/copilot.ts +93 -11
- package/src/runtimes/pi.test.ts +24 -0
- package/src/runtimes/pi.ts +4 -3
- package/src/runtimes/types.ts +7 -0
- package/src/tracker/factory.test.ts +10 -0
- package/src/tracker/factory.ts +3 -2
- package/src/worktree/tmux.test.ts +202 -49
- package/src/worktree/tmux.ts +49 -37
- package/templates/copilot-hooks.json.tmpl +13 -0
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
killProcessTree,
|
|
14
14
|
killSession,
|
|
15
15
|
listSessions,
|
|
16
|
+
sanitizeTmuxName,
|
|
16
17
|
sendKeys,
|
|
17
18
|
waitForTuiReady,
|
|
18
19
|
} from "./tmux.ts";
|
|
@@ -102,13 +103,13 @@ describe("createSession", () => {
|
|
|
102
103
|
const tmuxCallArgs = spawnSpy.mock.calls[1] as unknown[];
|
|
103
104
|
const cmd = tmuxCallArgs[0] as string[];
|
|
104
105
|
expect(cmd[0]).toBe("tmux");
|
|
105
|
-
expect(cmd[
|
|
106
|
-
expect(cmd[
|
|
107
|
-
expect(cmd[
|
|
108
|
-
expect(cmd[
|
|
109
|
-
expect(cmd[
|
|
106
|
+
expect(cmd[3]).toBe("new-session");
|
|
107
|
+
expect(cmd[5]).toBe("-s");
|
|
108
|
+
expect(cmd[6]).toBe("my-session");
|
|
109
|
+
expect(cmd[7]).toBe("-c");
|
|
110
|
+
expect(cmd[8]).toBe("/work/dir");
|
|
110
111
|
// The command should be wrapped with PATH export
|
|
111
|
-
const wrappedCmd = cmd[
|
|
112
|
+
const wrappedCmd = cmd[9] as string;
|
|
112
113
|
expect(wrappedCmd).toContain("echo hello");
|
|
113
114
|
expect(wrappedCmd).toContain("export PATH=");
|
|
114
115
|
|
|
@@ -136,7 +137,16 @@ describe("createSession", () => {
|
|
|
136
137
|
expect(spawnSpy).toHaveBeenCalledTimes(3);
|
|
137
138
|
const thirdCallArgs = spawnSpy.mock.calls[2] as unknown[];
|
|
138
139
|
const cmd = thirdCallArgs[0] as string[];
|
|
139
|
-
expect(cmd).toEqual([
|
|
140
|
+
expect(cmd).toEqual([
|
|
141
|
+
"tmux",
|
|
142
|
+
"-L",
|
|
143
|
+
"overstory",
|
|
144
|
+
"list-panes",
|
|
145
|
+
"-t",
|
|
146
|
+
"test-agent",
|
|
147
|
+
"-F",
|
|
148
|
+
"#{pane_pid}",
|
|
149
|
+
]);
|
|
140
150
|
});
|
|
141
151
|
|
|
142
152
|
test("throws AgentError if session creation fails", async () => {
|
|
@@ -239,7 +249,7 @@ describe("createSession", () => {
|
|
|
239
249
|
// Call 0: which ov, Call 1: which overstory, Call 2: tmux new-session
|
|
240
250
|
const tmuxCallArgs = spawnSpy.mock.calls[2] as unknown[];
|
|
241
251
|
const cmd = tmuxCallArgs[0] as string[];
|
|
242
|
-
const tmuxCmd = cmd[
|
|
252
|
+
const tmuxCmd = cmd[9] as string;
|
|
243
253
|
expect(tmuxCmd).toContain("echo test");
|
|
244
254
|
});
|
|
245
255
|
|
|
@@ -360,7 +370,14 @@ describe("listSessions", () => {
|
|
|
360
370
|
expect(spawnSpy).toHaveBeenCalledTimes(1);
|
|
361
371
|
const callArgs = spawnSpy.mock.calls[0] as unknown[];
|
|
362
372
|
const cmd = callArgs[0] as string[];
|
|
363
|
-
expect(cmd).toEqual([
|
|
373
|
+
expect(cmd).toEqual([
|
|
374
|
+
"tmux",
|
|
375
|
+
"-L",
|
|
376
|
+
"overstory",
|
|
377
|
+
"list-sessions",
|
|
378
|
+
"-F",
|
|
379
|
+
"#{session_name}:#{pid}",
|
|
380
|
+
]);
|
|
364
381
|
});
|
|
365
382
|
});
|
|
366
383
|
|
|
@@ -383,7 +400,16 @@ describe("getPanePid", () => {
|
|
|
383
400
|
expect(pid).toBe(42);
|
|
384
401
|
const callArgs = spawnSpy.mock.calls[0] as unknown[];
|
|
385
402
|
const cmd = callArgs[0] as string[];
|
|
386
|
-
expect(cmd).toEqual([
|
|
403
|
+
expect(cmd).toEqual([
|
|
404
|
+
"tmux",
|
|
405
|
+
"-L",
|
|
406
|
+
"overstory",
|
|
407
|
+
"display-message",
|
|
408
|
+
"-p",
|
|
409
|
+
"-t",
|
|
410
|
+
"overstory-auth",
|
|
411
|
+
"#{pane_pid}",
|
|
412
|
+
]);
|
|
387
413
|
});
|
|
388
414
|
|
|
389
415
|
test("returns null when session does not exist", async () => {
|
|
@@ -679,7 +705,7 @@ describe("killSession", () => {
|
|
|
679
705
|
const cmd = args[0] as string[];
|
|
680
706
|
cmds.push(cmd);
|
|
681
707
|
|
|
682
|
-
if (cmd[0] === "tmux" && cmd[
|
|
708
|
+
if (cmd[0] === "tmux" && cmd[3] === "display-message") {
|
|
683
709
|
// getPanePid → returns PID 500
|
|
684
710
|
return mockSpawnResult("500\n", "", 0);
|
|
685
711
|
}
|
|
@@ -687,7 +713,7 @@ describe("killSession", () => {
|
|
|
687
713
|
// getDescendantPids → no children
|
|
688
714
|
return mockSpawnResult("", "", 1);
|
|
689
715
|
}
|
|
690
|
-
if (cmd[0] === "tmux" && cmd[
|
|
716
|
+
if (cmd[0] === "tmux" && cmd[3] === "kill-session") {
|
|
691
717
|
return mockSpawnResult("", "", 0);
|
|
692
718
|
}
|
|
693
719
|
return mockSpawnResult("", "", 0);
|
|
@@ -700,6 +726,8 @@ describe("killSession", () => {
|
|
|
700
726
|
// Should have called: tmux display-message, pgrep, tmux kill-session
|
|
701
727
|
expect(cmds[0]).toEqual([
|
|
702
728
|
"tmux",
|
|
729
|
+
"-L",
|
|
730
|
+
"overstory",
|
|
703
731
|
"display-message",
|
|
704
732
|
"-p",
|
|
705
733
|
"-t",
|
|
@@ -708,7 +736,7 @@ describe("killSession", () => {
|
|
|
708
736
|
]);
|
|
709
737
|
expect(cmds[1]).toEqual(["pgrep", "-P", "500"]);
|
|
710
738
|
const lastCmd = cmds[cmds.length - 1];
|
|
711
|
-
expect(lastCmd).toEqual(["tmux", "kill-session", "-t", "overstory-auth"]);
|
|
739
|
+
expect(lastCmd).toEqual(["tmux", "-L", "overstory", "kill-session", "-t", "overstory-auth"]);
|
|
712
740
|
|
|
713
741
|
// Should have sent SIGTERM to root PID 500
|
|
714
742
|
expect(killSpy).toHaveBeenCalledWith(500, "SIGTERM");
|
|
@@ -720,11 +748,11 @@ describe("killSession", () => {
|
|
|
720
748
|
const cmd = args[0] as string[];
|
|
721
749
|
cmds.push(cmd);
|
|
722
750
|
|
|
723
|
-
if (cmd[0] === "tmux" && cmd[
|
|
751
|
+
if (cmd[0] === "tmux" && cmd[3] === "display-message") {
|
|
724
752
|
// getPanePid → session not found
|
|
725
753
|
return mockSpawnResult("", "can't find session", 1);
|
|
726
754
|
}
|
|
727
|
-
if (cmd[0] === "tmux" && cmd[
|
|
755
|
+
if (cmd[0] === "tmux" && cmd[3] === "kill-session") {
|
|
728
756
|
return mockSpawnResult("", "", 0);
|
|
729
757
|
}
|
|
730
758
|
return mockSpawnResult("", "", 0);
|
|
@@ -734,8 +762,8 @@ describe("killSession", () => {
|
|
|
734
762
|
|
|
735
763
|
// Should go straight to tmux kill-session (no pgrep calls)
|
|
736
764
|
expect(cmds).toHaveLength(2);
|
|
737
|
-
expect(cmds[0]?.[
|
|
738
|
-
expect(cmds[1]?.[
|
|
765
|
+
expect(cmds[0]?.[3]).toBe("display-message");
|
|
766
|
+
expect(cmds[1]?.[3]).toBe("kill-session");
|
|
739
767
|
// No process.kill calls since we had no PID
|
|
740
768
|
expect(killSpy).not.toHaveBeenCalled();
|
|
741
769
|
});
|
|
@@ -743,13 +771,13 @@ describe("killSession", () => {
|
|
|
743
771
|
test("succeeds silently when session is already gone after process cleanup", async () => {
|
|
744
772
|
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
745
773
|
const cmd = args[0] as string[];
|
|
746
|
-
if (cmd[0] === "tmux" && cmd[
|
|
774
|
+
if (cmd[0] === "tmux" && cmd[3] === "display-message") {
|
|
747
775
|
return mockSpawnResult("500\n", "", 0);
|
|
748
776
|
}
|
|
749
777
|
if (cmd[0] === "pgrep") {
|
|
750
778
|
return mockSpawnResult("", "", 1);
|
|
751
779
|
}
|
|
752
|
-
if (cmd[0] === "tmux" && cmd[
|
|
780
|
+
if (cmd[0] === "tmux" && cmd[3] === "kill-session") {
|
|
753
781
|
// Session already gone after process cleanup
|
|
754
782
|
return mockSpawnResult("", "can't find session: overstory-auth", 1);
|
|
755
783
|
}
|
|
@@ -765,10 +793,10 @@ describe("killSession", () => {
|
|
|
765
793
|
test("throws AgentError on unexpected tmux kill-session failure", async () => {
|
|
766
794
|
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
767
795
|
const cmd = args[0] as string[];
|
|
768
|
-
if (cmd[0] === "tmux" && cmd[
|
|
796
|
+
if (cmd[0] === "tmux" && cmd[3] === "display-message") {
|
|
769
797
|
return mockSpawnResult("", "can't find session", 1);
|
|
770
798
|
}
|
|
771
|
-
if (cmd[0] === "tmux" && cmd[
|
|
799
|
+
if (cmd[0] === "tmux" && cmd[3] === "kill-session") {
|
|
772
800
|
return mockSpawnResult("", "server exited unexpectedly", 1);
|
|
773
801
|
}
|
|
774
802
|
return mockSpawnResult("", "", 0);
|
|
@@ -780,10 +808,10 @@ describe("killSession", () => {
|
|
|
780
808
|
test("AgentError contains session name on failure", async () => {
|
|
781
809
|
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
782
810
|
const cmd = args[0] as string[];
|
|
783
|
-
if (cmd[0] === "tmux" && cmd[
|
|
811
|
+
if (cmd[0] === "tmux" && cmd[3] === "display-message") {
|
|
784
812
|
return mockSpawnResult("", "error", 1);
|
|
785
813
|
}
|
|
786
|
-
if (cmd[0] === "tmux" && cmd[
|
|
814
|
+
if (cmd[0] === "tmux" && cmd[3] === "kill-session") {
|
|
787
815
|
return mockSpawnResult("", "server exited unexpectedly", 1);
|
|
788
816
|
}
|
|
789
817
|
return mockSpawnResult("", "", 0);
|
|
@@ -836,7 +864,7 @@ describe("isSessionAlive", () => {
|
|
|
836
864
|
expect(spawnSpy).toHaveBeenCalledTimes(1);
|
|
837
865
|
const callArgs = spawnSpy.mock.calls[0] as unknown[];
|
|
838
866
|
const cmd = callArgs[0] as string[];
|
|
839
|
-
expect(cmd).toEqual(["tmux", "has-session", "-t", "my-agent"]);
|
|
867
|
+
expect(cmd).toEqual(["tmux", "-L", "overstory", "has-session", "-t", "my-agent"]);
|
|
840
868
|
});
|
|
841
869
|
});
|
|
842
870
|
|
|
@@ -907,7 +935,16 @@ describe("sendKeys", () => {
|
|
|
907
935
|
expect(spawnSpy).toHaveBeenCalledTimes(1);
|
|
908
936
|
const callArgs = spawnSpy.mock.calls[0] as unknown[];
|
|
909
937
|
const cmd = callArgs[0] as string[];
|
|
910
|
-
expect(cmd).toEqual([
|
|
938
|
+
expect(cmd).toEqual([
|
|
939
|
+
"tmux",
|
|
940
|
+
"-L",
|
|
941
|
+
"overstory",
|
|
942
|
+
"send-keys",
|
|
943
|
+
"-t",
|
|
944
|
+
"overstory-auth",
|
|
945
|
+
"echo hello world",
|
|
946
|
+
"Enter",
|
|
947
|
+
]);
|
|
911
948
|
});
|
|
912
949
|
|
|
913
950
|
test("flattens newlines in keys to spaces", async () => {
|
|
@@ -920,6 +957,8 @@ describe("sendKeys", () => {
|
|
|
920
957
|
const cmd = callArgs[0] as string[];
|
|
921
958
|
expect(cmd).toEqual([
|
|
922
959
|
"tmux",
|
|
960
|
+
"-L",
|
|
961
|
+
"overstory",
|
|
923
962
|
"send-keys",
|
|
924
963
|
"-t",
|
|
925
964
|
"overstory-agent",
|
|
@@ -956,7 +995,16 @@ describe("sendKeys", () => {
|
|
|
956
995
|
expect(spawnSpy).toHaveBeenCalledTimes(1);
|
|
957
996
|
const callArgs = spawnSpy.mock.calls[0] as unknown[];
|
|
958
997
|
const cmd = callArgs[0] as string[];
|
|
959
|
-
expect(cmd).toEqual([
|
|
998
|
+
expect(cmd).toEqual([
|
|
999
|
+
"tmux",
|
|
1000
|
+
"-L",
|
|
1001
|
+
"overstory",
|
|
1002
|
+
"send-keys",
|
|
1003
|
+
"-t",
|
|
1004
|
+
"overstory-agent",
|
|
1005
|
+
"",
|
|
1006
|
+
"Enter",
|
|
1007
|
+
]);
|
|
960
1008
|
});
|
|
961
1009
|
|
|
962
1010
|
test("throws descriptive error when tmux server is not running", async () => {
|
|
@@ -1039,7 +1087,17 @@ describe("capturePaneContent", () => {
|
|
|
1039
1087
|
|
|
1040
1088
|
const callArgs = spawnSpy.mock.calls[0] as unknown[];
|
|
1041
1089
|
const cmd = callArgs[0] as string[];
|
|
1042
|
-
expect(cmd).toEqual([
|
|
1090
|
+
expect(cmd).toEqual([
|
|
1091
|
+
"tmux",
|
|
1092
|
+
"-L",
|
|
1093
|
+
"overstory",
|
|
1094
|
+
"capture-pane",
|
|
1095
|
+
"-t",
|
|
1096
|
+
"my-session",
|
|
1097
|
+
"-p",
|
|
1098
|
+
"-S",
|
|
1099
|
+
"-100",
|
|
1100
|
+
]);
|
|
1043
1101
|
});
|
|
1044
1102
|
|
|
1045
1103
|
test("uses default 50 lines when not specified", async () => {
|
|
@@ -1049,7 +1107,7 @@ describe("capturePaneContent", () => {
|
|
|
1049
1107
|
|
|
1050
1108
|
const callArgs = spawnSpy.mock.calls[0] as unknown[];
|
|
1051
1109
|
const cmd = callArgs[0] as string[];
|
|
1052
|
-
expect(cmd[
|
|
1110
|
+
expect(cmd[8]).toBe("-50");
|
|
1053
1111
|
});
|
|
1054
1112
|
|
|
1055
1113
|
test("returns null when capture-pane fails", async () => {
|
|
@@ -1122,7 +1180,7 @@ describe("waitForTuiReady", () => {
|
|
|
1122
1180
|
let captureCallCount = 0;
|
|
1123
1181
|
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1124
1182
|
const cmd = args[0] as string[];
|
|
1125
|
-
if (cmd[
|
|
1183
|
+
if (cmd[3] === "capture-pane") {
|
|
1126
1184
|
captureCallCount++;
|
|
1127
1185
|
if (captureCallCount <= 3) {
|
|
1128
1186
|
// First 3 capture-pane polls: empty pane (TUI still loading)
|
|
@@ -1174,7 +1232,7 @@ describe("waitForTuiReady", () => {
|
|
|
1174
1232
|
// capture-pane fails (session dead), has-session also fails (session dead)
|
|
1175
1233
|
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1176
1234
|
const cmd = args[0] as string[];
|
|
1177
|
-
if (cmd[
|
|
1235
|
+
if (cmd[3] === "capture-pane") {
|
|
1178
1236
|
return mockSpawnResult("", "can't find session", 1);
|
|
1179
1237
|
}
|
|
1180
1238
|
// has-session: session is dead
|
|
@@ -1192,7 +1250,7 @@ describe("waitForTuiReady", () => {
|
|
|
1192
1250
|
let captureCallCount = 0;
|
|
1193
1251
|
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1194
1252
|
const cmd = args[0] as string[];
|
|
1195
|
-
if (cmd[
|
|
1253
|
+
if (cmd[3] === "capture-pane") {
|
|
1196
1254
|
captureCallCount++;
|
|
1197
1255
|
// Pane stays empty for all polls (session alive but TUI not rendered yet)
|
|
1198
1256
|
return mockSpawnResult("", "", 0);
|
|
@@ -1214,7 +1272,7 @@ describe("waitForTuiReady", () => {
|
|
|
1214
1272
|
// Pane always shows prompt indicator but never shows status bar text
|
|
1215
1273
|
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1216
1274
|
const cmd = args[0] as string[];
|
|
1217
|
-
if (cmd[
|
|
1275
|
+
if (cmd[3] === "capture-pane") {
|
|
1218
1276
|
return mockSpawnResult("Welcome to Claude Code!\n\u276f", "", 0);
|
|
1219
1277
|
}
|
|
1220
1278
|
// has-session: session is alive
|
|
@@ -1230,7 +1288,7 @@ describe("waitForTuiReady", () => {
|
|
|
1230
1288
|
// Pane always shows status bar but never shows prompt indicator
|
|
1231
1289
|
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1232
1290
|
const cmd = args[0] as string[];
|
|
1233
|
-
if (cmd[
|
|
1291
|
+
if (cmd[3] === "capture-pane") {
|
|
1234
1292
|
return mockSpawnResult("bypass permissions", "", 0);
|
|
1235
1293
|
}
|
|
1236
1294
|
// has-session: session is alive
|
|
@@ -1246,7 +1304,7 @@ describe("waitForTuiReady", () => {
|
|
|
1246
1304
|
let captureCallCount = 0;
|
|
1247
1305
|
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1248
1306
|
const cmd = args[0] as string[];
|
|
1249
|
-
if (cmd[
|
|
1307
|
+
if (cmd[3] === "capture-pane") {
|
|
1250
1308
|
captureCallCount++;
|
|
1251
1309
|
if (captureCallCount <= 2) {
|
|
1252
1310
|
// First 2 polls: only prompt indicator visible (phase 1 only)
|
|
@@ -1271,7 +1329,7 @@ describe("waitForTuiReady", () => {
|
|
|
1271
1329
|
let captureCallCount = 0;
|
|
1272
1330
|
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1273
1331
|
const cmd = args[0] as string[];
|
|
1274
|
-
if (cmd[
|
|
1332
|
+
if (cmd[3] === "capture-pane") {
|
|
1275
1333
|
captureCallCount++;
|
|
1276
1334
|
if (captureCallCount === 1) {
|
|
1277
1335
|
// First poll: trust dialog is showing
|
|
@@ -1280,7 +1338,7 @@ describe("waitForTuiReady", () => {
|
|
|
1280
1338
|
// Subsequent polls: trust confirmed, real TUI with both indicators
|
|
1281
1339
|
return mockSpawnResult('Try "help"\nshift+tab', "", 0);
|
|
1282
1340
|
}
|
|
1283
|
-
if (cmd[
|
|
1341
|
+
if (cmd[3] === "send-keys") {
|
|
1284
1342
|
sendKeysCalls.push(cmd);
|
|
1285
1343
|
return mockSpawnResult("", "", 0);
|
|
1286
1344
|
}
|
|
@@ -1294,7 +1352,16 @@ describe("waitForTuiReady", () => {
|
|
|
1294
1352
|
// sendKeys should have been called once to confirm the trust dialog
|
|
1295
1353
|
expect(sendKeysCalls).toHaveLength(1);
|
|
1296
1354
|
const trustCall = sendKeysCalls[0];
|
|
1297
|
-
expect(trustCall).toEqual([
|
|
1355
|
+
expect(trustCall).toEqual([
|
|
1356
|
+
"tmux",
|
|
1357
|
+
"-L",
|
|
1358
|
+
"overstory",
|
|
1359
|
+
"send-keys",
|
|
1360
|
+
"-t",
|
|
1361
|
+
"overstory-agent",
|
|
1362
|
+
"",
|
|
1363
|
+
"Enter",
|
|
1364
|
+
]);
|
|
1298
1365
|
});
|
|
1299
1366
|
|
|
1300
1367
|
test("detects bypass permissions dialog and types 2 before Enter", async () => {
|
|
@@ -1302,7 +1369,7 @@ describe("waitForTuiReady", () => {
|
|
|
1302
1369
|
let captureCallCount = 0;
|
|
1303
1370
|
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1304
1371
|
const cmd = args[0] as string[];
|
|
1305
|
-
if (cmd[
|
|
1372
|
+
if (cmd[3] === "capture-pane") {
|
|
1306
1373
|
captureCallCount++;
|
|
1307
1374
|
if (captureCallCount === 1) {
|
|
1308
1375
|
return mockSpawnResult(
|
|
@@ -1313,7 +1380,7 @@ describe("waitForTuiReady", () => {
|
|
|
1313
1380
|
}
|
|
1314
1381
|
return mockSpawnResult('Try "help"\nshift+tab', "", 0);
|
|
1315
1382
|
}
|
|
1316
|
-
if (cmd[
|
|
1383
|
+
if (cmd[3] === "send-keys") {
|
|
1317
1384
|
sendKeysCalls.push(cmd);
|
|
1318
1385
|
return mockSpawnResult("", "", 0);
|
|
1319
1386
|
}
|
|
@@ -1324,8 +1391,25 @@ describe("waitForTuiReady", () => {
|
|
|
1324
1391
|
|
|
1325
1392
|
expect(ready).toBe(true);
|
|
1326
1393
|
expect(sendKeysCalls).toHaveLength(2);
|
|
1327
|
-
expect(sendKeysCalls[0]).toEqual([
|
|
1328
|
-
|
|
1394
|
+
expect(sendKeysCalls[0]).toEqual([
|
|
1395
|
+
"tmux",
|
|
1396
|
+
"-L",
|
|
1397
|
+
"overstory",
|
|
1398
|
+
"send-keys",
|
|
1399
|
+
"-t",
|
|
1400
|
+
"overstory-agent",
|
|
1401
|
+
"2",
|
|
1402
|
+
]);
|
|
1403
|
+
expect(sendKeysCalls[1]).toEqual([
|
|
1404
|
+
"tmux",
|
|
1405
|
+
"-L",
|
|
1406
|
+
"overstory",
|
|
1407
|
+
"send-keys",
|
|
1408
|
+
"-t",
|
|
1409
|
+
"overstory-agent",
|
|
1410
|
+
"",
|
|
1411
|
+
"Enter",
|
|
1412
|
+
]);
|
|
1329
1413
|
});
|
|
1330
1414
|
|
|
1331
1415
|
test("retries typed bypass dialog action when the same dialog persists", async () => {
|
|
@@ -1333,7 +1417,7 @@ describe("waitForTuiReady", () => {
|
|
|
1333
1417
|
let captureCallCount = 0;
|
|
1334
1418
|
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1335
1419
|
const cmd = args[0] as string[];
|
|
1336
|
-
if (cmd[
|
|
1420
|
+
if (cmd[3] === "capture-pane") {
|
|
1337
1421
|
captureCallCount++;
|
|
1338
1422
|
if (captureCallCount <= 3) {
|
|
1339
1423
|
return mockSpawnResult(
|
|
@@ -1344,7 +1428,7 @@ describe("waitForTuiReady", () => {
|
|
|
1344
1428
|
}
|
|
1345
1429
|
return mockSpawnResult('Try "help"\nshift+tab', "", 0);
|
|
1346
1430
|
}
|
|
1347
|
-
if (cmd[
|
|
1431
|
+
if (cmd[3] === "send-keys") {
|
|
1348
1432
|
sendKeysCalls.push(cmd);
|
|
1349
1433
|
return mockSpawnResult("", "", 0);
|
|
1350
1434
|
}
|
|
@@ -1355,10 +1439,44 @@ describe("waitForTuiReady", () => {
|
|
|
1355
1439
|
|
|
1356
1440
|
expect(ready).toBe(true);
|
|
1357
1441
|
expect(sendKeysCalls).toHaveLength(4);
|
|
1358
|
-
expect(sendKeysCalls[0]).toEqual([
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1442
|
+
expect(sendKeysCalls[0]).toEqual([
|
|
1443
|
+
"tmux",
|
|
1444
|
+
"-L",
|
|
1445
|
+
"overstory",
|
|
1446
|
+
"send-keys",
|
|
1447
|
+
"-t",
|
|
1448
|
+
"overstory-agent",
|
|
1449
|
+
"2",
|
|
1450
|
+
]);
|
|
1451
|
+
expect(sendKeysCalls[1]).toEqual([
|
|
1452
|
+
"tmux",
|
|
1453
|
+
"-L",
|
|
1454
|
+
"overstory",
|
|
1455
|
+
"send-keys",
|
|
1456
|
+
"-t",
|
|
1457
|
+
"overstory-agent",
|
|
1458
|
+
"",
|
|
1459
|
+
"Enter",
|
|
1460
|
+
]);
|
|
1461
|
+
expect(sendKeysCalls[2]).toEqual([
|
|
1462
|
+
"tmux",
|
|
1463
|
+
"-L",
|
|
1464
|
+
"overstory",
|
|
1465
|
+
"send-keys",
|
|
1466
|
+
"-t",
|
|
1467
|
+
"overstory-agent",
|
|
1468
|
+
"2",
|
|
1469
|
+
]);
|
|
1470
|
+
expect(sendKeysCalls[3]).toEqual([
|
|
1471
|
+
"tmux",
|
|
1472
|
+
"-L",
|
|
1473
|
+
"overstory",
|
|
1474
|
+
"send-keys",
|
|
1475
|
+
"-t",
|
|
1476
|
+
"overstory-agent",
|
|
1477
|
+
"",
|
|
1478
|
+
"Enter",
|
|
1479
|
+
]);
|
|
1362
1480
|
});
|
|
1363
1481
|
|
|
1364
1482
|
test("handles trust dialog only once (trustHandled flag)", async () => {
|
|
@@ -1366,7 +1484,7 @@ describe("waitForTuiReady", () => {
|
|
|
1366
1484
|
let captureCallCount = 0;
|
|
1367
1485
|
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1368
1486
|
const cmd = args[0] as string[];
|
|
1369
|
-
if (cmd[
|
|
1487
|
+
if (cmd[3] === "capture-pane") {
|
|
1370
1488
|
captureCallCount++;
|
|
1371
1489
|
if (captureCallCount <= 3) {
|
|
1372
1490
|
// Multiple polls still show trust dialog (slow dialog dismissal)
|
|
@@ -1375,7 +1493,7 @@ describe("waitForTuiReady", () => {
|
|
|
1375
1493
|
// Eventually TUI loads with both indicators
|
|
1376
1494
|
return mockSpawnResult('Try "help"\nbypass permissions', "", 0);
|
|
1377
1495
|
}
|
|
1378
|
-
if (cmd[
|
|
1496
|
+
if (cmd[3] === "send-keys") {
|
|
1379
1497
|
sendKeysCalls.push(cmd);
|
|
1380
1498
|
return mockSpawnResult("", "", 0);
|
|
1381
1499
|
}
|
|
@@ -1433,3 +1551,38 @@ describe("ensureTmuxAvailable", () => {
|
|
|
1433
1551
|
}
|
|
1434
1552
|
});
|
|
1435
1553
|
});
|
|
1554
|
+
|
|
1555
|
+
describe("sanitizeTmuxName", () => {
|
|
1556
|
+
test("replaces dots with underscores", () => {
|
|
1557
|
+
expect(sanitizeTmuxName("consulting.jayminwest.com")).toBe("consulting_jayminwest_com");
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
test("replaces colons with underscores", () => {
|
|
1561
|
+
expect(sanitizeTmuxName("host:8080")).toBe("host_8080");
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1564
|
+
test("replaces mixed dots and colons", () => {
|
|
1565
|
+
expect(sanitizeTmuxName("my.project:v2.0")).toBe("my_project_v2_0");
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
test("leaves names without special characters unchanged", () => {
|
|
1569
|
+
expect(sanitizeTmuxName("my-project")).toBe("my-project");
|
|
1570
|
+
});
|
|
1571
|
+
|
|
1572
|
+
test("handles empty string", () => {
|
|
1573
|
+
expect(sanitizeTmuxName("")).toBe("");
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
test("handles name with only dots", () => {
|
|
1577
|
+
expect(sanitizeTmuxName("...")).toBe("___");
|
|
1578
|
+
});
|
|
1579
|
+
|
|
1580
|
+
test("produces valid tmux session name components", () => {
|
|
1581
|
+
// A real-world project name that would break tmux target parsing
|
|
1582
|
+
const projectName = "consulting.jayminwest.com";
|
|
1583
|
+
const sessionName = `overstory-${sanitizeTmuxName(projectName)}-coordinator`;
|
|
1584
|
+
expect(sessionName).toBe("overstory-consulting_jayminwest_com-coordinator");
|
|
1585
|
+
// No dots or colons that tmux would interpret as separators
|
|
1586
|
+
expect(sessionName).not.toMatch(/[.:]/);
|
|
1587
|
+
});
|
|
1588
|
+
});
|
package/src/worktree/tmux.ts
CHANGED
|
@@ -11,6 +11,37 @@ import { dirname, resolve } from "node:path";
|
|
|
11
11
|
import { AgentError } from "../errors.ts";
|
|
12
12
|
import type { ReadyState } from "../runtimes/types.ts";
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Dedicated tmux server socket name for agent session isolation.
|
|
16
|
+
*
|
|
17
|
+
* All overstory agent sessions use `tmux -L overstory` so they run on a
|
|
18
|
+
* separate server from the user's personal tmux. This prevents user tmux
|
|
19
|
+
* config (themes, plugins, keybindings) from interfering with agent spawn.
|
|
20
|
+
* See GitHub #93.
|
|
21
|
+
*/
|
|
22
|
+
export const TMUX_SOCKET = "overstory";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Sanitize a name component for use in tmux session names.
|
|
26
|
+
*
|
|
27
|
+
* Tmux interprets dots (.) as session.window.pane separators and colons (:)
|
|
28
|
+
* as session:window separators in target strings (`-t`). If a project name
|
|
29
|
+
* contains these characters (e.g., "consulting.jayminwest.com"), the session
|
|
30
|
+
* is created fine but subsequent lookups via `-t` parse the dots as delimiters
|
|
31
|
+
* and fail to find the session. Replace both with underscores.
|
|
32
|
+
*/
|
|
33
|
+
export function sanitizeTmuxName(name: string): string {
|
|
34
|
+
return name.replace(/[.:]/g, "_");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build a tmux command array with the dedicated server socket.
|
|
39
|
+
* All agent session operations should use this to ensure isolation.
|
|
40
|
+
*/
|
|
41
|
+
function tmuxCmd(...args: string[]): string[] {
|
|
42
|
+
return ["tmux", "-L", TMUX_SOCKET, ...args];
|
|
43
|
+
}
|
|
44
|
+
|
|
14
45
|
/**
|
|
15
46
|
* Detect the directory containing the overstory binary.
|
|
16
47
|
*
|
|
@@ -123,7 +154,7 @@ export async function createSession(
|
|
|
123
154
|
exports.length > 0 ? `/bin/bash -c '${startupScript.replace(/'/g, "'\\''")}'` : command;
|
|
124
155
|
|
|
125
156
|
const { exitCode, stderr } = await runCommand(
|
|
126
|
-
|
|
157
|
+
tmuxCmd("new-session", "-d", "-s", name, "-c", cwd, wrappedCommand),
|
|
127
158
|
cwd,
|
|
128
159
|
);
|
|
129
160
|
|
|
@@ -138,7 +169,7 @@ export async function createSession(
|
|
|
138
169
|
// the session exists but the pane hasn't been registered yet (#73).
|
|
139
170
|
let pidResult: { stdout: string; stderr: string; exitCode: number } | undefined;
|
|
140
171
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
141
|
-
pidResult = await runCommand(
|
|
172
|
+
pidResult = await runCommand(tmuxCmd("list-panes", "-t", name, "-F", "#{pane_pid}"));
|
|
142
173
|
if (pidResult.exitCode === 0) break;
|
|
143
174
|
await Bun.sleep(250 * (attempt + 1));
|
|
144
175
|
}
|
|
@@ -170,12 +201,9 @@ export async function createSession(
|
|
|
170
201
|
* @throws AgentError if tmux is not installed
|
|
171
202
|
*/
|
|
172
203
|
export async function listSessions(): Promise<Array<{ name: string; pid: number }>> {
|
|
173
|
-
const { exitCode, stdout, stderr } = await runCommand(
|
|
174
|
-
"
|
|
175
|
-
|
|
176
|
-
"-F",
|
|
177
|
-
"#{session_name}:#{pid}",
|
|
178
|
-
]);
|
|
204
|
+
const { exitCode, stdout, stderr } = await runCommand(
|
|
205
|
+
tmuxCmd("list-sessions", "-F", "#{session_name}:#{pid}"),
|
|
206
|
+
);
|
|
179
207
|
|
|
180
208
|
// Exit code 1 with "no server running" means no sessions exist — not an error
|
|
181
209
|
if (exitCode !== 0) {
|
|
@@ -219,14 +247,9 @@ const KILL_GRACE_PERIOD_MS = 2000;
|
|
|
219
247
|
* the session doesn't exist or the PID can't be determined
|
|
220
248
|
*/
|
|
221
249
|
export async function getPanePid(name: string): Promise<number | null> {
|
|
222
|
-
const { exitCode, stdout } = await runCommand(
|
|
223
|
-
"
|
|
224
|
-
|
|
225
|
-
"-p",
|
|
226
|
-
"-t",
|
|
227
|
-
name,
|
|
228
|
-
"#{pane_pid}",
|
|
229
|
-
]);
|
|
250
|
+
const { exitCode, stdout } = await runCommand(
|
|
251
|
+
tmuxCmd("display-message", "-p", "-t", name, "#{pane_pid}"),
|
|
252
|
+
);
|
|
230
253
|
|
|
231
254
|
if (exitCode !== 0) {
|
|
232
255
|
return null;
|
|
@@ -383,7 +406,7 @@ export async function killSession(name: string): Promise<void> {
|
|
|
383
406
|
}
|
|
384
407
|
|
|
385
408
|
// Step 3: Kill the tmux session itself
|
|
386
|
-
const { exitCode, stderr } = await runCommand(
|
|
409
|
+
const { exitCode, stderr } = await runCommand(tmuxCmd("kill-session", "-t", name));
|
|
387
410
|
|
|
388
411
|
if (exitCode !== 0) {
|
|
389
412
|
// If the session is already gone (e.g., died during process cleanup), that's fine
|
|
@@ -427,7 +450,7 @@ export async function getCurrentSessionName(): Promise<string | null> {
|
|
|
427
450
|
* @returns true if the session exists, false otherwise
|
|
428
451
|
*/
|
|
429
452
|
export async function isSessionAlive(name: string): Promise<boolean> {
|
|
430
|
-
const { exitCode } = await runCommand(
|
|
453
|
+
const { exitCode } = await runCommand(tmuxCmd("has-session", "-t", name));
|
|
431
454
|
return exitCode === 0;
|
|
432
455
|
}
|
|
433
456
|
|
|
@@ -456,7 +479,7 @@ export type SessionState = "alive" | "dead" | "no_server";
|
|
|
456
479
|
* @returns The session state
|
|
457
480
|
*/
|
|
458
481
|
export async function checkSessionState(name: string): Promise<SessionState> {
|
|
459
|
-
const { exitCode, stderr } = await runCommand(
|
|
482
|
+
const { exitCode, stderr } = await runCommand(tmuxCmd("has-session", "-t", name));
|
|
460
483
|
if (exitCode === 0) return "alive";
|
|
461
484
|
if (stderr.includes("no server running") || stderr.includes("no sessions")) {
|
|
462
485
|
return "no_server";
|
|
@@ -472,15 +495,9 @@ export async function checkSessionState(name: string): Promise<SessionState> {
|
|
|
472
495
|
* @returns The trimmed pane content, or null if capture fails
|
|
473
496
|
*/
|
|
474
497
|
export async function capturePaneContent(name: string, lines = 50): Promise<string | null> {
|
|
475
|
-
const { exitCode, stdout } = await runCommand(
|
|
476
|
-
"
|
|
477
|
-
|
|
478
|
-
"-t",
|
|
479
|
-
name,
|
|
480
|
-
"-p",
|
|
481
|
-
"-S",
|
|
482
|
-
`-${lines}`,
|
|
483
|
-
]);
|
|
498
|
+
const { exitCode, stdout } = await runCommand(
|
|
499
|
+
tmuxCmd("capture-pane", "-t", name, "-p", "-S", `-${lines}`),
|
|
500
|
+
);
|
|
484
501
|
if (exitCode !== 0) {
|
|
485
502
|
return null;
|
|
486
503
|
}
|
|
@@ -584,14 +601,9 @@ export async function sendKeys(name: string, keys: string, maxRetries = 3): Prom
|
|
|
584
601
|
const flatKeys = keys.replace(/\n/g, " ");
|
|
585
602
|
|
|
586
603
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
587
|
-
const { exitCode, stderr } = await runCommand(
|
|
588
|
-
"
|
|
589
|
-
|
|
590
|
-
"-t",
|
|
591
|
-
name,
|
|
592
|
-
flatKeys,
|
|
593
|
-
"Enter",
|
|
594
|
-
]);
|
|
604
|
+
const { exitCode, stderr } = await runCommand(
|
|
605
|
+
tmuxCmd("send-keys", "-t", name, flatKeys, "Enter"),
|
|
606
|
+
);
|
|
595
607
|
|
|
596
608
|
if (exitCode === 0) {
|
|
597
609
|
return;
|
|
@@ -640,7 +652,7 @@ export async function sendKeys(name: string, keys: string, maxRetries = 3): Prom
|
|
|
640
652
|
|
|
641
653
|
async function sendRawKeys(name: string, keys: string): Promise<void> {
|
|
642
654
|
const flatKeys = keys.replace(/\n/g, " ");
|
|
643
|
-
const { exitCode, stderr } = await runCommand(
|
|
655
|
+
const { exitCode, stderr } = await runCommand(tmuxCmd("send-keys", "-t", name, flatKeys));
|
|
644
656
|
|
|
645
657
|
if (exitCode !== 0) {
|
|
646
658
|
const trimmedStderr = stderr.trim();
|