@bubblebrain-ai/bubble 0.0.28 → 0.0.30

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 (63) hide show
  1. package/README.md +23 -3
  2. package/dist/agent/categories.d.ts +2 -0
  3. package/dist/agent/categories.js +4 -0
  4. package/dist/agent/child-runner.d.ts +5 -1
  5. package/dist/agent/child-runner.js +35 -2
  6. package/dist/agent/profiles.js +3 -0
  7. package/dist/agent/structured-output.d.ts +37 -0
  8. package/dist/agent/structured-output.js +193 -0
  9. package/dist/agent/subagent-control.d.ts +3 -0
  10. package/dist/agent/subagent-scheduler.d.ts +10 -0
  11. package/dist/agent/subagent-scheduler.js +31 -0
  12. package/dist/agent/workflow/control.d.ts +37 -0
  13. package/dist/agent/workflow/control.js +20 -0
  14. package/dist/agent/workflow/errors.d.ts +16 -0
  15. package/dist/agent/workflow/errors.js +24 -0
  16. package/dist/agent/workflow/runtime.d.ts +75 -0
  17. package/dist/agent/workflow/runtime.js +237 -0
  18. package/dist/agent.d.ts +105 -0
  19. package/dist/agent.js +425 -17
  20. package/dist/context/compact-llm.d.ts +10 -1
  21. package/dist/context/compact-llm.js +13 -5
  22. package/dist/context/compact.d.ts +30 -0
  23. package/dist/context/compact.js +34 -17
  24. package/dist/goal/format.d.ts +1 -1
  25. package/dist/goal/format.js +1 -1
  26. package/dist/network/provider-transport.d.ts +9 -0
  27. package/dist/network/provider-transport.js +19 -1
  28. package/dist/provider.d.ts +14 -0
  29. package/dist/provider.js +24 -0
  30. package/dist/session.d.ts +16 -0
  31. package/dist/session.js +33 -1
  32. package/dist/slash-commands/commands.js +41 -113
  33. package/dist/slash-commands/types.d.ts +14 -9
  34. package/dist/tools/agent-lifecycle.d.ts +6 -0
  35. package/dist/tools/agent-lifecycle.js +285 -0
  36. package/dist/tools/child-tools.d.ts +10 -0
  37. package/dist/tools/child-tools.js +12 -0
  38. package/dist/tools/read.d.ts +1 -1
  39. package/dist/tools/read.js +9 -0
  40. package/dist/tui/image-display.d.ts +6 -0
  41. package/dist/tui/image-display.js +26 -1
  42. package/dist/tui-ink/app.d.ts +0 -18
  43. package/dist/tui-ink/app.js +168 -230
  44. package/dist/tui-ink/compaction-progress.d.ts +19 -0
  45. package/dist/tui-ink/compaction-progress.js +74 -0
  46. package/dist/tui-ink/input-box.d.ts +10 -1
  47. package/dist/tui-ink/input-box.js +56 -16
  48. package/dist/tui-ink/markdown.d.ts +18 -0
  49. package/dist/tui-ink/markdown.js +172 -16
  50. package/dist/tui-ink/message-list.d.ts +1 -2
  51. package/dist/tui-ink/message-list.js +50 -107
  52. package/dist/tui-ink/run.js +5 -0
  53. package/dist/tui-ink/subagent-inspector.d.ts +17 -0
  54. package/dist/tui-ink/subagent-inspector.js +189 -0
  55. package/dist/tui-ink/subagent-view.d.ts +47 -0
  56. package/dist/tui-ink/subagent-view.js +163 -0
  57. package/dist/tui-ink/terminal-env.d.ts +15 -0
  58. package/dist/tui-ink/terminal-env.js +22 -0
  59. package/dist/tui-ink/use-terminal-size.js +33 -6
  60. package/dist/tui-ink/width.d.ts +18 -0
  61. package/dist/tui-ink/width.js +130 -0
  62. package/dist/types.d.ts +35 -0
  63. package/package.json +2 -1
