thinkpool-pair 0.7.342 → 0.7.344
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 +119 -16
- 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 } },
|
|
@@ -1283,6 +1311,15 @@ const replayPump = createLatestReplayPump({
|
|
|
1283
1311
|
// every announce carries them so a late-joining or second device sees them too.
|
|
1284
1312
|
const termNames = loadNames(room)
|
|
1285
1313
|
const manualNameTouched = new Set()
|
|
1314
|
+
const acknowledgeTermOpen = (id, kind, entry = null) => {
|
|
1315
|
+
bcast('term-opened', {
|
|
1316
|
+
id,
|
|
1317
|
+
kind,
|
|
1318
|
+
sideParent: entry?.sideParent || undefined,
|
|
1319
|
+
sideTask: entry?.sideTask || undefined,
|
|
1320
|
+
name: termNames[id] || undefined,
|
|
1321
|
+
})
|
|
1322
|
+
}
|
|
1286
1323
|
const autoNameAttempts = new Set()
|
|
1287
1324
|
const autoNames = new Map()
|
|
1288
1325
|
// The SDK's supported-model LIST (value/displayName/description), captured from
|
|
@@ -1726,7 +1763,6 @@ function pumpDesign(term) {
|
|
|
1726
1763
|
}
|
|
1727
1764
|
const active = { ...next, resultOk: false, proof: null, timer: null }
|
|
1728
1765
|
designActive.set(term, active)
|
|
1729
|
-
designStatus({ previewId: next.record.previewId, requestId: next.request.cid, state: 'applying' })
|
|
1730
1766
|
// Complete room-visible instructions, without host paths or locator packets.
|
|
1731
1767
|
const visible = {
|
|
1732
1768
|
kind: 'you',
|
|
@@ -1747,6 +1783,10 @@ function pumpDesign(term) {
|
|
|
1747
1783
|
} catch { accepted = false }
|
|
1748
1784
|
if (accepted) beginStructuredTurn(lane)
|
|
1749
1785
|
stampEvent(visible); pushLog(lane, visible); bcast('code-event', { term, evt: visible })
|
|
1786
|
+
// Browser clients use this as the positive dispatch acknowledgment. Keep it
|
|
1787
|
+
// after the accepted turn and its visible prompt so "applying" never navigates
|
|
1788
|
+
// a submitter away from an actionable Design failure.
|
|
1789
|
+
if (accepted) designStatus({ previewId: next.record.previewId, requestId: next.request.cid, state: 'applying' })
|
|
1750
1790
|
if (!accepted) {
|
|
1751
1791
|
syncStructuredTurn(lane)
|
|
1752
1792
|
const failed = { kind: 'error', message: 'The producing lane could not start the edit.', recoverable: true }
|
|
@@ -2117,12 +2157,11 @@ function pushLog(entry, evt) {
|
|
|
2117
2157
|
// filters out (system/thinking_tokens/effort) ride the log seq-less so they don't
|
|
2118
2158
|
// hole the client's applied sequence. See event-id.mjs NO_SEQ_KINDS.
|
|
2119
2159
|
if (entry.seq && seqable(evt)) evt.seq = entry.seq.next()
|
|
2120
|
-
entry.log.push(evt)
|
|
2121
2160
|
// Durable, UNBOUNDED archive (powers "load earlier history"): append the transcript
|
|
2122
|
-
// event before the
|
|
2123
|
-
// is skipped. entry.id/entry.room are stamped in
|
|
2124
|
-
// is a no-op. This is the single place every new
|
|
2125
|
-
// 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.
|
|
2126
2165
|
if (entry.id && seqable(evt)) {
|
|
2127
2166
|
appendDurableEvents(entry.room, entry.id, [evt])
|
|
2128
2167
|
// First archived event sets the floor for a fresh lane (no seed ran). Lets the
|
|
@@ -2130,7 +2169,17 @@ function pushLog(entry, evt) {
|
|
|
2130
2169
|
// above this floor — a new lane's first turn at seq>1 is NOT older-history.
|
|
2131
2170
|
if (entry.archiveOldestSeq == null) entry.archiveOldestSeq = evt.seq
|
|
2132
2171
|
}
|
|
2133
|
-
|
|
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
|
+
}
|
|
2134
2183
|
return evt
|
|
2135
2184
|
}
|
|
2136
2185
|
|
|
@@ -2218,7 +2267,10 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2218
2267
|
? spawnDepth
|
|
2219
2268
|
: (sideParent || (spawnedBy && !String(spawnedBy).startsWith('flow:')) ? 1 : 0)
|
|
2220
2269
|
const initialHop = Number.isInteger(hop) && hop >= 0 ? hop : structuralDepth
|
|
2221
|
-
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,
|
|
2222
2274
|
// model: truthful active-model label — now the SAME `laneModel` the SDK is given, so the
|
|
2223
2275
|
// chip cannot disagree with the wire. When this lane runs on a custom (non-anthropic)
|
|
2224
2276
|
// registered provider the SDK id is impersonated (see the onEvent guard below), so
|
|
@@ -2243,7 +2295,12 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2243
2295
|
// Stable creation order — persisted so a bridge restart restores tabs in the SAME
|
|
2244
2296
|
// order (not readdir/filesystem order). Legacy recs (no openedAt) derive it from the
|
|
2245
2297
|
// first transcript event ts, so even the first post-fix restart is ordered right.
|
|
2246
|
-
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) }
|
|
2247
2304
|
entry._turnRev = entry.log.reduce((max, event) => Math.max(max, Number(event?.turnRev) || 0), 0)
|
|
2248
2305
|
const restoredBoundary = [...entry.log].reverse().find((event) => event?.kind === 'result' && Number(event?.turnRev) > 0)
|
|
2249
2306
|
entry._settledTurnRev = Number(restoredBoundary?.turnRev) || null
|
|
@@ -4111,6 +4168,13 @@ channel
|
|
|
4111
4168
|
// Multi-bridge rooms: a targeted open is for ONE machine. Untargeted
|
|
4112
4169
|
// opens (older web) are taken by whoever hears them — the solo case.
|
|
4113
4170
|
if (payload.host && payload.host !== name) return
|
|
4171
|
+
// A client retries an open when the first broadcast or this acknowledgement is
|
|
4172
|
+
// lost. PTY creation was already idempotent, but acknowledge the existing lane
|
|
4173
|
+
// explicitly so the watchdog can settle without waiting for a full roster.
|
|
4174
|
+
if (terms.has(payload.id)) {
|
|
4175
|
+
acknowledgeTermOpen(payload.id, 'pty', terms.get(payload.id))
|
|
4176
|
+
return
|
|
4177
|
+
}
|
|
4114
4178
|
// resume is a FLAG, never argv: the channel must not pass arbitrary
|
|
4115
4179
|
// args even though the room is shell-trust by design. The args come
|
|
4116
4180
|
// from our own KNOWN_AGENTS table, probe-gated so `--continue` with
|
|
@@ -4121,6 +4185,15 @@ channel
|
|
|
4121
4185
|
if (agent?.resume) { try { if (agent.resume.probe()) args = [...agent.resume.args] } catch { /* fresh */ } }
|
|
4122
4186
|
}
|
|
4123
4187
|
if (wantStructured(payload.cmd)) {
|
|
4188
|
+
// The open id is the idempotency key for the ENTIRE operation, including a
|
|
4189
|
+
// side lane's initial task. openStructured() already no-ops for an existing
|
|
4190
|
+
// runtime, but the old handler continued below and published side-started +
|
|
4191
|
+
// dispatched the task again. A retry must only replay the acceptance ack.
|
|
4192
|
+
const existing = sessions.get(payload.id)
|
|
4193
|
+
if (existing) {
|
|
4194
|
+
acknowledgeTermOpen(payload.id, 'structured', existing)
|
|
4195
|
+
return
|
|
4196
|
+
}
|
|
4124
4197
|
// model: the web passes the model to open on — a NEW terminal inherits the
|
|
4125
4198
|
// previous terminal's model (and the first uses the provider default). Falls
|
|
4126
4199
|
// through to the SDK default when absent. Stamped on the entry so the announce
|
|
@@ -4179,13 +4252,17 @@ channel
|
|
|
4179
4252
|
return
|
|
4180
4253
|
}
|
|
4181
4254
|
child.flush?.(); parent.flush?.(); announce()
|
|
4255
|
+
acknowledgeTermOpen(payload.id, 'structured', child)
|
|
4182
4256
|
process.stderr.write(`\n ◆ ${by} opened side lane ${String(payload.id).slice(0, 8)} under ${String(sideParent).slice(0, 8)}.\n`)
|
|
4183
4257
|
return
|
|
4184
4258
|
}
|
|
4259
|
+
const opened = sessions.get(payload.id)
|
|
4260
|
+
if (opened) acknowledgeTermOpen(payload.id, 'structured', opened)
|
|
4185
4261
|
process.stderr.write(`\n ◆ web opened a structured "${payload.cmd}" session (${payload.mode || 'default'}${payload.model ? `, model ${payload.model}` : ''}).\n`)
|
|
4186
4262
|
return
|
|
4187
4263
|
}
|
|
4188
4264
|
openTerm({ id: payload.id, cmd: payload.cmd, args })
|
|
4265
|
+
if (terms.has(payload.id)) acknowledgeTermOpen(payload.id, 'pty', terms.get(payload.id))
|
|
4189
4266
|
process.stderr.write(`\n ◆ web opened a "${payload.cmd}"${args.length ? ' (continue)' : ''} terminal (headless).\n`)
|
|
4190
4267
|
})
|
|
4191
4268
|
.on('broadcast', { event: 'term-close' }, ({ payload }) => {
|
|
@@ -4324,9 +4401,7 @@ channel
|
|
|
4324
4401
|
try {
|
|
4325
4402
|
const fp = canonicalRoomFilePath(payload, { updir: UPDIR })
|
|
4326
4403
|
if (!fp) throw new Error('invalid attachment metadata')
|
|
4327
|
-
const
|
|
4328
|
-
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
|
4329
|
-
const buf = Buffer.from(await r.arrayBuffer())
|
|
4404
|
+
const buf = await fetchRoomAttachment(payload)
|
|
4330
4405
|
if (buf.length > FILE_MAX_BYTES) throw new Error('file too large')
|
|
4331
4406
|
fs.mkdirSync(UPDIR, { recursive: true })
|
|
4332
4407
|
fs.writeFileSync(fp, buf)
|
|
@@ -4501,7 +4576,7 @@ channel
|
|
|
4501
4576
|
// two conversations. Drop the cached floor with it: the next pushLog re-seeds
|
|
4502
4577
|
// archiveOldestSeq from the FIRST post-clear event (the new epoch's floor), instead
|
|
4503
4578
|
// of leaving a stale pre-clear floor to mis-aim trimmedBeforeSeq's phantom-pill guard.
|
|
4504
|
-
s.log = []; s.seq = makeSeqCounter(0); s.archiveOldestSeq = null
|
|
4579
|
+
s.log = []; s.logBytes = 0; s.seq = makeSeqCounter(0); s.archiveOldestSeq = null
|
|
4505
4580
|
bcast('code-event', { term: payload.term, evt: { kind: 'clear' } })
|
|
4506
4581
|
flushSession(room, payload.term, { sessionId: s.session?.sessionId || null, log: [] })
|
|
4507
4582
|
ctlLine('context cleared. You can continue with these answers in mind.')
|
|
@@ -4766,6 +4841,7 @@ channel
|
|
|
4766
4841
|
realtimeHealthy = true; brokenSince = 0
|
|
4767
4842
|
trackPresence({ name, role: 'bridge', bridge_id: BRIDGE_ID, started_at: BRIDGE_STARTED_AT })
|
|
4768
4843
|
const startCmd = attachedCmd || autoAgent // autoAgent: account-mode auto-open (headless)
|
|
4844
|
+
const interruptedRecoveries = []
|
|
4769
4845
|
if (startCmd && !terms.size && !sessions.size) {
|
|
4770
4846
|
// Claude + structured mode → Agent SDK session; everything else → PTY.
|
|
4771
4847
|
// On restart, restore EVERY saved structured session for this room (not
|
|
@@ -4819,9 +4895,8 @@ channel
|
|
|
4819
4895
|
// Every interrupted role resumes exactly once. Flow lanes do not have a
|
|
4820
4896
|
// startup redispatch path; excluding them here stranded conductors/builders.
|
|
4821
4897
|
if (re && wasInterrupted) {
|
|
4822
|
-
|
|
4823
|
-
|
|
4824
|
-
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)
|
|
4825
4900
|
}
|
|
4826
4901
|
}
|
|
4827
4902
|
// Fresh open: ONLY for an explicit `-- <claude>` share. In account mode
|
|
@@ -4856,6 +4931,31 @@ channel
|
|
|
4856
4931
|
if (process.send && process.env.THINKPOOL_PAIR_ACCOUNT_CHILD === '1') {
|
|
4857
4932
|
try { process.send({ t: 'room-ready', room, bridgeId: BRIDGE_ID }) } catch { /* supervisor exited */ }
|
|
4858
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
|
+
})
|
|
4859
4959
|
process.stderr.write(headless
|
|
4860
4960
|
? `\n ◆ thinkpool — relaying room ${room} (headless). Open terminals from the web UI.\n\n`
|
|
4861
4961
|
: `\n ◆ thinkpool — sharing "${attachedCmd}"${continuing ? ' (continuing your latest session)' : ''} into room ${room}. Open the web UI and you're both in.\n\n`)
|
|
@@ -5379,6 +5479,9 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
|
|
|
5379
5479
|
if (!m) return
|
|
5380
5480
|
if (m.t === 'update-available') surfaceUpdate(m.version)
|
|
5381
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
|
+
}
|
|
5382
5485
|
// Supervisor rotated the owner JWT (account.mjs scheduleTokenRefresh) — adopt
|
|
5383
5486
|
// it so our authed writes (code-mockup) never go stale on a long session (L16).
|
|
5384
5487
|
else if (m.t === 'token') {
|
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) {
|