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 +37 -2
- package/claude-session.mjs +22 -6
- package/cross-terminal.mjs +18 -0
- package/package.json +1 -1
- package/sdk-smoke.mjs +1 -0
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
|
-
|
|
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) {
|
|
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' }),
|
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
|
package/package.json
CHANGED
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']
|