@amanm/openpaw 0.1.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 (72) hide show
  1. package/AGENTS.md +1 -0
  2. package/README.md +144 -0
  3. package/agent/agent.ts +217 -0
  4. package/agent/context-scan.ts +81 -0
  5. package/agent/file-editor-store.ts +27 -0
  6. package/agent/index.ts +31 -0
  7. package/agent/memory-store.ts +404 -0
  8. package/agent/model.ts +14 -0
  9. package/agent/prompt-builder.ts +139 -0
  10. package/agent/prompt-context-files.ts +151 -0
  11. package/agent/sandbox-paths.ts +52 -0
  12. package/agent/session-store.ts +80 -0
  13. package/agent/skill-catalog.ts +25 -0
  14. package/agent/skills/discover.ts +100 -0
  15. package/agent/tool-stream-format.ts +126 -0
  16. package/agent/tool-yaml-like.ts +96 -0
  17. package/agent/tools/bash.ts +100 -0
  18. package/agent/tools/file-editor.ts +293 -0
  19. package/agent/tools/list-dir.ts +58 -0
  20. package/agent/tools/load-skill.ts +40 -0
  21. package/agent/tools/memory.ts +84 -0
  22. package/agent/turn-context.ts +46 -0
  23. package/agent/types.ts +37 -0
  24. package/agent/workspace-bootstrap.ts +98 -0
  25. package/bin/openpaw.cjs +177 -0
  26. package/bundled-skills/find-skills/SKILL.md +163 -0
  27. package/cli/components/chat-app.tsx +759 -0
  28. package/cli/components/onboard-ui.tsx +325 -0
  29. package/cli/components/theme.ts +16 -0
  30. package/cli/configure.tsx +0 -0
  31. package/cli/lib/chat-transcript-types.ts +11 -0
  32. package/cli/lib/markdown-render-node.ts +523 -0
  33. package/cli/lib/onboard-markdown-syntax-style.ts +55 -0
  34. package/cli/lib/ui-messages-to-chat-transcript.ts +157 -0
  35. package/cli/lib/use-auto-copy-selection.ts +38 -0
  36. package/cli/onboard.tsx +248 -0
  37. package/cli/openpaw.tsx +144 -0
  38. package/cli/reset.ts +12 -0
  39. package/cli/tui.tsx +31 -0
  40. package/config/index.ts +3 -0
  41. package/config/paths.ts +71 -0
  42. package/config/personality-copy.ts +68 -0
  43. package/config/storage.ts +80 -0
  44. package/config/types.ts +37 -0
  45. package/gateway/bootstrap.ts +25 -0
  46. package/gateway/channel-adapter.ts +8 -0
  47. package/gateway/daemon-manager.ts +191 -0
  48. package/gateway/index.ts +18 -0
  49. package/gateway/session-key.ts +13 -0
  50. package/gateway/slash-command-tokens.ts +39 -0
  51. package/gateway/start-messaging.ts +40 -0
  52. package/gateway/telegram/active-thread-store.ts +89 -0
  53. package/gateway/telegram/adapter.ts +290 -0
  54. package/gateway/telegram/assistant-markdown.ts +48 -0
  55. package/gateway/telegram/bot-commands.ts +40 -0
  56. package/gateway/telegram/chat-preferences.ts +100 -0
  57. package/gateway/telegram/constants.ts +5 -0
  58. package/gateway/telegram/index.ts +4 -0
  59. package/gateway/telegram/message-html.ts +138 -0
  60. package/gateway/telegram/message-queue.ts +19 -0
  61. package/gateway/telegram/reserved-command-filter.ts +33 -0
  62. package/gateway/telegram/session-file-discovery.ts +62 -0
  63. package/gateway/telegram/session-key.ts +13 -0
  64. package/gateway/telegram/session-label.ts +14 -0
  65. package/gateway/telegram/sessions-list-reply.ts +39 -0
  66. package/gateway/telegram/stream-delivery.ts +618 -0
  67. package/gateway/tui/constants.ts +2 -0
  68. package/gateway/tui/tui-active-thread-store.ts +103 -0
  69. package/gateway/tui/tui-session-discovery.ts +94 -0
  70. package/gateway/tui/tui-session-label.ts +22 -0
  71. package/gateway/tui/tui-sessions-list-message.ts +37 -0
  72. package/package.json +52 -0
