thinkpool-pair 0.7.52 → 0.7.53

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
@@ -145,6 +145,16 @@ if (argv[0] === 'install-service' || argv[0] === 'uninstall-service') {
145
145
  process.exit(0)
146
146
  }
147
147
 
148
+ // Restart an installed boot-persistent service (sessions resume in place):
149
+ // `thinkpool-pair restart-service [<ROOM>]` — no room → the account service.
150
+ if (argv[0] === 'restart-service') {
151
+ const arg1 = argv[1] || ''
152
+ const svcRoom = (!arg1 || arg1.startsWith('-')) ? null : arg1.toUpperCase().trim()
153
+ const svc = await import('./service.mjs')
154
+ svc.restartService(svcRoom)
155
+ process.exit(0)
156
+ }
157
+
148
158
  // Account mode (Phase 1, docs/specs/2026-06-16-account-bridge.md): one bridge per
149
159
  // ACCOUNT, not per room. `login` links this device; `bind <ROOM> <dir>` maps a
150
160
  // session to a working dir on this machine; a bare invocation discovers + serves
@@ -188,8 +198,12 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
188
198
  child.on('exit', (code) => process.exit(code == null ? 0 : code))
189
199
  }),
190
200
  serveAccountForeground: async () => { const { runAccount } = await import('./account.mjs'); await runAccount(SUPABASE_URL, SUPABASE_ANON) },
191
- installService: ({ room = null, agentCmd } = {}) => { svc.installService(room, agentCmd ? [agentCmd] : []); process.exit(0) },
192
- uninstallService: ({ room = null } = {}) => { svc.uninstallService(room); process.exit(0) },
201
+ // install / uninstall / restart are NOT terminal — they do their work, print their
202
+ // notes, and RETURN so the launcher loop drops you back at the menu (the service
203
+ // runs in its own launchd/systemd process; this launcher isn't itself a bridge).
204
+ installService: ({ room = null, agentCmd } = {}) => { svc.installService(room, agentCmd ? [agentCmd] : []) },
205
+ uninstallService: ({ room = null } = {}) => { svc.uninstallService(room) },
206
+ restartService: ({ room = null } = {}) => { svc.restartService(room) },
193
207
  login: async () => { const { runLogin } = await import('./account.mjs'); await runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) },
194
208
  // Provider config is NOT terminal — it writes provider.json and returns to the
195
209
  // menu so you can pick a model, switch back, or do something else. (It used to
package/launcher.mjs CHANGED
@@ -123,10 +123,12 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
123
123
  }
124
124
  io.print(`\n Serving every session you host, from ${C.dim(state.cwd)}.\n`)
125
125
  const how = await askChoice('run it', [
126
- { label: 'As an always-on background service', hint: 'recommended — survives reboot, auto-updates' },
126
+ { label: 'As an always-on background service', hint: 'recommended — survives reboot, restarts on crash' },
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 === 0) await actions.installService({ room: null })
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 ? 'bounce it now — sessions resume' : 'none installed' },
173
178
  { label: 'Remove the background service', hint: state.accountSvc ? '' : 'none installed' },
174
179
  { label: '← Back to main menu', hint: '' },
175
- ], 4) // Enter = Back
176
- if (pick === 3) return
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
- // Real actions either replace the process (pairRoom re-execs + waits; serve/install
188
- // call process.exit) or run forever (account foreground), so a real "do something"
189
- // choice never returns here. Dry-run/mock actions just print + return → the loop
190
- // re-renders. The "press Enter" only lands on a no-op / back-out path.
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) does its work, prints its notes, and RETURNS — so the loop always drops
199
+ // you back at the menu. Submenus carry a "← Back" so you can bail at any point. The
200
+ // 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
- const choice = await askChoice('choose', [
195
- { label: 'Pair one room now', hint: 'run here, Ctrl-C stops it' },
196
- { label: 'Serve all my sessions', hint: 'account mode — needs login' },
197
- { label: 'Always-on background service', hint: 'survives reboot, auto-updates' },
198
- { label: 'Settings', hint: 'provider · account · remove service' },
199
- { label: 'Quit', hint: '' },
200
- ])
201
- if (choice === 4) return // Quit
202
- if (choice === 0) await pairRoom()
203
- else if (choice === 1) await serveAccount()
204
- else if (choice === 2) await serviceMenu()
205
- else if (choice === 3) await settingsMenu()
204
+ // Built per-render so the contextual "Restart" only shows 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, restarts on crash' },
209
+ ]
210
+ if (state.accountSvc) items.push({ key: 'restart', label: 'Restart the background service', hint: 'bounce it now — sessions resume' })
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.52",
3
+ "version": "0.7.53",
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": {
package/service.mjs CHANGED
@@ -144,6 +144,35 @@ export function installService(room, cmdArgs = [], { autoUpdate = false } = {})
144
144
  process.stderr.write(` ◆ ${what}\n ◆ ${a.note}\n ◆ ${updateNote}\n ◆ logs: ${path.join(a.logDir, `${slug(room)}.log`)}\n ◆ remove with: thinkpool-pair uninstall-service${removeArg}\n\n`)
145
145
  }
146
146
 
147
+ // Restart an already-installed boot-persistent service. launchd/systemd relaunch
148
+ // the supervised `npx …`, so any active session resumes in place (the bridge
149
+ // restores from disk). Use it to recover a wedged bridge, re-read provider.json,
150
+ // or — if the service was installed with --auto-update (@latest) — pull a new
151
+ // publish now. A PINNED service (the default) re-runs its same installed version.
152
+ // room falsy → the ACCOUNT service. Returns true on success.
153
+ export function restartService(room) {
154
+ const plat = process.platform
155
+ if (!isServiceInstalled(room)) {
156
+ process.stderr.write(' ⚠ no background service installed to restart — install one first.\n')
157
+ return false
158
+ }
159
+ const id = label(room)
160
+ try {
161
+ if (plat === 'darwin') execSync(`launchctl kickstart -k gui/$(id -u)/${id}`, { stdio: 'inherit' })
162
+ else if (plat === 'linux') execSync(`systemctl --user restart ${id}.service`, { stdio: 'inherit' })
163
+ else {
164
+ // Windows Startup .cmd has no managed daemon to kick — guide the manual path.
165
+ 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')
166
+ return false
167
+ }
168
+ process.stderr.write(` ◆ restarted ${id} — any active session resumes in place.\n`)
169
+ return true
170
+ } catch (e) {
171
+ process.stderr.write(` ⚠ restart failed: ${e.message}\n`)
172
+ return false
173
+ }
174
+ }
175
+
147
176
  // Is a boot-persistent service already installed? (artifact file present.)
148
177
  // room falsy → checks the ACCOUNT service. Lets the main run skip the offer +
149
178
  // avoid starting a second bridge that would race the service.