thinkpool-pair 0.7.238 → 0.7.240
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bridge.mjs +3 -1
- package/codex-mcp-http.mjs +53 -12
- package/hermes-isolation.mjs +3 -1
- package/launcher.mjs +8 -3
- package/package.json +1 -1
- package/service.mjs +153 -100
package/bridge.mjs
CHANGED
|
@@ -50,6 +50,7 @@ import { validateProviderSwitch, providerSwitchPlan, BUILTIN_PROVIDER } from './
|
|
|
50
50
|
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
|
|
51
51
|
import { z } from 'zod'
|
|
52
52
|
import { readCodexDefaultModel, readCodexModels, codexConfigForMode, codexThreadCanResume } from './codex-session.mjs'
|
|
53
|
+
import { withMcpSessionFactory } from './codex-mcp-http.mjs'
|
|
53
54
|
import { startStructuredSession } from './runtime-session.mjs'
|
|
54
55
|
import { defaultStructuredMode, shouldDeferStructuredRuntime, structuredRuntimeForCommand, structuredRuntimeMetadata, structuredRuntimeSupportsMode } from './runtime-registry.mjs'
|
|
55
56
|
import { probeHermesRuntime } from './hermes-probe.mjs'
|
|
@@ -2052,7 +2053,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2052
2053
|
// own `id` (excluded) + `entry` (per-turn call counter, reset by the code-turn
|
|
2053
2054
|
// handler). Auto-allowed by the PreToolUse gate (mcp__thinkpool__ prefix).
|
|
2054
2055
|
// Spec: docs/specs/2026-06-28-cross-terminal-awareness.md
|
|
2055
|
-
const
|
|
2056
|
+
const createPeekServer = () => createSdkMcpServer({
|
|
2056
2057
|
name: 'thinkpool',
|
|
2057
2058
|
version: '1.0.0',
|
|
2058
2059
|
tools: [
|
|
@@ -2420,6 +2421,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2420
2421
|
)] : []),
|
|
2421
2422
|
],
|
|
2422
2423
|
})
|
|
2424
|
+
const peekServer = withMcpSessionFactory(createPeekServer)
|
|
2423
2425
|
// S5 (slice 1b) — the review-lane write-block, wired into the live PreToolUse path.
|
|
2424
2426
|
// Null for builder lanes (no reviewSliceRoots) → their toolset is UNCHANGED. For a review
|
|
2425
2427
|
// lane, deny a mutating tool call if it targets ANY reviewed slice's worktree, allowing the
|
package/codex-mcp-http.mjs
CHANGED
|
@@ -6,18 +6,47 @@ import http from 'node:http'
|
|
|
6
6
|
import { randomUUID } from 'node:crypto'
|
|
7
7
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
|
8
8
|
|
|
9
|
+
export function withMcpSessionFactory(createSessionServer) {
|
|
10
|
+
if (typeof createSessionServer !== 'function') throw new TypeError('MCP session factory must be a function')
|
|
11
|
+
return Object.assign(createSessionServer(), { createSessionServer })
|
|
12
|
+
}
|
|
13
|
+
|
|
9
14
|
export async function startCodexMcpHttp({ sdkServer, host = '127.0.0.1' } = {}) {
|
|
10
|
-
const
|
|
11
|
-
|
|
15
|
+
const createSessionServer = sdkServer?.createSessionServer
|
|
16
|
+
const initialInstance = sdkServer?.instance
|
|
17
|
+
if (!initialInstance?.connect || typeof createSessionServer !== 'function') {
|
|
18
|
+
throw new TypeError('Codex MCP requires an SDK MCP server and createSessionServer factory')
|
|
19
|
+
}
|
|
12
20
|
|
|
13
21
|
const secretPath = `/mcp/${randomUUID()}`
|
|
14
|
-
// Stateful sessions
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
// Stateful sessions are required: this SDK's stateless transport rejects the
|
|
23
|
+
// `notifications/initialized` POST sent by every MCP client. A transport is
|
|
24
|
+
// single-initialize, though, while Codex App Server -> exec fallback and Hermes
|
|
25
|
+
// crash recovery both start a new client against the same lane endpoint.
|
|
26
|
+
// Keep exactly one active child session per lane. A new initialize atomically
|
|
27
|
+
// retires the old server+transport before receiving its own isolated instance.
|
|
28
|
+
let active = null
|
|
29
|
+
|
|
30
|
+
const retireActive = async () => {
|
|
31
|
+
const current = active
|
|
32
|
+
active = null
|
|
33
|
+
if (!current) return
|
|
34
|
+
await Promise.allSettled([
|
|
35
|
+
current.instance.close(),
|
|
36
|
+
current.transport.close(),
|
|
37
|
+
])
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const beginSession = async () => {
|
|
41
|
+
await retireActive()
|
|
42
|
+
const next = createSessionServer()
|
|
43
|
+
const instance = next?.instance
|
|
44
|
+
if (!instance?.connect) throw new TypeError('Codex MCP session factory did not return an SDK MCP server instance')
|
|
45
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() })
|
|
46
|
+
await instance.connect(transport)
|
|
47
|
+
active = { instance, transport }
|
|
48
|
+
return active
|
|
49
|
+
}
|
|
21
50
|
|
|
22
51
|
const server = http.createServer(async (req, res) => {
|
|
23
52
|
if (req.url !== secretPath) {
|
|
@@ -25,7 +54,19 @@ export async function startCodexMcpHttp({ sdkServer, host = '127.0.0.1' } = {})
|
|
|
25
54
|
return
|
|
26
55
|
}
|
|
27
56
|
try {
|
|
28
|
-
|
|
57
|
+
const sessionId = req.headers['mcp-session-id']
|
|
58
|
+
let target = active
|
|
59
|
+
if (!sessionId) {
|
|
60
|
+
if (req.method !== 'POST') {
|
|
61
|
+
res.writeHead(400).end('missing session')
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
target = await beginSession()
|
|
65
|
+
} else if (!target?.transport?.sessionId || sessionId !== target.transport.sessionId) {
|
|
66
|
+
res.writeHead(404).end('session not found')
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
await target.transport.handleRequest(req, res)
|
|
29
70
|
} catch {
|
|
30
71
|
if (!res.headersSent) res.writeHead(500)
|
|
31
72
|
res.end()
|
|
@@ -38,7 +79,7 @@ export async function startCodexMcpHttp({ sdkServer, host = '127.0.0.1' } = {})
|
|
|
38
79
|
server.listen(0, host, resolve)
|
|
39
80
|
})
|
|
40
81
|
} catch (error) {
|
|
41
|
-
await
|
|
82
|
+
await retireActive()
|
|
42
83
|
throw error
|
|
43
84
|
}
|
|
44
85
|
const address = server.address()
|
|
@@ -51,7 +92,7 @@ export async function startCodexMcpHttp({ sdkServer, host = '127.0.0.1' } = {})
|
|
|
51
92
|
if (closed) return
|
|
52
93
|
closed = true
|
|
53
94
|
await Promise.allSettled([
|
|
54
|
-
|
|
95
|
+
retireActive(),
|
|
55
96
|
new Promise((resolve) => server.close(resolve)),
|
|
56
97
|
])
|
|
57
98
|
},
|
package/hermes-isolation.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import path from 'node:path'
|
|
|
4
4
|
import { randomUUID } from 'node:crypto'
|
|
5
5
|
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
|
|
6
6
|
import { startHermesSession } from './hermes-session.mjs'
|
|
7
|
+
import { withMcpSessionFactory } from './codex-mcp-http.mjs'
|
|
7
8
|
|
|
8
9
|
const waitFor = (predicate, timeoutMs = 25_000) => new Promise((resolve, reject) => {
|
|
9
10
|
const started = Date.now()
|
|
@@ -36,10 +37,11 @@ export async function verifyHermesMcpIsolation({ rounds = 2, sessionFactory = st
|
|
|
36
37
|
for (let i = 0; i < 2; i++) {
|
|
37
38
|
const cwd = path.join(root, i === 0 ? 'alpha' : 'beta')
|
|
38
39
|
fs.mkdirSync(cwd)
|
|
39
|
-
const
|
|
40
|
+
const createMarkerServer = () => createSdkMcpServer({
|
|
40
41
|
name: 'thinkpool', version: '1.0.0',
|
|
41
42
|
tools: [tool(markers[i], 'Synthetic ThinkPool lane-isolation marker.', {}, async () => ({ content: [{ type: 'text', text: markers[i] }] }))],
|
|
42
43
|
})
|
|
44
|
+
const sdkServer = withMcpSessionFactory(createMarkerServer)
|
|
43
45
|
sessions.push(sessionFactory({ cwd, lazy: true, mcpServers: { thinkpool: sdkServer }, onEvent: (event) => events[i].push(event) }))
|
|
44
46
|
}
|
|
45
47
|
sessions.forEach((session) => session.sendTurn('/tools'))
|
package/launcher.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import path from 'node:path'
|
|
|
16
16
|
import { execSync } from 'node:child_process'
|
|
17
17
|
import { detectProvider, fetchModels, ANTHROPIC_COMPATIBLE, gatewayHint } from './byok-detect.mjs'
|
|
18
18
|
import { probeHermesRuntime } from './hermes-probe.mjs'
|
|
19
|
+
import { darwinServiceRunning } from './service.mjs'
|
|
19
20
|
|
|
20
21
|
const HOME = os.homedir()
|
|
21
22
|
const CFG_DIR = path.join(HOME, '.thinkpool-pair')
|
|
@@ -57,13 +58,17 @@ function serviceFile(room) {
|
|
|
57
58
|
return path.join(startup, `thinkpool-pair-${room || 'account'}.cmd`)
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
// Ground truth for the header: is the service actually
|
|
61
|
+
// Ground truth for the header: is the service actually RUNNING, not just registered?
|
|
61
62
|
// `launchctl load` can silently no-op, leaving a plist that never loads — file-existence
|
|
62
|
-
// would report "installed" while the bridge is off (the 2026-06-26 bug).
|
|
63
|
+
// would report "installed" while the bridge is off (the 2026-06-26 bug). A registered
|
|
64
|
+
// job can also be inactive with no pid, so inspect launchd's live state, not print's exit.
|
|
63
65
|
function serviceLoaded(room) {
|
|
64
66
|
const label = room ? `io.thinkpool.pair.${room.toLowerCase()}` : 'io.thinkpool.pair.account'
|
|
65
67
|
try {
|
|
66
|
-
if (process.platform === 'darwin') {
|
|
68
|
+
if (process.platform === 'darwin') {
|
|
69
|
+
const out = execSync(`launchctl print gui/$(id -u)/${label}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
70
|
+
return darwinServiceRunning(out)
|
|
71
|
+
}
|
|
67
72
|
if (process.platform === 'linux') { execSync(`systemctl --user is-active --quiet ${label}.service`); return true }
|
|
68
73
|
return fs.existsSync(serviceFile(room)) // Windows: no daemon, file presence is the only proxy
|
|
69
74
|
} catch { return false }
|
package/package.json
CHANGED
package/service.mjs
CHANGED
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
• account — `install-service` (no room) → supervises the ACCOUNT
|
|
14
14
|
supervisor (`npx thinkpool-pair`, bare), which serves
|
|
15
15
|
every session you're in. Pass room=null for this.
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
Pinned services launch an immutable local runtime directly. The optional
|
|
17
|
+
auto-update tier still resolves `thinkpool-pair@latest`; its self-restart
|
|
18
|
+
lives in bridge.mjs / account.mjs.
|
|
18
19
|
───────────────────────────────────────────────────────────── */
|
|
19
20
|
|
|
20
21
|
import os from 'node:os'
|
|
@@ -37,10 +38,16 @@ const label = (room) => {
|
|
|
37
38
|
const slug = (room) => room || 'account'
|
|
38
39
|
const xml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
39
40
|
const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
export function sanitizeServicePath(value, platform = process.platform) {
|
|
42
|
+
const sep = platform === 'win32' ? ';' : ':'
|
|
43
|
+
return String(value || '').split(sep).filter((part) => part && !part.includes('/.npm/_npx/')).join(sep)
|
|
44
|
+
}
|
|
45
|
+
export function darwinServiceRunning(output) {
|
|
46
|
+
const text = String(output || '')
|
|
47
|
+
return /(^|\n)[ \t]*state = running[ \t]*($|\n)/.test(text) && /(^|\n)[ \t]*pid = \d+[ \t]*($|\n)/.test(text)
|
|
48
|
+
}
|
|
49
|
+
// Legacy auto-update services still use npx. Wipe its disposable execution project
|
|
50
|
+
// before resolving the package so an @latest restart cannot re-serve a stale cache.
|
|
44
51
|
// `home` is the platform-correct, runtime-expanded home token ("$HOME" on darwin under
|
|
45
52
|
// /bin/bash; "$$HOME" inside a systemd unit so systemd emits a literal $HOME to bash).
|
|
46
53
|
// npx caches the temporary execution project as a unit. Removing only the package can
|
|
@@ -75,15 +82,39 @@ function npmPath(platform = process.platform) {
|
|
|
75
82
|
// Persistent services never execute through npx. Provision an immutable, versioned
|
|
76
83
|
// runtime from the tarball URL (which bypasses stale npm packument metadata), then point
|
|
77
84
|
// launchd/systemd directly at its bridge.mjs with the current Node binary.
|
|
78
|
-
|
|
85
|
+
function runtimeVersion(entry) {
|
|
86
|
+
try {
|
|
87
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(path.dirname(entry), 'package.json'), 'utf8'))
|
|
88
|
+
return typeof pkg?.version === 'string' ? pkg.version : null
|
|
89
|
+
} catch { return null }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function provisionRuntime(version, { exec = execSync, root = path.join(os.homedir(), '.thinkpool-pair', 'runtimes') } = {}) {
|
|
79
93
|
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(version))) throw new Error(`invalid runtime version: ${version}`)
|
|
80
|
-
const dir = path.join(
|
|
94
|
+
const dir = path.join(root, String(version))
|
|
81
95
|
const entry = path.join(dir, 'node_modules', 'thinkpool-pair', 'bridge.mjs')
|
|
82
|
-
if (fs.existsSync(entry)) return entry
|
|
83
|
-
|
|
96
|
+
if (fs.existsSync(entry) && runtimeVersion(entry) === String(version)) return entry
|
|
97
|
+
|
|
98
|
+
// Install beside the target and only swap it into place after BOTH the entrypoint
|
|
99
|
+
// and package version prove the payload. A killed/poisoned npm install must never
|
|
100
|
+
// leave `runtimes/0.7.237` containing 0.7.229 and then pass forever on file existence.
|
|
101
|
+
const tmp = `${dir}.install-${process.pid}-${Date.now()}`
|
|
102
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
103
|
+
fs.mkdirSync(tmp, { recursive: true })
|
|
84
104
|
const tarball = `https://registry.npmjs.org/thinkpool-pair/-/thinkpool-pair-${version}.tgz`
|
|
85
|
-
|
|
86
|
-
|
|
105
|
+
const tmpEntry = path.join(tmp, 'node_modules', 'thinkpool-pair', 'bridge.mjs')
|
|
106
|
+
try {
|
|
107
|
+
exec(`${shq(npmPath())} install --prefix ${shq(tmp)} ${shq(tarball)} --prefer-online --no-audit --no-fund`, { stdio: 'inherit', shell: '/bin/bash' })
|
|
108
|
+
const actual = runtimeVersion(tmpEntry)
|
|
109
|
+
if (!fs.existsSync(tmpEntry) || actual !== String(version)) {
|
|
110
|
+
throw new Error(`runtime verification failed: requested ${version}, installed ${actual || 'no valid package'}`)
|
|
111
|
+
}
|
|
112
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
113
|
+
fs.renameSync(tmp, dir)
|
|
114
|
+
} catch (e) {
|
|
115
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
116
|
+
throw e
|
|
117
|
+
}
|
|
87
118
|
return entry
|
|
88
119
|
}
|
|
89
120
|
|
|
@@ -91,10 +122,14 @@ export function provisionRuntime(version, { exec = execSync } = {}) {
|
|
|
91
122
|
// room falsy → ACCOUNT service (bare `thinkpool-pair`, auto-serves all sessions).
|
|
92
123
|
export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate = false, version = VERSION, staleProof = false, runtimeEntry = null } = {}) {
|
|
93
124
|
const npx = npxPath(platform)
|
|
94
|
-
|
|
125
|
+
// Account mode discovers each room's own bound directory, so its supervisor must use
|
|
126
|
+
// a stable home cwd. Persisting the caller's managed worktree here makes the service
|
|
127
|
+
// unstartable as soon as that lane is closed and its worktree is removed.
|
|
128
|
+
const cwd = room ? process.cwd() : os.homedir()
|
|
95
129
|
const id = slug(room)
|
|
96
130
|
const logDir = path.join(os.homedir(), '.thinkpool-pair')
|
|
97
131
|
const log = path.join(logDir, `${id}.log`)
|
|
132
|
+
const servicePath = sanitizeServicePath(process.env.PATH || '', platform)
|
|
98
133
|
const tail = cmdArgs.length ? ['--', ...cmdArgs] : []
|
|
99
134
|
const desc = room ? `bridge (${room})` : 'account bridge'
|
|
100
135
|
// OS-supervised tiers (launchd KeepAlive / systemd Restart) don't need --supervise.
|
|
@@ -116,8 +151,8 @@ export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate
|
|
|
116
151
|
const autoUpdateEnv = autoUpdate ? { THINKPOOL_PAIR_AUTOUPDATE: '1' } : {}
|
|
117
152
|
|
|
118
153
|
if (platform === 'darwin') {
|
|
119
|
-
// launchd KeepAlive supervises.
|
|
120
|
-
//
|
|
154
|
+
// launchd KeepAlive supervises. Stable runtimes execute directly; the legacy npx
|
|
155
|
+
// shape keeps its cache-wiping wrapper for auto-update compatibility.
|
|
121
156
|
const args = runtimeEntry
|
|
122
157
|
? [process.execPath, runtimeEntry, ...(room ? [room] : []), ...tail]
|
|
123
158
|
: staleProof
|
|
@@ -140,39 +175,12 @@ export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate
|
|
|
140
175
|
<key>WorkingDirectory</key><string>${xml(cwd)}</string>
|
|
141
176
|
<key>StandardOutPath</key><string>${xml(log)}</string>
|
|
142
177
|
<key>StandardErrorPath</key><string>${xml(log)}</string>
|
|
143
|
-
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(
|
|
178
|
+
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(servicePath)}</string>${autoUpdate ? '<key>THINKPOOL_PAIR_AUTOUPDATE</key><string>1</string>' : ''}</dict>
|
|
144
179
|
</dict></plist>\n`
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
// actually leave the domain (bootout is async; bootstrapping while teardown is in
|
|
150
|
-
// flight throws "Bootstrap failed: 5: Input/output error") → enable (clears a stale
|
|
151
|
-
// `disabled` override from a prior bootout / the account-storm cleanup) → bootstrap,
|
|
152
|
-
// retried on the EIO race → a final `print` so the command's exit code reports the
|
|
153
|
-
// real loaded state (installService warns if it ends not-loaded).
|
|
154
|
-
const lbl = label(room), dom = 'gui/$(id -u)'
|
|
155
|
-
const pr = `launchctl print ${dom}/${lbl} >/dev/null 2>&1`
|
|
156
|
-
const post = [
|
|
157
|
-
// Evict any existing instance so the REWRITTEN plist (new pinned version) is what
|
|
158
|
-
// loads. bootout is async + occasionally sticky: if the old supervised process
|
|
159
|
-
// won't leave the domain, the follow-up bootstrap throws "Bootstrap failed: 5:
|
|
160
|
-
// Input/output error" and we'd SILENTLY keep running the OLD pinned version while
|
|
161
|
-
// reporting success — the 2026-06-30 "Restart & update" stuck-on-0.7.96 bug. So:
|
|
162
|
-
// bootout, wait for it to actually leave, and if it WON'T, SIGKILL the wedged pid
|
|
163
|
-
// then bootout again (KeepAlive can't relaunch a job that's booting out).
|
|
164
|
-
`launchctl bootout ${dom}/${lbl} 2>/dev/null || true`,
|
|
165
|
-
`for i in $(seq 1 25); do ${pr} || break; sleep 0.2; done`,
|
|
166
|
-
`if ${pr}; then p=$(launchctl print ${dom}/${lbl} 2>/dev/null | sed -n 's/.*[^a-z]pid = \\([0-9][0-9]*\\).*/\\1/p' | head -1); [ -n "$p" ] && kill -9 "$p" 2>/dev/null || true; launchctl bootout ${dom}/${lbl} 2>/dev/null || true; for i in $(seq 1 25); do ${pr} || break; sleep 0.2; done; fi`,
|
|
167
|
-
// Clear a stale `disabled` override (from a prior bootout / account-storm cleanup),
|
|
168
|
-
// then load the rewritten plist from disk, retried on the EIO teardown race.
|
|
169
|
-
`launchctl enable ${dom}/${lbl} 2>/dev/null || true`,
|
|
170
|
-
`ok=0; for i in $(seq 1 25); do launchctl bootstrap ${dom} "${file}" 2>/dev/null && { ok=1; break; }; sleep 0.3; done`,
|
|
171
|
-
// Be HONEST about the outcome: if it never loaded, say so + give the exact manual
|
|
172
|
-
// recovery — never let a failed reload masquerade as a successful update.
|
|
173
|
-
`if [ "$ok" != 1 ] && ! ${pr}; then echo " ⚠ thinkpool-pair: launchd reload FAILED — run by hand: launchctl bootout ${dom}/${lbl} ; launchctl bootstrap ${dom} \\"${file}\\"" >&2; fi`,
|
|
174
|
-
].join('; ')
|
|
175
|
-
return { file, content, logDir, post: [post], note: 'Loaded as a LaunchAgent — starts at login, restarts on crash.' }
|
|
180
|
+
// The destructive reload is intentionally NOT represented as an inline `post`
|
|
181
|
+
// command. installService stages this plist and hands the transaction to an
|
|
182
|
+
// independent one-shot launchd job (buildDarwinReloadHandoff below).
|
|
183
|
+
return { file, content, logDir, post: [], expectedRuntime: runtimeEntry || verSpec, note: 'Loaded as a LaunchAgent — starts at login, restarts on crash.' }
|
|
176
184
|
}
|
|
177
185
|
|
|
178
186
|
if (platform === 'linux') {
|
|
@@ -202,7 +210,7 @@ RestartSec=2
|
|
|
202
210
|
# still restarted. Matches the bridge's exit-code contract (stop()=0, restart=non-zero).
|
|
203
211
|
RestartPreventExitStatus=0
|
|
204
212
|
WorkingDirectory=${cwd}
|
|
205
|
-
Environment=PATH=${
|
|
213
|
+
Environment=PATH=${servicePath}
|
|
206
214
|
${autoUpdate ? 'Environment=THINKPOOL_PAIR_AUTOUPDATE=1\n' : ''}StandardOutput=append:${log}
|
|
207
215
|
StandardError=append:${log}
|
|
208
216
|
|
|
@@ -231,9 +239,6 @@ WantedBy=default.target
|
|
|
231
239
|
throw new Error(`unsupported platform: ${platform}`)
|
|
232
240
|
}
|
|
233
241
|
|
|
234
|
-
// Blocking sleep (installService runs synchronously from the interactive menu; the
|
|
235
|
-
// user is watching, so we WANT to wait + report the real outcome, not fire-and-forget).
|
|
236
|
-
const sleepMs = (ms) => { try { execSync(`sleep ${(ms / 1000).toFixed(2)}`) } catch { /* noop */ } }
|
|
237
242
|
// Parse both legacy npx commands (`thinkpool-pair@<ver>`) and stable runtime commands
|
|
238
243
|
// (`.../runtimes/<ver>/...`).
|
|
239
244
|
export function parseRunningPairVersions(out) {
|
|
@@ -241,35 +246,84 @@ export function parseRunningPairVersions(out) {
|
|
|
241
246
|
return new Set(versions.map((s) => s.match(/\d+\.\d+\.\d+/)?.[0]).filter(Boolean))
|
|
242
247
|
}
|
|
243
248
|
|
|
244
|
-
//
|
|
245
|
-
|
|
249
|
+
// A macOS update can be invoked FROM a ThinkPool Code room hosted by the very account
|
|
250
|
+
// service being replaced. Running `bootout; bootstrap` in that caller is unsafe: bootout
|
|
251
|
+
// tears down the room process tree and can kill the updater before bootstrap. Hand the
|
|
252
|
+
// whole destructive transaction to a separate one-shot launchd job first. It survives
|
|
253
|
+
// the target service's teardown, reloads it, verifies the exact expected runtime in
|
|
254
|
+
// launchd's live definition, and records an atomic result for diagnosis.
|
|
255
|
+
export function buildDarwinReloadHandoff({ targetLabel, targetFile, stagedFile, expectedRuntime, version, nonce = `${process.pid}-${Date.now()}` }) {
|
|
256
|
+
if (!/^io\.thinkpool\.pair\.[A-Za-z0-9_.-]+$/.test(String(targetLabel))) throw new Error(`invalid service label: ${targetLabel}`)
|
|
257
|
+
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(version))) throw new Error(`invalid runtime version: ${version}`)
|
|
258
|
+
const safeNonce = String(nonce).replace(/[^A-Za-z0-9-]/g, '') || String(process.pid)
|
|
259
|
+
const helperLabel = `io.thinkpool.pair.update.${safeNonce}`
|
|
260
|
+
const helperFile = path.join(os.homedir(), 'Library', 'LaunchAgents', `${helperLabel}.plist`)
|
|
261
|
+
const backupFile = `${stagedFile}.previous`
|
|
262
|
+
const statusFile = path.join(os.homedir(), '.thinkpool-pair', 'update-status.json')
|
|
263
|
+
const logFile = path.join(os.homedir(), '.thinkpool-pair', 'update.log')
|
|
264
|
+
const script = [
|
|
265
|
+
'set -u',
|
|
266
|
+
'dom="gui/$(id -u)"',
|
|
267
|
+
`target=${shq(targetLabel)}`,
|
|
268
|
+
`helper=${shq(helperLabel)}`,
|
|
269
|
+
`target_file=${shq(targetFile)}`,
|
|
270
|
+
`staged_file=${shq(stagedFile)}`,
|
|
271
|
+
`backup_file=${shq(backupFile)}`,
|
|
272
|
+
`helper_file=${shq(helperFile)}`,
|
|
273
|
+
`expected=${shq(expectedRuntime)}`,
|
|
274
|
+
`status=${shq(statusFile)}`,
|
|
275
|
+
'tmp="$status.tmp.$$"',
|
|
276
|
+
'ok=0',
|
|
277
|
+
'had_previous=0',
|
|
278
|
+
'if [ -f "$target_file" ]; then cp "$target_file" "$backup_file" && had_previous=1; fi',
|
|
279
|
+
'launchctl bootout "$dom/$target" 2>/dev/null || true',
|
|
280
|
+
'for i in $(seq 1 40); do launchctl print "$dom/$target" >/dev/null 2>&1 || break; sleep 0.2; done',
|
|
281
|
+
'cp "$staged_file" "$target_file"',
|
|
282
|
+
'launchctl enable "$dom/$target" 2>/dev/null || true',
|
|
283
|
+
'for i in $(seq 1 30); do launchctl bootstrap "$dom" "$target_file" 2>/dev/null && break; sleep 0.3; done',
|
|
284
|
+
'for i in $(seq 1 60); do if launchctl print "$dom/$target" 2>/dev/null | grep -F -- "$expected" >/dev/null && launchctl print "$dom/$target" 2>/dev/null | grep -F "state = running" >/dev/null; then ok=1; break; fi; sleep 0.5; done',
|
|
285
|
+
`if [ "$ok" = 1 ]; then printf '%s\\n' '${JSON.stringify({ ok: true, version: String(version) })}' > "$tmp"; else rollback=0; if [ "$had_previous" = 1 ]; then launchctl bootout "$dom/$target" 2>/dev/null || true; for i in $(seq 1 40); do launchctl print "$dom/$target" >/dev/null 2>&1 || break; sleep 0.2; done; cp "$backup_file" "$target_file"; launchctl enable "$dom/$target" 2>/dev/null || true; for i in $(seq 1 30); do if launchctl bootstrap "$dom" "$target_file" 2>/dev/null; then rollback=1; break; fi; sleep 0.3; done; fi; if [ "$rollback" = 1 ]; then printf '%s\\n' '${JSON.stringify({ ok: false, version: String(version), error: 'new runtime not confirmed; previous service restored', rolledBack: true })}' > "$tmp"; else printf '%s\\n' '${JSON.stringify({ ok: false, version: String(version), error: 'new runtime not confirmed and rollback failed', rolledBack: false })}' > "$tmp"; fi; fi`,
|
|
286
|
+
'mv "$tmp" "$status"',
|
|
287
|
+
'rm -f "$staged_file" "$backup_file" "$helper_file"',
|
|
288
|
+
'launchctl bootout "$dom/$helper" >/dev/null 2>&1 || true',
|
|
289
|
+
'exit "$((1-ok))"',
|
|
290
|
+
].join('; ')
|
|
291
|
+
const content = `<?xml version="1.0" encoding="UTF-8"?>
|
|
292
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
293
|
+
<plist version="1.0"><dict>
|
|
294
|
+
<key>Label</key><string>${xml(helperLabel)}</string>
|
|
295
|
+
<key>ProgramArguments</key><array><string>/bin/bash</string><string>-lc</string><string>${xml(script)}</string></array>
|
|
296
|
+
<key>RunAtLoad</key><true/>
|
|
297
|
+
<key>StandardOutPath</key><string>${xml(logFile)}</string>
|
|
298
|
+
<key>StandardErrorPath</key><string>${xml(logFile)}</string>
|
|
299
|
+
</dict></plist>\n`
|
|
300
|
+
return { helperLabel, helperFile, backupFile, statusFile, logFile, script, content }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function startDarwinReloadHandoff(a, version) {
|
|
304
|
+
const stagedFile = path.join(a.logDir, `service-update-${process.pid}-${Date.now()}.plist`)
|
|
305
|
+
const h = buildDarwinReloadHandoff({ targetLabel: labelFromFile(a.file), targetFile: a.file, stagedFile, expectedRuntime: a.expectedRuntime, version })
|
|
306
|
+
fs.mkdirSync(path.dirname(h.helperFile), { recursive: true })
|
|
307
|
+
fs.mkdirSync(path.dirname(h.statusFile), { recursive: true })
|
|
308
|
+
fs.rmSync(h.statusFile, { force: true })
|
|
309
|
+
fs.writeFileSync(stagedFile, a.content)
|
|
310
|
+
fs.writeFileSync(h.helperFile, h.content)
|
|
246
311
|
try {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
312
|
+
execSync(`launchctl bootstrap gui/$(id -u) ${shq(h.helperFile)}`, { stdio: 'inherit', shell: '/bin/bash' })
|
|
313
|
+
process.stderr.write(` ◆ update handoff armed outside the bridge process tree — completion is recorded in ${h.statusFile}.\n`)
|
|
314
|
+
return true
|
|
315
|
+
} catch (e) {
|
|
316
|
+
fs.rmSync(stagedFile, { force: true })
|
|
317
|
+
fs.rmSync(h.helperFile, { force: true })
|
|
318
|
+
process.stderr.write(` ⚠ could not arm the launchd update handoff; existing service left running: ${e.message}\n`)
|
|
319
|
+
return false
|
|
320
|
+
}
|
|
250
321
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
// bridges (never the no-arg menu process) + re-bootstrap, then report the truth.
|
|
257
|
-
function verifyDarwinReload(lbl, file, version) {
|
|
258
|
-
const want = String(version)
|
|
259
|
-
const up = () => darwinSvcLoaded(lbl) && runningPairVersions().has(want)
|
|
260
|
-
for (let i = 0; i < 25 && !up(); i++) sleepMs(600) // fresh (cache-wiped) fetch takes a bit
|
|
261
|
-
if (up()) { process.stderr.write(` ◆ verified: bridge is now running v${want}.\n`); return true }
|
|
262
|
-
process.stderr.write(` ⚠ v${want} not up yet — clearing stale bridges and re-bootstrapping…\n`)
|
|
263
|
-
try { execSync(`launchctl bootout gui/$(id -u)/${lbl} 2>/dev/null || true`, { stdio: 'ignore' }) } catch { /* noop */ }
|
|
264
|
-
// Kill orphaned ROOM child bridges (`bridge.mjs <ROOM>`), which bootout may not have
|
|
265
|
-
// reaped. The uppercase room-code guard never matches the no-arg / `setup` menu.
|
|
266
|
-
try { execSync(`pkill -9 -f 'thinkpool-pair/bridge.mjs [A-Z0-9]' 2>/dev/null || true`, { stdio: 'ignore', shell: '/bin/bash' }) } catch { /* noop */ }
|
|
267
|
-
sleepMs(500)
|
|
268
|
-
try { execSync(`launchctl enable gui/$(id -u)/${lbl} 2>/dev/null || true`, { stdio: 'ignore' }) } catch { /* noop */ }
|
|
269
|
-
for (let i = 0; i < 25 && !up(); i++) { try { execSync(`launchctl bootstrap gui/$(id -u) "${file}" 2>/dev/null || true`, { stdio: 'ignore' }) } catch { /* noop */ } sleepMs(600) }
|
|
270
|
-
if (up()) { process.stderr.write(` ◆ recovered: bridge is now running v${want}.\n`); return true }
|
|
271
|
-
process.stderr.write(` ⚠ could NOT confirm v${want} is running. Recover by hand:\n launchctl bootout gui/$(id -u)/${lbl} ; pkill -9 -f thinkpool-pair ; launchctl bootstrap gui/$(id -u) "${file}"\n`)
|
|
272
|
-
return false
|
|
322
|
+
|
|
323
|
+
function labelFromFile(file) {
|
|
324
|
+
const match = path.basename(file).match(/^(io\.thinkpool\.pair\.[A-Za-z0-9_.-]+)\.plist$/)
|
|
325
|
+
if (!match) throw new Error(`cannot derive service label from ${file}`)
|
|
326
|
+
return match[1]
|
|
273
327
|
}
|
|
274
328
|
|
|
275
329
|
// F4 — every io.thinkpool.pair.* label currently LOADED in the launchd/systemd domain,
|
|
@@ -327,12 +381,8 @@ export function sweepExistingServices(keepLabel, platform = process.platform) {
|
|
|
327
381
|
}
|
|
328
382
|
}
|
|
329
383
|
|
|
330
|
-
// staleProof
|
|
331
|
-
//
|
|
332
|
-
// wrapper a fresh `install-service` could pin a stale build just like the update path did.
|
|
333
|
-
// Pinning still holds (we pin a fixed version number) — staleProof only guarantees that
|
|
334
|
-
// pinned version is fetched fresh on every (re)start. buildArtifact keeps staleProof=false
|
|
335
|
-
// so its unit tests assert the pure pinned shape.
|
|
384
|
+
// staleProof remains for the legacy npx artifact shape. Pinned services normally bypass
|
|
385
|
+
// npx entirely through the verified immutable runtime provisioned below.
|
|
336
386
|
export function installService(room, cmdArgs = [], { autoUpdate = false, version = VERSION, staleProof = true } = {}) {
|
|
337
387
|
// Pinned services get a stable local runtime. The legacy @latest opt-in keeps its
|
|
338
388
|
// existing npx behavior until it resolves a concrete version through updateService.
|
|
@@ -343,20 +393,22 @@ export function installService(room, cmdArgs = [], { autoUpdate = false, version
|
|
|
343
393
|
}
|
|
344
394
|
const a = buildArtifact(process.platform, { room, cmdArgs, autoUpdate, version, staleProof, runtimeEntry })
|
|
345
395
|
// F4: clear every OTHER pre-existing pair service first, so only the one we install remains
|
|
346
|
-
// (the "two account services live" storm trigger). Our own label is
|
|
347
|
-
//
|
|
396
|
+
// (the "two account services live" storm trigger). Our own label is handled by the
|
|
397
|
+
// launchd handoff (macOS) or the platform post command, so keep it.
|
|
348
398
|
try { sweepExistingServices(label(room), process.platform) } catch { /* best-effort */ }
|
|
349
399
|
fs.mkdirSync(a.logDir, { recursive: true })
|
|
350
400
|
fs.mkdirSync(path.dirname(a.file), { recursive: true })
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
401
|
+
if (process.platform === 'darwin') {
|
|
402
|
+
process.stderr.write(`\n ◆ staged replacement for ${a.file}\n`)
|
|
403
|
+
if (!startDarwinReloadHandoff(a, version)) return false
|
|
404
|
+
} else {
|
|
405
|
+
fs.writeFileSync(a.file, a.content)
|
|
406
|
+
process.stderr.write(`\n ◆ wrote ${a.file}\n`)
|
|
407
|
+
for (const cmd of a.post) {
|
|
408
|
+
try { execSync(cmd, { stdio: 'inherit' }) }
|
|
409
|
+
catch (e) { process.stderr.write(` ⚠ "${cmd}" failed; service update NOT confirmed: ${e.message}\n`); return false }
|
|
410
|
+
}
|
|
355
411
|
}
|
|
356
|
-
// Darwin: confirm the reload actually put the NEW version live, self-heal if not.
|
|
357
|
-
// Only for a PINNED (re)install where we know the exact version to look for — an
|
|
358
|
-
// autoUpdate (@latest) install has no fixed version number to verify against.
|
|
359
|
-
if (process.platform === 'darwin' && !autoUpdate) verifyDarwinReload(label(room), a.file, version)
|
|
360
412
|
// Be honest about the cadence — it is NOT instant. The supervisor polls npm
|
|
361
413
|
// roughly every 30 min (THINKPOOL_PAIR_UPDATE_INTERVAL) and only restarts once
|
|
362
414
|
// every served session is idle, so a just-published version can take a while.
|
|
@@ -368,15 +420,13 @@ export function installService(room, cmdArgs = [], { autoUpdate = false, version
|
|
|
368
420
|
: `PINNED to ${version} (stable local runtime; restarts do not depend on npx or the network). To update: use "Restart & update the bridge" (or re-run install-service from a newer thinkpool-pair). Pass --auto-update to track @latest instead.`
|
|
369
421
|
const removeArg = room ? ` ${room}` : ''
|
|
370
422
|
const what = room ? `room ${room}` : 'your account (auto-serves every session)'
|
|
371
|
-
process.stderr.write(` ◆ ${what}\n ◆ ${a.note}\n ◆ ${updateNote}\n ◆ logs: ${path.join(a.logDir, `${slug(room)}.log`)}\n ◆ remove with: thinkpool-pair uninstall-service${removeArg}\n\n`)
|
|
423
|
+
process.stderr.write(` ◆ ${what}\n ◆ ${process.platform === 'darwin' ? 'Launchd is completing and verifying the reload independently.' : a.note}\n ◆ ${updateNote}\n ◆ logs: ${path.join(a.logDir, `${slug(room)}.log`)}\n ◆ remove with: thinkpool-pair uninstall-service${removeArg}\n\n`)
|
|
372
424
|
return true
|
|
373
425
|
}
|
|
374
426
|
|
|
375
427
|
// Restart an already-installed boot-persistent service. launchd/systemd relaunch
|
|
376
|
-
// the
|
|
377
|
-
//
|
|
378
|
-
// or — if the service was installed with --auto-update (@latest) — pull a new
|
|
379
|
-
// publish now. A PINNED service (the default) re-runs its same installed version.
|
|
428
|
+
// the stable runtime (or legacy auto-update npx command), so active sessions resume
|
|
429
|
+
// from disk. Use it to recover a wedged bridge or re-read provider.json.
|
|
380
430
|
// room falsy → the ACCOUNT service. Returns true on success.
|
|
381
431
|
export function restartService(room) {
|
|
382
432
|
const plat = process.platform
|
|
@@ -430,7 +480,10 @@ export function serviceActive(room) {
|
|
|
430
480
|
const plat = process.platform
|
|
431
481
|
const id = label(room)
|
|
432
482
|
try {
|
|
433
|
-
if (plat === 'darwin') {
|
|
483
|
+
if (plat === 'darwin') {
|
|
484
|
+
const out = execSync(`launchctl print gui/$(id -u)/${id}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
485
|
+
return darwinServiceRunning(out)
|
|
486
|
+
}
|
|
434
487
|
if (plat === 'linux') { execSync(`systemctl --user is-active --quiet ${id}.service`); return true }
|
|
435
488
|
// Windows has no managed daemon — the Startup-folder script's presence is the only proxy.
|
|
436
489
|
return fs.existsSync(buildArtifact(plat, { room }).file)
|