thinkpool-pair 0.7.364 → 0.7.366

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +20 -0
  3. package/abort-turn-barrier.mjs +1 -23
  4. package/account.mjs +1 -1437
  5. package/acp-client.mjs +1 -140
  6. package/agent-detect.mjs +1 -28
  7. package/agent-notify.mjs +1 -142
  8. package/agent-visibility.mjs +1 -67
  9. package/auth-store.mjs +1 -120
  10. package/bridge.mjs +1 -6266
  11. package/byok-detect.mjs +1 -126
  12. package/claude-command-catalog.mjs +1 -91
  13. package/claude-session.mjs +1 -1519
  14. package/code-event-contract.mjs +1 -118
  15. package/codex-app-server.mjs +1 -340
  16. package/codex-commands.mjs +1 -83
  17. package/codex-event-mapper.mjs +1 -232
  18. package/codex-images.mjs +1 -69
  19. package/codex-mcp-http.mjs +1 -131
  20. package/codex-session.mjs +1 -1295
  21. package/command-catalog.mjs +1 -118
  22. package/command-guidance.mjs +1 -8
  23. package/context-contract.mjs +1 -95
  24. package/context-windows.mjs +1 -107
  25. package/cross-terminal.mjs +1 -789
  26. package/cumulative-event-relay.mjs +1 -53
  27. package/design-edit.mjs +1 -424
  28. package/design-source-contract.mjs +1 -4
  29. package/direct-pair-room.mjs +1 -57
  30. package/dispatch-lease.mjs +1 -37
  31. package/dispatch-permission-cleanup.mjs +1 -86
  32. package/edit-diff.mjs +1 -136
  33. package/error-recovery.mjs +1 -50
  34. package/event-bounds.mjs +1 -121
  35. package/event-delivery-queue.mjs +1 -60
  36. package/event-id.mjs +1 -549
  37. package/evidence-citations.mjs +1 -50
  38. package/evidence-compact.mjs +1 -11
  39. package/flow-assembly.mjs +1 -196
  40. package/flow-budget.mjs +1 -84
  41. package/flow-conductor.mjs +1 -259
  42. package/flow-context-store.mjs +1 -387
  43. package/flow-host-revert.mjs +1 -42
  44. package/flow-models.mjs +1 -139
  45. package/flow-preview.mjs +1 -148
  46. package/flow-receipt.mjs +1 -122
  47. package/flow-redispatch.mjs +1 -72
  48. package/flow-review-gate.mjs +1 -402
  49. package/flow-review-reflect.mjs +1 -115
  50. package/flow-review.mjs +1 -152
  51. package/flow-scope-evidence.mjs +1 -117
  52. package/flow-skill-registry.mjs +1 -140
  53. package/flow-task-graph.mjs +1 -562
  54. package/flow-worktree.mjs +1 -71
  55. package/git-diff-report.mjs +1 -121
  56. package/hermes-delegation-guard.mjs +1 -14
  57. package/hermes-event-mapper.mjs +1 -194
  58. package/hermes-isolation.mjs +1 -61
  59. package/hermes-model-cache.mjs +1 -54
  60. package/hermes-policy.mjs +1 -92
  61. package/hermes-probe.mjs +1 -57
  62. package/hermes-session.mjs +1 -683
  63. package/hermes-setup.mjs +1 -167
  64. package/host-memory.mjs +1 -116
  65. package/interrupted-resume.mjs +1 -95
  66. package/keep-awake.mjs +1 -148
  67. package/key-shape.mjs +1 -49
  68. package/lane-continuation.mjs +1 -83
  69. package/lane-lifecycle.mjs +1 -189
  70. package/lane-worktree.mjs +1 -77
  71. package/launcher.mjs +1 -354
  72. package/mcp-flight-recorder.mjs +1 -79
  73. package/mockup-delivery.mjs +1 -57
  74. package/model-prices.mjs +1 -113
  75. package/package.json +13 -4
  76. package/pair-bus.mjs +1 -98
  77. package/pair-control-authority.mjs +1 -89
  78. package/past-work-search.mjs +1 -105
  79. package/plan-meters.mjs +1 -144
  80. package/presence.mjs +1 -191
  81. package/privacy-report.mjs +1 -108
  82. package/provider-resilience.mjs +1 -356
  83. package/provider.mjs +1 -133
  84. package/providers.mjs +1 -491
  85. package/publish-guard.mjs +2 -0
  86. package/publish-manifest.json +129 -0
  87. package/question-response.mjs +1 -58
  88. package/reap-terminal.mjs +1 -68
  89. package/recap.mjs +1 -297
  90. package/replay-transport.mjs +1 -64
  91. package/repo-search.mjs +1 -190
  92. package/review-check.mjs +1 -182
  93. package/runtime-contract.mjs +1 -93
  94. package/runtime-registry.mjs +1 -64
  95. package/runtime-session.mjs +1 -20
  96. package/scheduled-run-admission.mjs +1 -364
  97. package/scheduled-runs.mjs +1 -268
  98. package/sdk-admission.mjs +1 -9
  99. package/sdk-smoke.mjs +1 -61
  100. package/serve-consent.mjs +1 -118
  101. package/serve-dir.mjs +1 -40
  102. package/service.mjs +1 -877
  103. package/session-store.mjs +1 -484
  104. package/side-lane.mjs +1 -63
  105. package/supabase-key.mjs +1 -176
  106. package/supervisor-ready.mjs +1 -57
  107. package/switch-provider.mjs +1 -84
  108. package/terminal-name.mjs +1 -359
  109. package/terminal-row-reconcile.mjs +1 -70
  110. package/thinkpool-prompt-contracts.mjs +1 -85
  111. package/thinkpool-room-prompt.mjs +1 -187
  112. package/transcript-sanitize.mjs +1 -332
  113. package/turn-stall.mjs +1 -61
  114. package/update-gate.mjs +1 -53
  115. package/viewport.mjs +1 -810
  116. package/worker-completion.mjs +1 -57
