thinkpool-pair 0.7.332 → 0.7.334

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
@@ -92,7 +92,7 @@ function stopFlowPreviews (flowId, laneId = null) {
92
92
  }
93
93
  import { FLOW_REVIEWER_PROMPT, FLOW_CODEX_REVIEWER_PROMPT, revertLane, parseReviewVerdict, reviewVerdictToReflection } from './flow-review.mjs'
94
94
  import { reviewGateDecision } from './flow-review-gate.mjs'
95
- import { readReviewFile, runReviewCheck } from './review-check.mjs'
95
+ import { IMMUTABLE_REVIEW_READ_TOOL_EXTRAS, UNSANDBOXED_REVIEW_CHECK_TOOL_EXTRAS, readReviewFile, runReviewCheck } from './review-check.mjs'
96
96
  import { pairAdjudicationPrompt, reviewReflectionDecision, REVIEW_DEFAULTS } from './flow-review-reflect.mjs'
97
97
  import { mergeWorktrees, inlineSingleHtml, initRepo } from './flow-assembly.mjs'
98
98
  import { canDispatch, FLOW_LIMITS, makeBudget, recordSpend, killSwitchEnv } from './flow-budget.mjs'
@@ -117,7 +117,7 @@ const flowRedispatch = new Map()
117
117
  // wave BEFORE overrun. Lives bridge-side because waves dispatch across separate
118
118
  // broadcasts; without persistent state the cap can never bite.
119
119
  const flowBudgets = new Map()
120
- import { formatPeek, PEEK, readTerminalBudgetDecision, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, CROSSROOM_BUS, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, closeLaneDecision, laneBusyOf, laneStatusOf, settleLaneBusy, nativeClaudeFallbackHint, buildDispatchPreview, authorizeDirectDispatch } from './cross-terminal.mjs'
120
+ import { formatPeek, PEEK, readTerminalTurnBudgetDecision, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, CROSSROOM_BUS, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, closeLaneDecision, laneBusyOf, laneStatusOf, settleLaneBusy, nativeClaudeFallbackHint, buildDispatchPreview, authorizeDirectDispatch } from './cross-terminal.mjs'
121
121
  import { createStandalonePairResponder, standalonePairIdentity } from './direct-pair-room.mjs'
122
122
  import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
123
123
  import { supersedeDispatchLease } from './dispatch-lease.mjs'
@@ -2596,13 +2596,16 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2596
2596
  if (args?.terminal && !target) {
2597
2597
  return { content: [{ type: 'text', text: formatPeek({ selfId: id, sessions, terms, names: termNames, terminal: args.terminal, lines: args?.lines }) }] }
2598
2598
  }
