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 +95 -2
- package/claude-session.mjs +165 -0
- package/devbox/setup-devbox.sh +95 -0
- package/package.json +2 -1
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: [
|
|
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
|
|
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.
|
|
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
|
}
|