thinkpool-pair 0.7.331 → 0.7.333

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
@@ -92,7 +92,7 @@ function stopFlowPreviews (flowId, laneId = null) {
92
92
  }
93
93
  import { FLOW_REVIEWER_PROMPT, FLOW_CODEX_REVIEWER_PROMPT, revertLane, parseReviewVerdict, reviewVerdictToReflection } from './flow-review.mjs'
94
94
  import { reviewGateDecision } from './flow-review-gate.mjs'
95
- import { readReviewFile, runReviewCheck } from './review-check.mjs'
95
+ import { IMMUTABLE_REVIEW_TOOL_EXTRAS, readReviewFile, runReviewCheck } from './review-check.mjs'
96
96
  import { pairAdjudicationPrompt, reviewReflectionDecision, REVIEW_DEFAULTS } from './flow-review-reflect.mjs'
97
97
  import { mergeWorktrees, inlineSingleHtml, initRepo } from './flow-assembly.mjs'
98
98
  import { canDispatch, FLOW_LIMITS, makeBudget, recordSpend, killSwitchEnv } from './flow-budget.mjs'
@@ -117,7 +117,7 @@ const flowRedispatch = new Map()
117
117
  // wave BEFORE overrun. Lives bridge-side because waves dispatch across separate
118
118
  // broadcasts; without persistent state the cap can never bite.
119
119
  const flowBudgets = new Map()
120
- import { formatPeek, PEEK, readTerminalBudgetDecision, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, CROSSROOM_BUS, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, closeLaneDecision, laneBusyOf, laneStatusOf, settleLaneBusy, nativeClaudeFallbackHint, buildDispatchPreview, authorizeDirectDispatch } from './cross-terminal.mjs'
120
+ import { formatPeek, PEEK, readTerminalTurnBudgetDecision, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, CROSSROOM_BUS, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, closeLaneDecision, laneBusyOf, laneStatusOf, settleLaneBusy, nativeClaudeFallbackHint, buildDispatchPreview, authorizeDirectDispatch } from './cross-terminal.mjs'
121
121
  import { createStandalonePairResponder, standalonePairIdentity } from './direct-pair-room.mjs'
122
122
  import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
123
123
  import { supersedeDispatchLease } from './dispatch-lease.mjs'
@@ -2596,13 +2596,16 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2596
2596
  if (args?.terminal && !target) {
2597
2597
  return { content: [{ type: 'text', text: formatPeek({ selfId: id, sessions, terms, names: termNames, terminal: args.terminal, lines: args?.lines }) }] }
2598
2598
  }
2599
- const budget = readTerminalBudgetDecision({
2599
+ const budget = readTerminalTurnBudgetDecision({
2600
2600
  targeted: !!args?.terminal,
2601
2601
  targetedCount: entry.peekCount,
2602
2602
  rosterCount: entry.peekRosterCount,
2603
+ budgetTurnRev: entry.peekBudgetTurnRev,
2604
+ currentTurnRev: entry._turnRev,
2603
2605
  })
2604
2606
  entry.peekCount = budget.targetedCount
2605
2607
  entry.peekRosterCount = budget.rosterCount
2608
+ entry.peekBudgetTurnRev = budget.budgetTurnRev
2606
2609
  if (!budget.ok) return { content: [{ type: 'text', text: budget.reason }] }
2607
2610
  const targetEntry = target?.kind === 'agent' ? sessions.get(target.id) : null
2608
2611
  const text = formatPeek({ selfId: id, sessions, terms, names: termNames, terminal: args?.terminal, lines: args?.lines })
@@ -3029,11 +3032,13 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3029
3032
  'Read one regular file from the declared immutable review target. This always reads the target’s pinned Git object, never a live worktree; target and path are validated and the response is byte-bounded.',
3030
3033
  { target: z.string(), path: z.string() },
3031
3034
  async (args) => ({ content: [{ type: 'text', text: JSON.stringify(await readReviewFile({ target: args?.target, filePath: args?.path, snapshots: entry.flowReviewSnapshots })) }] }),
3035
+ IMMUTABLE_REVIEW_TOOL_EXTRAS,
3032
3036
  ), tool(
3033
3037
  'run_review_check',
3034
3038
  'Run one fixed verification check against an immutable git-archived review target. This accepts only the declared target and a fixed check id; it never accepts a shell command, cwd, environment, executable, or arguments. The check runs in a scratch archive, never in a builder worktree.',
3035
3039
  { target: z.string(), checkId: z.string() },
3036
3040
  async (args) => ({ content: [{ type: 'text', text: JSON.stringify(await runReviewCheck({ target: args?.target, checkId: args?.checkId, snapshots: entry.flowReviewSnapshots })) }] }),
3041
+ IMMUTABLE_REVIEW_TOOL_EXTRAS,
3037
3042
  )] : []),
