thinkpool-pair 0.7.290 → 0.7.292

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'
@@ -251,7 +252,9 @@ if (argv[0] === 'restart-service') {
251
252
  const arg1 = argv[1] || ''
252
253
  const svcRoom = (!arg1 || arg1.startsWith('-')) ? null : arg1.toUpperCase().trim()
253
254
  const svc = await import('./service.mjs')
254
- const ok = argv.includes('--current') ? svc.restartService(svcRoom) : svc.updateService(svcRoom)
255
+ const ok = argv.includes('--current')
256
+ ? svc.restartService(svcRoom)
257
+ : await svc.updateAndConfirmService(svcRoom)
255
258
  process.exit(ok ? 0 : 1)
256
259
  }
257
260
 
@@ -345,8 +348,12 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
345
348
  installService: ({ room = null, agentCmd } = {}) => { svc.installService(room, agentCmd ? [agentCmd] : []) },
346
349
  uninstallService: ({ room = null } = {}) => { svc.uninstallService(room) },
347
350
  restartService: ({ room = null } = {}) => { svc.restartService(room) },
348
- // Menu and CLI share the same registry-resolve → repin → reload implementation.
349
- restartUpdateService: ({ room = null } = {}) => { svc.updateService(room) },
351
+ // The menu waits for OS-level proof before claiming success. In-service web
352
+ // updates keep using updateService(), whose non-blocking handoff is required
353
+ // because replacing the service tears down its own process tree.
354
+ restartUpdateService: async ({ room = null } = {}) => process.platform === 'win32'
355
+ ? svc.updateService(room)
356
+ : svc.updateAndConfirmService(room),
350
357
  login: async () => { const { runLogin } = await import('./account.mjs'); await runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) },
351
358
  // Provider config is NOT terminal — it writes provider.json and returns to the
352
359
  // menu so you can pick a model, switch back, or do something else. (It used to
@@ -574,6 +581,7 @@ if (process.stdin.isTTY && !headless && process.env.THINKPOOL_PAIR_AUTOUPDATE !=
574
581
  }
575
582
  const name = process.env.TP_NAME || os.userInfo().username || 'host'
576
583
  const BRIDGE_ID = (randomUUID?.().slice(0,8)) || 'bridgexx'
584
+ const BRIDGE_STARTED_AT = Date.now()
577
585
  // host: this machine's short label — os.hostname() with any DNS/domain suffix
578
586
  // stripped and capped ~24 chars. A /code room can be served by different bridges
579
587
  // over time (Max's Mac vs Conrad's Linux box); the client uses this to show WHICH
@@ -957,6 +965,13 @@ function beginStructuredTurn(entry, now = Date.now()) {
957
965
  return true
958
966
  }
959
967
 
