@os-eco/overstory-cli 0.9.2 → 0.9.3
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 +1 -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/coordinator.ts +2 -1
- package/src/commands/dashboard.ts +4 -1
- package/src/commands/init.ts +42 -0
- package/src/commands/inspect.ts +8 -4
- package/src/commands/monitor.ts +8 -2
- package/src/commands/sling.ts +5 -0
- 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 +166 -49
- package/src/worktree/tmux.ts +36 -37
- package/templates/copilot-hooks.json.tmpl +13 -0
|
@@ -102,13 +102,13 @@ describe("createSession", () => {
|
|
|
102
102
|
const tmuxCallArgs = spawnSpy.mock.calls[1] as unknown[];
|
|
103
103
|
const cmd = tmuxCallArgs[0] as string[];
|
|
104
104
|
expect(cmd[0]).toBe("tmux");
|
|
105
|
-
expect(cmd[
|
|
106
|
-
expect(cmd[
|
|
107
|
-
expect(cmd[
|
|
108
|
-
expect(cmd[
|
|
109
|
-
expect(cmd[
|
|
105
|
+
expect(cmd[3]).toBe("new-session");
|
|
106
|
+
expect(cmd[5]).toBe("-s");
|
|
107
|
+
expect(cmd[6]).toBe("my-session");
|
|
108
|
+
expect(cmd[7]).toBe("-c");
|
|
109
|
+
expect(cmd[8]).toBe("/work/dir");
|
|
110
110
|
// The command should be wrapped with PATH export
|
|
111
|
-
const wrappedCmd = cmd[
|
|
111
|
+
const wrappedCmd = cmd[9] as string;
|
|
112
112
|
expect(wrappedCmd).toContain("echo hello");
|
|
113
113
|
expect(wrappedCmd).toContain("export PATH=");
|
|
114
114
|
|
|
@@ -136,7 +136,16 @@ describe("createSession", () => {
|
|
|
136
136
|
expect(spawnSpy).toHaveBeenCalledTimes(3);
|
|
137
137
|
const thirdCallArgs = spawnSpy.mock.calls[2] as unknown[];
|
|
138
138
|
const cmd = thirdCallArgs[0] as string[];
|
|
139
|
-
expect(cmd).toEqual([
|
|
139
|
+
expect(cmd).toEqual([
|
|
140
|
+
"tmux",
|
|
141
|
+
"-L",
|
|
142
|
+
"overstory",
|
|
143
|
+
"list-panes",
|
|
144
|
+
"-t",
|
|
145
|
+
"test-agent",
|
|
146
|
+
"-F",
|
|
147
|
+
"#{pane_pid}",
|
|
148
|
+
]);
|
|
140
149
|
});
|
|
141
150
|
|
|
142
151
|
test("throws AgentError if session creation fails", async () => {
|
|
@@ -239,7 +248,7 @@ describe("createSession", () => {
|
|
|
239
248
|
// Call 0: which ov, Call 1: which overstory, Call 2: tmux new-session
|
|
240
249
|
const tmuxCallArgs = spawnSpy.mock.calls[2] as unknown[];
|
|
241
250
|
const cmd = tmuxCallArgs[0] as string[];
|
|
242
|
-
const tmuxCmd = cmd[
|
|
251
|
+
const tmuxCmd = cmd[9] as string;
|
|
243
252
|
expect(tmuxCmd).toContain("echo test");
|
|
244
253
|
});
|
|
245
254
|
|
|
@@ -360,7 +369,14 @@ describe("listSessions", () => {
|
|
|
360
369
|
expect(spawnSpy).toHaveBeenCalledTimes(1);
|
|
361
370
|
const callArgs = spawnSpy.mock.calls[0] as unknown[];
|
|
362
371
|
const cmd = callArgs[0] as string[];
|
|
363
|
-
expect(cmd).toEqual([
|
|
372
|
+
expect(cmd).toEqual([
|
|
373
|
+
"tmux",
|
|
374
|
+
"-L",
|
|
375
|
+
"overstory",
|
|
376
|
+
"list-sessions",
|
|
377
|
+
"-F",
|
|
378
|
+
"#{session_name}:#{pid}",
|
|
379
|
+
]);
|
|
364
380
|
});
|
|
365
381
|
});
|
|
366
382
|
|
|
@@ -383,7 +399,16 @@ describe("getPanePid", () => {
|
|
|
383
399
|
expect(pid).toBe(42);
|
|
384
400
|
const callArgs = spawnSpy.mock.calls[0] as unknown[];
|
|
385
401
|
const cmd = callArgs[0] as string[];
|
|
386
|
-
expect(cmd).toEqual([
|
|
402
|
+
expect(cmd).toEqual([
|
|
403
|
+
"tmux",
|
|
404
|
+
"-L",
|
|
405
|
+
"overstory",
|
|
406
|
+
"display-message",
|
|
407
|
+
"-p",
|
|
408
|
+
"-t",
|
|
409
|
+
"overstory-auth",
|
|
410
|
+
"#{pane_pid}",
|
|
411
|
+
]);
|
|
387
412
|
});
|
|
388
413
|
|
|
389
414
|
test("returns null when session does not exist", async () => {
|
|
@@ -679,7 +704,7 @@ describe("killSession", () => {
|
|
|
679
704
|
const cmd = args[0] as string[];
|
|
680
705
|
cmds.push(cmd);
|
|
681
706
|
|
|
682
|
-
if (cmd[0] === "tmux" && cmd[
|
|
707
|
+
if (cmd[0] === "tmux" && cmd[3] === "display-message") {
|
|
683
708
|
// getPanePid → returns PID 500
|
|
684
709
|
return mockSpawnResult("500\n", "", 0);
|
|
685
710
|
}
|
|
@@ -687,7 +712,7 @@ describe("killSession", () => {
|
|
|
687
712
|
// getDescendantPids → no children
|
|
688
713
|
return mockSpawnResult("", "", 1);
|
|
689
714
|
}
|
|
690
|
-
if (cmd[0] === "tmux" && cmd[
|
|
715
|
+
if (cmd[0] === "tmux" && cmd[3] === "kill-session") {
|
|
691
716
|
return mockSpawnResult("", "", 0);
|
|
692
717
|
}
|
|
693
718
|
return mockSpawnResult("", "", 0);
|
|
@@ -700,6 +725,8 @@ describe("killSession", () => {
|
|
|
700
725
|
// Should have called: tmux display-message, pgrep, tmux kill-session
|
|
701
726
|
expect(cmds[0]).toEqual([
|
|
702
727
|
"tmux",
|
|
728
|
+
"-L",
|
|
729
|
+
"overstory",
|
|
703
730
|
"display-message",
|
|
704
731
|
"-p",
|
|
705
732
|
"-t",
|
|
@@ -708,7 +735,7 @@ describe("killSession", () => {
|
|
|
708
735
|
]);
|
|
709
736
|
expect(cmds[1]).toEqual(["pgrep", "-P", "500"]);
|
|
710
737
|
const lastCmd = cmds[cmds.length - 1];
|
|
711
|
-
expect(lastCmd).toEqual(["tmux", "kill-session", "-t", "overstory-auth"]);
|
|
738
|
+
expect(lastCmd).toEqual(["tmux", "-L", "overstory", "kill-session", "-t", "overstory-auth"]);
|
|
712
739
|
|
|
713
740
|
// Should have sent SIGTERM to root PID 500
|
|
714
741
|
expect(killSpy).toHaveBeenCalledWith(500, "SIGTERM");
|
|
@@ -720,11 +747,11 @@ describe("killSession", () => {
|
|
|
720
747
|
const cmd = args[0] as string[];
|
|
721
748
|
cmds.push(cmd);
|
|
722
749
|
|
|
723
|
-
if (cmd[0] === "tmux" && cmd[
|
|
750
|
+
if (cmd[0] === "tmux" && cmd[3] === "display-message") {
|
|
724
751
|
// getPanePid → session not found
|
|
725
752
|
return mockSpawnResult("", "can't find session", 1);
|
|
726
753
|
}
|
|
727
|
-
if (cmd[0] === "tmux" && cmd[
|
|
754
|
+
if (cmd[0] === "tmux" && cmd[3] === "kill-session") {
|
|
728
755
|
return mockSpawnResult("", "", 0);
|
|
729
756
|
}
|
|
730
757
|
return mockSpawnResult("", "", 0);
|
|
@@ -734,8 +761,8 @@ describe("killSession", () => {
|
|
|
734
761
|
|
|
735
762
|
// Should go straight to tmux kill-session (no pgrep calls)
|
|
736
763
|
expect(cmds).toHaveLength(2);
|
|
737
|
-
expect(cmds[0]?.[
|
|
738
|
-
expect(cmds[1]?.[
|
|
764
|
+
expect(cmds[0]?.[3]).toBe("display-message");
|
|
765
|
+
expect(cmds[1]?.[3]).toBe("kill-session");
|
|
739
766
|
// No process.kill calls since we had no PID
|
|
740
767
|
expect(killSpy).not.toHaveBeenCalled();
|
|
741
768
|
});
|
|
@@ -743,13 +770,13 @@ describe("killSession", () => {
|
|
|
743
770
|
test("succeeds silently when session is already gone after process cleanup", async () => {
|
|
744
771
|
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
745
772
|
const cmd = args[0] as string[];
|
|
746
|
-
if (cmd[0] === "tmux" && cmd[
|
|
773
|
+
if (cmd[0] === "tmux" && cmd[3] === "display-message") {
|
|
747
774
|
return mockSpawnResult("500\n", "", 0);
|
|
748
775
|
}
|
|
749
776
|
if (cmd[0] === "pgrep") {
|
|
750
777
|
return mockSpawnResult("", "", 1);
|
|
751
778
|
}
|
|
752
|
-
if (cmd[0] === "tmux" && cmd[
|
|
779
|
+
if (cmd[0] === "tmux" && cmd[3] === "kill-session") {
|
|
753
780
|
// Session already gone after process cleanup
|
|
754
781
|
return mockSpawnResult("", "can't find session: overstory-auth", 1);
|
|
755
782
|
}
|
|
@@ -765,10 +792,10 @@ describe("killSession", () => {
|
|
|
765
792
|
test("throws AgentError on unexpected tmux kill-session failure", async () => {
|
|
766
793
|
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
767
794
|
const cmd = args[0] as string[];
|
|
768
|
-
if (cmd[0] === "tmux" && cmd[
|
|
795
|
+
if (cmd[0] === "tmux" && cmd[3] === "display-message") {
|
|
769
796
|
return mockSpawnResult("", "can't find session", 1);
|
|
770
797
|
}
|
|
771
|
-
if (cmd[0] === "tmux" && cmd[
|
|
798
|
+
if (cmd[0] === "tmux" && cmd[3] === "kill-session") {
|
|
772
799
|
return mockSpawnResult("", "server exited unexpectedly", 1);
|
|
773
800
|
}
|
|
774
801
|
return mockSpawnResult("", "", 0);
|
|
@@ -780,10 +807,10 @@ describe("killSession", () => {
|
|
|
780
807
|
test("AgentError contains session name on failure", async () => {
|
|
781
808
|
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
782
809
|
const cmd = args[0] as string[];
|
|
783
|
-
if (cmd[0] === "tmux" && cmd[
|
|
810
|
+
if (cmd[0] === "tmux" && cmd[3] === "display-message") {
|
|
784
811
|
return mockSpawnResult("", "error", 1);
|
|
785
812
|
}
|
|
786
|
-
if (cmd[0] === "tmux" && cmd[
|
|
813
|
+
if (cmd[0] === "tmux" && cmd[3] === "kill-session") {
|
|
787
814
|
return mockSpawnResult("", "server exited unexpectedly", 1);
|
|
788
815
|
}
|
|
789
816
|
return mockSpawnResult("", "", 0);
|
|
@@ -836,7 +863,7 @@ describe("isSessionAlive", () => {
|
|
|
836
863
|
expect(spawnSpy).toHaveBeenCalledTimes(1);
|
|
837
864
|
const callArgs = spawnSpy.mock.calls[0] as unknown[];
|
|
838
865
|
const cmd = callArgs[0] as string[];
|
|
839
|
-
expect(cmd).toEqual(["tmux", "has-session", "-t", "my-agent"]);
|
|
866
|
+
expect(cmd).toEqual(["tmux", "-L", "overstory", "has-session", "-t", "my-agent"]);
|
|
840
867
|
});
|
|
841
868
|
});
|
|
842
869
|
|
|
@@ -907,7 +934,16 @@ describe("sendKeys", () => {
|
|
|
907
934
|
expect(spawnSpy).toHaveBeenCalledTimes(1);
|
|
908
935
|
const callArgs = spawnSpy.mock.calls[0] as unknown[];
|
|
909
936
|
const cmd = callArgs[0] as string[];
|
|
910
|
-
expect(cmd).toEqual([
|
|
937
|
+
expect(cmd).toEqual([
|
|
938
|
+
"tmux",
|
|
939
|
+
"-L",
|
|
940
|
+
"overstory",
|
|
941
|
+
"send-keys",
|
|
942
|
+
"-t",
|
|
943
|
+
"overstory-auth",
|
|
944
|
+
"echo hello world",
|
|
945
|
+
"Enter",
|
|
946
|
+
]);
|
|
911
947
|
});
|
|
912
948
|
|
|
913
949
|
test("flattens newlines in keys to spaces", async () => {
|
|
@@ -920,6 +956,8 @@ describe("sendKeys", () => {
|
|
|
920
956
|
const cmd = callArgs[0] as string[];
|
|
921
957
|
expect(cmd).toEqual([
|
|
922
958
|
"tmux",
|
|
959
|
+
"-L",
|
|
960
|
+
"overstory",
|
|
923
961
|
"send-keys",
|
|
924
962
|
"-t",
|
|
925
963
|
"overstory-agent",
|
|
@@ -956,7 +994,16 @@ describe("sendKeys", () => {
|
|
|
956
994
|
expect(spawnSpy).toHaveBeenCalledTimes(1);
|
|
957
995
|
const callArgs = spawnSpy.mock.calls[0] as unknown[];
|
|
958
996
|
const cmd = callArgs[0] as string[];
|
|
959
|
-
expect(cmd).toEqual([
|
|
997
|
+
expect(cmd).toEqual([
|
|
998
|
+
"tmux",
|
|
999
|
+
"-L",
|
|
1000
|
+
"overstory",
|
|
1001
|
+
"send-keys",
|
|
1002
|
+
"-t",
|
|
1003
|
+
"overstory-agent",
|
|
1004
|
+
"",
|
|
1005
|
+
"Enter",
|
|
1006
|
+
]);
|
|
960
1007
|
});
|
|
961
1008
|
|
|
962
1009
|
test("throws descriptive error when tmux server is not running", async () => {
|
|
@@ -1039,7 +1086,17 @@ describe("capturePaneContent", () => {
|
|
|
1039
1086
|
|
|
1040
1087
|
const callArgs = spawnSpy.mock.calls[0] as unknown[];
|
|
1041
1088
|
const cmd = callArgs[0] as string[];
|
|
1042
|
-
expect(cmd).toEqual([
|
|
1089
|
+
expect(cmd).toEqual([
|
|
1090
|
+
"tmux",
|
|
1091
|
+
"-L",
|
|
1092
|
+
"overstory",
|
|
1093
|
+
"capture-pane",
|
|
1094
|
+
"-t",
|
|
1095
|
+
"my-session",
|
|
1096
|
+
"-p",
|
|
1097
|
+
"-S",
|
|
1098
|
+
"-100",
|
|
1099
|
+
]);
|
|
1043
1100
|
});
|
|
1044
1101
|
|
|
1045
1102
|
test("uses default 50 lines when not specified", async () => {
|
|
@@ -1049,7 +1106,7 @@ describe("capturePaneContent", () => {
|
|
|
1049
1106
|
|
|
1050
1107
|
const callArgs = spawnSpy.mock.calls[0] as unknown[];
|
|
1051
1108
|
const cmd = callArgs[0] as string[];
|
|
1052
|
-
expect(cmd[
|
|
1109
|
+
expect(cmd[8]).toBe("-50");
|
|
1053
1110
|
});
|
|
1054
1111
|
|
|
1055
1112
|
test("returns null when capture-pane fails", async () => {
|
|
@@ -1122,7 +1179,7 @@ describe("waitForTuiReady", () => {
|
|
|
1122
1179
|
let captureCallCount = 0;
|
|
1123
1180
|
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1124
1181
|
const cmd = args[0] as string[];
|
|
1125
|
-
if (cmd[
|
|
1182
|
+
if (cmd[3] === "capture-pane") {
|
|
1126
1183
|
captureCallCount++;
|
|
1127
1184
|
if (captureCallCount <= 3) {
|
|
1128
1185
|
// First 3 capture-pane polls: empty pane (TUI still loading)
|
|
@@ -1174,7 +1231,7 @@ describe("waitForTuiReady", () => {
|
|
|
1174
1231
|
// capture-pane fails (session dead), has-session also fails (session dead)
|
|
1175
1232
|
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1176
1233
|
const cmd = args[0] as string[];
|
|
1177
|
-
if (cmd[
|
|
1234
|
+
if (cmd[3] === "capture-pane") {
|
|
1178
1235
|
return mockSpawnResult("", "can't find session", 1);
|
|
1179
1236
|
}
|
|
1180
1237
|
// has-session: session is dead
|
|
@@ -1192,7 +1249,7 @@ describe("waitForTuiReady", () => {
|
|
|
1192
1249
|
let captureCallCount = 0;
|
|
1193
1250
|
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1194
1251
|
const cmd = args[0] as string[];
|
|
1195
|
-
if (cmd[
|
|
1252
|
+
if (cmd[3] === "capture-pane") {
|
|
1196
1253
|
captureCallCount++;
|
|
1197
1254
|
// Pane stays empty for all polls (session alive but TUI not rendered yet)
|
|
1198
1255
|
return mockSpawnResult("", "", 0);
|
|
@@ -1214,7 +1271,7 @@ describe("waitForTuiReady", () => {
|
|
|
1214
1271
|
// Pane always shows prompt indicator but never shows status bar text
|
|
1215
1272
|
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1216
1273
|
const cmd = args[0] as string[];
|
|
1217
|
-
if (cmd[
|
|
1274
|
+
if (cmd[3] === "capture-pane") {
|
|
1218
1275
|
return mockSpawnResult("Welcome to Claude Code!\n\u276f", "", 0);
|
|
1219
1276
|
}
|
|
1220
1277
|
// has-session: session is alive
|
|
@@ -1230,7 +1287,7 @@ describe("waitForTuiReady", () => {
|
|
|
1230
1287
|
// Pane always shows status bar but never shows prompt indicator
|
|
1231
1288
|
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1232
1289
|
const cmd = args[0] as string[];
|
|
1233
|
-
if (cmd[
|
|
1290
|
+
if (cmd[3] === "capture-pane") {
|
|
1234
1291
|
return mockSpawnResult("bypass permissions", "", 0);
|
|
1235
1292
|
}
|
|
1236
1293
|
// has-session: session is alive
|
|
@@ -1246,7 +1303,7 @@ describe("waitForTuiReady", () => {
|
|
|
1246
1303
|
let captureCallCount = 0;
|
|
1247
1304
|
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1248
1305
|
const cmd = args[0] as string[];
|
|
1249
|
-
if (cmd[
|
|
1306
|
+
if (cmd[3] === "capture-pane") {
|
|
1250
1307
|
captureCallCount++;
|
|
1251
1308
|
if (captureCallCount <= 2) {
|
|
1252
1309
|
// First 2 polls: only prompt indicator visible (phase 1 only)
|
|
@@ -1271,7 +1328,7 @@ describe("waitForTuiReady", () => {
|
|
|
1271
1328
|
let captureCallCount = 0;
|
|
1272
1329
|
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1273
1330
|
const cmd = args[0] as string[];
|
|
1274
|
-
if (cmd[
|
|
1331
|
+
if (cmd[3] === "capture-pane") {
|
|
1275
1332
|
captureCallCount++;
|
|
1276
1333
|
if (captureCallCount === 1) {
|
|
1277
1334
|
// First poll: trust dialog is showing
|
|
@@ -1280,7 +1337,7 @@ describe("waitForTuiReady", () => {
|
|
|
1280
1337
|
// Subsequent polls: trust confirmed, real TUI with both indicators
|
|
1281
1338
|
return mockSpawnResult('Try "help"\nshift+tab', "", 0);
|
|
1282
1339
|
}
|
|
1283
|
-
if (cmd[
|
|
1340
|
+
if (cmd[3] === "send-keys") {
|
|
1284
1341
|
sendKeysCalls.push(cmd);
|
|
1285
1342
|
return mockSpawnResult("", "", 0);
|
|
1286
1343
|
}
|
|
@@ -1294,7 +1351,16 @@ describe("waitForTuiReady", () => {
|
|
|
1294
1351
|
// sendKeys should have been called once to confirm the trust dialog
|
|
1295
1352
|
expect(sendKeysCalls).toHaveLength(1);
|
|
1296
1353
|
const trustCall = sendKeysCalls[0];
|
|
1297
|
-
expect(trustCall).toEqual([
|
|
1354
|
+
expect(trustCall).toEqual([
|
|
1355
|
+
"tmux",
|
|
1356
|
+
"-L",
|
|
1357
|
+
"overstory",
|
|
1358
|
+
"send-keys",
|
|
1359
|
+
"-t",
|
|
1360
|
+
"overstory-agent",
|
|
1361
|
+
"",
|
|
1362
|
+
"Enter",
|
|
1363
|
+
]);
|
|
1298
1364
|
});
|
|
1299
1365
|
|
|
1300
1366
|
test("detects bypass permissions dialog and types 2 before Enter", async () => {
|
|
@@ -1302,7 +1368,7 @@ describe("waitForTuiReady", () => {
|
|
|
1302
1368
|
let captureCallCount = 0;
|
|
1303
1369
|
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1304
1370
|
const cmd = args[0] as string[];
|
|
1305
|
-
if (cmd[
|
|
1371
|
+
if (cmd[3] === "capture-pane") {
|
|
1306
1372
|
captureCallCount++;
|
|
1307
1373
|
if (captureCallCount === 1) {
|
|
1308
1374
|
return mockSpawnResult(
|
|
@@ -1313,7 +1379,7 @@ describe("waitForTuiReady", () => {
|
|
|
1313
1379
|
}
|
|
1314
1380
|
return mockSpawnResult('Try "help"\nshift+tab', "", 0);
|
|
1315
1381
|
}
|
|
1316
|
-
if (cmd[
|
|
1382
|
+
if (cmd[3] === "send-keys") {
|
|
1317
1383
|
sendKeysCalls.push(cmd);
|
|
1318
1384
|
return mockSpawnResult("", "", 0);
|
|
1319
1385
|
}
|
|
@@ -1324,8 +1390,25 @@ describe("waitForTuiReady", () => {
|
|
|
1324
1390
|
|
|
1325
1391
|
expect(ready).toBe(true);
|
|
1326
1392
|
expect(sendKeysCalls).toHaveLength(2);
|
|
1327
|
-
expect(sendKeysCalls[0]).toEqual([
|
|
1328
|
-
|
|
1393
|
+
expect(sendKeysCalls[0]).toEqual([
|
|
1394
|
+
"tmux",
|
|
1395
|
+
"-L",
|
|
1396
|
+
"overstory",
|
|
1397
|
+
"send-keys",
|
|
1398
|
+
"-t",
|
|
1399
|
+
"overstory-agent",
|
|
1400
|
+
"2",
|
|
1401
|
+
]);
|
|
1402
|
+
expect(sendKeysCalls[1]).toEqual([
|
|
1403
|
+
"tmux",
|
|
1404
|
+
"-L",
|
|
1405
|
+
"overstory",
|
|
1406
|
+
"send-keys",
|
|
1407
|
+
"-t",
|
|
1408
|
+
"overstory-agent",
|
|
1409
|
+
"",
|
|
1410
|
+
"Enter",
|
|
1411
|
+
]);
|
|
1329
1412
|
});
|
|
1330
1413
|
|
|
1331
1414
|
test("retries typed bypass dialog action when the same dialog persists", async () => {
|
|
@@ -1333,7 +1416,7 @@ describe("waitForTuiReady", () => {
|
|
|
1333
1416
|
let captureCallCount = 0;
|
|
1334
1417
|
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1335
1418
|
const cmd = args[0] as string[];
|
|
1336
|
-
if (cmd[
|
|
1419
|
+
if (cmd[3] === "capture-pane") {
|
|
1337
1420
|
captureCallCount++;
|
|
1338
1421
|
if (captureCallCount <= 3) {
|
|
1339
1422
|
return mockSpawnResult(
|
|
@@ -1344,7 +1427,7 @@ describe("waitForTuiReady", () => {
|
|
|
1344
1427
|
}
|
|
1345
1428
|
return mockSpawnResult('Try "help"\nshift+tab', "", 0);
|
|
1346
1429
|
}
|
|
1347
|
-
if (cmd[
|
|
1430
|
+
if (cmd[3] === "send-keys") {
|
|
1348
1431
|
sendKeysCalls.push(cmd);
|
|
1349
1432
|
return mockSpawnResult("", "", 0);
|
|
1350
1433
|
}
|
|
@@ -1355,10 +1438,44 @@ describe("waitForTuiReady", () => {
|
|
|
1355
1438
|
|
|
1356
1439
|
expect(ready).toBe(true);
|
|
1357
1440
|
expect(sendKeysCalls).toHaveLength(4);
|
|
1358
|
-
expect(sendKeysCalls[0]).toEqual([
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1441
|
+
expect(sendKeysCalls[0]).toEqual([
|
|
1442
|
+
"tmux",
|
|
1443
|
+
"-L",
|
|
1444
|
+
"overstory",
|
|
1445
|
+
"send-keys",
|
|
1446
|
+
"-t",
|
|
1447
|
+
"overstory-agent",
|
|
1448
|
+
"2",
|
|
1449
|
+
]);
|
|
1450
|
+
expect(sendKeysCalls[1]).toEqual([
|
|
1451
|
+
"tmux",
|
|
1452
|
+
"-L",
|
|
1453
|
+
"overstory",
|
|
1454
|
+
"send-keys",
|
|
1455
|
+
"-t",
|
|
1456
|
+
"overstory-agent",
|
|
1457
|
+
"",
|
|
1458
|
+
"Enter",
|
|
1459
|
+
]);
|
|
1460
|
+
expect(sendKeysCalls[2]).toEqual([
|
|
1461
|
+
"tmux",
|
|
1462
|
+
"-L",
|
|
1463
|
+
"overstory",
|
|
1464
|
+
"send-keys",
|
|
1465
|
+
"-t",
|
|
1466
|
+
"overstory-agent",
|
|
1467
|
+
"2",
|
|
1468
|
+
]);
|
|
1469
|
+
expect(sendKeysCalls[3]).toEqual([
|
|
1470
|
+
"tmux",
|
|
1471
|
+
"-L",
|
|
1472
|
+
"overstory",
|
|
1473
|
+
"send-keys",
|
|
1474
|
+
"-t",
|
|
1475
|
+
"overstory-agent",
|
|
1476
|
+
"",
|
|
1477
|
+
"Enter",
|
|
1478
|
+
]);
|
|
1362
1479
|
});
|
|
1363
1480
|
|
|
1364
1481
|
test("handles trust dialog only once (trustHandled flag)", async () => {
|
|
@@ -1366,7 +1483,7 @@ describe("waitForTuiReady", () => {
|
|
|
1366
1483
|
let captureCallCount = 0;
|
|
1367
1484
|
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1368
1485
|
const cmd = args[0] as string[];
|
|
1369
|
-
if (cmd[
|
|
1486
|
+
if (cmd[3] === "capture-pane") {
|
|
1370
1487
|
captureCallCount++;
|
|
1371
1488
|
if (captureCallCount <= 3) {
|
|
1372
1489
|
// Multiple polls still show trust dialog (slow dialog dismissal)
|
|
@@ -1375,7 +1492,7 @@ describe("waitForTuiReady", () => {
|
|
|
1375
1492
|
// Eventually TUI loads with both indicators
|
|
1376
1493
|
return mockSpawnResult('Try "help"\nbypass permissions', "", 0);
|
|
1377
1494
|
}
|
|
1378
|
-
if (cmd[
|
|
1495
|
+
if (cmd[3] === "send-keys") {
|
|
1379
1496
|
sendKeysCalls.push(cmd);
|
|
1380
1497
|
return mockSpawnResult("", "", 0);
|
|
1381
1498
|
}
|
package/src/worktree/tmux.ts
CHANGED
|
@@ -11,6 +11,24 @@ 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
|
+
* Build a tmux command array with the dedicated server socket.
|
|
26
|
+
* All agent session operations should use this to ensure isolation.
|
|
27
|
+
*/
|
|
28
|
+
function tmuxCmd(...args: string[]): string[] {
|
|
29
|
+
return ["tmux", "-L", TMUX_SOCKET, ...args];
|
|
30
|
+
}
|
|
31
|
+
|
|
14
32
|
/**
|
|
15
33
|
* Detect the directory containing the overstory binary.
|
|
16
34
|
*
|
|
@@ -123,7 +141,7 @@ export async function createSession(
|
|
|
123
141
|
exports.length > 0 ? `/bin/bash -c '${startupScript.replace(/'/g, "'\\''")}'` : command;
|
|
124
142
|
|
|
125
143
|
const { exitCode, stderr } = await runCommand(
|
|
126
|
-
|
|
144
|
+
tmuxCmd("new-session", "-d", "-s", name, "-c", cwd, wrappedCommand),
|
|
127
145
|
cwd,
|
|
128
146
|
);
|
|
129
147
|
|
|
@@ -138,7 +156,7 @@ export async function createSession(
|
|
|
138
156
|
// the session exists but the pane hasn't been registered yet (#73).
|
|
139
157
|
let pidResult: { stdout: string; stderr: string; exitCode: number } | undefined;
|
|
140
158
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
141
|
-
pidResult = await runCommand(
|
|
159
|
+
pidResult = await runCommand(tmuxCmd("list-panes", "-t", name, "-F", "#{pane_pid}"));
|
|
142
160
|
if (pidResult.exitCode === 0) break;
|
|
143
161
|
await Bun.sleep(250 * (attempt + 1));
|
|
144
162
|
}
|
|
@@ -170,12 +188,9 @@ export async function createSession(
|
|
|
170
188
|
* @throws AgentError if tmux is not installed
|
|
171
189
|
*/
|
|
172
190
|
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
|
-
]);
|
|
191
|
+
const { exitCode, stdout, stderr } = await runCommand(
|
|
192
|
+
tmuxCmd("list-sessions", "-F", "#{session_name}:#{pid}"),
|
|
193
|
+
);
|
|
179
194
|
|
|
180
195
|
// Exit code 1 with "no server running" means no sessions exist — not an error
|
|
181
196
|
if (exitCode !== 0) {
|
|
@@ -219,14 +234,9 @@ const KILL_GRACE_PERIOD_MS = 2000;
|
|
|
219
234
|
* the session doesn't exist or the PID can't be determined
|
|
220
235
|
*/
|
|
221
236
|
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
|
-
]);
|
|
237
|
+
const { exitCode, stdout } = await runCommand(
|
|
238
|
+
tmuxCmd("display-message", "-p", "-t", name, "#{pane_pid}"),
|
|
239
|
+
);
|
|
230
240
|
|
|
231
241
|
if (exitCode !== 0) {
|
|
232
242
|
return null;
|
|
@@ -383,7 +393,7 @@ export async function killSession(name: string): Promise<void> {
|
|
|
383
393
|
}
|
|
384
394
|
|
|
385
395
|
// Step 3: Kill the tmux session itself
|
|
386
|
-
const { exitCode, stderr } = await runCommand(
|
|
396
|
+
const { exitCode, stderr } = await runCommand(tmuxCmd("kill-session", "-t", name));
|
|
387
397
|
|
|
388
398
|
if (exitCode !== 0) {
|
|
389
399
|
// If the session is already gone (e.g., died during process cleanup), that's fine
|
|
@@ -427,7 +437,7 @@ export async function getCurrentSessionName(): Promise<string | null> {
|
|
|
427
437
|
* @returns true if the session exists, false otherwise
|
|
428
438
|
*/
|
|
429
439
|
export async function isSessionAlive(name: string): Promise<boolean> {
|
|
430
|
-
const { exitCode } = await runCommand(
|
|
440
|
+
const { exitCode } = await runCommand(tmuxCmd("has-session", "-t", name));
|
|
431
441
|
return exitCode === 0;
|
|
432
442
|
}
|
|
433
443
|
|
|
@@ -456,7 +466,7 @@ export type SessionState = "alive" | "dead" | "no_server";
|
|
|
456
466
|
* @returns The session state
|
|
457
467
|
*/
|
|
458
468
|
export async function checkSessionState(name: string): Promise<SessionState> {
|
|
459
|
-
const { exitCode, stderr } = await runCommand(
|
|
469
|
+
const { exitCode, stderr } = await runCommand(tmuxCmd("has-session", "-t", name));
|
|
460
470
|
if (exitCode === 0) return "alive";
|
|
461
471
|
if (stderr.includes("no server running") || stderr.includes("no sessions")) {
|
|
462
472
|
return "no_server";
|
|
@@ -472,15 +482,9 @@ export async function checkSessionState(name: string): Promise<SessionState> {
|
|
|
472
482
|
* @returns The trimmed pane content, or null if capture fails
|
|
473
483
|
*/
|
|
474
484
|
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
|
-
]);
|
|
485
|
+
const { exitCode, stdout } = await runCommand(
|
|
486
|
+
tmuxCmd("capture-pane", "-t", name, "-p", "-S", `-${lines}`),
|
|
487
|
+
);
|
|
484
488
|
if (exitCode !== 0) {
|
|
485
489
|
return null;
|
|
486
490
|
}
|
|
@@ -584,14 +588,9 @@ export async function sendKeys(name: string, keys: string, maxRetries = 3): Prom
|
|
|
584
588
|
const flatKeys = keys.replace(/\n/g, " ");
|
|
585
589
|
|
|
586
590
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
587
|
-
const { exitCode, stderr } = await runCommand(
|
|
588
|
-
"
|
|
589
|
-
|
|
590
|
-
"-t",
|
|
591
|
-
name,
|
|
592
|
-
flatKeys,
|
|
593
|
-
"Enter",
|
|
594
|
-
]);
|
|
591
|
+
const { exitCode, stderr } = await runCommand(
|
|
592
|
+
tmuxCmd("send-keys", "-t", name, flatKeys, "Enter"),
|
|
593
|
+
);
|
|
595
594
|
|
|
596
595
|
if (exitCode === 0) {
|
|
597
596
|
return;
|
|
@@ -640,7 +639,7 @@ export async function sendKeys(name: string, keys: string, maxRetries = 3): Prom
|
|
|
640
639
|
|
|
641
640
|
async function sendRawKeys(name: string, keys: string): Promise<void> {
|
|
642
641
|
const flatKeys = keys.replace(/\n/g, " ");
|
|
643
|
-
const { exitCode, stderr } = await runCommand(
|
|
642
|
+
const { exitCode, stderr } = await runCommand(tmuxCmd("send-keys", "-t", name, flatKeys));
|
|
644
643
|
|
|
645
644
|
if (exitCode !== 0) {
|
|
646
645
|
const trimmedStderr = stderr.trim();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Copilot CLI lifecycle hooks — schema reverse-engineered from copilot CLI behavior. Key differences from Claude Code: event names are camelCase (onSessionStart, onToolUse, onError), no matcher field, hook entries are { command } only (no type field).",
|
|
3
|
+
"hooks": {
|
|
4
|
+
"onSessionStart": [
|
|
5
|
+
{
|
|
6
|
+
"command": "[ -z \"$OVERSTORY_AGENT_NAME\" ] && exit 0; ov prime --agent {{AGENT_NAME}}"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"command": "[ -z \"$OVERSTORY_AGENT_NAME\" ] && exit 0; ov mail check --inject --agent {{AGENT_NAME}}"
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
}
|