@ikarmic/forge-core 5.2.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 (129) hide show
  1. package/dist/agent/agent.js +324 -122
  2. package/dist/agent/cache-hints.d.ts +2 -0
  3. package/dist/agent/cache-hints.js +18 -3
  4. package/dist/agent/enhance-prompt.js +2 -1
  5. package/dist/agent/history-adapt.d.ts +23 -0
  6. package/dist/agent/history-adapt.js +114 -0
  7. package/dist/agent/prompts.d.ts +19 -2
  8. package/dist/agent/prompts.js +44 -2
  9. package/dist/agent/retry.d.ts +50 -0
  10. package/dist/agent/retry.js +192 -0
  11. package/dist/agent/shell-command.d.ts +23 -0
  12. package/dist/agent/shell-command.js +93 -0
  13. package/dist/agent/tool-repair.d.ts +8 -0
  14. package/dist/agent/tool-repair.js +16 -0
  15. package/dist/catalog/models-dev-snapshot.d.ts +1 -0
  16. package/dist/catalog/models-dev-snapshot.js +3 -0
  17. package/dist/catalog/models-dev.d.ts +45 -0
  18. package/dist/catalog/models-dev.js +155 -0
  19. package/dist/checkpoints/store.js +3 -1
  20. package/dist/context/compaction.d.ts +21 -6
  21. package/dist/context/compaction.js +94 -28
  22. package/dist/context/mask.d.ts +9 -9
  23. package/dist/context/mask.js +30 -1
  24. package/dist/format/auto-format.d.ts +22 -0
  25. package/dist/format/auto-format.js +39 -0
  26. package/dist/git/auto-commit.d.ts +4 -2
  27. package/dist/git/auto-commit.js +16 -5
  28. package/dist/github/github-tools.d.ts +1 -0
  29. package/dist/github/github-tools.js +9 -9
  30. package/dist/github/pr.d.ts +34 -0
  31. package/dist/github/pr.js +70 -0
  32. package/dist/human-text.d.ts +6 -0
  33. package/dist/human-text.js +37 -0
  34. package/dist/index.d.ts +19 -9
  35. package/dist/index.js +14 -6
  36. package/dist/journey/answers-block.d.ts +2 -4
  37. package/dist/lsp/diagnostics.d.ts +4 -0
  38. package/dist/lsp/diagnostics.js +8 -2
  39. package/dist/lsp/index.d.ts +1 -1
  40. package/dist/lsp/index.js +1 -1
  41. package/dist/mcp/index.d.ts +1 -1
  42. package/dist/mcp/index.js +1 -1
  43. package/dist/mcp/json-schema-to-zod.js +2 -4
  44. package/dist/mcp/mcp-client.d.ts +14 -0
  45. package/dist/mcp/mcp-client.js +102 -18
  46. package/dist/memory/session-digest.js +2 -1
  47. package/dist/orchestrator/specialists-registry.js +14 -1
  48. package/dist/orchestrator/specialists.js +3 -3
  49. package/dist/pricing.js +12 -0
  50. package/dist/semantic/embedder.d.ts +11 -0
  51. package/dist/semantic/embedder.js +16 -0
  52. package/dist/semantic/semantic-index.d.ts +39 -1
  53. package/dist/semantic/semantic-index.js +43 -13
  54. package/dist/semantic/vector-store.d.ts +2 -0
  55. package/dist/semantic/vector-store.js +13 -1
  56. package/dist/sessions/index.d.ts +2 -2
  57. package/dist/sessions/index.js +1 -1
  58. package/dist/sessions/search.js +2 -2
  59. package/dist/sessions/session-export.d.ts +1 -1
  60. package/dist/sessions/session-export.js +41 -33
  61. package/dist/sessions/session-store.d.ts +20 -1
  62. package/dist/sessions/session-store.js +7 -0
  63. package/dist/sessions/watch.js +4 -4
  64. package/dist/skills/activator.d.ts +6 -0
  65. package/dist/skills/activator.js +17 -0
  66. package/dist/skills/skill-tool.d.ts +12 -0
  67. package/dist/skills/skill-tool.js +28 -0
  68. package/dist/tools/background-process.d.ts +63 -0
  69. package/dist/tools/background-process.js +150 -0
  70. package/dist/tools/command-sandbox.d.ts +24 -0
  71. package/dist/tools/command-sandbox.js +73 -0
  72. package/dist/tools/edit-file.d.ts +8 -0
  73. package/dist/tools/edit-file.js +39 -16
  74. package/dist/tools/edit-match.d.ts +16 -0
  75. package/dist/tools/edit-match.js +115 -0
  76. package/dist/tools/fetch-url.d.ts +15 -1
  77. package/dist/tools/fetch-url.js +7 -47
  78. package/dist/tools/get-diagnostics.d.ts +14 -0
  79. package/dist/tools/get-diagnostics.js +44 -0
  80. package/dist/tools/glob-filter.d.ts +7 -0
  81. package/dist/tools/glob-filter.js +12 -0
  82. package/dist/tools/html-to-markdown.d.ts +13 -0
  83. package/dist/tools/html-to-markdown.js +507 -0
  84. package/dist/tools/index.d.ts +5 -1
  85. package/dist/tools/index.js +11 -1
  86. package/dist/tools/list-files.d.ts +1 -0
  87. package/dist/tools/list-files.js +17 -5
  88. package/dist/tools/list-symbols.js +2 -2
  89. package/dist/tools/network-policy.d.ts +5 -0
  90. package/dist/tools/network-policy.js +77 -0
  91. package/dist/tools/office-docs.d.ts +6 -0
  92. package/dist/tools/office-docs.js +393 -0
  93. package/dist/tools/permission-rules.js +55 -5
  94. package/dist/tools/read-file.d.ts +2 -0
  95. package/dist/tools/read-file.js +47 -10
  96. package/dist/tools/read-image.js +2 -2
  97. package/dist/tools/read-only.d.ts +1 -1
  98. package/dist/tools/read-only.js +4 -0
  99. package/dist/tools/repo-clone.d.ts +40 -0
  100. package/dist/tools/repo-clone.js +218 -0
  101. package/dist/tools/result-cap.d.ts +8 -0
  102. package/dist/tools/result-cap.js +44 -0
  103. package/dist/tools/run-command.d.ts +18 -1
  104. package/dist/tools/run-command.js +44 -9
  105. package/dist/tools/search-files.js +12 -53
  106. package/dist/tools/search-worker.d.ts +23 -0
  107. package/dist/tools/search-worker.js +86 -0
  108. package/dist/tools/shared.d.ts +14 -1
  109. package/dist/tools/shared.js +76 -4
  110. package/dist/tools/shell.d.ts +27 -0
  111. package/dist/tools/shell.js +80 -0
  112. package/dist/tools/sub-agent.d.ts +1 -1
  113. package/dist/tools/sub-agent.js +4 -1
  114. package/dist/tools/suggest-plan.d.ts +18 -0
  115. package/dist/tools/suggest-plan.js +28 -0
  116. package/dist/types.d.ts +132 -1
  117. package/dist/wakeup/wakeup-tools.d.ts +22 -0
  118. package/dist/wakeup/wakeup-tools.js +34 -0
  119. package/dist/wakeup/wakeup.d.ts +75 -0
  120. package/dist/wakeup/wakeup.js +151 -0
  121. package/dist/worktree/health.d.ts +51 -0
  122. package/dist/worktree/health.js +186 -0
  123. package/dist/worktree/index.d.ts +4 -0
  124. package/dist/worktree/index.js +2 -0
  125. package/dist/worktree/setup.d.ts +51 -0
  126. package/dist/worktree/setup.js +173 -0
  127. package/dist/worktree/worktree.d.ts +7 -0
  128. package/dist/worktree/worktree.js +9 -4
  129. package/package.json +1 -1
