agent-sh 0.8.0 → 0.9.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 (74) hide show
  1. package/README.md +25 -34
  2. package/dist/agent/agent-loop.d.ts +29 -6
  3. package/dist/agent/agent-loop.js +177 -59
  4. package/dist/agent/conversation-state.d.ts +3 -1
  5. package/dist/agent/conversation-state.js +6 -2
  6. package/dist/agent/nuclear-form.js +5 -4
  7. package/dist/agent/system-prompt.d.ts +4 -5
  8. package/dist/agent/system-prompt.js +12 -28
  9. package/dist/{token-budget.js → agent/token-budget.js} +1 -1
  10. package/dist/agent/tool-protocol.d.ts +83 -0
  11. package/dist/agent/tool-protocol.js +386 -0
  12. package/dist/agent/types.d.ts +21 -1
  13. package/dist/core.d.ts +7 -7
  14. package/dist/core.js +76 -194
  15. package/dist/event-bus.d.ts +26 -0
  16. package/dist/event-bus.js +20 -1
  17. package/dist/extension-loader.d.ts +5 -0
  18. package/dist/extension-loader.js +104 -17
  19. package/dist/extensions/agent-backend.d.ts +13 -0
  20. package/dist/extensions/agent-backend.js +167 -0
  21. package/dist/extensions/command-suggest.d.ts +3 -3
  22. package/dist/extensions/command-suggest.js +4 -3
  23. package/dist/extensions/index.d.ts +19 -0
  24. package/dist/extensions/index.js +25 -0
  25. package/dist/extensions/slash-commands.d.ts +1 -1
  26. package/dist/extensions/slash-commands.js +16 -1
  27. package/dist/extensions/terminal-buffer.d.ts +1 -1
  28. package/dist/extensions/terminal-buffer.js +13 -4
  29. package/dist/extensions/tui-renderer.js +63 -43
  30. package/dist/index.js +14 -20
  31. package/dist/settings.d.ts +6 -0
  32. package/dist/settings.js +4 -1
  33. package/dist/{input-handler.d.ts → shell/input-handler.d.ts} +1 -1
  34. package/dist/{input-handler.js → shell/input-handler.js} +60 -43
  35. package/dist/{output-parser.d.ts → shell/output-parser.d.ts} +1 -1
  36. package/dist/{output-parser.js → shell/output-parser.js} +1 -1
  37. package/dist/{shell.d.ts → shell/shell.d.ts} +8 -2
  38. package/dist/{shell.js → shell/shell.js} +20 -6
  39. package/dist/types.d.ts +49 -10
  40. package/dist/utils/compositor.d.ts +62 -0
  41. package/dist/utils/compositor.js +88 -0
  42. package/dist/utils/diff-renderer.js +92 -4
  43. package/dist/utils/floating-panel.d.ts +2 -0
  44. package/dist/utils/floating-panel.js +30 -14
  45. package/dist/utils/handler-registry.d.ts +26 -10
  46. package/dist/utils/handler-registry.js +52 -16
  47. package/dist/utils/line-editor.d.ts +23 -3
  48. package/dist/utils/line-editor.js +180 -42
  49. package/dist/utils/markdown.d.ts +1 -0
  50. package/dist/utils/markdown.js +1 -1
  51. package/dist/utils/message-utils.d.ts +35 -0
  52. package/dist/utils/message-utils.js +75 -0
  53. package/dist/utils/terminal-buffer.d.ts +5 -1
  54. package/dist/utils/terminal-buffer.js +18 -2
  55. package/dist/utils/tool-interactive.d.ts +12 -0
  56. package/dist/utils/tool-interactive.js +53 -0
  57. package/examples/extensions/ash-acp-bridge/README.md +39 -0
  58. package/examples/extensions/ash-acp-bridge/package.json +23 -0
  59. package/examples/extensions/ash-acp-bridge/src/index.ts +571 -0
  60. package/examples/extensions/ash-acp-bridge/tsconfig.json +14 -0
  61. package/examples/extensions/ash-mcp-bridge/README.md +72 -0
  62. package/examples/extensions/ash-mcp-bridge/index.ts +154 -0
  63. package/examples/extensions/ash-mcp-bridge/package.json +9 -0
  64. package/examples/extensions/interactive-prompts.ts +82 -110
  65. package/examples/extensions/overlay-agent.ts +84 -38
  66. package/examples/extensions/peer-mesh.ts +450 -0
  67. package/examples/extensions/questionnaire.ts +249 -0
  68. package/examples/extensions/tmux-pane.ts +307 -0
  69. package/examples/extensions/web-access.ts +327 -0
  70. package/package.json +9 -1
  71. package/dist/extensions/overlay-agent.d.ts +0 -14
  72. package/dist/extensions/overlay-agent.js +0 -147
  73. package/examples/extensions/terminal-buffer.ts +0 -184
  74. /package/dist/{token-budget.d.ts → agent/token-budget.d.ts} +0 -0