3038
3043
  ],
3039
3044
  })
package/codex-session.mjs CHANGED
@@ -34,6 +34,7 @@ import { CODEX_THINKPOOL_FIRST_TURN_PREAMBLE, buildThinkPoolTurnGuidance, create
34
34
  import { questionAnswerResponse } from './question-response.mjs'
35
35
  import { codexReviewTarget, isCodexCompactCommand } from './codex-commands.mjs'
36
36
  import { createCumulativeEventRelay } from './cumulative-event-relay.mjs'
37
+ import { stallDecision, stallEvent } from './turn-stall.mjs'
37
38
 
38
39
  const DEFAULT_SANDBOX = 'workspace-write'
39
40
  const SAFE_SANDBOXES = new Set(['read-only', 'workspace-write', 'danger-full-access'])
@@ -362,7 +363,7 @@ function appServerItemForMapper(item) {
362
363
  * @param {string} [o.providerConfig] optional -c overrides / provider block (M2: from the provider registry)
363
364
  * @returns {{ sendTurn(text, options?), abort(), end(), readonly sessionId }}
364
365
  */
365
- export function startCodexSession({ cwd, model, effort: initialEffort = 'high', resume, env, sandbox, mode = 'default', onEvent, providerConfig, terminalRolePrompt, rolePrompt, roomContext, mcpServers, prepareCwd = null, requestPermission, appServerGate = codexAppServerGate, appServerFactory = createCodexAppServer, mcpHttpFactory = startCodexMcpHttp, spawnImpl = spawn, admitStart = null }) {
366
+ export function startCodexSession({ cwd, model, effort: initialEffort = 'high', resume, env, sandbox, mode = 'default', onEvent, providerConfig, terminalRolePrompt, rolePrompt, roomContext, mcpServers, prepareCwd = null, requestPermission, appServerGate = codexAppServerGate, appServerFactory = createCodexAppServer, mcpHttpFactory = startCodexMcpHttp, spawnImpl = spawn, admitStart = null, stallOptions = {} }) {
366
367
  let activeMode = CODEX_MODE_CONFIG[mode] ? mode : 'default'
367
368
  let modeConfig = codexConfigForMode(activeMode)
368
369
  sandbox = normalizeCodexSandbox(sandbox || modeConfig.sandbox)
@@ -381,6 +382,30 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
381
382
  let aborted = false
382
383
  let ended = false
383
384
  let turnActive = false
385
+ // Codex can accept a room turn and then emit nothing while App Server is
386
+ // booting or an upstream model call is retrying. The room's generic roster
387
+ // clock can label that lane "stalled", but only the owning transport can
388
+ // surface an honest in-pane status and safely interrupt/replay the exact
389
+ // logical prompt. Keep this lifecycle parallel to Claude's shared watchdog.
390
+ const stallNow = typeof stallOptions.now === 'function' ? stallOptions.now : Date.now
391
+ const stallSetInterval = typeof stallOptions.setInterval === 'function' ? stallOptions.setInterval : setInterval
392
+ const stallClearInterval = typeof stallOptions.clearInterval === 'function' ? stallOptions.clearInterval : clearInterval
393
+ const STALL_MS = Number.isFinite(stallOptions.stallMs)
394
+ ? Math.max(1, Number(stallOptions.stallMs))
395
+ : Math.max(30000, parseInt(childEnv.TP_STALL_MS, 10) || 90000)
396
+ const FORCE_STOP_MS = Number.isFinite(stallOptions.forceStopMs)
397
+ ? Math.max(STALL_MS, Number(stallOptions.forceStopMs))
398
+ : Math.max(STALL_MS * 3, parseInt(childEnv.TP_FORCE_STOP_MS, 10) || 300000)
399
+ const STALL_INTERVAL_MS = Number.isFinite(stallOptions.intervalMs)
400
+ ? Math.max(1, Number(stallOptions.intervalMs))
401
+ : 5000
402
+ let lastEvtTs = stallNow()
403
+ let stalledSent = false
404
+ let stallRetried = false
405
+ let stallRetryRequested = false
406
+ let stallGiveupRequested = false
407
+ let awaitingUser = 0
408
+ let stallTimer = null
384
409
  let activeModel = model || null
385
410
  let activeEffort = EFFORT_LEVELS.has(initialEffort) ? initialEffort : 'high'
386
411
  let latestUsageSnapshot = sessionId ? readCodexThreadUsage(sessionId) : null
@@ -403,7 +428,14 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
403
428
  let chain = Promise.resolve()
404
429
  const queue = []
405
430
 
406
- const relayEvent = createCumulativeEventRelay((event) => { try { onEvent?.(event) } catch { /* consumer isolation */ } })
431
+ // Synthetic watchdog chrome must not count as provider activity, otherwise
432
+ // its 90s status would reset the silence clock and postpone the 5m recovery.
433
+ // Everything on the normal relay path is real transport/session activity and
434
+ // immediately clears the one-shot stalled banner.
435
+ const emitRaw = createCumulativeEventRelay((event) => { try { onEvent?.(event) } catch { /* consumer isolation */ } })
436
+ const touchActivity = () => { lastEvtTs = stallNow(); stalledSent = false }
437
+ const relayEvent = (event) => { touchActivity(); emitRaw(event) }
438
+ relayEvent.cancel = () => emitRaw.cancel()
407
439
  const mapper = new CodexEventMapper({
408
440
  onEvent: relayEvent,
409
441
  model: model || null,
@@ -430,6 +462,15 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
430
462
  relayEvent(event)
431
463
  }
432
464
 
465
+ const armTurnLiveness = ({ retry = false } = {}) => {
466
+ turnActive = true
467
+ lastEvtTs = stallNow()
468
+ stalledSent = false
469
+ stallRetryRequested = false
470
+ stallGiveupRequested = false
471
+ if (!retry) stallRetried = false
472
+ }
473
+
433
474
  const note = (text) => relayEvent({ kind: 'note', text })
434
475
 
435
476
  const closeAppServerTurn = (turnId = activeTurnId) => {
@@ -462,12 +503,14 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
462
503
  const turnId = String(params.turn.id)
463
504
  if (ended || aborted) { closeAppServerTurn(turnId); return }
464
505
  if (!activeTurnId) activeTurnId = turnId
506
+ touchActivity()
465
507
  return
466
508
  }
467
509
  // Every mapped App Server notification below is turn-scoped in the tested
468
510
  // protocol and carries turnId. Fail closed on missing, stale, completed, or
469
511
  // interrupted ids so a provider's buffered stdout cannot reopen a closed turn.
470
512
  if ((method === 'turn/plan/updated' || method === 'item/agentMessage/delta' || method === 'item/started' || method === 'item/completed' || method === 'error') && !appServerTurnIsCurrent(params)) return
513
+ touchActivity()
471
514
  if (method === 'turn/plan/updated') {
472
515
  const todos = (Array.isArray(params.plan) ? params.plan : []).map(({ step, status }) => ({
473
516
  content: step || '',
@@ -524,7 +567,14 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
524
567
  // Missing/broken room permission plumbing denies safely. App Server expects
525
568
  // an ordinary response even for denial; an RPC error can strand the turn.
526
569
  let decision = 'deny'
570
+ awaitingUser++
527
571
  try { decision = await requestPermission?.(card) } catch { /* deny */ }
572
+ finally {
573
+ awaitingUser = Math.max(0, awaitingUser - 1)
574
+ // Give the resumed model call a fresh watchdog window. Human think time
575
+ // is intentionally excluded and must not force-stop the turn next tick.
576
+ touchActivity()
577
+ }
528
578
  if (method === 'item/tool/requestUserInput') return codexUserInputResponse(decision, card.questions)
529
579
  return codexApprovalResponse(decision)
530
580
  }
@@ -556,6 +606,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
556
606
  },
557
607
  })
558
608
  await appServer.start()
609
+ touchActivity()
559
610
  }
560
611
  sessionId = await appServer.startThread({
561
612
  threadId: turnNo > 0 ? sessionId : null,
@@ -568,12 +619,16 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
568
619
  // independent ThinkPool sandbox / approval posture above.
569
620
  config: { 'features.default_mode_request_user_input': true },
570
621
  })
622
+ touchActivity()
571
623
  // App Server reports thread/start before its per-thread MCP clients finish
572
624
  // initializing. Starting the first turn in that gap snapshots a toolset
573
625
  // without ThinkPool even though the server becomes ready milliseconds later.
574
626
  // Hold the turn boundary until the room MCP is actually ready; a timeout or
575
627
  // startup failure drops into the required-MCP codex exec fallback below.
576
- if (peer?.url) await appServer.waitForMcpServer({ threadId: sessionId, name: 'thinkpool' })
628
+ if (peer?.url) {
629
+ await appServer.waitForMcpServer({ threadId: sessionId, name: 'thinkpool' })
630
+ touchActivity()
631
+ }
577
632
  appServerThreadReady = true
578
633
  pushMappedEvent({ type: 'thread.started', thread_id: sessionId })
579
634
  return true
@@ -588,15 +643,101 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
588
643
  }
589
644
  }
590
645
 
646
+ function stopStalledTransport({ force = false } = {}) {
647
+ const server = appServer
648
+ const turnId = activeTurnId
649
+
650
+ if (server && force) {
651
+ // A second stall is terminal. End the resident process, not merely its
652
+ // turn: an interrupt whose completion notification is itself wedged would
653
+ // otherwise leave `chain` unresolved and silently queue every later send.
654
+ closeAppServerTurn(turnId)
655
+ appServerDisabled = true
656
+ appServerThreadReady = false
657
+ try { server.end() } catch { /* noop */ }
658
+ if (appServer === server) appServer = null
659
+ } else if (server && turnId) {
660
+ // Fence the old native id before interrupting so buffered deltas/tool rows
661
+ // cannot cross into the retried logical turn.
662
+ closeAppServerTurn(turnId)
663
+ Promise.resolve(server.interrupt({ threadId: sessionId, turnId })).catch(() => {
664
+ // An interrupt rejected before delivery: tear down the uncertain control
665
+ // channel. The current pump will use the exec fallback for its one retry.
666
+ appServerDisabled = true
667
+ appServerThreadReady = false
668
+ try { server.end() } catch { /* noop */ }
669
+ if (appServer === server) appServer = null
670
+ })
671
+ } else if (server) {
672
+ // The stall happened during cold App Server / MCP bootstrap, before a
673
+ // native turn id existed. Closing that process makes the pending startup
674
+ // reject; ensureAppServer then returns false and the same prompt gets its
675
+ // bounded retry through codex exec.
676
+ appServerDisabled = true
677
+ appServerThreadReady = false
678
+ try { server.end() } catch { /* noop */ }
679
+ if (appServer === server) appServer = null
680
+ }
681
+
682
+ if (child) {
683
+ const stalledChild = child
684
+ try { stalledChild.kill('SIGTERM') } catch { /* noop */ }
685
+ setTimeout(() => {
686
+ if (child === stalledChild) { try { stalledChild.kill('SIGKILL') } catch { /* noop */ } }
687
+ }, 1500)
688
+ }
689
+ }
690
+
691
+ stallTimer = stallSetInterval(() => {
692
+ const quietMs = stallNow() - lastEvtTs
693
+ const action = stallDecision({
694
+ turnActive,
695
+ awaitingUser,
696
+ quietMs,
697
+ stallMs: STALL_MS,
698
+ forceStopMs: FORCE_STOP_MS,
699
+ stalledSent,
700
+ stallRetried,
701
+ })
702
+ if (action === 'none') return
703
+
704
+ const event = stallEvent(action, quietMs)
705
+ if (event) emitRaw(event)
706
+ if (action === 'status') {
707
+ stalledSent = true
708
+ return
709
+ }
710
+ if (action === 'retry') {
711
+ // Preserve the prompt and retry budget in pump(); do not use the public
712
+ // abort flag because that intentionally discards queued work and emits a
713
+ // terminal aborted boundary.
714
+ stallRetried = true
715
+ stallRetryRequested = true
716
+ lastEvtTs = stallNow()
717
+ stalledSent = false
718
+ stopStalledTransport()
719
+ return
720
+ }
721
+
722
+ // The retry also went silent. Surface the durable terminal boundary now so
723
+ // the lane cannot remain busy if the native interrupt itself is wedged.
724
+ stallGiveupRequested = true
725
+ stallRetryRequested = false
726
+ queue.length = 0
727
+ emitTurnBoundary({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: undefined, denials: 0, resultText: null })
728
+ stopStalledTransport({ force: true })
729
+ }, STALL_INTERVAL_MS)
730
+ stallTimer.unref?.()
731
+
591
732
  async function runAppServer(prompt, options = {}) {
592
733
  // Stop can win before the queued pump enters native startup. Do not launch a
593
734
  // fresh App Server merely to discover the accepted room turn was cancelled.
594
- if (ended || aborted) return true
735
+ if (ended || aborted || stallGiveupRequested) return true
595
736
  if (!await ensureAppServer()) return false
596
737
  // Stop can land while the cold App Server / MCP bootstrap is still awaiting.
597
738
  // The accepted turn was already closed at the room boundary; never start it
598
739
  // after bootstrap finally resolves.
599
- if (ended || aborted) return true
740
+ if (ended || aborted || stallGiveupRequested) return true
600
741
  mapper.setUsageBaseline(sessionId ? readCodexThreadUsage(sessionId) : null)
601
742
  turnActive = true
602
743
  try {
@@ -609,6 +750,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
609
750
  approvalPolicy: modeConfig.approvalPolicy,
610
751
  collaborationMode: codexDefaultCollaborationMode(activeModel, activeEffort),
611
752
  })
753
+ touchActivity()
612
754
  // Stop may land after the RPC was written but before startTurn returns its
613
755
  // id. abort() already closed the room boundary; interrupt the now-known
614
756
  // native turn before waiting, and never let it continue invisibly.
@@ -620,11 +762,18 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
620
762
  const completed = await appServer.waitForTurn(activeTurnId)
621
763
  closeAppServerTurn(completed?.turn?.id || activeTurnId)
622
764
  const status = completed?.turn?.status
623
- if (aborted || status === 'interrupted') {
765
+ if (aborted) {
766
+ emitTurnBoundary({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: completed?.turn?.durationMs, denials: 0, resultText: null })
767
+ } else if (status === 'interrupted' && (stallRetryRequested || stallGiveupRequested)) {
768
+ // The watchdog owns this lifecycle: retry is re-armed by pump(), while
769
+ // giveup already emitted the one terminal boundary synchronously.
770
+ } else if (status === 'interrupted') {
624
771
  emitTurnBoundary({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: completed?.turn?.durationMs, denials: 0, resultText: null })
625
772
  } else if (status === 'failed') {
773
+ stallRetryRequested = false
626
774
  pushMappedEvent({ type: 'turn.failed', message: completed?.turn?.error?.message || 'codex turn failed' })
627
775
  } else {
776
+ stallRetryRequested = false
628
777
  pushMappedEvent({ type: 'turn.completed' })
629
778
  }
630
779
  } catch (error) {
@@ -636,7 +785,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
636
785
  closeAppServerTurn(activeTurnId)
637
786
  try { appServer?.end() } catch { /* noop */ }
638
787
  appServer = null
639
- if (!ended) pushMappedEvent({ type: 'turn.failed', message: `codex app-server turn failed: ${error?.message || error}` })
788
+ if (!ended && !stallRetryRequested && !stallGiveupRequested) pushMappedEvent({ type: 'turn.failed', message: `codex app-server turn failed: ${error?.message || error}` })
640
789
  } finally {
641
790
  // A transport failure or interrupt can land after prose deltas but before
642
791
  // item/completed. Preserve that partial prose as one durable row rather
@@ -654,15 +803,16 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
654
803
  }
655
804
 
656
805
  async function runAppServerReview(commandText) {
657
- if (ended || aborted) return true
806
+ if (ended || aborted || stallGiveupRequested) return true
658
807
  if (!await ensureAppServer()) return false
659
- if (ended || aborted) return true
808
+ if (ended || aborted || stallGiveupRequested) return true
660
809
  mapper.setUsageBaseline(sessionId ? readCodexThreadUsage(sessionId) : null)
661
810
  turnActive = true
662
811
  try {
663
812
  const started = await appServer.startReview({ threadId: sessionId, target: codexReviewTarget(commandText) })
664
813
  activeTurnId = started?.turn?.id || started?.turnId
665
814
  if (!activeTurnId) throw new Error('Codex review/start returned no turn id')
815
+ touchActivity()
666
816
  if (aborted || ended) {
667
817
  closeAppServerTurn(activeTurnId)
668
818
  try { await appServer.interrupt({ threadId: sessionId, turnId: activeTurnId }) } catch { /* boundary is already closed */ }
@@ -671,11 +821,17 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
671
821
  const completed = await appServer.waitForTurn(activeTurnId)
672
822
  closeAppServerTurn(completed?.turn?.id || activeTurnId)
673
823
  const status = completed?.turn?.status
674
- if (aborted || status === 'interrupted') {
824
+ if (aborted) {
825
+ emitTurnBoundary({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: completed?.turn?.durationMs, denials: 0, resultText: null })
826
+ } else if (status === 'interrupted' && (stallRetryRequested || stallGiveupRequested)) {
827
+ // Watchdog-owned interruption; pump decides retry vs terminal giveup.
828
+ } else if (status === 'interrupted') {
675
829
  emitTurnBoundary({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: completed?.turn?.durationMs, denials: 0, resultText: null })
676
830
  } else if (status === 'failed') {
831
+ stallRetryRequested = false
677
832
  pushMappedEvent({ type: 'turn.failed', message: completed?.turn?.error?.message || 'codex review failed' })
678
833
  } else {
834
+ stallRetryRequested = false
679
835
  pushMappedEvent({ type: 'turn.completed' })
680
836
  }
681
837
  } catch (error) {
@@ -684,7 +840,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
684
840
  closeAppServerTurn(activeTurnId)
685
841
  try { appServer?.end() } catch { /* noop */ }
686
842
  appServer = null
687
- if (!ended) pushMappedEvent({ type: 'turn.failed', message: `codex review failed: ${error?.message || error}` })
843
+ if (!ended && !stallRetryRequested && !stallGiveupRequested) pushMappedEvent({ type: 'turn.failed', message: `codex review failed: ${error?.message || error}` })
688
844
  } finally {
689
845
  for (const state of streamedAgentItems.values()) {
690
846
  if (!state.text) continue
@@ -699,7 +855,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
699
855
  }
700
856
 
701
857
  async function runExec(prompt, options = {}) {
702
- if (ended || aborted) return
858
+ if (ended || aborted || stallGiveupRequested) return
703
859
  // First turn: fresh exec. Later turns: resume THIS lane's captured thread.
704
860
  // `--last` is process-global and can cross-wire two concurrently-active Codex
705
861
  // lanes, so it is never safe in a multi-lane bridge.
@@ -741,7 +897,10 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
741
897
  try { ev = JSON.parse(line) } catch { return /* non-JSON progress line */
742
898
  }
743
899
  if (ev.type === 'thread.started' && ev.thread_id) sessionId = sessionId || ev.thread_id
744
- if (ev.type === 'turn.completed' || ev.type === 'turn.failed') sawTerminalResult = true
900
+ if (ev.type === 'turn.completed' || ev.type === 'turn.failed') {
901
+ sawTerminalResult = true
902
+ stallRetryRequested = false
903
+ }
745
904
  pushMappedEvent(ev)
746
905
  })
747
906
  // stderr is codex's human progress channel; surface tail on failure only.
@@ -755,7 +914,10 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
755
914
  settled = true
756
915
  child = null
757
916
  turnActive = false
758
- if (aborted && !sawTerminalResult) {
917
+ if ((stallRetryRequested || stallGiveupRequested) && !sawTerminalResult && !aborted) {
918
+ // Watchdog interruption: pump either replays once or has already
919
+ // published the terminal giveup boundary.
920
+ } else if (aborted && !sawTerminalResult) {
759
921
  emitTurnBoundary({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: undefined, denials: 0, resultText: null })
760
922
  } else if (!aborted && !sawTerminalResult && code !== 0 && code != null) {
761
923
  emitTurnBoundary({ kind: 'error', message: `codex exec exited ${code}${stderrTail ? ': ' + stderrTail.trim().slice(-300) : ''}`, recoverable: true })
@@ -766,7 +928,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
766
928
  if (settled) return
767
929
  settled = true
768
930
  child = null
769
- emitTurnBoundary({ kind: 'error', message: `codex failed to start: ${e.message}`, recoverable: true })
931
+ if (!stallRetryRequested && !stallGiveupRequested) emitTurnBoundary({ kind: 'error', message: `codex failed to start: ${e.message}`, recoverable: true })
770
932
  resolve()
771
933
  })
772
934
  child.on('close', finish)
@@ -778,6 +940,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
778
940
  const next = queue.shift()
779
941
  if (!next) return
780
942
  chain = chain.then(async () => {
943
+ armTurnLiveness()
781
944
  // runExec sees turnNo===0 on the FIRST turn (fresh exec); increment only
782
945
  // after, so subsequent turns resume this lane's captured thread id.
783
946
  // Claude receives these through the Agent SDK's system-reminder path.
@@ -794,13 +957,36 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
794
957
  forceFullReminder: next.forceFullReminder,
795
958
  })
796
959
  const review = /^\s*\/review(?:\s|$)/i.test(String(next.text || ''))
797
- const usedAppServer = review
798
- ? await runAppServerReview(next.text)
799
- : await runAppServer(prompt, next.options)
800
- if (!usedAppServer) {
801
- if (review) {
802
- emitTurnBoundary({ kind: 'error', message: 'Codex review is unavailable without the tested App Server runtime.', recoverable: true })
803
- } else await runExec(prompt, next.options)
960
+ let retryAttempt = false
961
+ while (!ended && !aborted && !stallGiveupRequested) {
962
+ const usedAppServer = review
963
+ ? await runAppServerReview(next.text)
964
+ : await runAppServer(prompt, next.options)
965
+ if (!usedAppServer) {
966
+ if (review) {
967
+ stallRetryRequested = false
968
+ emitTurnBoundary({ kind: 'error', message: 'Codex review is unavailable without the tested App Server runtime.', recoverable: true })
969
+ } else {
970
+ // A watchdog interruption during cold App Server startup has no
971
+ // native turn to wait on. Treat the exec fallback itself as the one
972
+ // retry rather than accidentally granting a third attempt.
973
+ if (stallRetryRequested) {
974
+ stallRetryRequested = false
975
+ retryAttempt = true
976
+ armTurnLiveness({ retry: true })
977
+ emitRaw({ kind: 'note', text: 'retrying the stalled turn on a fresh connection' })
978
+ }
979
+ await runExec(prompt, next.options)
980
+ }
981
+ }
982
+ if (stallRetryRequested && !retryAttempt && !stallGiveupRequested && !ended && !aborted) {
983
+ stallRetryRequested = false
984
+ retryAttempt = true
985
+ armTurnLiveness({ retry: true })
986
+ emitRaw({ kind: 'note', text: 'retrying the stalled turn on a fresh connection' })
987
+ continue
988
+ }
989
+ break
804
990
  }
805
991
  turnNo++
806
992
  pump()
@@ -833,6 +1019,9 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
833
1019
  try { cwd = prepareCwd() || cwd } catch { /* keep original cwd */ }
834
1020
  }
835
1021
  if (turnActive && appServer && activeTurnId) {
1022
+ // A delivered human steer is new work for the active model call; give it
1023
+ // a fresh quiet window before declaring the transport stalled.
1024
+ touchActivity()
836
1025
  const fullReminder = usesFullThinkPoolReminder({ promptIndex, forceFull: thisTurnForceFull })
837
1026
  const prompt = buildCodexPrompt({
838
1027
  text,
@@ -866,7 +1055,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
866
1055
  // first provider event many seconds later.
867
1056
  if (!turnActive) {
868
1057
  aborted = false
869
- turnActive = true
1058
+ armTurnLiveness()
870
1059
  }
871
1060
  // start the pump if idle
872
1061
  if (queue.length === 1) pump()
@@ -876,6 +1065,8 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
876
1065
  const activeChain = chain
877
1066
  const bootstrapOnly = turnActive && !activeTurnId && !child
878
1067
  aborted = true
1068
+ stallRetryRequested = false
1069
+ stallGiveupRequested = false
879
1070
  queue.length = 0
880
1071
  closeAppServerTurn(activeTurnId)
881
1072
  let interrupt = Promise.resolve()
@@ -895,6 +1086,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
895
1086
  end() {
896
1087
  ended = true
897
1088
  turnActive = false
1089
+ if (stallTimer) stallClearInterval(stallTimer)
898
1090
  relayEvent.cancel()
899
1091
  queue.length = 0
900
1092
  closeAppServerTurn(activeTurnId)
@@ -18,7 +18,7 @@ export const PEEK = {
18
18
  // explicitly when a diagnosis genuinely needs deeper sibling history.
19
19
  defaultLines: 20,
20
20
  maxLines: 200,
21
- perTurnCap: 10, // targeted transcript reads allowed per user turn (bridge resets it)
21
+ perTurnCap: 10, // targeted transcript reads allowed per accepted room turn
22
22
  rosterPerTurnCap: 1, // no-argument roster reads use a separate allowance; ROOM NOW is default
23
23
  lineCap: 200, // per-line truncation
24
24
  rosterPreview: 100, // last-line preview length in the roster listing
@@ -37,7 +37,7 @@ export const readTerminalBudgetDecision = ({ targeted = false, targetedCount = 0
37
37
  ok: false,
38
38
  targetedCount: nextTargeted,
39
39
  rosterCount: nextRoster,
40
- reason: `Cross-terminal transcript read limit reached for this turn (${limits.perTurnCap}). Stop polling and continue with the worker results already collected; a new person-authored turn resets the allowance.`,
40
+ reason: `Cross-terminal transcript read limit reached for this turn (${limits.perTurnCap}). Stop polling and continue with the worker results already collected; a fresh accepted room turn resets the allowance.`,
41
41
  }
42
42
  return { ok: true, targetedCount: nextTargeted + 1, rosterCount: nextRoster }
43
43
  }
@@ -50,6 +50,34 @@ export const readTerminalBudgetDecision = ({ targeted = false, targetedCount = 0
50
50
  return { ok: true, targetedCount: nextTargeted, rosterCount: nextRoster + 1 }
51
51
  }
52
52
 
53
+ // Provider/model reconstruction can deliver a fresh accepted agent turn without
54
+ // another browser `code-turn` frame (context-carry and interrupted-resume paths).
55
+ // Key read allowances to the bridge-owned turn revision as a second reset seam,
56
+ // so a reconstructed runtime never inherits ten reads spent by the old model.
57
+ // This intentionally resets only read allowances; hop/post/spawn loop breakers
58
+ // remain tied to real person ingress and cannot be escaped by reconstruction.
59
+ export const readTerminalTurnBudgetDecision = ({
60
+ targeted = false,
61
+ targetedCount = 0,
62
+ rosterCount = 0,
63
+ budgetTurnRev = null,
64
+ currentTurnRev = 0,
65
+ } = {}, limits = PEEK) => {
66
+ const current = Math.max(0, Number(currentTurnRev) || 0)
67
+ const previous = budgetTurnRev == null
68
+ ? null
69
+ : Number.isFinite(Number(budgetTurnRev)) ? Number(budgetTurnRev) : null
70
+ const sameTurn = previous === current
71
+ return {
72
+ ...readTerminalBudgetDecision({
73
+ targeted,
74
+ targetedCount: sameTurn ? targetedCount : 0,
75
+ rosterCount: sameTurn ? rosterCount : 0,
76
+ }, limits),
77
+ budgetTurnRev: current,
78
+ }
79
+ }
80
+
53
81
  // One shared lane-status classifier for every roster consumer: the bridge wire,
54
82
  // read_terminal, and ROOM NOW. Timestamps stay absolute on the wire so clients can
55
83
  // keep the displayed age current without a broadcast every second.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.331",
3
+ "version": "0.7.333",
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": {
package/review-check.mjs CHANGED
@@ -17,6 +17,21 @@ const CHECKS = Object.freeze({
17
17
  })
18
18
  const OUTPUT_CAP = 512 * 1024
19
19
 
20
+ // Codex's native read-only/review mode never raises approval cards. MCP tools
21
+ // without annotations are conservatively treated as approval-requiring, which
22
+ // makes `approvalPolicy: never` auto-decline even our immutable readers before
23
+ // their handlers run. Keep the policy next to the implementation and reuse it
24
+ // for both source reads and fixed scratch checks: neither mutates the reviewed
25
+ // target, neither reaches the open world, and repeating either is safe.
26
+ export const IMMUTABLE_REVIEW_TOOL_EXTRAS = Object.freeze({
27
+ annotations: Object.freeze({
28
+ readOnlyHint: true,
29
+ destructiveHint: false,
30
+ idempotentHint: true,
31
+ openWorldHint: false,
32
+ }),
33
+ })
34
+
20
35
  export function reviewCheckCommand(checkId) {
21
36
  if (!Object.hasOwn(CHECKS, checkId)) throw new Error('unapproved review check')
22
37
  const [command, args] = CHECKS[checkId]