thinkpool-pair 0.7.284 → 0.7.285

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
@@ -929,6 +929,37 @@ const terms = new Map()
929
929
  // PTY `terms` map so none of the byte-relay code paths (flush, resize,
930
930
  // pty-in, scrollback) ever touch them. id → { session, cmd, log, pending }.
931
931
  const sessions = new Map()
932
+
933
+ // One monotonically increasing authority per bridge process. Realtime preserves
934
+ // messages best-effort, not total order across a reconnect; clients use this to
935
+ // reject a late snapshot from an older channel while bridge_id distinguishes a
936
+ // real process restart (where the counter intentionally begins again).
937
+ let announceRev = 0
938
+
939
+ function syncStructuredTurn(entry, now = Date.now()) {
940
+ const busy = entry?.session?.turnActive ?? false
941
+ const changed = busy !== entry?._busyAnn
942
+ if (!changed) return false
943
+ if (busy) {
944
+ entry._turnRev = (Number(entry._turnRev) || 0) + 1
945
+ entry._turnStart = now
946
+ }
947
+ entry._busyAnn = busy
948
+ return true
949
+ }
950
+
951
+ function beginStructuredTurn(entry, now = Date.now()) {
952
+ if (entry?._busyAnn === true) return false
953
+ entry._turnRev = (Number(entry._turnRev) || 0) + 1
954
+ entry._turnStart = now
955
+ entry._busyAnn = true
956
+ return true
957
+ }
958
+
959
+ function stampStructuredTurn(entry, event) {
960
+ if (event && event.turnRev == null && Number(entry?._turnRev) > 0) event.turnRev = entry._turnRev
961
+ return event
962
+ }
932
963
  let attachedId = null
933
964
  let shuttingDown = false
934
965
 
