thinkpool-pair 0.7.335 → 0.7.336

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/account.mjs CHANGED
@@ -526,6 +526,11 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
526
526
  // never impersonate a completed restart.
527
527
  const BRIDGE_ID = randomUUID()
528
528
  let restarting = false // set by the "restart" broadcast → stop() exits non-zero so a supervisor respawns us
529
+ // Account-control handlers are registered before the discovery loop is
530
+ // initialized. Route early requests through a harmless placeholder, then
531
+ // replace it once `tick` exists; this avoids a temporal-dead-zone crash during
532
+ // supervisor bootstrap while still letting a live web room request discovery.
533
+ let requestDiscoveryTick = () => {}
529
534
  // Web "attach this session" card → bind an unbound/new room to a local dir (and
530
535
  // optionally set it as the account default) so it serves WITHOUT a terminal command.
531
536
  // Rides the private, owner-gated control topic; the dir must exist on THIS machine.
@@ -538,7 +543,17 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
538
543
  bindDir(code, dir)
539
544
  if (payload?.setDefault) { saveDefaultDir(dir); process.stderr.write(`\n ◆ default project dir set → ${dir}\n`) }
540
545
  process.stderr.write(`\n ◆ ${code} bound → ${dir} (from the web). Serving…\n`)
541
- try { tick() } catch { /* the periodic tick will serve it next cycle */ }
546
+ requestDiscoveryTick()
547
+ })
548
+ // A newly-created web room already knows its owner and has an authenticated,
549
+ // owner-private account-control channel. Nudge discovery immediately instead
550
+ // of waiting for the supervisor's 15-second reconciliation interval. `tick`
551
+ // still re-queries owner-owned active rooms, so the browser cannot make this
552
+ // host serve an arbitrary code.
553
+ acctControl.on('broadcast', { event: 'serve-room' }, ({ payload }) => {
554
+ const code = String(payload?.code || '').toUpperCase().trim()
555
+ if (!/^[A-Z0-9]{4,12}$/.test(code)) return
556
+ requestDiscoveryTick()
542
557
  })
543
558
  // Dashboard "Disconnect" → broadcast shutdown → clean exit (presence leaves, bar flips live).
544
559
  // If we're the auto-restarting background service (launchd KeepAlive / systemd
@@ -906,6 +921,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
906
921
  tickingSince = now
907
922
  try { await tickBody() } finally { if (tickingSince === now) tickingSince = 0 }
908
923
  }
924
+ requestDiscoveryTick = () => { void tick().catch(() => {}) }
909
925
  // Expose for the watchdog to force-unwedge the guard after a reconnect.
910
926
  const clearTickGuard = () => { tickingSince = 0 }
