thinkpool-pair 0.5.0 → 0.6.0

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
@@ -34,6 +34,7 @@ import path from 'node:path'
34
34
  import readline from 'node:readline'
35
35
  import { randomUUID } from 'node:crypto'
36
36
  import { createClient } from '@supabase/supabase-js'
37
+ import { startClaudeSession } from './claude-session.mjs'
37
38
 
38
39
  // Public client creds (the same anon values the web app ships — safe to embed).
39
40
  // Override with TP_SUPABASE_URL / TP_SUPABASE_ANON if you ever need to.
@@ -118,6 +119,11 @@ const argv = process.argv.slice(2)
118
119
  const room = (argv[0] || '').toUpperCase().trim()
119
120
  if (!room || room.startsWith('-')) { console.error('usage: npx thinkpool-pair <ROOM> [--headless] [--continue|--fresh] [-- <command…>]'); process.exit(1) }
120
121
  const headless = argv.includes('--headless')
122
+ // Structured mode (Phase 2, opt-in): Claude Code runs through the Agent SDK
123
+ // (structured events + risk-tiered permission gate) instead of the PTY byte
124
+ // relay. Default OFF — the PTY path is untouched. Only applies to `claude`.
125
+ // Spec: docs/specs/2026-06-11-code-structured-reader.md
126
+ const STRUCTURED = process.env.TP_STRUCTURED === '1' || argv.includes('--structured')
121
127
  const installedAgents = KNOWN_AGENTS.filter(a => onPath(a.cmd))
122
128
  const dashIdx = argv.indexOf('--')
123
129
  // Own flags are only read from BEFORE `--`; after it, every token belongs
@@ -187,6 +193,10 @@ const channel = supabase.channel(`tpcode:${room}`, {
187
193
  // ── terminal registry ──────────────────────────────────────────────
188
194
  // id → { term (pty), cmd, attached, scrollback, buf }
189
195
  const terms = new Map()
196
+ // Structured Claude sessions live in their own registry, separate from the
197
+ // PTY `terms` map so none of the byte-relay code paths (flush, resize,
198
+ // pty-in, scrollback) ever touch them. id → { session, cmd, log, pending }.
199
+ const sessions = new Map()
190
200
  let attachedId = null
191
201
  let shuttingDown = false
192
202
 
@@ -212,10 +222,23 @@ const announce = () =>
212
222
  // updir: where room file-drops land (forward-slash normalised — the web
213
223
  // client string-joins host paths onto it; Node accepts `/` on Windows).
214
224
  updir: UPDIR.split(path.sep).join('/'),
215
- agents: installedAgents,
225
+ // canResume: this agent can continue a prior session in THIS cwd —
226
+ // re-probed per announce (a fresh run creates a session, so the flag
227
+ // can flip true while the bridge is up). Functions don't survive
228
+ // JSON, so the wire shape is explicit.
229
+ agents: installedAgents.map(a => {
230
+ let canResume = false
231
+ if (a.resume) { try { canResume = a.resume.probe() } catch { /* stays false */ } }
232
+ return { label: a.label, cmd: a.cmd, canResume }
233
+ }),
216
234
  // cols/rows: the PTY's one true size — web viewers render this grid and
217
235
  // scale it to their own page instead of voting to reflow it.
218
- terms: [...terms.entries()].map(([id, t]) => ({ id, cmd: t.cmd, alive: true, cols: t.term.cols, rows: t.term.rows })),
236
+ terms: [
237
+ ...[...terms.entries()].map(([id, t]) => ({ id, cmd: t.cmd, alive: true, cols: t.term.cols, rows: t.term.rows })),
238
+ // Structured sessions advertise kind:'structured' so the web renders the
239
+ // reader (not xterm) and drives them with code-turn / code-perm.
240
+ ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', alive: true })),
241
+ ],
219
242
  })
220
243
 
221
244
  // One flush timer batches every terminal's pending bytes (~35ms cadence).
@@ -281,6 +304,33 @@ function openTerm({ id, cmd, args = [], attached = false, cols, rows }) {
281
304
  return entry
282
305
  }
283
306
 
