@arnilo/prism-coding-agent 0.0.3

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.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Path resolution helpers.
3
+ *
4
+ * Behavioral port of pi's core/tools/path-utils for @arnilo/prism-coding-agent.
5
+ * stdlib only (node:fs, node:os, node:path, node:url). pi's version delegates
6
+ * homedir/tilde expansion to an internal utils/paths.js; that logic is inlined
7
+ * here as normalizePath/resolvePath so the package stays self-contained.
8
+ */
9
+ import { accessSync, constants as fsConstants } from "node:fs";
10
+ import { access } from "node:fs/promises";
11
+ import { homedir } from "node:os";
12
+ import { isAbsolute, join, resolve as nodeResolve } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
15
+ const NARROW_NO_BREAK_SPACE = "\u202F";
16
+ function normalizePath(input, options = {}) {
17
+ let normalized = options.trim ? input.trim() : input;
18
+ if (options.normalizeUnicodeSpaces) {
19
+ normalized = normalized.replace(UNICODE_SPACES, " ");
20
+ }
21
+ if (options.stripAtPrefix && normalized.startsWith("@")) {
22
+ normalized = normalized.slice(1);
23
+ }
24
+ if (options.expandTilde ?? true) {
25
+ const home = options.homeDir ?? homedir();
26
+ if (normalized === "~")
27
+ return home;
28
+ if (normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))) {
29
+ return join(home, normalized.slice(2));
30
+ }
31
+ }
32
+ if (/^file:\/\//.test(normalized)) {
33
+ return fileURLToPath(normalized);
34
+ }
35
+ return normalized;
36
+ }
37
+ function resolvePath(input, baseDir = process.cwd(), options = {}) {
38
+ const normalized = normalizePath(input, options);
39
+ const normalizedBaseDir = normalizePath(baseDir);
40
+ return isAbsolute(normalized) ? nodeResolve(normalized) : nodeResolve(normalizedBaseDir, normalized);
41
+ }
42
+ export async function pathExists(filePath) {
43
+ try {
44
+ await access(filePath, fsConstants.F_OK);
45
+ return true;
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
51
+ export function expandPath(filePath) {
52
+ return normalizePath(filePath, { normalizeUnicodeSpaces: true, stripAtPrefix: true });
53
+ }
54
+ /**
55
+ * Resolve a path relative to the given cwd. Handles ~ expansion and absolute paths.
56
+ */
57
+ export function resolveToCwd(filePath, cwd) {
58
+ return resolvePath(filePath, cwd, { normalizeUnicodeSpaces: true, stripAtPrefix: true });
59
+ }
60
+ // macOS screenshot / NFD / curly-quote fallbacks for read paths.
61
+ function tryMacOSScreenshotPath(filePath) {
62
+ return filePath.replace(/ (AM|PM)\./gi, `${NARROW_NO_BREAK_SPACE}$1.`);
63
+ }
64
+ function tryNFDVariant(filePath) {
65
+ // macOS stores filenames in NFD (decomposed) form; try converting user input to NFD.
66
+ return filePath.normalize("NFD");
67
+ }
68
+ function tryCurlyQuoteVariant(filePath) {
69
+ // macOS uses U+2019 in screenshot names; users typically type a straight apostrophe.
70
+ return filePath.replace(/'/g, "\u2019");
71
+ }
72
+ function fileExists(filePath) {
73
+ try {
74
+ accessSync(filePath, fsConstants.F_OK);
75
+ return true;
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
81
+ export function resolveReadPath(filePath, cwd) {
82
+ const resolved = resolveToCwd(filePath, cwd);
83
+ if (fileExists(resolved))
84
+ return resolved;
85
+ const amPmVariant = tryMacOSScreenshotPath(resolved);
86
+ if (amPmVariant !== resolved && fileExists(amPmVariant))
87
+ return amPmVariant;
88
+ const nfdVariant = tryNFDVariant(resolved);
89
+ if (nfdVariant !== resolved && fileExists(nfdVariant))
90
+ return nfdVariant;
91
+ const curlyVariant = tryCurlyQuoteVariant(resolved);
92
+ if (curlyVariant !== resolved && fileExists(curlyVariant))
93
+ return curlyVariant;
94
+ const nfdCurlyVariant = tryCurlyQuoteVariant(nfdVariant);
95
+ if (nfdCurlyVariant !== resolved && fileExists(nfdCurlyVariant))
96
+ return nfdCurlyVariant;
97
+ return resolved;
98
+ }
99
+ export async function resolveReadPathAsync(filePath, cwd) {
100
+ const resolved = resolveToCwd(filePath, cwd);
101
+ if ((await pathExists(resolved)))
102
+ return resolved;
103
+ const amPmVariant = tryMacOSScreenshotPath(resolved);
104
+ if (amPmVariant !== resolved && (await pathExists(amPmVariant)))
105
+ return amPmVariant;
106
+ const nfdVariant = tryNFDVariant(resolved);
107
+ if (nfdVariant !== resolved && (await pathExists(nfdVariant)))
108
+ return nfdVariant;
109
+ const curlyVariant = tryCurlyQuoteVariant(resolved);
110
+ if (curlyVariant !== resolved && (await pathExists(curlyVariant)))
111
+ return curlyVariant;
112
+ const nfdCurlyVariant = tryCurlyQuoteVariant(nfdVariant);
113
+ if (nfdCurlyVariant !== resolved && (await pathExists(nfdCurlyVariant)))
114
+ return nfdCurlyVariant;
115
+ return resolved;
116
+ }
117
+ //# sourceMappingURL=path-utils.js.map
package/dist/read.d.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Read tool: read a file from the host filesystem.
3
+ *
4
+ * Behavioral port of pi's core/tools/read for @arnilo/prism-coding-agent, adapted to Prism's
5
+ * `ToolDefinition` contract. Faithfully ports pi's text path (offset/limit → `truncateHead` →
6
+ * continuation notices) and image path (magic-byte MIME → `ImageContent` with base64). Drops pi's
7
+ * TUI (`renderCall`/`renderResult`, theme/syntax-highlight, compact classifications, key hints) and
8
+ * the model-aware non-vision note (Prism's `ToolExecutionContext` has no model field).
9
+ *
10
+ * Deviations from pi (documented):
11
+ * - **`autoResizeImages` is a documented no-op** (deferred). pi resizes images to ≤2000×2000 via a
12
+ * photon/WASM + `worker_threads` helper (`utils/image-process.js`); pulling that in adds native
13
+ * build weight for a display-size concern hosts can own. The tool returns the raw image bytes as
14
+ * base64 `ImageContent`. `ponytail:` ceiling noted inline; upgrade path = port image processing
15
+ * when a host needs context-size capping.
16
+ * - Abort + all read failures return a Prism `error` result (pi throws/rejects). Prism's
17
+ * `dispatchToolCall` would catch a throw anyway, but returning a clean error result is predictable
18
+ * for direct-`execute` callers and matches the package's `shell` tool.
19
+ * - Truncation footers say "Use the shell tool" (pi: "Use bash") since the package's shell tool is
20
+ * named `shell`.
21
+ */
22
+ import { Buffer } from "node:buffer";
23
+ import type { ToolDefinition } from "@arnilo/prism";
24
+ import { type TruncationResult } from "./truncate.js";
25
+ /** Detect a supported image MIME type from a buffer's leading bytes. Returns null for non-images. */
26
+ export declare function detectSupportedImageMimeType(buffer: Buffer): string | null;
27
+ /** Sniff the leading bytes of a file and return its image MIME type (null if not a supported image). */
28
+ export declare function detectSupportedImageMimeTypeFromFile(filePath: string): Promise<string | null>;
29
+ /**
30
+ * Pluggable operations for the read tool. Override to delegate file reading to remote systems
31
+ * (e.g. SSH) while keeping the tool's truncation/offset/limit behavior.
32
+ */
33
+ export interface ReadOperations {
34
+ /** Read file contents as a Buffer. */
35
+ readFile: (absolutePath: string) => Promise<Buffer>;
36
+ /** Check the file is readable (throw if not). */
37
+ access: (absolutePath: string) => Promise<void>;
38
+ /** Detect image MIME type from the file; return null/undefined for non-images. */
39
+ detectImageMimeType?: (absolutePath: string) => Promise<string | null | undefined>;
40
+ }
41
+ export interface ReadToolOptions {
42
+ /**
43
+ * Whether to auto-resize images (pi resizes to ≤2000×2000). **Not yet implemented** — the tool
44
+ * returns raw image bytes. Kept as a placeholder so hosts can opt in once resizing is ported.
45
+ */
46
+ autoResizeImages?: boolean;
47
+ /** Custom operations backend (default: local filesystem). */
48
+ operations?: ReadOperations;
49
+ /** Max lines kept from the head (default 2000). */
50
+ maxLines?: number;
51
+ /** Max bytes kept from the head (default 50KB). */
52
+ maxBytes?: number;
53
+ }
54
+ export declare function createReadTool(cwd: string, options?: ReadToolOptions): ToolDefinition;
55
+ /** Re-exported for hosts building custom read tools or analyzing truncation metadata. */
56
+ export type { TruncationResult };
package/dist/read.js ADDED
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Read tool: read a file from the host filesystem.
3
+ *
4
+ * Behavioral port of pi's core/tools/read for @arnilo/prism-coding-agent, adapted to Prism's
5
+ * `ToolDefinition` contract. Faithfully ports pi's text path (offset/limit → `truncateHead` →
6
+ * continuation notices) and image path (magic-byte MIME → `ImageContent` with base64). Drops pi's
7
+ * TUI (`renderCall`/`renderResult`, theme/syntax-highlight, compact classifications, key hints) and
8
+ * the model-aware non-vision note (Prism's `ToolExecutionContext` has no model field).
9
+ *
10
+ * Deviations from pi (documented):
11
+ * - **`autoResizeImages` is a documented no-op** (deferred). pi resizes images to ≤2000×2000 via a
12
+ * photon/WASM + `worker_threads` helper (`utils/image-process.js`); pulling that in adds native
13
+ * build weight for a display-size concern hosts can own. The tool returns the raw image bytes as
14
+ * base64 `ImageContent`. `ponytail:` ceiling noted inline; upgrade path = port image processing
15
+ * when a host needs context-size capping.
16
+ * - Abort + all read failures return a Prism `error` result (pi throws/rejects). Prism's
17
+ * `dispatchToolCall` would catch a throw anyway, but returning a clean error result is predictable
18
+ * for direct-`execute` callers and matches the package's `shell` tool.
19
+ * - Truncation footers say "Use the shell tool" (pi: "Use bash") since the package's shell tool is
20
+ * named `shell`.
21
+ */
22
+ import { Buffer } from "node:buffer";
23
+ import { constants } from "node:fs";
24
+ import { access as fsAccess, open, readFile as fsReadFile } from "node:fs/promises";
25
+ import { resolveReadPathAsync } from "./path-utils.js";
26
+ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead, } from "./truncate.js";
27
+ // --- magic-byte image MIME detection (faithful port of pi utils/mime.js, pure JS, no deps) ---
28
+ const IMAGE_TYPE_SNIFF_BYTES = 4100;
29
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
30
+ /** Detect a supported image MIME type from a buffer's leading bytes. Returns null for non-images. */
31
+ export function detectSupportedImageMimeType(buffer) {
32
+ if (startsWith(buffer, [0xff, 0xd8, 0xff])) {
33
+ // SOI marker; 0xff 0xd8 0xff 0xf7 is a JFIF-extension frame, not a standalone JPEG image.
34
+ return buffer[3] === 0xf7 ? null : "image/jpeg";
35
+ }
36
+ if (startsWith(buffer, PNG_SIGNATURE)) {
37
+ return isPng(buffer) && !isAnimatedPng(buffer) ? "image/png" : null;
38
+ }
39
+ if (startsWithAscii(buffer, 0, "GIF")) {
40
+ return "image/gif";
41
+ }
42
+ if (startsWithAscii(buffer, 0, "RIFF") && startsWithAscii(buffer, 8, "WEBP")) {
43
+ return "image/webp";
44
+ }
45
+ if (startsWithAscii(buffer, 0, "BM") && isBmp(buffer)) {
46
+ return "image/bmp";
47
+ }
48
+ return null;
49
+ }
50
+ /** Sniff the leading bytes of a file and return its image MIME type (null if not a supported image). */
51
+ export async function detectSupportedImageMimeTypeFromFile(filePath) {
52
+ const fileHandle = await open(filePath, "r");
53
+ try {
54
+ const buffer = Buffer.alloc(IMAGE_TYPE_SNIFF_BYTES);
55
+ const { bytesRead } = await fileHandle.read(buffer, 0, IMAGE_TYPE_SNIFF_BYTES, 0);
56
+ return detectSupportedImageMimeType(buffer.subarray(0, bytesRead));
57
+ }
58
+ finally {
59
+ await fileHandle.close();
60
+ }
61
+ }
62
+ function isPng(buffer) {
63
+ // First chunk after the 8-byte signature must be a 13-byte IHDR.
64
+ return (buffer.length >= 16 &&
65
+ readUint32BE(buffer, PNG_SIGNATURE.length) === 13 &&
66
+ startsWithAscii(buffer, 12, "IHDR"));
67
+ }
68
+ function isAnimatedPng(buffer) {
69
+ // Walk PNG chunks; an acTL chunk before the first IDAT marks an animated (APNG) image.
70
+ let offset = PNG_SIGNATURE.length;
71
+ while (offset + 8 <= buffer.length) {
72
+ const chunkLength = readUint32BE(buffer, offset);
73
+ const chunkTypeOffset = offset + 4;
74
+ if (startsWithAscii(buffer, chunkTypeOffset, "acTL"))
75
+ return true;
76
+ if (startsWithAscii(buffer, chunkTypeOffset, "IDAT"))
77
+ return false;
78
+ const nextOffset = offset + 8 + chunkLength + 4;
79
+ if (nextOffset <= offset || nextOffset > buffer.length)
80
+ return false;
81
+ offset = nextOffset;
82
+ }
83
+ return false;
84
+ }
85
+ function isBmp(buffer) {
86
+ if (buffer.length < 26)
87
+ return false;
88
+ const declaredFileSize = readUint32LE(buffer, 2);
89
+ const pixelDataOffset = readUint32LE(buffer, 10);
90
+ const dibHeaderSize = readUint32LE(buffer, 14);
91
+ if (declaredFileSize !== 0 && declaredFileSize < 26)
92
+ return false;
93
+ if (pixelDataOffset < 14 + dibHeaderSize)
94
+ return false;
95
+ if (declaredFileSize !== 0 && pixelDataOffset >= declaredFileSize)
96
+ return false;
97
+ let colorPlanes;
98
+ let bitsPerPixel;
99
+ if (dibHeaderSize === 12) {
100
+ colorPlanes = readUint16LE(buffer, 22);
101
+ bitsPerPixel = readUint16LE(buffer, 24);
102
+ }
103
+ else if (dibHeaderSize >= 40 && dibHeaderSize <= 124) {
104
+ if (buffer.length < 30)
105
+ return false;
106
+ colorPlanes = readUint16LE(buffer, 26);
107
+ bitsPerPixel = readUint16LE(buffer, 28);
108
+ }
109
+ else {
110
+ return false;
111
+ }
112
+ return colorPlanes === 1 && [1, 4, 8, 16, 24, 32].includes(bitsPerPixel);
113
+ }
114
+ function readUint16LE(buffer, offset) {
115
+ return (buffer[offset] ?? 0) + ((buffer[offset + 1] ?? 0) << 8);
116
+ }
117
+ function readUint32BE(buffer, offset) {
118
+ return ((buffer[offset] ?? 0) * 0x1000000 +
119
+ ((buffer[offset + 1] ?? 0) << 16) +
120
+ ((buffer[offset + 2] ?? 0) << 8) +
121
+ (buffer[offset + 3] ?? 0));
122
+ }
123
+ function readUint32LE(buffer, offset) {
124
+ return ((buffer[offset] ?? 0) +
125
+ ((buffer[offset + 1] ?? 0) << 8) +
126
+ ((buffer[offset + 2] ?? 0) << 16) +
127
+ (buffer[offset + 3] ?? 0) * 0x1000000);
128
+ }
129
+ function startsWith(buffer, bytes) {
130
+ if (buffer.length < bytes.length)
131
+ return false;
132
+ return bytes.every((byte, index) => buffer[index] === byte);
133
+ }
134
+ function startsWithAscii(buffer, offset, text) {
135
+ if (buffer.length < offset + text.length)
136
+ return false;
137
+ for (let index = 0; index < text.length; index++) {
138
+ if (buffer[offset + index] !== text.charCodeAt(index))
139
+ return false;
140
+ }
141
+ return true;
142
+ }
143
+ const defaultReadOperations = {
144
+ readFile: (path) => fsReadFile(path),
145
+ access: (path) => fsAccess(path, constants.R_OK),
146
+ detectImageMimeType: detectSupportedImageMimeTypeFromFile,
147
+ };
148
+ function errorResult(toolCallId, message) {
149
+ return {
150
+ toolCallId,
151
+ name: "read",
152
+ content: [{ type: "text", text: message }],
153
+ error: { message },
154
+ };
155
+ }
156
+ export function createReadTool(cwd, options) {
157
+ const ops = options?.operations ?? defaultReadOperations;
158
+ const maxLines = options?.maxLines ?? DEFAULT_MAX_LINES;
159
+ const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;
160
+ return {
161
+ name: "read",
162
+ description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp); images are returned as image content. For text files, output is truncated to ${maxLines} lines or ${maxBytes / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`,
163
+ parameters: {
164
+ type: "object",
165
+ properties: {
166
+ path: { type: "string", description: "Path to the file to read (relative or absolute)" },
167
+ offset: { type: "number", description: "Line number to start reading from (1-indexed)" },
168
+ limit: { type: "number", description: "Maximum number of lines to read" },
169
+ },
170
+ required: ["path"],
171
+ additionalProperties: false,
172
+ },
173
+ async execute(args, context) {
174
+ const toolCallId = context.toolCallId;
175
+ if (context.signal?.aborted) {
176
+ return errorResult(toolCallId, "Operation aborted");
177
+ }
178
+ const path = typeof args.path === "string" ? args.path : "";
179
+ const offset = typeof args.offset === "number" ? args.offset : undefined;
180
+ const limit = typeof args.limit === "number" ? args.limit : undefined;
181
+ if (path.length === 0) {
182
+ return errorResult(toolCallId, "path is required and must be a non-empty string.");
183
+ }
184
+ try {
185
+ const absolutePath = await resolveReadPathAsync(path, cwd);
186
+ await ops.access(absolutePath);
187
+ const mimeType = ops.detectImageMimeType ? await ops.detectImageMimeType(absolutePath) : undefined;
188
+ if (mimeType) {
189
+ const buffer = await ops.readFile(absolutePath);
190
+ // ponytail: auto-resize deferred — raw bytes returned; port image processing (resize to
191
+ // ≤2000×2000) when a host needs context-size capping. Until then large images bloat context.
192
+ return {
193
+ toolCallId,
194
+ name: "read",
195
+ content: [
196
+ { type: "text", text: `Read image file [${mimeType}]` },
197
+ { type: "image", data: buffer.toString("base64"), mimeType },
198
+ ],
199
+ metadata: { image: { mimeType, resized: false } },
200
+ };
201
+ }
202
+ // Text path: faithful port of pi's offset/limit → truncateHead → continuation logic.
203
+ const buffer = await ops.readFile(absolutePath);
204
+ const textContent = buffer.toString("utf-8");
205
+ const allLines = textContent.split("\n");
206
+ const totalFileLines = allLines.length;
207
+ // 1-indexed offset → 0-indexed array access.
208
+ const startLine = offset ? Math.max(0, offset - 1) : 0;
209
+ const startLineDisplay = startLine + 1;
210
+ if (startLine >= allLines.length) {
211
+ throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`);
212
+ }
213
+ let selectedContent;
214
+ let userLimitedLines;
215
+ if (limit !== undefined) {
216
+ const endLine = Math.min(startLine + limit, allLines.length);
217
+ selectedContent = allLines.slice(startLine, endLine).join("\n");
218
+ userLimitedLines = endLine - startLine;
219
+ }
220
+ else {
221
+ selectedContent = allLines.slice(startLine).join("\n");
222
+ }
223
+ const truncation = truncateHead(selectedContent, { maxLines, maxBytes });
224
+ let outputText;
225
+ if (truncation.firstLineExceedsLimit) {
226
+ // First line alone exceeds the byte limit — point at a shell fallback.
227
+ const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8"));
228
+ outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(maxBytes)} limit. Use the shell tool: sed -n '${startLineDisplay}p' ${path} | head -c ${maxBytes}]`;
229
+ }
230
+ else if (truncation.truncated) {
231
+ const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
232
+ const nextOffset = endLineDisplay + 1;
233
+ outputText = truncation.content;
234
+ if (truncation.truncatedBy === "lines") {
235
+ outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;
236
+ }
237
+ else {
238
+ outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(maxBytes)} limit). Use offset=${nextOffset} to continue.]`;
239
+ }
240
+ }
241
+ else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
242
+ // User limit stopped early but the file has more content.
243
+ const remaining = allLines.length - (startLine + userLimitedLines);
244
+ const nextOffset = startLine + userLimitedLines + 1;
245
+ outputText = `${truncation.content}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
246
+ }
247
+ else {
248
+ outputText = truncation.content;
249
+ }
250
+ return {
251
+ toolCallId,
252
+ name: "read",
253
+ content: [{ type: "text", text: outputText }],
254
+ metadata: { truncation },
255
+ };
256
+ }
257
+ catch (error) {
258
+ const message = error instanceof Error ? error.message : String(error);
259
+ return errorResult(toolCallId, message);
260
+ }
261
+ },
262
+ };
263
+ }
264
+ //# sourceMappingURL=read.js.map
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Shell tool: execute a command in the host shell.
3
+ *
4
+ * Behavioral port of pi's core/tools/bash for @arnilo/prism-coding-agent, adapted to
5
+ * Prism's `ToolDefinition` contract. Drops pi's TUI (live `onUpdate` streaming,
6
+ * `renderCall`/`renderResult`) and process-shutdown child tracking; re-ports spawn/
7
+ * kill/waitForChildProcess directly over stdlib so the package stays self-contained.
8
+ *
9
+ * Deviations from pi (documented):
10
+ * - Tool named `"shell"` (pi: `"bash"`).
11
+ * - `timeout` param is in **seconds** (matches pi).
12
+ * - Shell resolution honors `process.env.SHELL` → `/bin/bash` → `sh` (pi forces `/bin/bash`);
13
+ * overridable via `options.shellPath`. Rationale: a host integrating the tool usually wants its
14
+ * login shell respected; `shellPath` still lets a host force bash for fully predictable POSIX.
15
+ * - Non-zero exit is **not** a tool error: returned as a normal `ToolResult` with `exitCode` in
16
+ * `metadata` and a status footer in `content` (pi throws). timeout/abort *are* error results
17
+ * (the command did not complete). Rationale: `ToolResult.error` should mean the tool call failed,
18
+ * not that the command returned non-zero.
19
+ * - Drops detached-child PID tracking (`killTrackedDetachedChildren`): the host owns process
20
+ * lifecycle; the tool kills the tree only on timeout/abort. Drops the stdin command transport
21
+ * (argv `-c` only). Default spawn env is `process.env` (no pi CLI binDir PATH injection).
22
+ */
23
+ import { type ChildProcess } from "node:child_process";
24
+ import type { ToolDefinition } from "@arnilo/prism";
25
+ export interface ShellConfig {
26
+ shell: string;
27
+ args: string[];
28
+ }
29
+ export interface BashSpawnContext {
30
+ command: string;
31
+ cwd: string;
32
+ env: NodeJS.ProcessEnv;
33
+ }
34
+ export type BashSpawnHook = (context: BashSpawnContext) => BashSpawnContext;
35
+ export interface BashExecOptions {
36
+ onData: (data: Buffer) => void;
37
+ signal?: AbortSignal;
38
+ timeout?: number;
39
+ env?: NodeJS.ProcessEnv;
40
+ }
41
+ export interface BashOperations {
42
+ /** Execute a command and stream combined output. Resolves to the exit code (null if killed). */
43
+ exec: (command: string, cwd: string, options: BashExecOptions) => Promise<{
44
+ exitCode: number | null;
45
+ }>;
46
+ }
47
+ export interface ShellToolOptions {
48
+ /** Custom operations backend (default: local shell). Override to delegate to remote shells. */
49
+ operations?: BashOperations;
50
+ /** Command prefix prepended to every command (e.g. shell setup commands). */
51
+ commandPrefix?: string;
52
+ /** Explicit shell binary path; overrides SHELL/defaults. */
53
+ shellPath?: string;
54
+ /** Hook to adjust command, cwd, or env before execution. */
55
+ spawnHook?: BashSpawnHook;
56
+ /** Max lines kept in the tail snapshot (default 2000). */
57
+ maxLines?: number;
58
+ /** Max bytes kept in the tail snapshot (default 50KB). */
59
+ maxBytes?: number;
60
+ /** Temp-file prefix for spilled full output (default "prism-shell"). */
61
+ tempFilePrefix?: string;
62
+ }
63
+ /** Resolve the shell binary + args. shellPath → SHELL env → /bin/bash → sh. */
64
+ export declare function getShellConfig(customShellPath?: string): ShellConfig;
65
+ /** Kill a process and all its descendants (cross-platform). */
66
+ export declare function killProcessTree(pid: number): void;
67
+ /**
68
+ * Wait for a child to terminate without hanging on inherited stdio handles held by detached descendants.
69
+ *
70
+ * A short-lived child can `exit` while a detached descendant keeps its stdout/stderr pipe open. After
71
+ * `exit` we wait for the pipes to fall idle: the grace timer is re-armed on every chunk, so an actively
72
+ * writing descendant keeps us reading, while a quiet inherited handle releases us after the grace elapses.
73
+ */
74
+ export declare function waitForChildProcess(child: ChildProcess): Promise<number | null>;
75
+ /** Default local-shell operations: spawn the command in a shell, stream combined stdout+stderr. */
76
+ export declare function createLocalBashOperations(options?: {
77
+ shellPath?: string;
78
+ }): BashOperations;
79
+ export declare function createShellTool(cwd: string, options?: ShellToolOptions): ToolDefinition;