agent-sh 0.1.0

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 (50) hide show
  1. package/README.md +659 -0
  2. package/dist/acp-client.d.ts +76 -0
  3. package/dist/acp-client.js +507 -0
  4. package/dist/context-manager.d.ts +45 -0
  5. package/dist/context-manager.js +405 -0
  6. package/dist/core.d.ts +41 -0
  7. package/dist/core.js +76 -0
  8. package/dist/event-bus.d.ts +140 -0
  9. package/dist/event-bus.js +79 -0
  10. package/dist/executor.d.ts +31 -0
  11. package/dist/executor.js +116 -0
  12. package/dist/extension-loader.d.ts +16 -0
  13. package/dist/extension-loader.js +164 -0
  14. package/dist/extensions/file-autocomplete.d.ts +2 -0
  15. package/dist/extensions/file-autocomplete.js +63 -0
  16. package/dist/extensions/shell-recall.d.ts +9 -0
  17. package/dist/extensions/shell-recall.js +8 -0
  18. package/dist/extensions/slash-commands.d.ts +2 -0
  19. package/dist/extensions/slash-commands.js +105 -0
  20. package/dist/extensions/tui-renderer.d.ts +2 -0
  21. package/dist/extensions/tui-renderer.js +354 -0
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.js +159 -0
  24. package/dist/input-handler.d.ts +48 -0
  25. package/dist/input-handler.js +302 -0
  26. package/dist/output-parser.d.ts +55 -0
  27. package/dist/output-parser.js +166 -0
  28. package/dist/shell.d.ts +54 -0
  29. package/dist/shell.js +219 -0
  30. package/dist/types.d.ts +71 -0
  31. package/dist/types.js +1 -0
  32. package/dist/utils/ansi.d.ts +12 -0
  33. package/dist/utils/ansi.js +23 -0
  34. package/dist/utils/box-frame.d.ts +21 -0
  35. package/dist/utils/box-frame.js +60 -0
  36. package/dist/utils/diff-renderer.d.ts +20 -0
  37. package/dist/utils/diff-renderer.js +506 -0
  38. package/dist/utils/diff.d.ts +24 -0
  39. package/dist/utils/diff.js +122 -0
  40. package/dist/utils/file-watcher.d.ts +31 -0
  41. package/dist/utils/file-watcher.js +101 -0
  42. package/dist/utils/markdown.d.ts +39 -0
  43. package/dist/utils/markdown.js +248 -0
  44. package/dist/utils/palette.d.ts +32 -0
  45. package/dist/utils/palette.js +36 -0
  46. package/dist/utils/tool-display.d.ts +33 -0
  47. package/dist/utils/tool-display.js +141 -0
  48. package/examples/extensions/interactive-prompts.ts +161 -0
  49. package/examples/extensions/solarized-theme.ts +27 -0
  50. package/package.json +72 -0
