thinkpool-pair 0.7.151 → 0.7.153

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
@@ -988,11 +988,25 @@ const mockupSeen = new Map() // `${term}/${slug}` → mtimeMs of the last mani
988
988
  // in the Map" guess meant closing the generating terminal moved the cards to
989
989
  // whatever terminal was now first — they leaked into Terminal 1.)
990
990
  const handleManifest = async (box, file, term) => {
991
- if (!file || !file.endsWith('.json')) return
991
+ // Ignore our own delivery receipts — writing <slug>.receipt.json into the
992
+ // watched outbox re-fires fs.watch; without this guard it would re-trigger
993
+ // handleManifest in a loop (it still ends in .json). Must come first.
994
+ if (!file || file.endsWith('.receipt.json') || !file.endsWith('.json')) return
992
995
  const full = path.join(box, file)
993
996
  let stat
994
997
  try { stat = fs.statSync(full) } catch { return } // tmp/removed mid-write
995
998
  const slug = file.replace(/\.json$/, '')
999
+ // Delivery receipt (atomic tmp+mv) so render.sh can confirm the card actually
1000
+ // reached the room — written on success AND failure. Best-effort: a receipt
1001
+ // that can't be written just leaves render.sh at "delivery unconfirmed".
1002
+ const writeReceipt = (ok, status) => {
1003
+ try {
1004
+ const rc = path.join(box, `${slug}.receipt.json`)
1005
+ const tmp = `${rc}.tmp`
1006
+ fs.writeFileSync(tmp, JSON.stringify({ ok, status, ts: Date.now() }))
1007
+ fs.renameSync(tmp, rc)
1008
+ } catch { /* receipts are best-effort */ }
1009
+ }
996
1010
  const seenKey = `${term}/${slug}`
997
1011
  if (mockupSeen.get(seenKey) === stat.mtimeMs) return // already handled
998
1012
  mockupSeen.set(seenKey, stat.mtimeMs)
@@ -1017,11 +1031,16 @@ const handleManifest = async (box, file, term) => {
1017
1031
  desktopPng: readB64(m.desktop), mobilePng: readB64(m.mobile), html: readTxt(m.html),
1018
1032
  }),
1019
1033
  })
1020
- if (!res.ok) { process.stderr.write(`\n ◇ mockup "${m.slug}" upload failed (${res.status}).\n`); return }
1034
+ if (!res.ok) {
1035
+ writeReceipt(false, res.status)
1036
+ process.stderr.write(`\n ◇ mockup "${m.slug}" upload failed (${res.status}).\n`); return
1037
+ }
1021
1038
  const { paths } = await res.json()
1022
1039
  bcast('code-event', { term, evt: { kind: 'mockup', __struct: true, cid, term, slug: m.slug, title: m.title || m.slug, ts: m.ts, paths } })
1040
+ writeReceipt(true, 200)
1023
1041
  process.stderr.write(`\n ◆ mockup "${m.title || m.slug}" pushed to the room.\n`)
1024
1042
  } catch (e) {
1043
+ writeReceipt(false, 'error')
1025
1044
  process.stderr.write(`\n ◇ mockup "${m.slug}" send failed: ${e?.message || e}\n`)
1026
1045
  }
1027
1046
  }
@@ -1761,6 +1780,22 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1761
1780
  // Tier C precheck — the PreToolUse gate calls this BEFORE raising a card, so a
1762
1781
  // disabled/looping/over-cap post never bothers a person. Closes over `entry`.
1763
1782
  crossPostGate: () => crossPostDecision({ hop: entry.hop || 0, postCount: entry.postCount || 0, disabled: process.env.TP_CROSSPOST_OFF === '1' }),
