@sirenodeck/sirenodeck 0.1.1-alpha.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 (485) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +69 -0
  3. package/bin/_wrapper-shared.js +42 -0
  4. package/bin/dev.js +95 -0
  5. package/bin/sirenodeck.js +42 -0
  6. package/config/default-config.yml +117 -0
  7. package/config/starter-config.yml +54 -0
  8. package/dist/main.mjs +64702 -0
  9. package/package.json +140 -0
  10. package/src/action/executor.ts +156 -0
  11. package/src/action/index.ts +7 -0
  12. package/src/addon/api-types.ts +15 -0
  13. package/src/addon/api.ts +461 -0
  14. package/src/addon/check-runner.ts +84 -0
  15. package/src/addon/index.ts +5 -0
  16. package/src/addon/loader.ts +441 -0
  17. package/src/addon/registry.ts +230 -0
  18. package/src/addon/spec.ts +69 -0
  19. package/src/api/addon.ts +33 -0
  20. package/src/api/asset-url.ts +24 -0
  21. package/src/api/index.ts +50 -0
  22. package/src/api/protocol-internal.ts +656 -0
  23. package/src/api/protocol.ts +2 -0
  24. package/src/api/react/index.ts +11 -0
  25. package/src/api/react/registry.ts +91 -0
  26. package/src/api/react/use-addon-channel.ts +26 -0
  27. package/src/api/react/use-button-state.tsx +23 -0
  28. package/src/api/react/use-deck.ts +24 -0
  29. package/src/assets/logo.png +0 -0
  30. package/src/assets/logo72x72.png +0 -0
  31. package/src/assets/logoFull.png +0 -0
  32. package/src/builtin-addons/brightness/README.md +51 -0
  33. package/src/builtin-addons/brightness/buttons/brightness/backend.ts +13 -0
  34. package/src/builtin-addons/brightness/buttons/brightness/config.ts +10 -0
  35. package/src/builtin-addons/brightness/buttons/brightness/frontend.tsx +50 -0
  36. package/src/builtin-addons/brightness/domain/macos.ts +46 -0
  37. package/src/builtin-addons/brightness/index.d.ts +5 -0
  38. package/src/builtin-addons/brightness/index.ts +20 -0
  39. package/src/builtin-addons/brightness/poller.ts +27 -0
  40. package/src/builtin-addons/brightness/providers/darwin.ts +107 -0
  41. package/src/builtin-addons/brightness/providers/index.ts +81 -0
  42. package/src/builtin-addons/brightness/providers/linux.ts +108 -0
  43. package/src/builtin-addons/brightness/providers/windows.ts +77 -0
  44. package/src/builtin-addons/brightness/sirenodeck.json +6 -0
  45. package/src/builtin-addons/coding-agents/assets/claude-code.svg +1 -0
  46. package/src/builtin-addons/coding-agents/assets/opencode-dark-square.svg +1 -0
  47. package/src/builtin-addons/coding-agents/buttons/agent/backend.ts +82 -0
  48. package/src/builtin-addons/coding-agents/buttons/agent/config.ts +13 -0
  49. package/src/builtin-addons/coding-agents/buttons/agent/frontend.tsx +130 -0
  50. package/src/builtin-addons/coding-agents/buttons/agent/pages.tsx +150 -0
  51. package/src/builtin-addons/coding-agents/buttons/summary/backend.ts +48 -0
  52. package/src/builtin-addons/coding-agents/buttons/summary/config.ts +11 -0
  53. package/src/builtin-addons/coding-agents/buttons/summary/frontend.tsx +254 -0
  54. package/src/builtin-addons/coding-agents/decks/agents.ts +69 -0
  55. package/src/builtin-addons/coding-agents/global/backend.ts +402 -0
  56. package/src/builtin-addons/coding-agents/global-entry.ts +7 -0
  57. package/src/builtin-addons/coding-agents/hook/script.ts +123 -0
  58. package/src/builtin-addons/coding-agents/index.ts +15 -0
  59. package/src/builtin-addons/coding-agents/manifest.ts +115 -0
  60. package/src/builtin-addons/coding-agents/providers/claude-code.ts +470 -0
  61. package/src/builtin-addons/coding-agents/providers/claude-instances.ts +184 -0
  62. package/src/builtin-addons/coding-agents/providers/opencode-instances.ts +108 -0
  63. package/src/builtin-addons/coding-agents/providers/opencode.ts +636 -0
  64. package/src/builtin-addons/coding-agents/providers/registry.ts +120 -0
  65. package/src/builtin-addons/coding-agents/providers/spawn.ts +112 -0
  66. package/src/builtin-addons/coding-agents/shared/claude-hook-events.ts +55 -0
  67. package/src/builtin-addons/coding-agents/shared/claude-status.ts +236 -0
  68. package/src/builtin-addons/coding-agents/shared/live-count.ts +39 -0
  69. package/src/builtin-addons/coding-agents/shared/notifier.ts +58 -0
  70. package/src/builtin-addons/coding-agents/shared/opencode-status.ts +82 -0
  71. package/src/builtin-addons/coding-agents/shared/snapshot.ts +66 -0
  72. package/src/builtin-addons/coding-agents/shared/state.ts +72 -0
  73. package/src/builtin-addons/coding-agents/sirenodeck.json +6 -0
  74. package/src/builtin-addons/coding-agents/types/types.ts +33 -0
  75. package/src/builtin-addons/core/README.md +89 -0
  76. package/src/builtin-addons/core/buttons/action/backend.ts +7 -0
  77. package/src/builtin-addons/core/buttons/action/config.ts +13 -0
  78. package/src/builtin-addons/core/buttons/action/frontend.tsx +16 -0
  79. package/src/builtin-addons/core/buttons/change-deck/backend.ts +18 -0
  80. package/src/builtin-addons/core/buttons/change-deck/config.ts +15 -0
  81. package/src/builtin-addons/core/buttons/change-deck/frontend.tsx +18 -0
  82. package/src/builtin-addons/core/buttons/media-sample/backend.ts +8 -0
  83. package/src/builtin-addons/core/buttons/media-sample/config.ts +7 -0
  84. package/src/builtin-addons/core/buttons/media-sample/frontend.tsx +2 -0
  85. package/src/builtin-addons/core/buttons/page-nav/backend.ts +28 -0
  86. package/src/builtin-addons/core/buttons/page-nav/config.ts +9 -0
  87. package/src/builtin-addons/core/buttons/page-nav/frontend.tsx +68 -0
  88. package/src/builtin-addons/core/buttons/toggle/backend.ts +119 -0
  89. package/src/builtin-addons/core/buttons/toggle/config.ts +57 -0
  90. package/src/builtin-addons/core/buttons/toggle/frontend.tsx +57 -0
  91. package/src/builtin-addons/core/buttons/toggle/global-service.ts +174 -0
  92. package/src/builtin-addons/core/index.ts +120 -0
  93. package/src/builtin-addons/core/sirenodeck.json +6 -0
  94. package/src/builtin-addons/date-time/README.md +73 -0
  95. package/src/builtin-addons/date-time/buttons/analog-clock/backend.ts +10 -0
  96. package/src/builtin-addons/date-time/buttons/analog-clock/config.ts +4 -0
  97. package/src/builtin-addons/date-time/buttons/analog-clock/frontend.tsx +88 -0
  98. package/src/builtin-addons/date-time/buttons/custom/backend.ts +10 -0
  99. package/src/builtin-addons/date-time/buttons/custom/config.ts +9 -0
  100. package/src/builtin-addons/date-time/buttons/custom/frontend.tsx +26 -0
  101. package/src/builtin-addons/date-time/buttons/date/backend.ts +10 -0
  102. package/src/builtin-addons/date-time/buttons/date/config.ts +10 -0
  103. package/src/builtin-addons/date-time/buttons/date/frontend.tsx +42 -0
  104. package/src/builtin-addons/date-time/buttons/time/backend.ts +10 -0
  105. package/src/builtin-addons/date-time/buttons/time/config.ts +9 -0
  106. package/src/builtin-addons/date-time/buttons/time/frontend.tsx +29 -0
  107. package/src/builtin-addons/date-time/index.d.ts +5 -0
  108. package/src/builtin-addons/date-time/index.ts +30 -0
  109. package/src/builtin-addons/date-time/poller.ts +11 -0
  110. package/src/builtin-addons/date-time/shared/format-date.ts +36 -0
  111. package/src/builtin-addons/date-time/shared/format.ts +105 -0
  112. package/src/builtin-addons/date-time/shared/use-now.ts +10 -0
  113. package/src/builtin-addons/date-time/sirenodeck.json +6 -0
  114. package/src/builtin-addons/emoji-selector/README.md +45 -0
  115. package/src/builtin-addons/emoji-selector/assets/launcher.svg +10 -0
  116. package/src/builtin-addons/emoji-selector/assets/smileys.svg +8 -0
  117. package/src/builtin-addons/emoji-selector/buttons/category/backend.ts +14 -0
  118. package/src/builtin-addons/emoji-selector/buttons/category/config.ts +8 -0
  119. package/src/builtin-addons/emoji-selector/buttons/category/frontend.tsx +19 -0
  120. package/src/builtin-addons/emoji-selector/buttons/emoji/backend.ts +7 -0
  121. package/src/builtin-addons/emoji-selector/buttons/emoji/config.ts +9 -0
  122. package/src/builtin-addons/emoji-selector/buttons/emoji/frontend.tsx +11 -0
  123. package/src/builtin-addons/emoji-selector/buttons/launcher/backend.ts +13 -0
  124. package/src/builtin-addons/emoji-selector/buttons/launcher/config.ts +6 -0
  125. package/src/builtin-addons/emoji-selector/buttons/launcher/frontend.tsx +31 -0
  126. package/src/builtin-addons/emoji-selector/data/categories.ts +1084 -0
  127. package/src/builtin-addons/emoji-selector/decks/index.ts +137 -0
  128. package/src/builtin-addons/emoji-selector/index.d.ts +5 -0
  129. package/src/builtin-addons/emoji-selector/index.ts +29 -0
  130. package/src/builtin-addons/emoji-selector/sirenodeck.json +6 -0
  131. package/src/builtin-addons/emoji-selector/support.ts +49 -0
  132. package/src/builtin-addons/index.ts +13 -0
  133. package/src/builtin-addons/internal-settings/README.md +26 -0
  134. package/src/builtin-addons/internal-settings/backend.ts +58 -0
  135. package/src/builtin-addons/internal-settings/buttons/app-info/backend.ts +7 -0
  136. package/src/builtin-addons/internal-settings/buttons/app-info/config.ts +13 -0
  137. package/src/builtin-addons/internal-settings/buttons/app-info/frontend.tsx +18 -0
  138. package/src/builtin-addons/internal-settings/buttons/brightness/common/frontend.tsx +50 -0
  139. package/src/builtin-addons/internal-settings/buttons/brightness-down/backend.ts +16 -0
  140. package/src/builtin-addons/internal-settings/buttons/brightness-down/config.ts +6 -0
  141. package/src/builtin-addons/internal-settings/buttons/brightness-down/frontend.tsx +8 -0
  142. package/src/builtin-addons/internal-settings/buttons/brightness-up/backend.ts +16 -0
  143. package/src/builtin-addons/internal-settings/buttons/brightness-up/config.ts +6 -0
  144. package/src/builtin-addons/internal-settings/buttons/brightness-up/frontend.tsx +8 -0
  145. package/src/builtin-addons/internal-settings/decks/settings.ts +25 -0
  146. package/src/builtin-addons/internal-settings/index.d.ts +4 -0
  147. package/src/builtin-addons/internal-settings/index.ts +53 -0
  148. package/src/builtin-addons/internal-settings/logo72x72.png +0 -0
  149. package/src/builtin-addons/internal-settings/sirenodeck.json +6 -0
  150. package/src/builtin-addons/media/README.md +45 -0
  151. package/src/builtin-addons/media/backend.ts +143 -0
  152. package/src/builtin-addons/media/buttons/media-player/backend.ts +19 -0
  153. package/src/builtin-addons/media/buttons/media-player/components/MediaStatus.tsx +25 -0
  154. package/src/builtin-addons/media/buttons/media-player/components/MediaSurface.tsx +52 -0
  155. package/src/builtin-addons/media/buttons/media-player/components/ProgressBar.tsx +21 -0
  156. package/src/builtin-addons/media/buttons/media-player/components/status-meta.ts +36 -0
  157. package/src/builtin-addons/media/buttons/media-player/config.ts +4 -0
  158. package/src/builtin-addons/media/buttons/media-player/frontend.tsx +29 -0
  159. package/src/builtin-addons/media/buttons/volume/common/frontend.tsx +52 -0
  160. package/src/builtin-addons/media/buttons/volume/mute/backend.ts +13 -0
  161. package/src/builtin-addons/media/buttons/volume/mute/config.ts +4 -0
  162. package/src/builtin-addons/media/buttons/volume/mute/frontend.tsx +26 -0
  163. package/src/builtin-addons/media/buttons/volume/volume-down/backend.ts +14 -0
  164. package/src/builtin-addons/media/buttons/volume/volume-down/config.ts +6 -0
  165. package/src/builtin-addons/media/buttons/volume/volume-down/frontend.tsx +10 -0
  166. package/src/builtin-addons/media/buttons/volume/volume-up/backend.ts +14 -0
  167. package/src/builtin-addons/media/buttons/volume/volume-up/config.ts +6 -0
  168. package/src/builtin-addons/media/buttons/volume/volume-up/frontend.tsx +8 -0
  169. package/src/builtin-addons/media/checks.ts +104 -0
  170. package/src/builtin-addons/media/executor.ts +36 -0
  171. package/src/builtin-addons/media/index.d.ts +5 -0
  172. package/src/builtin-addons/media/index.ts +53 -0
  173. package/src/builtin-addons/media/progress.ts +16 -0
  174. package/src/builtin-addons/media/providers/darwin.ts +391 -0
  175. package/src/builtin-addons/media/providers/index.ts +27 -0
  176. package/src/builtin-addons/media/providers/linux.ts +196 -0
  177. package/src/builtin-addons/media/providers/types.ts +48 -0
  178. package/src/builtin-addons/media/providers/windows.ts +121 -0
  179. package/src/builtin-addons/media/sirenodeck.json +6 -0
  180. package/src/builtin-addons/media/state.ts +19 -0
  181. package/src/builtin-addons/register-builtins.ts +65 -0
  182. package/src/builtin-addons/session/README.md +14 -0
  183. package/src/builtin-addons/session/buttons/session-info/backend.ts +8 -0
  184. package/src/builtin-addons/session/buttons/session-info/config.ts +4 -0
  185. package/src/builtin-addons/session/buttons/session-info/frontend.tsx +2 -0
  186. package/src/builtin-addons/session/buttons/time/backend.ts +7 -0
  187. package/src/builtin-addons/session/buttons/time/config.ts +6 -0
  188. package/src/builtin-addons/session/buttons/time/frontend.tsx +2 -0
  189. package/src/builtin-addons/session/index.d.ts +5 -0
  190. package/src/builtin-addons/session/index.ts +18 -0
  191. package/src/builtin-addons/session/sirenodeck.json +6 -0
  192. package/src/builtin-addons/system-status/README.md +169 -0
  193. package/src/builtin-addons/system-status/buttons/_shared.ts +131 -0
  194. package/src/builtin-addons/system-status/buttons/system-status/frontend.tsx +224 -0
  195. package/src/builtin-addons/system-status/buttons/system-status/schemas.ts +67 -0
  196. package/src/builtin-addons/system-status/domain/catalog.ts +53 -0
  197. package/src/builtin-addons/system-status/domain/chart-sampler.ts +58 -0
  198. package/src/builtin-addons/system-status/domain/display-metrics.ts +128 -0
  199. package/src/builtin-addons/system-status/domain/index.ts +10 -0
  200. package/src/builtin-addons/system-status/domain/live-metrics.ts +1021 -0
  201. package/src/builtin-addons/system-status/domain/metric-ids.ts +0 -0
  202. package/src/builtin-addons/system-status/index.ts +16 -0
  203. package/src/builtin-addons/system-status/manifest.ts +105 -0
  204. package/src/builtin-addons/system-status/shared/metrics-catalog.ts +316 -0
  205. package/src/builtin-addons/system-status/sirenodeck.json +6 -0
  206. package/src/builtin-addons/test-buildin/decks/action-buttons-test.ts +56 -0
  207. package/src/builtin-addons/test-buildin/index.d.ts +5 -0
  208. package/src/builtin-addons/test-buildin/index.ts +16 -0
  209. package/src/builtin-addons/value-display/README.md +59 -0
  210. package/src/builtin-addons/value-display/backend.ts +86 -0
  211. package/src/builtin-addons/value-display/buttons/value-display/backend.ts +17 -0
  212. package/src/builtin-addons/value-display/buttons/value-display/config.ts +36 -0
  213. package/src/builtin-addons/value-display/buttons/value-display/frontend.tsx +54 -0
  214. package/src/builtin-addons/value-display/index.d.ts +5 -0
  215. package/src/builtin-addons/value-display/index.ts +23 -0
  216. package/src/builtin-addons/value-display/sirenodeck.json +6 -0
  217. package/src/builtin-addons/weather/README.md +46 -0
  218. package/src/builtin-addons/weather/backend.ts +121 -0
  219. package/src/builtin-addons/weather/buttons/weather/backend.ts +27 -0
  220. package/src/builtin-addons/weather/buttons/weather/config.ts +29 -0
  221. package/src/builtin-addons/weather/buttons/weather/frontend.tsx +63 -0
  222. package/src/builtin-addons/weather/buttons/weather/icons.tsx +88 -0
  223. package/src/builtin-addons/weather/buttons/weather/pages.tsx +109 -0
  224. package/src/builtin-addons/weather/domain/codes.ts +35 -0
  225. package/src/builtin-addons/weather/domain/fetch.ts +47 -0
  226. package/src/builtin-addons/weather/index.d.ts +5 -0
  227. package/src/builtin-addons/weather/index.ts +23 -0
  228. package/src/builtin-addons/weather/poller.ts +70 -0
  229. package/src/builtin-addons/weather/provider/city-key.ts +9 -0
  230. package/src/builtin-addons/weather/provider/codes.ts +35 -0
  231. package/src/builtin-addons/weather/provider/fetch.ts +97 -0
  232. package/src/builtin-addons/weather/provider/geocode.ts +67 -0
  233. package/src/builtin-addons/weather/provider/types.ts +42 -0
  234. package/src/builtin-addons/weather/sirenodeck.json +6 -0
  235. package/src/cli/coding-agents-onboarding.ts +434 -0
  236. package/src/cli/commands/addon-decks.ts +441 -0
  237. package/src/cli/commands/addon-registry.ts +639 -0
  238. package/src/cli/commands/emulator-mode.ts +289 -0
  239. package/src/cli/commands/install.ts +128 -0
  240. package/src/cli/commands/logs.ts +74 -0
  241. package/src/cli/commands/network-bind.ts +111 -0
  242. package/src/cli/commands/pipeline/helpers.ts +75 -0
  243. package/src/cli/commands/pipeline/preflight.ts +20 -0
  244. package/src/cli/commands/port-identity.ts +152 -0
  245. package/src/cli/commands/reload.ts +37 -0
  246. package/src/cli/commands/restart.ts +139 -0
  247. package/src/cli/commands/run.ts +2270 -0
  248. package/src/cli/commands/service/install.ts +121 -0
  249. package/src/cli/commands/service/render-template.ts +158 -0
  250. package/src/cli/commands/service-manager.ts +184 -0
  251. package/src/cli/commands/spawn-daemon.ts +251 -0
  252. package/src/cli/commands/start.ts +1061 -0
  253. package/src/cli/commands/status.ts +110 -0
  254. package/src/cli/commands/stop.ts +121 -0
  255. package/src/cli/commands/subprocess-supervisor.ts +203 -0
  256. package/src/cli/commands/system-requirements.ts +699 -0
  257. package/src/cli/cwd.ts +28 -0
  258. package/src/cli/first-run.ts +149 -0
  259. package/src/cli/http-server.ts +244 -0
  260. package/src/cli/index.ts +355 -0
  261. package/src/cli/install-root.ts +26 -0
  262. package/src/cli/main.ts +194 -0
  263. package/src/cli/package-manager.ts +175 -0
  264. package/src/cli/probe-deps.ts +45 -0
  265. package/src/cli/prompt.ts +22 -0
  266. package/src/cli/startup-display.ts +452 -0
  267. package/src/config/builtin-icons.ts +57 -0
  268. package/src/config/config-diff.ts +54 -0
  269. package/src/config/discovery.ts +64 -0
  270. package/src/config/icon-resolver.ts +90 -0
  271. package/src/config/include-resolver.ts +202 -0
  272. package/src/config/index.ts +9 -0
  273. package/src/config/loader.ts +187 -0
  274. package/src/config/mutation.ts +514 -0
  275. package/src/config/reference-expander.ts +84 -0
  276. package/src/config/schemas.ts +223 -0
  277. package/src/config/validation.ts +254 -0
  278. package/src/core/asset-id.ts +21 -0
  279. package/src/core/gesture-state.ts +344 -0
  280. package/src/core/icon-asset-registry.ts +127 -0
  281. package/src/core/icon-source.ts +86 -0
  282. package/src/core/index.ts +30 -0
  283. package/src/core/mime.ts +16 -0
  284. package/src/core/pagination.ts +90 -0
  285. package/src/core/pub-sub.ts +126 -0
  286. package/src/core/settings.ts +153 -0
  287. package/src/core/store.ts +88 -0
  288. package/src/core/watcher.ts +73 -0
  289. package/src/deck/addon-handler-bridge.ts +516 -0
  290. package/src/deck/deck-config.ts +271 -0
  291. package/src/deck/editor-surfaces.ts +87 -0
  292. package/src/deck/host-context.ts +18 -0
  293. package/src/deck/index.ts +102 -0
  294. package/src/deck/macro-parse.ts +182 -0
  295. package/src/deck/methods.ts +348 -0
  296. package/src/deck/paginate-deck.ts +98 -0
  297. package/src/deck/position-buttons.ts +57 -0
  298. package/src/deck/runtime/index.ts +5 -0
  299. package/src/deck/runtime/runtime.ts +1060 -0
  300. package/src/deck/runtime-subscriptions.ts +45 -0
  301. package/src/deck/system-back-injection.ts +62 -0
  302. package/src/deck/system-buttons/registry.tsx +97 -0
  303. package/src/deck/system-buttons/types.ts +14 -0
  304. package/src/device/black-frame.ts +73 -0
  305. package/src/device/index.ts +13 -0
  306. package/src/device/linux-udev.ts +36 -0
  307. package/src/device/models.ts +89 -0
  308. package/src/device/reconnecting.ts +193 -0
  309. package/src/device/registry.ts +38 -0
  310. package/src/device/stream-deck.ts +155 -0
  311. package/src/index.ts +74 -0
  312. package/src/outputClient/emulator.ts +357 -0
  313. package/src/outputClient/index.ts +26 -0
  314. package/src/outputClient/open-browser.ts +26 -0
  315. package/src/outputClient/real.ts +393 -0
  316. package/src/outputClient/types.ts +83 -0
  317. package/src/render/browser-renderer.ts +240 -0
  318. package/src/render/buffer-hash.ts +20 -0
  319. package/src/render/editor-handler.ts +197 -0
  320. package/src/render/emulator-server.ts +128 -0
  321. package/src/render/icon-source-resolver.ts +69 -0
  322. package/src/render/index.ts +16 -0
  323. package/src/render/protocol.ts +61 -0
  324. package/src/render/push-raw-image.ts +100 -0
  325. package/src/render/screenshot-cadence.ts +87 -0
  326. package/src/render/state-publisher.ts +110 -0
  327. package/src/render/vite-server.ts +138 -0
  328. package/src/render/ws-bridge.ts +315 -0
  329. package/src/system/device-selection.ts +80 -0
  330. package/src/system/glob-match.ts +74 -0
  331. package/src/system/providers/active-app/darwin.ts +117 -0
  332. package/src/system/providers/active-app/linux.ts +245 -0
  333. package/src/system/providers/active-app/wayland-gnome.ts +303 -0
  334. package/src/system/providers/active-app/windows.ts +94 -0
  335. package/src/system/providers/active-app.ts +97 -0
  336. package/src/system/providers/clipboard/darwin.ts +65 -0
  337. package/src/system/providers/clipboard/linux.ts +163 -0
  338. package/src/system/providers/clipboard/windows.ts +94 -0
  339. package/src/system/providers/clipboard.ts +84 -0
  340. package/src/system/providers/error.ts +16 -0
  341. package/src/system/providers/key-macro/darwin.ts +221 -0
  342. package/src/system/providers/key-macro/linux.ts +651 -0
  343. package/src/system/providers/key-macro/parser.ts +215 -0
  344. package/src/system/providers/key-macro/windows.ts +476 -0
  345. package/src/system/providers/key-macro.ts +79 -0
  346. package/src/system/providers/notification/darwin.ts +36 -0
  347. package/src/system/providers/notification/linux.ts +115 -0
  348. package/src/system/providers/notification/windows.ts +72 -0
  349. package/src/system/providers/notification.ts +75 -0
  350. package/src/system/providers/session/darwin.ts +82 -0
  351. package/src/system/providers/session/linux.ts +175 -0
  352. package/src/system/providers/session/windows.ts +73 -0
  353. package/src/system/providers/session.ts +89 -0
  354. package/src/system/providers/shared.ts +65 -0
  355. package/src/system/requirements.ts +235 -0
  356. package/src/system/setup-wizard/config-seed.ts +30 -0
  357. package/src/system/setup-wizard/format.ts +275 -0
  358. package/src/system/setup-wizard/index.ts +45 -0
  359. package/src/system/setup-wizard/open-settings.ts +26 -0
  360. package/src/system/setup-wizard/plan.ts +230 -0
  361. package/src/system/setup-wizard/probe-cache.ts +34 -0
  362. package/src/system/setup-wizard/probe.ts +486 -0
  363. package/src/system/setup-wizard/runtime-features.ts +85 -0
  364. package/src/system/setup-wizard/sudo.ts +178 -0
  365. package/src/system/setup-wizard/types.ts +133 -0
  366. package/src/system/virtual-stream-deck.ts +67 -0
  367. package/src/themes/css.ts +210 -0
  368. package/src/themes/default/assets/IBMPlexSans-Italic-VariableFont_wdth,wght.ttf +0 -0
  369. package/src/themes/default/assets/IBMPlexSans-VariableFont_wdth,wght.ttf +0 -0
  370. package/src/themes/default/assets/IBM_Plex_Mono/IBMPlexMono-Bold.ttf +0 -0
  371. package/src/themes/default/assets/IBM_Plex_Mono/IBMPlexMono-BoldItalic.ttf +0 -0
  372. package/src/themes/default/assets/IBM_Plex_Mono/IBMPlexMono-ExtraLight.ttf +0 -0
  373. package/src/themes/default/assets/IBM_Plex_Mono/IBMPlexMono-ExtraLightItalic.ttf +0 -0
  374. package/src/themes/default/assets/IBM_Plex_Mono/IBMPlexMono-Italic.ttf +0 -0
  375. package/src/themes/default/assets/IBM_Plex_Mono/IBMPlexMono-Light.ttf +0 -0
  376. package/src/themes/default/assets/IBM_Plex_Mono/IBMPlexMono-LightItalic.ttf +0 -0
  377. package/src/themes/default/assets/IBM_Plex_Mono/IBMPlexMono-Medium.ttf +0 -0
  378. package/src/themes/default/assets/IBM_Plex_Mono/IBMPlexMono-MediumItalic.ttf +0 -0
  379. package/src/themes/default/assets/IBM_Plex_Mono/IBMPlexMono-Regular.ttf +0 -0
  380. package/src/themes/default/assets/IBM_Plex_Mono/IBMPlexMono-SemiBold.ttf +0 -0
  381. package/src/themes/default/assets/IBM_Plex_Mono/IBMPlexMono-SemiBoldItalic.ttf +0 -0
  382. package/src/themes/default/assets/IBM_Plex_Mono/IBMPlexMono-Thin.ttf +0 -0
  383. package/src/themes/default/assets/IBM_Plex_Mono/IBMPlexMono-ThinItalic.ttf +0 -0
  384. package/src/themes/default/assets/IBM_Plex_Mono/OFL.txt +93 -0
  385. package/src/themes/default/assets/OFL.txt +93 -0
  386. package/src/themes/default/assets/README.txt +106 -0
  387. package/src/themes/default/assets/static/IBMPlexSans-Bold.ttf +0 -0
  388. package/src/themes/default/assets/static/IBMPlexSans-BoldItalic.ttf +0 -0
  389. package/src/themes/default/assets/static/IBMPlexSans-ExtraLight.ttf +0 -0
  390. package/src/themes/default/assets/static/IBMPlexSans-ExtraLightItalic.ttf +0 -0
  391. package/src/themes/default/assets/static/IBMPlexSans-Italic.ttf +0 -0
  392. package/src/themes/default/assets/static/IBMPlexSans-Light.ttf +0 -0
  393. package/src/themes/default/assets/static/IBMPlexSans-LightItalic.ttf +0 -0
  394. package/src/themes/default/assets/static/IBMPlexSans-Medium.ttf +0 -0
  395. package/src/themes/default/assets/static/IBMPlexSans-MediumItalic.ttf +0 -0
  396. package/src/themes/default/assets/static/IBMPlexSans-Regular.ttf +0 -0
  397. package/src/themes/default/assets/static/IBMPlexSans-SemiBold.ttf +0 -0
  398. package/src/themes/default/assets/static/IBMPlexSans-SemiBoldItalic.ttf +0 -0
  399. package/src/themes/default/assets/static/IBMPlexSans-Thin.ttf +0 -0
  400. package/src/themes/default/assets/static/IBMPlexSans-ThinItalic.ttf +0 -0
  401. package/src/themes/default/assets/static/IBMPlexSans_Condensed-Bold.ttf +0 -0
  402. package/src/themes/default/assets/static/IBMPlexSans_Condensed-BoldItalic.ttf +0 -0
  403. package/src/themes/default/assets/static/IBMPlexSans_Condensed-ExtraLight.ttf +0 -0
  404. package/src/themes/default/assets/static/IBMPlexSans_Condensed-ExtraLightItalic.ttf +0 -0
  405. package/src/themes/default/assets/static/IBMPlexSans_Condensed-Italic.ttf +0 -0
  406. package/src/themes/default/assets/static/IBMPlexSans_Condensed-Light.ttf +0 -0
  407. package/src/themes/default/assets/static/IBMPlexSans_Condensed-LightItalic.ttf +0 -0
  408. package/src/themes/default/assets/static/IBMPlexSans_Condensed-Medium.ttf +0 -0
  409. package/src/themes/default/assets/static/IBMPlexSans_Condensed-MediumItalic.ttf +0 -0
  410. package/src/themes/default/assets/static/IBMPlexSans_Condensed-Regular.ttf +0 -0
  411. package/src/themes/default/assets/static/IBMPlexSans_Condensed-SemiBold.ttf +0 -0
  412. package/src/themes/default/assets/static/IBMPlexSans_Condensed-SemiBoldItalic.ttf +0 -0
  413. package/src/themes/default/assets/static/IBMPlexSans_Condensed-Thin.ttf +0 -0
  414. package/src/themes/default/assets/static/IBMPlexSans_Condensed-ThinItalic.ttf +0 -0
  415. package/src/themes/default/assets/static/IBMPlexSans_SemiCondensed-Bold.ttf +0 -0
  416. package/src/themes/default/assets/static/IBMPlexSans_SemiCondensed-BoldItalic.ttf +0 -0
  417. package/src/themes/default/assets/static/IBMPlexSans_SemiCondensed-ExtraLight.ttf +0 -0
  418. package/src/themes/default/assets/static/IBMPlexSans_SemiCondensed-ExtraLightItalic.ttf +0 -0
  419. package/src/themes/default/assets/static/IBMPlexSans_SemiCondensed-Italic.ttf +0 -0
  420. package/src/themes/default/assets/static/IBMPlexSans_SemiCondensed-Light.ttf +0 -0
  421. package/src/themes/default/assets/static/IBMPlexSans_SemiCondensed-LightItalic.ttf +0 -0
  422. package/src/themes/default/assets/static/IBMPlexSans_SemiCondensed-Medium.ttf +0 -0
  423. package/src/themes/default/assets/static/IBMPlexSans_SemiCondensed-MediumItalic.ttf +0 -0
  424. package/src/themes/default/assets/static/IBMPlexSans_SemiCondensed-Regular.ttf +0 -0
  425. package/src/themes/default/assets/static/IBMPlexSans_SemiCondensed-SemiBold.ttf +0 -0
  426. package/src/themes/default/assets/static/IBMPlexSans_SemiCondensed-SemiBoldItalic.ttf +0 -0
  427. package/src/themes/default/assets/static/IBMPlexSans_SemiCondensed-Thin.ttf +0 -0
  428. package/src/themes/default/assets/static/IBMPlexSans_SemiCondensed-ThinItalic.ttf +0 -0
  429. package/src/themes/default/components.css +81 -0
  430. package/src/themes/default/sirenodeck.json +170 -0
  431. package/src/themes/index.ts +14 -0
  432. package/src/themes/light/assets/IBMPlexSans-Italic-VariableFont_wdth,wght.ttf +0 -0
  433. package/src/themes/light/assets/IBMPlexSans-VariableFont_wdth,wght.ttf +0 -0
  434. package/src/themes/light/assets/IBM_Plex_Mono/IBMPlexMono-Bold.ttf +0 -0
  435. package/src/themes/light/assets/IBM_Plex_Mono/IBMPlexMono-Medium.ttf +0 -0
  436. package/src/themes/light/assets/IBM_Plex_Mono/IBMPlexMono-Regular.ttf +0 -0
  437. package/src/themes/light/assets/IBM_Plex_Mono/OFL.txt +93 -0
  438. package/src/themes/light/assets/OFL.txt +93 -0
  439. package/src/themes/light/assets/README.txt +106 -0
  440. package/src/themes/light/assets/static/IBMPlexSans-Light.ttf +0 -0
  441. package/src/themes/light/assets/static/IBMPlexSans-LightItalic.ttf +0 -0
  442. package/src/themes/light/assets/static/IBMPlexSans-Thin.ttf +0 -0
  443. package/src/themes/light/components.css +70 -0
  444. package/src/themes/light/sirenodeck.json +170 -0
  445. package/src/themes/loader.ts +343 -0
  446. package/src/themes/manifest.ts +164 -0
  447. package/src/themes/use-resolved-theme.tsx +32 -0
  448. package/src/ui/ButtonFrame.tsx +230 -0
  449. package/src/ui/contexts/AssetCacheContext.tsx +43 -0
  450. package/src/ui/index.ts +20 -0
  451. package/src/ui/primitives/Chip.tsx +53 -0
  452. package/src/ui/primitives/Icon.tsx +209 -0
  453. package/src/ui/primitives/Label.tsx +82 -0
  454. package/src/ui/primitives/ProgressBar.tsx +38 -0
  455. package/src/ui/primitives/TapIndicator.tsx +67 -0
  456. package/src/ui/primitives/Text.tsx +810 -0
  457. package/src/ui/primitives/index.ts +6 -0
  458. package/src/ui/surfaces/BarsSurface.tsx +211 -0
  459. package/src/ui/surfaces/IconLabelProgressSurface.tsx +133 -0
  460. package/src/ui/surfaces/IconLabelSurface.tsx +59 -0
  461. package/src/ui/surfaces/LabelValueListSurface.tsx +202 -0
  462. package/src/ui/surfaces/PaginatedSurface.tsx +164 -0
  463. package/src/ui/surfaces/SplitActionSurface.tsx +98 -0
  464. package/src/ui/surfaces/TemporaryErrorSurface.tsx +68 -0
  465. package/src/ui/surfaces/ValueChart.tsx +218 -0
  466. package/src/ui/surfaces/index.ts +32 -0
  467. package/src/ui/surfaces/paginated-schema.ts +22 -0
  468. package/src/ui/theme-presentation-builder.ts +184 -0
  469. package/src/ui/theme-presentation.tsx +146 -0
  470. package/src/ui/utils/cn.ts +12 -0
  471. package/src/util/cache-paths.ts +38 -0
  472. package/src/util/config-diff.ts +16 -0
  473. package/src/util/daemon-control.ts +85 -0
  474. package/src/util/daemon.ts +575 -0
  475. package/src/util/device-config.ts +56 -0
  476. package/src/util/errors.ts +13 -0
  477. package/src/util/log-reader.ts +114 -0
  478. package/src/util/log-tail.ts +103 -0
  479. package/src/util/logger.ts +417 -0
  480. package/src/util/process-title.ts +79 -0
  481. package/src/util/single-instance.ts +202 -0
  482. package/src/version.ts +10 -0
  483. package/src/vite/index.ts +6 -0
  484. package/src/vite/virtual-modules.ts +631 -0
  485. package/src/vite-env.d.ts +1 -0
