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 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
- // First real task only. Naming is deterministic and entirely local: no provider
1483
- // receives the task, and every runtime follows the same zero-token path.
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
@@ -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 = async () => {
31
+ const retireActive = () => {
31
32
  const current = active
32
33
  active = null
33
- if (!current) return
34
- await Promise.allSettled([
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
- await retireActive()
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
- target = await beginSession()
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
- retireActive(),
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
- if ((stallRetryRequested || stallGiveupRequested) && !sawTerminalResult && !aborted) {
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: `codex exec exited ${code}${stderrTail ? ': ' + stderrTail.trim().slice(-300) : ''}`, recoverable: true })
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.344",
3
+ "version": "0.7.346",
4
4
  "description": "Connect Claude Code, Codex, or Hermes on your computer to a Thinkpool Code room.",
5
5
  "type": "module",
6
6
  "bin": {
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
+ }