Skip to content

OpenCode harness extension and the dld-core library - #43

Merged
jimutt merged 17 commits into
v1from
feat/opencode-dld-run
Aug 27, 2026
Merged

OpenCode harness extension and the dld-core library#43
jimutt merged 17 commits into
v1from
feat/opencode-dld-run

Conversation

@jimutt

@jimutt jimutt commented Aug 26, 2026

Copy link
Copy Markdown
Owner

What

The OpenCode V2 harness integration for dld-run: a server plugin driving the continuation loop and a TUI plugin rendering the status line, sidebar widget, and board overlay. Plus extensions/dld-core — a TypeScript-native library both harness extensions now share.

Why

Validates that the dld-run design is harness-agnostic: the same state layer, scripts, and run contract work on a second harness with a different extension model (client/server, SolidJS TUI). Live-tested end-to-end: full run lifecycle with the four-part completion transaction enforced by the plugin.

Contents

  • The spike (DL-016..DL-021): loop dispatch, start command, TUI surfaces, review-driven fixes, completion transaction
  • Cleanup: two review passes (correctness/security, then simplification/quality)
  • dld-core (DL-022): function-shaped API — typed mutations over an injectable exec, shared parser/render/state modules. This is Move 1 of docs/plan/typescript-core.md (the npm package direction)
  • All suites green: 305 bats, 104 bun, tsc, tessl lint

Notable

  • Targets OpenCode V2 beta (@opencode-ai/plugin@0.0.0-beta-18269, pinned and documented) — the V2 plugin API is explicitly unstable
  • Deferred: maxMinutes enforcement, async exec, packaging for opencode2 plugin add, synthetic-message resume: false check
  • Stacks on Pi harness extension: the dld-run skill and pi plugin #42 (the pi harness extension)

jimutt and others added 11 commits August 26, 2026 09:39
DL-016: V2 combined plugin (server + CLI), shared state layer
DL-017: Spike scope — validate state sharing, loop dispatch, exec, status, widget
DL-018: Full implementation — complete UI surface after the spike
Generated-By: pi 0.84.2
Generated-By: pi 0.84.2
Co-Authored-By: Kimi K3 <noreply@pi.dev>
The spike proves the core mechanism: an OpenCode V2 server plugin
subscribes to session.execution.succeeded, reads the active run from
.dld/runs/ via the shared bash scripts, and dispatches continuation
prompts through ctx.session.prompt. The state layer is identical to
the Pi extension.

Key findings:
- Loop dispatch works: session.execution.succeeded → ctx.session.prompt
- Workspace root comes from session.location.directory, not process.cwd()
- The event fires with two shapes; filter on data?.sessionID
- execSync replaces pi.exec; Bun.$ was not needed
- ctx.storage is server-only; the CLI plugin reads .dld/runs/ from disk
- The plugin loads from .opencode/plugins/ with @opencode-ai/plugin@beta

The TUI plugin (tui.tsx) implements status line, widget, and board
but is untested — it requires an interactive TUI session.

npm's @beta tag is stale (September 2025); bun installs the real
beta-18269. The sandbox npm proxy blocks packages published after
2026-07-31.

Generated-By: pi 0.84.2
Generated-By: pi 0.84.2
Co-Authored-By: Kimi K3 <noreply@pi.dev>
Gives the OpenCode spike real types and lets symlinked plugin files
resolve the package from dld-kit's node_modules.

Generated-By: pi 0.84.2
Generated-By: pi 0.84.2
Co-Authored-By: Kimi K3 <noreply@pi.dev>
The spike's command surface covered status, pause, resume, and stop but
not start — the first command a user tries. Worse, the 'Unknown
subcommand' synthetic message became agent context, and the agent
started exploring the filesystem to diagnose the failure.

The start flow mirrors the Pi extension: parseStartArgs handles range,
dash, and positional forms; guard-preconditions refuses early;
create-run plus add-item builds the run; a session.prompt kicks off
item 1 immediately.

Plugin messages are now prefixed '[dld-run plugin]' and phrased so the
agent's correct action is to relay them verbatim, not debug them.

DL-019 amends DL-017's spike scope.

Co-Authored-By: Kimi K3 <noreply@pi.dev>
Generated-By: pi 0.84.2
The first tui.tsx guessed the API surface. Real-world testing found:
- ctx.ui.slot, not ctx.slot; render returns JSX, not strings
- Keymap layers use Solid's useContext and must live inside a
  component — the board command mounts via an app-slot component,
  the pattern OpenCode's own storybook plugin uses
