thinkpool-pair 0.7.364 → 0.7.366

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 (116) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +20 -0
  3. package/abort-turn-barrier.mjs +1 -23
  4. package/account.mjs +1 -1437
  5. package/acp-client.mjs +1 -140
  6. package/agent-detect.mjs +1 -28
  7. package/agent-notify.mjs +1 -142
  8. package/agent-visibility.mjs +1 -67
  9. package/auth-store.mjs +1 -120
  10. package/bridge.mjs +1 -6266
  11. package/byok-detect.mjs +1 -126
  12. package/claude-command-catalog.mjs +1 -91
  13. package/claude-session.mjs +1 -1519
  14. package/code-event-contract.mjs +1 -118
  15. package/codex-app-server.mjs +1 -340
  16. package/codex-commands.mjs +1 -83
  17. package/codex-event-mapper.mjs +1 -232
  18. package/codex-images.mjs +1 -69
  19. package/codex-mcp-http.mjs +1 -131
  20. package/codex-session.mjs +1 -1295
  21. package/command-catalog.mjs +1 -118
  22. package/command-guidance.mjs +1 -8
  23. package/context-contract.mjs +1 -95
  24. package/context-windows.mjs +1 -107
  25. package/cross-terminal.mjs +1 -789
  26. package/cumulative-event-relay.mjs +1 -53
  27. package/design-edit.mjs +1 -424
  28. package/design-source-contract.mjs +1 -4
  29. package/direct-pair-room.mjs +1 -57
  30. package/dispatch-lease.mjs +1 -37
  31. package/dispatch-permission-cleanup.mjs +1 -86
  32. package/edit-diff.mjs +1 -136
  33. package/error-recovery.mjs +1 -50
  34. package/event-bounds.mjs +1 -121
  35. package/event-delivery-queue.mjs +1 -60
  36. package/event-id.mjs +1 -549
  37. package/evidence-citations.mjs +1 -50
  38. package/evidence-compact.mjs +1 -11
  39. package/flow-assembly.mjs +1 -196
  40. package/flow-budget.mjs +1 -84
  41. package/flow-conductor.mjs +1 -259
  42. package/flow-context-store.mjs +1 -387
  43. package/flow-host-revert.mjs +1 -42
  44. package/flow-models.mjs +1 -139
  45. package/flow-preview.mjs +1 -148
  46. package/flow-receipt.mjs +1 -122
  47. package/flow-redispatch.mjs +1 -72
  48. package/flow-review-gate.mjs +1 -402
  49. package/flow-review-reflect.mjs +1 -115
  50. package/flow-review.mjs +1 -152
  51. package/flow-scope-evidence.mjs +1 -117
  52. package/flow-skill-registry.mjs +1 -140
  53. package/flow-task-graph.mjs +1 -562
  54. package/flow-worktree.mjs +1 -71
  55. package/git-diff-report.mjs +1 -121
  56. package/hermes-delegation-guard.mjs +1 -14
  57. package/hermes-event-mapper.mjs +1 -194
  58. package/hermes-isolation.mjs +1 -61
  59. package/hermes-model-cache.mjs +1 -54
  60. package/hermes-policy.mjs +1 -92
  61. package/hermes-probe.mjs +1 -57
  62. package/hermes-session.mjs +1 -683
  63. package/hermes-setup.mjs +1 -167
  64. package/host-memory.mjs +1 -116
  65. package/interrupted-resume.mjs +1 -95
  66. package/keep-awake.mjs +1 -148
  67. package/key-shape.mjs +1 -49
  68. package/lane-continuation.mjs +1 -83
  69. package/lane-lifecycle.mjs +1 -189
  70. package/lane-worktree.mjs +1 -77
  71. package/launcher.mjs +1 -354
  72. package/mcp-flight-recorder.mjs +1 -79
  73. package/mockup-delivery.mjs +1 -57
  74. package/model-prices.mjs +1 -113
  75. package/package.json +13 -4
  76. package/pair-bus.mjs +1 -98
  77. package/pair-control-authority.mjs +1 -89
  78. package/past-work-search.mjs +1 -105
  79. package/plan-meters.mjs +1 -144
  80. package/presence.mjs +1 -191
  81. package/privacy-report.mjs +1 -108
  82. package/provider-resilience.mjs +1 -356
  83. package/provider.mjs +1 -133
  84. package/providers.mjs +1 -491
  85. package/publish-guard.mjs +2 -0
  86. package/publish-manifest.json +129 -0
  87. package/question-response.mjs +1 -58
  88. package/reap-terminal.mjs +1 -68
  89. package/recap.mjs +1 -297
  90. package/replay-transport.mjs +1 -64
  91. package/repo-search.mjs +1 -190
  92. package/review-check.mjs +1 -182
  93. package/runtime-contract.mjs +1 -93
  94. package/runtime-registry.mjs +1 -64
  95. package/runtime-session.mjs +1 -20
  96. package/scheduled-run-admission.mjs +1 -364
  97. package/scheduled-runs.mjs +1 -268
  98. package/sdk-admission.mjs +1 -9
  99. package/sdk-smoke.mjs +1 -61
  100. package/serve-consent.mjs +1 -118
  101. package/serve-dir.mjs +1 -40
  102. package/service.mjs +1 -877
  103. package/session-store.mjs +1 -484
  104. package/side-lane.mjs +1 -63
  105. package/supabase-key.mjs +1 -176
  106. package/supervisor-ready.mjs +1 -57
  107. package/switch-provider.mjs +1 -84
  108. package/terminal-name.mjs +1 -359
  109. package/terminal-row-reconcile.mjs +1 -70
  110. package/thinkpool-prompt-contracts.mjs +1 -85
  111. package/thinkpool-room-prompt.mjs +1 -187
  112. package/transcript-sanitize.mjs +1 -332
  113. package/turn-stall.mjs +1 -61
  114. package/update-gate.mjs +1 -53
  115. package/viewport.mjs +1 -810
  116. package/worker-completion.mjs +1 -57
