thinkpool-pair 0.7.239 → 0.7.240

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 (3) hide show
  1. package/launcher.mjs +8 -3
  2. package/package.json +1 -1
  3. package/service.mjs +153 -100
package/launcher.mjs CHANGED
@@ -16,6 +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
20
 
20
21
  const HOME = os.homedir()
21
22
  const CFG_DIR = path.join(HOME, '.thinkpool-pair')
@@ -57,13 +58,17 @@ function serviceFile(room) {
57
58
  return path.join(startup, `thinkpool-pair-${room || 'account'}.cmd`)
58
59
  }
59
60
 
60
- // Ground truth for the header: is the service actually LOADED, not just a plist on disk?
61
+ // Ground truth for the header: is the service actually RUNNING, not just registered?
61
62
  // `launchctl load` can silently no-op, leaving a plist that never loads — file-existence
62
- // would report "installed" while the bridge is off (the 2026-06-26 bug). Query the manager.
63
+ // would report "installed" while the bridge is off (the 2026-06-26 bug). A registered
64
+ // job can also be inactive with no pid, so inspect launchd's live state, not print's exit.
63
65
  function serviceLoaded(room) {
64
66
  const label = room ? `io.thinkpool.pair.${room.toLowerCase()}` : 'io.thinkpool.pair.account'
65
67
  try {
66
- if (process.platform === 'darwin') { execSync(`launchctl print gui/$(id -u)/${label}`, { stdio: 'ignore' }); return true }
68
+ if (process.platform === 'darwin') {
69
+ const out = execSync(`launchctl print gui/$(id -u)/${label}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
70
+ return darwinServiceRunning(out)
71
+ }
67
72
  if (process.platform === 'linux') { execSync(`systemctl --user is-active --quiet ${label}.service`); return true }
68
73
  return fs.existsSync(serviceFile(room)) // Windows: no daemon, file presence is the only proxy
69
74
  } catch { return false }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.239",
3
+ "version": "0.7.240",
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
@@ -13,8 +13,9 @@
13
13
  • account — `install-service` (no room) → supervises the ACCOUNT
14
14
  supervisor (`npx thinkpool-pair`, bare), which serves
15
15
  every session you're in. Pass room=null for this.
16
- Both launch `thinkpool-pair@latest`, so a restart pulls the newest
17
- publish; auto-update self-restart lives in bridge.mjs / account.mjs.
16
+ Pinned services launch an immutable local runtime directly. The optional
17
+ auto-update tier still resolves `thinkpool-pair@latest`; its self-restart
18
+ lives in bridge.mjs / account.mjs.
18
19
  ───────────────────────────────────────────────────────────── */
19
20
 
20
21
  import os from 'node:os'
@@ -37,10 +38,16 @@ const label = (room) => {
37
38
  const slug = (room) => room || 'account'
38
39
  const xml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
39
40
  const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`
40
- // Stale-proof launch wrapper: wipe any cached thinkpool-pair in the npx store, then exec
41
- // the pinned package FRESH. Folds update-local-service.sh into the builder — without this a
42
- // deliberate "Restart & update" (or any KeepAlive restart) can re-serve a stale npx cache,
43
- // which is what left Max's account bridge stuck on 0.7.75 while @latest was 0.7.78.
41
+ export function sanitizeServicePath(value, platform = process.platform) {
42
+ const sep = platform === 'win32' ? ';' : ':'
43
+ return String(value || '').split(sep).filter((part) => part && !part.includes('/.npm/_npx/')).join(sep)
44
+ }
45
+ export function darwinServiceRunning(output) {
46
+ const text = String(output || '')
47
+ return /(^|\n)[ \t]*state = running[ \t]*($|\n)/.test(text) && /(^|\n)[ \t]*pid = \d+[ \t]*($|\n)/.test(text)
48
+ }
49
+ // Legacy auto-update services still use npx. Wipe its disposable execution project
50
+ // before resolving the package so an @latest restart cannot re-serve a stale cache.
44
51
  // `home` is the platform-correct, runtime-expanded home token ("$HOME" on darwin under
45
52
  // /bin/bash; "$$HOME" inside a systemd unit so systemd emits a literal $HOME to bash).
46
53
  // npx caches the temporary execution project as a unit. Removing only the package can
@@ -75,15 +82,39 @@ function npmPath(platform = process.platform) {
75
82
  // Persistent services never execute through npx. Provision an immutable, versioned
76
83
  // runtime from the tarball URL (which bypasses stale npm packument metadata), then point
77
84
  // launchd/systemd directly at its bridge.mjs with the current Node binary.
78
- export function provisionRuntime(version, { exec = execSync } = {}) {
85
+ function runtimeVersion(entry) {
86
+ try {
87
+ const pkg = JSON.parse(fs.readFileSync(path.join(path.dirname(entry), 'package.json'), 'utf8'))
88
+ return typeof pkg?.version === 'string' ? pkg.version : null
89
+ } catch { return null }
90
+ }
91
+
92
+ export function provisionRuntime(version, { exec = execSync, root = path.join(os.homedir(), '.thinkpool-pair', 'runtimes') } = {}) {
79
93
  if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(version))) throw new Error(`invalid runtime version: ${version}`)
80
- const dir = path.join(os.homedir(), '.thinkpool-pair', 'runtimes', String(version))
94
+ const dir = path.join(root, String(version))
81
95
  const entry = path.join(dir, 'node_modules', 'thinkpool-pair', 'bridge.mjs')
82
- if (fs.existsSync(entry)) return entry
83
- fs.mkdirSync(dir, { recursive: true })
96
+ if (fs.existsSync(entry) && runtimeVersion(entry) === String(version)) return entry
97
+
98
+ // Install beside the target and only swap it into place after BOTH the entrypoint
99
+ // and package version prove the payload. A killed/poisoned npm install must never
100
+ // leave `runtimes/0.7.237` containing 0.7.229 and then pass forever on file existence.
101
+ const tmp = `${dir}.install-${process.pid}-${Date.now()}`
102
+ fs.rmSync(tmp, { recursive: true, force: true })
103
+ fs.mkdirSync(tmp, { recursive: true })
84
104
  const tarball = `https://registry.npmjs.org/thinkpool-pair/-/thinkpool-pair-${version}.tgz`
85
- exec(`${shq(npmPath())} install --prefix ${shq(dir)} ${shq(tarball)} --prefer-online --no-audit --no-fund`, { stdio: 'inherit', shell: '/bin/bash' })
86
- if (!fs.existsSync(entry)) throw new Error(`runtime install completed without ${entry}`)
105
+ const tmpEntry = path.join(tmp, 'node_modules', 'thinkpool-pair', 'bridge.mjs')
106
+ try {
107
+ exec(`${shq(npmPath())} install --prefix ${shq(tmp)} ${shq(tarball)} --prefer-online --no-audit --no-fund`, { stdio: 'inherit', shell: '/bin/bash' })
108
+ const actual = runtimeVersion(tmpEntry)
109
+ if (!fs.existsSync(tmpEntry) || actual !== String(version)) {
110
+ throw new Error(`runtime verification failed: requested ${version}, installed ${actual || 'no valid package'}`)
111
+ }
112
+ fs.rmSync(dir, { recursive: true, force: true })
113
+ fs.renameSync(tmp, dir)
114
+ } catch (e) {
115
+ fs.rmSync(tmp, { recursive: true, force: true })
116
+ throw e
117
+ }
87
118
  return entry
88
119
  }
89
120
 
@@ -91,10 +122,14 @@ export function provisionRuntime(version, { exec = execSync } = {}) {
91
122
  // room falsy → ACCOUNT service (bare `thinkpool-pair`, auto-serves all sessions).
92
123
  export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate = false, version = VERSION, staleProof = false, runtimeEntry = null } = {}) {
93
124
  const npx = npxPath(platform)
94
- const cwd = process.cwd()
125
+ // Account mode discovers each room's own bound directory, so its supervisor must use
126
+ // a stable home cwd. Persisting the caller's managed worktree here makes the service
127
+ // unstartable as soon as that lane is closed and its worktree is removed.
128
+ const cwd = room ? process.cwd() : os.homedir()
95
129
  const id = slug(room)
96
130
  const logDir = path.join(os.homedir(), '.thinkpool-pair')
97
131
  const log = path.join(logDir, `${id}.log`)
132
+ const servicePath = sanitizeServicePath(process.env.PATH || '', platform)
98
133
  const tail = cmdArgs.length ? ['--', ...cmdArgs] : []
99
134
  const desc = room ? `bridge (${room})` : 'account bridge'
100
135
  // OS-supervised tiers (launchd KeepAlive / systemd Restart) don't need --supervise.
@@ -116,8 +151,8 @@ export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate
116
151
  const autoUpdateEnv = autoUpdate ? { THINKPOOL_PAIR_AUTOUPDATE: '1' } : {}
117
152
 
118
153
  if (platform === 'darwin') {
119
- // launchd KeepAlive supervises. staleProof → wrap in a cache-wiping /bin/bash launch so
120
- // every (re)start re-fetches the pinned version fresh (deliberate-update path).
154
+ // launchd KeepAlive supervises. Stable runtimes execute directly; the legacy npx
155
+ // shape keeps its cache-wiping wrapper for auto-update compatibility.
121
156
  const args = runtimeEntry
122
157
  ? [process.execPath, runtimeEntry, ...(room ? [room] : []), ...tail]
123
158
  : staleProof
@@ -140,39 +175,12 @@ export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate
140
175
  <key>WorkingDirectory</key><string>${xml(cwd)}</string>
141
176
  <key>StandardOutPath</key><string>${xml(log)}</string>
142
177
  <key>StandardErrorPath</key><string>${xml(log)}</string>
143
- <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(process.env.PATH || '')}</string>${autoUpdate ? '<key>THINKPOOL_PAIR_AUTOUPDATE</key><string>1</string>' : ''}</dict>
178
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(servicePath)}</string>${autoUpdate ? '<key>THINKPOOL_PAIR_AUTOUPDATE</key><string>1</string>' : ''}</dict>
144
179
  </dict></plist>\n`
145
- // Modern bootstrap/enable, NOT the deprecated `launchctl load`. On current macOS
146
- // (Darwin 24/25) `load` silently no-ops for a GUI LaunchAgent — the plist lands on
147
- // disk but never loads, so the bridge is "installed" yet off (the 2026-06-26 bug).
148
- // Sequence: bootout any existing instance (idempotent reinstall) → WAIT for it to
149
- // actually leave the domain (bootout is async; bootstrapping while teardown is in
150
- // flight throws "Bootstrap failed: 5: Input/output error") → enable (clears a stale
151
- // `disabled` override from a prior bootout / the account-storm cleanup) → bootstrap,
152
- // retried on the EIO race → a final `print` so the command's exit code reports the
153
- // real loaded state (installService warns if it ends not-loaded).
154
- const lbl = label(room), dom = 'gui/$(id -u)'
155
- const pr = `launchctl print ${dom}/${lbl} >/dev/null 2>&1`
156
- const post = [
157
- // Evict any existing instance so the REWRITTEN plist (new pinned version) is what
158
- // loads. bootout is async + occasionally sticky: if the old supervised process
159
- // won't leave the domain, the follow-up bootstrap throws "Bootstrap failed: 5:
160
- // Input/output error" and we'd SILENTLY keep running the OLD pinned version while
161
- // reporting success — the 2026-06-30 "Restart & update" stuck-on-0.7.96 bug. So:
162
- // bootout, wait for it to actually leave, and if it WON'T, SIGKILL the wedged pid
163
- // then bootout again (KeepAlive can't relaunch a job that's booting out).
164
- `launchctl bootout ${dom}/${lbl} 2>/dev/null || true`,
165
- `for i in $(seq 1 25); do ${pr} || break; sleep 0.2; done`,
166
- `if ${pr}; then p=$(launchctl print ${dom}/${lbl} 2>/dev/null | sed -n 's/.*[^a-z]pid = \\([0-9][0-9]*\\).*/\\1/p' | head -1); [ -n "$p" ] && kill -9 "$p" 2>/dev/null || true; launchctl bootout ${dom}/${lbl} 2>/dev/null || true; for i in $(seq 1 25); do ${pr} || break; sleep 0.2; done; fi`,
167
- // Clear a stale `disabled` override (from a prior bootout / account-storm cleanup),
168
- // then load the rewritten plist from disk, retried on the EIO teardown race.
169
- `launchctl enable ${dom}/${lbl} 2>/dev/null || true`,
170
- `ok=0; for i in $(seq 1 25); do launchctl bootstrap ${dom} "${file}" 2>/dev/null && { ok=1; break; }; sleep 0.3; done`,
171
- // Be HONEST about the outcome: if it never loaded, say so + give the exact manual
172
- // recovery — never let a failed reload masquerade as a successful update.
173
- `if [ "$ok" != 1 ] && ! ${pr}; then echo " ⚠ thinkpool-pair: launchd reload FAILED — run by hand: launchctl bootout ${dom}/${lbl} ; launchctl bootstrap ${dom} \\"${file}\\"" >&2; fi`,
174
- ].join('; ')
175
- return { file, content, logDir, post: [post], note: 'Loaded as a LaunchAgent — starts at login, restarts on crash.' }
180
+ // The destructive reload is intentionally NOT represented as an inline `post`
181
+ // command. installService stages this plist and hands the transaction to an
182
+ // independent one-shot launchd job (buildDarwinReloadHandoff below).
183
+ return { file, content, logDir, post: [], expectedRuntime: runtimeEntry || verSpec, note: 'Loaded as a LaunchAgent — starts at login, restarts on crash.' }
176
184
  }
177
185
 
178
186
  if (platform === 'linux') {
@@ -202,7 +210,7 @@ RestartSec=2
202
210
  # still restarted. Matches the bridge's exit-code contract (stop()=0, restart=non-zero).
203
211
  RestartPreventExitStatus=0
204
212
  WorkingDirectory=${cwd}
205
- Environment=PATH=${process.env.PATH || ''}
213
+ Environment=PATH=${servicePath}
206
214
  ${autoUpdate ? 'Environment=THINKPOOL_PAIR_AUTOUPDATE=1\n' : ''}StandardOutput=append:${log}
207
215
  StandardError=append:${log}
208
216
 
@@ -231,9 +239,6 @@ WantedBy=default.target
231
239
  throw new Error(`unsupported platform: ${platform}`)
232
240
  }
233
241
 
234
- // Blocking sleep (installService runs synchronously from the interactive menu; the
235
- // user is watching, so we WANT to wait + report the real outcome, not fire-and-forget).
236
- const sleepMs = (ms) => { try { execSync(`sleep ${(ms / 1000).toFixed(2)}`) } catch { /* noop */ } }
237
242
  // Parse both legacy npx commands (`thinkpool-pair@<ver>`) and stable runtime commands
238
243
  // (`.../runtimes/<ver>/...`).
239
244
  export function parseRunningPairVersions(out) {
@@ -241,35 +246,84 @@ export function parseRunningPairVersions(out) {
241
246
  return new Set(versions.map((s) => s.match(/\d+\.\d+\.\d+/)?.[0]).filter(Boolean))
242
247
  }
243
248
 
244
- // Which thinkpool-pair versions are running right now. Returns a Set of versions.
245
- const runningPairVersions = () => {
249
+ // A macOS update can be invoked FROM a ThinkPool Code room hosted by the very account
250
+ // service being replaced. Running `bootout; bootstrap` in that caller is unsafe: bootout
251
+ // tears down the room process tree and can kill the updater before bootstrap. Hand the
252
+ // whole destructive transaction to a separate one-shot launchd job first. It survives
253
+ // the target service's teardown, reloads it, verifies the exact expected runtime in
254
+ // launchd's live definition, and records an atomic result for diagnosis.
255
+ export function buildDarwinReloadHandoff({ targetLabel, targetFile, stagedFile, expectedRuntime, version, nonce = `${process.pid}-${Date.now()}` }) {
256
+ if (!/^io\.thinkpool\.pair\.[A-Za-z0-9_.-]+$/.test(String(targetLabel))) throw new Error(`invalid service label: ${targetLabel}`)
257
+ if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(version))) throw new Error(`invalid runtime version: ${version}`)
258
+ const safeNonce = String(nonce).replace(/[^A-Za-z0-9-]/g, '') || String(process.pid)
259
+ const helperLabel = `io.thinkpool.pair.update.${safeNonce}`
260
+ const helperFile = path.join(os.homedir(), 'Library', 'LaunchAgents', `${helperLabel}.plist`)
261
+ const backupFile = `${stagedFile}.previous`
262
+ const statusFile = path.join(os.homedir(), '.thinkpool-pair', 'update-status.json')
263
+ const logFile = path.join(os.homedir(), '.thinkpool-pair', 'update.log')
264
+ const script = [
265
+ 'set -u',
266
+ 'dom="gui/$(id -u)"',
267
+ `target=${shq(targetLabel)}`,
268
+ `helper=${shq(helperLabel)}`,
269
+ `target_file=${shq(targetFile)}`,
270
+ `staged_file=${shq(stagedFile)}`,
271
+ `backup_file=${shq(backupFile)}`,
272
+ `helper_file=${shq(helperFile)}`,
273
+ `expected=${shq(expectedRuntime)}`,
274
+ `status=${shq(statusFile)}`,
275
+ 'tmp="$status.tmp.$$"',
276
+ 'ok=0',
277
+ 'had_previous=0',
278
+ 'if [ -f "$target_file" ]; then cp "$target_file" "$backup_file" && had_previous=1; fi',
279
+ 'launchctl bootout "$dom/$target" 2>/dev/null || true',
280
+ 'for i in $(seq 1 40); do launchctl print "$dom/$target" >/dev/null 2>&1 || break; sleep 0.2; done',
281
+ 'cp "$staged_file" "$target_file"',
282
+ 'launchctl enable "$dom/$target" 2>/dev/null || true',
283
+ 'for i in $(seq 1 30); do launchctl bootstrap "$dom" "$target_file" 2>/dev/null && break; sleep 0.3; done',
284
+ '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`,
286
+ 'mv "$tmp" "$status"',
287
+ 'rm -f "$staged_file" "$backup_file" "$helper_file"',
288
+ 'launchctl bootout "$dom/$helper" >/dev/null 2>&1 || true',
289
+ 'exit "$((1-ok))"',
290
+ ].join('; ')
291
+ const content = `<?xml version="1.0" encoding="UTF-8"?>
292
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
293
+ <plist version="1.0"><dict>
294
+ <key>Label</key><string>${xml(helperLabel)}</string>
295
+ <key>ProgramArguments</key><array><string>/bin/bash</string><string>-lc</string><string>${xml(script)}</string></array>
296
+ <key>RunAtLoad</key><true/>
297
+ <key>StandardOutPath</key><string>${xml(logFile)}</string>
298
+ <key>StandardErrorPath</key><string>${xml(logFile)}</string>
299
+ </dict></plist>\n`
300
+ return { helperLabel, helperFile, backupFile, statusFile, logFile, script, content }
301
+ }
302
+
303
+ function startDarwinReloadHandoff(a, version) {
304
+ const stagedFile = path.join(a.logDir, `service-update-${process.pid}-${Date.now()}.plist`)
305
+ const h = buildDarwinReloadHandoff({ targetLabel: labelFromFile(a.file), targetFile: a.file, stagedFile, expectedRuntime: a.expectedRuntime, version })
306
+ fs.mkdirSync(path.dirname(h.helperFile), { recursive: true })
307
+ fs.mkdirSync(path.dirname(h.statusFile), { recursive: true })
308
+ fs.rmSync(h.statusFile, { force: true })
309
+ fs.writeFileSync(stagedFile, a.content)
310
+ fs.writeFileSync(h.helperFile, h.content)
246
311
  try {
247
- const out = execSync('ps ax -o command= 2>/dev/null', { encoding: 'utf8', shell: '/bin/bash' })
248
- return parseRunningPairVersions(out)
249
- } catch { return new Set() }
312
+ execSync(`launchctl bootstrap gui/$(id -u) ${shq(h.helperFile)}`, { stdio: 'inherit', shell: '/bin/bash' })
313
+ process.stderr.write(` ◆ update handoff armed outside the bridge process tree — completion is recorded in ${h.statusFile}.\n`)
314
+ return true
315
+ } catch (e) {
316
+ fs.rmSync(stagedFile, { force: true })
317
+ fs.rmSync(h.helperFile, { force: true })
318
+ process.stderr.write(` ⚠ could not arm the launchd update handoff; existing service left running: ${e.message}\n`)
319
+ return false
320
+ }
250
321
  }