@@ -1,6 +1,5 @@
1
- export function compactSessionEntries(entries, options = {}) {
1
+ export function planSessionCompaction(entries, options = {}) {
2
2
  const keepRecentTurns = options.keepRecentTurns ?? 2;
3
- const maxSummaryItems = options.maxSummaryItems ?? 4;
4
3
  const metadataEntries = entries.filter((entry) => entry.type === "metadata");
5
4
  const nonMetadataEntries = entries.filter((entry) => entry.type !== "metadata");
6
5
  const latestSummaryIndex = findLatestSummaryIndex(nonMetadataEntries);
@@ -10,34 +9,52 @@ export function compactSessionEntries(entries, options = {}) {
10
9
  .map((entry, index) => (entry.type === "user_message" ? index : -1))
11
10
  .filter((index) => index >= 0);
12
11
  if (turnStartIndexes.length <= keepRecentTurns) {
13
- return { compacted: false };
12
+ return { compactable: false };
14
13
  }
15
14
  const keepStartIndex = turnStartIndexes[Math.max(0, turnStartIndexes.length - keepRecentTurns)];
16
15
  if (keepStartIndex <= 0) {
17
- return { compacted: false };
18
- }
19
- const oldEntries = activeEntries.slice(0, keepStartIndex);
20
- const keptEntries = activeEntries.slice(keepStartIndex);
21
- const summary = buildCompactionSummary(oldEntries, maxSummaryItems);
22
- if (!summary) {
23
- return { compacted: false };
16
+ return { compactable: false };
24
17
  }
18
+ return {
19
+ compactable: true,
20
+ metadataEntries,
21
+ oldEntries: activeEntries.slice(0, keepStartIndex),
22
+ keptEntries: activeEntries.slice(keepStartIndex),
23
+ };
24
+ }
25
+ /**
26
+ * Assemble the post-compaction entry list from a plan and a (possibly
27
+ * LLM-generated) summary string. The summary entry is keyed off the full
28
+ * original `entries` so its id never collides with a prior summary.
29
+ */
30
+ export function buildCompactedEntries(entries, plan, summary) {
25
31
  const summaryEntry = {
26
32
  id: nextSummaryId(entries),
27
33
  type: "summary",
28
34
  summary,
29
35
  timestamp: Date.now(),
30
36
  };
31
- const nextEntries = [
32
- ...metadataEntries,
33
- summaryEntry,
34
- ...keptEntries,
35
- ];
37
+ return [...plan.metadataEntries, summaryEntry, ...plan.keptEntries];
38
+ }
39
+ /** Flatten a plan's old entries into messages for an external summarizer. */
40
+ export function planOldMessages(plan) {
41
+ return entriesToMessages(plan.oldEntries);
42
+ }
43
+ export function compactSessionEntries(entries, options = {}) {
44
+ const maxSummaryItems = options.maxSummaryItems ?? 4;
45
+ const plan = planSessionCompaction(entries, options);
46
+ if (!plan.compactable) {
47
+ return { compacted: false };
48
+ }
49
+ const summary = buildCompactionSummary(plan.oldEntries, maxSummaryItems);
50
+ if (!summary) {
51
+ return { compacted: false };
52
+ }
36
53
  return {
37
54
  compacted: true,
38
55
  summary,
39
- entries: nextEntries,
40
- droppedEntries: oldEntries.length,
56
+ entries: buildCompactedEntries(entries, plan, summary),
57
+ droppedEntries: plan.oldEntries.length,
41
58
  };
42
59
  }
43
60
  export function compactMessages(messages, options = {}) {
@@ -14,5 +14,5 @@ export declare function goalSummaryText(goal: GoalState): string;
14
14
  * update_goal tool can't report this — see goal/tools.ts).
15
15
  */
16
16
  export declare function goalCompleteNotice(goal: GoalState): string;
17
- /** Compact single-line indicator for the status line / sidebar. */
17
+ /** Compact single-line indicator for status surfaces. */
18
18
  export declare function goalIndicatorLine(goal: GoalState, maxObjective?: number): string;
@@ -95,7 +95,7 @@ function completionTokenUsagePhrase(goal) {
95
95
  ? `${formatTokensCompact(goal.tokensUsed)}/${formatTokensCompact(goal.tokenBudget)} tok used`
96
96
  : `${formatTokensCompact(goal.tokensUsed)} tok used`;
97
97
  }
98
- /** Compact single-line indicator for the status line / sidebar. */
98
+ /** Compact single-line indicator for status surfaces. */
99
99
  export function goalIndicatorLine(goal, maxObjective = 48) {
100
100
  const segments = [`goal: ${goalStatusLabel(goal.status)}`, `${goal.turnsSpent} turns`];
101
101
  const tokens = tokensPart(goal);
@@ -28,5 +28,14 @@ export declare function normalizeProviderNetworkError(error: unknown, options: {
28
28
  env?: NodeJS.ProcessEnv;
29
29
  }): Error;
30
30
  export declare function isProviderTransportError(error: unknown): boolean;
31
+ /**
32
+ * Request/response timeouts surface as prose rather than errno tokens — e.g.
33
+ * Bun fetch throws a DOMException named "TimeoutError" with message
34
+ * "The operation timed out.", and openai-node raises APIConnectionTimeoutError.
35
+ * These are kept OUT of isProviderNetworkErrorText on purpose: that predicate
36
+ * drives normalizeProviderNetworkError's proxy/TLS/CA advice, and a plain
37
+ * timeout must not be rewrapped into a misleading "check your proxy" message.
38
+ */
39
+ export declare function isProviderTimeoutErrorText(text: string): boolean;
31
40
  export declare function shouldEnableFetchVerbose(env?: NodeJS.ProcessEnv, providerVerboseEnvVar?: string): boolean;
32
41
  export {};
@@ -99,7 +99,25 @@ export function normalizeProviderNetworkError(error, options) {
99
99
  return new Error(message, { cause: error });
100
100
  }
101
101
  export function isProviderTransportError(error) {
102
- return isProviderNetworkErrorText(errorMessageChain(error).join("\n"));
102
+ const text = errorMessageChain(error).join("\n");
103
+ return isProviderNetworkErrorText(text) || isProviderTimeoutErrorText(text);
104
+ }
105
+ /**
106
+ * Request/response timeouts surface as prose rather than errno tokens — e.g.
107
+ * Bun fetch throws a DOMException named "TimeoutError" with message
108
+ * "The operation timed out.", and openai-node raises APIConnectionTimeoutError.
109
+ * These are kept OUT of isProviderNetworkErrorText on purpose: that predicate
110
+ * drives normalizeProviderNetworkError's proxy/TLS/CA advice, and a plain
111
+ * timeout must not be rewrapped into a misleading "check your proxy" message.
112
+ */
113
+ export function isProviderTimeoutErrorText(text) {
114
+ return [
115
+ /operation timed out/i,
116
+ /request timed out/i,
117
+ /\bTimeoutError\b/i,
118
+ /\bAPIConnectionTimeoutError\b/i,
119
+ /\bESOCKETTIMEDOUT\b/i,
120
+ ].some((pattern) => pattern.test(text));
103
121
  }
104
122
  export function shouldEnableFetchVerbose(env = process.env, providerVerboseEnvVar) {
105
123
  const providerValue = providerVerboseEnvVar ? env[providerVerboseEnvVar] : undefined;
@@ -54,4 +54,18 @@ export declare function translateOpenAIFullResponse(response: any): AsyncIterabl
54
54
  * in index order to keep multi-call turns deterministic.
55
55
  */
56
56
  export declare function translateOpenAIStream(stream: AsyncIterable<any>, options?: TranslateOpenAIStreamOptions): AsyncIterable<StreamChunk>;
57
+ /** Largest value Node's 32-bit timers accept; ~24.8 days. */
58
+ export declare const MAX_TIMER_MS = 2147483647;
59
+ /**
60
+ * Resolve the provider request timeout (ms) from the operator override.
61
+ *
62
+ * Default is effectively NO TIMEOUT — safe for streaming APIs where the model
63
+ * sends chunks continuously. But Node's timers are 32-bit: a duration above
64
+ * 2**31-1 ms overflows, which makes Node print a TimeoutOverflowWarning to
65
+ * stderr (corrupting the Ink TUI) AND silently clamp the timeout to 1ms,
66
+ * aborting the request almost immediately. So we use the largest SAFE timer
67
+ * value as the "no timeout" sentinel — never Number.MAX_SAFE_INTEGER — and
68
+ * clamp any operator-supplied value into range too.
69
+ */
70
+ export declare function resolveRequestTimeoutMs(raw: string | undefined): number;
57
71
  export {};
package/dist/provider.js CHANGED
@@ -94,6 +94,7 @@ export function createProviderInstance(options) {
94
94
  const client = new OpenAI({
95
95
  apiKey: options.apiKey,
96
96
  baseURL: options.baseURL,
97
+ timeout: resolveRequestTimeoutMs(process.env.BUBBLE_PROVIDER_REQUEST_TIMEOUT_MS),
97
98
  });
98
99
  const fallbackModel = "gpt-4o";
99
100
  async function* streamChat(messages, chatOptions) {
@@ -704,6 +705,29 @@ function mergeToolArgumentDelta(current, incoming, mode) {
704
705
  debugToolArgs({ stage: "merge", branch: mode === "delta" ? "delta-append" : "snapshot-fallback-concat", current, incoming, args: current + incoming, delta: incoming });
705
706
  return { args: current + incoming, delta: incoming };
706
707
  }
708
+ function parsePositiveInt(raw) {
709
+ if (!raw?.trim())
710
+ return undefined;
711
+ const value = Number(raw);
712
+ return Number.isInteger(value) && value > 0 ? value : undefined;
713
+ }
714
+ /** Largest value Node's 32-bit timers accept; ~24.8 days. */
715
+ export const MAX_TIMER_MS = 2_147_483_647; // 2**31 - 1
716
+ /**
717
+ * Resolve the provider request timeout (ms) from the operator override.
718
+ *
719
+ * Default is effectively NO TIMEOUT — safe for streaming APIs where the model
720
+ * sends chunks continuously. But Node's timers are 32-bit: a duration above
721
+ * 2**31-1 ms overflows, which makes Node print a TimeoutOverflowWarning to
722
+ * stderr (corrupting the Ink TUI) AND silently clamp the timeout to 1ms,
723
+ * aborting the request almost immediately. So we use the largest SAFE timer
724
+ * value as the "no timeout" sentinel — never Number.MAX_SAFE_INTEGER — and
725
+ * clamp any operator-supplied value into range too.
726
+ */
727
+ export function resolveRequestTimeoutMs(raw) {
728
+ const requested = parsePositiveInt(raw);
729
+ return Math.min(requested ?? MAX_TIMER_MS, MAX_TIMER_MS);
730
+ }
707
731
  function mergeStreamingText(current, incoming, mode) {
708
732
  if (!current)
709
733
  return { args: incoming, delta: incoming };
package/dist/session.d.ts CHANGED
@@ -57,6 +57,22 @@ export declare class SessionManager {
57
57
  appendTodosSnapshot(todos: Todo[]): void;
58
58
  getTodos(): Todo[];
59
59
  compact(options?: CompactOptions): CompactResult;
60
+ /**
61
+ * Inspect whether the session is large enough to compact and, if so, return
62
+ * the older messages an external summarizer should condense. Returns null
63
+ * when there isn't enough history past the last summary to bother — the
64
+ * caller should then report "already compact enough" without calling a model.
65
+ */
66
+ getCompactionPlan(options?: CompactOptions): {
67
+ oldMessages: Message[];
68
+ } | null;
69
+ /**
70
+ * Apply a precomputed (typically LLM-generated) summary as the compaction
71
+ * checkpoint, rewriting the log to [metadata, summary, kept turns]. Mirrors
72
+ * `compact()` but skips the built-in heuristic summarizer. Returns
73
+ * `{ compacted: false }` if the session is no longer compactable.
74
+ */
75
+ applyLLMCompaction(summary: string, options?: CompactOptions): CompactResult;
60
76
  getMessages(): Message[];
61
77
  /**
62
78
  * Pre-edit file snapshot store for this session, used by /rewind.
package/dist/session.js CHANGED
@@ -6,7 +6,7 @@ import { mkdirSync, appendFileSync, existsSync, readFileSync, readdirSync, statS
6
6
  import { basename, dirname, join } from "node:path";
7
7
  import { getBubbleHome } from "./bubble-home.js";
8
8
  import { CheckpointStore } from "./checkpoints.js";
9
- import { compactSessionEntries } from "./context/compact.js";
9
+ import { buildCompactedEntries, compactSessionEntries, planOldMessages, planSessionCompaction, } from "./context/compact.js";
10
10
  import { SessionLog } from "./session-log.js";
11
11
  import { normalizeSingleLine, truncateVisual } from "./text-display.js";
12
12
  import { deterministicTitleFromUserContent } from "./session-title.js";
@@ -162,6 +162,38 @@ export class SessionManager {
162
162
  }
163
163
  return result;
164
164
  }
165
+ /**
166
+ * Inspect whether the session is large enough to compact and, if so, return
167
+ * the older messages an external summarizer should condense. Returns null
168
+ * when there isn't enough history past the last summary to bother — the
169
+ * caller should then report "already compact enough" without calling a model.
170
+ */
171
+ getCompactionPlan(options) {
172
+ const plan = planSessionCompaction(this.log.list(), options);
173
+ if (!plan.compactable)
174
+ return null;
175
+ return { oldMessages: planOldMessages(plan) };
176
+ }
177
+ /**
178
+ * Apply a precomputed (typically LLM-generated) summary as the compaction
179
+ * checkpoint, rewriting the log to [metadata, summary, kept turns]. Mirrors
180
+ * `compact()` but skips the built-in heuristic summarizer. Returns
181
+ * `{ compacted: false }` if the session is no longer compactable.
182
+ */
183
+ applyLLMCompaction(summary, options) {
184
+ const entries = this.log.list();
185
+ const plan = planSessionCompaction(entries, options);
186
+ if (!plan.compactable)
187
+ return { compacted: false };
188
+ const nextEntries = buildCompactedEntries(entries, plan, summary);
189
+ this.rewrite(nextEntries);
190
+ return {
191
+ compacted: true,
192
+ summary,
193
+ entries: nextEntries,
194
+ droppedEntries: plan.oldEntries.length,
195
+ };
196
+ }
165
197
  getMessages() {
166
198
  return this.log.toMessages();
167
199
  }
@@ -1,4 +1,4 @@
1
- import { UserConfig, maskKey } from "../config.js";
1
+ import { UserConfig } from "../config.js";
2
2
  import { formatContextUsage } from "../context/usage.js";
3
3
  import { formatDiagnostics } from "../lsp/index.js";
4
4
  import { normalizeNameForMCP } from "../mcp/name.js";
@@ -8,7 +8,6 @@ import { getAvailableThinkingLevels, getDefaultThinkingLevel, normalizeThinkingL
8
8
  import { SessionManager } from "../session.js";
9
9
  import { buildSystemPrompt } from "../system-prompt.js";
10
10
  import { normalizeSingleLine } from "../text-display.js";
11
- import { copyToClipboard } from "../clipboard.js";
12
11
  import { formatRelativeTime } from "../tui/recent-activity.js";
13
12
  import { HOOK_EVENT_NAMES, isHookEventName } from "../hooks/index.js";
14
13
  import { isThinkingLevel } from "../variant/thinking-level.js";
@@ -287,17 +286,6 @@ async function handleMemoryCommand(args, ctx) {
287
286
  }
288
287
  return "Usage: /memory [status|search|compact|summarize|refresh|reset]";
289
288
  }
290
- function parseKeyArgs(args, ctx) {
291
- const trimmed = args.trim();
292
- const [first, ...rest] = trimmed.split(/\s+/);
293
- const explicitProvider = first
294
- ? ctx.registry.getConfigured().find((provider) => provider.id === first)
295
- : undefined;
296
- if (explicitProvider) {
297
- return { provider: explicitProvider, apiKey: rest.join(" ") };
298
- }
299
- return { provider: ctx.registry.getDefault(), apiKey: trimmed };
300
- }
301
289
  const builtinSlashCommandEntries = [
302
290
  {
303
291
  name: "skills",
@@ -363,33 +351,6 @@ const builtinSlashCommandEntries = [
363
351
  return `Theme set to ${arg}${arg === "auto" ? ` (resolved to ${resolved})` : ""}.`;
364
352
  },
365
353
  },
366
- {
367
- name: "sidebar",
368
- description: "Toggle the right sidebar. Usage: /sidebar [open|close|auto]",
369
- async handler(args, ctx) {
370
- if (!ctx.toggleSidebar || !ctx.setSidebarMode) {
371
- return "Sidebar control is only available inside the TUI.";
372
- }
373
- const arg = args.trim().toLowerCase();
374
- if (!arg) {
375
- ctx.toggleSidebar();
376
- return;
377
- }
378
- if (["open", "show", "expand", "expanded", "on"].includes(arg)) {
379
- ctx.setSidebarMode("expanded");
380
- return;
381
- }
382
- if (["close", "hide", "collapse", "collapsed", "off"].includes(arg)) {
383
- ctx.setSidebarMode("collapsed");
384
- return;
385
- }
386
- if (arg === "auto") {
387
- ctx.setSidebarMode("auto");
388
- return;
389
- }
390
- return "Usage: /sidebar [open|close|auto]";
391
- },
392
- },
393
354
  {
394
355
  name: "clear",
395
356
  description: "Clear the current conversation history",
@@ -403,27 +364,6 @@ const builtinSlashCommandEntries = [
403
364
  ctx.clearMessages();
404
365
  },
405
366
  },
406
- {
407
- name: "copy",
408
- description: "Copy the last assistant message to the system clipboard",
409
- async handler(args, ctx) {
410
- const lastAssistant = [...ctx.agent.messages]
411
- .reverse()
412
- .find((m) => m.role === "assistant" && typeof m.content === "string" && m.content.trim().length > 0);
413
- if (!lastAssistant || typeof lastAssistant.content !== "string") {
414
- return "No assistant message to copy yet.";
415
- }
416
- const text = lastAssistant.content;
417
- try {
418
- await copyToClipboard(text);
419
- }
420
- catch (err) {
421
- return `Failed to copy to clipboard: ${err?.message || String(err)}`;
422
- }
423
- const chars = text.length;
424
- return `Copied last assistant message to clipboard (${chars} character${chars === 1 ? "" : "s"}).`;
425
- },
426
- },
427
367
  {
428
368
  name: "rewind",
429
369
  description: "Rewind conversation and/or file edits to before an earlier message. Usage: /rewind [n] [--code|--chat]",
@@ -667,31 +607,6 @@ const builtinSlashCommandEntries = [
667
607
  return `Model switched to ${displaySelectedModel(next, ctx.agent.thinking)}.`;
668
608
  },
669
609
  },
670
- {
671
- name: "key",
672
- description: "Set API key for the current or a specific provider. Usage: /key [provider-id] <key>",
673
- async handler(args, ctx) {
674
- if (!args) {
675
- ctx.openPicker("key");
676
- return;
677
- }
678
- const { provider, apiKey } = parseKeyArgs(args, ctx);
679
- if (!provider) {
680
- return "No provider configured. Use /provider --add <id> first.";
681
- }
682
- if (!apiKey) {
683
- return `Usage: /key ${provider.id} <key>`;
684
- }
685
- if (ctx.registry.getModelConfig().hasProvider(provider.id)) {
686
- return `API key for ${provider.name} is managed in ~/.bubble/models.json. Please edit that file directly.`;
687
- }
688
- ctx.registry.updateProviderKey(provider.id, apiKey);
689
- ctx.registry.setDefault(provider.id);
690
- ctx.agent.setProvider(ctx.createProvider(provider.id, apiKey, provider.baseURL));
691
- ctx.agent.providerId = provider.id;
692
- return `API key updated for ${provider.name} to ${maskKey(apiKey)}.`;
693
- },
694
- },
695
610
  {
696
611
  name: "logout",
697
612
  description: "Remove OAuth credentials for a provider. Usage: /logout [openai]",
@@ -732,32 +647,6 @@ const builtinSlashCommandEntries = [
732
647
  : "Exited plan mode.";
733
648
  },
734
649
  },
735
- {
736
- name: "todos",
737
- description: "Show the current todo list. Use /todos clear to reset it.",
738
- async handler(args, ctx) {
739
- const sub = args.trim();
740
- if (sub === "clear") {
741
- const previous = ctx.agent.getTodos().length;
742
- if (previous === 0) {
743
- return "Todo list is already empty.";
744
- }
745
- ctx.agent.setTodos([]);
746
- return `Cleared ${previous} todo item${previous === 1 ? "" : "s"}.`;
747
- }
748
- const todos = ctx.agent.getTodos();
749
- if (todos.length === 0) {
750
- return "No todos yet. The assistant will create some when working on multi-step tasks.";
751
- }
752
- const glyph = (status) => status === "completed" ? "✔" : status === "in_progress" ? "▶" : "○";
753
- const lines = ["Todos:"];
754
- for (const todo of todos) {
755
- const label = todo.status === "in_progress" ? (todo.activeForm || todo.content) : todo.content;
756
- lines.push(` ${glyph(todo.status)} ${label}`);
757
- }
758
- return lines.join("\n");
759
- },
760
- },
761
650
  {
762
651
  name: "permissions",
763
652
  description: "Inspect or edit allow/deny rules. Subcommands: add <scope> <list> <rule>, remove <scope> <list> <rule>, clear (session allowlist), reload.",
@@ -978,7 +867,46 @@ const builtinSlashCommandEntries = [
978
867
  if (preHook?.decision === "deny") {
979
868
  return preHook.reason ?? `Compaction blocked by hook ${preHook.sourceHookId ?? "<unknown>"}.`;
980
869
  }
981
- const result = ctx.sessionManager.compact();
870
+ // Plan first so we can report "already compact" without spending a model
871
+ // call, and so the LLM summarizer gets the exact set of evicted messages.
872
+ const plan = ctx.sessionManager.getCompactionPlan();
873
+ if (!plan) {
874
+ await ctx.hookController?.runEvent({
875
+ eventName: "PostCompact",
876
+ cwd: ctx.cwd,
877
+ sessionId: ctx.sessionManager.getSessionFile(),
878
+ agentRole: "driver",
879
+ target: "manual",
880
+ payload: { kind: "manual", compacted: false },
881
+ });
882
+ return "Session is already compact enough.";
883
+ }
884
+ // Stream an LLM summary for high fidelity, reporting progress to the TUI.
885
+ // On any failure (or empty output) fall back to the instant heuristic
886
+ // compaction so /compact always makes progress.
887
+ let result;
888
+ try {
889
+ ctx.compactionProgress?.({ phase: "collecting", streamedChars: 0 });
890
+ let summary = "";
891
+ try {
892
+ summary = await ctx.agent.summarizeForCompaction(plan.oldMessages, (full) => {
893
+ ctx.compactionProgress?.({ phase: "summarizing", streamedChars: full.length });
894
+ });
895
+ }
896
+ catch {
897
+ summary = "";
898
+ }
899
+ if (summary) {
900
+ ctx.compactionProgress?.({ phase: "applying", streamedChars: summary.length });
901
+ result = ctx.sessionManager.applyLLMCompaction(summary);
902
+ }
903
+ else {
904
+ result = ctx.sessionManager.compact();
905
+ }
906
+ }
907
+ finally {
908
+ ctx.compactionProgress?.(null);
909
+ }
982
910
  if (!result.compacted) {
983
911
  await ctx.hookController?.runEvent({
984
912
  eventName: "PostCompact",
@@ -10,11 +10,15 @@ import type { LspService } from "../lsp/index.js";
10
10
  import type { MemoryScope } from "../memory/index.js";
11
11
  import type { ThemeMode } from "../config.js";
12
12
  import type { ExternalHookController } from "../hooks/controller.js";
13
- export type SidebarMode = "auto" | "expanded" | "collapsed";
14
- export interface SidebarCommandState {
15
- mode: SidebarMode;
16
- visible: boolean;
17
- active: boolean;
13
+ /**
14
+ * Live progress for a manual `/compact` run, pushed to the TUI so it can render
15
+ * a progress bar. `phase` advances collecting → summarizing → applying;
16
+ * `streamedChars` is the running length of the streamed summary (drives the
17
+ * bar's fill within the summarizing phase). Hosts without a UI omit the sink.
18
+ */
19
+ export interface CompactionProgress {
20
+ phase: "collecting" | "summarizing" | "applying";
21
+ streamedChars: number;
18
22
  }
19
23
  export interface SlashCommandContext {
20
24
  agent: Agent;
@@ -42,10 +46,6 @@ export interface SlashCommandContext {
42
46
  getResolvedTheme?: () => "light" | "dark";
43
47
  /** Persist a new theme mode AND apply it to the running TUI. */
44
48
  setThemeMode?: (mode: ThemeMode) => void;
45
- /** Toggle the right session sidebar in the running TUI. */
46
- toggleSidebar?: () => SidebarCommandState;
47
- /** Set the right session sidebar mode in the running TUI. */
48
- setSidebarMode?: (mode: SidebarMode) => SidebarCommandState;
49
49
  /** Open the feedback dialog. `initialDescription` prefills the description field. */
50
50
  openFeedback?: (initialDescription: string) => void;
51
51
  /** Open the interactive rewind picker. When absent, /rewind falls back to a text listing. */
@@ -56,6 +56,11 @@ export interface SlashCommandContext {
56
56
  fillComposer?: (text: string) => void;
57
57
  /** Open the interactive usage stats panel. */
58
58
  openStats?: () => void;
59
+ /**
60
+ * Push live compaction progress to the running TUI. Pass a progress object
61
+ * while compacting and `null` to clear the indicator. Absent in non-TUI hosts.
62
+ */
63
+ compactionProgress?: (progress: CompactionProgress | null) => void;
59
64
  }
60
65
  /**
61
66
  * Return types for a slash command handler:
@@ -28,5 +28,11 @@ export declare function createListAgentsTool(): ToolRegistryEntry;
28
28
  export declare const AGENT_TEAM_MIN_ITEMS = 2;
29
29
  export declare const AGENT_TEAM_MAX_ITEMS = 32;
30
30
  export declare function createAgentTeamTool(options?: AgentLifecycleToolOptions, sharedTrust?: ProjectProfileTrust): ToolRegistryEntry;
31
+ /** Specs bound for one agent_batch call (design v2 §1.3). */
32
+ export declare const AGENT_BATCH_MIN_SPECS = 2;
33
+ export declare const AGENT_BATCH_MAX_SPECS = 32;
34
+ export declare function createAgentBatchTool(options?: AgentLifecycleToolOptions, sharedTrust?: ProjectProfileTrust): ToolRegistryEntry;
35
+ export declare function createRunWorkflowTool(options?: AgentLifecycleToolOptions): ToolRegistryEntry;
36
+ export declare function createWaitWorkflowTool(): ToolRegistryEntry;
31
37
  export declare function createAgentLifecycleTools(options?: AgentLifecycleToolOptions): ToolRegistryEntry[];
32
38
  export {};