@@ -0,0 +1,72 @@
1
+ # ash-mcp-bridge
2
+
3
+ Connects any MCP (Model Context Protocol) server to ash. Spawns servers as child processes over stdio, discovers their tools, and registers each as a native ash tool.
4
+
5
+ ## Setup
6
+
7
+ ```bash
8
+ cp -r examples/extensions/ash-mcp-bridge ~/.agent-sh/extensions/
9
+ cd ~/.agent-sh/extensions/ash-mcp-bridge && npm install
10
+ ```
11
+
12
+ ## Configuration
13
+
14
+ Add server definitions to `~/.agent-sh/settings.json`:
15
+
16
+ ```json
17
+ {
18
+ "mcp-bridge": {
19
+ "servers": {
20
+ "vision": {
21
+ "command": "npx",
22
+ "args": ["-y", "@z_ai/mcp-server"],
23
+ "env": {
24
+ "Z_AI_API_KEY": "your-key",
25
+ "Z_AI_MODE": "ZAI"
26
+ }
27
+ }
28
+ }
29
+ }
30
+ }
31
+ ```
32
+
33
+ Each server entry:
34
+
35
+ | Field | Type | Description |
36
+ |-------|------|-------------|
37
+ | `command` | `string` | Executable to spawn (e.g. `npx`, `node`) |
38
+ | `args` | `string[]` | Command arguments |
39
+ | `env` | `Record<string, string>` | Extra environment variables (merged with `process.env`) |
40
+
41
+ ## How it works
42
+
43
+ On activation, the extension:
44
+
45
+ 1. Reads `mcp-bridge.servers` from settings
46
+ 2. Spawns each server as a child process with stdio transport
47
+ 3. Connects via the MCP SDK client
48
+ 4. Calls `listTools()` to discover available tools
49
+ 5. Registers each tool as `mcp_{server}_{tool}` (e.g. `mcp_vision_image_analysis`)
50
+
51
+ Tools are then available to the agent like any built-in tool.
52
+
53
+ ## Example: Z.AI Vision
54
+
55
+ ```json
56
+ {
57
+ "mcp-bridge": {
58
+ "servers": {
59
+ "vision": {
60
+ "command": "npx",
61
+ "args": ["-y", "@z_ai/mcp-server"],
62
+ "env": {
63
+ "Z_AI_API_KEY": "your-key",
64
+ "Z_AI_MODE": "ZAI"
65
+ }
66
+ }
67
+ }
68
+ }
69
+ }
70
+ ```
71
+
72
+ This gives the agent access to tools like `mcp_vision_image_analysis`, `mcp_vision_ui_to_artifact`, `mcp_vision_extract_text_from_screenshot`, etc.
@@ -0,0 +1,154 @@
1
+ /**
2
+ * MCP Bridge — connects external MCP servers to agent-sh.
3
+ *
4
+ * Spawns MCP servers as child processes, discovers their tools,
5
+ * and registers each tool as an agent-sh ToolDefinition.
6
+ *
7
+ * Configure in ~/.agent-sh/settings.json:
8
+ *
9
+ * {
10
+ * "extensions": ["./path/to/mcp-bridge"],
11
+ * "mcp-bridge": {
12
+ * "servers": {
13
+ * "vision": {
14
+ * "command": "npx",
15
+ * "args": ["-y", "@z_ai/mcp-server"],
16
+ * "env": {
17
+ * "Z_AI_API_KEY": "your-key",
18
+ * "Z_AI_MODE": "ZAI"
19
+ * }
20
+ * }
21
+ * }
22
+ * }
23
+ * }
24
+ */
25
+
26
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
27
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
28
+
29
+ interface McpServerConfig {
30
+ command: string;
31
+ args?: string[];
32
+ env?: Record<string, string>;
33
+ }
34
+
35
+ interface McpBridgeSettings {
36
+ servers: Record<string, McpServerConfig>;
37
+ }
38
+
39
+ interface ConnectedServer {
40
+ name: string;
41
+ client: Client;
42
+ transport: StdioClientTransport;
43
+ }
44
+
45
+ export default async function activate(ctx: any): Promise<void> {
46
+ const { bus } = ctx;
47
+
48
+ const settings = ctx.getExtensionSettings("mcp-bridge", {
49
+ servers: {},
50
+ }) as McpBridgeSettings;
51
+
52
+ const serverEntries = Object.entries(settings.servers);
53
+ if (serverEntries.length === 0) return;
54
+
55
+ const connected: ConnectedServer[] = [];
56
+
57
+ for (const [name, config] of serverEntries) {
58
+ try {
59
+ const server = await connectServer(name, config, ctx);
60
+ connected.push(server);
61
+ } catch (err: any) {
62
+ bus.emit("ui:info", {
63
+ message: `mcp-bridge: failed to connect "${name}": ${err.message}`,
64
+ });
65
+ }
66
+ }
67
+
68
+ // Clean up on exit
69
+ bus.on("app:quit", () => {
70
+ for (const server of connected) {
71
+ try {
72
+ server.transport.close();
73
+ } catch {}
74
+ }
75
+ });
76
+ }
77
+
78
+ async function connectServer(
79
+ name: string,
80
+ config: McpServerConfig,
81
+ ctx: any,
82
+ ): Promise<ConnectedServer> {
83
+ const transport = new StdioClientTransport({
84
+ command: config.command,
85
+ args: config.args,
86
+ env: { ...process.env, ...config.env } as Record<string, string>,
87
+ });
88
+
89
+ const client = new Client({ name: `ash-${name}`, version: "0.1.0" });
90
+ await client.connect(transport);
91
+
92
+ // Discover and register tools
93
+ const { tools } = await client.listTools();
94
+ for (const tool of tools) {
95
+ const toolName = `mcp_${name}_${tool.name}`;
96
+ ctx.registerTool({
97
+ name: toolName,
98
+ displayName: tool.name,
99
+ description: `[${name}] ${tool.description ?? ""}`,
100
+ input_schema: tool.inputSchema as Record<string, unknown>,
101
+
102
+ async execute(args: Record<string, unknown>) {
103
+ try {
104
+ const result = await client.callTool({
105
+ name: tool.name,
106
+ arguments: args,
107
+ });
108
+
109
+ const text = (result.content as any[])
110
+ .map((c: any) => {
111
+ if (c.type === "text") return c.text;
112
+ if (c.type === "image") return `[image: ${c.mimeType}]`;
113
+ return JSON.stringify(c);
114
+ })
115
+ .join("\n");
116
+
117
+ return {
118
+ content: text,
119
+ exitCode: result.isError ? 1 : 0,
120
+ isError: !!result.isError,
121
+ };
122
+ } catch (err: any) {
123
+ return {
124
+ content: `MCP error: ${err.message}`,
125
+ exitCode: 1,
126
+ isError: true,
127
+ };
128
+ }
129
+ },
130
+
131
+ getDisplayInfo() {
132
+ return { kind: "execute" as const };
133
+ },
134
+
135
+ formatCall(args: Record<string, unknown>) {
136
+ // Show a compact summary of the args
137
+ const keys = Object.keys(args);
138
+ if (keys.length === 0) return tool.name;
139
+ const first = args[keys[0]];
140
+ const preview =
141
+ typeof first === "string"
142
+ ? first.slice(0, 60) + (first.length > 60 ? "…" : "")
143
+ : JSON.stringify(first).slice(0, 60);
144
+ return `${tool.name}: ${preview}`;
145
+ },
146
+ });
147
+ }
148
+
149
+ ctx.bus.emit("ui:info", {
150
+ message: `mcp-bridge: "${name}" connected (${tools.length} tools)`,
151
+ });
152
+
153
+ return { name, client, transport };
154
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "ash-mcp-bridge",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "dependencies": {
7
+ "@modelcontextprotocol/sdk": "^1.12.1"
8
+ }
9
+ }
@@ -4,34 +4,36 @@
4
4
  * Adds permission gates for tool calls and file writes.