2599
- const budget = readTerminalBudgetDecision({
2599
+ const budget = readTerminalTurnBudgetDecision({
2600
2600
  targeted: !!args?.terminal,
2601
2601
  targetedCount: entry.peekCount,
2602
2602
  rosterCount: entry.peekRosterCount,
2603
+ budgetTurnRev: entry.peekBudgetTurnRev,
2604
+ currentTurnRev: entry._turnRev,
2603
2605
  })
2604
2606
  entry.peekCount = budget.targetedCount
2605
2607
  entry.peekRosterCount = budget.rosterCount
2608
+ entry.peekBudgetTurnRev = budget.budgetTurnRev
2606
2609
  if (!budget.ok) return { content: [{ type: 'text', text: budget.reason }] }
2607
2610
  const targetEntry = target?.kind === 'agent' ? sessions.get(target.id) : null
2608
2611
  const text = formatPeek({ selfId: id, sessions, terms, names: termNames, terminal: args?.terminal, lines: args?.lines })
@@ -3029,11 +3032,13 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3029
3032
  'Read one regular file from the declared immutable review target. This always reads the target’s pinned Git object, never a live worktree; target and path are validated and the response is byte-bounded.',
3030
3033
  { target: z.string(), path: z.string() },
3031
3034
  async (args) => ({ content: [{ type: 'text', text: JSON.stringify(await readReviewFile({ target: args?.target, filePath: args?.path, snapshots: entry.flowReviewSnapshots })) }] }),
3035
+ IMMUTABLE_REVIEW_READ_TOOL_EXTRAS,
3032
3036
  ), tool(
3033
3037
  'run_review_check',
3034
- 'Run one fixed verification check against an immutable git-archived review target. This accepts only the declared target and a fixed check id; it never accepts a shell command, cwd, environment, executable, or arguments. The check runs in a scratch archive, never in a builder worktree.',
3038
+ 'Run one fixed verification check against an immutable git-archived review target. This accepts only the declared target and a fixed check id, but executes target-defined code in a scratch archive that is not an OS filesystem/network sandbox. It can require approval and must be refused by approvalPolicy=never reviewers.',
3035
3039
  { target: z.string(), checkId: z.string() },
3036
3040
  async (args) => ({ content: [{ type: 'text', text: JSON.stringify(await runReviewCheck({ target: args?.target, checkId: args?.checkId, snapshots: entry.flowReviewSnapshots })) }] }),
3041
+ UNSANDBOXED_REVIEW_CHECK_TOOL_EXTRAS,
3037
3042
  )] : []),
3038
3043
  ],
3039
3044
  })
