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,114 @@
1
+ import express from 'express';
2
+ import readline from 'node:readline';
3
+ import { createServer } from 'node:http';
4
+ import { existsSync } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { dirname, join } from 'node:path';
7
+ import { config } from './config.js';
8
+ import { log } from './logger.js';
9
+ import { apiRouter } from './rest/router.js';
10
+ import { attachWebSocket } from './ws/wsServer.js';
11
+ import { disposeAll } from './ws/terminalSession.js';
12
+ import { refreshTargets } from './targets.js';
13
+ import { winShell } from './winshell/manager.js';
14
+ import { openBrowser } from './openBrowser.js';
15
+
16
+ const app = express();
17
+ app.use(express.json({ limit: '5mb' }));
18
+ app.use('/api', apiRouter);
19
+
20
+ // Serve the built client. Published npm package bundles it at <pkg>/public;
21
+ // in the dev monorepo it lives at server/../../client/dist.
22
+ const here = dirname(fileURLToPath(import.meta.url));
23
+ const clientDist = [join(here, '..', 'public'), join(here, '..', '..', 'client', 'dist')].find(existsSync);
24
+ if (clientDist) {
25
+ app.use(express.static(clientDist));
26
+ // SPA fallback for non-API GET routes (avoids path-to-regexp '*' quirks in Express 5).
27
+ app.use((req, res, next) => {
28
+ if (req.method !== 'GET' || req.path.startsWith('/api')) return next();
29
+ res.sendFile(join(clientDist, 'index.html'));
30
+ });
31
+ log.info(`serving client from ${clientDist}`);
32
+ }
33
+
34
+ const server = createServer(app);
35
+ attachWebSocket(server);
36
+
37
+ server.listen(config.port, config.host, () => {
38
+ const url = `http://${config.host}:${config.port}`;
39
+ log.info(`tmuxes listening on ${url}`);
40
+ // Warm the target cache (discovers WSL distros on Windows) ahead of any WS.
41
+ void refreshTargets().catch((e) => log.warn(`initial target discovery failed: ${e}`));
42
+ // One-click launchers set TMUXES_OPEN=1 to pop the browser open.
43
+ if (process.env.TMUXES_OPEN) openBrowser(url);
44
+ });
45
+
46
+ let shuttingDown = false;
47
+ function shutdown(signal: string): void {
48
+ if (shuttingDown) return;
49
+ shuttingDown = true;
50
+ log.info(`received ${signal}, shutting down`);
51
+ // Restore the console we put into raw mode (Windows Ctrl+C handling).
52
+ try {
53
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
54
+ } catch {
55
+ /* ignore */
56
+ }
57
+ disposeAll();
58
+ winShell.disposeAll();
59
+ server.close(() => process.exit(0));
60
+ // Don't wait forever for lingering sockets.
61
+ setTimeout(() => process.exit(0), 3000).unref();
62
+ }
63
+ process.on('SIGINT', () => shutdown('SIGINT'));
64
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
65
+ // Console window closed (Windows) / terminal hangup (POSIX) → free the port.
66
+ process.on('SIGHUP', () => shutdown('SIGHUP'));
67
+ // Windows Ctrl+Break.
68
+ if (process.platform === 'win32') process.on('SIGBREAK', () => shutdown('SIGBREAK'));
69
+
70
+ // On Windows, node-pty's ConPTY backend breaks the normal CTRL_C_EVENT → SIGINT
71
+ // path for the host process (see microsoft/node-pty#190), so `process.on('SIGINT')`
72
+ // can't be relied on. Instead we read the raw Ctrl+C byte (0x03) straight from the
73
+ // console, set up at startup (before any pty is spawned). This bypasses the signal
74
+ // machinery entirely. Ctrl+Break still works via the SIGBREAK handler above.
75
+ if (process.platform === 'win32') {
76
+ const stdin = process.stdin;
77
+ if (stdin.isTTY) {
78
+ try {
79
+ stdin.setRawMode(true); // deliver keys as bytes; Ctrl+C arrives as 0x03
80
+ stdin.resume();
81
+ stdin.on('data', (buf: Buffer) => {
82
+ if (buf.includes(0x03)) shutdown('Ctrl+C');
83
+ });
84
+ } catch {
85
+ // Fall back to the readline SIGINT bridge (needs explicit terminal mode).
86
+ readline
87
+ .createInterface({ input: stdin, terminal: true })
88
+ .on('SIGINT', () => shutdown('SIGINT'));
89
+ }
90
+ } else {
91
+ // stdin isn't a console TTY (e.g. piped) — best-effort bridge.
92
+ try {
93
+ readline
94
+ .createInterface({ input: stdin, terminal: true })
95
+ .on('SIGINT', () => shutdown('SIGINT'));
96
+ } catch {
97
+ /* nothing more we can do */
98
+ }
99
+ }
100
+ }
101
+
102
+ // If the port is already held (e.g. a previous run is still alive), exit with a
103
+ // clear message instead of an unhandled-error stack trace.
104
+ server.on('error', (err: NodeJS.ErrnoException) => {
105
+ if (err.code === 'EADDRINUSE') {
106
+ log.error(
107
+ `port ${config.port} is already in use — another tmuxes instance is still running. ` +
108
+ `Close it (or its window), then start again.`,
109
+ );
110
+ } else {
111
+ log.error(`server error: ${err.message}`);
112
+ }
113
+ process.exit(1);
114
+ });
@@ -0,0 +1,16 @@
1
+ /** Tiny timestamped console logger — no external dependency. */
2
+ function ts(): string {
3
+ return new Date().toISOString();
4
+ }
5
+
6
+ export const log = {
7
+ info(msg: string, ...rest: unknown[]): void {
8
+ console.log(`[${ts()}] ${msg}`, ...rest);
9
+ },
10
+ warn(msg: string, ...rest: unknown[]): void {
11
+ console.warn(`[${ts()}] WARN ${msg}`, ...rest);
12
+ },
13
+ error(msg: string, ...rest: unknown[]): void {
14
+ console.error(`[${ts()}] ERROR ${msg}`, ...rest);
15
+ },
16
+ };
@@ -1,9 +1,10 @@
1
- /** Compatibility shim for the sessions route.
2
- *
3
- * Agent status now comes directly from Claude Code / Codex lifecycle hooks via
4
- * tmux session options, so there is no terminal-output heuristic to annotate.
5
- */
6
- export function annotate(_targetId, sessions) {
7
- return sessions;
8
- }
9
- //# sourceMappingURL=monitor.js.map
1
+ import type { SessionInfo } from './tmux/formats.js';
2
+
3
+ /** Compatibility shim for the sessions route.
4
+ *
5
+ * Agent status now comes directly from Claude Code / Codex lifecycle hooks via
6
+ * tmux session options, so there is no terminal-output heuristic to annotate.
7
+ */
8
+ export function annotate(_targetId: string, sessions: SessionInfo[]): SessionInfo[] {
9
+ return sessions;
10
+ }
@@ -0,0 +1,28 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { isWindows, isMac } from './platform.js';
3
+ import { log } from './logger.js';
4
+
5
+ /** Open `url` in the default browser (best-effort, detached). Used by the
6
+ * one-click launchers when TMUXES_OPEN is set. */
7
+ export function openBrowser(url: string): void {
8
+ try {
9
+ let file: string;
10
+ let args: string[];
11
+ if (isWindows) {
12
+ // `cmd /c start "" "<url>"` — the empty "" is the window title start needs.
13
+ file = 'cmd.exe';
14
+ args = ['/c', 'start', '', url];
15
+ } else if (isMac) {
16
+ file = 'open';
17
+ args = [url];
18
+ } else {
19
+ file = 'xdg-open';
20
+ args = [url];
21
+ }
22
+ const child = spawn(file, args, { detached: true, stdio: 'ignore' });
23
+ child.on('error', (e) => log.warn(`could not open browser: ${e.message}`));
24
+ child.unref();
25
+ } catch (e) {
26
+ log.warn(`could not open browser: ${e instanceof Error ? e.message : String(e)}`);
27
+ }
28
+ }
@@ -1,5 +1,4 @@
1
- /** Platform helpers. Windows has no native tmux, so it reaches tmux inside WSL
2
- * via wsl.exe; macOS and Linux run tmux directly. */
3
- export const isWindows = process.platform === 'win32';
4
- export const isMac = process.platform === 'darwin';
5
- //# sourceMappingURL=platform.js.map
1
+ /** Platform helpers. Windows has no native tmux, so it reaches tmux inside WSL
2
+ * via wsl.exe; macOS and Linux run tmux directly. */
3
+ export const isWindows = process.platform === 'win32';
4
+ export const isMac = process.platform === 'darwin';
@@ -0,0 +1,290 @@
1
+ import { Router, type Request, type Response, type NextFunction } from 'express';
2
+ import { getTarget, isValidTargetId, refreshTargets, type Target } from '../targets.js';
3
+ import { isValidSessionName } from '../validate.js';
4
+ import {
5
+ TmuxError,
6
+ listSessions,
7
+ createSession,
8
+ renameSession,
9
+ killSession,
10
+ listWindows,
11
+ launchAgentInSession,
12
+ type LaunchAgent,
13
+ } from '../tmux/sessions.js';
14
+ import { annotate } from '../monitor.js';
15
+ import {
16
+ getSessionCwd,
17
+ listDirectory,
18
+ listSessionDirectory,
19
+ readFilePreview,
20
+ resolveScopedPath,
21
+ writeFile,
22
+ } from '../files.js';
23
+ import { readFolders, writeFolders } from '../foldersStore.js';
24
+ import { winShell, ManagerError } from '../winshell/manager.js';
25
+
26
+ /** Cap on a saved file's size (matches the editor's text-only use). */
27
+ const MAX_WRITE_BYTES = 5_000_000;
28
+
29
+ type AsyncHandler = (req: Request, res: Response) => Promise<unknown>;
30
+
31
+ const wrap =
32
+ (fn: AsyncHandler) =>
33
+ (req: Request, res: Response, next: NextFunction): void => {
34
+ fn(req, res).catch(next);
35
+ };
36
+
37
+ /** Resolve :id → Target or throw a TmuxError with the right status. */
38
+ function requireTarget(req: Request): Target {
39
+ const id = req.params.id;
40
+ if (!isValidTargetId(id)) throw new TmuxError(400, 'invalid target id');
41
+ const target = getTarget(id);
42
+ if (!target) throw new TmuxError(404, 'target not found');
43
+ return target;
44
+ }
45
+
46
+ function requireSessionName(raw: unknown): string {
47
+ if (!isValidSessionName(raw)) throw new TmuxError(400, 'invalid session name');
48
+ return raw;
49
+ }
50
+
51
+ function requireLaunchAgent(raw: unknown): LaunchAgent {
52
+ if (raw === 'claude' || raw === 'codex') return raw;
53
+ throw new TmuxError(400, 'invalid agent');
54
+ }
55
+
56
+ /** A filesystem path supplied via query. Passed as a single argv element (no
57
+ * shell), so we only sanity-check shape, not contents — this app already
58
+ * grants full shell access on the target by design. */
59
+ function requirePath(raw: unknown): string {
60
+ if (typeof raw !== 'string' || raw.length === 0 || raw.length > 4096 || raw.includes('\0')) {
61
+ throw new TmuxError(400, 'invalid path');
62
+ }
63
+ return raw;
64
+ }
65
+
66
+ async function requireSessionScopedPath(
67
+ target: Target,
68
+ session: unknown,
69
+ path: unknown,
70
+ opts: { forWrite?: boolean } = {},
71
+ ): Promise<string> {
72
+ const name = requireSessionName(session);
73
+ const rawPath = requirePath(path);
74
+ const cwd = await getSessionCwd(target, name);
75
+ return resolveScopedPath(target, cwd, rawPath, opts);
76
+ }
77
+
78
+ export const apiRouter = Router();
79
+
80
+ apiRouter.get('/health', (_req, res) => {
81
+ res.json({ ok: true });
82
+ });
83
+
84
+ apiRouter.get(
85
+ '/targets',
86
+ wrap(async (_req, res) => {
87
+ // Re-discover (WSL distros / ssh config can change between loads).
88
+ res.json({ targets: await refreshTargets() });
89
+ }),
90
+ );
91
+
92
+ apiRouter.get(
93
+ '/targets/:id/sessions',
94
+ wrap(async (req, res) => {
95
+ const target = requireTarget(req);
96
+ const sessions = target.kind === 'winlocal' ? winShell.list() : await listSessions(target);
97
+ res.json({ sessions: annotate(target.id, sessions) });
98
+ }),
99
+ );
100
+
101
+ apiRouter.post(
102
+ '/targets/:id/sessions',
103
+ wrap(async (req, res) => {
104
+ const target = requireTarget(req);
105
+ const body = (req.body ?? {}) as { name?: unknown; command?: unknown; shell?: unknown };
106
+
107
+ const name = body.name === undefined || body.name === '' ? undefined : body.name;
108
+ if (name !== undefined && !isValidSessionName(name)) {
109
+ throw new TmuxError(400, 'invalid session name');
110
+ }
111
+ const command =
112
+ typeof body.command === 'string' && body.command.length > 0 ? body.command : undefined;
113
+
114
+ if (target.kind === 'winlocal') {
115
+ const shellId = typeof body.shell === 'string' ? body.shell : undefined;
116
+ res.status(201).json(winShell.create({ name, shellId, command }));
117
+ return;
118
+ }
119
+
120
+ const result = await createSession(target, { name, command });
121
+ res.status(201).json(result);
122
+ }),
123
+ );
124
+
125
+ apiRouter.patch(
126
+ '/targets/:id/sessions/:name',
127
+ wrap(async (req, res) => {
128
+ const target = requireTarget(req);
129
+ const name = requireSessionName(req.params.name);
130
+ const newName = (req.body ?? {}).newName;
131
+ if (!isValidSessionName(newName)) throw new TmuxError(400, 'invalid new session name');
132
+ if (target.kind === 'winlocal') {
133
+ winShell.rename(name, newName);
134
+ res.json({ name: newName });
135
+ return;
136
+ }
137
+ await renameSession(target, name, newName);
138
+ res.json({ name: newName });
139
+ }),
140
+ );
141
+
142
+ apiRouter.post(
143
+ '/targets/:id/sessions/:name/agent',
144
+ wrap(async (req, res) => {
145
+ const target = requireTarget(req);
146
+ if (target.kind === 'winlocal') throw new TmuxError(400, 'agent hooks require a tmux target');
147
+ const name = requireSessionName(req.params.name);
148
+ const agent = requireLaunchAgent((req.body ?? {}).agent);
149
+ await launchAgentInSession(target, name, agent);
150
+ res.json({ ok: true });
151
+ }),
152
+ );
153
+
154
+ apiRouter.delete(
155
+ '/targets/:id/sessions/:name',
156
+ wrap(async (req, res) => {
157
+ const target = requireTarget(req);
158
+ const name = requireSessionName(req.params.name);
159
+ if (target.kind === 'winlocal') {
160
+ winShell.kill(name);
161
+ res.status(204).end();
162
+ return;
163
+ }
164
+ await killSession(target, name);
165
+ res.status(204).end();
166
+ }),
167
+ );
168
+
169
+ apiRouter.get(
170
+ '/targets/:id/sessions/:name/windows',
171
+ wrap(async (req, res) => {
172
+ const target = requireTarget(req);
173
+ const name = requireSessionName(req.params.name);
174
+ if (target.kind === 'winlocal') {
175
+ res.json({ windows: winShell.windows(name) });
176
+ return;
177
+ }
178
+ res.json({ windows: await listWindows(target, name) });
179
+ }),
180
+ );
181
+
182
+ /** The file browser relies on tmux's pane cwd; native Windows shells have none. */
183
+ function rejectIfWinlocal(target: Target): void {
184
+ if (target.kind === 'winlocal') {
185
+ throw new TmuxError(400, 'file browsing is not available for native shell sessions');
186
+ }
187
+ }
188
+
189
+ // --- File browser (current pane cwd + directory listing + file preview) ---
190
+
191
+ apiRouter.get(
192
+ '/targets/:id/sessions/:name/cwd',
193
+ wrap(async (req, res) => {
194
+ const target = requireTarget(req);
195
+ rejectIfWinlocal(target);
196
+ const name = requireSessionName(req.params.name);
197
+ res.json({ cwd: await getSessionCwd(target, name) });
198
+ }),
199
+ );
200
+
201
+ apiRouter.get(
202
+ '/targets/:id/files',
203
+ wrap(async (req, res) => {
204
+ const target = requireTarget(req);
205
+ rejectIfWinlocal(target);
206
+ const path = await requireSessionScopedPath(target, req.query.session, req.query.path);
207
+ res.json({ path, entries: await listDirectory(target, path) });
208
+ }),
209
+ );
210
+
211
+ apiRouter.get(
212
+ '/targets/:id/sessions/:name/files',
213
+ wrap(async (req, res) => {
214
+ const target = requireTarget(req);
215
+ rejectIfWinlocal(target);
216
+ const name = requireSessionName(req.params.name);
217
+ const rawPath = req.query.path;
218
+ const path = rawPath === undefined ? undefined : requirePath(rawPath);
219
+ res.json(await listSessionDirectory(target, name, path));
220
+ }),
221
+ );
222
+
223
+ apiRouter.get(
224
+ '/targets/:id/file',
225
+ wrap(async (req, res) => {
226
+ const target = requireTarget(req);
227
+ rejectIfWinlocal(target);
228
+ const path = await requireSessionScopedPath(target, req.query.session, req.query.path);
229
+ res.json(await readFilePreview(target, path));
230
+ }),
231
+ );
232
+
233
+ apiRouter.put(
234
+ '/targets/:id/file',
235
+ wrap(async (req, res) => {
236
+ const target = requireTarget(req);
237
+ rejectIfWinlocal(target);
238
+ const body = (req.body ?? {}) as { session?: unknown; path?: unknown; content?: unknown };
239
+ const path = await requireSessionScopedPath(target, body.session, body.path, { forWrite: true });
240
+ if (typeof body.content !== 'string') throw new TmuxError(400, 'content must be a string');
241
+ if (Buffer.byteLength(body.content, 'utf8') > MAX_WRITE_BYTES) {
242
+ throw new TmuxError(413, 'file too large to save');
243
+ }
244
+ await writeFile(target, path, body.content);
245
+ res.json({ ok: true });
246
+ }),
247
+ );
248
+
249
+ // --- Sidebar folder organization, persisted on the target (syncs clients) ---
250
+
251
+ apiRouter.get(
252
+ '/targets/:id/folders',
253
+ wrap(async (req, res) => {
254
+ const target = requireTarget(req);
255
+ res.json(await readFolders(target));
256
+ }),
257
+ );
258
+
259
+ apiRouter.put(
260
+ '/targets/:id/folders',
261
+ wrap(async (req, res) => {
262
+ const target = requireTarget(req);
263
+ const body = req.body as { folders?: unknown; assign?: unknown };
264
+ if (
265
+ !body ||
266
+ typeof body !== 'object' ||
267
+ Array.isArray(body) ||
268
+ !Array.isArray(body.folders) ||
269
+ typeof body.assign !== 'object' ||
270
+ body.assign === null
271
+ ) {
272
+ throw new TmuxError(400, 'invalid folders payload');
273
+ }
274
+ await writeFolders(target, {
275
+ folders: body.folders,
276
+ assign: body.assign as Record<string, unknown>,
277
+ });
278
+ res.json({ ok: true });
279
+ }),
280
+ );
281
+
282
+ // Central error mapper.
283
+ apiRouter.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
284
+ if (err instanceof TmuxError || err instanceof ManagerError) {
285
+ res.status(err.status).json({ error: err.message });
286
+ return;
287
+ }
288
+ const message = err instanceof Error ? err.message : 'internal error';
289
+ res.status(500).json({ error: message });
290
+ });
@@ -0,0 +1,79 @@
1
+ import { unlinkSync } from 'node:fs';
2
+ import { runCommand, type CommandResult, type RunOptions } from './exec.js';
3
+ import type { Target } from './targets.js';
4
+ import { sshControlPath } from './tmux/builder.js';
5
+ import { isWindows } from './platform.js';
6
+ import { dropWindowsSshSession, runWindowsSshCommand } from './windowsSsh.js';
7
+
8
+ type BuildArgv = (opts: { multiplex?: boolean }) => { file: string; args: string[] };
9
+
10
+ const SSH_INTERRUPTION_RE =
11
+ /mux_client_request_session|Control socket connect|Broken pipe|Connection reset|Connection closed|Connection to .* closed|Connection timed out|Operation timed out|No route to host|Network is unreachable|Connection refused|kex_exchange_identification|banner exchange|stdio forwarding failed/i;
12
+
13
+ const SSH_CLIENT_FAILURE_RE =
14
+ /Permission denied \(|Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED|Could not resolve hostname|Too many authentication failures|Connection timed out|Operation timed out|No route to host|Network is unreachable|Connection refused|Connection reset|Connection closed|Connection to .* closed|Broken pipe|kex_exchange_identification|banner exchange/i;
15
+
16
+ function firstLine(stderr: string): string {
17
+ return stderr.trim().split('\n')[0] || 'ssh command failed';
18
+ }
19
+
20
+ function isInterruptedSsh(result: CommandResult): boolean {
21
+ return result.code !== 0 && SSH_INTERRUPTION_RE.test(result.stderr);
22
+ }
23
+
24
+ function isSshClientFailure(result: CommandResult): boolean {
25
+ return result.code !== 0 && SSH_CLIENT_FAILURE_RE.test(result.stderr);
26
+ }
27
+
28
+ /** Run a target command. If an SSH control master is stale/interrupted, retry
29
+ * exactly once using a fresh direct SSH connection. Authentication failures are
30
+ * not retried, to avoid repeated failed-login noise. */
31
+ export async function runTargetCommand(
32
+ target: Target,
33
+ buildArgv: BuildArgv,
34
+ opts: RunOptions = {},
35
+ ): Promise<CommandResult> {
36
+ if (target.kind === 'ssh' && isWindows) return runWindowsTargetCommand(target, buildArgv, opts);
37
+
38
+ const firstArgv = buildArgv({ multiplex: true });
39
+ const first = await runCommand(firstArgv.file, firstArgv.args, opts);
40
+ if (target.kind !== 'ssh' || !isInterruptedSsh(first)) return first;
41
+
42
+ const controlPath = sshControlPath(target);
43
+ if (controlPath) {
44
+ try {
45
+ unlinkSync(controlPath);
46
+ } catch {
47
+ /* stale socket may already be gone */
48
+ }
49
+ }
50
+
51
+ const retryArgv = buildArgv({ multiplex: false });
52
+ const retry = await runCommand(retryArgv.file, retryArgv.args, opts);
53
+ if (retry.code === 0 || !isSshClientFailure(retry)) return retry;
54
+
55
+ return {
56
+ ...retry,
57
+ stderr: `SSH connection interrupted; one reconnect attempt failed: ${firstLine(retry.stderr)}\n${retry.stderr}`,
58
+ };
59
+ }
60
+
61
+ async function runWindowsTargetCommand(
62
+ target: Target,
63
+ buildArgv: BuildArgv,
64
+ opts: RunOptions,
65
+ ): Promise<CommandResult> {
66
+ const firstArgv = buildArgv({ multiplex: false });
67
+ const first = await runWindowsSshCommand(target, firstArgv.file, firstArgv.args, opts);
68
+ if (!isInterruptedSsh(first)) return first;
69
+
70
+ dropWindowsSshSession(target);
71
+ const retryArgv = buildArgv({ multiplex: false });
72
+ const retry = await runWindowsSshCommand(target, retryArgv.file, retryArgv.args, opts);
73
+ if (retry.code === 0 || !isSshClientFailure(retry)) return retry;
74
+
75
+ return {
76
+ ...retry,
77
+ stderr: `SSH connection interrupted; one reconnect attempt failed: ${firstLine(retry.stderr)}\n${retry.stderr}`,
78
+ };
79
+ }