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

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.
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
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.
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.
| Area | Choice | Why |
|---|---|---|
| Context window | compact at 256k, 1M available | A 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. |
| Effort | medium by default | Depth comes from spawning a stronger subagent, not from making every turn expensive. |
| Permissions | bypassPermissions, no prompts | A config that asks every turn contradicts an agent told not to ask. Safety moves to the deny list and to git being recoverable. |
| Denied | dist/, next/, plan/worktree/cron/notebook modes | Build output teaches nothing; fewer modes means fewer paths to wander down. |
| Models | pinned aliases | --model opus shouldn't drift when a new default ships. |
| Symbols | ENABLE_LSP_TOOL + typescript-lsp | Backs the "never grep-guess a definition" rule with real resolution. |
| Telemetry | DISABLE_NONESSENTIAL_TRAFFIC | One switch covering metrics, error reports, surveys, feature flags. Does not affect auto-updates. |
| Hooks | audio only | With 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.
ast-grep for TS/JS, fd over find, LSP for symbols);ast-grep and ast-grep-outline for syntax-aware search — a structural pattern can't match a string that merely looks like code.
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.
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.
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.
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.
| Area | Default |
|---|---|
| Main agent | GPT-6 Astra, low reasoning, concise output |
| Compaction | 256,000 total-context tokens; model's actual context capacity unchanged |
| Permissions | never + danger-full-access, matching the existing local setup |
| Integrations | Superpowers; Context7 via the existing stdio MCP configuration |
| Web research | Native live web search; independent of ChatGPT connectors |
| Apps | App tools disabled, default app enablement false |
| Plugins | Remote catalog and recommendations off; Superpowers, browser and unified computer use enabled; other listed bundled plugins disabled |
| Skills | Shared local skills + Superpowers + Codex system skills; local PDF skill enabled |
| Analytics | Analytics and feedback disabled |
| Sounds | macOS 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.
| Role | Model / effort | Scope |
|---|---|---|
| explorer | GPT-5.6 Luna / low | Read-only code mapping and reference tracing |
| researcher | GPT-5.6 Luna / high | Read-only web and Context7 research; primary-source URLs |
| worker | Luna/high for simple tasks; Sol/low for anything beyond simple; Astra/low for 3D/games | Implementation and tests; Astra baseline for 3D/games |
| reviewer | GPT-5.6 Sol / low | Read-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.
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.
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.
hooks/register.ts 162 lines1import 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}
162hooks/compact.ts 136 lines1import 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}
136hooks/lifecycle.ts 138 lines1import 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}
138hooks/policy.ts 151 lines1import { 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}
151hooks/provider.ts 5 lines1/** These providers execute tools outside Claude Code and need policy translation. */
2export function isHarnessModel(_model: string | undefined): boolean {
3 return false;
4}
5hooks/usage.ts 201 lines1import 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}
201hooks/workers.ts 204 lines1import 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}
204hooks/quota-advice.ts 28 lines1import 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}
28hooks/usage-view.ts 125 lines1import 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