@sorenllm/opencode-forge 0.9.0 → 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 +157 -61
  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");
@@ -13419,6 +13498,9 @@ function createJobManager(opts = {}) {
13419
13498
  if (job.notify && job.wakeState === "none" && !job.terminalEvidence) {
13420
13499
  job.wakeState = "queued";
13421
13500
  job.wakeQueuedAt = now();
13501
+ try {
13502
+ opts.onWakeQueued?.(job);
13503
+ } catch {}
13422
13504
  }
13423
13505
  if (job.terminalEvidence) {
13424
13506
  jobs.delete(job.id);
@@ -13567,8 +13649,8 @@ function newJobId(now = Date.now) {
13567
13649
 
13568
13650
  // src/job-runner.ts
13569
13651
  import { spawn as spawn3 } from "node:child_process";
13570
- import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readSync, readFileSync as readFileSync2, statSync, unlinkSync } from "node:fs";
13571
- 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";
13572
13654
  import { tmpdir } from "node:os";
13573
13655
  var DEFAULT_IDLE_MS = 60000;
13574
13656
  var DEFAULT_MAX_WAIT_MS = 120000;
@@ -13579,17 +13661,17 @@ var JOB_ENV_MARKER = "FORGE_JOB_ID";
13579
13661
  var TAIL_POLL_MS = 200;
13580
13662
  var ADOPT_LIVENESS_MS = 1000;
13581
13663
  function jobsLogDir(base) {
13582
- return join2(base ?? join2(tmpdir(), "opencode-forge"), "jobs");
13664
+ return join3(base ?? join3(tmpdir(), "opencode-forge"), "jobs");
13583
13665
  }
13584
13666
  var TAIL_CHUNK_BYTES = 4 * 1024 * 1024;
13585
13667
  function createFileTail(logPath, onText, pollMs = TAIL_POLL_MS) {
13586
- let pos = existsSync(logPath) ? statSync(logPath).size : 0;
13668
+ let pos = existsSync(logPath) ? statSync2(logPath).size : 0;
13587
13669
  let stopped = false;
13588
13670
  const flush = () => {
13589
13671
  if (stopped)
13590
13672
  return;
13591
13673
  try {
13592
- let size = existsSync(logPath) ? statSync(logPath).size : 0;
13674
+ let size = existsSync(logPath) ? statSync2(logPath).size : 0;
13593
13675
  if (size < pos)
13594
13676
  pos = size;
13595
13677
  const fd = openSync(logPath, "r");
@@ -13608,7 +13690,7 @@ function createFileTail(logPath, onText, pollMs = TAIL_POLL_MS) {
13608
13690
  break;
13609
13691
  pos += read;
13610
13692
  onText(buf.toString("utf8", 0, read));
13611
- size = existsSync(logPath) ? statSync(logPath).size : size;
13693
+ size = existsSync(logPath) ? statSync2(logPath).size : size;
13612
13694
  if (stopped)
13613
13695
  break;
13614
13696
  }
@@ -13636,7 +13718,7 @@ function startJob(manager, opts) {
13636
13718
  const maxWaitMs = Math.min(Math.max(0, opts.maxWaitMs ?? DEFAULT_MAX_WAIT_MS), HARD_MAX_WAIT_MS);
13637
13719
  mkdirSync(opts.logDir, { recursive: true });
13638
13720
  const id = newJobId();
13639
- const logPath = join2(opts.logDir, `${id}.log`);
13721
+ const logPath = join3(opts.logDir, `${id}.log`);
13640
13722
  let child;
13641
13723
  let settled = false;
13642
13724
  let exiting = false;
@@ -13692,6 +13774,7 @@ function startJob(manager, opts) {
13692
13774
  try {
13693
13775
  wfd = openSync(logPath, "a");
13694
13776
  child = shellSpawn(opts.spawnFn ?? spawn3, opts.cmd, {
13777
+ shell: opts.shell,
13695
13778
  cwd: opts.cwd,
13696
13779
  env: { ...opts.env ?? {}, [JOB_ENV_MARKER]: id },
13697
13780
  stdio: ["ignore", wfd, wfd]
@@ -13869,7 +13952,7 @@ function readJobLog(logPath, opts = {}) {
13869
13952
  let windowed = false;
13870
13953
  try {
13871
13954
  if (existsSync(logPath)) {
13872
- const size = statSync(logPath).size;
13955
+ const size = statSync2(logPath).size;
13873
13956
  if (size > LOG_READ_MAX_BYTES) {
13874
13957
  const fd = openSync(logPath, "r");
13875
13958
  try {
@@ -13907,7 +13990,7 @@ function rotateLogs(logDir, keep = DEFAULT_LOG_KEEP) {
13907
13990
  try {
13908
13991
  entries = readdirSync(logDir).filter((f) => f.endsWith(".log")).map((name) => {
13909
13992
  try {
13910
- return { name, mtime: statSync(join2(logDir, name)).mtimeMs };
13993
+ return { name, mtime: statSync2(join3(logDir, name)).mtimeMs };
13911
13994
  } catch {
13912
13995
  return { name, mtime: 0 };
13913
13996
  }
@@ -13921,7 +14004,7 @@ function rotateLogs(logDir, keep = DEFAULT_LOG_KEEP) {
13921
14004
  entries.sort((a, b) => a.mtime - b.mtime);
13922
14005
  for (const e of entries.slice(0, excess)) {
13923
14006
  try {
13924
- unlinkSync(join2(logDir, e.name));
14007
+ unlinkSync(join3(logDir, e.name));
13925
14008
  } catch {}
13926
14009
  }
13927
14010
  }
@@ -14141,9 +14224,9 @@ function createJobFence(opts = {}) {
14141
14224
  }
14142
14225
 
14143
14226
  // src/job-registry.ts
14144
- 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";
14145
14228
  import { execFileSync, spawn as spawn5 } from "node:child_process";
14146
- import { dirname, join as join3 } from "node:path";
14229
+ import { dirname as dirname2, join as join4 } from "node:path";
14147
14230
  var REGISTRY_KEEP = 100;
14148
14231
  function structuralRelocate(deadPid, platform = process.platform) {
14149
14232
  if (deadPid <= 0)
@@ -14223,7 +14306,7 @@ function acquireLock(lockPath, timeoutMs = 2000) {
14223
14306
  if (err.code !== "EEXIST")
14224
14307
  throw err;
14225
14308
  try {
14226
- if (Date.now() - statSync2(lockPath).mtimeMs > 5000) {
14309
+ if (Date.now() - statSync3(lockPath).mtimeMs > 5000) {
14227
14310
  unlinkSync2(lockPath);
14228
14311
  continue;
14229
14312
  }
@@ -14248,7 +14331,7 @@ function readRaw(path) {
14248
14331
  }
14249
14332
  }
14250
14333
  function writeRaw(path, entries) {
14251
- mkdirSync2(dirname(path), { recursive: true });
14334
+ mkdirSync2(dirname2(path), { recursive: true });
14252
14335
  writeFileSync2(path, `${JSON.stringify({ version: 1, entries: entries.slice(0, REGISTRY_KEEP) }, null, 2)}
14253
14336
  `);
14254
14337
  }
@@ -14304,7 +14387,7 @@ function createJobRegistry(registryPath) {
14304
14387
  };
14305
14388
  }
14306
14389
  function registryPathFor(jobsDir) {
14307
- return join3(jobsDir, "registry.json");
14390
+ return join4(jobsDir, "registry.json");
14308
14391
  }
14309
14392
 
14310
14393
  // src/host-exit.ts
@@ -14386,7 +14469,7 @@ function createExitCleanup(manager, opts = {}) {
14386
14469
 
14387
14470
  // src/watchdog.ts
14388
14471
  import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
14389
- import { dirname as dirname2 } from "node:path";
14472
+ import { dirname as dirname3 } from "node:path";
14390
14473
  var WATCHDOG_ENV_MARK = "FORGE_WATCHDOG_MARK";
14391
14474
  var DEFAULT_STALL_MS = 600000;
14392
14475
  var MIN_STALL_MS = 60000;
@@ -14604,7 +14687,7 @@ function watchdogLogDir(base) {
14604
14687
  function createFileLedger(logPath, maxEntries = 200, maxBytes = 1e6) {
14605
14688
  const append = (entry) => {
14606
14689
  try {
14607
- mkdirSync3(dirname2(logPath), { recursive: true });
14690
+ mkdirSync3(dirname3(logPath), { recursive: true });
14608
14691
  let lines = [];
14609
14692
  try {
14610
14693
  lines = readFileSync4(logPath, "utf8").split(`
@@ -14757,7 +14840,7 @@ function createLocator(platform = process.platform, posix = {}, win = {}) {
14757
14840
 
14758
14841
  // src/models-dev.ts
14759
14842
  import { mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "node:fs";
14760
- import { join as join4 } from "node:path";
14843
+ import { join as join5 } from "node:path";
14761
14844
  import { tmpdir as tmpdir2 } from "node:os";
14762
14845
  var DEFAULT_MODELS_DEV_URL = "https://models.dev/api.json";
14763
14846
  function parseIdentity(identity) {
@@ -14779,7 +14862,7 @@ function nativeLadder(catalog, identity) {
14779
14862
  return Array.isArray(options) ? [...options] : null;
14780
14863
  }
14781
14864
  function modelsDevCacheDir(base) {
14782
- return join4(base ?? join4(tmpdir2(), "opencode-forge"), "models-dev");
14865
+ return join5(base ?? join5(tmpdir2(), "opencode-forge"), "models-dev");
14783
14866
  }
14784
14867
  function namedLevels(rows) {
14785
14868
  const out = [];
@@ -14821,7 +14904,7 @@ function reduceModelsDev(raw) {
14821
14904
  async function loadModelsDevSnapshot(opts = {}) {
14822
14905
  const url2 = opts.url ?? process.env.OPENCODE_MODELS_URL ?? DEFAULT_MODELS_DEV_URL;
14823
14906
  const cacheDir = modelsDevCacheDir(opts.cacheDir);
14824
- const cacheFile = join4(cacheDir, "snapshot.json");
14907
+ const cacheFile = join5(cacheDir, "snapshot.json");
14825
14908
  const fetcher = opts.fetcher;
14826
14909
  if (fetcher) {
14827
14910
  try {
@@ -15021,7 +15104,7 @@ function resolvePinnedDepth(thoughtLevel, family, ladder) {
15021
15104
  }
15022
15105
 
15023
15106
  // src/forge-config.ts
15024
- import { readFileSync as readFileSync7, statSync as statSync3 } from "node:fs";
15107
+ import { readFileSync as readFileSync7, statSync as statSync4 } from "node:fs";
15025
15108
  function sanitizeJsonc(text) {
15026
15109
  const out = [];
15027
15110
  let i = 0;
@@ -15349,7 +15432,7 @@ function forgeConfigPaths(opts) {
15349
15432
  function createForgeConfigLoader(deps) {
15350
15433
  const stat = deps.stat ?? ((path) => {
15351
15434
  try {
15352
- const s = statSync3(path);
15435
+ const s = statSync4(path);
15353
15436
  return { mtimeMs: s.mtimeMs, size: s.size };
15354
15437
  } catch {
15355
15438
  return null;
@@ -15593,18 +15676,25 @@ function jobStage() {
15593
15676
  return 0;
15594
15677
  }
15595
15678
  var jobLogDir = jobsLogDir();
15596
- var jobLedgerPath = join5(jobLogDir, "ledger.jsonl");
15679
+ var jobLedgerPath = join6(jobLogDir, "ledger.jsonl");
15597
15680
  function jobLedgerSink(entry) {
15598
15681
  try {
15599
15682
  mkdirSync5(jobLogDir, { recursive: true });
15600
- if (existsSync3(jobLedgerPath) && statSync4(jobLedgerPath).size > 1e6)
15683
+ if (existsSync3(jobLedgerPath) && statSync5(jobLedgerPath).size > 1e6)
15601
15684
  writeFileSync5(jobLedgerPath, "");
15602
15685
  appendFileSync(jobLedgerPath, `${JSON.stringify(entry)}
15603
15686
  `);
15604
15687
  } catch {}
15605
15688
  }
15606
- var jobManager = createJobManager({ sink: jobLedgerSink });
15689
+ var jobManager = createJobManager({
15690
+ sink: jobLedgerSink,
15691
+ onWakeQueued: (job) => {
15692
+ if (idleJobSessions.has(job.ownerSession))
15693
+ deliverJobWakes(job.ownerSession);
15694
+ }
15695
+ });
15607
15696
  var jobClient = null;
15697
+ var idleJobSessions = new Set;
15608
15698
  function jobWakeText(job) {
15609
15699
  return [
15610
15700
  `[forge:job-complete] Background job ${job.id} finished (${job.state}${job.exitCode !== null ? `, exit ${job.exitCode}` : ""}).`,
@@ -15653,13 +15743,13 @@ function nowIso() {
15653
15743
  return new Date().toISOString();
15654
15744
  }
15655
15745
  function planDirOf(worktree) {
15656
- return join5(worktree, ".opencode", "plan");
15746
+ return join6(worktree, ".opencode", "plan");
15657
15747
  }
15658
15748
  function readPlanDir(worktree) {
15659
15749
  const dir = planDirOf(worktree);
15660
15750
  if (!existsSync3(dir))
15661
15751
  return [];
15662
- 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") }));
15663
15753
  }
15664
15754
  function ensureSession(sessionID, worktree) {
15665
15755
  const existing = sessions.get(sessionID);
@@ -15685,7 +15775,7 @@ function resolveActivePlan(state) {
15685
15775
  const ranked = rankActivePlans(readPlanDir(state.worktree));
15686
15776
  if (ranked.length === 0)
15687
15777
  return null;
15688
- const path = join5(planDirOf(state.worktree), ranked[0].name);
15778
+ const path = join6(planDirOf(state.worktree), ranked[0].name);
15689
15779
  state.planPath = path;
15690
15780
  return { path, doc: ranked[0].doc };
15691
15781
  }
@@ -15720,7 +15810,7 @@ var planWriteTool = tool({
15720
15810
  } else {
15721
15811
  const dir = planDirOf(state.worktree);
15722
15812
  mkdirSync5(dir, { recursive: true });
15723
- 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"))));
15724
15814
  mode = "created";
15725
15815
  }
15726
15816
  const text = renderPlan(args, now, created);
@@ -15838,13 +15928,13 @@ var planDiscardTool = tool({
15838
15928
  }
15839
15929
  });
15840
15930
  function goalDirOf(worktree) {
15841
- return join5(worktree, ".opencode", "goal");
15931
+ return join6(worktree, ".opencode", "goal");
15842
15932
  }
15843
15933
  function readGoalDir(worktree) {
15844
15934
  const dir = goalDirOf(worktree);
15845
15935
  if (!existsSync3(dir))
15846
15936
  return [];
15847
- 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") }));
15848
15938
  }
15849
15939
  function resolveSessionGoal(state) {
15850
15940
  if (state.goalPath && existsSync3(state.goalPath)) {
@@ -15855,7 +15945,7 @@ function resolveSessionGoal(state) {
15855
15945
  }
15856
15946
  const live = rankLiveGoals(readGoalDir(state.worktree))[0];
15857
15947
  if (live) {
15858
- const path = join5(goalDirOf(state.worktree), live.name);
15948
+ const path = join6(goalDirOf(state.worktree), live.name);
15859
15949
  state.goalPath = path;
15860
15950
  return { path, doc: live.doc };
15861
15951
  }
@@ -15954,7 +16044,7 @@ var goalWriteTool = tool({
15954
16044
  const dir = goalDirOf(state.worktree);
15955
16045
  mkdirSync5(dir, { recursive: true });
15956
16046
  const name = goalFileName(localDateNow(), slugifyGoal(args.goal), readdirSync4(dir).filter((f) => f.endsWith(".md")));
15957
- const path = join5(dir, name);
16047
+ const path = join6(dir, name);
15958
16048
  const text = renderGoal(input, {
15959
16049
  now,
15960
16050
  status: arm ? "active" : "queued",
@@ -16083,7 +16173,7 @@ var goalResumeTool = tool({
16083
16173
  if (!goal || goal.doc.status === "queued") {
16084
16174
  const oldest = rankQueuedGoals(readGoalDir(state.worktree))[0];
16085
16175
  if (oldest) {
16086
- const path = join5(goalDirOf(state.worktree), oldest.name);
16176
+ const path = join6(goalDirOf(state.worktree), oldest.name);
16087
16177
  state.goalPath = path;
16088
16178
  goal = { path, doc: oldest.doc };
16089
16179
  }
@@ -16133,7 +16223,8 @@ var FORGE_SHELL_DESCRIPTION = [
16133
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).",
16134
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.",
16135
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`.",
16136
- "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."
16137
16228
  ].join(`
16138
16229
  `);
16139
16230
  var forgeShellTool = tool({
@@ -16165,7 +16256,7 @@ var forgeShellTool = tool({
16165
16256
  }
16166
16257
  }
16167
16258
  const { registry: registry2, fence } = ensureJobLifecycle();
16168
- 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;
16169
16260
  const started = startJob(jobManager, {
16170
16261
  cmd: args.command,
16171
16262
  cwd,
@@ -16501,7 +16592,7 @@ var pendingContinuationTurn = new Set;
16501
16592
  function goalProbe(line) {
16502
16593
  if (process.env.FORGE_GOAL_PROBE) {
16503
16594
  try {
16504
- appendFileSync(join5(tmpdir3(), "forge-goal-probe.log"), `${new Date().toISOString()} ${line}
16595
+ appendFileSync(join6(tmpdir3(), "forge-goal-probe.log"), `${new Date().toISOString()} ${line}
16505
16596
  `);
16506
16597
  } catch {}
16507
16598
  }
@@ -16716,14 +16807,14 @@ var server = async (input, options) => {
16716
16807
  if (wdStallRaw !== undefined && wdStallRaw !== wdStall) {
16717
16808
  wdFallbacks.push(`watchdog.stallMs ${JSON.stringify(String(wdStallRaw))} adjusted to ${wdStall} (floor/default applied)`);
16718
16809
  }
16719
- const watchdogLedger = createFileLedger(join5(watchdogLogDir(), "log.jsonl"));
16810
+ const watchdogLedger = createFileLedger(join6(watchdogLogDir(), "log.jsonl"));
16720
16811
  const rawLocator = createLocator();
16721
16812
  const probing = () => process.env.FORGE_WATCHDOG_PROBE === "1";
16722
16813
  const probeLine = (text) => {
16723
16814
  if (!probing())
16724
16815
  return;
16725
16816
  try {
16726
- appendFileSync(join5(tmpdir3(), "forge-watchdog-probe.log"), `${new Date().toISOString()} ${text}
16817
+ appendFileSync(join6(tmpdir3(), "forge-watchdog-probe.log"), `${new Date().toISOString()} ${text}
16727
16818
  `);
16728
16819
  } catch {}
16729
16820
  };
@@ -16779,6 +16870,7 @@ var server = async (input, options) => {
16779
16870
  forgeDisabled = forgeUserCfg?.disable === true;
16780
16871
  if (forgeDisabled)
16781
16872
  return;
16873
+ setHostShell(typeof cfg.shell === "string" ? cfg.shell : undefined);
16782
16874
  cfg.default_agent ??= FORGE_AGENT;
16783
16875
  const existing = agentSection[FORGE_AGENT];
16784
16876
  agentSection[FORGE_AGENT] = {
@@ -16883,7 +16975,7 @@ var server = async (input, options) => {
16883
16975
  "tool.execute.before": async (input2, output) => {
16884
16976
  if (process.env.FORGE_PERM_PROBE) {
16885
16977
  try {
16886
- 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}
16887
16979
  `);
16888
16980
  } catch {}
16889
16981
  }
@@ -16893,7 +16985,7 @@ var server = async (input, options) => {
16893
16985
  watchdog.track(input2.callID, input2.sessionID, input2.tool, undefined, cmdText !== undefined ? commandNeedle(cmdText) : undefined);
16894
16986
  if (process.env.FORGE_WATCHDOG_PROBE) {
16895
16987
  try {
16896
- 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)}
16897
16989
  `);
16898
16990
  } catch {}
16899
16991
  }
@@ -16916,7 +17008,7 @@ var server = async (input, options) => {
16916
17008
  if (raw) {
16917
17009
  const state = stateForBan(input2.sessionID);
16918
17010
  const base = state?.worktree ?? hostWorktree ?? "";
16919
- const abs = (isAbsolute(raw) ? raw : join5(base, raw)).replaceAll("\\", "/").toLowerCase();
17011
+ const abs = (isAbsolute(raw) ? raw : join6(base, raw)).replaceAll("\\", "/").toLowerCase();
16920
17012
  if (abs.includes("/.opencode/plan/") || abs.includes("/.opencode/goal/")) {
16921
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.`);
16922
17014
  }
@@ -16946,8 +17038,11 @@ var server = async (input, options) => {
16946
17038
  if (forgeDisabled)
16947
17039
  return;
16948
17040
  const raw = input2;
16949
- if (typeof raw.sessionID === "string" && raw.sessionID && typeof raw.agent === "string" && raw.agent) {
16950
- sessionAgents.set(raw.sessionID, raw.agent);
17041
+ if (typeof raw.sessionID === "string" && raw.sessionID) {
17042
+ idleJobSessions.delete(raw.sessionID);
17043
+ if (typeof raw.agent === "string" && raw.agent) {
17044
+ sessionAgents.set(raw.sessionID, raw.agent);
17045
+ }
16951
17046
  }
16952
17047
  },
16953
17048
  "chat.params": async (input2, output) => {
@@ -17004,7 +17099,7 @@ var server = async (input, options) => {
17004
17099
  const name = (typeof meta.tool === "string" ? meta.tool : undefined) ?? (typeof permissionField === "string" ? permissionField : undefined) ?? input2.id ?? input2.type;
17005
17100
  if (process.env.FORGE_PERM_PROBE) {
17006
17101
  try {
17007
- 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)}
17008
17103
  `);
17009
17104
  } catch {}
17010
17105
  }
@@ -17033,6 +17128,7 @@ var server = async (input, options) => {
17033
17128
  const sessionID = event.properties.sessionID;
17034
17129
  if (typeof sessionID === "string" && sessionID) {
17035
17130
  goalProbe(`idle event session=${sessionID}`);
17131
+ idleJobSessions.add(sessionID);
17036
17132
  scheduleIdleContinuation(client, sessionID);
17037
17133
  deliverJobWakes(sessionID);
17038
17134
  }
@@ -17057,7 +17153,7 @@ var server = async (input, options) => {
17057
17153
  watchdog.markSeen(input2.callID);
17058
17154
  if (process.env.FORGE_WATCHDOG_PROBE) {
17059
17155
  try {
17060
- 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}
17061
17157
  `);
17062
17158
  } catch {}
17063
17159
  }
@@ -17067,7 +17163,7 @@ var server = async (input, options) => {
17067
17163
  watchdog.untrack(input2.callID);
17068
17164
  if (process.env.FORGE_WATCHDOG_PROBE) {
17069
17165
  try {
17070
- 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}
17071
17167
  `);
17072
17168
  } catch {}
17073
17169
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sorenllm/opencode-forge",
3
- "version": "0.9.0",
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",