thinkpool-pair 0.7.311 → 0.7.312

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/bridge.mjs CHANGED
@@ -55,6 +55,8 @@ import { readCodexDefaultModel, readCodexModels, codexConfigForMode, codexThread
55
55
  import { codexAccountUsageLine, codexCreditsReportLine, codexLimitReportLine } from './codex-commands.mjs'
56
56
  import { withMcpSessionFactory } from './codex-mcp-http.mjs'
57
57
  import { startStructuredSession } from './runtime-session.mjs'
58
+ import { claudeOneShot } from './claude-session.mjs'
59
+ import { fallbackTerminalName, suggestTerminalName } from './terminal-name.mjs'
58
60
  import { defaultStructuredMode, normalizeStructuredEffort, shouldDeferStructuredRuntime, structuredModeForSlice, structuredModeLocked, structuredModesForLane, structuredRuntimeForCommand, structuredRuntimeMetadata, structuredRuntimeSupportsMode } from './runtime-registry.mjs'
59
61
  import { commandCatalogForRuntime, commandHelpLine, reconcileCommandCatalog } from './command-catalog.mjs'
60
62
  import { probeHermesRuntime } from './hermes-probe.mjs'
@@ -1160,6 +1162,9 @@ const replayPump = createLatestReplayPump({
1160
1162
  // Persisted on the host so a rename is cross-device + survives a bridge restart;
1161
1163
  // every announce carries them so a late-joining or second device sees them too.
1162
1164
  const termNames = loadNames(room)
1165
+ const manualNameTouched = new Set()
1166
+ const autoNameAttempts = new Set()
1167
+ const autoNames = new Map()
1163
1168
  // The SDK's supported-model LIST (value/displayName/description), captured from
1164
1169
  // any session's `models` event. Announced room-level so EVERY device gets it on
1165
1170
  // connect — not just the one that saw the one-shot event (the picker fell back to
@@ -1254,6 +1259,91 @@ const announce = () => {
1254
1259
  ],
1255
1260
  }) }
1256
1261
 
