@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.
- package/README.md +43 -0
- package/dist/index.js +157 -61
- 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
|
|
12339
|
-
import { isAbsolute, join as
|
|
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
|
-
|
|
13127
|
-
|
|
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 ??
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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) ?
|
|
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) ?
|
|
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) ?
|
|
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 =
|
|
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 =
|
|
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:
|
|
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(
|
|
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
|
|
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
|
|
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() -
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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) &&
|
|
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({
|
|
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
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 :
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 :
|
|
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
|
|
16950
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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.
|
|
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",
|