thinkpool-pair 0.7.344 → 0.7.346
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 +37 -3
- package/codex-mcp-http.mjs +37 -6
- package/codex-session.mjs +64 -5
- package/package.json +1 -1
- package/terminal-name.mjs +26 -0
package/bridge.mjs
CHANGED
|
@@ -55,7 +55,7 @@ import { readCodexDefaultModel, readCodexModels, codexConfigForMode, codexThread
|
|
|
55
55
|
import { codexAccountUsageLine, codexCreditsReportLine, codexLimitReportLine } from './codex-commands.mjs'
|
|
56
56
|
import { withMcpSessionFactory } from './codex-mcp-http.mjs'
|
|
57
57
|
import { startStructuredSession } from './runtime-session.mjs'
|
|
58
|
-
import { fallbackTerminalName } from './terminal-name.mjs'
|
|
58
|
+
import { cleanTerminalName, fallbackTerminalName, modelTerminalNameInput } from './terminal-name.mjs'
|
|
59
59
|
import { defaultStructuredMode, normalizeStructuredEffort, shouldDeferStructuredRuntime, structuredModeForSlice, structuredModeLocked, structuredModesForLane, structuredRuntimeForCommand, structuredRuntimeMetadata, structuredRuntimeSupportsMode } from './runtime-registry.mjs'
|
|
60
60
|
import { commandCatalogForRuntime, commandHelpLine, reconcileCommandCatalog } from './command-catalog.mjs'
|
|
61
61
|
import { gitDiffReport } from './git-diff-report.mjs'
|
|
@@ -1479,8 +1479,40 @@ const applyAutoTerminalName = (id, candidate) => {
|
|
|
1479
1479
|
return true
|
|
1480
1480
|
}
|
|
1481
1481
|
|
|
1482
|
-
|
|
1483
|
-
|
|
1482
|
+
const requestManagedTerminalName = async (id, task, retry = true) => {
|
|
1483
|
+
if (!codeAuthToken || !id || !task) return
|
|
1484
|
+
const controller = new AbortController()
|
|
1485
|
+
const timeout = setTimeout(() => controller.abort(), 4500)
|
|
1486
|
+
try {
|
|
1487
|
+
const response = await fetch(`${WEB_BASE}/api/code-terminal-name`, {
|
|
1488
|
+
method: 'POST',
|
|
1489
|
+
headers: {
|
|
1490
|
+
Authorization: `Bearer ${codeAuthToken}`,
|
|
1491
|
+
'Content-Type': 'application/json',
|
|
1492
|
+
},
|
|
1493
|
+
body: JSON.stringify({ code: room, terminalId: id, task }),
|
|
1494
|
+
signal: controller.signal,
|
|
1495
|
+
})
|
|
1496
|
+
clearTimeout(timeout)
|
|
1497
|
+
const data = await response.json().catch(() => ({}))
|
|
1498
|
+
// Bridge-created lanes can receive their task before the browser has
|
|
1499
|
+
// inserted the terminal row. Retry that non-billable race once; every
|
|
1500
|
+
// provider failure simply keeps the already-visible local fallback.
|
|
1501
|
+
if (retry && response.status === 404 && data?.code === 'terminal_pending') {
|
|
1502
|
+
const timer = setTimeout(() => { void requestManagedTerminalName(id, task, false) }, 1500)
|
|
1503
|
+
timer.unref?.()
|
|
1504
|
+
return
|
|
1505
|
+
}
|
|
1506
|
+
if (!response.ok) return
|
|
1507
|
+
const candidate = cleanTerminalName(data?.name)
|
|
1508
|
+
if (candidate) applyAutoTerminalName(id, candidate)
|
|
1509
|
+
} catch {
|
|
1510
|
+
clearTimeout(timeout)
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
// First real task only. The zero-token local name appears immediately; a
|
|
1515
|
+
// managed tiny model may improve it later without delaying the agent turn.
|
|
1484
1516
|
const autoNameTerminal = (id, text) => {
|
|
1485
1517
|
const entry = sessions.get(id)
|
|
1486
1518
|
if (!entry || termNames[id] || manualNameTouched.has(id) || autoNameAttempts.has(id)) return
|
|
@@ -1489,6 +1521,8 @@ const autoNameTerminal = (id, text) => {
|
|
|
1489
1521
|
if (!fallback) return
|
|
1490
1522
|
autoNameAttempts.add(id)
|
|
1491
1523
|
applyAutoTerminalName(id, fallback)
|
|
1524
|
+
const task = modelTerminalNameInput(text)
|
|
1525
|
+
if (task) void requestManagedTerminalName(id, task)
|
|
1492
1526
|
}
|
|
1493
1527
|
|
|
1494
1528
|
// (cross-person grantee yield removed 2026-07-06 — owner-only serving now; the owner's
|
package/codex-mcp-http.mjs
CHANGED
|
@@ -26,19 +26,24 @@ export async function startCodexMcpHttp({ sdkServer, host = '127.0.0.1' } = {})
|
|
|
26
26
|
// Keep exactly one active child session per lane. A new initialize atomically
|
|
27
27
|
// retires the old server+transport before receiving its own isolated instance.
|
|
28
28
|
let active = null
|
|
29
|
+
let handoffChain = Promise.resolve()
|
|
29
30
|
|
|
30
|
-
const retireActive =
|
|
31
|
+
const retireActive = () => {
|
|
31
32
|
const current = active
|
|
32
33
|
active = null
|
|
33
|
-
if (!current) return
|
|
34
|
-
|
|
34
|
+
if (!current) return Promise.resolve()
|
|
35
|
+
// Detach first, then clean up in the background. In production an App
|
|
36
|
+
// Server MCP transport remained stuck in close while its watchdog fallback
|
|
37
|
+
// was already waiting to initialize `codex exec`; awaiting this cleanup
|
|
38
|
+
// consumed the replacement client's entire 30-second handshake budget.
|
|
39
|
+
return Promise.allSettled([
|
|
35
40
|
current.instance.close(),
|
|
36
41
|
current.transport.close(),
|
|
37
42
|
])
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
const beginSession = async () => {
|
|
41
|
-
|
|
46
|
+
void retireActive()
|
|
42
47
|
const next = createSessionServer()
|
|
43
48
|
const instance = next?.instance
|
|
44
49
|
if (!instance?.connect) throw new TypeError('Codex MCP session factory did not return an SDK MCP server instance')
|
|
@@ -48,6 +53,12 @@ export async function startCodexMcpHttp({ sdkServer, host = '127.0.0.1' } = {})
|
|
|
48
53
|
return active
|
|
49
54
|
}
|
|
50
55
|
|
|
56
|
+
const serializeHandoff = (task) => {
|
|
57
|
+
const run = handoffChain.then(task, task)
|
|
58
|
+
handoffChain = run.catch(() => {})
|
|
59
|
+
return run
|
|
60
|
+
}
|
|
61
|
+
|
|
51
62
|
const server = http.createServer(async (req, res) => {
|
|
52
63
|
if (req.url !== secretPath) {
|
|
53
64
|
res.writeHead(404).end('not found')
|
|
@@ -61,7 +72,14 @@ export async function startCodexMcpHttp({ sdkServer, host = '127.0.0.1' } = {})
|
|
|
61
72
|
res.writeHead(400).end('missing session')
|
|
62
73
|
return
|
|
63
74
|
}
|
|
64
|
-
|
|
75
|
+
// Serialize replacement initialize requests through their response
|
|
76
|
+
// boundary. This removes the window where two simultaneous children
|
|
77
|
+
// both observe no active session and orphan each other's transport.
|
|
78
|
+
await serializeHandoff(async () => {
|
|
79
|
+
target = await beginSession()
|
|
80
|
+
await target.transport.handleRequest(req, res)
|
|
81
|
+
})
|
|
82
|
+
return
|
|
65
83
|
} else if (!target?.transport?.sessionId || sessionId !== target.transport.sessionId) {
|
|
66
84
|
res.writeHead(404).end('session not found')
|
|
67
85
|
return
|
|
@@ -88,11 +106,24 @@ export async function startCodexMcpHttp({ sdkServer, host = '127.0.0.1' } = {})
|
|
|
88
106
|
let closed = false
|
|
89
107
|
return {
|
|
90
108
|
url,
|
|
109
|
+
async handoff() {
|
|
110
|
+
if (closed) return
|
|
111
|
+
await serializeHandoff(async () => { void retireActive() })
|
|
112
|
+
},
|
|
91
113
|
async close() {
|
|
92
114
|
if (closed) return
|
|
93
115
|
closed = true
|
|
116
|
+
const closing = retireActive()
|
|
117
|
+
// A retired SDK server may itself be wedged. Closing the loopback listener
|
|
118
|
+
// must not keep the bridge process alive indefinitely on shutdown.
|
|
94
119
|
await Promise.allSettled([
|
|
95
|
-
|
|
120
|
+
Promise.race([
|
|
121
|
+
closing,
|
|
122
|
+
new Promise((resolve) => {
|
|
123
|
+
const timer = setTimeout(resolve, 1000)
|
|
124
|
+
timer.unref?.()
|
|
125
|
+
}),
|
|
126
|
+
]),
|
|
96
127
|
new Promise((resolve) => server.close(resolve)),
|
|
97
128
|
])
|
|
98
129
|
},
|
package/codex-session.mjs
CHANGED
|
@@ -303,6 +303,19 @@ export function buildCodexExecArgs({ sessionId, model, effort, sandbox, approval
|
|
|
303
303
|
return args
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
// `codex exec` can fail before it has created or resumed a native turn when a
|
|
307
|
+
// required MCP client loses the App Server -> exec handoff race. Retrying is
|
|
308
|
+
// safe only when stderr proves session initialization failed AND the JSONL
|
|
309
|
+
// stream contains no turn/item activity. A silent process or a generic exit is
|
|
310
|
+
// delivery-uncertain and must never replay the person's prompt automatically.
|
|
311
|
+
export function codexExecFailedBeforeTurn({ stderr = '', eventTypes = [] } = {}) {
|
|
312
|
+
const types = Array.isArray(eventTypes) ? eventTypes.map((type) => String(type || '')) : []
|
|
313
|
+
if (types.some((type) => type && type !== 'thread.started')) return false
|
|
314
|
+
const message = String(stderr || '')
|
|
315
|
+
return /timed out handshaking with MCP server/i.test(message)
|
|
316
|
+
|| (/thread\/resume failed/i.test(message) && /failed to initialize session/i.test(message))
|
|
317
|
+
}
|
|
318
|
+
|
|
306
319
|
const CODEX_DESTRUCTIVE = /\brm\s+\S|\brmdir\s+\S|\bgit\s+(push\s+(-f|--force)|reset\s+--hard|clean\s+-[a-z]*f)|\bdrop\s+(table|database)\b|\b(mkfs|dd)\b|\bsudo\b|>\s*\/dev\/|\bchmod\s+-R|\bchown\s+-R|\bkillall\b|\btruncate\b/i
|
|
307
320
|
|
|
308
321
|
export function codexApprovalCard(method, params = {}, item = null) {
|
|
@@ -423,6 +436,10 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
423
436
|
let stallRetried = false
|
|
424
437
|
let stallRetryRequested = false
|
|
425
438
|
let stallGiveupRequested = false
|
|
439
|
+
// True only while the current logical attempt is still in bridge-owned
|
|
440
|
+
// bootstrap. It flips before writing a turn/start request or spawning exec.
|
|
441
|
+
// Absence of provider output is not proof that a prompt was not delivered.
|
|
442
|
+
let stallReplaySafe = true
|
|
426
443
|
let awaitingUser = 0
|
|
427
444
|
let stallTimer = null
|
|
428
445
|
let activeModel = model || null
|
|
@@ -489,6 +506,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
489
506
|
stalledSent = false
|
|
490
507
|
stallRetryRequested = false
|
|
491
508
|
stallGiveupRequested = false
|
|
509
|
+
stallReplaySafe = true
|
|
492
510
|
if (!retry) stallRetried = false
|
|
493
511
|
}
|
|
494
512
|
|
|
@@ -742,6 +760,17 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
742
760
|
})
|
|
743
761
|
if (action === 'none') return
|
|
744
762
|
|
|
763
|
+
// Do not emit the generic "retrying the turn" copy when native delivery is
|
|
764
|
+
// already uncertain; that would promise the exact unsafe action we refuse.
|
|
765
|
+
if (action === 'retry' && !stallReplaySafe) {
|
|
766
|
+
stallGiveupRequested = true
|
|
767
|
+
stallRetryRequested = false
|
|
768
|
+
emitRaw({ kind: 'error', recoverable: true, message: 'Codex stopped responding after the turn began. The transport was stopped, but the prompt was not replayed automatically.' })
|
|
769
|
+
emitTurnBoundary({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: undefined, denials: 0, resultText: null })
|
|
770
|
+
stopStalledTransport({ force: true })
|
|
771
|
+
return
|
|
772
|
+
}
|
|
773
|
+
|
|
745
774
|
const event = stallEvent(action, quietMs)
|
|
746
775
|
if (event) emitRaw(event)
|
|
747
776
|
if (action === 'status') {
|
|
@@ -782,6 +811,9 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
782
811
|
mapper.setUsageBaseline(sessionId ? readCodexThreadUsage(sessionId) : null)
|
|
783
812
|
turnActive = true
|
|
784
813
|
try {
|
|
814
|
+
// startTurn writes the prompt-bearing RPC. From this edge onward delivery
|
|
815
|
+
// is at least uncertain, even if the response or first event never lands.
|
|
816
|
+
stallReplaySafe = false
|
|
785
817
|
activeTurnId = await appServer.startTurn({
|
|
786
818
|
threadId: sessionId,
|
|
787
819
|
input: prompt,
|
|
@@ -850,6 +882,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
850
882
|
mapper.setUsageBaseline(sessionId ? readCodexThreadUsage(sessionId) : null)
|
|
851
883
|
turnActive = true
|
|
852
884
|
try {
|
|
885
|
+
stallReplaySafe = false
|
|
853
886
|
const started = await appServer.startReview({ threadId: sessionId, target: codexReviewTarget(commandText) })
|
|
854
887
|
activeTurnId = started?.turn?.id || started?.turnId
|
|
855
888
|
if (!activeTurnId) throw new Error('Codex review/start returned no turn id')
|
|
@@ -903,6 +936,11 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
903
936
|
let peer
|
|
904
937
|
try { peer = await ensureMcpHttp() }
|
|
905
938
|
catch (e) { note(`Thinkpool peer tools unavailable: ${e?.message || e}`) }
|
|
939
|
+
// Detach any App Server-owned MCP session before exec initializes its own.
|
|
940
|
+
// Cleanup of the old transport is deliberately asynchronous so a wedged
|
|
941
|
+
// close cannot consume Codex's 30-second required-MCP handshake window.
|
|
942
|
+
try { await peer?.handoff?.() }
|
|
943
|
+
catch (e) { note(`Thinkpool peer handoff failed: ${e?.message || e}`) }
|
|
906
944
|
const args = buildCodexExecArgs({
|
|
907
945
|
sessionId: turnNo === 0 ? null : sessionId,
|
|
908
946
|
model: activeModel,
|
|
@@ -928,15 +966,18 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
928
966
|
if (stdinFd != null) opts.stdio = [stdinFd, 'pipe', 'pipe']
|
|
929
967
|
|
|
930
968
|
turnActive = true
|
|
969
|
+
stallReplaySafe = false
|
|
931
970
|
child = spawnImpl('codex', args, opts)
|
|
932
971
|
if (stdinFd != null) { try { fs.closeSync(stdinFd) } catch { /* child owns its duplicated fd */ } }
|
|
933
972
|
|
|
973
|
+
const nativeEventTypes = []
|
|
934
974
|
const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity })
|
|
935
975
|
rl.on('line', (line) => {
|
|
936
976
|
if (!line.trim()) return
|
|
937
977
|
let ev
|
|
938
978
|
try { ev = JSON.parse(line) } catch { return /* non-JSON progress line */
|
|
939
979
|
}
|
|
980
|
+
nativeEventTypes.push(ev.type)
|
|
940
981
|
if (ev.type === 'thread.started' && ev.thread_id) sessionId = sessionId || ev.thread_id
|
|
941
982
|
if (ev.type === 'turn.completed' || ev.type === 'turn.failed') {
|
|
942
983
|
sawTerminalResult = true
|
|
@@ -955,22 +996,30 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
955
996
|
settled = true
|
|
956
997
|
child = null
|
|
957
998
|
turnActive = false
|
|
958
|
-
|
|
999
|
+
const failureMessage = `codex exec exited ${code}${stderrTail ? ': ' + stderrTail.trim().slice(-300) : ''}`
|
|
1000
|
+
const preTurnInitFailure = code !== 0 && code != null && !sawTerminalResult && codexExecFailedBeforeTurn({
|
|
1001
|
+
stderr: stderrTail,
|
|
1002
|
+
eventTypes: nativeEventTypes,
|
|
1003
|
+
})
|
|
1004
|
+
if (preTurnInitFailure && !aborted && !stallGiveupRequested) {
|
|
1005
|
+
resolve({ preTurnInitFailure: true, message: failureMessage })
|
|
1006
|
+
return
|
|
1007
|
+
} else if ((stallRetryRequested || stallGiveupRequested) && !sawTerminalResult && !aborted) {
|
|
959
1008
|
// Watchdog interruption: pump either replays once or has already
|
|
960
1009
|
// published the terminal giveup boundary.
|
|
961
1010
|
} else if (aborted && !sawTerminalResult) {
|
|
962
1011
|
emitTurnBoundary({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: undefined, denials: 0, resultText: null })
|
|
963
1012
|
} else if (!aborted && !sawTerminalResult && code !== 0 && code != null) {
|
|
964
|
-
emitTurnBoundary({ kind: 'error', message:
|
|
1013
|
+
emitTurnBoundary({ kind: 'error', message: failureMessage, recoverable: true })
|
|
965
1014
|
}
|
|
966
|
-
resolve()
|
|
1015
|
+
resolve({ preTurnInitFailure: false })
|
|
967
1016
|
}
|
|
968
1017
|
child.on('error', (e) => {
|
|
969
1018
|
if (settled) return
|
|
970
1019
|
settled = true
|
|
971
1020
|
child = null
|
|
972
1021
|
if (!stallRetryRequested && !stallGiveupRequested) emitTurnBoundary({ kind: 'error', message: `codex failed to start: ${e.message}`, recoverable: true })
|
|
973
|
-
resolve()
|
|
1022
|
+
resolve({ preTurnInitFailure: false })
|
|
974
1023
|
})
|
|
975
1024
|
child.on('close', finish)
|
|
976
1025
|
})
|
|
@@ -999,6 +1048,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
999
1048
|
})
|
|
1000
1049
|
const review = /^\s*\/review(?:\s|$)/i.test(String(next.text || ''))
|
|
1001
1050
|
let retryAttempt = false
|
|
1051
|
+
let execInitRetryUsed = false
|
|
1002
1052
|
while (!ended && !aborted && !stallGiveupRequested) {
|
|
1003
1053
|
const usedAppServer = review
|
|
1004
1054
|
? await runAppServerReview(next.text)
|
|
@@ -1017,7 +1067,16 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
1017
1067
|
armTurnLiveness({ retry: true })
|
|
1018
1068
|
emitRaw({ kind: 'note', text: 'retrying the stalled turn on a fresh connection' })
|
|
1019
1069
|
}
|
|
1020
|
-
await runExec(prompt, next.options)
|
|
1070
|
+
const outcome = await runExec(prompt, next.options)
|
|
1071
|
+
if (outcome?.preTurnInitFailure) {
|
|
1072
|
+
if (!execInitRetryUsed && !ended && !aborted && !stallGiveupRequested) {
|
|
1073
|
+
execInitRetryUsed = true
|
|
1074
|
+
armTurnLiveness({ retry: true })
|
|
1075
|
+
emitRaw({ kind: 'note', text: 'Codex initialization failed before the turn started; retrying once with a fresh ThinkPool connection.' })
|
|
1076
|
+
continue
|
|
1077
|
+
}
|
|
1078
|
+
emitTurnBoundary({ kind: 'error', message: outcome.message, recoverable: true })
|
|
1079
|
+
}
|
|
1021
1080
|
}
|
|
1022
1081
|
}
|
|
1023
1082
|
if (stallRetryRequested && !retryAttempt && !stallGiveupRequested && !ended && !aborted) {
|
package/package.json
CHANGED
package/terminal-name.mjs
CHANGED
|
@@ -315,3 +315,29 @@ export function fallbackTerminalName(text) {
|
|
|
315
315
|
.slice(0, 1600)
|
|
316
316
|
return taskTitle(body)
|
|
317
317
|
}
|
|
318
|
+
|
|
319
|
+
const MODEL_INPUT_MAX_BYTES = 1600
|
|
320
|
+
|
|
321
|
+
const truncateUtf8 = (value, maxBytes = MODEL_INPUT_MAX_BYTES) => {
|
|
322
|
+
let result = ''
|
|
323
|
+
let bytes = 0
|
|
324
|
+
for (const char of String(value || '')) {
|
|
325
|
+
const size = Buffer.byteLength(char, 'utf8')
|
|
326
|
+
if (bytes + size > maxBytes) break
|
|
327
|
+
result += char
|
|
328
|
+
bytes += size
|
|
329
|
+
}
|
|
330
|
+
return result.trim()
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// The managed namer never receives the raw room prompt. Reuse the local
|
|
334
|
+
// extractor's authoritative-task selection, secret redaction, and host-reference
|
|
335
|
+
// stripping, then add a byte ceiling so multilingual text stays cost-bounded.
|
|
336
|
+
export function modelTerminalNameInput(text) {
|
|
337
|
+
const body = withoutHostReferences(withoutSecrets(extractTerminalTask(text) || ''))
|
|
338
|
+
.replace(/[`*_>#()[\]{}]/g, ' ')
|
|
339
|
+
.replace(/\s+/g, ' ')
|
|
340
|
+
.trim()
|
|
341
|
+
if (!body || containsSecret(body)) return null
|
|
342
|
+
return truncateUtf8(body)
|
|
343
|
+
}
|