agent-sh 0.15.0 → 0.15.2

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 (124) hide show
  1. package/dist/agent/agent-loop.js +11 -8
  2. package/dist/agent/events.d.ts +4 -0
  3. package/docs/README.md +14 -0
  4. package/docs/agent.md +398 -0
  5. package/docs/architecture.md +196 -0
  6. package/docs/context-management.md +200 -0
  7. package/docs/extensions.md +951 -0
  8. package/docs/library.md +84 -0
  9. package/docs/troubleshooting.md +65 -0
  10. package/docs/tui-composition.md +294 -0
  11. package/docs/usage.md +306 -0
  12. package/examples/extensions/ash-scheme/package.json +1 -1
  13. package/examples/extensions/ashi/EXTENDING.md +2 -2
  14. package/examples/extensions/ashi/README.md +2 -2
  15. package/examples/extensions/ashi/docs/ui-surface-protocol.md +1 -1
  16. package/examples/extensions/ashi/package.json +5 -3
  17. package/examples/extensions/ashi/src/chat/tool-group.ts +3 -2
  18. package/examples/extensions/ashi/src/cli.ts +9 -8
  19. package/examples/extensions/ashi/src/dialogs.ts +16 -1
  20. package/examples/extensions/ashi/src/events.ts +1 -0
  21. package/examples/extensions/ashi/src/frontend.ts +26 -6
  22. package/examples/extensions/ashi/src/renderer.ts +24 -4
  23. package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +4 -3
  24. package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +5 -8
  25. package/examples/extensions/ashi/src/ui.ts +11 -0
  26. package/examples/extensions/ashi-ink/package.json +2 -2
  27. package/examples/extensions/claude-code-bridge/package.json +1 -1
  28. package/examples/extensions/opencode-bridge/package.json +1 -1
  29. package/package.json +3 -1
  30. package/src/agent/agent-loop.ts +1566 -0
  31. package/src/agent/entry-format.ts +19 -0
  32. package/src/agent/events.ts +153 -0
  33. package/src/agent/extensions/rolling-history/constants.ts +1 -0
  34. package/src/agent/extensions/rolling-history/index.ts +202 -0
  35. package/src/agent/extensions/rolling-history/recall.ts +131 -0
  36. package/src/agent/extensions/rolling-history/strategy.ts +404 -0
  37. package/src/agent/host-types.ts +192 -0
  38. package/src/agent/index.ts +591 -0
  39. package/src/agent/live-view.ts +279 -0
  40. package/src/agent/llm-client.ts +111 -0
  41. package/src/agent/llm-facade.ts +43 -0
  42. package/src/agent/normalize-args.ts +61 -0
  43. package/src/agent/nuclear-form.ts +382 -0
  44. package/src/agent/providers/deepseek.ts +39 -0
  45. package/src/agent/providers/ollama.ts +92 -0
  46. package/src/agent/providers/openai-compatible.ts +36 -0
  47. package/src/agent/providers/openai.ts +52 -0
  48. package/src/agent/providers/opencode.ts +142 -0
  49. package/src/agent/providers/openrouter.ts +105 -0
  50. package/src/agent/providers/zai-coding-plan.ts +33 -0
  51. package/src/agent/session-store.ts +336 -0
  52. package/src/agent/skills.ts +228 -0
  53. package/src/agent/store.ts +310 -0
  54. package/src/agent/subagent.ts +305 -0
  55. package/src/agent/system-prompt.ts +151 -0
  56. package/src/agent/token-budget.ts +12 -0
  57. package/src/agent/tool-protocol.ts +722 -0
  58. package/src/agent/tool-registry.ts +66 -0
  59. package/src/agent/tools/bash.ts +95 -0
  60. package/src/agent/tools/edit-file.ts +154 -0
  61. package/src/agent/tools/expand-home.ts +7 -0
  62. package/src/agent/tools/glob.ts +108 -0
  63. package/src/agent/tools/grep.ts +228 -0
  64. package/src/agent/tools/list-skills.ts +37 -0
  65. package/src/agent/tools/ls.ts +81 -0
  66. package/src/agent/tools/pwsh.ts +140 -0
  67. package/src/agent/tools/read-file.ts +164 -0
  68. package/src/agent/tools/write-file.ts +72 -0
  69. package/src/agent/types.ts +149 -0
  70. package/src/cli/args.ts +91 -0
  71. package/src/cli/auth/cli.ts +244 -0
  72. package/src/cli/auth/discover.ts +52 -0
  73. package/src/cli/auth/keys.ts +143 -0
  74. package/src/cli/index.ts +295 -0
  75. package/src/cli/init.ts +74 -0
  76. package/src/cli/install.ts +439 -0
  77. package/src/cli/shell-env.ts +68 -0
  78. package/src/cli/subcommands.ts +24 -0
  79. package/src/core/event-bus.ts +252 -0
  80. package/src/core/extension-loader.ts +347 -0
  81. package/src/core/index.ts +152 -0
  82. package/src/core/settings.ts +398 -0
  83. package/src/core/types.ts +61 -0
  84. package/src/extensions/file-autocomplete.ts +71 -0
  85. package/src/extensions/index.ts +38 -0
  86. package/src/extensions/slash-commands/events.ts +14 -0
  87. package/src/extensions/slash-commands/index.ts +269 -0
  88. package/src/shell/events.ts +73 -0
  89. package/src/shell/host-types.ts +150 -0
  90. package/src/shell/index.ts +159 -0
  91. package/src/shell/input-handler.ts +505 -0
  92. package/src/shell/output-parser.ts +156 -0
  93. package/src/shell/shell-context.ts +193 -0
  94. package/src/shell/shell.ts +414 -0
  95. package/src/shell/strategies/bash.ts +83 -0
  96. package/src/shell/strategies/fish.ts +77 -0
  97. package/src/shell/strategies/index.ts +24 -0
  98. package/src/shell/strategies/types.ts +64 -0
  99. package/src/shell/strategies/zsh.ts +92 -0
  100. package/src/shell/terminal.ts +124 -0
  101. package/src/shell/tui-input-view.ts +222 -0
  102. package/src/shell/tui-renderer.ts +1126 -0
  103. package/src/utils/ansi.ts +140 -0
  104. package/src/utils/box-frame.ts +138 -0
  105. package/src/utils/compositor.ts +157 -0
  106. package/src/utils/diff-renderer.ts +829 -0
  107. package/src/utils/diff.ts +244 -0
  108. package/src/utils/executor.ts +305 -0
  109. package/src/utils/file-watcher.ts +110 -0
  110. package/src/utils/floating-panel.ts +1160 -0
  111. package/src/utils/handler-registry.ts +110 -0
  112. package/src/utils/line-editor.ts +636 -0
  113. package/src/utils/markdown.ts +437 -0
  114. package/src/utils/message-utils.ts +113 -0
  115. package/src/utils/package-version.ts +12 -0
  116. package/src/utils/palette.ts +64 -0
  117. package/src/utils/ref-counter.ts +9 -0
  118. package/src/utils/ripgrep-path.ts +17 -0
  119. package/src/utils/shell-output-spill.ts +76 -0
  120. package/src/utils/stream-transform.ts +292 -0
  121. package/src/utils/terminal-buffer.ts +213 -0
  122. package/src/utils/tool-display.ts +315 -0
  123. package/src/utils/tool-interactive.ts +71 -0
  124. package/src/utils/tty.ts +14 -0