1262
+ const uniqueTerminalName = (id, candidate) => {
1263
+ const used = new Set(Object.entries(termNames)
1264
+ .filter(([otherId]) => otherId !== id)
1265
+ .map(([, label]) => String(label).toLowerCase()))
1266
+ if (!used.has(candidate.toLowerCase())) return candidate
1267
+ for (let n = 2; n < 100; n++) {
1268
+ const suffix = ` ${n}`
1269
+ const next = `${candidate.slice(0, 48 - suffix.length).trim()}${suffix}`
1270
+ if (!used.has(next.toLowerCase())) return next
1271
+ }
1272
+ return candidate
1273
+ }
1274
+
1275
+ // Best-effort row convergence: the bridge map updates the room immediately and
1276
+ // survives this host's restarts; the row carries the generated name to another host.
1277
+ const persistAutoTerminalName = async (id, label, previous, retry = true) => {
1278
+ if (!codeAuthToken || !id || !label) return
1279
+ try {
1280
+ // Compare-and-set: an in-flight generated write must never overwrite a
1281
+ // manual rename that won just before this request reached Postgres.
1282
+ const expected = previous
1283
+ ? `&name=eq.${encodeURIComponent(previous)}`
1284
+ : `&or=${encodeURIComponent('(name.is.null,name.eq.Terminal)')}`
1285
+ const response = await fetch(`${SUPABASE_URL}/rest/v1/code_terminals?id=eq.${encodeURIComponent(id)}${expected}&select=id`, {
1286
+ method: 'PATCH',
1287
+ headers: {
1288
+ apikey: SUPABASE_ANON,
1289
+ Authorization: `Bearer ${codeAuthToken}`,
1290
+ 'Content-Type': 'application/json',
1291
+ Prefer: 'return=representation',
1292
+ },
1293
+ body: JSON.stringify({ name: label }),
1294
+ })
1295
+ const rows = response.ok ? await response.json().catch(() => []) : []
1296
+ // Bridge-created lanes can receive work before the browser inserts their row.
1297
+ if (retry && (!response.ok || !Array.isArray(rows) || rows.length === 0)) {
1298
+ const timer = setTimeout(() => { void persistAutoTerminalName(id, label, previous, false) }, 1500)
1299
+ timer.unref?.()
1300
+ }
1301
+ } catch {
1302
+ if (retry) {
1303
+ const timer = setTimeout(() => { void persistAutoTerminalName(id, label, previous, false) }, 1500)
1304
+ timer.unref?.()
1305
+ }
1306
+ }
1307
+ }
1308
+
1309
+ const applyAutoTerminalName = (id, candidate) => {
1310
+ if (!candidate || manualNameTouched.has(id)) return false
1311
+ const currentAuto = autoNames.get(id)
1312
+ if (termNames[id] && termNames[id] !== currentAuto) return false
1313
+ const previous = termNames[id] || null
1314
+ const label = uniqueTerminalName(id, candidate)
1315
+ if (termNames[id] === label) return true
1316
+ termNames[id] = label
1317
+ autoNames.set(id, label)
1318
+ saveNames(room, termNames)
1319
+ void persistAutoTerminalName(id, label, previous)
1320
+ announce()
1321
+ return true
1322
+ }
1323
+
1324
+ // First real task only. A zero-token title appears immediately. Built-in Claude
1325
+ // may refine it through the existing raw Haiku path; other runtimes stay local so
1326
+ // a Codex/Hermes/custom-provider prompt is never leaked across providers.
1327
+ const autoNameTerminal = (id, text) => {
1328
+ const entry = sessions.get(id)
1329
+ if (!entry || termNames[id] || manualNameTouched.has(id) || autoNameAttempts.has(id)) return
1330
+ if (entry.log?.some((event) => event?.kind === 'you')) return
1331
+ const fallback = fallbackTerminalName(text)
1332
+ if (!fallback) return
1333
+ autoNameAttempts.add(id)
1334
+ applyAutoTerminalName(id, fallback)
1335
+ const canUseHaiku = entry.runtime === 'claude'
1336
+ && (!entry.provider || entry.provider === 'anthropic')
1337
+ && hostMemoryAdmission('name this terminal').ok
1338
+ if (!canUseHaiku) return
1339
+ const context = `Repository: ${repoLabel}. Existing terminal names: ${Object.values(termNames).filter(Boolean).slice(-8).join(', ') || 'none'}.`
1340
+ void suggestTerminalName({
1341
+ text,
1342
+ context,
1343
+ generate: (prompt) => claudeOneShot({ prompt, cwd: entry.cwd || process.cwd(), env: process.env, timeoutMs: 8000 }),
1344
+ }).then((candidate) => applyAutoTerminalName(id, candidate))
1345
+ }
1346
+
1257
1347
  // (cross-person grantee yield removed 2026-07-06 — owner-only serving now; the owner's
1258
1348
  // bridge never stands down. See docs/specs/2026-07-06-remove-cross-person-serve.md.)
1259
1349
 
