thinkpool-pair 0.6.0 → 0.6.3

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
@@ -124,6 +124,10 @@ const headless = argv.includes('--headless')
124
124
  // relay. Default OFF — the PTY path is untouched. Only applies to `claude`.
125
125
  // Spec: docs/specs/2026-06-11-code-structured-reader.md
126
126
  const STRUCTURED = process.env.TP_STRUCTURED === '1' || argv.includes('--structured')
127
+ // Claude Code runs as a STRUCTURED Agent-SDK session by default now (the picked
128
+ // design — structured reader, risk-tiered permissions). Other CLIs keep the PTY
129
+ // relay. TP_PTY=1 forces claude back to the raw PTY if ever needed.
130
+ const wantStructured = (cmd) => /(^|[/\\])claude$/.test(cmd || '') && process.env.TP_PTY !== '1'
127
131
  const installedAgents = KNOWN_AGENTS.filter(a => onPath(a.cmd))
128
132
  const dashIdx = argv.indexOf('--')
129
133
  // Own flags are only read from BEFORE `--`; after it, every token belongs
@@ -383,6 +387,11 @@ channel
383
387
  const agent = KNOWN_AGENTS.find(a => a.cmd === payload.cmd)
384
388
  if (agent?.resume) { try { if (agent.resume.probe()) args = [...agent.resume.args] } catch { /* fresh */ } }
385
389
  }
390
+ if (wantStructured(payload.cmd)) {
391
+ openStructured({ id: payload.id })
392
+ process.stderr.write(`\n ◆ web opened a structured "${payload.cmd}" session.\n`)
393
+ return
394
+ }
386
395
  openTerm({ id: payload.id, cmd: payload.cmd, args })
387
396
  process.stderr.write(`\n ◆ web opened a "${payload.cmd}"${args.length ? ' (continue)' : ''} terminal (headless).\n`)
388
397
  })
@@ -435,7 +444,17 @@ channel
435
444
  })
436
445
  .on('broadcast', { event: 'code-turn' }, ({ payload }) => {
437
446
  const s = payload?.term && sessions.get(payload.term)
438
- if (s && payload.text != null) s.session.sendTurn(payload.text)
447
+ if (s && payload.text != null) {
448
+ s.session.sendTurn(payload.text)
449
+ // Echo the turn so BOTH readers (and late joiners, via the log) show who
450
+ // said what — UNLESS silent (an @pool synthesis, which renders as its own
451
+ // 'pool' line). The sender rendered it optimistically; partner gets this.
452
+ if (!payload.silent) {
453
+ const evt = { kind: 'you', text: payload.text, cid: payload.cid, by: payload.by }
454
+ s.log.push(evt); if (s.log.length > STRUCTURED_LOG_MAX) s.log.shift()
455
+ bcast('code-event', { term: payload.term, evt })
456
+ }
457
+ }
439
458
  })
440
459
  .on('broadcast', { event: 'code-perm' }, ({ payload }) => {
441
460
  const s = payload?.term && sessions.get(payload.term)
@@ -446,6 +465,10 @@ channel
446
465
  const s = payload?.term && sessions.get(payload.term)
447
466
  if (s) s.session.abort()
448
467
  })
