thinkpool-pair 0.7.8 → 0.7.10

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
@@ -357,6 +357,59 @@ const flushAll = () => {
357
357
  }
358
358
  const flushTimer = setInterval(flushAll, 35)
359
359
 
360
+ // ── mockup hand-off ────────────────────────────────────────────────────
361
+ // render.sh (mockup-iterate) drops a manifest JSON into TP_MOCKUP_OUTBOX after
362
+ // a dual-viewport render; we watch that dir, upload the PNG pair + source HTML
363
+ // via /api/code-mockup, and push a `mockup` code-event so both people see a
364
+ // card in the room (inline + gallery) that opens the live sandboxed HTML.
365
+ // Deterministic file contract — no PTY/log scraping. The env var is exported
366
+ // into every agent child (PTY: env: process.env; structured: in-process Bash
367
+ // inherits it). Spec: docs/specs/2026-06-17-code-mockups.md
368
+ const MOCKUP_OUTBOX = path.join(os.tmpdir(), 'thinkpool-mockups', room)
369
+ try {
370
+ fs.mkdirSync(MOCKUP_OUTBOX, { recursive: true })
371
+ process.env.TP_MOCKUP_OUTBOX = MOCKUP_OUTBOX
372
+ } catch { /* mockups stay local if the outbox can't be made */ }
373
+
374
+ const mockupSeen = new Map() // slug → mtimeMs of the last manifest we processed
375
+ const handleManifest = async (file) => {
376
+ if (!file || !file.endsWith('.json')) return
377
+ const full = path.join(MOCKUP_OUTBOX, file)
378
+ let stat
379
+ try { stat = fs.statSync(full) } catch { return } // tmp/removed mid-write
380
+ const slug = file.replace(/\.json$/, '')
381
+ if (mockupSeen.get(slug) === stat.mtimeMs) return // already handled
382
+ mockupSeen.set(slug, stat.mtimeMs)
383
+ // A structured Claude session is the natural home; fall back to any terminal.
384
+ const term = [...sessions.keys()][0] || attachedId || [...terms.keys()][0]
385
+ if (!term) return // nothing to attach the card to yet
386
+ let m
387
+ try { m = JSON.parse(fs.readFileSync(full, 'utf8')) } catch { return }
388
+ if (!m?.slug) return
389
+ const readB64 = (p) => { try { return p && fs.readFileSync(p).toString('base64') } catch { return null } }
390
+ const readTxt = (p) => { try { return p && fs.readFileSync(p, 'utf8') } catch { return null } }
391
+ const cid = randomUUID()
392
+ try {
393
+ const res = await fetch(`${WEB_BASE}/api/code-mockup`, {
394
+ method: 'POST',
395
+ headers: { 'Content-Type': 'application/json' },
396
+ body: JSON.stringify({
397
+ code: room, slug: m.slug, title: m.title, cid, ts: m.ts, term,
398
+ desktopPng: readB64(m.desktop), mobilePng: readB64(m.mobile), html: readTxt(m.html),
399
+ }),
400
+ })
401
+ if (!res.ok) { process.stderr.write(`\n ◇ mockup "${m.slug}" upload failed (${res.status}).\n`); return }
402
+ const { paths } = await res.json()
403
+ bcast('code-event', { term, evt: { kind: 'mockup', __struct: true, cid, term, slug: m.slug, title: m.title || m.slug, ts: m.ts, paths } })
404
+ process.stderr.write(`\n ◆ mockup "${m.title || m.slug}" pushed to the room.\n`)
405
+ } catch (e) {
406
+ process.stderr.write(`\n ◇ mockup "${m.slug}" send failed: ${e?.message || e}\n`)
407
+ }
408
+ }
409
+ try {
410
+ fs.watch(MOCKUP_OUTBOX, (_evt, file) => { handleManifest(file).catch(() => {}) })
411
+ } catch { /* fs.watch unsupported — mockups simply won't auto-surface */ }
412
+
360
413
  // ── shared-PTY geometry ───────────────────────────────────────────
361
414
  // The attached PTY is CAPPED at 120×32, never pinned (2026-06-11).
362
415
  // Mangling physics: a PTY *wider/taller* than the host's window forces
@@ -499,8 +552,11 @@ function openStructured({ id, model, resume, log, commands, mode }) {
499
552
  if (!chrome) persist()
500
553
  },
