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.
- package/README.md +659 -0
- package/dist/acp-client.d.ts +76 -0
- package/dist/acp-client.js +507 -0
- package/dist/context-manager.d.ts +45 -0
- package/dist/context-manager.js +405 -0
- package/dist/core.d.ts +41 -0
- package/dist/core.js +76 -0
- package/dist/event-bus.d.ts +140 -0
- package/dist/event-bus.js +79 -0
- package/dist/executor.d.ts +31 -0
- package/dist/executor.js +116 -0
- package/dist/extension-loader.d.ts +16 -0
- package/dist/extension-loader.js +164 -0
- package/dist/extensions/file-autocomplete.d.ts +2 -0
- package/dist/extensions/file-autocomplete.js +63 -0
- package/dist/extensions/shell-recall.d.ts +9 -0
- package/dist/extensions/shell-recall.js +8 -0
- package/dist/extensions/slash-commands.d.ts +2 -0
- package/dist/extensions/slash-commands.js +105 -0
- package/dist/extensions/tui-renderer.d.ts +2 -0
- package/dist/extensions/tui-renderer.js +354 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +159 -0
- package/dist/input-handler.d.ts +48 -0
- package/dist/input-handler.js +302 -0
- package/dist/output-parser.d.ts +55 -0
- package/dist/output-parser.js +166 -0
- package/dist/shell.d.ts +54 -0
- package/dist/shell.js +219 -0
- package/dist/types.d.ts +71 -0
- package/dist/types.js +1 -0
- package/dist/utils/ansi.d.ts +12 -0
- package/dist/utils/ansi.js +23 -0
- package/dist/utils/box-frame.d.ts +21 -0
- package/dist/utils/box-frame.js +60 -0
- package/dist/utils/diff-renderer.d.ts +20 -0
- package/dist/utils/diff-renderer.js +506 -0
- package/dist/utils/diff.d.ts +24 -0
- package/dist/utils/diff.js +122 -0
- package/dist/utils/file-watcher.d.ts +31 -0
- package/dist/utils/file-watcher.js +101 -0
- package/dist/utils/markdown.d.ts +39 -0
- package/dist/utils/markdown.js +248 -0
- package/dist/utils/palette.d.ts +32 -0
- package/dist/utils/palette.js +36 -0
- package/dist/utils/tool-display.d.ts +33 -0
- package/dist/utils/tool-display.js +141 -0
- package/examples/extensions/interactive-prompts.ts +161 -0
- package/examples/extensions/solarized-theme.ts +27 -0
- 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;
|
package/dist/executor.js
ADDED
|
@@ -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,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,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
|
+
}
|