@@ -1058,8 +1089,9 @@ const announce = () => {
1058
1089
  // (name-only, NEVER key/baseUrl) so a cross-device viewer can badge a lane opened
1059
1090
  // on a custom provider without the owner's account-channel registry.
1060
1091
  const provNames = providerNameMap()
1092
+ const rev = ++announceRev
1061
1093
  return bcast('bridge', {
1062
- v: 2, name, bridge_id: BRIDGE_ID, repo: repoLabel, branch: readBranch(),
1094
+ v: 2, name, bridge_id: BRIDGE_ID, rev, repo: repoLabel, branch: readBranch(),
1063
1095
  // sdkWarn: the auto-pulled agent SDK failed its boot compatibility smoke test —
1064
1096
  // surfaced so the room can show a banner (turns may misbehave; pin a good SDK).
1065
1097
  ...(sdkStatus.ok === false ? { sdkWarn: `${sdkStatus.version}: ${sdkStatus.reason}` } : {}),
@@ -1130,7 +1162,7 @@ const announce = () => {
1130
1162
  // laneStatusOf: authoritative busy/idle + last-action timestamp/age +
1131
1163
  // STUCK/BLOCKED alert. The bridge owns the turn and permission state, so
1132
1164
  // every roster consumer reads one status instead of reconstructing it.
1133
- ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, ...laneStatusOf(s), ...(s.session?.turnActive && s._turnStart ? { turnStartedAt: s._turnStart } : {}), hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, capabilities: { ...structuredRuntimeMetadata(s.runtime || 'claude'), modes: structuredModesForLane(s.runtime || 'claude', s) }, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [], canSteer: s.session?.canSteer ?? false } : s.runtime === 'hermes' ? { models: s.models || [], canSteer: s.session?.canSteer ?? false } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.sideParent ? { sideParent: s.sideParent, sideTask: s.sideTask || undefined, sideHandback: !!s.sideHandback } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
1165
+ ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, ...laneStatusOf(s), turnRev: Number(s._turnRev) || 0, ...(s.session?.turnActive && s._turnStart ? { turnStartedAt: s._turnStart } : {}), hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, capabilities: { ...structuredRuntimeMetadata(s.runtime || 'claude'), modes: structuredModesForLane(s.runtime || 'claude', s) }, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [], canSteer: s.session?.canSteer ?? false } : s.runtime === 'hermes' ? { models: s.models || [], canSteer: s.session?.canSteer ?? false } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.sideParent ? { sideParent: s.sideParent, sideTask: s.sideTask || undefined, sideHandback: !!s.sideHandback } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
1134
1166
  // provider: the registered LLM-provider this lane runs on, NAME-ONLY {id,name}
1135
1167
  // (NEVER the key or baseUrl). Additive; older clients ignore it. Omitted for the
1136
1168
  // built-in/default Claude path (no badge). Makes the lane's provider badge +
@@ -1684,6 +1716,7 @@ function pushLog(entry, evt) {
1684
1716
  // every old user message stacks at the TOP of the timeline (room XE3IQ, 2026-06-25).
1685
1717
  // A real ts makes ordering wall-clock — immune to eviction AND /clear seq resets.
1686
1718
  // stampEvent is idempotent, so the callers that already stamp are unaffected.
1719
+ stampStructuredTurn(entry, evt)
1687
1720
  stampEvent(evt)
1688
1721
  entry.lastActionAt = evt.ts
1689
1722
  entry.lastEvent = evt
@@ -1818,6 +1851,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
1818
1851
  // order (not readdir/filesystem order). Legacy recs (no openedAt) derive it from the
1819
1852
  // first transcript event ts, so even the first post-fix restart is ordered right.
1820
1853
  openedAt: openedAt || (Array.isArray(log) ? (log.find((e) => e?.ts)?.ts || 0) : 0) || Date.now() }
1854
+ entry._turnRev = entry.log.reduce((max, event) => Math.max(max, Number(event?.turnRev) || 0), 0)
1821
1855
  entry.interruptedRecap = restoredTurnOpen(entry.log) ? buildRecapFromLog(entry.log, RECAP_CAP) : null
1822
1856
  // Slice 3 — a permission card left unanswered past the grace window pushes
1823
1857
  // "<lane> — needs you: <what>"; answering it anywhere retracts the banner
@@ -2841,6 +2875,8 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2841
2875
  // replay-union dedupes ONLY by cid, and SDK events carry none — without an
2842
2876
  // id, an event that arrives both live AND in a reconnect replay renders
2843
2877
  // twice (the 2026-06-19 duplicate-message bug). See event-id.mjs.
2878
+ const busyChanged = syncStructuredTurn(entry)
2879
+ stampStructuredTurn(entry, evt)
2844
2880
  stampEvent(evt)
2845
2881
  const stalledChanged = evt.kind === 'stalled' ? !entry.stalled : !!entry.stalled
2846
2882
  entry.stalled = evt.kind === 'stalled'
@@ -2856,10 +2892,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2856
2892
  // The RISING edge is also where a turn's wall clock starts — the only place the
2857
2893
  // bridge sees "this lane just began working". `result`/`error` reads it to decide
2858
2894
  // whether the turn was long enough that nobody was watching (Slice 3).
2859
- { const busyNow = entry.session?.turnActive ?? false
2860
- const busyChanged = busyNow !== entry._busyAnn
2861
- if (busyChanged) { if (busyNow) entry._turnStart = Date.now(); entry._busyAnn = busyNow }
2862
- if (busyChanged || stalledChanged) announce() }
2895
+ if (busyChanged || stalledChanged) announce()
2863
2896
  // App Server steering is opt-in and can fall back to exec at runtime. Keep
2864
2897
  // the roster capability authoritative so the browser never immediate-sends
2865
2898
  // into an exec-only turn and loses its shared queue.
@@ -3909,7 +3942,12 @@ channel
3909
3942
  const nativeImages = s.runtime === 'codex' || s.runtime === 'hermes'
3910
3943
  ? await waitForNativeImages(payload.files, { updir: UPDIR })
3911
3944
  : []
3945
+ // Sample the runtime before dispatch so a missed prior falling edge cannot
3946
+ // make this genuinely new turn inherit the previous turn's revision.
3947
+ syncStructuredTurn(s)
3912
3948
  const accepted = s.session.sendTurn(sendText, nativeImages.length ? { images: nativeImages } : undefined)
3949
+ if (accepted === false) syncStructuredTurn(s)
3950
+ else beginStructuredTurn(s)
3913
3951
  echoYou()
3914
3952
  if (accepted === false) {
3915
3953
  // A runtime that did not accept a turn must still close the optimistic
package/codex-session.mjs CHANGED
@@ -33,6 +33,7 @@ import { startCodexMcpHttp } from './codex-mcp-http.mjs'
33
33
  import { CODEX_THINKPOOL_FIRST_TURN_PREAMBLE, buildThinkPoolTurnGuidance, createRoomContextSelector, usesFullThinkPoolReminder } from './thinkpool-room-prompt.mjs'
34
34
  import { questionAnswerResponse } from './question-response.mjs'
35
35
  import { codexReviewTarget, isCodexCompactCommand } from './codex-commands.mjs'
36
+ import { createCumulativeEventRelay } from './cumulative-event-relay.mjs'
36
37
 
37
38
  const DEFAULT_SANDBOX = 'workspace-write'
38
39
  const SAFE_SANDBOXES = new Set(['read-only', 'workspace-write', 'danger-full-access'])
@@ -402,8 +403,9 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
402
403
  let chain = Promise.resolve()
403
404
  const queue = []
404
405
 
406
+ const relayEvent = createCumulativeEventRelay((event) => { try { onEvent?.(event) } catch { /* consumer isolation */ } })
405
407
  const mapper = new CodexEventMapper({
406
- onEvent,
408
+ onEvent: relayEvent,
407
409
  model: model || null,
408
410
  usageSnapshotForSession: (id) => {
409
411
  const snapshot = readCodexThreadUsage(id)
@@ -425,10 +427,10 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
425
427
  }
426
428
  const emitTurnBoundary = (event) => {
427
429
  turnActive = false
428
- try { onEvent?.(event) } catch { /* noop */ }
430
+ relayEvent(event)
429
431
  }
430
432
 
431
- const note = (text) => { try { onEvent?.({ kind: 'note', text }) } catch { /* noop */ } }
433
+ const note = (text) => relayEvent({ kind: 'note', text })
432
434
 
433
435
  const closeAppServerTurn = (turnId = activeTurnId) => {
434
436
  const id = String(turnId || '')
@@ -472,7 +474,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
472
474
  activeForm: step || '',
473
475
  status: status === 'inProgress' ? 'in_progress' : status === 'completed' ? 'completed' : 'pending',
474
476
  })).filter((todo) => todo.content)
475
- try { onEvent?.({ kind: 'assistant', blocks: [{ type: 'tool_use', id: `codex-plan:${params.turnId || 'active'}`, name: 'TodoWrite', input: { todos } }], parentToolUseId: null }) } catch { /* noop */ }
477
+ relayEvent({ kind: 'assistant', blocks: [{ type: 'tool_use', id: `codex-plan:${params.turnId || 'active'}`, name: 'TodoWrite', input: { todos } }], parentToolUseId: null })
476
478
  return
477
479
  }
478
480
  if (method === 'item/agentMessage/delta') {
@@ -484,7 +486,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
484
486
  // Cumulative transient snapshot. The bridge broadcasts this chrome event
485
487
  // without assigning transcript seq/archive rows; room.jsx replaces the
486
488
  // stable cid in place. item/completed emits one durable assistant row.
487
- try { onEvent?.({ kind: 'assistant_stream', cid: state.cid, text: state.text, itemId, streaming: true }) } catch { /* noop */ }
489
+ relayEvent({ kind: 'assistant_stream', cid: state.cid, text: state.text, itemId, streaming: true })
488
490
  return
489
491
  }
490
492
  if (method === 'item/started' || method === 'item/completed') {
@@ -496,7 +498,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
496
498
  if (raw.type === 'agentMessage' && streamedAgentItems.has(raw.id)) {
497
499
  const state = streamedAgentItems.get(raw.id)
498
500
  streamedAgentItems.delete(raw.id)
499
- try { onEvent?.({ kind: 'assistant', blocks: [{ type: 'text', text: raw.text || state.text }], parentToolUseId: null, replacesCid: state.cid }) } catch { /* noop */ }
501
+ relayEvent({ kind: 'assistant', blocks: [{ type: 'text', text: raw.text || state.text }], parentToolUseId: null, replacesCid: state.cid })
500
502
  return
501
503
  }
502
504
  }
@@ -639,7 +641,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
639
641
  // than leaving a replay-less streaming shell behind.
640
642
  for (const state of streamedAgentItems.values()) {
641
643
  if (!state.text) continue
642
- try { onEvent?.({ kind: 'assistant', blocks: [{ type: 'text', text: state.text }], parentToolUseId: null, replacesCid: state.cid }) } catch { /* noop */ }
644
+ relayEvent({ kind: 'assistant', blocks: [{ type: 'text', text: state.text }], parentToolUseId: null, replacesCid: state.cid })
643
645
  }
644
646
  activeTurnId = null
645
647
  turnActive = false
@@ -683,7 +685,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
683
685
  } finally {
684
686
  for (const state of streamedAgentItems.values()) {
685
687
  if (!state.text) continue
686
- try { onEvent?.({ kind: 'assistant', blocks: [{ type: 'text', text: state.text }], parentToolUseId: null, replacesCid: state.cid }) } catch { /* noop */ }
688
+ relayEvent({ kind: 'assistant', blocks: [{ type: 'text', text: state.text }], parentToolUseId: null, replacesCid: state.cid })
687
689
  }
688
690
  activeTurnId = null
689
691
  turnActive = false
@@ -817,7 +819,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
817
819
  if (!turnActive && queue.length === 0 && !appServerThreadReady && !child) {
818
820
  const gate = typeof admitStart === 'function' ? admitStart() : { ok: true }
819
821
  if (gate?.ok === false) {
820
- try { onEvent?.({ kind: 'error', message: gate.reason || 'Host memory is critically low. This Codex runtime was not started.', recoverable: true }) } catch { /* noop */ }
822
+ relayEvent({ kind: 'error', message: gate.reason || 'Host memory is critically low. This Codex runtime was not started.', recoverable: true })
821
823
  return false
822
824
  }
823
825
  }
@@ -848,7 +850,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
848
850
  queue.push({ text, options, promptIndex, forceFullReminder: thisTurnForceFull })
849
851
  if (!turnActive && queue.length === 1) pump()
850
852
  } else {
851
- try { onEvent?.({ kind: 'error', message: 'Codex steering delivery is uncertain; the message was not replayed automatically.', recoverable: true }) } catch { /* noop */ }
853
+ relayEvent({ kind: 'error', message: 'Codex steering delivery is uncertain; the message was not replayed automatically.', recoverable: true })
852
854
  }
853
855
  })
