thinkpool-pair 0.7.277 → 0.7.279

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
@@ -3124,8 +3124,8 @@ function pendingResolution(pending, payload = {}) {
3124
3124
  const decision = payload.decision || 'deny'
3125
3125
  return pending?.payload?.answerFormat === 'dispatch'
3126
3126
  ? { decision, dispatchFingerprint: payload.dispatchFingerprint || null, controlItemId: payload.controlItemId || null }
3127
- : (pending?.payload?.answerFormat === 'codex' || pending?.payload?.answerFormat === 'hermes')
3128
- ? { decision, answers: payload.answers || {} }
3127
+ : (pending?.payload?.risk === 'ask' || pending?.payload?.answerFormat === 'codex' || pending?.payload?.answerFormat === 'hermes')
3128
+ ? { decision, answers: payload.answers || {}, responder: payload.responder || null }
3129
3129
  : decision
3130
3130
  }
3131
3131
 
@@ -3946,7 +3946,7 @@ channel
3946
3946
  if (p.timer) clearTimeout(p.timer)
3947
3947
  s.pending.delete(payload.id)
3948
3948
  s.permNotifier?.resolve(payload.id)
3949
- p.resolve(pendingResolution(p, { ...payload, decision: authority.decision, answers: authority.answers }))
3949
+ p.resolve(pendingResolution(p, { ...payload, decision: authority.decision, answers: authority.answers, responder: authority.responder }))
3950
3950
  announce()
3951
3951
  })
3952
3952
  })
