thinkpool-pair 0.7.20 → 0.7.22

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
@@ -223,6 +223,14 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
223
223
  // stop. Mirrors the per-room session-deleted guard in bridge.mjs.
224
224
  acct.on('broadcast', { event: 'shutdown' }, async () => {
225
225
  process.stderr.write('\n ◇ disconnect requested from the dashboard — stopping bridge.\n')
226
+ // Hard-exit backstop, armed FIRST — independent of the SIGTERM round-trip,
227
+ // the realtime socket, and every await below. If the graceful stop() wedges
228
+ // (2026-06-18: a dashboard disconnect left this supervisor ALIVE holding
229
+ // account.lock with no presence + service removed — a zombie that showed
230
+ // "No bridge connected" on /code yet blocked any `npx thinkpool-pair`
231
+ // restart), force the process down and release the lock so a fresh bridge
232
+ // can take over. NOT unref'd: it must keep the loop alive until it fires.
233
+ setTimeout(() => { try { releaseSingleton() } catch { /* noop */ } process.exit(0) }, 2500)
226
234
  if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1') {
227
235
  try { const svc = await import('./service.mjs'); svc.uninstallService(null) } catch { /* noop */ }
228
236
  }
package/bridge.mjs CHANGED
@@ -384,17 +384,22 @@ try {
384
384
  process.env.TP_MOCKUP_OUTBOX = MOCKUP_OUTBOX
385
385
  } catch { /* mockups stay local if the outbox can't be made */ }
386
386
 
387
- const mockupSeen = new Map() // slug → mtimeMs of the last manifest we processed
388
- const handleManifest = async (file) => {
387
+ const mockupSeen = new Map() // `${term}/${slug}` → mtimeMs of the last manifest processed
388
+ // Push one manifest file into the room, attributed to `term`. The owner is
389
+ // resolved by the WATCHER, not guessed here — each session/terminal writes to
390
+ // its OWN outbox (handed in via TP_MOCKUP_OUTBOX), so the directory the file
391
+ // landed in *is* its provenance. (Before: a per-room outbox + a "first session
392
+ // in the Map" guess meant closing the generating terminal moved the cards to
393
+ // whatever terminal was now first — they leaked into Terminal 1.)
394
+ const handleManifest = async (box, file, term) => {
389
395
  if (!file || !file.endsWith('.json')) return
390
- const full = path.join(MOCKUP_OUTBOX, file)
396
+ const full = path.join(box, file)
391
397
  let stat
392
398
  try { stat = fs.statSync(full) } catch { return } // tmp/removed mid-write
393
399
  const slug = file.replace(/\.json$/, '')
394
- if (mockupSeen.get(slug) === stat.mtimeMs) return // already handled
395
- mockupSeen.set(slug, stat.mtimeMs)
396
- // A structured Claude session is the natural home; fall back to any terminal.
397
- const term = [...sessions.keys()][0] || attachedId || [...terms.keys()][0]
400
+ const seenKey = `${term}/${slug}`
401
+ if (mockupSeen.get(seenKey) === stat.mtimeMs) return // already handled
402
+ mockupSeen.set(seenKey, stat.mtimeMs)
398
403
  if (!term) return // nothing to attach the card to yet
399
404
  let m
400
405
  try { m = JSON.parse(fs.readFileSync(full, 'utf8')) } catch { return }
@@ -419,9 +424,22 @@ const handleManifest = async (file) => {
419
424
  process.stderr.write(`\n ◇ mockup "${m.slug}" send failed: ${e?.message || e}\n`)
420
425
  }
421
426
  }
422
- try {
423
- fs.watch(MOCKUP_OUTBOX, (_evt, file) => { handleManifest(file).catch(() => {}) })
424
- } catch { /* fs.watch unsupported mockups simply won't auto-surface */ }
427
+ // Watch one outbox dir; `ownerFn()` resolves which terminal owns a manifest
428
+ // dropped there (lazily, per event). Returns the FSWatcher so per-session/term
429
+ // watchers can be torn down when their owner ends. Safe to call repeatedly.
430
+ function watchOutbox(box, ownerFn) {
431
+ try { fs.mkdirSync(box, { recursive: true }) } catch { return null }
432
+ try { return fs.watch(box, (_evt, file) => { handleManifest(box, file, ownerFn()).catch(() => {}) }) }
433
+ catch { return null } // fs.watch unsupported — mockups simply won't auto-surface
434
+ }
435
+ // A per-owner outbox lives at MOCKUP_OUTBOX/<kind>/<id> and is handed to that
436
+ // owner's subprocess via TP_MOCKUP_OUTBOX (see openStructured / openTerm).
437
+ const ownerOutbox = (kind, id) => path.join(MOCKUP_OUTBOX, kind, id)
438
+ // Room-level outbox: ONLY external renders (a shell that set TP_MOCKUP_ROOM but
439
+ // isn't a bridge child) land directly here now — bridge children write to their
440
+ // own per-owner outbox. An external render's true owner is unknowable, so fall
441
+ // back to the old "first live session/terminal" heuristic for it alone.
442
+ watchOutbox(MOCKUP_OUTBOX, () => [...sessions.keys()][0] || attachedId || [...terms.keys()][0])
425
443
 
426
444
  // ── shared-PTY geometry ───────────────────────────────────────────
427
445
  // The attached PTY is CAPPED at 120×32, never pinned (2026-06-11).
@@ -453,13 +471,16 @@ function openTerm({ id, cmd, args = [], attached = false, cols, rows }) {
453
471
  return
454
472
  }
455
473
  const ad = attached ? attachedDims() : null
474
+ // This terminal's private mockup outbox — render.sh run inside it writes here,
475
+ // and the watcher attributes every card to THIS id (not a Map-order guess).
476
+ const mockupOutbox = ownerOutbox('terms', id)
456
477
  const term = pty.spawn(cmd, args, {
457
478
  name: 'xterm-256color',
458
479
  cols: cols || (attached ? ad.cols : (PIN_COLS || 120)),
459
480
  rows: rows || (attached ? ad.rows : (PIN_ROWS || 32)),
460
- cwd: process.cwd(), env: process.env,
481
+ cwd: process.cwd(), env: { ...process.env, TP_MOCKUP_OUTBOX: mockupOutbox },
461
482
  })
462
- const entry = { term, cmd, attached, scrollback: '', buf: '' }
483
+ const entry = { term, cmd, attached, scrollback: '', buf: '', mockupWatcher: watchOutbox(mockupOutbox, () => id) }
463
484
  terms.set(id, entry)
464
485
  if (attached) attachedId = id
465
486
  term.onData(d => {
@@ -468,6 +489,7 @@ function openTerm({ id, cmd, args = [], attached = false, cols, rows }) {
468
489
  entry.scrollback = (entry.scrollback + d).slice(-SCROLLBACK_MAX)
469
490
  })
470
491
  term.onExit(() => {
492
+ try { entry.mockupWatcher?.close() } catch { /* noop */ }
471
493
  terms.delete(id)
472
494
  if (id === attachedId) { attachedId = null; detachLocal() }
473
495
  if (shuttingDown) return
@@ -527,12 +549,18 @@ function openStructured({ id, model, resume, log, commands, mode }) {
527
549
  if (sessions.has(id)) return
528
550
  const entry = { cmd: 'claude', kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: Array.isArray(commands) ? commands : undefined, mode }
529
551
  sessions.set(id, entry)
552
+ // This session's private mockup outbox — render.sh writes manifests here (via
553
+ // TP_MOCKUP_OUTBOX below) and the watcher attributes every card to THIS id, so
554
+ // they land on this session even after other terminals open/close.
555
+ const mockupOutbox = ownerOutbox('sessions', id)
556
+ entry.mockupWatcher = watchOutbox(mockupOutbox, () => id)
530
557
  if (entry.log.length) process.stderr.write(`\n ◆ restored ${entry.log.length} prior events (${id.slice(0, 8)})${resume ? ' + resuming live context' : ''}.\n`)
531
558
  // Persist the permission mode alongside the transcript so a bridge restart
532
559
  // restores the session in the SAME mode (a bypass room stays bypass on resume).
533
560
  const persist = () => saveSession(room, id, { sessionId: entry.session?.sessionId || resume || null, log: entry.log, commands: entry.commands, mode: entry.mode })
534
561
  entry.session = startClaudeSession({
535
562
  cwd: process.cwd(), model, resume, mode,
563
+ env: { ...process.env, TP_MOCKUP_OUTBOX: mockupOutbox },
536
564
  onEvent: (evt) => {
537
565
  // Self-heal a stale resume — the saved SDK session expired. Reopen fresh,
538
566
  // keeping the transcript (scrollback survives; live context is gone).
@@ -540,6 +568,7 @@ function openStructured({ id, model, resume, log, commands, mode }) {
540
568
  entry.recovered = true
541
569
  process.stderr.write(`\n ◆ saved session expired — starting fresh (transcript kept).\n`)
542
570
  try { entry.session?.end() } catch { /* noop */ }
571
+ try { entry.mockupWatcher?.close() } catch { /* noop */ }
543
572
  sessions.delete(id)
544
573
  openStructured({ id, model, log: entry.log, commands: entry.commands, mode: entry.mode })
545
574
  return
@@ -591,6 +620,7 @@ function endStructured(id) {
591
620
  const s = sessions.get(id)
592
621
  if (s) {
593
622
  try { s.session?.end() } catch { /* noop */ }
623
+ try { s.mockupWatcher?.close() } catch { /* noop */ }
594
624
  sessions.delete(id)
595
625
  bcast('term-exit', { id })
596
626
  }
@@ -807,7 +837,13 @@ channel
807
837
  if (wantStructured(startCmd)) {
808
838
  const all = loadAll(room).filter((r) => r.log?.length || r.sessionId)
809
839
  if (all.length) for (const rec of all) openStructured({ id: rec.id, resume: canResume(rec) ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode })
810
- else openStructured({ id: randomUUID() })
840
+ // Fresh open: ONLY for an explicit `-- <claude>` share. In account mode
841
+ // (autoAgent set, no attachedCmd) the web's `?new=1` flow opens the first
842
+ // terminal; opening one here too raced it into TWO terminals — each a
843
+ // fresh random id, so openStructured's id-dedup couldn't catch it. The
844
+ // saved-session restore above still runs unconditionally, so a bridge
845
+ // restart resumes backgrounded sessions regardless of this guard.
846
+ else if (attachedCmd) openStructured({ id: randomUUID() })
811
847
  }
812
848
  else {
813
849
  // Reuse the prior auto-opened PTY id across restarts so a reconnecting
@@ -105,6 +105,10 @@ const simplifyBlocks = (blocks = []) => blocks.map((b) => {
105
105
  * @param {string=} o.cwd working directory for the agent
106
106
  * @param {string=} o.model model id (default: host's configured)
107
107
  * @param {string=} o.resume session id to resume
108
+ * @param {object=} o.env full environment for the agent subprocess
109
+ * (REPLACES process.env in the child — spread process.env yourself).
110
+ * Used to hand each session its own TP_MOCKUP_OUTBOX so mockup cards
111
+ * attribute to the session that generated them, not a Map-order guess.
108
112
  * @param {(evt)=>void} o.onEvent receives normalized structured events
109
113
  * @param {(req)=>Promise<'allow'|'deny'>} o.requestPermission
110
114
  * called for EVERY tool call with { id, toolName, input, risk };
@@ -113,7 +117,7 @@ const simplifyBlocks = (blocks = []) => blocks.map((b) => {
113
117
  */
114
118
  const MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
115
119
 
116
- export function startClaudeSession({ cwd, model, resume, mode: initialMode = 'default', onEvent, requestPermission }) {
120
+ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission }) {
117
121
  const ac = new AbortController()
118
122
  const input = makeInputStream()
119
123
  let sessionId = resume || null
@@ -283,6 +287,7 @@ export function startClaudeSession({ cwd, model, resume, mode: initialMode = 'de
283
287
  if (cwd) opts.cwd = cwd
284
288
  if (model) opts.model = model
285
289
  if (resume) opts.resume = resume
290
+ if (env) opts.env = env
286
291
 
287
292
  ;(async () => {
288
293
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.20",
3
+ "version": "0.7.22",
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": {