thinkpool-pair 0.7.273 → 0.7.275

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/account.mjs CHANGED
@@ -501,12 +501,11 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
501
501
  acct.on('broadcast', { event: 'restart' }, () => {
502
502
  restarting = true
503
503
  // The dashboard "Restart bridge" button ALSO updates to the newest published version
504
- // (Max, 2026-07-03). If an OS service manages this bridge, spawn a DETACHED updater
505
- // that re-pins the service to @latest and reloads it (install-service resolves the
506
- // newest version → rewrites the plist/unit → bootout+bootstrap). Detached → its own
507
- // session, so it SURVIVES this process being booted out (the reload kills us); it
508
- // inherits our env, whose PATH carries nvm's bin, so npx/node resolve — a bare launchd
509
- // PATH would NOT (the 2026-07-03 gotcha that made a manual updater silently no-op).
504
+ // (Max, 2026-07-03). Use service.mjs's authoritative registry → immutable runtime →
505
+ // one-shot reload transaction directly. Spawning `npx install-service` here used a
506
+ // disposable cache that legacy services could delete underneath the updater. The
507
+ // service primitive arms an independent launchd handoff before this process is booted
508
+ // out, so the reload survives us without a second package execution tree.
510
509
  // The reload IS the bounce; sessions resume from disk on the new version. Best-effort:
511
510
  // if there's no service or npm is unreachable, we fall through to the plain bounce, so
512
511
  // the button is never dead — and even a failed update degrades to today's behaviour.
@@ -525,8 +524,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
525
524
  try {
526
525
  const svc = await import('./service.mjs')
527
526
  if (svc.serviceActive(null)) {
528
- spawn('npx', ['-y', 'thinkpool-pair@latest', 'install-service'], { detached: true, stdio: 'ignore', env: process.env }).unref()
529
- updating = true
527
+ updating = svc.updateService(null) !== false
530
528
  }
531
529
  } catch { /* no service / spawn failed → plain bounce below */ }
532
530
  process.stderr.write(updating
@@ -99,6 +99,8 @@ export class CodexAppServerClient {
99
99
  this.pending = new Map()
100
100
  this.turnWaiters = new Map()
101
101
  this.completedTurns = new Map()
102
+ this.mcpStartup = new Map()
103
+ this.mcpWaiters = new Map()
102
104
  this.closedError = null
103
105
  this.stderrTail = ''
104
106
  }
@@ -196,6 +198,25 @@ export class CodexAppServerClient {
196
198
  }
197
199
  }
198
200
  }
201
+ if (message.method === 'mcpServer/startupStatus/updated') {
202
+ const params = message.params || {}
203
+ const threadId = String(params.threadId || '')
204
+ const name = String(params.name || '')
205
+ if (threadId && name) {
206
+ const key = `${threadId}\0${name}`
207
+ this.mcpStartup.set(key, params)
208
+ const waiter = this.mcpWaiters.get(key)
209
+ if (waiter && params.status === 'ready') {
210
+ this.mcpWaiters.delete(key)
211
+ if (waiter.timer) clearTimeout(waiter.timer)
212
+ waiter.resolve(params)
213
+ } else if (waiter && (params.status === 'failed' || params.error || params.failureReason)) {
214
+ this.mcpWaiters.delete(key)
215
+ if (waiter.timer) clearTimeout(waiter.timer)
216
+ waiter.reject(new Error(`Codex MCP server ${name} failed to start: ${params.error || params.failureReason || params.status}`))
217
+ }
218
+ }
219
+ }
199
220
  try { this.onNotification?.(message.method, message.params || {}) } catch { /* consumer isolation */ }
200
221
  }
201
222
 
@@ -208,6 +229,12 @@ export class CodexAppServerClient {
208
229
  for (const { reject } of this.turnWaiters.values()) reject(this.closedError)
209
230
  this.turnWaiters.clear()
210
231
  this.completedTurns.clear()
232
+ for (const { reject, timer } of this.mcpWaiters.values()) {
233
+ if (timer) clearTimeout(timer)
234
+ reject(this.closedError)
235
+ }
236
+ this.mcpWaiters.clear()
237
+ this.mcpStartup.clear()
211
238
  try { this.onClose?.(this.closedError) } catch { /* consumer isolation */ }
212
239
  }
213
240
 
@@ -221,6 +248,32 @@ export class CodexAppServerClient {
221
248
  return id
222
249
  }
