thinkpool-pair 0.7.268 → 0.7.270

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
@@ -51,10 +51,11 @@ import { validateProviderSwitch, providerSwitchPlan, BUILTIN_PROVIDER } from './
51
51
  import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
52
52
  import { z } from 'zod'
53
53
  import { readCodexDefaultModel, readCodexModels, codexConfigForMode, codexThreadCanResume } from './codex-session.mjs'
54
+ import { codexAccountUsageLine, codexCreditsReportLine, codexLimitReportLine } from './codex-commands.mjs'
54
55
  import { withMcpSessionFactory } from './codex-mcp-http.mjs'
55
56
  import { startStructuredSession } from './runtime-session.mjs'
56
- import { defaultStructuredMode, normalizeStructuredEffort, shouldDeferStructuredRuntime, structuredRuntimeForCommand, structuredRuntimeMetadata, structuredRuntimeSupportsMode } from './runtime-registry.mjs'
57
- import { reconcileCommandCatalog } from './command-catalog.mjs'
57
+ import { defaultStructuredMode, normalizeStructuredEffort, shouldDeferStructuredRuntime, structuredModeForSlice, structuredModeLocked, structuredModesForLane, structuredRuntimeForCommand, structuredRuntimeMetadata, structuredRuntimeSupportsMode } from './runtime-registry.mjs'
58
+ import { commandCatalogForRuntime, commandHelpLine, reconcileCommandCatalog } from './command-catalog.mjs'
58
59
  import { probeHermesRuntime } from './hermes-probe.mjs'
59
60
  import { hermesRequiredMcpTools, hermesRoleFor } from './hermes-policy.mjs'
60
61
  import { canonicalRoomFilePath, waitForNativeImages } from './codex-images.mjs'
@@ -1128,7 +1129,7 @@ const announce = () => {
1128
1129
  // laneStatusOf: authoritative busy/idle + last-action timestamp/age +
1129
1130
  // STUCK/BLOCKED alert. The bridge owns the turn and permission state, so
1130
1131
  // every roster consumer reads one status instead of reconstructing it.
1131
- ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, ...laneStatusOf(s), hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, capabilities: structuredRuntimeMetadata(s.runtime || 'claude'), ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [], canSteer: s.session?.canSteer ?? false } : s.runtime === 'hermes' ? { models: s.models || [], canSteer: s.session?.canSteer ?? false } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.sideParent ? { sideParent: s.sideParent, sideTask: s.sideTask || undefined, sideHandback: !!s.sideHandback } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
1132
+ ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, ...laneStatusOf(s), hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, capabilities: { ...structuredRuntimeMetadata(s.runtime || 'claude'), modes: structuredModesForLane(s.runtime || 'claude', s) }, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [], canSteer: s.session?.canSteer ?? false } : s.runtime === 'hermes' ? { models: s.models || [], canSteer: s.session?.canSteer ?? false } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.sideParent ? { sideParent: s.sideParent, sideTask: s.sideTask || undefined, sideHandback: !!s.sideHandback } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
1132
1133
  // provider: the registered LLM-provider this lane runs on, NAME-ONLY {id,name}
1133
1134
  // (NEVER the key or baseUrl). Additive; older clients ignore it. Omitted for the
1134
1135
  // built-in/default Claude path (no badge). Makes the lane's provider badge +
