thinkpool-pair 0.7.319 → 0.7.321
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/README.md +6 -6
- package/account.mjs +66 -33
- package/bridge.mjs +70 -16
- package/command-guidance.mjs +8 -0
- package/launcher.mjs +9 -7
- package/package.json +2 -1
- package/presence.mjs +31 -0
- package/provider.mjs +5 -4
- package/serve-consent.mjs +3 -1
- package/service.mjs +91 -25
- package/update-gate.mjs +11 -0
package/README.md
CHANGED
|
@@ -31,14 +31,14 @@ The bridge self-heals at three levels — all cross-platform (macOS / Linux / Wi
|
|
|
31
31
|
backoff). Zero-dependency. Combined with session restore (below), a crash is
|
|
32
32
|
invisible — the room reconnects and the Claude session resumes where it left off.
|
|
33
33
|
```bash
|
|
34
|
-
npx thinkpool-pair <ROOM> --supervise -- claude
|
|
34
|
+
npx thinkpool-pair@latest <ROOM> --supervise -- claude
|
|
35
35
|
```
|
|
36
36
|
- **Boot-persistent service** — survive reboot/logout too. Installs the right
|
|
37
37
|
native service for your OS (launchd on macOS, systemd `--user` on Linux, a
|
|
38
38
|
Startup-folder script on Windows):
|
|
39
39
|
```bash
|
|
40
|
-
npx thinkpool-pair install-service <ROOM> -- claude # set and forget
|
|
41
|
-
npx thinkpool-pair uninstall-service <ROOM> # remove it
|
|
40
|
+
npx thinkpool-pair@latest install-service <ROOM> -- claude # set and forget
|
|
41
|
+
npx thinkpool-pair@latest uninstall-service <ROOM> # remove it
|
|
42
42
|
```
|
|
43
43
|
The service runs `npx thinkpool-pair@latest`, so it **auto-updates** — new
|
|
44
44
|
versions apply on the next restart, no re-install. (Linux: run
|
|
@@ -186,7 +186,7 @@ that format:
|
|
|
186
186
|
> not OpenRouter's Anthropic endpoint.
|
|
187
187
|
|
|
188
188
|
```bash
|
|
189
|
-
npx thinkpool-pair provider custom --base <url> --token <key> [--model <name>]
|
|
189
|
+
npx thinkpool-pair@latest provider custom --base <url> --token <key> [--model <name>]
|
|
190
190
|
```
|
|
191
191
|
|
|
192
192
|
Common base urls (the agent appends `/v1/messages`):
|
|
@@ -203,11 +203,11 @@ Common base urls (the agent appends `/v1/messages`):
|
|
|
203
203
|
> gateway is for. Avoid LiteLLM 1.82.7 / 1.82.8 (compromised releases); use a
|
|
204
204
|
> current version.
|
|
205
205
|
|
|
206
|
-
Show the current provider (`npx thinkpool-pair provider`), or reset back to your
|
|
206
|
+
Show the current provider (`npx thinkpool-pair@latest provider`), or reset back to your
|
|
207
207
|
regular Claude login:
|
|
208
208
|
|
|
209
209
|
```bash
|
|
210
|
-
npx thinkpool-pair provider anthropic
|
|
210
|
+
npx thinkpool-pair@latest provider anthropic
|
|
211
211
|
```
|
|
212
212
|
|
|
213
213
|
Provider config is stored in `~/.thinkpool-pair/provider.json` (mode 0600) and
|
package/account.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { createClient } from '@supabase/supabase-js'
|
|
|
15
15
|
import os from 'node:os'
|
|
16
16
|
import { saveAuth, loadAuth, markAuthReconnectRequired, loadDirs, bindDir, loadServed, rememberServed, loadDefaultDir, saveDefaultDir, claudeRecentProjectDir } from './auth-store.mjs'
|
|
17
17
|
import { isSafeToRestart } from './update-gate.mjs'
|
|
18
|
-
import { makeThrottledTrack, presenceRecoveryEligible, presenceSelfEchoObservation, reducePresenceChannelEvidence } from './presence.mjs'
|
|
18
|
+
import { makeThrottledTrack, presenceRecoveryEligible, presenceSelfEchoObservation, recoveryBackoffMs, reducePresenceChannelEvidence, subscribeWithoutStartupDeadlock } from './presence.mjs'
|
|
19
19
|
import { publicKeyB64, announceProviders, listProviders, addProvider, addProviderModel, removeProvider, unseal } from './providers.mjs'
|
|
20
20
|
import { supervisorServes, supervisorRoomsToStop } from './serve-consent.mjs'
|
|
21
21
|
import { resolveServeDir } from './serve-dir.mjs'
|
|
@@ -24,6 +24,7 @@ import { installedAgentCommands } from './agent-detect.mjs'
|
|
|
24
24
|
import { hostMemoryAdmission } from './host-memory.mjs'
|
|
25
25
|
import { createPairBusBroker, mergePairRoomRoster, pairBusStatusFromRealtime, PAIR_BUS_STATUS } from './pair-bus.mjs'
|
|
26
26
|
import { clearSupervisorReady, supervisorPresenceEchoed, writeSupervisorReady } from './supervisor-ready.mjs'
|
|
27
|
+
import { PAIR_CLI, pairCli } from './command-guidance.mjs'
|
|
27
28
|
|
|
28
29
|
const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return null } })()
|
|
29
30
|
|
|
@@ -121,7 +122,7 @@ export async function runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) {
|
|
|
121
122
|
process.stderr.write(`\n ◆ Link this device to your ThinkPool account.\n Open (signed in) and approve:\n\n ${url}\n\n code: ${user_code}\n\n Waiting…\n`)
|
|
122
123
|
const started = Date.now()
|
|
123
124
|
const poll = async () => {
|
|
124
|
-
if (Date.now() - started > 5 * 60 * 1000) { process.stderr.write(
|
|
125
|
+
if (Date.now() - started > 5 * 60 * 1000) { process.stderr.write(`\n ◇ link timed out — run \`${pairCli('login')}\` again.\n`); process.exit(1) }
|
|
125
126
|
try {
|
|
126
127
|
// A discarded `error` costs one tick — the loop retries in a second, and a
|
|
127
128
|
// permanent failure still lands on the "link timed out" branch above rather than
|
|
@@ -130,10 +131,10 @@ export async function runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) {
|
|
|
130
131
|
const { data: r } = await sb.rpc('claim_device_code', { p_device_code: device_code })
|
|
131
132
|
if (r?.status === 'approved' && r.session?.refresh_token) {
|
|
132
133
|
saveAuth(r.session)
|
|
133
|
-
process.stderr.write(`\n ◆ linked as ${r.session.email || 'your account'}. You can close the browser tab.\n Now run:
|
|
134
|
+
process.stderr.write(`\n ◆ linked as ${r.session.email || 'your account'}. You can close the browser tab.\n Now run: ${PAIR_CLI}\n`)
|
|
134
135
|
process.exit(0)
|
|
135
136
|
}
|
|
136
|
-
if (r?.status === 'expired' || r?.status === 'not_found') { process.stderr.write(
|
|
137
|
+
if (r?.status === 'expired' || r?.status === 'not_found') { process.stderr.write(`\n ◇ link expired — run \`${pairCli('login')}\` again.\n`); process.exit(1) }
|
|
137
138
|
} catch { /* transient — keep polling */ }
|
|
138
139
|
setTimeout(poll, 3000)
|
|
139
140
|
}
|
|
@@ -397,18 +398,18 @@ export function scheduleTokenRefresh({ sb, session, onRefreshed, onReconnectRequ
|
|
|
397
398
|
|
|
398
399
|
export function runBind(room, dir) {
|
|
399
400
|
const r = (room || '').toUpperCase().trim()
|
|
400
|
-
if (!r) { console.error(
|
|
401
|
+
if (!r) { console.error(`usage: ${pairCli('bind', '<ROOM>', '<directory>')}`); process.exit(1) }
|
|
401
402
|
const abs = path.resolve(dir || '.')
|
|
402
403
|
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) { console.error(`✗ not a directory: ${abs}`); process.exit(1) }
|
|
403
404
|
bindDir(r, abs)
|
|
404
|
-
console.error(`✓ bound ${r} → ${abs}\n Run \`
|
|
405
|
+
console.error(`✓ bound ${r} → ${abs}\n Run \`${PAIR_CLI}\` (or restart it) to serve this session.`)
|
|
405
406
|
process.exit(0)
|
|
406
407
|
}
|
|
407
408
|
|
|
408
409
|
// ── account supervisor: a headless child bridge per bound room, in its dir ──
|
|
409
410
|
export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
410
411
|
if (!acquireSingleton()) {
|
|
411
|
-
console.error(
|
|
412
|
+
console.error(`\n ◇ An account bridge is already running on this machine\n (~/.thinkpool-pair/account.lock). Not starting a second — two would race\n your saved login. Stop the other one first, or share a single room with\n \`${pairCli('<ROOM>')}\`.\n`)
|
|
412
413
|
process.exit(0)
|
|
413
414
|
}
|
|
414
415
|
// We own account.lock now, so any previous readiness receipt is stale. A fresh
|
|
@@ -430,7 +431,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
430
431
|
// Reached ONLY when there is no saved login at all (not linked). Stand down CLEANLY
|
|
431
432
|
// with exit 0: launchd KeepAlive has SuccessfulExit=false, so exit 0 is NOT respawned
|
|
432
433
|
// — no crash storm for the "never logged in" case. The user links with `login`.
|
|
433
|
-
console.error(
|
|
434
|
+
console.error(`\n ◇ Not linked to a ThinkPool account on this machine.\n Link it: ${pairCli('login')}\n`)
|
|
434
435
|
process.exit(0)
|
|
435
436
|
}
|
|
436
437
|
if (auth.reconnectRequired) {
|
|
@@ -470,9 +471,9 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
470
471
|
? ' (auto-updating background service — survives reboot, tracks @latest)\n'
|
|
471
472
|
: isManagedService
|
|
472
473
|
? ' (background service — survives reboot, restarts on crash, pinned to this version)\n'
|
|
473
|
-
:
|
|
474
|
+
: ` (run hands-off + auto-updating: ${pairCli('install-service')})\n`
|
|
474
475
|
const _effDefault = loadDefaultDir() || claudeRecentProjectDir() || DEFAULT_DIR
|
|
475
|
-
process.stderr.write(`\n ◆ thinkpool-pair — account mode · ${email}\n New sessions auto-serve from ${_effDefault}${loadDefaultDir() ? ' (your default)' : (claudeRecentProjectDir() ? ' (your most-recent Claude Code project)' : '')}\n (change it:
|
|
476
|
+
process.stderr.write(`\n ◆ thinkpool-pair — account mode · ${email}\n New sessions auto-serve from ${_effDefault}${loadDefaultDir() ? ' (your default)' : (claudeRecentProjectDir() ? ' (your most-recent Claude Code project)' : '')}\n (change it: ${pairCli('set-default-dir', '<dir>')} · one room: ${pairCli('bind', '<code>', '<dir>')})\n${svcHint}`)
|
|
476
477
|
|
|
477
478
|
const children = new Map() // room -> child process
|
|
478
479
|
const childIdle = new Map() // room -> bool (last idle report: in the quiet window)
|
|
@@ -564,27 +565,43 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
564
565
|
// just stops — restart is meaningful for the always-on bridge. Old bridges (pre-0.7.60)
|
|
565
566
|
// don't subscribe to this event, so the dashboard button is a harmless no-op there.
|
|
566
567
|
let restartPreparationRunning = false
|
|
567
|
-
acct.on('broadcast', { event: 'restart' }, ({ payload } = {}) => {
|
|
568
|
+
acct.on('broadcast', { event: 'restart' }, async ({ payload } = {}) => {
|
|
569
|
+
const nonce = payload?.nonce
|
|
570
|
+
const reply = async (ok, error) => {
|
|
571
|
+
try { await acct.send({ type: 'broadcast', event: 'restart-status', payload: { nonce, ok, error } }) } catch { /* channel down */ }
|
|
572
|
+
}
|
|
573
|
+
if (!(await isOwner(payload?.jwt))) {
|
|
574
|
+
await reply(false, 'unauthorized')
|
|
575
|
+
return
|
|
576
|
+
}
|
|
568
577
|
// A dashboard restart is an EXPLICIT apply request, but it is not permission
|
|
569
578
|
// to interrupt a live turn or a pending human decision. Stage the immutable
|
|
570
579
|
// runtime while work continues; applyIfIdle() owns the only destructive edge.
|
|
571
|
-
|
|
572
|
-
|
|
580
|
+
if (restartPreparationRunning || applyingUpdate) {
|
|
581
|
+
await reply(false, 'a bridge update is already being prepared')
|
|
582
|
+
return
|
|
583
|
+
}
|
|
573
584
|
restartPreparationRunning = true
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
} catch {
|
|
582
|
-
pendingUpdate = (typeof payload?.version === 'string' ? payload.version : VERSION) || 'restart'
|
|
583
|
-
} finally {
|
|
584
|
-
restartPreparationRunning = false
|
|
585
|
+
try {
|
|
586
|
+
const svc = await import('./service.mjs')
|
|
587
|
+
const managed = svc.serviceActive(null)
|
|
588
|
+
const staged = managed ? svc.stageServiceUpdate(null) : null
|
|
589
|
+
if (managed && !staged) {
|
|
590
|
+
await reply(false, 'the published runtime could not be staged; the existing bridge was left running')
|
|
591
|
+
return
|
|
585
592
|
}
|
|
586
|
-
|
|
587
|
-
|
|
593
|
+
// The dashboard's advertised version is only a fallback marker for a
|
|
594
|
+
// foreground supervisor. Managed hosts independently resolve and verify npm.
|
|
595
|
+
pendingUpdate = staged || (typeof payload?.version === 'string' ? payload.version : VERSION) || 'restart'
|
|
596
|
+
applyRequested = true
|
|
597
|
+
await reply(true)
|
|
598
|
+
} catch (error) {
|
|
599
|
+
await reply(false, `restart preparation failed: ${error?.message || error}`)
|
|
600
|
+
return
|
|
601
|
+
} finally {
|
|
602
|
+
restartPreparationRunning = false
|
|
603
|
+
}
|
|
604
|
+
void applyIfIdle()
|
|
588
605
|
})
|
|
589
606
|
// Throttled so a realtime reconnect storm can't machine-gun track() into the
|
|
590
607
|
// per-client presence rate limit (ClientPresenceRateLimitReached → channel closed
|
|
@@ -698,10 +715,10 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
698
715
|
// Re-track on EVERY (re)subscribe, not just the first: a realtime reconnect
|
|
699
716
|
// (network blip, or a token swap mid-flight) rejoins the channel and must
|
|
700
717
|
// re-announce, or the dashboard would read "no bridge" until the next restart.
|
|
701
|
-
|
|
718
|
+
const initialAccountRealtime = await subscribeWithoutStartupDeadlock(acct, (st) => {
|
|
702
719
|
if (st === 'SUBSCRIBED') {
|
|
703
720
|
presenceEvidence = reducePresenceChannelEvidence(presenceEvidence, st)
|
|
704
|
-
pushPresence()
|
|
721
|
+
pushPresence()
|
|
705
722
|
}
|
|
706
723
|
else if (st === 'CLOSED' || st === 'CHANNEL_ERROR' || st === 'TIMED_OUT') {
|
|
707
724
|
// Invalidate the old generation before inspecting presenceState again. The
|
|
@@ -714,7 +731,10 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
714
731
|
// The callback stays wired for the channel's life, so later rejoins re-fire it.
|
|
715
732
|
try { sb.realtime?.connect?.() } catch { /* noop */ }
|
|
716
733
|
}
|
|
717
|
-
})
|
|
734
|
+
})
|
|
735
|
+
if (!initialAccountRealtime.connected) {
|
|
736
|
+
process.stderr.write(`\n ◇ account realtime not ready at startup (${initialAccountRealtime.status}) — staying alive and recovering in process.\n`)
|
|
737
|
+
}
|
|
718
738
|
|
|
719
739
|
// Keep the account JWT fresh so the presence socket never gets deauthed at
|
|
720
740
|
// expiry (the 2026-06-17 "No bridge connected while sessions run" bug). On each
|
|
@@ -955,7 +975,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
955
975
|
const why = choice.reason === 'home'
|
|
956
976
|
? `no project directory set — refusing to serve it from your home folder (${choice.dir}); the agent would lose its session context and the room would show "${path.basename(choice.dir)}" as the repo.`
|
|
957
977
|
: 'no directory to serve it from.'
|
|
958
|
-
process.stderr.write(`\n ◇ ${room}: ${why}\n Bind it to its repo:
|
|
978
|
+
process.stderr.write(`\n ◇ ${room}: ${why}\n Bind it to its repo: ${pairCli('bind', room, '<repo-dir>')}\n`)
|
|
959
979
|
}
|
|
960
980
|
continue
|
|
961
981
|
}
|
|
@@ -1158,13 +1178,26 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
1158
1178
|
// wedged tick can't starve it: when the claim has been quiet ≥2 ticks it nudges realtime
|
|
1159
1179
|
// to reconnect, force-clears the re-entrancy guard, and re-issues the heartbeat directly
|
|
1160
1180
|
// (claim_bridge is a plain HTTP RPC — it recovers even while the socket is still flapping).
|
|
1181
|
+
let claimRecoveryAttempt = 0
|
|
1182
|
+
let claimRecoveryNextAt = 0
|
|
1183
|
+
let claimRecoveryInFlight = false
|
|
1161
1184
|
const heartbeatWatch = setInterval(async () => {
|
|
1162
1185
|
if (stopping) return
|
|
1163
|
-
|
|
1164
|
-
|
|
1186
|
+
const now = Date.now()
|
|
1187
|
+
if (!claimLoopWedged({ lastClaimOkAt, now })) {
|
|
1188
|
+
claimRecoveryAttempt = 0; claimRecoveryNextAt = 0
|
|
1189
|
+
return
|
|
1190
|
+
}
|
|
1191
|
+
if (claimRecoveryInFlight || now < claimRecoveryNextAt) return
|
|
1192
|
+
claimRecoveryInFlight = true
|
|
1193
|
+
const waitMs = recoveryBackoffMs(claimRecoveryAttempt)
|
|
1194
|
+
claimRecoveryNextAt = now + waitMs
|
|
1195
|
+
claimRecoveryAttempt += 1
|
|
1196
|
+
console.log(`claim_watchdog sup=${SUP_ID} quiet_ms=${now - lastClaimOkAt} attempt=${claimRecoveryAttempt} next_ms=${waitMs} — reconnecting realtime + re-claiming`)
|
|
1165
1197
|
try { sb.realtime?.connect?.() } catch { /* noop */ }
|
|
1166
1198
|
clearTickGuard() // unwedge the guard so the next scheduled tick can run
|
|
1167
|
-
try { await refreshClaim(); pushPresence() } catch { /* the
|
|
1199
|
+
try { await refreshClaim(); pushPresence() } catch { /* the backed-off attempt retries */ }
|
|
1200
|
+
finally { claimRecoveryInFlight = false }
|
|
1168
1201
|
}, 20_000)
|
|
1169
1202
|
heartbeatWatch.unref?.()
|
|
1170
1203
|
|
package/bridge.mjs
CHANGED
|
@@ -68,6 +68,7 @@ import { requiresSdkAdmission, sdkSmokePassed } from './sdk-admission.mjs'
|
|
|
68
68
|
import { hermesUserInputResponse } from './question-response.mjs'
|
|
69
69
|
import { hostMemoryAdmission } from './host-memory.mjs'
|
|
70
70
|
import { queueAbortBarrier, waitForAbortBarrier } from './abort-turn-barrier.mjs'
|
|
71
|
+
import { PAIR_CLI, pairCli } from './command-guidance.mjs'
|
|
71
72
|
|
|
72
73
|
const STRUCTURED_MODES = new Set(['default', 'acceptEdits', 'plan', 'review', 'bypassPermissions'])
|
|
73
74
|
import { FLOW_CONDUCTOR_PROMPT, FLOW_LANE_PROMPT, FLOW_CODEX_CONDUCTOR_PROMPT, FLOW_CODEX_LANE_PROMPT, buildConductorEnv, assembleCrossWaveContext, buildLanePrompt } from './flow-conductor.mjs'
|
|
@@ -121,7 +122,7 @@ import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSP
|
|
|
121
122
|
import { createStandalonePairResponder, standalonePairIdentity } from './direct-pair-room.mjs'
|
|
122
123
|
import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
|
|
123
124
|
import { supersedeDispatchLease } from './dispatch-lease.mjs'
|
|
124
|
-
import { turnInFlight } from './update-gate.mjs'
|
|
125
|
+
import { realtimeRecoveryDecision, turnInFlight } from './update-gate.mjs'
|
|
125
126
|
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
|
|
126
127
|
import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, ImageEventQueue, imageQueueConfig, uploadCodeImage as uploadCodeImageRequest, usageReportLine, codexUsageReportLine, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
|
|
127
128
|
import { createLatestReplayPump, requestedReplayIds } from './replay-transport.mjs'
|
|
@@ -130,7 +131,7 @@ import { fetchPairControlDeliveryAuthority } from './pair-control-authority.mjs'
|
|
|
130
131
|
import { SIDE_HANDOFF_PROMPT, SIDE_ROLE_PROMPT, appendSideContext, assistantTextSince, dispatchSideContexts, sideContextBlock, sideSnapshot } from './side-lane.mjs'
|
|
131
132
|
import { planMeterLine } from './plan-meters.mjs'
|
|
132
133
|
import { priceForModel } from './model-prices.mjs'
|
|
133
|
-
import { makeThrottledTrack } from './presence.mjs'
|
|
134
|
+
import { makeThrottledTrack, recoveryBackoffMs } from './presence.mjs'
|
|
134
135
|
import { MockupDeliveryQueue, completeMockupManifest, isMockupDeliveryBoundary } from './mockup-delivery.mjs'
|
|
135
136
|
import { resolveAnonKey, DEFAULT_SUPABASE_URL } from './supabase-key.mjs'
|
|
136
137
|
import { buildTerminalRolePrompt, HERMES_VISIBLE_WORKER_FALLBACK_RULE, THINKPOOL_PROMPT_BUNDLE } from './thinkpool-room-prompt.mjs'
|
|
@@ -292,7 +293,7 @@ if (argv[0] === 'bind') { const { runBind } = await import('./account.mjs'); r
|
|
|
292
293
|
if (argv[0] === 'set-default-dir') {
|
|
293
294
|
const { saveDefaultDir } = await import('./auth-store.mjs')
|
|
294
295
|
let d = (argv[1] || '').trim(); if (d.startsWith('~')) d = d.replace(/^~/, os.homedir())
|
|
295
|
-
if (!d) { console.error(
|
|
296
|
+
if (!d) { console.error(` usage: ${pairCli('set-default-dir', '<dir>')}`); process.exit(1) }
|
|
296
297
|
saveDefaultDir(d)
|
|
297
298
|
console.log(` ◆ default project dir set → ${d}\n New/unbound sessions auto-serve from here — no per-session bind. Takes effect next serve tick.`)
|
|
298
299
|
process.exit(0)
|
|
@@ -358,8 +359,9 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
|
|
|
358
359
|
child.on('exit', (code) => process.exit(code == null ? 0 : code))
|
|
359
360
|
}),
|
|
360
361
|
serveAccountForeground: async () => { const { runAccount } = await import('./account.mjs'); await runAccount(SUPABASE_URL, SUPABASE_ANON) },
|
|
361
|
-
// Confirmed installs/updates
|
|
362
|
-
//
|
|
362
|
+
// Confirmed installs/updates continue in the exact runtime just proven live. This
|
|
363
|
+
// keeps the person inside the npx menu without letting the stale installer claim
|
|
364
|
+
// its in-memory VERSION matches the newly installed managed service.
|
|
363
365
|
installService: ({ room = null, agentCmd } = {}) => svc.installAndConfirmService(room, agentCmd ? [agentCmd] : []),
|
|
364
366
|
uninstallService: ({ room = null } = {}) => { svc.uninstallService(room) },
|
|
365
367
|
restartService: ({ room = null } = {}) => { svc.restartService(room) },
|
|
@@ -369,6 +371,27 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
|
|
|
369
371
|
restartUpdateService: async ({ room = null } = {}) => process.platform === 'win32'
|
|
370
372
|
? svc.updateService(room)
|
|
371
373
|
: svc.updateAndConfirmService(room),
|
|
374
|
+
relaunchLauncher: async () => {
|
|
375
|
+
const installedVersion = svc.serviceRuntimeVersion(null)
|
|
376
|
+
if (!installedVersion) {
|
|
377
|
+
io.print('\n ⚠ the managed runtime was confirmed earlier but could not be resolved for launcher handoff; staying in this launcher.')
|
|
378
|
+
return false
|
|
379
|
+
}
|
|
380
|
+
let entry = ''
|
|
381
|
+
try { entry = svc.provisionRuntime(installedVersion) }
|
|
382
|
+
catch (error) {
|
|
383
|
+
io.print(`\n ⚠ could not open the verified v${installedVersion} launcher: ${error?.message || error}`)
|
|
384
|
+
return false
|
|
385
|
+
}
|
|
386
|
+
return new Promise((resolve) => {
|
|
387
|
+
const child = spawn(process.execPath, [entry], { stdio: 'inherit', cwd: process.cwd(), env: process.env })
|
|
388
|
+
child.once('error', (error) => {
|
|
389
|
+
io.print(`\n ⚠ launcher handoff failed: ${error?.message || error}`)
|
|
390
|
+
resolve(false)
|
|
391
|
+
})
|
|
392
|
+
child.once('exit', () => resolve(true))
|
|
393
|
+
})
|
|
394
|
+
},
|
|
372
395
|
login: async () => { const { runLogin } = await import('./account.mjs'); await runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) },
|
|
373
396
|
// Provider config is NOT terminal — it writes provider.json and returns to the
|
|
374
397
|
// menu so you can pick a model, switch back, or do something else. (It used to
|
|
@@ -437,7 +460,7 @@ function checkSdkCompat() {
|
|
|
437
460
|
if (!argv[0] || argv[0].startsWith('-')) { const { runAccount } = await import('./account.mjs'); await runAccount(SUPABASE_URL, SUPABASE_ANON) }
|
|
438
461
|
|
|
439
462
|
const room = (argv[0] || '').toUpperCase().trim()
|
|
440
|
-
if (!room) { console.error(
|
|
463
|
+
if (!room) { console.error(`usage: ${pairCli('<ROOM>', '[--headless]', '[--continue|--fresh]', '[-- <command…>]')} | ${PAIR_CLI} (account mode)`); process.exit(1) }
|
|
441
464
|
// Security (audit 2026-07-02): `room` becomes a filesystem path segment under
|
|
442
465
|
// ~/.thinkpool-pair (session-store) AND is interpolated into launchctl/systemd
|
|
443
466
|
// service labels. sessions.id is unconstrained TEXT, so refuse path-unsafe / shell-
|
|
@@ -549,7 +572,7 @@ if (dashIdx >= 0) {
|
|
|
549
572
|
}
|
|
550
573
|
} else if (!headless) {
|
|
551
574
|
if (installedAgents.length === 0) {
|
|
552
|
-
console.error(`\n No known coding-agent CLI found on your PATH.\n Install one (claude / codex / gemini / aider / cursor-agent / opencode …)\n or share a specific command:
|
|
575
|
+
console.error(`\n No known coding-agent CLI found on your PATH.\n Install one (claude / codex / gemini / aider / cursor-agent / opencode …)\n or share a specific command: ${pairCli(room, '--', '<your-command>')}\n`)
|
|
553
576
|
process.exit(1)
|
|
554
577
|
}
|
|
555
578
|
attachedCmd = await pickAgent(installedAgents)
|
|
@@ -831,7 +854,7 @@ let myServeUid = null // this bridge's authed uid (null = anon)
|
|
|
831
854
|
// 401 must NEVER serve, or the gate is bypassed (DEFECT 1). gateFailAction classifies.
|
|
832
855
|
if (gateFailAction(e) === 'refuse') {
|
|
833
856
|
process.stderr.write(refusalMessage({ room, name: null, reason: 'no-identity' }))
|
|
834
|
-
process.stderr.write(`\n ◇ (serve-consent: auth rejected at the gate — ${e.message}; the stored login is expired or revoked. Re-run:
|
|
857
|
+
process.stderr.write(`\n ◇ (serve-consent: auth rejected at the gate — ${e.message}; the stored login is expired or revoked. Re-run: ${pairCli('login')})\n`)
|
|
835
858
|
process.exit(0)
|
|
836
859
|
}
|
|
837
860
|
// Availability failure → fail OPEN (spec: never strand a user on a flaky fetch).
|
|
@@ -1402,7 +1425,7 @@ let standalonePairChannel = null // standalone owner bridge's direct paired-sess
|
|
|
1402
1425
|
// standalone bridge (no supervisor) or a disabled/timed-out request returns { error }.
|
|
1403
1426
|
function pairRequest(type, payload = {}, timeoutMs = 4000) {
|
|
1404
1427
|
if (process.env.TP_CROSSROOM_OFF === '1') return Promise.resolve({ error: 'Cross-session reach is turned off in this room.' })
|
|
1405
|
-
if (!IS_ACCOUNT_CHILD) return Promise.resolve({ error:
|
|
1428
|
+
if (!IS_ACCOUNT_CHILD) return Promise.resolve({ error: `Cross-session reach needs the ThinkPool account bridge running your sessions (${PAIR_CLI}). This room is running standalone, so it can only see its own terminals — use read_terminal.` })
|
|
1406
1429
|
const reqId = randomUUID()
|
|
1407
1430
|
return new Promise((resolve) => {
|
|
1408
1431
|
const timer = setTimeout(() => { pairWaiters.delete(reqId); resolve({ error: 'The other session did not respond in time.' }) }, timeoutMs)
|
|
@@ -5181,15 +5204,46 @@ designChannel
|
|
|
5181
5204
|
if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') process.stderr.write(`\n ⚠ design realtime ${status} (tpdesign:${room}).\n`)
|
|
5182
5205
|
})
|
|
5183
5206
|
|
|
5184
|
-
// Watchdog —
|
|
5185
|
-
//
|
|
5186
|
-
//
|
|
5187
|
-
|
|
5188
|
-
|
|
5189
|
-
|
|
5207
|
+
// Watchdog — restart only for a Realtime-only wedge. A total network/Supabase
|
|
5208
|
+
// outage cannot be repaired by killing the room process; doing so destroys local
|
|
5209
|
+
// agent runtimes and leaves the supervisor unable to rediscover the room until HTTP
|
|
5210
|
+
// returns. Probe the public health endpoint with capped backoff, preserve agents while
|
|
5211
|
+
// offline or mid-turn, and cross the destructive edge only when HTTP proves healthy.
|
|
5212
|
+
let realtimeRecoveryInFlight = false
|
|
5213
|
+
let realtimeRecoveryAttempt = 0
|
|
5214
|
+
let realtimeRecoveryNextAt = 0
|
|
5215
|
+
const probeSupabaseHttp = async () => {
|
|
5216
|
+
const controller = new AbortController()
|
|
5217
|
+
const timer = setTimeout(() => controller.abort(), 5000)
|
|
5218
|
+
try {
|
|
5219
|
+
const response = await fetch(`${SUPABASE_URL}/auth/v1/health`, { headers: { apikey: SUPABASE_ANON }, signal: controller.signal })
|
|
5220
|
+
return response.ok
|
|
5221
|
+
} catch { return false }
|
|
5222
|
+
finally { clearTimeout(timer) }
|
|
5223
|
+
}
|
|
5224
|
+
setInterval(async () => {
|
|
5225
|
+
if (realtimeHealthy || !brokenSince) {
|
|
5226
|
+
realtimeRecoveryAttempt = 0; realtimeRecoveryNextAt = 0
|
|
5227
|
+
return
|
|
5228
|
+
}
|
|
5229
|
+
const now = Date.now()
|
|
5230
|
+
if (now - brokenSince < 60_000 || realtimeRecoveryInFlight || now < realtimeRecoveryNextAt) return
|
|
5231
|
+
realtimeRecoveryInFlight = true
|
|
5232
|
+
const httpHealthy = await probeSupabaseHttp()
|
|
5233
|
+
const decision = realtimeRecoveryDecision({ brokenSince, now: Date.now(), httpHealthy, activeTurn: turnInFlight(sessions) })
|
|
5234
|
+
if (decision === 'restart') {
|
|
5235
|
+
process.stderr.write('\n ⚠ realtime wedged while HTTP is healthy — exiting for a clean restart.\n')
|
|
5190
5236
|
shutdown(1) // reap PTYs + attempt presence-leave (was a raw exit → orphaned children)
|
|
5237
|
+
return
|
|
5191
5238
|
}
|
|
5192
|
-
|
|
5239
|
+
const waitMs = recoveryBackoffMs(realtimeRecoveryAttempt)
|
|
5240
|
+
realtimeRecoveryAttempt += 1
|
|
5241
|
+
realtimeRecoveryNextAt = Date.now() + waitMs
|
|
5242
|
+
process.stderr.write(`\n ◇ realtime unavailable (${decision}) — preserving local agents; retrying in ${Math.round(waitMs / 1000)}s.\n`)
|
|
5243
|
+
try { supabase.realtime?.disconnect?.() } catch { /* noop */ }
|
|
5244
|
+
try { supabase.realtime?.connect?.() } catch { /* noop */ }
|
|
5245
|
+
realtimeRecoveryInFlight = false
|
|
5246
|
+
}, 15_000).unref()
|
|
5193
5247
|
|
|
5194
5248
|
// ── auto-update (OS-service tier only) ─────────────────────────────────
|
|
5195
5249
|
// launchd (KeepAlive) and systemd (Restart=always) re-run
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Canonical, cache-busting invocation for every command printed to people.
|
|
2
|
+
export const PAIR_CLI = 'npx thinkpool-pair@latest'
|
|
3
|
+
|
|
4
|
+
export function pairCli(...args) {
|
|
5
|
+
return [PAIR_CLI, ...args.filter((arg) => arg !== undefined && arg !== null && String(arg).length > 0)]
|
|
6
|
+
.map(String)
|
|
7
|
+
.join(' ')
|
|
8
|
+
}
|
package/launcher.mjs
CHANGED
|
@@ -285,18 +285,20 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
285
285
|
else if (pick.key === 'service') {
|
|
286
286
|
if (await ensureLoggedIn()) {
|
|
287
287
|
const installed = await actions.installService({ room: null })
|
|
288
|
-
if (installed === true)
|
|
288
|
+
if (installed === true) {
|
|
289
|
+
if (!actions.relaunchLauncher || await actions.relaunchLauncher()) return
|
|
290
|
+
}
|
|
289
291
|
resync()
|
|
290
292
|
}
|
|
291
293
|
}
|
|
292
294
|
else if (pick.key === 'restart') {
|
|
293
295
|
const updated = await actions.restartUpdateService({ room: null })
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
296
|
+
// Keep the person inside the launcher. A confirmed service update may have
|
|
297
|
+
// installed a newer package than this process has in memory, so hand the same
|
|
298
|
+
// TTY to the exact verified runtime instead of dumping back to the shell.
|
|
299
|
+
if (updated === true) {
|
|
300
|
+
if (!actions.relaunchLauncher || await actions.relaunchLauncher()) return
|
|
301
|
+
}
|
|
300
302
|
resync()
|
|
301
303
|
}
|
|
302
304
|
else if (pick.key === 'uninstall') { await actions.uninstallService({ room: null }); resync() }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.321",
|
|
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": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bridge.mjs",
|
|
11
|
+
"command-guidance.mjs",
|
|
11
12
|
"abort-turn-barrier.mjs",
|
|
12
13
|
"host-memory.mjs",
|
|
13
14
|
"sdk-smoke.mjs",
|
package/presence.mjs
CHANGED
|
@@ -57,6 +57,37 @@ export function makeThrottledTrack(channel, { minMs = 5000, onStatus = null } =
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// A service process must not wedge its entire startup behind the first Realtime
|
|
61
|
+
// SUBSCRIBED callback. Settle the startup gate on success, a terminal channel state,
|
|
62
|
+
// or a bounded timeout while leaving the callback attached for later auto-rejoins.
|
|
63
|
+
export function subscribeWithoutStartupDeadlock(channel, onStatus, {
|
|
64
|
+
timeoutMs = 15_000,
|
|
65
|
+
setTimer = setTimeout,
|
|
66
|
+
clearTimer = clearTimeout,
|
|
67
|
+
} = {}) {
|
|
68
|
+
if (!channel?.subscribe || typeof onStatus !== 'function') return Promise.resolve({ connected: false, status: 'unavailable' })
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
let settled = false
|
|
71
|
+
const finish = (connected, status) => {
|
|
72
|
+
if (settled) return
|
|
73
|
+
settled = true
|
|
74
|
+
clearTimer(timer)
|
|
75
|
+
resolve({ connected, status })
|
|
76
|
+
}
|
|
77
|
+
const timer = setTimer(() => finish(false, 'STARTUP_TIMEOUT'), Math.max(1, Number(timeoutMs) || 1))
|
|
78
|
+
channel.subscribe((status) => {
|
|
79
|
+
onStatus(status)
|
|
80
|
+
if (status === 'SUBSCRIBED') finish(true, status)
|
|
81
|
+
else if (status === 'CLOSED' || status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') finish(false, status)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function recoveryBackoffMs(attempt, { baseMs = 20_000, capMs = 300_000 } = {}) {
|
|
87
|
+
const n = Math.max(0, Math.floor(Number(attempt) || 0))
|
|
88
|
+
return Math.min(Math.max(1, Number(capMs) || 1), Math.max(1, Number(baseMs) || 1) * (2 ** Math.min(n, 10)))
|
|
89
|
+
}
|
|
90
|
+
|
|
60
91
|
// A RealtimeChannel keeps its last presenceState() locally. After CHANNEL_ERROR /
|
|
61
92
|
// CLOSED that cache can still contain our own key even though the server (and every
|
|
62
93
|
// dashboard subscriber) has already removed it. Treat every subscribe/error edge as
|
package/provider.mjs
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
import os from 'node:os'
|
|
32
32
|
import fs from 'node:fs'
|
|
33
33
|
import path from 'node:path'
|
|
34
|
+
import { pairCli } from './command-guidance.mjs'
|
|
34
35
|
|
|
35
36
|
const DIR = path.join(os.homedir(), '.thinkpool-pair')
|
|
36
37
|
const FILE = path.join(DIR, 'provider.json')
|
|
@@ -68,7 +69,7 @@ export function applyProviderEnv() {
|
|
|
68
69
|
const authToken = process.env.TP_ANTHROPIC_AUTH_TOKEN || cfg.authToken
|
|
69
70
|
const model = process.env.TP_ANTHROPIC_MODEL || cfg.model
|
|
70
71
|
if (!baseUrl || !authToken) {
|
|
71
|
-
process.stderr.write(`\n ◇ provider "${provider}" is set but is missing baseUrl/authToken.\n Finish setup:
|
|
72
|
+
process.stderr.write(`\n ◇ provider "${provider}" is set but is missing baseUrl/authToken.\n Finish setup: ${pairCli('provider', 'custom', '--base', '<url>', '--token', '<key>')}\n Falling back to regular Claude for now.\n`)
|
|
72
73
|
return { provider: 'anthropic', source: 'incomplete' }
|
|
73
74
|
}
|
|
74
75
|
process.env.ANTHROPIC_BASE_URL = baseUrl
|
|
@@ -121,9 +122,9 @@ export async function runProvider(args) {
|
|
|
121
122
|
|
|
122
123
|
console.error(
|
|
123
124
|
'\n usage:\n' +
|
|
124
|
-
'
|
|
125
|
-
'
|
|
126
|
-
'
|
|
125
|
+
` ${pairCli('provider')} # show current\n` +
|
|
126
|
+
` ${pairCli('provider', 'custom', '--base', '<url>', '--token', '<key>', '[--model <m>]')} # any Anthropic-compatible endpoint\n` +
|
|
127
|
+
` ${pairCli('provider', 'anthropic')} # reset to Claude\n\n` +
|
|
127
128
|
' common base urls (SDK appends /v1/messages):\n' +
|
|
128
129
|
' Z.ai GLM → https://api.z.ai/api/anthropic\n' +
|
|
129
130
|
' OpenRouter → https://openrouter.ai/api\n'
|
package/serve-consent.mjs
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
// so even an old bridge can no longer READ a grant (the column is gone) and will refuse
|
|
19
19
|
// as a non-owner. That closes the bypass.
|
|
20
20
|
|
|
21
|
+
import { pairCli } from './command-guidance.mjs'
|
|
22
|
+
|
|
21
23
|
// serveDecision — may THIS bridge serve THIS room?
|
|
22
24
|
// ownerId : code_sessions.owner_id (uuid | null)
|
|
23
25
|
// myUid : this bridge's authed user id (uuid | null) — null when the bridge is anon
|
|
@@ -109,7 +111,7 @@ export function refusalMessage ({ room, name, reason }) {
|
|
|
109
111
|
const label = name ? `"${name}"` : '(unnamed)'
|
|
110
112
|
if (reason === 'no-identity') {
|
|
111
113
|
return `\n ◇ Refusing to serve room ${room} — this bridge is not signed in.\n` +
|
|
112
|
-
` A room is served only by its owner's bridge. Sign in first:
|
|
114
|
+
` A room is served only by its owner's bridge. Sign in first: ${pairCli('login')}\n`
|
|
113
115
|
}
|
|
114
116
|
return `\n ◇ Refusing to serve room ${room} ${label} — it is owned by another person; only their bridge serves it.\n` +
|
|
115
117
|
` Pairing gives you a read-only view across the pair, not hosting rights. There is no override.\n`
|
package/service.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import path from 'node:path'
|
|
|
24
24
|
import { execSync } from 'node:child_process'
|
|
25
25
|
import { hostMemoryAdmission } from './host-memory.mjs'
|
|
26
26
|
import { readSupervisorReady, supervisorReadyMatches } from './supervisor-ready.mjs'
|
|
27
|
+
import { pairCli } from './command-guidance.mjs'
|
|
27
28
|
|
|
28
29
|
// Service identity. Account mode has no room → a single stable id so there's
|
|
29
30
|
// exactly one account service per machine (a second install replaces it).
|
|
@@ -294,6 +295,8 @@ export function buildDarwinReloadHandoff({ targetLabel, targetFile, stagedFile,
|
|
|
294
295
|
const backupFile = `${stagedFile}.previous`
|
|
295
296
|
const statusFile = path.join(os.homedir(), '.thinkpool-pair', 'update-status.json')
|
|
296
297
|
const logFile = path.join(os.homedir(), '.thinkpool-pair', 'update.log')
|
|
298
|
+
const readyFile = path.join(os.homedir(), '.thinkpool-pair', 'account-ready.json')
|
|
299
|
+
const requiresReady = targetLabel === label(null)
|
|
297
300
|
const script = [
|
|
298
301
|
'set -u',
|
|
299
302
|
'dom="gui/$(id -u)"',
|
|
@@ -304,7 +307,10 @@ export function buildDarwinReloadHandoff({ targetLabel, targetFile, stagedFile,
|
|
|
304
307
|
`backup_file=${shq(backupFile)}`,
|
|
305
308
|
`helper_file=${shq(helperFile)}`,
|
|
306
309
|
`expected=${shq(expectedRuntime)}`,
|
|
310
|
+
`version=${shq(version)}`,
|
|
307
311
|
`status=${shq(statusFile)}`,
|
|
312
|
+
`ready=${shq(readyFile)}`,
|
|
313
|
+
`requires_ready=${requiresReady ? '1' : '0'}`,
|
|
308
314
|
'tmp="$status.tmp.$$"',
|
|
309
315
|
'ok=0',
|
|
310
316
|
'live_pid=""',
|
|
@@ -313,11 +319,12 @@ export function buildDarwinReloadHandoff({ targetLabel, targetFile, stagedFile,
|
|
|
313
319
|
'if [ -f "$target_file" ]; then cp "$target_file" "$backup_file" && had_previous=1; fi',
|
|
314
320
|
'launchctl bootout "$dom/$target" 2>/dev/null || true',
|
|
315
321
|
'for i in $(seq 1 40); do launchctl print "$dom/$target" >/dev/null 2>&1 || break; sleep 0.2; done',
|
|
322
|
+
'if [ "$requires_ready" = 1 ]; then rm -f "$ready"; fi',
|
|
316
323
|
'cp "$staged_file" "$target_file"',
|
|
317
324
|
'launchctl enable "$dom/$target" 2>/dev/null || true',
|
|
318
325
|
'for i in $(seq 1 30); do launchctl bootstrap "$dom" "$target_file" 2>/dev/null && break; sleep 0.3; done',
|
|
319
|
-
'for i in $(seq 1 60); 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)"; if [ -n "$pid" ] && 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',
|
|
320
|
-
`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; 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
|
|
326
|
+
'for i in $(seq 1 60); 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',
|
|
327
|
+
`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 60); 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: false, version: String(version), target: String(targetLabel), error: 'new runtime not confirmed; 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 and ready rollback failed', rolledBack: false })}' > "$tmp"; fi; fi`,
|
|
321
328
|
'mv "$tmp" "$status"',
|
|
322
329
|
'rm -f "$staged_file" "$backup_file" "$helper_file"',
|
|
323
330
|
'launchctl bootout "$dom/$helper" >/dev/null 2>&1 || true',
|
|
@@ -335,6 +342,53 @@ export function buildDarwinReloadHandoff({ targetLabel, targetFile, stagedFile,
|
|
|
335
342
|
return { helperLabel, helperFile, backupFile, statusFile, logFile, script, content }
|
|
336
343
|
}
|
|
337
344
|
|
|
345
|
+
// Linux has the same self-replacement hazard as launchd: `systemctl restart` issued
|
|
346
|
+
// from inside the account service can terminate the caller before it observes the new
|
|
347
|
+
// process. Run the destructive transaction in a separate transient user unit, and use
|
|
348
|
+
// the same process-bound account readiness receipt as macOS before committing success.
|
|
349
|
+
export function buildLinuxReloadHandoff({ targetLabel, targetFile, stagedFile, expectedRuntime, version, nonce = `${process.pid}-${Date.now()}` }) {
|
|
350
|
+
if (!/^io\.thinkpool\.pair\.[A-Za-z0-9_.-]+$/.test(String(targetLabel))) throw new Error(`invalid service label: ${targetLabel}`)
|
|
351
|
+
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(version))) throw new Error(`invalid runtime version: ${version}`)
|
|
352
|
+
const safeNonce = String(nonce).replace(/[^A-Za-z0-9-]/g, '') || String(process.pid)
|
|
353
|
+
const helperLabel = `io.thinkpool.pair.update.${safeNonce}`
|
|
354
|
+
const backupFile = `${stagedFile}.previous`
|
|
355
|
+
const statusFile = path.join(os.homedir(), '.thinkpool-pair', 'update-status.json')
|
|
356
|
+
const logFile = path.join(os.homedir(), '.thinkpool-pair', 'update.log')
|
|
357
|
+
const readyFile = path.join(os.homedir(), '.thinkpool-pair', 'account-ready.json')
|
|
358
|
+
const requiresReady = targetLabel === label(null)
|
|
359
|
+
const script = [
|
|
360
|
+
'set -u',
|
|
361
|
+
`target=${shq(targetLabel)}`,
|
|
362
|
+
'unit="$target.service"',
|
|
363
|
+
`target_file=${shq(targetFile)}`,
|
|
364
|
+
`staged_file=${shq(stagedFile)}`,
|
|
365
|
+
`backup_file=${shq(backupFile)}`,
|
|
366
|
+
`expected=${shq(expectedRuntime)}`,
|
|
367
|
+
`version=${shq(version)}`,
|
|
368
|
+
`status=${shq(statusFile)}`,
|
|
369
|
+
`ready=${shq(readyFile)}`,
|
|
370
|
+
`requires_ready=${requiresReady ? '1' : '0'}`,
|
|
371
|
+
'tmp="$status.tmp.$$"',
|
|
372
|
+
'ok=0',
|
|
373
|
+
'had_previous=0',
|
|
374
|
+
'if [ -f "$target_file" ]; then cp "$target_file" "$backup_file" && had_previous=1; fi',
|
|
375
|
+
'systemctl --user stop "$unit" 2>/dev/null || true',
|
|
376
|
+
'if [ "$requires_ready" = 1 ]; then rm -f "$ready"; fi',
|
|
377
|
+
'cp "$staged_file" "$target_file"',
|
|
378
|
+
'systemctl --user daemon-reload',
|
|
379
|
+
'systemctl --user enable "$unit" >/dev/null 2>&1 || true',
|
|
380
|
+
'systemctl --user restart "$unit" >/dev/null 2>&1 || true',
|
|
381
|
+
'live_pid=""',
|
|
382
|
+
'stable=0',
|
|
383
|
+
'for i in $(seq 1 60); 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',
|
|
384
|
+
`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 60); 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: false, version: String(version), target: String(targetLabel), error: 'new runtime not confirmed; 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 and ready rollback failed', rolledBack: false })}' > "$tmp"; fi; fi`,
|
|
385
|
+
'mv "$tmp" "$status"',
|
|
386
|
+
'rm -f "$staged_file" "$backup_file"',
|
|
387
|
+
'exit "$((1-ok))"',
|
|
388
|
+
].join('; ')
|
|
389
|
+
return { helperLabel, backupFile, statusFile, logFile, script }
|
|
390
|
+
}
|
|
391
|
+
|
|
338
392
|
const accountLockFile = () => path.join(os.homedir(), '.thinkpool-pair', 'account.lock')
|
|
339
393
|
const processAlive = (pid) => {
|
|
340
394
|
try { process.kill(pid, 0); return true } catch (error) { return error?.code === 'EPERM' }
|
|
@@ -402,8 +456,25 @@ function startDarwinReloadHandoff(a, version) {
|
|
|
402
456
|
}
|
|
403
457
|
}
|
|
404
458
|
|
|
459
|
+
function startLinuxReloadHandoff(a, version) {
|
|
460
|
+
const stagedFile = path.join(a.logDir, `service-update-${process.pid}-${Date.now()}.service`)
|
|
461
|
+
const h = buildLinuxReloadHandoff({ targetLabel: labelFromFile(a.file), targetFile: a.file, stagedFile, expectedRuntime: a.expectedRuntime, version })
|
|
462
|
+
fs.mkdirSync(path.dirname(h.statusFile), { recursive: true })
|
|
463
|
+
fs.rmSync(h.statusFile, { force: true })
|
|
464
|
+
fs.writeFileSync(stagedFile, a.content)
|
|
465
|
+
try {
|
|
466
|
+
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' })
|
|
467
|
+
process.stderr.write(` ◆ update handoff armed outside the bridge service cgroup — completion is recorded in ${h.statusFile}.\n`)
|
|
468
|
+
return true
|
|
469
|
+
} catch (e) {
|
|
470
|
+
fs.rmSync(stagedFile, { force: true })
|
|
471
|
+
process.stderr.write(` ⚠ could not arm the systemd update handoff; existing service left running: ${e.message}\n`)
|
|
472
|
+
return false
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
405
476
|
function labelFromFile(file) {
|
|
406
|
-
const match = path.basename(file).match(/^(io\.thinkpool\.pair\.[A-Za-z0-9_.-]+)\.plist$/)
|
|
477
|
+
const match = path.basename(file).match(/^(io\.thinkpool\.pair\.[A-Za-z0-9_.-]+)\.(?:plist|service)$/)
|
|
407
478
|
if (!match) throw new Error(`cannot derive service label from ${file}`)
|
|
408
479
|
return match[1]
|
|
409
480
|
}
|
|
@@ -483,6 +554,9 @@ export function installService(room, cmdArgs = [], { autoUpdate = false, version
|
|
|
483
554
|
if (process.platform === 'darwin') {
|
|
484
555
|
process.stderr.write(`\n ◆ staged replacement for ${a.file}\n`)
|
|
485
556
|
if (!startDarwinReloadHandoff(a, version)) return false
|
|
557
|
+
} else if (process.platform === 'linux') {
|
|
558
|
+
process.stderr.write(`\n ◆ staged replacement for ${a.file}\n`)
|
|
559
|
+
if (!startLinuxReloadHandoff(a, version)) return false
|
|
486
560
|
} else {
|
|
487
561
|
fs.writeFileSync(a.file, a.content)
|
|
488
562
|
process.stderr.write(`\n ◆ wrote ${a.file}\n`)
|
|
@@ -499,10 +573,10 @@ export function installService(room, cmdArgs = [], { autoUpdate = false, version
|
|
|
499
573
|
? (process.platform === 'win32'
|
|
500
574
|
? 'auto-update ON: a new thinkpool-pair@latest applies on the next login/restart (Windows has no in-process self-update).'
|
|
501
575
|
: `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).`)
|
|
502
|
-
: `PINNED to ${version} (stable local runtime; restarts do not depend on npx or the network). To update: use "Restart & update the bridge" (or
|
|
576
|
+
: `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.`
|
|
503
577
|
const removeArg = room ? ` ${room}` : ''
|
|
504
578
|
const what = room ? `room ${room}` : 'your account (auto-serves every session)'
|
|
505
|
-
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:
|
|
579
|
+
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: ${pairCli('uninstall-service')}${removeArg}\n\n`)
|
|
506
580
|
return true
|
|
507
581
|
}
|
|
508
582
|
|
|
@@ -524,7 +598,7 @@ export async function installAndConfirmService(room, cmdArgs = [], {
|
|
|
524
598
|
},
|
|
525
599
|
now = Date.now,
|
|
526
600
|
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
527
|
-
timeoutMs =
|
|
601
|
+
timeoutMs = 85000,
|
|
528
602
|
pollMs = 250,
|
|
529
603
|
stderr = process.stderr,
|
|
530
604
|
} = {}) {
|
|
@@ -540,7 +614,7 @@ export async function installAndConfirmService(room, cmdArgs = [], {
|
|
|
540
614
|
|
|
541
615
|
const confirmation = () => {
|
|
542
616
|
const live = snapshot(room, { platform, exec })
|
|
543
|
-
const ready = !!room || platform
|
|
617
|
+
const ready = !!room || platform === 'win32' || supervisorReadyMatches(readReady(), live, version, { notBefore: confirmationStartedAt })
|
|
544
618
|
if (live?.version === version && ready) {
|
|
545
619
|
stderr.write(` ✓ bridge install confirmed — running v${version}.\n`)
|
|
546
620
|
return true
|
|
@@ -548,14 +622,10 @@ export async function installAndConfirmService(room, cmdArgs = [], {
|
|
|
548
622
|
return false
|
|
549
623
|
}
|
|
550
624
|
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
const confirmed = confirmation()
|
|
556
|
-
if (!confirmed) stderr.write(` ⚠ install v${version} was not confirmed: the service manager cannot prove that runtime is running.\n`)
|
|
557
|
-
return confirmed
|
|
558
|
-
}
|
|
625
|
+
// Windows has no service-manager process identity. macOS and Linux both cross an
|
|
626
|
+
// asynchronous external-helper boundary and must wait for its receipt plus account
|
|
627
|
+
// readiness; a live PID alone is not a connected bridge.
|
|
628
|
+
if (platform === 'win32') return true
|
|
559
629
|
|
|
560
630
|
const deadline = now() + Math.max(0, Number(timeoutMs) || 0)
|
|
561
631
|
const expectedTarget = label(room)
|
|
@@ -676,7 +746,7 @@ export async function updateAndConfirmService(room, {
|
|
|
676
746
|
},
|
|
677
747
|
now = Date.now,
|
|
678
748
|
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
679
|
-
timeoutMs =
|
|
749
|
+
timeoutMs = 85000,
|
|
680
750
|
pollMs = 250,
|
|
681
751
|
stderr = process.stderr,
|
|
682
752
|
} = {}) {
|
|
@@ -699,7 +769,7 @@ export async function updateAndConfirmService(room, {
|
|
|
699
769
|
const live = snapshot(room, { platform, exec })
|
|
700
770
|
const versionChanged = !!before && before.version !== target
|
|
701
771
|
const processChanged = !!before?.pid && !!live?.pid && before.pid !== live.pid
|
|
702
|
-
const ready = !!room || platform
|
|
772
|
+
const ready = !!room || platform === 'win32' || supervisorReadyMatches(readReady(), live, target, { notBefore: confirmationStartedAt })
|
|
703
773
|
if (live?.version === target && (versionChanged || processChanged) && ready) {
|
|
704
774
|
stderr.write(` ✓ bridge restart confirmed — running v${target}.\n`)
|
|
705
775
|
return { ok: true, live }
|
|
@@ -712,14 +782,10 @@ export async function updateAndConfirmService(room, {
|
|
|
712
782
|
else stderr.write(` ⚠ update v${target} was not confirmed: the service manager cannot prove the new runtime is running.\n`)
|
|
713
783
|
}
|
|
714
784
|
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
if (platform
|
|
719
|
-
const proof = confirmation()
|
|
720
|
-
if (!proof.ok) printUnconfirmed(proof)
|
|
721
|
-
return proof.ok
|
|
722
|
-
}
|
|
785
|
+
// The Windows Startup-folder tier has no authoritative live-service identity, so
|
|
786
|
+
// callers use updateService directly and label it as a next-launch update. Both
|
|
787
|
+
// managed Unix tiers are asynchronous external handoffs and share the receipt loop.
|
|
788
|
+
if (platform === 'win32') return true
|
|
723
789
|
|
|
724
790
|
const deadline = now() + Math.max(0, Number(timeoutMs) || 0)
|
|
725
791
|
const expectedTarget = label(room)
|
package/update-gate.mjs
CHANGED
|
@@ -40,3 +40,14 @@ export function turnInFlight(sessions) {
|
|
|
40
40
|
}
|
|
41
41
|
return false
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
// Restarting a room process helps only when the HTTP control plane is reachable and
|
|
45
|
+
// Realtime alone is wedged. During a total network outage it destroys healthy local
|
|
46
|
+
// agent processes and cannot improve connectivity; a live turn or permission decision
|
|
47
|
+
// is likewise a hard hold even when HTTP is healthy.
|
|
48
|
+
export function realtimeRecoveryDecision({ brokenSince, now, httpHealthy, activeTurn, graceMs = 60_000 }) {
|
|
49
|
+
if (!brokenSince || now - brokenSince < graceMs) return 'wait'
|
|
50
|
+
if (!httpHealthy) return 'hold-offline'
|
|
51
|
+
if (activeTurn) return 'hold-active'
|
|
52
|
+
return 'restart'
|
|
53
|
+
}
|