thinkpool-pair 0.7.193 → 0.7.196
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 +15 -5
- package/codex-session.mjs +33 -12
- package/package.json +1 -1
- package/service.mjs +55 -13
package/bridge.mjs
CHANGED
|
@@ -50,7 +50,9 @@ import { validateProviderSwitch, providerSwitchPlan, BUILTIN_PROVIDER } from './
|
|
|
50
50
|
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
|
|
51
51
|
import { z } from 'zod'
|
|
52
52
|
import { startClaudeSession } from './claude-session.mjs'
|
|
53
|
-
import { startCodexSession, readCodexDefaultModel, readCodexModels } from './codex-session.mjs'
|
|
53
|
+
import { startCodexSession, readCodexDefaultModel, readCodexModels, codexConfigForMode } from './codex-session.mjs'
|
|
54
|
+
|
|
55
|
+
const STRUCTURED_MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
|
|
54
56
|
import { FLOW_CONDUCTOR_PROMPT, FLOW_LANE_PROMPT, buildConductorEnv, assembleCrossWaveContext, buildLanePrompt } from './flow-conductor.mjs'
|
|
55
57
|
import { normalizePlanOutput, laneModelFor } from './flow-task-graph.mjs' // FL-B1 — validate the conductor's submit_flow_plan task-graph; laneModelFor — per-slice model tier (2026-07-03-flow-lane-model-tiers)
|
|
56
58
|
// S1 (context-offload) — durable digest store. mark_flow_done digests a closed slice in;
|
|
@@ -1050,7 +1052,7 @@ const announce = () => {
|
|
|
1050
1052
|
// turnActive). Authoritative — the bridge owns the turn — so the /code
|
|
1051
1053
|
// dashboard reads "N agents working" off the roster instead of sniffing
|
|
1052
1054
|
// the live code-event stream (which a passive dashboard channel misses).
|
|
1053
|
-
...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, busy: s.session?.turnActive ?? false, commands: s.commands, mode: s.mode || undefined, name: termNames[id] || undefined, model: s.model || undefined, ...(s.runtime === 'codex' ? { approvalPolicy:
|
|
1055
|
+
...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, busy: s.session?.turnActive ?? false, commands: s.commands, mode: s.mode || undefined, name: termNames[id] || undefined, model: s.model || undefined, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [] } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
|
|
1054
1056
|
// provider: the registered LLM-provider this lane runs on, NAME-ONLY {id,name}
|
|
1055
1057
|
// (NEVER the key or baseUrl). Additive; older clients ignore it. Omitted for the
|
|
1056
1058
|
// built-in/default Claude path (no badge). Makes the lane's provider badge +
|
|
@@ -1546,7 +1548,7 @@ function worktreeSnapshot(cwd) {
|
|
|
1546
1548
|
function openStructured({ id, runtime = 'claude', model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage }) {
|
|
1547
1549
|
if (sessions.has(id)) return
|
|
1548
1550
|
runtime = runtime === 'codex' ? 'codex' : 'claude'
|
|
1549
|
-
|
|
1551
|
+
mode = STRUCTURED_MODES.has(mode) ? mode : 'default'
|
|
1550
1552
|
// The lane's EFFECTIVE model — computed ONCE and used for BOTH the truthful display
|
|
1551
1553
|
// label (entry.model) and the SDK's `model` option below. These used to be computed
|
|
1552
1554
|
// separately: the label resolved the provider's configured model while the SDK got the
|
|
@@ -2963,9 +2965,17 @@ channel
|
|
|
2963
2965
|
})
|
|
2964
2966
|
.on('broadcast', { event: 'code-mode' }, ({ payload }) => {
|
|
2965
2967
|
const s = payload?.term && sessions.get(payload.term)
|
|
2966
|
-
if (s) {
|
|
2968
|
+
if (s && STRUCTURED_MODES.has(payload.mode)) {
|
|
2967
2969
|
if (s.runtime === 'codex') {
|
|
2968
|
-
s.mode
|
|
2970
|
+
if (!s.session.setMode(payload.mode)) {
|
|
2971
|
+
const evt = { kind: 'control', text: 'finish or stop the current Codex turn before switching permissions' }
|
|
2972
|
+
pushLog(s, evt)
|
|
2973
|
+
bcast('code-event', { term: payload.term, evt })
|
|
2974
|
+
announce()
|
|
2975
|
+
return
|
|
2976
|
+
}
|
|
2977
|
+
s.mode = payload.mode
|
|
2978
|
+
s.flush?.()
|
|
2969
2979
|
announce()
|
|
2970
2980
|
return
|
|
2971
2981
|
}
|
package/codex-session.mjs
CHANGED
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
// no tool_use↔function-call loss. Auth = the HOST's `codex login` (ChatGPT,
|
|
11
11
|
// flat sub) by default; BYOK via env CODEX_API_KEY works too (exec-only).
|
|
12
12
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
13
|
+
// `codex exec` is non-interactive, so ThinkPool maps its shared permission modes
|
|
14
|
+
// onto Codex's native sandbox + approval policy. A mode change starts a fresh
|
|
15
|
+
// Codex thread so the new sandbox actually takes effect; the visible transcript
|
|
16
|
+
// remains in ThinkPool.
|
|
17
17
|
//
|
|
18
18
|
// ONE-SHOT-PER-TURN model: unlike Claude's long-lived query(), `codex exec` is
|
|
19
19
|
// one prompt → run → exit. sendTurn spawns an exec; continuity across turns uses
|
|
@@ -30,7 +30,17 @@ import readline from 'node:readline'
|
|
|
30
30
|
import { CodexEventMapper } from './codex-event-mapper.mjs'
|
|
31
31
|
|
|
32
32
|
const DEFAULT_SANDBOX = 'workspace-write'
|
|
33
|
-
const SAFE_SANDBOXES = new Set(['read-only', 'workspace-write'])
|
|
33
|
+
const SAFE_SANDBOXES = new Set(['read-only', 'workspace-write', 'danger-full-access'])
|
|
34
|
+
export const CODEX_MODE_CONFIG = {
|
|
35
|
+
default: { sandbox: 'workspace-write', approvalPolicy: 'untrusted' },
|
|
36
|
+
acceptEdits: { sandbox: 'workspace-write', approvalPolicy: 'on-request' },
|
|
37
|
+
plan: { sandbox: 'read-only', approvalPolicy: 'never' },
|
|
38
|
+
bypassPermissions: { sandbox: 'danger-full-access', approvalPolicy: 'never' },
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function codexConfigForMode(mode) {
|
|
42
|
+
return CODEX_MODE_CONFIG[mode] || CODEX_MODE_CONFIG.default
|
|
43
|
+
}
|
|
34
44
|
|
|
35
45
|
export function readCodexDefaultModel({ home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), fsImpl = fs } = {}) {
|
|
36
46
|
try {
|
|
@@ -64,7 +74,7 @@ export function buildCodexPrompt({ text, rolePrompt, roomContext }) {
|
|
|
64
74
|
return `<thinkpool-context>\n${additions.join('\n\n')}\n</thinkpool-context>\n\n${String(text ?? '')}`
|
|
65
75
|
}
|
|
66
76
|
|
|
67
|
-
export function buildCodexExecArgs({ sessionId, model, sandbox, providerConfig, prompt }) {
|
|
77
|
+
export function buildCodexExecArgs({ sessionId, model, sandbox, approvalPolicy = 'untrusted', providerConfig, prompt }) {
|
|
68
78
|
const safeSandbox = normalizeCodexSandbox(sandbox)
|
|
69
79
|
const args = sessionId
|
|
70
80
|
// `codex exec resume` deliberately has no --sandbox option. The resumed
|
|
@@ -73,6 +83,7 @@ export function buildCodexExecArgs({ sessionId, model, sandbox, providerConfig,
|
|
|
73
83
|
? ['exec', 'resume', '--json', '--skip-git-repo-check']
|
|
74
84
|
: ['exec', '--json', '--sandbox', safeSandbox, '--skip-git-repo-check']
|
|
75
85
|
if (model) args.push('-m', model)
|
|
86
|
+
args.push('-c', `approval_policy="${approvalPolicy}"`)
|
|
76
87
|
if (providerConfig) args.push('-c', providerConfig)
|
|
77
88
|
if (sessionId) args.push(sessionId)
|
|
78
89
|
args.push(String(prompt ?? ''))
|
|
@@ -91,8 +102,10 @@ export function buildCodexExecArgs({ sessionId, model, sandbox, providerConfig,
|
|
|
91
102
|
* @param {string} [o.providerConfig] optional -c overrides / provider block (M2: from the provider registry)
|
|
92
103
|
* @returns {{ sendTurn(text), abort(), end(), readonly sessionId }}
|
|
93
104
|
*/
|
|
94
|
-
export function startCodexSession({ cwd, model, resume, env, sandbox =
|
|
95
|
-
|
|
105
|
+
export function startCodexSession({ cwd, model, resume, env, sandbox, mode = 'default', onEvent, providerConfig, rolePrompt, roomContext }) {
|
|
106
|
+
let activeMode = CODEX_MODE_CONFIG[mode] ? mode : 'default'
|
|
107
|
+
let modeConfig = codexConfigForMode(activeMode)
|
|
108
|
+
sandbox = normalizeCodexSandbox(sandbox || modeConfig.sandbox)
|
|
96
109
|
const childEnv = { ...process.env, ...(env || {}) }
|
|
97
110
|
let sessionId = resume || null
|
|
98
111
|
let turnNo = resume ? 1 : 0
|
|
@@ -115,6 +128,7 @@ export function startCodexSession({ cwd, model, resume, env, sandbox = DEFAULT_S
|
|
|
115
128
|
sessionId: turnNo === 0 ? null : sessionId,
|
|
116
129
|
model: activeModel,
|
|
117
130
|
sandbox,
|
|
131
|
+
approvalPolicy: modeConfig.approvalPolicy,
|
|
118
132
|
providerConfig,
|
|
119
133
|
prompt,
|
|
120
134
|
})
|
|
@@ -214,10 +228,17 @@ export function startCodexSession({ cwd, model, resume, env, sandbox = DEFAULT_S
|
|
|
214
228
|
mapper.setModel(activeModel)
|
|
215
229
|
return true
|
|
216
230
|
},
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
231
|
+
setMode(nextMode) {
|
|
232
|
+
if (turnActive || !CODEX_MODE_CONFIG[nextMode]) return false
|
|
233
|
+
activeMode = nextMode
|
|
234
|
+
modeConfig = codexConfigForMode(activeMode)
|
|
235
|
+
sandbox = modeConfig.sandbox
|
|
236
|
+
// Resume retains the original sandbox. Start a fresh native thread on the
|
|
237
|
+
// next turn so the selected mode is real, while ThinkPool keeps the lane log.
|
|
238
|
+
sessionId = null
|
|
239
|
+
turnNo = 0
|
|
240
|
+
return true
|
|
241
|
+
},
|
|
221
242
|
clearContext() {
|
|
222
243
|
if (turnActive) return false
|
|
223
244
|
sessionId = null
|
package/package.json
CHANGED
package/service.mjs
CHANGED
|
@@ -66,9 +66,30 @@ function npxPath(platform) {
|
|
|
66
66
|
try { fs.accessSync(cand); return cand } catch { return platform === 'win32' ? 'npx.cmd' : 'npx' }
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
function npmPath(platform = process.platform) {
|
|
70
|
+
const bin = path.dirname(process.execPath)
|
|
71
|
+
const cand = path.join(bin, platform === 'win32' ? 'npm.cmd' : 'npm')
|
|
72
|
+
try { fs.accessSync(cand); return cand } catch { return platform === 'win32' ? 'npm.cmd' : 'npm' }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Persistent services never execute through npx. Provision an immutable, versioned
|
|
76
|
+
// runtime from the tarball URL (which bypasses stale npm packument metadata), then point
|
|
77
|
+
// launchd/systemd directly at its bridge.mjs with the current Node binary.
|
|
78
|
+
export function provisionRuntime(version, { exec = execSync } = {}) {
|
|
79
|
+
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(version))) throw new Error(`invalid runtime version: ${version}`)
|
|
80
|
+
const dir = path.join(os.homedir(), '.thinkpool-pair', 'runtimes', String(version))
|
|
81
|
+
const entry = path.join(dir, 'node_modules', 'thinkpool-pair', 'bridge.mjs')
|
|
82
|
+
if (fs.existsSync(entry)) return entry
|
|
83
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
84
|
+
const tarball = `https://registry.npmjs.org/thinkpool-pair/-/thinkpool-pair-${version}.tgz`
|
|
85
|
+
exec(`${shq(npmPath())} install --prefix ${shq(dir)} ${shq(tarball)} --prefer-online --no-audit --no-fund`, { stdio: 'inherit', shell: '/bin/bash' })
|
|
86
|
+
if (!fs.existsSync(entry)) throw new Error(`runtime install completed without ${entry}`)
|
|
87
|
+
return entry
|
|
88
|
+
}
|
|
89
|
+
|
|
69
90
|
// Pure artifact builder — returned shape is testable without side effects.
|
|
70
91
|
// room falsy → ACCOUNT service (bare `thinkpool-pair`, auto-serves all sessions).
|
|
71
|
-
export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate = false, version = VERSION, staleProof = false } = {}) {
|
|
92
|
+
export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate = false, version = VERSION, staleProof = false, runtimeEntry = null } = {}) {
|
|
72
93
|
const npx = npxPath(platform)
|
|
73
94
|
const cwd = process.cwd()
|
|
74
95
|
const id = slug(room)
|
|
@@ -97,7 +118,9 @@ export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate
|
|
|
97
118
|
if (platform === 'darwin') {
|
|
98
119
|
// launchd KeepAlive supervises. staleProof → wrap in a cache-wiping /bin/bash launch so
|
|
99
120
|
// every (re)start re-fetches the pinned version fresh (deliberate-update path).
|
|
100
|
-
const args =
|
|
121
|
+
const args = runtimeEntry
|
|
122
|
+
? [process.execPath, runtimeEntry, ...(room ? [room] : []), ...tail]
|
|
123
|
+
: staleProof
|
|
101
124
|
? ['/bin/bash', '-lc', `${cacheWipe('"$HOME"')}; exec ${[npx, ...pkgArgs, ...tail].map(shq).join(' ')}`]
|
|
102
125
|
: [npx, ...pkgArgs, ...tail]
|
|
103
126
|
const file = path.join(os.homedir(), 'Library', 'LaunchAgents', `${label(room)}.plist`)
|
|
@@ -153,11 +176,15 @@ export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate
|
|
|
153
176
|
}
|
|
154
177
|
|
|
155
178
|
if (platform === 'linux') {
|
|
156
|
-
const args =
|
|
179
|
+
const args = runtimeEntry
|
|
180
|
+
? [process.execPath, runtimeEntry, ...(room ? [room] : []), ...tail]
|
|
181
|
+
: [npx, ...pkgArgs, ...tail] // systemd Restart=always supervises
|
|
157
182
|
const file = path.join(os.homedir(), '.config', 'systemd', 'user', `${label(room)}.service`)
|
|
158
183
|
// staleProof → cache-wiping /bin/bash launch (same intent as darwin). `$$HOME` so systemd
|
|
159
184
|
// emits a literal $HOME to bash rather than expanding it itself (empty + warning).
|
|
160
|
-
const execStart =
|
|
185
|
+
const execStart = runtimeEntry
|
|
186
|
+
? args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(' ')
|
|
187
|
+
: staleProof
|
|
161
188
|
? `/bin/bash -lc "${cacheWipe('$$HOME')}; exec ${[npx, ...pkgArgs, ...tail].join(' ')}"`
|
|
162
189
|
: args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(' ')
|
|
163
190
|
const content = `[Unit]
|
|
@@ -192,7 +219,9 @@ WantedBy=default.target
|
|
|
192
219
|
// so it gets login-persistence only — no in-process crash-restart on Windows.
|
|
193
220
|
const startup = path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
|
|
194
221
|
const file = path.join(startup, `thinkpool-pair-${id}.cmd`)
|
|
195
|
-
const inner =
|
|
222
|
+
const inner = runtimeEntry
|
|
223
|
+
? [process.execPath, runtimeEntry, ...(room ? [room, '--supervise'] : []), ...tail].map((s) => `"${s}"`).join(' ')
|
|
224
|
+
: room
|
|
196
225
|
? ['npx', '-y', ...onlineFlag, verSpec, room, '--supervise', ...tail].join(' ')
|
|
197
226
|
: ['npx', '-y', ...onlineFlag, verSpec].join(' ')
|
|
198
227
|
const content = `@echo off\r\ntitle thinkpool-pair ${id}\r\n${inner}\r\n`
|
|
@@ -205,12 +234,18 @@ WantedBy=default.target
|
|
|
205
234
|
// Blocking sleep (installService runs synchronously from the interactive menu; the
|
|
206
235
|
// user is watching, so we WANT to wait + report the real outcome, not fire-and-forget).
|
|
207
236
|
const sleepMs = (ms) => { try { execSync(`sleep ${(ms / 1000).toFixed(2)}`) } catch { /* noop */ } }
|
|
208
|
-
//
|
|
209
|
-
//
|
|
237
|
+
// Parse both legacy npx commands (`thinkpool-pair@<ver>`) and stable runtime commands
|
|
238
|
+
// (`.../runtimes/<ver>/...`).
|
|
239
|
+
export function parseRunningPairVersions(out) {
|
|
240
|
+
const versions = String(out).match(/(?:thinkpool-pair@|runtimes\/)(\d+\.\d+\.\d+)/g) || []
|
|
241
|
+
return new Set(versions.map((s) => s.match(/\d+\.\d+\.\d+/)?.[0]).filter(Boolean))
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Which thinkpool-pair versions are running right now. Returns a Set of versions.
|
|
210
245
|
const runningPairVersions = () => {
|
|
211
246
|
try {
|
|
212
|
-
const out = execSync(
|
|
213
|
-
return
|
|
247
|
+
const out = execSync('ps ax -o command= 2>/dev/null', { encoding: 'utf8', shell: '/bin/bash' })
|
|
248
|
+
return parseRunningPairVersions(out)
|
|
214
249
|
} catch { return new Set() }
|
|
215
250
|
}
|
|
216
251
|
const darwinSvcLoaded = (lbl) => { try { execSync(`launchctl print gui/$(id -u)/${lbl}`, { stdio: 'ignore' }); return true } catch { return false } }
|
|
@@ -299,7 +334,14 @@ export function sweepExistingServices(keepLabel, platform = process.platform) {
|
|
|
299
334
|
// pinned version is fetched fresh on every (re)start. buildArtifact keeps staleProof=false
|
|
300
335
|
// so its unit tests assert the pure pinned shape.
|
|
301
336
|
export function installService(room, cmdArgs = [], { autoUpdate = false, version = VERSION, staleProof = true } = {}) {
|
|
302
|
-
|
|
337
|
+
// Pinned services get a stable local runtime. The legacy @latest opt-in keeps its
|
|
338
|
+
// existing npx behavior until it resolves a concrete version through updateService.
|
|
339
|
+
let runtimeEntry = null
|
|
340
|
+
if (!autoUpdate) {
|
|
341
|
+
try { runtimeEntry = provisionRuntime(version) }
|
|
342
|
+
catch (e) { process.stderr.write(` ⚠ runtime install failed; service left unchanged: ${e.message}\n`); return false }
|
|
343
|
+
}
|
|
344
|
+
const a = buildArtifact(process.platform, { room, cmdArgs, autoUpdate, version, staleProof, runtimeEntry })
|
|
303
345
|
// F4: clear every OTHER pre-existing pair service first, so only the one we install remains
|
|
304
346
|
// (the "two account services live" storm trigger). Our own label is bootout+bootstrapped
|
|
305
347
|
// by a.post below, so keep it.
|
|
@@ -323,10 +365,11 @@ export function installService(room, cmdArgs = [], { autoUpdate = false, version
|
|
|
323
365
|
? (process.platform === 'win32'
|
|
324
366
|
? 'auto-update ON: a new thinkpool-pair@latest applies on the next login/restart (Windows has no in-process self-update).'
|
|
325
367
|
: `auto-update ON: checks npm ~every 30 min and restarts to apply a new @latest once sessions are idle (≥${idleSecs}s quiet; they resume in place).`)
|
|
326
|
-
: `PINNED to ${version}
|
|
368
|
+
: `PINNED to ${version} (stable local runtime; restarts do not depend on npx or the network). To update: use "Restart & update the bridge" (or re-run install-service from a newer thinkpool-pair). Pass --auto-update to track @latest instead.`
|
|
327
369
|
const removeArg = room ? ` ${room}` : ''
|
|
328
370
|
const what = room ? `room ${room}` : 'your account (auto-serves every session)'
|
|
329
371
|
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`)
|
|
372
|
+
return true
|
|
330
373
|
}
|
|
331
374
|
|
|
332
375
|
// Restart an already-installed boot-persistent service. launchd/systemd relaunch
|
|
@@ -376,8 +419,7 @@ export function updateService(room, { exec = execSync, install = installService,
|
|
|
376
419
|
return false
|
|
377
420
|
}
|
|
378
421
|
process.stderr.write(`\n ◆ updating the background service to v${target} and restarting…\n`)
|
|
379
|
-
install(room, [], { version: target, staleProof: true })
|
|
380
|
-
return true
|
|
422
|
+
return install(room, [], { version: target, staleProof: true }) !== false
|
|
381
423
|
}
|
|
382
424
|
|
|
383
425
|
// Ground truth: is the OS service manager ACTUALLY running our service right now?
|