@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,2270 @@
1
+ import { exec } from "node:child_process"
2
+ import { createHash } from "node:crypto"
3
+ import {
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ realpathSync,
8
+ writeFileSync,
9
+ } from "node:fs"
10
+ import { homedir, networkInterfaces } from "node:os"
11
+ import {
12
+ basename,
13
+ dirname,
14
+ isAbsolute,
15
+ join,
16
+ resolve as resolvePath,
17
+ } from "node:path"
18
+
19
+ import type pino from "pino"
20
+
21
+ import { SIRENO_ADDON_API_VERSION } from "@/addon/api-types"
22
+ import { loadAddons } from "@/addon/loader"
23
+ import { AddonRegistry } from "@/addon/registry"
24
+ import { registerBuiltins } from "@/builtin-addons"
25
+ import { registerSystemStatusAddon } from "@/builtin-addons/system-status"
26
+ import { configChanged } from "@/util/config-diff"
27
+ import { loadConfig } from "@/config/loader"
28
+ import { resolveConfigPath, resolveXdgConfigHome } from "./pipeline/helpers"
29
+ import {
30
+ formatFullIssues,
31
+ serializeButtonSchemas,
32
+ validateButton,
33
+ validateFull,
34
+ validatePerDeck,
35
+ } from "@/config/validation"
36
+ import {
37
+ createDeckRuntime,
38
+ injectSystemButtons,
39
+ type Methods,
40
+ type PubSub,
41
+ type Runtime,
42
+ type RuntimeDeck,
43
+ type Store,
44
+ } from "@/deck"
45
+ import { paginateDeck } from "@/deck/paginate-deck"
46
+ import { positionButtons } from "@/deck/position-buttons"
47
+ import { subscribeNavigateDeck } from "@/deck/runtime-subscriptions"
48
+ import {
49
+ createActiveAppProvider,
50
+ type ActiveAppProvider,
51
+ } from "@/system/providers/active-app"
52
+ import { createKeyMacroProvider } from "@/system/providers/key-macro"
53
+ import { createNotificationProvider } from "@/system/providers/notification"
54
+ import {
55
+ createNullSessionProvider,
56
+ createSessionProvider,
57
+ } from "@/system/providers/session"
58
+ import type { CommandExecutor } from "@/system/providers/shared"
59
+ import {
60
+ checkRequirements,
61
+ formatCapabilityWarning,
62
+ type SystemCapability,
63
+ } from "@/system/requirements"
64
+ import { copyThemeAssets, resolveActiveTheme } from "@/themes/loader"
65
+ import { resolveAddonCacheDir } from "@/util/cache-paths"
66
+ import { probeGlobalPackageRoots } from "../package-manager"
67
+ import { installPackage } from "./install"
68
+ import { isNpmAddonSpec } from "@/addon/spec"
69
+ import { confirm } from "@/cli/prompt"
70
+
71
+ import { createActionExecutor } from "@/action/executor"
72
+ import {
73
+ getAssetByPath,
74
+ getUnsentAssets,
75
+ registerDeckIcon,
76
+ registerIconForDeck,
77
+ } from "@/core/icon-asset-registry"
78
+ import { ConfigWatcher } from "@/core/watcher"
79
+ import { bridgeAddonServices } from "@/deck/addon-handler-bridge"
80
+ import {
81
+ buildDeckConfigMessage,
82
+ buildDeckTree,
83
+ buildResolverOptions,
84
+ type AddonFrontendRef,
85
+ } from "@/deck/deck-config"
86
+ import { getHostContext } from "@/deck/host-context"
87
+ import { StatePublisher } from "@/render/state-publisher"
88
+ import { startWsBridge, type WsBridge } from "@/render/ws-bridge"
89
+ import { createConfigMutationService } from "@/config/mutation"
90
+ import {
91
+ createEditorMessageHandler,
92
+ type EditorMessageHandler,
93
+ } from "@/render/editor-handler"
94
+
95
+ import {
96
+ selectOutputClient,
97
+ type OutputClient,
98
+ type OutputHandle,
99
+ } from "@/outputClient"
100
+ import type { AddonInventoryEntry } from "@/api/protocol-internal"
101
+ import { loadDeviceConfig } from "@/util/device-config"
102
+ import {
103
+ collectAddonDefaultButtonConfig,
104
+ materializeAddonDecks,
105
+ } from "./addon-decks"
106
+ import {
107
+ collectBuiltinAddonRegistry,
108
+ type ScannedAddon,
109
+ type ScannedButtonType,
110
+ type ScannedDeck,
111
+ } from "./addon-registry"
112
+ import { resolveFrontendCwd } from "./emulator-mode"
113
+ import { selectLanAddresses } from "./network-bind"
114
+
115
+ export interface SignalProvider {
116
+ onSignal(handler: () => void): () => void
117
+ // ponytail: SIGUSR1 is the in-place-reload primitive (production's
118
+ // systemctl reload-or-restart and the dev `pnpm dev reload` both send
119
+ // it). Without a registered handler, Node's default action for SIGUSR1
120
+ // is to terminate the process — meaning every `reload` call today is
121
+ // a latent daemon crash. Wire the handler so SIGUSR1 triggers a
122
+ // runtime.invalidate() (re-broadcast deck-config + nudge the
123
+ // BrowserRenderer's screenshot tick). Tests construct their own
124
+ // SignalProvider; update them to supply onReload too.
125
+ onReload(handler: () => void): () => void
126
+ }
127
+
128
+ export const defaultSignals: SignalProvider = {
129
+ onSignal(handler: () => void): () => void {
130
+ process.once("SIGINT", handler)
131
+ process.once("SIGTERM", handler)
132
+ return () => {
133
+ process.off("SIGINT", handler)
134
+ process.off("SIGTERM", handler)
135
+ }
136
+ },
137
+ onReload(handler: () => void): () => void {
138
+ // ponytail: use `process.on` (not `process.once`) so the handler stays
139
+ // registered across reloads. `process.once` would fire once and then the
140
+ // second SIGUSR1 would fall through to Node's default (terminate).
141
+ // The returned unregister calls `process.off`, which works the same
142
+ // for either form — we just need the listener to persist.
143
+ process.on("SIGUSR1", handler)
144
+ return () => process.off("SIGUSR1", handler)
145
+ },
146
+ }
147
+
148
+ export interface RunOptions {
149
+ readonly config?: string
150
+ readonly port?: number
151
+ readonly emulator?: boolean
152
+ readonly remote?: boolean
153
+ readonly deviceModel?: string
154
+ readonly frontendUrl?: string
155
+ readonly intervalMs?: number
156
+ readonly xdgConfigHome?: string
157
+ readonly homeDir?: string
158
+ readonly noAutoOpen?: boolean
159
+ readonly signals?: SignalProvider
160
+ readonly onChildren?: (pids: ReadonlyArray<number>) => void
161
+ readonly onAddonsUpdate?: (addons: ReadonlyArray<ScannedAddon>) => void
162
+ readonly logger: pino.Logger
163
+ }
164
+
165
+ export interface SetupAddonServicesOptions {
166
+ readonly runtime: Runtime
167
+ readonly methods: Methods
168
+ readonly decks: ReadonlyArray<RuntimeDeck>
169
+ readonly pubSub: PubSub
170
+ readonly scanned: ReadonlyArray<ScannedAddon>
171
+ readonly externalAddons: ReadonlyArray<ScannedAddon>
172
+ readonly addonByType: Map<string, AddonFrontendRef>
173
+ readonly executor: ReturnType<typeof createActionExecutor>
174
+ readonly statePublisher: Pick<
175
+ StatePublisher,
176
+ "registerChannel" | "setActiveDeck"
177
+ >
178
+ readonly bridge: Pick<WsBridge, "broadcast" | "registerCacheablePoller">
179
+ readonly isCompact: boolean
180
+ readonly initialDeck?: RuntimeDeck
181
+ readonly signal: AbortSignal
182
+ readonly store: Store
183
+ readonly logger: pino.Logger
184
+ // ponytail: type is inferred from buildResolverOptions — adding a named
185
+ // type here would re-import a stale pre-existing module
186
+ // (`@/render/icon-resolver`) that doesn't exist on disk.
187
+ readonly resolverOptions: ReturnType<typeof buildResolverOptions>
188
+ /** Optional host callback that re-materializes addon decks (dynamic decks). */
189
+ readonly requestDeckRebuild?: () => void
190
+ }
191
+
192
+ export interface SetupAddonServicesResult {
193
+ readonly dispose: () => void
194
+ }
195
+
196
+ const collectActiveDeckAddonNames = (
197
+ deck: RuntimeDeck,
198
+ addonByType: Map<string, AddonFrontendRef>,
199
+ ): string[] => {
200
+ const addonNames = new Set<string>()
201
+ for (const button of deck.buttons ?? []) {
202
+ const entry = addonByType.get(button.type)
203
+ if (entry !== undefined) addonNames.add(entry.name)
204
+ }
205
+ return [...addonNames]
206
+ }
207
+
208
+ export const setupAddonServices = (
209
+ options: SetupAddonServicesOptions,
210
+ ): SetupAddonServicesResult => {
211
+ const {
212
+ runtime,
213
+ decks,
214
+ pubSub,
215
+ scanned,
216
+ externalAddons,
217
+ addonByType,
218
+ executor,
219
+ statePublisher,
220
+ bridge,
221
+ isCompact,
222
+ initialDeck,
223
+ signal,
224
+ store,
225
+ methods,
226
+ logger,
227
+ resolverOptions,
228
+ requestDeckRebuild,
229
+ } = options
230
+
231
+ void bridgeAddonServices({
232
+ runtime,
233
+ decks,
234
+ scanned,
235
+ externalAddons,
236
+ executor,
237
+ pubSub,
238
+ signal,
239
+ logger,
240
+ statePublisher,
241
+ bridge,
242
+ store,
243
+ methods,
244
+ ...(requestDeckRebuild !== undefined ? { requestDeckRebuild } : {}),
245
+ })
246
+
247
+ const unsubscribeDeck = pubSub.subscribe(
248
+ "runtime:activeDeck",
249
+ (payload: unknown) => {
250
+ const deckId =
251
+ typeof payload === "object" && payload !== null && "deckId" in payload
252
+ ? String((payload as { deckId: unknown }).deckId)
253
+ : undefined
254
+ if (deckId === undefined) return
255
+ const deck = decks.find((d) => d.id === deckId)
256
+ if (deck === undefined) return
257
+ statePublisher.setActiveDeck({
258
+ addonNames: collectActiveDeckAddonNames(deck, addonByType),
259
+ })
260
+ },
261
+ )
262
+
263
+ const unsubscribeBrightnessBridge = pubSub.subscribe<{
264
+ value: number
265
+ }>("sireno:settings:brightness", (payload) => {
266
+ bridge.broadcast({
267
+ type: "state",
268
+ channels: { "sireno:settings:brightness": payload },
269
+ })
270
+ })
271
+
272
+ let lastBroadcastedDeckId: string | undefined = initialDeck?.id
273
+ const unsubscribeDeckBroadcast = pubSub.subscribe(
274
+ "runtime:activeDeck",
275
+ (payload: unknown) => {
276
+ const deckId =
277
+ typeof payload === "object" && payload !== null && "deckId" in payload
278
+ ? String((payload as { deckId: unknown }).deckId)
279
+ : undefined
280
+ if (deckId === undefined) return
281
+ if (deckId === lastBroadcastedDeckId) return
282
+ lastBroadcastedDeckId = deckId
283
+ const deck = decks.find((d) => d.id === deckId)
284
+ if (deck === undefined) return
285
+ const msg = buildDeckConfigMessage(
286
+ deck,
287
+ addonByType,
288
+ resolverOptions,
289
+ {
290
+ navStackDepth: runtime.navStackDepth(),
291
+ hasOverlayDeckAvailable: runtime.hasOverlayDeckAvailable(),
292
+ inOverlayMode: runtime.getOverlay() !== null,
293
+ },
294
+ undefined,
295
+ isCompact,
296
+ (fullPath) => getAssetByPath(fullPath)?.id,
297
+ runtime.getAvailableOverlayDeckIcon(),
298
+ runtime.getAvailableOverlayDeckName(),
299
+ { lockActive: runtime.isLockActive() },
300
+ )
301
+ bridge.broadcast(msg)
302
+ },
303
+ )
304
+
305
+ const unsubscribeOverlayAvailableBroadcast = pubSub.subscribe(
306
+ "runtime:overlay-available",
307
+ () => {
308
+ const activeDeck = runtime.getActiveDeck()
309
+ if (activeDeck === undefined) return
310
+ const msg = buildDeckConfigMessage(
311
+ activeDeck,
312
+ addonByType,
313
+ resolverOptions,
314
+ {
315
+ navStackDepth: runtime.navStackDepth(),
316
+ hasOverlayDeckAvailable: runtime.hasOverlayDeckAvailable(),
317
+ inOverlayMode: runtime.getOverlay() !== null,
318
+ },
319
+ undefined,
320
+ isCompact,
321
+ (fullPath) => getAssetByPath(fullPath)?.id,
322
+ runtime.getAvailableOverlayDeckIcon(),
323
+ runtime.getAvailableOverlayDeckName(),
324
+ { lockActive: runtime.isLockActive() },
325
+ )
326
+ logger.info(
327
+ {
328
+ deckId: msg.deckId,
329
+ hasOverlayDeckAvailable: msg.hasOverlayDeckAvailable,
330
+ overlayDeckIcon: msg.overlayDeckIcon,
331
+ },
332
+ "orchestrator: broadcasting overlay-available update",
333
+ )
334
+ bridge.broadcast(msg)
335
+ },
336
+ )
337
+
338
+ // ponytail: periodic heartbeat — ensures the frontend's overlay state
339
+ // stays in sync after a transient disconnect or missed event. Fires every
340
+ // 2s, cheap (one deck-config per cycle), and idempotent on the frontend.
341
+ // Skip the broadcast when nothing observable changed since the last tick:
342
+ // an unconditional tick at 1s forced every client to re-render twice a
343
+ // second even with identical state, which drowned the emulator shell and
344
+ // starved the bridge's event loop during the 5s handshake window.
345
+ let lastHeartbeatKey = ""
346
+ const heartbeat = setInterval(() => {
347
+ const activeDeck = runtime.getActiveDeck()
348
+ if (activeDeck === undefined) return
349
+ const key = [
350
+ activeDeck.id,
351
+ runtime.navStackDepth(),
352
+ runtime.hasOverlayDeckAvailable(),
353
+ runtime.getAvailableOverlayDeckIcon(),
354
+ runtime.getAvailableOverlayDeckName(),
355
+ isCompact,
356
+ runtime.getOverlay() !== null,
357
+ ].join("|")
358
+ if (key === lastHeartbeatKey) return
359
+ lastHeartbeatKey = key
360
+ const msg = buildDeckConfigMessage(
361
+ activeDeck,
362
+ addonByType,
363
+ resolverOptions,
364
+ {
365
+ navStackDepth: runtime.navStackDepth(),
366
+ hasOverlayDeckAvailable: runtime.hasOverlayDeckAvailable(),
367
+ inOverlayMode: runtime.getOverlay() !== null,
368
+ },
369
+ undefined,
370
+ isCompact,
371
+ (fullPath) => getAssetByPath(fullPath)?.id,
372
+ runtime.getAvailableOverlayDeckIcon(),
373
+ runtime.getAvailableOverlayDeckName(),
374
+ { lockActive: runtime.isLockActive() },
375
+ )
376
+ bridge.broadcast(msg)
377
+ }, 2000)
378
+ signal.addEventListener("abort", () => clearInterval(heartbeat))
379
+
380
+ const unsubscribeNavigate = subscribeNavigateDeck(pubSub, runtime)
381
+
382
+ const unsubscribeDispatch = pubSub.subscribe(
383
+ "runtime:dispatch",
384
+ async (payload: unknown) => {
385
+ if (
386
+ typeof payload !== "object" ||
387
+ payload === null ||
388
+ !("value" in payload)
389
+ ) {
390
+ return
391
+ }
392
+ const value = String((payload as { value: unknown }).value)
393
+ if (value.length === 0) return
394
+ try {
395
+ await methods.dispatch(value)
396
+ } catch (err) {
397
+ logger.error({ err }, "[runtime:dispatch] addon dispatch failed")
398
+ }
399
+ },
400
+ )
401
+
402
+ const unsubscribeButtonError = pubSub.subscribe(
403
+ "runtime:buttonError",
404
+ (payload: unknown) => {
405
+ if (
406
+ typeof payload !== "object" ||
407
+ payload === null ||
408
+ !("deckId" in payload) ||
409
+ !("position" in payload)
410
+ ) {
411
+ return
412
+ }
413
+ const deckId = String((payload as { deckId: unknown }).deckId)
414
+ const position = Number((payload as { position: unknown }).position)
415
+ const durationMs =
416
+ "durationMs" in payload
417
+ ? Number((payload as { durationMs: unknown }).durationMs)
418
+ : 5000
419
+ const details =
420
+ "details" in payload &&
421
+ typeof (payload as { details?: unknown }).details === "string"
422
+ ? (payload as { details: string }).details
423
+ : undefined
424
+ if (!Number.isFinite(position) || position < 0) return
425
+ bridge.broadcast({
426
+ type: "button-error",
427
+ deckId,
428
+ position,
429
+ durationMs: Number.isFinite(durationMs) ? durationMs : 5000,
430
+ ...(details !== undefined ? { details } : {}),
431
+ })
432
+ },
433
+ )
434
+
435
+ if (initialDeck !== undefined) {
436
+ statePublisher.setActiveDeck({
437
+ addonNames: collectActiveDeckAddonNames(initialDeck, addonByType),
438
+ })
439
+ }
440
+
441
+ return {
442
+ dispose: () => {
443
+ unsubscribeDeck()
444
+ unsubscribeDeckBroadcast()
445
+ unsubscribeOverlayAvailableBroadcast()
446
+ unsubscribeBrightnessBridge()
447
+ unsubscribeNavigate()
448
+ unsubscribeDispatch()
449
+ unsubscribeButtonError()
450
+ },
451
+ }
452
+ }
453
+
454
+ interface LoadConfigAndThemeResult {
455
+ readonly configPath: string
456
+ readonly theme: {
457
+ name: string
458
+ apiVersion: number
459
+ manifestPath: string
460
+ uiOverridesPath: string | null
461
+ }
462
+ readonly themeDir: string
463
+ readonly decks: ReadonlyArray<RuntimeDeck>
464
+ readonly sourceDecks: ReadonlyArray<RuntimeDeck>
465
+ readonly pubSub: PubSub
466
+ readonly runtime: Runtime
467
+ readonly methods: Methods
468
+ readonly store: Store
469
+ }
470
+
471
+ interface LoadConfigResult {
472
+ readonly configPath: string
473
+ readonly config: ReturnType<typeof loadConfig>["config"]
474
+ readonly registry: AddonRegistry
475
+ readonly theme: ReturnType<typeof resolveActiveTheme>["theme"]
476
+ readonly themeDir: string
477
+ readonly addonSpecToName: ReadonlyMap<string, string>
478
+ readonly addonEntryPaths: ReadonlyMap<string, string>
479
+ readonly addonBrowserEntryPaths: ReadonlyMap<string, string>
480
+ }
481
+
482
+ const loadExternalAddonsIntoRegistry = async (
483
+ registry: AddonRegistry,
484
+ config: ReturnType<typeof loadConfig>["config"],
485
+ configDir: string,
486
+ homeDir: string,
487
+ logger: RunOptions["logger"],
488
+ configPath: string,
489
+ ): Promise<{
490
+ specToName: Map<string, string>
491
+ nameToEntryPath: Map<string, string>
492
+ nameToBrowserEntryPath: Map<string, string>
493
+ }> => {
494
+ // ponytail: returns specifier→manifest name for addons that successfully
495
+ // registered. The override map in buildRuntime keys on manifest name (what
496
+ // materializeAddonDecks looks up) — the user wrote `src:` (path), so we
497
+ // resolve the two here. nameToEntryPath feeds buildExternalScannedAddons
498
+ // so the bridge can import the entry file at runtime.
499
+ const specToName = new Map<string, string>()
500
+ const nameToEntryPath = new Map<string, string>()
501
+ const nameToBrowserEntryPath = new Map<string, string>()
502
+ const entries = config.addons ?? []
503
+ if (entries.length === 0)
504
+ return { specToName, nameToEntryPath, nameToBrowserEntryPath }
505
+ const { roots: globalRoots, timedOut, timeoutMs } = probeGlobalPackageRoots()
506
+ for (const manager of timedOut)
507
+ logger.warn(
508
+ { manager, timeoutMs },
509
+ `addons: ${manager} did not answer within ${timeoutMs}ms and was killed — globally installed addons may not resolve`,
510
+ )
511
+ let result = await loadAddons({
512
+ entries,
513
+ configDir,
514
+ homeDir,
515
+ currentApiVersion: SIRENO_ADDON_API_VERSION,
516
+ cacheDir: resolveAddonCacheDir(),
517
+ projectDir: configDir,
518
+ globalPackageRoots: globalRoots,
519
+ })
520
+ const missing = result.issues
521
+ .filter((issue) => issue.message.includes("is not installed"))
522
+ .map((issue) => issue.source)
523
+ if (missing.length > 0 && process.stdin.isTTY) {
524
+ for (const source of missing) {
525
+ const entry = entries.find(
526
+ (candidate) =>
527
+ (typeof candidate === "string" ? candidate : candidate.src) ===
528
+ source,
529
+ )
530
+ if (
531
+ await confirm({
532
+ message: `Addon '${source}' is not installed. Install it now?`,
533
+ initialValue: true,
534
+ })
535
+ ) {
536
+ await installPackage({
537
+ config: configPath,
538
+ packageName: source,
539
+ ...(typeof entry !== "string" && entry?.global === true
540
+ ? { global: true }
541
+ : {}),
542
+ configure: false,
543
+ })
544
+ }
545
+ }
546
+ result = await loadAddons({
547
+ entries,
548
+ configDir,
549
+ homeDir,
550
+ currentApiVersion: SIRENO_ADDON_API_VERSION,
551
+ cacheDir: resolveAddonCacheDir(),
552
+ projectDir: configDir,
553
+ globalPackageRoots: globalRoots,
554
+ })
555
+ }
556
+ for (const issue of result.issues) {
557
+ const payload = { source: issue.source, message: issue.message }
558
+ const detail = `addon '${issue.source}': ${issue.message}`
559
+ if (issue.level === "error")
560
+ logger.error(payload, `addon load error — ${detail}`)
561
+ else if (issue.level === "warning")
562
+ logger.warn(payload, `addon load warning — ${detail}`)
563
+ else logger.info(payload, `addon load info — ${detail}`)
564
+ }
565
+ for (const loaded of result.addons) {
566
+ try {
567
+ registry.load(loaded.manifest)
568
+ specToName.set(loaded.source.specifier, loaded.manifest.name)
569
+ // ponytail: two consumers, opposite needs. The DAEMON imports the Node
570
+ // entry (host UI stubbed, so `import()` resolves); the BROWSER gets the
571
+ // dedicated bundle when the addon ships one (host UI left external, so
572
+ // vite resolves it to the real components — that is what fixed
573
+ // pomodoro's blank labels). These were briefly one map set to
574
+ // `browserEntryPath ?? entryPath`, which handed the daemon a bundle
575
+ // Node cannot resolve and silently killed every addon button handler.
576
+ nameToEntryPath.set(loaded.manifest.name, loaded.entryPath)
577
+ nameToBrowserEntryPath.set(
578
+ loaded.manifest.name,
579
+ loaded.browserEntryPath ?? loaded.entryPath,
580
+ )
581
+ logger.info(
582
+ {
583
+ addon: loaded.manifest.name,
584
+ source: loaded.source.specifier,
585
+ separateBrowserBundle: loaded.browserEntryPath !== null,
586
+ },
587
+ "addon loaded",
588
+ )
589
+ } catch (err) {
590
+ logger.error(
591
+ { err, addon: loaded.manifest.name },
592
+ "addon failed to register",
593
+ )
594
+ }
595
+ }
596
+ return { specToName, nameToEntryPath, nameToBrowserEntryPath }
597
+ }
598
+
599
+ export const validateAndLoadConfig = async (
600
+ options: RunOptions,
601
+ ): Promise<LoadConfigResult> => {
602
+ const resolved = resolveConfigPath(options)
603
+ const configPath = resolved.path
604
+ options.logger.info(
605
+ { configPath, source: resolved.source },
606
+ `start: using config ${configPath}`,
607
+ )
608
+ const { config } = loadConfig({ configPath })
609
+ const registry = new AddonRegistry()
610
+ registerBuiltins(registry)
611
+ // ponytail: wire `config.addons[]` into the registry. Without this the
612
+ // loader's only caller was the test suite, so chrome-overlay et al. were
613
+ // never registered — builtins only. Load failures stay non-fatal (one bad
614
+ // addon must not kill the daemon); issues are surfaced through the logger.
615
+ const {
616
+ specToName: addonSpecToName,
617
+ nameToEntryPath: addonEntryPaths,
618
+ nameToBrowserEntryPath: addonBrowserEntryPaths,
619
+ } = await loadExternalAddonsIntoRegistry(
620
+ registry,
621
+ config,
622
+ dirname(configPath),
623
+ options.homeDir ?? homedir(),
624
+ options.logger,
625
+ configPath,
626
+ )
627
+ // ponytail: dump every deck type the registry holds so the operator can
628
+ // see at startup which addons registered and which got silently skipped.
629
+ options.logger.info(
630
+ {
631
+ addonDecks: registry.listDeckTypes?.(),
632
+ userDecks: Object.keys(config.decks),
633
+ addonConfigEntries: (config.addons ?? []).map((a) =>
634
+ typeof a === "string" ? a : a.src,
635
+ ),
636
+ },
637
+ "registry: deck types loaded",
638
+ )
639
+ // Per-button config errors are non-fatal: each broken button is replaced
640
+ // with a `core:temporary-error` cell at its position and logged in full
641
+ // (the incorrect config + the schema issues) so the operator can see
642
+ // exactly what went wrong without restarting the daemon. We only fail
643
+ // startup on structural problems (missing main, duplicate positions,
644
+ // unknown/invalid button types in non-config ways).
645
+ const validation = validateFull(config, registry)
646
+ const structuralErrors = validation.issues.filter(
647
+ (i) => !i.path.includes(".config."),
648
+ )
649
+ if (structuralErrors.some((i) => i.level === "error")) {
650
+ throw new Error(
651
+ `Config validation failed:\n${formatFullIssues(structuralErrors)}`,
652
+ )
653
+ }
654
+ const { roots: globalRoots, timedOut, timeoutMs } = probeGlobalPackageRoots()
655
+ for (const manager of timedOut)
656
+ options.logger.warn(
657
+ { manager, timeoutMs },
658
+ `themes: ${manager} did not answer within ${timeoutMs}ms and was killed — globally installed themes may not resolve`,
659
+ )
660
+ const themeOptions = {
661
+ // ponytail: theme paths in config.yml are relative to the config file's
662
+ // directory, not to process.cwd(). `loadThemeFromPath` resolves via
663
+ // `path.resolve(themePath)`, which uses cwd — and the dev wrapper sets
664
+ // cwd to packages/cli/. Pre-resolve here so `./packages/themes/...`
665
+ // works regardless of where the daemon is launched from.
666
+ theme:
667
+ config.theme !== undefined &&
668
+ typeof config.theme === "string" &&
669
+ (config.theme.startsWith("./") || config.theme.startsWith("../"))
670
+ ? resolvePath(dirname(configPath), config.theme)
671
+ : config.theme,
672
+ packageRoots: [join(dirname(configPath), "node_modules"), ...globalRoots],
673
+ }
674
+ let resolvedTheme: ReturnType<typeof resolveActiveTheme>
675
+ try {
676
+ resolvedTheme = resolveActiveTheme(registry, themeOptions)
677
+ } catch (error) {
678
+ const source =
679
+ config.theme === undefined
680
+ ? null
681
+ : typeof config.theme === "string"
682
+ ? config.theme
683
+ : config.theme.src
684
+ if (source === null || !isNpmAddonSpec(source) || !process.stdin.isTTY)
685
+ throw error
686
+ if (
687
+ !(await confirm({
688
+ message: `Theme '${source}' is not installed. Install it now?`,
689
+ initialValue: true,
690
+ }))
691
+ )
692
+ throw error
693
+ await installPackage({
694
+ config: configPath,
695
+ packageName: source,
696
+ ...(typeof config.theme === "object" && config.theme.global === true
697
+ ? { global: true }
698
+ : {}),
699
+ configure: false,
700
+ })
701
+ resolvedTheme = resolveActiveTheme(registry, themeOptions)
702
+ }
703
+ const { theme, getCss } = resolvedTheme
704
+ const themeDir: string = resolveFrontendCwd()
705
+ const cssContent: string = getCss()
706
+ if (cssContent.length > 0) {
707
+ const cssDir = join(themeDir, ".sirenodeck")
708
+ if (!existsSync(cssDir)) mkdirSync(cssDir, { recursive: true })
709
+ writeFileSync(join(cssDir, "theme.css"), cssContent, "utf8")
710
+ copyThemeAssets(dirname(theme.manifestPath), cssDir)
711
+ }
712
+ process.env["SIRENO_THEME_DIR"] = themeDir
713
+ process.env["SIRENO_THEME"] = JSON.stringify({
714
+ name: theme.name,
715
+ manifestPath: theme.manifestPath,
716
+ uiOverridesPath: theme.uiOverridesPath,
717
+ })
718
+ process.env["SIRENO_THEME_NAME"] = theme.name
719
+ return {
720
+ configPath,
721
+ config,
722
+ registry,
723
+ theme,
724
+ themeDir,
725
+ addonSpecToName,
726
+ addonEntryPaths,
727
+ addonBrowserEntryPaths,
728
+ }
729
+ }
730
+
731
+ export const buildAddonConfigOverrides = (
732
+ addonEntries: ReadonlyArray<unknown>,
733
+ addonSpecToName: ReadonlyMap<string, string>,
734
+ logger: pino.Logger,
735
+ ): Map<
736
+ string,
737
+ {
738
+ addonWideConfig: Record<string, unknown>
739
+ perDeck: Map<string, import("@/cli/commands/addon-decks").AddonDeckOverride>
740
+ defaults: { autoShow?: boolean } | undefined
741
+ }
742
+ > => {
743
+ const overrides = new Map<
744
+ string,
745
+ {
746
+ addonWideConfig: Record<string, unknown>
747
+ perDeck: Map<
748
+ string,
749
+ import("@/cli/commands/addon-decks").AddonDeckOverride
750
+ >
751
+ defaults: { autoShow?: boolean } | undefined
752
+ }
753
+ >()
754
+ for (const entry of addonEntries) {
755
+ // ponytail: `addons[]` entries are either a raw string spec or
756
+ // `{src, enabled?, config?}`. The user wrote `src:` (or just a string
757
+ // for the spec). Normalize here so we have a single source-of-truth key.
758
+ const src =
759
+ typeof entry === "string"
760
+ ? entry
761
+ : typeof entry === "object" &&
762
+ entry !== null &&
763
+ typeof (entry as { src?: unknown }).src === "string"
764
+ ? (entry as { src: string }).src
765
+ : null
766
+ if (src === null) continue
767
+ const cfg =
768
+ typeof entry === "object" && entry !== null
769
+ ? (entry as { config?: unknown }).config
770
+ : undefined
771
+ if (typeof cfg !== "object" || cfg === null) continue
772
+ // ponytail: addonWideConfig is everything under `entry.config` EXCEPT
773
+ // `decks` (which carries per-deck overrides) and `defaults` (which carries
774
+ // addon-wide deck defaults). We accept unknown keys here because the
775
+ // schema only constrains the `decks` sub-record.
776
+ const {
777
+ decks: perDeckRaw,
778
+ defaults,
779
+ ...rest
780
+ } = cfg as {
781
+ decks?: Record<
782
+ string,
783
+ import("@/cli/commands/addon-decks").AddonDeckOverride
784
+ >
785
+ defaults?: { autoShow?: boolean }
786
+ } & Record<string, unknown>
787
+ // ponytail: key on the loaded addon's manifest name — that's what
788
+ // materializeAddonDecks looks up. The user wrote `src:` (path or npm
789
+ // specifier); `addonSpecToName` resolves it post-load. If the addon
790
+ // didn't load (path typo, missing module), warn and drop the override.
791
+ const addonName = addonSpecToName.get(src)
792
+ if (addonName === undefined) {
793
+ logger.warn(
794
+ { src },
795
+ "addon config override targets an addon that failed to load; ignoring",
796
+ )
797
+ continue
798
+ }
799
+ overrides.set(addonName, {
800
+ addonWideConfig: rest,
801
+ perDeck: new Map(Object.entries(perDeckRaw ?? {})),
802
+ defaults,
803
+ })
804
+ }
805
+ return overrides
806
+ }
807
+
808
+ const buildRuntime = (
809
+ options: RunOptions,
810
+ loaded: LoadConfigResult,
811
+ keyCount: number,
812
+ lockActive: boolean = false,
813
+ ): LoadConfigAndThemeResult => {
814
+ const { logger } = options
815
+ const { config, registry, theme, themeDir } = loaded
816
+ const sourcePath = realpathSync(loaded.configPath)
817
+ const sourceFingerprint = createHash("sha256")
818
+ .update(readFileSync(sourcePath, "utf8"))
819
+ .digest("hex")
820
+
821
+ const decks: RuntimeDeck[] = Object.entries(config.decks).flatMap(
822
+ ([id, d]) => {
823
+ const objectButtons = d.buttons.flatMap((b, sourceButtonIndex) =>
824
+ typeof b === "string" ? [] : [{ ...b, sourceButtonIndex }],
825
+ )
826
+ // ponytail: recompute from snapshot every keyCount change — prior state
827
+ // is stale the moment keyCount differs. positionButtons is the
828
+ // authoritative assignment for the current device.
829
+ const runtimeButtons: RuntimeDeck["buttons"] = positionButtons(
830
+ objectButtons,
831
+ keyCount,
832
+ logger,
833
+ ).map((b) => ({
834
+ id: `${b.position}-${id}-0`,
835
+ type: b.type,
836
+ ...(b.position !== undefined ? { position: b.position } : {}),
837
+ ...(typeof b.config === "object" && b.config !== null
838
+ ? { config: b.config }
839
+ : {}),
840
+ ...(b.actions !== undefined ? { actions: b.actions } : {}),
841
+ ...(b.variant !== undefined && b.variant.length > 0
842
+ ? { variant: b.variant }
843
+ : {}),
844
+ ...(b.buttonColor !== undefined ? { buttonColor: b.buttonColor } : {}),
845
+ sourceTarget: {
846
+ sourcePath,
847
+ sourceDeckId: id,
848
+ sourceButtonIndex: b.sourceButtonIndex,
849
+ sourceButtonPath: `decks.${id}.buttons[${b.sourceButtonIndex}]`,
850
+ fingerprint: sourceFingerprint,
851
+ capability: "update" as const,
852
+ },
853
+ }))
854
+ const processNames =
855
+ d.trigger?.process_name !== undefined
856
+ ? Array.isArray(d.trigger.process_name)
857
+ ? d.trigger.process_name
858
+ : [d.trigger.process_name]
859
+ : undefined
860
+ const windowNames =
861
+ d.trigger?.window_name !== undefined
862
+ ? Array.isArray(d.trigger.window_name)
863
+ ? d.trigger.window_name
864
+ : [d.trigger.window_name]
865
+ : undefined
866
+ const sharedDeckFields = {
867
+ sourceDeckId: id,
868
+ editable: true,
869
+ isOverlay:
870
+ (processNames?.length ?? 0) > 0 || (windowNames?.length ?? 0) > 0,
871
+ paginated: d.paginated === true || runtimeButtons.length > keyCount - 1,
872
+ isMain: id === "main",
873
+ ...(processNames !== undefined ? { processNames } : {}),
874
+ ...(windowNames !== undefined ? { windowNames } : {}),
875
+ ...(d.autoShow === true ? { autoShow: true } : {}),
876
+ ...(d.icon !== undefined ? { icon: d.icon } : {}),
877
+ }
878
+ if (runtimeButtons.length > keyCount - 1) {
879
+ const pages = paginateDeck({
880
+ baseDeckId: id,
881
+ buttons: runtimeButtons,
882
+ keyCount,
883
+ })
884
+ return pages.map((p) => {
885
+ const mappedButtons: RuntimeDeck["buttons"] = (
886
+ p.deck.buttons ?? []
887
+ ).map((b, i) => {
888
+ const {
889
+ position,
890
+ type,
891
+ config,
892
+ actions,
893
+ buttonColor,
894
+ sourceTarget,
895
+ id: _btnId,
896
+ ...rest
897
+ } = b as {
898
+ position?: number
899
+ type: string
900
+ config?: unknown
901
+ actions?: RuntimeDeck["buttons"][number]["actions"]
902
+ buttonColor?:
903
+ | "blue"
904
+ | "green"
905
+ | "purple"
906
+ | "cyan"
907
+ | "magenta"
908
+ | "amber"
909
+ | "lime"
910
+ sourceTarget?: RuntimeDeck["buttons"][number]["sourceTarget"]
911
+ id?: string
912
+ }
913
+ const mergedConfig = {
914
+ ...(typeof config === "object" && config !== null
915
+ ? (config as Record<string, unknown>)
916
+ : {}),
917
+ ...rest,
918
+ }
919
+ return {
920
+ id: `${position}-${id}-${p.pageIndex}`,
921
+ type,
922
+ ...(position !== undefined ? { position } : {}),
923
+ ...(actions !== undefined ? { actions } : {}),
924
+ ...(buttonColor !== undefined ? { buttonColor } : {}),
925
+ ...(sourceTarget !== undefined ? { sourceTarget } : {}),
926
+ ...(Object.keys(mergedConfig).length > 0
927
+ ? { config: mergedConfig }
928
+ : {}),
929
+ }
930
+ })
931
+ return {
932
+ id: p.deckId,
933
+ name: d.name ?? id,
934
+ buttons: mappedButtons,
935
+ projectionId: p.deckId,
936
+ pageIndex: p.pageIndex,
937
+ ...sharedDeckFields,
938
+ }
939
+ })
940
+ }
941
+ return [
942
+ {
943
+ id,
944
+ name: d.name ?? id,
945
+ buttons: runtimeButtons,
946
+ projectionId: id,
947
+ pageIndex: 0,
948
+ ...sharedDeckFields,
949
+ },
950
+ ]
951
+ },
952
+ )
953
+
954
+ // ponytail: phase 11 — collect per-addon overrides from
955
+ // `addons[i].config.decks.<deckId>` into a single map keyed by addon
956
+ // name. addonWideConfig carries opaque keys (the addon reads them in
957
+ // createDeck(s)); perDeck is keyed by addon-deck-id.
958
+ const addonConfigOverrides = buildAddonConfigOverrides(
959
+ config.addons ?? [],
960
+ loaded.addonSpecToName,
961
+ logger,
962
+ )
963
+
964
+ const effectiveDecks: RuntimeDeck[] =
965
+ decks.length > 0
966
+ ? decks
967
+ : [{ id: "main", name: "Main", isMain: true, buttons: [] }]
968
+ const sourceDecks = materializeAddonDecks(
969
+ registry,
970
+ effectiveDecks,
971
+ logger,
972
+ keyCount,
973
+ config.lock?.buttons,
974
+ addonConfigOverrides,
975
+ )
976
+ const allDecsWithSystemButtons = injectSystemButtons(sourceDecks, keyCount, {
977
+ lockActive,
978
+ })
979
+ const { decks: allDecks, errorsByDeck } = applyConfigErrorReplacements(
980
+ allDecsWithSystemButtons,
981
+ config,
982
+ registry,
983
+ logger,
984
+ )
985
+ const { runtime, methods, pubSub, store } = createDeckRuntime({
986
+ decks: allDecks,
987
+ logger,
988
+ })
989
+
990
+ return {
991
+ configPath: loaded.configPath,
992
+ theme: {
993
+ name: theme.name,
994
+ apiVersion: theme.apiVersion,
995
+ manifestPath: theme.manifestPath,
996
+ uiOverridesPath: theme.uiOverridesPath,
997
+ },
998
+ themeDir,
999
+ decks: allDecks,
1000
+ sourceDecks,
1001
+ pubSub,
1002
+ runtime,
1003
+ methods,
1004
+ store,
1005
+ }
1006
+ }
1007
+
1008
+ interface DeckButtonError {
1009
+ position: number
1010
+ buttonId?: string
1011
+ details: string
1012
+ }
1013
+
1014
+ /**
1015
+ * Walks each runtime deck and replaces any button whose (deckId, position)
1016
+ * was flagged as a config error with a `core:temporary-error` cell. The
1017
+ * full incorrect config and the full zod issues are logged once per
1018
+ * broken button so the operator can fix the YAML without digging through
1019
+ * a stack trace.
1020
+ *
1021
+ * ponytail: invalid buttons keep their assigned slot — the user sees exactly
1022
+ * which slot is broken. Validation uses the runtime button's assigned
1023
+ * position (from positionButtons), not the optional config-defined position.
1024
+ *
1025
+ * Returns the patched decks plus a per-deck error map for downstream
1026
+ * surfacing in `deck-config` messages.
1027
+ */
1028
+ export const applyConfigErrorReplacements = (
1029
+ decks: ReadonlyArray<RuntimeDeck>,
1030
+ config: import("@/config/schemas").RawConfig,
1031
+ registry: AddonRegistry,
1032
+ logger: pino.Logger,
1033
+ ): {
1034
+ decks: RuntimeDeck[]
1035
+ errorsByDeck: Map<string, DeckButtonError[]>
1036
+ } => {
1037
+ const brokenByDeckPosition = new Map<
1038
+ string,
1039
+ Map<number, { details: string; button: unknown }>
1040
+ >()
1041
+
1042
+ for (const deck of decks) {
1043
+ for (const btn of deck.buttons ?? []) {
1044
+ // ponytail: skip addon-injected decks — their buttons have semantic ids
1045
+ // (e.g. brightness-down, app-info, back) and `internal:true` services;
1046
+ // re-validating them via the user-config schema always fails.
1047
+ const isAddonInjectedDeck = deck.id.startsWith("internal-settings:")
1048
+ if (isAddonInjectedDeck) continue
1049
+ const position = btn.position
1050
+ if (position === undefined) continue
1051
+ // ponytail: skip system buttons — they are injected, not user-configured
1052
+ if (btn.type.startsWith("core:")) continue
1053
+
1054
+ const path = `decks.${deck.id}.buttons[@position:${position}]`
1055
+ const { issues, schemaIssues } = validateButton(
1056
+ btn as unknown as import("@/config/schemas").RawButtonDef,
1057
+ registry,
1058
+ path,
1059
+ deck.id,
1060
+ position,
1061
+ )
1062
+ if (issues.length === 0) continue
1063
+
1064
+ const details =
1065
+ schemaIssues
1066
+ .map((i) => `${[...i.path].join(".") || "(root)"}: ${i.message}`)
1067
+ .join("; ") || issues.map((i) => i.message).join("; ")
1068
+ logger.warn(
1069
+ {
1070
+ deckId: deck.id,
1071
+ position,
1072
+ buttonId: btn.id,
1073
+ path,
1074
+ config: btn,
1075
+ schemaIssues,
1076
+ },
1077
+ `invalid button at ${deck.id}:${position}: ${details}`,
1078
+ )
1079
+ let perDeckMap = brokenByDeckPosition.get(deck.id)
1080
+ if (perDeckMap === undefined) {
1081
+ perDeckMap = new Map()
1082
+ brokenByDeckPosition.set(deck.id, perDeckMap)
1083
+ }
1084
+ perDeckMap.set(position, { details, button: btn })
1085
+ }
1086
+ }
1087
+
1088
+ const errorsByDeck = new Map<string, DeckButtonError[]>()
1089
+ const patched = decks.map((deck) => {
1090
+ const brokenMap = brokenByDeckPosition.get(deck.id)
1091
+ if (brokenMap === undefined || brokenMap.size === 0) return deck
1092
+ const errors: DeckButtonError[] = []
1093
+ const buttons = deck.buttons.map((btn) => {
1094
+ const position = btn.position
1095
+ if (position === undefined) return btn
1096
+ const broken = brokenMap.get(position)
1097
+ if (broken === undefined) return btn
1098
+ errors.push({ position, details: broken.details })
1099
+ return {
1100
+ id: `${position}-${deck.id}-0`,
1101
+ type: "core:temporary-error" as const,
1102
+ position,
1103
+ config: { details: broken.details },
1104
+ }
1105
+ })
1106
+ errorsByDeck.set(deck.id, errors)
1107
+ return { ...deck, buttons, buttonErrors: errors }
1108
+ })
1109
+ return { decks: patched, errorsByDeck }
1110
+ }
1111
+
1112
+ const loadConfigAndTheme = async (
1113
+ options: RunOptions,
1114
+ keyCount: number = 15,
1115
+ ): Promise<LoadConfigAndThemeResult> => {
1116
+ const loaded = await validateAndLoadConfig(options)
1117
+ return buildRuntime(options, loaded, keyCount)
1118
+ }
1119
+
1120
+ interface SystemProviders {
1121
+ readonly activeApp: ActiveAppProvider
1122
+ readonly session: import("@/system/providers/session").SessionProvider
1123
+ readonly keyMacro: import("@/system/providers/key-macro").KeyMacroProvider
1124
+ readonly notification: import("@/system/providers/notification").NotificationProvider
1125
+ }
1126
+
1127
+ const startSystemProviders = async (
1128
+ options: RunOptions,
1129
+ runtime: Runtime,
1130
+ methods: Methods,
1131
+ ): Promise<SystemProviders> => {
1132
+ const { logger } = options
1133
+ const { spawn } = await import("node:child_process")
1134
+ const executor: CommandExecutor = {
1135
+ async run(
1136
+ command: string,
1137
+ args: ReadonlyArray<string>,
1138
+ execOptions?: { timeoutMs?: number },
1139
+ ) {
1140
+ // Uses 'exit' (not 'close') so tools that keep stdio fds open after their
1141
+ // main exits — notably wl-copy — don't hang the runtime. Streams are
1142
+ // drained via 'data' events until 'exit' fires; once the process exits,
1143
+ // no further writes are possible.
1144
+ const timeoutMs = execOptions?.timeoutMs
1145
+ const start = Date.now()
1146
+ return await new Promise((resolve) => {
1147
+ const proc = spawn(command, [...args], {
1148
+ stdio: ["pipe", "pipe", "pipe"],
1149
+ })
1150
+ let stdout = ""
1151
+ let stderr = ""
1152
+ let timedOut = false
1153
+ let killTimer: ReturnType<typeof setTimeout> | undefined
1154
+ proc.stdout.on("data", (chunk: Buffer) => {
1155
+ stdout += chunk.toString()
1156
+ })
1157
+ proc.stderr.on("data", (chunk: Buffer) => {
1158
+ stderr += chunk.toString()
1159
+ })
1160
+ const onExit = (code: number | null): void => {
1161
+ if (killTimer !== undefined) clearTimeout(killTimer)
1162
+ resolve({
1163
+ exitCode: timedOut ? -1 : (code ?? -1),
1164
+ stdout,
1165
+ stderr,
1166
+ })
1167
+ }
1168
+ proc.on("error", (err) => {
1169
+ if (killTimer !== undefined) clearTimeout(killTimer)
1170
+ resolve({
1171
+ exitCode: -1,
1172
+ stdout,
1173
+ stderr: stderr ? `${stderr}\n${err.message}` : err.message,
1174
+ })
1175
+ })
1176
+ proc.on("exit", onExit)
1177
+ if (timeoutMs !== undefined && timeoutMs > 0) {
1178
+ killTimer = setTimeout(() => {
1179
+ timedOut = true
1180
+ proc.kill("SIGKILL")
1181
+ }, timeoutMs)
1182
+ }
1183
+ })
1184
+ },
1185
+ }
1186
+
1187
+ const env = { ...process.env } as Readonly<Record<string, string>>
1188
+ const platform = process.platform
1189
+
1190
+ // ponytail: when the CLI is launched from a stripped-PATH environment
1191
+ // (systemd/launchd/IDE), `which ydotool` returns nothing even when the
1192
+ // binary exists at /usr/local/bin. Probe a few well-known install dirs
1193
+ // as a fallback so the requirements check doesn't false-negative.
1194
+ const wellKnownBinDirs = [
1195
+ "/usr/local/bin",
1196
+ "/usr/bin",
1197
+ `${homedir()}/.local/bin`,
1198
+ "/snap/bin",
1199
+ "/opt/homebrew/bin",
1200
+ ]
1201
+ const extraFsProbe = (command: string): boolean => {
1202
+ for (const dir of wellKnownBinDirs) {
1203
+ if (existsSync(join(dir, command))) return true
1204
+ }
1205
+ return false
1206
+ }
1207
+
1208
+ const requirements = await checkRequirements({
1209
+ platform,
1210
+ executor,
1211
+ env,
1212
+ extraFsProbe,
1213
+ })
1214
+ methods.setRequirements(requirements)
1215
+ for (const [capability, status] of Object.entries(requirements)) {
1216
+ const warning = formatCapabilityWarning(
1217
+ capability as SystemCapability,
1218
+ status,
1219
+ )
1220
+ if (warning.length > 0) {
1221
+ logger.warn({ capability, status }, warning)
1222
+ }
1223
+ }
1224
+
1225
+ const [activeApp, session, keyMacro, notification] = await Promise.all([
1226
+ createActiveAppProvider({ platform, executor, logger }),
1227
+ options.emulator === true
1228
+ ? createNullSessionProvider(logger)
1229
+ : createSessionProvider({
1230
+ platform,
1231
+ executor,
1232
+ logger,
1233
+ }),
1234
+ createKeyMacroProvider({ platform, executor, env, logger, extraFsProbe }),
1235
+ createNotificationProvider({
1236
+ platform,
1237
+ executor,
1238
+ env,
1239
+ logger,
1240
+ extraFsProbe,
1241
+ }),
1242
+ ])
1243
+
1244
+ runtime.setActiveAppProvider(activeApp)
1245
+ runtime.setSessionProvider(session)
1246
+ methods.setKeyMacroProvider(keyMacro)
1247
+ methods.setNotificationProvider(notification)
1248
+
1249
+ return { activeApp, session, keyMacro, notification }
1250
+ }
1251
+
1252
+ interface AddonRegistryBundle {
1253
+ readonly scanned: ReadonlyArray<ScannedAddon>
1254
+ readonly addonByType: Map<string, AddonFrontendRef>
1255
+ }
1256
+
1257
+ // ponytail: shape a ScannedAddon into the addon-spec the frontend vite
1258
+ // plugin expects (see packages/cli/src/vite/virtual-modules.ts). Both
1259
+ // builtins and third-party addons share the same shape — only the source
1260
+ // of `frontendEntry` differs. Reused by the runPipeline pass that merges
1261
+ // externalScanned into SIRENO_ADDONS after both scanned arrays exist.
1262
+ // ponytail: vite serves the ADDON SOURCE, not the bundled dist. The dist
1263
+ // bundle aliases host-UI imports (e.g. '@/ui/primitives/Label') to an
1264
+ // inert stub so plain Node can import it for manifest/globalService —
1265
+ // serving that artifact to the browser amputates every core-UI label.
1266
+ // Source restores vite's real host-alias resolution, so addons get the
1267
+ // full core kit (any @sirenodeck/sirenodeck/ui component). Falls back to the
1268
+ // dist entry when src/index.ts doesn't exist (npm-cached third-party
1269
+ // addons ship compiled output only).
1270
+ const browserFrontendMain = (entry: string): string => {
1271
+ const dir = dirname(entry)
1272
+ // Entry lives in <pkg>/dist (JSON-manifest addons, compiled) or directly
1273
+ // in <pkg> (regex-scanned builtins' index.ts). Normalize to pkg root.
1274
+ const pkgRoot = basename(dir) === "dist" ? dirname(dir) : dir
1275
+ const src = resolvePath(pkgRoot, "src", "index.ts")
1276
+ return existsSync(src) ? src : entry
1277
+ }
1278
+
1279
+ export const addonSpecFromScanned = (s: ScannedAddon) => ({
1280
+ name: s.name,
1281
+ frontend:
1282
+ s.browserEntry != null
1283
+ ? { main: browserFrontendMain(s.browserEntry) }
1284
+ : s.frontendEntry !== null
1285
+ ? { main: browserFrontendMain(s.frontendEntry) }
1286
+ : undefined,
1287
+ buttons: s.types.map((t) => ({ type: t })),
1288
+ buttonTypes: Object.fromEntries(
1289
+ Object.entries(s.buttonTypes).map(([type, info]) => [
1290
+ type,
1291
+ info.exportName,
1292
+ ]),
1293
+ ),
1294
+ defaultButton: s.defaultButton,
1295
+ })
1296
+
1297
+ const addonOverrideFields: Array<
1298
+ "name" | "icon" | "autoShow" | "trigger" | "config"
1299
+ > = ["name", "icon", "autoShow", "trigger", "config"]
1300
+
1301
+ // ponytail: shape a ScannedAddon into the AddonsPage inventory entry the
1302
+ // emulator renders. Mirrors the AddonsPage TypeScript shape so the WS
1303
+ // message can flow straight into a prop without further mapping.
1304
+ export const addonInventoryFromScanned = (
1305
+ s: ScannedAddon,
1306
+ addonIndex = 0,
1307
+ materializedDecks: ReadonlyArray<RuntimeDeck> = [],
1308
+ defaultConfig?: unknown,
1309
+ ): AddonInventoryEntry => {
1310
+ const generatedDecks = materializedDecks.filter(
1311
+ (deck) => deck.addonOwner?.addonName === s.name,
1312
+ )
1313
+ const generatedIds = new Set(
1314
+ generatedDecks.map((deck) => deck.sourceDeckId ?? deck.id),
1315
+ )
1316
+ const sourceDecks = s.decks
1317
+ .filter(
1318
+ (deck) => !deck.id.endsWith(":__multi__") && !generatedIds.has(deck.id),
1319
+ )
1320
+ .map((deck) => ({
1321
+ id: deck.id,
1322
+ generated: false,
1323
+ pageIndex: 0,
1324
+ isOverlay: deck.isOverlay === true || deck.hasTrigger,
1325
+ paginated: deck.paginated,
1326
+ buttons: [],
1327
+ internal: deck.internal,
1328
+ addonIndex,
1329
+ overrideKey: deck.id,
1330
+ overrideFields: addonOverrideFields,
1331
+ }))
1332
+ const decks = [
1333
+ ...sourceDecks,
1334
+ ...generatedDecks.map((deck) => ({
1335
+ id: deck.id,
1336
+ sourceId: deck.sourceDeckId,
1337
+ generated: true,
1338
+ pageIndex: deck.pageIndex ?? 0,
1339
+ isOverlay: deck.isOverlay === true || deck.isOverlayDeck === true,
1340
+ paginated: deck.paginated === true,
1341
+ buttons: deck.buttons.map((button) => ({
1342
+ type: button.type,
1343
+ generated: true,
1344
+ ...(button.position !== undefined ? { position: button.position } : {}),
1345
+ ...(button.config !== undefined ? { config: button.config } : {}),
1346
+ })),
1347
+ internal:
1348
+ s.decks.find((source) => source.id === deck.sourceDeckId)?.internal ??
1349
+ false,
1350
+ addonIndex: deck.addonOwner?.addonIndex ?? addonIndex,
1351
+ overrideKey: deck.addonOwner?.overrideKey ?? deck.sourceDeckId ?? deck.id,
1352
+ overrideFields: addonOverrideFields,
1353
+ })),
1354
+ ]
1355
+ return {
1356
+ name: s.name,
1357
+ addonIndex,
1358
+ ...(s.path !== undefined ? { path: s.path } : {}),
1359
+ internal: s.internal === true,
1360
+ source: s.source,
1361
+ buttonTypes: Object.entries(s.buttonTypes).map(([type, info]) => ({
1362
+ type,
1363
+ internal: info.internal,
1364
+ generated: false,
1365
+ ...(info.defaultConfig !== undefined
1366
+ ? { defaultConfig: info.defaultConfig }
1367
+ : {}),
1368
+ })),
1369
+ defaultButton: s.defaultButton ?? null,
1370
+ ...(defaultConfig !== undefined ? { defaultConfig } : {}),
1371
+ decks,
1372
+ }
1373
+ }
1374
+
1375
+ const buildAddonBundle = async (): Promise<AddonRegistryBundle> => {
1376
+ const registry = await collectBuiltinAddonRegistry()
1377
+
1378
+ return { scanned: registry.scanned, addonByType: registry.byType }
1379
+ }
1380
+
1381
+ // ponytail: previously SIRENO_ADDONS was populated inside buildAddonBundle
1382
+ // from builtins only, so third-party addons (e.g. addon-pomodoro) never
1383
+ // reached the frontend vite plugin's addonRegistry — ButtonSurface hit
1384
+ // the `registryEntry === undefined` branch and rendered an empty
1385
+ // ButtonFrame. External addons are registered via buildExternalScannedAddons
1386
+ // after validateAndLoadConfig, so the merged env-var write has to happen
1387
+ // here in runPipeline, after both scanned arrays exist.
1388
+ export const publishSIRENO_ADDONS = (
1389
+ scanned: ReadonlyArray<ScannedAddon>,
1390
+ externalScanned: ReadonlyArray<ScannedAddon>,
1391
+ ): void => {
1392
+ if (process.env["SIRENO_ADDONS"] !== undefined) return
1393
+ process.env["SIRENO_ADDONS"] = JSON.stringify(
1394
+ [...scanned, ...externalScanned].map(addonSpecFromScanned),
1395
+ )
1396
+ }
1397
+
1398
+ // ponytail: merge registry-loaded third-party addons into the bridge
1399
+ // inventory. The bridge only receives the builtin `scanned` array; without
1400
+ // this pass, AddonsPage shows builtins only and hides e.g. chrome-overlay.
1401
+ export const buildExternalScannedAddons = (
1402
+ registry: AddonRegistry,
1403
+ builtinScanned: ReadonlyArray<ScannedAddon>,
1404
+ externalAddonDirs: ReadonlyMap<string, string>,
1405
+ externalEntryPaths: ReadonlyMap<string, string>,
1406
+ externalBrowserEntryPaths: ReadonlyMap<string, string> = new Map(),
1407
+ ): ReadonlyArray<ScannedAddon> => {
1408
+ const builtinNames = new Set(builtinScanned.map((a) => a.name))
1409
+ const out: ScannedAddon[] = []
1410
+ for (const manifest of registry.listAddons()) {
1411
+ if (builtinNames.has(manifest.name)) continue
1412
+ const types = Object.keys(manifest.buttonTypes)
1413
+ if (types.length === 0 && (manifest.decks ?? []).length === 0) continue
1414
+ const path = externalAddonDirs.get(manifest.name) ?? ""
1415
+ const entryPath = externalEntryPaths.get(manifest.name) ?? ""
1416
+ const browserPath = externalBrowserEntryPaths.get(manifest.name) ?? ""
1417
+ const buttonTypes: Record<string, ScannedButtonType> = {}
1418
+ for (const [type, def] of Object.entries(manifest.buttonTypes)) {
1419
+ buttonTypes[type] = {
1420
+ exportName: def.name ?? type,
1421
+ internal: def.service?.internal === true || def.internal === true,
1422
+ }
1423
+ }
1424
+ const decks: ScannedDeck[] = []
1425
+ for (const entry of manifest.decks ?? []) {
1426
+ if (typeof entry.createDecks === "function") {
1427
+ decks.push({
1428
+ id: `${manifest.name}:__multi__`,
1429
+ hasTrigger: false,
1430
+ paginated: false,
1431
+ buttons: -1,
1432
+ internal: entry.internal ?? false,
1433
+ })
1434
+ } else if (typeof entry.createDeck === "function") {
1435
+ const trigger = (entry as { trigger?: unknown }).trigger as
1436
+ | Record<string, unknown>
1437
+ | undefined
1438
+ const hasTrigger =
1439
+ trigger !== undefined &&
1440
+ (trigger["process_name"] !== undefined ||
1441
+ trigger["window_name"] !== undefined)
1442
+ decks.push({
1443
+ id: entry.id,
1444
+ hasTrigger,
1445
+ paginated: hasTrigger,
1446
+ buttons: -1,
1447
+ internal: entry.internal ?? false,
1448
+ })
1449
+ } else {
1450
+ const trigger = (entry as { trigger?: unknown }).trigger as
1451
+ | Record<string, unknown>
1452
+ | undefined
1453
+ const hasTrigger =
1454
+ trigger !== undefined &&
1455
+ (trigger["process_name"] !== undefined ||
1456
+ trigger["window_name"] !== undefined)
1457
+ decks.push({
1458
+ id: entry.id,
1459
+ hasTrigger,
1460
+ paginated: hasTrigger,
1461
+ buttons: Array.isArray(entry.buttons) ? entry.buttons.length : 0,
1462
+ internal: entry.internal ?? false,
1463
+ })
1464
+ }
1465
+ }
1466
+ const hasButtonTypes = types.length > 0
1467
+ const hasGlobalService = manifest.globalService !== undefined
1468
+ // ponytail: both slots point to the same entry file because one module
1469
+ // exports both `manifest.buttonTypes` and `manifest.globalService`. A
1470
+ // third-party addon that supplies only decks gets `null` for both and
1471
+ // the bridge skips its frontend/global service wiring (today's behavior).
1472
+ // ponytail: frontendEntry/globalServiceEntry are imported by the DAEMON
1473
+ // (addon-handler-bridge), so they must be the Node entry. browserEntry is
1474
+ // what vite serves. Collapsing them is what broke every addon button.
1475
+ const frontendEntry = hasButtonTypes && entryPath !== "" ? entryPath : null
1476
+ const globalServiceEntry =
1477
+ hasGlobalService && entryPath !== "" ? entryPath : null
1478
+ const browserEntry =
1479
+ hasButtonTypes && browserPath !== "" ? browserPath : null
1480
+ out.push({
1481
+ name: manifest.name,
1482
+ types,
1483
+ frontendEntry,
1484
+ browserEntry,
1485
+ publishIntervalMs: manifest.publishIntervalMs ?? null,
1486
+ pollerEntry: null,
1487
+ buttonTypes,
1488
+ deckTypes: {},
1489
+ source: "json",
1490
+ globalServiceEntry,
1491
+ path,
1492
+ internal: false,
1493
+ decks,
1494
+ })
1495
+ }
1496
+ return out
1497
+ }
1498
+
1499
+ const mergeAddonByType = (
1500
+ baseByType: Map<string, AddonFrontendRef>,
1501
+ externalScanned: ReadonlyArray<ScannedAddon>,
1502
+ ): Map<string, AddonFrontendRef> => {
1503
+ const merged = new Map(baseByType)
1504
+ for (const addon of externalScanned) {
1505
+ for (const type of addon.types) {
1506
+ if (!merged.has(type)) {
1507
+ merged.set(type, {
1508
+ name: addon.name,
1509
+ frontendEntry: addon.frontendEntry,
1510
+ })
1511
+ }
1512
+ if (type === `${addon.name}:${addon.name}` && !merged.has(addon.name)) {
1513
+ merged.set(addon.name, {
1514
+ name: addon.name,
1515
+ frontendEntry: addon.frontendEntry,
1516
+ })
1517
+ }
1518
+ }
1519
+ }
1520
+ return merged
1521
+ }
1522
+
1523
+ // ponytail: extracted so tests can exercise the addon-spec-to-dir mapping
1524
+ // without spinning up the full pipeline (WS bridge, output client, etc.).
1525
+ // Reads `addons[]` from the parsed config — entries are either a raw string
1526
+ // spec or `{src, enabled?, config?}`. The basename of the resolved path
1527
+ // becomes the addon key for icon resolution; e.g. `{src: /p/chrome-overlay}`
1528
+ // maps `addon://chrome-overlay/assets/foo.svg` to `/p/chrome-overlay/assets/foo.svg`.
1529
+ export const buildExternalAddonDirs = (
1530
+ addonEntries: ReadonlyArray<unknown>,
1531
+ configPath: string,
1532
+ addonEntryPaths: ReadonlyMap<string, string> = new Map(),
1533
+ ): Map<string, string> => {
1534
+ const result = new Map<string, string>()
1535
+ // ponytail: prefer the LOADED addons. Deriving the key from the config entry
1536
+ // only works for local paths — an npm specifier like
1537
+ // `@sirenodeck/addon-app-shortcuts` resolved to a non-existent
1538
+ // `<configDir>/@sirenodeck/addon-app-shortcuts` and keyed the map on
1539
+ // "addon-app-shortcuts", while its icons say `addon://app-shortcuts/...`.
1540
+ // Every npm addon's icons therefore failed with "unknown addon dir".
1541
+ // `addonEntryPaths` is keyed by manifest name and points at the real entry
1542
+ // file, whose dirname is the `<pkg>/dist` the assets sit beside.
1543
+ const resolvedKeys = new Set(addonEntryPaths.keys())
1544
+ for (const [name, entryPath] of addonEntryPaths) {
1545
+ result.set(name, dirname(entryPath))
1546
+ }
1547
+ for (const entry of addonEntries) {
1548
+ const source =
1549
+ typeof entry === "string"
1550
+ ? entry
1551
+ : typeof entry === "object" &&
1552
+ entry !== null &&
1553
+ typeof (entry as { src?: unknown }).src === "string"
1554
+ ? (entry as { src: string }).src
1555
+ : null
1556
+ if (source === null) continue
1557
+ const expanded = source.startsWith("~/")
1558
+ ? join(homedir(), source.slice(2))
1559
+ : source
1560
+ const abs = isAbsolute(expanded)
1561
+ ? expanded
1562
+ : resolvePath(dirname(configPath), expanded)
1563
+ // ponytail: addon:// asset resolution joins this dir + the icon's
1564
+ // subpath. Addons ship assets in <pkg>/dist/assets (post-build copy),
1565
+ // so prefer dist when present — also prevents this map from
1566
+ // clobbering the correct dirname(frontendEntry) registration in
1567
+ // buildResolverOptions. Source-only addons keep their root.
1568
+ const base = existsSync(join(abs, "dist")) ? join(abs, "dist") : abs
1569
+ // Never let the config-entry guess override a RESOLVED addon's real dir,
1570
+ // but keep last-wins among the guesses themselves (two entries whose paths
1571
+ // share a basename still resolve to the later one, as before).
1572
+ if (!resolvedKeys.has(basename(abs))) result.set(basename(abs), base)
1573
+ }
1574
+ return result
1575
+ }
1576
+
1577
+ export const runPipeline = async (options: RunOptions): Promise<void> => {
1578
+ const { logger } = options
1579
+
1580
+ const xdgConfigHome = resolveXdgConfigHome(options)
1581
+
1582
+ // ponytail: hoist every long-lived resource so the unconditional cleanup
1583
+ // block below runs even when outputClient.init() throws (e.g. Vite can't
1584
+ // bind the frontend port). Without this, the heartbeat setInterval, the
1585
+ // WS bridge, providers, and onConnection listeners all stay alive after
1586
+ // the rejection — the daemon process never exits and the user reports
1587
+ // "the app gets stuck and the service never starts".
1588
+ let loadedConfig: Awaited<ReturnType<typeof validateAndLoadConfig>> | null =
1589
+ null
1590
+ let outputClient: OutputClient | null = null
1591
+ let providers: Awaited<ReturnType<typeof startSystemProviders>> | null = null
1592
+ let addonBundle: Awaited<ReturnType<typeof buildAddonBundle>> | null = null
1593
+ let bridge: Awaited<ReturnType<typeof startWsBridge>> | null = null
1594
+ let addonServices: ReturnType<typeof setupAddonServices> | null = null
1595
+ let runtime: ReturnType<typeof buildRuntime>["runtime"] | null = null
1596
+ let methods: ReturnType<typeof buildRuntime>["methods"] | null = null
1597
+ let decks: ReadonlyArray<RuntimeDeck> | null = null
1598
+ let themeDir: string | null = null
1599
+ let pubSub: PubSub | null = null
1600
+ let store: Store | null = null
1601
+ let descriptor: Awaited<ReturnType<OutputClient["selectDevice"]>> | null =
1602
+ null
1603
+ let addonServicesDispose: (() => void) | null = null
1604
+ let bridgeSignal: AbortController | null = null
1605
+ let statePublisher: StatePublisher | null = null
1606
+ let configWatcher: ConfigWatcher | null = null
1607
+ let outputHandle: OutputHandle | null = null
1608
+ let currentOutputHandle: OutputHandle | null = null
1609
+ let unregisterSignal: (() => void) | null = null
1610
+ let unregisterReload: (() => void) | null = null
1611
+ let unsubscribeLockMode: (() => void) | null = null
1612
+ let removeServiceLogListener: (() => void) | null = null
1613
+ let editorMutationService: ReturnType<
1614
+ typeof createConfigMutationService
1615
+ > | null = null
1616
+ let editorHandler: EditorMessageHandler | null = null
1617
+ let resolveDone: () => void = () => undefined
1618
+ let resolveDoneForCrash: () => void = () => undefined
1619
+ const done = new Promise<void>((resolve) => {
1620
+ resolveDone = resolve
1621
+ resolveDoneForCrash = resolve
1622
+ })
1623
+
1624
+ try {
1625
+ // Validate config first so a broken YAML exits before we ever touch
1626
+ // hardware or spawn an emulator.
1627
+ loadedConfig = await validateAndLoadConfig(options)
1628
+
1629
+ outputClient = selectOutputClient({
1630
+ emulator: options.emulator === true,
1631
+ xdgConfigHome,
1632
+ })
1633
+ const devices = await outputClient.listDevices()
1634
+ const savedDevice = loadDeviceConfig({ xdgConfigHome })
1635
+ descriptor = await outputClient.selectDevice(
1636
+ devices,
1637
+ savedDevice?.serial ?? null,
1638
+ logger,
1639
+ )
1640
+ await outputClient.storeSelection(descriptor)
1641
+
1642
+ const loaded = buildRuntime(options, loadedConfig, descriptor.keyCount)
1643
+ themeDir = loaded.themeDir
1644
+ decks = loaded.decks
1645
+ pubSub = loaded.pubSub
1646
+ runtime = loaded.runtime
1647
+ methods = loaded.methods
1648
+ store = loaded.store
1649
+
1650
+ providers = await startSystemProviders(options, runtime, methods)
1651
+
1652
+ const isCompact = outputClient.kind === "real"
1653
+
1654
+ addonBundle = await buildAddonBundle()
1655
+
1656
+ const remote = options.remote === true
1657
+ const lanAddresses = remote
1658
+ ? selectLanAddresses({ networkInterfaces: networkInterfaces })
1659
+ : []
1660
+ const lanHost = remote ? lanAddresses[0]?.address : undefined
1661
+ const bridgeHost: "127.0.0.1" | "0.0.0.0" = remote ? "0.0.0.0" : "127.0.0.1"
1662
+
1663
+ bridge = await startWsBridge({
1664
+ port: 52937,
1665
+ host: bridgeHost,
1666
+ ...(lanHost !== undefined ? { lanHost } : {}),
1667
+ expectedToken: process.env["SIRENO_TOKEN"] ?? "",
1668
+ })
1669
+
1670
+ // ponytail: register external addon dirs (from config's `addons:` list) so
1671
+ // `addon://<name>/assets/icon.png` resolves for overlay deck icons. The
1672
+ // addonDirs built from addonBundle.addonByType only contains builtins.
1673
+ const externalAddonDirs = buildExternalAddonDirs(
1674
+ loadedConfig.config.addons ?? [],
1675
+ loadedConfig.configPath,
1676
+ loadedConfig.addonEntryPaths,
1677
+ )
1678
+
1679
+ let resolverOptions = buildResolverOptions(
1680
+ addonBundle.addonByType,
1681
+ [dirname(loadedConfig.configPath)],
1682
+ externalAddonDirs,
1683
+ )
1684
+
1685
+ // ponytail: gate the n-1 system button on the session's lock state from
1686
+ // the first frame. The startup injection in buildRuntime ran before the
1687
+ // session provider existed; if the OS is locked at boot, re-inject
1688
+ // without the n-1 so the deck structure is honest. Re-inject on every
1689
+ // subsequent lock transition so the source stays correct.
1690
+ const reInjectedDecks = (isLocked: boolean): ReadonlyArray<RuntimeDeck> =>
1691
+ injectSystemButtons(loaded.sourceDecks, descriptor!.keyCount, {
1692
+ lockActive: isLocked,
1693
+ })
1694
+ const broadcastActiveDeck = (): void => {
1695
+ const activeDeck = runtime!.getActiveDeck()
1696
+ if (activeDeck === undefined) return
1697
+ const msg = buildDeckConfigMessage(
1698
+ activeDeck,
1699
+ runtimeAddonByType,
1700
+ resolverOptions,
1701
+ {
1702
+ navStackDepth: runtime!.navStackDepth(),
1703
+ hasOverlayDeckAvailable: runtime!.hasOverlayDeckAvailable(),
1704
+ inOverlayMode: runtime!.getOverlay() !== null,
1705
+ },
1706
+ descriptor!.keyCount,
1707
+ outputClient!.kind === "real",
1708
+ (fullPath) => getAssetByPath(fullPath)?.id,
1709
+ runtime!.getAvailableOverlayDeckIcon(),
1710
+ runtime!.getAvailableOverlayDeckName(),
1711
+ { lockActive: runtime!.isLockActive() },
1712
+ )
1713
+ bridge!.broadcast(msg)
1714
+ }
1715
+ if (providers.session.getState() === "locked") {
1716
+ const reInjected = reInjectedDecks(true)
1717
+ runtime.setDecks(reInjected)
1718
+ decks = reInjected
1719
+ broadcastActiveDeck()
1720
+ }
1721
+ unsubscribeLockMode = pubSub.subscribe("runtime:lock-mode", (payload) => {
1722
+ const isLocked =
1723
+ typeof payload === "object" &&
1724
+ payload !== null &&
1725
+ (payload as { active?: unknown }).active === true
1726
+ const reInjected = reInjectedDecks(isLocked)
1727
+ runtime!.setDecks(reInjected)
1728
+ decks = reInjected
1729
+ // ponytail: runtime:setDecks publishes runtime:activeDeck, but the
1730
+ // deck-id-equality dedup in the bridge subscriber skips the
1731
+ // re-broadcast when the active deck id is unchanged. Force a fresh
1732
+ // broadcast so the wire filter sees the lock state on the new decks.
1733
+ broadcastActiveDeck()
1734
+ })
1735
+
1736
+ bridgeSignal = new AbortController()
1737
+ statePublisher = new StatePublisher({
1738
+ bridge,
1739
+ logger,
1740
+ onPublish: () => pubSub?.publish("runtime:invalidate", undefined),
1741
+ })
1742
+
1743
+ const addonRegistryForSystemStatus = new AddonRegistry()
1744
+ registerSystemStatusAddon(addonRegistryForSystemStatus)
1745
+
1746
+ const mainDeck = runtime.getActiveDeck()
1747
+ const externalScanned = buildExternalScannedAddons(
1748
+ loadedConfig.registry,
1749
+ addonBundle.scanned,
1750
+ externalAddonDirs,
1751
+ loadedConfig.addonEntryPaths,
1752
+ loadedConfig.addonBrowserEntryPaths,
1753
+ )
1754
+ publishSIRENO_ADDONS(addonBundle.scanned, externalScanned)
1755
+ options.onAddonsUpdate?.([...addonBundle.scanned, ...externalScanned])
1756
+ // ponytail: ship the merged inventory to the bridge so the emulator's
1757
+ // Addons tab can render it on connect. Builtins are bundled; third-party
1758
+ // addons (from config.yml `addons:`) join here. --emulator mode never
1759
+ // binds the start-mode HTTP API on 3939, so this is the only path.
1760
+ bridge.setAddonInventory(
1761
+ (() => {
1762
+ const defaultConfigs = collectAddonDefaultButtonConfig(
1763
+ loadedConfig!.registry,
1764
+ decks ?? [],
1765
+ logger,
1766
+ )
1767
+ return [...addonBundle!.scanned, ...externalScanned].map(
1768
+ (addon, addonIndex) =>
1769
+ addonInventoryFromScanned(
1770
+ addon,
1771
+ addonIndex,
1772
+ decks ?? [],
1773
+ defaultConfigs.get(addon.name),
1774
+ ),
1775
+ )
1776
+ })(),
1777
+ )
1778
+
1779
+ const mainDeckId = mainDeck?.id ?? "main"
1780
+ bridge.setDeckTree(buildDeckTree(decks, mainDeckId))
1781
+
1782
+ // ponytail: addon-triggered deck rebuild (dynamic decks). Mirrors the
1783
+ // config hot-reload decks-only branch: rebuild the runtime deck set,
1784
+ // fall back if the active deck vanished (e.g. a page count shrank), and
1785
+ // rebroadcast deck-config so the frontend sees the new pages.
1786
+ const runtimeAddonByType = mergeAddonByType(
1787
+ addonBundle.addonByType,
1788
+ externalScanned,
1789
+ )
1790
+ const serviceDecks: RuntimeDeck[] = [...decks]
1791
+ let currentLoadedConfig = loadedConfig
1792
+ const nextMainDeckId = (
1793
+ config: typeof currentLoadedConfig.config,
1794
+ ): string =>
1795
+ config.decks["main"] !== undefined
1796
+ ? "main"
1797
+ : (Object.keys(config.decks)[0] ?? "main")
1798
+ const requestDeckRebuild = (): void => {
1799
+ if (
1800
+ runtime === null ||
1801
+ descriptor === null ||
1802
+ outputClient === null ||
1803
+ bridge === null ||
1804
+ addonBundle === null ||
1805
+ loadedConfig === null ||
1806
+ providers === null
1807
+ ) {
1808
+ return
1809
+ }
1810
+ const rebuilt = buildRuntime(
1811
+ options,
1812
+ loadedConfig,
1813
+ descriptor.keyCount,
1814
+ providers.session.getState() === "locked",
1815
+ ).decks
1816
+ decks = rebuilt
1817
+ serviceDecks.splice(0, serviceDecks.length, ...rebuilt)
1818
+ runtime.setDecks(rebuilt)
1819
+ const activeId = runtime.getActiveDeckId()
1820
+ if (!rebuilt.some((d) => d.id === activeId)) {
1821
+ const fallback = rebuilt.some((d) => d.id === "main")
1822
+ ? "main"
1823
+ : (rebuilt[0]?.id ?? activeId)
1824
+ runtime.navigateToDeck(fallback, { addToHistory: false })
1825
+ }
1826
+ for (const deck of rebuilt) {
1827
+ registerDeckIcon(deck, resolverOptions, logger)
1828
+ registerIconForDeck(deck.buttons ?? [], resolverOptions, logger)
1829
+ }
1830
+ bridge.setDeckTree(
1831
+ buildDeckTree(rebuilt, nextMainDeckId(currentLoadedConfig.config)),
1832
+ )
1833
+ const activeDeck = runtime.getActiveDeck()
1834
+ const msg = buildDeckConfigMessage(
1835
+ activeDeck,
1836
+ runtimeAddonByType,
1837
+ resolverOptions,
1838
+ {
1839
+ navStackDepth: runtime.navStackDepth(),
1840
+ hasOverlayDeckAvailable: runtime.hasOverlayDeckAvailable(),
1841
+ inOverlayMode: runtime.getOverlay() !== null,
1842
+ },
1843
+ descriptor.keyCount,
1844
+ outputClient.kind === "real",
1845
+ (fullPath) => getAssetByPath(fullPath)?.id,
1846
+ runtime.getAvailableOverlayDeckIcon(),
1847
+ runtime.getAvailableOverlayDeckName(),
1848
+ { lockActive: runtime.isLockActive() },
1849
+ )
1850
+ bridge.broadcast(msg)
1851
+ logger.info(
1852
+ {
1853
+ deckId: msg.deckId,
1854
+ buttonCount: (
1855
+ msg.surfaces[msg.deckId] as { buttons?: unknown[] } | undefined
1856
+ )?.buttons?.length,
1857
+ },
1858
+ "addon requested deck rebuild",
1859
+ )
1860
+ }
1861
+ methods?.setDeckRebuilder(requestDeckRebuild)
1862
+
1863
+ addonServices = setupAddonServices({
1864
+ runtime,
1865
+ methods,
1866
+ decks: serviceDecks,
1867
+ pubSub,
1868
+ scanned: addonBundle.scanned,
1869
+ externalAddons: externalScanned,
1870
+ // ponytail: addonBundle.addonByType only has builtins — merge in
1871
+ // third-party addons so collectActiveDeckAddonNames can resolve their
1872
+ // types and the state publisher starts their pollers.
1873
+ addonByType: runtimeAddonByType,
1874
+ executor: createActionExecutor({ host: getHostContext() }),
1875
+ statePublisher,
1876
+ bridge,
1877
+ isCompact,
1878
+ resolverOptions,
1879
+ requestDeckRebuild,
1880
+ ...(mainDeck !== undefined ? { initialDeck: mainDeck } : {}),
1881
+ signal: bridgeSignal.signal,
1882
+ store,
1883
+ logger,
1884
+ })
1885
+ addonServicesDispose = () => addonServices?.dispose()
1886
+
1887
+ for (const deck of decks) {
1888
+ registerDeckIcon(deck, resolverOptions, logger)
1889
+ registerIconForDeck(deck.buttons ?? [], resolverOptions, logger)
1890
+ }
1891
+
1892
+ bridge.onConnection((socket) => {
1893
+ // Send the full asset bundle to every new connection. The previous
1894
+ // dedupe-by-id approach caused the React frontend to render the
1895
+ // fallback icon after a hot-reload or page refresh: the FIRST
1896
+ // connection consumed the assets, every subsequent connection got
1897
+ // an empty list, and the new client started with an empty cache.
1898
+ // Assets are tiny (a 1.4KB chrome.svg), so re-sending them on
1899
+ // reconnect is cheaper than the bug.
1900
+ const allAssets = getUnsentAssets(new Set())
1901
+ if (allAssets.length > 0) {
1902
+ socket.send(
1903
+ JSON.stringify({
1904
+ type: "assets",
1905
+ deckId: mainDeck?.id ?? "",
1906
+ assets: allAssets.map((a) => ({
1907
+ id: a.id,
1908
+ filename: a.fullPath,
1909
+ src: a.src,
1910
+ })),
1911
+ }),
1912
+ )
1913
+ }
1914
+ const activeDeck = runtime!.getActiveDeck()
1915
+ if (activeDeck !== undefined) {
1916
+ const msg = buildDeckConfigMessage(
1917
+ activeDeck,
1918
+ runtimeAddonByType,
1919
+ resolverOptions,
1920
+ {
1921
+ navStackDepth: runtime!.navStackDepth(),
1922
+ hasOverlayDeckAvailable: runtime!.hasOverlayDeckAvailable(),
1923
+ inOverlayMode: runtime!.getOverlay() !== null,
1924
+ },
1925
+ descriptor!.keyCount,
1926
+ outputClient!.kind === "real",
1927
+ (fullPath) => getAssetByPath(fullPath)?.id,
1928
+ runtime!.getAvailableOverlayDeckIcon(),
1929
+ runtime!.getAvailableOverlayDeckName(),
1930
+ { lockActive: runtime!.isLockActive() },
1931
+ )
1932
+ logger.info(
1933
+ {
1934
+ deckId: msg.deckId,
1935
+ buttonCount: (
1936
+ msg.surfaces[msg.deckId] as { buttons?: unknown[] } | undefined
1937
+ )?.buttons?.length,
1938
+ },
1939
+ "orchestrator: sending deck-config",
1940
+ )
1941
+ socket.send(JSON.stringify(msg))
1942
+ }
1943
+ })
1944
+
1945
+ const onServiceLog = (entry: {
1946
+ level: string
1947
+ msg: string
1948
+ ts: number
1949
+ component?: string
1950
+ deckId?: string
1951
+ position?: number
1952
+ addonName?: string
1953
+ gesture?: "tap" | "dbl-tap" | "hold"
1954
+ keyIndex?: number
1955
+ }) => {
1956
+ bridge!.broadcast({
1957
+ type: "service-log",
1958
+ level: entry.level as
1959
+ | "debug"
1960
+ | "error"
1961
+ | "fatal"
1962
+ | "info"
1963
+ | "trace"
1964
+ | "warn",
1965
+ msg: entry.msg,
1966
+ ts: entry.ts,
1967
+ ...(entry.component !== undefined
1968
+ ? { component: entry.component }
1969
+ : {}),
1970
+ ...(entry.deckId !== undefined ? { deckId: entry.deckId } : {}),
1971
+ ...(entry.position !== undefined ? { position: entry.position } : {}),
1972
+ ...(entry.addonName !== undefined
1973
+ ? { addonName: entry.addonName }
1974
+ : {}),
1975
+ ...(entry.gesture !== undefined ? { gesture: entry.gesture } : {}),
1976
+ ...(entry.keyIndex !== undefined ? { keyIndex: entry.keyIndex } : {}),
1977
+ })
1978
+ }
1979
+ process.on("sireno:log", onServiceLog)
1980
+ removeServiceLogListener = () =>
1981
+ process.removeListener("sireno:log", onServiceLog)
1982
+
1983
+ const trackedPids = new Set<number>()
1984
+
1985
+ outputHandle = await outputClient.init({
1986
+ bridge,
1987
+ runtime,
1988
+ pubSub,
1989
+ noAutoOpen: options.noAutoOpen === true,
1990
+ store,
1991
+ decks,
1992
+ theme: {
1993
+ name: loaded.theme.name,
1994
+ apiVersion: loaded.theme.apiVersion,
1995
+ },
1996
+ themeDir,
1997
+ configPath: resolvePath(loadedConfig.configPath),
1998
+ logger,
1999
+ rebuildDecksForKeyCount: (keyCount: number) =>
2000
+ buildRuntime(
2001
+ options,
2002
+ currentLoadedConfig,
2003
+ keyCount,
2004
+ providers?.session.getState() === "locked",
2005
+ ).decks,
2006
+ onChildPid: (pid) => {
2007
+ if (trackedPids.has(pid)) return
2008
+ trackedPids.add(pid)
2009
+ options.onChildren?.([...trackedPids])
2010
+ },
2011
+ onChildCrash: () => {
2012
+ logger.fatal(
2013
+ "supervised vite child exhausted its retry budget, shutting down",
2014
+ )
2015
+ resolveDoneForCrash()
2016
+ },
2017
+ ...(options.frontendUrl !== undefined
2018
+ ? { frontendUrl: options.frontendUrl }
2019
+ : {}),
2020
+ ...(options.port !== undefined ? { port: options.port } : {}),
2021
+ ...(options.intervalMs !== undefined
2022
+ ? { intervalMs: options.intervalMs }
2023
+ : {}),
2024
+ remote: options.remote === true,
2025
+ lanHost,
2026
+ lanAddresses: lanAddresses.map((a) => a.address),
2027
+ })
2028
+
2029
+ currentOutputHandle = outputHandle
2030
+ let refreshQueue = Promise.resolve()
2031
+
2032
+ // Note: do NOT overwrite the accumulated `trackedPids` Set with the initial
2033
+ // `outputHandle.childPids` snapshot — that erased the respawned-PID entries
2034
+ // and orphaned the live vite after the supervisor's respawn. The
2035
+ // `onChildPid` callback above already added every spawned PID (initial +
2036
+ // respawns) into `trackedPids` and reported it via `options.onChildren`,
2037
+ // so the children file stays complete without this second write.
2038
+
2039
+ editorMutationService = createConfigMutationService({
2040
+ configPath: loadedConfig.configPath,
2041
+ })
2042
+
2043
+ const refreshConfig = async (notifyEditor = true): Promise<void> => {
2044
+ const nextLoaded = await validateAndLoadConfig(options)
2045
+ if (!configChanged(currentLoadedConfig.config, nextLoaded.config)) return
2046
+
2047
+ const previousConfig = currentLoadedConfig.config
2048
+ const wasActive = runtime!.getActiveDeckId()
2049
+ const nextRuntime = buildRuntime(
2050
+ options,
2051
+ nextLoaded,
2052
+ descriptor!.keyCount,
2053
+ providers?.session.getState() === "locked",
2054
+ )
2055
+ runtime!.setDecks(nextRuntime.decks)
2056
+ decks = nextRuntime.decks
2057
+ serviceDecks.splice(0, serviceDecks.length, ...nextRuntime.decks)
2058
+ currentLoadedConfig = nextLoaded
2059
+
2060
+ const nextExternal = buildExternalScannedAddons(
2061
+ nextLoaded.registry,
2062
+ addonBundle!.scanned,
2063
+ buildExternalAddonDirs(
2064
+ nextLoaded.config.addons ?? [],
2065
+ nextLoaded.configPath,
2066
+ nextLoaded.addonEntryPaths,
2067
+ ),
2068
+ nextLoaded.addonEntryPaths,
2069
+ nextLoaded.addonBrowserEntryPaths,
2070
+ )
2071
+ runtimeAddonByType.clear()
2072
+ for (const [type, ref] of mergeAddonByType(
2073
+ addonBundle!.addonByType,
2074
+ nextExternal,
2075
+ ))
2076
+ runtimeAddonByType.set(type, ref)
2077
+ resolverOptions = buildResolverOptions(
2078
+ runtimeAddonByType,
2079
+ [dirname(nextLoaded.configPath)],
2080
+ buildExternalAddonDirs(
2081
+ nextLoaded.config.addons ?? [],
2082
+ nextLoaded.configPath,
2083
+ nextLoaded.addonEntryPaths,
2084
+ ),
2085
+ )
2086
+ bridge!.setActiveTheme?.({ name: nextLoaded.theme.name })
2087
+ bridge!.setAddonInventory(
2088
+ [...addonBundle!.scanned, ...nextExternal].map((addon, addonIndex) =>
2089
+ addonInventoryFromScanned(
2090
+ addon,
2091
+ addonIndex,
2092
+ nextRuntime.decks,
2093
+ collectAddonDefaultButtonConfig(
2094
+ nextLoaded.registry,
2095
+ nextRuntime.decks,
2096
+ logger,
2097
+ ).get(addon.name),
2098
+ ),
2099
+ ),
2100
+ )
2101
+ bridge!.setDeckTree(
2102
+ buildDeckTree(nextRuntime.decks, nextMainDeckId(nextLoaded.config)),
2103
+ )
2104
+ for (const deck of nextRuntime.decks) {
2105
+ registerDeckIcon(deck, resolverOptions, logger)
2106
+ registerIconForDeck(deck.buttons ?? [], resolverOptions, logger)
2107
+ }
2108
+
2109
+ // setDecks broadcasts when the active projection changed. Otherwise this
2110
+ // is the one normal deck-config frame for the refresh.
2111
+ if (runtime!.getActiveDeckId() === wasActive) broadcastActiveDeck()
2112
+ if (
2113
+ JSON.stringify(previousConfig.theme) !==
2114
+ JSON.stringify(nextLoaded.config.theme) ||
2115
+ JSON.stringify(previousConfig.addons) !==
2116
+ JSON.stringify(nextLoaded.config.addons) ||
2117
+ JSON.stringify(previousConfig.lock) !==
2118
+ JSON.stringify(nextLoaded.config.lock)
2119
+ ) {
2120
+ bridge!.broadcast({ type: "iframe-reload" })
2121
+ }
2122
+ if (notifyEditor) editorHandler?.invalidate()
2123
+ }
2124
+
2125
+ const handleConfigChange = (notifyEditor = true): Promise<void> => {
2126
+ const task = refreshQueue.then(
2127
+ () => refreshConfig(notifyEditor),
2128
+ () => refreshConfig(notifyEditor),
2129
+ )
2130
+ refreshQueue = task.then(
2131
+ () => undefined,
2132
+ () => undefined,
2133
+ )
2134
+ return task.catch((err: unknown) => {
2135
+ logger.warn(
2136
+ { err: err instanceof Error ? err.message : String(err) },
2137
+ "config refresh failed; keeping previous config",
2138
+ )
2139
+ })
2140
+ }
2141
+
2142
+ // Hot-reload and editor writes both enter refreshConfig. The mutation
2143
+ // handler waits for this promise, so a watcher cannot race a just-written file.
2144
+ configWatcher = new ConfigWatcher(editorMutationService.sources(), {
2145
+ onChange: () => {
2146
+ void handleConfigChange()
2147
+ },
2148
+ // ponytail: chokidar emits "unlink" when the watched file
2149
+ // disappears — e.g. the worktree holding the config was removed
2150
+ // out from under the daemon. Without this hook the daemon keeps
2151
+ // running with stale state and the next config-touch throws a
2152
+ // confusing `Config file not found`. Shut down cleanly so the
2153
+ // operator gets one obvious message instead of a flood.
2154
+ onUnlink: (path) => {
2155
+ logger.fatal(
2156
+ { configPath: path },
2157
+ "config file was deleted (worktree removed?) — shutting down daemon; restart with a valid config",
2158
+ )
2159
+ resolveDoneForCrash()
2160
+ },
2161
+ })
2162
+ editorHandler = createEditorMessageHandler({
2163
+ mutationService: editorMutationService,
2164
+ getState: () => ({
2165
+ config: currentLoadedConfig.config,
2166
+ sources: editorMutationService!.sources(),
2167
+ sourceContents: Object.fromEntries(
2168
+ editorMutationService!
2169
+ .sources()
2170
+ .map((source) => [source, readFileSync(source, "utf8")]),
2171
+ ),
2172
+ themes: (
2173
+ currentLoadedConfig.registry.listThemes?.() ?? [
2174
+ { name: currentLoadedConfig.theme.name },
2175
+ ]
2176
+ ).map((theme) => ({
2177
+ name: theme.name,
2178
+ active: theme.name === currentLoadedConfig.theme.name,
2179
+ })),
2180
+ buttonSchemas: serializeButtonSchemas(currentLoadedConfig.registry),
2181
+ surfaces: runtime!.getEditorSurfaces({
2182
+ keyCount: descriptor!.keyCount,
2183
+ }),
2184
+ }),
2185
+ validateConfig: (buttonType, config) =>
2186
+ validateButton(
2187
+ { type: buttonType, config: config as Record<string, unknown> },
2188
+ currentLoadedConfig.registry,
2189
+ "button",
2190
+ ).issues.map((issue) => issue.message),
2191
+ onChanged: () => handleConfigChange(false),
2192
+ broadcast: (message) => bridge!.broadcast(message),
2193
+ })
2194
+ bridge.onMessage(editorHandler.onMessage)
2195
+ bridge.onMessage((message) => {
2196
+ if (message.type !== "select-deck") return
2197
+ const selected = decks?.find((deck) => deck.id === message.deckId)
2198
+ if (selected === undefined) return
2199
+ if (selected.isOverlay === true) {
2200
+ runtime?.setOverlay(selected.id, { source: "manual" })
2201
+ } else {
2202
+ runtime?.navigateToDeck(selected.id)
2203
+ }
2204
+ })
2205
+ bridge.onConnection(editorHandler.onConnection)
2206
+ await configWatcher.start({ ignoreInitial: true })
2207
+
2208
+ const signals = options.signals ?? defaultSignals
2209
+ unregisterSignal = signals.onSignal(() => {
2210
+ logger.info("received signal, shutting down")
2211
+ resolveDone()
2212
+ })
2213
+ // ponytail: SIGUSR1 is the in-place-reload signal. The reload command
2214
+ // (CLI side) and `systemctl reload-or-restart` both send it. The
2215
+ // handler triggers runtime.invalidate() which publishes
2216
+ // `runtime:invalidate` on the pubsub — the BrowserRenderer subscribes
2217
+ // to that and triggers a screenshot tick, so connected frontends
2218
+ // re-render on demand without a full preflight re-run.
2219
+ unregisterReload = signals.onReload(() => {
2220
+ logger.info("runtime: reloaded via SIGUSR1")
2221
+ // runtime is non-null here: buildRuntime assigned it at line ~1379,
2222
+ // and the reload handler only fires after this point.
2223
+ if (runtime !== null) runtime.invalidate()
2224
+ })
2225
+
2226
+ await done
2227
+ } finally {
2228
+ if (unregisterSignal !== null) unregisterSignal()
2229
+ if (unregisterReload !== null) unregisterReload()
2230
+ if (bridgeSignal !== null) bridgeSignal.abort()
2231
+ if (addonServicesDispose !== null) addonServicesDispose()
2232
+ if (unsubscribeLockMode !== null) unsubscribeLockMode()
2233
+ if (statePublisher !== null) statePublisher.stopAll()
2234
+ if (
2235
+ options.emulator !== true &&
2236
+ currentOutputHandle !== null &&
2237
+ typeof currentOutputHandle.pushBlackFrame === "function"
2238
+ ) {
2239
+ try {
2240
+ await currentOutputHandle.pushBlackFrame()
2241
+ } catch (err) {
2242
+ logger.warn({ err: (err as Error).message }, "pushBlackFrame failed")
2243
+ }
2244
+ }
2245
+ if (removeServiceLogListener !== null) removeServiceLogListener()
2246
+ if (configWatcher !== null) {
2247
+ try {
2248
+ await configWatcher.close()
2249
+ } catch {
2250
+ // already closed or failed during init — ignore
2251
+ }
2252
+ }
2253
+ await Promise.allSettled([
2254
+ currentOutputHandle !== null
2255
+ ? currentOutputHandle.stop()
2256
+ : Promise.resolve(),
2257
+ runtime !== null ? runtime.stopActiveAppPolling() : Promise.resolve(),
2258
+ providers !== null ? providers.activeApp.stop() : Promise.resolve(),
2259
+ providers !== null ? providers.session.stop() : Promise.resolve(),
2260
+ providers !== null ? providers.keyMacro.stop() : Promise.resolve(),
2261
+ bridge !== null ? bridge.close() : Promise.resolve(),
2262
+ ])
2263
+ logger.info("shutdown complete")
2264
+ }
2265
+ }
2266
+
2267
+ void resolveXdgConfigHome
2268
+ void exec
2269
+
2270
+ export const run = runPipeline