thinkpool-pair 0.7.245 → 0.7.246
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bridge.mjs +40 -40
- package/event-id.mjs +231 -0
- package/package.json +3 -1
- package/sdk-admission.mjs +9 -0
- package/session-store.mjs +84 -13
package/bridge.mjs
CHANGED
|
@@ -57,6 +57,7 @@ import { probeHermesRuntime } from './hermes-probe.mjs'
|
|
|
57
57
|
import { canonicalRoomFilePath, waitForNativeImages } from './codex-images.mjs'
|
|
58
58
|
import { createManagedLaneWorktree, removeManagedLaneWorktree } from './lane-worktree.mjs'
|
|
59
59
|
import { commandOnPath } from './agent-detect.mjs'
|
|
60
|
+
import { requiresSdkAdmission, sdkSmokePassed } from './sdk-admission.mjs'
|
|
60
61
|
|
|
61
62
|
const STRUCTURED_MODES = new Set(['default', 'acceptEdits', 'plan', 'review', 'bypassPermissions'])
|
|
62
63
|
import { FLOW_CONDUCTOR_PROMPT, FLOW_LANE_PROMPT, FLOW_CODEX_CONDUCTOR_PROMPT, FLOW_CODEX_LANE_PROMPT, buildConductorEnv, assembleCrossWaveContext, buildLanePrompt } from './flow-conductor.mjs'
|
|
@@ -109,7 +110,7 @@ import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSP
|
|
|
109
110
|
import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
|
|
110
111
|
import { turnInFlight } from './update-gate.mjs'
|
|
111
112
|
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
|
|
112
|
-
import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, usageReportLine, codexUsageReportLine, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
|
|
113
|
+
import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, ImageEventQueue, imageQueueConfig, uploadCodeImage as uploadCodeImageRequest, usageReportLine, codexUsageReportLine, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
|
|
113
114
|
import { classifyCodeEvent, unknownCodeEventNotice } from './code-event-contract.mjs'
|
|
114
115
|
import { fetchPairControlDeliveryAuthority } from './pair-control-authority.mjs'
|
|
115
116
|
import { SIDE_HANDOFF_PROMPT, SIDE_ROLE_PROMPT, appendSideContext, assistantTextSince, sideContextBlock, sideSnapshot } from './side-lane.mjs'
|
|
@@ -122,6 +123,7 @@ import { buildTerminalRolePrompt, HERMES_VISIBLE_WORKER_FALLBACK_RULE } from './
|
|
|
122
123
|
// Override with TP_SUPABASE_URL / TP_SUPABASE_ANON if you ever need to.
|
|
123
124
|
const SUPABASE_URL = process.env.TP_SUPABASE_URL || DEFAULT_SUPABASE_URL
|
|
124
125
|
const WEB_BASE = process.env.TP_WEB_BASE || 'https://thinkpool.io'
|
|
126
|
+
const IMAGE_QUEUE_CONFIG = imageQueueConfig()
|
|
125
127
|
|
|
126
128
|
// The anon key is RESOLVED, not baked. It used to be a string literal here, and
|
|
127
129
|
// that literal now sits in 224 published tarballs on 224 users' laptops: disable
|
|
@@ -361,10 +363,9 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
|
|
|
361
363
|
// The SDK tracks a caret (^0.3.x), so a restart can auto-pull a NEW SDK, and its
|
|
362
364
|
// interrupt/turn API has broken between 0.3.x minors before. Smoke-test the API
|
|
363
365
|
// surface (offline, no tokens) the FIRST time a given SDK version is seen, so a
|
|
364
|
-
// bad one is caught
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
const SMOKE_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), 'sdk-smoke.mjs')
|
|
366
|
+
// bad one is caught before a room can serve turns. Account/service commands remain
|
|
367
|
+
// available so the operator can repair the installation.
|
|
368
|
+
const SMOKE_PATH = process.env.TP_SDK_SMOKE_PATH || path.join(path.dirname(fileURLToPath(import.meta.url)), 'sdk-smoke.mjs')
|
|
368
369
|
let sdkStatus = { ok: null, version: 'unknown', reason: '' }
|
|
369
370
|
function currentSdkVersion() {
|
|
370
371
|
try {
|
|
@@ -382,7 +383,7 @@ function runSmoke() {
|
|
|
382
383
|
catch (e) { return (e.stdout || '').toString().trim() || `SMOKE:FAIL:${currentSdkVersion()}:${e.code === 'ETIMEDOUT' ? 'timeout' : (e.message || 'run error').slice(0, 80)}` }
|
|
383
384
|
}
|
|
384
385
|
if (argv[0] === 'verify-sdk') {
|
|
385
|
-
const out = runSmoke(); console.log(out); process.exit(
|
|
386
|
+
const out = runSmoke(); console.log(out); process.exit(sdkSmokePassed(out) ? 0 : 1)
|
|
386
387
|
}
|
|
387
388
|
function checkSdkCompat() {
|
|
388
389
|
const dir = path.join(os.homedir(), '.thinkpool-pair'); const okFile = path.join(dir, 'sdk-ok')
|
|
@@ -397,10 +398,9 @@ function checkSdkCompat() {
|
|
|
397
398
|
} else {
|
|
398
399
|
sdkStatus = { ok: false, version: (m && m[2]) || version, reason: (m && m[3]) || 'unknown' }
|
|
399
400
|
try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'sdk-bad'), `${sdkStatus.version}: ${sdkStatus.reason}`) } catch { /* noop */ }
|
|
400
|
-
process.stderr.write(`\n
|
|
401
|
+
process.stderr.write(`\n ⛔ agent SDK v${sdkStatus.version} FAILED the compatibility smoke test — ${sdkStatus.reason}.\n ⛔ refusing to serve agent turns. Repair or pin a known-good SDK, then restart.\n`)
|
|
401
402
|
}
|
|
402
403
|
}
|
|
403
|
-
try { checkSdkCompat() } catch (e) { process.stderr.write(` ⚠ SDK smoke check skipped: ${e.message}\n`) }
|
|
404
404
|
|
|
405
405
|
if (!argv[0] || argv[0].startsWith('-')) { const { runAccount } = await import('./account.mjs'); await runAccount(SUPABASE_URL, SUPABASE_ANON) }
|
|
406
406
|
|
|
@@ -418,6 +418,10 @@ if (!/^[A-Z0-9_-]{2,32}$/.test(room)) {
|
|
|
418
418
|
console.error(`refused: invalid room code ${JSON.stringify(room)} (expected 2–32 alphanumerics, dash, or underscore)`)
|
|
419
419
|
process.exit(1)
|
|
420
420
|
}
|
|
421
|
+
if (requiresSdkAdmission(argv)) {
|
|
422
|
+
checkSdkCompat()
|
|
423
|
+
if (!sdkStatus.ok) process.exit(1)
|
|
424
|
+
}
|
|
421
425
|
// ── Supervisor mode (--supervise / --keep-alive): keep the bridge alive across
|
|
422
426
|
// crashes. We re-exec ourselves without the flag and respawn the child on any
|
|
423
427
|
// non-clean exit with exponential backoff. Zero-dependency, cross-platform. With
|
|
@@ -1503,38 +1507,30 @@ watchOutbox(MOCKUP_OUTBOX, () => [...sessions.keys()][0] || attachedId || [...te
|
|
|
1503
1507
|
// { type:'image', path } ref. The persisted log + every replay then carry the path,
|
|
1504
1508
|
// never base64; the web client mints a signed URL on render (structured.jsx, same
|
|
1505
1509
|
// as the mockup thumbs). Spec: docs/specs/2026-06-26-code-agent-images.md
|
|
1506
|
-
async function uploadCodeImage(term, cid, im) {
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1510
|
+
async function uploadCodeImage(term, cid, im, activeUploads) {
|
|
1511
|
+
return uploadCodeImageRequest({
|
|
1512
|
+
webBase: WEB_BASE,
|
|
1513
|
+
room,
|
|
1514
|
+
authToken: codeAuthToken,
|
|
1515
|
+
term,
|
|
1516
|
+
cid,
|
|
1517
|
+
image: im,
|
|
1518
|
+
timeoutMs: IMAGE_QUEUE_CONFIG.uploadTimeoutMs,
|
|
1519
|
+
activeUploads,
|
|
1513
1520
|
})
|
|
1514
|
-
if (!res.ok) throw new Error(`code-image ${res.status}`)
|
|
1515
|
-
const { path } = await res.json()
|
|
1516
|
-
if (!path) throw new Error('code-image: no path')
|
|
1517
|
-
return path
|
|
1518
1521
|
}
|
|
1519
|
-
//
|
|
1520
|
-
//
|
|
1521
|
-
//
|
|
1522
|
-
// still gets a contiguous seq at chain-resolution time; its ts was stamped at
|
|
1523
|
-
// arrival, so it sorts into place. On upload failure the block degrades to the same
|
|
1524
|
-
// render-safe text placeholder — base64 is NEVER logged, broadcast, or persisted.
|
|
1522
|
+
// Every structured event enters one per-session emission boundary. With no upload in
|
|
1523
|
+
// flight the fast path stays synchronous; after an image, later tool/results wait in
|
|
1524
|
+
// runtime order. The queue owns timeout, raw-base64 budgets, and teardown degradation.
|
|
1525
1525
|
function deferImageEvent(entry, id, evt, imgs, emitTail) {
|
|
1526
|
-
entry.
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
}
|
|
1535
|
-
}
|
|
1536
|
-
emitTail(evt)
|
|
1537
|
-
}).catch(() => {})
|
|
1526
|
+
entry.imageQueue ||= new ImageEventQueue({
|
|
1527
|
+
maxPending: IMAGE_QUEUE_CONFIG.maxPending,
|
|
1528
|
+
maxBytes: IMAGE_QUEUE_CONFIG.maxBytes,
|
|
1529
|
+
upload: (queuedEvt, im, activeUploads) => uploadCodeImage(id, queuedEvt.cid, im, activeUploads),
|
|
1530
|
+
onUploadError: (e) => process.stderr.write(`\n ◇ screenshot upload failed (${e?.message || e}).\n`),
|
|
1531
|
+
onOverload: ({ maxPending, maxBytes }) => process.stderr.write(`\n ◇ screenshot omitted (image queue cap: ${maxPending} pending / ${maxBytes} encoded bytes).\n`),
|
|
1532
|
+
})
|
|
1533
|
+
entry.imageQueue.enqueue(evt, imgs, emitTail)
|
|
1538
1534
|
}
|
|
1539
1535
|
// Fire-and-forget: drop a closed term's screenshots from Storage so they don't
|
|
1540
1536
|
// outlive the room (COGS). Per-term scope — never touches a sibling term's images.
|
|
@@ -2715,6 +2711,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2715
2711
|
message: evt.message,
|
|
2716
2712
|
recap: recoveryRecap,
|
|
2717
2713
|
reopen: (carryRecap) => {
|
|
2714
|
+
entry.imageQueue?.close()
|
|
2718
2715
|
try { entry.mockupWatcher?.close() } catch { /* noop */ }
|
|
2719
2716
|
sessions.delete(id)
|
|
2720
2717
|
openStructured({
|
|
@@ -2988,10 +2985,10 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2988
2985
|
// (live OR replay). Lift it to Storage FIRST, then emit the URL-only event —
|
|
2989
2986
|
// so the persisted log + replay carry a path, never base64. Order/seq stay
|
|
2990
2987
|
// intact: pushLog (inside emitTail) is the single seq point, called when the
|
|
2991
|
-
// upload resolves.
|
|
2988
|
+
// upload resolves. Later events share the same queue; otherwise a closing
|
|
2989
|
+
// result could overtake its delayed tool_result (BH-DATA-003).
|
|
2992
2990
|
const imgs = inlineImageBlocks(evt)
|
|
2993
|
-
|
|
2994
|
-
emitTail(evt)
|
|
2991
|
+
deferImageEvent(entry, id, evt, imgs, emitTail)
|
|
2995
2992
|
},
|
|
2996
2993
|
requestPermission: (req) => new Promise((resolve) => {
|
|
2997
2994
|
// FLOW conductor plan → intercept (no generic card). The plan JSON rides
|
|
@@ -3147,6 +3144,7 @@ function acceptEditsPending(s) {
|
|
|
3147
3144
|
function respawnStructured(id, provider) {
|
|
3148
3145
|
const s = sessions.get(id)
|
|
3149
3146
|
if (!s) return false
|
|
3147
|
+
s.imageQueue?.close()
|
|
3150
3148
|
// Capture the lane's identity for re-open (NOT resume — fresh SDK session).
|
|
3151
3149
|
// NOTE the deliberate absence of `model`: a respawn crosses a provider boundary,
|
|
3152
3150
|
// and the old lane's model id means nothing on the new backend. Carrying it sent
|
|
@@ -3210,6 +3208,7 @@ function endStructured(id) {
|
|
|
3210
3208
|
if (!id) return
|
|
3211
3209
|
const s = sessions.get(id)
|
|
3212
3210
|
if (s) {
|
|
3211
|
+
s.imageQueue?.close()
|
|
3213
3212
|
drainPending(s)
|
|
3214
3213
|
try { s.session?.end() } catch { /* noop */ }
|
|
3215
3214
|
try { s.mockupWatcher?.close() } catch { /* noop */ }
|
|
@@ -4565,6 +4564,7 @@ async function shutdown(code = 0, farewell = true) {
|
|
|
4565
4564
|
// Flush structured session state synchronously BEFORE ending sessions. saveSession
|
|
4566
4565
|
// debounces ~1.5s, so without this any events since the last write are lost on exit
|
|
4567
4566
|
// (Contract #2). writeFileSync, well inside the 1500ms hard-exit backstop above.
|
|
4567
|
+
for (const s of sessions.values()) { try { s.imageQueue?.close() } catch { /* noop */ } }
|
|
4568
4568
|
for (const s of sessions.values()) { try { s.flush?.() } catch { /* noop */ } }
|
|
4569
4569
|
for (const t of terms.values()) { try { t.term.kill() } catch { /* noop */ } }
|
|
4570
4570
|
for (const s of sessions.values()) { try { s.session.end() } catch { /* noop */ } }
|
package/event-id.mjs
CHANGED
|
@@ -210,6 +210,237 @@ export function inlineImageBlocks(evt) {
|
|
|
210
210
|
return out
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
// One ordered emission boundary for structured-session events while agent screenshots
|
|
214
|
+
// are lifted out of inline base64. This stays import-safe: bridge.mjs owns room/auth
|
|
215
|
+
// state; these definitions own deadlines, bounded reservations, ordering, and teardown.
|
|
216
|
+
export const SCREENSHOT_OMITTED = '[screenshot omitted — upload failed]'
|
|
217
|
+
export const DEFAULT_IMAGE_UPLOAD_TIMEOUT_MS = 15_000
|
|
218
|
+
export const DEFAULT_IMAGE_QUEUE_MAX_PENDING = 4
|
|
219
|
+
export const DEFAULT_IMAGE_QUEUE_MAX_BYTES = 16_000_000
|
|
220
|
+
|
|
221
|
+
const sanePositiveInt = (value, fallback) => {
|
|
222
|
+
const n = Number(value)
|
|
223
|
+
return Number.isSafeInteger(n) && n > 0 ? n : fallback
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function imageQueueConfig(env = process.env) {
|
|
227
|
+
return {
|
|
228
|
+
uploadTimeoutMs: sanePositiveInt(env.TP_CODE_IMAGE_UPLOAD_TIMEOUT_MS, DEFAULT_IMAGE_UPLOAD_TIMEOUT_MS),
|
|
229
|
+
maxPending: sanePositiveInt(env.TP_CODE_IMAGE_QUEUE_MAX_PENDING, DEFAULT_IMAGE_QUEUE_MAX_PENDING),
|
|
230
|
+
maxBytes: sanePositiveInt(env.TP_CODE_IMAGE_QUEUE_MAX_BYTES, DEFAULT_IMAGE_QUEUE_MAX_BYTES),
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Deadline covers fetch AND response parsing. Promise.race is deliberate: AbortSignal is
|
|
235
|
+
// still passed to real fetch, but an injected/broken fetch that ignores it cannot pin the
|
|
236
|
+
// ordered queue forever. activeUploads lets session teardown cancel the deadline now.
|
|
237
|
+
export async function uploadCodeImage({
|
|
238
|
+
webBase,
|
|
239
|
+
room,
|
|
240
|
+
authToken,
|
|
241
|
+
term,
|
|
242
|
+
cid,
|
|
243
|
+
image,
|
|
244
|
+
fetchImpl = globalThis.fetch,
|
|
245
|
+
timeoutMs = DEFAULT_IMAGE_UPLOAD_TIMEOUT_MS,
|
|
246
|
+
activeUploads = new Set(),
|
|
247
|
+
}) {
|
|
248
|
+
const controller = new AbortController()
|
|
249
|
+
let timer = null
|
|
250
|
+
let rejectCancel = null
|
|
251
|
+
let settled = false
|
|
252
|
+
|
|
253
|
+
const cancelled = new Promise((_, reject) => { rejectCancel = reject })
|
|
254
|
+
const handle = {
|
|
255
|
+
cancel(reason = new Error('code-image upload cancelled')) {
|
|
256
|
+
if (settled) return
|
|
257
|
+
if (timer) { clearTimeout(timer); timer = null }
|
|
258
|
+
try { controller.abort(reason) } catch { /* AbortController is best-effort */ }
|
|
259
|
+
rejectCancel(reason instanceof Error ? reason : new Error(String(reason)))
|
|
260
|
+
},
|
|
261
|
+
}
|
|
262
|
+
activeUploads.add(handle)
|
|
263
|
+
timer = setTimeout(() => handle.cancel(new Error(`code-image upload timed out after ${timeoutMs}ms`)), timeoutMs)
|
|
264
|
+
timer.unref?.()
|
|
265
|
+
|
|
266
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
267
|
+
if (authToken) headers.Authorization = `Bearer ${authToken}`
|
|
268
|
+
const request = (async () => {
|
|
269
|
+
const res = await fetchImpl(`${webBase}/api/code-image`, {
|
|
270
|
+
method: 'POST',
|
|
271
|
+
headers,
|
|
272
|
+
signal: controller.signal,
|
|
273
|
+
body: JSON.stringify({ code: room, term, cid, idx: image.idx, mediaType: image.mediaType, b64: image.b64 }),
|
|
274
|
+
})
|
|
275
|
+
if (!res.ok) throw new Error(`code-image ${res.status}`)
|
|
276
|
+
const { path } = await res.json()
|
|
277
|
+
if (!path) throw new Error('code-image: no path')
|
|
278
|
+
return path
|
|
279
|
+
})()
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
return await Promise.race([request, cancelled])
|
|
283
|
+
} finally {
|
|
284
|
+
settled = true
|
|
285
|
+
if (timer) clearTimeout(timer)
|
|
286
|
+
activeUploads.delete(handle)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const encodedImageBytes = (image) => Buffer.byteLength(String(image?.b64 || ''), 'ascii')
|
|
291
|
+
|
|
292
|
+
function stripRawImages(evt, images, placeholder) {
|
|
293
|
+
for (const image of images) {
|
|
294
|
+
const block = Array.isArray(evt?.content) ? evt.content[image.idx] : null
|
|
295
|
+
if (block?.source?.type === 'base64' || image.b64) {
|
|
296
|
+
evt.content[image.idx] = { type: 'text', text: placeholder }
|
|
297
|
+
}
|
|
298
|
+
image.b64 = ''
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export class ImageEventQueue {
|
|
303
|
+
constructor({
|
|
304
|
+
upload,
|
|
305
|
+
maxPending = DEFAULT_IMAGE_QUEUE_MAX_PENDING,
|
|
306
|
+
maxBytes = DEFAULT_IMAGE_QUEUE_MAX_BYTES,
|
|
307
|
+
placeholder = SCREENSHOT_OMITTED,
|
|
308
|
+
onUploadError = () => {},
|
|
309
|
+
onOverload = () => {},
|
|
310
|
+
}) {
|
|
311
|
+
if (typeof upload !== 'function') throw new TypeError('ImageEventQueue requires upload')
|
|
312
|
+
this.upload = upload
|
|
313
|
+
this.maxPending = sanePositiveInt(maxPending, DEFAULT_IMAGE_QUEUE_MAX_PENDING)
|
|
314
|
+
this.maxBytes = sanePositiveInt(maxBytes, DEFAULT_IMAGE_QUEUE_MAX_BYTES)
|
|
315
|
+
this.placeholder = placeholder
|
|
316
|
+
this.onUploadError = onUploadError
|
|
317
|
+
this.onOverload = onOverload
|
|
318
|
+
this.pendingCount = 0
|
|
319
|
+
this.pendingBytes = 0
|
|
320
|
+
this.peakPendingCount = 0
|
|
321
|
+
this.peakPendingBytes = 0
|
|
322
|
+
this.activeUploads = new Set()
|
|
323
|
+
this.tasks = new Set()
|
|
324
|
+
this.tail = Promise.resolve()
|
|
325
|
+
this.closed = false
|
|
326
|
+
this.overloadNotified = false
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
stats() {
|
|
330
|
+
return {
|
|
331
|
+
pendingCount: this.pendingCount,
|
|
332
|
+
pendingBytes: this.pendingBytes,
|
|
333
|
+
peakPendingCount: this.peakPendingCount,
|
|
334
|
+
peakPendingBytes: this.peakPendingBytes,
|
|
335
|
+
activeUploads: this.activeUploads.size,
|
|
336
|
+
queuedEvents: this.tasks.size,
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
enqueue(evt, images, emitTail) {
|
|
341
|
+
if (this.closed || typeof emitTail !== 'function') return false
|
|
342
|
+
const imgs = Array.isArray(images) ? images : []
|
|
343
|
+
const count = imgs.length
|
|
344
|
+
const bytes = imgs.reduce((sum, image) => sum + encodedImageBytes(image), 0)
|
|
345
|
+
const accepted = count > 0 &&
|
|
346
|
+
this.pendingCount + count <= this.maxPending &&
|
|
347
|
+
this.pendingBytes + bytes <= this.maxBytes
|
|
348
|
+
|
|
349
|
+
if (count && accepted) {
|
|
350
|
+
for (const image of imgs) {
|
|
351
|
+
image._queueBytes = encodedImageBytes(image)
|
|
352
|
+
image._queueReserved = true
|
|
353
|
+
}
|
|
354
|
+
this.pendingCount += count
|
|
355
|
+
this.pendingBytes += bytes
|
|
356
|
+
this.peakPendingCount = Math.max(this.peakPendingCount, this.pendingCount)
|
|
357
|
+
this.peakPendingBytes = Math.max(this.peakPendingBytes, this.pendingBytes)
|
|
358
|
+
} else if (count) {
|
|
359
|
+
// Critical memory boundary: discard raw base64 BEFORE this event can join a
|
|
360
|
+
// chain blocked behind an earlier upload.
|
|
361
|
+
stripRawImages(evt, imgs, this.placeholder)
|
|
362
|
+
if (!this.overloadNotified) {
|
|
363
|
+
this.overloadNotified = true
|
|
364
|
+
this.onOverload({ count, bytes, maxPending: this.maxPending, maxBytes: this.maxBytes })
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Preserve the old synchronous fast path when no image upload is ahead of us.
|
|
369
|
+
if (!accepted && this.tasks.size === 0) {
|
|
370
|
+
emitTail(evt)
|
|
371
|
+
return true
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const task = { evt, images: imgs, emitTail, accepted, emitted: false, done: false }
|
|
375
|
+
this.tasks.add(task)
|
|
376
|
+
this.tail = this.tail.catch(() => {}).then(() => this._run(task))
|
|
377
|
+
return true
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async _run(task) {
|
|
381
|
+
if (task.done || this.closed) return
|
|
382
|
+
try {
|
|
383
|
+
if (task.accepted) {
|
|
384
|
+
for (const image of task.images) {
|
|
385
|
+
if (!image._queueReserved || this.closed) continue
|
|
386
|
+
try {
|
|
387
|
+
const path = await this.upload(task.evt, image, this.activeUploads)
|
|
388
|
+
task.evt.content[image.idx] = { type: 'image', path, mediaType: image.mediaType }
|
|
389
|
+
} catch (error) {
|
|
390
|
+
task.evt.content[image.idx] = { type: 'text', text: this.placeholder }
|
|
391
|
+
if (!this.closed) this.onUploadError(error)
|
|
392
|
+
} finally {
|
|
393
|
+
image.b64 = ''
|
|
394
|
+
this._release(image)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (!this.closed && !task.emitted) {
|
|
399
|
+
task.emitTail(task.evt)
|
|
400
|
+
task.emitted = true
|
|
401
|
+
}
|
|
402
|
+
} finally {
|
|
403
|
+
task.done = true
|
|
404
|
+
this.tasks.delete(task)
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
_release(image) {
|
|
409
|
+
if (!image?._queueReserved) return
|
|
410
|
+
image._queueReserved = false
|
|
411
|
+
this.pendingCount = Math.max(0, this.pendingCount - 1)
|
|
412
|
+
this.pendingBytes = Math.max(0, this.pendingBytes - (image._queueBytes || 0))
|
|
413
|
+
image._queueBytes = 0
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async idle() {
|
|
417
|
+
await this.tail.catch(() => {})
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Session close/provider switch/shutdown is synchronous. Cancel active deadlines,
|
|
421
|
+
// replace any still-raw image, and flush queued tails in insertion order before the
|
|
422
|
+
// owner persists/deletes the entry. Later promise continuations see done/closed.
|
|
423
|
+
close({ flush = true } = {}) {
|
|
424
|
+
if (this.closed) return
|
|
425
|
+
this.closed = true
|
|
426
|
+
const reason = new Error('code-image queue closed')
|
|
427
|
+
for (const upload of this.activeUploads) upload.cancel(reason)
|
|
428
|
+
this.activeUploads.clear()
|
|
429
|
+
for (const task of this.tasks) {
|
|
430
|
+
stripRawImages(task.evt, task.images, this.placeholder)
|
|
431
|
+
for (const image of task.images) this._release(image)
|
|
432
|
+
if (flush && !task.emitted) {
|
|
433
|
+
try { task.emitTail(task.evt) } catch { /* teardown must continue */ }
|
|
434
|
+
task.emitted = true
|
|
435
|
+
}
|
|
436
|
+
task.done = true
|
|
437
|
+
}
|
|
438
|
+
this.tasks.clear()
|
|
439
|
+
this.pendingCount = 0
|
|
440
|
+
this.pendingBytes = 0
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
213
444
|
// Pure aggregation for the /usage report — replay a session's retained log events and
|
|
214
445
|
// fold every completed turn (kind:'result' with usage) into per-model + total token/cost
|
|
215
446
|
// sums. No side effects, import-safe (bridge/test-usage-summary.mjs exercises it), so the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.246",
|
|
4
4
|
"description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"bridge.mjs",
|
|
11
11
|
"sdk-smoke.mjs",
|
|
12
|
+
"sdk-admission.mjs",
|
|
13
|
+
"sdk-admission.mjs",
|
|
12
14
|
"launcher.mjs",
|
|
13
15
|
"byok-detect.mjs",
|
|
14
16
|
"context-windows.mjs",
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// SDK checks protect agent-serving room processes only. Account/service commands
|
|
2
|
+
// remain available so an operator can inspect or repair a bad SDK installation.
|
|
3
|
+
export function requiresSdkAdmission(argv) {
|
|
4
|
+
return Boolean(argv?.[0]) && argv[0] !== 'verify-sdk'
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function sdkSmokePassed(output) {
|
|
8
|
+
return /^SMOKE:PASS:[^:\n]+(?:\n|$)/.test(String(output || ''))
|
|
9
|
+
}
|
package/session-store.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import os from 'node:os'
|
|
12
12
|
import fs from 'node:fs'
|
|
13
13
|
import path from 'node:path'
|
|
14
|
+
import { SPAWN } from './cross-terminal.mjs'
|
|
14
15
|
|
|
15
16
|
// TP_PAIR_ROOT override exists for tests + sandboxes so they never touch the real
|
|
16
17
|
// ~/.thinkpool-pair store. Read LIVE per call (not captured at module load) so a test
|
|
@@ -35,9 +36,51 @@ const archiveDir = (room) => path.join(dir(room), '.archive')
|
|
|
35
36
|
// Only resume the live SDK context for recent sessions — an expired session id
|
|
36
37
|
// fails ("No conversation found"); past this window we restore transcript only.
|
|
37
38
|
const RESUME_MAX_AGE_MS = 12 * 60 * 60 * 1000
|
|
38
|
-
|
|
39
|
+
// A live snapshot exists precisely so a live structured lane can survive a bridge
|
|
40
|
+
// restart. Keep every lane the bridge can admit. Explicitly closed lanes move to
|
|
41
|
+
// .archive/, so closed/history retention stays separate from this live set.
|
|
42
|
+
export const LIVE_SESSION_MAX = SPAWN.machineMax
|
|
39
43
|
|
|
40
|
-
function ensureDir(room) {
|
|
44
|
+
function ensureDir(room) { fs.mkdirSync(dir(room), { recursive: true }) }
|
|
45
|
+
|
|
46
|
+
// Commit a replacement through a sibling file so a restart never observes a torn
|
|
47
|
+
// final record. The temp file is fsynced before the same-directory rename: readers
|
|
48
|
+
// see the complete previous value or the complete replacement, never a half-write.
|
|
49
|
+
// This is shared by session JSON plus the restart-critical names and PTY metadata.
|
|
50
|
+
let tempSerial = 0
|
|
51
|
+
let beforeAtomicRenameForTest = null
|
|
52
|
+
function atomicCommit(file, contents) {
|
|
53
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.tmp.${process.pid}.${Date.now()}.${tempSerial++}`)
|
|
54
|
+
let fd = null
|
|
55
|
+
try {
|
|
56
|
+
fd = fs.openSync(tmp, 'wx', 0o600)
|
|
57
|
+
// fs.writeSync may legally write fewer bytes than requested. Never fsync and
|
|
58
|
+
// promote a partial temp file over the last good snapshot.
|
|
59
|
+
const bytes = Buffer.from(contents)
|
|
60
|
+
for (let offset = 0; offset < bytes.length;) {
|
|
61
|
+
const written = fs.writeSync(fd, bytes, offset, bytes.length - offset)
|
|
62
|
+
if (written <= 0) throw new Error(`short write while committing ${file}`)
|
|
63
|
+
offset += written
|
|
64
|
+
}
|
|
65
|
+
fs.fsyncSync(fd)
|
|
66
|
+
fs.closeSync(fd); fd = null
|
|
67
|
+
beforeAtomicRenameForTest?.({ file, tmp })
|
|
68
|
+
fs.renameSync(tmp, file)
|
|
69
|
+
return true
|
|
70
|
+
} catch (error) {
|
|
71
|
+
try { if (fd != null) fs.closeSync(fd) } catch { /* best effort */ }
|
|
72
|
+
try { fs.rmSync(tmp, { force: true }) } catch { /* best effort */ }
|
|
73
|
+
throw error
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Direct persistence tests inject a crash-equivalent immediately before rename.
|
|
77
|
+
// Production never configures this hook.
|
|
78
|
+
export function setAtomicCommitHookForTest(hook) {
|
|
79
|
+
beforeAtomicRenameForTest = typeof hook === 'function' ? hook : null
|
|
80
|
+
}
|
|
81
|
+
function persistenceError(action, file, error) {
|
|
82
|
+
console.error(`[thinkpool-pair session-store] ${action} failed for ${file}: ${error?.message || error}`)
|
|
83
|
+
}
|
|
41
84
|
// Move a session file into <room>/.archive/ instead of unlinking it. Archived files
|
|
42
85
|
// are OUTSIDE the readdirSync(dir) path loadAll/listRecs read, so a bridge restart
|
|
43
86
|
// never resurrects them — but they survive on disk for manual recovery. This is the
|
|
@@ -50,7 +93,11 @@ function archive(room, file) {
|
|
|
50
93
|
const base = path.basename(file)
|
|
51
94
|
const dst = path.join(archiveDir(room), `${base}.${Date.now()}`)
|
|
52
95
|
fs.renameSync(file, dst)
|
|
53
|
-
|
|
96
|
+
return true
|
|
97
|
+
} catch (error) {
|
|
98
|
+
persistenceError('archive session snapshot', file, error)
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
54
101
|
}
|
|
55
102
|
function listRecs(room) {
|
|
56
103
|
try {
|
|
@@ -65,7 +112,12 @@ function listRecs(room) {
|
|
|
65
112
|
// truncated log's first event ts drifts far from real creation. Load-time only.
|
|
66
113
|
try { const s = fs.statSync(p); r._bt = s.birthtimeMs || s.mtimeMs || 0 } catch { r._bt = 0 }
|
|
67
114
|
return r
|
|
68
|
-
} catch {
|
|
115
|
+
} catch (error) {
|
|
116
|
+
// Keep the damaged file in place for recovery; never silently make a
|
|
117
|
+
// restart look like a deliberately closed lane.
|
|
118
|
+
persistenceError('parse session snapshot', p, error)
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
69
121
|
})
|
|
70
122
|
.filter(Boolean)
|
|
71
123
|
} catch { return [] }
|
|
@@ -80,8 +132,8 @@ function prune(room) {
|
|
|
80
132
|
// Only UNNAMED sessions are eligible for prune. A named terminal is deliberate
|
|
81
133
|
// user intent (a project) and must never be silently destroyed by churn.
|
|
82
134
|
const prunable = files.filter((x) => { const id = namedId(x.p); return !id || !names[id] })
|
|
83
|
-
prunable.slice(
|
|
84
|
-
} catch {
|
|
135
|
+
prunable.slice(LIVE_SESSION_MAX).forEach((x) => archive(room, x.p))
|
|
136
|
+
} catch (error) { persistenceError('prune live session snapshots', dir(room), error) }
|
|
85
137
|
}
|
|
86
138
|
|
|
87
139
|
// ── Durable append-only event log (unbounded history; the <id>.json snapshot is
|
|
@@ -210,14 +262,23 @@ export function readDurablePage(room, id, beforeSeq, limit = 200) {
|
|
|
210
262
|
|
|
211
263
|
const timers = new Map()
|
|
212
264
|
function write(room, id, data) {
|
|
213
|
-
|
|
265
|
+
const file = path.join(dir(room), `${id}.json`)
|
|
266
|
+
try {
|
|
267
|
+
ensureDir(room)
|
|
268
|
+
atomicCommit(file, JSON.stringify({ ...data, id, savedAt: Date.now() }))
|
|
269
|
+
prune(room)
|
|
270
|
+
return true
|
|
271
|
+
} catch (error) {
|
|
272
|
+
persistenceError('write session snapshot', file, error)
|
|
273
|
+
return false
|
|
274
|
+
}
|
|
214
275
|
}
|
|
215
276
|
// Debounced — events arrive in bursts; one write per ~1.5s is plenty.
|
|
216
277
|
export function saveSession(room, id, data) {
|
|
217
278
|
clearTimeout(timers.get(id))
|
|
218
279
|
timers.set(id, setTimeout(() => write(room, id, data), 1500))
|
|
219
280
|
}
|
|
220
|
-
export function flushSession(room, id, data) { clearTimeout(timers.get(id)); write(room, id, data) }
|
|
281
|
+
export function flushSession(room, id, data) { clearTimeout(timers.get(id)); return write(room, id, data) }
|
|
221
282
|
// User-initiated close: cancel any pending debounced save and move the on-disk
|
|
222
283
|
// record to .archive/ so a later loadAll() (bridge restart) can't resurrect a
|
|
223
284
|
// closed terminal — but the transcript stays recoverable. PTY ids never have an
|
|
@@ -225,7 +286,8 @@ export function flushSession(room, id, data) { clearTimeout(timers.get(id)); wri
|
|
|
225
286
|
export function deleteSession(room, id) {
|
|
226
287
|
clearTimeout(timers.get(id)); timers.delete(id)
|
|
227
288
|
const p = path.join(dir(room), `${id}.json`)
|
|
228
|
-
try {
|
|
289
|
+
try { return !fs.existsSync(p) || archive(room, p) }
|
|
290
|
+
catch (error) { persistenceError('archive closed session snapshot', p, error); return false }
|
|
229
291
|
}
|
|
230
292
|
|
|
231
293
|
// Most-recently-saved structured session for the room (drives attached restore).
|
|
@@ -240,7 +302,7 @@ export function canResume(rec) {
|
|
|
240
302
|
// EVERY saved session for the room, oldest→newest (so tabs reappear in order).
|
|
241
303
|
// Drives resume-ALL on a bridge restart — without this only the latest session
|
|
242
304
|
// came back and every backgrounded terminal died on restart despite its state
|
|
243
|
-
// sitting on disk. Bounded by
|
|
305
|
+
// sitting on disk. Bounded by LIVE_SESSION_MAX, exactly the admission capacity.
|
|
244
306
|
export function loadAll(room) {
|
|
245
307
|
return listRecs(room).sort((a, b) => (a.savedAt || 0) - (b.savedAt || 0))
|
|
246
308
|
}
|
|
@@ -254,7 +316,9 @@ export function loadPtyId(room) {
|
|
|
254
316
|
try { return fs.readFileSync(ptyFile(room), 'utf8').trim() || null } catch { return null }
|
|
255
317
|
}
|
|
256
318
|
export function savePtyId(room, id) {
|
|
257
|
-
|
|
319
|
+
const file = ptyFile(room)
|
|
320
|
+
try { ensureDir(room); atomicCommit(file, String(id)); return true }
|
|
321
|
+
catch (error) { persistenceError('write PTY id', file, error); return false }
|
|
258
322
|
}
|
|
259
323
|
|
|
260
324
|
// Per-room terminal display names (terminal id -> label), set via the web's
|
|
@@ -265,8 +329,15 @@ export function savePtyId(room, id) {
|
|
|
265
329
|
// (NOT *.json) so it never lands in listRecs/loadAll.
|
|
266
330
|
const namesFile = (room) => path.join(dir(room), '.names')
|
|
267
331
|
export function loadNames(room) {
|
|
268
|
-
|
|
332
|
+
const file = namesFile(room)
|
|
333
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')) || {} }
|
|
334
|
+
catch (error) {
|
|
335
|
+
if (error?.code !== 'ENOENT') persistenceError('parse terminal names', file, error)
|
|
336
|
+
return {}
|
|
337
|
+
}
|
|
269
338
|
}
|
|
270
339
|
export function saveNames(room, names) {
|
|
271
|
-
|
|
340
|
+
const file = namesFile(room)
|
|
341
|
+
try { ensureDir(room); atomicCommit(file, JSON.stringify(names || {})); return true }
|
|
342
|
+
catch (error) { persistenceError('write terminal names', file, error); return false }
|
|
272
343
|
}
|