tmux-ide 2.9.2 → 2.9.3

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 (263) hide show
  1. package/README.md +1 -1
  2. package/bin/cli.js +2071 -625
  3. package/package.json +14 -5
  4. package/packages/contracts/src/desktop-host.ts +3 -0
  5. package/packages/contracts/src/saved-machines.ts +19 -0
  6. package/packages/contracts/src/terminal-replica.ts +1 -1
  7. package/packages/core/package.json +3 -2
  8. package/packages/core/src/terminal-delivery.ts +51 -3
  9. package/packages/core/src/terminal-fnv64-wasm.ts +4 -3
  10. package/packages/core/src/terminal-replica-hash-cache.ts +129 -31
  11. package/packages/core/src/terminal-replica-owned-row.ts +27 -0
  12. package/packages/core/src/terminal-replica.ts +13 -28
  13. package/packages/daemon/dist/command-center/daemon-fleet-facts-observer.js +96 -10
  14. package/packages/daemon/dist/command-center/resources/saved-machine-route.js +40 -1
  15. package/packages/daemon/dist/command-center/ws-events.js +5 -24
  16. package/packages/daemon/dist/lib/canonical-daemon-bootstrap.js +8 -0
  17. package/packages/daemon/dist/lib/canonical-daemon.js +28 -2
  18. package/packages/daemon/dist/lib/daemon-embed.js +1 -1
  19. package/packages/daemon/dist/lib/daemon-service-manager.js +122 -0
  20. package/packages/daemon/dist/lib/daemon-service-plan.js +127 -0
  21. package/packages/daemon/dist/lib/daemon-service.js +259 -0
  22. package/packages/daemon/dist/lib/install-origin.js +2 -0
  23. package/packages/daemon/dist/lib/local-fleet-request.js +9 -3
  24. package/packages/daemon/dist/lib/log.js +6 -10
  25. package/packages/daemon/dist/lib/managed-installer-update.js +67 -0
  26. package/packages/daemon/dist/lib/{tui-download-lock.js → private-operation-lock.js} +11 -11
  27. package/packages/daemon/dist/lib/remote-daemon-info.js +3 -2
  28. package/packages/daemon/dist/lib/remote-daemon-lifecycle.js +4 -4
  29. package/packages/daemon/dist/lib/remote-tmux-command.js +10 -0
  30. package/packages/daemon/dist/lib/runtime-namespace.js +28 -16
  31. package/packages/daemon/dist/lib/saved-machines.js +40 -2
  32. package/packages/daemon/dist/lib/ssh-daemon-transport.js +24 -9
  33. package/packages/daemon/dist/lib/tmux-server-owner.js +1 -1
  34. package/packages/daemon/dist/lib/tmux-server-registration.js +21 -3
  35. package/packages/daemon/dist/lib/tui-binary.js +2 -2
  36. package/packages/daemon/dist/lib/update.js +18 -0
  37. package/packages/daemon/dist/lib/workspace-multiplexer-verbs.js +24 -1
  38. package/packages/daemon/dist/machines.js +40 -4
  39. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
  40. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  41. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  42. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
  43. package/packages/daemon/dist/terminal/attachments/direct-websocket.js +4 -1
  44. package/packages/daemon/dist/terminal/attachments/native-runtime.js +22 -13
  45. package/packages/daemon/dist/terminal/mirror/mirror-service.js +101 -3
  46. package/packages/daemon/dist/terminal/mirror/registered-window-guard.js +62 -0
  47. package/packages/daemon/dist/terminal/mirror/session-channel.js +75 -4
  48. package/packages/daemon/dist/terminal/mirror/shared-window-index.js +45 -0
  49. package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +11 -1
  50. package/packages/daemon/dist/terminal/session-runtime/multiplexer-backend.js +1 -0
  51. package/packages/daemon/dist/terminal/session-runtime/registry.js +56 -7
  52. package/packages/daemon/dist/terminal/session-runtime/semantic-mutation-executor.js +3 -1
  53. package/packages/daemon/dist/tui/mirror/dialog-model.js +2 -35
  54. package/packages/daemon/dist/tui/mirror/runtime/application-daemon-authority-owner.js +8 -0
  55. package/packages/daemon/dist/tui/mirror/runtime/application-home-fleet.js +20 -2
  56. package/packages/daemon/dist/tui/mirror/runtime/application-machine-agents.js +7 -1
  57. package/packages/daemon/dist/tui/mirror/runtime/application-machine-authority.js +59 -9
  58. package/packages/daemon/dist/tui/mirror/runtime/application-machine-navigation.js +27 -19
  59. package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +23 -18
  60. package/packages/daemon/dist/tui/mirror/runtime/application-machine-startup.js +31 -2
  61. package/packages/daemon/dist/tui/mirror/runtime/application-palette-search-owner.js +7 -3
  62. package/packages/daemon/dist/tui/mirror/runtime/application-performance-log.js +3 -0
  63. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-input-ingress.js +146 -2
  64. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +201 -50
  65. package/packages/daemon/dist/tui/mirror/runtime/bounded-performance-record-writer.js +50 -10
  66. package/packages/daemon/dist/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.js +6 -5
  67. package/packages/daemon/dist/tui/mirror/runtime/terminal-pane-input-router.js +3 -0
  68. package/packages/daemon/dist/tui/mirror/runtime/workspace-terminal-fast-lane.js +8 -2
  69. package/packages/daemon/dist/tui/mirror/terminal-text.js +35 -0
  70. package/packages/daemon/dist/tui/mirror/workspace/connection-feedback.js +5 -3
  71. package/packages/daemon/src/command-center/daemon-fleet-facts-observer.ts +100 -10
  72. package/packages/daemon/src/command-center/resources/saved-machine-route.ts +48 -1
  73. package/packages/daemon/src/command-center/ws-events.ts +5 -25
  74. package/packages/daemon/src/lib/canonical-daemon-bootstrap.ts +12 -0
  75. package/packages/daemon/src/lib/canonical-daemon.ts +44 -2
  76. package/packages/daemon/src/lib/daemon-embed.ts +1 -0
  77. package/packages/daemon/src/lib/daemon-service-manager.ts +152 -0
  78. package/packages/daemon/src/lib/daemon-service-plan.ts +153 -0
  79. package/packages/daemon/src/lib/daemon-service.ts +301 -0
  80. package/packages/daemon/src/lib/install-origin.ts +5 -0
  81. package/packages/daemon/src/lib/local-fleet-request.ts +19 -3
  82. package/packages/daemon/src/lib/log.ts +13 -9
  83. package/packages/daemon/src/lib/managed-installer-update.ts +79 -0
  84. package/packages/daemon/src/lib/{tui-download-lock.ts → private-operation-lock.ts} +15 -11
  85. package/packages/daemon/src/lib/remote-daemon-info.ts +3 -3
  86. package/packages/daemon/src/lib/remote-daemon-lifecycle.ts +4 -4
  87. package/packages/daemon/src/lib/remote-tmux-command.ts +12 -0
  88. package/packages/daemon/src/lib/runtime-namespace.ts +27 -18
  89. package/packages/daemon/src/lib/saved-machines.ts +40 -1
  90. package/packages/daemon/src/lib/ssh-daemon-transport.ts +25 -7
  91. package/packages/daemon/src/lib/tmux-server-owner.ts +1 -0
  92. package/packages/daemon/src/lib/tmux-server-registration.ts +21 -3
  93. package/packages/daemon/src/lib/tui-binary.ts +2 -2
  94. package/packages/daemon/src/lib/update.ts +24 -0
  95. package/packages/daemon/src/lib/workspace-multiplexer-verbs.ts +22 -0
  96. package/packages/daemon/src/machines.ts +53 -5
  97. package/packages/daemon/src/terminal/attachments/direct-websocket.ts +8 -2
  98. package/packages/daemon/src/terminal/attachments/native-runtime.ts +28 -13
  99. package/packages/daemon/src/terminal/mirror/mirror-service.ts +105 -3
  100. package/packages/daemon/src/terminal/mirror/registered-window-guard.ts +66 -0
  101. package/packages/daemon/src/terminal/mirror/session-channel.ts +92 -4
  102. package/packages/daemon/src/terminal/mirror/shared-window-index.ts +42 -0
  103. package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +12 -1
  104. package/packages/daemon/src/terminal/session-runtime/multiplexer-backend.ts +2 -0
  105. package/packages/daemon/src/terminal/session-runtime/registry.ts +70 -8
  106. package/packages/daemon/src/terminal/session-runtime/semantic-mutation-executor.ts +3 -1
  107. package/packages/daemon/src/tui/mirror/dialog-model.ts +2 -32
  108. package/packages/daemon/src/tui/mirror/runtime/application-daemon-authority-owner.ts +8 -0
  109. package/packages/daemon/src/tui/mirror/runtime/application-home-fleet.ts +19 -2
  110. package/packages/daemon/src/tui/mirror/runtime/application-machine-agents.ts +6 -1
  111. package/packages/daemon/src/tui/mirror/runtime/application-machine-authority.ts +60 -11
  112. package/packages/daemon/src/tui/mirror/runtime/application-machine-navigation.ts +39 -33
  113. package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +31 -23
  114. package/packages/daemon/src/tui/mirror/runtime/application-machine-startup.ts +38 -2
  115. package/packages/daemon/src/tui/mirror/runtime/application-palette-search-owner.ts +7 -4
  116. package/packages/daemon/src/tui/mirror/runtime/application-performance-log.ts +3 -0
  117. package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +1 -1
  118. package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +47 -46
  119. package/packages/daemon/src/tui/mirror/runtime/application-terminal-input-ingress.ts +165 -2
  120. package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +207 -53
  121. package/packages/daemon/src/tui/mirror/runtime/bounded-performance-record-writer.ts +49 -9
  122. package/packages/daemon/src/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.ts +6 -5
  123. package/packages/daemon/src/tui/mirror/runtime/terminal-pane-input-router.ts +4 -0
  124. package/packages/daemon/src/tui/mirror/runtime/workspace-terminal-fast-lane.ts +8 -3
  125. package/packages/daemon/src/tui/mirror/terminal-text.ts +32 -0
  126. package/packages/daemon/src/tui/mirror/workspace/connection-feedback.ts +6 -3
  127. package/packages/daemon-client/src/fleet-connection-status.ts +10 -1
  128. package/packages/daemon-client/src/workspace-client.ts +4 -1
  129. package/packages/contracts/src/__tests__/actions-contract.test.ts +0 -119
  130. package/packages/contracts/src/__tests__/agent-graph-overlay.test.ts +0 -428
  131. package/packages/contracts/src/__tests__/app-window-mutation.test.ts +0 -51
  132. package/packages/contracts/src/__tests__/app-window-state.test.ts +0 -34
  133. package/packages/contracts/src/__tests__/application-shell.test.ts +0 -645
  134. package/packages/contracts/src/__tests__/automation-operations.test.ts +0 -78
  135. package/packages/contracts/src/__tests__/causal-cell.test.ts +0 -47
  136. package/packages/contracts/src/__tests__/client-view-state.test.ts +0 -115
  137. package/packages/contracts/src/__tests__/cohesion-fixture.test.ts +0 -106
  138. package/packages/contracts/src/__tests__/commands.test.ts +0 -86
  139. package/packages/contracts/src/__tests__/control.test.ts +0 -154
  140. package/packages/contracts/src/__tests__/daemon-events.test.ts +0 -405
  141. package/packages/contracts/src/__tests__/daemon-resources.test.ts +0 -143
  142. package/packages/contracts/src/__tests__/daemon-wire.test.ts +0 -149
  143. package/packages/contracts/src/__tests__/desktop-host.test.ts +0 -269
  144. package/packages/contracts/src/__tests__/desktop-missions.test.ts +0 -215
  145. package/packages/contracts/src/__tests__/experience-conformance.test.ts +0 -153
  146. package/packages/contracts/src/__tests__/experience-kernel-boundary.test.ts +0 -113
  147. package/packages/contracts/src/__tests__/experience-shell.test.ts +0 -65
  148. package/packages/contracts/src/__tests__/fixtures/daemon-wire-v2.json +0 -35
  149. package/packages/contracts/src/__tests__/fixtures/daemon-wire-v3.json +0 -35
  150. package/packages/contracts/src/__tests__/fixtures/daemon-wire-v4.json +0 -35
  151. package/packages/contracts/src/__tests__/fixtures/scoped-receipt.ts +0 -62
  152. package/packages/contracts/src/__tests__/fleet-agent-graph.test.ts +0 -231
  153. package/packages/contracts/src/__tests__/fleet-catalog.test.ts +0 -270
  154. package/packages/contracts/src/__tests__/fleet-lifecycle.test.ts +0 -62
  155. package/packages/contracts/src/__tests__/focus-overlay.test.ts +0 -110
  156. package/packages/contracts/src/__tests__/ide-config.test.ts +0 -64
  157. package/packages/contracts/src/__tests__/interaction-evidence.test.ts +0 -288
  158. package/packages/contracts/src/__tests__/interaction-receipt-v2.test.ts +0 -72
  159. package/packages/contracts/src/__tests__/issue-error.test.ts +0 -78
  160. package/packages/contracts/src/__tests__/mission-projections.test.ts +0 -186
  161. package/packages/contracts/src/__tests__/missions.test.ts +0 -272
  162. package/packages/contracts/src/__tests__/multiplexer-verbs.test.ts +0 -242
  163. package/packages/contracts/src/__tests__/pane-appearance.test.ts +0 -146
  164. package/packages/contracts/src/__tests__/pane-read-result.test.ts +0 -55
  165. package/packages/contracts/src/__tests__/pane-stream.test.ts +0 -536
  166. package/packages/contracts/src/__tests__/pane-widget-marker.test.ts +0 -327
  167. package/packages/contracts/src/__tests__/performance-metrics.test.ts +0 -73
  168. package/packages/contracts/src/__tests__/performance-qualification.test.ts +0 -137
  169. package/packages/contracts/src/__tests__/rich-card-widget.test.ts +0 -29
  170. package/packages/contracts/src/__tests__/session-runtime.test.ts +0 -512
  171. package/packages/contracts/src/__tests__/startup-readiness.test.ts +0 -409
  172. package/packages/contracts/src/__tests__/terminal-attachment-stream.test.ts +0 -72
  173. package/packages/contracts/src/__tests__/terminal-attachments.test.ts +0 -329
  174. package/packages/contracts/src/__tests__/terminal-delivery.test.ts +0 -88
  175. package/packages/contracts/src/__tests__/terminal-runtime-inventory.test.ts +0 -68
  176. package/packages/contracts/src/__tests__/terminals.test.ts +0 -87
  177. package/packages/contracts/src/__tests__/tmux-server-scope.test.ts +0 -166
  178. package/packages/contracts/src/__tests__/transport-contract-loading.test.ts +0 -91
  179. package/packages/contracts/src/__tests__/visual-tokens.test.ts +0 -224
  180. package/packages/contracts/src/__tests__/window-links.test.ts +0 -243
  181. package/packages/contracts/src/__tests__/workspace-changes-resource.test.ts +0 -317
  182. package/packages/contracts/src/__tests__/workspace-changes-view.test.ts +0 -122
  183. package/packages/contracts/src/__tests__/workspace-config.test.ts +0 -294
  184. package/packages/contracts/src/__tests__/workspace-files-resource.test.ts +0 -261
  185. package/packages/contracts/src/__tests__/workspace-files-tree.test.ts +0 -147
  186. package/packages/contracts/src/__tests__/workspace-pane-creation.test.ts +0 -249
  187. package/packages/contracts/src/__tests__/workspace-promotion.test.ts +0 -94
  188. package/packages/contracts/src/__tests__/workspace-resource-identity.test.ts +0 -90
  189. package/packages/contracts/src/daemon-resource-request.test.ts +0 -192
  190. package/packages/contracts/src/native-operation-identity.test.ts +0 -23
  191. package/packages/contracts/src/workspace-admission.test.ts +0 -21
  192. package/packages/contracts/src/workspace-catalog-resource.test.ts +0 -146
  193. package/packages/core/src/agent-provisioning.test.ts +0 -42
  194. package/packages/core/src/application-shell-session.test.ts +0 -105
  195. package/packages/core/src/interaction-evidence.test.ts +0 -134
  196. package/packages/core/src/interaction-flow.test.ts +0 -66
  197. package/packages/core/src/interaction-receipts.test.ts +0 -671
  198. package/packages/core/src/navigator.test.ts +0 -34
  199. package/packages/core/src/optimistic-projection-conformance.test.ts +0 -75
  200. package/packages/core/src/optimistic-projection.test.ts +0 -118
  201. package/packages/core/src/performance-metrics.test.ts +0 -121
  202. package/packages/core/src/performance-qualification.test.ts +0 -283
  203. package/packages/core/src/saved-machines.test.ts +0 -80
  204. package/packages/core/src/terminal-compact-cold.test.ts +0 -87
  205. package/packages/core/src/terminal-conformance.test.ts +0 -48
  206. package/packages/core/src/terminal-delivery-cache-lifetime.test.ts +0 -167
  207. package/packages/core/src/terminal-delivery-encode-cooperative.test.ts +0 -140
  208. package/packages/core/src/terminal-delivery-representation-hash.test.ts +0 -77
  209. package/packages/core/src/terminal-delivery.test.ts +0 -1471
  210. package/packages/core/src/terminal-fnv64-wasm.test.ts +0 -61
  211. package/packages/core/src/terminal-replica-cold.test.ts +0 -31
  212. package/packages/core/src/terminal-replica-cooperative.test.ts +0 -170
  213. package/packages/core/src/terminal-replica-hash-cache.test.ts +0 -283
  214. package/packages/core/src/terminal-replica.test.ts +0 -347
  215. package/packages/core/src/workspace-selection.test.ts +0 -63
  216. package/packages/daemon/dist/__tests__/support.js +0 -33
  217. package/packages/daemon/dist/lib/__tests__/fixtures/workspace-state-writer.js +0 -58
  218. package/packages/daemon/dist/lib/__tests__/installed-recovery-fixture.js +0 -400
  219. package/packages/daemon/dist/terminal/__tests__/MockPtyAdapter.js +0 -182
  220. package/packages/daemon/dist/terminal/mirror/__tests__/scripted-channel.js +0 -98
  221. package/packages/daemon/dist/terminal/mirror/__tests__/simulated-channel.js +0 -156
  222. package/packages/daemon/src/tui/mirror/__snapshots__/activity-surface-renderer.test.tsx.snap +0 -42
  223. package/packages/daemon/src/tui/mirror/__snapshots__/changes-terminal-surface-renderer.test.tsx.snap +0 -267
  224. package/packages/daemon/src/tui/mirror/__snapshots__/home-files-surface-renderer.test.tsx.snap +0 -267
  225. package/packages/daemon/src/tui/mirror/__snapshots__/missions-surface-renderer.test.tsx.snap +0 -134
  226. package/packages/daemon/src/tui/mirror/__snapshots__/recipes-gallery-renderer.test.tsx.snap +0 -141
  227. package/packages/daemon/src/tui/mirror/__snapshots__/shell-chrome-renderer.test.tsx.snap +0 -134
  228. package/packages/daemon/src/tui/mirror/runtime/__snapshots__/application-shell-view-renderer.test.tsx.snap +0 -87
  229. package/packages/daemon/src/tui/mirror/workspace/__snapshots__/agent-terminal-canvas-renderer.test.tsx.snap +0 -348
  230. package/packages/daemon/src/tui/mirror/workspace/__snapshots__/application-shell-renderer.test.tsx.snap +0 -134
  231. package/packages/daemon/src/tui/mirror/workspace/__snapshots__/command-palette-surface-renderer.test.tsx.snap +0 -255
  232. package/packages/daemon/src/tui/mirror/workspace/__snapshots__/pane-frame-renderer.test.tsx.snap +0 -525
  233. package/packages/daemon/src/tui/mirror/workspace/__snapshots__/terminal-pane-header-polish-renderer.test.tsx.snap +0 -31
  234. package/packages/daemon/src/tui/mirror/workspace/__snapshots__/workbench-shell-renderer.test.tsx.snap +0 -134
  235. package/packages/daemon-client/src/application-shell-session.test.ts +0 -196
  236. package/packages/daemon-client/src/automation-client.test.ts +0 -214
  237. package/packages/daemon-client/src/bootstrap-coordinator.test.ts +0 -145
  238. package/packages/daemon-client/src/connection-supervisor.test.ts +0 -109
  239. package/packages/daemon-client/src/direct-application-shell-transport.test.ts +0 -941
  240. package/packages/daemon-client/src/first-latest-coordinator.test.ts +0 -175
  241. package/packages/daemon-client/src/fleet-dial-scheduler.test.ts +0 -114
  242. package/packages/daemon-client/src/fleet-environments.test.ts +0 -40
  243. package/packages/daemon-client/src/generation-bound-store-timers.test.ts +0 -74
  244. package/packages/daemon-client/src/owner-action-client.test.ts +0 -245
  245. package/packages/daemon-client/src/pane-stream-client.test.ts +0 -2841
  246. package/packages/daemon-client/src/pane-stream-clock-calibration.test.ts +0 -207
  247. package/packages/daemon-client/src/pane-stream-loopback-benchmark.test.ts +0 -51
  248. package/packages/daemon-client/src/push-resource-session.test.ts +0 -509
  249. package/packages/daemon-client/src/resource-replica.test.ts +0 -186
  250. package/packages/daemon-client/src/runtime-resource-ledger.test.ts +0 -18
  251. package/packages/daemon-client/src/scoped-tmux-server-transport.test.ts +0 -114
  252. package/packages/daemon-client/src/terminal-fast-lane.test.ts +0 -835
  253. package/packages/daemon-client/src/tmux-server-client.test.ts +0 -279
  254. package/packages/daemon-client/src/tmux-server-interaction-events.test.ts +0 -230
  255. package/packages/daemon-client/src/workspace-catalog-v2.test.ts +0 -109
  256. package/packages/daemon-client/src/workspace-client-boundaries.test.ts +0 -59
  257. package/packages/daemon-client/src/workspace-client-operations.test.ts +0 -96
  258. package/packages/daemon-client/src/workspace-client.test.ts +0 -2912
  259. package/packages/daemon-client/src/workspace-event-supervisor.test.ts +0 -1086
  260. package/packages/daemon-client/src/workspace-pane-client.test.ts +0 -80
  261. package/packages/presentation/src/presentation-contract.test.ts +0 -63
  262. package/packages/tmux-bridge/src/runner.test.ts +0 -614
  263. package/packages/tmux-bridge/src/targeting.test.ts +0 -107
