thinkpool-pair 0.5.1 → 0.6.2

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,15 @@ 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')
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'
121
131
  const installedAgents = KNOWN_AGENTS.filter(a => onPath(a.cmd))
122
132
  const dashIdx = argv.indexOf('--')
123
133
  // Own flags are only read from BEFORE `--`; after it, every token belongs
@@ -187,6 +197,10 @@ const channel = supabase.channel(`tpcode:${room}`, {
187
197
  // ── terminal registry ──────────────────────────────────────────────
188
198
  // id → { term (pty), cmd, attached, scrollback, buf }
189
199
  const terms = new Map()
200
+ // Structured Claude sessions live in their own registry, separate from the
201
+ // PTY `terms` map so none of the byte-relay code paths (flush, resize,
202
+ // pty-in, scrollback) ever touch them. id → { session, cmd, log, pending }.
203
+ const sessions = new Map()
190
204
  let attachedId = null
191
205
  let shuttingDown = false
192
206
 
@@ -223,7 +237,12 @@ const announce = () =>
223
237
  }),
224
238
  // cols/rows: the PTY's one true size — web viewers render this grid and
225
239
  // scale it to their own page instead of voting to reflow it.
226
- terms: [...terms.entries()].map(([id, t]) => ({ id, cmd: t.cmd, alive: true, cols: t.term.cols, rows: t.term.rows })),
240
+ terms: [
241
+ ...[...terms.entries()].map(([id, t]) => ({ id, cmd: t.cmd, alive: true, cols: t.term.cols, rows: t.term.rows })),
242
+ // Structured sessions advertise kind:'structured' so the web renders the
243
+ // reader (not xterm) and drives them with code-turn / code-perm.
244
+ ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', alive: true })),
245
+ ],
227
246
  })
228
247
 
229
248
  // One flush timer batches every terminal's pending bytes (~35ms cadence).
@@ -289,6 +308,33 @@ function openTerm({ id, cmd, args = [], attached = false, cols, rows }) {
289
308
  return entry
290
309
  }
291
310
 
311
+ // ── structured Claude session ──────────────────────────────────────
312
+ // The Phase-2 path: instead of a PTY, run Claude Code through the Agent
313
+ // SDK and relay STRUCTURED events. onEvent → broadcast `code-event`;
314
+ // every tool call round-trips through `code-perm-req`/`code-perm` (the
315
+ // risk-tiered permission card). A rolling event log replays to joiners.
316
+ const STRUCTURED_LOG_MAX = 400
317
+ function openStructured({ id, model, resume }) {
318
+ if (sessions.has(id)) return
319
+ const entry = { cmd: 'claude', kind: 'structured', log: [], pending: new Map(), session: null }
320
+ sessions.set(id, entry)
321
+ entry.session = startClaudeSession({
322
+ cwd: process.cwd(), model, resume,
323
+ onEvent: (evt) => {
324
+ entry.log.push(evt)
325
+ if (entry.log.length > STRUCTURED_LOG_MAX) entry.log.shift()
326
+ bcast('code-event', { term: id, evt })
327
+ },
328
+ requestPermission: (req) => new Promise((resolve) => {
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 })
331
+ }),
332
+ })
333
+ announce()
334
+ process.stderr.write(`\n ◆ structured Claude session (${id.slice(0, 8)}) — driven from the room.\n`)
335
+ return entry
336
+ }
337
+
292
338
  // After the attached CLI exits, the host's stdin stops feeding a PTY —
293
339
  // restore the cooked terminal so Ctrl-C reaches the bridge itself.
294
340
  const detachLocal = () => {
@@ -341,6 +387,11 @@ channel
341
387
  const agent = KNOWN_AGENTS.find(a => a.cmd === payload.cmd)
342
388
  if (agent?.resume) { try { if (agent.resume.probe()) args = [...agent.resume.args] } catch { /* fresh */ } }
343
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
+ }
344
395
  openTerm({ id: payload.id, cmd: payload.cmd, args })
345
396
  process.stderr.write(`\n ◆ web opened a "${payload.cmd}"${args.length ? ' (continue)' : ''} terminal (headless).\n`)
346
397
  })
@@ -359,6 +410,11 @@ channel
359
410
  b64: Buffer.from(t.scrollback, 'utf8').toString('base64'),
360
411
  })
361
412
  }
413
+ // Structured sessions replay their event log (reader rebuilds from it).
414
+ for (const [id, s] of sessions) {
415
+ if (!s.log.length) continue
416
+ bcast('code-replay', { to: payload?.to ?? null, term: id, events: s.log })
417
+ }
362
418
  announce()
363
419
  })
364
420
  .on('broadcast', { event: 'file-put' }, ({ payload }) => {
@@ -381,11 +437,47 @@ channel
381
437
  }
382
438
  })()
