thinkpool-pair 0.7.47 → 0.7.49
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 +16 -2
- package/claude-session.mjs +18 -2
- package/launcher.mjs +37 -23
- package/package.json +1 -1
- package/service.mjs +29 -1
package/bridge.mjs
CHANGED
|
@@ -142,6 +142,16 @@ if (argv[0] === 'install-service' || argv[0] === 'uninstall-service') {
|
|
|
142
142
|
process.exit(0)
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
// Restart an installed boot-persistent service (re-fetches @latest, sessions resume):
|
|
146
|
+
// `thinkpool-pair restart-service [<ROOM>]` — no room → the account service.
|
|
147
|
+
if (argv[0] === 'restart-service') {
|
|
148
|
+
const arg1 = argv[1] || ''
|
|
149
|
+
const svcRoom = (!arg1 || arg1.startsWith('-')) ? null : arg1.toUpperCase().trim()
|
|
150
|
+
const svc = await import('./service.mjs')
|
|
151
|
+
svc.restartService(svcRoom)
|
|
152
|
+
process.exit(0)
|
|
153
|
+
}
|
|
154
|
+
|
|
145
155
|
// Account mode (Phase 1, docs/specs/2026-06-16-account-bridge.md): one bridge per
|
|
146
156
|
// ACCOUNT, not per room. `login` links this device; `bind <ROOM> <dir>` maps a
|
|
147
157
|
// session to a working dir on this machine; a bare invocation discovers + serves
|
|
@@ -185,8 +195,12 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
|
|
|
185
195
|
child.on('exit', (code) => process.exit(code == null ? 0 : code))
|
|
186
196
|
}),
|
|
187
197
|
serveAccountForeground: async () => { const { runAccount } = await import('./account.mjs'); await runAccount(SUPABASE_URL, SUPABASE_ANON) },
|
|
188
|
-
|
|
189
|
-
|
|
198
|
+
// install / uninstall / restart are NOT terminal anymore — they do their work,
|
|
199
|
+
// print their notes, and RETURN so the launcher loop drops you back at the menu
|
|
200
|
+
// (the service runs in its own launchd/systemd process; this launcher isn't a bridge).
|
|
201
|
+
installService: ({ room = null, agentCmd } = {}) => { svc.installService(room, agentCmd ? [agentCmd] : []) },
|
|
202
|
+
uninstallService: ({ room = null } = {}) => { svc.uninstallService(room) },
|
|
203
|
+
restartService: ({ room = null } = {}) => { svc.restartService(room) },
|
|
190
204
|
login: async () => { const { runLogin } = await import('./account.mjs'); await runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) },
|
|
191
205
|
// Provider config is NOT terminal — it writes provider.json and returns to the
|
|
192
206
|
// menu so you can pick a model, switch back, or do something else. (It used to
|
package/claude-session.mjs
CHANGED
|
@@ -357,7 +357,13 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
357
357
|
if (cwd) opts.cwd = cwd
|
|
358
358
|
if (model) opts.model = model
|
|
359
359
|
if (resume) opts.resume = resume
|
|
360
|
-
|
|
360
|
+
// Force-enable Claude Code's predicted-next-prompt. `promptSuggestions: true`
|
|
361
|
+
// alone defers to the server-side `tengu_prompt_suggestion` feature gate, which
|
|
362
|
+
// Anthropic flips on/off during rollout — so the ghost-text chip silently died
|
|
363
|
+
// mid-day with NO change on our side. CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION is the
|
|
364
|
+
// documented override (the SDK docs name `=false` as the kill switch; `=1` forces
|
|
365
|
+
// on past the gate). opts.env REPLACES the child env, so spread the inherited one.
|
|
366
|
+
opts.env = { ...(env || process.env), CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION: '1' }
|
|
361
367
|
|
|
362
368
|
// Before the SDK replays this transcript, heal any cross-provider tool blocks
|
|
363
369
|
// it left behind (e.g. a room that ran on Z.ai GLM, now back on anthropic).
|
|
@@ -532,7 +538,17 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
532
538
|
},
|
|
533
539
|
// Graceful interrupt (Esc / Stop) — stops the current turn but keeps the
|
|
534
540
|
// session alive for the next one. ac.abort() is teardown only (end()).
|
|
535
|
-
async abort() {
|
|
541
|
+
async abort() {
|
|
542
|
+
try { await q?.interrupt?.() } catch { /* noop */ }
|
|
543
|
+
// interrupt() stops the turn but emits NO terminal message. Without one the
|
|
544
|
+
// struct event stream ends on a non-terminal event, so a page refresh replays
|
|
545
|
+
// it as still "thinking" (the client's optimistic stop is in-memory only and is
|
|
546
|
+
// lost on reload). Emit a terminal result so busy clears AND persists across
|
|
547
|
+
// refresh — the client persists on `result` and structWorkingFor() treats it as
|
|
548
|
+
// done. Guarded on turnActive so we don't double-emit if the SDK already
|
|
549
|
+
// surfaced a result for the interrupt. (2026-06-22)
|
|
550
|
+
if (turnActive) { turnActive = false; emit({ kind: 'result', subtype: 'aborted', sessionId }) }
|
|
551
|
+
},
|
|
536
552
|
end() { closed = true; clearInterval(stallTimer); input.end(); try { ac.abort() } catch { /* noop */ } },
|
|
537
553
|
get sessionId() { return sessionId },
|
|
538
554
|
get mode() { return mode },
|
package/launcher.mjs
CHANGED
|
@@ -125,8 +125,10 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
125
125
|
const how = await askChoice('run it', [
|
|
126
126
|
{ label: 'As an always-on background service', hint: 'recommended — survives reboot, auto-updates' },
|
|
127
127
|
{ label: 'Here in this terminal (foreground)', hint: 'stops when you close it' },
|
|
128
|
+
{ label: '← Back to main menu', hint: '' },
|
|
128
129
|
])
|
|
129
|
-
if (how ===
|
|
130
|
+
if (how === 2) return
|
|
131
|
+
if (how === 0) { await actions.installService({ room: null }); resync() }
|
|
130
132
|
else await actions.serveAccountForeground()
|
|
131
133
|
}
|
|
132
134
|
|
|
@@ -134,15 +136,17 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
134
136
|
const what = await askChoice('install for', [
|
|
135
137
|
{ label: 'All my sessions (account)', hint: state.loggedIn ? '' : 'needs login first' },
|
|
136
138
|
{ label: 'One specific room', hint: 'no login needed' },
|
|
139
|
+
{ label: '← Back to main menu', hint: '' },
|
|
137
140
|
])
|
|
141
|
+
if (what === 2) return
|
|
138
142
|
if (what === 0) {
|
|
139
143
|
if (!state.loggedIn) { io.print('\n ' + C.yellow('Account service needs a login first (pick “Serve all my sessions”).')); return }
|
|
140
|
-
await actions.installService({ room: null }); return
|
|
144
|
+
await actions.installService({ room: null }); resync(); return
|
|
141
145
|
}
|
|
142
|
-
const room = (await io.ask(`\n Room code ${C.cyan('▸')} `)).toUpperCase().trim()
|
|
146
|
+
const room = (await io.ask(`\n Room code ${C.dim('(blank to go back)')} ${C.cyan('▸')} `)).toUpperCase().trim()
|
|
143
147
|
if (!room) { io.print(' ' + C.yellow('no room — back to menu')); return }
|
|
144
148
|
const agent = state.agents[0] || { cmd: 'claude' }
|
|
145
|
-
await actions.installService({ room, agentCmd: agent.cmd })
|
|
149
|
+
await actions.installService({ room, agentCmd: agent.cmd }); resync()
|
|
146
150
|
}
|
|
147
151
|
|
|
148
152
|
const providerMenu = async () => {
|
|
@@ -170,39 +174,49 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
170
174
|
const pick = await askChoice('\n change', [
|
|
171
175
|
{ label: 'LLM provider', hint: `current: ${state.provider}` },
|
|
172
176
|
{ label: 'Account', hint: state.loggedIn ? `linked: ${state.email || 'yes'}` : 'not linked' },
|
|
177
|
+
{ label: 'Restart the background service', hint: state.accountSvc ? 'apply the latest update now' : 'none installed' },
|
|
173
178
|
{ label: 'Remove the background service', hint: state.accountSvc ? '' : 'none installed' },
|
|
174
179
|
{ label: '← Back to main menu', hint: '' },
|
|
175
|
-
],
|
|
176
|
-
if (pick ===
|
|
180
|
+
], 5) // Enter = Back
|
|
181
|
+
if (pick === 4) return
|
|
177
182
|
if (pick === 0) await providerMenu()
|
|
178
183
|
else if (pick === 1) { await actions.login(); resync(); return }
|
|
184
|
+
else if (pick === 2) {
|
|
185
|
+
if (state.accountSvc) { await actions.restartService({ room: null }); resync() }
|
|
186
|
+
else io.print(' ' + C.yellow('no background service installed'))
|
|
187
|
+
}
|
|
179
188
|
else {
|
|
180
|
-
if (state.accountSvc) await actions.uninstallService({ room: null })
|
|
189
|
+
if (state.accountSvc) { await actions.uninstallService({ room: null }); resync() }
|
|
181
190
|
else io.print(' ' + C.yellow('no background service installed'))
|
|
182
191
|
}
|
|
183
192
|
}
|
|
184
193
|
}
|
|
185
194
|
|
|
186
195
|
// ── menu loop ──
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
196
|
+
// pairRoom re-execs + waits, and account-foreground runs forever, so those choices
|
|
197
|
+
// never return here. Everything else (install / restart / uninstall / settings /
|
|
198
|
+
// provider) just does its work, prints its notes, and RETURNS — so the loop always
|
|
199
|
+
// drops you back at the menu. Submenus carry a "← Back" so you can bail at any point.
|
|
200
|
+
// The trailing "press Enter" lands on every returning path.
|
|
191
201
|
for (;;) {
|
|
192
202
|
header()
|
|
193
203
|
io.print(' ' + C.bold('What do you want to do?') + '\n')
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
{ label: '
|
|
197
|
-
{ label: '
|
|
198
|
-
{
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
204
|
+
// Built per-render so the contextual "Restart" only appears once a service exists.
|
|
205
|
+
const items = [
|
|
206
|
+
{ key: 'pair', label: 'Pair one room now', hint: 'run here, Ctrl-C stops it' },
|
|
207
|
+
{ key: 'serve', label: 'Serve all my sessions', hint: 'account mode — needs login' },
|
|
208
|
+
{ key: 'service', label: 'Always-on background service', hint: 'survives reboot, auto-updates' },
|
|
209
|
+
]
|
|
210
|
+
if (state.accountSvc) items.push({ key: 'restart', label: 'Restart the background service', hint: 'apply the latest update now' })
|
|
211
|
+
items.push({ key: 'settings', label: 'Settings', hint: 'provider · account · restart · remove service' })
|
|
212
|
+
items.push({ key: 'quit', label: 'Quit', hint: '' })
|
|
213
|
+
const pick = items[await askChoice('choose', items)]
|
|
214
|
+
if (pick.key === 'quit') return
|
|
215
|
+
if (pick.key === 'pair') await pairRoom()
|
|
216
|
+
else if (pick.key === 'serve') await serveAccount()
|
|
217
|
+
else if (pick.key === 'service') await serviceMenu()
|
|
218
|
+
else if (pick.key === 'restart') { await actions.restartService({ room: null }); resync() }
|
|
219
|
+
else if (pick.key === 'settings') await settingsMenu()
|
|
206
220
|
await io.ask('\n ' + C.dim('press Enter to return to the menu…'))
|
|
207
221
|
}
|
|
208
222
|
}
|
package/package.json
CHANGED
package/service.mjs
CHANGED
|
@@ -126,12 +126,40 @@ export function installService(room, cmdArgs = []) {
|
|
|
126
126
|
const idleSecs = Math.max(10, parseInt(process.env.THINKPOOL_PAIR_UPDATE_IDLE, 10) || 90)
|
|
127
127
|
const autoUpdate = process.platform === 'win32'
|
|
128
128
|
? 'auto-update: a new thinkpool-pair@latest applies on the next login/restart (Windows has no in-process self-update).'
|
|
129
|
-
: `auto-update: checks npm ~every 30 min and restarts to apply a new version once sessions are idle (≥${idleSecs}s quiet; they resume in place). Not instant — to update now, restart it (
|
|
129
|
+
: `auto-update: checks npm ~every 30 min and restarts to apply a new version once sessions are idle (≥${idleSecs}s quiet; they resume in place). Not instant — to update now, restart it (pick “Restart the background service” in the menu, run 'thinkpool-pair restart-service${slug(room) === 'account' ? '' : ' ' + room}', or 'launchctl kickstart -k gui/$(id -u)/io.thinkpool.pair.${slug(room)}' on macOS).`
|
|
130
130
|
const removeArg = room ? ` ${room}` : ''
|
|
131
131
|
const what = room ? `room ${room}` : 'your account (auto-serves every session)'
|
|
132
132
|
process.stderr.write(` ◆ ${what}\n ◆ ${a.note}\n ◆ ${autoUpdate}\n ◆ logs: ${path.join(a.logDir, `${slug(room)}.log`)}\n ◆ remove with: thinkpool-pair uninstall-service${removeArg}\n\n`)
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
// Restart an already-installed boot-persistent service so it re-resolves
|
|
136
|
+
// thinkpool-pair@latest right now — the supported "apply the update now" path.
|
|
137
|
+
// launchd/systemd relaunch `npx … @latest`, so a just-published version is picked
|
|
138
|
+
// up; any active session resumes in place (the bridge restores from disk).
|
|
139
|
+
// room falsy → the ACCOUNT service. Returns true on success.
|
|
140
|
+
export function restartService(room) {
|
|
141
|
+
const plat = process.platform
|
|
142
|
+
if (!isServiceInstalled(room)) {
|
|
143
|
+
process.stderr.write(' ⚠ no background service installed to restart — install one first.\n')
|
|
144
|
+
return false
|
|
145
|
+
}
|
|
146
|
+
const id = label(room)
|
|
147
|
+
try {
|
|
148
|
+
if (plat === 'darwin') execSync(`launchctl kickstart -k gui/$(id -u)/${id}`, { stdio: 'inherit' })
|
|
149
|
+
else if (plat === 'linux') execSync(`systemctl --user restart ${id}.service`, { stdio: 'inherit' })
|
|
150
|
+
else {
|
|
151
|
+
// Windows Startup .cmd has no managed daemon to kick — guide the manual path.
|
|
152
|
+
process.stderr.write(' ⚠ Windows has no managed daemon — close the running thinkpool-pair window and re-launch the Startup .cmd (or reboot) to restart.\n')
|
|
153
|
+
return false
|
|
154
|
+
}
|
|
155
|
+
process.stderr.write(` ◆ restarted ${id} — it re-fetches thinkpool-pair@latest; any active session resumes in place.\n`)
|
|
156
|
+
return true
|
|
157
|
+
} catch (e) {
|
|
158
|
+
process.stderr.write(` ⚠ restart failed: ${e.message}\n`)
|
|
159
|
+
return false
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
135
163
|
// Is a boot-persistent service already installed? (artifact file present.)
|
|
136
164
|
// room falsy → checks the ACCOUNT service. Lets the main run skip the offer +
|
|
137
165
|
// avoid starting a second bridge that would race the service.
|