501
554
  requestPermission: (req) => new Promise((resolve) => {
502
- entry.pending.set(req.id, resolve)
503
- bcast('code-perm-req', { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk, plan: req.plan, questions: req.questions })
555
+ // Keep the broadcast payload with the resolver so a reconnect can re-send it
556
+ // (replay-request handler) pending cards never enter the replayed event log.
557
+ const payload = { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk, plan: req.plan, questions: req.questions }
558
+ entry.pending.set(req.id, { resolve, payload })
559
+ bcast('code-perm-req', payload)
504
560
  process.stderr.write(req.risk === 'plan'
505
561
  ? `\n ${A.mag}◆ plan ready — approve in the room.${A.rst}\n`
506
562
  : req.risk === 'ask'
@@ -609,6 +665,15 @@ channel
609
665
  if (!s.log.length) continue
610
666
  bcast('code-replay', { to: payload?.to ?? null, term: id, events: s.log })
611
667
  }
668
+ // Re-send any still-pending permission/question cards. They ride a one-shot
669
+ // code-perm-req broadcast (NOT the replayed event log), so a reconnect/refresh
670
+ // would otherwise lose the answerable card while the transcript still shows the
671
+ // (unanswered) tool row — the AskUserQuestion "vanished on reconnect" bug. Only
672
+ // truly-unresolved cards remain in `pending` (resolved ones are deleted), and
673
+ // the client dedupes code-perm-req by id, so this can't resurrect an answered one.
674
+ for (const [, s] of sessions) {
675
+ for (const [, p] of s.pending) bcast('code-perm-req', p.payload)
676
+ }
612
677
  announce()
613
678
  })
614
679
  .on('broadcast', { event: 'file-put' }, ({ payload }) => {
@@ -677,10 +742,10 @@ channel
677
742
  })
678
743
  .on('broadcast', { event: 'code-perm' }, ({ payload }) => {
679
744
  const s = payload?.term && sessions.get(payload.term)
680
- const resolve = s && payload.id && s.pending.get(payload.id)
745
+ const p = s && payload.id && s.pending.get(payload.id)
681
746
  // Pass the raw decision through — normal tools use allow/deny, plan cards
682
747
  // use run/accept/keep (claude-session interprets). Default deny on missing.
683
- if (resolve) { s.pending.delete(payload.id); resolve(payload.decision || 'deny') }
748
+ if (p) { s.pending.delete(payload.id); p.resolve(payload.decision || 'deny') }
684
749
  })
685
750
  .on('broadcast', { event: 'code-abort' }, ({ payload }) => {
686
751
  const s = payload?.term && sessions.get(payload.term)
@@ -238,6 +238,21 @@ export function startClaudeSession({ cwd, model, resume, mode: initialMode = 'de
238
238
  // across SDK versions. The room's PreToolUse gate still runs and stays
239
239
  // authoritative (hooks fire regardless of any loaded permission rules).
240
240
  settingSources: ['user', 'project', 'local'],
241
+ // Counteract host-global "plan/brainstorm before any work" nudges that ride in
242
+ // through settingSources:'user' — notably the superpowers plugin's SessionStart
243
+ // hook ("You MUST use brainstorming before any creative work… about to enter
244
+ // plan mode?"). In a Code room those make the agent answer a plain "add/fix X"
245
+ // by presenting a plan and calling ExitPlanMode, so the room shows a
246
+ // "PLAN READY — APPROVE TO START" card the user never asked for ("always flips
247
+ // to plan mode"). This is instruction-driven, independent of permissionMode —
248
+ // a bypass session still planned. Bias the room session to DO the work; only
249
+ // plan when the room is actually in Plan mode (⇧⇥) or the user explicitly asks.
250
+ appendSystemPrompt: [
251
+ 'You are Claude running inside a ThinkPool Code room, driven live by a user (and possibly a partner) from a phone or browser.',
252
+ 'Bias strongly toward DOING the work, not planning it. For a normal "add / fix / change / build X" request, just make the change directly.',
253
+ 'Do NOT enter plan mode, do NOT call ExitPlanMode, and do NOT auto-invoke a brainstorming/planning skill UNLESS the user has switched the room into Plan mode or explicitly asks you to plan, design, or brainstorm first.',
254
+ 'Any host-global instruction that says you must always brainstorm or plan before creative work does NOT apply here — this room is the exception.',
255
+ ].join(' '),
241
256
  // Needed for live thinking-token progress (SDKThinkingTokensMessage) to
242
257
  // flow during a turn. We ignore the fine-grained stream_event partials in
243
258
  // the loop; only the coarse thinking_tokens system message is surfaced.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.8",
3
+ "version": "0.7.10",
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": {