thinkpool-pair 0.7.29 → 0.7.31

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
@@ -14,6 +14,7 @@ import fs from 'node:fs'
14
14
  import { createClient } from '@supabase/supabase-js'
15
15
  import os from 'node:os'
16
16
  import { saveAuth, loadAuth, loadDirs, bindDir } from './auth-store.mjs'
17
+ import { isSafeToRestart } from './update-gate.mjs'
17
18
 
18
19
  const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return null } })()
19
20
 
@@ -208,6 +209,9 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
208
209
 
209
210
  const children = new Map() // room -> child process
210
211
  const childIdle = new Map() // room -> bool (last idle report; unknown = not idle)
212
+ const childPeer = new Map() // room -> bool (a web client is watching this room)
213
+ let applyRequested = false // a user clicked "apply" in some room (Slice 3)
214
+ let pendingUpdate = null // newest published version once the poll sees it
211
215
  const warned = new Set()
212
216
 
213
217
  // Account presence — so the web dashboard can show "● Bridge active" for this
@@ -277,8 +281,17 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
277
281
  delete env.THINKPOOL_PAIR_AUTOUPDATE
278
282
  const child = spawn(process.execPath, [BRIDGE, room, '--headless', '--auto=claude'], { cwd: dir, stdio: ['inherit', 'inherit', 'inherit', 'ipc'], env })
279
283
  children.set(room, child)
280
- child.on('message', (m) => { if (m && m.t === 'idle') childIdle.set(room, !!m.idle) })
281
- child.on('exit', () => { children.delete(room); childIdle.delete(room) }) // re-served on the next tick
284
+ childIdle.set(room, false) // unknown until it reports never restart a fresh child
285
+ childPeer.set(room, false)
286
+ child.on('message', (m) => {
287
+ if (!m) return
288
+ if (m.t === 'idle') { childIdle.set(room, !!m.idle); childPeer.set(room, !!m.webPeer) }
289
+ else if (m.t === 'apply-update') { applyRequested = true; applyIfIdle() } // user clicked the chip
290
+ })
291
+ child.on('exit', () => { children.delete(room); childIdle.delete(room); childPeer.delete(room) }) // re-served on the next tick
292
+ // If an update is already pending, tell the freshly-served child immediately so
293
+ // its room shows the "update ready" chip without waiting for the next poll.
294
+ if (pendingUpdate) { try { child.send({ t: 'update-available', version: pendingUpdate }) } catch { /* child not ready */ } }
282
295
  }
283
296
  pushPresence()
284
297
  }
@@ -299,6 +312,16 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
299
312
  }
300
313
  for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => stop(sig))
301
314
 
315
+ // ── Slice 3 apply-gate (function scope so the child-IPC handler can call it) ──
316
+ // Restart the account bridge to apply a pending update ONLY when it's safe: every
317
+ // child idle (between turns) AND either a user clicked apply OR nobody's watching
318
+ // (unattended fallback). The predicate is unit-tested (tests/update-gate.test.mjs).
319
+ const applyIfIdle = () => {
320
+ if (!isSafeToRestart({ pendingUpdate, stopping, childIdle, childPeer, requested: applyRequested })) return
321
+ process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting account bridge to update; sessions resume.\n`)
322
+ stop('SIGTERM') // graceful: children persist + leave presence; exit 0 → service reruns @latest
323
+ }
324
+
302
325
  // ── auto-update (account-service tier only) ───────────────────────────────
303
326
  // launchd (KeepAlive) / systemd (Restart=always) re-run `npx -y
304
327
  // thinkpool-pair@latest` (no room) on every restart, so a clean exit here
@@ -312,7 +335,6 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
312
335
  // THINKPOOL_PAIR_UPDATE_IDLE child idle seconds before a restart (default 90)
313
336
  if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
314
337
  const POLL_MS = Math.max(60, parseInt(process.env.THINKPOOL_PAIR_UPDATE_INTERVAL, 10) || 1800) * 1000
315
- let pendingUpdate = null
316
338
  // Strictly-newer x.y.z core; ignore prerelease tails (never downgrade/churn).
317
339
  const isNewer = (latest, cur) => {
318
340
  const core = (v) => String(v).split('-')[0].split('.').map((n) => parseInt(n, 10) || 0)
@@ -330,20 +352,15 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
330
352
  return typeof j?.version === 'string' ? j.version : null
331
353
  } catch { return null } finally { clearTimeout(to) }
332
354
  }
