thinkpool-pair 0.7.109 → 0.7.111

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/account.mjs CHANGED
@@ -13,7 +13,7 @@ import path from 'node:path'
13
13
  import fs from 'node:fs'
14
14
  import { createClient } from '@supabase/supabase-js'
15
15
  import os from 'node:os'
16
- import { saveAuth, loadAuth, loadDirs, bindDir, loadServed, rememberServed } from './auth-store.mjs'
16
+ import { saveAuth, loadAuth, loadDirs, bindDir, loadServed, rememberServed, loadDefaultDir, saveDefaultDir, claudeRecentProjectDir } from './auth-store.mjs'
17
17
  import { isSafeToRestart } from './update-gate.mjs'
18
18
  import { makeThrottledTrack } from './presence.mjs'
19
19
  import { resolveServeDir } from './serve-dir.mjs'
@@ -226,7 +226,8 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
226
226
  const svcHint = process.env.THINKPOOL_PAIR_AUTOUPDATE === '1'
227
227
  ? ' (auto-updating background service — survives reboot, tracks @latest)\n'
228
228
  : ' (run hands-off + auto-updating: npx thinkpool-pair install-service)\n'
229
- process.stderr.write(`\n ◆ thinkpool-pair — account mode · ${email}\n Auto-serving your sessions from ${DEFAULT_DIR}\n (point one at another repo: npx thinkpool-pair bind <code> <dir>)\n${svcHint}`)
229
+ const _effDefault = loadDefaultDir() || claudeRecentProjectDir() || DEFAULT_DIR
230
+ process.stderr.write(`\n ◆ thinkpool-pair — account mode · ${email}\n New sessions auto-serve from ${_effDefault}${loadDefaultDir() ? ' (your default)' : (claudeRecentProjectDir() ? ' (your most-recent Claude Code project)' : '')}\n (change it: npx thinkpool-pair set-default-dir <dir> · one room: npx thinkpool-pair bind <code> <dir>)\n${svcHint}`)
230
231
 
231
232
  const children = new Map() // room -> child process
232
233
  const childIdle = new Map() // room -> bool (last idle report: in the quiet window)
@@ -255,6 +256,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
255
256
  let applyRequested = false // a user clicked "apply" in some room (Slice 3)
256
257
  let pendingUpdate = null // newest published version once the poll sees it
257
258
  const warned = new Set()
259
+ const refused = new Map() // room -> reason ('home'|'none'|'dir-gone'): owned but NOT served, announced in presence so the web shows a precise "attach this session" card instead of spinning
258
260
 
259
261
  // Account presence — so the web dashboard can show "● Bridge active" for this
260
262
  // account without per-room probing. We TRACK this machine (name + served room
@@ -262,6 +264,20 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
262
264
  const machine = os.hostname().replace(/\.local$/, '')
263
265
  const acct = sb.channel(`tpacct:${session.user.id}`, { config: { presence: { key: machine }, broadcast: { self: false } } })
264
266
  let restarting = false // set by the "restart" broadcast → stop() exits non-zero so a supervisor respawns us
267
+ // Web "attach this session" card → bind an unbound/new room to a local dir (and
268
+ // optionally set it as the account default) so it serves WITHOUT a terminal command.
269
+ // Rides tpacct (keyed to the owner's uid); the dir must exist on THIS machine.
270
+ acct.on('broadcast', { event: 'bind-room' }, ({ payload }) => {
271
+ const code = String(payload?.code || '').toUpperCase().trim()
272
+ let dir = String(payload?.dir || '').trim()
273
+ if (dir.startsWith('~')) dir = path.join(os.homedir(), dir.slice(1))
274
+ if (!code || !dir) return
275
+ if (!fs.existsSync(dir)) { process.stderr.write(`\n ◇ bind ${code}: directory not found (${dir}) — ignored.\n`); return }
276
+ bindDir(code, dir)
277
+ if (payload?.setDefault) { saveDefaultDir(dir); process.stderr.write(`\n ◆ default project dir set → ${dir}\n`) }
278
+ process.stderr.write(`\n ◆ ${code} bound → ${dir} (from the web). Serving…\n`)
279
+ try { tick() } catch { /* the periodic tick will serve it next cycle */ }
280
+ })
265
281
  // Dashboard "Disconnect" → broadcast shutdown → clean exit (presence leaves, bar flips live).
266
282
  // If we're the auto-restarting background service (launchd KeepAlive / systemd
267
283
  // Restart=always sets AUTOUPDATE=1), a bare exit would be respawned ~2s later —
@@ -324,7 +340,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
324
340
  // service (always-on, survives reboot) vs a foreground `npx` run that dies with its
325
341
  // terminal. THINKPOOL_PAIR_AUTOUPDATE=1 is set ONLY by the install-service plist/unit
326
342
  // (service.mjs), so it's the reliable proxy for "running as the managed service".