251
- const darwinSvcLoaded = (lbl) => { try { execSync(`launchctl print gui/$(id -u)/${lbl}`, { stdio: 'ignore' }); return true } catch { return false } }
252
- // After the reload, CONFIRM the new pinned version is actually running — the shell
253
- // post can report "loaded" while an ORPHANED old child bridge (that bootout didn't
254
- // reap) keeps serving the OLD version, which is the "Restart & update never works"
255
- // bug (Max 2026-07-02). If the new version isn't up, SIGKILL lingering ROOM child
256
- // bridges (never the no-arg menu process) + re-bootstrap, then report the truth.
257
- function verifyDarwinReload(lbl, file, version) {
258
- const want = String(version)
259
- const up = () => darwinSvcLoaded(lbl) && runningPairVersions().has(want)
260
- for (let i = 0; i < 25 && !up(); i++) sleepMs(600) // fresh (cache-wiped) fetch takes a bit
261
- if (up()) { process.stderr.write(` ◆ verified: bridge is now running v${want}.\n`); return true }
262
- process.stderr.write(` ⚠ v${want} not up yet — clearing stale bridges and re-bootstrapping…\n`)
263
- try { execSync(`launchctl bootout gui/$(id -u)/${lbl} 2>/dev/null || true`, { stdio: 'ignore' }) } catch { /* noop */ }
264
- // Kill orphaned ROOM child bridges (`bridge.mjs <ROOM>`), which bootout may not have
265
- // reaped. The uppercase room-code guard never matches the no-arg / `setup` menu.
266
- try { execSync(`pkill -9 -f 'thinkpool-pair/bridge.mjs [A-Z0-9]' 2>/dev/null || true`, { stdio: 'ignore', shell: '/bin/bash' }) } catch { /* noop */ }
267
- sleepMs(500)
268
- try { execSync(`launchctl enable gui/$(id -u)/${lbl} 2>/dev/null || true`, { stdio: 'ignore' }) } catch { /* noop */ }
269
- for (let i = 0; i < 25 && !up(); i++) { try { execSync(`launchctl bootstrap gui/$(id -u) "${file}" 2>/dev/null || true`, { stdio: 'ignore' }) } catch { /* noop */ } sleepMs(600) }
270
- if (up()) { process.stderr.write(` ◆ recovered: bridge is now running v${want}.\n`); return true }
271
- process.stderr.write(` ⚠ could NOT confirm v${want} is running. Recover by hand:\n launchctl bootout gui/$(id -u)/${lbl} ; pkill -9 -f thinkpool-pair ; launchctl bootstrap gui/$(id -u) "${file}"\n`)
272
- return false
322
+
323
+ function labelFromFile(file) {
324
+ const match = path.basename(file).match(/^(io\.thinkpool\.pair\.[A-Za-z0-9_.-]+)\.plist$/)
325
+ if (!match) throw new Error(`cannot derive service label from ${file}`)
326
+ return match[1]
273
327
  }
274
328
 
275
329
  // F4 — every io.thinkpool.pair.* label currently LOADED in the launchd/systemd domain,
@@ -327,12 +381,8 @@ export function sweepExistingServices(keepLabel, platform = process.platform) {
327
381
  }
328
382
  }