333
- // Idle iff every served child has reported idle. No children → trivially idle.
334
- // Unknown (no report yet) counts as NOT idle, so we never cycle a fresh child.
335
- const allIdle = () => { for (const room of children.keys()) if (!childIdle.get(room)) return false; return true }
336
- const applyIfIdle = () => {
337
- if (!pendingUpdate || stopping || !allIdle()) return
338
- process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting account bridge to update; sessions resume.\n`)
339
- stop('SIGTERM') // graceful: children persist + leave presence; exit 0 → service reruns @latest
340
- }
341
355
  const check = async () => {
342
356
  const latest = await fetchLatest()
343
357
  if (latest && isNewer(latest, VERSION)) {
344
- if (!pendingUpdate) process.stderr.write(`\n ◆ thinkpool-pair ${latest} published — will restart at the next idle moment (all sessions quiet).\n`)
358
+ if (!pendingUpdate) process.stderr.write(`\n ◆ thinkpool-pair ${latest} published — "update ready" surfaced to each room; applies between turns.\n`)
345
359
  pendingUpdate = latest
346
- applyIfIdle()
360
+ // Tell every served child so its room shows the chip (children never self-update;
361
+ // only this supervisor restart re-resolves @latest). Slice 3 nudge.
362
+ for (const c of children.values()) { try { c.send({ t: 'update-available', version: latest }) } catch { /* child not ready */ } }
363
+ applyIfIdle() // unattended fallback fires here when no room has a web peer
347
364
  }
348
365
  }
349
366
  setInterval(check, POLL_MS).unref()
package/bridge.mjs CHANGED
@@ -37,8 +37,9 @@ import { fileURLToPath } from 'node:url'
37
37
  import { randomUUID } from 'node:crypto'
38
38
  import { createClient } from '@supabase/supabase-js'
39
39
  import { startClaudeSession } from './claude-session.mjs'
40
+ import { turnInFlight } from './update-gate.mjs'
40
41
  import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames } from './session-store.mjs'
41
- import { stampEvent } from './event-id.mjs'
42
+ import { stampEvent, makeSeqCounter, maxSeq, seqable } from './event-id.mjs'
42
43
 
43
44
  // Public client creds (the same anon values the web app ships — safe to embed).
44
45
  // Override with TP_SUPABASE_URL / TP_SUPABASE_ANON if you ever need to.
@@ -366,6 +367,23 @@ let shuttingDown = false
366
367
  let lastActivity = Date.now()
367
368
  const markActivity = () => { lastActivity = Date.now() }
368
369
 
370
+ // ── Slice 3: update-ready nudge state ──────────────────────────────
371
+ // The newest published version once the update poll (service tier) or the account
372
+ // supervisor (via IPC) reports one is out. Surfaced to the room as the "update
373
+ // ready" chip; the restart is gated to between turns (never mid-turn, Contract #1).
374
+ let pendingUpdate = null
375
+ let applyRequested = false // a user clicked "apply" in the room
376
+ // A web client (not another bridge) is present — checks presence metas for a
377
+ // non-bridge role. Gates the unattended auto-restart: when someone's watching we
378
+ // wait for their apply click instead of restarting under them.
379
+ const webPeerPresent = () => {
380
+ try {
381
+ const st = channel.presenceState() || {}
382
+ for (const metas of Object.values(st)) for (const m of (metas || [])) if (m && m.role !== 'bridge') return true
383
+ } catch { /* noop */ }
384
+ return false
385
+ }
386
+
369
387
  const bcast = (event, payload) => {
370
388
  if (event === 'pty-out' || event === 'code-event') lastActivity = Date.now()
371
389
  try {
@@ -388,6 +406,9 @@ const announce = () =>
388
406
  // cwd + version: the host's working dir + thinkpool-pair version, shown in
389
407
  // the room's welcome banner. Re-sent per announce so late joiners get them.
390
408
  cwd, version: VERSION,
409
+ // pendingUpdate: a newer thinkpool-pair is out — drives the room's "update ready"
410
+ // chip; re-sent per announce so reloads/late-joiners see it (Slice 3).
411
+ pendingUpdate,
391
412
  // updir: where room file-drops land (forward-slash normalised — the web
392
413
  // client string-joins host paths onto it; Node accepts `/` on Windows).
393
414
  updir: UPDIR.split(path.sep).join('/'),
@@ -594,6 +615,20 @@ function printLocal(evt) {
594
615
  }
595
616
  }
596
617
 
618
+ // Single stamp+append point for a structured session's replayed log. Assigns the
619
+ // per-term contiguous seq (C1), appends, and caps at STRUCTURED_LOG_MAX (oldest
620
+ // first). EVERY event that joins entry.log must go through here so the client's
621
+ // "drop ≤ seqHi" cursor stays correct (a logged event with no seq would break it).
622
+ function pushLog(entry, evt) {
623
+ // Only TRANSCRIPT events consume a seq (seqable) — replay-chrome kinds the web
624
+ // filters out (system/thinking_tokens/effort) ride the log seq-less so they don't
625
+ // hole the client's applied sequence. See event-id.mjs NO_SEQ_KINDS.
626
+ if (entry.seq && seqable(evt)) evt.seq = entry.seq.next()
627
+ entry.log.push(evt)
628
+ if (entry.log.length > STRUCTURED_LOG_MAX) entry.log.shift()
629
+ return evt
630
+ }
631
+
597
632
  // The Phase-2 path: instead of a PTY, run Claude Code through the Agent SDK and
598
633
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
599
634
  // persist to the host file; tool calls round-trip through the perm card; the
@@ -601,6 +636,11 @@ function printLocal(evt) {
601
636
  function openStructured({ id, model, resume, log, commands, mode }) {
602
637
  if (sessions.has(id)) return
603
638
  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 }
639
+ // C1 (RT-2): per-term contiguous seq counter. Seeded from the restored log's max
640
+ // so a bridge restart resumes ABOVE every persisted seq — fresh events never reuse
641
+ // an old seq, which is what keeps the between-turns restart dup-free (see
642
+ // event-id.mjs + src/pages/code/seqDedup.js). pushLog() is the single stamp point.
643
+ entry.seq = makeSeqCounter(maxSeq(entry.log))
604
644
  sessions.set(id, entry)
605
645
  // This session's private mockup outbox — render.sh writes manifests here (via
606
646
  // TP_MOCKUP_OUTBOX below) and the watcher attributes every card to THIS id, so
@@ -639,7 +679,10 @@ function openStructured({ id, model, resume, log, commands, mode }) {
639
679
  if (evt.kind === 'system' && Array.isArray(evt.commands) && evt.commands.length && !entry.commands) { entry.commands = evt.commands; announce(); persist() }
640
680
  // Chrome events (mode / usage / clear / compact) are transient state, not
641
681
  // transcript — broadcast + print them but keep out of the persisted/replayed log.
642
- const chrome = evt.kind === 'mode' || evt.kind === 'usage' || evt.kind === 'clear' || evt.kind === 'compact'
682
+ // 'stalled' is transient turn-liveness state (would replay as a stale frozen
683
+ // banner if persisted); 'compaction' is a real transcript milestone (the recap
684
+ // card) and is intentionally NOT chrome, so it persists + replays.
685
+ const chrome = evt.kind === 'mode' || evt.kind === 'usage' || evt.kind === 'clear' || evt.kind === 'compact' || evt.kind === 'stalled'
643
686
  // compact turn finished (or errored) → clear the pulsing indicator.
644
687
  // No "done" ctl line — the SDK's own output in the transcript is the
645
688
  // signal (compact summary on success, AbortError text on abort/too-small).
@@ -647,10 +690,7 @@ function openStructured({ id, model, resume, log, commands, mode }) {
647
690
  entry.compacting = false
648
691
  bcast('code-event', { term: id, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
649
692
  }
650
- if (!chrome) {
651
- entry.log.push(evt)
652
- if (entry.log.length > STRUCTURED_LOG_MAX) entry.log.shift()
653
- }
693
+ if (!chrome) pushLog(entry, evt)
654
694
  bcast('code-event', { term: id, evt })
655
695
  printLocal(evt)
656
696
  if (!chrome) persist()
@@ -702,6 +742,16 @@ function endStructured(id) {
702
742
  if (s) announce()
703
743
  }
704
744
 
745
+ // Slice 3 — surface a pending update to the room as the "update ready" chip:
746
+ // broadcast a chrome code-event per structured term + stash it on the announce so
747
+ // reloads/late-joiners see it too. The restart itself is gated to between turns.
748
+ function surfaceUpdate(version) {
749
+ if (!version || pendingUpdate === version) return
750
+ pendingUpdate = version
751
+ for (const id of sessions.keys()) bcast('code-event', { term: id, evt: { kind: 'update-available', version } })
752
+ announce()
753
+ }
754
+
705
755
  // After the attached CLI exits, the host's stdin stops feeding a PTY —
706
756
  // restore the cooked terminal so Ctrl-C reaches the bridge itself.
707
757
  const detachLocal = () => {
@@ -797,9 +847,17 @@ channel
797
847
  })
798
848
  }
799
849
  // Structured sessions replay their event log (reader rebuilds from it).
850
+ // C1 (RT-2): send only the tail past the client's per-term cursor (seqHi) when
851
+ // it supplies one — the authoritative replay-from-cursor that supersedes
852
+ // re-sending + re-merging the whole log on every reconnect. Events with no seq
853
+ // (legacy/pre-C1 logs) ride along always; the client cid-dedups those and
854
+ // additionally drops ≤ seqHi, so an over-send is harmless. A fresh joiner (no
855
+ // cursor) still gets the full log.
800
856
  for (const [id, s] of sessions) {
801
857
  if (!s.log.length) continue
802
- bcast('code-replay', { to: payload?.to ?? null, term: id, events: s.log })
858
+ const from = Number(payload?.cursors?.[id]) || 0
859
+ const events = from > 0 ? s.log.filter((e) => e.seq == null || e.seq > from) : s.log
860
+ bcast('code-replay', { to: payload?.to ?? null, term: id, events })
803
861
  }
804
862
  // Re-send any still-pending permission/question cards. They ride a one-shot
805
863
  // code-perm-req broadcast (NOT the replayed event log), so a reconnect/refresh
@@ -847,7 +905,7 @@ channel
847
905
  const echoYou = () => {
848
906
  if (payload.silent) return
849
907
  const evt = { kind: 'you', text, cid: payload.cid, by: payload.by }
850
- s.log.push(evt); if (s.log.length > STRUCTURED_LOG_MAX) s.log.shift()
908
+ pushLog(s, evt)
851
909
  bcast('code-event', { term: payload.term, evt })
852
910
  }
853
911
  // Session controls (/model, /clear) are NOT agent turns — they change the
@@ -857,7 +915,7 @@ channel
857
915
  // slash still flow through as a real, self-terminating turn.
858
916
  const ctlLine = (ctlText) => {
859
917
  const evt = { kind: 'control', text: ctlText, by: payload.by, cid: payload.cid }
860
- s.log.push(evt); if (s.log.length > STRUCTURED_LOG_MAX) s.log.shift()
918
+ pushLog(s, evt)
861
919
  bcast('code-event', { term: payload.term, evt })
862
920
  }
863
921
  const mm = text.match(/^\/model\b\s*(\S+)?/)
@@ -868,7 +926,7 @@ channel
868
926
  }
869
927
  if (/^\/clear\s*$/.test(text)) {
870
928
  s.session.sendTurn(text)
871
- s.log = []; bcast('code-event', { term: payload.term, evt: { kind: 'clear' } })
929
+ s.log = []; s.seq = makeSeqCounter(0); bcast('code-event', { term: payload.term, evt: { kind: 'clear' } })
872
930
  flushSession(room, payload.term, { sessionId: s.session?.sessionId || null, log: [] })
873
931
  ctlLine('context cleared. You can continue with these answers in mind.')
874
932
  return
@@ -904,6 +962,18 @@ channel
904
962
  .on('broadcast', { event: 'code-close' }, ({ payload }) => {
905
963
  endStructured(payload?.id)
906
964
  })
965
+ // Slice 3 — the user clicked "apply" on the update chip. Mark it requested; the
966
+ // actual restart is gated to between turns. Account-child: forward to the
967
+ // supervisor (it owns the restart, applied only when every child is idle).
968
+ // Standalone service tier: its applyIfIdle loop lands it at the next idle. Either
969
+ // way it never interrupts a turn (Contract #1).
970
+ .on('broadcast', { event: 'apply-update' }, () => {
971
+ if (!pendingUpdate) return
972
+ applyRequested = true
973
+ if (process.send && process.env.THINKPOOL_PAIR_ACCOUNT_CHILD === '1') {
974
+ try { process.send({ t: 'apply-update' }) } catch { /* parent gone */ }
975
+ }
976
+ })
907
977
  // Persist + re-announce terminal renames. The web also echoes term-rename to
908
978
  // online peers directly; storing it here is what reaches a device that joins
909
979
  // LATER (or a second machine) — those only ever see the announce.
@@ -985,9 +1055,9 @@ setInterval(() => {
985
1055
  if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
986
1056
  const POLL_MS = Math.max(60, parseInt(process.env.THINKPOOL_PAIR_UPDATE_INTERVAL, 10) || 1800) * 1000
987
1057
  const IDLE_MS = Math.max(10, parseInt(process.env.THINKPOOL_PAIR_UPDATE_IDLE, 10) || 90) * 1000
988
- let pendingUpdate = null // the newer version string, once seen
989
-
1058
+ // pendingUpdate is module-level now (shared with surfaceUpdate + the room chip).
990
1059
  const isIdle = () => {
1060
+ if (turnInFlight(sessions)) return false // a structured turn is live
991
1061
  if (Date.now() - lastActivity < IDLE_MS) return false
992
1062
  for (const t of terms.values()) if (t.buf) return false // bytes mid-flush
993
1063
  return true
@@ -1011,14 +1081,18 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
1011
1081
  }
1012
1082
  const applyIfIdle = () => {
1013
1083
  if (!pendingUpdate || !isIdle()) return
1084
+ // Slice 3: when a web client is watching, wait for their "apply" click instead
1085
+ // of restarting under them. With nobody present (cloud VM / closed tab) the
1086
+ // unattended fallback still auto-applies at idle so headless boxes self-heal.
1087
+ if (!applyRequested && webPeerPresent()) return
1014
1088
  process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting to update; the session resumes.\n`)