223
250
 
251
+ waitForMcpServer({ threadId, name, timeoutMs = this.threadTimeoutMs } = {}) {
252
+ const safeThreadId = String(threadId || '')
253
+ const safeName = String(name || '')
254
+ if (!safeThreadId || !safeName) return Promise.reject(new Error('Codex MCP readiness needs a thread id and server name'))
255
+ const key = `${safeThreadId}\0${safeName}`
256
+ const current = this.mcpStartup.get(key)
257
+ if (current?.status === 'ready') return Promise.resolve(current)
258
+ if (current && (current.status === 'failed' || current.error || current.failureReason)) {
259
+ return Promise.reject(new Error(`Codex MCP server ${safeName} failed to start: ${current.error || current.failureReason || current.status}`))
260
+ }
261
+ if (this.mcpWaiters.has(key)) return this.mcpWaiters.get(key).promise
262
+ let timer = null
263
+ let resolveWaiter
264
+ let rejectWaiter
265
+ const promise = new Promise((resolve, reject) => {
266
+ resolveWaiter = resolve
267
+ rejectWaiter = reject
268
+ if (timeoutMs > 0) timer = setTimeout(() => {
269
+ this.mcpWaiters.delete(key)
270
+ reject(new Error(`Codex MCP server ${safeName} did not become ready within ${timeoutMs}ms`))
271
+ }, timeoutMs)
272
+ })
273
+ this.mcpWaiters.set(key, { promise, resolve: resolveWaiter, reject: rejectWaiter, timer })
274
+ return promise
275
+ }
276
+
224
277
  async startTurn({ threadId, input, images, model, effort, approvalPolicy, collaborationMode } = {}) {
225
278
  const result = await this.request('turn/start', {
226
279
  threadId,
package/codex-session.mjs CHANGED
@@ -352,7 +352,7 @@ function appServerItemForMapper(item) {
352
352
  * @param {string} [o.providerConfig] optional -c overrides / provider block (M2: from the provider registry)
353
353
  * @returns {{ sendTurn(text, options?), abort(), end(), readonly sessionId }}
354
354
  */
355
- export function startCodexSession({ cwd, model, effort: initialEffort = 'high', resume, env, sandbox, mode = 'default', onEvent, providerConfig, terminalRolePrompt, rolePrompt, roomContext, mcpServers, prepareCwd = null, requestPermission, appServerGate = codexAppServerGate, appServerFactory = createCodexAppServer, spawnImpl = spawn }) {
355
+ export function startCodexSession({ cwd, model, effort: initialEffort = 'high', resume, env, sandbox, mode = 'default', onEvent, providerConfig, terminalRolePrompt, rolePrompt, roomContext, mcpServers, prepareCwd = null, requestPermission, appServerGate = codexAppServerGate, appServerFactory = createCodexAppServer, mcpHttpFactory = startCodexMcpHttp, spawnImpl = spawn }) {
356
356
  let activeMode = CODEX_MODE_CONFIG[mode] ? mode : 'default'
357
357
  let modeConfig = codexConfigForMode(activeMode)
358
358
  sandbox = normalizeCodexSandbox(sandbox || modeConfig.sandbox)
@@ -424,7 +424,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
424
424
  async function ensureMcpHttp() {
425
425
  const thinkpool = mcpServers?.thinkpool
426
426
  if (!thinkpool) return null
427
- if (!mcpHttpPromise) mcpHttpPromise = startCodexMcpHttp({ sdkServer: thinkpool })
427
+ if (!mcpHttpPromise) mcpHttpPromise = mcpHttpFactory({ sdkServer: thinkpool })
428
428
  return mcpHttpPromise
429
429
  }
430
430
 
@@ -526,6 +526,12 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
526
526
  // independent ThinkPool sandbox / approval posture above.
527
527
  config: { 'features.default_mode_request_user_input': true },
528
528
  })
529
+ // App Server reports thread/start before its per-thread MCP clients finish
530
+ // initializing. Starting the first turn in that gap snapshots a toolset
531
+ // without ThinkPool even though the server becomes ready milliseconds later.
532
+ // Hold the turn boundary until the room MCP is actually ready; a timeout or
533
+ // startup failure drops into the required-MCP codex exec fallback below.
534
+ if (peer?.url) await appServer.waitForMcpServer({ threadId: sessionId, name: 'thinkpool' })
529
535
  appServerThreadReady = true
530
536
  pushMappedEvent({ type: 'thread.started', thread_id: sessionId })
531
537
  return true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.273",
3
+ "version": "0.7.275",
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
@@ -46,16 +46,12 @@ export function darwinServiceRunning(output) {
46
46
  const text = String(output || '')
47
47
  return /(^|\n)[ \t]*state = running[ \t]*($|\n)/.test(text) && /(^|\n)[ \t]*pid = \d+[ \t]*($|\n)/.test(text)
48
48
  }
49
- // Legacy auto-update services still use npx. Wipe its disposable execution project
50
- // before resolving the package so an @latest restart cannot re-serve a stale cache.
51
- // `home` is the platform-correct, runtime-expanded home token ("$HOME" on darwin under
52
- // /bin/bash; "$$HOME" inside a systemd unit so systemd emits a literal $HOME to bash).
53
- // npx caches the temporary execution project as a unit. Removing only the package can
54
- // leave its generated .bin/package-lock metadata behind, producing the contradictory
55
- // state where `npm view` sees a version but `npx` reports ETARGET or no executable.
56
- // The whole _npx directory is disposable; clear its children so the next launch is a
57
- // genuinely clean install.
58
- const cacheWipe = (home) => `rm -rf ${home}/.npm/_npx/* 2>/dev/null`
49
+ // Legacy auto-update services still use npx. They MUST use a bridge-owned cache:
50
+ // deleting ~/.npm/_npx corrupts unrelated interactive `npx thinkpool-pair` processes
51
+ // (0.7.274 removed account.mjs underneath a live launcher). The service cache is
52
+ // disposable and isolated, so clearing it cannot mutate a person's npm execution tree.
53
+ const serviceNpmCache = () => path.join(os.homedir(), '.thinkpool-pair', 'npm-cache')
54
+ const cacheWipe = (cacheRoot) => `rm -rf ${shq(path.join(cacheRoot, '_npx'))}/* 2>/dev/null`
59
55
 
60
56
  // The version installing the service. By DEFAULT the service is pinned to this exact
61
57
  // version (not @latest) so a future bad npm publish can't auto-roll to every machine's
@@ -130,6 +126,7 @@ export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate
130
126
  const logDir = path.join(os.homedir(), '.thinkpool-pair')
131
127
  const log = path.join(logDir, `${id}.log`)
132
128
  const servicePath = sanitizeServicePath(process.env.PATH || '', platform)
129
+ const npmCache = serviceNpmCache()
133
130
  const tail = cmdArgs.length ? ['--', ...cmdArgs] : []
134
131
  const desc = room ? `bridge (${room})` : 'account bridge'
135
132
  // OS-supervised tiers (launchd KeepAlive / systemd Restart) don't need --supervise.
@@ -156,7 +153,7 @@ export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate
156
153
  const args = runtimeEntry
157
154
  ? [process.execPath, runtimeEntry, ...(room ? [room] : []), ...tail]
158
155
  : staleProof
159
- ? ['/bin/bash', '-lc', `${cacheWipe('"$HOME"')}; exec ${[npx, ...pkgArgs, ...tail].map(shq).join(' ')}`]
156
+ ? ['/bin/bash', '-lc', `${cacheWipe(npmCache)}; export NPM_CONFIG_CACHE=${shq(npmCache)}; exec ${[npx, ...pkgArgs, ...tail].map(shq).join(' ')}`]
160
157
  : [npx, ...pkgArgs, ...tail]
161
158
  const file = path.join(os.homedir(), 'Library', 'LaunchAgents', `${label(room)}.plist`)
162
159
  const content = `<?xml version="1.0" encoding="UTF-8"?>
@@ -175,7 +172,7 @@ export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate
175
172
  <key>WorkingDirectory</key><string>${xml(cwd)}</string>
176
173
  <key>StandardOutPath</key><string>${xml(log)}</string>
177
174
  <key>StandardErrorPath</key><string>${xml(log)}</string>
178
- <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(servicePath)}</string>${autoUpdate ? '<key>THINKPOOL_PAIR_AUTOUPDATE</key><string>1</string>' : ''}</dict>
175
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(servicePath)}</string>${runtimeEntry ? '' : `<key>NPM_CONFIG_CACHE</key><string>${xml(npmCache)}</string>`}${autoUpdate ? '<key>THINKPOOL_PAIR_AUTOUPDATE</key><string>1</string>' : ''}</dict>
179
176
  </dict></plist>\n`
180
177
  // The destructive reload is intentionally NOT represented as an inline `post`
181
178
  // command. installService stages this plist and hands the transaction to an
@@ -193,7 +190,7 @@ export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate
193
190
  const execStart = runtimeEntry
194
191
  ? args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(' ')
195
192
  : staleProof
196
- ? `/bin/bash -lc "${cacheWipe('$$HOME')}; exec ${[npx, ...pkgArgs, ...tail].join(' ')}"`
193
+ ? `/bin/bash -lc "${cacheWipe(npmCache)}; export NPM_CONFIG_CACHE=${shq(npmCache)}; exec ${[npx, ...pkgArgs, ...tail].join(' ')}"`
197
194
  : args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(' ')
198
195
  const content = `[Unit]
199
196
  Description=ThinkPool Code ${desc}
@@ -211,7 +208,7 @@ RestartSec=2
211
208
  RestartPreventExitStatus=0
212
209
  WorkingDirectory=${cwd}
213
210
  Environment=PATH=${servicePath}
214
- ${autoUpdate ? 'Environment=THINKPOOL_PAIR_AUTOUPDATE=1\n' : ''}StandardOutput=append:${log}
211
+ ${runtimeEntry ? '' : `Environment=NPM_CONFIG_CACHE=${npmCache}\n`}${autoUpdate ? 'Environment=THINKPOOL_PAIR_AUTOUPDATE=1\n' : ''}StandardOutput=append:${log}
215
212
  StandardError=append:${log}
216
213
 
217
214
  [Install]
@@ -232,7 +229,7 @@ WantedBy=default.target
232
229
  : room
233
230
  ? ['npx', '-y', ...onlineFlag, verSpec, room, '--supervise', ...tail].join(' ')
234
231
  : ['npx', '-y', ...onlineFlag, verSpec].join(' ')
235
- const content = `@echo off\r\ntitle thinkpool-pair ${id}\r\n${inner}\r\n`
232
+ const content = `@echo off\r\ntitle thinkpool-pair ${id}\r\n${runtimeEntry ? '' : `set "NPM_CONFIG_CACHE=${npmCache}"\r\n`}${inner}\r\n`
236
233
  return { file, content, logDir, post: [], note: 'Installed to the Startup folder — runs at login' + (room ? ' with --supervise (auto-restart on crash).' : ' (account mode).') + ' Start it now without rebooting by double-clicking the .cmd, or run it from a terminal.' }
237
234
  }
238
235
 
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "bundleVersion": 4,
3
+ "bundleVersion": 5,
4
4
  "contracts": [
5
5
  {
6
6
  "id": "room-coordination",
7
- "version": 1,
7
+ "version": 2,
8
8
  "routes": [
9
9
  {
10
10
  "id": "room-awareness",
@@ -27,12 +27,16 @@
27
27
  ],
28
28
  "impact": [
29
29
  {"path": "bridge/cross-terminal.mjs"},
30
- {"path": "bridge/bridge.mjs", "diffPattern": "read_terminal|list_sessions|read_session|post_to_terminal|post_to_session"}
30
+ {"path": "bridge/bridge.mjs", "diffPattern": "read_terminal|list_sessions|read_session|post_to_terminal|post_to_session"},
31
+ {"path": "bridge/codex-session.mjs", "diffPattern": "MCP|Mcp|mcp|read_terminal|list_sessions|read_session|post_to_terminal|post_to_session"},
32
+ {"path": "bridge/codex-app-server.mjs", "diffPattern": "MCP|Mcp|mcp"}
31
33
  ],
32
34
  "evidence": [
33
35
  {"path": "bridge/cross-terminal.mjs", "pattern": "read_terminal"},
34
36
  {"path": "bridge/cross-terminal.mjs", "pattern": "post_to_session"},
35
- {"path": "bridge/bridge.mjs", "pattern": "'list_sessions'"}
37
+ {"path": "bridge/bridge.mjs", "pattern": "'list_sessions'"},
38
+ {"path": "bridge/codex-session.mjs", "pattern": "waitForMcpServer"},
39
+ {"path": "bridge/codex-app-server.mjs", "pattern": "mcpServer/startupStatus/updated"}
36
40
  ]
37
41
  },
38
42
  {