383
439
  })
440
+ // ── structured-session control (Phase 2) ──
441
+ .on('broadcast', { event: 'code-open' }, ({ payload }) => {
442
+ if (payload?.host && payload.host !== name) return
443
+ openStructured({ id: payload?.id || randomUUID(), model: payload?.model, resume: payload?.resume })
444
+ })
445
+ .on('broadcast', { event: 'code-turn' }, ({ payload }) => {
446
+ const s = payload?.term && sessions.get(payload.term)
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
+ }
458
+ })
459
+ .on('broadcast', { event: 'code-perm' }, ({ payload }) => {
460
+ const s = payload?.term && sessions.get(payload.term)
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') }
463
+ })
464
+ .on('broadcast', { event: 'code-abort' }, ({ payload }) => {
465
+ const s = payload?.term && sessions.get(payload.term)
466
+ if (s) s.session.abort()
467
+ })
468
+ .on('broadcast', { event: 'code-close' }, ({ payload }) => {
469
+ const s = payload?.id && sessions.get(payload.id)
470
+ if (s) { try { s.session.end() } catch { /* noop */ } ; sessions.delete(payload.id); bcast('term-exit', { id: payload.id }); announce() }
471
+ })
384
472
  .on('broadcast', { event: 'who' }, announce)
385
473
  .subscribe(status => {
386
474
  if (status === 'SUBSCRIBED') {
387
475
  channel.track({ name, role: 'bridge' })
388
- if (attachedCmd && !terms.size) openTerm({ id: randomUUID(), cmd: attachedCmd, args: attachedArgs, attached: true })
476
+ if (attachedCmd && !terms.size && !sessions.size) {
477
+ // Claude + structured mode → Agent SDK session; everything else → PTY.
478
+ if (wantStructured(attachedCmd)) openStructured({ id: randomUUID() })
479
+ else openTerm({ id: randomUUID(), cmd: attachedCmd, args: attachedArgs, attached: true })
480
+ }
389
481
  announce()
390
482
  process.stderr.write(headless
391
483
  ? `\n ◆ thinkpool — relaying room ${room} (headless). Open terminals from the web UI.\n\n`
@@ -403,6 +495,7 @@ function shutdown() {
403
495
  } catch { /* noop */ }
404
496
  try { supabase.removeChannel(channel) } catch { /* noop */ }
405
497
  for (const t of terms.values()) { try { t.term.kill() } catch { /* noop */ } }
498
+ for (const s of sessions.values()) { try { s.session.end() } catch { /* noop */ } }
406
499
  detachLocal()
407
500
  process.exit(0)
408
501
  }
@@ -0,0 +1,165 @@
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
+ // 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
25
+ const READONLY_TOOLS = new Set(['Read', 'Grep', 'Glob', 'NotebookRead', 'TodoRead', 'LS'])
26
+ const NETWORK_TOOLS = new Set(['WebFetch', 'WebSearch'])
27
+ const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'TodoWrite'])
28
+
29
+ export function classifyRisk(toolName, input) {
30
+ if (toolName === 'Bash') {
31
+ const cmd = (input && (input.command ?? input.cmd)) || ''
32
+ return DESTRUCTIVE.test(cmd) ? 'high' : 'medium'
33
+ }
34
+ if (READONLY_TOOLS.has(toolName)) return 'low'
35
+ if (NETWORK_TOOLS.has(toolName)) return 'network'
36
+ if (WRITE_TOOLS.has(toolName)) return 'medium'
37
+ // Unknown / MCP / Task tools: treat as medium (asks, amber) rather than
38
+ // silently allowing — safer default for a remote-driven agent.
39
+ return 'medium'
40
+ }
41
+
42
+ // ── input stream — a generator we keep open and feed turns into ──
43
+ function makeInputStream() {
44
+ const queue = []
45
+ let wake = null
46
+ let ended = false
47
+ async function* gen() {
48
+ while (!ended) {
49
+ if (queue.length) { yield queue.shift(); continue }
50
+ await new Promise((r) => { wake = r })
51
+ }
52
+ }
53
+ return {
54
+ stream: gen(),
55
+ push(content) {
56
+ queue.push({ type: 'user', message: { role: 'user', content } })
57
+ if (wake) { wake(); wake = null }
58
+ },
59
+ end() { ended = true; if (wake) { wake(); wake = null } },
60
+ }
61
+ }
62
+
63
+ // Simplify SDK assistant content blocks to a stable wire shape.
64
+ const simplifyBlocks = (blocks = []) => blocks.map((b) => {
65
+ if (b.type === 'text') return { type: 'text', text: b.text }
66
+ if (b.type === 'thinking') return { type: 'thinking', text: b.thinking || '' }
67
+ if (b.type === 'tool_use') return { type: 'tool_use', id: b.id, name: b.name, input: b.input }
68
+ return { type: b.type }
69
+ }).filter(Boolean)
70
+
71
+ /**
72
+ * Start a structured Claude Code session.
73
+ *
74
+ * @param {object} o
75
+ * @param {string=} o.cwd working directory for the agent
76
+ * @param {string=} o.model model id (default: host's configured)
77
+ * @param {string=} o.resume session id to resume
78
+ * @param {(evt)=>void} o.onEvent receives normalized structured events
79
+ * @param {(req)=>Promise<'allow'|'deny'>} o.requestPermission
80
+ * called for EVERY tool call with { id, toolName, input, risk };
81
+ * resolve 'allow'/'deny'. (Caller implements any auto-allow policy.)
82
+ * @returns {{ sendTurn(text), abort(), end(), readonly sessionId }}
83
+ */
84
+ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermission }) {
85
+ const ac = new AbortController()
86
+ const input = makeInputStream()
87
+ let sessionId = resume || null
88
+ let closed = false
89
+
90
+ const emit = (evt) => { try { onEvent?.(evt) } catch { /* never let a consumer throw into the loop */ } }
91
+
92
+ // PreToolUse — fires on EVERY tool call (the universal gate). Classify
93
+ // risk, round-trip the decision to the room, return allow/deny.
94
+ const preTool = async (hookInput) => {
95
+ const toolName = hookInput.tool_name
96
+ const toolInput = hookInput.tool_input
97
+ const risk = classifyRisk(toolName, toolInput)
98
+ 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)
102
+ // On deny, permissionDecisionReason IS what the model receives as the
103
+ // tool error — make it a real instruction, not an opaque tag.
104
+ const denied = decision === 'deny'
105
+ return {
106
+ continue: true,
107
+ hookSpecificOutput: {
108
+ hookEventName: 'PreToolUse',
109
+ permissionDecision: denied ? 'deny' : 'allow',
110
+ permissionDecisionReason: denied
111
+ ? 'Denied by the user in the ThinkPool room. Do not retry this tool — ask what to do instead.'
112
+ : 'Approved in the ThinkPool room.',
113
+ },
114
+ }
115
+ }
116
+
117
+ const opts = {
118
+ abortController: ac,
119
+ permissionMode: 'default',
120
+ hooks: { PreToolUse: [{ hooks: [preTool] }] },
121
+ }
122
+ if (cwd) opts.cwd = cwd
123
+ if (model) opts.model = model
124
+ if (resume) opts.resume = resume
125
+
126
+ ;(async () => {
127
+ try {
128
+ for await (const m of query({ prompt: input.stream, options: opts })) {
129
+ if (closed) break
130
+ switch (m.type) {
131
+ case 'system':
132
+ if (m.session_id) sessionId = m.session_id
133
+ emit({ kind: 'system', sessionId, model: m.model || model || null })
134
+ break
135
+ case 'assistant':
136
+ emit({ kind: 'assistant', blocks: simplifyBlocks(m.message?.content) })
137
+ break
138
+ case 'user':
139
+ // tool_result blocks arrive on the user-role echo
140
+ for (const b of (m.message?.content || [])) {
141
+ if (b?.type === 'tool_result') {
142
+ emit({ kind: 'tool_result', toolUseId: b.tool_use_id, content: b.content, isError: !!b.is_error })
143
+ }
144
+ }
145
+ break
146
+ case 'result':
147
+ if (m.session_id) sessionId = m.session_id
148
+ emit({ kind: 'result', subtype: m.subtype, sessionId, costUsd: m.total_cost_usd, usage: m.usage, numTurns: m.num_turns })
149
+ break
150
+ default:
151
+ break
152
+ }
153
+ }
154
+ } catch (e) {
155
+ if (!closed) emit({ kind: 'error', message: e?.message || String(e) })
156
+ }
157
+ })()
158
+
159
+ return {
160
+ sendTurn(text) { if (!closed) input.push(String(text)) },
161
+ abort() { try { ac.abort() } catch { /* noop */ } },
162
+ end() { closed = true; input.end(); try { ac.abort() } catch { /* noop */ } },
163
+ get sessionId() { return sessionId },
164
+ }
165
+ }
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.5.1",
3
+ "version": "0.6.2",
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": {
@@ -10,6 +10,7 @@
10
10
  "node": ">=18"
11
11
  },
12
12
  "dependencies": {
13
+ "@anthropic-ai/claude-agent-sdk": "^0.3.173",
13
14
  "@supabase/supabase-js": "^2.45.0",
14
15
  "node-pty": "^1.2.0-beta.13"
15
16
  }