thinkpool-pair 0.7.264 → 0.7.266

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
@@ -60,6 +60,7 @@ import { canonicalRoomFilePath, waitForNativeImages } from './codex-images.mjs'
60
60
  import { createManagedLaneWorktree, removeManagedLaneWorktree } from './lane-worktree.mjs'
61
61
  import { commandOnPath } from './agent-detect.mjs'
62
62
  import { requiresSdkAdmission, sdkSmokePassed } from './sdk-admission.mjs'
63
+ import { hermesUserInputResponse } from './question-response.mjs'
63
64
 
64
65
  const STRUCTURED_MODES = new Set(['default', 'acceptEdits', 'plan', 'review', 'bypassPermissions'])
65
66
  import { FLOW_CONDUCTOR_PROMPT, FLOW_LANE_PROMPT, FLOW_CODEX_CONDUCTOR_PROMPT, FLOW_CODEX_LANE_PROMPT, buildConductorEnv, assembleCrossWaveContext, buildLanePrompt } from './flow-conductor.mjs'
@@ -2189,10 +2190,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2189
2190
  answerFormat: 'hermes',
2190
2191
  autoResolutionMs: args.autoResolutionMs,
2191
2192
  })
2192
- const decision = result && typeof result === 'object' ? result.decision : result
2193
- const answers = result && typeof result === 'object' && result.answers && typeof result.answers === 'object' ? result.answers : {}
2194
- const status = String(decision || '').startsWith('answer:') ? 'answered' : decision === 'timeout' ? 'timed_out' : 'dismissed'
2195
- return { content: [{ type: 'text', text: JSON.stringify({ status, answers }) }] }
2193
+ return { content: [{ type: 'text', text: JSON.stringify(hermesUserInputResponse(result, questions)) }] }
2196
2194
  },
2197
2195
  )] : []),
2198
2196
  ...createViewportTools({ tool, z, manager: entry.viewport }),
@@ -1051,6 +1051,11 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
1051
1051
  // session is ending) — not a real error. Stay silent; the re-create owns what's next.
1052
1052
  if (myAc.signal.aborted) return
1053
1053
  const msg = e?.message || String(e)
1054
+ // onEvent is also the bridge's synchronous busy-edge sampling point. The
1055
+ // query has already ended here, so publish the failure only after clearing
1056
+ // the turn. Otherwise a non-recoverable SDK error leaves the roster on its
1057
+ // last `busy: true` announce until a tab refocus asks for fresh state.
1058
+ turnActive = false
1054
1059
  emit({ kind: 'error', message: msg })
1055
1060
  // Auto-restart a transient upstream stream drop: re-run query() with
1056
1061
  // resume so the conversation continues. Bounded by RESTART_MAX (reset on
@@ -1058,7 +1063,6 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
1058
1063
  // fresh input stream is needed because the throw killed the old iterator.
