thinkpool-pair 0.7.326 → 0.7.327

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 (3) hide show
  1. package/account.mjs +30 -34
  2. package/bridge.mjs +87 -178
  3. package/package.json +1 -1
package/account.mjs CHANGED
@@ -25,6 +25,7 @@ import { hostMemoryAdmission } from './host-memory.mjs'
25
25
  import { createPairBusBroker, mergePairRoomRoster, pairBusStatusFromRealtime, PAIR_BUS_STATUS } from './pair-bus.mjs'
26
26
  import { clearSupervisorReady, supervisorPresenceEchoed, writeSupervisorReady } from './supervisor-ready.mjs'
27
27
  import { PAIR_CLI, pairCli } from './command-guidance.mjs'
28
+ import { syncRealtimeAuth } from './design-edit.mjs'
28
29
 
29
30
  const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return null } })()
30
31
 
@@ -515,7 +516,11 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
515
516
  let lastClaimHeld = null // track state changes for claim_tick logging
516
517
  console.log(`◇ supervisor boot sup=${SUP_ID} host=${machine} pid=${process.pid} npx=${VERSION || 'dev'}`)
517
518
 
519
+ if (!await syncRealtimeAuth(sb, currentAccessToken, SUPABASE_ANON)) {
520
+ process.stderr.write('\n ⚠ realtime auth could not be initialized; private bridge control is unavailable.\n')
521
+ }
518
522
  const acct = sb.channel(`tpacct:${session.user.id}`, { config: { presence: { key: machine }, broadcast: { self: false } } })
523
+ const acctControl = sb.channel(`tpacct-control:${session.user.id}`, { config: { private: true, broadcast: { self: false } } })
519
524
  // One id per account-bridge PROCESS. The dashboard keeps its restart indicator
520
525
  // up until this value changes, so stale presence + fresh claim heartbeats can
521
526
  // never impersonate a completed restart.
@@ -523,8 +528,8 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
523
528
  let restarting = false // set by the "restart" broadcast → stop() exits non-zero so a supervisor respawns us
524
529
  // Web "attach this session" card → bind an unbound/new room to a local dir (and
525
530
  // optionally set it as the account default) so it serves WITHOUT a terminal command.
