@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.
- package/dist/agent/agent.js +324 -122
- package/dist/agent/cache-hints.d.ts +2 -0
- package/dist/agent/cache-hints.js +18 -3
- package/dist/agent/enhance-prompt.js +2 -1
- package/dist/agent/history-adapt.d.ts +23 -0
- package/dist/agent/history-adapt.js +114 -0
- package/dist/agent/prompts.d.ts +19 -2
- package/dist/agent/prompts.js +44 -2
- package/dist/agent/retry.d.ts +50 -0
- package/dist/agent/retry.js +192 -0
- package/dist/agent/shell-command.d.ts +23 -0
- package/dist/agent/shell-command.js +93 -0
- package/dist/agent/tool-repair.d.ts +8 -0
- package/dist/agent/tool-repair.js +16 -0
- package/dist/catalog/models-dev-snapshot.d.ts +1 -0
- package/dist/catalog/models-dev-snapshot.js +3 -0
- package/dist/catalog/models-dev.d.ts +45 -0
- package/dist/catalog/models-dev.js +155 -0
- package/dist/checkpoints/store.js +3 -1
- package/dist/context/compaction.d.ts +21 -6
- package/dist/context/compaction.js +94 -28
- package/dist/context/mask.d.ts +9 -9
- package/dist/context/mask.js +30 -1
- package/dist/format/auto-format.d.ts +22 -0
- package/dist/format/auto-format.js +39 -0
- package/dist/git/auto-commit.d.ts +4 -2
- package/dist/git/auto-commit.js +16 -5
- package/dist/github/github-tools.d.ts +1 -0
- package/dist/github/github-tools.js +9 -9
- package/dist/github/pr.d.ts +34 -0
- package/dist/github/pr.js +70 -0
- package/dist/human-text.d.ts +6 -0
- package/dist/human-text.js +37 -0
- package/dist/index.d.ts +19 -9
- package/dist/index.js +14 -6
- package/dist/journey/answers-block.d.ts +2 -4
- package/dist/lsp/diagnostics.d.ts +4 -0
- package/dist/lsp/diagnostics.js +8 -2
- package/dist/lsp/index.d.ts +1 -1
- package/dist/lsp/index.js +1 -1
- package/dist/mcp/index.d.ts +1 -1
- package/dist/mcp/index.js +1 -1
- package/dist/mcp/json-schema-to-zod.js +2 -4
- package/dist/mcp/mcp-client.d.ts +14 -0
- package/dist/mcp/mcp-client.js +102 -18
- package/dist/memory/session-digest.js +2 -1
- package/dist/orchestrator/specialists-registry.js +14 -1
- package/dist/orchestrator/specialists.js +3 -3
- package/dist/pricing.js +12 -0
- package/dist/semantic/embedder.d.ts +11 -0
- package/dist/semantic/embedder.js +16 -0
- package/dist/semantic/semantic-index.d.ts +39 -1
- package/dist/semantic/semantic-index.js +43 -13
- package/dist/semantic/vector-store.d.ts +2 -0
- package/dist/semantic/vector-store.js +13 -1
- package/dist/sessions/index.d.ts +2 -2
- package/dist/sessions/index.js +1 -1
- package/dist/sessions/search.js +2 -2
- package/dist/sessions/session-export.d.ts +1 -1
- package/dist/sessions/session-export.js +41 -33
- package/dist/sessions/session-store.d.ts +20 -1
- package/dist/sessions/session-store.js +7 -0
- package/dist/sessions/watch.js +4 -4
- package/dist/skills/activator.d.ts +6 -0
- package/dist/skills/activator.js +17 -0
- package/dist/skills/skill-tool.d.ts +12 -0
- package/dist/skills/skill-tool.js +28 -0
- package/dist/tools/background-process.d.ts +63 -0
- package/dist/tools/background-process.js +150 -0
- package/dist/tools/command-sandbox.d.ts +24 -0
- package/dist/tools/command-sandbox.js +73 -0
- package/dist/tools/edit-file.d.ts +8 -0
- package/dist/tools/edit-file.js +39 -16
- package/dist/tools/edit-match.d.ts +16 -0
- package/dist/tools/edit-match.js +115 -0
- package/dist/tools/fetch-url.d.ts +15 -1
- package/dist/tools/fetch-url.js +7 -47
- package/dist/tools/get-diagnostics.d.ts +14 -0
- package/dist/tools/get-diagnostics.js +44 -0
- package/dist/tools/glob-filter.d.ts +7 -0
- package/dist/tools/glob-filter.js +12 -0
- package/dist/tools/html-to-markdown.d.ts +13 -0
- package/dist/tools/html-to-markdown.js +507 -0
- package/dist/tools/index.d.ts +5 -1
- package/dist/tools/index.js +11 -1
- package/dist/tools/list-files.d.ts +1 -0
- package/dist/tools/list-files.js +17 -5
- package/dist/tools/list-symbols.js +2 -2
- package/dist/tools/network-policy.d.ts +5 -0
- package/dist/tools/network-policy.js +77 -0
- package/dist/tools/office-docs.d.ts +6 -0
- package/dist/tools/office-docs.js +393 -0
- package/dist/tools/permission-rules.js +55 -5
- package/dist/tools/read-file.d.ts +2 -0
- package/dist/tools/read-file.js +47 -10
- package/dist/tools/read-image.js +2 -2
- package/dist/tools/read-only.d.ts +1 -1
- package/dist/tools/read-only.js +4 -0
- package/dist/tools/repo-clone.d.ts +40 -0
- package/dist/tools/repo-clone.js +218 -0
- package/dist/tools/result-cap.d.ts +8 -0
- package/dist/tools/result-cap.js +44 -0
- package/dist/tools/run-command.d.ts +18 -1
- package/dist/tools/run-command.js +44 -9
- package/dist/tools/search-files.js +12 -53
- package/dist/tools/search-worker.d.ts +23 -0
- package/dist/tools/search-worker.js +86 -0
- package/dist/tools/shared.d.ts +14 -1
- package/dist/tools/shared.js +76 -4
- package/dist/tools/shell.d.ts +27 -0
- package/dist/tools/shell.js +80 -0
- package/dist/tools/sub-agent.d.ts +1 -1
- package/dist/tools/sub-agent.js +4 -1
- package/dist/tools/suggest-plan.d.ts +18 -0
- package/dist/tools/suggest-plan.js +28 -0
- package/dist/types.d.ts +132 -1
- package/dist/wakeup/wakeup-tools.d.ts +22 -0
- package/dist/wakeup/wakeup-tools.js +34 -0
- package/dist/wakeup/wakeup.d.ts +75 -0
- package/dist/wakeup/wakeup.js +151 -0
- package/dist/worktree/health.d.ts +51 -0
- package/dist/worktree/health.js +186 -0
- package/dist/worktree/index.d.ts +4 -0
- package/dist/worktree/index.js +2 -0
- package/dist/worktree/setup.d.ts +51 -0
- package/dist/worktree/setup.js +173 -0
- package/dist/worktree/worktree.d.ts +7 -0
- package/dist/worktree/worktree.js +9 -4
- package/package.json +1 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
2
1
|
import path from 'node:path';
|
|
3
2
|
import { z } from 'zod';
|
|
4
|
-
import {
|
|
3
|
+
import { fileGlobMatcher } from './glob-filter.js';
|
|
5
4
|
import { IgnoreRules } from './ignore-rules.js';
|
|
6
|
-
import {
|
|
5
|
+
import { DEFAULT_SEARCH_TIMEOUT_MS, matchInWorker } from './search-worker.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,20 +28,15 @@ 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 =
|
|
31
|
+
const root = await resolveReadPath(args.path ?? '.', ctx);
|
|
32
32
|
const regex = safeRegex(args.pattern);
|
|
33
|
-
const
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
let filesScanned = 0;
|
|
36
|
+
// The walk (I/O and ignore rules) stays here; reading and matching run in a worker with a time limit (RB-22).
|
|
37
|
+
const files = [];
|
|
39
38
|
const rules = args.include_ignored ? null : IgnoreRules.forRoot(ctx.cwd);
|
|
40
39
|
async function walk(dir) {
|
|
41
|
-
if (matches.length >= maxResults) {
|
|
42
|
-
truncated = true;
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
40
|
const { readdir } = await import('node:fs/promises');
|
|
46
41
|
let entries;
|
|
47
42
|
try {
|
|
@@ -54,10 +49,6 @@ export const searchFilesTool = {
|
|
|
54
49
|
throw err;
|
|
55
50
|
}
|
|
56
51
|
for (const entry of entries) {
|
|
57
|
-
if (matches.length >= maxResults) {
|
|
58
|
-
truncated = true;
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
52
|
if (entry.name.startsWith('.'))
|
|
62
53
|
continue;
|
|
63
54
|
if (DEFAULT_IGNORED_DIRS.has(entry.name))
|
|
@@ -70,42 +61,9 @@ export const searchFilesTool = {
|
|
|
70
61
|
}
|
|
71
62
|
else if (entry.isFile()) {
|
|
72
63
|
const rel = path.relative(root, abs).split(path.sep).join('/');
|
|
73
|
-
if (
|
|
64
|
+
if (matchesInclude && !matchesInclude(rel, entry.name))
|
|
74
65
|
continue;
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
async function searchFile(abs, rel) {
|
|
80
|
-
filesScanned++;
|
|
81
|
-
let buf;
|
|
82
|
-
try {
|
|
83
|
-
buf = await readFile(abs);
|
|
84
|
-
}
|
|
85
|
-
catch {
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
// Skip binary files.
|
|
89
|
-
if (buf.includes(0))
|
|
90
|
-
return;
|
|
91
|
-
const text = buf.toString('utf8');
|
|
92
|
-
const lines = text.split(/\r?\n/);
|
|
93
|
-
for (let i = 0; i < lines.length; i++) {
|
|
94
|
-
if (matches.length >= maxResults) {
|
|
95
|
-
truncated = true;
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
const line = lines[i];
|
|
99
|
-
regex.lastIndex = 0;
|
|
100
|
-
if (regex.test(line)) {
|
|
101
|
-
const start = Math.max(0, i - contextLines);
|
|
102
|
-
const end = Math.min(lines.length - 1, i + contextLines);
|
|
103
|
-
const block = [];
|
|
104
|
-
for (let j = start; j <= end; j++) {
|
|
105
|
-
const marker = j === i ? '>' : ' ';
|
|
106
|
-
block.push(`${marker} ${String(j + 1).padStart(6)}\t${lines[j]}`);
|
|
107
|
-
}
|
|
108
|
-
matches.push({ path: rel, line: i + 1, text: truncateByLines(block.join('\n'), 10) });
|
|
66
|
+
files.push({ abs, rel });
|
|
109
67
|
}
|
|
110
68
|
}
|
|
111
69
|
}
|
|
@@ -113,12 +71,13 @@ export const searchFilesTool = {
|
|
|
113
71
|
if (!stat)
|
|
114
72
|
throw new Error(`Path not found: ${root}`);
|
|
115
73
|
if (stat.isFile()) {
|
|
116
|
-
|
|
74
|
+
files.push({ abs: root, rel: path.basename(root) });
|
|
117
75
|
}
|
|
118
76
|
else {
|
|
119
77
|
await walk(root);
|
|
120
78
|
}
|
|
121
|
-
|
|
79
|
+
const result = await matchInWorker({ files, source: regex.source, flags: regex.flags, maxResults, contextLines }, { timeoutMs: ctx.searchTimeoutMs ?? DEFAULT_SEARCH_TIMEOUT_MS });
|
|
80
|
+
return { pattern: args.pattern, ...result };
|
|
122
81
|
},
|
|
123
82
|
};
|
|
124
83
|
/**
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Worker } from 'node:worker_threads';
|
|
2
|
+
import type { SearchMatch } from './search-files.js';
|
|
3
|
+
export interface MatchJob {
|
|
4
|
+
files: Array<{
|
|
5
|
+
abs: string;
|
|
6
|
+
rel: string;
|
|
7
|
+
}>;
|
|
8
|
+
source: string;
|
|
9
|
+
flags: string;
|
|
10
|
+
maxResults: number;
|
|
11
|
+
contextLines: number;
|
|
12
|
+
}
|
|
13
|
+
export interface MatchResult {
|
|
14
|
+
matches: SearchMatch[];
|
|
15
|
+
truncated: boolean;
|
|
16
|
+
filesScanned: number;
|
|
17
|
+
}
|
|
18
|
+
export declare const DEFAULT_SEARCH_TIMEOUT_MS = 20000;
|
|
19
|
+
/** Match `job` in a worker; throws when it runs longer than `timeoutMs`. Falls back to in-process if no worker starts. */
|
|
20
|
+
export declare function matchInWorker(job: MatchJob, opts: {
|
|
21
|
+
timeoutMs: number;
|
|
22
|
+
startWorker?: (source: string, data: MatchJob) => Worker;
|
|
23
|
+
}): Promise<MatchResult>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* search_files' matching, run in a worker thread with a time limit. JavaScript cannot interrupt a running regex, so a
|
|
3
|
+
* pattern that backtracks badly on the main thread froze the process (in VS Code, the extension host): in a worker it
|
|
4
|
+
* is terminated after `timeoutMs`. The worker source is a plain string, not a function's text, so a bundler cannot
|
|
5
|
+
* rename or wrap anything in it; the same string runs in-process when a worker cannot start.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { Worker } from 'node:worker_threads';
|
|
9
|
+
export const DEFAULT_SEARCH_TIMEOUT_MS = 20_000;
|
|
10
|
+
/** Body of `function (job, readFile)`: read each file, skip binaries, match line by line, keep context. */
|
|
11
|
+
const MATCH_BODY = String.raw `
|
|
12
|
+
var regex = new RegExp(job.source, job.flags);
|
|
13
|
+
var matches = [];
|
|
14
|
+
var truncated = false;
|
|
15
|
+
var filesScanned = 0;
|
|
16
|
+
function truncateByLines(text, maxLines) {
|
|
17
|
+
var lines = text.split(/\r?\n/);
|
|
18
|
+
if (lines.length <= maxLines) return text;
|
|
19
|
+
var dropped = lines.length - maxLines;
|
|
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
|
+
}
|
|
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
|
+
}
|
|
30
|
+
for (var f = 0; f < job.files.length; f++) {
|
|
31
|
+
if (matches.length >= job.maxResults) { truncated = true; break; }
|
|
32
|
+
var file = job.files[f];
|
|
33
|
+
filesScanned++;
|
|
34
|
+
var buf;
|
|
35
|
+
try { buf = readFile(file.abs); } catch (e) { continue; }
|
|
36
|
+
if (buf.includes(0)) continue;
|
|
37
|
+
var lines = buf.toString('utf8').split(/\r?\n/);
|
|
38
|
+
for (var i = 0; i < lines.length; i++) {
|
|
39
|
+
if (matches.length >= job.maxResults) { truncated = true; break; }
|
|
40
|
+
regex.lastIndex = 0;
|
|
41
|
+
var hit = regex.exec(lines[i]);
|
|
42
|
+
if (hit) {
|
|
43
|
+
var start = Math.max(0, i - job.contextLines);
|
|
44
|
+
var end = Math.min(lines.length - 1, i + job.contextLines);
|
|
45
|
+
var block = [];
|
|
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));
|
|
47
|
+
matches.push({ path: file.rel, line: i + 1, text: truncateByLines(block.join('\n'), 10) });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { matches: matches, truncated: truncated, filesScanned: filesScanned };
|
|
52
|
+
`;
|
|
53
|
+
const WORKER_SOURCE = `
|
|
54
|
+
const { parentPort, workerData } = require('node:worker_threads');
|
|
55
|
+
const fs = require('node:fs');
|
|
56
|
+
const match = new Function('job', 'readFile', ${JSON.stringify(MATCH_BODY)});
|
|
57
|
+
parentPort.postMessage(match(workerData, (p) => fs.readFileSync(p)));
|
|
58
|
+
`;
|
|
59
|
+
function matchInProcess(job) {
|
|
60
|
+
return new Function('job', 'readFile', MATCH_BODY)(job, (p) => readFileSync(p));
|
|
61
|
+
}
|
|
62
|
+
/** Match `job` in a worker; throws when it runs longer than `timeoutMs`. Falls back to in-process if no worker starts. */
|
|
63
|
+
export function matchInWorker(job, opts) {
|
|
64
|
+
let worker;
|
|
65
|
+
try {
|
|
66
|
+
worker = (opts.startWorker ?? ((source, data) => new Worker(source, { eval: true, workerData: data })))(WORKER_SOURCE, job);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return Promise.resolve(matchInProcess(job));
|
|
70
|
+
}
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const timer = setTimeout(() => {
|
|
73
|
+
void worker.terminate();
|
|
74
|
+
reject(new Error(`The search pattern took longer than ${Math.round(opts.timeoutMs / 1000)} s and was stopped. Simplify it (avoid repeated groups that can match the same text in several ways) or search fewer files.`));
|
|
75
|
+
}, opts.timeoutMs);
|
|
76
|
+
worker.once('message', (result) => {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
void worker.terminate();
|
|
79
|
+
resolve(result);
|
|
80
|
+
});
|
|
81
|
+
worker.once('error', (err) => {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
reject(err);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
package/dist/tools/shared.d.ts
CHANGED
|
@@ -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
|
|
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.
|
package/dist/tools/shared.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { realpathSync } from 'node:fs';
|
|
1
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { checkPolicyViolation } from './policy.js';
|
|
4
5
|
/**
|
|
@@ -15,11 +16,82 @@ export function resolveSafePath(inputPath, ctx, options = {}) {
|
|
|
15
16
|
if (!options.allowOutside) {
|
|
16
17
|
// Lexically, then for real: a symlink or junction inside the workspace can point anywhere on disk.
|
|
17
18
|
if (escapes(ctx.cwd, resolved) || escapes(realPath(ctx.cwd), realPath(resolved))) {
|
|
18
|
-
|
|
19
|
+
// A read may also reach a folder this run was explicitly given (a repository it cloned), never anything else.
|
|
20
|
+
if (options.read && inReadRoot(resolved, ctx))
|
|
21
|
+
return resolved;
|
|
22
|
+
throw new OutsideWorkspaceError(`Path "${inputPath}" resolves outside of the working directory (${ctx.cwd}).`);
|
|
19
23
|
}
|
|
20
24
|
}
|
|
21
25
|
return resolved;
|
|
22
26
|
}
|
|
27
|
+
/** A path that leads outside the working directory: the one error a host may answer by asking (G16). */
|
|
28
|
+
export class OutsideWorkspaceError extends Error {
|
|
29
|
+
constructor(message) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = 'OutsideWorkspaceError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Is `resolved` inside one of the run's extra read roots, judged by where it really is after links (RB-20)? */
|
|
35
|
+
function inReadRoot(resolved, ctx) {
|
|
36
|
+
const real = realPath(resolved);
|
|
37
|
+
return (ctx.readRoots ?? []).some((root) => !escapes(realPath(root), real));
|
|
38
|
+
}
|
|
39
|
+
const SENSITIVE_DIRS = new Set(['.ssh', '.aws', '.gnupg', '.kube', '.azure', '.docker', '.password-store', 'credentials', 'protect', 'user data']);
|
|
40
|
+
/** Why a directory must not be offered for reading, or undefined: too broad to be a choice, or holds credentials. */
|
|
41
|
+
function notOfferable(dir) {
|
|
42
|
+
if (path.parse(dir).root === dir)
|
|
43
|
+
return 'it is the top of a drive, too broad to open';
|
|
44
|
+
let home;
|
|
45
|
+
try {
|
|
46
|
+
home = realpathSync.native(homedir());
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
home = undefined;
|
|
50
|
+
}
|
|
51
|
+
// The home folder and everything above it would open the credential folders inside it.
|
|
52
|
+
if (home && !escapes(dir, home))
|
|
53
|
+
return 'it is your home folder or a folder above it, too broad to open';
|
|
54
|
+
const key = (p) => (process.platform === 'win32' ? p.toLowerCase() : p);
|
|
55
|
+
if (path.normalize(dir).split(path.sep).some((part) => SENSITIVE_DIRS.has(key(part))))
|
|
56
|
+
return 'it holds credentials';
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* `resolveAndCheckPath` for a READ tool, with one more way to succeed: when the path leads outside the project and the host
|
|
61
|
+
* can ask, the person is asked about the real directory (links resolved), once per directory per run. A yes opens that
|
|
62
|
+
* directory and below to read tools; a no, or a directory that is too broad or holds credentials, is a refusal.
|
|
63
|
+
*/
|
|
64
|
+
export async function resolveReadPath(inputPath, ctx) {
|
|
65
|
+
try {
|
|
66
|
+
return resolveAndCheckPath(inputPath, ctx, { read: true });
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
if (!(err instanceof OutsideWorkspaceError) || !ctx.askReadOutside)
|
|
70
|
+
throw err;
|
|
71
|
+
const resolved = path.isAbsolute(inputPath) ? path.normalize(inputPath) : path.resolve(ctx.cwd, inputPath);
|
|
72
|
+
const real = realPath(resolved);
|
|
73
|
+
let isDir = false;
|
|
74
|
+
try {
|
|
75
|
+
isDir = statSync(real).isDirectory();
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
isDir = false;
|
|
79
|
+
}
|
|
80
|
+
const dir = isDir ? real : path.dirname(real);
|
|
81
|
+
const key = process.platform === 'win32' ? dir.toLowerCase() : dir;
|
|
82
|
+
if (ctx.deniedReadDirs?.has(key))
|
|
83
|
+
throw err;
|
|
84
|
+
const why = notOfferable(dir);
|
|
85
|
+
if (why)
|
|
86
|
+
throw new OutsideWorkspaceError(`Path "${inputPath}" is outside the working directory, and Forge will not offer to open ${dir}: ${why}.`);
|
|
87
|
+
if (!(await ctx.askReadOutside(dir))) {
|
|
88
|
+
(ctx.deniedReadDirs ??= new Set()).add(key);
|
|
89
|
+
throw new OutsideWorkspaceError(`Path "${inputPath}" is outside the working directory, and reading in ${dir} was not allowed.`);
|
|
90
|
+
}
|
|
91
|
+
(ctx.readRoots ??= []).push(dir);
|
|
92
|
+
return resolveAndCheckPath(inputPath, ctx, { read: true });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
23
95
|
/**
|
|
24
96
|
* For a `path.relative(root, target)` result: does it lead out of `root`? `..` itself or `../…` does; a name that merely
|
|
25
97
|
* starts with two dots (`..notes.md`) does not. Use this instead of `rel.startsWith('..')`.
|
|
@@ -53,8 +125,8 @@ function realPath(p) {
|
|
|
53
125
|
* tools call this instead of `resolveSafePath` directly so the sandbox is
|
|
54
126
|
* applied uniformly.
|
|
55
127
|
*/
|
|
56
|
-
export function resolveAndCheckPath(inputPath, ctx) {
|
|
57
|
-
const resolved = resolveSafePath(inputPath, ctx);
|
|
128
|
+
export function resolveAndCheckPath(inputPath, ctx, options = {}) {
|
|
129
|
+
const resolved = resolveSafePath(inputPath, ctx, options);
|
|
58
130
|
const violation = checkPolicyViolation(ctx.policy, { cwd: ctx.cwd, resolvedPath: resolved });
|
|
59
131
|
if (violation)
|
|
60
132
|
throw new Error(`[sandbox] ${violation}`);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type ShellKind = 'pwsh' | 'powershell' | 'bash' | 'cmd' | 'sh';
|
|
2
|
+
/** What a person can ask for. */
|
|
3
|
+
export type ShellPreference = 'auto' | 'pwsh' | 'powershell' | 'bash' | 'cmd';
|
|
4
|
+
export interface ShellChoice {
|
|
5
|
+
kind: ShellKind;
|
|
6
|
+
/** The program to start; absent for cmd.exe and sh, which `spawn(..., { shell: true })` finds itself. */
|
|
7
|
+
file?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface ChooseShellInput {
|
|
10
|
+
platform: NodeJS.Platform;
|
|
11
|
+
env: NodeJS.ProcessEnv;
|
|
12
|
+
preference?: ShellPreference;
|
|
13
|
+
/** Tests: does this file exist? */
|
|
14
|
+
exists?: (file: string) => boolean;
|
|
15
|
+
/** Tests: where is this program on PATH? */
|
|
16
|
+
find?: (name: string) => string | undefined;
|
|
17
|
+
/** Tests: Git Bash, when known. */
|
|
18
|
+
gitBash?: string | undefined;
|
|
19
|
+
}
|
|
20
|
+
export declare function chooseShell(input: ChooseShellInput): ShellChoice;
|
|
21
|
+
/** The program and arguments that run `command` in `shell`. `file` is undefined for cmd.exe and sh: use `shell: true`. */
|
|
22
|
+
export declare function shellInvocation(shell: ShellChoice, command: string): {
|
|
23
|
+
file: string | undefined;
|
|
24
|
+
args: string[];
|
|
25
|
+
};
|
|
26
|
+
/** The shell for this machine, looked up once per preference. */
|
|
27
|
+
export declare function runnerShell(preference?: ShellPreference | undefined, platform?: NodeJS.Platform): ShellChoice;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which shell Forge's own command runner uses (G11). On Windows `cmd.exe` has no `ls`, `cat` or `grep`, and models
|
|
3
|
+
* write worse commands for it, so the runner prefers `pwsh`, then Windows PowerShell, then Git Bash, then `cmd.exe`.
|
|
4
|
+
* Elsewhere it stays `/bin/sh`. In VS Code the command runs in the integrated terminal instead and this is not used.
|
|
5
|
+
*
|
|
6
|
+
* RB-24 still holds: a shell started in the workspace must not run a program the repository ships. cmd.exe is
|
|
7
|
+
* handled by `shellEnv`; PowerShell and bash look in PATH only (a script in the current folder needs `.\`).
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { resolveOnPath } from '../lsp/client.js';
|
|
12
|
+
/** `bash.exe` of a Git for Windows install, found from `git` on PATH. Never `System32\bash.exe`, which starts WSL. */
|
|
13
|
+
function findGitBash(input, find, exists) {
|
|
14
|
+
if (input.gitBash !== undefined)
|
|
15
|
+
return input.gitBash;
|
|
16
|
+
const git = find('git');
|
|
17
|
+
if (!git)
|
|
18
|
+
return undefined;
|
|
19
|
+
const root = path.dirname(path.dirname(git));
|
|
20
|
+
for (const rel of [['bin', 'bash.exe'], ['usr', 'bin', 'bash.exe']]) {
|
|
21
|
+
const candidate = path.join(root, ...rel);
|
|
22
|
+
if (exists(candidate) && !/system32/i.test(candidate))
|
|
23
|
+
return candidate;
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
export function chooseShell(input) {
|
|
28
|
+
if (input.platform !== 'win32')
|
|
29
|
+
return { kind: 'sh' };
|
|
30
|
+
const exists = input.exists ?? existsSync;
|
|
31
|
+
const find = input.find ?? ((n) => resolveOnPath(n, input.env, 'win32'));
|
|
32
|
+
const pwsh = find('pwsh');
|
|
33
|
+
const powershell = find('powershell');
|
|
34
|
+
const bash = findGitBash(input, find, exists);
|
|
35
|
+
const available = {
|
|
36
|
+
pwsh: pwsh ? { kind: 'pwsh', file: pwsh } : undefined,
|
|
37
|
+
powershell: powershell ? { kind: 'powershell', file: powershell } : undefined,
|
|
38
|
+
bash: bash ? { kind: 'bash', file: bash } : undefined,
|
|
39
|
+
cmd: { kind: 'cmd' },
|
|
40
|
+
};
|
|
41
|
+
const pref = input.preference ?? 'auto';
|
|
42
|
+
if (pref !== 'auto' && available[pref])
|
|
43
|
+
return available[pref];
|
|
44
|
+
// Asked for a shell that is not installed: fall through the usual order.
|
|
45
|
+
return available.pwsh ?? available.powershell ?? available.bash ?? available.cmd;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* `powershell -Command` ends with exit code 1 when the last command failed, whatever the code of a native program was.
|
|
49
|
+
* This keeps the program's own code (node, git and npm tell apart failures by it) with the status of the LAST command,
|
|
50
|
+
* as sh and cmd report it: a native failure followed by a cmdlet that works is a success. The tail is on its own line
|
|
51
|
+
* so a trailing comment in the command cannot swallow it.
|
|
52
|
+
*/
|
|
53
|
+
function withExitCode(command) {
|
|
54
|
+
return `$global:LASTEXITCODE = $null\n${command}\nif (-not $?) { if ($null -ne $LASTEXITCODE -and $LASTEXITCODE -ne 0) { exit $LASTEXITCODE } else { exit 1 } }`;
|
|
55
|
+
}
|
|
56
|
+
/** The program and arguments that run `command` in `shell`. `file` is undefined for cmd.exe and sh: use `shell: true`. */
|
|
57
|
+
export function shellInvocation(shell, command) {
|
|
58
|
+
switch (shell.kind) {
|
|
59
|
+
case 'pwsh':
|
|
60
|
+
return { file: shell.file, args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', withExitCode(command)] };
|
|
61
|
+
case 'powershell':
|
|
62
|
+
// Windows PowerShell writes the console code page (not UTF-8) to a pipe, which mangles accents and CJK text.
|
|
63
|
+
return { file: shell.file, args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `[Console]::OutputEncoding=[System.Text.Encoding]::UTF8\n${withExitCode(command)}`] };
|
|
64
|
+
case 'bash':
|
|
65
|
+
return { file: shell.file, args: ['-c', command] };
|
|
66
|
+
default:
|
|
67
|
+
return { file: undefined, args: [] };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const cache = new Map();
|
|
71
|
+
/** The shell for this machine, looked up once per preference. */
|
|
72
|
+
export function runnerShell(preference = 'auto', platform = process.platform) {
|
|
73
|
+
const key = `${platform}:${preference}`;
|
|
74
|
+
let found = cache.get(key);
|
|
75
|
+
if (!found) {
|
|
76
|
+
found = chooseShell({ platform, env: process.env, preference });
|
|
77
|
+
cache.set(key, found);
|
|
78
|
+
}
|
|
79
|
+
return found;
|
|
80
|
+
}
|
|
@@ -77,7 +77,7 @@ export declare function childAgentConfig(setup: {
|
|
|
77
77
|
model: ModelConfig;
|
|
78
78
|
maxIterations: number;
|
|
79
79
|
systemPrompt?: string;
|
|
80
|
-
}, ctx: Pick<ToolContext, 'autoApproveTools' | 'permissionRules' | 'toolSandbox' | 'askBudget'>, cwd: string, auto: boolean): AgentConfig;
|
|
80
|
+
}, ctx: Pick<ToolContext, 'autoApproveTools' | 'permissionRules' | 'toolSandbox' | 'askBudget' | 'commandSandbox'>, cwd: string, auto: boolean): AgentConfig;
|
|
81
81
|
/** What a child runs without asking: what the parent does, else the read-only tools. Destructive tools still never run unasked. */
|
|
82
82
|
export declare function childAutoApprove(ctx: Pick<ToolContext, 'autoApproveTools'>): string[];
|
|
83
83
|
/** The configured sub-agent model, else the parent's, else a last-resort default. */
|
package/dist/tools/sub-agent.js
CHANGED
|
@@ -107,7 +107,9 @@ const DEFAULT_MAX_ITERATIONS = 10;
|
|
|
107
107
|
* passing `allowSubAgent: true` via context if you really want it)
|
|
108
108
|
* - nothing else: the sub-agent gets the full built-in toolset.
|
|
109
109
|
*/
|
|
110
|
-
|
|
110
|
+
// A child has no one to approve a command and no run of its own to stop a process when it ends, so it gets no
|
|
111
|
+
// background processes either (RB-14).
|
|
112
|
+
const FORBIDDEN_TOOLS = new Set(['sub_agent', 'sub_agent_wait', 'bg_start', 'bg_read', 'bg_stop', 'schedule_wakeup', 'cancel_wakeup', 'repo_clone', 'suggest_plan']);
|
|
111
113
|
/**
|
|
112
114
|
* Delegate a task to a child agent. The child runs synchronously and its
|
|
113
115
|
* final text + run statistics are returned to the parent. Useful for
|
|
@@ -328,6 +330,7 @@ export function childAgentConfig(setup, ctx, cwd, auto) {
|
|
|
328
330
|
autoApproveTools: childAutoApprove(ctx),
|
|
329
331
|
...(ctx.permissionRules ? { permissionRules: ctx.permissionRules } : {}),
|
|
330
332
|
...(ctx.toolSandbox ? { toolSandbox: ctx.toolSandbox } : {}),
|
|
333
|
+
...(ctx.commandSandbox ? { commandSandbox: ctx.commandSandbox } : {}),
|
|
331
334
|
// The same budget object, not a copy: a child that started its own four
|
|
332
335
|
// would be a way around the limit the parent is under (RB-14).
|
|
333
336
|
...(ctx.askBudget ? { askBudget: ctx.askBudget } : {}),
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ToolDefinition } from '../types.js';
|
|
3
|
+
declare const params: z.ZodObject<{
|
|
4
|
+
reason: z.ZodString;
|
|
5
|
+
}, z.core.$strip>;
|
|
6
|
+
export interface SuggestPlanResult {
|
|
7
|
+
/** True when the chat will ask the person; the model should then stop. */
|
|
8
|
+
suggested: boolean;
|
|
9
|
+
reason?: string;
|
|
10
|
+
note: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* `suggest_plan`: the model says a request is big or unclear enough to plan before changing anything. It only signals.
|
|
14
|
+
* The chat reads the result after the run and asks the person ("Plan first / Just do it"); "Just do it" runs the request
|
|
15
|
+
* again with `planDeclined` set, so the model cannot ask twice. Where no chat can ask, it says to carry on.
|
|
16
|
+
*/
|
|
17
|
+
export declare const suggestPlanTool: ToolDefinition<z.infer<typeof params>, SuggestPlanResult>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const params = z.object({
|
|
3
|
+
reason: z.string().min(1).describe('One sentence on why this needs a plan first, for the person to read.'),
|
|
4
|
+
});
|
|
5
|
+
/**
|
|
6
|
+
* `suggest_plan`: the model says a request is big or unclear enough to plan before changing anything. It only signals.
|
|
7
|
+
* The chat reads the result after the run and asks the person ("Plan first / Just do it"); "Just do it" runs the request
|
|
8
|
+
* again with `planDeclined` set, so the model cannot ask twice. Where no chat can ask, it says to carry on.
|
|
9
|
+
*/
|
|
10
|
+
export const suggestPlanTool = {
|
|
11
|
+
name: 'suggest_plan',
|
|
12
|
+
description: 'Suggest planning first when a request asks for a whole system or feature, a redesign or rewrite, or work of more than about ten steps or five files. ' +
|
|
13
|
+
'The person is asked whether to plan first. Call it as your first action, before reading or editing anything, then stop and reply with one short sentence. Not for a simple edit, a question, or when they asked for a plan or told you to just do it.',
|
|
14
|
+
parameters: params,
|
|
15
|
+
execute: async (args, ctx) => {
|
|
16
|
+
if (ctx.planDeclined) {
|
|
17
|
+
return { suggested: false, note: 'The person already chose to go ahead without a plan. Carry on with the task.' };
|
|
18
|
+
}
|
|
19
|
+
if (!ctx.suggestPlan) {
|
|
20
|
+
return { suggested: false, note: 'Nobody can be asked here. Carry on with the task, and state your plan in a sentence or two first.' };
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
suggested: true,
|
|
24
|
+
reason: args.reason,
|
|
25
|
+
note: 'The person is being asked whether to plan first. Stop now: do not change any file or run any command, and reply with one short sentence saying you suggested planning first.',
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
};
|