1015
1089
  shutdown(0, false) // clean teardown (no farewell banner); service reruns @latest, session-store resumes
1016
1090
  }
1017
1091
  const check = async () => {
1018
1092
  const latest = await fetchLatest()
1019
1093
  if (latest && isNewer(latest, VERSION)) {
1020
- if (!pendingUpdate) process.stderr.write(`\n ◆ thinkpool-pair ${latest} published — will restart at the next idle moment.\n`)
1021
- pendingUpdate = latest
1094
+ if (!pendingUpdate) process.stderr.write(`\n ◆ thinkpool-pair ${latest} published — "update ready" in the room; applies between turns.\n`)
1095
+ surfaceUpdate(latest) // sets pendingUpdate + broadcasts the chip + re-announces
1022
1096
  applyIfIdle()
1023
1097
  }
1024
1098
  }
@@ -1032,11 +1106,16 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
1032
1106
  // the supervisor can pick a moment when EVERY child is idle to restart-update.
1033
1107
  const IDLE_MS = Math.max(10, parseInt(process.env.THINKPOOL_PAIR_UPDATE_IDLE, 10) || 90) * 1000
1034
1108
  const idleNow = () => {
1109
+ if (turnInFlight(sessions)) return false // a structured turn is live
1035
1110
  if (Date.now() - lastActivity < IDLE_MS) return false
1036
1111
  for (const t of terms.values()) if (t.buf) return false // bytes mid-flush
1037
1112
  return true
1038
1113
  }
