thinkpool-pair 0.7.343 → 0.7.345

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
@@ -504,6 +504,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
504
504
  let applyRequested = false // a user clicked "apply" in some room (Slice 3)
505
505
  let pendingUpdate = null // newest published version once the poll sees it
506
506
  let applyingUpdate = false
507
+ const restartProbes = new Map() // nonce -> { pending rooms, ok, timer, resolve }
507
508
  const warned = new Set()
508
509
  const refused = new Map() // room -> reason ('home'|'none'|'dir-gone'): owned but NOT served, announced in presence so the web shows a precise "attach this session" card instead of spinning
509
510
 
@@ -1064,6 +1065,18 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
1064
1065
  void applyIfIdle()
1065
1066
  }
1066
1067
  else if (m.t === 'busy') { childIdle.set(room, false); childBetween.set(room, false) }
1068
+ else if (m.t === 'restart-probe-result') {
1069
+ const probe = restartProbes.get(m.nonce)
1070
+ if (!probe || !probe.pending.has(room) || children.get(room) !== child) return
1071
+ probe.pending.delete(room)
1072
+ probe.ok = probe.ok && m.between === true
1073
+ childBetween.set(room, m.between === true)
1074
+ if (!probe.pending.size) {
1075
+ clearTimeout(probe.timer)
1076
+ restartProbes.delete(m.nonce)
1077
+ probe.resolve(probe.ok)
1078
+ }
1079
+ }
1067
1080
  else if (m.t === 'apply-update') { applyRequested = true; applyIfIdle() } // user clicked the chip
1068
1081
  // ── Thinkpool Ensemble cross-ROOM routing ──────────────────────────────
1069
1082
  // `room` here is the room that sent the message (this closure is per-child).
@@ -1312,9 +1325,44 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
1312
1325
  // Restart the account bridge to apply a pending update ONLY when it's safe: every
1313
1326
  // child idle (between turns) AND either a user clicked apply OR nobody's watching
1314
1327
  // (unattended fallback). The predicate is unit-tested (tests/update-gate.test.mjs).
