thinkpool-pair 0.7.263 → 0.7.265

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 }),
@@ -3104,23 +3102,20 @@ function cancelPendingDurablePermissions(s, permissionIds = null, reason = 'perm
3104
3102
  })
3105
3103
  }
3106
3104
 
3107
- const restoredPermissionCleanupPending = new Set()
3105
+ let restoredPermissionCleanupPending = false
3108
3106
 
3109
- async function cancelRestoredDurablePermissions(terminalIds = []) {
3110
- for (const terminalId of terminalIds) if (terminalId) restoredPermissionCleanupPending.add(terminalId)
3111
- const terminals = [...restoredPermissionCleanupPending]
3112
- const outcomes = await Promise.all(terminals.map(async (terminalId) => {
3113
- const result = await cancelDurablePermissions({
3114
- supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON, token: codeAuthToken, roomCode: room,
3115
- // New bridge process, old durable bridge authority: filter by the
3116
- // restored terminal and let the cleanup read each historical binding.
3117
- terminalId, reason: 'bridge_restarted',
3118
- })
3119
- if (!result.ok && process.env.TP_DEBUG) process.stderr.write(`\n ◇ restored permission cleanup incomplete (${result.code})\n`)
3120
- return { terminalId, ok: result.ok }
3121
- }))
3122
- for (const outcome of outcomes) if (outcome.ok) restoredPermissionCleanupPending.delete(outcome.terminalId)
3123
- return { ok: restoredPermissionCleanupPending.size === 0, pending: [...restoredPermissionCleanupPending] }
3107
+ async function cancelRestoredDurablePermissions() {
3108
+ restoredPermissionCleanupPending = true
3109
+ const result = await cancelDurablePermissions({
3110
+ supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON, token: codeAuthToken, roomCode: room,
3111
+ // A new process gets a fresh bridge id. Every approval bound to any older
3112
+ // id has lost its process-local resolver, including archived terminals that
3113
+ // no longer have a session file and therefore never enter loadAll().
3114
+ currentBridgeId: BRIDGE_ID, reason: 'bridge_restarted',
3115
+ })
3116
+ if (result.ok) restoredPermissionCleanupPending = false
3117
+ else if (process.env.TP_DEBUG) process.stderr.write(`\n ◇ restored permission cleanup incomplete (${result.code})\n`)
3118
+ return result
3124
3119
  }
3125
3120
 
3126
3121
  function drainPending(s) {
@@ -4065,7 +4060,7 @@ channel
4065
4060
  // Cancel their durable dispatch rows BEFORE opening a restored SDK
4066
4061
  // session, so a new permission raised during resume can never be
4067
4062
  // mistaken for a pre-restart card from the same terminal.
4068
- await cancelRestoredDurablePermissions(all.map((rec) => rec.id))
4063
+ await cancelRestoredDurablePermissions()
4069
4064
  if (all.length) for (const rec of all) {
4070
4065
  const wasInterrupted = restoredTurnOpen(rec.log || [])
4071
4066
  // Codex can persist a thread id before its rollout receives the
@@ -4608,9 +4603,9 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
4608
4603
  })
4609
4604
  refreshOwnerPlan()
4610
4605
  // A restart sweep may have raced an expiring owner JWT. Keep failed
4611
- // terminal ids until the supervisor hands us its next fresh token, then
4612
- // retry the exact cleanup instead of leaving dead question cards forever.
4613
- if (restoredPermissionCleanupPending.size) void cancelRestoredDurablePermissions()
4606
+ // room sweep queued until the supervisor hands us its next fresh token,
4607
+ // then retry without leaving archived-terminal questions behind.
4608
+ if (restoredPermissionCleanupPending) void cancelRestoredDurablePermissions()
4614
4609
  }
4615
4610
  })
