@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.
@@ -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[1]).toBe("new-session");
106
- expect(cmd[3]).toBe("-s");
107
- expect(cmd[4]).toBe("my-session");
108
- expect(cmd[5]).toBe("-c");
109
- expect(cmd[6]).toBe("/work/dir");
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[7] as string;
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(["tmux", "list-panes", "-t", "test-agent", "-F", "#{pane_pid}"]);
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[7] as string;
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(["tmux", "list-sessions", "-F", "#{session_name}:#{pid}"]);
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(["tmux", "display-message", "-p", "-t", "overstory-auth", "#{pane_pid}"]);
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[1] === "display-message") {
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[1] === "kill-session") {
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[1] === "display-message") {
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[1] === "kill-session") {
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]?.[1]).toBe("display-message");
738
- expect(cmds[1]?.[1]).toBe("kill-session");
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[1] === "display-message") {
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[1] === "kill-session") {
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[1] === "display-message") {
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[1] === "kill-session") {
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[1] === "display-message") {
811
+ if (cmd[0] === "tmux" && cmd[3] === "display-message") {
784
812
  return mockSpawnResult("", "error", 1);
785
813
  }
786
- if (cmd[0] === "tmux" && cmd[1] === "kill-session") {
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(["tmux", "send-keys", "-t", "overstory-auth", "echo hello world", "Enter"]);
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(["tmux", "send-keys", "-t", "overstory-agent", "", "Enter"]);
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(["tmux", "capture-pane", "-t", "my-session", "-p", "-S", "-100"]);
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[6]).toBe("-50");
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[1] === "capture-pane") {
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[1] === "capture-pane") {
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[1] === "capture-pane") {
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[1] === "capture-pane") {
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[1] === "capture-pane") {
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[1] === "capture-pane") {
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[1] === "capture-pane") {
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[1] === "send-keys") {
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(["tmux", "send-keys", "-t", "overstory-agent", "", "Enter"]);
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[1] === "capture-pane") {
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[1] === "send-keys") {
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(["tmux", "send-keys", "-t", "overstory-agent", "2"]);
1328
- expect(sendKeysCalls[1]).toEqual(["tmux", "send-keys", "-t", "overstory-agent", "", "Enter"]);
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[1] === "capture-pane") {
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[1] === "send-keys") {
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(["tmux", "send-keys", "-t", "overstory-agent", "2"]);
1359
- expect(sendKeysCalls[1]).toEqual(["tmux", "send-keys", "-t", "overstory-agent", "", "Enter"]);
1360
- expect(sendKeysCalls[2]).toEqual(["tmux", "send-keys", "-t", "overstory-agent", "2"]);
1361
- expect(sendKeysCalls[3]).toEqual(["tmux", "send-keys", "-t", "overstory-agent", "", "Enter"]);
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[1] === "capture-pane") {
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[1] === "send-keys") {
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
+ });
@@ -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
- ["tmux", "new-session", "-d", "-s", name, "-c", cwd, wrappedCommand],
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(["tmux", "list-panes", "-t", name, "-F", "#{pane_pid}"]);
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
- "tmux",
175
- "list-sessions",
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
- "tmux",
224
- "display-message",
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(["tmux", "kill-session", "-t", name]);
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(["tmux", "has-session", "-t", name]);
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(["tmux", "has-session", "-t", name]);
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
- "tmux",
477
- "capture-pane",
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
- "tmux",
589
- "send-keys",
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(["tmux", "send-keys", "-t", name, flatKeys]);
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();