@@ -0,0 +1,1061 @@
1
+ import { existsSync, readFileSync, readdirSync, readlinkSync } from "node:fs"
2
+ import { execFileSync } from "node:child_process"
3
+ import { dirname, join, resolve as resolvePath } from "node:path"
4
+ import { fileURLToPath } from "node:url"
5
+
6
+ import { confirm, isCancel, select } from "@/cli/prompt"
7
+ import type pino from "pino"
8
+
9
+ import {
10
+ cmdlineMentionsCliRoot,
11
+ isOrphan,
12
+ isOurDaemon,
13
+ isOurViteChild,
14
+ readProcCmdline,
15
+ } from "./port-identity"
16
+
17
+ import { resolveConfigPath as resolveRunConfigPath } from "./pipeline/helpers"
18
+ import type { ResolveConfigPathResult } from "./pipeline/helpers"
19
+ import {
20
+ acquireStartLock,
21
+ generateToken,
22
+ isRunning,
23
+ pruneStaleChildren,
24
+ readConfigPath,
25
+ readPid,
26
+ removeChildrenFile,
27
+ removePidFile,
28
+ removeRuntimeStateFile,
29
+ removeStartLock,
30
+ resolveDaemonPaths,
31
+ terminateChildren,
32
+ writeChildren,
33
+ writeConfigPath,
34
+ writeFlags,
35
+ writePid,
36
+ type RuntimeFlags,
37
+ } from "@/util/daemon"
38
+ import {
39
+ acquireInstanceLock,
40
+ instanceLockPath,
41
+ isSocketLive,
42
+ } from "@/util/single-instance"
43
+
44
+ import { startHttpServer, type RunningHttpServer } from "../http-server"
45
+ import { removeDaemonControl, startDaemonControl } from "@/util/daemon-control"
46
+ import { tailLogs } from "@/util/log-tail"
47
+ import {
48
+ builtinDir,
49
+ collectBuiltinAddonRegistry,
50
+ type ScannedAddon,
51
+ } from "./addon-registry"
52
+ import {
53
+ ensureInstalled,
54
+ invokeManager,
55
+ isUnitInstalled,
56
+ } from "./service-manager"
57
+ import { isUnderServiceManager, spawnDetached } from "./spawn-daemon"
58
+ import { runPipeline, type RunOptions, type SignalProvider } from "./run"
59
+ import { preflight } from "./pipeline/preflight"
60
+ import {
61
+ type SystemReport,
62
+ probeAllCached,
63
+ summarizeReport,
64
+ } from "@/system/setup-wizard"
65
+ import { systemRequirements } from "./system-requirements"
66
+ import { buildStandardProbeDeps } from "@/cli/probe-deps"
67
+ import { onboardCodingAgents } from "@/cli/coding-agents-onboarding"
68
+ import { resolveUserPath } from "@/cli/cwd"
69
+
70
+ export interface StartOptions {
71
+ readonly config?: string
72
+ readonly port?: number
73
+ readonly emulator?: boolean
74
+ readonly remote?: boolean
75
+ readonly deviceModel?: string
76
+ readonly frontendUrl?: string
77
+ readonly intervalMs?: number
78
+ readonly xdgConfigHome?: string
79
+ readonly homeDir?: string
80
+ readonly httpPort?: number
81
+ readonly logs?: boolean
82
+ readonly system?: boolean
83
+ readonly noAutoOpen?: boolean
84
+ readonly signals?: SignalProvider
85
+ readonly logger: pino.Logger
86
+ }
87
+
88
+ const toRunOptions = (
89
+ options: StartOptions,
90
+ onChildren: (pids: ReadonlyArray<number>) => void,
91
+ onAddonsUpdate?: (addons: ReadonlyArray<ScannedAddon>) => void,
92
+ ): RunOptions => ({
93
+ logger: options.logger,
94
+ config: options.config,
95
+ port: options.port,
96
+ emulator: options.emulator,
97
+ remote: options.remote,
98
+ deviceModel: options.deviceModel,
99
+ frontendUrl: options.frontendUrl,
100
+ intervalMs: options.intervalMs,
101
+ xdgConfigHome: options.xdgConfigHome,
102
+ homeDir: options.homeDir,
103
+ signals: options.signals,
104
+ noAutoOpen: options.noAutoOpen === true,
105
+ onChildren,
106
+ onAddonsUpdate,
107
+ })
108
+
109
+ const resolveFrontendDist = (): string => {
110
+ const here = dirname(fileURLToPath(import.meta.url))
111
+ return resolvePath(here, "../../../frontend/dist")
112
+ }
113
+
114
+ const isDevInvocation = (): boolean => {
115
+ return (process.argv[1] ?? "").endsWith(".ts")
116
+ }
117
+
118
+ // ponytail: kill every process currently listening on the daemon's expected
119
+ // ports — but ONLY if the identity gate (cmdline + orphan check) says it's
120
+ // ours. Otherwise the port collision stays and the new daemon's preflight
121
+ // surfaces a clear EADDRINUSE, which is the correct signal to the user.
122
+
123
+ // ponytail: each backend distinguishes "tool ran, found nothing" (the common
124
+ // preflight case when no daemon is up) from "tool unavailable / crashed"
125
+ // (rare — binary missing on PATH, permission denied, etc.). Only the latter
126
+ // surfaces as a WARN; empty results stay silent so a clean `p dev start`
127
+ // doesn't flood with 4 false-positive "all backends failed" lines.
128
+ export type PortScanResult =
129
+ | { readonly ok: true; readonly pids: ReadonlyArray<number> }
130
+ | { readonly ok: false; readonly reason: string }
131
+
132
+ const trySs = (port: number): PortScanResult => {
133
+ const ssLineRegex = (p: number): RegExp =>
134
+ new RegExp(`:${p}\\b[\\s\\S]*?users:\\([^)]*?pid=(\\d+)[,\\)]`)
135
+ try {
136
+ const out = execFileSync("ss", ["-ltnp"], {
137
+ encoding: "utf8",
138
+ stdio: ["ignore", "pipe", "ignore"],
139
+ })
140
+ const pids: number[] = []
141
+ for (const line of out.split("\n")) {
142
+ const m = line.match(ssLineRegex(port))
143
+ if (m && m[1]) pids.push(Number.parseInt(m[1], 10))
144
+ }
145
+ return { ok: true, pids }
146
+ } catch (err) {
147
+ return { ok: false, reason: describeExecFailure(err, "ss") }
148
+ }
149
+ }
150
+
151
+ const parseLsofPids = (out: string): ReadonlyArray<number> =>
152
+ out
153
+ .split("\n")
154
+ .map((line) => Number.parseInt(line.trim(), 10))
155
+ .filter((n) => Number.isFinite(n) && n > 0)
156
+
157
+ const tryLsof = (port: number): PortScanResult => {
158
+ try {
159
+ const out = execFileSync("lsof", [`-iTCP:${port}`, "-sTCP:LISTEN", "-t"], {
160
+ encoding: "utf8",
161
+ stdio: ["ignore", "pipe", "ignore"],
162
+ })
163
+ return { ok: true, pids: parseLsofPids(out) }
164
+ } catch (err) {
165
+ // ponytail: lsof exits 1 with EMPTY output when simply nothing matches the
166
+ // filter — its documented "no results" status, not a failure. execFileSync
167
+ // throws on any non-zero exit, so the common "port is free" case was being
168
+ // recorded as "backend unavailable". On macOS that was every call (ss and
169
+ // /proc/net/tcp don't exist there), so detectPortPids concluded it had no
170
+ // working backend at all, warned on every start, and orphan cleanup went
171
+ // blind — leaving stale vites holding :5180/:52937 after an unclean exit.
172
+ // Treat exit 1 with no stdout as a clean empty result; a missing binary
173
+ // (ENOENT) or any other status stays a real failure.
174
+ const e = err as NodeJS.ErrnoException & {
175
+ status?: number | null
176
+ stdout?: string | Buffer
177
+ }
178
+ const stdout =
179
+ typeof e.stdout === "string" ? e.stdout : (e.stdout?.toString() ?? "")
180
+ if (e.code !== "ENOENT" && e.status === 1) {
181
+ return { ok: true, pids: parseLsofPids(stdout) }
182
+ }
183
+ return { ok: false, reason: describeExecFailure(err, "lsof") }
184
+ }
185
+ }
186
+
187
+ const tryProcNet = (port: number): PortScanResult => {
188
+ if (process.platform !== "linux") {
189
+ return { ok: false, reason: "/proc/net/tcp: not on linux" }
190
+ }
191
+ try {
192
+ const hex = port.toString(16).toUpperCase().padStart(4, "0")
193
+ const out = readFileSync("/proc/net/tcp", "utf8")
194
+ const pids = new Set<number>()
195
+ const inodes: string[] = []
196
+ for (const line of out.split("\n").slice(1)) {
197
+ const cols = line.trim().split(/\s+/)
198
+ if (cols.length < 10) continue
199
+ const localAddr = cols[1] ?? ""
200
+ const [, localPortHex] = localAddr.split(":")
201
+ if (localPortHex !== hex) continue
202
+ const inode = cols[9]
203
+ if (inode) inodes.push(inode)
204
+ }
205
+ const procDirs = readdirSyncSync("/proc")
206
+ for (const pidStr of procDirs) {
207
+ if (!/^\d+$/.test(pidStr)) continue
208
+ try {
209
+ const fdList = readdirSyncSync(`/proc/${pidStr}/fd`)
210
+ for (const fd of fdList) {
211
+ try {
212
+ const link = readlinkSyncFn(`/proc/${pidStr}/fd/${fd}`)
213
+ for (const inode of inodes) {
214
+ if (link === `socket:[${inode}]`) {
215
+ pids.add(Number.parseInt(pidStr, 10))
216
+ }
217
+ }
218
+ } catch {
219
+ // ignore
220
+ }
221
+ }
222
+ } catch {
223
+ // ignore
224
+ }
225
+ }
226
+ return { ok: true, pids: Array.from(pids) }
227
+ } catch (err) {
228
+ const e = err as NodeJS.ErrnoException
229
+ return {
230
+ ok: false,
231
+ reason: `/proc/net/tcp: ${e.code ?? e.message}`,
232
+ }
233
+ }
234
+ }
235
+
236
+ const describeExecFailure = (err: unknown, tool: string): string => {
237
+ const e = err as NodeJS.ErrnoException
238
+ if (e.code === "ENOENT") return `${tool}: binary not found on PATH`
239
+ if (typeof e.code === "string") return `${tool}: ${e.code}`
240
+ return `${tool}: ${e.message}`
241
+ }
242
+
243
+ const readdirSyncSync = (dir: string): string[] => {
244
+ try {
245
+ return readdirSync(dir)
246
+ } catch {
247
+ return []
248
+ }
249
+ }
250
+
251
+ const readlinkSyncFn = (path: string): string | null => {
252
+ try {
253
+ return readlinkSync(path)
254
+ } catch {
255
+ return null
256
+ }
257
+ }
258
+
259
+ export const detectPortPids = (
260
+ port: number,
261
+ logger: pino.Logger,
262
+ ): ReadonlyArray<number> => {
263
+ const ss = trySs(port)
264
+ if (ss.ok && ss.pids.length > 0) return ss.pids
265
+ const lsof = tryLsof(port)
266
+ if (lsof.ok && lsof.pids.length > 0) {
267
+ logger.debug({ port }, "port detection: ss failed, used lsof")
268
+ return lsof.pids
269
+ }
270
+ const procNet = tryProcNet(port)
271
+ if (procNet.ok && procNet.pids.length > 0) {
272
+ logger.debug({ port }, "port detection: ss/lsof failed, used /proc/net/tcp")
273
+ return procNet.pids
274
+ }
275
+ // ponytail: only WARN when EVERY backend is unavailable. "nothing listening"
276
+ // (the common preflight case) is a clean empty result, not a failure.
277
+ // On macOS /proc/net/tcp is absent by design — ss+lsof are still consulted
278
+ // and a clean empty from both means "no listener", not "backend down".
279
+ if (ss.ok || lsof.ok || procNet.ok) return []
280
+ logger.warn(
281
+ {
282
+ port,
283
+ ss: ss.ok ? undefined : ss.reason,
284
+ lsof: lsof.ok ? undefined : lsof.reason,
285
+ procNet: procNet.ok ? undefined : procNet.reason,
286
+ },
287
+ "port detection: no port-detection backends available — orphan cleanup may miss stale vites",
288
+ )
289
+ return []
290
+ }
291
+
292
+ // ponytail: kill a previous-session daemon that's still bound to the WS port
293
+ // (52937). The pid file is the primary source of truth for "is there a
294
+ // daemon running" but it's stale-prone — a SIGKILL'd daemon leaves its
295
+ // pid file behind, while a daemon that crashed during preflight never
296
+ // wrote one. The Linux process title (`/proc/<pid>/comm`) is set by
297
+ // main.ts:23 to `sirenodeck:dm` and survives across pid-file loss. Cross-
298
+ // check the cmdline fingerprint (isOurDaemon) so an unrelated binary
299
+ // named "sirenodeck:dm" by its supervisor doesn't get reaped.
300
+ //
301
+ // We only run this for the WS port — that's the one the new daemon
302
+ // binds FIRST, and the one whose collision most reliably breaks startup.
303
+ // HTTP and frontend ports are bound after; if the daemon reaps itself
304
+ // cleanly the vites go with it (they're parented to the daemon, get
305
+ // reparented to init on exit, and killPortListeners picks them up).
306
+ export const reapStaleDaemon = async (
307
+ wsPort: number,
308
+ logger: pino.Logger,
309
+ ): Promise<void> => {
310
+ const pids = detectPortPids(wsPort, logger)
311
+ const daemonPids = pids.filter(isOurDaemon)
312
+ if (daemonPids.length === 0) return
313
+ for (const pid of daemonPids) {
314
+ try {
315
+ process.kill(pid, "SIGTERM")
316
+ logger.warn(
317
+ { pid, port: wsPort },
318
+ "start: SIGTERM to stale daemon holding WS port (self-heal)",
319
+ )
320
+ } catch (err) {
321
+ logger.warn(
322
+ { err, pid },
323
+ "start: SIGTERM to stale daemon failed (may already be dead)",
324
+ )
325
+ }
326
+ }
327
+ const deadline = Date.now() + 3_000
328
+ while (Date.now() < deadline) {
329
+ await new Promise((r) => setTimeout(r, 100))
330
+ if (detectPortPids(wsPort, logger).length === 0) return
331
+ }
332
+ // SIGKILL stragglers (only our own daemons — anything else is left
333
+ // alone with a warning so the user can intervene manually).
334
+ for (const pid of detectPortPids(wsPort, logger)) {
335
+ if (isOurDaemon(pid)) {
336
+ try {
337
+ process.kill(pid, "SIGKILL")
338
+ logger.warn(
339
+ { pid, port: wsPort },
340
+ "start: SIGKILL to stale daemon that ignored SIGTERM",
341
+ )
342
+ } catch {
343
+ // already dead
344
+ }
345
+ } else {
346
+ logger.warn(
347
+ { pid, port: wsPort },
348
+ "start: WS port held by a non-sirenodeck daemon — leaving it alone",
349
+ )
350
+ }
351
+ }
352
+ }
353
+
354
+ const killPortListeners = async (
355
+ ports: ReadonlyArray<number>,
356
+ logger: pino.Logger,
357
+ ): Promise<void> => {
358
+ const daemonPid = readPid()
359
+ for (const port of ports) {
360
+ const pids = new Set(detectPortPids(port, logger))
361
+ for (const pid of pids) {
362
+ if (!isOurViteChild(pid)) {
363
+ logger.warn(
364
+ { pid, port },
365
+ "start: port in use by a process that is NOT a sirenodeck child — leaving it alone",
366
+ )
367
+ continue
368
+ }
369
+ if (!cmdlineMentionsCliRoot(readProcCmdline(pid) ?? "")) {
370
+ logger.warn(
371
+ { pid, port },
372
+ "start: port in use by a vite, but not under packages/cli/ — leaving it alone",
373
+ )
374
+ continue
375
+ }
376
+ if (!isOrphan(pid, daemonPid)) {
377
+ logger.warn(
378
+ { pid, port },
379
+ "start: port in use by a live (non-orphan) sirenodeck vite — leaving it alone",
380
+ )
381
+ continue
382
+ }
383
+ try {
384
+ process.kill(pid, "SIGTERM")
385
+ logger.warn(
386
+ { pid, port },
387
+ "start: killed orphan sirenodeck vite bound to daemon port",
388
+ )
389
+ } catch {
390
+ // already dead
391
+ }
392
+ }
393
+ }
394
+ // brief grace so SIGTERM takes effect before the new daemon binds
395
+ await new Promise((r) => setTimeout(r, 500))
396
+ }
397
+
398
+ const promptConflict = async (
399
+ pid: number | null,
400
+ ): Promise<"restart" | "cancel"> => {
401
+ const who = pid === null ? "" : ` with pid ${pid}`
402
+ if (!process.stdin.isTTY) {
403
+ throw new Error(
404
+ `Daemon already running${who} (non-interactive: not stopping)`,
405
+ )
406
+ }
407
+ const answer = await select<"restart" | "cancel">({
408
+ message: `Daemon already running${who}.`,
409
+ options: [
410
+ {
411
+ value: "restart",
412
+ label: "restart",
413
+ hint: "Stop the existing daemon and start a new one",
414
+ },
415
+ {
416
+ value: "cancel",
417
+ label: "cancel",
418
+ hint: "Exit without changes",
419
+ },
420
+ ],
421
+ })
422
+ if (isCancel(answer)) return "cancel"
423
+ return answer
424
+ }
425
+
426
+ const stopExisting = async (
427
+ pid: number,
428
+ logger: pino.Logger,
429
+ ): Promise<void> => {
430
+ if (!isRunning(pid)) {
431
+ logger.warn({ pid }, "existing pid file is stale, removing")
432
+ removePidFile()
433
+ await terminateChildren({ logger, timeoutMs: 2_000 })
434
+ removeChildrenFile()
435
+ removeStartLock()
436
+ return
437
+ }
438
+ logger.info({ pid }, "stopping existing daemon")
439
+ try {
440
+ process.kill(pid, "SIGTERM")
441
+ } catch (err) {
442
+ logger.warn({ err, pid }, "failed to send SIGTERM to existing daemon")
443
+ }
444
+ const deadline = Date.now() + 5_000
445
+ while (Date.now() < deadline && isRunning(pid)) {
446
+ await new Promise((r) => setTimeout(r, 100))
447
+ }
448
+ if (isRunning(pid)) {
449
+ logger.warn({ pid }, "existing daemon did not exit in 5s, sending SIGKILL")
450
+ try {
451
+ process.kill(pid, "SIGKILL")
452
+ } catch (err) {
453
+ logger.warn({ err, pid }, "failed to send SIGKILL to existing daemon")
454
+ }
455
+ }
456
+ await terminateChildren({ logger, timeoutMs: 2_000 })
457
+ removePidFile()
458
+ removeDaemonControl(resolveDaemonPaths())
459
+ removeRuntimeStateFile()
460
+ removeChildrenFile()
461
+ removeStartLock()
462
+ }
463
+
464
+ const resolveConfigPath = (options: StartOptions): ResolveConfigPathResult => {
465
+ // ponytail: an explicit --config always wins. The cache below exists so
466
+ // `restart` / in-place edits keep the same config the session launched
467
+ // with, but a user (or tooling) that passes --config clearly wants THAT
468
+ // file — honoring the cache over an explicit arg silently swapped in the
469
+ // user's ~/.config/sirenodeck/config.yml instead (the bug that made
470
+ // --config appear to "do nothing").
471
+ if (options.config !== undefined) {
472
+ const explicit = resolveUserPath(options.config)
473
+ if (!existsSync(explicit)) {
474
+ throw new Error(
475
+ `Config file not found: ${explicit}\n` +
476
+ ` Fix: pass a valid --config path.`,
477
+ )
478
+ }
479
+ return { path: explicit, source: "cli" }
480
+ }
481
+ // ponytail: the daemon honors the cached pointer first so the running
482
+ // session keeps editing the same config it was launched with. When the
483
+ // cached path is gone (e.g. worktree removed) we fall through to the
484
+ // shared precedence: cli arg → XDG → run folder.
485
+ const cached = readConfigPath()
486
+ const cachedUsable = cached !== null && existsSync(cached) ? cached : null
487
+ if (cachedUsable !== null) {
488
+ return { path: cachedUsable, source: "cli" }
489
+ }
490
+ return resolveRunConfigPath({
491
+ ...(options.xdgConfigHome !== undefined
492
+ ? { xdgConfigHome: options.xdgConfigHome }
493
+ : {}),
494
+ ...(options.homeDir !== undefined ? { homeDir: options.homeDir } : {}),
495
+ logger: options.logger,
496
+ })
497
+ }
498
+
499
+ const buildRuntimeFlags = (options: StartOptions): RuntimeFlags => ({
500
+ emulator: options.emulator === true || options.remote === true,
501
+ remote: options.remote,
502
+ httpPort: options.httpPort ?? 3939,
503
+ ...(options.config !== undefined ? { config: options.config } : {}),
504
+ ...(options.deviceModel !== undefined
505
+ ? { deviceModel: options.deviceModel }
506
+ : {}),
507
+ ...(options.port !== undefined ? { port: options.port } : {}),
508
+ ...(options.noAutoOpen !== undefined
509
+ ? { noAutoOpen: options.noAutoOpen }
510
+ : {}),
511
+ })
512
+
513
+ const runInProcess = async (options: StartOptions): Promise<void> => {
514
+ const { logger } = options
515
+
516
+ // ponytail: the single-instance lock comes FIRST — before the start lock,
517
+ // before preflight, and above all before killPortListeners. Everything below
518
+ // this point assumes the ports, the children and the pid file belong to us;
519
+ // a daemon that does not hold the lock has no business cleaning any of them
520
+ // up, because they belong to the daemon that does. Holding the lock for the
521
+ // whole process lifetime is what makes a second daemon impossible rather
522
+ // than merely unlikely: the kernel, not a file we wrote, is the referee.
523
+ const instance = await acquireInstanceLock(resolveDaemonPaths(), logger)
524
+ if (instance.kind === "busy") {
525
+ throw new Error(
526
+ instance.holderPid === null
527
+ ? "another sirenodeck daemon is already running"
528
+ : `another sirenodeck daemon is already running (pid ${instance.holderPid}) — stop it with \`sireno stop\`, or restart it with \`sireno restart\``,
529
+ )
530
+ }
531
+
532
+ const startLock = acquireStartLock()
533
+ if (startLock === null) {
534
+ instance.lock.release()
535
+ throw new Error("another start is already in progress")
536
+ }
537
+
538
+ const releaseAll = (): void => {
539
+ startLock.release()
540
+ instance.lock.release()
541
+ }
542
+
543
+ // ponytail: the start lock is only released by the post-runPipeline
544
+ // .finally on a clean pipeline exit. But preflight/setup code below
545
+ // (config read, addon scan, http server boot) can throw EADDRINUSE or
546
+ // similar BEFORE runPipeline ever starts, leaving the lock orphaned
547
+ // for 60s — which is exactly the user's "another start is already in
548
+ // progress" error after a previous start crashed. Wrap the setup in a
549
+ // try/catch that releases on throw, and also call release() in the
550
+ // existing .finally so the runtime-exit path stays covered.
551
+ try {
552
+ await runInProcessSetup(options, logger, releaseAll)
553
+ } catch (err) {
554
+ releaseAll()
555
+ throw err
556
+ }
557
+ }
558
+
559
+ const runInProcessSetup = async (
560
+ options: StartOptions,
561
+ logger: pino.Logger,
562
+ releaseLock: () => void,
563
+ ): Promise<void> => {
564
+ const resolved = resolveConfigPath(options)
565
+ const configPath = resolved.path
566
+ options.logger.info(
567
+ { configPath, source: resolved.source },
568
+ `start: using config ${configPath}`,
569
+ )
570
+ // ponytail: an explicit `start` always wins — never replay the persisted
571
+ // flags file here. The file exists so `restart` can reuse the last mode;
572
+ // letting it also override a fresh invocation made one past `--emulator`
573
+ // run force every later plain `p dev start` into emulator mode.
574
+ const runtimeFlags = buildRuntimeFlags(options)
575
+ writeConfigPath(configPath)
576
+ writeFlags(runtimeFlags)
577
+
578
+ const builtinAddons = (await collectBuiltinAddonRegistry()).scanned
579
+ const allScanned: ScannedAddon[] = [...builtinAddons]
580
+
581
+ const runOptions = toRunOptions(
582
+ {
583
+ ...options,
584
+ config: configPath,
585
+ port: runtimeFlags.port,
586
+ emulator: runtimeFlags.emulator,
587
+ remote: runtimeFlags.remote,
588
+ deviceModel: runtimeFlags.deviceModel,
589
+ httpPort: runtimeFlags.httpPort,
590
+ },
591
+ (pids) => {
592
+ writeChildren({ pids: [...pids] })
593
+ logger.info({ pids }, "daemon: tracked children")
594
+ },
595
+ (addons) => {
596
+ allScanned.length = 0
597
+ allScanned.push(...addons)
598
+ },
599
+ )
600
+
601
+ await preflight(runOptions)
602
+
603
+ writePid(process.pid)
604
+ const token = generateToken()
605
+ const daemonPaths = resolveDaemonPaths()
606
+ const control = await startDaemonControl(token, daemonPaths, logger)
607
+ process.env["SIRENO_TOKEN"] = token
608
+ logger.info({ tokenLen: token.length }, "daemon: pid + token written")
609
+
610
+ let httpServer: RunningHttpServer | null = null
611
+ const distDir = resolveFrontendDist()
612
+ const indexPath = join(distDir, "index.html")
613
+ if (existsSync(indexPath)) {
614
+ try {
615
+ httpServer = await startHttpServer({
616
+ port: runtimeFlags.httpPort,
617
+ distDir,
618
+ getToken: () => token,
619
+ logger,
620
+ getConfigContent: () => {
621
+ try {
622
+ return readFileSync(configPath, "utf8")
623
+ } catch {
624
+ return null
625
+ }
626
+ },
627
+ getConfigPath: () => configPath,
628
+ getAddons: () =>
629
+ allScanned.map((s) => ({
630
+ name: s.name,
631
+ path: s.path ?? join(builtinDir, s.name),
632
+ internal: s.internal === true,
633
+ source: s.source,
634
+ buttonTypes: Object.entries(s.buttonTypes).map(([type, info]) => ({
635
+ type,
636
+ internal: info.internal,
637
+ })),
638
+ defaultButton: null,
639
+ decks: s.decks.map((d) => ({
640
+ id: d.id,
641
+ isOverlay: false,
642
+ paginated: d.paginated,
643
+ buttons: d.buttons,
644
+ internal: d.internal,
645
+ })),
646
+ })),
647
+ })
648
+ } catch (err) {
649
+ logger.warn(
650
+ { err },
651
+ "daemon: failed to start http server, continuing without it",
652
+ )
653
+ httpServer = null
654
+ }
655
+ } else if (!isDevInvocation()) {
656
+ logger.warn(
657
+ { distDir },
658
+ "daemon: frontend dist not found, skipping http server (run `pnpm build` first for the prod HTTP server)",
659
+ )
660
+ }
661
+
662
+ void runPipeline(runOptions)
663
+ .catch((err: unknown) => {
664
+ logger.error({ err }, "background run failed")
665
+ })
666
+ .finally(async () => {
667
+ releaseLock()
668
+ if (httpServer !== null) {
669
+ try {
670
+ await httpServer.stop()
671
+ logger.info("daemon: http server stopped")
672
+ } catch (err) {
673
+ logger.warn({ err }, "daemon: http server stop failed")
674
+ }
675
+ }
676
+ await new Promise<void>((resolve) => control.close(() => resolve()))
677
+ removeDaemonControl(daemonPaths)
678
+ // ponytail: kill tracked children (frontend vite, config UI vite, ...)
679
+ // BEFORE removing the children file. Without this, when the daemon
680
+ // exits cleanly the children outlive it as init-adopted orphans, keep
681
+ // their ports, and the next `start` invocation fails with EADDRINUSE
682
+ // — exactly the user's "the frontend port still in use by children"
683
+ // symptom. TerminateChildren sends SIGTERM (then SIGKILL after 2s)
684
+ // and is awaited so the operation completes before process.exit.
685
+ await terminateChildren({ logger, timeoutMs: 3_000 })
686
+ removePidFile()
687
+ removeDaemonControl(resolveDaemonPaths())
688
+ removeRuntimeStateFile()
689
+ removeChildrenFile()
690
+ removeStartLock()
691
+ logger.info("daemon: shutdown complete")
692
+ // ponytail: explicit exit — without this, lingering handles in the
693
+ // emulator's active-app polling or lingering ws-bridge connections keep
694
+ // the event loop alive and the daemon process never terminates after
695
+ // a startup failure. The systemd / fork-off flows don't go through
696
+ // runInProcess, so this only affects the in-process daemon path.
697
+ process.exit(process.exitCode ?? 0)
698
+ })
699
+ }
700
+
701
+ // ponytail: dev-mode `start`. Production goes through systemd/launchd — the
702
+ // OS owns the daemon lifecycle and the wrapper exits as soon as `start` forks
703
+ // the service manager. Dev's `pnpm dev start` mirrors that shape: spawn the
704
+ // daemon via `spawnDetached`, write the pid file, return to the cli. The
705
+ // wrapper exits cleanly so `pnpm dev status | stop | restart | reload | logs`
706
+ // from any other shell work against the same pid-file contract production
707
+ // uses. No auto-restart: the daemon stays dead on crash, the operator runs
708
+ // `pnpm dev restart` to recover — same as production (systemd eventually
709
+ // gives up too).
710
+ const resolveCliRoot = (): string => {
711
+ const here = dirname(fileURLToPath(import.meta.url))
712
+ return resolvePath(here, "..", "..", "..")
713
+ }
714
+
715
+ const buildDetachedArgs = (flags: RuntimeFlags): string[] => {
716
+ const args: string[] = ["start"]
717
+ if (flags.emulator) args.push("--emulator")
718
+ if (flags.remote) args.push("--remote")
719
+ if (flags.port !== undefined) args.push("--port", String(flags.port))
720
+ if (flags.config !== undefined) args.push("--config", flags.config)
721
+ if (flags.noAutoOpen === true) args.push("--no-autoopen")
722
+ if (flags.deviceModel !== undefined) {
723
+ args.push("--device-model", flags.deviceModel)
724
+ }
725
+ if (flags.httpPort !== undefined && flags.httpPort !== 3939) {
726
+ args.push("--http-port", String(flags.httpPort))
727
+ }
728
+ return args
729
+ }
730
+
731
+ const startInBackground = async (options: StartOptions): Promise<void> => {
732
+ const { logger } = options
733
+ const resolved = resolveConfigPath(options)
734
+ const configPath = resolved.path
735
+ logger.info(
736
+ { configPath, source: resolved.source },
737
+ `start: using config ${configPath}`,
738
+ )
739
+ const runtimeFlags = buildRuntimeFlags(options)
740
+ writeConfigPath(configPath)
741
+ writeFlags(runtimeFlags)
742
+ pruneStaleChildren(undefined, logger)
743
+ await terminateChildren({ logger, timeoutMs: 2_000 })
744
+
745
+ // ponytail: in dev mode (pnpm dev), the daemon should run from
746
+ // SOURCE via the dev wrapper (bin/dev.js + tsx), not the bundled
747
+ // dist/main.mjs. The bundle strips runtime assets like the `default`
748
+ // and `light` themes at packages/cli/src/themes/ — the loader's
749
+ // filesystem scan can't find them inside dist/, so any `theme:`
750
+ // reference in config.yml resolves to "not a registered theme, not
751
+ // a path, and not a known npm package" and crashes with
752
+ // unhandledRejection. The dev wrapper runs the source path and
753
+ // keeps the themes on disk for the loader to discover.
754
+ const binPath = resolvePath(
755
+ resolveCliRoot(),
756
+ "bin",
757
+ isDevInvocation() ? "dev.js" : "sirenodeck.js",
758
+ )
759
+ const args = buildDetachedArgs(runtimeFlags)
760
+ const { pid } = spawnDetached({
761
+ binPath,
762
+ args,
763
+ remote: options.remote === true,
764
+ })
765
+ writePid(pid)
766
+ logger.info(
767
+ { pid, configPath, args },
768
+ "start: daemon spawned, returning to cli",
769
+ )
770
+ }
771
+
772
+ const startProduction = async (options: StartOptions): Promise<void> => {
773
+ const { logger } = options
774
+ const resolved = resolveConfigPath(options)
775
+ const configPath = resolved.path
776
+ logger.info(
777
+ { configPath, source: resolved.source },
778
+ `start: using config ${configPath}`,
779
+ )
780
+ const runtimeFlags = buildRuntimeFlags(options)
781
+ writeConfigPath(configPath)
782
+ writeFlags(runtimeFlags)
783
+
784
+ // ponytail: startup banner (printed by the command handler) replaces what
785
+ // used to be a spinner here. The handler's waitForDaemonReady prints the
786
+ // outro when the daemon is actually serving.
787
+ await ensureInstalled({
788
+ logger,
789
+ ...(options.system === true ? { system: true } : {}),
790
+ })
791
+ await invokeManager({ action: "restart", logger })
792
+
793
+ const paths = resolveDaemonPaths()
794
+ const deadline = Date.now() + 5_000
795
+ let pid: number | null = null
796
+ while (Date.now() < deadline) {
797
+ pid = readPid(paths)
798
+ if (pid !== null && isRunning(pid)) break
799
+ await new Promise((r) => setTimeout(r, 100))
800
+ }
801
+ logger.info(
802
+ { childPid: pid, configPath },
803
+ pid !== null
804
+ ? "start: daemon started via service manager"
805
+ : "start: daemon started (pid not yet visible)",
806
+ )
807
+
808
+ if (options.logs === true && process.exitCode !== 1) {
809
+ const logPath = `${paths.runtimeDir}/service.log`
810
+ await tailLogs({ logPath, follow: true, lines: 50 })
811
+ }
812
+ }
813
+
814
+ const probeSystemForFirstRun = async (
815
+ options: StartOptions,
816
+ ): Promise<{
817
+ report: SystemReport
818
+ summary: ReturnType<typeof summarizeReport>
819
+ } | null> => {
820
+ const home = options.homeDir ?? process.env["HOME"] ?? ""
821
+ const xdgConfigHome =
822
+ options.xdgConfigHome ?? process.env["XDG_CONFIG_HOME"] ?? `${home}/.config`
823
+ const baseDeps = buildStandardProbeDeps()
824
+ // ponytail: probe the config this start will actually use. `--config` and the
825
+ // cached daemon pointer both count; only when neither resolves does the probe
826
+ // fall back to the XDG default (which is also the wizard's seed target).
827
+ let configPath: string | undefined
828
+ try {
829
+ configPath = resolveConfigPath(options).path
830
+ } catch {
831
+ configPath = undefined
832
+ }
833
+ try {
834
+ const report = await probeAllCached({
835
+ ...baseDeps,
836
+ homeDir: home !== "" ? home : baseDeps.homeDir,
837
+ xdgConfigHome,
838
+ ...(configPath !== undefined ? { configPath } : {}),
839
+ })
840
+ return { report, summary: summarizeReport(report) }
841
+ } catch {
842
+ return null
843
+ }
844
+ }
845
+
846
+ const runFirstRunCheckIfNeeded = async (
847
+ options: StartOptions,
848
+ logger: pino.Logger,
849
+ ): Promise<boolean> => {
850
+ const probed = await probeSystemForFirstRun(options)
851
+ if (probed === null) return true
852
+ const { summary } = probed
853
+ // Optional host integrations (clipboard and active-app detection) are not
854
+ // startup requirements. Hardware readiness is checked by preflight after
855
+ // this informational probe; config and udev are the only setup blockers.
856
+ const missing = summary.udevMissing || summary.configMissing
857
+ if (!missing) return true
858
+
859
+ if (process.env["SIRENO_SKIP_WIZARD"]) {
860
+ logger.warn(
861
+ { lines: summary.lines },
862
+ "start: some requirements still missing — run `sirenodeck system-requirements` to fix",
863
+ )
864
+ return false
865
+ }
866
+
867
+ if (!process.stdin.isTTY) {
868
+ logger.warn(
869
+ { lines: summary.lines },
870
+ "start: missing requirements and no TTY — run `sirenodeck system-requirements` interactively to fix",
871
+ )
872
+ process.exitCode = 1
873
+ return false
874
+ }
875
+
876
+ const shouldRunWizard = await confirm({
877
+ message:
878
+ "It looks like this is your first run (missing config or system capabilities). Run the setup wizard now?",
879
+ initialValue: true,
880
+ })
881
+ if (!shouldRunWizard) {
882
+ logger.warn(
883
+ { lines: summary.lines },
884
+ "start: requirements missing — continuing anyway. Run `sirenodeck system-requirements` later to fix.",
885
+ )
886
+ return true
887
+ }
888
+
889
+ await systemRequirements({
890
+ logger,
891
+ configPath: resolveConfigPath(options).path,
892
+ ...(options.homeDir !== undefined ? { homeDir: options.homeDir } : {}),
893
+ ...(options.xdgConfigHome !== undefined
894
+ ? { xdgConfigHome: options.xdgConfigHome }
895
+ : {}),
896
+ })
897
+
898
+ const reprobed = await probeSystemForFirstRun(options)
899
+ if (reprobed === null) return false
900
+ const stillMissing =
901
+ reprobed.summary.udevMissing || reprobed.summary.configMissing
902
+ if (stillMissing) {
903
+ logger.warn(
904
+ { lines: reprobed.summary.lines },
905
+ "start: some requirements still missing — exiting",
906
+ )
907
+ process.exitCode = 1
908
+ return false
909
+ }
910
+ return true
911
+ }
912
+
913
+ const start = async (options: StartOptions): Promise<void> => {
914
+ const { logger } = options
915
+
916
+ // ponytail: skip the first-run wizard in the daemon child. The forked
917
+ // daemon has `SIRENO_DAEMON_CHILD=1` and `stdio: ["ignore", "pipe", "pipe"]`
918
+ // — stdin is closed, so the wizard's `!process.stdin.isTTY` branch fires,
919
+ // sets `process.exitCode = 1`, and the daemon's `process.exit(exitCode)`
920
+ // then exits 1. The supervisor reads that as "unexpected exit" and respawns
921
+ // the child, which re-runs the wizard, exits 1, respawns — visible to the
922
+ // operator as the CLI restarting in a tight loop. The parent already ran
923
+ // the wizard before forking, so the child has no business re-running it.
924
+ if (isUnderServiceManager()) {
925
+ await runInProcess(options)
926
+ return
927
+ }
928
+
929
+ if (!(await runFirstRunCheckIfNeeded(options, logger))) return
930
+
931
+ await onboardCodingAgents(resolveConfigPath(options).path)
932
+
933
+ // ponytail: settle the "is a daemon already running?" question BEFORE any
934
+ // reaping. The old order reaped first and asked afterwards, which is only
935
+ // safe while the reap is accurate — and on macOS it was not: every identity
936
+ // check read /proc, found nothing, and classified the running daemon's own
937
+ // children as strangers. Asking first also means the answer can come from
938
+ // the instance lock, which is the one source that cannot be stale: a daemon
939
+ // holds it for exactly as long as it is alive.
940
+ const existing = readPid()
941
+ const lockHeld = await isSocketLive(instanceLockPath(resolveDaemonPaths()))
942
+ if (lockHeld || (existing !== null && isRunning(existing))) {
943
+ const action = await promptConflict(existing)
944
+ if (action === "cancel") {
945
+ logger.info("start cancelled")
946
+ return
947
+ }
948
+ // ponytail: a supervised daemon cannot be restarted by killing it. launchd
949
+ // with KeepAlive brings it back in about two seconds — measured — and the
950
+ // daemon we would spawn here loses the race for the instance lock and dies
951
+ // with "another sirenodeck daemon is already running". Before the lock
952
+ // existed the same race simply produced TWO daemons, which is how this
953
+ // machine ended up with one from 09:39 and another from 16:54 fighting
954
+ // over the same ports. The manager owns the process, so the manager is
955
+ // what restarts it; the config and flags go through the state files, the
956
+ // same way `startProduction` hands them over.
957
+ if (isUnitInstalled()) {
958
+ const resolvedRestart = resolveConfigPath(options)
959
+ writeConfigPath(resolvedRestart.path)
960
+ writeFlags(buildRuntimeFlags(options))
961
+ logger.info(
962
+ { configPath: resolvedRestart.path },
963
+ "start: daemon is service-managed — restarting through the service manager",
964
+ )
965
+ await invokeManager({ action: "restart", logger })
966
+ const deadline = Date.now() + 10_000
967
+ let restarted: number | null = null
968
+ while (Date.now() < deadline) {
969
+ await new Promise((r) => setTimeout(r, 200))
970
+ const pid = readPid()
971
+ if (pid !== null && pid !== existing && isRunning(pid)) {
972
+ restarted = pid
973
+ break
974
+ }
975
+ }
976
+ logger.info(
977
+ { pid: restarted },
978
+ restarted === null
979
+ ? "start: service manager restarted the daemon (pid not yet visible)"
980
+ : "start: daemon restarted by the service manager",
981
+ )
982
+ return
983
+ }
984
+ if (existing !== null) await stopExisting(existing, logger)
985
+ else
986
+ // The lock is held but nothing wrote a pid file. There is no process to
987
+ // signal by pid, so the port reap below is what clears it.
988
+ logger.warn(
989
+ "start: a daemon holds the instance lock but left no pid file — falling back to the port reap",
990
+ )
991
+ removePidFile()
992
+ removeDaemonControl(resolveDaemonPaths())
993
+ removeRuntimeStateFile()
994
+ removeChildrenFile()
995
+ removeStartLock()
996
+ } else {
997
+ // ponytail: previous daemon is dead (or never started cleanly) — kill any
998
+ // children it left behind (frontend vite on :5180, config UI vite on :52938,
999
+ // ws-bridge on :52937). Without this, the children's ports stay bound
1000
+ // by orphans and the new daemon's preflight can't bind them — user's
1001
+ // complaint: "the frontend port still in use by children". terminateChildren
1002
+ // sends SIGTERM, waits 2s, then SIGKILL on stragglers.
1003
+ if (existing !== null) {
1004
+ logger.warn(
1005
+ { pid: existing },
1006
+ "start: existing daemon pid is dead — cleaning up before starting",
1007
+ )
1008
+ }
1009
+ await terminateChildren({ logger, timeoutMs: 2_000 })
1010
+ pruneStaleChildren(undefined, logger)
1011
+ if (existing !== null) {
1012
+ removePidFile()
1013
+ removeDaemonControl(resolveDaemonPaths())
1014
+ removeRuntimeStateFile()
1015
+ removeChildrenFile()
1016
+ removeStartLock()
1017
+ }
1018
+ }
1019
+
1020
+ // ponytail: if a previous-session `sirenodeck:dm` daemon is still bound to
1021
+ // the WS port (52937), reap it before spawning. The pid file is stale-
1022
+ // prone (SIGKILL'd daemons leave it behind; crashed preflight daemons
1023
+ // never wrote one). Identity check uses /proc/<pid>/comm === "sirenodeck:dm"
1024
+ // cross-checked with the sirenodeck cmdline fingerprint, so unrelated
1025
+ // processes holding 52937 (a Discord voice call, an `nc -l`, the user's
1026
+ // own server) get left alone. The WS port is the load-bearing one —
1027
+ // EADDRINUSE there causes the daemon's runPipeline to fail before the
1028
+ // HTTP / frontend ports are even reached.
1029
+ await reapStaleDaemon(options.port ?? 52937, logger)
1030
+
1031
+ // ponytail: clear the daemon's expected ports before spawning. This is
1032
+ // the load-bearing fix for "the frontend port is in use by children":
1033
+ // even when the previous daemon's children file is gone (e.g. after a
1034
+ // SIGKILL that left orphans adopted by systemd), the port still binds
1035
+ // the orphan. ss -ltnp → SIGTERM every listener → 500ms grace → start.
1036
+ // Cost: bounded, no-op when the ports are free.
1037
+ await killPortListeners(
1038
+ options.emulator === true
1039
+ ? [5180, 3939, 52937, 52938]
1040
+ : [5180, 3939, 52937],
1041
+ logger,
1042
+ )
1043
+
1044
+ // ponytail: a SIGKILL'd or crashed daemon leaves runtime-state.json behind
1045
+ // (no cleanup ran) — and when there was no pid file at all, the branches
1046
+ // above never removed it. The next start's waitForRuntimeState would read
1047
+ // that stale state (old token, old remote flag, old URLs) before the new
1048
+ // daemon writes its own — surfacing e.g. the LAN/QR banner from a previous
1049
+ // `--remote` session during a plain `--emulator` start. Remove it
1050
+ // unconditionally: the daemon writes a fresh file when its pipeline is
1051
+ // ready, so the CLI only ever sees the state of the daemon it spawned.
1052
+ removeRuntimeStateFile()
1053
+
1054
+ if (isDevInvocation()) {
1055
+ await startInBackground(options)
1056
+ } else {
1057
+ await startProduction(options)
1058
+ }
1059
+ }
1060
+
1061
+ export default start