@ikarmic/forge-core 5.3.0 → 5.4.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 (113) hide show
  1. package/dist/agent/agent.js +132 -70
  2. package/dist/agent/cache-hints.d.ts +2 -0
  3. package/dist/agent/cache-hints.js +18 -3
  4. package/dist/agent/history-adapt.d.ts +23 -0
  5. package/dist/agent/history-adapt.js +114 -0
  6. package/dist/agent/prompts.d.ts +4 -2
  7. package/dist/agent/prompts.js +10 -2
  8. package/dist/agent/tool-repair.d.ts +8 -0
  9. package/dist/agent/tool-repair.js +16 -0
  10. package/dist/catalog/models-dev.d.ts +10 -0
  11. package/dist/catalog/models-dev.js +12 -0
  12. package/dist/checkpoints/store.js +3 -1
  13. package/dist/context/compaction.d.ts +21 -6
  14. package/dist/context/compaction.js +86 -26
  15. package/dist/context/mask.d.ts +9 -9
  16. package/dist/context/mask.js +30 -1
  17. package/dist/format/auto-format.d.ts +22 -0
  18. package/dist/format/auto-format.js +39 -0
  19. package/dist/github/github-tools.d.ts +1 -0
  20. package/dist/github/github-tools.js +9 -9
  21. package/dist/github/pr.d.ts +34 -0
  22. package/dist/github/pr.js +70 -0
  23. package/dist/human-text.d.ts +6 -0
  24. package/dist/human-text.js +37 -0
  25. package/dist/index.d.ts +13 -8
  26. package/dist/index.js +10 -6
  27. package/dist/journey/answers-block.d.ts +2 -4
  28. package/dist/lsp/diagnostics.d.ts +4 -0
  29. package/dist/lsp/diagnostics.js +8 -2
  30. package/dist/lsp/index.d.ts +1 -1
  31. package/dist/lsp/index.js +1 -1
  32. package/dist/mcp/index.d.ts +1 -1
  33. package/dist/mcp/index.js +1 -1
  34. package/dist/mcp/json-schema-to-zod.js +2 -4
  35. package/dist/mcp/mcp-client.d.ts +14 -0
  36. package/dist/mcp/mcp-client.js +102 -18
  37. package/dist/orchestrator/specialists-registry.js +14 -1
  38. package/dist/orchestrator/specialists.js +3 -3
  39. package/dist/semantic/embedder.d.ts +11 -0
  40. package/dist/semantic/embedder.js +16 -0
  41. package/dist/semantic/semantic-index.d.ts +39 -1
  42. package/dist/semantic/semantic-index.js +43 -13
  43. package/dist/semantic/vector-store.d.ts +2 -0
  44. package/dist/semantic/vector-store.js +13 -1
  45. package/dist/sessions/index.d.ts +2 -2
  46. package/dist/sessions/index.js +1 -1
  47. package/dist/sessions/search.js +2 -2
  48. package/dist/sessions/session-export.d.ts +1 -1
  49. package/dist/sessions/session-export.js +41 -33
  50. package/dist/sessions/session-store.d.ts +20 -1
  51. package/dist/sessions/session-store.js +7 -0
  52. package/dist/sessions/watch.js +4 -4
  53. package/dist/skills/activator.d.ts +6 -0
  54. package/dist/skills/activator.js +17 -0
  55. package/dist/skills/skill-tool.d.ts +12 -0
  56. package/dist/skills/skill-tool.js +28 -0
  57. package/dist/tools/background-process.d.ts +63 -0
  58. package/dist/tools/background-process.js +150 -0
  59. package/dist/tools/edit-file.js +2 -27
  60. package/dist/tools/edit-match.d.ts +16 -0
  61. package/dist/tools/edit-match.js +115 -0
  62. package/dist/tools/fetch-url.d.ts +15 -1
  63. package/dist/tools/fetch-url.js +7 -47
  64. package/dist/tools/get-diagnostics.d.ts +14 -0
  65. package/dist/tools/get-diagnostics.js +44 -0
  66. package/dist/tools/glob-filter.d.ts +7 -0
  67. package/dist/tools/glob-filter.js +12 -0
  68. package/dist/tools/html-to-markdown.d.ts +13 -0
  69. package/dist/tools/html-to-markdown.js +507 -0
  70. package/dist/tools/index.d.ts +5 -1
  71. package/dist/tools/index.js +11 -1
  72. package/dist/tools/list-files.d.ts +1 -0
  73. package/dist/tools/list-files.js +17 -5
  74. package/dist/tools/list-symbols.js +2 -2
  75. package/dist/tools/network-policy.d.ts +5 -0
  76. package/dist/tools/network-policy.js +77 -0
  77. package/dist/tools/office-docs.d.ts +6 -0
  78. package/dist/tools/office-docs.js +393 -0
  79. package/dist/tools/permission-rules.js +55 -5
  80. package/dist/tools/read-file.d.ts +2 -0
  81. package/dist/tools/read-file.js +47 -10
  82. package/dist/tools/read-image.js +2 -2
  83. package/dist/tools/read-only.d.ts +1 -1
  84. package/dist/tools/read-only.js +4 -0
  85. package/dist/tools/repo-clone.d.ts +40 -0
  86. package/dist/tools/repo-clone.js +218 -0
  87. package/dist/tools/result-cap.d.ts +8 -0
  88. package/dist/tools/result-cap.js +44 -0
  89. package/dist/tools/run-command.d.ts +18 -1
  90. package/dist/tools/run-command.js +41 -16
  91. package/dist/tools/search-files.js +5 -5
  92. package/dist/tools/search-worker.js +11 -2
  93. package/dist/tools/shared.d.ts +14 -1
  94. package/dist/tools/shared.js +76 -4
  95. package/dist/tools/shell.d.ts +27 -0
  96. package/dist/tools/shell.js +80 -0
  97. package/dist/tools/sub-agent.js +3 -1
  98. package/dist/tools/suggest-plan.d.ts +18 -0
  99. package/dist/tools/suggest-plan.js +28 -0
  100. package/dist/types.d.ts +74 -0
  101. package/dist/wakeup/wakeup-tools.d.ts +22 -0
  102. package/dist/wakeup/wakeup-tools.js +34 -0
  103. package/dist/wakeup/wakeup.d.ts +75 -0
  104. package/dist/wakeup/wakeup.js +151 -0
  105. package/dist/worktree/health.d.ts +51 -0
  106. package/dist/worktree/health.js +186 -0
  107. package/dist/worktree/index.d.ts +4 -0
  108. package/dist/worktree/index.js +2 -0
  109. package/dist/worktree/setup.d.ts +51 -0
  110. package/dist/worktree/setup.js +173 -0
  111. package/dist/worktree/worktree.d.ts +7 -0
  112. package/dist/worktree/worktree.js +9 -4
  113. package/package.json +1 -1