468
+ .on('broadcast', { event: 'code-mode' }, ({ payload }) => {
469
+ const s = payload?.term && sessions.get(payload.term)
470
+ if (s) s.session.setMode(payload.mode)
471
+ })
449
472
  .on('broadcast', { event: 'code-close' }, ({ payload }) => {
450
473
  const s = payload?.id && sessions.get(payload.id)
451
474
  if (s) { try { s.session.end() } catch { /* noop */ } ; sessions.delete(payload.id); bcast('term-exit', { id: payload.id }); announce() }
@@ -456,7 +479,7 @@ channel
456
479
  channel.track({ name, role: 'bridge' })
457
480
  if (attachedCmd && !terms.size && !sessions.size) {
458
481
  // Claude + structured mode → Agent SDK session; everything else → PTY.
459
- if (STRUCTURED && /(^|[/\\])claude$/.test(attachedCmd)) openStructured({ id: randomUUID() })
482
+ if (wantStructured(attachedCmd)) openStructured({ id: randomUUID() })
460
483
  else openTerm({ id: randomUUID(), cmd: attachedCmd, args: attachedArgs, attached: true })
461
484
  }
462
485
  announce()
@@ -18,7 +18,10 @@ import { query } from '@anthropic-ai/claude-agent-sdk'
18
18
  // ── risk classification — the accent/danger tier of the permission card ──
19
19
  // low (read-only) · medium (writes/runs) · network (leaves the machine) ·
20
20
  // high (destructive, deny-first). See the permission spec + mockups.
21
- const DESTRUCTIVE = /\brm\s+-[a-z]*[rf]|\bgit\s+(push\s+(-f|--force)|reset\s+--hard|clean\s+-[a-z]*f)|\bdrop\s+(table|database)\b|\b(mkfs|dd)\b|\bsudo\b|>\s*\/dev\/|\bchmod\s+-R|\bchown\s+-R|\bkillall\b|\btruncate\b/i
21
+ // Any `rm`/`rmdir` with an argument is destructive (a bare `rm NOTES.md`
22
+ // deletes just as permanently as `rm -rf`). Plus force-push, hard reset,
23
+ // clean -f, DROP, mkfs/dd, sudo, /dev redirects, recursive chmod/chown, etc.
24
+ const DESTRUCTIVE = /\brm\s+\S|\brmdir\s+\S|\bgit\s+(push\s+(-f|--force)|reset\s+--hard|clean\s+-[a-z]*f)|\bdrop\s+(table|database)\b|\b(mkfs|dd)\b|\bsudo\b|>\s*\/dev\/|\bchmod\s+-R|\bchown\s+-R|\bkillall\b|\btruncate\b/i
22
25
  const READONLY_TOOLS = new Set(['Read', 'Grep', 'Glob', 'NotebookRead', 'TodoRead', 'LS'])
23
26
  const NETWORK_TOOLS = new Set(['WebFetch', 'WebSearch'])
24
27
  const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'TodoWrite'])