@@ -0,0 +1,152 @@
1
+ import { execFile } from "node:child_process";
2
+ import type { DaemonServicePlan } from "./daemon-service-plan.ts";
3
+
4
+ export interface DaemonServiceState {
5
+ readonly loaded: boolean;
6
+ readonly active: boolean;
7
+ readonly pid: number | null;
8
+ readonly definitionPath: string | null;
9
+ }
10
+
11
+ export type ServiceCommand = (
12
+ file: string,
13
+ args: readonly string[],
14
+ ) => Promise<{ code: number | null; stdout: string; stderr: string }>;
15
+
16
+ const runCommand: ServiceCommand = (file, args) =>
17
+ new Promise((resolve) => {
18
+ execFile(
19
+ file,
20
+ [...args],
21
+ {
22
+ encoding: "utf8",
23
+ timeout: 15_000,
24
+ maxBuffer: 256 * 1024,
25
+ env: { ...process.env, LC_ALL: "C", LANG: "C" },
26
+ },
27
+ (error, stdout, stderr) =>
28
+ resolve({
29
+ code: error ? (typeof error.code === "number" ? error.code : null) : 0,
30
+ stdout,
31
+ stderr,
32
+ }),
33
+ );
34
+ });
35
+
36
+ export class DaemonServiceManagerError extends Error {
37
+ readonly code = "DAEMON_SERVICE_MANAGER_FAILED";
38
+ constructor(
39
+ readonly operation: string,
40
+ readonly manager: string,
41
+ ) {
42
+ super(`${manager} user service ${operation} failed; check the user manager and service logs`);
43
+ this.name = "DaemonServiceManagerError";
44
+ }
45
+ }
46
+
47
+ /** Bounded argv-only OS boundary. Never expose manager output (it may contain environment data). */
48
+ export function createDaemonServiceManager(plan: DaemonServicePlan, run = runCommand) {
49
+ const launchd = plan.manager === "launchd";
50
+ const file = launchd ? "/bin/launchctl" : "systemctl";
51
+ const domain = plan.target.slice(0, plan.target.lastIndexOf("/"));
52
+ const command = async (operation: string, args: readonly string[]) => {
53
+ const result = await run(file, launchd ? args : ["--user", ...args]);
54
+ if (result.code !== 0) throw new DaemonServiceManagerError(operation, plan.manager);
55
+ return result;
56
+ };
57
+ return {
58
+ async available(): Promise<void> {
59
+ await command(
60
+ "availability check",
61
+ launchd ? ["print-disabled", domain] : ["show-environment"],
62
+ );
63
+ },
64
+ async inspect(): Promise<DaemonServiceState> {
65
+ const result = await run(
66
+ file,
67
+ launchd
68
+ ? ["print", plan.target]
69
+ : [
70
+ "--user",
71
+ "show",
72
+ plan.target,
73
+ "--property=LoadState,ActiveState,MainPID,FragmentPath",
74
+ ],
75
+ );
76
+ const absent = { loaded: false, active: false, pid: null, definitionPath: null } as const;
77
+ if (launchd) {
78
+ if (result.code === 113 && result.stderr.includes("Could not find service")) return absent;
79
+ if (result.code !== 0) throw new DaemonServiceManagerError("inspection", plan.manager);
80
+ const field = (name: string) =>
81
+ result.stdout.match(new RegExp(`^\\t${name} = (.*)$`, "m"))?.[1];
82
+ const path = field("path");
83
+ const pidText = field("pid");
84
+ const state = field("state");
85
+ if (!path || !state || (pidText !== undefined && !/^[1-9][0-9]*$/u.test(pidText)))
86
+ throw new DaemonServiceManagerError("inspection format", plan.manager);
87
+ return {
88
+ loaded: true,
89
+ active: state === "running",
90
+ pid: pidText ? Number(pidText) : null,
91
+ definitionPath: path,
92
+ };
93
+ }
94
+ const fields = new Map<string, string>();
95
+ for (const line of result.stdout.trim().split("\n")) {
96
+ const index = line.indexOf("=");
97
+ if (index < 1 || fields.has(line.slice(0, index)))
98
+ throw new DaemonServiceManagerError("inspection format", plan.manager);
99
+ fields.set(line.slice(0, index), line.slice(index + 1));
100
+ }
101
+ if (fields.get("LoadState") === "not-found" && (result.code === 0 || result.code === 4))
102
+ return absent;
103
+ if (result.code !== 0 || fields.get("LoadState") !== "loaded" || !fields.has("ActiveState"))
104
+ throw new DaemonServiceManagerError("inspection", plan.manager);
105
+ const pidText = fields.get("MainPID");
106
+ const path = fields.get("FragmentPath");
107
+ if (!pidText || !/^(0|[1-9][0-9]*)$/u.test(pidText) || !path)
108
+ throw new DaemonServiceManagerError("inspection format", plan.manager);
109
+ return {
110
+ loaded: true,
111
+ active: fields.get("ActiveState") === "active",
112
+ pid: Number(pidText) || null,
113
+ definitionPath: path,
114
+ };
115
+ },
116
+ async install(): Promise<void> {
117
+ if (launchd) await command("bootstrap", ["bootstrap", domain, plan.unitPath]);
118
+ else {
119
+ await command("reload", ["daemon-reload"]);
120
+ // The manager may have a different XDG_CONFIG_HOME from the invoking
121
+ // shell. Enable the owned file explicitly so it is linked into its search path.
122
+ await command("enable/start", ["enable", "--now", plan.unitPath]);
123
+ }
124
+ },
125
+ async restart(hasProcess = true): Promise<void> {
126
+ // An explicit recovery after a failed launcher must also clear systemd's
127
+ // start-rate counter. The lifecycle owner has already verified this unit's
128
+ // private definition and reservation; never reset all user units.
129
+ if (!launchd && !hasProcess)
130
+ await command("reset failed state", ["reset-failed", plan.target]);
131
+ // KeepAlive relaunches after a graceful TERM. kickstart -k would force-kill
132
+ // the daemon instead of allowing canonical ownership to be released.
133
+ await command(
134
+ "restart",
135
+ launchd
136
+ ? hasProcess
137
+ ? ["kill", "SIGTERM", plan.target]
138
+ : ["kickstart", plan.target]
139
+ : ["restart", plan.target],
140
+ );
141
+ },
142
+ async stop(): Promise<void> {
143
+ await command(
144
+ "stop/remove",
145
+ launchd ? ["bootout", plan.target] : ["disable", "--now", plan.target],
146
+ );
147
+ },
148
+ async reload(): Promise<void> {
149
+ if (!launchd) await command("reload", ["daemon-reload"]);
150
+ },
151
+ };
152
+ }
@@ -0,0 +1,153 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isAbsolute, join } from "node:path";
3
+ import { runtimeNamespaceEnvironment, type RuntimeNamespace } from "./runtime-namespace.ts";
4
+
5
+ export interface DaemonServicePlan {
6
+ readonly manager: "launchd" | "systemd";
7
+ readonly supervisionId: string;
8
+ readonly target: string;
9
+ readonly unitPath: string;
10
+ readonly recordPath: string;
11
+ readonly executable: string;
12
+ readonly contents: string;
13
+ readonly stdoutPath: string;
14
+ readonly stderrPath: string;
15
+ }
16
+
17
+ function safeValue(value: string, name: string): string {
18
+ if (!value || [...value].some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127))
19
+ throw new TypeError(`${name} must be nonempty and contain no control characters`);
20
+ return value;
21
+ }
22
+
23
+ function absolute(value: string, name: string): string {
24
+ safeValue(value, name);
25
+ if (!isAbsolute(value)) throw new TypeError(`${name} must be an absolute path`);
26
+ return value;
27
+ }
28
+
29
+ function xml(value: string): string {
30
+ return value.replace(
31
+ /[&<>"']/gu,
32
+ (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[char]!,
33
+ );
34
+ }
35
+
36
+ // systemd applies specifier expansion to paths, environment values and ExecStart.
37
+ // ExecStart additionally expands dollars unless the ':' executable prefix is used.
38
+ function systemd(value: string): string {
39
+ return `"${value.replace(/%/gu, "%%").replace(/\\/gu, "\\\\").replace(/"/gu, '\\"')}"`;
40
+ }
41
+
42
+ /** Pure rendering only. Reservation and OS-manager mutations belong to the lifecycle owner. */
43
+ export function planDaemonService(options: {
44
+ platform: string;
45
+ uid: number;
46
+ home: string;
47
+ configHome?: string;
48
+ executable: string;
49
+ path: string;
50
+ namespace: RuntimeNamespace;
51
+ }): DaemonServicePlan {
52
+ const { namespace } = options;
53
+ if (options.platform !== "darwin" && options.platform !== "linux")
54
+ throw new TypeError("Daemon services support macOS launchd and Linux systemd user managers");
55
+ if (!Number.isSafeInteger(options.uid) || options.uid <= 0)
56
+ throw new TypeError("Daemon services require a non-root user");
57
+ if (namespace.mode === "development")
58
+ throw new TypeError("Development instances own their lifecycle; use dev:instance instead");
59
+ const home = absolute(options.home, "Home directory");
60
+ const executable = absolute(options.executable, "Stable CLI executable");
61
+ // Keep the supplied stable launcher path. realpath would pin installer services
62
+ // to a retired version instead of following the next verified activation.
63
+ const identity = createHash("sha256")
64
+ .update(absolute(namespace.daemonInfoDir, "Daemon state directory"))
65
+ .digest("hex")
66
+ .slice(0, 24);
67
+ const supervisionId = `tmux-ide.${identity}`;
68
+ const stdoutPath = join(namespace.daemonInfoDir, "service.stdout.log");
69
+ const stderrPath = join(namespace.daemonInfoDir, "service.stderr.log");
70
+ const environment = {
71
+ HOME: home,
72
+ PATH: safeValue(options.path, "Service PATH"),
73
+ ...runtimeNamespaceEnvironment(namespace),
74
+ TMUX_IDE_CONFIG: namespace.configPath,
75
+ TMUX_IDE_SETTINGS_DIR: namespace.settingsDir,
76
+ TMUX_IDE_CLAUDE_DIR: namespace.claudeDir,
77
+ TMUX_IDE_CLAUDE_SETTINGS: namespace.claudeSettingsPath,
78
+ TMUX_IDE_CLAUDE_HOOK_PATH: namespace.claudeHookPath,
79
+ TMUX_IDE_OPENCODE_DIR: namespace.opencodeDir,
80
+ };
81
+ for (const [name, value] of Object.entries(environment)) safeValue(value, name);
82
+ const args = [executable, "--headless", "--supervised", supervisionId];
83
+ const shared = {
84
+ supervisionId,
85
+ executable,
86
+ recordPath: join(namespace.daemonInfoDir, "service.json"),
87
+ stdoutPath,
88
+ stderrPath,
89
+ };
90
+ if (options.platform === "darwin") {
91
+ const label = `com.${supervisionId}`;
92
+ const string = (value: string) => `<string>${xml(value)}</string>`;
93
+ return Object.freeze({
94
+ ...shared,
95
+ manager: "launchd",
96
+ target: `gui/${options.uid}/${label}`,
97
+ unitPath: join(home, "Library", "LaunchAgents", `${label}.plist`),
98
+ contents: `<?xml version="1.0" encoding="UTF-8"?>
99
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
100
+ <plist version="1.0"><dict>
101
+ <key>Label</key>${string(label)}
102
+ <key>ProgramArguments</key><array>${args.map(string).join("")}</array>
103
+ <key>EnvironmentVariables</key><dict>${Object.entries(environment)
104
+ .map(([key, value]) => `<key>${xml(key)}</key>${string(value)}`)
105
+ .join("")}</dict>
106
+ <key>WorkingDirectory</key>${string(home)}
107
+ <key>RunAtLoad</key><true/>
108
+ <key>KeepAlive</key><true/>
109
+ <key>ThrottleInterval</key><integer>3</integer>
110
+ <key>ExitTimeOut</key><integer>30</integer>
111
+ <key>AbandonProcessGroup</key><true/>
112
+ <key>StandardOutPath</key>${string(stdoutPath)}
113
+ <key>StandardErrorPath</key>${string(stderrPath)}
114
+ </dict></plist>
115
+ `,
116
+ });
117
+ }
118
+ const configHome = absolute(options.configHome ?? join(home, ".config"), "Config directory");
119
+ const target = `${supervisionId}.service`;
120
+ // WorkingDirectory is a raw path, not an ExecStart-style quoted word.
121
+ // The equivalent '/.' suffix preserves trailing spaces/backslashes without
122
+ // allowing the unit parser to trim them or treat them as line continuation.
123
+ const workingDirectory = `${home.replace(/%/gu, "%%")}/.`;
124
+ return Object.freeze({
125
+ ...shared,
126
+ manager: "systemd",
127
+ target,
128
+ unitPath: join(configHome, "systemd", "user", target),
129
+ contents: `[Unit]
130
+ Description=tmux-ide canonical user daemon
131
+ StartLimitIntervalSec=60
132
+ StartLimitBurst=10
133
+
134
+ [Service]
135
+ Type=exec
136
+ ExecStart=:${args.map(systemd).join(" ")}
137
+ WorkingDirectory=${workingDirectory}
138
+ ${Object.entries(environment)
139
+ .map(([key, value]) => `Environment=${systemd(`${key}=${value}`)}`)
140
+ .join("\n")}
141
+ Restart=always
142
+ RestartSec=3
143
+ TimeoutStopSec=30
144
+ # tmux pane work has an independent lifetime; never kill the whole service cgroup.
145
+ KillMode=process
146
+ StandardOutput=journal
147
+ StandardError=journal
148
+
149
+ [Install]
150
+ WantedBy=default.target
151
+ `,
152
+ });
153
+ }
@@ -0,0 +1,301 @@
1
+ import {
2
+ accessSync,
3
+ constants,
4
+ openSync,
5
+ closeSync,
6
+ fstatSync,
7
+ mkdirSync,
8
+ readFileSync,
9
+ unlinkSync,
10
+ writeFileSync,
11
+ renameSync,
12
+ rmSync,
13
+ realpathSync,
14
+ linkSync,
15
+ } from "node:fs";
16
+ import { randomUUID } from "node:crypto";
17
+ import { dirname, join } from "node:path";
18
+ import { homedir } from "node:os";
19
+ import { z } from "zod";
20
+ import {
21
+ getCanonicalDaemonInfoPath,
22
+ inspectCanonicalDaemonInfo,
23
+ probeCanonicalDaemonIdentity,
24
+ releaseCanonicalDaemonSupervision,
25
+ reserveCanonicalDaemonSupervision,
26
+ type CanonicalDaemonInfoState,
27
+ } from "./canonical-daemon.ts";
28
+ import { createDaemonServiceManager } from "./daemon-service-manager.ts";
29
+ import { planDaemonService, type DaemonServicePlan } from "./daemon-service-plan.ts";
30
+ import { resolveRuntimeNamespace } from "./runtime-namespace.ts";
31
+ import { IdeError } from "./errors.ts";
32
+ import { acquirePrivateOperationLock } from "./private-operation-lock.ts";
33
+
34
+ const RecordSchema = z.strictObject({
35
+ version: z.literal(1),
36
+ removing: z.boolean().optional(),
37
+ executable: z.string().min(1),
38
+ contents: z
39
+ .string()
40
+ .min(1)
41
+ .max(64 * 1024),
42
+ });
43
+
44
+ export interface DaemonServiceDependencies {
45
+ plan(executable: string): DaemonServicePlan;
46
+ manager: typeof createDaemonServiceManager;
47
+ inspect: typeof inspectCanonicalDaemonInfo;
48
+ reserve: typeof reserveCanonicalDaemonSupervision;
49
+ release: typeof releaseCanonicalDaemonSupervision;
50
+ probe: typeof probeCanonicalDaemonIdentity;
51
+ recordPath: string;
52
+ waitMs: number;
53
+ }
54
+
55
+ function defaults(): DaemonServiceDependencies {
56
+ const namespace = resolveRuntimeNamespace();
57
+ return {
58
+ plan: (executable) =>
59
+ planDaemonService({
60
+ platform: process.platform,
61
+ uid: process.getuid?.() ?? 0,
62
+ home: homedir(),
63
+ configHome: process.env.XDG_CONFIG_HOME || undefined,
64
+ executable,
65
+ path: process.env.PATH || "/usr/bin:/bin",
66
+ namespace,
67
+ }),
68
+ manager: createDaemonServiceManager,
69
+ inspect: inspectCanonicalDaemonInfo,
70
+ reserve: reserveCanonicalDaemonSupervision,
71
+ release: releaseCanonicalDaemonSupervision,
72
+ probe: probeCanonicalDaemonIdentity,
73
+ recordPath: join(dirname(getCanonicalDaemonInfoPath()), "service.json"),
74
+ waitMs: 30_000,
75
+ };
76
+ }
77
+
78
+ function refused(message: string): never {
79
+ throw new IdeError(message, { code: "DAEMON_SERVICE_REFUSED" });
80
+ }
81
+
82
+ function ownedFile(path: string): string | null {
83
+ let descriptor: number | undefined;
84
+ try {
85
+ descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
86
+ const stat = fstatSync(descriptor);
87
+ if (
88
+ !stat.isFile() ||
89
+ stat.uid !== process.getuid?.() ||
90
+ (stat.mode & 0o022) !== 0 ||
91
+ stat.size > 128 * 1024
92
+ )
93
+ refused(`Service file is not a bounded, user-owned regular file: ${path}`);
94
+ return readFileSync(descriptor, "utf8");
95
+ } catch (error) {
96
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
97
+ throw error;
98
+ } finally {
99
+ if (descriptor !== undefined) closeSync(descriptor);
100
+ }
101
+ }
102
+
103
+ function boundTo(state: CanonicalDaemonInfoState, id: string): boolean {
104
+ return state.status === "reserved"
105
+ ? state.reservation.supervisionId === id
106
+ : state.status === "valid" && state.info.supervisionId === id;
107
+ }
108
+
109
+ /** Explicit OS supervision. Never starts a detached daemon or signals a guessed PID. */
110
+ export async function manageDaemonService(
111
+ action: "install" | "status" | "restart" | "remove",
112
+ executable?: string,
113
+ deps: DaemonServiceDependencies = defaults(),
114
+ ) {
115
+ const previousRecord = ownedFile(deps.recordPath);
116
+ const record = previousRecord === null ? null : RecordSchema.parse(JSON.parse(previousRecord));
117
+ if (record?.removing && action !== "remove" && action !== "status")
118
+ refused("Service removal is incomplete; finish remove before installing or restarting");
119
+ if (!record && action !== "install") {
120
+ if (action === "status") return { status: "not-installed" as const };
121
+ refused("No managed service is installed in this daemon namespace");
122
+ }
123
+ if (action === "install" && !executable)
124
+ refused("Service installation requires an absolute stable CLI launcher path");
125
+ if (record && executable && record.executable !== executable)
126
+ refused(
127
+ "A different launcher is already registered; remove the stopped service before changing it",
128
+ );
129
+ const plan = deps.plan(record?.executable ?? executable!);
130
+ if (plan.recordPath !== deps.recordPath) refused("Service namespace changed during resolution");
131
+ const contents = record?.contents ?? plan.contents;
132
+ const manager = deps.manager(plan);
133
+ const assertFiles = (allowMissingUnit = false) => {
134
+ const unit = ownedFile(plan.unitPath);
135
+ if (unit !== contents && !((record?.removing || allowMissingUnit) && unit === null))
136
+ refused("Service definition is missing or modified; refusing to control it");
137
+ if (record && ownedFile(plan.recordPath) !== previousRecord)
138
+ refused("Service ownership record changed");
139
+ };
140
+ const inspectManager = async () => {
141
+ const state = await manager.inspect();
142
+ // systemd reports its generated search-path symlink for units enabled by
143
+ // absolute path. Resolve that link to the owned definition; the definition
144
+ // itself still has to pass our no-follow private-file and content checks.
145
+ if (
146
+ state.loaded &&
147
+ (!state.definitionPath || realpathSync(state.definitionPath) !== realpathSync(plan.unitPath))
148
+ )
149
+ refused("The service manager loaded a different definition; refusing to control it");
150
+ return state;
151
+ };
152
+ const ready = async (previousInstance?: string) => {
153
+ const deadline = Date.now() + deps.waitMs;
154
+ do {
155
+ const state = deps.inspect();
156
+ if (!boundTo(state, plan.supervisionId))
157
+ refused("Service reservation changed during startup");
158
+ if (state.status === "valid" && state.info.instanceId !== previousInstance) {
159
+ const identity = await deps.probe(state.info);
160
+ const service = await inspectManager();
161
+ if (
162
+ identity &&
163
+ service.active &&
164
+ service.pid === state.info.pid &&
165
+ identity.pid === state.info.pid &&
166
+ identity.instanceId === state.info.instanceId &&
167
+ identity.startedAt === state.info.startedAt &&
168
+ identity.protocolVersion === state.info.protocolVersion
169
+ )
170
+ return {
171
+ status: "running" as const,
172
+ manager: plan.manager,
173
+ target: plan.target,
174
+ pid: state.info.pid,
175
+ instanceId: state.info.instanceId,
176
+ };
177
+ }
178
+ await new Promise((resolve) => setTimeout(resolve, 100));
179
+ } while (Date.now() < deadline);
180
+ refused(
181
+ "Service did not publish a verified daemon before the deadline; inspect service logs, then retry restart or remove",
182
+ );
183
+ };
184
+ await manager.available();
185
+ const service = await inspectManager();
186
+ const allowMissingUnit = action === "remove" && !service.loaded;
187
+ if (record) assertFiles(allowMissingUnit);
188
+ if (action === "status") {
189
+ const state = deps.inspect();
190
+ const identity =
191
+ service.active && state.status === "valid" && boundTo(state, plan.supervisionId)
192
+ ? await deps.probe(state.info)
193
+ : null;
194
+ const verified =
195
+ identity !== null &&
196
+ state.status === "valid" &&
197
+ service.pid === state.info.pid &&
198
+ identity.pid === state.info.pid &&
199
+ identity.instanceId === state.info.instanceId &&
200
+ identity.startedAt === state.info.startedAt &&
201
+ identity.protocolVersion === state.info.protocolVersion;
202
+ return {
203
+ status: verified
204
+ ? ("running" as const)
205
+ : service.active
206
+ ? ("active-unverified" as const)
207
+ : ("stopped" as const),
208
+ manager: plan.manager,
209
+ target: plan.target,
210
+ pid: service.pid,
211
+ reservationMatches: boundTo(state, plan.supervisionId),
212
+ unitPath: plan.unitPath,
213
+ };
214
+ }
215
+ if (!record && (service.loaded || ownedFile(plan.unitPath) !== null))
216
+ refused("A service definition already exists without this namespace's ownership record");
217
+ if (
218
+ record &&
219
+ !boundTo(deps.inspect(), plan.supervisionId) &&
220
+ !(record.removing && action === "remove" && deps.inspect().status === "missing")
221
+ )
222
+ refused("Service reservation is missing or belongs to another owner");
223
+ if (!record) {
224
+ accessSync(plan.executable, constants.X_OK);
225
+ // Canonical reservation admission rejects live, uncertain and foreign owners.
226
+ deps.reserve(plan.supervisionId);
227
+ }
228
+ const lock = `${plan.recordPath}.lock`;
229
+ const releaseLock = await acquirePrivateOperationLock(lock, 1000);
230
+ let wroteRecord = false;
231
+ let wroteUnit = false;
232
+ let activationAttempted = false;
233
+ try {
234
+ if (record) assertFiles(allowMissingUnit);
235
+ if (action === "install" && !record) {
236
+ writeFileSync(
237
+ plan.recordPath,
238
+ JSON.stringify({ version: 1, executable: plan.executable, contents }) + "\n",
239
+ { flag: "wx", mode: 0o600 },
240
+ );
241
+ wroteRecord = true;
242
+ mkdirSync(dirname(plan.unitPath), { recursive: true, mode: 0o700 });
243
+ const unitStage = `${plan.unitPath}.${randomUUID()}.tmp`;
244
+ writeFileSync(unitStage, contents, { flag: "wx", mode: 0o600 });
245
+ try {
246
+ linkSync(unitStage, plan.unitPath);
247
+ } finally {
248
+ unlinkSync(unitStage);
249
+ }
250
+ wroteUnit = true;
251
+ activationAttempted = true;
252
+ await manager.install();
253
+ return await ready();
254
+ }
255
+ if (action === "remove") {
256
+ if (service.loaded) await manager.stop();
257
+ let stopped = await inspectManager();
258
+ const stopDeadline = Date.now() + deps.waitMs;
259
+ while ((stopped.active || stopped.pid !== null) && Date.now() < stopDeadline) {
260
+ await new Promise((resolve) => setTimeout(resolve, 100));
261
+ stopped = await inspectManager();
262
+ }
263
+ if (stopped.active || stopped.pid !== null)
264
+ refused("Service manager has not confirmed the daemon stopped; reservation retained");
265
+ assertFiles(allowMissingUnit);
266
+ const removalRecord = JSON.stringify({ ...record!, removing: true }) + "\n";
267
+ const temporaryRecord = `${plan.recordPath}.${randomUUID()}.tmp`;
268
+ writeFileSync(temporaryRecord, removalRecord, { flag: "wx", mode: 0o600 });
269
+ try {
270
+ if (ownedFile(plan.recordPath) !== previousRecord)
271
+ refused("Service ownership record changed during removal");
272
+ renameSync(temporaryRecord, plan.recordPath);
273
+ } finally {
274
+ rmSync(temporaryRecord, { force: true });
275
+ }
276
+ if (ownedFile(plan.unitPath) !== null) unlinkSync(plan.unitPath);
277
+ await manager.reload();
278
+ if ((await inspectManager()).loaded)
279
+ refused("Service remains loaded after removal; reservation retained");
280
+ if (deps.inspect().status !== "missing") deps.release(plan.supervisionId);
281
+ unlinkSync(plan.recordPath);
282
+ return { status: "removed" as const, target: plan.target };
283
+ }
284
+ const before = deps.inspect();
285
+ if (action === "install" && service.active) return await ready();
286
+ if (service.loaded) await manager.restart(service.pid !== null);
287
+ else await manager.install();
288
+ return await ready(before.status === "valid" ? before.info.instanceId : undefined);
289
+ } catch (error) {
290
+ // Never discard ownership after an uncertain OS activation. The retained
291
+ // record/reservation gives the explicit remove/restart commands a recovery path.
292
+ if (!activationAttempted && wroteRecord) {
293
+ if (wroteUnit && ownedFile(plan.unitPath) === contents) unlinkSync(plan.unitPath);
294
+ unlinkSync(plan.recordPath);
295
+ deps.release(plan.supervisionId);
296
+ }
297
+ throw error;
298
+ } finally {
299
+ releaseLock();
300
+ }
301
+ }
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, realpathSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  export type InstallOrigin =
5
+ | "installer"
5
6
  | "npm"
6
7
  | "pnpm"
7
8
  | "bun"
@@ -12,6 +13,10 @@ export type InstallOrigin =
12
13
  | "unknown";
13
14
  /** Conservative layout evidence; an arbitrary node_modules directory is not a global install. */
14
15
  export function detectPackageManager(path: string): Exclude<InstallOrigin, "dev"> {
16
+ if (
17
+ /\/share\/tmux-ide\/releases\/install-[^/]+\/npm\/lib\/node_modules\/tmux-ide\/bin$/.test(path)
18
+ )
19
+ return "installer";
15
20
  if (/\/(?:Cellar|Caskroom)\//.test(path)) return "homebrew";
16
21
  if (/\/_npx\//.test(path)) return "npx";
17
22
  if (/\/(?:\.yarn|yarn)\//.test(path)) return "yarn";
@@ -1,4 +1,9 @@
1
- import { SavedMachineRegistrySchema, type SavedMachine } from "@tmux-ide/contracts/saved-machines";
1
+ import {
2
+ SavedMachineRegistrySchema,
3
+ type SavedMachine,
4
+ SavedMachineMutationSchema,
5
+ type SavedMachineMutation,
6
+ } from "@tmux-ide/contracts/saved-machines";
2
7
  import {
3
8
  canonicalDaemonUrl,
4
9
  isCanonicalDaemonAlive,
@@ -8,6 +13,17 @@ import {
8
13
  /** Intentional local registry mutation. Never uses selected-machine authority. */
9
14
  export async function saveMachineProfiles(machines: readonly SavedMachine[]) {
10
15
  const registry = SavedMachineRegistrySchema.parse({ version: 1, machines });
16
+ return writeLocalMachineRegistry("POST", { registry });
17
+ }
18
+
19
+ export async function mutateMachineProfile(change: SavedMachineMutation) {
20
+ return writeLocalMachineRegistry("PATCH", { change: SavedMachineMutationSchema.parse(change) });
21
+ }
22
+
23
+ async function writeLocalMachineRegistry(
24
+ method: "POST" | "PATCH",
25
+ payload: Record<string, unknown>,
26
+ ) {
11
27
  const daemon = readCanonicalDaemonInfo();
12
28
  if (!daemon?.authToken || !(await isCanonicalDaemonAlive(daemon)))
13
29
  throw new Error("Start a local tmux-ide daemon to save machine profiles.");
@@ -16,14 +32,14 @@ export async function saveMachineProfiles(machines: readonly SavedMachine[]) {
16
32
  canonicalDaemonUrl("http", daemon.bindHostname, daemon.port) +
17
33
  "/api/resources/saved-machines",
18
34
  {
19
- method: "POST",
35
+ method,
20
36
  redirect: "error",
21
37
  signal: AbortSignal.timeout(5000),
22
38
  headers: {
23
39
  Authorization: `Bearer ${daemon.authToken}`,
24
40
  "Content-Type": "application/json",
25
41
  },
26
- body: JSON.stringify({ expectedInstanceId: daemon.instanceId, registry }),
42
+ body: JSON.stringify({ expectedInstanceId: daemon.instanceId, ...payload }),
27
43
  },
28
44
  );
29
45
  if (!response.ok) {