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 +8 -0
- package/bridge.mjs +49 -13
- package/claude-session.mjs +6 -1
- package/package.json +1 -1
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
|
|
388
|
-
|
|
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(
|
|
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
|
-
|
|
395
|
-
mockupSeen.
|
|
396
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
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
|
package/claude-session.mjs
CHANGED
|
@@ -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 {
|