tmuxes 0.1.8 → 0.1.9

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.
Files changed (140) hide show
  1. package/.node-version +1 -0
  2. package/.nvmrc +1 -0
  3. package/.tmp-npm-cache/_cacache/content-v2/sha512/43/27/5e000b8b9c56a6ccc66f709485499f4304e2cb1982582ba571321c07b3ef56fcabd2c671898cc8003365a0485b6fd8e73e7b17b073cec0f7d1628c1a99df +0 -0
  4. package/.tmp-npm-cache/_cacache/content-v2/sha512/51/cf/4301295d74559ed494bae160d54d8741077f89faebb311882ac065019246951e7b53f3dcb913793c42b331e14c7070c4810c3cdc27a427d103a7db4614e0 +0 -0
  5. package/.tmp-npm-cache/_cacache/content-v2/sha512/c3/4d/d68a454a916e74c2617f586fbf770981b33811d667c2547eb0e9fc21938f4ee7e98f1ceee4bde8ad8815b5f6efe21b60eee798837d68f51a3340d7e5bb7a +0 -0
  6. package/.tmp-npm-cache/_cacache/content-v2/sha512/fe/40/2abfbefc96299e8bf714aa91d62607190ae299e102cf5933db2e2904640d65d25d67dbbb6fa2ddc92a17f00b9dbfdf2e37487f67d96ec36c64a285b59a7d +0 -0
  7. package/.tmp-npm-cache/_cacache/index-v5/27/fe/81a3de6ce7ae3d1e41a3421de20c5629998c4ee5d0ffe2037630f03b03b2 +4 -0
  8. package/.tmp-npm-cache/_cacache/index-v5/65/22/dd66711f62681fce09aabb2357a2907b4a0c778ac5227c4baf9603fd86e8 +4 -0
  9. package/.tmp-npm-cache/_update-notifier-last-checked +0 -0
  10. package/AGENTS.md +15 -0
  11. package/CLAUDE.md +3 -0
  12. package/LICENSE +21 -21
  13. package/README.en.md +304 -0
  14. package/README.md +299 -295
  15. package/SECURITY.md +31 -0
  16. package/{public → client}/index.html +12 -13
  17. package/client/package.json +29 -0
  18. package/client/src/App.tsx +123 -0
  19. package/client/src/activity.ts +5 -0
  20. package/client/src/api.ts +130 -0
  21. package/client/src/attention.tsx +157 -0
  22. package/client/src/components/FileExplorer.tsx +156 -0
  23. package/client/src/components/FileViewer.tsx +194 -0
  24. package/client/src/components/SessionRow.tsx +108 -0
  25. package/client/src/components/SessionTree.tsx +197 -0
  26. package/client/src/components/SettingsButton.tsx +122 -0
  27. package/client/src/components/Sidebar.tsx +96 -0
  28. package/client/src/components/StatusBanner.tsx +31 -0
  29. package/client/src/components/TargetGroup.tsx +275 -0
  30. package/client/src/components/TerminalPanel.tsx +192 -0
  31. package/client/src/folders.ts +245 -0
  32. package/client/src/hooks/useTerminal.ts +67 -0
  33. package/client/src/hooks/useTmuxSocket.ts +65 -0
  34. package/client/src/i18n.ts +213 -0
  35. package/client/src/main.tsx +17 -0
  36. package/client/src/settings.tsx +87 -0
  37. package/client/src/styles.css +723 -0
  38. package/client/src/types.ts +93 -0
  39. package/client/src/util.ts +65 -0
  40. package/client/tsconfig.json +13 -0
  41. package/client/vite.config.ts +15 -0
  42. package/fig/fig1.png +0 -0
  43. package/package.json +28 -61
  44. package/scripts/prepack.mjs +35 -0
  45. package/{bin → server/bin}/tmuxes.js +36 -36
  46. package/server/package.json +61 -0
  47. package/server/src/agentHooks.ts +120 -0
  48. package/server/src/agentOutput.ts +36 -0
  49. package/server/src/agentState.ts +70 -0
  50. package/server/src/config.ts +31 -0
  51. package/server/src/exe.ts +34 -0
  52. package/server/src/exec.ts +61 -0
  53. package/server/src/files.ts +330 -0
  54. package/server/src/foldersStore.ts +114 -0
  55. package/server/src/index.ts +114 -0
  56. package/server/src/logger.ts +16 -0
  57. package/{dist/monitor.js → server/src/monitor.ts} +10 -9
  58. package/server/src/openBrowser.ts +28 -0
  59. package/{dist/platform.js → server/src/platform.ts} +4 -5
  60. package/server/src/rest/router.ts +290 -0
  61. package/server/src/targetCommand.ts +79 -0
  62. package/server/src/targets.ts +152 -0
  63. package/server/src/tmux/builder.ts +198 -0
  64. package/server/src/tmux/formats.ts +95 -0
  65. package/server/src/tmux/sessions.ts +204 -0
  66. package/server/src/validate.ts +79 -0
  67. package/server/src/windowsSsh.ts +239 -0
  68. package/server/src/winshell/manager.ts +296 -0
  69. package/server/src/ws/protocol.ts +15 -0
  70. package/server/src/ws/sshState.ts +36 -0
  71. package/server/src/ws/terminalSession.ts +207 -0
  72. package/server/src/ws/wsServer.ts +153 -0
  73. package/server/src/wsl.ts +38 -0
  74. package/server/test/agentHooks.test.ts +66 -0
  75. package/server/test/agentOutput.test.ts +26 -0
  76. package/server/test/agentState.test.ts +24 -0
  77. package/server/test/builder.test.ts +162 -0
  78. package/server/test/files.test.ts +81 -0
  79. package/server/test/formats.test.ts +123 -0
  80. package/server/test/monitor.test.ts +25 -0
  81. package/server/test/validate.test.ts +71 -0
  82. package/server/test/wsl.test.ts +18 -0
  83. package/server/tsconfig.json +9 -0
  84. package/server/vitest.config.ts +12 -0
  85. package/start.cmd +30 -0
  86. package/start.command +20 -0
  87. package/start.sh +20 -0
  88. package/tsconfig.base.json +19 -0
  89. package/dist/agentHooks.js +0 -91
  90. package/dist/agentHooks.js.map +0 -1
  91. package/dist/agentOutput.js +0 -30
  92. package/dist/agentOutput.js.map +0 -1
  93. package/dist/agentState.js +0 -45
  94. package/dist/agentState.js.map +0 -1
  95. package/dist/config.js +0 -32
  96. package/dist/config.js.map +0 -1
  97. package/dist/exe.js +0 -37
  98. package/dist/exe.js.map +0 -1
  99. package/dist/exec.js +0 -43
  100. package/dist/exec.js.map +0 -1
  101. package/dist/files.js +0 -243
  102. package/dist/files.js.map +0 -1
  103. package/dist/foldersStore.js +0 -103
  104. package/dist/foldersStore.js.map +0 -1
  105. package/dist/index.js +0 -117
  106. package/dist/index.js.map +0 -1
  107. package/dist/logger.js +0 -16
  108. package/dist/logger.js.map +0 -1
  109. package/dist/monitor.js.map +0 -1
  110. package/dist/openBrowser.js +0 -31
  111. package/dist/openBrowser.js.map +0 -1
  112. package/dist/platform.js.map +0 -1
  113. package/dist/rest/router.js +0 -190
  114. package/dist/rest/router.js.map +0 -1
  115. package/dist/targetCommand.js +0 -41
  116. package/dist/targetCommand.js.map +0 -1
  117. package/dist/targets.js +0 -131
  118. package/dist/targets.js.map +0 -1
  119. package/dist/tmux/builder.js +0 -173
  120. package/dist/tmux/builder.js.map +0 -1
  121. package/dist/tmux/formats.js +0 -61
  122. package/dist/tmux/formats.js.map +0 -1
  123. package/dist/tmux/sessions.js +0 -157
  124. package/dist/tmux/sessions.js.map +0 -1
  125. package/dist/validate.js +0 -65
  126. package/dist/validate.js.map +0 -1
  127. package/dist/winshell/manager.js +0 -267
  128. package/dist/winshell/manager.js.map +0 -1
  129. package/dist/ws/protocol.js +0 -4
  130. package/dist/ws/protocol.js.map +0 -1
  131. package/dist/ws/sshState.js +0 -35
  132. package/dist/ws/sshState.js.map +0 -1
  133. package/dist/ws/terminalSession.js +0 -204
  134. package/dist/ws/terminalSession.js.map +0 -1
  135. package/dist/ws/wsServer.js +0 -151
  136. package/dist/ws/wsServer.js.map +0 -1
  137. package/dist/wsl.js +0 -35
  138. package/dist/wsl.js.map +0 -1
  139. package/public/assets/index-BpVrfoZw.js +0 -44
  140. package/public/assets/index-D_X5SnGx.css +0 -1