1059
1064
  if (RECOVERABLE.test(msg) && restartCount < RESTART_MAX) {
1060
1065
  restartCount++
1061
- turnActive = false
1062
1066
  emit({ kind: 'note', text: `connection dropped — reconnecting (${restartCount}/${RESTART_MAX})` })
1063
1067
  // End the OLD input stream before swapping — else its generator leaks
1064
1068
  // (suspended forever) and a turn pushed into it after the throw is silently
@@ -1076,7 +1080,6 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
1076
1080
  // of hard-failing. Bounded by RESTART_MAX. Loses only the resumed history, not the turn.
1077
1081
  if (/no conversation found/i.test(msg) && sessionId && restartCount < RESTART_MAX) {
1078
1082
  restartCount++
1079
- turnActive = false
1080
1083
  sessionId = null; persistedSessionId = null
1081
1084
  const oldInput = input
1082
1085
  input = makeInputStream()
@@ -1088,7 +1091,6 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
1088
1091
  // silently queuing into a dead stream. The bridge's onEvent self-heal
1089
1092
  // still fires for "No conversation found" (it reopens fresh).
1090
1093
  closed = true
1091
- turnActive = false
1092
1094
  } finally {
1093
1095
  ac.signal.removeEventListener('abort', onParentAbort)
1094
1096
  if (qAc === myAc) qAc = null
package/codex-session.mjs CHANGED
@@ -31,6 +31,7 @@ import { buildCodexAppServerArgs, codexAppServerGate, createCodexAppServer } fro
31
31
  import { CodexEventMapper } from './codex-event-mapper.mjs'
32
32
  import { startCodexMcpHttp } from './codex-mcp-http.mjs'
33
33
  import { CODEX_THINKPOOL_FIRST_TURN_PREAMBLE, buildThinkPoolTurnGuidance, createRoomContextSelector, usesFullThinkPoolReminder } from './thinkpool-room-prompt.mjs'
34
+ import { questionAnswerResponse } from './question-response.mjs'
34
35
 
35
36
  const DEFAULT_SANDBOX = 'workspace-write'
36
37
  const SAFE_SANDBOXES = new Set(['read-only', 'workspace-write', 'danger-full-access'])
@@ -316,46 +317,8 @@ export function codexApprovalResponse(decision) {
316
317
  return { decision: decision === 'always' ? 'acceptForSession' : decision === 'allow' ? 'accept' : 'decline' }
317
318
  }
318
319
 
319
- function legacyQuestionAnswers(decision, questions) {
320
- if (typeof decision !== 'string' || !decision.startsWith('answer:')) return {}
321
- const text = decision.slice(7).trim()
322
- if (!text || !questions.length) return {}
323
- if (questions.length === 1) {
324
- const question = questions[0]
325
- const prefix = `${question.header || 'Q1'}: `
326
- const value = text.startsWith(prefix) ? text.slice(prefix.length) : text
327
- return { [String(question.id || 0)]: { answers: [value] } }
328
- }
329
- const answers = {}
330
- questions.forEach((question, index) => {
331
- const id = String(question.id || index)
332
- const marker = `${index ? '; ' : ''}${question.header || `Q${index + 1}`}: `
333
- const start = text.indexOf(marker)
334
- if (start < 0) return
335
- const valueStart = start + marker.length
336
- const nextQuestion = questions[index + 1]
337
- const nextMarker = nextQuestion ? `; ${nextQuestion.header || `Q${index + 2}`}: ` : null
338
- const end = nextMarker ? text.indexOf(nextMarker, valueStart) : -1
339
- const value = text.slice(valueStart, end < 0 ? undefined : end).trim()
340
- if (value) answers[id] = { answers: [value] }
341
- })
342
- return answers
343
- }
344
-
345
320
  export function codexUserInputResponse(result, questions = []) {
346
- const supplied = result && typeof result === 'object' ? result.answers : null
347
- const answers = {}
348
- if (supplied && typeof supplied === 'object') {
349
- const allowed = new Set(questions.map((question, index) => String(question?.id || index)))
350
- for (const [id, value] of Object.entries(supplied)) {
351
- if (!allowed.has(String(id)) || !Array.isArray(value?.answers)) continue
352
- const values = value.answers.map((answer) => String(answer)).filter(Boolean)
353
- if (values.length) answers[String(id)] = { answers: values }
354
- }
355
- }
356
- if (Object.keys(answers).length) return { answers }
357
- const decision = result && typeof result === 'object' ? result.decision : result
358
- return { answers: legacyQuestionAnswers(decision, questions) }
321
+ return questionAnswerResponse(result, questions)
359
322
  }
360
323
 
361
324
  function appServerItemForMapper(item) {
@@ -430,6 +393,22 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
430
393
  },
431
394
  })
432
395
 
396
+ // The bridge samples `session.turnActive` synchronously inside onEvent to
397
+ // announce the roster's busy edge. Codex transports report their terminal
398
+ // boundary before their surrounding promise/process cleanup runs, so clearing
399
+ // turnActive only in `finally`/`close` publishes one last stale `busy: true`
400
+ // announce with no later event to correct it. A tab refocus re-announcement
401
+ // eventually healed the roster, but until then the room stayed on "thinking".
402
+ // Settle at the event boundary itself for every Codex transport.
403
+ const pushMappedEvent = (event) => {
404
+ if (event?.type === 'turn.completed' || event?.type === 'turn.failed') turnActive = false
405
+ mapper.push(event)
406
+ }
407
+ const emitTurnBoundary = (event) => {
408
+ turnActive = false
409
+ try { onEvent?.(event) } catch { /* noop */ }
410
+ }
411
+
433
412
  const note = (text) => { try { onEvent?.({ kind: 'note', text }) } catch { /* noop */ } }
