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 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: 'never', 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' } : {}),
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
- if (runtime === 'codex') mode = 'bypassPermissions'
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 = 'bypassPermissions'
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
- // v1 TRADEOFF (spec §tradeoff, G3): `codex exec` is non-interactive — fixed
14
- // --sandbox, NO per-tool human gate. The room OBSERVES each command/file-change
15
- // live (item.started→item.completed) but cannot approve/deny mid-run. Codex's
16
- // own sandbox governs safety. Default sandbox: workspace-write.
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 = DEFAULT_SANDBOX, onEvent, providerConfig, rolePrompt, roomContext }) {
95
- sandbox = normalizeCodexSandbox(sandbox)
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
- // Codex v1 has a fixed sandbox for the lifetime of the lane. The bridge may
218
- // still call setMode from shared chrome; report unsupported rather than
219
- // pretending the Claude permission mode changed Codex's sandbox.
220
- setMode() { return false },
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.193",
3
+ "version": "0.7.196",
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
@@ -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 = staleProof
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 = [npx, ...pkgArgs, ...tail] // systemd Restart=always supervises
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 = staleProof
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 = room
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
- // Which thinkpool-pair versions are running right now (the account supervisor shows as
209
- // `npm exec thinkpool-pair@<ver>`). Returns a Set of version strings.
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(`ps ax -o command= 2>/dev/null | grep -oE 'thinkpool-pair@[0-9]+\\.[0-9]+\\.[0-9]+' | sort -u`, { encoding: 'utf8', shell: '/bin/bash' })
213
- return new Set(out.split('\n').map((l) => l.trim().split('@')[1]).filter(Boolean))
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
- const a = buildArtifact(process.platform, { room, cmdArgs, autoUpdate, version, staleProof })
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}${staleProof ? ' (stale-proof: every restart re-fetches it fresh, never a stale npx cache)' : ''} (safe default — a bad publish can't auto-roll to this always-on bridge). To update: use "Restart & update the bridge" (or re-run install-service from a newer thinkpool-pair). Pass --auto-update to track @latest instead.`
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?