@sorenllm/opencode-forge 0.18.0 → 0.19.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/README.md +48 -3
- package/dist/index.js +324 -11
- package/package.json +64 -64
package/README.md
CHANGED
|
@@ -70,7 +70,7 @@ tool layer (verified against the host's `tools` filter):
|
|
|
70
70
|
| | **forge family** — `forge` + your forge.json `forge-*` workers | **everyone else** — native `build` / `plan` / `general` / `explore` and your own agents |
|
|
71
71
|
| --- | --- | --- |
|
|
72
72
|
| exec surface | `forge_shell` / `forge_jobs` only; builtin `shell`/`bash` hidden (unconditional, probe-independent; covers user-defined `agent.forge` entries too) | builtin `shell`/`bash`, fully native |
|
|
73
|
-
| forge tools (`plan_*`, `goal_*`, `crew_*`, `forge_shell`, `forge_jobs`) | `forge` sees all; `forge-*` workers only `forge_shell` + `forge_jobs` | none visible |
|
|
73
|
+
| forge tools (`plan_*`, `goal_*`, `crew_*`, `forge_shell`, `forge_jobs`, `forge_grep`, `forge_glob`) | `forge` sees all; `forge-*` workers only `forge_shell` + `forge_jobs` + the search pair (`forge_grep`/`forge_glob`) | none visible |
|
|
74
74
|
| dispatching `forge-*` via `task` | allowed | refused at the tool layer |
|
|
75
75
|
| writing `.opencode/plan/` / `.opencode/goal/` | allowed (state machine governs) | refused at the tool layer (reads stay open) |
|
|
76
76
|
| forge system prompts (job guidance, plan/goal notices) | injected | never injected — zero forge text |
|
|
@@ -152,6 +152,10 @@ the plugin never writes them):
|
|
|
152
152
|
"watchdog": {
|
|
153
153
|
"mode": "kill", // "kill" (default) | "dry-run" | "off" — forge-family sessions only
|
|
154
154
|
"stallMs": 600000 // stall threshold, min 60000
|
|
155
|
+
},
|
|
156
|
+
"search": {
|
|
157
|
+
"rgPath": "" // optional explicit ripgrep binary path for forge_grep/forge_glob;
|
|
158
|
+
// default resolution: this override (when it exists) → rg on PATH
|
|
155
159
|
}
|
|
156
160
|
}]
|
|
157
161
|
]
|
|
@@ -412,6 +416,45 @@ These are runtime debris, not data — with ONE caveat: **if you uninstall with
|
|
|
412
416
|
/F /T`, or just reboot); deleting the directory afterwards is safe and
|
|
413
417
|
complete.
|
|
414
418
|
|
|
419
|
+
## Search tools (`forge_grep` / `forge_glob`)
|
|
420
|
+
|
|
421
|
+
rg-backed search for the forge family — same interface and output format as
|
|
422
|
+
the builtin `grep`/`glob` pair, but **`follow` and `hidden` default to TRUE**:
|
|
423
|
+
workspace content behind symlinks/junctions and inside dot-directories (which
|
|
424
|
+
the builtin pair silently misses) is in scope by default. `**/.git/**` is
|
|
425
|
+
always excluded. The builtin pair stays visible — the forge family is
|
|
426
|
+
instructed to prefer `forge_grep`/`forge_glob` first and use builtin
|
|
427
|
+
`grep`/`glob` as fallback only; the standing worker mandate reads:
|
|
428
|
+
|
|
429
|
+
> Run every search through the forge_grep/forge_glob tools first — the
|
|
430
|
+
> builtin grep/glob tools are fallback only. forge_grep/forge_glob see
|
|
431
|
+
> through symlinks/junctions and dot-directories because follow and hidden
|
|
432
|
+
> are on by default.
|
|
433
|
+
|
|
434
|
+
- **Params**: `pattern` (required); `path` (optional — defaults to the session
|
|
435
|
+
worktree; a file path targets that file); `include` (forge_grep file
|
|
436
|
+
filter); `follow` / `hidden` (optional booleans, both default `true`).
|
|
437
|
+
- **Output parity**: `Found N matches` with `path: Line n:` blocks
|
|
438
|
+
(forge_glob: a plain path list) — the same shapes as the builtin pair.
|
|
439
|
+
Results truncate at 100 with a truncation notice advising a narrower
|
|
440
|
+
path or pattern.
|
|
441
|
+
- **Follow dedupe**: a file reachable through its real path AND a link path
|
|
442
|
+
reports ONCE (lexicographically first path kept); when any dedupe occurred
|
|
443
|
+
the output ends with a `+N duplicate paths merged` line.
|
|
444
|
+
- **Outside-workspace links**: followed, with the resolved target disclosed
|
|
445
|
+
in the result.
|
|
446
|
+
- **Readonly by construction**: ripgrep is spawned directly with a fixed
|
|
447
|
+
argument vector — no shell, no redirection surface. The tools carry no
|
|
448
|
+
permission ask-gate and are not subject to the readonly exec seal, so
|
|
449
|
+
explicit-readonly workers search without friction.
|
|
450
|
+
|
|
451
|
+
**ripgrep dependency.** No binary is bundled. rg is resolved as:
|
|
452
|
+
`search.rgPath` config override (when it exists on disk) → `rg` on PATH →
|
|
453
|
+
fail-soft guidance error (never a crash):
|
|
454
|
+
|
|
455
|
+
> install ripgrep (or set search.rgPath in forge config to an existing rg
|
|
456
|
+
> binary), or fall back to the builtin grep/glob tools for this search
|
|
457
|
+
|
|
415
458
|
## Forge subagents (static worker agents in forge.json)
|
|
416
459
|
|
|
417
460
|
Forge subagents are **static, user-authored worker agents**: each forge.json
|
|
@@ -576,8 +619,10 @@ A rejected entry costs only itself — its siblings materialize normally.
|
|
|
576
619
|
layer), carrying the pinned `model`
|
|
577
620
|
(pinned workers) or NO model key at all (Auto workers — the description
|
|
578
621
|
says "inherits the parent session's model" instead of naming a brain),
|
|
579
|
-
|
|
580
|
-
|
|
622
|
+
the worker discipline (workspace-relative paths only, verbatim reporting
|
|
623
|
+
of tool refusals, evidence-bearing conclusions, search through
|
|
624
|
+
`forge_grep`/`forge_glob` first with builtin `grep`/`glob` as fallback
|
|
625
|
+
only) around its role prompt,
|
|
581
626
|
and a deny-style permission: an ABSENT `shape` defaults to WRITE (open
|
|
582
627
|
permissions — the compatibility-preserving default for existing pools);
|
|
583
628
|
an explicit `shape: "readonly"` denies
|
package/dist/index.js
CHANGED
|
@@ -12334,9 +12334,9 @@ function tool(input) {
|
|
|
12334
12334
|
}
|
|
12335
12335
|
tool.schema = exports_external;
|
|
12336
12336
|
// plugin.ts
|
|
12337
|
-
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
12337
|
+
import { execFileSync as execFileSync3, spawn as spawn6 } from "node:child_process";
|
|
12338
12338
|
import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync5, readdirSync as readdirSync4, readFileSync as readFileSync8, realpathSync, statSync as statSync5, writeFileSync as writeFileSync5 } from "node:fs";
|
|
12339
|
-
import { basename as basename2, dirname as dirname4, isAbsolute, join as join6, relative } from "node:path";
|
|
12339
|
+
import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute2, join as join6, relative as relative2 } from "node:path";
|
|
12340
12340
|
import { tmpdir as tmpdir3, homedir } from "node:os";
|
|
12341
12341
|
|
|
12342
12342
|
// src/plan-file.ts
|
|
@@ -15029,6 +15029,7 @@ var WORKER_MANDATES = [
|
|
|
15029
15029
|
"Mandates (non-negotiable):",
|
|
15030
15030
|
"- Use workspace-relative paths ONLY. Never write to or read from absolute paths outside this workspace; the workspace root is where you were started, not the filesystem root.",
|
|
15031
15031
|
"- Run every shell command through the forge_shell tool (the builtin shell is not part of your toolset by design). Long-running or non-exiting commands: forge_shell with run_in_background, then collect results via forge_jobs poll before yielding.",
|
|
15032
|
+
"- Run every search through the forge_grep/forge_glob tools first — the builtin grep/glob tools are fallback only. forge_grep/forge_glob see through symlinks/junctions and dot-directories because follow and hidden are on by default.",
|
|
15032
15033
|
"- If any tool call is refused or denied, report the refusal VERBATIM in your final answer and stop that line of work. Never improvise a workaround around a permission denial.",
|
|
15033
15034
|
"- End with conclusions backed by evidence references (file:line, command output, or the exact file/content you produced)."
|
|
15034
15035
|
].join(`
|
|
@@ -15974,6 +15975,202 @@ function parentDirLabel(filePath) {
|
|
|
15974
15975
|
return i < 0 ? s : s.slice(0, i);
|
|
15975
15976
|
}
|
|
15976
15977
|
|
|
15978
|
+
// src/search-core.ts
|
|
15979
|
+
import { join as winJoin } from "node:path";
|
|
15980
|
+
import { join as posixJoin } from "node:path/posix";
|
|
15981
|
+
import { relative, isAbsolute } from "node:path";
|
|
15982
|
+
import { existsSync as fsExistsSync } from "node:fs";
|
|
15983
|
+
var GIT_EXCLUSION_GLOB = "--glob=!**/.git/**";
|
|
15984
|
+
function buildRgArgv(params) {
|
|
15985
|
+
const { kind, pattern, path, include } = params;
|
|
15986
|
+
const follow = params.follow !== false;
|
|
15987
|
+
const hidden = params.hidden !== false;
|
|
15988
|
+
const argv = ["--no-config", "--json"];
|
|
15989
|
+
if (hidden)
|
|
15990
|
+
argv.push("--hidden");
|
|
15991
|
+
if (follow)
|
|
15992
|
+
argv.push("--follow");
|
|
15993
|
+
argv.push(GIT_EXCLUSION_GLOB);
|
|
15994
|
+
if (typeof include === "string" && include.length > 0) {
|
|
15995
|
+
argv.push(`--glob=${include}`);
|
|
15996
|
+
}
|
|
15997
|
+
argv.push("--");
|
|
15998
|
+
argv.push(pattern);
|
|
15999
|
+
if (typeof path === "string" && path.length > 0)
|
|
16000
|
+
argv.push(path);
|
|
16001
|
+
return argv;
|
|
16002
|
+
}
|
|
16003
|
+
function buildRgFilesArgv(params) {
|
|
16004
|
+
const follow = params.follow !== false;
|
|
16005
|
+
const hidden = params.hidden !== false;
|
|
16006
|
+
const argv = ["--no-config", "--files"];
|
|
16007
|
+
if (hidden)
|
|
16008
|
+
argv.push("--hidden");
|
|
16009
|
+
if (follow)
|
|
16010
|
+
argv.push("--follow");
|
|
16011
|
+
argv.push(`--glob=${params.pattern}`);
|
|
16012
|
+
argv.push(GIT_EXCLUSION_GLOB);
|
|
16013
|
+
if (typeof params.path === "string" && params.path.length > 0)
|
|
16014
|
+
argv.push(params.path);
|
|
16015
|
+
return argv;
|
|
16016
|
+
}
|
|
16017
|
+
function parseRgFilesList(stdout) {
|
|
16018
|
+
if (typeof stdout !== "string" || stdout.length === 0)
|
|
16019
|
+
return [];
|
|
16020
|
+
return stdout.split(/\r?\n/).filter((l) => l.length > 0).map((p) => ({ path: p, line: 0, text: "" }));
|
|
16021
|
+
}
|
|
16022
|
+
var MAX_STREAM_LINE_LENGTH = 1048576;
|
|
16023
|
+
function parseRgJsonStream(stdout) {
|
|
16024
|
+
const out = { records: [], garbageLines: 0, oversizedLines: 0 };
|
|
16025
|
+
if (typeof stdout !== "string" || stdout.length === 0)
|
|
16026
|
+
return out;
|
|
16027
|
+
const lines = stdout.split(/\r?\n/);
|
|
16028
|
+
for (const raw of lines) {
|
|
16029
|
+
if (raw.length === 0)
|
|
16030
|
+
continue;
|
|
16031
|
+
if (raw.length > MAX_STREAM_LINE_LENGTH) {
|
|
16032
|
+
out.oversizedLines++;
|
|
16033
|
+
continue;
|
|
16034
|
+
}
|
|
16035
|
+
let ev;
|
|
16036
|
+
try {
|
|
16037
|
+
ev = JSON.parse(raw);
|
|
16038
|
+
} catch {
|
|
16039
|
+
out.garbageLines++;
|
|
16040
|
+
continue;
|
|
16041
|
+
}
|
|
16042
|
+
if (ev === null || typeof ev !== "object" || ev.type !== "match")
|
|
16043
|
+
continue;
|
|
16044
|
+
const data = ev.data;
|
|
16045
|
+
if (data === null || typeof data !== "object")
|
|
16046
|
+
continue;
|
|
16047
|
+
const pathText = data?.path?.text;
|
|
16048
|
+
const lineNumber = data?.line_number;
|
|
16049
|
+
const lineText = data?.lines?.text;
|
|
16050
|
+
if (typeof pathText !== "string" || typeof lineNumber !== "number" || typeof lineText !== "string")
|
|
16051
|
+
continue;
|
|
16052
|
+
out.records.push({ path: pathText, line: lineNumber, text: lineText });
|
|
16053
|
+
}
|
|
16054
|
+
return out;
|
|
16055
|
+
}
|
|
16056
|
+
function createMemoizedResolver(resolve2) {
|
|
16057
|
+
const cache = new Map;
|
|
16058
|
+
return (p) => {
|
|
16059
|
+
const hit = cache.get(p);
|
|
16060
|
+
if (hit !== undefined)
|
|
16061
|
+
return hit;
|
|
16062
|
+
const v = resolve2(p);
|
|
16063
|
+
cache.set(p, v);
|
|
16064
|
+
return v;
|
|
16065
|
+
};
|
|
16066
|
+
}
|
|
16067
|
+
function isOutsideWorktree(resolved, root) {
|
|
16068
|
+
const norm = (s) => s.replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase();
|
|
16069
|
+
const r = norm(root);
|
|
16070
|
+
const v = norm(resolved);
|
|
16071
|
+
if (v === r)
|
|
16072
|
+
return false;
|
|
16073
|
+
const rel = relative(r, v);
|
|
16074
|
+
return rel.startsWith("..") || isAbsolute(rel);
|
|
16075
|
+
}
|
|
16076
|
+
function dedupeFollowMatches(records, opts) {
|
|
16077
|
+
if (!opts.follow)
|
|
16078
|
+
return { records: [...records], mergedDuplicates: 0, outsideTargets: [] };
|
|
16079
|
+
const resolve2 = createMemoizedResolver(opts.resolvePath);
|
|
16080
|
+
const seen = new Map;
|
|
16081
|
+
const outside = new Set;
|
|
16082
|
+
let merged = 0;
|
|
16083
|
+
for (const rec of records) {
|
|
16084
|
+
const real = resolve2(rec.path);
|
|
16085
|
+
if (isOutsideWorktree(real, opts.worktreeRoot))
|
|
16086
|
+
outside.add(real);
|
|
16087
|
+
const bucket = seen.get(real);
|
|
16088
|
+
if (bucket === undefined) {
|
|
16089
|
+
seen.set(real, [rec]);
|
|
16090
|
+
continue;
|
|
16091
|
+
}
|
|
16092
|
+
const sameLine = bucket.find((k) => k.line === rec.line);
|
|
16093
|
+
if (sameLine !== undefined) {
|
|
16094
|
+
merged++;
|
|
16095
|
+
if (rec.path < sameLine.path)
|
|
16096
|
+
sameLine.path = rec.path;
|
|
16097
|
+
continue;
|
|
16098
|
+
}
|
|
16099
|
+
if (rec.path < bucket[0].path)
|
|
16100
|
+
bucket[0].path = rec.path;
|
|
16101
|
+
bucket.push(rec);
|
|
16102
|
+
}
|
|
16103
|
+
const kept = [];
|
|
16104
|
+
for (const bucket of seen.values()) {
|
|
16105
|
+
const firstPath = bucket.reduce((m, r) => r.path < m ? r.path : m, bucket[0].path);
|
|
16106
|
+
for (const r of bucket)
|
|
16107
|
+
kept.push({ ...r, path: firstPath });
|
|
16108
|
+
}
|
|
16109
|
+
kept.sort((a, b) => a.path === b.path ? a.line - b.line : a.path < b.path ? -1 : 1);
|
|
16110
|
+
return { records: kept, mergedDuplicates: merged, outsideTargets: [...outside].sort() };
|
|
16111
|
+
}
|
|
16112
|
+
var RG_MISSING_REMEDIES = "install ripgrep (or set search.rgPath in forge config to an existing rg binary), or fall back to the builtin grep/glob tools for this search";
|
|
16113
|
+
function resolveRgBinary(config2, seams) {
|
|
16114
|
+
const platform = seams?.platform ?? process.platform;
|
|
16115
|
+
const sep2 = seams?.pathSep ?? (platform === "win32" ? ";" : ":");
|
|
16116
|
+
const exists = seams?.existsSync ?? fsExistsSync;
|
|
16117
|
+
const pathEnv = seams?.pathEnv ?? (process.env.PATH ?? "");
|
|
16118
|
+
if (typeof config2?.rgPath === "string" && config2.rgPath.length > 0 && exists(config2.rgPath)) {
|
|
16119
|
+
return { ok: true, binary: config2.rgPath, source: "config" };
|
|
16120
|
+
}
|
|
16121
|
+
const exe = platform === "win32" ? "rg.exe" : "rg";
|
|
16122
|
+
const join6 = platform === "win32" ? winJoin : posixJoin;
|
|
16123
|
+
for (const dir of pathEnv.split(sep2)) {
|
|
16124
|
+
if (dir.length === 0)
|
|
16125
|
+
continue;
|
|
16126
|
+
const candidate = join6(dir, exe);
|
|
16127
|
+
if (exists(candidate))
|
|
16128
|
+
return { ok: true, binary: candidate, source: "path" };
|
|
16129
|
+
}
|
|
16130
|
+
return {
|
|
16131
|
+
ok: false,
|
|
16132
|
+
error: `ripgrep (rg) not found — ${RG_MISSING_REMEDIES}`
|
|
16133
|
+
};
|
|
16134
|
+
}
|
|
16135
|
+
var MAX_RESULTS = 100;
|
|
16136
|
+
function formatSearchOutput(records, opts) {
|
|
16137
|
+
const lines = [];
|
|
16138
|
+
const total = records.length;
|
|
16139
|
+
if (total === 0) {
|
|
16140
|
+
lines.push("No files found");
|
|
16141
|
+
} else if (opts.kind === "glob") {
|
|
16142
|
+
const unique = [];
|
|
16143
|
+
const seen = new Set;
|
|
16144
|
+
for (const r of records) {
|
|
16145
|
+
if (!seen.has(r.path)) {
|
|
16146
|
+
seen.add(r.path);
|
|
16147
|
+
unique.push(r.path);
|
|
16148
|
+
}
|
|
16149
|
+
}
|
|
16150
|
+
for (const p of unique.slice(0, MAX_RESULTS))
|
|
16151
|
+
lines.push(p);
|
|
16152
|
+
if (unique.length > MAX_RESULTS) {
|
|
16153
|
+
lines.push(`(truncated at ${MAX_RESULTS} results — narrow the path or pattern)`);
|
|
16154
|
+
}
|
|
16155
|
+
} else {
|
|
16156
|
+
lines.push(`Found ${total} matches`);
|
|
16157
|
+
const shown = records.slice(0, MAX_RESULTS);
|
|
16158
|
+
for (const r of shown)
|
|
16159
|
+
lines.push(`${r.path}: Line ${r.line}: ${r.text.replace(/\r?\n$/, "")}`);
|
|
16160
|
+
if (total > MAX_RESULTS) {
|
|
16161
|
+
lines.push(`(truncated at ${MAX_RESULTS} results of ${total} — narrow the path or pattern)`);
|
|
16162
|
+
}
|
|
16163
|
+
}
|
|
16164
|
+
for (const t of opts.outsideTargets ?? []) {
|
|
16165
|
+
lines.push(`outside-workspace target searched via link: ${t}`);
|
|
16166
|
+
}
|
|
16167
|
+
const merged = opts.mergedDuplicates ?? 0;
|
|
16168
|
+
if (merged > 0)
|
|
16169
|
+
lines.push(`+${merged} duplicate paths merged`);
|
|
16170
|
+
return lines.join(`
|
|
16171
|
+
`);
|
|
16172
|
+
}
|
|
16173
|
+
|
|
15977
16174
|
// plugin.ts
|
|
15978
16175
|
var FORGE_AGENT = "forge";
|
|
15979
16176
|
var FORGE_STATE_TOOLS = [
|
|
@@ -15992,7 +16189,8 @@ var FORGE_STATE_TOOLS = [
|
|
|
15992
16189
|
"crew_close"
|
|
15993
16190
|
];
|
|
15994
16191
|
var FORGE_EXEC_TOOLS = ["forge_shell", "forge_jobs"];
|
|
15995
|
-
var
|
|
16192
|
+
var FORGE_SEARCH_TOOLS = ["forge_grep", "forge_glob"];
|
|
16193
|
+
var FORGE_ALL_TOOLS = [...FORGE_STATE_TOOLS, ...FORGE_EXEC_TOOLS, ...FORGE_SEARCH_TOOLS];
|
|
15996
16194
|
var FORGE_TOOL_SET = new Set(FORGE_ALL_TOOLS);
|
|
15997
16195
|
var FORGE_STATE_TOOL_SET = new Set(FORGE_STATE_TOOLS);
|
|
15998
16196
|
var NATIVE_PARTITION_FALLBACKS = ["build", "plan", "general", "explore"];
|
|
@@ -16391,7 +16589,8 @@ function readPlanDir(worktree) {
|
|
|
16391
16589
|
function ensureSession(sessionID, worktree) {
|
|
16392
16590
|
const existing = sessions.get(sessionID);
|
|
16393
16591
|
if (existing) {
|
|
16394
|
-
|
|
16592
|
+
if (!isRootish(worktree))
|
|
16593
|
+
existing.worktree = worktree;
|
|
16395
16594
|
return existing;
|
|
16396
16595
|
}
|
|
16397
16596
|
const state = { sessionID, worktree };
|
|
@@ -16423,7 +16622,7 @@ function resolveActivePlan(state) {
|
|
|
16423
16622
|
return { path, doc: ranked[0].doc };
|
|
16424
16623
|
}
|
|
16425
16624
|
function relFrom(worktree, path) {
|
|
16426
|
-
const rel =
|
|
16625
|
+
const rel = relative2(worktree, path);
|
|
16427
16626
|
return rel && !rel.startsWith("..") ? rel.replaceAll("\\", "/") : path;
|
|
16428
16627
|
}
|
|
16429
16628
|
var planWriteTool = tool({
|
|
@@ -16911,7 +17110,7 @@ var forgeShellTool = tool({
|
|
|
16911
17110
|
}
|
|
16912
17111
|
}
|
|
16913
17112
|
const { registry: registry2, fence } = ensureJobLifecycle();
|
|
16914
|
-
const cwd = args.workdir ?
|
|
17113
|
+
const cwd = args.workdir ? isAbsolute2(args.workdir) ? args.workdir : join6(state.worktree, args.workdir) : state.worktree;
|
|
16915
17114
|
const started = startJob(jobManager, {
|
|
16916
17115
|
cmd: args.command,
|
|
16917
17116
|
cwd,
|
|
@@ -17065,6 +17264,107 @@ ${p.newOutput || "(none in this window)"}`,
|
|
|
17065
17264
|
};
|
|
17066
17265
|
}
|
|
17067
17266
|
});
|
|
17267
|
+
var searchRgPathOverride;
|
|
17268
|
+
var FORGE_SEARCH_PREFERENCE_LINE = "Prefer forge_grep/forge_glob over the builtin grep/glob — follow and hidden are on by default (symlinks/junctions and dot-directories are in scope; the builtin pair is blind to both).";
|
|
17269
|
+
var FORGE_GREP_DESCRIPTION = [
|
|
17270
|
+
"Fast content search (rg --json under the hood) that sees through symlinks/junctions and dot-directories where the builtin pair is blind. Readonly by construction: the pattern and path are passed to ripgrep as single literal arguments — no shell exists, so shell metacharacters can neither redirect output nor alter execution.",
|
|
17271
|
+
"Output matches the builtin grep's format: `Found N matches` header + `path: Line n: content` blocks, truncated at 100 results with a notice. With follow on (default), a file reachable through multiple paths reports once and the output ends with a `+N duplicate paths merged` line; links whose targets lie outside the workspace are followed and the resolved outside target is disclosed.",
|
|
17272
|
+
FORGE_SEARCH_PREFERENCE_LINE
|
|
17273
|
+
].join(`
|
|
17274
|
+
`);
|
|
17275
|
+
var FORGE_GLOB_DESCRIPTION = [
|
|
17276
|
+
"Fast file-pattern search (rg --files under the hood, the file pattern rides rg's --glob filter) that lists matching paths — including EMPTY files — and paths behind symlinks/junctions and inside dot-directories where the builtin glob is blind. Readonly by construction — no shell ever exists.",
|
|
17277
|
+
"Output matches the builtin glob's format: a plain unique path list (`No files found` when empty), truncated at 100 results with a notice. With follow on (default), duplicate link-path hits merge and the output ends with a `+N duplicate paths merged` line.",
|
|
17278
|
+
FORGE_SEARCH_PREFERENCE_LINE
|
|
17279
|
+
].join(`
|
|
17280
|
+
`);
|
|
17281
|
+
function reportedPathResolver(worktree) {
|
|
17282
|
+
return (p) => {
|
|
17283
|
+
try {
|
|
17284
|
+
return realpathSync.native(isAbsolute2(p) ? p : join6(worktree, p)).toString();
|
|
17285
|
+
} catch {
|
|
17286
|
+
return p;
|
|
17287
|
+
}
|
|
17288
|
+
};
|
|
17289
|
+
}
|
|
17290
|
+
async function runRgSearch(kind, args, context) {
|
|
17291
|
+
const resolution = resolveRgBinary(searchRgPathOverride !== undefined ? { rgPath: searchRgPathOverride } : undefined);
|
|
17292
|
+
if (!resolution.ok)
|
|
17293
|
+
return { title: "ripgrep not found", output: resolution.error };
|
|
17294
|
+
const state = ensureSession(context.sessionID, worktreeFor(context));
|
|
17295
|
+
const follow = args.follow !== false;
|
|
17296
|
+
const hidden = args.hidden !== false;
|
|
17297
|
+
const targetPath = typeof args.path === "string" && args.path.length > 0 ? isAbsolute2(args.path) ? args.path : join6(state.worktree, args.path) : undefined;
|
|
17298
|
+
const searchPath = targetPath ?? ".";
|
|
17299
|
+
const argv = kind === "glob" ? buildRgFilesArgv({ pattern: args.pattern, path: searchPath, follow, hidden }) : buildRgArgv({ kind, pattern: args.pattern, include: args.include, path: searchPath, follow, hidden });
|
|
17300
|
+
context.metadata?.({ title: `forge_${kind}: ${args.pattern.slice(0, 60)}` });
|
|
17301
|
+
const child = spawn6(resolution.binary, argv, { cwd: state.worktree, shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
17302
|
+
let stdout = "";
|
|
17303
|
+
let stderr = "";
|
|
17304
|
+
let spawnError;
|
|
17305
|
+
child.on("error", (err) => {
|
|
17306
|
+
spawnError = err;
|
|
17307
|
+
});
|
|
17308
|
+
child.stdout?.on("data", (d) => {
|
|
17309
|
+
stdout += d.toString("utf8");
|
|
17310
|
+
});
|
|
17311
|
+
child.stderr?.on("data", (d) => {
|
|
17312
|
+
stderr += d.toString("utf8");
|
|
17313
|
+
});
|
|
17314
|
+
const code = await new Promise((resolve2) => {
|
|
17315
|
+
child.on("close", (c) => resolve2(c));
|
|
17316
|
+
});
|
|
17317
|
+
if (spawnError !== undefined) {
|
|
17318
|
+
return { title: "rg spawn failed", output: `[forge:search] Failed to run ripgrep (${resolution.binary}): ${spawnError.message}` };
|
|
17319
|
+
}
|
|
17320
|
+
if (code !== null && code > 1) {
|
|
17321
|
+
return { title: `rg exit ${code}`, output: [`[forge:search] ripgrep exited ${code}:`, stderr.trim() || "(no stderr)", "Narrow the path or fix the pattern (rg treats it as a regular expression)."].join(`
|
|
17322
|
+
`) };
|
|
17323
|
+
}
|
|
17324
|
+
const stripDotPrefix = (p) => p.replace(/^\.(?:\\|\/)/, "");
|
|
17325
|
+
const rawRecords = kind === "glob" ? parseRgFilesList(stdout) : parseRgJsonStream(stdout).records;
|
|
17326
|
+
const records = rawRecords.map((r) => ({ ...r, path: stripDotPrefix(r.path) }));
|
|
17327
|
+
const deduped = dedupeFollowMatches(records, {
|
|
17328
|
+
worktreeRoot: state.worktree,
|
|
17329
|
+
resolvePath: reportedPathResolver(state.worktree),
|
|
17330
|
+
follow
|
|
17331
|
+
});
|
|
17332
|
+
return {
|
|
17333
|
+
title: `${deduped.records.length} result${deduped.records.length === 1 ? "" : "s"}`,
|
|
17334
|
+
output: formatSearchOutput(deduped.records, {
|
|
17335
|
+
kind,
|
|
17336
|
+
mergedDuplicates: deduped.mergedDuplicates,
|
|
17337
|
+
outsideTargets: deduped.outsideTargets
|
|
17338
|
+
})
|
|
17339
|
+
};
|
|
17340
|
+
}
|
|
17341
|
+
var forgeGrepTool = tool({
|
|
17342
|
+
description: FORGE_GREP_DESCRIPTION,
|
|
17343
|
+
args: {
|
|
17344
|
+
pattern: tool.schema.string().describe("The regular expression to search for (passed to ripgrep verbatim as a single literal argument)"),
|
|
17345
|
+
path: tool.schema.string().optional().describe("Optional target file or directory (workspace-relative, or absolute). Defaults to the session worktree"),
|
|
17346
|
+
include: tool.schema.string().optional().describe('File filter glob (e.g. "*.ts") — only matching files are searched'),
|
|
17347
|
+
follow: tool.schema.boolean().optional().describe("Follow symlinks/junctions during traversal (default TRUE — diverges from the builtin pair by design)"),
|
|
17348
|
+
hidden: tool.schema.boolean().optional().describe("Search dot-directories (default TRUE — diverges from the builtin pair by design)")
|
|
17349
|
+
},
|
|
17350
|
+
execute: async (args, context) => {
|
|
17351
|
+
noteToolAgent(context);
|
|
17352
|
+
return runRgSearch("grep", { pattern: args.pattern, path: args.path, include: args.include, follow: args.follow, hidden: args.hidden }, context);
|
|
17353
|
+
}
|
|
17354
|
+
});
|
|
17355
|
+
var forgeGlobTool = tool({
|
|
17356
|
+
description: FORGE_GLOB_DESCRIPTION,
|
|
17357
|
+
args: {
|
|
17358
|
+
pattern: tool.schema.string().describe(`The file-pattern glob to list (e.g. "**/*.ts") — rides ripgrep's --glob filter verbatim`),
|
|
17359
|
+
path: tool.schema.string().optional().describe("Optional target directory (workspace-relative, or absolute). Defaults to the session worktree"),
|
|
17360
|
+
follow: tool.schema.boolean().optional().describe("Follow symlinks/junctions during traversal (default TRUE — diverges from the builtin pair by design)"),
|
|
17361
|
+
hidden: tool.schema.boolean().optional().describe("List dot-directories' files (default TRUE — diverges from the builtin pair by design)")
|
|
17362
|
+
},
|
|
17363
|
+
execute: async (args, context) => {
|
|
17364
|
+
noteToolAgent(context);
|
|
17365
|
+
return runRgSearch("glob", { pattern: args.pattern, path: args.path, follow: args.follow, hidden: args.hidden }, context);
|
|
17366
|
+
}
|
|
17367
|
+
});
|
|
17068
17368
|
var CREW_INIT_TEMPLATE = [
|
|
17069
17369
|
'(forge crew orchestration. Argument: "$ARGUMENTS")',
|
|
17070
17370
|
"",
|
|
@@ -17382,6 +17682,8 @@ function forgeTools() {
|
|
|
17382
17682
|
tools.forge_shell = forgeShellTool;
|
|
17383
17683
|
tools.forge_jobs = forgeJobsTool;
|
|
17384
17684
|
}
|
|
17685
|
+
tools.forge_grep = forgeGrepTool;
|
|
17686
|
+
tools.forge_glob = forgeGlobTool;
|
|
17385
17687
|
if (!forgeDisabled) {
|
|
17386
17688
|
tools.crew_begin = crewBeginTool;
|
|
17387
17689
|
tools.crew_close = crewCloseTool;
|
|
@@ -17609,6 +17911,17 @@ var server = async (input, options) => {
|
|
|
17609
17911
|
hostWorktree = effectiveInitDir;
|
|
17610
17912
|
rememberHostAnchor(effectiveInitDir);
|
|
17611
17913
|
const client = input.client;
|
|
17914
|
+
const resolveSessionAnchorForPools = async (sessionID) => {
|
|
17915
|
+
if (!sessionID)
|
|
17916
|
+
return "";
|
|
17917
|
+
try {
|
|
17918
|
+
const rec = client?.session?.get ? await client.session.get({ path: { id: sessionID } }) : undefined;
|
|
17919
|
+
const anchor = effectiveWorktree(rec?.worktree, rec?.directory);
|
|
17920
|
+
if (anchor && !isRootish(anchor))
|
|
17921
|
+
return anchor;
|
|
17922
|
+
} catch {}
|
|
17923
|
+
return sessions.get(sessionID)?.worktree ?? "";
|
|
17924
|
+
};
|
|
17612
17925
|
jobClient = input.client;
|
|
17613
17926
|
const jobsOpts = options?.jobs;
|
|
17614
17927
|
if (jobsOpts?.mode === "auto" || jobsOpts?.mode === "forge" || jobsOpts?.mode === "native")
|
|
@@ -17616,6 +17929,8 @@ var server = async (input, options) => {
|
|
|
17616
17929
|
jobsKeepBuiltinShell = jobsOpts?.keepBuiltinShell === true;
|
|
17617
17930
|
if (jobsOpts?.survive === "never" || jobsOpts?.survive === "always" || jobsOpts?.survive === "deny")
|
|
17618
17931
|
jobsSurviveMode = jobsOpts.survive;
|
|
17932
|
+
const searchOpts = options?.search;
|
|
17933
|
+
searchRgPathOverride = typeof searchOpts?.rgPath === "string" && searchOpts.rgPath.trim().length > 0 ? searchOpts.rgPath : undefined;
|
|
17619
17934
|
const wdOpts = options?.watchdog;
|
|
17620
17935
|
const wdFallbacks = [];
|
|
17621
17936
|
const wdMode = parseMode(wdOpts?.mode);
|
|
@@ -17821,8 +18136,7 @@ var server = async (input, options) => {
|
|
|
17821
18136
|
const agentId = target.slice("forge-".length);
|
|
17822
18137
|
const entry = resolution?.agents[agentId];
|
|
17823
18138
|
if (resolution && entry) {
|
|
17824
|
-
const
|
|
17825
|
-
const anchor = bound?.worktree ?? "";
|
|
18139
|
+
const anchor = await resolveSessionAnchorForPools(input2.sessionID);
|
|
17826
18140
|
if (!anchor) {
|
|
17827
18141
|
if (!poolGateUnknownNotes.has(input2.sessionID)) {
|
|
17828
18142
|
poolGateUnknownNotes.add(input2.sessionID);
|
|
@@ -17844,8 +18158,7 @@ var server = async (input, options) => {
|
|
|
17844
18158
|
if (target && target.toLowerCase().startsWith("forge-") && input2.sessionID) {
|
|
17845
18159
|
const crew = crews.get(input2.sessionID);
|
|
17846
18160
|
if (crew && (crew.mode === "pending" || crew.mode === "executing")) {
|
|
17847
|
-
const
|
|
17848
|
-
const anchor = bound?.worktree ?? "";
|
|
18161
|
+
const anchor = await resolveSessionAnchorForPools(input2.sessionID);
|
|
17849
18162
|
if (anchor) {
|
|
17850
18163
|
const complianceRefusal = crewPoolComplianceRefusal(anchor);
|
|
17851
18164
|
if (complianceRefusal)
|
|
@@ -17862,7 +18175,7 @@ var server = async (input, options) => {
|
|
|
17862
18175
|
if (raw) {
|
|
17863
18176
|
const state = stateForBan(input2.sessionID);
|
|
17864
18177
|
const base = state?.worktree ?? hostWorktree ?? "";
|
|
17865
|
-
const abs = (
|
|
18178
|
+
const abs = (isAbsolute2(raw) ? raw : join6(base, raw)).replaceAll("\\", "/").toLowerCase();
|
|
17866
18179
|
if (abs.includes("/.opencode/plan/") || abs.includes("/.opencode/goal/")) {
|
|
17867
18180
|
throw new Error(`[forge:partition] Write refused: "${raw}" is inside the forge state directories (.opencode/plan, .opencode/goal), which belong to the forge agent in this workspace.`);
|
|
17868
18181
|
}
|
package/package.json
CHANGED
|
@@ -1,64 +1,64 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@sorenllm/opencode-forge",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Single general-purpose forge agent for opencode: static worker subagents defined in forge.json (pinned model + thoughtLevel pair, or an Auto worker inheriting the parent model; dispatched through the native task tool — TUI-visible), a plan harness (file-backed plans with approve/close confirmation gates and a hard draft write-ban), a goal harness (autonomous, host-verified objectives), a /crew orchestration command (declared-plan waves with an evidence-checked close gate), a job supervisor (forge_shell with exit-bound completion, idle/success early return, background jobs with poll/log/kill/handoff), and a builtin-shell hang watchdog.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "./dist/index.js",
|
|
7
|
-
"exports": {
|
|
8
|
-
".": {
|
|
9
|
-
"import": "./dist/index.js"
|
|
10
|
-
},
|
|
11
|
-
"./server": {
|
|
12
|
-
"import": "./dist/index.js"
|
|
13
|
-
}
|
|
14
|
-
},
|
|
15
|
-
"files": [
|
|
16
|
-
"dist",
|
|
17
|
-
"README.md"
|
|
18
|
-
],
|
|
19
|
-
"scripts": {
|
|
20
|
-
"bundle": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && bun build ./plugin.ts --outfile ./dist/index.js --target node --format esm",
|
|
21
|
-
"test": "node --test tests/*.test.mjs",
|
|
22
|
-
"typecheck": "tsc --noEmit"
|
|
23
|
-
},
|
|
24
|
-
"keywords": [
|
|
25
|
-
"opencode",
|
|
26
|
-
"opencode-plugin",
|
|
27
|
-
"subagents",
|
|
28
|
-
"plan",
|
|
29
|
-
"planning",
|
|
30
|
-
"workflow",
|
|
31
|
-
"single-agent",
|
|
32
|
-
"goal",
|
|
33
|
-
"crew",
|
|
34
|
-
"orchestration",
|
|
35
|
-
"background-jobs",
|
|
36
|
-
"shell",
|
|
37
|
-
"watchdog"
|
|
38
|
-
],
|
|
39
|
-
"license": "MIT",
|
|
40
|
-
"author": "chengsongren",
|
|
41
|
-
"homepage": "https://github.com/ChengZiiii/opencode-forge#readme",
|
|
42
|
-
"repository": {
|
|
43
|
-
"type": "git",
|
|
44
|
-
"url": "git+https://github.com/ChengZiiii/opencode-forge.git"
|
|
45
|
-
},
|
|
46
|
-
"bugs": {
|
|
47
|
-
"url": "https://github.com/ChengZiiii/opencode-forge/issues"
|
|
48
|
-
},
|
|
49
|
-
"peerDependencies": {
|
|
50
|
-
"@opencode-ai/plugin": "^1.18.0"
|
|
51
|
-
},
|
|
52
|
-
"devDependencies": {
|
|
53
|
-
"@opencode-ai/plugin": "^1.18.0",
|
|
54
|
-
"@types/node": "^22.13.9",
|
|
55
|
-
"typescript": "^5.8.2"
|
|
56
|
-
},
|
|
57
|
-
"engines": {
|
|
58
|
-
"opencode": "^1.18.0"
|
|
59
|
-
},
|
|
60
|
-
"publishConfig": {
|
|
61
|
-
"access": "public",
|
|
62
|
-
"registry": "https://registry.npmjs.org/"
|
|
63
|
-
}
|
|
64
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@sorenllm/opencode-forge",
|
|
3
|
+
"version": "0.19.0",
|
|
4
|
+
"description": "Single general-purpose forge agent for opencode: static worker subagents defined in forge.json (pinned model + thoughtLevel pair, or an Auto worker inheriting the parent model; dispatched through the native task tool — TUI-visible), a plan harness (file-backed plans with approve/close confirmation gates and a hard draft write-ban), a goal harness (autonomous, host-verified objectives), a /crew orchestration command (declared-plan waves with an evidence-checked close gate), a job supervisor (forge_shell with exit-bound completion, idle/success early return, background jobs with poll/log/kill/handoff), and a builtin-shell hang watchdog.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./server": {
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"bundle": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && bun build ./plugin.ts --outfile ./dist/index.js --target node --format esm",
|
|
21
|
+
"test": "node --test tests/*.test.mjs",
|
|
22
|
+
"typecheck": "tsc --noEmit"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"opencode",
|
|
26
|
+
"opencode-plugin",
|
|
27
|
+
"subagents",
|
|
28
|
+
"plan",
|
|
29
|
+
"planning",
|
|
30
|
+
"workflow",
|
|
31
|
+
"single-agent",
|
|
32
|
+
"goal",
|
|
33
|
+
"crew",
|
|
34
|
+
"orchestration",
|
|
35
|
+
"background-jobs",
|
|
36
|
+
"shell",
|
|
37
|
+
"watchdog"
|
|
38
|
+
],
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"author": "chengsongren",
|
|
41
|
+
"homepage": "https://github.com/ChengZiiii/opencode-forge#readme",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/ChengZiiii/opencode-forge.git"
|
|
45
|
+
},
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/ChengZiiii/opencode-forge/issues"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@opencode-ai/plugin": "^1.18.0"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@opencode-ai/plugin": "^1.18.0",
|
|
54
|
+
"@types/node": "^22.13.9",
|
|
55
|
+
"typescript": "^5.8.2"
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"opencode": "^1.18.0"
|
|
59
|
+
},
|
|
60
|
+
"publishConfig": {
|
|
61
|
+
"access": "public",
|
|
62
|
+
"registry": "https://registry.npmjs.org/"
|
|
63
|
+
}
|
|
64
|
+
}
|