526
- // Rides tpacct (keyed to the owner's uid); the dir must exist on THIS machine.
527
- acct.on('broadcast', { event: 'bind-room' }, ({ payload }) => {
531
+ // Rides the private, owner-gated control topic; the dir must exist on THIS machine.
532
+ acctControl.on('broadcast', { event: 'bind-room' }, ({ payload }) => {
528
533
  const code = String(payload?.code || '').toUpperCase().trim()
529
534
  let dir = String(payload?.dir || '').trim()
530
535
  if (dir.startsWith('~')) dir = path.join(os.homedir(), dir.slice(1))
@@ -541,7 +546,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
541
546
  // so the toggle would appear to do nothing (presence leaves, then rejoins).
542
547
  // Uninstall the account service FIRST so the supervisor won't restart us, then
543
548
  // stop. Mirrors the per-room session-deleted guard in bridge.mjs.
544
- acct.on('broadcast', { event: 'shutdown' }, async () => {
549
+ acctControl.on('broadcast', { event: 'shutdown' }, async () => {
545
550
  process.stderr.write('\n ◇ disconnect requested from the dashboard — stopping bridge.\n')
546
551
  // Hard-exit backstop, armed FIRST — independent of the SIGTERM round-trip,
547
552
  // the realtime socket, and every await below. If the graceful stop() wedges
@@ -565,14 +570,10 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
565
570
  // just stops — restart is meaningful for the always-on bridge. Old bridges (pre-0.7.60)
566
571
  // don't subscribe to this event, so the dashboard button is a harmless no-op there.
567
572
  let restartPreparationRunning = false
568
- acct.on('broadcast', { event: 'restart' }, async ({ payload } = {}) => {
573
+ acctControl.on('broadcast', { event: 'restart' }, async ({ payload } = {}) => {
569
574
  const nonce = payload?.nonce
570
575
  const reply = async (ok, error) => {
571
- try { await acct.send({ type: 'broadcast', event: 'restart-status', payload: { nonce, ok, error } }) } catch { /* channel down */ }
572
- }
573
- if (!(await isOwner(payload?.jwt))) {
574
- await reply(false, 'unauthorized')
575
- return
576
+ try { await acctControl.send({ type: 'broadcast', event: 'restart-status', payload: { nonce, ok, error } }) } catch { /* channel down */ }
576
577
  }
577
578
  // A dashboard restart is an EXPLICIT apply request, but it is not permission
578
579
  // to interrupt a live turn or a pending human decision. Stage the immutable
@@ -636,22 +637,12 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
636
637
  }
637
638
 
638
639
  // ── Provider registry — the multi-BYOK wire contract (slice 1). ─────────────
639
- // AUTH: the account channel `tpacct:<uid>` is created WITHOUT config.private:true,
640
- // so it is a PUBLIC realtime channel — NOT RLS-gated (the existing shutdown/restart
641
- // handlers ride the same public channel; accepted for those, NOT for key management).
642
- // Every provider event therefore carries the sender's `jwt` (session access token);
643
- // we verify getUser(jwt).id === the owner uid before ANY registry mutation or read.
644
- // Only the owner manages their own keys on their own machine — a partner is refused.
645
- const OWNER_UID = session.user.id
646
- const isOwner = async (jwt) => {
647
- if (!jwt || typeof jwt !== 'string') return false
648
- try { const { data } = await sb.auth.getUser(jwt); return !!data?.user && data.user.id === OWNER_UID } catch { return false }
649
- }
650
- const provReply = (event, payload) => { try { acct.send({ type: 'broadcast', event, payload }) } catch { /* channel down */ } }
651
- // provider-add {sealed, nonce, jwt} → decrypt, validate, append, re-announce.
652
- acct.on('broadcast', { event: 'provider-add' }, async ({ payload }) => {
640
+ // The private tpacct-control:<uid> join/send policy binds the sender to this
641
+ // exact owner. Credentials stay in the Realtime handshake, never the payload.
642
+ const provReply = (event, payload) => { try { acctControl.send({ type: 'broadcast', event, payload }) } catch { /* channel down */ } }
643
+ // provider-add {sealed, nonce} → decrypt, validate, append, re-announce.
644
+ acctControl.on('broadcast', { event: 'provider-add' }, async ({ payload }) => {
653
645
  const nonce = payload?.nonce
654
- if (!(await isOwner(payload?.jwt))) return provReply('provider-add-res', { nonce, ok: false, error: 'unauthorized' })
655
646
  let fields
656
647
  // Decrypt failures answer ok:false WITHOUT logging the ciphertext or plaintext
657
648
  // (security invariant d) — a bare boolean, never the sealed blob or the key.
@@ -660,29 +651,26 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
660
651
  if (r.ok) { pushPresence(); process.stderr.write(`\n ◆ provider added (${String(fields?.name || '').slice(0, 40)}) — re-announced.\n`) }
661
652
  provReply('provider-add-res', { nonce, ok: r.ok, error: r.ok ? undefined : r.error, id: r.ok ? r.id : undefined })
662
653
  })
663
- // provider-add-model {id, model, name?, nonce, jwt} → clone the source row's
654
+ // provider-add-model {id, model, name?, nonce} → clone the source row's
664
655
  // baseUrl + key HOST-SIDE onto a new model, re-announce. Deliberately carries NO
665
656
  // `sealed` envelope: the key never leaves the bridge for this op, so there is
666
657
  // nothing to decrypt. Owner-authed exactly like its siblings.
667
- acct.on('broadcast', { event: 'provider-add-model' }, async ({ payload }) => {
658
+ acctControl.on('broadcast', { event: 'provider-add-model' }, async ({ payload }) => {
668
659
  const nonce = payload?.nonce
669
- if (!(await isOwner(payload?.jwt))) return provReply('provider-add-model-res', { nonce, ok: false, error: 'unauthorized' })
670
660
  const r = addProviderModel({ id: payload?.id, model: payload?.model, name: payload?.name })
671
661
  if (r.ok) { pushPresence(); process.stderr.write(`\n ◆ model added to an existing provider (${String(payload?.model || '').slice(0, 40)}) — re-announced.\n`) }
672
662
  provReply('provider-add-model-res', { nonce, ok: r.ok, error: r.ok ? undefined : r.error, id: r.ok ? r.id : undefined })
673
663
  })
674
- // provider-remove {id, nonce, jwt} → remove (built-in refuses), re-announce.
675
- acct.on('broadcast', { event: 'provider-remove' }, async ({ payload }) => {
664
+ // provider-remove {id, nonce} → remove (built-in refuses), re-announce.
665
+ acctControl.on('broadcast', { event: 'provider-remove' }, async ({ payload }) => {
676
666
  const nonce = payload?.nonce
677
- if (!(await isOwner(payload?.jwt))) return provReply('provider-remove-res', { nonce, ok: false, error: 'unauthorized' })
678
667
  const r = removeProvider(payload?.id)
679
668
  if (r.ok) { pushPresence(); process.stderr.write('\n ◆ provider removed — re-announced.\n') }
680
669
  provReply('provider-remove-res', { nonce, ok: r.ok, error: r.ok ? undefined : r.error })
681
670
  })
682
- // providers-list-req {nonce, jwt} → the masked list (keyHint = last 4 chars only).
683
- acct.on('broadcast', { event: 'providers-list-req' }, async ({ payload }) => {
671
+ // providers-list-req {nonce} → the masked list (keyHint = last 4 chars only).
672
+ acctControl.on('broadcast', { event: 'providers-list-req' }, async ({ payload }) => {
684
673
  const nonce = payload?.nonce
685
- if (!(await isOwner(payload?.jwt))) return provReply('providers-list-res', { nonce, ok: false, error: 'unauthorized', providers: [] })
686
674
  provReply('providers-list-res', { nonce, ok: true, providers: listProviders() })
687
675
  })
688
676
 
@@ -735,6 +723,14 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
735
723
  if (!initialAccountRealtime.connected) {
736
724
  process.stderr.write(`\n ◇ account realtime not ready at startup (${initialAccountRealtime.status}) — staying alive and recovering in process.\n`)
737
725
  }
726
+ const initialAccountControl = await subscribeWithoutStartupDeadlock(acctControl, (st) => {
727
+ if (st === 'CLOSED' || st === 'CHANNEL_ERROR' || st === 'TIMED_OUT') {
728
+ process.stderr.write(`\n ⚠ private account control ${st}; host-changing dashboard actions are unavailable until Realtime reconnects.\n`)
729
+ }
730
+ })
731
+ if (!initialAccountControl.connected) {
732
+ process.stderr.write(`\n ◇ private account control not ready at startup (${initialAccountControl.status}) — presence remains available.\n`)
733
+ }
738
734
 
739
735
  // Keep the account JWT fresh so the presence socket never gets deauthed at
740
736
  // expiry (the 2026-06-17 "No bridge connected while sessions run" bug). On each
@@ -1275,7 +1271,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
1275
1271
  // until it lapses), never lost data. No caller remains to surface an error to, and a
1276
1272
  // throw here would skip process.exit.
1277
1273
  // fail-open-ok: exit-path teardown; a lost release_bridge expires on its own TTL
1278
- ;(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(acct) } catch { /* noop */ } process.exit(code) })()
1274
+ ;(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) })()
1279
1275
  setTimeout(() => process.exit(code), 1500) // hard backstop if the flush hangs
1280
1276
  }
1281
1277
  for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => stop(sig))
package/bridge.mjs CHANGED
@@ -47,7 +47,7 @@ import { createPermNotifier, shouldNotifyTurnDone, shouldRecordTurnDone, permiss
47
47
  // resolveProviderEnv(id) → {ANTHROPIC_BASE_URL,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_MODEL} for a
48
48
  // registered custom provider, or null for the built-in/unknown (leave the default env intact).
49
49
  // Multi-provider BYOK slice 1: a lane spawned with a `provider` id runs on that endpoint.
50
- import { resolveProviderEnv, resolveProviderRef, providerNameMap, publicKeyB64, bridgeHostId, announceProviders, listProviders, addProvider, removeProvider, unseal, effectiveLaneModel, sameProviderEnv, providerModel } from './providers.mjs'
50
+ import { resolveProviderEnv, resolveProviderRef, providerNameMap, publicKeyB64, bridgeHostId, announceProviders, listProviders, effectiveLaneModel, sameProviderEnv, providerModel } from './providers.mjs'
51
51
  import { validateProviderSwitch, providerSwitchPlan, BUILTIN_PROVIDER } from './switch-provider.mjs'
52
52
  import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
53
53
  import { z } from 'zod'
@@ -764,30 +764,6 @@ function reconcileTerminalRows() {
764
764
  if (reaped.length) process.stderr.write(`\n ◆ removed ${reaped.length} stale terminal row${reaped.length === 1 ? '' : 's'} after restore.\n`)
765
765
  })
766
766
  }
767
- // Auth: is the sender a PARTICIPANT of THIS room (owner OR granted OR joined)? Room mode
768
- // serves the room, so RLS returns participants; we confirm the jwt's user is one of them.
769
- // Pattern (per #218/#219): getUser + uid → REST existence check on code_sessions
770
- // (code=room, participants cs.{uid}) — NOT byte comparison of the jwt against codeAuthToken
771
- // (that bug already happened once tonight: a freshly-refreshed jwt has a new signature/iat/jti
772
- // per the grant cycle, so a byte compare rejects the legitimate sender). Hoisted to module
773
- // scope so provider-switch + providers-list-req share the ONE implementation. Returns false
774
- // for anon / missing / invalid jwt without throwing. Fails closed on any fetch error.
775
- const isRoomParticipant = async (jwt) => {
776
- if (!jwt || typeof jwt !== 'string') return false
777
- try {
778
- const { data } = await supabase.auth.getUser(jwt)
779
- if (!data?.user) return false
780
- const uid = data.user.id
781
- // Query with the CALLER's jwt (not the bridge host's codeAuthToken) so RLS scopes
782
- // the read to what THIS user may see, and require an actual matching row — `r.ok`
783
- // alone is true for an empty [] result, which let ANY authenticated user pass.
784
- // participants is a uuid[]; PostgREST array-contains is cs.{uid} (braces required).
785
- const r = await fetch(`${SUPABASE_URL}/rest/v1/code_sessions?code=eq.${encodeURIComponent(room)}&participants=cs.%7B${encodeURIComponent(uid)}%7D&select=code`, { headers: { apikey: SUPABASE_ANON, Authorization: `Bearer ${jwt}` } })
786
- if (!r.ok) return false
787
- const rows = await r.json().catch(() => [])
788
- return Array.isArray(rows) && rows.length > 0
789
- } catch { return false }
790
- }
791
767
  // ── Room-serve owner consent (Contract C-CODE-3, owner-only since 2026-07-06) ──
792
768
  // A room is served by its OWNER's bridge ONLY — no cross-person grantee path
793
769
  // (removed 2026-07-06; see docs/specs/2026-07-06-remove-cross-person-serve.md).
@@ -983,6 +959,12 @@ const designChannel = supabase.channel(`tpdesign:${room}`, {
983
959
  config: { private: true, broadcast: { self: false } },
984
960
  })
985
961
 
962
+ // Host-changing room commands are isolated from the public collaboration topic.
963
+ // Realtime RLS admits current room members and authenticates every send/receive.
964
+ const controlChannel = supabase.channel(`tpcontrol:${room}`, {
965
+ config: { private: true, broadcast: { self: false } },
966
+ })
967
+
986
968
  // ── terminal registry ──────────────────────────────────────────────
987
969
  // id → { term (pty), cmd, attached, scrollback, buf }
988
970
  const terms = new Map()
@@ -3884,6 +3866,85 @@ process.stdout.on('resize', () => {
3884
3866
  let realtimeHealthy = false
3885
3867
  let brokenSince = Date.now()
3886
3868
 
3869
+ // ── Private room control plane ──────────────────────────────────────────────
3870
+ // Authentication and membership are enforced by realtime.messages RLS when
3871
+ // tpcontrol:<room> joins and broadcasts. Access tokens never enter payloads.
3872
+ controlChannel
3873
+ .on('broadcast', { event: 'apply-update' }, () => {
3874
+ if (!pendingUpdate) return
3875
+ applyRequested = true
3876
+ if (process.send && process.env.THINKPOOL_PAIR_ACCOUNT_CHILD === '1') {
3877
+ try { process.send({ t: 'apply-update' }) } catch { /* parent gone */ }
3878
+ }
3879
+ })
3880
+ .on('broadcast', { event: 'bridge-update' }, async ({ payload }) => {
3881
+ const nonce = payload?.nonce
3882
+ const reply = (ok, error, state) => controlChannel.send({
3883
+ type: 'broadcast', event: 'bridge-update-res',
3884
+ payload: { nonce, ok, ...(error ? { error } : {}), ...(state ? { state } : {}) },
3885
+ })
3886
+ if (!nonce) return reply(false, 'invalid request')
3887
+ const target = typeof payload?.v === 'string' && /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(payload.v)
3888
+ ? payload.v : null
3889
+ if (!target) return reply(false, 'invalid bridge version')
3890
+
3891
+ surfaceUpdate(target)
3892
+ applyRequested = true
3893
+ if (process.send && process.env.THINKPOOL_PAIR_ACCOUNT_CHILD === '1') {
3894
+ try { process.send({ t: 'apply-update' }) } catch { return reply(false, 'account supervisor is unavailable') }
3895
+ return reply(true, null, 'queued')
3896
+ }
3897
+
3898
+ let managed = false
3899
+ try {
3900
+ const svc = await import('./service.mjs')
3901
+ managed = svc.serviceActive(room)
3902
+ } catch { /* reported as an explicit foreground refusal below */ }
3903
+ if (!managed) {
3904
+ applyRequested = false
3905
+ return reply(false, 'This bridge is running in a terminal. Restart it once with npx thinkpool-pair@latest, or install the background service to enable remote updates.')
3906
+ }
3907
+ if (!betweenUpdateTurns()) return reply(true, null, 'queued')
3908
+ await reply(true, null, 'applying')
3909
+ void applyStandaloneManagedUpdate()
3910
+ })
3911
+ .on('broadcast', { event: 'providers-list-req' }, ({ payload }) => {
3912
+ controlChannel.send({ type: 'broadcast', event: 'providers-list-res', payload: { nonce: payload?.nonce, ok: true, providers: listProviders() } })
3913
+ })
3914
+ .on('broadcast', { event: 'provider-switch' }, ({ payload }) => {
3915
+ const nonce = payload?.nonce
3916
+ const reply = (ok, error, restarted) => controlChannel.send({ type: 'broadcast', event: 'provider-switch-res', payload: { nonce, ok, ...(error ? { error } : {}), ...(restarted === undefined ? {} : { restarted }) } })
3917
+ const term = payload?.term
3918
+ const s = term ? sessions.get(term) : null
3919
+ if (!s) return reply(false, 'no such live lane')
3920
+ const v = validateProviderSwitch({
3921
+ provider: payload?.provider,
3922
+ currentProvider: s.provider,
3923
+ registeredIds: listProviders().map((p) => p.id),
3924
+ })
3925
+ if (!v.ok) return reply(false, v.error)
3926
+ const target = payload?.provider === BUILTIN_PROVIDER ? null : payload?.provider
3927
+ const plan = providerSwitchPlan({
3928
+ provider: payload?.provider,
3929
+ currentProvider: s.provider,
3930
+ sameEnv: sameProviderEnv(s.provider, target),
3931
+ targetModel: providerModel(target),
3932
+ })
3933
+ if (plan.action === 'noop') return reply(true, undefined, false)
3934
+ if (plan.action === 'in-place' && switchModelInPlace(term, target)) {
3935
+ process.stderr.write(`\n ◆ lane ${String(term).slice(0, 8)} switched model → ${providerModel(target)} (same key, context kept).\n`)
3936
+ announce()
3937
+ return reply(true, undefined, false)
3938
+ }
3939
+ const done = respawnStructured(term, target)
3940
+ if (!done) return reply(false, 'no such live lane')
3941
+ process.stderr.write(`\n ◆ lane ${String(term).slice(0, 8)} switched provider → ${target || 'anthropic'} (memory reset, transcript kept).\n`)
3942
+ reply(true, undefined, true)
3943
+ })
3944
+ .subscribe((status) => {
3945
+ if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') process.stderr.write(`\n ⚠ private bridge control ${status} (tpcontrol:${room}).\n`)
3946
+ })
3947
+
3887
3948
  channel
3888
3949
  .on('broadcast', { event: 'pty-in' }, ({ payload }) => {
3889
3950
  if (!payload?.data) return
@@ -4532,54 +4593,6 @@ channel
4532
4593
  .on('broadcast', { event: 'code-close' }, ({ payload }) => {
4533
4594
  endStructured(payload?.id)
4534
4595
  })
4535
- // Slice 3 — the user clicked "apply" on the update chip. Mark it requested; the
4536
- // actual restart is gated to between turns. Account-child: forward to the
4537
- // supervisor (it owns the restart, applied only when every child is idle).
4538
- // Standalone service tier: its applyIfIdle loop lands it at the next idle. Either
4539
- // way it never interrupts a turn (Contract #1).
4540
- .on('broadcast', { event: 'apply-update' }, () => {
4541
- if (!pendingUpdate) return
4542
- applyRequested = true
4543
- if (process.send && process.env.THINKPOOL_PAIR_ACCOUNT_CHILD === '1') {
4544
- try { process.send({ t: 'apply-update' }) } catch { /* parent gone */ }
4545
- }
4546
- })
4547
- // Authenticated + acknowledged update contract used by both the room card and
4548
- // the dashboard's room-scoped fallback. Unlike the legacy apply-update event,
4549
- // this does not depend on the bridge having discovered npm first: the web names
4550
- // the advertised target, the host updater independently resolves npm latest,
4551
- // and only a managed service is allowed to accept the operation.
4552
- .on('broadcast', { event: 'bridge-update' }, async ({ payload }) => {
4553
- const nonce = payload?.nonce
4554
- const reply = (ok, error, state) => channel.send({
4555
- type: 'broadcast', event: 'bridge-update-res',
4556
- payload: { nonce, ok, ...(error ? { error } : {}), ...(state ? { state } : {}) },
4557
- })
4558
- if (!nonce || !(await isRoomParticipant(payload?.jwt))) return reply(false, 'unauthorized')
4559
- const target = typeof payload?.v === 'string' && /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(payload.v)
4560
- ? payload.v : null
4561
- if (!target) return reply(false, 'invalid bridge version')
4562
-
4563
- surfaceUpdate(target)
4564
- applyRequested = true
4565
- if (process.send && process.env.THINKPOOL_PAIR_ACCOUNT_CHILD === '1') {
4566
- try { process.send({ t: 'apply-update' }) } catch { return reply(false, 'account supervisor is unavailable') }
4567
- return reply(true, null, 'queued')
4568
- }
4569
-
4570
- let managed = false
4571
- try {
4572
- const svc = await import('./service.mjs')
4573
- managed = svc.serviceActive(room)
4574
- } catch { /* reported as an explicit foreground refusal below */ }
4575
- if (!managed) {
4576
- applyRequested = false
4577
- return reply(false, 'This bridge is running in a terminal. Restart it once with npx thinkpool-pair@latest, or install the background service to enable remote updates.')
4578
- }
4579
- if (!betweenUpdateTurns()) return reply(true, null, 'queued')
4580
- await reply(true, null, 'applying')
4581
- void applyStandaloneManagedUpdate()
4582
- })
4583
4596
  // Persist + re-announce terminal renames. The web also echoes term-rename to
4584
4597
  // online peers directly; storing it here is what reaches a device that joins
4585
4598
  // LATER (or a second machine) — those only ever see the announce.
@@ -4593,111 +4606,6 @@ channel
4593
4606
  announce()
4594
4607
  })
4595
4608
  .on('broadcast', { event: 'who' }, announce)
4596
- // ── Provider registry — the multi-BYOK wire contract (room mode). ─────────────
4597
- // AUTH: room mode serves a room the user may not own (post-#208 grant). We verify
4598
- // the sender is a participant of THIS room before ANY operation. List is open to
4599
- // any participant; add/remove mutate the HOST's registry, so gate to the BRIDGE
4600
- // HOST's own uid (the authed login). Anon is refused for all three.
4601
- .on('broadcast', { event: 'provider-add' }, async ({ payload }) => {
4602
- const nonce = payload?.nonce
4603
- // Auth: must be the bridge host (the authed login running this bridge)
4604
- // Use the same pattern as providers-list-req: getUser + uid comparison, not byte comparison
4605
- try {
4606
- const { data } = await supabase.auth.getUser(payload?.jwt)
4607
- if (!data?.user || data.user.id !== myServeUid) {
4608
- return channel.send({ type: 'broadcast', event: 'provider-add-res', payload: { nonce, ok: false, error: 'unauthorized' } })
4609
- }
4610
- } catch {
4611
- return channel.send({ type: 'broadcast', event: 'provider-add-res', payload: { nonce, ok: false, error: 'unauthorized' } })
4612
- }
4613
- let fields
4614
- try { fields = unseal(payload?.sealed) } catch { return channel.send({ type: 'broadcast', event: 'provider-add-res', payload: { nonce, ok: false, error: 'could not decrypt the sealed payload' } }) }
4615
- const r = addProvider(fields)
4616
- if (r.ok) { announce(); process.stderr.write(`\n ◆ provider added (${String(fields?.name || '').slice(0, 40)}) — re-announced.\n`) }
4617
- channel.send({ type: 'broadcast', event: 'provider-add-res', payload: { nonce, ok: r.ok, error: r.ok ? undefined : r.error, id: r.ok ? r.id : undefined } })
4618
- })
4619
- // provider-remove {id, nonce, jwt} → remove, re-announce.
4620
- .on('broadcast', { event: 'provider-remove' }, async ({ payload }) => {
4621
- const nonce = payload?.nonce
4622
- // Auth: must be the bridge host (the authed login running this bridge)
4623
- // Use the same pattern as providers-list-req: getUser + uid comparison, not byte comparison
4624
- try {
4625
- const { data } = await supabase.auth.getUser(payload?.jwt)
4626
- if (!data?.user || data.user.id !== myServeUid) {
4627
- return channel.send({ type: 'broadcast', event: 'provider-remove-res', payload: { nonce, ok: false, error: 'unauthorized' } })
4628
- }
4629
- } catch {
4630
- return channel.send({ type: 'broadcast', event: 'provider-remove-res', payload: { nonce, ok: false, error: 'unauthorized' } })
4631
- }
4632
- const r = removeProvider(payload?.id)
4633
- if (r.ok) { announce(); process.stderr.write('\n ◆ provider removed — re-announced.\n') }
4634
- channel.send({ type: 'broadcast', event: 'provider-remove-res', payload: { nonce, ok: r.ok, error: r.ok ? undefined : r.error } })
4635
- })
4636
- // providers-list-req {nonce, jwt} → the masked list (keyHint = last 4 chars only).
4637
- // Auth via the shared isRoomParticipant (hoisted; same pattern as provider-switch).
4638
- .on('broadcast', { event: 'providers-list-req' }, async ({ payload }) => {
4639
- const nonce = payload?.nonce
4640
- if (!(await isRoomParticipant(payload?.jwt))) return channel.send({ type: 'broadcast', event: 'providers-list-res', payload: { nonce, ok: false, error: 'unauthorized', providers: [] } })
4641
- channel.send({ type: 'broadcast', event: 'providers-list-res', payload: { nonce, ok: true, providers: listProviders() } })
4642
- })
4643
- // ── Per-lane provider SWITCH (multi-BYOK slice 2). Switch a LIVE structured lane to a
4644
- // different LLM provider by restarting that lane's agent process under the SAME terminal
4645
- // id with the new provider's env. Visible transcript persists; the agent's conversational
4646
- // memory resets (cross-backend, no SDK resume) — accepted UX the UI warns about. Same-
4647
- // provider model changes keep using the EXISTING /model path (in-process setModel); the UI
4648
- // only sends provider-switch when the provider ACTUALLY changes.
4649
- // provider-switch {term, provider, nonce, jwt} → bridge validates + respawns
4650
- // provider-switch-res {nonce, ok, error?} ← reply (NEVER key material)
4651
- // Auth: participant check via supabase.auth.getUser (isRoomParticipant) — NOT byte
4652
- // comparison of the jwt (a refreshed jwt has a new signature/iat/jti). Anon refused.
4653
- // After the respawn the lane's next announce reflects its new provider (additive {id,name}
4654
- // projection the client already renders from #219). Spec: docs/specs/2026-07-05-provider-switch.md.
4655
- .on('broadcast', { event: 'provider-switch' }, async ({ payload }) => {
4656
- const nonce = payload?.nonce
4657
- // `restarted` (0.7.179+): did the agent lose its context? false for a same-key model
4658
- // change, true for a real provider change. Older clients ignore the extra field.
4659
- const reply = (ok, error, restarted) => channel.send({ type: 'broadcast', event: 'provider-switch-res', payload: { nonce, ok, ...(error ? { error } : {}), ...(restarted === undefined ? {} : { restarted }) } })
4660
- // AUTH first — anon / non-participant / bad jwt → ok:false 'unauthorized'. No lane
4661
- // state is touched for a rejected sender (fail closed before any lookup).
4662
- if (!(await isRoomParticipant(payload?.jwt))) return reply(false, 'unauthorized')
4663
- const term = payload?.term
4664
- const s = term ? sessions.get(term) : null
4665
- // The target must be a LIVE structured session this bridge serves.
4666
- if (!s) return reply(false, 'no such live lane')
4667
- // VALIDATE — pure: missing / unknown provider, or a no-op same-provider switch.
4668
- const v = validateProviderSwitch({
4669
- provider: payload?.provider,
4670
- currentProvider: s.provider,
4671
- registeredIds: listProviders().map((p) => p.id),
4672
- })
4673
- if (!v.ok) return reply(false, v.error)
4674
- const target = payload?.provider === BUILTIN_PROVIDER ? null : payload?.provider
4675
-
4676
- // The three-way choice is pure + unit-tested (switch-provider.mjs). Two registry rows
4677
- // on ONE endpoint + key (Max's glm-4.6 and glm-5.2 on the same z.ai account) are the
4678
- // same provider asked for a different model: the child's env is identical, so tearing
4679
- // the agent down and recapping it is pure loss. Only a different endpoint/key earns a
4680
- // respawn. An in-place attempt that fails (lane raced closed, setModel threw) falls
4681
- // back to the respawn rather than leaving the lane on a stale model.
4682
- const plan = providerSwitchPlan({
4683
- provider: payload?.provider,
4684
- currentProvider: s.provider,
4685
- sameEnv: sameProviderEnv(s.provider, target),
4686
- targetModel: providerModel(target),
4687
- })
4688
- if (plan.action === 'noop') return reply(true, undefined, false) // idempotent (UI guarantees a change)
4689
-
4690
- if (plan.action === 'in-place' && switchModelInPlace(term, target)) {
4691
- process.stderr.write(`\n ◆ lane ${String(term).slice(0, 8)} switched model → ${providerModel(target)} (same key, context kept).\n`)
4692
- announce()
4693
- return reply(true, undefined, false)
4694
- }
4695
-
4696
- const done = respawnStructured(term, target)
4697
- if (!done) return reply(false, 'no such live lane') // raced closed between the lookup + respawn
4698
- process.stderr.write(`\n ◆ lane ${String(term).slice(0, 8)} switched provider → ${target || 'anthropic'} (memory reset, transcript kept).\n`)
4699
- reply(true, undefined, true)
4700
- })
4701
4609
  .subscribe(async status => {
4702
4610
  if (status === 'SUBSCRIBED') {
4703
4611
  realtimeHealthy = true; brokenSince = 0
@@ -5395,6 +5303,7 @@ async function shutdown(code = 0, farewell = true) {
5395
5303
  // process teardown). Close it too so shutdown is symmetric.
5396
5304
  try { await supabase.removeChannel(flowChannel) } catch { /* noop */ }
5397
5305
  try { await supabase.removeChannel(designChannel) } catch { /* noop */ }
5306
+ try { await supabase.removeChannel(controlChannel) } catch { /* noop */ }
5398
5307
  setTimeout(() => process.exit(code), 250) // grace for the leave/close frames to flush
5399
5308
  }
5400
5309
  process.on('SIGINT', () => shutdown(0))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.326",
3
+ "version": "0.7.327",
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": {