1039
- setInterval(() => { try { process.send({ t: 'idle', idle: idleNow() }) } catch { /* parent gone */ } }, 5000).unref()
1114
+ // The supervisor owns updates (children never self-update). It IPCs us when a newer
1115
+ // version is out → surface the chip; we report idle + whether a web peer is present
1116
+ // so it restarts only between turns and only when nobody is mid-click (Slice 3).
1117
+ process.on('message', (m) => { if (m && m.t === 'update-available') surfaceUpdate(m.version) })
1118
+ setInterval(() => { try { process.send({ t: 'idle', idle: idleNow(), webPeer: webPeerPresent() }) } catch { /* parent gone */ } }, 5000).unref()
1040
1119
  } else if (VERSION) {
1041
1120
  // Foreground run (not the supervised service): it can't self-restart, but
1042
1121
  // nudge once if a newer version is out so a relaunch with @latest picks it up.
@@ -141,8 +141,24 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
141
141
  // per turn on `result`.
142
142
  let turnBaseOut = 0 // output tokens from completed messages this turn
143
143
  let curMsgOut = 0 // latest output_tokens for the in-flight message
144
+ // ── stall watchdog (C2) — stream silence ≠ done; a turn can stall (deltas pause
145
+ // 3+ min) or abort silently with no `result`. Track turn liveness + last event
146
+ // time; if an active turn goes quiet past STALL_MS, emit one `stalled` chrome
147
+ // event so the room can stop showing a frozen "working" forever. Cleared on the
148
+ // next event / `result`. Refs: TS SDK #44, claude-code #38905.
149
+ const STALL_MS = Math.max(30000, parseInt(process.env.TP_STALL_MS, 10) || 120000)
150
+ let turnActive = false // true between a sent turn and its `result`
151
+ let lastEvtTs = Date.now() // wall-clock of the most recent emitted event
152
+ let stalledSent = false // one `stalled` per stall, not a storm
144
153
 
145
- const emit = (evt) => { try { onEvent?.(evt) } catch { /* never let a consumer throw into the loop */ } }
154
+ 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 */ } }
155
+ const stallTimer = setInterval(() => {
156
+ if (turnActive && !stalledSent && Date.now() - lastEvtTs > STALL_MS) {
157
+ stalledSent = true
158
+ emit({ kind: 'stalled', sinceMs: Date.now() - lastEvtTs })
159
+ }
160
+ }, 5000)
161
+ stallTimer.unref?.()
146
162
 