@@ -0,0 +1,37 @@
1
+ import type { ToolDefinition } from "../types.js";
2
+ import { discoverSkills } from "../skills.js";
3
+
4
+ export function createListSkillsTool(getCwd: () => string): ToolDefinition {
5
+ return {
6
+ name: "list_skills",
7
+ description:
8
+ "List available skills. Use read_file on a skill's path to load its full instructions.",
9
+ input_schema: {
10
+ type: "object",
11
+ properties: {},
12
+ },
13
+
14
+ showOutput: false,
15
+
16
+ async execute() {
17
+ const skills = discoverSkills(getCwd());
18
+ if (skills.length === 0) {
19
+ return {
20
+ content: "No skills found.",
21
+ exitCode: 0,
22
+ isError: false,
23
+ };
24
+ }
25
+
26
+ const lines = skills.map(
27
+ (s) => `${s.name} ${s.filePath}\n ${s.description}`,
28
+ );
29
+
30
+ return {
31
+ content: lines.join("\n\n"),
32
+ exitCode: 0,
33
+ isError: false,
34
+ };
35
+ },
36
+ };
37
+ }
@@ -0,0 +1,81 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { contentText, type ToolDefinition } from "../types.js";
4
+ import { expandHome } from "./expand-home.js";
5
+
6
+ function formatSize(bytes: number): string {
7
+ if (bytes < 1024) return `${bytes}B`;
8
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`;
9
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
10
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`;
11
+ }
12
+
13
+ export function createLsTool(getCwd: () => string): ToolDefinition {
14
+ return {
15
+ name: "ls",
16
+ description:
17
+ "List files and directories with timestamps and sizes. " +
18
+ "Use for exploring a single directory. Use glob for recursive file search by pattern.",
19
+ input_schema: {
20
+ type: "object",
21
+ properties: {
22
+ path: {
23
+ type: "string",
24
+ description: "Directory to list (default: cwd)",
25
+ },
26
+ },
27
+ },
28
+
29
+ showOutput: false,
30
+
31
+ getDisplayInfo: (args) => ({
32
+ kind: "read",
33
+ icon: "◆",
34
+ locations: args.path
35
+ ? [{ path: args.path as string }]
36
+ : [],
37
+ }),
38
+
39
+ formatResult: (_args, result) => {
40
+ const text = contentText(result.content);
41
+ if (result.isError || text === "(empty directory)") return { summary: "0 entries" };
42
+ const lines = text.split("\n").filter(Boolean);
43
+ return { summary: `${lines.length} entries` };
44
+ },
45
+
46
+ async execute(args) {
47
+ const dirPath = expandHome((args.path as string) ?? ".");
48
+ const absPath = path.resolve(getCwd(), dirPath);
49
+
50
+ try {
51
+ const entries = await fs.readdir(absPath, {
52
+ withFileTypes: true,
53
+ });
54
+
55
+ const items = await Promise.all(
56
+ entries.map(async (e) => {
57
+ const fullPath = path.join(absPath, e.name);
58
+ try {
59
+ const stat = await fs.stat(fullPath);
60
+ const size = e.isDirectory() ? "-" : formatSize(stat.size);
61
+ const mtime = stat.mtime.toISOString().slice(0, 16).replace("T", " ");
62
+ return `${mtime} ${size.padStart(8)} ${e.isDirectory() ? e.name + "/" : e.name}`;
63
+ } catch {
64
+ return `${"?".padStart(16)} ${"?".padStart(8)} ${e.isDirectory() ? e.name + "/" : e.name}`;
65
+ }
66
+ }),
67
+ );
68
+
69
+ return {
70
+ content: items.join("\n") || "(empty directory)",
71
+ exitCode: 0,
72
+ isError: false,
73
+ };
74
+ } catch (err) {
75
+ const msg =
76
+ err instanceof Error ? err.message : String(err);
77
+ return { content: `Error: ${msg}`, exitCode: 1, isError: true };
78
+ }
79
+ },
80
+ };
81
+ }
@@ -0,0 +1,140 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { executeArgv, killSession } from "../../utils/executor.js";
3
+ import type { EventBus } from "../../core/event-bus.js";
4
+ import type { ToolDefinition } from "../types.js";
5
+
6
+ let cachedPwshPath: string | null | undefined;
7
+
8
+ /** Resolve a usable PowerShell binary, or null if none is on PATH.
9
+ * Prefers PowerShell 7+ (`pwsh`), falls back to Windows PowerShell (`powershell`). */
10
+ function findPwsh(): string | null {
11
+ if (cachedPwshPath !== undefined) return cachedPwshPath;
12
+
13
+ // Prefer PowerShell 7 (pwsh)
14
+ const pwsh = spawnSync("where", ["pwsh"], { encoding: "utf-8" });
15
+ if (pwsh.status === 0) {
16
+ cachedPwshPath = pwsh.stdout.split(/\r?\n/)[0]!.trim() || null;
17
+ if (cachedPwshPath) return cachedPwshPath;
18
+ }
19
+
20
+ // Fallback to Windows PowerShell (powershell.exe)
21
+ const ps = spawnSync("where", ["powershell"], { encoding: "utf-8" });
22
+ cachedPwshPath = ps.status === 0 ? ps.stdout.split(/\r?\n/)[0]!.trim() || null : null;
23
+ return cachedPwshPath;
24
+ }
25
+
26
+ /** Return the PowerShell executable name for display purposes. */
27
+ function getPwshDisplayName(): string {
28
+ const path = findPwsh();
29
+ if (!path) return "PowerShell";
30
+ return path.toLowerCase().includes("pwsh") ? "pwsh" : "powershell";
31
+ }
32
+
33
+ export function createPwshTool(opts: {
34
+ getCwd: () => string;
35
+ getEnv: () => Record<string, string>;
36
+ bus: EventBus;
37
+ }): ToolDefinition {
38
+ return {
39
+ name: "pwsh",
40
+ description:
41
+ "Execute a PowerShell command in an isolated subprocess. " +
42
+ "Use this on Windows when the `bash` tool fails (no /bin/bash available). " +
43
+ "Use PowerShell syntax — e.g. `Get-ChildItem`, `Select-String`, `$env:HOME`. " +
44
+ "Does not affect the user's shell state. " +
45
+ "cwd is set to the working directory from the shell context. " +
46
+ "Do NOT use pwsh for file searching — use grep/glob instead. " +
47
+ "Do NOT use pwsh for reading files — use read_file instead.",
48
+ input_schema: {
49
+ type: "object",
50
+ properties: {
51
+ command: {
52
+ type: "string",
53
+ description: "The PowerShell command to execute",
54
+ },
55
+ timeout: {
56
+ type: "number",
57
+ description: "Timeout in seconds (default: 60)",
58
+ },
59
+ description: {
60
+ type: "string",
61
+ description:
62
+ "Short description of what this command does (e.g., 'Install dependencies', 'Run test suite')",
63
+ },
64
+ },
65
+ required: ["command"],
66
+ },
67
+
68
+ showOutput: true,
69
+ modifiesFiles: true,
70
+
71
+ getDisplayInfo: () => ({
72
+ kind: "execute",
73
+ icon: "▶",
74
+ locations: [],
75
+ }),
76
+
77
+ async execute(args, onChunk, ctx) {
78
+ const command = args.command as string;
79
+ const timeout = ((args.timeout as number) ?? 60) * 1000;
80
+
81
+ const intercepted = opts.bus.emitPipe("agent:terminal-intercept", {
82
+ command,
83
+ cwd: opts.getCwd(),
84
+ intercepted: false,
85
+ output: "",
86
+ });
87
+ if (intercepted.intercepted) {
88
+ return {
89
+ content: intercepted.output,
90
+ exitCode: 0,
91
+ isError: false,
92
+ };
93
+ }
94
+
95
+ const pwshPath = findPwsh();
96
+ if (!pwshPath) {
97
+ return {
98
+ content: "PowerShell not found on PATH. Neither pwsh (PowerShell 7+) nor powershell (Windows PowerShell) is available.",
99
+ exitCode: 1,
100
+ isError: true,
101
+ };
102
+ }
103
+
104
+ const { session, done } = executeArgv({
105
+ file: pwshPath,
106
+ args: ["-NoProfile", "-NonInteractive", "-Command", command],
107
+ cwd: opts.getCwd(),
108
+ env: opts.getEnv(),
109
+ timeout,
110
+ onOutput: onChunk,
111
+ });
112
+
113
+ const onAbort = () => killSession(session);
114
+ ctx?.signal?.addEventListener("abort", onAbort, { once: true });
115
+ try {
116
+ await done;
117
+ } finally {
118
+ ctx?.signal?.removeEventListener("abort", onAbort);
119
+ }
120
+
121
+ if (session.spawnFailed) {
122
+ return {
123
+ content: `${getPwshDisplayName()} not found on PATH. Install PowerShell 7: winget install Microsoft.PowerShell.`,
124
+ exitCode: 1,
125
+ isError: true,
126
+ };
127
+ }
128
+
129
+ const content = session.truncated
130
+ ? `[output truncated, showing last portion]\n${session.output}`
131
+ : session.output;
132
+
133
+ return {
134
+ content: content || "(no output)",
135
+ exitCode: session.exitCode,
136
+ isError: session.exitCode !== 0,
137
+ };
138
+ },
139
+ };
140
+ }
@@ -0,0 +1,164 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { contentText, type ImageContent, type ToolDefinition } from "../types.js";
4
+ import { expandHome } from "./expand-home.js";
5
+
6
+ /** Tracks the last-read state of a file for deduplication. */
7
+ export interface FileReadState {
8
+ mtimeMs: number;
9
+ offset: number;
10
+ limit: number | undefined;
11
+ }
12
+
13
+ /** Shared cache — keyed by absolute path. */
14
+ export type FileReadCache = Map<string, FileReadState>;
15
+
16
+ const IMAGE_MIME_TYPES: Record<string, string> = {
17
+ ".png": "image/png",
18
+ ".jpg": "image/jpeg",
19
+ ".jpeg": "image/jpeg",
20
+ ".gif": "image/gif",
21
+ ".webp": "image/webp",
22
+ };
23
+
24
+ export function createReadFileTool(
25
+ getCwd: () => string,
26
+ cache?: FileReadCache,
27
+ ): ToolDefinition {
28
+ return {
29
+ name: "read_file",
30
+ description:
31
+ "Read a file's contents with line numbers. Use offset and limit for large files. " +
32
+ "Always read a file before editing it. " +
33
+ "If the file hasn't changed since last read, returns a stub to save context.",
34
+ input_schema: {
35
+ type: "object",
36
+ properties: {
37
+ path: {
38
+ type: "string",
39
+ description: "Absolute or relative file path",
40
+ },
41
+ offset: {
42
+ type: "number",
43
+ description: "Starting line number (1-indexed)",
44
+ },
45
+ limit: {
46
+ type: "number",
47
+ description: "Max lines to read",
48
+ },
49
+ },
50
+ required: ["path"],
51
+ },
52
+
53
+ showOutput: false,
54
+
55
+ getDisplayInfo: (args) => ({
56
+ kind: "read",
57
+ icon: "◆",
58
+ locations: [{ path: args.path as string }],
59
+ }),
60
+
61
+ formatResult: (_args, result) => {
62
+ const text = contentText(result.content);
63
+ if (result.isError) return {};
64
+ if (text.startsWith("File unchanged")) return { summary: "cached" };
65
+ const lines = text.split("\n").filter(l => !l.startsWith("["));
66
+ return { summary: `${lines.length} lines` };
67
+ },
68
+
69
+ async execute(args) {
70
+ const filePath = expandHome(args.path as string);
71
+ const absPath = path.resolve(getCwd(), filePath);
72
+ const reqOffset = (args.offset as number) ?? 1;
73
+ const reqLimit = args.limit as number | undefined;
74
+
75
+ try {
76
+ const stat = await fs.stat(absPath);
77
+
78
+ // Deduplication: if the file hasn't changed and same range, return stub.
79
+ // bypass_cache lets in-process callers consume content as a value
80
+ // rather than a tool_result, where the dedup stub would be meaningless.
81
+ if (cache && !args.bypass_cache) {
82
+ const prev = cache.get(absPath);
83
+ if (
84
+ prev &&
85
+ prev.mtimeMs === stat.mtimeMs &&
86
+ prev.offset === reqOffset &&
87
+ prev.limit === reqLimit
88
+ ) {
89
+ return {
90
+ content:
91
+ "File unchanged since last read. The content from the earlier read_file result in this conversation is still current — refer to that instead of re-reading.",
92
+ exitCode: 0,
93
+ isError: false,
94
+ };
95
+ }
96
+ }
97
+
98
+ // Check file size before reading to avoid OOM on huge files
99
+ const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
100
+ if (stat.size > MAX_FILE_SIZE && !args.offset && !args.limit) {
101
+ const sizeMB = (stat.size / (1024 * 1024)).toFixed(1);
102
+ return {
103
+ content: `File is ${sizeMB}MB (${stat.size} bytes) — too large to read in full. Use offset and limit to read specific sections, e.g. offset=1 limit=200.`,
104
+ exitCode: 1,
105
+ isError: true,
106
+ };
107
+ }
108
+
109
+ const ext = path.extname(absPath).toLowerCase();
110
+ const mimeType = IMAGE_MIME_TYPES[ext];
111
+ if (mimeType) {
112
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5MB — base64 adds ~33%
113
+ if (stat.size > MAX_IMAGE_BYTES) {
114
+ return {
115
+ content: `Image is ${(stat.size / (1024 * 1024)).toFixed(1)}MB — too large. Images are capped at 5MB.`,
116
+ exitCode: 1,
117
+ isError: true,
118
+ };
119
+ }
120
+ const buf = await fs.readFile(absPath);
121
+ const data = buf.toString("base64");
122
+ return {
123
+ content: [{ type: "image", data, mimeType }],
124
+ exitCode: 0,
125
+ isError: false,
126
+ };
127
+ }
128
+
129
+ const content = await fs.readFile(absPath, "utf-8");
130
+ const lines = content.split("\n");
131
+
132
+ const start = reqOffset - 1; // 1-indexed → 0-indexed
133
+ const end = reqLimit ? start + reqLimit : lines.length;
134
+ const slice = lines.slice(start, end);
135
+
136
+ // Add line numbers (1-indexed)
137
+ const numbered = slice
138
+ .map((line, i) => `${start + i + 1}\t${line}`)
139
+ .join("\n");
140
+
141
+ const truncated = end < lines.length;
142
+ const suffix = truncated
143
+ ? `\n[${lines.length - end} more lines, use offset=${end + 1} to continue]`
144
+ : "";
145
+
146
+ // Skip cache write for in-process callers — they shouldn't poison
147
+ // the LLM-facing dedup state.
148
+ if (cache && !args.bypass_cache) {
149
+ cache.set(absPath, {
150
+ mtimeMs: stat.mtimeMs,
151
+ offset: reqOffset,
152
+ limit: reqLimit,
153
+ });
154
+ }
155
+
156
+ return { content: numbered + suffix, exitCode: 0, isError: false };
157
+ } catch (err) {
158
+ const msg =
159
+ err instanceof Error ? err.message : String(err);
160
+ return { content: `Error: ${msg}`, exitCode: 1, isError: true };
161
+ }
162
+ },
163
+ };
164
+ }
@@ -0,0 +1,72 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import type { ToolDefinition } from "../types.js";
4
+ import { computeDiff } from "../../utils/diff.js";
5
+ import { expandHome } from "./expand-home.js";
6
+
7
+ export function createWriteFileTool(getCwd: () => string): ToolDefinition {
8
+ return {
9
+ name: "write_file",
10
+ description:
11
+ "Create a new file or completely overwrite an existing one. Creates parent directories if needed. " +
12
+ "ALWAYS prefer edit_file for modifying existing files — only use write_file for new files or complete rewrites.",
13
+ input_schema: {
14
+ type: "object",
15
+ properties: {
16
+ path: {
17
+ type: "string",
18
+ description: "Absolute or relative file path",
19
+ },
20
+ content: {
21
+ type: "string",
22
+ description: "File content to write",
23
+ },
24
+ },
25
+ required: ["path", "content"],
26
+ },
27
+
28
+ showOutput: true,
29
+ modifiesFiles: true,
30
+
31
+ getDisplayInfo: (args) => ({
32
+ kind: "write",
33
+ icon: "✎",
34
+ locations: [{ path: args.path as string }],
35
+ }),
36
+
37
+ async execute(args) {
38
+ const filePath = expandHome(args.path as string);
39
+ const content = args.content as string;
40
+ const absPath = path.resolve(getCwd(), filePath);
41
+
42
+ try {
43
+ let oldContent: string | null = null;
44
+ try {
45
+ oldContent = await fs.readFile(absPath, "utf-8");
46
+ } catch {
47
+ // New file
48
+ }
49
+
50
+ await fs.mkdir(path.dirname(absPath), { recursive: true });
51
+ await fs.writeFile(absPath, content);
52
+
53
+ const diff = computeDiff(oldContent, content);
54
+ const stats = diff.isNewFile ? `+${diff.added}` : `+${diff.added} -${diff.removed}`;
55
+ return {
56
+ content: oldContent === null
57
+ ? `Created ${absPath} (${stats})`
58
+ : `Wrote ${absPath} (${stats})`,
59
+ exitCode: 0,
60
+ isError: false,
61
+ display: {
62
+ summary: stats,
63
+ body: { kind: "diff", diff, filePath: absPath },
64
+ },
65
+ };
66
+ } catch (err) {
67
+ const msg = err instanceof Error ? err.message : String(err);
68
+ return { content: `Error: ${msg}`, exitCode: 1, isError: true };
69
+ }
70
+ },
71
+ };
72
+ }
@@ -0,0 +1,149 @@
1
+ import type { EventBus } from "../core/event-bus.js";
2
+
3
+ /**
4
+ * Minimal agent backend interface — bus-driven.
5
+ *
6
+ * Backends self-wire to bus events in their constructor:
7
+ * - agent:submit → handle queries
8
+ * - agent:cancel-request → handle cancellation
9
+ *
10
+ * They emit bus events for results:
11
+ * - agent:response-chunk, agent:tool-started, agent:tool-completed, etc.
12
+ *
13
+ * The only imperative method is kill() for lifecycle cleanup.
14
+ */
15
+ export interface AgentBackend {
16
+ /** Async startup (e.g. spawn subprocess). No-op if not needed. */
17
+ start?(): Promise<void>;
18
+ kill(): void;
19
+ }
20
+
21
+ /** Image content block for multimodal tool results. */
22
+ export interface ImageContent {
23
+ type: "image";
24
+ /** Base64-encoded image data (no data: URL prefix). */
25
+ data: string;
26
+ /** MIME type (e.g. "image/png", "image/jpeg"). */
27
+ mimeType: string;
28
+ }
29
+
30
+ /** Extract the text portion of a tool result's content. Returns "" for image-only results. */
31
+ export function contentText(content: string | ImageContent[]): string {
32
+ if (typeof content === "string") return content;
33
+ return content.map(c => `[image: ${c.mimeType}]`).join("\n");
34
+ }
35
+
36
+ export interface ToolResult {
37
+ content: string | ImageContent[];
38
+ exitCode: number | null;
39
+ isError: boolean;
40
+ /** When set, takes precedence over `tool.formatResult()`. */
41
+ display?: ToolResultDisplay;
42
+ }
43
+
44
+ /** Structured result display — returned by formatResult or computed by defaults. */
45
+ export interface ToolResultDisplay {
46
+ /** One-line summary shown next to ✓/✗ (e.g. "42 papers found", "+3/-1"). */
47
+ summary?: string;
48
+ /** Structured content to render below the status line. */
49
+ body?: ToolResultBody;
50
+ }
51
+
52
+ export type ToolResultBody =
53
+ | { kind: "diff"; diff: unknown; filePath: string }
54
+ | { kind: "lines"; lines: string[]; maxLines?: number }
55
+
56
+ export interface ToolDisplayInfo {
57
+ kind: "read" | "write" | "execute" | "search";
58
+ locations?: { path: string; line?: number | null }[];
59
+ /** Custom icon character for TUI display (e.g., "◆", "⌕"). When set, the TUI shows
60
+ * icon + detail only. When absent, the tool name is shown alongside the detail. */
61
+ icon?: string;
62
+ /** highlight.js-style language identifier ("scheme", "python", …) for
63
+ * renderers that syntax-highlight tool source. Omit for plain text. */
64
+ sourceLanguage?: string;
65
+ }
66
+
67
+ /** Interactive UI session — imperative control over rendering + input. */
68
+ export interface InteractiveSession<T> {
69
+ /** Return lines to render. Called on mount and after each input. */
70
+ render(width: number): string[];
71
+ /** Handle raw input. Call done(result) to finish the session. */
72
+ handleInput(data: string, done: (result: T) => void): void;
73
+ /** done() lets the session resolve itself from outside handleInput. */
74
+ onMount?(invalidate: () => void, done: (result: T) => void): void;
75
+ /** Called when session ends (cleanup). */
76
+ onUnmount?(): void;
77
+ }
78
+
79
+ /** Interactive UI capability passed to tools during execution. */
80
+ export interface ToolUI {
81
+ /** Present a custom interactive UI and wait for the user's response. */
82
+ custom<T>(session: InteractiveSession<T>): Promise<T>;
83
+ }
84
+
85
+ /** Context passed to tool execute() as optional third parameter. */
86
+ export interface ToolExecutionContext {
87
+ ui?: ToolUI;
88
+ /** Aborted on Ctrl-C — tools with subprocess work should listen and clean up. */
89
+ signal?: AbortSignal;
90
+ }
91
+
92
+ /** LLM-facing view of a tool — what `adviseToolSchema` advisors return. */
93
+ export interface ToolSchemaView {
94
+ description: string;
95
+ parameters: Record<string, unknown>;
96
+ }
97
+
98
+ /** LLM-facing view of a skill — what `adviseSkill` advisors return. */
99
+ export interface SkillView {
100
+ description: string;
101
+ filePath: string;
102
+ }
103
+
104
+ export interface ToolDefinition {
105
+ name: string;
106
+ /** Short label for TUI display (e.g. "search" instead of "ads_search"). Defaults to name. */
107
+ displayName?: string;
108
+ description: string;
109
+ input_schema: Record<string, unknown>;
110
+
111
+ execute(
112
+ args: Record<string, unknown>,
113
+ onChunk?: (chunk: string) => void,
114
+ ctx?: ToolExecutionContext,
115
+ ): Promise<ToolResult>;
116
+
117
+ /** Whether to stream tool output to the TUI (default: true). */
118
+ showOutput?: boolean;
119
+
120
+ /** Whether this tool may modify files — triggers file watcher (default: false). */
121
+ modifiesFiles?: boolean;
122
+
123
+ /** Results are re-fetchable; nuclear compaction drops the tool_result
124
+ * body on eviction (like the builtin read_file/grep/ls). Default: false. */
125
+ readOnly?: boolean;
126
+
127
+ /** Derive display metadata (icon kind, file paths) for the TUI. */
128
+ getDisplayInfo?: (args: Record<string, unknown>) => ToolDisplayInfo;
129
+
130
+ /**
131
+ * Format a short display string for the TUI when this tool is called.
132
+ * Return a concise summary of the args (e.g. the query, the file path).
133
+ * When absent, the TUI derives the detail from common arg fields (command, path, pattern).
134
+ */
135
+ formatCall?: (args: Record<string, unknown>) => string;
136
+
137
+ /**
138
+ * Format result display for the TUI after execution completes.
139
+ * Return a summary string and/or structured body to render.
140
+ * When absent, defaults are computed based on tool kind.
141
+ * Extensions can further override via bus.onPipe("agent:tool-completed", ...).
142
+ */
143
+ formatResult?: (args: Record<string, unknown>, result: ToolResult) => ToolResultDisplay;
144
+
145
+ /** Override the agent-loop's per-tool-result truncation cap (default 16 KB).
146
+ * Use for tools that bundle multiple operations and legitimately produce
147
+ * larger output (interpreter substrates etc.). */
148
+ maxResultBytes?: number;
149
+ }