thinkpool-pair 0.7.357 → 0.7.358

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
@@ -1755,6 +1755,11 @@ const designArtifacts = new Map() // previewId → trusted source record
1755
1755
  const designQueues = new Map() // producer term → validated requests
1756
1756
  const designActive = new Map() // producer term → request awaiting result + proof
1757
1757
  const designRestore = new Map() // current previewId → immediately previous verified record/edit
1758
+ // Realtime can carry the settled DOM for ordinary authored mockups. Including
1759
+ // that already-uploaded snapshot in the correlated verified-live event lets an
1760
+ // open Design lane advance revisions without a second auth/sign/download round
1761
+ // trip. Large artifacts keep the normal signed-storage path.
1762
+ const MAX_INLINE_DESIGN_REVISION_BYTES = 128 * 1024
1758
1763
  const designStatus = (payload) => designChannel.send({
1759
1764
  type: 'broadcast', event: 'design-status', payload: { ts: Date.now(), ...payload },
1760
1765
  })
@@ -1777,7 +1782,12 @@ const finishDesign = (term, state, extra = {}) => {
1777
1782
  const maybeVerifyDesign = (term) => {
1778
1783
  const active = designActive.get(term)
1779
1784
  if (!active?.resultOk || !active.proof) return
1780
- finishDesign(term, 'verified-live', { message: 'Verified at desktop and mobile.', artifact: active.proof, canRestore: active.record.sourceKind !== 'preview' })
1785
+ finishDesign(term, 'verified-live', {
1786
+ message: 'Verified at desktop and mobile.',
1787
+ artifact: active.proof.artifact,
1788
+ ...(active.proof.artifactHtml ? { artifactHtml: active.proof.artifactHtml } : {}),
1789
+ canRestore: active.record.sourceKind !== 'preview',
1790
+ })
1781
1791
  }
1782
1792
 
1783
1793
  function pumpDesign(term) {
@@ -1949,7 +1959,10 @@ const handleManifest = async ({ box, term, trustedDesignSource = false, slug, m
1949
1959
  : designRecord.sourcePath === active?.record?.sourcePath
1950
1960
  if (correlated && dual && sameTarget && revisionProvesChange) {
1951
1961
  if (active.record.sourceKind !== 'preview') designRestore.set(designRecord.previewId, { priorRecord: active.record, request: active.request })
1952
- active.proof = artifact
1962
+ const artifactHtml = Buffer.byteLength(displaySource, 'utf8') <= MAX_INLINE_DESIGN_REVISION_BYTES
1963
+ ? displaySource
1964
+ : null
1965
+ active.proof = { artifact, artifactHtml }
1953
1966
  maybeVerifyDesign(term)
1954
1967
  }
1955
1968
  }
@@ -2238,6 +2251,80 @@ function restoredTurnOpen(log) {
2238
2251
  return false
2239
2252
  }
2240
2253
 
2254
+ function emitCodexCompactionControl(entry, text, by = null) {
2255
+ const evt = { kind: 'control', text, by }
2256
+ pushLog(entry, evt)
2257
+ bcast('code-event', { term: entry.id, evt })
2258
+ }
2259
+
2260
+ async function runCodexCompaction(entry, request = {}) {
2261
+ const recap = buildRecapFromLog(entry.log, RECAP_CAP, { reason: 'compact' })
2262
+ if (!recap) {
2263
+ emitCodexCompactionControl(entry, 'nothing to compact — context unchanged', request.by)
2264
+ return
2265
+ }
2266
+ const preTokens = entry.lastUsage?.ctx?.used || null
2267
+ bcast('code-event', { term: entry.id, evt: { kind: 'compact', status: 'start', ts: Date.now() } })
2268
+ let nativeCompacted = false
2269
+ try {
2270
+ nativeCompacted = await entry.session.compactContext?.()
2271
+ } catch {
2272
+ // compactContext classifies delivery certainty. A truly unexpected throw
2273
+ // remains non-replayable here; resetting after an unknown native request can
2274
+ // execute compaction twice against two different threads.
2275
+ nativeCompacted = null
2276
+ } finally {
2277
+ // Native compaction emits usage while compactActive=true, so onEvent
2278
+ // legitimately announces a busy edge. It has no ordinary result event;
2279
+ // close that borrowed edge after compactContext clears compactActive.
2280
+ if (!entry.session.turnActive && settleLaneControl(entry)) announce()
2281
+ }
2282
+ if (nativeCompacted === true) {
2283
+ const evt = { kind: 'compaction', trigger: 'manual', preTokens, by: request.by, native: true }
2284
+ pushLog(entry, evt)
2285
+ bcast('code-event', { term: entry.id, evt })
2286
+ bcast('code-event', { term: entry.id, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
2287
+ entry.flush?.()
2288
+ return
2289
+ }
2290
+ if (nativeCompacted == null) {
2291
+ bcast('code-event', { term: entry.id, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
2292
+ emitCodexCompactionControl(entry, 'Native compaction is still settling; existing context was preserved.', request.by)
2293
+ entry.flush?.()
2294
+ return
2295
+ }
2296
+ if (entry.session.clearContext?.() === false) {
2297
+ bcast('code-event', { term: entry.id, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
2298
+ emitCodexCompactionControl(entry, 'Codex context compaction unavailable right now', request.by)
2299
+ return
2300
+ }
2301
+ entry.pendingRecap = recap
2302
+ const evt = { kind: 'compaction', trigger: 'manual', preTokens, by: request.by }
2303
+ pushLog(entry, evt)
2304
+ bcast('code-event', { term: entry.id, evt })
2305
+ // The new native thread has only the bounded recap queued for the next human
2306
+ // turn. Reset the visible meter to that conservative text estimate.
2307
+ if (entry.lastUsage?.ctx?.max) {
2308
+ const used = Math.ceil(recap.length / 4)
2309
+ const max = entry.lastUsage.ctx.max
2310
+ const usage = { kind: 'usage', ctx: { ...entry.lastUsage.ctx, used, max, pct: Math.min(100, Math.round((used / max) * 100)), over: used > max } }
2311
+ entry.lastUsage = usage
2312
+ bcast('code-event', { term: entry.id, evt: usage })
2313
+ }
2314
+ bcast('code-event', { term: entry.id, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
2315
+ entry.flush?.()
2316
+ }
2317
+
2318
+ function startCodexCompaction(entry, request = {}) {
2319
+ if (!entry || entry.runtime !== 'codex' || entry.codexCompactionPromise) return false
2320
+ const job = runCodexCompaction(entry, request)
2321
+ entry.codexCompactionPromise = job
2322
+ void job.finally(() => {
2323
+ if (entry.codexCompactionPromise === job) entry.codexCompactionPromise = null
2324
+ }).catch(() => { /* runCodexCompaction already surfaced classified failures */ })
2325
+ return true
2326
+ }
2327
+
2241
2328
  // Active-worktree snapshot for the per-turn ROOM NOW tail. Cached ~60s per cwd —
2242
2329
  // a subprocess on every turn of every lane would be waste, and worktrees change on
2243
2330
  // minutes-scale. Plain `git worktree list` output (path · sha · [branch]) is the
@@ -3656,6 +3743,15 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3656
3743
  pushLog(entry, ce)
3657
3744
  bcast('code-event', { term: id, evt: ce })
3658
3745
  }
3746
+ // A busy Codex lane accepts one deferred /compact intent. Claim the
3747
+ // compaction synchronously at the terminal result boundary so a new human
3748
+ // turn cannot slip into the gap, and coalesce every repeated tap into this
3749
+ // one job.
3750
+ if ((evt.kind === 'result' || evt.kind === 'error') && entry.runtime === 'codex' && entry.pendingCodexCompaction) {
3751
+ const request = entry.pendingCodexCompaction
3752
+ entry.pendingCodexCompaction = null
3753
+ startCodexCompaction(entry, request)
3754
+ }
3659
3755
  // ── Slice 3: the turn settled. Two things happen, in this order.
3660
3756
  // 1. Every outstanding permission card on this lane is now dead (the SDK
3661
3757
  // won't ask again for a turn that ended) — clearAll retracts any
@@ -4678,6 +4774,7 @@ channel
4678
4774
  }
4679
4775
  if (/^\/clear\s*$/.test(text)) {
4680
4776
  s.pendingRecap = null // /clear means forget context — drop any un-fired carry recap
4777
+ s.pendingCodexCompaction = null // explicit clear supersedes a deferred compact
4681
4778
  if (s.runtime === 'codex' || s.runtime === 'hermes') s.session.clearContext?.()
4682
4779
  else s.session.sendTurn(text)
4683
4780
  // The seq reset is load-bearing on BOTH ends: the client's clear handler sets
@@ -4698,61 +4795,16 @@ channel
4698
4795
  // No persisted ctl line — the live indicator + the CompactionCard are the record.
4699
4796
  if (/^\/compact\s*$/.test(text)) {
4700
4797
  if (s.runtime === 'codex') {
4798
+ // One lifecycle owns every distinct press. During an ordinary turn the
4799
+ // first request is queued and truthfully runs at its result boundary;
4800
+ // later taps, including taps during native compaction, coalesce silently.
4801
+ if (s.codexCompactionPromise || s.pendingCodexCompaction) return
4701
4802
  if (s.session.turnActive) {
4702
- ctlLine('Compact after this turn ends.', { tone: 'caution' })
4703
- return
4704
- }
4705
- const recap = buildRecapFromLog(s.log, RECAP_CAP, { reason: 'compact' })
4706
- if (!recap) {
4707
- ctlLine('nothing to compact — context unchanged')
4708
- return
4709
- }
4710
- const preTokens = s.lastUsage?.ctx?.used || null
4711
- bcast('code-event', { term: payload.term, evt: { kind: 'compact', status: 'start', ts: Date.now() } })
4712
- let nativeCompacted = false
4713
- try {
4714
- nativeCompacted = await s.session.compactContext?.()
4715
- } catch {
4716
- // The bounded recap below is the safe fallback for a native transport
4717
- // failure. The roster edge is still settled in finally.
4718
- } finally {
4719
- // Native compaction emits usage while compactActive=true, so onEvent
4720
- // legitimately announces a busy edge. It has no ordinary result event;
4721
- // close that borrowed edge here after compactContext's finally clears
4722
- // compactActive, or "Compacting…" degrades into stale "Thinking…".
4723
- // A browser turn may have arrived while compactContext awaited App
4724
- // Server readiness. Never let control cleanup settle that ordinary
4725
- // turn's busy edge; the normal result boundary owns it.
4726
- if (!s.session.turnActive && settleLaneControl(s)) announce()
4727
- }
4728
- if (nativeCompacted) {
4729
- const ce = { kind: 'compaction', trigger: 'manual', preTokens, by: payload.by, native: true }
4730
- pushLog(s, ce)
4731
- bcast('code-event', { term: payload.term, evt: ce })
4732
- bcast('code-event', { term: payload.term, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
4733
- s.flush?.()
4803
+ s.pendingCodexCompaction = { by: payload.by, cid: payload.cid }
4804
+ ctlLine('Compaction queued for after this turn.', { tone: 'caution' })
4734
4805
  return
4735
4806
  }
4736
- if (s.session.clearContext?.() === false) {
4737
- bcast('code-event', { term: payload.term, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
4738
- ctlLine('Codex context compaction unavailable right now')
4739
- return
4740
- }
4741
- s.pendingRecap = recap
4742
- const ce = { kind: 'compaction', trigger: 'manual', preTokens, by: payload.by }
4743
- pushLog(s, ce)
4744
- bcast('code-event', { term: payload.term, evt: ce })
4745
- // The new native thread has only the bounded recap queued for the next
4746
- // human turn. Reset the visible meter to that conservative text estimate.
4747
- if (s.lastUsage?.ctx?.max) {
4748
- const used = Math.ceil(recap.length / 4)
4749
- const max = s.lastUsage.ctx.max
4750
- const usage = { kind: 'usage', ctx: { ...s.lastUsage.ctx, used, max, pct: Math.min(100, Math.round((used / max) * 100)), over: used > max } }
4751
- s.lastUsage = usage
4752
- bcast('code-event', { term: payload.term, evt: usage })
4753
- }
4754
- bcast('code-event', { term: payload.term, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
4755
- s.flush?.()
4807
+ startCodexCompaction(s, { by: payload.by, cid: payload.cid })
4756
4808
  return
4757
4809
  }
4758
4810
  s.compacting = true
package/codex-session.mjs CHANGED
@@ -1153,7 +1153,11 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
1153
1153
  // synchronously after sendTurn(), so this is the edge that makes Thinking,
1154
1154
  // Stop, and the tab spinner appear at dispatch time rather than at the
1155
1155
  // first provider event many seconds later.
1156
- if (!turnActive) {
1156
+ // Native compaction already owns that public busy edge. A human turn
1157
+ // accepted during it stays queued without flipping the ordinary-turn
1158
+ // latch; otherwise compactContext's post-bootstrap recheck mistakes the
1159
+ // queued turn for an active one and abandons a compaction it already owns.
1160
+ if (!turnActive && !compactActive) {
1157
1161
  aborted = false
1158
1162
  armTurnLiveness()
1159
1163
  }
@@ -1229,13 +1233,20 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
1229
1233
  },
1230
1234
  async compactContext() {
1231
1235
  if (turnActive || compactActive) return false
1232
- const readyAppServer = await ensureAppServer()
1233
- // ensureAppServer may need to boot the transport. Re-check after that
1234
- // await so a normal turn that began meanwhile owns the lane exclusively.
1235
- if (!readyAppServer || turnActive || compactActive) return false
1236
+ // A Stop latch belongs to the turn it cancelled. sendTurn clears it when
1237
+ // accepting new work, but /compact enters through this direct control path.
1238
+ // Leaving it set makes appServerNotification discard the real
1239
+ // turn/started event, then the five-second waiter falsely declares failure
1240
+ // while Codex continues compacting the old thread in the background.
1241
+ aborted = false
1236
1242
  compactActive = true
1237
1243
  let startTimer = null
1244
+ let compactAccepted = false
1238
1245
  try {
1246
+ const readyAppServer = await ensureAppServer()
1247
+ // compactActive claims the lane before bootstrap, so sendTurn queues
1248
+ // behind us. An independently active ordinary turn still wins safely.
1249
+ if (!readyAppServer || turnActive) return false
1239
1250
  const compactTurn = new Promise((resolve, reject) => {
1240
1251
  startTimer = setTimeout(() => {
1241
1252
  compactStartWaiter = null
@@ -1244,6 +1255,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
1244
1255
  compactStartWaiter = { resolve, reject }
1245
1256
  })
1246
1257
  await appServer.compact({ threadId: sessionId })
1258
+ compactAccepted = true
1247
1259
  const turnId = await compactTurn
1248
1260
  if (startTimer) clearTimeout(startTimer)
1249
1261
  const completed = await appServer.waitForTurn(turnId)
@@ -1252,6 +1264,14 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
1252
1264
  if (status === 'completed') return true
1253
1265
  throw new Error(completed?.turn?.error?.message || `compaction ${status || 'failed'}`)
1254
1266
  } catch (error) {
1267
+ // Once thread/compact/start was accepted, replaying via a bounded-recap
1268
+ // reset is unsafe: the native compaction may still complete (the exact
1269
+ // production failure this guard closes). Preserve the existing thread
1270
+ // and report uncertainty instead of executing a second compaction path.
1271
+ if (compactAccepted || !['rejected', 'not_sent'].includes(error?.delivery)) {
1272
+ note(`Native Codex compaction status is uncertain; existing context was preserved: ${error?.message || error}`)
1273
+ return null
1274
+ }
1255
1275
  note(`Native Codex compaction unavailable; using bounded recap fallback: ${error?.message || error}`)
1256
1276
  return false
1257
1277
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.357",
3
+ "version": "0.7.358",
4
4
  "description": "Connect Claude Code, Codex, or Hermes on your computer to a Thinkpool Code room.",
5
5
  "type": "module",
6
6
  "bin": {