@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.
Files changed (3) hide show
  1. package/README.md +48 -3
  2. package/dist/index.js +324 -11
  3. 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
- the worker discipline (workspace-relative paths only, verbatim reporting
580
- of tool refusals, evidence-bearing conclusions) around its role prompt,
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 FORGE_ALL_TOOLS = [...FORGE_STATE_TOOLS, ...FORGE_EXEC_TOOLS];
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
- existing.worktree = worktree;
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 = relative(worktree, path);
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 ? isAbsolute(args.workdir) ? args.workdir : join6(state.worktree, args.workdir) : state.worktree;
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 bound = sessions.get(input2.sessionID);
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 bound = sessions.get(input2.sessionID);
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 = (isAbsolute(raw) ? raw : join6(base, raw)).replaceAll("\\", "/").toLowerCase();
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.18.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
+ }