SLOPSHOPPER

switchboard

Shared Switchboard gateway, setup, status and reversible Claude startup integration.

newpanecommandstatusnetworkagents
v0.5.1Apache-2.0updated 2026-10-09akparhi/context-engineering/plugins/switchboard
A shopper browsing a rack in a slop shop
README

context-engineering

Repo-managed Claude Code and Codex configuration, shared skills, and a public plugin marketplace.

The premise throughout: the scarce resource is the model's attention, and the second scarcest is mine. Config, skills, and plugins here all follow from that.

Layout

claude-profiles/    one settings.json per machine
  default.json      the default profile
  darkforest.json
claude-shared/      config every profile links to
  CLAUDE.md         how the agent behaves
  output-styles/    terse and reminder
  agents/           subagents copied from Anthropic's feature-dev plugin
codex-profiles/     full Codex config.toml profiles
  default.toml      personal defaults + this machine’s desktop/trust settings
codex-shared/       AGENTS.md, agent roles and macOS sound hooks
shared/skills/      skills used by both Claude and Codex
plugins/            this repo is also a plugin marketplace
  arcane/           pstack-claude workflows and principles
  switchboard/      local gateway that runs Codex models inside Claude Code
bin/setup.mjs       Claude installer
bin/reinstall-plugin.sh  refresh installed plugins after editing them
bin/setup-codex.mjs Codex installer

Setup on a new machine

bun run setup

It asks which profile (default unless you say otherwise), where to put the config (~/.claude unless you say otherwise), and which plugins to install. Both answers can be passed as arguments to skip the questions:

bun run setup darkforest ~/.darkforest/claude

Setup links settings.json to the chosen profile and output-styles/, agents/, CLAUDE.md to claude-shared/, plus skills/ to shared/skills/, then registers this repo as a plugin marketplace and installs whichever plugins you pick — including their dependencies, without which a plugin's MCP server will not start and its tools will not exist.

Re-running is safe: an existing symlink is repointed, and a real file is moved to <name>.bak.<timestamp> rather than overwritten — on a machine that already has config, that file is the only copy.

What's here

claude-profiles/ — the harness

settings.json decides what the tool may do; claude-shared/CLAUDE.md decides how the agent behaves once running. The two are written to match, so the rules are enforceable rather than advisory.

AreaChoiceWhy
Context windowcompact at 256k, 1M availableA large context is a liability — attention degrades and cost climbs as it fills. A soft ceiling keeps the pressure toward delegation while leaving headroom for work that genuinely needs it.
Effortmedium by defaultDepth comes from spawning a stronger subagent, not from making every turn expensive.
PermissionsbypassPermissions, no promptsA config that asks every turn contradicts an agent told not to ask. Safety moves to the deny list and to git being recoverable.
Denieddist/, next/, plan/worktree/cron/notebook modesBuild output teaches nothing; fewer modes means fewer paths to wander down.
Modelspinned aliases--model opus shouldn't drift when a new default ships.
SymbolsENABLE_LSP_TOOL + typescript-lspBacks the "never grep-guess a definition" rule with real resolution.
TelemetryDISABLE_NONESSENTIAL_TRAFFICOne switch covering metrics, error reports, surveys, feature flags. Does not affect auto-updates.
Hooksaudio onlyWith prompts bypassed, a sound is the only signal that the agent needs a human: one for blocked, one for done.

darkforest.json differs from default.json only in machine-specific paths.

claude-shared/CLAUDE.md — the agent

  • just do it (clear directive → execute; the confirm line is drawn at irreversible and unrecoverable, so git operations are free);
  • output brevity (compress by deleting clauses that carried no decision — never negations, never invented abbreviations; suspended for security warnings and irreversible-action confirms);
  • orchestration (main session coordinates, subagents do independent chunks — the reason is context hygiene, not parallelism; artifacts pass as file paths, never pasted);
  • exploration defaults (ast-grep for TS/JS, fd over find, LSP for symbols);
  • coding standards (a YAGNI ladder that stops at the first rung that holds, with a hard floor at validation, security, and anything explicitly asked for).

shared/skills/

ast-grep and ast-grep-outline for syntax-aware search — a structural pattern can't match a string that merely looks like code.

Plugins

Install from any machine, no clone needed:

claude plugin marketplace add akparhi/context-engineering
claude plugin install arcane@akparhi
claude plugin install switchboard@akparhi

Inside Claude Code, the same is /plugin marketplace add akparhi/context-engineering, then /plugin install <name>@akparhi. Add --scope project to enable a plugin for one repo only. Restart Claude Code after installing.

Claude Code runs plugins from its own cache, not from this repo. After editing a plugin here, run bun run reinstall (both plugins) or bun run reinstall arcane, then restart open sessions.

arcane

A fork of pstack-claude, the Claude Code port of pstack. A SessionStart hook injects a routing mandate: multi-file changes, design choices and unknown bugs go through the arcane:alchemy skill, which picks a playbook (bug fix, feature, refactoring, perf, PR, shipping and more). The same context lists which skill to use for each activity, such as arcane:architect for design, arcane:test-audit for tests, and arcane:pr for PRs. Principle skills load on their own when their situation comes up.

Every subagent model comes from one file, plugins/arcane/models.json, which the hook injects each session. Edit it and reinstall to change a role's model. Each user can also override its tiers, default effort, and each role in /config, without editing the file. bun run reinstall regenerates those /config options from models.json with bin/sync-arcane-config.mjs. Differences from upstream: no Codex support, no bro or setup-pstack skill, effort limited to session or high, opus instead of fable as the strongest model, and quoted agent descriptions that fix upstream's "Malformed plugin agent" startup error. See plugins/arcane/README.md.

switchboard

Starts the real Claude Code binary behind a local gateway so external providers' models (such as Codex models through a ChatGPT login) show up in /model and can run as subagents. Needs Bun installed permanently; Bun fetches the plugin's dependencies on first run. After installing, run /switchboard:setup to add the launch command (default switchboard), /switchboard:login to sign in, and /switchboard:status to check it. /switchboard:uninstall reverses setup.

Codex setup

Requires Node 22+ and a current Codex CLI (validated with 0.154.0). Authenticate with codex login on new machines.

npm run setup:codex
# Alternative profile/destination; existing files/directories are backed up.
node bin/setup-codex.mjs default ~/.codex
# Offline/relink only:
node bin/setup-codex.mjs default ~/.codex --skip-plugins

~/.codex/config.toml links directly to codex-profiles/default.toml. AGENTS.md, agents/, hooks.json and hooks/ link to codex-shared/. Both ~/.claude/skills and ~/.agents/skills link to shared/skills/; Codex uses the latter documented discovery location. Its existing .codex/skills/.system stays runtime-managed. For a custom destination, setup links each shared skill under <destination>/skills, which Codex discovers through CODEX_HOME. The default home uses ~/.agents/skills so runtime-managed system skills remain untouched. Conflicting skill directories are backed up before linking; .system stays private to that home.

