mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
8e420cb8c4
## Problem `wt config plugins opencode install` writes a plugin OpenCode 2 silently ignores, reported in #4014. OpenCode 2's loader decodes a plugin module's default export against a schema that accepts `{ id, effect }` or `{ id, setup }` — a bare default-exported function, which is what this plugin has always been, fails the decode and the load is dropped without a message ([`packages/core/src/plugin/module.ts#L60`](https://github.com/sst/opencode/blob/b2cecc6350d377c382e1ec32ee66ec63ad68f715/packages/core/src/plugin/module.ts#L60) on the `beta` branch, which is where the OpenCode 2 prereleases come from — `dev` is the 1.18.x line). The plugin also ran `wt` through Bun's `$`, which the host only supplies when it is itself running under Bun. ## Solution One file, both runtimes: the default export is now `{ id, setup, server }`, and `wt` is spawned with `node:child_process` instead of Bun's shell. OpenCode 2 reads `setup` and subscribes to session events; OpenCode 1.16+ reads `server` and gets exactly the hooks it has today. Each version ignores the other's key, so the install path, filename, and marker behavior are all unchanged. Two details in the `setup` arm that differ from the port in the issue's thread — both from reading the shipped `@opencode-ai/plugin@beta` types: `session.status` carries `status.type ∈ {idle, busy, retry}` and `session.idle` is annotated deprecated, so the status is read rather than the event name; and `session.status` with an `idle` status sets 💬 rather than 🤖. **The part worth a maintainer's call**: this keeps the OpenCode 1 shape rather than switching cleanly to v2 alone. OpenCode 2 is still prerelease (`npm dist-tags` for `@opencode-ai/plugin`: `latest` 1.18.29, `beta` 0.0.0-beta-19192), so a v2-only file would make every current stable install fail with `Plugin … must default export an object with server()`. The cost is the `server` arm plus its two local types — say the word and I will cut it to `{ id, setup }`. Either way OpenCode below 1.14.19 loses the plugin: that is where `readV1Plugin` — the function that admits an object-shaped default export — first appears, and older releases accept only the bare function. 1.16 is the floor worth documenting, though, because it is where the host began filtering events to each plugin instance's directory ([v1.16.0](https://github.com/sst/opencode/blob/6cb74317a6efacd656483cb0489d8e7e3701c12e/packages/opencode/src/plugin/index.ts#L258-L259)); [v1.15.0](https://github.com/sst/opencode/blob/2662a4f955e563fd22cd5c4873ca350d21745275/packages/opencode/src/plugin/index.ts#L246) fans every bus event to every instance, so on 1.14.19–1.15.x the marker follows the worktree the instance was created for rather than the session that is active — the same behavior today's plugin has, not a regression. ## Verification `cargo test --test integration test_docs_are_in_sync`, the twelve `opencode` integration tests, and `packaged_assets` pass. Beyond that I ran the new file through both loaders' own logic under `node --experimental-strip-types`, with a stub `wt` on `PATH` recording its arguments. <details><summary>Loader checks and recorded marker calls</summary> Decoding the real module with the exact schema OpenCode 2 uses (`effect@4.0.0-rc.112`, the version its plugin package depends on), and with OpenCode 1's [`readV1Plugin`](https://github.com/sst/opencode/blob/337fd144d2ba144743368f78d9579a99cce175bd/packages/opencode/src/plugin/shared.ts#L272) detect-mode logic: ``` OpenCode 2 module decode : ok, id=worktrunk OpenCode 1 readV1Plugin : ok, id=worktrunk v2 setup returned cleanup: true v2 setup on a 1.18 context: bails, no throw ``` The last line covers a case worth calling out: OpenCode 1.18 also carries a `{ id, setup }` loader, but the context it passes has neither `location` nor `event`. `setup` returns early there instead of throwing, and `server` drives the marker. Feeding a fake event stream, the commands the plugin actually issued: ``` v2: set 🤖 (status busy) set 🤖 (status retry) set 💬 (status idle) — skipped, event location is another worktree — set 💬 (session.idle) clear (session.deleted) clear (cleanup) v1: set 🤖 / set 💬 / clear (unchanged from today) ``` What I could not check from CI is the plugin running inside a real OpenCode 2 session — @pragmaticivan, if you are still up for testing, `wt config plugins opencode install` off this branch should behave the same as your local port. Install location is unchanged and confirmed on `beta`: plugins are still discovered as `.ts`/`.js` files under `plugin/`, `plugins/` in each config directory ([`source-directory.ts#L7`](https://github.com/sst/opencode/blob/b2cecc6350d377c382e1ec32ee66ec63ad68f715/packages/core/src/plugin/source-directory.ts#L7)). </details> Closes #4014 --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
131 lines
4.8 KiB
TypeScript
131 lines
4.8 KiB
TypeScript
// Worktrunk activity tracking plugin for OpenCode.
|
|
//
|
|
// Tracks OpenCode session activity per branch, showing status markers in `wt list`:
|
|
// 🤖 — agent is working
|
|
// 💬 — agent is waiting for input
|
|
//
|
|
// Installed globally via: wt config plugins opencode install
|
|
// Or manually: copy to ~/.config/opencode/plugins/worktrunk.ts
|
|
//
|
|
// One file, two plugin runtimes. OpenCode 2 decodes the default export as
|
|
// `{ id, setup }` and loads nothing else — the bare default-exported function
|
|
// this file used to be is rejected without a message. OpenCode 1 reads
|
|
// `{ id, server }` and never looks at `setup`; 1.16 is the floor that matters,
|
|
// where the host began filtering events per plugin instance (the object shape
|
|
// itself loads as far back as 1.14.19). Both ignore the other's key, so
|
|
// a single installed file works either side of the version boundary.
|
|
//
|
|
// The types below are declared locally rather than imported from
|
|
// `@opencode-ai/plugin`: its `Plugin` type means different things in the two
|
|
// versions, and a lone file in the config directory has no package to resolve.
|
|
|
|
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
|
|
const execute = promisify(execFile);
|
|
|
|
const WORKING = "🤖";
|
|
const WAITING = "💬";
|
|
|
|
/**
|
|
* Run `wt config state marker …` in this plugin instance's worktree.
|
|
*
|
|
* `cwd` is load-bearing: the marker belongs to the worktree the session is in,
|
|
* not to whatever directory the host process happens to sit in.
|
|
*/
|
|
async function marker(directory: string, args: string[]): Promise<void> {
|
|
try {
|
|
await execute("wt", ["config", "state", "marker", ...args], { cwd: directory });
|
|
} catch {
|
|
// `wt` may be off PATH, or the directory may no longer be a worktree. An
|
|
// activity marker is not worth raising an error into the session over.
|
|
}
|
|
}
|
|
|
|
// --- OpenCode 2 -------------------------------------------------------------
|
|
|
|
type SessionEvent = {
|
|
type: string;
|
|
location?: { directory: string };
|
|
data?: { status?: { type?: string } };
|
|
};
|
|
|
|
type SetupContext = {
|
|
location?: { directory: string };
|
|
event?: { subscribe(options?: { signal?: AbortSignal }): AsyncIterable<SessionEvent> };
|
|
};
|
|
|
|
function setup(context: SetupContext) {
|
|
const directory = context.location?.directory;
|
|
const events = context.event;
|
|
// OpenCode 1.18 also calls `setup`, from a second loader that passes neither
|
|
// of these. Its `server` hook below drives the marker there, so bail quietly
|
|
// rather than throwing out of a plugin the host did load.
|
|
if (!directory || !events) return;
|
|
|
|
const controller = new AbortController();
|
|
|
|
const watcher = (async () => {
|
|
for await (const event of events.subscribe({ signal: controller.signal })) {
|
|
// The event envelope makes `location` optional; the session events below
|
|
// carry one, and skipping the rest keeps each instance to its own worktree.
|
|
if (event.location?.directory !== directory) continue;
|
|
|
|
switch (event.type) {
|
|
case "session.status":
|
|
// Status is `idle | busy | retry`. OpenCode 2 marks `session.idle`
|
|
// deprecated in favor of this, so read the status, not the event name.
|
|
await marker(directory, ["set", event.data?.status?.type === "idle" ? WAITING : WORKING]);
|
|
break;
|
|
case "session.idle":
|
|
await marker(directory, ["set", WAITING]);
|
|
break;
|
|
case "session.deleted":
|
|
await marker(directory, ["clear"]);
|
|
break;
|
|
}
|
|
}
|
|
})().catch((error: unknown) => {
|
|
if (!controller.signal.aborted) {
|
|
console.error("Worktrunk activity tracking stopped", error);
|
|
}
|
|
});
|
|
|
|
// Awaiting the watcher before clearing keeps an in-flight `set` from landing
|
|
// after the `clear` and leaving a marker behind.
|
|
return async () => {
|
|
controller.abort();
|
|
await watcher;
|
|
await marker(directory, ["clear"]);
|
|
};
|
|
}
|
|
|
|
// --- OpenCode 1 (1.16+) -----------------------------------------------------
|
|
|
|
type ServerInput = { directory: string };
|
|
type HookInput = { event: { type: string } };
|
|
|
|
function server({ directory }: ServerInput) {
|
|
return {
|
|
// OpenCode 1.16+ filters events to this plugin's directory before calling
|
|
// the hook, so there is nothing to match on here. 1.15.x does not — it fans
|
|
// every bus event to every plugin instance — so the marker there follows the
|
|
// worktree the instance was created for, whichever session is active.
|
|
event: async ({ event }: HookInput) => {
|
|
switch (event.type) {
|
|
case "session.status":
|
|
await marker(directory, ["set", WORKING]);
|
|
break;
|
|
case "session.idle":
|
|
await marker(directory, ["set", WAITING]);
|
|
break;
|
|
case "session.deleted":
|
|
await marker(directory, ["clear"]);
|
|
break;
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
export default { id: "worktrunk", setup, server };
|