@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,1021 @@
1
+ import { execFile as execFileCb } from "node:child_process"
2
+ import { existsSync } from "node:fs"
3
+ import { readFile, readdir, statfs } from "node:fs/promises"
4
+ import { cpus, freemem, loadavg, totalmem, uptime as osUptime } from "node:os"
5
+ import { promisify } from "node:util"
6
+
7
+ import {
8
+ SYSTEM_METRIC_IDS,
9
+ type SystemMetricId,
10
+ type SystemMetricSnapshot,
11
+ } from "../shared/metrics-catalog"
12
+
13
+ // ponytail: vite-plugin-oxc (vitest transform) doesn't recognise
14
+ // `node:child_process/promises` as a built-in module specifier. The
15
+ // promisify path works on every Node ≥14 with zero extra deps.
16
+ const execFile = promisify(execFileCb)
17
+
18
+ export { SYSTEM_METRIC_IDS }
19
+
20
+ interface ProbeResult {
21
+ available: boolean
22
+ max?: number
23
+ percentage?: number
24
+ unit?: string
25
+ value?: number
26
+ }
27
+
28
+ function clampPercent(value: number): number {
29
+ if (!Number.isFinite(value)) return 0
30
+ return Math.min(100, Math.max(0, Math.round(value)))
31
+ }
32
+
33
+ // ponytail: `c.times` from `node:os` are cumulative jiffies since boot — not
34
+ // instantaneous. Two back-to-back readings give nearly identical values
35
+ // because the ratio barely moves; the metric would look frozen. We cache
36
+ // the previous (idle, total) sample and report `(Δtotal - Δidle) / Δtotal`
37
+ // as instant CPU usage over the inter-poll window (~1s). First sample has
38
+ // no baseline → returns 0 to avoid a misleading one-shot.
39
+ let prevCpuSample: { idle: number; total: number } | null = null
40
+
41
+ async function probeCpu(): Promise<ProbeResult> {
42
+ const list = cpus()
43
+ if (list.length === 0) return { available: false }
44
+ let idle = 0
45
+ let total = 0
46
+ for (const c of list) {
47
+ idle += c.times.idle
48
+ total +=
49
+ c.times.user + c.times.nice + c.times.sys + c.times.idle + c.times.irq
50
+ }
51
+ const prev = prevCpuSample
52
+ // Counter reset (boot, container restart) or first sample → re-baseline.
53
+ if (prev === null || total <= prev.total) {
54
+ prevCpuSample = { idle, total }
55
+ return { available: true, max: 100, percentage: 0, unit: "%", value: 0 }
56
+ }
57
+ const dTotal = total - prev.total
58
+ const dIdle = idle - prev.idle
59
+ prevCpuSample = { idle, total }
60
+ const pct = dTotal > 0 ? clampPercent((1 - dIdle / dTotal) * 100) : 0
61
+ return { available: true, max: 100, percentage: pct, unit: "%", value: pct }
62
+ }
63
+
64
+ async function probeRam(): Promise<ProbeResult> {
65
+ const total = totalmem()
66
+ const used = total - freemem()
67
+ if (total <= 0) return { available: false, unit: "%" }
68
+ const pct = clampPercent((used / total) * 100)
69
+ return { available: true, max: total, percentage: pct, unit: "%", value: pct }
70
+ }
71
+
72
+ // ponytail: macOS has no /proc, so the Linux sysfs readers below all bailed
73
+ // and every metric except cpu/ram/disk/uptime/load/frequency reported
74
+ // unavailable — which is most of the default deck's first button. These
75
+ // helpers fill in the ones macOS does expose through documented CLIs. Each
76
+ // returns null on any parse failure so the caller can report unavailable
77
+ // rather than a wrong number.
78
+ const DARWIN_CMD_TIMEOUT_MS = 2_000
79
+
80
+ const darwinRun = async (
81
+ binary: string,
82
+ args: ReadonlyArray<string>,
83
+ ): Promise<string | null> => {
84
+ try {
85
+ const { stdout } = await execFile(binary, [...args], {
86
+ timeout: DARWIN_CMD_TIMEOUT_MS,
87
+ signal: AbortSignal.timeout(DARWIN_CMD_TIMEOUT_MS),
88
+ })
89
+ return stdout
90
+ } catch {
91
+ return null
92
+ }
93
+ }
94
+
95
+ // `sysctl -n vm.swapusage` →
96
+ // "total = 15360.00M used = 14146.44M free = 1213.56M (encrypted)"
97
+ const SWAP_FIELD = (name: string): RegExp =>
98
+ new RegExp(`${name}\\s*=\\s*([\\d.]+)([KMG])`, "i")
99
+
100
+ const SWAP_UNIT_BYTES: Readonly<Record<string, number>> = {
101
+ K: 1024,
102
+ M: 1024 ** 2,
103
+ G: 1024 ** 3,
104
+ }
105
+
106
+ export const parseDarwinSwap = (
107
+ raw: string,
108
+ ): { totalBytes: number; usedBytes: number } | null => {
109
+ const total = SWAP_FIELD("total").exec(raw)
110
+ const used = SWAP_FIELD("used").exec(raw)
111
+ if (total?.[1] === undefined || used?.[1] === undefined) return null
112
+ const toBytes = (m: RegExpExecArray): number =>
113
+ Number.parseFloat(m[1]!) *
114
+ (SWAP_UNIT_BYTES[(m[2] ?? "M").toUpperCase()] ?? 0)
115
+ const totalBytes = toBytes(total)
116
+ const usedBytes = toBytes(used)
117
+ if (!Number.isFinite(totalBytes) || totalBytes <= 0) return null
118
+ return { totalBytes, usedBytes }
119
+ }
120
+
121
+ // `pmset -g batt` →
122
+ // " -InternalBattery-0 (id=...)\t100%; charged; 0:00 remaining present: true"
123
+ export const parseDarwinBattery = (raw: string): number | null => {
124
+ const m = /(\d{1,3})%/.exec(raw)
125
+ if (m?.[1] === undefined) return null
126
+ const pct = Number.parseInt(m[1], 10)
127
+ return Number.isFinite(pct) ? pct : null
128
+ }
129
+
130
+ // `netstat -ibn` — one row per interface/address. The <Link#N> rows carry the
131
+ // per-interface totals; the address rows repeat the same counters, so summing
132
+ // every row would double-count. Skip loopback and virtual interfaces to match
133
+ // the Linux reader's NETWORK_IFACE_SKIP_RE.
134
+ export const parseDarwinNetstat = (
135
+ raw: string,
136
+ ): { rx: number; tx: number } | null => {
137
+ const lines = raw.trim().split("\n")
138
+ const header = lines[0]?.trim().split(/\s+/) ?? []
139
+ const ibytes = header.indexOf("Ibytes")
140
+ const obytes = header.indexOf("Obytes")
141
+ if (ibytes === -1 || obytes === -1) return null
142
+ let rx = 0
143
+ let tx = 0
144
+ let counted = false
145
+ for (const line of lines.slice(1)) {
146
+ const cols = line.trim().split(/\s+/)
147
+ const name = cols[0]
148
+ if (name === undefined) continue
149
+ if (NETWORK_IFACE_SKIP_RE.test(name)) continue
150
+ // Only the <Link#N> rows, so per-address duplicates aren't summed twice.
151
+ if (!(cols[2] ?? "").startsWith("<Link")) continue
152
+ const i = Number.parseInt(cols[ibytes] ?? "", 10)
153
+ const o = Number.parseInt(cols[obytes] ?? "", 10)
154
+ if (!Number.isFinite(i) || !Number.isFinite(o)) continue
155
+ rx += i
156
+ tx += o
157
+ counted = true
158
+ }
159
+ return counted ? { rx, tx } : null
160
+ }
161
+
162
+ // ponytail: Linux reads /proc/meminfo SwapTotal/SwapFree in kB; macOS uses
163
+ // sysctl vm.swapusage. Other platforms report unavailable rather than guess.
164
+ async function probeSwap(): Promise<ProbeResult> {
165
+ if (process.platform === "darwin") {
166
+ const raw = await darwinRun("sysctl", ["-n", "vm.swapusage"])
167
+ const parsed = raw === null ? null : parseDarwinSwap(raw)
168
+ if (parsed === null) return { available: false, unit: "%" }
169
+ const pct = clampPercent((parsed.usedBytes / parsed.totalBytes) * 100)
170
+ return {
171
+ available: true,
172
+ max: parsed.totalBytes,
173
+ percentage: pct,
174
+ unit: "%",
175
+ value: pct,
176
+ }
177
+ }
178
+ if (process.platform !== "linux") return { available: false, unit: "%" }
179
+ if (!existsSync("/proc/meminfo")) return { available: false, unit: "%" }
180
+ try {
181
+ const raw = await readFile("/proc/meminfo", "utf8")
182
+ const totalKb = Number.parseInt(
183
+ /SwapTotal:\s*(\d+)\s*kB/.exec(raw)?.[1] ?? "",
184
+ 10,
185
+ )
186
+ const freeKb = Number.parseInt(
187
+ /SwapFree:\s*(\d+)\s*kB/.exec(raw)?.[1] ?? "",
188
+ 10,
189
+ )
190
+ if (!Number.isFinite(totalKb) || totalKb <= 0) {
191
+ return { available: false, unit: "%" }
192
+ }
193
+ const totalBytes = totalKb * 1024
194
+ const usedBytes = Math.max(0, (totalKb - freeKb) * 1024)
195
+ const pct = clampPercent((usedBytes / totalBytes) * 100)
196
+ return {
197
+ available: true,
198
+ max: totalBytes,
199
+ percentage: pct,
200
+ unit: "%",
201
+ value: pct,
202
+ }
203
+ } catch {
204
+ return { available: false, unit: "%" }
205
+ }
206
+ }
207
+
208
+ async function probeDisk(): Promise<ProbeResult> {
209
+ try {
210
+ const stats = await statfs("/")
211
+ const total = Number(stats.blocks) * Number(stats.bsize)
212
+ const used =
213
+ (Number(stats.blocks) - Number(stats.bfree)) * Number(stats.bsize)
214
+ if (total <= 0) return { available: false, unit: "%" }
215
+ const pct = clampPercent((used / total) * 100)
216
+ return {
217
+ available: true,
218
+ max: total,
219
+ percentage: pct,
220
+ unit: "%",
221
+ value: pct,
222
+ }
223
+ } catch {
224
+ return { available: false, unit: "%" }
225
+ }
226
+ }
227
+
228
+ async function probeNetwork(): Promise<ProbeResult> {
229
+ // ponytail: cheap & portable. Real throughput needs /proc/net/dev per-interface counters.
230
+ const count = cpus().length > 0 ? 1 : 0
231
+ return { available: count > 0, unit: "inter.", value: count }
232
+ }
233
+
234
+ async function probeBattery(): Promise<ProbeResult> {
235
+ if (process.platform === "darwin") {
236
+ const raw = await darwinRun("pmset", ["-g", "batt"])
237
+ const pct = raw === null ? null : parseDarwinBattery(raw)
238
+ if (pct === null) return { available: false, unit: "%" }
239
+ const clamped = clampPercent(pct)
240
+ return {
241
+ available: true,
242
+ max: 100,
243
+ percentage: clamped,
244
+ unit: "%",
245
+ value: clamped,
246
+ }
247
+ }
248
+ // Linux: /sys/class/power_supply/BAT0/capacity
249
+ if (process.platform !== "linux") return { available: false, unit: "%" }
250
+ if (!existsSync("/sys/class/power_supply/BAT0/capacity")) {
251
+ return { available: false, unit: "%" }
252
+ }
253
+ try {
254
+ const raw = await readFile("/sys/class/power_supply/BAT0/capacity", "utf8")
255
+ const pct = clampPercent(Number.parseInt(raw.trim(), 10))
256
+ return { available: true, max: 100, percentage: pct, unit: "%", value: pct }
257
+ } catch {
258
+ return { available: false, unit: "%" }
259
+ }
260
+ }
261
+
262
+ // ponytail: scan /sys/class/hwmon for CPU-package sensors and read
263
+ // temp1_input. Skip acpitz — the thermal_zone0 fallback below covers it,
264
+ // and acpitz often sorts first (hwmon0), shadowing k10temp/coretemp.
265
+ // ponytail: CPU/GPU temperature and fan RPM have no unprivileged source on
266
+ // Apple Silicon from the tools already in use here — `powermetrics` demands
267
+ // root, and the only readable "Temperature" key in ioreg belongs to
268
+ // AppleSmartBattery, which is the battery's, not the SoC's. `macmon` reads
269
+ // them through IOReport without sudo ("Sudoless performance monitoring for
270
+ // Apple Silicon", brew install macmon). It is optional: absent, these metrics
271
+ // keep reporting unavailable exactly as before.
272
+ //
273
+ // Same shape as the media addon preferring `media-control` and brightness
274
+ // preferring `brightness` — an optional helper that unlocks a platform, never
275
+ // a hard dependency.
276
+ interface MacmonSample {
277
+ readonly cpuTemp: number | null
278
+ readonly gpuTemp: number | null
279
+ readonly fanRpm: number | null
280
+ }
281
+
282
+ const MACMON_SHARE_MS = 2_000
283
+ let sharedMacmon: { at: number; value: MacmonSample | null } | null = null
284
+ let inFlightMacmon: Promise<MacmonSample | null> | null = null
285
+
286
+ const finite = (v: unknown): number | null =>
287
+ typeof v === "number" && Number.isFinite(v) ? v : null
288
+
289
+ export const parseMacmonSample = (raw: string): MacmonSample | null => {
290
+ // `macmon pipe` emits one JSON object per sample, newline delimited.
291
+ const line = raw.split("\n").find((l) => l.trim().startsWith("{"))
292
+ if (line === undefined) return null
293
+ let data: Record<string, unknown>
294
+ try {
295
+ data = JSON.parse(line) as Record<string, unknown>
296
+ } catch {
297
+ return null
298
+ }
299
+ const temp = (data["temp"] ?? {}) as Record<string, unknown>
300
+ const fans = Array.isArray(data["fans"]) ? data["fans"] : []
301
+ // A machine with several fans reports the fastest — the one that is doing
302
+ // the work. A passively cooled Mac reports an empty array, not zero.
303
+ let fanRpm: number | null = null
304
+ for (const fan of fans) {
305
+ const rpm = finite((fan as Record<string, unknown>)["rpm"])
306
+ if (rpm !== null && rpm > 0) fanRpm = Math.max(fanRpm ?? 0, rpm)
307
+ }
308
+ return {
309
+ cpuTemp: finite(temp["cpu_temp_avg"]),
310
+ gpuTemp: finite(temp["gpu_temp_avg"]),
311
+ fanRpm,
312
+ }
313
+ }
314
+
315
+ async function readMacmon(): Promise<MacmonSample | null> {
316
+ const now = Date.now()
317
+ if (sharedMacmon !== null && now - sharedMacmon.at < MACMON_SHARE_MS) {
318
+ return sharedMacmon.value
319
+ }
320
+ // Three metrics read one sample; collapse concurrent probes onto one spawn.
321
+ if (inFlightMacmon !== null) return inFlightMacmon
322
+ inFlightMacmon = (async () => {
323
+ // --samples 1 makes it exit on its own; the interval is the settle time
324
+ // before the first reading, well inside darwinRun's 2s ceiling.
325
+ const raw = await darwinRun("macmon", [
326
+ "pipe",
327
+ "--samples",
328
+ "1",
329
+ "--interval",
330
+ "200",
331
+ ])
332
+ const value = raw === null ? null : parseMacmonSample(raw)
333
+ sharedMacmon = { at: Date.now(), value }
334
+ return value
335
+ })().finally(() => {
336
+ inFlightMacmon = null
337
+ })
338
+ return inFlightMacmon
339
+ }
340
+
341
+ async function probeTemperature(): Promise<ProbeResult> {
342
+ if (process.platform === "darwin") {
343
+ const sample = await readMacmon()
344
+ const value = sample?.cpuTemp ?? null
345
+ return value === null
346
+ ? { available: false, unit: "°C" }
347
+ : { available: true, value, unit: "°C" }
348
+ }
349
+ if (process.platform !== "linux") return { available: false, unit: "°C" }
350
+
351
+ try {
352
+ if (existsSync("/sys/class/hwmon")) {
353
+ const entries = await readdir("/sys/class/hwmon")
354
+ const hwmons: Array<{ dir: string; name: string }> = []
355
+ for (const e of entries) {
356
+ const namePath = `/sys/class/hwmon/${e}/name`
357
+ if (!existsSync(namePath)) continue
358
+ const name = (await readFile(namePath, "utf8")).trim()
359
+ hwmons.push({ dir: `/sys/class/hwmon/${e}`, name })
360
+ }
361
+ const matched = hwmons
362
+ .map((h, i) => ({ h, i }))
363
+ .filter(({ h }) =>
364
+ CPU_HWMON_NAME_PATTERNS.some((re) => re.test(h.name)),
365
+ )
366
+ .filter(({ h }) => !/^acpitz$/i.test(h.name))
367
+ .sort((a, b) => a.i - b.i)[0]
368
+ if (matched) {
369
+ const temp = `${matched.h.dir}/temp1_input`
370
+ if (existsSync(temp)) {
371
+ const raw = await readFile(temp, "utf8")
372
+ const milli = Number.parseInt(raw.trim(), 10)
373
+ if (Number.isFinite(milli) && milli > 0) {
374
+ return {
375
+ available: true,
376
+ unit: "°C",
377
+ value: Math.round(milli / 1000),
378
+ }
379
+ }
380
+ }
381
+ }
382
+ }
383
+ } catch {
384
+ // fall through to thermal_zone0
385
+ }
386
+
387
+ // Fallback: /sys/class/thermal/thermal_zone0/temp (millidegrees C)
388
+ if (!existsSync("/sys/class/thermal/thermal_zone0/temp")) {
389
+ return { available: false, unit: "°C" }
390
+ }
391
+ try {
392
+ const raw = await readFile("/sys/class/thermal/thermal_zone0/temp", "utf8")
393
+ const milli = Number.parseInt(raw.trim(), 10)
394
+ if (!Number.isFinite(milli)) return { available: false, unit: "°C" }
395
+ return { available: true, unit: "°C", value: Math.round(milli / 1000) }
396
+ } catch {
397
+ return { available: false, unit: "°C" }
398
+ }
399
+ }
400
+
401
+ async function probeUptime(): Promise<ProbeResult> {
402
+ const sec = Math.round(osUptime())
403
+ return { available: true, value: sec }
404
+ }
405
+
406
+ async function probeFrequency(): Promise<ProbeResult> {
407
+ // ponytail: os.cpus()[i].speed is unreliable on Linux (often 0). Fall back to /proc/cpuinfo MHz.
408
+ const list = cpus()
409
+ const fromOs = list.find(
410
+ (c) => typeof c.speed === "number" && c.speed > 0,
411
+ )?.speed
412
+ if (typeof fromOs === "number" && fromOs > 0) {
413
+ const ghz = Number((fromOs / 1000).toFixed(2))
414
+ return { available: true, unit: "GHz", value: ghz }
415
+ }
416
+ if (process.platform === "linux" && existsSync("/proc/cpuinfo")) {
417
+ try {
418
+ const raw = await readFile("/proc/cpuinfo", "utf8")
419
+ const m = raw.match(/cpu MHz\s*:\s*([\d.]+)/)
420
+ if (m && m[1]) {
421
+ const mhz = Number.parseFloat(m[1])
422
+ if (Number.isFinite(mhz) && mhz > 0) {
423
+ const ghz = Number((mhz / 1000).toFixed(2))
424
+ return { available: true, unit: "GHz", value: ghz }
425
+ }
426
+ }
427
+ } catch {
428
+ // fall through
429
+ }
430
+ }
431
+ return { available: false, unit: "GHz" }
432
+ }
433
+
434
+ async function probeLoad(): Promise<ProbeResult> {
435
+ const [one] = loadavg()
436
+ if (one === undefined || !Number.isFinite(one)) {
437
+ return { available: false }
438
+ }
439
+ return { available: true, value: Number(one.toFixed(2)) }
440
+ }
441
+
442
+ async function probeProcesses(): Promise<ProbeResult> {
443
+ if (process.platform === "linux" && existsSync("/proc")) {
444
+ try {
445
+ const entries = await readdir("/proc")
446
+ const count = entries.filter((e) => /^\d+$/.test(e)).length
447
+ return { available: count > 0, unit: "procs", value: count }
448
+ } catch {
449
+ return { available: false, unit: "procs" }
450
+ }
451
+ }
452
+ if (process.platform === "darwin") {
453
+ const raw = await darwinRun("ps", ["-Ao", "pid="])
454
+ if (raw === null) return { available: false, unit: "procs" }
455
+ const count = raw.split("\n").filter((l) => l.trim().length > 0).length
456
+ return { available: count > 0, unit: "procs", value: count }
457
+ }
458
+ // ponytail: no portable process count without a dep; report unavailable elsewhere.
459
+ return { available: false, unit: "procs" }
460
+ }
461
+
462
+ // ponytail: cpu-boost has two kernel interfaces. cpufreq/boost is the modern
463
+ // unified path (1 = enabled, 0 = disabled). Older Intel systems use
464
+ // intel_pstate/no_turbo with inverted semantics (0 = boost on). Either
465
+ // wins; otherwise the platform just doesn't expose boost and we return
466
+ // unavailable rather than guess.
467
+ async function probeCpuBoost(): Promise<ProbeResult> {
468
+ // ponytail: deliberately not implemented for darwin. Apple Silicon has no
469
+ // turbo toggle to report — there is no equivalent of cpufreq/boost or
470
+ // intel_pstate/no_turbo — so there is nothing to read, with or without
471
+ // macmon. Same for cpu-voltages below: no exposed voltage rail.
472
+ if (process.platform !== "linux") {
473
+ return { available: false, unit: "" }
474
+ }
475
+ try {
476
+ if (existsSync("/sys/devices/system/cpu/cpufreq/boost")) {
477
+ const raw = await readFile(
478
+ "/sys/devices/system/cpu/cpufreq/boost",
479
+ "utf8",
480
+ )
481
+ const n = Number.parseInt(raw.trim(), 10)
482
+ if (n === 0 || n === 1) {
483
+ return { available: true, unit: "", value: n }
484
+ }
485
+ }
486
+ if (existsSync("/sys/devices/system/cpu/intel_pstate/no_turbo")) {
487
+ const raw = await readFile(
488
+ "/sys/devices/system/cpu/intel_pstate/no_turbo",
489
+ "utf8",
490
+ )
491
+ const n = Number.parseInt(raw.trim(), 10)
492
+ if (n === 0 || n === 1) {
493
+ return { available: true, unit: "", value: n === 0 ? 1 : 0 }
494
+ }
495
+ }
496
+ } catch {
497
+ // fall through
498
+ }
499
+ return { available: false, unit: "" }
500
+ }
501
+
502
+ const CPU_HWMON_NAME_PATTERNS = [
503
+ /^k10temp$/i,
504
+ /^zenpower$/i,
505
+ /^coretemp$/i,
506
+ /^cpu\b/i,
507
+ /^acpitz$/i,
508
+ ]
509
+
510
+ // ponytail: scanning /sys/class/hwmon and matching by name keeps us vendor-
511
+ // agnostic. AMD (k10temp/zenpower), Intel (coretemp), and ACPI thermal
512
+ // zones all expose CPU voltage on `in0_input` (millivolts). Pick the first
513
+ // match by preference order; if the system has none, report unavailable
514
+ // rather than guess.
515
+ async function probeCpuVoltages(): Promise<ProbeResult> {
516
+ if (process.platform !== "linux") {
517
+ return { available: false, unit: "V" }
518
+ }
519
+ if (!existsSync("/sys/class/hwmon")) {
520
+ return { available: false, unit: "V" }
521
+ }
522
+ try {
523
+ const entries = await readdir("/sys/class/hwmon")
524
+ const hwmons: Array<{ dir: string; name: string }> = []
525
+ for (const e of entries) {
526
+ const namePath = `/sys/class/hwmon/${e}/name`
527
+ if (!existsSync(namePath)) continue
528
+ const name = (await readFile(namePath, "utf8")).trim()
529
+ hwmons.push({ dir: `/sys/class/hwmon/${e}`, name })
530
+ }
531
+ const matched = hwmons
532
+ .map((h, i) => ({ h, i }))
533
+ .filter(({ h }) => CPU_HWMON_NAME_PATTERNS.some((re) => re.test(h.name)))
534
+ .sort((a, b) => a.i - b.i)[0]
535
+ if (!matched) return { available: false, unit: "V" }
536
+ const in0 = `${matched.h.dir}/in0_input`
537
+ if (!existsSync(in0)) return { available: false, unit: "V" }
538
+ const raw = await readFile(in0, "utf8")
539
+ const mv = Number.parseInt(raw.trim(), 10)
540
+ if (!Number.isFinite(mv) || mv <= 0) {
541
+ return { available: false, unit: "V" }
542
+ }
543
+ return {
544
+ available: true,
545
+ unit: "V",
546
+ value: Number((mv / 1000).toFixed(2)),
547
+ }
548
+ } catch {
549
+ return { available: false, unit: "V" }
550
+ }
551
+ }
552
+
553
+ // ponytail: /proc/diskstats sectors are cumulative per whole disk. We sum
554
+ // across all whole disks (skip partitions, loop, ram, dm-, md, sr*) and
555
+ // report a delta-over-poll-interval byte rate. Same trick as probeCpu —
556
+ // the first sample has no baseline, so it returns 0 to avoid a misleading
557
+ // "all disks went from 0 → N MB in zero seconds" spike.
558
+ const DISKSTATS_SKIP_RE =
559
+ /^(loop|ram|dm-|md|drbd)\d*|^(sr|nbd)\d+$|^[a-z]+\d+_|^dm-/
560
+ let prevDiskIoSample: { ts: number; sectors: number } | null = null
561
+ let prevDarwinDiskIoSample: { ts: number; bytes: number } | null = null
562
+
563
+ async function readDiskstatsSectors(): Promise<number | null> {
564
+ if (!existsSync("/proc/diskstats")) return null
565
+ try {
566
+ const raw = await readFile("/proc/diskstats", "utf8")
567
+ let totalSectors = 0
568
+ for (const line of raw.split("\n")) {
569
+ const parts = line.trim().split(/\s+/)
570
+ if (parts.length < 14) continue
571
+ const name = parts[2]
572
+ if (!name || DISKSTATS_SKIP_RE.test(name)) continue
573
+ if (/^(nvme\d+n\d+)p\d+$/.test(name)) continue
574
+ const readSectors = Number.parseInt(parts[5] ?? "", 10)
575
+ const writtenSectors = Number.parseInt(parts[9] ?? "", 10)
576
+ if (!Number.isFinite(readSectors) || !Number.isFinite(writtenSectors)) {
577
+ continue
578
+ }
579
+ totalSectors += readSectors + writtenSectors
580
+ }
581
+ return totalSectors
582
+ } catch {
583
+ return null
584
+ }
585
+ }
586
+
587
+ // ponytail: macOS has no /proc/diskstats, but IOBlockStorageDriver carries the
588
+ // equivalent cumulative counters ("Bytes (Read)" / "Bytes (Write)") per device.
589
+ // Sum across devices — ioreg lists several and only the backing store has
590
+ // non-zero totals — and feed the same delta-over-interval maths the Linux
591
+ // reader uses, so both platforms report the same B/s quantity.
592
+ export const parseDarwinDiskBytes = (raw: string): number | null => {
593
+ let total = 0
594
+ let seen = false
595
+ for (const m of raw.matchAll(/"Bytes \((?:Read|Write)\)"\s*=\s*(\d+)/g)) {
596
+ const n = Number.parseInt(m[1] ?? "", 10)
597
+ if (!Number.isFinite(n)) continue
598
+ total += n
599
+ seen = true
600
+ }
601
+ return seen ? total : null
602
+ }
603
+
604
+ async function readDarwinDiskBytes(): Promise<number | null> {
605
+ const raw = await darwinRun("ioreg", [
606
+ "-c",
607
+ "IOBlockStorageDriver",
608
+ "-r",
609
+ "-d",
610
+ "1",
611
+ "-w",
612
+ "0",
613
+ ])
614
+ return raw === null ? null : parseDarwinDiskBytes(raw)
615
+ }
616
+
617
+ async function probeDiskIo(): Promise<ProbeResult> {
618
+ if (process.platform === "darwin") {
619
+ const bytes = await readDarwinDiskBytes()
620
+ if (bytes === null) return { available: false, unit: "B/s" }
621
+ const now = Date.now()
622
+ const prev = prevDarwinDiskIoSample
623
+ if (prev === null || bytes < prev.bytes) {
624
+ prevDarwinDiskIoSample = { ts: now, bytes }
625
+ return { available: true, unit: "B/s", value: 0 }
626
+ }
627
+ const dtSec = Math.max(0.001, (now - prev.ts) / 1000)
628
+ const delta = bytes - prev.bytes
629
+ prevDarwinDiskIoSample = { ts: now, bytes }
630
+ return {
631
+ available: true,
632
+ unit: "B/s",
633
+ value: Math.round(delta / dtSec),
634
+ }
635
+ }
636
+ if (process.platform !== "linux") {
637
+ return { available: false, unit: "B/s" }
638
+ }
639
+ const sectors = await readDiskstatsSectors()
640
+ if (sectors === null) return { available: false, unit: "B/s" }
641
+ const now = Date.now()
642
+ const prev = prevDiskIoSample
643
+ if (prev === null || sectors < prev.sectors) {
644
+ prevDiskIoSample = { ts: now, sectors }
645
+ return { available: true, unit: "B/s", value: 0 }
646
+ }
647
+ const dtSec = Math.max(0.001, (now - prev.ts) / 1000)
648
+ const dSectors = sectors - prev.sectors
649
+ prevDiskIoSample = { ts: now, sectors }
650
+ const bytesPerSec = Math.round((dSectors * 512) / dtSec)
651
+ return { available: true, unit: "B/s", value: bytesPerSec }
652
+ }
653
+
654
+ // ponytail: hwmon fans expose `fan<N>_input` (RPM). Many systems have none
655
+ // (laptops with passive cooling). Pick the first hwmon with any non-zero
656
+ // fan reading — keeps the metric meaningful (a "0 RPM" fan isn't a fan).
657
+ async function probeFanRpm(): Promise<ProbeResult> {
658
+ if (process.platform === "darwin") {
659
+ const sample = await readMacmon()
660
+ const value = sample?.fanRpm ?? null
661
+ return value === null
662
+ ? { available: false, unit: "RPM" }
663
+ : { available: true, value, unit: "RPM" }
664
+ }
665
+ if (process.platform !== "linux") {
666
+ return { available: false, unit: "RPM" }
667
+ }
668
+ if (!existsSync("/sys/class/hwmon")) {
669
+ return { available: false, unit: "RPM" }
670
+ }
671
+ try {
672
+ const entries = await readdir("/sys/class/hwmon")
673
+ for (const e of entries) {
674
+ const dir = `/sys/class/hwmon/${e}`
675
+ const files = await readdir(dir).catch(() => [])
676
+ const fans = files.filter((f) => /^fan\d+_input$/.test(f)).sort()
677
+ for (const f of fans) {
678
+ const raw = await readFile(`${dir}/${f}`, "utf8").catch(() => "")
679
+ const rpm = Number.parseInt(raw.trim(), 10)
680
+ if (Number.isFinite(rpm) && rpm > 0) {
681
+ return { available: true, unit: "RPM", value: rpm }
682
+ }
683
+ }
684
+ }
685
+ return { available: false, unit: "RPM" }
686
+ } catch {
687
+ return { available: false, unit: "RPM" }
688
+ }
689
+ }
690
+
691
+ // ponytail: amd/amdgpu publishes busy% on /sys/class/drm/card*/device/
692
+ // gpu_busy_percent and temp on the card's hwmon temp1_input. Both are
693
+ // first-match across cards. NVIDIA users get a one-shot fallback to
694
+ // nvidia-smi (stdlib execFile with a 1.5s ceiling so a missing binary
695
+ // doesn't stall the poller). Other vendors (Intel i915, nouveau) report
696
+ // unavailable rather than spawn a shell — YAGNI on coverage we can't
697
+ // validate in CI.
698
+ async function readAmdGpuBusyPercent(): Promise<number | null> {
699
+ if (!existsSync("/sys/class/drm")) return null
700
+ const cards = (await readdir("/sys/class/drm").catch(() => [])).filter((c) =>
701
+ /^card\d+$/.test(c),
702
+ )
703
+ for (const card of cards) {
704
+ const p = `/sys/class/drm/${card}/device/gpu_busy_percent`
705
+ if (!existsSync(p)) continue
706
+ const raw = await readFile(p, "utf8").catch(() => "")
707
+ const pct = Number.parseInt(raw.trim(), 10)
708
+ if (Number.isFinite(pct) && pct >= 0 && pct <= 100) return pct
709
+ }
710
+ return null
711
+ }
712
+
713
+ async function readAmdGpuTempMilliC(): Promise<number | null> {
714
+ if (!existsSync("/sys/class/drm")) return null
715
+ const cards = (await readdir("/sys/class/drm").catch(() => [])).filter((c) =>
716
+ /^card\d+$/.test(c),
717
+ )
718
+ for (const card of cards) {
719
+ const devDir = `/sys/class/drm/${card}/device`
720
+ if (!existsSync(devDir)) continue
721
+ const entries = await readdir(devDir).catch(() => [])
722
+ const hwmons = entries.filter((e) => /^hwmon\d+$/.test(e))
723
+ for (const h of hwmons) {
724
+ const t = `${devDir}/${h}/temp1_input`
725
+ if (!existsSync(t)) continue
726
+ const raw = await readFile(t, "utf8").catch(() => "")
727
+ const milli = Number.parseInt(raw.trim(), 10)
728
+ if (Number.isFinite(milli) && milli > 0) return milli
729
+ }
730
+ }
731
+ return null
732
+ }
733
+
734
+ async function tryNvidiaSmi(field: string): Promise<number | null> {
735
+ try {
736
+ const { stdout } = await execFile(
737
+ "nvidia-smi",
738
+ [`--query-gpu=${field}`, "--format=csv,noheader,nounits"],
739
+ { timeout: 1500, signal: AbortSignal.timeout(1500) },
740
+ )
741
+ const first = stdout.trim().split("\n")[0]?.trim() ?? ""
742
+ const n = Number.parseInt(first, 10)
743
+ return Number.isFinite(n) ? n : null
744
+ } catch {
745
+ return null
746
+ }
747
+ }
748
+
749
+ // ponytail: Apple Silicon publishes GPU busy-ness in the IOAccelerator node's
750
+ // PerformanceStatistics dict, readable by any user — no root, no private
751
+ // framework. "Device Utilization %" is the whole-GPU figure; the Renderer/
752
+ // Tiler entries break it down per engine.
753
+ export const parseDarwinGpuUtilization = (raw: string): number | null => {
754
+ const m = /"Device Utilization %"\s*=\s*(\d+)/.exec(raw)
755
+ if (m?.[1] === undefined) return null
756
+ const pct = Number.parseInt(m[1], 10)
757
+ return Number.isFinite(pct) ? pct : null
758
+ }
759
+
760
+ async function probeGpuUsage(): Promise<ProbeResult> {
761
+ if (process.platform === "darwin") {
762
+ const raw = await darwinRun("ioreg", [
763
+ "-r",
764
+ "-d",
765
+ "1",
766
+ "-w",
767
+ "0",
768
+ "-c",
769
+ "IOAccelerator",
770
+ ])
771
+ const pct = raw === null ? null : parseDarwinGpuUtilization(raw)
772
+ if (pct === null) return { available: false, unit: "%" }
773
+ const clamped = clampPercent(pct)
774
+ return {
775
+ available: true,
776
+ max: 100,
777
+ percentage: clamped,
778
+ unit: "%",
779
+ value: clamped,
780
+ }
781
+ }
782
+ if (process.platform !== "linux") {
783
+ return { available: false, unit: "%" }
784
+ }
785
+ const pct = await readAmdGpuBusyPercent()
786
+ if (pct !== null) {
787
+ return {
788
+ available: true,
789
+ max: 100,
790
+ percentage: clampPercent(pct),
791
+ unit: "%",
792
+ value: clampPercent(pct),
793
+ }
794
+ }
795
+ const nvidia = await tryNvidiaSmi("utilization.gpu")
796
+ if (nvidia !== null) {
797
+ return {
798
+ available: true,
799
+ max: 100,
800
+ percentage: clampPercent(nvidia),
801
+ unit: "%",
802
+ value: clampPercent(nvidia),
803
+ }
804
+ }
805
+ return { available: false, unit: "%" }
806
+ }
807
+
808
+ async function probeGpuTemp(): Promise<ProbeResult> {
809
+ if (process.platform === "darwin") {
810
+ const sample = await readMacmon()
811
+ const value = sample?.gpuTemp ?? null
812
+ return value === null
813
+ ? { available: false, unit: "°C" }
814
+ : { available: true, value, unit: "°C" }
815
+ }
816
+ if (process.platform !== "linux") {
817
+ return { available: false, unit: "°C" }
818
+ }
819
+ const milli = await readAmdGpuTempMilliC()
820
+ if (milli !== null) {
821
+ return {
822
+ available: true,
823
+ unit: "°C",
824
+ value: Math.round(milli / 1000),
825
+ }
826
+ }
827
+ const nvidia = await tryNvidiaSmi("temperature.gpu")
828
+ if (nvidia !== null && nvidia > 0) {
829
+ return { available: true, unit: "°C", value: nvidia }
830
+ }
831
+ return { available: false, unit: "°C" }
832
+ }
833
+
834
+ // ponytail: /sys/class/net/<iface>/statistics/{rx,tx}_bytes is cumulative
835
+ // per interface. Sum across non-loopback, non-virtual ifaces (skip lo,
836
+ // docker*, veth*, br-*, tun*, tap*) and report Δbytes / Δt as a rate. The
837
+ // first sample re-baselines to 0 to avoid a "100 GB/s on boot" spike.
838
+ const NETWORK_IFACE_SKIP_RE =
839
+ /^(lo|docker|veth|br-|tun|tap|virbr|vmnet|vboxnet|awdl|llw|bridge|ipv6tnl|sit|gre|ip6gre|ipip|gretap|erspan)/
840
+
841
+ let prevNetworkSample: { ts: number; rx: number; tx: number } | null = null
842
+
843
+ async function readNetBytes(
844
+ field: "rx_bytes" | "tx_bytes",
845
+ ): Promise<number | null> {
846
+ if (!existsSync("/sys/class/net")) return null
847
+ const ifaces = await readdir("/sys/class/net").catch(() => [])
848
+ let total = 0
849
+ let counted = false
850
+ for (const iface of ifaces) {
851
+ if (NETWORK_IFACE_SKIP_RE.test(iface)) continue
852
+ const p = `/sys/class/net/${iface}/statistics/${field}`
853
+ if (!existsSync(p)) continue
854
+ const raw = await readFile(p, "utf8").catch(() => "")
855
+ const n = Number.parseInt(raw.trim(), 10)
856
+ if (Number.isFinite(n) && n >= 0) {
857
+ total += n
858
+ counted = true
859
+ }
860
+ }
861
+ return counted ? total : null
862
+ }
863
+
864
+ // ponytail: macOS has no /sys/class/net; `netstat -ibn` carries the same
865
+ // cumulative per-interface byte counters. Both platforms then share the
866
+ // identical delta-over-interval logic below.
867
+ async function readNetTotals(): Promise<{ rx: number; tx: number } | null> {
868
+ if (process.platform === "darwin") {
869
+ const raw = await darwinRun("netstat", ["-ibn"])
870
+ return raw === null ? null : parseDarwinNetstat(raw)
871
+ }
872
+ const rx = await readNetBytes("rx_bytes")
873
+ const tx = await readNetBytes("tx_bytes")
874
+ if (rx === null || tx === null) return null
875
+ return { rx, tx }
876
+ }
877
+
878
+ async function readNetworkDelta(): Promise<{
879
+ dt: number
880
+ rx: number
881
+ tx: number
882
+ } | null> {
883
+ const totals = await readNetTotals()
884
+ if (totals === null) return null
885
+ const { rx, tx } = totals
886
+ const now = Date.now()
887
+ const prev = prevNetworkSample
888
+ if (prev === null || rx < prev.rx || tx < prev.tx) {
889
+ prevNetworkSample = { ts: now, rx, tx }
890
+ return null
891
+ }
892
+ const dt = Math.max(0.001, (now - prev.ts) / 1000)
893
+ prevNetworkSample = { ts: now, rx, tx }
894
+ return { dt, rx: rx - prev.rx, tx: tx - prev.tx }
895
+ }
896
+
897
+ // ponytail: network-read and network-write are two metric ids over ONE pair of
898
+ // counters. Each used to call readNetworkDelta() independently, and that call
899
+ // consumes the baseline — so whichever ran second measured the interval since
900
+ // the first, a few microseconds earlier, and reported ~0 B/s. A deck showing
901
+ // both (the default config does) had one of them permanently dead, and which
902
+ // one depended on scheduling order. Share a single delta across any calls that
903
+ // land in the same tick; the window is far below the poll interval, so a real
904
+ // next tick still re-samples.
905
+ const NETWORK_DELTA_SHARE_MS = 250
906
+ let sharedNetworkDelta: {
907
+ at: number
908
+ value: { dt: number; rx: number; tx: number } | null
909
+ } | null = null
910
+ let inFlightNetworkDelta: Promise<{
911
+ dt: number
912
+ rx: number
913
+ tx: number
914
+ } | null> | null = null
915
+
916
+ async function readSharedNetworkDelta(): Promise<{
917
+ dt: number
918
+ rx: number
919
+ tx: number
920
+ } | null> {
921
+ const now = Date.now()
922
+ if (
923
+ sharedNetworkDelta !== null &&
924
+ now - sharedNetworkDelta.at < NETWORK_DELTA_SHARE_MS
925
+ ) {
926
+ return sharedNetworkDelta.value
927
+ }
928
+ // Collapse concurrent callers onto one in-flight read as well, so two
929
+ // probes dispatched in the same Promise.all don't both hit the counters.
930
+ if (inFlightNetworkDelta !== null) return inFlightNetworkDelta
931
+ inFlightNetworkDelta = (async () => {
932
+ try {
933
+ const value = await readNetworkDelta()
934
+ sharedNetworkDelta = { at: Date.now(), value }
935
+ return value
936
+ } finally {
937
+ inFlightNetworkDelta = null
938
+ }
939
+ })()
940
+ return inFlightNetworkDelta
941
+ }
942
+
943
+ export function __resetNetworkDeltaCacheForTests(): void {
944
+ sharedNetworkDelta = null
945
+ inFlightNetworkDelta = null
946
+ prevNetworkSample = null
947
+ }
948
+
949
+ async function probeNetworkRead(): Promise<ProbeResult> {
950
+ if (process.platform !== "linux" && process.platform !== "darwin") {
951
+ return { available: false, unit: "B/s" }
952
+ }
953
+ const delta = await readSharedNetworkDelta()
954
+ if (delta === null) return { available: false, unit: "B/s" }
955
+ return {
956
+ available: true,
957
+ unit: "B/s",
958
+ value: Math.round(delta.rx / delta.dt),
959
+ }
960
+ }
961
+
962
+ async function probeNetworkWrite(): Promise<ProbeResult> {
963
+ if (process.platform !== "linux" && process.platform !== "darwin") {
964
+ return { available: false, unit: "B/s" }
965
+ }
966
+ const delta = await readSharedNetworkDelta()
967
+ if (delta === null) return { available: false, unit: "B/s" }
968
+ return {
969
+ available: true,
970
+ unit: "B/s",
971
+ value: Math.round(delta.tx / delta.dt),
972
+ }
973
+ }
974
+
975
+ const PROBES: Record<SystemMetricId, () => Promise<ProbeResult>> = {
976
+ cpu: probeCpu,
977
+ ram: probeRam,
978
+ swap: probeSwap,
979
+ disk: probeDisk,
980
+ network: probeNetwork,
981
+ battery: probeBattery,
982
+ temperature: probeTemperature,
983
+ uptime: probeUptime,
984
+ frequency: probeFrequency,
985
+ load: probeLoad,
986
+ processes: probeProcesses,
987
+ "cpu-boost": probeCpuBoost,
988
+ "cpu-voltages": probeCpuVoltages,
989
+ "disk-io": probeDiskIo,
990
+ "fan-rpm": probeFanRpm,
991
+ "gpu-temp": probeGpuTemp,
992
+ "gpu-usage": probeGpuUsage,
993
+ "network-read": probeNetworkRead,
994
+ "network-write": probeNetworkWrite,
995
+ }
996
+
997
+ export async function probeMetric(
998
+ id: SystemMetricId,
999
+ ): Promise<SystemMetricSnapshot> {
1000
+ const probe = PROBES[id]
1001
+ try {
1002
+ const r = await probe()
1003
+ return {
1004
+ available: r.available,
1005
+ id,
1006
+ label: id,
1007
+ ...(r.max !== undefined ? { max: r.max } : {}),
1008
+ ...(r.percentage !== undefined ? { percentage: r.percentage } : {}),
1009
+ ...(r.unit !== undefined ? { unit: r.unit } : {}),
1010
+ ...(r.value !== undefined ? { value: r.value } : {}),
1011
+ }
1012
+ } catch {
1013
+ return { available: false, id, label: id }
1014
+ }
1015
+ }
1016
+
1017
+ export async function probeMetrics(
1018
+ ids: readonly SystemMetricId[],
1019
+ ): Promise<SystemMetricSnapshot[]> {
1020
+ return Promise.all(ids.map(probeMetric))
1021
+ }