thinkpool-pair 0.7.159 → 0.7.161

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/account.mjs CHANGED
@@ -16,7 +16,8 @@ import os from 'node:os'
16
16
  import { saveAuth, loadAuth, loadDirs, bindDir, loadServed, rememberServed, loadDefaultDir, saveDefaultDir, claudeRecentProjectDir } from './auth-store.mjs'
17
17
  import { isSafeToRestart } from './update-gate.mjs'
18
18
  import { makeThrottledTrack } from './presence.mjs'
19
- import { supervisorServes } from './serve-consent.mjs'
19
+ import { publicKeyB64, announceProviders, listProviders, addProvider, removeProvider, unseal } from './providers.mjs'
20
+ import { supervisorServes, shouldCooldownOnExit, roomInCooldown } from './serve-consent.mjs'
20
21
  import { resolveServeDir } from './serve-dir.mjs'
21
22
  import { pairKeyFor, pairTopic, CROSSROOM_BUS } from './cross-terminal.mjs'
22
23
 
@@ -231,6 +232,13 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
231
232
  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: npx thinkpool-pair set-default-dir <dir> · one room: npx thinkpool-pair bind <code> <dir>)\n${svcHint}`)
232
233
 
233
234
  const children = new Map() // room -> child process
235
+ // grantCooldown: room -> wall-clock ms deadline. A grantee child that yielded to the
236
+ // owner's returning bridge (exit 75) parks its room here so discovery does NOT re-spawn a
237
+ // child that would only yield again — the owner-serves/grantee-stands-by coexistence state
238
+ // must not thrash-churn every 15s tick (DEFECT 2, 2026-07-05). Expires after TP_GRANT_COOLDOWN_MS;
239
+ // after that discovery retries once — serving normally if the owner has since gone.
240
+ const grantCooldown = new Map()
241
+ const GRANT_COOLDOWN_MS = Math.max(15_000, parseInt(process.env.TP_GRANT_COOLDOWN_MS, 10) || 120_000)
234
242
  const childIdle = new Map() // room -> bool (last idle report: in the quiet window)
235
243
  const childBetween = new Map() // room -> bool (between turns now — safe to restart per Contract #1)
236
244
  const childPeer = new Map() // room -> bool (a web client is watching this room)
@@ -362,7 +370,56 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
362
370
  // service (always-on, survives reboot) vs a foreground `npx` run that dies with its
363
371
  // terminal. THINKPOOL_PAIR_AUTOUPDATE=1 is set ONLY by the install-service plist/unit
364
372
  // (service.mjs), so it's the reliable proxy for "running as the managed service".
365
- const pushPresence = () => trackPresence({ name: machine, version: VERSION, rooms: [...children.keys()], refused: [...refused].map(([code, reason]) => ({ code, reason })), service: process.env.THINKPOOL_PAIR_AUTOUPDATE === '1', ts: Date.now() })
373
+ // providerPubKey: the bridge's RSA-OAEP public key (SPKI base64), generated +
374
+ // persisted 0600 on first read. The dashboard imports it to SEAL a provider's API
375
+ // key so it reaches the bridge without ever crossing the wire in the clear. Computed
376
+ // ONCE (the keypair is stable across restarts). `providers` is the name-only registry
377
+ // (id+name+model, NEVER the key or baseUrl) so the dashboard can list what's available
378
+ // to spawn against — re-read each announce so an add/remove reflects immediately.
379
+ // Both are additive; older clients ignore them (feature: multi-provider BYOK, slice 1).
380
+ const PROVIDER_PUBKEY = publicKeyB64()
381
+ const pushPresence = () => trackPresence({ name: machine, version: VERSION, rooms: [...children.keys()], refused: [...refused].map(([code, reason]) => ({ code, reason })), service: process.env.THINKPOOL_PAIR_AUTOUPDATE === '1', providerPubKey: PROVIDER_PUBKEY, providers: announceProviders(), ts: Date.now() })
382
+
383
+ // ── Provider registry — the multi-BYOK wire contract (slice 1). ─────────────
384
+ // AUTH: the account channel `tpacct:<uid>` is created WITHOUT config.private:true,
385
+ // so it is a PUBLIC realtime channel — NOT RLS-gated (the existing shutdown/restart
386
+ // handlers ride the same public channel; accepted for those, NOT for key management).
387
+ // Every provider event therefore carries the sender's `jwt` (session access token);
388
+ // we verify getUser(jwt).id === the owner uid before ANY registry mutation or read.
389
+ // Only the owner manages their own keys on their own machine — a partner is refused.
390
+ const OWNER_UID = session.user.id
391
+ const isOwner = async (jwt) => {
392
+ if (!jwt || typeof jwt !== 'string') return false
393
+ try { const { data } = await sb.auth.getUser(jwt); return !!data?.user && data.user.id === OWNER_UID } catch { return false }
394
+ }
395
+ const provReply = (event, payload) => { try { acct.send({ type: 'broadcast', event, payload }) } catch { /* channel down */ } }
396
+ // provider-add {sealed, nonce, jwt} → decrypt, validate, append, re-announce.
397
+ acct.on('broadcast', { event: 'provider-add' }, async ({ payload }) => {
398
+ const nonce = payload?.nonce
399
+ if (!(await isOwner(payload?.jwt))) return provReply('provider-add-res', { nonce, ok: false, error: 'unauthorized' })
400
+ let fields
401
+ // Decrypt failures answer ok:false WITHOUT logging the ciphertext or plaintext
402
+ // (security invariant d) — a bare boolean, never the sealed blob or the key.
403
+ try { fields = unseal(payload?.sealed) } catch { return provReply('provider-add-res', { nonce, ok: false, error: 'could not decrypt the sealed payload' }) }
404
+ const r = addProvider(fields)
405
+ if (r.ok) { pushPresence(); process.stderr.write(`\n ◆ provider added (${String(fields?.name || '').slice(0, 40)}) — re-announced.\n`) }
406
+ provReply('provider-add-res', { nonce, ok: r.ok, error: r.ok ? undefined : r.error, id: r.ok ? r.id : undefined })
407
+ })
408
+ // provider-remove {id, nonce, jwt} → remove (built-in refuses), re-announce.
409
+ acct.on('broadcast', { event: 'provider-remove' }, async ({ payload }) => {
410
+ const nonce = payload?.nonce
411
+ if (!(await isOwner(payload?.jwt))) return provReply('provider-remove-res', { nonce, ok: false, error: 'unauthorized' })
412
+ const r = removeProvider(payload?.id)
413
+ if (r.ok) { pushPresence(); process.stderr.write('\n ◆ provider removed — re-announced.\n') }
414
+ provReply('provider-remove-res', { nonce, ok: r.ok, error: r.ok ? undefined : r.error })
415
+ })
416
+ // providers-list-req {nonce, jwt} → the masked list (keyHint = last 4 chars only).
417
+ acct.on('broadcast', { event: 'providers-list-req' }, async ({ payload }) => {
418
+ const nonce = payload?.nonce
419
+ if (!(await isOwner(payload?.jwt))) return provReply('providers-list-res', { nonce, ok: false, error: 'unauthorized', providers: [] })
420
+ provReply('providers-list-res', { nonce, ok: true, providers: listProviders() })
421
+ })
422
+
366
423
  // Re-track on EVERY (re)subscribe, not just the first: a realtime reconnect
367
424
  // (network blip, or a token swap mid-flight) rejoins the channel and must
368
425
  // re-announce, or the dashboard would read "no bridge" until the next restart.
@@ -554,6 +611,13 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
554
611
  for (const r of rooms) {
555
612
  const room = r.code
556
613
  if (!room || children.has(room)) { if (room) refused.delete(room); continue } // served → not refused
614
+ // Grant-reclaim cooldown (DEFECT 2): this room's grantee child recently yielded to the
615
+ // owner's bridge (exit 75). Skip re-serving it until the window elapses so we don't
616
+ // thrash-respawn a child that will only yield again. refused already reads 'owner-serves'
617
+ // (set on that exit below), so presence stays honest while we stand by.
618
+ const cdUntil = grantCooldown.get(room)
619
+ if (roomInCooldown(cdUntil, Date.now())) continue
620
+ if (cdUntil) grantCooldown.delete(room) // window elapsed — allow one fresh attempt (owner may have gone)
557
621
  // Defense-in-depth behind the .or above: serve iff we OWN the room or hold its grant.
558
622
  if (!supervisorServes({ ownerId: r.owner_id, grantUid: r.serve_grant_uid, myUid: me })) continue
559
623
  // Resolve the serve dir: explicit bind > last-served memory > launch dir. REFUSE
@@ -673,13 +737,22 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
673
737
  try { w.reply(m) } catch { /* reply path gone */ }
674
738
  }
675
739
  })
676
- const dropRoom = () => {
740
+ const dropRoom = (code) => {
677
741
  children.delete(room); childIdle.delete(room); childBetween.delete(room); childPeer.delete(room); roomNames.delete(room); roomPartner.delete(room) // re-served on the next tick
742
+ // Owner-reclaim SENTINEL (75): the grantee child stood down because the owner's bridge
743
+ // returned. Park the room on a cooldown so discovery doesn't respawn it into another
744
+ // immediate yield (DEFECT 2), and mark refused 'owner-serves' so presence is honest.
745
+ // A revoke-yield exits 0 (discovery drops a revoked room anyway); owner-owned rooms
746
+ // never yield, so neither cools down. `code` is undefined on the 'error' path — fine.
747
+ if (shouldCooldownOnExit(code)) {
748
+ grantCooldown.set(room, Date.now() + GRANT_COOLDOWN_MS)
749
+ refused.set(room, 'owner-serves')
750
+ }
678
751
  // Fail any in-flight cross-room peeks/posts this dead room had ASKED (bus-origin
679
752
  // entries have askerRoom null and just time out on their own).
680
753
  for (const [reqId, w] of pairPending) { if (w?.askerRoom === room) pairPending.delete(reqId) }
681
754
  }
682
- child.on('exit', dropRoom)
755
+ child.on('exit', (code) => dropRoom(code))
683
756
  // A spawn that never starts (execPath/cwd vanished between the existsSync check and
684
757
  // spawn) emits 'error', NOT 'exit'. Without this handler the stale children-map entry
685
758
  // would block re-serving forever AND an unhandled 'error' would crash the supervisor.
package/bridge.mjs CHANGED
@@ -37,7 +37,11 @@ import { fileURLToPath } from 'node:url'
37
37
  import { createRequire } from 'node:module'
38
38
  import { randomUUID } from 'node:crypto'
39
39
  import { createClient } from '@supabase/supabase-js'
40
- import { serveDecision, amGrantee, standDownOnPeerAnnounce, standDownOnGrantChange, fetchServeRow, refusalMessage } from './serve-consent.mjs'
40
+ import { serveDecision, amGrantee, standDownOnPeerAnnounce, standDownOnGrantChange, fetchServeRow, refusalMessage, gateFailAction, GRANT_RECLAIM_EXIT } from './serve-consent.mjs'
41
+ // resolveProviderEnv(id) → {ANTHROPIC_BASE_URL,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_MODEL} for a
42
+ // registered custom provider, or null for the built-in/unknown (leave the default env intact).
43
+ // Multi-provider BYOK slice 1: a lane spawned with a `provider` id runs on that endpoint.
44
+ import { resolveProviderEnv } from './providers.mjs'
41
45
  import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
42
46
  import { z } from 'zod'
43
47
  import { startClaudeSession } from './claude-session.mjs'
@@ -586,8 +590,33 @@ let yielding = false // set once when standing down, so reclaim + revoke
586
590
  // Standalone room mode ran anon unless TP_ACCESS_TOKEN was in env; load the stored
587
591
  // login so `npx thinkpool-pair <CODE>` authenticates AS the signed-in user (the
588
592
  // identity the consent check needs). Account children already carry the env token.
593
+ // Refresh the stored access token BEFORE the gate (DEFECT 1, 2026-07-05). A stored
594
+ // access token is minted at login and Supabase access tokens expire ~1h; a long-lived
595
+ // standalone bridge — or a service relaunched from a stale store — would otherwise hit
596
+ // the RLS-gated gate with a DEAD token: fetchServeRow → 401, which (old behavior) masked
597
+ // as a network error and FAILED OPEN → the consent gate was bypassed in the common case.
598
+ // authedClient exchanges the rotated refresh token for a live pair and persists it via
599
+ // saveAuth (refresh-token rotation: the used token is now dead, so the store must never be
600
+ // left on the stale pair). Only refresh when the token came from the STORE — an env token
601
+ // (TP_ACCESS_TOKEN, from a supervisor that already keeps it fresh) is left untouched.
602
+ let tokenFromStore = false
589
603
  if (!codeAuthToken) {
590
- try { const { loadAuth } = await import('./auth-store.mjs'); codeAuthToken = loadAuth()?.access_token || null } catch { /* no store — stays anon */ }
604
+ try {
605
+ const { loadAuth } = await import('./auth-store.mjs')
606
+ codeAuthToken = loadAuth()?.access_token || null
607
+ if (codeAuthToken) tokenFromStore = true
608
+ } catch { /* no store — stays anon */ }
609
+ }
610
+ if (tokenFromStore) {
611
+ try {
612
+ const { authedClient } = await import('./account.mjs')
613
+ const refreshed = await authedClient(SUPABASE_URL, SUPABASE_ANON)
614
+ if (refreshed?.session?.access_token) codeAuthToken = refreshed.session.access_token
615
+ // If the refresh FAILS (dead refresh token / offline), keep the stale token: an
616
+ // offline refresh must not strand a legit owner. A dead refresh token then yields a
617
+ // 401 at fetchServeRow → refuse (correct: the login is genuinely gone); a transient
618
+ // offline refresh yields a fetch reject → fail-open (correct: availability).
619
+ } catch { /* account module / refresh failed — proceed with the stored token */ }
591
620
  }
592
621
  try {
593
622
  if (codeAuthToken) {
@@ -606,8 +635,15 @@ let yielding = false // set once when standing down, so reclaim + revoke
606
635
  iServeAsGrantee = amGrantee({ ownerId, grantUid, myUid: myServeUid })
607
636
  if (iServeAsGrantee) process.stderr.write(`\n ◆ serving room ${room} by the owner's grant — will yield if the owner's bridge returns or the grant is revoked.\n`)
608
637
  } catch (e) {
609
- // Fail OPEN on a network/transport error ONLY (spec: never strand a user on a
610
- // flaky fetch). A clean "no row / not granted" is a decision above, not a throw.
638
+ // A THROWN fetchServeRow error is either a definitively-bad auth (401/403) or an
639
+ // availability failure (5xx / transport reject). Only the latter fails OPEN — a stale
640
+ // 401 must NEVER serve, or the gate is bypassed (DEFECT 1). gateFailAction classifies.
641
+ if (gateFailAction(e) === 'refuse') {
642
+ process.stderr.write(refusalMessage({ room, name: null, reason: 'no-identity' }))
643
+ process.stderr.write(`\n ◇ (serve-consent: auth rejected at the gate — ${e.message}; the stored login is expired or revoked. Re-run: npx thinkpool-pair login)\n`)
644
+ process.exit(0)
645
+ }
646
+ // Availability failure → fail OPEN (spec: never strand a user on a flaky fetch).
611
647
  process.stderr.write(`\n ◇ serve-consent check degraded (${e.message}); proceeding without it.\n`)
612
648
  }
613
649
  }
@@ -902,7 +938,13 @@ function onPeerBridgeAnnounce (payload) {
902
938
  if (!standDownOnPeerAnnounce({ myUid: myServeUid, ownerId: roomOwnerId, peerUid: payload?.uid })) return
903
939
  yielding = true
904
940
  process.stderr.write(`\n ◆ owner's bridge reconnected — yielding room ${room}.\n`)
