@bubblebrain-ai/bubble 0.0.8 → 0.0.9

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.
Files changed (74) hide show
  1. package/dist/agent/categories.d.ts +34 -0
  2. package/dist/agent/categories.js +98 -0
  3. package/dist/agent/profiles.d.ts +4 -0
  4. package/dist/agent/profiles.js +2 -3
  5. package/dist/agent/subagent-control.d.ts +5 -0
  6. package/dist/agent/subagent-control.js +4 -0
  7. package/dist/agent/subagent-lifecycle-reminder.d.ts +3 -0
  8. package/dist/agent/subagent-lifecycle-reminder.js +102 -0
  9. package/dist/agent/subagent-route-format.d.ts +8 -0
  10. package/dist/agent/subagent-route-format.js +18 -0
  11. package/dist/agent/subtask-policy.d.ts +0 -1
  12. package/dist/agent/subtask-policy.js +0 -4
  13. package/dist/agent.d.ts +12 -0
  14. package/dist/agent.js +152 -13
  15. package/dist/config.d.ts +23 -3
  16. package/dist/config.js +59 -6
  17. package/dist/context/budget.d.ts +3 -3
  18. package/dist/context/budget.js +29 -15
  19. package/dist/context/compact.d.ts +23 -0
  20. package/dist/context/compact.js +129 -0
  21. package/dist/context/llm-compactor.d.ts +19 -0
  22. package/dist/context/llm-compactor.js +200 -0
  23. package/dist/context/projector.js +28 -12
  24. package/dist/context/token-estimator.d.ts +14 -0
  25. package/dist/context/token-estimator.js +106 -0
  26. package/dist/context/tool-output-truncate.d.ts +8 -0
  27. package/dist/context/tool-output-truncate.js +59 -0
  28. package/dist/context/usage.js +9 -9
  29. package/dist/main.js +43 -6
  30. package/dist/model-catalog.d.ts +9 -0
  31. package/dist/model-catalog.js +16 -0
  32. package/dist/orchestrator/default-hooks.js +18 -0
  33. package/dist/provider-openai-codex.d.ts +13 -2
  34. package/dist/provider-openai-codex.js +81 -32
  35. package/dist/provider-registry.js +20 -4
  36. package/dist/slash-commands/commands.js +24 -0
  37. package/dist/slash-commands/types.d.ts +7 -0
  38. package/dist/tools/agent-lifecycle.js +22 -4
  39. package/dist/tools/edit.js +2 -2
  40. package/dist/tools/glob.js +2 -1
  41. package/dist/tools/grep.js +2 -2
  42. package/dist/tools/lsp.js +2 -2
  43. package/dist/tools/path-utils.d.ts +2 -0
  44. package/dist/tools/path-utils.js +16 -0
  45. package/dist/tools/read.js +117 -5
  46. package/dist/tools/write.js +3 -2
  47. package/dist/tui-ink/app.d.ts +11 -2
  48. package/dist/tui-ink/app.js +191 -78
  49. package/dist/tui-ink/approval/approval-dialog.js +4 -1
  50. package/dist/tui-ink/approval/diff-view.js +2 -1
  51. package/dist/tui-ink/approval/select.js +2 -1
  52. package/dist/tui-ink/code-highlight.d.ts +2 -0
  53. package/dist/tui-ink/code-highlight.js +30 -2
  54. package/dist/tui-ink/detect-theme.d.ts +19 -0
  55. package/dist/tui-ink/detect-theme.js +123 -0
  56. package/dist/tui-ink/footer.js +4 -3
  57. package/dist/tui-ink/input-box.js +83 -26
  58. package/dist/tui-ink/input-history.d.ts +16 -0
  59. package/dist/tui-ink/input-history.js +81 -0
  60. package/dist/tui-ink/markdown.js +30 -20
  61. package/dist/tui-ink/message-list.js +112 -16
  62. package/dist/tui-ink/model-picker.js +6 -1
  63. package/dist/tui-ink/plan-confirm.js +2 -1
  64. package/dist/tui-ink/question-dialog.js +2 -1
  65. package/dist/tui-ink/run.d.ts +5 -1
  66. package/dist/tui-ink/run.js +30 -2
  67. package/dist/tui-ink/theme.d.ts +64 -35
  68. package/dist/tui-ink/theme.js +81 -8
  69. package/dist/tui-ink/todos.js +5 -3
  70. package/dist/tui-ink/trace-groups.d.ts +3 -1
  71. package/dist/tui-ink/trace-groups.js +93 -14
  72. package/dist/tui-ink/welcome.js +23 -4
  73. package/dist/types.d.ts +6 -0
  74. package/package.json +2 -1
