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,56 @@
1
+ // Based on VibeTunnel (MIT) — github.com/amantus-ai/vibetunnel
2
+ import type { Context, Next, MiddlewareHandler } from "hono";
3
+ import { AuthService } from "./auth-service.ts";
4
+ import type { AuthConfig } from "./types.ts";
5
+
6
+ /**
7
+ * Hono middleware that checks Authorization Bearer header.
8
+ *
9
+ * - Bypasses /health and /api/auth/* routes.
10
+ * - When method is "none" (default), passes through everything (backward compatible).
11
+ * - Otherwise validates the JWT and sets c.set("userId", ...).
12
+ */
13
+ export function authMiddleware(authService: AuthService, config: AuthConfig): MiddlewareHandler {
14
+ return async (c: Context, next: Next) => {
15
+ // Backward compatible: no auth required when method is "none"
16
+ if (config.method === "none") {
17
+ return next();
18
+ }
19
+
20
+ const path = new URL(c.req.url).pathname;
21
+
22
+ // Bypass health and auth endpoints
23
+ if (path === "/health" || path.startsWith("/api/auth/")) {
24
+ return next();
25
+ }
26
+
27
+ const authHeader = c.req.header("Authorization");
28
+ if (!authHeader?.startsWith("Bearer ")) {
29
+ return c.json({ error: "Missing or invalid Authorization header" }, 401);
30
+ }
31
+
32
+ const token = authHeader.slice(7);
33
+ const result = authService.verifyToken(token);
34
+ if (!result.valid) {
35
+ return c.json({ error: "Invalid or expired token" }, 401);
36
+ }
37
+
38
+ c.set("userId", result.userId);
39
+ return next();
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Validate a JWT token from a query parameter (for WebSocket upgrade).
45
+ * Returns the userId if valid, null otherwise.
46
+ */
47
+ export function validateWsToken(
48
+ authService: AuthService,
49
+ config: AuthConfig,
50
+ token: string | null,
51
+ ): string | null {
52
+ if (config.method === "none") return "anonymous";
53
+ if (!token) return null;
54
+ const result = authService.verifyToken(token);
55
+ return result.valid ? (result.userId ?? null) : null;
56
+ }
@@ -0,0 +1,2 @@
1
+ // Schema extracted to @tmux-ide/contracts (T056). Re-export shim.
2
+ export { AuthConfigSchema, type AuthConfig } from "@tmux-ide/contracts";
@@ -0,0 +1,5 @@
1
+ import { randomBytes } from "node:crypto";
2
+
3
+ export function generateAuthToken(): string {
4
+ return randomBytes(32).toString("base64url");
5
+ }
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Character-range authorship tracking for plan files.
3
+ *
4
+ * Follows the @proof/core marks model: each mark tracks authorship at the
5
+ * character range level with quote-based re-anchoring when positions shift.
6
+ *
7
+ * @module authorship
8
+ */
9
+
10
+ // ============================================================================
11
+ // Types (following @proof/core marks model)
12
+ // ============================================================================
13
+
14
+ export type MarkKind =
15
+ | "authored"
16
+ | "approved"
17
+ | "flagged"
18
+ | "comment"
19
+ | "insert"
20
+ | "delete"
21
+ | "replace";
22
+
23
+ export interface MarkRange {
24
+ from: number;
25
+ to: number;
26
+ }
27
+
28
+ export interface Mark {
29
+ id: string;
30
+ kind: MarkKind;
31
+ by: string; // "ai:François" or "human:thijs"
32
+ at: string; // ISO timestamp
33
+ range: MarkRange; // character positions in the clean content
34
+ quote: string; // text content for re-anchoring
35
+ orphaned?: boolean;
36
+ }
37
+
38
+ export interface MarksDocument {
39
+ version: number;
40
+ marks: Record<string, Mark>;
41
+ }
42
+
43
+ export interface AuthorshipStats {
44
+ aiPercent: number;
45
+ humanPercent: number;
46
+ totalChars: number;
47
+ }
48
+
49
+ // ============================================================================
50
+ // Comment block format
51
+ // ============================================================================
52
+
53
+ const MARKS_START = "<!-- TMUX-IDE:MARKS";
54
+ const MARKS_END = "-->";
55
+
56
+ // ============================================================================
57
+ // ID generation
58
+ // ============================================================================
59
+
60
+ let markIdCounter = 0;
61
+
62
+ export function generateMarkId(): string {
63
+ return `m${Date.now()}_${++markIdCounter}`;
64
+ }
65
+
66
+ // ============================================================================
67
+ // Quote normalization (matches @proof/core)
68
+ // ============================================================================
69
+
70
+ export function normalizeQuote(text: string): string {
71
+ return text.replace(/\s+/g, " ").trim();
72
+ }
73
+
74
+ // ============================================================================
75
+ // Extract / Embed
76
+ // ============================================================================
77
+
78
+ /**
79
+ * Extract marks from a markdown file.
80
+ * Returns clean content (without the comment block) and parsed marks.
81
+ */
82
+ export function extractMarks(markdown: string): {
83
+ content: string;
84
+ marks: MarksDocument | null;
85
+ } {
86
+ const startIdx = markdown.lastIndexOf(MARKS_START);
87
+ if (startIdx === -1) {
88
+ return { content: markdown, marks: null };
89
+ }
90
+
91
+ const endIdx = markdown.indexOf(MARKS_END, startIdx + MARKS_START.length);
92
+ if (endIdx === -1) {
93
+ return { content: markdown, marks: null };
94
+ }
95
+
96
+ const jsonStr = markdown.slice(startIdx + MARKS_START.length, endIdx).trim();
97
+ const content = (
98
+ markdown.slice(0, startIdx) + markdown.slice(endIdx + MARKS_END.length)
99
+ ).trimEnd();
100
+
101
+ try {
102
+ const data = JSON.parse(jsonStr) as MarksDocument;
103
+ return { content, marks: data };
104
+ } catch {
105
+ return { content, marks: null };
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Embed marks into a markdown file.
111
+ * Strips any existing marks comment and appends a new one at the end.
112
+ */
113
+ export function embedMarks(markdown: string, doc: MarksDocument): string {
114
+ const { content } = extractMarks(markdown);
115
+ const json = JSON.stringify(doc, null, 2);
116
+ return `${content}\n\n${MARKS_START}\n${json}\n${MARKS_END}\n`;
117
+ }
118
+
119
+ // ============================================================================
120
+ // Mark creation helpers
121
+ // ============================================================================
122
+
123
+ export function createAuthored(by: string, range: MarkRange, quote: string): Mark {
124
+ return {
125
+ id: generateMarkId(),
126
+ kind: "authored",
127
+ by,
128
+ at: new Date().toISOString(),
129
+ range,
130
+ quote: normalizeQuote(quote),
131
+ };
132
+ }
133
+
134
+ // ============================================================================
135
+ // Stats calculation
136
+ // ============================================================================
137
+
138
+ /**
139
+ * Calculate AI vs human authorship percentages from marks.
140
+ * Only counts 'authored' marks. Uses character range lengths.
141
+ */
142
+ export function calculateStats(marks: Record<string, Mark>): AuthorshipStats {
143
+ let aiChars = 0;
144
+ let humanChars = 0;
145
+
146
+ for (const mark of Object.values(marks)) {
147
+ if (mark.kind !== "authored") continue;
148
+ if (mark.orphaned) continue;
149
+ const chars = mark.range.to - mark.range.from;
150
+ if (mark.by.startsWith("ai:") || mark.by === "ai") {
151
+ aiChars += chars;
152
+ } else {
153
+ humanChars += chars;
154
+ }
155
+ }
156
+
157
+ const totalChars = aiChars + humanChars;
158
+ if (totalChars === 0) {
159
+ return { aiPercent: 0, humanPercent: 0, totalChars: 0 };
160
+ }
161
+
162
+ return {
163
+ aiPercent: Math.round((aiChars / totalChars) * 100),
164
+ humanPercent: Math.round((humanChars / totalChars) * 100),
165
+ totalChars,
166
+ };
167
+ }
168
+
169
+ // ============================================================================
170
+ // Re-anchoring: recover positions when content shifts
171
+ // ============================================================================
172
+
173
+ /**
174
+ * Find the position of a quote in content text.
175
+ * Returns { from, to } or null if not found.
176
+ */
177
+ function findQuotePosition(content: string, quote: string): MarkRange | null {
178
+ if (!quote) return null;
179
+ const normalized = normalizeQuote(quote);
180
+ const normalizedContent = normalizeQuote(content);
181
+ const idx = normalizedContent.indexOf(normalized);
182
+ if (idx === -1) return null;
183
+
184
+ // Map back to original content positions (approximate — whitespace may differ)
185
+ // Walk through original content counting normalized chars
186
+ let origFrom = -1;
187
+ let normalizedPos = 0;
188
+ for (let i = 0; i < content.length; i++) {
189
+ if (/\s/.test(content[i]!) && (i === 0 || /\s/.test(content[i - 1]!))) continue;
190
+ if (normalizedPos === idx && origFrom === -1) origFrom = i;
191
+ if (normalizedPos === idx + normalized.length) {
192
+ return { from: origFrom, to: i };
193
+ }
194
+ normalizedPos++;
195
+ }
196
+
197
+ // Fallback: simple indexOf on original
198
+ const simpleIdx = content.indexOf(quote);
199
+ if (simpleIdx !== -1) {
200
+ return { from: simpleIdx, to: simpleIdx + quote.length };
201
+ }
202
+
203
+ return null;
204
+ }
205
+
206
+ /**
207
+ * Re-anchor marks whose ranges may have shifted due to content edits.
208
+ * Uses quote text to find new positions. Marks orphaned if quote not found.
209
+ */
210
+ export function reanchorMarks(content: string, marks: Record<string, Mark>): Record<string, Mark> {
211
+ const result: Record<string, Mark> = {};
212
+
213
+ for (const [id, mark] of Object.entries(marks)) {
214
+ // Check if current range still matches quote
215
+ const currentText = content.slice(mark.range.from, mark.range.to);
216
+ if (normalizeQuote(currentText) === normalizeQuote(mark.quote)) {
217
+ result[id] = mark;
218
+ continue;
219
+ }
220
+
221
+ // Try to re-anchor using quote
222
+ const newRange = findQuotePosition(content, mark.quote);
223
+ if (newRange) {
224
+ result[id] = { ...mark, range: newRange, orphaned: false };
225
+ } else {
226
+ result[id] = { ...mark, orphaned: true };
227
+ }
228
+ }
229
+
230
+ return result;
231
+ }
232
+
233
+ // ============================================================================
234
+ // Tagging: tag entire content as authored by a given actor
235
+ // ============================================================================
236
+
237
+ /**
238
+ * Tag the full content as authored by the given actor.
239
+ * Preserves existing marks and only adds a mark for uncovered ranges.
240
+ */
241
+ export function tagContent(markdown: string, by: string): string {
242
+ const { content, marks: existingDoc } = extractMarks(markdown);
243
+ const existingMarks = existingDoc?.marks ?? {};
244
+
245
+ // Re-anchor existing marks against current content
246
+ const anchored =
247
+ Object.keys(existingMarks).length > 0 ? reanchorMarks(content, existingMarks) : {};
248
+
249
+ // Find uncovered character ranges
250
+ const covered = new Set<number>();
251
+ for (const mark of Object.values(anchored)) {
252
+ if (mark.kind !== "authored" || mark.orphaned) continue;
253
+ for (let i = mark.range.from; i < mark.range.to && i < content.length; i++) {
254
+ covered.add(i);
255
+ }
256
+ }
257
+
258
+ // Build marks for uncovered ranges (coalesce adjacent)
259
+ const newMarks: Record<string, Mark> = { ...anchored };
260
+ let rangeStart = -1;
261
+
262
+ for (let i = 0; i <= content.length; i++) {
263
+ const isCovered = covered.has(i);
264
+ const atEnd = i === content.length;
265
+
266
+ if (!isCovered && !atEnd && rangeStart === -1) {
267
+ rangeStart = i;
268
+ } else if ((isCovered || atEnd) && rangeStart !== -1) {
269
+ const quote = content.slice(rangeStart, i);
270
+ if (quote.trim().length > 0) {
271
+ const mark = createAuthored(by, { from: rangeStart, to: i }, quote);
272
+ newMarks[mark.id] = mark;
273
+ }
274
+ rangeStart = -1;
275
+ }
276
+ }
277
+
278
+ const doc: MarksDocument = { version: 2, marks: newMarks };
279
+ return embedMarks(content, doc);
280
+ }
@@ -0,0 +1,122 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ export interface CanonicalDaemonInfo {
6
+ readonly pid: number;
7
+ readonly port: number;
8
+ readonly version: string;
9
+ readonly startedAt: string;
10
+ readonly bindHostname: string;
11
+ readonly authToken: string | null;
12
+ }
13
+
14
+ const DAEMON_INFO_DIR_ENV = "TMUX_IDE_DAEMON_INFO_DIR";
15
+ const REGISTRY_DIR_ENV = "TMUX_IDE_REGISTRY_DIR";
16
+ const DAEMON_INFO_FILE = "daemon.json";
17
+
18
+ export function getCanonicalDaemonInfoPath(): string {
19
+ const dir =
20
+ process.env[DAEMON_INFO_DIR_ENV] ??
21
+ process.env[REGISTRY_DIR_ENV] ??
22
+ join(homedir(), ".tmux-ide");
23
+ return join(dir, DAEMON_INFO_FILE);
24
+ }
25
+
26
+ function parseCanonicalDaemonInfo(raw: unknown): CanonicalDaemonInfo | null {
27
+ if (!raw || typeof raw !== "object") return null;
28
+ const info = raw as Partial<CanonicalDaemonInfo>;
29
+ const pid = info.pid;
30
+ const port = info.port;
31
+ if (typeof pid !== "number" || typeof port !== "number") return null;
32
+ if (!Number.isInteger(pid) || !Number.isInteger(port)) return null;
33
+ if (typeof info.version !== "string" || typeof info.startedAt !== "string") return null;
34
+ if (typeof info.bindHostname !== "string") return null;
35
+ if (info.authToken !== null && typeof info.authToken !== "string") return null;
36
+ return {
37
+ pid,
38
+ port,
39
+ version: info.version,
40
+ startedAt: info.startedAt,
41
+ bindHostname: info.bindHostname,
42
+ authToken: info.authToken,
43
+ };
44
+ }
45
+
46
+ export function writeCanonicalDaemonInfo(info: CanonicalDaemonInfo): void {
47
+ const path = getCanonicalDaemonInfoPath();
48
+ mkdirSync(dirname(path), { recursive: true });
49
+ const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`;
50
+ const persisted: CanonicalDaemonInfo = {
51
+ pid: info.pid,
52
+ port: info.port,
53
+ version: info.version,
54
+ startedAt: info.startedAt,
55
+ bindHostname: info.bindHostname,
56
+ authToken: info.authToken,
57
+ };
58
+ writeFileSync(tmpPath, JSON.stringify(persisted, null, 2) + "\n", "utf-8");
59
+ renameSync(tmpPath, path);
60
+ }
61
+
62
+ export function readCanonicalDaemonInfo(): CanonicalDaemonInfo | null {
63
+ const path = getCanonicalDaemonInfoPath();
64
+ if (!existsSync(path)) return null;
65
+ try {
66
+ return parseCanonicalDaemonInfo(JSON.parse(readFileSync(path, "utf-8")));
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ export function clearCanonicalDaemonInfo(): void {
73
+ rmSync(getCanonicalDaemonInfoPath(), { force: true });
74
+ }
75
+
76
+ function isPidAlive(pid: number): boolean {
77
+ try {
78
+ process.kill(pid, 0);
79
+ return true;
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ function probeHostname(bindHostname: string): string {
86
+ return bindHostname === "0.0.0.0" ? "127.0.0.1" : bindHostname;
87
+ }
88
+
89
+ function timeoutSignal(ms: number): AbortSignal {
90
+ const controller = new AbortController();
91
+ setTimeout(() => controller.abort(), ms).unref?.();
92
+ return controller.signal;
93
+ }
94
+
95
+ /**
96
+ * Version-skew guard. A client (desktop app, editor extension, second
97
+ * CLI) attaching to the canonical daemon compiles in the daemon
98
+ * version it expects. If the live daemon advertises a different
99
+ * `version`, the wire contract may have drifted — warn loudly rather
100
+ * than failing silently on a mismatched action/WS schema.
101
+ */
102
+ export function warnOnDaemonVersionSkew(info: CanonicalDaemonInfo, expectedVersion: string): void {
103
+ if (info.version === expectedVersion) return;
104
+ console.warn(
105
+ `[tmux-ide] canonical daemon version skew: daemon.json reports ` +
106
+ `"${info.version}" but this client expects "${expectedVersion}". ` +
107
+ `The action/WS contract may have drifted — restart the canonical ` +
108
+ `daemon (tmux-ide) so it matches this client build.`,
109
+ );
110
+ }
111
+
112
+ export async function isCanonicalDaemonAlive(info: CanonicalDaemonInfo): Promise<boolean> {
113
+ if (!isPidAlive(info.pid)) return false;
114
+ try {
115
+ const res = await fetch(`http://${probeHostname(info.bindHostname)}:${info.port}/health`, {
116
+ signal: timeoutSignal(750),
117
+ });
118
+ return res.ok;
119
+ } catch {
120
+ return false;
121
+ }
122
+ }
@@ -0,0 +1,216 @@
1
+ import { createRequire } from "node:module";
2
+ import { z } from "zod";
3
+ import {
4
+ ActionContractsZ,
5
+ type ActionInput,
6
+ type ActionName,
7
+ type ActionResult,
8
+ } from "../command-center/actions/contract.ts";
9
+ import type { ActionErrorCode } from "../command-center/actions/errors.ts";
10
+ import {
11
+ clearCanonicalDaemonInfo,
12
+ isCanonicalDaemonAlive,
13
+ readCanonicalDaemonInfo,
14
+ warnOnDaemonVersionSkew,
15
+ type CanonicalDaemonInfo,
16
+ } from "./canonical-daemon.ts";
17
+ import {
18
+ startEmbeddedDaemon,
19
+ type EmbeddedDaemonHandle,
20
+ type EmbeddedDaemonOptions,
21
+ } from "./daemon-embed.ts";
22
+
23
+ interface ActionFailure {
24
+ code: ActionErrorCode;
25
+ message: string;
26
+ details?: unknown;
27
+ }
28
+
29
+ const FailureEnvelopeZ = z.object({
30
+ ok: z.literal(false),
31
+ error: z.object({
32
+ code: z.string(),
33
+ message: z.string(),
34
+ details: z.unknown().optional(),
35
+ }),
36
+ });
37
+
38
+ interface CliActionBridgeDeps {
39
+ fetch: typeof fetch;
40
+ cwd: () => string;
41
+ readCanonicalDaemonInfo: () => CanonicalDaemonInfo | null;
42
+ clearCanonicalDaemonInfo: () => void;
43
+ isCanonicalDaemonAlive: (info: CanonicalDaemonInfo) => Promise<boolean>;
44
+ startEmbeddedDaemon: (opts: EmbeddedDaemonOptions) => Promise<EmbeddedDaemonHandle>;
45
+ }
46
+
47
+ let deps: CliActionBridgeDeps = {
48
+ fetch,
49
+ cwd: () => process.cwd(),
50
+ readCanonicalDaemonInfo,
51
+ clearCanonicalDaemonInfo,
52
+ isCanonicalDaemonAlive,
53
+ startEmbeddedDaemon,
54
+ };
55
+
56
+ export class CliActionInvocationError extends Error {
57
+ readonly code: ActionErrorCode;
58
+ readonly details: unknown;
59
+
60
+ constructor(error: ActionFailure) {
61
+ super(error.message);
62
+ this.name = "CliActionInvocationError";
63
+ this.code = error.code;
64
+ this.details = error.details ?? null;
65
+ }
66
+ }
67
+
68
+ export function __setCliActionBridgeDepsForTests(
69
+ overrides: Partial<CliActionBridgeDeps>,
70
+ ): () => void {
71
+ const previous = deps;
72
+ deps = { ...deps, ...overrides };
73
+ return () => {
74
+ deps = previous;
75
+ };
76
+ }
77
+
78
+ function timeoutSignal(ms: number): AbortSignal {
79
+ const controller = new AbortController();
80
+ setTimeout(() => controller.abort(), ms).unref?.();
81
+ return controller.signal;
82
+ }
83
+
84
+ async function isDaemonAlive(port: number): Promise<boolean> {
85
+ try {
86
+ const res = await deps.fetch(`http://127.0.0.1:${port}/health`, {
87
+ signal: timeoutSignal(500),
88
+ });
89
+ return res.ok;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ function hostnameForClient(bindHostname: string): string {
96
+ return bindHostname === "0.0.0.0" ? "127.0.0.1" : bindHostname;
97
+ }
98
+
99
+ function daemonBaseUrl(info: CanonicalDaemonInfo): string {
100
+ return `http://${hostnameForClient(info.bindHostname)}:${info.port}`;
101
+ }
102
+
103
+ const requireFromHere = createRequire(import.meta.url);
104
+
105
+ /** The daemon version this CLI client was built against — compared
106
+ * against the live daemon's advertised version on attach. */
107
+ function expectedDaemonVersion(): string {
108
+ try {
109
+ const pkg = requireFromHere("../../package.json") as { version?: string };
110
+ return pkg.version ?? "0.0.0";
111
+ } catch {
112
+ return "0.0.0";
113
+ }
114
+ }
115
+
116
+ async function resolveCanonicalDaemon(): Promise<{
117
+ baseUrl: string;
118
+ transientHandle: EmbeddedDaemonHandle | null;
119
+ restoreCwd: string | null;
120
+ } | null> {
121
+ const existing = deps.readCanonicalDaemonInfo();
122
+ if (existing) {
123
+ if (await deps.isCanonicalDaemonAlive(existing)) {
124
+ warnOnDaemonVersionSkew(existing, expectedDaemonVersion());
125
+ return { baseUrl: daemonBaseUrl(existing), transientHandle: null, restoreCwd: null };
126
+ }
127
+ deps.clearCanonicalDaemonInfo();
128
+ }
129
+
130
+ // Test / short-lived-CLI escape: skip the transient daemon spawn when
131
+ // TMUX_IDE_CLI_NO_AUTOSTART is set. Callers (e.g. config-cli.test.ts)
132
+ // exercise the local-mutation fallback path; without this guard a
133
+ // single `tmux-ide config enable-team` in a fixture dir would start a
134
+ // full embedded daemon and never tear it down in time.
135
+ if (process.env.TMUX_IDE_CLI_NO_AUTOSTART) {
136
+ return null;
137
+ }
138
+
139
+ const dir = deps.cwd();
140
+ const previousCwd = process.cwd();
141
+ try {
142
+ process.chdir(dir);
143
+ const handle = await deps.startEmbeddedDaemon({
144
+ sessionName: undefined,
145
+ bindHostname: "127.0.0.1",
146
+ silent: true,
147
+ });
148
+ if (!(await isDaemonAlive(handle.port))) {
149
+ await handle.stop();
150
+ process.chdir(previousCwd);
151
+ return null;
152
+ }
153
+ return { baseUrl: handle.apiBaseUrl, transientHandle: handle, restoreCwd: previousCwd };
154
+ } catch {
155
+ process.chdir(previousCwd);
156
+ return null;
157
+ }
158
+ }
159
+
160
+ async function stopTransientDaemon(daemon: {
161
+ transientHandle: EmbeddedDaemonHandle | null;
162
+ restoreCwd: string | null;
163
+ }): Promise<void> {
164
+ if (daemon.transientHandle) await daemon.transientHandle.stop().catch(() => undefined);
165
+ if (daemon.restoreCwd) process.chdir(daemon.restoreCwd);
166
+ }
167
+
168
+ export async function tryDispatchAction<Name extends ActionName>(
169
+ name: Name,
170
+ input: ActionInput<Name>,
171
+ options: { cwd?: string } = {},
172
+ ): Promise<ActionResult<Name> | null> {
173
+ const dir = options.cwd ?? deps.cwd();
174
+ const previousDeps = deps;
175
+ deps = { ...deps, cwd: () => dir };
176
+ const daemon = await resolveCanonicalDaemon();
177
+ deps = previousDeps;
178
+ if (!daemon) return null;
179
+
180
+ const contract = ActionContractsZ[name];
181
+ const parsedInput = contract.input.parse(input);
182
+ let response: Response;
183
+ try {
184
+ response = await deps.fetch(`${daemon.baseUrl}/api/v2/action/${encodeURIComponent(name)}`, {
185
+ method: "POST",
186
+ headers: { "Content-Type": "application/json" },
187
+ body: JSON.stringify(parsedInput),
188
+ signal: timeoutSignal(2000),
189
+ });
190
+ } catch {
191
+ await stopTransientDaemon(daemon);
192
+ return null;
193
+ }
194
+
195
+ let body: unknown;
196
+ try {
197
+ body = await response.json();
198
+ } catch {
199
+ await stopTransientDaemon(daemon);
200
+ return null;
201
+ }
202
+ await stopTransientDaemon(daemon);
203
+
204
+ const failure = FailureEnvelopeZ.safeParse(body);
205
+ if (failure.success) {
206
+ throw new CliActionInvocationError({
207
+ code: failure.data.error.code as ActionErrorCode,
208
+ message: failure.data.error.message,
209
+ details: failure.data.error.details,
210
+ });
211
+ }
212
+
213
+ const success = z.object({ ok: z.literal(true), result: contract.result }).safeParse(body);
214
+ if (!success.success) return null;
215
+ return success.data.result as ActionResult<Name>;
216
+ }