thinkpool-pair 0.7.340 → 0.7.342

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/account.mjs CHANGED
@@ -21,6 +21,7 @@ import { supervisorServes, supervisorRoomsToStop } from './serve-consent.mjs'
21
21
  import { resolveServeDir } from './serve-dir.mjs'
22
22
  import { pairKeyFor, pairTopic, CROSSROOM_BUS } from './cross-terminal.mjs'
23
23
  import { installedAgentCommands } from './agent-detect.mjs'
24
+ import { agentVisibilityStates, setAgentPickerVisibility } from './agent-visibility.mjs'
24
25
  import { hostMemoryAdmission } from './host-memory.mjs'
25
26
  import { createPairBusBroker, mergePairRoomRoster, pairBusStatusFromRealtime, PAIR_BUS_STATUS } from './pair-bus.mjs'
26
27
  import { clearSupervisorReady, supervisorPresenceEchoed, writeSupervisorReady } from './supervisor-ready.mjs'
@@ -639,7 +640,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
639
640
  // to spawn against — re-read each announce so an add/remove reflects immediately.
640
641
  // Both are additive; older clients ignore them (feature: multi-provider BYOK, slice 1).
641
642
  const PROVIDER_PUBKEY = publicKeyB64()
642
- const pushPresence = () => trackPresence({ name: machine, bridgeId: BRIDGE_ID, version: VERSION, rooms: [...children.keys()], readyRooms: [...readyRooms], refused: [...refused].map(([code, reason]) => ({ code, reason })), service: runsAsService(), providerPubKey: PROVIDER_PUBKEY, providers: announceProviders(), agents: installedAgentCommands().map((cmd) => ({ cmd })), accountAuthState, ts: Date.now() })
643
+ const pushPresence = () => trackPresence({ name: machine, bridgeId: BRIDGE_ID, version: VERSION, rooms: [...children.keys()], readyRooms: [...readyRooms], refused: [...refused].map(([code, reason]) => ({ code, reason })), service: runsAsService(), providerPubKey: PROVIDER_PUBKEY, providers: announceProviders(), agents: agentVisibilityStates(installedAgentCommands()), accountAuthState, ts: Date.now() })
643
644
 
644
645
  // Persist account-auth truth separately from process presence. Presence will vanish
645
646
  // when the old JWT expires; the claim row keeps the reconnect instruction visible on
@@ -688,6 +689,21 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
688
689
  const nonce = payload?.nonce
689
690
  provReply('providers-list-res', { nonce, ok: true, providers: listProviders() })
690
691
  })
692
+ // agent-visibility-set {cmd, enabled, nonce} changes only the web picker.
693
+ // The runtime stays installed and explicit runtime requests keep working.
694
+ acctControl.on('broadcast', { event: 'agent-visibility-set' }, async ({ payload }) => {
695
+ const nonce = payload?.nonce
696
+ const installed = installedAgentCommands()
697
+ const r = setAgentPickerVisibility(payload?.cmd, payload?.enabled === true, { installed })
698
+ if (r.ok) {
699
+ pushPresence()
700
+ for (const child of children.values()) {
701
+ try { child.send({ t: 'agent-visibility-changed' }) } catch { /* child may be exiting */ }
702
+ }
703
+ process.stderr.write(`\n ◆ terminal picker agents updated — ${r.agents.filter((agent) => agent.enabled).map((agent) => agent.cmd).join(', ')} visible.\n`)
704
+ }
705
+ provReply('agent-visibility-res', { nonce, ok: r.ok, error: r.ok ? undefined : r.error, agents: r.ok ? r.agents : undefined })
706
+ })
691
707
 
692
708
  // ENABLE presence delivery + the self-echo's ground truth. supabase-js only asks
693
709
  // the server for presence state when a presence callback is bound at join time —