- Slot renders are not reactive to filesystem reads; a shared
  createSignal refreshed by fs.watch on .dld/runs/ drives re-renders.
  The agent mutates state by shelling out to scripts mid-turn, so no
  OpenCode event can drive this — the filesystem is the only place
  every writer is visible. A 10s interval backs the watcher up.
- JSX needs @jsxImportSource @opentui/solid or bun emits react
  imports that don't resolve outside React projects
- solid-js and @opentui/solid are peer deps of @opencode-ai/plugin;
  the install script installs them alongside

Verified live in llm-eval-service: status line updates during a run,
the loop dispatched both items, and the run completed.

Co-Authored-By: Kimi K3 <noreply@pi.dev>
Generated-By: pi 0.84.2
The first live test surfaced dispatch spam: a session resuming a run
with an in-flight item re-dispatched the identical prompt on every
execution completion. The Opus 5 review found the root causes:

- Dispatch guard: lastDispatch tracks which item each session was told
  to work; a turn ending mid-item no longer re-delivers it
- Error mapping mirrors Pi: exit 2 pauses, other non-zero skips,
  exit-0-empty completes — a script failure can no longer mark a run
  complete
- Paused runs resumable via list fallback; resume re-runs
  guard-preconditions (DL-004)
- execFileSync replaces execSync: argv arrays, no shell, no injection
  through slugs or titles, paths with spaces work
- TUI reactivity: signal reads moved inside JSX expressions — Solid
  untracks component bodies, which is why the sidebar went stale
  despite the fs.watch signal
- Pause interrupts the in-flight turn (DL-014), stop writes stopped,
  transitions append events, partial start blocks the run

Still unported, recorded in DL-020 as follow-up: the four-part
completion transaction and maxMinutes enforcement. Item completion
currently relies on the agent following the skill.

Accept DL-020.

Co-Authored-By: Kimi K3 <noreply@pi.dev>
Generated-By: pi 0.84.2
… accept DL-021

The spike shipped without DL-003's completion transaction — item
completion relied on the agent grading its own homework. The loop now
runs the transaction on session.execution.succeeded, before dispatch:

- verifying item with new evidence → verify-item.sh
- pass + review enabled → nag the agent once per item (synthetic)
- pass + review disabled → accept → repin → append-event, each step
  checked, a failure aborts the rest
- fail, attempts < 2 → back to implementing, dispatch guard cleared
  so the retry is delivered
- fail, attempts >= 2 → block-item, run paused, failure surfaced

Completion takes priority over dispatch, and the run status is
re-read after the transaction so a blocked-item pause doesn't fall
through to dispatching on stale state.

maxMinutes enforcement remains the one unported bound — separate
follow-up.

Co-Authored-By: Kimi K3 <noreply@pi.dev>
Generated-By: pi 0.84.2
Deletions: collapsed the three-way --decisions branch, removed the
dead onCleanup try/catch (Solid warns, never throws), made
blockedQuestions optional to match the guards, removed the dead
scriptPath import in the Pi run-state.ts, extracted itemIcon from
two duplicated ternary chains, padded range slugs (dl-014-022).

Correctness: verify-item.sh gets 300s timeout and 16MB buffer with
SIGTERM mapped to code 3 (infrastructure, not a verification failure
— the transaction surfaces it without counting an attempt); review
is required and fails toward more review; the item claim and the
partial-start rollback are checked, with a loud message when the
rollback itself fails; past-tense and statusMap narrowing cleaned up;
tui.tsx uses execFileSync like the server.

Co-Authored-By: Kimi K3 <noreply@pi.dev>
Generated-By: pi 0.84.2
docs/plan/typescript-core.md outlines the two-move path: dld-core as a
shared TS module with a function-shaped API (strangler-fig over the
bash scripts), then the state layer rewritten as TS and dld-kit
published as an npm package with a global CLI, following OpenSpec's
model.

This is a direction document, not a decision — each step gets its own
decision record as we implement it.

Co-Authored-By: Kimi K3 <noreply@pi.dev>
Generated-By: pi 0.84.2
Function-shaped API, no script paths or exit codes in the surface.
Reads are pure; mutations delegate to the bash scripts behind typed
results; exec is injectable so pi keeps its async sandbox and OpenCode
uses child processes directly. Path resolution is existence-checked.

This is Move 1 of docs/plan/typescript-core.md.

