@sorenllm/opencode-forge 0.3.3 → 0.3.4

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 +18 -4
  2. package/dist/index.js +51 -13
  3. package/package.json +61 -61
package/README.md CHANGED
@@ -130,7 +130,7 @@ What this plugin touches, exhaustively:
130
130
  | `<tmp>/opencode-forge/jobs/ledger.jsonl` | job registry ledger (bounded: 1 MB reset, 200 entries) | runtime debris — delete freely, also after uninstall |
131
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
132
  | `<tmp>/opencode-forge/watchdog/log.jsonl` | watchdog interventions ledger (bounded: 200 entries, oldest rotated) | runtime debris — delete freely, also after uninstall |
133
- | `<tmp>/opencode-forge/dispatch/ledger.jsonl` | dispatch ledger: resolved identity/depth, outcomes, tokens/cost, timeouts, lost-on-exit, crew summaries (bounded, capped rotation) | runtime debris — delete freely, also after uninstall |
133
+ | `<tmp>/opencode-forge/dispatch/ledger.jsonl` | dispatch ledger: resolved identity/depth, outcomes, tokens/cost, timeouts, transport interruptions, lost-on-exit, crew summaries (bounded, capped rotation) | runtime debris — delete freely, also after uninstall |
134
134
  | `<project>/.opencode/forge.json`, `~/.config/opencode/forge.json` | your dispatch agent definitions (JSONC). **User data — the plugin only reads it, never writes or migrates it** | yours — version the project one, keep the global one out of sync tools if it holds machine-specific models |
135
135
  | `~/.cache/opencode/packages/...` | installed package copy | written by the `opencode plugin` installer, not the plugin |
136
136
  | `~/.config/opencode/opencode.json` | `plugin` array entry | written by the installer |
@@ -420,6 +420,18 @@ stops polling, suppresses the brief, and ledgers `killed`. On host exit,
420
420
  in-flight dispatches are ledgered `lost-on-exit` — sessions are host memory
421
421
  objects, there are no orphan processes and no survive semantics.
422
422
 
423
+ **Transport-interruption recovery**: the child's prompt is delivered by a
424
+ turn-synchronous HTTP POST that stays open for the whole first turn. If that
425
+ POST dies at the transport layer (fetch abort / network failure — anything
426
+ that is not an HTTP error response), the dispatch does **not** fail: the
427
+ engine ledgers `transport-interrupted` (with the child sessionID) and falls
428
+ back to completion polling under the same deadline — the child session is a
429
+ host-side object and usually keeps running, so its result is still collected
430
+ and delivered normally. The message is never re-POSTed (the prompt may
431
+ already be delivering); a message that never arrived polls as waiting and
432
+ ends in the honest deadline `timeout`. Terminal reports and terminal ledger
433
+ rows carry the child sessionID, dispatchId, parentSessionID, and durationMs.
434
+
423
435
  ### Crew workflow (`/crew`)
424
436
 
425
437
  `/crew <objective>` registers a crew: decompose the objective into
@@ -447,9 +459,11 @@ ends it honestly and the ledger keeps the history.
447
459
  live TUI sessions; prefer sync under `opencode run`.
448
460
  - **Host tool ceiling (~262s observed on 1.18.32)**: the host may kill a
449
461
  sync tool call that exceeds roughly 262 seconds even though the plugin's
450
- own deadline is higher. Keep `timeoutMs` at or below ~240000 for
451
- sync-heavy workflows; a killed call still leaves the child session
452
- reclaimable via `forge_dispatch_list` / the host API.
462
+ own deadline is higher. A same-family transport abort (~281s observed,
463
+ 2026-09-27 incident) can also kill the background dispatch's turn POST.
464
+ Transport-interruption recovery (above) absorbs both: the dispatch falls
465
+ back to polling and still delivers the child's result; keep `timeoutMs`
466
+ modest anyway for sync-heavy workflows.
453
467
  - **Child permission asks**: a worker that hits a permission ask surfaces the
454
468
  ask on the CHILD's session in the TUI. An unanswered ask stalls the child
455
469
  until the dispatch deadline — scope worker prompts to avoid permission
