thinkpool-pair 0.7.310 → 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/account.mjs +68 -8
- package/auth-store.mjs +27 -13
- package/bridge.mjs +97 -0
- package/claude-session.mjs +35 -21
- package/cross-terminal.mjs +1 -1
- package/launcher.mjs +9 -4
- package/package.json +2 -1
- package/terminal-name.mjs +54 -0
package/account.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import path from 'node:path'
|
|
|
13
13
|
import fs from 'node:fs'
|
|
14
14
|
import { createClient } from '@supabase/supabase-js'
|
|
15
15
|
import os from 'node:os'
|
|
16
|
-
import { saveAuth, loadAuth, loadDirs, bindDir, loadServed, rememberServed, loadDefaultDir, saveDefaultDir, claudeRecentProjectDir } from './auth-store.mjs'
|
|
16
|
+
import { saveAuth, loadAuth, markAuthReconnectRequired, loadDirs, bindDir, loadServed, rememberServed, loadDefaultDir, saveDefaultDir, claudeRecentProjectDir } from './auth-store.mjs'
|
|
17
17
|
import { isSafeToRestart } from './update-gate.mjs'
|
|
18
18
|
import { makeThrottledTrack, presenceSelfEchoObservation } from './presence.mjs'
|
|
19
19
|
import { publicKeyB64, announceProviders, listProviders, addProvider, addProviderModel, removeProvider, unseal } from './providers.mjs'
|
|
@@ -252,16 +252,33 @@ export async function refreshAccountSession(sb, holdRefreshToken, deps = {}) {
|
|
|
252
252
|
if (adopted) return adopted
|
|
253
253
|
const rt = load()?.refresh_token || rt0
|
|
254
254
|
const { data, error } = await sb.auth.refreshSession({ refresh_token: rt })
|
|
255
|
-
|
|
255
|
+
// Preserve the server's structured Auth error. The caller must distinguish a
|
|
256
|
+
// retryable network/service failure from a refresh token that is definitively
|
|
257
|
+
// gone; collapsing both to null is what let a disconnected account keep painting
|
|
258
|
+
// as a healthy bridge until its presence JWT finally expired.
|
|
259
|
+
if (error || !data?.session) return { session: null, error: error || null }
|
|
256
260
|
save(data.session) // atomic (auth-store) — never a torn token file
|
|
257
261
|
return { session: data.session, adopted: false }
|
|
258
262
|
} finally { if (locked) release() }
|
|
259
263
|
}
|
|
260
264
|
|
|
265
|
+
// Supabase documents these as terminal session/refresh-token states. They require a
|
|
266
|
+
// fresh device-code login; request timeouts, rate limits and 5xx errors remain retryable.
|
|
267
|
+
export const ACCOUNT_RECONNECT_ERROR_CODES = new Set([
|
|
268
|
+
'refresh_token_not_found',
|
|
269
|
+
'refresh_token_already_used',
|
|
270
|
+
'session_not_found',
|
|
271
|
+
])
|
|
272
|
+
|
|
273
|
+
export function accountRefreshNeedsReconnect(error) {
|
|
274
|
+
return ACCOUNT_RECONNECT_ERROR_CODES.has(String(error?.code || ''))
|
|
275
|
+
}
|
|
276
|
+
|
|
261
277
|
// Exchange the stored credentials for a live session + authed client.
|
|
262
278
|
export async function authedClient(SUPABASE_URL, SUPABASE_ANON) {
|
|
263
279
|
const a = loadAuth()
|
|
264
280
|
if (!a?.refresh_token) return null
|
|
281
|
+
if (a.reconnect_required) return { reconnectRequired: true }
|
|
265
282
|
const sb = createClient(SUPABASE_URL, SUPABASE_ANON, { auth: { persistSession: false, autoRefreshToken: false } })
|
|
266
283
|
// F1: skip the refresh entirely when the saved access token is still comfortably valid
|
|
267
284
|
// (>5 min to expiry). Every gratuitous startup rotation is a race window — and, killed
|
|
@@ -273,7 +290,13 @@ export async function authedClient(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
273
290
|
}
|
|
274
291
|
// Near-expiry / missing / invalid access token → do a serialized rotation (F3).
|
|
275
292
|
const r = await refreshAccountSession(sb, a.refresh_token)
|
|
276
|
-
if (!r?.session)
|
|
293
|
+
if (!r?.session) {
|
|
294
|
+
if (accountRefreshNeedsReconnect(r?.error)) {
|
|
295
|
+
markAuthReconnectRequired(r.error.code)
|
|
296
|
+
return { reconnectRequired: true }
|
|
297
|
+
}
|
|
298
|
+
return null
|
|
299
|
+
}
|
|
277
300
|
return { sb, session: r.session }
|
|
278
301
|
}
|
|
279
302
|
|
|
@@ -289,6 +312,7 @@ export async function authedClientWithRetry(SUPABASE_URL, SUPABASE_ANON, opts =
|
|
|
289
312
|
let delay = baseMs
|
|
290
313
|
for (let n = 1; n <= maxAttempts; n++) {
|
|
291
314
|
const a = await attempt(SUPABASE_URL, SUPABASE_ANON)
|
|
315
|
+
if (a?.reconnectRequired) return a
|
|
292
316
|
if (a) return a
|
|
293
317
|
if (!load()?.refresh_token) return null // not linked — caller says `login`, exits 0
|
|
294
318
|
if (n >= maxAttempts) break
|
|
@@ -320,10 +344,11 @@ export function claimLoopWedged({ lastClaimOkAt, now, quietMsThreshold = 40_000
|
|
|
320
344
|
// skewMs — refresh this far ahead of expiry (default 90s)
|
|
321
345
|
// forceMs — fixed interval override (test hook: TP_TOKEN_REFRESH_FORCE_MS)
|
|
322
346
|
// retryMs — backoff after a transient failure (offline / rotation race)
|
|
323
|
-
export function scheduleTokenRefresh({ sb, session, onRefreshed, skewMs = 90_000, forceMs = 0, retryMs = 30_000, refresh = refreshAccountSession }) {
|
|
347
|
+
export function scheduleTokenRefresh({ sb, session, onRefreshed, onReconnectRequired, skewMs = 90_000, forceMs = 0, retryMs = 30_000, refresh = refreshAccountSession }) {
|
|
324
348
|
let timer = null
|
|
325
349
|
let cur = session
|
|
326
350
|
let cancelled = false
|
|
351
|
+
let reconnectRequired = false
|
|
327
352
|
const arm = (delay) => {
|
|
328
353
|
if (cancelled) return
|
|
329
354
|
clearTimeout(timer)
|
|
@@ -343,12 +368,21 @@ export function scheduleTokenRefresh({ sb, session, onRefreshed, skewMs = 90_000
|
|
|
343
368
|
// instead of blindly refreshing with our in-memory `cur.refresh_token` — the old
|
|
344
369
|
// tick retried with a stale token it held and could lose the family to a race.
|
|
345
370
|
const r = await refresh(sb, cur.refresh_token)
|
|
346
|
-
if (!r?.session)
|
|
371
|
+
if (!r?.session) {
|
|
372
|
+
if (accountRefreshNeedsReconnect(r?.error)) {
|
|
373
|
+
if (!reconnectRequired) {
|
|
374
|
+
reconnectRequired = true
|
|
375
|
+
try { await onReconnectRequired?.(r.error) } catch { /* reporting is best-effort; retry still owns recovery */ }
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
throw r?.error || new Error('no session')
|
|
379
|
+
}
|
|
347
380
|
cur = r.session
|
|
348
381
|
// Hand the fresh token to the live realtime socket so the presence
|
|
349
382
|
// connection stays authed across the swap (no drop, no rejoin needed).
|
|
350
383
|
try { sb.realtime?.setAuth?.(cur.access_token) } catch { /* noop */ }
|
|
351
|
-
|
|
384
|
+
reconnectRequired = false
|
|
385
|
+
try { await onRefreshed?.(cur) } catch { /* noop */ }
|
|
352
386
|
arm(nextDelay())
|
|
353
387
|
} catch {
|
|
354
388
|
// Transient (offline, or a refresh-token rotation race with another login).
|
|
@@ -394,6 +428,12 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
394
428
|
console.error('\n ◇ Not linked to a ThinkPool account on this machine.\n Link it: npx thinkpool-pair login\n')
|
|
395
429
|
process.exit(0)
|
|
396
430
|
}
|
|
431
|
+
if (auth.reconnectRequired) {
|
|
432
|
+
releaseStartupAnchor()
|
|
433
|
+
releaseSingleton()
|
|
434
|
+
console.error('\n ◇ This bridge lost its Thinkpool account link.\n Reconnect it: npx thinkpool-pair@latest login\n')
|
|
435
|
+
process.exit(0)
|
|
436
|
+
}
|
|
397
437
|
const { sb, session } = auth
|
|
398
438
|
const email = session.user?.email || 'your account'
|
|
399
439
|
// Current owner JWT, handed to each room-bridge child for authed web writes
|
|
@@ -548,6 +588,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
548
588
|
// lastTrackStatus retains the most recent track()'s server verdict ('ok'|'timed out'|
|
|
549
589
|
// 'error') so the presence self-echo watchdog can log WHY presence went dark.
|
|
550
590
|
let lastTrackStatus = 'init'
|
|
591
|
+
let accountAuthState = 'connected'
|
|
551
592
|
const trackPresence = makeThrottledTrack(acct, { minMs: 5000, onStatus: (s) => { lastTrackStatus = s } })
|
|
552
593
|
// `service` lets the dashboard show whether this bridge is the durable installed
|
|
553
594
|
// service (always-on, survives reboot) vs a foreground `npx` run that dies with its
|
|
@@ -560,7 +601,17 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
560
601
|
// to spawn against — re-read each announce so an add/remove reflects immediately.
|
|
561
602
|
// Both are additive; older clients ignore them (feature: multi-provider BYOK, slice 1).
|
|
562
603
|
const PROVIDER_PUBKEY = publicKeyB64()
|
|
563
|
-
const pushPresence = () => trackPresence({ name: machine, bridgeId: BRIDGE_ID, version: VERSION, rooms: [...children.keys()], readyRooms: [...readyRooms], refused: [...refused].map(([code, reason]) => ({ code, reason })), service: runsAsService(), providerPubKey: PROVIDER_PUBKEY, providers: announceProviders(), agents: installedAgentCommands().map((cmd) => ({ cmd })), ts: Date.now() })
|
|
604
|
+
const pushPresence = () => trackPresence({ name: machine, bridgeId: BRIDGE_ID, version: VERSION, rooms: [...children.keys()], readyRooms: [...readyRooms], refused: [...refused].map(([code, reason]) => ({ code, reason })), service: runsAsService(), providerPubKey: PROVIDER_PUBKEY, providers: announceProviders(), agents: installedAgentCommands().map((cmd) => ({ cmd })), accountAuthState, ts: Date.now() })
|
|
605
|
+
|
|
606
|
+
// Persist account-auth truth separately from process presence. Presence will vanish
|
|
607
|
+
// when the old JWT expires; the claim row keeps the reconnect instruction visible on
|
|
608
|
+
// the dashboard until this same bridge refreshes successfully or another bridge takes
|
|
609
|
+
// over with a healthy login. Older databases simply reject this best-effort RPC.
|
|
610
|
+
const reportAccountAuthState = async (state) => {
|
|
611
|
+
try {
|
|
612
|
+
await withTimeout(sb.rpc('report_bridge_auth_state', { p_bridge_id: BRIDGE_ID, p_state: state }), 5000, 'report_bridge_auth_state')
|
|
613
|
+
} catch { /* migration not present yet, offline, or the old JWT already expired */ }
|
|
614
|
+
}
|
|
564
615
|
|
|
565
616
|
// ── Provider registry — the multi-BYOK wire contract (slice 1). ─────────────
|
|
566
617
|
// AUTH: the account channel `tpacct:<uid>` is created WITHOUT config.private:true,
|
|
@@ -647,13 +698,22 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
647
698
|
const keepFresh = scheduleTokenRefresh({
|
|
648
699
|
sb,
|
|
649
700
|
session,
|
|
650
|
-
onRefreshed: (s) => {
|
|
701
|
+
onRefreshed: async (s) => {
|
|
702
|
+
accountAuthState = 'connected'
|
|
651
703
|
saveAuth(s); pushPresence()
|
|
704
|
+
await reportAccountAuthState('connected')
|
|
652
705
|
// Push the rotated owner token to every live child so their authed writes
|
|
653
706
|
// (code-mockup) never go stale on a long session (whip L16).
|
|
654
707
|
currentAccessToken = s.access_token || currentAccessToken
|
|
655
708
|
for (const c of children.values()) { try { c.send({ t: 'token', accessToken: currentAccessToken }) } catch { /* child not ready */ } }
|
|
656
709
|
},
|
|
710
|
+
onReconnectRequired: async (error) => {
|
|
711
|
+
accountAuthState = 'reconnect-required'
|
|
712
|
+
markAuthReconnectRequired(error?.code || 'refresh-token-invalid')
|
|
713
|
+
pushPresence()
|
|
714
|
+
await reportAccountAuthState('reconnect-required')
|
|
715
|
+
process.stderr.write('\n ◇ This bridge lost its Thinkpool account link.\n Reconnect it: npx thinkpool-pair@latest login\n')
|
|
716
|
+
},
|
|
657
717
|
forceMs: parseInt(process.env.TP_TOKEN_REFRESH_FORCE_MS, 10) || 0,
|
|
658
718
|
})
|
|
659
719
|
|
package/auth-store.mjs
CHANGED
|
@@ -19,19 +19,8 @@ const SERVED = path.join(DIR, 'served.json')
|
|
|
19
19
|
|
|
20
20
|
function ensureDir() { try { fs.mkdirSync(DIR, { recursive: true }) } catch { /* noop */ } }
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
if (!session?.refresh_token) return
|
|
22
|
+
function writeAuthPayload(payload) {
|
|
24
23
|
ensureDir()
|
|
25
|
-
const payload = JSON.stringify({
|
|
26
|
-
refresh_token: session.refresh_token,
|
|
27
|
-
access_token: session.access_token || null,
|
|
28
|
-
// Unix SECONDS. Lets the bridge adopt the saved access token WITHOUT a refresh while
|
|
29
|
-
// it's still comfortably valid (account.mjs F1) — every skipped rotation is one fewer
|
|
30
|
-
// race window (and one fewer chance to be killed mid-rotation and brick the login).
|
|
31
|
-
expires_at: session.expires_at || null,
|
|
32
|
-
email: session.user?.email || session.email || null,
|
|
33
|
-
savedAt: Date.now(),
|
|
34
|
-
})
|
|
35
24
|
// ATOMIC write (2026-07-08): a crash/kill/kickstart mid-write must never leave a
|
|
36
25
|
// truncated auth.json — an unparseable token file reads as "not linked" and bricks the
|
|
37
26
|
// saved login (three bridge outages in two days, one from a restart landing mid-rotation).
|
|
@@ -43,13 +32,38 @@ export function saveAuth(session) {
|
|
|
43
32
|
const tmp = `${AUTH}.tmp.${process.pid}.${Date.now()}`
|
|
44
33
|
try {
|
|
45
34
|
const fd = fs.openSync(tmp, 'w', 0o600)
|
|
46
|
-
try { fs.writeSync(fd, payload); fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
|
|
35
|
+
try { fs.writeSync(fd, JSON.stringify(payload)); fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
|
|
47
36
|
fs.renameSync(tmp, AUTH)
|
|
48
37
|
} catch {
|
|
49
38
|
try { fs.rmSync(tmp, { force: true }) } catch { /* noop */ }
|
|
50
39
|
}
|
|
51
40
|
}
|
|
41
|
+
|
|
42
|
+
export function saveAuth(session) {
|
|
43
|
+
if (!session?.refresh_token) return
|
|
44
|
+
writeAuthPayload({
|
|
45
|
+
refresh_token: session.refresh_token,
|
|
46
|
+
access_token: session.access_token || null,
|
|
47
|
+
// Unix SECONDS. Lets the bridge adopt the saved access token WITHOUT a refresh while
|
|
48
|
+
// it's still comfortably valid (account.mjs F1) — every skipped rotation is one fewer
|
|
49
|
+
// race window (and one fewer chance to be killed mid-rotation and brick the login).
|
|
50
|
+
expires_at: session.expires_at || null,
|
|
51
|
+
email: session.user?.email || session.email || null,
|
|
52
|
+
savedAt: Date.now(),
|
|
53
|
+
})
|
|
54
|
+
}
|
|
52
55
|
export function loadAuth() { try { return JSON.parse(fs.readFileSync(AUTH, 'utf8')) } catch { return null } }
|
|
56
|
+
export function markAuthReconnectRequired(errorCode = null) {
|
|
57
|
+
const current = loadAuth()
|
|
58
|
+
if (!current?.refresh_token) return false
|
|
59
|
+
writeAuthPayload({
|
|
60
|
+
...current,
|
|
61
|
+
reconnect_required: true,
|
|
62
|
+
reconnect_error_code: errorCode || null,
|
|
63
|
+
reconnect_required_at: Date.now(),
|
|
64
|
+
})
|
|
65
|
+
return true
|
|
66
|
+
}
|
|
53
67
|
export function clearAuth() { try { fs.unlinkSync(AUTH) } catch { /* noop */ } }
|
|
54
68
|
|
|
55
69
|
export function loadDirs() { try { return JSON.parse(fs.readFileSync(DIRS, 'utf8')) } catch { return {} } }
|
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)
|
package/claude-session.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 = () => {
|
package/cross-terminal.mjs
CHANGED
|
@@ -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 )
|
|
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/launcher.mjs
CHANGED
|
@@ -75,8 +75,10 @@ export function detectState() {
|
|
|
75
75
|
const hermesInstalled = onPath('hermes')
|
|
76
76
|
const hermesReady = onPath('thinkpool') && probeHermesRuntime().available
|
|
77
77
|
const accountSvc = serviceLoaded(null)
|
|
78
|
+
const reconnectRequired = !!auth?.reconnect_required
|
|
78
79
|
return {
|
|
79
|
-
loggedIn: !!auth?.refresh_token,
|
|
80
|
+
loggedIn: !!auth?.refresh_token && !reconnectRequired,
|
|
81
|
+
reconnectRequired,
|
|
80
82
|
email: auth?.email || auth?.user?.email || null,
|
|
81
83
|
provider: isCustom ? `Custom (${provider.baseUrl || 'endpoint'})` : 'Anthropic (default)',
|
|
82
84
|
providerModel: isCustom ? (provider.model || null) : null,
|
|
@@ -106,6 +108,7 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
106
108
|
...next,
|
|
107
109
|
agents: Array.isArray(next?.agents) ? next.agents : [],
|
|
108
110
|
loggedIn: !!next?.loggedIn,
|
|
111
|
+
reconnectRequired: !!next?.reconnectRequired,
|
|
109
112
|
accountSvc: !!next?.accountSvc,
|
|
110
113
|
serviceVersion: typeof next?.serviceVersion === 'string' && next.serviceVersion ? next.serviceVersion : null,
|
|
111
114
|
platform: next?.platform || process.platform,
|
|
@@ -130,7 +133,7 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
130
133
|
io.print('')
|
|
131
134
|
io.print(' ' + C.dim('┌ ') + C.bold('thinkpool-pair') + C.dim(' ' + '─'.repeat(40)))
|
|
132
135
|
io.print(` ${C.dim('│ launcher ')} ${state.version ? `v${state.version}` : 'version unavailable'}`)
|
|
133
|
-
io.print(` ${C.dim('│ account ')} ${state.loggedIn ? C.green((state.email || 'linked') + ' ✓') : C.yellow('not linked')}`)
|
|
136
|
+
io.print(` ${C.dim('│ account ')} ${state.reconnectRequired ? C.yellow('reconnect required') : state.loggedIn ? C.green((state.email || 'linked') + ' ✓') : C.yellow('not linked')}`)
|
|
134
137
|
io.print(` ${C.dim('│ agents ')} ${state.agents.length ? state.agents.map(a => a.label).join(', ') : C.yellow('none ready')}`)
|
|
135
138
|
io.print(` ${C.dim('│ directory')} ${C.dim(state.cwd)}`)
|
|
136
139
|
const bridgeStatus = !state.accountSvc
|
|
@@ -165,7 +168,9 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
165
168
|
// logged in — offers to link on the spot, re-reads disk, and refuses to serve if still not.
|
|
166
169
|
const ensureLoggedIn = async () => {
|
|
167
170
|
if (state.loggedIn) return true
|
|
168
|
-
io.print('\n ' + C.yellow(
|
|
171
|
+
io.print('\n ' + C.yellow(state.reconnectRequired
|
|
172
|
+
? 'This bridge lost its Thinkpool account link.'
|
|
173
|
+
: 'You are not linked to a ThinkPool account yet.'))
|
|
169
174
|
if (await askYesNo(' Link this device now?', true)) { await actions.login(); resync() }
|
|
170
175
|
if (!state.loggedIn) { io.print(' ' + C.yellow('login needed to serve your sessions — back to menu')); return false }
|
|
171
176
|
return true
|
|
@@ -230,7 +235,7 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
230
235
|
for (;;) {
|
|
231
236
|
const options = [
|
|
232
237
|
{ key: 'provider', label: 'Provider', hint: `current: ${state.provider}` },
|
|
233
|
-
{ key: 'account', label: 'Account', hint: state.loggedIn ? `linked: ${state.email || 'yes'}` : 'not linked' },
|
|
238
|
+
{ key: 'account', label: 'Account', hint: state.reconnectRequired ? 'reconnect required' : state.loggedIn ? `linked: ${state.email || 'yes'}` : 'not linked' },
|
|
234
239
|
]
|
|
235
240
|
if (state.hermesInstalled) options.push({ key: 'hermes', label: state.hermesReady ? 'Hermes profile' : 'Set up Hermes', hint: state.hermesReady ? 'isolated profile ready' : 'set up isolated profile + delegation guard' })
|
|
236
241
|
options.push({ key: 'back', label: 'Back to main menu', hint: '' })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
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
|
+
}
|