thinkpool-pair 0.7.290 → 0.7.291

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
@@ -120,6 +120,7 @@ import { supersedeDispatchLease } from './dispatch-lease.mjs'
120
120
  import { turnInFlight } from './update-gate.mjs'
121
121
  import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
122
122
  import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, ImageEventQueue, imageQueueConfig, uploadCodeImage as uploadCodeImageRequest, usageReportLine, codexUsageReportLine, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
123
+ import { createLatestReplayPump, requestedReplayIds } from './replay-transport.mjs'
123
124
  import { classifyCodeEvent, unknownCodeEventNotice } from './code-event-contract.mjs'
124
125
  import { fetchPairControlDeliveryAuthority } from './pair-control-authority.mjs'
125
126
  import { SIDE_HANDOFF_PROMPT, SIDE_ROLE_PROMPT, appendSideContext, assistantTextSince, sideContextBlock, sideSnapshot } from './side-lane.mjs'
@@ -574,6 +575,7 @@ if (process.stdin.isTTY && !headless && process.env.THINKPOOL_PAIR_AUTOUPDATE !=
574
575
  }
575
576
  const name = process.env.TP_NAME || os.userInfo().username || 'host'
576
577
  const BRIDGE_ID = (randomUUID?.().slice(0,8)) || 'bridgexx'
578
+ const BRIDGE_STARTED_AT = Date.now()
577
579
  // host: this machine's short label — os.hostname() with any DNS/domain suffix
578
580
  // stripped and capped ~24 chars. A /code room can be served by different bridges
579
581
  // over time (Max's Mac vs Conrad's Linux box); the client uses this to show WHICH
@@ -957,6 +959,13 @@ function beginStructuredTurn(entry, now = Date.now()) {
957
959
  return true
958
960
  }
959
961
 
