thinkpool-pair 0.7.265 → 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.
@@ -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
@@ -393,6 +393,22 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
393
393
  },
394
394
  })
395
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
+
396
412
  const note = (text) => { try { onEvent?.({ kind: 'note', text }) } catch { /* noop */ } }
397
413
  if (requestedResume && !resumeUsable) {
398
414
  queueMicrotask(() => note('The stopped Codex turn had no resumable context; continuing in a fresh thread.'))
@@ -447,12 +463,12 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
447
463
  return
448
464
  }
449
465
  }
450
- 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) })
451
467
  return
452
468
  }
453
469
  if (method === 'error') {
454
470
  const message = params.error?.message || params.message || 'codex app-server error'
455
- mapper.push({ type: 'error', message })
471
+ pushMappedEvent({ type: 'error', message })
456
472
  }
457
473
  }
458
474
 
@@ -507,7 +523,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
507
523
  config: { 'features.default_mode_request_user_input': true },
508
524
  })
509
525
  appServerThreadReady = true
510
- mapper.push({ type: 'thread.started', thread_id: sessionId })
526
+ pushMappedEvent({ type: 'thread.started', thread_id: sessionId })
511
527
  return true
512
528
  } catch (error) {
513
529
  // Fallback is safe only here: no turn/start request has been accepted, so
@@ -539,11 +555,11 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
539
555
  const completed = await appServer.waitForTurn(activeTurnId)
540
556
  const status = completed?.turn?.status
541
557
  if (aborted || status === 'interrupted') {
542
- 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 })
543
559
  } else if (status === 'failed') {
544
- 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' })
545
561
  } else {
546
- mapper.push({ type: 'turn.completed' })
562
+ pushMappedEvent({ type: 'turn.completed' })
547
563
  }
548
564
  } catch (error) {
549
565
  // Once a started turn's transport fails we must not replay that prompt via
@@ -553,7 +569,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
553
569
  appServerThreadReady = false
554
570
  try { appServer?.end() } catch { /* noop */ }
555
571
  appServer = null
556
- 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}` })
557
573
  } finally {
558
574
  // A transport failure or interrupt can land after prose deltas but before
559
575
  // item/completed. Preserve that partial prose as one durable row rather
@@ -614,7 +630,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
614
630
  }
615
631
  if (ev.type === 'thread.started' && ev.thread_id) sessionId = sessionId || ev.thread_id
616
632
  if (ev.type === 'turn.completed' || ev.type === 'turn.failed') sawTerminalResult = true
617
- mapper.push(ev)
633
+ pushMappedEvent(ev)
618
634
  })
619
635
  // stderr is codex's human progress channel; surface tail on failure only.
620
636
  let stderrTail = ''
@@ -628,18 +644,17 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
628
644
  child = null
629
645
  turnActive = false
630
646
  if (aborted && !sawTerminalResult) {
631
- 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 })
632
648
  } else if (!aborted && !sawTerminalResult && code !== 0 && code != null) {
633
- 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 })
634
650
  }
635
651
  resolve()
636
652
  }
637
653
  child.on('error', (e) => {
638
654
  if (settled) return
639
655
  settled = true
640
- try { onEvent?.({ kind: 'error', message: `codex failed to start: ${e.message}`, recoverable: true }) } catch { /* noop */ }
641
656
  child = null
642
- turnActive = false
657
+ emitTurnBoundary({ kind: 'error', message: `codex failed to start: ${e.message}`, recoverable: true })
643
658
  resolve()
644
659
  })
645
660
  child.on('close', finish)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.265",
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": {