tmux-ide 2.6.0 → 2.6.1

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 (209) hide show
  1. package/bin/cli.js +13 -5
  2. package/bin/cli.ts +1 -1
  3. package/bunfig.toml +4 -0
  4. package/package.json +11 -3
  5. package/packages/contracts/package.json +22 -0
  6. package/packages/contracts/src/__tests__/ide-config.test.ts +46 -0
  7. package/packages/contracts/src/__tests__/terminals.test.ts +87 -0
  8. package/packages/contracts/src/actions-contract.ts +310 -0
  9. package/packages/contracts/src/actions-errors.ts +41 -0
  10. package/packages/contracts/src/domain.ts +36 -0
  11. package/packages/contracts/src/ide-config.ts +170 -0
  12. package/packages/contracts/src/index.ts +24 -0
  13. package/packages/contracts/src/lib-internal/auth.ts +13 -0
  14. package/packages/contracts/src/lib-internal/hq.ts +38 -0
  15. package/packages/contracts/src/terminals.ts +116 -0
  16. package/packages/contracts/src/tmux.ts +60 -0
  17. package/packages/contracts/src/workspace.ts +67 -0
  18. package/packages/daemon/src/agent-explain.ts +270 -0
  19. package/packages/daemon/src/attach.ts +20 -0
  20. package/packages/daemon/src/bin.ts +4 -0
  21. package/packages/daemon/src/canonical.ts +7 -0
  22. package/packages/daemon/src/cli.ts +499 -0
  23. package/packages/daemon/src/command-center/actions/contract.ts +2 -0
  24. package/packages/daemon/src/command-center/actions/dispatcher.ts +137 -0
  25. package/packages/daemon/src/command-center/actions/errors.ts +105 -0
  26. package/packages/daemon/src/command-center/actions/handlers/_project-context.ts +30 -0
  27. package/packages/daemon/src/command-center/actions/handlers/_resolve-project.ts +78 -0
  28. package/packages/daemon/src/command-center/actions/handlers/app-set-remote-access.ts +118 -0
  29. package/packages/daemon/src/command-center/actions/handlers/config-actions.ts +113 -0
  30. package/packages/daemon/src/command-center/actions/handlers/daemon-shutdown.ts +38 -0
  31. package/packages/daemon/src/command-center/actions/handlers/project-activate.ts +30 -0
  32. package/packages/daemon/src/command-center/actions/handlers/project-launch.ts +70 -0
  33. package/packages/daemon/src/command-center/actions/handlers/project-open-terminal.ts +87 -0
  34. package/packages/daemon/src/command-center/actions/handlers/project-restart.ts +38 -0
  35. package/packages/daemon/src/command-center/actions/handlers/project-stop.ts +62 -0
  36. package/packages/daemon/src/command-center/actions/handlers/terminal-respawn.ts +119 -0
  37. package/packages/daemon/src/command-center/actions/handlers/terminal-stop.ts +35 -0
  38. package/packages/daemon/src/command-center/actions/registry.ts +149 -0
  39. package/packages/daemon/src/command-center/discovery.ts +96 -0
  40. package/packages/daemon/src/command-center/index.ts +31 -0
  41. package/packages/daemon/src/command-center/schemas.ts +85 -0
  42. package/packages/daemon/src/command-center/server.ts +1260 -0
  43. package/packages/daemon/src/command-center/ws-events.ts +316 -0
  44. package/packages/daemon/src/config.ts +549 -0
  45. package/packages/daemon/src/detect.ts +248 -0
  46. package/packages/daemon/src/doctor.ts +242 -0
  47. package/packages/daemon/src/embed.ts +5 -0
  48. package/packages/daemon/src/index.ts +12 -0
  49. package/packages/daemon/src/init.ts +211 -0
  50. package/packages/daemon/src/inspect.ts +178 -0
  51. package/packages/daemon/src/js-yaml.d.ts +10 -0
  52. package/packages/daemon/src/launch.ts +349 -0
  53. package/packages/daemon/src/lib/active-projects.ts +49 -0
  54. package/packages/daemon/src/lib/agent-discovery.ts +121 -0
  55. package/packages/daemon/src/lib/app-config.ts +336 -0
  56. package/packages/daemon/src/lib/app-settings.ts +53 -0
  57. package/packages/daemon/src/lib/auth/auth-service.ts +227 -0
  58. package/packages/daemon/src/lib/auth/middleware.ts +56 -0
  59. package/packages/daemon/src/lib/auth/types.ts +2 -0
  60. package/packages/daemon/src/lib/auth-token.ts +5 -0
  61. package/packages/daemon/src/lib/authorship.ts +280 -0
  62. package/packages/daemon/src/lib/canonical-daemon.ts +122 -0
  63. package/packages/daemon/src/lib/cli-action-bridge.ts +216 -0
  64. package/packages/daemon/src/lib/daemon-embed.ts +782 -0
  65. package/packages/daemon/src/lib/daemon-watchdog.ts +111 -0
  66. package/packages/daemon/src/lib/daemon.ts +79 -0
  67. package/packages/daemon/src/lib/dot-path.ts +17 -0
  68. package/packages/daemon/src/lib/errors.ts +67 -0
  69. package/packages/daemon/src/lib/filesystem-browser.ts +292 -0
  70. package/packages/daemon/src/lib/launch-plan.ts +90 -0
  71. package/packages/daemon/src/lib/log.ts +134 -0
  72. package/packages/daemon/src/lib/output.ts +76 -0
  73. package/packages/daemon/src/lib/project-init-runner.ts +150 -0
  74. package/packages/daemon/src/lib/project-inspect.ts +80 -0
  75. package/packages/daemon/src/lib/project-onboard.ts +149 -0
  76. package/packages/daemon/src/lib/project-probe.ts +92 -0
  77. package/packages/daemon/src/lib/project-registry.ts +296 -0
  78. package/packages/daemon/src/lib/session-monitor.ts +122 -0
  79. package/packages/daemon/src/lib/session-options.ts +100 -0
  80. package/packages/daemon/src/lib/shell.ts +8 -0
  81. package/packages/daemon/src/lib/sizes.ts +36 -0
  82. package/packages/daemon/src/lib/skill-sync.ts +155 -0
  83. package/packages/daemon/src/lib/slugify.ts +10 -0
  84. package/packages/daemon/src/lib/terminals-store.ts +125 -0
  85. package/packages/daemon/src/lib/update-check.ts +298 -0
  86. package/packages/daemon/src/lib/update.ts +158 -0
  87. package/packages/daemon/src/lib/workspace-registry.ts +229 -0
  88. package/packages/daemon/src/lib/worktree.ts +289 -0
  89. package/packages/daemon/src/lib/yaml-io.ts +27 -0
  90. package/packages/daemon/src/ls.ts +40 -0
  91. package/packages/daemon/src/restart.ts +24 -0
  92. package/packages/daemon/src/restore.ts +514 -0
  93. package/packages/daemon/src/schemas/domain.ts +2 -0
  94. package/packages/daemon/src/schemas/filesystem.ts +34 -0
  95. package/packages/daemon/src/schemas/ide-config.ts +2 -0
  96. package/packages/daemon/src/schemas/index.ts +59 -0
  97. package/packages/daemon/src/schemas/inspect.ts +67 -0
  98. package/packages/daemon/src/schemas/registry.ts +55 -0
  99. package/packages/daemon/src/schemas/ws-events.ts +135 -0
  100. package/packages/daemon/src/send.ts +171 -0
  101. package/packages/daemon/src/server/README.md +15 -0
  102. package/packages/daemon/src/server/index.ts +74 -0
  103. package/packages/daemon/src/server/pty-bridge.ts +532 -0
  104. package/packages/daemon/src/server/standalone.ts +18 -0
  105. package/packages/daemon/src/server/ws-route.ts +483 -0
  106. package/packages/daemon/src/status.ts +59 -0
  107. package/packages/daemon/src/stop.ts +28 -0
  108. package/packages/daemon/src/terminal/NodePtyAdapter.ts +271 -0
  109. package/packages/daemon/src/terminal/PtyAdapter.ts +140 -0
  110. package/packages/daemon/src/terminal/README.md +92 -0
  111. package/packages/daemon/src/tui/chrome/cheatsheet.ts +260 -0
  112. package/packages/daemon/src/tui/chrome/chip.ts +30 -0
  113. package/packages/daemon/src/tui/chrome/events.ts +119 -0
  114. package/packages/daemon/src/tui/chrome/kitty-keys.ts +55 -0
  115. package/packages/daemon/src/tui/chrome/menu.ts +289 -0
  116. package/packages/daemon/src/tui/chrome/notify.ts +195 -0
  117. package/packages/daemon/src/tui/chrome/panels.ts +111 -0
  118. package/packages/daemon/src/tui/chrome/sidebar.ts +222 -0
  119. package/packages/daemon/src/tui/chrome/snapshot.ts +425 -0
  120. package/packages/daemon/src/tui/chrome/statusline.ts +595 -0
  121. package/packages/daemon/src/tui/chrome/updater.ts +428 -0
  122. package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
  123. package/packages/daemon/src/tui/compiled.ts +113 -0
  124. package/packages/daemon/src/tui/detect/classify.ts +193 -0
  125. package/packages/daemon/src/tui/detect/manifest-loader.ts +192 -0
  126. package/packages/daemon/src/tui/detect/manifest.ts +184 -0
  127. package/packages/daemon/src/tui/detect/manifests.ts +226 -0
  128. package/packages/daemon/src/tui/detect/process-tree.ts +197 -0
  129. package/packages/daemon/src/tui/detect/snapshot.ts +70 -0
  130. package/packages/daemon/src/tui/integrations/claude.ts +176 -0
  131. package/packages/daemon/src/tui/integrations/offer.ts +145 -0
  132. package/packages/daemon/src/tui/main.ts +70 -0
  133. package/packages/daemon/src/tui/mirror/control-client.ts +143 -0
  134. package/packages/daemon/src/tui/mirror/control.ts +97 -0
  135. package/packages/daemon/src/tui/mirror/pane-mirror.ts +81 -0
  136. package/packages/daemon/src/tui/mirror/viewer.tsx +166 -0
  137. package/packages/daemon/src/tui/team/CONTROL.md +50 -0
  138. package/packages/daemon/src/tui/team/entry.ts +11 -0
  139. package/packages/daemon/src/tui/team/fuzzy.ts +133 -0
  140. package/packages/daemon/src/tui/team/home.ts +170 -0
  141. package/packages/daemon/src/tui/team/index.tsx +1521 -0
  142. package/packages/daemon/src/tui/team/input.ts +34 -0
  143. package/packages/daemon/src/tui/team/keymap.ts +127 -0
  144. package/packages/daemon/src/tui/team/mouse.ts +29 -0
  145. package/packages/daemon/src/tui/team/nav.ts +31 -0
  146. package/packages/daemon/src/tui/team/preview.ts +34 -0
  147. package/packages/daemon/src/tui/team/projects.ts +191 -0
  148. package/packages/daemon/src/tui/team/report.ts +73 -0
  149. package/packages/daemon/src/tui/team/sessions.ts +344 -0
  150. package/packages/daemon/src/tui/team/tree.ts +62 -0
  151. package/packages/daemon/src/types.ts +13 -0
  152. package/packages/daemon/src/ui/index.ts +32 -0
  153. package/packages/daemon/src/ui/terminal/index.ts +9 -0
  154. package/packages/daemon/src/ui/types.ts +91 -0
  155. package/packages/daemon/src/ui/web/base.css +80 -0
  156. package/packages/daemon/src/ui/web/components/Box.tsx +59 -0
  157. package/packages/daemon/src/ui/web/components/Input.tsx +32 -0
  158. package/packages/daemon/src/ui/web/components/ScrollBox.tsx +60 -0
  159. package/packages/daemon/src/ui/web/components/Text.tsx +28 -0
  160. package/packages/daemon/src/ui/web/hooks.ts +106 -0
  161. package/packages/daemon/src/ui/web/index.ts +27 -0
  162. package/packages/daemon/src/ui/web/render.ts +77 -0
  163. package/packages/daemon/src/ui/web/utils/color.ts +27 -0
  164. package/packages/daemon/src/validate.ts +217 -0
  165. package/packages/daemon/src/widgets/changes/README.md +3 -0
  166. package/packages/daemon/src/widgets/changes/index.tsx +691 -0
  167. package/packages/daemon/src/widgets/config/README.md +3 -0
  168. package/packages/daemon/src/widgets/config/index.tsx +481 -0
  169. package/packages/daemon/src/widgets/explorer/README.md +3 -0
  170. package/packages/daemon/src/widgets/explorer/breadcrumbs.tsx +77 -0
  171. package/packages/daemon/src/widgets/explorer/footer.tsx +20 -0
  172. package/packages/daemon/src/widgets/explorer/header.tsx +23 -0
  173. package/packages/daemon/src/widgets/explorer/index.tsx +456 -0
  174. package/packages/daemon/src/widgets/explorer/tree-model.ts +103 -0
  175. package/packages/daemon/src/widgets/explorer/tree.tsx +165 -0
  176. package/packages/daemon/src/widgets/lib/config-model.ts +116 -0
  177. package/packages/daemon/src/widgets/lib/files.ts +88 -0
  178. package/packages/daemon/src/widgets/lib/git.ts +88 -0
  179. package/packages/daemon/src/widgets/lib/grammar.ts +126 -0
  180. package/packages/daemon/src/widgets/lib/help-overlay.tsx +101 -0
  181. package/packages/daemon/src/widgets/lib/pane-comms.ts +209 -0
  182. package/packages/daemon/src/widgets/lib/theme.ts +194 -0
  183. package/packages/daemon/src/widgets/lib/watcher.ts +132 -0
  184. package/packages/daemon/src/widgets/preview/README.md +3 -0
  185. package/packages/daemon/src/widgets/preview/index.tsx +416 -0
  186. package/packages/daemon/src/widgets/resolve.ts +121 -0
  187. package/packages/daemon/src/widgets/setup/README.md +3 -0
  188. package/packages/daemon/src/widgets/setup/agent-naming.tsx +112 -0
  189. package/packages/daemon/src/widgets/setup/config-tree.tsx +246 -0
  190. package/packages/daemon/src/widgets/setup/detect-panel.tsx +72 -0
  191. package/packages/daemon/src/widgets/setup/field-editor.tsx +265 -0
  192. package/packages/daemon/src/widgets/setup/footer.tsx +107 -0
  193. package/packages/daemon/src/widgets/setup/index.tsx +341 -0
  194. package/packages/daemon/src/widgets/setup/layout-picker.tsx +96 -0
  195. package/packages/daemon/src/widgets/setup/orchestrator-panel.tsx +200 -0
  196. package/packages/daemon/src/widgets/setup/review-panel.tsx +140 -0
  197. package/packages/daemon/src/widgets/setup/setup-model.ts +188 -0
  198. package/packages/daemon/src/widgets/sidebar/index.tsx +527 -0
  199. package/packages/tmux-bridge/package.json +22 -0
  200. package/packages/tmux-bridge/src/errors.ts +28 -0
  201. package/packages/tmux-bridge/src/index.ts +31 -0
  202. package/packages/tmux-bridge/src/monitor.ts +77 -0
  203. package/packages/tmux-bridge/src/panes.ts +136 -0
  204. package/packages/tmux-bridge/src/runner.test.ts +501 -0
  205. package/packages/tmux-bridge/src/runner.ts +91 -0
  206. package/packages/tmux-bridge/src/sessions.ts +126 -0
  207. package/packages/tmux-bridge/src/targeting.test.ts +107 -0
  208. package/packages/tmux-bridge/src/targeting.ts +90 -0
  209. package/scripts/postinstall.js +26 -2
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Orphaned legacy crash-recovery watchdog for the old per-session daemon.
3
+ *
4
+ * The canonical daemon is now owned by Electron or `tmux-ide --headless`.
5
+ * No supported code path should spawn this file.
6
+ *
7
+ * Minimal crash-recovery watchdog for the tmux-ide daemon.
8
+ * Respawns daemon.ts on unexpected exits with exponential backoff.
9
+ * Gives up after 5 crashes within 60 seconds.
10
+ *
11
+ * Entry: bun src/lib/daemon-watchdog.ts <session> [port]
12
+ *
13
+ * ZERO imports from business logic — this file must never crash
14
+ * due to broken application code.
15
+ */
16
+
17
+ import { spawn, type ChildProcess } from "node:child_process";
18
+ import { resolve, dirname } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { appendFileSync, mkdirSync, existsSync } from "node:fs";
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+ const sessionArg = process.argv[2];
24
+ const port = process.argv[3] ?? "0";
25
+
26
+ if (!sessionArg) {
27
+ console.error("Usage: daemon-watchdog.ts <session> [port]");
28
+ process.exit(1);
29
+ }
30
+
31
+ const session: string = sessionArg;
32
+
33
+ const daemonScript = resolve(__dirname, "daemon.ts");
34
+
35
+ let backoffMs = 1000;
36
+ let crashCount = 0;
37
+ let lastCrashTime = 0;
38
+ let child: ChildProcess | null = null;
39
+
40
+ function logError(msg: string): void {
41
+ try {
42
+ const tasksDir = resolve(process.cwd(), ".tasks");
43
+ if (!existsSync(tasksDir)) mkdirSync(tasksDir, { recursive: true });
44
+ const entry = JSON.stringify({
45
+ timestamp: new Date().toISOString(),
46
+ type: "error",
47
+ source: "daemon-watchdog",
48
+ message: msg,
49
+ });
50
+ appendFileSync(resolve(tasksDir, "events.log"), entry + "\n");
51
+ } catch {
52
+ // If even logging fails, silently continue
53
+ }
54
+ }
55
+
56
+ function spawnDaemon(): void {
57
+ // The daemon MUST run under node/tsx, never bun: node-pty's `onData`
58
+ // callback never fires under bun (PTY spawns + exits silently). See T085
59
+ // for the diagnostic story and T087 for the PtyAdapter abstraction this
60
+ // pins down. Prod swaps this for `node dist/lib/daemon.js`.
61
+ child = spawn("tsx", [daemonScript, session, port], {
62
+ cwd: process.cwd(),
63
+ stdio: ["ignore", "pipe", "pipe"],
64
+ });
65
+
66
+ // Pipe child stdout/stderr to our own so logs are visible in the detached process
67
+ child.stdout?.pipe(process.stdout);
68
+ child.stderr?.pipe(process.stderr);
69
+
70
+ child.on("exit", (code) => {
71
+ child = null;
72
+
73
+ // Clean shutdown (code 0) — don't respawn
74
+ if (code === 0) {
75
+ process.exit(0);
76
+ }
77
+
78
+ const now = Date.now();
79
+
80
+ // Reset crash counter if the last crash was more than 60s ago
81
+ if (now - lastCrashTime > 60_000) {
82
+ crashCount = 0;
83
+ backoffMs = 1000;
84
+ }
85
+
86
+ crashCount++;
87
+ lastCrashTime = now;
88
+
89
+ if (crashCount > 5) {
90
+ logError(`Daemon crashed ${crashCount} times in 60s — giving up`);
91
+ process.exit(1);
92
+ }
93
+
94
+ logError(`Daemon exited with code ${code}, respawning in ${backoffMs}ms`);
95
+ setTimeout(spawnDaemon, backoffMs);
96
+ backoffMs = Math.min(backoffMs * 2, 30_000);
97
+ });
98
+ }
99
+
100
+ // Forward termination signals to the child daemon
101
+ process.on("SIGTERM", () => {
102
+ if (child) child.kill("SIGTERM");
103
+ else process.exit(0);
104
+ });
105
+
106
+ process.on("SIGINT", () => {
107
+ if (child) child.kill("SIGTERM");
108
+ else process.exit(0);
109
+ });
110
+
111
+ spawnDaemon();
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Orphaned legacy daemon entrypoint.
3
+ *
4
+ * The per-session daemon spawn path is removed. This file is retained only
5
+ * for stale external invocations and starts the canonical headless daemon in
6
+ * this process. Prefer `tmux-ide --headless`.
7
+ */
8
+
9
+ import { fileURLToPath } from "node:url";
10
+ import { resolve } from "node:path";
11
+ import { startEmbeddedDaemon, type EmbeddedDaemonHandle } from "./daemon-embed.ts";
12
+
13
+ async function main(): Promise<void> {
14
+ // Accept the legacy `node daemon.ts <session> <port>` invocation: the
15
+ // first positional may be a session name OR (when omitted) a port. We
16
+ // sniff by digit-shape and forward both. Without this, an explicit
17
+ // session name was silently dropped on the floor and the daemon booted
18
+ // as `__embedded__` — leaving the workspace registry empty for live
19
+ // tmux sessions the operator clearly meant to expose.
20
+ const arg2 = process.argv[2];
21
+ const arg3 = process.argv[3];
22
+ const arg2IsPort = arg2 != null && /^\d+$/.test(arg2);
23
+ const sessionName = arg2IsPort ? undefined : arg2;
24
+ const rawPort = arg2IsPort ? arg2 : arg3;
25
+ const port = rawPort === undefined || rawPort === "0" ? undefined : Number.parseInt(rawPort, 10);
26
+ let handle: EmbeddedDaemonHandle;
27
+ try {
28
+ handle = await startEmbeddedDaemon({
29
+ port,
30
+ bindHostname: "127.0.0.1",
31
+ ...(sessionName ? { sessionName } : {}),
32
+ });
33
+ } catch (err) {
34
+ console.error("[daemon] failed to start:", err);
35
+ process.exit(1);
36
+ }
37
+
38
+ let stopping = false;
39
+ const exitAfterStop = (code: number): void => {
40
+ if (stopping) return;
41
+ stopping = true;
42
+ // Defer one tick so any awaiter of handle.stop() observes the resolved
43
+ // promise before we tear the process down.
44
+ process.nextTick(() => process.exit(code));
45
+ };
46
+ const shutdown = async (): Promise<void> => {
47
+ if (stopping) return;
48
+ try {
49
+ await handle.stop();
50
+ exitAfterStop(0);
51
+ } catch (err) {
52
+ console.error("[daemon] failed to stop:", err);
53
+ exitAfterStop(1);
54
+ }
55
+ };
56
+
57
+ // Wrap handle.stop so non-signal stop paths terminate the process. Without
58
+ // this, the canonical-daemon takeover action (POST /api/v2/action/daemon.shutdown
59
+ // from a competing launcher), the tick() that calls stopSelf when the
60
+ // tmux session disappears, and the remote-access restart backend all
61
+ // close the HTTP listener but leave the Node event loop spinning on
62
+ // task-store WAL timers and other handles. The visible symptom is a
63
+ // ghost daemon: process alive, port 6060 dead, takeover falls back to
64
+ // its 10s SIGTERM/SIGKILL deadline before the next launch can bind.
65
+ const origStop = handle.stop.bind(handle);
66
+ handle.stop = async (opts) => {
67
+ try {
68
+ await origStop(opts);
69
+ } finally {
70
+ exitAfterStop(0);
71
+ }
72
+ };
73
+
74
+ process.on("SIGTERM", () => void shutdown());
75
+ process.on("SIGINT", () => void shutdown());
76
+ }
77
+
78
+ const isMainModule = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
79
+ if (isMainModule) void main();
@@ -0,0 +1,17 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ export function getByPath(obj: Record<string, any>, path: string): unknown {
3
+ return path.split(".").reduce((o: any, k) => o?.[k], obj);
4
+ }
5
+
6
+ export function setByPath(obj: Record<string, any>, path: string, value: unknown): void {
7
+ const keys = path.split(".");
8
+ const last = keys.pop()!;
9
+ let i = 0;
10
+ const target = keys.reduce((o: any, k) => {
11
+ const nextKey = keys[i + 1] ?? last;
12
+ if (o[k] === undefined) o[k] = /^\d+$/.test(nextKey) ? [] : {};
13
+ i++;
14
+ return o[k];
15
+ }, obj);
16
+ target[last] = value;
17
+ }
@@ -0,0 +1,67 @@
1
+ export class IdeError extends Error {
2
+ code: string | undefined;
3
+ exitCode: number;
4
+
5
+ constructor(
6
+ message: string,
7
+ { code, exitCode = 1, cause }: { code?: string; exitCode?: number; cause?: Error } = {},
8
+ ) {
9
+ super(message, { cause });
10
+ this.name = "IdeError";
11
+ this.code = code;
12
+ this.exitCode = exitCode;
13
+ }
14
+
15
+ toJSON(): { error: string; code: string | undefined; cause?: string } {
16
+ const obj: { error: string; code: string | undefined; cause?: string } = {
17
+ error: this.message,
18
+ code: this.code,
19
+ };
20
+ if (this.cause) obj.cause = (this.cause as Error).message;
21
+ return obj;
22
+ }
23
+ }
24
+
25
+ export class ConfigError extends IdeError {
26
+ constructor(message: string, code: string, { cause }: { cause?: Error } = {}) {
27
+ super(message, { code, exitCode: 1, cause });
28
+ this.name = "ConfigError";
29
+ }
30
+ }
31
+
32
+ // TmuxError now lives in @tmux-ide/tmux-bridge. Re-exported here so the
33
+ // existing `import { TmuxError } from "./errors.ts"` paths keep working
34
+ // across the daemon. The bridge's class extends Error directly (not IdeError);
35
+ // the CLI's printCommandError handles both via duck-typing on `code`/`exitCode`.
36
+ export { TmuxError } from "@tmux-ide/tmux-bridge";
37
+
38
+ export class SessionError extends IdeError {
39
+ constructor(message: string, code: string, { cause }: { cause?: Error } = {}) {
40
+ super(message, { code, exitCode: 1, cause });
41
+ this.name = "SessionError";
42
+ }
43
+ }
44
+
45
+ export type DaemonStartupReason =
46
+ | "port_in_use"
47
+ | "port_invalid"
48
+ | "bind_failed"
49
+ | "tmux_session_missing"
50
+ | "canonical_already_running";
51
+
52
+ export class DaemonStartupError extends IdeError {
53
+ readonly reason: DaemonStartupReason;
54
+
55
+ constructor(message: string, reason: DaemonStartupReason, { cause }: { cause?: Error } = {}) {
56
+ super(message, { code: `DAEMON_${reason.toUpperCase()}`, exitCode: 1, cause });
57
+ this.name = "DaemonStartupError";
58
+ this.reason = reason;
59
+ }
60
+ }
61
+
62
+ export class DaemonShutdownError extends IdeError {
63
+ constructor(message: string, { cause }: { cause?: Error } = {}) {
64
+ super(message, { code: "DAEMON_SHUTDOWN_FAILED", exitCode: 1, cause });
65
+ this.name = "DaemonShutdownError";
66
+ }
67
+ }
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Filesystem browsing for the dashboard's directory picker.
3
+ *
4
+ * Two responsibilities:
5
+ * 1. Sandbox enforcement — the daemon refuses to list anything outside the
6
+ * user's home or the platform-conventional roots (/Users, /home,
7
+ * /Volumes). Never trust the client to limit paths.
8
+ * 2. Entry classification — directories first, then files; alphabetical
9
+ * within group; symlinks resolved once to determine target kind.
10
+ *
11
+ * The io functions are pluggable for tests so we don't need a full mock
12
+ * filesystem.
13
+ */
14
+
15
+ import { realpathSync, readdirSync, statSync, type Dirent } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { isAbsolute, join, resolve, sep } from "node:path";
18
+
19
+ import type { FilesystemBrowseResult, FilesystemEntry } from "../schemas/filesystem.ts";
20
+
21
+ /** Root directories the sandbox allows in addition to the user's home. */
22
+ const ALLOWED_PLATFORM_ROOTS = ["/Users", "/home", "/Volumes"] as const;
23
+
24
+ /** Per-entry stat budget. Slow network mounts get skipped silently. */
25
+ const STAT_BUDGET_MS = 100;
26
+
27
+ export class SandboxViolationError extends Error {
28
+ readonly code = "outside-sandbox";
29
+ constructor(path: string) {
30
+ super(`Path "${path}" is outside the allowed sandbox`);
31
+ this.name = "SandboxViolationError";
32
+ }
33
+ }
34
+
35
+ export class PathNotFoundError extends Error {
36
+ readonly code = "not-found";
37
+ constructor(path: string) {
38
+ super(`Path "${path}" does not exist`);
39
+ this.name = "PathNotFoundError";
40
+ }
41
+ }
42
+
43
+ export class InvalidPathError extends Error {
44
+ readonly code = "invalid-path";
45
+ constructor(message: string) {
46
+ super(message);
47
+ this.name = "InvalidPathError";
48
+ }
49
+ }
50
+
51
+ export interface BrowseIo {
52
+ /** `fs.realpathSync`. Throws if the path doesn't exist. */
53
+ realpath(path: string): string;
54
+ /** `fs.statSync`. Used to follow symlinks once when classifying. */
55
+ stat(path: string): { isDirectory(): boolean };
56
+ /** `fs.readdirSync(path, { withFileTypes: true })`. */
57
+ readdir(path: string): Dirent[];
58
+ /** `os.homedir()`. */
59
+ home(): string;
60
+ /** Wall-clock; injectable for tests and for the per-entry stat budget. */
61
+ now(): number;
62
+ }
63
+
64
+ /**
65
+ * Resolve the user's home directory. Honors `TMUX_IDE_HOME_OVERRIDE` so
66
+ * tests can pin a temp directory without depending on `os.homedir()` (some
67
+ * runtimes — bun in particular — cache the value at startup and do not
68
+ * pick up later `process.env.HOME` mutations).
69
+ */
70
+ function resolveHome(): string {
71
+ const override = process.env.TMUX_IDE_HOME_OVERRIDE;
72
+ if (override && override.trim().length > 0) return override;
73
+ return homedir();
74
+ }
75
+
76
+ const realIo: BrowseIo = {
77
+ realpath: realpathSync,
78
+ stat: statSync,
79
+ readdir: (path) => readdirSync(path, { withFileTypes: true }),
80
+ home: resolveHome,
81
+ now: () => Date.now(),
82
+ };
83
+
84
+ export interface BrowseInput {
85
+ /** Absolute path to list. Empty/missing → home. */
86
+ path?: string | null | undefined;
87
+ /** When true, return entries whose names start with `.`. */
88
+ showHidden?: boolean;
89
+ }
90
+
91
+ /**
92
+ * Validate an input path before passing to realpath. We reject obvious
93
+ * traversal attempts and non-absolute paths so error messages stay clean.
94
+ * `~` and `~/...` expand to the user's home directory so the dialog's
95
+ * "Home" quick-jump (and any caller that hands us a tilde-prefixed path)
96
+ * doesn't trip the absolute-path check.
97
+ */
98
+ function preflight(rawPath: string, io: BrowseIo): string {
99
+ const trimmed = rawPath.trim();
100
+ if (!trimmed) {
101
+ throw new InvalidPathError("Path must not be empty");
102
+ }
103
+ if (trimmed.includes("\0")) {
104
+ throw new InvalidPathError("Path contains a null byte");
105
+ }
106
+ let candidate = trimmed;
107
+ if (candidate === "~") {
108
+ candidate = io.home();
109
+ } else if (candidate.startsWith("~/")) {
110
+ candidate = join(io.home(), candidate.slice(2));
111
+ }
112
+ if (!isAbsolute(candidate)) {
113
+ throw new InvalidPathError("Path must be absolute");
114
+ }
115
+ // Collapse `..` and `.` early so the realpath call has a clean input.
116
+ return resolve(candidate);
117
+ }
118
+
119
+ /**
120
+ * Returns true iff `canonical` is the allowed root or strictly under it.
121
+ * Both arguments must be canonical (already realpath'd) and absolute.
122
+ */
123
+ function isUnderRoot(canonical: string, root: string): boolean {
124
+ if (canonical === root) return true;
125
+ const prefix = root.endsWith(sep) ? root : root + sep;
126
+ return canonical.startsWith(prefix);
127
+ }
128
+
129
+ /**
130
+ * Sandbox check: the canonical path must live inside the user's home OR
131
+ * one of the platform-conventional public roots. Anything else throws.
132
+ *
133
+ * Exposed for tests; do NOT skip this from the route handler.
134
+ */
135
+ export function assertInsideSandbox(canonical: string, home: string): void {
136
+ if (isUnderRoot(canonical, home)) return;
137
+ for (const root of ALLOWED_PLATFORM_ROOTS) {
138
+ if (isUnderRoot(canonical, root)) return;
139
+ }
140
+ throw new SandboxViolationError(canonical);
141
+ }
142
+
143
+ /**
144
+ * Compute the parent directory path for browse navigation. Returns null
145
+ * when the parent would escape the sandbox, so the UI can disable the
146
+ * back button.
147
+ */
148
+ export function computeParentPath(canonical: string, home: string): string | null {
149
+ // `path.resolve` collapses trailing separators; never returns an empty
150
+ // string, which means "/" maps to "/".
151
+ if (canonical === "/" || canonical === sep) return null;
152
+
153
+ const parent = resolve(canonical, "..");
154
+ if (parent === canonical) return null;
155
+
156
+ try {
157
+ assertInsideSandbox(parent, home);
158
+ } catch {
159
+ return null;
160
+ }
161
+ return parent;
162
+ }
163
+
164
+ /**
165
+ * Sort dirs first, files second; case-insensitive alphabetical within group.
166
+ * Stable: returns a new array, doesn't mutate input.
167
+ */
168
+ export function sortEntries(entries: ReadonlyArray<FilesystemEntry>): FilesystemEntry[] {
169
+ return [...entries].sort((a, b) => {
170
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
171
+ return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
172
+ });
173
+ }
174
+
175
+ interface ClassifyResult {
176
+ entry: FilesystemEntry | null;
177
+ }
178
+
179
+ /**
180
+ * Convert a single dirent to a `FilesystemEntry`. Symlinks are followed
181
+ * exactly once via `stat` so we can mark `isDir` correctly. Per-entry stat
182
+ * is bounded by `STAT_BUDGET_MS`; entries that exceed it are dropped.
183
+ *
184
+ * Exposed for tests.
185
+ */
186
+ export function classifyDirent(dirent: Dirent, fullPath: string, io: BrowseIo): ClassifyResult {
187
+ const isSymlink = dirent.isSymbolicLink();
188
+ if (!isSymlink) {
189
+ return {
190
+ entry: {
191
+ name: dirent.name,
192
+ fullPath,
193
+ isDir: dirent.isDirectory(),
194
+ isSymlink: false,
195
+ },
196
+ };
197
+ }
198
+
199
+ // Follow symlinks once. Slow network mounts get a short stat budget and
200
+ // skip silently if exceeded.
201
+ const start = io.now();
202
+ let isDir: boolean;
203
+ try {
204
+ const stat = io.stat(fullPath);
205
+ if (io.now() - start > STAT_BUDGET_MS) {
206
+ return { entry: null };
207
+ }
208
+ isDir = stat.isDirectory();
209
+ } catch {
210
+ // Broken symlink — keep it as a non-directory entry so the UI can
211
+ // still show it, but mark accordingly.
212
+ return {
213
+ entry: {
214
+ name: dirent.name,
215
+ fullPath,
216
+ isDir: false,
217
+ isSymlink: true,
218
+ },
219
+ };
220
+ }
221
+
222
+ return {
223
+ entry: {
224
+ name: dirent.name,
225
+ fullPath,
226
+ isDir,
227
+ isSymlink: true,
228
+ },
229
+ };
230
+ }
231
+
232
+ /**
233
+ * Browse a directory. Returns canonical path + entries + parent.
234
+ *
235
+ * @throws {InvalidPathError} Bad input (empty, relative, null bytes).
236
+ * @throws {PathNotFoundError} Path doesn't exist (realpath failed).
237
+ * @throws {SandboxViolationError} Path resolves outside the allowed sandbox.
238
+ */
239
+ export function browseDirectory(input: BrowseInput, io: BrowseIo = realIo): FilesystemBrowseResult {
240
+ const home = io.home();
241
+ const requestedPath = input.path?.trim() ? input.path.trim() : home;
242
+
243
+ let preflightPath: string;
244
+ try {
245
+ preflightPath = preflight(requestedPath, io);
246
+ } catch (err) {
247
+ if (err instanceof InvalidPathError) throw err;
248
+ throw new InvalidPathError("Invalid path");
249
+ }
250
+
251
+ let canonical: string;
252
+ try {
253
+ canonical = io.realpath(preflightPath);
254
+ } catch (err) {
255
+ const code = (err as NodeJS.ErrnoException).code;
256
+ if (code === "ENOENT" || code === "ENOTDIR") {
257
+ throw new PathNotFoundError(preflightPath);
258
+ }
259
+ throw err;
260
+ }
261
+
262
+ assertInsideSandbox(canonical, home);
263
+
264
+ let dirents: Dirent[];
265
+ try {
266
+ dirents = io.readdir(canonical);
267
+ } catch (err) {
268
+ const code = (err as NodeJS.ErrnoException).code;
269
+ if (code === "ENOENT") {
270
+ throw new PathNotFoundError(canonical);
271
+ }
272
+ if (code === "ENOTDIR") {
273
+ throw new InvalidPathError(`"${canonical}" is not a directory`);
274
+ }
275
+ throw err;
276
+ }
277
+
278
+ const showHidden = Boolean(input.showHidden);
279
+ const entries: FilesystemEntry[] = [];
280
+ for (const dirent of dirents) {
281
+ if (!showHidden && dirent.name.startsWith(".")) continue;
282
+ const fullPath = join(canonical, dirent.name);
283
+ const { entry } = classifyDirent(dirent, fullPath, io);
284
+ if (entry !== null) entries.push(entry);
285
+ }
286
+
287
+ return {
288
+ path: canonical,
289
+ parentPath: computeParentPath(canonical, home),
290
+ entries: sortEntries(entries),
291
+ };
292
+ }
@@ -0,0 +1,90 @@
1
+ import { resolve } from "node:path";
2
+ import type { Pane, Row, PaneAction } from "../types.ts";
3
+ import { shellEscape } from "./shell.ts";
4
+
5
+ export function buildPaneCommand(pane: Pane): string | null {
6
+ if (!pane.command) return null;
7
+ return pane.command;
8
+ }
9
+
10
+ export function collectPaneStartupPlan(
11
+ rows: Row[],
12
+ paneMap: string[][],
13
+ firstPanesOfRows: Set<string>,
14
+ dir: string,
15
+ ): { focusPane: string; paneActions: PaneAction[] } {
16
+ let focusPane = paneMap[0]![0]!;
17
+ const paneActions: PaneAction[] = [];
18
+
19
+ for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) {
20
+ const row = rows[rowIdx]!;
21
+ const panes = row.panes ?? [];
22
+
23
+ for (let paneIdx = 0; paneIdx < panes.length; paneIdx++) {
24
+ const pane = panes[paneIdx]!;
25
+ const tmuxPane = paneMap[rowIdx]![paneIdx]!;
26
+ // Derive @ide_role from pane config
27
+ let paneRole: string;
28
+ if (pane.role === "lead") {
29
+ paneRole = "lead";
30
+ } else if (pane.role === "teammate" || pane.role === "planner") {
31
+ paneRole = "teammate";
32
+ } else if (pane.type) {
33
+ paneRole = "widget";
34
+ } else {
35
+ paneRole = "shell";
36
+ }
37
+
38
+ // Derive @ide_type from pane config
39
+ let paneType: string;
40
+ if (pane.type) {
41
+ paneType = pane.type;
42
+ } else if (pane.command && /claude|codex/i.test(pane.command)) {
43
+ paneType = "agent";
44
+ } else {
45
+ paneType = "shell";
46
+ }
47
+
48
+ const action: PaneAction = {
49
+ targetPane: tmuxPane,
50
+ title: pane.title ?? null,
51
+ chdir: null,
52
+ exports: [],
53
+ command: null,
54
+ widgetType: pane.type ?? null,
55
+ widgetTarget: pane.target ?? null,
56
+ paneRole,
57
+ paneType,
58
+ };
59
+
60
+ if (pane.dir && firstPanesOfRows.has(tmuxPane)) {
61
+ action.chdir = resolve(dir, pane.dir);
62
+ }
63
+
64
+ if (pane.env && typeof pane.env === "object") {
65
+ action.exports = Object.entries(pane.env).map(
66
+ ([key, value]) => `export ${shellEscape(key)}=${shellEscape(String(value))}`,
67
+ );
68
+ }
69
+
70
+ let command = buildPaneCommand(pane);
71
+ // Inject --name flag into Claude/Codex commands so the agent
72
+ // sets its own pane title to match the configured name.
73
+ // This is agent-agnostic: any CLI that respects --name will work.
74
+ if (command && pane.title && /claude|codex/i.test(command) && !command.includes("--name")) {
75
+ command = `${command} --name ${shellEscape(pane.title)}`;
76
+ }
77
+ if (command) {
78
+ action.command = command;
79
+ }
80
+
81
+ if (pane.focus) {
82
+ focusPane = tmuxPane;
83
+ }
84
+
85
+ paneActions.push(action);
86
+ }
87
+ }
88
+
89
+ return { focusPane, paneActions };
90
+ }