905
- shutdown(0)
941
+ // Exit with the grant-reclaim SENTINEL (not a clean 0). A supervisor that spawned this
942
+ // grantee child reads the code and puts the room on a cooldown, so it does NOT respawn a
943
+ // child that will only yield again — the owner-serves/grantee-stands-by coexistence state
944
+ // must not thrash-churn every discovery tick (DEFECT 2, 2026-07-05). shutdown() still sets
945
+ // shuttingDown FIRST, so the term-exit broadcast stays suppressed and structured terminal
946
+ // rows survive the stand-down (#205/#208 durability — a handover must not delete rows).
947
+ shutdown(GRANT_RECLAIM_EXIT)
906
948
  }
907
949
 
908
950
  // ── Thinkpool Ensemble — cross-ROOM reach (Tier 1: same-machine pair peek) ────
@@ -1374,12 +1416,12 @@ function worktreeSnapshot(cwd) {
1374
1416
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
1375
1417
  // persist to the host file; tool calls round-trip through the perm card; the
1376
1418
  // rolling log replays to joiners and survives bridge restarts (session-store).
1377
- function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, reviewSliceRoots, openedAt, defer }) {
1419
+ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, reviewSliceRoots, openedAt, defer, provider }) {
1378
1420
  if (sessions.has(id)) return
1379
1421
  // spawnedBy: set when this lane was Dispatched (spawn_terminal). Restored from the
1380
1422
  // session store so the Ensemble flag survives a bridge restart (else a respin
1381
1423
  // stripped it and the lane reverted to a plain tab — the t6 "no chip" bug).
1382
- const entry = { cmd: 'claude', kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: Array.isArray(commands) ? commands : undefined, mode, model: model || null, spawnedBy: spawnedBy || undefined, flowSessionId: flowSessionId || null, flowTaskKey: flowTaskKey || null, cwd: cwd || null,
1424
+ const entry = { cmd: 'claude', kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: Array.isArray(commands) ? commands : undefined, mode, model: model || null, provider: provider || null, spawnedBy: spawnedBy || undefined, flowSessionId: flowSessionId || null, flowTaskKey: flowTaskKey || null, cwd: cwd || null,
1383
1425
  // Stable creation order — persisted so a bridge restart restores tabs in the SAME
1384
1426
  // order (not readdir/filesystem order). Legacy recs (no openedAt) derive it from the
1385
1427
  // first transcript event ts, so even the first post-fix restart is ordered right.
@@ -1557,7 +1599,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1557
1599
  // restart. Without this, sessionData omitted it → on restart the resumed session
1558
1600
  // re-launched on the host default (Opus) regardless of the last switch, and the
1559
1601
  // switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
1560
- const sessionData = () => ({ sessionId: entry.session?.sessionId || resume || null, log: entry.log, commands: entry.commands, mode: entry.mode, model: entry.model || null, spawnedBy: entry.spawnedBy, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, cwd: entry.cwd, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null })
1602
+ const sessionData = () => ({ sessionId: entry.session?.sessionId || resume || null, log: entry.log, commands: entry.commands, mode: entry.mode, model: entry.model || null, provider: entry.provider || null, spawnedBy: entry.spawnedBy, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, cwd: entry.cwd, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null })
1561
1603
  const persist = () => saveSession(room, id, sessionData())
1562
1604
  // Synchronous flush of this session's record. Used on open (so a brand-new session
1563
1605
  // has a file under its id BEFORE its first event — surviving a restart inside the
@@ -1723,6 +1765,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1723
1765
  name: z.string().max(80).optional().describe('a short label for the new lane so the room (and you) can find it, e.g. "Research: topologies"'),
1724
1766
  task: z.string().optional().describe('an initial task to hand the new lane immediately; omit to open it idle'),
1725
1767
  model: z.string().optional().describe('optional model for the lane, e.g. opus / sonnet / haiku'),
1768
+ provider: z.string().optional().describe('optional registered LLM provider id to run this lane on (from the account\'s provider registry); omit for the default Claude/Anthropic path'),
1726
1769
  mode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan']).optional().describe('permission mode for the new lane; defaults to inheriting YOUR current mode. Raising a lane to bypassPermissions from a non-bypass lane asks the room to confirm once.'),
1727
1770
  },
1728
1771
  async (args) => {
@@ -1753,7 +1796,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1753
1796
  // plan-mode lane would stall on ExitPlanMode cards. An explicit args.mode still wins;
1754
1797
  // otherwise inherit, but fall plan → default (an autonomous worker doesn't plan-gate).
1755
1798
  const childMode = args?.mode || (entry.mode === 'plan' ? 'default' : entry.mode)
1756
- openStructured({ id: newId, model: args?.model, mode: childMode })
1799
+ openStructured({ id: newId, model: args?.model, provider: args?.provider, mode: childMode })
1757
1800
  const ne = sessions.get(newId)
1758
1801
  if (!ne) { if (args?.name) { delete termNames[newId]; saveNames(room, termNames) } return okText('Could not open a new lane — the terminal cap may have just been reached. Close one and retry.') }
1759
1802
  ne.spawnedBy = id // ownership: only the spawner may close_terminal it
@@ -1937,7 +1980,12 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1937
1980
  // Tier 3 — the SENDER-side gate for post_to_session (the receipt card lives in
1938
1981
  // the target room). roomHop/crossRoomPostCount reset on a real human turn (code-turn).
1939
1982
  crossRoomPostGate: () => crossRoomPostDecision({ roomHop: entry.roomHop || 0, postCount: entry.crossRoomPostCount || 0, disabled: process.env.TP_PAIRBUS_OFF === '1' }),
1940
- env: { ...process.env, ...buildConductorEnv({ flowSessionId, mode }), TP_MOCKUP_OUTBOX: mockupOutbox },
1983
+ // Per-lane provider (multi-BYOK slice 1): a lane opened with a registered `provider`
1984
+ // id runs on THAT Anthropic-compatible endpoint. resolveProviderEnv → {ANTHROPIC_BASE_URL,
1985
+ // ANTHROPIC_AUTH_TOKEN, ANTHROPIC_MODEL}, spread AFTER process.env so it overrides the
1986
+ // per-machine default (provider.mjs/applyProviderEnv). null for built-in/unknown → the
1987
+ // default Claude env is left exactly as-is (unchanged path).
1988
+ env: { ...process.env, ...buildConductorEnv({ flowSessionId, mode }), ...(resolveProviderEnv(provider) || {}), TP_MOCKUP_OUTBOX: mockupOutbox },
1941
1989
  onEvent: (evt) => {
1942
1990
  // Self-heal a stale resume — the saved SDK session expired. Reopen fresh,
1943
1991
  // keeping the transcript (scrollback survives; live context is gone).
@@ -2282,7 +2330,7 @@ channel
2282
2330
  // previous terminal's model (and the first uses the provider default). Falls
2283
2331
  // through to the SDK default when absent. Stamped on the entry so the announce
2284
2332
  // carries it at open (chip shows the model immediately, not the branch).
2285
- openStructured({ id: payload.id, mode: payload.mode, model: payload.model || undefined })
2333
+ openStructured({ id: payload.id, mode: payload.mode, model: payload.model || undefined, provider: payload.provider || undefined })
2286
2334
  process.stderr.write(`\n ◆ web opened a structured "${payload.cmd}" session (${payload.mode || 'default'}${payload.model ? `, model ${payload.model}` : ''}).\n`)
2287
2335
  return
2288
2336
  }
@@ -2390,7 +2438,7 @@ channel
2390
2438
  // ── structured-session control (Phase 2) ──
2391
2439
  .on('broadcast', { event: 'code-open' }, ({ payload }) => {
2392
2440
  if (payload?.host && payload.host !== name) return
2393
- openStructured({ id: payload?.id || randomUUID(), model: payload?.model, resume: payload?.resume, mode: payload?.mode })
2441
+ openStructured({ id: payload?.id || randomUUID(), model: payload?.model, provider: payload?.provider, resume: payload?.resume, mode: payload?.mode })
2394
2442
  })
2395
2443
  .on('broadcast', { event: 'code-turn' }, async ({ payload }) => {
2396
2444
  const s = payload?.term && sessions.get(payload.term)
@@ -2573,7 +2621,7 @@ channel
2573
2621
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
2574
2622
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
2575
2623
  // lanes keep their worktree cwd + the ability to mark done.
2576
- openStructured({ id: rec.id, model: rec.model || undefined, resume: canResume(rec) ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, cwd: rec.cwd, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt,
2624
+ openStructured({ id: rec.id, model: rec.model || undefined, provider: rec.provider || undefined, resume: canResume(rec) ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, cwd: rec.cwd, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt,
2577
2625
  // Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
2578
2626
  // shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
2579
2627
  // (mid-turn needs auto-resume; flow needs its lane live).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.159",
3
+ "version": "0.7.161",
4
4
  "description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,6 +37,7 @@
37
37
  "account.mjs",
38
38
  "auth-store.mjs",
39
39
  "provider.mjs",
40
+ "providers.mjs",
40
41
  "README.md"
41
42
  ],
42
43
  "scripts": {
package/providers.mjs ADDED
@@ -0,0 +1,232 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ providers.mjs — the bridge's LLM-provider REGISTRY (multi-BYOK).
3
+
4
+ Slice 1 of the multi-provider BYOK feature (spec:
5
+ docs/specs/2026-07-05-llm-provider-registry.md). Lets a user register
6
+ extra Anthropic-compatible providers (name + base url + model + API
7
+ key) on their OWN bridge; a Code lane can then spawn against any
8
+ registered provider by id. Keys live ONLY on the bridge host — they are
9
+ never broadcast, never written to the DB, never in an announce.
10
+
11
+ This module owns:
12
+ • providers.json — the registry ({id,name,baseUrl,model,key,addedAt}[]), 0600
13
+ • bridge-key.json — the bridge's RSA keypair, 0600 (host-only)
14
+ • seal/unseal — the hybrid envelope the dashboard uses to hand the
15
+ bridge a key WITHOUT it ever crossing the wire in the
16
+ clear (RSA-OAEP wraps an AES-256-GCM session key).
17
+ • env resolution — {ANTHROPIC_BASE_URL,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_MODEL}
18
+ for a chosen provider id, matching provider.mjs conventions.
19
+
20
+ The built-in `anthropic` provider (the host's regular Claude login) is
21
+ IMPLICIT: never stored, never removable, always present. A lane with no
22
+ provider id runs the default Claude path unchanged.
23
+
24
+ ── Why HYBRID sealing, not raw RSA ──────────────────────────────────
25
+ RSA-OAEP-2048/SHA-256 caps plaintext at 190 bytes. A real payload —
26
+ {name,baseUrl,model,key} with e.g. an OpenAI `sk-proj-…` key (~164 chars)
27
+ plus JSON overhead — blows past that and RSA encrypt throws. So `sealed`
28
+ is a hybrid envelope: RSA-OAEP wraps a fresh 32-byte AES key, AES-256-GCM
29
+ encrypts the payload. Both ends are WebCrypto-native (browser dashboard
30
+ seals, Node bridge unseals). Envelope shape (base64 of UTF8 JSON):
31
+ { v:1, ek:base64(RSA-OAEP(aesKeyRaw)), iv:base64(12B), ct:base64(gcmCiphertext||16B-tag) }
32
+ WebCrypto's subtle.encrypt returns ciphertext||tag concatenated; Node's
33
+ createDecipheriv wants the tag separately, so unseal splits the last 16.
34
+
35
+ ── Auth (see account.mjs handlers) ─────────────────────────────────
36
+ The account channel `tpacct:<uid>` is a PUBLIC realtime channel (no
37
+ config.private:true), so it is NOT RLS-gated — the sender is verified at
38
+ the application layer (getUser(jwt).id === owner uid) before any registry
39
+ mutation. This module stays transport-agnostic; the caller does auth.
40
+ ───────────────────────────────────────────────────────────── */
41
+ import os from 'node:os'
42
+ import fs from 'node:fs'
43
+ import path from 'node:path'
44
+ import crypto from 'node:crypto'
45
+
46
+ const DIR = path.join(os.homedir(), '.thinkpool-pair')
47
+ const REG_FILE = path.join(DIR, 'providers.json')
48
+ const KEY_FILE = path.join(DIR, 'bridge-key.json')
49
+
50
+ // The built-in provider id — always present, never stored, never removable.
51
+ export const BUILTIN_ID = 'anthropic'
52
+
53
+ function ensureDir() { try { fs.mkdirSync(DIR, { recursive: true, mode: 0o700 }) } catch { /* noop */ } }
54
+
55
+ // ── registry load / save ───────────────────────────────────────────────
56
+ /** @returns {Array<{id:string,name:string,baseUrl:string,model?:string,key:string,addedAt:number}>} */
57
+ export function loadProviders() {
58
+ try {
59
+ const arr = JSON.parse(fs.readFileSync(REG_FILE, 'utf8'))
60
+ return Array.isArray(arr) ? arr.filter((p) => p && p.id && p.id !== BUILTIN_ID) : []
61
+ } catch { return [] }
62
+ }
63
+
64
+ function saveProviders(arr) {
65
+ ensureDir()
66
+ fs.writeFileSync(REG_FILE, JSON.stringify(arr, null, 2), { mode: 0o600 })
67
+ // Writing with { mode } only applies on CREATE; harden an existing file too.
68
+ try { fs.chmodSync(REG_FILE, 0o600) } catch { /* noop */ }
69
+ }
70
+
71
+ // ── keypair ─────────────────────────────────────────────────────────────
72
+ // Generate an RSA-OAEP-2048 keypair on first run, persist 0600, return the
73
+ // public key as SPKI base64 (what the dashboard imports) + the private key
74
+ // object (for unseal). Idempotent: a persisted key is reused.
75
+ let _keyCache = null
76
+ export function ensureKeypair() {
77
+ if (_keyCache) return _keyCache
78
+ ensureDir()
79
+ try {
80
+ const saved = JSON.parse(fs.readFileSync(KEY_FILE, 'utf8'))
81
+ if (saved?.publicKeyB64 && saved?.privateKeyPem) {
82
+ _keyCache = {
83
+ publicKeyB64: saved.publicKeyB64,
84
+ privateKey: crypto.createPrivateKey({ key: saved.privateKeyPem, format: 'pem' }),
85
+ }
86
+ return _keyCache
87
+ }
88
+ } catch { /* generate below */ }
89
+
90
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 })
91
+ const publicKeyB64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64')
92
+ const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' })
93
+ fs.writeFileSync(KEY_FILE, JSON.stringify({ publicKeyB64, privateKeyPem, createdAt: Date.now() }, null, 2), { mode: 0o600 })
94
+ try { fs.chmodSync(KEY_FILE, 0o600) } catch { /* noop */ }
95
+ _keyCache = { publicKeyB64, privateKey }
96
+ return _keyCache
97
+ }
98
+
99
+ /** SPKI base64 public key for the announce (`providerPubKey`). */
100
+ export function publicKeyB64() { return ensureKeypair().publicKeyB64 }
101
+
102
+ // ── seal / unseal (hybrid RSA-OAEP + AES-256-GCM) ───────────────────────
103
+ // seal() is here so the UNIT can drive both ends node-side; the real seal
104
+ // happens in the browser dashboard via WebCrypto against the same envelope.
105
+ /** @param {object} payload @param {string=} pubKeyB64 → base64(envelope JSON) */
106
+ export function seal(payload, pubKeyB64) {
107
+ const spkiB64 = pubKeyB64 || publicKeyB64()
108
+ const publicKey = crypto.createPublicKey({ key: Buffer.from(spkiB64, 'base64'), format: 'der', type: 'spki' })
109
+ const aesKey = crypto.randomBytes(32)
110
+ const iv = crypto.randomBytes(12)
111
+ const cipher = crypto.createCipheriv('aes-256-gcm', aesKey, iv)
112
+ const enc = Buffer.concat([cipher.update(JSON.stringify(payload), 'utf8'), cipher.final()])
113
+ const tag = cipher.getAuthTag()
114
+ const ek = crypto.publicEncrypt(
115
+ { key: publicKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
116
+ aesKey,
117
+ )
118
+ const envelope = {
119
+ v: 1,
120
+ ek: ek.toString('base64'),
121
+ iv: iv.toString('base64'),
122
+ ct: Buffer.concat([enc, tag]).toString('base64'), // ct||tag, matches WebCrypto
123
+ }
124
+ return Buffer.from(JSON.stringify(envelope), 'utf8').toString('base64')
125
+ }
126
+
127
+ /**
128
+ * Decrypt a sealed envelope with the bridge private key. Returns the parsed
129
+ * payload object, or throws. Callers MUST catch and answer ok:false WITHOUT
130
+ * logging the ciphertext or plaintext (security invariant d).
131
+ * @param {string} sealedB64 base64(envelope JSON)
132
+ */
133
+ export function unseal(sealedB64) {
134
+ const { privateKey } = ensureKeypair()
135
+ const env = JSON.parse(Buffer.from(String(sealedB64 || ''), 'base64').toString('utf8'))
136
+ if (!env || env.v !== 1 || !env.ek || !env.iv || !env.ct) throw new Error('bad envelope')
137
+ const aesKey = crypto.privateDecrypt(
138
+ { key: privateKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
139
+ Buffer.from(env.ek, 'base64'),
140
+ )
141
+ const iv = Buffer.from(env.iv, 'base64')
142
+ const ctTag = Buffer.from(env.ct, 'base64')
143
+ if (ctTag.length < 16) throw new Error('bad ciphertext')
144
+ const tag = ctTag.subarray(ctTag.length - 16)
145
+ const ct = ctTag.subarray(0, ctTag.length - 16)
146
+ const decipher = crypto.createDecipheriv('aes-256-gcm', aesKey, iv)
147
+ decipher.setAuthTag(tag)
148
+ const dec = Buffer.concat([decipher.update(ct), decipher.final()])
149
+ return JSON.parse(dec.toString('utf8'))
150
+ }
151
+
152
+ // ── CRUD ────────────────────────────────────────────────────────────────
153
+ function validate({ name, baseUrl, key }) {
154
+ if (!name || typeof name !== 'string' || !name.trim()) return 'name is required'
155
+ if (!key || typeof key !== 'string' || !key.trim()) return 'key is required'
156
+ let u
157
+ try { u = new URL(String(baseUrl)) } catch { return 'baseUrl must be a valid https url' }
158
+ if (u.protocol !== 'https:') return 'baseUrl must be https'
159
+ return null
160
+ }
161
+
162
+ /**
163
+ * Append a provider. `fields` = {name, baseUrl, model?, key} (already unsealed).
164
+ * @returns {{ok:true,id:string} | {ok:false,error:string}}
165
+ */
166
+ export function addProvider(fields) {
167
+ const err = validate(fields || {})
168
+ if (err) return { ok: false, error: err }
169
+ const arr = loadProviders()
170
+ const id = crypto.randomUUID()
171
+ arr.push({
172
+ id,
173
+ name: String(fields.name).trim(),
174
+ baseUrl: String(fields.baseUrl).trim(),
175
+ model: fields.model ? String(fields.model).trim() : undefined,
176
+ key: String(fields.key).trim(),
177
+ addedAt: Date.now(),
178
+ })
179
+ saveProviders(arr)
180
+ return { ok: true, id }
181
+ }
182
+
183
+ /**
184
+ * Remove a provider by id. The built-in `anthropic` refuses.
185
+ * @returns {{ok:true} | {ok:false,error:string}}
186
+ */
187
+ export function removeProvider(id) {
188
+ if (!id || id === BUILTIN_ID) return { ok: false, error: 'the built-in Anthropic provider cannot be removed' }
189
+ const arr = loadProviders()
190
+ const next = arr.filter((p) => p.id !== id)
191
+ if (next.length === arr.length) return { ok: false, error: 'no such provider' }
192
+ saveProviders(next)
193
+ return { ok: true }
194
+ }
195
+
196
+ // ── read-only projections (never expose the raw key) ────────────────────
197
+ /** Masked list for the dashboard: keyHint = last 4 chars only. Built-in first. */
198
+ export function listProviders() {
199
+ const custom = loadProviders().map((p) => ({
200
+ id: p.id,
201
+ name: p.name,
202
+ model: p.model || null,
203
+ keyHint: keyHint(p.key),
204
+ }))
205
+ return [{ id: BUILTIN_ID, name: 'Anthropic (Claude)', model: null, keyHint: null }, ...custom]
206
+ }
207
+
208
+ /** Name-only projection for the announce/presence payload — NO key, NO baseUrl. */
209
+ export function announceProviders() {
210
+ const custom = loadProviders().map((p) => ({ id: p.id, name: p.name, model: p.model || null }))
211
+ return [{ id: BUILTIN_ID, name: 'Anthropic (Claude)', model: null }, ...custom]
212
+ }
213
+
214
+ function keyHint(key) {
215
+ const k = String(key || '')
216
+ return k.length <= 4 ? k : k.slice(-4)
217
+ }
218
+
219
+ /**
220
+ * Resolve the spawn env for a provider id, matching provider.mjs conventions
221
+ * exactly (ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_MODEL).
222
+ * Built-in / unknown id → null (the caller leaves the default Claude env intact).
223
+ * @returns {{ANTHROPIC_BASE_URL:string,ANTHROPIC_AUTH_TOKEN:string,ANTHROPIC_MODEL?:string} | null}
224
+ */
225
+ export function resolveProviderEnv(id) {
226
+ if (!id || id === BUILTIN_ID) return null
227
+ const p = loadProviders().find((x) => x.id === id)
228
+ if (!p) return null
229
+ const env = { ANTHROPIC_BASE_URL: p.baseUrl, ANTHROPIC_AUTH_TOKEN: p.key }
230
+ if (p.model) env.ANTHROPIC_MODEL = p.model
231
+ return env
232
+ }
package/serve-consent.mjs CHANGED
@@ -74,9 +74,16 @@ export function standDownOnGrantChange ({ myUid, ownerId, grantUid }) {
74
74
 
75
75
  // fetchServeRow — read the room's consent columns with whatever identity the bridge
76
76
  // holds (authed token → RLS returns the row if the user is a member; anon → empty).
77
- // THROWS on a network/transport error so the caller can fail OPEN (never strand a
78
- // user on a flaky fetch). A clean HTTP response that simply yields no row is NOT a
79
- // throw — it returns { ownerId: null } and the caller refuses (unknown room).
77
+ // THROWS on a non-OK response or a transport error. The thrown error carries `.authHard`
78
+ // so the caller can tell the two apart (Contract C-CODE-3 invariant 2, hardened 2026-07-05):
79
+ // - 401/403 (authHard === true) → auth is DEFINITIVELY bad (dead/expired token). The
80
+ // caller REFUSES: a stale access token must never fail OPEN into serving, or the gate
81
+ // is bypassed in the common case (stored tokens expire ~1h). The caller refreshes the
82
+ // token BEFORE the gate, so a 401 here means the refresh also failed → genuinely unauthed.
83
+ // - 5xx / genuine fetch() reject (authHard === false) → availability failure. The caller
84
+ // fails OPEN (never strand a user on a flaky network / a Supabase blip).
85
+ // A clean HTTP response that simply yields no row is NOT a throw — it returns
86
+ // { ownerId: null } and the caller refuses (unknown room).
80
87
  export async function fetchServeRow ({ supabaseUrl, anonKey, token, room }) {
81
88
  const headers = { apikey: anonKey }
82
89
  if (token) headers.Authorization = `Bearer ${token}`
@@ -84,7 +91,12 @@ export async function fetchServeRow ({ supabaseUrl, anonKey, token, room }) {
84
91
  `${supabaseUrl}/rest/v1/code_sessions?select=owner_id,serve_grant_uid,name&code=eq.${encodeURIComponent(room)}`,
85
92
  { headers },
86
93
  )
87
- if (!r.ok) throw new Error(`code_sessions fetch ${r.status}`)
94
+ if (!r.ok) {
95
+ const err = new Error(`code_sessions fetch ${r.status}`)
96
+ err.status = r.status
97
+ err.authHard = r.status === 401 || r.status === 403
98
+ throw err
99
+ }
88
100
  const rows = await r.json()
89
101
  const row = Array.isArray(rows) ? rows[0] : null
90
102
  return {
@@ -94,6 +106,36 @@ export async function fetchServeRow ({ supabaseUrl, anonKey, token, room }) {
94
106
  }
95
107
  }
96
108
 
109
+ // gateFailAction — pure classifier for a THROWN fetchServeRow error at the boot gate.
110
+ // 'refuse' — auth is definitively bad (401/403 after a token refresh); do NOT fail open.
111
+ // 'fail-open' — an availability error (5xx, offline, transport reject); serve rather than
112
+ // strand a user on a flaky fetch. This is the ONLY fail-open path.
113
+ export function gateFailAction (err) {
114
+ return err?.authHard ? 'refuse' : 'fail-open'
115
+ }
116
+
117
+ // ── supervisor grant-reclaim cooldown (DEFECT 2, 2026-07-05) ──────────────────
118
+ // A grantee child that yields to the owner's returning bridge exits with this SENTINEL
119
+ // code (distinct from a clean 0 / a crash 1). The supervisor reads the exit code and puts
120
+ // the room on a cooldown so it does NOT thrash-respawn a child that will only yield again —
121
+ // the feature's INTENDED coexistence state (owner serves, grantee stands by) must not churn.
122
+ // A revoke-yield keeps exit 0 (discovery drops a revoked room anyway); owner-owned rooms
123
+ // never yield, so they never cool down.
124
+ export const GRANT_RECLAIM_EXIT = 75
125
+
126
+ // shouldCooldownOnExit — did this child exit BECAUSE the owner reclaimed the room (75)?
127
+ // Only that exit arms the cooldown; a clean stop (0), a crash (1), or a signal (null) do not.
128
+ export function shouldCooldownOnExit (code) {
129
+ return code === GRANT_RECLAIM_EXIT
130
+ }
131
+
132
+ // roomInCooldown — is a room still inside its grant-reclaim cooldown window? The supervisor
133
+ // discovery tick skips re-serving while this is true. `until` is a wall-clock ms deadline
134
+ // (Date.now() + window); a null/expired deadline means serve again (owner may have gone).
135
+ export function roomInCooldown (until, now) {
136
+ return !!(until && now < until)
137
+ }
138
+
97
139
  // refusalMessage — the human-facing stderr line when a bridge refuses to serve.
98
140
  export function refusalMessage ({ room, name, reason }) {
99
141
  const label = name ? `"${name}"` : '(unnamed)'