@agentdeck/bridge 1.0.3 → 1.0.5

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 (215) hide show
  1. package/assets/fm-helper/agentdeck-fm-helper +0 -0
  2. package/dist/adapters/claude-code.d.ts.map +1 -1
  3. package/dist/adapters/claude-code.js +3 -0
  4. package/dist/adapters/claude-code.js.map +1 -1
  5. package/dist/adb-reverse.d.ts.map +1 -1
  6. package/dist/adb-reverse.js +4 -2
  7. package/dist/adb-reverse.js.map +1 -1
  8. package/dist/apme/collector.js +2 -2
  9. package/dist/apme/collector.js.map +1 -1
  10. package/dist/apme/outcome.js +2 -2
  11. package/dist/apme/outcome.js.map +1 -1
  12. package/dist/apme/runner.js +3 -3
  13. package/dist/apme/runner.js.map +1 -1
  14. package/dist/ble-sync-spawn.js +1 -1
  15. package/dist/ble-sync-spawn.js.map +1 -1
  16. package/dist/calendar.d.ts +82 -0
  17. package/dist/calendar.d.ts.map +1 -0
  18. package/dist/calendar.js +324 -0
  19. package/dist/calendar.js.map +1 -0
  20. package/dist/card-feed.d.ts +63 -5
  21. package/dist/card-feed.d.ts.map +1 -1
  22. package/dist/card-feed.js +218 -14
  23. package/dist/card-feed.js.map +1 -1
  24. package/dist/card-modules.d.ts +4 -0
  25. package/dist/card-modules.d.ts.map +1 -1
  26. package/dist/card-modules.js +5 -3
  27. package/dist/card-modules.js.map +1 -1
  28. package/dist/check-deps.js +4 -4
  29. package/dist/check-deps.js.map +1 -1
  30. package/dist/cli.d.ts +35 -0
  31. package/dist/cli.d.ts.map +1 -1
  32. package/dist/cli.js +406 -76
  33. package/dist/cli.js.map +1 -1
  34. package/dist/codex-otel.d.ts +89 -0
  35. package/dist/codex-otel.d.ts.map +1 -0
  36. package/dist/codex-otel.js +487 -0
  37. package/dist/codex-otel.js.map +1 -0
  38. package/dist/codex-rate-limits.d.ts.map +1 -1
  39. package/dist/codex-rate-limits.js +15 -3
  40. package/dist/codex-rate-limits.js.map +1 -1
  41. package/dist/daemon-server.d.ts +4 -0
  42. package/dist/daemon-server.d.ts.map +1 -1
  43. package/dist/daemon-server.js +797 -54
  44. package/dist/daemon-server.js.map +1 -1
  45. package/dist/daemon-ws-client.d.ts +108 -24
  46. package/dist/daemon-ws-client.d.ts.map +1 -1
  47. package/dist/daemon-ws-client.js +175 -39
  48. package/dist/daemon-ws-client.js.map +1 -1
  49. package/dist/daemon.js +1 -1
  50. package/dist/device-voice-reply.d.ts.map +1 -1
  51. package/dist/device-voice-reply.js +15 -3
  52. package/dist/device-voice-reply.js.map +1 -1
  53. package/dist/device-voice.d.ts +37 -0
  54. package/dist/device-voice.d.ts.map +1 -1
  55. package/dist/device-voice.js +130 -1
  56. package/dist/device-voice.js.map +1 -1
  57. package/dist/diag-analyzer.d.ts.map +1 -1
  58. package/dist/diag-analyzer.js +1 -0
  59. package/dist/diag-analyzer.js.map +1 -1
  60. package/dist/display-monitor.d.ts.map +1 -1
  61. package/dist/display-monitor.js +1 -0
  62. package/dist/display-monitor.js.map +1 -1
  63. package/dist/esp32-serial.d.ts +4 -0
  64. package/dist/esp32-serial.d.ts.map +1 -1
  65. package/dist/esp32-serial.js +6 -3
  66. package/dist/esp32-serial.js.map +1 -1
  67. package/dist/foundation-models-helper.d.ts +23 -0
  68. package/dist/foundation-models-helper.d.ts.map +1 -1
  69. package/dist/foundation-models-helper.js +32 -0
  70. package/dist/foundation-models-helper.js.map +1 -1
  71. package/dist/gateway-probe.js +1 -1
  72. package/dist/gateway-probe.js.map +1 -1
  73. package/dist/glance-frame.d.ts +64 -0
  74. package/dist/glance-frame.d.ts.map +1 -0
  75. package/dist/glance-frame.js +364 -0
  76. package/dist/glance-frame.js.map +1 -0
  77. package/dist/hook-codex-sessions.d.ts +40 -0
  78. package/dist/hook-codex-sessions.d.ts.map +1 -0
  79. package/dist/hook-codex-sessions.js +174 -0
  80. package/dist/hook-codex-sessions.js.map +1 -0
  81. package/dist/idotmatrix/idotmatrix-daemon-sync.d.ts.map +1 -1
  82. package/dist/idotmatrix/idotmatrix-daemon-sync.js +5 -18
  83. package/dist/idotmatrix/idotmatrix-daemon-sync.js.map +1 -1
  84. package/dist/idotmatrix/idotmatrix-discover.d.ts.map +1 -1
  85. package/dist/idotmatrix/idotmatrix-discover.js +12 -17
  86. package/dist/idotmatrix/idotmatrix-discover.js.map +1 -1
  87. package/dist/idotmatrix/idotmatrix-settings.d.ts +15 -0
  88. package/dist/idotmatrix/idotmatrix-settings.d.ts.map +1 -1
  89. package/dist/idotmatrix/idotmatrix-settings.js +21 -0
  90. package/dist/idotmatrix/idotmatrix-settings.js.map +1 -1
  91. package/dist/index.d.ts +7 -0
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +88 -7
  94. package/dist/index.js.map +1 -1
  95. package/dist/linux-service.d.ts +52 -0
  96. package/dist/linux-service.d.ts.map +1 -0
  97. package/dist/linux-service.js +206 -0
  98. package/dist/linux-service.js.map +1 -0
  99. package/dist/logger.d.ts +6 -0
  100. package/dist/logger.d.ts.map +1 -1
  101. package/dist/logger.js +15 -0
  102. package/dist/logger.js.map +1 -1
  103. package/dist/mdns-discover.d.ts +19 -0
  104. package/dist/mdns-discover.d.ts.map +1 -0
  105. package/dist/mdns-discover.js +109 -0
  106. package/dist/mdns-discover.js.map +1 -0
  107. package/dist/model-catalog.d.ts.map +1 -1
  108. package/dist/model-catalog.js +1 -0
  109. package/dist/model-catalog.js.map +1 -1
  110. package/dist/modules/adb-module.js +1 -1
  111. package/dist/modules/adb-module.js.map +1 -1
  112. package/dist/output-parser.d.ts +9 -0
  113. package/dist/output-parser.d.ts.map +1 -1
  114. package/dist/output-parser.js +67 -16
  115. package/dist/output-parser.js.map +1 -1
  116. package/dist/passive-observer.d.ts +57 -8
  117. package/dist/passive-observer.d.ts.map +1 -1
  118. package/dist/passive-observer.js +210 -74
  119. package/dist/passive-observer.js.map +1 -1
  120. package/dist/pixoo/pixoo-bridge.d.ts.map +1 -1
  121. package/dist/pixoo/pixoo-bridge.js +47 -6
  122. package/dist/pixoo/pixoo-bridge.js.map +1 -1
  123. package/dist/pixoo/pixoo-renderer.d.ts +2 -1
  124. package/dist/pixoo/pixoo-renderer.d.ts.map +1 -1
  125. package/dist/pixoo/pixoo-renderer.js +137 -8
  126. package/dist/pixoo/pixoo-renderer.js.map +1 -1
  127. package/dist/pocket-autonomy.d.ts +78 -0
  128. package/dist/pocket-autonomy.d.ts.map +1 -0
  129. package/dist/pocket-autonomy.js +529 -0
  130. package/dist/pocket-autonomy.js.map +1 -0
  131. package/dist/pty-manager.d.ts.map +1 -1
  132. package/dist/pty-manager.js +3 -2
  133. package/dist/pty-manager.js.map +1 -1
  134. package/dist/python-ble-runtime.d.ts +64 -0
  135. package/dist/python-ble-runtime.d.ts.map +1 -0
  136. package/dist/python-ble-runtime.js +216 -0
  137. package/dist/python-ble-runtime.js.map +1 -0
  138. package/dist/remote-sessions.d.ts +64 -0
  139. package/dist/remote-sessions.d.ts.map +1 -0
  140. package/dist/remote-sessions.js +81 -0
  141. package/dist/remote-sessions.js.map +1 -0
  142. package/dist/review-runner.js +2 -2
  143. package/dist/review-runner.js.map +1 -1
  144. package/dist/session-aggregator.d.ts +2 -0
  145. package/dist/session-aggregator.d.ts.map +1 -1
  146. package/dist/session-aggregator.js +6 -1
  147. package/dist/session-aggregator.js.map +1 -1
  148. package/dist/session-focus-relay.d.ts +47 -1
  149. package/dist/session-focus-relay.d.ts.map +1 -1
  150. package/dist/session-focus-relay.js +130 -15
  151. package/dist/session-focus-relay.js.map +1 -1
  152. package/dist/session-push-channel.d.ts +31 -0
  153. package/dist/session-push-channel.d.ts.map +1 -0
  154. package/dist/session-push-channel.js +94 -0
  155. package/dist/session-push-channel.js.map +1 -0
  156. package/dist/session-registry.d.ts +126 -0
  157. package/dist/session-registry.d.ts.map +1 -1
  158. package/dist/session-registry.js +180 -3
  159. package/dist/session-registry.js.map +1 -1
  160. package/dist/subagent-timeline.d.ts +34 -0
  161. package/dist/subagent-timeline.d.ts.map +1 -0
  162. package/dist/subagent-timeline.js +156 -0
  163. package/dist/subagent-timeline.js.map +1 -0
  164. package/dist/timebox/timebox-daemon-sync.d.ts.map +1 -1
  165. package/dist/timebox/timebox-daemon-sync.js +5 -20
  166. package/dist/timebox/timebox-daemon-sync.js.map +1 -1
  167. package/dist/timebox/timebox-discover.d.ts.map +1 -1
  168. package/dist/timebox/timebox-discover.js +7 -16
  169. package/dist/timebox/timebox-discover.js.map +1 -1
  170. package/dist/tui/gauge.d.ts +4 -2
  171. package/dist/tui/gauge.d.ts.map +1 -1
  172. package/dist/tui/gauge.js +8 -4
  173. package/dist/tui/gauge.js.map +1 -1
  174. package/dist/tui/renderer.d.ts.map +1 -1
  175. package/dist/tui/renderer.js +28 -0
  176. package/dist/tui/renderer.js.map +1 -1
  177. package/dist/types.d.ts +1 -1
  178. package/dist/types.d.ts.map +1 -1
  179. package/dist/types.js.map +1 -1
  180. package/dist/usage-api.d.ts +43 -0
  181. package/dist/usage-api.d.ts.map +1 -1
  182. package/dist/usage-api.js +114 -9
  183. package/dist/usage-api.js.map +1 -1
  184. package/dist/usage-event.d.ts +19 -0
  185. package/dist/usage-event.d.ts.map +1 -1
  186. package/dist/usage-event.js +35 -0
  187. package/dist/usage-event.js.map +1 -1
  188. package/dist/utils/project-name.d.ts.map +1 -1
  189. package/dist/utils/project-name.js +1 -0
  190. package/dist/utils/project-name.js.map +1 -1
  191. package/dist/version-check.d.ts.map +1 -1
  192. package/dist/version-check.js +3 -2
  193. package/dist/version-check.js.map +1 -1
  194. package/dist/voice-assistant.d.ts.map +1 -1
  195. package/dist/voice-assistant.js +2 -1
  196. package/dist/voice-assistant.js.map +1 -1
  197. package/dist/voice.d.ts.map +1 -1
  198. package/dist/voice.js +1 -0
  199. package/dist/voice.js.map +1 -1
  200. package/dist/weather.d.ts +72 -0
  201. package/dist/weather.d.ts.map +1 -0
  202. package/dist/weather.js +206 -0
  203. package/dist/weather.js.map +1 -0
  204. package/dist/windows-service.js +5 -5
  205. package/dist/windows-service.js.map +1 -1
  206. package/fm-helper/AgentDeckFMHelper.swift +200 -0
  207. package/package.json +20 -14
  208. package/python/requirements-ble.txt +5 -0
  209. package/src/idotmatrix/brightness.py +40 -0
  210. package/src/idotmatrix/identity_generated.py +50 -0
  211. package/src/idotmatrix/scan.py +55 -0
  212. package/src/idotmatrix/sync.py +400 -0
  213. package/src/pysync/matrix_sync_common.py +89 -0
  214. package/src/timebox/scan_ble.py +52 -0
  215. package/src/timebox/sync_ble.py +394 -0
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { Command } from 'commander';
2
+ import { Command, InvalidArgumentError } from 'commander';
3
3
  import { writeFileSync, unlinkSync, existsSync, realpathSync } from 'fs';
