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
@@ -78,10 +78,11 @@ export function toNuclearEntries(messages, startSeq, instanceId) {
78
78
  // ── Formatting ────────────────────────────────────────────────────
79
79
  /** Format a nuclear entry as a display line (for in-context injection). */
80
80
  export function formatNuclearLine(entry) {
81
- const time = new Date(entry.ts).toLocaleTimeString("en-US", {
82
- hour: "2-digit", minute: "2-digit", hour12: false,
83
- });
84
- return `#${entry.seq} ${time} ${entry.sum}`;
81
+ const d = new Date(entry.ts);
82
+ const pad = (n) => String(n).padStart(2, "0");
83
+ // ISO-ish compact: 2026-04-13 14:05
84
+ const stamp = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
85
+ return `#${entry.seq} [${stamp}] ${entry.sum}`;
85
86
  }
86
87
  // ── Serialization (JSONL for history file) ────────────────────────
87
88
  /** Serialize a nuclear entry to a JSONL line. */
@@ -1,14 +1,13 @@
1
- import type { ToolDefinition } from "./types.js";
2
1
  import type { ContextManager } from "../context-manager.js";
3
2
  /**
4
3
  * Static system prompt — identical across all queries, cacheable.
5
4
  * Contains only identity and behavioral instructions.
6
5
  */
7
- export declare const STATIC_SYSTEM_PROMPT = "You are an AI coding assistant embedded in agent-sh, a terminal shell.\nYou have access to the user's shell environment and can read, write, and execute code.\nYou share the user's working directory, environment variables, and shell history.\n\n# Tool Decision Guide\n\nYou have three categories of tools \u2014 choose based on who needs the output and\nwhether the command has lasting effects:\n\n**Scratchpad tools** (bash, read_file, grep, glob, ls, edit_file, write_file):\nUse these to investigate, search, read, and modify files. Output is returned\nto you for reasoning \u2014 the user doesn't see it directly.\n\n**Display** (display):\nUse this to show output to the user in their terminal. The user sees the\noutput directly, but it is NOT returned to you. Use when:\n- The user asks to see something (cat a file, git log, git diff, man page)\n- The output is for the user to read, not for you to process\n\n**Live shell** (user_shell):\nUse this to run complete, non-interactive commands in the user's real shell. Use for:\n- Commands that affect shell state (cd, export, source)\n- Installing packages, starting servers, running builds\n- Any command where the user wants real side effects\n- Set return_output=true only if you need to inspect the result\n\n**Terminal interaction** (terminal_read, terminal_keys):\nUse these to observe and interact with what is currently on the user's terminal screen.\n- terminal_read: see what the user sees (current screen contents, cursor position)\n- terminal_keys: send keystrokes as if the user typed them\nUse for: driving interactive programs (vim, htop, less, ssh, REPLs), answering questions\nabout what's on screen, or typing at the shell prompt when a program is already running.\nDo NOT use user_shell to interact with an already-running program \u2014 use these instead.\n\nDefault to scratchpad tools for your own investigation. Use display when the\nuser is the intended audience. Use user_shell when the command has real effects.\nUse terminal_read/terminal_keys when interacting with what's already on screen.\n\n# Interactive Overlay Sessions\n\nWhen the dynamic context includes `interactive-session: true`, the user has summoned you\nvia a hotkey overlay from inside their live terminal. They may be in the middle of using\na program (vim, ssh, a REPL, etc.) or at a shell prompt. In this mode:\n- Start with terminal_read if you need to understand what's on screen.\n- Prefer terminal_keys to interact with whatever is currently running.\n- Use user_shell only for running new, standalone commands \u2014 not for interacting with\n what's already on screen.\n- Keep responses concise \u2014 the user is in the middle of a workflow.\n\n# Tool Usage Guidelines\n- Use read_file before editing a file you haven't seen\n- Prefer edit_file over write_file for modifying existing files\n- Use grep/glob to find files before reading them\n- Keep bash commands focused; avoid long-running blocking commands\n- Always check command exit codes for errors";
6
+ export declare const STATIC_SYSTEM_PROMPT: string;
8
7
  /**
9
8
  * Build the dynamic context — injected as a user message before each query.
10
- * Contains everything that changes: tools, shell context, conventions, cwd.
9
+ * Contains everything that changes: shell context, conventions, cwd.
11
10
  *
12
- * Runs through the "agent:dynamic-context" pipe so extensions can append.
11
+ * Runs through the "dynamic-context:build" handler so extensions can advise.
13
12
  */
14
- export declare function buildDynamicContext(tools: ToolDefinition[], contextManager: ContextManager, shellBudgetTokens?: number): string;
13
+ export declare function buildDynamicContext(contextManager: ContextManager, shellBudgetTokens?: number): string;
@@ -1,6 +1,9 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { fileURLToPath } from "node:url";
3
4
  import { discoverSkills } from "./skills.js";
5
+ /** Resolve the absolute path to agent-sh's own docs directory. */
6
+ const DOCS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../docs");
4
7
  /** File names to scan for project conventions (checked in order). */
5
8
  const CONVENTION_FILES = ["CLAUDE.md", "AGENT.md"];
6
9
  /**
@@ -36,7 +39,7 @@ function loadConventionFiles(dir) {
36
39
  * Static system prompt — identical across all queries, cacheable.
37
40
  * Contains only identity and behavioral instructions.
38
41
  */
39
- export const STATIC_SYSTEM_PROMPT = `You are an AI coding assistant embedded in agent-sh, a terminal shell.
42
+ export const STATIC_SYSTEM_PROMPT = `You are ash, an AI coding assistant embedded in agent-sh, a terminal shell.
40
43
  You have access to the user's shell environment and can read, write, and execute code.
41
44
  You share the user's working directory, environment variables, and shell history.
42
45
 
@@ -62,46 +65,27 @@ Use this to run complete, non-interactive commands in the user's real shell. Use
62
65
  - Any command where the user wants real side effects
63
66
  - Set return_output=true only if you need to inspect the result
64
67
 
65
- **Terminal interaction** (terminal_read, terminal_keys):
66
- Use these to observe and interact with what is currently on the user's terminal screen.
67
- - terminal_read: see what the user sees (current screen contents, cursor position)
68
- - terminal_keys: send keystrokes as if the user typed them
69
- Use for: driving interactive programs (vim, htop, less, ssh, REPLs), answering questions
70
- about what's on screen, or typing at the shell prompt when a program is already running.
71
- Do NOT use user_shell to interact with an already-running program — use these instead.
72
-
73
68
  Default to scratchpad tools for your own investigation. Use display when the
74
69
  user is the intended audience. Use user_shell when the command has real effects.
75
- Use terminal_read/terminal_keys when interacting with what's already on screen.
76
-
77
- # Interactive Overlay Sessions
78
-
79
- When the dynamic context includes \`interactive-session: true\`, the user has summoned you
80
- via a hotkey overlay from inside their live terminal. They may be in the middle of using
81
- a program (vim, ssh, a REPL, etc.) or at a shell prompt. In this mode:
82
- - Start with terminal_read if you need to understand what's on screen.
83
- - Prefer terminal_keys to interact with whatever is currently running.
84
- - Use user_shell only for running new, standalone commands — not for interacting with
85
- what's already on screen.
86
- - Keep responses concise — the user is in the middle of a workflow.
87
70
 
88
71
  # Tool Usage Guidelines
89
72
  - Use read_file before editing a file you haven't seen
90
73
  - Prefer edit_file over write_file for modifying existing files
91
74
  - Use grep/glob to find files before reading them
92
75
  - Keep bash commands focused; avoid long-running blocking commands
93
- - Always check command exit codes for errors`;
76
+ - Always check command exit codes for errors
77
+
78
+ # Documentation
79
+ agent-sh documentation is available in: ${DOCS_DIR}
80
+ Use read_file on ${DOCS_DIR}/README.md for an index of all docs.`;
94
81
  /**
95
82
  * Build the dynamic context — injected as a user message before each query.
96
- * Contains everything that changes: tools, shell context, conventions, cwd.
83
+ * Contains everything that changes: shell context, conventions, cwd.
97
84
  *
98
- * Runs through the "agent:dynamic-context" pipe so extensions can append.
85
+ * Runs through the "dynamic-context:build" handler so extensions can advise.
99
86
  */
100
- export function buildDynamicContext(tools, contextManager, shellBudgetTokens) {
87
+ export function buildDynamicContext(contextManager, shellBudgetTokens) {
101
88
  const sections = [];
102
- // Tools
103
- sections.push("# Available Tools\n" +
104
- tools.map((t) => `- ${t.name}: ${t.description}`).join("\n"));
105
89
  // Project conventions (CLAUDE.md / AGENT.md)
106
90
  const conventions = loadConventionFiles(contextManager.getCwd());
107
91
  if (conventions.length > 0) {
@@ -8,7 +8,7 @@
8
8
  * The budget accounts for fixed overhead (system prompt, tool definitions,
9
9
  * response reserve) and divides the remaining space by a configurable ratio.
10
10
  */
11
- import { getSettings } from "./settings.js";
11
+ import { getSettings } from "../settings.js";
12
12
  /** Overhead estimates (tokens). */
13
13
  const SYSTEM_PROMPT_OVERHEAD = 800;
14
14
  const DYNAMIC_CONTEXT_OVERHEAD = 500; // conventions, metadata, skills list
@@ -0,0 +1,83 @@
1
+ /**
2
+ * ToolProtocol — abstracts how tools are presented to the LLM and how
3
+ * tool calls are parsed from responses.
4
+ *
5
+ * Two modes:
6
+ * "api" — tools sent via OpenAI tools param, parsed from delta.tool_calls
7
+ * "inline" — tools described as text, tool calls are JSON code blocks
8
+ *
9
+ * The agent loop uses this interface uniformly so the rest of the code
10
+ * doesn't need to know which mode is active.
11
+ */
12
+ import type { ChatCompletionTool } from "../utils/llm-client.js";
13
+ import type { ToolDefinition } from "./types.js";
14
+ import type { ConversationState } from "./conversation-state.js";
15
+ export interface PendingToolCall {
16
+ id: string;
17
+ name: string;
18
+ argumentsJson: string;
19
+ }
20
+ export interface ToolResult {
21
+ callId: string;
22
+ toolName: string;
23
+ content: string;
24
+ isError: boolean;
25
+ }
26
+ /** Streaming filter — strips tool calls from display output. */
27
+ export interface StreamFilter {
28
+ feed(chunk: string): string;
29
+ flush(): string;
30
+ }
31
+ export interface ToolProtocol {
32
+ readonly mode: string;
33
+ /** Tools to pass in the API request's `tools` parameter. undefined = omit. */
34
+ getApiTools(tools: ToolDefinition[]): ChatCompletionTool[] | undefined;
35
+ /** Extra text for dynamic context (tool catalog for inline mode). */
36
+ getToolPrompt(tools: ToolDefinition[]): string;
37
+ /** Extract tool calls from a completed response. */
38
+ extractToolCalls(responseText: string, streamedCalls: PendingToolCall[]): PendingToolCall[];
39
+ /** Rewrite a tool call before execution (e.g., unwrap meta-tool). */
40
+ rewriteToolCall(tc: PendingToolCall): PendingToolCall;
41
+ /** Record the assistant turn in conversation state. */
42
+ recordAssistant(conv: ConversationState, text: string, toolCalls: PendingToolCall[]): void;
43
+ /** Record all tool results for a batch as conversation messages. */
44
+ recordResults(conv: ConversationState, results: ToolResult[]): void;
45
+ /** Create a stream filter for stripping tool calls from display. null = pass-through. */
46
+ createStreamFilter(toolNames: string[]): StreamFilter | null;
47
+ }
48
+ export declare class ApiToolProtocol implements ToolProtocol {
49
+ readonly mode: "api";
50
+ getApiTools(tools: ToolDefinition[]): ChatCompletionTool[] | undefined;
51
+ getToolPrompt(): string;
52
+ extractToolCalls(_text: string, streamedCalls: PendingToolCall[]): PendingToolCall[];
53
+ rewriteToolCall(tc: PendingToolCall): PendingToolCall;
54
+ recordAssistant(conv: ConversationState, text: string, toolCalls: PendingToolCall[]): void;
55
+ recordResults(conv: ConversationState, results: ToolResult[]): void;
56
+ createStreamFilter(): null;
57
+ }
58
+ export declare class InlineToolProtocol implements ToolProtocol {
59
+ readonly mode: "inline";
60
+ private callCounter;
61
+ getApiTools(): undefined;
62
+ getToolPrompt(tools: ToolDefinition[]): string;
63
+ rewriteToolCall(tc: PendingToolCall): PendingToolCall;
64
+ extractToolCalls(text: string, _streamedCalls: PendingToolCall[]): PendingToolCall[];
65
+ recordAssistant(conv: ConversationState, text: string, _toolCalls: PendingToolCall[]): void;
66
+ recordResults(conv: ConversationState, results: ToolResult[]): void;
67
+ createStreamFilter(_toolNames: string[]): StreamFilter;
68
+ }
69
+ export declare class DeferredToolProtocol implements ToolProtocol {
70
+ readonly mode: "deferred";
71
+ private coreNames;
72
+ /** Cached extension tool schemas for arg validation. */
73
+ private extSchemas;
74
+ constructor(coreNames: string[]);
75
+ getApiTools(tools: ToolDefinition[]): ChatCompletionTool[] | undefined;
76
+ getToolPrompt(): string;
77
+ extractToolCalls(_text: string, streamedCalls: PendingToolCall[]): PendingToolCall[];
78
+ rewriteToolCall(tc: PendingToolCall): PendingToolCall;
79
+ recordAssistant(conv: ConversationState, text: string, toolCalls: PendingToolCall[]): void;
80
+ recordResults(conv: ConversationState, results: ToolResult[]): void;
81
+ createStreamFilter(): null;
82
+ }
83
+ export declare function createToolProtocol(mode: "api" | "inline" | "deferred"): ToolProtocol;
@@ -0,0 +1,386 @@
1
+ // ── API mode (current behavior) ──────────────────────────────────
2
+ export class ApiToolProtocol {
3
+ mode = "api";
4
+ getApiTools(tools) {
5
+ if (tools.length === 0)
6
+ return undefined;
7
+ return tools.map((t) => ({
8
+ type: "function",
9
+ function: {
10
+ name: t.name,
11
+ description: t.description,
12
+ parameters: t.input_schema,
13
+ },
14
+ }));
15
+ }
16
+ getToolPrompt() {
17
+ return "";
18
+ }
19
+ extractToolCalls(_text, streamedCalls) {
20
+ return streamedCalls;
21
+ }
22
+ rewriteToolCall(tc) {
23
+ return tc;
24
+ }
25
+ recordAssistant(conv, text, toolCalls) {
26
+ const calls = toolCalls.length
27
+ ? toolCalls.map((tc) => ({
28
+ id: tc.id,
29
+ function: { name: tc.name, arguments: tc.argumentsJson },
30
+ }))
31
+ : undefined;
32
+ conv.addAssistantMessage(text || null, calls);
33
+ }
34
+ recordResults(conv, results) {
35
+ for (const r of results) {
36
+ const content = r.isError ? `Error: ${r.content}` : r.content;
37
+ conv.addToolResult(r.callId, content);
38
+ }
39
+ }
40
+ createStreamFilter() {
41
+ return null;
42
+ }
43
+ }
44
+ // ── Inline mode (JSON code block tool calls) ─────────────────────
45
+ export class InlineToolProtocol {
46
+ mode = "inline";
47
+ callCounter = 0;
48
+ getApiTools() {
49
+ return undefined;
50
+ }
51
+ getToolPrompt(tools) {
52
+ if (tools.length === 0)
53
+ return "";
54
+ const lines = [
55
+ "",
56
+ "# Tools",
57
+ "",
58
+ "To call a tool, write a ```tool fenced block with JSON:",
59
+ "",
60
+ "```tool",
61
+ '{"tool": "grep", "pattern": "TODO", "path": "src/"}',
62
+ "```",
63
+ "",
64
+ "The `tool` field selects which tool. All other fields are arguments.",
65
+ "Multiple tool blocks allowed per response.",
66
+ "",
67
+ "Available: " + tools.map((t) => `${t.name}${formatParams(t.input_schema)}`).join(", "),
68
+ ];
69
+ return lines.join("\n");
70
+ }
71
+ rewriteToolCall(tc) {
72
+ return tc;
73
+ }
74
+ extractToolCalls(text, _streamedCalls) {
75
+ const calls = [];
76
+ // Match ```tool ... ``` blocks
77
+ const regex = /```tool\s*\n([\s\S]*?)```/g;
78
+ let match;
79
+ while ((match = regex.exec(text)) !== null) {
80
+ const body = match[1].trim();
81
+ try {
82
+ const obj = JSON.parse(body);
83
+ const name = obj.tool;
84
+ if (typeof name !== "string")
85
+ continue;
86
+ // Separate tool name from args
87
+ const { tool: _, ...args } = obj;
88
+ calls.push({
89
+ id: `inline_${++this.callCounter}`,
90
+ name,
91
+ argumentsJson: JSON.stringify(args),
92
+ });
93
+ }
94
+ catch {
95
+ // Not valid JSON — skip
96
+ }
97
+ }
98
+ return calls;
99
+ }
100
+ recordAssistant(conv, text, _toolCalls) {
101
+ conv.addAssistantMessage(text || null);
102
+ }
103
+ recordResults(conv, results) {
104
+ if (results.length === 0)
105
+ return;
106
+ const parts = results.map((r) => {
107
+ const status = r.isError ? "error" : "ok";
108
+ return `[${r.toolName} ${r.callId} ${status}]\n${r.content}`;
109
+ });
110
+ conv.addToolResultInline(parts.join("\n\n"));
111
+ }
112
+ createStreamFilter(_toolNames) {
113
+ return new CodeBlockFilter();
114
+ }
115
+ }
116
+ // ── Code block stream filter ────────────────────────────────────
117
+ /**
118
+ * Strips ```tool ... ``` blocks from streamed text.
119
+ * Simple state machine: normal → in_fence → normal.
120
+ */
121
+ class CodeBlockFilter {
122
+ buf = "";
123
+ inFence = false;
124
+ lastEmittedNewlines = 0; // track trailing newlines to collapse blanks
125
+ feed(chunk) {
126
+ this.buf += chunk;
127
+ let raw = "";
128
+ while (this.buf.length > 0) {
129
+ if (this.inFence) {
130
+ // Look for closing ```
131
+ const closeIdx = this.buf.indexOf("```");
132
+ if (closeIdx !== -1) {
133
+ // Skip past closing ``` and any trailing whitespace on that line
134
+ let end = closeIdx + 3;
135
+ while (end < this.buf.length && this.buf[end] === "\n")
136
+ end++;
137
+ this.buf = this.buf.slice(end);
138
+ this.inFence = false;
139
+ continue;
140
+ }
141
+ // No closing yet — keep buffering
142
+ break;
143
+ }
144
+ // Look for opening ```tool
145
+ const openIdx = this.buf.indexOf("```tool");
146
+ if (openIdx !== -1) {
147
+ // Emit everything before the fence, trimming trailing newline
148
+ let before = this.buf.slice(0, openIdx);
149
+ if (before.endsWith("\n"))
150
+ before = before.slice(0, -1);
151
+ raw += before;
152
+ this.buf = this.buf.slice(openIdx + 7); // skip ```tool
153
+ this.inFence = true;
154
+ continue;
155
+ }
156
+ // Stray ``` on its own line (residual closing fence)
157
+ const strayIdx = this.buf.indexOf("```");
158
+ if (strayIdx !== -1) {
159
+ // Check if it's just backticks on a line (possibly with whitespace)
160
+ const lineStart = this.buf.lastIndexOf("\n", strayIdx - 1) + 1;
161
+ const lineEnd = this.buf.indexOf("\n", strayIdx);
162
+ const line = this.buf.slice(lineStart, lineEnd === -1 ? undefined : lineEnd).trim();
163
+ if (line === "```") {
164
+ raw += this.buf.slice(0, lineStart);
165
+ this.buf = this.buf.slice(lineEnd === -1 ? this.buf.length : lineEnd + 1);
166
+ continue;
167
+ }
168
+ }
169
+ // Could be a partial match at the end
170
+ const marker = "```tool";
171
+ let partial = false;
172
+ for (let i = Math.min(marker.length - 1, this.buf.length); i >= 1; i--) {
173
+ if (this.buf.endsWith(marker.slice(0, i))) {
174
+ raw += this.buf.slice(0, this.buf.length - i);
175
+ this.buf = this.buf.slice(this.buf.length - i);
176
+ partial = true;
177
+ break;
178
+ }
179
+ }
180
+ if (partial)
181
+ break;
182
+ // No fence anywhere — emit all
183
+ raw += this.buf;
184
+ this.buf = "";
185
+ }
186
+ // Collapse runs of 3+ newlines into 2 (one blank line max)
187
+ return this.collapseNewlines(raw);
188
+ }
189
+ flush() {
190
+ const out = this.collapseNewlines(this.buf);
191
+ this.buf = "";
192
+ this.inFence = false;
193
+ return out;
194
+ }
195
+ collapseNewlines(text) {
196
+ if (!text)
197
+ return text;
198
+ // Count leading newlines and merge with trailing from last emit
199
+ let i = 0;
200
+ while (i < text.length && text[i] === "\n")
201
+ i++;
202
+ const leading = i;
203
+ const totalNewlines = this.lastEmittedNewlines + leading;
204
+ // Allow at most 2 consecutive newlines
205
+ let prefix = "";
206
+ if (leading > 0) {
207
+ const allowed = Math.max(0, 2 - this.lastEmittedNewlines);
208
+ prefix = "\n".repeat(Math.min(leading, allowed));
209
+ text = text.slice(leading);
210
+ }
211
+ // Collapse internal runs
212
+ text = text.replace(/\n{3,}/g, "\n\n");
213
+ // Track trailing newlines for next call
214
+ let trailing = 0;
215
+ let j = text.length;
216
+ while (j > 0 && text[j - 1] === "\n") {
217
+ j--;
218
+ trailing++;
219
+ }
220
+ this.lastEmittedNewlines = trailing > 0 ? trailing : (prefix ? totalNewlines - leading + prefix.length : 0);
221
+ return prefix + text;
222
+ }
223
+ }
224
+ // ── Helpers ──────────────────────────────────────────────────────
225
+ function formatParams(schema) {
226
+ const props = schema.properties;
227
+ if (!props || Object.keys(props).length === 0)
228
+ return "()";
229
+ const required = new Set(schema.required ?? []);
230
+ const params = Object.entries(props).map(([name, prop]) => {
231
+ const opt = required.has(name) ? "" : "?";
232
+ const enumVals = prop.enum;
233
+ if (enumVals)
234
+ return `${name}${opt}: ${enumVals.join("|")}`;
235
+ return `${name}${opt}`;
236
+ });
237
+ return `(${params.join(", ")})`;
238
+ }
239
+ // ── Deferred mode (core tools full schema, extensions via meta-tool) ──
240
+ const META_TOOL_NAME = "use_extension";
241
+ export class DeferredToolProtocol {
242
+ mode = "deferred";
243
+ coreNames;
244
+ /** Cached extension tool schemas for arg validation. */
245
+ extSchemas = new Map();
246
+ constructor(coreNames) {
247
+ this.coreNames = new Set(coreNames);
248
+ }
249
+ getApiTools(tools) {
250
+ const core = tools.filter((t) => this.coreNames.has(t.name));
251
+ const ext = tools.filter((t) => !this.coreNames.has(t.name));
252
+ // Cache extension schemas for validation in rewriteToolCall
253
+ this.extSchemas.clear();
254
+ for (const t of ext) {
255
+ this.extSchemas.set(t.name, t.input_schema);
256
+ }
257
+ const apiTools = core.map((t) => ({
258
+ type: "function",
259
+ function: {
260
+ name: t.name,
261
+ description: t.description,
262
+ parameters: t.input_schema,
263
+ },
264
+ }));
265
+ if (ext.length > 0) {
266
+ const catalog = ext
267
+ .map((t) => `${t.name}${formatParams(t.input_schema)}`)
268
+ .join(", ");
269
+ apiTools.push({
270
+ type: "function",
271
+ function: {
272
+ name: META_TOOL_NAME,
273
+ description: `Call an extension tool. Available: ${catalog}`,
274
+ parameters: {
275
+ type: "object",
276
+ properties: {
277
+ name: { type: "string", description: "Tool name to call" },
278
+ args: {
279
+ type: "object",
280
+ description: "Tool arguments",
281
+ properties: {},
282
+ additionalProperties: true,
283
+ },
284
+ },
285
+ required: ["name"],
286
+ },
287
+ },
288
+ });
289
+ }
290
+ return apiTools.length > 0 ? apiTools : undefined;
291
+ }
292
+ getToolPrompt() {
293
+ return "";
294
+ }
295
+ extractToolCalls(_text, streamedCalls) {
296
+ return streamedCalls;
297
+ }
298
+ rewriteToolCall(tc) {
299
+ if (tc.name !== META_TOOL_NAME)
300
+ return tc;
301
+ // Unwrap: use_extension(name="foo", args={...}) → foo({...})
302
+ try {
303
+ const parsed = JSON.parse(tc.argumentsJson);
304
+ const targetName = parsed.name;
305
+ const targetArgs = (parsed.args ?? {});
306
+ // Validate: does the extension exist?
307
+ const schema = this.extSchemas.get(targetName);
308
+ if (!schema) {
309
+ const available = [...this.extSchemas.keys()].join(", ");
310
+ return {
311
+ id: tc.id,
312
+ name: META_TOOL_NAME,
313
+ argumentsJson: JSON.stringify({
314
+ _error: `Unknown extension "${targetName}". Available: ${available}`,
315
+ }),
316
+ };
317
+ }
318
+ // Validate: check for unknown/missing params against schema
319
+ const schemaProps = schema.properties;
320
+ const requiredParams = new Set(schema.required ?? []);
321
+ if (schemaProps) {
322
+ const validParams = new Set(Object.keys(schemaProps));
323
+ const providedParams = Object.keys(targetArgs);
324
+ // Check for unknown params (likely wrong names)
325
+ const unknown = providedParams.filter((p) => !validParams.has(p));
326
+ // Check for missing required params
327
+ const missing = [...requiredParams].filter((p) => !targetArgs[p]);
328
+ if (unknown.length > 0 || missing.length > 0) {
329
+ const expected = [...validParams]
330
+ .map((p) => `${p}${requiredParams.has(p) ? " (required)" : ""}`)
331
+ .join(", ");
332
+ let hint = `Wrong arguments for "${targetName}". Expected params: ${expected}.`;
333
+ if (unknown.length > 0)
334
+ hint += ` Unknown: ${unknown.join(", ")}.`;
335
+ if (missing.length > 0)
336
+ hint += ` Missing: ${missing.join(", ")}.`;
337
+ return {
338
+ id: tc.id,
339
+ name: META_TOOL_NAME,
340
+ argumentsJson: JSON.stringify({ _error: hint }),
341
+ };
342
+ }
343
+ }
344
+ return {
345
+ id: tc.id,
346
+ name: targetName,
347
+ argumentsJson: JSON.stringify(targetArgs),
348
+ };
349
+ }
350
+ catch {
351
+ return tc; // Let it fail naturally downstream
352
+ }
353
+ }
354
+ recordAssistant(conv, text, toolCalls) {
355
+ const calls = toolCalls.length
356
+ ? toolCalls.map((tc) => ({
357
+ id: tc.id,
358
+ function: { name: tc.name, arguments: tc.argumentsJson },
359
+ }))
360
+ : undefined;
361
+ conv.addAssistantMessage(text || null, calls);
362
+ }
363
+ recordResults(conv, results) {
364
+ for (const r of results) {
365
+ const content = r.isError ? `Error: ${r.content}` : r.content;
366
+ conv.addToolResult(r.callId, content);
367
+ }
368
+ }
369
+ createStreamFilter() {
370
+ return null;
371
+ }
372
+ }
373
+ // ── Factory ─────────────────────────────────────────────────────
374
+ /** Core tool names — always sent with full schema. */
375
+ const CORE_TOOLS = [
376
+ "bash", "read_file", "write_file", "edit_file",
377
+ "grep", "glob", "ls", "user_shell", "display",
378
+ "list_skills", "conversation_recall",
379
+ ];
380
+ export function createToolProtocol(mode) {
381
+ if (mode === "inline")
382
+ return new InlineToolProtocol();
383
+ if (mode === "deferred")
384
+ return new DeferredToolProtocol(CORE_TOOLS);
385
+ return new ApiToolProtocol();
386
+ }
@@ -47,13 +47,33 @@ export interface ToolDisplayInfo {
47
47
  * icon + detail only. When absent, the tool name is shown alongside the detail. */
48
48
  icon?: string;
49
49
  }