5
5
  * Without this extension, agent-sh runs in yolo mode (auto-approve).
6
6
  *
7
+ * Uses the interactive UI primitive for compositor-aware, themed rendering.
8
+ *
7
9
  * Usage:
8
- * # Load by short name (built-in):
9
- * agent-sh --extensions interactive-prompts
10
+ * agent-sh -e ./examples/extensions/interactive-prompts.ts
10
11
  *
11
12
  * # Or copy to ~/.agent-sh/extensions/ for permanent use:
12
13
  * cp examples/extensions/interactive-prompts.ts ~/.agent-sh/extensions/
13
- *
14
- * # Or install as an npm package and load by name:
15
- * agent-sh --extensions my-prompts-package
16
14
  */
17
15
  import { renderDiff } from "agent-sh/utils/diff-renderer.js";
18
16
  import { renderBoxFrame } from "agent-sh/utils/box-frame.js";
19
17
  import { palette as p } from "agent-sh/utils/palette.js";
20
18
  import type { ExtensionContext } from "agent-sh/types";
19
+ import type { ToolUI } from "agent-sh/agent/types.js";
21
20
 
22
21
  export default function activate({ bus }: ExtensionContext) {
23
22
  let autoApproveWrites = false;
24
23
 
25
24
  bus.onPipeAsync("permission:request", async (payload) => {
25
+ const ui = payload.ui as ToolUI | undefined;
26
+ if (!ui) return payload;
27
+
26
28
  switch (payload.kind) {
27
29
  case "tool-call":
28
- return handleToolCallPermission(payload);
30
+ return handleToolCall(payload, ui);
29
31
  case "file-write": {
30
32
  if (autoApproveWrites) {
31
- return { ...payload, decision: { approved: true } };
33
+ return { ...payload, decision: { outcome: "approved" } };
32
34
  }
33
- const result = await handleFileWritePermission(payload);
34
- if (result.decision.autoApprove) {
35
+ const result = await handleFileWrite(payload, ui);
36
+ if ((result.decision as any).autoApprove) {
35
37
  autoApproveWrites = true;
36
38
  }
37
39
  return result;
@@ -42,120 +44,90 @@ export default function activate({ bus }: ExtensionContext) {
42
44
  });
43
45
  }
44
46
 
45
- async function handleToolCallPermission(payload) {
47
+ async function handleToolCall(payload: any, ui: ToolUI) {
46
48
  const options = payload.metadata.options;
47
- const answer = await promptPermission(payload.title);
49
+
50
+ const answer = await ui.custom<"approve" | "approve_all" | "deny">({
51
+ render(width) {
52
+ const boxW = Math.min(84, width);
53
+ return renderBoxFrame(
54
+ [`${p.bold}⚠ ${payload.title}${p.reset}`],
55
+ {
56
+ width: boxW,
57
+ style: "rounded",
58
+ borderColor: p.warning,
59
+ title: "Permission required",
60
+ footer: [` ${p.dim}[y]es / [n]o / [a]llow all${p.reset}`],
61
+ },
62
+ );
63
+ },
64
+ handleInput(data, done) {
65
+ const ch = data.toLowerCase();
66
+ if (ch === "y") done("approve");
67
+ else if (ch === "a") done("approve_all");
68
+ else if (ch === "n" || ch === "\x1b") done("deny");
69
+ },
70
+ });
48
71
 
49
72
  if (answer === "approve" || answer === "approve_all") {
50
- const option = answer === "approve_all"
51
- ? options.find((o) => o.kind === "allow_always") ?? options.find((o) => o.kind === "allow_once")
52
- : options.find((o) => o.kind === "allow_once" || o.kind === "allow_always");
73
+ const kind = answer === "approve_all" ? "allow_always" : "allow_once";
74
+ const option = options?.find((o: any) => o.kind === kind)
75
+ ?? options?.find((o: any) => o.kind === "allow_once" || o.kind === "allow_always");
53
76
  if (option) {
54
77
  return { ...payload, decision: { outcome: "selected", optionId: option.optionId } };
55
78
  }
79
+ return { ...payload, decision: { outcome: "approved" } };
56
80
  }
57
81
  return { ...payload, decision: { outcome: "cancelled" } };
58
82
  }
59
83
 
60
- async function handleFileWritePermission(payload) {
84
+ async function handleFileWrite(payload: any, ui: ToolUI) {
61
85
  const diff = payload.metadata.diff;
62
- const filePath = payload.metadata.path;
63
- const answer = await previewDiff({ path: filePath, diff });
64
- if (answer === "approve") {
65
- return { ...payload, decision: { approved: true } };
66
- }
67
- if (answer === "approve_all") {
68
- return { ...payload, decision: { approved: true, autoApprove: true } };
69
- }
70
- return { ...payload, decision: { approved: false } };
71
- }
72
-
73
- async function promptPermission(title) {
74
- const termW = process.stdout.columns || 80;
75
- const boxW = Math.min(84, termW);
76
-
77
- const framed = renderBoxFrame(
78
- [`${p.bold}⚠ ${title}${p.reset}`],
79
- {
80
- width: boxW,
81
- style: "rounded",
82
- borderColor: p.warning,
83
- title: "Permission required",
84
- footer: [` ${p.dim}[y]es / [n]o / [a]llow all${p.reset}`],
86
+ const filePath = payload.metadata.path ?? payload.title;
87
+
88
+ const answer = await ui.custom<"approve" | "approve_all" | "reject">({
89
+ render(width) {
90
+ const boxW = Math.min(84, width);
91
+ const contentW = boxW - 4;
92
+ const MAX_DISPLAY = 25;
93
+
94
+ const stats = diff.isNewFile
95
+ ? `(+${diff.added} lines)`
96
+ : `(+${diff.added} / -${diff.removed})`;
97
+ const title = diff.isNewFile
98
+ ? `new: ${filePath} ${stats}`
99
+ : `${filePath} ${stats}`;
100
+
101
+ const diffLines = renderDiff(diff, {
102
+ width: contentW,
103
+ filePath,
104
+ maxLines: MAX_DISPLAY,
105
+ trueColor: true,
106
+ mode: "unified",
107
+ });
108
+ const content = ["", ...diffLines.slice(1), ""];
109
+
110
+ return renderBoxFrame(content, {
111
+ width: boxW,
112
+ style: "rounded",
113
+ borderColor: p.warning,
114
+ title,
115
+ footer: [` ${p.bold}[y] Apply [n] Skip [a] Don't ask again${p.reset}`],
116
+ });
117
+ },
118
+ handleInput(data, done) {
119
+ const ch = data.toLowerCase();
120
+ if (ch === "y") done("approve");
121
+ else if (ch === "a") done("approve_all");
122
+ else if (ch === "n" || ch === "\x1b") done("reject");
85
123
  },
86
- );
87
-
88
- process.stdout.write("\n");
89
- for (const line of framed) {
90
- process.stdout.write(line + "\n");
91
- }
92
- process.stdout.write(" ");
93
-
94
- return new Promise((resolve) => {
95
- const handler = (data) => {
96
- const ch = data.toString("utf-8").toLowerCase();
97
- process.stdin.removeListener("data", handler);
98
- process.stdout.write("\n");
99
-
100
- if (ch === "y") resolve("approve");
101
- else if (ch === "a") resolve("approve_all");
102
- else resolve(null);
103
- };
104
- process.stdin.on("data", handler);
105
- });
106
- }
107
-
108
- async function previewDiff(opts) {
109
- const termW = process.stdout.columns || 80;
110
- const boxW = Math.min(84, termW);
111
- const contentW = boxW - 4;
112
- const MAX_DISPLAY = 25;
113
-
114
- const stats = opts.diff.isNewFile
115
- ? `(+${opts.diff.added} lines)`
116
- : `(+${opts.diff.added} / -${opts.diff.removed})`;
117
- const title = opts.diff.isNewFile
118
- ? `new: ${opts.path} ${stats}`
119
- : `${opts.path} ${stats}`;
120
-
121
- const diffLines = renderDiff(opts.diff, {
122
- width: contentW,
123
- filePath: opts.path,
124
- maxLines: MAX_DISPLAY,
125
- trueColor: true,
126
- mode: "unified",
127
- });
128
- const content = ["", ...diffLines.slice(1), ""];
129
-
130
- const framed = renderBoxFrame(content, {
131
- width: boxW,
132
- style: "rounded",
133
- borderColor: p.warning,
134
- title,
135
- footer: [` ${p.bold}[y] Apply [n] Skip [a] Don't ask again${p.reset}`],
136
124
  });
137
125
 
138
- process.stdout.write("\n");
139
- for (const line of framed) {
140
- process.stdout.write(line + "\n");
126
+ if (answer === "approve") {
127
+ return { ...payload, decision: { outcome: "approved" } };
141
128
  }
142
-
143
- return new Promise((resolve) => {
144
- const handler = (data) => {
145
- const ch = data.toString("utf-8").toLowerCase();
146
- process.stdin.removeListener("data", handler);
147
-
148
- if (ch === "y") {
149
- process.stdout.write(` ${p.success}✓ Applied${p.reset}\n`);
150
- resolve("approve");
151
- } else if (ch === "a") {
152
- process.stdout.write(` ${p.success}✓ Applied (auto-approve on)${p.reset}\n`);
153
- resolve("approve_all");
154
- } else {
155
- process.stdout.write(` ${p.error}✗ Skipped${p.reset}\n`);
156
- resolve("reject");
157
- }
158
- };
159
- process.stdin.on("data", handler);
160
- });
129
+ if (answer === "approve_all") {
130
+ return { ...payload, decision: { outcome: "approved", autoApprove: true } };
131
+ }
132
+ return { ...payload, decision: { outcome: "cancelled" } };
161
133
  }
@@ -5,66 +5,112 @@
5
5
  * inside vim, htop, or ssh. Composites a floating response box on top
6
6
  * of the current terminal content.
7
7
  *
8
- * Requires: npm install @xterm/headless@5.5.0 @xterm/addon-serialize@0.13.0
8
+ * Uses createRemoteSession() to route the full tui-renderer pipeline
9
+ * (markdown, tool grouping, spinner, diffs) into the floating panel.
10
+ *
11
+ * Install:
12
+ * cp examples/extensions/overlay-agent.ts ~/.agent-sh/extensions/
9
13
  *
10
- * Usage:
14
+ * Or load directly:
11
15
  * agent-sh -e ./examples/extensions/overlay-agent.ts
12
16
  *
13
- * # Or copy to ~/.agent-sh/extensions/ for permanent use:
14
- * cp examples/extensions/overlay-agent.ts ~/.agent-sh/extensions/
17
+ * Requires: npm install @xterm/headless@5.5.0 @xterm/addon-serialize@0.13.0
15
18
  */
16
- import type { ExtensionContext } from "agent-sh/types";
17
- import { formatScreenContext } from "agent-sh/utils/terminal-buffer.js";
19
+ import type { ExtensionContext, RemoteSession } from "../../src/types.js";
20
+ import type { RenderSurface } from "../../src/utils/compositor.js";
21
+ import { FloatingPanel } from "../../src/utils/floating-panel.js";
18
22
 
19
- const BOLD = "\x1b[1m";
20
- const CYAN = "\x1b[36m";
21
- const RESET = "\x1b[0m";
23
+ /** Adapt a FloatingPanel to the RenderSurface interface. */
24
+ function createPanelSurface(panel: FloatingPanel): RenderSurface {
25
+ return {
26
+ write(text: string): void {
27
+ // Handle \r (carriage return) — overwrite the current line.
28
+ // The spinner uses "\r <content>\x1b[K" to update in-place.
29
+ if (text.startsWith("\r")) {
30
+ // Strip \r and any erase-line sequences
31
+ const cleaned = text.replace(/^\r/, "").replace(/\x1b\[\d*K/g, "");
32
+ if (cleaned.trim()) {
33
+ panel.updateLastLine(() => cleaned);
34
+ }
35
+ return;
36
+ }
37
+
38
+ // Regular text — may contain newlines
39
+ panel.appendText(text);
40
+ },
41
+ writeLine(line: string): void {
42
+ panel.appendLine(line);
43
+ },
44
+ get columns(): number {
45
+ return panel.computeGeometry().contentW;
46
+ },
47
+ };
48
+ }
22
49
 
23
- export default function activate({ bus, advise, createFloatingPanel, terminalBuffer }: ExtensionContext): void {
24
- const panel = createFloatingPanel({
50
+ export default function activate(ctx: ExtensionContext): void {
51
+ const { bus, registerInstruction, createRemoteSession, terminalBuffer } = ctx;
52
+
53
+ const panel = new FloatingPanel(bus, {
25
54
  trigger: "\x1c", // Ctrl+\
26
55
  dimBackground: true,
27
- autoDismissMs: 2000,
56
+ terminalBuffer: terminalBuffer ?? undefined,
28
57
  });
29
58
 
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
- }
59
+ const panelSurface = createPanelSurface(panel);
60
+ let session: RemoteSession | null = null;
61
+
62
+ registerInstruction("Interactive Overlay Sessions", [
63
+ "When the dynamic context includes `interactive-session: true`, the user has summoned you",
64
+ "via a hotkey overlay from inside their live terminal. They may be in the middle of using",
65
+ "a program (vim, ssh, a REPL, etc.) or at a shell prompt. In this mode:",
66
+ "- Start with terminal_read if you need to understand what's on screen.",
67
+ "- Prefer terminal_keys to interact with whatever is currently running.",
68
+ "- Use user_shell only for running new, standalone commands — not for interacting with",
69
+ " what's already on screen.",
70
+ "- Keep responses concise — the user is in the middle of a workflow.",
71
+ ].join("\n"));
72
+
73
+ // ── Panel lifecycle ────────────────────────────────────────────
36
74
 
37
- // ── Panel lifecycle ────────────────────────────────────────
38
75
  panel.handlers.advise("panel:submit", (_next, query: string) => {
76
+ if (!session) {
77
+ session = createRemoteSession({
78
+ surface: panelSurface,
79
+ suppressQueryBox: true,
80
+ interactive: true,
81
+ });
82
+ }
39
83
  panel.setActive();
40
- panel.appendLine(`${CYAN}${BOLD}❯${RESET} ${query}`);
41
- panel.appendLine("");
42
- bus.emit("agent:submit", { query });
84
+ session.submit(query);
43
85
  });
44
86
 
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
- }
87
+ panel.handlers.advise("panel:show", (_next) => {
88
+ // Re-establish session if panel is shown while agent is still working
89
+ if (panel.active && !session) {
90
+ session = createRemoteSession({
91
+ surface: panelSurface,
92
+ suppressQueryBox: true,
93
+ interactive: true,
94
+ });
52
95
  }
53
96
  });
54
97
 
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);
98
+ // On dismiss: close session only if agent is not actively processing.
99
+ // If agent is still working (phase="active"), keep session alive so
100
+ // output buffers in the panel and agent can keep executing tools.
101
+ panel.handlers.advise("panel:dismiss", (next) => {
102
+ next();
103
+ if (session && !panel.processing) {
104
+ session.close();
105
+ session = null;
106
+ }
64
107
  });
65
108
 
66
109
  bus.on("agent:processing-done", () => {
67
110
  if (!panel.active) return;
68
111
  panel.setDone();
112
+ // If panel was hidden while processing (passthrough), setDone()
113
+ // triggers dismiss() which closes the session above.
114
+ // If panel is still visible, session stays for the follow-up prompt.
69
115
  });
70
116
  }