@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.
- package/README.md +419 -223
- package/dist/index.js +1971 -68
- 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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
##
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
|
125
|
-
| --- | --- | --- |
|
|
126
|
-
|
|
|
127
|
-
|
|
|
128
|
-
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
`
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|