147
163
  // Apply a permission-mode change to the live SDK *outside* any hook callback.
148
164
  // setPermissionMode is a streaming control request; awaiting it from INSIDE the
@@ -319,6 +335,13 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
319
335
  if (turnBaseOut + curMsgOut === 0) emit({ kind: 'thinking_tokens', tokens: m.estimated_tokens, delta: m.estimated_tokens_delta })
320
336
  break
321
337
  }
338
+ // compact_boundary — the authoritative compaction event (replaces the
339
+ // old heuristic): trigger ('manual' for /compact vs 'auto') + the token
340
+ // count before compaction. Emit a real recap card the room can pin.
341
+ if (m.subtype === 'compact_boundary') {
342
+ emit({ kind: 'compaction', trigger: m.compact_metadata?.trigger || 'auto', preTokens: m.compact_metadata?.pre_tokens ?? null })
343
+ break
344
+ }
322
345
  if (m.session_id) sessionId = m.session_id
323
346
  // m.slash_commands (init message) — the commands this session really
324
347
  // supports: built-ins + the host's custom .claude/commands. Surfaced
@@ -330,7 +353,10 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
330
353
  for (const b of (m.message?.content || [])) {
331
354
  if (b?.type === 'tool_use' && b.id) toolStart.set(b.id, Date.now())
332
355
  }
