@sorenllm/opencode-forge 0.9.1 → 0.10.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 +43 -0
  2. package/dist/index.js +140 -58
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -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) /
@@ -605,6 +622,32 @@ supervisor chapter above). The hide is unconditional: it covers a
605
622
  `jobs.keepBuiltinShell: true` (builtin shell back on the forge family) and
606
623
  `jobs.mode: "native"` (supervisor retired entirely).
607
624
 
625
+ ### The exec channel is final for this plugin-API generation (2026-09-28)
626
+
627
+ The four layers — tools-map hide, prompt mandate, belt refusal, `forge_shell`
628
+ itself — are the ceiling of what the current opencode plugin API can express,
629
+ and the design is closed deliberately:
630
+
631
+ - **Not expressible**: `tool.execute.before` can only mutate `args` or throw.
632
+ There is no tool-name rewrite, no executor replacement, no result injection —
633
+ "transparently reroute a builtin bash call through the job runner" cannot be
634
+ written. Registering a plugin tool named `bash` collides globally: tool
635
+ registration is not per-agent, and the per-agent mechanism is exactly the
636
+ tools map some hosts ignore.
637
+ - **Prior art ships a subset**: OMO (oh-my-opencode-slim / oh-my-openagent,
638
+ the largest opencode plugin ecosystem) pairs a permission-deny boundary with
639
+ a prompt channel mandate and does not hide the builtin shell at plugin level
640
+ either — their models also see bash and pay one denial round.
641
+ - **The residual is bounded and accepted**: on hosts where the hide does not
642
+ hold, a first bash attempt costs one round-trip per session — the belt
643
+ refuses pre-execution (~2 ms, the command never runs), the message names
644
+ `forge_shell`, and models re-issue once and stay on-channel. Field data
645
+ (2026-09-28): zero duplicate executions attributable to the belt.
646
+ - **Reopen on upstream movement**: hosts honoring injected tools maps (the
647
+ belt becomes pure insurance), a tool-execute replacement hook (transparent
648
+ rerouting becomes expressible), or exit-based builtin-shell completion (the
649
+ watchdog retirement path above applies).
650
+
608
651
  A process restart forgets the session binding: the write-ban soft-disables
609
652
  (safety over strictness) and the next session's system notice + `/plan
610
653
  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;
@@ -15352,7 +15432,7 @@ function forgeConfigPaths(opts) {
15352
15432
  function createForgeConfigLoader(deps) {
15353
15433
  const stat = deps.stat ?? ((path) => {
15354
15434
  try {
15355
- const s = statSync3(path);
15435
+ const s = statSync4(path);
15356
15436
  return { mtimeMs: s.mtimeMs, size: s.size };
15357
15437
  } catch {
15358
15438
  return null;
@@ -15596,11 +15676,11 @@ function jobStage() {
15596
15676
  return 0;
15597
15677
  }
15598
15678
  var jobLogDir = jobsLogDir();
15599
- var jobLedgerPath = join5(jobLogDir, "ledger.jsonl");
15679
+ var jobLedgerPath = join6(jobLogDir, "ledger.jsonl");
15600
15680
  function jobLedgerSink(entry) {
15601
15681
  try {
15602
15682
  mkdirSync5(jobLogDir, { recursive: true });
15603
- if (existsSync3(jobLedgerPath) && statSync4(jobLedgerPath).size > 1e6)
15683
+ if (existsSync3(jobLedgerPath) && statSync5(jobLedgerPath).size > 1e6)
15604
15684
  writeFileSync5(jobLedgerPath, "");
15605
15685
  appendFileSync(jobLedgerPath, `${JSON.stringify(entry)}
15606
15686
  `);
@@ -15663,13 +15743,13 @@ function nowIso() {
15663
15743
  return new Date().toISOString();
15664
15744
  }
15665
15745
  function planDirOf(worktree) {
15666
- return join5(worktree, ".opencode", "plan");
15746
+ return join6(worktree, ".opencode", "plan");
15667
15747
  }
15668
15748
  function readPlanDir(worktree) {
15669
15749
  const dir = planDirOf(worktree);
15670
15750
  if (!existsSync3(dir))
15671
15751
  return [];
15672
- return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync8(join5(dir, name), "utf8") }));
15752
+ return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync8(join6(dir, name), "utf8") }));
15673
15753
  }
15674
15754
  function ensureSession(sessionID, worktree) {
15675
15755
  const existing = sessions.get(sessionID);
@@ -15695,7 +15775,7 @@ function resolveActivePlan(state) {
15695
15775
  const ranked = rankActivePlans(readPlanDir(state.worktree));
15696
15776
  if (ranked.length === 0)
15697
15777
  return null;
15698
- const path = join5(planDirOf(state.worktree), ranked[0].name);
15778
+ const path = join6(planDirOf(state.worktree), ranked[0].name);
15699
15779
  state.planPath = path;
15700
15780
  return { path, doc: ranked[0].doc };
15701
15781
  }
@@ -15730,7 +15810,7 @@ var planWriteTool = tool({
15730
15810
  } else {
15731
15811
  const dir = planDirOf(state.worktree);
15732
15812
  mkdirSync5(dir, { recursive: true });
15733
- path = join5(dir, planFileName(localDate(), slugify(args.goal), readdirSync4(dir).filter((f) => f.endsWith(".md"))));
15813
+ path = join6(dir, planFileName(localDate(), slugify(args.goal), readdirSync4(dir).filter((f) => f.endsWith(".md"))));
15734
15814
  mode = "created";
15735
15815
  }
15736
15816
  const text = renderPlan(args, now, created);
@@ -15848,13 +15928,13 @@ var planDiscardTool = tool({
15848
15928
  }
15849
15929
  });
15850
15930
  function goalDirOf(worktree) {
15851
- return join5(worktree, ".opencode", "goal");
15931
+ return join6(worktree, ".opencode", "goal");
15852
15932
  }
15853
15933
  function readGoalDir(worktree) {
15854
15934
  const dir = goalDirOf(worktree);
15855
15935
  if (!existsSync3(dir))
15856
15936
  return [];
15857
- return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync8(join5(dir, name), "utf8") }));
15937
+ return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync8(join6(dir, name), "utf8") }));
15858
15938
  }