@@ -0,0 +1,67 @@
1
+ // Host-local picker visibility for installed coding agents.
2
+ //
3
+ // Installation remains detection truth: hiding a runtime does not uninstall it
4
+ // or prevent an explicit bridge command from opening it. This preference only
5
+ // controls which installed runtimes the web offers in human-facing terminal
6
+ // pickers. The file is shared by the account supervisor and its room children,
7
+ // so one dashboard toggle applies to every room served by this bridge.
8
+ import fs from 'node:fs'
9
+ import os from 'node:os'
10
+ import path from 'node:path'
11
+
12
+ export const PICKER_AGENT_COMMANDS = ['claude', 'codex', 'thinkpool']
13
+
14
+ const root = () => process.env.TP_PAIR_ROOT || path.join(os.homedir(), '.thinkpool-pair')
15
+ const defaultFile = () => path.join(root(), 'agent-visibility.json')
16
+ const normalize = (command) => String(command || '').toLowerCase() === 'hermes'
17
+ ? 'thinkpool'
18
+ : String(command || '').toLowerCase()
19
+
20
+ export function loadHiddenAgentCommands({ file = defaultFile(), fsImpl = fs } = {}) {
21
+ try {
22
+ const saved = JSON.parse(fsImpl.readFileSync(file, 'utf8'))
23
+ return new Set((Array.isArray(saved?.hidden) ? saved.hidden : [])
24
+ .map(normalize)
25
+ .filter((command) => PICKER_AGENT_COMMANDS.includes(command)))
26
+ } catch {
27
+ return new Set()
28
+ }
29
+ }
30
+
31
+ export function agentVisibilityStates(commands, { hidden = loadHiddenAgentCommands() } = {}) {
32
+ const found = new Set((Array.isArray(commands) ? commands : []).map(normalize))
33
+ const states = PICKER_AGENT_COMMANDS
34
+ .filter((command) => found.has(command))
35
+ .map((cmd) => ({ cmd, enabled: !hidden.has(cmd) }))
36
+ // A corrupt or hand-edited preference must not leave the "+" action with no
37
+ // valid runtime. The first installed product runtime is the safe recovery.
38
+ if (states.length > 0 && !states.some((agent) => agent.enabled)) states[0].enabled = true
39
+ return states
40
+ }
41
+
42
+ export function setAgentPickerVisibility(command, enabled, {
43
+ installed = [],
44
+ file = defaultFile(),
45
+ fsImpl = fs,
46
+ } = {}) {
47
+ const cmd = normalize(command)
48
+ if (!PICKER_AGENT_COMMANDS.includes(cmd)) return { ok: false, error: 'unknown agent runtime' }
49
+ const states = agentVisibilityStates(installed, { hidden: loadHiddenAgentCommands({ file, fsImpl }) })
50
+ if (!states.some((agent) => agent.cmd === cmd)) return { ok: false, error: 'agent runtime is not installed' }
51
+ if (!enabled && states.filter((agent) => agent.enabled).length <= 1) {
52
+ return { ok: false, error: 'keep at least one agent in the terminal picker' }
53
+ }
54
+
55
+ const hidden = new Set(states.filter((agent) => !agent.enabled).map((agent) => agent.cmd))
56
+ if (enabled) hidden.delete(cmd)
57
+ else hidden.add(cmd)
58
+
59
+ try {
60
+ fsImpl.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
61
+ fsImpl.writeFileSync(file, JSON.stringify({ hidden: [...hidden] }, null, 2), { mode: 0o600 })
62
+ try { fsImpl.chmodSync(file, 0o600) } catch { /* best-effort hardening */ }
63
+ } catch {
64
+ return { ok: false, error: 'could not save agent visibility' }
65
+ }
66
+ return { ok: true, agents: agentVisibilityStates(installed, { hidden }) }
67
+ }
package/bridge.mjs CHANGED
@@ -64,6 +64,7 @@ import { hermesRequiredMcpTools, hermesRoleFor } from './hermes-policy.mjs'
64
64
  import { canonicalRoomFilePath, waitForNativeImages } from './codex-images.mjs'
65
65
  import { createManagedLaneWorktree, removeManagedLaneWorktree } from './lane-worktree.mjs'
66
66
  import { commandOnPath } from './agent-detect.mjs'
67
+ import { agentVisibilityStates } from './agent-visibility.mjs'
67
68
  import { requiresSdkAdmission, sdkSmokePassed } from './sdk-admission.mjs'
68
69
  import { hermesUserInputResponse } from './question-response.mjs'
69
70
  import { hostMemoryAdmission } from './host-memory.mjs'
