@sorenllm/opencode-forge 0.9.1 → 0.11.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 +66 -6
  2. package/dist/index.js +248 -77
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -171,7 +171,7 @@ What this plugin touches, exhaustively:
171
171
  | `<tmp>/opencode-forge/jobs/registry.json` | persistent survivor registry (bounded: 100 entries) | runtime debris — after uninstall, kill any still-running `survive` jobs yourself first |
172
172
  | `<tmp>/opencode-forge/watchdog/log.jsonl` | watchdog interventions ledger (bounded: 200 entries, oldest rotated) | runtime debris — delete freely, also after uninstall |
173
173
  | `<tmp>/opencode-forge/dispatch/ledger.jsonl` | dispatch ledger — **no longer written** (the dispatch engine was removed); only present as inert debris from older versions | runtime debris — delete freely |
174
- | `<project>/.opencode/forge.json`, `~/.config/opencode/forge.json` | your static subagent definitions (JSONC). **User data — the plugin only reads it, never writes or migrates it** | yours — version the project one, keep the global one out of sync tools if it holds machine-specific models |
174
+ | the nearest `.opencode/forge.json` on the workspace's ancestor chain, `~/.config/opencode/forge.json` | your static subagent definitions (JSONC), two MERGED layers (project overrides global per agent id). **User data — the plugin only reads it, never writes or migrates it** | yours — version the project one, keep the global one out of sync tools if it holds machine-specific models |
175
175
  | `~/.cache/opencode/packages/...` | installed package copy | written by the `opencode plugin` installer, not the plugin |
176
176
  | `~/.config/opencode/opencode.json` | `plugin` array entry | written by the installer |
177
177
 
@@ -275,6 +275,23 @@ Every result form carries `cwd:` — the absolute directory the command
275
275
  actually ran in. When the agent's narrative about the working directory
276
276
  disagrees with that line, the line wins.
277
277
 
