thinkpool-pair 0.4.0 → 0.5.1

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.
Files changed (2) hide show
  1. package/bridge.mjs +70 -7
  2. package/package.json +4 -2
package/bridge.mjs CHANGED
@@ -7,6 +7,14 @@
7
7
  npx thinkpool-pair <ROOM> -- <cmd…> # share a specific CLI (skip the picker)
8
8
  npx thinkpool-pair <ROOM> --headless # pure relay — terminals open from the web
9
9
 
10
+ v0.5 — continue, don't restart: when the picked agent supports
11
+ resuming (Claude Code: `--continue` = most recent conversation in
12
+ THIS directory) and a prior session exists here, the bridge offers to
13
+ continue it instead of starting fresh — quit your running agent, run
14
+ the bridge in the same directory, and you're back in the same
15
+ conversation, now shared. `--continue` / `--fresh` skip the question.
16
+ (True attach to a live process was ruled out: reptyr-style TTY
17
+ hijacking is Linux-only and SIP-blocked on macOS.)
10
18
  v0.4 — room file drops: files dropped/pasted in the web room download
11
19
  to a temp dir here (file-put → file-done) so the agent can read them.
12
20
  v0.3 — multi-terminal. The bridge is your machine's presence in the
@@ -49,8 +57,12 @@ catch {
49
57
  // Order = display order; Claude Code stays first (the original default). The
50
58
  // picker only offers the ones actually installed (PATH probe below). Power
51
59
  // users can bypass it entirely with `-- <any command>`.
60
+ // `resume` marks agents where continuing the latest session is a SAFE,
61
+ // verified flag (wrong flags kill the spawn → bridge exits — only add
62
+ // agents after checking the real CLI). probe() must be cheap + read-only;
63
+ // a miss just means "continue" isn't offered.
52
64
  const KNOWN_AGENTS = [
53
- { label: 'Claude Code', cmd: 'claude' },
65
+ { label: 'Claude Code', cmd: 'claude', resume: { args: ['--continue'], probe: claudeHasSession } },
54
66
  { label: 'Codex CLI', cmd: 'codex' },
55
67
  { label: 'Gemini CLI', cmd: 'gemini' },
56
68
  { label: 'Aider', cmd: 'aider' },
@@ -62,6 +74,16 @@ const KNOWN_AGENTS = [
62
74
  { label: 'Qwen Code', cmd: 'qwen' },
63
75
  ]
64
76
 
77
+ // Does Claude Code have a resumable conversation for THIS directory?
78
+ // Sessions live under ~/.claude/projects/<cwd with [/\.:] → '-'> (one
79
+ // subdir per session). Read-only probe; any miss = no "continue" offer.
80
+ function claudeHasSession() {
81
+ try {
82
+ const slug = path.resolve(process.cwd()).replace(/[/\\.:]/g, '-')
83
+ return fs.readdirSync(path.join(os.homedir(), '.claude', 'projects', slug)).length > 0
84
+ } catch { return false }
85
+ }
86
+
65
87
  // Is `c` on PATH? Pure-Node scan (no `which` subprocess), cross-platform.
66
88
  const onPath = (c) => {
67
89
  const exts = process.platform === 'win32'
@@ -94,11 +116,23 @@ const pickAgent = (installed) => new Promise((resolve) => {
94
116
 
95
117
  const argv = process.argv.slice(2)
96
118
  const room = (argv[0] || '').toUpperCase().trim()
97
- if (!room || room.startsWith('-')) { console.error('usage: npx thinkpool-pair <ROOM> [--headless] [-- <command…>]'); process.exit(1) }
119
+ if (!room || room.startsWith('-')) { console.error('usage: npx thinkpool-pair <ROOM> [--headless] [--continue|--fresh] [-- <command…>]'); process.exit(1) }
98
120
  const headless = argv.includes('--headless')
99
121
  const installedAgents = KNOWN_AGENTS.filter(a => onPath(a.cmd))
100
122
  const dashIdx = argv.indexOf('--')
101
- let attachedCmd = null, attachedArgs = []
123
+ // Own flags are only read from BEFORE `--`; after it, every token belongs
124
+ // to the shared command (`-- claude --continue` must not trip these).
125
+ const ownArgs = dashIdx >= 0 ? argv.slice(0, dashIdx) : argv
126
+ const forceContinue = ownArgs.includes('--continue')
127
+ const forceFresh = ownArgs.includes('--fresh')
128
+
129
+ // One yes/no on the bridge's stderr (same channel as the agent picker).
130
+ const askYesNo = (q) => new Promise((resolve) => {
131
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr })
132
+ rl.question(q, (ans) => { rl.close(); resolve(!/^n/i.test(String(ans).trim())) })
133
+ })
134
+
135
+ let attachedCmd = null, attachedArgs = [], continuing = false
102
136
  if (dashIdx >= 0) {
103
137
  ;[attachedCmd, ...attachedArgs] = argv.slice(dashIdx + 1)
104
138
  } else if (!headless) {
@@ -107,6 +141,18 @@ if (dashIdx >= 0) {
107
141
  process.exit(1)
108
142
  }
109
143
  attachedCmd = await pickAgent(installedAgents)
144
+ // Continue the latest session instead of starting fresh — offered only
145
+ // when the agent supports it AND a prior session exists in this cwd.
146
+ // --continue forces it (your call even if the probe misses); --fresh
147
+ // skips the question; non-TTY stays fresh (no surprise in scripts).
148
+ const agent = KNOWN_AGENTS.find(a => a.cmd === attachedCmd)
149
+ if (agent?.resume && !forceFresh) {
150
+ if (forceContinue) continuing = true
151
+ else if (process.stdin.isTTY && agent.resume.probe()) {
152
+ continuing = await askYesNo(`\n Continue your latest ${agent.label} session in this directory? [Y/n] `)
153
+ }
154
+ if (continuing) attachedArgs = [...agent.resume.args]
155
+ }
110
156
  }
111
157
  const name = process.env.TP_NAME || os.userInfo().username || 'host'
112
158
 
@@ -166,7 +212,15 @@ const announce = () =>
166
212
  // updir: where room file-drops land (forward-slash normalised — the web
167
213
  // client string-joins host paths onto it; Node accepts `/` on Windows).
168
214
  updir: UPDIR.split(path.sep).join('/'),
169
- agents: installedAgents,
215
+ // canResume: this agent can continue a prior session in THIS cwd —
216
+ // re-probed per announce (a fresh run creates a session, so the flag
217
+ // can flip true while the bridge is up). Functions don't survive
218
+ // JSON, so the wire shape is explicit.
219
+ agents: installedAgents.map(a => {
220
+ let canResume = false
221
+ if (a.resume) { try { canResume = a.resume.probe() } catch { /* stays false */ } }
222
+ return { label: a.label, cmd: a.cmd, canResume }
223
+ }),
170
224
  // cols/rows: the PTY's one true size — web viewers render this grid and
171
225
  // scale it to their own page instead of voting to reflow it.
172
226
  terms: [...terms.entries()].map(([id, t]) => ({ id, cmd: t.cmd, alive: true, cols: t.term.cols, rows: t.term.rows })),
@@ -278,8 +332,17 @@ channel
278
332
  // Multi-bridge rooms: a targeted open is for ONE machine. Untargeted
279
333
  // opens (older web) are taken by whoever hears them — the solo case.
280
334
  if (payload.host && payload.host !== name) return
281
- openTerm({ id: payload.id, cmd: payload.cmd })
282
- process.stderr.write(`\n ◆ web opened a "${payload.cmd}" terminal (headless).\n`)
335
+ // resume is a FLAG, never argv: the channel must not pass arbitrary
336
+ // args even though the room is shell-trust by design. The args come
337
+ // from our own KNOWN_AGENTS table, probe-gated so `--continue` with
338
+ // no prior session can't insta-kill the fresh terminal.
339
+ let args = []
340
+ if (payload.resume) {
341
+ const agent = KNOWN_AGENTS.find(a => a.cmd === payload.cmd)
342
+ if (agent?.resume) { try { if (agent.resume.probe()) args = [...agent.resume.args] } catch { /* fresh */ } }
343
+ }
344
+ openTerm({ id: payload.id, cmd: payload.cmd, args })
345
+ process.stderr.write(`\n ◆ web opened a "${payload.cmd}"${args.length ? ' (continue)' : ''} terminal (headless).\n`)
283
346
  })
284
347
  .on('broadcast', { event: 'term-close' }, ({ payload }) => {
285
348
  const t = payload?.id && terms.get(payload.id)
@@ -326,7 +389,7 @@ channel
326
389
  announce()
327
390
  process.stderr.write(headless
328
391
  ? `\n ◆ thinkpool — relaying room ${room} (headless). Open terminals from the web UI.\n\n`
329
- : `\n ◆ thinkpool — sharing "${attachedCmd}" into room ${room}. Open the web UI and you're both in.\n\n`)
392
+ : `\n ◆ thinkpool — sharing "${attachedCmd}"${continuing ? ' (continuing your latest session)' : ''} into room ${room}. Open the web UI and you're both in.\n\n`)
330
393
  }
331
394
  })
332
395
 
package/package.json CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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
  },