thinkpool-pair 0.7.291 → 0.7.292

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.
package/bridge.mjs CHANGED
@@ -252,7 +252,9 @@ if (argv[0] === 'restart-service') {
252
252
  const arg1 = argv[1] || ''
253
253
  const svcRoom = (!arg1 || arg1.startsWith('-')) ? null : arg1.toUpperCase().trim()
254
254
  const svc = await import('./service.mjs')
255
- const ok = argv.includes('--current') ? svc.restartService(svcRoom) : svc.updateService(svcRoom)
255
+ const ok = argv.includes('--current')
256
+ ? svc.restartService(svcRoom)
257
+ : await svc.updateAndConfirmService(svcRoom)
256
258
  process.exit(ok ? 0 : 1)
257
259
  }
258
260
 
@@ -346,8 +348,12 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
346
348
  installService: ({ room = null, agentCmd } = {}) => { svc.installService(room, agentCmd ? [agentCmd] : []) },
347
349
  uninstallService: ({ room = null } = {}) => { svc.uninstallService(room) },
348
350
  restartService: ({ room = null } = {}) => { svc.restartService(room) },
349
- // Menu and CLI share the same registry-resolve → repin → reload implementation.
350
- restartUpdateService: ({ room = null } = {}) => { svc.updateService(room) },
351
+ // The menu waits for OS-level proof before claiming success. In-service web
352
+ // updates keep using updateService(), whose non-blocking handoff is required
353
+ // because replacing the service tears down its own process tree.
354
+ restartUpdateService: async ({ room = null } = {}) => process.platform === 'win32'
355
+ ? svc.updateService(room)
356
+ : svc.updateAndConfirmService(room),
351
357
  login: async () => { const { runLogin } = await import('./account.mjs'); await runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) },
352
358
  // Provider config is NOT terminal — it writes provider.json and returns to the
353
359
  // menu so you can pick a model, switch back, or do something else. (It used to
package/launcher.mjs CHANGED
@@ -16,7 +16,7 @@ import path from 'node:path'
16
16
  import { execSync } from 'node:child_process'
17
17
  import { detectProvider, fetchModels, ANTHROPIC_COMPATIBLE, gatewayHint } from './byok-detect.mjs'
18
18
  import { probeHermesRuntime } from './hermes-probe.mjs'
19
- import { darwinServiceRunning } from './service.mjs'
19
+ import { darwinServiceRunning, serviceRuntimeVersion } from './service.mjs'
20
20
 
21
21
  const HOME = os.homedir()
22
22
  const CFG_DIR = path.join(HOME, '.thinkpool-pair')
@@ -28,16 +28,8 @@ const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./pack
28
28
 
29
29
  export const KNOWN_AGENTS = [
30
30
  { label: 'Claude Code', cmd: 'claude', resume: ['--continue'] },
31
- { label: 'Codex CLI', cmd: 'codex' },
32
- { label: 'Hermes Agent', cmd: 'thinkpool' },
33
- { label: 'Gemini CLI', cmd: 'gemini' },
34
- { label: 'Aider', cmd: 'aider' },
35
- { label: 'Cursor CLI', cmd: 'cursor-agent' },
36
- { label: 'opencode', cmd: 'opencode' },
37
- { label: 'Copilot CLI', cmd: 'copilot' },
38
- { label: 'Goose', cmd: 'goose' },
39
- { label: 'Crush', cmd: 'crush' },
40
- { label: 'Qwen Code', cmd: 'qwen' },
31
+ { label: 'Codex', cmd: 'codex' },
32
+ { label: 'Hermes', cmd: 'thinkpool' },
41
33
  ]
42
34
 