333
- emit({ kind: 'assistant', blocks: simplifyBlocks(m.message?.content) })
356
+ // parentToolUseId: non-null when this assistant message comes from a
357
+ // sub-agent (Task tool) — the universal nesting spine. Thread it so the
358
+ // room can group sub-agent activity under its parent Task card.
359
+ emit({ kind: 'assistant', blocks: simplifyBlocks(m.message?.content), parentToolUseId: m.parent_tool_use_id || null })
334
360
  break
335
361
  case 'user':
336
362
  // tool_result blocks arrive on the user-role echo
@@ -338,7 +364,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
338
364
  if (b?.type === 'tool_result') {
339
365
  const start = toolStart.get(b.tool_use_id)
340
366
  if (start != null) toolStart.delete(b.tool_use_id)
341
- emit({ kind: 'tool_result', toolUseId: b.tool_use_id, content: b.content, isError: !!b.is_error, durationMs: start != null ? Date.now() - start : undefined })
367
+ emit({ kind: 'tool_result', toolUseId: b.tool_use_id, content: b.content, isError: !!b.is_error, durationMs: start != null ? Date.now() - start : undefined, parentToolUseId: m.parent_tool_use_id || null })
342
368
  }
343
369
  }
344
370
  break
@@ -361,7 +387,8 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
361
387
  case 'result':