package/dist/main.js CHANGED
@@ -56,6 +56,20 @@ async function main() {
56
56
  })
57
57
  : createUnavailableProvider(unavailableProviderMessage);
58
58
  const createProvider = (providerId, apiKey, baseURL) => createProviderInstance({ providerId, apiKey, baseURL, thinkingLevel: args.thinkingLevel });
59
+ const createProviderForRoute = async (route) => {
60
+ const providerId = route.providerId;
61
+ if (!providerId) {
62
+ throw new Error(`Subagent route for model "${route.model}" did not include a provider.`);
63
+ }
64
+ if (registry.supportsOAuth(providerId) && registry.getAuthStorage().has(providerId)) {
65
+ await registry.prepareProvider(providerId);
66
+ }
67
+ const target = registry.getConfigured().find((item) => item.id === providerId);
68
+ if (!target?.enabled || !target.apiKey) {
69
+ throw new Error(`Subagent route requires provider "${providerId}", but it is not configured or has no active credentials.`);
70
+ }
71
+ return createProvider(providerId, target.apiKey, target.baseURL);
72
+ };
59
73
  let agentRef;
60
74
  const todoStore = {
61
75
  getTodos: () => agentRef?.getTodos() ?? [],
@@ -250,6 +264,8 @@ async function main() {
250
264
  skills: skillSummaries,
251
265
  memoryPrompt,
252
266
  fileStateTracker,
267
+ agentCategories: userConfig.getAgentCategories(),
268
+ providerFactory: createProviderForRoute,
253
269
  });
254
270
  agentRef = agent;
