@matthias-hausberger/beige 1.0.0-beta3 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,5 @@
1
- import type { BeigeConfig } from "../config/schema.js";
2
1
  export interface TUIOptions {
3
- config: BeigeConfig;
4
- agentName: string;
2
+ agentName?: string;
5
3
  gatewayUrl?: string;
6
4
  }
7
5
  export declare function launchTUI(opts: TUIOptions): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"tui.d.ts","sourceRoot":"","sources":["../../src/channels/tui.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,WAAW,EAAe,MAAM,qBAAqB,CAAC;AA6CpE,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,WAAW,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAsBD,wBAAsB,SAAS,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAuI/D"}
1
+ {"version":3,"file":"tui.d.ts","sourceRoot":"","sources":["../../src/channels/tui.ts"],"names":[],"mappings":"AA0FA,MAAM,WAAW,UAAU;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAyBD,wBAAsB,SAAS,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAmK/D"}
@@ -1,25 +1,26 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import { createAgentSession, InteractiveMode, AuthStorage, ModelRegistry, SessionManager, SettingsManager, createExtensionRuntime, } from "@mariozechner/pi-coding-agent";
3
- import { getModel } from "@mariozechner/pi-ai";
3
+ import { createAssistantMessageEventStream, } from "@mariozechner/pi-ai";
4
4
  import { resolve, basename } from "path";
5
5
  import { mkdirSync } from "fs";
6
6
  import { beigeDir } from "../paths.js";
7
7
  import { buildSystemPrompt, readWorkspaceAgentsMd } from "../gateway/agent-manager.js";
8
8
  import { SessionSettingsStore, resolveSessionSetting } from "../gateway/session-settings.js";
9
9
  import { BeigeSessionStore } from "../gateway/sessions.js";
10
- import { loadSkills, validateSkillDeps } from "../skills/registry.js";
11
10
  import { RestrictedModelRegistry, buildAllowedModels } from "../config/restricted-model-registry.js";
12
11
  /**
13
12
  * TUI channel — runs in a separate process and connects to the gateway HTTP API.
14
13
  *
15
14
  * Architecture:
16
- * - The LLM session runs locally (pi InteractiveMode full pi experience)
17
- * - Core tool execution (read/write/patch/exec) is proxied to the gateway API
18
- * - The gateway owns the sandboxes, audit, and policy enforcement
15
+ * - LLM calls are proxied through the gateway (the only process that needs API keys)
16
+ * - Core tool execution (read/write/patch/exec) is also proxied to the gateway
17
+ * - The gateway owns auth, sandboxes, audit, and policy enforcement
18
+ * - The TUI never loads the config file and never needs API keys
19
19
  *
20
- * This gives you the best of both worlds:
20
+ * This gives you:
21
21
  * - Full pi TUI (editor, streaming, model switching, compaction, history)
22
22
  * - Sandboxed tool execution managed by the gateway
23
+ * - LLM calls routed through the gateway's auth + provider setup
23
24
  *
24
25
  * Commands:
25
26
  * - /new — Start a fresh session (pi built-in, works correctly)
@@ -28,15 +29,10 @@ import { RestrictedModelRegistry, buildAllowedModels } from "../config/restricte
28
29
  * - /beige-agent [name] — Switch to a different beige agent
29
30
  * - /beige-verbose on|off — Toggle verbose tool-call notifications
30
31
  * - /v on|off — Shorthand for /beige-verbose
31
- *
32
- * Note: Beige-specific commands use the "beige-" prefix to avoid conflicts
33
- * with pi built-ins. /new is pi's built-in command which resets the session
34
- * in-place — this is the correct approach (creating a new AgentSession would
35
- * leave InteractiveMode with a stale reference).
36
32
  */
37
33
  const DEFAULT_GATEWAY_URL = "http://127.0.0.1:7433";
