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.
Files changed (54) hide show
  1. package/README.md +12 -43
  2. package/dist/agent/agent-loop.d.ts +1 -0
  3. package/dist/agent/agent-loop.js +119 -26
  4. package/dist/agent/subagent.js +3 -1
  5. package/dist/agent/system-prompt.d.ts +1 -1
  6. package/dist/agent/system-prompt.js +21 -16
  7. package/dist/agent/tools/bash.js +10 -1
  8. package/dist/agent/tools/display.d.ts +13 -0
  9. package/dist/agent/tools/display.js +70 -0
  10. package/dist/agent/tools/edit-file.js +60 -7
  11. package/dist/agent/tools/glob.js +39 -7
  12. package/dist/agent/tools/grep.js +111 -20
  13. package/dist/agent/tools/ls.js +31 -2
  14. package/dist/agent/tools/read-file.d.ts +9 -1
  15. package/dist/agent/tools/read-file.js +50 -4
  16. package/dist/agent/tools/user-shell.js +40 -13
  17. package/dist/agent/tools/write-file.js +9 -1
  18. package/dist/agent/types.d.ts +35 -1
  19. package/dist/context-manager.d.ts +3 -1
  20. package/dist/context-manager.js +11 -1
  21. package/dist/core.d.ts +1 -3
  22. package/dist/core.js +23 -12
  23. package/dist/event-bus.d.ts +41 -3
  24. package/dist/extension-loader.d.ts +1 -1
  25. package/dist/extension-loader.js +1 -3
  26. package/dist/extensions/overlay-agent.d.ts +11 -0
  27. package/dist/extensions/overlay-agent.js +43 -0
  28. package/dist/extensions/terminal-buffer.d.ts +14 -0
  29. package/dist/extensions/terminal-buffer.js +120 -0
  30. package/dist/extensions/tui-renderer.js +344 -83
  31. package/dist/index.js +45 -36
  32. package/dist/input-handler.js +10 -3
  33. package/dist/output-parser.js +8 -0
  34. package/dist/settings.js +1 -1
  35. package/dist/shell.d.ts +5 -0
  36. package/dist/shell.js +29 -4
  37. package/dist/types.d.ts +13 -0
  38. package/dist/utils/diff.js +10 -0
  39. package/dist/utils/floating-panel.d.ts +198 -0
  40. package/dist/utils/floating-panel.js +590 -0
  41. package/dist/utils/markdown.d.ts +1 -0
  42. package/dist/utils/markdown.js +23 -1
  43. package/dist/utils/output-writer.d.ts +14 -0
  44. package/dist/utils/output-writer.js +16 -0
  45. package/dist/utils/terminal-buffer.d.ts +65 -0
  46. package/dist/utils/terminal-buffer.js +166 -0
  47. package/dist/utils/tool-display.d.ts +4 -0
  48. package/dist/utils/tool-display.js +22 -5
  49. package/examples/extensions/claude-code-bridge/index.ts +8 -12
  50. package/examples/extensions/overlay-agent.ts +70 -0
  51. package/examples/extensions/pi-bridge/index.ts +10 -12
  52. package/examples/extensions/secret-guard.ts +100 -0
  53. package/examples/extensions/terminal-buffer.ts +184 -0
  54. 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 + detail (icon implies the tool type)
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 (visible in terminal). " +
34
- "Use for cd, export, source, or commands the user wants to see. " +
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, modeInstruction, modeLabel }: any) => {
75
- const prompt = modeInstruction
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
- "EXECUTE mode ('>'): Use your standard tools. Do NOT use user_shell.\n" +
96
- "HELP mode ('?'): Run the command via mcp__agent-sh__user_shell. Just run it, no explanation.\n" +
97
- "Each prompt includes a per-query mode instruction follow it.",
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 (visible in terminal). " +
52
- "Use for cd, export, source, or commands the user wants to see. " +
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). Use in HELP mode.",
55
+ promptSnippet: "Execute commands in the user's live terminal (PTY).",
56
56
  promptGuidelines: [
57
- "You are running inside agent-sh, a terminal wrapper with two interaction modes.",
58
- "EXECUTE mode (triggered by '>'): Use your standard tools (bash, file ops). Do NOT use user_shell.",
59
- "HELP mode (triggered by '?'): Run the command via user_shell. Do not explain or confirm just run it.",
60
- "Each prompt includes a per-query mode instruction follow it.",
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, modeInstruction, modeLabel }: any) => {
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
- const prompt = modeInstruction ? `${modeInstruction}\n${query}` : query;
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(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
+ }