Co-Authored-By: Kimi K3 <noreply@pi.dev>
Generated-By: pi 0.84.2
…-022

dld-core is a TypeScript-native library with a function-shaped API:
no script paths, no argv, no exit codes in the surface.

- run-api.ts: typed mutations (createRun, addItem, setRunStatus,
  setItemStatus, repinItem, verifyItem, blockItem, nextItem,
  activeRun, resumableRun, guardPreconditions, appendRunEvent) over
  an injectable exec. verifyItem returns pass/fail/infrastructure —
  a timeout is not a test failure. This is the API the future npm
  package implements; the bash scripts are the implementation behind
  it for now.
- parse-start-args.ts: the third copy, now the only one, with tests.
  Range slugs are padded (dl-014-022) — the docstring always claimed
  this; the code finally agrees.
- run-state.ts, render.ts, paths.ts: moved from extensions/dld-run.
  packageRoot walks up until it finds the scripts directory instead
  of assuming a depth.

The OpenCode plugin deletes ~350 lines of duplicated logic and every
unjustified cast; the Pi extension's parser and readers redirect.
The widget window now anchors on the current item on both harnesses.

All suites green: 104 bun, 305 bats, tsc, tessl lint.

Co-Authored-By: Kimi K3 <noreply@pi.dev>
Generated-By: pi 0.84.2
@jimutt
jimutt changed the base branch from feat/pi-harness-extension to v1 August 27, 2026 07:32
jimutt and others added 6 commits August 27, 2026 09:40
…h prompt

Co-Authored-By: Kimi K3 <noreply@pi.dev>
Generated-By: pi 0.84.2
…ccept DL-023

A real run wedged after item 1: the agent implemented the decision,
committed, and asked 'Proceed?' — but never ran the state scripts, so
the item stayed implementing and the dispatch guard suppressed
re-delivery forever.

Two fixes, one decision:

1. Bounded re-delivery: the first suppression re-delivers the item
   once (the turn may have ended for reasons unrelated to the work);
   the second surfaces a wedge message and the loop stays quiet.
   Never two re-deliveries in a row — the anti-spam property holds.
   Resume clears the budgets so a resumed run starts fresh.

2. Inline protocol: the dispatch prompt carries the state-machine
   mechanics (set-item-status verifying, add-evidence, never
   self-accept, block-item for blockers, end the turn without asking)
   so a run works in projects without the dld-run skill loaded. The
   skill owns rationale and review; the dispatch needs only the
   mechanics that advance state.

Co-Authored-By: Kimi K3 <noreply@pi.dev>
Generated-By: pi 0.84.2
Two CI failures on the OpenCode branch:

1. npm ci failed with ERESOLVE: @opentui/solid@0.5.8 peers solid-js
   1.9.12 exactly, and package.json allowed ^1.9.15. Pinned to 1.9.12.

2. The DL-023 commit accidentally deleted the tracked
   package-lock.json (an rm during a local proxy workaround, swept
   into git add -A). The repo has used bun.lock since the extension
   landed; CI now matches local development with bun install
   --frozen-lockfile instead of npm ci.

Co-Authored-By: Kimi K3 <noreply@pi.dev>
Generated-By: pi 0.84.2
bun 1.2.23 can't parse lockfileVersion 2. Local development is on
1.4.1; CI matches.

Co-Authored-By: Kimi K3 <noreply@pi.dev>
Generated-By: pi 0.84.2
1.4.1 doesn't exist as a stable — 1.4.0 is the last non-canary.

Co-Authored-By: Kimi K3 <noreply@pi.dev>
Generated-By: pi 0.84.2
- typescript ^5.6.0 → ^7.0.2 (the native-compiler line; 5.9.3 was
  what the old pin resolved to)
- target ES2022 → ES2024, lib ES2023 → ES2024 (ES2025 lib isn't in
  TS 7's lib set yet)
- erasableSyntaxOnly: bans enums, namespaces, and parameter
  properties — everything that requires code generation, which is
  exactly what breaks source-only loaders (jiti, bun). It immediately
  caught a real violation: LoopController's constructor parameter
  property, now an explicit field assignment.

All suites green: 104 bun, 305 bats, tsc 7.0.2.

Co-Authored-By: Kimi K3 <noreply@pi.dev>
Generated-By: pi 0.84.2
@jimutt
jimutt marked this pull request as ready for review August 27, 2026 08:47
@jimutt
jimutt merged commit 290c807 into v1 Aug 27, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant