thinkpool-pair 0.7.194 → 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.194",
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
@@ -234,12 +234,18 @@ WantedBy=default.target
234
234
  // Blocking sleep (installService runs synchronously from the interactive menu; the
235
235
  // user is watching, so we WANT to wait + report the real outcome, not fire-and-forget).
236
236
  const sleepMs = (ms) => { try { execSync(`sleep ${(ms / 1000).toFixed(2)}`) } catch { /* noop */ } }
237
- // Which thinkpool-pair versions are running right now (the account supervisor shows as
238
- // `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.
239
245
  const runningPairVersions = () => {
240
246
  try {
241
- 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' })
242
- 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)
243
249
  } catch { return new Set() }
244
250
  }
245
251
  const darwinSvcLoaded = (lbl) => { try { execSync(`launchctl print gui/$(id -u)/${lbl}`, { stdio: 'ignore' }); return true } catch { return false } }