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.
- package/LICENSE +18 -0
- package/README.md +20 -0
- package/abort-turn-barrier.mjs +1 -23
- package/account.mjs +1 -1437
- package/acp-client.mjs +1 -140
- package/agent-detect.mjs +1 -28
- package/agent-notify.mjs +1 -142
- package/agent-visibility.mjs +1 -67
- package/auth-store.mjs +1 -120
- package/bridge.mjs +1 -6266
- package/byok-detect.mjs +1 -126
- package/claude-command-catalog.mjs +1 -91
- package/claude-session.mjs +1 -1519
- package/code-event-contract.mjs +1 -118
- package/codex-app-server.mjs +1 -340
- package/codex-commands.mjs +1 -83
- package/codex-event-mapper.mjs +1 -232
- package/codex-images.mjs +1 -69
- package/codex-mcp-http.mjs +1 -131
- package/codex-session.mjs +1 -1295
- package/command-catalog.mjs +1 -118
- package/command-guidance.mjs +1 -8
- package/context-contract.mjs +1 -95
- package/context-windows.mjs +1 -107
- package/cross-terminal.mjs +1 -789
- package/cumulative-event-relay.mjs +1 -53
- package/design-edit.mjs +1 -424
- package/design-source-contract.mjs +1 -4
- package/direct-pair-room.mjs +1 -57
- package/dispatch-lease.mjs +1 -37
- package/dispatch-permission-cleanup.mjs +1 -86
- package/edit-diff.mjs +1 -136
- package/error-recovery.mjs +1 -50
- package/event-bounds.mjs +1 -121
- package/event-delivery-queue.mjs +1 -60
- package/event-id.mjs +1 -549
- package/evidence-citations.mjs +1 -50
- package/evidence-compact.mjs +1 -11
- package/flow-assembly.mjs +1 -196
- package/flow-budget.mjs +1 -84
- package/flow-conductor.mjs +1 -259
- package/flow-context-store.mjs +1 -387
- package/flow-host-revert.mjs +1 -42
- package/flow-models.mjs +1 -139
- package/flow-preview.mjs +1 -148
- package/flow-receipt.mjs +1 -122
- package/flow-redispatch.mjs +1 -72
- package/flow-review-gate.mjs +1 -402
- package/flow-review-reflect.mjs +1 -115
- package/flow-review.mjs +1 -152
- package/flow-scope-evidence.mjs +1 -117
- package/flow-skill-registry.mjs +1 -140
- package/flow-task-graph.mjs +1 -562
- package/flow-worktree.mjs +1 -71
- package/git-diff-report.mjs +1 -121
- package/hermes-delegation-guard.mjs +1 -14
- package/hermes-event-mapper.mjs +1 -194
- package/hermes-isolation.mjs +1 -61
- package/hermes-model-cache.mjs +1 -54
- package/hermes-policy.mjs +1 -92
- package/hermes-probe.mjs +1 -57
- package/hermes-session.mjs +1 -683
- package/hermes-setup.mjs +1 -167
- package/host-memory.mjs +1 -116
- package/interrupted-resume.mjs +1 -95
- package/keep-awake.mjs +1 -148
- package/key-shape.mjs +1 -49
- package/lane-continuation.mjs +1 -83
- package/lane-lifecycle.mjs +1 -189
- package/lane-worktree.mjs +1 -77
- package/launcher.mjs +1 -354
- package/mcp-flight-recorder.mjs +1 -79
- package/mockup-delivery.mjs +1 -57
- package/model-prices.mjs +1 -113
- package/package.json +13 -4
- package/pair-bus.mjs +1 -98
- package/pair-control-authority.mjs +1 -89
- package/past-work-search.mjs +1 -105
- package/plan-meters.mjs +1 -144
- package/presence.mjs +1 -191
- package/privacy-report.mjs +1 -108
- package/provider-resilience.mjs +1 -356
- package/provider.mjs +1 -133
- package/providers.mjs +1 -491
- package/publish-guard.mjs +2 -0
- package/publish-manifest.json +129 -0
- package/question-response.mjs +1 -58
- package/reap-terminal.mjs +1 -68
- package/recap.mjs +1 -297
- package/replay-transport.mjs +1 -64
- package/repo-search.mjs +1 -190
- package/review-check.mjs +1 -182
- package/runtime-contract.mjs +1 -93
- package/runtime-registry.mjs +1 -64
- package/runtime-session.mjs +1 -20
- package/scheduled-run-admission.mjs +1 -364
- package/scheduled-runs.mjs +1 -268
- package/sdk-admission.mjs +1 -9
- package/sdk-smoke.mjs +1 -61
- package/serve-consent.mjs +1 -118
- package/serve-dir.mjs +1 -40
- package/service.mjs +1 -877
- package/session-store.mjs +1 -484
- package/side-lane.mjs +1 -63
- package/supabase-key.mjs +1 -176
- package/supervisor-ready.mjs +1 -57
- package/switch-provider.mjs +1 -84
- package/terminal-name.mjs +1 -359
- package/terminal-row-reconcile.mjs +1 -70
- package/thinkpool-prompt-contracts.mjs +1 -85
- package/thinkpool-room-prompt.mjs +1 -187
- package/transcript-sanitize.mjs +1 -332
- package/turn-stall.mjs +1 -61
- package/update-gate.mjs +1 -53
- package/viewport.mjs +1 -810
- package/worker-completion.mjs +1 -57
package/supabase-key.mjs
CHANGED
|
@@ -1,176 +1 @@
|
|
|
1
|
-
|
|
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"})}
|
package/supervisor-ready.mjs
CHANGED
|
@@ -1,57 +1 @@
|
|
|
1
|
-
import fs from
|
|
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}}
|
package/switch-provider.mjs
CHANGED
|
@@ -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}}
|