4
4
  import { homedir } from 'os';
5
5
  import { dirname, join } from 'path';
@@ -8,12 +8,42 @@ import { fileURLToPath } from 'url';
8
8
  import { createRequire } from 'module';
9
9
  import { request } from 'http';
10
10
  import { BRIDGE_WS_PORT } from './types.js';
11
+ import { SESSION_WEIGHT_MIN, SESSION_WEIGHT_MAX } from '@agentdeck/shared';
12
+ import { ensureBleRuntime, getBleRuntimeStatus } from './python-ble-runtime.js';
11
13
  import { TASK_NAME, installWindowsTask, taskExists, runWindowsTask, endWindowsTask, deleteWindowsTask, } from './windows-service.js';
14
+ import { deriveRemoteAttachOpts } from './session-registry.js';
15
+ import { SERVICE_NAME, hasSystemctl, unitExists, installUnit, startUnit, disableUnit, getUnitPath, getDataDir, } from './linux-service.js';
12
16
  const require = createRequire(import.meta.url);
13
17
  const packageJson = require('../package.json');
18
+ /**
19
+ * Pre-PTY warning for a host hint given without the opt-in switch. Must run in
20
+ * the CLI action handler BEFORE the bridge starts: once the PTY takes the
21
+ * terminal, `log()` output is swallowed and the user would never see it.
22
+ */
23
+ function warnIfDaemonHostIgnored(opts) {
24
+ if (deriveRemoteAttachOpts(opts).ignoredHostHint) {
25
+ console.error('[agentdeck] --daemon-host / AGENTDECK_DAEMON_HOST is set but ignored: remote attach requires --remote-daemon (or AGENTDECK_REMOTE_DAEMON=1). Session stays local-only.');
26
+ }
27
+ }
14
28
  function log(msg) {
15
29
  process.stderr.write(msg + '\n');
16
30
  }
