@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,16 +1,20 @@
1
1
  import { checkPermission } from "../permissions/rule.js";
2
2
  import { truncateHookText } from "../hooks/index.js";
3
+ import { bashSessionGrant } from "./session-cache.js";
4
+ import { isProtectedWorkspacePath } from "./protected-paths.js";
3
5
  /**
4
6
  * Default ApprovalController. Decision tree:
5
7
  *
6
8
  * deny rule match → reject (applies even under bypassPermissions)
7
9
  * bypassPermissions → auto-approve, no prompt
8
10
  * default + edit|write → auto-approve (workspace paths only; a
9
- * path outside the workspace falls through
10
- * to allow rules / the interactive prompt)
11
+ * path outside the workspace, or a protected
12
+ * one — .git, agent permission settings —
13
+ * falls through to the interactive prompt)
11
14
  * plan → reject with instructions to use exit_plan_mode
12
- * allow rule match → auto-approve
13
- * bash in session allowlist → auto-approve
15
+ * allow rule match → auto-approve (never for protected paths)
16
+ * session grant (bash prefix,
17
+ * MCP tool) → auto-approve
14
18
  * bash / other → delegate to UI; if no UI, reject
15
19
  *
16
20
  * Deny rules sit at the top as a hard ceiling: bypassPermissions is a trust
@@ -28,7 +32,8 @@ export class PermissionAwareApprovalController {
28
32
  return { decision: "ask" };
29
33
  return checkPermission(ruleSet, query);
30
34
  }
31
- async request(req) {
35
+ async request(original) {
36
+ const req = this.annotate(original);
32
37
  const ruleResult = this.checkRequestRules(req);
33
38
  const finalize = async (decision) => {
34
39
  await this.runPermissionResultHook(req, decision);
@@ -49,7 +54,9 @@ export class PermissionAwareApprovalController {
49
54
  return finalize({ action: "approve" });
50
55
  }
51
56
  const outsideWorkspace = (req.type === "edit" || req.type === "write") && req.outsideWorkspace === true;
52
- if (mode === "default" && !outsideWorkspace && (req.type === "edit" || req.type === "write" || req.type === "patch")) {
57
+ const protectedPath = (req.type === "edit" || req.type === "write" || req.type === "patch") && req.protectedPath === true;
58
+ if (mode === "default" && !outsideWorkspace && !protectedPath
59
+ && (req.type === "edit" || req.type === "write" || req.type === "patch")) {
53
60
  return finalize({ action: "approve" });
54
61
  }
55
62
  // Project profile trust is a user decision, not a destructive action:
@@ -60,13 +67,19 @@ export class PermissionAwareApprovalController {
60
67
  feedback: "Plan mode is active. Do not call destructive tools directly — propose your changes via exit_plan_mode and wait for user approval.",
61
68
  });
62
69
  }
63
- if (ruleResult.decision === "allow") {
70
+ // A broad allow rule (`Edit`, `Write(**)`) must not silently cover a
71
+ // protected path; the user confirms those one at a time.
72
+ if (ruleResult.decision === "allow" && !protectedPath) {
64
73
  return finalize({ action: "approve" });
65
74
  }
66
75
  // Session-scoped allowlist: previously-approved bash prefixes skip the prompt.
67
76
  if (req.type === "bash" && this.options.bashAllowlist?.matches(req.command)) {
68
77
  return finalize({ action: "approve" });
69
78
  }
79
+ const grantKey = sessionGrantKey(req);
80
+ if (grantKey && this.options.sessionGrants?.has(grantKey)) {
81
+ return finalize({ action: "approve" });
82
+ }
70
83
  const handler = this.options.handlerRef.current;
71
84
  if (!handler) {
72
85
  return finalize({
@@ -74,7 +87,48 @@ export class PermissionAwareApprovalController {
74
87
  feedback: "No interactive UI is available to approve this tool call.",
75
88
  });
76
89
  }
77
- return finalize(await handler(req));
90
+ const decision = await handler(req);
91
+ if (decision.action === "approve" && decision.remember === "session") {
92
+ this.rememberForSession(req);
93
+ }
94
+ return finalize(decision);
95
+ }
96
+ /**
97
+ * Facts the host needs to render the prompt correctly: whether a path is
98
+ * protected (never auto-approve it host-side either) and what scope a
99
+ * "this session" approval would cover.
100
+ */
101
+ annotate(req) {
102
+ const cwd = this.options.cwd;
103
+ switch (req.type) {
104
+ case "edit":
105
+ case "write":
106
+ return isProtectedWorkspacePath(cwd, req.path) ? { ...req, protectedPath: true } : req;
107
+ case "patch": {
108
+ const paths = [...req.paths, ...req.files.map((file) => file.path)];
109
+ return paths.some((path) => isProtectedWorkspacePath(cwd, path)) ? { ...req, protectedPath: true } : req;
110
+ }
111
+ case "bash": {
112
+ const grant = this.options.bashAllowlist ? bashSessionGrant(req.command) : undefined;
113
+ return grant ? { ...req, sessionGrant: grant } : req;
114
+ }
115
+ case "external_tool":
116
+ return req.kind === "mcp" && this.options.sessionGrants && req.title.trim()
117
+ ? { ...req, sessionGrant: req.title.trim() }
118
+ : req;
119
+ default:
120
+ return req;
121
+ }
122
+ }
123
+ rememberForSession(req) {
124
+ if (req.type === "bash") {
125
+ if (req.sessionGrant)
126
+ this.options.bashAllowlist?.add(req.sessionGrant);
127
+ return;
128
+ }
129
+ const key = sessionGrantKey(req);
130
+ if (key)
131
+ this.options.sessionGrants?.add(key);
78
132
  }