@@ -0,0 +1,207 @@
1
+ import * as pty from 'node-pty';
2
+ import { homedir } from 'node:os';
3
+ import type { WebSocket } from 'ws';
4
+ import type { Target } from '../targets.js';
5
+ import { attachArgv } from '../tmux/builder.js';
6
+ import { resolveExecutable } from '../exe.js';
7
+ import { classifySsh } from './sshState.js';
8
+ import type { ClientControl, ServerControl } from './protocol.js';
9
+ import { log } from '../logger.js';
10
+
11
+ const HEARTBEAT_MS = 30_000;
12
+ const HIGH_WATER = 1 << 20; // 1 MiB buffered → pause the PTY
13
+ const LOW_WATER = 1 << 18; // 256 KiB → resume
14
+ const KILL_GRACE_MS = 2_000;
15
+
16
+ /** Owns exactly one PTY for one WebSocket. dispose() is idempotent. */
17
+ export class TerminalSession {
18
+ private readonly ptyProc: pty.IPty;
19
+ private disposed = false;
20
+ private alive = true;
21
+ private paused = false;
22
+ private heartbeat?: NodeJS.Timeout;
23
+ private drainTimer?: NodeJS.Timeout;
24
+ private killTimer?: NodeJS.Timeout;
25
+ /** Scan ssh output for failure/prompt states until the link looks healthy. */
26
+ private sshScanBudget: number;
27
+
28
+ constructor(
29
+ private readonly ws: WebSocket,
30
+ private readonly target: Target,
31
+ private readonly session: string,
32
+ cols: number,
33
+ rows: number,
34
+ ) {
35
+ this.sshScanBudget = target.kind === 'ssh' ? 8192 : 0;
36
+
37
+ const { file, args } = attachArgv(target, session);
38
+ // node-pty on Windows needs a full exe path (no PATH/.exe resolution).
39
+ this.ptyProc = pty.spawn(resolveExecutable(file), args, {
40
+ name: 'xterm-256color',
41
+ cols,
42
+ rows,
43
+ cwd: homedir(),
44
+ env: { ...process.env, TERM: 'xterm-256color' },
45
+ });
46
+
47
+ this.ptyProc.onData((data) => this.onPtyData(data));
48
+ this.ptyProc.onExit(({ exitCode }) => this.onPtyExit(exitCode));
49
+
50
+ this.ws.on('message', (data, isBinary) => this.onClientMessage(data, isBinary));
51
+ this.ws.on('close', () => this.dispose());
52
+ this.ws.on('error', () => this.dispose());
53
+ this.ws.on('pong', () => {
54
+ this.alive = true;
55
+ });
56
+
57
+ this.heartbeat = setInterval(() => this.tick(), HEARTBEAT_MS);
58
+
59
+ this.sendControl({ type: 'ready', target: target.id, session });
60
+ }
61
+
62
+ private onPtyData(data: string): void {
63
+ if (this.sshScanBudget > 0) {
64
+ this.sshScanBudget -= data.length;
65
+ const ssh = classifySsh(data);
66
+ if (ssh) this.sendControl({ type: 'ssh', state: ssh.state, message: ssh.message });
67
+ }
68
+ this.sendBinary(Buffer.from(data, 'utf8'));
69
+ }
70
+
71
+ private onPtyExit(exitCode: number | null): void {
72
+ this.sendControl({ type: 'exit', code: exitCode });
73
+ this.closeWs(1000, 'pty exited');
74
+ this.dispose();
75
+ }
76
+
77
+ private onClientMessage(data: unknown, isBinary: boolean): void {
78
+ if (this.disposed) return;
79
+ if (isBinary) {
80
+ // Raw keystrokes → straight into the PTY.
81
+ this.ptyProc.write(toBufferString(data));
82
+ return;
83
+ }
84
+ let msg: ClientControl;
85
+ try {
86
+ msg = JSON.parse(toBufferString(data)) as ClientControl;
87
+ } catch {
88
+ return; // ignore malformed control frames
89
+ }
90
+ if (msg.type === 'resize') {
91
+ const cols = clampDim(msg.cols);
92
+ const rows = clampDim(msg.rows);
93
+ if (cols && rows) {
94
+ try {
95
+ this.ptyProc.resize(cols, rows);
96
+ } catch {
97
+ /* pty may have exited */
98
+ }
99
+ }
100
+ } else if (msg.type === 'ping') {
101
+ this.sendControl({ type: 'pong' });
102
+ }
103
+ }
104
+
105
+ private sendBinary(buf: Buffer): void {
106
+ if (this.disposed || this.ws.readyState !== this.ws.OPEN) return;
107
+ this.ws.send(buf, { binary: true });
108
+ if (!this.paused && this.ws.bufferedAmount > HIGH_WATER) {
109
+ this.paused = true;
110
+ this.ptyProc.pause();
111
+ this.drainTimer = setInterval(() => this.checkDrain(), 50);
112
+ }
113
+ }
114
+
115
+ private checkDrain(): void {
116
+ if (this.disposed) return;
117
+ if (this.ws.bufferedAmount < LOW_WATER) {
118
+ this.paused = false;
119
+ if (this.drainTimer) clearInterval(this.drainTimer);
120
+ this.drainTimer = undefined;
121
+ this.ptyProc.resume();
122
+ }
123
+ }
124
+
125
+ private sendControl(msg: ServerControl): void {
126
+ if (this.disposed || this.ws.readyState !== this.ws.OPEN) return;
127
+ this.ws.send(JSON.stringify(msg), { binary: false });
128
+ }
129
+
130
+ private tick(): void {
131
+ if (!this.alive) {
132
+ log.warn(`heartbeat lost for ${this.target.id}/${this.session}, terminating`);
133
+ try {
134
+ this.ws.terminate();
135
+ } catch {
136
+ /* ignore */
137
+ }
138
+ this.dispose();
139
+ return;
140
+ }
141
+ this.alive = false;
142
+ try {
143
+ this.ws.ping();
144
+ } catch {
145
+ /* ignore */
146
+ }
147
+ }
148
+
149
+ private closeWs(code: number, reason: string): void {
150
+ try {
151
+ if (this.ws.readyState === this.ws.OPEN) this.ws.close(code, reason);
152
+ } catch {
153
+ /* ignore */
154
+ }
155
+ }
156
+
157
+ /** Idempotent teardown — called from pty exit, ws close, and shutdown. */
158
+ dispose(): void {
159
+ if (this.disposed) return;
160
+ this.disposed = true;
161
+
162
+ if (this.heartbeat) clearInterval(this.heartbeat);
163
+ if (this.drainTimer) clearInterval(this.drainTimer);
164
+
165
+ try {
166
+ this.ptyProc.kill(); // SIGHUP → tmux client detaches; session keeps running
167
+ } catch {
168
+ /* already gone */
169
+ }
170
+ // Force-kill if it lingers.
171
+ this.killTimer = setTimeout(() => {
172
+ try {
173
+ this.ptyProc.kill('SIGKILL');
174
+ } catch {
175
+ /* already gone */
176
+ }
177
+ }, KILL_GRACE_MS);
178
+ this.killTimer.unref?.();
179
+
180
+ this.closeWs(1000, 'disposed');
181
+ registry.delete(this);
182
+ }
183
+ }
184
+
185
+ function toBufferString(data: unknown): string {
186
+ if (typeof data === 'string') return data;
187
+ if (Buffer.isBuffer(data)) return data.toString('utf8');
188
+ if (Array.isArray(data)) return Buffer.concat(data).toString('utf8');
189
+ if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8');
190
+ return String(data);
191
+ }
192
+
193
+ function clampDim(n: unknown): number | null {
194
+ if (typeof n !== 'number' || !Number.isInteger(n) || n < 1 || n > 1000) return null;
195
+ return n;
196
+ }
197
+
198
+ /** All live sessions, so the process can tear them down on shutdown. */
199
+ export const registry = new Set<TerminalSession>();
200
+
201
+ export function track(s: TerminalSession): void {
202
+ registry.add(s);
203
+ }
204
+
205
+ export function disposeAll(): void {
206
+ for (const s of [...registry]) s.dispose();
207
+ }
@@ -0,0 +1,153 @@
1
+ import type { Server as HttpServer, IncomingMessage } from 'node:http';
2
+ import type { Duplex } from 'node:stream';
3
+ import { WebSocketServer, type WebSocket } from 'ws';
4
+ import { config } from '../config.js';
5
+ import { getTarget, isValidTargetId, type Target } from '../targets.js';
6
+ import { isValidSessionName, isValidDimension } from '../validate.js';
7
+ import { TerminalSession, track } from './terminalSession.js';
8
+ import { winShell, type ShellClient } from '../winshell/manager.js';
9
+ import type { ClientControl } from './protocol.js';
10
+ import { log } from '../logger.js';
11
+
12
+ const HEARTBEAT_MS = 30_000;
13
+
14
+ function reject(socket: Duplex, status: number, message: string): void {
15
+ socket.write(`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\n\r\n`);
16
+ socket.destroy();
17
+ }
18
+
19
+ function rawToString(data: unknown): string {
20
+ if (typeof data === 'string') return data;
21
+ if (Buffer.isBuffer(data)) return data.toString('utf8');
22
+ if (Array.isArray(data)) return Buffer.concat(data as Buffer[]).toString('utf8');
23
+ if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8');
24
+ return String(data);
25
+ }
26
+
27
+ let nextClientId = 1;
28
+
29
+ /** Attach a WS to a native shell session (one persistent pty, many clients). */
30
+ function attachWinShell(ws: WebSocket, target: Target, session: string, cols: number, rows: number): void {
31
+ const client: ShellClient = {
32
+ id: nextClientId++,
33
+ sendBinary: (buf) => {
34
+ if (ws.readyState === ws.OPEN) ws.send(buf, { binary: true });
35
+ },
36
+ sendControl: (msg) => {
37
+ if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg), { binary: false });
38
+ },
39
+ isOpen: () => ws.readyState === ws.OPEN,
40
+ close: (code, reason) => {
41
+ try {
42
+ ws.close(code, reason);
43
+ } catch {
44
+ /* ignore */
45
+ }
46
+ },
47
+ };
48
+
49
+ let shellSession;
50
+ try {
51
+ shellSession = winShell.attachOrCreate(session, cols, rows, client);
52
+ } catch (e) {
53
+ client.sendControl({ type: 'error', message: e instanceof Error ? e.message : 'failed to start shell' });
54
+ client.close(1011, 'shell error');
55
+ return;
56
+ }
57
+ client.sendControl({ type: 'ready', target: target.id, session });
58
+
59
+ ws.on('message', (data, isBinary) => {
60
+ if (isBinary) {
61
+ shellSession.write(rawToString(data));
62
+ return;
63
+ }
64
+ let msg: ClientControl;
65
+ try {
66
+ msg = JSON.parse(rawToString(data)) as ClientControl;
67
+ } catch {
68
+ return;
69
+ }
70
+ if (msg.type === 'resize' && isValidDimension(msg.cols) && isValidDimension(msg.rows)) {
71
+ shellSession.resize(msg.cols, msg.rows);
72
+ } else if (msg.type === 'ping') {
73
+ client.sendControl({ type: 'pong' });
74
+ }
75
+ });
76
+
77
+ let alive = true;
78
+ ws.on('pong', () => {
79
+ alive = true;
80
+ });
81
+ const hb = setInterval(() => {
82
+ if (!alive) {
83
+ try {
84
+ ws.terminate();
85
+ } catch {
86
+ /* ignore */
87
+ }
88
+ return;
89
+ }
90
+ alive = false;
91
+ try {
92
+ ws.ping();
93
+ } catch {
94
+ /* ignore */
95
+ }
96
+ }, HEARTBEAT_MS);
97
+
98
+ const cleanup = () => {
99
+ clearInterval(hb);
100
+ shellSession.detach(client); // pty stays alive — persistence across reconnects
101
+ };
102
+ ws.on('close', cleanup);
103
+ ws.on('error', cleanup);
104
+ }
105
+
106
+ /** Attach the single /ws interactive-attach endpoint to the HTTP server. */
107
+ export function attachWebSocket(server: HttpServer): void {
108
+ const wss = new WebSocketServer({ noServer: true });
109
+
110
+ server.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {
111
+ let url: URL;
112
+ try {
113
+ url = new URL(req.url ?? '', `http://${req.headers.host ?? 'localhost'}`);
114
+ } catch {
115
+ reject(socket, 400, 'Bad Request');
116
+ return;
117
+ }
118
+
119
+ if (url.pathname !== '/ws') {
120
+ reject(socket, 404, 'Not Found');
121
+ return;
122
+ }
123
+
124
+ // The WS upgrade bypasses Express middleware — enforce Origin here.
125
+ if (!config.isAllowedOrigin(req.headers.origin)) {
126
+ log.warn(`rejected WS upgrade from disallowed origin: ${req.headers.origin}`);
127
+ reject(socket, 403, 'Forbidden');
128
+ return;
129
+ }
130
+
131
+ const targetId = url.searchParams.get('target') ?? '';
132
+ const session = url.searchParams.get('session') ?? '';
133
+ if (!isValidTargetId(targetId)) return reject(socket, 400, 'Bad Request');
134
+ const target = getTarget(targetId);
135
+ if (!target) return reject(socket, 404, 'Not Found');
136
+ if (!isValidSessionName(session)) return reject(socket, 400, 'Bad Request');
137
+
138
+ const colsRaw = Number(url.searchParams.get('cols'));
139
+ const rowsRaw = Number(url.searchParams.get('rows'));
140
+ const cols = isValidDimension(colsRaw) ? colsRaw : 80;
141
+ const rows = isValidDimension(rowsRaw) ? rowsRaw : 24;
142
+
143
+ wss.handleUpgrade(req, socket, head, (ws) => {
144
+ log.info(`attach ${target.id}/${session} (${cols}x${rows})`);
145
+ if (target.kind === 'winlocal') {
146
+ attachWinShell(ws, target, session, cols, rows);
147
+ return;
148
+ }
149
+ const ts = new TerminalSession(ws, target, session, cols, rows);
150
+ track(ts);
151
+ });
152
+ });
153
+ }
@@ -0,0 +1,38 @@
1
+ import { runCommand } from './exec.js';
2
+ import { log } from './logger.js';
3
+
4
+ /** Distros that exist for the container runtime, never for interactive use. */
5
+ const SYSTEM_DISTROS = /^docker-desktop(-data)?$/i;
6
+
7
+ const NUL = 0;
8
+ const BOM = 0xfeff;
9
+
10
+ /**
11
+ * Enumerate installed WSL distros (Windows only). `wsl.exe -l -q` prints names
12
+ * one per line in UTF-16LE (with a BOM), so we decode accordingly.
13
+ */
14
+ export async function listWslDistros(): Promise<string[]> {
15
+ const r = await runCommand('wsl.exe', ['-l', '-q'], { encoding: 'utf16le', timeoutMs: 8000 });
16
+ if (r.code !== 0) {
17
+ if (r.stdout.trim() || r.stderr.trim()) {
18
+ log.warn(`wsl.exe -l -q failed: ${(r.stderr || r.stdout).trim().split('\n')[0]}`);
19
+ }
20
+ return [];
21
+ }
22
+ return parseWslList(r.stdout);
23
+ }
24
+
25
+ /** Parse `wsl.exe -l -q` decoded output into clean distro names. */
26
+ export function parseWslList(stdout: string): string[] {
27
+ // Drop NULs / BOM via code point so the source stays pure ASCII.
28
+ const cleaned = Array.from(stdout)
29
+ .filter((ch) => {
30
+ const c = ch.charCodeAt(0);
31
+ return c !== NUL && c !== BOM;
32
+ })
33
+ .join('');
34
+ return cleaned
35
+ .split(/\r?\n/)
36
+ .map((line) => line.trim())
37
+ .filter((name) => name.length > 0 && !SYSTEM_DISTROS.test(name));
38
+ }
@@ -0,0 +1,66 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { augmentAgentCommand, detectAgentKind } from '../src/agentHooks.js';
3
+
4
+ afterEach(() => {
5
+ delete process.env.TMUXES_NO_AUTOHOOK;
6
+ });
7
+
8
+ describe('detectAgentKind', () => {
9
+ it('recognizes Claude Code and Codex commands', () => {
10
+ expect(detectAgentKind('claude')).toBe('claude');
11
+ expect(detectAgentKind('codex')).toBe('codex');
12
+ });
13
+
14
+ it('ignores non-agent commands, including the POSIX cc compiler, and the opt-out flag', () => {
15
+ expect(detectAgentKind('bash')).toBeUndefined();
16
+ expect(detectAgentKind('cc')).toBeUndefined();
17
+ process.env.TMUXES_NO_AUTOHOOK = '1';
18
+ expect(detectAgentKind('codex')).toBeUndefined();
19
+ });
20
+ });
21
+
22
+ describe('augmentAgentCommand', () => {
23
+ it('adds running, decision, done, and error hooks to Claude Code', () => {
24
+ const out = augmentAgentCommand('claude --model opus "do x"');
25
+ expect(out.kind).toBe('claude');
26
+ expect(out.command).toMatch(/^claude --settings '.*' --model opus "do x"$/);
27
+ expect(out.command).toContain('"UserPromptSubmit"');
28
+ expect(out.command).toContain('"PreToolUse"');
29
+ expect(out.command).toContain('"PostToolUse"');
30
+ expect(out.command).toContain('"PermissionRequest"');
31
+ expect(out.command).toContain('"Notification"');
32
+ expect(out.command).toContain('"Stop"');
33
+ expect(out.command).toContain('@tmuxes_agent claude:running::UserPromptSubmit:$(date +%s).$$');
34
+ expect(out.command).toContain('@tmuxes_agent claude:waiting:decision:PermissionRequest:$(date +%s).$$');
35
+ expect(out.command).toContain('@tmuxes_agent claude:idle:done:Stop:$(date +%s).$$');
36
+ expect(out.command).toContain('@tmuxes_agent claude:idle:error:StopFailure:$(date +%s).$$');
37
+ });
38
+
39
+ it('produces valid JSON for Claude settings', () => {
40
+ const out = augmentAgentCommand('claude');
41
+ const json = out.command.match(/--settings '(.*)'$/)?.[1];
42
+ expect(json).toBeTruthy();
43
+ const parsed = JSON.parse(json!);
44
+ expect(parsed.hooks.PermissionRequest[0].hooks[0].command).toContain('decision');
45
+ expect(parsed.hooks.Stop[0].hooks[0].command).toContain('done');
46
+ expect(parsed.hooks.StopFailure[0].hooks[0].command).toContain('error');
47
+ });
48
+
49
+ it('adds lifecycle hook config overrides to Codex', () => {
50
+ const out = augmentAgentCommand('codex "fix the bug"');
51
+ expect(out.kind).toBe('codex');
52
+ expect(out.command).toMatch(/^codex -c 'hooks\.UserPromptSubmit=.*' -c 'hooks\.PreToolUse=.*' /);
53
+ expect(out.command.endsWith(' "fix the bug"')).toBe(true);
54
+ expect(out.command).toContain('hooks.PermissionRequest');
55
+ expect(out.command).toContain('hooks.Stop');
56
+ expect(out.command).toContain('@tmuxes_agent codex:running::PreToolUse:$(date +%s).$$');
57
+ expect(out.command).toContain('@tmuxes_agent codex:waiting:decision:PermissionRequest:$(date +%s).$$');
58
+ expect(out.command).toContain('@tmuxes_agent codex:idle:done:Stop:$(date +%s).$$');
59
+ });
60
+
61
+ it('leaves non-agent or disabled commands untouched', () => {
62
+ expect(augmentAgentCommand('bash')).toEqual({ command: 'bash' });
63
+ process.env.TMUXES_NO_AUTOHOOK = 'true';
64
+ expect(augmentAgentCommand('codex "x"')).toEqual({ command: 'codex "x"' });
65
+ });
66
+ });
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { classifyAgentTerminalError } from '../src/agentOutput.js';
3
+
4
+ describe('classifyAgentTerminalError', () => {
5
+ it('detects Codex stream disconnects that do not emit a stop hook', () => {
6
+ const text =
7
+ 'stream disconnected before completion: error sending request for url (https://chatgpt.com/backend-api/codex/responses)';
8
+ expect(classifyAgentTerminalError(text, 'codex')).toBe('CodexStreamDisconnected');
9
+ });
10
+
11
+ it('handles wrapped or styled terminal output', () => {
12
+ const text =
13
+ '\u001b[31mstream disconnected before completion:\u001b[0m\nerror sending request for url (https://chatgpt.com/backend-api/codex/responses)';
14
+ expect(classifyAgentTerminalError(text, 'codex')).toBe('CodexStreamDisconnected');
15
+ });
16
+
17
+ it('does not apply Codex-specific URL matching to Claude', () => {
18
+ const text =
19
+ 'stream disconnected before completion: error sending request for url (https://chatgpt.com/backend-api/codex/responses)';
20
+ expect(classifyAgentTerminalError(text, 'claude')).toBe('StreamDisconnected');
21
+ });
22
+
23
+ it('ignores ordinary terminal output', () => {
24
+ expect(classifyAgentTerminalError('building project\nall tests passed', 'codex')).toBeUndefined();
25
+ });
26
+ });
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { agentInitialValue, agentValue, parseAgentValue } from '../src/agentState.js';
3
+
4
+ describe('agent state values', () => {
5
+ it('initializes a launched agent without a notification reason', () => {
6
+ const parsed = parseAgentValue(agentInitialValue('codex'));
7
+ expect(parsed).toMatchObject({
8
+ agentKind: 'codex',
9
+ agentState: 'idle',
10
+ agentEvent: 'launch',
11
+ });
12
+ expect(parsed?.attentionReason).toBeUndefined();
13
+ });
14
+
15
+ it('parses abnormal stop notifications', () => {
16
+ expect(parseAgentValue(agentValue('codex', 'idle', 'error', 'CodexStreamDisconnected', '42'))).toMatchObject({
17
+ agentKind: 'codex',
18
+ agentState: 'idle',
19
+ attentionReason: 'error',
20
+ agentEvent: 'CodexStreamDisconnected',
21
+ agentNonce: '42',
22
+ });
23
+ });
24
+ });
@@ -0,0 +1,162 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { homedir } from 'node:os';
3
+ import {
4
+ sshQuote,
5
+ localTmux,
6
+ remoteTmux,
7
+ wslTmux,
8
+ managementArgv,
9
+ attachArgv,
10
+ newSessionArgv,
11
+ } from '../src/tmux/builder.js';
12
+ import type { Target } from '../src/targets.js';
13
+
14
+ const local: Target = { id: 'local', kind: 'local', label: 'Local' };
15
+ const sshFull: Target = { id: 'env-x', kind: 'ssh', label: 'alice@web1:2222', host: 'web1', user: 'alice', port: 2222 };
16
+ const sshAlias: Target = { id: 'cfg-devbox', kind: 'ssh', label: 'devbox', host: 'devbox' };
17
+ const wsl: Target = { id: 'wsl-Ubuntu', kind: 'wsl', label: 'Ubuntu', distro: 'Ubuntu' };
18
+ const isWindows = process.platform === 'win32';
19
+
20
+ function expectConnectionSharing(argv: string[]): void {
21
+ if (isWindows) {
22
+ expect(argv).not.toContain('ControlMaster=auto');
23
+ expect(argv).not.toContain('ControlPersist=yes');
24
+ expect(argv.some((arg) => arg.startsWith('ControlPath='))).toBe(false);
25
+ return;
26
+ }
27
+
28
+ expect(argv).toContain('ControlMaster=auto');
29
+ expect(argv).toContain('ControlPersist=yes');
30
+ expect(argv.some((arg) => arg.startsWith('ControlPath='))).toBe(true);
31
+ }
32
+
33
+ describe('sshQuote', () => {
34
+ it('single-quotes and escapes embedded quotes', () => {
35
+ expect(sshQuote('plain')).toBe(`'plain'`);
36
+ expect(sshQuote(`it's`)).toBe(`'it'\\''s'`);
37
+ });
38
+ });
39
+
40
+ describe('localTmux', () => {
41
+ it('prefixes tmux with zero quoting', () => {
42
+ expect(localTmux(['list-sessions', '-F', 'x'])).toEqual(['tmux', 'list-sessions', '-F', 'x']);
43
+ });
44
+ });
45
+
46
+ describe('remoteTmux management (tty:false)', () => {
47
+ it('uses BatchMode, ConnectTimeout, connection sharing, port, and quotes remote args', () => {
48
+ const argv = remoteTmux(sshFull, ['list-sessions', '-F', 'a b'], { tty: false });
49
+ expect(argv.slice(0, 5)).toEqual(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8']);
50
+ expectConnectionSharing(argv);
51
+ expect(argv).toContain('-p');
52
+ expect(argv).toContain('2222');
53
+ expect(argv).toContain('alice@web1');
54
+ expect(argv.slice(-4)).toEqual(['tmux', `'list-sessions'`, `'-F'`, `'a b'`]);
55
+ });
56
+ it('uses the bare alias when no user/port', () => {
57
+ const argv = remoteTmux(sshAlias, ['kill-session', '-t', 'work'], { tty: false });
58
+ expect(argv).toContain('devbox');
59
+ expect(argv).not.toContain('-p');
60
+ expect(argv[argv.length - 1]).toBe(`'work'`);
61
+ });
62
+ it('can disable connection sharing for a one-shot reconnect attempt', () => {
63
+ const argv = remoteTmux(sshAlias, ['list-sessions'], { tty: false, multiplex: false });
64
+ expect(argv).toContain('ControlMaster=no');
65
+ expect(argv).not.toContain('ControlMaster=auto');
66
+ expect(argv).not.toContain('ControlPersist=yes');
67
+ expect(argv.some((arg) => arg.startsWith('ControlPath='))).toBe(false);
68
+ });
69
+ });
70
+
71
+ describe('remoteTmux interactive (tty:true)', () => {
72
+ it('forces a PTY with -tt, shares the ssh connection, and does not quote args', () => {
73
+ const argv = remoteTmux(sshFull, ['new-session', '-A', '-s', 'work'], { tty: true });
74
+ expect(argv).toContain('-tt');
75
+ expectConnectionSharing(argv);
76
+ expect(argv.some((arg) => arg.startsWith('ServerAliveInterval='))).toBe(false);
77
+ expect(argv.slice(-4)).toEqual(['new-session', '-A', '-s', 'work']);
78
+ });
79
+ });
80
+
81
+ describe('wslTmux', () => {
82
+ it('management runs tmux directly in the distro via wsl.exe -- (no shell)', () => {
83
+ expect(wslTmux('Ubuntu', ['list-sessions', '-F', 'x'], { tty: false })).toEqual([
84
+ 'wsl.exe',
85
+ '-d',
86
+ 'Ubuntu',
87
+ '--exec',
88
+ 'tmux',
89
+ 'list-sessions',
90
+ '-F',
91
+ 'x',
92
+ ]);
93
+ });
94
+ it('interactive sets TERM via env since WSL does not inherit it', () => {
95
+ expect(wslTmux('Ubuntu', ['new-session', '-A', '-s', 'work'], { tty: true })).toEqual([
96
+ 'wsl.exe',
97
+ '-d',
98
+ 'Ubuntu',
99
+ '--exec',
100
+ 'env',
101
+ 'TERM=xterm-256color',
102
+ 'tmux',
103
+ 'new-session',
104
+ '-A',
105
+ '-s',
106
+ 'work',
107
+ ]);
108
+ });
109
+ });
110
+
111
+ describe('managementArgv / attachArgv', () => {
112
+ it('splits file and args for local', () => {
113
+ expect(managementArgv(local, ['list-sessions'])).toEqual({ file: 'tmux', args: ['list-sessions'] });
114
+ });
115
+ it('local attach uses new-session -A without -d', () => {
116
+ const { file, args } = attachArgv(local, 'work');
117
+ expect(file).toBe('tmux');
118
+ expect(args).toEqual(['new-session', '-A', '-s', 'work']);
119
+ expect(args).not.toContain('-d');
120
+ });
121
+ it('ssh attach goes through ssh -tt', () => {
122
+ const { file, args } = attachArgv(sshAlias, 'work');
123
+ expect(file).toBe('ssh');
124
+ expect(args).toContain('-tt');
125
+ expect(args.slice(-4)).toEqual(['new-session', '-A', '-s', 'work']);
126
+ });
127
+ it('wsl management goes through wsl.exe', () => {
128
+ expect(managementArgv(wsl, ['list-sessions'])).toEqual({
129
+ file: 'wsl.exe',
130
+ args: ['-d', 'Ubuntu', '--exec', 'tmux', 'list-sessions'],
131
+ });
132
+ });
133
+ it('wsl attach goes through wsl.exe with env TERM', () => {
134
+ const { file, args } = attachArgv(wsl, 'work');
135
+ expect(file).toBe('wsl.exe');
136
+ expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--exec', 'env', 'TERM=xterm-256color']);
137
+ expect(args.slice(-4)).toEqual(['new-session', '-A', '-s', 'work']);
138
+ });
139
+ });
140
+
141
+ describe('newSessionArgv (always starts in HOME)', () => {
142
+ const sub = ['new-session', '-d', '-s', 'work'];
143
+ it('local appends tmux -c <homedir>', () => {
144
+ expect(newSessionArgv(local, sub)).toEqual({
145
+ file: 'tmux',
146
+ args: ['new-session', '-d', '-s', 'work', '-c', homedir()],
147
+ });
148
+ });
149
+ it('wsl uses wsl --cd ~ (distro home), no tmux -c', () => {
150
+ expect(newSessionArgv(wsl, sub)).toEqual({
151
+ file: 'wsl.exe',
152
+ args: ['-d', 'Ubuntu', '--cd', '~', '--exec', 'tmux', 'new-session', '-d', '-s', 'work'],
153
+ });
154
+ });
155
+ it('ssh relies on the remote $HOME default (no --cd / -c)', () => {
156
+ const { file, args } = newSessionArgv(sshAlias, sub);
157
+ expect(file).toBe('ssh');
158
+ expect(args).not.toContain('--cd');
159
+ expect(args).not.toContain('-c');
160
+ expect(args.slice(-4)).toEqual([`'new-session'`, `'-d'`, `'-s'`, `'work'`]);
161
+ });
162
+ });