thinkpool-pair 0.7.308 → 0.7.310

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.
@@ -0,0 +1,23 @@
1
+ // Realtime delivers code-abort and the browser's newly-flushed code-turn through
2
+ // independent handlers. Keep that next human turn behind the runtime's real abort
3
+ // completion so a late aborted boundary cannot close the new turn.
4
+
5
+ export function queueAbortBarrier(entry, runAbort = () => entry?.session?.abort?.()) {
6
+ if (!entry) return Promise.resolve()
7
+ const prior = entry._abortBarrier || Promise.resolve()
8
+ let barrier
9
+ barrier = Promise.resolve(prior)
10
+ .catch(() => {})
11
+ .then(() => runAbort())
12
+ .catch(() => {})
13
+ .finally(() => {
14
+ if (entry._abortBarrier === barrier) entry._abortBarrier = null
15
+ })
16
+ entry._abortBarrier = barrier
17
+ return barrier
18
+ }
19
+
20
+ export async function waitForAbortBarrier(entry) {
21
+ const barrier = entry?._abortBarrier
22
+ if (barrier) await barrier
23
+ }
package/bridge.mjs CHANGED
@@ -65,6 +65,7 @@ import { commandOnPath } from './agent-detect.mjs'
65
65
  import { requiresSdkAdmission, sdkSmokePassed } from './sdk-admission.mjs'
66
66
  import { hermesUserInputResponse } from './question-response.mjs'
67
67
  import { hostMemoryAdmission } from './host-memory.mjs'
68
+ import { queueAbortBarrier, waitForAbortBarrier } from './abort-turn-barrier.mjs'
68
69
 
69
70
  const STRUCTURED_MODES = new Set(['default', 'acceptEdits', 'plan', 'review', 'bypassPermissions'])
70
71
  import { FLOW_CONDUCTOR_PROMPT, FLOW_LANE_PROMPT, FLOW_CODEX_CONDUCTOR_PROMPT, FLOW_CODEX_LANE_PROMPT, buildConductorEnv, assembleCrossWaveContext, buildLanePrompt } from './flow-conductor.mjs'
@@ -3996,6 +3997,12 @@ channel
3996
3997
  .on('broadcast', { event: 'code-turn' }, async ({ payload }) => {
3997
3998
  const s = payload?.term && sessions.get(payload.term)
3998
3999
  if (!s || payload.text == null) return
4000
+ // Stop and the browser's queue flush are separate realtime frames. Codex and
4001
+ // Hermes need their native cancellation to settle before the flushed turn is
4002
+ // accepted; otherwise the old aborted result can land after the new `you` line
4003
+ // and close it. Re-check identity after the await in case the lane was closed.
4004
+ await waitForAbortBarrier(s)
4005
+ if (sessions.get(payload.term) !== s || !s.session) return
3999
4006
  markActivity()
4000
4007
  // B2/C: a real human turn is hop 0 with fresh cross-terminal budgets. This is
4001
4008
  // the loop-breaker reset point — an injected (cross-posted) turn does NOT come
@@ -4290,7 +4297,11 @@ channel
4290
4297
  })
4291
4298
  .on('broadcast', { event: 'code-abort' }, ({ payload }) => {
4292
4299
  const s = payload?.term && sessions.get(payload.term)
4293
- if (s) { drainPending(s); s.session.abort(); announce() } // settle any open permission card so the hook doesn't hang
4300
+ if (s) {
4301
+ drainPending(s) // settle any open permission card so the hook doesn't hang
4302
+ queueAbortBarrier(s).finally(() => announce())
4303
+ announce()
4304
+ }
4294
4305
  })
