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 +9 -3
- package/launcher.mjs +38 -34
- package/package.json +1 -1
- package/service.mjs +133 -10
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')
|
|
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
|
-
//
|
|
350
|
-
|
|
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
|
|
32
|
-
{ label: 'Hermes
|
|
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
|
|
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
|
|
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') +
|
|
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('│
|
|
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
|
-
|
|
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
|
|
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: '
|
|
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
|
|
226
|
-
options.push({ key: 'back', label: '
|
|
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: '
|
|
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
|
-
|
|
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:
|
|
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
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 --
|
|
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
|
|
246
|
-
return new Set(versions.map((s) => s.match(/\d+\.\d+\.\d
|
|
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 =
|
|
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 =
|
|
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') {
|
|
488
|
-
//
|
|
489
|
-
|
|
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
|
|