thinkpool-pair 0.7.60 → 0.7.62
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/account.mjs +9 -3
- package/bridge.mjs +20 -104
- package/launcher.mjs +3 -20
- package/package.json +2 -2
- package/presence.mjs +51 -0
- package/cmux.mjs +0 -148
package/account.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { createClient } from '@supabase/supabase-js'
|
|
|
15
15
|
import os from 'node:os'
|
|
16
16
|
import { saveAuth, loadAuth, loadDirs, bindDir } from './auth-store.mjs'
|
|
17
17
|
import { isSafeToRestart } from './update-gate.mjs'
|
|
18
|
+
import { makeThrottledTrack } from './presence.mjs'
|
|
18
19
|
|
|
19
20
|
const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return null } })()
|
|
20
21
|
|
|
@@ -247,8 +248,8 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
247
248
|
})
|
|
248
249
|
// Dashboard "Restart" → broadcast restart → graceful stop with a NON-ZERO exit so
|
|
249
250
|
// EVERY supervisor respawns us: launchd KeepAlive / systemd Restart=always respawn
|
|
250
|
-
// regardless of exit code, and the bridge.mjs --supervise
|
|
251
|
-
//
|
|
251
|
+
// regardless of exit code, and the bridge.mjs --supervise wrapper (room tier)
|
|
252
|
+
// respawns on non-zero but treats 0 as a clean stop. Children persist + resume
|
|
252
253
|
// their sessions (session-store), so the bounce is invisible in-room. Unlike shutdown
|
|
253
254
|
// we do NOT uninstall the service. A plain foreground bridge has no supervisor, so it
|
|
254
255
|
// just stops — restart is meaningful for the always-on bridge. Old bridges (pre-0.7.60)
|
|
@@ -259,7 +260,12 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
259
260
|
setTimeout(() => { try { releaseSingleton() } catch { /* noop */ } process.exit(1) }, 2500)
|
|
260
261
|
process.kill(process.pid, 'SIGTERM')
|
|
261
262
|
})
|
|
262
|
-
|
|
263
|
+
// Throttled so a realtime reconnect storm can't machine-gun track() into the
|
|
264
|
+
// per-client presence rate limit (ClientPresenceRateLimitReached → channel closed
|
|
265
|
+
// → dashboard reads "no bridge" while sessions run; 2026-06-17/-22/-24). The 15s
|
|
266
|
+
// tick + token-refresh callers are unaffected (their interval exceeds the window).
|
|
267
|
+
const trackPresence = makeThrottledTrack(acct, { minMs: 5000 })
|
|
268
|
+
const pushPresence = () => trackPresence({ name: machine, version: VERSION, rooms: [...children.keys()], ts: Date.now() })
|
|
263
269
|
// Re-track on EVERY (re)subscribe, not just the first: a realtime reconnect
|
|
264
270
|
// (network blip, or a token swap mid-flight) rejoins the channel and must
|
|
265
271
|
// re-announce, or the dashboard would read "no bridge" until the next restart.
|
package/bridge.mjs
CHANGED
|
@@ -40,6 +40,7 @@ import { startClaudeSession } from './claude-session.mjs'
|
|
|
40
40
|
import { turnInFlight } from './update-gate.mjs'
|
|
41
41
|
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames } from './session-store.mjs'
|
|
42
42
|
import { stampEvent, makeSeqCounter, maxSeq, seqable } from './event-id.mjs'
|
|
43
|
+
import { makeThrottledTrack } from './presence.mjs'
|
|
43
44
|
|
|
44
45
|
// Public client creds (the same anon values the web app ships — safe to embed).
|
|
45
46
|
// Override with TP_SUPABASE_URL / TP_SUPABASE_ANON if you ever need to.
|
|
@@ -149,14 +150,7 @@ if (argv[0] === 'install-service' || argv[0] === 'uninstall-service') {
|
|
|
149
150
|
const autoUpdate = argv.includes('--auto-update')
|
|
150
151
|
const svc = await import('./service.mjs')
|
|
151
152
|
if (argv[0] === 'install-service') svc.installService(svcRoom, svcCmd, { autoUpdate })
|
|
152
|
-
else
|
|
153
|
-
// Account scope (room=null) may be the cmux-hosted tier (cmux-service.json) —
|
|
154
|
-
// that's torn down via cmux, not launchctl/systemctl. Room scope is always OS.
|
|
155
|
-
const cmux = await import('./cmux.mjs')
|
|
156
|
-
if (svcRoom === null && cmux.isCmuxServiceInstalled()) {
|
|
157
|
-
cmux.uninstallCmuxService(); process.stderr.write(' ◆ removed the cmux-hosted account service.\n')
|
|
158
|
-
} else svc.uninstallService(svcRoom)
|
|
159
|
-
}
|
|
153
|
+
else svc.uninstallService(svcRoom)
|
|
160
154
|
process.exit(0)
|
|
161
155
|
}
|
|
162
156
|
|
|
@@ -165,10 +159,7 @@ if (argv[0] === 'install-service' || argv[0] === 'uninstall-service') {
|
|
|
165
159
|
if (argv[0] === 'restart-service') {
|
|
166
160
|
const arg1 = argv[1] || ''
|
|
167
161
|
const svcRoom = (!arg1 || arg1.startsWith('-')) ? null : arg1.toUpperCase().trim()
|
|
168
|
-
const
|
|
169
|
-
if (svcRoom === null && cmux.isCmuxServiceInstalled()) {
|
|
170
|
-
cmux.restartCmuxService(); process.stderr.write(' ◆ restarted the cmux-hosted account service — sessions resume in place.\n')
|
|
171
|
-
} else { const svc = await import('./service.mjs'); svc.restartService(svcRoom) }
|
|
162
|
+
const svc = await import('./service.mjs'); svc.restartService(svcRoom)
|
|
172
163
|
process.exit(0)
|
|
173
164
|
}
|
|
174
165
|
|
|
@@ -189,62 +180,6 @@ if (argv[0] === 'login') { const { runLogin } = await import('./account.mjs'); a
|
|
|
189
180
|
if (argv[0] === 'bind') { const { runBind } = await import('./account.mjs'); runBind(argv[1], argv[2]) }
|
|
190
181
|
if (argv[0] === 'provider') { const { runProvider } = await import('./provider.mjs'); await runProvider(argv.slice(1)); process.exit(0) }
|
|
191
182
|
|
|
192
|
-
// Explicit account-serve entrypoint — used by the cmux-hosted service tier (cmux.mjs).
|
|
193
|
-
// A cmux surface HAS a TTY, so a bare invocation would open the interactive launcher
|
|
194
|
-
// instead of serving; `serve` runs account mode directly, regardless of TTY. With
|
|
195
|
-
// `--supervise` it first wraps itself in a crash-restart loop (re-execing `serve`
|
|
196
|
-
// without the flag): cmux, unlike launchd KeepAlive, will not relaunch a surface
|
|
197
|
-
// whose command exits, so the account supervisor must restart itself.
|
|
198
|
-
if (argv[0] === 'serve') {
|
|
199
|
-
if (argv.includes('--supervise') || argv.includes('--keep-alive')) {
|
|
200
|
-
const childArgs = process.argv.slice(1).filter((a) => a !== '--supervise' && a !== '--keep-alive')
|
|
201
|
-
let backoff = 1000, stopping = false, child = null
|
|
202
|
-
const spawnChild = () => {
|
|
203
|
-
const t0 = Date.now()
|
|
204
|
-
child = spawn(process.execPath, childArgs, { stdio: 'inherit' })
|
|
205
|
-
child.on('exit', (code, signal) => {
|
|
206
|
-
if (stopping || code === 0) process.exit(code || 0)
|
|
207
|
-
const ranLong = Date.now() - t0 > 60000
|
|
208
|
-
const wait = ranLong ? 1000 : backoff
|
|
209
|
-
backoff = ranLong ? 1000 : Math.min(backoff * 2, 30000)
|
|
210
|
-
process.stderr.write(`\n ⚠ account supervisor exited (${signal || `code ${code}`}); restarting in ${Math.round(wait / 1000)}s…\n`)
|
|
211
|
-
setTimeout(spawnChild, wait)
|
|
212
|
-
})
|
|
213
|
-
child.on('error', () => { /* spawn failure → exit handler retries */ })
|
|
214
|
-
}
|
|
215
|
-
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { stopping = true; try { child?.kill(sig) } catch { /* noop */ } setTimeout(() => process.exit(0), 400) })
|
|
216
|
-
process.stderr.write('\n ◆ supervisor up — keeping the account bridge alive (auto-restart on crash).\n')
|
|
217
|
-
spawnChild()
|
|
218
|
-
await new Promise(() => {}) // block forever; the child IS the account supervisor
|
|
219
|
-
}
|
|
220
|
-
const { runAccount } = await import('./account.mjs')
|
|
221
|
-
await runAccount(SUPABASE_URL, SUPABASE_ANON)
|
|
222
|
-
process.exit(0)
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// `npx thinkpool-pair cmux-doctor` — explain why the cmux service tier did/didn't engage.
|
|
226
|
-
// Detection is env-based (CMUX_WORKSPACE_ID/SURFACE_ID); the socket is only needed for the
|
|
227
|
-
// actual surface spawn. This prints both so a "it didn't detect cmux" report is one command.
|
|
228
|
-
if (argv[0] === 'cmux-doctor') {
|
|
229
|
-
const cmux = await import('./cmux.mjs')
|
|
230
|
-
const env = process.env
|
|
231
|
-
const w = (s) => process.stderr.write(s + '\n')
|
|
232
|
-
const reachable = cmux.cmuxReachable()
|
|
233
|
-
w('\n thinkpool-pair · cmux doctor')
|
|
234
|
-
w(` CMUX_WORKSPACE_ID : ${env.CMUX_WORKSPACE_ID || '(unset)'}`)
|
|
235
|
-
w(` CMUX_SURFACE_ID : ${env.CMUX_SURFACE_ID || '(unset)'}`)
|
|
236
|
-
w(` CMUX_SOCKET_PATH : ${env.CMUX_SOCKET_PATH || '(unset)'}`)
|
|
237
|
-
w(` cmux on PATH : ${onPath('cmux')}`)
|
|
238
|
-
w(` inCmux() : ${cmux.inCmux()}`)
|
|
239
|
-
w(` cmux ping : ${reachable ? 'ok' : 'FAILED (socket unreachable)'}`)
|
|
240
|
-
w(` detectCmux() : ${cmux.detectCmux()} ${cmux.detectCmux() ? '→ cmux-surface offered' : '→ falls back to OS service'}`)
|
|
241
|
-
w(` cmux service set : ${cmux.isCmuxServiceInstalled()}`)
|
|
242
|
-
if (!cmux.inCmux()) w('\n ⚠ not launched from cmux (no CMUX_WORKSPACE_ID/SURFACE_ID). Run this INSIDE a cmux\n terminal surface — a plain Terminal/iTerm window will not be detected.')
|
|
243
|
-
else if (!reachable) w('\n ⚠ in cmux, but the automation socket is unreachable. detectCmux() is env-based so\n the surface is still offered, but the actual spawn needs the socket — if install\n falls back to the OS service, enable cmux automation/socket access in cmux settings.')
|
|
244
|
-
else w('\n ✓ cmux detected and drivable — the cmux-surface service tier is available.')
|
|
245
|
-
process.exit(0)
|
|
246
|
-
}
|
|
247
|
-
|
|
248
183
|
// One-command launcher: a bare invocation on a TTY (or the `setup` alias) opens the
|
|
249
184
|
// interactive menu instead of going straight to account mode. The menu only ASSEMBLES
|
|
250
185
|
// existing commands — it re-execs `<ROOM>`, or calls installService / runLogin /
|
|
@@ -254,7 +189,6 @@ if (argv[0] === 'cmux-doctor') {
|
|
|
254
189
|
if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
|
|
255
190
|
const { runLauncher, detectState } = await import('./launcher.mjs')
|
|
256
191
|
const svc = await import('./service.mjs')
|
|
257
|
-
const cmux = await import('./cmux.mjs')
|
|
258
192
|
const PKG_VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return 'latest' } })()
|
|
259
193
|
const BRIDGE = fileURLToPath(import.meta.url)
|
|
260
194
|
const io = {
|
|
@@ -277,39 +211,14 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
|
|
|
277
211
|
// notes, and RETURN so the launcher loop drops you back at the menu (the service
|
|
278
212
|
// runs in its own launchd/systemd process; this launcher isn't itself a bridge).
|
|
279
213
|
installService: ({ room = null, agentCmd } = {}) => { svc.installService(room, agentCmd ? [agentCmd] : []) },
|
|
280
|
-
uninstallService: ({ room = null } = {}) => {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
if (room === null && cmux.isCmuxServiceInstalled()) { cmux.restartCmuxService(); process.stderr.write('\n ◆ restarted the cmux-hosted account service — sessions resume in place.\n') }
|
|
286
|
-
else svc.restartService(room)
|
|
287
|
-
},
|
|
288
|
-
// "Restart & update the bridge" — re-pin the service to THIS launcher's version
|
|
289
|
-
// (you launched via @latest, so it's the newest publish) and restart. The cmux
|
|
290
|
-
// surface re-pins + respawns; the OS service is reinstalled (rewrites the plist/unit
|
|
291
|
-
// to the new version and reloads). Pinned throughout — no auto-roll.
|
|
214
|
+
uninstallService: ({ room = null } = {}) => { svc.uninstallService(room) },
|
|
215
|
+
restartService: ({ room = null } = {}) => { svc.restartService(room) },
|
|
216
|
+
// "Restart & update the bridge" — re-pin the OS service to THIS launcher's version
|
|
217
|
+
// (you launched via @latest, so it's the newest publish) and restart: the service is
|
|
218
|
+
// reinstalled (rewrites the plist/unit to the new version and reloads). Pinned — no auto-roll.
|
|
292
219
|
restartUpdateService: ({ room = null } = {}) => {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
process.stderr.write(`\n ◆ updated the cmux account service to v${PKG_VERSION} and restarted it — sessions resume in place.\n`)
|
|
296
|
-
} else {
|
|
297
|
-
process.stderr.write(`\n ◆ updating the background service to v${PKG_VERSION} and restarting…\n`)
|
|
298
|
-
svc.installService(room, []) // rewrites the plist/unit at this version + reloads = update + restart
|
|
299
|
-
}
|
|
300
|
-
},
|
|
301
|
-
// Account service hosted as a cmux surface — offered only inside cmux, with its
|
|
302
|
-
// weaker-persistence caveat shown first (launcher.mjs serveAccount). Falls back to
|
|
303
|
-
// the OS service if the cmux socket can't actually be driven (detection is env-based).
|
|
304
|
-
detectCmux: () => cmux.detectCmux(),
|
|
305
|
-
installAccountCmux: () => {
|
|
306
|
-
try {
|
|
307
|
-
const rec = cmux.installCmuxService({ version: PKG_VERSION })
|
|
308
|
-
process.stderr.write(`\n ◆ account service now runs as a cmux surface ("${cmux.WS_NAME}") — visible and killable.\n ◆ it restarts itself on crash; stop it with: npx thinkpool-pair uninstall-service\n ◆ pinned to v${rec.version}.\n\n`)
|
|
309
|
-
} catch (e) {
|
|
310
|
-
process.stderr.write(`\n ⚠ couldn't spawn a cmux surface (${e.message || e}). The cmux automation socket may be\n disabled — run 'npx thinkpool-pair cmux-doctor' to check. Falling back to the system service.\n`)
|
|
311
|
-
svc.installService(null, [])
|
|
312
|
-
}
|
|
220
|
+
process.stderr.write(`\n ◆ updating the background service to v${PKG_VERSION} and restarting…\n`)
|
|
221
|
+
svc.installService(room, []) // rewrites the plist/unit at this version + reloads = update + restart
|
|
313
222
|
},
|
|
314
223
|
login: async () => { const { runLogin } = await import('./account.mjs'); await runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) },
|
|
315
224
|
// Provider config is NOT terminal — it writes provider.json and returns to the
|
|
@@ -476,6 +385,13 @@ const safeName = (n) => String(n || 'file').replace(/[^a-zA-Z0-9._-]/g, '_').sli
|
|
|
476
385
|
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON, {
|
|
477
386
|
realtime: { params: { eventsPerSecond: 60 } },
|
|
478
387
|
})
|
|
388
|
+
// Throttled presence track() (presence.mjs) — a reconnect storm re-fires SUBSCRIBED and
|
|
389
|
+
// the handler re-track()s; without throttle that spirals into the per-client presence
|
|
390
|
+
// rate limit (ClientPresenceRateLimitReached). Lazy so it binds `channel` (the chain
|
|
391
|
+
// result below) only on first call — always after the async subscribe callback, by which
|
|
392
|
+
// point the const is assigned.
|
|
393
|
+
let _presence = null
|
|
394
|
+
const trackPresence = (payload) => { (_presence ||= makeThrottledTrack(channel, { minMs: 5000 }))(payload) }
|
|
479
395
|
const channel = supabase.channel(`tpcode:${room}`, {
|
|
480
396
|
config: { broadcast: { self: false }, presence: { key: `bridge:${name}` } },
|
|
481
397
|
})
|
|
@@ -747,7 +663,7 @@ function printLocal(evt) {
|
|
|
747
663
|
case 'you': return w(`${A.cyan}❯ ${evt.text}${A.rst}${evt.by ? ` ${A.dim}(${evt.by})${A.rst}` : ''}`)
|
|
748
664
|
case 'chat': return w(`${A.dim}${evt.by}: ${evt.text}${A.rst}`)
|
|
749
665
|
case 'whisper': return w(`${A.dim}// ${evt.by}: ${evt.text}${A.rst}`)
|
|
750
|
-
case 'pool': return w(evt.pending ? `${A.cyan}◌ pool
|
|
666
|
+
case 'pool': return w(evt.pending ? `${A.cyan}◌ pool dispatching…${A.rst}` : `${A.cyan}◆ pool → ${evt.text}${A.rst}`)
|
|
751
667
|
case 'note': return w(`${A.dim}— ${evt.text} —${A.rst}`)
|
|
752
668
|
case 'clear': return w(`${A.dim}— context cleared —${A.rst}`)
|
|
753
669
|
case 'usage': { const c = evt.ctx; return c ? w(`${A.dim} ctx ${c.pct}% · ${Math.round(c.used / 1000)}k/${Math.round(c.max / 1000)}k${evt.costUsd != null ? ` · $${evt.costUsd.toFixed(3)}` : ''}${A.rst}`) : undefined }
|
|
@@ -1091,7 +1007,7 @@ channel
|
|
|
1091
1007
|
markActivity()
|
|
1092
1008
|
const text = String(payload.text)
|
|
1093
1009
|
// Echo the turn so BOTH readers (and late joiners, via the log) show who
|
|
1094
|
-
// said what — UNLESS silent (an @pool
|
|
1010
|
+
// said what — UNLESS silent (an @pool fire, which renders its own line).
|
|
1095
1011
|
const echoYou = () => {
|
|
1096
1012
|
if (payload.silent) return
|
|
1097
1013
|
// Display the clean body (web sends `body` when the agent `text` carries host
|
|
@@ -1181,7 +1097,7 @@ channel
|
|
|
1181
1097
|
.subscribe(status => {
|
|
1182
1098
|
if (status === 'SUBSCRIBED') {
|
|
1183
1099
|
realtimeHealthy = true; brokenSince = 0
|
|
1184
|
-
|
|
1100
|
+
trackPresence({ name, role: 'bridge' })
|
|
1185
1101
|
const startCmd = attachedCmd || autoAgent // autoAgent: account-mode auto-open (headless)
|
|
1186
1102
|
if (startCmd && !terms.size && !sessions.size) {
|
|
1187
1103
|
// Claude + structured mode → Agent SDK session; everything else → PTY.
|
package/launcher.mjs
CHANGED
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
import os from 'node:os'
|
|
14
14
|
import fs from 'node:fs'
|
|
15
15
|
import path from 'node:path'
|
|
16
|
-
import { isCmuxServiceInstalled } from './cmux.mjs'
|
|
17
16
|
|
|
18
17
|
const HOME = os.homedir()
|
|
19
18
|
const CFG_DIR = path.join(HOME, '.thinkpool-pair')
|
|
@@ -61,11 +60,7 @@ export function detectState() {
|
|
|
61
60
|
provider: isCustom ? `Custom (${provider.baseUrl || 'endpoint'})` : 'Anthropic (default)',
|
|
62
61
|
providerModel: isCustom ? (provider.model || null) : null,
|
|
63
62
|
agents: KNOWN_AGENTS.filter(a => onPath(a.cmd)),
|
|
64
|
-
accountSvc: fs.existsSync(serviceFile(null))
|
|
65
|
-
// Which tier hosts the account service, so the menu can label it honestly.
|
|
66
|
-
// Cheap, read-only (file existence) — no cmux socket call here; the live
|
|
67
|
-
// cmux detection (which pings) happens in serveAccount at install time.
|
|
68
|
-
accountSvcKind: isCmuxServiceInstalled() ? 'cmux' : (fs.existsSync(serviceFile(null)) ? 'os' : null),
|
|
63
|
+
accountSvc: fs.existsSync(serviceFile(null)),
|
|
69
64
|
cwd: process.cwd(),
|
|
70
65
|
}
|
|
71
66
|
}
|
|
@@ -100,7 +95,7 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
100
95
|
io.print(` ${C.dim('│ provider ')} ${state.provider}`)
|
|
101
96
|
io.print(` ${C.dim('│ agents ')} ${state.agents.length ? state.agents.map(a => a.label).join(', ') : C.yellow('none on PATH')}`)
|
|
102
97
|
io.print(` ${C.dim('│ directory')} ${C.dim(state.cwd)}`)
|
|
103
|
-
io.print(` ${C.dim('│ service ')} ${state.accountSvc ? C.green('account service installed'
|
|
98
|
+
io.print(` ${C.dim('│ service ')} ${state.accountSvc ? C.green('account service installed') : 'none'}`)
|
|
104
99
|
io.print(' ' + C.dim('└' + '─'.repeat(54)) + '\n')
|
|
105
100
|
}
|
|
106
101
|
|
|
@@ -134,19 +129,7 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
134
129
|
])
|
|
135
130
|
if (how === 2) return
|
|
136
131
|
if (how === 1) { await actions.serveAccountForeground(); return }
|
|
137
|
-
// how === 0 — always-on
|
|
138
|
-
// surface (visible + killable) — but flag its weaker persistence FIRST and let
|
|
139
|
-
// the user opt back into the durable OS service.
|
|
140
|
-
if (actions.detectCmux && actions.detectCmux()) {
|
|
141
|
-
io.print('\n ' + C.cyan('Detected cmux') + ' — I can run the service as a cmux surface you can see and kill,')
|
|
142
|
-
io.print(' alongside your other terminals. Heads up — this is ' + C.yellow('less durable') + ' than the system service:')
|
|
143
|
-
io.print(' ' + C.dim('•') + ' stays up while cmux is running')
|
|
144
|
-
io.print(' ' + C.dim('•') + ' restarts itself on crash (--supervise)')
|
|
145
|
-
io.print(' ' + C.dim('•') + ' best-effort return via cmux session-restore')
|
|
146
|
-
io.print(' ' + C.dim('•') + ' ' + C.yellow('does NOT survive logout/reboot') + ' unless cmux launches at login')
|
|
147
|
-
const useCmux = await askYesNo('\n Run it as a cmux surface? (n = always-on system service)', true)
|
|
148
|
-
if (useCmux) { await actions.installAccountCmux(); resync(); return }
|
|
149
|
-
}
|
|
132
|
+
// how === 0 — always-on durable OS service (launchd / systemd / Windows Startup).
|
|
150
133
|
await actions.installService({ room: null }); resync()
|
|
151
134
|
}
|
|
152
135
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.62",
|
|
4
4
|
"description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"transcript-sanitize.mjs",
|
|
16
16
|
"session-store.mjs",
|
|
17
17
|
"service.mjs",
|
|
18
|
-
"
|
|
18
|
+
"presence.mjs",
|
|
19
19
|
"account.mjs",
|
|
20
20
|
"auth-store.mjs",
|
|
21
21
|
"provider.mjs",
|
package/presence.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/* ─────────────────────────────────────────────────────────────
|
|
2
|
+
presence.mjs — a throttled wrapper for Supabase Realtime presence
|
|
3
|
+
track(), shared by the account supervisor (account.mjs) and every
|
|
4
|
+
room bridge (bridge.mjs).
|
|
5
|
+
|
|
6
|
+
The bug it fixes: supabase-js auto-rejoins a dropped channel and
|
|
7
|
+
re-fires `SUBSCRIBED`; our subscribe handlers re-`track()` presence
|
|
8
|
+
on each one. With no throttle a SINGLE drop can spiral —
|
|
9
|
+
track() → trip the per-client presence rate limit
|
|
10
|
+
→ server closes the channel → auto-rejoin → SUBSCRIBED
|
|
11
|
+
→ track() → trip again → … forever
|
|
12
|
+
which the server logs as `ClientPresenceRateLimitReached:
|
|
13
|
+
:client_rate_limit_exceeded`. The supervisor stays alive and keeps
|
|
14
|
+
relaying sessions, but its presence never re-sticks, so the dashboard
|
|
15
|
+
reads "No bridge connected" while sessions plainly run (the
|
|
16
|
+
2026-06-17 / -22 / -24 recurrences).
|
|
17
|
+
|
|
18
|
+
makeThrottledTrack coalesces bursts to at most one track() per
|
|
19
|
+
window: the FIRST call fires immediately (presence appears fast),
|
|
20
|
+
any further calls inside the window collapse into a single trailing
|
|
21
|
+
fire with the latest payload. At minMs=5000 that's ≤0.2 track/s —
|
|
22
|
+
far under any presence rate limit — so a reconnect storm can never
|
|
23
|
+
trip it, while the steady-state callers (a 15s tick, a token refresh)
|
|
24
|
+
are unaffected (their interval already exceeds the window).
|
|
25
|
+
───────────────────────────────────────────────────────────── */
|
|
26
|
+
|
|
27
|
+
// channel: a supabase RealtimeChannel (stable across reconnects — supabase-js
|
|
28
|
+
// rejoins the same object, so channel.track stays valid). Returns a function you
|
|
29
|
+
// call with the presence payload anywhere you'd have called channel.track(payload).
|
|
30
|
+
export function makeThrottledTrack(channel, { minMs = 5000 } = {}) {
|
|
31
|
+
let last = 0
|
|
32
|
+
let timer = null
|
|
33
|
+
let pending = null
|
|
34
|
+
const fire = () => {
|
|
35
|
+
timer = null
|
|
36
|
+
last = Date.now()
|
|
37
|
+
const payload = pending
|
|
38
|
+
pending = null
|
|
39
|
+
try { channel.track(payload) } catch { /* presence track is best-effort */ }
|
|
40
|
+
}
|
|
41
|
+
return (payload) => {
|
|
42
|
+
pending = payload
|
|
43
|
+
const since = Date.now() - last
|
|
44
|
+
if (since >= minMs) {
|
|
45
|
+
if (timer) { clearTimeout(timer) }
|
|
46
|
+
fire() // leading edge — presence appears immediately
|
|
47
|
+
} else if (!timer) {
|
|
48
|
+
timer = setTimeout(fire, minMs - since) // trailing edge — coalesce the burst
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
package/cmux.mjs
DELETED
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
/* ─────────────────────────────────────────────────────────────
|
|
2
|
-
cmux.mjs — host the ACCOUNT service as a cmux surface instead of
|
|
3
|
-
a detached launchd/systemd daemon, when the bridge was launched
|
|
4
|
-
from inside cmux.
|
|
5
|
-
|
|
6
|
-
Why a separate tier: the OS service (service.mjs) is invisible —
|
|
7
|
-
it logs to a file and is supervised by launchd KeepAlive. Inside
|
|
8
|
-
cmux, the user would rather see the service running in a normal
|
|
9
|
-
surface they can watch and kill, alongside everything else. cmux,
|
|
10
|
-
unlike launchd, does NOT relaunch a surface whose command exits,
|
|
11
|
-
so the surface runs `serve --supervise` (bridge.mjs) which restarts
|
|
12
|
-
the account supervisor itself on crash.
|
|
13
|
-
|
|
14
|
-
Detection is deliberately strict: being on $PATH is NOT proof cmux
|
|
15
|
-
spawned us (claude-session.mjs makes the same distinction). Only the
|
|
16
|
-
CMUX_WORKSPACE_ID / CMUX_SURFACE_ID env vars — which cmux injects
|
|
17
|
-
into every surface it creates — prove it, and we additionally confirm
|
|
18
|
-
the socket actually answers before committing to this tier. If either
|
|
19
|
-
check fails the caller falls back to the OS service.
|
|
20
|
-
|
|
21
|
-
Persistence trade-off (flagged to the user in launcher.mjs before
|
|
22
|
-
install): up while cmux runs · crash-restart via --supervise ·
|
|
23
|
-
best-effort return on cmux session-restore · does NOT survive
|
|
24
|
-
logout/reboot unless cmux launches at login.
|
|
25
|
-
|
|
26
|
-
Spec: docs/specs/2026-06-23-cmux-hosted-account-service.md
|
|
27
|
-
───────────────────────────────────────────────────────────── */
|
|
28
|
-
|
|
29
|
-
import os from 'node:os'
|
|
30
|
-
import fs from 'node:fs'
|
|
31
|
-
import path from 'node:path'
|
|
32
|
-
import { execFileSync } from 'node:child_process'
|
|
33
|
-
|
|
34
|
-
const HOME = os.homedir()
|
|
35
|
-
const CFG_DIR = path.join(HOME, '.thinkpool-pair')
|
|
36
|
-
// One stable record per machine → exactly one cmux-hosted account service.
|
|
37
|
-
export const RECORD = path.join(CFG_DIR, 'cmux-service.json')
|
|
38
|
-
export const WS_NAME = 'thinkpool-pair service'
|
|
39
|
-
|
|
40
|
-
// Run a cmux CLI command; return trimmed stdout, or throw. Isolated so the rest
|
|
41
|
-
// of the module (and its tests) can run without the socket by injecting `run`.
|
|
42
|
-
function cmux(args, { timeout = 6000 } = {}) {
|
|
43
|
-
return execFileSync('cmux', args, { encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'ignore'] }).trim()
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// cmux injects these into every surface it spawns. PATH containing a cmux shim is
|
|
47
|
-
// NOT enough — only the env proves cmux launched this shell.
|
|
48
|
-
export function inCmux(env = process.env) {
|
|
49
|
-
return !!(env.CMUX_WORKSPACE_ID || env.CMUX_SURFACE_ID)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Can we actually drive cmux right now? (socket answers a ping.) Returns false on
|
|
53
|
-
// any failure — a broken/absent socket means we cannot host a surface, so fall back.
|
|
54
|
-
export function cmuxReachable(run = cmux) {
|
|
55
|
-
try { run(['ping'], { timeout: 4000 }); return true } catch { return false }
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Detection keys ONLY on the env cmux injects into every surface it spawns — NOT on
|
|
59
|
-
// whether the socket answers. The socket ping proved flaky (it needs CMUX_SOCKET_PASSWORD
|
|
60
|
-
// and can broken-pipe even from a real surface), and gating detection on it silently
|
|
61
|
-
// killed the cmux offer in 0.7.57. If the env says cmux we offer the cmux tier; if the
|
|
62
|
-
// actual new-workspace spawn then can't reach the socket, installCmuxService throws and
|
|
63
|
-
// the caller falls back to the OS service. (cmuxReachable stays exported for cmux-doctor.)
|
|
64
|
-
export function detectCmux(env = process.env) {
|
|
65
|
-
return inCmux(env)
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function readRecord(recordFile = RECORD) {
|
|
69
|
-
try { return JSON.parse(fs.readFileSync(recordFile, 'utf8')) } catch { return null }
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function isCmuxServiceInstalled(recordFile = RECORD) {
|
|
73
|
-
return !!readRecord(recordFile)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// The command the cmux surface runs. PINNED to the installing version (same safety
|
|
77
|
-
// default as service.mjs — a bad publish can't auto-roll to the always-on bridge).
|
|
78
|
-
// `serve` runs account mode regardless of TTY (a cmux surface has a TTY, so a bare
|
|
79
|
-
// invocation would open the interactive launcher instead). `--supervise` restarts
|
|
80
|
-
// the account supervisor on crash, since cmux won't re-run a dead surface.
|
|
81
|
-
export function serviceCommand(version) {
|
|
82
|
-
return `npx -y thinkpool-pair@${version || 'latest'} serve --supervise`
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Pull a stable handle out of cmux output. Prefer a UUID (stable across reorders);
|
|
86
|
-
// fall back to a positional `workspace:N` ref; null if neither is present.
|
|
87
|
-
export function parseWorkspaceRef(out) {
|
|
88
|
-
const uuid = (String(out).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i) || [])[0]
|
|
89
|
-
if (uuid) return uuid
|
|
90
|
-
const ref = (String(out).match(/workspace:\d+/) || [])[0]
|
|
91
|
-
return ref || null
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Best-effort: find the service workspace by its name when we have no stored ref
|
|
95
|
-
// (e.g. the record predates ref capture, or parsing failed at install time).
|
|
96
|
-
function resolveByName(run, name = WS_NAME) {
|
|
97
|
-
try {
|
|
98
|
-
const out = run(['--id-format', 'uuids', 'list-workspaces'])
|
|
99
|
-
const line = String(out).split('\n').find((l) => l.includes(name))
|
|
100
|
-
return line ? parseWorkspaceRef(line) : null
|
|
101
|
-
} catch { return null }
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function spawnSurface(run, command, cwd = HOME) {
|
|
105
|
-
// --focus false: never steal focus from the user's current surface.
|
|
106
|
-
const out = run(['--id-format', 'uuids', 'new-workspace', '--name', WS_NAME, '--cwd', cwd, '--command', command, '--focus', 'false'])
|
|
107
|
-
return parseWorkspaceRef(out)
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function closeSurface(run, rec) {
|
|
111
|
-
const ref = rec.workspace || resolveByName(run)
|
|
112
|
-
if (!ref) return
|
|
113
|
-
try { run(['close-workspace', '--workspace', ref]) } catch { /* may already be gone */ }
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export function installCmuxService({ version, cwd = HOME, run = cmux, recordFile = RECORD } = {}) {
|
|
117
|
-
const command = serviceCommand(version)
|
|
118
|
-
const workspace = spawnSurface(run, command, cwd)
|
|
119
|
-
const rec = { kind: 'cmux', version: version || 'latest', name: WS_NAME, command, workspace, installedAt: new Date().toISOString() }
|
|
120
|
-
fs.mkdirSync(path.dirname(recordFile), { recursive: true })
|
|
121
|
-
fs.writeFileSync(recordFile, JSON.stringify(rec, null, 2))
|
|
122
|
-
return rec
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Restart = close the surface + spawn a fresh one. The account supervisor restores
|
|
126
|
-
// every active session from disk (session-store crash-resume), so sessions resume
|
|
127
|
-
// in place — same contract as an OS-service restart. Passing `version` re-pins the
|
|
128
|
-
// surface command to that version first (the "Restart & update the bridge" path):
|
|
129
|
-
// the command stays PINNED (never @latest — no auto-roll), just bumped to the version
|
|
130
|
-
// the user explicitly launched.
|
|
131
|
-
export function restartCmuxService({ run = cmux, recordFile = RECORD, version } = {}) {
|
|
132
|
-
const rec = readRecord(recordFile)
|
|
133
|
-
if (!rec) return false
|
|
134
|
-
closeSurface(run, rec)
|
|
135
|
-
if (version) { rec.version = version; rec.command = serviceCommand(version) }
|
|
136
|
-
rec.workspace = spawnSurface(run, rec.command)
|
|
137
|
-
rec.installedAt = new Date().toISOString()
|
|
138
|
-
fs.writeFileSync(recordFile, JSON.stringify(rec, null, 2))
|
|
139
|
-
return true
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
export function uninstallCmuxService({ run = cmux, recordFile = RECORD } = {}) {
|
|
143
|
-
const rec = readRecord(recordFile)
|
|
144
|
-
if (!rec) return false
|
|
145
|
-
closeSurface(run, rec)
|
|
146
|
-
try { fs.rmSync(recordFile, { force: true }) } catch { /* noop */ }
|
|
147
|
-
return true
|
|
148
|
-
}
|