thinkpool-pair 0.6.2 → 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
@@ -465,6 +465,10 @@ channel
465
465
  const s = payload?.term && sessions.get(payload.term)
466
466
  if (s) s.session.abort()
467
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
+ })
468
472
  .on('broadcast', { event: 'code-close' }, ({ payload }) => {
469
473
  const s = payload?.id && sessions.get(payload.id)
470
474
  if (s) { try { s.session.end() } catch { /* noop */ } ; sessions.delete(payload.id); bcast('term-exit', { id: payload.id }); announce() }
@@ -81,24 +81,37 @@ 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
97
104
  const risk = classifyRisk(toolName, toolInput)
105
+ const auto =
106
+ risk === 'low' ||
107
+ mode === 'bypassPermissions' ||
108
+ (mode === 'acceptEdits' && WRITE_TOOLS.has(toolName) && risk !== 'high')
98
109
  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)
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
+ }
102
115
  // On deny, permissionDecisionReason IS what the model receives as the
103
116
  // tool error — make it a real instruction, not an opaque tag.
104
117
  const denied = decision === 'deny'
@@ -109,7 +122,7 @@ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermiss
109
122
  permissionDecision: denied ? 'deny' : 'allow',
110
123
  permissionDecisionReason: denied
111
124
  ? 'Denied by the user in the ThinkPool room. Do not retry this tool — ask what to do instead.'
112
- : 'Approved in the ThinkPool room.',
125
+ : auto ? `Auto-approved (${mode}).` : 'Approved in the ThinkPool room.',
113
126
  },
114
127
  }
115
128
  }
@@ -125,7 +138,8 @@ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermiss
125
138
 
126
139
  ;(async () => {
127
140
  try {
128
- 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) {
129
143
  if (closed) break
130
144
  switch (m.type) {
131
145
  case 'system':
@@ -158,8 +172,20 @@ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermiss
158
172
 
159
173
  return {
160
174
  sendTurn(text) { if (!closed) input.push(String(text)) },
161
- 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 */ } },
162
187
  end() { closed = true; input.end(); try { ac.abort() } catch { /* noop */ } },
163
188
  get sessionId() { return sessionId },
189
+ get mode() { return mode },
164
190
  }
165
191
  }
package/package.json CHANGED
@@ -1,11 +1,19 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.6.2",
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