thinkpool-pair 0.7.54 → 0.7.56
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 +12 -1
- package/bridge.mjs +21 -2
- package/claude-session.mjs +58 -12
- package/package.json +1 -1
package/account.mjs
CHANGED
|
@@ -186,6 +186,9 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
186
186
|
}
|
|
187
187
|
const { sb, session } = auth
|
|
188
188
|
const email = session.user?.email || 'your account'
|
|
189
|
+
// Current owner JWT, handed to each room-bridge child for authed web writes
|
|
190
|
+
// (code-mockup, whip L16). Refreshed below; pushed to live children on rotation.
|
|
191
|
+
let currentAccessToken = session.access_token || null
|
|
189
192
|
|
|
190
193
|
// Owner-gate (Contract C-CODE-2): hosting a code session — spawning the agent and
|
|
191
194
|
// burning compute on this machine — requires Pro or Enterprise. This is the layer
|
|
@@ -253,7 +256,13 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
253
256
|
const keepFresh = scheduleTokenRefresh({
|
|
254
257
|
sb,
|
|
255
258
|
session,
|
|
256
|
-
onRefreshed: (s) => {
|
|
259
|
+
onRefreshed: (s) => {
|
|
260
|
+
saveAuth(s); pushPresence()
|
|
261
|
+
// Push the rotated owner token to every live child so their authed writes
|
|
262
|
+
// (code-mockup) never go stale on a long session (whip L16).
|
|
263
|
+
currentAccessToken = s.access_token || currentAccessToken
|
|
264
|
+
for (const c of children.values()) { try { c.send({ t: 'token', accessToken: currentAccessToken }) } catch { /* child not ready */ } }
|
|
265
|
+
},
|
|
257
266
|
forceMs: parseInt(process.env.TP_TOKEN_REFRESH_FORCE_MS, 10) || 0,
|
|
258
267
|
})
|
|
259
268
|
|
|
@@ -287,6 +296,8 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
287
296
|
// can't make a child self-exit; flag it as an account child instead.
|
|
288
297
|
const env = { ...process.env, THINKPOOL_PAIR_ACCOUNT_CHILD: '1' }
|
|
289
298
|
delete env.THINKPOOL_PAIR_AUTOUPDATE
|
|
299
|
+
// Hand the child the owner JWT for authed web writes (code-mockup, L16).
|
|
300
|
+
if (currentAccessToken) env.TP_ACCESS_TOKEN = currentAccessToken
|
|
290
301
|
const child = spawn(process.execPath, [BRIDGE, room, '--headless', '--auto=claude'], { cwd: dir, stdio: ['inherit', 'inherit', 'inherit', 'ipc'], env })
|
|
291
302
|
children.set(room, child)
|
|
292
303
|
childIdle.set(room, false) // unknown until it reports → never restart a fresh child
|
package/bridge.mjs
CHANGED
|
@@ -47,6 +47,14 @@ const SUPABASE_URL = process.env.TP_SUPABASE_URL || 'https://daytvtakmlixpfbbq
|
|
|
47
47
|
const WEB_BASE = process.env.TP_WEB_BASE || 'https://thinkpool.io'
|
|
48
48
|
const SUPABASE_ANON = process.env.TP_SUPABASE_ANON || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRheXR2dGFrbWxpeHBmYmJxempkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzgwNTMxNzEsImV4cCI6MjA5MzYyOTE3MX0.zYmz8bHUNEY4STUr2JuXChAsKwxNwkwFHe1Hd0Betqo'
|
|
49
49
|
|
|
50
|
+
// Owner access token for AUTHED web writes (currently /api/code-mockup, whip L16).
|
|
51
|
+
// The room-bridge is anon by design; the account supervisor (account.mjs) owns the
|
|
52
|
+
// session, so it hands a fresh owner token in at spawn (TP_ACCESS_TOKEN) and pushes
|
|
53
|
+
// a rotated one over IPC ({ t:'token' }) on each refresh. Stays null for a
|
|
54
|
+
// directly-started bridge with no account login — those writes go unattributed,
|
|
55
|
+
// never owner-spoofed (the server treats a missing/!owner token accordingly).
|
|
56
|
+
let codeAuthToken = process.env.TP_ACCESS_TOKEN || null
|
|
57
|
+
|
|
50
58
|
// Per-terminal rolling scrollback (raw chars). Replayed to joining clients.
|
|
51
59
|
// Kept comfortably under realtime payload limits once base64'd.
|
|
52
60
|
const SCROLLBACK_MAX = 120_000
|
|
@@ -518,9 +526,14 @@ const handleManifest = async (box, file, term) => {
|
|
|
518
526
|
const readTxt = (p) => { try { return p && fs.readFileSync(p, 'utf8') } catch { return null } }
|
|
519
527
|
const cid = randomUUID()
|
|
520
528
|
try {
|
|
529
|
+
const mockupHeaders = { 'Content-Type': 'application/json' }
|
|
530
|
+
// Authenticate as the owner so the server can attribute the persisted row to a
|
|
531
|
+
// verified user instead of trusting "knows the code" (whip L16). Omitted when
|
|
532
|
+
// we have no token (direct bridge, no login) → server persists it unattributed.
|
|
533
|
+
if (codeAuthToken) mockupHeaders.Authorization = `Bearer ${codeAuthToken}`
|
|
521
534
|
const res = await fetch(`${WEB_BASE}/api/code-mockup`, {
|
|
522
535
|
method: 'POST',
|
|
523
|
-
headers:
|
|
536
|
+
headers: mockupHeaders,
|
|
524
537
|
body: JSON.stringify({
|
|
525
538
|
code: room, slug: m.slug, title: m.title, cid, ts: m.ts, term,
|
|
526
539
|
desktopPng: readB64(m.desktop), mobilePng: readB64(m.mobile), html: readTxt(m.html),
|
|
@@ -1206,7 +1219,13 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
|
|
|
1206
1219
|
// Contract #1) + `idle` (the quiet window, for the unattended fallback) + whether a
|
|
1207
1220
|
// web peer is present, so the supervisor restarts on an explicit click between turns
|
|
1208
1221
|
// and otherwise waits for true idle with nobody watching (Slice 3).
|
|
1209
|
-
process.on('message', (m) => {
|
|
1222
|
+
process.on('message', (m) => {
|
|
1223
|
+
if (!m) return
|
|
1224
|
+
if (m.t === 'update-available') surfaceUpdate(m.version)
|
|
1225
|
+
// Supervisor rotated the owner JWT (account.mjs scheduleTokenRefresh) — adopt
|
|
1226
|
+
// it so our authed writes (code-mockup) never go stale on a long session (L16).
|
|
1227
|
+
else if (m.t === 'token') codeAuthToken = m.accessToken || null
|
|
1228
|
+
})
|
|
1210
1229
|
setInterval(() => { try { process.send({ t: 'idle', idle: idleNow(), between: betweenTurns(), webPeer: webPeerPresent() }) } catch { /* parent gone */ } }, 5000).unref()
|
|
1211
1230
|
} else if (VERSION) {
|
|
1212
1231
|
// Foreground run (not the supervised service): it can't self-restart, but
|
package/claude-session.mjs
CHANGED
|
@@ -177,6 +177,16 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
177
177
|
// the catch self-heals.
|
|
178
178
|
const FORCE_STOP_MS = Math.max(STALL_MS * 3, parseInt(process.env.TP_FORCE_STOP_MS, 10) || 300000)
|
|
179
179
|
let forceStopped = false
|
|
180
|
+
// ── Haiku suggestion fallback ──
|
|
181
|
+
// Claude Code's own `prompt_suggestion` rarely fires in /code — the binary suppresses it
|
|
182
|
+
// per-turn (cache_cold when a turn's tokens > 10k, rate_limit when throttled, first turn).
|
|
183
|
+
// When it doesn't arrive shortly after `result`, we generate one ourselves with a cheap
|
|
184
|
+
// one-shot Haiku call on the SAME subscription auth (no API key), fed ONLY the last
|
|
185
|
+
// assistant reply (suggestions are short: "proceed" / "go with A") — fast + ~free.
|
|
186
|
+
let sawSuggestion = false // did Claude's own prompt_suggestion fire this turn?
|
|
187
|
+
let lastAssistantText = '' // most recent assistant prose — the only context the fallback needs
|
|
188
|
+
let sugTimer = null // pending fallback timer
|
|
189
|
+
const SUGGEST_FALLBACK_MS = Math.max(1200, parseInt(process.env.TP_SUGGEST_FALLBACK_MS, 10) || 2500)
|
|
180
190
|
|
|
181
191
|
const emit = (evt) => { lastEvtTs = Date.now(); if (evt && evt.kind !== 'stalled') stalledSent = false; try { onEvent?.(evt) } catch { /* never let a consumer throw into the loop */ } }
|
|
182
192
|
const stallTimer = setInterval(() => {
|
|
@@ -362,16 +372,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
362
372
|
// Anthropic flips on/off during rollout — so the ghost-text chip silently died
|
|
363
373
|
// mid-day with NO change on our side. CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION is the
|
|
364
374
|
// documented override. opts.env REPLACES the child env, so spread the inherited one.
|
|
365
|
-
opts.env = { ...(env || process.env), CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION: '
|
|
366
|
-
// …but the env override only flips the FEATURE GATE. The SDK forwards
|
|
367
|
-
// `promptSuggestions` solely into the control-init payload — it NEVER adds the
|
|
368
|
-
// `--prompt-suggestions` CLI flag the binary actually requires to generate a
|
|
369
|
-
// suggestion per turn (verified live: the spawned `claude` argv never carried it,
|
|
370
|
-
// and only `rate_limit_event` ever reached the bridge — no prompt_suggestion). Force
|
|
371
|
-
// it via extraArgs, which the SDK appends to argv as `--prompt-suggestions true`.
|
|
372
|
-
// Safe in SDK stream-json mode: the sibling `--include-partial-messages` flag carries
|
|
373
|
-
// the identical "requires --print/stream-json" rule and already runs here. (2026-06-22)
|
|
374
|
-
opts.extraArgs = { ...(opts.extraArgs || {}), 'prompt-suggestions': 'true' }
|
|
375
|
+
opts.env = { ...(env || process.env), CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION: '1' }
|
|
375
376
|
|
|
376
377
|
// Before the SDK replays this transcript, heal any cross-provider tool blocks
|
|
377
378
|
// it left behind (e.g. a room that ran on Z.ai GLM, now back on anthropic).
|
|
@@ -383,6 +384,44 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
383
384
|
if (healed.blocks) process.stderr.write(`\n ◆ healed ${healed.blocks} cross-provider tool block(s) in the transcript before resume.\n`)
|
|
384
385
|
}
|
|
385
386
|
|
|
387
|
+
// One-shot Haiku fallback suggestion. Runs on the SAME subscription auth as the room
|
|
388
|
+
// (opts.env carries the OAuth — no API key needed), but as a BARE model call: no
|
|
389
|
+
// settingSources, no MCP, no tools — so it's a fast cold call, not a full session boot.
|
|
390
|
+
// Fed only `lastAssistantText` (trimmed). Never blocks a turn; any failure is silent.
|
|
391
|
+
const haikuSuggest = async () => {
|
|
392
|
+
const seed = (lastAssistantText || '').trim().slice(-1500)
|
|
393
|
+
if (!seed || closed) return
|
|
394
|
+
const ac2 = new AbortController()
|
|
395
|
+
const t = setTimeout(() => { try { ac2.abort() } catch { /* noop */ } }, 8000)
|
|
396
|
+
try {
|
|
397
|
+
const hq = query({
|
|
398
|
+
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:`,
|
|
399
|
+
options: {
|
|
400
|
+
model: 'claude-haiku-4-5',
|
|
401
|
+
...(cwd ? { cwd } : {}),
|
|
402
|
+
env: opts.env,
|
|
403
|
+
maxTurns: 1,
|
|
404
|
+
permissionMode: 'bypassPermissions',
|
|
405
|
+
settingSources: [], // no CLAUDE.md / commands / agents — raw call
|
|
406
|
+
strictMcpConfig: true,
|
|
407
|
+
mcpServers: {}, // no MCP servers — fast cold start
|
|
408
|
+
abortController: ac2,
|
|
409
|
+
},
|
|
410
|
+
})
|
|
411
|
+
let out = ''
|
|
412
|
+
for await (const mm of hq) {
|
|
413
|
+
if (mm.type === 'assistant') for (const b of (mm.message?.content || [])) if (b.type === 'text') out += b.text
|
|
414
|
+
if (mm.type === 'result') break
|
|
415
|
+
}
|
|
416
|
+
out = out.trim().split('\n')[0].replace(/^["'`]+|["'`]+$/g, '').trim().slice(0, 140)
|
|
417
|
+
if (out && !sawSuggestion && !closed && !/^(sure|of course|certainly|let me know|i can help|happy to)\b/i.test(out)) {
|
|
418
|
+
emit({ kind: 'suggestion', text: out, source: 'haiku' })
|
|
419
|
+
process.stderr.write(` ◆ [suggestion] haiku fallback: ${JSON.stringify(out)}\n`)
|
|
420
|
+
}
|
|
421
|
+
} catch { /* fallback failed (rate limit / abort / model error) — silent */ }
|
|
422
|
+
finally { clearTimeout(t) }
|
|
423
|
+
}
|
|
424
|
+
|
|
386
425
|
const runQuery = async () => {
|
|
387
426
|
try {
|
|
388
427
|
q = query({ prompt: input.stream, options: { ...opts, ...(sessionId ? { resume: sessionId } : {}) } })
|
|
@@ -430,6 +469,8 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
430
469
|
// parentToolUseId: non-null when this assistant message comes from a
|
|
431
470
|
// sub-agent (Task tool) — the universal nesting spine. Thread it so the
|
|
432
471
|
// room can group sub-agent activity under its parent Task card.
|
|
472
|
+
// Capture the latest assistant prose — the only context the Haiku fallback needs.
|
|
473
|
+
{ const txt = (m.message?.content || []).filter((b) => b?.type === 'text').map((b) => b.text).join('\n').trim(); if (txt) lastAssistantText = txt }
|
|
433
474
|
emit({ kind: 'assistant', blocks: simplifyBlocks(m.message?.content), parentToolUseId: m.parent_tool_use_id || null })
|
|
434
475
|
break
|
|
435
476
|
case 'user':
|
|
@@ -465,6 +506,10 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
465
506
|
restartCount = 0 // a successful turn refills the auto-restart budget
|
|
466
507
|
forceStopped = false
|
|
467
508
|
emit({ kind: 'result', subtype: m.subtype, sessionId, costUsd: m.total_cost_usd, usage: m.usage, numTurns: m.num_turns, durationMs: m.duration_ms ?? null, denials: Array.isArray(m.permission_denials) ? m.permission_denials.length : 0 })
|
|
509
|
+
// Arm the Haiku fallback: if Claude's own prompt_suggestion doesn't arrive within
|
|
510
|
+
// the window, generate one from the last assistant reply. (Skip aborted turns.)
|
|
511
|
+
if (sugTimer) clearTimeout(sugTimer)
|
|
512
|
+
if (m.subtype !== 'aborted') sugTimer = setTimeout(() => { sugTimer = null; if (!sawSuggestion && !closed) haikuSuggest() }, SUGGEST_FALLBACK_MS)
|
|
468
513
|
// Surface a usage/context meter (chrome, not a transcript line). The
|
|
469
514
|
// context window % comes from the control request; cost is cumulative.
|
|
470
515
|
;(async () => {
|
|
@@ -481,7 +526,8 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
481
526
|
// Claude Code's own predicted next prompt (promptSuggestions opt-in).
|
|
482
527
|
// Arrives once per turn AFTER `result`; surfaced verbatim as ephemeral
|
|
483
528
|
// chrome (a ghost-text chip in the room composer), never persisted.
|
|
484
|
-
if (
|
|
529
|
+
if (sugTimer) { clearTimeout(sugTimer); sugTimer = null } // built-in won — cancel the Haiku fallback
|
|
530
|
+
if (m.suggestion) { sawSuggestion = true; emit({ kind: 'suggestion', text: m.suggestion }) }
|
|
485
531
|
break
|
|
486
532
|
default:
|
|
487
533
|
break
|
|
@@ -513,7 +559,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
513
559
|
runQuery()
|
|
514
560
|
|
|
515
561
|
return {
|
|
516
|
-
sendTurn(text) { if (!closed) { turnActive = true; lastEvtTs = Date.now(); stalledSent = false; input.push(String(text)) } },
|
|
562
|
+
sendTurn(text) { if (!closed) { turnActive = true; sawSuggestion = false; if (sugTimer) { clearTimeout(sugTimer); sugTimer = null } lastEvtTs = Date.now(); stalledSent = false; input.push(String(text)) } },
|
|
517
563
|
// Set the permission mode — Claude Code's ⇧⇥ cycle. setPermissionMode is a
|
|
518
564
|
// streaming control request (drives plan-mode behaviour SDK-side); the local
|
|
519
565
|
// `mode` drives our PreToolUse auto-approve policy. Echo so the room syncs.
|