@bubblebrain-ai/bubble 0.0.58 → 0.0.60

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 (177) hide show
  1. package/README.md +56 -6
  2. package/dist/agent/child-runner.js +5 -1
  3. package/dist/agent/internal-reminder-sanitizer.d.ts +1 -1
  4. package/dist/agent/internal-reminder-sanitizer.js +7 -1
  5. package/dist/agent/profiles.js +4 -4
  6. package/dist/agent/routing-catalog.d.ts +19 -1
  7. package/dist/agent/routing-catalog.js +32 -13
  8. package/dist/agent/subagent/runtime.d.ts +28 -0
  9. package/dist/agent/subagent/runtime.js +34 -30
  10. package/dist/agent/subagent-control.d.ts +7 -0
  11. package/dist/agent/subagent-control.js +2 -0
  12. package/dist/agent/subagent-lifecycle-reminder.js +2 -1
  13. package/dist/agent/subagent-wait-guidance.d.ts +2 -0
  14. package/dist/agent/subagent-wait-guidance.js +8 -0
  15. package/dist/agent/subtask-policy.js +4 -4
  16. package/dist/agent.d.ts +20 -11
  17. package/dist/agent.js +200 -117
  18. package/dist/anthropic-thinking.d.ts +4 -0
  19. package/dist/anthropic-thinking.js +11 -0
  20. package/dist/approval/controller.d.ts +20 -6
  21. package/dist/approval/controller.js +67 -8
  22. package/dist/approval/protected-paths.d.ts +1 -0
  23. package/dist/approval/protected-paths.js +35 -0
  24. package/dist/approval/session-cache.d.ts +12 -26
  25. package/dist/approval/session-cache.js +20 -50
  26. package/dist/approval/types.d.ts +27 -1
  27. package/dist/cli.d.ts +5 -0
  28. package/dist/cli.js +25 -2
  29. package/dist/config.d.ts +3 -0
  30. package/dist/config.js +7 -0
  31. package/dist/context/checkpoint.d.ts +18 -0
  32. package/dist/context/checkpoint.js +117 -0
  33. package/dist/context/compact-llm.js +7 -0
  34. package/dist/context/compact.d.ts +8 -0
  35. package/dist/context/compact.js +40 -7
  36. package/dist/context/llm-compactor.d.ts +36 -2
  37. package/dist/context/llm-compactor.js +227 -44
  38. package/dist/context/session-write-lock.d.ts +20 -0
  39. package/dist/context/session-write-lock.js +162 -0
  40. package/dist/context/tool-images.d.ts +3 -0
  41. package/dist/context/tool-images.js +35 -0
  42. package/dist/context/usage.js +22 -5
  43. package/dist/feishu/agent-host/run-driver.js +56 -9
  44. package/dist/lsp/service.d.ts +13 -1
  45. package/dist/lsp/service.js +163 -43
  46. package/dist/main.js +82 -16
  47. package/dist/mcp/config.d.ts +4 -0
  48. package/dist/mcp/config.js +17 -0
  49. package/dist/mcp/manager.d.ts +13 -1
  50. package/dist/mcp/manager.js +32 -1
  51. package/dist/mcp/transports.js +11 -0
  52. package/dist/memory/db.d.ts +1 -0
  53. package/dist/memory/db.js +20 -4
  54. package/dist/memory/phase1.js +122 -16
  55. package/dist/memory/prompts.js +3 -0
  56. package/dist/model-catalog.js +19 -3
  57. package/dist/model-config.d.ts +1 -0
  58. package/dist/model-config.js +5 -5
  59. package/dist/model-pricing.js +26 -9
  60. package/dist/network/provider-transport-log.d.ts +12 -0
  61. package/dist/network/provider-transport-log.js +32 -0
  62. package/dist/network/provider-transport.js +6 -1
  63. package/dist/network/retry.d.ts +1 -1
  64. package/dist/network/retry.js +1 -1
  65. package/dist/oauth/grok.d.ts +1 -0
  66. package/dist/oauth/grok.js +20 -16
  67. package/dist/oauth/openai-codex.d.ts +1 -0
  68. package/dist/oauth/openai-codex.js +29 -25
  69. package/dist/oauth/refresh-control.d.ts +12 -0
  70. package/dist/oauth/refresh-control.js +42 -0
  71. package/dist/oauth/storage.d.ts +55 -1
  72. package/dist/oauth/storage.js +281 -16
  73. package/dist/permissions/rule.d.ts +6 -3
  74. package/dist/permissions/rule.js +71 -12
  75. package/dist/permissions/settings.d.ts +28 -1
  76. package/dist/permissions/settings.js +93 -3
  77. package/dist/permissions/shell-command.d.ts +36 -0
  78. package/dist/permissions/shell-command.js +226 -0
  79. package/dist/permissions/trust-prompt.d.ts +10 -0
  80. package/dist/permissions/trust-prompt.js +34 -0
  81. package/dist/permissions/trust.d.ts +26 -0
  82. package/dist/permissions/trust.js +113 -0
  83. package/dist/prompt/compose.d.ts +3 -0
  84. package/dist/prompt/compose.js +24 -7
  85. package/dist/prompt/environment.js +2 -2
  86. package/dist/prompt/reminders.js +1 -1
  87. package/dist/prompt/runtime.js +1 -1
  88. package/dist/provider-anthropic.d.ts +6 -2
  89. package/dist/provider-anthropic.js +98 -18
  90. package/dist/provider-error-record.d.ts +9 -0
  91. package/dist/provider-error-record.js +42 -14
  92. package/dist/provider-grok.js +5 -1
  93. package/dist/provider-openai-codex.d.ts +8 -0
  94. package/dist/provider-openai-codex.js +107 -24
  95. package/dist/provider-registry.d.ts +45 -2
  96. package/dist/provider-registry.js +300 -88
  97. package/dist/provider-transform.js +8 -0
  98. package/dist/provider-zen-errors.d.ts +9 -0
  99. package/dist/provider-zen-errors.js +36 -0
  100. package/dist/provider.js +36 -5
  101. package/dist/sdk/index.d.ts +69 -8
  102. package/dist/sdk/index.js +252 -39
  103. package/dist/sdk/replay-event-log.d.ts +7 -0
  104. package/dist/sdk/replay-event-log.js +66 -16
  105. package/dist/session-context-fence.d.ts +16 -0
  106. package/dist/session-context-fence.js +26 -0
  107. package/dist/session-log.d.ts +11 -2
  108. package/dist/session-log.js +187 -39
  109. package/dist/session-types.d.ts +5 -1
  110. package/dist/session.d.ts +39 -7
  111. package/dist/session.js +341 -93
  112. package/dist/skills/format.d.ts +31 -1
  113. package/dist/skills/format.js +45 -24
  114. package/dist/slash-commands/commands.d.ts +3 -0
  115. package/dist/slash-commands/commands.js +29 -14
  116. package/dist/system-prompt.d.ts +3 -1
  117. package/dist/tasks/manager.d.ts +7 -0
  118. package/dist/tasks/manager.js +97 -18
  119. package/dist/tools/agent-lifecycle.js +11 -7
  120. package/dist/tools/child-tools.d.ts +1 -1
  121. package/dist/tools/child-tools.js +4 -4
  122. package/dist/tools/edit-apply.d.ts +0 -3
  123. package/dist/tools/edit-apply.js +45 -249
  124. package/dist/tools/edit.js +3 -3
  125. package/dist/tools/exit-plan-mode.d.ts +5 -0
  126. package/dist/tools/exit-plan-mode.js +3 -2
  127. package/dist/tools/file-state.d.ts +4 -0
  128. package/dist/tools/file-state.js +12 -3
  129. package/dist/tools/grep.js +20 -1
  130. package/dist/tools/index.d.ts +1 -1
  131. package/dist/tools/index.js +3 -3
  132. package/dist/tools/ls.d.ts +3 -0
  133. package/dist/tools/ls.js +85 -0
  134. package/dist/tools/path-utils.d.ts +6 -0
  135. package/dist/tools/path-utils.js +38 -1
  136. package/dist/tools/sensitive-paths.d.ts +8 -0
  137. package/dist/tools/sensitive-paths.js +85 -3
  138. package/dist/tools/skill.d.ts +2 -0
  139. package/dist/tools/skill.js +34 -2
  140. package/dist/tools/task-tools.js +11 -7
  141. package/dist/tui/app.js +22 -12
  142. package/dist/tui/components/approval-dialog.d.ts +5 -5
  143. package/dist/tui/components/approval-dialog.js +16 -51
  144. package/dist/tui/components/skills-panel.d.ts +4 -0
  145. package/dist/tui/components/skills-panel.js +15 -1
  146. package/dist/tui/components/streaming-message.js +7 -2
  147. package/dist/tui/components/tasks-pane.d.ts +38 -0
  148. package/dist/tui/components/tasks-pane.js +182 -32
  149. package/dist/tui/components/transcript.d.ts +11 -4
  150. package/dist/tui/components/transcript.js +158 -71
  151. package/dist/tui/composer-autocomplete.d.ts +17 -2
  152. package/dist/tui/composer-autocomplete.js +79 -2
  153. package/dist/tui/controller/controller.d.ts +9 -4
  154. package/dist/tui/controller/controller.js +87 -95
  155. package/dist/tui/controller/goal-runtime-controller.js +1 -1
  156. package/dist/tui/controller/session-transition.js +1 -1
  157. package/dist/tui/model/subagent-view.d.ts +11 -19
  158. package/dist/tui/model/subagent-view.js +56 -85
  159. package/dist/tui/model/task-lifecycle.d.ts +57 -0
  160. package/dist/tui/model/task-lifecycle.js +124 -0
  161. package/dist/tui/model/trace-groups.js +10 -9
  162. package/dist/tui/trace-groups.js +10 -9
  163. package/dist/types.d.ts +20 -0
  164. package/dist/variant/variant-resolver.js +2 -0
  165. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.d.ts +1 -0
  166. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.d.ts.map +1 -1
  167. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.js +6 -0
  168. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.js.map +1 -1
  169. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.d.ts +2 -0
  170. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.d.ts.map +1 -1
  171. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.js +8 -4
  172. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.js.map +1 -1
  173. package/package.json +11 -3
  174. package/dist/prompt/skills.d.ts +0 -2
  175. package/dist/prompt/skills.js +0 -4
  176. package/dist/tools/glob.d.ts +0 -5
  177. package/dist/tools/glob.js +0 -199