@@ -1345,6 +1435,7 @@ async function receiveCrossRoomPost({ fromRoom, fromHost, fromTerminalName, text
1345
1435
  te.roomHop = 1; te.hop = (te.hop || 0) + 1; te.peekCount = 0; te.postCount = 0; te.pairPeekCount = 0; te.crossRoomPostCount = 0
1346
1436
  const msg = `[From: room ${fromLabel} — relayed via the ThinkPool cross-room Ensemble, approved by a person in this room]\n${body}`
1347
1437
  const evt = { kind: 'you', text: msg, by: `room ${fromRoom}`, crosspost: true, relaySourceName: String(fromTerminalName || '').trim().slice(0, 80) || undefined }
1438
+ autoNameTerminal(targetId, body)
1348
1439
  stampEvent(evt); pushLog(te, evt); bcast('code-event', { term: targetId, evt })
1349
1440
  try { if (te.session.sendTurn(msg) === false) return { error: `Could not deliver to room ${room} — the lane refused the turn (check its visible host-memory/runtime error).` } } catch { return { error: `Could not deliver to room ${room} — the lane may have just closed.` } }
1350
1441
  return { ok: true, ref: String(targetId).slice(0, 8) }
@@ -2577,6 +2668,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2577
2668
  // Echo the injected prompt into the TARGET lane so both people see what
2578
2669
  // arrived (rides the existing code-event 'you' path — no new topic).
2579
2670
  const evt = { kind: 'you', text: msg, by: `terminal ${fromRef} (agent)`, crosspost: true, relaySourceName: termNames[id] || undefined }
2671
+ autoNameTerminal(target.id, args.text)
2580
2672
  stampEvent(evt)
2581
2673
  pushLog(te, evt)
2582
2674
  bcast('code-event', { term: target.id, evt })
@@ -2630,6 +2722,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2630
2722
  announce()
2631
2723
  const msg = `[Task from main terminal ${fromRef}'s agent — opened as an independent MAIN CASCADE CONDUCTOR terminal, not an Ensemble child]\n${String(args.task).trim()}`
2632
2724
  const evt = { kind: 'you', text: msg, by: `main terminal ${fromRef} (agent)`, crosspost: true, relaySourceName: termNames[id] || undefined }
2725
+ autoNameTerminal(newId, args.task)
2633
2726
  stampEvent(evt); pushLog(conductor, evt); bcast('code-event', { term: newId, evt })
2634
2727
  try { if (conductor.session.sendTurn(msg) === false) return okText(`Opened main conductor ${newRef}, but host pressure prevented its runtime from starting. Existing lanes remain connected; free memory and retry the task.`) }
2635
2728
  catch { return okText(`Opened main conductor ${newRef}${args?.name ? ` ("${args.name}")` : ''}, but it may still be starting — the initial task could not be delivered.`) }
@@ -2749,6 +2842,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2749
2842
  : ''
2750
2843
  const msg = `[Task from terminal ${fromRef}'s agent — relayed via ThinkPool Ensemble; you are its WORKER SUB-TERMINAL, never a main terminal or Cascade conductor]\n${args.task}${reviewTarget}`
2751
2844
  const evt = { kind: 'you', text: msg, by: `terminal ${fromRef} (agent)`, crosspost: true, relaySourceName: termNames[id] || undefined }
2845
+ autoNameTerminal(newId, args.task)
2752
2846
  stampEvent(evt); pushLog(ne, evt); bcast('code-event', { term: newId, evt })
2753
2847
  try { if (ne.session.sendTurn(msg) === false) return okText(`Opened lane ${newRef}${args?.name ? ` ("${args.name}")` : ''}, but host pressure prevented its runtime from accepting the task. Existing lanes remain connected; free memory and retry.`) } catch { return okText(`Opened lane ${newRef}${args?.name ? ` ("${args.name}")` : ''}, but it may still be starting — could not hand off the task. Try post_to_terminal shortly.`) }
2754
2848
  return okText(`Opened agent lane ${newRef}${args?.name ? ` ("${args.name}")` : ''} and handed it the task. It runs in its own lane — check back with read_terminal, then close_terminal when done.`)
@@ -4241,6 +4335,7 @@ channel
4241
4335
  // Sample the runtime before dispatch so a missed prior falling edge cannot
4242
4336
  // make this genuinely new turn inherit the previous turn's revision.
4243
4337
  syncStructuredTurn(s)
4338
+ autoNameTerminal(payload.term, payload.body != null ? String(payload.body) : text)
4244
4339
  const accepted = s.session.sendTurn(sendText, Object.keys(turnOptions).length ? turnOptions : undefined)
4245
4340
  if (accepted === false) syncStructuredTurn(s)
4246
4341
  else beginStructuredTurn(s)
@@ -4410,6 +4505,8 @@ channel
4410
4505
  // LATER (or a second machine) — those only ever see the announce.
4411
4506
  .on('broadcast', { event: 'term-rename' }, ({ payload }) => {
4412
4507
  if (!payload?.id) return
4508
+ manualNameTouched.add(payload.id)
4509
+ autoNames.delete(payload.id)
4413
4510
  if (payload.name) termNames[payload.id] = String(payload.name).slice(0, 80)
4414
4511
  else delete termNames[payload.id]
4415
4512
  saveNames(room, termNames)
@@ -27,10 +27,42 @@ import { stallDecision, stallEvent, isCompactTurn } from './turn-stall.mjs'
27
27
 
28
28
  // The caret-pulled SDK's real version (^0.3.x auto-upgrades on restart). Resolved
29
29
  // once at import by walking up from the package entry to its own package.json.
30
+ const req = createRequire(import.meta.url)
31
+
32
+ // Shared raw one-shot for tiny bridge-owned inference jobs. No settings, skills,
33
+ // MCP servers, or repo instructions are loaded; callers provide the model + prompt.
34
+ export async function claudeOneShot({ prompt, model = 'claude-haiku-4-5', cwd, env, timeoutMs = 8000 } = {}) {
35
+ const abortController = new AbortController()
36
+ const timer = setTimeout(() => { try { abortController.abort() } catch { /* noop */ } }, timeoutMs)
37
+ try {
38
+ const result = query({
39
+ prompt,
40
+ options: {
41
+ model,
42
+ ...(cwd ? { cwd } : {}),
43
+ env,
44
+ maxTurns: 1,
45
+ permissionMode: 'bypassPermissions',
46
+ settingSources: [],
47
+ strictMcpConfig: true,
48
+ mcpServers: {},
49
+ abortController,
50
+ },
51
+ })
52
+ let out = ''
53
+ for await (const message of result) {
54
+ if (message.type === 'assistant') {
55
+ for (const block of (message.message?.content || [])) if (block.type === 'text') out += block.text
56
+ }
57
+ if (message.type === 'result') break
58
+ }
59
+ return out.trim()
60
+ } finally { clearTimeout(timer) }
61
+ }
62
+
30
63
  // Named in the [SDK-REGRESSION] guard below so a silent gate-change is attributable.
31
64
  const SDK_VERSION = (() => {
32
65
  try {
33
- const req = createRequire(import.meta.url)
34
66
  let d = dirname(req.resolve('@anthropic-ai/claude-agent-sdk'))
35
67
  for (let i = 0; i < 8; i++) {
36
68
  try { const p = JSON.parse(readFileSync(join(d, 'package.json'), 'utf8')); if (p.name === '@anthropic-ai/claude-agent-sdk') return p.version } catch { /* keep walking */ }
@@ -788,35 +820,17 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
788
820
  if (!suggest) return
789
821
  const seed = (lastAssistantText || '').trim().slice(-1500)
790
822
  if (!seed || closed) return
791
- const ac2 = new AbortController()
792
- const t = setTimeout(() => { try { ac2.abort() } catch { /* noop */ } }, 8000)
793
823
  try {
794
- const hq = query({
824
+ let out = await claudeOneShot({
795
825
  prompt: `You are predicting the user's NEXT chat message in a live coding session, to prefill their composer. Given the assistant's latest reply below, output the single most likely next user message — short and natural (often just "proceed", "go with option A", "yes do that", or a brief follow-up). One line, <=12 words, imperative, no preamble, no quotes, no markdown.\n\nAssistant's latest reply:\n"""\n${seed}\n"""\n\nNext user message:`,
796
- options: {
797
- model: 'claude-haiku-4-5',
798
- ...(cwd ? { cwd } : {}),
799
- env: opts.env,
800
- maxTurns: 1,
801
- permissionMode: 'bypassPermissions',
802
- settingSources: [], // no CLAUDE.md / commands / agents — raw call
803
- strictMcpConfig: true,
804
- mcpServers: {}, // no MCP servers — fast cold start
805
- abortController: ac2,
806
- },
826
+ model: 'claude-haiku-4-5', cwd, env: opts.env, timeoutMs: 8000,
807
827
  })
808
- let out = ''
809
- for await (const mm of hq) {
810
- if (mm.type === 'assistant') for (const b of (mm.message?.content || [])) if (b.type === 'text') out += b.text
811
- if (mm.type === 'result') break
812
- }
813
828
  out = out.trim().split('\n')[0].replace(/^["'`]+|["'`]+$/g, '').trim().slice(0, 140)
814
829
  if (out && !sawSuggestion && !closed && !/^(sure|of course|certainly|let me know|i can help|happy to)\b/i.test(out)) {
815
830
  emit({ kind: 'suggestion', text: out, source: 'haiku' })
816
831
  process.stderr.write(` ◆ [suggestion] haiku fallback: ${JSON.stringify(out)}\n`)
817
832
  }
818
833
  } catch { /* fallback failed (rate limit / abort / model error) — silent */ }
819
- finally { clearTimeout(t) }
820
834
  }
821
835
 
822
836
  const admitColdStart = () => {
@@ -266,7 +266,7 @@ export const nativeClaudeProviderAccessFailed = (events) => {
266
266
  let text = ''
267
267
  try { text = typeof events === 'string' ? events : JSON.stringify(events || '') }
268
268
  catch { text = String(events || '') }
269
- return /organization has disabled (?:claude )?subscription access|(?:claude )?subscription access (?:has been |is )?disabled|invalid anthropic (?:api )?key|invalid x-api-key|not logged in to claude|(?:run|use) \/login[^\n]{0,80}claude|no valid anthropic (?:api )?key/i.test(text)
269
+ return /organization has disabled (?:claude(?: subscription access)?|subscription access)|(?:claude )?subscription access (?:has been |is )?disabled|invalid anthropic (?:api )?key|invalid x-api-key|not logged in to claude|(?:run|use) \/login[^\n]{0,80}claude|no valid anthropic (?:api )?key/i.test(text)
270
270
  }
271
271
 
272
272
  const HERMES_CLAUDE_REVIEW_PREFERENCE = Object.freeze([
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.311",
3
+ "version": "0.7.312",
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": {
@@ -18,6 +18,7 @@
18
18
  "byok-detect.mjs",
19
19
  "context-windows.mjs",
20
20
  "claude-session.mjs",
21
+ "terminal-name.mjs",
21
22
  "claude-command-catalog.mjs",
22
23
  "codex-session.mjs",
23
24
  "question-response.mjs",
@@ -0,0 +1,54 @@
1
+ import { nativeClaudeProviderAccessFailed } from './cross-terminal.mjs'
2
+
3
+ const SKIP = new Set([
4
+ 'a', 'an', 'and', 'are', 'at', 'be', 'can', 'could', 'for', 'from', 'how', 'i',
5
+ 'in', 'is', 'it', 'just', 'like', 'maybe', 'me', 'my', 'of', 'on', 'or', 'our',
6
+ 'please', 'something', 'that', 'the', 'this', 'to', 'we', 'with', 'would', 'you',
7
+ ])
8
+
9
+ const GENERIC = /^(?:new )?(?:agent |coding )?(?:terminal|task|lane|session|work)$/i
10
+
11
+ export function cleanTerminalName(value) {
12
+ let name = String(value || '')
13
+ .trim()
14
+ .split(/\r?\n/, 1)[0]
15
+ .replace(/^\s*(?:[-*#>]+|title\s*:?)\s*/i, '')
16
+ .replace(/^["'`]+|["'`]+$/g, '')
17
+ .replace(/[^\p{L}\p{N}+#&.' -]+/gu, ' ')
18
+ .replace(/\s+/g, ' ')
19
+ .trim()
20
+ if (!name || GENERIC.test(name) || nativeClaudeProviderAccessFailed(name)) return null
21
+ if (/^(?:error|failed|sorry|unable|i (?:cannot|can't)|rate limit|request failed)\b/i.test(name)) return null
22
+ if (name.length > 48) name = name.slice(0, 48).replace(/\s+\S*$/, '').trim()
23
+ return name || null
24
+ }
25
+
26
+ export function fallbackTerminalName(text) {
27
+ const body = String(text || '')
28
+ .replace(/^\s*\[[^\]\n]{1,240}\]\s*/g, '')
29
+ .replace(/^\s*(?:hey|hi|okay|ok|so)\b[,:!]?\s*/i, '')
30
+ .replace(/^\s*(?:can|could|would|will)\s+you\s+/i, '')
31
+ .replace(/^\s*(?:how about|i (?:do not|don't) know|i guess)\s+/i, '')
32
+ .replace(/[`*_>#()[\]{}]/g, ' ')
33
+ .slice(0, 600)
34
+ const words = body.match(/[\p{L}\p{N}][\p{L}\p{N}+#.'-]*/gu) || []
35
+ const useful = words.filter((word) => !SKIP.has(word.toLowerCase()) && !/^https?$/i.test(word))
36
+ if (!useful.length) return null
37
+ const title = useful.slice(0, 5).map((word) => {
38
+ if (/^[A-Z\d+#.-]{2,}$/.test(word) || /\d/.test(word)) return word
39
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
40
+ }).join(' ')
41
+ return cleanTerminalName(title)
42
+ }
43
+
44
+ export async function suggestTerminalName({ text, context = '', generate } = {}) {
45
+ const fallback = fallbackTerminalName(text)
46
+ if (!fallback || typeof generate !== 'function') return fallback
47
+ const prompt = [
48
+ 'Name one terminal lane from its first task. Output only a distinctive 2-5 word title, title case, at most 40 characters. Describe the concrete work, not the person or model. Never output “Terminal”, “Task”, “Session”, or a numbered label.',
49
+ context ? `Room context: ${String(context).slice(0, 500)}` : '',
50
+ `First task:\n${String(text || '').slice(0, 1600)}`,
51
+ ].filter(Boolean).join('\n\n')
52
+ try { return cleanTerminalName(await generate(prompt)) || fallback }
53
+ catch { return fallback }
54
+ }