@sorenllm/opencode-forge 0.9.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -6
- package/dist/index.js +248 -77
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -171,7 +171,7 @@ What this plugin touches, exhaustively:
|
|
|
171
171
|
| `<tmp>/opencode-forge/jobs/registry.json` | persistent survivor registry (bounded: 100 entries) | runtime debris — after uninstall, kill any still-running `survive` jobs yourself first |
|
|
172
172
|
| `<tmp>/opencode-forge/watchdog/log.jsonl` | watchdog interventions ledger (bounded: 200 entries, oldest rotated) | runtime debris — delete freely, also after uninstall |
|
|
173
173
|
| `<tmp>/opencode-forge/dispatch/ledger.jsonl` | dispatch ledger — **no longer written** (the dispatch engine was removed); only present as inert debris from older versions | runtime debris — delete freely |
|
|
174
|
-
|
|
|
174
|
+
| the nearest `.opencode/forge.json` on the workspace's ancestor chain, `~/.config/opencode/forge.json` | your static subagent definitions (JSONC), two MERGED layers (project overrides global per agent id). **User data — the plugin only reads it, never writes or migrates it** | yours — version the project one, keep the global one out of sync tools if it holds machine-specific models |
|
|
175
175
|
| `~/.cache/opencode/packages/...` | installed package copy | written by the `opencode plugin` installer, not the plugin |
|
|
176
176
|
| `~/.config/opencode/opencode.json` | `plugin` array entry | written by the installer |
|
|
177
177
|
|
|
@@ -275,6 +275,23 @@ Every result form carries `cwd:` — the absolute directory the command
|
|
|
275
275
|
actually ran in. When the agent's narrative about the working directory
|
|
276
276
|
disagrees with that line, the line wins.
|
|
277
277
|
|
|
278
|
+
**Interpreter selection.** Commands run under the host-aligned shell —
|
|
279
|
+
the same interpreter the host's builtin shell tool uses — never under
|
|
280
|
+
Node's `shell: true` default (cmd.exe / plain sh). On Windows the chain
|
|
281
|
+
is `pwsh → powershell → git-bash → %COMSPEC%` (cmd.exe only as a
|
|
282
|
+
machine-level capability fallback); on POSIX the login shell with bash
|
|
283
|
+
preferred and `/bin/sh` as the fallback. PowerShell-family shells are
|
|
284
|
+
invoked directly (`<shell> -NoProfile -Command <command>` plus an
|
|
285
|
+
exit-code guard, so a native command's exit status propagates as the
|
|
286
|
+
job's exit status) — which means you write PowerShell syntax in
|
|
287
|
+
`forge_shell` on Windows: `echo $env:USERNAME` expands, quoted-exe
|
|
288
|
+
invocations use the call operator (`& "C:\path with spaces\tool.exe" args`).
|
|
289
|
+
The tool description states the interpreter family, and there is no
|
|
290
|
+
configuration knob: the interpreter follows the host, by design.
|
|
291
|
+
Goal-harness verification checks run through the same resolver, so a
|
|
292
|
+
check drafted under the model's shell expectations behaves identically
|
|
293
|
+
at the completion gate.
|
|
294
|
+
|
|
278
295
|
**`forge_jobs`** manages the registry: `list` / `poll {jobId, waitMs≤30s}`
|
|
279
296
|
(bounded wait for new output or exit, drains it) / `log {jobId, offset?,
|
|
280
297
|
limit?}` (line paging over the on-disk log) / `kill` (whole process tree) /
|
|
@@ -388,11 +405,28 @@ subagent behavior is untouched. Onboarding is this README, for you the human.
|
|
|
388
405
|
|
|
389
406
|
### forge.json — the configuration
|
|
390
407
|
|
|
391
|
-
A dedicated `forge.json` (JSONC — comments allowed), resolved
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
408
|
+
A dedicated `forge.json` (JSONC — comments allowed), resolved exactly like
|
|
409
|
+
opencode's own config language — **merged together, not replaced**, project
|
|
410
|
+
discovered by walking up:
|
|
411
|
+
|
|
412
|
+
1. the project layer: the **nearest** `.opencode/forge.json` walking up from
|
|
413
|
+
the workspace (session worktree → session directory → host launch
|
|
414
|
+
directory as the anchor chain; first file found on the ancestor chain
|
|
415
|
+
wins, nothing above it is consulted)
|
|
416
|
+
2. the global layer: `~/.config/opencode/forge.json`
|
|
417
|
+
|
|
418
|
+
The two layers **merge**: agents combine, and on an id collision the project
|
|
419
|
+
definition wins wholesale (one definition per id — no cross-layer field
|
|
420
|
+
blending). A broken file empties only its own layer (error finding with the
|
|
421
|
+
parse location); the other layer still applies. Both layers hot-apply —
|
|
422
|
+
edits, and files appearing or disappearing on the ancestor chain, take
|
|
423
|
+
effect without a host restart (new agents still need the restart to enter
|
|
424
|
+
the task vocabulary; see the apply-timing table below).
|
|
425
|
+
|
|
426
|
+
One host instance materializes one pool (from its launch anchor) — a
|
|
427
|
+
workspace-local file that the host was not launched on is NOT silently
|
|
428
|
+
ignored: `/crew` names it and tells you to relaunch there (or fold the
|
|
429
|
+
agents into the global layer).
|
|
396
430
|
|
|
397
431
|
```jsonc
|
|
398
432
|
{
|
|
@@ -605,6 +639,32 @@ supervisor chapter above). The hide is unconditional: it covers a
|
|
|
605
639
|
`jobs.keepBuiltinShell: true` (builtin shell back on the forge family) and
|
|
606
640
|
`jobs.mode: "native"` (supervisor retired entirely).
|
|
607
641
|
|
|
642
|
+
### The exec channel is final for this plugin-API generation (2026-09-28)
|
|
643
|
+
|
|
644
|
+
The four layers — tools-map hide, prompt mandate, belt refusal, `forge_shell`
|
|
645
|
+
itself — are the ceiling of what the current opencode plugin API can express,
|
|
646
|
+
and the design is closed deliberately:
|
|
647
|
+
|
|
648
|
+
- **Not expressible**: `tool.execute.before` can only mutate `args` or throw.
|
|
649
|
+
There is no tool-name rewrite, no executor replacement, no result injection —
|
|
650
|
+
"transparently reroute a builtin bash call through the job runner" cannot be
|
|
651
|
+
written. Registering a plugin tool named `bash` collides globally: tool
|
|
652
|
+
registration is not per-agent, and the per-agent mechanism is exactly the
|
|
653
|
+
tools map some hosts ignore.
|
|
654
|
+
- **Prior art ships a subset**: OMO (oh-my-opencode-slim / oh-my-openagent,
|
|
655
|
+
the largest opencode plugin ecosystem) pairs a permission-deny boundary with
|
|
656
|
+
a prompt channel mandate and does not hide the builtin shell at plugin level
|
|
657
|
+
either — their models also see bash and pay one denial round.
|
|
658
|
+
- **The residual is bounded and accepted**: on hosts where the hide does not
|
|
659
|
+
hold, a first bash attempt costs one round-trip per session — the belt
|
|
660
|
+
refuses pre-execution (~2 ms, the command never runs), the message names
|
|
661
|
+
`forge_shell`, and models re-issue once and stay on-channel. Field data
|
|
662
|
+
(2026-09-28): zero duplicate executions attributable to the belt.
|
|
663
|
+
- **Reopen on upstream movement**: hosts honoring injected tools maps (the
|
|
664
|
+
belt becomes pure insurance), a tool-execute replacement hook (transparent
|
|
665
|
+
rerouting becomes expressible), or exit-based builtin-shell completion (the
|
|
666
|
+
watchdog retirement path above applies).
|
|
667
|
+
|
|
608
668
|
A process restart forgets the session binding: the write-ban soft-disables
|
|
609
669
|
(safety over strictness) and the next session's system notice + `/plan
|
|
610
670
|
resume` re-bind from the plan file on disk, which is the source of truth.
|
package/dist/index.js
CHANGED
|
@@ -12335,8 +12335,8 @@ function tool(input) {
|
|
|
12335
12335
|
tool.schema = exports_external;
|
|
12336
12336
|
// plugin.ts
|
|
12337
12337
|
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
12338
|
-
import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync5, readdirSync as readdirSync4, readFileSync as readFileSync8, statSync as
|
|
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");
|
|
@@ -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
|
|
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
|
|
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) ?
|
|
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) ?
|
|
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) ?
|
|
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 =
|
|
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 =
|
|
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:
|
|
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(
|
|
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
|
|
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
|
|
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() -
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
15107
|
+
import { readFileSync as readFileSync7, statSync as statSync4 } from "node:fs";
|
|
15028
15108
|
function sanitizeJsonc(text) {
|
|
15029
15109
|
const out = [];
|
|
15030
15110
|
let i = 0;
|
|
@@ -15349,17 +15429,35 @@ function forgeConfigPaths(opts) {
|
|
|
15349
15429
|
const global = opts.homeDir ? `${opts.homeDir}${sep2}.config${sep2}opencode${sep2}forge.json` : "";
|
|
15350
15430
|
return { project, global };
|
|
15351
15431
|
}
|
|
15432
|
+
function isFsRoot(p) {
|
|
15433
|
+
return p === "" || p === "/" || p === "\\" || /^[A-Za-z]:[\\/]?$/.test(p);
|
|
15434
|
+
}
|
|
15435
|
+
function parentDirOf(p) {
|
|
15436
|
+
let s = p;
|
|
15437
|
+
while (s.endsWith("/") || s.endsWith("\\")) {
|
|
15438
|
+
if (isFsRoot(s))
|
|
15439
|
+
return null;
|
|
15440
|
+
s = s.slice(0, -1);
|
|
15441
|
+
if (isFsRoot(s))
|
|
15442
|
+
return null;
|
|
15443
|
+
}
|
|
15444
|
+
const i = Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\"));
|
|
15445
|
+
if (i < 0)
|
|
15446
|
+
return null;
|
|
15447
|
+
const parent = s.slice(0, i + 1);
|
|
15448
|
+
return parent === "" ? null : parent;
|
|
15449
|
+
}
|
|
15352
15450
|
function createForgeConfigLoader(deps) {
|
|
15353
15451
|
const stat = deps.stat ?? ((path) => {
|
|
15354
15452
|
try {
|
|
15355
|
-
const s =
|
|
15453
|
+
const s = statSync4(path);
|
|
15356
15454
|
return { mtimeMs: s.mtimeMs, size: s.size };
|
|
15357
15455
|
} catch {
|
|
15358
15456
|
return null;
|
|
15359
15457
|
}
|
|
15360
15458
|
});
|
|
15361
15459
|
const readFile = deps.readFile ?? ((path) => readFileSync7(path, "utf8"));
|
|
15362
|
-
const
|
|
15460
|
+
const globalPath = forgeConfigPaths({ homeDir: deps.homeDir }).global;
|
|
15363
15461
|
const cache = new Map;
|
|
15364
15462
|
const readCandidate = (path, source) => {
|
|
15365
15463
|
const st = stat(path);
|
|
@@ -15378,11 +15476,12 @@ function createForgeConfigLoader(deps) {
|
|
|
15378
15476
|
agents: {},
|
|
15379
15477
|
source,
|
|
15380
15478
|
path,
|
|
15479
|
+
projectPath: source === "project" ? path : null,
|
|
15381
15480
|
findings: [
|
|
15382
15481
|
{
|
|
15383
15482
|
level: "error",
|
|
15384
15483
|
code: "config-parse-error",
|
|
15385
|
-
message: `${path}: ${parsed.error.message}${parsed.error.line !== undefined ? ` (line ${parsed.error.line}, column ${parsed.error.column})` : ""} —
|
|
15484
|
+
message: `${path}: ${parsed.error.message}${parsed.error.line !== undefined ? ` (line ${parsed.error.line}, column ${parsed.error.column})` : ""} — this layer contributes no agents until the file parses`
|
|
15386
15485
|
}
|
|
15387
15486
|
]
|
|
15388
15487
|
};
|
|
@@ -15393,25 +15492,55 @@ function createForgeConfigLoader(deps) {
|
|
|
15393
15492
|
agents: {},
|
|
15394
15493
|
source,
|
|
15395
15494
|
path,
|
|
15396
|
-
|
|
15495
|
+
projectPath: source === "project" ? path : null,
|
|
15496
|
+
findings: [{ level: "error", code: "config-not-object", message: `${path}: top level must be an object — this layer contributes no agents` }]
|
|
15397
15497
|
};
|
|
15398
15498
|
} else {
|
|
15399
15499
|
const validated = validateAgentSet(top === undefined ? undefined : top.agents);
|
|
15400
|
-
result = { agents: validated.agents, source, path, findings: validated.findings };
|
|
15500
|
+
result = { agents: validated.agents, source, path, projectPath: source === "project" ? path : null, findings: validated.findings };
|
|
15401
15501
|
}
|
|
15402
15502
|
}
|
|
15403
15503
|
cache.set(path, { mtimeMs: st.mtimeMs, size: st.size, result });
|
|
15404
15504
|
return result;
|
|
15405
15505
|
};
|
|
15506
|
+
const candidateAt = (dir) => {
|
|
15507
|
+
const sep2 = dir.includes("\\") && !dir.includes("/") ? "\\" : "/";
|
|
15508
|
+
const base = dir.endsWith("/") || dir.endsWith("\\") ? dir : `${dir}${sep2}`;
|
|
15509
|
+
return `${base}.opencode${sep2}forge.json`;
|
|
15510
|
+
};
|
|
15511
|
+
const discoverProjectFile = (anchor) => {
|
|
15512
|
+
let dir = (anchor ?? "").trim();
|
|
15513
|
+
if (!dir)
|
|
15514
|
+
return null;
|
|
15515
|
+
for (;; ) {
|
|
15516
|
+
const candidate = candidateAt(dir);
|
|
15517
|
+
if (stat(candidate) !== null)
|
|
15518
|
+
return candidate;
|
|
15519
|
+
const parent = parentDirOf(dir);
|
|
15520
|
+
if (parent === null || parent === dir)
|
|
15521
|
+
return null;
|
|
15522
|
+
dir = parent;
|
|
15523
|
+
}
|
|
15524
|
+
};
|
|
15406
15525
|
return {
|
|
15407
15526
|
load() {
|
|
15408
|
-
const
|
|
15527
|
+
const projectPath = discoverProjectFile(deps.projectDir ?? "");
|
|
15528
|
+
const project = projectPath ? readCandidate(projectPath, "project") : null;
|
|
15529
|
+
const global = globalPath ? readCandidate(globalPath, "global") : null;
|
|
15530
|
+
if (project && global) {
|
|
15531
|
+
return {
|
|
15532
|
+
agents: { ...global.agents, ...project.agents },
|
|
15533
|
+
source: "project+global",
|
|
15534
|
+
path: project.path,
|
|
15535
|
+
projectPath: project.path,
|
|
15536
|
+
findings: [...global.findings, ...project.findings]
|
|
15537
|
+
};
|
|
15538
|
+
}
|
|
15409
15539
|
if (project)
|
|
15410
|
-
return project;
|
|
15411
|
-
const global = paths.global ? readCandidate(paths.global, "global") : null;
|
|
15540
|
+
return { agents: project.agents, source: "project", path: project.path, projectPath: project.path, findings: project.findings };
|
|
15412
15541
|
if (global)
|
|
15413
|
-
return global;
|
|
15414
|
-
return { agents: {}, source: "none", path: null, findings: [] };
|
|
15542
|
+
return { agents: global.agents, source: "global", path: global.path, projectPath: null, findings: global.findings };
|
|
15543
|
+
return { agents: {}, source: "none", path: null, projectPath: null, findings: [] };
|
|
15415
15544
|
}
|
|
15416
15545
|
};
|
|
15417
15546
|
}
|
|
@@ -15529,6 +15658,22 @@ function noteToolAgent(context) {
|
|
|
15529
15658
|
var forgeDisabled = false;
|
|
15530
15659
|
var forgeLoader = null;
|
|
15531
15660
|
var forgeLoaderDir = null;
|
|
15661
|
+
var sessionAnchorLoaders = new Map;
|
|
15662
|
+
function loaderForAnchor(anchor) {
|
|
15663
|
+
const key = anchor || "-";
|
|
15664
|
+
let l = sessionAnchorLoaders.get(key);
|
|
15665
|
+
if (!l) {
|
|
15666
|
+
l = createForgeConfigLoader({ projectDir: anchor, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
|
|
15667
|
+
sessionAnchorLoaders.set(key, l);
|
|
15668
|
+
while (sessionAnchorLoaders.size > 8) {
|
|
15669
|
+
const oldest = sessionAnchorLoaders.keys().next().value;
|
|
15670
|
+
if (oldest === undefined)
|
|
15671
|
+
break;
|
|
15672
|
+
sessionAnchorLoaders.delete(oldest);
|
|
15673
|
+
}
|
|
15674
|
+
}
|
|
15675
|
+
return l;
|
|
15676
|
+
}
|
|
15532
15677
|
var crews = new Map;
|
|
15533
15678
|
var modelsDevCatalog = null;
|
|
15534
15679
|
var depthFindingNotes = new Map;
|
|
@@ -15596,11 +15741,11 @@ function jobStage() {
|
|
|
15596
15741
|
return 0;
|
|
15597
15742
|
}
|
|
15598
15743
|
var jobLogDir = jobsLogDir();
|
|
15599
|
-
var jobLedgerPath =
|
|
15744
|
+
var jobLedgerPath = join6(jobLogDir, "ledger.jsonl");
|
|
15600
15745
|
function jobLedgerSink(entry) {
|
|
15601
15746
|
try {
|
|
15602
15747
|
mkdirSync5(jobLogDir, { recursive: true });
|
|
15603
|
-
if (existsSync3(jobLedgerPath) &&
|
|
15748
|
+
if (existsSync3(jobLedgerPath) && statSync5(jobLedgerPath).size > 1e6)
|
|
15604
15749
|
writeFileSync5(jobLedgerPath, "");
|
|
15605
15750
|
appendFileSync(jobLedgerPath, `${JSON.stringify(entry)}
|
|
15606
15751
|
`);
|
|
@@ -15663,13 +15808,13 @@ function nowIso() {
|
|
|
15663
15808
|
return new Date().toISOString();
|
|
15664
15809
|
}
|
|
15665
15810
|
function planDirOf(worktree) {
|
|
15666
|
-
return
|
|
15811
|
+
return join6(worktree, ".opencode", "plan");
|
|
15667
15812
|
}
|
|
15668
15813
|
function readPlanDir(worktree) {
|
|
15669
15814
|
const dir = planDirOf(worktree);
|
|
15670
15815
|
if (!existsSync3(dir))
|
|
15671
15816
|
return [];
|
|
15672
|
-
return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync8(
|
|
15817
|
+
return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync8(join6(dir, name), "utf8") }));
|
|
15673
15818
|
}
|
|
15674
15819
|
function ensureSession(sessionID, worktree) {
|
|
15675
15820
|
const existing = sessions.get(sessionID);
|
|
@@ -15681,9 +15826,15 @@ function ensureSession(sessionID, worktree) {
|
|
|
15681
15826
|
sessions.set(sessionID, state);
|
|
15682
15827
|
return state;
|
|
15683
15828
|
}
|
|
15829
|
+
function sessionAnchor(context) {
|
|
15830
|
+
const viaDir = effectiveWorktree(context.worktree, context.directory);
|
|
15831
|
+
if (viaDir && !isRootish(viaDir))
|
|
15832
|
+
return viaDir;
|
|
15833
|
+
return hostWorktree || (context.worktree ?? "").trim() || (context.directory ?? "").trim();
|
|
15834
|
+
}
|
|
15684
15835
|
function worktreeFor(context) {
|
|
15685
15836
|
noteToolAgent(context);
|
|
15686
|
-
return
|
|
15837
|
+
return sessionAnchor(context) || context.worktree;
|
|
15687
15838
|
}
|
|
15688
15839
|
function resolveActivePlan(state) {
|
|
15689
15840
|
if (state.planPath && existsSync3(state.planPath)) {
|
|
@@ -15695,7 +15846,7 @@ function resolveActivePlan(state) {
|
|
|
15695
15846
|
const ranked = rankActivePlans(readPlanDir(state.worktree));
|
|
15696
15847
|
if (ranked.length === 0)
|
|
15697
15848
|
return null;
|
|
15698
|
-
const path =
|
|
15849
|
+
const path = join6(planDirOf(state.worktree), ranked[0].name);
|
|
15699
15850
|
state.planPath = path;
|
|
15700
15851
|
return { path, doc: ranked[0].doc };
|
|
15701
15852
|
}
|
|
@@ -15730,7 +15881,7 @@ var planWriteTool = tool({
|
|
|
15730
15881
|
} else {
|
|
15731
15882
|
const dir = planDirOf(state.worktree);
|
|
15732
15883
|
mkdirSync5(dir, { recursive: true });
|
|
15733
|
-
path =
|
|
15884
|
+
path = join6(dir, planFileName(localDate(), slugify(args.goal), readdirSync4(dir).filter((f) => f.endsWith(".md"))));
|
|
15734
15885
|
mode = "created";
|
|
15735
15886
|
}
|
|
15736
15887
|
const text = renderPlan(args, now, created);
|
|
@@ -15848,13 +15999,13 @@ var planDiscardTool = tool({
|
|
|
15848
15999
|
}
|
|
15849
16000
|
});
|
|
15850
16001
|
function goalDirOf(worktree) {
|
|
15851
|
-
return
|
|
16002
|
+
return join6(worktree, ".opencode", "goal");
|
|
15852
16003
|
}
|
|
15853
16004
|
function readGoalDir(worktree) {
|
|
15854
16005
|
const dir = goalDirOf(worktree);
|
|
15855
16006
|
if (!existsSync3(dir))
|
|
15856
16007
|
return [];
|
|
15857
|
-
return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync8(
|
|
16008
|
+
return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync8(join6(dir, name), "utf8") }));
|
|
15858
16009
|
}
|
|
15859
16010
|
function resolveSessionGoal(state) {
|
|
15860
16011
|
if (state.goalPath && existsSync3(state.goalPath)) {
|
|
@@ -15865,7 +16016,7 @@ function resolveSessionGoal(state) {
|
|
|
15865
16016
|
}
|
|
15866
16017
|
const live = rankLiveGoals(readGoalDir(state.worktree))[0];
|
|
15867
16018
|
if (live) {
|
|
15868
|
-
const path =
|
|
16019
|
+
const path = join6(goalDirOf(state.worktree), live.name);
|
|
15869
16020
|
state.goalPath = path;
|
|
15870
16021
|
return { path, doc: live.doc };
|
|
15871
16022
|
}
|
|
@@ -15964,7 +16115,7 @@ var goalWriteTool = tool({
|
|
|
15964
16115
|
const dir = goalDirOf(state.worktree);
|
|
15965
16116
|
mkdirSync5(dir, { recursive: true });
|
|
15966
16117
|
const name = goalFileName(localDateNow(), slugifyGoal(args.goal), readdirSync4(dir).filter((f) => f.endsWith(".md")));
|
|
15967
|
-
const path =
|
|
16118
|
+
const path = join6(dir, name);
|
|
15968
16119
|
const text = renderGoal(input, {
|
|
15969
16120
|
now,
|
|
15970
16121
|
status: arm ? "active" : "queued",
|
|
@@ -16093,7 +16244,7 @@ var goalResumeTool = tool({
|
|
|
16093
16244
|
if (!goal || goal.doc.status === "queued") {
|
|
16094
16245
|
const oldest = rankQueuedGoals(readGoalDir(state.worktree))[0];
|
|
16095
16246
|
if (oldest) {
|
|
16096
|
-
const path =
|
|
16247
|
+
const path = join6(goalDirOf(state.worktree), oldest.name);
|
|
16097
16248
|
state.goalPath = path;
|
|
16098
16249
|
goal = { path, doc: oldest.doc };
|
|
16099
16250
|
}
|
|
@@ -16143,7 +16294,8 @@ var FORGE_SHELL_DESCRIPTION = [
|
|
|
16143
16294
|
"Run a shell command without ever blocking the session indefinitely. The call completes on the FIRST of: process exit (bound to the exit event — a detached grandchild holding the stdio pipes cannot suspend the call); success_pattern matching new output (opt-in regex); idle_ms with no new output (default 60000); max_wait_ms hard cap (default 120000, max 600000 — returns still-running, never kills).",
|
|
16144
16295
|
"Idle/max-wait return `still-running` with a jobId — the process stays alive; keep watching with forge_jobs poll / log, stop it with forge_jobs kill. run_in_background returns {jobId, logPath} immediately.",
|
|
16145
16296
|
"success_pattern semantics: a match completes the call as success; the process is kept alive by default (server semantics — the thing you just verified keeps running); pass keep_alive=false to kill its tree on match. Common patterns: dev servers `listening on|ready in|Local:`, builds `Compiled successfully|Done in`, test suites `passed|all tests`.",
|
|
16146
|
-
"This tool is the forge agent's exec surface for every shell command — quick ones included. Long-running or possibly non-exiting commands (dev servers, watchers, installers, anything spawning detached children) should start with run_in_background and collect results via forge_jobs."
|
|
16297
|
+
"This tool is the forge agent's exec surface for every shell command — quick ones included. Long-running or possibly non-exiting commands (dev servers, watchers, installers, anything spawning detached children) should start with run_in_background and collect results via forge_jobs.",
|
|
16298
|
+
"Interpreter: commands run under the host-preferred shell — the PowerShell family on Windows (git-bash only on machines without PowerShell), the login shell with bash preferred on POSIX — the same interpreter the host's builtin shell tool uses."
|
|
16147
16299
|
].join(`
|
|
16148
16300
|
`);
|
|
16149
16301
|
var forgeShellTool = tool({
|
|
@@ -16175,7 +16327,7 @@ var forgeShellTool = tool({
|
|
|
16175
16327
|
}
|
|
16176
16328
|
}
|
|
16177
16329
|
const { registry: registry2, fence } = ensureJobLifecycle();
|
|
16178
|
-
const cwd = args.workdir ? isAbsolute(args.workdir) ? args.workdir :
|
|
16330
|
+
const cwd = args.workdir ? isAbsolute(args.workdir) ? args.workdir : join6(state.worktree, args.workdir) : state.worktree;
|
|
16179
16331
|
const started = startJob(jobManager, {
|
|
16180
16332
|
cmd: args.command,
|
|
16181
16333
|
cwd,
|
|
@@ -16336,7 +16488,7 @@ var CREW_INIT_TEMPLATE = [
|
|
|
16336
16488
|
"",
|
|
16337
16489
|
"CREW IS NOT INITIALIZED: no forge subagents are configured (no usable forge.json agents). Refuse to enter orchestration and tell the user this feature needs a one-time setup, offering BOTH options:",
|
|
16338
16490
|
"",
|
|
16339
|
-
"1. The user configures it themselves: create
|
|
16491
|
+
"1. The user configures it themselves: create either layer — MERGED, the project layer overrides the global one per agent id (the project file is the nearest .opencode/forge.json walking up from the workspace root):",
|
|
16340
16492
|
"{paths}",
|
|
16341
16493
|
"",
|
|
16342
16494
|
"2. Or, ONLY if the user explicitly asks you to configure it in this conversation, you may write the file yourself through the normal write path. NEVER write forge.json without that explicit go-ahead.",
|
|
@@ -16382,15 +16534,24 @@ var crewBeginTool = tool({
|
|
|
16382
16534
|
if (active && active.doc.status === "draft") {
|
|
16383
16535
|
throw new Error("[forge:crew] A plan is in draft; crew orchestration is denied during planning. Call plan_approve for user approval, or /plan discard to abandon the plan.");
|
|
16384
16536
|
}
|
|
16385
|
-
const
|
|
16386
|
-
|
|
16387
|
-
|
|
16388
|
-
|
|
16389
|
-
|
|
16390
|
-
|
|
16537
|
+
const hostLoaded = forgeLoader?.load();
|
|
16538
|
+
const hostIds = hostLoaded ? Object.keys(hostLoaded.agents) : [];
|
|
16539
|
+
const anchor = sessionAnchor(context);
|
|
16540
|
+
const hostAnchorDir = forgeLoaderDir ?? "";
|
|
16541
|
+
const sessionLoaded = anchor && anchor !== hostAnchorDir ? loaderForAnchor(anchor).load() : hostLoaded;
|
|
16542
|
+
const sessionProject = sessionLoaded?.projectPath ?? null;
|
|
16543
|
+
if (hostIds.length === 0) {
|
|
16544
|
+
const paths = forgeConfigPaths({ projectDir: hostAnchorDir || undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
|
|
16545
|
+
const lines = [
|
|
16546
|
+
"[forge:crew] CREW IS NOT INITIALIZED on this host: no forge subagents are materialized. Crew orchestration is unavailable until the user sets it up.",
|
|
16547
|
+
`Two layers, MERGED (the project layer overrides the global one per agent id): ${paths.project} (nearest .opencode/forge.json walking up from the workspace) OR ${paths.global}.`,
|
|
16391
16548
|
'Template (JSONC): {"agents": {"research": {"model": "provider/model", "thoughtLevel": "low"}}}',
|
|
16392
16549
|
"The user may configure it themselves, or ask you to write the file — only on their explicit go-ahead. Newly added agents need a host restart; thoughtLevel edits apply without one."
|
|
16393
|
-
]
|
|
16550
|
+
];
|
|
16551
|
+
if (sessionLoaded && Object.keys(sessionLoaded.agents).length > 0 && sessionProject) {
|
|
16552
|
+
lines.push(`NOTE: this session's workspace DOES have a forge.json (${sessionProject}) with usable agents — but this host instance was launched from ${hostAnchorDir || "a different directory"}, so they are not in the task vocabulary. Relaunch opencode in that workspace, or add the agents to the global layer.`);
|
|
16553
|
+
}
|
|
16554
|
+
throw new Error(lines.join(`
|
|
16394
16555
|
`));
|
|
16395
16556
|
}
|
|
16396
16557
|
if (!args.objective?.trim())
|
|
@@ -16416,13 +16577,22 @@ var crewBeginTool = tool({
|
|
|
16416
16577
|
throw new Error(`[forge:crew] This session already has an active crew: "${existing.objective}" (started ${existing.startedAt}). Finish and close it with crew_close before starting another.`);
|
|
16417
16578
|
}
|
|
16418
16579
|
crews.set(context.sessionID, { objective: args.objective.trim(), startedAt: nowIso(), subtasks });
|
|
16580
|
+
let disclosure = "";
|
|
16581
|
+
if (sessionProject && sessionProject !== (hostLoaded?.projectPath ?? null)) {
|
|
16582
|
+
const localOnly = Object.keys(sessionLoaded?.agents ?? {}).filter((id) => !hostIds.includes(id));
|
|
16583
|
+
if (localOnly.length > 0) {
|
|
16584
|
+
disclosure = `
|
|
16585
|
+
[forge:crew] NOTE: this session's workspace has its own forge.json (${sessionProject}) defining ${localOnly.map((id) => `forge-${id}`).join(", ")} — NOT dispatchable on this host (launched from ${hostAnchorDir || "a different directory"}). Relaunch opencode in that workspace to use them, or fold them into the global layer.`;
|
|
16586
|
+
}
|
|
16587
|
+
}
|
|
16419
16588
|
return {
|
|
16420
16589
|
title: `crew registered: ${args.objective.trim().slice(0, 60)}`,
|
|
16421
16590
|
output: [
|
|
16422
16591
|
`[forge:crew] Crew registered for this session. Objective: ${args.objective.trim()}`,
|
|
16423
16592
|
`Declared plan (${subtasks.length}):`,
|
|
16424
16593
|
...subtasks.map((s, i) => ` ${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`),
|
|
16425
|
-
"Proceed with the discipline: waves of parallel native task calls (next wave after the previous results) → per-subtask evidence verdicts → at most one retry → crew_close with the full report covering every declared subtask."
|
|
16594
|
+
"Proceed with the discipline: waves of parallel native task calls (next wave after the previous results) → per-subtask evidence verdicts → at most one retry → crew_close with the full report covering every declared subtask.",
|
|
16595
|
+
...disclosure ? [disclosure] : []
|
|
16426
16596
|
].join(`
|
|
16427
16597
|
`)
|
|
16428
16598
|
};
|
|
@@ -16511,7 +16681,7 @@ var pendingContinuationTurn = new Set;
|
|
|
16511
16681
|
function goalProbe(line) {
|
|
16512
16682
|
if (process.env.FORGE_GOAL_PROBE) {
|
|
16513
16683
|
try {
|
|
16514
|
-
appendFileSync(
|
|
16684
|
+
appendFileSync(join6(tmpdir3(), "forge-goal-probe.log"), `${new Date().toISOString()} ${line}
|
|
16515
16685
|
`);
|
|
16516
16686
|
} catch {}
|
|
16517
16687
|
}
|
|
@@ -16726,14 +16896,14 @@ var server = async (input, options) => {
|
|
|
16726
16896
|
if (wdStallRaw !== undefined && wdStallRaw !== wdStall) {
|
|
16727
16897
|
wdFallbacks.push(`watchdog.stallMs ${JSON.stringify(String(wdStallRaw))} adjusted to ${wdStall} (floor/default applied)`);
|
|
16728
16898
|
}
|
|
16729
|
-
const watchdogLedger = createFileLedger(
|
|
16899
|
+
const watchdogLedger = createFileLedger(join6(watchdogLogDir(), "log.jsonl"));
|
|
16730
16900
|
const rawLocator = createLocator();
|
|
16731
16901
|
const probing = () => process.env.FORGE_WATCHDOG_PROBE === "1";
|
|
16732
16902
|
const probeLine = (text) => {
|
|
16733
16903
|
if (!probing())
|
|
16734
16904
|
return;
|
|
16735
16905
|
try {
|
|
16736
|
-
appendFileSync(
|
|
16906
|
+
appendFileSync(join6(tmpdir3(), "forge-watchdog-probe.log"), `${new Date().toISOString()} ${text}
|
|
16737
16907
|
`);
|
|
16738
16908
|
} catch {}
|
|
16739
16909
|
};
|
|
@@ -16789,6 +16959,7 @@ var server = async (input, options) => {
|
|
|
16789
16959
|
forgeDisabled = forgeUserCfg?.disable === true;
|
|
16790
16960
|
if (forgeDisabled)
|
|
16791
16961
|
return;
|
|
16962
|
+
setHostShell(typeof cfg.shell === "string" ? cfg.shell : undefined);
|
|
16792
16963
|
cfg.default_agent ??= FORGE_AGENT;
|
|
16793
16964
|
const existing = agentSection[FORGE_AGENT];
|
|
16794
16965
|
agentSection[FORGE_AGENT] = {
|
|
@@ -16893,7 +17064,7 @@ var server = async (input, options) => {
|
|
|
16893
17064
|
"tool.execute.before": async (input2, output) => {
|
|
16894
17065
|
if (process.env.FORGE_PERM_PROBE) {
|
|
16895
17066
|
try {
|
|
16896
|
-
appendFileSync(
|
|
17067
|
+
appendFileSync(join6(tmpdir3(), "forge-perm-probe.log"), `${new Date().toISOString()} before tool=${JSON.stringify(input2.tool)} session=${input2.sessionID}
|
|
16897
17068
|
`);
|
|
16898
17069
|
} catch {}
|
|
16899
17070
|
}
|
|
@@ -16903,7 +17074,7 @@ var server = async (input, options) => {
|
|
|
16903
17074
|
watchdog.track(input2.callID, input2.sessionID, input2.tool, undefined, cmdText !== undefined ? commandNeedle(cmdText) : undefined);
|
|
16904
17075
|
if (process.env.FORGE_WATCHDOG_PROBE) {
|
|
16905
17076
|
try {
|
|
16906
|
-
appendFileSync(
|
|
17077
|
+
appendFileSync(join6(tmpdir3(), "forge-watchdog-probe.log"), `${new Date().toISOString()} track ${input2.callID} needle=${JSON.stringify(cmdText !== undefined ? commandNeedle(cmdText) : undefined)} rawArgs=${JSON.stringify(output?.args ?? null).slice(0, 200)}
|
|
16907
17078
|
`);
|
|
16908
17079
|
} catch {}
|
|
16909
17080
|
}
|
|
@@ -16926,7 +17097,7 @@ var server = async (input, options) => {
|
|
|
16926
17097
|
if (raw) {
|
|
16927
17098
|
const state = stateForBan(input2.sessionID);
|
|
16928
17099
|
const base = state?.worktree ?? hostWorktree ?? "";
|
|
16929
|
-
const abs = (isAbsolute(raw) ? raw :
|
|
17100
|
+
const abs = (isAbsolute(raw) ? raw : join6(base, raw)).replaceAll("\\", "/").toLowerCase();
|
|
16930
17101
|
if (abs.includes("/.opencode/plan/") || abs.includes("/.opencode/goal/")) {
|
|
16931
17102
|
throw new Error(`[forge:partition] Write refused: "${raw}" is inside the forge state directories (.opencode/plan, .opencode/goal), which belong to the forge agent in this workspace.`);
|
|
16932
17103
|
}
|
|
@@ -17017,7 +17188,7 @@ var server = async (input, options) => {
|
|
|
17017
17188
|
const name = (typeof meta.tool === "string" ? meta.tool : undefined) ?? (typeof permissionField === "string" ? permissionField : undefined) ?? input2.id ?? input2.type;
|
|
17018
17189
|
if (process.env.FORGE_PERM_PROBE) {
|
|
17019
17190
|
try {
|
|
17020
|
-
appendFileSync(
|
|
17191
|
+
appendFileSync(join6(tmpdir3(), "forge-perm-probe.log"), `${new Date().toISOString()} ask name=${JSON.stringify(name)} in_status=${output.status} id=${JSON.stringify(input2.id)} type=${JSON.stringify(input2.type)} meta=${JSON.stringify(input2.metadata)}
|
|
17021
17192
|
`);
|
|
17022
17193
|
} catch {}
|
|
17023
17194
|
}
|
|
@@ -17071,7 +17242,7 @@ var server = async (input, options) => {
|
|
|
17071
17242
|
watchdog.markSeen(input2.callID);
|
|
17072
17243
|
if (process.env.FORGE_WATCHDOG_PROBE) {
|
|
17073
17244
|
try {
|
|
17074
|
-
appendFileSync(
|
|
17245
|
+
appendFileSync(join6(tmpdir3(), "forge-watchdog-probe.log"), `${new Date().toISOString()} mark ${input2.callID} hostpid=${process.pid}
|
|
17075
17246
|
`);
|
|
17076
17247
|
} catch {}
|
|
17077
17248
|
}
|
|
@@ -17081,7 +17252,7 @@ var server = async (input, options) => {
|
|
|
17081
17252
|
watchdog.untrack(input2.callID);
|
|
17082
17253
|
if (process.env.FORGE_WATCHDOG_PROBE) {
|
|
17083
17254
|
try {
|
|
17084
|
-
appendFileSync(
|
|
17255
|
+
appendFileSync(join6(tmpdir3(), "forge-watchdog-probe.log"), `${new Date().toISOString()} untrack ${input2.callID}
|
|
17085
17256
|
`);
|
|
17086
17257
|
} catch {}
|
|
17087
17258
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sorenllm/opencode-forge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Single general-purpose forge agent for opencode: static worker subagents defined in forge.json (pinned model + thoughtLevel pair, or an Auto worker inheriting the parent model; dispatched through the native task tool — TUI-visible), a plan harness (file-backed plans with approve/close confirmation gates and a hard draft write-ban), a goal harness (autonomous, host-verified objectives), a /crew orchestration command (declared-plan waves with an evidence-checked close gate), a job supervisor (forge_shell with exit-bound completion, idle/success early return, background jobs with poll/log/kill/handoff), and a builtin-shell hang watchdog.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|