79
133
  requestToQuery(req) {
80
134
  switch (req.type) {
@@ -219,6 +273,11 @@ function summarizeApprovalRequest(req) {
219
273
  };
220
274
  }
221
275
  }
276
+ function sessionGrantKey(req) {
277
+ if (req.type === "external_tool" && req.sessionGrant)
278
+ return `${req.kind}:${req.sessionGrant}`;
279
+ return undefined;
280
+ }
222
281
  function externalCommand(rawInput) {
223
282
  if (typeof rawInput !== "object" || rawInput === null || Array.isArray(rawInput))
224
283
  return undefined;
@@ -0,0 +1 @@
1
+ export declare function isProtectedWorkspacePath(cwd: string, filePath: string): boolean;
@@ -0,0 +1,35 @@
1
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
2
+ import { resolveThroughLinks } from "../tools/path-utils.js";
3
+ /**
4
+ * Workspace files whose content grants capabilities beyond the edit itself.
5
+ * Default mode auto-approves ordinary workspace edits; these always ask,
6
+ * because writing them is a privilege escalation rather than a code change:
7
+ *
8
+ * - anything inside a `.git` directory — hooks and `core.hooksPath` /
9
+ * `core.fsmonitor` in `.git/config` run arbitrary commands the next time
10
+ * anyone runs git;
11
+ * - agent permission settings (`.bubble/settings*.json`, and Claude Code's
12
+ * `.claude/settings*.json`) — an allow rule written there approves every
13
+ * later command without a prompt.
14
+ */
15
+ const PROTECTED_SETTINGS = [
16
+ { dir: ".bubble", files: new Set(["settings.json", "settings.local.json"]) },
17
+ { dir: ".claude", files: new Set(["settings.json", "settings.local.json"]) },
18
+ ];
19
+ export function isProtectedWorkspacePath(cwd, filePath) {
20
+ const absolute = isAbsolute(filePath) ? resolve(filePath) : resolve(cwd, filePath);
21
+ // Also where the path really leads: `docs/hooks -> ../.git/hooks` must not
22
+ // turn a hook into an ordinary auto-approved edit.
23
+ return isProtected(cwd, absolute) || isProtected(resolveThroughLinks(cwd), resolveThroughLinks(absolute));
24
+ }
25
+ function isProtected(cwd, absolute) {
26
+ // Case-folded: on the default macOS (and Windows) filesystem `.GIT/config`
27
+ // is `.git/config`. On case-sensitive systems this only asks more often.
28
+ const rel = relative(resolve(cwd), absolute).toLowerCase();
29
+ const parts = rel.split(sep).filter(Boolean);
30
+ if (parts.includes(".git"))
31
+ return true;
32
+ const parent = basename(dirname(absolute)).toLowerCase();
33
+ const name = basename(absolute).toLowerCase();
34
+ return PROTECTED_SETTINGS.some((entry) => entry.dir === parent && entry.files.has(name));
35
+ }
@@ -1,35 +1,21 @@
1
1
  /**
2
- * Session-scoped allowlist of bash command prefixes. Not persisted to disk.
2
+ * Session-scoped set of bash commands the user approved "for this session".
3
+ * Not persisted to disk.
3
4
  *
4
- * Matching rule: a command is allowed if it starts with one of the stored
5
- * prefixes and the next character is either end-of-string or whitespace.
6
- * This means the prefix "git status" will match "git status" and
7
- * "git status -s", but NOT "git statuss" or "git statusbad".
8
- *
9
- * For ergonomic parity with Claude Code we also strip a trailing ":*" when
10
- * storing so that users may type "npm run:*" as a pattern — functionally
11
- * equivalent to just "npm run" under our simple prefix rule.
5
+ * Exact match only (after trimming), like Kimi Code's session approvals: a
6
+ * remembered command never vouches for a different one, so no shell parsing
7
+ * is involved and a compound line cannot smuggle an extra command past it.
8
+ * Broader, prefix-style grants belong in allow rules (`Bash(npm test:*)`),
9
+ * which are matched per simple command.
12
10
  */
13
11
  export declare class BashAllowlist {
14
- private prefixes;
15
- add(prefix: string): void;
16
- remove(prefix: string): boolean;
12
+ private commands;
13
+ add(command: string): void;
14
+ remove(command: string): boolean;
17
15
  clear(): void;
18
16
  matches(command: string): boolean;
19
17
  list(): string[];
20
18
  size(): number;
21
19
  }
22
- /**
23
- * Infers a reasonable "don't ask again" prefix from a bash command. Uses the
24
- * first two whitespace-separated tokens when the second token looks like a
25
- * subcommand (no leading `-` or `/`), otherwise falls back to the first token.
26
- * Examples:
27
- * "git status -s" → "git status"
28
- * "git status" → "git status"
29
- * "git" → "git"
30
- * "npm run test" → "npm run"
31
- * "npm test" → "npm test"
32
- * "rm -rf /tmp/x" → "rm"
33
- * "./scripts/foo.sh" → "./scripts/foo.sh"
34
- */
35
- export declare function inferBashPrefix(command: string): string;
20
+ /** What a "this session" approval of `command` remembers: the command itself. */
21
+ export declare function bashSessionGrant(command: string): string | undefined;
@@ -1,68 +1,38 @@
1
1
  /**
2
- * Session-scoped allowlist of bash command prefixes. Not persisted to disk.
2
+ * Session-scoped set of bash commands the user approved "for this session".
3
+ * Not persisted to disk.
3
4
  *
4
- * Matching rule: a command is allowed if it starts with one of the stored
5
- * prefixes and the next character is either end-of-string or whitespace.
6
- * This means the prefix "git status" will match "git status" and
7
- * "git status -s", but NOT "git statuss" or "git statusbad".
8
- *
9
- * For ergonomic parity with Claude Code we also strip a trailing ":*" when
10
- * storing so that users may type "npm run:*" as a pattern — functionally
11
- * equivalent to just "npm run" under our simple prefix rule.
5
+ * Exact match only (after trimming), like Kimi Code's session approvals: a
6
+ * remembered command never vouches for a different one, so no shell parsing
7
+ * is involved and a compound line cannot smuggle an extra command past it.
8
+ * Broader, prefix-style grants belong in allow rules (`Bash(npm test:*)`),
9
+ * which are matched per simple command.
12
10
  */
13
11
  export class BashAllowlist {
14
- prefixes = new Set();
15
- add(prefix) {
16
- const cleaned = prefix.trim().replace(/:\*$/, "").trim();
12
+ commands = new Set();
13
+ add(command) {
14
+ const cleaned = command.trim();
17
15
  if (!cleaned)
18
16
  return;
19
- this.prefixes.add(cleaned);
17
+ this.commands.add(cleaned);
20
18
  }
21
- remove(prefix) {
22
- return this.prefixes.delete(prefix.trim());
19
+ remove(command) {
20
+ return this.commands.delete(command.trim());
23
21
  }
24
22
  clear() {
25
- this.prefixes.clear();
23
+ this.commands.clear();
26
24
  }
27
25
  matches(command) {
28
- const trimmed = command.trim();
29
- for (const prefix of this.prefixes) {
30
- if (trimmed === prefix)
31
- return true;
32
- if (trimmed.startsWith(prefix) && /\s/.test(trimmed.charAt(prefix.length))) {
33
- return true;
34
- }
35
- }
36
- return false;
26
+ return this.commands.has(command.trim());
37
27
  }
38
28
  list() {
39
- return [...this.prefixes].sort();
29
+ return [...this.commands].sort();
40
30
  }
41
31
  size() {
42
- return this.prefixes.size;
32
+ return this.commands.size;
43
33
  }
44
34
  }
45
- /**
46
- * Infers a reasonable "don't ask again" prefix from a bash command. Uses the
47
- * first two whitespace-separated tokens when the second token looks like a
48
- * subcommand (no leading `-` or `/`), otherwise falls back to the first token.
49
- * Examples:
50
- * "git status -s" → "git status"
51
- * "git status" → "git status"
52
- * "git" → "git"
53
- * "npm run test" → "npm run"
54
- * "npm test" → "npm test"
55
- * "rm -rf /tmp/x" → "rm"
56
- * "./scripts/foo.sh" → "./scripts/foo.sh"
57
- */
58
- export function inferBashPrefix(command) {
59
- const tokens = command.trim().split(/\s+/);
60
- if (tokens.length === 0 || !tokens[0])
61
- return "";
62
- const first = tokens[0];
63
- const second = tokens[1];
64
- if (second && /^[A-Za-z_]/.test(second)) {
65
- return `${first} ${second}`;
66
- }
67
- return first;
35
+ /** What a "this session" approval of `command` remembers: the command itself. */
36
+ export function bashSessionGrant(command) {
37
+ return command.trim() || undefined;
68
38
  }
@@ -18,6 +18,12 @@ export interface EditApprovalRequest {
18
18
  * (bypassPermissions still auto-approves, consistent with bash).
19
19
  */
20
20
  outsideWorkspace?: boolean;
21
+ /**
22
+ * Set by the approval controller: the path is a workspace file that can
23
+ * grant further capabilities (permission settings, git hooks/config), so
24
+ * it is never auto-approved in default mode. Hosts must not auto-approve it.
25
+ */
26
+ protectedPath?: boolean;
21
27
  }
22
28
  export interface WriteApprovalRequest {
23
29
  type: "write";
@@ -29,6 +35,8 @@ export interface WriteApprovalRequest {
29
35
  fileExists: boolean;
30
36
  /** See EditApprovalRequest.outsideWorkspace. */
31
37
  outsideWorkspace?: boolean;
38
+ /** See EditApprovalRequest.protectedPath. */
39
+ protectedPath?: boolean;
32
40
  }
33
41
  export interface PatchApprovalRequest {
34
42
  type: "patch";
@@ -42,6 +50,8 @@ export interface PatchApprovalRequest {
42
50
  }>;
43
51
  /** Combined unified diff for all file changes. */
44
52
  diff: string;
53
+ /** See EditApprovalRequest.protectedPath. */
54
+ protectedPath?: boolean;
45
55
  }
46
56
  export interface BashApprovalRequest {
47
57
  type: "bash";
@@ -54,6 +64,12 @@ export interface BashApprovalRequest {
54
64
  * session allowlist, and bypass approve it like foreground by design.
55
65
  */
56
66
  background?: boolean;
67
+ /**
68
+ * Set by the approval controller when this request may be approved for the
69
+ * rest of the session: the exact scope the grant would cover (a bash
70
+ * command prefix, an MCP tool name). Absent = hosts offer only "once".
71
+ */
72
+ sessionGrant?: string;
57
73
  }
58
74
  export interface LspApprovalRequest {
59
75
  type: "lsp";
@@ -77,7 +93,9 @@ export interface AgentProfileApprovalRequest {
77
93
  promptPreview: string;
78
94
  }
79
95
  /**
80
- * Permission gate for a tool exposed by an external ACP runtime.
96
+ * Permission gate for a tool Bubble does not implement itself: a tool
97
+ * exposed by an external ACP runtime (kind = ACP tool category) or an MCP
98
+ * server tool (kind = "mcp", title = the namespaced mcp__server__tool name).
81
99
  *
82
100
  * Keep this structural rather than importing ACP SDK types so the approval
83
101
  * layer remains provider-agnostic. External runtimes are responsible for
@@ -95,11 +113,19 @@ export interface ExternalToolApprovalRequest {
95
113
  path: string;
96
114
  line?: number | null;
97
115
  }>;
116
+ /** See BashApprovalRequest.sessionGrant. */
117
+ sessionGrant?: string;
98
118
  }
99
119
  export type ApprovalRequest = EditApprovalRequest | WriteApprovalRequest | PatchApprovalRequest | BashApprovalRequest | LspApprovalRequest | AgentProfileApprovalRequest | ExternalToolApprovalRequest;
100
120
  export type ApprovalDecision = {
101
121
  action: "approve";
102
122
  feedback?: string;
123
+ /**
124
+ * "session": also approve future requests covered by the request's
125
+ * `sessionGrant` for the rest of the session. Ignored when the request
126
+ * carried no sessionGrant.
127
+ */
128
+ remember?: "session";
103
129
  } | {
104
130
  action: "reject";
105
131
  feedback?: string;
package/dist/cli.d.ts CHANGED
@@ -30,3 +30,8 @@ export interface CliArgs {
30
30
  }
31
31
  export declare function parseArgs(argv: string[]): CliArgs;
32
32
  export declare function printHelp(): void;
33
+ /**
34
+ * Make `cwd` the process working directory, so --cwd behaves exactly like
35
+ * launching Bubble there. Returns an error message instead of throwing.
36
+ */
37
+ export declare function enterWorkingDirectory(cwd: string): string | undefined;
package/dist/cli.js CHANGED
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * CLI argument parsing.
3
3
  */
4
+ import { statSync } from "node:fs";
5
+ import { resolve } from "node:path";
4
6
  import { THINKING_LEVELS } from "./types.js";
5
7
  import { isThinkingLevel } from "./variant/thinking-level.js";
6
8
  export function parseArgs(argv) {
@@ -27,9 +29,13 @@ export function parseArgs(argv) {
27
29
  case "-m":
28
30
  args.model = argv[++i];
29
31
  break;
30
- case "--cwd":
31
- args.cwd = argv[++i];
32
+ case "--cwd": {
33
+ // Absolute, so it survives the chdir in main() and matches session metadata.
34
+ const dir = argv[++i];
35
+ if (dir)
36
+ args.cwd = resolve(dir);
32
37
  break;
38
+ }
33
39
  case "--api-key":
34
40
  case "-k":
35
41
  args.apiKey = argv[++i];
@@ -150,3 +156,20 @@ Options (serve --feishu):
150
156
  --dry-run Connect once, then exit (smoke test)
151
157
  `);
152
158
  }
159
+ /**
160
+ * Make `cwd` the process working directory, so --cwd behaves exactly like
161
+ * launching Bubble there. Returns an error message instead of throwing.
162
+ */
163
+ export function enterWorkingDirectory(cwd) {
164
+ if (cwd === process.cwd())
165
+ return undefined;
166
+ try {
167
+ if (!statSync(cwd).isDirectory())
168
+ return `--cwd ${cwd} is not a directory.`;
169
+ process.chdir(cwd);
170
+ return undefined;
171
+ }
172
+ catch {
173
+ return `--cwd ${cwd} does not exist or cannot be opened.`;
174
+ }
175
+ }
package/dist/config.d.ts CHANGED
@@ -20,6 +20,7 @@ export interface UserConfigData {
20
20
  /** Skill names disabled from invocation/search until re-enabled in /skills. */
21
21
  skills?: {
22
22
  disabled?: string[];
23
+ catalogChars?: number;
23
24
  };
24
25
  /**
25
26
  * Three shapes are accepted on disk so we can evolve without breaking
@@ -76,6 +77,8 @@ export declare class UserConfig {
76
77
  setDefaultProvider(id: string): void;
77
78
  getSkillPaths(): string[];
78
79
  setSkillPaths(paths: string[]): void;
80
+ /** Skill catalog budget in characters; undefined uses the default, 0 turns the catalog off. */
81
+ getSkillCatalogChars(): number | undefined;
79
82
  getDisabledSkills(): string[];
80
83
  setDisabledSkills(names: string[]): void;
81
84
  getTheme(): ThemeConfig;
package/dist/config.js CHANGED
@@ -202,6 +202,13 @@ export class UserConfig {
202
202
  this.data.skillPaths = paths.slice();
203
203
  this.save();
204
204
  }
205
+ /** Skill catalog budget in characters; undefined uses the default, 0 turns the catalog off. */
206
+ getSkillCatalogChars() {
207
+ const value = this.data.skills?.catalogChars;
208
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
209
+ ? Math.min(Math.floor(value), 200_000)
210
+ : undefined;
211
+ }
205
212
  getDisabledSkills() {
206
213
  const disabled = this.data.skills?.disabled;
207
214
  if (!Array.isArray(disabled))
@@ -0,0 +1,18 @@
1
+ import type { Message } from "../types.js";
2
+ /** A versioned, exact conversational projection. Never includes the host system prompt. */
3
+ export interface ContextCheckpoint {
4
+ version: 1;
5
+ compactionId: string;
6
+ reason: "auto" | "manual" | "overflow" | "resident";
7
+ baseRevision?: string;
8
+ summary?: string;
9
+ messages: Message[];
10
+ }
11
+ export declare function createContextCheckpoint(messages: Message[], reason: ContextCheckpoint["reason"], summary?: string, baseRevision?: string): ContextCheckpoint;
12
+ /** Live meta reminders may interleave results, but every canonical call needs a real result. */
13
+ export declare function hasCompleteToolGroups(messages: Message[]): boolean;
14
+ export declare function checkpointMessages(checkpoint: ContextCheckpoint): Message[];
15
+ /** Replay-side read: a checkpoint this build cannot validate (a newer format,
16
+ * a hand-edited record) is not a boundary. Originals are retained, so callers
17
+ * fall back to an earlier boundary instead of failing the whole session. */
18
+ export declare function tryCheckpointMessages(checkpoint: ContextCheckpoint): Message[] | undefined;
@@ -0,0 +1,117 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { isClaudeWithBoundThinking } from "../anthropic-thinking.js";
3
+ import { isInternalBlockOnlyContent, sanitizeInternalReminderBlocks, sanitizeInternalReasoningText, sanitizeAssistantProviderMetadata } from "../agent/internal-reminder-sanitizer.js";
4
+ import { isCompactionSummaryMessage, splitLeadingContext } from "./compact.js";
5
+ export function createContextCheckpoint(messages, reason, summary, baseRevision) {
6
+ const { body } = splitLeadingContext(messages);
7
+ // Runtime reminders must be recomputed by the host. Compaction summaries are
8
+ // first-class context, not ephemeral reminders, and must survive verbatim.
9
+ const durable = body.filter(message => {
10
+ if (isCompactionSummaryMessage(message))
11
+ return true;
12
+ if (message.role === "meta")
13
+ return false;
14
+ // Backstop for older histories containing provider-projected reminders.
15
+ // Never remove user prose merely because it mentions or quotes a block.
16
+ return !(message.role === "user" && typeof message.content === "string"
17
+ && isInternalBlockOnlyContent(message.content)
18
+ && !sanitizeInternalReminderBlocks(message.content).trim());
19
+ });
20
+ const sanitized = durable.map(sanitizeCheckpointMessage);
21
+ return { version: 1, compactionId: randomUUID(), reason, baseRevision,
22
+ summary: summary === undefined ? undefined : sanitizeInternalReminderBlocks(summary),
23
+ messages: structuredClone(sanitized) };
24
+ }
25
+ /** Live meta reminders may interleave results, but every canonical call needs a real result. */
26
+ export function hasCompleteToolGroups(messages) {
27
+ const pending = new Set();
28
+ for (const message of messages) {
29
+ if (message.role === "meta")
30
+ continue;
31
+ if (message.role === "tool") {
32
+ if (!pending.delete(message.toolCallId))
33
+ return false;
34
+ }
35
+ else {
36
+ if (pending.size)
37
+ return false;
38
+ if (message.role === "assistant") {
39
+ for (const call of message.toolCalls ?? []) {
40
+ // Ids only need to be unambiguous within their own group: providers
41
+ // may scope them per response (`call_1` in every reply).
42
+ if (!call.id || pending.has(call.id))
43
+ return false;
44
+ pending.add(call.id);
45
+ }
46
+ }
47
+ }
48
+ }
49
+ return pending.size === 0;
50
+ }
51
+ export function checkpointMessages(checkpoint) {
52
+ if (checkpoint?.version !== 1 || typeof checkpoint.compactionId !== "string" || !checkpoint.compactionId
53
+ || !Array.isArray(checkpoint.messages))
54
+ throw new Error("Unsupported context checkpoint");
55
+ const pending = new Set();
56
+ for (const message of checkpoint.messages) {
57
+ if (!message || !["user", "assistant", "tool", "system", "meta"].includes(message.role)
58
+ || !(typeof message.content === "string" || (message.role === "user" && Array.isArray(message.content)))) {
59
+ throw new Error("Invalid context checkpoint message");
60
+ }
61
+ if (message.role === "assistant") {
62
+ if (pending.size)
63
+ throw new Error("Incomplete checkpoint tool group");
64
+ for (const call of message.toolCalls ?? []) {
65
+ if (!call.id || pending.has(call.id))
66
+ throw new Error("Invalid checkpoint tool call id");
67
+ pending.add(call.id);
68
+ }
69
+ }
70
+ else if (message.role === "tool") {
71
+ if (!pending.delete(message.toolCallId))
72
+ throw new Error("Orphan checkpoint tool result");
73
+ }
74
+ else if (pending.size)
75
+ throw new Error("Interrupted checkpoint tool group");
76
+ }
77
+ if (pending.size)
78
+ throw new Error("Incomplete checkpoint tool group");
79
+ // Also repairs checkpoints written by builds that retained now-invalid
80
+ // signatures. Messages appended AFTER this boundary are replayed normally.
81
+ return structuredClone(checkpoint.messages.map(sanitizeCheckpointMessage));
82
+ }
83
+ function sanitizeCheckpointMessage(message) {
84
+ if (message.role !== "assistant")
85
+ return message;
86
+ let metadata = message.providerMetadata;
87
+ const blocks = metadata?.anthropic?.contentBlocks;
88
+ if (blocks && isClaudeWithBoundThinking(message.modelId ?? message.model)) {
89
+ // A checkpoint rewrites the prefix. Remove the entire bound thinking run,
90
+ // including encrypted blocks, so the model can reason from the new context.
91
+ // Keep text, tool calls/results, and display reasoning; never mutate originals.
92
+ metadata = {
93
+ ...metadata,
94
+ anthropic: {
95
+ ...metadata?.anthropic,
96
+ contentBlocks: blocks.filter(block => block.type !== "thinking" && block.type !== "redacted_thinking"),
97
+ },
98
+ };
99
+ }
100
+ return {
101
+ ...message,
102
+ content: sanitizeInternalReminderBlocks(message.content),
103
+ ...(message.reasoning !== undefined ? { reasoning: sanitizeInternalReasoningText(message.reasoning) } : {}),
104
+ ...(metadata ? { providerMetadata: sanitizeAssistantProviderMetadata(metadata) } : {}),
105
+ };
106
+ }
107
+ /** Replay-side read: a checkpoint this build cannot validate (a newer format,
108
+ * a hand-edited record) is not a boundary. Originals are retained, so callers
109
+ * fall back to an earlier boundary instead of failing the whole session. */
110
+ export function tryCheckpointMessages(checkpoint) {
111
+ try {
112
+ return checkpointMessages(checkpoint);
113
+ }
114
+ catch {
115
+ return undefined;
116
+ }
117
+ }
@@ -132,6 +132,13 @@ async function generateSummary(oldMessages, options) {
132
132
  export function serializeTranscript(messages) {
133
133
  const lines = [];
134
134
  for (const message of messages) {
135
+ // Manual /compact receives raw history, unlike the automatic path which
136
+ // first folds summary carriers into user messages. Keep those summaries,
137
+ // but do not turn ordinary system/meta reminders into durable history.
138
+ if (isCompactionSummaryMessage(message)) {
139
+ lines.push(`[Prior compaction summary] ${messageText(message)}`);
140
+ continue;
141
+ }
135
142
  switch (message.role) {
136
143
  case "user":
137
144
  lines.push(`[user] ${contentToText(message.content)}`);
@@ -50,6 +50,14 @@ export interface CompactResult {
50
50
  summary?: string;
51
51
  entries?: SessionLogEntry[];
52
52
  messages?: Message[];
53
+ /** Original messages the new summary replaces (prior summary carriers
54
+ * plus the evicted body) — the exact set an external summarizer should
55
+ * condense. Kept-verbatim messages are excluded so they are not duplicated
56
+ * into the summary. */
57
+ evictedMessages?: Message[];
58
+ /** Index in `messages` of the one summary that stands for `evictedMessages`.
59
+ * Other summaries in `messages` cover different history and must survive. */
60
+ summaryIndex?: number;
53
61
  droppedEntries?: number;
54
62
  }
55
63
  /**