@@ -1295,6 +1296,7 @@ const announce = () => {
1295
1296
  // (name-only, NEVER key/baseUrl) so a cross-device viewer can badge a lane opened
1296
1297
  // on a custom provider without the owner's account-channel registry.
1297
1298
  const provNames = providerNameMap()
1299
+ const pickerState = new Map(agentVisibilityStates(installedAgents.map((agent) => agent.cmd)).map((agent) => [agent.cmd, agent.enabled]))
1298
1300
  const rev = ++announceRev
1299
1301
  return bcastAwait('bridge', {
1300
1302
  v: 2, name, bridge_id: BRIDGE_ID, started_at: BRIDGE_STARTED_AT, rev, repo: repoLabel, branch: readBranch(),
@@ -1353,7 +1355,7 @@ const announce = () => {
1353
1355
  agents: installedAgents.map(a => {
1354
1356
  let canResume = false
1355
1357
  if (a.resume) { try { canResume = a.resume.probe() } catch { /* stays false */ } }
1356
- return { label: a.label, cmd: a.cmd, canResume }
1358
+ return { label: a.label, cmd: a.cmd, canResume, enabled: pickerState.get(a.cmd) !== false }
1357
1359
  }),
1358
1360
  // cols/rows: the PTY's one true size — web viewers render this grid and
1359
1361
  // scale it to their own page instead of voting to reflow it.
@@ -5376,6 +5378,7 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
5376
5378
  process.on('message', (m) => {
5377
5379
  if (!m) return
5378
5380
  if (m.t === 'update-available') surfaceUpdate(m.version)
5381
+ else if (m.t === 'agent-visibility-changed') void announce()
5379
5382
  // Supervisor rotated the owner JWT (account.mjs scheduleTokenRefresh) — adopt
5380
5383
  // it so our authed writes (code-mockup) never go stale on a long session (L16).
5381
5384
  else if (m.t === 'token') {
@@ -14,7 +14,6 @@ const command = (name, description, route, inputHint, runtimes = ['claude', 'cod
14
14
 
15
15
  export const CODE_ROOM_COMMANDS = Object.freeze([
16
16
  command('/side', 'investigate beside this terminal', 'side', 'task'),
17
- command('/flow', 'open an explicit visible Cascade', 'flow', 'task [--mode guide|steer|autopilot]'),
18
17
  command('/help', 'list commands available in this lane', 'control'),
19
18
  command('/status', 'runtime, model, permissions, and busy state', 'control'),
20
19
  command('/usage', 'session usage and provider limits', 'control'),
@@ -28,10 +27,21 @@ export const CODE_ROOM_COMMANDS = Object.freeze([
28
27
  command('/queue', 'run a prompt after the active turn', 'queue', 'prompt'),
29
28
  command('/steer', 'guide the active turn', 'steer', 'prompt', ['codex', 'hermes']),
30
29
  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
30
  command('/review', 'review uncommitted changes', 'runtime', null, ['codex']),
33
31
  ])
34
32
 
33
+ // Native catalogs are runtime evidence, never portable session metadata.
34
+ // Claude's adapter default-denies raw SDK commands and passes only proven room
35
+ // controls plus installed Skills. Hermes' isolated ACP bootstrap owns its safe
36
+ // native catalog. Codex App Server publishes no native slash catalog, so a
37
+ // restored/foreign list must not manufacture one. Compatibility aliases stay
38
+ // callable at their runtime boundary without remaining discoverable.
39
+ const NATIVE_COMMAND_POLICY = Object.freeze({
40
+ claude: Object.freeze({ accept: true, hidden: Object.freeze(new Set(['/flow'])) }),
41
+ codex: Object.freeze({ accept: false, hidden: Object.freeze(new Set(['/flow'])) }),
42
+ hermes: Object.freeze({ accept: true, hidden: Object.freeze(new Set(['/flow', '/reasoning'])) }),
43
+ })
44
+
35
45
  const cleanRuntime = (runtime) => runtime === 'thinkpool' ? 'hermes' : runtime
36
46
  const cleanName = (value) => {
37
47
  const raw = typeof value === 'string' ? value : value?.name
@@ -54,6 +64,7 @@ const normalizeNative = (value) => {
54
64
  // retained only after the runtime-specific adapter has already allowlisted them.
55
65
  export function commandCatalogForRuntime(runtime, nativeCommands = []) {
56
66
  const id = cleanRuntime(runtime)
67
+ const nativePolicy = NATIVE_COMMAND_POLICY[id] || NATIVE_COMMAND_POLICY.codex
57
68
  const byName = new Map()
58
69
  for (const item of CODE_ROOM_COMMANDS) {
59
70
  if (!item.runtimes.includes(id)) continue
@@ -66,7 +77,7 @@ export function commandCatalogForRuntime(runtime, nativeCommands = []) {
66
77
  }
67
78
  for (const raw of (Array.isArray(nativeCommands) ? nativeCommands : [])) {
68
79
  const item = normalizeNative(raw)
69
- if (!item) continue
80
+ if (!item || !nativePolicy.accept || nativePolicy.hidden.has(item.name)) continue
70
81
  const shared = byName.get(item.name)
71
82
  const hermesNativeControl = id === 'hermes' && ['/help', '/status', '/context', '/credits'].includes(item.name)
72
83
  byName.set(item.name, shared
@@ -168,10 +168,10 @@ acp_adapter.server.HermesACPAgent._build_model_state = nous_only_model_state
168
168
  # account tokens, or lifecycle/admin controls enter the room surface.
169
169
  _TP_REASONING_CONFIG_ID = "thinkpool_reasoning_effort"
170
170
  _TP_REASONING_LEVELS = frozenset({"none", "low", "medium", "high", "xhigh", "max"})
171
+ _TP_HIDDEN_COMMANDS = frozenset({"reasoning"})
171
172
  _TP_COMMANDS = (
172
173
  {"name": "credits", "description": "Show safe Nous credit balance and top-up handoff"},
173
174
  {"name": "status", "description": "Show session, model, context, version, and reasoning status"},
174
- {"name": "reasoning", "description": "Set session-only reasoning effort", "input_hint": "low, medium, high, xhigh, max, none, or reset"},
175
175
  )
176
176
 
177
177
  _native_compact = getattr(acp_adapter.server.HermesACPAgent, "_cmd_compact", None)
@@ -283,8 +283,12 @@ _available_commands = getattr(acp_adapter.server.HermesACPAgent, "_available_com
283
283
  @classmethod
284
284
  def thinkpool_available_commands(cls):
285
285
  # Keep the upstream catalog canonical, then append exactly our process-local
286
- # commands. This drives ACP updates and /help from one registry.
287
- base = list(_available_commands.__func__(cls)) if _available_commands else []
286
+ # commands. Compatibility aliases remain executable without appearing in
287
+ # ACP updates or /help; the room-level /effort control is canonical.
288
+ base = [
289
+ item for item in (list(_available_commands.__func__(cls)) if _available_commands else [])
290
+ if str(getattr(item, "name", item.get("name", "") if isinstance(item, dict) else "") or "").lstrip("/").lower() not in _TP_HIDDEN_COMMANDS
291
+ ]
288
292
  try:
289
293
  from acp.schema import AvailableCommand, UnstructuredCommandInput
290
294
  known = {getattr(item, "name", "") for item in base}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.340",
3
+ "version": "0.7.342",
4
4
  "description": "Connect Claude Code, Codex, or Hermes on your computer to a Thinkpool Code room.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,6 +18,7 @@
18
18
  "sdk-admission.mjs",
19
19
  "launcher.mjs",
20
20
  "privacy-report.mjs",
21
+ "agent-visibility.mjs",
21
22
  "byok-detect.mjs",
22
23
  "context-windows.mjs",
23
24
  "claude-session.mjs",
@@ -42,6 +42,7 @@ export function buildPrivacyReport({
42
42
  ['Saved login', 'auth.json', exists(path.join(configDir, 'auth.json')), 'refresh token and account identity'],
43
43
  ['Provider registry', 'providers.json', exists(path.join(configDir, 'providers.json')), 'provider endpoint, model, and API key'],
44
44
  ['Bridge keypair', 'bridge-key.json', exists(path.join(configDir, 'bridge-key.json')), 'private key used to open browser-sealed provider keys'],
45
+ ['Agent picker visibility', 'agent-visibility.json', exists(path.join(configDir, 'agent-visibility.json')), 'which installed runtimes are hidden from terminal pickers'],
45
46
  ['Room directories', 'dirs.json / served.json', exists(path.join(configDir, 'dirs.json')) || exists(path.join(configDir, 'served.json')), 'room-to-project directory mappings'],
46
47
  ['Bridge logs', 'update.log and service logs', exists(path.join(configDir, 'update.log')), 'update and background-service diagnostics'],
47
48
  ]
@@ -105,4 +106,3 @@ export function runPrivacyReport(options) {
105
106
  process.stdout.write(formatPrivacyReport(report))
106
107
  return report
107
108
  }
108
-