968
+ function advanceStructuredTurn(entry, now = Date.now()) {
969
+ entry._turnRev = (Number(entry._turnRev) || 0) + 1
970
+ entry._turnStart = now
971
+ entry._busyAnn = true
972
+ return true
973
+ }
974
+
960
975
  function stampStructuredTurn(entry, event) {
961
976
  if (event && event.turnRev == null && Number(entry?._turnRev) > 0) event.turnRev = entry._turnRev
962
977
  return event
@@ -1064,17 +1079,21 @@ const defendFrame = (event, payload) => {
1064
1079
  return p
1065
1080
  }
1066
1081
 
1067
- const bcast = (event, payload, ch = channel) => {
1082
+ const bcastAwait = async (event, payload, ch = channel) => {
1068
1083
  if (event === 'pty-out' || event === 'code-event') lastActivity = Date.now()
1069
1084
  payload = defendFrame(event, payload)
1070
1085
  try {
1071
1086
  if (ch.channelAdapter?.canPush?.() ?? true) {
1072
- ch.send({ type: 'broadcast', event, payload })
1087
+ return await ch.send({ type: 'broadcast', event, payload })
1073
1088
  } else {
1074
- ch.httpSend(event, payload).catch(() => { /* offline — replay covers it */ })
1089
+ return await ch.httpSend(event, payload)
1075
1090
  }
1076
- } catch { /* noop */ }
1091
+ } catch { return null /* offline — replay covers it */ }
1077
1092
  }
1093
+ const bcast = (event, payload, ch = channel) => { void bcastAwait(event, payload, ch) }
1094
+ const replayPump = createLatestReplayPump({
1095
+ send: ({ event, payload }) => bcastAwait(event, payload),
1096
+ })
1078
1097
 
1079
1098
  // Per-room terminal display names (id -> label), set by the web's `term-rename`.
1080
1099
  // Persisted on the host so a rename is cross-device + survives a bridge restart;
@@ -1093,7 +1112,7 @@ const announce = () => {
1093
1112
  const provNames = providerNameMap()
1094
1113
  const rev = ++announceRev
1095
1114
  return bcast('bridge', {
1096
- v: 2, name, bridge_id: BRIDGE_ID, rev, repo: repoLabel, branch: readBranch(),
1115
+ v: 2, name, bridge_id: BRIDGE_ID, started_at: BRIDGE_STARTED_AT, rev, repo: repoLabel, branch: readBranch(),
1097
1116
  // sdkWarn: the auto-pulled agent SDK failed its boot compatibility smoke test —
1098
1117
  // surfaced so the room can show a banner (turns may misbehave; pin a good SDK).
1099
1118
  ...(sdkStatus.ok === false ? { sdkWarn: `${sdkStatus.version}: ${sdkStatus.reason}` } : {}),
@@ -2876,6 +2895,20 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2876
2895
  // per-machine default (provider.mjs/applyProviderEnv). null for built-in/unknown → the
2877
2896
  // default Claude env is left exactly as-is (unchanged path).
2878
2897
  env: { ...process.env, ...buildConductorEnv({ flowSessionId, mode }), ...(resolveProviderEnv(provider) || {}), TP_MOCKUP_OUTBOX: mockupOutbox },
2898
+ onTurnStart: (options = {}) => {
2899
+ // Hermes promotes /queue items internally, without a second code-turn.
2900
+ // Advance the lifecycle before its first output and publish the deferred
2901
+ // human line under the new turn revision, keeping all viewers converged.
2902
+ if (entry._busyAnn === true) advanceStructuredTurn(entry)
2903
+ else beginStructuredTurn(entry)
2904
+ const queuedEcho = options._thinkpoolQueuedEcho
2905
+ if (queuedEcho) {
2906
+ const evt = { kind: 'you', ...queuedEcho }
2907
+ pushLog(entry, evt)
2908
+ bcast('code-event', { term: id, evt })
2909
+ }
2910
+ announce()
2911
+ },
2879
2912
  onEvent: (evt) => {
2880
2913
  if (!classifyCodeEvent(evt).known) {
2881
2914
  // Never archive or replay an unknown provider payload as a known fact. Do
@@ -2933,7 +2966,9 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2933
2966
  // replay-union dedupes ONLY by cid, and SDK events carry none — without an
2934
2967
  // id, an event that arrives both live AND in a reconnect replay renders
2935
2968
  // twice (the 2026-06-19 duplicate-message bug). See event-id.mjs.
2936
- const busyChanged = syncStructuredTurn(entry)
2969
+ const continuesQueued = runtime === 'hermes' && (evt.kind === 'result' || evt.kind === 'error') && (entry.session?.queuedDepth || 0) > 0
2970
+ if (continuesQueued) evt.continuesQueued = true
2971
+ const busyChanged = continuesQueued ? false : syncStructuredTurn(entry)
2937
2972
  stampStructuredTurn(entry, evt)
2938
2973
  stampEvent(evt)
2939
2974
  const stalledChanged = evt.kind === 'stalled' ? !entry.stalled : !!entry.stalled
@@ -3651,14 +3686,17 @@ channel
3651
3686
  // Flush pending bytes first so the replay is complete up to "now"; both
3652
3687
  // ride the same ordered socket, so the client sees replay-then-live.
3653
3688
  flushAll()
3654
- for (const [id, t] of terms) {
3689
+ const to = payload?.to ?? null
3690
+ const requestId = String(payload?.requestId || randomUUID?.() || `${Date.now()}`)
3691
+ const frames = []
3692
+ for (const [id, t] of requestedReplayIds(terms, payload?.terms, payload?.priority)) {
3655
3693
  if (!t.scrollback) continue
3656
3694
  // Cap scrollback too — a huge PTY buffer would blow the same frame limit.
3657
3695
  const sb = t.scrollback.length > 100000 ? t.scrollback.slice(-100000) : t.scrollback
3658
- bcast('pty-replay', {
3659
- to: payload?.to ?? null, term: id,
3696
+ frames.push({ event: 'pty-replay', payload: {
3697
+ to, term: id, requestId,
3660
3698
  b64: Buffer.from(sb, 'utf8').toString('base64'),
3661
- })
3699
+ } })
3662
3700
  }
3663
3701
  // Structured sessions replay their event log (reader rebuilds from it).
3664
3702
  // C1 (RT-2): send only the tail past the client's per-term cursor (seqHi) when
@@ -3669,14 +3707,13 @@ channel
3669
3707
  // cursor) still gets the full log.
3670
3708
  // Hydrate the viewed lane first. Other terminals still warm in the background,
3671
3709
  // 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)
3710
+ const replaySessions = requestedReplayIds(sessions, payload?.terms, payload?.priority)
3674
3711
  for (const [id, s] of replaySessions) {
3675
3712
  // An explicit empty replay is an acknowledgement, not data. It lets mixed
3676
3713
  // clients clear their loading cover even if they missed the announce's
3677
3714
  // hasTranscript:false state.
3678
3715
  if (!s.log.length) {
3679
- bcast('code-replay', { to: payload?.to ?? null, term: id, events: [], empty: true })
3716
+ frames.push({ event: 'code-replay', payload: { to, term: id, requestId, chunkIndex: 0, chunkCount: 1, events: [], empty: true } })
3680
3717
  continue
3681
3718
  }
3682
3719
  const from = Number(payload?.cursors?.[id]) || 0
@@ -3716,10 +3753,14 @@ channel
3716
3753
  // trimmedBefore rides the FIRST (oldest) chunk only — the later chunks are contiguous
3717
3754
  // with it, so a stamp there would read as a second, phantom gap. Same rule as
3718
3755
  // 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
3756
+ for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
3757
+ const events = chunks[chunkIndex]
3758
+ frames.push({ event: 'code-replay', payload: {
3759
+ to, term: id, requestId, chunkIndex, chunkCount: chunks.length, events,
3760
+ ...(ahead ? { reset: true } : {}),
3761
+ ...(chunkIndex === 0 && trimmedBefore != null ? { trimmedBefore } : {}),
3762
+ ...(chunkIndex === 0 && s.lastUsage?.ctx ? { lastUsage: s.lastUsage } : {}),
3763
+ } })
3723
3764
  }
3724
3765
  }
3725
3766
  // Re-send any still-pending permission/question cards. They ride a one-shot
@@ -3728,10 +3769,15 @@ channel
3728
3769
  // (unanswered) tool row — the AskUserQuestion "vanished on reconnect" bug. Only
3729
3770
  // truly-unresolved cards remain in `pending` (resolved ones are deleted), and
3730
3771
  // 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()
3772
+ // One awaited frame per event-loop turn. A newer request from this viewer
3773
+ // replaces every unsent frame from the older one, so visibility/online races
3774
+ // cannot multiply a cold replay into several concurrent megabyte bursts.
3775
+ replayPump.enqueue(to || '*', frames, async () => {
3776
+ for (const [, s] of sessions) {
3777
+ for (const [, p] of s.pending) await bcastAwait('code-perm-req', p.payload)
3778
+ }
3779
+ announce()
3780
+ })
3735
3781
  })
3736
3782
  // "Load earlier history": serve an OLDER page from the durable JSONL archive (the
3737
3783
  // events the in-memory 2000-cap evicted). beforeSeq anchors the page at the client's
@@ -3750,11 +3796,12 @@ channel
3750
3796
  if (!servesHistoryPage({ payloadHost: payload?.host, bridgeName: name, hasSession: sessions.has(id), hasArchive: hasDurableArchive(room, id) })) return
3751
3797
  const { events, hasMore } = readDurablePage(room, id, Number(payload?.beforeSeq) || null, 200)
3752
3798
  const to = payload?.to ?? null
3753
- if (!events.length) { bcast('history-page', { to, term: id, events: [], hasMore: false }); return }
3799
+ const requestId = String(payload?.requestId || randomUUID?.() || `${Date.now()}`)
3800
+ if (!events.length) { bcast('history-page', { to, term: id, requestId, chunkIndex: 0, chunkCount: 1, events: [], hasMore: false }); return }
3754
3801
  const chunks = chunkReplayEvents(events.map((e) => boundEventForBroadcast(e)))
3755
3802
  // hasMore rides only the FIRST (oldest) chunk so the client sets the floor flag once
3756
3803
  // 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 }))
3804
+ chunks.forEach((evs, i) => bcast('history-page', { to, term: id, requestId, chunkIndex: i, chunkCount: chunks.length, events: evs, hasMore: i === 0 ? hasMore : true }))
3758
3805
  })
3759
3806
  .on('broadcast', { event: 'file-put' }, ({ payload }) => {
3760
3807
  if (!payload?.id || !payload?.url) return
@@ -4030,13 +4077,24 @@ channel
4030
4077
  const nativeImages = s.runtime === 'codex' || s.runtime === 'hermes'
4031
4078
  ? await waitForNativeImages(payload.files, { updir: UPDIR })
4032
4079
  : []
4080
+ const deferHermesQueueEcho = s.runtime === 'hermes' && s.session.turnActive && /^\s*\/queue\s+/i.test(text)
4081
+ const turnOptions = {
4082
+ ...(nativeImages.length ? { images: nativeImages } : {}),
4083
+ ...(deferHermesQueueEcho && !payload.silent ? { _thinkpoolQueuedEcho: {
4084
+ text: payload.body != null ? String(payload.body) : text,
4085
+ cid: payload.cid,
4086
+ by: payload.by,
4087
+ ...(Array.isArray(payload.files) && payload.files.length ? { files: payload.files } : {}),
4088
+ ...(Array.isArray(payload.pastes) && payload.pastes.length ? { pastes: payload.pastes } : {}),
4089
+ } } : {}),
4090
+ }
4033
4091
  // Sample the runtime before dispatch so a missed prior falling edge cannot
4034
4092
  // make this genuinely new turn inherit the previous turn's revision.
4035
4093
  syncStructuredTurn(s)
4036
- const accepted = s.session.sendTurn(sendText, nativeImages.length ? { images: nativeImages } : undefined)
4094
+ const accepted = s.session.sendTurn(sendText, Object.keys(turnOptions).length ? turnOptions : undefined)
4037
4095
  if (accepted === false) syncStructuredTurn(s)
4038
4096
  else beginStructuredTurn(s)
4039
- echoYou()
4097
+ if (!deferHermesQueueEcho || accepted === false) echoYou()
4040
4098
  if (accepted === false) {
4041
4099
  // A runtime that did not accept a turn must still close the optimistic
4042
4100
  // user-line lifecycle. Without this boundary the client truthfully shows
@@ -4312,7 +4370,7 @@ channel
4312
4370
  .subscribe(async status => {
4313
4371
  if (status === 'SUBSCRIBED') {
4314
4372
  realtimeHealthy = true; brokenSince = 0
4315
- trackPresence({ name, role: 'bridge', bridge_id: BRIDGE_ID })
4373
+ trackPresence({ name, role: 'bridge', bridge_id: BRIDGE_ID, started_at: BRIDGE_STARTED_AT })
4316
4374
  const startCmd = attachedCmd || autoAgent // autoAgent: account-mode auto-open (headless)
4317
4375
  if (startCmd && !terms.size && !sessions.size) {
4318
4376
  // 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/launcher.mjs CHANGED
@@ -16,7 +16,7 @@ import path from 'node:path'
16
16
  import { execSync } from 'node:child_process'
17
17
  import { detectProvider, fetchModels, ANTHROPIC_COMPATIBLE, gatewayHint } from './byok-detect.mjs'
18
18
  import { probeHermesRuntime } from './hermes-probe.mjs'
19
- import { darwinServiceRunning } from './service.mjs'
19
+ import { darwinServiceRunning, serviceRuntimeVersion } from './service.mjs'
20
20
 
21
21
  const HOME = os.homedir()
22
22
  const CFG_DIR = path.join(HOME, '.thinkpool-pair')
@@ -28,16 +28,8 @@ const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./pack
28
28
 
29
29
  export const KNOWN_AGENTS = [
30
30
  { label: 'Claude Code', cmd: 'claude', resume: ['--continue'] },
31
- { label: 'Codex CLI', cmd: 'codex' },
32
- { label: 'Hermes Agent', cmd: 'thinkpool' },
33
- { label: 'Gemini CLI', cmd: 'gemini' },
34
- { label: 'Aider', cmd: 'aider' },
35
- { label: 'Cursor CLI', cmd: 'cursor-agent' },
36
- { label: 'opencode', cmd: 'opencode' },
37
- { label: 'Copilot CLI', cmd: 'copilot' },
38
- { label: 'Goose', cmd: 'goose' },
39
- { label: 'Crush', cmd: 'crush' },
40
- { label: 'Qwen Code', cmd: 'qwen' },
31
+ { label: 'Codex', cmd: 'codex' },
32
+ { label: 'Hermes', cmd: 'thinkpool' },
41
33
  ]
42
34
 
43
35
  function onPath(c) {
@@ -82,6 +74,7 @@ export function detectState() {
82
74
  const isCustom = !!provider?.provider && provider.provider !== 'anthropic'
83
75
  const hermesInstalled = onPath('hermes')
84
76
  const hermesReady = onPath('thinkpool') && probeHermesRuntime().available
77
+ const accountSvc = serviceLoaded(null)
85
78
  return {
86
79
  loggedIn: !!auth?.refresh_token,
87
80
  email: auth?.email || auth?.user?.email || null,
@@ -90,7 +83,9 @@ export function detectState() {
90
83
  agents: KNOWN_AGENTS.filter(a => onPath(a.cmd) && (a.cmd !== 'thinkpool' || hermesReady)),
91
84
  hermesInstalled,
92
85
  hermesReady,
93
- accountSvc: serviceLoaded(null),
86
+ accountSvc,
87
+ serviceVersion: accountSvc ? serviceRuntimeVersion(null) : null,
88
+ platform: process.platform,
94
89
  cwd: process.cwd(),
95
90
  version: VERSION,
96
91
  }
@@ -107,7 +102,19 @@ const C = {
107
102
  // reflects the change (real bridge passes detectState; tests omit it to keep the
108
103
  // injected state stable).
109
104
  export async function runLauncher({ actions, io, state = detectState(), refresh = null }) {
110
- const resync = () => { if (refresh) state = refresh() }
105
+ const normalizeState = (next) => ({
106
+ ...next,
107
+ agents: Array.isArray(next?.agents) ? next.agents : [],
108
+ loggedIn: !!next?.loggedIn,
109
+ accountSvc: !!next?.accountSvc,
110
+ serviceVersion: typeof next?.serviceVersion === 'string' && next.serviceVersion ? next.serviceVersion : null,
111
+ platform: next?.platform || process.platform,
112
+ provider: next?.provider || 'Anthropic (default)',
113
+ cwd: next?.cwd || process.cwd(),
114
+ version: next?.version || VERSION,
115
+ })
116
+ state = normalizeState(state)
117
+ const resync = () => { if (refresh) state = normalizeState(refresh()) }
111
118
  const askChoice = async (prompt, options, def = 1) => {
112
119
  options.forEach((o, i) => io.print(` ${C.cyan(String(i + 1))}) ${o.label}${o.hint ? ' ' + C.dim(o.hint) : ''}`))
113
120
  const a = await io.ask(`\n ${prompt} ${C.dim(`[${def}]`)} ${C.cyan('▸')} `)
@@ -121,12 +128,17 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
121
128
 
122
129
  const header = () => {
123
130
  io.print('')
124
- io.print(' ' + C.dim('┌ ') + C.bold('thinkpool-pair') + (state.version ? C.dim(` v${state.version}`) : '') + C.dim(' ' + '─'.repeat(state.version ? 40 - (` v${state.version}`).length : 40)))
131
+ io.print(' ' + C.dim('┌ ') + C.bold('thinkpool-pair') + C.dim(' ' + '─'.repeat(40)))
132
+ io.print(` ${C.dim('│ launcher ')} ${state.version ? `v${state.version}` : 'version unavailable'}`)
125
133
  io.print(` ${C.dim('│ account ')} ${state.loggedIn ? C.green((state.email || 'linked') + ' ✓') : C.yellow('not linked')}`)
126
- io.print(` ${C.dim('│ provider ')} ${state.provider}`)
127
- io.print(` ${C.dim('│ agents ')} ${state.agents.length ? state.agents.map(a => a.label).join(', ') : C.yellow('none on PATH')}`)
134
+ io.print(` ${C.dim('│ agents ')} ${state.agents.length ? state.agents.map(a => a.label).join(', ') : C.yellow('none ready')}`)
128
135
  io.print(` ${C.dim('│ directory')} ${C.dim(state.cwd)}`)
129
- io.print(` ${C.dim('│ service ')} ${state.accountSvc ? C.green('account service installed') : 'none'}`)
136
+ const bridgeStatus = !state.accountSvc
137
+ ? 'not installed'
138
+ : state.platform === 'win32'
139
+ ? C.green('startup entry installed')
140
+ : C.green(`running${state.serviceVersion ? ` v${state.serviceVersion}` : ' (version unavailable)'}`)
141
+ io.print(` ${C.dim('│ bridge ')} ${bridgeStatus}`)
130
142
  io.print(' ' + C.dim('└' + '─'.repeat(54)) + '\n')
131
143
  }
132
144
 
@@ -135,7 +147,7 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
135
147
  const pairRoom = async () => {
136
148
  const room = (await io.ask(`\n Room code ${C.dim('(from /code in the web app)')} ${C.cyan('▸')} `)).toUpperCase().trim()
137
149
  if (!room) { io.print(' ' + C.yellow('no room — back to menu')); return }
138
- if (!state.agents.length) { io.print('\n ' + C.yellow('No coding-agent CLI on your PATH (claude / codex / gemini / aider …).')); return }
150
+ if (!state.agents.length) { io.print('\n ' + C.yellow('No supported agent runtime is ready (Claude Code, Codex, or Hermes).')); return }
139
151
  let agent = state.agents[0]
140
152
  if (state.agents.length > 1) { io.print('\n Share which agent?'); agent = state.agents[await askChoice('agent', state.agents.map(a => ({ label: a.label })))] }
141
153
  io.print('\n Run mode?')
@@ -217,30 +229,20 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
217
229
  const settingsMenu = async () => {
218
230
  for (;;) {
219
231
  const options = [
220
- { key: 'provider', label: 'LLM provider', hint: `current: ${state.provider}` },
232
+ { key: 'provider', label: 'Provider', hint: `current: ${state.provider}` },
221
233
  { key: 'account', label: 'Account', hint: state.loggedIn ? `linked: ${state.email || 'yes'}` : 'not linked' },
222
- { key: 'restart', label: 'Restart & update the bridge', hint: state.accountSvc ? 'pull the newest published version, then restart — sessions resume' : 'none installed' },
223
- { key: 'remove', label: 'Remove the background service', hint: state.accountSvc ? '' : 'none installed' },
224
234
  ]
225
- if (state.hermesInstalled) options.push({ key: 'hermes', label: 'Hermes Agent', hint: state.hermesReady ? 'isolated profile ready' : 'set up isolated profile + delegation guard' })
226
- options.push({ key: 'back', label: '← Back to main menu', hint: '' })
235
+ if (state.hermesInstalled) options.push({ key: 'hermes', label: state.hermesReady ? 'Hermes profile' : 'Set up Hermes', hint: state.hermesReady ? 'isolated profile ready' : 'set up isolated profile + delegation guard' })
236
+ options.push({ key: 'back', label: 'Back to main menu', hint: '' })
227
237
  const picked = options[await askChoice('\n change', options, options.length)]
228
238
  if (picked.key === 'back') return
229
239
  if (picked.key === 'provider') await providerMenu()
230
240
  else if (picked.key === 'account') { await actions.login(); resync(); return }
231
- else if (picked.key === 'restart') {
232
- if (state.accountSvc) { await actions.restartUpdateService({ room: null }); resync() }
233
- else io.print(' ' + C.yellow('no background service installed'))
234
- }
235
- else if (picked.key === 'remove') {
236
- if (state.accountSvc) { await actions.uninstallService({ room: null }); resync() }
237
- else io.print(' ' + C.yellow('no background service installed'))
238
- }
239
241
  else if (picked.key === 'hermes') {
240
242
  const setup = await askChoice('\n Hermes profile', [
241
243
  { label: 'Clone active profile', hint: 'copies provider/config explicitly; fresh ThinkPool session history' },
242
244
  { label: 'Create clean profile', hint: 'no copied credentials or bundled skills; configure provider afterward' },
243
- { label: '← Back', hint: '' },
245
+ { label: 'Back', hint: '' },
244
246
  ], 3)
245
247
  if (setup !== 2) { await actions.setupHermes({ mode: setup === 0 ? 'clone' : 'clean' }); resync() }
246
248
  }
@@ -261,14 +263,16 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
261
263
  // Slots 1+2 swap on whether the always-on service is already installed.
262
264
  const items = state.accountSvc
263
265
  ? [
264
- { key: 'restart', label: 'Restart & update the bridge', hint: 'pull the newest version + restart — sessions resume' },
266
+ state.platform === 'win32'
267
+ ? { key: 'restart', label: 'Update startup entry', hint: 'installs the latest version; relaunch the bridge window to apply' }
268
+ : { key: 'restart', label: 'Restart & update bridge', hint: 'install the latest published version; sessions resume' },
265
269
  { key: 'uninstall', label: 'Remove background service', hint: 'stop serving your sessions on this device' },
266
270
  ]
267
271
  : [
268
272
  { key: 'serve', label: 'Serve all my sessions', hint: 'runs here, Ctrl-C stops it' },
269
273
  { key: 'service', label: 'Always-on background service', hint: 'recommended — survives reboot, restarts on crash' },
270
274
  ]
271
- items.push({ key: 'settings', label: 'Settings', hint: 'provider · account · restart · remove service' })
275
+ items.push({ key: 'settings', label: 'Settings', hint: `provider · account${state.hermesInstalled ? ' · Hermes profile' : ''}` })
272
276
  items.push({ key: 'quit', label: 'Quit', hint: '' })
273
277
  const pick = items[await askChoice('choose', items)]
274
278
  if (pick.key === 'quit') 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.292",
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
+ }
package/service.mjs CHANGED
@@ -217,7 +217,7 @@ StandardError=append:${log}
217
217
  [Install]
218
218
  WantedBy=default.target
219
219
  `
220
- return { file, content, logDir, post: ['systemctl --user daemon-reload', `systemctl --user enable --now ${label(room)}.service`], note: 'Enabled as a systemd --user service. Run `loginctl enable-linger $USER` once to keep it running after logout.' }
220
+ return { file, content, logDir, post: ['systemctl --user daemon-reload', `systemctl --user enable ${label(room)}.service`, `systemctl --user restart ${label(room)}.service`], note: 'Enabled as a systemd --user service. Run `loginctl enable-linger $USER` once to keep it running after logout.' }
221
221
  }
222
222
 
223
223
  if (platform === 'win32') {
@@ -242,8 +242,40 @@ WantedBy=default.target
242
242
  // Parse both legacy npx commands (`thinkpool-pair@<ver>`) and stable runtime commands
243
243
  // (`.../runtimes/<ver>/...`).
244
244
  export function parseRunningPairVersions(out) {
245
- const versions = String(out).match(/(?:thinkpool-pair@|runtimes\/)(\d+\.\d+\.\d+)/g) || []
246
- return new Set(versions.map((s) => s.match(/\d+\.\d+\.\d+/)?.[0]).filter(Boolean))
245
+ const versions = String(out).match(/(?:thinkpool-pair@|runtimes[\\/])(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)/g) || []
246
+ return new Set(versions.map((s) => s.match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/)?.[0]).filter(Boolean))
247
+ }
248
+
249
+ // Return a version only when the OS service manager proves that THIS service is
250
+ // both live and configured to execute one unambiguous pair runtime. This is
251
+ // deliberately stricter than isServiceInstalled(): a plist/unit on disk, or a
252
+ // process list that happens to contain thinkpool-pair, is not update evidence.
253
+ // Windows' Startup-folder tier has no service-manager identity to query, so an
254
+ // exact running version cannot be attributed safely and is therefore unproven.
255
+ export function serviceRuntimeSnapshot(room, { platform = process.platform, exec = execSync } = {}) {
256
+ const exactVersion = (output) => {
257
+ const versions = parseRunningPairVersions(output)
258
+ return versions.size === 1 ? [...versions][0] : null
259
+ }
260
+ const id = label(room)
261
+ try {
262
+ if (platform === 'darwin') {
263
+ const out = exec(`launchctl print gui/$(id -u)/${id}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], shell: '/bin/bash' })
264
+ const pid = String(out).match(/(^|\n)[ \t]*pid = (\d+)[ \t]*($|\n)/)?.[2]
265
+ return darwinServiceRunning(out) ? { version: exactVersion(out), pid: pid || null } : null
266
+ }
267
+ if (platform === 'linux') {
268
+ const out = exec(`systemctl --user show ${id}.service --property=ActiveState --property=MainPID --property=ExecStart`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
269
+ const pid = String(out).match(/^MainPID=([1-9]\d*)$/m)?.[1]
270
+ if (!/^ActiveState=active$/m.test(String(out)) || !pid) return null
271
+ return { version: exactVersion(out), pid }
272
+ }
273
+ } catch { /* service manager could not prove a live runtime */ }
274
+ return null
275
+ }
276
+
277
+ export function serviceRuntimeVersion(room, options) {
278
+ return serviceRuntimeSnapshot(room, options)?.version || null
247
279
  }
248
280
 
249
281
  // A macOS update can be invoked FROM a ThinkPool Code room hosted by the very account
@@ -282,7 +314,7 @@ export function buildDarwinReloadHandoff({ targetLabel, targetFile, stagedFile,
282
314
  'launchctl enable "$dom/$target" 2>/dev/null || true',
283
315
  'for i in $(seq 1 30); do launchctl bootstrap "$dom" "$target_file" 2>/dev/null && break; sleep 0.3; done',
284
316
  'for i in $(seq 1 60); do if launchctl print "$dom/$target" 2>/dev/null | grep -F -- "$expected" >/dev/null && launchctl print "$dom/$target" 2>/dev/null | grep -F "state = running" >/dev/null; then ok=1; break; fi; sleep 0.5; done',
285
- `if [ "$ok" = 1 ]; then printf '%s\\n' '${JSON.stringify({ ok: true, version: String(version) })}' > "$tmp"; else rollback=0; if [ "$had_previous" = 1 ]; then launchctl bootout "$dom/$target" 2>/dev/null || true; for i in $(seq 1 40); do launchctl print "$dom/$target" >/dev/null 2>&1 || break; sleep 0.2; done; cp "$backup_file" "$target_file"; launchctl enable "$dom/$target" 2>/dev/null || true; for i in $(seq 1 30); do if launchctl bootstrap "$dom" "$target_file" 2>/dev/null; then rollback=1; break; fi; sleep 0.3; done; fi; if [ "$rollback" = 1 ]; then printf '%s\\n' '${JSON.stringify({ ok: false, version: String(version), error: 'new runtime not confirmed; previous service restored', rolledBack: true })}' > "$tmp"; else printf '%s\\n' '${JSON.stringify({ ok: false, version: String(version), error: 'new runtime not confirmed and rollback failed', rolledBack: false })}' > "$tmp"; fi; fi`,
317
+ `if [ "$ok" = 1 ]; then printf '%s\\n' '${JSON.stringify({ ok: true, version: String(version), target: String(targetLabel) })}' > "$tmp"; else rollback=0; if [ "$had_previous" = 1 ]; then launchctl bootout "$dom/$target" 2>/dev/null || true; for i in $(seq 1 40); do launchctl print "$dom/$target" >/dev/null 2>&1 || break; sleep 0.2; done; cp "$backup_file" "$target_file"; launchctl enable "$dom/$target" 2>/dev/null || true; for i in $(seq 1 30); do if launchctl bootstrap "$dom" "$target_file" 2>/dev/null; then rollback=1; break; fi; sleep 0.3; done; fi; if [ "$rollback" = 1 ]; then printf '%s\\n' '${JSON.stringify({ ok: false, version: String(version), target: String(targetLabel), error: 'new runtime not confirmed; previous service restored', rolledBack: true })}' > "$tmp"; else printf '%s\\n' '${JSON.stringify({ ok: false, version: String(version), target: String(targetLabel), error: 'new runtime not confirmed and rollback failed', rolledBack: false })}' > "$tmp"; fi; fi`,
286
318
  'mv "$tmp" "$status"',
287
319
  'rm -f "$staged_file" "$backup_file" "$helper_file"',
288
320
  'launchctl bootout "$dom/$helper" >/dev/null 2>&1 || true',
@@ -472,21 +504,109 @@ export function updateService(room, { exec = execSync, install = installService,
472
504
  return install(room, [], { version: target, staleProof: true }) !== false
473
505
  }
474
506
 
507
+ // Menu/CLI-only update path. updateService intentionally returns as soon as it has
508
+ // safely staged an update: on macOS it must do that so an independent launchd helper
509
+ // can replace the service without killing its caller mid-transaction. Consumers that
510
+ // need to say "updated" rather than "staged" must use this primitive instead.
511
+ //
512
+ // All dependencies are injectable so this contract can be tested without a service,
513
+ // a clock, or npm. The default timeout exceeds the launchd handoff's bounded reload
514
+ // and verification loops, but is still finite.
515
+ export async function updateAndConfirmService(room, {
516
+ platform = process.platform,
517
+ exec = execSync,
518
+ update = updateService,
519
+ install = installService,
520
+ active = isServiceInstalled,
521
+ snapshot = serviceRuntimeSnapshot,
522
+ readStatus = () => {
523
+ try { return JSON.parse(fs.readFileSync(path.join(os.homedir(), '.thinkpool-pair', 'update-status.json'), 'utf8')) } catch { return null }
524
+ },
525
+ now = Date.now,
526
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
527
+ timeoutMs = 55000,
528
+ pollMs = 250,
529
+ stderr = process.stderr,
530
+ } = {}) {
531
+ const before = snapshot(room, { platform, exec })
532
+ let target = null
533
+ const captureTarget = (...args) => {
534
+ const result = exec(...args)
535
+ const resolved = String(result).trim()
536
+ if (/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(resolved)) target = resolved
537
+ return result
538
+ }
539
+ const staged = update(room, { exec: captureTarget, install, active }) !== false
540
+ if (!staged || !target) {
541
+ stderr.write(' ⚠ update was not confirmed: staging the published runtime failed; the existing service was left unchanged.\n')
542
+ return false
543
+ }
544
+
545
+ const confirmation = () => {
546
+ const live = snapshot(room, { platform, exec })
547
+ const versionChanged = !!before && before.version !== target
548
+ const processChanged = !!before?.pid && !!live?.pid && before.pid !== live.pid
549
+ if (live?.version === target && (versionChanged || processChanged)) {
550
+ stderr.write(` ✓ bridge restart confirmed — running v${target}.\n`)
551
+ return { ok: true, live }
552
+ }
553
+ return { ok: false, live }
554
+ }
555
+ const printUnconfirmed = ({ live } = {}) => {
556
+ if (live?.version && live.version !== target) stderr.write(` ⚠ update v${target} was not confirmed: live service is still v${live.version}.\n`)
557
+ else if (live?.version === target) stderr.write(` ⚠ update v${target} was not confirmed: the service process did not restart.\n`)
558
+ else stderr.write(` ⚠ update v${target} was not confirmed: the service manager cannot prove the new runtime is running.\n`)
559
+ }
560
+
561
+ // systemd's restart is synchronous. The Windows Startup-folder tier has no
562
+ // authoritative live-service identity, so callers use updateService directly
563
+ // and label it as a next-launch update rather than a completed restart.
564
+ if (platform !== 'darwin') {
565
+ const proof = confirmation()
566
+ if (!proof.ok) printUnconfirmed(proof)
567
+ return proof.ok
568
+ }
569
+
570
+ const deadline = now() + Math.max(0, Number(timeoutMs) || 0)
571
+ const expectedTarget = label(room)
572
+ let lastProof = null
573
+ for (;;) {
574
+ const status = readStatus()
575
+ if (status && typeof status === 'object' && status.target === expectedTarget) {
576
+ if (status.ok === false && status.version === target) {
577
+ stderr.write(` ⚠ update v${target} was not confirmed: ${status.error || 'the launchd handoff failed'}.\n`)
578
+ return false
579
+ }
580
+ if (status.ok === true && status.version === target) {
581
+ lastProof = confirmation()
582
+ if (lastProof.ok) return true
583
+ }
584
+ }
585
+ if (now() >= deadline) {
586
+ if (lastProof) printUnconfirmed(lastProof)
587
+ else stderr.write(` ⚠ update v${target} timed out waiting for launchd confirmation; the service may have rolled back.\n`)
588
+ return false
589
+ }
590
+ await sleep(Math.max(1, Number(pollMs) || 1))
591
+ }
592
+ }
593
+
475
594
  // Ground truth: is the OS service manager ACTUALLY running our service right now?
476
595
  // A leftover plist/unit on disk does NOT mean it's loaded — `launchctl load` can
477
596
  // silently no-op (the 2026-06-26 "says installed but it's off" bug), and a unit can
478
597
  // be left disabled. Ask the manager, don't trust file presence. room falsy → account.
479
- export function serviceActive(room) {
480
- const plat = process.platform
598
+ export function serviceActive(room, { platform = process.platform, exec = execSync } = {}) {
599
+ const plat = platform
481
600
  const id = label(room)
482
601
  try {
483
602
  if (plat === 'darwin') {
484
- const out = execSync(`launchctl print gui/$(id -u)/${id}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
603
+ const out = exec(`launchctl print gui/$(id -u)/${id}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
485
604
  return darwinServiceRunning(out)
486
605
  }
487
- if (plat === 'linux') { execSync(`systemctl --user is-active --quiet ${id}.service`); return true }
488
- // Windows has no managed daemon — the Startup-folder script's presence is the only proxy.
489
- return fs.existsSync(buildArtifact(plat, { room }).file)
606
+ if (plat === 'linux') { exec(`systemctl --user is-active --quiet ${id}.service`); return true }
607
+ // A Startup entry is install evidence, never proof that its console process
608
+ // is alive. Windows has no per-user manager we can query authoritatively.
609
+ return false
490
610
  } catch { return false }
491
611
  }
492
612
 
@@ -495,6 +615,9 @@ export function serviceActive(room) {
495
615
  // service. Reflects launchd/systemd reality, not just file existence (so a plist that
496
616
  // failed to load reads as not-installed → the menu re-offers install, which self-heals).
497
617
  export function isServiceInstalled(room) {
618
+ if (process.platform === 'win32') {
619
+ try { return fs.existsSync(buildArtifact('win32', { room }).file) } catch { return false }
620
+ }
498
621
  return serviceActive(room)
499
622
  }
500
623