@@ -1,10 +1,14 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { z } from 'zod';
3
3
  import { isNotebookPath, parseNotebook, renderNotebook } from './notebook.js';
4
+ import { officeKind, renderDocx, renderXlsx } from './office-docs.js';
4
5
  import { isPdf, MAX_ATTACHMENT_BYTES } from '../attachments.js';
5
6
  import { folderInstructionsFor } from '../steering/folder-instructions.js';
6
- import { resolveAndCheckPath, truncateByLines } from './shared.js';
7
+ import { resolveReadPath } from './shared.js';
7
8
  import { decodeText } from './text-encoding.js';
9
+ const MAX_LINE_CHARS = 2000;
10
+ /** About 50 KB of text per call: enough to page through a file, small enough to leave room for the rest of the chat. */
11
+ const MAX_READ_CHARS = 50_000;
8
12
  const params = z.object({
9
13
  path: z.string().describe('Path to the file to read, relative to the working directory or absolute.'),
10
14
  /** 1-based line number to start reading from. Omit to start at the beginning. */
@@ -14,10 +18,10 @@ const params = z.object({
14
18
  });
15
19
  export const readFileTool = {
16
20
  name: 'read_file',
17
- description: 'Read the contents of a file. Returns text with line numbers; supports an optional offset and max_lines to page through large files. A Jupyter notebook (.ipynb) is shown as numbered cells with their outputs. A PDF is attached whole so you can read it (text, tables and figures). Other binary files are rejected.',
21
+ description: 'Read the contents of a file. Returns text with line numbers; supports an optional offset and max_lines to page through large files. A Jupyter notebook (.ipynb) is shown as numbered cells with their outputs. A Word document (.docx) is shown as text with its headings, lists and tables; an Excel workbook (.xlsx) as one table per sheet with column letters and row numbers. A PDF is attached whole so you can read it (text, tables and figures). Other binary files, including the older .doc and .xls, are rejected.',
18
22
  parameters: params,
19
23
  execute: async (args, ctx) => {
20
- const resolved = resolveAndCheckPath(args.path, ctx);
24
+ const resolved = await resolveReadPath(args.path, ctx);
21
25
  let raw;
22
26
  try {
23
27
  raw = await readFile(resolved);
@@ -48,12 +52,25 @@ export const readFileTool = {
48
52
  }
49
53
  return { path: resolved, mime: 'application/pdf', bytes: raw.length, dataUrl: `data:application/pdf;base64,${raw.toString('base64')}` };
50
54
  }
51
- // Reject obvious binary content.
52
- const decoded = decodeText(raw);
53
- if (decoded.encoding === 'utf8' && looksBinary(raw)) {
54
- throw new Error(`Refusing to read binary file: ${args.path} (${raw.length} bytes)`);
55
+ let text;
56
+ const office = officeKind(resolved);
57
+ if (office) {
58
+ // A Word or Excel file is a zip of XML: show its text, then page through it like any other file (G20).
59
+ try {
60
+ text = office === 'docx' ? renderDocx(raw) : renderXlsx(raw);
61
+ }
62
+ catch (err) {
63
+ throw new Error(`Could not read ${args.path}: ${err.message}`);
64
+ }
65
+ }
66
+ else {
67
+ // Reject obvious binary content.
68
+ const decoded = decodeText(raw);
69
+ if (decoded.encoding === 'utf8' && looksBinary(raw)) {
70
+ throw new Error(`Refusing to read binary file: ${args.path} (${raw.length} bytes)`);
71
+ }
72
+ text = decoded.text;
55
73
  }
56
- const text = decoded.text;
57
74
  const lines = text.split(/\r?\n/);
58
75
  // A file that ends with a newline splits into one extra, empty piece. It is not a line. Numbering it told the model a
59
76
  // 37-line file had 38 lines and that the last was empty (a live spec caught it), and made a file of exactly max_lines
@@ -64,14 +81,34 @@ export const readFileTool = {
64
81
  const maxLines = args.max_lines ?? 2000;
65
82
  const slice = lines.slice(offset - 1, offset - 1 + maxLines);
66
83
  const endIdx = offset - 1 + maxLines;
67
- const truncated = endIdx < lines.length;
84
+ // A minified file is one line of hundreds of kilobytes, and a log can be megabytes: cut long lines and stop at a byte
85
+ // budget, and say where to carry on (G4). The line limit alone did neither.
86
+ const shown = [];
87
+ let used = 0;
88
+ let nextOffset;
89
+ for (let i = 0; i < slice.length; i++) {
90
+ const line = slice[i];
91
+ const cut = line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS)}... (${line.length - MAX_LINE_CHARS} more characters)` : line;
92
+ const row = `${String(offset + i).padStart(6)}\t${cut}`;
93
+ if (shown.length > 0 && used + row.length + 1 > MAX_READ_CHARS) {
94
+ nextOffset = offset + i;
95
+ break;
96
+ }
97
+ shown.push(row);
98
+ used += row.length + 1;
99
+ }
100
+ if (nextOffset === undefined && endIdx < lines.length)
101
+ nextOffset = endIdx + 1;
102
+ const truncated = nextOffset !== undefined;
103
+ const note = nextOffset !== undefined ? `\n\n... (${lines.length - nextOffset + 1} more lines; continue with offset ${nextOffset})` : '';
68
104
  const folderInstructions = ctx.shownFolderInstructions ? await folderInstructionsFor(resolved, ctx.cwd, ctx.shownFolderInstructions) : undefined;
69
105
  return {
70
106
  path: resolved,
71
107
  totalLines: lines.length,
72
108
  offset,
73
- content: truncateByLines(slice.map((l, i) => `${String(offset + i).padStart(6)}\t${l}`).join('\n'), maxLines),
109
+ content: shown.join('\n') + note,
74
110
  truncated,
111
+ ...(nextOffset !== undefined ? { nextOffset } : {}),
75
112
  ...(folderInstructions ? { folderInstructions } : {}),
76
113
  };
77
114
  },
@@ -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>;
@@ -1,15 +1,18 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { tmpdir } from 'node:os';
2
3
  import { z } from 'zod';
4
+ import { sandboxCommand } from './command-sandbox.js';
3
5
  import { checkPolicyViolation } from './policy.js';
4
6
  import { resolveAndCheckPath } from './shared.js';
5
7
  import { shellEnv } from './shell-env.js';
8
+ import { runnerShell, shellInvocation } from './shell.js';
6
9
  /**
7
10
  * Kill a child process and its whole tree. Windows: `taskkill /T`. POSIX: the child is started as the leader of its
8
11
  * own process group (`detached`), and the signal goes to the group (`-pid`); signalling only the shell left whatever
9
12
  * it had forked (`sh -c "node ..."`) running, holding the output pipes open, so a stopped or timed-out command
10
13
  * never finished. SIGKILL follows after a grace period if the process is still alive.
11
14
  */
12
- function killTree(child, graceMs = 5000) {
15
+ export function killTree(child, graceMs = 5000) {
13
16
  if (process.platform === 'win32') {
14
17
  try {
15
18
  spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
@@ -32,6 +35,30 @@ function killTree(child, graceMs = 5000) {
32
35
  signalTree('SIGTERM');
33
36
  setTimeout(() => signalTree('SIGKILL'), graceMs).unref();
34
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
+ }
35
62
  const params = z.object({
36
63
  command: z.string().describe('The shell command to execute. Use arrays for cross-platform safety when possible.'),
37
64
  /**
@@ -59,15 +86,12 @@ export const runCommandTool = {
59
86
  // to spawn() with no check, and the policy check below used the
60
87
  // parent's original ctx.cwd regardless, so a configured path policy
61
88
  // could never see (let alone block) the override.
62
- const cwd = args.cwd ? resolveAndCheckPath(args.cwd, ctx) : ctx.cwd;
63
- const violation = checkPolicyViolation(ctx.policy, { cwd, command: args.command });
64
- if (violation)
65
- throw new Error(`[sandbox] ${violation}`);
89
+ const { cwd, commandLine } = prepareCommand(args.command, args.cwd, ctx);
66
90
  const timeoutMs = args.timeout_ms ?? DEFAULT_TIMEOUT_MS;
67
91
  if (ctx.commandRunner) {
68
92
  const started = Date.now();
69
93
  const out = await ctx.commandRunner({
70
- command: args.command,
94
+ command: commandLine,
71
95
  cwd,
72
96
  timeoutMs,
73
97
  ...(ctx.abortSignal ? { signal: ctx.abortSignal } : {}),
@@ -91,8 +115,9 @@ export const runCommandTool = {
91
115
  }
92
116
  return new Promise((resolve, reject) => {
93
117
  const started = Date.now();
94
- const child = spawn(args.command, {
95
- 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 = {
96
121
  cwd,
97
122
  // Windows: never run a same-named program the repository ships in its root (see shell-env.ts).
98
123
  env: shellEnv(process.env),
@@ -100,7 +125,8 @@ export const runCommandTool = {
100
125
  windowsHide: true,
101
126
  // POSIX: own process group, so a kill reaches everything the shell started (see killTree).
102
127
  detached: process.platform !== 'win32',
103
- });
128
+ };
129
+ const child = inv.file ? spawn(inv.file, inv.args, options) : spawn(commandLine, { ...options, shell: true });
104
130
  const stdoutChunks = [];
105
131
  const stderrChunks = [];
106
132
  child.stdout.on('data', (chunk) => stdoutChunks.push(chunk));
@@ -115,6 +141,15 @@ export const runCommandTool = {
115
141
  }
116
142
  const onAbort = () => killTree(child);
117
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
+ });
118
153
  child.on('error', (err) => {
119
154
  if (timeoutHandle)
120
155
  clearTimeout(timeoutHandle);