4295
4306
  .on('broadcast', { event: 'code-mode' }, ({ payload }) => {
4296
4307
  const s = payload?.term && sessions.get(payload.term)
package/codex-session.mjs CHANGED
@@ -589,6 +589,9 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
589
589
  }
590
590
 
591
591
  async function runAppServer(prompt, options = {}) {
592
+ // Stop can win before the queued pump enters native startup. Do not launch a
593
+ // fresh App Server merely to discover the accepted room turn was cancelled.
594
+ if (ended || aborted) return true
592
595
  if (!await ensureAppServer()) return false
593
596
  // Stop can land while the cold App Server / MCP bootstrap is still awaiting.
594
597
  // The accepted turn was already closed at the room boundary; never start it
@@ -651,6 +654,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
651
654
  }
652
655
 
653
656
  async function runAppServerReview(commandText) {
657
+ if (ended || aborted) return true
654
658
  if (!await ensureAppServer()) return false
655
659
  if (ended || aborted) return true
656
660
  mapper.setUsageBaseline(sessionId ? readCodexThreadUsage(sessionId) : null)
@@ -869,11 +873,13 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
869
873
  return true
870
874
  },
871
875
  abort() {
876
+ const activeChain = chain
872
877
  const bootstrapOnly = turnActive && !activeTurnId && !child
873
878
  aborted = true
874
879
  queue.length = 0
875
880
  closeAppServerTurn(activeTurnId)
876
- if (appServer && activeTurnId) { void appServer.interrupt({ threadId: sessionId, turnId: activeTurnId }).catch(() => {}) ; return }
881
+ let interrupt = Promise.resolve()
882
+ if (appServer && activeTurnId) interrupt = appServer.interrupt({ threadId: sessionId, turnId: activeTurnId }).catch(() => {})
877
883
  if (child) { try { child.kill('SIGTERM') } catch { /* noop */ } ; setTimeout(() => { try { child?.kill('SIGKILL') } catch { /* noop */ } }, 1500) }
878
884
  else if (bootstrapOnly) {
879
885
  // No native turn exists to produce a boundary. Close the optimistic
@@ -881,6 +887,10 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
881
887
  // aborted check discard the delayed startup.
882
888
  emitTurnBoundary({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 0, durationMs: undefined, denials: 0, resultText: null })
883
889
  }
890
+ // `code-turn` may already be queued behind Stop in Realtime. Resolve only
891
+ // after the interrupted native turn's chain has fully unwound; accepting a
892
+ // new turn earlier lets the old finally/result close the new lifecycle.
893
+ return interrupt.then(() => activeChain).catch(() => activeChain).then(() => {})
884
894
  },
885
895
  end() {
886
896
  ended = true
@@ -566,7 +566,7 @@ export function startHermesSession({
566
566
  })
567
567
  return true
568
568
  },
569
- abort() {
569
+ async abort() {
570
570
  if (!turnActive) return
571
571
  queuedTurns.length = 0
572
572
  queueDrainPending = false
@@ -582,14 +582,16 @@ export function startHermesSession({
582
582
  retireClient()
583
583
  client?.end()
584
584
  finishAbortedTurn()
585
+ await promptChain.catch(() => {})
585
586
  return
586
587
  }
587
588
  if (!sessionId || !client?.alive) {
588
589
  finishAbortedTurn()
590
+ await promptChain.catch(() => {})
589
591
  return
590
592
  }
591
593
  finishAbortedTurn()
592
- void client.notify('session/cancel', { sessionId }).catch((error) => {
594
+ await client.notify('session/cancel', { sessionId }).catch((error) => {
593
595
  // Never display a stopped lane while the un-cancelled Hermes process
594
596
  // might still be executing. A transport failure tears down the ACP
595
597
  // process and makes the lane honestly non-resumable until restart.
@@ -597,6 +599,10 @@ export function startHermesSession({
597
599
  client?.end()
598
600
  emit({ kind: 'error', message: `Hermes cancellation delivery failed; ACP process stopped: ${error?.message || error}`, recoverable: true })
599
601
  })
602
+ // The ACP cancellation notification is not itself the completion edge.
603
+ // Wait for the active prompt promise to unwind (including the 0.18.2
604
+ // cancelled-response recovery) before a bridge queue flush starts anew.
605
+ await promptChain.catch(() => {})
600
606
  },
601
607
  end() {
602
608
  ended = true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.308",
3
+ "version": "0.7.310",
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": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "bridge.mjs",
11
+ "abort-turn-barrier.mjs",
11
12
  "host-memory.mjs",
12
13
  "sdk-smoke.mjs",
13
14
  "sdk-admission.mjs",
package/reap-terminal.mjs CHANGED
@@ -36,21 +36,28 @@ export async function reapTerminalRow ({ id, token, supabaseUrl, anonKey, shutti
36
36
  if (!token) return { reaped: false, reason: 'anon' }
37
37
  if (!id) return { reaped: false, reason: 'no-id' }
38
38
  try {
39
- // Mirror of the client reap: DELETE code_terminals WHERE id = <terminal id>. The
40
- // terminal id IS the code_terminals PK (client/bridge-minted uuid). RLS scopes the
41
- // delete to this bridge's sessions, so id alone is sufficient + idempotent (a fast
42
- // client tab may have reaped first — 204 / 0 rows is fine).
43
- const response = await fetchImpl(`${supabaseUrl}/rest/v1/code_terminals?id=eq.${encodeURIComponent(id)}`, {
44
- method: 'DELETE',
45
- headers: { apikey: anonKey, Authorization: `Bearer ${token}` },
39
+ // A terminal at the 50k transcript cap cannot archive + cascade-delete inside
40
+ // the authenticated role's ordinary 8s statement timeout. The member-authorized
41
+ // RPC scopes a longer timeout to this one cleanup operation; a raw REST DELETE
42
+ // timed out and left the row as a cross-device dormant ghost (8TMX546Q).
43
+ const response = await fetchImpl(`${supabaseUrl}/rest/v1/rpc/delete_code_terminal`, {
44
+ method: 'POST',
45
+ headers: {
46
+ apikey: anonKey,
47
+ Authorization: `Bearer ${token}`,
48
+ 'Content-Type': 'application/json',
49
+ },
50
+ body: JSON.stringify({ p_terminal: id }),
46
51
  })
47
52
  // fetch resolves for HTTP failures. A 401/500 did not reap the row and must
48
53
  // stay visible to the fire-and-forget caller as a failed best-effort cleanup.
49
54
  if (!response?.ok) {
55
+ let detail = ''
56
+ try { detail = String(await response.text()).slice(0, 240) } catch { /* no body */ }
50
57
  return fail({
51
58
  reaped: false,
52
59
  reason: 'error',
53
- error: `HTTP ${response?.status ?? 'unknown'}${response?.statusText ? ` ${response.statusText}` : ''}`,
60
+ error: `HTTP ${response?.status ?? 'unknown'}${response?.statusText ? ` ${response.statusText}` : ''}${detail ? `: ${detail}` : ''}`,
54
61
  status: response?.status,
55
62
  })
56
63
  }
@@ -1,3 +1,5 @@
1
+ import { reapTerminalRow } from './reap-terminal.mjs'
2
+
1
3
  /* Reconcile durable terminal rows after a bridge has restored its on-disk roster.
2
4
 
3
5
  A clean close is reaped by reap-terminal.mjs. This module closes the other
@@ -48,13 +50,16 @@ export async function reconcileTerminalRows ({
48
50
 
49
51
  const reaped = []
50
52
  for (const id of stale) {
51
- const del = await fetchImpl(
52
- `${supabaseUrl}/rest/v1/code_terminals?id=eq.${encodeURIComponent(id)}` +
53
- `&host_id=eq.${encodeURIComponent(hostId)}`,
54
- { method: 'DELETE', headers: { apikey: anonKey, Authorization: `Bearer ${token}` } },
55
- )
56
- if (!del?.ok) {
57
- return fail({ reason: 'error', error: `delete HTTP ${del?.status ?? 'unknown'}`, status: del?.status, reaped })
53
+ const deletion = await reapTerminalRow({
54
+ id, token, supabaseUrl, anonKey, fetchImpl,
55
+ })
56
+ if (!deletion.reaped) {
57
+ return fail({
58
+ reason: 'error',
59
+ error: `delete ${deletion.error || deletion.reason}`,
60
+ status: deletion.status,
61
+ reaped,
62
+ })
58
63
  }
59
64
  reaped.push(id)
60
65
  }