307
+ // ── structured Claude session ──────────────────────────────────────
308
+ // The Phase-2 path: instead of a PTY, run Claude Code through the Agent
309
+ // SDK and relay STRUCTURED events. onEvent → broadcast `code-event`;
310
+ // every tool call round-trips through `code-perm-req`/`code-perm` (the
311
+ // risk-tiered permission card). A rolling event log replays to joiners.
312
+ const STRUCTURED_LOG_MAX = 400
313
+ function openStructured({ id, model, resume }) {
314
+ if (sessions.has(id)) return
315
+ const entry = { cmd: 'claude', kind: 'structured', log: [], pending: new Map(), session: null }
316
+ sessions.set(id, entry)
317
+ entry.session = startClaudeSession({
318
+ cwd: process.cwd(), model, resume,
319
+ onEvent: (evt) => {
320
+ entry.log.push(evt)
321
+ if (entry.log.length > STRUCTURED_LOG_MAX) entry.log.shift()
322
+ bcast('code-event', { term: id, evt })
323
+ },
324
+ requestPermission: (req) => new Promise((resolve) => {
325
+ entry.pending.set(req.id, resolve)
326
+ bcast('code-perm-req', { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk })
327
+ }),
328
+ })
329
+ announce()
330
+ process.stderr.write(`\n ◆ structured Claude session (${id.slice(0, 8)}) — driven from the room.\n`)
331
+ return entry
332
+ }
333
+
284
334
  // After the attached CLI exits, the host's stdin stops feeding a PTY —
285
335
  // restore the cooked terminal so Ctrl-C reaches the bridge itself.
286
336
  const detachLocal = () => {
@@ -324,8 +374,17 @@ channel
324
374
  // Multi-bridge rooms: a targeted open is for ONE machine. Untargeted
325
375
  // opens (older web) are taken by whoever hears them — the solo case.
326
376
  if (payload.host && payload.host !== name) return
327
- openTerm({ id: payload.id, cmd: payload.cmd })
328
- process.stderr.write(`\n ◆ web opened a "${payload.cmd}" terminal (headless).\n`)
377
+ // resume is a FLAG, never argv: the channel must not pass arbitrary
378
+ // args even though the room is shell-trust by design. The args come
379
+ // from our own KNOWN_AGENTS table, probe-gated so `--continue` with
380
+ // no prior session can't insta-kill the fresh terminal.
381
+ let args = []
382
+ if (payload.resume) {
383
+ const agent = KNOWN_AGENTS.find(a => a.cmd === payload.cmd)
384
+ if (agent?.resume) { try { if (agent.resume.probe()) args = [...agent.resume.args] } catch { /* fresh */ } }
385
+ }
386
+ openTerm({ id: payload.id, cmd: payload.cmd, args })
387
+ process.stderr.write(`\n ◆ web opened a "${payload.cmd}"${args.length ? ' (continue)' : ''} terminal (headless).\n`)
329
388
  })
330
389
  .on('broadcast', { event: 'term-close' }, ({ payload }) => {
331
390
  const t = payload?.id && terms.get(payload.id)
@@ -342,6 +401,11 @@ channel
342
401
  b64: Buffer.from(t.scrollback, 'utf8').toString('base64'),
343
402
  })
344
403
  }
404
+ // Structured sessions replay their event log (reader rebuilds from it).
405
+ for (const [id, s] of sessions) {
406
+ if (!s.log.length) continue
407
+ bcast('code-replay', { to: payload?.to ?? null, term: id, events: s.log })
408
+ }
345
409
  announce()
346
410
  })
347
411
  .on('broadcast', { event: 'file-put' }, ({ payload }) => {
@@ -364,11 +428,37 @@ channel
364
428
  }
365
429
  })()
366
430
  })
431
+ // ── structured-session control (Phase 2) ──
432
+ .on('broadcast', { event: 'code-open' }, ({ payload }) => {
433
+ if (payload?.host && payload.host !== name) return
434
+ openStructured({ id: payload?.id || randomUUID(), model: payload?.model, resume: payload?.resume })
435
+ })
436
+ .on('broadcast', { event: 'code-turn' }, ({ payload }) => {
437
+ const s = payload?.term && sessions.get(payload.term)
438
+ if (s && payload.text != null) s.session.sendTurn(payload.text)
439
+ })
440
+ .on('broadcast', { event: 'code-perm' }, ({ payload }) => {
441
+ const s = payload?.term && sessions.get(payload.term)
442
+ const resolve = s && payload.id && s.pending.get(payload.id)
443
+ if (resolve) { s.pending.delete(payload.id); resolve(payload.decision === 'deny' ? 'deny' : 'allow') }
444
+ })
445
+ .on('broadcast', { event: 'code-abort' }, ({ payload }) => {
446
+ const s = payload?.term && sessions.get(payload.term)
447
+ if (s) s.session.abort()
448
+ })
449
+ .on('broadcast', { event: 'code-close' }, ({ payload }) => {
450
+ const s = payload?.id && sessions.get(payload.id)
451
+ if (s) { try { s.session.end() } catch { /* noop */ } ; sessions.delete(payload.id); bcast('term-exit', { id: payload.id }); announce() }
452
+ })
367
453
  .on('broadcast', { event: 'who' }, announce)