911
927
  const tickBody = async () => {
package/bridge.mjs CHANGED
@@ -130,6 +130,7 @@ import { fetchPairControlDeliveryAuthority } from './pair-control-authority.mjs'
130
130
  import { SIDE_HANDOFF_PROMPT, SIDE_ROLE_PROMPT, appendSideContext, assistantTextSince, dispatchSideContexts, sideContextBlock, sideSnapshot } from './side-lane.mjs'
131
131
  import { planMeterLine } from './plan-meters.mjs'
132
132
  import { priceForModel } from './model-prices.mjs'
133
+ import { loadHermesModelCache, saveHermesModelCache } from './hermes-model-cache.mjs'
133
134
  import { makeThrottledTrack, recoveryBackoffMs } from './presence.mjs'
134
135
  import { MockupDeliveryQueue, completeMockupManifest, isMockupDeliveryBoundary } from './mockup-delivery.mjs'
135
136
  import { resolveAnonKey, DEFAULT_SUPABASE_URL } from './supabase-key.mjs'
@@ -140,6 +141,10 @@ import { buildTerminalRolePrompt, HERMES_VISIBLE_WORKER_FALLBACK_RULE, THINKPOOL
140
141
  const SUPABASE_URL = process.env.TP_SUPABASE_URL || DEFAULT_SUPABASE_URL
141
142
  const WEB_BASE = process.env.TP_WEB_BASE || 'https://thinkpool.io'
142
143
  const IMAGE_QUEUE_CONFIG = imageQueueConfig()
144
+ // Shared by every Hermes lane in this room child and persisted across room
145
+ // children. Without this, each new room must boot ACP (up to 30s) merely to
146
+ // discover the same public model list before its first real turn.
147
+ let hostHermesModels = loadHermesModelCache()
143
148
 
144
149
  // The anon key is RESOLVED, not baked. It used to be a string literal here, and
145
150
  // that literal now sits in 224 published tarballs on 224 users' laptops: disable
@@ -2139,7 +2144,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2139
2144
  ? spawnDepth
2140
2145
  : (sideParent || (spawnedBy && !String(spawnedBy).startsWith('flow:')) ? 1 : 0)
2141
2146
  const initialHop = Number.isInteger(hop) && hop >= 0 ? hop : structuralDepth
2142
- const entry = { cmd: runtime, runtime, kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: commandCatalogForRuntime(runtime, commands), mode, effort, models: runtime === 'codex' ? codexModels : runtime === 'hermes' && Array.isArray(models) ? models : undefined,
2147
+ const entry = { cmd: runtime, runtime, kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: commandCatalogForRuntime(runtime, commands), mode, effort, models: runtime === 'codex' ? codexModels : runtime === 'hermes' ? (Array.isArray(models) && models.length ? models : hostHermesModels) : undefined,
2143
2148
  // model: truthful active-model label — now the SAME `laneModel` the SDK is given, so the
2144
2149
  // chip cannot disagree with the wire. When this lane runs on a custom (non-anthropic)
2145
2150
  // registered provider the SDK id is impersonated (see the onEvent guard below), so
@@ -3311,7 +3316,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3311
3316
  // A custom Anthropic-compatible endpoint may expose its own catalog through the
3312
3317
  // same SDK method. Never publish that as the ROOM's Claude catalog: its models
3313
3318
  // already render in the provider group and otherwise leak under ANTHROPIC.
3314
- if (entry.runtime === 'hermes' && evt.kind === 'models' && Array.isArray(evt.models) && evt.models.length) { entry.models = evt.models; announce() }
3319
+ if (entry.runtime === 'hermes' && evt.kind === 'models' && Array.isArray(evt.models) && evt.models.length) { entry.models = saveHermesModelCache(evt.models); hostHermesModels = entry.models; announce() }
3315
3320
  else if (onBuiltin && evt.kind === 'models' && Array.isArray(evt.models) && evt.models.length) { roomModels = evt.models; announce() } }
3316
3321
  // Hermes model changes are asynchronous ACP requests. Keep the previous model
3317
3322
  // authoritative until Hermes acknowledges session/set_model; then settle the
@@ -0,0 +1,54 @@
1
+ // Hermes exposes its model catalog only after `session/new`, which may take up
2
+ // to 30 seconds while the ACP process boots. The catalog is public metadata and
3
+ // identical for every room served by this Hermes profile, so keep the last
4
+ // validated copy at the host level. A live Hermes session always replaces it.
5
+
6
+ import fs from 'node:fs'
7
+ import os from 'node:os'
8
+ import path from 'node:path'
9
+
10
+ const MAX_MODELS = 1000
11
+ const MAX_BYTES = 512 * 1024
12
+ const MAX_FIELD = 512
13
+
14
+ const cachePath = () => process.env.TP_HERMES_MODEL_CACHE
15
+ || path.join(process.env.TP_PAIR_ROOT || path.join(os.homedir(), '.thinkpool-pair'), 'hermes-models.json')
16
+
17
+ export function normalizeHermesModelCache(models) {
18
+ if (!Array.isArray(models)) return []
19
+ const seen = new Set()
20
+ const normalized = []
21
+ for (const raw of models.slice(0, MAX_MODELS)) {
22
+ const value = String(raw?.value || '').trim().slice(0, MAX_FIELD)
23
+ if (!value || seen.has(value)) continue
24
+ seen.add(value)
25
+ normalized.push({
26
+ value,
27
+ displayName: String(raw?.displayName || value).trim().slice(0, MAX_FIELD) || value,
28
+ description: String(raw?.description || '').trim().slice(0, MAX_FIELD),
29
+ })
30
+ }
31
+ return normalized
32
+ }
33
+
34
+ export function loadHermesModelCache() {
35
+ try {
36
+ const file = cachePath()
37
+ if ((Number(fs.statSync(file).size) || 0) > MAX_BYTES) return []
38
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
39
+ return normalizeHermesModelCache(parsed?.models)
40
+ } catch { return [] }
41
+ }
42
+
43
+ export function saveHermesModelCache(models) {
44
+ const normalized = normalizeHermesModelCache(models)
45
+ if (!normalized.length) return []
46
+ try {
47
+ const file = cachePath()
48
+ fs.mkdirSync(path.dirname(file), { recursive: true })
49
+ const temp = `${file}.${process.pid}.${Date.now()}.tmp`
50
+ fs.writeFileSync(temp, JSON.stringify({ savedAt: Date.now(), models: normalized }), { mode: 0o600 })
51
+ fs.renameSync(temp, file)
52
+ } catch { /* cache is opportunistic; live discovery remains the fallback */ }
53
+ return normalized
54
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.335",
3
+ "version": "0.7.336",
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": {
@@ -36,6 +36,7 @@
36
36
  "codex-commands.mjs",
37
37
  "acp-client.mjs",
38
38
  "hermes-session.mjs",
39
+ "hermes-model-cache.mjs",
39
40
  "hermes-policy.mjs",
40
41
  "hermes-acp-bootstrap.py",
41
42
  "hermes-event-mapper.mjs",