255
271
  if (sessionManager) {
@@ -335,10 +351,8 @@ async function main() {
335
351
  return;
336
352
  }
337
353
  const tuiRuntime = process.env.BUBBLE_TUI === "opentui" ? "opentui" : "ink";
338
- const { runTui } = tuiRuntime === "opentui"
339
- ? await import("./tui/run.js")
340
- : await import("./tui-ink/run.js");
341
- await runTui(agent, args, {
354
+ const themeConfig = userConfig.getTheme();
355
+ const commonOptions = {
342
356
  sessionManager,
343
357
  createProvider,
344
358
  registry,
@@ -350,12 +364,35 @@ async function main() {
350
364
  settingsManager,
351
365
  lspService,
352
366
  mcpManager,
353
- theme: userConfig.getTheme(),
354
367
  flushMemory,
355
368
  runMemoryCompaction,
356
369
  runMemorySummary,
357
370
  runMemoryRefresh,
358
- });
371
+ };
372
+ if (tuiRuntime === "opentui") {
373
+ const { runTui } = await import("./tui/run.js");
374
+ await runTui(agent, args, {
375
+ ...commonOptions,
376
+ theme: themeConfig.overrides,
377
+ });
378
+ }
379
+ else {
380
+ // Probe the terminal background BEFORE Ink takes over stdin. OSC 11
381
+ // needs raw mode, and once Ink owns stdin the reply never reaches us.
382
+ let detectedTheme = "dark";
383
+ if (themeConfig.mode === "auto") {
384
+ const { detectTerminalTheme } = await import("./tui-ink/detect-theme.js");
385
+ detectedTheme = await detectTerminalTheme();
386
+ }
387
+ const { runTui } = await import("./tui-ink/run.js");
388
+ await runTui(agent, args, {
389
+ ...commonOptions,
390
+ themeMode: themeConfig.mode,
391
+ themeOverrides: themeConfig.overrides,
392
+ detectedTheme,
393
+ onThemeModeChange: (mode) => userConfig.setThemeMode(mode),
394
+ });
395
+ }
359
396
  }
360
397
  finally {
361
398
  await shutdownRuntime();
@@ -11,10 +11,19 @@ export interface BuiltinModelDefinition {
11
11
  providerId: string;
12
12
  reasoningLevels: ReasoningEffort[];
13
13
  contextWindow?: number;
14
+ /**
15
+ * Server-declared cap on per-tool-output tokens. When set, the agent must
16
+ * truncate each tool result to this token budget before adding it to history
17
+ * — otherwise the server's input window is exceeded by raw tool dumps.
18
+ * (For codex models this comes from the API's `truncation_policy.limit`.)
19
+ */
20
+ toolOutputTokenLimit?: number;
14
21
  }
15
22
  export declare const BUILTIN_PROVIDERS: BuiltinProviderDefinition[];
16
23
  export declare const BUILTIN_MODELS: BuiltinModelDefinition[];
17
24
  export declare function listBuiltinModels(providerId: string): BuiltinModelDefinition[];
25
+ export declare function registerDynamicModelMetadata(model: BuiltinModelDefinition): void;
18
26
  export declare function getBuiltinModel(providerId: string, modelId: string): BuiltinModelDefinition | undefined;
19
27
  export declare function getBuiltinProvider(providerId: string): BuiltinProviderDefinition | undefined;
20
28
  export declare function getModelContextWindow(providerId: string, modelId: string): number | undefined;
29
+ export declare function getToolOutputTokenLimit(providerId: string, modelId: string): number | undefined;
@@ -85,7 +85,20 @@ export const BUILTIN_MODELS = [
85
85
  export function listBuiltinModels(providerId) {
86
86
  return BUILTIN_MODELS.filter((model) => model.providerId === providerId);
87
87
  }
88
+ // Runtime overlay populated from provider-side discovery (e.g. ChatGPT codex /models).
89
+ // Looked up before the static catalog so newly-released models work without a code change.
90
+ const dynamicOverlay = new Map();
91
+ function overlayKey(providerId, modelId) {
92
+ return `${providerId}:${modelId}`;
93
+ }
94
+ export function registerDynamicModelMetadata(model) {
95
+ dynamicOverlay.set(overlayKey(model.providerId, model.id), model);
96
+ }
88
97
  export function getBuiltinModel(providerId, modelId) {
98
+ const overlayHit = dynamicOverlay.get(overlayKey(providerId, modelId))
99
+ || (providerId === "openai" ? dynamicOverlay.get(overlayKey("openai-codex", modelId)) : undefined);
100
+ if (overlayHit)
101
+ return overlayHit;
89
102
  return BUILTIN_MODELS.find((model) => model.providerId === providerId && model.id === modelId)
90
103
  || (providerId === "openai"
91
104
  ? BUILTIN_MODELS.find((model) => model.providerId === "openai-codex" && model.id === modelId)
@@ -97,3 +110,6 @@ export function getBuiltinProvider(providerId) {
97
110
  export function getModelContextWindow(providerId, modelId) {
98
111
  return getBuiltinModel(providerId, modelId)?.contextWindow;
99
112
  }
113
+ export function getToolOutputTokenLimit(providerId, modelId) {
114
+ return getBuiltinModel(providerId, modelId)?.toolOutputTokenLimit;
115
+ }
@@ -6,6 +6,7 @@ import { arbitrateToolCall } from "../agent/tool-arbiter.js";
6
6
  import { buildEditRetryEscalationReminder, buildSmallTaskHint, buildTaskSummaryReminder, buildWorkflowPhaseReminder, } from "../prompt/reminders.js";
7
7
  import { reminderForTaskType } from "../prompt/task-reminders.js";
8
8
  import { formatCoverageSummary, resolveWorkflowPhase } from "./workflow.js";
9
+ import { buildSubagentLifecycleReminder } from "../agent/subagent-lifecycle-reminder.js";
9
10
  export function createDefaultHooks() {
10
11
  return [
11
12
  {
@@ -127,6 +128,12 @@ export function createDefaultHooks() {
127
128
  }
128
129
  },
129
130
  beforeContinuation(ctx) {
131
+ if (hasSubagentLifecycleActivity(ctx.toolCalls, ctx.toolResults)) {
132
+ const reminder = buildSubagentLifecycleReminder(ctx.agent.listSubAgents(), ctx.toolResults);
133
+ if (reminder) {
134
+ ctx.queueReminder(reminder);
135
+ }
136
+ }
130
137
  if (ctx.state.taskType === "security_investigation" && ctx.state.evidenceTracker?.isCoreCoverageComplete()) {
131
138
  ctx.requestTextOnlyTurn("Core security investigation evidence has been collected. Summarize the findings instead of continuing with more tool calls.");
132
139
  return;
@@ -149,6 +156,17 @@ function isCodeWriteResult(_toolCall, result) {
149
156
  }
150
157
  return result.metadata?.kind === "write" || result.metadata?.kind === "edit";
151
158
  }
159
+ function hasSubagentLifecycleActivity(toolCalls, toolResults) {
160
+ return toolCalls.some((toolCall) => isSubagentLifecycleTool(toolCall.name))
161
+ || toolResults.some((result) => result.metadata?.kind === "subagent");
162
+ }
163
+ function isSubagentLifecycleTool(name) {
164
+ return name === "spawn_agent"
165
+ || name === "wait_agent"
166
+ || name === "send_input"
167
+ || name === "close_agent"
168
+ || name === "task";
169
+ }
152
170
  function hashEditCall(toolCall) {
153
171
  // Cheap fingerprint that identifies "same edit/write call". JSON of the
154
172
  // sorted parsed args is good enough — we only need stable equality between
@@ -1,4 +1,14 @@
1
- import type { Provider, ThinkingLevel } from "./types.js";
1
+ import type { Provider, ReasoningEffort, ThinkingLevel } from "./types.js";
2
+ export interface CodexModelDescriptor {
3
+ id: string;
4
+ displayName?: string;
5
+ contextWindow?: number;
6
+ reasoningLevels?: ReasoningEffort[];
7
+ visibility?: string;
8
+ minimalClientVersion?: string;
9
+ /** Server-declared per-tool-output token cap (truncation_policy.limit when mode=tokens). */
10
+ toolOutputTokenLimit?: number;
11
+ }
2
12
  export declare function isOpenAICodexBaseUrl(baseURL: string): boolean;
3
13
  export declare function getOpenAICodexFallbackModels(): string[];
4
14
  export declare function extractChatGptAccountId(accessToken: string): string | undefined;
@@ -11,4 +21,5 @@ export declare function createOpenAICodexProvider(options: {
11
21
  export declare function fetchOpenAICodexModels(options: {
12
22
  baseURL: string;
13
23
  accessToken: string;
14
- }): Promise<string[]>;
24
+ }): Promise<CodexModelDescriptor[]>;
25
+ export declare function sortCodexModelDescriptors(descriptors: CodexModelDescriptor[]): CodexModelDescriptor[];
@@ -2,7 +2,10 @@ import { listBuiltinModels } from "./model-catalog.js";
2
2
  import { resolveProviderRequestConfig } from "./provider-transform.js";
3
3
  const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
4
4
  const OPENAI_BETA_RESPONSES = "responses=experimental";
5
- const CODEX_CLIENT_VERSION = "0.121.0";
5
+ // OpenAI gates new codex models server-side by client_version (each model carries a
6
+ // `minimal_client_version`). Track a recent real Codex CLI release; override via env
7
+ // when OpenAI lifts the gate again before we cut a new release.
8
+ const CODEX_CLIENT_VERSION = process.env.BUBBLE_CODEX_CLIENT_VERSION?.trim() || "0.150.0";
6
9
  const MODEL_DISCOVERY_PATHS = [
7
10
  `/codex/models?client_version=${CODEX_CLIENT_VERSION}`,
8
11
  "/models",
@@ -205,9 +208,9 @@ export async function fetchOpenAICodexModels(options) {
205
208
  if (!response?.ok)
206
209
  continue;
207
210
  const payload = await response.json().catch(() => undefined);
208
- const ids = extractModelIds(payload);
209
- if (ids.length > 0) {
210
- return sortCodexModelIds(ids);
211
+ const descriptors = extractCodexModelDescriptors(payload);
212
+ if (descriptors.length > 0) {
213
+ return sortCodexModelDescriptors(descriptors);
211
214
  }
212
215
  }
213
216
  return [];
@@ -361,18 +364,54 @@ function resolveRelativeUrl(baseURL, path) {
361
364
  const normalized = (baseURL.trim() || DEFAULT_CODEX_BASE_URL).replace(/\/+$/, "");
362
365
  return `${normalized}${path}`;
363
366
  }
364
- function extractModelIds(payload) {
365
- const ids = [];
367
+ const REASONING_EFFORTS = [
368
+ "off", "minimal", "low", "medium", "high", "xhigh", "max",
369
+ ];
370
+ function extractCodexModelDescriptors(payload) {
371
+ const out = [];
366
372
  const seen = new Set();
367
- const maybeAdd = (value) => {
368
- if (typeof value !== "string")
369
- return;
370
- if (!/^gpt-|^codex-/i.test(value))
371
- return;
372
- if (seen.has(value))
373
- return;
374
- seen.add(value);
375
- ids.push(value);
373
+ const isCodexId = (value) => typeof value === "string" && /^gpt-|^codex-/i.test(value);
374
+ const pickId = (record) => {
375
+ for (const key of ["slug", "id", "model_slug", "model"]) {
376
+ const v = record[key];
377
+ if (isCodexId(v))
378
+ return v;
379
+ }
380
+ return undefined;
381
+ };
382
+ const buildDescriptor = (record, id) => {
383
+ const desc = { id };
384
+ const displayName = record.display_name;
385
+ if (typeof displayName === "string" && displayName)
386
+ desc.displayName = displayName;
387
+ const ctx = record.context_window;
388
+ if (typeof ctx === "number" && ctx > 0)
389
+ desc.contextWindow = ctx;
390
+ const visibility = record.visibility;
391
+ if (typeof visibility === "string")
392
+ desc.visibility = visibility;
393
+ const minVer = record.minimal_client_version;
394
+ if (typeof minVer === "string")
395
+ desc.minimalClientVersion = minVer;
396
+ const levels = record.supported_reasoning_levels;
397
+ if (Array.isArray(levels)) {
398
+ const efforts = new Set(["off"]);
399
+ for (const level of levels) {
400
+ const effort = level?.effort;
401
+ if (typeof effort === "string" && REASONING_EFFORTS.includes(effort)) {
402
+ efforts.add(effort);
403
+ }
404
+ }
405
+ desc.reasoningLevels = REASONING_EFFORTS.filter((e) => efforts.has(e));
406
+ }
407
+ const truncPolicy = record.truncation_policy;
408
+ if (truncPolicy && truncPolicy.mode === "tokens") {
409
+ const limit = truncPolicy.limit;
410
+ if (typeof limit === "number" && limit > 0) {
411
+ desc.toolOutputTokenLimit = limit;
412
+ }
413
+ }
414
+ return desc;
376
415
  };
377
416
  const visit = (value) => {
378
417
  if (Array.isArray(value)) {
@@ -380,32 +419,42 @@ function extractModelIds(payload) {
380
419
  visit(item);
381
420
  return;
382
421
  }
383
- if (!value || typeof value !== "object") {
384
- maybeAdd(value);
422
+ if (!value || typeof value !== "object")
385
423
  return;
386
- }
387
424
  const record = value;
388
- maybeAdd(record.id);
389
- maybeAdd(record.slug);
390
- maybeAdd(record.model);
391
- maybeAdd(record.model_slug);
425
+ const id = pickId(record);
426
+ if (id && !seen.has(id)) {
427
+ seen.add(id);
428
+ out.push(buildDescriptor(record, id));
429
+ }
392
430
  for (const child of Object.values(record)) {
393
- if (child !== record.id && child !== record.slug && child !== record.model && child !== record.model_slug) {
431
+ if (child && typeof child === "object")
394
432
  visit(child);
395
- }
396
433
  }
397
434
  };
398
435
  visit(payload);
399
- return ids;
436
+ return out;
437
+ }
438
+ // Extracts the family version from a codex slug (e.g. "gpt-5.5-codex" → 5005).
439
+ // Used so models from a newer family float to the top even before the static
440
+ // catalog knows about them.
441
+ function parseCodexFamilyRank(id) {
442
+ const match = id.match(/(\d+)\.(\d+)/);
443
+ if (!match)
444
+ return 0;
445
+ return parseInt(match[1], 10) * 1000 + parseInt(match[2], 10);
400
446
  }
401
- function sortCodexModelIds(ids) {
402
- const preferredModels = getOpenAICodexFallbackModels();
403
- const preferred = new Map(preferredModels.map((id, index) => [id, index]));
404
- return [...ids].sort((left, right) => {
405
- const leftRank = preferred.get(left) ?? Number.MAX_SAFE_INTEGER;
406
- const rightRank = preferred.get(right) ?? Number.MAX_SAFE_INTEGER;
447
+ export function sortCodexModelDescriptors(descriptors) {
448
+ const preferred = new Map(getOpenAICodexFallbackModels().map((id, index) => [id, index]));
449
+ return [...descriptors].sort((left, right) => {
450
+ const leftFamily = parseCodexFamilyRank(left.id);
451
+ const rightFamily = parseCodexFamilyRank(right.id);
452
+ if (leftFamily !== rightFamily)
453
+ return rightFamily - leftFamily;
454
+ const leftRank = preferred.get(left.id) ?? Number.MAX_SAFE_INTEGER;
455
+ const rightRank = preferred.get(right.id) ?? Number.MAX_SAFE_INTEGER;
407
456
  if (leftRank !== rightRank)
408
457
  return leftRank - rightRank;
409
- return left.localeCompare(right);
458
+ return left.id.localeCompare(right.id);
410
459
  });
411
460
  }
@@ -4,7 +4,7 @@
4
4
  * Supports OpenAI-compatible providers with dynamic or static model lists.
5
5
  * Reads provider configuration from models.json first, then falls back to config.json.
6
6
  */
7
- import { BUILTIN_PROVIDERS as CATALOG_PROVIDERS, getBuiltinModel, getBuiltinProvider, listBuiltinModels, } from "./model-catalog.js";
7
+ import { BUILTIN_PROVIDERS as CATALOG_PROVIDERS, getBuiltinModel, getBuiltinProvider, listBuiltinModels, registerDynamicModelMetadata, } from "./model-catalog.js";
8
8
  import { ModelConfig } from "./model-config.js";
9
9
  import { AuthStorage } from "./oauth/index.js";
10
10
  import { fetchOpenAICodexModels } from "./provider-openai-codex.js";
@@ -194,12 +194,28 @@ export class ProviderRegistry {
194
194
  }
195
195
  if (provider.id === "openai" && provider.authType === "oauth" && provider.apiKey) {
196
196
  try {
197
- const models = await fetchOpenAICodexModels({
197
+ const descriptors = await fetchOpenAICodexModels({
198
198
  baseURL: provider.baseURL,
199
199
  accessToken: provider.apiKey,
200
200
  });
201
- if (models.length > 0) {
202
- return models.map((id) => ({ id, name: id, providerId: provider.id }));
201
+ const visible = descriptors.filter((d) => d.visibility !== "hide");
202
+ if (visible.length > 0) {
203
+ for (const d of visible) {
204
+ const catalogEntry = getBuiltinModel("openai-codex", d.id);
205
+ registerDynamicModelMetadata({
206
+ id: d.id,
207
+ name: d.displayName || catalogEntry?.name || d.id,
208
+ providerId: "openai-codex",
209
+ reasoningLevels: d.reasoningLevels ?? catalogEntry?.reasoningLevels ?? ["off"],
210
+ contextWindow: d.contextWindow ?? catalogEntry?.contextWindow,
211
+ toolOutputTokenLimit: d.toolOutputTokenLimit ?? catalogEntry?.toolOutputTokenLimit,
212
+ });
213
+ }
214
+ return visible.map((d) => ({
215
+ id: d.id,
216
+ name: d.displayName || d.id,
217
+ providerId: provider.id,
218
+ }));
203
219
  }
204
220
  }
205
221
  catch {
@@ -308,6 +308,30 @@ const builtinSlashCommandEntries = [
308
308
  ctx.exit();
309
309
  },
310
310
  },
311
+ {
312
+ name: "theme",
313
+ description: "Switch the color theme. Usage: /theme [auto|light|dark]",
314
+ async handler(args, ctx) {
315
+ if (!ctx.setThemeMode || !ctx.getThemeMode || !ctx.getResolvedTheme) {
316
+ return "Theme switching is only available inside the TUI.";
317
+ }
318
+ const arg = args.trim().toLowerCase();
319
+ if (!arg) {
320
+ const order = ["auto", "light", "dark"];
321
+ const current = ctx.getThemeMode();
322
+ const next = order[(order.indexOf(current) + 1) % order.length];
323
+ ctx.setThemeMode(next);
324
+ const resolved = next === "auto" ? ctx.getResolvedTheme() : next;
325
+ return `Theme: ${next}${next === "auto" ? ` (resolved to ${resolved})` : ""}`;
326
+ }
327
+ if (arg !== "auto" && arg !== "light" && arg !== "dark") {
328
+ return "Usage: /theme [auto|light|dark]";
329
+ }
330
+ ctx.setThemeMode(arg);
331
+ const resolved = arg === "auto" ? ctx.getResolvedTheme() : arg;
332
+ return `Theme set to ${arg}${arg === "auto" ? ` (resolved to ${resolved})` : ""}.`;
333
+ },
334
+ },
311
335
  {
312
336
  name: "clear",
313
337
  description: "Clear the current conversation history",
@@ -8,6 +8,7 @@ import type { SettingsManager } from "../permissions/settings.js";
8
8
  import type { McpManager } from "../mcp/manager.js";
9
9
  import type { LspService } from "../lsp/index.js";
10
10
  import type { MemoryScope } from "../memory/index.js";
11
+ import type { ThemeMode } from "../config.js";
11
12
  export interface SlashCommandContext {
12
13
  agent: Agent;
13
14
  addMessage: (role: "user" | "assistant" | "error", content: string) => void;
@@ -27,6 +28,12 @@ export interface SlashCommandContext {
27
28
  runMemoryCompaction?: () => Promise<string>;
28
29
  runMemorySummary?: (scope?: MemoryScope) => Promise<string>;
29
30
  runMemoryRefresh?: (scope?: MemoryScope) => Promise<string>;
31
+ /** Get the current theme mode (auto/light/dark) — undefined when running in non-TUI contexts. */
32
+ getThemeMode?: () => ThemeMode;
33
+ /** Get the resolved active theme (always light or dark) — undefined when running in non-TUI contexts. */
34
+ getResolvedTheme?: () => "light" | "dark";
35
+ /** Persist a new theme mode AND apply it to the running TUI. */
36
+ setThemeMode?: (mode: ThemeMode) => void;
30
37
  }
31
38
  /**
32
39
  * Return types for a slash command handler:
@@ -1,4 +1,5 @@
1
1
  import { discoverAgentProfiles, findAgentProfile } from "../agent/profiles.js";
2
+ import { formatSubagentRoute } from "../agent/subagent-route-format.js";
2
3
  export function createSpawnAgentTool() {
3
4
  return {
4
5
  name: "spawn_agent",
@@ -16,6 +17,7 @@ export function createSpawnAgentTool() {
16
17
  properties: {
17
18
  agent_type: { type: "string", description: "Subagent profile or role name. Defaults to default." },
18
19
  agent: { type: "string", description: "Alias for agent_type." },
20
+ category: { type: "string", description: "Optional semantic category for model/thinking routing, such as quick, deep, explore, review, frontend, or writing." },
19
21
  message: { type: "string", description: "Initial task for the subagent." },
20
22
  task: { type: "string", description: "Alias for message." },
21
23
  fork_context: { type: "boolean", description: "When true, copy recent parent conversation into the child thread." },
@@ -55,15 +57,18 @@ export function createSpawnAgentTool() {
55
57
  const snapshot = await ctx.agent.spawnSubAgent(message, ctx.cwd, {
56
58
  profile: resolved.profile,
57
59
  parentToolCallId: ctx.toolCall?.id ?? snapshotFallbackId(),
60
+ category: stringArg(args.category),
58
61
  approval: parseApproval(args.approval),
59
62
  abortSignal: ctx.abortSignal,
60
63
  forkContext: args.fork_context === true,
61
64
  });
62
65
  return formatLifecycleResult("spawn_agent", [snapshot], [
63
- `Spawned ${snapshot.nickname} (${snapshot.agentName})`,
66
+ `Spawned ${snapshot.nickname} (${formatSnapshotRole(snapshot)})`,
64
67
  `agent_id: ${snapshot.agentId}`,
65
68
  `status: ${snapshot.status}`,
66
- `next: call wait_agent for ${snapshot.agentId} to collect the delegated result`,
69
+ ...formatRouteLines(snapshot),
70
+ `next: call wait_agent for ${snapshot.agentId} before reporting this subagent's current status or final result`,
71
+ "counting: this spawn result creates one unique subagent; later wait_agent results for the same agent_id are updates, not additional subagents",
67
72
  ]);
68
73
  }
69
74
  catch (error) {
@@ -278,13 +283,17 @@ function isFinalSnapshotStatus(status) {
278
283
  || status === "closed";
279
284
  }
280
285
  function formatSnapshot(snapshot) {
281
- const label = `${snapshot.nickname} (${snapshot.agentName})`;
286
+ const label = `${snapshot.nickname} (${formatSnapshotRole(snapshot)})`;
282
287
  const lines = [
283
288
  `## ${label}`,
284
289
  `agent_id: ${snapshot.agentId}`,
285
290
  `status: ${snapshot.status}`,
286
- `task: ${snapshot.task}`,
287
291
  ];
292
+ if (snapshot.category) {
293
+ lines.push(`category: ${snapshot.category}`);
294
+ }
295
+ lines.push(...formatRouteLines(snapshot));
296
+ lines.push(`task: ${snapshot.task}`);
288
297
  if (snapshot.summary) {
289
298
  lines.push("", "Summary:", snapshot.summary);
290
299
  }
@@ -299,6 +308,13 @@ function formatSnapshot(snapshot) {
299
308
  }
300
309
  return lines;
301
310
  }
311
+ function formatSnapshotRole(snapshot) {
312
+ return [snapshot.agentName, snapshot.category ? `/${snapshot.category}` : ""].join("") || "default";
313
+ }
314
+ function formatRouteLines(snapshot) {
315
+ const route = formatSubagentRoute(snapshot.route, { includeThinking: true });
316
+ return route ? [`route: ${route}`] : [];
317
+ }
302
318
  function snapshotToMetadata(snapshot) {
303
319
  return {
304
320
  subAgentId: snapshot.agentId,
@@ -306,6 +322,8 @@ function snapshotToMetadata(snapshot) {
306
322
  nickname: snapshot.nickname,
307
323
  status: snapshot.status === "closed" ? "cancelled" : snapshot.status,
308
324
  profileSource: snapshot.profileSource,
325
+ category: snapshot.category,
326
+ route: snapshot.route,
309
327
  task: snapshot.task,
310
328
  summary: snapshot.summary,
311
329
  toolNotes: snapshot.toolNotes,
@@ -5,7 +5,6 @@
5
5
  */
6
6
  import { constants } from "node:fs";
7
7
  import { access, readFile, writeFile } from "node:fs/promises";
8
- import { resolve } from "node:path";
9
8
  import { createTwoFilesPatch } from "diff";
10
9
  import { gateToolAction } from "../approval/tool-helper.js";
11
10
  import { countUnifiedDiffChanges } from "../diff-stats.js";
@@ -13,6 +12,7 @@ import { formatDiagnosticBlocks } from "../lsp/index.js";
13
12
  import { applyEditsToContent, EditApplyError, formatEditMatchNotes } from "./edit-apply.js";
14
13
  import { withFileMutationQueue } from "./file-mutation-queue.js";
15
14
  import { isWithinWorkspace } from "./file-state.js";
15
+ import { resolveToolPath } from "./path-utils.js";
16
16
  export function createEditTool(cwd, approval, lsp, fileState) {
17
17
  return {
18
18
  name: "edit",
@@ -39,7 +39,7 @@ export function createEditTool(cwd, approval, lsp, fileState) {
39
39
  required: ["path", "edits"],
40
40
  },
41
41
  async execute(args) {
42
- const filePath = resolve(cwd, args.path);
42
+ const filePath = resolveToolPath(cwd, args.path);
43
43
  if (!isWithinWorkspace(cwd, filePath)) {
44
44
  return {
45
45
  content: `Error: Edit path is outside the workspace: ${filePath}`,
@@ -5,6 +5,7 @@ import { readdir, stat } from "node:fs/promises";
5
5
  import { relative, resolve } from "node:path";
6
6
  import picomatch from "picomatch";
7
7
  import { isSensitivePath } from "./sensitive-paths.js";
8
+ import { resolveToolPath } from "./path-utils.js";
8
9
  const MAX_RESULTS = 100;
9
10
  const DEFAULT_IGNORES = new Set([
10
11
  ".git",
@@ -31,7 +32,7 @@ export function createGlobTool(cwd) {
31
32
  required: ["pattern"],
32
33
  },
33
34
  async execute(args, ctx) {
34
- const root = resolve(cwd, typeof args.path === "string" && args.path.trim() ? args.path : ".");
35
+ const root = resolveToolPath(cwd, typeof args.path === "string" && args.path.trim() ? args.path : ".");
35
36
  const pattern = String(args.pattern || "").trim();
36
37
  if (!pattern) {
37
38
  return { content: "Error: glob pattern is required", isError: true, status: "command_error" };
@@ -2,9 +2,9 @@
2
2
  * Grep tool - search file contents using ripgrep.
3
3
  */
4
4
  import { execFile } from "node:child_process";
5
- import { resolve } from "node:path";
6
5
  import { isSensitivePath } from "./sensitive-paths.js";
7
6
  import { analyzeToolIntent } from "../agent/tool-intent.js";
7
+ import { resolveToolPath } from "./path-utils.js";
8
8
  const MAX_MATCHES = 100;
9
9
  export function createGrepTool(cwd) {
10
10
  return {
@@ -22,7 +22,7 @@ export function createGrepTool(cwd) {
22
22
  required: ["pattern"],
23
23
  },
24
24
  async execute(args) {
25
- const searchPath = args.path ? resolve(cwd, args.path) : cwd;
25
+ const searchPath = args.path ? resolveToolPath(cwd, args.path) : cwd;
26
26
  const pattern = String(args.pattern);
27
27
  const intent = analyzeToolIntent({
28
28
  name: "grep",
package/dist/tools/lsp.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { access } from "node:fs/promises";
2
2
  import { constants } from "node:fs";
3
- import { resolve } from "node:path";
4
3
  import { gateToolAction } from "../approval/tool-helper.js";
5
4
  import { getLspService } from "../lsp/index.js";
5
+ import { resolveToolPath } from "./path-utils.js";
6
6
  const OPERATIONS = [
7
7
  "goToDefinition",
8
8
  "findReferences",
@@ -37,7 +37,7 @@ export function createLspTool(cwd, lsp = getLspService(cwd), approval) {
37
37
  if (!OPERATIONS.includes(operation)) {
38
38
  return { content: `Error: Unsupported LSP operation: ${args.operation}`, isError: true };
39
39
  }
40
- const file = resolve(cwd, String(args.filePath));
40
+ const file = resolveToolPath(cwd, args.filePath);
41
41
  try {
42
42
  await access(file, constants.R_OK);
43
43
  }
@@ -0,0 +1,2 @@
1
+ export declare function expandHomePath(value: unknown): string;
2
+ export declare function resolveToolPath(cwd: string, value: unknown, fallback?: string): string;
@@ -0,0 +1,16 @@
1
+ import { homedir } from "node:os";
2
+ import { join, resolve } from "node:path";
3
+ export function expandHomePath(value) {
4
+ const text = String(value ?? "");
5
+ if (text === "~")
6
+ return homedir();
7
+ if (text.startsWith("~/") || text.startsWith("~\\")) {
8
+ return join(homedir(), text.slice(2));
9
+ }
10
+ return text;
11
+ }
12
+ export function resolveToolPath(cwd, value, fallback = ".") {
13
+ const text = String(value ?? "");
14
+ const path = text === "" ? fallback : text;
15
+ return resolve(cwd, expandHomePath(path));
16
+ }