Codex 0.154.0 does not inherit parent-directory trust across nested Git repositories (verified with a fresh nested repository and config/read). Both requested roots, /Users/akparhi/Projects and /Users/akparhi/Documents/Codex, have entries; existing discovered Git roots beneath them are listed explicitly. New repositories still need an explicit trust entry.

The default profile includes this machine's existing desktop preferences, trusted paths, bundled-marketplace paths and local PDF-skill path. Copy it to codex-profiles/<machine>.toml and adjust those paths on another machine. Credentials, sessions, caches and plugin downloads remain outside git. App settings changes may write through the config symlink; review git diff afterward. Re-run setup if an app replaces the symlink.

AreaDefault
Main agentGPT-6 Astra, low reasoning, concise output
Compaction256,000 total-context tokens; model's actual context capacity unchanged
Permissionsnever + danger-full-access, matching the existing local setup
IntegrationsSuperpowers; Context7 via the existing stdio MCP configuration
Web researchNative live web search; independent of ChatGPT connectors
AppsApp tools disabled, default app enablement false
PluginsRemote catalog and recommendations off; Superpowers, browser and unified computer use enabled; other listed bundled plugins disabled
SkillsShared local skills + Superpowers + Codex system skills; local PDF skill enabled
AnalyticsAnalytics and feedback disabled
SoundsmacOS Funk for attention; Submarine for completion

Codex has no documented one-for-one equivalent of Claude's syncClaudeAiSkills / syncClaudeAiPlugins. This profile disables apps, the remote catalog, and unwanted bundled plugins explicitly; browser and unified computer use are enabled. It does not disconnect accounts or change ChatGPT web settings. New app/runtime releases may introduce new plugins: inspect the active skill catalog after upgrades. codex plugin list omits remote catalog rows when that catalog is disabled; codex debug prompt-input confirms cached Superpowers skills still load.

Subagents

RoleModel / effortScope
explorerGPT-5.6 Luna / lowRead-only code mapping and reference tracing
researcherGPT-5.6 Luna / highRead-only web and Context7 research; primary-source URLs
workerLuna/high for simple tasks; Sol/low for anything beyond simple; Astra/low for 3D/gamesImplementation and tests; Astra baseline for 3D/games
reviewerGPT-5.6 Sol / lowRead-only correctness/security/architecture review

Three concurrent children maximum; untyped children default to Luna/high. The main session stays the orchestrator: delegate implementation, research, exploration, testing and review, including single noisy tasks. Small tasks use one focused child rather than fan-out. Pass isolated briefs and artifact paths; return concise conclusions and verification summaries. Role instructions disable recursive delegation. Choose higher effort up front for high-fidelity work. The single worker role leaves model/effort unpinned: the orchestrator sets both explicitly on an isolated spawn; omitted values fall back to global Luna/high defaults. Explicitly select Sol/low for anything beyond simple implementation; when uncertain, choose Sol/low. Fixed model/effort settings in other custom roles take precedence over spawn overrides; for another tier, use an isolated default agent with explicit model and effort. For 3D/game implementation, start at Astra/low and raise Astra effort when fidelity demands it. Routing choices do not guarantee quota savings. Read-only roles request a read-only sandbox, but parent runtime permission overrides can take precedence. Roles and model availability depend on the installed harness; standalone role files are not an assertion that every client exposes an agent_type argument.

Current Codex releases support bounded multi-agent wait tuning through [features.multi_agent_v2]; the default profile configures a 60-second minimum, 10-minute default and one-hour maximum waits. Runtime limits still apply, so callers must use a shorter timeout when required. Longer event waits reduce polling churn, but the claimed token savings come from a community report and are not a confirmed fix.

Sound hooks

Restart Codex and run /hooks to review/trust the three command hooks. Codex skips untrusted hooks; changing their definitions requires another review. Trust hashes are recorded by Codex in the linked profile and are keyed by the local hooks path. This machine’s three sound hooks have been reviewed and trusted; other paths or changed definitions require review.

  • PermissionRequest: Funk (normally rare with approvals disabled).
  • PreToolUse for question tools: Funk.
  • Stop: Submarine. No SubagentStop sound.

Scripts return {} and never block a decision on playback failure. Playback uses macOS afplay, at the system volume, with a timeout; other platforms silently skip audio. These match claude-profiles/default.json at commit 42387a7 (September 1, 2026), the last version before September 2.

node codex-shared/hooks/sound.mjs attention </dev/null
node codex-shared/hooks/sound.mjs complete </dev/null
npm run test:config

Hooks use ${CODEX_HOME:-$HOME/.codex}. Launch custom homes with CODEX_HOME=/path/to/config codex. Global guidance replaces Claude-specific reminder/cache-heal hooks; no prompt text is re-injected on every turn. No LSP plugin is installed; use available symbol tools or the project's typechecker when needed.

Backups and rollback

Setup moves real destinations to <name>.bak.<timestamp> before linking. Re-running unchanged setup is a no-op; existing symlinks are repointed. Restore a real config by removing its symlink and renaming the chosen backup back to the original filename. Restore the associated AGENTS.md and hooks.json backups the same way. Shared skills remain in this repository.

Sources