329
383
 
330
- // staleProof defaults ON for every real install: even a FIRST install can be launched from
331
- // a stale npx cache (the launcher itself is npx-resolved), so without the cache-wiping
332
- // wrapper a fresh `install-service` could pin a stale build just like the update path did.
333
- // Pinning still holds (we pin a fixed version number) — staleProof only guarantees that
334
- // pinned version is fetched fresh on every (re)start. buildArtifact keeps staleProof=false
335
- // so its unit tests assert the pure pinned shape.
384
+ // staleProof remains for the legacy npx artifact shape. Pinned services normally bypass
385
+ // npx entirely through the verified immutable runtime provisioned below.
336
386
  export function installService(room, cmdArgs = [], { autoUpdate = false, version = VERSION, staleProof = true } = {}) {
337
387
  // Pinned services get a stable local runtime. The legacy @latest opt-in keeps its
338
388
  // existing npx behavior until it resolves a concrete version through updateService.
@@ -343,20 +393,22 @@ export function installService(room, cmdArgs = [], { autoUpdate = false, version
343
393
  }
344
394
  const a = buildArtifact(process.platform, { room, cmdArgs, autoUpdate, version, staleProof, runtimeEntry })
345
395
  // F4: clear every OTHER pre-existing pair service first, so only the one we install remains
346
- // (the "two account services live" storm trigger). Our own label is bootout+bootstrapped
347
- // by a.post below, so keep it.
396
+ // (the "two account services live" storm trigger). Our own label is handled by the
397
+ // launchd handoff (macOS) or the platform post command, so keep it.
348
398
  try { sweepExistingServices(label(room), process.platform) } catch { /* best-effort */ }
349
399
  fs.mkdirSync(a.logDir, { recursive: true })
350
400
  fs.mkdirSync(path.dirname(a.file), { recursive: true })
351
- fs.writeFileSync(a.file, a.content)
352
- process.stderr.write(`\n ◆ wrote ${a.file}\n`)
353
- for (const cmd of a.post) {
354
- try { execSync(cmd, { stdio: 'inherit' }) } catch (e) { process.stderr.write(` ⚠ "${cmd}" failed: ${e.message}\n`) }
401
+ if (process.platform === 'darwin') {
402
+ process.stderr.write(`\n ◆ staged replacement for ${a.file}\n`)
403
+ if (!startDarwinReloadHandoff(a, version)) return false
404
+ } else {
405
+ fs.writeFileSync(a.file, a.content)
406
+ process.stderr.write(`\n ◆ wrote ${a.file}\n`)
407
+ for (const cmd of a.post) {
408
+ try { execSync(cmd, { stdio: 'inherit' }) }
409
+ catch (e) { process.stderr.write(` ⚠ "${cmd}" failed; service update NOT confirmed: ${e.message}\n`); return false }
410
+ }
355
411
  }
356
- // Darwin: confirm the reload actually put the NEW version live, self-heal if not.
357
- // Only for a PINNED (re)install where we know the exact version to look for — an
358
- // autoUpdate (@latest) install has no fixed version number to verify against.
359
- if (process.platform === 'darwin' && !autoUpdate) verifyDarwinReload(label(room), a.file, version)
360
412
  // Be honest about the cadence — it is NOT instant. The supervisor polls npm
361
413
  // roughly every 30 min (THINKPOOL_PAIR_UPDATE_INTERVAL) and only restarts once
362
414
  // every served session is idle, so a just-published version can take a while.
@@ -368,15 +420,13 @@ export function installService(room, cmdArgs = [], { autoUpdate = false, version
368
420
  : `PINNED to ${version} (stable local runtime; restarts do not depend on npx or the network). To update: use "Restart & update the bridge" (or re-run install-service from a newer thinkpool-pair). Pass --auto-update to track @latest instead.`
369
421
  const removeArg = room ? ` ${room}` : ''
370
422
  const what = room ? `room ${room}` : 'your account (auto-serves every session)'
371
- process.stderr.write(` ◆ ${what}\n ◆ ${a.note}\n ◆ ${updateNote}\n ◆ logs: ${path.join(a.logDir, `${slug(room)}.log`)}\n ◆ remove with: thinkpool-pair uninstall-service${removeArg}\n\n`)
423
+ process.stderr.write(` ◆ ${what}\n ◆ ${process.platform === 'darwin' ? 'Launchd is completing and verifying the reload independently.' : a.note}\n ◆ ${updateNote}\n ◆ logs: ${path.join(a.logDir, `${slug(room)}.log`)}\n ◆ remove with: thinkpool-pair uninstall-service${removeArg}\n\n`)
372
424
  return true
373
425
  }
374
426
 
375
427
  // Restart an already-installed boot-persistent service. launchd/systemd relaunch
376
- // the supervised `npx …`, so any active session resumes in place (the bridge
377
- // restores from disk). Use it to recover a wedged bridge, re-read provider.json,
378
- // or — if the service was installed with --auto-update (@latest) — pull a new
379
- // publish now. A PINNED service (the default) re-runs its same installed version.
428
+ // the stable runtime (or legacy auto-update npx command), so active sessions resume
429
+ // from disk. Use it to recover a wedged bridge or re-read provider.json.
380
430
  // room falsy → the ACCOUNT service. Returns true on success.
381
431
  export function restartService(room) {
382
432
  const plat = process.platform
@@ -430,7 +480,10 @@ export function serviceActive(room) {
430
480
  const plat = process.platform
431
481
  const id = label(room)
432
482
  try {
433
- if (plat === 'darwin') { execSync(`launchctl print gui/$(id -u)/${id}`, { stdio: 'ignore' }); return true }
483
+ if (plat === 'darwin') {
484
+ const out = execSync(`launchctl print gui/$(id -u)/${id}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
485
+ return darwinServiceRunning(out)
486
+ }
434
487
  if (plat === 'linux') { execSync(`systemctl --user is-active --quiet ${id}.service`); return true }
435
488
  // Windows has no managed daemon — the Startup-folder script's presence is the only proxy.
436
489
  return fs.existsSync(buildArtifact(plat, { room }).file)