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,425 @@
1
+ /**
2
+ * Fleet snapshot — the fleet's DISASTER-RECOVERY layer.
3
+ *
4
+ * The chrome {@link ./updater.ts updater} sees the whole tmux server every
5
+ * tick. This module turns that view into a durable, structural picture of the
6
+ * fleet — every session's windows, panes, cwds, layouts, titles, and the agent
7
+ * that was running in each pane — and persists it to `~/.tmux-ide/snapshot.json`.
8
+ * When the tmux server dies for real, `tmux-ide restore` ({@link ../../restore.ts})
9
+ * rebuilds the fleet from this file.
10
+ *
11
+ * Split as usual: {@link buildSnapshot} + {@link snapshotFingerprint} are PURE
12
+ * (assembled from raw tmux lines + a process table, unit-tested without a live
13
+ * tmux), while {@link collectFleetSnapshot} / {@link writeSnapshot} /
14
+ * {@link readSnapshot} are the thin io wrappers. {@link createSnapshotter}
15
+ * carries the throttle + change-detection the updater loop drives each tick.
16
+ */
17
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { dirname, join } from "node:path";
20
+ import { z } from "zod";
21
+ import { runTmux } from "@tmux-ide/tmux-bridge";
22
+ import { readProcessTable, resolveAgentCommand, type ProcEntry } from "../detect/process-tree.ts";
23
+ import { isListableSession } from "../team/sessions.ts";
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Model
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /** A single pane, frozen for rebuild. */
30
+ export interface PaneSnapshot {
31
+ /** `pane_index` within its window. */
32
+ index: number;
33
+ /** `pane_current_path` — the pane's working directory. */
34
+ cwd: string;
35
+ /**
36
+ * The command to re-run under `--run-commands`, or `null` for a bare shell
37
+ * (which restores as a plain pane). An agent pane records the RESOLVED agent
38
+ * id (e.g. `claude`), not the generic `node`/`bun` shim; a non-shell,
39
+ * non-agent pane records its `pane_current_command` verbatim.
40
+ */
41
+ command: string | null;
42
+ /** Resolved agent id (e.g. `claude`), or `null` for a non-agent pane. */
43
+ agent: string | null;
44
+ /** `@agent_session_id` — the agent's own session id (0037 resumes from it). */
45
+ agentSessionId: string | null;
46
+ /** `@agent_state` — the authority status line. Excluded from the fingerprint (it churns every tick). */
47
+ agentState: string | null;
48
+ /** `pane_title`. */
49
+ title: string;
50
+ }
51
+
52
+ /** A single window (tab), with the layout string that restores its geometry. */
53
+ export interface WindowSnapshot {
54
+ /** `window_index`. */
55
+ index: number;
56
+ /** `window_name`. */
57
+ name: string;
58
+ /** Whether this is the session's active window. */
59
+ active: boolean;
60
+ /** `window_layout` — the checksummed layout string tmux's `select-layout` accepts verbatim. */
61
+ layout: string;
62
+ panes: PaneSnapshot[];
63
+ }
64
+
65
+ /** A single session, with its adopted flag so restore re-adopts it. */
66
+ export interface SessionSnapshot {
67
+ name: string;
68
+ /** Session cwd — the first window's first pane's cwd. */
69
+ cwd: string;
70
+ /** Whether the session was adopted into the chrome. */
71
+ adopted: boolean;
72
+ windows: WindowSnapshot[];
73
+ }
74
+
75
+ /** The whole fleet at one instant. */
76
+ export interface FleetSnapshot {
77
+ version: 1;
78
+ savedAt: string;
79
+ sessions: SessionSnapshot[];
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Read validation — a snapshot on disk is untrusted input
84
+ // ---------------------------------------------------------------------------
85
+
86
+ const PaneSnapshotSchemaZ = z.object({
87
+ index: z.number(),
88
+ cwd: z.string(),
89
+ command: z.string().nullable(),
90
+ agent: z.string().nullable(),
91
+ agentSessionId: z.string().nullable(),
92
+ agentState: z.string().nullable(),
93
+ title: z.string(),
94
+ });
95
+
96
+ const WindowSnapshotSchemaZ = z.object({
97
+ index: z.number(),
98
+ name: z.string(),
99
+ active: z.boolean(),
100
+ layout: z.string(),
101
+ panes: z.array(PaneSnapshotSchemaZ),
102
+ });
103
+
104
+ const SessionSnapshotSchemaZ = z.object({
105
+ name: z.string(),
106
+ cwd: z.string(),
107
+ adopted: z.boolean(),
108
+ windows: z.array(WindowSnapshotSchemaZ),
109
+ });
110
+
111
+ const FleetSnapshotSchemaZ = z.object({
112
+ version: z.literal(1),
113
+ savedAt: z.string(),
114
+ sessions: z.array(SessionSnapshotSchemaZ),
115
+ });
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Pure assembly
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /** The tab-separated `list-panes -a` fields {@link buildSnapshot} parses, in order. */
122
+ export const SNAPSHOT_PANE_FORMAT = [
123
+ "#{session_name}",
124
+ "#{window_index}",
125
+ "#{window_name}",
126
+ "#{window_active}",
127
+ "#{window_layout}",
128
+ "#{pane_index}",
129
+ "#{pane_current_path}",
130
+ "#{pane_current_command}",
131
+ "#{pane_pid}",
132
+ "#{@agent_session_id}",
133
+ "#{@agent_state}",
134
+ "#{@agent_hint}",
135
+ "#{pane_title}",
136
+ ].join("\t");
137
+
138
+ /** The `list-sessions` fields the adopted-flag pass parses. */
139
+ export const SNAPSHOT_SESSION_FORMAT = ["#{session_name}", "#{@tmux_ide_adopted}"].join("\t");
140
+
141
+ /** A bare interactive shell — restores as a plain pane, records no command. */
142
+ function isBareShell(cmd: string): boolean {
143
+ return /^-?(zsh|bash|sh|fish|dash|ksh|tcsh|csh|nu)$/.test(cmd.trim());
144
+ }
145
+
146
+ /**
147
+ * Decide a pane's `agent` + `command` from its command line + process tree.
148
+ *
149
+ * A real agent (a manifest that isn't the `shell` catch-all) records its id as
150
+ * BOTH the agent and the restore command; a bare shell records neither; any
151
+ * other foreground process records its `pane_current_command` as the command.
152
+ */
153
+ function resolvePaneCommand(
154
+ cmd: string,
155
+ pid: number,
156
+ hint: string,
157
+ table: ProcEntry[],
158
+ ): { agent: string | null; command: string | null } {
159
+ const { manifest } = resolveAgentCommand(cmd, pid, table, { hint: hint || undefined });
160
+ if (manifest && manifest.id !== "shell") {
161
+ return { agent: manifest.id, command: manifest.id };
162
+ }
163
+ if (isBareShell(cmd)) return { agent: null, command: null };
164
+ return { agent: null, command: cmd };
165
+ }
166
+
167
+ /** `""` → null, so absent user-options round-trip as null rather than empty string. */
168
+ function nullable(value: string): string | null {
169
+ return value.length > 0 ? value : null;
170
+ }
171
+
172
+ /**
173
+ * PURE — assemble a {@link FleetSnapshot} from raw `list-panes -a` lines, raw
174
+ * `list-sessions` lines (for the adopted flag), and a process table (for agent
175
+ * resolution). Internal `_`-prefixed sessions are skipped. Windows and panes
176
+ * come out in ascending index order; a session's cwd is its first window's
177
+ * first pane's cwd.
178
+ */
179
+ export function buildSnapshot(
180
+ rawPanes: string[],
181
+ rawSessions: string[],
182
+ table: ProcEntry[],
183
+ savedAt: string = new Date().toISOString(),
184
+ ): FleetSnapshot {
185
+ // Adopted flag per session (marker field exactly "1").
186
+ const adopted = new Set<string>();
187
+ for (const line of rawSessions) {
188
+ const [name = "", flag = ""] = line.split("\t");
189
+ if (name && flag === "1") adopted.add(name);
190
+ }
191
+
192
+ // Group panes → session → window, preserving insertion but sorting at the end.
193
+ interface WinAccum {
194
+ index: number;
195
+ name: string;
196
+ active: boolean;
197
+ layout: string;
198
+ panes: PaneSnapshot[];
199
+ }
200
+ const sessions = new Map<string, Map<number, WinAccum>>();
201
+
202
+ for (const line of rawPanes) {
203
+ if (line.length === 0) continue;
204
+ const [
205
+ session = "",
206
+ windowIndex = "0",
207
+ windowName = "",
208
+ windowActive = "0",
209
+ layout = "",
210
+ paneIndex = "0",
211
+ cwd = "",
212
+ cmd = "",
213
+ pid = "0",
214
+ agentSessionId = "",
215
+ agentState = "",
216
+ hint = "",
217
+ ...titleParts
218
+ ] = line.split("\t");
219
+ if (!session || !isListableSession(session)) continue;
220
+
221
+ let windows = sessions.get(session);
222
+ if (!windows) {
223
+ windows = new Map();
224
+ sessions.set(session, windows);
225
+ }
226
+ const wIndex = Number(windowIndex) || 0;
227
+ let win = windows.get(wIndex);
228
+ if (!win) {
229
+ win = {
230
+ index: wIndex,
231
+ name: windowName,
232
+ active: windowActive === "1",
233
+ layout,
234
+ panes: [],
235
+ };
236
+ windows.set(wIndex, win);
237
+ }
238
+
239
+ const { agent, command } = resolvePaneCommand(cmd, Number(pid) || 0, hint, table);
240
+ win.panes.push({
241
+ index: Number(paneIndex) || 0,
242
+ cwd,
243
+ command,
244
+ agent,
245
+ agentSessionId: nullable(agentSessionId),
246
+ agentState: nullable(agentState),
247
+ title: titleParts.join("\t"),
248
+ });
249
+ }
250
+
251
+ const out: SessionSnapshot[] = [];
252
+ for (const [name, windows] of sessions) {
253
+ const windowList: WindowSnapshot[] = [...windows.values()]
254
+ .sort((a, b) => a.index - b.index)
255
+ .map((w) => ({
256
+ index: w.index,
257
+ name: w.name,
258
+ active: w.active,
259
+ layout: w.layout,
260
+ panes: w.panes.slice().sort((a, b) => a.index - b.index),
261
+ }));
262
+ const cwd = windowList[0]?.panes[0]?.cwd ?? "";
263
+ out.push({ name, cwd, adopted: adopted.has(name), windows: windowList });
264
+ }
265
+ // Stable session order so the fingerprint doesn't churn on Map iteration.
266
+ out.sort((a, b) => a.name.localeCompare(b.name));
267
+
268
+ return { version: 1, savedAt, sessions: out };
269
+ }
270
+
271
+ /**
272
+ * PURE — a stable fingerprint of the fleet's STRUCTURE. Everything but
273
+ * `savedAt` and each pane's `agentState` is included, so the fingerprint is
274
+ * unchanged as agents churn working→idle every tick (agentState is a timestamped
275
+ * status line) but DOES change when a pane/window/session, a cwd, a layout, a
276
+ * title, or an agent-session-id changes. The updater rewrites the file only when
277
+ * this value moves — a steady fleet issues zero snapshot writes.
278
+ */
279
+ export function snapshotFingerprint(snapshot: FleetSnapshot): string {
280
+ const structural = {
281
+ sessions: snapshot.sessions.map((s) => ({
282
+ name: s.name,
283
+ cwd: s.cwd,
284
+ adopted: s.adopted,
285
+ windows: s.windows.map((w) => ({
286
+ index: w.index,
287
+ name: w.name,
288
+ active: w.active,
289
+ layout: w.layout,
290
+ panes: w.panes.map((p) => ({
291
+ index: p.index,
292
+ cwd: p.cwd,
293
+ command: p.command,
294
+ agent: p.agent,
295
+ agentSessionId: p.agentSessionId,
296
+ title: p.title,
297
+ // agentState deliberately omitted — it churns every tick.
298
+ })),
299
+ })),
300
+ })),
301
+ };
302
+ return JSON.stringify(structural);
303
+ }
304
+
305
+ // ---------------------------------------------------------------------------
306
+ // io
307
+ // ---------------------------------------------------------------------------
308
+
309
+ /** Injectable io for {@link collectFleetSnapshot} — real tmux by default. */
310
+ export interface SnapshotIo {
311
+ listPanes: () => string;
312
+ listSessions: () => string;
313
+ processTable: () => ProcEntry[];
314
+ }
315
+
316
+ const defaultIo: SnapshotIo = {
317
+ listPanes: () => runTmux(["list-panes", "-a", "-F", SNAPSHOT_PANE_FORMAT]).toString(),
318
+ listSessions: () => runTmux(["list-sessions", "-F", SNAPSHOT_SESSION_FORMAT]).toString(),
319
+ processTable: () => readProcessTable(),
320
+ };
321
+
322
+ /** Collect the live fleet into a {@link FleetSnapshot}. Thin io over {@link buildSnapshot}. */
323
+ export function collectFleetSnapshot(io: SnapshotIo = defaultIo): FleetSnapshot {
324
+ const rawPanes = io.listPanes().split("\n").filter(Boolean);
325
+ const rawSessions = io.listSessions().split("\n").filter(Boolean);
326
+ return buildSnapshot(rawPanes, rawSessions, io.processTable());
327
+ }
328
+
329
+ /** Absolute path to the fleet snapshot. */
330
+ export function snapshotPath(): string {
331
+ return join(homedir(), ".tmux-ide", "snapshot.json");
332
+ }
333
+
334
+ /**
335
+ * Write the snapshot atomically (temp + rename), keeping ONE previous
336
+ * generation as `snapshot.json.1`. Best-effort: a filesystem failure must never
337
+ * break the updater loop.
338
+ */
339
+ export function writeSnapshot(snapshot: FleetSnapshot): void {
340
+ const path = snapshotPath();
341
+ try {
342
+ mkdirSync(dirname(path), { recursive: true });
343
+ const tmp = `${path}.tmp`;
344
+ writeFileSync(tmp, JSON.stringify(snapshot, null, 2) + "\n");
345
+ // Rotate the current generation out of the way, then swap the new one in.
346
+ if (existsSync(path)) {
347
+ try {
348
+ renameSync(path, `${path}.1`);
349
+ } catch {
350
+ // one-generation rotation is best-effort
351
+ }
352
+ }
353
+ renameSync(tmp, path);
354
+ } catch {
355
+ // a failed write just means a staler snapshot — never fatal
356
+ }
357
+ }
358
+
359
+ /** Read + validate the snapshot on disk. Returns null when missing or malformed. */
360
+ export function readSnapshot(): FleetSnapshot | null {
361
+ const path = snapshotPath();
362
+ try {
363
+ if (!existsSync(path)) return null;
364
+ const raw = readFileSync(path, "utf-8");
365
+ if (raw.trim().length === 0) return null;
366
+ const result = FleetSnapshotSchemaZ.safeParse(JSON.parse(raw));
367
+ return result.success ? result.data : null;
368
+ } catch {
369
+ return null;
370
+ }
371
+ }
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // Throttle + change detection — driven by the updater loop
375
+ // ---------------------------------------------------------------------------
376
+
377
+ /** Snapshot every N ticks. At {@link ./updater.ts TICK_MS} = 2s, 15 ticks ≈ 30s. */
378
+ export const SNAPSHOT_EVERY = 15;
379
+
380
+ /** Resolve the tick throttle, honoring `TMUX_IDE_SNAPSHOT_EVERY` (tests/tuning). */
381
+ export function snapshotEvery(): number {
382
+ const raw = process.env.TMUX_IDE_SNAPSHOT_EVERY;
383
+ const n = raw ? Number(raw) : NaN;
384
+ return Number.isInteger(n) && n > 0 ? n : SNAPSHOT_EVERY;
385
+ }
386
+
387
+ /** Deps for {@link createSnapshotter} — injectable so the throttle is unit-tested. */
388
+ export interface SnapshotterDeps {
389
+ collect: () => FleetSnapshot;
390
+ read: () => FleetSnapshot | null;
391
+ write: (snapshot: FleetSnapshot) => void;
392
+ every: number;
393
+ }
394
+
395
+ /**
396
+ * A stateful snapshotter the updater loop pulses once per tick via
397
+ * {@link Snapshotter.onTick}. It fires only every `every` ticks, and even then
398
+ * writes only when the STRUCTURE changed since the last write (seeded from the
399
+ * existing file on first fire, so a restart doesn't rewrite an unchanged fleet).
400
+ */
401
+ export interface Snapshotter {
402
+ onTick: () => void;
403
+ }
404
+
405
+ export function createSnapshotter(deps: SnapshotterDeps): Snapshotter {
406
+ let ticks = 0;
407
+ let seeded = false;
408
+ let lastFingerprint: string | null = null;
409
+ return {
410
+ onTick(): void {
411
+ ticks++;
412
+ if (deps.every <= 0 || ticks % deps.every !== 0) return;
413
+ if (!seeded) {
414
+ const existing = deps.read();
415
+ lastFingerprint = existing ? snapshotFingerprint(existing) : null;
416
+ seeded = true;
417
+ }
418
+ const snapshot = deps.collect();
419
+ const fingerprint = snapshotFingerprint(snapshot);
420
+ if (fingerprint === lastFingerprint) return;
421
+ lastFingerprint = fingerprint;
422
+ deps.write(snapshot);
423
+ },
424
+ };
425
+ }