@@ -1747,6 +1748,7 @@ function worktreeSnapshot(cwd) {
1747
1748
  function openStructured({ id, runtime = 'claude', model, models, effort, resume, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, rolePrompt, sliceType, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage }) {
1748
1749
  if (sessions.has(id)) return
1749
1750
  runtime = structuredRuntimeMetadata(runtime) ? runtime : 'claude'
1751
+ mode = structuredModeForSlice(runtime, { mode, sliceType, flowRole })
1750
1752
  // No explicit mode → a sensible default per runtime (see defaultModeForRuntime):
1751
1753
  // codex → bypassPermissions, so a freshly-opened codex terminal can fetch /
1752
1754
  // advance / ship instead of hitting the no-network wall; claude → default.
@@ -1783,7 +1785,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
1783
1785
  ? spawnDepth
1784
1786
  : (sideParent || (spawnedBy && !String(spawnedBy).startsWith('flow:')) ? 1 : 0)
1785
1787
  const initialHop = Number.isInteger(hop) && hop >= 0 ? hop : structuralDepth
1786
- const entry = { cmd: runtime, runtime, kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: Array.isArray(commands) ? commands : undefined, mode, effort, models: runtime === 'codex' ? codexModels : runtime === 'hermes' && Array.isArray(models) ? models : undefined,
1788
+ const entry = { cmd: runtime, runtime, kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: commandCatalogForRuntime(runtime, commands), mode, effort, models: runtime === 'codex' ? codexModels : runtime === 'hermes' && Array.isArray(models) ? models : undefined,
1787
1789
  // model: truthful active-model label — now the SAME `laneModel` the SDK is given, so the
1788
1790
  // chip cannot disagree with the wire. When this lane runs on a custom (non-anthropic)
1789
1791
  // registered provider the SDK id is impersonated (see the onEvent guard below), so
@@ -2427,7 +2429,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2427
2429
  runtime: resolved.runtime,
2428
2430
  model: resolved.model,
2429
2431
  provider: resolved.provider,
2430
- mode: resolved.mode,
2432
+ mode: structuredModeForSlice(resolved.runtime, { mode: resolved.mode, sliceType: args?.sliceType }),
2431
2433
  }
2432
2434
  let preview
2433
2435
  try {
@@ -2483,7 +2485,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2483
2485
  // Stamp ownership/depth BEFORE the runtime starts so its first system
2484
2486
  // preamble is truthful. Mutating ne.spawnedBy after openStructured was
2485
2487
  // too late: Codex/Claude had already booted with the top-level wording.
2486
- openStructured({ id: newId, runtime: resolved.runtime, model: resolved.model, provider: resolved.provider, mode: resolved.mode, sliceType: args?.sliceType, flowReviewTargets: manualReviewSnapshots.map((item) => item.taskKey), flowReviewSnapshots: manualReviewSnapshots, spawnedBy: id, spawnDepth: childSpawnDepth, cascadeRole: 'worker', hop: childHop })
2488
+ openStructured({ id: newId, runtime: resolved.runtime, model: resolved.model, provider: resolved.provider, mode: effectiveArgs.mode, sliceType: args?.sliceType, flowReviewTargets: manualReviewSnapshots.map((item) => item.taskKey), flowReviewSnapshots: manualReviewSnapshots, spawnedBy: id, spawnDepth: childSpawnDepth, cascadeRole: 'worker', hop: childHop })
2487
2489
  const ne = sessions.get(newId)
2488
2490
  if (!ne) { if (args?.name) { delete termNames[newId]; saveNames(room, termNames) } return okText('Could not open a new lane — the terminal cap may have just been reached. Close one and retry.') }
2489
2491
  ne.peekCount = 0; ne.postCount = 0; ne.spawnTimes = []
@@ -2663,10 +2665,16 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2663
2665
  // turn (human prompt, dispatch, or cascade task), the runtime calls this
2664
2666
  // while still cold; subsequent turns reuse the persisted worktree.
2665
2667
  prepareCwd: (!cwd && !flowSessionId && !flowTaskKey) ? () => {
2666
- if (entry.managedWorktree?.dir) return entry.managedWorktree.dir
2667
2668
  try {
2668
- entry.managedWorktree = createManagedLaneWorktree({ terminalId: id, cwd: process.cwd() })
2669
+ if (!entry.managedWorktree?.dir) {
2670
+ entry.managedWorktree = createManagedLaneWorktree({ terminalId: id, cwd: process.cwd() })
2671
+ }
2669
2672
  entry.cwd = entry.managedWorktree.dir
2673
+ // ViewportManager is created while an ordinary terminal is still cold,
2674
+ // before this lazy worktree exists. Rebind it in the same handoff that
2675
+ // moves the agent runtime, or preview_start serves the bridge checkout's
2676
+ // stale dist instead of this lane's build.
2677
+ entry.viewport.setWorkspaceRoot(entry.cwd)
2670
2678
  try { entry.flush?.() } catch { /* first turn may precede flush wiring */ }
2671
2679
  return entry.cwd
2672
2680
  } catch (error) {
@@ -2767,7 +2775,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2767
2775
  const duplicateCommandCatalog = evt.kind === 'system'
2768
2776
  ? reconcileCommandCatalog({
2769
2777
  current: entry.commands,
2770
- incoming: evt.commands,
2778
+ incoming: commandCatalogForRuntime(runtime, evt.commands),
2771
2779
  onChanged: (commands) => { entry.commands = commands; announce(); persist() },
2772
2780
  })
2773
2781
  : false
@@ -3698,6 +3706,41 @@ channel
3698
3706
  pushLog(s, evt)
3699
3707
  bcast('code-event', { term: payload.term, evt })
3700
3708
  }
3709
+ if (/^\/help\s*$/.test(text) && s.runtime !== 'hermes') {
3710
+ ctlLine(`Available commands \u00b7 ${commandHelpLine(s.commands)}`)
3711
+ return
3712
+ }
3713
+ if (/^\/status\s*$/.test(text) && s.runtime !== 'hermes') {
3714
+ const label = structuredRuntimeMetadata(s.runtime)?.label || s.runtime
3715
+ const state = s.session?.turnActive ? 'working' : 'idle'
3716
+ ctlLine(`${label} \u00b7 ${state} \u00b7 model ${s.model || 'default'} \u00b7 ${s.mode || 'default'} permissions \u00b7 effort ${s.effort || 'default'}`)
3717
+ return
3718
+ }
3719
+ if (/^\/context\s*$/.test(text) && s.runtime !== 'hermes') {
3720
+ const ctx = s.lastUsage?.ctx
3721
+ ctlLine(ctx?.max
3722
+ ? `Context \u00b7 ${Number(ctx.used || 0).toLocaleString('en-US')} / ${Number(ctx.max).toLocaleString('en-US')} tokens (${ctx.pct ?? Math.round((Number(ctx.used || 0) / Number(ctx.max)) * 100)}%)`
3723
+ : 'Context usage unavailable until the runtime reports a completed turn')
3724
+ return
3725
+ }
3726
+ if (/^\/diff\s*$/.test(text)) {
3727
+ try {
3728
+ const cwd = s.cwd || process.cwd()
3729
+ const summary = execFileSync('git', ['-C', cwd, 'status', '--short'], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] }).trim()
3730
+ ctlLine(summary ? `Working tree changes\n${summary.slice(0, 1600)}` : 'Working tree clean')
3731
+ } catch { ctlLine('Working-tree diff unavailable outside a readable Git checkout') }
3732
+ return
3733
+ }
3734
+ if (/^\/credits\s*$/.test(text) && s.runtime !== 'hermes') {
3735
+ if (s.runtime !== 'codex' || typeof s.session?.accountUsage !== 'function') {
3736
+ ctlLine('Credit balance is unavailable for this runtime; use /usage for provider limits')
3737
+ return
3738
+ }
3739
+ Promise.resolve(s.session.accountUsage()).then((report) => {
3740
+ ctlLine(codexCreditsReportLine(report?.limits))
3741
+ }).catch(() => ctlLine('Codex credits unavailable for this account'))
3742
+ return
3743
+ }
3701
3744
  const mm = text.match(/^\/model\b\s*(\S+)?/)
3702
3745
  if (mm) {
3703
3746
  if (mm[1]) {
@@ -3752,7 +3795,7 @@ channel
3752
3795
  // /compact → show the ephemeral "Compacting…" indicator (the compact-start event),
3753
3796
  // track so onEvent clears it + attributes the recap card when the SDK turn finishes.
3754
3797
  // No persisted ctl line — the live indicator + the CompactionCard are the record.
3755
- if (/^\/compact\b/.test(text)) {
3798
+ if (/^\/compact\s*$/.test(text)) {
3756
3799
  if (s.runtime === 'codex') {
3757
3800
  if (s.session.turnActive) {
3758
3801
  ctlLine('finish or stop the current Codex turn before compacting context')
@@ -3763,6 +3806,15 @@ channel
3763
3806
  ctlLine('nothing to compact — context unchanged')
3764
3807
  return
3765
3808
  }
3809
+ const nativeCompacted = await s.session.compactContext?.()
3810
+ if (nativeCompacted) {
3811
+ const ce = { kind: 'compaction', trigger: 'manual', preTokens: s.lastUsage?.ctx?.used || null, by: payload.by, native: true }
3812
+ pushLog(s, ce)
3813
+ bcast('code-event', { term: payload.term, evt: ce })
3814
+ s.lastUsage = null
3815
+ s.flush?.()
3816
+ return
3817
+ }
3766
3818
  if (s.session.clearContext?.() === false) {
3767
3819
  ctlLine('Codex context compaction unavailable right now')
3768
3820
  return
@@ -3802,7 +3854,17 @@ channel
3802
3854
  ctlLine(s.runtime === 'codex'
3803
3855
  ? codexUsageReportLine(s.model, s.session?.usageSnapshot)
3804
3856
  : usageReportLine(s.model, s.log))
3805
- if (s.runtime !== 'codex') planMeterLine().then((l) => { if (l) ctlLine(l) }).catch(() => { /* meters are never load-bearing */ })
3857
+ if (s.runtime === 'codex') {
3858
+ Promise.resolve(s.session?.accountUsage?.()).then((report) => {
3859
+ const usage = codexAccountUsageLine(report?.usage)
3860
+ if (usage) ctlLine(usage)
3861
+ ctlLine(codexLimitReportLine(report?.limits))
3862
+ }).catch(() => ctlLine('Codex provider limits unavailable'))
3863
+ } else planMeterLine().then((l) => { if (l) ctlLine(l) }).catch(() => { /* meters are never load-bearing */ })
3864
+ return
3865
+ }
3866
+ if (s.runtime === 'codex' && /^\/review(?:\s|$)/.test(text) && (s.flowSessionId || s.flowRole || s.sliceType === 'review')) {
3867
+ ctlLine('Native /review is unavailable in Flow and reviewer lanes; their immutable review contract already owns scope and authority')
3806
3868
  return
3807
3869
  }
3808
3870
  // Context-carry (2026-07-08) point 4: a real human turn arrived BEFORE the post-switch/
@@ -3880,6 +3942,13 @@ channel
3880
3942
  .on('broadcast', { event: 'code-mode' }, ({ payload }) => {
3881
3943
  const s = payload?.term && sessions.get(payload.term)
3882
3944
  if (s && STRUCTURED_MODES.has(payload.mode)) {
3945
+ if (structuredModeLocked(s) && payload.mode !== s.mode) {
3946
+ const evt = { kind: 'control', text: `This ${s.flowRole || 'review'} lane is structurally locked to ${s.mode}; Flow and reviewer restrictions take precedence.` }
3947
+ pushLog(s, evt)
3948
+ bcast('code-event', { term: payload.term, evt })
3949
+ announce()
3950
+ return
3951
+ }
3883
3952
  if (s.runtime === 'codex' || s.runtime === 'hermes') {
3884
3953
  if (!s.session.setMode(payload.mode)) {
3885
3954
  const evt = { kind: 'control', text: `finish or stop the current ${structuredRuntimeMetadata(s.runtime)?.label || 'agent'} turn before switching permissions` }
@@ -4322,7 +4391,7 @@ flowChannel
4322
4391
  // Model tiers (2026-07-03-flow-lane-model-tiers): pick the lane's brain by slice_type
4323
4392
  // (scaffold→sonnet, feature/fix/review→opus; env can blanket-override or `inherit` to
4324
4393
  // restore today's exact behavior). undefined → no model key passed (openStructured default).
4325
- openStructured({ id: laneId, runtime: flowRuntime, cwd: dir, model: flowLaneModelFor({ sliceType: t.slice_type, runtime: flowRuntime, catalog: flowCatalog }), mode: flowRuntime === 'codex' && isReview ? 'review' : 'bypassPermissions', rolePrompt: laneRolePrompt, flowSessionId: payload.flowId, flowTaskKey: t.task_key, flowRole: isReview ? 'reviewer' : 'builder', flowReviewTargets: isReview ? (t.deps || []) : [], flowReviewSnapshots, dispatchBaseSha, revertTarget: redispatch?.revertTarget || null, spawnedBy: `flow:${payload.flowId}`, resume: redispatch?.resumeSessionId || undefined, reviewSliceRoots })
4394
+ openStructured({ id: laneId, runtime: flowRuntime, cwd: dir, model: flowLaneModelFor({ sliceType: t.slice_type, runtime: flowRuntime, catalog: flowCatalog }), mode: structuredModeForSlice(flowRuntime, { mode: 'bypassPermissions', flowRole: isReview ? 'reviewer' : 'builder' }), rolePrompt: laneRolePrompt, flowSessionId: payload.flowId, flowTaskKey: t.task_key, flowRole: isReview ? 'reviewer' : 'builder', flowReviewTargets: isReview ? (t.deps || []) : [], flowReviewSnapshots, dispatchBaseSha, revertTarget: redispatch?.revertTarget || null, spawnedBy: `flow:${payload.flowId}`, resume: redispatch?.resumeSessionId || undefined, reviewSliceRoots })
4326
4395
  const le = sessions.get(laneId)
4327
4396
  if (le) {
4328
4397
  // S4 — stamp the surviving revert target on the resumed lane so a later reviewer still
@@ -0,0 +1,91 @@
1
+ // Claude Code's SDK init payload is an *advertisement*, not a headless command
2
+ // contract. In particular, 2.1.206 advertises TUI/profile commands (`/agents`,
3
+ // `/config`, `/doctor`, …) which are not safe or meaningful in a shared room.
4
+ // Keep the room catalog deliberately small and evidence-based:
5
+ //
6
+ // 1. bridge-owned controls work through the existing room controls;
7
+ // 2. `/compact` is the one native lifecycle command proven on the streaming
8
+ // Agent SDK path; and
9
+ // 3. a discovered slash name is exposed only when the same init event calls it
10
+ // an installed Skill. Skills are structured prompt invocations, unlike
11
+ // Claude Code's interactive/profile command palette.
12
+ //
13
+ // This is also an explicit command matrix for the 2.1.206 probe. Everything
14
+ // outside the allow path is intentionally omitted; `CLAUDE_COMMAND_EXCLUSIONS`
15
+ // records the high-risk/interactive families so a later SDK change cannot turn
16
+ // discovery into accidental exposure.
17
+
18
+ const cleanName = (value) => {
19
+ const name = String(typeof value === 'object' ? value?.name : value || '')
20
+ .trim()
21
+ .replace(/^\/+/, '')
22
+ return /^[A-Za-z0-9][A-Za-z0-9:_-]*$/.test(name) ? name : ''
23
+ }
24
+
25
+ const stringMeta = (value) => {
26
+ if (!value || typeof value !== 'object') return {}
27
+ const description = String(value.description || '').trim()
28
+ const inputHint = String(value.inputHint || value.input_hint || value.input?.hint || '').trim()
29
+ return {
30
+ ...(description ? { description } : {}),
31
+ ...(inputHint ? { inputHint } : {}),
32
+ }
33
+ }
34
+
35
+ export const CLAUDE_COMMAND_MATRIX = Object.freeze({
36
+ '/clear': Object.freeze({ description: 'clear context · confirms', owner: 'bridge-control' }),
37
+ '/compact': Object.freeze({ description: 'compact context', owner: 'sdk-lifecycle' }),
38
+ '/model': Object.freeze({ description: 'pick model — opens selector', owner: 'bridge-control' }),
39
+ '/usage': Object.freeze({ description: 'session usage and provider limits', owner: 'bridge-control' }),
40
+ '<installed-skill>': Object.freeze({ description: 'run an installed Claude skill', owner: 'sdk-skill' }),
41
+ })
42
+
43
+ // Exclusions are grouped by why the room cannot safely implement them. This
44
+ // is intentionally a deny-list *and* a default-deny catalog: unknown future
45
+ // slash names stay hidden until they have an explicit structured contract.
46
+ export const CLAUDE_COMMAND_EXCLUSIONS = Object.freeze({
47
+ profileOrIdentity: Object.freeze(['agents', 'color', 'config', 'rename', 'team-onboarding']),
48
+ credentialsOrUpdates: Object.freeze(['claude-api', 'mcp', 'update-config']),
49
+ diagnosticsOrUpload: Object.freeze(['debug', 'doctor', 'heapdump', 'insights']),
50
+ hiddenAgentOrAutomation: Object.freeze(['__remote-workflow', 'batch', 'code-review', 'loop', 'review', 'schedule', 'security-review']),
51
+ unsafeInteractive: Object.freeze(['design', 'design-consent', 'design-revoke', 'fast', 'fewer-permission-prompts', 'goal', 'init', 'recap', 'reload-skills', 'run']),
52
+ })
53
+
54
+ const EXCLUDED = new Set(Object.values(CLAUDE_COMMAND_EXCLUSIONS).flat())
55
+ const BRIDGE_CONTROLS = new Set(['clear', 'compact', 'model', 'usage'])
56
+
57
+ // Convert Claude's string-only 2.1.206 payload (and a future object-shaped
58
+ // payload) into the metadata contract shared by all structured runtimes. An
59
+ // argument-bearing future skill retains its description and input hint; no
60
+ // argument command is silently advertised as a one-tap action.
61
+ export function normalizeClaudeCommandCatalog({ slashCommands, skills } = {}) {
62
+ const advertised = Array.isArray(slashCommands) ? slashCommands : []
63
+ const installedSkills = new Set((Array.isArray(skills) ? skills : []).map(cleanName).filter(Boolean))
64
+ const byName = new Map()
65
+
66
+ for (const command of advertised) {
67
+ const name = cleanName(command)
68
+ if (!name || EXCLUDED.has(name)) continue
69
+ const allowed = BRIDGE_CONTROLS.has(name) || installedSkills.has(name)
70
+ if (!allowed || byName.has(name)) continue
71
+ const canonical = CLAUDE_COMMAND_MATRIX[`/${name}`]
72
+ const supplied = stringMeta(command)
73
+ byName.set(name, {
74
+ name: `/${name}`,
75
+ description: supplied.description || canonical?.description || `Run installed Claude skill: /${name}`,
76
+ ...(supplied.inputHint ? { inputHint: supplied.inputHint } : {}),
77
+ })
78
+ }
79
+
80
+ // Room controls remain available on older SDK init shapes that omit the raw
81
+ // slash list. They are ordered first because they have bridge support.
82
+ const controls = ['clear', 'compact', 'model', 'usage'].map((name) => byName.get(name) || ({
83
+ name: `/${name}`,
84
+ description: CLAUDE_COMMAND_MATRIX[`/${name}`].description,
85
+ }))
86
+ const skillsOnly = [...byName.entries()]
87
+ .filter(([name]) => !BRIDGE_CONTROLS.has(name))
88
+ .sort(([a], [b]) => a.localeCompare(b))
89
+ .map(([, command]) => command)
90
+ return [...controls, ...skillsOnly]
91
+ }
@@ -21,6 +21,7 @@ import { sanitizeSession } from './transcript-sanitize.mjs'
21
21
  import { reviewGatePreToolDecision } from './flow-review-gate.mjs'
22
22
  import { crossPostNeedsCard } from './cross-terminal.mjs'
23
23
  import { correctContext } from './context-windows.mjs'
24
+ import { normalizeClaudeCommandCatalog } from './claude-command-catalog.mjs'
24
25
  import { THINKPOOL_CASCADE_RULE, THINKPOOL_REMOTE_DELIVERY_RULES, THINKPOOL_RUNTIME_AUTHORITY_RULE, THINKPOOL_RUNTIME_TURN_REMINDER, buildThinkPoolTurnGuidance, createRoomContextSelector, usesFullThinkPoolReminder } from './thinkpool-room-prompt.mjs'
25
26
  import { stallDecision, stallEvent, isCompactTurn } from './turn-stall.mjs'
26
27
 
@@ -878,9 +879,10 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
878
879
  readyLogged = true
879
880
  process.stderr.write(`\n ◆ session ready in ${Date.now() - spawnT0}ms — MCP ${process.env.TP_MCP_STRICT === '1' ? 'OFF' : 'on'}${resume ? ', resume' : ', fresh'}.\n`)
880
881
  }
881
- // m.slash_commands (init message) — the commands this session really
882
- // supports: built-ins + the host's custom .claude/commands. Surfaced
883
- // so the room composer's autocomplete lists what ACTUALLY exists.
882
+ // The init advertises both real Skills and Claude Code's TUI/profile
883
+ // palette. Normalise it to the structured room contract: supported
884
+ // bridge controls plus installed Skills, never unsafe interactive or
885
+ // profile-global commands (see claude-command-catalog.mjs).
884
886
  // Prefer the INTENDED model (opts.model — set at open and on every /model switch)
885
887
  // over the message's own `m.model`. A RESUMED session REPLAYS the transcript's old
886
888
  // init messages, all carrying the PRE-switch model, and each one used to reset the
@@ -888,7 +890,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
888
890
  // "init model=claude-opus-4-8" dragged the chip back to Opus with no result yet to
889
891
  // correct it). opts.model reflects what the session will actually run, so trust it.
890
892
  curModel = opts.model || m.model || model || curModel
891
- emit({ kind: 'system', sessionId, model: opts.model || m.model || model || null, commands: Array.isArray(m.slash_commands) ? m.slash_commands : undefined })
893
+ emit({ kind: 'system', sessionId, model: opts.model || m.model || model || null, commands: normalizeClaudeCommandCatalog({ slashCommands: m.slash_commands, skills: m.skills }) })
892
894
  // AskUserQuestion regression guard — the init message carries the session's
893
895
  // real tool list. If a caret SDK bump silently dropped AskUserQuestion from
894
896
  // it (the 2026-07-08 outage), surface it loudly instead of a dead card. One-
@@ -257,6 +257,26 @@ export class CodexAppServerClient {
257
257
  return this.request('turn/interrupt', { threadId, turnId }, this.controlTimeoutMs)
258
258
  }
259
259
 
260
+ compact({ threadId } = {}) {
261
+ return this.request('thread/compact/start', { threadId }, this.controlTimeoutMs)
262
+ }
263
+
264
+ accountUsage() {
265
+ return this.request('account/usage/read', null, this.controlTimeoutMs)
266
+ }
267
+
268
+ accountRateLimits() {
269
+ return this.request('account/rateLimits/read', null, this.controlTimeoutMs)
270
+ }
271
+
272
+ startReview({ threadId, target } = {}) {
273
+ return this.request('review/start', {
274
+ threadId,
275
+ target: target || { type: 'uncommittedChanges' },
276
+ delivery: 'inline',
277
+ }, this.turnStartTimeoutMs)
278
+ }
279
+
260
280
  end() {
261
281
  const child = this.child
262
282
  this._close(new Error('codex app-server ended'))
@@ -0,0 +1,83 @@
1
+ // Codex Code-room command policy.
2
+ //
3
+ // This is intentionally NOT a transcription of the interactive `codex` TUI
4
+ // palette. Codex CLI 0.144.1's App Server schema has no slash-command catalog
5
+ // endpoint. Keep only a command which the room can actually execute, and keep
6
+ // bridge-wide controls in bridge.mjs where their shared state semantics live.
7
+
8
+ /**
9
+ * Commands surfaced from the Codex runtime's system catalog.
10
+ *
11
+ * /compact is bridge-executed today: bridge.mjs clears the native Codex context
12
+ * and carries a bounded room recap into the next fresh thread. App Server also
13
+ * exposes thread/compact/start in 0.144.1, but the bridge path is the supported
14
+ * cross-transport implementation, including exec fallback and the room's
15
+ * durable recap semantics.
16
+ */
17
+ export const CODEX_COMMAND_CATALOG = Object.freeze([])
18
+
19
+ /**
20
+ * Evidence-backed command matrix for the installed Codex CLI (0.144.1).
21
+ * `surface` means what ThinkPool intentionally advertises; it is not a claim
22
+ * that an interactive terminal palette command works through App Server.
23
+ */
24
+ export const CODEX_COMMAND_MATRIX = Object.freeze([
25
+ Object.freeze({ name: '/compact', surface: true, runtime: 'native', api: 'thread/compact/start', reason: 'Native App Server compaction; bounded-recap reset remains the exec fallback.' }),
26
+ Object.freeze({ name: '/model', surface: true, runtime: 'bridge', api: 'model/list', reason: 'Shared picker owns the model argument.' }),
27
+ Object.freeze({ name: '/clear', surface: true, runtime: 'bridge', api: null, reason: 'Shared reset clears visible lane history and starts a fresh native thread.' }),
28
+ Object.freeze({ name: '/usage', surface: true, runtime: 'bridge+native', api: 'account/usage/read + account/rateLimits/read', reason: 'Reports lane usage plus redacted provider totals and limits.' }),
29
+ Object.freeze({ name: '/credits', surface: true, runtime: 'native', api: 'account/rateLimits/read', reason: 'Reports balance/availability only; never account identity.' }),
30
+ Object.freeze({ name: '/review', surface: true, runtime: 'native', api: 'review/start', reason: 'Ordinary lanes only; Flow and reviewer structural restrictions win.' }),
31
+ ])
32
+
33
+ /**
34
+ * Explicit exclusions: present in an interactive CLI or App Server protocol,
35
+ * but not safe/meaningful Code-room commands. Never add these by inference.
36
+ */
37
+ export const CODEX_COMMAND_EXCLUSIONS = Object.freeze([
38
+ 'profile-global configuration, skills, plugins, marketplaces, and experimental features',
39
+ 'updates, diagnostics/feedback upload, and external-agent import',
40
+ 'credentials, login/logout, identity, and account mutation',
41
+ 'thread archive/delete/fork/name/goal/rollback and hidden-agent controls',
42
+ 'MCP OAuth, filesystem, shell, and interactive command-exec protocol methods',
43
+ 'detached review and review from Flow/reviewer lanes, because visible structural restrictions win',
44
+ ])
45
+
46
+ export function isCodexCompactCommand(text) {
47
+ return /^\s*\/compact\s*$/i.test(String(text || ''))
48
+ }
49
+
50
+ export function codexReviewTarget(text) {
51
+ const body = String(text || '').replace(/^\s*\/review\b/i, '').trim()
52
+ if (!body) return { type: 'uncommittedChanges' }
53
+ const base = body.match(/^base\s*:\s*(\S+)$/i)
54
+ if (base) return { type: 'baseBranch', branch: base[1] }
55
+ const commit = body.match(/^commit\s*:\s*([a-f0-9]{7,64})$/i)
56
+ if (commit) return { type: 'commit', sha: commit[1] }
57
+ return { type: 'custom', instructions: body }
58
+ }
59
+
60
+ const pct = (value) => Number.isFinite(Number(value)) ? `${Math.round(Number(value))}%` : null
61
+ const reset = (value) => Number.isFinite(Number(value)) ? new Date(Number(value) * 1000).toISOString().replace('T', ' ').slice(0, 16) + 'Z' : null
62
+
63
+ export function codexLimitReportLine(snapshot) {
64
+ const limits = snapshot?.rateLimits || null
65
+ if (!limits) return 'Codex provider limits unavailable'
66
+ const parts = []
67
+ if (limits.primary?.usedPercent != null) parts.push(`primary ${pct(limits.primary.usedPercent)}${reset(limits.primary.resetsAt) ? ` · resets ${reset(limits.primary.resetsAt)}` : ''}`)
68
+ if (limits.secondary?.usedPercent != null) parts.push(`secondary ${pct(limits.secondary.usedPercent)}${reset(limits.secondary.resetsAt) ? ` · resets ${reset(limits.secondary.resetsAt)}` : ''}`)
69
+ return parts.length ? `Codex limits · ${parts.join(' · ')}` : 'Codex provider limits unavailable'
70
+ }
71
+
72
+ export function codexCreditsReportLine(snapshot) {
73
+ const credits = snapshot?.rateLimits?.credits
74
+ if (!credits) return 'Codex credits unavailable for this account'
75
+ if (credits.unlimited) return 'Codex credits · unlimited'
76
+ if (credits.balance != null) return `Codex credits · balance ${String(credits.balance)}`
77
+ return `Codex credits · ${credits.hasCredits ? 'available' : 'none available'}`
78
+ }
79
+
80
+ export function codexAccountUsageLine(snapshot) {
81
+ const total = snapshot?.summary?.lifetimeTokens
82
+ return Number.isFinite(Number(total)) ? `Codex account usage · ${Number(total).toLocaleString('en-US')} lifetime tokens` : null
83
+ }
@@ -29,6 +29,7 @@
29
29
  // apply unchanged. The stable exec path is observe-only; the opt-in App Server
30
30
  // path routes command/file approval requests through the shared room cards.
31
31
  import fs from 'node:fs'
32
+ import { CODEX_COMMAND_CATALOG } from './codex-commands.mjs'
32
33
 
33
34
  const safeMcpPart = (value) => String(value || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_')
34
35
  const mcpToolName = (item) => `mcp__${safeMcpPart(item?.server)}__${safeMcpPart(item?.tool)}`
@@ -80,7 +81,10 @@ export class CodexEventMapper {
80
81
  this._sessionId = ev.thread_id || this._sessionId || null
81
82
  if (!this._systemSent) {
82
83
  this._systemSent = true
83
- this._emit({ kind: 'system', sessionId: this._sessionId, model: this._model, commands: [] })
84
+ // Codex App Server 0.144.1 has no slash-command discovery endpoint.
85
+ // Advertise only the explicit room-supported catalog, never guessed
86
+ // interactive-TUI commands.
87
+ this._emit({ kind: 'system', sessionId: this._sessionId, model: this._model, commands: CODEX_COMMAND_CATALOG })
84
88
  }
85
89
  return
86
90
  }
package/codex-session.mjs CHANGED
@@ -32,6 +32,7 @@ import { CodexEventMapper } from './codex-event-mapper.mjs'
32
32
  import { startCodexMcpHttp } from './codex-mcp-http.mjs'
33
33
  import { CODEX_THINKPOOL_FIRST_TURN_PREAMBLE, buildThinkPoolTurnGuidance, createRoomContextSelector, usesFullThinkPoolReminder } from './thinkpool-room-prompt.mjs'
34
34
  import { questionAnswerResponse } from './question-response.mjs'
35
+ import { codexReviewTarget, isCodexCompactCommand } from './codex-commands.mjs'
35
36
 
36
37
  const DEFAULT_SANDBOX = 'workspace-write'
37
38
  const SAFE_SANDBOXES = new Set(['read-only', 'workspace-write', 'danger-full-access'])
@@ -40,7 +41,10 @@ export const CODEX_MODE_CONFIG = {
40
41
  default: { sandbox: 'workspace-write', approvalPolicy: 'untrusted' },
41
42
  acceptEdits: { sandbox: 'workspace-write', approvalPolicy: 'on-request' },
42
43
  plan: { sandbox: 'read-only', approvalPolicy: 'never' },
43
- review: { sandbox: 'workspace-write', approvalPolicy: 'never' },
44
+ // Reviewer authority is structural, not a prompt convention. It may inspect
45
+ // the pinned snapshot and call the bridge-owned verdict/check tools, but it
46
+ // cannot mutate even its own worktree as an escape hatch.
47
+ review: { sandbox: 'read-only', approvalPolicy: 'never' },
44
48
  bypassPermissions: { sandbox: 'danger-full-access', approvalPolicy: 'never' },
45
49
  }
46
50
 
@@ -586,6 +590,43 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
586
590
  return true
587
591
  }
588
592
 
593
+ async function runAppServerReview(commandText) {
594
+ if (!await ensureAppServer()) return false
595
+ mapper.setUsageBaseline(sessionId ? readCodexThreadUsage(sessionId) : null)
596
+ aborted = false
597
+ turnActive = true
598
+ try {
599
+ const started = await appServer.startReview({ threadId: sessionId, target: codexReviewTarget(commandText) })
600
+ activeTurnId = started?.turn?.id || started?.turnId
601
+ if (!activeTurnId) throw new Error('Codex review/start returned no turn id')
602
+ const completed = await appServer.waitForTurn(activeTurnId)
603
+ const status = completed?.turn?.status
604
+ if (aborted || status === 'interrupted') {
605
+ emitTurnBoundary({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: completed?.turn?.durationMs, denials: 0, resultText: null })
606
+ } else if (status === 'failed') {
607
+ pushMappedEvent({ type: 'turn.failed', message: completed?.turn?.error?.message || 'codex review failed' })
608
+ } else {
609
+ pushMappedEvent({ type: 'turn.completed' })
610
+ }
611
+ } catch (error) {
612
+ appServerDisabled = true
613
+ appServerThreadReady = false
614
+ try { appServer?.end() } catch { /* noop */ }
615
+ appServer = null
616
+ if (!ended) pushMappedEvent({ type: 'turn.failed', message: `codex review failed: ${error?.message || error}` })
617
+ } finally {
618
+ for (const state of streamedAgentItems.values()) {
619
+ if (!state.text) continue
620
+ try { onEvent?.({ kind: 'assistant', blocks: [{ type: 'text', text: state.text }], parentToolUseId: null, replacesCid: state.cid }) } catch { /* noop */ }
621
+ }
622
+ activeTurnId = null
623
+ turnActive = false
624
+ appServerItems.clear()
625
+ streamedAgentItems.clear()
626
+ }
627
+ return true
628
+ }
629
+
589
630
  async function runExec(prompt, options = {}) {
590
631
  // First turn: fresh exec. Later turns: resume THIS lane's captured thread.
591
632
  // `--last` is process-global and can cross-wire two concurrently-active Codex
@@ -681,8 +722,15 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
681
722
  promptIndex: next.promptIndex,
682
723
  forceFullReminder: next.forceFullReminder,
683
724
  })
684
- const usedAppServer = await runAppServer(prompt, next.options)
685
- if (!usedAppServer) await runExec(prompt, next.options)
725
+ const review = /^\s*\/review(?:\s|$)/i.test(String(next.text || ''))
726
+ const usedAppServer = review
727
+ ? await runAppServerReview(next.text)
728
+ : await runAppServer(prompt, next.options)
729
+ if (!usedAppServer) {
730
+ if (review) {
731
+ emitTurnBoundary({ kind: 'error', message: 'Codex review is unavailable without the tested App Server runtime.', recoverable: true })
732
+ } else await runExec(prompt, next.options)
733
+ }
686
734
  turnNo++
687
735
  pump()
688
736
  })
@@ -698,7 +746,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
698
746
  if (ended) return false
699
747
  const promptIndex = userPromptNo++
700
748
  const thisTurnForceFull = forceFullReminder
701
- forceFullReminder = /^\s*\/(?:compact|reset|clear)\b/i.test(String(text || ''))
749
+ forceFullReminder = isCodexCompactCommand(text) || /^\s*\/(?:reset|clear)\b/i.test(String(text || ''))
702
750
  if (!turnActive && turnNo === 0 && prepareCwd) {
703
751
  try { cwd = prepareCwd() || cwd } catch { /* keep original cwd */ }
704
752
  }
@@ -779,5 +827,23 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
779
827
  appServerThreadReady = false
780
828
  return true
781
829
  },
830
+ async compactContext() {
831
+ if (turnActive || !await ensureAppServer()) return false
832
+ try {
833
+ await appServer.compact({ threadId: sessionId })
834
+ return true
835
+ } catch (error) {
836
+ note(`Native Codex compaction unavailable; using bounded recap fallback: ${error?.message || error}`)
837
+ return false
838
+ }
839
+ },
840
+ async accountUsage() {
841
+ if (!await ensureAppServer()) return null
842
+ const [usage, limits] = await Promise.all([
843
+ appServer.accountUsage().catch(() => null),
844
+ appServer.accountRateLimits().catch(() => null),
845
+ ])
846
+ return { usage, limits }
847
+ },
782
848
  }
783
849
  }
@@ -1,5 +1,101 @@
1
+ // Runtime-neutral Code-room command catalog. Native runtimes publish very
2
+ // different surfaces: Claude mixes skills with TUI commands, Codex App Server
3
+ // publishes no slash catalog, and Hermes publishes ACP metadata. Keep the safe
4
+ // room controls here so all three runtimes share names, descriptions, hints,
5
+ // and routing while runtime-owned commands remain evidence-gated.
6
+
7
+ const command = (name, description, route, inputHint, runtimes = ['claude', 'codex', 'hermes']) => Object.freeze({
8
+ name,
9
+ description,
10
+ route,
11
+ ...(inputHint ? { inputHint } : {}),
12
+ runtimes: Object.freeze(runtimes),
13
+ })
14
+
15
+ export const CODE_ROOM_COMMANDS = Object.freeze([
16
+ command('/side', 'investigate beside this terminal', 'side', 'task'),
17
+ command('/flow', 'open an explicit visible Cascade', 'flow', 'task [--mode guide|steer|autopilot]'),
18
+ command('/help', 'list commands available in this lane', 'control'),
19
+ command('/status', 'runtime, model, permissions, and busy state', 'control'),
20
+ command('/usage', 'session usage and provider limits', 'control'),
21
+ command('/context', 'current context-window usage', 'control'),
22
+ command('/diff', 'working-tree change summary', 'control'),
23
+ command('/compact', 'compact context', 'runtime'),
24
+ command('/clear', 'clear context · confirms', 'clear'),
25
+ command('/model', 'pick model — opens selector', 'model'),
26
+ command('/mode', 'set permission mode', 'mode', 'normal | auto-accept | plan | bypass'),
27
+ command('/effort', 'set reasoning effort', 'effort', 'low | medium | high | xhigh | max'),
28
+ command('/queue', 'run a prompt after the active turn', 'queue', 'prompt'),
29
+ command('/steer', 'guide the active turn', 'steer', 'prompt', ['codex', 'hermes']),
30
+ command('/credits', 'provider credit balance', 'credits', null, ['codex', 'hermes']),
31
+ command('/reasoning', 'Hermes reasoning effort', 'runtime', 'low | medium | high | xhigh | max | none | reset', ['hermes']),
32
+ command('/review', 'review uncommitted changes', 'runtime', null, ['codex']),
33
+ ])
34
+
35
+ const cleanRuntime = (runtime) => runtime === 'thinkpool' ? 'hermes' : runtime
36
+ const cleanName = (value) => {
37
+ const raw = typeof value === 'string' ? value : value?.name
38
+ const name = `/${String(raw || '').trim().replace(/^\/+/, '')}`
39
+ return /^\/[A-Za-z0-9][A-Za-z0-9:_-]*$/.test(name) ? name : ''
40
+ }
41
+
42
+ const normalizeNative = (value) => {
43
+ const name = cleanName(value)
44
+ if (!name) return null
45
+ if (typeof value === 'string') return { name, description: 'runtime command', route: 'runtime' }
46
+ const description = String(value?.description || '').trim() || 'runtime command'
47
+ const inputHint = String(value?.inputHint || value?.input_hint || value?.input?.hint || '').trim()
48
+ const route = String(value?.route || '').trim() || 'runtime'
49
+ return { name, description, route, ...(inputHint ? { inputHint } : {}) }
50
+ }
51
+
52
+ // Merge native metadata into the shared catalog without allowing a runtime to
53
+ // redefine the routing of a bridge-owned control. Unknown native commands are
54
+ // retained only after the runtime-specific adapter has already allowlisted them.
55
+ export function commandCatalogForRuntime(runtime, nativeCommands = []) {
56
+ const id = cleanRuntime(runtime)
57
+ const byName = new Map()
58
+ for (const item of CODE_ROOM_COMMANDS) {
59
+ if (!item.runtimes.includes(id)) continue
60
+ byName.set(item.name, {
61
+ name: item.name,
62
+ description: item.description,
63
+ route: item.route,
64
+ ...(item.inputHint ? { inputHint: item.inputHint } : {}),
65
+ })
66
+ }
67
+ for (const raw of (Array.isArray(nativeCommands) ? nativeCommands : [])) {
68
+ const item = normalizeNative(raw)
69
+ if (!item) continue
70
+ const shared = byName.get(item.name)
71
+ const hermesNativeControl = id === 'hermes' && ['/help', '/status', '/context', '/credits'].includes(item.name)
72
+ byName.set(item.name, shared
73
+ ? { ...item, ...shared, description: item.description === 'runtime command' ? shared.description : item.description, ...(item.inputHint ? { inputHint: item.inputHint } : {}), ...(hermesNativeControl ? { route: 'runtime' } : {}) }
74
+ : item)
75
+ }
76
+ return [...byName.values()]
77
+ }
78
+
79
+ export const commandNeedsInput = (item) => !!String(item?.inputHint || '').trim()
80
+ export const commandRoute = (item) => String(item?.route || 'runtime')
81
+
82
+ export function commandHelpLine(commands) {
83
+ return (Array.isArray(commands) ? commands : [])
84
+ .map((item) => `${item.name}${item.inputHint ? ` <${item.inputHint}>` : ''}`)
85
+ .join(' ')
86
+ }
87
+
88
+ export function normalizePermissionMode(value) {
89
+ const mode = String(value || '').trim().toLowerCase().replace(/[\s_-]+/g, '')
90
+ if (['normal', 'default', 'manual'].includes(mode)) return 'default'
91
+ if (['auto', 'autoaccept', 'autoacceptedits', 'acceptedits'].includes(mode)) return 'acceptEdits'
92
+ if (mode === 'plan') return 'plan'
93
+ if (['bypass', 'bypasspermissions', 'fullauto'].includes(mode)) return 'bypassPermissions'
94
+ return null
95
+ }
96
+
1
97
  // Command catalogs are session chrome, but their ACP system event is also the
2
- // lifecycle-ready signal. Keep those two concerns separate so a reconstructed
98
+ // lifecycle-ready signal. Keep those concerns separate so a reconstructed
3
99
  // identical catalog cannot swallow warm/recap/auto-resume work.
4
100
  export function reconcileCommandCatalog({ current, incoming, onChanged, onLifecycle } = {}) {
5
101
  const hasIncoming = Array.isArray(incoming) && incoming.length > 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.268",
3
+ "version": "0.7.270",
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": {
@@ -15,6 +15,7 @@
15
15
  "byok-detect.mjs",
16
16
  "context-windows.mjs",
17
17
  "claude-session.mjs",
18
+ "claude-command-catalog.mjs",
18
19
  "codex-session.mjs",
19
20
  "question-response.mjs",
20
21
  "thinkpool-room-prompt.mjs",
@@ -25,6 +26,7 @@
25
26
  "codex-mcp-http.mjs",
26
27
  "lane-worktree.mjs",
27
28
  "codex-event-mapper.mjs",
29
+ "codex-commands.mjs",
28
30
  "acp-client.mjs",
29
31
  "hermes-session.mjs",
30
32
  "hermes-policy.mjs",
@@ -28,6 +28,19 @@ export const structuredRuntimeForCommand = (command) => {
28
28
  export const defaultStructuredMode = (runtime) => structuredRuntimeMetadata(runtime)?.defaultMode || 'default'
29
29
  export const structuredRuntimeSupportsMode = (runtime, mode) => structuredRuntimeMetadata(runtime)?.modes?.includes(mode) === true
30
30
  export const structuredRuntimeSupportsFlow = (runtime) => structuredRuntimeMetadata(runtime)?.flow === true
31
+ export const structuredModeLocked = ({ flowRole, sliceType } = {}) => (
32
+ flowRole === 'conductor' || flowRole === 'reviewer' || sliceType === 'review'
33
+ )
34
+ export const structuredModeForSlice = (runtime, lane = {}) => {
35
+ if (lane.flowRole !== 'reviewer' && lane.sliceType !== 'review') return lane.mode
36
+ if (structuredRuntimeSupportsMode(runtime, 'review')) return 'review'
37
+ if (structuredRuntimeSupportsMode(runtime, 'plan')) return 'plan'
38
+ return lane.mode
39
+ }
40
+ export const structuredModesForLane = (runtime, lane = {}) => {
41
+ const modes = structuredRuntimeMetadata(runtime)?.modes || []
42
+ return structuredModeLocked(lane) && modes.includes(lane.mode) ? [lane.mode] : modes
43
+ }
31
44
  const STRUCTURED_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max'])
32
45
  export const normalizeStructuredEffort = (runtime, effort) => {
33
46
  if (effort === null) return null
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "bundleVersion": 1,
3
+ "bundleVersion": 2,
4
4
  "contracts": [
5
5
  {
6
6
  "id": "room-coordination",
@@ -37,16 +37,16 @@
37
37
  },
38
38
  {
39
39
  "id": "work-routing",
40
- "version": 1,
40
+ "version": 2,
41
41
  "routes": [
42
42
  {
43
43
  "id": "work-routing",
44
44
  "tools": ["spawn_terminal", "open_main_terminal", "close_terminal"],
45
45
  "trigger": "\\b(parallel|delegate|worker|sub[- ]?terminal|conductor|cascade|spawn_terminal|open_main_terminal|close_terminal)\\b",
46
- "prompt": "For genuinely decomposable work in a conductor-capable role, open visible worker slices with spawn_terminal, collect and verify them, then close_terminal. An explicitly requested separate Cascade/conductor uses open_main_terminal, never spawn_terminal."
46
+ "prompt": "For genuinely decomposable work in a conductor-capable role, open visible worker slices with spawn_terminal, collect and verify them, then close_terminal. An explicitly requested separate Cascade/conductor uses open_main_terminal, never spawn_terminal. Review slices are structurally read-only and must use the review slice type; they never inherit autonomous bypass authority."
47
47
  }
48
48
  ],
49
- "expandedPrompt": "CASCADE WORKFLOW (default for non-trivial work that genuinely decomposes; mandatory when the people ask for Cascade, tiered lanes, one-shot flow, or unattended multi-slice execution): first investigate enough to name evidence-backed slices and their dependencies, then post a short plan in the room. The current top-level/conductor terminal keeps decomposition, integration, and final judgment; use spawn_terminal only for bounded worker slices, while an explicitly requested separate conductor uses open_main_terminal. Parallelize only disjoint slices and encode dependencies instead of holding them in your head. Choose sliceType=scaffold for mechanical/search work, feature or fix for implementation, and review with a balanced capable tier for adversarial verification. Every worker brief names the evidence/diagnosis, exact scope, observable acceptance proof, relevant repo gates, whether merge/publish is required, and a safe-skip escape hatch. After dispatch, remain responsible: use read_terminal to collect each owned lane, verify its claim, close_terminal immediately, and dispatch newly unblocked work; never declare the Cascade done or yield a final result while owned workers remain uncollected. Check origin/main CI before merge-bearing waves, coordinate shared fixtures/publishers, and treat sibling pushes as context rather than proof. Run an adversarial review after builders (or pipeline review behind completed phases), finish with production-condition evidence, verify every reported SHA/version, and close every worker you opened.",
49
+ "expandedPrompt": "CASCADE WORKFLOW (default for non-trivial work that genuinely decomposes; mandatory when the people ask for Cascade, tiered lanes, one-shot flow, or unattended multi-slice execution): first investigate enough to name evidence-backed slices and their dependencies, then post a short plan in the room. The current top-level/conductor terminal keeps decomposition, integration, and final judgment; use spawn_terminal only for bounded worker slices, while an explicitly requested separate conductor uses open_main_terminal. Parallelize only disjoint slices and encode dependencies instead of holding them in your head. Choose sliceType=scaffold for mechanical/search work, feature or fix for implementation, and review with a balanced capable tier for adversarial verification. Review slices are structurally read-only: they resolve to a native review/Plan mode before launch and can never inherit autonomous bypass authority. Every worker brief names the evidence/diagnosis, exact scope, observable acceptance proof, relevant repo gates, whether merge/publish is required, and a safe-skip escape hatch. After dispatch, remain responsible: use read_terminal to collect each owned lane, verify its claim, close_terminal immediately, and dispatch newly unblocked work; never declare the Cascade done or yield a final result while owned workers remain uncollected. Check origin/main CI before merge-bearing waves, coordinate shared fixtures/publishers, and treat sibling pushes as context rather than proof. Run an adversarial review after builders (or pipeline review behind completed phases), finish with production-condition evidence, verify every reported SHA/version, and close every worker you opened.",
50
50
  "impact": [
51
51
  {"path": "bridge/bridge.mjs", "diffPattern": "spawn_terminal|open_main_terminal|close_terminal|cascadeRole|spawnDepth"},
52
52
  {"path": "bridge/lane-lifecycle.mjs"},
package/viewport.mjs CHANGED
@@ -313,6 +313,12 @@ export class ViewportManager {
313
313
  this.root = null
314
314
  }
315
315
 
316
+ setWorkspaceRoot(workspaceRoot) {
317
+ if (!workspaceRoot) throw new Error('A lane workspace is required.')
318
+ this.workspaceRoot = path.resolve(workspaceRoot)
319
+ return this.workspaceRoot
320
+ }
321
+
316
322
  async start({ root = 'dist' } = {}) {
317
323
  const resolved = await resolveContainedRoot(this.workspaceRoot, root)
318
324
  await this.stop()