434
413
  if (requestedResume && !resumeUsable) {
435
414
  queueMicrotask(() => note('The stopped Codex turn had no resumable context; continuing in a fresh thread.'))
@@ -484,12 +463,12 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
484
463
  return
485
464
  }
486
465
  }
487
- mapper.push({ type: method === 'item/started' ? 'item.started' : 'item.completed', item: appServerItemForMapper(raw) })
466
+ pushMappedEvent({ type: method === 'item/started' ? 'item.started' : 'item.completed', item: appServerItemForMapper(raw) })
488
467
  return
489
468
  }
490
469
  if (method === 'error') {
491
470
  const message = params.error?.message || params.message || 'codex app-server error'
492
- mapper.push({ type: 'error', message })
471
+ pushMappedEvent({ type: 'error', message })
493
472
  }
494
473
  }
495
474
 
@@ -544,7 +523,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
544
523
  config: { 'features.default_mode_request_user_input': true },
545
524
  })
546
525
  appServerThreadReady = true
547
- mapper.push({ type: 'thread.started', thread_id: sessionId })
526
+ pushMappedEvent({ type: 'thread.started', thread_id: sessionId })
548
527
  return true
549
528
  } catch (error) {
550
529
  // Fallback is safe only here: no turn/start request has been accepted, so
@@ -576,11 +555,11 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
576
555
  const completed = await appServer.waitForTurn(activeTurnId)
577
556
  const status = completed?.turn?.status
578
557
  if (aborted || status === 'interrupted') {
579
- try { onEvent?.({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: completed?.turn?.durationMs, denials: 0, resultText: null }) } catch { /* noop */ }
558
+ emitTurnBoundary({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: completed?.turn?.durationMs, denials: 0, resultText: null })
580
559
  } else if (status === 'failed') {
581
- mapper.push({ type: 'turn.failed', message: completed?.turn?.error?.message || 'codex turn failed' })
560
+ pushMappedEvent({ type: 'turn.failed', message: completed?.turn?.error?.message || 'codex turn failed' })
582
561
  } else {
583
- mapper.push({ type: 'turn.completed' })
562
+ pushMappedEvent({ type: 'turn.completed' })
584
563
  }
585
564
  } catch (error) {
586
565
  // Once a started turn's transport fails we must not replay that prompt via
@@ -590,7 +569,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
590
569
  appServerThreadReady = false
591
570
  try { appServer?.end() } catch { /* noop */ }
592
571
  appServer = null
593
- if (!ended) mapper.push({ type: 'turn.failed', message: `codex app-server turn failed: ${error?.message || error}` })
572
+ if (!ended) pushMappedEvent({ type: 'turn.failed', message: `codex app-server turn failed: ${error?.message || error}` })
594
573
  } finally {
595
574
  // A transport failure or interrupt can land after prose deltas but before
596
575
  // item/completed. Preserve that partial prose as one durable row rather
@@ -651,7 +630,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
651
630
  }
652
631
  if (ev.type === 'thread.started' && ev.thread_id) sessionId = sessionId || ev.thread_id
653
632
  if (ev.type === 'turn.completed' || ev.type === 'turn.failed') sawTerminalResult = true
654
- mapper.push(ev)
633
+ pushMappedEvent(ev)
655
634
  })
656
635
  // stderr is codex's human progress channel; surface tail on failure only.
657
636
  let stderrTail = ''
@@ -665,18 +644,17 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
665
644
  child = null
666
645
  turnActive = false
667
646
  if (aborted && !sawTerminalResult) {
668
- try { onEvent?.({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: undefined, denials: 0, resultText: null }) } catch { /* noop */ }
647
+ emitTurnBoundary({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: undefined, denials: 0, resultText: null })
669
648
  } else if (!aborted && !sawTerminalResult && code !== 0 && code != null) {
670
- try { onEvent?.({ kind: 'error', message: `codex exec exited ${code}${stderrTail ? ': ' + stderrTail.trim().slice(-300) : ''}`, recoverable: true }) } catch { /* noop */ }
649
+ emitTurnBoundary({ kind: 'error', message: `codex exec exited ${code}${stderrTail ? ': ' + stderrTail.trim().slice(-300) : ''}`, recoverable: true })
671
650
  }