368
454
  .subscribe(status => {
369
455
  if (status === 'SUBSCRIBED') {
370
456
  channel.track({ name, role: 'bridge' })
371
- if (attachedCmd && !terms.size) openTerm({ id: randomUUID(), cmd: attachedCmd, args: attachedArgs, attached: true })
457
+ if (attachedCmd && !terms.size && !sessions.size) {
458
+ // Claude + structured mode → Agent SDK session; everything else → PTY.
459
+ if (STRUCTURED && /(^|[/\\])claude$/.test(attachedCmd)) openStructured({ id: randomUUID() })
460
+ else openTerm({ id: randomUUID(), cmd: attachedCmd, args: attachedArgs, attached: true })
461
+ }
372
462
  announce()
373
463
  process.stderr.write(headless
374
464
  ? `\n ◆ thinkpool — relaying room ${room} (headless). Open terminals from the web UI.\n\n`
@@ -386,6 +476,7 @@ function shutdown() {
386
476
  } catch { /* noop */ }
387
477
  try { supabase.removeChannel(channel) } catch { /* noop */ }
388
478
  for (const t of terms.values()) { try { t.term.kill() } catch { /* noop */ } }
479
+ for (const s of sessions.values()) { try { s.session.end() } catch { /* noop */ } }
389
480
  detachLocal()
390
481
  process.exit(0)
391
482
  }
@@ -0,0 +1,157 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ claude-session.mjs — a structured, interactive Claude Code session
3
+ for the ThinkPool bridge. Wraps @anthropic-ai/claude-agent-sdk:
4
+ one long-lived streaming-input query() per terminal, structured
5
+ events out, user turns + abort in, and a PreToolUse permission gate
6
+ that classifies each tool call's risk and round-trips the decision
7
+ to the room (the risk-tiered permission card).
8
+
9
+ This replaces the PTY byte relay for Claude Code only. Other CLIs
10
+ keep the node-pty path in bridge.mjs. Auth is the HOST's own Claude
11
+ Code login (Keychain / API key) — no ThinkPool credential involved.
12
+ Spec: docs/specs/2026-06-11-code-structured-reader.md
13
+ ───────────────────────────────────────────────────────────── */
14
+
15
+ import { randomUUID } from 'node:crypto'
16
+ import { query } from '@anthropic-ai/claude-agent-sdk'
17
+
18
+ // ── risk classification — the accent/danger tier of the permission card ──
19
+ // low (read-only) · medium (writes/runs) · network (leaves the machine) ·
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
22
+ const READONLY_TOOLS = new Set(['Read', 'Grep', 'Glob', 'NotebookRead', 'TodoRead', 'LS'])
23
+ const NETWORK_TOOLS = new Set(['WebFetch', 'WebSearch'])
24
+ const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'TodoWrite'])
25
+
26
+ export function classifyRisk(toolName, input) {
27
+ if (toolName === 'Bash') {
28
+ const cmd = (input && (input.command ?? input.cmd)) || ''
29
+ return DESTRUCTIVE.test(cmd) ? 'high' : 'medium'
30
+ }
31
+ if (READONLY_TOOLS.has(toolName)) return 'low'
32
+ if (NETWORK_TOOLS.has(toolName)) return 'network'
33
+ if (WRITE_TOOLS.has(toolName)) return 'medium'
34
+ // Unknown / MCP / Task tools: treat as medium (asks, amber) rather than
35
+ // silently allowing — safer default for a remote-driven agent.
36
+ return 'medium'
37
+ }
38
+
39
+ // ── input stream — a generator we keep open and feed turns into ──
40
+ function makeInputStream() {
41
+ const queue = []
42
+ let wake = null
43
+ let ended = false
44
+ async function* gen() {
45
+ while (!ended) {
46
+ if (queue.length) { yield queue.shift(); continue }
47
+ await new Promise((r) => { wake = r })
48
+ }
49
+ }
50
+ return {
51
+ stream: gen(),
52
+ push(content) {
53
+ queue.push({ type: 'user', message: { role: 'user', content } })
54
+ if (wake) { wake(); wake = null }
55
+ },
56
+ end() { ended = true; if (wake) { wake(); wake = null } },
57
+ }
58
+ }
59
+
60
+ // Simplify SDK assistant content blocks to a stable wire shape.
61
+ const simplifyBlocks = (blocks = []) => blocks.map((b) => {
62
+ if (b.type === 'text') return { type: 'text', text: b.text }
63
+ if (b.type === 'thinking') return { type: 'thinking', text: b.thinking || '' }
64
+ if (b.type === 'tool_use') return { type: 'tool_use', id: b.id, name: b.name, input: b.input }
65
+ return { type: b.type }
66
+ }).filter(Boolean)
67
+
68
+ /**
69
+ * Start a structured Claude Code session.
70
+ *
71
+ * @param {object} o
72
+ * @param {string=} o.cwd working directory for the agent
73
+ * @param {string=} o.model model id (default: host's configured)
74
+ * @param {string=} o.resume session id to resume
75
+ * @param {(evt)=>void} o.onEvent receives normalized structured events
76
+ * @param {(req)=>Promise<'allow'|'deny'>} o.requestPermission
77
+ * called for EVERY tool call with { id, toolName, input, risk };
78
+ * resolve 'allow'/'deny'. (Caller implements any auto-allow policy.)
79
+ * @returns {{ sendTurn(text), abort(), end(), readonly sessionId }}
80
+ */
81
+ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermission }) {
82
+ const ac = new AbortController()
83
+ const input = makeInputStream()
84
+ let sessionId = resume || null
85
+ let closed = false
86
+
87
+ const emit = (evt) => { try { onEvent?.(evt) } catch { /* never let a consumer throw into the loop */ } }
88
+
89
+ // PreToolUse — fires on EVERY tool call (the universal gate). Classify
90
+ // risk, round-trip the decision to the room, return allow/deny.
91
+ const preTool = async (hookInput) => {
92
+ const toolName = hookInput.tool_name
93
+ const toolInput = hookInput.tool_input
94
+ const risk = classifyRisk(toolName, toolInput)
95
+ 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)
99
+ return {
100
+ continue: true,
101
+ hookSpecificOutput: {
102
+ hookEventName: 'PreToolUse',
103
+ permissionDecision: decision === 'deny' ? 'deny' : 'allow',
104
+ permissionDecisionReason: 'thinkpool-room',
105
+ },
106
+ }
107
+ }
108
+
109
+ const opts = {
110
+ abortController: ac,
111
+ permissionMode: 'default',
112
+ hooks: { PreToolUse: [{ hooks: [preTool] }] },
113
+ }
114
+ if (cwd) opts.cwd = cwd
115
+ if (model) opts.model = model
116
+ if (resume) opts.resume = resume
117
+
118
+ ;(async () => {
119
+ try {
120
+ for await (const m of query({ prompt: input.stream, options: opts })) {
121
+ if (closed) break
122
+ switch (m.type) {
123
+ case 'system':
124
+ if (m.session_id) sessionId = m.session_id
125
+ emit({ kind: 'system', sessionId, model: m.model || model || null })
126
+ break
127
+ case 'assistant':
128
+ emit({ kind: 'assistant', blocks: simplifyBlocks(m.message?.content) })
129
+ break
130
+ case 'user':
131
+ // tool_result blocks arrive on the user-role echo
132
+ for (const b of (m.message?.content || [])) {
133
+ if (b?.type === 'tool_result') {
134
+ emit({ kind: 'tool_result', toolUseId: b.tool_use_id, content: b.content, isError: !!b.is_error })
135
+ }
136
+ }
137
+ break
138
+ case 'result':
139
+ if (m.session_id) sessionId = m.session_id
140
+ emit({ kind: 'result', subtype: m.subtype, sessionId, costUsd: m.total_cost_usd, usage: m.usage, numTurns: m.num_turns })
141
+ break
142
+ default:
143
+ break
144
+ }
145
+ }
146
+ } catch (e) {
147
+ if (!closed) emit({ kind: 'error', message: e?.message || String(e) })
148
+ }
149
+ })()
150
+
151
+ return {
152
+ sendTurn(text) { if (!closed) input.push(String(text)) },
153
+ abort() { try { ac.abort() } catch { /* noop */ } },
154
+ end() { closed = true; input.end(); try { ac.abort() } catch { /* noop */ } },
155
+ get sessionId() { return sessionId },
156
+ }
157
+ }
@@ -0,0 +1,95 @@
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
package/package.json CHANGED
@@ -1,13 +1,16 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
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
- "bin": { "thinkpool-pair": "bridge.mjs" },
6
+ "bin": {
7
+ "thinkpool-pair": "bridge.mjs"
8
+ },
7
9
  "engines": {
8
10
  "node": ">=18"
9
11
  },
10
12
  "dependencies": {
13
+ "@anthropic-ai/claude-agent-sdk": "^0.3.173",
11
14
  "@supabase/supabase-js": "^2.45.0",
12
15
  "node-pty": "^1.2.0-beta.13"
13
16
  }