tmuxes 0.1.7 → 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 +301 -289
  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,70 @@
1
+ export type AgentKind = 'claude' | 'codex';
2
+ export type AgentState = 'running' | 'waiting' | 'idle';
3
+ export type AttentionReason = 'decision' | 'done' | 'error';
4
+
5
+ export interface AgentSnapshot {
6
+ agentKind: AgentKind;
7
+ agentState: AgentState;
8
+ attentionReason?: AttentionReason;
9
+ agentEvent?: string;
10
+ agentNonce?: string;
11
+ }
12
+
13
+ export const AGENT_OPTION = '@tmuxes_agent';
14
+
15
+ function isAgentKind(v: string): v is AgentKind {
16
+ return v === 'claude' || v === 'codex';
17
+ }
18
+
19
+ function isAgentState(v: string): v is AgentState {
20
+ return v === 'running' || v === 'waiting' || v === 'idle';
21
+ }
22
+
23
+ function isAttentionReason(v: string): v is AttentionReason {
24
+ return v === 'decision' || v === 'done' || v === 'error';
25
+ }
26
+
27
+ function cleanToken(v: string): string {
28
+ return v.replace(/[^A-Za-z0-9_.-]/g, '_');
29
+ }
30
+
31
+ export function agentValue(
32
+ kind: AgentKind,
33
+ state: AgentState,
34
+ reason: AttentionReason | '',
35
+ event: string,
36
+ nonce: string,
37
+ ): string {
38
+ return [
39
+ kind,
40
+ state,
41
+ reason,
42
+ cleanToken(event),
43
+ nonce.replace(/:/g, '_'),
44
+ ].join(':');
45
+ }
46
+
47
+ export function agentInitialValue(kind: AgentKind): string {
48
+ return agentValue(kind, 'idle', '', 'launch', String(Date.now()));
49
+ }
50
+
51
+ export function agentHookCommand(
52
+ kind: AgentKind,
53
+ state: AgentState,
54
+ reason: AttentionReason | '',
55
+ event: string,
56
+ ): string {
57
+ return `tmux set-option -q ${AGENT_OPTION} ${agentValue(kind, state, reason, event, '$(date +%s).$$')}`;
58
+ }
59
+
60
+ export function parseAgentValue(raw: string | undefined): AgentSnapshot | undefined {
61
+ if (!raw) return undefined;
62
+ const [kind, state, reason, event, ...nonceParts] = raw.split(':');
63
+ if (!isAgentKind(kind) || !isAgentState(state)) return undefined;
64
+ const snap: AgentSnapshot = { agentKind: kind, agentState: state };
65
+ if (isAttentionReason(reason)) snap.attentionReason = reason;
66
+ if (event) snap.agentEvent = event;
67
+ const nonce = nonceParts.join(':');
68
+ if (nonce) snap.agentNonce = nonce;
69
+ return snap;
70
+ }
@@ -0,0 +1,31 @@
1
+ /** Central configuration. Localhost-only by design — see README/SECURITY. */
2
+ export const config = {
3
+ /** Bind address. Intentionally not configurable: this is a no-auth local shell UI. */
4
+ host: '127.0.0.1',
5
+ port: Number(process.env.TMUXES_PORT ?? 7420),
6
+
7
+ /** ssh timeouts (seconds). */
8
+ ssh: {
9
+ /** Management calls fail fast so the UI never hangs. */
10
+ connectTimeoutMgmt: 8,
11
+ /** Interactive attach is allowed a little longer. */
12
+ connectTimeoutTty: 10,
13
+ /** Keep the OpenSSH control master alive for long-lived SSH reuse. */
14
+ controlPersist: 'yes',
15
+ },
16
+
17
+ /**
18
+ * Allowed WebSocket Origins. The WS upgrade bypasses Express middleware, so
19
+ * we enforce this in the upgrade handler to block DNS-rebind / cross-site WS
20
+ * hijack. Empty/absent Origin (non-browser clients) is allowed.
21
+ */
22
+ isAllowedOrigin(origin: string | undefined): boolean {
23
+ if (!origin) return true;
24
+ try {
25
+ const { hostname } = new URL(origin);
26
+ return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '[::1]';
27
+ } catch {
28
+ return false;
29
+ }
30
+ },
31
+ } as const;
@@ -0,0 +1,34 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { isAbsolute, join, delimiter } from 'node:path';
3
+ import { isWindows } from './platform.js';
4
+
5
+ /**
6
+ * Resolve a command name to a full path for node-pty on Windows.
7
+ *
8
+ * Unlike child_process.spawn, node-pty's ConPTY path does NOT search PATH or
9
+ * append a PATHEXT extension, so `pty.spawn('ssh', …)` fails with
10
+ * "File not found". We resolve the executable ourselves (PATH + common system
11
+ * locations). On POSIX, execvp already searches PATH, so the name is returned
12
+ * unchanged.
13
+ */
14
+ export function resolveExecutable(file: string): string {
15
+ if (!isWindows) return file;
16
+ if (isAbsolute(file) && existsSync(file)) return file;
17
+
18
+ const hasExt = /\.[A-Za-z0-9]+$/.test(file);
19
+ const exts = hasExt ? [''] : (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';');
20
+ const sysRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows';
21
+ const dirs = [
22
+ ...(process.env.PATH || '').split(delimiter),
23
+ join(sysRoot, 'System32'),
24
+ join(sysRoot, 'System32', 'OpenSSH'), // ssh.exe ships here, not always on PATH
25
+ ];
26
+ for (const dir of dirs) {
27
+ if (!dir) continue;
28
+ for (const ext of exts) {
29
+ const candidate = join(dir, file + ext);
30
+ if (existsSync(candidate)) return candidate;
31
+ }
32
+ }
33
+ return file; // fall back; node-pty surfaces a clear error if truly missing
34
+ }
@@ -0,0 +1,61 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ export interface CommandResult {
4
+ code: number | null;
5
+ signal: NodeJS.Signals | null;
6
+ stdout: string;
7
+ stderr: string;
8
+ }
9
+
10
+ export interface RunOptions {
11
+ timeoutMs?: number;
12
+ /** stdout decoding — wsl.exe emits UTF-16LE, everything else is UTF-8. */
13
+ encoding?: BufferEncoding;
14
+ /** Written to the child's stdin (then closed) — used to pipe file contents. */
15
+ input?: string;
16
+ }
17
+
18
+ /**
19
+ * Run a command as an argv array with NO shell. Never rejects — a nonzero exit
20
+ * or spawn error resolves with the captured output so callers can interpret it
21
+ * (e.g. tmux "no server running" is a normal empty case, not a throw).
22
+ */
23
+ export function runCommand(file: string, args: string[], opts: RunOptions = {}): Promise<CommandResult> {
24
+ const encoding = opts.encoding ?? 'utf8';
25
+ return new Promise((resolve) => {
26
+ const child = spawn(file, args, { shell: false });
27
+ const outChunks: Buffer[] = [];
28
+ const errChunks: Buffer[] = [];
29
+ let settled = false;
30
+
31
+ const timer = opts.timeoutMs
32
+ ? setTimeout(() => {
33
+ child.kill('SIGKILL');
34
+ }, opts.timeoutMs)
35
+ : null;
36
+
37
+ child.stdout.on('data', (d: Buffer) => outChunks.push(d));
38
+ child.stderr.on('data', (d: Buffer) => errChunks.push(d));
39
+
40
+ if (opts.input !== undefined) {
41
+ child.stdin.on('error', () => {
42
+ /* ignore EPIPE if the child exits early */
43
+ });
44
+ child.stdin.end(opts.input);
45
+ }
46
+
47
+ const done = (code: number | null, signal: NodeJS.Signals | null, extraErr?: string) => {
48
+ if (settled) return;
49
+ settled = true;
50
+ if (timer) clearTimeout(timer);
51
+ const stderr = Buffer.concat(errChunks).toString(encoding) + (extraErr ?? '');
52
+ resolve({ code, signal, stdout: Buffer.concat(outChunks).toString(encoding), stderr });
53
+ };
54
+
55
+ child.on('error', (err) => {
56
+ // e.g. ENOENT when ssh/tmux/wsl.exe is missing — surface as a failed result.
57
+ done(null, null, err.message);
58
+ });
59
+ child.on('close', (code, signal) => done(code, signal));
60
+ });
61
+ }
@@ -0,0 +1,330 @@
1
+ import { promises as fsp } from 'node:fs';
2
+ import { basename as posixBasename, dirname as posixDirname } from 'node:path/posix';
3
+ import { commandArgv } from './tmux/builder.js';
4
+ import { TmuxError } from './tmux/sessions.js';
5
+ import type { Target } from './targets.js';
6
+ import { runTargetCommand } from './targetCommand.js';
7
+
8
+ const REMOTE_TIMEOUT_MS = 15_000;
9
+ /** Max bytes returned for a file preview. */
10
+ export const FILE_PREVIEW_CAP = 2_000_000;
11
+ /** A NUL anywhere in a preview means the file is binary. */
12
+ const NUL = String.fromCharCode(0);
13
+
14
+ export interface FileEntry {
15
+ name: string;
16
+ type: 'dir' | 'file';
17
+ }
18
+
19
+ export interface FilePreview {
20
+ path: string;
21
+ content: string;
22
+ truncated: boolean;
23
+ binary: boolean;
24
+ }
25
+
26
+ export interface SessionDirectory {
27
+ cwd: string;
28
+ path: string;
29
+ entries: FileEntry[];
30
+ }
31
+
32
+ const SESSION_DIRECTORY_SCRIPT = [
33
+ 'session=$1',
34
+ 'requested=$2',
35
+ 'cwd=$(tmux display-message -p -t "$session" \'#{pane_current_path}\') || exit $?',
36
+ 'if [ -z "$cwd" ]; then echo "empty session cwd" >&2; exit 70; fi',
37
+ 'root=$(realpath -- "$cwd") || exit $?',
38
+ 'if [ -z "$requested" ]; then requested=$cwd; fi',
39
+ 'case "$requested" in /*) ;; *) echo "path must be absolute" >&2; exit 64;; esac',
40
+ 'candidate=$(realpath -- "$requested") || exit $?',
41
+ 'if [ "$root" = "/" ]; then inside=1; else case "$candidate" in "$root"|"$root"/*) inside=1 ;; *) inside=0 ;; esac; fi',
42
+ 'if [ "$inside" != 1 ]; then echo "path is outside the session working directory" >&2; exit 77; fi',
43
+ 'if [ ! -d "$candidate" ]; then echo "not a directory" >&2; exit 66; fi',
44
+ 'printf "%s\\0%s\\0" "$cwd" "$candidate"',
45
+ 'ls -Ap1 -- "$candidate"',
46
+ ].join('\n');
47
+
48
+ function timeout(t: Target): number | undefined {
49
+ return t.kind === 'local' ? undefined : REMOTE_TIMEOUT_MS;
50
+ }
51
+
52
+ function normalizeRoot(path: string): string {
53
+ if (path === '/') return path;
54
+ return path.replace(/\/+$/, '');
55
+ }
56
+
57
+ export function isInsideRoot(root: string, candidate: string): boolean {
58
+ const normalizedRoot = normalizeRoot(root);
59
+ return normalizedRoot === '/' || candidate === normalizedRoot || candidate.startsWith(`${normalizedRoot}/`);
60
+ }
61
+
62
+ async function remoteRealpath(t: Target, path: string): Promise<string> {
63
+ const r = await run(t, ['realpath', path]);
64
+ if (r.code !== 0) {
65
+ if (/No such file|not found/i.test(r.stderr)) throw new TmuxError(404, 'path not found');
66
+ if (/Permission denied/i.test(r.stderr)) throw new TmuxError(403, 'permission denied');
67
+ throw new TmuxError(502, r.stderr.trim().split('\n')[0] || 'cannot resolve path');
68
+ }
69
+ const resolved = r.stdout.trim().split('\n')[0];
70
+ if (!resolved) throw new TmuxError(502, 'empty resolved path');
71
+ return resolved;
72
+ }
73
+
74
+ async function realpath(t: Target, path: string): Promise<string> {
75
+ if (t.kind === 'local') {
76
+ try {
77
+ return await fsp.realpath(path);
78
+ } catch (e) {
79
+ throw fsError(e, path);
80
+ }
81
+ }
82
+ return remoteRealpath(t, path);
83
+ }
84
+
85
+ async function tryRealpath(t: Target, path: string): Promise<string | null> {
86
+ try {
87
+ return await realpath(t, path);
88
+ } catch (e) {
89
+ if (e instanceof TmuxError && e.status === 404) return null;
90
+ throw e;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Resolve a requested target path and verify it stays inside the session's cwd.
96
+ * This keeps the file browser/editor scoped to the agent workspace instead of
97
+ * exposing arbitrary host files through the no-auth local server.
98
+ */
99
+ export async function resolveScopedPath(
100
+ t: Target,
101
+ rootPath: string,
102
+ requestedPath: string,
103
+ opts: { forWrite?: boolean } = {},
104
+ ): Promise<string> {
105
+ if (!requestedPath.startsWith('/')) throw new TmuxError(400, 'path must be absolute');
106
+
107
+ const root = await realpath(t, rootPath);
108
+ let candidate: string | null = null;
109
+
110
+ if (opts.forWrite) {
111
+ candidate = await tryRealpath(t, requestedPath);
112
+ if (!candidate) {
113
+ const name = posixBasename(requestedPath);
114
+ if (!name || name === '.' || name === '..') throw new TmuxError(400, 'invalid path');
115
+ const parent = await realpath(t, posixDirname(requestedPath));
116
+ candidate = parent === '/' ? `/${name}` : `${parent}/${name}`;
117
+ }
118
+ } else {
119
+ candidate = await realpath(t, requestedPath);
120
+ }
121
+
122
+ if (!isInsideRoot(root, candidate)) {
123
+ throw new TmuxError(403, 'path is outside the session working directory');
124
+ }
125
+ return candidate;
126
+ }
127
+
128
+ async function run(t: Target, argv: string[]) {
129
+ return runTargetCommand(t, (opts) => commandArgv(t, argv, opts), { timeoutMs: timeout(t) });
130
+ }
131
+
132
+ /** The working directory of a session's active pane (tmux #{pane_current_path}). */
133
+ export async function getSessionCwd(t: Target, session: string): Promise<string> {
134
+ const r = await run(t, ['tmux', 'display-message', '-p', '-t', session, '#{pane_current_path}']);
135
+ if (r.code !== 0) {
136
+ if (/can't find|no server running|session not found/i.test(r.stderr)) {
137
+ throw new TmuxError(404, `session "${session}" not found`);
138
+ }
139
+ throw new TmuxError(502, r.stderr.trim().split('\n')[0] || 'cannot read session cwd');
140
+ }
141
+ const cwd = r.stdout.trim();
142
+ if (!cwd) throw new TmuxError(502, 'empty session cwd');
143
+ return cwd;
144
+ }
145
+
146
+ function sortEntries(entries: FileEntry[]): FileEntry[] {
147
+ return entries.sort((a, b) => {
148
+ if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
149
+ return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
150
+ });
151
+ }
152
+
153
+ function parseRemoteEntries(stdout: string): FileEntry[] {
154
+ return stdout
155
+ .split('\n')
156
+ .filter((l) => l.length > 0)
157
+ .map((line) => {
158
+ const isDir = line.endsWith('/');
159
+ const name = isDir ? line.slice(0, -1) : line;
160
+ return { name, type: isDir ? ('dir' as const) : ('file' as const) };
161
+ })
162
+ .filter((e) => e.name && e.name !== '.' && e.name !== '..');
163
+ }
164
+
165
+ function sessionDirectoryError(session: string, stderr: string): TmuxError {
166
+ const first = stderr.trim().split('\n')[0] || 'cannot list directory';
167
+ if (/can't find|no server running|session not found/i.test(stderr)) {
168
+ return new TmuxError(404, `session "${session}" not found`);
169
+ }
170
+ if (/path must be absolute/i.test(stderr)) return new TmuxError(400, 'path must be absolute');
171
+ if (/not a directory/i.test(stderr)) return new TmuxError(400, 'not a directory');
172
+ if (/Permission denied/i.test(stderr)) return new TmuxError(403, 'permission denied');
173
+ if (/outside the session working directory/i.test(stderr)) {
174
+ return new TmuxError(403, 'path is outside the session working directory');
175
+ }
176
+ if (/No such file|not found|realpath:/i.test(stderr)) return new TmuxError(404, 'path not found');
177
+ return new TmuxError(502, first);
178
+ }
179
+
180
+ async function remoteSessionDirectory(
181
+ t: Target,
182
+ session: string,
183
+ requestedPath?: string,
184
+ ): Promise<SessionDirectory> {
185
+ if (requestedPath !== undefined && !requestedPath.startsWith('/')) {
186
+ throw new TmuxError(400, 'path must be absolute');
187
+ }
188
+ const r = await run(t, [
189
+ 'sh',
190
+ '-c',
191
+ SESSION_DIRECTORY_SCRIPT,
192
+ 'tmuxes-session-directory',
193
+ session,
194
+ requestedPath ?? '',
195
+ ]);
196
+ if (r.code !== 0) throw sessionDirectoryError(session, r.stderr);
197
+
198
+ const firstSep = r.stdout.indexOf(NUL);
199
+ const secondSep = firstSep < 0 ? -1 : r.stdout.indexOf(NUL, firstSep + 1);
200
+ if (firstSep < 0 || secondSep < 0) throw new TmuxError(502, 'cannot parse directory listing');
201
+
202
+ const cwd = r.stdout.slice(0, firstSep);
203
+ const path = r.stdout.slice(firstSep + 1, secondSep);
204
+ const entries = parseRemoteEntries(r.stdout.slice(secondSep + 1));
205
+ return { cwd, path, entries: sortEntries(entries) };
206
+ }
207
+
208
+ export async function listSessionDirectory(
209
+ t: Target,
210
+ session: string,
211
+ requestedPath?: string,
212
+ ): Promise<SessionDirectory> {
213
+ if (t.kind !== 'local') return remoteSessionDirectory(t, session, requestedPath);
214
+
215
+ const cwd = await getSessionCwd(t, session);
216
+ const path = await resolveScopedPath(t, cwd, requestedPath ?? cwd);
217
+ return { cwd, path, entries: await listDirectory(t, path) };
218
+ }
219
+
220
+ /** List a directory ON THE TARGET. Local uses fs; wsl/ssh shell out to `ls`. */
221
+ export async function listDirectory(t: Target, path: string): Promise<FileEntry[]> {
222
+ if (t.kind === 'local') {
223
+ let dirents;
224
+ try {
225
+ dirents = await fsp.readdir(path, { withFileTypes: true });
226
+ } catch (e) {
227
+ throw fsError(e, path);
228
+ }
229
+ const entries: FileEntry[] = [];
230
+ for (const d of dirents) {
231
+ if (d.name.startsWith('.') && (d.name === '.' || d.name === '..')) continue;
232
+ let isDir = d.isDirectory();
233
+ if (d.isSymbolicLink()) {
234
+ // Resolve symlinks so linked dirs are navigable.
235
+ try {
236
+ const st = await fsp.stat(path.replace(/\/$/, '') + '/' + d.name);
237
+ isDir = st.isDirectory();
238
+ } catch {
239
+ isDir = false;
240
+ }
241
+ }
242
+ entries.push({ name: d.name, type: isDir ? 'dir' : 'file' });
243
+ }
244
+ return sortEntries(entries);
245
+ }
246
+
247
+ // wsl/ssh: `ls -Ap1 -- <path>` → one per line, dirs end with "/".
248
+ const r = await run(t, ['ls', '-Ap1', '--', path]);
249
+ if (r.code !== 0) {
250
+ if (/No such file|not found/i.test(r.stderr)) throw new TmuxError(404, 'directory not found');
251
+ if (/Permission denied/i.test(r.stderr)) throw new TmuxError(403, 'permission denied');
252
+ throw new TmuxError(502, r.stderr.trim().split('\n')[0] || 'cannot list directory');
253
+ }
254
+ return sortEntries(parseRemoteEntries(r.stdout));
255
+ }
256
+
257
+ /** Read a (capped) file preview ON THE TARGET. */
258
+ export async function readFilePreview(t: Target, path: string): Promise<FilePreview> {
259
+ if (t.kind === 'local') {
260
+ let fh;
261
+ try {
262
+ fh = await fsp.open(path, 'r');
263
+ } catch (e) {
264
+ throw fsError(e, path);
265
+ }
266
+ try {
267
+ const st = await fh.stat();
268
+ if (st.isDirectory()) throw new TmuxError(400, 'path is a directory');
269
+ const cap = FILE_PREVIEW_CAP;
270
+ const buf = Buffer.alloc(Math.min(cap, Number(st.size)));
271
+ const { bytesRead } = await fh.read(buf, 0, buf.length, 0);
272
+ const slice = buf.subarray(0, bytesRead);
273
+ return {
274
+ path,
275
+ content: slice.toString('utf8'),
276
+ truncated: st.size > cap,
277
+ binary: slice.includes(0),
278
+ };
279
+ } finally {
280
+ await fh.close();
281
+ }
282
+ }
283
+
284
+ // wsl/ssh: read up to cap+1 bytes; if we got cap+1 the file is larger.
285
+ const r = await run(t, ['head', '-c', String(FILE_PREVIEW_CAP + 1), '--', path]);
286
+ if (r.code !== 0) {
287
+ if (/No such file|not found/i.test(r.stderr)) throw new TmuxError(404, 'file not found');
288
+ if (/Is a directory/i.test(r.stderr)) throw new TmuxError(400, 'path is a directory');
289
+ if (/Permission denied/i.test(r.stderr)) throw new TmuxError(403, 'permission denied');
290
+ throw new TmuxError(502, r.stderr.trim().split('\n')[0] || 'cannot read file');
291
+ }
292
+ const truncated = r.stdout.length > FILE_PREVIEW_CAP;
293
+ const content = truncated ? r.stdout.slice(0, FILE_PREVIEW_CAP) : r.stdout;
294
+ return { path, content, truncated, binary: content.includes(NUL) };
295
+ }
296
+
297
+ /** Overwrite a file ON THE TARGET with `content` (UTF-8). Local uses fs;
298
+ * wsl/ssh pipe the bytes into `tee -- <path>` over stdin (tee truncates). */
299
+ export async function writeFile(t: Target, path: string, content: string): Promise<void> {
300
+ if (t.kind === 'local') {
301
+ try {
302
+ await fsp.writeFile(path, content, 'utf8');
303
+ } catch (e) {
304
+ throw fsError(e, path);
305
+ }
306
+ return;
307
+ }
308
+
309
+ // `tee` opens the file with O_TRUNC|O_CREAT, so shorter content can't leave a
310
+ // stale tail. Its stdout echo of the content is ignored.
311
+ const r = await runTargetCommand(t, (opts) => commandArgv(t, ['tee', '--', path], opts), {
312
+ timeoutMs: REMOTE_TIMEOUT_MS,
313
+ input: content,
314
+ });
315
+ if (r.code !== 0) {
316
+ if (/No such file|not found/i.test(r.stderr)) throw new TmuxError(404, 'directory not found');
317
+ if (/Is a directory/i.test(r.stderr)) throw new TmuxError(400, 'path is a directory');
318
+ if (/Permission denied/i.test(r.stderr)) throw new TmuxError(403, 'permission denied');
319
+ throw new TmuxError(502, r.stderr.trim().split('\n')[0] || 'cannot write file');
320
+ }
321
+ }
322
+
323
+ function fsError(e: unknown, path: string): TmuxError {
324
+ const code = (e as NodeJS.ErrnoException)?.code;
325
+ if (code === 'ENOENT') return new TmuxError(404, `not found: ${path}`);
326
+ if (code === 'EACCES') return new TmuxError(403, `permission denied: ${path}`);
327
+ if (code === 'ENOTDIR') return new TmuxError(400, `not a directory: ${path}`);
328
+ if (code === 'EISDIR') return new TmuxError(400, `path is a directory: ${path}`);
329
+ return new TmuxError(502, e instanceof Error ? e.message : 'filesystem error');
330
+ }
@@ -0,0 +1,114 @@
1
+ import { promises as fsp } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join, dirname } from 'node:path';
4
+ import { config } from './config.js';
5
+ import { sshClientArgs, sshQuote } from './tmux/builder.js';
6
+ import type { Target } from './targets.js';
7
+ import { TmuxError } from './tmux/sessions.js';
8
+ import { runTargetCommand } from './targetCommand.js';
9
+
10
+ /**
11
+ * Sidebar folder organization, stored ON THE TARGET so it follows the cluster:
12
+ * every client connecting to the same target reads/writes the same file, so the
13
+ * folder tree syncs across browsers and machines (like the tmux sessions do).
14
+ *
15
+ * Path: $HOME/.config/tmuxes/folders.json on the target.
16
+ * - local / winlocal → the server's own filesystem (fs).
17
+ * - ssh → the remote $HOME (ssh runs commands from $HOME).
18
+ * - wsl → the distro's $HOME (`wsl --cd ~`).
19
+ */
20
+
21
+ const REL = '.config/tmuxes/folders.json';
22
+ const REMOTE_TIMEOUT_MS = 15_000;
23
+ const MAX_BYTES = 500_000;
24
+
25
+ export interface FolderPayload {
26
+ folders: unknown[];
27
+ assign: Record<string, unknown>;
28
+ }
29
+
30
+ const EMPTY: FolderPayload = { folders: [], assign: {} };
31
+
32
+ function localPath(): string {
33
+ return join(homedir(), '.config', 'tmuxes', 'folders.json');
34
+ }
35
+
36
+ function remoteArgv(
37
+ t: Target,
38
+ script: string,
39
+ opts: { multiplex?: boolean } = {},
40
+ ): { file: string; args: string[] } {
41
+ if (t.kind === 'wsl') {
42
+ // --cd ~ → run from the distro home; sh -c handles the redirections.
43
+ return { file: 'wsl.exe', args: ['-d', t.distro ?? '', '--cd', '~', '--exec', 'sh', '-c', script] };
44
+ }
45
+ // ssh: wrap in `sh -c '<script>'` so it works regardless of the remote login
46
+ // shell, and runs from the default cwd ($HOME).
47
+ return {
48
+ file: 'ssh',
49
+ args: [
50
+ ...sshClientArgs(t, {
51
+ batchMode: true,
52
+ connectTimeout: config.ssh.connectTimeoutMgmt,
53
+ multiplex: opts.multiplex,
54
+ }).slice(1),
55
+ 'sh',
56
+ '-c',
57
+ sshQuote(script),
58
+ ],
59
+ };
60
+ }
61
+
62
+ function runRemote(t: Target, script: string, input?: string) {
63
+ return runTargetCommand(t, (opts) => remoteArgv(t, script, opts), {
64
+ timeoutMs: REMOTE_TIMEOUT_MS,
65
+ input,
66
+ });
67
+ }
68
+
69
+ async function readRaw(t: Target): Promise<string> {
70
+ if (t.kind === 'local' || t.kind === 'winlocal') {
71
+ try {
72
+ return await fsp.readFile(localPath(), 'utf8');
73
+ } catch (e) {
74
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') return '';
75
+ throw new TmuxError(502, 'cannot read folders');
76
+ }
77
+ }
78
+ // `2>/dev/null` so a missing file produces empty stdout + no stderr; a real
79
+ // transport failure (ssh down) prints to stderr → surfaced as 502.
80
+ const r = await runRemote(t, `cat ${REL} 2>/dev/null`);
81
+ if (r.code === 0) return r.stdout;
82
+ if (r.stderr.trim()) throw new TmuxError(502, r.stderr.trim().split('\n')[0]);
83
+ return ''; // missing file
84
+ }
85
+
86
+ async function writeRaw(t: Target, raw: string): Promise<void> {
87
+ if (t.kind === 'local' || t.kind === 'winlocal') {
88
+ await fsp.mkdir(dirname(localPath()), { recursive: true });
89
+ await fsp.writeFile(localPath(), raw, 'utf8');
90
+ return;
91
+ }
92
+ const r = await runRemote(t, `mkdir -p .config/tmuxes && cat > ${REL}`, raw);
93
+ if (r.code !== 0) throw new TmuxError(502, r.stderr.trim().split('\n')[0] || 'cannot write folders');
94
+ }
95
+
96
+ export async function readFolders(t: Target): Promise<FolderPayload> {
97
+ const raw = await readRaw(t);
98
+ if (!raw.trim()) return EMPTY;
99
+ try {
100
+ const o = JSON.parse(raw) as Partial<FolderPayload>;
101
+ if (o && Array.isArray(o.folders) && o.assign && typeof o.assign === 'object') {
102
+ return { folders: o.folders, assign: o.assign as Record<string, unknown> };
103
+ }
104
+ } catch {
105
+ /* corrupt file → treat as empty */
106
+ }
107
+ return EMPTY;
108
+ }
109
+
110
+ export async function writeFolders(t: Target, payload: FolderPayload): Promise<void> {
111
+ const raw = JSON.stringify({ folders: payload.folders, assign: payload.assign });
112
+ if (Buffer.byteLength(raw, 'utf8') > MAX_BYTES) throw new TmuxError(413, 'folders payload too large');
113
+ await writeRaw(t, raw);
114
+ }