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 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 + surfaced loudly (stderr + the announce banner) instead of
365
- // silently breaking turns. sdkStatus feeds the announce. Fully guarded — a check
366
- // error never blocks serving.
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(/^SMOKE:PASS/.test(out) ? 0 : 1)
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 ⚠⚠ agent SDK v${sdkStatus.version} FAILED the compatibility smoke test — ${sdkStatus.reason}.\n ⚠⚠ turns may misbehave. Pin a known-good SDK + restart:\n bridge/package.json → "@anthropic-ai/claude-agent-sdk": "<good version>", then republish + Restart & update.\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
- const headers = { 'Content-Type': 'application/json' }
1508
- if (codeAuthToken) headers.Authorization = `Bearer ${codeAuthToken}`
1509
- const res = await fetch(`${WEB_BASE}/api/code-image`, {
1510
- method: 'POST',
1511
- headers,
1512
- body: JSON.stringify({ code: room, term, cid, idx: im.idx, mediaType: im.mediaType, b64: im.b64 }),
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
- // Defer an image-bearing tool_result behind a per-session upload chain (preserves
1520
- // arrival order among images), then log + broadcast the URL-only event through the
1521
- // normal path. pushLog is the single seq-assignment point, so the deferred event
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.imageChain = (entry.imageChain || Promise.resolve()).then(async () => {
1527
- for (const im of imgs) {
1528
- try {
1529
- const path = await uploadCodeImage(id, evt.cid, im)
1530
- evt.content[im.idx] = { type: 'image', path, mediaType: im.mediaType }
1531
- } catch (e) {
1532
- process.stderr.write(`\n ◇ screenshot upload failed (${e?.message || e}).\n`)
1533
- evt.content[im.idx] = { type: 'text', text: '[screenshot omitted — upload failed]' }
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. Other kinds emit synchronously, unchanged.
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
- if (imgs.length) { deferImageEvent(entry, id, evt, imgs, emitTail); return }
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.245",
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
- const KEEP = 8 // newest N UNNAMED session files per room (named are immune — see prune)
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) { try { fs.mkdirSync(dir(room), { recursive: true }) } catch { /* noop */ } }
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
- } catch { /* noop */ }
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 { return null }
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(KEEP).forEach((x) => archive(room, x.p))
84
- } catch { /* noop */ }
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
- try { ensureDir(room); fs.writeFileSync(path.join(dir(room), `${id}.json`), JSON.stringify({ ...data, id, savedAt: Date.now() })); prune(room) } catch { /* noop */ }
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 { if (fs.existsSync(p)) archive(room, p) } catch { /* noop */ }
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 KEEP (prune keeps the newest 8).
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
- try { ensureDir(room); fs.writeFileSync(ptyFile(room), String(id)) } catch { /* noop */ }
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
- try { return JSON.parse(fs.readFileSync(namesFile(room), 'utf8')) || {} } catch { return {} }
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
- try { ensureDir(room); fs.writeFileSync(namesFile(room), JSON.stringify(names || {})) } catch { /* noop */ }
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
  }