43
35
  function onPath(c) {
@@ -82,6 +74,7 @@ export function detectState() {
82
74
  const isCustom = !!provider?.provider && provider.provider !== 'anthropic'
83
75
  const hermesInstalled = onPath('hermes')
84
76
  const hermesReady = onPath('thinkpool') && probeHermesRuntime().available
77
+ const accountSvc = serviceLoaded(null)
85
78
  return {
86
79
  loggedIn: !!auth?.refresh_token,
87
80
  email: auth?.email || auth?.user?.email || null,
@@ -90,7 +83,9 @@ export function detectState() {
90
83
  agents: KNOWN_AGENTS.filter(a => onPath(a.cmd) && (a.cmd !== 'thinkpool' || hermesReady)),
91
84
  hermesInstalled,
92
85
  hermesReady,
93
- accountSvc: serviceLoaded(null),
86
+ accountSvc,
87
+ serviceVersion: accountSvc ? serviceRuntimeVersion(null) : null,
88
+ platform: process.platform,
94
89
  cwd: process.cwd(),
95
90
  version: VERSION,
96
91
  }
@@ -107,7 +102,19 @@ const C = {
107
102
  // reflects the change (real bridge passes detectState; tests omit it to keep the
108
103
  // injected state stable).
109
104
  export async function runLauncher({ actions, io, state = detectState(), refresh = null }) {
110
- const resync = () => { if (refresh) state = refresh() }
105
+ const normalizeState = (next) => ({
106
+ ...next,
107
+ agents: Array.isArray(next?.agents) ? next.agents : [],
108
+ loggedIn: !!next?.loggedIn,
109
+ accountSvc: !!next?.accountSvc,
110
+ serviceVersion: typeof next?.serviceVersion === 'string' && next.serviceVersion ? next.serviceVersion : null,
111
+ platform: next?.platform || process.platform,
112
+ provider: next?.provider || 'Anthropic (default)',
113
+ cwd: next?.cwd || process.cwd(),
114
+ version: next?.version || VERSION,
115
+ })
116
+ state = normalizeState(state)
117
+ const resync = () => { if (refresh) state = normalizeState(refresh()) }
111
118
  const askChoice = async (prompt, options, def = 1) => {
112
119
  options.forEach((o, i) => io.print(` ${C.cyan(String(i + 1))}) ${o.label}${o.hint ? ' ' + C.dim(o.hint) : ''}`))
113
120
  const a = await io.ask(`\n ${prompt} ${C.dim(`[${def}]`)} ${C.cyan('▸')} `)
@@ -121,12 +128,17 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
121
128
 
122
129
  const header = () => {
123
130
  io.print('')
124
- io.print(' ' + C.dim('┌ ') + C.bold('thinkpool-pair') + (state.version ? C.dim(` v${state.version}`) : '') + C.dim(' ' + '─'.repeat(state.version ? 40 - (` v${state.version}`).length : 40)))
131
+ io.print(' ' + C.dim('┌ ') + C.bold('thinkpool-pair') + C.dim(' ' + '─'.repeat(40)))
132
+ io.print(` ${C.dim('│ launcher ')} ${state.version ? `v${state.version}` : 'version unavailable'}`)
125
133
  io.print(` ${C.dim('│ account ')} ${state.loggedIn ? C.green((state.email || 'linked') + ' ✓') : C.yellow('not linked')}`)
126
- io.print(` ${C.dim('│ provider ')} ${state.provider}`)
127
- io.print(` ${C.dim('│ agents ')} ${state.agents.length ? state.agents.map(a => a.label).join(', ') : C.yellow('none on PATH')}`)
134
+ io.print(` ${C.dim('│ agents ')} ${state.agents.length ? state.agents.map(a => a.label).join(', ') : C.yellow('none ready')}`)
128
135
  io.print(` ${C.dim('│ directory')} ${C.dim(state.cwd)}`)
129
- io.print(` ${C.dim('│ service ')} ${state.accountSvc ? C.green('account service installed') : 'none'}`)
136
+ const bridgeStatus = !state.accountSvc
137
+ ? 'not installed'
138
+ : state.platform === 'win32'
139
+ ? C.green('startup entry installed')
140
+ : C.green(`running${state.serviceVersion ? ` v${state.serviceVersion}` : ' (version unavailable)'}`)
141
+ io.print(` ${C.dim('│ bridge ')} ${bridgeStatus}`)
130
142
  io.print(' ' + C.dim('└' + '─'.repeat(54)) + '\n')
131
143
  }
132
144
 
@@ -135,7 +147,7 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
135
147
  const pairRoom = async () => {
136
148
  const room = (await io.ask(`\n Room code ${C.dim('(from /code in the web app)')} ${C.cyan('▸')} `)).toUpperCase().trim()
137
149
  if (!room) { io.print(' ' + C.yellow('no room — back to menu')); return }
138
- if (!state.agents.length) { io.print('\n ' + C.yellow('No coding-agent CLI on your PATH (claude / codex / gemini / aider …).')); return }
150
+ if (!state.agents.length) { io.print('\n ' + C.yellow('No supported agent runtime is ready (Claude Code, Codex, or Hermes).')); return }
139
151
  let agent = state.agents[0]
140
152
  if (state.agents.length > 1) { io.print('\n Share which agent?'); agent = state.agents[await askChoice('agent', state.agents.map(a => ({ label: a.label })))] }
141
153
  io.print('\n Run mode?')
@@ -217,30 +229,20 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
217
229
  const settingsMenu = async () => {
218
230
  for (;;) {
219
231
  const options = [
220
- { key: 'provider', label: 'LLM provider', hint: `current: ${state.provider}` },
232
+ { key: 'provider', label: 'Provider', hint: `current: ${state.provider}` },
221
233
  { key: 'account', label: 'Account', hint: state.loggedIn ? `linked: ${state.email || 'yes'}` : 'not linked' },
222
- { key: 'restart', label: 'Restart & update the bridge', hint: state.accountSvc ? 'pull the newest published version, then restart — sessions resume' : 'none installed' },
223
- { key: 'remove', label: 'Remove the background service', hint: state.accountSvc ? '' : 'none installed' },
224
234
  ]
225
- if (state.hermesInstalled) options.push({ key: 'hermes', label: 'Hermes Agent', hint: state.hermesReady ? 'isolated profile ready' : 'set up isolated profile + delegation guard' })
226
- options.push({ key: 'back', label: '← Back to main menu', hint: '' })
235
+ if (state.hermesInstalled) options.push({ key: 'hermes', label: state.hermesReady ? 'Hermes profile' : 'Set up Hermes', hint: state.hermesReady ? 'isolated profile ready' : 'set up isolated profile + delegation guard' })
236
+ options.push({ key: 'back', label: 'Back to main menu', hint: '' })
227
237
  const picked = options[await askChoice('\n change', options, options.length)]
228
238
  if (picked.key === 'back') return
229
239
  if (picked.key === 'provider') await providerMenu()
230
240
  else if (picked.key === 'account') { await actions.login(); resync(); return }
231
- else if (picked.key === 'restart') {
232
- if (state.accountSvc) { await actions.restartUpdateService({ room: null }); resync() }
233
- else io.print(' ' + C.yellow('no background service installed'))
234
- }
235
- else if (picked.key === 'remove') {
236
- if (state.accountSvc) { await actions.uninstallService({ room: null }); resync() }
237
- else io.print(' ' + C.yellow('no background service installed'))
238
- }
239
241
  else if (picked.key === 'hermes') {
240
242
  const setup = await askChoice('\n Hermes profile', [
241
243
  { label: 'Clone active profile', hint: 'copies provider/config explicitly; fresh ThinkPool session history' },
242
244
  { label: 'Create clean profile', hint: 'no copied credentials or bundled skills; configure provider afterward' },
243
- { label: '← Back', hint: '' },
245
+ { label: 'Back', hint: '' },
244
246
  ], 3)
245
247
  if (setup !== 2) { await actions.setupHermes({ mode: setup === 0 ? 'clone' : 'clean' }); resync() }
246
248
  }
@@ -261,14 +263,16 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
261
263
  // Slots 1+2 swap on whether the always-on service is already installed.
262
264
  const items = state.accountSvc
263
265
  ? [
264
- { key: 'restart', label: 'Restart & update the bridge', hint: 'pull the newest version + restart — sessions resume' },
266
+ state.platform === 'win32'
267
+ ? { key: 'restart', label: 'Update startup entry', hint: 'installs the latest version; relaunch the bridge window to apply' }
268
+ : { key: 'restart', label: 'Restart & update bridge', hint: 'install the latest published version; sessions resume' },
265
269
  { key: 'uninstall', label: 'Remove background service', hint: 'stop serving your sessions on this device' },
266
270
  ]
267
271
  : [
268
272
  { key: 'serve', label: 'Serve all my sessions', hint: 'runs here, Ctrl-C stops it' },
269
273
  { key: 'service', label: 'Always-on background service', hint: 'recommended — survives reboot, restarts on crash' },
270
274
  ]
271
- items.push({ key: 'settings', label: 'Settings', hint: 'provider · account · restart · remove service' })
275
+ items.push({ key: 'settings', label: 'Settings', hint: `provider · account${state.hermesInstalled ? ' · Hermes profile' : ''}` })
272
276
  items.push({ key: 'quit', label: 'Quit', hint: '' })
273
277
  const pick = items[await askChoice('choose', items)]
274
278
  if (pick.key === 'quit') return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.291",
3
+ "version": "0.7.292",
4
4
  "description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
5
5
  "type": "module",
6
6
  "bin": {
package/service.mjs CHANGED
@@ -217,7 +217,7 @@ StandardError=append:${log}
217
217
  [Install]
218
218
  WantedBy=default.target
219
219
  `
220
- return { file, content, logDir, post: ['systemctl --user daemon-reload', `systemctl --user enable --now ${label(room)}.service`], note: 'Enabled as a systemd --user service. Run `loginctl enable-linger $USER` once to keep it running after logout.' }
220
+ return { file, content, logDir, post: ['systemctl --user daemon-reload', `systemctl --user enable ${label(room)}.service`, `systemctl --user restart ${label(room)}.service`], note: 'Enabled as a systemd --user service. Run `loginctl enable-linger $USER` once to keep it running after logout.' }
221
221
  }
222
222
 
223
223
  if (platform === 'win32') {
@@ -242,8 +242,40 @@ WantedBy=default.target
242
242
  // Parse both legacy npx commands (`thinkpool-pair@<ver>`) and stable runtime commands
243
243
  // (`.../runtimes/<ver>/...`).
244
244
  export function parseRunningPairVersions(out) {
245
- const versions = String(out).match(/(?:thinkpool-pair@|runtimes\/)(\d+\.\d+\.\d+)/g) || []
246
- return new Set(versions.map((s) => s.match(/\d+\.\d+\.\d+/)?.[0]).filter(Boolean))
245
+ const versions = String(out).match(/(?:thinkpool-pair@|runtimes[\\/])(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)/g) || []
246
+ return new Set(versions.map((s) => s.match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/)?.[0]).filter(Boolean))
247
+ }
248
+
249
+ // Return a version only when the OS service manager proves that THIS service is
250
+ // both live and configured to execute one unambiguous pair runtime. This is
251
+ // deliberately stricter than isServiceInstalled(): a plist/unit on disk, or a
252
+ // process list that happens to contain thinkpool-pair, is not update evidence.
253
+ // Windows' Startup-folder tier has no service-manager identity to query, so an
254
+ // exact running version cannot be attributed safely and is therefore unproven.
255
+ export function serviceRuntimeSnapshot(room, { platform = process.platform, exec = execSync } = {}) {
256
+ const exactVersion = (output) => {
257
+ const versions = parseRunningPairVersions(output)
258
+ return versions.size === 1 ? [...versions][0] : null
259
+ }
260
+ const id = label(room)
261
+ try {
262
+ if (platform === 'darwin') {
263
+ const out = exec(`launchctl print gui/$(id -u)/${id}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], shell: '/bin/bash' })
264
+ const pid = String(out).match(/(^|\n)[ \t]*pid = (\d+)[ \t]*($|\n)/)?.[2]
265
+ return darwinServiceRunning(out) ? { version: exactVersion(out), pid: pid || null } : null
266
+ }
267
+ if (platform === 'linux') {
268
+ const out = exec(`systemctl --user show ${id}.service --property=ActiveState --property=MainPID --property=ExecStart`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
269
+ const pid = String(out).match(/^MainPID=([1-9]\d*)$/m)?.[1]
270
+ if (!/^ActiveState=active$/m.test(String(out)) || !pid) return null
271
+ return { version: exactVersion(out), pid }
272
+ }
273
+ } catch { /* service manager could not prove a live runtime */ }
274
+ return null
275
+ }
276
+
277
+ export function serviceRuntimeVersion(room, options) {
278
+ return serviceRuntimeSnapshot(room, options)?.version || null
247
279
  }
248
280
 
249
281
  // A macOS update can be invoked FROM a ThinkPool Code room hosted by the very account
@@ -282,7 +314,7 @@ export function buildDarwinReloadHandoff({ targetLabel, targetFile, stagedFile,
282
314
  'launchctl enable "$dom/$target" 2>/dev/null || true',
283
315
  'for i in $(seq 1 30); do launchctl bootstrap "$dom" "$target_file" 2>/dev/null && break; sleep 0.3; done',
284
316
  'for i in $(seq 1 60); do if launchctl print "$dom/$target" 2>/dev/null | grep -F -- "$expected" >/dev/null && launchctl print "$dom/$target" 2>/dev/null | grep -F "state = running" >/dev/null; then ok=1; break; fi; sleep 0.5; done',
285
- `if [ "$ok" = 1 ]; then printf '%s\\n' '${JSON.stringify({ ok: true, version: String(version) })}' > "$tmp"; else rollback=0; if [ "$had_previous" = 1 ]; then launchctl bootout "$dom/$target" 2>/dev/null || true; for i in $(seq 1 40); do launchctl print "$dom/$target" >/dev/null 2>&1 || break; sleep 0.2; done; cp "$backup_file" "$target_file"; launchctl enable "$dom/$target" 2>/dev/null || true; for i in $(seq 1 30); do if launchctl bootstrap "$dom" "$target_file" 2>/dev/null; then rollback=1; break; fi; sleep 0.3; done; fi; if [ "$rollback" = 1 ]; then printf '%s\\n' '${JSON.stringify({ ok: false, version: String(version), error: 'new runtime not confirmed; previous service restored', rolledBack: true })}' > "$tmp"; else printf '%s\\n' '${JSON.stringify({ ok: false, version: String(version), error: 'new runtime not confirmed and rollback failed', rolledBack: false })}' > "$tmp"; fi; fi`,
317
+ `if [ "$ok" = 1 ]; then printf '%s\\n' '${JSON.stringify({ ok: true, version: String(version), target: String(targetLabel) })}' > "$tmp"; else rollback=0; if [ "$had_previous" = 1 ]; then launchctl bootout "$dom/$target" 2>/dev/null || true; for i in $(seq 1 40); do launchctl print "$dom/$target" >/dev/null 2>&1 || break; sleep 0.2; done; cp "$backup_file" "$target_file"; launchctl enable "$dom/$target" 2>/dev/null || true; for i in $(seq 1 30); do if launchctl bootstrap "$dom" "$target_file" 2>/dev/null; then rollback=1; break; fi; sleep 0.3; done; fi; if [ "$rollback" = 1 ]; then printf '%s\\n' '${JSON.stringify({ ok: false, version: String(version), target: String(targetLabel), error: 'new runtime not confirmed; previous service restored', rolledBack: true })}' > "$tmp"; else printf '%s\\n' '${JSON.stringify({ ok: false, version: String(version), target: String(targetLabel), error: 'new runtime not confirmed and rollback failed', rolledBack: false })}' > "$tmp"; fi; fi`,
286
318
  'mv "$tmp" "$status"',
287
319
  'rm -f "$staged_file" "$backup_file" "$helper_file"',
288
320
  'launchctl bootout "$dom/$helper" >/dev/null 2>&1 || true',
@@ -472,21 +504,109 @@ export function updateService(room, { exec = execSync, install = installService,
472
504
  return install(room, [], { version: target, staleProof: true }) !== false
473
505
  }
474
506
 
507
+ // Menu/CLI-only update path. updateService intentionally returns as soon as it has
508
+ // safely staged an update: on macOS it must do that so an independent launchd helper
509
+ // can replace the service without killing its caller mid-transaction. Consumers that
510
+ // need to say "updated" rather than "staged" must use this primitive instead.
511
+ //
512
+ // All dependencies are injectable so this contract can be tested without a service,
513
+ // a clock, or npm. The default timeout exceeds the launchd handoff's bounded reload
514
+ // and verification loops, but is still finite.
515
+ export async function updateAndConfirmService(room, {
516
+ platform = process.platform,
517
+ exec = execSync,
518
+ update = updateService,
519
+ install = installService,
520
+ active = isServiceInstalled,
521
+ snapshot = serviceRuntimeSnapshot,
522
+ readStatus = () => {
523
+ try { return JSON.parse(fs.readFileSync(path.join(os.homedir(), '.thinkpool-pair', 'update-status.json'), 'utf8')) } catch { return null }
524
+ },
525
+ now = Date.now,
526
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
527
+ timeoutMs = 55000,
528
+ pollMs = 250,
529
+ stderr = process.stderr,
530
+ } = {}) {
531
+ const before = snapshot(room, { platform, exec })
532
+ let target = null
533
+ const captureTarget = (...args) => {
534
+ const result = exec(...args)
535
+ const resolved = String(result).trim()
536
+ if (/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(resolved)) target = resolved
537
+ return result
538
+ }
539
+ const staged = update(room, { exec: captureTarget, install, active }) !== false
540
+ if (!staged || !target) {
541
+ stderr.write(' ⚠ update was not confirmed: staging the published runtime failed; the existing service was left unchanged.\n')
542
+ return false
543
+ }
544
+
545
+ const confirmation = () => {
546
+ const live = snapshot(room, { platform, exec })
547
+ const versionChanged = !!before && before.version !== target
548
+ const processChanged = !!before?.pid && !!live?.pid && before.pid !== live.pid
549
+ if (live?.version === target && (versionChanged || processChanged)) {
550
+ stderr.write(` ✓ bridge restart confirmed — running v${target}.\n`)
551
+ return { ok: true, live }
552
+ }
553
+ return { ok: false, live }
554
+ }
555
+ const printUnconfirmed = ({ live } = {}) => {
556
+ if (live?.version && live.version !== target) stderr.write(` ⚠ update v${target} was not confirmed: live service is still v${live.version}.\n`)
557
+ else if (live?.version === target) stderr.write(` ⚠ update v${target} was not confirmed: the service process did not restart.\n`)
558
+ else stderr.write(` ⚠ update v${target} was not confirmed: the service manager cannot prove the new runtime is running.\n`)
559
+ }
560
+
561
+ // systemd's restart is synchronous. The Windows Startup-folder tier has no
562
+ // authoritative live-service identity, so callers use updateService directly
563
+ // and label it as a next-launch update rather than a completed restart.
564
+ if (platform !== 'darwin') {
565
+ const proof = confirmation()
566
+ if (!proof.ok) printUnconfirmed(proof)
567
+ return proof.ok
568
+ }
569
+
570
+ const deadline = now() + Math.max(0, Number(timeoutMs) || 0)
571
+ const expectedTarget = label(room)
572
+ let lastProof = null
573
+ for (;;) {
574
+ const status = readStatus()
575
+ if (status && typeof status === 'object' && status.target === expectedTarget) {
576
+ if (status.ok === false && status.version === target) {
577
+ stderr.write(` ⚠ update v${target} was not confirmed: ${status.error || 'the launchd handoff failed'}.\n`)
578
+ return false
579
+ }
580
+ if (status.ok === true && status.version === target) {
581
+ lastProof = confirmation()
582
+ if (lastProof.ok) return true
583
+ }
584
+ }
585
+ if (now() >= deadline) {
586
+ if (lastProof) printUnconfirmed(lastProof)
587
+ else stderr.write(` ⚠ update v${target} timed out waiting for launchd confirmation; the service may have rolled back.\n`)
588
+ return false
589
+ }
590
+ await sleep(Math.max(1, Number(pollMs) || 1))
591
+ }
592
+ }
593
+
475
594
  // Ground truth: is the OS service manager ACTUALLY running our service right now?
476
595
  // A leftover plist/unit on disk does NOT mean it's loaded — `launchctl load` can
477
596
  // silently no-op (the 2026-06-26 "says installed but it's off" bug), and a unit can
478
597
  // be left disabled. Ask the manager, don't trust file presence. room falsy → account.
479
- export function serviceActive(room) {
480
- const plat = process.platform
598
+ export function serviceActive(room, { platform = process.platform, exec = execSync } = {}) {
599
+ const plat = platform
481
600
  const id = label(room)
482
601
  try {
483
602
  if (plat === 'darwin') {
484
- const out = execSync(`launchctl print gui/$(id -u)/${id}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
603
+ const out = exec(`launchctl print gui/$(id -u)/${id}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
485
604
  return darwinServiceRunning(out)
486
605
  }
487
- if (plat === 'linux') { execSync(`systemctl --user is-active --quiet ${id}.service`); return true }
488
- // Windows has no managed daemon — the Startup-folder script's presence is the only proxy.
489
- return fs.existsSync(buildArtifact(plat, { room }).file)
606
+ if (plat === 'linux') { exec(`systemctl --user is-active --quiet ${id}.service`); return true }
607
+ // A Startup entry is install evidence, never proof that its console process
608
+ // is alive. Windows has no per-user manager we can query authoritatively.
609
+ return false
490
610
  } catch { return false }
491
611
  }
492
612
 
@@ -495,6 +615,9 @@ export function serviceActive(room) {
495
615
  // service. Reflects launchd/systemd reality, not just file existence (so a plist that
496
616
  // failed to load reads as not-installed → the menu re-offers install, which self-heals).
497
617
  export function isServiceInstalled(room) {
618
+ if (process.platform === 'win32') {
619
+ try { return fs.existsSync(buildArtifact('win32', { room }).file) } catch { return false }
620
+ }
498
621
  return serviceActive(room)
499
622
  }
500
623