50
+ /** Interactive UI session — imperative control over rendering + input. */
51
+ export interface InteractiveSession<T> {
52
+ /** Return lines to render. Called on mount and after each input. */
53
+ render(width: number): string[];
54
+ /** Handle raw input. Call done(result) to finish the session. */
55
+ handleInput(data: string, done: (result: T) => void): void;
56
+ /** Called when session starts. Receives invalidate() for async re-renders. */
57
+ onMount?(invalidate: () => void): void;
58
+ /** Called when session ends (cleanup). */
59
+ onUnmount?(): void;
60
+ }
61
+ /** Interactive UI capability passed to tools during execution. */
62
+ export interface ToolUI {
63
+ /** Present a custom interactive UI and wait for the user's response. */
64
+ custom<T>(session: InteractiveSession<T>): Promise<T>;
65
+ }
66
+ /** Context passed to tool execute() as optional third parameter. */
67
+ export interface ToolExecutionContext {
68
+ ui: ToolUI;
69
+ }
50
70
  export interface ToolDefinition {
51
71
  name: string;
52
72
  /** Short label for TUI display (e.g. "search" instead of "ads_search"). Defaults to name. */
53
73
  displayName?: string;
54
74
  description: string;
55
75
  input_schema: Record<string, unknown>;
56
- execute(args: Record<string, unknown>, onChunk?: (chunk: string) => void): Promise<ToolResult>;
76
+ execute(args: Record<string, unknown>, onChunk?: (chunk: string) => void, ctx?: ToolExecutionContext): Promise<ToolResult>;
57
77
  /** Whether to stream tool output to the TUI (default: true). */
58
78
  showOutput?: boolean;
59
79
  /** Whether this tool may modify files — triggers file watcher (default: false). */
package/dist/core.d.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Core kernel — the minimum viable agent-sh.
3
3
  *
4
- * Wires up EventBus + ContextManager + AgentBackend without any frontend.
4
+ * Wires up EventBus + ContextManager without any frontend or agent backend.
5
5
  * Consumers attach their own I/O (Shell, WebSocket, REST, tests) by
6
6
  * subscribing to bus events.
7
7
  *
8
- * The default backend (AgentLoop) is created eagerly but wired lazily —
9
- * extensions can register alternative backends via agent:register-backend
10
- * before activateBackend() is called.
8
+ * Agent backends are loaded as extensions and register themselves via
9
+ * the agent:register-backend bus event. The built-in "ash" backend is
10
+ * loaded from src/extensions/agent-backend.ts.
11
11
  *
12
12
  * Usage:
13
13
  * import { createCore } from "agent-sh";
@@ -18,8 +18,8 @@
18
18
  */
19
19
  import { EventBus } from "./event-bus.js";
20
20
  import { ContextManager } from "./context-manager.js";
21
- import { LlmClient } from "./utils/llm-client.js";
22
21
  import type { AgentShellConfig, ExtensionContext } from "./types.js";
22
+ import { HandlerRegistry } from "./utils/handler-registry.js";
23
23
  export { EventBus } from "./event-bus.js";
24
24
  export type { ShellEvents } from "./event-bus.js";
25
25
  export type { AgentShellConfig, ExtensionContext } from "./types.js";
@@ -31,8 +31,8 @@ export { LlmClient } from "./utils/llm-client.js";
31
31
  export interface AgentShellCore {
32
32
  bus: EventBus;
33
33
  contextManager: ContextManager;
34
- /** LLM client for fast-path features (null when no provider configured). */
35
- llmClient: LlmClient | null;
34
+ /** Handler registry for define/advise/call. */
35
+ handlers: HandlerRegistry;
36
36
  /** Activate the agent backend (call after extensions load). */
37
37
  activateBackend(): void;
38
38
  /** Convenience: emit agent:submit and await the response. */