tmux-ide 2.6.0 → 2.7.0

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 (271) hide show
  1. package/README.md +14 -9
  2. package/bin/cli.js +1024 -519
  3. package/bin/cli.ts +63 -6
  4. package/bunfig.toml +4 -0
  5. package/package.json +18 -7
  6. package/packages/contracts/package.json +22 -0
  7. package/packages/contracts/src/__tests__/ide-config.test.ts +46 -0
  8. package/packages/contracts/src/__tests__/terminals.test.ts +87 -0
  9. package/packages/contracts/src/actions-contract.ts +310 -0
  10. package/packages/contracts/src/actions-errors.ts +41 -0
  11. package/packages/contracts/src/domain.ts +36 -0
  12. package/packages/contracts/src/ide-config.ts +170 -0
  13. package/packages/contracts/src/index.ts +24 -0
  14. package/packages/contracts/src/lib-internal/auth.ts +13 -0
  15. package/packages/contracts/src/lib-internal/hq.ts +38 -0
  16. package/packages/contracts/src/terminals.ts +116 -0
  17. package/packages/contracts/src/tmux.ts +60 -0
  18. package/packages/contracts/src/workspace.ts +67 -0
  19. package/packages/daemon/dist/agent-explain.d.ts +8 -1
  20. package/packages/daemon/dist/agent-explain.js +19 -3
  21. package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
  22. package/packages/daemon/dist/lib/tui-binary.js +130 -0
  23. package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
  24. package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
  25. package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
  26. package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
  27. package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
  28. package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
  29. package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
  30. package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
  31. package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
  32. package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
  33. package/packages/daemon/src/agent-explain.ts +298 -0
  34. package/packages/daemon/src/attach.ts +20 -0
  35. package/packages/daemon/src/bin.ts +4 -0
  36. package/packages/daemon/src/canonical.ts +7 -0
  37. package/packages/daemon/src/cli.ts +499 -0
  38. package/packages/daemon/src/command-center/actions/contract.ts +2 -0
  39. package/packages/daemon/src/command-center/actions/dispatcher.ts +137 -0
  40. package/packages/daemon/src/command-center/actions/errors.ts +105 -0
  41. package/packages/daemon/src/command-center/actions/handlers/_project-context.ts +30 -0
  42. package/packages/daemon/src/command-center/actions/handlers/_resolve-project.ts +78 -0
  43. package/packages/daemon/src/command-center/actions/handlers/app-set-remote-access.ts +118 -0
  44. package/packages/daemon/src/command-center/actions/handlers/config-actions.ts +113 -0
  45. package/packages/daemon/src/command-center/actions/handlers/daemon-shutdown.ts +38 -0
  46. package/packages/daemon/src/command-center/actions/handlers/project-activate.ts +30 -0
  47. package/packages/daemon/src/command-center/actions/handlers/project-launch.ts +70 -0
  48. package/packages/daemon/src/command-center/actions/handlers/project-open-terminal.ts +87 -0
  49. package/packages/daemon/src/command-center/actions/handlers/project-restart.ts +38 -0
  50. package/packages/daemon/src/command-center/actions/handlers/project-stop.ts +62 -0
  51. package/packages/daemon/src/command-center/actions/handlers/terminal-respawn.ts +119 -0
  52. package/packages/daemon/src/command-center/actions/handlers/terminal-stop.ts +35 -0
  53. package/packages/daemon/src/command-center/actions/registry.ts +149 -0
  54. package/packages/daemon/src/command-center/discovery.ts +96 -0
  55. package/packages/daemon/src/command-center/index.ts +31 -0
  56. package/packages/daemon/src/command-center/schemas.ts +85 -0
  57. package/packages/daemon/src/command-center/server.ts +1260 -0
  58. package/packages/daemon/src/command-center/ws-events.ts +316 -0
  59. package/packages/daemon/src/config.ts +549 -0
  60. package/packages/daemon/src/detect.ts +248 -0
  61. package/packages/daemon/src/doctor.ts +242 -0
  62. package/packages/daemon/src/embed.ts +5 -0
  63. package/packages/daemon/src/index.ts +12 -0
  64. package/packages/daemon/src/init.ts +211 -0
  65. package/packages/daemon/src/inspect.ts +178 -0
  66. package/packages/daemon/src/js-yaml.d.ts +10 -0
  67. package/packages/daemon/src/launch.ts +349 -0
  68. package/packages/daemon/src/lib/active-projects.ts +49 -0
  69. package/packages/daemon/src/lib/agent-discovery.ts +121 -0
  70. package/packages/daemon/src/lib/app-config.ts +427 -0
  71. package/packages/daemon/src/lib/app-settings.ts +53 -0
  72. package/packages/daemon/src/lib/auth/auth-service.ts +227 -0
  73. package/packages/daemon/src/lib/auth/middleware.ts +56 -0
  74. package/packages/daemon/src/lib/auth/types.ts +2 -0
  75. package/packages/daemon/src/lib/auth-token.ts +5 -0
  76. package/packages/daemon/src/lib/authorship.ts +280 -0
  77. package/packages/daemon/src/lib/canonical-daemon.ts +122 -0
  78. package/packages/daemon/src/lib/cli-action-bridge.ts +216 -0
  79. package/packages/daemon/src/lib/daemon-embed.ts +782 -0
  80. package/packages/daemon/src/lib/daemon-watchdog.ts +111 -0
  81. package/packages/daemon/src/lib/daemon.ts +79 -0
  82. package/packages/daemon/src/lib/dot-path.ts +17 -0
  83. package/packages/daemon/src/lib/errors.ts +67 -0
  84. package/packages/daemon/src/lib/filesystem-browser.ts +292 -0
  85. package/packages/daemon/src/lib/launch-plan.ts +90 -0
  86. package/packages/daemon/src/lib/log.ts +134 -0
  87. package/packages/daemon/src/lib/output.ts +76 -0
  88. package/packages/daemon/src/lib/project-init-runner.ts +150 -0
  89. package/packages/daemon/src/lib/project-inspect.ts +80 -0
  90. package/packages/daemon/src/lib/project-onboard.ts +149 -0
  91. package/packages/daemon/src/lib/project-probe.ts +92 -0
  92. package/packages/daemon/src/lib/project-registry.ts +296 -0
  93. package/packages/daemon/src/lib/session-monitor.ts +122 -0
  94. package/packages/daemon/src/lib/session-options.ts +100 -0
  95. package/packages/daemon/src/lib/shell.ts +8 -0
  96. package/packages/daemon/src/lib/sizes.ts +36 -0
  97. package/packages/daemon/src/lib/skill-sync.ts +155 -0
  98. package/packages/daemon/src/lib/slugify.ts +10 -0
  99. package/packages/daemon/src/lib/terminals-store.ts +125 -0
  100. package/packages/daemon/src/lib/tui-binary.ts +165 -0
  101. package/packages/daemon/src/lib/update-check.ts +298 -0
  102. package/packages/daemon/src/lib/update.ts +158 -0
  103. package/packages/daemon/src/lib/workspace-registry.ts +229 -0
  104. package/packages/daemon/src/lib/worktree.ts +289 -0
  105. package/packages/daemon/src/lib/yaml-io.ts +27 -0
  106. package/packages/daemon/src/ls.ts +40 -0
  107. package/packages/daemon/src/restart.ts +24 -0
  108. package/packages/daemon/src/restore.ts +514 -0
  109. package/packages/daemon/src/schemas/domain.ts +2 -0
  110. package/packages/daemon/src/schemas/filesystem.ts +34 -0
  111. package/packages/daemon/src/schemas/ide-config.ts +2 -0
  112. package/packages/daemon/src/schemas/index.ts +59 -0
  113. package/packages/daemon/src/schemas/inspect.ts +67 -0
  114. package/packages/daemon/src/schemas/registry.ts +55 -0
  115. package/packages/daemon/src/schemas/ws-events.ts +135 -0
  116. package/packages/daemon/src/send.ts +171 -0
  117. package/packages/daemon/src/server/README.md +15 -0
  118. package/packages/daemon/src/server/index.ts +74 -0
  119. package/packages/daemon/src/server/pty-bridge.ts +532 -0
  120. package/packages/daemon/src/server/standalone.ts +18 -0
  121. package/packages/daemon/src/server/ws-route.ts +483 -0
  122. package/packages/daemon/src/status.ts +59 -0
  123. package/packages/daemon/src/stop.ts +28 -0
  124. package/packages/daemon/src/terminal/NodePtyAdapter.ts +271 -0
  125. package/packages/daemon/src/terminal/PtyAdapter.ts +140 -0
  126. package/packages/daemon/src/terminal/README.md +92 -0
  127. package/packages/daemon/src/tui/chrome/cheatsheet.ts +260 -0
  128. package/packages/daemon/src/tui/chrome/chip.ts +30 -0
  129. package/packages/daemon/src/tui/chrome/events.ts +119 -0
  130. package/packages/daemon/src/tui/chrome/kitty-keys.ts +55 -0
  131. package/packages/daemon/src/tui/chrome/menu.ts +289 -0
  132. package/packages/daemon/src/tui/chrome/notify.ts +382 -0
  133. package/packages/daemon/src/tui/chrome/panels.ts +111 -0
  134. package/packages/daemon/src/tui/chrome/sidebar.ts +222 -0
  135. package/packages/daemon/src/tui/chrome/snapshot.ts +425 -0
  136. package/packages/daemon/src/tui/chrome/statusline.ts +595 -0
  137. package/packages/daemon/src/tui/chrome/updater.ts +510 -0
  138. package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
  139. package/packages/daemon/src/tui/compiled.ts +121 -0
  140. package/packages/daemon/src/tui/detect/classify.ts +208 -0
  141. package/packages/daemon/src/tui/detect/manifest-loader.ts +193 -0
  142. package/packages/daemon/src/tui/detect/manifest.ts +199 -0
  143. package/packages/daemon/src/tui/detect/manifests.ts +354 -0
  144. package/packages/daemon/src/tui/detect/process-tree.ts +217 -0
  145. package/packages/daemon/src/tui/detect/snapshot.ts +70 -0
  146. package/packages/daemon/src/tui/integrations/claude.ts +176 -0
  147. package/packages/daemon/src/tui/integrations/offer.ts +145 -0
  148. package/packages/daemon/src/tui/main.ts +82 -0
  149. package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
  150. package/packages/daemon/src/tui/mirror/agent-chip.ts +97 -0
  151. package/packages/daemon/src/tui/mirror/agent-rows.ts +133 -0
  152. package/packages/daemon/src/tui/mirror/app-state.ts +179 -0
  153. package/packages/daemon/src/tui/mirror/app.tsx +5265 -0
  154. package/packages/daemon/src/tui/mirror/blit.ts +186 -0
  155. package/packages/daemon/src/tui/mirror/control-client.ts +214 -0
  156. package/packages/daemon/src/tui/mirror/control.ts +97 -0
  157. package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
  158. package/packages/daemon/src/tui/mirror/dialog-stack.ts +354 -0
  159. package/packages/daemon/src/tui/mirror/diff-model.ts +112 -0
  160. package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
  161. package/packages/daemon/src/tui/mirror/file-tree.ts +97 -0
  162. package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
  163. package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
  164. package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
  165. package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
  166. package/packages/daemon/src/tui/mirror/menu-model.ts +187 -0
  167. package/packages/daemon/src/tui/mirror/palette.ts +274 -0
  168. package/packages/daemon/src/tui/mirror/pane-mirror.ts +561 -0
  169. package/packages/daemon/src/tui/mirror/pane-surface.tsx +415 -0
  170. package/packages/daemon/src/tui/mirror/perf-tap.ts +160 -0
  171. package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
  172. package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
  173. package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
  174. package/packages/daemon/src/tui/mirror/selection.ts +262 -0
  175. package/packages/daemon/src/tui/mirror/session-mirror.ts +443 -0
  176. package/packages/daemon/src/tui/mirror/settings-model.ts +345 -0
  177. package/packages/daemon/src/tui/mirror/size-truth.ts +77 -0
  178. package/packages/daemon/src/tui/mirror/spans.ts +46 -0
  179. package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
  180. package/packages/daemon/src/tui/team/CONTROL.md +50 -0
  181. package/packages/daemon/src/tui/team/entry.ts +38 -0
  182. package/packages/daemon/src/tui/team/fuzzy.ts +133 -0
  183. package/packages/daemon/src/tui/team/home.ts +170 -0
  184. package/packages/daemon/src/tui/team/index.tsx +1521 -0
  185. package/packages/daemon/src/tui/team/input.ts +34 -0
  186. package/packages/daemon/src/tui/team/keymap.ts +127 -0
  187. package/packages/daemon/src/tui/team/mouse.ts +29 -0
  188. package/packages/daemon/src/tui/team/nav.ts +31 -0
  189. package/packages/daemon/src/tui/team/preview.ts +34 -0
  190. package/packages/daemon/src/tui/team/projects.ts +191 -0
  191. package/packages/daemon/src/tui/team/report.ts +83 -0
  192. package/packages/daemon/src/tui/team/sessions.ts +433 -0
  193. package/packages/daemon/src/tui/team/tree.ts +62 -0
  194. package/packages/daemon/src/types.ts +13 -0
  195. package/packages/daemon/src/ui/index.ts +32 -0
  196. package/packages/daemon/src/ui/terminal/index.ts +9 -0
  197. package/packages/daemon/src/ui/types.ts +91 -0
  198. package/packages/daemon/src/ui/web/base.css +80 -0
  199. package/packages/daemon/src/ui/web/components/Box.tsx +59 -0
  200. package/packages/daemon/src/ui/web/components/Input.tsx +32 -0
  201. package/packages/daemon/src/ui/web/components/ScrollBox.tsx +60 -0
  202. package/packages/daemon/src/ui/web/components/Text.tsx +28 -0
  203. package/packages/daemon/src/ui/web/hooks.ts +106 -0
  204. package/packages/daemon/src/ui/web/index.ts +27 -0
  205. package/packages/daemon/src/ui/web/render.ts +77 -0
  206. package/packages/daemon/src/ui/web/utils/color.ts +27 -0
  207. package/packages/daemon/src/validate.ts +217 -0
  208. package/packages/daemon/src/widgets/changes/README.md +3 -0
  209. package/packages/daemon/src/widgets/changes/index.tsx +691 -0
  210. package/packages/daemon/src/widgets/config/README.md +3 -0
  211. package/packages/daemon/src/widgets/config/index.tsx +481 -0
  212. package/packages/daemon/src/widgets/explorer/README.md +3 -0
  213. package/packages/daemon/src/widgets/explorer/breadcrumbs.tsx +77 -0
  214. package/packages/daemon/src/widgets/explorer/footer.tsx +20 -0
  215. package/packages/daemon/src/widgets/explorer/header.tsx +23 -0
  216. package/packages/daemon/src/widgets/explorer/index.tsx +456 -0
  217. package/packages/daemon/src/widgets/explorer/tree-model.ts +103 -0
  218. package/packages/daemon/src/widgets/explorer/tree.tsx +165 -0
  219. package/packages/daemon/src/widgets/lib/config-model.ts +116 -0
  220. package/packages/daemon/src/widgets/lib/files.ts +88 -0
  221. package/packages/daemon/src/widgets/lib/git.ts +88 -0
  222. package/packages/daemon/src/widgets/lib/grammar.ts +126 -0
  223. package/packages/daemon/src/widgets/lib/help-overlay.tsx +101 -0
  224. package/packages/daemon/src/widgets/lib/pane-comms.ts +209 -0
  225. package/packages/daemon/src/widgets/lib/theme.ts +194 -0
  226. package/packages/daemon/src/widgets/lib/watcher.ts +132 -0
  227. package/packages/daemon/src/widgets/preview/README.md +3 -0
  228. package/packages/daemon/src/widgets/preview/index.tsx +416 -0
  229. package/packages/daemon/src/widgets/resolve.ts +121 -0
  230. package/packages/daemon/src/widgets/setup/README.md +3 -0
  231. package/packages/daemon/src/widgets/setup/agent-naming.tsx +112 -0
  232. package/packages/daemon/src/widgets/setup/config-tree.tsx +246 -0
  233. package/packages/daemon/src/widgets/setup/detect-panel.tsx +72 -0
  234. package/packages/daemon/src/widgets/setup/field-editor.tsx +265 -0
  235. package/packages/daemon/src/widgets/setup/footer.tsx +107 -0
  236. package/packages/daemon/src/widgets/setup/index.tsx +341 -0
  237. package/packages/daemon/src/widgets/setup/layout-picker.tsx +96 -0
  238. package/packages/daemon/src/widgets/setup/orchestrator-panel.tsx +200 -0
  239. package/packages/daemon/src/widgets/setup/review-panel.tsx +140 -0
  240. package/packages/daemon/src/widgets/setup/setup-model.ts +188 -0
  241. package/packages/daemon/src/widgets/sidebar/index.tsx +527 -0
  242. package/packages/tmux-bridge/package.json +22 -0
  243. package/packages/tmux-bridge/src/errors.ts +28 -0
  244. package/packages/tmux-bridge/src/index.ts +31 -0
  245. package/packages/tmux-bridge/src/monitor.ts +77 -0
  246. package/packages/tmux-bridge/src/panes.ts +136 -0
  247. package/packages/tmux-bridge/src/runner.test.ts +501 -0
  248. package/packages/tmux-bridge/src/runner.ts +91 -0
  249. package/packages/tmux-bridge/src/sessions.ts +126 -0
  250. package/packages/tmux-bridge/src/targeting.test.ts +107 -0
  251. package/packages/tmux-bridge/src/targeting.ts +90 -0
  252. package/scripts/build-tui.mjs +11 -4
  253. package/scripts/perf-mirror.mjs +313 -0
  254. package/scripts/postinstall.js +26 -2
  255. package/skill/SKILL.md +22 -0
  256. package/templates/AGENTS.md +14 -7
  257. package/templates/agent-team-monorepo.yml +8 -0
  258. package/templates/agent-team-nextjs.yml +8 -0
  259. package/templates/agent-team.yml +10 -0
  260. package/templates/convex.yml +2 -0
  261. package/templates/default.yml +11 -5
  262. package/templates/go.yml +4 -0
  263. package/templates/missions.yml +6 -0
  264. package/templates/nextjs.yml +4 -0
  265. package/templates/python.yml +4 -0
  266. package/templates/skills/backend.md +5 -12
  267. package/templates/skills/frontend.md +5 -12
  268. package/templates/skills/general-worker.md +5 -12
  269. package/templates/skills/researcher.md +7 -12
  270. package/templates/skills/reviewer.md +7 -16
  271. package/templates/vite.yml +4 -0
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Project registry — persists the list of projects the user has registered
3
+ * with tmux-ide via the dashboard. Stored at `~/.tmux-ide/projects.json`,
4
+ * written atomically via temp+rename (same approach as task-store).
5
+ *
6
+ * The pure decider (`applyAction`) is exported separately from the io-bound
7
+ * accessors so tests can reason about state transitions without hitting the
8
+ * filesystem. The module also exposes a `projectRegistryEmitter` that the
9
+ * `/ws/events` channel listens on to broadcast `projects.changed` frames.
10
+ */
11
+
12
+ import { EventEmitter } from "node:events";
13
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { dirname, isAbsolute, join, resolve } from "node:path";
16
+ import { z } from "zod";
17
+ import { RegisteredProjectSchemaZ, type RegisteredProject } from "../schemas/registry.ts";
18
+ import { probeProject, sanitizeName, type ProbeIo, type ProjectProbe } from "./project-probe.ts";
19
+
20
+ const REGISTRY_DIR_ENV = "TMUX_IDE_REGISTRY_DIR";
21
+
22
+ const RegistryFileSchemaZ = z.object({
23
+ version: z.literal(1),
24
+ projects: z.array(RegisteredProjectSchemaZ),
25
+ });
26
+
27
+ type RegistryFile = z.infer<typeof RegistryFileSchemaZ>;
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Typed errors — never use stringly-typed catches at the boundary
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export class ProjectRegistryError extends Error {
34
+ readonly code: string;
35
+ constructor(message: string, code: string) {
36
+ super(message);
37
+ this.name = "ProjectRegistryError";
38
+ this.code = code;
39
+ }
40
+ }
41
+
42
+ export class ProjectAlreadyRegisteredError extends ProjectRegistryError {
43
+ readonly suggestion: string;
44
+ constructor(name: string, suggestion: string) {
45
+ super(`Project "${name}" is already registered`, "ALREADY_REGISTERED");
46
+ this.name = "ProjectAlreadyRegisteredError";
47
+ this.suggestion = suggestion;
48
+ }
49
+ }
50
+
51
+ export class ProjectNotFoundError extends ProjectRegistryError {
52
+ constructor(name: string) {
53
+ super(`Project "${name}" not found in registry`, "NOT_FOUND");
54
+ this.name = "ProjectNotFoundError";
55
+ }
56
+ }
57
+
58
+ export class ProjectDirNotFoundError extends ProjectRegistryError {
59
+ constructor(dir: string) {
60
+ super(`Directory "${dir}" does not exist`, "DIR_NOT_FOUND");
61
+ this.name = "ProjectDirNotFoundError";
62
+ }
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Pure decider: state in → state out. No io.
67
+ // ---------------------------------------------------------------------------
68
+
69
+ export type RegistryAction =
70
+ | { type: "register"; project: RegisteredProject }
71
+ | { type: "unregister"; name: string }
72
+ | { type: "replace"; project: RegisteredProject };
73
+
74
+ export function applyAction(
75
+ state: readonly RegisteredProject[],
76
+ action: RegistryAction,
77
+ ): RegisteredProject[] {
78
+ switch (action.type) {
79
+ case "register":
80
+ return [...state, action.project];
81
+ case "unregister":
82
+ return state.filter((p) => p.name !== action.name);
83
+ case "replace":
84
+ return state.map((p) => (p.name === action.project.name ? action.project : p));
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Resolve a unique name for a probed project, appending `-2`, `-3`, … until
90
+ * we don't collide with an existing entry.
91
+ */
92
+ export function resolveUniqueName(state: readonly RegisteredProject[], desired: string): string {
93
+ const used = new Set(state.map((p) => p.name));
94
+ if (!used.has(desired)) return desired;
95
+ let counter = 2;
96
+ while (used.has(`${desired}-${counter}`)) counter++;
97
+ return `${desired}-${counter}`;
98
+ }
99
+
100
+ /**
101
+ * Build a `RegisteredProject` value from a probe + chosen name + timestamp.
102
+ * Pure — separated from `registerProject` so tests can verify the shape
103
+ * without io.
104
+ */
105
+ export function buildRegisteredProject(
106
+ probe: ProjectProbe,
107
+ name: string,
108
+ registeredAt: string,
109
+ ): RegisteredProject {
110
+ return {
111
+ name,
112
+ dir: probe.dir,
113
+ hasIdeYml: probe.hasIdeYml,
114
+ gitOrigin: probe.gitOrigin,
115
+ gitBranch: probe.gitBranch,
116
+ registeredAt,
117
+ };
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Module-level emitter — listened on by ws-events.handleWsEventsConnection
122
+ // ---------------------------------------------------------------------------
123
+
124
+ export const projectRegistryEmitter = new EventEmitter();
125
+ // One listener per connected ws-events client; can grow with tabs.
126
+ projectRegistryEmitter.setMaxListeners(0);
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Persistence — io-bound, single mutex (registry is small + writes are rare)
130
+ // ---------------------------------------------------------------------------
131
+
132
+ function registryDir(): string {
133
+ const override = process.env[REGISTRY_DIR_ENV];
134
+ if (override && override.length > 0) return override;
135
+ return join(homedir(), ".tmux-ide");
136
+ }
137
+
138
+ function registryPath(): string {
139
+ return join(registryDir(), "projects.json");
140
+ }
141
+
142
+ let cache: RegisteredProject[] | null = null;
143
+
144
+ function readDisk(): RegisteredProject[] {
145
+ const path = registryPath();
146
+ if (!existsSync(path)) return [];
147
+ const raw = readFileSync(path, "utf-8");
148
+ if (raw.trim().length === 0) return [];
149
+ let parsed: unknown;
150
+ try {
151
+ parsed = JSON.parse(raw);
152
+ } catch {
153
+ console.warn("[project-registry] %s contains invalid JSON; ignoring", path);
154
+ return [];
155
+ }
156
+ const result = RegistryFileSchemaZ.safeParse(parsed);
157
+ if (!result.success) {
158
+ console.warn(
159
+ "[project-registry] %s failed schema validation; ignoring (%s)",
160
+ path,
161
+ result.error.issues
162
+ .slice(0, 3)
163
+ .map((i) => `${i.path.join(".")}: ${i.message}`)
164
+ .join("; "),
165
+ );
166
+ return [];
167
+ }
168
+ return result.data.projects;
169
+ }
170
+
171
+ function writeDisk(projects: RegisteredProject[]): void {
172
+ const path = registryPath();
173
+ const dir = dirname(path);
174
+ mkdirSync(dir, { recursive: true });
175
+ const file: RegistryFile = { version: 1, projects };
176
+ const tmpPath = `${path}.tmp`;
177
+ writeFileSync(tmpPath, JSON.stringify(file, null, 2) + "\n");
178
+ renameSync(tmpPath, path);
179
+ }
180
+
181
+ function ensureCache(): RegisteredProject[] {
182
+ if (cache !== null) return cache;
183
+ cache = readDisk();
184
+ return cache;
185
+ }
186
+
187
+ function commit(next: RegisteredProject[]): void {
188
+ cache = next;
189
+ writeDisk(next);
190
+ projectRegistryEmitter.emit("change");
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Public api
195
+ // ---------------------------------------------------------------------------
196
+
197
+ export function listProjects(): RegisteredProject[] {
198
+ // Return a defensive copy so callers can't mutate the cache.
199
+ return [...ensureCache()];
200
+ }
201
+
202
+ export function getProject(name: string): RegisteredProject | null {
203
+ return ensureCache().find((p) => p.name === name) ?? null;
204
+ }
205
+
206
+ export interface RegisterInput {
207
+ dir: string;
208
+ name?: string;
209
+ /** Pluggable io for tests. */
210
+ io?: ProbeIo;
211
+ /** Override `Date.now()` for deterministic tests. */
212
+ now?: () => Date;
213
+ /** Override existsSync for the dir-validity check (tests). */
214
+ exists?: (path: string) => boolean;
215
+ }
216
+
217
+ export async function registerProject(input: RegisterInput): Promise<RegisteredProject> {
218
+ const exists = input.exists ?? existsSync;
219
+ const absoluteDir = isAbsolute(input.dir) ? input.dir : resolve(input.dir);
220
+ if (!exists(absoluteDir)) {
221
+ throw new ProjectDirNotFoundError(absoluteDir);
222
+ }
223
+
224
+ const probe = await probeProject(absoluteDir, input.io);
225
+ const state = ensureCache();
226
+
227
+ // If a name was explicitly requested, treat collisions as hard errors and
228
+ // suggest an alternative. If no name was given, auto-resolve via -2/-3/…
229
+ const desired = input.name ? sanitizeName(input.name) : probe.name;
230
+ const cleaned = desired.length > 0 ? desired : probe.name;
231
+ let resolvedName: string;
232
+ if (input.name) {
233
+ if (state.some((p) => p.name === cleaned)) {
234
+ throw new ProjectAlreadyRegisteredError(cleaned, resolveUniqueName(state, cleaned));
235
+ }
236
+ resolvedName = cleaned;
237
+ } else {
238
+ resolvedName = resolveUniqueName(state, cleaned);
239
+ }
240
+
241
+ // Reject re-registering the same dir (different name). Probe again under a
242
+ // different name is fine, but we want to avoid duplicate dirs in the list.
243
+ const dupDir = state.find((p) => p.dir === probe.dir);
244
+ if (dupDir) {
245
+ throw new ProjectAlreadyRegisteredError(dupDir.name, dupDir.name);
246
+ }
247
+
248
+ const now = (input.now ?? (() => new Date()))();
249
+ const project = buildRegisteredProject(probe, resolvedName, now.toISOString());
250
+ commit(applyAction(state, { type: "register", project }));
251
+ return project;
252
+ }
253
+
254
+ export function unregisterProject(name: string): void {
255
+ const state = ensureCache();
256
+ if (!state.some((p) => p.name === name)) {
257
+ throw new ProjectNotFoundError(name);
258
+ }
259
+ commit(applyAction(state, { type: "unregister", name }));
260
+ }
261
+
262
+ export interface ProbeOptions {
263
+ io?: ProbeIo;
264
+ }
265
+
266
+ /**
267
+ * Re-probe a registered project by name, persist the refreshed snapshot, and
268
+ * broadcast the change. Throws if the project isn't registered.
269
+ */
270
+ export async function refreshProject(
271
+ name: string,
272
+ options: ProbeOptions = {},
273
+ ): Promise<RegisteredProject> {
274
+ const state = ensureCache();
275
+ const existing = state.find((p) => p.name === name);
276
+ if (!existing) throw new ProjectNotFoundError(name);
277
+
278
+ const probe = await probeProject(existing.dir, options.io);
279
+ const refreshed = buildRegisteredProject(probe, existing.name, existing.registeredAt);
280
+ commit(applyAction(state, { type: "replace", project: refreshed }));
281
+ return refreshed;
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // Test helpers — never call from production code
286
+ // ---------------------------------------------------------------------------
287
+
288
+ /** Reset the in-memory cache. Tests use this between cases. */
289
+ export function _resetCacheForTests(): void {
290
+ cache = null;
291
+ }
292
+
293
+ /** Force a re-read from disk on next access. */
294
+ export function _invalidateCacheForTests(): void {
295
+ cache = null;
296
+ }
@@ -0,0 +1,122 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ const SPINNERS = /^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⠂⠒⠢⠆⠐⠠⠄◐◓◑◒|/\\-] /;
4
+
5
+ interface MonitorPane {
6
+ id: string;
7
+ pid: string;
8
+ cmd?: string;
9
+ title?: string;
10
+ role?: string;
11
+ type?: string;
12
+ name?: string;
13
+ }
14
+
15
+ // --- Port detection (pure helpers) ---
16
+
17
+ function getListeningPids(): Set<string> {
18
+ // Returns Set of PIDs that have a listening TCP port in range 1024-20000
19
+ try {
20
+ const raw = execFileSync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-FpPn"], {
21
+ encoding: "utf-8",
22
+ stdio: ["ignore", "pipe", "ignore"],
23
+ timeout: 2000,
24
+ });
25
+ const pids = new Set<string>();
26
+ let currentPid: string | null = null;
27
+ for (const line of raw.split("\n")) {
28
+ if (line.startsWith("p")) {
29
+ currentPid = line.slice(1);
30
+ } else if (line.startsWith("n") && currentPid) {
31
+ const match = line.match(/:(\d+)$/);
32
+ if (match) {
33
+ const port = parseInt(match[1]!, 10);
34
+ if (port >= 1024 && port <= 20000) pids.add(currentPid);
35
+ }
36
+ }
37
+ }
38
+ return pids;
39
+ } catch {
40
+ return new Set<string>();
41
+ }
42
+ }
43
+
44
+ function getProcessTree(): Map<string, string> {
45
+ // Returns Map<pid, ppid>
46
+ try {
47
+ const raw = execFileSync("ps", ["-axo", "pid=,ppid="], {
48
+ encoding: "utf-8",
49
+ stdio: ["ignore", "pipe", "ignore"],
50
+ timeout: 2000,
51
+ });
52
+ const tree = new Map<string, string>();
53
+ for (const line of raw.trim().split("\n")) {
54
+ const parts = line.trim().split(/\s+/);
55
+ if (parts.length === 2) tree.set(parts[0]!, parts[1]!);
56
+ }
57
+ return tree;
58
+ } catch {
59
+ return new Map<string, string>();
60
+ }
61
+ }
62
+
63
+ export function computePortPanes(
64
+ panes: MonitorPane[],
65
+ { listeners, tree }: { listeners?: Set<string>; tree?: Map<string, string> } = {},
66
+ ): Set<string> {
67
+ // Walk up from each listening PID to find which pane owns it
68
+ const resolvedListeners = listeners ?? getListeningPids();
69
+ const resolvedTree = tree ?? getProcessTree();
70
+ if (resolvedListeners.size === 0) return new Set<string>();
71
+
72
+ const panePids = new Map(panes.map((p) => [p.pid, p.id]));
73
+ const result = new Set<string>();
74
+
75
+ for (const listenerPid of resolvedListeners) {
76
+ let pid: string | undefined = listenerPid;
77
+ while (pid && pid !== "0") {
78
+ if (panePids.has(pid)) {
79
+ result.add(panePids.get(pid)!);
80
+ break;
81
+ }
82
+ pid = resolvedTree.get(pid);
83
+ }
84
+ }
85
+ return result;
86
+ }
87
+
88
+ // --- Agent detection ---
89
+
90
+ export function computeAgentStates(panes: MonitorPane[]): Map<string, "busy" | "idle" | null> {
91
+ // Returns Map<paneId, "busy" | "idle" | null>
92
+ const states = new Map<string, "busy" | "idle" | null>();
93
+ for (const pane of panes) {
94
+ const role = pane.role ?? "";
95
+
96
+ // Primary: use @ide_role pane option if available
97
+ if (role === "lead" || role === "teammate") {
98
+ states.set(pane.id, SPINNERS.test(pane.title ?? "") ? "busy" : "idle");
99
+ continue;
100
+ }
101
+
102
+ // Fallback: command-based detection for pre-upgrade sessions
103
+ const cmd = (pane.cmd ?? "").toLowerCase();
104
+ if (!cmd.includes("claude") && !cmd.includes("codex")) {
105
+ states.set(pane.id, null);
106
+ continue;
107
+ }
108
+ states.set(pane.id, SPINNERS.test(pane.title ?? "") ? "busy" : "idle");
109
+ }
110
+ return states;
111
+ }
112
+
113
+ // The per-session tick loop / orchestrator bootstrap that used to live
114
+ // here was orphaned by the canonical-tree fold — `daemon-embed.ts` owns
115
+ // both responsibilities now (see `startEmbeddedDaemon` + its
116
+ // orchestrator branch). Removing the legacy CLI block also fixes the
117
+ // esbuild-bundle case where `import.meta.url`-vs-`process.argv[1]`
118
+ // resolved to true at the bin/cli.js entry, causing the daemon's
119
+ // session monitor to hijack every CLI invocation (see N1 of
120
+ // docs/npm-distribution-audit.md). The pure exports above
121
+ // (`computeAgentStates`, `computePortPanes`) remain — that's what
122
+ // `daemon-embed.ts` actually imports.
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Composable builders for tmux session configuration.
3
+ * Each returns an array of tmux command arrays.
4
+ */
5
+
6
+ import type { ThemeConfig, TmuxCommand } from "../types.ts";
7
+
8
+ export function buildSessionOptions(
9
+ session: string,
10
+ { theme = {} }: { theme?: ThemeConfig } = {},
11
+ ): TmuxCommand[] {
12
+ return [
13
+ ...themeOptions(session, theme),
14
+ ...borderOptions(session, theme),
15
+ ...behaviorOptions(session),
16
+ ...statusBarOptions(session, theme),
17
+ ...keyBindings(),
18
+ ];
19
+ }
20
+
21
+ export function themeOptions(session: string, theme: ThemeConfig): TmuxCommand[] {
22
+ const accent = theme.accent ?? "colour75";
23
+ const border = theme.border ?? "colour238";
24
+ const bg = theme.bg ?? "colour235";
25
+ const fg = theme.fg ?? "colour248";
26
+
27
+ return [
28
+ ["set-option", "-t", session, "status-style", `bg=${bg},fg=${fg}`],
29
+ ["set-option", "-t", session, "pane-border-style", `fg=${border}`],
30
+ ["set-option", "-t", session, "pane-active-border-style", `fg=${accent}`],
31
+ ];
32
+ }
33
+
34
+ export function borderOptions(session: string, theme: ThemeConfig): TmuxCommand[] {
35
+ const accent = theme.accent ?? "colour75";
36
+ const border = theme.border ?? "colour238";
37
+ const fg = theme.fg ?? "colour248";
38
+
39
+ return [
40
+ ["set-option", "-t", session, "pane-border-status", "top"],
41
+ [
42
+ "set-option",
43
+ "-t",
44
+ session,
45
+ "pane-border-format",
46
+ ` #{?pane_active,#[fg=${accent}#,bold]▸ #T #[fg=${fg}]#{pane_current_path},#[fg=${border}]· #T #{pane_current_path}} `,
47
+ ],
48
+ ];
49
+ }
50
+
51
+ export function behaviorOptions(session: string): TmuxCommand[] {
52
+ return [
53
+ ["set-option", "-t", session, "mouse", "on"],
54
+ ["set-option", "-t", session, "escape-time", "0"],
55
+ ["set-option", "-t", session, "status-interval", "1"],
56
+ ];
57
+ }
58
+
59
+ export function statusBarOptions(session: string, theme: ThemeConfig): TmuxCommand[] {
60
+ const accent = theme.accent ?? "colour75";
61
+ const border = theme.border ?? "colour238";
62
+ const fg = theme.fg ?? "colour248";
63
+
64
+ // Pane tab components — each is a self-contained piece
65
+ const agentIndicator = [
66
+ `#{?#{==:#{@agent_busy},1},#[fg=${accent}]⏺ ,`,
67
+ `#{?#{==:#{@agent_idle},1},#[fg=${border}]● ,}}`,
68
+ ].join("");
69
+ const portIndicator = `#{?#{==:#{@has_port},1},#[fg=green]⏺ ,}`;
70
+ const paneStyle = `#{?pane_active,#[fg=${accent}],#[fg=${border}]}`;
71
+ const paneTab = `${agentIndicator}${portIndicator}${paneStyle}#[range=pane|#{pane_id}] #T #[norange]#[default]`;
72
+ const separator = `#{?loop_last_flag,,#[fg=${border}]│}`;
73
+
74
+ return [
75
+ [
76
+ "set-option",
77
+ "-t",
78
+ session,
79
+ "status-left",
80
+ `#[fg=colour0,bg=${accent},bold] ${session.toUpperCase()} IDE #[default] `,
81
+ ],
82
+ ["set-option", "-t", session, "status-left-length", "30"],
83
+ [
84
+ "set-option",
85
+ "-t",
86
+ session,
87
+ "status-right",
88
+ `#[fg=colour243]%H:%M #[fg=${accent}]│ #[fg=${fg}]%b %d `,
89
+ ],
90
+ ["set-option", "-t", session, "status-justify", "centre"],
91
+ ["set-option", "-t", session, "window-status-current-format", `#[fg=${accent},bold]●`],
92
+ ["set-option", "-t", session, "window-status-format", `#[fg=${border}]○`],
93
+ ["set-option", "-t", session, "status", "2"],
94
+ ["set-option", "-t", session, "status-format[1]", ` #{P:${paneTab}${separator}}`],
95
+ ];
96
+ }
97
+
98
+ export function keyBindings(): TmuxCommand[] {
99
+ return [["bind-key", "-n", "MouseDown1StatusDefault", "select-pane", "-t", "="]];
100
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Quote a string for safe embedding in POSIX sh/bash commands as a single word.
3
+ * Wraps in single quotes; embedded `'` becomes `'\''` (end quote, literal quote, resume).
4
+ */
5
+ export function shellEscape(s: string): string {
6
+ // Each "'" in the input becomes '\'' outside a single-quoted string.
7
+ return "'" + s.replace(/'/g, "'\\''") + "'";
8
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Compute absolute sizes for items where some have explicit sizes and others don't.
3
+ * Items with `size` (e.g. "70%") keep their value; remaining space is split equally
4
+ * among items without a size.
5
+ */
6
+ export function computeSizes(items: { size?: string }[]): number[] {
7
+ let claimed = 0;
8
+ let unclaimed = 0;
9
+ for (const item of items) {
10
+ if (item.size) {
11
+ claimed += parseFloat(item.size);
12
+ } else {
13
+ unclaimed++;
14
+ }
15
+ }
16
+ const remaining = Math.max(0, 100 - claimed);
17
+ const defaultSize = unclaimed > 0 ? remaining / unclaimed : 0;
18
+ return items.map((item) => (item.size ? parseFloat(item.size) : defaultSize));
19
+ }
20
+
21
+ /**
22
+ * Convert absolute sizes (e.g. [70, 30]) to tmux split percentages for sequential splits.
23
+ * Returns array of -p values (one per split after the first item).
24
+ *
25
+ * Each tmux split divides the current pane. The percentage given to -p is
26
+ * the portion allocated to the NEW (bottom/right) pane.
27
+ */
28
+ export function toSplitPercents(sizes: number[]): number[] {
29
+ const percents: number[] = [];
30
+ for (let i = 1; i < sizes.length; i++) {
31
+ const remaining = sizes.slice(i - 1).reduce((a, b) => a + b, 0);
32
+ const topShare = sizes[i - 1]!;
33
+ percents.push(Math.round(((remaining - topShare) / remaining) * 100));
34
+ }
35
+ return percents;
36
+ }