@@ -99,18 +99,25 @@ export function autoAllow({ toolName, input, mode = 'default', alwaysAllow = new
99
99
  // denies (PreToolUse can't inject a tool_result) and puts the human's pick in
100
100
  // the deny reason, which IS what the model receives. Exported so the feedback
101
101
  // contract is locked in a unit test (mock requestPermission → this output).
102
- // `decision` is the requestPermission return: 'answer:<pick>' when the person
103
- // selected, anything else (including '' / dismissal / a broken path) is treated
104
- // as "no selection".
102
+ // `decision` is the requestPermission return: either the legacy 'answer:<pick>'
103
+ // string or { decision, responder } from the durable room control. Anything else
104
+ // (including '' / dismissal / a broken path) is treated as "no selection".
105
105
  export function askUserQuestionHookOutput(decision) {
106
- const ans = (typeof decision === 'string' && decision.startsWith('answer:')) ? decision.slice(7) : ''
106
+ const rawDecision = decision && typeof decision === 'object' ? decision.decision : decision
107
+ const ans = (typeof rawDecision === 'string' && rawDecision.startsWith('answer:')) ? rawDecision.slice(7) : ''
108
+ const responder = decision && typeof decision === 'object' ? decision.responder : null
109
+ const answeredBy = typeof responder?.name === 'string' && responder.name.trim()
110
+ ? responder.name.trim()
111
+ : typeof responder?.id === 'string' && responder.id
112
+ ? `room member ${responder.id}`
113
+ : 'The user'
107
114
  return {
108
115
  continue: true,
109
116
  hookSpecificOutput: {
110
117
  hookEventName: 'PreToolUse',
111
118
  permissionDecision: 'deny',
112
119
  permissionDecisionReason: ans
113
- ? `The user answered in the ThinkPool room — ${ans}. Treat this as their selection and continue; do not call AskUserQuestion again for the same question.`
120
+ ? `${answeredBy} answered in the ThinkPool room — ${ans}. Treat this as their selection and continue; do not call AskUserQuestion again for the same question.`
114
121
  : 'The user dismissed the question in the ThinkPool room without selecting. Ask in plain prose, or proceed with a sensible default.',
115
122
  },
116
123
  }
package/codex-session.mjs CHANGED
@@ -389,6 +389,12 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
389
389
  let appServer = null
390
390
  let appServerThreadReady = false
391
391
  let activeTurnId = null
392
+ // App Server notifications are asynchronous to turn/completed. In production a
393
+ // stopped turn emitted its durable `aborted` boundary, then delivered a queued
394
+ // item/started 89ms later and a Bash result 159.9s after that. Without a native
395
+ // turn fence those late rows resurrected the lane after Stop. Keep a bounded set
396
+ // of closed native turns and accept turn-scoped traffic only for the one active id.
397
+ const closedAppServerTurnIds = new Set()
392
398
  const appServerItems = new Map()
393
399
  const streamedAgentItems = new Map() // item id → { cid, text }
394
400
  let steerChain = Promise.resolve()
@@ -423,6 +429,18 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
423
429
  }
424
430
 
425
431
  const note = (text) => { try { onEvent?.({ kind: 'note', text }) } catch { /* noop */ } }
432
+
433
+ const closeAppServerTurn = (turnId = activeTurnId) => {
434
+ const id = String(turnId || '')
435
+ if (!id) return
436
+ closedAppServerTurnIds.add(id)
437
+ while (closedAppServerTurnIds.size > 64) closedAppServerTurnIds.delete(closedAppServerTurnIds.values().next().value)
438
+ }
439
+
440
+ const appServerTurnIsCurrent = (params = {}) => {
441
+ const turnId = String(params.turnId || '')
442
+ return !!turnId && !ended && !aborted && !closedAppServerTurnIds.has(turnId) && turnId === String(activeTurnId || '')
443
+ }
426
444
  if (requestedResume && !resumeUsable) {
427
445
  queueMicrotask(() => note('The stopped Codex turn had no resumable context; continuing in a fresh thread.'))
428
446
  }
@@ -439,9 +457,15 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
439
457
 
440
458
  function appServerNotification(method, params = {}) {
441
459
  if (method === 'turn/started' && params.turn?.id) {
442
- activeTurnId = activeTurnId || params.turn.id
460
+ const turnId = String(params.turn.id)
461
+ if (ended || aborted) { closeAppServerTurn(turnId); return }
462
+ if (!activeTurnId) activeTurnId = turnId
443
463
  return
444
464
  }
465
+ // Every mapped App Server notification below is turn-scoped in the tested
466
+ // protocol and carries turnId. Fail closed on missing, stale, completed, or
467
+ // interrupted ids so a provider's buffered stdout cannot reopen a closed turn.
468
+ if ((method === 'turn/plan/updated' || method === 'item/agentMessage/delta' || method === 'item/started' || method === 'item/completed' || method === 'error') && !appServerTurnIsCurrent(params)) return
445
469
  if (method === 'turn/plan/updated') {
446
470
  const todos = (Array.isArray(params.plan) ? params.plan : []).map(({ step, status }) => ({
447
471
  content: step || '',
@@ -486,6 +510,13 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
486
510
  }
487
511
 
488
512
  async function appServerRequest(method, params = {}) {
513
+ // A request racing Stop must never raise a fresh room card after the turn's
514
+ // durable boundary. Respond with the protocol's ordinary denial shape so the
515
+ // native runtime can settle without reopening any ThinkPool state.
516
+ if (!appServerTurnIsCurrent(params)) {
517
+ if (method === 'item/tool/requestUserInput') return { answers: {} }
518
+ return { decision: 'decline' }
519
+ }
489
520
  const card = codexApprovalCard(method, params, appServerItems.get(params.itemId))
490
521
  if (!card) throw new Error(`Unsupported Codex App Server request: ${method}`)
491
522
  // Missing/broken room permission plumbing denies safely. App Server expects
@@ -578,10 +609,12 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
578
609
  // id. abort() already closed the room boundary; interrupt the now-known
579
610
  // native turn before waiting, and never let it continue invisibly.
580
611
  if (aborted || ended) {
612
+ closeAppServerTurn(activeTurnId)
581
613
  try { await appServer.interrupt({ threadId: sessionId, turnId: activeTurnId }) } catch { /* boundary is already closed */ }
582
614
  return true
583
615
  }
584
616
  const completed = await appServer.waitForTurn(activeTurnId)
617
+ closeAppServerTurn(completed?.turn?.id || activeTurnId)
585
618
  const status = completed?.turn?.status
586
619
  if (aborted || status === 'interrupted') {
587
620
  emitTurnBoundary({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: completed?.turn?.durationMs, denials: 0, resultText: null })
@@ -596,6 +629,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
596
629
  // turns only; they can safely resume this thread through the stable driver.
597
630
  appServerDisabled = true
598
631
  appServerThreadReady = false
632
+ closeAppServerTurn(activeTurnId)
599
633
  try { appServer?.end() } catch { /* noop */ }
600
634
  appServer = null
601
635
  if (!ended) pushMappedEvent({ type: 'turn.failed', message: `codex app-server turn failed: ${error?.message || error}` })
@@ -625,10 +659,12 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
625
659
  activeTurnId = started?.turn?.id || started?.turnId
626
660
  if (!activeTurnId) throw new Error('Codex review/start returned no turn id')
627
661
  if (aborted || ended) {
662
+ closeAppServerTurn(activeTurnId)
628
663
  try { await appServer.interrupt({ threadId: sessionId, turnId: activeTurnId }) } catch { /* boundary is already closed */ }
629
664
  return true
630
665
  }
631
666
  const completed = await appServer.waitForTurn(activeTurnId)
667
+ closeAppServerTurn(completed?.turn?.id || activeTurnId)
632
668
  const status = completed?.turn?.status
633
669
  if (aborted || status === 'interrupted') {
634
670
  emitTurnBoundary({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: completed?.turn?.durationMs, denials: 0, resultText: null })
@@ -640,6 +676,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
640
676
  } catch (error) {
641
677
  appServerDisabled = true
642
678
  appServerThreadReady = false
679
+ closeAppServerTurn(activeTurnId)
643
680
  try { appServer?.end() } catch { /* noop */ }
644
681
  appServer = null
645
682
  if (!ended) pushMappedEvent({ type: 'turn.failed', message: `codex review failed: ${error?.message || error}` })
@@ -834,6 +871,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
834
871
  const bootstrapOnly = turnActive && !activeTurnId && !child
835
872
  aborted = true
836
873
  queue.length = 0
874
+ closeAppServerTurn(activeTurnId)
837
875
  if (appServer && activeTurnId) { void appServer.interrupt({ threadId: sessionId, turnId: activeTurnId }).catch(() => {}) ; return }
838
876
  if (child) { try { child.kill('SIGTERM') } catch { /* noop */ } ; setTimeout(() => { try { child?.kill('SIGKILL') } catch { /* noop */ } }, 1500) }
839
877
  else if (bootstrapOnly) {
@@ -847,6 +885,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
847
885
  ended = true
848
886
  turnActive = false
849
887
  queue.length = 0
888
+ closeAppServerTurn(activeTurnId)
850
889
  if (child) { try { child.kill() } catch { /* noop */ } }
851
890
  try { appServer?.end() } catch { /* noop */ }
852
891
  void mcpHttpPromise?.then((h) => h.close()).catch(() => {})
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.277",
3
+ "version": "0.7.279",
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": {
@@ -34,6 +34,15 @@ export function verifyPairControlDeliveryAuthority ({ item, attempts, payload, r
34
34
  event?.actor_id === item.resolved_by && Number(event.item_version) === Number(item.version) &&
35
35
  event.correlation_key === payload.attemptKey && event.payload?.data?.transport === 'code-perm') : []
36
36
  if (matching.length !== 1) return denied('delivery_attempt_unverified')
37
+ const durableResponder = resolution.responder
38
+ const responder = durableResponder && durableResponder.id === item.resolved_by
39
+ ? {
40
+ id: item.resolved_by,
41
+ name: typeof durableResponder.name === 'string' && durableResponder.name.trim()
42
+ ? durableResponder.name.trim().slice(0, 120)
43
+ : null,
44
+ }
45
+ : { id: item.resolved_by, name: null }
37
46
  return Object.freeze({
38
47
  ok: true,
39
48
  code: 'durable_delivery_authorized',
@@ -41,6 +50,7 @@ export function verifyPairControlDeliveryAuthority ({ item, attempts, payload, r
41
50
  // App Server input is recovered from the durable resolution only. The public
42
51
  // broadcast intentionally remains a wake-up hint and cannot supply answers.
43
52
  answers: resolution.answers && typeof resolution.answers === 'object' ? resolution.answers : {},
53
+ responder: Object.freeze(responder),
44
54
  itemId: item.id,
45
55
  version: Number(item.version),
46
56
  attemptKey: payload.attemptKey,
@@ -38,9 +38,15 @@ export function questionAnswerResponse (result, questions = []) {
38
38
  if (values.length) answers[String(id)] = { answers: values }
39
39
  }
40
40
  }
41
- if (Object.keys(answers).length) return { answers }
41
+ const responder = result?.responder && typeof result.responder === 'object'
42
+ ? {
43
+ id: typeof result.responder.id === 'string' ? result.responder.id : null,
44
+ name: typeof result.responder.name === 'string' ? result.responder.name : null,
45
+ }
46
+ : null
47
+ if (Object.keys(answers).length) return { answers, ...(responder ? { responder } : {}) }
42
48
  const decision = result && typeof result === 'object' ? result.decision : result
43
- return { answers: legacyQuestionAnswers(decision, questions) }
49
+ return { answers: legacyQuestionAnswers(decision, questions), ...(responder ? { responder } : {}) }
44
50
  }
45
51
 
46
52
  export function hermesUserInputResponse (result, questions = []) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "bundleVersion": 5,
3
+ "bundleVersion": 6,
4
4
  "contracts": [
5
5
  {
6
6
  "id": "room-coordination",
@@ -65,7 +65,7 @@
65
65
  },
66
66
  {
67
67
  "id": "room-question",
68
- "version": 1,
68
+ "version": 2,
69
69
  "routes": [
70
70
  {
71
71
  "id": "room-question",
@@ -74,6 +74,7 @@
74
74
  "prompt": "When a missing choice genuinely blocks useful progress, use request_user_input so the question is answerable in the room; otherwise make a safe in-scope assumption and continue."
75
75
  }
76
76
  ],
77
+ "resultContract": "A resolved room question returns the selected answers plus the durable responder identity {id, name}; the id must match the pair-control row's resolved_by authority.",
77
78
  "impact": [
78
79
  {"path": "bridge/bridge.mjs", "diffPattern": "request_user_input"},
79
80
  {"path": "bridge/claude-session.mjs", "diffPattern": "AskUserQuestion|request_user_input"},