15859
15939
  function resolveSessionGoal(state) {
15860
15940
  if (state.goalPath && existsSync3(state.goalPath)) {
@@ -15865,7 +15945,7 @@ function resolveSessionGoal(state) {
15865
15945
  }
15866
15946
  const live = rankLiveGoals(readGoalDir(state.worktree))[0];
15867
15947
  if (live) {
15868
- const path = join5(goalDirOf(state.worktree), live.name);
15948
+ const path = join6(goalDirOf(state.worktree), live.name);
15869
15949
  state.goalPath = path;
15870
15950
  return { path, doc: live.doc };
15871
15951
  }
@@ -15964,7 +16044,7 @@ var goalWriteTool = tool({
15964
16044
  const dir = goalDirOf(state.worktree);
15965
16045
  mkdirSync5(dir, { recursive: true });
15966
16046
  const name = goalFileName(localDateNow(), slugifyGoal(args.goal), readdirSync4(dir).filter((f) => f.endsWith(".md")));
15967
- const path = join5(dir, name);
16047
+ const path = join6(dir, name);
15968
16048
  const text = renderGoal(input, {
15969
16049
  now,
15970
16050
  status: arm ? "active" : "queued",
@@ -16093,7 +16173,7 @@ var goalResumeTool = tool({
16093
16173
  if (!goal || goal.doc.status === "queued") {
16094
16174
  const oldest = rankQueuedGoals(readGoalDir(state.worktree))[0];
16095
16175
  if (oldest) {
16096
- const path = join5(goalDirOf(state.worktree), oldest.name);
16176
+ const path = join6(goalDirOf(state.worktree), oldest.name);
16097
16177
  state.goalPath = path;
16098
16178
  goal = { path, doc: oldest.doc };
16099
16179
  }
@@ -16143,7 +16223,8 @@ var FORGE_SHELL_DESCRIPTION = [
16143
16223
  "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
16224
  "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
16225
  "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."
16226
+ "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.",
16227
+ "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
16228
  ].join(`
16148
16229
  `);
16149
16230
  var forgeShellTool = tool({
@@ -16175,7 +16256,7 @@ var forgeShellTool = tool({
16175
16256
  }
16176
16257
  }
16177
16258
  const { registry: registry2, fence } = ensureJobLifecycle();
16178
- const cwd = args.workdir ? isAbsolute(args.workdir) ? args.workdir : join5(state.worktree, args.workdir) : state.worktree;
16259
+ const cwd = args.workdir ? isAbsolute(args.workdir) ? args.workdir : join6(state.worktree, args.workdir) : state.worktree;
16179
16260
  const started = startJob(jobManager, {
16180
16261
  cmd: args.command,
16181
16262
  cwd,
@@ -16511,7 +16592,7 @@ var pendingContinuationTurn = new Set;
16511
16592
  function goalProbe(line) {
16512
16593
  if (process.env.FORGE_GOAL_PROBE) {
16513
16594
  try {
16514
- appendFileSync(join5(tmpdir3(), "forge-goal-probe.log"), `${new Date().toISOString()} ${line}
16595
+ appendFileSync(join6(tmpdir3(), "forge-goal-probe.log"), `${new Date().toISOString()} ${line}
16515
16596
  `);
16516
16597
  } catch {}
16517
16598
  }
@@ -16726,14 +16807,14 @@ var server = async (input, options) => {
16726
16807
  if (wdStallRaw !== undefined && wdStallRaw !== wdStall) {
16727
16808
  wdFallbacks.push(`watchdog.stallMs ${JSON.stringify(String(wdStallRaw))} adjusted to ${wdStall} (floor/default applied)`);
16728
16809
  }
16729
- const watchdogLedger = createFileLedger(join5(watchdogLogDir(), "log.jsonl"));
16810
+ const watchdogLedger = createFileLedger(join6(watchdogLogDir(), "log.jsonl"));
16730
16811
  const rawLocator = createLocator();
16731
16812
  const probing = () => process.env.FORGE_WATCHDOG_PROBE === "1";
16732
16813
  const probeLine = (text) => {
16733
16814
  if (!probing())
16734
16815
  return;
16735
16816
  try {
16736
- appendFileSync(join5(tmpdir3(), "forge-watchdog-probe.log"), `${new Date().toISOString()} ${text}
16817
+ appendFileSync(join6(tmpdir3(), "forge-watchdog-probe.log"), `${new Date().toISOString()} ${text}
16737
16818
  `);
16738
16819
  } catch {}
16739
16820
  };
@@ -16789,6 +16870,7 @@ var server = async (input, options) => {
16789
16870
  forgeDisabled = forgeUserCfg?.disable === true;
16790
16871
  if (forgeDisabled)
16791
16872
  return;
16873
+ setHostShell(typeof cfg.shell === "string" ? cfg.shell : undefined);
16792
16874
  cfg.default_agent ??= FORGE_AGENT;
16793
16875
  const existing = agentSection[FORGE_AGENT];
16794
16876
  agentSection[FORGE_AGENT] = {
@@ -16893,7 +16975,7 @@ var server = async (input, options) => {
16893
16975
  "tool.execute.before": async (input2, output) => {
16894
16976
  if (process.env.FORGE_PERM_PROBE) {
16895
16977
  try {
16896
- appendFileSync(join5(tmpdir3(), "forge-perm-probe.log"), `${new Date().toISOString()} before tool=${JSON.stringify(input2.tool)} session=${input2.sessionID}
16978
+ appendFileSync(join6(tmpdir3(), "forge-perm-probe.log"), `${new Date().toISOString()} before tool=${JSON.stringify(input2.tool)} session=${input2.sessionID}
16897
16979
  `);
16898
16980
  } catch {}
16899
16981
  }
@@ -16903,7 +16985,7 @@ var server = async (input, options) => {
16903
16985
  watchdog.track(input2.callID, input2.sessionID, input2.tool, undefined, cmdText !== undefined ? commandNeedle(cmdText) : undefined);
16904
16986
  if (process.env.FORGE_WATCHDOG_PROBE) {
16905
16987
  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)}
16988
+ 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
16989
  `);
16908
16990
  } catch {}
16909
16991
  }
@@ -16926,7 +17008,7 @@ var server = async (input, options) => {
16926
17008
  if (raw) {
16927
17009
  const state = stateForBan(input2.sessionID);
16928
17010
  const base = state?.worktree ?? hostWorktree ?? "";
16929
- const abs = (isAbsolute(raw) ? raw : join5(base, raw)).replaceAll("\\", "/").toLowerCase();
17011
+ const abs = (isAbsolute(raw) ? raw : join6(base, raw)).replaceAll("\\", "/").toLowerCase();
16930
17012
  if (abs.includes("/.opencode/plan/") || abs.includes("/.opencode/goal/")) {
16931
17013
  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
17014
  }
@@ -17017,7 +17099,7 @@ var server = async (input, options) => {
17017
17099
  const name = (typeof meta.tool === "string" ? meta.tool : undefined) ?? (typeof permissionField === "string" ? permissionField : undefined) ?? input2.id ?? input2.type;
17018
17100
  if (process.env.FORGE_PERM_PROBE) {
17019
17101
  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)}
17102
+ 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
17103
  `);
17022
17104
  } catch {}
17023
17105
  }
@@ -17071,7 +17153,7 @@ var server = async (input, options) => {
17071
17153
  watchdog.markSeen(input2.callID);
17072
17154
  if (process.env.FORGE_WATCHDOG_PROBE) {
17073
17155
  try {
17074
- appendFileSync(join5(tmpdir3(), "forge-watchdog-probe.log"), `${new Date().toISOString()} mark ${input2.callID} hostpid=${process.pid}
17156
+ appendFileSync(join6(tmpdir3(), "forge-watchdog-probe.log"), `${new Date().toISOString()} mark ${input2.callID} hostpid=${process.pid}
17075
17157
  `);
17076
17158
  } catch {}
17077
17159
  }
@@ -17081,7 +17163,7 @@ var server = async (input, options) => {
17081
17163
  watchdog.untrack(input2.callID);
17082
17164
  if (process.env.FORGE_WATCHDOG_PROBE) {
17083
17165
  try {
17084
- appendFileSync(join5(tmpdir3(), "forge-watchdog-probe.log"), `${new Date().toISOString()} untrack ${input2.callID}
17166
+ appendFileSync(join6(tmpdir3(), "forge-watchdog-probe.log"), `${new Date().toISOString()} untrack ${input2.callID}
17085
17167
  `);
17086
17168
  } catch {}
17087
17169
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sorenllm/opencode-forge",
3
- "version": "0.9.1",
3
+ "version": "0.10.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",