31
+ /**
32
+ * Parse a `--weight <n>` value into an integer sort override. Rejects
33
+ * non-integers so a typo (`--weight foo`) fails loudly instead of silently
34
+ * collapsing to 0, and rejects values outside the documented cross-platform
35
+ * range (shared SESSION_WEIGHT_MIN/MAX) — the wire value must fit every
36
+ * consumer's integer type (Kotlin `Int` in particular), and comparators on
37
+ * fixed-width platforms must never see values that could overflow. Lower
38
+ * sorts first (see shared sortSessions).
39
+ */
40
+ function parseWeight(value) {
41
+ const n = Number(value);
42
+ if (!Number.isInteger(n) || n < SESSION_WEIGHT_MIN || n > SESSION_WEIGHT_MAX) {
43
+ throw new InvalidArgumentError(`weight must be an integer between ${SESSION_WEIGHT_MIN} and ${SESSION_WEIGHT_MAX} (e.g. --weight 1, --weight -5)`);
44
+ }
45
+ return n;
46
+ }
17
47
  function formatBytes(value) {
18
48
  const bytes = typeof value === 'number' && Number.isFinite(value) ? value : 0;
19
49
  if (bytes >= 1024 * 1024)
@@ -135,19 +165,30 @@ async function isDaemonPort(port) {
135
165
  return false;
136
166
  }
137
167
  }
138
- function resolveTimeboxSyncPaths() {
139
- const distPath = dirname(fileURLToPath(import.meta.url));
140
- const projectRoot = join(distPath, '..', '..');
141
- const timeboxDir = join(projectRoot, 'bridge', 'src', 'timebox');
142
- return {
143
- python: join(projectRoot, '.venv', 'bin', 'python'),
144
- bleScript: join(timeboxDir, 'sync_ble.py'),
145
- scanScript: join(timeboxDir, 'scan_ble.py'),
146
- };
147
- }
148
168
  function projectRootPath() {
149
169
  return join(dirname(fileURLToPath(import.meta.url)), '..', '..');
150
170
  }