34
+ // ── Entry point ──────────────────────────────────────────────────────────────
38
35
  export async function launchTUI(opts) {
39
- const { config } = opts;
40
36
  const gatewayUrl = opts.gatewayUrl ?? DEFAULT_GATEWAY_URL;
41
37
  // Verify gateway is reachable
42
38
  try {
@@ -67,52 +63,80 @@ export async function launchTUI(opts) {
67
63
  console.error(`[TUI] Unknown agent '${agentName}'. Available: ${agentNames.join(", ")}`);
68
64
  process.exit(1);
69
65
  }
70
- // ── Auth (LLM keys — session runs locally) ────────────────
71
- // Use beige's own auth/models files so credentials are isolated from pi's
72
- // ~/.pi/agent/auth.json. /login and /logout persist to ~/.beige/auth.json.
66
+ // ── Auth storage (no real keys — LLM calls go through gateway proxy) ──
73
67
  const beigeAuthPath = resolve(beigeDir(), "auth.json");
74
68
  const beigeModelsPath = resolve(beigeDir(), "models.json");
75
69
  const authStorage = AuthStorage.create(beigeAuthPath);
76
- for (const [provider, providerConfig] of Object.entries(config.llm.providers)) {
77
- if (providerConfig.apiKey) {
78
- authStorage.setRuntimeApiKey(provider, providerConfig.apiKey);
79
- }
80
- }
81
70
  const modelRegistry = new ModelRegistry(authStorage, beigeModelsPath);
82
- // Register custom providers from config (baseUrl, api overrides)
83
- for (const [provider, providerConfig] of Object.entries(config.llm.providers)) {
84
- if (providerConfig.baseUrl || providerConfig.api) {
85
- modelRegistry.registerProvider(provider, {
86
- baseUrl: providerConfig.baseUrl,
87
- apiKey: providerConfig.apiKey,
88
- api: providerConfig.api,
89
- });
90
- }
71
+ // ── Register proxy providers ─────────────────────────────
72
+ // For each provider used by this agent, register a provider with a custom
73
+ // streamSimple that proxies LLM calls through the gateway. The gateway
74
+ // resolves the API key and forwards to the real LLM provider.
75
+ const modelsRes = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(agentName)}/models`);
76
+ const { models: modelInfos } = (await modelsRes.json());
77
+ if (modelInfos.length === 0) {
78
+ console.error(`[TUI] No models found for agent '${agentName}'`);
79
+ process.exit(1);
80
+ }
81
+ // Group models by provider and register each provider with proxy streamSimple
82
+ const providerModels = new Map();
83
+ for (const m of modelInfos) {
84
+ const list = providerModels.get(m.provider) ?? [];
85
+ list.push(m);
86
+ providerModels.set(m.provider, list);
87
+ }
88
+ for (const [provider, providerModelInfos] of providerModels) {
89
+ // Set a dummy runtime key so the model appears "available" to pi's registry.
90
+ // The actual API key is resolved by the gateway proxy.
91
+ authStorage.setRuntimeApiKey(provider, "beige-gateway-proxy");
92
+ modelRegistry.registerProvider(provider, {
93
+ // api is required by ModelRegistry when streamSimple is provided.
94
+ // All models for a given provider share the same API protocol.
95
+ api: providerModelInfos[0].api,
96
+ // apiKey is required when defining models — the gateway resolves the
97
+ // real key; this placeholder just satisfies ModelRegistry validation.
98
+ apiKey: "beige-gateway-proxy",
99
+ baseUrl: modelInfos[0].baseUrl,
100
+ streamSimple: createProxyStreamSimple(gatewayUrl, () => agentName),
101
+ models: providerModelInfos.map((m) => ({
102
+ id: m.id,
103
+ name: m.name,
104
+ api: m.api,
105
+ reasoning: m.reasoning,
106
+ input: m.input,
107
+ cost: m.cost,
108
+ contextWindow: m.contextWindow,
109
+ maxTokens: m.maxTokens,
110
+ headers: m.headers,
111
+ compat: m.compat,
112
+ })),
113
+ });
91
114
  }
92
- // ── Load skills ────────────────────────────────────────────
93
- const loadedSkills = await loadSkills(config);
94
115
  // ── Create restricted model registry ──────────────────────
95
- // The agent can only use models defined in its config (model + fallbackModels)
96
- const agentConfig = config.agents[agentName];
97
- const allowedModels = buildAllowedModels(agentConfig.model, agentConfig.fallbackModels);
116
+ const primaryRef = agentInfo.model;
117
+ const fallbackRefs = agentInfo.fallbackModels ?? [];
118
+ const allowedModels = buildAllowedModels(primaryRef, fallbackRefs);
98
119
  const restrictedModelRegistry = new RestrictedModelRegistry(modelRegistry, allowedModels);
120
+ // ── Workspace dir ────────────────────────────────────────
121
+ const agentDir = resolve(beigeDir(), "agents", agentName);
122
+ const workspaceDir = agentInfo.workspaceDir ?? resolve(agentDir, "workspace");
99
123
  // ── Shared state ──────────────────────────────────────────
100
124
  const settingsStore = new SessionSettingsStore();
101
125
  const sessionStore = new BeigeSessionStore();
102
126
  const toolStartHandlerRef = { fn: undefined };
103
127
  const state = {
104
128
  agentName,
105
- agentConfig,
129
+ agentModelRef: primaryRef,
130
+ agentFallbackRefs: fallbackRefs,
131
+ workspaceDir,
106
132
  session: null,
107
133
  toolStartHandlerRef,
108
134
  gatewayUrl,
109
- config,
110
135
  authStorage,
111
136
  modelRegistry: restrictedModelRegistry,
112
137
  underlyingModelRegistry: modelRegistry,
113
138
  settingsStore,
114
139
  sessionStore,
115
- loadedSkills,
116
140
  };
117
141
  // Wire initial verbose state
118
142
  const sessionKey = BeigeSessionStore.tuiKey(agentName);
@@ -121,9 +145,6 @@ export async function launchTUI(opts) {
121
145
  toolStartHandlerRef.fn = makeTUIToolStartHandler();
122
146
  }
123
147
  // ── Build extension and create session ───────────────────
124
- // systemPromptRef and agentsFilesRef are shared mutable refs read by the
125
- // resource loader. They are updated by /beige-agent (switch agent) and by
126
- // the session_switch handler (re-read AGENTS.md after /new).
127
148
  const systemPromptRef = { value: "" };
128
149
  const agentsFilesRef = { value: [] };
129
150
  const extensionsResult = await buildBeigeExtension(state, agentNames, systemPromptRef, agentsFilesRef);
@@ -133,14 +154,14 @@ export async function launchTUI(opts) {
133
154
  process.exit(1);
134
155
  }
135
156
  // ── Launch pi TUI ─────────────────────────────────────────
136
- console.log(`[TUI] Agent: ${agentName} (${state.agentConfig.model.provider}/${state.agentConfig.model.model})`);
137
- // Show allowed models (for model switching)
157
+ console.log(`[TUI] Agent: ${agentName} (${primaryRef.provider}/${primaryRef.model})`);
138
158
  const availableModels = state.modelRegistry.getAvailable();
139
159
  if (availableModels.length > 1) {
140
160
  const modelList = availableModels.map(m => `${m.provider}/${m.id}`).join(", ");
141
161
  console.log(`[TUI] Allowed models: ${modelList}`);
142
162
  }
143
163
  console.log(`[TUI] Tools: ${agentInfo.tools.join(", ") || "(core only)"}`);
164
+ console.log(`[TUI] LLM: proxied via gateway (no local API keys needed)`);
144
165
  console.log(`[TUI] Verbose: ${initialVerbose ? "on" : "off"} — use /beige-verbose on|off to toggle`);
145
166
  console.log(`[TUI] Commands: /new, /beige-resume, /beige-sessions, /beige-agent <name>, /beige-verbose on|off (or /v on|off)`);
146
167
  // Suppress pi's "update available" banner — beige manages its own update lifecycle.
@@ -148,6 +169,117 @@ export async function launchTUI(opts) {
148
169
  const mode = new InteractiveMode(state.session, {});
149
170
  await mode.run();
150
171
  }
172
+ // ── LLM proxy ────────────────────────────────────────────────────────────────
173
+ /**
174
+ * Create a streamSimple function that proxies LLM calls through the gateway.
175
+ *
176
+ * The gateway resolves the API key, selects the correct stream function based
177
+ * on the model's api field, and streams AssistantMessageEvent objects back as
178
+ * newline-delimited JSON.
179
+ */
180
+ function createProxyStreamSimple(gatewayUrl, getAgentName) {
181
+ return (model, context, options) => {
182
+ const stream = createAssistantMessageEventStream();
183
+ (async () => {
184
+ try {
185
+ const res = await fetch(`${gatewayUrl}/api/chat/stream`, {
186
+ method: "POST",
187
+ headers: { "Content-Type": "application/json" },
188
+ body: JSON.stringify({
189
+ provider: model.provider,
190
+ modelId: model.id,
191
+ context,
192
+ agentName: getAgentName(),
193
+ sessionKey: `tui:${getAgentName()}:default`,
194
+ options: {
195
+ reasoning: options?.reasoning,
196
+ maxTokens: options?.maxTokens,
197
+ temperature: options?.temperature,
198
+ },
199
+ }),
200
+ signal: options?.signal,
201
+ });
202
+ if (!res.ok) {
203
+ const errorText = await res.text();
204
+ stream.push({
205
+ type: "error",
206
+ reason: "error",
207
+ error: makeErrorMessage(model, "error", `Gateway ${res.status}: ${errorText}`),
208
+ });
209
+ stream.end();
210
+ return;
211
+ }
212
+ // Read newline-delimited JSON stream
213
+ const reader = res.body.getReader();
214
+ const decoder = new TextDecoder();
215
+ let buffer = "";
216
+ while (true) {
217
+ const { done, value } = await reader.read();
218
+ if (done)
219
+ break;
220
+ buffer += decoder.decode(value, { stream: true });
221
+ const lines = buffer.split("\n");
222
+ buffer = lines.pop() ?? "";
223
+ for (const line of lines) {
224
+ if (!line.trim())
225
+ continue;
226
+ const event = JSON.parse(line);
227
+ // ── Gateway-specific event types ─────────────────────────
228
+ if (event.type === "blocked") {
229
+ // The gateway's prePrompt hook blocked this message.
230
+ // Write to stderr so the user sees why nothing happened,
231
+ // then treat it as an abort so the agent loop stops cleanly.
232
+ process.stderr.write(`\r❌ ${event.reason ?? "Message blocked by plugin hook."}\n`);
233
+ stream.push({
234
+ type: "error",
235
+ reason: "aborted",
236
+ error: makeErrorMessage(model, "aborted"),
237
+ });
238
+ stream.end();
239
+ return;
240
+ }
241
+ if (event.type === "model_fallback") {
242
+ // Gateway transparently switched to a fallback model due to a
243
+ // rate limit — no action required, subsequent events will come
244
+ // from the new model and its metadata will be in the done event.
245
+ console.log(`[TUI] Gateway switched to fallback model: ${event.provider}/${event.modelId}`);
246
+ continue;
247
+ }
248
+ stream.push(event);
249
+ }
250
+ }
251
+ stream.end();
252
+ }
253
+ catch (err) {
254
+ const isAborted = err.name === "AbortError";
255
+ stream.push({
256
+ type: "error",
257
+ reason: isAborted ? "aborted" : "error",
258
+ error: makeErrorMessage(model, isAborted ? "aborted" : "error", isAborted ? undefined : (err instanceof Error ? err.message : String(err))),
259
+ });
260
+ stream.end();
261
+ }
262
+ })();
263
+ return stream;
264
+ };
265
+ }
266
+ /** Build a minimal AssistantMessage for error/abort events. */
267
+ function makeErrorMessage(model, stopReason, errorMessage) {
268
+ return {
269
+ role: "assistant",
270
+ content: [],
271
+ api: model.api,
272
+ provider: model.provider,
273
+ model: model.id,
274
+ usage: {
275
+ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
276
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
277
+ },
278
+ stopReason,
279
+ errorMessage,
280
+ timestamp: Date.now(),
281
+ };
282
+ }
151
283
  // ── Session creation ──────────────────────────────────────────────────────────
152
284
  /**
153
285
  * Create a new pi session for the current agent in state.
@@ -159,31 +291,25 @@ export async function launchTUI(opts) {
159
291
  * AgentSession.
160
292
  */
161
293
  async function createSession(state, extensionsResult, systemPromptRef, agentsFilesRef) {
162
- const { agentName, agentConfig, gatewayUrl, authStorage, modelRegistry, underlyingModelRegistry, toolStartHandlerRef, loadedSkills } = state;
163
- // Fetch agent info from gateway
294
+ const { agentName, agentModelRef, gatewayUrl, authStorage, modelRegistry, underlyingModelRegistry, toolStartHandlerRef, workspaceDir } = state;
295
+ // Fetch agent info from gateway (for tool/skill context)
164
296
  const agentsRes = await fetch(`${gatewayUrl}/api/agents`);
165
297
  const { agents } = (await agentsRes.json());
166
298
  const agentInfo = agents.find((a) => a.name === agentName);
167
- const toolNames = agentInfo?.tools ?? [];
168
- const skillNames = agentInfo?.skills ?? [];
169
- // Validate skill dependencies
170
- validateSkillDeps(skillNames, toolNames, loadedSkills);
171
- // Use underlying registry for model lookup (restricted registry delegates find())
172
- const model = resolveModel(agentConfig, underlyingModelRegistry);
173
- // Pass a getter so tool calls always route to the currently-active agent,
174
- // even after /beige-agent switches state.agentName.
299
+ // Resolve the model from the proxy registry
300
+ const model = underlyingModelRegistry.find(agentModelRef.provider, agentModelRef.model);
301
+ if (!model) {
302
+ throw new Error(`Model not found: ${agentModelRef.provider}/${agentModelRef.model}`);
303
+ }
304
+ // Pass a getter so tool calls always route to the currently-active agent
175
305
  const coreTools = createProxyTools(() => state.agentName, gatewayUrl, toolStartHandlerRef);
176
- // Use pre-built tool/skill context from the gateway (which has full tool manifests).
306
+ // Use pre-built tool/skill context from the gateway
177
307
  const toolContext = agentInfo?.toolContext ?? "";
178
308
  const skillContext = agentInfo?.skillContext ?? "";
179
- // Populate the shared mutable ref with the initial system prompt.
180
309
  systemPromptRef.value = buildSystemPrompt(agentName, toolContext, skillContext);
181
310
  // Read workspace AGENTS.md so it's injected into the system prompt context.
182
- const agentDir = resolve(beigeDir(), "agents", agentName);
183
- const workspaceDir = agentConfig.workspaceDir ?? resolve(agentDir, "workspace");
184
311
  agentsFilesRef.value = readWorkspaceAgentsMd(workspaceDir);
185
312
  const sessionsDir = resolve(beigeDir(), "sessions", agentName);
186
- // Always start a fresh session. Users can resume via /beige-resume.
187
313
  const sessionManager = SessionManager.create(process.cwd(), sessionsDir);
188
314
  const resourceLoader = {
189
315
  getExtensions: () => extensionsResult,
@@ -191,16 +317,20 @@ async function createSession(state, extensionsResult, systemPromptRef, agentsFil
191
317
  getPrompts: () => ({ prompts: [], diagnostics: [] }),
192
318
  getThemes: () => ({ themes: [], diagnostics: [] }),
193
319
  getAgentsFiles: () => ({ agentsFiles: agentsFilesRef.value }),
194
- // Read from mutable ref so updates from /beige-agent are reflected.
195
320
  getSystemPrompt: () => systemPromptRef.value,
196
321
  getAppendSystemPrompt: () => [],
197
322
  getPathMetadata: () => new Map(),
198
323
  extendResources: () => { },
199
324
  reload: async () => { },
200
325
  };
326
+ // Find the GatewayModelInfo for the thinking level
327
+ const modelsRes = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(agentName)}/models`);
328
+ const { models: modelInfos } = (await modelsRes.json());
329
+ const currentModelInfo = modelInfos.find((m) => m.id === agentModelRef.model && m.provider === agentModelRef.provider);
330
+ const thinkingLevel = (currentModelInfo?.thinkingLevel ?? "off");
201
331
  const { session } = await createAgentSession({
202
332
  model,
203
- thinkingLevel: agentConfig.model.thinkingLevel ?? "off",
333
+ thinkingLevel,
204
334
  tools: [],
205
335
  customTools: coreTools,
206
336
  sessionManager,
@@ -210,11 +340,9 @@ async function createSession(state, extensionsResult, systemPromptRef, agentsFil
210
340
  }),
211
341
  resourceLoader,
212
342
  authStorage,
213
- // Pass underlying registry for session creation
214
343
  modelRegistry: modelRegistry.getUnderlying(),
215
344
  });
