@sorenllm/opencode-forge 0.3.0 → 0.3.2

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 +422 -373
  2. package/dist/index.js +838 -100
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,373 +1,422 @@
1
- # opencode-forge
2
-
3
- Single general-purpose **forge** agent + two orthogonal harnesses for
4
- [opencode](https://opencode.ai) ≥ 1.18: a **plan harness** (decide first,
5
- execute later) and a **goal harness** (arm an objective, let the loop drive
6
- itself to a host-verified finish). They share nothing but a safety interop;
7
- OpenSpec spec workflows remain a third, separate lane.
8
-
9
- ```
10
- /plan fix login timeout → read-only recon → plan_write (draft, writes denied)
11
- → present, end turn → USER REVIEW (revise / discard / go-ahead)
12
- → plan_approve (user dialog = final gate) on explicit go-ahead
13
- → execute task by task, plan_tick on each (timestamped audit)
14
- → all ticked → per-criterion self-check → plan_close
15
- (user dialog = completion gate) → done
16
- /plan → list in-progress plans with progress
17
- /plan resume → continue the most recent unfinished plan
18
- /plan discard → abandon the current plan (abandoned, writes restored)
19
- ```
20
-
21
- ```
22
- /goal make the suite green --check "npm test" --contains "src/a.ts::export const A"
23
- → goal_write (arm=true; the user dialog IS the arm action)
24
- → loop: work → goal_check (advisory) → idle → continuation brief
25
- → goal_complete re-runs EVERY check itself on the host
26
- (fail-closed) + per-criterion attestations → user dialog
27
- = completion gate → completed
28
- /goal add ... → queue an inert goal (no dialog, no loop)
29
- /goal → live goal + queue overview
30
- /goal pause | resume | discard/stop/cancel
31
- ```
32
-
33
- - Plan files: `.opencode/plan/<date>-<slug>.md` in your project, frontmatter
34
- state machine `draft → approved → done` (exit: `abandoned`).
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.
38
- - `plan_approve` / `plan_close` are pinned to a confirmation dialog: the
39
- model can never flip the state itself.
40
- - Goal files: `.opencode/goal/<date>-<slug>.md`, state machine
41
- `queued → active ⇄ paused → completed / abandoned`, one live goal per
42
- session plus a workspace queue. Completion is **verified by the plugin**,
43
- not attested by the model: `goal_complete` re-executes every check itself
44
- 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.
48
-
49
- ## Install
50
-
51
- Requires opencode ≥ 1.18.
52
-
53
- ```bash
54
- # npm (recommended)
55
- opencode plugin @sorenllm/opencode-forge --global
56
- # or GitHub source
57
- opencode plugin github:ChengZiiii/opencode-forge --global
58
- ```
59
-
60
- Local development: add `"file:///<repo abs path>"` to the `plugin` array in
61
- your opencode config. Single-file install: copy `dist/index.js` to
62
- `~/.config/opencode/plugin/forge.js` — it is fully self-contained (the plan
63
- discipline rides inside the /plan command template; there is no separate
64
- skill file).
65
-
66
- Note: do not enable opencode's experimental plan mode
67
- (`OPENCODE_EXPERIMENTAL_PLAN_MODE`) together with forge — two plan mechanisms
68
- would overlap.
69
-
70
- ## Configuration
71
-
72
- Everything works with zero config. Optional knobs (your config, your files —
73
- the plugin never writes them):
74
-
75
- ```jsonc
76
- {
77
- "agent": {
78
- "forge": {
79
- "model": "provider/model", // pick any model for forge
80
- "disable": true // one-knob return to native: no forge,
81
- // build/plan restored, no tools/commands
82
- }
83
- },
84
- // per-plugin options ride the plugin entry as a [spec, options] tuple
85
- "plugin": [
86
- ["@sorenllm/opencode-forge", {
87
- "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
90
- },
91
- "watchdog": {
92
- "mode": "kill", // "kill" (default) | "dry-run" | "off"
93
- "stallMs": 600000 // stall threshold, min 60000
94
- }
95
- }]
96
- ]
97
- }
98
- ```
99
-
100
- If you already have a `command.plan` or `command.goal` of your own, it wins
101
- and the plugin's command of that name is not registered.
102
-
103
- ## Uninstall (four steps, restores native opencode)
104
-
105
- 1. Remove the plugin entry from the `plugin` array in
106
- `~/.config/opencode/opencode.json` (global installs).
107
- 2. Delete the package store dir:
108
- `~/.cache/opencode/packages/@sorenllm/opencode-forge/` (npm installs,
109
- scope-dir layout; for github installs it is
110
- `~/.cache/opencode/packages/github_ChengZiiii/opencode-forge/`).
111
- 3. Delete the `agent["forge"]` block from your config if you added one
112
- (otherwise the name lingers in the agent list).
113
- 4. Done — the hidden native `build`/`plan` agents come back automatically
114
- (the hide was runtime-only). Your `.opencode/plan/` and `.opencode/goal/`
115
- files are yours; delete them yourself if you want.
116
- 5. Optional runtime debris: delete `<tmp>/opencode-forge/` (job logs, the
117
- job registry ledger, and the watchdog ledger — the file ledger table
118
- above lists everything).
119
-
120
- ## File ledger
121
-
122
- What this plugin touches, exhaustively:
123
-
124
- | Where | What | Lifetime |
125
- | --- | --- | --- |
126
- | `<project>/.opencode/plan/*.md` | plan files | user data — kept forever, uninstall never deletes |
127
- | `<project>/.opencode/goal/*.md` | goal files (contract, Check Log, Turn Ledger) | user data — kept forever, uninstall never deletes |
128
- | merged config object (RAM only) | forge agent, native build/plan `disable`, `command.plan`, `command.goal`, goal permission keys, `permission.forge_shell`, stage-0 builtin shell hide | vanishes when the plugin is removed; nothing is written to disk |
129
- | `<tmp>/opencode-forge/jobs/<jobId>.log` | job output tee (full output; oldest rotated out above 50 files) | runtime debris — delete freely, also after uninstall |
130
- | `<tmp>/opencode-forge/jobs/ledger.jsonl` | job registry ledger (bounded: 1 MB reset, 200 entries) | runtime debris — delete freely, also after uninstall |
131
- | `<tmp>/opencode-forge/watchdog/log.jsonl` | watchdog interventions ledger (bounded: 200 entries, oldest rotated) | runtime debris — delete freely, also after uninstall |
132
- | `~/.cache/opencode/packages/...` | installed package copy | written by the `opencode plugin` installer, not the plugin |
133
- | `~/.config/opencode/opencode.json` | `plugin` array entry | written by the installer |
134
-
135
- The plugin writes no temp files, no logs, nothing outside the table (a
136
- `FORGE_GOAL_PROBE=1` env opt-in appends continuation diagnostics to the OS
137
- temp dir for debugging).
138
-
139
- ## Goal mode (autonomous, host-verified objectives)
140
-
141
- Three orthogonal workflows — pick per task, they never bind to each other:
142
-
143
- | Workflow | decides | use when |
144
- | --- | --- | --- |
145
- | OpenSpec change | spec deltas, review gates | multi-session features with spec impact |
146
- | `/plan` | approach + task order, you approve then it executes | single-task execution you want to review first |
147
- | `/goal` | arm an objective + verification contract, the loop executes | well-defined objective with machine-checkable success |
148
-
149
- `/goal <objective>` drafts a **contract**: goal, success criteria,
150
- verification checks, constraints, non-goals, budgets. Contract markers in the
151
- objective map to structured fields:
152
-
153
- ```
154
- /goal make the release green --check "npm test" --check "npm run lint"
155
- --contains "CHANGELOG.md::## Unreleased"
156
- --success "zero failing tests" --constraint "no dependency bumps"
157
- --non-goal "refactoring" --max-turns 15 --max-minutes 30
158
- ```
159
-
160
- Two check types, both **executed by the plugin on the host** (the model never
161
- grades its own homework):
162
-
163
- - `--check "cmd"` — shell command in the workspace; passes on exit 0,
164
- timeout-configurable (default 120 s, max 600 s).
165
- - `--contains "file::text"` — file contract: the literal text must be present
166
- in that workspace file.
167
-
168
- **Arming and the loop.** `goal_write` with `arm=true` pops one confirmation
169
- dialog — your Allow IS the arm action; nothing autonomous runs before it.
170
- From then on, whenever the session goes idle, the plugin re-prompts the agent
171
- with a `[forge:goal-continue]` brief (with debounce, owner-checked, and
172
- compaction-aware: autocontinue is suppressed for active-goal sessions so a
173
- context compaction can never silently re-trigger the loop). Each continuation
174
- turn is counted and recorded in the goal's Turn Ledger.
175
-
176
- **Budgets and auto-pause.** `--max-turns` (default 25, hard ceiling 200) and
177
- `--max-minutes` (default 60, hard ceiling 480). The loop pauses itself — with
178
- a `stop_reason` in the frontmatter — on: budget exhaustion, two consecutive
179
- no-progress continuation turns, three consecutive transport failures, or a
180
- live draft plan appearing in the session (the only plan/goal interop: a draft
181
- plan's write ban would wall the loop off, so the goal pauses instead of
182
- burning turns against it). `/goal pause` (or `goal_pause` with a blocker
183
- description) pauses by hand; `/goal resume` re-arms through another
184
- confirmation dialog.
185
-
186
- **Completion is fail-closed.** `goal_complete` re-executes every check itself
187
- at the gate — results recorded in the Check Log never substitute for the
188
- re-run — and requires one attestation per success criterion. Only then does
189
- the user dialog appear. Revising the contract (`goal_write` with
190
- `revise=true`) bumps the revision: earlier evidence no longer counts, but the
191
- Check Log and Turn Ledger survive as an audit trail.
192
-
193
- **Queueing.** One live goal per session; `/goal add ...` queues additional
194
- goals (inert, no dialog). When the live goal reaches a terminal state,
195
- `/goal resume` promotes the oldest queued goal into the now-free session.
196
-
197
- **Run-mode limitation.** `opencode run` exits before the idle continuation
198
- debounce fires, so the autonomous loop effectively requires a TUI/serve
199
- session. Arming, checks, completion, pause/resume, and queueing all work in
200
- run mode; `--auto` approves the gates, without it they auto-reject
201
- (headless cannot silently pass a gate).
202
-
203
- **Security boundary.** Verification shell commands run on your host, in the
204
- workspace, via the plugin — that is the point (host-verified completion).
205
- They are drafted by the model from your objective. Read them in the dialog
206
- before allowing the arm; `--contains` contracts are strictly
207
- workspace-relative (path escape is refused).
208
-
209
- ## Job supervisor (a shell that can never hang the session)
210
-
211
- The builtin `shell` tool treats closed stdio pipes as completion — a
212
- detached grandchild holding them open suspends the call indefinitely (the
213
- multi-hour agent hangs behind upstream issues #47350 / #50316 / #49169).
214
- Subagents have it worse: the native task tool has no timeout at all.
215
- `forge_shell` is the replacement exec surface for the forge agent.
216
-
217
- **`forge_shell`** completes on the FIRST of four conditions, and only the
218
- exit condition is bound to the actual exit event (structural immunity to
219
- the stdio-EOF bug):
220
-
221
- | condition | knob | on trigger |
222
- | --- | --- | --- |
223
- | process exit | — | final; exit code + output tail returned |
224
- | `success_pattern` regex matches new output | opt-in | completes as success; process kept alive by default (server semantics), `keep_alive: false` kills its tree |
225
- | `idle_ms` with no new output | default 60000 | early return `still-running` + `jobId`; process stays alive |
226
- | `max_wait_ms` hard cap | default 120000, max 600000 | early return `still-running`; never kills |
227
-
228
- `run_in_background: true` skips all waiting and returns
229
- `{jobId, logPath}` immediately. Every run pops one permission dialog
230
- (`permission.forge_shell = "ask"`; an explicit `deny` in your config wins).
231
-
232
- **`forge_jobs`** manages the registry: `list` / `poll {jobId, waitMs≤30s}`
233
- (bounded wait for new output or exit, drains it) / `log {jobId, offset?,
234
- limit?}` (line paging over the on-disk log) / `kill` (whole process tree) /
235
- `clear` (drop a finished entry) / `handoff` (rebind ownership to the root
236
- session so a subagent's job survives the subagent). Delegated agents are
237
- instructed to poll their jobs before yielding a conclusion.
238
-
239
- **Exit wakes.** A job that exits after its `forge_shell` call already
240
- returned queues a single `[forge:job-complete]` message, delivered into the
241
- owning session via `promptAsync` the next time it goes idle (exactly once;
242
- `notify: false` opts out per job).
243
-
244
- **Ownership.** Jobs belong to the session that created them. Session
245
- deleted → its live session-scoped jobs are killed and the event is recorded
246
- in a bounded ledger (`handoff` beforehand survives). Plugin unload disposes
247
- everything it still owns.
248
-
249
- ### Stage matrix (future compatibility, by design)
250
-
251
- OpenCode upstream is converging on native backgrounding (PRs #47231 /
252
- #50276, umbrella #34366). The supervisor degrades ahead of it:
253
-
254
- | stage | how you get there | builtin shell | forge_shell / forge_jobs |
255
- | --- | --- | --- | --- |
256
- | 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 |
257
- | 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 |
258
- | 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 |
259
-
260
- Pin `jobs.mode: "forge"` to stay on stage 0 forever; `jobs.keepBuiltinShell:
261
- true` keeps the builtin shell visible at any stage.
262
-
263
- ### Artifacts and uninstall additions
264
-
265
- Job output tees to `<tmp>/opencode-forge/jobs/<jobId>.log` (oldest rotated
266
- out above 50 files) and registry events append to
267
- `<tmp>/opencode-forge/jobs/ledger.jsonl` (bounded, 1 MB reset, 200 entries).
268
- These are runtime debris, not data: deleting the directory after uninstall
269
- is safe and complete.
270
-
271
- ## Hang watchdog (a stuck builtin shell unblocks itself)
272
-
273
- `forge_shell` is structurally immune to the stdio-EOF hang, but the builtin
274
- `shell`/`bash` tool can still hang outside it — user-defined agents keep
275
- the builtin shell, and the stage matrix above restores it at stage 1/2.
276
- The watchdog is the independent backstop for those paths, in **every**
277
- session (primary and delegated subagents alike):
278
-
279
- 1. The host's shell environment hook stamps each builtin shell call's
280
- process with a plugin-namespaced marker (`FORGE_WATCHDOG_MARK`), and the
281
- call is timed from start to end.
282
- 2. At 80% of the stall budget: a diagnostic entry (the session is busy —
283
- injecting a message would just queue, so nothing is sent to it).
284
- 3. At `watchdog.stallMs` (default 600000): the call's process tree is
285
- located and **killed** — the pipes hit EOF and the pending tool call
286
- resolves immediately with whatever output was captured.
287
- 4. If the call still hasn't returned after the kill, or no matching
288
- process exists (a hang with nothing to kill): an honest
289
- diagnostics-only `unresolved` report.
290
-
291
- Location is exact where foreign process environments are readable (POSIX
292
- `/proc/*/environ` marker match). On Windows they are not readable, so the
293
- watchdog infers in two guarded waves, both restricted to processes created
294
- during the stalled call: wave 1 matches descendants of the opencode host
295
- process or processes whose command line carries the stalled call's own
296
- command text; wave 2 — only when wave 1 found nothing, or killed and the
297
- call still didn't return within the observation window — additionally
298
- matches processes whose command lives in the stalled command's own
299
- directory, which is exactly where the detached stdio holders of the
300
- exit-with-inherited-stdio hang class sit. The locator never targets its
301
- own probe processes, console hosts (`conhost.exe`), or anything created
302
- before the call's time window. If the shell environment hook itself ever
303
- stops firing (host API drift), the watchdog detects the missing marker and
304
- degrades itself to dry-run instead of killing by inference alone.
305
-
306
- **Modes** (`watchdog.mode`, default `kill`): `dry-run` records the exact
307
- process list it *would* terminate and kills nothing — recommended for a
308
- first observation round on a new host; `off` disables timing entirely.
309
- `watchdog.stallMs` is clamped to a 60 s protection floor. Invalid option
310
- values fall back to the defaults and the fallback is ledgered.
311
-
312
- **Known trade-off.** The builtin shell exposes no output visibility, so
313
- the watchdog cannot tell "hung" from "quietly working" — a legitimate
314
- silent command longer than the threshold will be killed. That is the
315
- deliberated price of stopping multi-hour hangs; the guidance layer already
316
- routes legitimate long-running work to `forge_shell`, which has real idle
317
- detection. On Windows, concurrent builtin shell calls started inside the
318
- same window are inferred together (the rare case) — `dry-run` makes the
319
- exact blast radius auditable before you trust `kill`.
320
-
321
- **Retiring it.** When upstream ships exit-based completion for the builtin
322
- shell, switch `watchdog.mode` to `dry-run` for an observation round, then
323
- `off`.
324
-
325
- ### Watchdog ledger
326
-
327
- Every intervention (warn / kill / dry-run candidate / unresolved /
328
- config fallback) appends to `<tmp>/opencode-forge/watchdog/log.jsonl`
329
- (bounded: 200 entries, oldest rotated out). Deleting the directory after
330
- uninstall is safe and complete.
331
-
332
- ## Design stance (read before filing "bash is blocked" issues)
333
-
334
- During a plan's draft phase every mutating tool — `bash` included — is
335
- denied, and an `allow` in your config does not override it. Reconnaissance is
336
- read/grep/glob; if you genuinely need a shell command to decide the plan,
337
- approve the plan first (revising after approval is allowed via a new `/plan`).
338
- The escape hatches are `plan_approve` and `/plan discard`, by design.
339
-
340
- Separately, at stage 0 the builtin shell is hidden on the plugin-created
341
- forge agent on purpose — `forge_shell` is the exec surface there (see the
342
- job supervisor chapter above). A **user-defined** `agent.forge` entry,
343
- `jobs.keepBuiltinShell: true`, or any stage ≥ 1 keeps the builtin shell
344
- visible.
345
-
346
- A process restart forgets the session binding: the write-ban soft-disables
347
- (safety over strictness) and the next session's system notice + `/plan
348
- resume` re-bind from the plan file on disk, which is the source of truth.
349
-
350
- ## Development
351
-
352
- ```bash
353
- npm install
354
- bun run typecheck # tsc --noEmit
355
- node --test tests/*.test.mjs
356
- bun run bundle # rebuild self-contained dist/index.js (committed)
357
- ```
358
-
359
- Architecture: `plugin.ts` (dual entry — v1 `server` full-featured + v2
360
- `setup` defensive forward-compat; the /plan and /goal command templates each
361
- carry their own full discipline, hermes-style: the entry turn is the
362
- rulebook) + `src/plan-file.ts` / `src/goal-file.ts`
363
- (pure document cores, unit-tested, no opencode imports) + `src/run-check.ts`
364
- (shell/file-contract runner with tree-kill timeouts and a workspace path
365
- guard) + `src/proc.ts` (shared spawn/tree-kill muscle) + `src/job-manager.ts`
366
- (job registry, ownership, wake queue — pure logic) + `src/job-runner.ts`
367
- (four-condition race, pipe capture, log tee). Behavioral changes go through the
368
- OpenSpec workflow in `openspec/` — see AGENTS.md. Common pitfalls live in
369
- `../opencode-plugin-dev-pitfalls.md`.
370
-
371
- ## License
372
-
373
- MIT
1
+ # opencode-forge
2
+
3
+ Single general-purpose **forge** agent + two orthogonal harnesses for
4
+ [opencode](https://opencode.ai) ≥ 1.18: a **plan harness** (decide first,
5
+ execute later) and a **goal harness** (arm an objective, let the loop drive
6
+ itself to a host-verified finish). They share nothing but a safety interop;
7
+ OpenSpec spec workflows remain a third, separate lane.
8
+
9
+ ```
10
+ /plan fix login timeout → read-only recon → plan_write (draft, writes denied)
11
+ → present, end turn → USER REVIEW (revise / discard / go-ahead)
12
+ → plan_approve (user dialog = final gate) on explicit go-ahead
13
+ → execute task by task, plan_tick on each (timestamped audit)
14
+ → all ticked → per-criterion self-check → plan_close
15
+ (user dialog = completion gate) → done
16
+ /plan → list in-progress plans with progress
17
+ /plan resume → continue the most recent unfinished plan
18
+ /plan discard → abandon the current plan (abandoned, writes restored)
19
+ ```
20
+
21
+ ```
22
+ /goal make the suite green --check "npm test" --contains "src/a.ts::export const A"
23
+ → goal_write (arm=true; the user dialog IS the arm action)
24
+ → loop: work → goal_check (advisory) → idle → continuation brief
25
+ → goal_complete re-runs EVERY check itself on the host
26
+ (fail-closed) + per-criterion attestations → user dialog
27
+ = completion gate → completed
28
+ /goal add ... → queue an inert goal (no dialog, no loop)
29
+ /goal → live goal + queue overview
30
+ /goal pause | resume | discard/stop/cancel
31
+ ```
32
+
33
+ - Plan files: `.opencode/plan/<date>-<slug>.md` in your project, frontmatter
34
+ state machine `draft → approved → done` (exit: `abandoned`).
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.
38
+ - `plan_approve` / `plan_close` are pinned to a confirmation dialog: the
39
+ model can never flip the state itself.
40
+ - Goal files: `.opencode/goal/<date>-<slug>.md`, state machine
41
+ `queued → active ⇄ paused → completed / abandoned`, one live goal per
42
+ session plus a workspace queue. Completion is **verified by the plugin**,
43
+ not attested by the model: `goal_complete` re-executes every check itself
44
+ 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.
48
+
49
+ ## Install
50
+
51
+ Requires opencode ≥ 1.18.
52
+
53
+ ```bash
54
+ # npm (recommended)
55
+ opencode plugin @sorenllm/opencode-forge --global
56
+ # or GitHub source
57
+ opencode plugin github:ChengZiiii/opencode-forge --global
58
+ ```
59
+
60
+ Local development: add `"file:///<repo abs path>"` to the `plugin` array in
61
+ your opencode config. Single-file install: copy `dist/index.js` to
62
+ `~/.config/opencode/plugin/forge.js` — it is fully self-contained (the plan
63
+ discipline rides inside the /plan command template; there is no separate
64
+ skill file).
65
+
66
+ Note: do not enable opencode's experimental plan mode
67
+ (`OPENCODE_EXPERIMENTAL_PLAN_MODE`) together with forge — two plan mechanisms
68
+ would overlap.
69
+
70
+ ## Configuration
71
+
72
+ Everything works with zero config. Optional knobs (your config, your files —
73
+ the plugin never writes them):
74
+
75
+ ```jsonc
76
+ {
77
+ "agent": {
78
+ "forge": {
79
+ "model": "provider/model", // pick any model for forge
80
+ "disable": true // one-knob return to native: no forge,
81
+ // build/plan restored, no tools/commands
82
+ }
83
+ },
84
+ // per-plugin options ride the plugin entry as a [spec, options] tuple
85
+ "plugin": [
86
+ ["@sorenllm/opencode-forge", {
87
+ "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
90
+ },
91
+ "watchdog": {
92
+ "mode": "kill", // "kill" (default) | "dry-run" | "off"
93
+ "stallMs": 600000 // stall threshold, min 60000
94
+ }
95
+ }]
96
+ ]
97
+ }
98
+ ```
99
+
100
+ If you already have a `command.plan` or `command.goal` of your own, it wins
101
+ and the plugin's command of that name is not registered.
102
+
103
+ ## Uninstall (four steps, restores native opencode)
104
+
105
+ 1. Remove the plugin entry from the `plugin` array in
106
+ `~/.config/opencode/opencode.json` (global installs).
107
+ 2. Delete the package store dir:
108
+ `~/.cache/opencode/packages/@sorenllm/opencode-forge/` (npm installs,
109
+ scope-dir layout; for github installs it is
110
+ `~/.cache/opencode/packages/github_ChengZiiii/opencode-forge/`).
111
+ 3. Delete the `agent["forge"]` block from your config if you added one
112
+ (otherwise the name lingers in the agent list).
113
+ 4. Done — the hidden native `build`/`plan` agents come back automatically
114
+ (the hide was runtime-only). Your `.opencode/plan/` and `.opencode/goal/`
115
+ files are yours; delete them yourself if you want.
116
+ 5. Optional runtime debris: delete `<tmp>/opencode-forge/` (job logs, the
117
+ job registry ledger, and the watchdog ledger — the file ledger table
118
+ above lists everything).
119
+
120
+ ## File ledger
121
+
122
+ What this plugin touches, exhaustively:
123
+
124
+ | Where | What | Lifetime |
125
+ | --- | --- | --- |
126
+ | `<project>/.opencode/plan/*.md` | plan files | user data — kept forever, uninstall never deletes |
127
+ | `<project>/.opencode/goal/*.md` | goal files (contract, Check Log, Turn Ledger) | user data — kept forever, uninstall never deletes |
128
+ | merged config object (RAM only) | forge agent, native build/plan `disable`, `command.plan`, `command.goal`, goal permission keys, `permission.forge_shell`, stage-0 builtin shell hide | vanishes when the plugin is removed; nothing is written to disk |
129
+ | `<tmp>/opencode-forge/jobs/<jobId>.log` | job output tee (full output; oldest rotated out above 50 files) | runtime debris — delete freely, also after uninstall |
130
+ | `<tmp>/opencode-forge/jobs/ledger.jsonl` | job registry ledger (bounded: 1 MB reset, 200 entries) | runtime debris — delete freely, also after uninstall |
131
+ | `<tmp>/opencode-forge/jobs/registry.json` | persistent survivor registry (bounded: 100 entries) | runtime debris — after uninstall, kill any still-running `survive` jobs yourself first |
132
+ | `<tmp>/opencode-forge/watchdog/log.jsonl` | watchdog interventions ledger (bounded: 200 entries, oldest rotated) | runtime debris — delete freely, also after uninstall |
133
+ | `~/.cache/opencode/packages/...` | installed package copy | written by the `opencode plugin` installer, not the plugin |
134
+ | `~/.config/opencode/opencode.json` | `plugin` array entry | written by the installer |
135
+
136
+ The plugin writes no temp files, no logs, nothing outside the table (a
137
+ `FORGE_GOAL_PROBE=1` env opt-in appends continuation diagnostics to the OS
138
+ temp dir for debugging).
139
+
140
+ ## Goal mode (autonomous, host-verified objectives)
141
+
142
+ Three orthogonal workflows — pick per task, they never bind to each other:
143
+
144
+ | Workflow | decides | use when |
145
+ | --- | --- | --- |
146
+ | OpenSpec change | spec deltas, review gates | multi-session features with spec impact |
147
+ | `/plan` | approach + task order, you approve then it executes | single-task execution you want to review first |
148
+ | `/goal` | arm an objective + verification contract, the loop executes | well-defined objective with machine-checkable success |
149
+
150
+ `/goal <objective>` drafts a **contract**: goal, success criteria,
151
+ verification checks, constraints, non-goals, budgets. Contract markers in the
152
+ objective map to structured fields:
153
+
154
+ ```
155
+ /goal make the release green --check "npm test" --check "npm run lint"
156
+ --contains "CHANGELOG.md::## Unreleased"
157
+ --success "zero failing tests" --constraint "no dependency bumps"
158
+ --non-goal "refactoring" --max-turns 15 --max-minutes 30
159
+ ```
160
+
161
+ Two check types, both **executed by the plugin on the host** (the model never
162
+ grades its own homework):
163
+
164
+ - `--check "cmd"` — shell command in the workspace; passes on exit 0,
165
+ timeout-configurable (default 120 s, max 600 s).
166
+ - `--contains "file::text"` — file contract: the literal text must be present
167
+ in that workspace file.
168
+
169
+ **Arming and the loop.** `goal_write` with `arm=true` pops one confirmation
170
+ dialog — your Allow IS the arm action; nothing autonomous runs before it.
171
+ From then on, whenever the session goes idle, the plugin re-prompts the agent
172
+ with a `[forge:goal-continue]` brief (with debounce, owner-checked, and
173
+ compaction-aware: autocontinue is suppressed for active-goal sessions so a
174
+ context compaction can never silently re-trigger the loop). Each continuation
175
+ turn is counted and recorded in the goal's Turn Ledger.
176
+
177
+ **Budgets and auto-pause.** `--max-turns` (default 25, hard ceiling 200) and
178
+ `--max-minutes` (default 60, hard ceiling 480). The loop pauses itself — with
179
+ a `stop_reason` in the frontmatter — on: budget exhaustion, two consecutive
180
+ no-progress continuation turns, three consecutive transport failures, or a
181
+ live draft plan appearing in the session (the only plan/goal interop: a draft
182
+ plan's write ban would wall the loop off, so the goal pauses instead of
183
+ burning turns against it). `/goal pause` (or `goal_pause` with a blocker
184
+ description) pauses by hand; `/goal resume` re-arms through another
185
+ confirmation dialog.
186
+
187
+ **Completion is fail-closed.** `goal_complete` re-executes every check itself
188
+ at the gate — results recorded in the Check Log never substitute for the
189
+ re-run — and requires one attestation per success criterion. Only then does
190
+ the user dialog appear. Revising the contract (`goal_write` with
191
+ `revise=true`) bumps the revision: earlier evidence no longer counts, but the
192
+ Check Log and Turn Ledger survive as an audit trail.
193
+
194
+ **Queueing.** One live goal per session; `/goal add ...` queues additional
195
+ goals (inert, no dialog). When the live goal reaches a terminal state,
196
+ `/goal resume` promotes the oldest queued goal into the now-free session.
197
+
198
+ **Run-mode limitation.** `opencode run` exits before the idle continuation
199
+ debounce fires, so the autonomous loop effectively requires a TUI/serve
200
+ session. Arming, checks, completion, pause/resume, and queueing all work in
201
+ run mode; `--auto` approves the gates, without it they auto-reject
202
+ (headless cannot silently pass a gate).
203
+
204
+ **Security boundary.** Verification shell commands run on your host, in the
205
+ workspace, via the plugin — that is the point (host-verified completion).
206
+ They are drafted by the model from your objective. Read them in the dialog
207
+ before allowing the arm; `--contains` contracts are strictly
208
+ workspace-relative (path escape is refused).
209
+
210
+ ## Job supervisor (a shell that can never hang the session)
211
+
212
+ The builtin `shell` tool treats closed stdio pipes as completion — a
213
+ detached grandchild holding them open suspends the call indefinitely (the
214
+ multi-hour agent hangs behind upstream issues #47350 / #50316 / #49169).
215
+ Subagents have it worse: the native task tool has no timeout at all.
216
+ `forge_shell` is the replacement exec surface for the forge agent.
217
+
218
+ **`forge_shell`** completes on the FIRST of four conditions, and only the
219
+ exit condition is bound to the actual exit event (structural immunity to
220
+ the stdio-EOF bug):
221
+
222
+ | condition | knob | on trigger |
223
+ | --- | --- | --- |
224
+ | process exit | — | final; exit code + output tail returned |
225
+ | `success_pattern` regex matches new output | opt-in | completes as success; process kept alive by default (server semantics), `keep_alive: false` kills its tree |
226
+ | `idle_ms` with no new output | default 60000 | early return `still-running` + `jobId`; process stays alive |
227
+ | `max_wait_ms` hard cap | default 120000, max 600000 | early return `still-running`; never kills |
228
+
229
+ `run_in_background: true` skips all waiting and returns
230
+ `{jobId, logPath}` immediately. Every run pops one permission dialog
231
+ (`permission.forge_shell = "ask"`; an explicit `deny` in your config wins).
232
+ Every result form carries `cwd:` — the absolute directory the command
233
+ actually ran in. When the agent's narrative about the working directory
234
+ disagrees with that line, the line wins.
235
+
236
+ **`forge_jobs`** manages the registry: `list` / `poll {jobId, waitMs≤30s}`
237
+ (bounded wait for new output or exit, drains it) / `log {jobId, offset?,
238
+ limit?}` (line paging over the on-disk log) / `kill` (whole process tree) /
239
+ `clear` (drop a finished entry) / `handoff` (rebind ownership to the root
240
+ session so a subagent's job survives the subagent). Delegated agents are
241
+ instructed to poll their jobs before yielding a conclusion.
242
+
243
+ **Exit wakes.** A job that exits after its `forge_shell` call already
244
+ returned queues a single `[forge:job-complete]` message, delivered into the
245
+ owning session via `promptAsync` the next time it goes idle (exactly once;
246
+ `notify: false` opts out per job).
247
+
248
+ **Ownership.** Jobs belong to the session that created them. Session
249
+ deleted → its live session-scoped jobs are killed and the event is recorded
250
+ in a bounded ledger (`handoff` beforehand survives). Plugin unload disposes
251
+ everything it still owns.
252
+
253
+ ### Host exit cleans up on every path (0.3.1)
254
+
255
+ Background jobs used to survive the opencode process as broken zombies (the
256
+ host's stdout pipe died with it, so per-request writers broke on the next
257
+ write). Jobs now die with the host on every exit path, layered:
258
+
259
+ 1. **stdio is file-backed.** Job stdout/stderr ARE the log file (inherited
260
+ fd) — the host holds no job pipes at all, so nothing can break, and a
261
+ `survive` job (below) stays genuinely healthy after the host is gone.
262
+ 2. **JS exit matrix.** `SIGINT` / `SIGTERM` / `process exit` /
263
+ `uncaughtException` / `unhandledRejection` / plugin dispose all force-kill
264
+ every live non-survive job (synchronous `taskkill /T /F` — fast enough to
265
+ finish before the OS terminates the host; dispose gets a graceful pass
266
+ with a 3s grace window first).
267
+ 3. **OS fence (Windows).** One lazily-started PowerShell watcher holds a Job
268
+ Object with `KILL_ON_JOB_CLOSE` around every spawned tree (periodic
269
+ process-table sweep adopts late-born grandchildren). Host dies ANY way —
270
+ including `taskkill /F`, where no JS handler can run — the watcher's stdin
271
+ pipe dies with it, the handle closes, and the kernel kills the whole tree
272
+ (~sub-second measured). POSIX has no kernel equivalent here (PDEATHSIG
273
+ was rejected for its parent-thread pitfalls): process groups + the exit
274
+ matrix carry it, and the next start's registry scan reports orphans.
275
+
276
+ Known boundaries: a host killed within ~1s of a job's start can leak that
277
+ job's grandchild (the fence watcher is still compiling); Chromium-family
278
+ processes that explicitly break away from the job object escape the fence
279
+ (deliberate: `BREAKAWAY_OK` is not set). Both are recorded in the ledger
280
+ when observable.
281
+
282
+ **Survive mode (explicit opt-out of death).** `forge_shell { survive: true }`
283
+ starts a job that OUTLIVES the host: it is recorded in
284
+ `<tmp>/opencode-forge/jobs/registry.json` (pid + command + log path), gets no
285
+ fence and no exit kill, and the NEXT opencode run adopts it automatically —
286
+ `forge_jobs list` shows it as `previous-run`, and poll/log/kill work on it
287
+ as usual. Config `jobs.survive: "always"` flips the per-call default; config
288
+ `jobs.survive: "deny"` disables survival entirely and a per-call
289
+ `survive: true` against it is an error (the explicit deny wins). Survivors
290
+ whose pid died are detected at next start, ledgered as orphans, and dropped
291
+ from the registry. Stop survivors explicitly — nothing else will.
292
+
293
+ ### Stage matrix (future compatibility, by design)
294
+
295
+ OpenCode upstream is converging on native backgrounding (PRs #47231 /
296
+ #50276, umbrella #34366). The supervisor degrades ahead of it:
297
+
298
+ | stage | how you get there | builtin shell | forge_shell / forge_jobs |
299
+ | --- | --- | --- | --- |
300
+ | 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 |
301
+ | 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 |
302
+ | 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 |
303
+
304
+ Pin `jobs.mode: "forge"` to stay on stage 0 forever; `jobs.keepBuiltinShell:
305
+ true` keeps the builtin shell visible at any stage.
306
+
307
+ ### Artifacts and uninstall additions
308
+
309
+ Job output lands in `<tmp>/opencode-forge/jobs/<jobId>.log` (the file IS the
310
+ job's stdout/stderr; oldest rotated out above 50 files; reads are windowed to
311
+ 8 MB). Registry events append to
312
+ `<tmp>/opencode-forge/jobs/ledger.jsonl` (bounded, 1 MB reset, 200 entries),
313
+ and surviving jobs persist in `<tmp>/opencode-forge/jobs/registry.json`
314
+ (bounded to 100 entries; a stale `registry.json.lock` breaks itself after 5s).
315
+ These are runtime debris, not data — with ONE caveat: **if you uninstall with
316
+ `survive` jobs still running, killing them is on you** (`taskkill /PID <pid>
317
+ /F /T`, or just reboot); deleting the directory afterwards is safe and
318
+ complete.
319
+
320
+ ## Hang watchdog (a stuck builtin shell unblocks itself)
321
+
322
+ `forge_shell` is structurally immune to the stdio-EOF hang, but the builtin
323
+ `shell`/`bash` tool can still hang outside it — user-defined agents keep
324
+ the builtin shell, and the stage matrix above restores it at stage 1/2.
325
+ The watchdog is the independent backstop for those paths, in **every**
326
+ session (primary and delegated subagents alike):
327
+
328
+ 1. The host's shell environment hook stamps each builtin shell call's
329
+ process with a plugin-namespaced marker (`FORGE_WATCHDOG_MARK`), and the
330
+ call is timed from start to end.
331
+ 2. At 80% of the stall budget: a diagnostic entry (the session is busy —
332
+ injecting a message would just queue, so nothing is sent to it).
333
+ 3. At `watchdog.stallMs` (default 600000): the call's process tree is
334
+ located and **killed** — the pipes hit EOF and the pending tool call
335
+ resolves immediately with whatever output was captured.
336
+ 4. If the call still hasn't returned after the kill, or no matching
337
+ process exists (a hang with nothing to kill): an honest
338
+ diagnostics-only `unresolved` report.
339
+
340
+ Location is exact where foreign process environments are readable (POSIX
341
+ `/proc/*/environ` marker match). On Windows they are not readable, so the
342
+ watchdog infers in two guarded waves, both restricted to processes created
343
+ during the stalled call: wave 1 matches descendants of the opencode host
344
+ process or processes whose command line carries the stalled call's own
345
+ command text; wave 2 — only when wave 1 found nothing, or killed and the
346
+ call still didn't return within the observation window — additionally
347
+ matches processes whose command lives in the stalled command's own
348
+ directory, which is exactly where the detached stdio holders of the
349
+ exit-with-inherited-stdio hang class sit. The locator never targets its
350
+ own probe processes, console hosts (`conhost.exe`), or anything created
351
+ before the call's time window. If the shell environment hook itself ever
352
+ stops firing (host API drift), the watchdog detects the missing marker and
353
+ degrades itself to dry-run instead of killing by inference alone.
354
+
355
+ **Modes** (`watchdog.mode`, default `kill`): `dry-run` records the exact
356
+ process list it *would* terminate and kills nothing — recommended for a
357
+ first observation round on a new host; `off` disables timing entirely.
358
+ `watchdog.stallMs` is clamped to a 60 s protection floor. Invalid option
359
+ values fall back to the defaults and the fallback is ledgered.
360
+
361
+ **Known trade-off.** The builtin shell exposes no output visibility, so
362
+ the watchdog cannot tell "hung" from "quietly working" — a legitimate
363
+ silent command longer than the threshold will be killed. That is the
364
+ deliberated price of stopping multi-hour hangs; the guidance layer already
365
+ routes legitimate long-running work to `forge_shell`, which has real idle
366
+ detection. On Windows, concurrent builtin shell calls started inside the
367
+ same window are inferred together (the rare case) — `dry-run` makes the
368
+ exact blast radius auditable before you trust `kill`.
369
+
370
+ **Retiring it.** When upstream ships exit-based completion for the builtin
371
+ shell, switch `watchdog.mode` to `dry-run` for an observation round, then
372
+ `off`.
373
+
374
+ ### Watchdog ledger
375
+
376
+ Every intervention (warn / kill / dry-run candidate / unresolved /
377
+ config fallback) appends to `<tmp>/opencode-forge/watchdog/log.jsonl`
378
+ (bounded: 200 entries, oldest rotated out). Deleting the directory after
379
+ uninstall is safe and complete.
380
+
381
+ ## Design stance (read before filing "bash is blocked" issues)
382
+
383
+ During a plan's draft phase every mutating tool — `bash` included — is
384
+ denied, and an `allow` in your config does not override it. Reconnaissance is
385
+ read/grep/glob; if you genuinely need a shell command to decide the plan,
386
+ approve the plan first (revising after approval is allowed via a new `/plan`).
387
+ The escape hatches are `plan_approve` and `/plan discard`, by design.
388
+
389
+ Separately, at stage 0 the builtin shell is hidden on the plugin-created
390
+ forge agent on purpose — `forge_shell` is the exec surface there (see the
391
+ job supervisor chapter above). A **user-defined** `agent.forge` entry,
392
+ `jobs.keepBuiltinShell: true`, or any stage ≥ 1 keeps the builtin shell
393
+ visible.
394
+
395
+ A process restart forgets the session binding: the write-ban soft-disables
396
+ (safety over strictness) and the next session's system notice + `/plan
397
+ resume` re-bind from the plan file on disk, which is the source of truth.
398
+
399
+ ## Development
400
+
401
+ ```bash
402
+ npm install
403
+ bun run typecheck # tsc --noEmit
404
+ node --test tests/*.test.mjs
405
+ bun run bundle # rebuild self-contained dist/index.js (committed)
406
+ ```
407
+
408
+ Architecture: `plugin.ts` (dual entry — v1 `server` full-featured + v2
409
+ `setup` defensive forward-compat; the /plan and /goal command templates each
410
+ carry their own full discipline, hermes-style: the entry turn is the
411
+ rulebook) + `src/plan-file.ts` / `src/goal-file.ts`
412
+ (pure document cores, unit-tested, no opencode imports) + `src/run-check.ts`
413
+ (shell/file-contract runner with tree-kill timeouts and a workspace path
414
+ guard) + `src/proc.ts` (shared spawn/tree-kill muscle) + `src/job-manager.ts`
415
+ (job registry, ownership, wake queue — pure logic) + `src/job-runner.ts`
416
+ (four-condition race, pipe capture, log tee). Behavioral changes go through the
417
+ OpenSpec workflow in `openspec/` — see AGENTS.md. Common pitfalls live in
418
+ `../opencode-plugin-dev-pitfalls.md`.
419
+
420
+ ## License
421
+
422
+ MIT