1328
+ function confirmChildrenBetweenTurns(timeoutMs = 2500) {
1329
+ const targets = [...children.entries()]
1330
+ if (!targets.length) return Promise.resolve(true)
1331
+ const nonce = randomUUID()
1332
+ return new Promise((resolve) => {
1333
+ const probe = { pending: new Set(targets.map(([room]) => room)), ok: true, resolve, timer: null }
1334
+ probe.timer = setTimeout(() => {
1335
+ restartProbes.delete(nonce)
1336
+ resolve(false)
1337
+ }, timeoutMs)
1338
+ probe.timer.unref?.()
1339
+ restartProbes.set(nonce, probe)
1340
+ for (const [room, child] of targets) {
1341
+ try { child.send({ t: 'restart-probe', nonce }) }
1342
+ catch {
1343
+ probe.pending.delete(room)
1344
+ probe.ok = false
1345
+ }
1346
+ }
1347
+ if (!probe.pending.size) {
1348
+ clearTimeout(probe.timer)
1349
+ restartProbes.delete(nonce)
1350
+ resolve(probe.ok)
1351
+ }
1352
+ })
1353
+ }
1354
+
1315
1355
  async function applyIfIdle() {
1316
1356
  if (applyingUpdate || !isSafeToRestart({ pendingUpdate, stopping, childIdle, childBetween, childPeer, requested: applyRequested })) return
1317
1357
  applyingUpdate = true
1358
+ // The 5s heartbeat is a dashboard signal, not an atomic restart barrier. Re-read
1359
+ // every child at the destructive edge so a turn that began after the last sample
1360
+ // vetoes the update instead of becoming an interrupted recovery.
1361
+ if (!await confirmChildrenBetweenTurns()) {
1362
+ applyingUpdate = false
1363
+ process.stderr.write('\n ◆ bridge update still queued — an agent became active before the restart boundary.\n')
1364
+ return
1365
+ }
1318
1366
  process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting account bridge to update; sessions resume.\n`)
1319
1367
  try {
1320
1368
  const svc = await import('./service.mjs')
package/bridge.mjs CHANGED
@@ -108,6 +108,7 @@ import { canDispatch, FLOW_LIMITS, makeBudget, recordSpend, killSwitchEnv } from
108
108
  // Spec: docs/specs/2026-06-30-flow-build-s4-clean-redispatch.md
109
109
  import { prepareRedispatch, redispatchKey } from './flow-redispatch.mjs'
110
110
  import { sanitizeSession } from './transcript-sanitize.mjs'
111
+ import { boundStructuredLog, STRUCTURED_LOG_BYTE_CAP } from './event-bounds.mjs'
111
112
  import { executeFlowRevert } from './flow-host-revert.mjs'
112
113
  // ACCEPTED LIMITATION — this registry is in-memory only. A bridge restart in the window between
113
114
  // a flow-revert kill and the next dispatch wave loses the pending resume record, so the task
@@ -742,6 +743,33 @@ function readBranch() {
742
743
  const UPDIR = path.join(os.tmpdir(), 'thinkpool-pair', room)
743
744
  const FILE_MAX_BYTES = 25 * 1024 * 1024 // must match web FILE_MB + bucket file_size_limit (25 MB) — see src/pages/code/code-constants.js
744
745
  const safeName = (n) => String(n || 'file').replace(/[^a-zA-Z0-9._-]/g, '_').slice(-80)
746
+ const roomAttachmentStorageUrl = (storagePath) => {
747
+ const value = String(storagePath || '')
748
+ const prefix = `code/${room}/`
749
+ if (!value.startsWith(prefix) || value.includes('..') || value.split('/').some((part) => !part)) return null
750
+ return `${SUPABASE_URL}/storage/v1/object/authenticated/session-attachments/${value.split('/').map(encodeURIComponent).join('/')}`
751
+ }
752
+ const fetchRoomAttachment = async (payload) => {
753
+ const authenticated = codeAuthToken && roomAttachmentStorageUrl(payload?.path)
754
+ const candidates = [
755
+ { url: payload?.url, headers: undefined },
756
+ ...(authenticated ? [{ url: authenticated, headers: { apikey: SUPABASE_ANON, Authorization: `Bearer ${codeAuthToken}` } }] : []),
757
+ ].filter((candidate) => candidate.url)
758
+ let lastError = new Error('no attachment source')
759
+ for (let attempt = 0; attempt < 3; attempt++) {
760
+ for (const candidate of candidates) {
761
+ try {
762
+ const response = await fetch(candidate.url, { headers: candidate.headers })
763
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
764
+ return Buffer.from(await response.arrayBuffer())
765
+ } catch (error) {
766
+ lastError = error
767
+ }
768
+ }
769
+ if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1)))
770
+ }
771
+ throw lastError
772
+ }
745
773
 
746
774
  const supabase = createClient(SUPABASE_URL, SUPABASE_ANON, {
747
775
  realtime: { params: { eventsPerSecond: 60 } },
@@ -2129,12 +2157,11 @@ function pushLog(entry, evt) {
2129
2157
  // filters out (system/thinking_tokens/effort) ride the log seq-less so they don't
2130
2158
  // hole the client's applied sequence. See event-id.mjs NO_SEQ_KINDS.
2131
2159
  if (entry.seq && seqable(evt)) evt.seq = entry.seq.next()
2132
- entry.log.push(evt)
2133
2160
  // Durable, UNBOUNDED archive (powers "load earlier history"): append the transcript
2134
- // event before the in-memory cap evicts it. Only seqable kinds — chrome/thinking noise
2135
- // is skipped. entry.id/entry.room are stamped in openStructured; a PTY entry (no id)
2136
- // is a no-op. This is the single place every new event passes through, so nothing is
2137
- // missed and reloaded events (which don't ride pushLog) never double-append.
2161
+ // event in full before the bounded replay copy enters memory. Only seqable kinds —
2162
+ // chrome/thinking noise is skipped. entry.id/entry.room are stamped in
2163
+ // openStructured; a PTY entry (no id) is a no-op. This is the single place every new
2164
+ // event passes through, so nothing is missed and reloaded events never double-append.
2138
2165
  if (entry.id && seqable(evt)) {
2139
2166
  appendDurableEvents(entry.room, entry.id, [evt])
2140
2167
  // First archived event sets the floor for a fresh lane (no seed ran). Lets the
@@ -2142,7 +2169,17 @@ function pushLog(entry, evt) {
2142
2169
  // above this floor — a new lane's first turn at seq>1 is NOT older-history.
2143
2170
  if (entry.archiveOldestSeq == null) entry.archiveOldestSeq = evt.seq
2144
2171
  }
2145
- if (entry.log.length > STRUCTURED_LOG_MAX) entry.log.shift()
2172
+ // The rolling replay log is also the restart snapshot. Keep it browser-sized here,
2173
+ // not only at broadcast time: otherwise one giant tool result is copied into every
2174
+ // debounced snapshot and turns a bridge restart into tens of MB of synchronous JSON
2175
+ // parsing/writing. The complete event remains in the append-only JSONL archive above.
2176
+ const replayEvent = boundEventForBroadcast(evt)
2177
+ entry.log.push(replayEvent)
2178
+ entry.logBytes = (entry.logBytes || 0) + JSON.stringify(replayEvent).length + 1
2179
+ while (entry.log.length > STRUCTURED_LOG_MAX || (entry.log.length > 1 && entry.logBytes > STRUCTURED_LOG_BYTE_CAP)) {
2180
+ const removed = entry.log.shift()
2181
+ entry.logBytes -= JSON.stringify(removed).length + 1
2182
+ }
2146
2183
  return evt
2147
2184
  }
2148
2185
 
@@ -2230,7 +2267,10 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2230
2267
  ? spawnDepth
2231
2268
  : (sideParent || (spawnedBy && !String(spawnedBy).startsWith('flow:')) ? 1 : 0)
2232
2269
  const initialHop = Number.isInteger(hop) && hop >= 0 ? hop : structuralDepth
2233
- const entry = { cmd: runtime, runtime, kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: commandCatalogForRuntime(runtime, commands), mode, effort, models: runtime === 'codex' ? codexModels : runtime === 'hermes' ? (Array.isArray(models) && models.length ? models : hostHermesModels) : undefined,
2270
+ const restoredRawLog = Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : []
2271
+ const restoredLog = boundStructuredLog(restoredRawLog)
2272
+ const restoredOffset = restoredRawLog.length - restoredLog.length
2273
+ const entry = { cmd: runtime, runtime, kind: 'structured', log: restoredLog, pending: new Map(), session: null, recovered: false, commands: commandCatalogForRuntime(runtime, commands), mode, effort, models: runtime === 'codex' ? codexModels : runtime === 'hermes' ? (Array.isArray(models) && models.length ? models : hostHermesModels) : undefined,
2234
2274
  // model: truthful active-model label — now the SAME `laneModel` the SDK is given, so the
2235
2275
  // chip cannot disagree with the wire. When this lane runs on a custom (non-anthropic)
2236
2276
  // registered provider the SDK id is impersonated (see the onEvent guard below), so
@@ -2255,7 +2295,12 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2255
2295
  // Stable creation order — persisted so a bridge restart restores tabs in the SAME
2256
2296
  // order (not readdir/filesystem order). Legacy recs (no openedAt) derive it from the
2257
2297
  // first transcript event ts, so even the first post-fix restart is ordered right.
2258
- openedAt: openedAt || (Array.isArray(log) ? (log.find((e) => e?.ts)?.ts || 0) : 0) || Date.now() }
2298
+ openedAt: openedAt || (Array.isArray(log) ? (log.find((e) => e?.ts)?.ts || 0) : 0) || Date.now(),
2299
+ // A legacy bridge may have persisted raw multi-megabyte events. The restored
2300
+ // in-memory copy is already bounded; rewrite that compact form only after the
2301
+ // room has announced ready so migration cannot extend the loading screen.
2302
+ snapshotNeedsCompaction: restoredOffset > 0 || restoredLog.some((event, index) => event !== restoredRawLog[index + restoredOffset]),
2303
+ logBytes: restoredLog.reduce((bytes, event) => bytes + JSON.stringify(event).length + 1, 0) }
2259
2304
  entry._turnRev = entry.log.reduce((max, event) => Math.max(max, Number(event?.turnRev) || 0), 0)
2260
2305
  const restoredBoundary = [...entry.log].reverse().find((event) => event?.kind === 'result' && Number(event?.turnRev) > 0)
2261
2306
  entry._settledTurnRev = Number(restoredBoundary?.turnRev) || null
@@ -4356,9 +4401,7 @@ channel
4356
4401
  try {
4357
4402
  const fp = canonicalRoomFilePath(payload, { updir: UPDIR })
4358
4403
  if (!fp) throw new Error('invalid attachment metadata')
4359
- const r = await fetch(payload.url)
4360
- if (!r.ok) throw new Error(`HTTP ${r.status}`)
4361
- const buf = Buffer.from(await r.arrayBuffer())
4404
+ const buf = await fetchRoomAttachment(payload)
4362
4405
  if (buf.length > FILE_MAX_BYTES) throw new Error('file too large')
4363
4406
  fs.mkdirSync(UPDIR, { recursive: true })
4364
4407
  fs.writeFileSync(fp, buf)
@@ -4533,7 +4576,7 @@ channel
4533
4576
  // two conversations. Drop the cached floor with it: the next pushLog re-seeds
4534
4577
  // archiveOldestSeq from the FIRST post-clear event (the new epoch's floor), instead
4535
4578
  // of leaving a stale pre-clear floor to mis-aim trimmedBeforeSeq's phantom-pill guard.
4536
- s.log = []; s.seq = makeSeqCounter(0); s.archiveOldestSeq = null
4579
+ s.log = []; s.logBytes = 0; s.seq = makeSeqCounter(0); s.archiveOldestSeq = null
4537
4580
  bcast('code-event', { term: payload.term, evt: { kind: 'clear' } })
4538
4581
  flushSession(room, payload.term, { sessionId: s.session?.sessionId || null, log: [] })
4539
4582
  ctlLine('context cleared. You can continue with these answers in mind.')
@@ -4798,6 +4841,7 @@ channel
4798
4841
  realtimeHealthy = true; brokenSince = 0
4799
4842
  trackPresence({ name, role: 'bridge', bridge_id: BRIDGE_ID, started_at: BRIDGE_STARTED_AT })
4800
4843
  const startCmd = attachedCmd || autoAgent // autoAgent: account-mode auto-open (headless)
4844
+ const interruptedRecoveries = []
4801
4845
  if (startCmd && !terms.size && !sessions.size) {
4802
4846
  // Claude + structured mode → Agent SDK session; everything else → PTY.
4803
4847
  // On restart, restore EVERY saved structured session for this room (not
@@ -4851,9 +4895,8 @@ channel
4851
4895
  // Every interrupted role resumes exactly once. Flow lanes do not have a
4852
4896
  // startup redispatch path; excluding them here stranded conductors/builders.
4853
4897
  if (re && wasInterrupted) {
4854
- const recovery = recoverInterruptedTurn(re, { resumable, recap: recoveryRecap })
4855
- if (recovery === 'sent') process.stderr.write(`\n ◆ auto-resumed interrupted Codex turn (${rec.id.slice(0, 8)}) — sent continue.\n`)
4856
- else if (recovery === 'recap-sent') process.stderr.write(`\n ◆ resumed interrupted Codex turn (${rec.id.slice(0, 8)}) with a fresh context recap.\n`)
4898
+ re.pendingInterruptedRecovery = { resumable, recap: recoveryRecap }
4899
+ interruptedRecoveries.push(re)
4857
4900
  }
4858
4901
  }
4859
4902
  // Fresh open: ONLY for an explicit `-- <claude>` share. In account mode
@@ -4888,6 +4931,31 @@ channel
4888
4931
  if (process.send && process.env.THINKPOOL_PAIR_ACCOUNT_CHILD === '1') {
4889
4932
  try { process.send({ t: 'room-ready', room, bridgeId: BRIDGE_ID }) } catch { /* supervisor exited */ }
4890
4933
  }
4934
+ // The room is usable before any runtime processes cold-boot. Stagger interrupted
4935
+ // resumes so several Codex/Claude MCP handshakes cannot stampede the same host and
4936
+ // starve the loopback MCP server. A person speaking during the delay cancels that
4937
+ // lane's stale auto-continue via supersedeInterruptedResume().
4938
+ interruptedRecoveries.forEach((entry, index) => {
4939
+ const timer = setTimeout(() => {
4940
+ const pending = entry.pendingInterruptedRecovery
4941
+ entry.pendingInterruptedRecovery = null
4942
+ if (!pending || sessions.get(entry.id) !== entry) return
4943
+ const recovery = recoverInterruptedTurn(entry, pending)
4944
+ if (recovery === 'sent') process.stderr.write(`\n ◆ auto-resumed interrupted Codex turn (${entry.id.slice(0, 8)}) — sent continue.\n`)
4945
+ else if (recovery === 'recap-sent') process.stderr.write(`\n ◆ resumed interrupted Codex turn (${entry.id.slice(0, 8)}) with a fresh context recap.\n`)
4946
+ }, 1000 + index * 5000)
4947
+ timer.unref?.()
4948
+ })
4949
+ // Compact legacy oversized snapshots after readiness. Each entry already holds the
4950
+ // bounded form, so this writes the small replacement without keeping the dashboard
4951
+ // or room bootstrap behind fsyncs.
4952
+ setImmediate(() => {
4953
+ for (const entry of sessions.values()) {
4954
+ if (!entry.snapshotNeedsCompaction) continue
4955
+ entry.snapshotNeedsCompaction = false
4956
+ try { entry.flush?.() } catch { /* keep the prior recoverable snapshot */ }
4957
+ }
4958
+ })
4891
4959
  process.stderr.write(headless
4892
4960
  ? `\n ◆ thinkpool — relaying room ${room} (headless). Open terminals from the web UI.\n\n`
4893
4961
  : `\n ◆ thinkpool — sharing "${attachedCmd}"${continuing ? ' (continuing your latest session)' : ''} into room ${room}. Open the web UI and you're both in.\n\n`)
@@ -5411,6 +5479,9 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
5411
5479
  if (!m) return
5412
5480
  if (m.t === 'update-available') surfaceUpdate(m.version)
5413
5481
  else if (m.t === 'agent-visibility-changed') void announce()
5482
+ else if (m.t === 'restart-probe') {
5483
+ try { process.send({ t: 'restart-probe-result', nonce: m.nonce, between: betweenTurns() }) } catch { /* parent is leaving */ }
5484
+ }
5414
5485
  // Supervisor rotated the owner JWT (account.mjs scheduleTokenRefresh) — adopt
5415
5486
  // it so our authed writes (code-mockup) never go stale on a long session (L16).
5416
5487
  else if (m.t === 'token') {
@@ -26,19 +26,24 @@ export async function startCodexMcpHttp({ sdkServer, host = '127.0.0.1' } = {})
26
26
  // Keep exactly one active child session per lane. A new initialize atomically
27
27
  // retires the old server+transport before receiving its own isolated instance.
28
28
  let active = null
29
+ let handoffChain = Promise.resolve()
29
30
 
30
- const retireActive = async () => {
31
+ const retireActive = () => {
31
32
  const current = active
32
33
  active = null
33
- if (!current) return
34
- await Promise.allSettled([
34
+ if (!current) return Promise.resolve()
35
+ // Detach first, then clean up in the background. In production an App
36
+ // Server MCP transport remained stuck in close while its watchdog fallback
37
+ // was already waiting to initialize `codex exec`; awaiting this cleanup
38
+ // consumed the replacement client's entire 30-second handshake budget.
39
+ return Promise.allSettled([
35
40
  current.instance.close(),
36
41
  current.transport.close(),
37
42
  ])
38
43
  }
39
44
 
40
45
  const beginSession = async () => {
41
- await retireActive()
46
+ void retireActive()
42
47
  const next = createSessionServer()
43
48
  const instance = next?.instance
44
49
  if (!instance?.connect) throw new TypeError('Codex MCP session factory did not return an SDK MCP server instance')
@@ -48,6 +53,12 @@ export async function startCodexMcpHttp({ sdkServer, host = '127.0.0.1' } = {})
48
53
  return active
49
54
  }
50
55
 
56
+ const serializeHandoff = (task) => {
57
+ const run = handoffChain.then(task, task)
58
+ handoffChain = run.catch(() => {})
59
+ return run
60
+ }
61
+
51
62
  const server = http.createServer(async (req, res) => {
52
63
  if (req.url !== secretPath) {
53
64
  res.writeHead(404).end('not found')
@@ -61,7 +72,14 @@ export async function startCodexMcpHttp({ sdkServer, host = '127.0.0.1' } = {})
61
72
  res.writeHead(400).end('missing session')
62
73
  return
63
74
  }
64
- target = await beginSession()
75
+ // Serialize replacement initialize requests through their response
76
+ // boundary. This removes the window where two simultaneous children
77
+ // both observe no active session and orphan each other's transport.
78
+ await serializeHandoff(async () => {
79
+ target = await beginSession()
80
+ await target.transport.handleRequest(req, res)
81
+ })
82
+ return
65
83
  } else if (!target?.transport?.sessionId || sessionId !== target.transport.sessionId) {
66
84
  res.writeHead(404).end('session not found')
67
85
  return
@@ -88,11 +106,24 @@ export async function startCodexMcpHttp({ sdkServer, host = '127.0.0.1' } = {})
88
106
  let closed = false
89
107
  return {
90
108
  url,
109
+ async handoff() {
110
+ if (closed) return
111
+ await serializeHandoff(async () => { void retireActive() })
112
+ },
91
113
  async close() {
92
114
  if (closed) return
93
115
  closed = true
116
+ const closing = retireActive()
117
+ // A retired SDK server may itself be wedged. Closing the loopback listener
118
+ // must not keep the bridge process alive indefinitely on shutdown.
94
119
  await Promise.allSettled([
95
- retireActive(),
120
+ Promise.race([
121
+ closing,
122
+ new Promise((resolve) => {
123
+ const timer = setTimeout(resolve, 1000)
124
+ timer.unref?.()
125
+ }),
126
+ ]),
96
127
  new Promise((resolve) => server.close(resolve)),
97
128
  ])
98
129
  },
package/codex-session.mjs CHANGED
@@ -303,6 +303,19 @@ export function buildCodexExecArgs({ sessionId, model, effort, sandbox, approval
303
303
  return args
304
304
  }
305
305
 
306
+ // `codex exec` can fail before it has created or resumed a native turn when a
307
+ // required MCP client loses the App Server -> exec handoff race. Retrying is
308
+ // safe only when stderr proves session initialization failed AND the JSONL
309
+ // stream contains no turn/item activity. A silent process or a generic exit is
310
+ // delivery-uncertain and must never replay the person's prompt automatically.
311
+ export function codexExecFailedBeforeTurn({ stderr = '', eventTypes = [] } = {}) {
312
+ const types = Array.isArray(eventTypes) ? eventTypes.map((type) => String(type || '')) : []
313
+ if (types.some((type) => type && type !== 'thread.started')) return false
314
+ const message = String(stderr || '')
315
+ return /timed out handshaking with MCP server/i.test(message)
316
+ || (/thread\/resume failed/i.test(message) && /failed to initialize session/i.test(message))
317
+ }
318
+
306
319
  const CODEX_DESTRUCTIVE = /\brm\s+\S|\brmdir\s+\S|\bgit\s+(push\s+(-f|--force)|reset\s+--hard|clean\s+-[a-z]*f)|\bdrop\s+(table|database)\b|\b(mkfs|dd)\b|\bsudo\b|>\s*\/dev\/|\bchmod\s+-R|\bchown\s+-R|\bkillall\b|\btruncate\b/i
307
320
 
308
321
  export function codexApprovalCard(method, params = {}, item = null) {
@@ -423,6 +436,10 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
423
436
  let stallRetried = false
424
437
  let stallRetryRequested = false
425
438
  let stallGiveupRequested = false
439
+ // True only while the current logical attempt is still in bridge-owned
440
+ // bootstrap. It flips before writing a turn/start request or spawning exec.
441
+ // Absence of provider output is not proof that a prompt was not delivered.
442
+ let stallReplaySafe = true
426
443
  let awaitingUser = 0
427
444
  let stallTimer = null
428
445
  let activeModel = model || null
@@ -489,6 +506,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
489
506
  stalledSent = false
490
507
  stallRetryRequested = false
491
508
  stallGiveupRequested = false
509
+ stallReplaySafe = true
492
510
  if (!retry) stallRetried = false
493
511
  }
494
512
 
@@ -742,6 +760,17 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
742
760
  })
743
761
  if (action === 'none') return
744
762
 
763
+ // Do not emit the generic "retrying the turn" copy when native delivery is
764
+ // already uncertain; that would promise the exact unsafe action we refuse.
765
+ if (action === 'retry' && !stallReplaySafe) {
766
+ stallGiveupRequested = true
767
+ stallRetryRequested = false
768
+ emitRaw({ kind: 'error', recoverable: true, message: 'Codex stopped responding after the turn began. The transport was stopped, but the prompt was not replayed automatically.' })
769
+ emitTurnBoundary({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: undefined, denials: 0, resultText: null })
770
+ stopStalledTransport({ force: true })
771
+ return
772
+ }
773
+
745
774
  const event = stallEvent(action, quietMs)
746
775
  if (event) emitRaw(event)
747
776
  if (action === 'status') {
@@ -782,6 +811,9 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
782
811
  mapper.setUsageBaseline(sessionId ? readCodexThreadUsage(sessionId) : null)
783
812
  turnActive = true
784
813
  try {
814
+ // startTurn writes the prompt-bearing RPC. From this edge onward delivery
815
+ // is at least uncertain, even if the response or first event never lands.
816
+ stallReplaySafe = false
785
817
  activeTurnId = await appServer.startTurn({
786
818
  threadId: sessionId,
787
819
  input: prompt,
@@ -850,6 +882,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
850
882
  mapper.setUsageBaseline(sessionId ? readCodexThreadUsage(sessionId) : null)
851
883
  turnActive = true
852
884
  try {
885
+ stallReplaySafe = false
853
886
  const started = await appServer.startReview({ threadId: sessionId, target: codexReviewTarget(commandText) })
854
887
  activeTurnId = started?.turn?.id || started?.turnId
855
888
  if (!activeTurnId) throw new Error('Codex review/start returned no turn id')
@@ -903,6 +936,11 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
903
936
  let peer
904
937
  try { peer = await ensureMcpHttp() }
905
938
  catch (e) { note(`Thinkpool peer tools unavailable: ${e?.message || e}`) }
939
+ // Detach any App Server-owned MCP session before exec initializes its own.
940
+ // Cleanup of the old transport is deliberately asynchronous so a wedged
941
+ // close cannot consume Codex's 30-second required-MCP handshake window.
942
+ try { await peer?.handoff?.() }
943
+ catch (e) { note(`Thinkpool peer handoff failed: ${e?.message || e}`) }
906
944
  const args = buildCodexExecArgs({
907
945
  sessionId: turnNo === 0 ? null : sessionId,
908
946
  model: activeModel,
@@ -928,15 +966,18 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
928
966
  if (stdinFd != null) opts.stdio = [stdinFd, 'pipe', 'pipe']
929
967
 
930
968
  turnActive = true
969
+ stallReplaySafe = false
931
970
  child = spawnImpl('codex', args, opts)
932
971
  if (stdinFd != null) { try { fs.closeSync(stdinFd) } catch { /* child owns its duplicated fd */ } }
933
972
 
973
+ const nativeEventTypes = []
934
974
  const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity })
935
975
  rl.on('line', (line) => {
936
976
  if (!line.trim()) return
937
977
  let ev
938
978
  try { ev = JSON.parse(line) } catch { return /* non-JSON progress line */
939
979
  }
980
+ nativeEventTypes.push(ev.type)
940
981
  if (ev.type === 'thread.started' && ev.thread_id) sessionId = sessionId || ev.thread_id
941
982
  if (ev.type === 'turn.completed' || ev.type === 'turn.failed') {
942
983
  sawTerminalResult = true
@@ -955,22 +996,30 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
955
996
  settled = true
956
997
  child = null
957
998
  turnActive = false
958
- if ((stallRetryRequested || stallGiveupRequested) && !sawTerminalResult && !aborted) {
999
+ const failureMessage = `codex exec exited ${code}${stderrTail ? ': ' + stderrTail.trim().slice(-300) : ''}`
1000
+ const preTurnInitFailure = code !== 0 && code != null && !sawTerminalResult && codexExecFailedBeforeTurn({
1001
+ stderr: stderrTail,
1002
+ eventTypes: nativeEventTypes,
1003
+ })
1004
+ if (preTurnInitFailure && !aborted && !stallGiveupRequested) {
1005
+ resolve({ preTurnInitFailure: true, message: failureMessage })
1006
+ return
1007
+ } else if ((stallRetryRequested || stallGiveupRequested) && !sawTerminalResult && !aborted) {
959
1008
  // Watchdog interruption: pump either replays once or has already
960
1009
  // published the terminal giveup boundary.
961
1010
  } else if (aborted && !sawTerminalResult) {
962
1011
  emitTurnBoundary({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: undefined, denials: 0, resultText: null })
963
1012
  } else if (!aborted && !sawTerminalResult && code !== 0 && code != null) {
964
- emitTurnBoundary({ kind: 'error', message: `codex exec exited ${code}${stderrTail ? ': ' + stderrTail.trim().slice(-300) : ''}`, recoverable: true })
1013
+ emitTurnBoundary({ kind: 'error', message: failureMessage, recoverable: true })
965
1014
  }
966
- resolve()
1015
+ resolve({ preTurnInitFailure: false })
967
1016
  }
968
1017
  child.on('error', (e) => {
969
1018
  if (settled) return
970
1019
  settled = true
971
1020
  child = null
972
1021
  if (!stallRetryRequested && !stallGiveupRequested) emitTurnBoundary({ kind: 'error', message: `codex failed to start: ${e.message}`, recoverable: true })
973
- resolve()
1022
+ resolve({ preTurnInitFailure: false })
974
1023
  })
975
1024
  child.on('close', finish)
976
1025
  })
@@ -999,6 +1048,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
999
1048
  })
1000
1049
  const review = /^\s*\/review(?:\s|$)/i.test(String(next.text || ''))
1001
1050
  let retryAttempt = false
1051
+ let execInitRetryUsed = false
1002
1052
  while (!ended && !aborted && !stallGiveupRequested) {
1003
1053
  const usedAppServer = review
1004
1054
  ? await runAppServerReview(next.text)
@@ -1017,7 +1067,16 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
1017
1067
  armTurnLiveness({ retry: true })
1018
1068
  emitRaw({ kind: 'note', text: 'retrying the stalled turn on a fresh connection' })
1019
1069
  }
1020
- await runExec(prompt, next.options)
1070
+ const outcome = await runExec(prompt, next.options)
1071
+ if (outcome?.preTurnInitFailure) {
1072
+ if (!execInitRetryUsed && !ended && !aborted && !stallGiveupRequested) {
1073
+ execInitRetryUsed = true
1074
+ armTurnLiveness({ retry: true })
1075
+ emitRaw({ kind: 'note', text: 'Codex initialization failed before the turn started; retrying once with a fresh ThinkPool connection.' })
1076
+ continue
1077
+ }
1078
+ emitTurnBoundary({ kind: 'error', message: outcome.message, recoverable: true })
1079
+ }
1021
1080
  }
1022
1081
  }
1023
1082
  if (stallRetryRequested && !retryAttempt && !stallGiveupRequested && !ended && !aborted) {
package/event-bounds.mjs CHANGED
@@ -4,6 +4,9 @@
4
4
  replay frame or tx: seed row into a multi-megabyte hydration dependency. */
5
5
 
6
6
  export const STRUCTURED_EVENT_CAP = 80000
7
+ // 14 × the 150KB replay-frame budget: the largest complete rolling window the
8
+ // transport can deliver without trimming. Older events remain in the JSONL archive.
9
+ export const STRUCTURED_LOG_BYTE_CAP = 2100000
7
10
 
8
11
  const sizeOf = (value) => {
9
12
  try { return JSON.stringify(value).length } catch { return Infinity }
@@ -94,3 +97,21 @@ export function boundStructuredEvent(event, cap = STRUCTURED_EVENT_CAP) {
94
97
  identity._notice = 'Large tool payload truncated for fast transcript hydration'
95
98
  return identity
96
99
  }
100
+
101
+ export function boundStructuredLog(events, {
102
+ eventCap = STRUCTURED_EVENT_CAP,
103
+ byteCap = STRUCTURED_LOG_BYTE_CAP,
104
+ countCap = 2000,
105
+ } = {}) {
106
+ if (!Array.isArray(events) || !events.length) return []
107
+ const bounded = events.slice(-countCap).map((event) => boundStructuredEvent(event, eventCap))
108
+ let bytes = 0
109
+ let start = bounded.length
110
+ while (start > 0) {
111
+ const size = sizeOf(bounded[start - 1]) + 1
112
+ if (start < bounded.length && bytes + size > byteCap) break
113
+ bytes += size
114
+ start--
115
+ }
116
+ return start === 0 ? bounded : bounded.slice(start)
117
+ }
@@ -86,7 +86,10 @@ export function recoverMissingResumeOnce(entry, { message, recap = '', reopen }
86
86
  // cold Claude/Codex runtime can trigger its init event and enqueue a stale second
87
87
  // `continue` behind the person's actual request.
88
88
  export function supersedeInterruptedResume(entry) {
89
- if (!entry?.pendingAutoResume) return false
89
+ if (!entry) return false
90
+ const queued = !!entry.pendingInterruptedRecovery
91
+ entry.pendingInterruptedRecovery = null
92
+ if (!entry.pendingAutoResume) return queued
90
93
  entry.pendingAutoResume = false
91
94
  return true
92
95
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.343",
3
+ "version": "0.7.345",
4
4
  "description": "Connect Claude Code, Codex, or Hermes on your computer to a Thinkpool Code room.",
5
5
  "type": "module",
6
6
  "bin": {
package/session-store.mjs CHANGED
@@ -12,6 +12,7 @@ import os from 'node:os'
12
12
  import fs from 'node:fs'
13
13
  import path from 'node:path'
14
14
  import { SPAWN } from './cross-terminal.mjs'
15
+ import { boundStructuredLog } from './event-bounds.mjs'
15
16
 
16
17
  // TP_PAIR_ROOT override exists for tests + sandboxes so they never touch the real
17
18
  // ~/.thinkpool-pair store. Read LIVE per call (not captured at module load) so a test
@@ -265,7 +266,8 @@ function write(room, id, data) {
265
266
  const file = path.join(dir(room), `${id}.json`)
266
267
  try {
267
268
  ensureDir(room)
268
- atomicCommit(file, JSON.stringify({ ...data, id, savedAt: Date.now() }))
269
+ const log = Array.isArray(data?.log) ? boundStructuredLog(data.log) : data?.log
270
+ atomicCommit(file, JSON.stringify({ ...data, ...(log ? { log } : {}), id, savedAt: Date.now() }))
269
271
  prune(room)
270
272
  return true
271
273
  } catch (error) {