thinkpool-pair 0.7.334 → 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.334",
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",
@@ -113,6 +114,7 @@
113
114
  "@anthropic-ai/claude-agent-sdk": "^0.3.198",
114
115
  "@modelcontextprotocol/sdk": "^1.29.0",
115
116
  "@supabase/supabase-js": "^2.45.0",
117
+ "compromise": "14.16.0",
116
118
  "yaml": "2.9.0",
117
119
  "zod": "^4.4.3"
118
120
  },
package/terminal-name.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { nativeClaudeProviderAccessFailed } from './cross-terminal.mjs'
2
+ import nlp from 'compromise/two'
2
3
 
3
4
  const SKIP = new Set([
4
5
  'a', 'an', 'and', 'are', 'at', 'be', 'can', 'could', 'for', 'from', 'how', 'i',
@@ -18,12 +19,66 @@ const NOISE = /^(?:context|background|for reference|here(?:'s| is)|note|current(
18
19
  const EXPLANATION = /^(?:because|cause|since|so that|this is because)\b/i
19
20
  const CONSTRAINT = /^(?:users? can still|keep|must|never|should|without)\b/i
20
21
  const DEPENDENT_FRAGMENT = /^(?:after|before|by|during|for|from|in|instead(?: of)?|on|through|until|with|without)\b/i
21
- const INFORMATION_REQUEST = /^(?:which|what|who|where|when|why|how)\b/i
22
- const NEGATIVE_PREFERENCE = /^(?:(?:i|we)\s+)?(?:do not|don't|dont|would not|wouldn't|won't|wont)\s+(?:(?:want|wanna|need)(?:\s+to)?|use|include|choose)\b|^(?:(?:i|we)\s+)?(?:want|wanna|need)(?:\s+to)?\s+avoid\b/i
23
22
  const ISSUE = /\b(?:broken|buggy|crash(?:es|ed|ing)?|duplicate|error|fail(?:s|ed|ing|ure)?|flash(?:es|ed|ing)?|missing|no animation|not working|out of (?:scrollable )?view|stuck|wrong)\b/i
24
23
  const REQUEST = /^(?:please\s+)?(?:can|could|would|will)\s+(?:we|you)\b|^(?:please\s+)?(?:how about|let's|let us|we need to|i want you to)\b/i
25
24
  const SECRET = /(?<![\p{L}\p{N}])(?:sk-(?:proj-)?[a-z0-9_-]{8,}|gsk_[a-z0-9_-]{8,}|xox[baprs]-[a-z0-9_-]{8,}|gh[pousr]_[a-z0-9_-]{8,}|glpat-[a-z0-9_-]{8,}|npm_[a-z0-9]{24,}|(?:sk|rk)_(?:live|test)_[a-z0-9]{8,}|whsec_[a-z0-9]{8,}|AIza[a-z0-9_-]{8,}|AKIA[A-Z0-9]{12,}|bearer\s+[a-z0-9._-]{8,}|eyJ[a-z0-9_-]{8,}\.[a-z0-9_-]{8,}\.[a-z0-9_-]{8,})(?![\p{L}\p{N}])/giu
26
25
  const HANDOFF_MARKER = /^---\s*(?:the person's next message|current person request(?:\s*\([^\n]*\))?|side task)\s*---\s*$/gim
26
+ const QUESTION_WORDS = new Set(['how', 'what', 'when', 'where', 'which', 'who', 'whom', 'whose', 'why'])
27
+ const PREFERENCE_WORDS = new Set(['avoid', 'choose', 'include', 'need', 'use', 'want'])
28
+ const PREFERENCE_OBJECT_WORDS = new Set(['avoid', 'choose', 'include', 'use'])
29
+
30
+ const conceptsForTerm = (term) => new Set(
31
+ [term?.text, term?.normal, term?.machine, term?.implicit]
32
+ .map((value) => String(value || '').trim().toLowerCase())
33
+ .filter(Boolean),
34
+ )
35
+
36
+ const analyzeGrammar = (value, suppliedTerms = null) => {
37
+ const text = String(value || '').trim()
38
+ const terms = suppliedTerms || nlp(text).json({ terms: true }).flatMap((sentence) => sentence.terms || [])
39
+ const visible = terms
40
+ .filter((term) => String(term?.text || '').trim())
41
+ .map((term) => ({ text: term.text, concepts: conceptsForTerm(term), tags: new Set(term.tags || []) }))
42
+ const hasQuestionWord = visible.some((term) => [...term.concepts].some((word) => QUESTION_WORDS.has(word)))
43
+ const taggedQuestionWord = visible.some((term) => term.tags.has('QuestionWord'))
44
+ const negative = terms.some((term) => Array.isArray(term?.tags) && term.tags.includes('Negative'))
45
+ const preference = terms.some((term) => [...conceptsForTerm(term)].some((word) => PREFERENCE_WORDS.has(word)))
46
+ return {
47
+ text,
48
+ visible,
49
+ informationRequest: hasQuestionWord && (taggedQuestionWord || /\?\s*$/.test(text)),
50
+ negativePreference: negative && preference,
51
+ }
52
+ }
53
+
54
+ const parsedClauses = (value) => String(value || '')
55
+ .replace(/,\s*(?:but|however)\s+/gi, '\n')
56
+ .split(/\n+|;\s*/)
57
+ .flatMap((part) => {
58
+ const text = part.trim()
59
+ if (!text) return []
60
+ const sentences = nlp(text).json({ terms: true })
61
+ return sentences.length
62
+ ? sentences.map((sentence) => analyzeGrammar(sentence.text, sentence.terms || []))
63
+ : [analyzeGrammar(text)]
64
+ })
65
+ .filter((clause) => clause.text)
66
+
67
+ const questionSubject = (analysis) => {
68
+ const index = analysis.visible.findIndex((term) => [...term.concepts].some((word) => QUESTION_WORDS.has(word)))
69
+ return index >= 0 ? analysis.visible.slice(index + 1).map((term) => term.text).join(' ') : analysis.text
70
+ }
71
+
72
+ const negativePreferenceSubject = (analysis) => {
73
+ let index = -1
74
+ for (let i = 0; i < analysis.visible.length; i++) {
75
+ if ([...analysis.visible[i].concepts].some((word) => PREFERENCE_OBJECT_WORDS.has(word))) index = i
76
+ }
77
+ if (index < 0) {
78
+ index = analysis.visible.findIndex((term) => [...term.concepts].some((word) => word === 'need' || word === 'want'))
79
+ }
80
+ return index >= 0 ? analysis.visible.slice(index + 1).map((term) => term.text).join(' ') : analysis.text
81
+ }
27
82
 
28
83
  const ACTIONS = Object.freeze([
29
84
  { title: 'Fix', score: 100, re: /\b(?:fix|fixes|fixed|fixing|repair|repairs|repaired|repairing)\b/i },
@@ -117,35 +172,31 @@ const explicitIntent = (text) => {
117
172
  }
118
173
 
119
174
  const bestIntentClause = (text) => {
120
- const clauses = text
121
- .replace(/,\s*(?:but|however)\s+/gi, '\n')
122
- .split(/\n+|(?<=[.!?;])\s+/)
123
- .map((part) => part.trim())
124
- .filter(Boolean)
175
+ const clauses = parsedClauses(text)
125
176
  let best = null
126
- for (let i = 0; i < clauses.length; i++) {
127
- const clause = clauses[i].replace(/^[-*#>\d.)\s]+/, '').trim()
177
+ for (const parsed of clauses) {
178
+ const clause = parsed.text.replace(/^[-*#>\d.)\s]+/, '').trim()
128
179
  if (!clause || /^(?:https?:\/\/|\/|[A-Za-z]:\\)/.test(clause)) continue
129
- const words = clause.match(/[\p{L}\p{N}][\p{L}\p{N}+#.'-]*/gu) || []
180
+ const grammar = clause === parsed.text ? parsed : analyzeGrammar(clause)
130
181
  const action = bestAction(clause)
131
182
  let score = action?.score || 0
132
183
  if (REQUEST.test(clause)) score += 18
133
- if (INFORMATION_REQUEST.test(clause)) score += 36
184
+ if (grammar.informationRequest) score += 36
134
185
  if (ISSUE.test(clause)) score += /\b(?:crash|fail|flash|stuck|wrong)\w*\b/i.test(clause) ? 36 : 26
135
- if (words.length >= 3 && words.length <= 24) score += 10
136
- else if (words.length > 40) score -= 14
186
+ if (grammar.visible.length >= 3 && grammar.visible.length <= 24) score += 10
187
+ else if (grammar.visible.length > 40) score -= 14
137
188
  if (NOISE.test(clause)) score -= 30
138
189
  if (EXPLANATION.test(clause)) score -= 34
139
190
  if (CONSTRAINT.test(clause)) score -= 45
140
- if (NEGATIVE_PREFERENCE.test(clause)) score -= 70
191
+ if (grammar.negativePreference) score -= 70
141
192
  // A short dependent tail such as "From ground up" or "On mobile" adds
142
193
  // scope to the preceding request; it is not a standalone task. Recency is
143
194
  // deliberately not a signal here: the title should represent the main
144
195
  // theme, not whichever sentence happened to come last.
145
- if (!action && !REQUEST.test(clause) && !INFORMATION_REQUEST.test(clause) && DEPENDENT_FRAGMENT.test(clause)) score -= 24
196
+ if (!action && !REQUEST.test(clause) && !grammar.informationRequest && DEPENDENT_FRAGMENT.test(clause)) score -= 24
146
197
  if (!best || score > best.score) best = { clause, score }
147
198
  }
148
- return best?.clause || clauses[0] || null
199
+ return best?.clause || clauses[0]?.text || null
149
200
  }
150
201
 
151
202
  export function extractTerminalTask(value) {
@@ -210,9 +261,13 @@ const taskTitle = (value) => {
210
261
  .replace(/^\s*(?:how about|i (?:do not|don't) know|i guess|i want you to|we need to|your task is to|let(?:'s| us))\s+/i, '')
211
262
  .trim()
212
263
  if (!body) return null
213
- if (NEGATIVE_PREFERENCE.test(body)) return null
214
- const informationRequest = INFORMATION_REQUEST.test(body)
215
- if (informationRequest) body = body.replace(/^\s*(?:which|what)\s+/i, '').trim()
264
+ const grammar = analyzeGrammar(body)
265
+ if (grammar.negativePreference) {
266
+ const subject = contentWords(negativePreferenceSubject(grammar)).slice(0, 4)
267
+ return subject.length ? cleanTerminalName(['Avoid', ...subject.map(titleWord)].join(' ')) : null
268
+ }
269
+ const informationRequest = grammar.informationRequest
270
+ if (informationRequest) body = questionSubject(grammar).trim()
216
271
  const signalText = body
217
272
  body = body
218
273
  .replace(/\s*,?\s+(?:so (?:that|you|we|it)\b|because\b|cause\b|if you know what i mean\b)[\s\S]*$/i, '')