Source 9 files
hooks/register.ts 162 lines
1import type { EngineInterface, Register } from 'claude-code';
2import { register as registerCompaction } from './compact.ts';
3import { register as registerLifecycle } from './lifecycle.ts';
4import { type PolicyClient, type PolicyState, recordPrompt } from './policy.ts';
5import { isHarnessModel } from './provider.ts';
6import { register as registerUsage } from './usage.ts';
7import { register as registerWorkers } from './workers.ts';
8
9const maxBody = 32000;
10
11type GatewayResponse = {
12  refused?: true;
13  httpStatus?: number;
14  error?: string;
15  output?: string;
16  isError?: boolean;
17  terminal?: boolean;
18  accepted?: boolean;
19  generation?: number | string;
20  status?: string;
21  stale?: boolean;
22  nextCursor?: number;
23};
24
25// Claude Code 2.1.272 loads exactly one entry from hooks.json `modules`; compose here.
26export const register: Register = (on, options) => {
27  const policyState: PolicyState = {};
28  registerUsage(on, options);
29  // Detaching makes the gateway forget this session's mode; keeping its generation
30  // here would leave every later prompt stale against a gateway holding nothing.
31  const agentModels = new Map<string, string>();
32  registerLifecycle(
33    on,
34    options,
35    () => {
36      policyState.generation = undefined;
37      policyState.prompt = undefined;
38      policyState.harnessReady = false;
39      policyState.preparing = undefined;
40    },
41    agentModels,
42  );
43  registerCompaction(on, options, agentModels);
44  registerWorkers(on, options, agentModels, policyState);
45  on('session.start', async ($, event, next) => {
46    if (!(await active($))) {
47      return next(event);
48    }
49    await $.command.register({
50      name: 'switchboard-usage',
51      description: 'Open provider quotas, spend, and session receipts.',
52      immediate: true,
53    });
54    const response = await request($, '/switchboard/mod/session', {
55      sessionId: await $.session.id(),
56      cwd: await $.session.cwd(),
57      model: await $.session.model(),
58      event: 'start',
59    });
60    policyState.generation =
61      typeof response?.generation === 'number' ? response.generation : undefined;
62    return next(event);
63  });
64  on('classic.UserPromptSubmit', async ($, event, next) => {
65    if (await active($)) {
66      policyState.prompt = snapshotOf(event);
67      policyState.harnessReady = false;
68      policyState.preparing = undefined;
69      policyState.generation = await recordPrompt(
70        await policyClient($),
71        policyState.prompt,
72        policyState.generation,
73      );
74      policyState.harnessReady =
75        policyState.generation !== undefined && isHarnessModel(await $.session.model());
76    }
77    return next(event);
78  });
79  on('classic.SessionStart', async ($, event, next) => {
80    if ((await active($)) && typeof event.permission_mode === 'string') {
81      policyState.prompt = snapshotOf(event);
82      policyState.harnessReady = false;
83      policyState.preparing = undefined;
84      policyState.generation = await recordPrompt(
85        await policyClient($),
86        policyState.prompt,
87        policyState.generation,
88      );
89      policyState.harnessReady =
90        policyState.generation !== undefined && isHarnessModel(await $.session.model());
91    }
92    return next(event);
93  });
94};
95
96async function active($: EngineInterface): Promise<boolean> {
97  const base = await $.env.get('SWITCHBOARD_MOD_GATEWAY_URL');
98  const token = await $.env.get('SWITCHBOARD_GATEWAY_TOKEN');
99  return Boolean(base && token);
100}
101
102async function request($: EngineInterface, route: string, payload: Record<string, unknown>) {
103  const base = await $.env.get('SWITCHBOARD_MOD_GATEWAY_URL');
104  const token = await $.env.get('SWITCHBOARD_GATEWAY_TOKEN');
105  if (!base || !token) {
106    return undefined;
107  }
108  const body = JSON.stringify(payload);
109  if (encodeURIComponent(body).replace(/%[A-F\d]{2}/gi, 'x').length > maxBody) {
110    return undefined;
111  }
112  const isGet = route.startsWith('/switchboard/mod/display?') || route.startsWith('/switchboard/mod/mode?');
113  let timer: ReturnType<typeof setTimeout> | undefined;
114  try {
115    const response = $.http.fetch(`${base}${route}`, {
116      method: isGet ? 'GET' : 'POST',
117      headers: { 'content-type': 'application/json', 'x-switchboard-gateway-token': token },
118      ...(isGet ? {} : { body }),
119    });
120    const timeout = new Promise<never>((_, reject) => {
121      timer = setTimeout(() => reject(new Error('gateway request timeout')), 1500);
122    });
123    const result = await Promise.race([response, timeout]);
124    return result.ok ? (JSON.parse(result.text) as GatewayResponse) : refusal(result);
125  } catch {
126    return undefined;
127  } finally {
128    if (timer) {
129      clearTimeout(timer);
130    }
131  }
132}
133
134// A refused reply keeps the gateway's reason; `refused` stops polling callers.
135function refusal(result: { text: string; status: number }): GatewayResponse {
136  let error: string | undefined;
137  try {
138    const parsed: unknown = JSON.parse(result.text);
139    if (parsed && typeof parsed === 'object') {
140      error = (parsed as { error?: unknown }).error as string | undefined;
141    }
142  } catch {
143    // A non-JSON body still names the status below.
144  }
145  return {
146    refused: true,
147    httpStatus: result.status,
148    error: error ?? `gateway ${result.status}`,
149  };
150}
151
152function snapshotOf(event: { session_id: string; cwd: string; permission_mode?: string }) {
153  return { sessionId: event.session_id, cwd: event.cwd, permissionMode: event.permission_mode };
154}
155
156async function policyClient($: EngineInterface): Promise<PolicyClient> {
157  return {
158    model: await $.session.model(),
159    request: (route, payload) => request($, route, payload),
160  };
161}
162
hooks/compact.ts 136 lines
1import type { EngineInterface, Register } from 'claude-code';
2import { isHarnessModel } from './provider.ts';
3
4const maxBody = 32000;
5
6type GatewayResponse = {
7  accepted?: boolean;
8  error?: string;
9  generation?: number;
10  precomputeId?: string;
11  allow?: boolean;
12  messages?: readonly import('claude-code').SessionMessage[];
13};
14
15export const register = (
16  on: Parameters<Register>[0],
17  _options: Parameters<Register>[1],
18  agentModels: ReadonlyMap<string, string> = new Map(),
19) => {
20  on('session.compact', async ($, event, next) => {
21    // session.model() describes only the main loop. A Claude child must never
22    // inherit its external parent's compaction policy (or the reverse).
23    const model = await compactionModel($, event.agentId, agentModels);
24    if (!isHarnessModel(model)) {
25      return next(event);
26    }
27    if (!(await active($))) {
28      return next(event);
29    }
30    const sessionId = await $.session.id();
31    const mode = await request($, {}, `/switchboard/mod/mode?sessionId=${encodeURIComponent(sessionId)}`);
32    if (mode?.generation === undefined) {
33      return { skip: 'Switchboard compaction policy generation is unavailable.' };
34    }
35    const payload = {
36      sessionId,
37      agentId: event.agentId,
38      generation: mode.generation,
39      trigger: event.trigger,
40      messages: event.messages,
41      instructions: event.instructions,
42    };
43    if (event.trigger === 'precompute') {
44      await precompute($, payload);
45      return { skip: 'Switchboard summary preparation runs outside the hook budget.' };
46    }
47    const result = await request($, payload, '/switchboard/mod/compact/authorize');
48    if (result?.messages) {
49      return { messages: result.messages };
50    }
51    if (result?.allow) {
52      return next(event);
53    }
54    // Oversized transcripts still require a separate, small tool-free authorization.
55    const fallback = await request(
56      $,
57      {
58        sessionId,
59        agentId: event.agentId,
60        generation: mode.generation,
61      },
62      '/switchboard/mod/compact/authorize',
63    );
64    if (!fallback?.allow) {
65      return { skip: 'Switchboard tool-free compaction authorization was not acknowledged.' };
66    }
67    return next(event);
68  });
69};
70
71async function precompute($: EngineInterface, payload: Record<string, unknown>) {
72  const prepared = await request($, payload, '/switchboard/mod/compact/precompute');
73  if (prepared?.accepted && prepared.precomputeId) {
74    void request(
75      $,
76      {
77        sessionId: payload.sessionId,
78        agentId: payload.agentId,
79        generation: payload.generation,
80        precomputeId: prepared.precomputeId,
81      },
82      '/switchboard/mod/compact/run',
83    );
84  }
85}
86
87async function active($: EngineInterface): Promise<boolean> {
88  const base = await $.env.get('SWITCHBOARD_MOD_GATEWAY_URL');
89  const token = await $.env.get('SWITCHBOARD_GATEWAY_TOKEN');
90  return Boolean(base && token);
91}
92
93async function request(
94  $: EngineInterface,
95  payload: Record<string, unknown>,
96  route = '/switchboard/mod/session',
97) {
98  const base = await $.env.get('SWITCHBOARD_MOD_GATEWAY_URL');
99  const token = await $.env.get('SWITCHBOARD_GATEWAY_TOKEN');
100  if (!base || !token) {
101    return undefined;
102  }
103  const body = JSON.stringify(payload);
104  if (encodeURIComponent(body).replace(/%[A-F\d]{2}/gi, 'x').length > maxBody) {
105    return undefined;
106  }
107  let timer: ReturnType<typeof setTimeout> | undefined;
108  try {
109    const response = $.http.fetch(`${base}${route}`, {
110      method: route.includes('?') ? 'GET' : 'POST',
111      headers: { 'content-type': 'application/json', 'x-switchboard-gateway-token': token },
112      ...(route.includes('?') ? {} : { body }),
113    });
114    const timeout = new Promise<never>((_, reject) => {
115      timer = setTimeout(() => reject(new Error('gateway request timeout')), 1500);
116    });
117    const result = await Promise.race([response, timeout]);
118    return result.ok ? (JSON.parse(result.text) as GatewayResponse) : undefined;
119  } catch {
120    return undefined;
121  } finally {
122    if (timer) {
123      clearTimeout(timer);
124    }
125  }
126}
127
128/** The main model is never evidence of a child's provider. */
129export async function compactionModel(
130  $: EngineInterface,
131  agentId: string | undefined,
132  agentModels: ReadonlyMap<string, string>,
133): Promise<string | undefined> {
134  return agentId ? agentModels.get(agentId) : $.session.model();
135}
136
hooks/lifecycle.ts 138 lines
1import type { EngineInterface, Register } from 'claude-code';
2import { isHarnessModel } from './provider.ts';
3import { forgetUsageSession } from './usage.ts';
4
5type Status = {
6  model?: string;
7  state?: string;
8  detail?: string;
9  elapsedMs?: number;
10  startedAt?: number;
11};
12
13export const register = (
14  on: Parameters<Register>[0],
15  _options: Parameters<Register>[1],
16  onDetach: () => void = () => {},
17  models: Map<string, string> = new Map(),
18) => {
19  const running = new Map<string, object>();
20  on('turn.step', async function* ($, event, next) {
21    // Observability only: forward every core chunk unchanged, without serving inference.
22    const key = event.agentId ?? 'main';
23    models.set(key, event.model);
24    const native = isHarnessModel(event.model);
25    if (native && !running.has(key) && running.size < 128) {
26      const token = {};
27      running.set(key, token);
28      void poll($, event.agentId, () => running.get(key) === token);
29    }
30    void postStep($, event);
31    return yield* next(event);
32  });
33  on('turn.complete', async ($, event, next) => {
34    const key = event.agentId ?? 'main';
35    const model = models.get(key);
36    if (model !== undefined && (model.startsWith('switchboard/') || (await isOpenAiAlias($, model)))) {
37      await request($, '/switchboard/mod/usage/complete', {
38        sessionId: await $.session.id(),
39        agentId: event.agentId,
40        turnId: event.turnId,
41        outcome: event.reason,
42      });
43    }
44    const wasRunning = running.delete(key);
45    // Retain a child's identity between turns: compaction can precede its next step.
46    if (event.isAborted && isHarnessModel(model)) {
47      void cancelCompaction($, event.agentId);
48    }
49    if (wasRunning && running.size === 0) {
50      void $.ui.status(undefined);
51    }
52    return next(event);
53  });
54  on('session.detach', async ($, event, next) => {
55    onDetach();
56    forgetUsageSession(await $.session.id());
57    running.clear();
58    models.clear();
59    void detach($);
60    return next(event);
61  });
62};
63
64async function postStep($: EngineInterface, event: object) {
65  await request($, '/switchboard/mod/telemetry', { ...event, sessionId: await $.session.id() });
66}
67
68async function detach($: EngineInterface) {
69  await request($, '/switchboard/mod/detach', { sessionId: await $.session.id() });
70}
71
72async function poll($: EngineInterface, agentId: string | undefined, active: () => boolean) {
73  const since = Date.now();
74  const sessionId = await $.session.id();
75  const query = `sessionId=${encodeURIComponent(sessionId)}&agentId=${encodeURIComponent(agentId ?? 'main')}`;
76  let failures = 0;
77  while (active() && failures < 5) {
78    const status = await request($, `/switchboard/mod/lifecycle?${query}`);
79    if (!active()) {
80      return;
81    }
82    failures = status ? 0 : failures + 1;
83    if (status?.state && (status.startedAt ?? 0) >= since) {
84      await $.ui.status(statusText(status, agentId));
85      if (status.state !== 'running') {
86        return;
87      }
88    }
89    await new Promise((resolve) => setTimeout(resolve, 500));
90  }
91}
92
93async function request(
94  $: EngineInterface,
95  route: string,
96  payload?: object,
97): Promise<Status | undefined> {
98  const base = await $.env.get('SWITCHBOARD_MOD_GATEWAY_URL');
99  const token = await $.env.get('SWITCHBOARD_GATEWAY_TOKEN');
100  if (!base || !token) {
101    return undefined;
102  }
103  let timer: ReturnType<typeof setTimeout> | undefined;
104  try {
105    const response = await Promise.race([
106      $.http.fetch(`${base}${route}`, {
107        method: payload ? 'POST' : 'GET',
108        headers: { 'content-type': 'application/json', 'x-switchboard-gateway-token': token },
109        ...(payload ? { body: JSON.stringify(payload) } : {}),
110      }),
111      new Promise<never>((_, reject) => {
112        timer = setTimeout(() => reject(new Error('Gateway timeout')), 1500);
113      }),
114    ]);
115    return response.ok ? (JSON.parse(response.text) as Status) : undefined;
116  } catch {
117    return undefined;
118  } finally {
119    if (timer) {
120      clearTimeout(timer);
121    }
122  }
123}
124
125function statusText(status: Status, agentId: string | undefined) {
126  const elapsed = Math.floor((status.elapsedMs ?? 0) / 1000);
127  return `${status.model} · ${agentId ?? 'main'} · ${status.state} · ${elapsed}s ${status.detail ?? ''}`;
128}
129async function cancelCompaction($: EngineInterface, agentId?: string) {
130  await request($, '/switchboard/mod/compact/cancel', { sessionId: await $.session.id(), agentId });
131}
132
133/** Bare OpenAI aliases (sol, luna, ...) are published by the launcher; hooks cannot import its model table. */
134async function isOpenAiAlias($: EngineInterface, model: string) {
135  const aliases = await $.env.get('SWITCHBOARD_OPENAI_MODELS');
136  return aliases?.split(',').includes(model.replace(/\[1m\]$/i, '')) ?? false;
137}
138
hooks/policy.ts 151 lines
1import { isHarnessModel } from './provider.ts';
2
3export type PolicyResponse = {
4  refused?: true;
5  httpStatus?: number;
6  error?: string;
7  generation?: number | string;
8  status?: string;
9  accepted?: boolean;
10};
11export type PolicyClient = {
12  model: string;
13  request: (route: string, payload: Record<string, unknown>) => Promise<PolicyResponse | undefined>;
14};
15export type PromptSnapshot = { sessionId: string; cwd: string; permissionMode: unknown };
16export type PolicyState = {
17  generation?: number;
18  prompt?: PromptSnapshot;
19  harnessReady?: boolean;
20  preparing?: Promise<number | undefined>;
21};
22
23/** Share one admission across helpers spawned from the same prompt. */
24export async function ensureHarnessPolicy(client: PolicyClient, state: PolicyState) {
25  const snapshot = state.prompt;
26  if (!snapshot || state.harnessReady) {
27    return;
28  }
29  const preparing = state.preparing ?? admitPrompt(client, snapshot, state.generation);
30  state.preparing = preparing;
31  const generation = await preparing;
32  if (state.prompt === snapshot) {
33    state.generation = generation;
34    state.harnessReady = generation !== undefined;
35    state.preparing = undefined;
36  }
37}
38
39/** Prepare translated settings only for a harness prompt or a requested harness worker. */
40export async function admitPrompt(
41  client: PolicyClient,
42  snapshot: PromptSnapshot,
43  sourceGeneration: number | undefined,
44): Promise<number | undefined> {
45  const prepared = await preparePolicy(client, snapshot.sessionId, snapshot.cwd, sourceGeneration);
46  if (!prepared) {
47    return undefined;
48  }
49  const response = await client.request('/switchboard/mod/session', {
50    policyGeneration: prepared.policyGeneration,
51    sessionId: snapshot.sessionId,
52    cwd: snapshot.cwd,
53    model: client.model,
54    event: 'prompt',
55    generation: prepared.generation,
56    permissionMode: snapshot.permissionMode,
57  });
58  if (!response?.accepted || typeof response.generation !== 'number') {
59    return undefined;
60  }
61  return response.generation;
62}
63
64/** Only an actual harness prompt waits for translated settings policy. */
65export async function recordPrompt(
66  client: PolicyClient,
67  snapshot: PromptSnapshot,
68  generation: number | undefined,
69): Promise<number | undefined> {
70  const model = client.model;
71  if (isHarnessModel(model)) {
72    return admitPrompt(client, snapshot, generation);
73  }
74  const response = await client.request('/switchboard/mod/session', {
75    ...snapshot,
76    model,
77    event: 'prompt',
78  });
79  return response?.accepted && typeof response.generation === 'number'
80    ? response.generation
81    : undefined;
82}
83
84type PolicyHandoff = { policyGeneration: string; generation: number | undefined };
85
86/** Exported for offline coverage of the stale-generation resync. */
87export async function preparePolicy(
88  client: PolicyClient,
89  sessionId: string,
90  cwd: string,
91  sourceGeneration: number | undefined,
92): Promise<PolicyHandoff | undefined> {
93  let generation = sourceGeneration;
94  let started = await client.request('/switchboard/mod/policy', { sessionId, cwd, sourceGeneration });
95  if (started?.refused) {
96    // A reloaded hooks module keeps a mode generation the gateway no longer agrees
97    // with, and `/clear` detaches the session so the gateway holds none at all;
98    // either way every later prompt reads stale. Adopt the gateway's own value once,
99    // including the absence of one, which begins a fresh session.
100    const resynced = await modeGeneration(client, sessionId);
101    if (!resynced || resynced.generation === generation) {
102      return undefined;
103    }
104    generation = resynced.generation;
105    started = await client.request('/switchboard/mod/policy', {
106      sessionId,
107      cwd,
108      sourceGeneration: generation,
109    });
110  }
111  if (started?.refused || typeof started?.generation !== 'string') {
112    return undefined;
113  }
114  const policyGeneration = await awaitPolicy(client, sessionId, started.generation);
115  return policyGeneration === undefined ? undefined : { policyGeneration, generation };
116}
117
118async function awaitPolicy(client: PolicyClient, sessionId: string, generation: string) {
119  // Policy discovery runs `claude plugin list` and settings admission; on a
120  // cold Windows start that takes several seconds. Stay under the 10 s hook budget.
121  const deadline = Date.now() + 8000;
122  while (Date.now() < deadline) {
123    const result = await client.request('/switchboard/mod/policy', { sessionId, generation });
124    if (result?.status === 'ready') {
125      return generation;
126    }
127    if (!result || result.refused || result.status === 'failed') {
128      return undefined;
129    }
130    await new Promise((resolve) => setTimeout(resolve, 100));
131  }
132  return undefined;
133}
134
135/** The gateway's own mode generation, or `{ generation: undefined }` when it holds none. */
136async function modeGeneration(client: PolicyClient, sessionId: string) {
137  const mode = await client.request(
138    `/switchboard/mod/mode?sessionId=${encodeURIComponent(sessionId)}`,
139    {},
140  );
141  if (typeof mode?.generation === 'number') {
142    return { generation: mode.generation };
143  }
144  // A 409 is the gateway answering that it holds no mode for this session, as after
145  // `/clear` detaches it. An unreachable gateway leaves the truth unknown instead.
146  if (mode && (!mode.refused || mode.httpStatus === 409)) {
147    return { generation: undefined };
148  }
149  return undefined;
150}
151
hooks/provider.ts 5 lines
1/** These providers execute tools outside Claude Code and need policy translation. */
2export function isHarnessModel(_model: string | undefined): boolean {
3  return false;
4}
5
hooks/usage.ts 201 lines
1import type { EngineInterface, Register } from 'claude-code';
2import { quotaAdvice } from './quota-advice.ts';
3import type { UsagePaneProps } from './usage-view.ts';
4
5const panes = new Map<string, UsagePaneProps>();
6const advisorySessions = new Set<string>();
7
8export function forgetUsageSession(session: string) {
9  advisorySessions.delete(session);
10  panes.delete(session);
11}
12
13export const register: Register = (on) => {
14  on('classic.PreToolUse', async ($, event, next) => {
15    if ((event.tool !== 'Agent' && event.tool !== 'Task') || event.agentId !== undefined) {
16      return next(event);
17    }
18    const session = await $.session.id();
19    if (!advisorySessions.has(session)) {
20      return next(event);
21    }
22    const snapshot = dashboard(
23      await request($, `/switchboard/mod/usage?sessionId=${encodeURIComponent(session)}&view=providers`),
24    );
25    const result = await next(event);
26    // Preserve all permission decisions and tool arguments, including on lookup failure.
27    if (!advisorySessions.has(session)) {
28      return result;
29    }
30    return {
31      ...result,
32      additionalContext: [...(result.additionalContext ?? []), quotaAdvice(snapshot)],
33    };
34  });
35  on('command.run', { command: 'switchboard-usage' }, async ($, event) => {
36    if (event.args.trim()) {
37      return { text: 'Use /switchboard-usage without arguments.' };
38    }
39    const session = await $.session.id();
40    const response = dashboard(
41      await request($, `/switchboard/mod/usage?sessionId=${encodeURIComponent(session)}&view=providers`),
42    );
43    if (!response) {
44      return { text: 'Switchboard usage is unavailable. Launch this session with switchboard.' };
45    }
46    if (panes.size >= 16) {
47      panes.clear();
48    }
49    panes.set(session, { ...response, quotaAdviceEnabled: advisorySessions.has(session) });
50    try {
51      await $.ui.open({
52        id: 'switchboard-usage',
53        title: 'Switchboard usage',
54        focus: true,
55        closeOnEscape: true,
56        rows: 20,
57      });
58      $.ui.invalidate('ui.render');
59      return {};
60    } catch {
61      return {
62        text: response.providers
63          .map((provider) => `${provider.name}: ${provider.summary}`)
64          .join('\n'),
65      };
66    }
67  });
68  on('ui.render', { component: 'Pane' }, async ($, event, next) => {
69    if (event.requestId !== 'switchboard-usage' || event.surface !== 'terminal') {
70      return next(event);
71    }
72    const props = panes.get(await $.session.id());
73    if (!props) {
74      return next(event);
75    }
76    const { Client } = $.ui.resolve(event);
77    return Client({ key: 'usage', module: './usage-view.ts', props, width: '100%', flexGrow: 1 });
78  });
79  on('ui.message', { source: 'client' }, async ($, event, next) => {
80    if (event.requestId !== 'switchboard-usage' || event.element !== 'usage' || !record(event.data)) {
81      return next(event);
82    }
83    const action = event.data.action;
84    if (action !== 'refresh' && action !== 'receipts' && action !== 'toggle-quota-advice') {
85      return next(event);
86    }
87    const session = await $.session.id();
88    const previous = panes.get(session);
89    if (!previous) {
90      return next(event);
91    }
92    if (action === 'toggle-quota-advice') {
93      const enabled = !advisorySessions.has(session);
94      if (enabled) {
95        advisorySessions.add(session);
96      } else {
97        advisorySessions.delete(session);
98      }
99      const props = { ...previous, quotaAdviceEnabled: enabled };
100      panes.set(session, props);
101      return { props };
102    }
103    const route =
104      action === 'refresh'
105        ? `/switchboard/mod/usage?view=providers&refresh=true&sessionId=${encodeURIComponent(session)}`
106        : `/switchboard/mod/receipts?sessionId=${encodeURIComponent(session)}`;
107    const response = await request($, route);
108    const props = updatePane(previous, action, response);
109    panes.set(session, props);
110    return { props };
111  });
112};
113
114function record(value: unknown): value is Record<string, unknown> {
115  return value !== null && typeof value === 'object' && !Array.isArray(value);
116}
117function dashboard(value: unknown): UsagePaneProps | undefined {
118  if (!record(value) || typeof value.updatedAt !== 'string' || !Array.isArray(value.providers)) {
119    return undefined;
120  }
121  if (
122    !value.providers.every(
123      (item) =>
124        record(item) &&
125        typeof item.id === 'string' &&
126        typeof item.name === 'string' &&
127        typeof item.status === 'string' &&
128        typeof item.summary === 'string' &&
129        Array.isArray(item.details) &&
130        item.details.every((line) => typeof line === 'string'),
131    )
132  ) {
133    return undefined;
134  }
135  return value as UsagePaneProps;
136}
137function updatePane(previous: UsagePaneProps, action: string, response: unknown): UsagePaneProps {
138  if (action === 'refresh') {
139    const refreshed = dashboard(response);
140    return refreshed
141      ? {
142          ...refreshed,
143          receiptLines: previous.receiptLines,
144          quotaAdviceEnabled: previous.quotaAdviceEnabled,
145        }
146      : { ...previous, error: 'Refresh failed. Showing the previous values.' };
147  }
148  if (!record(response) || !Array.isArray(response.receipts)) {
149    return { ...previous, error: 'Could not load receipts.' };
150  }
151  return {
152    ...previous,
153    error: undefined,
154    receiptLines: response.receipts.slice(-20).reverse().flatMap(receiptLines),
155  };
156}
157function receiptLines(value: unknown): string[] {
158  if (!record(value) || !record(value.usage)) {
159    return [];
160  }
161  const owner = typeof value.agentId === 'string' ? value.agentId : 'Main turn';
162  return [
163    `${owner} · ${String(value.outcome)}${value.incomplete ? ' (incomplete)' : ''} · ${String(value.time)}`,
164    `  ${String(value.requests)} requests · ${String(value.usage.input_tokens)} input · ${String(value.usage.output_tokens)} output`,
165    ...(Array.isArray(value.entries) ? value.entries.flatMap(receiptEntry) : []),
166  ];
167}
168function receiptEntry(value: unknown): string[] {
169  if (!record(value)) {
170    return [];
171  }
172  return [
173    `  ${String(value.provider)} · ${String(value.model ?? 'model unreported')} · effort ${String(value.effort ?? 'unreported')} · ${String(value.endpoint ?? 'endpoint unreported')} · ${String(value.source)}`,
174  ];
175}
176
177async function request($: EngineInterface, route: string): Promise<unknown> {
178  const base = await $.env.get('SWITCHBOARD_MOD_GATEWAY_URL');
179  const token = await $.env.get('SWITCHBOARD_GATEWAY_TOKEN');
180  if (!base || !token) {
181    return undefined;
182  }
183  let timer: ReturnType<typeof setTimeout> | undefined;
184  try {
185    const result = await Promise.race([
186      $.http.fetch(`${base}${route}`, {
187        method: 'GET',
188        headers: { 'x-switchboard-gateway-token': token },
189      }),
190      new Promise<never>((_, reject) => {
191        timer = setTimeout(() => reject(new Error('Usage timeout')), 8500);
192      }),
193    ]);
194    return result.ok ? JSON.parse(result.text) : undefined;
195  } catch {
196    return undefined;
197  } finally {
198    clearTimeout(timer);
199  }
200}
201
hooks/workers.ts 204 lines
1import type { EngineInterface, Register } from 'claude-code';
2import {
3  ensureHarnessPolicy,
4  type PolicyClient,
5  type PolicyResponse,
6  type PolicyState,
7} from './policy.ts';
8import { isHarnessModel } from './provider.ts';
9
10const maxBody = 32000;
11const issues =
12  'https://github.com/akparhi/context-engineering/issues/new';
13
14/**
15 * A refusal the agent reads, with the invitation to report it.
16 *
17 * Most refusals are the plugin working: a mode the user chose, a worker their
18 * settings do not offer. The invitation is conditional so an ordinary denial
19 * does not become an issue, and it never files anything on the user's behalf.
20 */
21function reportable(reason: string) {
22  return `${reason}\n\nIf this reads like a defect in the switchboard plugin rather than a permission the user chose, tell them so and offer to open an issue at ${issues}, quoting the reason above.`;
23}
24
25type GatewayResponse = PolicyResponse & {
26  accepted?: boolean;
27  error?: string;
28  isOffered?: boolean;
29  execution?: 'claude' | 'harness';
30  known?: boolean;
31  model?: string;
32};
33
34// A refused reply keeps only the reason: a non-2xx status never carries an
35// acknowledgement, so every caller still fails closed on the HTTP status alone.
36function refusal(text: string, status: number): GatewayResponse {
37  let reason: string | undefined;
38  try {
39    const parsed: unknown = JSON.parse(text);
40    if (parsed && typeof parsed === 'object') {
41      const value = (parsed as { error?: unknown }).error;
42      reason = typeof value === 'string' && value ? value : undefined;
43    }
44  } catch {
45    // A non-JSON body still names the status below.
46  }
47  const detail = text.trim().slice(0, 200);
48  return {
49    refused: true,
50    httpStatus: status,
51    error: reason ?? (detail ? `gateway ${status}: ${detail}` : `gateway ${status}`),
52  };
53}
54
55export const register = (
56  on: Parameters<Register>[0],
57  _options: Parameters<Register>[1],
58  agentModels: Map<string, string> = new Map(),
59  policyState: PolicyState = {},
60) => {
61  on('agent.offer', async ($, event, next) => {
62    if (!(await active($))) {
63      return next(event);
64    }
65    const response = await request(
66      $,
67      {
68        sessionId: await $.session.id(),
69        cwd: await $.session.cwd(),
70        agent: event.agent,
71        parentModel: await $.session.model(),
72      },
73      '/switchboard/mod/offer',
74    );
75    // Claude owns its own catalog. Only a positively identified harness worker
76    // is subject to Switchboard's settings-translation compatibility filter.
77    return response?.execution === 'harness' && response.isOffered === false
78      ? { isOffered: false }
79      : next(event);
80  });
81  on('classic.SubagentStart', async ($, event, next) => {
82    if (!(await active($))) {
83      return next(event);
84    }
85    const response = await request($, {
86      sessionId: event.session_id,
87      agentId: event.agent_id,
88      subagentType: event.agent_type,
89      cwd: event.cwd,
90    });
91    if (response?.accepted && response.model) {
92      agentModels.set(event.agent_id, response.model);
93    }
94    // Registration is observational for Claude-loop workers. An unregistered
95    // harness worker still cannot dispatch: resolveHarness rejects its scope.
96    return next(event);
97  });
98  on('agent.spawn', async ($, event, next) => {
99    if (!(await active($))) {
100      return next(event);
101    }
102    const payload = {
103      sessionId: await $.session.id(),
104      parentAgentId: event.parentAgentId,
105      permissionMode: event.permissionMode,
106      subagentType: event.subagentType,
107      cwd: event.cwd ?? (await $.session.cwd()),
108      model: event.model,
109      parentModel: event.parentModel,
110      fork: event.fork,
111      background: event.background,
112    };
113    const selection = await request($, payload, '/switchboard/mod/worker-model');
114    const harness = harnessSpawn(event, selection);
115    if (harness) {
116      await prepareHarness($, policyState);
117      const mode = await request(
118        $,
119        {},
120        `/switchboard/mod/mode?sessionId=${encodeURIComponent(payload.sessionId)}`,
121      );
122      const response = await request($, { ...payload, generation: mode?.generation });
123      if (!response?.accepted) {
124        return {
125          deny: reportable(response?.error ?? 'Switchboard harness worker policy was not acknowledged.'),
126        };
127      }
128    } else {
129      // Keep context for a possible later harness child, but never veto the
130      // engine's native worker because Switchboard could not reconstruct its policy.
131      await request($, payload);
132    }
133    const result = await next(event);
134    if (result.agentId && result.model) {
135      agentModels.set(result.agentId, result.model);
136    }
137    return result;
138  });
139};
140
141function harnessSpawn(
142  event: { fork?: boolean; model?: string; parentModel?: string },
143  selection: GatewayResponse | undefined,
144): boolean {
145  const inferred = event.fork ? event.parentModel : (event.model ?? event.parentModel);
146  return selection?.execution === 'harness' || (!selection?.known && isHarnessModel(inferred));
147}
148
149async function active($: EngineInterface): Promise<boolean> {
150  const base = await $.env.get('SWITCHBOARD_MOD_GATEWAY_URL');
151  const token = await $.env.get('SWITCHBOARD_GATEWAY_TOKEN');
152  return Boolean(base && token);
153}
154
155async function request(
156  $: EngineInterface,
157  payload: Record<string, unknown>,
158  route = '/switchboard/mod/worker',
159) {
160  const base = await $.env.get('SWITCHBOARD_MOD_GATEWAY_URL');
161  const token = await $.env.get('SWITCHBOARD_GATEWAY_TOKEN');
162  if (!base || !token) {
163    return undefined;
164  }
165  const body = JSON.stringify(payload);
166  if (encodeURIComponent(body).replace(/%[A-F\d]{2}/gi, 'x').length > maxBody) {
167    return undefined;
168  }
169  let timer: ReturnType<typeof setTimeout> | undefined;
170  try {
171    const response = $.http.fetch(`${base}${route}`, {
172      method: route.includes('?') ? 'GET' : 'POST',
173      headers: { 'content-type': 'application/json', 'x-switchboard-gateway-token': token },
174      ...(route.includes('?') ? {} : { body }),
175    });
176    const timeout = new Promise<never>((_, reject) => {
177      timer = setTimeout(() => reject(new Error('gateway request timeout')), 1500);
178    });
179    const result = await Promise.race([response, timeout]);
180    return result.ok
181      ? (JSON.parse(result.text) as GatewayResponse)
182      : refusal(result.text, result.status);
183  } catch {
184    return undefined;
185  } finally {
186    if (timer) {
187      clearTimeout(timer);
188    }
189  }
190}
191
192async function policyClient($: EngineInterface): Promise<PolicyClient> {
193  return {
194    model: await $.session.model(),
195    request: (route, payload) => request($, payload, route),
196  };
197}
198
199async function prepareHarness($: EngineInterface, state: PolicyState) {
200  if (state.prompt && !state.harnessReady) {
201    await ensureHarnessPolicy(await policyClient($), state);
202  }
203}
204
hooks/quota-advice.ts 28 lines
1import type { UsagePaneProps } from './usage-view.ts';
2
3/** Only account summaries enter model context; receipts and worker data stay in the menu. */
4export function quotaAdvice(snapshot?: UsagePaneProps): string {
5  const instructions = [
6    '[Switchboard quota-aware model selection — advisory]',
7    'Before choosing a subagent model, prefer the lowest subscription usage among the enabled providers whose models suit the task.',
8    'Respect explicit user model choices. You may choose a more-used model when its capabilities better fit the task; this hook does not restrict your choice.',
9    'Use current usage, without waiting for a quota reset. Prefer included subscription capacity over paid overages.',
10    'Percentages describe separate provider allowances, not equal token budgets. Unknown quota is not unused quota.',
11  ];
12  if (!snapshot) {
13    return [
14      ...instructions,
15      'Current quota could not be retrieved. Choose based on task requirements.',
16    ].join('\n');
17  }
18  const rows = snapshot.providers.filter((provider) => provider.status !== 'disabled');
19  return [
20    ...instructions,
21    `Account usage checked ${snapshot.updatedAt}:`,
22    ...rows.map(
23      (provider) =>
24        `${provider.name}: ${provider.status === 'ready' ? provider.summary : 'quota unknown or unavailable'}`,
25    ),
26  ].join('\n');
27}
28
hooks/usage-view.ts 125 lines
1import type { ClientModule } from 'claude-code';
2
3type Provider = {
4  id: string;
5  name: string;
6  status: string;
7  summary: string;
8  details: string[];
9  url?: string;
10};
11export type UsagePaneProps = {
12  updatedAt: string;
13  providers: Provider[];
14  receiptLines?: string[];
15  error?: string;
16  quotaAdviceEnabled?: boolean;
17};
18type State = { selected: string; offset: number };
19
20const view: ClientModule<UsagePaneProps, State> = (props, surface) => {
21  const { Box, Text, Button } = surface.elements;
22  const state = surface.state ?? { selected: 'all', offset: 0 };
23  const tabs = [
24    { id: 'all', name: 'All' },
25    ...props.providers,
26    { id: 'receipts', name: 'Receipts' },
27  ];
28  const width = Math.max(10, surface.columns || 80);
29  const lines = viewLines(props, state.selected).flatMap((line) => wrapLine(line, width));
30  const height = Math.max(1, (surface.rows || 20) - 8);
31  const offset = Math.max(0, Math.min(state.offset, lines.length - height));
32  const choose = (selected: string) => {
33    surface.setState({ selected, offset: 0 });
34    if (selected === 'receipts') {
35      surface.post({ action: 'receipts' });
36    }
37  };
38  const refreshAction = state.selected === 'receipts' ? 'receipts' : 'refresh';
39  surface.onKey((event) => {
40    if (event.key === 'r') {
41      surface.post({ action: refreshAction });
42    }
43    if (event.key === 'up' || event.key === 'down') {
44      surface.setState({ ...state, offset: Math.max(0, offset + (event.key === 'up' ? -1 : 1)) });
45    }
46    if (event.key === 'left' || event.key === 'right') {
47      const index = tabs.findIndex((tab) => tab.id === state.selected);
48      choose(tabs[(index + (event.key === 'left' ? tabs.length - 1 : 1)) % tabs.length].id);
49    }
50  });
51  return Box({
52    flexDirection: 'column',
53    children: [
54      Box({
55        flexDirection: 'row',
56        flexWrap: 'wrap',
57        gap: 1,
58        children: tabs.map((tab) =>
59          Button({
60            key: tab.id,
61            label: tab.id === state.selected ? `[${tab.name}]` : tab.name,
62            // `autoFocus` is typed `true | absent`; passing `false` fails tree validation.
63            ...(tab.id === state.selected ? { autoFocus: true as const } : {}),
64            onPress: () => choose(tab.id),
65          }),
66        ),
67      }),
68      Text({ dimColor: true, children: `Account data checked ${props.updatedAt}` }),
69      Button({
70        key: 'quota-advice',
71        label: `Quota-aware model selection: ${props.quotaAdviceEnabled ? 'On' : 'Off'}`,
72        onPress: () => surface.post({ action: 'toggle-quota-advice' }),
73      }),
74      ...(props.error ? [Text({ color: 'yellow', children: props.error })] : []),
75      ...lines.slice(offset, offset + height).map((line) => Text({ children: line })),
76      Text({
77        dimColor: true,
78        children: `${offset + 1}–${Math.min(lines.length, offset + height)} of ${lines.length} · ←/→ providers · ↑/↓ scroll · r refresh · Esc close`,
79      }),
80      Button({
81        key: 'refresh',
82        label: 'Refresh',
83        onPress: () => surface.post({ action: refreshAction }),
84      }),
85    ],
86  });
87};
88
89function viewLines(props: UsagePaneProps, selected: string): string[] {
90  if (selected === 'receipts') {
91    return props.receiptLines?.length
92      ? props.receiptLines
93      : ['No completed receipts in this session.'];
94  }
95  const provider = props.providers.find((item) => item.id === selected);
96  if (provider) {
97    return [
98      provider.name,
99      provider.summary,
100      ...provider.details,
101      ...(provider.url ? [provider.url] : []),
102    ];
103  }
104  return props.providers.flatMap((item) => [item.name, `  ${item.summary}`, '']);
105}
106export default view;
107
108function wrapLine(line: string, width: number): string[] {
109  if (line.length <= width) {
110    return [line];
111  }
112  const result: string[] = [];
113  let remaining = line;
114  while (remaining.length > width) {
115    const space = remaining.lastIndexOf(' ', width);
116    const end = space > 0 ? space : width;
117    result.push(remaining.slice(0, end));
118    remaining = remaining.slice(end).trimStart();
119  }
120  if (remaining) {
121    result.push(remaining);
122  }
123  return result;
124}
125