672
651
  resolve()
673
652
  }
674
653
  child.on('error', (e) => {
675
654
  if (settled) return
676
655
  settled = true
677
- try { onEvent?.({ kind: 'error', message: `codex failed to start: ${e.message}`, recoverable: true }) } catch { /* noop */ }
678
656
  child = null
679
- turnActive = false
657
+ emitTurnBoundary({ kind: 'error', message: `codex failed to start: ${e.message}`, recoverable: true })
680
658
  resolve()
681
659
  })
682
660
  child.on('close', finish)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.264",
3
+ "version": "0.7.266",
4
4
  "description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,6 +16,7 @@
16
16
  "context-windows.mjs",
17
17
  "claude-session.mjs",
18
18
  "codex-session.mjs",
19
+ "question-response.mjs",
19
20
  "thinkpool-room-prompt.mjs",
20
21
  "thinkpool-prompt-contracts.mjs",
21
22
  "thinkpool-capabilities.json",
@@ -0,0 +1,52 @@
1
+ function legacyQuestionAnswers (decision, questions) {
2
+ if (typeof decision !== 'string' || !decision.startsWith('answer:')) return {}
3
+ const text = decision.slice(7).trim()
4
+ if (!text || !questions.length) return {}
5
+ if (questions.length === 1) {
6
+ const question = questions[0]
7
+ const prefix = `${question.header || 'Q1'}: `
8
+ const value = text.startsWith(prefix) ? text.slice(prefix.length) : text
9
+ return { [String(question.id || 0)]: { answers: [value] } }
10
+ }
11
+ const answers = {}
12
+ questions.forEach((question, index) => {
13
+ const id = String(question.id || index)
14
+ const marker = `${index ? '; ' : ''}${question.header || `Q${index + 1}`}: `
15
+ const start = text.indexOf(marker)
16
+ if (start < 0) return
17
+ const valueStart = start + marker.length
18
+ const nextQuestion = questions[index + 1]
19
+ const nextMarker = nextQuestion ? `; ${nextQuestion.header || `Q${index + 2}`}: ` : null
20
+ const end = nextMarker ? text.indexOf(nextMarker, valueStart) : -1
21
+ const value = text.slice(valueStart, end < 0 ? undefined : end).trim()
22
+ if (value) answers[id] = { answers: [value] }
23
+ })
24
+ return answers
25
+ }
26
+
27
+ // Codex and Hermes use different native tool protocols, but the room answer is
28
+ // one contract. Keep filtering, multi-select preservation, and legacy fallback
29
+ // in one pure adapter so the two runtimes cannot drift independently.
30
+ export function questionAnswerResponse (result, questions = []) {
31
+ const supplied = result && typeof result === 'object' ? result.answers : null
32
+ const answers = {}
33
+ if (supplied && typeof supplied === 'object') {
34
+ const allowed = new Set(questions.map((question, index) => String(question?.id || index)))
35
+ for (const [id, value] of Object.entries(supplied)) {
36
+ if (!allowed.has(String(id)) || !Array.isArray(value?.answers)) continue
37
+ const values = value.answers.map((answer) => String(answer)).filter(Boolean)
38
+ if (values.length) answers[String(id)] = { answers: values }
39
+ }
40
+ }
41
+ if (Object.keys(answers).length) return { answers }
42
+ const decision = result && typeof result === 'object' ? result.decision : result
43
+ return { answers: legacyQuestionAnswers(decision, questions) }
44
+ }
45
+
46
+ export function hermesUserInputResponse (result, questions = []) {
47
+ const decision = result && typeof result === 'object' ? result.decision : result
48
+ return {
49
+ status: String(decision || '').startsWith('answer:') ? 'answered' : decision === 'timeout' ? 'timed_out' : 'dismissed',
50
+ ...questionAnswerResponse(result, questions),
51
+ }
52
+ }