@@ -0,0 +1,79 @@
1
+ import { EventEmitter } from "node:events";
2
+ /**
3
+ * Typed event bus with two modes:
4
+ * - emit/on/off: fire-and-forget notifications
5
+ * - emitPipe/onPipe: synchronous transform chain where each listener
6
+ * can modify the payload before passing to the next
7
+ */
8
+ export class EventBus {
9
+ emitter = new EventEmitter();
10
+ pipeListeners = new Map();
11
+ asyncPipeListeners = new Map();
12
+ /** Subscribe to a fire-and-forget event. */
13
+ on(event, fn) {
14
+ this.emitter.on(event, fn);
15
+ }
16
+ /** Unsubscribe from a fire-and-forget event. */
17
+ off(event, fn) {
18
+ this.emitter.off(event, fn);
19
+ }
20
+ /** Emit a fire-and-forget event. */
21
+ emit(event, payload) {
22
+ this.emitter.emit(event, payload);
23
+ }
24
+ /** Register a transform listener for a pipeline event. */
25
+ onPipe(event, fn) {
26
+ let listeners = this.pipeListeners.get(event);
27
+ if (!listeners) {
28
+ listeners = [];
29
+ this.pipeListeners.set(event, listeners);
30
+ }
31
+ listeners.push(fn);
32
+ }
33
+ /**
34
+ * Emit a pipeline event — each registered pipe listener receives the
35
+ * output of the previous one. Returns the final transformed payload.
36
+ * If no listeners are registered, returns the original payload unchanged.
37
+ */
38
+ emitPipe(event, payload) {
39
+ const listeners = this.pipeListeners.get(event);
40
+ if (!listeners)
41
+ return payload;
42
+ let result = payload;
43
+ for (const fn of listeners) {
44
+ result = fn(result);
45
+ }
46
+ return result;
47
+ }
48
+ /** Register an async transform listener for a pipeline event. */
49
+ onPipeAsync(event, fn) {
50
+ let listeners = this.asyncPipeListeners.get(event);
51
+ if (!listeners) {
52
+ listeners = [];
53
+ this.asyncPipeListeners.set(event, listeners);
54
+ }
55
+ listeners.push(fn);
56
+ }
57
+ /**
58
+ * Emit an async pipeline event. Two phases:
59
+ * 1. Notify — fire regular `on` listeners synchronously (e.g., TUI flushes state)
60
+ * 2. Transform — run async pipe listeners in series, each receiving the
61
+ * output of the previous (e.g., extension provides a permission decision)
62
+ *
63
+ * Returns the final transformed payload. If no pipe listeners are registered,
64
+ * returns the original payload unchanged (with safe defaults).
65
+ */
66
+ async emitPipeAsync(event, payload) {
67
+ // Phase 1: notify (lets renderers prepare for interactive I/O)
68
+ this.emitter.emit(event, payload);
69
+ // Phase 2: transform (extensions provide decisions)
70
+ const listeners = this.asyncPipeListeners.get(event);
71
+ if (!listeners)
72
+ return payload;
73
+ let result = payload;
74
+ for (const fn of listeners) {
75
+ result = await fn(result);
76
+ }
77
+ return result;
78
+ }
79
+ }
@@ -0,0 +1,31 @@
1
+ import { type ChildProcess } from "node:child_process";
2
+ export interface ExecutorSession {
3
+ id: string;
4
+ command: string;
5
+ output: string;
6
+ exitCode: number | null;
7
+ done: boolean;
8
+ truncated: boolean;
9
+ process: ChildProcess | null;
10
+ resolve?: () => void;
11
+ }
12
+ /**
13
+ * Spawn a command in an isolated child process with piped I/O.
14
+ * Does NOT use the user's PTY — completely separate process.
15
+ */
16
+ export declare function executeCommand(opts: {
17
+ command: string;
18
+ cwd: string;
19
+ env?: Record<string, string>;
20
+ timeout?: number;
21
+ maxOutputBytes?: number;
22
+ onOutput?: (chunk: string) => void;
23
+ }): {
24
+ session: ExecutorSession;
25
+ done: Promise<void>;
26
+ };
27
+ /**
28
+ * Kill a running session's process group.
29
+ * Sends SIGTERM first, then SIGKILL after 5 seconds.
30
+ */
31
+ export declare function killSession(session: ExecutorSession): void;
@@ -0,0 +1,116 @@
1
+ import { spawn } from "node:child_process";
2
+ import { stripAnsi } from "./utils/ansi.js";
3
+ const DEFAULT_TIMEOUT = 60_000;
4
+ const DEFAULT_MAX_OUTPUT = 256 * 1024; // 256KB
5
+ /**
6
+ * Spawn a command in an isolated child process with piped I/O.
7
+ * Does NOT use the user's PTY — completely separate process.
8
+ */
9
+ export function executeCommand(opts) {
10
+ const timeout = opts.timeout ?? DEFAULT_TIMEOUT;
11
+ const maxOutput = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT;
12
+ const session = {
13
+ id: "",
14
+ command: opts.command,
15
+ output: "",
16
+ exitCode: null,
17
+ done: false,
18
+ truncated: false,
19
+ process: null,
20
+ };
21
+ const done = new Promise((resolve) => {
22
+ session.resolve = resolve;
23
+ });
24
+ // Build env — filter undefined values
25
+ const env = {};
26
+ const source = opts.env ?? process.env;
27
+ for (const [k, v] of Object.entries(source)) {
28
+ if (v !== undefined)
29
+ env[k] = v;
30
+ }
31
+ let child;
32
+ try {
33
+ child = spawn("/bin/bash", ["-c", opts.command], {
34
+ stdio: ["ignore", "pipe", "pipe"],
35
+ cwd: opts.cwd,
36
+ env,
37
+ detached: true,
38
+ });
39
+ }
40
+ catch (err) {
41
+ session.exitCode = -1;
42
+ session.output = `Failed to spawn: ${err instanceof Error ? err.message : String(err)}`;
43
+ session.done = true;
44
+ session.resolve?.();
45
+ return { session, done };
46
+ }
47
+ session.process = child;
48
+ const handleData = (data) => {
49
+ const raw = data.toString("utf-8");
50
+ const clean = stripAnsi(raw);
51
+ // Accumulate cleaned output for the agent
52
+ session.output += clean;
53
+ // Enforce output cap — truncate from beginning, keep tail
54
+ if (session.output.length > maxOutput) {
55
+ session.output = session.output.slice(-maxOutput);
56
+ session.truncated = true;
57
+ }
58
+ // Real-time streaming callback
59
+ opts.onOutput?.(raw);
60
+ };
61
+ child.stdout?.on("data", handleData);
62
+ child.stderr?.on("data", handleData);
63
+ // Timeout handler
64
+ const timer = setTimeout(() => {
65
+ if (!session.done) {
66
+ killSession(session);
67
+ }
68
+ }, timeout);
69
+ child.on("exit", (code, signal) => {
70
+ clearTimeout(timer);
71
+ session.exitCode = code ?? (signal ? -1 : null);
72
+ session.done = true;
73
+ session.process = null;
74
+ session.resolve?.();
75
+ });
76
+ child.on("error", (err) => {
77
+ clearTimeout(timer);
78
+ if (!session.done) {
79
+ session.exitCode = -1;
80
+ session.output += `\nProcess error: ${err.message}`;
81
+ session.done = true;
82
+ session.process = null;
83
+ session.resolve?.();
84
+ }
85
+ });
86
+ return { session, done };
87
+ }
88
+ /**
89
+ * Kill a running session's process group.
90
+ * Sends SIGTERM first, then SIGKILL after 5 seconds.
91
+ */
92
+ export function killSession(session) {
93
+ const proc = session.process;
94
+ if (!proc || !proc.pid)
95
+ return;
96
+ try {
97
+ // Kill the entire process group
98
+ process.kill(-proc.pid, "SIGTERM");
99
+ }
100
+ catch {
101
+ // Process may already be dead
102
+ }
103
+ // Fallback: SIGKILL after 5 seconds
104
+ const fallback = setTimeout(() => {
105
+ if (!session.done && proc.pid) {
106
+ try {
107
+ process.kill(-proc.pid, "SIGKILL");
108
+ }
109
+ catch {
110
+ // Ignore
111
+ }
112
+ }
113
+ }, 5000);
114
+ // Don't let the timer keep the process alive
115
+ fallback.unref();
116
+ }
@@ -0,0 +1,16 @@
1
+ import type { ExtensionContext } from "./types.js";
2
+ /**
3
+ * Load extensions from three sources (merged, deduplicated):
4
+ *
5
+ * 1. CLI flags: -e / --extensions (npm packages or file paths)
6
+ * 2. settings.json: ~/.agent-sh/settings.json → extensions[]
7
+ * 3. Extensions dir: ~/.agent-sh/extensions/ (files and directories with index.{ts,js})
8
+ *
9
+ * Extension specifiers resolve as:
10
+ * - File path (relative or absolute) → import directly
11
+ * - Bare name → npm package (Node resolution)
12
+ *
13
+ * Each module should export a default or named `activate(ctx)` function.
14
+ * Errors are non-fatal — logged via ui:error and skipped.
15
+ */
16
+ export declare function loadExtensions(ctx: ExtensionContext, cliExtensions?: string[]): Promise<void>;
@@ -0,0 +1,164 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import * as os from "node:os";
4
+ const CONFIG_DIR = path.join(os.homedir(), ".agent-sh");
5
+ const EXT_DIR = path.join(CONFIG_DIR, "extensions");
6
+ const SETTINGS_PATH = path.join(CONFIG_DIR, "settings.json");
7
+ const TS_EXTS = [".ts", ".tsx", ".mts"];
8
+ const SCRIPT_EXTS = [".js", ".mjs", ".ts", ".tsx", ".mts"];
9
+ let tsRegistered = false;
10
+ async function ensureTsSupport() {
11
+ if (tsRegistered)
12
+ return;
13
+ try {
14
+ const { register } = await import("tsx/esm/api");
15
+ register();
16
+ tsRegistered = true;
17
+ }
18
+ catch {
19
+ // tsx not available — TS extensions will fail with a clear error
20
+ }
21
+ }
22
+ async function loadSettings() {
23
+ try {
24
+ const raw = await fs.readFile(SETTINGS_PATH, "utf-8");
25
+ return JSON.parse(raw);
26
+ }
27
+ catch {
28
+ return {};
29
+ }
30
+ }
31
+ /**
32
+ * Load extensions from three sources (merged, deduplicated):
33
+ *
34
+ * 1. CLI flags: -e / --extensions (npm packages or file paths)
35
+ * 2. settings.json: ~/.agent-sh/settings.json → extensions[]
36
+ * 3. Extensions dir: ~/.agent-sh/extensions/ (files and directories with index.{ts,js})
37
+ *
38
+ * Extension specifiers resolve as:
39
+ * - File path (relative or absolute) → import directly
40
+ * - Bare name → npm package (Node resolution)
41
+ *
42
+ * Each module should export a default or named `activate(ctx)` function.
43
+ * Errors are non-fatal — logged via ui:error and skipped.
44
+ */
45
+ export async function loadExtensions(ctx, cliExtensions) {
46
+ const specifiers = [];
47
+ // 1. CLI -e / --extensions
48
+ if (cliExtensions) {
49
+ specifiers.push(...cliExtensions);
50
+ }
51
+ // 2. settings.json
52
+ const settings = await loadSettings();
53
+ if (settings.extensions) {
54
+ specifiers.push(...settings.extensions);
55
+ }
56
+ // 3. ~/.agent-sh/extensions/ directory
57
+ try {
58
+ const entries = await fs.readdir(EXT_DIR, { withFileTypes: true });
59
+ for (const entry of entries) {
60
+ const fullPath = path.join(EXT_DIR, entry.name);
61
+ if (entry.isDirectory()) {
62
+ // Directory extension: look for index.{ts,js,mjs,...}
63
+ const indexFile = await findIndex(fullPath);
64
+ if (indexFile) {
65
+ specifiers.push(indexFile);
66
+ }
67
+ }
68
+ else if (SCRIPT_EXTS.some((ext) => entry.name.endsWith(ext))) {
69
+ specifiers.push(fullPath);
70
+ }
71
+ }
72
+ }
73
+ catch {
74
+ // Directory doesn't exist — no user extensions
75
+ }
76
+ // Deduplicate
77
+ const seen = new Set();
78
+ const unique = specifiers.filter((s) => {
79
+ if (seen.has(s))
80
+ return false;
81
+ seen.add(s);
82
+ return true;
83
+ });
84
+ // Load each extension
85
+ for (const specifier of unique) {
86
+ try {
87
+ const importPath = await resolveSpecifier(specifier);
88
+ if (TS_EXTS.some((ext) => importPath.endsWith(ext))) {
89
+ await ensureTsSupport();
90
+ }
91
+ const mod = await import(importPath);
92
+ // tsx may double-wrap default exports: mod.default.default
93
+ const activate = typeof mod.default === "function"
94
+ ? mod.default
95
+ : typeof mod.default?.default === "function"
96
+ ? mod.default.default
97
+ : mod.activate;
98
+ if (typeof activate === "function") {
99
+ activate(ctx);
100
+ }
101
+ }
102
+ catch (err) {
103
+ ctx.bus.emit("ui:error", {
104
+ message: `Failed to load extension ${specifier}: ${err instanceof Error ? err.message : String(err)}`,
105
+ });
106
+ }
107
+ }
108
+ }
109
+ /**
110
+ * Find an index file in a directory extension.
111
+ */
112
+ async function findIndex(dir) {
113
+ for (const ext of SCRIPT_EXTS) {
114
+ const candidate = path.join(dir, `index${ext}`);
115
+ try {
116
+ await fs.access(candidate);
117
+ return candidate;
118
+ }
119
+ catch {
120
+ // try next
121
+ }
122
+ }
123
+ return null;
124
+ }
125
+ /**
126
+ * Resolve a specifier to an importable string.
127
+ *
128
+ * - Relative path (starts with ".") → resolve from cwd, file:// URL
129
+ * - Absolute path → file:// URL (directories resolved to index file)
130
+ * - Bare name → npm package (let Node resolve)
131
+ */
132
+ async function resolveSpecifier(specifier) {
133
+ let resolved;
134
+ if (specifier.startsWith(".")) {
135
+ resolved = path.resolve(process.cwd(), specifier);
136
+ }
137
+ else if (path.isAbsolute(specifier)) {
138
+ resolved = specifier;
139
+ }
140
+ else {
141
+ // Bare specifier — npm package
142
+ return specifier;
143
+ }
144
+ // If it's a directory, find the index file
145
+ try {
146
+ const stat = await fs.stat(resolved);
147
+ if (stat.isDirectory()) {
148
+ const indexFile = await findIndex(resolved);
149
+ if (indexFile) {
150
+ return `file://${indexFile}`;
151
+ }
152
+ throw new Error(`No index file found in ${resolved}`);
153
+ }
154
+ }
155
+ catch (err) {
156
+ if (err.code === "ENOENT") {
157
+ // Not a directory, treat as file
158
+ }
159
+ else if (err instanceof Error && err.message.startsWith("No index")) {
160
+ throw err;
161
+ }
162
+ }
163
+ return `file://${resolved}`;
164
+ }
@@ -0,0 +1,2 @@
1
+ import type { ExtensionContext } from "../types.js";
2
+ export default function activate({ bus, contextManager }: ExtensionContext): void;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * File autocomplete extension.
3
+ *
4
+ * Provides @-triggered file path completion in agent input mode.
5
+ * Responds to "autocomplete:request" pipe events by listing files
6
+ * matching the path after the @ trigger.
7
+ */
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ export default function activate({ bus, contextManager }) {
11
+ bus.onPipe("autocomplete:request", (payload) => {
12
+ const atPos = payload.buffer.lastIndexOf("@");
13
+ if (atPos < 0 || (atPos > 0 && payload.buffer[atPos - 1] !== " ")) {
14
+ return payload;
15
+ }
16
+ const afterAt = payload.buffer.slice(atPos + 1);
17
+ if (afterAt.includes(" ") || !/^[a-zA-Z0-9_.\/-]*$/.test(afterAt)) {
18
+ return payload;
19
+ }
20
+ const files = listFiles(afterAt, contextManager.getCwd());
21
+ if (files.length === 0)
22
+ return payload;
23
+ return { ...payload, items: [...payload.items, ...files] };
24
+ });
25
+ }
26
+ function listFiles(query, cwd) {
27
+ const lastSlash = query.lastIndexOf("/");
28
+ let searchDir;
29
+ let prefix;
30
+ let basePath;
31
+ if (lastSlash >= 0) {
32
+ basePath = query.slice(0, lastSlash + 1);
33
+ searchDir = path.resolve(cwd, query.slice(0, lastSlash) || ".");
34
+ prefix = query.slice(lastSlash + 1);
35
+ }
36
+ else {
37
+ basePath = "";
38
+ searchDir = cwd;
39
+ prefix = query;
40
+ }
41
+ let entries;
42
+ try {
43
+ entries = fs.readdirSync(searchDir, { withFileTypes: true });
44
+ }
45
+ catch {
46
+ return [];
47
+ }
48
+ return entries
49
+ .filter((e) => !e.name.startsWith(".") &&
50
+ e.name.toLowerCase().startsWith(prefix.toLowerCase()))
51
+ .sort((a, b) => {
52
+ if (a.isDirectory() && !b.isDirectory())
53
+ return -1;
54
+ if (!a.isDirectory() && b.isDirectory())
55
+ return 1;
56
+ return a.name.localeCompare(b.name);
57
+ })
58
+ .slice(0, 15)
59
+ .map((e) => ({
60
+ name: basePath + e.name + (e.isDirectory() ? "/" : ""),
61
+ description: e.isDirectory() ? "dir" : "",
62
+ }));
63
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Shell recall extension.
3
+ *
4
+ * Intercepts __shell_recall terminal commands via the
5
+ * "agent:terminal-intercept" pipe, returning virtual output from
6
+ * ContextManager's recall API without spawning a subprocess.
7
+ */
8
+ import type { ExtensionContext } from "../types.js";
9
+ export default function activate({ bus, contextManager }: ExtensionContext): void;
@@ -0,0 +1,8 @@
1
+ export default function activate({ bus, contextManager }) {
2
+ bus.onPipe("agent:terminal-intercept", (payload) => {
3
+ if (!payload.command.trimStart().startsWith("__shell_recall"))
4
+ return payload;
5
+ const output = contextManager.handleRecallCommand(payload.command.trim());
6
+ return { ...payload, intercepted: true, output };
7
+ });
8
+ }
@@ -0,0 +1,2 @@
1
+ import type { ExtensionContext } from "../types.js";
2
+ export default function activate({ bus, getAcpClient, quit }: ExtensionContext): void;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Slash commands extension.
3
+ *
4
+ * Registers built-in slash commands on the event bus:
5
+ * - Responds to "autocomplete:request" pipe for /-prefixed completions
6
+ * - Handles "command:execute" events and dispatches to matching handler
7
+ * - Uses "ui:info"/"ui:error" for user feedback (no direct TUI dependency)
8
+ */
9
+ import { execSync } from "node:child_process";
10
+ import { palette as p } from "../utils/palette.js";
11
+ export default function activate({ bus, getAcpClient, quit }) {
12
+ const commands = [
13
+ {
14
+ name: "/help",
15
+ description: "Show available commands",
16
+ handler: () => {
17
+ const lines = commands.map((c) => ` ${p.accent}${c.name.padEnd(12)}${p.reset} ${c.description}`);
18
+ bus.emit("ui:info", { message: "Available commands:\n" + lines.join("\n") });
19
+ },
20
+ },
21
+ {
22
+ name: "/clear",
23
+ description: "Start a new agent session",
24
+ handler: async () => {
25
+ try {
26
+ await getAcpClient().resetSession();
27
+ bus.emit("ui:info", { message: "Session cleared." });
28
+ }
29
+ catch (err) {
30
+ bus.emit("ui:error", {
31
+ message: `Failed to reset session: ${err instanceof Error ? err.message : String(err)}`,
32
+ });
33
+ }
34
+ },
35
+ },
36
+ {
37
+ name: "/copy",
38
+ description: "Copy last agent response to clipboard",
39
+ handler: () => {
40
+ const text = getAcpClient().getLastResponseText();
41
+ if (!text) {
42
+ bus.emit("ui:info", { message: "No agent response to copy." });
43
+ return;
44
+ }
45
+ try {
46
+ if (process.platform === "darwin") {
47
+ execSync("pbcopy", { input: text });
48
+ }
49
+ else {
50
+ execSync("xclip -selection clipboard", { input: text });
51
+ }
52
+ bus.emit("ui:info", { message: "Copied to clipboard." });
53
+ }
54
+ catch {
55
+ bus.emit("ui:error", { message: "Failed to copy to clipboard." });
56
+ }
57
+ },
58
+ },
59
+ {
60
+ name: "/compact",
61
+ description: "Ask agent to summarize the conversation",
62
+ handler: async () => {
63
+ await getAcpClient().sendPrompt("Please provide a concise summary of our conversation so far and the current state of the work.");
64
+ },
65
+ },
66
+ {
67
+ name: "/quit",
68
+ description: "Exit agent-sh",
69
+ handler: () => {
70
+ quit();
71
+ },
72
+ },
73
+ ];
74
+ // Provide command completions for /-prefixed input
75
+ bus.onPipe("autocomplete:request", (payload) => {
76
+ if (!payload.buffer.startsWith("/"))
77
+ return payload;
78
+ const prefix = payload.buffer.toLowerCase();
79
+ const matching = commands
80
+ .filter((c) => c.name.toLowerCase().startsWith(prefix))
81
+ .map((c) => ({ name: c.name, description: c.description }));
82
+ if (matching.length === 0)
83
+ return payload;
84
+ return { ...payload, items: [...payload.items, ...matching] };
85
+ });
86
+ // Handle command execution
87
+ bus.on("command:execute", (e) => {
88
+ const cmd = commands.find((c) => c.name === e.name);
89
+ if (cmd) {
90
+ const result = cmd.handler(e.args);
91
+ if (result instanceof Promise) {
92
+ result.catch((err) => {
93
+ bus.emit("ui:error", {
94
+ message: err instanceof Error ? err.message : String(err),
95
+ });
96
+ });
97
+ }
98
+ }
99
+ else {
100
+ bus.emit("ui:info", {
101
+ message: `Unknown command: ${e.name}. Type /help for available commands.`,
102
+ });
103
+ }
104
+ });
105
+ }
@@ -0,0 +1,2 @@
1
+ import type { ExtensionContext } from "../types.js";
2
+ export default function activate({ bus }: ExtensionContext): void;