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,158 @@
1
+ /**
2
+ * `tmux-ide update [--dry-run]` — act on the update the dock surfaces.
3
+ *
4
+ * The dock's `⬆ v<latest>` chip (see {@link ./update-check.ts}) tells you an
5
+ * update is out; this closes the loop with the ONE command that installs it. The
6
+ * catch is that "the command" depends on how tmux-ide was installed — a cloned
7
+ * dev checkout updates with `git pull`, a global install with its package
8
+ * manager (npm/pnpm/bun) — so the command DETECTS the install method from the
9
+ * running CLI's real path before printing (or, without `--dry-run`, running) it.
10
+ *
11
+ * The detection + rendering are PURE ({@link detectPackageManager},
12
+ * {@link planUpdate}, {@link renderPlan}), so they're unit-tested without a live
13
+ * filesystem; {@link findGitCheckoutRoot} and {@link runUpdate} are the io — the
14
+ * latter is the only place a real global install is ever spawned, and only when
15
+ * `--dry-run` is absent.
16
+ */
17
+ import { execSync } from "node:child_process";
18
+ import { existsSync } from "node:fs";
19
+ import { dirname, join } from "node:path";
20
+ import { getCurrentVersion, getUpdateStatus, isNewer } from "./update-check.ts";
21
+
22
+ /** The package managers a global install can come from. */
23
+ export type PackageManager = "npm" | "pnpm" | "bun";
24
+
25
+ /** The resolved way to update this install. */
26
+ export interface UpdatePlan {
27
+ /** `dev` = a git checkout (`git pull`); otherwise the global package manager. */
28
+ method: "dev" | PackageManager;
29
+ /** The shell command to run, or null for a dev checkout (manual `git pull`). */
30
+ command: string | null;
31
+ /** A short human note on WHY this method was chosen (the detection evidence). */
32
+ reason: string;
33
+ }
34
+
35
+ /** The exact global-install command per package manager. */
36
+ export const UPDATE_COMMANDS: Record<PackageManager, string> = {
37
+ npm: "npm install -g tmux-ide@latest",
38
+ pnpm: "pnpm add -g tmux-ide@latest",
39
+ bun: "bun add -g tmux-ide@latest",
40
+ };
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Pure
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /**
47
+ * PURE — infer the package manager from a global CLI path. The global bin lives
48
+ * under a manager-specific directory, so the path is the tell: a `bun`/`.bun`
49
+ * segment → bun, a `pnpm` segment → pnpm, else npm (the default global installer,
50
+ * incl. nvm's `.../node/vX/lib/node_modules/...`). Checked bun→pnpm→npm so the
51
+ * more specific segments win.
52
+ */
53
+ export function detectPackageManager(cliPath: string): PackageManager {
54
+ const p = cliPath.toLowerCase();
55
+ if (/(^|\/)\.?bun(\/|$)/.test(p)) return "bun";
56
+ if (p.includes("pnpm")) return "pnpm";
57
+ return "npm";
58
+ }
59
+
60
+ /**
61
+ * PURE — the {@link UpdatePlan} for a CLI at `cliPath`. When `gitRoot` is set (a
62
+ * `.git` was found at/above the CLI — a cloned checkout) the plan is a manual
63
+ * `git pull`; otherwise it's the detected package manager's global-install
64
+ * command.
65
+ */
66
+ export function planUpdate(input: { cliPath: string; gitRoot: string | null }): UpdatePlan {
67
+ if (input.gitRoot) {
68
+ return { method: "dev", command: null, reason: `git checkout at ${input.gitRoot}` };
69
+ }
70
+ const pm = detectPackageManager(input.cliPath);
71
+ return {
72
+ method: pm,
73
+ command: UPDATE_COMMANDS[pm],
74
+ reason: `global ${pm} install (${input.cliPath})`,
75
+ };
76
+ }
77
+
78
+ /**
79
+ * PURE — the human-readable block the CLI prints for a plan. Leads with the
80
+ * version delta (or "up to date"/"unknown"), then the command to run — for a dev
81
+ * checkout that's the `git pull` hint, for a global install the exact package-
82
+ * manager line. `dryRun` only changes the framing ("would run" vs "run"); the
83
+ * command shown is identical either way. Ends with the re-adopt note: the running
84
+ * updater keeps the OLD code until `_tmux-ide-chrome` is killed and a session
85
+ * re-adopted, so a fresh dock reflects the new version.
86
+ */
87
+ export function renderPlan(
88
+ plan: UpdatePlan,
89
+ { current, latest, dryRun }: { current: string; latest: string | null; dryRun: boolean },
90
+ ): string {
91
+ const lines: string[] = [];
92
+ // isNewer, not inequality — the registry can lag the checkout (a dev build
93
+ // is AHEAD of the published version), and that must not read as an update.
94
+ if (latest && isNewer(latest, current)) {
95
+ lines.push(`tmux-ide v${current} → v${latest} available`);
96
+ } else if (latest) {
97
+ lines.push(`tmux-ide v${current} is up to date (registry: v${latest})`);
98
+ } else {
99
+ lines.push(`tmux-ide v${current} (latest version unknown — run \`tmux-ide doctor\`)`);
100
+ }
101
+ lines.push("");
102
+ if (plan.method === "dev") {
103
+ lines.push("Detected a cloned checkout — update with git:");
104
+ lines.push(" git pull");
105
+ lines.push(` (${plan.reason})`);
106
+ } else {
107
+ const verb = dryRun ? "Would run" : "Running";
108
+ lines.push(`Detected a global ${plan.method} install — ${verb}:`);
109
+ lines.push(` ${plan.command}`);
110
+ }
111
+ lines.push("");
112
+ lines.push("After updating, refresh the dock so it runs the new code:");
113
+ lines.push(" tmux kill-session -t _tmux-ide-chrome # stop the old updater");
114
+ lines.push(" tmux-ide adopt <session> # re-adopt to relaunch it");
115
+ return lines.join("\n");
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // io
120
+ // ---------------------------------------------------------------------------
121
+
122
+ /**
123
+ * io — walk up from `startDir` looking for a `.git` entry; return the first
124
+ * directory that has one (a cloned checkout root) or null at the filesystem root.
125
+ * A global install under `node_modules` has no `.git` above it, so this cleanly
126
+ * separates "dev checkout" from "installed package".
127
+ */
128
+ export function findGitCheckoutRoot(startDir: string): string | null {
129
+ let dir = startDir;
130
+ for (;;) {
131
+ if (existsSync(join(dir, ".git"))) return dir;
132
+ const parent = dirname(dir);
133
+ if (parent === dir) return null;
134
+ dir = parent;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * io — run (or, with `dryRun`, just print) the update.
140
+ *
141
+ * `cliDir` is the directory of the running CLI (`bin/`), the anchor for both the
142
+ * git-checkout probe and the package-manager path heuristic. The plan is printed
143
+ * either way; only a non-dry-run global install actually spawns the installer
144
+ * (`execSync`, output inherited). A dev checkout NEVER auto-runs `git pull` — it
145
+ * only prints the hint. Returns the plan so the CLI/tests can assert on it.
146
+ */
147
+ export function runUpdate({ cliDir, dryRun }: { cliDir: string; dryRun: boolean }): UpdatePlan {
148
+ const current = getCurrentVersion();
149
+ const { latest } = getUpdateStatus({ currentVersion: current });
150
+ const gitRoot = findGitCheckoutRoot(cliDir);
151
+ const plan = planUpdate({ cliPath: cliDir, gitRoot });
152
+ console.log(renderPlan(plan, { current, latest, dryRun }));
153
+ if (!dryRun && plan.command) {
154
+ console.log("");
155
+ execSync(plan.command, { stdio: "inherit" });
156
+ }
157
+ return plan;
158
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Workspace registry — runtime list of projects the daemon is serving.
3
+ *
4
+ * Replaces the legacy single-session TMUX_IDE_SESSION env coupling so a
5
+ * single daemon can host N concurrent projects (goal 12, T065).
6
+ *
7
+ * Persisted at ~/.tmux-ide/workspaces.json via atomic temp+rename. On
8
+ * `load()` the registry reconciles itself against `tmux list-sessions`
9
+ * output and drops entries whose session no longer exists.
10
+ *
11
+ * Distinct from project-registry.ts — that one is a long-lived
12
+ * "bookmark list" of projects the user knows about (probed metadata).
13
+ * This one tracks live workspaces and is reconciled with tmux on boot.
14
+ */
15
+
16
+ import { EventEmitter } from "node:events";
17
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { dirname, join } from "node:path";
20
+ import { z } from "zod";
21
+ import { WorkspaceSchemaZ, type Workspace } from "@tmux-ide/contracts";
22
+
23
+ const REGISTRY_DIR_ENV = "TMUX_IDE_REGISTRY_DIR";
24
+
25
+ const RegistryFileSchemaZ = z.object({
26
+ version: z.literal(1),
27
+ workspaces: z.array(WorkspaceSchemaZ),
28
+ });
29
+
30
+ type RegistryFile = z.infer<typeof RegistryFileSchemaZ>;
31
+
32
+ export type ListSessionsFn = () => readonly string[];
33
+
34
+ export interface WorkspaceRegistryOptions {
35
+ /** Override registry dir for tests. Defaults to ~/.tmux-ide. */
36
+ dir?: string;
37
+ /** Inject a tmux list-sessions implementation (defaults to bridge). */
38
+ listSessions?: ListSessionsFn;
39
+ }
40
+
41
+ export interface AddWorkspaceInput {
42
+ name: string;
43
+ sessionName?: string;
44
+ projectDir: string;
45
+ ideConfigPath?: string | null;
46
+ /** Override Date.now() for deterministic tests. */
47
+ now?: () => Date;
48
+ }
49
+
50
+ export class WorkspaceAlreadyExistsError extends Error {
51
+ readonly code = "ALREADY_EXISTS";
52
+ constructor(name: string) {
53
+ super(`Workspace "${name}" already exists`);
54
+ this.name = "WorkspaceAlreadyExistsError";
55
+ }
56
+ }
57
+
58
+ export class WorkspaceNotFoundError extends Error {
59
+ readonly code = "NOT_FOUND";
60
+ constructor(name: string) {
61
+ super(`Workspace "${name}" not found`);
62
+ this.name = "WorkspaceNotFoundError";
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Thin wrapper around the shared module emitter so consumers can subscribe
68
+ * with typed events without depending on EventEmitter directly.
69
+ */
70
+ export type WorkspaceEvent =
71
+ | { type: "workspace.added"; workspace: Workspace }
72
+ | { type: "workspace.removed"; name: string };
73
+
74
+ export class WorkspaceRegistry {
75
+ private readonly dir: string;
76
+ private readonly listSessions: ListSessionsFn;
77
+ private readonly emitter = new EventEmitter();
78
+ private workspaces: Workspace[] = [];
79
+ private loaded = false;
80
+
81
+ constructor(options: WorkspaceRegistryOptions = {}) {
82
+ this.dir = options.dir ?? process.env[REGISTRY_DIR_ENV] ?? join(homedir(), ".tmux-ide");
83
+ this.listSessions = options.listSessions ?? defaultListSessions;
84
+ this.emitter.setMaxListeners(0);
85
+ }
86
+
87
+ /**
88
+ * Load workspaces from disk and reconcile against live tmux sessions.
89
+ * Drops entries whose tmux session is gone (silently — they were
90
+ * persisted by a prior daemon invocation that may have crashed).
91
+ *
92
+ * Safe to call repeatedly; subsequent calls re-reconcile.
93
+ */
94
+ async load(): Promise<void> {
95
+ const fromDisk = this.readDisk();
96
+ let live: Set<string>;
97
+ try {
98
+ live = new Set(this.listSessions());
99
+ } catch {
100
+ // tmux is unavailable — keep all entries; reconcile when we can.
101
+ live = new Set(fromDisk.map((w) => w.sessionName));
102
+ }
103
+ const reconciled = fromDisk.filter((w) => live.has(w.sessionName));
104
+ this.workspaces = reconciled;
105
+ this.loaded = true;
106
+ if (reconciled.length !== fromDisk.length) {
107
+ this.writeDisk();
108
+ }
109
+ }
110
+
111
+ list(): Workspace[] {
112
+ return [...this.workspaces];
113
+ }
114
+
115
+ get(name: string): Workspace | null {
116
+ return this.workspaces.find((w) => w.name === name) ?? null;
117
+ }
118
+
119
+ has(name: string): boolean {
120
+ return this.workspaces.some((w) => w.name === name);
121
+ }
122
+
123
+ add(input: AddWorkspaceInput): Workspace {
124
+ if (this.has(input.name)) {
125
+ throw new WorkspaceAlreadyExistsError(input.name);
126
+ }
127
+ const now = (input.now ?? (() => new Date()))();
128
+ const workspace: Workspace = {
129
+ name: input.name,
130
+ sessionName: input.sessionName ?? input.name,
131
+ projectDir: input.projectDir,
132
+ ideConfigPath: input.ideConfigPath ?? null,
133
+ addedAt: now.toISOString(),
134
+ };
135
+ this.workspaces = [...this.workspaces, workspace];
136
+ this.writeDisk();
137
+ this.emitter.emit("workspace.added", workspace);
138
+ return workspace;
139
+ }
140
+
141
+ remove(name: string): void {
142
+ if (!this.has(name)) {
143
+ throw new WorkspaceNotFoundError(name);
144
+ }
145
+ this.workspaces = this.workspaces.filter((w) => w.name !== name);
146
+ this.writeDisk();
147
+ this.emitter.emit("workspace.removed", name);
148
+ }
149
+
150
+ /** Subscribe to workspace.added | workspace.removed events. */
151
+ on<E extends WorkspaceEvent["type"]>(
152
+ event: E,
153
+ handler: (
154
+ payload: Extract<WorkspaceEvent, { type: E }> extends { workspace: Workspace }
155
+ ? Workspace
156
+ : string,
157
+ ) => void,
158
+ ): () => void {
159
+ this.emitter.on(event, handler as (...args: unknown[]) => void);
160
+ return () => this.emitter.off(event, handler as (...args: unknown[]) => void);
161
+ }
162
+
163
+ // ----------------- io -----------------
164
+
165
+ private filePath(): string {
166
+ return join(this.dir, "workspaces.json");
167
+ }
168
+
169
+ private readDisk(): Workspace[] {
170
+ const path = this.filePath();
171
+ if (!existsSync(path)) return [];
172
+ let parsed: unknown;
173
+ try {
174
+ parsed = JSON.parse(readFileSync(path, "utf-8"));
175
+ } catch {
176
+ return [];
177
+ }
178
+ const result = RegistryFileSchemaZ.safeParse(parsed);
179
+ if (!result.success) return [];
180
+ return result.data.workspaces;
181
+ }
182
+
183
+ private writeDisk(): void {
184
+ const path = this.filePath();
185
+ mkdirSync(dirname(path), { recursive: true });
186
+ const file: RegistryFile = { version: 1, workspaces: this.workspaces };
187
+ const tmp = `${path}.tmp`;
188
+ writeFileSync(tmp, JSON.stringify(file, null, 2) + "\n");
189
+ renameSync(tmp, path);
190
+ }
191
+
192
+ /** @internal Test-only: assert the registry is loaded. */
193
+ _isLoaded(): boolean {
194
+ return this.loaded;
195
+ }
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // Default singleton — the daemon process holds one registry; HTTP handlers
200
+ // import it via getDefaultWorkspaceRegistry().
201
+ // ---------------------------------------------------------------------------
202
+
203
+ let _default: WorkspaceRegistry | null = null;
204
+
205
+ export function getDefaultWorkspaceRegistry(): WorkspaceRegistry {
206
+ if (!_default) _default = new WorkspaceRegistry();
207
+ return _default;
208
+ }
209
+
210
+ /** @internal Test hook: replace the singleton. */
211
+ export function _setDefaultWorkspaceRegistryForTests(registry: WorkspaceRegistry | null): void {
212
+ _default = registry;
213
+ }
214
+
215
+ function defaultListSessions(): string[] {
216
+ // Lazy import keeps tmux-bridge optional for test environments that
217
+ // don't have tmux available; tests inject a stub instead.
218
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
219
+ const { execFileSync } = require("node:child_process") as typeof import("node:child_process");
220
+ try {
221
+ const raw = execFileSync("tmux", ["list-sessions", "-F", "#{session_name}"], {
222
+ encoding: "utf-8",
223
+ stdio: ["ignore", "pipe", "pipe"],
224
+ }) as string;
225
+ return raw.split("\n").filter(Boolean);
226
+ } catch {
227
+ return [];
228
+ }
229
+ }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Git worktree flow — one checkout per branch so parallel agents (or a fleet)
3
+ * each work on isolation. `tmux-ide worktree create <branch>` adds a git
4
+ * worktree in a SIBLING directory (never inside the repo) and opens a tmux
5
+ * session there; the CLI (`bin/cli.ts`, `worktree` case) wires the io.
6
+ *
7
+ * The pure helpers — {@link worktreeSessionName}, {@link worktreePath},
8
+ * {@link parseWorktreeList}, {@link mapWorktreeError} — carry no io so they're
9
+ * cheaply testable. The io wrappers ({@link createWorktree},
10
+ * {@link removeWorktree}, {@link listWorktrees}) shell out to `git` and map its
11
+ * stderr into a typed {@link WorktreeError} with an actionable message.
12
+ */
13
+ import { execFileSync } from "node:child_process";
14
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
15
+ import { IdeError } from "./errors.ts";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Typed error
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export type WorktreeErrorCode =
22
+ | "NOT_A_GIT_REPO"
23
+ | "BRANCH_EXISTS"
24
+ | "BRANCH_NOT_FOUND"
25
+ | "ALREADY_CHECKED_OUT"
26
+ | "WORKTREE_EXISTS"
27
+ | "WORKTREE_DIRTY"
28
+ | "WORKTREE_NOT_FOUND"
29
+ | "GIT_FAILED";
30
+
31
+ export class WorktreeError extends IdeError {
32
+ constructor(message: string, code: WorktreeErrorCode) {
33
+ super(message, { code, exitCode: 1 });
34
+ this.name = "WorktreeError";
35
+ }
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Pure helpers
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * PURE — sanitize a component for a tmux session name. tmux treats `:` as a
44
+ * target separator and silently rewrites `.` to `_`, so we normalize both (plus
45
+ * whitespace) to `-` up front, keeping the name stable and predictable. `/` is
46
+ * left intact — tmux accepts it in session names (verified live on tmux 3.6).
47
+ */
48
+ function sanitizeForTmux(part: string): string {
49
+ // Slashes too: `feat/x` would parse as a path-ish target in tmux commands
50
+ // and can't appear in the status bar's `#[range=user|sw<name>]` mouse ranges.
51
+ return part.replace(/[.:/\s]+/g, "-");
52
+ }
53
+
54
+ /**
55
+ * PURE — the tmux session name for a project's worktree on `branch`, e.g.
56
+ * `myapp@fix-auth`. Both sides are sanitized ({@link sanitizeForTmux}) so a
57
+ * dotted/colon'd branch (or project) stays a single inert session name; the
58
+ * `@` separator is preserved so the session reads as "project, this branch".
59
+ */
60
+ export function worktreeSessionName(project: string, branch: string): string {
61
+ return `${sanitizeForTmux(project)}@${sanitizeForTmux(branch)}`;
62
+ }
63
+
64
+ /**
65
+ * PURE — the default base directory for a repo's worktrees: a SIBLING of the
66
+ * repo named `<repo-name>-worktrees`. Kept outside the repo so a worktree is
67
+ * never nested inside its own parent checkout (which git rejects and which
68
+ * would pollute the project tree).
69
+ */
70
+ export function defaultWorktreeBaseDir(repoDir: string): string {
71
+ const abs = resolve(repoDir);
72
+ return join(dirname(abs), `${basename(abs)}-worktrees`);
73
+ }
74
+
75
+ /**
76
+ * PURE — the on-disk path for a worktree of `branch`. Defaults to
77
+ * `<repo>/../<repo-name>-worktrees/<branch>`; a non-empty `configuredDir` (the
78
+ * app-config `worktrees.dir` override) replaces the base, resolved relative to
79
+ * the repo when it isn't absolute. The branch is the final path segment (a
80
+ * slashed branch nests, which `git worktree add` creates).
81
+ */
82
+ export function worktreePath(
83
+ repoDir: string,
84
+ branch: string,
85
+ configuredDir?: string | null,
86
+ ): string {
87
+ const base =
88
+ configuredDir && configuredDir.length > 0
89
+ ? isAbsolute(configuredDir)
90
+ ? configuredDir
91
+ : resolve(repoDir, configuredDir)
92
+ : defaultWorktreeBaseDir(repoDir);
93
+ return join(base, branch);
94
+ }
95
+
96
+ /** A single record from `git worktree list --porcelain`. */
97
+ export interface WorktreeEntry {
98
+ /** Absolute path of the worktree. */
99
+ path: string;
100
+ /** The checked-out commit sha, or null (bare repo). */
101
+ head: string | null;
102
+ /** Short branch name (no `refs/heads/`), or null when detached/bare. */
103
+ branch: string | null;
104
+ /** The bare repository entry. */
105
+ bare: boolean;
106
+ /** A detached-HEAD worktree (no branch). */
107
+ detached: boolean;
108
+ }
109
+
110
+ /**
111
+ * PURE — parse `git worktree list --porcelain`. Records are separated by blank
112
+ * lines; each has a `worktree <path>` line, then optional `HEAD <sha>`,
113
+ * `branch refs/heads/<name>`, `bare`, `detached` attribute lines. Unknown
114
+ * attribute lines are ignored. Malformed/empty input yields `[]`.
115
+ */
116
+ export function parseWorktreeList(porcelain: string): WorktreeEntry[] {
117
+ const entries: WorktreeEntry[] = [];
118
+ let current: WorktreeEntry | null = null;
119
+
120
+ const flush = () => {
121
+ if (current) entries.push(current);
122
+ current = null;
123
+ };
124
+
125
+ for (const rawLine of porcelain.split("\n")) {
126
+ const line = rawLine.replace(/\r$/, "");
127
+ if (line.length === 0) {
128
+ flush();
129
+ continue;
130
+ }
131
+ if (line.startsWith("worktree ")) {
132
+ flush();
133
+ current = {
134
+ path: line.slice("worktree ".length),
135
+ head: null,
136
+ branch: null,
137
+ bare: false,
138
+ detached: false,
139
+ };
140
+ continue;
141
+ }
142
+ if (!current) continue;
143
+ if (line.startsWith("HEAD ")) {
144
+ current.head = line.slice("HEAD ".length);
145
+ } else if (line.startsWith("branch ")) {
146
+ current.branch = line.slice("branch ".length).replace(/^refs\/heads\//, "");
147
+ } else if (line === "bare") {
148
+ current.bare = true;
149
+ } else if (line === "detached") {
150
+ current.detached = true;
151
+ }
152
+ }
153
+ flush();
154
+ return entries;
155
+ }
156
+
157
+ /**
158
+ * PURE — map a failed `git` invocation's stderr to a typed
159
+ * {@link WorktreeError}. Recognizes the common worktree failures so the CLI can
160
+ * surface an actionable message instead of a raw git dump; anything unmatched
161
+ * falls back to `GIT_FAILED` carrying the trimmed stderr.
162
+ */
163
+ export function mapWorktreeError(stderr: string, fallbackMessage: string): WorktreeError {
164
+ const text = stderr.trim();
165
+ const lower = text.toLowerCase();
166
+
167
+ if (lower.includes("not a git repository")) {
168
+ return new WorktreeError(
169
+ "Not a git repository. Run `tmux-ide worktree` from inside a git repo.",
170
+ "NOT_A_GIT_REPO",
171
+ );
172
+ }
173
+ if (lower.includes("already exists") && lower.includes("branch")) {
174
+ return new WorktreeError(
175
+ `${text}\nUse \`tmux-ide worktree create <branch>\` without --from to check out the existing branch, or pick a new name.`,
176
+ "BRANCH_EXISTS",
177
+ );
178
+ }
179
+ if (lower.includes("is already checked out") || lower.includes("already used by worktree")) {
180
+ return new WorktreeError(text, "ALREADY_CHECKED_OUT");
181
+ }
182
+ if (lower.includes("already exists")) {
183
+ return new WorktreeError(text, "WORKTREE_EXISTS");
184
+ }
185
+ if (
186
+ (lower.includes("invalid reference") || lower.includes("not a valid ref")) &&
187
+ !lower.includes("already")
188
+ ) {
189
+ return new WorktreeError(text, "BRANCH_NOT_FOUND");
190
+ }
191
+ if (
192
+ lower.includes("contains modified or untracked files") ||
193
+ lower.includes("use --force") ||
194
+ lower.includes("use 'remove -f'")
195
+ ) {
196
+ return new WorktreeError(
197
+ `${text}\nRe-run with --force to discard those changes.`,
198
+ "WORKTREE_DIRTY",
199
+ );
200
+ }
201
+ if (lower.includes("is not a working tree") || lower.includes("not a working tree")) {
202
+ return new WorktreeError(text, "WORKTREE_NOT_FOUND");
203
+ }
204
+ return new WorktreeError(text.length > 0 ? text : fallbackMessage, "GIT_FAILED");
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // io — git shell-outs (map stderr → WorktreeError)
209
+ // ---------------------------------------------------------------------------
210
+
211
+ /** Injectable git runner so io wrappers stay unit-testable. */
212
+ export type GitRunner = (repoDir: string, args: string[]) => string;
213
+
214
+ let gitRunner: GitRunner = (repoDir, args) =>
215
+ execFileSync("git", args, {
216
+ cwd: repoDir,
217
+ encoding: "utf-8",
218
+ stdio: ["ignore", "pipe", "pipe"],
219
+ });
220
+
221
+ /** Swap the git runner (tests). Returns a restore fn. */
222
+ export function _setGitRunnerForTests(fn: GitRunner): () => void {
223
+ const prev = gitRunner;
224
+ gitRunner = fn;
225
+ return () => {
226
+ gitRunner = prev;
227
+ };
228
+ }
229
+
230
+ interface GitExecError {
231
+ stderr?: Buffer | string;
232
+ }
233
+
234
+ function runGit(repoDir: string, args: string[], fallbackMessage: string): string {
235
+ try {
236
+ return gitRunner(repoDir, args);
237
+ } catch (error) {
238
+ const stderr = (error as GitExecError).stderr;
239
+ const text = stderr ? stderr.toString() : "";
240
+ throw mapWorktreeError(text, fallbackMessage);
241
+ }
242
+ }
243
+
244
+ export interface CreateWorktreeOptions {
245
+ /** Create a NEW branch (git worktree add -b). Off → check out an existing branch. */
246
+ newBranch?: boolean;
247
+ /** Base ref for the new branch (default: current HEAD). Only used with newBranch. */
248
+ from?: string | null;
249
+ }
250
+
251
+ /**
252
+ * Add a git worktree for `branch` at `worktreeAbsPath` and return that path.
253
+ * With `newBranch`, creates the branch (optionally off `from`); otherwise
254
+ * checks out an existing branch. Failures throw a typed {@link WorktreeError}.
255
+ */
256
+ export function createWorktree(
257
+ repoDir: string,
258
+ branch: string,
259
+ worktreeAbsPath: string,
260
+ options: CreateWorktreeOptions = {},
261
+ ): string {
262
+ const args = ["worktree", "add"];
263
+ if (options.newBranch) {
264
+ args.push("-b", branch, worktreeAbsPath);
265
+ if (options.from && options.from.length > 0) args.push(options.from);
266
+ } else {
267
+ args.push(worktreeAbsPath, branch);
268
+ }
269
+ runGit(repoDir, args, `Failed to create worktree for ${branch}`);
270
+ return worktreeAbsPath;
271
+ }
272
+
273
+ /** Remove a git worktree by path. `force` discards uncommitted changes. */
274
+ export function removeWorktree(
275
+ repoDir: string,
276
+ worktreeAbsPath: string,
277
+ options: { force?: boolean } = {},
278
+ ): void {
279
+ const args = ["worktree", "remove"];
280
+ if (options.force) args.push("--force");
281
+ args.push(worktreeAbsPath);
282
+ runGit(repoDir, args, `Failed to remove worktree ${worktreeAbsPath}`);
283
+ }
284
+
285
+ /** List the repo's worktrees. Throws a typed {@link WorktreeError} on failure. */
286
+ export function listWorktrees(repoDir: string): WorktreeEntry[] {
287
+ const out = runGit(repoDir, ["worktree", "list", "--porcelain"], "Failed to list worktrees");
288
+ return parseWorktreeList(out);
289
+ }