@sorenllm/opencode-forge 0.4.0 → 0.5.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 +67 -29
  2. package/dist/index.js +146 -31
  3. package/package.json +64 -64
package/README.md CHANGED
@@ -33,8 +33,10 @@ OpenSpec spec workflows remain a third, separate lane.
33
33
  - Plan files: `.opencode/plan/<date>-<slug>.md` in your project, frontmatter
34
34
  state machine `draft → approved → done` (exit: `abandoned`).
35
35
  - While a plan is in draft, `write` / `edit` / `bash` / `task` are **denied
36
- at the permission layer** — including your own `allow` config. The only
37
- exits are approval and discard. This is deliberate; see Design stance.
36
+ at the permission layer** — including your own `allow` config, and for
37
+ every agent in that session (the draft protects shared session state; see
38
+ Agent partition below). The only exits are approval and discard. This is
39
+ deliberate; see Design stance.
38
40
  - `plan_approve` / `plan_close` are pinned to a confirmation dialog: the
39
41
  model can never flip the state itself.
40
42
  - Goal files: `.opencode/goal/<date>-<slug>.md`, state machine
@@ -42,9 +44,40 @@ OpenSpec spec workflows remain a third, separate lane.
42
44
  session plus a workspace queue. Completion is **verified by the plugin**,
43
45
  not attested by the model: `goal_complete` re-executes every check itself
44
46
  and refuses (fail-closed) on any failure.
45
- - The native `build` / `plan` agents are hidden while the plugin is loaded
46
- (runtime injection, nothing written to your config). Uninstall restores
47
- them automatically; plan and goal files are never deleted.
47
+ - The native `build` / `plan` agents **stay installed and independent**:
48
+ forge coexists with them in the Tab cycle. The plugin hard-partitions the
49
+ two worlds (see Agent partition below) instead of hiding anything; plan
50
+ and goal files are never deleted on uninstall.
51
+
52
+ ## Agent partition (who sees what)
53
+
54
+ The plugin draws a hard line between two agent families, enforced at the
55
+ tool layer (verified against the host's `tools` filter):
56
+
57
+ | | **forge family** — `forge` + your forge.json `forge-*` workers | **everyone else** — native `build` / `plan` / `general` / `explore` and your own agents |
58
+ | --- | --- | --- |
59
+ | exec surface | `forge_shell` / `forge_jobs` only; builtin `shell`/`bash` hidden (unconditional, probe-independent; covers user-defined `agent.forge` entries too) | builtin `shell`/`bash`, fully native |
60
+ | forge tools (`plan_*`, `goal_*`, `crew_*`, `forge_shell`, `forge_jobs`) | `forge` sees all; `forge-*` workers only `forge_shell` + `forge_jobs` | none visible |
61
+ | dispatching `forge-*` via `task` | allowed | refused at the tool layer |
62
+ | writing `.opencode/plan/` / `.opencode/goal/` | allowed (state machine governs) | refused at the tool layer (reads stay open) |
63
+ | forge system prompts (job guidance, plan/goal notices) | injected | never injected — zero forge text |
64
+ | watchdog (stuck builtin shell) | governed | untouched — no markers, no kills |
65
+
66
+ Escape hatches: `jobs.keepBuiltinShell: true` gives the forge family the
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
69
+ between agents in one session): harness state (plans/goals) stays
70
+ session-bound, tool surfaces follow the current speaker, and the goal loop
71
+ parks instead of driving a non-forge turn. One deliberate exception: a
72
+ draft-phase write ban binds the **session** (protecting shared state), so a
73
+ non-forge agent is write-blocked until the draft is approved or discarded in
74
+ forge. Running `/plan` `/goal` `/crew` under a non-forge agent redirects to
75
+ forge instead of executing.
76
+
77
+ **Default subject.** The host otherwise defaults new sessions to `build`
78
+ once a build agent exists — the plugin pins `default_agent: "forge"`
79
+ automatically (a `default_agent` you set yourself always wins), so bare
80
+ runs and fresh sessions still land on forge under coexistence.
48
81
 
49
82
  ## Install
50
83
 
@@ -78,18 +111,19 @@ the plugin never writes them):
78
111
  "forge": {
79
112
  "model": "provider/model", // pick any model for forge
80
113
  "disable": true // one-knob return to native: no forge,
81
- // build/plan restored, no tools/commands
114
+ // no injections at all, no tools/commands
82
115
  }
83
116
  },
84
117
  // per-plugin options ride the plugin entry as a [spec, options] tuple
