thinkpool-pair 0.7.199 → 0.7.201

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
@@ -20,6 +20,7 @@ import { publicKeyB64, announceProviders, listProviders, addProvider, addProvide
20
20
  import { supervisorServes } from './serve-consent.mjs'
21
21
  import { resolveServeDir } from './serve-dir.mjs'
22
22
  import { pairKeyFor, pairTopic, CROSSROOM_BUS } from './cross-terminal.mjs'
23
+ import { installedAgentCommands } from './agent-detect.mjs'
23
24
 
24
25
  const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return null } })()
25
26
 
@@ -557,7 +558,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
557
558
  // to spawn against — re-read each announce so an add/remove reflects immediately.
558
559
  // Both are additive; older clients ignore them (feature: multi-provider BYOK, slice 1).
559
560
  const PROVIDER_PUBKEY = publicKeyB64()
560
- const pushPresence = () => trackPresence({ name: machine, version: VERSION, rooms: [...children.keys()], refused: [...refused].map(([code, reason]) => ({ code, reason })), service: runsAsService(), providerPubKey: PROVIDER_PUBKEY, providers: announceProviders(), ts: Date.now() })
561
+ const pushPresence = () => trackPresence({ name: machine, version: VERSION, rooms: [...children.keys()], refused: [...refused].map(([code, reason]) => ({ code, reason })), service: runsAsService(), providerPubKey: PROVIDER_PUBKEY, providers: announceProviders(), agents: installedAgentCommands().map((cmd) => ({ cmd })), ts: Date.now() })
561
562
 
562
563
  // ── Provider registry — the multi-BYOK wire contract (slice 1). ─────────────
563
564
  // AUTH: the account channel `tpacct:<uid>` is created WITHOUT config.private:true,
@@ -0,0 +1,28 @@
1
+ // One installed-agent detector shared by the room bridge and account supervisor.
2
+ // Both surfaces announce the same machine; they must not disagree about which
3
+ // coding CLIs it can open merely because they publish on different channels.
4
+ import fs from 'node:fs'
5
+ import path from 'node:path'
6
+
7
+ export const KNOWN_AGENT_COMMANDS = [
8
+ 'claude', 'codex', 'gemini', 'aider', 'cursor-agent',
9
+ 'opencode', 'copilot', 'goose', 'crush', 'qwen',
10
+ ]
11
+
12
+ export function commandOnPath(command, { env = process.env, platform = process.platform, fsImpl = fs } = {}) {
13
+ const exts = platform === 'win32'
14
+ ? (env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';')
15
+ : ['']
16
+ const delimiter = platform === 'win32' ? ';' : path.delimiter
17
+ for (const dir of (env.PATH || '').split(delimiter)) {
18
+ if (!dir) continue
19
+ for (const ext of exts) {
20
+ try { if (fsImpl.existsSync(path.join(dir, command + ext))) return true } catch { /* unreadable path entry */ }
21
+ }
22
+ }
23
+ return false
24
+ }
25
+
26
+ export function installedAgentCommands(options) {
27
+ return KNOWN_AGENT_COMMANDS.filter((command) => commandOnPath(command, options))
28
+ }
package/bridge.mjs CHANGED
@@ -52,6 +52,7 @@ import { z } from 'zod'
52
52
  import { startClaudeSession } from './claude-session.mjs'
53
53
  import { startCodexSession, readCodexDefaultModel, readCodexModels, codexConfigForMode } from './codex-session.mjs'
54
54
  import { createManagedLaneWorktree, removeManagedLaneWorktree } from './lane-worktree.mjs'
55
+ import { commandOnPath } from './agent-detect.mjs'
55
56
 
56
57
  const STRUCTURED_MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
57
58
  import { FLOW_CONDUCTOR_PROMPT, FLOW_LANE_PROMPT, buildConductorEnv, assembleCrossWaveContext, buildLanePrompt } from './flow-conductor.mjs'
@@ -178,19 +179,7 @@ function claudeHasSession() {
178
179
  } catch { return false }
179
180
  }
180
181
 
181
- // Is `c` on PATH? Pure-Node scan (no `which` subprocess), cross-platform.
182
- const onPath = (c) => {
183
- const exts = process.platform === 'win32'
184
- ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';')
185
- : ['']
186
- for (const dir of (process.env.PATH || '').split(path.delimiter)) {
187
- if (!dir) continue
188
- for (const ext of exts) {
189
- try { if (fs.existsSync(path.join(dir, c + ext))) return true } catch { /* noop */ }
190
- }
191
- }
192
- return false
193
- }
182
+ const onPath = (command) => commandOnPath(command)
194
183
 
