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 +8 -3
- package/codex-session.mjs +215 -23
- package/cross-terminal.mjs +30 -2
- package/package.json +1 -1
- package/review-check.mjs +15 -0
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,
|
|
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 =
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
|
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')
|
|
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 (
|
|
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
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
|
|
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)
|
package/cross-terminal.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
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]
|