1783
+ // Tier C consent scope (2026-07-03) — the PreToolUse gate asks whether THIS lane
1784
+ // spawned the post's target, so a parent→child post auto-allows without a human
1785
+ // tap. Resolves the sibling exactly like the post_to_terminal handler does, then
1786
+ // checks the ownership marker (spawn_terminal sets ne.spawnedBy = spawner id). A
1787
+ // flow lane's spawnedBy is `flow:<id>` (not a session id), so it never matches
1788
+ // here — flow posts ride the bypass rule instead. Fails safe (false → card).
1789
+ // Spec: docs/specs/2026-07-03-xpost-consent-scope.md.
1790
+ didSpawnTarget: (terminalRef) => {
1791
+ try {
1792
+ const sib = siblingsOf({ selfId: id, sessions, terms, names: termNames })
1793
+ const target = resolveSibling(sib, terminalRef)
1794
+ if (!target) return false
1795
+ const te = sessions.get(target.id)
1796
+ return !!te && te.spawnedBy === id
1797
+ } catch { return false }
1798
+ },
1764
1799
  // Tier 3 — the SENDER-side gate for post_to_session (the receipt card lives in
1765
1800
  // the target room). roomHop/crossRoomPostCount reset on a real human turn (code-turn).
1766
1801
  crossRoomPostGate: () => crossRoomPostDecision({ roomHop: entry.roomHop || 0, postCount: entry.crossRoomPostCount || 0, disabled: process.env.TP_PAIRBUS_OFF === '1' }),
@@ -16,6 +16,7 @@ import { randomUUID } from 'node:crypto'
16
16
  import { query } from '@anthropic-ai/claude-agent-sdk'
17
17
  import { sanitizeSession } from './transcript-sanitize.mjs'
18
18
  import { reviewGatePreToolDecision } from './flow-review-gate.mjs'
19
+ import { crossPostNeedsCard } from './cross-terminal.mjs'
19
20
 
20
21
  // ── risk classification — the accent/danger tier of the permission card ──
21
22
  // low (read-only) · medium (writes/runs) · network (leaves the machine) ·
@@ -186,7 +187,7 @@ export async function oneShotSummary({ prompt, env, cwd, timeoutMs = 25000 }) {
186
187
  return null
187
188
  }
188
189
 
189
- export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, rolePrompt, blockSubagents = false, onSubmitPlan = null, onLaneDone = null, onReviewVerdict = null, reviewGate = null, lazy = false, roomContext = null }) {
190
+ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, didSpawnTarget = null, rolePrompt, blockSubagents = false, onSubmitPlan = null, onLaneDone = null, onReviewVerdict = null, reviewGate = null, lazy = false, roomContext = null }) {
190
191
  // Per-turn reminder + live ROOM NOW tail. roomContext (bridge-supplied) returns the
191
192
  // room's CURRENT state — sibling lanes, active git worktrees — or null. The static
192
193
  // rules keep the agent aware of the room's FEATURES; the live tail keeps it aware of
@@ -482,16 +483,31 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
482
483
  const allowed = decision === 'allow'
483
484
  return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: allowed ? 'allow' : 'deny', permissionDecisionReason: allowed ? 'Bypass spawn approved in the ThinkPool room — the new lane runs autonomously.' : 'Bypass spawn denied in the room — do not retry as bypass; re-spawn without mode:bypassPermissions to get a gated lane, or ask what to do.' } }
484
485
  }
485
- // ThinkPool cross-terminal POST (Tier C) WRITES into a sibling agent's lane —
486
- // human-gated EVERY time, never remembered. First a bridge-side precheck
487
- // (kill-switch / loop-breaker / per-turn cap) so a blocked or runaway post
488
- // never even raises a card; then the standard permission round-trip at 'high'
489
- // risk (card offers Deny / Allow-once only — no "always"). Spec INV-4.
486
+ // ThinkPool cross-terminal POST (Tier C) WRITES into a sibling agent's lane.
487
+ // First a bridge-side precheck (kill-switch / loop-breaker / per-turn cap) so a
488
+ // blocked or runaway post never even raises a card. THEN the consent SCOPE
489
+ // (crossPostNeedsCard, 2026-07-03): a post auto-allows with NO card in exactly
490
+ // two cases — (a) this lane is in bypassPermissions (bypass means bypass), or
491
+ // (b) this lane SPAWNED the target (parent→child orchestration; bridge-side
492
+ // didSpawnTarget resolves the sibling + checks spawnedBy). Every OTHER post — a
493
+ // non-owner into a lane it did not spawn, in any non-bypass mode — still round-
494
+ // trips the 'high'-risk card (Deny / Allow-once only). This unblocks automated
495
+ // conductor→lane orchestration (the 3-stacked-Dispatch-cards bug) WITHOUT loosening
496
+ // the invariant: a lane can never be posted into by a non-owner without a human tap,
497
+ // except in bypass. Cross-ROOM post_to_session (dual-consent) is untouched below.
498
+ // Spec: docs/specs/2026-07-03-xpost-consent-scope.md.
490
499
  if (toolName === 'mcp__thinkpool__post_to_terminal') {
491
500
  const gate = crossPostGate ? crossPostGate() : { ok: true }
492
501
  if (!gate.ok) {
493
502
  return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: gate.reason || 'Cross-terminal post not allowed right now.' } }