854
856
  return true
@@ -884,6 +886,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
884
886
  end() {
885
887
  ended = true
886
888
  turnActive = false
889
+ relayEvent.cancel()
887
890
  queue.length = 0
888
891
  closeAppServerTurn(activeTurnId)
889
892
  if (child) { try { child.kill() } catch { /* noop */ } }
@@ -912,7 +915,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
912
915
  setEffort(nextEffort) {
913
916
  if (turnActive || !EFFORT_LEVELS.has(nextEffort)) return false
914
917
  activeEffort = nextEffort
915
- try { onEvent?.({ kind: 'effort', level: activeEffort }) } catch { /* noop */ }
918
+ relayEvent({ kind: 'effort', level: activeEffort })
916
919
  return true
917
920
  },
918
921
  clearContext() {
@@ -0,0 +1,48 @@
1
+ // Cumulative provider snapshots are replace-in-place UI state, not transcript
2
+ // facts. Deliver the first immediately and coalesce a burst to its latest frame;
3
+ // any durable event flushes the pending snapshot first to preserve wire order.
4
+ export function createCumulativeEventRelay(emit, waitMs = 150) {
5
+ let lastSentAt = 0
6
+ let pending = null
7
+ let timer = null
8
+
9
+ const send = (event) => {
10
+ lastSentAt = Date.now()
11
+ emit(event)
12
+ }
13
+ const flush = () => {
14
+ if (timer) clearTimeout(timer)
15
+ timer = null
16
+ if (!pending) return
17
+ const event = pending
18
+ pending = null
19
+ send(event)
20
+ }
21
+ const relay = (event) => {
22
+ if (event?.kind !== 'assistant_stream') {
23
+ flush()
24
+ emit(event)
25
+ return
26
+ }
27
+ const remaining = waitMs - (Date.now() - lastSentAt)
28
+ if (!lastSentAt || remaining <= 0) {
29
+ pending = null
30
+ if (timer) clearTimeout(timer)
31
+ timer = null
32
+ send(event)
33
+ return
34
+ }
35
+ pending = event
36
+ if (!timer) {
37
+ timer = setTimeout(flush, remaining)
38
+ timer.unref?.()
39
+ }
40
+ }
41
+ relay.flush = flush
42
+ relay.cancel = () => {
43
+ if (timer) clearTimeout(timer)
44
+ timer = null
45
+ pending = null
46
+ }
47
+ return relay
48
+ }
@@ -11,6 +11,7 @@ import { startCodexMcpHttp } from './codex-mcp-http.mjs'
11
11
  import { autoAllow, classifyRisk } from './claude-session.mjs'
12
12
  import { crossPostNeedsCard } from './cross-terminal.mjs'
13
13
  import { buildThinkPoolTurnGuidance, createRoomContextSelector, usesFullThinkPoolReminder } from './thinkpool-room-prompt.mjs'
14
+ import { createCumulativeEventRelay } from './cumulative-event-relay.mjs'
14
15
 
15
16
  export const HERMES_COMMAND = 'thinkpool'
16
17
  export const HERMES_ACP_PROTOCOL_VERSION = 1
@@ -112,7 +113,7 @@ export function startHermesSession({
112
113
  ? 'dont_ask'
113
114
  : 'default'
114
115
 
115
- const emit = (event) => { try { onEvent?.(event) } catch { /* consumer isolation */ } }
116
+ const emit = createCumulativeEventRelay((event) => { try { onEvent?.(event) } catch { /* consumer isolation */ } })
116
117
 
117
118
  const thinkpoolPeerTool = (name) => {
118
119
  const lower = String(name || '').toLowerCase()
@@ -598,6 +599,7 @@ export function startHermesSession({
598
599
  end() {
599
600
  ended = true
600
601
  turnActive = false
602
+ emit.cancel()
601
603
  queuedTurns.length = 0
602
604
  queueDrainPending = false
603
605
  if (sessionId && client?.alive) void client.request('session/close', { sessionId }, 1500).catch(() => {}).finally(() => client?.end())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.284",
3
+ "version": "0.7.285",
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": {
@@ -27,6 +27,7 @@
27
27
  "codex-mcp-http.mjs",
28
28
  "lane-worktree.mjs",
29
29
  "codex-event-mapper.mjs",
30
+ "cumulative-event-relay.mjs",
30
31
  "codex-commands.mjs",
31
32
  "acp-client.mjs",
32
33
  "hermes-session.mjs",