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 +48 -0
- package/bridge.mjs +86 -15
- package/codex-mcp-http.mjs +37 -6
- package/codex-session.mjs +64 -5
- package/event-bounds.mjs +21 -0
- package/interrupted-resume.mjs +4 -1
- package/package.json +1 -1
- package/session-store.mjs +3 -1
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
|
|
2135
|
-
// is skipped. entry.id/entry.room are stamped in
|
|
2136
|
-
// is a no-op. This is the single place every new
|
|
2137
|
-
// missed and reloaded events
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
4855
|
-
|
|
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') {
|
package/codex-mcp-http.mjs
CHANGED
|
@@ -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 =
|
|
31
|
+
const retireActive = () => {
|
|
31
32
|
const current = active
|
|
32
33
|
active = null
|
|
33
|
-
if (!current) return
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|
package/interrupted-resume.mjs
CHANGED
|
@@ -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
|
|
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
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
|
-
|
|
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) {
|