thinkpool-pair 0.7.152 → 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 +16 -0
- package/claude-session.mjs +22 -6
- package/cross-terminal.mjs +18 -0
- package/package.json +1 -1
package/bridge.mjs
CHANGED
|
@@ -1780,6 +1780,22 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
1780
1780
|
// Tier C precheck — the PreToolUse gate calls this BEFORE raising a card, so a
|
|
1781
1781
|
// disabled/looping/over-cap post never bothers a person. Closes over `entry`.
|
|
1782
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
|
+
},
|
|
1783
1799
|
// Tier 3 — the SENDER-side gate for post_to_session (the receipt card lives in
|
|
1784
1800
|
// the target room). roomHop/crossRoomPostCount reset on a real human turn (code-turn).
|
|
1785
1801
|
crossRoomPostGate: () => crossRoomPostDecision({ roomHop: entry.roomHop || 0, postCount: entry.crossRoomPostCount || 0, disabled: process.env.TP_PAIRBUS_OFF === '1' }),
|
package/claude-session.mjs
CHANGED
|
@@ -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
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
//
|
|
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'
|
package/cross-terminal.mjs
CHANGED
|
@@ -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
|