327
- const pushPresence = () => trackPresence({ name: machine, version: VERSION, rooms: [...children.keys()], service: process.env.THINKPOOL_PAIR_AUTOUPDATE === '1', ts: Date.now() })
343
+ const pushPresence = () => trackPresence({ name: machine, version: VERSION, rooms: [...children.keys()], refused: [...refused].map(([code, reason]) => ({ code, reason })), service: process.env.THINKPOOL_PAIR_AUTOUPDATE === '1', ts: Date.now() })
328
344
  // Re-track on EVERY (re)subscribe, not just the first: a realtime reconnect
329
345
  // (network blip, or a token swap mid-flight) rejoins the channel and must
330
346
  // re-announce, or the dashboard would read "no bridge" until the next restart.
@@ -491,18 +507,26 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
491
507
  // participant's bridge is duplication the UI never even looks at.
492
508
  const { data } = await sb.from('code_sessions').select('code,name,last_active_at,owner_id,participants').eq('owner_id', session.user.id).order('last_active_at', { ascending: false }).limit(20)
493
509
  rooms = data || []
510
+ // Prune refused entries for rooms we no longer own (only on a SUCCESSFUL fetch,
511
+ // so a transient query error never wipes the set).
512
+ const ownedCodes = new Set(rooms.map((r) => r.code))
513
+ for (const c of [...refused.keys()]) if (!ownedCodes.has(c)) refused.delete(c)
494
514
  } catch { /* transient — keep existing children, retry next tick */ }
495
515
  const dirs = loadDirs()
496
516
  const served = loadServed()
