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 +25 -2
- package/claude-session.mjs +44 -10
- package/package.json +9 -1
- package/devbox/setup-devbox.sh +0 -95
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)
|
|
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 (
|
|
482
|
+
if (wantStructured(attachedCmd)) openStructured({ id: randomUUID() })
|
|
460
483
|
else openTerm({ id: randomUUID(), cmd: attachedCmd, args: attachedArgs, attached: true })
|
|
461
484
|
}
|
|
462
485
|
announce()
|
package/claude-session.mjs
CHANGED
|
@@ -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
|
-
|
|
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).
|
|
90
|
-
//
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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:
|
|
104
|
-
permissionDecisionReason:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
},
|
package/devbox/setup-devbox.sh
DELETED
|
@@ -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
|