@sorenllm/opencode-forge 0.6.0 → 0.8.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 +26 -15
  2. package/dist/index.js +46 -8
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -65,7 +65,11 @@ tool layer (verified against the host's `tools` filter):
65
65
 
66
66
  Escape hatches: `jobs.keepBuiltinShell: true` gives the forge family the
67
67
  builtin shell back; `jobs.mode: "native"` retires `forge_shell`/`forge_jobs`
68
- entirely (the hide is withdrawn in the same stroke). Mixed sessions (Tab
68
+ entirely (the hide is withdrawn in the same stroke). The forge family is
69
+ prompted to route every shell command through `forge_shell` — on hosts that
70
+ ignore injected tools maps the builtin tool stays visible but is refused at
71
+ call time, and the channel mandate is what keeps the model from trying it
72
+ first (the belt refusal remains the backstop). Mixed sessions (Tab
69
73
  between agents in one session): harness state (plans/goals) stays
70
74
  session-bound, tool surfaces follow the current speaker, and the goal loop
71
75
  parks instead of driving a non-forge turn. One deliberate exception: a
@@ -257,12 +261,12 @@ Subagents have it worse: the native task tool has no timeout at all.
257
261
  exit condition is bound to the actual exit event (structural immunity to
258
262
  the stdio-EOF bug):
259
263
 
260
- | condition | knob | on trigger |
261
- | --- | --- | --- |
262
- | process exit | — | final; exit code + output tail returned |
263
- | `success_pattern` regex matches new output | opt-in | completes as success; process kept alive by default (server semantics), `keep_alive: false` kills its tree |
264
- | `idle_ms` with no new output | default 60000 | early return `still-running` + `jobId`; process stays alive |
265
- | `max_wait_ms` hard cap | default 120000, max 600000 | early return `still-running`; never kills |
264
+ | condition | knob | on trigger | completion behavior |
265
+ | --- | --- | --- | --- |
266
+ | process exit | — | final; exit code + output tail returned | **terminal evidence** — output delivered in full; the job self-clears from the registry (no wake, no clear needed) |
267
+ | `success_pattern` regex matches new output | opt-in | completes as success; process kept alive by default (server semantics), `keep_alive: false` kills its tree | kept alive → later exit still wakes (the server dying is real news); killed inline → self-clears |
268
+ | `idle_ms` with no new output | default 60000 | early return `still-running` + `jobId`; process stays alive | later exit queues a wake; entry stays listed until cleared |
269
+ | `max_wait_ms` hard cap | default 120000, max 600000 | early return `still-running`; never kills | same as idle |
266
270
 
267
271
  `run_in_background: true` skips all waiting and returns
268
272
  `{jobId, logPath}` immediately. Every run pops one permission dialog
@@ -274,14 +278,21 @@ disagrees with that line, the line wins.
274
278
  **`forge_jobs`** manages the registry: `list` / `poll {jobId, waitMs≤30s}`
275
279
  (bounded wait for new output or exit, drains it) / `log {jobId, offset?,
276
280
  limit?}` (line paging over the on-disk log) / `kill` (whole process tree) /
277
- `clear` (drop a finished entry) / `handoff` (rebind ownership to the root
278
- session so a subagent's job survives the subagent). Delegated agents are
279
- instructed to poll their jobs before yielding a conclusion.
280
-
281
- **Exit wakes.** A job that exits after its `forge_shell` call already
282
- returned queues a single `[forge:job-complete]` message, delivered into the
283
- owning session via `promptAsync` the next time it goes idle (exactly once;
284
- `notify: false` opts out per job).
281
+ `clear` (drop a finished entry — only early-returned/background jobs need
282
+ this; synchronously consumed jobs self-clear at completion) / `handoff`
283
+ (rebind ownership to the root session so a subagent's job survives the
284
+ subagent). Delegated agents are instructed to poll their jobs before
285
+ yielding a conclusion.
286
+
287
+ **Exit wakes.** A job whose caller last saw it before completion —
288
+ `run_in_background` starts and `still-running`/kept-alive `success` early
289
+ returns — queues a single `[forge:job-complete]` message when it finishes,
290
+ delivered into the owning session via `promptAsync` the next time it goes
291
+ idle (exactly once; `notify: false` opts out per job). A foreground call
292
+ resolved on the exit event is terminal evidence: the caller already holds
293
+ the complete output, so no wake fires and the registry entry self-clears —
294
+ a stale `poll`/`kill`/`clear` on such an id answers "already consumed"
295
+ instead of "unknown job".
285
296
 
286
297
  **Ownership.** Jobs belong to the session that created them. Session
287
298
  deleted → its live session-scoped jobs are killed and the event is recorded
package/dist/index.js CHANGED
@@ -13416,12 +13416,30 @@ function createJobManager(opts = {}) {
13416
13416
  job.state = state;
13417
13417
  job.exitCode = exitCode;
13418
13418
  job.endedAt = now();
13419
- if (job.notify && job.wakeState === "none") {
13419
+ if (job.notify && job.wakeState === "none" && !job.terminalEvidence) {
13420
13420
  job.wakeState = "queued";
13421
13421
  job.wakeQueuedAt = now();
13422
13422
  }
13423
+ if (job.terminalEvidence) {
13424
+ jobs.delete(job.id);
13425
+ rememberConsumed(job.id);
13426
+ }
13423
13427
  enforceCaps();
13424
13428
  }
13429
+ const consumedIds = new Set;
13430
+ function rememberConsumed(id) {
13431
+ if (consumedIds.has(id))
13432
+ return;
13433
+ consumedIds.add(id);
13434
+ if (consumedIds.size > maxFinishedJobs) {
13435
+ const oldest = consumedIds.values().next().value;
13436
+ if (oldest !== undefined)
13437
+ consumedIds.delete(oldest);
13438
+ }
13439
+ }
13440
+ function consumed(id) {
13441
+ return consumedIds.has(id);
13442
+ }
13425
13443
  function kill(job) {
13426
13444
  if (isTerminal2(job))
13427
13445
  return;
@@ -13533,6 +13551,7 @@ function createJobManager(opts = {}) {
13533
13551
  deliverWakesFor,
13534
13552
  abandonStaleWakes,
13535
13553
  ledgerEntries,
13554
+ consumed,
13536
13555
  size: () => jobs.size
13537
13556
  };
13538
13557
  }
@@ -13640,6 +13659,11 @@ function startJob(manager, opts) {
13640
13659
  clearTimeout(maxWaitTimer);
13641
13660
  };
13642
13661
  const firstOutputHooks = [];
13662
+ const foregroundWatch = !opts.runInBackground;
13663
+ const stampTerminalEvidence = () => {
13664
+ if (foregroundWatch && !settled)
13665
+ job.terminalEvidence = true;
13666
+ };
13643
13667
  function resolveStillRunning() {
13644
13668
  if (settled)
13645
13669
  return;
@@ -13675,6 +13699,7 @@ function startJob(manager, opts) {
13675
13699
  closeSync(wfd);
13676
13700
  } catch {}
13677
13701
  }
13702
+ stampTerminalEvidence();
13678
13703
  manager.markTerminal(job, "killed", null);
13679
13704
  maxWaitTimer && clearTimeout(maxWaitTimer);
13680
13705
  resolveSettle({ status: "exited", exitCode: null, outputTail: "", spawnError: String(err) });
@@ -13726,6 +13751,8 @@ function startJob(manager, opts) {
13726
13751
  killTree(child);
13727
13752
  if (opts.survive)
13728
13753
  opts.registry?.remove(id);
13754
+ if (foregroundWatch)
13755
+ job.terminalEvidence = true;
13729
13756
  manager.markTerminal(job, "succeeded", null);
13730
13757
  }
13731
13758
  resolveSettle({ status: "succeeded", matched: m[0], outputTail: job.tail, keptAlive });
@@ -13747,6 +13774,7 @@ function startJob(manager, opts) {
13747
13774
  tail.flush();
13748
13775
  tail.stop();
13749
13776
  liveTails.delete(id);
13777
+ stampTerminalEvidence();
13750
13778
  manager.markTerminal(job, "exited", code);
13751
13779
  if (opts.survive)
13752
13780
  opts.registry?.remove(id);
@@ -13765,6 +13793,7 @@ function startJob(manager, opts) {
13765
13793
  if (opts.survive)
13766
13794
  opts.registry?.remove(id);
13767
13795
  if (!settled) {
13796
+ stampTerminalEvidence();
13768
13797
  settled = true;
13769
13798
  stopTimers();
13770
13799
  manager.markTerminal(job, "killed", null);
@@ -15407,6 +15436,12 @@ var FORGE_PROMPT = `You are forge — the single general-purpose coding agent. Y
15407
15436
  Workflow modes are strictly user-initiated. Never enter plan or goal mode — and never call a plan_* or goal_* tool — unless the user ran /plan or /goal, unmistakably asked for that mode (e.g. "plan first", "set a goal"), or the session already carries a [forge:plan-notice] / [forge:goal-notice] for a mode they started. Ordinary task requests are normal work. Mode-specific rules arrive with those commands and notices; when a notice is present, follow it.
15408
15437
 
15409
15438
  Subagent dispatch (task tool): when the work suits a scoped worker, prefer a configured \`forge-*\` subagent whose description matches the job. If none matches, say so plainly to the user and dispatch through the native task channel instead.`;
15439
+ function forgePromptText(opts) {
15440
+ const mandate = opts.hardRefusal ? "Exec surface: every shell command — quick ones included — runs through the forge_shell tool. The builtin shell/bash tools are refused on this agent; never try them first, not even for trivial commands." : "Exec surface: prefer the forge_shell tool for every shell command — quick ones included; it is this agent's exec surface, and the builtin shell exists only as an explicit escape hatch.";
15441
+ return `${FORGE_PROMPT}
15442
+
15443
+ ${mandate} Long-running or non-exiting commands: forge_shell with run_in_background, then collect results via forge_jobs.`;
15444
+ }
15410
15445
  var PLAN_COMMAND_TEMPLATE = [
15411
15446
  '(forge plan harness routing. Argument: "$ARGUMENTS")',
15412
15447
  "",
@@ -16095,7 +16130,7 @@ var FORGE_SHELL_DESCRIPTION = [
16095
16130
  "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).",
16096
16131
  "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.",
16097
16132
  "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`.",
16098
- "Long-running or possibly non-exiting commands (dev servers, watchers, installers, anything spawning detached children) MUST use this tool instead of the builtin shell."
16133
+ "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."
16099
16134
  ].join(`
16100
16135
  `);
16101
16136
  var forgeShellTool = tool({
@@ -16169,6 +16204,8 @@ var forgeShellTool = tool({
16169
16204
  output: [
16170
16205
  `[forge:job] Command finished (exit=${r.exitCode ?? "none"}).`,
16171
16206
  `cwd: ${cwd}`,
16207
+ `jobId: ${started.job.id}`,
16208
+ `Output was delivered in full above — terminal evidence: the job self-cleared from the registry (no wake fires, no forge_jobs clear needed).`,
16172
16209
  ...r.spawnError ? [`spawn error: ${r.spawnError}`] : [],
16173
16210
  `output:
16174
16211
  ${r.outputTail || "(none)"}`
@@ -16182,7 +16219,7 @@ ${r.outputTail || "(none)"}`
16182
16219
  output: [
16183
16220
  `[forge:job] Success pattern matched: "${r.matched}".`,
16184
16221
  `cwd: ${cwd}`,
16185
- r.keptAlive ? `The process was kept alive as job ${started.job.id} (stop it with forge_jobs kill when done).` : "The process tree was terminated (keep_alive=false).",
16222
+ r.keptAlive ? `The process was kept alive as job ${started.job.id} (stop it with forge_jobs kill when done).` : `The process tree was terminated (keep_alive=false) — terminal evidence: job ${started.job.id} self-cleared from the registry (no wake, no clear needed).`,
16186
16223
  `output:
16187
16224
  ${r.outputTail}`
16188
16225
  ].join(`
@@ -16205,7 +16242,7 @@ ${r.outputTail || "(none yet)"}`,
16205
16242
  });
16206
16243
  var FORGE_JOBS_ACTIONS = ["list", "poll", "log", "kill", "clear", "handoff"];
16207
16244
  var forgeJobsTool = tool({
16208
- description: "Manage forge_shell jobs. Actions: list (all jobs, newest first); poll {jobId, waitMs<=30000} — bounded wait for NEW output or exit, drains it; log {jobId, offset?, limit?} — line paging over the on-disk log (omitted offset = tail window, default 200 lines); kill {jobId} — terminate the job's whole process tree; clear {jobId} — drop a finished job from the registry; handoff {jobId} — rebind ownership to the root session so the job survives this (sub)session's end. A delegated agent MUST poll its jobs before yielding its conclusion.",
16245
+ description: "Manage forge_shell jobs. Actions: list (all jobs, newest first); poll {jobId, waitMs<=30000} — bounded wait for NEW output or exit, drains it; log {jobId, offset?, limit?} — line paging over the on-disk log (omitted offset = tail window, default 200 lines); kill {jobId} — terminate the job's whole process tree; clear {jobId} — drop a finished job from the registry (only early-returned/background jobs need this — synchronously consumed jobs self-clear at completion); handoff {jobId} — rebind ownership to the root session so the job survives this (sub)session's end. A delegated agent MUST poll its jobs before yielding its conclusion.",
16209
16246
  args: {
16210
16247
  action: tool.schema.string().describe(`One of: ${FORGE_JOBS_ACTIONS.join(", ")}`),
16211
16248
  jobId: tool.schema.string().optional().describe("Job id from forge_shell (required for every action except list)"),
@@ -16230,8 +16267,9 @@ var forgeJobsTool = tool({
16230
16267
  if (!args.jobId)
16231
16268
  throw new Error(`Action "${action}" requires jobId.`);
16232
16269
  const job = jobManager.get(args.jobId);
16233
- if (!job)
16234
- throw new Error(`No job ${args.jobId} in the registry (finished jobs age out; the on-disk log may still exist).`);
16270
+ if (!job) {
16271
+ throw new Error(jobManager.consumed(args.jobId) ? `Job ${args.jobId} was already consumed: a terminal-evidence foreground return delivered its complete output inline, so it self-cleared from the registry — no wake fires and no clear is needed.` : `No job ${args.jobId} in the registry (finished jobs age out; the on-disk log may still exist).`);
16272
+ }
16235
16273
  if (action === "poll") {
16236
16274
  const p = await pollJob(jobManager, args.jobId, args.waitMs ?? 0);
16237
16275
  if (!p)
@@ -16744,7 +16782,7 @@ var server = async (input, options) => {
16744
16782
  ...existing ?? {},
16745
16783
  description: existing?.description ?? "forge — the single general-purpose coding agent: takes implementation tasks directly; planning goes through /plan into the plan harness (plans land in .opencode/plan/, with approve/close confirmation gates and tick discipline enforced by tools and the permission layer).",
16746
16784
  mode: existing?.mode ?? "primary",
16747
- prompt: existing?.prompt ?? FORGE_PROMPT
16785
+ prompt: existing?.prompt ?? (jobStage() < 2 ? forgePromptText({ hardRefusal: !jobsKeepBuiltinShell }) : FORGE_PROMPT)
16748
16786
  };
16749
16787
  if (!jobsKeepBuiltinShell && jobStage() < 2) {
16750
16788
  const entry = agentSection[FORGE_AGENT];
@@ -17051,7 +17089,7 @@ var server = async (input, options) => {
17051
17089
  output.system.push(`[forge:crew-active] A crew is ACTIVE in this session: "${crewActive.objective}" (started ${crewActive.startedAt}). A second /crew must be refused; finish with crew_close when every subtask has a verdict.`);
17052
17090
  }
17053
17091
  if (!forgeDisabled && jobStage() < 2 && !output.system.some((s) => s.startsWith("[forge:job-guidance]"))) {
17054
- output.system.push("[forge:job-guidance] Long-running or possibly non-exiting shell commands (dev servers, watchers, installers, anything spawning detached children) go through forge_shell, never the builtin shell: it returns on idle/success/exit with a jobId instead of blocking indefinitely; manage jobs with forge_jobs. Delegated agents: collect your job results with forge_jobs poll before yielding your conclusion.");
17092
+ output.system.push(jobsKeepBuiltinShell ? "[forge:job-guidance] Long-running or possibly non-exiting shell commands (dev servers, watchers, installers, anything spawning detached children) go through forge_shell, never the builtin shell: it returns on idle/success/exit with a jobId instead of blocking indefinitely; manage jobs with forge_jobs. Delegated agents: collect your job results with forge_jobs poll before yielding your conclusion." : "[forge:job-guidance] Every shell command — quick ones included — goes through forge_shell: the builtin shell/bash tools are refused on this agent, so never try them first. forge_shell returns on exit/success/idle with a jobId instead of blocking indefinitely; long-running or non-exiting commands use run_in_background. Manage jobs with forge_jobs. Delegated agents: collect your job results with forge_jobs poll before yielding your conclusion.");
17055
17093
  }
17056
17094
  const state = sessions.get(input2.sessionID);
17057
17095
  if (!state)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sorenllm/opencode-forge",
3
- "version": "0.6.0",
3
+ "version": "0.8.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",