thinkpool-pair 0.7.364 → 0.7.366
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +18 -0
- package/README.md +20 -0
- package/abort-turn-barrier.mjs +1 -23
- package/account.mjs +1 -1437
- package/acp-client.mjs +1 -140
- package/agent-detect.mjs +1 -28
- package/agent-notify.mjs +1 -142
- package/agent-visibility.mjs +1 -67
- package/auth-store.mjs +1 -120
- package/bridge.mjs +1 -6266
- package/byok-detect.mjs +1 -126
- package/claude-command-catalog.mjs +1 -91
- package/claude-session.mjs +1 -1519
- package/code-event-contract.mjs +1 -118
- package/codex-app-server.mjs +1 -340
- package/codex-commands.mjs +1 -83
- package/codex-event-mapper.mjs +1 -232
- package/codex-images.mjs +1 -69
- package/codex-mcp-http.mjs +1 -131
- package/codex-session.mjs +1 -1295
- package/command-catalog.mjs +1 -118
- package/command-guidance.mjs +1 -8
- package/context-contract.mjs +1 -95
- package/context-windows.mjs +1 -107
- package/cross-terminal.mjs +1 -789
- package/cumulative-event-relay.mjs +1 -53
- package/design-edit.mjs +1 -424
- package/design-source-contract.mjs +1 -4
- package/direct-pair-room.mjs +1 -57
- package/dispatch-lease.mjs +1 -37
- package/dispatch-permission-cleanup.mjs +1 -86
- package/edit-diff.mjs +1 -136
- package/error-recovery.mjs +1 -50
- package/event-bounds.mjs +1 -121
- package/event-delivery-queue.mjs +1 -60
- package/event-id.mjs +1 -549
- package/evidence-citations.mjs +1 -50
- package/evidence-compact.mjs +1 -11
- package/flow-assembly.mjs +1 -196
- package/flow-budget.mjs +1 -84
- package/flow-conductor.mjs +1 -259
- package/flow-context-store.mjs +1 -387
- package/flow-host-revert.mjs +1 -42
- package/flow-models.mjs +1 -139
- package/flow-preview.mjs +1 -148
- package/flow-receipt.mjs +1 -122
- package/flow-redispatch.mjs +1 -72
- package/flow-review-gate.mjs +1 -402
- package/flow-review-reflect.mjs +1 -115
- package/flow-review.mjs +1 -152
- package/flow-scope-evidence.mjs +1 -117
- package/flow-skill-registry.mjs +1 -140
- package/flow-task-graph.mjs +1 -562
- package/flow-worktree.mjs +1 -71
- package/git-diff-report.mjs +1 -121
- package/hermes-delegation-guard.mjs +1 -14
- package/hermes-event-mapper.mjs +1 -194
- package/hermes-isolation.mjs +1 -61
- package/hermes-model-cache.mjs +1 -54
- package/hermes-policy.mjs +1 -92
- package/hermes-probe.mjs +1 -57
- package/hermes-session.mjs +1 -683
- package/hermes-setup.mjs +1 -167
- package/host-memory.mjs +1 -116
- package/interrupted-resume.mjs +1 -95
- package/keep-awake.mjs +1 -148
- package/key-shape.mjs +1 -49
- package/lane-continuation.mjs +1 -83
- package/lane-lifecycle.mjs +1 -189
- package/lane-worktree.mjs +1 -77
- package/launcher.mjs +1 -354
- package/mcp-flight-recorder.mjs +1 -79
- package/mockup-delivery.mjs +1 -57
- package/model-prices.mjs +1 -113
- package/package.json +13 -4
- package/pair-bus.mjs +1 -98
- package/pair-control-authority.mjs +1 -89
- package/past-work-search.mjs +1 -105
- package/plan-meters.mjs +1 -144
- package/presence.mjs +1 -191
- package/privacy-report.mjs +1 -108
- package/provider-resilience.mjs +1 -356
- package/provider.mjs +1 -133
- package/providers.mjs +1 -491
- package/publish-guard.mjs +2 -0
- package/publish-manifest.json +129 -0
- package/question-response.mjs +1 -58
- package/reap-terminal.mjs +1 -68
- package/recap.mjs +1 -297
- package/replay-transport.mjs +1 -64
- package/repo-search.mjs +1 -190
- package/review-check.mjs +1 -182
- package/runtime-contract.mjs +1 -93
- package/runtime-registry.mjs +1 -64
- package/runtime-session.mjs +1 -20
- package/scheduled-run-admission.mjs +1 -364
- package/scheduled-runs.mjs +1 -268
- package/sdk-admission.mjs +1 -9
- package/sdk-smoke.mjs +1 -61
- package/serve-consent.mjs +1 -118
- package/serve-dir.mjs +1 -40
- package/service.mjs +1 -877
- package/session-store.mjs +1 -484
- package/side-lane.mjs +1 -63
- package/supabase-key.mjs +1 -176
- package/supervisor-ready.mjs +1 -57
- package/switch-provider.mjs +1 -84
- package/terminal-name.mjs +1 -359
- package/terminal-row-reconcile.mjs +1 -70
- package/thinkpool-prompt-contracts.mjs +1 -85
- package/thinkpool-room-prompt.mjs +1 -187
- package/transcript-sanitize.mjs +1 -332
- package/turn-stall.mjs +1 -61
- package/update-gate.mjs +1 -53
- package/viewport.mjs +1 -810
- package/worker-completion.mjs +1 -57
package/account.mjs
CHANGED
|
@@ -1,1437 +1 @@
|
|
|
1
|
-
/* account.mjs — account-mode bridge (Phase 1).
|
|
2
|
-
One `npx thinkpool-pair` per ACCOUNT instead of per room:
|
|
3
|
-
login — link this device to your ThinkPool account (realtime handoff).
|
|
4
|
-
bind — map a room code → a working directory on THIS machine.
|
|
5
|
-
account — discover your code sessions and serve each one (a headless child
|
|
6
|
-
bridge per room, run in its bound directory). Reuses the proven
|
|
7
|
-
single-room bridge verbatim; the supervisor only adds discovery.
|
|
8
|
-
Spec: docs/specs/2026-06-16-account-bridge.md */
|
|
9
|
-
import { spawn } from 'node:child_process'
|
|
10
|
-
import { fileURLToPath } from 'node:url'
|
|
11
|
-
import { randomUUID } from 'node:crypto'
|
|
12
|
-
import path from 'node:path'
|
|
13
|
-
import fs from 'node:fs'
|
|
14
|
-
import { createClient } from '@supabase/supabase-js'
|
|
15
|
-
import os from 'node:os'
|
|
16
|
-
import { saveAuth, loadAuth, markAuthReconnectRequired, loadDirs, bindDir, loadServed, rememberServed, loadDefaultDir, saveDefaultDir, claudeRecentProjectDir } from './auth-store.mjs'
|
|
17
|
-
import { isSafeToRestart } from './update-gate.mjs'
|
|
18
|
-
import { makeThrottledTrack, presenceRecoveryEligible, presenceSelfEchoObservation, recoveryBackoffMs, reducePresenceChannelEvidence, subscribeWithoutStartupDeadlock } from './presence.mjs'
|
|
19
|
-
import { publicKeyB64, announceProviders, listProviders, addProvider, addProviderModel, removeProvider, unseal } from './providers.mjs'
|
|
20
|
-
import { supervisorServes, supervisorRoomsToStop } from './serve-consent.mjs'
|
|
21
|
-
import { resolveServeDir } from './serve-dir.mjs'
|
|
22
|
-
import { pairKeyFor, pairTopic, CROSSROOM_BUS } from './cross-terminal.mjs'
|
|
23
|
-
import { installedAgentCommands } from './agent-detect.mjs'
|
|
24
|
-
import { agentVisibilityStates, setAgentPickerVisibility } from './agent-visibility.mjs'
|
|
25
|
-
import { hostMemoryAdmission } from './host-memory.mjs'
|
|
26
|
-
import { createPairBusBroker, mergePairRoomRoster, pairBusStatusFromRealtime, PAIR_BUS_STATUS } from './pair-bus.mjs'
|
|
27
|
-
import { clearSupervisorReady, supervisorPresenceEchoed, writeSupervisorReady } from './supervisor-ready.mjs'
|
|
28
|
-
import { PAIR_CLI, pairCli } from './command-guidance.mjs'
|
|
29
|
-
import { syncRealtimeAuth } from './design-edit.mjs'
|
|
30
|
-
|
|
31
|
-
const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return null } })()
|
|
32
|
-
|
|
33
|
-
const BRIDGE = fileURLToPath(new URL('./bridge.mjs', import.meta.url))
|
|
34
|
-
|
|
35
|
-
// Startup can spend several seconds waiting on the cross-process refresh lock or
|
|
36
|
-
// backing off after a transient auth failure. Those sleeps are intentionally unref'd
|
|
37
|
-
// once the supervisor is running, but before Realtime + the 15s tick exist they leave
|
|
38
|
-
// an unresolved top-level await as Node's only work; Node 22 exits 13 with
|
|
39
|
-
// "Detected unsettled top-level await". Hold one referenced timer only for bootstrap,
|
|
40
|
-
// then release it as soon as the supervisor's real lifetime interval exists.
|
|
41
|
-
export function createStartupAnchor({ set = setInterval, clear = clearInterval } = {}) {
|
|
42
|
-
const timer = set(() => {}, 60_000)
|
|
43
|
-
let released = false
|
|
44
|
-
return () => {
|
|
45
|
-
if (released) return
|
|
46
|
-
released = true
|
|
47
|
-
clear(timer)
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// ── single-instance lock ────────────────────────────────────────────────────
|
|
52
|
-
// Two account supervisors on one machine share ONE ~/.thinkpool-pair/auth.json
|
|
53
|
-
// refresh token and race its rotation — which on 2026-06-17 deleted a live Pro
|
|
54
|
-
// login mid-session. The account supervisor is single-instance by nature; this
|
|
55
|
-
// pidfile lock refuses a second one. Acquired BEFORE auth so two can't both hit
|
|
56
|
-
// the refresh path. Exported for the test harness.
|
|
57
|
-
const LOCK = path.join(os.homedir(), '.thinkpool-pair', 'account.lock')
|
|
58
|
-
|
|
59
|
-
// True if `pid` is a live process (EPERM = exists but not ours; ESRCH = gone).
|
|
60
|
-
function pidAlive(pid) {
|
|
61
|
-
if (!pid || pid === process.pid) return false
|
|
62
|
-
try { process.kill(pid, 0); return true } catch (e) { return e.code === 'EPERM' }
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Returns true only if we demonstrably hold the lock. A stale lock (holder dead /
|
|
66
|
-
// garbage / ours) is taken over. Filesystem errors fail CLOSED: serving without a
|
|
67
|
-
// lock can start a second bridge, and duplicate bridges can race terminal reconcile
|
|
68
|
-
// and delete room history. A temporarily unmanageable lock is safer than that.
|
|
69
|
-
export function acquireSingleton(lockPath = LOCK) {
|
|
70
|
-
try { fs.mkdirSync(path.dirname(lockPath), { recursive: true }) } catch { return false }
|
|
71
|
-
for (let i = 0; i < 3; i++) {
|
|
72
|
-
try {
|
|
73
|
-
const fd = fs.openSync(lockPath, 'wx') // atomic create-exclusive
|
|
74
|
-
fs.writeSync(fd, String(process.pid)); fs.closeSync(fd)
|
|
75
|
-
return true
|
|
76
|
-
} catch (e) {
|
|
77
|
-
if (e.code !== 'EEXIST') return false // cannot prove ownership → refuse to serve
|
|
78
|
-
let pid = 0
|
|
79
|
-
try { pid = parseInt(fs.readFileSync(lockPath, 'utf8').trim(), 10) || 0 } catch { return false }
|
|
80
|
-
if (pidAlive(pid)) return false // a live supervisor owns it → refuse
|
|
81
|
-
try { fs.rmSync(lockPath, { force: true }) } catch { return false } // stale but unmanageable
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
return false
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Release only if WE own it (don't unlink a lock another instance took over).
|
|
88
|
-
export function releaseSingleton(lockPath = LOCK) {
|
|
89
|
-
try { if ((parseInt(fs.readFileSync(lockPath, 'utf8').trim(), 10) || 0) === process.pid) fs.rmSync(lockPath, { force: true }) } catch { /* noop */ }
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// ── machine-wide single-bridge lock ─────────────────────────────────────────
|
|
93
|
-
// The SAME pidfile is now the machine-wide "one live bridge" lock, not just the
|
|
94
|
-
// account-supervisor guard: a standalone `npx thinkpool-pair <ROOM>` takes it too
|
|
95
|
-
// (see bridge.mjs, post-`--supervise`). Two bridges serving one room race its
|
|
96
|
-
// terminal reconcile, which reaps + CASCADE-deletes terminal history (the XE3IQ
|
|
97
|
-
// "flat search" loss, 2026-06-25). Account mode and standalone room mode share
|
|
98
|
-
// this lock; an account-spawned room child skips it because its parent
|
|
99
|
-
// supervisor already holds it. Wrappers so bridge.mjs need not know the path.
|
|
100
|
-
export function acquireMachineLock(lockPath = LOCK) { return acquireSingleton(lockPath) }
|
|
101
|
-
export function releaseMachineLock(lockPath = LOCK) { return releaseSingleton(lockPath) }
|
|
102
|
-
|
|
103
|
-
// A served bridge process must hold the machine lock UNLESS it's a room child the
|
|
104
|
-
// account supervisor spawned (THINKPOOL_PAIR_ACCOUNT_CHILD=1) — the parent holds
|
|
105
|
-
// it, and a child fighting its parent for the lock would refuse itself and break
|
|
106
|
-
// account mode. Pure (env in) so the policy is unit-testable.
|
|
107
|
-
export function shouldHoldMachineLock(env = process.env) {
|
|
108
|
-
return env.THINKPOOL_PAIR_ACCOUNT_CHILD !== '1'
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// ── login: OAuth-style DEVICE-CODE flow (hardened) ──────────────────────────
|
|
112
|
-
// The bridge asks Postgres (start_device_code) for a secret device_code + a short
|
|
113
|
-
// human user_code, prints the user_code + URL, then POLLS claim_device_code with
|
|
114
|
-
// the secret until the signed-in web has approved (approve_device_code stores the
|
|
115
|
-
// browser's session). The session is delivered only over authenticated TLS RPCs
|
|
116
|
-
// + a single-use secret — never broadcast on a public channel.
|
|
117
|
-
export async function runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) {
|
|
118
|
-
const sb = createClient(SUPABASE_URL, SUPABASE_ANON, { auth: { persistSession: false } })
|
|
119
|
-
const { data, error } = await sb.rpc('start_device_code')
|
|
120
|
-
const row = Array.isArray(data) ? data[0] : data
|
|
121
|
-
if (error || !row?.device_code) { process.stderr.write(`\n ✗ couldn't start login: ${error?.message || 'no response'}\n`); process.exit(1) }
|
|
122
|
-
const { device_code, user_code } = row
|
|
123
|
-
const url = `${WEB_BASE}/code/link?c=${encodeURIComponent(user_code)}`
|
|
124
|
-
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`)
|
|
125
|
-
const started = Date.now()
|
|
126
|
-
const poll = async () => {
|
|
127
|
-
if (Date.now() - started > 5 * 60 * 1000) { process.stderr.write(`\n ◇ link timed out — run \`${pairCli('login')}\` again.\n`); process.exit(1) }
|
|
128
|
-
try {
|
|
129
|
-
// A discarded `error` costs one tick — the loop retries in a second, and a
|
|
130
|
-
// permanent failure still lands on the "link timed out" branch above rather than
|
|
131
|
-
// hanging. Nothing is persisted here, so nothing can be silently lost.
|
|
132
|
-
// fail-open-ok: read-only poll; a lost tick retries, and permanent failure times out loudly
|
|
133
|
-
const { data: r } = await sb.rpc('claim_device_code', { p_device_code: device_code })
|
|
134
|
-
if (r?.status === 'approved' && r.session?.refresh_token) {
|
|
135
|
-
saveAuth(r.session)
|
|
136
|
-
process.stderr.write(`\n ◆ linked as ${r.session.email || 'your account'}. You can close the browser tab.\n Now run: ${PAIR_CLI}\n`)
|
|
137
|
-
process.exit(0)
|
|
138
|
-
}
|
|
139
|
-
if (r?.status === 'expired' || r?.status === 'not_found') { process.stderr.write(`\n ◇ link expired — run \`${pairCli('login')}\` again.\n`); process.exit(1) }
|
|
140
|
-
} catch { /* transient — keep polling */ }
|
|
141
|
-
setTimeout(poll, 3000)
|
|
142
|
-
}
|
|
143
|
-
poll()
|
|
144
|
-
await new Promise(() => {})
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// A promise that resolves after ms, never keeping the loop alive on its own.
|
|
148
|
-
const sleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); t.unref?.() })
|
|
149
|
-
|
|
150
|
-
// Reject a promise if it doesn't settle within ms — so a wedged realtime socket can't
|
|
151
|
-
// freeze a Supabase call (and the tick that awaits it) forever (the 2026-07-08 wedge:
|
|
152
|
-
// a tick hung on a half-open socket, the re-entrancy guard stuck true, the claim
|
|
153
|
-
// heartbeat stopped, and the bridge stood by against its own 564s-stale row).
|
|
154
|
-
export function withTimeout(promise, ms, label = 'op') {
|
|
155
|
-
return new Promise((resolve, reject) => {
|
|
156
|
-
const t = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); t.unref?.()
|
|
157
|
-
Promise.resolve(promise).then((v) => { clearTimeout(t); resolve(v) }, (e) => { clearTimeout(t); reject(e) })
|
|
158
|
-
})
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Decode a JWT's `exp` (unix seconds) WITHOUT verifying — used only to decide whether the
|
|
162
|
-
// saved access token is fresh enough to skip a refresh. 0 on any parse failure (→ refresh).
|
|
163
|
-
function jwtExpSeconds(token) {
|
|
164
|
-
try {
|
|
165
|
-
const seg = String(token).split('.')[1]
|
|
166
|
-
const json = Buffer.from(seg.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')
|
|
167
|
-
const exp = JSON.parse(json)?.exp
|
|
168
|
-
return typeof exp === 'number' ? exp : 0
|
|
169
|
-
} catch { return 0 }
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// F1 predicate (pure, testable): is the saved access token still worth adopting WITHOUT a
|
|
173
|
-
// refresh? True iff we have an access token whose expiry (from the persisted expires_at, or
|
|
174
|
-
// decoded from the JWT) is more than marginSec ahead of now. Skipping the refresh here is
|
|
175
|
-
// one fewer rotation → one fewer race window / mid-rotation brick.
|
|
176
|
-
export function savedTokenStillValid(saved, nowSec = Math.floor(Date.now() / 1000), marginSec = 300) {
|
|
177
|
-
if (!saved?.access_token) return false
|
|
178
|
-
const expSec = saved.expires_at || jwtExpSeconds(saved.access_token)
|
|
179
|
-
return !!expSec && (expSec - nowSec) > marginSec
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// ── cross-process refresh serialization (F3) ────────────────────────────────
|
|
183
|
-
// Two bridges refreshing ONE refresh token outside Supabase's short reuse window makes
|
|
184
|
-
// Supabase revoke the entire token FAMILY — every future refresh then fails permanently
|
|
185
|
-
// until a fresh login. A short-lived lock file serializes the actual network refresh
|
|
186
|
-
// across processes; a lock older than staleMs is stale (holder crashed mid-refresh) and
|
|
187
|
-
// is taken over. fs errors fail-OPEN (return true) — a lock we can't manage must never
|
|
188
|
-
// block a genuine refresh. Exported for the test harness.
|
|
189
|
-
const REFRESH_LOCK = path.join(os.homedir(), '.thinkpool-pair', 'refresh.lock')
|
|
190
|
-
export function acquireRefreshLock(lockPath = REFRESH_LOCK, staleMs = 30_000) {
|
|
191
|
-
try { fs.mkdirSync(path.dirname(lockPath), { recursive: true }) } catch { /* noop */ }
|
|
192
|
-
for (let i = 0; i < 2; i++) {
|
|
193
|
-
try {
|
|
194
|
-
const fd = fs.openSync(lockPath, 'wx') // atomic create-exclusive
|
|
195
|
-
fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() })); fs.closeSync(fd)
|
|
196
|
-
return true
|
|
197
|
-
} catch (e) {
|
|
198
|
-
if (e.code !== 'EEXIST') return true // unmanageable → fail open
|
|
199
|
-
let age = Infinity
|
|
200
|
-
try { age = Date.now() - (JSON.parse(fs.readFileSync(lockPath, 'utf8'))?.at || 0) } catch { age = Infinity }
|
|
201
|
-
if (age < staleMs) return false // a live refresh holds it → back off
|
|
202
|
-
try { fs.rmSync(lockPath, { force: true }) } catch { return true } // stale → drop + retry
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
return false
|
|
206
|
-
}
|
|
207
|
-
export function releaseRefreshLock(lockPath = REFRESH_LOCK) { try { fs.rmSync(lockPath, { force: true }) } catch { /* noop */ } }
|
|
208
|
-
|
|
209
|
-
// Build an authed session from a saved {access_token, refresh_token} WITHOUT rotating the
|
|
210
|
-
// refresh token. setSession validates the access token (getUser — no rotation) when it is
|
|
211
|
-
// still valid; callers only ever adopt a FRESH token, so it never falls through to a refresh.
|
|
212
|
-
async function adoptSavedSession(sb, saved) {
|
|
213
|
-
if (!saved?.access_token || !saved?.refresh_token) return null
|
|
214
|
-
try {
|
|
215
|
-
const { data, error } = await sb.auth.setSession({ access_token: saved.access_token, refresh_token: saved.refresh_token })
|
|
216
|
-
if (error || !data?.session) return null
|
|
217
|
-
return data.session
|
|
218
|
-
} catch { return null }
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// One refresh, serialized across processes + adopting a sibling's rotation (F3). Steps:
|
|
222
|
-
// • auth.json already holds a DIFFERENT refresh token than the one we carry → a sibling
|
|
223
|
-
// (or a re-mint) rotated: ADOPT the disk pair instead of refreshing again.
|
|
224
|
-
// • else take the refresh lock; if a sibling holds it, wait briefly and adopt THEIR
|
|
225
|
-
// rotated result rather than racing a second rotation (which revokes the family).
|
|
226
|
-
// • under the lock, re-read disk once more, then refreshSession → saveAuth.
|
|
227
|
-
// Deps are injectable for the test harness. Returns { session, adopted } or null.
|
|
228
|
-
export async function refreshAccountSession(sb, holdRefreshToken, deps = {}) {
|
|
229
|
-
const { load = loadAuth, save = saveAuth, acquire = acquireRefreshLock, release = releaseRefreshLock, wait = sleep } = deps
|
|
230
|
-
const adoptIfRotated = async (rt) => {
|
|
231
|
-
const d = load()
|
|
232
|
-
if (d?.refresh_token && rt && d.refresh_token !== rt) {
|
|
233
|
-
const s = await adoptSavedSession(sb, d)
|
|
234
|
-
if (s) return { session: s, adopted: true }
|
|
235
|
-
}
|
|
236
|
-
return null
|
|
237
|
-
}
|
|
238
|
-
let adopted = await adoptIfRotated(holdRefreshToken)
|
|
239
|
-
if (adopted) return adopted
|
|
240
|
-
const rt0 = load()?.refresh_token || holdRefreshToken
|
|
241
|
-
if (!rt0) return null
|
|
242
|
-
let locked = acquire()
|
|
243
|
-
if (!locked) {
|
|
244
|
-
// A sibling is mid-refresh. Wait for it to finish and adopt its rotated pair rather
|
|
245
|
-
// than firing a competing rotation (the family-revoke race).
|
|
246
|
-
for (let i = 0; i < 20; i++) {
|
|
247
|
-
await wait(250)
|
|
248
|
-
adopted = await adoptIfRotated(rt0)
|
|
249
|
-
if (adopted) return adopted
|
|
250
|
-
if (acquire()) { locked = true; break }
|
|
251
|
-
}
|
|
252
|
-
if (!locked) return null // couldn't get the lock or adopt — caller retries later
|
|
253
|
-
}
|
|
254
|
-
try {
|
|
255
|
-
adopted = await adoptIfRotated(rt0) // re-check under the lock (sibling may have just finished)
|
|
256
|
-
if (adopted) return adopted
|
|
257
|
-
const rt = load()?.refresh_token || rt0
|
|
258
|
-
const { data, error } = await sb.auth.refreshSession({ refresh_token: rt })
|
|
259
|
-
// Preserve the server's structured Auth error. The caller must distinguish a
|
|
260
|
-
// retryable network/service failure from a refresh token that is definitively
|
|
261
|
-
// gone; collapsing both to null is what let a disconnected account keep painting
|
|
262
|
-
// as a healthy bridge until its presence JWT finally expired.
|
|
263
|
-
if (error || !data?.session) return { session: null, error: error || null }
|
|
264
|
-
save(data.session) // atomic (auth-store) — never a torn token file
|
|
265
|
-
return { session: data.session, adopted: false }
|
|
266
|
-
} finally { if (locked) release() }
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// Supabase documents these as terminal session/refresh-token states. They require a
|
|
270
|
-
// fresh device-code login; request timeouts, rate limits and 5xx errors remain retryable.
|
|
271
|
-
export const ACCOUNT_RECONNECT_ERROR_CODES = new Set([
|
|
272
|
-
'refresh_token_not_found',
|
|
273
|
-
'refresh_token_already_used',
|
|
274
|
-
'session_not_found',
|
|
275
|
-
])
|
|
276
|
-
|
|
277
|
-
export function accountRefreshNeedsReconnect(error) {
|
|
278
|
-
return ACCOUNT_RECONNECT_ERROR_CODES.has(String(error?.code || ''))
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// Exchange the stored credentials for a live session + authed client.
|
|
282
|
-
export async function authedClient(SUPABASE_URL, SUPABASE_ANON) {
|
|
283
|
-
const a = loadAuth()
|
|
284
|
-
if (!a?.refresh_token) return null
|
|
285
|
-
if (a.reconnect_required) return { reconnectRequired: true }
|
|
286
|
-
const sb = createClient(SUPABASE_URL, SUPABASE_ANON, { auth: { persistSession: false, autoRefreshToken: false } })
|
|
287
|
-
// F1: skip the refresh entirely when the saved access token is still comfortably valid
|
|
288
|
-
// (>5 min to expiry). Every gratuitous startup rotation is a race window — and, killed
|
|
289
|
-
// mid-rotation, a login-brick. Adopt the saved pair; scheduleTokenRefresh rotates near
|
|
290
|
-
// expiry as it already does.
|
|
291
|
-
if (savedTokenStillValid(a)) {
|
|
292
|
-
const s = await adoptSavedSession(sb, a)
|
|
293
|
-
if (s) return { sb, session: s }
|
|
294
|
-
}
|
|
295
|
-
// Near-expiry / missing / invalid access token → do a serialized rotation (F3).
|
|
296
|
-
const r = await refreshAccountSession(sb, a.refresh_token)
|
|
297
|
-
if (!r?.session) {
|
|
298
|
-
if (accountRefreshNeedsReconnect(r?.error)) {
|
|
299
|
-
markAuthReconnectRequired(r.error.code)
|
|
300
|
-
return { reconnectRequired: true }
|
|
301
|
-
}
|
|
302
|
-
return null
|
|
303
|
-
}
|
|
304
|
-
return { sb, session: r.session }
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// F2: startup refresh can fail transiently (offline, a sibling mid-rotation, a wedged
|
|
308
|
-
// socket). Retry IN-PROCESS with backoff — NEVER exit(1), because launchd KeepAlive would
|
|
309
|
-
// respawn us within seconds and each respawn hammers the refresh path (the 16× crash
|
|
310
|
-
// storm). authedClient re-reads auth.json every attempt, so a re-mint or a sibling's
|
|
311
|
-
// rotation is picked up on the next try. Returns null ONLY when there is no saved login
|
|
312
|
-
// at all (genuinely not linked) — the caller then stands down CLEANLY (exit 0, no respawn).
|
|
313
|
-
// Deps injectable for the test harness.
|
|
314
|
-
export async function authedClientWithRetry(SUPABASE_URL, SUPABASE_ANON, opts = {}) {
|
|
315
|
-
const { attempt = authedClient, load = loadAuth, wait = sleep, onRetry = null, maxAttempts = Infinity, baseMs = 15_000, capMs = 300_000 } = opts
|
|
316
|
-
let delay = baseMs
|
|
317
|
-
for (let n = 1; n <= maxAttempts; n++) {
|
|
318
|
-
const a = await attempt(SUPABASE_URL, SUPABASE_ANON)
|
|
319
|
-
if (a?.reconnectRequired) return a
|
|
320
|
-
if (a) return a
|
|
321
|
-
if (!load()?.refresh_token) return null // not linked — caller says `login`, exits 0
|
|
322
|
-
if (n >= maxAttempts) break
|
|
323
|
-
onRetry?.(delay, n)
|
|
324
|
-
await wait(delay)
|
|
325
|
-
delay = Math.min(delay * 2, capMs)
|
|
326
|
-
}
|
|
327
|
-
return null
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// Pure predicate (2026-07-08): the claim/heartbeat loop is "wedged" when no successful
|
|
331
|
-
// claim has landed in ≥ quietMsThreshold (default 40s = ~2 missed 15s ticks). Extracted so
|
|
332
|
-
// the watchdog's trigger is unit-testable without a live socket.
|
|
333
|
-
export function claimLoopWedged({ lastClaimOkAt, now, quietMsThreshold = 40_000 }) {
|
|
334
|
-
return (now - lastClaimOkAt) >= quietMsThreshold
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// ── keep the account JWT fresh (presence keepalive) ─────────────────────────
|
|
338
|
-
// The account supervisor authenticates its realtime PRESENCE connection with the
|
|
339
|
-
// account access token. Supabase Realtime enforces the JWT `exp` (~1h): once it
|
|
340
|
-
// lapses the server deauths the socket, presence vanishes, and the dashboard
|
|
341
|
-
// flips to "No bridge connected" — while sessions keep running, because the room
|
|
342
|
-
// children are SEPARATE anon-keyed processes. The supervisor used to refresh the
|
|
343
|
-
// token exactly once at startup (autoRefreshToken:false), so presence silently
|
|
344
|
-
// died ~1h in and never came back until a restart. This refreshes BEFORE expiry,
|
|
345
|
-
// pushes the new token to the realtime socket (setAuth) so it never drops, and
|
|
346
|
-
// re-announces presence. onRefreshed persists the rotated refresh token + re-tracks.
|
|
347
|
-
// Returns { cancel } and exposes _tick for the deterministic test harness.
|
|
348
|
-
// skewMs — refresh this far ahead of expiry (default 90s)
|
|
349
|
-
// forceMs — fixed interval override (test hook: TP_TOKEN_REFRESH_FORCE_MS)
|
|
350
|
-
// retryMs — backoff after a transient failure (offline / rotation race)
|
|
351
|
-
export function scheduleTokenRefresh({ sb, session, onRefreshed, onReconnectRequired, skewMs = 90_000, forceMs = 0, retryMs = 30_000, refresh = refreshAccountSession }) {
|
|
352
|
-
let timer = null
|
|
353
|
-
let cur = session
|
|
354
|
-
let cancelled = false
|
|
355
|
-
let reconnectRequired = false
|
|
356
|
-
const arm = (delay) => {
|
|
357
|
-
if (cancelled) return
|
|
358
|
-
clearTimeout(timer)
|
|
359
|
-
timer = setTimeout(tick, Math.max(1_000, delay))
|
|
360
|
-
if (timer?.unref) timer.unref()
|
|
361
|
-
}
|
|
362
|
-
// expires_at is unix SECONDS; fall back to a 1h horizon if absent.
|
|
363
|
-
const nextDelay = () => {
|
|
364
|
-
if (forceMs) return forceMs
|
|
365
|
-
const expMs = cur?.expires_at ? cur.expires_at * 1000 : Date.now() + 3600_000
|
|
366
|
-
return Math.max(30_000, expMs - Date.now() - skewMs)
|
|
367
|
-
}
|
|
368
|
-
async function tick() {
|
|
369
|
-
if (cancelled) return
|
|
370
|
-
try {
|
|
371
|
-
// Serialized refresh (F3): re-reads auth.json and adopts a sibling's rotated pair
|
|
372
|
-
// instead of blindly refreshing with our in-memory `cur.refresh_token` — the old
|
|
373
|
-
// tick retried with a stale token it held and could lose the family to a race.
|
|
374
|
-
const r = await refresh(sb, cur.refresh_token)
|
|
375
|
-
if (!r?.session) {
|
|
376
|
-
if (accountRefreshNeedsReconnect(r?.error)) {
|
|
377
|
-
if (!reconnectRequired) {
|
|
378
|
-
reconnectRequired = true
|
|
379
|
-
try { await onReconnectRequired?.(r.error) } catch { /* reporting is best-effort; retry still owns recovery */ }
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
throw r?.error || new Error('no session')
|
|
383
|
-
}
|
|
384
|
-
cur = r.session
|
|
385
|
-
// Hand the fresh token to the live realtime socket so the presence
|
|
386
|
-
// connection stays authed across the swap (no drop, no rejoin needed).
|
|
387
|
-
try { sb.realtime?.setAuth?.(cur.access_token) } catch { /* noop */ }
|
|
388
|
-
reconnectRequired = false
|
|
389
|
-
try { await onRefreshed?.(cur) } catch { /* noop */ }
|
|
390
|
-
arm(nextDelay())
|
|
391
|
-
} catch {
|
|
392
|
-
// Transient (offline, or a refresh-token rotation race with another login).
|
|
393
|
-
// Retry soon; NEVER crash the bridge and NEVER clearAuth (see runAccount).
|
|
394
|
-
arm(retryMs)
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
arm(nextDelay())
|
|
398
|
-
return { cancel: () => { cancelled = true; clearTimeout(timer) }, _tick: tick }
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
export function runBind(room, dir) {
|
|
402
|
-
const r = (room || '').toUpperCase().trim()
|
|
403
|
-
if (!r) { console.error(`usage: ${pairCli('bind', '<ROOM>', '<directory>')}`); process.exit(1) }
|
|
404
|
-
const abs = path.resolve(dir || '.')
|
|
405
|
-
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) { console.error(`✗ not a directory: ${abs}`); process.exit(1) }
|
|
406
|
-
bindDir(r, abs)
|
|
407
|
-
console.error(`✓ bound ${r} → ${abs}\n Run \`${PAIR_CLI}\` (or restart it) to serve this session.`)
|
|
408
|
-
process.exit(0)
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// ── account supervisor: a headless child bridge per bound room, in its dir ──
|
|
412
|
-
export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
413
|
-
if (!acquireSingleton()) {
|
|
414
|
-
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`)
|
|
415
|
-
process.exit(0)
|
|
416
|
-
}
|
|
417
|
-
// We own account.lock now, so any previous readiness receipt is stale. A fresh
|
|
418
|
-
// receipt is written only after the first reconciliation tick AND a server-side
|
|
419
|
-
// presence self-echo prove that this exact supervisor finished bootstrapping.
|
|
420
|
-
clearSupervisorReady()
|
|
421
|
-
const releaseStartupAnchor = createStartupAnchor()
|
|
422
|
-
// F2: NEVER exit(1) on a refresh failure. The old code exited 1 here, and launchd
|
|
423
|
-
// KeepAlive respawned us within seconds → refresh again → the 16× crash storm (and,
|
|
424
|
-
// worse, hammering the refresh path burns rotations and can revoke the token family).
|
|
425
|
-
// Instead retry IN-PROCESS with backoff, re-reading auth.json each time so a headless
|
|
426
|
-
// re-mint or a sibling's rotation is picked up without a restart. Do NOT delete
|
|
427
|
-
// auth.json — a transient failure must never wipe a still-valid login (2026-06-17).
|
|
428
|
-
const auth = await authedClientWithRetry(SUPABASE_URL, SUPABASE_ANON, {
|
|
429
|
-
onRetry: (delay) => process.stderr.write(`\n ◇ Couldn't refresh your account session yet (offline, or another bridge is mid-rotation) — retrying in ${Math.round(delay / 1000)}s. Saved login left in place.\n`),
|
|
430
|
-
})
|
|
431
|
-
if (!auth) {
|
|
432
|
-
releaseSingleton()
|
|
433
|
-
// Reached ONLY when there is no saved login at all (not linked). Stand down CLEANLY
|
|
434
|
-
// with exit 0: launchd KeepAlive has SuccessfulExit=false, so exit 0 is NOT respawned
|
|
435
|
-
// — no crash storm for the "never logged in" case. The user links with `login`.
|
|
436
|
-
console.error(`\n ◇ Not linked to a ThinkPool account on this machine.\n Link it: ${pairCli('login')}\n`)
|
|
437
|
-
process.exit(0)
|
|
438
|
-
}
|
|
439
|
-
if (auth.reconnectRequired) {
|
|
440
|
-
releaseStartupAnchor()
|
|
441
|
-
releaseSingleton()
|
|
442
|
-
console.error('\n ◇ This bridge lost its Thinkpool account link.\n Reconnect it: npx thinkpool-pair@latest login\n')
|
|
443
|
-
process.exit(0)
|
|
444
|
-
}
|
|
445
|
-
const { sb, session } = auth
|
|
446
|
-
const email = session.user?.email || 'your account'
|
|
447
|
-
// Current owner JWT, handed to each room-bridge child for authed web writes
|
|
448
|
-
// (code-mockup, whip L16). Refreshed below; pushed to live children on rotation.
|
|
449
|
-
let currentAccessToken = session.access_token || null
|
|
450
|
-
|
|
451
|
-
// No plan-refusal here (Contract C-CODE-2′, spec 2026-06-25-code-free-launch).
|
|
452
|
-
// /code is now a metered free tier, not Pro-only: a free account can legitimately
|
|
453
|
-
// own sessions (within its monthly quota, or during the launch window). The cost
|
|
454
|
-
// brake moved to session CREATION — start_code_session enforces the quota/window
|
|
455
|
-
// server-side, so a row exists iff the owner was entitled to create it. The bridge
|
|
456
|
-
// therefore serves whatever sessions the account legitimately owns (owner-only
|
|
457
|
-
// discovery below, line ~349, still bounds it to the account's OWN rooms). Inference
|
|
458
|
-
// runs on this machine's own Claude login regardless of plan, so hosting costs
|
|
459
|
-
// ThinkPool nothing; there's no cost reason to refuse a free host.
|
|
460
|
-
|
|
461
|
-
const DEFAULT_DIR = process.cwd() // unbound sessions auto-serve here; `bind` overrides per session
|
|
462
|
-
// Ground truth for "am I the managed service": ask the service manager, not the
|
|
463
|
-
// THINKPOOL_PAIR_AUTOUPDATE env proxy. Since pinned installs became the default,
|
|
464
|
-
// the plist/unit sets AUTOUPDATE only when --auto-update was passed — the env var
|
|
465
|
-
// now means "track @latest", NOT "I'm the service". Testing the env alone made a
|
|
466
|
-
// pinned managed service (a) announce service:false → dashboard says "foreground",
|
|
467
|
-
// and (b) skip the uninstall on a dashboard Kill → launchd respawns the "killed"
|
|
468
|
-
// bridge ~2s later (the 2026-06-18 zombie-toggle, reintroduced by pinning).
|
|
469
|
-
let isManagedService = false
|
|
470
|
-
try { const svc = await import('./service.mjs'); isManagedService = svc.serviceActive(null) } catch { /* not installed */ }
|
|
471
|
-
const runsAsService = () => process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' || isManagedService
|
|
472
|
-
const svcHint = process.env.THINKPOOL_PAIR_AUTOUPDATE === '1'
|
|
473
|
-
? ' (auto-updating background service — survives reboot, tracks @latest)\n'
|
|
474
|
-
: isManagedService
|
|
475
|
-
? ' (background service — survives reboot, restarts on crash, pinned to this version)\n'
|
|
476
|
-
: ` (run hands-off + auto-updating: ${pairCli('install-service')})\n`
|
|
477
|
-
const _effDefault = loadDefaultDir() || claudeRecentProjectDir() || DEFAULT_DIR
|
|
478
|
-
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}`)
|
|
479
|
-
|
|
480
|
-
const children = new Map() // room -> child process
|
|
481
|
-
const childIdle = new Map() // room -> bool (last idle report: in the quiet window)
|
|
482
|
-
const childBetween = new Map() // room -> bool (between turns now — safe to restart per Contract #1)
|
|
483
|
-
const childPeer = new Map() // room -> bool (a web client is watching this room)
|
|
484
|
-
const readyRooms = new Set() // child subscribed + restored + announced usable room state
|
|
485
|
-
// Thinkpool Ensemble cross-ROOM router (Tier 1): the supervisor is the only process
|
|
486
|
-
// that knows every room on this machine, so it routes a child's read_session into the
|
|
487
|
-
// target room's child and relays the reply back. roomNames feeds list_sessions; pairPending
|
|
488
|
-
// maps an in-flight peek's reqId → the room that asked, so the target's reply finds its way home.
|
|
489
|
-
const roomNames = new Map() // room -> display name (for list_sessions)
|
|
490
|
-
// pairPending: reqId -> { reply, askerRoom }. `reply(m)` delivers a target child's
|
|
491
|
-
// pair-*-res back to its origin — a local asker child (Tier 1/local Tier 3) OR the
|
|
492
|
-
// pair bus (a partner's request, Tier 2/3). askerRoom (or null for bus origin) lets
|
|
493
|
-
// a dead-room cleanup drop only its own in-flight entries.
|
|
494
|
-
const pairPending = new Map()
|
|
495
|
-
// ── Tier 2/3 cross-MACHINE pair bus ─────────────────────────────────────────
|
|
496
|
-
// The supervisor is the IPC↔bus bridge: it joins one per-pair Supabase channel
|
|
497
|
-
// (tppair:<pairKey>, RLS-gated to the two real partners) per distinct partner, and
|
|
498
|
-
// relays peek/post requests between a local room child and the partner's supervisor.
|
|
499
|
-
// Spec: docs/specs/2026-06-30-cross-room-pair-bus.md
|
|
500
|
-
const roomPartner = new Map() // room -> the OTHER participant's uid (the pair partner), for 2-person rooms
|
|
501
|
-
const pairChannels = new Map() // partnerUid -> { channel, key, status } (one bus per distinct partner)
|
|
502
|
-
const postBucket = new Map() // partnerUid -> { count, resetAt } inbound cross-room-post rate limit (runaway-cost backstop)
|
|
503
|
-
const POST_BUCKET = { max: 6, windowMs: 60_000 } // ≤6 inbound cross-room posts / partner / minute
|
|
504
|
-
let applyRequested = false // a user clicked "apply" in some room (Slice 3)
|
|
505
|
-
let pendingUpdate = null // newest published version once the poll sees it
|
|
506
|
-
let applyingUpdate = false
|
|
507
|
-
const restartProbes = new Map() // nonce -> { pending rooms, ok, timer, resolve }
|
|
508
|
-
const warned = new Set()
|
|
509
|
-
const refused = new Map() // room -> reason ('home'|'none'|'dir-gone'): owned but NOT served, announced in presence so the web shows a precise "attach this session" card instead of spinning
|
|
510
|
-
|
|
511
|
-
// Account presence — so the web dashboard can show "● Bridge active" for this
|
|
512
|
-
// account without per-room probing. We TRACK this machine (name + served room
|
|
513
|
-
// codes + version) on a per-account channel; the dashboard subscribes read-only.
|
|
514
|
-
const machine = os.hostname().replace(/\.local$/, '')
|
|
515
|
-
|
|
516
|
-
// ── supervisor logging (cascade brg-instrument, supervisor-log lane) ─────────
|
|
517
|
-
const SUP_ID = (randomUUID?.() || '').slice(0, 8)
|
|
518
|
-
let lastClaimHeld = null // track state changes for claim_tick logging
|
|
519
|
-
console.log(`◇ supervisor boot sup=${SUP_ID} host=${machine} pid=${process.pid} npx=${VERSION || 'dev'}`)
|
|
520
|
-
|
|
521
|
-
if (!await syncRealtimeAuth(sb, currentAccessToken, SUPABASE_ANON)) {
|
|
522
|
-
process.stderr.write('\n ⚠ realtime auth could not be initialized; private bridge control is unavailable.\n')
|
|
523
|
-
}
|
|
524
|
-
const acct = sb.channel(`tpacct:${session.user.id}`, { config: { presence: { key: machine }, broadcast: { self: false } } })
|
|
525
|
-
const acctControl = sb.channel(`tpacct-control:${session.user.id}`, { config: { private: true, broadcast: { self: false } } })
|
|
526
|
-
// One id per account-bridge PROCESS. The dashboard keeps its restart indicator
|
|
527
|
-
// up until this value changes, so stale presence + fresh claim heartbeats can
|
|
528
|
-
// never impersonate a completed restart.
|
|
529
|
-
const BRIDGE_ID = randomUUID()
|
|
530
|
-
let restarting = false // set by the "restart" broadcast → stop() exits non-zero so a supervisor respawns us
|
|
531
|
-
// Account-control handlers are registered before the discovery loop is
|
|
532
|
-
// initialized. Route early requests through a harmless placeholder, then
|
|
533
|
-
// replace it once `tick` exists; this avoids a temporal-dead-zone crash during
|
|
534
|
-
// supervisor bootstrap while still letting a live web room request discovery.
|
|
535
|
-
let requestDiscoveryTick = () => {}
|
|
536
|
-
// Web "attach this session" card → bind an unbound/new room to a local dir (and
|
|
537
|
-
// optionally set it as the account default) so it serves WITHOUT a terminal command.
|
|
538
|
-
// Rides the private, owner-gated control topic; the dir must exist on THIS machine.
|
|
539
|
-
acctControl.on('broadcast', { event: 'bind-room' }, ({ payload }) => {
|
|
540
|
-
const code = String(payload?.code || '').toUpperCase().trim()
|
|
541
|
-
let dir = String(payload?.dir || '').trim()
|
|
542
|
-
if (dir.startsWith('~')) dir = path.join(os.homedir(), dir.slice(1))
|
|
543
|
-
if (!code || !dir) return
|
|
544
|
-
if (!fs.existsSync(dir)) { process.stderr.write(`\n ◇ bind ${code}: directory not found (${dir}) — ignored.\n`); return }
|
|
545
|
-
bindDir(code, dir)
|
|
546
|
-
if (payload?.setDefault) { saveDefaultDir(dir); process.stderr.write(`\n ◆ default project dir set → ${dir}\n`) }
|
|
547
|
-
process.stderr.write(`\n ◆ ${code} bound → ${dir} (from the web). Serving…\n`)
|
|
548
|
-
requestDiscoveryTick()
|
|
549
|
-
})
|
|
550
|
-
// A newly-created web room already knows its owner and has an authenticated,
|
|
551
|
-
// owner-private account-control channel. Nudge discovery immediately instead
|
|
552
|
-
// of waiting for the supervisor's 15-second reconciliation interval. `tick`
|
|
553
|
-
// still re-queries owner-owned active rooms, so the browser cannot make this
|
|
554
|
-
// host serve an arbitrary code.
|
|
555
|
-
acctControl.on('broadcast', { event: 'serve-room' }, ({ payload }) => {
|
|
556
|
-
const code = String(payload?.code || '').toUpperCase().trim()
|
|
557
|
-
if (!/^[A-Z0-9]{4,12}$/.test(code)) return
|
|
558
|
-
requestDiscoveryTick()
|
|
559
|
-
})
|
|
560
|
-
// Dashboard "Disconnect" → broadcast shutdown → clean exit (presence leaves, bar flips live).
|
|
561
|
-
// If we're the auto-restarting background service (launchd KeepAlive / systemd
|
|
562
|
-
// Restart=always sets AUTOUPDATE=1), a bare exit would be respawned ~2s later —
|
|
563
|
-
// so the toggle would appear to do nothing (presence leaves, then rejoins).
|
|
564
|
-
// Uninstall the account service FIRST so the supervisor won't restart us, then
|
|
565
|
-
// stop. Mirrors the per-room session-deleted guard in bridge.mjs.
|
|
566
|
-
acctControl.on('broadcast', { event: 'shutdown' }, async () => {
|
|
567
|
-
process.stderr.write('\n ◇ disconnect requested from the dashboard — stopping bridge.\n')
|
|
568
|
-
// Hard-exit backstop, armed FIRST — independent of the SIGTERM round-trip,
|
|
569
|
-
// the realtime socket, and every await below. If the graceful stop() wedges
|
|
570
|
-
// (2026-06-18: a dashboard disconnect left this supervisor ALIVE holding
|
|
571
|
-
// account.lock with no presence + service removed — a zombie that showed
|
|
572
|
-
// "No bridge connected" on /code yet blocked any `npx thinkpool-pair`
|
|
573
|
-
// restart), force the process down and release the lock so a fresh bridge
|
|
574
|
-
// can take over. NOT unref'd: it must keep the loop alive until it fires.
|
|
575
|
-
setTimeout(() => { try { releaseSingleton() } catch { /* noop */ } process.exit(0) }, 2500)
|
|
576
|
-
if (runsAsService()) {
|
|
577
|
-
try { const svc = await import('./service.mjs'); svc.uninstallService(null) } catch { /* noop */ }
|
|
578
|
-
}
|
|
579
|
-
process.kill(process.pid, 'SIGTERM')
|
|
580
|
-
})
|
|
581
|
-
// Dashboard "Restart" → broadcast restart → graceful stop with a NON-ZERO exit so
|
|
582
|
-
// EVERY supervisor respawns us: launchd KeepAlive / systemd Restart=always respawn
|
|
583
|
-
// regardless of exit code, and the bridge.mjs --supervise wrapper (room tier)
|
|
584
|
-
// respawns on non-zero but treats 0 as a clean stop. Children persist + resume
|
|
585
|
-
// their sessions (session-store), so the bounce is invisible in-room. Unlike shutdown
|
|
586
|
-
// we do NOT uninstall the service. A plain foreground bridge has no supervisor, so it
|
|
587
|
-
// just stops — restart is meaningful for the always-on bridge. Old bridges (pre-0.7.60)
|
|
588
|
-
// don't subscribe to this event, so the dashboard button is a harmless no-op there.
|
|
589
|
-
let restartPreparationRunning = false
|
|
590
|
-
acctControl.on('broadcast', { event: 'restart' }, async ({ payload } = {}) => {
|
|
591
|
-
const nonce = payload?.nonce
|
|
592
|
-
const reply = async (ok, error) => {
|
|
593
|
-
try { await acctControl.send({ type: 'broadcast', event: 'restart-status', payload: { nonce, ok, error } }) } catch { /* channel down */ }
|
|
594
|
-
}
|
|
595
|
-
// A dashboard restart is an EXPLICIT apply request, but it is not permission
|
|
596
|
-
// to interrupt a live turn or a pending human decision. Stage the immutable
|
|
597
|
-
// runtime while work continues; applyIfIdle() owns the only destructive edge.
|
|
598
|
-
if (restartPreparationRunning || applyingUpdate) {
|
|
599
|
-
await reply(false, 'a bridge update is already being prepared')
|
|
600
|
-
return
|
|
601
|
-
}
|
|
602
|
-
restartPreparationRunning = true
|
|
603
|
-
try {
|
|
604
|
-
const svc = await import('./service.mjs')
|
|
605
|
-
const managed = svc.serviceActive(null)
|
|
606
|
-
const staged = managed ? svc.stageServiceUpdate(null) : null
|
|
607
|
-
if (managed && !staged) {
|
|
608
|
-
await reply(false, 'the published runtime could not be staged; the existing bridge was left running')
|
|
609
|
-
return
|
|
610
|
-
}
|
|
611
|
-
// The dashboard's advertised version is only a fallback marker for a
|
|
612
|
-
// foreground supervisor. Managed hosts independently resolve and verify npm.
|
|
613
|
-
pendingUpdate = staged || (typeof payload?.version === 'string' ? payload.version : VERSION) || 'restart'
|
|
614
|
-
applyRequested = true
|
|
615
|
-
await reply(true)
|
|
616
|
-
} catch (error) {
|
|
617
|
-
await reply(false, `restart preparation failed: ${error?.message || error}`)
|
|
618
|
-
return
|
|
619
|
-
} finally {
|
|
620
|
-
restartPreparationRunning = false
|
|
621
|
-
}
|
|
622
|
-
void applyIfIdle()
|
|
623
|
-
})
|
|
624
|
-
// Throttled so a realtime reconnect storm can't machine-gun track() into the
|
|
625
|
-
// per-client presence rate limit (ClientPresenceRateLimitReached → channel closed
|
|
626
|
-
// → dashboard reads "no bridge" while sessions run; 2026-06-17/-22/-24). The 15s
|
|
627
|
-
// tick + token-refresh callers are unaffected (their interval exceeds the window).
|
|
628
|
-
// lastTrackStatus retains the most recent track()'s server verdict ('ok'|'timed out'|
|
|
629
|
-
// 'error') so the presence self-echo watchdog can log WHY presence went dark.
|
|
630
|
-
let lastTrackStatus = 'init'
|
|
631
|
-
let accountAuthState = 'connected'
|
|
632
|
-
const trackPresence = makeThrottledTrack(acct, { minMs: 5000, onStatus: (s) => { lastTrackStatus = s } })
|
|
633
|
-
// `service` lets the dashboard show whether this bridge is the durable installed
|
|
634
|
-
// service (always-on, survives reboot) vs a foreground `npx` run that dies with its
|
|
635
|
-
// terminal — runsAsService() above (service-manager ground truth, not the env proxy).
|
|
636
|
-
// providerPubKey: the bridge's RSA-OAEP public key (SPKI base64), generated +
|
|
637
|
-
// persisted 0600 on first read. The dashboard imports it to SEAL a provider's API
|
|
638
|
-
// key so it reaches the bridge without ever crossing the wire in the clear. Computed
|
|
639
|
-
// ONCE (the keypair is stable across restarts). `providers` is the name-only registry
|
|
640
|
-
// (id+name+model, NEVER the key or baseUrl) so the dashboard can list what's available
|
|
641
|
-
// to spawn against — re-read each announce so an add/remove reflects immediately.
|
|
642
|
-
// Both are additive; older clients ignore them (feature: multi-provider BYOK, slice 1).
|
|
643
|
-
const PROVIDER_PUBKEY = publicKeyB64()
|
|
644
|
-
const pushPresence = () => trackPresence({ name: machine, bridgeId: BRIDGE_ID, version: VERSION, rooms: [...children.keys()], readyRooms: [...readyRooms], refused: [...refused].map(([code, reason]) => ({ code, reason })), service: runsAsService(), providerPubKey: PROVIDER_PUBKEY, providers: announceProviders(), agents: agentVisibilityStates(installedAgentCommands()), accountAuthState, ts: Date.now() })
|
|
645
|
-
|
|
646
|
-
// Persist account-auth truth separately from process presence. Presence will vanish
|
|
647
|
-
// when the old JWT expires; the claim row keeps the reconnect instruction visible on
|
|
648
|
-
// the dashboard until this same bridge refreshes successfully or another bridge takes
|
|
649
|
-
// over with a healthy login. Older databases simply reject this best-effort RPC.
|
|
650
|
-
const reportAccountAuthState = async (state) => {
|
|
651
|
-
try {
|
|
652
|
-
await withTimeout(sb.rpc('report_bridge_auth_state', { p_bridge_id: BRIDGE_ID, p_state: state }), 5000, 'report_bridge_auth_state')
|
|
653
|
-
} catch { /* migration not present yet, offline, or the old JWT already expired */ }
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
// ── Provider registry — the multi-BYOK wire contract (slice 1). ─────────────
|
|
657
|
-
// The private tpacct-control:<uid> join/send policy binds the sender to this
|
|
658
|
-
// exact owner. Credentials stay in the Realtime handshake, never the payload.
|
|
659
|
-
const provReply = (event, payload) => { try { acctControl.send({ type: 'broadcast', event, payload }) } catch { /* channel down */ } }
|
|
660
|
-
// provider-add {sealed, nonce} → decrypt, validate, append, re-announce.
|
|
661
|
-
acctControl.on('broadcast', { event: 'provider-add' }, async ({ payload }) => {
|
|
662
|
-
const nonce = payload?.nonce
|
|
663
|
-
let fields
|
|
664
|
-
// Decrypt failures answer ok:false WITHOUT logging the ciphertext or plaintext
|
|
665
|
-
// (security invariant d) — a bare boolean, never the sealed blob or the key.
|
|
666
|
-
try { fields = unseal(payload?.sealed) } catch { return provReply('provider-add-res', { nonce, ok: false, error: 'could not decrypt the sealed payload' }) }
|
|
667
|
-
const r = addProvider(fields)
|
|
668
|
-
if (r.ok) { pushPresence(); process.stderr.write(`\n ◆ provider added (${String(fields?.name || '').slice(0, 40)}) — re-announced.\n`) }
|
|
669
|
-
provReply('provider-add-res', { nonce, ok: r.ok, error: r.ok ? undefined : r.error, id: r.ok ? r.id : undefined })
|
|
670
|
-
})
|
|
671
|
-
// provider-add-model {id, model, name?, nonce} → clone the source row's
|
|
672
|
-
// baseUrl + key HOST-SIDE onto a new model, re-announce. Deliberately carries NO
|
|
673
|
-
// `sealed` envelope: the key never leaves the bridge for this op, so there is
|
|
674
|
-
// nothing to decrypt. Owner-authed exactly like its siblings.
|
|
675
|
-
acctControl.on('broadcast', { event: 'provider-add-model' }, async ({ payload }) => {
|
|
676
|
-
const nonce = payload?.nonce
|
|
677
|
-
const r = addProviderModel({ id: payload?.id, model: payload?.model, name: payload?.name })
|
|
678
|
-
if (r.ok) { pushPresence(); process.stderr.write(`\n ◆ model added to an existing provider (${String(payload?.model || '').slice(0, 40)}) — re-announced.\n`) }
|
|
679
|
-
provReply('provider-add-model-res', { nonce, ok: r.ok, error: r.ok ? undefined : r.error, id: r.ok ? r.id : undefined })
|
|
680
|
-
})
|
|
681
|
-
// provider-remove {id, nonce} → remove (built-in refuses), re-announce.
|
|
682
|
-
acctControl.on('broadcast', { event: 'provider-remove' }, async ({ payload }) => {
|
|
683
|
-
const nonce = payload?.nonce
|
|
684
|
-
const r = removeProvider(payload?.id)
|
|
685
|
-
if (r.ok) { pushPresence(); process.stderr.write('\n ◆ provider removed — re-announced.\n') }
|
|
686
|
-
provReply('provider-remove-res', { nonce, ok: r.ok, error: r.ok ? undefined : r.error })
|
|
687
|
-
})
|
|
688
|
-
// providers-list-req {nonce} → the masked list (keyHint = last 4 chars only).
|
|
689
|
-
acctControl.on('broadcast', { event: 'providers-list-req' }, async ({ payload }) => {
|
|
690
|
-
const nonce = payload?.nonce
|
|
691
|
-
provReply('providers-list-res', { nonce, ok: true, providers: listProviders() })
|
|
692
|
-
})
|
|
693
|
-
// agent-visibility-set {cmd, enabled, nonce} changes only the web picker.
|
|
694
|
-
// The runtime stays installed and explicit runtime requests keep working.
|
|
695
|
-
acctControl.on('broadcast', { event: 'agent-visibility-set' }, async ({ payload }) => {
|
|
696
|
-
const nonce = payload?.nonce
|
|
697
|
-
const installed = installedAgentCommands()
|
|
698
|
-
const r = setAgentPickerVisibility(payload?.cmd, payload?.enabled === true, { installed })
|
|
699
|
-
if (r.ok) {
|
|
700
|
-
pushPresence()
|
|
701
|
-
for (const child of children.values()) {
|
|
702
|
-
try { child.send({ t: 'agent-visibility-changed' }) } catch { /* child may be exiting */ }
|
|
703
|
-
}
|
|
704
|
-
process.stderr.write(`\n ◆ terminal picker agents updated — ${r.agents.filter((agent) => agent.enabled).map((agent) => agent.cmd).join(', ')} visible.\n`)
|
|
705
|
-
}
|
|
706
|
-
provReply('agent-visibility-res', { nonce, ok: r.ok, error: r.ok ? undefined : r.error, agents: r.ok ? r.agents : undefined })
|
|
707
|
-
})
|
|
708
|
-
|
|
709
|
-
// ENABLE presence delivery + the self-echo's ground truth. supabase-js only asks
|
|
710
|
-
// the server for presence state when a presence callback is bound at join time —
|
|
711
|
-
// with no listener, presenceState() stays {} FOREVER even while our own track()
|
|
712
|
-
// lands fine. That blind spot made the 0.7.174 self-echo watchdog false-positive
|
|
713
|
-
// respawn the supervisor every ~2.5 min ("the bridge disconnects and reconnects
|
|
714
|
-
// randomly", 2026-07-09 — nine respawns in an hour, track=ok on every one).
|
|
715
|
-
// Binding sync does two jobs: turns presence delivery ON (the echo becomes real)
|
|
716
|
-
// and stamps presenceSyncSeenAt. acctSubscribedAt bounds the startup case where
|
|
717
|
-
// SUBSCRIBED fires but the first sync never arrives: that is a broken Realtime
|
|
718
|
-
// channel, not an unknowable state, once the listener was attached before join.
|
|
719
|
-
let presenceEvidence = reducePresenceChannelEvidence()
|
|
720
|
-
let initialTickDone = false
|
|
721
|
-
let readinessWritten = false
|
|
722
|
-
const publishReadiness = () => {
|
|
723
|
-
if (readinessWritten || !initialTickDone) return
|
|
724
|
-
let present = false
|
|
725
|
-
try { present = supervisorPresenceEchoed(acct.presenceState?.(), machine, BRIDGE_ID, VERSION) } catch { present = false }
|
|
726
|
-
if (!present) return
|
|
727
|
-
readinessWritten = writeSupervisorReady({ version: VERSION, bridgeId: BRIDGE_ID })
|
|
728
|
-
if (!readinessWritten) process.stderr.write('\n ◇ Could not record local supervisor readiness; the bridge remains running but installers will not claim it was confirmed.\n')
|
|
729
|
-
}
|
|
730
|
-
acct.on('presence', { event: 'sync' }, () => {
|
|
731
|
-
presenceEvidence = reducePresenceChannelEvidence(presenceEvidence, 'sync')
|
|
732
|
-
publishReadiness()
|
|
733
|
-
})
|
|
734
|
-
|
|
735
|
-
// Re-track on EVERY (re)subscribe, not just the first: a realtime reconnect
|
|
736
|
-
// (network blip, or a token swap mid-flight) rejoins the channel and must
|
|
737
|
-
// re-announce, or the dashboard would read "no bridge" until the next restart.
|
|
738
|
-
const initialAccountRealtime = await subscribeWithoutStartupDeadlock(acct, (st) => {
|
|
739
|
-
if (st === 'SUBSCRIBED') {
|
|
740
|
-
presenceEvidence = reducePresenceChannelEvidence(presenceEvidence, st)
|
|
741
|
-
pushPresence()
|
|
742
|
-
}
|
|
743
|
-
else if (st === 'CLOSED' || st === 'CHANNEL_ERROR' || st === 'TIMED_OUT') {
|
|
744
|
-
// Invalidate the old generation before inspecting presenceState again. The
|
|
745
|
-
// channel retains a local self key across failures; accepting that cache is
|
|
746
|
-
// what left the managed service zombified while a fresh foreground run worked.
|
|
747
|
-
presenceEvidence = reducePresenceChannelEvidence(presenceEvidence, st)
|
|
748
|
-
// Realtime dropped (the 2026-07-08 `realtime CLOSED` blip). The socket normally
|
|
749
|
-
// auto-reconnects, but nudge it so presence — and the claim heartbeat that shares
|
|
750
|
-
// this client — recover instead of the loop standing by against its own stale row.
|
|
751
|
-
// The callback stays wired for the channel's life, so later rejoins re-fire it.
|
|
752
|
-
try { sb.realtime?.connect?.() } catch { /* noop */ }
|
|
753
|
-
}
|
|
754
|
-
})
|
|
755
|
-
if (!initialAccountRealtime.connected) {
|
|
756
|
-
process.stderr.write(`\n ◇ account realtime not ready at startup (${initialAccountRealtime.status}) — staying alive and recovering in process.\n`)
|
|
757
|
-
}
|
|
758
|
-
const initialAccountControl = await subscribeWithoutStartupDeadlock(acctControl, (st) => {
|
|
759
|
-
if (st === 'CLOSED' || st === 'CHANNEL_ERROR' || st === 'TIMED_OUT') {
|
|
760
|
-
process.stderr.write(`\n ⚠ private account control ${st}; host-changing dashboard actions are unavailable until Realtime reconnects.\n`)
|
|
761
|
-
}
|
|
762
|
-
})
|
|
763
|
-
if (!initialAccountControl.connected) {
|
|
764
|
-
process.stderr.write(`\n ◇ private account control not ready at startup (${initialAccountControl.status}) — presence remains available.\n`)
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
// Keep the account JWT fresh so the presence socket never gets deauthed at
|
|
768
|
-
// expiry (the 2026-06-17 "No bridge connected while sessions run" bug). On each
|
|
769
|
-
// refresh: persist the rotated refresh token + re-announce presence.
|
|
770
|
-
const keepFresh = scheduleTokenRefresh({
|
|
771
|
-
sb,
|
|
772
|
-
session,
|
|
773
|
-
onRefreshed: async (s) => {
|
|
774
|
-
accountAuthState = 'connected'
|
|
775
|
-
saveAuth(s); pushPresence()
|
|
776
|
-
await reportAccountAuthState('connected')
|
|
777
|
-
// Push the rotated owner token to every live child so their authed writes
|
|
778
|
-
// (code-mockup) never go stale on a long session (whip L16).
|
|
779
|
-
currentAccessToken = s.access_token || currentAccessToken
|
|
780
|
-
for (const c of children.values()) { try { c.send({ t: 'token', accessToken: currentAccessToken }) } catch { /* child not ready */ } }
|
|
781
|
-
},
|
|
782
|
-
onReconnectRequired: async (error) => {
|
|
783
|
-
accountAuthState = 'reconnect-required'
|
|
784
|
-
markAuthReconnectRequired(error?.code || 'refresh-token-invalid')
|
|
785
|
-
pushPresence()
|
|
786
|
-
await reportAccountAuthState('reconnect-required')
|
|
787
|
-
process.stderr.write('\n ◇ This bridge lost its Thinkpool account link.\n Reconnect it: npx thinkpool-pair@latest login\n')
|
|
788
|
-
},
|
|
789
|
-
forceMs: parseInt(process.env.TP_TOKEN_REFRESH_FORCE_MS, 10) || 0,
|
|
790
|
-
})
|
|
791
|
-
|
|
792
|
-
// ── cross-machine single active bridge (INV-2) ──────────────────────────────
|
|
793
|
-
// account.lock guards THIS Mac; this claim guards the ACCOUNT across Macs. We
|
|
794
|
-
// serve rooms ONLY while we hold the claim. A non-holder stands by (stays alive
|
|
795
|
-
// for launchd KeepAlive, serves nothing) and takes over only when the holder's
|
|
796
|
-
// heartbeat goes stale — so two launchd bridges never flap respawning. Two Macs
|
|
797
|
-
// both serving one account is the cross-machine half of the terminal-reconcile
|
|
798
|
-
// reap that lost room XE3IQ's history.
|
|
799
|
-
// Spec: docs/specs/2026-06-25-single-bridge-durable-history.md (INV-2)
|
|
800
|
-
let claimHeld = false
|
|
801
|
-
let claimFencing = 0
|
|
802
|
-
let standbyHost = null
|
|
803
|
-
// Wall-clock of the last SUCCESSFUL claim RPC. The heartbeat watchdog (below) uses it to
|
|
804
|
-
// detect a wedged loop and self-heal — the 2026-07-08 outage stood by against its OWN
|
|
805
|
-
// 564s-stale row because the tick had frozen and the heartbeat simply stopped.
|
|
806
|
-
let lastClaimOkAt = Date.now()
|
|
807
|
-
// Presence self-echo watchdog state (2026-07-08 recurrence). claim_bridge rides HTTP and
|
|
808
|
-
// stays healthy, so claimLoopWedged is blind to a zombie realtime channel where track()
|
|
809
|
-
// lands nowhere. The server's presence SYNC echoing OUR OWN `machine` key back into
|
|
810
|
-
// acct.presenceState() is the only proof a track() actually reached the server.
|
|
811
|
-
let selfEchoMissingSince = 0 // wall-clock we first saw our own key missing (0 = present/ok)
|
|
812
|
-
let presenceRecycledForMissing = 0 // de-dupe: the missing-since value we already forced a recycle for
|
|
813
|
-
const refreshClaim = async () => {
|
|
814
|
-
try {
|
|
815
|
-
// Time-box the RPC: a half-open realtime socket must not freeze the heartbeat (and
|
|
816
|
-
// the tick that awaits it) forever. On timeout we throw → keep the prior held state.
|
|
817
|
-
const { data } = await withTimeout(sb.rpc('claim_bridge', { p_bridge_id: BRIDGE_ID, p_hostname: machine }), 10_000, 'claim_bridge')
|
|
818
|
-
lastClaimOkAt = Date.now()
|
|
819
|
-
const c = Array.isArray(data) ? data[0] : data
|
|
820
|
-
const held = !!c?.held
|
|
821
|
-
const host = c?.holder_host || '?'
|
|
822
|
-
if (held && !claimHeld) process.stderr.write(`\n ◆ holding this account — serving your sessions (single active bridge).\n`)
|
|
823
|
-
else if (!held && claimHeld) process.stderr.write(`\n ◇ another device (${host}) took over this account — standing by.\n`)
|
|
824
|
-
else if (!held && standbyHost !== host) process.stderr.write(`\n ◇ another device (${host}) is serving this account — standing by.\n (this Mac takes over automatically if that device goes offline.)\n`)
|
|
825
|
-
claimHeld = held
|
|
826
|
-
claimFencing = held && Number.isSafeInteger(Number(c?.fencing)) && Number(c.fencing) > 0 ? Number(c.fencing) : 0
|
|
827
|
-
standbyHost = held ? null : host
|
|
828
|
-
|
|
829
|
-
// ── supervisor claim_tick logging (cascade brg-instrument) ─────────────────
|
|
830
|
-
if (lastClaimHeld !== held) {
|
|
831
|
-
console.log(`claim_tick sup=${SUP_ID} held=${held} holder=${held ? machine : host}`)
|
|
832
|
-
lastClaimHeld = held
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
return held
|
|
836
|
-
} catch {
|
|
837
|
-
// Transient claim error: keep serving if we already hold; never START
|
|
838
|
-
// serving on an unknown claim (that's the double-serve we're preventing).
|
|
839
|
-
return claimHeld
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
// ── pair-bus plumbing (function declarations hoist, so order here is free) ──
|
|
844
|
-
const pairBus = createPairBusBroker({ channels: pairChannels, randomId: randomUUID })
|
|
845
|
-
const resolveBus = (payload) => pairBus.resolve(payload)
|
|
846
|
-
const busRequestFirst = (event, payload, timeoutMs) => pairBus.requestFirst(event, payload, timeoutMs)
|
|
847
|
-
const busRequestCollect = (event, payload, windowMs) => pairBus.requestCollect(event, payload, windowMs)
|
|
848
|
-
const authorizedPairRooms = async (sourceRoom) => {
|
|
849
|
-
try {
|
|
850
|
-
const listed = await withTimeout(sb.rpc('list_pair_rooms', { p_code: sourceRoom }), 2500, 'paired-room roster')
|
|
851
|
-
if (listed?.error || !Array.isArray(listed?.data)) return { verified: false, rooms: [] }
|
|
852
|
-
return { verified: true, rooms: listed.data }
|
|
853
|
-
} catch { return { verified: false, rooms: [] } }
|
|
854
|
-
}
|
|
855
|
-
// Inbound cross-room-post rate limit, per partner (a partner's agent posting into
|
|
856
|
-
// MY rooms spends MY tokens — the runaway-cost backstop, on top of the per-turn cap).
|
|
857
|
-
const allowInboundPost = (partnerUid) => {
|
|
858
|
-
const now = Date.now()
|
|
859
|
-
let b = postBucket.get(partnerUid)
|
|
860
|
-
if (!b || now >= b.resetAt) { b = { count: 0, resetAt: now + POST_BUCKET.windowMs }; postBucket.set(partnerUid, b) }
|
|
861
|
-
if (b.count >= POST_BUCKET.max) return false
|
|
862
|
-
b.count++; return true
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
// ── bus REQUEST handlers (a partner's supervisor → me). partnerUid is bound per
|
|
866
|
-
// channel, so a request arriving on partner P's bus is trusted as from P (the
|
|
867
|
-
// channel is RLS-gated to {me, P}); I only ever answer for rooms I serve AND share
|
|
868
|
-
// with P (roomPartner === P) — defense in depth behind the channel firewall.
|
|
869
|
-
const onPeerListReq = (partnerUid, ch, payload) => {
|
|
870
|
-
// EN-M4 — the kill-switch must gate the ANSWERING side too. TP_CROSSROOM_OFF on THIS
|
|
871
|
-
// device means "no cross-session reach here", which has to include refusing to EXPOSE
|
|
872
|
-
// this device's rooms to a peer — otherwise a partner still reads in while you've opted
|
|
873
|
-
// out. (TP_PAIRBUS_OFF already disables the bus entirely.)
|
|
874
|
-
if (process.env.TP_PAIRBUS_OFF === '1' || process.env.TP_CROSSROOM_OFF === '1') return
|
|
875
|
-
const rooms = [...children.keys()].filter((r) => roomPartner.get(r) === partnerUid).map((r) => ({ code: r, name: roomNames.get(r) || null, host: machine }))
|
|
876
|
-
try { ch.send({ type: 'broadcast', event: 'peer-list-res', payload: { nonce: payload?.nonce, rooms } }) } catch { /* channel down */ }
|
|
877
|
-
}
|
|
878
|
-
const onPeerPeekReq = (partnerUid, ch, payload) => {
|
|
879
|
-
const reply = (extra) => { try { ch.send({ type: 'broadcast', event: 'peer-peek-res', payload: { nonce: payload?.nonce, ...extra } }) } catch { /* channel down */ } }
|
|
880
|
-
if (process.env.TP_PAIRBUS_OFF === '1' || process.env.TP_CROSSROOM_OFF === '1') return reply({ error: 'Cross-session reach is off on this device.' })
|
|
881
|
-
const target = String(payload?.targetRoom || '').toUpperCase().trim()
|
|
882
|
-
const tc = children.get(target)
|
|
883
|
-
if (!tc || roomPartner.get(target) !== partnerUid) return reply({ error: `Room ${target} is not a room this device shares with you.` })
|
|
884
|
-
const reqId = randomUUID()
|
|
885
|
-
pairPending.set(reqId, { reply: (mm) => reply({ text: mm.text, error: mm.error }), askerRoom: null })
|
|
886
|
-
try { tc.send({ t: 'pair-peek-local', reqId, terminal: payload.terminal, lines: payload.lines }) }
|
|
887
|
-
catch { pairPending.delete(reqId); reply({ error: `Could not reach room ${target}.` }) }
|
|
888
|
-
}
|
|
889
|
-
const onPeerPostReq = (partnerUid, ch, payload) => {
|
|
890
|
-
const reply = (extra) => { try { ch.send({ type: 'broadcast', event: 'peer-post-res', payload: { nonce: payload?.nonce, ...extra } }) } catch { /* channel down */ } }
|
|
891
|
-
if (process.env.TP_PAIRBUS_OFF === '1') return reply({ error: 'Cross-room posting is off on this device.' })
|
|
892
|
-
if (!allowInboundPost(partnerUid)) return reply({ error: 'Too many cross-room posts to this device right now — try again shortly.' })
|
|
893
|
-
const target = String(payload?.targetRoom || '').toUpperCase().trim()
|
|
894
|
-
const tc = children.get(target)
|
|
895
|
-
if (!tc || roomPartner.get(target) !== partnerUid) return reply({ error: `Room ${target} is not a room this device shares with you.` })
|
|
896
|
-
const reqId = randomUUID()
|
|
897
|
-
pairPending.set(reqId, { reply: (mm) => reply({ ok: mm.ok, ref: mm.ref, error: mm.error }), askerRoom: null })
|
|
898
|
-
try { tc.send({ t: 'pair-post-local', reqId, fromRoom: payload.fromRoom, fromHost: payload.fromHost, fromTerminalName: payload.fromTerminalName, text: payload.text, terminal: payload.terminal }) }
|
|
899
|
-
catch { pairPending.delete(reqId); reply({ error: `Could not reach room ${target}.` }) }
|
|
900
|
-
}
|
|
901
|
-
// Join the per-pair bus for `partnerUid` (idempotent). The channel is PRIVATE so
|
|
902
|
-
// Realtime Authorization (RLS on realtime.messages, migration 20260630120000) gates
|
|
903
|
-
// both join and broadcast to the two real partners — a stranger never joins.
|
|
904
|
-
const subscribePairBus = (partnerUid, key) => {
|
|
905
|
-
if (!partnerUid || pairChannels.has(partnerUid)) return
|
|
906
|
-
const topic = pairTopic(key); if (!topic) return
|
|
907
|
-
const ch = sb.channel(topic, { config: { private: true, broadcast: { self: false } } })
|
|
908
|
-
const entry = { channel: ch, key, status: PAIR_BUS_STATUS.JOINING }
|
|
909
|
-
ch.on('broadcast', { event: 'peer-list-req' }, ({ payload }) => onPeerListReq(partnerUid, ch, payload))
|
|
910
|
-
.on('broadcast', { event: 'peer-list-res' }, ({ payload }) => resolveBus(payload))
|
|
911
|
-
.on('broadcast', { event: 'peer-peek-req' }, ({ payload }) => onPeerPeekReq(partnerUid, ch, payload))
|
|
912
|
-
.on('broadcast', { event: 'peer-peek-res' }, ({ payload }) => resolveBus(payload))
|
|
913
|
-
.on('broadcast', { event: 'peer-post-req' }, ({ payload }) => onPeerPostReq(partnerUid, ch, payload))
|
|
914
|
-
.on('broadcast', { event: 'peer-post-res' }, ({ payload }) => resolveBus(payload))
|
|
915
|
-
.subscribe((status) => {
|
|
916
|
-
entry.status = pairBusStatusFromRealtime(status)
|
|
917
|
-
if (status === 'SUBSCRIBED') process.stderr.write(`\n ◆ pair bus subscribed — cross-machine Ensemble reach with your partner.\n`)
|
|
918
|
-
else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') process.stderr.write(`\n ◇ pair bus ${status.toLowerCase().replace('_', ' ')} — partner sessions are offline until it reconnects.\n`)
|
|
919
|
-
})
|
|
920
|
-
pairChannels.set(partnerUid, entry)
|
|
921
|
-
}
|
|
922
|
-
// Forward a local child's cross-room request to the bus (Tier 2 peek / Tier 3 post)
|
|
923
|
-
// when the target room is NOT served on this machine. Used inside the child handler.
|
|
924
|
-
const busPeek = (targetRoom, terminal, lines) => busRequestFirst('peer-peek-req', { targetRoom, terminal, lines }, CROSSROOM_BUS.busTimeoutMs)
|
|
925
|
-
const busPost = (targetRoom, terminal, text, fromRoom, fromTerminalName) => busRequestFirst('peer-post-req', { targetRoom, terminal, text, fromRoom, fromHost: machine, fromTerminalName }, 180_000)
|
|
926
|
-
|
|
927
|
-
// Re-entrancy guard: bind-room fires tick() AND a 15s interval fires tick(); tick
|
|
928
|
-
// awaits a code_sessions query before children.set(), so two overlapping runs can
|
|
929
|
-
// both see children.has(room) false and DOUBLE-SPAWN a 2nd bridge for one room
|
|
930
|
-
// (two bridges per room = the CASCADE-delete / ghost-terminal hazard). Serialize.
|
|
931
|
-
// SELF-HEALING guard (2026-07-08): a plain boolean stuck `true` was the wedge — a tick
|
|
932
|
-
// that hung on a half-open socket never ran its `finally`, so `ticking` stayed true and
|
|
933
|
-
// every later tick returned early forever (heartbeat dead, own row goes 564s stale). Use
|
|
934
|
-
// a start-timestamp instead: a prior run older than 60s is presumed wedged and we proceed
|
|
935
|
-
// anyway (the timed-out awaits inside now guarantee it eventually unwinds).
|
|
936
|
-
let tickingSince = 0
|
|
937
|
-
const tick = async () => {
|
|
938
|
-
const now = Date.now()
|
|
939
|
-
if (tickingSince && now - tickingSince < 60_000) return // a genuine in-flight tick
|
|
940
|
-
tickingSince = now
|
|
941
|
-
try { await tickBody() } finally { if (tickingSince === now) tickingSince = 0 }
|
|
942
|
-
}
|
|
943
|
-
requestDiscoveryTick = () => { void tick().catch(() => {}) }
|
|
944
|
-
// Expose for the watchdog to force-unwedge the guard after a reconnect.
|
|
945
|
-
const clearTickGuard = () => { tickingSince = 0 }
|
|
946
|
-
const tickBody = async () => {
|
|
947
|
-
// Hold the account claim before serving anything (cross-machine guard). When
|
|
948
|
-
// standing by, make sure we're serving nothing and wait for the next tick.
|
|
949
|
-
if (!(await refreshClaim())) {
|
|
950
|
-
if (children.size) for (const [, ch] of children) { try { ch.kill('SIGTERM') } catch { /* noop */ } }
|
|
951
|
-
pushPresence()
|
|
952
|
-
return
|
|
953
|
-
}
|
|
954
|
-
let rooms = []
|
|
955
|
-
let discoverySucceeded = false
|
|
956
|
-
const me = session.user.id
|
|
957
|
-
try {
|
|
958
|
-
// Discovery (Contract C-CODE-2, owner-only since 2026-07-06): a host bridges ONLY
|
|
959
|
-
// the sessions it OWNS. RLS returns owned OR participant rooms; the owner filter
|
|
960
|
-
// keeps rooms you merely JOINED as a guest out of auto-serve — your machine never
|
|
961
|
-
// spawns into another owner's session (cross-person serving removed; pairing is
|
|
962
|
-
// read-only peer peek, not hosting). The web watches tpacct:<session-owner>.
|
|
963
|
-
const { data, error } = await sb.from('code_sessions')
|
|
964
|
-
.select('code,name,last_active_at,owner_id,participants,closed_at')
|
|
965
|
-
.eq('owner_id', me)
|
|
966
|
-
.is('closed_at', null)
|
|
967
|
-
.order('last_active_at', { ascending: false })
|
|
968
|
-
.limit(20)
|
|
969
|
-
if (error) throw error
|
|
970
|
-
rooms = data || []
|
|
971
|
-
discoverySucceeded = true
|
|
972
|
-
// Prune refused entries for rooms we can no longer serve — owned OR granted (only
|
|
973
|
-
// on a SUCCESSFUL fetch, so a transient query error never wipes the set).
|
|
974
|
-
const servableCodes = new Set(rooms.map((r) => r.code))
|
|
975
|
-
for (const c of [...refused.keys()]) if (!servableCodes.has(c)) refused.delete(c)
|
|
976
|
-
} catch { /* transient — keep existing children, retry next tick */ }
|
|
977
|
-
// The realtime shutdown broadcast is an acceleration, never the lifecycle
|
|
978
|
-
// authority. Reconcile every successful discovery read so a bridge that missed
|
|
979
|
-
// `session-deleted` still stops after its room is closed, deleted, or transferred.
|
|
980
|
-
if (discoverySucceeded) {
|
|
981
|
-
for (const room of supervisorRoomsToStop({ rooms, childRooms: children.keys(), myUid: me })) {
|
|
982
|
-
const child = children.get(room)
|
|
983
|
-
if (!child) continue
|
|
984
|
-
console.log(`child_retire sup=${SUP_ID} room=${room} pid=${child.pid || '?'} reason=not-active-owned`)
|
|
985
|
-
try { child.kill('SIGTERM') } catch { /* child exit reconciliation retries next tick */ }
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
const dirs = loadDirs()
|
|
989
|
-
const served = loadServed()
|
|
990
|
-
for (const r of rooms) {
|
|
991
|
-
const room = r.code
|
|
992
|
-
if (!room || children.has(room)) { if (room) refused.delete(room); continue } // served → not refused
|
|
993
|
-
// Defense-in-depth behind the owner-only query above: serve iff we OWN the room.
|
|
994
|
-
if (!supervisorServes({ ownerId: r.owner_id, myUid: me, closedAt: r.closed_at })) continue
|
|
995
|
-
// Resolve the serve dir: explicit bind > last-served memory > launch dir. REFUSE
|
|
996
|
-
// to auto-serve an unbound room from $HOME — that strips the agent's resume context
|
|
997
|
-
// (cwd-keyed SDK sessions) and shows your home folder as the repo (FVIHV1DE).
|
|
998
|
-
// Default-dir precedence for an unbound room: explicit user default > Claude Code's
|
|
999
|
-
// most-recent project dir (zero-config — where you actually code) > launch cwd ($HOME,
|
|
1000
|
-
// which then gets refused). So a new session auto-attaches to your active repo with no setup.
|
|
1001
|
-
const choice = resolveServeDir({ bound: dirs[room], remembered: served[room], defaultDir: loadDefaultDir() || claudeRecentProjectDir() || DEFAULT_DIR, home: os.homedir() })
|
|
1002
|
-
if (choice.refuse) {
|
|
1003
|
-
refused.set(room, choice.reason)
|
|
1004
|
-
if (!warned.has(room)) {
|
|
1005
|
-
warned.add(room)
|
|
1006
|
-
const why = choice.reason === 'home'
|
|
1007
|
-
? `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.`
|
|
1008
|
-
: 'no directory to serve it from.'
|
|
1009
|
-
process.stderr.write(`\n ◇ ${room}: ${why}\n Bind it to its repo: ${pairCli('bind', room, '<repo-dir>')}\n`)
|
|
1010
|
-
}
|
|
1011
|
-
continue
|
|
1012
|
-
}
|
|
1013
|
-
const dir = choice.dir
|
|
1014
|
-
if (!fs.existsSync(dir)) {
|
|
1015
|
-
refused.set(room, 'dir-gone')
|
|
1016
|
-
if (!warned.has(room)) { warned.add(room); process.stderr.write(`\n ◇ ${room}: bound directory is gone (${dir}) — re-bind it.\n`) }
|
|
1017
|
-
continue
|
|
1018
|
-
}
|
|
1019
|
-
warned.delete(room)
|
|
1020
|
-
refused.delete(room) // about to serve → clear any prior refusal
|
|
1021
|
-
// Learn this room → dir so a later relaunch from $HOME resumes it HERE (the real
|
|
1022
|
-
// repo) instead of the home-dir fallback. Only the auto path is remembered; an
|
|
1023
|
-
// explicit bind already persists in dirs.json.
|
|
1024
|
-
if (choice.fromDefault) rememberServed(room, dir)
|
|
1025
|
-
roomNames.set(room, r.name || null) // feed list_sessions (cross-room roster)
|
|
1026
|
-
process.stderr.write(`\n ◆ serving ${room}${r.name ? ` "${r.name}"` : ''} → ${dir}${dirs[room] ? '' : ' (default)'}\n`)
|
|
1027
|
-
// Children NEVER self-update (only the supervisor restart re-resolves
|
|
1028
|
-
// @latest); they report idle to us over IPC so we can restart at a moment
|
|
1029
|
-
// when every session is quiet. Strip AUTOUPDATE so an inherited service env
|
|
1030
|
-
// can't make a child self-exit; flag it as an account child instead.
|
|
1031
|
-
const env = { ...process.env, THINKPOOL_PAIR_ACCOUNT_CHILD: '1', TP_ACCOUNT_BRIDGE_ID: BRIDGE_ID, TP_ACCOUNT_BRIDGE_FENCING: String(claimFencing) }
|
|
1032
|
-
delete env.THINKPOOL_PAIR_AUTOUPDATE
|
|
1033
|
-
// Hand the child the owner JWT for authed web writes (code-mockup, L16).
|
|
1034
|
-
if (currentAccessToken) env.TP_ACCESS_TOKEN = currentAccessToken
|
|
1035
|
-
|
|
1036
|
-
// ── supervisor child_spawn logging (cascade brg-instrument) ─────────────────
|
|
1037
|
-
const alreadyHad = children.has(room)
|
|
1038
|
-
const memory = hostMemoryAdmission(`spawn the room bridge for ${room}`)
|
|
1039
|
-
if (!memory.ok) {
|
|
1040
|
-
if (!warned.has(`memory:${room}`)) {
|
|
1041
|
-
warned.add(`memory:${room}`)
|
|
1042
|
-
process.stderr.write(`\n ◇ ${room}: ${memory.reason}\n`)
|
|
1043
|
-
}
|
|
1044
|
-
refused.set(room, 'host-memory')
|
|
1045
|
-
continue
|
|
1046
|
-
}
|
|
1047
|
-
warned.delete(`memory:${room}`)
|
|
1048
|
-
const child = spawn(process.execPath, [BRIDGE, room, '--headless', '--auto=claude'], { cwd: dir, stdio: ['inherit', 'inherit', 'inherit', 'ipc'], env })
|
|
1049
|
-
console.log(`child_spawn sup=${SUP_ID} room=${room} pid=${child.pid} dir=${dir} had_prior=${alreadyHad}`)
|
|
1050
|
-
|
|
1051
|
-
children.set(room, child)
|
|
1052
|
-
readyRooms.delete(room)
|
|
1053
|
-
childIdle.set(room, false) // unknown until it reports → never restart a fresh child
|
|
1054
|
-
childBetween.set(room, false)
|
|
1055
|
-
childPeer.set(room, false)
|
|
1056
|
-
child.on('message', (m) => {
|
|
1057
|
-
if (!m) return
|
|
1058
|
-
if (m.t === 'room-ready') {
|
|
1059
|
-
readyRooms.add(room)
|
|
1060
|
-
console.log(`child_ready sup=${SUP_ID} room=${room} pid=${child.pid}`)
|
|
1061
|
-
pushPresence()
|
|
1062
|
-
}
|
|
1063
|
-
else if (m.t === 'idle') {
|
|
1064
|
-
childIdle.set(room, !!m.idle)
|
|
1065
|
-
childBetween.set(room, m.between != null ? !!m.between : !!m.idle)
|
|
1066
|
-
childPeer.set(room, !!m.webPeer)
|
|
1067
|
-
void applyIfIdle()
|
|
1068
|
-
}
|
|
1069
|
-
else if (m.t === 'busy') { childIdle.set(room, false); childBetween.set(room, false) }
|
|
1070
|
-
else if (m.t === 'restart-probe-result') {
|
|
1071
|
-
const probe = restartProbes.get(m.nonce)
|
|
1072
|
-
if (!probe || !probe.pending.has(room) || children.get(room) !== child) return
|
|
1073
|
-
probe.pending.delete(room)
|
|
1074
|
-
probe.ok = probe.ok && m.between === true
|
|
1075
|
-
childBetween.set(room, m.between === true)
|
|
1076
|
-
if (!probe.pending.size) {
|
|
1077
|
-
clearTimeout(probe.timer)
|
|
1078
|
-
restartProbes.delete(m.nonce)
|
|
1079
|
-
probe.resolve(probe.ok)
|
|
1080
|
-
}
|
|
1081
|
-
}
|
|
1082
|
-
else if (m.t === 'apply-update') { applyRequested = true; applyIfIdle() } // user clicked the chip
|
|
1083
|
-
// ── Thinkpool Ensemble cross-ROOM routing ──────────────────────────────
|
|
1084
|
-
// `room` here is the room that sent the message (this closure is per-child).
|
|
1085
|
-
// A target room may be LOCAL (another child on this machine — Tier 1 peek /
|
|
1086
|
-
// local Tier 3 post) or REMOTE (the partner's machine, over the pair bus —
|
|
1087
|
-
// Tier 2 peek / cross-machine Tier 3 post). list merges both.
|
|
1088
|
-
else if (m.t === 'pair-list-req') {
|
|
1089
|
-
;(async () => {
|
|
1090
|
-
if (process.env.TP_CROSSROOM_OFF === '1') { try { child.send({ t: 'pair-list-res', reqId: m.reqId, rooms: [], error: 'Cross-session reach is turned off on this bridge.' }) } catch { /* child gone */ } return }
|
|
1091
|
-
const local = [...children.keys()].filter((r) => r !== room).map((r) => ({ code: r, name: roomNames.get(r) || null }))
|
|
1092
|
-
let authorized = []; let replies = []
|
|
1093
|
-
if (process.env.TP_PAIRBUS_OFF !== '1') {
|
|
1094
|
-
const result = await Promise.all([
|
|
1095
|
-
authorizedPairRooms(room),
|
|
1096
|
-
pairChannels.size ? busRequestCollect('peer-list-req', {}, 1200) : Promise.resolve([]),
|
|
1097
|
-
])
|
|
1098
|
-
authorized = result[0].rooms; replies = result[1]
|
|
1099
|
-
}
|
|
1100
|
-
const remote = mergePairRoomRoster({ authorized, replies, myUid: session.user.id, localCodes: children.keys() })
|
|
1101
|
-
try { child.send({ t: 'pair-list-res', reqId: m.reqId, rooms: [...local, ...remote] }) } catch { /* child gone */ }
|
|
1102
|
-
})()
|
|
1103
|
-
}
|
|
1104
|
-
else if (m.t === 'pair-peek-req') {
|
|
1105
|
-
;(async () => {
|
|
1106
|
-
const reply = (extra) => { try { child.send({ t: 'pair-peek-res', reqId: m.reqId, ...extra }) } catch { /* child gone */ } }
|
|
1107
|
-
if (process.env.TP_CROSSROOM_OFF === '1') return reply({ error: 'Cross-session reach is turned off on this bridge.' })
|
|
1108
|
-
const target = String(m.targetRoom || '').toUpperCase().trim()
|
|
1109
|
-
if (!target || target === room) return reply({ error: `${target || 'That'} is not another of your active sessions.` })
|
|
1110
|
-
const tc = children.get(target)
|
|
1111
|
-
if (tc) { // LOCAL target — forward to that child; route its reply home
|
|
1112
|
-
const reqId = randomUUID()
|
|
1113
|
-
pairPending.set(reqId, { reply: (mm) => reply({ text: mm.text, error: mm.error }), askerRoom: room })
|
|
1114
|
-
try { tc.send({ t: 'pair-peek-local', reqId, terminal: m.terminal, lines: m.lines }) }
|
|
1115
|
-
catch { pairPending.delete(reqId); reply({ error: `Could not reach session ${target}.` }) }
|
|
1116
|
-
return
|
|
1117
|
-
}
|
|
1118
|
-
// REMOTE target — ask the pair bus(es).
|
|
1119
|
-
if (process.env.TP_PAIRBUS_OFF === '1') return reply({ error: 'Cross-session reach is turned off on this bridge.' })
|
|
1120
|
-
const [authorized, live] = await Promise.all([
|
|
1121
|
-
authorizedPairRooms(room),
|
|
1122
|
-
pairChannels.size ? busRequestCollect('peer-list-req', {}, 800) : Promise.resolve([]),
|
|
1123
|
-
])
|
|
1124
|
-
if (authorized.verified && !authorized.rooms.some((r) => r?.code === target && r?.owner_id !== session.user.id)) return reply({ error: `Room ${target} is not shared with this pair.` })
|
|
1125
|
-
if (!pairChannels.size) return reply({ error: authorized.verified ? `Room ${target} is paired with you, but its host bridge is offline.` : `Session ${target} is not running on this machine, and no pair bus is open to reach it.` })
|
|
1126
|
-
if (!live.some((p) => p?.rooms?.some((r) => r?.code === target))) return reply({ error: `Room ${target} is paired with you, but its host bridge is offline.` })
|
|
1127
|
-
const res = await busPeek(target, m.terminal, m.lines)
|
|
1128
|
-
if (!res) return reply({ error: `Room ${target} did not respond — it may be offline, or not shared with you.` })
|
|
1129
|
-
reply({ text: res.text, error: res.error })
|
|
1130
|
-
})()
|
|
1131
|
-
}
|
|
1132
|
-
else if (m.t === 'pair-post-req') { // Tier 3 cross-room post (sender card already approved)
|
|
1133
|
-
;(async () => {
|
|
1134
|
-
const reply = (extra) => { try { child.send({ t: 'pair-post-res', reqId: m.reqId, ...extra }) } catch { /* child gone */ } }
|
|
1135
|
-
if (process.env.TP_PAIRBUS_OFF === '1') return reply({ error: 'Cross-room posting is turned off on this bridge.' })
|
|
1136
|
-
const target = String(m.targetRoom || '').toUpperCase().trim()
|
|
1137
|
-
if (!target || target === room) return reply({ error: `${target || 'That'} is not another of your sessions.` })
|
|
1138
|
-
const tc = children.get(target)
|
|
1139
|
-
if (tc) { // LOCAL target — the target child raises its own receipt card
|
|
1140
|
-
const reqId = randomUUID()
|
|
1141
|
-
pairPending.set(reqId, { reply: (mm) => reply({ ok: mm.ok, ref: mm.ref, error: mm.error }), askerRoom: room })
|
|
1142
|
-
try { tc.send({ t: 'pair-post-local', reqId, fromRoom: room, fromHost: machine, fromTerminalName: m.fromTerminalName, text: m.text, terminal: m.terminal }) }
|
|
1143
|
-
catch { pairPending.delete(reqId); reply({ error: `Could not reach session ${target}.` }) }
|
|
1144
|
-
return
|
|
1145
|
-
}
|
|
1146
|
-
// REMOTE target — prove the partner's bridge is currently answering
|
|
1147
|
-
// before starting a 180s human-approval wait. The DB-backed roster may
|
|
1148
|
-
// legitimately list an offline paired room; reachability comes from the bus.
|
|
1149
|
-
const [authorized, live] = await Promise.all([
|
|
1150
|
-
authorizedPairRooms(room),
|
|
1151
|
-
pairChannels.size ? busRequestCollect('peer-list-req', {}, 1200) : Promise.resolve([]),
|
|
1152
|
-
])
|
|
1153
|
-
if (authorized.verified && !authorized.rooms.some((r) => r?.code === target && r?.owner_id !== session.user.id)) return reply({ error: `Room ${target} is not shared with this pair.` })
|
|
1154
|
-
if (!pairChannels.size) return reply({ error: authorized.verified ? `Room ${target} is paired with you, but its host bridge is offline.` : `Room ${target} is not on this machine, and no pair bus is open to reach it.` })
|
|
1155
|
-
if (!live.some((p) => p?.rooms?.some((r) => r?.code === target))) return reply({ error: `Room ${target} is paired with you, but its host bridge is offline — no recipient approval card can be raised yet.` })
|
|
1156
|
-
const res = await busPost(target, m.terminal, m.text, room, m.fromTerminalName)
|
|
1157
|
-
if (!res) return reply({ error: `Room ${target} did not respond to the hand-off in time.` })
|
|
1158
|
-
reply({ ok: res.ok, ref: res.ref, error: res.error })
|
|
1159
|
-
})()
|
|
1160
|
-
}
|
|
1161
|
-
else if (m.t === 'pair-peek-res' || m.t === 'pair-post-res') {
|
|
1162
|
-
// A target child answered — deliver via the stored reply fn (to a local
|
|
1163
|
-
// asker child, or back onto the pair bus for a partner's request).
|
|
1164
|
-
const w = pairPending.get(m.reqId)
|
|
1165
|
-
if (!w) return
|
|
1166
|
-
pairPending.delete(m.reqId)
|
|
1167
|
-
try { w.reply(m) } catch { /* reply path gone */ }
|
|
1168
|
-
}
|
|
1169
|
-
})
|
|
1170
|
-
const dropRoom = (code) => {
|
|
1171
|
-
// ── supervisor child_exit logging (cascade brg-instrument) ─────────────────
|
|
1172
|
-
console.log(`child_exit sup=${SUP_ID} room=${room} pid=${child?.pid || '?'} code=${code || '?'}`)
|
|
1173
|
-
|
|
1174
|
-
children.delete(room); readyRooms.delete(room); childIdle.delete(room); childBetween.delete(room); childPeer.delete(room); roomNames.delete(room); roomPartner.delete(room) // re-served on the next tick
|
|
1175
|
-
// Fail any in-flight cross-room peeks/posts this dead room had ASKED (bus-origin
|
|
1176
|
-
// entries have askerRoom null and just time out on their own).
|
|
1177
|
-
for (const [reqId, w] of pairPending) { if (w?.askerRoom === room) pairPending.delete(reqId) }
|
|
1178
|
-
}
|
|
1179
|
-
child.on('exit', (code) => dropRoom(code))
|
|
1180
|
-
// A spawn that never starts (execPath/cwd vanished between the existsSync check and
|
|
1181
|
-
// spawn) emits 'error', NOT 'exit'. Without this handler the stale children-map entry
|
|
1182
|
-
// would block re-serving forever AND an unhandled 'error' would crash the supervisor.
|
|
1183
|
-
child.on('error', (e) => {
|
|
1184
|
-
dropRoom()
|
|
1185
|
-
try { process.stderr.write(`\n ◇ ${room}: failed to launch its bridge (${e?.message || e}) — retrying next tick.\n`) } catch { /* noop */ }
|
|
1186
|
-
})
|
|
1187
|
-
// If an update is already pending, tell the freshly-served child immediately so
|
|
1188
|
-
// its room shows the "update ready" chip without waiting for the next poll.
|
|
1189
|
-
if (pendingUpdate) { try { child.send({ t: 'update-available', version: pendingUpdate }) } catch { /* child not ready */ } }
|
|
1190
|
-
}
|
|
1191
|
-
// ── reconcile pair buses (Tier 2/3) — derive each served 2-person room's partner
|
|
1192
|
-
// and join that pair's bus. One channel per DISTINCT partner (many shared rooms →
|
|
1193
|
-
// one bus). roomPartner feeds the bus handlers' "is this room shared with P" check.
|
|
1194
|
-
if (process.env.TP_PAIRBUS_OFF !== '1') {
|
|
1195
|
-
for (const r of rooms) {
|
|
1196
|
-
if (!r?.code || !children.has(r.code)) continue // only rooms we actually serve
|
|
1197
|
-
const parts = Array.isArray(r.participants) ? r.participants : []
|
|
1198
|
-
const partner = parts.length === 2 ? parts.find((p) => p && p !== session.user.id) : null
|
|
1199
|
-
if (!partner) { roomPartner.delete(r.code); continue }
|
|
1200
|
-
roomPartner.set(r.code, partner)
|
|
1201
|
-
const key = pairKeyFor(session.user.id, partner)
|
|
1202
|
-
if (key) subscribePairBus(partner, key)
|
|
1203
|
-
}
|
|
1204
|
-
}
|
|
1205
|
-
pushPresence()
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
await tick()
|
|
1209
|
-
const iv = setInterval(tick, 15000)
|
|
1210
|
-
initialTickDone = true
|
|
1211
|
-
publishReadiness()
|
|
1212
|
-
releaseStartupAnchor()
|
|
1213
|
-
const updateTimers = [] // auto-update poll/apply timers — cleared in stop() so none fire post-exit
|
|
1214
|
-
let stopping = false
|
|
1215
|
-
|
|
1216
|
-
// ── claim/heartbeat watchdog (2026-07-08) ──────────────────────────────────
|
|
1217
|
-
// The 15s tick carries the claim heartbeat. If anything inside a tick wedged (a half-open
|
|
1218
|
-
// realtime socket), the heartbeat stopped, our own claim row went stale, and — because a
|
|
1219
|
-
// same-host holder is never displaced by itself — the bridge stood by FOREVER against its
|
|
1220
|
-
// own ghost (the 564s-stale-own-hostname outage). This timer runs on its OWN interval so a
|
|
1221
|
-
// wedged tick can't starve it: when the claim has been quiet ≥2 ticks it nudges realtime
|
|
1222
|
-
// to reconnect, force-clears the re-entrancy guard, and re-issues the heartbeat directly
|
|
1223
|
-
// (claim_bridge is a plain HTTP RPC — it recovers even while the socket is still flapping).
|
|
1224
|
-
let claimRecoveryAttempt = 0
|
|
1225
|
-
let claimRecoveryNextAt = 0
|
|
1226
|
-
let claimRecoveryInFlight = false
|
|
1227
|
-
const heartbeatWatch = setInterval(async () => {
|
|
1228
|
-
if (stopping) return
|
|
1229
|
-
const now = Date.now()
|
|
1230
|
-
if (!claimLoopWedged({ lastClaimOkAt, now })) {
|
|
1231
|
-
claimRecoveryAttempt = 0; claimRecoveryNextAt = 0
|
|
1232
|
-
return
|
|
1233
|
-
}
|
|
1234
|
-
if (claimRecoveryInFlight || now < claimRecoveryNextAt) return
|
|
1235
|
-
claimRecoveryInFlight = true
|
|
1236
|
-
const waitMs = recoveryBackoffMs(claimRecoveryAttempt)
|
|
1237
|
-
claimRecoveryNextAt = now + waitMs
|
|
1238
|
-
claimRecoveryAttempt += 1
|
|
1239
|
-
console.log(`claim_watchdog sup=${SUP_ID} quiet_ms=${now - lastClaimOkAt} attempt=${claimRecoveryAttempt} next_ms=${waitMs} — reconnecting realtime + re-claiming`)
|
|
1240
|
-
try { sb.realtime?.connect?.() } catch { /* noop */ }
|
|
1241
|
-
clearTickGuard() // unwedge the guard so the next scheduled tick can run
|
|
1242
|
-
try { await refreshClaim(); pushPresence() } catch { /* the backed-off attempt retries */ }
|
|
1243
|
-
finally { claimRecoveryInFlight = false }
|
|
1244
|
-
}, 20_000)
|
|
1245
|
-
heartbeatWatch.unref?.()
|
|
1246
|
-
|
|
1247
|
-
// ── presence self-echo watchdog (2026-07-08) ───────────────────────────────
|
|
1248
|
-
// The claim heartbeat rides claim_bridge (plain HTTP) so it recovers even while the
|
|
1249
|
-
// realtime socket is a zombie — which is exactly why claimLoopWedged can't see the case
|
|
1250
|
-
// where the socket went CLOSED-then-half-open and channel.track() lands nowhere FOREVER,
|
|
1251
|
-
// leaving the dashboard on "No bridge connected" while children serve rooms. The server's
|
|
1252
|
-
// presence SYNC is the only proof: if our own `machine` key is absent from
|
|
1253
|
-
// acct.presenceState() while we hold the claim, the track never reached the server. Runs on
|
|
1254
|
-
// its OWN interval (like the claim watchdog) so a wedged tick can't starve it. Two-stage
|
|
1255
|
-
// escalation via the pure predicate:
|
|
1256
|
-
// ≥~60s missing → recycle the realtime socket (full disconnect+connect — the connect()-only
|
|
1257
|
-
// nudge already in the subscribe/CLOSED handlers provably didn't recover it)
|
|
1258
|
-
// ≥~90s more → exit(1); launchd KeepAlive / systemd reruns clean, children persist and
|
|
1259
|
-
// resume from the session-store (the same invisible bounce the dashboard
|
|
1260
|
-
// Restart button already relies on). Guarded: never while stopping, only
|
|
1261
|
-
// while claimHeld (a standby bridge doesn't track).
|
|
1262
|
-
const presenceWatch = setInterval(() => {
|
|
1263
|
-
if (stopping) return
|
|
1264
|
-
const now = Date.now()
|
|
1265
|
-
const recoveryEligible = presenceRecoveryEligible({ claimHeld, lastClaimOkAt, now })
|
|
1266
|
-
let present = false
|
|
1267
|
-
try { present = !!acct.presenceState?.()[machine] } catch { present = false }
|
|
1268
|
-
const observation = presenceSelfEchoObservation({
|
|
1269
|
-
// A fresh HTTP claim distinguishes a Realtime-only zombie from a full
|
|
1270
|
-
// connectivity outage. In the latter case, wait in-process; a respawn cannot
|
|
1271
|
-
// restore the network and launchd would otherwise loop indefinitely.
|
|
1272
|
-
tracking: recoveryEligible,
|
|
1273
|
-
subscribedAt: Math.max(presenceEvidence.subscribedAt, lastClaimOkAt),
|
|
1274
|
-
syncSeenAt: presenceEvidence.syncSeenAt,
|
|
1275
|
-
present,
|
|
1276
|
-
missingSince: selfEchoMissingSince,
|
|
1277
|
-
now,
|
|
1278
|
-
})
|
|
1279
|
-
selfEchoMissingSince = observation.missingSince
|
|
1280
|
-
if (!selfEchoMissingSince) { presenceRecycledForMissing = 0; return }
|
|
1281
|
-
const verdict = observation.verdict
|
|
1282
|
-
if (verdict === 'recycle') {
|
|
1283
|
-
if (presenceRecycledForMissing === selfEchoMissingSince) return // already recycled this episode — wait out the respawn window
|
|
1284
|
-
presenceRecycledForMissing = selfEchoMissingSince
|
|
1285
|
-
console.log(`presence_watchdog sup=${SUP_ID} action=recycle missing_ms=${now - selfEchoMissingSince} track=${lastTrackStatus} — full realtime disconnect+reconnect`)
|
|
1286
|
-
try { sb.realtime?.disconnect?.() } catch { /* noop */ }
|
|
1287
|
-
try { sb.realtime?.connect?.() } catch { /* noop */ }
|
|
1288
|
-
try { pushPresence() } catch { /* re-announced on the next SUBSCRIBED too */ }
|
|
1289
|
-
} else if (verdict === 'respawn') {
|
|
1290
|
-
console.log(`presence_watchdog sup=${SUP_ID} action=respawn missing_ms=${now - selfEchoMissingSince} track=${lastTrackStatus} — recycle failed, exiting for a clean supervisor respawn`)
|
|
1291
|
-
process.exit(1)
|
|
1292
|
-
}
|
|
1293
|
-
}, 15_000)
|
|
1294
|
-
presenceWatch.unref?.()
|
|
1295
|
-
// restart-vs-stop is the ONLY difference in exit code: a web "restart" sets this so
|
|
1296
|
-
// we exit non-zero (every supervisor respawns); a normal stop/shutdown exits 0.
|
|
1297
|
-
const stop = (sig) => {
|
|
1298
|
-
if (stopping) return; stopping = true
|
|
1299
|
-
const code = restarting ? 1 : 0
|
|
1300
|
-
|
|
1301
|
-
// ── supervisor stand-down logging (cascade brg-instrument) ─────────────────
|
|
1302
|
-
console.log(`supervisor_stop sup=${SUP_ID} reason=${sig || 'unknown'} restarting=${restarting} children=${children.size}`)
|
|
1303
|
-
clearInterval(iv)
|
|
1304
|
-
clearInterval(heartbeatWatch)
|
|
1305
|
-
clearInterval(presenceWatch)
|
|
1306
|
-
for (const t of updateTimers) { try { clearInterval(t) } catch { /* noop */ } } // clearInterval clears Timeouts too
|
|
1307
|
-
keepFresh.cancel()
|
|
1308
|
-
clearSupervisorReady({ pid: process.pid })
|
|
1309
|
-
releaseSingleton()
|
|
1310
|
-
for (const c of children.values()) { try { c.kill(sig || 'SIGTERM') } catch { /* noop */ } }
|
|
1311
|
-
pairBus.close()
|
|
1312
|
-
for (const { channel } of pairChannels.values()) { try { sb.removeChannel(channel) } catch { /* noop */ } } // tear down pair buses (Tier 2/3)
|
|
1313
|
-
// Flush the presence LEAVE before exiting so the dashboard flips to
|
|
1314
|
-
// "not connected" in realtime (don't fire-and-forget the untrack).
|
|
1315
|
-
//
|
|
1316
|
-
// Shutdown teardown; the process is already exiting. A lost `release_bridge` leaves
|
|
1317
|
-
// the claim to expire on its own TTL — degraded (the dashboard shows "connected"
|
|
1318
|
-
// until it lapses), never lost data. No caller remains to surface an error to, and a
|
|
1319
|
-
// throw here would skip process.exit.
|
|
1320
|
-
// fail-open-ok: exit-path teardown; a lost release_bridge expires on its own TTL
|
|
1321
|
-
;(async () => { try { if (claimHeld) await sb.rpc('release_bridge', { p_bridge_id: BRIDGE_ID }) } catch { /* noop */ } try { await acct.untrack() } catch { /* noop */ } try { await sb.removeChannel(acctControl) } catch { /* noop */ } try { await sb.removeChannel(acct) } catch { /* noop */ } process.exit(code) })()
|
|
1322
|
-
setTimeout(() => process.exit(code), 1500) // hard backstop if the flush hangs
|
|
1323
|
-
}
|
|
1324
|
-
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => stop(sig))
|
|
1325
|
-
|
|
1326
|
-
// ── Slice 3 apply-gate (function scope so the child-IPC handler can call it) ──
|
|
1327
|
-
// Restart the account bridge to apply a pending update ONLY when it's safe: every
|
|
1328
|
-
// child idle (between turns) AND either a user clicked apply OR nobody's watching
|
|
1329
|
-
// (unattended fallback). The predicate is unit-tested (tests/update-gate.test.mjs).
|
|
1330
|
-
function confirmChildrenBetweenTurns(timeoutMs = 2500) {
|
|
1331
|
-
const targets = [...children.entries()]
|
|
1332
|
-
if (!targets.length) return Promise.resolve(true)
|
|
1333
|
-
const nonce = randomUUID()
|
|
1334
|
-
return new Promise((resolve) => {
|
|
1335
|
-
const probe = { pending: new Set(targets.map(([room]) => room)), ok: true, resolve, timer: null }
|
|
1336
|
-
probe.timer = setTimeout(() => {
|
|
1337
|
-
restartProbes.delete(nonce)
|
|
1338
|
-
resolve(false)
|
|
1339
|
-
}, timeoutMs)
|
|
1340
|
-
probe.timer.unref?.()
|
|
1341
|
-
restartProbes.set(nonce, probe)
|
|
1342
|
-
for (const [room, child] of targets) {
|
|
1343
|
-
try { child.send({ t: 'restart-probe', nonce }) }
|
|
1344
|
-
catch {
|
|
1345
|
-
probe.pending.delete(room)
|
|
1346
|
-
probe.ok = false
|
|
1347
|
-
}
|
|
1348
|
-
}
|
|
1349
|
-
if (!probe.pending.size) {
|
|
1350
|
-
clearTimeout(probe.timer)
|
|
1351
|
-
restartProbes.delete(nonce)
|
|
1352
|
-
resolve(probe.ok)
|
|
1353
|
-
}
|
|
1354
|
-
})
|
|
1355
|
-
}
|
|
1356
|
-
|
|
1357
|
-
async function applyIfIdle() {
|
|
1358
|
-
if (applyingUpdate || !isSafeToRestart({ pendingUpdate, stopping, childIdle, childBetween, childPeer, requested: applyRequested })) return
|
|
1359
|
-
applyingUpdate = true
|
|
1360
|
-
// The 5s heartbeat is a dashboard signal, not an atomic restart barrier. Re-read
|
|
1361
|
-
// every child at the destructive edge so a turn that began after the last sample
|
|
1362
|
-
// vetoes the update instead of becoming an interrupted recovery.
|
|
1363
|
-
if (!await confirmChildrenBetweenTurns()) {
|
|
1364
|
-
applyingUpdate = false
|
|
1365
|
-
process.stderr.write('\n ◆ bridge update still queued — an agent became active before the restart boundary.\n')
|
|
1366
|
-
return
|
|
1367
|
-
}
|
|
1368
|
-
process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting account bridge to update; sessions resume.\n`)
|
|
1369
|
-
try {
|
|
1370
|
-
const svc = await import('./service.mjs')
|
|
1371
|
-
if (svc.serviceActive(null) && svc.applyStagedServiceUpdate(null, pendingUpdate) !== false) return
|
|
1372
|
-
} catch { /* fall back to the legacy supervised restart below */ }
|
|
1373
|
-
applyingUpdate = false
|
|
1374
|
-
restarting = true
|
|
1375
|
-
if (!runsAsService()) {
|
|
1376
|
-
try { spawn(process.execPath, [process.argv[1]], { detached: true, stdio: 'ignore', env: process.env }).unref() } catch { /* noop */ }
|
|
1377
|
-
}
|
|
1378
|
-
stop('SIGTERM')
|
|
1379
|
-
}
|
|
1380
|
-
|
|
1381
|
-
// ── auto-update (account-service tier only) ───────────────────────────────
|
|
1382
|
-
// launchd (KeepAlive) / systemd (Restart=always) re-run `npx -y
|
|
1383
|
-
// thinkpool-pair@latest` (no room) on every restart, so a clean exit here
|
|
1384
|
-
// pulls the newest publish — and because the new supervisor re-spawns children
|
|
1385
|
-
// from the newly-resolved bridge, the room children get the new version too
|
|
1386
|
-
// (they can't self-update; only this restart propagates it). Gated to
|
|
1387
|
-
// THINKPOOL_PAIR_AUTOUPDATE=1 (set by the account service installer). We wait
|
|
1388
|
-
// for EVERY child to report idle so no one's mid-keystroke when we cycle; each
|
|
1389
|
-
// child persists + resumes its session, so the restart is invisible in-room.
|
|
1390
|
-
// THINKPOOL_PAIR_UPDATE_INTERVAL registry poll seconds (default 1800)
|
|
1391
|
-
// THINKPOOL_PAIR_UPDATE_IDLE child idle seconds before a restart (default 90)
|
|
1392
|
-
if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
|
|
1393
|
-
const POLL_MS = Math.max(60, parseInt(process.env.THINKPOOL_PAIR_UPDATE_INTERVAL, 10) || 1800) * 1000
|
|
1394
|
-
// Strictly-newer x.y.z core; ignore prerelease tails (never downgrade/churn).
|
|
1395
|
-
const isNewer = (latest, cur) => {
|
|
1396
|
-
const core = (v) => String(v).split('-')[0].split('.').map((n) => parseInt(n, 10) || 0)
|
|
1397
|
-
const a = core(latest), b = core(cur)
|
|
1398
|
-
for (let i = 0; i < 3; i++) { if ((a[i] || 0) > (b[i] || 0)) return true; if ((a[i] || 0) < (b[i] || 0)) return false }
|
|
1399
|
-
return false
|
|
1400
|
-
}
|
|
1401
|
-
const fetchLatest = async () => {
|
|
1402
|
-
const ctrl = new AbortController()
|
|
1403
|
-
const to = setTimeout(() => ctrl.abort(), 8000)
|
|
1404
|
-
try {
|
|
1405
|
-
const r = await fetch('https://registry.npmjs.org/thinkpool-pair/latest', { signal: ctrl.signal, headers: { accept: 'application/json' } })
|
|
1406
|
-
if (!r.ok) return null
|
|
1407
|
-
const j = await r.json()
|
|
1408
|
-
return typeof j?.version === 'string' ? j.version : null
|
|
1409
|
-
} catch { return null } finally { clearTimeout(to) }
|
|
1410
|
-
}
|
|
1411
|
-
const check = async () => {
|
|
1412
|
-
const latest = await fetchLatest()
|
|
1413
|
-
if (latest && isNewer(latest, VERSION)) {
|
|
1414
|
-
if (!pendingUpdate) process.stderr.write(`\n ◆ thinkpool-pair ${latest} published — "update ready" surfaced to each room; applies between turns.\n`)
|
|
1415
|
-
// Provision while children continue running. applyIfIdle performs the
|
|
1416
|
-
// final safety check only after this slow network/install work is done.
|
|
1417
|
-
try {
|
|
1418
|
-
const svc = await import('./service.mjs')
|
|
1419
|
-
svc.provisionRuntime(latest)
|
|
1420
|
-
} catch (error) {
|
|
1421
|
-
process.stderr.write(`\n ⚠ bridge ${latest} could not be staged yet: ${error?.message || error}\n`)
|
|
1422
|
-
return
|
|
1423
|
-
}
|
|
1424
|
-
pendingUpdate = latest
|
|
1425
|
-
// Tell every served child so its room shows the chip (children never self-update;
|
|
1426
|
-
// only this supervisor restart re-resolves @latest). Slice 3 nudge.
|
|
1427
|
-
for (const c of children.values()) { try { c.send({ t: 'update-available', version: latest }) } catch { /* child not ready */ } }
|
|
1428
|
-
applyIfIdle() // unattended fallback fires here when no room has a web peer
|
|
1429
|
-
}
|
|
1430
|
-
}
|
|
1431
|
-
updateTimers.push(setInterval(check, POLL_MS).unref())
|
|
1432
|
-
updateTimers.push(setInterval(applyIfIdle, 15000).unref())
|
|
1433
|
-
updateTimers.push(setTimeout(check, 60000).unref())
|
|
1434
|
-
}
|
|
1435
|
-
|
|
1436
|
-
await new Promise(() => {})
|
|
1437
|
-
}
|
|
1
|
+
import{spawn as e}from"node:child_process";import{fileURLToPath as r}from"node:url";import{randomUUID as t}from"node:crypto";import o from"node:path";import n from"node:fs";import{createClient as s}from"@supabase/supabase-js";import i from"node:os";import{saveAuth as a,loadAuth as c,markAuthReconnectRequired as d,loadDirs as l,bindDir as p,loadServed as u,rememberServed as m,loadDefaultDir as f,saveDefaultDir as h,claudeRecentProjectDir as y}from"./auth-store.mjs";import{isSafeToRestart as v}from"./update-gate.mjs";import{makeThrottledTrack as g,presenceRecoveryEligible as w,presenceSelfEchoObservation as _,recoveryBackoffMs as b,reducePresenceChannelEvidence as k,subscribeWithoutStartupDeadlock as $}from"./presence.mjs";import{publicKeyB64 as R,announceProviders as S,listProviders as T,addProvider as I,addProviderModel as O,removeProvider as x,unseal as C}from"./providers.mjs";import{supervisorServes as A,supervisorRoomsToStop as P}from"./serve-consent.mjs";import{resolveServeDir as N}from"./serve-dir.mjs";import{pairKeyFor as E,pairTopic as q,CROSSROOM_BUS as M}from"./cross-terminal.mjs";import{installedAgentCommands as U}from"./agent-detect.mjs";import{agentVisibilityStates as D,setAgentPickerVisibility as j}from"./agent-visibility.mjs";import{hostMemoryAdmission as F}from"./host-memory.mjs";import{createPairBusBroker as L,mergePairRoomRoster as H,pairBusStatusFromRealtime as B,PAIR_BUS_STATUS as G}from"./pair-bus.mjs";import{clearSupervisorReady as z,supervisorPresenceEchoed as K,writeSupervisorReady as W}from"./supervisor-ready.mjs";import{PAIR_CLI as J,pairCli as V}from"./command-guidance.mjs";import{syncRealtimeAuth as X}from"./design-edit.mjs";const Y=(()=>{try{return JSON.parse(n.readFileSync(new URL("./package.json",import.meta.url),"utf8")).version}catch{return null}})(),Z=r(new URL("./bridge.mjs",import.meta.url));export function createStartupAnchor({set:e=setInterval,clear:r=clearInterval}={}){const t=e(()=>{},6e4);let o=!1;return()=>{o||(o=!0,r(t))}}const Q=o.join(i.homedir(),".thinkpool-pair","account.lock");function ee(e){if(!e||e===process.pid)return!1;try{return process.kill(e,0),!0}catch(e){return"EPERM"===e.code}}export function acquireSingleton(e=Q){try{n.mkdirSync(o.dirname(e),{recursive:!0})}catch{return!1}for(let r=0;r<3;r++)try{const r=n.openSync(e,"wx");return n.writeSync(r,String(process.pid)),n.closeSync(r),!0}catch(r){if("EEXIST"!==r.code)return!1;let t=0;try{t=parseInt(n.readFileSync(e,"utf8").trim(),10)||0}catch{return!1}if(ee(t))return!1;try{n.rmSync(e,{force:!0})}catch{return!1}}return!1}export function releaseSingleton(e=Q){try{(parseInt(n.readFileSync(e,"utf8").trim(),10)||0)===process.pid&&n.rmSync(e,{force:!0})}catch{}}export function acquireMachineLock(e=Q){return acquireSingleton(e)}export function releaseMachineLock(e=Q){return releaseSingleton(e)}export function shouldHoldMachineLock(e=process.env){return"1"!==e.THINKPOOL_PAIR_ACCOUNT_CHILD}export async function runLogin(e,r,t){const o=s(e,r,{auth:{persistSession:!1}}),{data:n,error:i}=await o.rpc("start_device_code"),c=Array.isArray(n)?n[0]:n;!i&&c?.device_code||(process.stderr.write(`\n ✗ couldn't start login: ${i?.message||"no response"}\n`),process.exit(1));const{device_code:d,user_code:l}=c,p=`${t}/code/link?c=${encodeURIComponent(l)}`;process.stderr.write(`\n ◆ Link this device to your ThinkPool account.\n Open (signed in) and approve:\n\n ${p}\n\n code: ${l}\n\n Waiting…\n`);const u=Date.now(),m=async()=>{Date.now()-u>3e5&&(process.stderr.write(`\n ◇ link timed out — run \`${V("login")}\` again.\n`),process.exit(1));try{const{data:e}=await o.rpc("claim_device_code",{p_device_code:d});"approved"===e?.status&&e.session?.refresh_token&&(a(e.session),process.stderr.write(`\n ◆ linked as ${e.session.email||"your account"}. You can close the browser tab.\n Now run: ${J}\n`),process.exit(0)),"expired"!==e?.status&&"not_found"!==e?.status||(process.stderr.write(`\n ◇ link expired — run \`${V("login")}\` again.\n`),process.exit(1))}catch{}setTimeout(m,3e3)};m(),await new Promise(()=>{})}const re=e=>new Promise(r=>{const t=setTimeout(r,e);t.unref?.()});export function withTimeout(e,r,t="op"){return new Promise((o,n)=>{const s=setTimeout(()=>n(new Error(`${t} timed out after ${r}ms`)),r);s.unref?.(),Promise.resolve(e).then(e=>{clearTimeout(s),o(e)},e=>{clearTimeout(s),n(e)})})}export function savedTokenStillValid(e,r=Math.floor(Date.now()/1e3),t=300){if(!e?.access_token)return!1;const o=e.expires_at||function(e){try{const r=String(e).split(".")[1],t=Buffer.from(r.replace(/-/g,"+").replace(/_/g,"/"),"base64").toString("utf8"),o=JSON.parse(t)?.exp;return"number"==typeof o?o:0}catch{return 0}}(e.access_token);return!!o&&o-r>t}const te=o.join(i.homedir(),".thinkpool-pair","refresh.lock");export function acquireRefreshLock(e=te,r=3e4){try{n.mkdirSync(o.dirname(e),{recursive:!0})}catch{}for(let t=0;t<2;t++)try{const r=n.openSync(e,"wx");return n.writeSync(r,JSON.stringify({pid:process.pid,at:Date.now()})),n.closeSync(r),!0}catch(t){if("EEXIST"!==t.code)return!0;let o=1/0;try{o=Date.now()-(JSON.parse(n.readFileSync(e,"utf8"))?.at||0)}catch{o=1/0}if(o<r)return!1;try{n.rmSync(e,{force:!0})}catch{return!0}}return!1}export function releaseRefreshLock(e=te){try{n.rmSync(e,{force:!0})}catch{}}async function oe(e,r){if(!r?.access_token||!r?.refresh_token)return null;try{const{data:t,error:o}=await e.auth.setSession({access_token:r.access_token,refresh_token:r.refresh_token});return o||!t?.session?null:t.session}catch{return null}}export async function refreshAccountSession(e,r,t={}){const{load:o=c,save:n=a,acquire:s=acquireRefreshLock,release:i=releaseRefreshLock,wait:d=re}=t,l=async r=>{const t=o();if(t?.refresh_token&&r&&t.refresh_token!==r){const r=await oe(e,t);if(r)return{session:r,adopted:!0}}return null};let p=await l(r);if(p)return p;const u=o()?.refresh_token||r;if(!u)return null;let m=s();if(!m){for(let e=0;e<20;e++){if(await d(250),p=await l(u),p)return p;if(s()){m=!0;break}}if(!m)return null}try{if(p=await l(u),p)return p;const r=o()?.refresh_token||u,{data:t,error:s}=await e.auth.refreshSession({refresh_token:r});return s||!t?.session?{session:null,error:s||null}:(n(t.session),{session:t.session,adopted:!1})}finally{m&&i()}}export const ACCOUNT_RECONNECT_ERROR_CODES=new Set(["refresh_token_not_found","refresh_token_already_used","session_not_found"]);export function accountRefreshNeedsReconnect(e){return ACCOUNT_RECONNECT_ERROR_CODES.has(String(e?.code||""))}export async function authedClient(e,r){const t=c();if(!t?.refresh_token)return null;if(t.reconnect_required)return{reconnectRequired:!0};const o=s(e,r,{auth:{persistSession:!1,autoRefreshToken:!1}});if(savedTokenStillValid(t)){const e=await oe(o,t);if(e)return{sb:o,session:e}}const n=await refreshAccountSession(o,t.refresh_token);return n?.session?{sb:o,session:n.session}:accountRefreshNeedsReconnect(n?.error)?(d(n.error.code),{reconnectRequired:!0}):null}export async function authedClientWithRetry(e,r,t={}){const{attempt:o=authedClient,load:n=c,wait:s=re,onRetry:i=null,maxAttempts:a=1/0,baseMs:d=15e3,capMs:l=3e5}=t;let p=d;for(let t=1;t<=a;t++){const c=await o(e,r);if(c?.reconnectRequired)return c;if(c)return c;if(!n()?.refresh_token)return null;if(t>=a)break;i?.(p,t),await s(p),p=Math.min(2*p,l)}return null}export function claimLoopWedged({lastClaimOkAt:e,now:r,quietMsThreshold:t=4e4}){return r-e>=t}export function scheduleTokenRefresh({sb:e,session:r,onRefreshed:t,onReconnectRequired:o,skewMs:n=9e4,forceMs:s=0,retryMs:i=3e4,refresh:a=refreshAccountSession}){let c=null,d=r,l=!1,p=!1;const u=e=>{l||(clearTimeout(c),c=setTimeout(f,Math.max(1e3,e)),c?.unref&&c.unref())},m=()=>{if(s)return s;const e=d?.expires_at?1e3*d.expires_at:Date.now()+36e5;return Math.max(3e4,e-Date.now()-n)};async function f(){if(!l)try{const r=await a(e,d.refresh_token);if(!r?.session){if(accountRefreshNeedsReconnect(r?.error)&&!p){p=!0;try{await(o?.(r.error))}catch{}}throw r?.error||new Error("no session")}d=r.session;try{e.realtime?.setAuth?.(d.access_token)}catch{}p=!1;try{await(t?.(d))}catch{}u(m())}catch{u(i)}}return u(m()),{cancel:()=>{l=!0,clearTimeout(c)},_tick:f}}export function runBind(e,r){const t=(e||"").toUpperCase().trim();t||(console.error(`usage: ${V("bind","<ROOM>","<directory>")}`),process.exit(1));const s=o.resolve(r||".");n.existsSync(s)&&n.statSync(s).isDirectory()||(console.error(`✗ not a directory: ${s}`),process.exit(1)),p(t,s),console.error(`✓ bound ${t} → ${s}\n Run \`${J}\` (or restart it) to serve this session.`),process.exit(0)}export async function runAccount(r,s){acquireSingleton()||(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 \`${V("<ROOM>")}\`.\n`),process.exit(0)),z();const c=createStartupAnchor(),J=await authedClientWithRetry(r,s,{onRetry:e=>process.stderr.write(`\n ◇ Couldn't refresh your account session yet (offline, or another bridge is mid-rotation) — retrying in ${Math.round(e/1e3)}s. Saved login left in place.\n`)});J||(releaseSingleton(),console.error(`\n ◇ Not linked to a ThinkPool account on this machine.\n Link it: ${V("login")}\n`),process.exit(0)),J.reconnectRequired&&(c(),releaseSingleton(),console.error("\n ◇ This bridge lost its Thinkpool account link.\n Reconnect it: npx thinkpool-pair@latest login\n"),process.exit(0));const{sb:Q,session:ee}=J,re=ee.user?.email||"your account";let te=ee.access_token||null;const oe=process.cwd();let ne=!1;try{const e=await import("./service.mjs");ne=e.serviceActive(null)}catch{}const se=()=>"1"===process.env.THINKPOOL_PAIR_AUTOUPDATE||ne,ie="1"===process.env.THINKPOOL_PAIR_AUTOUPDATE?" (auto-updating background service — survives reboot, tracks @latest)\n":ne?" (background service — survives reboot, restarts on crash, pinned to this version)\n":` (run hands-off + auto-updating: ${V("install-service")})\n`,ae=f()||y()||oe;process.stderr.write(`\n ◆ thinkpool-pair — account mode · ${re}\n New sessions auto-serve from ${ae}${f()?" (your default)":y()?" (your most-recent Claude Code project)":""}\n (change it: ${V("set-default-dir","<dir>")} · one room: ${V("bind","<code>","<dir>")})\n${ie}`);const ce=new Map,de=new Map,le=new Map,pe=new Map,ue=new Set,me=new Map,fe=new Map,he=new Map,ye=new Map,ve=new Map;let ge=!1,we=null,_e=!1;const be=new Map,ke=new Set,$e=new Map,Re=i.hostname().replace(/\.local$/,""),Se=(t?.()||"").slice(0,8);let Te=null;console.log(`◇ supervisor boot sup=${Se} host=${Re} pid=${process.pid} npx=${Y||"dev"}`),await X(Q,te,s)||process.stderr.write("\n ⚠ realtime auth could not be initialized; private bridge control is unavailable.\n");const Ie=Q.channel(`tpacct:${ee.user.id}`,{config:{presence:{key:Re},broadcast:{self:!1}}}),Oe=Q.channel(`tpacct-control:${ee.user.id}`,{config:{private:!0,broadcast:{self:!1}}}),xe=t();let Ce=!1,Ae=()=>{};Oe.on("broadcast",{event:"bind-room"},({payload:e})=>{const r=String(e?.code||"").toUpperCase().trim();let t=String(e?.dir||"").trim();t.startsWith("~")&&(t=o.join(i.homedir(),t.slice(1))),r&&t&&(n.existsSync(t)?(p(r,t),e?.setDefault&&(h(t),process.stderr.write(`\n ◆ default project dir set → ${t}\n`)),process.stderr.write(`\n ◆ ${r} bound → ${t} (from the web). Serving…\n`),Ae()):process.stderr.write(`\n ◇ bind ${r}: directory not found (${t}) — ignored.\n`))}),Oe.on("broadcast",{event:"serve-room"},({payload:e})=>{const r=String(e?.code||"").toUpperCase().trim();/^[A-Z0-9]{4,12}$/.test(r)&&Ae()}),Oe.on("broadcast",{event:"shutdown"},async()=>{if(process.stderr.write("\n ◇ disconnect requested from the dashboard — stopping bridge.\n"),setTimeout(()=>{try{releaseSingleton()}catch{}process.exit(0)},2500),se())try{(await import("./service.mjs")).uninstallService(null)}catch{}process.kill(process.pid,"SIGTERM")});let Pe=!1;Oe.on("broadcast",{event:"restart"},async({payload:e}={})=>{const r=e?.nonce,t=async(e,t)=>{try{await Oe.send({type:"broadcast",event:"restart-status",payload:{nonce:r,ok:e,error:t}})}catch{}};if(Pe||_e)await t(!1,"a bridge update is already being prepared");else{Pe=!0;try{const r=await import("./service.mjs"),o=r.serviceActive(null),n=o?r.stageServiceUpdate(null):null;if(o&&!n)return void await t(!1,"the published runtime could not be staged; the existing bridge was left running");we=n||("string"==typeof e?.version?e.version:Y)||"restart",ge=!0,await t(!0)}catch(e){return void await t(!1,`restart preparation failed: ${e?.message||e}`)}finally{Pe=!1}gr()}});let Ne="init",Ee="connected";const qe=g(Ie,{minMs:5e3,onStatus:e=>{Ne=e}}),Me=R(),Ue=()=>qe({name:Re,bridgeId:xe,version:Y,rooms:[...ce.keys()],readyRooms:[...ue],refused:[...$e].map(([e,r])=>({code:e,reason:r})),service:se(),providerPubKey:Me,providers:S(),agents:D(U()),accountAuthState:Ee,ts:Date.now()}),De=async e=>{try{await withTimeout(Q.rpc("report_bridge_auth_state",{p_bridge_id:xe,p_state:e}),5e3,"report_bridge_auth_state")}catch{}},je=(e,r)=>{try{Oe.send({type:"broadcast",event:e,payload:r})}catch{}};Oe.on("broadcast",{event:"provider-add"},async({payload:e})=>{const r=e?.nonce;let t;try{t=C(e?.sealed)}catch{return je("provider-add-res",{nonce:r,ok:!1,error:"could not decrypt the sealed payload"})}const o=I(t);o.ok&&(Ue(),process.stderr.write(`\n ◆ provider added (${String(t?.name||"").slice(0,40)}) — re-announced.\n`)),je("provider-add-res",{nonce:r,ok:o.ok,error:o.ok?void 0:o.error,id:o.ok?o.id:void 0})}),Oe.on("broadcast",{event:"provider-add-model"},async({payload:e})=>{const r=e?.nonce,t=O({id:e?.id,model:e?.model,name:e?.name});t.ok&&(Ue(),process.stderr.write(`\n ◆ model added to an existing provider (${String(e?.model||"").slice(0,40)}) — re-announced.\n`)),je("provider-add-model-res",{nonce:r,ok:t.ok,error:t.ok?void 0:t.error,id:t.ok?t.id:void 0})}),Oe.on("broadcast",{event:"provider-remove"},async({payload:e})=>{const r=e?.nonce,t=x(e?.id);t.ok&&(Ue(),process.stderr.write("\n ◆ provider removed — re-announced.\n")),je("provider-remove-res",{nonce:r,ok:t.ok,error:t.ok?void 0:t.error})}),Oe.on("broadcast",{event:"providers-list-req"},async({payload:e})=>{const r=e?.nonce;je("providers-list-res",{nonce:r,ok:!0,providers:T()})}),Oe.on("broadcast",{event:"agent-visibility-set"},async({payload:e})=>{const r=e?.nonce,t=U(),o=j(e?.cmd,!0===e?.enabled,{installed:t});if(o.ok){Ue();for(const e of ce.values())try{e.send({t:"agent-visibility-changed"})}catch{}process.stderr.write(`\n ◆ terminal picker agents updated — ${o.agents.filter(e=>e.enabled).map(e=>e.cmd).join(", ")} visible.\n`)}je("agent-visibility-res",{nonce:r,ok:o.ok,error:o.ok?void 0:o.error,agents:o.ok?o.agents:void 0})});let Fe=k(),Le=!1,He=!1;const Be=()=>{if(He||!Le)return;let e=!1;try{e=K(Ie.presenceState?.(),Re,xe,Y)}catch{e=!1}e&&(He=W({version:Y,bridgeId:xe}),He||process.stderr.write("\n ◇ Could not record local supervisor readiness; the bridge remains running but installers will not claim it was confirmed.\n"))};Ie.on("presence",{event:"sync"},()=>{Fe=k(Fe,"sync"),Be()});const Ge=await $(Ie,e=>{if("SUBSCRIBED"===e)Fe=k(Fe,e),Ue();else if("CLOSED"===e||"CHANNEL_ERROR"===e||"TIMED_OUT"===e){Fe=k(Fe,e);try{Q.realtime?.connect?.()}catch{}}});Ge.connected||process.stderr.write(`\n ◇ account realtime not ready at startup (${Ge.status}) — staying alive and recovering in process.\n`);const ze=await $(Oe,e=>{"CLOSED"!==e&&"CHANNEL_ERROR"!==e&&"TIMED_OUT"!==e||process.stderr.write(`\n ⚠ private account control ${e}; host-changing dashboard actions are unavailable until Realtime reconnects.\n`)});ze.connected||process.stderr.write(`\n ◇ private account control not ready at startup (${ze.status}) — presence remains available.\n`);const Ke=scheduleTokenRefresh({sb:Q,session:ee,onRefreshed:async e=>{Ee="connected",a(e),Ue(),await De("connected"),te=e.access_token||te;for(const e of ce.values())try{e.send({t:"token",accessToken:te})}catch{}},onReconnectRequired:async e=>{Ee="reconnect-required",d(e?.code||"refresh-token-invalid"),Ue(),await De("reconnect-required"),process.stderr.write("\n ◇ This bridge lost its Thinkpool account link.\n Reconnect it: npx thinkpool-pair@latest login\n")},forceMs:parseInt(process.env.TP_TOKEN_REFRESH_FORCE_MS,10)||0});let We=!1,Je=0,Ve=null,Xe=Date.now(),Ye=0,Ze=0;const Qe=async()=>{try{const{data:e}=await withTimeout(Q.rpc("claim_bridge",{p_bridge_id:xe,p_hostname:Re}),1e4,"claim_bridge");Xe=Date.now();const r=Array.isArray(e)?e[0]:e,t=!!r?.held,o=r?.holder_host||"?";return t&&!We?process.stderr.write("\n ◆ holding this account — serving your sessions (single active bridge).\n"):!t&&We?process.stderr.write(`\n ◇ another device (${o}) took over this account — standing by.\n`):t||Ve===o||process.stderr.write(`\n ◇ another device (${o}) is serving this account — standing by.\n (this Mac takes over automatically if that device goes offline.)\n`),We=t,Je=t&&Number.isSafeInteger(Number(r?.fencing))&&Number(r.fencing)>0?Number(r.fencing):0,Ve=t?null:o,Te!==t&&(console.log(`claim_tick sup=${Se} held=${t} holder=${t?Re:o}`),Te=t),t}catch{return We}},er=L({channels:ye,randomId:t}),rr=e=>er.resolve(e),tr=(e,r,t)=>er.requestFirst(e,r,t),or=(e,r,t)=>er.requestCollect(e,r,t),nr=async e=>{try{const r=await withTimeout(Q.rpc("list_pair_rooms",{p_code:e}),2500,"paired-room roster");return r?.error||!Array.isArray(r?.data)?{verified:!1,rooms:[]}:{verified:!0,rooms:r.data}}catch{return{verified:!1,rooms:[]}}},sr=(e,r)=>{if(!e||ye.has(e))return;const o=q(r);if(!o)return;const n=Q.channel(o,{config:{private:!0,broadcast:{self:!1}}}),s={channel:n,key:r,status:G.JOINING};n.on("broadcast",{event:"peer-list-req"},({payload:r})=>((e,r,t)=>{if("1"===process.env.TP_PAIRBUS_OFF||"1"===process.env.TP_CROSSROOM_OFF)return;const o=[...ce.keys()].filter(r=>he.get(r)===e).map(e=>({code:e,name:me.get(e)||null,host:Re}));try{r.send({type:"broadcast",event:"peer-list-res",payload:{nonce:t?.nonce,rooms:o}})}catch{}})(e,n,r)).on("broadcast",{event:"peer-list-res"},({payload:e})=>rr(e)).on("broadcast",{event:"peer-peek-req"},({payload:r})=>((e,r,o)=>{const n=e=>{try{r.send({type:"broadcast",event:"peer-peek-res",payload:{nonce:o?.nonce,...e}})}catch{}};if("1"===process.env.TP_PAIRBUS_OFF||"1"===process.env.TP_CROSSROOM_OFF)return n({error:"Cross-session reach is off on this device."});const s=String(o?.targetRoom||"").toUpperCase().trim(),i=ce.get(s);if(!i||he.get(s)!==e)return n({error:`Room ${s} is not a room this device shares with you.`});const a=t();fe.set(a,{reply:e=>n({text:e.text,error:e.error}),askerRoom:null});try{i.send({t:"pair-peek-local",reqId:a,terminal:o.terminal,lines:o.lines})}catch{fe.delete(a),n({error:`Could not reach room ${s}.`})}})(e,n,r)).on("broadcast",{event:"peer-peek-res"},({payload:e})=>rr(e)).on("broadcast",{event:"peer-post-req"},({payload:r})=>((e,r,o)=>{const n=e=>{try{r.send({type:"broadcast",event:"peer-post-res",payload:{nonce:o?.nonce,...e}})}catch{}};if("1"===process.env.TP_PAIRBUS_OFF)return n({error:"Cross-room posting is off on this device."});if(!(e=>{const r=Date.now();let t=ve.get(e);return(!t||r>=t.resetAt)&&(t={count:0,resetAt:r+6e4},ve.set(e,t)),!(t.count>=6||(t.count++,0))})(e))return n({error:"Too many cross-room posts to this device right now — try again shortly."});const s=String(o?.targetRoom||"").toUpperCase().trim(),i=ce.get(s);if(!i||he.get(s)!==e)return n({error:`Room ${s} is not a room this device shares with you.`});const a=t();fe.set(a,{reply:e=>n({ok:e.ok,ref:e.ref,error:e.error}),askerRoom:null});try{i.send({t:"pair-post-local",reqId:a,fromRoom:o.fromRoom,fromHost:o.fromHost,fromTerminalName:o.fromTerminalName,text:o.text,terminal:o.terminal})}catch{fe.delete(a),n({error:`Could not reach room ${s}.`})}})(e,n,r)).on("broadcast",{event:"peer-post-res"},({payload:e})=>rr(e)).subscribe(e=>{s.status=B(e),"SUBSCRIBED"===e?process.stderr.write("\n ◆ pair bus subscribed — cross-machine Ensemble reach with your partner.\n"):"CHANNEL_ERROR"!==e&&"TIMED_OUT"!==e||process.stderr.write(`\n ◇ pair bus ${e.toLowerCase().replace("_"," ")} — partner sessions are offline until it reconnects.\n`)}),ye.set(e,s)};let ir=0;const ar=async()=>{const e=Date.now();if(!(ir&&e-ir<6e4)){ir=e;try{await cr()}finally{ir===e&&(ir=0)}}};Ae=()=>{ar().catch(()=>{})};const cr=async()=>{if(!await Qe()){if(ce.size)for(const[,e]of ce)try{e.kill("SIGTERM")}catch{}return void Ue()}let r=[],s=!1;const a=ee.user.id;try{const{data:e,error:t}=await Q.from("code_sessions").select("code,name,last_active_at,owner_id,participants,closed_at").eq("owner_id",a).is("closed_at",null).order("last_active_at",{ascending:!1}).limit(20);if(t)throw t;r=e||[],s=!0;const o=new Set(r.map(e=>e.code));for(const e of[...$e.keys()])o.has(e)||$e.delete(e)}catch{}if(s)for(const e of P({rooms:r,childRooms:ce.keys(),myUid:a})){const r=ce.get(e);if(r){console.log(`child_retire sup=${Se} room=${e} pid=${r.pid||"?"} reason=not-active-owned`);try{r.kill("SIGTERM")}catch{}}}const c=l(),d=u();for(const s of r){const r=s.code;if(!r||ce.has(r)){r&&$e.delete(r);continue}if(!A({ownerId:s.owner_id,myUid:a,closedAt:s.closed_at}))continue;const l=N({bound:c[r],remembered:d[r],defaultDir:f()||y()||oe,home:i.homedir()});if(l.refuse){if($e.set(r,l.reason),!ke.has(r)){ke.add(r);const e="home"===l.reason?`no project directory set — refusing to serve it from your home folder (${l.dir}); the agent would lose its session context and the room would show "${o.basename(l.dir)}" as the repo.`:"no directory to serve it from.";process.stderr.write(`\n ◇ ${r}: ${e}\n Bind it to its repo: ${V("bind",r,"<repo-dir>")}\n`)}continue}const p=l.dir;if(!n.existsSync(p)){$e.set(r,"dir-gone"),ke.has(r)||(ke.add(r),process.stderr.write(`\n ◇ ${r}: bound directory is gone (${p}) — re-bind it.\n`));continue}ke.delete(r),$e.delete(r),l.fromDefault&&m(r,p),me.set(r,s.name||null),process.stderr.write(`\n ◆ serving ${r}${s.name?` "${s.name}"`:""} → ${p}${c[r]?"":" (default)"}\n`);const u={...process.env,THINKPOOL_PAIR_ACCOUNT_CHILD:"1",TP_ACCOUNT_BRIDGE_ID:xe,TP_ACCOUNT_BRIDGE_FENCING:String(Je)};delete u.THINKPOOL_PAIR_AUTOUPDATE,te&&(u.TP_ACCESS_TOKEN=te);const h=ce.has(r),v=F(`spawn the room bridge for ${r}`);if(!v.ok){ke.has(`memory:${r}`)||(ke.add(`memory:${r}`),process.stderr.write(`\n ◇ ${r}: ${v.reason}\n`)),$e.set(r,"host-memory");continue}ke.delete(`memory:${r}`);const g=e(process.execPath,[Z,r,"--headless","--auto=claude"],{cwd:p,stdio:["inherit","inherit","inherit","ipc"],env:u});console.log(`child_spawn sup=${Se} room=${r} pid=${g.pid} dir=${p} had_prior=${h}`),ce.set(r,g),ue.delete(r),de.set(r,!1),le.set(r,!1),pe.set(r,!1),g.on("message",e=>{if(e)if("room-ready"===e.t)ue.add(r),console.log(`child_ready sup=${Se} room=${r} pid=${g.pid}`),Ue();else if("idle"===e.t)de.set(r,!!e.idle),le.set(r,null!=e.between?!!e.between:!!e.idle),pe.set(r,!!e.webPeer),gr();else if("busy"===e.t)de.set(r,!1),le.set(r,!1);else if("restart-probe-result"===e.t){const t=be.get(e.nonce);if(!t||!t.pending.has(r)||ce.get(r)!==g)return;t.pending.delete(r),t.ok=t.ok&&!0===e.between,le.set(r,!0===e.between),t.pending.size||(clearTimeout(t.timer),be.delete(e.nonce),t.resolve(t.ok))}else if("apply-update"===e.t)ge=!0,gr();else if("pair-list-req"===e.t)(async()=>{if("1"===process.env.TP_CROSSROOM_OFF){try{g.send({t:"pair-list-res",reqId:e.reqId,rooms:[],error:"Cross-session reach is turned off on this bridge."})}catch{}return}const t=[...ce.keys()].filter(e=>e!==r).map(e=>({code:e,name:me.get(e)||null}));let o=[],n=[];if("1"!==process.env.TP_PAIRBUS_OFF){const e=await Promise.all([nr(r),ye.size?or("peer-list-req",{},1200):Promise.resolve([])]);o=e[0].rooms,n=e[1]}const s=H({authorized:o,replies:n,myUid:ee.user.id,localCodes:ce.keys()});try{g.send({t:"pair-list-res",reqId:e.reqId,rooms:[...t,...s]})}catch{}})();else if("pair-peek-req"===e.t)(async()=>{const o=r=>{try{g.send({t:"pair-peek-res",reqId:e.reqId,...r})}catch{}};if("1"===process.env.TP_CROSSROOM_OFF)return o({error:"Cross-session reach is turned off on this bridge."});const n=String(e.targetRoom||"").toUpperCase().trim();if(!n||n===r)return o({error:`${n||"That"} is not another of your active sessions.`});const s=ce.get(n);if(s){const i=t();fe.set(i,{reply:e=>o({text:e.text,error:e.error}),askerRoom:r});try{s.send({t:"pair-peek-local",reqId:i,terminal:e.terminal,lines:e.lines})}catch{fe.delete(i),o({error:`Could not reach session ${n}.`})}return}if("1"===process.env.TP_PAIRBUS_OFF)return o({error:"Cross-session reach is turned off on this bridge."});const[i,a]=await Promise.all([nr(r),ye.size?or("peer-list-req",{},800):Promise.resolve([])]);if(i.verified&&!i.rooms.some(e=>e?.code===n&&e?.owner_id!==ee.user.id))return o({error:`Room ${n} is not shared with this pair.`});if(!ye.size)return o({error:i.verified?`Room ${n} is paired with you, but its host bridge is offline.`:`Session ${n} is not running on this machine, and no pair bus is open to reach it.`});if(!a.some(e=>e?.rooms?.some(e=>e?.code===n)))return o({error:`Room ${n} is paired with you, but its host bridge is offline.`});const c=await(d=n,l=e.terminal,p=e.lines,tr("peer-peek-req",{targetRoom:d,terminal:l,lines:p},M.busTimeoutMs));var d,l,p;if(!c)return o({error:`Room ${n} did not respond — it may be offline, or not shared with you.`});o({text:c.text,error:c.error})})();else if("pair-post-req"===e.t)(async()=>{const o=r=>{try{g.send({t:"pair-post-res",reqId:e.reqId,...r})}catch{}};if("1"===process.env.TP_PAIRBUS_OFF)return o({error:"Cross-room posting is turned off on this bridge."});const n=String(e.targetRoom||"").toUpperCase().trim();if(!n||n===r)return o({error:`${n||"That"} is not another of your sessions.`});const s=ce.get(n);if(s){const i=t();fe.set(i,{reply:e=>o({ok:e.ok,ref:e.ref,error:e.error}),askerRoom:r});try{s.send({t:"pair-post-local",reqId:i,fromRoom:r,fromHost:Re,fromTerminalName:e.fromTerminalName,text:e.text,terminal:e.terminal})}catch{fe.delete(i),o({error:`Could not reach session ${n}.`})}return}const[i,a]=await Promise.all([nr(r),ye.size?or("peer-list-req",{},1200):Promise.resolve([])]);if(i.verified&&!i.rooms.some(e=>e?.code===n&&e?.owner_id!==ee.user.id))return o({error:`Room ${n} is not shared with this pair.`});if(!ye.size)return o({error:i.verified?`Room ${n} is paired with you, but its host bridge is offline.`:`Room ${n} is not on this machine, and no pair bus is open to reach it.`});if(!a.some(e=>e?.rooms?.some(e=>e?.code===n)))return o({error:`Room ${n} is paired with you, but its host bridge is offline — no recipient approval card can be raised yet.`});const c=await(d=n,l=e.terminal,p=e.text,u=r,m=e.fromTerminalName,tr("peer-post-req",{targetRoom:d,terminal:l,text:p,fromRoom:u,fromHost:Re,fromTerminalName:m},18e4));var d,l,p,u,m;if(!c)return o({error:`Room ${n} did not respond to the hand-off in time.`});o({ok:c.ok,ref:c.ref,error:c.error})})();else if("pair-peek-res"===e.t||"pair-post-res"===e.t){const r=fe.get(e.reqId);if(!r)return;fe.delete(e.reqId);try{r.reply(e)}catch{}}});const w=e=>{console.log(`child_exit sup=${Se} room=${r} pid=${g?.pid||"?"} code=${e||"?"}`),ce.delete(r),ue.delete(r),de.delete(r),le.delete(r),pe.delete(r),me.delete(r),he.delete(r);for(const[e,t]of fe)t?.askerRoom===r&&fe.delete(e)};if(g.on("exit",e=>w(e)),g.on("error",e=>{w();try{process.stderr.write(`\n ◇ ${r}: failed to launch its bridge (${e?.message||e}) — retrying next tick.\n`)}catch{}}),we)try{g.send({t:"update-available",version:we})}catch{}}if("1"!==process.env.TP_PAIRBUS_OFF)for(const e of r){if(!e?.code||!ce.has(e.code))continue;const r=Array.isArray(e.participants)?e.participants:[],t=2===r.length?r.find(e=>e&&e!==ee.user.id):null;if(!t){he.delete(e.code);continue}he.set(e.code,t);const o=E(ee.user.id,t);o&&sr(t,o)}Ue()};await ar();const dr=setInterval(ar,15e3);Le=!0,Be(),c();const lr=[];let pr=!1,ur=0,mr=0,fr=!1;const hr=setInterval(async()=>{if(pr)return;const e=Date.now();if(!claimLoopWedged({lastClaimOkAt:Xe,now:e}))return ur=0,void(mr=0);if(fr||e<mr)return;fr=!0;const r=b(ur);mr=e+r,ur+=1,console.log(`claim_watchdog sup=${Se} quiet_ms=${e-Xe} attempt=${ur} next_ms=${r} — reconnecting realtime + re-claiming`);try{Q.realtime?.connect?.()}catch{}ir=0;try{await Qe(),Ue()}catch{}finally{fr=!1}},2e4);hr.unref?.();const yr=setInterval(()=>{if(pr)return;const e=Date.now(),r=w({claimHeld:We,lastClaimOkAt:Xe,now:e});let t=!1;try{t=!!Ie.presenceState?.()[Re]}catch{t=!1}const o=_({tracking:r,subscribedAt:Math.max(Fe.subscribedAt,Xe),syncSeenAt:Fe.syncSeenAt,present:t,missingSince:Ye,now:e});if(Ye=o.missingSince,!Ye)return void(Ze=0);const n=o.verdict;if("recycle"===n){if(Ze===Ye)return;Ze=Ye,console.log(`presence_watchdog sup=${Se} action=recycle missing_ms=${e-Ye} track=${Ne} — full realtime disconnect+reconnect`);try{Q.realtime?.disconnect?.()}catch{}try{Q.realtime?.connect?.()}catch{}try{Ue()}catch{}}else"respawn"===n&&(console.log(`presence_watchdog sup=${Se} action=respawn missing_ms=${e-Ye} track=${Ne} — recycle failed, exiting for a clean supervisor respawn`),process.exit(1))},15e3);yr.unref?.();const vr=e=>{if(pr)return;pr=!0;const r=Ce?1:0;console.log(`supervisor_stop sup=${Se} reason=${e||"unknown"} restarting=${Ce} children=${ce.size}`),clearInterval(dr),clearInterval(hr),clearInterval(yr);for(const e of lr)try{clearInterval(e)}catch{}Ke.cancel(),z({pid:process.pid}),releaseSingleton();for(const r of ce.values())try{r.kill(e||"SIGTERM")}catch{}er.close();for(const{channel:e}of ye.values())try{Q.removeChannel(e)}catch{}(async()=>{try{We&&await Q.rpc("release_bridge",{p_bridge_id:xe})}catch{}try{await Ie.untrack()}catch{}try{await Q.removeChannel(Oe)}catch{}try{await Q.removeChannel(Ie)}catch{}process.exit(r)})(),setTimeout(()=>process.exit(r),1500)};for(const e of["SIGINT","SIGTERM","SIGHUP"])process.on(e,()=>vr(e));async function gr(){if(!_e&&v({pendingUpdate:we,stopping:pr,childIdle:de,childBetween:le,childPeer:pe,requested:ge})){if(_e=!0,!await function(e=2500){const r=[...ce.entries()];if(!r.length)return Promise.resolve(!0);const o=t();return new Promise(t=>{const n={pending:new Set(r.map(([e])=>e)),ok:!0,resolve:t,timer:null};n.timer=setTimeout(()=>{be.delete(o),t(!1)},e),n.timer.unref?.(),be.set(o,n);for(const[e,t]of r)try{t.send({t:"restart-probe",nonce:o})}catch{n.pending.delete(e),n.ok=!1}n.pending.size||(clearTimeout(n.timer),be.delete(o),t(n.ok))})}())return _e=!1,void process.stderr.write("\n ◆ bridge update still queued — an agent became active before the restart boundary.\n");process.stderr.write(`\n ◆ thinkpool-pair ${we} ready (running ${Y}) — restarting account bridge to update; sessions resume.\n`);try{const e=await import("./service.mjs");if(e.serviceActive(null)&&!1!==e.applyStagedServiceUpdate(null,we))return}catch{}if(_e=!1,Ce=!0,!se())try{e(process.execPath,[process.argv[1]],{detached:!0,stdio:"ignore",env:process.env}).unref()}catch{}vr("SIGTERM")}}if("1"===process.env.THINKPOOL_PAIR_AUTOUPDATE&&Y){const e=1e3*Math.max(60,parseInt(process.env.THINKPOOL_PAIR_UPDATE_INTERVAL,10)||1800),r=(e,r)=>{const t=e=>String(e).split("-")[0].split(".").map(e=>parseInt(e,10)||0),o=t(e),n=t(r);for(let e=0;e<3;e++){if((o[e]||0)>(n[e]||0))return!0;if((o[e]||0)<(n[e]||0))return!1}return!1},t=async()=>{const e=new AbortController,r=setTimeout(()=>e.abort(),8e3);try{const r=await fetch("https://registry.npmjs.org/thinkpool-pair/latest",{signal:e.signal,headers:{accept:"application/json"}});if(!r.ok)return null;const t=await r.json();return"string"==typeof t?.version?t.version:null}catch{return null}finally{clearTimeout(r)}},o=async()=>{const e=await t();if(e&&r(e,Y)){we||process.stderr.write(`\n ◆ thinkpool-pair ${e} published — "update ready" surfaced to each room; applies between turns.\n`);try{(await import("./service.mjs")).provisionRuntime(e)}catch(r){return void process.stderr.write(`\n ⚠ bridge ${e} could not be staged yet: ${r?.message||r}\n`)}we=e;for(const r of ce.values())try{r.send({t:"update-available",version:e})}catch{}gr()}};lr.push(setInterval(o,e).unref()),lr.push(setInterval(gr,15e3).unref()),lr.push(setTimeout(o,6e4).unref())}await new Promise(()=>{})}
|