thinkpool-pair 0.6.2 → 0.6.4

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
@@ -327,7 +327,7 @@ function openStructured({ id, model, resume }) {
327
327
  },
328
328
  requestPermission: (req) => new Promise((resolve) => {
329
329
  entry.pending.set(req.id, resolve)
330
- bcast('code-perm-req', { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk })
330
+ bcast('code-perm-req', { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk, plan: req.plan })
331
331
  }),
332
332
  })
333
333
  announce()
@@ -459,12 +459,18 @@ channel
459
459
  .on('broadcast', { event: 'code-perm' }, ({ payload }) => {
460
460
  const s = payload?.term && sessions.get(payload.term)
461
461
  const resolve = s && payload.id && s.pending.get(payload.id)
462
- if (resolve) { s.pending.delete(payload.id); resolve(payload.decision === 'deny' ? 'deny' : 'allow') }
462
+ // Pass the raw decision through normal tools use allow/deny, plan cards
463
+ // use run/accept/keep (claude-session interprets). Default deny on missing.
464
+ if (resolve) { s.pending.delete(payload.id); resolve(payload.decision || 'deny') }
463
465
  })
464
466
  .on('broadcast', { event: 'code-abort' }, ({ payload }) => {
465
467
  const s = payload?.term && sessions.get(payload.term)
466
468
  if (s) s.session.abort()
467
469
  })
470
+ .on('broadcast', { event: 'code-mode' }, ({ payload }) => {
471
+ const s = payload?.term && sessions.get(payload.term)
472
+ if (s) s.session.setMode(payload.mode)
473
+ })
468
474
  .on('broadcast', { event: 'code-close' }, ({ payload }) => {
469
475
  const s = payload?.id && sessions.get(payload.id)
470
476
  if (s) { try { s.session.end() } catch { /* noop */ } ; sessions.delete(payload.id); bcast('term-exit', { id: payload.id }); announce() }
@@ -81,24 +81,55 @@ const simplifyBlocks = (blocks = []) => blocks.map((b) => {
81
81
  * resolve 'allow'/'deny'. (Caller implements any auto-allow policy.)
82
82
  * @returns {{ sendTurn(text), abort(), end(), readonly sessionId }}
83
83
  */
84
+ const MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
85
+
84
86
  export function startClaudeSession({ cwd, model, resume, onEvent, requestPermission }) {
85
87
  const ac = new AbortController()
86
88
  const input = makeInputStream()
87
89
  let sessionId = resume || null
88
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
89
94
 
90
95
  const emit = (evt) => { try { onEvent?.(evt) } catch { /* never let a consumer throw into the loop */ } }
91
96
 
92
- // PreToolUse — fires on EVERY tool call (the universal gate). Classify
93
- // 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.
94
101
  const preTool = async (hookInput) => {
95
102
  const toolName = hookInput.tool_name
96
103
  const toolInput = hookInput.tool_input
104
+ // ── Plan approval — ExitPlanMode is how the agent presents its plan in
105
+ // plan mode. Render a dedicated plan card (not the generic perm card) with
106
+ // three outcomes: run (exit → default), accept (exit → acceptEdits), keep
107
+ // (deny → stay planning). On approval we flip the SDK permission mode so
108
+ // subsequent tools actually execute.
109
+ if (toolName === 'ExitPlanMode') {
110
+ let choice = 'keep'
111
+ try { choice = await requestPermission?.({ id: randomUUID(), toolName, input: toolInput, risk: 'plan', plan: toolInput?.plan || '' }) ?? 'keep' }
112
+ catch { choice = 'keep' }
113
+ if (choice === 'run' || choice === 'accept') {
114
+ const next = choice === 'accept' ? 'acceptEdits' : 'default'
115
+ mode = next
116
+ try { await q?.setPermissionMode?.(next) } catch { /* only valid mid-stream */ }
117
+ emit({ kind: 'mode', mode: next })
118
+ return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: `Plan approved in the ThinkPool room — proceed (${next} mode).` } }
119
+ }
120
+ return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'The user chose "keep planning" in the ThinkPool room. Do not exit plan mode — keep refining the plan, then call ExitPlanMode again when ready.' } }
121
+ }
97
122
  const risk = classifyRisk(toolName, toolInput)
123
+ const auto =
124
+ risk === 'low' ||
125
+ mode === 'bypassPermissions' ||
126
+ (mode === 'acceptEdits' && WRITE_TOOLS.has(toolName) && risk !== 'high')
98
127
  let decision = 'allow'
99
- try {
100
- decision = await requestPermission?.({ id: randomUUID(), toolName, input: toolInput, risk }) ?? 'allow'
101
- } catch { decision = 'deny' } // a broken permission path must fail safe (deny)
128
+ if (!auto) {
129
+ try {
130
+ decision = await requestPermission?.({ id: randomUUID(), toolName, input: toolInput, risk }) ?? 'allow'
131
+ } catch { decision = 'deny' } // a broken permission path must fail safe (deny)
132
+ }
102
133
  // On deny, permissionDecisionReason IS what the model receives as the
103
134
  // tool error — make it a real instruction, not an opaque tag.
104
135
  const denied = decision === 'deny'
@@ -109,7 +140,7 @@ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermiss
109
140
  permissionDecision: denied ? 'deny' : 'allow',
110
141
  permissionDecisionReason: denied
111
142
  ? 'Denied by the user in the ThinkPool room. Do not retry this tool — ask what to do instead.'
112
- : 'Approved in the ThinkPool room.',
143
+ : auto ? `Auto-approved (${mode}).` : 'Approved in the ThinkPool room.',
113
144
  },
114
145
  }
115
146
  }
@@ -125,7 +156,8 @@ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermiss
125
156
 
126
157
  ;(async () => {
127
158
  try {
128
- for await (const m of query({ prompt: input.stream, options: opts })) {
159
+ q = query({ prompt: input.stream, options: opts })
160
+ for await (const m of q) {
129
161
  if (closed) break
130
162
  switch (m.type) {
131
163
  case 'system':
@@ -158,8 +190,20 @@ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermiss
158
190
 
159
191
  return {
160
192
  sendTurn(text) { if (!closed) input.push(String(text)) },
161
- abort() { try { ac.abort() } catch { /* noop */ } },
193
+ // Set the permission mode Claude Code's ⇧⇥ cycle. setPermissionMode is a
194
+ // streaming control request (drives plan-mode behaviour SDK-side); the local
195
+ // `mode` drives our PreToolUse auto-approve policy. Echo so the room syncs.
196
+ async setMode(m) {
197
+ if (!MODES.has(m) || closed) return
198
+ mode = m
199
+ try { await q?.setPermissionMode?.(m) } catch { /* only valid mid-stream */ }
200
+ emit({ kind: 'mode', mode })
201
+ },
202
+ // Graceful interrupt (Esc / Stop) — stops the current turn but keeps the
203
+ // session alive for the next one. ac.abort() is teardown only (end()).
204
+ async abort() { try { await q?.interrupt?.() } catch { /* noop */ } },
162
205
  end() { closed = true; input.end(); try { ac.abort() } catch { /* noop */ } },
163
206
  get sessionId() { return sessionId },
207
+ get mode() { return mode },
164
208
  }
165
209
  }
package/package.json CHANGED
@@ -1,11 +1,19 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
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