@@ -78,30 +81,48 @@ const simplifyBlocks = (blocks = []) => blocks.map((b) => {
78
81
  * resolve 'allow'/'deny'. (Caller implements any auto-allow policy.)
79
82
  * @returns {{ sendTurn(text), abort(), end(), readonly sessionId }}
80
83
  */
84
+ const MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
85
+
81
86
  export function startClaudeSession({ cwd, model, resume, onEvent, requestPermission }) {
82
87
  const ac = new AbortController()
83
88
  const input = makeInputStream()
84
89
  let sessionId = resume || null
85
90
  let closed = false
91
+ let q = null // the live Query — control requests (interrupt /
92
+ // setPermissionMode) route through it once streaming.
93
+ let mode = 'default' // mirrors Claude Code's ⇧⇥ cycle
86
94
 
87
95
  const emit = (evt) => { try { onEvent?.(evt) } catch { /* never let a consumer throw into the loop */ } }
88
96
 
89
- // PreToolUse — fires on EVERY tool call (the universal gate). Classify
90
- // risk, round-trip the decision to the room, return allow/deny.
97
+ // PreToolUse — fires on EVERY tool call (the universal gate). The mode policy
98
+ // mirrors Claude Code exactly: reads never prompt (any mode); Auto-accept
99
+ // edits auto-approves non-destructive writes; Bash / network / destructive
100
+ // always round-trip to the room's risk-tiered permission card.
91
101
  const preTool = async (hookInput) => {
92
102
  const toolName = hookInput.tool_name
93
103
  const toolInput = hookInput.tool_input
94
104
  const risk = classifyRisk(toolName, toolInput)
105
+ const auto =
106
+ risk === 'low' ||
107
+ mode === 'bypassPermissions' ||
108
+ (mode === 'acceptEdits' && WRITE_TOOLS.has(toolName) && risk !== 'high')
95
109
  let decision = 'allow'
96
- try {
97
- decision = await requestPermission?.({ id: randomUUID(), toolName, input: toolInput, risk }) ?? 'allow'
98
- } catch { decision = 'deny' } // a broken permission path must fail safe (deny)
110
+ if (!auto) {
111
+ try {
112
+ decision = await requestPermission?.({ id: randomUUID(), toolName, input: toolInput, risk }) ?? 'allow'
113
+ } catch { decision = 'deny' } // a broken permission path must fail safe (deny)
114
+ }
115
+ // On deny, permissionDecisionReason IS what the model receives as the
116
+ // tool error — make it a real instruction, not an opaque tag.
117
+ const denied = decision === 'deny'
99
118
  return {
100
119
  continue: true,
101
120
  hookSpecificOutput: {
102
121
  hookEventName: 'PreToolUse',
103
- permissionDecision: decision === 'deny' ? 'deny' : 'allow',
104
- permissionDecisionReason: 'thinkpool-room',
122
+ permissionDecision: denied ? 'deny' : 'allow',
123
+ permissionDecisionReason: denied
124
+ ? 'Denied by the user in the ThinkPool room. Do not retry this tool — ask what to do instead.'
125
+ : auto ? `Auto-approved (${mode}).` : 'Approved in the ThinkPool room.',
105
126
  },
106
127
  }
107
128
  }
@@ -117,7 +138,8 @@ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermiss
117
138
 
118
139
  ;(async () => {
119
140
  try {
120
- for await (const m of query({ prompt: input.stream, options: opts })) {
141
+ q = query({ prompt: input.stream, options: opts })
142
+ for await (const m of q) {
121
143
  if (closed) break
122
144
  switch (m.type) {
123
145
  case 'system':
@@ -150,8 +172,20 @@ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermiss
150
172
 
151
173
  return {
152
174
  sendTurn(text) { if (!closed) input.push(String(text)) },
153
- abort() { try { ac.abort() } catch { /* noop */ } },
175
+ // Set the permission mode Claude Code's ⇧⇥ cycle. setPermissionMode is a
176
+ // streaming control request (drives plan-mode behaviour SDK-side); the local
177
+ // `mode` drives our PreToolUse auto-approve policy. Echo so the room syncs.
178
+ async setMode(m) {
179
+ if (!MODES.has(m) || closed) return
180
+ mode = m
181
+ try { await q?.setPermissionMode?.(m) } catch { /* only valid mid-stream */ }
182
+ emit({ kind: 'mode', mode })
183
+ },
184
+ // Graceful interrupt (Esc / Stop) — stops the current turn but keeps the
185
+ // session alive for the next one. ac.abort() is teardown only (end()).
186
+ async abort() { try { await q?.interrupt?.() } catch { /* noop */ } },
154
187
  end() { closed = true; input.end(); try { ac.abort() } catch { /* noop */ } },
155
188
  get sessionId() { return sessionId },
189
+ get mode() { return mode },
156
190
  }
157
191
  }
package/package.json CHANGED
@@ -1,11 +1,19 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.6.0",
3
+ "version": "0.6.3",
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": {
7
7
  "thinkpool-pair": "bridge.mjs"
8
8
  },
9
+ "files": [
10
+ "bridge.mjs",
11
+ "claude-session.mjs",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "release": "./publish.sh"
16
+ },
9
17
  "engines": {
10
18
  "node": ">=18"
11
19
  },
@@ -1,95 +0,0 @@
1
- #!/usr/bin/env bash
2
- # thinkpool devbox — turn an always-plugged-in Mac into a permanent
3
- # ThinkPool code host (the old-MacBook-on-a-shelf setup, 2026-06-11).
4
- #
5
- # bash setup-devbox.sh <ROOM> [project-dir]
6
- #
7
- # ROOM fixed room code — thinkpool.io/code?r=<ROOM> becomes the
8
- # permanent address you and your partner share
9
- # project-dir the repo the bridge shares (default: current directory)
10
- #
11
- # What it does:
12
- # 1. pmset: never sleep (lid closed included), restart after power loss
13
- # 2. Remote Login (SSH) on — your emergency way in
14
- # 3. LaunchAgent com.thinkpool.pair: runs `npx thinkpool-pair@latest
15
- # <ROOM> --continue` from the project dir, restarts it forever
16
- # (KeepAlive). --continue resumes the latest Claude Code session in
17
- # that directory on every restart; harmless for agents without
18
- # resume support (they start fresh).
19
- #
20
- # What it can NOT do (one-time manual steps, printed at the end):
21
- # - Auto-login (System Settings → Users & Groups)
22
- # - Tailscale install (recommended)
23
- #
24
- # Re-run any time — it rewrites the agent idempotently.
25
-
26
- set -euo pipefail
27
-
28
- ROOM="${1:?usage: setup-devbox.sh <ROOM> [project-dir]}"
29
- PROJ="${2:-$PWD}"
30
- ROOM="$(echo "$ROOM" | tr '[:lower:]' '[:upper:]')"
31
- PROJ="$(cd "$PROJ" && pwd)"
32
- PLIST="$HOME/Library/LaunchAgents/com.thinkpool.pair.plist"
33
- LOG="$HOME/Library/Logs/thinkpool-pair.log"
34
-
35
- echo "── thinkpool devbox setup ──"
36
- echo " room: $ROOM"
37
- echo " project: $PROJ"
38
-
39
- # ── 1. power: never sleep, survive power cuts ───────────────────────────
40
- echo "── pmset (needs sudo) ──"
41
- sudo pmset -a sleep 0 disksleep 0 displaysleep 1
42
- sudo pmset -a disablesleep 1 # lid closed ≠ sleep, no external display needed
43
- sudo pmset -a autorestart 1 # power cut → boots back up
44
- sudo pmset -a womp 1 2>/dev/null || true # wake on LAN, when supported
45
-
46
- # ── 2. SSH in case the bridge wedges ────────────────────────────────────
47
- sudo systemsetup -setremotelogin on >/dev/null 2>&1 || \
48
- echo " (Remote Login refused — enable it in System Settings → Sharing)"
49
-
50
- # ── 3. the forever-bridge LaunchAgent ───────────────────────────────────
51
- # Login shell (-l) so launchd's bare PATH still finds node/npx/claude
52
- # (nvm, Homebrew, ~/.local/bin — whatever this Mac uses).
53
- mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs"
54
- cat > "$PLIST" <<PLIST
55
- <?xml version="1.0" encoding="UTF-8"?>
56
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
57
- <plist version="1.0">
58
- <dict>
59
- <key>Label</key> <string>com.thinkpool.pair</string>
60
- <key>ProgramArguments</key>
61
- <array>
62
- <string>/bin/zsh</string>
63
- <string>-l</string>
64
- <string>-c</string>
65
- <string>exec npx --yes thinkpool-pair@latest $ROOM --continue</string>
66
- </array>
67
- <key>WorkingDirectory</key> <string>$PROJ</string>
68
- <key>RunAtLoad</key> <true/>
69
- <key>KeepAlive</key> <true/>
70
- <key>ThrottleInterval</key> <integer>15</integer>
71
- <key>StandardOutPath</key> <string>$LOG</string>
72
- <key>StandardErrorPath</key><string>$LOG</string>
73
- </dict>
74
- </plist>
75
- PLIST
76
-
77
- launchctl unload "$PLIST" 2>/dev/null || true
78
- launchctl load "$PLIST"
79
- echo " LaunchAgent loaded — log: $LOG"
80
-
81
- # ── done ────────────────────────────────────────────────────────────────
82
- cat <<EOF
83
-
84
- ── done. two manual steps remain ──
85
- 1. System Settings → Users & Groups → automatically log in as this
86
- user (the agent runs at login, not at boot, until you do this).
87
- 2. Recommended: install Tailscale so you can SSH from anywhere.
88
-
89
- permanent room: https://thinkpool.io/code?r=$ROOM
90
- watch the log: tail -f $LOG
91
- stop it: launchctl unload $PLIST
92
-
93
- battery note: a dead 2017 battery on permanent AC can swell — glance
94
- at the lid/trackpad for bulge now and then, or have it removed.
95
- EOF