@sorenllm/opencode-forge 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +153 -3
  2. package/dist/index.js +1236 -68
  3. package/package.json +6 -3
package/README.md CHANGED
@@ -80,7 +80,20 @@ the plugin never writes them):
80
80
  "disable": true // one-knob return to native: no forge,
81
81
  // build/plan restored, no tools/commands
82
82
  }
83
- }
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
+ ]
84
97
  }
85
98
  ```
86
99
 
@@ -100,6 +113,9 @@ and the plugin's command of that name is not registered.
100
113
  4. Done — the hidden native `build`/`plan` agents come back automatically
101
114
  (the hide was runtime-only). Your `.opencode/plan/` and `.opencode/goal/`
102
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).
103
119
 
104
120
  ## File ledger
105
121
 
@@ -109,7 +125,10 @@ What this plugin touches, exhaustively:
109
125
  | --- | --- | --- |
110
126
  | `<project>/.opencode/plan/*.md` | plan files | user data — kept forever, uninstall never deletes |
111
127
  | `<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 |
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 |
113
132
  | `~/.cache/opencode/packages/...` | installed package copy | written by the `opencode plugin` installer, not the plugin |
114
133
  | `~/.config/opencode/opencode.json` | `plugin` array entry | written by the installer |
115
134
 
@@ -187,6 +206,129 @@ They are drafted by the model from your objective. Read them in the dialog
187
206
  before allowing the arm; `--contains` contracts are strictly
188
207
  workspace-relative (path escape is refused).
189
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
+
190
332
  ## Design stance (read before filing "bash is blocked" issues)
191
333
 
192
334
  During a plan's draft phase every mutating tool — `bash` included — is
@@ -195,6 +337,12 @@ read/grep/glob; if you genuinely need a shell command to decide the plan,
195
337
  approve the plan first (revising after approval is allowed via a new `/plan`).
196
338
  The escape hatches are `plan_approve` and `/plan discard`, by design.
197
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
+
198
346
  A process restart forgets the session binding: the write-ban soft-disables
199
347
  (safety over strictness) and the next session's system notice + `/plan
200
348
  resume` re-bind from the plan file on disk, which is the source of truth.
@@ -214,7 +362,9 @@ carry their own full discipline, hermes-style: the entry turn is the
214
362
  rulebook) + `src/plan-file.ts` / `src/goal-file.ts`
215
363
  (pure document cores, unit-tested, no opencode imports) + `src/run-check.ts`
216
364
  (shell/file-contract runner with tree-kill timeouts and a workspace path
217
- guard). Behavioral changes go through the
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
218
368
  OpenSpec workflow in `openspec/` — see AGENTS.md. Common pitfalls live in
219
369
  `../opencode-plugin-dev-pitfalls.md`.
220
370