@bubblebrain-ai/bubble 0.0.1 → 0.0.3

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.
package/dist/agent.d.ts CHANGED
@@ -4,6 +4,9 @@
4
4
  */
5
5
  import type { AgentEvent, ContentPart, PermissionMode, Message, Provider, ThinkingLevel, Todo, ToolResult, ToolRegistryEntry } from "./types.js";
6
6
  import { type TurnHooks } from "./orchestrator/hooks.js";
7
+ export declare class AgentAbortError extends Error {
8
+ constructor(message?: string);
9
+ }
7
10
  export interface AgentOptions {
8
11
  provider: Provider;
9
12
  sessionID?: string;
@@ -57,6 +60,7 @@ export declare class Agent {
57
60
  /** Whether a given tool is deferred and not yet unlocked. */
58
61
  isDeferredAndLocked(name: string): boolean;
59
62
  injectSystemReminder(content: string): void;
63
+ injectModeReminder(): void;
60
64
  get model(): string;
61
65
  set model(value: string);
62
66
  get providerId(): string;
@@ -67,6 +71,7 @@ export declare class Agent {
67
71
  model?: string;
68
72
  temperature?: number;
69
73
  thinkingLevel?: ThinkingLevel;
74
+ abortSignal?: AbortSignal;
70
75
  }): Promise<string>;
71
76
  get thinking(): ThinkingLevel;
72
77
  set thinking(value: ThinkingLevel);
@@ -82,7 +87,9 @@ export declare class Agent {
82
87
  /** Internal: snapshot counter that bumps on every setTodos. Used by run loop to detect mutations. */
83
88
  get todosVersion(): number;
84
89
  setSystemPrompt(prompt: string): void;
85
- run(userInput: string | ContentPart[], cwd: string): AsyncIterable<AgentEvent>;
90
+ run(userInput: string | ContentPart[], cwd: string, options?: {
91
+ abortSignal?: AbortSignal;
92
+ }): AsyncIterable<AgentEvent>;
86
93
  private recoverFromOverflow;
87
94
  compactResidentHistory(): void;
88
95
  runSubtask(input: string | ContentPart[], cwd: string, options?: {
package/dist/agent.js CHANGED
@@ -8,7 +8,7 @@ import { getContextBudget } from "./context/budget.js";
8
8
  import { isContextOverflowError } from "./context/overflow.js";
9
9
  import { projectMessages } from "./context/projector.js";
10
10
  import { aggressivePruneMessages } from "./context/prune.js";
11
- import { buildDeferredToolsReminder, buildToolFreezeReminder, reminderForMode } from "./prompt/reminders.js";
11
+ import { buildDeferredToolsReminder, buildToolFreezeReminder, isPermissionModeReminder, reminderForMode } from "./prompt/reminders.js";
12
12
  import { HookBus } from "./orchestrator/hooks.js";
13
13
  import { createDefaultHooks } from "./orchestrator/default-hooks.js";
14
14
  import { filterToolsForSubtask, getSubtaskPolicy } from "./agent/subtask-policy.js";
@@ -19,6 +19,12 @@ const RESIDENT_HISTORY_CHAR_SOFT_LIMIT = 256 * 1024;
19
19
  const RESIDENT_HISTORY_CHAR_HARD_LIMIT = 512 * 1024;
20
20
  const RESIDENT_HISTORY_HEAP_SOFT_LIMIT = 512 * 1024 * 1024;
21
21
  const RESIDENT_HISTORY_HEAP_HARD_LIMIT = 768 * 1024 * 1024;
22
+ export class AgentAbortError extends Error {
23
+ constructor(message = "Agent run cancelled.") {
24
+ super(message);
25
+ this.name = "AgentAbortError";
26
+ }
27
+ }
22
28
  export class Agent {
23
29
  messages = [];
24
30
  provider;
@@ -67,7 +73,7 @@ export class Agent {
67
73
  // If the agent boots in a non-default mode, inject the corresponding reminder so the
68
74
  // model sees the active rules on its very first turn. Default mode needs no reminder.
69
75
  if (this._mode !== "default") {
70
- this.injectSystemReminder(reminderForMode(this._mode));
76
+ this.injectModeReminder();
71
77
  }
72
78
  // Advertise any deferred tools so the model knows they exist and how to
73
79
  // reach them. Keeps the per-turn tool list small; schemas load on demand.
@@ -97,6 +103,12 @@ export class Agent {
97
103
  injectSystemReminder(content) {
98
104
  this.appendMessage({ role: "user", content, isMeta: true });
99
105
  }
106
+ injectModeReminder() {
107
+ this.messages = this.messages.filter((message) => !(message.role === "user"
108
+ && message.isMeta
109
+ && isPermissionModeReminder(message.content)));
110
+ this.injectSystemReminder(reminderForMode(this._mode));
111
+ }
100
112
  get model() {
101
113
  return this._model;
102
114
  }
@@ -123,6 +135,7 @@ export class Agent {
123
135
  model: options?.model ?? this.apiModel,
124
136
  temperature: options?.temperature ?? this.temperature,
125
137
  thinkingLevel: options?.thinkingLevel ?? this.thinkingLevel,
138
+ abortSignal: options?.abortSignal,
126
139
  });
127
140
  }
128
141
  get thinking() {
@@ -148,7 +161,7 @@ export class Agent {
148
161
  return;
149
162
  this._mode = value;
150
163
  this._modeVersion += 1;
151
- this.injectSystemReminder(reminderForMode(value));
164
+ this.injectModeReminder();
152
165
  this.onModeUpdate?.(value);
153
166
  }
154
167
  /** Internal: snapshot counter that bumps on every mode change. Used by run loop. */
@@ -175,7 +188,9 @@ export class Agent {
175
188
  }
176
189
  this.messages.unshift(systemMessage);
177
190
  }
178
- async *run(userInput, cwd) {
191
+ async *run(userInput, cwd, options = {}) {
192
+ const abortSignal = options.abortSignal;
193
+ throwIfAborted(abortSignal);
179
194
  const hookBus = new HookBus();
180
195
  for (const hooks of createDefaultHooks()) {
181
196
  hookBus.register(hooks);
@@ -210,6 +225,7 @@ export class Agent {
210
225
  let consecutiveOverflowRecoveries = 0;
211
226
  let step = 0;
212
227
  while (true) {
228
+ throwIfAborted(abortSignal);
213
229
  flushGovernorReminders();
214
230
  yield { type: "turn_start" };
215
231
  step += 1;
@@ -253,6 +269,9 @@ export class Agent {
253
269
  };
254
270
  await hookBus.runBeforeModelCall(beforeModelCallCtx);
255
271
  toolEntries = beforeModelCallCtx.toolEntries;
272
+ if (this._mode !== "plan") {
273
+ toolEntries = toolEntries.filter((t) => t.name !== "exit_plan_mode");
274
+ }
256
275
  flushGovernorReminders();
257
276
  const toolDefinitions = ((hookState.forceTextOnlyReason ? [] : toolEntries))
258
277
  .map((t) => ({
@@ -273,8 +292,10 @@ export class Agent {
273
292
  tools: toolDefinitions,
274
293
  temperature: this.temperature,
275
294
  thinkingLevel: this.thinkingLevel,
295
+ abortSignal,
276
296
  });
277
297
  for await (const chunk of stream) {
298
+ throwIfAborted(abortSignal);
278
299
  switch (chunk.type) {
279
300
  case "text":
280
301
  assistantMsg.content += chunk.content;
@@ -346,6 +367,7 @@ export class Agent {
346
367
  }
347
368
  const executedResults = [];
348
369
  for (let index = 0; index < parsedCalls.length; index++) {
370
+ throwIfAborted(abortSignal);
349
371
  let tc = parsedCalls[index];
350
372
  let blockedResult;
351
373
  await hookBus.runBeforeToolCall({
@@ -373,7 +395,8 @@ export class Agent {
373
395
  yield { type: "tool_start", id: tc.id, name: tc.name, args: tc.parsedArgs };
374
396
  const todosVersionBefore = this._todosVersion;
375
397
  const modeVersionBefore = this._modeVersion;
376
- let result = blockedResult ?? await this.executeTool(tc, cwd);
398
+ let result = blockedResult ?? await this.executeTool(tc, cwd, abortSignal);
399
+ throwIfAborted(abortSignal);
377
400
  await hookBus.runAfterToolCall({
378
401
  agent: this,
379
402
  cwd,
@@ -581,7 +604,14 @@ export class Agent {
581
604
  this.messages.push(message);
582
605
  this.onMessageAppend?.(message);
583
606
  }
584
- async executeTool(toolCall, cwd) {
607
+ async executeTool(toolCall, cwd, abortSignal) {
608
+ throwIfAborted(abortSignal);
609
+ if (toolCall.name === "exit_plan_mode" && this._mode !== "plan") {
610
+ return {
611
+ content: "Ignored exit_plan_mode because plan mode is not active. " +
612
+ "Continue with the user's request directly using the regular tools.",
613
+ };
614
+ }
585
615
  const tool = this.tools.get(toolCall.name);
586
616
  if (!tool) {
587
617
  return {
@@ -608,6 +638,7 @@ export class Agent {
608
638
  return await tool.execute(toolCall.parsedArgs, {
609
639
  cwd,
610
640
  sessionID: this.sessionID,
641
+ abortSignal,
611
642
  toolCall: { id: toolCall.id, name: toolCall.name },
612
643
  agent: this,
613
644
  });
@@ -651,6 +682,14 @@ function estimateResidentChars(messages) {
651
682
  }
652
683
  return total;
653
684
  }
685
+ function throwIfAborted(signal) {
686
+ if (!signal?.aborted)
687
+ return;
688
+ const reason = signal.reason;
689
+ if (reason instanceof Error)
690
+ throw reason;
691
+ throw new AgentAbortError(typeof reason === "string" ? reason : undefined);
692
+ }
654
693
  function estimateToolPayloadChars(messages) {
655
694
  return messages.reduce((sum, message) => {
656
695
  if (message.role !== "tool") {
@@ -28,12 +28,12 @@ export interface ApprovalControllerOptions {
28
28
  * Default ApprovalController. Decision tree:
29
29
  *
30
30
  * deny rule match → reject (applies even under bypassPermissions)
31
- * bypassPermissions / dontAsk → auto-approve, no prompt
32
- * acceptEdits + edit|write → auto-approve
31
+ * bypassPermissions → auto-approve, no prompt
32
+ * default + edit|write → auto-approve
33
33
  * plan → reject with instructions to use exit_plan_mode
34
34
  * allow rule match → auto-approve
35
35
  * bash in session allowlist → auto-approve
36
- * default / other → delegate to UI; if no UI, reject
36
+ * bash / other → delegate to UI; if no UI, reject
37
37
  *
38
38
  * Deny rules sit at the top as a hard ceiling: bypassPermissions is a trust
39
39
  * escalation, not a policy override. Users who want to permit a currently-
@@ -3,12 +3,12 @@ import { checkPermission } from "../permissions/rule.js";
3
3
  * Default ApprovalController. Decision tree:
4
4
  *
5
5
  * deny rule match → reject (applies even under bypassPermissions)
6
- * bypassPermissions / dontAsk → auto-approve, no prompt
7
- * acceptEdits + edit|write → auto-approve
6
+ * bypassPermissions → auto-approve, no prompt
7
+ * default + edit|write → auto-approve
8
8
  * plan → reject with instructions to use exit_plan_mode
9
9
  * allow rule match → auto-approve
10
10
  * bash in session allowlist → auto-approve
11
- * default / other → delegate to UI; if no UI, reject
11
+ * bash / other → delegate to UI; if no UI, reject
12
12
  *
13
13
  * Deny rules sit at the top as a hard ceiling: bypassPermissions is a trust
14
14
  * escalation, not a policy override. Users who want to permit a currently-
@@ -35,10 +35,10 @@ export class PermissionAwareApprovalController {
35
35
  };
36
36
  }
37
37
  const mode = this.options.getMode();
38
- if (mode === "bypassPermissions" || mode === "dontAsk") {
38
+ if (mode === "bypassPermissions") {
39
39
  return { action: "approve" };
40
40
  }
41
- if (mode === "acceptEdits" && (req.type === "edit" || req.type === "write")) {
41
+ if (mode === "default" && (req.type === "edit" || req.type === "write")) {
42
42
  return { action: "approve" };
43
43
  }
44
44
  if (mode === "plan") {
package/dist/cli.d.ts CHANGED
@@ -12,8 +12,6 @@ export interface CliArgs {
12
12
  prompt?: string;
13
13
  thinkingLevel?: ThinkingLevel;
14
14
  mode?: PermissionMode;
15
- /** When true, --dangerously-skip-permissions was passed; bypassPermissions is reachable via the mode cycle and auto-approves every tool. */
16
- bypassEnabled?: boolean;
17
15
  }
18
16
  export declare function parseArgs(argv: string[]): CliArgs;
19
17
  export declare function printHelp(): void;
package/dist/cli.js CHANGED
@@ -45,10 +45,10 @@ export function parseArgs(argv) {
45
45
  args.mode = "plan";
46
46
  break;
47
47
  case "--accept-edits":
48
- args.mode = "acceptEdits";
48
+ // Backward-compatible no-op: Build mode now includes edit/write auto-approval.
49
+ args.mode = "default";
49
50
  break;
50
51
  case "--dangerously-skip-permissions":
51
- args.bypassEnabled = true;
52
52
  args.mode = "bypassPermissions";
53
53
  break;
54
54
  default:
@@ -73,7 +73,6 @@ Options:
73
73
  --reasoning Enable reasoning mode at medium effort
74
74
  --reasoning-effort <l> Set reasoning effort: off|minimal|low|medium|high|xhigh|max
75
75
  --plan Start in plan mode (read-only investigation; propose before executing)
76
- --accept-edits Start with edits/writes auto-approved (bash still prompts)
77
76
  --dangerously-skip-permissions
78
77
  Enable bypass mode (auto-approve EVERY tool; disables all safety prompts)
79
78
  -p, --print Non-interactive mode (single prompt)
package/dist/main.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env bun
1
+ #!/usr/bin/env node
2
2
  /**
3
3
  * Main entry point - assembles all layers and runs the agent.
4
4
  */
package/dist/main.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env bun
1
+ #!/usr/bin/env node
2
2
  /**
3
3
  * Main entry point - assembles all layers and runs the agent.
4
4
  */
@@ -284,8 +284,7 @@ async function main() {
284
284
  // Reassigning agent.messages drops any <system-reminder> we injected during
285
285
  // construction. Re-inject if the agent is starting in plan mode.
286
286
  if (agent.mode === "plan") {
287
- const { PLAN_MODE_ENTER_REMINDER } = await import("./prompt/reminders.js");
288
- agent.injectSystemReminder(PLAN_MODE_ENTER_REMINDER);
287
+ agent.injectModeReminder();
289
288
  }
290
289
  console.log(chalk.dim(`Resumed session: ${sessionManager.getSessionFile()}`));
291
290
  }
@@ -326,7 +325,6 @@ async function main() {
326
325
  settingsManager,
327
326
  lspService,
328
327
  mcpManager,
329
- bypassEnabled: args.bypassEnabled,
330
328
  theme: userConfig.getTheme(),
331
329
  flushMemory,
332
330
  runMemoryCompaction,
@@ -13,11 +13,7 @@ export interface PermissionModeInfo {
13
13
  }
14
14
  export declare const PERMISSION_MODE_INFO: Record<PermissionMode, PermissionModeInfo>;
15
15
  /**
16
- * Cycle order for the interactive mode keybind. This intentionally mirrors
17
- * opencode's primary-agent switch: the keybind toggles Build and Plan only.
18
- * Permission presets like acceptEdits/bypassPermissions remain opt-in through
19
- * flags or commands and are never reached accidentally from Tab.
16
+ * Cycle order for the interactive mode keybind. The visible TUI loop keeps the
17
+ * mental model simple: Build -> Plan -> Bypass -> Build.
20
18
  */
21
- export declare function getNextPermissionMode(current: PermissionMode, _options?: {
22
- bypassEnabled?: boolean;
23
- }): PermissionMode;
19
+ export declare function getNextPermissionMode(current: PermissionMode): PermissionMode;
@@ -1,20 +1,16 @@
1
1
  export const PERMISSION_MODE_INFO = {
2
2
  default: { title: "Default", shortTitle: "default", symbol: "", color: "muted" },
3
- acceptEdits: { title: "Accept edits", shortTitle: "accept edits", symbol: "⏵⏵", color: "success" },
4
3
  plan: { title: "Plan", shortTitle: "plan", symbol: "⏸", color: "accent" },
5
4
  bypassPermissions: { title: "Bypass permissions", shortTitle: "bypass", symbol: "⏵⏵", color: "error" },
6
- dontAsk: { title: "Do not ask", shortTitle: "silent", symbol: "·", color: "warning" },
7
5
  };
8
6
  /**
9
- * Cycle order for the interactive mode keybind. This intentionally mirrors
10
- * opencode's primary-agent switch: the keybind toggles Build and Plan only.
11
- * Permission presets like acceptEdits/bypassPermissions remain opt-in through
12
- * flags or commands and are never reached accidentally from Tab.
7
+ * Cycle order for the interactive mode keybind. The visible TUI loop keeps the
8
+ * mental model simple: Build -> Plan -> Bypass -> Build.
13
9
  */
14
- export function getNextPermissionMode(current, _options = {}) {
10
+ export function getNextPermissionMode(current) {
11
+ if (current === "default")
12
+ return "plan";
15
13
  if (current === "plan")
16
- return "default";
17
- if (current === "bypassPermissions" || current === "dontAsk")
18
- return "default";
19
- return "plan";
14
+ return "bypassPermissions";
15
+ return "default";
20
16
  }
@@ -21,10 +21,8 @@ import { normalizeLspConfig } from "../lsp/config.js";
21
21
  import { parseRules } from "./rule.js";
22
22
  const KNOWN_MODES = new Set([
23
23
  "default",
24
- "acceptEdits",
25
24
  "plan",
26
25
  "bypassPermissions",
27
- "dontAsk",
28
26
  ]);
29
27
  export class SettingsManager {
30
28
  cwd;
@@ -83,8 +81,9 @@ export class SettingsManager {
83
81
  continue;
84
82
  const perms = data.permissions;
85
83
  if (typeof perms.defaultMode === "string") {
86
- if (KNOWN_MODES.has(perms.defaultMode)) {
87
- defaultMode = perms.defaultMode;
84
+ const rawMode = perms.defaultMode === "acceptEdits" ? "default" : perms.defaultMode;
85
+ if (KNOWN_MODES.has(rawMode)) {
86
+ defaultMode = rawMode;
88
87
  }
89
88
  else {
90
89
  diagnostics.push({
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import type { PermissionMode } from "../types.js";
10
10
  export declare function wrapInSystemReminder(content: string): string;
11
+ export declare function isPermissionModeReminder(content: unknown): boolean;
11
12
  /** Picks the correct reminder text for a transition TO a given mode. */
12
13
  export declare function reminderForMode(mode: PermissionMode): string;
13
14
  export declare const PLAN_MODE_ENTER_REMINDER: string;
@@ -9,6 +9,13 @@
9
9
  export function wrapInSystemReminder(content) {
10
10
  return `<system-reminder>\n${content.trim()}\n</system-reminder>`;
11
11
  }
12
+ export function isPermissionModeReminder(content) {
13
+ if (typeof content !== "string")
14
+ return false;
15
+ return content.includes("Plan mode is now ACTIVE")
16
+ || content.includes("Permission mode is now: bypassPermissions")
17
+ || content.includes("Permission mode is now: default Build mode");
18
+ }
12
19
  const PLAN_MODE_ENTER = `
13
20
  Plan mode is now ACTIVE.
14
21
 
@@ -21,12 +28,6 @@ Rules while in plan mode:
21
28
  - The user will approve, edit, or reject your plan. On approval the harness switches back to default mode and you may execute.
22
29
  - On rejection, remain in plan mode and iterate.
23
30
  `;
24
- const ACCEPT_EDITS_ENTER = `
25
- Permission mode is now: acceptEdits.
26
-
27
- The user has granted blanket approval for file edits and writes in this session.
28
- Bash commands still require explicit approval. Other tool safety rules are unchanged.
29
- `;
30
31
  const BYPASS_ENTER = `
31
32
  Permission mode is now: bypassPermissions.
32
33
 
@@ -34,25 +35,18 @@ ALL tool calls auto-approve with no user confirmation. The user has explicitly o
34
35
  Proceed with extra care — explain risky actions in the chat BEFORE performing them, and
35
36
  prefer reversible operations when possible.
36
37
  `;
37
- const DONT_ASK_ENTER = `
38
- Permission mode is now: dontAsk.
39
-
40
- All tool calls auto-approve silently. Minimise narration; execute and report results tersely.
41
- `;
42
38
  const DEFAULT_ENTER = `
43
- Permission mode is now: default. Each destructive tool call will be confirmed by the user.
39
+ Permission mode is now: default Build mode.
40
+
41
+ File edits and writes auto-approve. Bash commands and other destructive tools still require explicit approval unless allowed by rules.
44
42
  `;
45
43
  /** Picks the correct reminder text for a transition TO a given mode. */
46
44
  export function reminderForMode(mode) {
47
45
  switch (mode) {
48
46
  case "plan":
49
47
  return wrapInSystemReminder(PLAN_MODE_ENTER);
50
- case "acceptEdits":
51
- return wrapInSystemReminder(ACCEPT_EDITS_ENTER);
52
48
  case "bypassPermissions":
53
49
  return wrapInSystemReminder(BYPASS_ENTER);
54
- case "dontAsk":
55
- return wrapInSystemReminder(DONT_ASK_ENTER);
56
50
  case "default":
57
51
  default:
58
52
  return wrapInSystemReminder(DEFAULT_ENTER);
@@ -44,6 +44,7 @@ export function createOpenAICodexProvider(options) {
44
44
  const response = await fetch(resolveCodexUrl(options.baseURL), {
45
45
  method: "POST",
46
46
  headers: buildSseHeaders(options.apiKey, accountId, sessionId),
47
+ signal: chatOptions.abortSignal,
47
48
  body: JSON.stringify(buildRequestBody(messages, {
48
49
  model: chatOptions.model,
49
50
  tools: chatOptions.tools,
@@ -181,6 +182,7 @@ export function createOpenAICodexProvider(options) {
181
182
  model: chatOptions?.model ?? "gpt-5.4",
182
183
  temperature: chatOptions?.temperature,
183
184
  thinkingLevel: chatOptions?.thinkingLevel,
185
+ abortSignal: chatOptions?.abortSignal,
184
186
  })) {
185
187
  if (chunk.type === "text") {
186
188
  content += chunk.content;
package/dist/provider.js CHANGED
@@ -87,7 +87,9 @@ export function createProviderInstance(options) {
87
87
  if (requestConfig.reasoningEffort && requestConfig.reasoningEffort !== "off") {
88
88
  body.reasoning = { enabled: true };
89
89
  }
90
- const stream = (await client.chat.completions.create(body));
90
+ const stream = (await client.chat.completions.create(body, {
91
+ signal: chatOptions.abortSignal,
92
+ }));
91
93
  yield* translateOpenAIStream(stream);
92
94
  yield { type: "done" };
93
95
  }
@@ -108,7 +110,9 @@ export function createProviderInstance(options) {
108
110
  if (requestConfig.reasoningEffort && requestConfig.reasoningEffort !== "off") {
109
111
  body.reasoning = { enabled: true };
110
112
  }
111
- const response = await client.chat.completions.create(body);
113
+ const response = await client.chat.completions.create(body, {
114
+ signal: chatOptions?.abortSignal,
115
+ });
112
116
  return response.choices[0]?.message?.content ?? "";
113
117
  }
114
118
  return { streamChat, complete };
@@ -227,30 +227,9 @@ function parseKeyArgs(args, ctx) {
227
227
  const builtinSlashCommandEntries = [
228
228
  {
229
229
  name: "skills",
230
- description: "List available skills and any skill diagnostics",
230
+ description: "Open the searchable skills picker",
231
231
  async handler(args, ctx) {
232
- const skills = ctx.skillRegistry.summaries();
233
- const diagnostics = ctx.skillRegistry.getDiagnostics();
234
- const lines = [];
235
- if (skills.length === 0) {
236
- lines.push("No skills available.");
237
- }
238
- else {
239
- lines.push("Available skills:");
240
- for (const skill of skills) {
241
- const tagSuffix = skill.tags && skill.tags.length > 0 ? ` [tags: ${skill.tags.join(", ")}]` : "";
242
- lines.push(`- ${skill.name}: ${skill.description}${tagSuffix}`);
243
- }
244
- }
245
- if (diagnostics.length > 0) {
246
- lines.push("", "Skill diagnostics:");
247
- for (const diagnostic of diagnostics) {
248
- const prefix = diagnostic.level === "error" ? "ERROR" : "WARN";
249
- const target = diagnostic.skillName ?? diagnostic.filePath ?? "skills";
250
- lines.push(`- ${prefix} ${target}: ${diagnostic.message}`);
251
- }
252
- }
253
- return lines.join("\n");
232
+ ctx.openPicker("skill");
254
233
  },
255
234
  },
256
235
  {
@@ -16,7 +16,7 @@ export interface SlashCommandContext {
16
16
  exit: () => void;
17
17
  sessionManager?: SessionManager;
18
18
  createProvider: (providerId: string, apiKey: string, baseURL: string) => Provider;
19
- openPicker: (mode: "model" | "key" | "provider" | "provider-add" | "login" | "logout", providerId?: string) => void;
19
+ openPicker: (mode: "model" | "key" | "provider" | "provider-add" | "login" | "logout" | "skill", providerId?: string) => void;
20
20
  registry: ProviderRegistry;
21
21
  skillRegistry: SkillRegistry;
22
22
  bashAllowlist?: BashAllowlist;
@@ -20,7 +20,7 @@ export function createBashTool(cwd, approval) {
20
20
  },
21
21
  required: ["command"],
22
22
  },
23
- async execute(args) {
23
+ async execute(args, ctx) {
24
24
  if (!existsSync(cwd)) {
25
25
  return { content: `Error: Working directory does not exist: ${cwd}`, isError: true };
26
26
  }
@@ -52,11 +52,21 @@ export function createBashTool(cwd, approval) {
52
52
  let stdout = "";
53
53
  let stderr = "";
54
54
  let timedOut = false;
55
- const timeoutHandle = setTimeout(() => {
56
- timedOut = true;
55
+ let aborted = false;
56
+ const abortChild = () => {
57
+ if (aborted)
58
+ return;
59
+ aborted = true;
57
60
  child.kill("SIGTERM");
58
61
  setTimeout(() => child.kill("SIGKILL"), 5000);
62
+ };
63
+ const timeoutHandle = setTimeout(() => {
64
+ timedOut = true;
65
+ abortChild();
59
66
  }, timeoutSec * 1000);
67
+ if (ctx.abortSignal?.aborted)
68
+ abortChild();
69
+ ctx.abortSignal?.addEventListener("abort", abortChild, { once: true });
60
70
  child.stdout?.on("data", (data) => {
61
71
  stdout += data.toString();
62
72
  });
@@ -65,10 +75,12 @@ export function createBashTool(cwd, approval) {
65
75
  });
66
76
  child.on("error", (err) => {
67
77
  clearTimeout(timeoutHandle);
78
+ ctx.abortSignal?.removeEventListener("abort", abortChild);
68
79
  resolve({ content: `Error: ${err.message}`, isError: true });
69
80
  });
70
81
  child.on("close", (code) => {
71
82
  clearTimeout(timeoutHandle);
83
+ ctx.abortSignal?.removeEventListener("abort", abortChild);
72
84
  let output = "";
73
85
  if (stdout)
74
86
  output += `stdout:\n${stdout}\n`;
@@ -90,6 +102,21 @@ export function createBashTool(cwd, approval) {
90
102
  });
91
103
  return;
92
104
  }
105
+ if (aborted || ctx.abortSignal?.aborted) {
106
+ output += "[Command cancelled]";
107
+ resolve({
108
+ content: output.trim(),
109
+ isError: true,
110
+ status: "blocked",
111
+ metadata: {
112
+ kind: parsedSearch ? "search" : "shell",
113
+ pattern: parsedSearch?.pattern,
114
+ path: parsedSearch?.path,
115
+ reason: "cancelled",
116
+ },
117
+ });
118
+ return;
119
+ }
93
120
  if (Buffer.byteLength(output, "utf-8") > MAX_OUTPUT) {
94
121
  output = Buffer.from(output, "utf-8").subarray(0, MAX_OUTPUT).toString("utf-8");
95
122
  output += "\n[Output truncated]";
@@ -0,0 +1 @@
1
+ export declare function copyTextToClipboard(text: string): Promise<void>;
@@ -0,0 +1,53 @@
1
+ import { spawn } from "node:child_process";
2
+ export async function copyTextToClipboard(text) {
3
+ if (process.platform === "darwin") {
4
+ await writeToProcess("pbcopy", [], text);
5
+ return;
6
+ }
7
+ if (process.platform === "win32") {
8
+ await writeToProcess("powershell", [
9
+ "-NoProfile",
10
+ "-Command",
11
+ "Set-Clipboard -Value ([Console]::In.ReadToEnd())",
12
+ ], text);
13
+ return;
14
+ }
15
+ const candidates = [
16
+ ["wl-copy", []],
17
+ ["xclip", ["-selection", "clipboard"]],
18
+ ["xsel", ["--clipboard", "--input"]],
19
+ ];
20
+ let lastError;
21
+ for (const [command, args] of candidates) {
22
+ try {
23
+ await writeToProcess(command, args, text);
24
+ return;
25
+ }
26
+ catch (error) {
27
+ lastError = error;
28
+ }
29
+ }
30
+ throw lastError instanceof Error ? lastError : new Error("No clipboard command available");
31
+ }
32
+ function writeToProcess(command, args, input) {
33
+ return new Promise((resolve, reject) => {
34
+ const child = spawn(command, args, {
35
+ stdio: ["pipe", "ignore", "pipe"],
36
+ windowsHide: true,
37
+ });
38
+ let stderr = "";
39
+ child.stderr.setEncoding("utf8");
40
+ child.stderr.on("data", (chunk) => {
41
+ stderr += chunk;
42
+ });
43
+ child.on("error", reject);
44
+ child.on("close", (code) => {
45
+ if (code === 0) {
46
+ resolve();
47
+ return;
48
+ }
49
+ reject(new Error(stderr.trim() || `${command} exited with code ${code}`));
50
+ });
51
+ child.stdin.end(input);
52
+ });
53
+ }
@@ -0,0 +1,3 @@
1
+ export declare function normalizeKeyName(name?: string): string;
2
+ export declare function keyNameFromSequence(sequence?: string): string;
3
+ export declare function keyNameFromEvent(event: any): string;