package/dist/index.js CHANGED
@@ -15331,23 +15331,41 @@ Vocabulary: ${r.error.vocabulary.join(", ") || "(none)"}`, r.error.code);
15331
15331
  parts: [{ type: "text", text: composeWorkerPrompt({ prompt: req.prompt, agent: req.agent, shape: r.shape, ...r.role ? { role: r.role } : {} }) }]
15332
15332
  })
15333
15333
  });
15334
- await Promise.race([turnPost, deadlinePromise]);
15335
- if (deadlineHit && deps.now() - t0 >= timeoutMs) {
15336
- const elapsed = deps.now() - t0;
15334
+ turnPost.catch(() => {});
15335
+ try {
15336
+ await Promise.race([turnPost, deadlinePromise]);
15337
+ if (deadlineHit && deps.now() - t0 >= timeoutMs) {
15338
+ const elapsed = deps.now() - t0;
15339
+ deps.sink({
15340
+ ts: new Date().toISOString(),
15341
+ event: "timeout",
15342
+ tier: req.agent,
15343
+ agent: req.agent,
15344
+ identity: r.model,
15345
+ depth: r.depth,
15346
+ sessionID: childID,
15347
+ durationMs: elapsed,
15348
+ ...dispatchId !== undefined ? { dispatchId, parentSessionID: parentSessionID ?? "" } : {}
15349
+ });
15350
+ throw new DispatchError(`forge_dispatch timed out after ${elapsed}ms (deadline ${timeoutMs}ms). Partial state: child session ${childID} is left for the host to reclaim; its transcript remains queryable via the host API.`, "timeout");
15351
+ }
15352
+ await turnPost;
15353
+ } catch (e) {
15354
+ if (e instanceof DispatchError)
15355
+ throw e;
15337
15356
  deps.sink({
15338
15357
  ts: new Date().toISOString(),
15339
- event: "timeout",
15358
+ event: "transport-interrupted",
15340
15359
  tier: req.agent,
15341
15360
  agent: req.agent,
15342
15361
  identity: r.model,
15343
15362
  depth: r.depth,
15344
15363
  sessionID: childID,
15345
- durationMs: elapsed,
15364
+ durationMs: deps.now() - t0,
15365
+ note: e instanceof Error ? e.message : String(e),
15346
15366
  ...dispatchId !== undefined ? { dispatchId, parentSessionID: parentSessionID ?? "" } : {}
15347
15367
  });
15348
- throw new DispatchError(`forge_dispatch timed out after ${elapsed}ms (deadline ${timeoutMs}ms). Partial state: child session ${childID} is left for the host to reclaim; its transcript remains queryable via the host API.`, "timeout");
15349
15368
  }
15350
- await turnPost;
15351
15369
  let poll = null;
15352
15370
  let messages = [];
15353
15371
  for (;; ) {
@@ -15424,24 +15442,44 @@ Vocabulary: ${r.error.vocabulary.join(", ") || "(none)"}`, r.error.code);
15424
15442
  return result;
15425
15443
  }
15426
15444
  function bgTerminal(reg, dispatchId, identity, req, t0, e) {
15445
+ let entrySessionID = "";
15446
+ let parentSessionID = "";
15447
+ try {
15448
+ const entry = reg.get(dispatchId);
15449
+ entrySessionID = entry.sessionID;
15450
+ parentSessionID = entry.parentSessionID;
15451
+ } catch {}
15452
+ const durationMs = deps.now() - t0;
15427
15453
  const report = {
15428
15454
  agent: req.agent,
15429
15455
  requested: { agent: req.agent, depth: req.depth },
15430
15456
  actual: { model: identity, depth: null },
15431
- sessionID: "",
15457
+ sessionID: entrySessionID,
15432
15458
  outcome: "error",
15433
- durationMs: deps.now() - t0,
15459
+ durationMs,
15434
15460
  error: e instanceof Error ? e.message : String(e)
15435
15461
  };
15436
15462
  if (e instanceof DispatchError && e.code === "timeout") {
15437
15463
  report.outcome = "timeout";
15438
15464
  reg.markTimeout(dispatchId, report);
15439
- deps.onTerminal?.(reg.get(dispatchId).parentSessionID);
15465
+ deps.onTerminal?.(parentSessionID);
15440
15466
  return;
15441
15467
  }
15442
- deps.sink({ ts: new Date().toISOString(), event: "error", tier: req.agent, identity, outcome: "error", note: report.error });
15468
+ deps.sink({
15469
+ ts: new Date().toISOString(),
15470
+ event: "error",
15471
+ tier: req.agent,
15472
+ agent: req.agent,
15473
+ identity,
15474
+ outcome: "error",
15475
+ sessionID: entrySessionID,
15476
+ durationMs,
15477
+ note: report.error,
15478
+ dispatchId,
15479
+ parentSessionID
15480
+ });
15443
15481
  reg.markError(dispatchId, report);
15444
- deps.onTerminal?.(reg.get(dispatchId).parentSessionID);
15482
+ deps.onTerminal?.(parentSessionID);
15445
15483
  }
