thinkpool-pair 0.7.278 → 0.7.279

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.
Files changed (2) hide show
  1. package/codex-session.mjs +40 -1
  2. package/package.json +1 -1
package/codex-session.mjs CHANGED
@@ -389,6 +389,12 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
389
389
  let appServer = null
390
390
  let appServerThreadReady = false
391
391
  let activeTurnId = null
392
+ // App Server notifications are asynchronous to turn/completed. In production a
393
+ // stopped turn emitted its durable `aborted` boundary, then delivered a queued
394
+ // item/started 89ms later and a Bash result 159.9s after that. Without a native
395
+ // turn fence those late rows resurrected the lane after Stop. Keep a bounded set
396
+ // of closed native turns and accept turn-scoped traffic only for the one active id.
397
+ const closedAppServerTurnIds = new Set()
392
398
  const appServerItems = new Map()
393
399
  const streamedAgentItems = new Map() // item id → { cid, text }
394
400
  let steerChain = Promise.resolve()
@@ -423,6 +429,18 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
423
429
  }
424
430
 
425
431
  const note = (text) => { try { onEvent?.({ kind: 'note', text }) } catch { /* noop */ } }
432
+
433
+ const closeAppServerTurn = (turnId = activeTurnId) => {
434
+ const id = String(turnId || '')
435
+ if (!id) return
436
+ closedAppServerTurnIds.add(id)
437
+ while (closedAppServerTurnIds.size > 64) closedAppServerTurnIds.delete(closedAppServerTurnIds.values().next().value)
438
+ }
439
+
440
+ const appServerTurnIsCurrent = (params = {}) => {
441
+ const turnId = String(params.turnId || '')
442
+ return !!turnId && !ended && !aborted && !closedAppServerTurnIds.has(turnId) && turnId === String(activeTurnId || '')
443
+ }
426
444
  if (requestedResume && !resumeUsable) {
427
445
  queueMicrotask(() => note('The stopped Codex turn had no resumable context; continuing in a fresh thread.'))
428
446
  }
@@ -439,9 +457,15 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
439
457
 
440
458
  function appServerNotification(method, params = {}) {
441
459
  if (method === 'turn/started' && params.turn?.id) {
442
- activeTurnId = activeTurnId || params.turn.id
460
+ const turnId = String(params.turn.id)
461
+ if (ended || aborted) { closeAppServerTurn(turnId); return }
462
+ if (!activeTurnId) activeTurnId = turnId
443
463
  return
444
464
  }
465
+ // Every mapped App Server notification below is turn-scoped in the tested
466
+ // protocol and carries turnId. Fail closed on missing, stale, completed, or
467
+ // interrupted ids so a provider's buffered stdout cannot reopen a closed turn.
468
+ if ((method === 'turn/plan/updated' || method === 'item/agentMessage/delta' || method === 'item/started' || method === 'item/completed' || method === 'error') && !appServerTurnIsCurrent(params)) return
445
469
  if (method === 'turn/plan/updated') {
446
470
  const todos = (Array.isArray(params.plan) ? params.plan : []).map(({ step, status }) => ({
447
471
  content: step || '',
@@ -486,6 +510,13 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
486
510
  }
487
511
 
488
512
  async function appServerRequest(method, params = {}) {
513
+ // A request racing Stop must never raise a fresh room card after the turn's
514
+ // durable boundary. Respond with the protocol's ordinary denial shape so the
515
+ // native runtime can settle without reopening any ThinkPool state.
516
+ if (!appServerTurnIsCurrent(params)) {
517
+ if (method === 'item/tool/requestUserInput') return { answers: {} }
518
+ return { decision: 'decline' }
519
+ }
489
520
  const card = codexApprovalCard(method, params, appServerItems.get(params.itemId))
490
521
  if (!card) throw new Error(`Unsupported Codex App Server request: ${method}`)
491
522
  // Missing/broken room permission plumbing denies safely. App Server expects
@@ -578,10 +609,12 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
578
609
  // id. abort() already closed the room boundary; interrupt the now-known
579
610
  // native turn before waiting, and never let it continue invisibly.
580
611
  if (aborted || ended) {
612
+ closeAppServerTurn(activeTurnId)
581
613
  try { await appServer.interrupt({ threadId: sessionId, turnId: activeTurnId }) } catch { /* boundary is already closed */ }
582
614
  return true
583
615
  }
584
616
  const completed = await appServer.waitForTurn(activeTurnId)
617
+ closeAppServerTurn(completed?.turn?.id || activeTurnId)
585
618
  const status = completed?.turn?.status
586
619
  if (aborted || status === 'interrupted') {
587
620
  emitTurnBoundary({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: completed?.turn?.durationMs, denials: 0, resultText: null })
@@ -596,6 +629,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
596
629
  // turns only; they can safely resume this thread through the stable driver.
597
630
  appServerDisabled = true
598
631
  appServerThreadReady = false
632
+ closeAppServerTurn(activeTurnId)
599
633
  try { appServer?.end() } catch { /* noop */ }
600
634
  appServer = null
601
635
  if (!ended) pushMappedEvent({ type: 'turn.failed', message: `codex app-server turn failed: ${error?.message || error}` })
@@ -625,10 +659,12 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
625
659
  activeTurnId = started?.turn?.id || started?.turnId
626
660
  if (!activeTurnId) throw new Error('Codex review/start returned no turn id')
627
661
  if (aborted || ended) {
662
+ closeAppServerTurn(activeTurnId)
628
663
  try { await appServer.interrupt({ threadId: sessionId, turnId: activeTurnId }) } catch { /* boundary is already closed */ }
629
664
  return true
630
665
  }
631
666
  const completed = await appServer.waitForTurn(activeTurnId)
667
+ closeAppServerTurn(completed?.turn?.id || activeTurnId)
632
668
  const status = completed?.turn?.status
633
669
  if (aborted || status === 'interrupted') {
634
670
  emitTurnBoundary({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: completed?.turn?.durationMs, denials: 0, resultText: null })
@@ -640,6 +676,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
640
676
  } catch (error) {
641
677
  appServerDisabled = true
642
678
  appServerThreadReady = false
679
+ closeAppServerTurn(activeTurnId)
643
680
  try { appServer?.end() } catch { /* noop */ }
644
681
  appServer = null
645
682
  if (!ended) pushMappedEvent({ type: 'turn.failed', message: `codex review failed: ${error?.message || error}` })
@@ -834,6 +871,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
834
871
  const bootstrapOnly = turnActive && !activeTurnId && !child
835
872
  aborted = true
836
873
  queue.length = 0
874
+ closeAppServerTurn(activeTurnId)
837
875
  if (appServer && activeTurnId) { void appServer.interrupt({ threadId: sessionId, turnId: activeTurnId }).catch(() => {}) ; return }
838
876
  if (child) { try { child.kill('SIGTERM') } catch { /* noop */ } ; setTimeout(() => { try { child?.kill('SIGKILL') } catch { /* noop */ } }, 1500) }
839
877
  else if (bootstrapOnly) {
@@ -847,6 +885,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
847
885
  ended = true
848
886
  turnActive = false
849
887
  queue.length = 0
888
+ closeAppServerTurn(activeTurnId)
850
889
  if (child) { try { child.kill() } catch { /* noop */ } }
851
890
  try { appServer?.end() } catch { /* noop */ }
852
891
  void mcpHttpPromise?.then((h) => h.close()).catch(() => {})
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.278",
3
+ "version": "0.7.279",
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": {