278
+ **Interpreter selection.** Commands run under the host-aligned shell —
279
+ the same interpreter the host's builtin shell tool uses — never under
280
+ Node's `shell: true` default (cmd.exe / plain sh). On Windows the chain
281
+ is `pwsh → powershell → git-bash → %COMSPEC%` (cmd.exe only as a
282
+ machine-level capability fallback); on POSIX the login shell with bash
283
+ preferred and `/bin/sh` as the fallback. PowerShell-family shells are
284
+ invoked directly (`<shell> -NoProfile -Command <command>` plus an
285
+ exit-code guard, so a native command's exit status propagates as the
286
+ job's exit status) — which means you write PowerShell syntax in
287
+ `forge_shell` on Windows: `echo $env:USERNAME` expands, quoted-exe
288
+ invocations use the call operator (`& "C:\path with spaces\tool.exe" args`).
289
+ The tool description states the interpreter family, and there is no
290
+ configuration knob: the interpreter follows the host, by design.
291
+ Goal-harness verification checks run through the same resolver, so a
292
+ check drafted under the model's shell expectations behaves identically
293
+ at the completion gate.
294
+
278
295
  **`forge_jobs`** manages the registry: `list` / `poll {jobId, waitMs≤30s}`
279
296
  (bounded wait for new output or exit, drains it) / `log {jobId, offset?,
280
297
  limit?}` (line paging over the on-disk log) / `kill` (whole process tree) /
@@ -388,11 +405,28 @@ subagent behavior is untouched. Onboarding is this README, for you the human.
388
405
 
389
406
  ### forge.json — the configuration
390
407
 
391
- A dedicated `forge.json` (JSONC — comments allowed), resolved by a two-level
392
- cascade with a single winning source (never merged):
393
-
394
- 1. `<project>/.opencode/forge.json` — project-level, versionable, team-shared
395
- 2. `~/.config/opencode/forge.json` — global
408
+ A dedicated `forge.json` (JSONC — comments allowed), resolved exactly like
409
+ opencode's own config language — **merged together, not replaced**, project
410
+ discovered by walking up:
411
+
412
+ 1. the project layer: the **nearest** `.opencode/forge.json` walking up from
413
+ the workspace (session worktree → session directory → host launch
414
+ directory as the anchor chain; first file found on the ancestor chain
415
+ wins, nothing above it is consulted)
416
+ 2. the global layer: `~/.config/opencode/forge.json`
417
+
418
+ The two layers **merge**: agents combine, and on an id collision the project
419
+ definition wins wholesale (one definition per id — no cross-layer field
420
+ blending). A broken file empties only its own layer (error finding with the
421
+ parse location); the other layer still applies. Both layers hot-apply —
422
+ edits, and files appearing or disappearing on the ancestor chain, take
423
+ effect without a host restart (new agents still need the restart to enter
424
+ the task vocabulary; see the apply-timing table below).
425
+
426
+ One host instance materializes one pool (from its launch anchor) — a
427
+ workspace-local file that the host was not launched on is NOT silently
428
+ ignored: `/crew` names it and tells you to relaunch there (or fold the
429
+ agents into the global layer).
396
430
 
397
431
  ```jsonc
398
432
  {
@@ -605,6 +639,32 @@ supervisor chapter above). The hide is unconditional: it covers a
605
639
  `jobs.keepBuiltinShell: true` (builtin shell back on the forge family) and
606
640
  `jobs.mode: "native"` (supervisor retired entirely).
607
641
 
642
+ ### The exec channel is final for this plugin-API generation (2026-09-28)
643
+
644
+ The four layers — tools-map hide, prompt mandate, belt refusal, `forge_shell`
645
+ itself — are the ceiling of what the current opencode plugin API can express,
646
+ and the design is closed deliberately:
647
+
648
+ - **Not expressible**: `tool.execute.before` can only mutate `args` or throw.
649
+ There is no tool-name rewrite, no executor replacement, no result injection —
650
+ "transparently reroute a builtin bash call through the job runner" cannot be
651
+ written. Registering a plugin tool named `bash` collides globally: tool
652
+ registration is not per-agent, and the per-agent mechanism is exactly the
653
+ tools map some hosts ignore.
654
+ - **Prior art ships a subset**: OMO (oh-my-opencode-slim / oh-my-openagent,
655
+ the largest opencode plugin ecosystem) pairs a permission-deny boundary with
656
+ a prompt channel mandate and does not hide the builtin shell at plugin level
657
+ either — their models also see bash and pay one denial round.
658
+ - **The residual is bounded and accepted**: on hosts where the hide does not
659
+ hold, a first bash attempt costs one round-trip per session — the belt
660
+ refuses pre-execution (~2 ms, the command never runs), the message names
661
+ `forge_shell`, and models re-issue once and stay on-channel. Field data
662
+ (2026-09-28): zero duplicate executions attributable to the belt.
663
+ - **Reopen on upstream movement**: hosts honoring injected tools maps (the
664
+ belt becomes pure insurance), a tool-execute replacement hook (transparent
665
+ rerouting becomes expressible), or exit-based builtin-shell completion (the
666
+ watchdog retirement path above applies).
667
+
608
668
  A process restart forgets the session binding: the write-ban soft-disables
609
669
  (safety over strictness) and the next session's system notice + `/plan
610
670
  resume` re-bind from the plan file on disk, which is the source of truth.
package/dist/index.js CHANGED
@@ -12335,8 +12335,8 @@ function tool(input) {
12335
12335
  tool.schema = exports_external;
12336
12336
  // plugin.ts
12337
12337
  import { execFileSync as execFileSync3 } from "node:child_process";
12338
- import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync5, readdirSync as readdirSync4, readFileSync as readFileSync8, statSync as statSync4, writeFileSync as writeFileSync5 } from "node:fs";
12339
- import { isAbsolute, join as join5, relative } from "node:path";
12338
+ import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync5, readdirSync as readdirSync4, readFileSync as readFileSync8, statSync as statSync5, writeFileSync as writeFileSync5 } from "node:fs";
12339
+ import { isAbsolute, join as join6, relative } from "node:path";
12340
12340
  import { tmpdir as tmpdir3, homedir } from "node:os";
12341
12341
 
12342
12342
  // src/plan-file.ts
@@ -13118,13 +13118,97 @@ function completeCheckFailures(doc2, attestations) {
13118
13118
  // src/run-check.ts
13119
13119
  import { spawn as spawn2 } from "node:child_process";
13120
13120
  import { readFileSync } from "node:fs";
13121
- import { join, resolve, sep } from "node:path";
13121
+ import { join as join2, resolve, sep } from "node:path";
13122
+
13123
+ // src/proc.ts
13124
+ import { spawn, spawnSync as spawnSync2 } from "node:child_process";
13125
+
13126
+ // src/shell-select.ts
13127
+ import { spawnSync } from "node:child_process";
13128
+ import { statSync } from "node:fs";
13129
+ import { basename, dirname, join } from "node:path";
13130
+ var hostConfiguredShell;
13131
+ var memo;
13132
+ function setHostShell(shell) {
13133
+ hostConfiguredShell = shell && shell.trim() ? shell.trim() : undefined;
13134
+ memo = undefined;
13135
+ }
13136
+ var DENY = new Set(["fish", "nu"]);
13137
+ function shellName(bin) {
13138
+ return basename(bin).replace(/\.exe$/i, "").toLowerCase();
13139
+ }
13140
+ function classify(bin) {
13141
+ const name = shellName(bin);
13142
+ if (name === "pwsh" || name === "powershell")
13143
+ return { bin, family: "ps", login: false };
13144
+ if (name === "cmd")
13145
+ return { bin, family: "cmd", login: false };
13146
+ return { bin, family: "posix", login: name === "bash" || name === "zsh" };
13147
+ }
13148
+ function defaultWhich(platform, env) {
13149
+ return (bin) => {
13150
+ try {
13151
+ const r = spawnSync(platform === "win32" ? "where" : "which", [bin], {
13152
+ env,
13153
+ windowsHide: true,
13154
+ timeout: 5000
13155
+ });
13156
+ if (r.status !== 0 || !r.stdout)
13157
+ return;
13158
+ const first = r.stdout.toString().split(/\r?\n/).find((l) => l.trim());
13159
+ return first?.trim() || undefined;
13160
+ } catch {
13161
+ return;
13162
+ }
13163
+ };
13164
+ }
13165
+ function defaultStat(file2) {
13166
+ return statSync(file2, { throwIfNoEntry: false }) ?? undefined;
13167
+ }
13168
+ function gitBash(which, stat) {
13169
+ const git = which("git");
13170
+ if (!git)
13171
+ return;
13172
+ const candidate = join(dirname(dirname(git)), "bin", "bash.exe");
13173
+ return stat(candidate)?.isFile() ? candidate : undefined;
13174
+ }
13175
+ function resolveShell(deps = {}) {
13176
+ const hasDeps = Object.keys(deps).length > 0;
13177
+ if (!hasDeps && memo)
13178
+ return memo;
13179
+ const platform = deps.platform ?? process.platform;
13180
+ const env = deps.env ?? process.env;
13181
+ const which = deps.which ?? defaultWhich(platform, env);
13182
+ const stat = deps.stat ?? defaultStat;
13183
+ const pick2 = (candidate) => {
13184
+ if (!candidate)
13185
+ return;
13186
+ if (DENY.has(shellName(candidate)))
13187
+ return;
13188
+ const resolved = /^[a-z]:[\\/]/i.test(candidate) || candidate.includes("/") || candidate.includes("\\") ? stat(candidate)?.isFile() ? candidate : which(candidate) : which(candidate);
13189
+ return resolved ? classify(resolved) : undefined;
13190
+ };
13191
+ let result = pick2(hostConfiguredShell);
13192
+ if (!result && platform === "win32") {
13193
+ result = pick2("pwsh") ?? pick2("powershell") ?? pick2(gitBash(which, stat)) ?? pick2(env.COMSPEC || "cmd.exe");
13194
+ }
13195
+ if (!result && platform !== "win32") {
13196
+ result = pick2(env.SHELL) ?? pick2("bash") ?? pick2("/bin/sh");
13197
+ }
13198
+ if (!result) {
13199
+ result = platform === "win32" ? { bin: env.COMSPEC || "cmd.exe", family: "cmd", login: false } : { bin: "/bin/sh", family: "posix", login: false };
13200
+ }
13201
+ if (!hasDeps)
13202
+ memo = result;
13203
+ return result;
13204
+ }
13122
13205
 
13123
13206
  // src/proc.ts
13124
- import { spawn, spawnSync } from "node:child_process";
13125
13207
  function shellSpawn(spawnFn, cmd, opts = {}) {
13126
- return spawnFn(cmd, {
13127
- shell: true,
13208
+ const shell = opts.shell ?? resolveShell();
13209
+ const argv = shell.family === "ps" ? ["-NoProfile", "-Command", `${cmd}
13210
+ exit $LASTEXITCODE`] : shell.family === "cmd" ? ["/c", cmd] : shell.login ? ["-l", "-c", cmd] : ["-c", cmd];
13211
+ return spawnFn(shell.bin, argv, {
13128
13212
  ...opts.cwd !== undefined ? { cwd: opts.cwd } : {},
13129
13213
  env: opts.env ? { ...process.env, ...opts.env } : process.env,
13130
13214
  windowsHide: opts.windowsHide ?? true,
@@ -13171,7 +13255,7 @@ function pidAlive(pid) {
13171
13255
  function terminateTreeSync(pid, opts = {}) {
13172
13256
  const graceMs = Math.max(0, opts.graceMs ?? 0);
13173
13257
  const platform = opts.platform ?? process.platform;
13174
- const sync = opts.spawnSyncFn ?? spawnSync;
13258
+ const sync = opts.spawnSyncFn ?? spawnSync2;
13175
13259
  const wait = opts.wait ?? true;
13176
13260
  const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
13177
13261
  const run = (force) => {
@@ -13207,12 +13291,7 @@ var OUTPUT_LIMIT = 2048;
13207
13291
  var defaultShellRunner = (cmd, opts) => new Promise((resolveRun) => {
13208
13292
  let child;
13209
13293
  try {
13210
- child = spawn2(cmd, {
13211
- shell: true,
13212
- cwd: opts.cwd,
13213
- windowsHide: true,
13214
- ...process.platform !== "win32" ? { detached: true } : {}
13215
- });
13294
+ child = shellSpawn(spawn2, cmd, { cwd: opts.cwd });
13216
13295
  } catch (err) {
13217
13296
  resolveRun({ code: null, output: "", timedOut: false, spawnError: String(err) });
13218
13297
  return;
@@ -13281,7 +13360,7 @@ function runContainsItem(item, index, worktree) {
13281
13360
  if (!insideWorktree(worktree, item.file)) {
13282
13361
  return { index, kind: "contains", label, ok: false, detail: "path escapes the workspace boundary", durationMs: Date.now() - started };
13283
13362
  }
13284
- const abs = join(worktree, item.file);
13363
+ const abs = join2(worktree, item.file);
13285
13364
  let content;
13286
13365
  try {
13287
13366
  content = readFileSync(abs, "utf8");
@@ -13570,8 +13649,8 @@ function newJobId(now = Date.now) {
13570
13649
 
13571
13650
  // src/job-runner.ts
13572
13651
  import { spawn as spawn3 } from "node:child_process";
13573
- import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readSync, readFileSync as readFileSync2, statSync, unlinkSync } from "node:fs";
13574
- import { join as join2 } from "node:path";
13652
+ import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readSync, readFileSync as readFileSync2, statSync as statSync2, unlinkSync } from "node:fs";
13653
+ import { join as join3 } from "node:path";
13575
13654
  import { tmpdir } from "node:os";
13576
13655
  var DEFAULT_IDLE_MS = 60000;
13577
13656
  var DEFAULT_MAX_WAIT_MS = 120000;
@@ -13582,17 +13661,17 @@ var JOB_ENV_MARKER = "FORGE_JOB_ID";
13582
13661
  var TAIL_POLL_MS = 200;
13583
13662
  var ADOPT_LIVENESS_MS = 1000;
13584
13663
  function jobsLogDir(base) {
13585
- return join2(base ?? join2(tmpdir(), "opencode-forge"), "jobs");
13664
+ return join3(base ?? join3(tmpdir(), "opencode-forge"), "jobs");
13586
13665
  }
13587
13666
  var TAIL_CHUNK_BYTES = 4 * 1024 * 1024;
13588
13667
  function createFileTail(logPath, onText, pollMs = TAIL_POLL_MS) {
13589
- let pos = existsSync(logPath) ? statSync(logPath).size : 0;
13668
+ let pos = existsSync(logPath) ? statSync2(logPath).size : 0;
13590
13669
  let stopped = false;
13591
13670
  const flush = () => {
13592
13671
  if (stopped)
13593
13672
  return;
13594
13673
  try {
13595
- let size = existsSync(logPath) ? statSync(logPath).size : 0;
13674
+ let size = existsSync(logPath) ? statSync2(logPath).size : 0;
13596
13675
  if (size < pos)
13597
13676
  pos = size;
13598
13677
  const fd = openSync(logPath, "r");
@@ -13611,7 +13690,7 @@ function createFileTail(logPath, onText, pollMs = TAIL_POLL_MS) {
13611
13690
  break;
13612
13691
  pos += read;
13613
13692
  onText(buf.toString("utf8", 0, read));
13614
- size = existsSync(logPath) ? statSync(logPath).size : size;
13693
+ size = existsSync(logPath) ? statSync2(logPath).size : size;
13615
13694
  if (stopped)
13616
13695
  break;
13617
13696
  }
@@ -13639,7 +13718,7 @@ function startJob(manager, opts) {
13639
13718
  const maxWaitMs = Math.min(Math.max(0, opts.maxWaitMs ?? DEFAULT_MAX_WAIT_MS), HARD_MAX_WAIT_MS);
13640
13719
  mkdirSync(opts.logDir, { recursive: true });
13641
13720
  const id = newJobId();
13642
- const logPath = join2(opts.logDir, `${id}.log`);
13721
+ const logPath = join3(opts.logDir, `${id}.log`);
13643
13722
  let child;
13644
13723
  let settled = false;
13645
13724
  let exiting = false;
@@ -13695,6 +13774,7 @@ function startJob(manager, opts) {
13695
13774
  try {
13696
13775
  wfd = openSync(logPath, "a");
13697
13776
  child = shellSpawn(opts.spawnFn ?? spawn3, opts.cmd, {
13777
+ shell: opts.shell,
13698
13778
  cwd: opts.cwd,
13699
13779
  env: { ...opts.env ?? {}, [JOB_ENV_MARKER]: id },
13700
13780
  stdio: ["ignore", wfd, wfd]
@@ -13872,7 +13952,7 @@ function readJobLog(logPath, opts = {}) {
13872
13952
  let windowed = false;
13873
13953
  try {
13874
13954
  if (existsSync(logPath)) {
13875
- const size = statSync(logPath).size;
13955
+ const size = statSync2(logPath).size;
13876
13956
  if (size > LOG_READ_MAX_BYTES) {
13877
13957
  const fd = openSync(logPath, "r");
13878
13958
  try {
@@ -13910,7 +13990,7 @@ function rotateLogs(logDir, keep = DEFAULT_LOG_KEEP) {
13910
13990
  try {
13911
13991
  entries = readdirSync(logDir).filter((f) => f.endsWith(".log")).map((name) => {
13912
13992
  try {
13913
- return { name, mtime: statSync(join2(logDir, name)).mtimeMs };
13993
+ return { name, mtime: statSync2(join3(logDir, name)).mtimeMs };
13914
13994
  } catch {
13915
13995
  return { name, mtime: 0 };
13916
13996
  }
@@ -13924,7 +14004,7 @@ function rotateLogs(logDir, keep = DEFAULT_LOG_KEEP) {
13924
14004
  entries.sort((a, b) => a.mtime - b.mtime);
13925
14005
  for (const e of entries.slice(0, excess)) {
13926
14006
  try {
13927
- unlinkSync(join2(logDir, e.name));
14007
+ unlinkSync(join3(logDir, e.name));
13928
14008
  } catch {}
13929
14009
  }
13930
14010
  }
@@ -14144,9 +14224,9 @@ function createJobFence(opts = {}) {
14144
14224
  }
14145
14225
 
14146
14226
  // src/job-registry.ts
14147
- import { closeSync as closeSync2, existsSync as existsSync2, mkdirSync as mkdirSync2, openSync as openSync2, readFileSync as readFileSync3, readdirSync as readdirSync2, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
14227
+ import { closeSync as closeSync2, existsSync as existsSync2, mkdirSync as mkdirSync2, openSync as openSync2, readFileSync as readFileSync3, readdirSync as readdirSync2, statSync as statSync3, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
14148
14228
  import { execFileSync, spawn as spawn5 } from "node:child_process";
14149
- import { dirname, join as join3 } from "node:path";
14229
+ import { dirname as dirname2, join as join4 } from "node:path";
14150
14230
  var REGISTRY_KEEP = 100;
14151
14231
  function structuralRelocate(deadPid, platform = process.platform) {
14152
14232
  if (deadPid <= 0)
@@ -14226,7 +14306,7 @@ function acquireLock(lockPath, timeoutMs = 2000) {
14226
14306
  if (err.code !== "EEXIST")
14227
14307
  throw err;
14228
14308
  try {
14229
- if (Date.now() - statSync2(lockPath).mtimeMs > 5000) {
14309
+ if (Date.now() - statSync3(lockPath).mtimeMs > 5000) {
14230
14310
  unlinkSync2(lockPath);
14231
14311
  continue;
14232
14312
  }
@@ -14251,7 +14331,7 @@ function readRaw(path) {
14251
14331
  }
14252
14332
  }
14253
14333
  function writeRaw(path, entries) {
14254
- mkdirSync2(dirname(path), { recursive: true });
14334
+ mkdirSync2(dirname2(path), { recursive: true });
14255
14335
  writeFileSync2(path, `${JSON.stringify({ version: 1, entries: entries.slice(0, REGISTRY_KEEP) }, null, 2)}
14256
14336
  `);
14257
14337
  }
@@ -14307,7 +14387,7 @@ function createJobRegistry(registryPath) {
14307
14387
  };
14308
14388
  }
14309
14389
  function registryPathFor(jobsDir) {
14310
- return join3(jobsDir, "registry.json");
14390
+ return join4(jobsDir, "registry.json");
14311
14391
  }
14312
14392
 
14313
14393
  // src/host-exit.ts
@@ -14389,7 +14469,7 @@ function createExitCleanup(manager, opts = {}) {
14389
14469
 
14390
14470
  // src/watchdog.ts
14391
14471
  import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
14392
- import { dirname as dirname2 } from "node:path";
14472
+ import { dirname as dirname3 } from "node:path";
14393
14473
  var WATCHDOG_ENV_MARK = "FORGE_WATCHDOG_MARK";
14394
14474
  var DEFAULT_STALL_MS = 600000;
14395
14475
  var MIN_STALL_MS = 60000;
@@ -14607,7 +14687,7 @@ function watchdogLogDir(base) {
14607
14687
  function createFileLedger(logPath, maxEntries = 200, maxBytes = 1e6) {
14608
14688
  const append = (entry) => {
14609
14689
  try {
14610
- mkdirSync3(dirname2(logPath), { recursive: true });
14690
+ mkdirSync3(dirname3(logPath), { recursive: true });
14611
14691
  let lines = [];
14612
14692
  try {
14613
14693
  lines = readFileSync4(logPath, "utf8").split(`
@@ -14760,7 +14840,7 @@ function createLocator(platform = process.platform, posix = {}, win = {}) {
14760
14840
 
14761
14841
  // src/models-dev.ts
14762
14842
  import { mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "node:fs";
14763
- import { join as join4 } from "node:path";
14843
+ import { join as join5 } from "node:path";
14764
14844
  import { tmpdir as tmpdir2 } from "node:os";
14765
14845
  var DEFAULT_MODELS_DEV_URL = "https://models.dev/api.json";
14766
14846
  function parseIdentity(identity) {
@@ -14782,7 +14862,7 @@ function nativeLadder(catalog, identity) {
14782
14862
  return Array.isArray(options) ? [...options] : null;
14783
14863
  }
14784
14864
  function modelsDevCacheDir(base) {
14785
- return join4(base ?? join4(tmpdir2(), "opencode-forge"), "models-dev");
14865
+ return join5(base ?? join5(tmpdir2(), "opencode-forge"), "models-dev");
14786
14866
  }
14787
14867
  function namedLevels(rows) {
14788
14868
  const out = [];
@@ -14824,7 +14904,7 @@ function reduceModelsDev(raw) {
14824
14904
  async function loadModelsDevSnapshot(opts = {}) {
14825
14905
  const url2 = opts.url ?? process.env.OPENCODE_MODELS_URL ?? DEFAULT_MODELS_DEV_URL;
14826
14906
  const cacheDir = modelsDevCacheDir(opts.cacheDir);
14827
- const cacheFile = join4(cacheDir, "snapshot.json");
14907
+ const cacheFile = join5(cacheDir, "snapshot.json");
14828
14908
  const fetcher = opts.fetcher;
14829
14909
  if (fetcher) {
14830
14910
  try {
@@ -15024,7 +15104,7 @@ function resolvePinnedDepth(thoughtLevel, family, ladder) {
15024
15104
  }
15025
15105
 
15026
15106
  // src/forge-config.ts
15027
- import { readFileSync as readFileSync7, statSync as statSync3 } from "node:fs";
15107
+ import { readFileSync as readFileSync7, statSync as statSync4 } from "node:fs";
15028
15108
  function sanitizeJsonc(text) {
15029
15109
  const out = [];
15030
15110
  let i = 0;
@@ -15349,17 +15429,35 @@ function forgeConfigPaths(opts) {
15349
15429
  const global = opts.homeDir ? `${opts.homeDir}${sep2}.config${sep2}opencode${sep2}forge.json` : "";
15350
15430
  return { project, global };
15351
15431
  }
15432
+ function isFsRoot(p) {
15433
+ return p === "" || p === "/" || p === "\\" || /^[A-Za-z]:[\\/]?$/.test(p);
15434
+ }
15435
+ function parentDirOf(p) {
15436
+ let s = p;
15437
+ while (s.endsWith("/") || s.endsWith("\\")) {
15438
+ if (isFsRoot(s))
15439
+ return null;
15440
+ s = s.slice(0, -1);
15441
+ if (isFsRoot(s))
15442
+ return null;
15443
+ }
15444
+ const i = Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\"));
15445
+ if (i < 0)
15446
+ return null;
15447
+ const parent = s.slice(0, i + 1);
15448
+ return parent === "" ? null : parent;
15449
+ }
15352
15450
  function createForgeConfigLoader(deps) {
15353
15451
  const stat = deps.stat ?? ((path) => {
15354
15452
  try {
15355
- const s = statSync3(path);
15453
+ const s = statSync4(path);
15356
15454
  return { mtimeMs: s.mtimeMs, size: s.size };
15357
15455
  } catch {
15358
15456
  return null;
15359
15457
  }
15360
15458
  });
15361
15459
  const readFile = deps.readFile ?? ((path) => readFileSync7(path, "utf8"));
15362
- const paths = forgeConfigPaths({ projectDir: deps.projectDir, homeDir: deps.homeDir });
15460
+ const globalPath = forgeConfigPaths({ homeDir: deps.homeDir }).global;
15363
15461
  const cache = new Map;
15364
15462
  const readCandidate = (path, source) => {
15365
15463
  const st = stat(path);
@@ -15378,11 +15476,12 @@ function createForgeConfigLoader(deps) {
15378
15476
  agents: {},
15379
15477
  source,
15380
15478
  path,
15479
+ projectPath: source === "project" ? path : null,
15381
15480
  findings: [
15382
15481
  {
15383
15482
  level: "error",
15384
15483
  code: "config-parse-error",
15385
- message: `${path}: ${parsed.error.message}${parsed.error.line !== undefined ? ` (line ${parsed.error.line}, column ${parsed.error.column})` : ""} — the agent set is empty until the file parses`
15484
+ message: `${path}: ${parsed.error.message}${parsed.error.line !== undefined ? ` (line ${parsed.error.line}, column ${parsed.error.column})` : ""} — this layer contributes no agents until the file parses`
15386
15485
  }
15387
15486
  ]
15388
15487
  };
@@ -15393,25 +15492,55 @@ function createForgeConfigLoader(deps) {
15393
15492
  agents: {},
15394
15493
  source,
15395
15494
  path,
15396
- findings: [{ level: "error", code: "config-not-object", message: `${path}: top level must be an object — the agent set is empty` }]
15495
+ projectPath: source === "project" ? path : null,
15496
+ findings: [{ level: "error", code: "config-not-object", message: `${path}: top level must be an object — this layer contributes no agents` }]
15397
15497
  };
15398
15498
  } else {
15399
15499
  const validated = validateAgentSet(top === undefined ? undefined : top.agents);
15400
- result = { agents: validated.agents, source, path, findings: validated.findings };
15500
+ result = { agents: validated.agents, source, path, projectPath: source === "project" ? path : null, findings: validated.findings };
15401
15501
  }
15402
15502
  }
15403
15503
  cache.set(path, { mtimeMs: st.mtimeMs, size: st.size, result });
15404
15504
  return result;
15405
15505
  };
15506
+ const candidateAt = (dir) => {
15507
+ const sep2 = dir.includes("\\") && !dir.includes("/") ? "\\" : "/";
15508
+ const base = dir.endsWith("/") || dir.endsWith("\\") ? dir : `${dir}${sep2}`;
15509
+ return `${base}.opencode${sep2}forge.json`;
15510
+ };
15511
+ const discoverProjectFile = (anchor) => {
15512
+ let dir = (anchor ?? "").trim();
15513
+ if (!dir)
15514
+ return null;
15515
+ for (;; ) {
15516
+ const candidate = candidateAt(dir);
15517
+ if (stat(candidate) !== null)
15518
+ return candidate;
15519
+ const parent = parentDirOf(dir);
15520
+ if (parent === null || parent === dir)
15521
+ return null;
15522
+ dir = parent;
15523
+ }
15524
+ };
15406
15525
  return {
15407
15526
  load() {
15408
- const project = paths.project ? readCandidate(paths.project, "project") : null;
15527
+ const projectPath = discoverProjectFile(deps.projectDir ?? "");
15528
+ const project = projectPath ? readCandidate(projectPath, "project") : null;
15529
+ const global = globalPath ? readCandidate(globalPath, "global") : null;
15530
+ if (project && global) {
15531
+ return {
15532
+ agents: { ...global.agents, ...project.agents },
15533
+ source: "project+global",
15534
+ path: project.path,
15535
+ projectPath: project.path,
15536
+ findings: [...global.findings, ...project.findings]
15537
+ };
15538
+ }
15409
15539
  if (project)
15410
- return project;
15411
- const global = paths.global ? readCandidate(paths.global, "global") : null;
15540
+ return { agents: project.agents, source: "project", path: project.path, projectPath: project.path, findings: project.findings };
15412
15541
  if (global)
15413
- return global;
15414
- return { agents: {}, source: "none", path: null, findings: [] };
15542
+ return { agents: global.agents, source: "global", path: global.path, projectPath: null, findings: global.findings };
15543
+ return { agents: {}, source: "none", path: null, projectPath: null, findings: [] };
15415
15544
  }
15416
15545
  };
15417
15546
  }
@@ -15529,6 +15658,22 @@ function noteToolAgent(context) {
15529
15658
  var forgeDisabled = false;
15530
15659
  var forgeLoader = null;
15531
15660
  var forgeLoaderDir = null;
15661
+ var sessionAnchorLoaders = new Map;
15662
+ function loaderForAnchor(anchor) {
15663
+ const key = anchor || "-";
15664
+ let l = sessionAnchorLoaders.get(key);
15665
+ if (!l) {
15666
+ l = createForgeConfigLoader({ projectDir: anchor, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
15667
+ sessionAnchorLoaders.set(key, l);
15668
+ while (sessionAnchorLoaders.size > 8) {
15669
+ const oldest = sessionAnchorLoaders.keys().next().value;
15670
+ if (oldest === undefined)
15671
+ break;
15672
+ sessionAnchorLoaders.delete(oldest);
15673
+ }
15674
+ }
15675
+ return l;
15676
+ }
15532
15677
  var crews = new Map;
15533
15678
  var modelsDevCatalog = null;
15534
15679
  var depthFindingNotes = new Map;
@@ -15596,11 +15741,11 @@ function jobStage() {
15596
15741
  return 0;
15597
15742
  }
15598
15743
  var jobLogDir = jobsLogDir();
15599
- var jobLedgerPath = join5(jobLogDir, "ledger.jsonl");
15744
+ var jobLedgerPath = join6(jobLogDir, "ledger.jsonl");
15600
15745
  function jobLedgerSink(entry) {
15601
15746
  try {
15602
15747
  mkdirSync5(jobLogDir, { recursive: true });
15603
- if (existsSync3(jobLedgerPath) && statSync4(jobLedgerPath).size > 1e6)
15748
+ if (existsSync3(jobLedgerPath) && statSync5(jobLedgerPath).size > 1e6)
15604
15749
  writeFileSync5(jobLedgerPath, "");
15605
15750
  appendFileSync(jobLedgerPath, `${JSON.stringify(entry)}
15606
15751
  `);
@@ -15663,13 +15808,13 @@ function nowIso() {
15663
15808
  return new Date().toISOString();
15664
15809
  }
15665
15810
  function planDirOf(worktree) {
15666
- return join5(worktree, ".opencode", "plan");
15811
+ return join6(worktree, ".opencode", "plan");
15667
15812
  }
15668
15813
  function readPlanDir(worktree) {
15669
15814
  const dir = planDirOf(worktree);
15670
15815
  if (!existsSync3(dir))
15671
15816
  return [];
15672
- return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync8(join5(dir, name), "utf8") }));
15817
+ return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync8(join6(dir, name), "utf8") }));
15673
15818
  }
15674
15819
  function ensureSession(sessionID, worktree) {
15675
15820
  const existing = sessions.get(sessionID);
@@ -15681,9 +15826,15 @@ function ensureSession(sessionID, worktree) {
15681
15826
  sessions.set(sessionID, state);
15682
15827
  return state;
15683
15828
  }
15829
+ function sessionAnchor(context) {
15830
+ const viaDir = effectiveWorktree(context.worktree, context.directory);
15831
+ if (viaDir && !isRootish(viaDir))
15832
+ return viaDir;
15833
+ return hostWorktree || (context.worktree ?? "").trim() || (context.directory ?? "").trim();
15834
+ }
15684
15835
  function worktreeFor(context) {
15685
15836
  noteToolAgent(context);
15686
- return effectiveWorktree(context.worktree, hostWorktree) || context.worktree;
15837
+ return sessionAnchor(context) || context.worktree;
15687
15838
  }
15688
15839
  function resolveActivePlan(state) {
15689
15840
  if (state.planPath && existsSync3(state.planPath)) {
@@ -15695,7 +15846,7 @@ function resolveActivePlan(state) {
15695
15846
  const ranked = rankActivePlans(readPlanDir(state.worktree));
15696
15847
  if (ranked.length === 0)
15697
15848
  return null;
15698
- const path = join5(planDirOf(state.worktree), ranked[0].name);
15849
+ const path = join6(planDirOf(state.worktree), ranked[0].name);
15699
15850
  state.planPath = path;
15700
15851
  return { path, doc: ranked[0].doc };
15701
15852
  }
@@ -15730,7 +15881,7 @@ var planWriteTool = tool({
15730
15881
  } else {
15731
15882
  const dir = planDirOf(state.worktree);
15732
15883
  mkdirSync5(dir, { recursive: true });
15733
- path = join5(dir, planFileName(localDate(), slugify(args.goal), readdirSync4(dir).filter((f) => f.endsWith(".md"))));
15884
+ path = join6(dir, planFileName(localDate(), slugify(args.goal), readdirSync4(dir).filter((f) => f.endsWith(".md"))));
15734
15885
  mode = "created";
15735
15886
  }
15736
15887
  const text = renderPlan(args, now, created);
@@ -15848,13 +15999,13 @@ var planDiscardTool = tool({
15848
15999
  }
15849
16000
  });
15850
16001
  function goalDirOf(worktree) {
15851
- return join5(worktree, ".opencode", "goal");
16002
+ return join6(worktree, ".opencode", "goal");
15852
16003
  }
15853
16004
  function readGoalDir(worktree) {
15854
16005
  const dir = goalDirOf(worktree);
15855
16006
  if (!existsSync3(dir))
15856
16007
  return [];
15857
- return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync8(join5(dir, name), "utf8") }));
16008
+ return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync8(join6(dir, name), "utf8") }));
15858
16009
  }
15859
16010
  function resolveSessionGoal(state) {
15860
16011
  if (state.goalPath && existsSync3(state.goalPath)) {
@@ -15865,7 +16016,7 @@ function resolveSessionGoal(state) {
15865
16016
  }
15866
16017
  const live = rankLiveGoals(readGoalDir(state.worktree))[0];
15867
16018
  if (live) {
15868
- const path = join5(goalDirOf(state.worktree), live.name);
16019
+ const path = join6(goalDirOf(state.worktree), live.name);
15869
16020
  state.goalPath = path;
15870
16021
  return { path, doc: live.doc };
15871
16022
  }
@@ -15964,7 +16115,7 @@ var goalWriteTool = tool({
15964
16115
  const dir = goalDirOf(state.worktree);
15965
16116
  mkdirSync5(dir, { recursive: true });
15966
16117
  const name = goalFileName(localDateNow(), slugifyGoal(args.goal), readdirSync4(dir).filter((f) => f.endsWith(".md")));
15967
- const path = join5(dir, name);
16118
+ const path = join6(dir, name);
15968
16119
  const text = renderGoal(input, {
15969
16120
  now,
15970
16121
  status: arm ? "active" : "queued",
@@ -16093,7 +16244,7 @@ var goalResumeTool = tool({
16093
16244
  if (!goal || goal.doc.status === "queued") {
16094
16245
  const oldest = rankQueuedGoals(readGoalDir(state.worktree))[0];
16095
16246
  if (oldest) {
16096
- const path = join5(goalDirOf(state.worktree), oldest.name);
16247
+ const path = join6(goalDirOf(state.worktree), oldest.name);
16097
16248
  state.goalPath = path;
16098
16249
  goal = { path, doc: oldest.doc };
16099
16250
  }
@@ -16143,7 +16294,8 @@ var FORGE_SHELL_DESCRIPTION = [
16143
16294
  "Run a shell command without ever blocking the session indefinitely. The call completes on the FIRST of: process exit (bound to the exit event — a detached grandchild holding the stdio pipes cannot suspend the call); success_pattern matching new output (opt-in regex); idle_ms with no new output (default 60000); max_wait_ms hard cap (default 120000, max 600000 — returns still-running, never kills).",
16144
16295
  "Idle/max-wait return `still-running` with a jobId — the process stays alive; keep watching with forge_jobs poll / log, stop it with forge_jobs kill. run_in_background returns {jobId, logPath} immediately.",
16145
16296
  "success_pattern semantics: a match completes the call as success; the process is kept alive by default (server semantics — the thing you just verified keeps running); pass keep_alive=false to kill its tree on match. Common patterns: dev servers `listening on|ready in|Local:`, builds `Compiled successfully|Done in`, test suites `passed|all tests`.",
16146
- "This tool is the forge agent's exec surface for every shell command — quick ones included. Long-running or possibly non-exiting commands (dev servers, watchers, installers, anything spawning detached children) should start with run_in_background and collect results via forge_jobs."
16297
+ "This tool is the forge agent's exec surface for every shell command — quick ones included. Long-running or possibly non-exiting commands (dev servers, watchers, installers, anything spawning detached children) should start with run_in_background and collect results via forge_jobs.",
16298
+ "Interpreter: commands run under the host-preferred shell — the PowerShell family on Windows (git-bash only on machines without PowerShell), the login shell with bash preferred on POSIX — the same interpreter the host's builtin shell tool uses."
16147
16299
  ].join(`
16148
16300
  `);
16149
16301
  var forgeShellTool = tool({
@@ -16175,7 +16327,7 @@ var forgeShellTool = tool({
16175
16327
  }
16176
16328
  }
16177
16329
  const { registry: registry2, fence } = ensureJobLifecycle();
16178
- const cwd = args.workdir ? isAbsolute(args.workdir) ? args.workdir : join5(state.worktree, args.workdir) : state.worktree;
16330
+ const cwd = args.workdir ? isAbsolute(args.workdir) ? args.workdir : join6(state.worktree, args.workdir) : state.worktree;
16179
16331
  const started = startJob(jobManager, {
16180
16332
  cmd: args.command,
16181
16333
  cwd,
@@ -16336,7 +16488,7 @@ var CREW_INIT_TEMPLATE = [
16336
16488
  "",
16337
16489
  "CREW IS NOT INITIALIZED: no forge subagents are configured (no usable forge.json agents). Refuse to enter orchestration and tell the user this feature needs a one-time setup, offering BOTH options:",
16338
16490
  "",
16339
- "1. The user configures it themselves: create ONE of these files (project-level wins, never merged):",
16491
+ "1. The user configures it themselves: create either layer — MERGED, the project layer overrides the global one per agent id (the project file is the nearest .opencode/forge.json walking up from the workspace root):",
16340
16492
  "{paths}",
16341
16493
  "",
16342
16494
  "2. Or, ONLY if the user explicitly asks you to configure it in this conversation, you may write the file yourself through the normal write path. NEVER write forge.json without that explicit go-ahead.",
@@ -16382,15 +16534,24 @@ var crewBeginTool = tool({
16382
16534
  if (active && active.doc.status === "draft") {
16383
16535
  throw new Error("[forge:crew] A plan is in draft; crew orchestration is denied during planning. Call plan_approve for user approval, or /plan discard to abandon the plan.");
16384
16536
  }
16385
- const loaded = forgeLoader?.load();
16386
- if (!loaded || Object.keys(loaded.agents).length === 0) {
16387
- const paths = forgeConfigPaths({ projectDir: forgeLoaderDir ?? undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
16388
- throw new Error([
16389
- "[forge:crew] CREW IS NOT INITIALIZED: no forge subagents are configured. Crew orchestration is unavailable until the user sets it up.",
16390
- `Create ONE of these files (project-level wins): ${paths.project} OR ${paths.global}`,
16537
+ const hostLoaded = forgeLoader?.load();
16538
+ const hostIds = hostLoaded ? Object.keys(hostLoaded.agents) : [];
16539
+ const anchor = sessionAnchor(context);
16540
+ const hostAnchorDir = forgeLoaderDir ?? "";
16541
+ const sessionLoaded = anchor && anchor !== hostAnchorDir ? loaderForAnchor(anchor).load() : hostLoaded;
16542
+ const sessionProject = sessionLoaded?.projectPath ?? null;
16543
+ if (hostIds.length === 0) {
16544
+ const paths = forgeConfigPaths({ projectDir: hostAnchorDir || undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
16545
+ const lines = [
16546
+ "[forge:crew] CREW IS NOT INITIALIZED on this host: no forge subagents are materialized. Crew orchestration is unavailable until the user sets it up.",
16547
+ `Two layers, MERGED (the project layer overrides the global one per agent id): ${paths.project} (nearest .opencode/forge.json walking up from the workspace) OR ${paths.global}.`,
16391
16548
  'Template (JSONC): {"agents": {"research": {"model": "provider/model", "thoughtLevel": "low"}}}',
16392
16549
  "The user may configure it themselves, or ask you to write the file — only on their explicit go-ahead. Newly added agents need a host restart; thoughtLevel edits apply without one."
16393
- ].join(`
16550
+ ];
16551
+ if (sessionLoaded && Object.keys(sessionLoaded.agents).length > 0 && sessionProject) {
16552
+ lines.push(`NOTE: this session's workspace DOES have a forge.json (${sessionProject}) with usable agents — but this host instance was launched from ${hostAnchorDir || "a different directory"}, so they are not in the task vocabulary. Relaunch opencode in that workspace, or add the agents to the global layer.`);
16553
+ }
16554
+ throw new Error(lines.join(`
16394
16555
  `));
16395
16556
  }
16396
16557
  if (!args.objective?.trim())
@@ -16416,13 +16577,22 @@ var crewBeginTool = tool({
16416
16577
  throw new Error(`[forge:crew] This session already has an active crew: "${existing.objective}" (started ${existing.startedAt}). Finish and close it with crew_close before starting another.`);
16417
16578
  }
16418
16579
  crews.set(context.sessionID, { objective: args.objective.trim(), startedAt: nowIso(), subtasks });
16580
+ let disclosure = "";
16581
+ if (sessionProject && sessionProject !== (hostLoaded?.projectPath ?? null)) {
16582
+ const localOnly = Object.keys(sessionLoaded?.agents ?? {}).filter((id) => !hostIds.includes(id));
16583
+ if (localOnly.length > 0) {
16584
+ disclosure = `
16585
+ [forge:crew] NOTE: this session's workspace has its own forge.json (${sessionProject}) defining ${localOnly.map((id) => `forge-${id}`).join(", ")} — NOT dispatchable on this host (launched from ${hostAnchorDir || "a different directory"}). Relaunch opencode in that workspace to use them, or fold them into the global layer.`;
16586
+ }
16587
+ }
16419
16588
  return {
16420
16589
  title: `crew registered: ${args.objective.trim().slice(0, 60)}`,
16421
16590
  output: [
16422
16591
  `[forge:crew] Crew registered for this session. Objective: ${args.objective.trim()}`,
16423
16592
  `Declared plan (${subtasks.length}):`,
16424
16593
  ...subtasks.map((s, i) => ` ${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`),
16425
- "Proceed with the discipline: waves of parallel native task calls (next wave after the previous results) → per-subtask evidence verdicts → at most one retry → crew_close with the full report covering every declared subtask."
16594
+ "Proceed with the discipline: waves of parallel native task calls (next wave after the previous results) → per-subtask evidence verdicts → at most one retry → crew_close with the full report covering every declared subtask.",
16595
+ ...disclosure ? [disclosure] : []
16426
16596
  ].join(`
16427
16597
  `)
16428
16598
  };
@@ -16511,7 +16681,7 @@ var pendingContinuationTurn = new Set;
16511
16681
  function goalProbe(line) {
16512
16682
  if (process.env.FORGE_GOAL_PROBE) {
16513
16683
  try {
16514
- appendFileSync(join5(tmpdir3(), "forge-goal-probe.log"), `${new Date().toISOString()} ${line}
16684
+ appendFileSync(join6(tmpdir3(), "forge-goal-probe.log"), `${new Date().toISOString()} ${line}
16515
16685
  `);
16516
16686
  } catch {}
16517
16687
  }
@@ -16726,14 +16896,14 @@ var server = async (input, options) => {
16726
16896
  if (wdStallRaw !== undefined && wdStallRaw !== wdStall) {
16727
16897
  wdFallbacks.push(`watchdog.stallMs ${JSON.stringify(String(wdStallRaw))} adjusted to ${wdStall} (floor/default applied)`);
16728
16898
  }
16729
- const watchdogLedger = createFileLedger(join5(watchdogLogDir(), "log.jsonl"));
16899
+ const watchdogLedger = createFileLedger(join6(watchdogLogDir(), "log.jsonl"));
16730
16900
  const rawLocator = createLocator();
16731
16901
  const probing = () => process.env.FORGE_WATCHDOG_PROBE === "1";
16732
16902
  const probeLine = (text) => {
16733
16903
  if (!probing())
16734
16904
  return;
16735
16905
  try {
16736
- appendFileSync(join5(tmpdir3(), "forge-watchdog-probe.log"), `${new Date().toISOString()} ${text}
16906
+ appendFileSync(join6(tmpdir3(), "forge-watchdog-probe.log"), `${new Date().toISOString()} ${text}
16737
16907
  `);
16738
16908
  } catch {}
16739
16909
  };
@@ -16789,6 +16959,7 @@ var server = async (input, options) => {
16789
16959
  forgeDisabled = forgeUserCfg?.disable === true;
16790
16960
  if (forgeDisabled)
16791
16961
  return;
16962
+ setHostShell(typeof cfg.shell === "string" ? cfg.shell : undefined);
16792
16963
  cfg.default_agent ??= FORGE_AGENT;
16793
16964
  const existing = agentSection[FORGE_AGENT];
16794
16965
  agentSection[FORGE_AGENT] = {
@@ -16893,7 +17064,7 @@ var server = async (input, options) => {
16893
17064
  "tool.execute.before": async (input2, output) => {
16894
17065
  if (process.env.FORGE_PERM_PROBE) {
16895
17066
  try {
16896
- appendFileSync(join5(tmpdir3(), "forge-perm-probe.log"), `${new Date().toISOString()} before tool=${JSON.stringify(input2.tool)} session=${input2.sessionID}
17067
+ appendFileSync(join6(tmpdir3(), "forge-perm-probe.log"), `${new Date().toISOString()} before tool=${JSON.stringify(input2.tool)} session=${input2.sessionID}
16897
17068
  `);
16898
17069
  } catch {}
16899
17070
  }
@@ -16903,7 +17074,7 @@ var server = async (input, options) => {
16903
17074
  watchdog.track(input2.callID, input2.sessionID, input2.tool, undefined, cmdText !== undefined ? commandNeedle(cmdText) : undefined);
16904
17075
  if (process.env.FORGE_WATCHDOG_PROBE) {
16905
17076
  try {
16906
- appendFileSync(join5(tmpdir3(), "forge-watchdog-probe.log"), `${new Date().toISOString()} track ${input2.callID} needle=${JSON.stringify(cmdText !== undefined ? commandNeedle(cmdText) : undefined)} rawArgs=${JSON.stringify(output?.args ?? null).slice(0, 200)}
17077
+ appendFileSync(join6(tmpdir3(), "forge-watchdog-probe.log"), `${new Date().toISOString()} track ${input2.callID} needle=${JSON.stringify(cmdText !== undefined ? commandNeedle(cmdText) : undefined)} rawArgs=${JSON.stringify(output?.args ?? null).slice(0, 200)}
16907
17078
  `);
16908
17079
  } catch {}
16909
17080
  }
@@ -16926,7 +17097,7 @@ var server = async (input, options) => {
16926
17097
  if (raw) {
16927
17098
  const state = stateForBan(input2.sessionID);
16928
17099
  const base = state?.worktree ?? hostWorktree ?? "";
16929
- const abs = (isAbsolute(raw) ? raw : join5(base, raw)).replaceAll("\\", "/").toLowerCase();
17100
+ const abs = (isAbsolute(raw) ? raw : join6(base, raw)).replaceAll("\\", "/").toLowerCase();
16930
17101
  if (abs.includes("/.opencode/plan/") || abs.includes("/.opencode/goal/")) {
16931
17102
  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.`);
16932
17103
  }
@@ -17017,7 +17188,7 @@ var server = async (input, options) => {
17017
17188
  const name = (typeof meta.tool === "string" ? meta.tool : undefined) ?? (typeof permissionField === "string" ? permissionField : undefined) ?? input2.id ?? input2.type;
17018
17189
  if (process.env.FORGE_PERM_PROBE) {
17019
17190
  try {
17020
- appendFileSync(join5(tmpdir3(), "forge-perm-probe.log"), `${new Date().toISOString()} ask name=${JSON.stringify(name)} in_status=${output.status} id=${JSON.stringify(input2.id)} type=${JSON.stringify(input2.type)} meta=${JSON.stringify(input2.metadata)}
17191
+ appendFileSync(join6(tmpdir3(), "forge-perm-probe.log"), `${new Date().toISOString()} ask name=${JSON.stringify(name)} in_status=${output.status} id=${JSON.stringify(input2.id)} type=${JSON.stringify(input2.type)} meta=${JSON.stringify(input2.metadata)}
17021
17192
  `);
17022
17193
  } catch {}
17023
17194
  }
@@ -17071,7 +17242,7 @@ var server = async (input, options) => {
17071
17242
  watchdog.markSeen(input2.callID);
17072
17243
  if (process.env.FORGE_WATCHDOG_PROBE) {
17073
17244
  try {
17074
- appendFileSync(join5(tmpdir3(), "forge-watchdog-probe.log"), `${new Date().toISOString()} mark ${input2.callID} hostpid=${process.pid}
17245
+ appendFileSync(join6(tmpdir3(), "forge-watchdog-probe.log"), `${new Date().toISOString()} mark ${input2.callID} hostpid=${process.pid}
17075
17246
  `);
17076
17247
  } catch {}
17077
17248
  }
@@ -17081,7 +17252,7 @@ var server = async (input, options) => {
17081
17252
  watchdog.untrack(input2.callID);
17082
17253
  if (process.env.FORGE_WATCHDOG_PROBE) {
17083
17254
  try {
17084
- appendFileSync(join5(tmpdir3(), "forge-watchdog-probe.log"), `${new Date().toISOString()} untrack ${input2.callID}
17255
+ appendFileSync(join6(tmpdir3(), "forge-watchdog-probe.log"), `${new Date().toISOString()} untrack ${input2.callID}
17085
17256
  `);
17086
17257
  } catch {}
17087
17258
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sorenllm/opencode-forge",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
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
5
  "type": "module",
6
6
  "main": "./dist/index.js",