@@ -1,5 +1,6 @@
1
+ import { lstatSync, readlinkSync, realpathSync } from "node:fs";
1
2
  import { homedir } from "node:os";
2
- import { join, resolve } from "node:path";
3
+ import { basename, dirname, join, resolve } from "node:path";
3
4
  export function expandHomePath(value) {
4
5
  const text = String(value ?? "");
5
6
  if (text === "~")
@@ -14,3 +15,39 @@ export function resolveToolPath(cwd, value, fallback = ".") {
14
15
  const path = text === "" ? fallback : text;
15
16
  return resolve(cwd, expandHomePath(path));
16
17
  }
18
+ /**
19
+ * Where a path really leads once symlinks are followed — including links in
20
+ * its parent directories and dangling links (writing through a dangling link
21
+ * creates its target). Parts that do not exist yet are kept as written.
22
+ */
23
+ export function resolveThroughLinks(path) {
24
+ return followLinks(path, 0);
25
+ }
26
+ function followLinks(path, depth) {
27
+ let current = resolve(path);
28
+ const rest = [];
29
+ for (;;) {
30
+ try {
31
+ return join(realpathSync.native(current), ...rest);
32
+ }
33
+ catch {
34
+ // Missing (or dangling): follow a dangling link, else step up.
35
+ }
36
+ if (depth < 16) {
37
+ try {
38
+ if (lstatSync(current).isSymbolicLink()) {
39
+ const target = resolve(dirname(current), readlinkSync(current));
40
+ return followLinks(join(target, ...rest), depth + 1);
41
+ }
42
+ }
43
+ catch {
44
+ // Not there at all.
45
+ }
46
+ }
47
+ const parent = dirname(current);
48
+ if (parent === current)
49
+ return resolve(path);
50
+ rest.unshift(basename(current));
51
+ current = parent;
52
+ }
53
+ }
@@ -1,3 +1,11 @@
1
+ /**
2
+ * Credential storage the no-prompt tools (read, grep, ls, edit excerpts) must
3
+ * never open. Reading never asks for approval, and neither do web fetches, so
4
+ * without this a prompt-injected model could read a private key and send it
5
+ * out without a single prompt. Bash is not covered here beyond Bubble's own
6
+ * files: bash commands are shown to the user before they run.
7
+ */
1
8
  export declare function getSensitivePaths(): string[];
2
9
  export declare function isSensitivePath(filePath: string): boolean;
10
+ /** Bash commands may not name Bubble's own credential files. */
3
11
  export declare function referencesSensitivePath(command: string): boolean;
@@ -1,6 +1,14 @@
1
+ import { realpathSync } from "node:fs";
1
2
  import { homedir } from "node:os";
2
- import { resolve } from "node:path";
3
+ import { basename, dirname, resolve, sep } from "node:path";
3
4
  import { getBubbleHome } from "../bubble-home.js";
5
+ /**
6
+ * Credential storage the no-prompt tools (read, grep, ls, edit excerpts) must
7
+ * never open. Reading never asks for approval, and neither do web fetches, so
8
+ * without this a prompt-injected model could read a private key and send it
9
+ * out without a single prompt. Bash is not covered here beyond Bubble's own
10
+ * files: bash commands are shown to the user before they run.
11
+ */
4
12
  export function getSensitivePaths() {
5
13
  const bubbleHome = getBubbleHome();
6
14
  return [
@@ -8,10 +16,84 @@ export function getSensitivePaths() {
8
16
  resolve(bubbleHome, "auth.json"),
9
17
  ];
10
18
  }
19
+ /** Home-relative credential files. */
20
+ const HOME_CREDENTIAL_FILES = [
21
+ ".aws/credentials",
22
+ ".netrc",
23
+ ".git-credentials",
24
+ ".npmrc",
25
+ ".pypirc",
26
+ ".docker/config.json",
27
+ ".kube/config",
28
+ ".config/gh/hosts.yml",
29
+ ".cargo/credentials",
30
+ ".cargo/credentials.toml",
31
+ ".gem/credentials",
32
+ ".terraform.d/credentials.tfrc.json",
33
+ ".codex/auth.json",
34
+ ".claude/.credentials.json",
35
+ ];
36
+ /** Home-relative directories whose whole content is credential material. */
37
+ const HOME_CREDENTIAL_DIRS = [
38
+ ".gnupg",
39
+ ".password-store",
40
+ ".config/gcloud",
41
+ ".azure",
42
+ ".config/op",
43
+ ".aws/sso/cache",
44
+ ".aws/cli/cache",
45
+ "Library/Keychains",
46
+ ];
47
+ /** Non-secret files in ~/.ssh; everything else there (private keys) is blocked. */
48
+ const SSH_PUBLIC_FILES = new Set(["config", "known_hosts", "known_hosts.old", "authorized_keys"]);
11
49
  export function isSensitivePath(filePath) {
12
- const resolved = resolve(filePath);
13
- return getSensitivePaths().includes(resolved);
50
+ const candidates = new Set([resolve(filePath)]);
51
+ // A symlink inside the workspace must not smuggle a credential file out.
52
+ try {
53
+ candidates.add(realpathSync(filePath));
54
+ }
55
+ catch {
56
+ // Missing files have no link target to check.
57
+ }
58
+ return [...candidates].some(isSensitiveResolved);
59
+ }
60
+ function isSensitiveResolved(resolvedPath) {
61
+ // Case-folded: on the default macOS (and Windows) filesystem
62
+ // `~/.AWS/Credentials` opens `~/.aws/credentials`. On case-sensitive
63
+ // systems this only blocks a few more look-alike paths.
64
+ const path = resolvedPath.toLowerCase();
65
+ if (getSensitivePaths().some((sensitive) => sensitive.toLowerCase() === path))
66
+ return true;
67
+ // Compare against the home directory as written and as resolved, so a
68
+ // resolved link target still lines up when home itself sits behind a link.
69
+ return homeForms().some((home) => isSensitiveUnderHome(path, home.toLowerCase()));
70
+ }
71
+ function homeForms() {
72
+ const home = homedir();
73
+ try {
74
+ const real = realpathSync(home);
75
+ return real === home ? [home] : [home, real];
76
+ }
77
+ catch {
78
+ return [home];
79
+ }
80
+ }
81
+ function isSensitiveUnderHome(path, home) {
82
+ if (HOME_CREDENTIAL_FILES.some((file) => path === resolve(home, file.toLowerCase())))
83
+ return true;
84
+ if (HOME_CREDENTIAL_DIRS.some((dir) => isInside(path, resolve(home, dir.toLowerCase()))))
85
+ return true;
86
+ const ssh = resolve(home, ".ssh");
87
+ if (dirname(path) === ssh) {
88
+ const name = basename(path);
89
+ return !SSH_PUBLIC_FILES.has(name) && !name.endsWith(".pub");
90
+ }
91
+ return isInside(path, ssh) && path !== ssh;
92
+ }
93
+ function isInside(path, dir) {
94
+ return path === dir || path.startsWith(dir + sep);
14
95
  }
96
+ /** Bash commands may not name Bubble's own credential files. */
15
97
  export function referencesSensitivePath(command) {
16
98
  const lower = command.toLowerCase();
17
99
  if (lower.includes("~/.bubble/config.json") || lower.includes("~/.bubble/auth.json")) {
@@ -3,3 +3,5 @@ import type { SkillRegistry } from "../skills/registry.js";
3
3
  import type { ToolRegistryEntry } from "../types.js";
4
4
  export declare function formatLoadedSkill(skill: SkillRecord): string;
5
5
  export declare function createSkillTool(registry: SkillRegistry): ToolRegistryEntry;
6
+ /** Up to three known names closest to a mistyped one (substring first, then edit distance). */
7
+ export declare function closestSkillNames(name: string, names: string[]): string[];
@@ -22,7 +22,7 @@ export function createSkillTool(registry) {
22
22
  name: "skill",
23
23
  readOnly: true,
24
24
  effect: "read",
25
- description: "Load a named skill on demand. Use this when a task clearly matches one of the available skills.",
25
+ description: "Load a skill by its exact name, as listed in the Skills section of the system prompt. Returns the skill's instructions; follow them for the task.",
26
26
  parameters: {
27
27
  type: "object",
28
28
  properties: {
@@ -38,8 +38,11 @@ export function createSkillTool(registry) {
38
38
  }
39
39
  const skill = registry.get(name);
40
40
  if (!skill) {
41
+ const close = closestSkillNames(name, registry.summaries().map((s) => s.name));
41
42
  return {
42
- content: `Error: Unknown skill "${name}". Use skill_search to find available skills, then retry with the exact skill name.`,
43
+ content: `Error: Unknown skill "${name}".` +
44
+ (close.length ? ` Did you mean: ${close.join(", ")}?` : "") +
45
+ " Use the exact name from the Skills section.",
43
46
  isError: true,
44
47
  };
45
48
  }
@@ -47,3 +50,32 @@ export function createSkillTool(registry) {
47
50
  },
48
51
  };
49
52
  }
53
+ /** Up to three known names closest to a mistyped one (substring first, then edit distance). */
54
+ export function closestSkillNames(name, names) {
55
+ const target = name.toLowerCase();
56
+ const scored = names.map((candidate) => {
57
+ const lower = candidate.toLowerCase();
58
+ // Substring matches only for real fragments; a 1–2 letter typo would match everything.
59
+ const contains = target.length >= 3 && (lower.includes(target) || target.includes(lower));
60
+ return { candidate, score: contains ? 0 : editDistance(target, lower) };
61
+ });
62
+ const limit = Math.max(2, Math.floor(target.length / 3));
63
+ return scored
64
+ .filter((s) => s.score <= limit)
65
+ .sort((a, b) => a.score - b.score || a.candidate.localeCompare(b.candidate))
66
+ .slice(0, 3)
67
+ .map((s) => s.candidate);
68
+ }
69
+ function editDistance(a, b) {
70
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
71
+ for (let i = 1; i <= a.length; i++) {
72
+ let prev = row[0];
73
+ row[0] = i;
74
+ for (let j = 1; j <= b.length; j++) {
75
+ const cur = row[j];
76
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
77
+ prev = cur;
78
+ }
79
+ }
80
+ return row[b.length];
81
+ }
@@ -87,14 +87,18 @@ export function createBackgroundTaskTools(manager) {
87
87
  if (!existing) {
88
88
  return { content: `Error: unknown task id: ${id || "(empty)"}.`, isError: true };
89
89
  }
90
- if (existing.status !== "running") {
91
- return {
92
- content: `Task ${id} already finished (${existing.status}${existing.exitCode != null ? `, exit ${existing.exitCode}` : ""}). Nothing to kill.`,
93
- status: "success",
94
- metadata: { kind: "shell", background: true },
95
- };
96
- }
90
+ const alreadyFinished = (task) => ({
91
+ content: `Task ${id} already finished (${task.status}${task.exitCode != null ? `, exit ${task.exitCode}` : ""}). Nothing to kill.`,
92
+ status: "success",
93
+ metadata: { kind: "shell", background: true },
94
+ });
95
+ if (existing.status !== "running")
96
+ return alreadyFinished(existing);
97
97
  const killed = await manager.killTask(id);
98
+ // The task can exit on its own just before the kill lands; the manager
99
+ // then keeps its real outcome, so do not claim we killed it.
100
+ if (killed && killed.status !== "killed")
101
+ return alreadyFinished(killed);
98
102
  return {
99
103
  content: `Killed background task ${id}${killed?.description ? ` (${killed.description})` : ""}.`,
100
104
  status: "success",
package/dist/tui/app.js CHANGED
@@ -10,6 +10,7 @@
10
10
  * All runtime logic flows through BubbleTuiController + OverlayRequestController;
11
11
  * this file owns rendering and input routing only.
12
12
  */
13
+ import { classifyExternalRuntimeBinding } from "../external-runtime/session-policy.js";
13
14
  import process from "node:process";
14
15
  import { ProcessTerminal, TuiAltScreen, TuiMainScreen, VStack, Editor, Input, ScrollView, Markdown, matchesKey, isKeyRelease, isViewportTUI, } from "@bubblebrain-ai/pi-tui";
15
16
  import { StreamingMessageComponent } from "./components/streaming-message.js";
@@ -32,6 +33,7 @@ import { SkillsPanelComponent } from "./components/skills-panel.js";
32
33
  import { SessionPickerComponent } from "./components/session-picker.js";
33
34
  import { ComposerImagePreviewComponent, ImageViewerComponent } from "./components/image-preview.js";
34
35
  import { registry as slashRegistry } from "../slash-commands/index.js";
36
+ import { syncSystemPrompt } from "../slash-commands/commands.js";
35
37
  import { collectUsageStatsBundle } from "../stats/usage.js";
36
38
  import { ComposerController, } from "./controller/composer-controller.js";
37
39
  import { OverlayRequestController } from "./controller/overlay-controller.js";
@@ -153,6 +155,7 @@ export class PiTuiApp {
153
155
  providerId: () => this.options.agent.providerId,
154
156
  themeMode: () => this.themeMode,
155
157
  detectedTheme: () => this.detectedTheme,
158
+ grokRuntimeActive: () => classifyExternalRuntimeBinding(this.activeSessionManager().getMetadata?.().externalRuntime) === "grok",
156
159
  onModelSuggestionsChanged: () => {
157
160
  if (!this.disposed)
158
161
  this.editor.refreshAutocomplete();
@@ -206,6 +209,7 @@ export class PiTuiApp {
206
209
  groups: this.options.controller.getSubagentGroups?.() ?? [],
207
210
  workflows: this.options.controller.getWorkflows?.() ?? [],
208
211
  tasks: this.options.controller.getBackgroundTasks?.() ?? [],
212
+ turnStartedAt: this.options.controller.getTurnStartedAt?.(),
209
213
  }), () => this.tui.terminal.rows, {
210
214
  // Pane navigation only changes local presentation state. Reprojecting
211
215
  // the live trace/Markdown here is both unnecessary and expensive.
@@ -271,17 +275,10 @@ export class PiTuiApp {
271
275
  if (approvalHandlerRef) {
272
276
  approvalHandlerRef.current = async (request) => {
273
277
  const choice = await this.approvalDialog(request);
274
- if (choice.kind === "approve_bash_prefix") {
275
- if (request.type !== "bash" || !this.options.bashAllowlist) {
276
- return {
277
- action: "reject",
278
- feedback: "This host cannot remember Bash approvals for the current session.",
279
- };
280
- }
281
- // Session-scoped and command-scoped: PermissionAwareApprovalController
282
- // consults this same instance before opening the next dialog.
283
- this.options.bashAllowlist.add(choice.prefix);
284
- return { action: "approve" };
278
+ // "This session": the approval controller records the request's
279
+ // sessionGrant (exact bash command / MCP tool) before the next dialog.
280
+ if (choice.kind === "approve_session") {
281
+ return { action: "approve", remember: "session" };
285
282
  }
286
283
  return choice.kind === "approve_once"
287
284
  ? { action: "approve" }
@@ -619,6 +616,12 @@ export class PiTuiApp {
619
616
  this.composer.replaceDraft(mode === "model" ? "/model " : mode === "provider" ? "/provider " : "/theme ");
620
617
  this.editor.refreshAutocomplete();
621
618
  }
619
+ else if (mode === "login" || mode === "logout") {
620
+ // Same inline surface as /model: the account list appears as the
621
+ // command's argument menu, so nothing is chosen for the user.
622
+ this.composer.replaceDraft(mode === "login" ? "/login " : "/logout ");
623
+ this.editor.refreshAutocomplete();
624
+ }
622
625
  else if (mode === "key" && providerId) {
623
626
  this.openProviderKeyPhase(providerId);
624
627
  }
@@ -771,6 +774,10 @@ export class PiTuiApp {
771
774
  this.animationClock.setActive(false);
772
775
  this.tui.stop();
773
776
  const { FullscreenApp } = await import("./fullscreen.js");
777
+ // The import yields; a dispose (or a second /fullscreen) racing it must not
778
+ // mount an alternate screen nobody will tear down.
779
+ if (this.disposed || this.fullscreen)
780
+ return;
774
781
  this.fullscreen = new FullscreenApp({
775
782
  controller: this.options.controller,
776
783
  agent: this.options.agent,
@@ -848,8 +855,12 @@ export class PiTuiApp {
848
855
  getTerminalRows: () => this.tui.terminal.rows,
849
856
  onClose: () => handle?.hide(),
850
857
  onRender: () => this.renderSnapshot(),
858
+ getCatalogChars: () => this.options.agent.getSkillCatalogChars?.(),
851
859
  onSkillsChanged: () => {
852
860
  this.options.agent.setSkillSummaries(skillRegistry.summaries());
861
+ // The Skills catalog lives in the system prompt; rebuild it so the next turn sees the change.
862
+ const ctx = this.buildSlashContext();
863
+ syncSystemPrompt(ctx, ctx.agent.model ?? "");
853
864
  this.editor.refreshAutocomplete();
854
865
  this.renderSnapshot();
855
866
  },
@@ -1509,7 +1520,6 @@ export class PiTuiApp {
1509
1520
  approvalDialog(request) {
1510
1521
  return new Promise((resolve) => {
1511
1522
  const dialog = new ApprovalDialogComponent(request, () => this.tui.terminal.rows, {
1512
- allowBashPrefix: this.options.bashAllowlist !== undefined,
1513
1523
  getTheme: () => this.theme,
1514
1524
  });
1515
1525
  const handle = this.tui.showOverlay(dialog, {
@@ -4,8 +4,7 @@ import { type Theme } from "../model/theme.js";
4
4
  export type ApprovalDialogChoice = {
5
5
  kind: "approve_once";
6
6
  } | {
7
- kind: "approve_bash_prefix";
8
- prefix: string;
7
+ kind: "approve_session";
9
8
  } | {
10
9
  kind: "reject";
11
10
  };
@@ -14,8 +13,6 @@ interface ApprovalPresentation {
14
13
  details: string[];
15
14
  }
16
15
  export interface ApprovalDialogOptions {
17
- /** Only advertise session remembering when the host can persist the prefix. */
18
- allowBashPrefix?: boolean;
19
16
  getTheme?: () => Theme;
20
17
  }
21
18
  /** Convert every approval variant into a compact, request-specific heading and preview. */
@@ -31,10 +28,13 @@ export declare class ApprovalDialogComponent implements Component, Focusable {
31
28
  private readonly options;
32
29
  focused: boolean;
33
30
  private selectedIndex;
34
- private bashPrefix;
35
31
  onSelect?: (choice: ApprovalDialogChoice) => void;
36
32
  onCancel?: () => void;
37
33
  constructor(request: ApprovalRequest, getTerminalRows: () => number, options?: ApprovalDialogOptions);
34
+ /**
35
+ * "This session" appears only when the approval controller attached a
36
+ * sessionGrant: the exact bash command, or an MCP tool name.
37
+ */
38
38
  private choices;
39
39
  render(width: number): string[];
40
40
  handleInput(data: string): void;
@@ -1,21 +1,9 @@
1
1
  import path from "node:path";
2
2
  import chalk from "chalk";
3
- import { decodeKittyPrintable, matchesKey, truncateToWidth, wrapTextWithAnsi, } from "@bubblebrain-ai/pi-tui";
4
- import { inferBashPrefix } from "../../approval/session-cache.js";
3
+ import { matchesKey, truncateToWidth, wrapTextWithAnsi, } from "@bubblebrain-ai/pi-tui";
5
4
  import { paintSheetLine, padSheetLine, safeSheetText } from "./bottom-sheet.js";
6
5
  import { darkTheme } from "../model/theme.js";
7
6
  import { themeDim, themeForeground } from "../model/theme-style.js";
8
- function printableInput(data) {
9
- const decoded = decodeKittyPrintable(data);
10
- if (decoded !== undefined)
11
- return decoded;
12
- // ProcessTerminal has already unwrapped bracketed paste. Reject raw control
13
- // input here so an escape sequence can never become part of an allow rule.
14
- // eslint-disable-next-line no-control-regex
15
- if (!data || /[\u0000-\u001f\u007f-\u009f]/.test(data))
16
- return undefined;
17
- return data;
18
- }
19
7
  function bashCommandLabel(command) {
20
8
  const firstToken = safeSheetText(command).trim().match(/^([^\s;&|]+)/)?.[1] ?? "command";
21
9
  const unquoted = firstToken.replace(/^["'`]+|["'`]+$/g, "");
@@ -97,23 +85,29 @@ export class ApprovalDialogComponent {
97
85
  options;
98
86
  focused = false;
99
87
  selectedIndex = 0;
100
- bashPrefix;
101
88
  onSelect;
102
89
  onCancel;
103
90
  constructor(request, getTerminalRows, options = {}) {
104
91
  this.request = request;
105
92
  this.getTerminalRows = getTerminalRows;
106
93
  this.options = options;
107
- this.bashPrefix = request.type === "bash" ? inferBashPrefix(request.command) : "";
108
94
  }
95
+ /**
96
+ * "This session" appears only when the approval controller attached a
97
+ * sessionGrant: the exact bash command, or an MCP tool name.
98
+ */
109
99
  choices() {
100
+ const grant = this.request.type === "bash" || this.request.type === "external_tool"
101
+ ? this.request.sessionGrant
102
+ : undefined;
110
103
  return [
111
104
  { kind: "approve_once", label: "Yes, proceed" },
112
- ...(this.request.type === "bash" && this.options.allowBashPrefix
105
+ ...(grant
113
106
  ? [{
114
- kind: "approve_bash_prefix",
115
- label: "Yes, don't ask again for",
116
- editablePrefix: true,
107
+ kind: "approve_session",
108
+ label: this.request.type === "bash"
109
+ ? "Yes, and don't ask again for this exact command this session"
110
+ : `Yes, don't ask again this session for ${safeSheetText(grant)}`,
117
111
  }]
118
112
  : []),
119
113
  { kind: "reject", label: "No, reject" },
@@ -173,10 +167,7 @@ export class ApprovalDialogComponent {
173
167
  for (const { choice, index } of visibleChoices) {
174
168
  const selected = index === this.selectedIndex;
175
169
  const radio = selected ? "●" : "○";
176
- const prefix = choice.editablePrefix
177
- ? ` [${safeSheetText(this.bashPrefix) || "command prefix"}${selected ? "▏" : ""}]`
178
- : "";
179
- const label = `${index + 1} (${radio}) ${choice.label}${prefix}`;
170
+ const label = `${index + 1} (${radio}) ${choice.label}`;
180
171
  const line = `${indent}${selected
181
172
  ? chalk.bold(themeForeground(theme.inputText, label))
182
173
  : themeForeground(theme.muted, label)}`;
@@ -187,10 +178,7 @@ export class ApprovalDialogComponent {
187
178
  const paintedPanel = panelLines.slice(0, panelBudget).map(({ line, selected }) => paintSheetLine(line, safeWidth, selected, theme));
188
179
  if (!showHelp)
189
180
  return paintedPanel;
190
- const editing = choices[this.selectedIndex]?.editablePrefix;
191
- const help = editing
192
- ? `${this.selectedIndex + 1}/${choices.length} select │ type/backspace edit prefix │ Ctrl+U clear │ Enter confirm │ Esc deny`
193
- : `${this.selectedIndex + 1}/${choices.length} select │ Tab next │ Enter confirm │ Esc deny`;
181
+ const help = `${this.selectedIndex + 1}/${choices.length} select │ Tab next │ Enter confirm │ Esc deny`;
194
182
  return [...paintedPanel, themeDim(theme.dim, padSheetLine(help, safeWidth))];
195
183
  }
196
184
  handleInput(data) {
@@ -210,35 +198,12 @@ export class ApprovalDialogComponent {
210
198
  const selected = choices[this.selectedIndex];
211
199
  if (!selected)
212
200
  return;
213
- if (selected.editablePrefix && (matchesKey(data, "backspace") || matchesKey(data, "delete"))) {
214
- this.bashPrefix = Array.from(this.bashPrefix).slice(0, -1).join("");
215
- return;
216
- }
217
- if (selected.editablePrefix && matchesKey(data, "ctrl+u")) {
218
- this.bashPrefix = "";
219
- return;
220
- }
221
201
  if (matchesKey(data, "enter")) {
222
- if (selected.kind === "approve_bash_prefix") {
223
- const prefix = this.bashPrefix.trim();
224
- if (!prefix)
225
- return;
226
- this.onSelect?.({ kind: selected.kind, prefix });
227
- }
228
- else {
229
- this.onSelect?.({ kind: selected.kind });
230
- }
202
+ this.onSelect?.({ kind: selected.kind });
231
203
  return;
232
204
  }
233
205
  if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
234
206
  this.onCancel?.();
235
- return;
236
- }
237
- if (selected.editablePrefix) {
238
- const printable = printableInput(data);
239
- if (printable !== undefined) {
240
- this.bashPrefix += safeSheetText(printable).replace(/[\r\n\t]+/g, " ");
241
- }
242
207
  }
243
208
  }
244
209
  invalidate() {
@@ -6,6 +6,8 @@ export interface SkillsPanelComponentOptions {
6
6
  onClose(): void;
7
7
  onRender(): void;
8
8
  onSkillsChanged(): void;
9
+ /** Catalog budget in characters (the agent's setting); undefined uses the default. */
10
+ getCatalogChars?(): number | undefined;
9
11
  theme?: Theme;
10
12
  }
11
13
  /** Grok-style centered Skill browser/manager opened by /skills. */
@@ -37,6 +39,8 @@ export declare class SkillsPanelComponent implements Component, Focusable {
37
39
  handleMouse(event: TuiMouseEvent): boolean;
38
40
  invalidate(): void;
39
41
  private frameLine;
42
+ /** How much of the always-present catalog the enabled skills use, so users know when to prune. */
43
+ private catalogSummary;
40
44
  private renderSearch;
41
45
  private renderFooter;
42
46
  private buildRows;
@@ -2,6 +2,7 @@ import { homedir } from "node:os";
2
2
  import { dirname } from "node:path";
3
3
  import chalk from "chalk";
4
4
  import { Input, decodeKittyPrintable, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@bubblebrain-ai/pi-tui";
5
+ import { buildSkillCatalog } from "../../skills/format.js";
5
6
  import { parseTerminalMouseWheel } from "../model/terminal-mouse.js";
6
7
  import { darkTheme } from "../model/theme.js";
7
8
  import { themeBackground, themeDim, themeForeground } from "../model/theme-style.js";
@@ -116,7 +117,7 @@ export class SkillsPanelComponent {
116
117
  const top = themeForeground(theme.border, `┌${"─".repeat(topDashCount)}`) + ` ${close} ` + themeForeground(theme.border, "┐");
117
118
  const bottom = themeForeground(theme.border, `└${horizontal}┘`);
118
119
  const separator = themeForeground(theme.border, `├${horizontal}┤`);
119
- const tab = this.frameLine(` ${chalk.bold(themeForeground(theme.inputText, themeBackground(theme.traceHoverBg, " Skills ")))}`, innerWidth);
120
+ const tab = this.frameLine(` ${chalk.bold(themeForeground(theme.inputText, themeBackground(theme.traceHoverBg, " Skills ")))} ${themeForeground(theme.muted, this.catalogSummary())}`, innerWidth);
120
121
  const search = this.frameLine(this.renderSearch(innerWidth), innerWidth);
121
122
  const footerRows = this.searchActive ? 0 : 2;
122
123
  this.bodyRows = Math.max(1, frameHeight - 5 - footerRows);
@@ -284,6 +285,19 @@ export class SkillsPanelComponent {
284
285
  const theme = this.options.theme ?? darkTheme;
285
286
  return `${themeForeground(theme.border, "│")}${themeBackground(theme.backgroundPanel, fit(content, width))}${themeForeground(theme.border, "│")}`;
286
287
  }
288
+ /** How much of the always-present catalog the enabled skills use, so users know when to prune. */
289
+ catalogSummary() {
290
+ const catalog = buildSkillCatalog(this.registry.summaries(), { budgetChars: this.options.getCatalogChars?.() });
291
+ if (catalog.budgetChars === 0)
292
+ return "catalog off · skill_search only";
293
+ const duplicates = this.registry.getDiagnostics().filter((d) => d.message.startsWith("Duplicate skill name")).length;
294
+ return [
295
+ `catalog ${catalog.chars.toLocaleString("en-US")}/${catalog.budgetChars.toLocaleString("en-US")} chars`,
296
+ `${catalog.listed} listed`,
297
+ catalog.unlisted ? `${catalog.unlisted} not listed` : "",
298
+ duplicates ? `${duplicates} duplicate${duplicates === 1 ? "" : "s"} ignored` : "",
299
+ ].filter(Boolean).join(" · ");
300
+ }
287
301
  renderSearch(innerWidth) {
288
302
  const theme = this.options.theme ?? darkTheme;
289
303
  const filter = filterLabel(this.filter);
@@ -268,10 +268,15 @@ export class StreamingMessageComponent extends VStack {
268
268
  timelineBlocks.push({ rows, traceTargets: rows.map(() => undefined) });
269
269
  }
270
270
  const timeline = joinTranscriptProjections(timelineBlocks);
271
- this.reasoningGapRow.setLines(tail.reasoning && timeline.rows.length > 0 ? 1 : 0);
271
+ const start = Math.max(0, timeline.rows.length - this.timelineRows.length);
272
+ // The settled transcript joins reasoning and the timeline with exactly one
273
+ // spacer. A tool group already carries its own top padding, so adding the
274
+ // gap row on top of it made the live surface one row taller than the
275
+ // committed one and the trace jumped up at every turn boundary.
276
+ const timelineBringsSpacer = start === 0 && timeline.leadingSpacer === true;
277
+ this.reasoningGapRow.setLines(tail.reasoning && timeline.rows.length > 0 && !timelineBringsSpacer ? 1 : 0);
272
278
  if (timeline.rows.length > 0) {
273
279
  hasLiveRows = true;
274
- const start = Math.max(0, timeline.rows.length - this.timelineRows.length);
275
280
  const visible = timeline.rows.slice(start);
276
281
  const visibleTargets = timeline.traceTargets.slice(start);
277
282
  this.timelineEllipsisRow.setText(timeline.rows.length > visible.length
@@ -7,6 +7,8 @@ export interface TasksPaneSnapshot {
7
7
  groups: SubagentGroup[];
8
8
  workflows: WorkflowRunSnapshot[];
9
9
  tasks: BackgroundTaskInfo[];
10
+ /** Start of the current agent run; undefined keeps finished work hidden. */
11
+ turnStartedAt?: number;
10
12
  }
11
13
  export interface WorkflowPaneItem {
12
14
  kind: "workflow";
@@ -51,10 +53,25 @@ export declare class TasksPaneComponent implements Component {
51
53
  focused: boolean;
52
54
  private open;
53
55
  private manuallyClosed;
56
+ /**
57
+ * History is a view the user opts into for one look, never a mode: it is
58
+ * dropped whenever the pane closes and when a new round of activity starts.
59
+ * Otherwise one Ctrl+G after a finished turn left every earlier subagent
60
+ * listed above the composer for the rest of the session, next to a header
61
+ * that counts only the running ones.
62
+ */
54
63
  private showHistory;
55
64
  private selectedId?;
65
+ /**
66
+ * Whether the user picked the selection (keys or click). The pane always
67
+ * needs one, so it defaults to the first row — but a default nobody chose
68
+ * must not steer the window: a task that was once the only row stayed
69
+ * "selected" and scrolled later subagents out of an untouched pane.
70
+ */
71
+ private selectionIsUsers;
56
72
  private hoveredId?;
57
73
  private lastActiveCount;
74
+ private lastTurnStartedAt;
58
75
  private lastRows;
59
76
  private allRows;
60
77
  private frame;
@@ -67,11 +84,32 @@ export declare class TasksPaneComponent implements Component {
67
84
  isOpen(): boolean;
68
85
  isAvailable(): boolean;
69
86
  activeCount(): number;
87
+ /** Rows the default view lists: running work plus what this turn launched. */
88
+ defaultViewCount(): number;
89
+ visibleCount(): number;
90
+ /**
91
+ * Finished rows the pane lists next to the running ones, by outcome. Counted
92
+ * in the pane's current view, history included when it is showing, so the
93
+ * header never reports fewer rows than the list holds.
94
+ */
95
+ settledCounts(): {
96
+ done: number;
97
+ failed: number;
98
+ stopped: number;
99
+ };
70
100
  totalCount(): number;
71
101
  activityGlyph(): string;
72
102
  theme(): Theme;
73
103
  toggle(forceFocus?: boolean): void;
74
104
  close(): void;
105
+ /**
106
+ * Advance open/history state from the latest snapshot. Both the status bar
107
+ * and the pane call this before rendering: the bar is laid out first, so a
108
+ * transition made only inside the pane's render would leave the bar one
109
+ * frame behind — forever, when nothing is animating to trigger a redraw.
110
+ * Idempotent per snapshot (it compares against the last values it saw).
111
+ */
112
+ syncState(): void;
75
113
  render(width: number): string[];
76
114
  private renderItem;
77
115
  handleInput(data: string): void;