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 +2 -1
- package/agent-detect.mjs +28 -0
- package/bridge.mjs +17 -25
- package/claude-session.mjs +4 -1
- package/codex-event-mapper.mjs +43 -7
- package/package.json +2 -1
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,
|
package/agent-detect.mjs
ADDED
|
@@ -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
|
-
|
|
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
|
|
859
|
-
//
|
|
860
|
-
//
|
|
861
|
-
//
|
|
862
|
-
//
|
|
863
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
3235
|
-
//
|
|
3236
|
-
|
|
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)
|
package/claude-session.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/codex-event-mapper.mjs
CHANGED
|
@@ -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
|
|
72
|
-
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
//
|
|
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.
|
|
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",
|