195
184
  // Pick which installed agent to share → resolves to a command string. A single
196
185
  // match (or non-TTY stdin, e.g. piped) auto-selects with no prompt.
@@ -855,15 +844,16 @@ const sessions = new Map()
855
844
  let attachedId = null
856
845
  let shuttingDown = false
857
846
 
858
- // Background warmer (2026-07-02): after a restart, proactively cold-boot the lazily-
859
- // restored IDLE terminals — ready-gated to ≤WARM_CONCURRENCY at once so there's no
860
- // boot-storm (the contention that made restore take minutes) — so they're warm BEFORE
861
- // the user clicks them, erasing the lazy-boot first-turn penalty. A user turn to a
862
- // still-cold terminal boots it on demand as the fallback. warmComplete frees a slot when
863
- // a terminal's init `system` event proves it live (called from onEvent).
847
+ // Background warming is OPT-IN. Even a per-room cap multiplies under the account
848
+ // supervisor: four rooms at concurrency 3 launched twelve Claude SDK cold boots at once,
849
+ // stretching measured readiness from ~15s to 40–66s and leaving every idle process
850
+ // resident. Restored transcripts already paint immediately and sendTurn boots a cold lane
851
+ // on demand, so the safe default is zero speculative runtimes. TP_BACKGROUND_WARM=1 keeps
852
+ // the old behaviour available for a deliberately single-room host.
864
853
  const warmQueue = []
865
854
  let warmingCount = 0
866
855
  const WARM_CONCURRENCY = 3
