agent-sh 0.5.0 → 0.7.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 +12 -43
- package/dist/agent/agent-loop.d.ts +1 -0
- package/dist/agent/agent-loop.js +119 -26
- package/dist/agent/subagent.js +3 -1
- package/dist/agent/system-prompt.d.ts +1 -1
- package/dist/agent/system-prompt.js +21 -16
- package/dist/agent/tools/bash.js +10 -1
- package/dist/agent/tools/display.d.ts +13 -0
- package/dist/agent/tools/display.js +70 -0
- package/dist/agent/tools/edit-file.js +60 -7
- package/dist/agent/tools/glob.js +39 -7
- package/dist/agent/tools/grep.js +111 -20
- package/dist/agent/tools/ls.js +31 -2
- package/dist/agent/tools/read-file.d.ts +9 -1
- package/dist/agent/tools/read-file.js +50 -4
- package/dist/agent/tools/user-shell.js +40 -13
- package/dist/agent/tools/write-file.js +9 -1
- package/dist/agent/types.d.ts +35 -1
- package/dist/context-manager.d.ts +3 -1
- package/dist/context-manager.js +11 -1
- package/dist/core.d.ts +1 -3
- package/dist/core.js +23 -12
- package/dist/event-bus.d.ts +41 -3
- package/dist/extension-loader.d.ts +1 -1
- package/dist/extension-loader.js +1 -3
- package/dist/extensions/overlay-agent.d.ts +11 -0
- package/dist/extensions/overlay-agent.js +43 -0
- package/dist/extensions/terminal-buffer.d.ts +14 -0
- package/dist/extensions/terminal-buffer.js +120 -0
- package/dist/extensions/tui-renderer.js +344 -83
- package/dist/index.js +45 -36
- package/dist/input-handler.js +10 -3
- package/dist/output-parser.js +8 -0
- package/dist/settings.js +1 -1
- package/dist/shell.d.ts +5 -0
- package/dist/shell.js +29 -4
- package/dist/types.d.ts +13 -0
- package/dist/utils/diff.js +10 -0
- package/dist/utils/floating-panel.d.ts +198 -0
- package/dist/utils/floating-panel.js +590 -0
- package/dist/utils/markdown.d.ts +1 -0
- package/dist/utils/markdown.js +23 -1
- package/dist/utils/output-writer.d.ts +14 -0
- package/dist/utils/output-writer.js +16 -0
- package/dist/utils/terminal-buffer.d.ts +65 -0
- package/dist/utils/terminal-buffer.js +166 -0
- package/dist/utils/tool-display.d.ts +4 -0
- package/dist/utils/tool-display.js +22 -5
- package/examples/extensions/claude-code-bridge/index.ts +8 -12
- package/examples/extensions/overlay-agent.ts +70 -0
- package/examples/extensions/pi-bridge/index.ts +10 -12
- package/examples/extensions/secret-guard.ts +100 -0
- package/examples/extensions/terminal-buffer.ts +184 -0
- package/package.json +5 -1
|
@@ -5,9 +5,25 @@
|
|
|
5
5
|
* process.stdout.write directly. This enables testing (BufferWriter),
|
|
6
6
|
* alternative frontends, and a single point of control for output.
|
|
7
7
|
*/
|
|
8
|
+
/** Simple ref-counted counter. Increment/decrement never goes below zero. */
|
|
9
|
+
export class RefCounter {
|
|
10
|
+
count = 0;
|
|
11
|
+
increment() { this.count++; }
|
|
12
|
+
decrement() { this.count = Math.max(0, this.count - 1); }
|
|
13
|
+
reset() { this.count = 0; }
|
|
14
|
+
get active() { return this.count > 0; }
|
|
15
|
+
get value() { return this.count; }
|
|
16
|
+
}
|
|
8
17
|
/** Default writer that forwards to process.stdout. */
|
|
9
18
|
export class StdoutWriter {
|
|
19
|
+
/** When > 0, all writes are silently dropped. Ref-counted. */
|
|
20
|
+
_hold = new RefCounter();
|
|
21
|
+
hold() { this._hold.increment(); }
|
|
22
|
+
release() { this._hold.decrement(); }
|
|
23
|
+
get held() { return this._hold.active; }
|
|
10
24
|
write(text) {
|
|
25
|
+
if (this._hold.active)
|
|
26
|
+
return;
|
|
11
27
|
if (process.stdout.writable) {
|
|
12
28
|
try {
|
|
13
29
|
process.stdout.write(text);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { EventBus } from "../event-bus.js";
|
|
2
|
+
/** Check if @xterm/headless is installed without loading it. */
|
|
3
|
+
export declare function isXtermAvailable(): boolean;
|
|
4
|
+
export interface TerminalBufferConfig {
|
|
5
|
+
/** Terminal width in columns. Default: process.stdout.columns || 80. */
|
|
6
|
+
cols?: number;
|
|
7
|
+
/** Terminal height in rows. Default: process.stdout.rows || 24. */
|
|
8
|
+
rows?: number;
|
|
9
|
+
/** Scrollback buffer size. Default: 200. */
|
|
10
|
+
scrollback?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface ScreenSnapshot {
|
|
13
|
+
/** Clean text with ANSI sequences stripped. */
|
|
14
|
+
text: string;
|
|
15
|
+
/** Whether the alternate screen buffer is active (vim, htop, etc.). */
|
|
16
|
+
altScreen: boolean;
|
|
17
|
+
/** Cursor position. */
|
|
18
|
+
cursorX: number;
|
|
19
|
+
cursorY: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Format a screen snapshot as an XML context block for agent injection.
|
|
23
|
+
* Trims, caps to `maxLines` (from the bottom), and wraps in `<terminal_buffer>`.
|
|
24
|
+
* Returns the combined context string (baseContext + section), or just
|
|
25
|
+
* baseContext if the screen is empty.
|
|
26
|
+
*/
|
|
27
|
+
export declare function formatScreenContext(screen: ScreenSnapshot, maxLines?: number, baseContext?: string): string;
|
|
28
|
+
export declare class TerminalBuffer {
|
|
29
|
+
private readonly term;
|
|
30
|
+
private readonly serializeAddon;
|
|
31
|
+
private constructor();
|
|
32
|
+
/**
|
|
33
|
+
* Create a new TerminalBuffer. Returns null if xterm is not installed.
|
|
34
|
+
*/
|
|
35
|
+
static create(config?: TerminalBufferConfig): TerminalBuffer | null;
|
|
36
|
+
/**
|
|
37
|
+
* Create a TerminalBuffer and wire it to a bus's shell:pty-data event.
|
|
38
|
+
* Returns null if xterm is not installed.
|
|
39
|
+
*/
|
|
40
|
+
static createWired(bus: EventBus, config?: TerminalBufferConfig): TerminalBuffer | null;
|
|
41
|
+
/** Write raw data into the virtual terminal. */
|
|
42
|
+
write(data: string): void;
|
|
43
|
+
/** Get the raw serialized terminal output (includes ANSI sequences). */
|
|
44
|
+
serialize(): string;
|
|
45
|
+
/** Read clean screen text with metadata. */
|
|
46
|
+
readScreen(): ScreenSnapshot;
|
|
47
|
+
/**
|
|
48
|
+
* Get terminal screen as lines, padded/trimmed to exactly `rows` lines.
|
|
49
|
+
* Clean text only (ANSI stripped). Reads from the active buffer's
|
|
50
|
+
* viewport (not scrollback), so it works correctly on both the normal
|
|
51
|
+
* and alternate screen buffers.
|
|
52
|
+
*/
|
|
53
|
+
getScreenLines(rows?: number): string[];
|
|
54
|
+
/** Read visible viewport lines from a buffer. */
|
|
55
|
+
private readViewportLines;
|
|
56
|
+
/** Get cursor position. */
|
|
57
|
+
getCursor(): {
|
|
58
|
+
x: number;
|
|
59
|
+
y: number;
|
|
60
|
+
};
|
|
61
|
+
/** Resize the virtual terminal. */
|
|
62
|
+
resize(cols: number, rows: number): void;
|
|
63
|
+
/** Whether the alternate screen buffer is active. */
|
|
64
|
+
get altScreen(): boolean;
|
|
65
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless terminal buffer backed by xterm.js.
|
|
3
|
+
*
|
|
4
|
+
* Provides accurate terminal screen capture — correctly handles ANSI
|
|
5
|
+
* codes, cursor movement, alternate screen (vim/htop), line wrapping,
|
|
6
|
+
* and scrollback.
|
|
7
|
+
*
|
|
8
|
+
* Used by:
|
|
9
|
+
* - floating-panel.ts: composited overlay rendering + screen restore
|
|
10
|
+
* - terminal-buffer extension: agent tools (terminal_read, terminal_keys)
|
|
11
|
+
* - Any extension needing a virtual terminal snapshot
|
|
12
|
+
*
|
|
13
|
+
* The xterm dependency is loaded lazily on first use. If @xterm/headless
|
|
14
|
+
* is not installed, create() returns null.
|
|
15
|
+
*
|
|
16
|
+
* Install (optional):
|
|
17
|
+
* npm install @xterm/headless@5.5.0 @xterm/addon-serialize@0.13.0
|
|
18
|
+
*/
|
|
19
|
+
import { createRequire } from "module";
|
|
20
|
+
// ── Lazy xterm loader ───────────────────────────────────────────
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
let loadAttempted = false;
|
|
23
|
+
let available = false;
|
|
24
|
+
let TerminalCtor;
|
|
25
|
+
let SerializeAddonCtor;
|
|
26
|
+
function ensureXterm() {
|
|
27
|
+
if (loadAttempted)
|
|
28
|
+
return available;
|
|
29
|
+
loadAttempted = true;
|
|
30
|
+
try {
|
|
31
|
+
TerminalCtor = require("@xterm/headless").Terminal;
|
|
32
|
+
SerializeAddonCtor = require("@xterm/addon-serialize").SerializeAddon;
|
|
33
|
+
available = true;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
available = false;
|
|
37
|
+
}
|
|
38
|
+
return available;
|
|
39
|
+
}
|
|
40
|
+
/** Check if @xterm/headless is installed without loading it. */
|
|
41
|
+
export function isXtermAvailable() {
|
|
42
|
+
return ensureXterm();
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Format a screen snapshot as an XML context block for agent injection.
|
|
46
|
+
* Trims, caps to `maxLines` (from the bottom), and wraps in `<terminal_buffer>`.
|
|
47
|
+
* Returns the combined context string (baseContext + section), or just
|
|
48
|
+
* baseContext if the screen is empty.
|
|
49
|
+
*/
|
|
50
|
+
export function formatScreenContext(screen, maxLines = 80, baseContext) {
|
|
51
|
+
const trimmed = screen.text.trim();
|
|
52
|
+
if (!trimmed)
|
|
53
|
+
return baseContext ?? "";
|
|
54
|
+
const lines = trimmed.split("\n");
|
|
55
|
+
const capped = lines.length > maxLines
|
|
56
|
+
? lines.slice(-maxLines).join("\n")
|
|
57
|
+
: trimmed;
|
|
58
|
+
const header = screen.altScreen
|
|
59
|
+
? "<terminal_buffer mode=\"alternate\">"
|
|
60
|
+
: "<terminal_buffer>";
|
|
61
|
+
const section = `${header}\n${capped}\n</terminal_buffer>`;
|
|
62
|
+
return baseContext ? baseContext + "\n" + section : section;
|
|
63
|
+
}
|
|
64
|
+
// ── TerminalBuffer ──────────────────────────────────────────────
|
|
65
|
+
export class TerminalBuffer {
|
|
66
|
+
term;
|
|
67
|
+
serializeAddon;
|
|
68
|
+
constructor(term, serialize) {
|
|
69
|
+
this.term = term;
|
|
70
|
+
this.serializeAddon = serialize;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Create a new TerminalBuffer. Returns null if xterm is not installed.
|
|
74
|
+
*/
|
|
75
|
+
static create(config) {
|
|
76
|
+
if (!ensureXterm())
|
|
77
|
+
return null;
|
|
78
|
+
const cols = config?.cols ?? (process.stdout.columns || 80);
|
|
79
|
+
const rows = config?.rows ?? (process.stdout.rows || 24);
|
|
80
|
+
const scrollback = config?.scrollback ?? 200;
|
|
81
|
+
const term = new TerminalCtor({ cols, rows, allowProposedApi: true, scrollback });
|
|
82
|
+
const serialize = new SerializeAddonCtor();
|
|
83
|
+
term.loadAddon(serialize);
|
|
84
|
+
return new TerminalBuffer(term, serialize);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Create a TerminalBuffer and wire it to a bus's shell:pty-data event.
|
|
88
|
+
* Returns null if xterm is not installed.
|
|
89
|
+
*/
|
|
90
|
+
static createWired(bus, config) {
|
|
91
|
+
const tb = TerminalBuffer.create(config);
|
|
92
|
+
if (!tb)
|
|
93
|
+
return null;
|
|
94
|
+
// Buffer PTY data and drip-feed to xterm in the background.
|
|
95
|
+
// Synchronous term.write() in the pty-data handler introduces enough
|
|
96
|
+
// latency to change PTY read coalescing, causing visual artifacts.
|
|
97
|
+
let pending = "";
|
|
98
|
+
bus.on("shell:pty-data", ({ raw }) => { pending += raw; });
|
|
99
|
+
setInterval(() => {
|
|
100
|
+
if (pending) {
|
|
101
|
+
const d = pending;
|
|
102
|
+
pending = "";
|
|
103
|
+
tb.write(d);
|
|
104
|
+
}
|
|
105
|
+
}, 50);
|
|
106
|
+
process.stdout.on("resize", () => {
|
|
107
|
+
tb.resize(process.stdout.columns || 80, process.stdout.rows || 24);
|
|
108
|
+
});
|
|
109
|
+
return tb;
|
|
110
|
+
}
|
|
111
|
+
/** Write raw data into the virtual terminal. */
|
|
112
|
+
write(data) {
|
|
113
|
+
this.term.write(data);
|
|
114
|
+
}
|
|
115
|
+
/** Get the raw serialized terminal output (includes ANSI sequences). */
|
|
116
|
+
serialize() {
|
|
117
|
+
return this.serializeAddon.serialize();
|
|
118
|
+
}
|
|
119
|
+
/** Read clean screen text with metadata. */
|
|
120
|
+
readScreen() {
|
|
121
|
+
const buf = this.term.buffer.active;
|
|
122
|
+
const lines = this.readViewportLines(buf);
|
|
123
|
+
return {
|
|
124
|
+
text: lines.join("\n"),
|
|
125
|
+
altScreen: buf.type === "alternate",
|
|
126
|
+
cursorX: buf.cursorX,
|
|
127
|
+
cursorY: buf.cursorY,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Get terminal screen as lines, padded/trimmed to exactly `rows` lines.
|
|
132
|
+
* Clean text only (ANSI stripped). Reads from the active buffer's
|
|
133
|
+
* viewport (not scrollback), so it works correctly on both the normal
|
|
134
|
+
* and alternate screen buffers.
|
|
135
|
+
*/
|
|
136
|
+
getScreenLines(rows) {
|
|
137
|
+
const targetRows = rows ?? (process.stdout.rows || 24);
|
|
138
|
+
return this.readViewportLines(this.term.buffer.active, targetRows);
|
|
139
|
+
}
|
|
140
|
+
/** Read visible viewport lines from a buffer. */
|
|
141
|
+
readViewportLines(buf, rows) {
|
|
142
|
+
const targetRows = rows ?? buf.length;
|
|
143
|
+
const base = buf.baseY ?? 0;
|
|
144
|
+
const lines = [];
|
|
145
|
+
for (let y = 0; y < targetRows; y++) {
|
|
146
|
+
const line = buf.getLine(base + y);
|
|
147
|
+
lines.push(line ? line.translateToString(true) : "");
|
|
148
|
+
}
|
|
149
|
+
return lines;
|
|
150
|
+
}
|
|
151
|
+
/** Get cursor position. */
|
|
152
|
+
getCursor() {
|
|
153
|
+
return {
|
|
154
|
+
x: this.term.buffer.active.cursorX,
|
|
155
|
+
y: this.term.buffer.active.cursorY,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/** Resize the virtual terminal. */
|
|
159
|
+
resize(cols, rows) {
|
|
160
|
+
this.term.resize(cols, rows);
|
|
161
|
+
}
|
|
162
|
+
/** Whether the alternate screen buffer is active. */
|
|
163
|
+
get altScreen() {
|
|
164
|
+
return this.term.buffer.active.type === "alternate";
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -6,6 +6,8 @@ export interface ToolCallRender {
|
|
|
6
6
|
command?: string;
|
|
7
7
|
/** Tool kind from ACP (read, edit, execute, search, etc.). */
|
|
8
8
|
kind?: string;
|
|
9
|
+
/** Custom icon character — when set, tool name is omitted (icon implies tool). */
|
|
10
|
+
icon?: string;
|
|
9
11
|
/** File locations affected by the tool call. */
|
|
10
12
|
locations?: {
|
|
11
13
|
path: string;
|
|
@@ -13,6 +15,8 @@ export interface ToolCallRender {
|
|
|
13
15
|
}[];
|
|
14
16
|
/** Raw input parameters sent to the tool. */
|
|
15
17
|
rawInput?: unknown;
|
|
18
|
+
/** Pre-formatted display detail from tool's formatCall(). Takes precedence over rawInput extraction. */
|
|
19
|
+
displayDetail?: string;
|
|
16
20
|
}
|
|
17
21
|
export interface ToolResultRender {
|
|
18
22
|
exitCode: number | null;
|
|
@@ -39,6 +39,7 @@ const KIND_ICONS = {
|
|
|
39
39
|
move: "↗",
|
|
40
40
|
search: "⌕",
|
|
41
41
|
execute: "▶",
|
|
42
|
+
display: "◇",
|
|
42
43
|
think: "◇",
|
|
43
44
|
fetch: "↓",
|
|
44
45
|
switch_mode: "⇄",
|
|
@@ -49,7 +50,10 @@ function kindIcon(kind) {
|
|
|
49
50
|
// ── Tool call rendering ──────────────────────────────────────────
|
|
50
51
|
export function renderToolCall(tool, width) {
|
|
51
52
|
const mode = selectToolDisplayMode(width);
|
|
52
|
-
const icon = kindIcon(tool.kind);
|
|
53
|
+
const icon = tool.icon ?? kindIcon(tool.kind);
|
|
54
|
+
// If the tool registered a custom icon, it's self-describing — omit the name.
|
|
55
|
+
// Otherwise, include the tool name so the user knows what ran.
|
|
56
|
+
const hasCustomIcon = !!tool.icon;
|
|
53
57
|
if (mode === "summary") {
|
|
54
58
|
const text = truncateVisible(`${icon} ${tool.title}`, width);
|
|
55
59
|
return [`${p.warning}${text}${p.reset}`];
|
|
@@ -58,7 +62,10 @@ export function renderToolCall(tool, width) {
|
|
|
58
62
|
// Build a compact detail string to append after the title
|
|
59
63
|
let detail = "";
|
|
60
64
|
const cwd = process.cwd();
|
|
61
|
-
if (mode === "full") {
|
|
65
|
+
if (mode === "full" && tool.displayDetail) {
|
|
66
|
+
detail = tool.displayDetail;
|
|
67
|
+
}
|
|
68
|
+
else if (mode === "full") {
|
|
62
69
|
if (tool.command) {
|
|
63
70
|
detail = `$ ${tool.command}`;
|
|
64
71
|
}
|
|
@@ -97,14 +104,24 @@ export function renderToolCall(tool, width) {
|
|
|
97
104
|
}
|
|
98
105
|
}
|
|
99
106
|
}
|
|
100
|
-
// Render as single line: icon +
|
|
101
|
-
// Falls back to icon + title when no detail is available
|
|
107
|
+
// Render as single line: icon + kind + detail
|
|
102
108
|
const maxDetailW = Math.max(1, width - 4);
|
|
103
|
-
if (detail) {
|
|
109
|
+
if (detail && hasCustomIcon && tool.kind) {
|
|
110
|
+
const combined = `${tool.kind} ${detail}`;
|
|
111
|
+
const truncated = combined.length > maxDetailW ? combined.slice(0, maxDetailW - 1) + "…" : combined;
|
|
112
|
+
lines.push(`${p.warning}${icon}${p.reset} ${p.dim}${truncated}${p.reset}`);
|
|
113
|
+
}
|
|
114
|
+
else if (detail && hasCustomIcon) {
|
|
104
115
|
if (detail.length > maxDetailW)
|
|
105
116
|
detail = detail.slice(0, maxDetailW - 1) + "…";
|
|
106
117
|
lines.push(`${p.warning}${icon}${p.reset} ${p.dim}${detail}${p.reset}`);
|
|
107
118
|
}
|
|
119
|
+
else if (detail) {
|
|
120
|
+
const prefix = `${tool.title}: `;
|
|
121
|
+
const combined = prefix + detail;
|
|
122
|
+
const truncated = combined.length > maxDetailW ? combined.slice(0, maxDetailW - 1) + "…" : combined;
|
|
123
|
+
lines.push(`${p.warning}${icon}${p.reset} ${p.dim}${truncated}${p.reset}`);
|
|
124
|
+
}
|
|
108
125
|
else {
|
|
109
126
|
lines.push(`${p.warning}${icon} ${tool.title}${p.reset}`);
|
|
110
127
|
}
|
|
@@ -30,8 +30,8 @@ function createUserShellTool(bus: EventBus) {
|
|
|
30
30
|
|
|
31
31
|
return tool(
|
|
32
32
|
"user_shell",
|
|
33
|
-
"Run a command in the user's live shell (
|
|
34
|
-
"
|
|
33
|
+
"Run a command with lasting effects in the user's live shell (cd, export, " +
|
|
34
|
+
"install packages, start servers) or show output the user wants to see. " +
|
|
35
35
|
"Set return_output=true only if you need to inspect the result.",
|
|
36
36
|
{
|
|
37
37
|
command: z.string().describe("Command to execute in user's shell"),
|
|
@@ -71,12 +71,8 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
71
71
|
const listeners: Array<{ event: string; fn: Function }> = [];
|
|
72
72
|
|
|
73
73
|
const wireListeners = () => {
|
|
74
|
-
const onSubmit = async ({ query: userQuery
|
|
75
|
-
|
|
76
|
-
? `${modeInstruction}\n${userQuery}`
|
|
77
|
-
: userQuery;
|
|
78
|
-
|
|
79
|
-
bus.emit("agent:query", { query: userQuery, modeLabel });
|
|
74
|
+
const onSubmit = async ({ query: userQuery }: any) => {
|
|
75
|
+
bus.emit("agent:query", { query: userQuery });
|
|
80
76
|
bus.emit("agent:processing-start", {});
|
|
81
77
|
|
|
82
78
|
let fullResponseText = "";
|
|
@@ -84,7 +80,7 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
84
80
|
|
|
85
81
|
try {
|
|
86
82
|
activeQuery = query({
|
|
87
|
-
prompt,
|
|
83
|
+
prompt: userQuery,
|
|
88
84
|
options: {
|
|
89
85
|
cwd: process.cwd(),
|
|
90
86
|
systemPrompt: {
|
|
@@ -92,9 +88,9 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
92
88
|
preset: "claude_code",
|
|
93
89
|
append:
|
|
94
90
|
"You are running inside agent-sh, a terminal wrapper.\n" +
|
|
95
|
-
"
|
|
96
|
-
"
|
|
97
|
-
"
|
|
91
|
+
"Use your standard tools (Read, Edit, Write, Bash, Glob, Grep) for investigation.\n" +
|
|
92
|
+
"Use mcp__agent-sh__user_shell to run commands in the user's live shell when they ask to see output or need lasting effects (cd, install, start servers).\n" +
|
|
93
|
+
"Default to standard tools. Use user_shell when the user is the intended audience for the output or the command has real effects.",
|
|
98
94
|
},
|
|
99
95
|
mcpServers: { "agent-sh": shellServer },
|
|
100
96
|
allowedTools: [
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overlay agent extension.
|
|
3
|
+
*
|
|
4
|
+
* Provides a hotkey (Ctrl+\) to summon the agent from anywhere — even
|
|
5
|
+
* inside vim, htop, or ssh. Composites a floating response box on top
|
|
6
|
+
* of the current terminal content.
|
|
7
|
+
*
|
|
8
|
+
* Requires: npm install @xterm/headless@5.5.0 @xterm/addon-serialize@0.13.0
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* agent-sh -e ./examples/extensions/overlay-agent.ts
|
|
12
|
+
*
|
|
13
|
+
* # Or copy to ~/.agent-sh/extensions/ for permanent use:
|
|
14
|
+
* cp examples/extensions/overlay-agent.ts ~/.agent-sh/extensions/
|
|
15
|
+
*/
|
|
16
|
+
import type { ExtensionContext } from "agent-sh/types";
|
|
17
|
+
import { formatScreenContext } from "agent-sh/utils/terminal-buffer.js";
|
|
18
|
+
|
|
19
|
+
const BOLD = "\x1b[1m";
|
|
20
|
+
const CYAN = "\x1b[36m";
|
|
21
|
+
const RESET = "\x1b[0m";
|
|
22
|
+
|
|
23
|
+
export default function activate({ bus, advise, createFloatingPanel, terminalBuffer }: ExtensionContext): void {
|
|
24
|
+
const panel = createFloatingPanel({
|
|
25
|
+
trigger: "\x1c", // Ctrl+\
|
|
26
|
+
dimBackground: true,
|
|
27
|
+
autoDismissMs: 2000,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// ── Inject terminal buffer into agent context ──────────────
|
|
31
|
+
if (terminalBuffer) {
|
|
32
|
+
advise("context:build-extra", (next: () => string) =>
|
|
33
|
+
formatScreenContext(terminalBuffer.readScreen(), 80, next()),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Panel lifecycle ────────────────────────────────────────
|
|
38
|
+
panel.handlers.advise("panel:submit", (_next, query: string) => {
|
|
39
|
+
panel.setActive();
|
|
40
|
+
panel.appendLine(`${CYAN}${BOLD}❯${RESET} ${query}`);
|
|
41
|
+
panel.appendLine("");
|
|
42
|
+
bus.emit("agent:submit", { query });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// ── Stream agent response into panel ───────────────────────
|
|
46
|
+
bus.on("agent:response-chunk", (e) => {
|
|
47
|
+
if (!panel.active) return;
|
|
48
|
+
for (const block of e.blocks) {
|
|
49
|
+
if (block.type === "text" && block.text) {
|
|
50
|
+
panel.appendText(block.text);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
bus.on("agent:tool-started", (e) => {
|
|
56
|
+
if (!panel.active) return;
|
|
57
|
+
panel.appendLine(`▶ ${e.title}${e.displayDetail ? " " + e.displayDetail : ""}`);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
bus.on("agent:tool-completed", (e) => {
|
|
61
|
+
if (!panel.active) return;
|
|
62
|
+
const mark = e.exitCode === 0 ? " ✓" : ` ✗ exit ${e.exitCode}`;
|
|
63
|
+
panel.updateLastLine((line) => line + mark);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
bus.on("agent:processing-done", () => {
|
|
67
|
+
if (!panel.active) return;
|
|
68
|
+
panel.setDone();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -48,17 +48,16 @@ function createUserShellToolDef(bus: EventBus) {
|
|
|
48
48
|
name: "user_shell",
|
|
49
49
|
label: "user_shell",
|
|
50
50
|
description:
|
|
51
|
-
"Run a command in the user's live shell (
|
|
52
|
-
"
|
|
51
|
+
"Run a command with lasting effects in the user's live shell (cd, export, " +
|
|
52
|
+
"install packages, start servers) or show output the user wants to see. " +
|
|
53
53
|
"Output is shown directly to the user. Set return_output=true only " +
|
|
54
54
|
"if you need to inspect the result.",
|
|
55
|
-
promptSnippet: "Execute commands in the user's live terminal (PTY).
|
|
55
|
+
promptSnippet: "Execute commands in the user's live terminal (PTY).",
|
|
56
56
|
promptGuidelines: [
|
|
57
|
-
"You are running inside agent-sh, a terminal wrapper
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"user_shell executes in the user's actual shell (their aliases, env vars, cwd). Use bash for background work.",
|
|
57
|
+
"You are running inside agent-sh, a terminal wrapper.",
|
|
58
|
+
"Use your standard tools (bash, file ops) for investigation — output goes to you, not the user.",
|
|
59
|
+
"Use user_shell to run commands in the user's live shell when they ask to see output or need lasting effects (cd, install, start servers).",
|
|
60
|
+
"Default to standard tools. Use user_shell when the user is the intended audience for the output or the command has real effects.",
|
|
62
61
|
],
|
|
63
62
|
parameters: schema,
|
|
64
63
|
|
|
@@ -203,7 +202,7 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
203
202
|
const listeners: Array<{ event: string; fn: Function }> = [];
|
|
204
203
|
|
|
205
204
|
const wireListeners = () => {
|
|
206
|
-
const onSubmit = async ({ query
|
|
205
|
+
const onSubmit = async ({ query }: any) => {
|
|
207
206
|
if (!session) {
|
|
208
207
|
bus.emit("agent:error", {
|
|
209
208
|
message: booting ? "pi is still starting up..." : "pi session not initialized",
|
|
@@ -212,12 +211,11 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
212
211
|
return;
|
|
213
212
|
}
|
|
214
213
|
|
|
215
|
-
|
|
216
|
-
bus.emit("agent:query", { query, modeLabel });
|
|
214
|
+
bus.emit("agent:query", { query });
|
|
217
215
|
bus.emit("agent:processing-start", {});
|
|
218
216
|
|
|
219
217
|
try {
|
|
220
|
-
await session.prompt(
|
|
218
|
+
await session.prompt(query);
|
|
221
219
|
} catch (err) {
|
|
222
220
|
bus.emit("agent:error", {
|
|
223
221
|
message: err instanceof Error ? err.message : String(err),
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret guard extension.
|
|
3
|
+
*
|
|
4
|
+
* Redacts sensitive patterns (API keys, tokens, passwords) from tool output
|
|
5
|
+
* — both the streamed terminal display and the content sent back to the LLM.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* agent-sh -e ./examples/extensions/secret-guard.ts
|
|
9
|
+
*
|
|
10
|
+
* # Or install permanently:
|
|
11
|
+
* cp examples/extensions/secret-guard.ts ~/.agent-sh/extensions/
|
|
12
|
+
*
|
|
13
|
+
* Configuration (~/.agent-sh/settings.json):
|
|
14
|
+
* {
|
|
15
|
+
* "secret-guard": {
|
|
16
|
+
* "extraPatterns": ["CUSTOM_\\w+=\\S+"],
|
|
17
|
+
* "redactText": "***REDACTED***"
|
|
18
|
+
* }
|
|
19
|
+
* }
|
|
20
|
+
*/
|
|
21
|
+
import type { ExtensionContext } from "agent-sh/types";
|
|
22
|
+
|
|
23
|
+
// Common secret patterns — each matches key=value or key: value formats
|
|
24
|
+
const DEFAULT_PATTERNS = [
|
|
25
|
+
// API keys and tokens (generic)
|
|
26
|
+
/(?:api[_-]?key|api[_-]?secret|access[_-]?token|auth[_-]?token|secret[_-]?key|private[_-]?key)\s*[=:]\s*\S+/gi,
|
|
27
|
+
// AWS
|
|
28
|
+
/(?:AKIA|ASIA)[A-Z0-9]{16}/g,
|
|
29
|
+
/(?:aws_secret_access_key|aws_session_token)\s*[=:]\s*\S+/gi,
|
|
30
|
+
// Bearer tokens
|
|
31
|
+
/Bearer\s+[A-Za-z0-9\-._~+/]+=*/g,
|
|
32
|
+
// GitHub tokens
|
|
33
|
+
/gh[pousr]_[A-Za-z0-9_]{36,}/g,
|
|
34
|
+
// Anthropic / OpenAI keys
|
|
35
|
+
/sk-(?:ant-)?[A-Za-z0-9\-_]{10,}/g,
|
|
36
|
+
// Generic long hex/base64 secrets (env var assignment)
|
|
37
|
+
/(?:SECRET|TOKEN|PASSWORD|PASSWD|API_KEY|PRIVATE_KEY)\s*[=:]\s*\S+/gi,
|
|
38
|
+
// Connection strings with passwords
|
|
39
|
+
/[a-z+]+:\/\/[^:]+:[^@\s]+@/gi,
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
export default function activate(ctx: ExtensionContext) {
|
|
43
|
+
const { bus } = ctx;
|
|
44
|
+
const config = ctx.getExtensionSettings("secret-guard", {
|
|
45
|
+
extraPatterns: [] as string[],
|
|
46
|
+
redactText: "***REDACTED***",
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const patterns = [
|
|
50
|
+
...DEFAULT_PATTERNS,
|
|
51
|
+
...config.extraPatterns.map((p: string) => new RegExp(p, "gi")),
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
function redact(text: string): string {
|
|
55
|
+
let result = text;
|
|
56
|
+
for (const pattern of patterns) {
|
|
57
|
+
// Reset lastIndex for stateful regex (global flag)
|
|
58
|
+
pattern.lastIndex = 0;
|
|
59
|
+
result = result.replace(pattern, config.redactText);
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Redact the dynamic context (shell history, cwd, etc.) before it's sent
|
|
65
|
+
// to the LLM. This is the chokepoint — everything the model sees passes
|
|
66
|
+
// through dynamic-context:build.
|
|
67
|
+
ctx.advise("dynamic-context:build", (next) => {
|
|
68
|
+
return redact(next());
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Advise tool:execute to wrap both streaming output and final result.
|
|
72
|
+
// Chunks from child processes arrive at arbitrary byte boundaries, so a
|
|
73
|
+
// secret like "sk-ant-abc123" could be split across two chunks. We
|
|
74
|
+
// line-buffer: accumulate until we see '\n', redact complete lines, flush.
|
|
75
|
+
ctx.advise("tool:execute", async (next, toolCtx) => {
|
|
76
|
+
const origOnChunk = toolCtx.onChunk;
|
|
77
|
+
if (origOnChunk) {
|
|
78
|
+
let buf = "";
|
|
79
|
+
toolCtx.onChunk = (chunk: string) => {
|
|
80
|
+
buf += chunk;
|
|
81
|
+
const lastNl = buf.lastIndexOf("\n");
|
|
82
|
+
if (lastNl !== -1) {
|
|
83
|
+
// Flush all complete lines, redacted
|
|
84
|
+
origOnChunk(redact(buf.slice(0, lastNl + 1)));
|
|
85
|
+
buf = buf.slice(lastNl + 1);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const result = await next(toolCtx);
|
|
90
|
+
|
|
91
|
+
// Flush any remaining partial line
|
|
92
|
+
if (buf) origOnChunk(redact(buf));
|
|
93
|
+
|
|
94
|
+
return { ...result, content: redact(result.content) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const result = await next(toolCtx);
|
|
98
|
+
return { ...result, content: redact(result.content) };
|
|
99
|
+
});
|
|
100
|
+
}
|