494
503
  }
504
+ const spawnedByMe = didSpawnTarget ? !!didSpawnTarget(toolInput?.terminal) : false
505
+ if (!crossPostNeedsCard({ mode, spawnedByMe })) {
506
+ const why = mode === 'bypassPermissions'
507
+ ? 'Auto-approved (cross-terminal post in bypassPermissions mode — bypass means bypass).'
508
+ : 'Auto-approved (cross-terminal post into a lane you spawned — parent→child orchestration, no per-post tap).'
509
+ return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: why } }
510
+ }
495
511
  let decision = 'deny'
496
512
  try { decision = await requestPermission?.({ id: randomUUID(), toolName, input: toolInput, risk: 'high' }) ?? 'deny' } catch { decision = 'deny' }
497
513
  const allowed = decision === 'allow'
@@ -193,6 +193,24 @@ export const crossPostDecision = ({ hop = 0, postCount = 0, disabled = false } =
193
193
  return { ok: true }
194
194
  }
195
195
 
196
+ // ── Tier C consent SCOPE — does this post_to_terminal need a human card? ──────
197
+ // Runs AFTER crossPostDecision's kill-switch/hop/cap precheck passes, and decides
198
+ // only whether a person must TAP a card before the (already-permitted) post is
199
+ // delivered. Two — and only two — cases auto-allow (no card):
200
+ // (1) bypassPermissions mode — bypass means bypass, cross-lane posts included.
201
+ // Without this, a conductor's post into its OWN lane queued behind a human
202
+ // tap even in a bypass session (the 3-stacked-Dispatch-cards bug, 2026-07-03).
203
+ // (2) the SENDER SPAWNED the target lane (parent→child orchestration) — a
204
+ // conductor posting into a lane it Dispatched with spawn_terminal is not a
205
+ // stranger writing into someone else's work, so no per-post tap is needed.
206
+ // The CARD REMAINS for every other post: a non-owner posting into a lane it did
207
+ // not spawn, in any non-bypass mode. That is the load-bearing invariant — a lane
208
+ // can never be posted into by a non-owner without a human tap, except in bypass.
209
+ // Cross-ROOM post_to_session is a SEPARATE path (dual-consent) and is untouched.
210
+ // Spec: docs/specs/2026-07-03-xpost-consent-scope.md
211
+ export const crossPostNeedsCard = ({ mode = 'default', spawnedByMe = false } = {}) =>
212
+ !(mode === 'bypassPermissions' || spawnedByMe === true)
213
+
196
214
  // ── Tier C+: cross-terminal SPAWN (open a FRESH lane the agent owns) ──────────
197
215
  // Pure gate for whether a session may open a new terminal on THIS call. The
198
216
  // motivating bug: an agent dumped a fan-out into SIBLINGS that were already
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.151",
3
+ "version": "0.7.153",
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/sdk-smoke.mjs CHANGED
@@ -46,6 +46,7 @@ const version = ver()
46
46
  try {
47
47
  const sdk = await import('@anthropic-ai/claude-agent-sdk')
48
48
  if (typeof sdk.query !== 'function') { emit('FAIL', version, 'query is not a function'); clearTimeout(guard); process.exit(1) }
49
+ // eslint-disable-next-line require-yield -- deliberately empty: an async generator that ends immediately (the SDK's "no input" stream), never yields.
49
50
  async function* noInput() { return } // ends immediately — never blocks the construct
50
51
  const q = sdk.query({ prompt: noInput(), options: { permissionMode: 'default' } })
51
52
  const need = ['interrupt', 'setModel', 'setPermissionMode', 'supportedModels']