thinkpool-pair 0.7.325 → 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.
- package/account.mjs +30 -34
- package/bridge.mjs +87 -178
- 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
|
|
527
|
-
|
|
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
|
-
|
|
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
|
-
|
|
573
|
+
acctControl.on('broadcast', { event: 'restart' }, async ({ payload } = {}) => {
|
|
569
574
|
const nonce = payload?.nonce
|
|
570
575
|
const reply = async (ok, error) => {
|
|
571
|
-
try { await
|
|
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
|
-
//
|
|
640
|
-
//
|
|
641
|
-
|
|
642
|
-
//
|
|
643
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
675
|
-
|
|
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
|
|
683
|
-
|
|
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,
|
|
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))
|