856
+ const BACKGROUND_WARM_ENABLED = process.env.TP_BACKGROUND_WARM === '1'
867
857
  function pumpWarm() {
868
858
  while (warmingCount < WARM_CONCURRENCY && warmQueue.length) {
869
859
  const e = warmQueue.shift()
@@ -2704,7 +2694,10 @@ channel
2704
2694
  // through to the SDK default when absent. Stamped on the entry so the announce
2705
2695
  // carries it at open (chip shows the model immediately, not the branch).
2706
2696
  const runtime = structuredRuntime(payload.cmd)
2707
- openStructured({ id: payload.id, runtime, mode: payload.mode, model: payload.model || undefined, provider: runtime === 'codex' ? undefined : payload.provider || undefined })
2697
+ // Opening a tab is not work. Paint and announce it immediately, then cold-boot the
2698
+ // expensive Claude/Codex runtime on its first real turn. This keeps a fresh terminal
2699
+ // instant and prevents abandoned tabs from becoming permanent resident processes.
2700
+ openStructured({ id: payload.id, runtime, mode: payload.mode, model: payload.model || undefined, provider: runtime === 'codex' ? undefined : payload.provider || undefined, defer: true })
2708
2701
  process.stderr.write(`\n ◆ web opened a structured "${payload.cmd}" session (${payload.mode || 'default'}${payload.model ? `, model ${payload.model}` : ''}).\n`)
2709
2702
  return
2710
2703
  }
@@ -2840,7 +2833,7 @@ channel
2840
2833
  // ── structured-session control (Phase 2) ──
2841
2834
  .on('broadcast', { event: 'code-open' }, ({ payload }) => {
2842
2835
  if (payload?.host && payload.host !== name) return
2843
- openStructured({ id: payload?.id || randomUUID(), runtime: payload?.runtime, model: payload?.model, provider: payload?.runtime === 'codex' ? undefined : payload?.provider, resume: payload?.resume, mode: payload?.mode })
2836
+ openStructured({ id: payload?.id || randomUUID(), runtime: payload?.runtime, model: payload?.model, provider: payload?.runtime === 'codex' ? undefined : payload?.provider, resume: payload?.resume, mode: payload?.mode, defer: true })
2844
2837
  })
2845
2838
  .on('broadcast', { event: 'code-turn' }, async ({ payload }) => {
2846
2839
  const s = payload?.term && sessions.get(payload.term)
@@ -3231,10 +3224,9 @@ channel
3231
3224
  // saved-session restore above still runs unconditionally, so a bridge
3232
3225
  // restart resumes backgrounded sessions regardless of this guard.
3233
3226
  else if (attachedCmd) openStructured({ id: randomUUID(), runtime: structuredRuntime(attachedCmd) })
3234
- // Background-warm the lazily-restored idle terminals shortly after restore, so
3235
- // they're ready before the user clicks them (no first-turn penalty). Delayed so the
3236
- // eagerly-booted (mid-turn / auto-resumed) sessions get the machine to themselves first.
3237
- setTimeout(() => {
3227
+ // Optional compatibility lever for deliberately single-room hosts. Account-mode
3228
+ // and normal standalone launches leave restored idle lanes truly lazy by default.
3229
+ if (BACKGROUND_WARM_ENABLED) setTimeout(() => {
3238
3230
  for (const [, e] of sessions) if (e.deferred && !e.warmed && !e.warming && !e.session?.started) warmQueue.push(e)
3239
3231
  if (warmQueue.length) { process.stderr.write(`\n ◆ background-warming ${warmQueue.length} idle terminal(s) (≤${WARM_CONCURRENCY} at a time)…\n`); pumpWarm() }
3240
3232
  }, 6000)
@@ -239,7 +239,9 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
239
239
  // long time"; settingSources:['user','project','local'] boots every host MCP
240
240
  // server + SessionStart hook + plugin before init. This is the A-side baseline;
241
241
  // open a terminal with TP_MCP_STRICT=1 (below) for the B-side to isolate MCP's share.
242
- const spawnT0 = Date.now()
242
+ // A lazy lane may sit unopened for hours. Start the latency clock when query() really
243
+ // launches, not when the lightweight tab/session wrapper is created.
244
+ let spawnT0 = null
243
245
  let readyLogged = false
244
246
  let modelsSent = false // one-shot: emit the SDK's supported-model list on first init
245
247
  let sdkToolsChecked = false // one-shot: run the AskUserQuestion regression guard on the first init carrying tools[]
@@ -804,6 +806,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
804
806
  }
805
807
 
806
808
  const runQuery = async () => {
809
+ if (spawnT0 == null) spawnT0 = Date.now()
807
810
  started = true
808
811
  // Per-query AbortController (child of the session `ac`): lets a model switch kill
809
812
  // just this query. Resolve the previous qDone in case it's still pending.
@@ -28,16 +28,27 @@
28
28
  // the existing risk classifier (claude-session.classifyRisk) and room rendering
29
29
  // apply unchanged. Codex v1 is observe-only (no per-tool card) — classification
30
30
  // is for display accent + future gating, not blocking (spec §tradeoff).
31
+ import fs from 'node:fs'
32
+
33
+ const safeMcpPart = (value) => String(value || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_')
34
+ const mcpToolName = (item) => `mcp__${safeMcpPart(item?.server)}__${safeMcpPart(item?.tool)}`
35
+ const resultText = (value) => {
36
+ if (typeof value === 'string') return value
37
+ if (value == null) return ''
38
+ try { return JSON.stringify(value) } catch { return String(value) }
39
+ }
31
40
 
32
41
  export class CodexEventMapper {
33
42
  /**
34
43
  * @param {{onEvent:(evt)=>void, model?:string|null, sessionId?:string|null}} opts
35
44
  */
36
- constructor({ onEvent, model = null, sessionId = null }) {
45
+ constructor({ onEvent, model = null, sessionId = null, fsImpl = fs }) {
37
46
  this._onEvent = onEvent
38
47
  this._model = model || null
39
48
  this._sessionId = sessionId || null
40
49
  this._starts = new Map() // item.id → startMs (for durationMs)
50
+ this._fileBefore = new Map() // item.id → [{ path, kind, content }]
51
+ this._fs = fsImpl
41
52
  this._systemSent = false
42
53
  }
43
54
 
@@ -68,10 +79,18 @@ export class CodexEventMapper {
68
79
  if (it.type === 'command_execution') {
69
80
  this._emit({ kind: 'assistant', blocks: [{ type: 'tool_use', id: it.id, name: 'Bash', input: { command: it.command || '' } }], parentToolUseId: null })
70
81
  } else if (it.type === 'file_change') {
71
- const c = (it.changes && it.changes[0]) || {}
72
- this._emit({ kind: 'assistant', blocks: [{ type: 'tool_use', id: it.id, name: c.kind === 'add' ? 'Write' : 'Edit', input: { path: c.path || '' } }], parentToolUseId: null })
82
+ const before = (it.changes || []).map((c) => {
83
+ let content = ''
84
+ if (c.kind !== 'add' && c.path) {
85
+ try { content = this._fs.readFileSync(c.path, 'utf8') } catch { /* missing/unreadable → empty */ }
86
+ }
87
+ return { path: c.path || '', kind: c.kind || 'update', content }
88
+ })
89
+ this._fileBefore.set(it.id, before)
73
90
  } else if (it.type === 'web_search') {
74
91
  this._emit({ kind: 'assistant', blocks: [{ type: 'tool_use', id: it.id, name: 'WebSearch', input: { query: it.query || '' } }], parentToolUseId: null })
92
+ } else if (it.type === 'mcp_tool_call') {
93
+ this._emit({ kind: 'assistant', blocks: [{ type: 'tool_use', id: it.id, name: mcpToolName(it), input: it.arguments || {} }], parentToolUseId: null })
75
94
  }
76
95
  // agent_message / reasoning have no item.started (burst on completion).
77
96
  return
@@ -92,13 +111,30 @@ export class CodexEventMapper {
92
111
  const bad = it.exit_code != null ? it.exit_code !== 0 : false
93
112
  this._emit({ kind: 'tool_result', toolUseId: it.id, content: [{ type: 'text', text: it.aggregated_output || '' }], isError: bad, durationMs, parentToolUseId: null })
94
113
  } else if (it.type === 'file_change') {
95
- const c = (it.changes && it.changes[0]) || {}
96
- const verb = c.kind === 'add' ? 'wrote' : 'edited'
97
- this._emit({ kind: 'tool_result', toolUseId: it.id, content: [{ type: 'text', text: `${verb} ${c.path || ''}` }], isError: false, durationMs, parentToolUseId: null })
114
+ const before = this._fileBefore.get(it.id) || (it.changes || []).map((c) => ({ path: c.path || '', kind: c.kind || 'update', content: '' }))
115
+ this._fileBefore.delete(it.id)
116
+ const edits = before.map((c) => {
117
+ let next = ''
118
+ if (c.kind !== 'delete' && c.path) {
119
+ try { next = this._fs.readFileSync(c.path, 'utf8') } catch { /* deleted/unreadable → empty */ }
120
+ }
121
+ return { file_path: c.path, path: c.path, old_string: c.content, new_string: next, kind: c.kind }
122
+ })
123
+ const first = edits[0] || { file_path: '', path: '', old_string: '', new_string: '', kind: 'update' }
124
+ const name = edits.length > 1 ? 'MultiEdit' : first.kind === 'add' ? 'Write' : 'Edit'
125
+ const input = name === 'MultiEdit'
126
+ ? { edits }
127
+ : name === 'Write'
128
+ ? { file_path: first.file_path, path: first.path, content: first.new_string }
129
+ : first
130
+ this._emit({ kind: 'assistant', blocks: [{ type: 'tool_use', id: it.id, name, input }], parentToolUseId: null })
131
+ this._emit({ kind: 'tool_result', toolUseId: it.id, content: [{ type: 'text', text: `${name === 'Write' ? 'wrote' : 'edited'} ${first.path}` }], isError: false, durationMs, parentToolUseId: null })
98
132
  } else if (it.type === 'web_search') {
99
133
  this._emit({ kind: 'tool_result', toolUseId: it.id, content: [{ type: 'text', text: it.query || 'search completed' }], isError: false, durationMs, parentToolUseId: null })
134
+ } else if (it.type === 'mcp_tool_call') {
135
+ this._emit({ kind: 'tool_result', toolUseId: it.id, content: [{ type: 'text', text: it.error?.message || resultText(it.result) }], isError: it.status === 'failed' || !!it.error, durationMs, parentToolUseId: null })
100
136
  }
101
- // mcp_tool_call / plan_update remain additive future event shapes. Unknown
137
+ // plan_update remains an additive future event shape. Unknown
102
138
  // items are ignored rather than risking a malformed transcript row.
103
139
  return
104
140
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.199",
3
+ "version": "0.7.201",
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": {
@@ -20,6 +20,7 @@
20
20
  "turn-stall.mjs",
21
21
  "update-gate.mjs",
22
22
  "agent-notify.mjs",
23
+ "agent-detect.mjs",
23
24
  "event-id.mjs",
24
25
  "plan-meters.mjs",
25
26
  "recap.mjs",