362
388
  if (m.session_id) sessionId = m.session_id
363
389
  turnBaseOut = 0; curMsgOut = 0 // reset the live token count for the next turn
364
- emit({ kind: 'result', subtype: m.subtype, sessionId, costUsd: m.total_cost_usd, usage: m.usage, numTurns: m.num_turns })
390
+ turnActive = false // turn settled stall watchdog stands down
391
+ 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 })
365
392
  // Surface a usage/context meter (chrome, not a transcript line). The
366
393
  // context window % comes from the control request; cost is cumulative.
367
394
  ;(async () => {
@@ -384,7 +411,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
384
411
  })()
385
412
 
386
413
  return {
387
- sendTurn(text) { if (!closed) input.push(String(text)) },
414
+ sendTurn(text) { if (!closed) { turnActive = true; lastEvtTs = Date.now(); stalledSent = false; input.push(String(text)) } },
388
415
  // Set the permission mode — Claude Code's ⇧⇥ cycle. setPermissionMode is a
389
416
  // streaming control request (drives plan-mode behaviour SDK-side); the local
390
417
  // `mode` drives our PreToolUse auto-approve policy. Echo so the room syncs.
@@ -412,8 +439,9 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
412
439
  // Graceful interrupt (Esc / Stop) — stops the current turn but keeps the
413
440
  // session alive for the next one. ac.abort() is teardown only (end()).
414
441
  async abort() { try { await q?.interrupt?.() } catch { /* noop */ } },
415
- end() { closed = true; input.end(); try { ac.abort() } catch { /* noop */ } },
442
+ end() { closed = true; clearInterval(stallTimer); input.end(); try { ac.abort() } catch { /* noop */ } },
416
443
  get sessionId() { return sessionId },
417
444
  get mode() { return mode },
445
+ get turnActive() { return turnActive }, // a turn is in flight (gates between-turns update restart — Slice 3 Contract #1)
418
446
  }
419
447
  }
package/event-id.mjs CHANGED
@@ -27,3 +27,43 @@ export function stampEvent(evt, gen = randomUUID) {
27
27
  if (!evt.cid) evt.cid = gen()
28
28
  return evt
29
29
  }