@@ -0,0 +1,52 @@
1
+ import { parse, resolve, sep } from "node:path";
2
+ import { isSandboxRestricted } from "./turn-context";
3
+
4
+ /**
5
+ * Workspace root used for sandboxed file paths (`FILE_EDITOR_ROOT` overrides `workspaceRoot`).
6
+ */
7
+ export function workspaceSandboxBase(workspaceRoot: string): string {
8
+ return resolve(process.env.FILE_EDITOR_ROOT ?? workspaceRoot);
9
+ }
10
+
11
+ /**
12
+ * When the sandbox is off, tools use the OS filesystem root (same as previous `allowedBaseFor` behavior).
13
+ */
14
+ export function filesystemSandboxBase(): string {
15
+ return parse(process.cwd()).root;
16
+ }
17
+
18
+ /**
19
+ * Resolves `userPath` for file_editor and list_dir: when the sandbox is on, the result must lie
20
+ * under the workspace sandbox base or under one of the absolute `skillRoots`; when off, under
21
+ * the filesystem root only.
22
+ */
23
+ export function resolveScopePath(
24
+ workspaceRoot: string,
25
+ skillRoots: readonly string[],
26
+ userPath: string,
27
+ ): string {
28
+ if (!isSandboxRestricted()) {
29
+ return resolveUnderRoot(filesystemSandboxBase(), userPath);
30
+ }
31
+
32
+ const ws = workspaceSandboxBase(workspaceRoot);
33
+ const roots = [ws, ...skillRoots.map((r) => resolve(r))];
34
+ for (const base of roots) {
35
+ const full = resolve(base, userPath);
36
+ const prefix = base.endsWith(sep) ? base : `${base}${sep}`;
37
+ if (full === base || full.startsWith(prefix)) {
38
+ return full;
39
+ }
40
+ }
41
+ throw new Error(`Path outside sandbox: "${userPath}"`);
42
+ }
43
+
44
+ function resolveUnderRoot(root: string, userPath: string): string {
45
+ const r = resolve(root);
46
+ const full = resolve(r, userPath);
47
+ const prefix = r.endsWith(sep) ? r : `${r}${sep}`;
48
+ if (full !== r && !full.startsWith(prefix)) {
49
+ throw new Error(`Path traversal blocked: "${userPath}"`);
50
+ }
51
+ return full;
52
+ }
@@ -0,0 +1,80 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import type { ToolSet, UIMessage } from "ai";
5
+ import { safeValidateUIMessages } from "ai";
6
+ import { getSessionsDir } from "../config/paths";
7
+ import type { SessionId } from "./types";
8
+
9
+ const SESSION_FILE_VERSION = 1;
10
+
11
+ export type SessionFileV1 = {
12
+ version: typeof SESSION_FILE_VERSION;
13
+ messages: UIMessage[];
14
+ };
15
+
16
+ /**
17
+ * Maps a session id to a single filesystem-safe filename (no path separators).
18
+ */
19
+ export function sessionIdToFilename(sessionId: SessionId): string {
20
+ const safe = sessionId.replace(/[^a-zA-Z0-9._-]+/g, "_");
21
+ return `${safe}.json`;
22
+ }
23
+
24
+ export function getSessionFilePath(sessionId: SessionId): string {
25
+ return join(getSessionsDir(), sessionIdToFilename(sessionId));
26
+ }
27
+
28
+ /**
29
+ * Loads UI messages for a session; returns empty history if missing or invalid.
30
+ */
31
+ export async function loadSessionMessages(
32
+ sessionId: SessionId,
33
+ tools: ToolSet,
34
+ ): Promise<UIMessage[]> {
35
+ const path = getSessionFilePath(sessionId);
36
+ if (!existsSync(path)) {
37
+ return [];
38
+ }
39
+ try {
40
+ const raw = await Bun.file(path).text();
41
+ const parsed = JSON.parse(raw) as SessionFileV1 | unknown;
42
+ if (
43
+ typeof parsed !== "object" ||
44
+ parsed === null ||
45
+ !("messages" in parsed) ||
46
+ !Array.isArray((parsed as SessionFileV1).messages)
47
+ ) {
48
+ return [];
49
+ }
50
+ const messages = (parsed as SessionFileV1).messages as UIMessage[];
51
+ const validated = await safeValidateUIMessages({
52
+ messages,
53
+ tools: tools as never,
54
+ });
55
+ if (!validated.success) {
56
+ return [];
57
+ }
58
+ return validated.data;
59
+ } catch {
60
+ return [];
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Persists the full UI message list for a session.
66
+ */
67
+ export async function saveSessionMessages(
68
+ sessionId: SessionId,
69
+ messages: UIMessage[],
70
+ ): Promise<void> {
71
+ const dir = getSessionsDir();
72
+ if (!existsSync(dir)) {
73
+ mkdirSync(dir, { recursive: true });
74
+ }
75
+ const payload: SessionFileV1 = {
76
+ version: SESSION_FILE_VERSION,
77
+ messages,
78
+ };
79
+ await Bun.write(getSessionFilePath(sessionId), JSON.stringify(payload, null, 2));
80
+ }
@@ -0,0 +1,25 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { discoverSkillDirectories, type SkillMetadata } from "./skills/discover";
4
+
5
+ /**
6
+ * Mutable list of discovered skills for the agent runtime; rescan the filesystem to pick up installs.
7
+ */
8
+ export type OpenPawSkillCatalog = {
9
+ workspacePath: string;
10
+ skills: SkillMetadata[];
11
+ };
12
+
13
+ /**
14
+ * Directories scanned for `SKILL.md` under immediate subfolders (OpenPaw workspace + optional user dir).
15
+ */
16
+ export function skillScanDirsForWorkspace(workspacePath: string): string[] {
17
+ return [join(workspacePath, ".agents/skills"), join(homedir(), ".config/agent/skills")];
18
+ }
19
+
20
+ /**
21
+ * Re-reads skill metadata from disk into {@link OpenPawSkillCatalog.skills}.
22
+ */
23
+ export async function refreshSkillCatalog(catalog: OpenPawSkillCatalog): Promise<void> {
24
+ catalog.skills = await discoverSkillDirectories(skillScanDirsForWorkspace(catalog.workspacePath));
25
+ }
@@ -0,0 +1,100 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import { parse as parseYaml } from "yaml";
4
+
5
+ /** Metadata for one discovered skill (Agent Skills-style); `path` is the absolute skill folder. */
6
+ export type SkillMetadata = {
7
+ name: string;
8
+ description: string;
9
+ path: string;
10
+ };
11
+
12
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
13
+
14
+ /**
15
+ * Returns the raw YAML string inside the first frontmatter block, or null if none.
16
+ */
17
+ export function extractFrontmatterYaml(content: string): string | null {
18
+ const m = content.match(FRONTMATTER_RE);
19
+ return m?.[1]?.trim() ? m[1] : null;
20
+ }
21
+
22
+ /**
23
+ * Strips the leading `---` / `---` frontmatter block from SKILL.md content for tool output.
24
+ */
25
+ export function stripSkillBody(content: string): string {
26
+ const m = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
27
+ return (m ? content.slice(m[0].length) : content).trim();
28
+ }
29
+
30
+ /**
31
+ * Scans each base directory for immediate subfolders containing `SKILL.md`, parses YAML
32
+ * frontmatter for `name` and `description`, and returns skills in stable order.
33
+ * First occurrence of a given `name` wins; invalid or missing entries are skipped.
34
+ */
35
+ export async function discoverSkillDirectories(dirs: string[]): Promise<SkillMetadata[]> {
36
+ const skills: SkillMetadata[] = [];
37
+ const seenNames = new Set<string>();
38
+
39
+ for (const dir of dirs) {
40
+ let entries;
41
+ try {
42
+ entries = await readdir(dir, { withFileTypes: true });
43
+ } catch {
44
+ continue;
45
+ }
46
+
47
+ for (const entry of entries) {
48
+ if (!entry.isDirectory()) {
49
+ continue;
50
+ }
51
+
52
+ const skillDir = resolve(dir, entry.name);
53
+ const skillFile = join(skillDir, "SKILL.md");
54
+
55
+ let content: string;
56
+ try {
57
+ content = await readFile(skillFile, "utf-8");
58
+ } catch {
59
+ continue;
60
+ }
61
+
62
+ const fm = extractFrontmatterYaml(content);
63
+ if (fm == null) {
64
+ continue;
65
+ }
66
+
67
+ let meta: unknown;
68
+ try {
69
+ meta = parseYaml(fm);
70
+ } catch {
71
+ continue;
72
+ }
73
+
74
+ if (typeof meta !== "object" || meta === null) {
75
+ continue;
76
+ }
77
+
78
+ const rec = meta as { name?: unknown; description?: unknown };
79
+ if (typeof rec.name !== "string" || typeof rec.description !== "string") {
80
+ continue;
81
+ }
82
+
83
+ const name = rec.name.trim();
84
+ const description = rec.description.trim();
85
+ if (!name || !description) {
86
+ continue;
87
+ }
88
+
89
+ const dedupeKey = name.toLowerCase();
90
+ if (seenNames.has(dedupeKey)) {
91
+ continue;
92
+ }
93
+ seenNames.add(dedupeKey);
94
+
95
+ skills.push({ name, description, path: skillDir });
96
+ }
97
+ }
98
+
99
+ return skills;
100
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Short one-line formatting for tool I/O in Telegram, TUI, and stream callbacks.
3
+ */
4
+
5
+ import type { ToolStreamEvent } from "./types";
6
+ import { toolInputToYamlLike, toolOutputToYamlLike } from "./tool-yaml-like";
7
+
8
+ const DEFAULT_MAX_JSON_LEN = 200;
9
+
10
+ /**
11
+ * Serializes a value for one-line display, capped in length.
12
+ */
13
+ export function truncateJson(value: unknown, maxLen: number = DEFAULT_MAX_JSON_LEN): string {
14
+ try {
15
+ const s = typeof value === "string" ? value : JSON.stringify(value);
16
+ if (s.length <= maxLen) {
17
+ return s;
18
+ }
19
+ return `${s.slice(0, maxLen)}…`;
20
+ } catch {
21
+ return String(value);
22
+ }
23
+ }
24
+
25
+ /**
26
+ * One line for tool invocation (Telegram / TUI).
27
+ */
28
+ export function formatToolInputLine(toolName: string, input: unknown): string {
29
+ return `${toolName}: ${truncateJson(input)}`;
30
+ }
31
+
32
+ /**
33
+ * One line for tool result (Telegram / TUI).
34
+ */
35
+ export function formatToolOutputLine(output: unknown): string {
36
+ return `→ ${truncateJson(output)}`;
37
+ }
38
+
39
+ /**
40
+ * One line for tool failure.
41
+ */
42
+ export function formatToolErrorLine(toolName: string, errorText: string): string {
43
+ return `⚠ ${toolName}: ${errorText}`;
44
+ }
45
+
46
+ /**
47
+ * One line when tool execution was denied.
48
+ */
49
+ export function formatToolDeniedLine(toolName: string): string {
50
+ return `⛔ ${toolName} (denied)`;
51
+ }
52
+
53
+ /**
54
+ * Formats a streamed tool event for Telegram or TUI (one line).
55
+ */
56
+ export function formatToolStreamEvent(ev: ToolStreamEvent): string {
57
+ switch (ev.type) {
58
+ case "tool_input":
59
+ return formatToolInputLine(ev.toolName, ev.input);
60
+ case "tool_output":
61
+ return formatToolOutputLine(ev.output);
62
+ case "tool_error":
63
+ return formatToolErrorLine(ev.toolName, ev.errorText);
64
+ case "tool_denied":
65
+ return formatToolDeniedLine(ev.toolName);
66
+ default:
67
+ return "";
68
+ }
69
+ }
70
+
71
+ const TUI_YAML_BLOCK_MAX = 3500;
72
+
73
+ function truncateTuiYamlBlock(s: string, max: number): string {
74
+ if (s.length <= max) {
75
+ return s;
76
+ }
77
+ return `${s.slice(0, max - 1)}…`;
78
+ }
79
+
80
+ /**
81
+ * Markdown block for a tool invocation in the terminal chat (fenced YAML, same as live stream).
82
+ */
83
+ export function formatTuiToolInputMarkdown(toolName: string, input: unknown): string {
84
+ const yaml = truncateTuiYamlBlock(toolInputToYamlLike(toolName, input), TUI_YAML_BLOCK_MAX);
85
+ return `**Tool · ${toolName}**\n\n\`\`\`yaml\n${yaml}\n\`\`\``;
86
+ }
87
+
88
+ /**
89
+ * Markdown block for a tool result in the terminal chat.
90
+ */
91
+ export function formatTuiToolOutputMarkdown(output: unknown): string {
92
+ const yaml = truncateTuiYamlBlock(toolOutputToYamlLike(output), TUI_YAML_BLOCK_MAX);
93
+ return `→ **Result**\n\n\`\`\`yaml\n${yaml}\n\`\`\``;
94
+ }
95
+
96
+ /**
97
+ * Markdown block for a tool error in the terminal chat.
98
+ */
99
+ export function formatTuiToolErrorMarkdown(toolName: string, errorText: string): string {
100
+ return `⚠ **${toolName}**\n\n${errorText}`;
101
+ }
102
+
103
+ /**
104
+ * Markdown line for a denied tool in the terminal chat.
105
+ */
106
+ export function formatTuiToolDeniedMarkdown(toolName: string): string {
107
+ return `⛔ **${toolName}** (denied)`;
108
+ }
109
+
110
+ /**
111
+ * Markdown tool status for the terminal chat: headings plus fenced YAML (same shape as Telegram).
112
+ */
113
+ export function formatToolStreamEventForTui(ev: ToolStreamEvent): string {
114
+ switch (ev.type) {
115
+ case "tool_input":
116
+ return formatTuiToolInputMarkdown(ev.toolName, ev.input);
117
+ case "tool_output":
118
+ return formatTuiToolOutputMarkdown(ev.output);
119
+ case "tool_error":
120
+ return formatTuiToolErrorMarkdown(ev.toolName, ev.errorText);
121
+ case "tool_denied":
122
+ return formatTuiToolDeniedMarkdown(ev.toolName);
123
+ default:
124
+ return "";
125
+ }
126
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * YAML-like plain-text rendering for tool inputs and outputs.
3
+ * Shared by Telegram HTML and the terminal UI.
4
+ */
5
+
6
+ const DEFAULT_MAX_JSON_LEN = 800;
7
+
8
+ /**
9
+ * Serializes a value for one-line display, capped in length.
10
+ */
11
+ function truncateJson(value: unknown, maxLen: number = DEFAULT_MAX_JSON_LEN): string {
12
+ try {
13
+ const s = typeof value === "string" ? value : JSON.stringify(value);
14
+ if (s.length <= maxLen) {
15
+ return s;
16
+ }
17
+ return `${s.slice(0, maxLen)}…`;
18
+ } catch {
19
+ return String(value);
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Formats a scalar value for YAML-style lines (quote when ambiguous).
25
+ */
26
+ function formatYamlScalar(v: unknown): string {
27
+ if (typeof v === "string") {
28
+ if (/[\n\r:#]/.test(v) || v.length > 160 || v.includes('"')) {
29
+ return JSON.stringify(v);
30
+ }
31
+ return v;
32
+ }
33
+ try {
34
+ return JSON.stringify(v);
35
+ } catch {
36
+ return String(v);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Flattens a plain object into indented YAML-style key lines.
42
+ */
43
+ function linesForObject(obj: Record<string, unknown>, indent: number): string[] {
44
+ const pad = " ".repeat(indent);
45
+ const out: string[] = [];
46
+ for (const [k, v] of Object.entries(obj)) {
47
+ if (v !== null && typeof v === "object" && !Array.isArray(v)) {
48
+ out.push(`${pad}${k}:`);
49
+ out.push(...linesForObject(v as Record<string, unknown>, indent + 1));
50
+ } else {
51
+ out.push(`${pad}${k}: ${formatYamlScalar(v)}`);
52
+ }
53
+ }
54
+ return out;
55
+ }
56
+
57
+ /**
58
+ * Renders tool input as a compact YAML-style string (tool name as top-level key).
59
+ */
60
+ export function toolInputToYamlLike(toolName: string, input: unknown): string {
61
+ if (input !== null && typeof input === "object" && !Array.isArray(input)) {
62
+ const lines = [`${toolName}:`];
63
+ lines.push(...linesForObject(input as Record<string, unknown>, 1));
64
+ return lines.join("\n");
65
+ }
66
+ return `${toolName}: ${truncateJson(input)}`;
67
+ }
68
+
69
+ /**
70
+ * YAML-style rendering for tool results (e.g. bash exitCode/stdout/stderr or generic objects).
71
+ */
72
+ export function toolOutputToYamlLike(output: unknown): string {
73
+ if (output !== null && typeof output === "object" && !Array.isArray(output)) {
74
+ const o = output as Record<string, unknown>;
75
+ if ("exitCode" in o && ("stdout" in o || "stderr" in o)) {
76
+ const lines: string[] = [`exitCode: ${formatYamlScalar(o.exitCode)}`];
77
+ const stdout = typeof o.stdout === "string" ? o.stdout : String(o.stdout ?? "");
78
+ const stderr = typeof o.stderr === "string" ? o.stderr : String(o.stderr ?? "");
79
+ if (stdout.includes("\n")) {
80
+ lines.push("stdout: |");
81
+ for (const line of stdout.split("\n")) {
82
+ lines.push(` ${line}`);
83
+ }
84
+ } else {
85
+ lines.push(`stdout: ${formatYamlScalar(stdout)}`);
86
+ }
87
+ lines.push(`stderr: ${formatYamlScalar(stderr)}`);
88
+ return lines.join("\n");
89
+ }
90
+ return linesForObject(o, 0).join("\n");
91
+ }
92
+ if (typeof output === "string") {
93
+ return output;
94
+ }
95
+ return truncateJson(output, 2000);
96
+ }
@@ -0,0 +1,100 @@
1
+ import { spawn } from "node:child_process";
2
+ import { homedir } from "node:os";
3
+ import { tool } from "ai";
4
+ import { z } from "zod";
5
+ import { isSandboxRestricted } from "../turn-context";
6
+
7
+ const DEFAULT_TIMEOUT_MS = 60_000;
8
+ const MAX_OUTPUT_BYTES = 256_000;
9
+
10
+ function runBunSpawn(
11
+ command: string,
12
+ cwd: string,
13
+ timeoutMs: number,
14
+ ): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
15
+ return new Promise((resolve, reject) => {
16
+ const proc = spawn("/bin/sh", ["-c", command], {
17
+ cwd,
18
+ env: { ...process.env, PATH: process.env.PATH },
19
+ stdio: ["ignore", "pipe", "pipe"],
20
+ });
21
+
22
+ let stdout = "";
23
+ let stderr = "";
24
+ let killed = false;
25
+
26
+ const timer = setTimeout(() => {
27
+ killed = true;
28
+ proc.kill("SIGTERM");
29
+ }, timeoutMs);
30
+
31
+ const cap = (chunk: Buffer, acc: { s: string; n: number }) => {
32
+ const str = chunk.toString("utf8");
33
+ if (acc.n >= MAX_OUTPUT_BYTES) {
34
+ return;
35
+ }
36
+ const take = Math.min(str.length, MAX_OUTPUT_BYTES - acc.n);
37
+ acc.s += str.slice(0, take);
38
+ acc.n += take;
39
+ };
40
+
41
+ const outAcc = { s: "", n: 0 };
42
+ const errAcc = { s: "", n: 0 };
43
+
44
+ proc.stdout?.on("data", (d: Buffer) => cap(d, outAcc));
45
+ proc.stderr?.on("data", (d: Buffer) => cap(d, errAcc));
46
+
47
+ proc.on("error", (err) => {
48
+ clearTimeout(timer);
49
+ reject(err);
50
+ });
51
+
52
+ proc.on("close", (code) => {
53
+ clearTimeout(timer);
54
+ if (killed) {
55
+ resolve({
56
+ stdout: outAcc.s,
57
+ stderr: errAcc.s + "\n[openpaw] Command timed out.",
58
+ exitCode: 124,
59
+ });
60
+ return;
61
+ }
62
+ resolve({ stdout: outAcc.s, stderr: errAcc.s, exitCode: code });
63
+ });
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Runs a shell command; cwd is the workspace when sandbox is on, or the user home when off.
69
+ */
70
+ export function createBashTool(workspaceRoot: string) {
71
+ return tool({
72
+ description:
73
+ "Run a shell command via sh -c. With sandbox on, cwd is the OpenPaw workspace root; with sandbox off, cwd is the user home directory. Avoid destructive commands unless the user asked.",
74
+ inputSchema: z.object({
75
+ command: z.string().describe("Shell command to run (sh -c)"),
76
+ timeoutMs: z
77
+ .number()
78
+ .optional()
79
+ .describe(`Optional timeout in ms (default ${DEFAULT_TIMEOUT_MS})`),
80
+ }),
81
+ execute: async ({ command, timeoutMs }) => {
82
+ const ms = timeoutMs ?? DEFAULT_TIMEOUT_MS;
83
+ const cwd = isSandboxRestricted() ? workspaceRoot : homedir();
84
+ try {
85
+ const { stdout, stderr, exitCode } = await runBunSpawn(command, cwd, ms);
86
+ return {
87
+ exitCode,
88
+ stdout,
89
+ stderr,
90
+ };
91
+ } catch (e) {
92
+ return {
93
+ exitCode: -1,
94
+ stdout: "",
95
+ stderr: e instanceof Error ? e.message : String(e),
96
+ };
97
+ }
98
+ },
99
+ });
100
+ }