4616
4611
  setInterval(() => { try { process.send({ t: 'idle', idle: idleNow(), between: betweenTurns(), webPeer: webPeerPresent() }) } catch { /* parent gone */ } }, 5000).unref()
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) {
@@ -9,14 +9,17 @@ const SETTLED = new Set(['denied', 'idempotent_replay', 'already_resolved', 'sta
9
9
 
10
10
  const safeString = (value) => typeof value === 'string' && value.length > 0
11
11
 
12
- export function pendingDurablePermissionItems (items, { bridgeId = null, terminalId, permissionIds = null, actionKinds = ['approval', 'dispatch'] } = {}) {
13
- if ((bridgeId != null && !safeString(bridgeId)) || !safeString(terminalId)) return []
12
+ export function pendingDurablePermissionItems (items, { bridgeId = null, currentBridgeId = null, terminalId = null, permissionIds = null, actionKinds = ['approval', 'dispatch'] } = {}) {
13
+ if ((bridgeId != null && !safeString(bridgeId)) || (currentBridgeId != null && !safeString(currentBridgeId)) ||
14
+ (terminalId != null && !safeString(terminalId)) || (terminalId == null && currentBridgeId == null)) return []
14
15
  const requested = permissionIds == null ? null : new Set(permissionIds.filter(safeString))
15
16
  const actions = new Set(actionKinds)
16
17
  return (Array.isArray(items) ? items : []).filter((item) => {
17
18
  const permissionId = item?.request_context?.permission_id
18
19
  return PENDING.has(item?.status) && item?.item_kind === 'approval' && actions.has(item?.action_kind) &&
19
- (!bridgeId || item?.bridge_authority_id === bridgeId) && item?.local_authority_id === terminalId &&
20
+ (!bridgeId || item?.bridge_authority_id === bridgeId) &&
21
+ (!currentBridgeId || item?.bridge_authority_id !== currentBridgeId) &&
22
+ (!terminalId || item?.local_authority_id === terminalId) &&
20
23
  safeString(permissionId) && (!requested || requested.has(permissionId))
21
24
  })
22
25
  }
@@ -37,10 +40,13 @@ const rpc = async ({ fetchImpl, supabaseUrl, headers, name, body }) => {
37
40
  // room-member identity. Never accept an id supplied by a public broadcast.
38
41
  export async function cancelDurablePermissions ({
39
42
  fetchImpl = globalThis.fetch, supabaseUrl, anonKey, token, roomCode, bridgeId,
40
- terminalId, permissionIds = null, reason = 'permission_aborted', actionKinds = ['approval', 'dispatch'],
43
+ currentBridgeId = null, terminalId = null, permissionIds = null,
44
+ reason = 'permission_aborted', actionKinds = ['approval', 'dispatch'],
41
45
  } = {}) {
42
46
  if (typeof fetchImpl !== 'function' || !safeString(supabaseUrl) || !safeString(anonKey) ||
43
- !safeString(token) || !safeString(roomCode) || (bridgeId != null && !safeString(bridgeId)) || !safeString(terminalId)) {
47
+ !safeString(token) || !safeString(roomCode) || (bridgeId != null && !safeString(bridgeId)) ||
48
+ (currentBridgeId != null && !safeString(currentBridgeId)) ||
49
+ (terminalId != null && !safeString(terminalId)) || (terminalId == null && currentBridgeId == null)) {
44
50
  return Object.freeze({ ok: false, code: 'cleanup_unavailable', canceled: 0 })
45
51
  }
46
52
  if (permissionIds != null && (!Array.isArray(permissionIds) || !permissionIds.length)) {
@@ -51,7 +57,7 @@ export async function cancelDurablePermissions ({
51
57
  const listed = await rpc({ fetchImpl, supabaseUrl, headers, name: 'list_code_pair_controls', body: {
52
58
  p_session_code: String(roomCode).toUpperCase(), p_status: null, p_limit: 100,
53
59
  } })
54
- const candidates = pendingDurablePermissionItems(listed, { bridgeId, terminalId, permissionIds, actionKinds })
60
+ const candidates = pendingDurablePermissionItems(listed, { bridgeId, currentBridgeId, terminalId, permissionIds, actionKinds })
55
61
  const outcomes = await Promise.all(candidates.map(async (item) => {
56
62
  const result = await rpc({ fetchImpl, supabaseUrl, headers, name: 'resolve_code_pair_control', body: {
57
63
  p_item_id: item.id,
@@ -63,7 +69,7 @@ export async function cancelDurablePermissions ({
63
69
  // by its immutable authority binding, so resolve it with that exact
64
70
  // value after the owner-authenticated RLS list—not a guessed current ID.
65
71
  p_bridge_authority_id: item.bridge_authority_id,
66
- p_local_authority_id: terminalId,
72
+ p_local_authority_id: item.local_authority_id,
67
73
  p_idempotency_key: `cancel-permission:${item.id}:${reason}`.slice(0, 160),
68
74
  } })
69
75
  return { id: item.id, code: result?.code || 'invalid_response', ok: Boolean(result?.ok) && SETTLED.has(result.code) }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.263",
3
+ "version": "0.7.265",
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
+ }