@sorenllm/opencode-forge 0.2.2 → 0.3.1

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