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 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) => { saveAuth(s); pushPresence() },
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: { 'Content-Type': 'application/json' },
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) => { if (m && m.t === 'update-available') surfaceUpdate(m.version) })
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
@@ -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: 'true' }
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 (m.suggestion) emit({ kind: 'suggestion', text: m.suggestion })
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.54",
3
+ "version": "0.7.56",
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": {