171
+ async function runBlePython(python, args, captureStdout = false) {
172
+ return new Promise((resolve, reject) => {
173
+ const child = spawn(python, args, captureStdout ? { stdio: ['ignore', 'pipe', 'inherit'], windowsHide: true } : { stdio: 'inherit', windowsHide: true });
174
+ let stdout = '';
175
+ child.stdout?.on('data', (data) => {
176
+ stdout += data.toString();
177
+ });
178
+ child.once('error', reject);
179
+ child.once('close', (code) => resolve({ code, stdout }));
180
+ });
181
+ }
182
+ function prepareBleRuntime() {
183
+ try {
184
+ return ensureBleRuntime({ log });
185
+ }
186
+ catch (err) {
187
+ log(`BLE support unavailable: ${err instanceof Error ? err.message : String(err)}`);
188
+ process.exitCode = 1;
189
+ return null;
190
+ }
191
+ }
151
192
  const ESP32_OTA_BOARDS = [
152
193
  { board: 'inkdeck', env: 'inkdeck', aliases: [] },
153
194
  { board: 'ulanzi_tc001', env: 'led8x32', aliases: ['led8x32'] },
@@ -192,6 +233,117 @@ function platformioBin() {
192
233
  return '/opt/homebrew/bin/pio';
193
234
  return 'pio';
194
235
  }
236
+ // ===== Env-var default CLI args =====
237
+ //
238
+ // Two layers of environment-variable defaults, so a shell profile can set
239
+ // repeated flags once:
240
+ // AGENTDECK_COMMANDER_ARGS → args for the `agentdeck` (commander) layer,
241
+ // spliced into argv after the session subcommand.
242
+ // AGENTDECK_<AGENT>_ARGS → args appended to the spawned agent command,
243
+ // weaving into an explicit `-c` when present.
244
+ // See docs and cli.test.ts for behavior.
245
+ /** Session subcommands the global env-args var applies to. */
246
+ const SESSION_COMMANDS = ['claude', 'codex', 'opencode', 'monitor'];
247
+ /** Env var holding extra args for the spawned agent command, per agent type. */
248
+ const AGENT_ARGS_ENV = {
249
+ 'claude-code': 'AGENTDECK_CLAUDE_ARGS',
250
+ 'codex-cli': 'AGENTDECK_CODEX_ARGS',
251
+ opencode: 'AGENTDECK_OPENCODE_ARGS',
252
+ };
253
+ /**
254
+ * Split an env-provided arg string into tokens, honoring single/double quotes
255
+ * so values like `--flag "a b"` survive. No shell expansion (no $VAR,
256
+ * globbing, or escapes beyond quote grouping) — this only groups whitespace.
257
+ * Cross-platform: pure JS, never invokes a shell.
258
+ */
259
+ export function tokenizeArgString(s) {
260
+ if (!s)
261
+ return [];
262
+ const tokens = [];
263
+ // Match: double-quoted run | single-quoted run | bare non-space run.
264
+ const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
265
+ let cur = '';
266
+ let curActive = false;
267
+ let lastIndex = 0;
268
+ let m;
269
+ while ((m = re.exec(s)) !== null) {
270
+ // A gap of whitespace between matches ends the current token.
271
+ if (curActive && m.index > lastIndex) {
272
+ tokens.push(cur);
273
+ cur = '';
274
+ curActive = false;
275
+ }
276
+ cur += m[1] ?? m[2] ?? m[3] ?? '';
277
+ curActive = true;
278
+ lastIndex = re.lastIndex;
279
+ }
280
+ if (curActive)
281
+ tokens.push(cur);
282
+ return tokens;
283
+ }
284
+ /** Per-invocation escape hatch: disables BOTH env-default layers when typed. */
285
+ const NO_ENV_ARGS_FLAG = '--no-env-args';
286
+ /**
287
+ * If argv's subcommand is a session command and AGENTDECK_COMMANDER_ARGS is set,
288
+ * splice its tokens in immediately after the subcommand name. Env tokens land
289
+ * before any user-typed tokens, giving last-write override for SCALAR options
290
+ * (retype `--weight`/`--daemon-host` to win); boolean flags have no inverse
291
+ * spelling, so the per-invocation override is the typed `--no-env-args` hatch,
292
+ * which skips the splice entirely (and, parsed by commander, turns the
293
+ * per-agent append off in the session actions too).
294
+ * Returns a new argv; no-op (returns input) when unset or not a session command.
295
+ */
296
+ export function applyGlobalEnvArgs(argv, env = process.env) {
297
+ // The hatch itself is stripped from the env tokens: an env-smuggled
298
+ // --no-env-args would otherwise splice normally and then parse to
299
+ // envArgs=false, silently disabling only the per-agent layer — an
300
+ // undocumented half-state. The env var cannot turn the hatch on or off;
301
+ // only a typed flag can.
302
+ const extra = tokenizeArgString(env.AGENTDECK_COMMANDER_ARGS)
303
+ .filter((tok) => tok !== NO_ENV_ARGS_FLAG);
304
+ if (extra.length === 0)
305
+ return argv;
306
+ // The CLI has a fixed top-level command position: argv[0]=node,
307
+ // argv[1]=script, argv[2]=subcommand. Keying the decision to argv[2] (not a
308
+ // token scan) keeps non-session commands a true no-op even when a
309
+ // positional VALUE equals a session command name (`agentdeck speak b claude`).
310
+ const sub = argv[2];
311
+ if (sub === undefined || !SESSION_COMMANDS.includes(sub))
312
+ return argv;
313
+ // Typed escape hatch — checked on the RAW argv before splicing, because
314
+ // post-parse is too late (env tokens would already have parsed into opts).
315
+ // Exact-token match: a flag VALUE lexically equal to --no-env-args would
316
+ // false-positive, but producing that requires a deliberately pathological
317
+ // invocation (`-c --no-env-args`), and the argv[2] gate above bounds the
318
+ // scan to session commands.
319
+ if (argv.slice(3).includes(NO_ENV_ARGS_FLAG))
320
+ return argv;
321
+ const next = argv.slice();
322
+ next.splice(3, 0, ...extra);
323
+ return next;
324
+ }
325
+ /**
326
+ * Append the per-agent env var to the resolved `-c` command string. Raw append
327
+ * (single space + trim) — the PTY shell re-parses the whole string at spawn, so
328
+ * this rides the same platform shell path (cmd.exe / POSIX) that already handles
329
+ * a user's `-c`. No-op (returns `command`) when the var is unset/empty.
330
+ */
331
+ export function weaveAgentCommand(agentType, command, env = process.env) {
332
+ const extra = (env[AGENT_ARGS_ENV[agentType]] ?? '').trim();
333
+ if (!extra)
334
+ return command;
335
+ return `${command} ${extra}`.trim();
336
+ }
337
+ /**
338
+ * The session actions' one-call selection for the spawned agent command:
339
+ * weave the per-agent env args unless the user typed `--no-env-args`
340
+ * (commander parses it as `opts.envArgs === false`; the hatch covers BOTH
341
+ * env layers, and the commander-layer half is handled pre-parse in
342
+ * `applyGlobalEnvArgs`).
343
+ */
344
+ export function resolveAgentCommand(agentType, command, envArgsEnabled, env = process.env) {
345
+ return envArgsEnabled ? weaveAgentCommand(agentType, command, env) : command;
346
+ }
195
347
  // ===== Program =====
196
348
  const program = new Command();
197
349
  program
@@ -211,16 +363,24 @@ program
211
363
  .option('--no-adb', 'Disable ADB reverse setup')
212
364
  .option('--no-postit', 'Disable terminal tab title updates')
213
365
  .option('--wake-word', 'Enable wake word voice assistant ("오픈클로")')
366
+ .option('--remote-daemon', 'Opt in to attaching this session to a daemon on another machine (gates --daemon-host and mDNS discovery)')
367
+ .option('--daemon-host <host>', 'Explicit remote daemon endpoint (host or host:port); requires --remote-daemon')
368
+ .option('--no-env-args', 'Ignore AGENTDECK_COMMANDER_ARGS and AGENTDECK_<AGENT>_ARGS for this invocation')
369
+ .option('--weight <n>', 'Deck/tab sort order override (integer, lower first; default 0)', parseWeight)
214
370
  .action(async (opts) => {
371
+ warnIfDaemonHostIgnored(opts);
215
372
  const { startSession } = await import('./index.js');
216
373
  await startSession({
217
374
  agentType: 'claude-code',
218
375
  port: parseInt(opts.port, 10),
219
- command: opts.command,
376
+ command: resolveAgentCommand('claude-code', opts.command, opts.envArgs !== false),
220
377
  debug: opts.debug,
221
378
  noUpdateCheck: opts.updateCheck === false,
222
379
  postit: opts.postit !== false,
223
380
  wakeWord: !!opts.wakeWord,
381
+ remoteDaemon: !!opts.remoteDaemon,
382
+ daemonHost: opts.daemonHost,
383
+ weight: opts.weight,
224
384
  modules: opts.local ? { mdns: false, adb: false, serial: false, pixoo: false, timebox: false } : {
225
385
  mdns: false, // daemon-only — session bridges never advertise mDNS
226
386
  adb: opts.adb !== false ? 'auto' : false,
@@ -240,7 +400,12 @@ program
240
400
  .option('--no-adb', 'Disable ADB reverse setup')
241
401
  .option('--no-postit', 'Disable terminal tab title updates')
242
402
  .option('--no-codex-hooks', 'Skip ~/.codex/config.toml hook install')
403
+ .option('--remote-daemon', 'Opt in to attaching this session to a daemon on another machine (gates --daemon-host and mDNS discovery)')
404
+ .option('--daemon-host <host>', 'Explicit remote daemon endpoint (host or host:port); requires --remote-daemon')
405
+ .option('--no-env-args', 'Ignore AGENTDECK_COMMANDER_ARGS and AGENTDECK_<AGENT>_ARGS for this invocation')
406
+ .option('--weight <n>', 'Deck/tab sort order override (integer, lower first; default 0)', parseWeight)
243
407
  .action(async (opts) => {
408
+ warnIfDaemonHostIgnored(opts);
244
409
  // Install Codex lifecycle hooks before starting the session so the
245
410
  // first prompt's UserPromptSubmit / Stop events reach the daemon.
246
411
  // Idempotent: re-running with the same daemon port is a no-op.
@@ -269,9 +434,12 @@ program
269
434
  await startSession({
270
435
  agentType: 'codex-cli',
271
436
  port: parseInt(opts.port, 10),
272
- command: opts.command,
437
+ command: resolveAgentCommand('codex-cli', opts.command, opts.envArgs !== false),
273
438
  debug: opts.debug,
274
439
  postit: opts.postit !== false,
440
+ remoteDaemon: !!opts.remoteDaemon,
441
+ daemonHost: opts.daemonHost,
442
+ weight: opts.weight,
275
443
  modules: opts.local ? { mdns: false, adb: false, serial: false, pixoo: false, timebox: false } : {
276
444
  mdns: false, // daemon-only
277
445
  adb: opts.adb !== false ? 'auto' : false,
@@ -291,7 +459,12 @@ program
291
459
  .option('--no-adb', 'Disable ADB reverse setup')
292
460
  .option('--no-postit', 'Disable terminal tab title updates')
293
461
  .option('--no-opencode-hooks', 'Skip OpenCode observer plugin install')
462
+ .option('--remote-daemon', 'Opt in to attaching this session to a daemon on another machine (gates --daemon-host and mDNS discovery)')
463
+ .option('--daemon-host <host>', 'Explicit remote daemon endpoint (host or host:port); requires --remote-daemon')
464
+ .option('--no-env-args', 'Ignore AGENTDECK_COMMANDER_ARGS and AGENTDECK_<AGENT>_ARGS for this invocation')
465
+ .option('--weight <n>', 'Deck/tab sort order override (integer, lower first; default 0)', parseWeight)
294
466
  .action(async (opts) => {
467
+ warnIfDaemonHostIgnored(opts);
295
468
  // Install the OpenCode observer plugin so standalone `opencode` runs
296
469
  // (outside this managed session) also reach the daemon timeline. The
297
470
  // plugin self-disables inside managed PTYs via AGENTDECK_PORT, so this
@@ -319,9 +492,12 @@ program
319
492
  await startSession({
320
493
  agentType: 'opencode',
321
494
  port: parseInt(opts.port, 10),
322
- command: opts.command,
495
+ command: resolveAgentCommand('opencode', opts.command, opts.envArgs !== false),
323
496
  debug: opts.debug,
324
497
  postit: opts.postit !== false,
498
+ remoteDaemon: !!opts.remoteDaemon,
499
+ daemonHost: opts.daemonHost,
500
+ weight: opts.weight,
325
501
  modules: opts.local ? { mdns: false, adb: false, serial: false, pixoo: false, timebox: false } : {
326
502
  mdns: false,
327
503
  adb: opts.adb !== false ? 'auto' : false,
@@ -337,7 +513,13 @@ program
337
513
  .option('-p, --port <port>', 'Bridge server port', String(BRIDGE_WS_PORT))
338
514
  .option('-d, --debug', 'Enable debug logging')
339
515
  .option('--local', 'Disable all device modules')
516
+ .option('--remote-daemon', 'Opt in to attaching this session to a daemon on another machine (gates --daemon-host and mDNS discovery)')
517
+ .option('--daemon-host <host>', 'Explicit remote daemon endpoint (host or host:port); requires --remote-daemon')
518
+ // Commander-layer half only: monitor spawns no agent command to weave into.
519
+ .option('--no-env-args', 'Ignore AGENTDECK_COMMANDER_ARGS for this invocation')
520
+ .option('--weight <n>', 'Deck/tab sort order override (integer, lower first; default 0)', parseWeight)
340
521
  .action(async (opts) => {
522
+ warnIfDaemonHostIgnored(opts);
341
523
  const { startSession } = await import('./index.js');
342
524
  const port = parseInt(opts.port, 10);
343
525
  log(`\nMonitor mode: hook server on port ${port}`);
@@ -346,6 +528,9 @@ program
346
528
  agentType: 'monitor',
347
529
  port,
348
530
  debug: opts.debug,
531
+ remoteDaemon: !!opts.remoteDaemon,
532
+ daemonHost: opts.daemonHost,
533
+ weight: opts.weight,
349
534
  modules: opts.local ? { mdns: false, adb: false, serial: false, pixoo: false, timebox: false } : undefined,
350
535
  });
351
536
  });
@@ -453,6 +638,7 @@ daemon
453
638
  const child = spawn(process.execPath, args, {
454
639
  detached: true,
455
640
  stdio: ['ignore', out, err],
641
+ windowsHide: true,
456
642
  });
457
643
  child.unref();
458
644
  log(`Daemon started (PID ${child.pid})`);
@@ -490,6 +676,7 @@ daemon
490
676
  const child = spawn(process.execPath, args, {
491
677
  detached: true,
492
678
  stdio: 'ignore',
679
+ windowsHide: true,
493
680
  });
494
681
  child.unref();
495
682
  log(`Daemon restarted (PID ${child.pid})`);
@@ -522,7 +709,7 @@ daemon
522
709
  });
523
710
  daemon
524
711
  .command('install')
525
- .description('Install daemon auto-start (LaunchAgent on macOS, Scheduled Task on Windows)')
712
+ .description('Install daemon auto-start (LaunchAgent on macOS, Scheduled Task on Windows, systemd --user unit on Linux)')
526
713
  .action(async () => {
527
714
  if (process.platform === 'win32') {
528
715
  try {
@@ -576,6 +763,62 @@ daemon
576
763
  catch { /* hooks package not built yet — task install still succeeds */ }
577
764
  process.exit(0);
578
765
  }
766
+ if (process.platform === 'linux') {
767
+ if (!hasSystemctl()) {
768
+ log('systemd --user is not available on this host.');
769
+ log('Run the daemon manually with: agentdeck daemon start');
770
+ log('(or add your own autostart entry).');
771
+ }
772
+ else {
773
+ try {
774
+ installUnit();
775
+ startUnit();
776
+ log(`systemd user unit '${SERVICE_NAME}' installed and started.`);
777
+ log(`Unit file: ${getUnitPath()}`);
778
+ if (process.env.AGENTDECK_DATA_DIR) {
779
+ log(`Data dir: ${getDataDir()} (AGENTDECK_DATA_DIR persisted into the unit — re-run 'agentdeck daemon install' to change it)`);
780
+ }
781
+ else {
782
+ log(`Data dir: ${getDataDir()}`);
783
+ }
784
+ log('For boot-without-login on a headless host, run once: loginctl enable-linger $USER');
785
+ }
786
+ catch (e) {
787
+ const detail = e.stderr?.toString().trim() || e.message;
788
+ log('Failed to install the systemd user unit.');
789
+ if (detail)
790
+ log(detail);
791
+ log('You can still run the daemon manually with: agentdeck daemon start');
792
+ process.exit(1);
793
+ }
794
+ }
795
+ // Install Codex + OpenCode hooks for parity with the macOS/Windows paths.
796
+ try {
797
+ const { installCodexHooksIfNeeded } = await import('@agentdeck/hooks');
798
+ const result = installCodexHooksIfNeeded();
799
+ if (result.installed) {
800
+ log('Codex lifecycle hooks installed in ~/.codex/config.toml');
801
+ if (result.warning)
802
+ log(`Codex hooks degraded: ${result.warning}`);
803
+ }
804
+ else if (result.reason) {
805
+ log(`Codex hooks skipped: ${result.reason}`);
806
+ }
807
+ }
808
+ catch { /* hooks package not built yet — install still succeeds */ }
809
+ try {
810
+ const { installOpenCodeHooksIfNeeded, opencodePluginPath } = await import('@agentdeck/hooks');
811
+ const result = installOpenCodeHooksIfNeeded();
812
+ if (result.installed) {
813
+ log(`OpenCode observer plugin installed at ${opencodePluginPath()}`);
814
+ }
815
+ else if (result.reason) {
816
+ log(`OpenCode observer plugin skipped: ${result.reason}`);
817
+ }
818
+ }
819
+ catch { /* hooks package not built yet — install still succeeds */ }
820
+ process.exit(0);
821
+ }
579
822
  if (process.platform !== 'darwin') {
580
823
  log('LaunchAgent is macOS-only');
581
824
  process.exit(1);
@@ -618,7 +861,7 @@ daemon
618
861
  });
619
862
  daemon
620
863
  .command('uninstall')
621
- .description('Remove daemon auto-start (LaunchAgent on macOS, Scheduled Task on Windows)')
864
+ .description('Remove daemon auto-start (LaunchAgent on macOS, Scheduled Task on Windows, systemd --user unit on Linux)')
622
865
  .action(async () => {
623
866
  if (process.platform === 'win32') {
624
867
  if (!taskExists()) {
@@ -644,6 +887,17 @@ daemon
644
887
  }
645
888
  process.exit(0);
646
889
  }
890
+ if (process.platform === 'linux') {
891
+ if (!unitExists()) {
892
+ log(`systemd user unit '${SERVICE_NAME}' is not installed.`);
893
+ process.exit(0);
894
+ }
895
+ // Graceful daemon shutdown first, then disable + remove the unit.
896
+ await stopDaemon(BRIDGE_WS_PORT);
897
+ disableUnit();
898
+ log(`systemd user unit '${SERVICE_NAME}' removed.`);
899
+ process.exit(0);
900
+ }
647
901
  if (!existsSync(PLIST_PATH)) {
648
902
  log('LaunchAgent not installed');
649
903
  return;
@@ -949,6 +1203,7 @@ program
949
1203
  .option('-e, --env <env>', 'PlatformIO environment for default firmware path')
950
1204
  .option('-f, --firmware <path>', 'Firmware .bin path')
951
1205
  .option('--build', 'Build the PlatformIO environment before upload')
1206
+ .option('--stage', 'Stage for pull OTA: the board installs it on its next feed pull (no live WS needed)')
952
1207
  .action(async (target, opts) => {
953
1208
  const { readDaemonInfo, findDaemonPort } = await import('./session-registry.js');
954
1209
  const info = readDaemonInfo();
@@ -962,12 +1217,25 @@ program
962
1217
  execFileSync(pio, ['run', '-e', env], {
963
1218
  cwd: join(projectRootPath(), 'esp32'),
964
1219
  stdio: 'inherit',
1220
+ windowsHide: true,
965
1221
  });
966
1222
  }
967
1223
  // Send the canonical board string, not the raw alias — the daemon matches
968
1224
  // `target` against device_info.board, so `ttgo` must become `ttgo_t_display`.
969
1225
  const daemonTarget = resolveEsp32OtaDaemonTarget(target);
970
1226
  const firmwarePath = resolveEsp32FirmwarePath(target, opts.env, opts.firmware);
1227
+ if (opts.stage) {
1228
+ // Pull-OTA staging: for wake-sync-sleep boards (XTeink X3/X4) that never
1229
+ // hold a live WS. The daemon adverts the build on the board's feed pulls;
1230
+ // the board downloads + flashes itself on its next wake.
1231
+ const { statusCode, body } = await postJsonWithTimeout(`http://127.0.0.1:${port}/esp32/ota`, { target: daemonTarget, firmwarePath, stage: true }, 30_000);
1232
+ if (statusCode < 200 || statusCode >= 300 || body.ok === false) {
1233
+ throw new Error(String(body.error ?? `HTTP ${statusCode}`));
1234
+ }
1235
+ log(`Staged for ${body.board}: ${formatBytes(body.bytes)} md5=${body.md5}`);
1236
+ log('The board installs it on its next feed pull (battery cadence: typically within 15-60 min).');
1237
+ return;
1238
+ }
971
1239
  log(`Uploading ${firmwarePath} to ${daemonTarget} via daemon :${port}...`);
972
1240
  let { statusCode, body } = await postJsonWithTimeout(`http://127.0.0.1:${port}/esp32/ota`, { target: daemonTarget, firmwarePath }, 15 * 60_000);
973
1241
  if (body.ok === false && String(body.error ?? '').startsWith('firmware_unreadable')) {
@@ -1149,35 +1417,55 @@ task
1149
1417
  .action(async (opts) => {
1150
1418
  await postTaskClose({ signal: 'manual', outcome: 'abandoned', sessionId: opts.session });
1151
1419
  });
1420
+ // ===== Optional Python BLE runtime =====
1421
+ const ble = program.command('ble').description('Prepare optional Python support for BLE display devices');
1422
+ ble
1423
+ .command('setup')
1424
+ .description('Create the private BLE environment and install its Python dependencies')
1425
+ .action(() => {
1426
+ const runtime = prepareBleRuntime();
1427
+ if (runtime)
1428
+ log(`BLE support ready (${runtime.python}).`);
1429
+ });
1430
+ ble
1431
+ .command('status')
1432
+ .description('Show whether optional BLE support is ready')
1433
+ .action(() => {
1434
+ const status = getBleRuntimeStatus();
1435
+ if (status.ready) {
1436
+ log(`BLE support ready (${status.python}).`);
1437
+ return;
1438
+ }
1439
+ log(`BLE support is not ready: ${status.reason}. Run \`agentdeck ble setup\`.`);
1440
+ process.exitCode = 1;
1441
+ });
1152
1442
  // ===== iDotMatrix commands =====
1153
1443
  const idotmatrix = program.command('idotmatrix').description('Manage iDotMatrix BLE pixel display devices');
1154
1444
  idotmatrix
1155
1445
  .command('scan')
1156
1446
  .description('Discover iDotMatrix devices via BLE')
1157
1447
  .action(async () => {
1158
- const { dirname } = await import('path');
1159
- const { fileURLToPath } = await import('url');
1160
- const { spawn } = await import('child_process');
1161
- const distPath = dirname(fileURLToPath(import.meta.url));
1162
- const projectRoot = join(distPath, '..', '..');
1163
- const venvPython = join(projectRoot, '.venv', 'bin', 'python');
1164
- const scanScript = join(projectRoot, 'bridge', 'src', 'idotmatrix', 'scan.py');
1448
+ const runtime = prepareBleRuntime();
1449
+ if (!runtime)
1450
+ return;
1165
1451
  log('Scanning for BLE devices (5 seconds)...');
1166
- const py = spawn(venvPython, [scanScript]);
1167
- let stdoutData = '';
1168
- py.stdout.on('data', (data) => {
1169
- stdoutData += data.toString();
1170
- });
1171
- py.on('close', (code) => {
1452
+ try {
1453
+ // Panels are identified by advertised service UUID first and known name
1454
+ // families second; settings.json `idotmatrixNamePrefixes` widens the
1455
+ // latter for a rebranded panel that hides its service.
1456
+ const { loadIDotMatrixNamePrefixes } = await import('./idotmatrix/idotmatrix-settings.js');
1457
+ const { code, stdout } = await runBlePython(runtime.python, [runtime.paths.scripts.idotmatrixScan, ...loadIDotMatrixNamePrefixes()], true);
1172
1458
  if (code !== 0) {
1173
1459
  log('Failed to run scan script.');
1174
- process.exit(1);
1460
+ process.exitCode = 1;
1461
+ return;
1175
1462
  }
1176
1463
  try {
1177
- const devices = JSON.parse(stdoutData.trim());
1464
+ const devices = JSON.parse(stdout.trim());
1178
1465
  if (devices.error) {
1179
1466
  log(`Scan error: ${devices.error}`);
1180
- process.exit(1);
1467
+ process.exitCode = 1;
1468
+ return;
1181
1469
  }
1182
1470
  if (!Array.isArray(devices) || devices.length === 0) {
1183
1471
  log('No BLE devices found.');
@@ -1189,10 +1477,15 @@ idotmatrix
1189
1477
  log(`${prefix}${d.name} (${d.address})`);
1190
1478
  }
1191
1479
  }
1192
- catch (e) {
1193
- log(`Failed to parse scan output: ${stdoutData}`);
1480
+ catch {
1481
+ log(`Failed to parse scan output: ${stdout}`);
1482
+ process.exitCode = 1;
1194
1483
  }
1195
- });
1484
+ }
1485
+ catch (err) {
1486
+ log(`Failed to start BLE scan: ${err instanceof Error ? err.message : String(err)}`);
1487
+ process.exitCode = 1;
1488
+ }
1196
1489
  });
1197
1490
  idotmatrix
1198
1491
  .command('add <address>')
@@ -1243,9 +1536,6 @@ idotmatrix
1243
1536
  .option('-a, --address <address>', 'BLE Address (defaults to first configured device)')
1244
1537
  .action(async (valueStr, opts) => {
1245
1538
  const { loadIDotMatrixDevices } = await import('./idotmatrix/idotmatrix-settings.js');
1246
- const { dirname } = await import('path');
1247
- const { fileURLToPath } = await import('url');
1248
- const { spawn } = await import('child_process');
1249
1539
  const value = parseInt(valueStr, 10);
1250
1540
  if (isNaN(value) || value < 5 || value > 100) {
1251
1541
  log('Brightness value must be between 5 and 100.');
@@ -1260,22 +1550,30 @@ idotmatrix
1260
1550
  }
1261
1551
  targetAddress = devices[0].address;
1262
1552
  }
1263
- const distPath = dirname(fileURLToPath(import.meta.url));
1264
- const projectRoot = join(distPath, '..', '..');
1265
- const venvPython = join(projectRoot, '.venv', 'bin', 'python');
1266
- const brightnessScript = join(projectRoot, 'bridge', 'src', 'idotmatrix', 'brightness.py');
1553
+ const runtime = prepareBleRuntime();
1554
+ if (!runtime)
1555
+ return;
1267
1556
  log(`Setting brightness of ${targetAddress} to ${value}%...`);
1268
- const py = spawn(venvPython, [brightnessScript, '-a', targetAddress, '-b', String(value)], {
1269
- stdio: 'inherit'
1270
- });
1271
- py.on('close', (code) => {
1557
+ try {
1558
+ const { code } = await runBlePython(runtime.python, [
1559
+ runtime.paths.scripts.idotmatrixBrightness,
1560
+ '-a',
1561
+ targetAddress,
1562
+ '-b',
1563
+ String(value),
1564
+ ]);
1272
1565
  if (code === 0) {
1273
1566
  log('Brightness updated successfully.');
1274
1567
  }
1275
1568
  else {
1276
1569
  log(`Failed to update brightness, process exited with code ${code}`);
1570
+ process.exitCode = code ?? 1;
1277
1571
  }
1278
- });
1572
+ }
1573
+ catch (err) {
1574
+ log(`Failed to start brightness command: ${err instanceof Error ? err.message : String(err)}`);
1575
+ process.exitCode = 1;
1576
+ }
1279
1577
  });
1280
1578
  idotmatrix
1281
1579
  .command('sync [address]')
@@ -1286,9 +1584,6 @@ idotmatrix
1286
1584
  .action(async (addressOpt, opts) => {
1287
1585
  const { loadIDotMatrixDevices } = await import('./idotmatrix/idotmatrix-settings.js');
1288
1586
  const { readDaemonInfo, findDaemonPort } = await import('./session-registry.js');
1289
- const { dirname } = await import('path');
1290
- const { fileURLToPath } = await import('url');
1291
- const { spawn } = await import('child_process');
1292
1587
  let targetAddress = addressOpt;
1293
1588
  let defaultBrightness = 100;
1294
1589
  const devices = loadIDotMatrixDevices();
@@ -1314,17 +1609,30 @@ idotmatrix
1314
1609
  ? parseInt(opts.port, 10)
1315
1610
  : (info?.httpPort ?? info?.port ?? findDaemonPort() ?? BRIDGE_WS_PORT);
1316
1611
  const url = `http://127.0.0.1:${port}`;
1317
- const distPath = dirname(fileURLToPath(import.meta.url));
1318
- const projectRoot = join(distPath, '..', '..');
1319
- const venvPython = join(projectRoot, '.venv', 'bin', 'python');
1320
- const syncScript = join(projectRoot, 'bridge', 'src', 'idotmatrix', 'sync.py');
1612
+ const runtime = prepareBleRuntime();
1613
+ if (!runtime)
1614
+ return;
1321
1615
  log(`Starting BLE sync client linking to bridge at ${url} (brightness ${brightness}%, boost ${boost}x)...`);
1322
- const py = spawn(venvPython, [syncScript, '-a', targetAddress, '-u', url, '-b', String(brightness), '--boost', String(boost)], {
1323
- stdio: 'inherit'
1324
- });
1325
- py.on('close', (code) => {
1616
+ try {
1617
+ const { code } = await runBlePython(runtime.python, [
1618
+ runtime.paths.scripts.idotmatrixSync,
1619
+ '-a',
1620
+ targetAddress,
1621
+ '-u',
1622
+ url,
1623
+ '-b',
1624
+ String(brightness),
1625
+ '--boost',
1626
+ String(boost),
1627
+ ]);
1326
1628
  log(`Sync process exited with code ${code}`);
1327
- });
1629
+ if (code !== 0)
1630
+ process.exitCode = code ?? 1;
1631
+ }
1632
+ catch (err) {
1633
+ log(`Failed to start BLE sync: ${err instanceof Error ? err.message : String(err)}`);
1634
+ process.exitCode = 1;
1635
+ }
1328
1636
  });
1329
1637
  // ===== Divoom Timebox Mini Light commands =====
1330
1638
  const timebox = program.command('timebox').description('Manage Divoom Timebox Mini devices (Bluetooth LE)');
@@ -1332,22 +1640,22 @@ timebox
1332
1640
  .command('scan')
1333
1641
  .description('Discover BLE TimeBox-mini-light peripherals')
1334
1642
  .action(async () => {
1335
- const paths = resolveTimeboxSyncPaths();
1643
+ const runtime = prepareBleRuntime();
1644
+ if (!runtime)
1645
+ return;
1336
1646
  log('Scanning for BLE devices (5 seconds)...');
1337
- const py = spawn(paths.python, [paths.scanScript]);
1338
- let stdoutData = '';
1339
- py.stdout.on('data', (data) => {
1340
- stdoutData += data.toString();
1341
- });
1342
- py.on('close', (code) => {
1647
+ try {
1648
+ const { code, stdout } = await runBlePython(runtime.python, [runtime.paths.scripts.timeboxScan], true);
1343
1649
  if (code !== 0) {
1344
- log('BLE scan failed (is bleak installed in .venv?).');
1650
+ log('BLE scan failed.');
1651
+ process.exitCode = 1;
1345
1652
  return;
1346
1653
  }
1347
1654
  try {
1348
- const devices = JSON.parse(stdoutData.trim());
1655
+ const devices = JSON.parse(stdout.trim());
1349
1656
  if (devices.error) {
1350
1657
  log(`BLE scan error: ${devices.error}`);
1658
+ process.exitCode = 1;
1351
1659
  return;
1352
1660
  }
1353
1661
  if (!Array.isArray(devices) || devices.length === 0) {
@@ -1363,9 +1671,14 @@ timebox
1363
1671
  log('\nAdd the starred BLE device with: agentdeck timebox add <address>');
1364
1672
  }
1365
1673
  catch {
1366
- log(`Failed to parse BLE scan output: ${stdoutData}`);
1674
+ log(`Failed to parse BLE scan output: ${stdout}`);
1675
+ process.exitCode = 1;
1367
1676
  }
1368
- });
1677
+ }
1678
+ catch (err) {
1679
+ log(`Failed to start BLE scan: ${err instanceof Error ? err.message : String(err)}`);
1680
+ process.exitCode = 1;
1681
+ }
1369
1682
  });
1370
1683
  timebox
1371
1684
  .command('add <address>')
@@ -1442,11 +1755,22 @@ async function runTimeboxSync(targetOpt, opts, once) {
1442
1755
  ? parseInt(opts.bridgePort, 10)
1443
1756
  : (info?.httpPort ?? info?.port ?? findDaemonPort() ?? BRIDGE_WS_PORT);
1444
1757
  const url = `http://127.0.0.1:${bridgePort}`;
1445
- const paths = resolveTimeboxSyncPaths();
1446
- const args = [paths.bleScript, '--address', id, '--url', url, '--brightness', String(brightness), ...(once ? ['--once'] : [])];
1758
+ const runtime = prepareBleRuntime();
1759
+ if (!runtime)
1760
+ return;
1761
+ const args = [
1762
+ runtime.paths.scripts.timeboxSync,
1763
+ '--address',
1764
+ id,
1765
+ '--url',
1766
+ url,
1767
+ '--brightness',
1768
+ String(brightness),
1769
+ ...(once ? ['--once'] : []),
1770
+ ];
1447
1771
  log(`${once ? 'Sending one Timebox frame' : 'Starting Timebox sync'} via BLE ${id} from ${url} (brightness ${brightness}%)...`);
1448
- const py = spawn(paths.python, args, { stdio: 'inherit' });
1449
- py.on('close', (code) => {
1772
+ try {
1773
+ const { code } = await runBlePython(runtime.python, args);
1450
1774
  if (once) {
1451
1775
  if (code === 0) {
1452
1776
  log('Timebox test frame sent.');
@@ -1458,8 +1782,14 @@ async function runTimeboxSync(targetOpt, opts, once) {
1458
1782
  }
1459
1783
  else {
1460
1784
  log(`Timebox sync process exited with code ${code}`);
1785
+ if (code !== 0)
1786
+ process.exitCode = code ?? 1;
1461
1787
  }
1462
- });
1788
+ }
1789
+ catch (err) {
1790
+ log(`Failed to start Timebox BLE command: ${err instanceof Error ? err.message : String(err)}`);
1791
+ process.exitCode = 1;
1792
+ }
1463
1793
  }
1464
1794
  timebox
1465
1795
  .command('test [target]')
@@ -2259,7 +2589,7 @@ export async function runCli(argv = process.argv) {
2259
2589
  await showDefaultStatusOrHelp();
2260
2590
  return;
2261
2591
  }
2262
- program.parse(argv);
2592
+ program.parse(applyGlobalEnvArgs(argv));
2263
2593
  }
2264
2594
  export { program };
2265
2595
  if (isMainModule()) {