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 +2 -4
- package/claude-session.mjs +5 -3
- package/codex-session.mjs +29 -51
- package/package.json +2 -1
- package/question-response.mjs +52 -0
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
|
-
|
|
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 }),
|
package/claude-session.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
560
|
+
pushMappedEvent({ type: 'turn.failed', message: completed?.turn?.error?.message || 'codex turn failed' })
|
|
582
561
|
} else {
|
|
583
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|