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.
- package/LICENSE +18 -0
- package/README.md +20 -0
- package/abort-turn-barrier.mjs +1 -23
- package/account.mjs +1 -1437
- package/acp-client.mjs +1 -140
- package/agent-detect.mjs +1 -28
- package/agent-notify.mjs +1 -142
- package/agent-visibility.mjs +1 -67
- package/auth-store.mjs +1 -120
- package/bridge.mjs +1 -6266
- package/byok-detect.mjs +1 -126
- package/claude-command-catalog.mjs +1 -91
- package/claude-session.mjs +1 -1519
- package/code-event-contract.mjs +1 -118
- package/codex-app-server.mjs +1 -340
- package/codex-commands.mjs +1 -83
- package/codex-event-mapper.mjs +1 -232
- package/codex-images.mjs +1 -69
- package/codex-mcp-http.mjs +1 -131
- package/codex-session.mjs +1 -1295
- package/command-catalog.mjs +1 -118
- package/command-guidance.mjs +1 -8
- package/context-contract.mjs +1 -95
- package/context-windows.mjs +1 -107
- package/cross-terminal.mjs +1 -789
- package/cumulative-event-relay.mjs +1 -53
- package/design-edit.mjs +1 -424
- package/design-source-contract.mjs +1 -4
- package/direct-pair-room.mjs +1 -57
- package/dispatch-lease.mjs +1 -37
- package/dispatch-permission-cleanup.mjs +1 -86
- package/edit-diff.mjs +1 -136
- package/error-recovery.mjs +1 -50
- package/event-bounds.mjs +1 -121
- package/event-delivery-queue.mjs +1 -60
- package/event-id.mjs +1 -549
- package/evidence-citations.mjs +1 -50
- package/evidence-compact.mjs +1 -11
- package/flow-assembly.mjs +1 -196
- package/flow-budget.mjs +1 -84
- package/flow-conductor.mjs +1 -259
- package/flow-context-store.mjs +1 -387
- package/flow-host-revert.mjs +1 -42
- package/flow-models.mjs +1 -139
- package/flow-preview.mjs +1 -148
- package/flow-receipt.mjs +1 -122
- package/flow-redispatch.mjs +1 -72
- package/flow-review-gate.mjs +1 -402
- package/flow-review-reflect.mjs +1 -115
- package/flow-review.mjs +1 -152
- package/flow-scope-evidence.mjs +1 -117
- package/flow-skill-registry.mjs +1 -140
- package/flow-task-graph.mjs +1 -562
- package/flow-worktree.mjs +1 -71
- package/git-diff-report.mjs +1 -121
- package/hermes-delegation-guard.mjs +1 -14
- package/hermes-event-mapper.mjs +1 -194
- package/hermes-isolation.mjs +1 -61
- package/hermes-model-cache.mjs +1 -54
- package/hermes-policy.mjs +1 -92
- package/hermes-probe.mjs +1 -57
- package/hermes-session.mjs +1 -683
- package/hermes-setup.mjs +1 -167
- package/host-memory.mjs +1 -116
- package/interrupted-resume.mjs +1 -95
- package/keep-awake.mjs +1 -148
- package/key-shape.mjs +1 -49
- package/lane-continuation.mjs +1 -83
- package/lane-lifecycle.mjs +1 -189
- package/lane-worktree.mjs +1 -77
- package/launcher.mjs +1 -354
- package/mcp-flight-recorder.mjs +1 -79
- package/mockup-delivery.mjs +1 -57
- package/model-prices.mjs +1 -113
- package/package.json +13 -4
- package/pair-bus.mjs +1 -98
- package/pair-control-authority.mjs +1 -89
- package/past-work-search.mjs +1 -105
- package/plan-meters.mjs +1 -144
- package/presence.mjs +1 -191
- package/privacy-report.mjs +1 -108
- package/provider-resilience.mjs +1 -356
- package/provider.mjs +1 -133
- package/providers.mjs +1 -491
- package/publish-guard.mjs +2 -0
- package/publish-manifest.json +129 -0
- package/question-response.mjs +1 -58
- package/reap-terminal.mjs +1 -68
- package/recap.mjs +1 -297
- package/replay-transport.mjs +1 -64
- package/repo-search.mjs +1 -190
- package/review-check.mjs +1 -182
- package/runtime-contract.mjs +1 -93
- package/runtime-registry.mjs +1 -64
- package/runtime-session.mjs +1 -20
- package/scheduled-run-admission.mjs +1 -364
- package/scheduled-runs.mjs +1 -268
- package/sdk-admission.mjs +1 -9
- package/sdk-smoke.mjs +1 -61
- package/serve-consent.mjs +1 -118
- package/serve-dir.mjs +1 -40
- package/service.mjs +1 -877
- package/session-store.mjs +1 -484
- package/side-lane.mjs +1 -63
- package/supabase-key.mjs +1 -176
- package/supervisor-ready.mjs +1 -57
- package/switch-provider.mjs +1 -84
- package/terminal-name.mjs +1 -359
- package/terminal-row-reconcile.mjs +1 -70
- package/thinkpool-prompt-contracts.mjs +1 -85
- package/thinkpool-room-prompt.mjs +1 -187
- package/transcript-sanitize.mjs +1 -332
- package/turn-stall.mjs +1 -61
- package/update-gate.mjs +1 -53
- package/viewport.mjs +1 -810
- 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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,"&").replace(/</g,"<").replace(/>/g,">"),$=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`)}}
|