216
345
  // Replace the session's modelRegistry with our restricted version
217
- // This ensures the TUI's model switcher only shows allowed models
218
346
  session._modelRegistry = modelRegistry;
219
347
  state.session = session;
220
348
  }
@@ -272,7 +400,6 @@ async function buildBeigeExtension(state, availableAgents, systemPromptRef, agen
272
400
  let index = parseInt(arg, 10) - 1;
273
401
  if (isNaN(index) || index < 0 || index >= sessions.length) {
274
402
  if (arg === "") {
275
- // Show list if no arg provided
276
403
  ctx.ui.notify(`Usage: /beige-resume <number>\n\nSessions:\n${sessions
277
404
  .slice(0, 5)
278
405
  .map((s, i) => ` ${i + 1}. ${basename(s.file)}`)
@@ -284,10 +411,6 @@ async function buildBeigeExtension(state, availableAgents, systemPromptRef, agen
284
411
  return;
285
412
  }
286
413
  const targetSession = sessions[index];
287
- // Delegate to the pi SDK's switchSession(), which loads the session
288
- // file into the existing AgentSession and re-renders the chat UI.
289
- // This avoids a stale-session-reference bug where InteractiveMode's
290
- // internal this.session would still point to the old disposed session.
291
414
  await ctx.switchSession(targetSession.file);
292
415
  ctx.ui.notify(`📂 Resumed session: ${basename(targetSession.file)}`, "info");
293
416
  };
@@ -306,66 +429,89 @@ async function buildBeigeExtension(state, availableAgents, systemPromptRef, agen
306
429
  ctx.ui.notify(`Already using agent '${arg}'.`, "info");
307
430
  return;
308
431
  }
309
- // Switch to new agent
310
- const newAgentConfig = state.config.agents[arg];
311
- if (!newAgentConfig) {
312
- ctx.ui.notify(`Agent '${arg}' not found in config.`, "error");
432
+ // Fetch new agent info from gateway
433
+ const agentsRes = await fetch(`${state.gatewayUrl}/api/agents`);
434
+ const { agents } = (await agentsRes.json());
435
+ const newAgentInfo = agents.find((a) => a.name === arg);
436
+ if (!newAgentInfo) {
437
+ ctx.ui.notify(`Agent '${arg}' not found on gateway.`, "error");
438
+ return;
439
+ }
440
+ // Fetch model metadata for the new agent
441
+ const modelsRes = await fetch(`${state.gatewayUrl}/api/agents/${encodeURIComponent(arg)}/models`);
442
+ const { models: newModelInfos } = (await modelsRes.json());
443
+ if (newModelInfos.length === 0) {
444
+ ctx.ui.notify(`No models configured for agent '${arg}'.`, "error");
313
445
  return;
314
446
  }
315
- // Update state fields — proxy tools and resource loader read these dynamically.
447
+ // Register proxy providers for the new agent's models
448
+ const providerModels = new Map();
449
+ for (const m of newModelInfos) {
450
+ const list = providerModels.get(m.provider) ?? [];
451
+ list.push(m);
452
+ providerModels.set(m.provider, list);
453
+ }
454
+ for (const [provider, pModelInfos] of providerModels) {
455
+ state.authStorage.setRuntimeApiKey(provider, "beige-gateway-proxy");
456
+ state.underlyingModelRegistry.registerProvider(provider, {
457
+ api: pModelInfos[0].api,
458
+ apiKey: "beige-gateway-proxy",
459
+ baseUrl: pModelInfos[0].baseUrl,
460
+ streamSimple: createProxyStreamSimple(state.gatewayUrl, () => state.agentName),
461
+ models: pModelInfos.map((m) => ({
462
+ id: m.id,
463
+ name: m.name,
464
+ api: m.api,
465
+ reasoning: m.reasoning,
466
+ input: m.input,
467
+ cost: m.cost,
468
+ contextWindow: m.contextWindow,
469
+ maxTokens: m.maxTokens,
470
+ headers: m.headers,
471
+ compat: m.compat,
472
+ })),
473
+ });
474
+ }
475
+ // Update state
316
476
  state.agentName = arg;
317
- state.agentConfig = newAgentConfig;
318
- // Update restricted model registry for new agent's allowed models.
319
- const allowedModels = buildAllowedModels(newAgentConfig.model, newAgentConfig.fallbackModels);
477
+ state.agentModelRef = newAgentInfo.model;
478
+ state.agentFallbackRefs = newAgentInfo.fallbackModels ?? [];
479
+ state.workspaceDir = newAgentInfo.workspaceDir ?? resolve(beigeDir(), "agents", arg, "workspace");
480
+ // Update restricted model registry
481
+ const allowedModels = buildAllowedModels(state.agentModelRef, state.agentFallbackRefs);
320
482
  state.modelRegistry = new RestrictedModelRegistry(state.underlyingModelRegistry, allowedModels);
321
483
  if (state.session) {
322
484
  state.session._modelRegistry = state.modelRegistry;
323
485
  }
324
- // Rebuild the system prompt for the new agent and push it into both the
325
- // shared ref (read by resourceLoader.getSystemPrompt() on future rebuilds)
326
- // and the session's cached _baseSystemPrompt (used before every LLM call).
327
- const agentsRes = await fetch(`${state.gatewayUrl}/api/agents`);
328
- const { agents } = (await agentsRes.json());
329
- const agentInfo = agents.find((a) => a.name === arg);
330
- // Use pre-built tool/skill context from the gateway (which has full tool manifests).
331
- const toolContext = agentInfo?.toolContext ?? "";
332
- const skillContext = agentInfo?.skillContext ?? "";
333
- const newSystemPrompt = buildSystemPrompt(arg, toolContext, skillContext);
334
- systemPromptRef.value = newSystemPrompt;
335
- // Update agents files for the new agent's workspace.
336
- const newAgentDir = resolve(beigeDir(), "agents", arg);
337
- const newWorkspaceDir = newAgentConfig.workspaceDir ?? resolve(newAgentDir, "workspace");
338
- agentsFilesRef.value = readWorkspaceAgentsMd(newWorkspaceDir);
486
+ // Rebuild system prompt
487
+ const toolContext = newAgentInfo.toolContext ?? "";
488
+ const skillContext = newAgentInfo.skillContext ?? "";
489
+ systemPromptRef.value = buildSystemPrompt(arg, toolContext, skillContext);
490
+ agentsFilesRef.value = readWorkspaceAgentsMd(state.workspaceDir);
339
491
  if (state.session) {
340
- // Trigger a full rebuild so pi's buildSystemPrompt picks up both the
341
- // updated system prompt ref AND the fresh AGENTS.md from getAgentsFiles().
342
492
  const toolNames = state.session.getActiveToolNames();
343
493
  state.session._baseSystemPrompt = state.session._rebuildSystemPrompt(toolNames);
344
494
  state.session.agent.setSystemPrompt(state.session._baseSystemPrompt);
345
495
  }
346
- // Update verbose handler for new session key.
496
+ // Update verbose handler
347
497
  const sessionKey = BeigeSessionStore.tuiKey(arg);
348
498
  const verbose = resolveSessionSetting("verbose", false, undefined, state.settingsStore.get(sessionKey, "verbose"));
349
499
  state.toolStartHandlerRef.fn = verbose ? makeTUIToolStartHandler() : undefined;
350
- // Point the SessionManager at the new agent's session directory so that
351
- // ctx.newSession() creates the .jsonl file under ~/.beige/sessions/<new-agent>/
352
- // instead of the original agent's directory.
500
+ // Point session manager at new agent's session directory
353
501
  if (state.session) {
354
502
  const newSessionsDir = resolve(beigeDir(), "sessions", arg);
355
503
  mkdirSync(newSessionsDir, { recursive: true });
356
504
  state.session.sessionManager.sessionDir = newSessionsDir;
357
505
  }
358
- // Switch to the new agent's configured model so the fresh session doesn't
359
- // inherit the previous agent's model. setModel() validates the API key
360
- // via the (already-updated) restricted model registry, records a model
361
- // change event in the session journal, and re-clamps the thinking level.
506
+ // Switch to new agent's model
362
507
  if (state.session) {
363
- const newModel = resolveModel(newAgentConfig, state.modelRegistry);
364
- await state.session.setModel(newModel);
508
+ const newModel = state.underlyingModelRegistry.find(state.agentModelRef.provider, state.agentModelRef.model);
509
+ if (newModel) {
510
+ const newModelInfo = newModelInfos.find((m) => m.id === state.agentModelRef.model && m.provider === state.agentModelRef.provider);
511
+ await state.session.setModel(newModel);
512
+ }
365
513
  }
366
- // Start a fresh session in the new agent's session directory.
367
- // ctx.newSession() resets the existing AgentSession in-place (no new
368
- // instance), so InteractiveMode's internal reference stays valid.
514
+ // Start a fresh session
369
515
  await ctx.newSession();
370
516
  ctx.ui.notify(`🔄 Switched to agent '${arg}'.`, "info");
371
517
  };
@@ -374,18 +520,51 @@ async function buildBeigeExtension(state, availableAgents, systemPromptRef, agen
374
520
  path: "<beige-tui>",
375
521
  resolvedPath: "<beige-tui>",
376
522
  handlers: new Map([
377
- // Re-read AGENTS.md on /new and session switches so that edits the
378
- // agent made during the previous session are picked up.
379
523
  ["session_switch", [async () => {
380
- const dir = resolve(beigeDir(), "agents", state.agentName);
381
- const ws = state.agentConfig.workspaceDir ?? resolve(dir, "workspace");
382
- agentsFilesRef.value = readWorkspaceAgentsMd(ws);
524
+ agentsFilesRef.value = readWorkspaceAgentsMd(state.workspaceDir);
383
525
  if (state.session) {
384
526
  const toolNames = state.session.getActiveToolNames();
385
527
  state.session._baseSystemPrompt = state.session._rebuildSystemPrompt(toolNames);
386
528
  state.session.agent.setSystemPrompt(state.session._baseSystemPrompt);
387
529
  }
388
530
  }]],
531
+ // prePrompt and postResponse hooks are now executed server-side inside
532
+ // the gateway's /api/chat/stream handler before/after the LLM call.
533
+ // This eliminates the extra HTTP round-trips and ensures hooks run in
534
+ // the same process as the gateway regardless of which channel triggered
535
+ // the call. The `blocked` stream event handles the abort case.
536
+ // sessionCreated hook: fires on initial session load and /new.
537
+ ["session_start", [async () => {
538
+ try {
539
+ await fetch(`${state.gatewayUrl}/api/agents/${encodeURIComponent(state.agentName)}/hooks/session-created`, {
540
+ method: "POST",
541
+ headers: { "Content-Type": "application/json" },
542
+ body: JSON.stringify({
543
+ sessionKey: `tui:${state.agentName}:default`,
544
+ channel: "tui",
545
+ }),
546
+ });
547
+ }
548
+ catch {
549
+ // Non-fatal: hook failures should not block the TUI
550
+ }
551
+ }]],
552
+ // sessionDisposed hook: fires on process exit.
553
+ ["session_shutdown", [async () => {
554
+ try {
555
+ await fetch(`${state.gatewayUrl}/api/agents/${encodeURIComponent(state.agentName)}/hooks/session-disposed`, {
556
+ method: "POST",
557
+ headers: { "Content-Type": "application/json" },
558
+ body: JSON.stringify({
559
+ sessionKey: `tui:${state.agentName}:default`,
560
+ channel: "tui",
561
+ }),
562
+ });
563
+ }
564
+ catch {
565
+ // Non-fatal: gateway may already be shutting down
566
+ }
567
+ }]],
389
568
  ]),
390
569
  tools: new Map(),
391
570
  messageRenderers: new Map(),
@@ -401,14 +580,6 @@ async function buildBeigeExtension(state, availableAgents, systemPromptRef, agen
401
580
  };
402
581
  return { extensions: [extension], errors: [], runtime };
403
582
  }
404
- /**
405
- * List human-initiated sessions for an agent, sorted newest-first.
406
- *
407
- * Delegates to BeigeSessionStore.listSessions() so that sessions created
408
- * by toolkit tools (e.g. agent-to-agent sub-agent sessions, which carry
409
- * metadata.depth > 0) are automatically excluded. Only sessions the user
410
- * started directly appear in /beige-sessions and /beige-resume.
411
- */
412
583
  function listSessions(agentName) {
413
584
  const store = new BeigeSessionStore();
414
585
  return store.listSessions(agentName).map((info) => ({
@@ -525,22 +696,4 @@ function createProxyTools(getAgentName, gatewayUrl, handlerRef) {
525
696
  },
526
697
  ];
527
698
  }
528
- // ── Helpers ───────────────────────────────────────────────────────────────────
529
- function resolveModel(agentConfig, modelRegistry) {
530
- const { provider, model: modelId } = agentConfig.model;
531
- // Prefer ModelRegistry.find() over the static getModel() because the registry
532
- // applies OAuth provider transformations (e.g. modifyModels) that update baseUrl.
533
- // For GitHub Copilot business subscriptions, the OAuth provider rewrites baseUrl
534
- // from api.individual.githubcopilot.com → api.business.githubcopilot.com based
535
- // on the proxy-ep in the access token. Using the static getModel() would return
536
- // the unmodified built-in model with the wrong baseUrl, causing 421 Misdirected
537
- // Request errors.
538
- const registryModel = modelRegistry.find(provider, modelId);
539
- if (registryModel)
540
- return registryModel;
541
- const model = getModel(provider, modelId);
542
- if (model)
543
- return model;
544
- throw new Error(`Model not found: ${provider}/${modelId}`);
545
- }
546
699
  //# sourceMappingURL=tui.js.map