thinkpool-pair 0.7.51 → 0.7.53
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 +20 -3
- package/launcher.mjs +38 -24
- package/package.json +1 -1
- package/service.mjs +57 -16
package/bridge.mjs
CHANGED
|
@@ -136,12 +136,25 @@ if (argv[0] === 'install-service' || argv[0] === 'uninstall-service') {
|
|
|
136
136
|
const svcRoom = account ? null : arg1.toUpperCase().trim()
|
|
137
137
|
const sd = argv.indexOf('--')
|
|
138
138
|
const svcCmd = sd >= 0 ? argv.slice(sd + 1) : []
|
|
139
|
+
// PINNED to the installed version by default (a bad publish can't auto-roll to the
|
|
140
|
+
// always-on service); --auto-update opts into @latest tracking. (2026-06-22)
|
|
141
|
+
const autoUpdate = argv.includes('--auto-update')
|
|
139
142
|
const svc = await import('./service.mjs')
|
|
140
|
-
if (argv[0] === 'install-service') svc.installService(svcRoom, svcCmd)
|
|
143
|
+
if (argv[0] === 'install-service') svc.installService(svcRoom, svcCmd, { autoUpdate })
|
|
141
144
|
else svc.uninstallService(svcRoom)
|
|
142
145
|
process.exit(0)
|
|
143
146
|
}
|
|
144
147
|
|
|
148
|
+
// Restart an installed boot-persistent service (sessions resume in place):
|
|
149
|
+
// `thinkpool-pair restart-service [<ROOM>]` — no room → the account service.
|
|
150
|
+
if (argv[0] === 'restart-service') {
|
|
151
|
+
const arg1 = argv[1] || ''
|
|
152
|
+
const svcRoom = (!arg1 || arg1.startsWith('-')) ? null : arg1.toUpperCase().trim()
|
|
153
|
+
const svc = await import('./service.mjs')
|
|
154
|
+
svc.restartService(svcRoom)
|
|
155
|
+
process.exit(0)
|
|
156
|
+
}
|
|
157
|
+
|
|
145
158
|
// Account mode (Phase 1, docs/specs/2026-06-16-account-bridge.md): one bridge per
|
|
146
159
|
// ACCOUNT, not per room. `login` links this device; `bind <ROOM> <dir>` maps a
|
|
147
160
|
// session to a working dir on this machine; a bare invocation discovers + serves
|
|
@@ -185,8 +198,12 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
|
|
|
185
198
|
child.on('exit', (code) => process.exit(code == null ? 0 : code))
|
|
186
199
|
}),
|
|
187
200
|
serveAccountForeground: async () => { const { runAccount } = await import('./account.mjs'); await runAccount(SUPABASE_URL, SUPABASE_ANON) },
|
|
188
|
-
|
|
189
|
-
|
|
201
|
+
// install / uninstall / restart are NOT terminal — they do their work, print their
|
|
202
|
+
// notes, and RETURN so the launcher loop drops you back at the menu (the service
|
|
203
|
+
// runs in its own launchd/systemd process; this launcher isn't itself a bridge).
|
|
204
|
+
installService: ({ room = null, agentCmd } = {}) => { svc.installService(room, agentCmd ? [agentCmd] : []) },
|
|
205
|
+
uninstallService: ({ room = null } = {}) => { svc.uninstallService(room) },
|
|
206
|
+
restartService: ({ room = null } = {}) => { svc.restartService(room) },
|
|
190
207
|
login: async () => { const { runLogin } = await import('./account.mjs'); await runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) },
|
|
191
208
|
// Provider config is NOT terminal — it writes provider.json and returns to the
|
|
192
209
|
// menu so you can pick a model, switch back, or do something else. (It used to
|
package/launcher.mjs
CHANGED
|
@@ -123,10 +123,12 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
123
123
|
}
|
|
124
124
|
io.print(`\n Serving every session you host, from ${C.dim(state.cwd)}.\n`)
|
|
125
125
|
const how = await askChoice('run it', [
|
|
126
|
-
{ label: 'As an always-on background service', hint: 'recommended — survives reboot,
|
|
126
|
+
{ label: 'As an always-on background service', hint: 'recommended — survives reboot, restarts on crash' },
|
|
127
127
|
{ label: 'Here in this terminal (foreground)', hint: 'stops when you close it' },
|
|
128
|
+
{ label: '← Back to main menu', hint: '' },
|
|
128
129
|
])
|
|
129
|
-
if (how ===
|
|
130
|
+
if (how === 2) return
|
|
131
|
+
if (how === 0) { await actions.installService({ room: null }); resync() }
|
|
130
132
|
else await actions.serveAccountForeground()
|
|
131
133
|
}
|
|
132
134
|
|
|
@@ -134,15 +136,17 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
134
136
|
const what = await askChoice('install for', [
|
|
135
137
|
{ label: 'All my sessions (account)', hint: state.loggedIn ? '' : 'needs login first' },
|
|
136
138
|
{ label: 'One specific room', hint: 'no login needed' },
|
|
139
|
+
{ label: '← Back to main menu', hint: '' },
|
|
137
140
|
])
|
|
141
|
+
if (what === 2) return
|
|
138
142
|
if (what === 0) {
|
|
139
143
|
if (!state.loggedIn) { io.print('\n ' + C.yellow('Account service needs a login first (pick “Serve all my sessions”).')); return }
|
|
140
|
-
await actions.installService({ room: null }); return
|
|
144
|
+
await actions.installService({ room: null }); resync(); return
|
|
141
145
|
}
|
|
142
|
-
const room = (await io.ask(`\n Room code ${C.cyan('▸')} `)).toUpperCase().trim()
|
|
146
|
+
const room = (await io.ask(`\n Room code ${C.dim('(blank to go back)')} ${C.cyan('▸')} `)).toUpperCase().trim()
|
|
143
147
|
if (!room) { io.print(' ' + C.yellow('no room — back to menu')); return }
|
|
144
148
|
const agent = state.agents[0] || { cmd: 'claude' }
|
|
145
|
-
await actions.installService({ room, agentCmd: agent.cmd })
|
|
149
|
+
await actions.installService({ room, agentCmd: agent.cmd }); resync()
|
|
146
150
|
}
|
|
147
151
|
|
|
148
152
|
const providerMenu = async () => {
|
|
@@ -170,39 +174,49 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
170
174
|
const pick = await askChoice('\n change', [
|
|
171
175
|
{ label: 'LLM provider', hint: `current: ${state.provider}` },
|
|
172
176
|
{ label: 'Account', hint: state.loggedIn ? `linked: ${state.email || 'yes'}` : 'not linked' },
|
|
177
|
+
{ label: 'Restart the background service', hint: state.accountSvc ? 'bounce it now — sessions resume' : 'none installed' },
|
|
173
178
|
{ label: 'Remove the background service', hint: state.accountSvc ? '' : 'none installed' },
|
|
174
179
|
{ label: '← Back to main menu', hint: '' },
|
|
175
|
-
],
|
|
176
|
-
if (pick ===
|
|
180
|
+
], 5) // Enter = Back
|
|
181
|
+
if (pick === 4) return
|
|
177
182
|
if (pick === 0) await providerMenu()
|
|
178
183
|
else if (pick === 1) { await actions.login(); resync(); return }
|
|
184
|
+
else if (pick === 2) {
|
|
185
|
+
if (state.accountSvc) { await actions.restartService({ room: null }); resync() }
|
|
186
|
+
else io.print(' ' + C.yellow('no background service installed'))
|
|
187
|
+
}
|
|
179
188
|
else {
|
|
180
|
-
if (state.accountSvc) await actions.uninstallService({ room: null })
|
|
189
|
+
if (state.accountSvc) { await actions.uninstallService({ room: null }); resync() }
|
|
181
190
|
else io.print(' ' + C.yellow('no background service installed'))
|
|
182
191
|
}
|
|
183
192
|
}
|
|
184
193
|
}
|
|
185
194
|
|
|
186
195
|
// ── menu loop ──
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
196
|
+
// pairRoom re-execs + waits, and account-foreground runs forever, so those choices
|
|
197
|
+
// never return here. Everything else (install / restart / uninstall / settings /
|
|
198
|
+
// provider) does its work, prints its notes, and RETURNS — so the loop always drops
|
|
199
|
+
// you back at the menu. Submenus carry a "← Back" so you can bail at any point. The
|
|
200
|
+
// trailing "press Enter" lands on every returning path.
|
|
191
201
|
for (;;) {
|
|
192
202
|
header()
|
|
193
203
|
io.print(' ' + C.bold('What do you want to do?') + '\n')
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
{ label: '
|
|
197
|
-
{ label: '
|
|
198
|
-
{ label: '
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
204
|
+
// Built per-render so the contextual "Restart" only shows once a service exists.
|
|
205
|
+
const items = [
|
|
206
|
+
{ key: 'pair', label: 'Pair one room now', hint: 'run here, Ctrl-C stops it' },
|
|
207
|
+
{ key: 'serve', label: 'Serve all my sessions', hint: 'account mode — needs login' },
|
|
208
|
+
{ key: 'service', label: 'Always-on background service', hint: 'survives reboot, restarts on crash' },
|
|
209
|
+
]
|
|
210
|
+
if (state.accountSvc) items.push({ key: 'restart', label: 'Restart the background service', hint: 'bounce it now — sessions resume' })
|
|
211
|
+
items.push({ key: 'settings', label: 'Settings', hint: 'provider · account · restart · remove service' })
|
|
212
|
+
items.push({ key: 'quit', label: 'Quit', hint: '' })
|
|
213
|
+
const pick = items[await askChoice('choose', items)]
|
|
214
|
+
if (pick.key === 'quit') return
|
|
215
|
+
if (pick.key === 'pair') await pairRoom()
|
|
216
|
+
else if (pick.key === 'serve') await serveAccount()
|
|
217
|
+
else if (pick.key === 'service') await serviceMenu()
|
|
218
|
+
else if (pick.key === 'restart') { await actions.restartService({ room: null }); resync() }
|
|
219
|
+
else if (pick.key === 'settings') await settingsMenu()
|
|
206
220
|
await io.ask('\n ' + C.dim('press Enter to return to the menu…'))
|
|
207
221
|
}
|
|
208
222
|
}
|
package/package.json
CHANGED
package/service.mjs
CHANGED
|
@@ -28,6 +28,12 @@ const label = (room) => room ? `io.thinkpool.pair.${room.toLowerCase()}` : 'io.t
|
|
|
28
28
|
const slug = (room) => room || 'account'
|
|
29
29
|
const xml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
30
30
|
|
|
31
|
+
// The version installing the service. By DEFAULT the service is pinned to this exact
|
|
32
|
+
// version (not @latest) so a future bad npm publish can't auto-roll to every machine's
|
|
33
|
+
// always-on bridge — that's what spread the broken 0.7.49 on 2026-06-22. Updates become
|
|
34
|
+
// deliberate (re-run install-service). `--auto-update` opts back into @latest tracking.
|
|
35
|
+
const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return 'latest' } })()
|
|
36
|
+
|
|
31
37
|
// Resolve the real npx next to the current node, so the service stays seamless:
|
|
32
38
|
// it runs `npx -y thinkpool-pair@latest …`, always picking up the newest publish
|
|
33
39
|
// with no re-install. Absolute path + injected PATH so it works under launchd /
|
|
@@ -40,7 +46,7 @@ function npxPath(platform) {
|
|
|
40
46
|
|
|
41
47
|
// Pure artifact builder — returned shape is testable without side effects.
|
|
42
48
|
// room falsy → ACCOUNT service (bare `thinkpool-pair`, auto-serves all sessions).
|
|
43
|
-
export function buildArtifact(platform, { room = null, cmdArgs = [] } = {}) {
|
|
49
|
+
export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate = false } = {}) {
|
|
44
50
|
const npx = npxPath(platform)
|
|
45
51
|
const cwd = process.cwd()
|
|
46
52
|
const id = slug(room)
|
|
@@ -48,10 +54,15 @@ export function buildArtifact(platform, { room = null, cmdArgs = [] } = {}) {
|
|
|
48
54
|
const log = path.join(logDir, `${id}.log`)
|
|
49
55
|
const tail = cmdArgs.length ? ['--', ...cmdArgs] : []
|
|
50
56
|
const desc = room ? `bridge (${room})` : 'account bridge'
|
|
51
|
-
// OS-supervised tiers (launchd KeepAlive / systemd Restart) don't need
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
57
|
+
// OS-supervised tiers (launchd KeepAlive / systemd Restart) don't need --supervise.
|
|
58
|
+
// PINNED by default to `@${VERSION}` so a KeepAlive restart re-runs the SAME known-good
|
|
59
|
+
// version — a bad publish can't auto-roll here. `autoUpdate` opts into `@latest` tracking
|
|
60
|
+
// (and the in-process self-updater via THINKPOOL_PAIR_AUTOUPDATE below).
|
|
61
|
+
const verSpec = autoUpdate ? 'thinkpool-pair@latest' : `thinkpool-pair@${VERSION}`
|
|
62
|
+
const pkgArgs = room ? ['-y', verSpec, room] : ['-y', verSpec]
|
|
63
|
+
// In-process self-update (account.mjs / bridge.mjs) is gated on this env — only on when
|
|
64
|
+
// the user explicitly opted into auto-update.
|
|
65
|
+
const autoUpdateEnv = autoUpdate ? { THINKPOOL_PAIR_AUTOUPDATE: '1' } : {}
|
|
55
66
|
|
|
56
67
|
if (platform === 'darwin') {
|
|
57
68
|
const args = [npx, ...pkgArgs, ...tail] // launchd KeepAlive supervises
|
|
@@ -66,7 +77,7 @@ export function buildArtifact(platform, { room = null, cmdArgs = [] } = {}) {
|
|
|
66
77
|
<key>WorkingDirectory</key><string>${xml(cwd)}</string>
|
|
67
78
|
<key>StandardOutPath</key><string>${xml(log)}</string>
|
|
68
79
|
<key>StandardErrorPath</key><string>${xml(log)}</string>
|
|
69
|
-
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(process.env.PATH || '')}</string
|
|
80
|
+
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(process.env.PATH || '')}</string>${autoUpdate ? '<key>THINKPOOL_PAIR_AUTOUPDATE</key><string>1</string>' : ''}</dict>
|
|
70
81
|
</dict></plist>\n`
|
|
71
82
|
return { file, content, logDir, post: [`launchctl unload "${file}" 2>/dev/null; launchctl load "${file}"`], note: 'Loaded as a LaunchAgent — starts at login, restarts on crash.' }
|
|
72
83
|
}
|
|
@@ -84,8 +95,7 @@ Restart=always
|
|
|
84
95
|
RestartSec=2
|
|
85
96
|
WorkingDirectory=${cwd}
|
|
86
97
|
Environment=PATH=${process.env.PATH || ''}
|
|
87
|
-
Environment=THINKPOOL_PAIR_AUTOUPDATE=1
|
|
88
|
-
StandardOutput=append:${log}
|
|
98
|
+
${autoUpdate ? 'Environment=THINKPOOL_PAIR_AUTOUPDATE=1\n' : ''}StandardOutput=append:${log}
|
|
89
99
|
StandardError=append:${log}
|
|
90
100
|
|
|
91
101
|
[Install]
|
|
@@ -102,8 +112,8 @@ WantedBy=default.target
|
|
|
102
112
|
const startup = path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
|
|
103
113
|
const file = path.join(startup, `thinkpool-pair-${id}.cmd`)
|
|
104
114
|
const inner = room
|
|
105
|
-
? ['npx', '-y',
|
|
106
|
-
: ['npx', '-y',
|
|
115
|
+
? ['npx', '-y', verSpec, room, '--supervise', ...tail].join(' ')
|
|
116
|
+
: ['npx', '-y', verSpec].join(' ')
|
|
107
117
|
const content = `@echo off\r\ntitle thinkpool-pair ${id}\r\n${inner}\r\n`
|
|
108
118
|
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.' }
|
|
109
119
|
}
|
|
@@ -111,8 +121,8 @@ WantedBy=default.target
|
|
|
111
121
|
throw new Error(`unsupported platform: ${platform}`)
|
|
112
122
|
}
|
|
113
123
|
|
|
114
|
-
export function installService(room, cmdArgs = []) {
|
|
115
|
-
const a = buildArtifact(process.platform, { room, cmdArgs })
|
|
124
|
+
export function installService(room, cmdArgs = [], { autoUpdate = false } = {}) {
|
|
125
|
+
const a = buildArtifact(process.platform, { room, cmdArgs, autoUpdate })
|
|
116
126
|
fs.mkdirSync(a.logDir, { recursive: true })
|
|
117
127
|
fs.mkdirSync(path.dirname(a.file), { recursive: true })
|
|
118
128
|
fs.writeFileSync(a.file, a.content)
|
|
@@ -124,12 +134,43 @@ export function installService(room, cmdArgs = []) {
|
|
|
124
134
|
// roughly every 30 min (THINKPOOL_PAIR_UPDATE_INTERVAL) and only restarts once
|
|
125
135
|
// every served session is idle, so a just-published version can take a while.
|
|
126
136
|
const idleSecs = Math.max(10, parseInt(process.env.THINKPOOL_PAIR_UPDATE_IDLE, 10) || 90)
|
|
127
|
-
const
|
|
128
|
-
?
|
|
129
|
-
|
|
137
|
+
const updateNote = autoUpdate
|
|
138
|
+
? (process.platform === 'win32'
|
|
139
|
+
? 'auto-update ON: a new thinkpool-pair@latest applies on the next login/restart (Windows has no in-process self-update).'
|
|
140
|
+
: `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).`)
|
|
141
|
+
: `PINNED to ${VERSION} (safe default — a bad publish can't auto-roll to this always-on bridge). To update: re-run install-service from a newer thinkpool-pair. Pass --auto-update to track @latest instead.`
|
|
130
142
|
const removeArg = room ? ` ${room}` : ''
|
|
131
143
|
const what = room ? `room ${room}` : 'your account (auto-serves every session)'
|
|
132
|
-
process.stderr.write(` ◆ ${what}\n ◆ ${a.note}\n ◆ ${
|
|
144
|
+
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`)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Restart an already-installed boot-persistent service. launchd/systemd relaunch
|
|
148
|
+
// the supervised `npx …`, so any active session resumes in place (the bridge
|
|
149
|
+
// restores from disk). Use it to recover a wedged bridge, re-read provider.json,
|
|
150
|
+
// or — if the service was installed with --auto-update (@latest) — pull a new
|
|
151
|
+
// publish now. A PINNED service (the default) re-runs its same installed version.
|
|
152
|
+
// room falsy → the ACCOUNT service. Returns true on success.
|
|
153
|
+
export function restartService(room) {
|
|
154
|
+
const plat = process.platform
|
|
155
|
+
if (!isServiceInstalled(room)) {
|
|
156
|
+
process.stderr.write(' ⚠ no background service installed to restart — install one first.\n')
|
|
157
|
+
return false
|
|
158
|
+
}
|
|
159
|
+
const id = label(room)
|
|
160
|
+
try {
|
|
161
|
+
if (plat === 'darwin') execSync(`launchctl kickstart -k gui/$(id -u)/${id}`, { stdio: 'inherit' })
|
|
162
|
+
else if (plat === 'linux') execSync(`systemctl --user restart ${id}.service`, { stdio: 'inherit' })
|
|
163
|
+
else {
|
|
164
|
+
// Windows Startup .cmd has no managed daemon to kick — guide the manual path.
|
|
165
|
+
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')
|
|
166
|
+
return false
|
|
167
|
+
}
|
|
168
|
+
process.stderr.write(` ◆ restarted ${id} — any active session resumes in place.\n`)
|
|
169
|
+
return true
|
|
170
|
+
} catch (e) {
|
|
171
|
+
process.stderr.write(` ⚠ restart failed: ${e.message}\n`)
|
|
172
|
+
return false
|
|
173
|
+
}
|
|
133
174
|
}
|
|
134
175
|
|
|
135
176
|
// Is a boot-persistent service already installed? (artifact file present.)
|