package/supabase-key.mjs CHANGED
@@ -1,176 +1 @@
1
- /* supabase-key.mjs — resolve the bridge's client-safe Supabase key at RUNTIME.
2
-
3
- THE PROBLEM THIS SOLVES (docs/specs/2026-07-10-supabase-key-migration.md §5.1)
4
-
5
- `bridge.mjs` used to carry the legacy anon key as a string literal:
6
-
7
- const SUPABASE_ANON = process.env.TP_SUPABASE_ANON || 'eyJhbGci…'
8
-
9
- and 224 published `thinkpool-pair` versions shipped with it. The comment above
10
- it said "safe to embed" — true about CONFIDENTIALITY (the web bundle ships the
11
- same value), silent about LIFECYCLE. Disable that key in the Supabase dashboard
12
- and every installed bridge, on every user's laptop, dies at the same instant —
13
- and no server-side change repairs it, because Vercel env vars do not reach a
14
- program running on someone else's machine. It is the one failure in the repo
15
- our deploy pipeline cannot fix.
16
-
17
- THE FIX. The key becomes a value the SERVER controls. The bridge asks
18
- thinkpool.io for it at boot, caches it on disk, and keeps the literal only as
19
- a last resort. A rotation then repairs itself for bridges that never update.
20
-
21
- RESOLUTION ORDER — each step exists for a reason:
22
-
23
- 1 env TP_SUPABASE_ANON — the operator's explicit override. Always wins.
24
- 2 cache A fresh (< TTL) on-disk record. Keeps boot fast and works offline.
25
- 3 network GET {WEB_BASE}/api/config. The rotation-repair path.
26
- 4 stale An expired cache beats the literal: it is strictly newer.
27
- 5 baked The legacy literal. First-ever boot with no network. Never fails.
28
-
29
- TWO INVARIANTS, both tested in supabase-key.test.mjs:
30
-
31
- · A service-shaped key is NEVER adopted, from any source. The network is an
32
- input we do not control; a compromised or misconfigured config surface
33
- must not be able to hand a user's laptop an RLS-bypassing client.
34
- · No key material is ever logged. We log the SOURCE and the SHAPE. (C16.) */
35
-
36
- import os from 'node:os'
37
- import fs from 'node:fs'
38
- import path from 'node:path'
39
- import { classifyKey, isClientSafeKey } from './key-shape.mjs'
40
-
41
- export { classifyKey, isClientSafeKey }
42
-
43
- /* The legacy anon key. Public by design (the web bundle ships it), but now a
44
- FALLBACK rather than the source of truth. Do not add new readers. */
45
- export const BAKED_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRheXR2dGFrbWxpeHBmYmJxempkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzgwNTMxNzEsImV4cCI6MjA5MzYyOTE3MX0.zYmz8bHUNEY4STUr2JuXChAsKwxNwkwFHe1Hd0Betqo'
46
-
47
- export const DEFAULT_SUPABASE_URL = 'https://daytvtakmlixpfbbqzjd.supabase.co'
48
- export const DEFAULT_WEB_BASE = 'https://thinkpool.io'
49
-
50
- const CACHE_FILE = path.join(os.homedir(), '.thinkpool-pair', 'supabase-key.json')
51
- const CACHE_TTL_MS = 6 * 60 * 60 * 1000 // 6h — a rotation reaches a rebooting bridge same-day
52
- const FETCH_TIMEOUT_MS = 2000 // boot latency budget; the literal is right there
53
-
54
- // ── the on-disk cache ──────────────────────────────────────────────────────
55
- // Sits beside auth.json / dirs.json / served.json in ~/.thinkpool-pair.
56
- // Contains no secret (a publishable key), so no chmod dance — unlike auth.json.
57
- export function readCacheFile() {
58
- try {
59
- const rec = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'))
60
- if (!rec || typeof rec.key !== 'string' || typeof rec.url !== 'string') return null
61
- return { key: rec.key, url: rec.url, at: Number(rec.at) || 0 }
62
- } catch { return null }
63
- }
64
-
65
- export function writeCacheFile(rec) {
66
- // Atomic, for the same reason auth-store.mjs is: a kill mid-write must never
67
- // leave a truncated file that reads as "no cache" — or worse, as half a key.
68
- try {
69
- fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true })
70
- const tmp = `${CACHE_FILE}.${process.pid}.tmp`
71
- fs.writeFileSync(tmp, JSON.stringify(rec))
72
- fs.renameSync(tmp, CACHE_FILE)
73
- } catch { /* a cache we cannot write is a cache we do without */ }
74
- }
75
-
76
- // ── the network step ───────────────────────────────────────────────────────
77
- // Returns a validated client-safe key, or null. NEVER throws: a bridge must
78
- // boot on a train. Never logs the value.
79
- async function fetchConfigKey({ webBase, supabaseUrl, fetchImpl, timeoutMs, log }) {
80
- const ac = new AbortController()
81
- const timer = setTimeout(() => ac.abort(), timeoutMs)
82
- try {
83
- const r = await fetchImpl(`${webBase}/api/config`, {
84
- signal: ac.signal,
85
- headers: { accept: 'application/json' },
86
- })
87
- if (!r || !r.ok) { log(`config: HTTP ${r?.status ?? '?'} — keeping current key`); return null }
88
-
89
- // Until this endpoint is deployed, the SPA catch-all answers /api/config with
90
- // 200 + index.html. That must read as "no config", not as a network outage —
91
- // every bridge in the wild takes this path on the day before the deploy.
92
- let body
93
- try { body = await r.json() }
94
- catch { log('config: response was not JSON (endpoint not deployed?) — keeping current key'); return null }
95
-
96
- const key = body?.supabaseAnonKey
97
-
98
- // The served key must be for OUR project. A config surface pointing at a
99
- // different Supabase project would authenticate against the wrong database.
100
- if (body?.supabaseUrl && body.supabaseUrl !== supabaseUrl) {
101
- log('config: served a key for a different project — refused')
102
- return null
103
- }
104
- // The invariant. A service-shaped key from the network is never adopted.
105
- if (!isClientSafeKey(key)) {
106
- log(`config: served a ${classifyKey(key)} key — refused`)
107
- return null
108
- }
109
- return key
110
- } catch (e) {
111
- log(`config: unreachable (${e.name === 'AbortError' ? 'timeout' : 'network'}) — keeping current key`)
112
- return null
113
- } finally { clearTimeout(timer) }
114
- }
115
-
116
- // ── the resolver ───────────────────────────────────────────────────────────
117
- /**
118
- * @returns {Promise<{key: string, source: 'env'|'cache'|'network'|'stale-cache'|'baked'}>}
119
- * @throws if TP_SUPABASE_ANON is set to something that is not client-safe.
120
- */
121
- export async function resolveAnonKey(opts = {}) {
122
- const {
123
- env = process.env,
124
- supabaseUrl = env.TP_SUPABASE_URL || DEFAULT_SUPABASE_URL,
125
- webBase = env.TP_WEB_BASE || DEFAULT_WEB_BASE,
126
- fetchImpl = globalThis.fetch,
127
- readCache = readCacheFile,
128
- writeCache = writeCacheFile,
129
- now = Date.now,
130
- ttlMs = CACHE_TTL_MS,
131
- timeoutMs = FETCH_TIMEOUT_MS,
132
- log = () => {},
133
- } = opts
134
-
135
- // 1 · env — the operator said so. But an operator who exports a SECRET key
136
- // here would silently run an RLS-bypassing bridge, so this one throws
137
- // rather than falling through: a wrong explicit answer is worse than none.
138
- const fromEnv = String(env.TP_SUPABASE_ANON || '').trim()
139
- if (fromEnv) {
140
- if (!isClientSafeKey(fromEnv)) {
141
- throw new Error(
142
- `TP_SUPABASE_ANON is not a client-safe key (looks like: ${classifyKey(fromEnv)}). ` +
143
- 'Use the project\'s publishable/anon key — never a secret or service_role key.',
144
- )
145
- }
146
- log('key: source=env')
147
- return { key: fromEnv, source: 'env' }
148
- }
149
-
150
- const cached = readCache()
151
- const cacheUsable = !!cached && cached.url === supabaseUrl && isClientSafeKey(cached.key)
152
-
153
- // 2 · a fresh cache — skip the network entirely
154
- if (cacheUsable && (now() - cached.at) < ttlMs) {
155
- log('key: source=cache')
156
- return { key: cached.key, source: 'cache' }
157
- }
158
-
159
- // 3 · the network — the whole point of this module
160
- const fetched = await fetchConfigKey({ webBase, supabaseUrl, fetchImpl, timeoutMs, log })
161
- if (fetched) {
162
- writeCache({ key: fetched, url: supabaseUrl, at: now() })
163
- log(`key: source=network shape=${classifyKey(fetched)}`)
164
- return { key: fetched, source: 'network' }
165
- }
166
-
167
- // 4 · a stale cache still beats the literal — it is strictly more recent
168
- if (cacheUsable) {
169
- log('key: source=stale-cache')
170
- return { key: cached.key, source: 'stale-cache' }
171
- }
172
-
173
- // 5 · the literal. Offline first boot. Always works, until the day it doesn't.
174
- log('key: source=baked')
175
- return { key: BAKED_ANON_KEY, source: 'baked' }
176
- }
1
+ import e from"node:os";import r from"node:fs";import t from"node:path";import{classifyKey as o,isClientSafeKey as n}from"./key-shape.mjs";export{o as classifyKey,n as isClientSafeKey};export const BAKED_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRheXR2dGFrbWxpeHBmYmJxempkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzgwNTMxNzEsImV4cCI6MjA5MzYyOTE3MX0.zYmz8bHUNEY4STUr2JuXChAsKwxNwkwFHe1Hd0Betqo";export const DEFAULT_SUPABASE_URL="https://daytvtakmlixpfbbqzjd.supabase.co";export const DEFAULT_WEB_BASE="https://thinkpool.io";const s=t.join(e.homedir(),".thinkpool-pair","supabase-key.json"),a=216e5,c=2e3;export function readCacheFile(){try{const e=JSON.parse(r.readFileSync(s,"utf8"));return e&&"string"==typeof e.key&&"string"==typeof e.url?{key:e.key,url:e.url,at:Number(e.at)||0}:null}catch{return null}}export function writeCacheFile(e){try{r.mkdirSync(t.dirname(s),{recursive:!0});const o=`${s}.${process.pid}.tmp`;r.writeFileSync(o,JSON.stringify(e)),r.renameSync(o,s)}catch{}}export async function resolveAnonKey(e={}){const{env:r=process.env,supabaseUrl:t=r.TP_SUPABASE_URL||DEFAULT_SUPABASE_URL,webBase:s=r.TP_WEB_BASE||DEFAULT_WEB_BASE,fetchImpl:i=globalThis.fetch,readCache:l=readCacheFile,writeCache:u=writeCacheFile,now:p=Date.now,ttlMs:y=a,timeoutMs:k=c,log:h=()=>{}}=e,m=String(r.TP_SUPABASE_ANON||"").trim();if(m){if(!n(m))throw new Error(`TP_SUPABASE_ANON is not a client-safe key (looks like: ${o(m)}). Use the project's publishable/anon key — never a secret or service_role key.`);return h("key: source=env"),{key:m,source:"env"}}const f=l(),A=!!f&&f.url===t&&n(f.key);if(A&&p()-f.at<y)return h("key: source=cache"),{key:f.key,source:"cache"};const b=await async function({webBase:e,supabaseUrl:r,fetchImpl:t,timeoutMs:s,log:a}){const c=new AbortController,i=setTimeout(()=>c.abort(),s);try{const s=await t(`${e}/api/config`,{signal:c.signal,headers:{accept:"application/json"}});if(!s||!s.ok)return a(`config: HTTP ${s?.status??"?"} — keeping current key`),null;let i;try{i=await s.json()}catch{return a("config: response was not JSON (endpoint not deployed?) — keeping current key"),null}const l=i?.supabaseAnonKey;return i?.supabaseUrl&&i.supabaseUrl!==r?(a("config: served a key for a different project — refused"),null):n(l)?l:(a(`config: served a ${o(l)} key — refused`),null)}catch(e){return a(`config: unreachable (${"AbortError"===e.name?"timeout":"network"}) — keeping current key`),null}finally{clearTimeout(i)}}({webBase:s,supabaseUrl:t,fetchImpl:i,timeoutMs:k,log:h});return b?(u({key:b,url:t,at:p()}),h(`key: source=network shape=${o(b)}`),{key:b,source:"network"}):A?(h("key: source=stale-cache"),{key:f.key,source:"stale-cache"}):(h("key: source=baked"),{key:BAKED_ANON_KEY,source:"baked"})}
@@ -1,57 +1 @@
1
- import fs from 'node:fs'
2
- import os from 'node:os'
3
- import path from 'node:path'
4
-
5
- export const ACCOUNT_READY_FILE = path.join(os.homedir(), '.thinkpool-pair', 'account-ready.json')
6
-
7
- export function readSupervisorReady(file = ACCOUNT_READY_FILE, { fsMod = fs } = {}) {
8
- try {
9
- const value = JSON.parse(fsMod.readFileSync(file, 'utf8'))
10
- if (!value || typeof value !== 'object') return null
11
- return value
12
- } catch { return null }
13
- }
14
-
15
- export function supervisorReadyMatches(receipt, live, version, { notBefore = 0 } = {}) {
16
- return !!receipt && !!live?.pid &&
17
- String(receipt.pid) === String(live.pid) &&
18
- receipt.version === version &&
19
- typeof receipt.bridgeId === 'string' && receipt.bridgeId.length > 0 &&
20
- Number.isFinite(receipt.readyAt) && receipt.readyAt >= notBefore
21
- }
22
-
23
- // Supabase presence is keyed by hostname, so the key can outlive a crashed
24
- // process briefly. Require the unique process bridgeId inside the echoed metas;
25
- // hostname existence alone can certify the previous supervisor's stale presence.
26
- export function supervisorPresenceEchoed(state, machine, bridgeId, version) {
27
- if (!state || typeof state !== 'object' || typeof machine !== 'string' || !machine || typeof bridgeId !== 'string' || !bridgeId) return false
28
- const metas = Array.isArray(state[machine]) ? state[machine] : []
29
- return metas.some((meta) => meta?.bridgeId === bridgeId && meta?.version === version)
30
- }
31
-
32
- export function writeSupervisorReady({ pid = process.pid, version, bridgeId, readyAt = Date.now() }, file = ACCOUNT_READY_FILE, { fsMod = fs } = {}) {
33
- if (!Number.isInteger(Number(pid)) || Number(pid) <= 0 || typeof version !== 'string' || !version || typeof bridgeId !== 'string' || !bridgeId) return false
34
- const tmp = `${file}.tmp.${process.pid}.${Date.now()}`
35
- try {
36
- fsMod.mkdirSync(path.dirname(file), { recursive: true })
37
- fsMod.writeFileSync(tmp, `${JSON.stringify({ pid: Number(pid), version, bridgeId, readyAt })}\n`, { mode: 0o600 })
38
- fsMod.renameSync(tmp, file)
39
- return true
40
- } catch {
41
- try { fsMod.rmSync(tmp, { force: true }) } catch { /* noop */ }
42
- return false
43
- }
44
- }
45
-
46
- // Remove an owned receipt on shutdown. With no pid, clear stale startup evidence
47
- // after this process has acquired the singleton lock and therefore owns account mode.
48
- export function clearSupervisorReady({ pid = null, file = ACCOUNT_READY_FILE, fsMod = fs } = {}) {
49
- try {
50
- if (pid !== null) {
51
- const receipt = readSupervisorReady(file, { fsMod })
52
- if (!receipt || String(receipt.pid) !== String(pid)) return false
53
- }
54
- fsMod.rmSync(file, { force: true })
55
- return true
56
- } catch { return false }
57
- }
1
+ import r from"node:fs";import e from"node:os";import t from"node:path";export const ACCOUNT_READY_FILE=t.join(e.homedir(),".thinkpool-pair","account-ready.json");export function readSupervisorReady(e=ACCOUNT_READY_FILE,{fsMod:t=r}={}){try{const r=JSON.parse(t.readFileSync(e,"utf8"));return r&&"object"==typeof r?r:null}catch{return null}}export function supervisorReadyMatches(r,e,t,{notBefore:n=0}={}){return!!r&&!!e?.pid&&String(r.pid)===String(e.pid)&&r.version===t&&"string"==typeof r.bridgeId&&r.bridgeId.length>0&&Number.isFinite(r.readyAt)&&r.readyAt>=n}export function supervisorPresenceEchoed(r,e,t,n){return!!(r&&"object"==typeof r&&"string"==typeof e&&e&&"string"==typeof t&&t)&&(Array.isArray(r[e])?r[e]:[]).some(r=>r?.bridgeId===t&&r?.version===n)}export function writeSupervisorReady({pid:e=process.pid,version:n,bridgeId:o,readyAt:i=Date.now()},d=ACCOUNT_READY_FILE,{fsMod:s=r}={}){if(!Number.isInteger(Number(e))||Number(e)<=0||"string"!=typeof n||!n||"string"!=typeof o||!o)return!1;const p=`${d}.tmp.${process.pid}.${Date.now()}`;try{return s.mkdirSync(t.dirname(d),{recursive:!0}),s.writeFileSync(p,`${JSON.stringify({pid:Number(e),version:n,bridgeId:o,readyAt:i})}\n`,{mode:384}),s.renameSync(p,d),!0}catch{try{s.rmSync(p,{force:!0})}catch{}return!1}}export function clearSupervisorReady({pid:e=null,file:t=ACCOUNT_READY_FILE,fsMod:n=r}={}){try{if(null!==e){const r=readSupervisorReady(t,{fsMod:n});if(!r||String(r.pid)!==String(e))return!1}return n.rmSync(t,{force:!0}),!0}catch{return!1}}
@@ -1,84 +1 @@
1
- /* ─────────────────────────────────────────────────────────────
2
- switch-provider.mjs — validation for the per-lane provider-switch
3
- wire request (`provider-switch` on the room channel).
4
-
5
- The bridge handler (bridge.mjs) owns AUTH (supabase.getUser + room
6
- participation) and the actual RESPAWN (teardown-without-reap +
7
- reopen under the same terminal id with the new provider env). This
8
- module owns the decidable part — pure, no bridge state, no supabase —
9
- so the validation branches are unit-testable in isolation:
10
-
11
- V1 missing provider id → ok:false error:'missing provider'
12
- V2 unknown id (not registered) → ok:false error:'unknown provider'
13
- V3 valid + actually changes → ok:true noop:false
14
- V4 valid but same as current → ok:true noop:true (UI guarantees a
15
- change, but we stay correct + idempotent
16
- if it ever sends a no-op)
17
-
18
- The built-in Anthropic path is internally `provider: null` (omitted on
19
- the announce), but the wire sends the literal `'anthropic'`. Both mean
20
- the default Claude env, so `null` ↔ `'anthropic'` is a SAME-provider
21
- (no-op) switch. resolveProviderEnv (providers.mjs) treats them the same.
22
-
23
- Spec: docs/specs/2026-07-05-provider-switch.md (wire contract LOCKED;
24
- this module must NOT change the request/reply field names or shapes).
25
- ───────────────────────────────────────────────────────────── */
26
-
27
- export const BUILTIN_PROVIDER = 'anthropic'
28
-
29
- /**
30
- * Normalize a provider id so the built-in default (`null`, undefined, or the
31
- * literal 'anthropic') collapses to a single canonical value. Used by the
32
- * no-op check: switching a lane whose current provider is null (the default
33
- * path) to 'anthropic' is NOT a real change.
34
- * @param {string|null|undefined} p
35
- * @returns {string}
36
- */
37
- export function effectiveProvider(p) {
38
- return (!p || p === BUILTIN_PROVIDER) ? BUILTIN_PROVIDER : String(p)
39
- }
40
-
41
- /**
42
- * Validate a provider-switch request's provider field.
43
- *
44
- * @param {{provider: string, currentProvider?: string|null, registeredIds?: string[]}} input
45
- * @returns {{ok: true, noop: boolean} | {ok: false, error: string}}
46
- */
47
- export function validateProviderSwitch({ provider, currentProvider, registeredIds }) {
48
- const id = String(provider ?? '').trim()
49
- if (!id) return { ok: false, error: 'missing provider' }
50
-
51
- const known = id === BUILTIN_PROVIDER ||
52
- (Array.isArray(registeredIds) && registeredIds.includes(id))
53
- if (!known) return { ok: false, error: 'unknown provider' }
54
-
55
- const noop = effectiveProvider(id) === effectiveProvider(currentProvider)
56
- return { ok: true, noop }
57
- }
58
-
59
- /**
60
- * WHAT KIND of switch is this? The pure half of the provider-switch handler, so the
61
- * three-way choice is testable without booting a bridge (Max, 2026-07-09).
62
- *
63
- * 'noop' — same provider; nothing to do.
64
- * 'in-place' — a different registry row on the SAME endpoint + key (two models on
65
- * one z.ai account). Only ANTHROPIC_MODEL differs, so the live session
66
- * just setModel()s and KEEPS ITS CONTEXT. Requires a model to switch to.
67
- * 'respawn' — a genuinely different endpoint or key. Fresh agent, transcript kept,
68
- * recap carried. This is the only case that costs the user their context.
69
- *
70
- * `sameEnv` is supplied by the caller (providers.mjs `sameProviderEnv`) rather than
71
- * imported, so this module stays free of registry/disk state — the whole point of it.
72
- * A same-env target with NO configured model falls back to 'respawn': openStructured
73
- * would then seed the model from the target's env, which an in-place setModel cannot do.
74
- *
75
- * @param {{provider:string, currentProvider?:string|null, sameEnv:boolean, targetModel?:string|null}} input
76
- * @returns {{action:'noop'|'in-place'|'respawn', restarted:boolean}}
77
- */
78
- export function providerSwitchPlan({ provider, currentProvider, sameEnv, targetModel }) {
79
- if (effectiveProvider(provider) === effectiveProvider(currentProvider)) {
80
- return { action: 'noop', restarted: false }
81
- }
82
- if (sameEnv && targetModel) return { action: 'in-place', restarted: false }
83
- return { action: 'respawn', restarted: true }
84
- }
1
+ export const BUILTIN_PROVIDER="anthropic";export function effectiveProvider(r){return r&&"anthropic"!==r?String(r):"anthropic"}export function validateProviderSwitch({provider:r,currentProvider:e,registeredIds:t}){const i=String(r??"").trim();return i?"anthropic"===i||Array.isArray(t)&&t.includes(i)?{ok:!0,noop:effectiveProvider(i)===effectiveProvider(e)}:{ok:!1,error:"unknown provider"}:{ok:!1,error:"missing provider"}}export function providerSwitchPlan({provider:r,currentProvider:e,sameEnv:t,targetModel:i}){return effectiveProvider(r)===effectiveProvider(e)?{action:"noop",restarted:!1}:t&&i?{action:"in-place",restarted:!1}:{action:"respawn",restarted:!0}}