thinkpool-pair 0.7.199 → 0.7.200

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.
@@ -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.200",
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",