package/service.mjs CHANGED
@@ -1,877 +1 @@
1
- /* ─────────────────────────────────────────────────────────────
2
- service.mjs — install the bridge as a boot-persistent OS service
3
- so a customer's bridge survives reboot/logout and auto-restarts
4
- on crash, on every platform:
5
- • macOS → launchd LaunchAgent (KeepAlive)
6
- • Linux → systemd --user unit (Restart=always)
7
- • Windows → Startup-folder .cmd running --supervise
8
- `--supervise` (in bridge.mjs) is the cross-platform crash-restart
9
- baseline that needs no install; this adds boot persistence.
10
-
11
- Two service shapes, one builder:
12
- • room — `install-service <ROOM>` → supervises one room bridge.
13
- • account — `install-service` (no room) → supervises the ACCOUNT
14
- supervisor (`npx thinkpool-pair`, bare), which serves
15
- every session you're in. Pass room=null for this.
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.
19
- ───────────────────────────────────────────────────────────── */
20
-
21
- import os from 'node:os'
22
- import fs from 'node:fs'
23
- import path from 'node:path'
24
- import { execSync } from 'node:child_process'
25
- import { hostMemoryAdmission } from './host-memory.mjs'
26
- import { readSupervisorReady, supervisorReadyMatches } from './supervisor-ready.mjs'
27
- import { pairCli } from './command-guidance.mjs'
28
- import { loadKeepAwakePreference } from './keep-awake.mjs'
29
- import { scheduledRunsEnabled } from './scheduled-runs.mjs'
30
-
31
- // Service identity. Account mode has no room → a single stable id so there's
32
- // exactly one account service per machine (a second install replaces it).
33
- // Room codes flow UNESCAPED into launchctl/systemd shell strings + plist paths, so
34
- // this is the security chokepoint: reject anything that isn't a plain alnum code
35
- // before it can inject shell (`A;rm -rf ~`) or corrupt the service label. Every
36
- // room-interpolating path (buildArtifact, restart/uninstall/serviceActive) calls
37
- // label(), so one guard here covers them all.
38
- const ROOM_CODE_RE = /^[A-Za-z0-9]{2,16}$/
39
- const label = (room) => {
40
- if (room && !ROOM_CODE_RE.test(room)) throw new Error(`invalid room code: ${JSON.stringify(room)} (expected 2–16 alphanumerics)`)
41
- return room ? `io.thinkpool.pair.${room.toLowerCase()}` : 'io.thinkpool.pair.account'
42
- }
43
- const slug = (room) => room || 'account'
44
- const xml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
45
- const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`
46
- export function sanitizeServicePath(value, platform = process.platform) {
47
- const sep = platform === 'win32' ? ';' : ':'
48
- return String(value || '').split(sep).filter((part) => part && !part.includes('/.npm/_npx/')).join(sep)
49
- }
50
- export function darwinServiceRunning(output) {
51
- const text = String(output || '')
52
- return /(^|\n)[ \t]*state = running[ \t]*($|\n)/.test(text) && /(^|\n)[ \t]*pid = \d+[ \t]*($|\n)/.test(text)
53
- }
54
- // Legacy auto-update services still use npx. They MUST use a bridge-owned cache:
55
- // deleting ~/.npm/_npx corrupts unrelated interactive `npx thinkpool-pair` processes
56
- // (0.7.274 removed account.mjs underneath a live launcher). The service cache is
57
- // disposable and isolated, so clearing it cannot mutate a person's npm execution tree.
58
- const serviceNpmCache = () => path.join(os.homedir(), '.thinkpool-pair', 'npm-cache')
59
- const cacheWipe = (cacheRoot) => `rm -rf ${shq(path.join(cacheRoot, '_npx'))}/* 2>/dev/null`
60
-
61
- // The version installing the service. By DEFAULT the service is pinned to this exact
62
- // version (not @latest) so a future bad npm publish can't auto-roll to every machine's
63
- // always-on bridge — that's what spread the broken 0.7.49 on 2026-06-22. Updates become
64
- // deliberate (re-run install-service). `--auto-update` opts back into @latest tracking.
65
- const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return 'latest' } })()
66
-
67
- // Connected account readiness is deliberately stronger than "the OS process is
68
- // running": the supervisor must receive its own Supabase presence echo first.
69
- // That echo has taken ~62s on a real Linux host, so the old 30s handoff window
70
- // killed a healthy replacement and rolled back forever. Keep the external helper
71
- // alive long enough for one slow Realtime generation plus five stable PID probes.
72
- const SERVICE_PROOF_POLLS = 180
73
- const SERVICE_PROOF_SLEEP_SECONDS = '0.5'
74
- const SERVICE_CONFIRM_TIMEOUT_MS = 200_000
75
-
76
- // Resolve the real npx next to the current node, so the service stays seamless:
77
- // it runs `npx -y thinkpool-pair@latest …`, always picking up the newest publish
78
- // with no re-install. Absolute path + injected PATH so it works under launchd /
79
- // systemd's bare environment (which don't search PATH for argv[0]).
80
- function npxPath(platform) {
81
- const bin = path.dirname(process.execPath)
82
- const cand = path.join(bin, platform === 'win32' ? 'npx.cmd' : 'npx')
83
- try { fs.accessSync(cand); return cand } catch { return platform === 'win32' ? 'npx.cmd' : 'npx' }
84
- }
85
-
86
- function npmPath(platform = process.platform) {
87
- const bin = path.dirname(process.execPath)
88
- const cand = path.join(bin, platform === 'win32' ? 'npm.cmd' : 'npm')
89
- try { fs.accessSync(cand); return cand } catch { return platform === 'win32' ? 'npm.cmd' : 'npm' }
90
- }
91
-
92
- // Persistent services never execute through npx. Provision an immutable, versioned
93
- // runtime from the tarball URL (which bypasses stale npm packument metadata), then point
94
- // launchd/systemd directly at its bridge.mjs with the current Node binary.
95
- function runtimeVersion(entry) {
96
- try {
97
- const pkg = JSON.parse(fs.readFileSync(path.join(path.dirname(entry), 'package.json'), 'utf8'))
98
- return typeof pkg?.version === 'string' ? pkg.version : null
99
- } catch { return null }
100
- }
101
-
102
- export function provisionRuntime(version, { exec = execSync, root = path.join(os.homedir(), '.thinkpool-pair', 'runtimes'), admit = hostMemoryAdmission } = {}) {
103
- if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(version))) throw new Error(`invalid runtime version: ${version}`)
104
- const dir = path.join(root, String(version))
105
- const entry = path.join(dir, 'node_modules', 'thinkpool-pair', 'bridge.mjs')
106
- if (fs.existsSync(entry) && runtimeVersion(entry) === String(version)) return entry
107
- const memory = admit('stage a bridge runtime update')
108
- if (!memory?.ok) throw new Error(memory?.reason || 'host memory is critically low')
109
-
110
- // Install beside the target and only swap it into place after BOTH the entrypoint
111
- // and package version prove the payload. A killed/poisoned npm install must never
112
- // leave `runtimes/0.7.237` containing 0.7.229 and then pass forever on file existence.
113
- const tmp = `${dir}.install-${process.pid}-${Date.now()}`
114
- fs.rmSync(tmp, { recursive: true, force: true })
115
- fs.mkdirSync(tmp, { recursive: true })
116
- const tarball = `https://registry.npmjs.org/thinkpool-pair/-/thinkpool-pair-${version}.tgz`
117
- const tmpEntry = path.join(tmp, 'node_modules', 'thinkpool-pair', 'bridge.mjs')
118
- try {
119
- exec(`${shq(npmPath())} install --prefix ${shq(tmp)} ${shq(tarball)} --prefer-online --no-audit --no-fund`, { stdio: 'inherit', shell: '/bin/bash' })
120
- const actual = runtimeVersion(tmpEntry)
121
- if (!fs.existsSync(tmpEntry) || actual !== String(version)) {
122
- throw new Error(`runtime verification failed: requested ${version}, installed ${actual || 'no valid package'}`)
123
- }
124
- fs.rmSync(dir, { recursive: true, force: true })
125
- fs.renameSync(tmp, dir)
126
- } catch (e) {
127
- fs.rmSync(tmp, { recursive: true, force: true })
128
- throw e
129
- }
130
- return entry
131
- }
132
-
133
- // Pure artifact builder — returned shape is testable without side effects.
134
- // room falsy → ACCOUNT service (bare `thinkpool-pair`, auto-serves all sessions).
135
- export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate = false, version = VERSION, staleProof = false, runtimeEntry = null, scheduledRuns = scheduledRunsEnabled() } = {}) {
136
- const npx = npxPath(platform)
137
- // Account mode discovers each room's own bound directory, so its supervisor must use
138
- // a stable home cwd. Persisting the caller's managed worktree here makes the service
139
- // unstartable as soon as that lane is closed and its worktree is removed.
140
- const cwd = room ? process.cwd() : os.homedir()
141
- const id = slug(room)
142
- const logDir = path.join(os.homedir(), '.thinkpool-pair')
143
- const log = path.join(logDir, `${id}.log`)
144
- const servicePath = sanitizeServicePath(process.env.PATH || '', platform)
145
- const npmCache = serviceNpmCache()
146
- const tail = cmdArgs.length ? ['--', ...cmdArgs] : []
147
- const desc = room ? `bridge (${room})` : 'account bridge'
148
- // OS-supervised tiers (launchd KeepAlive / systemd Restart) don't need --supervise.
149
- // PINNED by default to `@${VERSION}` so a KeepAlive restart re-runs the SAME known-good
150
- // version — a bad publish can't auto-roll here. `autoUpdate` opts into `@latest` tracking
151
- // (and the in-process self-updater via THINKPOOL_PAIR_AUTOUPDATE below).
152
- const verSpec = autoUpdate ? 'thinkpool-pair@latest' : `thinkpool-pair@${version}`
153
- // Anti-zombie (2026-06-29): cacheWipe removes the INSTALLED package, but npx still
154
- // resolves `@latest` from npm's cached registry metadata (TTL'd). A restart inside
155
- // that window reinstalls the OLD version even though a newer one is published — the
156
- // bridge "comes back from the dead" stale (left FVIHV1DE on 0.7.86 while @latest was
157
- // 0.7.87). `--prefer-online` forces npx to REVALIDATE the packument against the
158
- // registry every resolve, so `@latest` always means the true latest. Only needed for
159
- // the @latest (autoUpdate) tier — a pinned exact version is immutable, no staleness.
160
- const onlineFlag = (autoUpdate || staleProof) ? ['--prefer-online'] : []
161
- const pkgArgs = room ? ['-y', ...onlineFlag, verSpec, room] : ['-y', ...onlineFlag, verSpec]
162
- // In-process self-update (account.mjs / bridge.mjs) is gated on this env — only on when
163
- // the user explicitly opted into auto-update.
164
- const autoUpdateEnv = autoUpdate ? { THINKPOOL_PAIR_AUTOUPDATE: '1' } : {}
165
- // Persistent services rebuild a deliberately tiny environment. Preserve this
166
- // activation only as the exact boolean true; arbitrary caller values are omitted.
167
- const scheduledRunsEnv = scheduledRuns === true
168
- ? { darwin: '<key>TP_SCHEDULED_RUNS_ENABLED</key><string>1</string>', linux: 'Environment=TP_SCHEDULED_RUNS_ENABLED=1\n', win32: 'set "TP_SCHEDULED_RUNS_ENABLED=1"\r\n' }
169
- : { darwin: '', linux: '', win32: '' }
170
-
171
- if (platform === 'darwin') {
172
- // launchd KeepAlive supervises. Stable runtimes execute directly; the legacy npx
173
- // shape keeps its cache-wiping wrapper for auto-update compatibility.
174
- const args = runtimeEntry
175
- ? [process.execPath, runtimeEntry, ...(room ? [room] : []), ...tail]
176
- : staleProof
177
- ? ['/bin/bash', '-lc', `${cacheWipe(npmCache)}; export NPM_CONFIG_CACHE=${shq(npmCache)}; exec ${[npx, ...pkgArgs, ...tail].map(shq).join(' ')}`]
178
- : [npx, ...pkgArgs, ...tail]
179
- const file = path.join(os.homedir(), 'Library', 'LaunchAgents', `${label(room)}.plist`)
180
- const content = `<?xml version="1.0" encoding="UTF-8"?>
181
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
182
- <plist version="1.0"><dict>
183
- <key>Label</key><string>${label(room)}</string>
184
- <key>ProgramArguments</key><array>${args.map((a) => `<string>${xml(a)}</string>`).join('')}</array>
185
- <key>RunAtLoad</key><true/>
186
- <!-- KeepAlive only on UNSUCCESSFUL exit. A bridge that exits 0 has intentionally
187
- stood down — told to stop, or another bridge already owns this machine's
188
- singleton lock — and must NOT be relaunched, or launchd thrashes it every
189
- few seconds forever (the 2026-06-28 account-bridge restart loop). A crash
190
- or an update-restart exits non-zero and IS relaunched. Mirrors the bridge's
191
- own exit-code contract (stop()=0, restart-requested=non-zero). -->
192
- <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
193
- <key>WorkingDirectory</key><string>${xml(cwd)}</string>
194
- <key>StandardOutPath</key><string>${xml(log)}</string>
195
- <key>StandardErrorPath</key><string>${xml(log)}</string>
196
- <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(servicePath)}</string>${runtimeEntry ? '' : `<key>NPM_CONFIG_CACHE</key><string>${xml(npmCache)}</string>`}${autoUpdate ? '<key>THINKPOOL_PAIR_AUTOUPDATE</key><string>1</string>' : ''}${scheduledRunsEnv.darwin}</dict>
197
- </dict></plist>\n`
198
- // The destructive reload is intentionally NOT represented as an inline `post`
199
- // command. installService stages this plist and hands the transaction to an
200
- // independent one-shot launchd job (buildDarwinReloadHandoff below).
201
- return { file, content, logDir, post: [], expectedRuntime: runtimeEntry || verSpec, note: 'Loaded as a LaunchAgent — starts at login, restarts on crash.' }
202
- }
203
-
204
- if (platform === 'linux') {
205
- const args = runtimeEntry
206
- ? [process.execPath, runtimeEntry, ...(room ? [room] : []), ...tail]
207
- : [npx, ...pkgArgs, ...tail] // systemd Restart=always supervises
208
- const file = path.join(os.homedir(), '.config', 'systemd', 'user', `${label(room)}.service`)
209
- // staleProof → cache-wiping /bin/bash launch (same intent as darwin). `$$HOME` so systemd
210
- // emits a literal $HOME to bash rather than expanding it itself (empty + warning).
211
- const execStart = runtimeEntry
212
- ? args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(' ')
213
- : staleProof
214
- ? `/bin/bash -lc "${cacheWipe(npmCache)}; export NPM_CONFIG_CACHE=${shq(npmCache)}; exec ${[npx, ...pkgArgs, ...tail].join(' ')}"`
215
- : args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(' ')
216
- const content = `[Unit]
217
- Description=ThinkPool Code ${desc}
218
- After=network-online.target
219
-
220
- [Service]
221
- ExecStart=${execStart}
222
- Restart=always
223
- RestartSec=2
224
- # Don't relaunch a clean stand-down. exit 0 = the bridge intentionally stopped —
225
- # told to stop, or another bridge already holds this machine's singleton lock —
226
- # so restarting it just thrashes every RestartSec forever (the 2026-06-28
227
- # account-bridge restart loop). Crashes + update-restarts exit non-zero and are
228
- # still restarted. Matches the bridge's exit-code contract (stop()=0, restart=non-zero).
229
- RestartPreventExitStatus=0
230
- WorkingDirectory=${cwd}
231
- Environment=PATH=${servicePath}
232
- ${runtimeEntry ? '' : `Environment=NPM_CONFIG_CACHE=${npmCache}\n`}${autoUpdate ? 'Environment=THINKPOOL_PAIR_AUTOUPDATE=1\n' : ''}${scheduledRunsEnv.linux}StandardOutput=append:${log}
233
- StandardError=append:${log}
234
-
235
- [Install]
236
- WantedBy=default.target
237
- `
238
- return { file, content, logDir, post: ['systemctl --user daemon-reload', `systemctl --user enable ${label(room)}.service`, `systemctl --user restart ${label(room)}.service`], expectedRuntime: runtimeEntry || verSpec, note: 'Enabled as a systemd --user service. Run `loginctl enable-linger $USER` once to keep it running after logout.' }
239
- }
240
-
241
- if (platform === 'win32') {
242
- // No native per-user daemon without extra tooling — a Startup-folder script
243
- // running --supervise gives login-persistence + crash-restart, dependency-free.
244
- // Account mode ignores --supervise (the account supervisor takes over argv),
245
- // so it gets login-persistence only — no in-process crash-restart on Windows.
246
- const startup = path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
247
- const file = path.join(startup, `thinkpool-pair-${id}.cmd`)
248
- const inner = runtimeEntry
249
- ? [process.execPath, runtimeEntry, ...(room ? [room, '--supervise'] : []), ...tail].map((s) => `"${s}"`).join(' ')
250
- : room
251
- ? ['npx', '-y', ...onlineFlag, verSpec, room, '--supervise', ...tail].join(' ')
252
- : ['npx', '-y', ...onlineFlag, verSpec].join(' ')
253
- const content = `@echo off\r\ntitle thinkpool-pair ${id}\r\n${runtimeEntry ? '' : `set "NPM_CONFIG_CACHE=${npmCache}"\r\n`}${scheduledRunsEnv.win32}${inner}\r\n`
254
- return { file, content, logDir, post: [], note: 'Installed to the Startup folder — runs at login' + (room ? ' with --supervise (auto-restart on crash).' : ' (account mode).') + ' Start it now without rebooting by double-clicking the .cmd, or run it from a terminal.' }
255
- }
256
-
257
- throw new Error(`unsupported platform: ${platform}`)
258
- }
259
-
260
- // Parse both legacy npx commands (`thinkpool-pair@<ver>`) and stable runtime commands
261
- // (`.../runtimes/<ver>/...`).
262
- export function parseRunningPairVersions(out) {
263
- const versions = String(out).match(/(?:thinkpool-pair@|runtimes[\\/])(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)/g) || []
264
- return new Set(versions.map((s) => s.match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/)?.[0]).filter(Boolean))
265
- }
266
-
267
- // Return a version only when the OS service manager proves that THIS service is
268
- // both live and configured to execute one unambiguous pair runtime. This is
269
- // deliberately stricter than isServiceInstalled(): a plist/unit on disk, or a
270
- // process list that happens to contain thinkpool-pair, is not update evidence.
271
- // Windows' Startup-folder tier has no service-manager identity to query, so an
272
- // exact running version cannot be attributed safely and is therefore unproven.
273
- export function serviceRuntimeSnapshot(room, { platform = process.platform, exec = execSync } = {}) {
274
- const exactVersion = (output) => {
275
- const versions = parseRunningPairVersions(output)
276
- return versions.size === 1 ? [...versions][0] : null
277
- }
278
- const id = label(room)
279
- try {
280
- if (platform === 'darwin') {
281
- const out = exec(`launchctl print gui/$(id -u)/${id}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], shell: '/bin/bash' })
282
- const pid = String(out).match(/(^|\n)[ \t]*pid = (\d+)[ \t]*($|\n)/)?.[2]
283
- return darwinServiceRunning(out) ? { version: exactVersion(out), pid: pid || null } : null
284
- }
285
- if (platform === 'linux') {
286
- const out = exec(`systemctl --user show ${id}.service --property=ActiveState --property=MainPID --property=ExecStart`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
287
- const pid = String(out).match(/^MainPID=([1-9]\d*)$/m)?.[1]
288
- if (!/^ActiveState=active$/m.test(String(out)) || !pid) return null
289
- return { version: exactVersion(out), pid }
290
- }
291
- } catch { /* service manager could not prove a live runtime */ }
292
- return null
293
- }
294
-
295
- export function serviceRuntimeVersion(room, options) {
296
- return serviceRuntimeSnapshot(room, options)?.version || null
297
- }
298
-
299
- // A macOS update can be invoked FROM a ThinkPool Code room hosted by the very account
300
- // service being replaced. Running `bootout; bootstrap` in that caller is unsafe: bootout
301
- // tears down the room process tree and can kill the updater before bootstrap. Hand the
302
- // whole destructive transaction to a separate one-shot launchd job first. It survives
303
- // the target service's teardown, reloads it, verifies the exact expected runtime in
304
- // launchd's live definition, and records an atomic result for diagnosis.
305
- export function buildDarwinReloadHandoff({ targetLabel, targetFile, stagedFile, expectedRuntime, version, nonce = `${process.pid}-${Date.now()}` }) {
306
- if (!/^io\.thinkpool\.pair\.[A-Za-z0-9_.-]+$/.test(String(targetLabel))) throw new Error(`invalid service label: ${targetLabel}`)
307
- if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(version))) throw new Error(`invalid runtime version: ${version}`)
308
- const safeNonce = String(nonce).replace(/[^A-Za-z0-9-]/g, '') || String(process.pid)
309
- const helperLabel = `io.thinkpool.pair.update.${safeNonce}`
310
- const helperFile = path.join(os.homedir(), 'Library', 'LaunchAgents', `${helperLabel}.plist`)
311
- const backupFile = `${stagedFile}.previous`
312
- const statusFile = path.join(os.homedir(), '.thinkpool-pair', 'update-status.json')
313
- const logFile = path.join(os.homedir(), '.thinkpool-pair', 'update.log')
314
- const readyFile = path.join(os.homedir(), '.thinkpool-pair', 'account-ready.json')
315
- const requiresReady = targetLabel === label(null)
316
- const script = [
317
- 'set -u',
318
- 'dom="gui/$(id -u)"',
319
- `target=${shq(targetLabel)}`,
320
- `helper=${shq(helperLabel)}`,
321
- `target_file=${shq(targetFile)}`,
322
- `staged_file=${shq(stagedFile)}`,
323
- `backup_file=${shq(backupFile)}`,
324
- `helper_file=${shq(helperFile)}`,
325
- `expected=${shq(expectedRuntime)}`,
326
- `version=${shq(version)}`,
327
- `status=${shq(statusFile)}`,
328
- `ready=${shq(readyFile)}`,
329
- `requires_ready=${requiresReady ? '1' : '0'}`,
330
- 'tmp="$status.tmp.$$"',
331
- 'ok=0',
332
- 'live_pid=""',
333
- 'stable=0',
334
- 'had_previous=0',
335
- 'if [ -f "$target_file" ]; then cp "$target_file" "$backup_file" && had_previous=1; fi',
336
- 'launchctl bootout "$dom/$target" 2>/dev/null || true',
337
- 'for i in $(seq 1 40); do launchctl print "$dom/$target" >/dev/null 2>&1 || break; sleep 0.2; done',
338
- 'if [ "$requires_ready" = 1 ]; then rm -f "$ready"; fi',
339
- 'cp "$staged_file" "$target_file"',
340
- 'launchctl enable "$dom/$target" 2>/dev/null || true',
341
- 'for i in $(seq 1 30); do launchctl bootstrap "$dom" "$target_file" 2>/dev/null && break; sleep 0.3; done',
342
- 'printf "update_handoff_start target=%s version=%s timeout_s=90\\n" "$target" "$version"',
343
- 'for i in $(seq 1 ' + SERVICE_PROOF_POLLS + '); do snapshot="$(launchctl print "$dom/$target" 2>/dev/null || true)"; pid="$(printf "%s\\n" "$snapshot" | sed -n "s/^[[:space:]]*pid = \\([0-9][0-9]*\\)[[:space:]]*$/\\1/p" | head -1)"; ready_ok=1; if [ "$requires_ready" = 1 ]; then ready_ok=0; [ -f "$ready" ] && grep -F "\\"pid\\":$pid" "$ready" >/dev/null 2>&1 && grep -F "\\"version\\":\\"$version\\"" "$ready" >/dev/null 2>&1 && ready_ok=1; fi; if [ -n "$pid" ] && [ "$ready_ok" = 1 ] && printf "%s\\n" "$snapshot" | grep -F -- "$expected" >/dev/null && printf "%s\\n" "$snapshot" | grep -F "state = running" >/dev/null; then if [ "$pid" = "$live_pid" ]; then stable=$((stable+1)); else live_pid="$pid"; stable=1; fi; if [ "$stable" -ge 5 ]; then ok=1; break; fi; else live_pid=""; stable=0; fi; sleep ' + SERVICE_PROOF_SLEEP_SECONDS + '; done',
344
- 'if [ "$ok" != 1 ]; then ready_state=missing; [ -f "$ready" ] && ready_state=present; printf "update_handoff_verify_timeout target=%s version=%s last_pid=%s ready=%s\\n" "$target" "$version" "$live_pid" "$ready_state"; fi',
345
- `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; if [ "$requires_ready" = 1 ]; then rm -f "$ready"; fi; cp "$backup_file" "$target_file"; launchctl enable "$dom/$target" 2>/dev/null || true; bootstrapped=0; for i in $(seq 1 30); do if launchctl bootstrap "$dom" "$target_file" 2>/dev/null; then bootstrapped=1; break; fi; sleep 0.3; done; if [ "$bootstrapped" = 1 ]; then rollback_pid=""; rollback_stable=0; for i in $(seq 1 ${SERVICE_PROOF_POLLS}); do snapshot="$(launchctl print "$dom/$target" 2>/dev/null || true)"; pid="$(printf "%s\\n" "$snapshot" | sed -n "s/^[[:space:]]*pid = \\([0-9][0-9]*\\)[[:space:]]*$/\\1/p" | head -1)"; ready_ok=1; if [ "$requires_ready" = 1 ]; then ready_ok=0; [ -f "$ready" ] && grep -F "\\"pid\\":$pid" "$ready" >/dev/null 2>&1 && ready_ok=1; fi; if [ -n "$pid" ] && [ "$ready_ok" = 1 ] && printf "%s\\n" "$snapshot" | grep -F "state = running" >/dev/null; then if [ "$pid" = "$rollback_pid" ]; then rollback_stable=$((rollback_stable+1)); else rollback_pid="$pid"; rollback_stable=1; fi; if [ "$rollback_stable" -ge 5 ]; then rollback=1; break; fi; else rollback_pid=""; rollback_stable=0; fi; sleep ${SERVICE_PROOF_SLEEP_SECONDS}; done; fi; else launchctl bootout "$dom/$target" 2>/dev/null || true; rm -f "$target_file"; fi; if [ "$rollback" = 1 ]; then printf '%s\\n' '${JSON.stringify({ ok: false, version: String(version), target: String(targetLabel), error: 'new runtime not confirmed within 90s; previous service restored and ready', rolledBack: true })}' > "$tmp"; else printf '%s\\n' '${JSON.stringify({ ok: false, version: String(version), target: String(targetLabel), error: 'new runtime not confirmed within 90s and ready rollback failed', rolledBack: false })}' > "$tmp"; fi; fi`,
346
- 'printf "update_handoff_complete target=%s version=%s ok=%s\\n" "$target" "$version" "$ok"',
347
- 'mv "$tmp" "$status"',
348
- 'rm -f "$staged_file" "$backup_file" "$helper_file"',
349
- 'launchctl bootout "$dom/$helper" >/dev/null 2>&1 || true',
350
- 'exit "$((1-ok))"',
351
- ].join('; ')
352
- const content = `<?xml version="1.0" encoding="UTF-8"?>
353
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
354
- <plist version="1.0"><dict>
355
- <key>Label</key><string>${xml(helperLabel)}</string>
356
- <key>ProgramArguments</key><array><string>/bin/bash</string><string>-lc</string><string>${xml(script)}</string></array>
357
- <key>RunAtLoad</key><true/>
358
- <key>StandardOutPath</key><string>${xml(logFile)}</string>
359
- <key>StandardErrorPath</key><string>${xml(logFile)}</string>
360
- </dict></plist>\n`
361
- return { helperLabel, helperFile, backupFile, statusFile, logFile, script, content }
362
- }
363
-
364
- // Linux has the same self-replacement hazard as launchd: `systemctl restart` issued
365
- // from inside the account service can terminate the caller before it observes the new
366
- // process. Run the destructive transaction in a separate transient user unit, and use
367
- // the same process-bound account readiness receipt as macOS before committing success.
368
- export function buildLinuxReloadHandoff({ targetLabel, targetFile, stagedFile, expectedRuntime, version, nonce = `${process.pid}-${Date.now()}` }) {
369
- if (!/^io\.thinkpool\.pair\.[A-Za-z0-9_.-]+$/.test(String(targetLabel))) throw new Error(`invalid service label: ${targetLabel}`)
370
- if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(version))) throw new Error(`invalid runtime version: ${version}`)
371
- const safeNonce = String(nonce).replace(/[^A-Za-z0-9-]/g, '') || String(process.pid)
372
- const helperLabel = `io.thinkpool.pair.update.${safeNonce}`
373
- const backupFile = `${stagedFile}.previous`
374
- const statusFile = path.join(os.homedir(), '.thinkpool-pair', 'update-status.json')
375
- const logFile = path.join(os.homedir(), '.thinkpool-pair', 'update.log')
376
- const readyFile = path.join(os.homedir(), '.thinkpool-pair', 'account-ready.json')
377
- const requiresReady = targetLabel === label(null)
378
- const script = [
379
- 'set -u',
380
- `target=${shq(targetLabel)}`,
381
- 'unit="$target.service"',
382
- `target_file=${shq(targetFile)}`,
383
- `staged_file=${shq(stagedFile)}`,
384
- `backup_file=${shq(backupFile)}`,
385
- `expected=${shq(expectedRuntime)}`,
386
- `version=${shq(version)}`,
387
- `status=${shq(statusFile)}`,
388
- `ready=${shq(readyFile)}`,
389
- `requires_ready=${requiresReady ? '1' : '0'}`,
390
- 'tmp="$status.tmp.$$"',
391
- 'ok=0',
392
- 'had_previous=0',
393
- 'if [ -f "$target_file" ]; then cp "$target_file" "$backup_file" && had_previous=1; fi',
394
- 'systemctl --user stop "$unit" 2>/dev/null || true',
395
- 'if [ "$requires_ready" = 1 ]; then rm -f "$ready"; fi',
396
- 'cp "$staged_file" "$target_file"',
397
- 'systemctl --user daemon-reload',
398
- 'systemctl --user enable "$unit" >/dev/null 2>&1 || true',
399
- 'systemctl --user restart "$unit" >/dev/null 2>&1 || true',
400
- 'live_pid=""',
401
- 'stable=0',
402
- 'printf "update_handoff_start target=%s version=%s timeout_s=90\\n" "$target" "$version"',
403
- 'for i in $(seq 1 ' + SERVICE_PROOF_POLLS + '); do snapshot="$(systemctl --user show "$unit" --property=ActiveState --property=MainPID --property=ExecStart 2>/dev/null || true)"; pid="$(printf "%s\\n" "$snapshot" | sed -n "s/^MainPID=\\([1-9][0-9]*\\)$/\\1/p" | head -1)"; ready_ok=1; if [ "$requires_ready" = 1 ]; then ready_ok=0; [ -f "$ready" ] && grep -F "\\"pid\\":$pid" "$ready" >/dev/null 2>&1 && grep -F "\\"version\\":\\"$version\\"" "$ready" >/dev/null 2>&1 && ready_ok=1; fi; if [ -n "$pid" ] && [ "$ready_ok" = 1 ] && printf "%s\\n" "$snapshot" | grep -F "ActiveState=active" >/dev/null && printf "%s\\n" "$snapshot" | grep -F -- "$expected" >/dev/null; then if [ "$pid" = "$live_pid" ]; then stable=$((stable+1)); else live_pid="$pid"; stable=1; fi; if [ "$stable" -ge 5 ]; then ok=1; break; fi; else live_pid=""; stable=0; fi; sleep ' + SERVICE_PROOF_SLEEP_SECONDS + '; done',
404
- 'if [ "$ok" != 1 ]; then ready_state=missing; [ -f "$ready" ] && ready_state=present; printf "update_handoff_verify_timeout target=%s version=%s last_pid=%s ready=%s\\n" "$target" "$version" "$live_pid" "$ready_state"; fi',
405
- `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 systemctl --user stop "$unit" 2>/dev/null || true; if [ "$requires_ready" = 1 ]; then rm -f "$ready"; fi; cp "$backup_file" "$target_file"; systemctl --user daemon-reload; systemctl --user enable "$unit" >/dev/null 2>&1 || true; systemctl --user restart "$unit" >/dev/null 2>&1 || true; rollback_pid=""; rollback_stable=0; for i in $(seq 1 ${SERVICE_PROOF_POLLS}); do snapshot="$(systemctl --user show "$unit" --property=ActiveState --property=MainPID 2>/dev/null || true)"; pid="$(printf "%s\\n" "$snapshot" | sed -n "s/^MainPID=\\([1-9][0-9]*\\)$/\\1/p" | head -1)"; ready_ok=1; if [ "$requires_ready" = 1 ]; then ready_ok=0; [ -f "$ready" ] && grep -F "\\"pid\\":$pid" "$ready" >/dev/null 2>&1 && ready_ok=1; fi; if [ -n "$pid" ] && [ "$ready_ok" = 1 ] && printf "%s\\n" "$snapshot" | grep -F "ActiveState=active" >/dev/null; then if [ "$pid" = "$rollback_pid" ]; then rollback_stable=$((rollback_stable+1)); else rollback_pid="$pid"; rollback_stable=1; fi; if [ "$rollback_stable" -ge 5 ]; then rollback=1; break; fi; else rollback_pid=""; rollback_stable=0; fi; sleep ${SERVICE_PROOF_SLEEP_SECONDS}; done; else systemctl --user stop "$unit" 2>/dev/null || true; rm -f "$target_file"; systemctl --user daemon-reload; fi; if [ "$rollback" = 1 ]; then printf '%s\\n' '${JSON.stringify({ ok: false, version: String(version), target: String(targetLabel), error: 'new runtime not confirmed within 90s; previous service restored and ready', rolledBack: true })}' > "$tmp"; else printf '%s\\n' '${JSON.stringify({ ok: false, version: String(version), target: String(targetLabel), error: 'new runtime not confirmed within 90s and ready rollback failed', rolledBack: false })}' > "$tmp"; fi; fi`,
406
- 'printf "update_handoff_complete target=%s version=%s ok=%s\\n" "$target" "$version" "$ok"',
407
- 'mv "$tmp" "$status"',
408
- 'rm -f "$staged_file" "$backup_file"',
409
- 'exit "$((1-ok))"',
410
- ].join('; ')
411
- return { helperLabel, backupFile, statusFile, logFile, script }
412
- }
413
-
414
- const accountLockFile = () => path.join(os.homedir(), '.thinkpool-pair', 'account.lock')
415
- const processAlive = (pid) => {
416
- try { process.kill(pid, 0); return true } catch (error) { return error?.code === 'EPERM' }
417
- }
418
-
419
- // A first background install can race a foreground/orphaned ThinkPool bridge that
420
- // still owns account.lock. The launchd child then exits 0, satisfying a momentary
421
- // `state = running` probe while KeepAlive correctly declines to restart it. Retire
422
- // only a PID whose command is demonstrably a packaged ThinkPool bridge; fail closed
423
- // on PID reuse or an unrelated process.
424
- export async function retireUnmanagedBridgeLock({
425
- lockFile = accountLockFile(),
426
- readLock = () => fs.readFileSync(lockFile, 'utf8'),
427
- removeLock = () => fs.rmSync(lockFile, { force: true }),
428
- commandForPid = (pid) => String(execSync(`ps -p ${pid} -o command=`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })),
429
- alive = processAlive,
430
- signal = (pid, sig) => process.kill(pid, sig),
431
- sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
432
- waitMs = 2500,
433
- pollMs = 100,
434
- stderr = process.stderr,
435
- } = {}) {
436
- let pid = 0
437
- try { pid = parseInt(String(readLock()).trim(), 10) || 0 } catch { return true }
438
- const removeIfSameAndDead = () => {
439
- if (alive(pid)) return false
440
- try { if ((parseInt(String(readLock()).trim(), 10) || 0) === pid) removeLock() } catch { /* already gone */ }
441
- return true
442
- }
443
- if (!pid || pid === process.pid || removeIfSameAndDead()) return true
444
- let command = ''
445
- try { command = commandForPid(pid) } catch { return removeIfSameAndDead() }
446
- const packagedBridge = /(?:node_modules[\\/]thinkpool-pair|\.npm[\\/]_npx|\.thinkpool-pair[\\/]runtimes)[\\/][^\n]*bridge\.mjs(?:\s|$)/.test(command)
447
- if (!packagedBridge) {
448
- stderr.write(` ⚠ account.lock belongs to PID ${pid}, but it is not a verified thinkpool-pair bridge; refusing to signal an unrelated process.\n`)
449
- return false
450
- }
451
- stderr.write(` ◆ stopping previous thinkpool-pair bridge (PID ${pid}) before installing the background service…\n`)
452
- try { signal(pid, 'SIGTERM') } catch { if (removeIfSameAndDead()) return true }
453
- const polls = Math.max(1, Math.ceil(Math.max(0, Number(waitMs) || 0) / Math.max(1, Number(pollMs) || 1)))
454
- for (let i = 0; i < polls; i++) { if (removeIfSameAndDead()) return true; await sleep(Math.max(1, Number(pollMs) || 1)) }
455
- try { signal(pid, 'SIGKILL') } catch { /* checked below */ }
456
- for (let i = 0; i < 5; i++) { if (removeIfSameAndDead()) return true; await sleep(Math.max(1, Number(pollMs) || 1)) }
457
- stderr.write(` ⚠ previous thinkpool-pair bridge PID ${pid} did not stop; background install aborted to avoid two competing bridges.\n`)
458
- return false
459
- }
460
-
461
- function startDarwinReloadHandoff(a, version) {
462
- const stagedFile = path.join(a.logDir, `service-update-${process.pid}-${Date.now()}.plist`)
463
- const h = buildDarwinReloadHandoff({ targetLabel: labelFromFile(a.file), targetFile: a.file, stagedFile, expectedRuntime: a.expectedRuntime, version })
464
- fs.mkdirSync(path.dirname(h.helperFile), { recursive: true })
465
- fs.mkdirSync(path.dirname(h.statusFile), { recursive: true })
466
- fs.rmSync(h.statusFile, { force: true })
467
- fs.writeFileSync(stagedFile, a.content)
468
- fs.writeFileSync(h.helperFile, h.content)
469
- try {
470
- execSync(`launchctl bootstrap gui/$(id -u) ${shq(h.helperFile)}`, { stdio: 'inherit', shell: '/bin/bash' })
471
- process.stderr.write(` ◆ update handoff armed outside the bridge process tree — completion is recorded in ${h.statusFile}.\n`)
472
- return true
473
- } catch (e) {
474
- fs.rmSync(stagedFile, { force: true })
475
- fs.rmSync(h.helperFile, { force: true })
476
- process.stderr.write(` ⚠ could not arm the launchd update handoff; existing service left running: ${e.message}\n`)
477
- return false
478
- }
479
- }
480
-
481
- function startLinuxReloadHandoff(a, version) {
482
- const stagedFile = path.join(a.logDir, `service-update-${process.pid}-${Date.now()}.service`)
483
- const h = buildLinuxReloadHandoff({ targetLabel: labelFromFile(a.file), targetFile: a.file, stagedFile, expectedRuntime: a.expectedRuntime, version })
484
- fs.mkdirSync(path.dirname(h.statusFile), { recursive: true })
485
- fs.rmSync(h.statusFile, { force: true })
486
- fs.writeFileSync(stagedFile, a.content)
487
- try {
488
- execSync(`systemd-run --user --unit=${shq(h.helperLabel)} --collect --property=StandardOutput=append:${shq(h.logFile)} --property=StandardError=append:${shq(h.logFile)} /bin/bash -lc ${shq(h.script)}`, { stdio: 'inherit', shell: '/bin/bash' })
489
- process.stderr.write(` ◆ update handoff armed outside the bridge service cgroup — completion is recorded in ${h.statusFile}.\n`)
490
- return true
491
- } catch (e) {
492
- fs.rmSync(stagedFile, { force: true })
493
- process.stderr.write(` ⚠ could not arm the systemd update handoff; existing service left running: ${e.message}\n`)
494
- return false
495
- }
496
- }
497
-
498
- function labelFromFile(file) {
499
- const match = path.basename(file).match(/^(io\.thinkpool\.pair\.[A-Za-z0-9_.-]+)\.(?:plist|service)$/)
500
- if (!match) throw new Error(`cannot derive service label from ${file}`)
501
- return match[1]
502
- }
503
-
504
- // F4 — every io.thinkpool.pair.* label currently LOADED in the launchd/systemd domain,
505
- // plus any label with a plist/unit still on disk (loaded or not). Pure enumeration so the
506
- // sweep below is testable in isolation. `keepLabel` is the label we're about to (re)install
507
- // — its own bootout+bootstrap is the caller's job, so the sweep leaves it alone.
508
- export function existingServiceLabels(platform = process.platform, { fsMod = fs } = {}) {
509
- const labels = new Set()
510
- try {
511
- if (platform === 'darwin') {
512
- try {
513
- const out = execSync(`launchctl print gui/$(id -u) 2>/dev/null | grep -oE 'io\\.thinkpool\\.pair\\.[A-Za-z0-9_]+' | sort -u`, { encoding: 'utf8', shell: '/bin/bash' })
514
- for (const l of out.split('\n')) { const t = l.trim(); if (t) labels.add(t) }
515
- } catch { /* nothing loaded */ }
516
- try {
517
- const dir = path.join(os.homedir(), 'Library', 'LaunchAgents')
518
- for (const f of fsMod.readdirSync(dir)) { const m = f.match(/^(io\.thinkpool\.pair\.[A-Za-z0-9_]+)\.plist$/); if (m) labels.add(m[1]) }
519
- } catch { /* no dir */ }
520
- } else if (platform === 'linux') {
521
- try {
522
- const out = execSync(`systemctl --user list-unit-files 'io.thinkpool.pair.*' --no-legend 2>/dev/null | awk '{print $1}'`, { encoding: 'utf8', shell: '/bin/bash' })
523
- for (const l of out.split('\n')) { const t = l.trim().replace(/\.service$/, ''); if (t) labels.add(t) }
524
- } catch { /* none */ }
525
- try {
526
- const dir = path.join(os.homedir(), '.config', 'systemd', 'user')
527
- for (const f of fsMod.readdirSync(dir)) { const m = f.match(/^(io\.thinkpool\.pair\.[A-Za-z0-9_]+)\.service$/); if (m) labels.add(m[1]) }
528
- } catch { /* no dir */ }
529
- }
530
- } catch { /* best-effort */ }
531
- return [...labels]
532
- }
533
-
534
- // F4 — before (re)installing, sweep EVERY pre-existing thinkpool-pair service EXCEPT the one
535
- // we're about to install (which the install path bootout+bootstraps itself). Today's outage
536
- // trigger was an uninstall/reinstall leaving TWO account services live, both heart-beating,
537
- // racing the saved login. Idempotent + best-effort; never throws. Also reaps orphaned ROOM
538
- // CHILD bridges (the proven verifyDarwinReload pattern — the uppercase-room guard never
539
- // matches the no-arg menu or the supervisor, so the live account bridge is never touched).
540
- export function sweepExistingServices(keepLabel, platform = process.platform) {
541
- for (const lbl of existingServiceLabels(platform)) {
542
- if (lbl === keepLabel) continue
543
- try {
544
- if (platform === 'darwin') {
545
- execSync(`launchctl bootout gui/$(id -u)/${lbl} 2>/dev/null || true`, { stdio: 'ignore', shell: '/bin/bash' })
546
- try { fs.rmSync(path.join(os.homedir(), 'Library', 'LaunchAgents', `${lbl}.plist`), { force: true }) } catch { /* noop */ }
547
- } else if (platform === 'linux') {
548
- execSync(`systemctl --user disable --now ${lbl}.service 2>/dev/null || true`, { stdio: 'ignore', shell: '/bin/bash' })
549
- }
550
- process.stderr.write(` ◆ swept stale service ${lbl}\n`)
551
- } catch { /* best-effort */ }
552
- }
553
- // Reap orphaned ROOM child bridges only — safe (never the supervisor / interactive menu).
554
- if (platform !== 'win32') {
555
- try { execSync(`pkill -9 -f 'thinkpool-pair/bridge.mjs [A-Z0-9]' 2>/dev/null || true`, { stdio: 'ignore', shell: '/bin/bash' }) } catch { /* noop */ }
556
- }
557
- }
558
-
559
- // staleProof remains for the legacy npx artifact shape. Pinned services normally bypass
560
- // npx entirely through the verified immutable runtime provisioned below.
561
- export function installService(room, cmdArgs = [], { autoUpdate = false, version = VERSION, staleProof = true } = {}) {
562
- // Pinned services get a stable local runtime. The legacy @latest opt-in keeps its
563
- // existing npx behavior until it resolves a concrete version through updateService.
564
- let runtimeEntry = null
565
- if (!autoUpdate) {
566
- try { runtimeEntry = provisionRuntime(version) }
567
- catch (e) { process.stderr.write(` ⚠ runtime install failed; service left unchanged: ${e.message}\n`); return false }
568
- }
569
- const a = buildArtifact(process.platform, { room, cmdArgs, autoUpdate, version, staleProof, runtimeEntry })
570
- // F4: clear every OTHER pre-existing pair service first, so only the one we install remains
571
- // (the "two account services live" storm trigger). Our own label is handled by the
572
- // launchd handoff (macOS) or the platform post command, so keep it.
573
- try { sweepExistingServices(label(room), process.platform) } catch { /* best-effort */ }
574
- fs.mkdirSync(a.logDir, { recursive: true })
575
- fs.mkdirSync(path.dirname(a.file), { recursive: true })
576
- if (process.platform === 'darwin') {
577
- process.stderr.write(`\n ◆ staged replacement for ${a.file}\n`)
578
- if (!startDarwinReloadHandoff(a, version)) return false
579
- } else if (process.platform === 'linux') {
580
- process.stderr.write(`\n ◆ staged replacement for ${a.file}\n`)
581
- if (!startLinuxReloadHandoff(a, version)) return false
582
- } else {
583
- fs.writeFileSync(a.file, a.content)
584
- process.stderr.write(`\n ◆ wrote ${a.file}\n`)
585
- for (const cmd of a.post) {
586
- try { execSync(cmd, { stdio: 'inherit' }) }
587
- catch (e) { process.stderr.write(` ⚠ "${cmd}" failed; service update NOT confirmed: ${e.message}\n`); return false }
588
- }
589
- }
590
- // Be honest about the cadence — it is NOT instant. The supervisor polls npm
591
- // roughly every 30 min (THINKPOOL_PAIR_UPDATE_INTERVAL) and only restarts once
592
- // every served session is idle, so a just-published version can take a while.
593
- const idleSecs = Math.max(10, parseInt(process.env.THINKPOOL_PAIR_UPDATE_IDLE, 10) || 90)
594
- const updateNote = autoUpdate
595
- ? (process.platform === 'win32'
596
- ? 'auto-update ON: a new thinkpool-pair@latest applies on the next login/restart (Windows has no in-process self-update).'
597
- : `auto-update ON: checks npm ~every 30 min and restarts to apply a new @latest once sessions are idle (≥${idleSecs}s quiet; they resume in place).`)
598
- : `PINNED to ${version} (stable local runtime; restarts do not depend on npx or the network). To update: use "Restart & update the bridge" (or run ${pairCli('install-service', room || undefined)}). Pass --auto-update to track @latest instead.`
599
- const removeArg = room ? ` ${room}` : ''
600
- const what = room ? `room ${room}` : 'your account (auto-serves every session)'
601
- const awakeNote = loadKeepAwakePreference()
602
- ? ' ◆ Keep-awake on: system sleep is inhibited while the bridge runs (higher battery use); the display may still dim, lock, and turn off.\n'
603
- : ''
604
- const runtimeNote = ' ◆ Prerequisite: Claude Code, Codex, or Hermes must be installed on this machine. BYOK supplies model credentials; it does not install an agent.\n'
605
- process.stderr.write(` ◆ ${what}\n ◆ ${process.platform === 'darwin' ? 'Launchd is completing and verifying the reload independently.' : a.note}\n${runtimeNote}${awakeNote} ◆ ${updateNote}\n ◆ logs: ${path.join(a.logDir, `${slug(room)}.log`)}\n ◆ remove with: ${pairCli('uninstall-service')}${removeArg}\n\n`)
606
- return true
607
- }
608
-
609
- // Interactive installs must not present an armed launchd helper as a completed
610
- // background service. installService() intentionally returns after staging because
611
- // an in-service update may tear down its caller; this menu/CLI primitive waits for the
612
- // helper receipt AND the exact live runtime before returning success.
613
- export async function installAndConfirmService(room, cmdArgs = [], {
614
- platform = process.platform,
615
- version = VERSION,
616
- staleProof = true,
617
- install = installService,
618
- exec = execSync,
619
- snapshot = serviceRuntimeSnapshot,
620
- prepareAccountHandoff = retireUnmanagedBridgeLock,
621
- readReady = () => readSupervisorReady(),
622
- readStatus = () => {
623
- try { return JSON.parse(fs.readFileSync(path.join(os.homedir(), '.thinkpool-pair', 'update-status.json'), 'utf8')) } catch { return null }
624
- },
625
- now = Date.now,
626
- sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
627
- timeoutMs = SERVICE_CONFIRM_TIMEOUT_MS,
628
- pollMs = 250,
629
- stderr = process.stderr,
630
- } = {}) {
631
- const before = snapshot(room, { platform, exec })
632
- if (!room && platform !== 'win32' && !before) {
633
- if (!(await prepareAccountHandoff())) {
634
- stderr.write(' ⚠ background install aborted: the previous bridge could not be handed off safely.\n')
635
- return false
636
- }
637
- }
638
- const confirmationStartedAt = now()
639
- if (install(room, cmdArgs, { version, staleProof }) === false) return false
640
-
641
- const confirmation = () => {
642
- const live = snapshot(room, { platform, exec })
643
- const ready = !!room || platform === 'win32' || supervisorReadyMatches(readReady(), live, version, { notBefore: confirmationStartedAt })
644
- if (live?.version === version && ready) {
645
- stderr.write(` ✓ bridge install confirmed — running v${version}.\n`)
646
- return true
647
- }
648
- return false
649
- }
650
-
651
- // Windows has no service-manager process identity. macOS and Linux both cross an
652
- // asynchronous external-helper boundary and must wait for its receipt plus account
653
- // readiness; a live PID alone is not a connected bridge.
654
- if (platform === 'win32') return true
655
-
656
- const deadline = now() + Math.max(0, Number(timeoutMs) || 0)
657
- const expectedTarget = label(room)
658
- for (;;) {
659
- const status = readStatus()
660
- if (status && typeof status === 'object' && status.target === expectedTarget && status.version === version) {
661
- if (status.ok === false) {
662
- stderr.write(` ⚠ install v${version} was not confirmed: ${status.error || 'the launchd handoff failed'}.\n`)
663
- return false
664
- }
665
- if (status.ok === true && confirmation()) return true
666
- }
667
- if (now() >= deadline) {
668
- stderr.write(` ⚠ install v${version} timed out waiting for supervisor readiness; no connected account bridge was proven.\n`)
669
- return false
670
- }
671
- await sleep(Math.max(1, Number(pollMs) || 1))
672
- }
673
- }
674
-
675
- // Restart an already-installed boot-persistent service. launchd/systemd relaunch
676
- // the stable runtime (or legacy auto-update npx command), so active sessions resume
677
- // from disk. Use it to recover a wedged bridge or re-read provider.json.
678
- // room falsy → the ACCOUNT service. Returns true on success.
679
- export function restartService(room) {
680
- const plat = process.platform
681
- if (!isServiceInstalled(room)) {
682
- process.stderr.write(' ⚠ no background service installed to restart — install one first.\n')
683
- return false
684
- }
685
- const id = label(room)
686
- try {
687
- if (plat === 'darwin') execSync(`launchctl kickstart -k gui/$(id -u)/${id}`, { stdio: 'inherit' })
688
- else if (plat === 'linux') execSync(`systemctl --user restart ${id}.service`, { stdio: 'inherit' })
689
- else {
690
- // Windows Startup .cmd has no managed daemon to kick — guide the manual path.
691
- process.stderr.write(' ⚠ Windows has no managed daemon — close the running thinkpool-pair window and re-launch the Startup .cmd (or reboot) to restart.\n')
692
- return false
693
- }
694
- process.stderr.write(` ◆ restarted ${id} — any active session resumes in place.\n`)
695
- return true
696
- } catch (e) {
697
- process.stderr.write(` ⚠ restart failed: ${e.message}\n`)
698
- return false
699
- }
700
- }
701
-
702
- // One authoritative update path for both the interactive menu and `restart-service`.
703
- // Resolve the registry version first, then rewrite + reload the service pinned to that
704
- // exact build. If npm is unavailable, fail without pretending an old-version restart
705
- // was an update.
706
- function publishedServiceVersion(exec = execSync) {
707
- let target = ''
708
- try {
709
- target = String(exec('npm view thinkpool-pair version', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })).trim()
710
- } catch { /* reported by caller */ }
711
- return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(target) ? target : null
712
- }
713
-
714
- // Download and verify the immutable runtime WITHOUT replacing the live service.
715
- // The account supervisor can do this while rooms are busy, then re-check its
716
- // between-turn contract immediately before the now-fast service-manager reload.
717
- export function stageServiceUpdate(room, { exec = execSync, active = isServiceInstalled, provision = provisionRuntime } = {}) {
718
- if (!active(room)) {
719
- process.stderr.write(' ⚠ no background service installed to update — install one first.\n')
720
- return null
721
- }
722
- const target = publishedServiceVersion(exec)
723
- if (!target) {
724
- process.stderr.write(" ⚠ couldn't reach npm or resolve the published version — bridge left unchanged; NO update or restart was applied.\n")
725
- return null
726
- }
727
- process.stderr.write(`\n ◆ preparing bridge v${target} in the background; active sessions keep running…\n`)
728
- try { provision(target); return target } catch (error) {
729
- process.stderr.write(` ⚠ couldn't stage bridge v${target}: ${error?.message || error}\n`)
730
- return null
731
- }
732
- }
733
-
734
- export function applyStagedServiceUpdate(room, target, { install = installService, active = isServiceInstalled } = {}) {
735
- if (!active(room) || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(target || ''))) return false
736
- process.stderr.write(`\n ◆ updating the background service to v${target} and restarting…\n`)
737
- return install(room, [], { version: target, staleProof: true }) !== false
738
- }
739
-
740
- export function updateService(room, { exec = execSync, install = installService, active = isServiceInstalled } = {}) {
741
- if (!active(room)) {
742
- process.stderr.write(' ⚠ no background service installed to update — install one first.\n')
743
- return false
744
- }
745
- const target = publishedServiceVersion(exec)
746
- if (!target) {
747
- process.stderr.write(" ⚠ couldn't reach npm or resolve the published version — bridge left unchanged; NO update or restart was applied.\n")
748
- return false
749
- }
750
- process.stderr.write(`\n ◆ updating the background service to v${target} and restarting…\n`)
751
- return install(room, [], { version: target, staleProof: true }) !== false
752
- }
753
-
754
- // Menu/CLI-only update path. updateService intentionally returns as soon as it has
755
- // safely staged an update: on macOS it must do that so an independent launchd helper
756
- // can replace the service without killing its caller mid-transaction. Consumers that
757
- // need to say "updated" rather than "staged" must use this primitive instead.
758
- //
759
- // All dependencies are injectable so this contract can be tested without a service,
760
- // a clock, or npm. The default timeout exceeds the launchd handoff's bounded reload
761
- // and verification loops, but is still finite.
762
- export async function updateAndConfirmService(room, {
763
- platform = process.platform,
764
- exec = execSync,
765
- update = updateService,
766
- install = installService,
767
- active = isServiceInstalled,
768
- snapshot = serviceRuntimeSnapshot,
769
- readReady = () => readSupervisorReady(),
770
- readStatus = () => {
771
- try { return JSON.parse(fs.readFileSync(path.join(os.homedir(), '.thinkpool-pair', 'update-status.json'), 'utf8')) } catch { return null }
772
- },
773
- now = Date.now,
774
- sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
775
- timeoutMs = SERVICE_CONFIRM_TIMEOUT_MS,
776
- pollMs = 250,
777
- stderr = process.stderr,
778
- } = {}) {
779
- const before = snapshot(room, { platform, exec })
780
- const confirmationStartedAt = now()
781
- let target = null
782
- const captureTarget = (...args) => {
783
- const result = exec(...args)
784
- const resolved = String(result).trim()
785
- if (/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(resolved)) target = resolved
786
- return result
787
- }
788
- const staged = update(room, { exec: captureTarget, install, active }) !== false
789
- if (!staged || !target) {
790
- stderr.write(' ⚠ update was not confirmed: staging the published runtime failed; the existing service was left unchanged.\n')
791
- return false
792
- }
793
-
794
- const confirmation = () => {
795
- const live = snapshot(room, { platform, exec })
796
- const versionChanged = !!before && before.version !== target
797
- const processChanged = !!before?.pid && !!live?.pid && before.pid !== live.pid
798
- const ready = !!room || platform === 'win32' || supervisorReadyMatches(readReady(), live, target, { notBefore: confirmationStartedAt })
799
- if (live?.version === target && (versionChanged || processChanged) && ready) {
800
- stderr.write(` ✓ bridge restart confirmed — running v${target}.\n`)
801
- return { ok: true, live }
802
- }
803
- return { ok: false, live }
804
- }
805
- const printUnconfirmed = ({ live } = {}) => {
806
- if (live?.version && live.version !== target) stderr.write(` ⚠ update v${target} was not confirmed: live service is still v${live.version}.\n`)
807
- else if (live?.version === target) stderr.write(` ⚠ update v${target} was not confirmed: the service process did not restart.\n`)
808
- else stderr.write(` ⚠ update v${target} was not confirmed: the service manager cannot prove the new runtime is running.\n`)
809
- }
810
-
811
- // The Windows Startup-folder tier has no authoritative live-service identity, so
812
- // callers use updateService directly and label it as a next-launch update. Both
813
- // managed Unix tiers are asynchronous external handoffs and share the receipt loop.
814
- if (platform === 'win32') return true
815
-
816
- const deadline = now() + Math.max(0, Number(timeoutMs) || 0)
817
- const expectedTarget = label(room)
818
- let lastProof = null
819
- for (;;) {
820
- const status = readStatus()
821
- if (status && typeof status === 'object' && status.target === expectedTarget) {
822
- if (status.ok === false && status.version === target) {
823
- stderr.write(` ⚠ update v${target} was not confirmed: ${status.error || 'the launchd handoff failed'}.\n`)
824
- return false
825
- }
826
- if (status.ok === true && status.version === target) {
827
- lastProof = confirmation()
828
- if (lastProof.ok) return true
829
- }
830
- }
831
- if (now() >= deadline) {
832
- if (lastProof) printUnconfirmed(lastProof)
833
- else stderr.write(` ⚠ update v${target} timed out waiting for launchd confirmation; the service may have rolled back.\n`)
834
- return false
835
- }
836
- await sleep(Math.max(1, Number(pollMs) || 1))
837
- }
838
- }
839
-
840
- // Ground truth: is the OS service manager ACTUALLY running our service right now?
841
- // A leftover plist/unit on disk does NOT mean it's loaded — `launchctl load` can
842
- // silently no-op (the 2026-06-26 "says installed but it's off" bug), and a unit can
843
- // be left disabled. Ask the manager, don't trust file presence. room falsy → account.
844
- export function serviceActive(room, { platform = process.platform, exec = execSync } = {}) {
845
- const plat = platform
846
- const id = label(room)
847
- try {
848
- if (plat === 'darwin') {
849
- const out = exec(`launchctl print gui/$(id -u)/${id}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
850
- return darwinServiceRunning(out)
851
- }
852
- if (plat === 'linux') { exec(`systemctl --user is-active --quiet ${id}.service`); return true }
853
- // A Startup entry is install evidence, never proof that its console process
854
- // is alive. Windows has no per-user manager we can query authoritatively.
855
- return false
856
- } catch { return false }
857
- }
858
-
859
- // Is a boot-persistent service installed AND loaded? room falsy → the ACCOUNT service.
860
- // Lets the main run skip the offer + avoid starting a second bridge that races the
861
- // service. Reflects launchd/systemd reality, not just file existence (so a plist that
862
- // failed to load reads as not-installed → the menu re-offers install, which self-heals).
863
- export function isServiceInstalled(room) {
864
- if (process.platform === 'win32') {
865
- try { return fs.existsSync(buildArtifact('win32', { room }).file) } catch { return false }
866
- }
867
- return serviceActive(room)
868
- }
869
-
870
- export function uninstallService(room) {
871
- const plat = process.platform
872
- let a
873
- try { a = buildArtifact(plat, { room }) } catch (e) { process.stderr.write(` ⚠ ${e.message}\n`); return }
874
- if (plat === 'darwin') { try { execSync(`launchctl bootout gui/$(id -u)/${label(room)} 2>/dev/null`) } catch { /* noop */ } }
875
- if (plat === 'linux') { try { execSync(`systemctl --user disable --now ${label(room)}.service 2>/dev/null`) } catch { /* noop */ } }
876
- try { fs.rmSync(a.file, { force: true }); process.stderr.write(` ◆ removed ${a.file}\n`) } catch (e) { process.stderr.write(` ⚠ ${e.message}\n`) }
877
- }
1
+ import e from"node:os";import t from"node:fs";import r from"node:path";import{execSync as n}from"node:child_process";import{hostMemoryAdmission as i}from"./host-memory.mjs";import{readSupervisorReady as o,supervisorReadyMatches as s}from"./supervisor-ready.mjs";import{pairCli as a}from"./command-guidance.mjs";import{loadKeepAwakePreference as l}from"./keep-awake.mjs";import{scheduledRunsEnabled as c}from"./scheduled-runs.mjs";const d=/^[A-Za-z0-9]{2,16}$/,p=e=>{if(e&&!d.test(e))throw new Error(`invalid room code: ${JSON.stringify(e)} (expected 2–16 alphanumerics)`);return e?`io.thinkpool.pair.${e.toLowerCase()}`:"io.thinkpool.pair.account"},u=e=>e||"account",f=e=>String(e).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"),$=e=>`'${String(e).replace(/'/g,"'\\''")}'`;export function sanitizeServicePath(e,t=process.platform){const r="win32"===t?";":":";return String(e||"").split(r).filter(e=>e&&!e.includes("/.npm/_npx/")).join(r)}export function darwinServiceRunning(e){const t=String(e||"");return/(^|\n)[ \t]*state = running[ \t]*($|\n)/.test(t)&&/(^|\n)[ \t]*pid = \d+[ \t]*($|\n)/.test(t)}const h=e=>`rm -rf ${$(r.join(e,"_npx"))}/* 2>/dev/null`,m=(()=>{try{return JSON.parse(t.readFileSync(new URL("./package.json",import.meta.url),"utf8")).version}catch{return"latest"}})();function g(e){try{const n=JSON.parse(t.readFileSync(r.join(r.dirname(e),"package.json"),"utf8"));return"string"==typeof n?.version?n.version:null}catch{return null}}export function provisionRuntime(o,{exec:s=n,root:a=r.join(e.homedir(),".thinkpool-pair","runtimes"),admit:l=i}={}){if(!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(o)))throw new Error(`invalid runtime version: ${o}`);const c=r.join(a,String(o)),d=r.join(c,"node_modules","thinkpool-pair","bridge.mjs");if(t.existsSync(d)&&g(d)===String(o))return d;const p=l("stage a bridge runtime update");if(!p?.ok)throw new Error(p?.reason||"host memory is critically low");const u=`${c}.install-${process.pid}-${Date.now()}`;t.rmSync(u,{recursive:!0,force:!0}),t.mkdirSync(u,{recursive:!0});const f=`https://registry.npmjs.org/thinkpool-pair/-/thinkpool-pair-${o}.tgz`,h=r.join(u,"node_modules","thinkpool-pair","bridge.mjs");try{s(`${$(function(e=process.platform){const n=r.dirname(process.execPath),i=r.join(n,"win32"===e?"npm.cmd":"npm");try{return t.accessSync(i),i}catch{return"win32"===e?"npm.cmd":"npm"}}())} install --prefix ${$(u)} ${$(f)} --prefer-online --no-audit --no-fund`,{stdio:"inherit",shell:"/bin/bash"});const e=g(h);if(!t.existsSync(h)||e!==String(o))throw new Error(`runtime verification failed: requested ${o}, installed ${e||"no valid package"}`);t.rmSync(c,{recursive:!0,force:!0}),t.renameSync(u,c)}catch(e){throw t.rmSync(u,{recursive:!0,force:!0}),e}return d}export function buildArtifact(n,{room:i=null,cmdArgs:o=[],autoUpdate:s=!1,version:a=m,staleProof:l=!1,runtimeEntry:d=null,scheduledRuns:g=c()}={}){const v=function(e){const n=r.dirname(process.execPath),i=r.join(n,"win32"===e?"npx.cmd":"npx");try{return t.accessSync(i),i}catch{return"win32"===e?"npx.cmd":"npx"}}(n),y=i?process.cwd():e.homedir(),k=u(i),b=r.join(e.homedir(),".thinkpool-pair"),w=r.join(b,`${k}.log`),S=sanitizeServicePath(process.env.PATH||"",n),_=r.join(e.homedir(),".thinkpool-pair","npm-cache"),x=o.length?["--",...o]:[],A=i?`bridge (${i})`:"account bridge",P=s?"thinkpool-pair@latest":`thinkpool-pair@${a}`,j=s||l?["--prefer-online"]:[],E=i?["-y",...j,P,i]:["-y",...j,P],F=!0===g?{darwin:"<key>TP_SCHEDULED_RUNS_ENABLED</key><string>1</string>",linux:"Environment=TP_SCHEDULED_RUNS_ENABLED=1\n",win32:'set "TP_SCHEDULED_RUNS_ENABLED=1"\r\n'}:{darwin:"",linux:"",win32:""};if("darwin"===n){const t=d?[process.execPath,d,...i?[i]:[],...x]:l?["/bin/bash","-lc",`${h(_)}; export NPM_CONFIG_CACHE=${$(_)}; exec ${[v,...E,...x].map($).join(" ")}`]:[v,...E,...x];return{file:r.join(e.homedir(),"Library","LaunchAgents",`${p(i)}.plist`),content:`<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n <key>Label</key><string>${p(i)}</string>\n <key>ProgramArguments</key><array>${t.map(e=>`<string>${f(e)}</string>`).join("")}</array>\n <key>RunAtLoad</key><true/>\n \x3c!-- KeepAlive only on UNSUCCESSFUL exit. A bridge that exits 0 has intentionally\n stood down — told to stop, or another bridge already owns this machine's\n singleton lock — and must NOT be relaunched, or launchd thrashes it every\n few seconds forever (the 2026-06-28 account-bridge restart loop). A crash\n or an update-restart exits non-zero and IS relaunched. Mirrors the bridge's\n own exit-code contract (stop()=0, restart-requested=non-zero). --\x3e\n <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>\n <key>WorkingDirectory</key><string>${f(y)}</string>\n <key>StandardOutPath</key><string>${f(w)}</string>\n <key>StandardErrorPath</key><string>${f(w)}</string>\n <key>EnvironmentVariables</key><dict><key>PATH</key><string>${f(S)}</string>${d?"":`<key>NPM_CONFIG_CACHE</key><string>${f(_)}</string>`}${s?"<key>THINKPOOL_PAIR_AUTOUPDATE</key><string>1</string>":""}${F.darwin}</dict>\n</dict></plist>\n`,logDir:b,post:[],expectedRuntime:d||P,note:"Loaded as a LaunchAgent — starts at login, restarts on crash."}}if("linux"===n){const t=d?[process.execPath,d,...i?[i]:[],...x]:[v,...E,...x];return{file:r.join(e.homedir(),".config","systemd","user",`${p(i)}.service`),content:`[Unit]\nDescription=ThinkPool Code ${A}\nAfter=network-online.target\n\n[Service]\nExecStart=${d?t.map(e=>`'${e.replace(/'/g,"'\\''")}'`).join(" "):l?`/bin/bash -lc "${h(_)}; export NPM_CONFIG_CACHE=${$(_)}; exec ${[v,...E,...x].join(" ")}"`:t.map(e=>`'${e.replace(/'/g,"'\\''")}'`).join(" ")}\nRestart=always\nRestartSec=2\n# Don't relaunch a clean stand-down. exit 0 = the bridge intentionally stopped —\n# told to stop, or another bridge already holds this machine's singleton lock —\n# so restarting it just thrashes every RestartSec forever (the 2026-06-28\n# account-bridge restart loop). Crashes + update-restarts exit non-zero and are\n# still restarted. Matches the bridge's exit-code contract (stop()=0, restart=non-zero).\nRestartPreventExitStatus=0\nWorkingDirectory=${y}\nEnvironment=PATH=${S}\n${d?"":`Environment=NPM_CONFIG_CACHE=${_}\n`}${s?"Environment=THINKPOOL_PAIR_AUTOUPDATE=1\n":""}${F.linux}StandardOutput=append:${w}\nStandardError=append:${w}\n\n[Install]\nWantedBy=default.target\n`,logDir:b,post:["systemctl --user daemon-reload",`systemctl --user enable ${p(i)}.service`,`systemctl --user restart ${p(i)}.service`],expectedRuntime:d||P,note:"Enabled as a systemd --user service. Run `loginctl enable-linger $USER` once to keep it running after logout."}}if("win32"===n){const t=r.join(process.env.APPDATA||r.join(e.homedir(),"AppData","Roaming"),"Microsoft","Windows","Start Menu","Programs","Startup"),n=r.join(t,`thinkpool-pair-${k}.cmd`),o=d?[process.execPath,d,...i?[i,"--supervise"]:[],...x].map(e=>`"${e}"`).join(" "):i?["npx","-y",...j,P,i,"--supervise",...x].join(" "):["npx","-y",...j,P].join(" ");return{file:n,content:`@echo off\r\ntitle thinkpool-pair ${k}\r\n${d?"":`set "NPM_CONFIG_CACHE=${_}"\r\n`}${F.win32}${o}\r\n`,logDir:b,post:[],note:"Installed to the Startup folder — runs at login"+(i?" with --supervise (auto-restart on crash).":" (account mode).")+" Start it now without rebooting by double-clicking the .cmd, or run it from a terminal."}}throw new Error(`unsupported platform: ${n}`)}export function parseRunningPairVersions(e){const t=String(e).match(/(?:thinkpool-pair@|runtimes[\\/])(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)/g)||[];return new Set(t.map(e=>e.match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/)?.[0]).filter(Boolean))}export function serviceRuntimeSnapshot(e,{platform:t=process.platform,exec:r=n}={}){const i=e=>{const t=parseRunningPairVersions(e);return 1===t.size?[...t][0]:null},o=p(e);try{if("darwin"===t){const e=r(`launchctl print gui/$(id -u)/${o}`,{encoding:"utf8",stdio:["ignore","pipe","ignore"],shell:"/bin/bash"}),t=String(e).match(/(^|\n)[ \t]*pid = (\d+)[ \t]*($|\n)/)?.[2];return darwinServiceRunning(e)?{version:i(e),pid:t||null}:null}if("linux"===t){const e=r(`systemctl --user show ${o}.service --property=ActiveState --property=MainPID --property=ExecStart`,{encoding:"utf8",stdio:["ignore","pipe","ignore"]}),t=String(e).match(/^MainPID=([1-9]\d*)$/m)?.[1];return/^ActiveState=active$/m.test(String(e))&&t?{version:i(e),pid:t}:null}}catch{}return null}export function serviceRuntimeVersion(e,t){return serviceRuntimeSnapshot(e,t)?.version||null}export function buildDarwinReloadHandoff({targetLabel:t,targetFile:n,stagedFile:i,expectedRuntime:o,version:s,nonce:a=`${process.pid}-${Date.now()}`}){if(!/^io\.thinkpool\.pair\.[A-Za-z0-9_.-]+$/.test(String(t)))throw new Error(`invalid service label: ${t}`);if(!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(s)))throw new Error(`invalid runtime version: ${s}`);const l=`io.thinkpool.pair.update.${String(a).replace(/[^A-Za-z0-9-]/g,"")||String(process.pid)}`,c=r.join(e.homedir(),"Library","LaunchAgents",`${l}.plist`),d=`${i}.previous`,u=r.join(e.homedir(),".thinkpool-pair","update-status.json"),h=r.join(e.homedir(),".thinkpool-pair","update.log"),m=r.join(e.homedir(),".thinkpool-pair","account-ready.json"),g=t===p(null),v=["set -u",'dom="gui/$(id -u)"',`target=${$(t)}`,`helper=${$(l)}`,`target_file=${$(n)}`,`staged_file=${$(i)}`,`backup_file=${$(d)}`,`helper_file=${$(c)}`,`expected=${$(o)}`,`version=${$(s)}`,`status=${$(u)}`,`ready=${$(m)}`,"requires_ready="+(g?"1":"0"),'tmp="$status.tmp.$$"',"ok=0",'live_pid=""',"stable=0","had_previous=0",'if [ -f "$target_file" ]; then cp "$target_file" "$backup_file" && had_previous=1; fi','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','if [ "$requires_ready" = 1 ]; then rm -f "$ready"; fi','cp "$staged_file" "$target_file"','launchctl enable "$dom/$target" 2>/dev/null || true','for i in $(seq 1 30); do launchctl bootstrap "$dom" "$target_file" 2>/dev/null && break; sleep 0.3; done','printf "update_handoff_start target=%s version=%s timeout_s=90\\n" "$target" "$version"','for i in $(seq 1 180); do snapshot="$(launchctl print "$dom/$target" 2>/dev/null || true)"; pid="$(printf "%s\\n" "$snapshot" | sed -n "s/^[[:space:]]*pid = \\([0-9][0-9]*\\)[[:space:]]*$/\\1/p" | head -1)"; ready_ok=1; if [ "$requires_ready" = 1 ]; then ready_ok=0; [ -f "$ready" ] && grep -F "\\"pid\\":$pid" "$ready" >/dev/null 2>&1 && grep -F "\\"version\\":\\"$version\\"" "$ready" >/dev/null 2>&1 && ready_ok=1; fi; if [ -n "$pid" ] && [ "$ready_ok" = 1 ] && printf "%s\\n" "$snapshot" | grep -F -- "$expected" >/dev/null && printf "%s\\n" "$snapshot" | grep -F "state = running" >/dev/null; then if [ "$pid" = "$live_pid" ]; then stable=$((stable+1)); else live_pid="$pid"; stable=1; fi; if [ "$stable" -ge 5 ]; then ok=1; break; fi; else live_pid=""; stable=0; fi; sleep 0.5; done','if [ "$ok" != 1 ]; then ready_state=missing; [ -f "$ready" ] && ready_state=present; printf "update_handoff_verify_timeout target=%s version=%s last_pid=%s ready=%s\\n" "$target" "$version" "$live_pid" "$ready_state"; fi',`if [ "$ok" = 1 ]; then printf '%s\\n' '${JSON.stringify({ok:!0,version:String(s),target:String(t)})}' > "$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; if [ "$requires_ready" = 1 ]; then rm -f "$ready"; fi; cp "$backup_file" "$target_file"; launchctl enable "$dom/$target" 2>/dev/null || true; bootstrapped=0; for i in $(seq 1 30); do if launchctl bootstrap "$dom" "$target_file" 2>/dev/null; then bootstrapped=1; break; fi; sleep 0.3; done; if [ "$bootstrapped" = 1 ]; then rollback_pid=""; rollback_stable=0; for i in $(seq 1 180); do snapshot="$(launchctl print "$dom/$target" 2>/dev/null || true)"; pid="$(printf "%s\\n" "$snapshot" | sed -n "s/^[[:space:]]*pid = \\([0-9][0-9]*\\)[[:space:]]*$/\\1/p" | head -1)"; ready_ok=1; if [ "$requires_ready" = 1 ]; then ready_ok=0; [ -f "$ready" ] && grep -F "\\"pid\\":$pid" "$ready" >/dev/null 2>&1 && ready_ok=1; fi; if [ -n "$pid" ] && [ "$ready_ok" = 1 ] && printf "%s\\n" "$snapshot" | grep -F "state = running" >/dev/null; then if [ "$pid" = "$rollback_pid" ]; then rollback_stable=$((rollback_stable+1)); else rollback_pid="$pid"; rollback_stable=1; fi; if [ "$rollback_stable" -ge 5 ]; then rollback=1; break; fi; else rollback_pid=""; rollback_stable=0; fi; sleep 0.5; done; fi; else launchctl bootout "$dom/$target" 2>/dev/null || true; rm -f "$target_file"; fi; if [ "$rollback" = 1 ]; then printf '%s\\n' '${JSON.stringify({ok:!1,version:String(s),target:String(t),error:"new runtime not confirmed within 90s; previous service restored and ready",rolledBack:!0})}' > "$tmp"; else printf '%s\\n' '${JSON.stringify({ok:!1,version:String(s),target:String(t),error:"new runtime not confirmed within 90s and ready rollback failed",rolledBack:!1})}' > "$tmp"; fi; fi`,'printf "update_handoff_complete target=%s version=%s ok=%s\\n" "$target" "$version" "$ok"','mv "$tmp" "$status"','rm -f "$staged_file" "$backup_file" "$helper_file"','launchctl bootout "$dom/$helper" >/dev/null 2>&1 || true','exit "$((1-ok))"'].join("; ");return{helperLabel:l,helperFile:c,backupFile:d,statusFile:u,logFile:h,script:v,content:`<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n <key>Label</key><string>${f(l)}</string>\n <key>ProgramArguments</key><array><string>/bin/bash</string><string>-lc</string><string>${f(v)}</string></array>\n <key>RunAtLoad</key><true/>\n <key>StandardOutPath</key><string>${f(h)}</string>\n <key>StandardErrorPath</key><string>${f(h)}</string>\n</dict></plist>\n`}}export function buildLinuxReloadHandoff({targetLabel:t,targetFile:n,stagedFile:i,expectedRuntime:o,version:s,nonce:a=`${process.pid}-${Date.now()}`}){if(!/^io\.thinkpool\.pair\.[A-Za-z0-9_.-]+$/.test(String(t)))throw new Error(`invalid service label: ${t}`);if(!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(s)))throw new Error(`invalid runtime version: ${s}`);const l=`io.thinkpool.pair.update.${String(a).replace(/[^A-Za-z0-9-]/g,"")||String(process.pid)}`,c=`${i}.previous`,d=r.join(e.homedir(),".thinkpool-pair","update-status.json"),u=r.join(e.homedir(),".thinkpool-pair","update.log"),f=r.join(e.homedir(),".thinkpool-pair","account-ready.json"),h=t===p(null);return{helperLabel:l,backupFile:c,statusFile:d,logFile:u,script:["set -u",`target=${$(t)}`,'unit="$target.service"',`target_file=${$(n)}`,`staged_file=${$(i)}`,`backup_file=${$(c)}`,`expected=${$(o)}`,`version=${$(s)}`,`status=${$(d)}`,`ready=${$(f)}`,"requires_ready="+(h?"1":"0"),'tmp="$status.tmp.$$"',"ok=0","had_previous=0",'if [ -f "$target_file" ]; then cp "$target_file" "$backup_file" && had_previous=1; fi','systemctl --user stop "$unit" 2>/dev/null || true','if [ "$requires_ready" = 1 ]; then rm -f "$ready"; fi','cp "$staged_file" "$target_file"',"systemctl --user daemon-reload",'systemctl --user enable "$unit" >/dev/null 2>&1 || true','systemctl --user restart "$unit" >/dev/null 2>&1 || true','live_pid=""',"stable=0",'printf "update_handoff_start target=%s version=%s timeout_s=90\\n" "$target" "$version"','for i in $(seq 1 180); do snapshot="$(systemctl --user show "$unit" --property=ActiveState --property=MainPID --property=ExecStart 2>/dev/null || true)"; pid="$(printf "%s\\n" "$snapshot" | sed -n "s/^MainPID=\\([1-9][0-9]*\\)$/\\1/p" | head -1)"; ready_ok=1; if [ "$requires_ready" = 1 ]; then ready_ok=0; [ -f "$ready" ] && grep -F "\\"pid\\":$pid" "$ready" >/dev/null 2>&1 && grep -F "\\"version\\":\\"$version\\"" "$ready" >/dev/null 2>&1 && ready_ok=1; fi; if [ -n "$pid" ] && [ "$ready_ok" = 1 ] && printf "%s\\n" "$snapshot" | grep -F "ActiveState=active" >/dev/null && printf "%s\\n" "$snapshot" | grep -F -- "$expected" >/dev/null; then if [ "$pid" = "$live_pid" ]; then stable=$((stable+1)); else live_pid="$pid"; stable=1; fi; if [ "$stable" -ge 5 ]; then ok=1; break; fi; else live_pid=""; stable=0; fi; sleep 0.5; done','if [ "$ok" != 1 ]; then ready_state=missing; [ -f "$ready" ] && ready_state=present; printf "update_handoff_verify_timeout target=%s version=%s last_pid=%s ready=%s\\n" "$target" "$version" "$live_pid" "$ready_state"; fi',`if [ "$ok" = 1 ]; then printf '%s\\n' '${JSON.stringify({ok:!0,version:String(s),target:String(t)})}' > "$tmp"; else rollback=0; if [ "$had_previous" = 1 ]; then systemctl --user stop "$unit" 2>/dev/null || true; if [ "$requires_ready" = 1 ]; then rm -f "$ready"; fi; cp "$backup_file" "$target_file"; systemctl --user daemon-reload; systemctl --user enable "$unit" >/dev/null 2>&1 || true; systemctl --user restart "$unit" >/dev/null 2>&1 || true; rollback_pid=""; rollback_stable=0; for i in $(seq 1 180); do snapshot="$(systemctl --user show "$unit" --property=ActiveState --property=MainPID 2>/dev/null || true)"; pid="$(printf "%s\\n" "$snapshot" | sed -n "s/^MainPID=\\([1-9][0-9]*\\)$/\\1/p" | head -1)"; ready_ok=1; if [ "$requires_ready" = 1 ]; then ready_ok=0; [ -f "$ready" ] && grep -F "\\"pid\\":$pid" "$ready" >/dev/null 2>&1 && ready_ok=1; fi; if [ -n "$pid" ] && [ "$ready_ok" = 1 ] && printf "%s\\n" "$snapshot" | grep -F "ActiveState=active" >/dev/null; then if [ "$pid" = "$rollback_pid" ]; then rollback_stable=$((rollback_stable+1)); else rollback_pid="$pid"; rollback_stable=1; fi; if [ "$rollback_stable" -ge 5 ]; then rollback=1; break; fi; else rollback_pid=""; rollback_stable=0; fi; sleep 0.5; done; else systemctl --user stop "$unit" 2>/dev/null || true; rm -f "$target_file"; systemctl --user daemon-reload; fi; if [ "$rollback" = 1 ]; then printf '%s\\n' '${JSON.stringify({ok:!1,version:String(s),target:String(t),error:"new runtime not confirmed within 90s; previous service restored and ready",rolledBack:!0})}' > "$tmp"; else printf '%s\\n' '${JSON.stringify({ok:!1,version:String(s),target:String(t),error:"new runtime not confirmed within 90s and ready rollback failed",rolledBack:!1})}' > "$tmp"; fi; fi`,'printf "update_handoff_complete target=%s version=%s ok=%s\\n" "$target" "$version" "$ok"','mv "$tmp" "$status"','rm -f "$staged_file" "$backup_file"','exit "$((1-ok))"'].join("; ")}}const v=()=>r.join(e.homedir(),".thinkpool-pair","account.lock"),y=e=>{try{return process.kill(e,0),!0}catch(e){return"EPERM"===e?.code}};export async function retireUnmanagedBridgeLock({lockFile:e=v(),readLock:r=()=>t.readFileSync(e,"utf8"),removeLock:i=()=>t.rmSync(e,{force:!0}),commandForPid:o=e=>String(n(`ps -p ${e} -o command=`,{encoding:"utf8",stdio:["ignore","pipe","ignore"]})),alive:s=y,signal:a=(e,t)=>process.kill(e,t),sleep:l=e=>new Promise(t=>setTimeout(t,e)),waitMs:c=2500,pollMs:d=100,stderr:p=process.stderr}={}){let u=0;try{u=parseInt(String(r()).trim(),10)||0}catch{return!0}const f=()=>{if(s(u))return!1;try{(parseInt(String(r()).trim(),10)||0)===u&&i()}catch{}return!0};if(!u||u===process.pid||f())return!0;let $="";try{$=o(u)}catch{return f()}if(!/(?:node_modules[\\/]thinkpool-pair|\.npm[\\/]_npx|\.thinkpool-pair[\\/]runtimes)[\\/][^\n]*bridge\.mjs(?:\s|$)/.test($))return p.write(` ⚠ account.lock belongs to PID ${u}, but it is not a verified thinkpool-pair bridge; refusing to signal an unrelated process.\n`),!1;p.write(` ◆ stopping previous thinkpool-pair bridge (PID ${u}) before installing the background service…\n`);try{a(u,"SIGTERM")}catch{if(f())return!0}const h=Math.max(1,Math.ceil(Math.max(0,Number(c)||0)/Math.max(1,Number(d)||1)));for(let e=0;e<h;e++){if(f())return!0;await l(Math.max(1,Number(d)||1))}try{a(u,"SIGKILL")}catch{}for(let e=0;e<5;e++){if(f())return!0;await l(Math.max(1,Number(d)||1))}return p.write(` ⚠ previous thinkpool-pair bridge PID ${u} did not stop; background install aborted to avoid two competing bridges.\n`),!1}function k(e){const t=r.basename(e).match(/^(io\.thinkpool\.pair\.[A-Za-z0-9_.-]+)\.(?:plist|service)$/);if(!t)throw new Error(`cannot derive service label from ${e}`);return t[1]}export function existingServiceLabels(i=process.platform,{fsMod:o=t}={}){const s=new Set;try{if("darwin"===i){try{const e=n("launchctl print gui/$(id -u) 2>/dev/null | grep -oE 'io\\.thinkpool\\.pair\\.[A-Za-z0-9_]+' | sort -u",{encoding:"utf8",shell:"/bin/bash"});for(const t of e.split("\n")){const e=t.trim();e&&s.add(e)}}catch{}try{const t=r.join(e.homedir(),"Library","LaunchAgents");for(const e of o.readdirSync(t)){const t=e.match(/^(io\.thinkpool\.pair\.[A-Za-z0-9_]+)\.plist$/);t&&s.add(t[1])}}catch{}}else if("linux"===i){try{const e=n("systemctl --user list-unit-files 'io.thinkpool.pair.*' --no-legend 2>/dev/null | awk '{print $1}'",{encoding:"utf8",shell:"/bin/bash"});for(const t of e.split("\n")){const e=t.trim().replace(/\.service$/,"");e&&s.add(e)}}catch{}try{const t=r.join(e.homedir(),".config","systemd","user");for(const e of o.readdirSync(t)){const t=e.match(/^(io\.thinkpool\.pair\.[A-Za-z0-9_]+)\.service$/);t&&s.add(t[1])}}catch{}}}catch{}return[...s]}export function sweepExistingServices(i,o=process.platform){for(const s of existingServiceLabels(o))if(s!==i)try{if("darwin"===o){n(`launchctl bootout gui/$(id -u)/${s} 2>/dev/null || true`,{stdio:"ignore",shell:"/bin/bash"});try{t.rmSync(r.join(e.homedir(),"Library","LaunchAgents",`${s}.plist`),{force:!0})}catch{}}else"linux"===o&&n(`systemctl --user disable --now ${s}.service 2>/dev/null || true`,{stdio:"ignore",shell:"/bin/bash"});process.stderr.write(` ◆ swept stale service ${s}\n`)}catch{}if("win32"!==o)try{n("pkill -9 -f 'thinkpool-pair/bridge.mjs [A-Z0-9]' 2>/dev/null || true",{stdio:"ignore",shell:"/bin/bash"})}catch{}}export function installService(e,i=[],{autoUpdate:o=!1,version:s=m,staleProof:c=!0}={}){let d=null;if(!o)try{d=provisionRuntime(s)}catch(e){return process.stderr.write(` ⚠ runtime install failed; service left unchanged: ${e.message}\n`),!1}const f=buildArtifact(process.platform,{room:e,cmdArgs:i,autoUpdate:o,version:s,staleProof:c,runtimeEntry:d});try{sweepExistingServices(p(e),process.platform)}catch{}if(t.mkdirSync(f.logDir,{recursive:!0}),t.mkdirSync(r.dirname(f.file),{recursive:!0}),"darwin"===process.platform){if(process.stderr.write(`\n ◆ staged replacement for ${f.file}\n`),!function(e,i){const o=r.join(e.logDir,`service-update-${process.pid}-${Date.now()}.plist`),s=buildDarwinReloadHandoff({targetLabel:k(e.file),targetFile:e.file,stagedFile:o,expectedRuntime:e.expectedRuntime,version:i});t.mkdirSync(r.dirname(s.helperFile),{recursive:!0}),t.mkdirSync(r.dirname(s.statusFile),{recursive:!0}),t.rmSync(s.statusFile,{force:!0}),t.writeFileSync(o,e.content),t.writeFileSync(s.helperFile,s.content);try{return n(`launchctl bootstrap gui/$(id -u) ${$(s.helperFile)}`,{stdio:"inherit",shell:"/bin/bash"}),process.stderr.write(` ◆ update handoff armed outside the bridge process tree — completion is recorded in ${s.statusFile}.\n`),!0}catch(e){return t.rmSync(o,{force:!0}),t.rmSync(s.helperFile,{force:!0}),process.stderr.write(` ⚠ could not arm the launchd update handoff; existing service left running: ${e.message}\n`),!1}}(f,s))return!1}else if("linux"===process.platform){if(process.stderr.write(`\n ◆ staged replacement for ${f.file}\n`),!function(e,i){const o=r.join(e.logDir,`service-update-${process.pid}-${Date.now()}.service`),s=buildLinuxReloadHandoff({targetLabel:k(e.file),targetFile:e.file,stagedFile:o,expectedRuntime:e.expectedRuntime,version:i});t.mkdirSync(r.dirname(s.statusFile),{recursive:!0}),t.rmSync(s.statusFile,{force:!0}),t.writeFileSync(o,e.content);try{return n(`systemd-run --user --unit=${$(s.helperLabel)} --collect --property=StandardOutput=append:${$(s.logFile)} --property=StandardError=append:${$(s.logFile)} /bin/bash -lc ${$(s.script)}`,{stdio:"inherit",shell:"/bin/bash"}),process.stderr.write(` ◆ update handoff armed outside the bridge service cgroup — completion is recorded in ${s.statusFile}.\n`),!0}catch(e){return t.rmSync(o,{force:!0}),process.stderr.write(` ⚠ could not arm the systemd update handoff; existing service left running: ${e.message}\n`),!1}}(f,s))return!1}else{t.writeFileSync(f.file,f.content),process.stderr.write(`\n ◆ wrote ${f.file}\n`);for(const e of f.post)try{n(e,{stdio:"inherit"})}catch(t){return process.stderr.write(` ⚠ "${e}" failed; service update NOT confirmed: ${t.message}\n`),!1}}const h=Math.max(10,parseInt(process.env.THINKPOOL_PAIR_UPDATE_IDLE,10)||90),g=o?"win32"===process.platform?"auto-update ON: a new thinkpool-pair@latest applies on the next login/restart (Windows has no in-process self-update).":`auto-update ON: checks npm ~every 30 min and restarts to apply a new @latest once sessions are idle (≥${h}s quiet; they resume in place).`:`PINNED to ${s} (stable local runtime; restarts do not depend on npx or the network). To update: use "Restart & update the bridge" (or run ${a("install-service",e||void 0)}). Pass --auto-update to track @latest instead.`,v=e?` ${e}`:"",y=e?`room ${e}`:"your account (auto-serves every session)",b=l()?" ◆ Keep-awake on: system sleep is inhibited while the bridge runs (higher battery use); the display may still dim, lock, and turn off.\n":"";return process.stderr.write(` ◆ ${y}\n ◆ ${"darwin"===process.platform?"Launchd is completing and verifying the reload independently.":f.note}\n ◆ Prerequisite: Claude Code, Codex, or Hermes must be installed on this machine. BYOK supplies model credentials; it does not install an agent.\n${b} ◆ ${g}\n ◆ logs: ${r.join(f.logDir,`${u(e)}.log`)}\n ◆ remove with: ${a("uninstall-service")}${v}\n\n`),!0}export async function installAndConfirmService(i,a=[],{platform:l=process.platform,version:c=m,staleProof:d=!0,install:u=installService,exec:f=n,snapshot:$=serviceRuntimeSnapshot,prepareAccountHandoff:h=retireUnmanagedBridgeLock,readReady:g=()=>o(),readStatus:v=()=>{try{return JSON.parse(t.readFileSync(r.join(e.homedir(),".thinkpool-pair","update-status.json"),"utf8"))}catch{return null}},now:y=Date.now,sleep:k=e=>new Promise(t=>setTimeout(t,e)),timeoutMs:b=2e5,pollMs:w=250,stderr:S=process.stderr}={}){const _=$(i,{platform:l,exec:f});if(!i&&"win32"!==l&&!_&&!await h())return S.write(" ⚠ background install aborted: the previous bridge could not be handed off safely.\n"),!1;const x=y();if(!1===u(i,a,{version:c,staleProof:d}))return!1;const A=()=>{const e=$(i,{platform:l,exec:f}),t=!!i||"win32"===l||s(g(),e,c,{notBefore:x});return!(e?.version!==c||!t||(S.write(` ✓ bridge install confirmed — running v${c}.\n`),0))};if("win32"===l)return!0;const P=y()+Math.max(0,Number(b)||0),j=p(i);for(;;){const e=v();if(e&&"object"==typeof e&&e.target===j&&e.version===c){if(!1===e.ok)return S.write(` ⚠ install v${c} was not confirmed: ${e.error||"the launchd handoff failed"}.\n`),!1;if(!0===e.ok&&A())return!0}if(y()>=P)return S.write(` ⚠ install v${c} timed out waiting for supervisor readiness; no connected account bridge was proven.\n`),!1;await k(Math.max(1,Number(w)||1))}}export function restartService(e){const t=process.platform;if(!isServiceInstalled(e))return process.stderr.write(" ⚠ no background service installed to restart — install one first.\n"),!1;const r=p(e);try{if("darwin"===t)n(`launchctl kickstart -k gui/$(id -u)/${r}`,{stdio:"inherit"});else{if("linux"!==t)return process.stderr.write(" ⚠ Windows has no managed daemon — close the running thinkpool-pair window and re-launch the Startup .cmd (or reboot) to restart.\n"),!1;n(`systemctl --user restart ${r}.service`,{stdio:"inherit"})}return process.stderr.write(` ◆ restarted ${r} — any active session resumes in place.\n`),!0}catch(e){return process.stderr.write(` ⚠ restart failed: ${e.message}\n`),!1}}function b(e=n){let t="";try{t=String(e("npm view thinkpool-pair version",{encoding:"utf8",stdio:["ignore","pipe","ignore"]})).trim()}catch{}return/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(t)?t:null}export function stageServiceUpdate(e,{exec:t=n,active:r=isServiceInstalled,provision:i=provisionRuntime}={}){if(!r(e))return process.stderr.write(" ⚠ no background service installed to update — install one first.\n"),null;const o=b(t);if(!o)return process.stderr.write(" ⚠ couldn't reach npm or resolve the published version — bridge left unchanged; NO update or restart was applied.\n"),null;process.stderr.write(`\n ◆ preparing bridge v${o} in the background; active sessions keep running…\n`);try{return i(o),o}catch(e){return process.stderr.write(` ⚠ couldn't stage bridge v${o}: ${e?.message||e}\n`),null}}export function applyStagedServiceUpdate(e,t,{install:r=installService,active:n=isServiceInstalled}={}){return!(!n(e)||!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(t||"")))&&(process.stderr.write(`\n ◆ updating the background service to v${t} and restarting…\n`),!1!==r(e,[],{version:t,staleProof:!0}))}export function updateService(e,{exec:t=n,install:r=installService,active:i=isServiceInstalled}={}){if(!i(e))return process.stderr.write(" ⚠ no background service installed to update — install one first.\n"),!1;const o=b(t);return o?(process.stderr.write(`\n ◆ updating the background service to v${o} and restarting…\n`),!1!==r(e,[],{version:o,staleProof:!0})):(process.stderr.write(" ⚠ couldn't reach npm or resolve the published version — bridge left unchanged; NO update or restart was applied.\n"),!1)}export async function updateAndConfirmService(i,{platform:a=process.platform,exec:l=n,update:c=updateService,install:d=installService,active:u=isServiceInstalled,snapshot:f=serviceRuntimeSnapshot,readReady:$=()=>o(),readStatus:h=()=>{try{return JSON.parse(t.readFileSync(r.join(e.homedir(),".thinkpool-pair","update-status.json"),"utf8"))}catch{return null}},now:m=Date.now,sleep:g=e=>new Promise(t=>setTimeout(t,e)),timeoutMs:v=2e5,pollMs:y=250,stderr:k=process.stderr}={}){const b=f(i,{platform:a,exec:l}),w=m();let S=null;if(!1===c(i,{exec:(...e)=>{const t=l(...e),r=String(t).trim();return/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(r)&&(S=r),t},install:d,active:u})||!S)return k.write(" ⚠ update was not confirmed: staging the published runtime failed; the existing service was left unchanged.\n"),!1;const _=()=>{const e=f(i,{platform:a,exec:l}),t=!!b&&b.version!==S,r=!!b?.pid&&!!e?.pid&&b.pid!==e.pid,n=!!i||"win32"===a||s($(),e,S,{notBefore:w});return e?.version===S&&(t||r)&&n?(k.write(` ✓ bridge restart confirmed — running v${S}.\n`),{ok:!0,live:e}):{ok:!1,live:e}},x=({live:e}={})=>{e?.version&&e.version!==S?k.write(` ⚠ update v${S} was not confirmed: live service is still v${e.version}.\n`):e?.version===S?k.write(` ⚠ update v${S} was not confirmed: the service process did not restart.\n`):k.write(` ⚠ update v${S} was not confirmed: the service manager cannot prove the new runtime is running.\n`)};if("win32"===a)return!0;const A=m()+Math.max(0,Number(v)||0),P=p(i);let j=null;for(;;){const e=h();if(e&&"object"==typeof e&&e.target===P){if(!1===e.ok&&e.version===S)return k.write(` ⚠ update v${S} was not confirmed: ${e.error||"the launchd handoff failed"}.\n`),!1;if(!0===e.ok&&e.version===S&&(j=_(),j.ok))return!0}if(m()>=A)return j?x(j):k.write(` ⚠ update v${S} timed out waiting for launchd confirmation; the service may have rolled back.\n`),!1;await g(Math.max(1,Number(y)||1))}}export function serviceActive(e,{platform:t=process.platform,exec:r=n}={}){const i=t,o=p(e);try{return"darwin"===i?darwinServiceRunning(r(`launchctl print gui/$(id -u)/${o}`,{encoding:"utf8",stdio:["ignore","pipe","ignore"]})):"linux"===i&&(r(`systemctl --user is-active --quiet ${o}.service`),!0)}catch{return!1}}export function isServiceInstalled(e){if("win32"===process.platform)try{return t.existsSync(buildArtifact("win32",{room:e}).file)}catch{return!1}return serviceActive(e)}export function uninstallService(e){const r=process.platform;let i;try{i=buildArtifact(r,{room:e})}catch(e){return void process.stderr.write(` ⚠ ${e.message}\n`)}if("darwin"===r)try{n(`launchctl bootout gui/$(id -u)/${p(e)} 2>/dev/null`)}catch{}if("linux"===r)try{n(`systemctl --user disable --now ${p(e)}.service 2>/dev/null`)}catch{}try{t.rmSync(i.file,{force:!0}),process.stderr.write(` ◆ removed ${i.file}\n`)}catch(e){process.stderr.write(` ⚠ ${e.message}\n`)}}