@@ -24,7 +24,7 @@
24
24
  */
25
25
  import { readFile } from 'node:fs/promises';
26
26
  import { z } from 'zod';
27
- import { resolveAndCheckPath } from './shared.js';
27
+ import { resolveReadPath } from './shared.js';
28
28
  /** Default cap: matches typical vision API limits. */
29
29
  const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
30
30
  /** Mime types we accept. Keep this list small: vision APIs vary. */
@@ -54,7 +54,7 @@ export const readImageTool = {
54
54
  description: 'Read an image file (PNG / JPEG / GIF / WebP) and return its bytes as a base64 data URL. Use this to inspect screenshots, diagrams, UI mocks, or other visual content. The image is attached to your next turn so vision-capable models can see it. Max 5 MB by default.',
55
55
  parameters: params,
56
56
  execute: async (args, ctx) => {
57
- const resolved = resolveAndCheckPath(args.path, ctx);
57
+ const resolved = await resolveReadPath(args.path, ctx);
58
58
  const maxBytes = args.max_bytes ?? DEFAULT_MAX_BYTES;
59
59
  let raw;
60
60
  try {
@@ -3,6 +3,6 @@
3
3
  * anything. Asking before each of them is noise, so they are approved without a prompt by default;
4
4
  * edits, commands, web access and sub-agents still ask.
5
5
  */
6
- export declare const READ_ONLY_TOOLS: readonly ["read_file", "list_files", "search_files", "find_symbol", "list_symbols", "find_references", "git_diff", "codebase_search", "memory_read", "sub_agent_wait"];
6
+ export declare const READ_ONLY_TOOLS: readonly ["read_file", "list_files", "search_files", "find_symbol", "list_symbols", "find_references", "git_diff", "codebase_search", "memory_read", "sub_agent_wait", "skill", "suggest_plan"];
7
7
  /** The tools that run without a prompt: the user's list, or the read-only tools when none is set. */
8
8
  export declare function resolveAutoApprove(configured?: readonly string[]): string[];
@@ -16,6 +16,10 @@ export const READ_ONLY_TOOLS = [
16
16
  'memory_read',
17
17
  // Only observes the sub-agents the person already let start.
18
18
  'sub_agent_wait',
19
+ // Returns the text of a skill that is already loaded; starts nothing.
20
+ 'skill',
21
+ // Only signals the chat, which asks the person before anything happens.
22
+ 'suggest_plan',
19
23
  ];
20
24
  /** The tools that run without a prompt: the user's list, or the read-only tools when none is set. */
21
25
  export function resolveAutoApprove(configured) {
@@ -0,0 +1,40 @@
1
+ import { z } from 'zod';
2
+ import type { ToolDefinition } from '../types.js';
3
+ export interface RepoSpec {
4
+ host: string;
5
+ owner: string;
6
+ repo: string;
7
+ /** What git is given. */
8
+ url: string;
9
+ }
10
+ /** Read what the model gave; throws a sentence saying what is wrong. */
11
+ export declare function parseRepoSpec(input: string): RepoSpec;
12
+ /** Arguments for `git clone`. Options end at `--`, so an address can never be read as one. */
13
+ export declare function cloneArgs(spec: Pick<RepoSpec, 'url'>, dest: string, emptyHooks: string, ref?: string): string[];
14
+ export interface CloneOptions {
15
+ cacheRoot: string;
16
+ ref?: string;
17
+ timeoutMs?: number;
18
+ maxBytes?: number;
19
+ /** Tests only: config applied after the safety settings, to reach a local fixture. */
20
+ testGitConfig?: string[];
21
+ }
22
+ /** What went wrong, from git's stderr: its own `fatal:` and `error:` lines, not the progress text before them. */
23
+ export declare function gitProblem(stderr: string): string;
24
+ /** Clone, or refresh an earlier clone. Returns the folder and whether it was a refresh. */
25
+ export declare function cloneInto(spec: RepoSpec, options: CloneOptions): Promise<{
26
+ path: string;
27
+ updated: boolean;
28
+ note?: string;
29
+ }>;
30
+ declare const params: z.ZodObject<{
31
+ repo: z.ZodString;
32
+ ref: z.ZodOptional<z.ZodString>;
33
+ }, z.core.$strip>;
34
+ export declare const repoCloneTool: ToolDefinition<z.infer<typeof params>, {
35
+ path: string;
36
+ url: string;
37
+ updated: boolean;
38
+ note: string;
39
+ }>;
40
+ export {};
@@ -0,0 +1,218 @@
1
+ /**
2
+ * `repo_clone`: fetch a public repository into a cache so the read tools can look at it.
3
+ *
4
+ * A model supplies the address and the repository is a stranger's files, so: only https and ssh to a named host
5
+ * (never `file:`, `ext::`, a local path or a plain `http:`), no credentials in the address, no internal host
6
+ * (RB-21), git run with hooks, fsmonitor, submodules and symlinks off, a size and a time limit, and the folder it
7
+ * leaves is readable by read tools only, for this run only (`ToolContext.readRoots`, RB-20). Starting git is
8
+ * starting a program the repository's settings can influence, so it asks first (RB-41).
9
+ */
10
+ import { execFile } from 'node:child_process';
11
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync } from 'node:fs';
12
+ import net from 'node:net';
13
+ import { homedir, tmpdir } from 'node:os';
14
+ import path from 'node:path';
15
+ import { z } from 'zod';
16
+ import { assertUrlNotBlocked, isBlockedAddress } from './fetch-url.js';
17
+ const GENERIC = 'Give a repository as owner/repo (GitHub), an https address, or an ssh address such as git@host:owner/repo. Local paths and other protocols are refused.';
18
+ const NAME = /^[A-Za-z0-9._-]+$/;
19
+ const HOST = /^[a-z0-9.-]+$/;
20
+ const REF = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
21
+ function validName(n) {
22
+ return NAME.test(n) && n !== '.' && n !== '..';
23
+ }
24
+ function checkHost(host) {
25
+ const bare = host.toLowerCase().replace(/^\[|\]$/g, '');
26
+ if (bare === 'localhost' || bare.endsWith('.localhost') || bare.endsWith('.local') || bare.endsWith('.internal'))
27
+ throw new Error(`"${host}" is an internal host, not allowed.`);
28
+ if (net.isIP(bare)) {
29
+ if (isBlockedAddress(bare))
30
+ throw new Error(`"${host}" is a loopback, private or link-local address, not allowed.`);
31
+ return bare;
32
+ }
33
+ if (!HOST.test(bare) || bare.startsWith('.') || bare.endsWith('.') || bare.includes('..'))
34
+ throw new Error(`"${host}" is not a valid host name.`);
35
+ return bare;
36
+ }
37
+ function ownerRepo(parts) {
38
+ const [owner, rawRepo, ...rest] = parts;
39
+ const repo = rawRepo?.replace(/\.git$/, '');
40
+ if (!owner || !repo || rest.length > 0)
41
+ throw new Error('The address must name an owner and a repository, like https://host/owner/repo.');
42
+ if (!validName(owner) || !validName(repo))
43
+ throw new Error(`Invalid owner or repository name: ${owner}/${repo}.`);
44
+ return { owner, repo };
45
+ }
46
+ /** Read what the model gave; throws a sentence saying what is wrong. */
47
+ export function parseRepoSpec(input) {
48
+ const spec = input.trim();
49
+ if (!spec)
50
+ throw new Error(`The repository is empty. ${GENERIC}`);
51
+ if (spec.startsWith('-'))
52
+ throw new Error(GENERIC);
53
+ // owner/repo
54
+ const short = /^([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)$/.exec(spec);
55
+ if (short) {
56
+ if (!validName(short[1]) || !validName(short[2].replace(/\.git$/, '')))
57
+ throw new Error(GENERIC);
58
+ const { owner, repo } = ownerRepo([short[1], short[2]]);
59
+ return { host: 'github.com', owner, repo, url: `https://github.com/${owner}/${repo}.git` };
60
+ }
61
+ // git@host:owner/repo
62
+ const scp = /^([A-Za-z0-9._-]+)@([A-Za-z0-9.[\]:-]+?):(?!\/\/)(\S+)$/.exec(spec);
63
+ if (scp && !spec.includes('://')) {
64
+ const host = checkHost(scp[2]);
65
+ const { owner, repo } = ownerRepo(scp[3].replace(/^\/+/, '').split('/'));
66
+ return { host, owner, repo, url: `${scp[1]}@${host}:${owner}/${repo}.git` };
67
+ }
68
+ let u;
69
+ try {
70
+ u = new URL(spec);
71
+ }
72
+ catch {
73
+ throw new Error(GENERIC);
74
+ }
75
+ if (u.protocol !== 'https:' && u.protocol !== 'ssh:')
76
+ throw new Error(`Only https and ssh addresses are allowed (got ${u.protocol}). ${GENERIC}`);
77
+ if (u.password || (u.protocol === 'https:' && u.username))
78
+ throw new Error('Credentials in the address are not accepted. Clone public repositories, or set up your own git credentials.');
79
+ if (u.port)
80
+ throw new Error('An address with a port is not accepted.');
81
+ const host = checkHost(u.hostname);
82
+ const { owner, repo } = ownerRepo(u.pathname.replace(/^\/+|\/+$/g, '').split('/'));
83
+ const url = u.protocol === 'https:' ? `https://${host}/${owner}/${repo}.git` : `ssh://${u.username || 'git'}@${host}/${owner}/${repo}.git`;
84
+ return { host, owner, repo, url };
85
+ }
86
+ /** The safety settings every git call here carries. */
87
+ function safety(emptyHooks) {
88
+ return [
89
+ '-c', `core.hooksPath=${emptyHooks}`,
90
+ '-c', 'core.fsmonitor=false',
91
+ '-c', 'core.symlinks=false',
92
+ '-c', 'protocol.file.allow=never',
93
+ '-c', 'protocol.ext.allow=never',
94
+ ];
95
+ }
96
+ /** Arguments for `git clone`. Options end at `--`, so an address can never be read as one. */
97
+ export function cloneArgs(spec, dest, emptyHooks, ref) {
98
+ return [
99
+ ...safety(emptyHooks),
100
+ 'clone', '--depth', '1', '--no-tags', '--single-branch', '--no-recurse-submodules', `--template=${emptyHooks}`,
101
+ ...(ref ? ['--branch', ref] : []),
102
+ '--', spec.url, dest,
103
+ ];
104
+ }
105
+ const DEFAULT_TIMEOUT_MS = 120_000;
106
+ const DEFAULT_MAX_BYTES = 300 * 1024 * 1024;
107
+ /** What went wrong, from git's stderr: its own `fatal:` and `error:` lines, not the progress text before them. */
108
+ export function gitProblem(stderr) {
109
+ const lines = stderr.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
110
+ const own = lines.filter((l) => /^(fatal|error|remote: (error|fatal)|warning: (could not|unable))/i.test(l));
111
+ return (own.length > 0 ? own.slice(-4) : lines.slice(-3)).join(' ').slice(0, 400);
112
+ }
113
+ function runGit(args, opts) {
114
+ return new Promise((resolve, reject) => {
115
+ execFile('git', args, {
116
+ cwd: opts.cwd,
117
+ timeout: opts.timeoutMs,
118
+ windowsHide: true,
119
+ maxBuffer: 4 * 1024 * 1024,
120
+ env: {
121
+ ...process.env,
122
+ GIT_TERMINAL_PROMPT: '0',
123
+ GIT_ASKPASS: '',
124
+ GIT_SSH_COMMAND: 'ssh -o BatchMode=yes',
125
+ GIT_ALLOW_PROTOCOL: opts.allowFile ? 'https:ssh:file' : 'https:ssh',
126
+ },
127
+ }, (err, stdout, stderr) => {
128
+ if (!err)
129
+ return resolve(String(stdout));
130
+ const killed = err.killed;
131
+ reject(new Error(killed ? `git took longer than ${Math.round(opts.timeoutMs / 1000)} seconds and was stopped.` : `git failed: ${gitProblem(String(stderr || err.message))}`));
132
+ });
133
+ });
134
+ }
135
+ function sizeOf(dir) {
136
+ let total = 0;
137
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
138
+ const p = path.join(dir, entry.name);
139
+ total += entry.isDirectory() ? sizeOf(p) : lstatSync(p).size;
140
+ }
141
+ return total;
142
+ }
143
+ /** Clone, or refresh an earlier clone. Returns the folder and whether it was a refresh. */
144
+ export async function cloneInto(spec, options) {
145
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
146
+ const allowFile = (options.testGitConfig?.length ?? 0) > 0;
147
+ const test = options.testGitConfig ?? [];
148
+ const dest = path.join(options.cacheRoot, spec.host, spec.owner, spec.repo);
149
+ const emptyHooks = mkdtempSync(path.join(tmpdir(), 'forge-nohooks-'));
150
+ try {
151
+ if (existsSync(path.join(dest, '.git'))) {
152
+ try {
153
+ const target = options.ref ?? 'HEAD';
154
+ await runGit([...safety(emptyHooks), ...test, 'fetch', '--depth', '1', '--no-tags', '--no-recurse-submodules', 'origin', target], { cwd: dest, timeoutMs, allowFile });
155
+ await runGit([...safety(emptyHooks), 'reset', '--hard', '-q', 'FETCH_HEAD'], { cwd: dest, timeoutMs, allowFile });
156
+ return { path: dest, updated: true };
157
+ }
158
+ catch (err) {
159
+ return { path: dest, updated: false, note: `Could not refresh it, so this is the copy fetched earlier. ${err.message}` };
160
+ }
161
+ }
162
+ mkdirSync(path.dirname(dest), { recursive: true });
163
+ const partial = `${dest}.partial-${process.pid}-${Date.now()}`;
164
+ try {
165
+ await runGit(insertTest(cloneArgs(spec, partial, emptyHooks, options.ref), test), { timeoutMs, allowFile });
166
+ if (sizeOf(partial) > (options.maxBytes ?? DEFAULT_MAX_BYTES))
167
+ throw new Error('The repository is too large to fetch here.');
168
+ renameSync(partial, dest);
169
+ }
170
+ catch (err) {
171
+ rmSync(partial, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
172
+ throw err;
173
+ }
174
+ return { path: dest, updated: false };
175
+ }
176
+ finally {
177
+ rmSync(emptyHooks, { recursive: true, force: true });
178
+ }
179
+ }
180
+ /** Test config goes right after the safety settings and before the `clone` word, so it overrides them. */
181
+ function insertTest(args, test) {
182
+ const at = args.indexOf('clone');
183
+ return [...args.slice(0, at), ...test, ...args.slice(at)];
184
+ }
185
+ function defaultCacheRoot() {
186
+ return path.join(homedir(), '.forge', 'cache', 'repos');
187
+ }
188
+ const params = z.object({
189
+ repo: z.string().describe('owner/repo for GitHub, an https address, or an ssh address such as git@host:owner/repo.'),
190
+ ref: z.string().optional().describe('A branch or tag to fetch. Default: the repository default branch.'),
191
+ });
192
+ export const repoCloneTool = {
193
+ name: 'repo_clone',
194
+ description: 'Fetch a public git repository (a shallow copy, no submodules) into a cache and return its folder, so you can read it with read_file, list_files and search_files. ' +
195
+ 'Only https and ssh addresses and owner/repo are accepted. The copy is read-only for you, and a repeat call refreshes it. It reaches the network and starts git, so it asks first.',
196
+ parameters: params,
197
+ destructive: true,
198
+ execute: async (args, ctx) => {
199
+ const spec = parseRepoSpec(args.repo);
200
+ if (args.ref !== undefined && !REF.test(args.ref))
201
+ throw new Error(`Invalid branch or tag name: ${args.ref}`);
202
+ // A name that resolves to an internal address is refused as well as a literal one (the same check as fetch_url).
203
+ await assertUrlNotBlocked(`https://${spec.host}/`, ctx.policy).catch((err) => {
204
+ throw new Error(err.message.replace(/^fetch_url: /, 'repo_clone: '));
205
+ });
206
+ const root = ctx.repoCache?.root ?? defaultCacheRoot();
207
+ const r = await cloneInto(spec, { cacheRoot: root, ...(args.ref ? { ref: args.ref } : {}), ...(ctx.repoCache?.testGitConfig ? { testGitConfig: ctx.repoCache.testGitConfig } : {}) });
208
+ // Read tools compare real paths on both sides (shared.ts), so this list needs no resolving.
209
+ if (ctx.readRoots && !ctx.readRoots.includes(r.path))
210
+ ctx.readRoots.push(r.path);
211
+ return {
212
+ path: r.path,
213
+ url: spec.url,
214
+ updated: r.updated,
215
+ note: `${r.note ? `${r.note} ` : ''}Read it with read_file, list_files and search_files using paths under ${r.path}. It is read-only; nothing you write goes there.`,
216
+ };
217
+ },
218
+ };
@@ -0,0 +1,8 @@
1
+ /** About 25,000 tokens. A surface can set its own through `AgentConfig.maxToolOutputChars`. */
2
+ export declare const DEFAULT_TOOL_RESULT_CHARS = 100000;
3
+ /**
4
+ * The result itself when it fits in `max` characters. Otherwise long strings inside it are cut first (so the shape
5
+ * stays); a result that is still too big is cut as text. An image or PDF (`dataUrl`) is attached by the agent, not
6
+ * read as text, so it is left alone.
7
+ */
8
+ export declare function capToolResult(result: unknown, max?: number): unknown;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * One size limit for what any tool hands back to the model (G6). `run_command` already cut its own streams; every
3
+ * other tool (an MCP server, `fetch_url`, a search with thousands of matches) could put a megabyte into the prompt.
4
+ */
5
+ import { clampOutput } from './run-command.js';
6
+ /** About 25,000 tokens. A surface can set its own through `AgentConfig.maxToolOutputChars`. */
7
+ export const DEFAULT_TOOL_RESULT_CHARS = 100_000;
8
+ function isMedia(value) {
9
+ const v = value;
10
+ return typeof v === 'object' && v !== null && typeof v.dataUrl === 'string';
11
+ }
12
+ function clampStrings(value, limit) {
13
+ if (typeof value === 'string')
14
+ return value.length > limit ? clampOutput(value, limit) : value;
15
+ if (Array.isArray(value))
16
+ return value.map((v) => clampStrings(v, limit));
17
+ if (typeof value === 'object' && value !== null) {
18
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, clampStrings(v, limit)]));
19
+ }
20
+ return value;
21
+ }
22
+ /**
23
+ * The result itself when it fits in `max` characters. Otherwise long strings inside it are cut first (so the shape
24
+ * stays); a result that is still too big is cut as text. An image or PDF (`dataUrl`) is attached by the agent, not
25
+ * read as text, so it is left alone.
26
+ */
27
+ export function capToolResult(result, max = DEFAULT_TOOL_RESULT_CHARS) {
28
+ if (typeof result === 'string')
29
+ return result.length > max ? clampOutput(result, max) : result;
30
+ if (result === null || typeof result !== 'object' || isMedia(result))
31
+ return result;
32
+ const size = JSON.stringify(result)?.length ?? 0;
33
+ if (size <= max)
34
+ return result;
35
+ // Keep as much of each long string as the budget allows: half the budget, then a quarter, then an eighth.
36
+ for (const divisor of [2, 4, 8]) {
37
+ const shrunk = clampStrings(result, Math.max(500, Math.floor(max / divisor)));
38
+ if ((JSON.stringify(shrunk)?.length ?? 0) <= max)
39
+ return shrunk;
40
+ }
41
+ // The text is a string inside the wrapper below, so its quotes get escaped again: leave room for that.
42
+ const text = JSON.stringify(result);
43
+ return { truncatedResult: clampOutput(text, Math.floor(max * 0.8)), note: `The result was ${size} characters and was cut to ${max}. Ask for less (a narrower path, a smaller range, a more specific pattern).` };
44
+ }
@@ -1,5 +1,22 @@
1
+ import { spawn } from 'node:child_process';
1
2
  import { z } from 'zod';
2
- import type { ToolDefinition } from '../types.js';
3
+ import type { ToolContext, ToolDefinition } from '../types.js';
4
+ /**
5
+ * Kill a child process and its whole tree. Windows: `taskkill /T`. POSIX: the child is started as the leader of its
6
+ * own process group (`detached`), and the signal goes to the group (`-pid`); signalling only the shell left whatever
7
+ * it had forked (`sh -c "node ..."`) running, holding the output pipes open, so a stopped or timed-out command
8
+ * never finished. SIGKILL follows after a grace period if the process is still alive.
9
+ */
10
+ export declare function killTree(child: ReturnType<typeof spawn>, graceMs?: number): void;
11
+ /**
12
+ * The checks every command Forge starts goes through, shared by `run_command` and `bg_start`: the working directory
13
+ * must be inside the project, the tool policy must allow the command, and the OS sandbox may wrap or refuse it.
14
+ * `commandLine` is what to spawn; the result still reports the command the model asked for.
15
+ */
16
+ export declare function prepareCommand(command: string, cwdOverride: string | undefined, ctx: ToolContext): {
17
+ cwd: string;
18
+ commandLine: string;
19
+ };
3
20
  declare const params: z.ZodObject<{
4
21
  command: z.ZodString;
5
22
  timeout_ms: z.ZodOptional<z.ZodNumber>;
@@ -5,13 +5,14 @@ import { sandboxCommand } from './command-sandbox.js';
5
5
  import { checkPolicyViolation } from './policy.js';
6
6
  import { resolveAndCheckPath } from './shared.js';
7
7
  import { shellEnv } from './shell-env.js';
8
+ import { runnerShell, shellInvocation } from './shell.js';
8
9
  /**
9
10
  * Kill a child process and its whole tree. Windows: `taskkill /T`. POSIX: the child is started as the leader of its
10
11
  * own process group (`detached`), and the signal goes to the group (`-pid`); signalling only the shell left whatever
11
12
  * it had forked (`sh -c "node ..."`) running, holding the output pipes open, so a stopped or timed-out command
12
13
  * never finished. SIGKILL follows after a grace period if the process is still alive.
13
14
  */
14
- function killTree(child, graceMs = 5000) {
15
+ export function killTree(child, graceMs = 5000) {
15
16
  if (process.platform === 'win32') {
16
17
  try {
17
18
  spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
@@ -34,6 +35,30 @@ function killTree(child, graceMs = 5000) {
34
35
  signalTree('SIGTERM');
35
36
  setTimeout(() => signalTree('SIGKILL'), graceMs).unref();
36
37
  }
38
+ /**
39
+ * The checks every command Forge starts goes through, shared by `run_command` and `bg_start`: the working directory
40
+ * must be inside the project, the tool policy must allow the command, and the OS sandbox may wrap or refuse it.
41
+ * `commandLine` is what to spawn; the result still reports the command the model asked for.
42
+ */
43
+ export function prepareCommand(command, cwdOverride, ctx) {
44
+ // resolveAndCheckPath throws if the override escapes ctx.cwd or violates ctx.policy's path rules.
45
+ const cwd = cwdOverride ? resolveAndCheckPath(cwdOverride, ctx) : ctx.cwd;
46
+ const violation = checkPolicyViolation(ctx.policy, { cwd, command });
47
+ if (violation)
48
+ throw new Error(`[sandbox] ${violation}`);
49
+ let commandLine = command;
50
+ if (ctx.commandSandbox) {
51
+ const boxed = sandboxCommand(command, { cwd, sandbox: ctx.commandSandbox, platform: process.platform, tmpDir: tmpdir() });
52
+ if (boxed.kind === 'refused')
53
+ throw new Error(`[sandbox] ${boxed.reason}`);
54
+ // A sandbox was asked for and this system cannot give one: say so, unless the person was told and chose to go on (F13).
55
+ if (boxed.kind === 'unavailable' && !ctx.commandSandbox.allowUnconfined) {
56
+ throw new Error('[sandbox] The command sandbox is on, but this system cannot confine commands (Windows has no sandbox tool), so the command was not run. Turn the sandbox off to run commands unconfined.');
57
+ }
58
+ commandLine = boxed.command;
59
+ }
60
+ return { cwd, commandLine };
61
+ }
37
62
  const params = z.object({
38
63
  command: z.string().describe('The shell command to execute. Use arrays for cross-platform safety when possible.'),
39
64
  /**
@@ -61,19 +86,8 @@ export const runCommandTool = {
61
86
  // to spawn() with no check, and the policy check below used the
62
87
  // parent's original ctx.cwd regardless, so a configured path policy
63
88
  // could never see (let alone block) the override.
64
- const cwd = args.cwd ? resolveAndCheckPath(args.cwd, ctx) : ctx.cwd;
65
- const violation = checkPolicyViolation(ctx.policy, { cwd, command: args.command });
66
- if (violation)
67
- throw new Error(`[sandbox] ${violation}`);
89
+ const { cwd, commandLine } = prepareCommand(args.command, args.cwd, ctx);
68
90
  const timeoutMs = args.timeout_ms ?? DEFAULT_TIMEOUT_MS;
69
- // The OS sandbox wraps the command line; the result still reports the command the model asked for.
70
- let commandLine = args.command;
71
- if (ctx.commandSandbox) {
72
- const boxed = sandboxCommand(args.command, { cwd, sandbox: ctx.commandSandbox, platform: process.platform, tmpDir: tmpdir() });
73
- if (boxed.kind === 'refused')
74
- throw new Error(`[sandbox] ${boxed.reason}`);
75
- commandLine = boxed.command;
76
- }
77
91
  if (ctx.commandRunner) {
78
92
  const started = Date.now();
79
93
  const out = await ctx.commandRunner({
@@ -101,8 +115,9 @@ export const runCommandTool = {
101
115
  }
102
116
  return new Promise((resolve, reject) => {
103
117
  const started = Date.now();
104
- const child = spawn(commandLine, {
105
- shell: true,
118
+ // Windows: PowerShell or Git Bash when installed (G11), else cmd.exe; elsewhere /bin/sh.
119
+ const inv = shellInvocation(runnerShell(ctx.shell), commandLine);
120
+ const options = {
106
121
  cwd,
107
122
  // Windows: never run a same-named program the repository ships in its root (see shell-env.ts).
108
123
  env: shellEnv(process.env),
@@ -110,7 +125,8 @@ export const runCommandTool = {
110
125
  windowsHide: true,
111
126
  // POSIX: own process group, so a kill reaches everything the shell started (see killTree).
112
127
  detached: process.platform !== 'win32',
113
- });
128
+ };
129
+ const child = inv.file ? spawn(inv.file, inv.args, options) : spawn(commandLine, { ...options, shell: true });
114
130
  const stdoutChunks = [];
115
131
  const stderrChunks = [];
116
132
  child.stdout.on('data', (chunk) => stdoutChunks.push(chunk));
@@ -125,6 +141,15 @@ export const runCommandTool = {
125
141
  }
126
142
  const onAbort = () => killTree(child);
127
143
  ctx.abortSignal?.addEventListener('abort', onAbort, { once: true });
144
+ // A stopped command can leave a grandchild that started in the instant the tree was listed (taskkill /T cannot see
145
+ // it) holding the output pipes, so 'close' would wait for it. Once the shell itself has exited, give the pipes a
146
+ // moment to drain and then release them.
147
+ child.on('exit', () => {
148
+ setTimeout(() => {
149
+ child.stdout.destroy();
150
+ child.stderr.destroy();
151
+ }, 500).unref();
152
+ });
128
153
  child.on('error', (err) => {
129
154
  if (timeoutHandle)
130
155
  clearTimeout(timeoutHandle);
@@ -1,9 +1,9 @@
1
1
  import path from 'node:path';
2
2
  import { z } from 'zod';
3
- import { globToRegex } from './policy.js';
3
+ import { fileGlobMatcher } from './glob-filter.js';
4
4
  import { IgnoreRules } from './ignore-rules.js';
5
5
  import { DEFAULT_SEARCH_TIMEOUT_MS, matchInWorker } from './search-worker.js';
6
- import { resolveAndCheckPath, DEFAULT_IGNORED_DIRS } from './shared.js';
6
+ import { resolveReadPath, DEFAULT_IGNORED_DIRS } from './shared.js';
7
7
  const params = z.object({
8
8
  pattern: z.string().describe('Regex pattern to search for in file contents.'),
9
9
  path: z
@@ -28,9 +28,9 @@ export const searchFilesTool = {
28
28
  description: 'Search file contents using a regular expression. Returns matches with file path, line number, and surrounding context. Skips heavy directories (node_modules, .git, dist, ...) by default.',
29
29
  parameters: params,
30
30
  execute: async (args, ctx) => {
31
- const root = resolveAndCheckPath(args.path ?? '.', ctx);
31
+ const root = await resolveReadPath(args.path ?? '.', ctx);
32
32
  const regex = safeRegex(args.pattern);
33
- const includeRe = args.include ? globToRegex(args.include) : null;
33
+ const matchesInclude = args.include ? fileGlobMatcher(args.include) : null;
34
34
  const maxResults = args.max_results ?? 200;
35
35
  const contextLines = args.context_lines ?? 1;
36
36
  // The walk (I/O and ignore rules) stays here; reading and matching run in a worker with a time limit (RB-22).
@@ -61,7 +61,7 @@ export const searchFilesTool = {
61
61
  }
62
62
  else if (entry.isFile()) {
63
63
  const rel = path.relative(root, abs).split(path.sep).join('/');
64
- if (includeRe && !includeRe.test(rel))
64
+ if (matchesInclude && !matchesInclude(rel, entry.name))
65
65
  continue;
66
66
  files.push({ abs, rel });
67
67
  }
@@ -19,6 +19,14 @@ const MATCH_BODY = String.raw `
19
19
  var dropped = lines.length - maxLines;
20
20
  return lines.slice(0, maxLines).join('\n') + '\n\n... (' + dropped + ' more line' + (dropped === 1 ? '' : 's') + ' truncated; re-read with a smaller offset to see more)';
21
21
  }
22
+ var LINE_MAX = 300;
23
+ // A match in a minified bundle is one huge line: show a window around the match, not the whole line (G5).
24
+ function clip(line, at) {
25
+ if (line.length <= LINE_MAX) return line;
26
+ var from = Math.max(0, Math.min(at - 100, line.length - LINE_MAX));
27
+ var to = Math.min(line.length, from + LINE_MAX);
28
+ return (from > 0 ? '...' : '') + line.slice(from, to) + (to < line.length ? '...' : '') + ' (' + (line.length - (to - from)) + ' more characters)';
29
+ }
22
30
  for (var f = 0; f < job.files.length; f++) {
23
31
  if (matches.length >= job.maxResults) { truncated = true; break; }
24
32
  var file = job.files[f];
@@ -30,11 +38,12 @@ const MATCH_BODY = String.raw `
30
38
  for (var i = 0; i < lines.length; i++) {
31
39
  if (matches.length >= job.maxResults) { truncated = true; break; }
32
40
  regex.lastIndex = 0;
33
- if (regex.test(lines[i])) {
41
+ var hit = regex.exec(lines[i]);
42
+ if (hit) {
34
43
  var start = Math.max(0, i - job.contextLines);
35
44
  var end = Math.min(lines.length - 1, i + job.contextLines);
36
45
  var block = [];
37
- for (var j = start; j <= end; j++) block.push((j === i ? '>' : ' ') + ' ' + String(j + 1).padStart(6) + '\t' + lines[j]);
46
+ for (var j = start; j <= end; j++) block.push((j === i ? '>' : ' ') + ' ' + String(j + 1).padStart(6) + '\t' + clip(lines[j], j === i ? hit.index : 0));
38
47
  matches.push({ path: file.rel, line: i + 1, text: truncateByLines(block.join('\n'), 10) });
39
48
  }
40
49
  }
@@ -8,7 +8,18 @@ import type { ToolContext } from '../types.js';
8
8
  */
9
9
  export declare function resolveSafePath(inputPath: string, ctx: ToolContext, options?: {
10
10
  allowOutside?: boolean;
11
+ read?: boolean;
11
12
  }): string;
13
+ /** A path that leads outside the working directory: the one error a host may answer by asking (G16). */
14
+ export declare class OutsideWorkspaceError extends Error {
15
+ constructor(message: string);
16
+ }
17
+ /**
18
+ * `resolveAndCheckPath` for a READ tool, with one more way to succeed: when the path leads outside the project and the host
19
+ * can ask, the person is asked about the real directory (links resolved), once per directory per run. A yes opens that
20
+ * directory and below to read tools; a no, or a directory that is too broad or holds credentials, is a refusal.
21
+ */
22
+ export declare function resolveReadPath(inputPath: string, ctx: ToolContext): Promise<string>;
12
23
  /**
13
24
  * For a `path.relative(root, target)` result: does it lead out of `root`? `..` itself or `../…` does; a name that merely
14
25
  * starts with two dots (`..notes.md`) does not. Use this instead of `rel.startsWith('..')`.
@@ -20,7 +31,9 @@ export declare function isOutsideRel(rel: string): boolean;
20
31
  * tools call this instead of `resolveSafePath` directly so the sandbox is
21
32
  * applied uniformly.
22
33
  */
23
- export declare function resolveAndCheckPath(inputPath: string, ctx: ToolContext): string;
34
+ export declare function resolveAndCheckPath(inputPath: string, ctx: ToolContext, options?: {
35
+ read?: boolean;
36
+ }): string;
24
37
  /**
25
38
  * Truncate a string to a maximum number of lines, appending a marker so the
26
39
  * model knows the output was truncated and can re-read with a smaller window.