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.
Files changed (116) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +20 -0
  3. package/abort-turn-barrier.mjs +1 -23
  4. package/account.mjs +1 -1437
  5. package/acp-client.mjs +1 -140
  6. package/agent-detect.mjs +1 -28
  7. package/agent-notify.mjs +1 -142
  8. package/agent-visibility.mjs +1 -67
  9. package/auth-store.mjs +1 -120
  10. package/bridge.mjs +1 -6266
  11. package/byok-detect.mjs +1 -126
  12. package/claude-command-catalog.mjs +1 -91
  13. package/claude-session.mjs +1 -1519
  14. package/code-event-contract.mjs +1 -118
  15. package/codex-app-server.mjs +1 -340
  16. package/codex-commands.mjs +1 -83
  17. package/codex-event-mapper.mjs +1 -232
  18. package/codex-images.mjs +1 -69
  19. package/codex-mcp-http.mjs +1 -131
  20. package/codex-session.mjs +1 -1295
  21. package/command-catalog.mjs +1 -118
  22. package/command-guidance.mjs +1 -8
  23. package/context-contract.mjs +1 -95
  24. package/context-windows.mjs +1 -107
  25. package/cross-terminal.mjs +1 -789
  26. package/cumulative-event-relay.mjs +1 -53
  27. package/design-edit.mjs +1 -424
  28. package/design-source-contract.mjs +1 -4
  29. package/direct-pair-room.mjs +1 -57
  30. package/dispatch-lease.mjs +1 -37
  31. package/dispatch-permission-cleanup.mjs +1 -86
  32. package/edit-diff.mjs +1 -136
  33. package/error-recovery.mjs +1 -50
  34. package/event-bounds.mjs +1 -121
  35. package/event-delivery-queue.mjs +1 -60
  36. package/event-id.mjs +1 -549
  37. package/evidence-citations.mjs +1 -50
  38. package/evidence-compact.mjs +1 -11
  39. package/flow-assembly.mjs +1 -196
  40. package/flow-budget.mjs +1 -84
  41. package/flow-conductor.mjs +1 -259
  42. package/flow-context-store.mjs +1 -387
  43. package/flow-host-revert.mjs +1 -42
  44. package/flow-models.mjs +1 -139
  45. package/flow-preview.mjs +1 -148
  46. package/flow-receipt.mjs +1 -122
  47. package/flow-redispatch.mjs +1 -72
  48. package/flow-review-gate.mjs +1 -402
  49. package/flow-review-reflect.mjs +1 -115
  50. package/flow-review.mjs +1 -152
  51. package/flow-scope-evidence.mjs +1 -117
  52. package/flow-skill-registry.mjs +1 -140
  53. package/flow-task-graph.mjs +1 -562
  54. package/flow-worktree.mjs +1 -71
  55. package/git-diff-report.mjs +1 -121
  56. package/hermes-delegation-guard.mjs +1 -14
  57. package/hermes-event-mapper.mjs +1 -194
  58. package/hermes-isolation.mjs +1 -61
  59. package/hermes-model-cache.mjs +1 -54
  60. package/hermes-policy.mjs +1 -92
  61. package/hermes-probe.mjs +1 -57
  62. package/hermes-session.mjs +1 -683
  63. package/hermes-setup.mjs +1 -167
  64. package/host-memory.mjs +1 -116
  65. package/interrupted-resume.mjs +1 -95
  66. package/keep-awake.mjs +1 -148
  67. package/key-shape.mjs +1 -49
  68. package/lane-continuation.mjs +1 -83
  69. package/lane-lifecycle.mjs +1 -189
  70. package/lane-worktree.mjs +1 -77
  71. package/launcher.mjs +1 -354
  72. package/mcp-flight-recorder.mjs +1 -79
  73. package/mockup-delivery.mjs +1 -57
  74. package/model-prices.mjs +1 -113
  75. package/package.json +13 -4
  76. package/pair-bus.mjs +1 -98
  77. package/pair-control-authority.mjs +1 -89
  78. package/past-work-search.mjs +1 -105
  79. package/plan-meters.mjs +1 -144
  80. package/presence.mjs +1 -191
  81. package/privacy-report.mjs +1 -108
  82. package/provider-resilience.mjs +1 -356
  83. package/provider.mjs +1 -133
  84. package/providers.mjs +1 -491
  85. package/publish-guard.mjs +2 -0
  86. package/publish-manifest.json +129 -0
  87. package/question-response.mjs +1 -58
  88. package/reap-terminal.mjs +1 -68
  89. package/recap.mjs +1 -297
  90. package/replay-transport.mjs +1 -64
  91. package/repo-search.mjs +1 -190
  92. package/review-check.mjs +1 -182
  93. package/runtime-contract.mjs +1 -93
  94. package/runtime-registry.mjs +1 -64
  95. package/runtime-session.mjs +1 -20
  96. package/scheduled-run-admission.mjs +1 -364
  97. package/scheduled-runs.mjs +1 -268
  98. package/sdk-admission.mjs +1 -9
  99. package/sdk-smoke.mjs +1 -61
  100. package/serve-consent.mjs +1 -118
  101. package/serve-dir.mjs +1 -40
  102. package/service.mjs +1 -877
  103. package/session-store.mjs +1 -484
  104. package/side-lane.mjs +1 -63
  105. package/supabase-key.mjs +1 -176
  106. package/supervisor-ready.mjs +1 -57
  107. package/switch-provider.mjs +1 -84
  108. package/terminal-name.mjs +1 -359
  109. package/terminal-row-reconcile.mjs +1 -70
  110. package/thinkpool-prompt-contracts.mjs +1 -85
  111. package/thinkpool-room-prompt.mjs +1 -187
  112. package/transcript-sanitize.mjs +1 -332
  113. package/turn-stall.mjs +1 -61
  114. package/update-gate.mjs +1 -53
  115. package/viewport.mjs +1 -810
  116. 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(()=>{})}