85
118
  "plugin": [
86
119
  ["@sorenllm/opencode-forge", {
87
120
  "jobs": {
88
- "mode": "auto", // "auto" (default) | "forge" | "native" — see the stage matrix below
89
- "keepBuiltinShell": false // stage 0: keep the builtin shell visible alongside forge_shell
121
+ "mode": "auto", // "auto" (default) | "forge" — equivalent, both keep the partition
122
+ // | "native" — retire the supervisor, builtin shell returns
123
+ "keepBuiltinShell": false // true: give the forge family the builtin shell back (the one exec escape hatch)
90
124
  },
91
125
  "watchdog": {
92
- "mode": "kill", // "kill" (default) | "dry-run" | "off"
126
+ "mode": "kill", // "kill" (default) | "dry-run" | "off" — forge-family sessions only
93
127
  "stallMs": 600000 // stall threshold, min 60000
94
128
  }
95
129
  }]
@@ -110,7 +144,8 @@ and the plugin's command of that name is not registered.
110
144
  `~/.cache/opencode/packages/github_ChengZiiii/opencode-forge/`).
111
145
  3. Delete the `agent["forge"]` block from your config if you added one
112
146
  (otherwise the name lingers in the agent list).
113
- 4. Done — the hidden native `build`/`plan` agents come back automatically
147
+ 4. Done — all runtime injections (agents, tool hiding, commands) are gone
148
+ with the plugin; nothing was ever written to your config
114
149
  (the hide was runtime-only). Your `.opencode/plan/` and `.opencode/goal/`
115
150
  files are yours; delete them yourself if you want.