497
517
  for (const r of rooms) {
498
518
  const room = r.code
499
- if (!room || children.has(room)) continue
519
+ if (!room || children.has(room)) { if (room) refused.delete(room); continue } // served → not refused
500
520
  if (r.owner_id && r.owner_id !== session.user.id) continue // never serve a room we don't own (defense-in-depth behind the .eq above)
501
521
  // Resolve the serve dir: explicit bind > last-served memory > launch dir. REFUSE
502
522
  // to auto-serve an unbound room from $HOME — that strips the agent's resume context
503
523
  // (cwd-keyed SDK sessions) and shows your home folder as the repo (FVIHV1DE).
504
- const choice = resolveServeDir({ bound: dirs[room], remembered: served[room], defaultDir: DEFAULT_DIR, home: os.homedir() })
524
+ // Default-dir precedence for an unbound room: explicit user default > Claude Code's
525
+ // most-recent project dir (zero-config — where you actually code) > launch cwd ($HOME,
526
+ // which then gets refused). So a new session auto-attaches to your active repo with no setup.
527
+ const choice = resolveServeDir({ bound: dirs[room], remembered: served[room], defaultDir: loadDefaultDir() || claudeRecentProjectDir() || DEFAULT_DIR, home: os.homedir() })
505
528
  if (choice.refuse) {
529
+ refused.set(room, choice.reason)
506
530
  if (!warned.has(room)) {
507
531
  warned.add(room)
508
532
  const why = choice.reason === 'home'
@@ -514,10 +538,12 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
514
538
  }
515
539
  const dir = choice.dir
516
540
  if (!fs.existsSync(dir)) {
541
+ refused.set(room, 'dir-gone')
517
542
  if (!warned.has(room)) { warned.add(room); process.stderr.write(`\n ◇ ${room}: bound directory is gone (${dir}) — re-bind it.\n`) }
518
543
  continue
519
544
  }
520
545
  warned.delete(room)
546
+ refused.delete(room) // about to serve → clear any prior refusal
521
547
  // Learn this room → dir so a later relaunch from $HOME resumes it HERE (the real
522
548
  // repo) instead of the home-dir fallback. Only the auto path is remembered; an
523
549
  // explicit bind already persists in dirs.json.
package/auth-store.mjs CHANGED
@@ -58,3 +58,31 @@ export function rememberServed(room, dir) {
58
58
  try { fs.writeFileSync(SERVED, JSON.stringify(d, null, 2)) } catch { /* noop */ }
59
59
  return d
60
60
  }
61
+
62
+ // Account-wide DEFAULT serve directory: new/unbound rooms auto-serve HERE instead of
63
+ // being refused from $HOME. Set once (CLI `set-default-dir <dir>`, or the web attach
64
+ // card). One dir for the common "I mostly work in one repo" case; per-room binds still
65
+ // override it. Stored as a plain path in ~/.thinkpool-pair/default-dir.
66
+ const DEFAULTDIR = path.join(DIR, 'default-dir')
67
+ export function loadDefaultDir() { try { const d = fs.readFileSync(DEFAULTDIR, 'utf8').trim(); return d || null } catch { return null } }
68
+ export function saveDefaultDir(dir) { ensureDir(); try { fs.writeFileSync(DEFAULTDIR, String(dir || '')) } catch { /* noop */ } return dir }
69
+ export function clearDefaultDir() { try { fs.unlinkSync(DEFAULTDIR) } catch { /* noop */ } }
70
+
71
+ // Zero-config default: Claude Code tracks every project directory you've worked in
72
+ // (~/.claude.json `projects`, with per-project session history under
73
+ // ~/.claude/projects/<path-with-slashes-as-dashes>). The MOST-RECENTLY-used real project
74
+ // dir is where you actually code — a great default for a new/unbound room, no setup. Skips
75
+ // $HOME, tmp, and worktree paths; ranks by the session-dir mtime (encode path forward only,
76
+ // never decode — the dash encoding is lossy for paths containing '-').
77
+ export function claudeRecentProjectDir() {
78
+ try {
79
+ const home = os.homedir()
80
+ const conf = JSON.parse(fs.readFileSync(path.join(home, '.claude.json'), 'utf8'))
81
+ const projRoot = path.join(home, '.claude', 'projects')
82
+ const best = Object.keys(conf.projects || {})
83
+ .filter((p) => p && p !== home && !p.startsWith('/private/tmp') && !p.startsWith('/tmp') && !p.includes('/.claude/') && fs.existsSync(p))
84
+ .map((p) => { let mt = 0; try { mt = fs.statSync(path.join(projRoot, p.replace(/\//g, '-'))).mtimeMs } catch { /* no session dir */ } return { p, mt } })
85
+ .sort((a, b) => b.mt - a.mt)[0]
86
+ return best?.p || null
87
+ } catch { return null }
88
+ }
package/bridge.mjs CHANGED
@@ -202,6 +202,14 @@ const PROVIDER_INFO = applyProviderEnv()
202
202
 
203
203
  if (argv[0] === 'login') { const { runLogin } = await import('./account.mjs'); await runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) }
204
204
  if (argv[0] === 'bind') { const { runBind } = await import('./account.mjs'); runBind(argv[1], argv[2]) }
205
+ if (argv[0] === 'set-default-dir') {
206
+ const { saveDefaultDir } = await import('./auth-store.mjs')
207
+ let d = (argv[1] || '').trim(); if (d.startsWith('~')) d = d.replace(/^~/, os.homedir())
208
+ if (!d) { console.error(' usage: npx thinkpool-pair set-default-dir <dir>'); process.exit(1) }
209
+ saveDefaultDir(d)
210
+ console.log(` ◆ default project dir set → ${d}\n New/unbound sessions auto-serve from here — no per-session bind. Takes effect next serve tick.`)
211
+ process.exit(0)
212
+ }
205
213
  if (argv[0] === 'provider') { const { runProvider } = await import('./provider.mjs'); await runProvider(argv.slice(1)); process.exit(0) }
206
214
 
207
215
  // One-command launcher: a bare invocation on a TTY (or the `setup` alias) opens the
@@ -0,0 +1,302 @@
1
+ // Thinkpool Flow — S5 adversarial reviewer tool-call gate.
2
+ //
3
+ // Pure decision function: given a reviewer lane's tool call, decide whether to allow or
4
+ // deny it. No fs side-effects except realpathSync (injectable via _realpath for tests).
5
+ //
6
+ // Spec: docs/specs/2026-06-30-flow-build-s5-reviewer-writeblock.md (S5, slice 1).
7
+ //
8
+ // Rules:
9
+ // READ-ONLY tools (Read, Grep, Glob, LS, …) → ALLOW (always)
10
+ // Edit / Write / NotebookEdit → structural path check against sliceRoot:
11
+ // • file_path resolves under sliceRoot AND is NOT verdictFile → DENY
12
+ // • file_path IS verdictFile (FLOW_REVIEW.json) → ALLOW (unless it symlinks into slice)
13
+ // Bash → ALLOW by default (reviewer MUST run tests); DENY on write-to-slice heuristics
14
+ // Unknown tools → DENY (conservative; add to READ_ONLY_TOOLS if truly non-mutating)
15
+ //
16
+ // Path-robustness:
17
+ // • '..' traversal: caught by path.resolve() normalization.
18
+ // • Absolute-escape: caught by path.resolve() normalization.
19
+ // • Symlink escape: caught by realpathSync() (injectable for tests). A write whose REAL
20
+ // resolved path lands under sliceRoot is denied even through a symlink.
21
+ // • Symlinked verdictFile pointing into the slice is denied.
22
+ // • Both canonical (realpathSync) and input (path.resolve) forms of sliceRoot are checked
23
+ // to handle cases where sliceRoot itself is a symlink (e.g. macOS /tmp → /private/tmp).
24
+ //
25
+ // BASH LIMITATION: Bash write-detection is HEURISTIC (best-effort pattern matching), unlike
26
+ // Edit/Write/NotebookEdit which have a structural file_path field. Shell quoting, variable
27
+ // expansion, heredocs, subshells, and aliases can bypass these patterns. The reviewer prompt
28
+ // already instructs it not to modify the slice; this gate is a structural backstop for the
29
+ // common cases, not a complete shell parser. If precise Bash blocking is required, use a
30
+ // wrapper that translates shell commands into structured tool calls.
31
+ //
32
+ // ACCEPTED LIMITATIONS (do not add fragile parsers for these):
33
+ // • python3 -c '...' / node -e '...' inline scripts: scripted writes embedded in string
34
+ // literals are not parsed — detecting them reliably requires a full language parser.
35
+ // • bare `git commit` / `git add .` with no explicit slice path: the gate detects git
36
+ // mutations only when sliceRoot appears literally in the command (via -C flag or path
37
+ // arg). A bare git command that relies on cwd is NOT caught by this heuristic.
38
+ // REAL MITIGATION: the reviewer lane runs in its OWN git worktree (not the slice's).
39
+ // Bare cwd-relative git mutations and inline scripted writes therefore affect the
40
+ // reviewer's worktree, not the slice. The wiring layer that provisions the reviewer lane
41
+ // MUST guarantee this worktree isolation — this gate cannot substitute for it.
42
+
43
+ import { realpathSync as nodeRealpathSync } from 'node:fs'
44
+ import path from 'node:path'
45
+
46
+ // ── Tool classification ───────────────────────────────────────────────────────
47
+
48
+ // Tools that cannot mutate files — always allowed for a reviewer.
49
+ const READ_ONLY_TOOLS = new Set([
50
+ 'Read', 'Grep', 'Glob', 'LS',
51
+ // Other non-mutating Claude Code tools:
52
+ 'WebFetch', 'WebSearch', 'Search', 'TodoRead', 'Explore',
53
+ ])
54
+
55
+ // Tools that write files — must have file_path checked before allowing.
56
+ const FILE_WRITE_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
57
+
58
+ // Git subcommands that mutate the working tree.
59
+ const GIT_MUTATING_RE = /\b(?:add|commit|apply|checkout|reset|revert|rm|clean|merge|rebase|cherry-pick|stash)\b/
60
+
61
+ // Shell commands that write to a path positional argument (HEURISTIC — see BASH LIMITATION).
62
+ // cp and mv are handled separately via CP_MV_RE — only their DESTINATION is checked,
63
+ // so that reading FROM the slice (e.g. cp /slice/f /tmp/x) is allowed.
64
+ const BASH_WRITE_CMD_RE = /\b(?:rm|tee|truncate|dd|install)\b/
65
+
66
+ // cp/mv require destination-aware analysis — see _assessBashCommand.
67
+ const CP_MV_RE = /\b(?:cp|mv)\b/
68
+
69
+ // sed in-place edit.
70
+ const SED_INPLACE_RE = /\bsed\b[^|;]*-i\b/
71
+
72
+ // ── reviewGateDecision ────────────────────────────────────────────────────────
73
+
74
+ /**
75
+ * Decide whether a reviewer lane's tool call is allowed.
76
+ *
77
+ * @param {object} opts
78
+ * @param {string} opts.toolName Tool name ('Read', 'Write', 'Bash', …).
79
+ * @param {object} [opts.input] Tool input (e.g. { file_path } or { command }).
80
+ * @param {string} opts.sliceRoot Absolute path of the directory tree under review.
81
+ * @param {string} [opts.verdictFile] Absolute path where reviewer writes FLOW_REVIEW.json.
82
+ * Writes to this exact path are ALLOWED unless it
83
+ * symlinks back into sliceRoot.
84
+ * @param {function}[opts._realpath] Injectable realpathSync (default: node:fs realpathSync).
85
+ * @returns {{ allow: boolean, reason: string }}
86
+ */
87
+ export function reviewGateDecision ({
88
+ toolName,
89
+ input = {},
90
+ sliceRoot,
91
+ verdictFile,
92
+ _realpath = nodeRealpathSync,
93
+ }) {
94
+ if (!sliceRoot) {
95
+ return { allow: false, reason: 'sliceRoot not provided — gate cannot assess containment; denied' }
96
+ }
97
+
98
+ // Resolve both forms of sliceRoot so comparisons work whether or not sliceRoot is
99
+ // itself a symlink (e.g. macOS /tmp → /private/tmp).
100
+ const inputSliceRoot = path.resolve(sliceRoot)
101
+ let canonicalSliceRoot
102
+ try {
103
+ canonicalSliceRoot = _realpath(sliceRoot)
104
+ } catch {
105
+ canonicalSliceRoot = inputSliceRoot
106
+ }
107
+
108
+ // ── READ-ONLY TOOLS ───────────────────────────────────────────────────
109
+ if (READ_ONLY_TOOLS.has(toolName)) {
110
+ return { allow: true, reason: `${toolName} is a read-only tool — allowed` }
111
+ }
112
+
113
+ // ── FILE WRITE TOOLS ──────────────────────────────────────────────────
114
+ if (FILE_WRITE_TOOLS.has(toolName)) {
115
+ const filePath = input.file_path
116
+ if (!filePath) {
117
+ return {
118
+ allow: false,
119
+ reason: `${toolName} has no file_path — cannot assess containment; denied conservatively`,
120
+ }
121
+ }
122
+ return _assessFilePath({
123
+ filePath, toolName,
124
+ inputSliceRoot, canonicalSliceRoot,
125
+ verdictFile, _realpath,
126
+ })
127
+ }
128
+
129
+ // ── BASH ──────────────────────────────────────────────────────────────
130
+ if (toolName === 'Bash') {
131
+ const cmd = typeof input.command === 'string' ? input.command : ''
132
+ return _assessBashCommand({ cmd, inputSliceRoot, canonicalSliceRoot })
133
+ }
134
+
135
+ // ── UNKNOWN TOOLS ─────────────────────────────────────────────────────
136
+ // Conservative default: deny unknown tools — they may be mutating.
137
+ // To allow a new read-only tool, add it to READ_ONLY_TOOLS above.
138
+ return {
139
+ allow: false,
140
+ reason: `Unknown tool "${toolName}" — denied by default (not in READ_ONLY_TOOLS; add if truly read-only)`,
141
+ }
142
+ }
143
+
144
+ // ── _assessFilePath ───────────────────────────────────────────────────────────
145
+
146
+ function _assessFilePath ({
147
+ filePath, toolName,
148
+ inputSliceRoot, canonicalSliceRoot,
149
+ verdictFile, _realpath,
150
+ }) {
151
+ const resolved = path.resolve(filePath)
152
+
153
+ // Is this a write to the verdictFile (FLOW_REVIEW.json)?
154
+ if (verdictFile) {
155
+ const resolvedVerdict = path.resolve(verdictFile)
156
+ if (resolved === resolvedVerdict) {
157
+ // B4 fix: check resolved path FIRST — before any symlink/realpath logic.
158
+ // If verdictFile's resolved path is inside sliceRoot, deny regardless of whether
159
+ // the file exists yet. The old ENOENT catch path ("no symlink possible; allow")
160
+ // was the hole: a non-existent verdictFile inside sliceRoot slipped through.
161
+ if (_isUnder(resolvedVerdict, canonicalSliceRoot) || _isUnder(resolvedVerdict, inputSliceRoot)) {
162
+ return {
163
+ allow: false,
164
+ reason: `verdictFile "${verdictFile}" resolves inside sliceRoot — denied`,
165
+ }
166
+ }
167
+ // Secondary check: deny if an existing verdictFile is a symlink INTO the slice.
168
+ // (Handles indirect containment via symlinks that point inside sliceRoot.)
169
+ try {
170
+ const realVerdict = _realpath(verdictFile)
171
+ if (_isUnder(realVerdict, canonicalSliceRoot) || _isUnder(realVerdict, inputSliceRoot)) {
172
+ return {
173
+ allow: false,
174
+ reason: `verdictFile "${verdictFile}" is a symlink resolving to "${realVerdict}" inside sliceRoot — denied`,
175
+ }
176
+ }
177
+ } catch {
178
+ // verdictFile doesn't exist yet — no symlink possible; allow.
179
+ }
180
+ return { allow: true, reason: `write to verdictFile (${path.basename(verdictFile)}) — allowed` }
181
+ }
182
+ }
183
+
184
+ // Not the verdictFile. Check whether the write target lands inside the slice.
185
+
186
+ // 1. String-level check — catches '..' traversal and absolute paths into sliceRoot.
187
+ if (_isUnder(resolved, canonicalSliceRoot) || _isUnder(resolved, inputSliceRoot)) {
188
+ return {
189
+ allow: false,
190
+ reason: `${toolName} to "${filePath}" (resolved: "${resolved}") is inside sliceRoot — denied`,
191
+ }
192
+ }
193
+
194
+ // 2. Realpath check — catches symlinks whose TARGET is inside sliceRoot.
195
+ try {
196
+ const real = _realpath(filePath)
197
+ if (_isUnder(real, canonicalSliceRoot) || _isUnder(real, inputSliceRoot)) {
198
+ return {
199
+ allow: false,
200
+ reason: `${toolName} to "${filePath}" symlinks to "${real}" inside sliceRoot — denied`,
201
+ }
202
+ }
203
+ } catch {
204
+ // ENOENT or other — step 1 already handled the in-slice case.
205
+ }
206
+
207
+ return { allow: true, reason: `${toolName} to "${filePath}" is outside sliceRoot — allowed` }
208
+ }
209
+
210
+ // ── _assessBashCommand ────────────────────────────────────────────────────────
211
+ // HEURISTIC — see BASH LIMITATION comment at top of file.
212
+
213
+ function _assessBashCommand ({ cmd, inputSliceRoot, canonicalSliceRoot }) {
214
+ // Returns true if `str` (a path extracted from the command) resolves under sliceRoot.
215
+ function inSlice (str) {
216
+ if (!str) return false
217
+ const abs = path.isAbsolute(str) ? str : path.resolve(str)
218
+ return _isUnder(abs, canonicalSliceRoot) || _isUnder(abs, inputSliceRoot)
219
+ }
220
+
221
+ // Returns true if either form of sliceRoot appears literally in the command string.
222
+ // Used for commands that embed the target path as a positional argument.
223
+ function cmdContainsSlice () {
224
+ return cmd.includes(canonicalSliceRoot) || cmd.includes(inputSliceRoot)
225
+ }
226
+
227
+ // ── Output redirection (> or >>) into sliceRoot ───────────────────────
228
+ // HEURISTIC: misses paths with spaces and shell variable expansion.
229
+ // Negative lookbehind excludes '<' so '<<' (heredoc) and '<>' (r/w) are not
230
+ // misread as write redirections.
231
+ // Group 1: optional surrounding quote char; group 2: the path (stripped of quotes).
232
+ // This catches both `> /slice/f` (unquoted) and `> "/slice/f"` / `> '/slice/f'` (D1/D2).
233
+ const redirRe = /(?<![<])>+\s*(['"]?)([^\s;|&'"]+)\1/g
234
+ let m
235
+ while ((m = redirRe.exec(cmd)) !== null) {
236
+ if (inSlice(m[2])) {
237
+ return {
238
+ allow: false,
239
+ reason: `Bash output redirection to "${m[2]}" targets sliceRoot — denied`,
240
+ }
241
+ }
242
+ }
243
+
244
+ // ── git mutating commands targeting sliceRoot ─────────────────────────
245
+ if (/\bgit\b/.test(cmd) && GIT_MUTATING_RE.test(cmd)) {
246
+ // Explicit -C flag: `git -C <dir> <verb>`
247
+ const cwdMatch = cmd.match(/-C\s+(['"]?)([^\s'"]+)\1/)
248
+ if (cwdMatch) {
249
+ const cwdArg = cwdMatch[2]
250
+ if (inSlice(cwdArg)) {
251
+ return {
252
+ allow: false,
253
+ reason: `Bash "git -C ${cwdArg} ..." targets sliceRoot — denied`,
254
+ }
255
+ }
256
+ }
257
+ // Fallback: sliceRoot path appears anywhere in a mutating git command.
258
+ if (cmdContainsSlice()) {
259
+ return { allow: false, reason: 'Bash git mutating command references sliceRoot — denied' }
260
+ }
261
+ }
262
+
263
+ // ── cp/mv: only deny when DESTINATION (last positional arg) is inside sliceRoot ──
264
+ // C5 fix: the old approach (cmdContainsSlice) wrongly denied `cp /slice/f /tmp/x`
265
+ // because sliceRoot appeared as the SOURCE. Only the destination can cause a write.
266
+ if (CP_MV_RE.test(cmd)) {
267
+ const tokens = cmd.trim().split(/\s+/)
268
+ // Skip the verb (i=0) and option flags; remaining tokens are positional path args.
269
+ const pathArgs = tokens.filter((t, i) => i > 0 && !t.startsWith('-'))
270
+ const dest = pathArgs[pathArgs.length - 1]
271
+ if (dest && inSlice(dest)) {
272
+ const verb = (cmd.match(CP_MV_RE) || [''])[0]
273
+ return {
274
+ allow: false,
275
+ reason: `Bash "${verb}" destination "${dest}" targets sliceRoot — denied`,
276
+ }
277
+ }
278
+ }
279
+
280
+ // ── rm / tee / truncate / dd / install targeting sliceRoot ──
281
+ if (BASH_WRITE_CMD_RE.test(cmd) && cmdContainsSlice()) {
282
+ const verb = (cmd.match(BASH_WRITE_CMD_RE) || [''])[0]
283
+ return {
284
+ allow: false,
285
+ reason: `Bash write command "${verb}" references sliceRoot — denied`,
286
+ }
287
+ }
288
+
289
+ // ── sed -i targeting sliceRoot ────────────────────────────────────────
290
+ if (SED_INPLACE_RE.test(cmd) && cmdContainsSlice()) {
291
+ return { allow: false, reason: 'Bash "sed -i" references sliceRoot — denied' }
292
+ }
293
+
294
+ return { allow: true, reason: 'Bash command allowed (no write-to-slice pattern detected)' }
295
+ }
296
+
297
+ // ── helpers ───────────────────────────────────────────────────────────────────
298
+
299
+ // Returns true if `filePath` equals `dir` OR is strictly inside it.
300
+ function _isUnder (filePath, dir) {
301
+ return filePath === dir || filePath.startsWith(dir + path.sep)
302
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.109",
3
+ "version": "0.7.111",
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": {
@@ -21,6 +21,7 @@
21
21
  "flow-task-graph.mjs",
22
22
  "flow-preview.mjs",
23
23
  "flow-review.mjs",
24
+ "flow-review-gate.mjs",
24
25
  "flow-assembly.mjs",
25
26
  "flow-budget.mjs",
26
27
  "serve-dir.mjs",
@@ -50,6 +50,7 @@ const ANTHROPIC_SERVER_TOOLS = new Set([
50
50
 
51
51
  const STU_MARKER = '"server_tool_use"'
52
52
  const TR_MARKER = '"tool_result"'
53
+ const TU_MARKER = '"tool_use"' // matches "type":"tool_use" but NOT "server_tool_use" or "tool_use_id"
53
54
 
54
55
  /**
55
56
  * Resolve a session's transcript path the same way Claude Code names it:
@@ -105,9 +106,107 @@ function fixToolResultInAssistant(content) {
105
106
  return { removed: before - next.length, content: next }
106
107
  }
107
108
 
109
+ /* ─────────────────────────────────────────────────────────────
110
+ THIRD HEAL — dangling tool_use (S4 clean-redispatch, 2026-06-30):
111
+ a lane force-killed mid-tool-call leaves an assistant message with a
112
+ tool_use block that has NO matching tool_result anywhere in the
113
+ transcript. On the next SDK replay Anthropic 400s:
114
+ "tool_use ids were found without tool_result blocks"
115
+ and the lane cannot resume.
116
+
117
+ THE FIX: cross-row scan — collect every tool_use id and every
118
+ tool_result tool_use_id across the whole transcript; any tool_use id
119
+ with no matching tool_result is "dangling". For each assistant message
120
+ that holds dangling calls, inject a synthetic user message immediately
121
+ after it carrying one tool_result per dangling id:
122
+ { type:'tool_result', tool_use_id:<id>, content:'[interrupted]', is_error:true }
123
+ This is the minimal valid placeholder that unblocks the next turn.
124
+
125
+ Idempotent: a tool_use already paired with a tool_result is untouched.
126
+ A second pass over the same (now-healed) transcript is a no-op because
127
+ the synthetic results are part of the file and pair the calls.
128
+ ───────────────────────────────────────────────────────────── */
129
+
130
+ // Return all tool_use ids from a parsed row (checks .message.content AND top-level .content).
131
+ function getToolUseIds(obj) {
132
+ const ids = []
133
+ const scan = (content) => {
134
+ if (!Array.isArray(content)) return
135
+ for (const b of content) { if (b && b.type === 'tool_use' && b.id) ids.push(b.id) }
136
+ }
137
+ if (obj.message) scan(obj.message.content)
138
+ scan(obj.content)
139
+ return ids
140
+ }
141
+
142
+ // Collect all tool_result tool_use_ids from a parsed row into a Set.
143
+ function collectToolResultIds(obj, into) {
144
+ const scan = (content) => {
145
+ if (!Array.isArray(content)) return
146
+ for (const b of content) { if (b && b.type === 'tool_result' && b.tool_use_id) into.add(b.tool_use_id) }
147
+ }
148
+ if (obj.message) scan(obj.message.content)
149
+ scan(obj.content)
150
+ }
151
+
152
+ // Return the role of a parsed row ('assistant' | 'user' | null).
153
+ function getRole(obj) {
154
+ if (!obj) return null
155
+ if (obj.message && obj.message.role) return obj.message.role
156
+ if (obj.type === 'assistant') return 'assistant'
157
+ if (obj.type === 'user') return 'user'
158
+ return null
159
+ }
160
+
161
+ /**
162
+ * Heal 3: scan parsed objects for dangling tool_use ids; insert synthetic
163
+ * user rows immediately after the assistant rows that own them.
164
+ * @param {string[]} rows — string rows (already updated by Heals 1+2)
165
+ * @param {(object|null)[]} parsed — objects (mutated in-place by Heals 1+2)
166
+ * @returns {{ rows: string[], injected: number }}
167
+ */
168
+ function fixDanglingToolUse(rows, parsed) {
169
+ // Full-transcript scan: collect every tool_result tool_use_id (= paired ids).
170
+ const pairedIds = new Set()
171
+ for (const obj of parsed) {
172
+ if (obj) collectToolResultIds(obj, pairedIds)
173
+ }
174
+
175
+ let injected = 0
176
+ const out = []
177
+ for (let i = 0; i < rows.length; i++) {
178
+ out.push(rows[i])
179
+ const obj = parsed[i]
180
+ if (!obj || getRole(obj) !== 'assistant') continue
181
+
182
+ const dangling = getToolUseIds(obj).filter(id => !pairedIds.has(id))
183
+ if (!dangling.length) continue
184
+
185
+ // Inject synthetic user row with one tool_result per dangling call.
186
+ const synRow = {
187
+ type: 'user',
188
+ message: {
189
+ role: 'user',
190
+ content: dangling.map(id => ({
191
+ type: 'tool_result',
192
+ tool_use_id: id,
193
+ content: '[interrupted]',
194
+ is_error: true,
195
+ })),
196
+ },
197
+ }
198
+ out.push(JSON.stringify(synRow))
199
+ // Mark injected ids as paired so a second pass treats them as clean (idempotency).
200
+ for (const id of dangling) pairedIds.add(id)
201
+ injected += dangling.length
202
+ }
203
+
204
+ return { rows: out, injected }
205
+ }
206
+
108
207
  /**
109
208
  * Sanitize one transcript file in place. Returns { lines, blocks } counting the
110
- * transcript lines rewritten and the individual blocks flipped. A missing,
209
+ * transcript lines rewritten and the individual blocks healed/injected. A missing,
111
210
  * unreadable, or already-clean file is a silent no-op ({ lines: 0, blocks: 0 }).
112
211
  */
113
212
  export function sanitizeTranscript(file) {
@@ -119,23 +218,29 @@ export function sanitizeTranscript(file) {
119
218
  }
120
219
  const fileHasStu = raw.includes(STU_MARKER)
121
220
  const fileHasTr = raw.includes(TR_MARKER)
122
- if (!fileHasStu && !fileHasTr) return { lines: 0, blocks: 0 } // fast path: nothing illegal possible
221
+ const fileHasTu = raw.includes(TU_MARKER)
222
+ if (!fileHasStu && !fileHasTr && !fileHasTu) return { lines: 0, blocks: 0 } // fast path: nothing illegal possible
123
223
 
124
224
  const rows = raw.split('\n')
125
225
  let lines = 0
126
226
  let blocks = 0
227
+
228
+ // Pre-parse ALL rows so Heal 3 can do cross-row analysis.
229
+ // Heals 1+2 mutate these objects in-place, so parsed[i] always reflects the
230
+ // post-heal state when Heal 3 runs. Unparseable lines → null (left byte-identical).
231
+ const parsed = rows.map(row => {
232
+ if (!row) return null
233
+ try { return JSON.parse(row) } catch { return null }
234
+ })
235
+
127
236
  for (let i = 0; i < rows.length; i++) {
128
237
  const row = rows[i]
129
238
  if (!row) continue
130
239
  const rowHasStu = row.includes(STU_MARKER)
131
240
  const rowHasTr = row.includes(TR_MARKER)
132
241
  if (!rowHasStu && !rowHasTr) continue
133
- let obj
134
- try {
135
- obj = JSON.parse(row)
136
- } catch {
137
- continue // never touch a line we can't parse — leave it byte-identical
138
- }
242
+ const obj = parsed[i]
243
+ if (!obj) continue // unparseable — already null, leave byte-identical
139
244
  let n = 0
140
245
  // Pass 1 — foreign server_tool_use → tool_use (any message).
141
246
  if (rowHasStu) {
@@ -160,11 +265,30 @@ export function sanitizeTranscript(file) {
160
265
  }
161
266
  }
162
267
 
163
- if (!blocks) return { lines: 0, blocks: 0 } // every server_tool_use was already a legal Anthropic one
268
+ // Heal 3 — dangling tool_use: cross-row scan + synthetic row insertion.
269
+ // Runs after Heals 1+2 so parsed objects already reflect those fixes.
270
+ //
271
+ // Gate: run whenever the fast-path was NOT taken — i.e., always here.
272
+ // The old `if (fileHasTu)` guard was wrong for a heal that depends on Heal 1's
273
+ // output: Heal 1 can create NEW tool_use blocks by flipping server_tool_use, but
274
+ // the raw `"server_tool_use"` string does NOT contain the `"tool_use"` substring
275
+ // (the `server_` prefix + underscore break the literal match), so fileHasTu=false
276
+ // and Heal 3 was skipped — leaving the now-dangling call unhealed (bug C9).
277
+ // The fast-path early-return above is the real no-op gate for truly-clean files.
278
+ let outRows = rows
279
+ {
280
+ const h3 = fixDanglingToolUse(rows, parsed)
281
+ if (h3.injected) {
282
+ outRows = h3.rows
283
+ blocks += h3.injected
284
+ }
285
+ }
286
+
287
+ if (!blocks) return { lines: 0, blocks: 0 } // nothing was changed by any heal
164
288
 
165
289
  // Atomic write — a crash mid-write must never leave a truncated transcript.
166
290
  const tmp = `${file}.tp-sanitize.tmp`
167
- fs.writeFileSync(tmp, rows.join('\n'))
291
+ fs.writeFileSync(tmp, outRows.join('\n'))
168
292
  fs.renameSync(tmp, file)
169
293
  return { lines, blocks }
170
294
  }