962
+ function advanceStructuredTurn(entry, now = Date.now()) {
963
+ entry._turnRev = (Number(entry._turnRev) || 0) + 1
964
+ entry._turnStart = now
965
+ entry._busyAnn = true
966
+ return true
967
+ }
968
+
960
969
  function stampStructuredTurn(entry, event) {
961
970
  if (event && event.turnRev == null && Number(entry?._turnRev) > 0) event.turnRev = entry._turnRev
962
971
  return event
@@ -1064,17 +1073,21 @@ const defendFrame = (event, payload) => {
1064
1073
  return p
1065
1074
  }
1066
1075
 
1067
- const bcast = (event, payload, ch = channel) => {
1076
+ const bcastAwait = async (event, payload, ch = channel) => {
1068
1077
  if (event === 'pty-out' || event === 'code-event') lastActivity = Date.now()
1069
1078
  payload = defendFrame(event, payload)
1070
1079
  try {
1071
1080
  if (ch.channelAdapter?.canPush?.() ?? true) {
1072
- ch.send({ type: 'broadcast', event, payload })
1081
+ return await ch.send({ type: 'broadcast', event, payload })
1073
1082
  } else {
1074
- ch.httpSend(event, payload).catch(() => { /* offline — replay covers it */ })
1083
+ return await ch.httpSend(event, payload)
1075
1084
  }
1076
- } catch { /* noop */ }
1085
+ } catch { return null /* offline — replay covers it */ }
1077
1086
  }
1087
+ const bcast = (event, payload, ch = channel) => { void bcastAwait(event, payload, ch) }
1088
+ const replayPump = createLatestReplayPump({
1089
+ send: ({ event, payload }) => bcastAwait(event, payload),
1090
+ })
1078
1091
 
1079
1092
  // Per-room terminal display names (id -> label), set by the web's `term-rename`.
1080
1093
  // Persisted on the host so a rename is cross-device + survives a bridge restart;
@@ -1093,7 +1106,7 @@ const announce = () => {
1093
1106
  const provNames = providerNameMap()
1094
1107
  const rev = ++announceRev
1095
1108
  return bcast('bridge', {
1096
- v: 2, name, bridge_id: BRIDGE_ID, rev, repo: repoLabel, branch: readBranch(),
1109
+ v: 2, name, bridge_id: BRIDGE_ID, started_at: BRIDGE_STARTED_AT, rev, repo: repoLabel, branch: readBranch(),
1097
1110
  // sdkWarn: the auto-pulled agent SDK failed its boot compatibility smoke test —
1098
1111
  // surfaced so the room can show a banner (turns may misbehave; pin a good SDK).
1099
1112
  ...(sdkStatus.ok === false ? { sdkWarn: `${sdkStatus.version}: ${sdkStatus.reason}` } : {}),
@@ -2876,6 +2889,20 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2876
2889
  // per-machine default (provider.mjs/applyProviderEnv). null for built-in/unknown → the
2877
2890
  // default Claude env is left exactly as-is (unchanged path).
2878
2891
  env: { ...process.env, ...buildConductorEnv({ flowSessionId, mode }), ...(resolveProviderEnv(provider) || {}), TP_MOCKUP_OUTBOX: mockupOutbox },
2892
+ onTurnStart: (options = {}) => {
2893
+ // Hermes promotes /queue items internally, without a second code-turn.
2894
+ // Advance the lifecycle before its first output and publish the deferred
2895
+ // human line under the new turn revision, keeping all viewers converged.
2896
+ if (entry._busyAnn === true) advanceStructuredTurn(entry)
2897
+ else beginStructuredTurn(entry)
2898
+ const queuedEcho = options._thinkpoolQueuedEcho
2899
+ if (queuedEcho) {
2900
+ const evt = { kind: 'you', ...queuedEcho }
2901
+ pushLog(entry, evt)
2902
+ bcast('code-event', { term: id, evt })
2903
+ }
2904
+ announce()
2905
+ },
2879
2906
  onEvent: (evt) => {
2880
2907
  if (!classifyCodeEvent(evt).known) {
2881
2908
  // Never archive or replay an unknown provider payload as a known fact. Do
@@ -2933,7 +2960,9 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2933
2960
  // replay-union dedupes ONLY by cid, and SDK events carry none — without an
2934
2961
  // id, an event that arrives both live AND in a reconnect replay renders
2935
2962
  // twice (the 2026-06-19 duplicate-message bug). See event-id.mjs.
2936
- const busyChanged = syncStructuredTurn(entry)
2963
+ const continuesQueued = runtime === 'hermes' && (evt.kind === 'result' || evt.kind === 'error') && (entry.session?.queuedDepth || 0) > 0
2964
+ if (continuesQueued) evt.continuesQueued = true
2965
+ const busyChanged = continuesQueued ? false : syncStructuredTurn(entry)
2937
2966
  stampStructuredTurn(entry, evt)
2938
2967
  stampEvent(evt)
2939
2968
  const stalledChanged = evt.kind === 'stalled' ? !entry.stalled : !!entry.stalled
@@ -3651,14 +3680,17 @@ channel
3651
3680
  // Flush pending bytes first so the replay is complete up to "now"; both
3652
3681
  // ride the same ordered socket, so the client sees replay-then-live.
3653
3682
  flushAll()
3654
- for (const [id, t] of terms) {
3683
+ const to = payload?.to ?? null
3684
+ const requestId = String(payload?.requestId || randomUUID?.() || `${Date.now()}`)
3685
+ const frames = []
3686
+ for (const [id, t] of requestedReplayIds(terms, payload?.terms, payload?.priority)) {
3655
3687
  if (!t.scrollback) continue
3656
3688
  // Cap scrollback too — a huge PTY buffer would blow the same frame limit.
3657
3689
  const sb = t.scrollback.length > 100000 ? t.scrollback.slice(-100000) : t.scrollback
3658
- bcast('pty-replay', {
3659
- to: payload?.to ?? null, term: id,
3690
+ frames.push({ event: 'pty-replay', payload: {
3691
+ to, term: id, requestId,
3660
3692
  b64: Buffer.from(sb, 'utf8').toString('base64'),
3661
- })
3693
+ } })
3662
3694
  }
3663
3695
  // Structured sessions replay their event log (reader rebuilds from it).
3664
3696
  // C1 (RT-2): send only the tail past the client's per-term cursor (seqHi) when
@@ -3669,14 +3701,13 @@ channel
3669
3701
  // cursor) still gets the full log.
3670
3702
  // Hydrate the viewed lane first. Other terminals still warm in the background,
3671
3703
  // but cannot queue ahead of the transcript the person is waiting to see.
3672
- const replaySessions = [...sessions.entries()].sort(([a], [b]) =>
3673
- a === payload?.priority ? -1 : b === payload?.priority ? 1 : 0)
3704
+ const replaySessions = requestedReplayIds(sessions, payload?.terms, payload?.priority)
3674
3705
  for (const [id, s] of replaySessions) {
3675
3706
  // An explicit empty replay is an acknowledgement, not data. It lets mixed
3676
3707
  // clients clear their loading cover even if they missed the announce's
3677
3708
  // hasTranscript:false state.
3678
3709
  if (!s.log.length) {
3679
- bcast('code-replay', { to: payload?.to ?? null, term: id, events: [], empty: true })
3710
+ frames.push({ event: 'code-replay', payload: { to, term: id, requestId, chunkIndex: 0, chunkCount: 1, events: [], empty: true } })
3680
3711
  continue
3681
3712
  }
3682
3713
  const from = Number(payload?.cursors?.[id]) || 0
@@ -3716,10 +3747,14 @@ channel
3716
3747
  // trimmedBefore rides the FIRST (oldest) chunk only — the later chunks are contiguous
3717
3748
  // with it, so a stamp there would read as a second, phantom gap. Same rule as
3718
3749
  // lastUsage above and hasMore on history-page below.
3719
- let firstChunk = true
3720
- for (const events of chunks) {
3721
- bcast('code-replay', { to: payload?.to ?? null, term: id, events, ...(ahead ? { reset: true } : {}), ...(firstChunk && trimmedBefore != null ? { trimmedBefore } : {}), ...(firstChunk && s.lastUsage?.ctx ? { lastUsage: s.lastUsage } : {}) })
3722
- firstChunk = false
3750
+ for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
3751
+ const events = chunks[chunkIndex]
3752
+ frames.push({ event: 'code-replay', payload: {
3753
+ to, term: id, requestId, chunkIndex, chunkCount: chunks.length, events,
3754
+ ...(ahead ? { reset: true } : {}),
3755
+ ...(chunkIndex === 0 && trimmedBefore != null ? { trimmedBefore } : {}),
3756
+ ...(chunkIndex === 0 && s.lastUsage?.ctx ? { lastUsage: s.lastUsage } : {}),
3757
+ } })
3723
3758
  }
3724
3759
  }
3725
3760
  // Re-send any still-pending permission/question cards. They ride a one-shot
@@ -3728,10 +3763,15 @@ channel
3728
3763
  // (unanswered) tool row — the AskUserQuestion "vanished on reconnect" bug. Only
3729
3764
  // truly-unresolved cards remain in `pending` (resolved ones are deleted), and
3730
3765
  // the client dedupes code-perm-req by id, so this can't resurrect an answered one.
3731
- for (const [, s] of sessions) {
3732
- for (const [, p] of s.pending) bcast('code-perm-req', p.payload)
3733
- }
3734
- announce()
3766
+ // One awaited frame per event-loop turn. A newer request from this viewer
3767
+ // replaces every unsent frame from the older one, so visibility/online races
3768
+ // cannot multiply a cold replay into several concurrent megabyte bursts.
3769
+ replayPump.enqueue(to || '*', frames, async () => {
3770
+ for (const [, s] of sessions) {
3771
+ for (const [, p] of s.pending) await bcastAwait('code-perm-req', p.payload)
3772
+ }
3773
+ announce()
3774
+ })
3735
3775
  })
3736
3776
  // "Load earlier history": serve an OLDER page from the durable JSONL archive (the
3737
3777
  // events the in-memory 2000-cap evicted). beforeSeq anchors the page at the client's
@@ -3750,11 +3790,12 @@ channel
3750
3790
  if (!servesHistoryPage({ payloadHost: payload?.host, bridgeName: name, hasSession: sessions.has(id), hasArchive: hasDurableArchive(room, id) })) return
3751
3791
  const { events, hasMore } = readDurablePage(room, id, Number(payload?.beforeSeq) || null, 200)
3752
3792
  const to = payload?.to ?? null
3753
- if (!events.length) { bcast('history-page', { to, term: id, events: [], hasMore: false }); return }
3793
+ const requestId = String(payload?.requestId || randomUUID?.() || `${Date.now()}`)
3794
+ if (!events.length) { bcast('history-page', { to, term: id, requestId, chunkIndex: 0, chunkCount: 1, events: [], hasMore: false }); return }
3754
3795
  const chunks = chunkReplayEvents(events.map((e) => boundEventForBroadcast(e)))
3755
3796
  // hasMore rides only the FIRST (oldest) chunk so the client sets the floor flag once
3756
3797
  // from the true page boundary; later chunks are just more of the same page.
3757
- chunks.forEach((evs, i) => bcast('history-page', { to, term: id, events: evs, hasMore: i === 0 ? hasMore : true }))
3798
+ chunks.forEach((evs, i) => bcast('history-page', { to, term: id, requestId, chunkIndex: i, chunkCount: chunks.length, events: evs, hasMore: i === 0 ? hasMore : true }))
3758
3799
  })
3759
3800
  .on('broadcast', { event: 'file-put' }, ({ payload }) => {
3760
3801
  if (!payload?.id || !payload?.url) return
@@ -4030,13 +4071,24 @@ channel
4030
4071
  const nativeImages = s.runtime === 'codex' || s.runtime === 'hermes'
4031
4072
  ? await waitForNativeImages(payload.files, { updir: UPDIR })
4032
4073
  : []
4074
+ const deferHermesQueueEcho = s.runtime === 'hermes' && s.session.turnActive && /^\s*\/queue\s+/i.test(text)
4075
+ const turnOptions = {
4076
+ ...(nativeImages.length ? { images: nativeImages } : {}),
4077
+ ...(deferHermesQueueEcho && !payload.silent ? { _thinkpoolQueuedEcho: {
4078
+ text: payload.body != null ? String(payload.body) : text,
4079
+ cid: payload.cid,
4080
+ by: payload.by,
4081
+ ...(Array.isArray(payload.files) && payload.files.length ? { files: payload.files } : {}),
4082
+ ...(Array.isArray(payload.pastes) && payload.pastes.length ? { pastes: payload.pastes } : {}),
4083
+ } } : {}),
4084
+ }
4033
4085
  // Sample the runtime before dispatch so a missed prior falling edge cannot
4034
4086
  // make this genuinely new turn inherit the previous turn's revision.
4035
4087
  syncStructuredTurn(s)
4036
- const accepted = s.session.sendTurn(sendText, nativeImages.length ? { images: nativeImages } : undefined)
4088
+ const accepted = s.session.sendTurn(sendText, Object.keys(turnOptions).length ? turnOptions : undefined)
4037
4089
  if (accepted === false) syncStructuredTurn(s)
4038
4090
  else beginStructuredTurn(s)
4039
- echoYou()
4091
+ if (!deferHermesQueueEcho || accepted === false) echoYou()
4040
4092
  if (accepted === false) {
4041
4093
  // A runtime that did not accept a turn must still close the optimistic
4042
4094
  // user-line lifecycle. Without this boundary the client truthfully shows
@@ -4312,7 +4364,7 @@ channel
4312
4364
  .subscribe(async status => {
4313
4365
  if (status === 'SUBSCRIBED') {
4314
4366
  realtimeHealthy = true; brokenSince = 0
4315
- trackPresence({ name, role: 'bridge', bridge_id: BRIDGE_ID })
4367
+ trackPresence({ name, role: 'bridge', bridge_id: BRIDGE_ID, started_at: BRIDGE_STARTED_AT })
4316
4368
  const startCmd = attachedCmd || autoAgent // autoAgent: account-mode auto-open (headless)
4317
4369
  if (startCmd && !terms.size && !sessions.size) {
4318
4370
  // Claude + structured mode → Agent SDK session; everything else → PTY.
@@ -68,7 +68,7 @@ export function startHermesSession({
68
68
  command = HERMES_COMMAND, args = ['acp'], clientFactory = createAcpClient,
69
69
  mcpHttpFactory = startCodexMcpHttp, lazy = false, hermesRole = null,
70
70
  crossPostGate = null, didSpawnTarget = null, crossRoomPostGate = null, effort = 'high',
71
- admitStart = null,
71
+ admitStart = null, onTurnStart = null,
72
72
  } = {}) {
73
73
  let activeCwd = cwd
74
74
  const requestedModel = model || null
@@ -503,6 +503,7 @@ export function startHermesSession({
503
503
  const next = queuedTurns.shift()
504
504
  const turnId = ++activeTurnId
505
505
  turnActive = true
506
+ try { onTurnStart?.(next.options || {}) } catch { /* observer cannot break the FIFO */ }
506
507
  try { await runPrompt(next.text, next.options, { steering: false, turnId, promptIndex: next.promptIndex, forceFull: next.forceFull }) }
507
508
  catch (error) { turnActive = false; emit({ kind: 'error', message: `Hermes queued turn failed: ${error?.message || error}`, recoverable: true }) }
508
509
  }
@@ -514,6 +515,7 @@ export function startHermesSession({
514
515
  return {
515
516
  get sessionId() { return sessionId },
516
517
  get turnActive() { return turnActive },
518
+ get queuedDepth() { return queuedTurns.length },
517
519
  get canSteer() { return !!client?.alive },
518
520
  get started() { return started },
519
521
  get models() { return [] },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.290",
3
+ "version": "0.7.291",
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": {
@@ -49,6 +49,7 @@
49
49
  "pair-control-authority.mjs",
50
50
  "event-id.mjs",
51
51
  "event-bounds.mjs",
52
+ "replay-transport.mjs",
52
53
  "plan-meters.mjs",
53
54
  "recap.mjs",
54
55
  "transcript-sanitize.mjs",
@@ -0,0 +1,64 @@
1
+ // Replay transport policy. Reconnects are allowed to supersede older work for
2
+ // the same viewer, and frames are deliberately paced so live events and Stop /
3
+ // permission traffic are not trapped behind a multi-megabyte replay burst.
4
+
5
+ export function requestedReplayIds(entries, terms, priority) {
6
+ const requested = Array.isArray(terms) ? new Set(terms.filter(Boolean)) : null
7
+ return [...entries]
8
+ .filter(([id]) => !requested || requested.has(id))
9
+ .sort(([a], [b]) => a === priority ? -1 : b === priority ? 1 : 0)
10
+ }
11
+
12
+ const defaultYield = () => new Promise((resolve) => setTimeout(resolve, 0))
13
+
14
+ export function createLatestReplayPump({ send, yieldTurn = defaultYield } = {}) {
15
+ if (typeof send !== 'function') throw new TypeError('replay pump requires send(frame)')
16
+ const viewers = new Map()
17
+
18
+ const run = async (viewer, state) => {
19
+ if (state.running) return
20
+ state.running = true
21
+ try {
22
+ while (state.frames.length) {
23
+ const generation = state.generation
24
+ const frame = state.frames.shift()
25
+ await send(frame)
26
+ await yieldTurn()
27
+ // enqueue() replaces the remaining frames. The send already in flight is
28
+ // allowed to finish; nothing older can start after the replacement.
29
+ if (generation !== state.generation) continue
30
+ }
31
+ const done = state.onDone
32
+ state.onDone = null
33
+ if (done) await done()
34
+ } finally {
35
+ state.running = false
36
+ if (state.frames.length) void run(viewer, state)
37
+ else if (viewers.get(viewer) === state) viewers.delete(viewer)
38
+ }
39
+ }
40
+
41
+ return {
42
+ enqueue(viewer, frames, onDone = null) {
43
+ const key = String(viewer || '*')
44
+ const state = viewers.get(key) || { generation: 0, frames: [], onDone: null, running: false }
45
+ state.generation += 1
46
+ state.frames = Array.isArray(frames) ? [...frames] : []
47
+ state.onDone = typeof onDone === 'function' ? onDone : null
48
+ viewers.set(key, state)
49
+ void run(key, state)
50
+ return state.generation
51
+ },
52
+ cancel(viewer) {
53
+ const state = viewers.get(String(viewer || '*'))
54
+ if (!state) return false
55
+ state.generation += 1
56
+ state.frames = []
57
+ state.onDone = null
58
+ return true
59
+ },
60
+ pending(viewer) {
61
+ return viewers.get(String(viewer || '*'))?.frames.length || 0
62
+ },
63
+ }
64
+ }