30
+
31
+ /* C1 (RT-2): a per-term monotonic, CONTIGUOUS sequence counter. Every event that
32
+ enters a session's replayed log gets `seq = counter.next()`, so the web room can
33
+ replay-from-cursor and dedup by "drop ≤ seqHi" instead of re-merging the whole
34
+ log by cid on every reconnect (see src/pages/code/seqDedup.js).
35
+
36
+ THE restart property (what makes Slice 3's between-turns restart dup-free): when a
37
+ bridge restart restores a persisted log, seed the counter from maxSeq(log) so the
38
+ NEXT seq is strictly above every restored seq. Reusing an old seq would make the
39
+ client drop the fresh event as a duplicate. */
40
+ export function makeSeqCounter(startAfter = 0) {
41
+ let n = Number.isFinite(startAfter) ? startAfter : 0
42
+ return {
43
+ next: () => ++n,
44
+ bumpTo: (m) => { if (Number.isFinite(m) && m > n) n = m },
45
+ current: () => n,
46
+ }
47
+ }
48
+
49
+ // Kinds the bridge logs for the live transcript but that the WEB treats as
50
+ // replay-chrome (src/pages/code/code-constants.js REPLAY_CHROME): filtered out of
51
+ // the replay + handled as transient state, never applied as a transcript line. They
52
+ // must NOT consume a per-term seq — the client advances its cursor (seqHi) ONLY over
53
+ // transcript events, so a seq on one of these would leave a hole in the applied
54
+ // sequence → the client sees a false gap → spurious resync storms. So the counter
55
+ // skips them and they ride the log seq-less (the client cid-handles/ignores them).
56
+ // MUST stay a subset of REPLAY_CHROME ∩ {kinds the bridge logs} — guarded by
57
+ // bridge/test-seq.mjs S6. (mode/usage/clear/compact/stalled are bridge-chrome and
58
+ // never logged at all, so they never reach the counter.)
59
+ export const NO_SEQ_KINDS = new Set(['system', 'thinking_tokens', 'effort'])
60
+ export const seqable = (evt) => !!evt && typeof evt === 'object' && !NO_SEQ_KINDS.has(evt.kind)
61
+
62
+ // Highest numeric `seq` across a log of events (0 when none) — the floor a restored
63
+ // session's counter resumes above.
64
+ export function maxSeq(events) {
65
+ if (!Array.isArray(events)) return 0
66
+ let m = 0
67
+ for (const e of events) if (e && typeof e.seq === 'number' && e.seq > m) m = e.seq
68
+ return m
69
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.29",
3
+ "version": "0.7.31",
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": {
@@ -10,6 +10,7 @@
10
10
  "bridge.mjs",
11
11
  "launcher.mjs",
12
12
  "claude-session.mjs",
13
+ "update-gate.mjs",
13
14
  "event-id.mjs",
14
15
  "transcript-sanitize.mjs",
15
16
  "session-store.mjs",
@@ -0,0 +1,31 @@
1
+ /* update-gate.mjs — pure predicates for the between-turns update apply (Slice 3
2
+ nudge protocol, spec docs/specs/2026-06-19-bridge-unified-launcher.md Part 2).
3
+
4
+ Extracted import-safe so the "safe to restart now" rule is unit-tested and can't
5
+ drift. Contract #1: an update only applies at idle / between turns — never mid-turn.
6
+ bridge.mjs runs the bridge on import (can't be imported by a test), so the gate
7
+ lives here. */
8
+
9
+ // Supervisor side: is it safe to restart the account bridge to apply a pending
10
+ // update? True iff a pending update exists, we're not already stopping, EVERY served
11
+ // child is idle (a child reports idle=false while a turn is in flight — that's what
12
+ // keeps Contract #1), AND either a user requested apply (clicked the chip) OR — the
13
+ // unattended fallback — no child has a web peer present (nobody is there to click).
14
+ export function isSafeToRestart({ pendingUpdate, stopping = false, childIdle, childPeer, requested = false }) {
15
+ if (!pendingUpdate || stopping) return false
16
+ for (const idle of childIdle.values()) if (!idle) return false // any busy child → wait (between-turns)
17
+ if (requested) return true // user clicked apply + all idle → go
18
+ for (const present of childPeer.values()) if (present) return false // someone's watching, no click → wait
19
+ return true // unattended: idle + nobody watching → auto-apply
20
+ }
21
+
22
+ // Child/standalone side: is any structured session mid-turn? Used both to gate a
23
+ // local apply and to make the idle heartbeat honest (idle=false while a turn runs).
24
+ // Accepts a sessions Map whose entries expose turnActive directly or via .session.
25
+ export function turnInFlight(sessions) {
26
+ for (const s of (sessions?.values?.() || [])) {
27
+ const ta = s && (typeof s.session?.turnActive === 'boolean' ? s.session.turnActive : s.turnActive)
28
+ if (ta) return true
29
+ }
30
+ return false
31
+ }