thinkpool-pair 0.7.330 → 0.7.332
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/codex-session.mjs +215 -23
- package/package.json +1 -1
- package/terminal-name.mjs +17 -5
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/package.json
CHANGED
package/terminal-name.mjs
CHANGED
|
@@ -17,6 +17,7 @@ const ANY_HEADING = /^(?:#{1,6}\s+|(?:context|background|constraints?|inputs?|ou
|
|
|
17
17
|
const NOISE = /^(?:context|background|for reference|here(?:'s| is)|note|current(?:ly)?|example|environment|room now|constraints?|acceptance|success criteria)\b/i
|
|
18
18
|
const EXPLANATION = /^(?:because|cause|since|so that|this is because)\b/i
|
|
19
19
|
const CONSTRAINT = /^(?:users? can still|keep|must|never|should|without)\b/i
|
|
20
|
+
const DEPENDENT_FRAGMENT = /^(?:after|before|by|during|for|from|in|instead(?: of)?|on|through|until|with|without)\b/i
|
|
20
21
|
const INFORMATION_REQUEST = /^(?:which|what|who|where|when|why|how)\b/i
|
|
21
22
|
const NEGATIVE_PREFERENCE = /^(?:(?:i|we)\s+)?(?:do not|don't|dont|would not|wouldn't|won't|wont)\s+(?:(?:want|wanna|need)(?:\s+to)?|use|include|choose)\b|^(?:(?:i|we)\s+)?(?:want|wanna|need)(?:\s+to)?\s+avoid\b/i
|
|
22
23
|
const ISSUE = /\b(?:broken|buggy|crash(?:es|ed|ing)?|duplicate|error|fail(?:s|ed|ing|ure)?|flash(?:es|ed|ing)?|missing|no animation|not working|out of (?:scrollable )?view|stuck|wrong)\b/i
|
|
@@ -29,6 +30,7 @@ const ACTIONS = Object.freeze([
|
|
|
29
30
|
{ title: 'Harden', score: 98, re: /\b(?:harden|hardens|hardened|hardening)\b/i },
|
|
30
31
|
{ title: 'Snap', score: 96, re: /\b(?:snap|snaps|snapped|snapping)\b/i },
|
|
31
32
|
{ title: 'Improve', score: 94, re: /\b(?:improve|improves|improved|improving|optimi[sz](?:e|es|ed|ing)|\bbetter\b)\b/i },
|
|
33
|
+
{ title: 'Rework', score: 93, re: /\b(?:rework|reworks|reworked|reworking)\b/i },
|
|
32
34
|
{ title: 'Implement', score: 92, re: /\b(?:implement|implements|implemented|implementing)\b/i },
|
|
33
35
|
{ title: 'Build', score: 90, re: /\b(?:build|builds|built|building|create|creates|created|creating)\b/i },
|
|
34
36
|
{ title: 'Add', score: 88, re: /\b(?:add|adds|added|adding|wire|wires|wired|wiring)\b/i },
|
|
@@ -136,7 +138,11 @@ const bestIntentClause = (text) => {
|
|
|
136
138
|
if (EXPLANATION.test(clause)) score -= 34
|
|
137
139
|
if (CONSTRAINT.test(clause)) score -= 45
|
|
138
140
|
if (NEGATIVE_PREFERENCE.test(clause)) score -= 70
|
|
139
|
-
|
|
141
|
+
// A short dependent tail such as "From ground up" or "On mobile" adds
|
|
142
|
+
// scope to the preceding request; it is not a standalone task. Recency is
|
|
143
|
+
// deliberately not a signal here: the title should represent the main
|
|
144
|
+
// theme, not whichever sentence happened to come last.
|
|
145
|
+
if (!action && !REQUEST.test(clause) && !INFORMATION_REQUEST.test(clause) && DEPENDENT_FRAGMENT.test(clause)) score -= 24
|
|
140
146
|
if (!best || score > best.score) best = { clause, score }
|
|
141
147
|
}
|
|
142
148
|
return best?.clause || clauses[0] || null
|
|
@@ -175,13 +181,18 @@ const titleWord = (word) => {
|
|
|
175
181
|
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
|
176
182
|
}
|
|
177
183
|
|
|
178
|
-
const contentWords = (value) => {
|
|
184
|
+
const contentWords = (value, excludedFamilies = []) => {
|
|
179
185
|
const words = String(value || '').match(/[\p{L}\p{N}][\p{L}\p{N}+#.'-]*/gu) || []
|
|
180
186
|
const seen = new Set()
|
|
181
187
|
const result = []
|
|
182
188
|
for (let word of words) {
|
|
183
189
|
const lower = word.toLowerCase()
|
|
184
190
|
if (SKIP.has(lower) || /^https?$/i.test(word)) continue
|
|
191
|
+
// ACTIONS intentionally folds synonyms into one title verb (review/test/verify
|
|
192
|
+
// all become Audit). Once that verb is chosen, words from the same family are
|
|
193
|
+
// not objects. Keeping them spent the title budget on "Audit Reviews Audits"
|
|
194
|
+
// before the extractor reached the actual scope.
|
|
195
|
+
if (excludedFamilies.some((family) => family.test(word))) continue
|
|
185
196
|
if (lower === 'flashes' || lower === 'flashing') word = 'flash'
|
|
186
197
|
if (lower === 'failing' || lower === 'failed' || lower === 'fails') word = 'failure'
|
|
187
198
|
const key = word.toLowerCase().replace(/(?:s|ed|ing)$/i, '')
|
|
@@ -227,12 +238,13 @@ const taskTitle = (value) => {
|
|
|
227
238
|
const before = body.slice(0, action.index)
|
|
228
239
|
objectText = body.slice(action.index + action.match.length)
|
|
229
240
|
const weakLead = bestAction(before)
|
|
230
|
-
if (weakLead?.score <= 76) domain = contentWords(before.slice(weakLead.index + weakLead.match.length)).slice(0, 3)
|
|
241
|
+
if (weakLead?.score <= 76) domain = contentWords(before.slice(weakLead.index + weakLead.match.length), [weakLead.re]).slice(0, 3)
|
|
231
242
|
objectText = objectText.split(/\s*,?\s+(?:and|then)\s+(?:(?:also)\s+)?(?=(?:add|build|create|implement|remove|replace|update|wire)\b)/i, 1)[0]
|
|
232
243
|
objectText = objectText.replace(/\b(?:and|then)\s+(?:audit|check|debug|investigate|review|test|verify)\b/gi, ' ')
|
|
233
244
|
}
|
|
234
|
-
|
|
235
|
-
|
|
245
|
+
const excludedObjectFamilies = action ? [action.re] : []
|
|
246
|
+
let object = contentWords(objectText, excludedObjectFamilies)
|
|
247
|
+
if (!object.length) object = contentWords(body, excludedObjectFamilies)
|
|
236
248
|
if (informationRequest) {
|
|
237
249
|
const available = object.findIndex((word) => /^available$/i.test(word))
|
|
238
250
|
if (available > 0) object = [object[available], ...object.slice(0, available), ...object.slice(available + 1)]
|