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,155 @@
1
+ /**
2
+ * Managed sync of the bundled Claude Code skill → `~/.claude/skills/tmux-ide`.
3
+ *
4
+ * `skill/SKILL.md` is the manual a coding agent loads to drive tmux-ide. It ships
5
+ * IN the package, but the copy that agents actually read lives in the user's
6
+ * `~/.claude/skills/` — so every install AND every update has to keep that copy
7
+ * fresh, or agents drive an old CLI. This module is that refresh: it copies the
8
+ * bundled SKILL.md into the tmux-ide skill dir with the version marker rewritten
9
+ * to the installed package version, and reports whether that was an install, an
10
+ * update, or a no-op.
11
+ *
12
+ * The dir is FULLY MANAGED: overwriting our own `~/.claude/skills/tmux-ide` is
13
+ * correct, and this NEVER touches anything outside it (user-authored skills in
14
+ * sibling dirs are safe). The version marker is line 2 of SKILL.md, an HTML
15
+ * comment ({@link VERSION_MARKER_RE}); {@link parseSkillVersion} reads it and
16
+ * {@link rewriteVersionMarker} substitutes it at copy time.
17
+ *
18
+ * The parse/render helpers are PURE and tested against fixtures; {@link syncSkill}
19
+ * is the thin io wrapper (tested with tmp dirs). Wired from three places — the
20
+ * npm `postinstall` (which re-implements the marker rewrite in plain JS since it
21
+ * can't import TS — see scripts/postinstall.js), `tmux-ide skill-sync` / the dev
22
+ * path of `tmux-ide update`, and `tmux-ide integration install claude`.
23
+ */
24
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
25
+ import { homedir } from "node:os";
26
+ import { dirname, join } from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+ import { getCurrentVersion } from "./update-check.ts";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Paths
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * Claude Code's config dir: `TMUX_IDE_CLAUDE_DIR` when set (tests / per-run
36
+ * overrides), else `~/.claude`. Mirrors the {@link ../tui/integrations/claude.ts}
37
+ * settings override so both halves of the agent setup honor the same scratch dir.
38
+ */
39
+ export function claudeDir(): string {
40
+ return process.env.TMUX_IDE_CLAUDE_DIR ?? join(homedir(), ".claude");
41
+ }
42
+
43
+ /** The fully-managed tmux-ide skill dir: `<claudeDir>/skills/tmux-ide`. */
44
+ export function skillTargetDir(): string {
45
+ return join(claudeDir(), "skills", "tmux-ide");
46
+ }
47
+
48
+ /** The installed SKILL.md path (inside {@link skillTargetDir}). */
49
+ export function skillTargetFile(): string {
50
+ return join(skillTargetDir(), "SKILL.md");
51
+ }
52
+
53
+ /**
54
+ * io — the bundled SKILL.md that ships in the package. Resolved relative to this
55
+ * module, handling BOTH the esbuild-bundled CLI (this file inlines into
56
+ * `bin/cli.js`, so `here` is `bin/` and the skill is one level up) AND the dev
57
+ * source tree (`packages/daemon/src/lib/`, four levels below the repo root) — the
58
+ * same dual-candidate trick as {@link getCurrentVersion}. Returns the first that
59
+ * exists, else the bundled-layout guess.
60
+ */
61
+ export function defaultSkillSource(): string {
62
+ const here = dirname(fileURLToPath(import.meta.url));
63
+ const candidates = [
64
+ join(here, "../skill/SKILL.md"), // bundled bin/cli.js → repo root
65
+ join(here, "../../../../skill/SKILL.md"), // dev src/lib → repo root
66
+ ];
67
+ return candidates.find((c) => existsSync(c)) ?? candidates[0]!;
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Pure — version marker
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /**
75
+ * The version marker line (an HTML comment on line 2 of SKILL.md). The sync
76
+ * machinery reads it to know which version an installed copy is, and rewrites it
77
+ * to the installed package version at copy time.
78
+ */
79
+ export const VERSION_MARKER_RE = /<!--\s*tmux-ide-skill-version:\s*([^\s]+)\s*-->/;
80
+
81
+ /** PURE — render the marker line for a given version. */
82
+ export function versionMarker(version: string): string {
83
+ return `<!-- tmux-ide-skill-version: ${version} -->`;
84
+ }
85
+
86
+ /** PURE — the version in a SKILL.md body, or null when the marker is absent. */
87
+ export function parseSkillVersion(content: string): string | null {
88
+ const match = content.match(VERSION_MARKER_RE);
89
+ return match ? match[1]! : null;
90
+ }
91
+
92
+ /**
93
+ * PURE — return `content` with its version marker rewritten to `version`. When no
94
+ * marker is present the content is returned unchanged (the bundled SKILL.md
95
+ * always carries one; this just never corrupts a hand-stripped file).
96
+ */
97
+ export function rewriteVersionMarker(content: string, version: string): string {
98
+ if (!VERSION_MARKER_RE.test(content)) return content;
99
+ return content.replace(VERSION_MARKER_RE, versionMarker(version));
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // io
104
+ // ---------------------------------------------------------------------------
105
+
106
+ /** The version marker read off an installed SKILL.md, or null if absent/unreadable. */
107
+ export function installedSkillVersion(dir: string = skillTargetDir()): string | null {
108
+ const file = join(dir, "SKILL.md");
109
+ if (!existsSync(file)) return null;
110
+ try {
111
+ return parseSkillVersion(readFileSync(file, "utf-8"));
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ /** What a {@link syncSkill} did. */
118
+ export interface SyncResult {
119
+ /** `installed` = target was absent; `updated` = content changed; `unchanged` = no-op. */
120
+ action: "installed" | "updated" | "unchanged";
121
+ /** The written SKILL.md path. */
122
+ path: string;
123
+ /** The installed version before an `updated` (the marker we replaced), if any. */
124
+ from?: string | null;
125
+ /** The version written into the copy's marker. */
126
+ to: string;
127
+ }
128
+
129
+ /**
130
+ * io — copy the bundled SKILL.md into the managed skill dir with its version
131
+ * marker rewritten to `version`. Content-driven and idempotent: the RENDERED
132
+ * source (marker substituted) is compared byte-for-byte against the installed
133
+ * copy — identical → `unchanged` (no write), absent → `installed`, otherwise
134
+ * `updated`. Only ever writes inside {@link skillTargetDir}; nothing outside it
135
+ * is read or touched.
136
+ */
137
+ export function syncSkill({
138
+ source = defaultSkillSource(),
139
+ version = getCurrentVersion(),
140
+ }: { source?: string; version?: string } = {}): SyncResult {
141
+ const rendered = rewriteVersionMarker(readFileSync(source, "utf-8"), version);
142
+ const dir = skillTargetDir();
143
+ const target = join(dir, "SKILL.md");
144
+
145
+ const existing = existsSync(target) ? readFileSync(target, "utf-8") : null;
146
+ if (existing === rendered) {
147
+ return { action: "unchanged", path: target, to: version };
148
+ }
149
+
150
+ mkdirSync(dir, { recursive: true });
151
+ writeFileSync(target, rendered, "utf-8");
152
+
153
+ if (existing === null) return { action: "installed", path: target, to: version };
154
+ return { action: "updated", path: target, from: parseSkillVersion(existing), to: version };
155
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Convert text to a URL/filename-safe slug.
3
+ */
4
+ export function slugify(text: string, maxLen = 40): string {
5
+ return text
6
+ .toLowerCase()
7
+ .replace(/[^a-z0-9]+/g, "-")
8
+ .replace(/^-|-$/g, "")
9
+ .slice(0, maxLen);
10
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Terminals registry (G20-P1).
3
+ *
4
+ * JSON-backed persistence for tab-strip metadata — names, scopes, and
5
+ * kind. The actual PTY process lives in `PtyBridgeRegistry`; this
6
+ * store only persists what the UI needs to restore tab labels across
7
+ * reloads. Atomic writes (temp + rename) so a crash mid-write leaves
8
+ * the previous list intact.
9
+ */
10
+
11
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import type { Terminal, TerminalKind } from "@tmux-ide/contracts";
14
+
15
+ const TERMINALS_FILE = ".tmux-ide/terminals.json";
16
+ const SAFE_ID = /^[A-Za-z0-9_-]+$/u;
17
+
18
+ function path(dir: string): string {
19
+ return join(dir, TERMINALS_FILE);
20
+ }
21
+
22
+ function ensureDir(dir: string): void {
23
+ mkdirSync(dirname(path(dir)), { recursive: true });
24
+ }
25
+
26
+ /** Load the full list for a session. Missing file → empty list. */
27
+ export function loadTerminals(dir: string): Terminal[] {
28
+ const file = path(dir);
29
+ if (!existsSync(file)) return [];
30
+ try {
31
+ const body = readFileSync(file, "utf-8");
32
+ const parsed = JSON.parse(body) as { terminals?: unknown };
33
+ if (!parsed.terminals || !Array.isArray(parsed.terminals)) return [];
34
+ return parsed.terminals.filter((t): t is Terminal => isTerminal(t)).map((t) => ({ ...t }));
35
+ } catch {
36
+ return [];
37
+ }
38
+ }
39
+
40
+ function isTerminal(value: unknown): value is Terminal {
41
+ if (!value || typeof value !== "object") return false;
42
+ const v = value as Record<string, unknown>;
43
+ return (
44
+ typeof v.id === "string" &&
45
+ SAFE_ID.test(v.id) &&
46
+ typeof v.projectId === "string" &&
47
+ typeof v.scopeId === "string" &&
48
+ typeof v.name === "string" &&
49
+ (v.kind === "shell" || v.kind === "setup" || v.kind === "run" || v.kind === "teardown") &&
50
+ typeof v.createdAt === "string" &&
51
+ typeof v.updatedAt === "string"
52
+ );
53
+ }
54
+
55
+ function writeAtomic(dir: string, terminals: Terminal[]): void {
56
+ ensureDir(dir);
57
+ const file = path(dir);
58
+ const tmp = `${file}.tmp`;
59
+ writeFileSync(tmp, JSON.stringify({ terminals }, null, 2) + "\n");
60
+ renameSync(tmp, file);
61
+ }
62
+
63
+ export interface CreateInput {
64
+ id: string;
65
+ projectId: string;
66
+ scopeId: string;
67
+ name: string;
68
+ kind: TerminalKind;
69
+ scripted?: boolean;
70
+ }
71
+
72
+ /** Insert OR replace a terminal record by id. Idempotent — calling
73
+ * `createTerminal` twice with the same deterministic id is a no-op
74
+ * on the canonical fields and updates `updatedAt`. */
75
+ export function upsertTerminal(dir: string, input: CreateInput): Terminal {
76
+ if (!SAFE_ID.test(input.id)) {
77
+ throw new Error(`invalid terminal id "${input.id}"`);
78
+ }
79
+ const existing = loadTerminals(dir);
80
+ const idx = existing.findIndex((t) => t.id === input.id);
81
+ const now = new Date().toISOString();
82
+ const next: Terminal = {
83
+ id: input.id,
84
+ projectId: input.projectId,
85
+ scopeId: input.scopeId,
86
+ name: input.name,
87
+ kind: input.kind,
88
+ createdAt: existing[idx]?.createdAt ?? now,
89
+ updatedAt: now,
90
+ ...(input.scripted ? { scripted: true } : {}),
91
+ };
92
+ if (idx === -1) existing.push(next);
93
+ else existing[idx] = next;
94
+ writeAtomic(dir, existing);
95
+ return next;
96
+ }
97
+
98
+ export function renameTerminal(dir: string, id: string, name: string): Terminal | null {
99
+ const all = loadTerminals(dir);
100
+ const idx = all.findIndex((t) => t.id === id);
101
+ if (idx === -1) return null;
102
+ const trimmed = name.trim();
103
+ if (!trimmed) throw new Error("name required");
104
+ const next: Terminal = {
105
+ ...all[idx]!,
106
+ name: trimmed,
107
+ updatedAt: new Date().toISOString(),
108
+ };
109
+ all[idx] = next;
110
+ writeAtomic(dir, all);
111
+ return next;
112
+ }
113
+
114
+ export function deleteTerminal(dir: string, id: string): boolean {
115
+ const all = loadTerminals(dir);
116
+ const next = all.filter((t) => t.id !== id);
117
+ if (next.length === all.length) return false;
118
+ writeAtomic(dir, next);
119
+ return true;
120
+ }
121
+
122
+ export function findTerminal(dir: string, id: string): Terminal | null {
123
+ const all = loadTerminals(dir);
124
+ return all.find((t) => t.id === id) ?? null;
125
+ }
@@ -0,0 +1,298 @@
1
+ /**
2
+ * The built-in update check — "you're on an old tmux-ide" surfaced where
3
+ * everyone already looks: the dock.
4
+ *
5
+ * Once a day the chrome updater asks npm for the latest published version, caches
6
+ * the answer in `~/.tmux-ide/update-check.json` (overridable via `TMUX_IDE_HOME`,
7
+ * consistent with {@link ../tui/chrome/welcome.ts}), and — when the cached latest
8
+ * is newer than what's running — every surface reads that ONE cache to decide
9
+ * whether to nudge: the dock's `⬆ v<latest>` segment, a one-time toast per
10
+ * version, `tmux-ide doctor`, the actions menu, and the `tmux-ide` start hint.
11
+ *
12
+ * The split, as elsewhere: {@link compareSemver} / {@link isNewer} /
13
+ * {@link shouldCheck} / {@link parseRegistryResponse} are PURE (unit-tested, never
14
+ * throw); {@link fetchLatestVersion}, the cache read/write, and the throttled
15
+ * {@link runUpdateCheck} are the thin — and strictly OFFLINE-SAFE — io wrappers.
16
+ * Nothing here ever throws into the updater loop: a failed fetch, an unreadable
17
+ * cache, or a garbage registry body all degrade to "no update known".
18
+ */
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import { dirname, join } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ /** The npm dist-tag endpoint that returns `{ "version": "x.y.z", … }`. */
25
+ export const REGISTRY_URL = "https://registry.npmjs.org/tmux-ide/latest";
26
+
27
+ /** Re-check no more than once per this window (24h). */
28
+ export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
29
+
30
+ /** The cheap, cache-derived answer every surface reads. */
31
+ export interface UpdateStatus {
32
+ /** The latest published version the last check saw, or null when unknown. */
33
+ latest: string | null;
34
+ /** True when {@link latest} is strictly newer than the running version. */
35
+ updateAvailable: boolean;
36
+ }
37
+
38
+ /** The on-disk cache shape (`~/.tmux-ide/update-check.json`). */
39
+ export interface UpdateCache {
40
+ /** Epoch ms of the last network check (throttles {@link shouldCheck}). */
41
+ lastCheckedAt: number | null;
42
+ /** The latest version that check saw, or null. */
43
+ latest: string | null;
44
+ /** Versions we've already toasted about (one-time-per-version debounce). */
45
+ notified?: string[];
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Pure
50
+ // ---------------------------------------------------------------------------
51
+
52
+ interface ParsedSemver {
53
+ nums: [number, number, number];
54
+ /** The prerelease tag (`1.0.0-rc.1` → `rc.1`), or "" for a release. */
55
+ pre: string;
56
+ }
57
+
58
+ /**
59
+ * PURE — split `[v]MAJOR.MINOR.PATCH[-prerelease][+build]` into its numeric core
60
+ * and prerelease tag. Lenient: a leading `v`, build metadata (`+…`), and missing
61
+ * or non-numeric core parts are all tolerated (coerced to 0), so a malformed
62
+ * version compares as `0.0.0` instead of throwing.
63
+ */
64
+ function parseSemver(version: string): ParsedSemver {
65
+ const core = version.trim().replace(/^v/i, "").split("+")[0] ?? "";
66
+ const dash = core.indexOf("-");
67
+ const main = dash === -1 ? core : core.slice(0, dash);
68
+ const pre = dash === -1 ? "" : core.slice(dash + 1);
69
+ const parts = main.split(".");
70
+ const num = (i: number): number => {
71
+ const n = Number.parseInt(parts[i] ?? "", 10);
72
+ return Number.isFinite(n) && n >= 0 ? n : 0;
73
+ };
74
+ return { nums: [num(0), num(1), num(2)], pre };
75
+ }
76
+
77
+ /**
78
+ * PURE — order two semver strings: `-1` if `a < b`, `1` if `a > b`, else `0`.
79
+ *
80
+ * Numeric core (major, minor, patch) compares field-by-field, so `2.10.0` sorts
81
+ * ABOVE `2.6.0` (numeric, not lexical). Prerelease follows semver's rule: a
82
+ * release outranks a prerelease of the same core (`1.0.0` > `1.0.0-rc`); two
83
+ * prereleases fall back to a plain lexical compare of their tags (a simple, good-
84
+ * enough ordering — we never need to rank `rc.2` vs `rc.10` precisely).
85
+ */
86
+ export function compareSemver(a: string, b: string): -1 | 0 | 1 {
87
+ const pa = parseSemver(a);
88
+ const pb = parseSemver(b);
89
+ for (let i = 0; i < 3; i++) {
90
+ if (pa.nums[i]! !== pb.nums[i]!) return pa.nums[i]! < pb.nums[i]! ? -1 : 1;
91
+ }
92
+ if (pa.pre === pb.pre) return 0;
93
+ if (pa.pre === "") return 1; // release > prerelease of same core
94
+ if (pb.pre === "") return -1;
95
+ return pa.pre < pb.pre ? -1 : 1;
96
+ }
97
+
98
+ /** PURE — is `latest` strictly newer than `current`? */
99
+ export function isNewer(latest: string, current: string): boolean {
100
+ return compareSemver(latest, current) === 1;
101
+ }
102
+
103
+ /**
104
+ * PURE — should we hit the network again? True when we've never checked
105
+ * (`lastCheckedAt` null) or the last check was at least {@link CHECK_INTERVAL_MS}
106
+ * ago. A future `lastCheckedAt` (clock skew) reads as "recent" and skips — the
107
+ * daily tick self-heals once the clock passes it.
108
+ */
109
+ export function shouldCheck(lastCheckedAt: number | null, nowMs: number): boolean {
110
+ if (lastCheckedAt === null) return true;
111
+ return nowMs - lastCheckedAt >= CHECK_INTERVAL_MS;
112
+ }
113
+
114
+ /**
115
+ * PURE — pull the `version` string out of a raw npm dist-tag response body.
116
+ * Returns null (never throws) for malformed JSON, a non-object, or a
117
+ * missing/empty/non-string `version`.
118
+ */
119
+ export function parseRegistryResponse(json: string): string | null {
120
+ try {
121
+ const parsed = JSON.parse(json) as unknown;
122
+ if (!parsed || typeof parsed !== "object") return null;
123
+ const version = (parsed as { version?: unknown }).version;
124
+ return typeof version === "string" && version.length > 0 ? version : null;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * PURE — derive the {@link UpdateStatus} for a running version from a (possibly
132
+ * null) cached latest. The single place "is there an update?" is decided, so
133
+ * every surface agrees.
134
+ */
135
+ export function deriveStatus(latest: string | null, currentVersion: string): UpdateStatus {
136
+ return {
137
+ latest,
138
+ updateAvailable: latest !== null && isNewer(latest, currentVersion),
139
+ };
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // io — cache + network (all offline-safe, never throw)
144
+ // ---------------------------------------------------------------------------
145
+
146
+ /**
147
+ * Absolute path to the cache file: `<home>/update-check.json`, where `<home>` is
148
+ * `TMUX_IDE_HOME` when set (tests / per-run overrides), else `~/.tmux-ide` — the
149
+ * same home resolution the welcome marker uses.
150
+ */
151
+ export function updateCachePath(): string {
152
+ const home = process.env.TMUX_IDE_HOME ?? join(homedir(), ".tmux-ide");
153
+ return join(home, "update-check.json");
154
+ }
155
+
156
+ /** io — read + parse the cache, or null when absent/unreadable/malformed. */
157
+ export function readUpdateCache(): UpdateCache | null {
158
+ const path = updateCachePath();
159
+ if (!existsSync(path)) return null;
160
+ try {
161
+ const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown;
162
+ if (!parsed || typeof parsed !== "object") return null;
163
+ const obj = parsed as Record<string, unknown>;
164
+ const lastCheckedAt = typeof obj.lastCheckedAt === "number" ? obj.lastCheckedAt : null;
165
+ const latest = typeof obj.latest === "string" && obj.latest.length > 0 ? obj.latest : null;
166
+ const notified = Array.isArray(obj.notified)
167
+ ? obj.notified.filter((v): v is string => typeof v === "string")
168
+ : undefined;
169
+ return { lastCheckedAt, latest, ...(notified ? { notified } : {}) };
170
+ } catch {
171
+ return null;
172
+ }
173
+ }
174
+
175
+ /** io — write the cache (creating `<home>` if needed). Best-effort, never throws. */
176
+ export function writeUpdateCache(cache: UpdateCache): void {
177
+ const path = updateCachePath();
178
+ try {
179
+ mkdirSync(dirname(path), { recursive: true });
180
+ writeFileSync(path, JSON.stringify(cache));
181
+ } catch {
182
+ // an unwritable cache just means we re-check next tick — never fatal
183
+ }
184
+ }
185
+
186
+ /**
187
+ * io — ask npm for the latest published version, or null on ANY failure (offline,
188
+ * timeout, non-200, garbage body). Aborts after `timeoutMs` so a slow registry
189
+ * can never stall the updater tick. This is the ONLY network call in the module.
190
+ */
191
+ export async function fetchLatestVersion(timeoutMs = 3000): Promise<string | null> {
192
+ const controller = new AbortController();
193
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
194
+ try {
195
+ const res = await fetch(REGISTRY_URL, { signal: controller.signal });
196
+ if (!res.ok) return null;
197
+ return parseRegistryResponse(await res.text());
198
+ } catch {
199
+ return null;
200
+ } finally {
201
+ clearTimeout(timer);
202
+ }
203
+ }
204
+
205
+ /**
206
+ * The running tmux-ide version, read from the nearest `package.json`. Handles
207
+ * BOTH the bundled CLI (`bin/cli.js`, so the repo-root package.json is one level
208
+ * up) AND the dev source tree (this file at `packages/daemon/src/lib/`, so the
209
+ * root is four levels up). The workspace `packages/daemon/package.json` is
210
+ * deliberately NOT a candidate — it carries a placeholder version. Falls back to
211
+ * `"0.0.0"` when nothing is readable (so an unknown version simply never shows an
212
+ * update rather than crashing).
213
+ */
214
+ export function getCurrentVersion(): string {
215
+ const here = dirname(fileURLToPath(import.meta.url));
216
+ const candidates = [
217
+ join(here, "../package.json"), // bundled bin/cli.js → repo root
218
+ join(here, "../../../../package.json"), // dev src/lib → repo root
219
+ ];
220
+ for (const candidate of candidates) {
221
+ try {
222
+ const parsed = JSON.parse(readFileSync(candidate, "utf-8")) as { version?: unknown };
223
+ if (typeof parsed.version === "string" && parsed.version.length > 0) return parsed.version;
224
+ } catch {
225
+ // try the next candidate
226
+ }
227
+ }
228
+ return "0.0.0";
229
+ }
230
+
231
+ /**
232
+ * The cheap, network-FREE status read every surface uses: derive
233
+ * {@link UpdateStatus} from the cache against the current version. `now`/
234
+ * `currentVersion` are injectable for tests; the defaults read the live clock +
235
+ * package version.
236
+ */
237
+ export function getUpdateStatus({
238
+ currentVersion = getCurrentVersion(),
239
+ }: { now?: number; currentVersion?: string } = {}): UpdateStatus {
240
+ const cache = readUpdateCache();
241
+ return deriveStatus(cache?.latest ?? null, currentVersion);
242
+ }
243
+
244
+ /**
245
+ * io — the throttled network refresh (called fire-and-forget from the updater
246
+ * tick). Does nothing if the last check was inside {@link CHECK_INTERVAL_MS};
247
+ * otherwise fetches the latest version and rewrites the cache with a fresh
248
+ * `lastCheckedAt`. On a failed fetch it still stamps `lastCheckedAt` (so we don't
249
+ * hammer a flaky/offline registry every tick) while KEEPING the previously known
250
+ * `latest`. Never throws.
251
+ */
252
+ export async function runUpdateCheck({ now = Date.now() }: { now?: number } = {}): Promise<void> {
253
+ const cache = readUpdateCache();
254
+ if (!shouldCheck(cache?.lastCheckedAt ?? null, now)) return;
255
+ const fetched = await fetchLatestVersion();
256
+ writeUpdateCache({
257
+ lastCheckedAt: now,
258
+ latest: fetched ?? cache?.latest ?? null,
259
+ ...(cache?.notified ? { notified: cache.notified } : {}),
260
+ });
261
+ }
262
+
263
+ /**
264
+ * The tick's entry point: return the cheap cache-derived {@link UpdateStatus} NOW,
265
+ * and (when `enabled`) kick off the throttled network refresh in the background so
266
+ * the NEXT tick sees a fresh answer. Disabled (`updates.check: false`) → always
267
+ * "no update", no network. The background refresh is deliberately un-awaited: the
268
+ * tick must never block on the registry.
269
+ */
270
+ export function maybeCheckForUpdate({
271
+ enabled,
272
+ now = Date.now(),
273
+ currentVersion = getCurrentVersion(),
274
+ }: {
275
+ enabled: boolean;
276
+ now?: number;
277
+ currentVersion?: string;
278
+ }): UpdateStatus {
279
+ if (!enabled) return { latest: null, updateAvailable: false };
280
+ const status = getUpdateStatus({ now, currentVersion });
281
+ void runUpdateCheck({ now }).catch(() => {});
282
+ return status;
283
+ }
284
+
285
+ /**
286
+ * io — record that we've toasted about `version` and report whether this is the
287
+ * FIRST time (so the caller should actually toast). Persisted in the cache file's
288
+ * `notified` list so the one-time guarantee survives updater restarts — the old
289
+ * in-memory notify debounce would re-fire on every respawn. Best-effort: if the
290
+ * cache can't be written we still return the in-memory verdict.
291
+ */
292
+ export function markUpdateNotified(version: string): boolean {
293
+ const cache = readUpdateCache() ?? { lastCheckedAt: null, latest: null };
294
+ const notified = cache.notified ?? [];
295
+ if (notified.includes(version)) return false;
296
+ writeUpdateCache({ ...cache, notified: [...notified, version] });
297
+ return true;
298
+ }