15446
15484
  async function dispatch(req, parentSessionID, opts) {
15447
15485
  const t0 = deps.now();
@@ -15736,7 +15774,7 @@ function validateCrewReport(report, ledgerRows, crew) {
15736
15774
  continue;
15737
15775
  if (row.ts < crew.startedAt)
15738
15776
  continue;
15739
- if (row.event === "validation" || row.event === "resolve-error" || row.event === "refused-cap" || row.event === "models-dev-degraded" || row.event === "retry-excluded" || row.event === "lost-on-exit" || row.event === "kill-late-completion")
15777
+ if (row.event === "validation" || row.event === "resolve-error" || row.event === "refused-cap" || row.event === "models-dev-degraded" || row.event === "retry-excluded" || row.event === "lost-on-exit" || row.event === "kill-late-completion" || row.event === "transport-interrupted")
15740
15778
  continue;
15741
15779
  if (CREW_DISPATCH_EVENTS.has(row.event) && row.dispatchId)
15742
15780
  rowById.set(row.dispatchId, row);
package/package.json CHANGED
@@ -1,61 +1,61 @@
1
- {
2
- "name": "@sorenllm/opencode-forge",
3
- "version": "0.3.3",
4
- "description": "Single general-purpose forge agent for opencode with two orthogonal harnesses and two hang backstops: a plan harness (file-backed plans in .opencode/plan/, approve/close confirmation gates, tick discipline, hard write-ban while planning), a goal harness (autonomous, host-verified objectives in .opencode/goal/), a job supervisor (forge_shell with exit-bound completion — immune to stdio-EOF hangs — idle/success early return, background jobs with poll/log/kill/handoff, exit wakes on session idle), and a builtin-shell hang watchdog (marked timing across all sessions, graded tree-kill with dry-run/off modes, auditable ledger).",
5
- "type": "module",
6
- "main": "./dist/index.js",
7
- "exports": {
8
- ".": {
9
- "import": "./dist/index.js"
10
- },
11
- "./server": {
12
- "import": "./dist/index.js"
13
- }
14
- },
15
- "files": [
16
- "dist",
17
- "README.md"
18
- ],
19
- "scripts": {
20
- "bundle": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && bun build ./plugin.ts --outfile ./dist/index.js --target node --format esm",
21
- "test": "node --test tests/*.test.mjs",
22
- "typecheck": "tsc --noEmit"
23
- },
24
- "keywords": [
25
- "opencode",
26
- "opencode-plugin",
27
- "plan",
28
- "planning",
29
- "workflow",
30
- "single-agent",
31
- "goal",
32
- "background-jobs",
33
- "shell",
34
- "watchdog"
35
- ],
36
- "license": "MIT",
37
- "author": "chengsongren",
38
- "homepage": "https://github.com/ChengZiiii/opencode-forge#readme",
39
- "repository": {
40
- "type": "git",
41
- "url": "git+https://github.com/ChengZiiii/opencode-forge.git"
42
- },
43
- "bugs": {
44
- "url": "https://github.com/ChengZiiii/opencode-forge/issues"
45
- },
46
- "peerDependencies": {
47
- "@opencode-ai/plugin": "^1.18.0"
48
- },
49
- "devDependencies": {
50
- "@opencode-ai/plugin": "^1.18.0",
51
- "@types/node": "^22.13.9",
52
- "typescript": "^5.8.2"
53
- },
54
- "engines": {
55
- "opencode": "^1.18.0"
56
- },
57
- "publishConfig": {
58
- "access": "public",
59
- "registry": "https://registry.npmjs.org/"
60
- }
61
- }
1
+ {
2
+ "name": "@sorenllm/opencode-forge",
3
+ "version": "0.3.4",
4
+ "description": "Single general-purpose forge agent for opencode with two orthogonal harnesses and two hang backstops: a plan harness (file-backed plans in .opencode/plan/, approve/close confirmation gates, tick discipline, hard write-ban while planning), a goal harness (autonomous, host-verified objectives in .opencode/goal/), a job supervisor (forge_shell with exit-bound completion — immune to stdio-EOF hangs — idle/success early return, background jobs with poll/log/kill/handoff, exit wakes on session idle), and a builtin-shell hang watchdog (marked timing across all sessions, graded tree-kill with dry-run/off modes, auditable ledger).",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/index.js"
10
+ },
11
+ "./server": {
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "scripts": {
20
+ "bundle": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && bun build ./plugin.ts --outfile ./dist/index.js --target node --format esm",
21
+ "test": "node --test tests/*.test.mjs",
22
+ "typecheck": "tsc --noEmit"
23
+ },
24
+ "keywords": [
25
+ "opencode",
26
+ "opencode-plugin",
27
+ "plan",
28
+ "planning",
29
+ "workflow",
30
+ "single-agent",
31
+ "goal",
32
+ "background-jobs",
33
+ "shell",
34
+ "watchdog"
35
+ ],
36
+ "license": "MIT",
37
+ "author": "chengsongren",
38
+ "homepage": "https://github.com/ChengZiiii/opencode-forge#readme",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/ChengZiiii/opencode-forge.git"
42
+ },
43
+ "bugs": {
44
+ "url": "https://github.com/ChengZiiii/opencode-forge/issues"
45
+ },
46
+ "peerDependencies": {
47
+ "@opencode-ai/plugin": "^1.18.0"
48
+ },
49
+ "devDependencies": {
50
+ "@opencode-ai/plugin": "^1.18.0",
51
+ "@types/node": "^22.13.9",
52
+ "typescript": "^5.8.2"
53
+ },
54
+ "engines": {
55
+ "opencode": "^1.18.0"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public",
59
+ "registry": "https://registry.npmjs.org/"
60
+ }
61
+ }