@@ -18,7 +18,7 @@ export const PEEK = {
18
18
  // explicitly when a diagnosis genuinely needs deeper sibling history.
19
19
  defaultLines: 20,
20
20
  maxLines: 200,
21
- perTurnCap: 10, // targeted transcript reads allowed per user turn (bridge resets it)
21
+ perTurnCap: 10, // targeted transcript reads allowed per accepted room turn
22
22
  rosterPerTurnCap: 1, // no-argument roster reads use a separate allowance; ROOM NOW is default
23
23
  lineCap: 200, // per-line truncation
24
24
  rosterPreview: 100, // last-line preview length in the roster listing
@@ -37,7 +37,7 @@ export const readTerminalBudgetDecision = ({ targeted = false, targetedCount = 0
37
37
  ok: false,
38
38
  targetedCount: nextTargeted,
39
39
  rosterCount: nextRoster,
40
- reason: `Cross-terminal transcript read limit reached for this turn (${limits.perTurnCap}). Stop polling and continue with the worker results already collected; a new person-authored turn resets the allowance.`,
40
+ reason: `Cross-terminal transcript read limit reached for this turn (${limits.perTurnCap}). Stop polling and continue with the worker results already collected; a fresh accepted room turn resets the allowance.`,
41
41
  }
42
42
  return { ok: true, targetedCount: nextTargeted + 1, rosterCount: nextRoster }
43
43
  }
@@ -50,6 +50,34 @@ export const readTerminalBudgetDecision = ({ targeted = false, targetedCount = 0
50
50
  return { ok: true, targetedCount: nextTargeted, rosterCount: nextRoster + 1 }
51
51
  }
52
52
 
53
+ // Provider/model reconstruction can deliver a fresh accepted agent turn without
54
+ // another browser `code-turn` frame (context-carry and interrupted-resume paths).
55
+ // Key read allowances to the bridge-owned turn revision as a second reset seam,
56
+ // so a reconstructed runtime never inherits ten reads spent by the old model.
57
+ // This intentionally resets only read allowances; hop/post/spawn loop breakers
58
+ // remain tied to real person ingress and cannot be escaped by reconstruction.
59
+ export const readTerminalTurnBudgetDecision = ({
60
+ targeted = false,
61
+ targetedCount = 0,
62
+ rosterCount = 0,
63
+ budgetTurnRev = null,
64
+ currentTurnRev = 0,
65
+ } = {}, limits = PEEK) => {
66
+ const current = Math.max(0, Number(currentTurnRev) || 0)
67
+ const previous = budgetTurnRev == null
68
+ ? null
69
+ : Number.isFinite(Number(budgetTurnRev)) ? Number(budgetTurnRev) : null
70
+ const sameTurn = previous === current
71
+ return {
72
+ ...readTerminalBudgetDecision({
73
+ targeted,
74
+ targetedCount: sameTurn ? targetedCount : 0,
75
+ rosterCount: sameTurn ? rosterCount : 0,
76
+ }, limits),
77
+ budgetTurnRev: current,
78
+ }
79
+ }
80
+
53
81
  // One shared lane-status classifier for every roster consumer: the bridge wire,
54
82
  // read_terminal, and ROOM NOW. Timestamps stay absolute on the wire so clients can
55
83
  // keep the displayed age current without a broadcast every second.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.332",
3
+ "version": "0.7.334",
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": {
package/review-check.mjs CHANGED
@@ -17,6 +17,33 @@ const CHECKS = Object.freeze({
17
17
  })
18
18
  const OUTPUT_CAP = 512 * 1024
19
19
 
20
+ // Codex's native read-only/review mode never raises approval cards. Mark only
21
+ // the pinned Git-blob reader as approval-free: it cannot execute target code,
22
+ // mutate the target, or reach the open world.
23
+ export const IMMUTABLE_REVIEW_READ_TOOL_EXTRAS = Object.freeze({
24
+ annotations: Object.freeze({
25
+ readOnlyHint: true,
26
+ destructiveHint: false,
27
+ idempotentHint: true,
28
+ openWorldHint: false,
29
+ }),
30
+ })
31
+
32
+ // Fixed check ids prevent command injection, but the selected check still runs
33
+ // target-defined code. The scratch archive protects the reviewed worktree; it
34
+ // is not an OS filesystem/network sandbox. Advertise that boundary honestly so
35
+ // approvalPolicy=never reviewers refuse execution instead of silently granting
36
+ // host/open-world authority. Sandboxed runners can still expose this behind an
37
+ // explicit approval in runtimes that support one.
38
+ export const UNSANDBOXED_REVIEW_CHECK_TOOL_EXTRAS = Object.freeze({
39
+ annotations: Object.freeze({
40
+ readOnlyHint: false,
41
+ destructiveHint: true,
42
+ idempotentHint: false,
43
+ openWorldHint: true,
44
+ }),
45
+ })
46
+
20
47
  export function reviewCheckCommand(checkId) {
21
48
  if (!Object.hasOwn(CHECKS, checkId)) throw new Error('unapproved review check')
22
49
  const [command, args] = CHECKS[checkId]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "bundleVersion": 15,
3
+ "bundleVersion": 16,
4
4
  "contracts": [
5
5
  {
6
6
  "id": "room-coordination",
@@ -130,13 +130,13 @@
130
130
  },
131
131
  {
132
132
  "id": "flow-completion",
133
- "version": 1,
133
+ "version": 2,
134
134
  "routes": [
135
135
  {
136
136
  "id": "flow-completion",
137
137
  "tools": ["submit_flow_plan", "mark_flow_done", "submit_flow_review", "read_review_file", "run_review_check"],
138
138
  "trigger": "\\b(flow|submit_flow_plan|mark_flow_done|submit_flow_review|read_review_file|run_review_check)\\b",
139
- "prompt": "Managed Flow roles use only their exposed completion and immutable-review tools: submit_flow_plan, mark_flow_done, submit_flow_review, read_review_file, and run_review_check."
139
+ "prompt": "Managed Flow roles use only their exposed completion and review tools: submit_flow_plan, mark_flow_done, submit_flow_review, read_review_file, and run_review_check. read_review_file is approval-free pinned-source access. run_review_check executes target-defined code without OS filesystem or network isolation and therefore remains subject to the runtime's normal approval boundary; approvalPolicy=never reviewers must not call it and must report the skipped check."
140
140
  }
141
141
  ],
142
142
  "impact": [