116
151
  5. Optional runtime debris: delete `<tmp>/opencode-forge/` (job logs, the
@@ -126,7 +161,7 @@ What this plugin touches, exhaustively:
126
161
  | --- | --- | --- |
127
162
  | `<project>/.opencode/plan/*.md` | plan files | user data — kept forever, uninstall never deletes |
128
163
  | `<project>/.opencode/goal/*.md` | goal files (contract, Check Log, Turn Ledger) | user data — kept forever, uninstall never deletes |
129
- | merged config object (RAM only) | forge agent, static `forge-<id>` subagents, native build/plan `disable`, `command.plan`, `command.goal`, `command.crew`, goal permission keys, `permission.forge_shell`, `permission.crew_close`, stage-0 builtin shell hide | vanishes when the plugin is removed; nothing is written to disk |
164
+ | merged config object (RAM only) | forge agent, static `forge-<id>` subagents, tool-partition injections (forge family shell hide, non-forge forge-tool hide, native build/plan/general/explore minimal entries), `command.plan`, `command.goal`, `command.crew`, goal permission keys, `permission.forge_shell`, `permission.crew_close` | vanishes when the plugin is removed; nothing is written to disk |
130
165
  | `<tmp>/opencode-forge/jobs/<jobId>.log` | job output tee (full output; oldest rotated out above 50 files) | runtime debris — delete freely, also after uninstall |
131
166
  | `<tmp>/opencode-forge/jobs/ledger.jsonl` | job registry ledger (bounded: 1 MB reset, 200 entries) | runtime debris — delete freely, also after uninstall |
132
167
  | `<tmp>/opencode-forge/jobs/registry.json` | persistent survivor registry (bounded: 100 entries) | runtime debris — after uninstall, kill any still-running `survive` jobs yourself first |
@@ -293,19 +328,20 @@ as usual. Config `jobs.survive: "always"` flips the per-call default; config
293
328
  whose pid died are detected at next start, ledgered as orphans, and dropped
294
329
  from the registry. Stop survivors explicitly — nothing else will.
295
330
 
296
- ### Stage matrix (future compatibility, by design)
331
+ ### Mode matrix (supervisor lifecycle)
297
332
 
298
- OpenCode upstream is converging on native backgrounding (PRs #47231 /
299
- #50276, umbrella #34366). The supervisor degrades ahead of it:
333
+ The old three-stage capability probe is retired: the forge-side partition
334
+ holds regardless of what the host's builtin shell can do, so `auto` and
335
+ `forge` are equivalent. Only the manual `native` mode retires the
336
+ supervisor:
300
337
 
301
- | stage | how you get there | builtin shell | forge_shell / forge_jobs |
338
+ | mode | how you get there | builtin shell (forge family) | forge_shell / forge_jobs |
302
339
  | --- | --- | --- | --- |
303
- | 0 — full forge path | default (`jobs.mode: "auto"`, no native support detected) | hidden on the plugin-created forge agent (runtime injection only) | registered; the exec surface |
304
- | 1 — native backgrounding detected | automatic: `config.experimental` background flag, or the builtin shell's schema grows `run_in_background` | visible again | still registered as the additive layer (idle/success/wake supervision); its description now points plain backgrounding at the native parameter |
305
- | 2 — native confirmed complete | manual only: `jobs.mode: "native"` (never auto-detected — completeness is a semantic judgement) | visible | retired; calls throw with a pointer to the native parameter |
340
+ | auto (default) / forge | nothing to do | hidden — the exec surface is forge_shell, always | registered |
341
+ | native | manual only: `jobs.mode: "native"` | restored | retired; calls throw with a pointer to the native parameter |
306
342
 
307
- Pin `jobs.mode: "forge"` to stay on stage 0 forever; `jobs.keepBuiltinShell:
308
- true` keeps the builtin shell visible at any stage.
343
+ `jobs.keepBuiltinShell: true` gives the forge family the builtin shell back
344
+ in any mode (the watchdog keeps governing those calls).
309
345
 
310
346
  ### Artifacts and uninstall additions
311
347
 
@@ -453,10 +489,11 @@ delete the directory freely, also after uninstalling.
453
489
  ## Hang watchdog (a stuck builtin shell unblocks itself)
454
490
 
455
491
  `forge_shell` is structurally immune to the stdio-EOF hang, but the builtin
456
- `shell`/`bash` tool can still hang outside it — user-defined agents keep
457
- the builtin shell, and the stage matrix above restores it at stage 1/2.
458
- The watchdog is the independent backstop for those paths, in **every**
459
- session (primary and delegated subagents alike):
492
+ `shell`/`bash` tool can still hang outside it — under `jobs.mode: "native"`
493
+ or `jobs.keepBuiltinShell: true`, forge-family sessions carry it again. The
494
+ watchdog is the independent backstop for those paths, in **forge-family
495
+ sessions only** (primary and delegated forge-* workers alike) — non-forge
496
+ agents' builtin shells are entirely native: no markers, no timing, no kills:
460
497
 
461
498
  1. The host's shell environment hook stamps each builtin shell call's
462
499
  process with a plugin-namespaced marker (`FORGE_WATCHDOG_MARK`), and the
@@ -519,11 +556,12 @@ read/grep/glob; if you genuinely need a shell command to decide the plan,
519
556
  approve the plan first (revising after approval is allowed via a new `/plan`).
520
557
  The escape hatches are `plan_approve` and `/plan discard`, by design.
521
558
 
522
- Separately, at stage 0 the builtin shell is hidden on the plugin-created
523
- forge agent on purpose — `forge_shell` is the exec surface there (see the
524
- job supervisor chapter above). A **user-defined** `agent.forge` entry,
525
- `jobs.keepBuiltinShell: true`, or any stage ≥ 1 keeps the builtin shell
526
- visible.
559
+ Separately, the builtin shell is hidden on the forge agent on purpose —
560
+ `forge_shell` is the exec surface there (see Agent partition and the job
561
+ supervisor chapter above). The hide is unconditional: it covers a
562
+ **user-defined** `agent.forge` entry too. The escape hatches are
563
+ `jobs.keepBuiltinShell: true` (builtin shell back on the forge family) and
564
+ `jobs.mode: "native"` (supervisor retired entirely).
527
565
 
528
566
  A process restart forgets the session binding: the write-ban soft-disables
529
567
  (safety over strictness) and the next session's system notice + `/plan
package/dist/index.js CHANGED
@@ -14867,6 +14867,7 @@ function validateCrewReport(report, declared) {
14867
14867
  var WORKER_MANDATES = [
14868
14868
  "Mandates (non-negotiable):",
14869
14869
  "- Use workspace-relative paths ONLY. Never write to or read from absolute paths outside this workspace; the workspace root is where you were started, not the filesystem root.",
14870
+ "- Run every shell command through the forge_shell tool (the builtin shell is not part of your toolset by design). Long-running or non-exiting commands: forge_shell with run_in_background, then collect results via forge_jobs poll before yielding.",
14870
14871
  "- If any tool call is refused or denied, report the refusal VERBATIM in your final answer and stop that line of work. Never improvise a workaround around a permission denial.",
14871
14872
  "- End with conclusions backed by evidence references (file:line, command output, or the exact file/content you produced)."
14872
14873
  ].join(`
@@ -14912,7 +14913,8 @@ function forgeAgentDef(agentId, def) {
14912
14913
  shape,
14913
14914
  role: rolePromptFor(agentId, def)
14914
14915
  }),
14915
- permission
14916
+ permission,
14917
+ tools: { shell: false, bash: false }
14916
14918
  };
14917
14919
  }
14918
14920
 
@@ -15363,6 +15365,25 @@ function createForgeConfigLoader(deps) {
15363
15365
 
15364
15366
  // plugin.ts
15365
15367
  var FORGE_AGENT = "forge";
15368
+ var FORGE_STATE_TOOLS = [
15369
+ "plan_write",
15370
+ "plan_tick",
15371
+ "plan_approve",
15372
+ "plan_close",
15373
+ "plan_discard",
15374
+ "goal_write",
15375
+ "goal_check",
15376
+ "goal_complete",
15377
+ "goal_pause",
15378
+ "goal_resume",
15379
+ "goal_discard",
15380
+ "crew_begin",
15381
+ "crew_close"
15382
+ ];
15383
+ var FORGE_EXEC_TOOLS = ["forge_shell", "forge_jobs"];
15384
+ var FORGE_ALL_TOOLS = [...FORGE_STATE_TOOLS, ...FORGE_EXEC_TOOLS];
15385
+ var FORGE_TOOL_SET = new Set(FORGE_ALL_TOOLS);
15386
+ var NATIVE_PARTITION_FALLBACKS = ["build", "plan", "general", "explore"];
15366
15387
  var FORGE_PROMPT = `You are forge — the single general-purpose coding agent. You handle every task directly: exploration, planning, implementation, and verification. There is no agent switching.
15367
15388
 
15368
15389
  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.
@@ -15371,6 +15392,8 @@ Subagent dispatch (task tool): when the work suits a scoped worker, prefer a con
15371
15392
  var PLAN_COMMAND_TEMPLATE = [
15372
15393
  '(forge plan harness routing. Argument: "$ARGUMENTS")',
15373
15394
  "",
15395
+ "FAMILY GUARD (step 0, before anything else): if you do not have the plan_write tool in your available tools, you are not in a forge session — STOP now: execute nothing below (including the directory listing), and tell the user that /plan belongs to the forge agent, who should switch to forge (Tab) and rerun it there.",
15396
+ "",
15374
15397
  "Current .opencode/plan/ directory:",
15375
15398
  "!`ls -1 .opencode/plan 2>/dev/null || echo '(empty)'`",
15376
15399
  "",
@@ -15397,6 +15420,8 @@ var PLAN_COMMAND_TEMPLATE = [
15397
15420
  var GOAL_COMMAND_TEMPLATE = [
15398
15421
  '(forge goal harness routing. Argument: "$ARGUMENTS")',
15399
15422
  "",
15423
+ "FAMILY GUARD (step 0, before anything else): if you do not have the goal_write tool in your available tools, you are not in a forge session — STOP now: execute nothing below (including the directory listing), and tell the user that /goal belongs to the forge agent, who should switch to forge (Tab) and rerun it there.",
15424
+ "",
15400
15425
  "Current .opencode/goal/ directory:",
15401
15426
  "!`ls -1 .opencode/goal 2>/dev/null || echo '(empty)'`",
15402
15427
  "",
@@ -15425,6 +15450,23 @@ function effectiveWorktree(worktree, fallback) {
15425
15450
  return (fallback ?? "").trim();
15426
15451
  }
15427
15452
  var sessions = new Map;
15453
+ var sessionAgents = new Map;
15454
+ function isForgeFamilyAgent(name) {
15455
+ if (typeof name !== "string")
15456
+ return false;
15457
+ const n = name.toLowerCase();
15458
+ return n === FORGE_AGENT || n.startsWith("forge-");
15459
+ }
15460
+ function sessionIsForgeFamily(sessionID) {
15461
+ if (!sessionID)
15462
+ return false;
15463
+ return isForgeFamilyAgent(sessionAgents.get(sessionID));
15464
+ }
15465
+ function noteToolAgent(context) {
15466
+ if (context && typeof context.sessionID === "string" && context.sessionID && typeof context.agent === "string" && context.agent) {
15467
+ sessionAgents.set(context.sessionID, context.agent);
15468
+ }
15469
+ }
15428
15470
  var forgeDisabled = false;
15429
15471
  var forgeLoader = null;
15430
15472
  var forgeLoaderDir = null;
@@ -15489,13 +15531,10 @@ function ensureJobLifecycle() {
15489
15531
  });
15490
15532
  return { registry: registry2, fence: jobFence };
15491
15533
  }
15492
- var nativeBackgroundSeen = false;
15493
15534
  function jobStage() {
15494
15535
  if (jobsMode === "native")
15495
15536
  return 2;
15496
- if (jobsMode === "forge")
15497
- return 0;
15498
- return nativeBackgroundSeen ? 1 : 0;
15537
+ return 0;
15499
15538
  }
15500
15539
  var jobLogDir = jobsLogDir();
15501
15540
  var jobLedgerPath = join5(jobLogDir, "ledger.jsonl");
@@ -15577,6 +15616,7 @@ function ensureSession(sessionID, worktree) {
15577
15616
  return state;
15578
15617
  }
15579
15618
  function worktreeFor(context) {
15619
+ noteToolAgent(context);
15580
15620
  return effectiveWorktree(context.worktree, hostWorktree) || context.worktree;
15581
15621
  }
15582
15622
  function resolveActivePlan(state) {
@@ -16040,7 +16080,6 @@ var FORGE_SHELL_DESCRIPTION = [
16040
16080
  "Long-running or possibly non-exiting commands (dev servers, watchers, installers, anything spawning detached children) MUST use this tool instead of the builtin shell."
16041
16081
  ].join(`
16042
16082
  `);
16043
- var STAGE1_NOTE = "Stage note: this host's builtin shell already offers a native run_in_background parameter — prefer that for plain backgrounding; keep using forge_shell when you need idle/success_pattern early return or exit wake messages.";
16044
16083
  var forgeShellTool = tool({
16045
16084
  description: FORGE_SHELL_DESCRIPTION,
16046
16085
  args: {
@@ -16159,6 +16198,7 @@ var forgeJobsTool = tool({
16159
16198
  execute: async (args, context) => {
16160
16199
  if (jobStage() >= 2)
16161
16200
  throw new Error("[forge] The job supervisor is retired on this host.");
16201
+ noteToolAgent(context);
16162
16202
  const action = String(args.action ?? "");
16163
16203
  if (!FORGE_JOBS_ACTIONS.includes(action)) {
16164
16204
  throw new Error(`Unknown action "${action}" — use one of: ${FORGE_JOBS_ACTIONS.join(", ")}.`);
@@ -16223,6 +16263,8 @@ ${p.newOutput || "(none in this window)"}`,
16223
16263
  var CREW_INIT_TEMPLATE = [
16224
16264
  '(forge crew orchestration. Argument: "$ARGUMENTS")',
16225
16265
  "",
16266
+ "FAMILY GUARD (step 0, before anything else): if you do not have the crew_begin tool in your available tools, you are not in a forge session — STOP now and tell the user that /crew belongs to the forge agent, who should switch to forge (Tab) and rerun it there.",
16267
+ "",
16226
16268
  "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:",
16227
16269
  "",
16228
16270
  "1. The user configures it themselves: create ONE of these files (project-level wins, never merged):",
@@ -16240,6 +16282,8 @@ function crewOrchestrationTemplate(agentIds) {
16240
16282
  return [
16241
16283
  '(forge crew orchestration. Argument: "$ARGUMENTS")',
16242
16284
  "",
16285
+ "FAMILY GUARD (step 0, before anything else): if you do not have the crew_begin tool in your available tools, you are not in a forge session — STOP now and tell the user that /crew belongs to the forge agent, who should switch to forge (Tab) and rerun it there.",
16286
+ "",
16243
16287
  `Configured forge subagents (task-tool vocabulary): ${agentIds.map((id) => `forge-${id}`).join(", ")}`,
16244
16288
  "",
16245
16289
  "You are entering CREW ORCHESTRATION discipline for this session (single subject: do not switch agents). Follow it exactly:",
@@ -16263,6 +16307,7 @@ var crewBeginTool = tool({
16263
16307
  })).describe("The declared subtask plan — decompose the objective into these before any execution; crew_close cross-checks the report against exactly this list")
16264
16308
  },
16265
16309
  execute: async (args, context) => {
16310
+ noteToolAgent(context);
16266
16311
  const state = stateForBan(context.sessionID);
16267
16312
  const active = state ? resolveActivePlan(state) : null;
16268
16313
  if (active && active.doc.status === "draft") {
@@ -16325,6 +16370,7 @@ var crewCloseTool = tool({
16325
16370
  })).describe("The full crew report — one entry per declared subtask")
16326
16371
  },
16327
16372
  execute: async (args, context) => {
16373
+ noteToolAgent(context);
16328
16374
  const crew = crews.get(context.sessionID);
16329
16375
  if (!crew)
16330
16376
  throw new Error("[forge:crew] No active crew in this session (crew state is in-memory and dies on host restart). Start one with /crew <objective>.");
@@ -16509,6 +16555,10 @@ async function continueIfEligible(client, sessionID) {
16509
16555
  goalProbe(`skip: not goal owner session=${sessionID} owner=${goal.doc.session || "(none)"}`);
16510
16556
  return;
16511
16557
  }
16558
+ if (!sessionIsForgeFamily(sessionID)) {
16559
+ goalProbe(`skip: agent switched to non-forge before send session=${sessionID}`);
16560
+ return;
16561
+ }
16512
16562
  if (accountedContinuationTurn) {
16513
16563
  if (turnHadActivity) {
16514
16564
  noProgressStreak.delete(sessionID);
@@ -16574,6 +16624,10 @@ async function continueIfEligible(client, sessionID) {
16574
16624
  function scheduleIdleContinuation(client, sessionID) {
16575
16625
  if (forgeDisabled)
16576
16626
  return;
16627
+ if (!sessionIsForgeFamily(sessionID)) {
16628
+ goalProbe(`skip: non-forge agent at schedule session=${sessionID}`);
16629
+ return;
16630
+ }
16577
16631
  const existing = idleTimers.get(sessionID);
16578
16632
  if (existing)
16579
16633
  clearTimeout(existing);
@@ -16666,23 +16720,15 @@ var server = async (input, options) => {
16666
16720
  forgeDisabled = forgeUserCfg?.disable === true;
16667
16721
  if (forgeDisabled)
16668
16722
  return;
16669
- for (const native of ["build", "plan"]) {
16670
- agentSection[native] = { ...agentSection[native] ?? {}, disable: true };
16671
- }
16723
+ cfg.default_agent ??= FORGE_AGENT;
16672
16724
  const existing = agentSection[FORGE_AGENT];
16673
- const userDefinedForge = existing !== undefined;
16674
16725
  agentSection[FORGE_AGENT] = {
16675
16726
  ...existing ?? {},
16676
16727
  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).",
16677
16728
  mode: existing?.mode ?? "primary",
16678
16729
  prompt: existing?.prompt ?? FORGE_PROMPT
16679
16730
  };
16680
- try {
16681
- const exp = JSON.stringify(cfg.experimental ?? "");
16682
- if (/background/i.test(exp))
16683
- nativeBackgroundSeen = true;
16684
- } catch {}
16685
- if (!userDefinedForge && !jobsKeepBuiltinShell && jobStage() < 1) {
16731
+ if (!jobsKeepBuiltinShell && jobStage() < 2) {
16686
16732
  const entry = agentSection[FORGE_AGENT];
16687
16733
  entry.tools = { ...entry.tools ?? {}, shell: false, bash: false };
16688
16734
  }
@@ -16721,6 +16767,34 @@ var server = async (input, options) => {
16721
16767
  agentSection[id] = entry;
16722
16768
  materializedSnapshot[id] = entry;
16723
16769
  }
16770
+ for (const id of Object.keys(agentSection)) {
16771
+ if (!id.startsWith("forge-"))
16772
+ continue;
16773
+ const entry = agentSection[id];
16774
+ const tools = { ...entry.tools ?? {} };
16775
+ for (const t of FORGE_STATE_TOOLS)
16776
+ if (tools[t] !== true)
16777
+ tools[t] = false;
16778
+ entry.tools = tools;
16779
+ }
16780
+ for (const [id, entry] of Object.entries(agentSection)) {
16781
+ if (id === FORGE_AGENT || id.startsWith("forge-"))
16782
+ continue;
16783
+ const e = entry;
16784
+ const tools = { ...e.tools ?? {} };
16785
+ for (const t of FORGE_ALL_TOOLS)
16786
+ if (tools[t] !== true)
16787
+ tools[t] = false;
16788
+ e.tools = tools;
16789
+ }
16790
+ for (const native of NATIVE_PARTITION_FALLBACKS) {
16791
+ if (agentSection[native] !== undefined)
16792
+ continue;
16793
+ const tools = {};
16794
+ for (const t of FORGE_ALL_TOOLS)
16795
+ tools[t] = false;
16796
+ agentSection[native] = { tools };
16797
+ }
16724
16798
  if (process.env.FORGE_TEST_NO_DISPATCH_FETCH === "1") {
16725
16799
  modelsDevCatalog = null;
16726
16800
  } else {
@@ -16754,7 +16828,7 @@ var server = async (input, options) => {
16754
16828
  `);
16755
16829
  } catch {}
16756
16830
  }
16757
- if (watchdog.mode !== "off" && (input2.tool === "shell" || input2.tool === "bash")) {
16831
+ if (watchdog.mode !== "off" && (input2.tool === "shell" || input2.tool === "bash") && sessionIsForgeFamily(input2.sessionID)) {
16758
16832
  const a = output?.args ?? {};
16759
16833
  const cmdText = typeof a.command === "string" ? a.command : typeof a.cmd === "string" ? a.cmd : undefined;
16760
16834
  watchdog.track(input2.callID, input2.sessionID, input2.tool, undefined, cmdText !== undefined ? commandNeedle(cmdText) : undefined);
@@ -16765,14 +16839,58 @@ var server = async (input, options) => {
16765
16839
  } catch {}
16766
16840
  }
16767
16841
  }
16842
+ if (input2.tool === "task") {
16843
+ const speaker = sessionAgents.get(input2.sessionID);
16844
+ if (speaker !== undefined && !isForgeFamilyAgent(speaker)) {
16845
+ const a = output?.args ?? {};
16846
+ const target = typeof a.subagent_type === "string" ? a.subagent_type : typeof a.agent === "string" ? a.agent : undefined;
16847
+ if (target && target.toLowerCase().startsWith("forge-")) {
16848
+ throw new Error(`[forge:partition] Dispatch refused: "${target}" is a forge-family worker and the current agent ("${speaker}") is outside the forge family. Use the native general/explore subagents, or switch to the forge agent to dispatch forge-* workers.`);
16849
+ }
16850
+ }
16851
+ }
16852
+ if ((input2.tool === "write" || input2.tool === "edit") && input2.sessionID) {
16853
+ const speaker = sessionAgents.get(input2.sessionID);
16854
+ if (speaker !== undefined && !isForgeFamilyAgent(speaker)) {
16855
+ const a = output?.args ?? {};
16856
+ const raw = typeof a.filePath === "string" ? a.filePath : typeof a.path === "string" ? a.path : undefined;
16857
+ if (raw) {
16858
+ const state = stateForBan(input2.sessionID);
16859
+ const base = state?.worktree ?? hostWorktree ?? "";
16860
+ const abs = (isAbsolute(raw) ? raw : join5(base, raw)).replaceAll("\\", "/").toLowerCase();
16861
+ if (abs.includes("/.opencode/plan/") || abs.includes("/.opencode/goal/")) {
16862
+ 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.`);
16863
+ }
16864
+ }
16865
+ }
16866
+ }
16768
16867
  if (typeof input2.tool === "string" && isWriteTool(input2.tool)) {
16769
16868
  const state = stateForBan(input2.sessionID);
16770
16869
  const active = state ? resolveActivePlan(state) : null;
16771
16870
  if (active && active.doc.status === "draft") {
16772
- throw new Error(`[forge] A plan is in draft; write operations are denied (${input2.tool}). Present the plan summary and call plan_approve for user approval, or /plan discard to abandon it.`);
16871
+ throw new Error(`[forge] A plan is in draft; write operations are denied (${input2.tool}). Switch back to the forge agent to present it and complete plan approval (plan_approve), or run /plan discard to abandon the plan.`);
16872
+ }
16873
+ }
16874
+ if (input2.sessionID && typeof input2.tool === "string") {
16875
+ const speaker = sessionAgents.get(input2.sessionID);
16876
+ if (speaker !== undefined) {
16877
+ if (!isForgeFamilyAgent(speaker) && FORGE_TOOL_SET.has(input2.tool)) {
16878
+ throw new Error(`[forge:partition] Tool refused: "${input2.tool}" belongs to the forge agent family and the current agent ("${speaker}") is outside it. Switch to forge (Tab) to use forge tooling.`);
16879
+ }
16880
+ if (isForgeFamilyAgent(speaker) && (input2.tool === "shell" || input2.tool === "bash") && !jobsKeepBuiltinShell && jobStage() < 2) {
16881
+ throw new Error(`[forge:partition] Builtin shell refused: the forge family executes through forge_shell (jobs.keepBuiltinShell or jobs.mode: "native" restores the builtin shell). Re-issue the command through forge_shell.`);
16882
+ }
16773
16883
  }
16774
16884
  }
16775
16885
  },
16886
+ "chat.message": async (input2) => {
16887
+ if (forgeDisabled)
16888
+ return;
16889
+ const raw = input2;
16890
+ if (typeof raw.sessionID === "string" && raw.sessionID && typeof raw.agent === "string" && raw.agent) {
16891
+ sessionAgents.set(raw.sessionID, raw.agent);
16892
+ }
16893
+ },
16776
16894
  "chat.params": async (input2, output) => {
16777
16895
  if (forgeDisabled)
16778
16896
  return;
@@ -16781,6 +16899,8 @@ var server = async (input, options) => {
16781
16899
  if (!sessionID)
16782
16900
  return;
16783
16901
  const agentName = typeof raw.agent === "string" ? raw.agent : raw.agent?.name;
16902
+ if (typeof agentName === "string" && agentName)
16903
+ sessionAgents.set(sessionID, agentName);
16784
16904
  if (typeof agentName !== "string" || !agentName.startsWith("forge-"))
16785
16905
  return;
16786
16906
  const frozen = sessionDepths.get(sessionID);
@@ -16863,6 +16983,7 @@ var server = async (input, options) => {
16863
16983
  if (typeof info?.id === "string" && info.id) {
16864
16984
  jobManager.onSessionEnd(info.id);
16865
16985
  sessionDepths.delete(info.id);
16986
+ sessionAgents.delete(info.id);
16866
16987
  }
16867
16988
  }
16868
16989
  },
@@ -16871,6 +16992,8 @@ var server = async (input, options) => {
16871
16992
  return;
16872
16993
  if (!input2.callID)
16873
16994
  return;
16995
+ if (!sessionIsForgeFamily(input2.sessionID))
16996
+ return;
16874
16997
  output.env[WATCHDOG_ENV_MARK] = markerValue(input2.callID);
16875
16998
  watchdog.markSeen(input2.callID);
16876
16999
  if (process.env.FORGE_WATCHDOG_PROBE) {
@@ -16900,21 +17023,11 @@ var server = async (input, options) => {
16900
17023
  if (input2.tool === "goal_check")
16901
17024
  act.checks++;
16902
17025
  },
16903
- "tool.definition": async (input2, output) => {
16904
- if (input2.toolID === "shell" || input2.toolID === "bash") {
16905
- const props = output.parameters?.properties;
16906
- if (props && "run_in_background" in props)
16907
- nativeBackgroundSeen = true;
16908
- } else if (input2.toolID === "forge_shell" && jobStage() === 1) {
16909
- if (!output.description.startsWith(STAGE1_NOTE)) {
16910
- output.description = `${STAGE1_NOTE}
16911
- ${output.description}`;
16912
- }
16913
- }
16914
- },
16915
17026
  "experimental.chat.system.transform": async (input2, output) => {
16916
17027
  if (!input2.sessionID)
16917
17028
  return;
17029
+ if (!sessionIsForgeFamily(input2.sessionID))
17030
+ return;
16918
17031
  const crewActive = crews.get(input2.sessionID);
16919
17032
  if (crewActive && !output.system.some((s) => s.startsWith("[forge:crew-active]"))) {
16920
17033
  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.`);
@@ -16947,6 +17060,8 @@ ${output.description}`;
16947
17060
  return;
16948
17061
  if (!input2.sessionID)
16949
17062
  return;
17063
+ if (!sessionIsForgeFamily(input2.sessionID))
17064
+ return;
16950
17065
  const state = sessions.get(input2.sessionID);
16951
17066
  if (!state)
16952
17067
  return;
@@ -16968,7 +17083,7 @@ Post-compaction turns must keep following this goal and its constraints.`);
16968
17083
  if (!state)
16969
17084
  return;
16970
17085
  const goal = resolveLiveGoal(state);
16971
- if (goal)
17086
+ if (goal && sessionIsForgeFamily(input2.sessionID))
16972
17087
  output.enabled = false;
16973
17088
  }
16974
17089
  };
package/package.json CHANGED
@@ -1,64 +1,64 @@
1
- {
2
- "name": "@sorenllm/opencode-forge",
3
- "version": "0.4.0",
4
- "description": "Single general-purpose forge agent for opencode: static worker subagents defined in forge.json (pinned model + optional reasoning depth, 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
- "type": "module",
6
- "main": "./dist/index.js",
7
- "exports": {
8
- ".": {
9
- "import": "./dist/index.js"
10
- },
11
- "./server": {
12
- "import": "./dist/index.js"
13
- }
14
- },
15
- "files": [
16
- "dist",
17
- "README.md"
18
- ],
19
- "scripts": {
20
- "bundle": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && bun build ./plugin.ts --outfile ./dist/index.js --target node --format esm",
21
- "test": "node --test tests/*.test.mjs",
22
- "typecheck": "tsc --noEmit"
23
- },
24
- "keywords": [
25
- "opencode",
26
- "opencode-plugin",
27
- "subagents",
28
- "plan",
29
- "planning",
30
- "workflow",
31
- "single-agent",
32
- "goal",
33
- "crew",
34
- "orchestration",
35
- "background-jobs",
36
- "shell",
37
- "watchdog"
38
- ],
39
- "license": "MIT",
40
- "author": "chengsongren",
41
- "homepage": "https://github.com/ChengZiiii/opencode-forge#readme",
42
- "repository": {
43
- "type": "git",
44
- "url": "git+https://github.com/ChengZiiii/opencode-forge.git"
45
- },
46
- "bugs": {
47
- "url": "https://github.com/ChengZiiii/opencode-forge/issues"
48
- },
49
- "peerDependencies": {
50
- "@opencode-ai/plugin": "^1.18.0"
51
- },
52
- "devDependencies": {
53
- "@opencode-ai/plugin": "^1.18.0",
54
- "@types/node": "^22.13.9",
55
- "typescript": "^5.8.2"
56
- },
57
- "engines": {
58
- "opencode": "^1.18.0"
59
- },
60
- "publishConfig": {
61
- "access": "public",
62
- "registry": "https://registry.npmjs.org/"
63
- }
64
- }
1
+ {
2
+ "name": "@sorenllm/opencode-forge",
3
+ "version": "0.5.0",
4
+ "description": "Single general-purpose forge agent for opencode: static worker subagents defined in forge.json (pinned model + optional reasoning depth, 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
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/index.js"
10
+ },
11
+ "./server": {
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "scripts": {
20
+ "bundle": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && bun build ./plugin.ts --outfile ./dist/index.js --target node --format esm",
21
+ "test": "node --test tests/*.test.mjs",
22
+ "typecheck": "tsc --noEmit"
23
+ },
24
+ "keywords": [
25
+ "opencode",
26
+ "opencode-plugin",
27
+ "subagents",
28
+ "plan",
29
+ "planning",
30
+ "workflow",
31
+ "single-agent",
32
+ "goal",
33
+ "crew",
34
+ "orchestration",
35
+ "background-jobs",
36
+ "shell",
37
+ "watchdog"
38
+ ],
39
+ "license": "MIT",
40
+ "author": "chengsongren",
41
+ "homepage": "https://github.com/ChengZiiii/opencode-forge#readme",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/ChengZiiii/opencode-forge.git"
45
+ },
46
+ "bugs": {
47
+ "url": "https://github.com/ChengZiiii/opencode-forge/issues"
48
+ },
49
+ "peerDependencies": {
50
+ "@opencode-ai/plugin": "^1.18.0"
51
+ },
52
+ "devDependencies": {
53
+ "@opencode-ai/plugin": "^1.18.0",
54
+ "@types/node": "^22.13.9",
55
+ "typescript": "^5.8.2"
56
+ },
57
+ "engines": {
58
+ "opencode": "^1.18.0"
59
+ },
60
+ "publishConfig": {
61
+ "access": "public",
62
+ "registry": "https://registry.npmjs.org/"
63
+ }
64
+ }