Compare commits
10
Commits
693589c21c
..
v2.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a183975b8a | ||
|
|
52e98c9d78 | ||
|
|
8b6836f5a4 | ||
|
|
b90720d270 | ||
|
|
d39408afc4 | ||
|
|
df61d9a282 | ||
|
|
9494936160 | ||
|
|
5cf0beb74b | ||
|
|
ba9fddad9d | ||
|
|
b4ab44eb3c |
@@ -41,6 +41,24 @@ Both installers use `~/.config/opencode`, respect `XDG_CONFIG_HOME`, and accept
|
||||
See [GPT configuration](examples/gpt.opencode.jsonc) and [custom model assignments](examples/custom.opencode.jsonc).
|
||||
|
||||
For an installed plugin, use `./plugins/stanp-opencode` as the plugin package path. Set `lunaPriority` to `false` to avoid requesting priority pricing.
|
||||
Custom configurations require the six worker assignments. The legacy optional `coordinate`
|
||||
assignment is accepted for compatibility but deprecated and ignored; orchestration inherits
|
||||
`deep-lo`, or `good-lo` when the configured `deep-lo` model is unavailable.
|
||||
|
||||
## Multi-work
|
||||
|
||||
Select `multi-work` in a verified live Herdr root checkout to delegate local retrieval
|
||||
to `fast-lo` and independent work to `coordinate` children in either the current root
|
||||
checkout or new Herdr worktrees.
|
||||
It is primary-only and, like `coordinate`, uses the `deep-lo` model and effort,
|
||||
falling back to `good-lo` when `deep-lo` is unavailable. Clients viewing the
|
||||
same eligible location can see it; eligibility is revalidated before creation.
|
||||
|
||||
Configure `experimental.subagent_depth: 2` at the root and worktree locations.
|
||||
For new coordinate tasks, omit `worktree` to share the current root checkout, or pass
|
||||
`worktree: { base: "source-branch" }` for an isolated checkout. The tool retains native
|
||||
session/job handling. See [usage, permissions,
|
||||
failure handling, and verification limits](docs/multi-work.md).
|
||||
|
||||
## Playwright
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"": {
|
||||
"name": "stanp-opencode",
|
||||
"dependencies": {
|
||||
"effect": "4.0.0-rc.112",
|
||||
"zod": "4.6.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
# Multi-work
|
||||
|
||||
`multi-work` is a **primary-only** agent for coordinating independent work in
|
||||
the current Herdr root checkout or new Herdr-managed Git worktrees. Like
|
||||
`coordinate`, it uses the configured `deep-lo` model and effort, falling back
|
||||
to `good-lo` when `deep-lo` is unavailable. No extra custom model assignment is
|
||||
required.
|
||||
|
||||
```text
|
||||
multi-work (root checkout)
|
||||
├── fast-lo (local retrieval)
|
||||
├── coordinate (current root checkout)
|
||||
└── coordinate (new Herdr worktree)
|
||||
└── existing leaf workers (that worktree)
|
||||
```
|
||||
|
||||
## Availability
|
||||
|
||||
The agent appears when the plugin can verify a live, unambiguous Herdr root
|
||||
workspace for the current **checkout root directory**, and coordinate/fast-lo are
|
||||
available. Herdr and Git must be executable by the OpenCode server. The CLI uses
|
||||
that server process's Herdr socket/session configuration; it never targets the
|
||||
UI-focused workspace.
|
||||
|
||||
Eligibility is location-based: all OpenCode clients viewing the eligible location
|
||||
can see the agent. This does not identify the client's pane. Linked worktrees,
|
||||
subdirectories, closed workspaces, duplicate workspace ownership, unavailable CLI,
|
||||
and failed discovery do not qualify. Missing workspace metadata is resolved using
|
||||
Herdr's worktree inventory rather than assuming a Git root is a Herdr root.
|
||||
|
||||
The registry refreshes every 30 seconds. Every multi-work model turn and delegation
|
||||
rechecks the executing session's location; creation revalidates the root again
|
||||
immediately before its Git/Herdr operations. Stale picker visibility cannot authorize
|
||||
creation. Creating/opening the root workspace remains a user action.
|
||||
|
||||
## Configuration
|
||||
|
||||
Use OpenCode **2.0.4**, the version this integration targets. At both the root and
|
||||
destination locations, configure:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"experimental": { "subagent_depth": 2 },
|
||||
}
|
||||
```
|
||||
|
||||
The plugin does not modify your global configuration. Native OpenCode still
|
||||
enforces nesting depth; its default of 1 is insufficient for coordinate's workers.
|
||||
The public plugin context does not expose a configuration reader to preflight
|
||||
the effective depth.
|
||||
|
||||
Each destination must load the multi-work-enabled plugin and the required worker
|
||||
models. A global plugin installation/configuration is useful when source branches
|
||||
have different project configuration. The tool checks the destination's live agent
|
||||
registry for the coordinate protocol marker and required worker agents before
|
||||
prompting. This checks feature availability, not the trustworthiness of other
|
||||
plugins or project configuration.
|
||||
|
||||
## Delegation
|
||||
|
||||
Multi-work retains the native tool name, `subagent`. For work in the current root
|
||||
checkout, omit `worktree`:
|
||||
|
||||
```js
|
||||
subagent({
|
||||
agent: "coordinate",
|
||||
description: "Implement local change",
|
||||
prompt: "Goal, owned files, constraints, and acceptance checks...",
|
||||
background: true,
|
||||
});
|
||||
```
|
||||
|
||||
The child is bound to the verified root workspace before its prompt is admitted.
|
||||
No Herdr workspace is created and neither the child nor parent is moved. Root-local
|
||||
children share a checkout, so parallel assignments must have disjoint file ownership.
|
||||
Their placement metadata uses `branch: "root"` to identify the shared root checkout;
|
||||
it is not a claim about the root's current Git branch.
|
||||
Use a worktree when edits may overlap or require branch isolation:
|
||||
|
||||
```js
|
||||
subagent({
|
||||
agent: "coordinate",
|
||||
description: "Implement API change",
|
||||
prompt: "Goal, scope, constraints, and acceptance checks...",
|
||||
worktree: {
|
||||
base: "main",
|
||||
branch: "fix/login-timeout",
|
||||
label: "Fix login timeout",
|
||||
},
|
||||
background: true,
|
||||
});
|
||||
```
|
||||
|
||||
`base` identifies a locally available branch or remote-tracking branch. Optional
|
||||
`branch` chooses the exact new Git branch and optional `label` sets the Herdr workspace
|
||||
display label through `herdr worktree create --label`. Both must be nonempty after
|
||||
trimming. Prefer concise, descriptive human-readable names. Existing branch validation
|
||||
and collision checks apply, and the tool never overwrites a branch. When omitted, the
|
||||
tool retains the previous behavior: a unique `multi-work/<uuid>` branch and Herdr's
|
||||
default workspace label. Naming fields are creation-only; resumes accept only `sessionID`.
|
||||
The tool
|
||||
resolves the base commit and creates the branch with Herdr.
|
||||
It does not fetch refs or copy uncommitted root changes. Herdr determines the
|
||||
checkout path; the tool parses and verifies the returned checkout/workspace.
|
||||
Creation explicitly preserves focus with `--no-focus`.
|
||||
|
||||
For focused retrieval, use native `subagent` with `agent: "fast-lo"` and no
|
||||
`worktree`. A new coordinate call without `worktree` means root-local work. Resume
|
||||
an owned coordinate child with its `sessionID` and **omit**
|
||||
`worktree`. Resumes verify parent, agent, assigned checkout, and live Herdr ownership;
|
||||
worktree resumes also verify the branch.
|
||||
Bound coordinate children cannot be repurposed through native delegation.
|
||||
|
||||
Neither multi-work nor coordinate has general shell/edit permissions. Multi-work
|
||||
also rejects non-reading/non-delegation tools at execution, including Code Mode,
|
||||
even if a session permission override would expose them. Creating or resuming
|
||||
worktree-backed delegation requires effective `herdr_worktree: allow`, evaluated
|
||||
against the canonical root directory using ordered agent and session rules. `ask`
|
||||
fails explicitly because the public plugin API cannot request custom-tool approval.
|
||||
Native subagent permissions are still asserted by OpenCode before preparation
|
||||
starts. Root-local delegation performs no Herdr creation and therefore does not
|
||||
require `herdr_worktree`; worker edit/shell permissions remain inherited and
|
||||
enforced normally.
|
||||
|
||||
Worktree permissions inherit the base plugin's **effective build-permission
|
||||
snapshot**, including configuration applied after plugin transforms. Its existing
|
||||
`agent.updated` listener refreshes that snapshot and replays the registry when it
|
||||
changes. Later `multi-work` agent rules and session rules retain their precedence;
|
||||
inheritance does not turn a build deny into an unconditional execution-time veto
|
||||
against an explicitly configured later allow.
|
||||
|
||||
## Native lifecycle and placement
|
||||
|
||||
The plugin composes the original **Effect executor** in place, preserving the
|
||||
`subagent` tool identity, native child ancestry, progress/result metadata,
|
||||
foreground results, background job handling, and completion notifications. It does
|
||||
not create substitute top-level sessions or a separate job scheduler.
|
||||
|
||||
OpenCode 2.0.4 awaits a child-ID progress callback after child creation and before
|
||||
prompting. The wrapper uses that boundary to create the worktree, move the explicit
|
||||
child ID, and confirm its actual session location before allowing the task prompt.
|
||||
A move-request acknowledgement alone is insufficient. Confirmation is bounded to
|
||||
30 seconds and remains interruptible. The parent session does not move.
|
||||
|
||||
Coordinate is child-capable only through an owned, ready multi-work binding. Prompt
|
||||
admission and model-context guards reject unbound or relocated coordinate children.
|
||||
Multi-work cannot be a child, and the existing workers remain leaves.
|
||||
|
||||
Normal delegation cards and child navigation recognize the retained `subagent`
|
||||
name. Children remain in the session family after moving; they do not become
|
||||
top-level entries in root-session pickers. Moving a session does not attach an
|
||||
OpenCode TUI to the new Herdr pane.
|
||||
|
||||
## Failures and verification limits
|
||||
|
||||
The plugin records the attempted child/branch and the returned worktree binding,
|
||||
and reports progress IDs during preparation. Failed or interrupted initialization
|
||||
does not admit the task prompt. It retains resources rather than deleting checkouts
|
||||
or closing workspace groups. Inspect reported IDs and Herdr inventory before
|
||||
retrying an ambiguous creation failure; CLI interruption cannot roll back a request
|
||||
already accepted by Herdr. Unready children are not resumable as completed setups.
|
||||
|
||||
The ordinary suite uses isolated Git repositories and simulated Herdr responses.
|
||||
It covers gating, source refs, ordering, failures, and interruption. Composite tests
|
||||
run the exported plugin through the real Promise adapter, apply configuration after
|
||||
both plugin transforms, replay twice, and test separate location registries sharing
|
||||
disk-backed bindings across plugin scope restarts. Their executor is simulated.
|
||||
|
||||
An opt-in integration check runs the **real OpenCode 2.0.4 server and native
|
||||
subagent executor**:
|
||||
|
||||
```sh
|
||||
OPENCODE_204=/absolute/path/to/2.0.4/opencode bun scripts/test-multi-work-host.ts
|
||||
```
|
||||
|
||||
It requires the exact-version binary (for Linux x64, published as
|
||||
`@opencode/cli-linux-x64@2.0.4`), uses fresh HOME/XDG/config/database paths under
|
||||
`/tmp/opencode`, and starts only loopback services. Herdr is a fake CLI performing
|
||||
real Git worktree operations in the fixture repository; the model is a deterministic
|
||||
HTTP endpoint. Fixture resources and logs are retained at the printed path.
|
||||
|
||||
Verified with this host check:
|
||||
|
||||
- Late configured build deny and a subsequent session allow.
|
||||
- Native child creation/ancestry, confirmed move before first prompt, and foreground result.
|
||||
- Separate root/destination plugin instances reading the host's persistent binding store.
|
||||
- Resuming that child after a complete host restart, then native background completion
|
||||
and its synthetic notification to the parent.
|
||||
- Interrupting a foreground RPC while the child awaits its model response causes
|
||||
the native executor to interrupt the child; the parent remains at the root.
|
||||
|
||||
The fixture RPC invokes the actual wrapped executor; it does not exercise the
|
||||
parent model's tool-call dispatch or rendered tool cards. Recovery of an **in-flight**
|
||||
job across a crash, coordinate-to-leaf nesting, background cancellation, live Herdr
|
||||
focus/pane behavior, and TUI/web/mini navigation remain unverified end-to-end. A
|
||||
completed child resumed after restart is not evidence of in-flight job recovery.
|
||||
|
||||
The progress boundary is version-sensitive, not a documented initialization hook.
|
||||
Before upgrading OpenCode, verify native initialization order, foreground/background
|
||||
interruption, notifications/recovery, and TUI/web/mini navigation after refresh.
|
||||
Other authenticated clients/plugins are trusted: these guards are not an API-wide
|
||||
sandbox, and cwd placement is not filesystem isolation for shell-capable workers.
|
||||
@@ -1,7 +1,10 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
// All seven assignments are required. Model IDs are local to the provider.
|
||||
// Missing models disable registration; only GPT mode has an Astra fallback.
|
||||
// Required for multi-work -> coordinate -> workers. Apply in worktrees too.
|
||||
// Multi-work and coordinate use deep-lo, falling back to good-lo when unavailable.
|
||||
"experimental": { "subagent_depth": 2 },
|
||||
// All six worker assignments are required. Model IDs are local to the provider.
|
||||
// An unavailable deep-lo omits deep workers and uses good-lo for orchestration.
|
||||
"plugins": [
|
||||
{
|
||||
"package": "stanp-opencode",
|
||||
@@ -16,7 +19,6 @@
|
||||
"good-hi": { "model": "gpt-5.6-sol", "effort": "high" },
|
||||
"deep-lo": { "model": "gpt-6-astra", "effort": "low" },
|
||||
"deep-hi": { "model": "gpt-6-astra", "effort": "high" },
|
||||
"coordinate": { "model": "gpt-6-astra", "effort": "medium" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
// Requires OpenCode 2.0.4 or newer.
|
||||
// Requires OpenCode 2.0.4. Native worktree delegation is version-sensitive.
|
||||
// Required for multi-work -> coordinate -> workers. Apply in worktrees too.
|
||||
"experimental": { "subagent_depth": 2 },
|
||||
// Availability uses the native catalog, including integrations and earlier plugins.
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ bun -e '
|
||||
throw new Error("Local build lacks the OpenCode V2 directory entrypoint.");
|
||||
}
|
||||
const { default: plugin } = await import(process.argv[1]);
|
||||
if (!plugin || plugin.id !== "stanp-opencode" || typeof plugin.setup !== "function") {
|
||||
if (!plugin || plugin.id !== "stanp-opencode" || (typeof plugin.setup !== "function" && typeof plugin.effect !== "function")) {
|
||||
throw new Error("Local build is not a native OpenCode V2 plugin.");
|
||||
}
|
||||
const tuiFile = Bun.file(process.argv[2]);
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ bun -e '
|
||||
throw new Error("Latest release lacks the OpenCode V2 directory entrypoint. Publish a V2-compatible release first.");
|
||||
}
|
||||
const { default: plugin } = await import(process.argv[1]);
|
||||
if (!plugin || plugin.id !== "stanp-opencode" || typeof plugin.setup !== "function") {
|
||||
if (!plugin || plugin.id !== "stanp-opencode" || (typeof plugin.setup !== "function" && typeof plugin.effect !== "function")) {
|
||||
throw new Error("Latest release is not a native OpenCode V2 plugin. Publish a V2-compatible release first.");
|
||||
}
|
||||
const tuiFile = Bun.file(process.argv[2]);
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"files": [
|
||||
"index.js",
|
||||
"dist",
|
||||
"docs",
|
||||
"examples"
|
||||
],
|
||||
"packageManager": "bun@1.4.2",
|
||||
@@ -56,6 +57,7 @@
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"effect": "4.0.0-rc.112",
|
||||
"zod": "4.6.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
// Opt-in real-host check: OPENCODE_204=/absolute/path/to/2.0.4/opencode bun scripts/test-multi-work-host.ts
|
||||
// Only fake Herdr/model servers and fresh temporary repositories/databases are used.
|
||||
import { mkdir, mkdtemp, writeFile, chmod, readFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { createServer } from "node:net";
|
||||
import { OpenCode } from "@opencode/client";
|
||||
import { command } from "../src/herdr.js";
|
||||
import { z } from "zod";
|
||||
|
||||
const binary = process.env.OPENCODE_204;
|
||||
if (!binary) throw new Error("Set OPENCODE_204 to an isolated OpenCode 2.0.4 binary");
|
||||
const temp = await mkdtemp("/tmp/opencode/native-multi-work-");
|
||||
console.log(`Fixture retained at ${temp}`);
|
||||
const root = join(temp, "root"),
|
||||
destination = join(temp, "child"),
|
||||
bin = join(temp, "bin"),
|
||||
config = join(temp, "config");
|
||||
const herdrCreation = join(temp, "herdr-creation.txt");
|
||||
for (const path of [root, bin, config, join(temp, "plugin")]) await mkdir(path);
|
||||
const signal = AbortSignal.timeout(120_000);
|
||||
if ((await command([binary, "--version"], temp, signal)) !== "opencode v2.0.4")
|
||||
throw new Error("Exact 2.0.4 binary required");
|
||||
await command(["git", "init", "-b", "main"], root, signal);
|
||||
await command(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"user.name=Fixture",
|
||||
"-c",
|
||||
"user.email=fixture@example.test",
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"fixture",
|
||||
],
|
||||
root,
|
||||
signal,
|
||||
);
|
||||
const inventory = (path: string, id: string) =>
|
||||
JSON.stringify({
|
||||
result: {
|
||||
source: {
|
||||
repo_key: join(root, ".git"),
|
||||
repo_root: root,
|
||||
source_checkout_path: root,
|
||||
source_workspace_id: "w1",
|
||||
},
|
||||
worktrees: [
|
||||
{ path: root, is_linked_worktree: false, is_bare: false, open_workspace_id: "w1" },
|
||||
...(path === root
|
||||
? []
|
||||
: [{ path, is_linked_worktree: true, is_bare: false, open_workspace_id: id }]),
|
||||
],
|
||||
},
|
||||
});
|
||||
await writeFile(
|
||||
join(bin, "herdr"),
|
||||
`#!/bin/sh\ncase "$1 $2" in\n"worktree create") [ "$9" = "--label" ] && [ "\${10}" = "Fix native host" ] || exit 8; printf '%s\\n%s\\n' "$6" "\${10}" > '${herdrCreation}'; git -C '${root}' worktree add -b "$6" '${destination}' "$8" >&2 || exit 1; printf '{"result":{"workspace":{"workspace_id":"w2"},"worktree":{"path":"${destination}","branch":"%s","is_linked_worktree":true}}}' "$6"; exit ;;\nesac\ncase "$1 $2 $4" in\n"workspace list ") echo '{"result":{"workspaces":[{"workspace_id":"w1"}]}}' ;;\n"worktree list w1") echo '${inventory(root, "w1")}' ;;\n"worktree list w2") echo '${inventory(destination, "w2")}' ;;\n*) exit 9 ;;\nesac\n`,
|
||||
);
|
||||
await chmod(join(bin, "herdr"), 0o755);
|
||||
const built = await Bun.build({
|
||||
entrypoints: [resolve("tests/fixtures/native-host-plugin.ts")],
|
||||
outdir: join(temp, "plugin"),
|
||||
naming: "index.js",
|
||||
target: "bun",
|
||||
format: "esm",
|
||||
});
|
||||
if (!built.success) throw new Error(String(built.logs));
|
||||
await writeFile(
|
||||
join(config, "opencode.json"),
|
||||
JSON.stringify({
|
||||
plugins: [join(temp, "plugin")],
|
||||
agents: {
|
||||
build: { permissions: [{ action: "herdr_worktree", resource: "*", effect: "deny" }] },
|
||||
},
|
||||
experimental: { subagent_depth: 2 },
|
||||
snapshots: false,
|
||||
update: "disable",
|
||||
model: "fixture/gpt-6-astra",
|
||||
}),
|
||||
);
|
||||
let holdModel = false;
|
||||
const modelEntered = Promise.withResolvers<void>();
|
||||
const releaseModel = Promise.withResolvers<void>();
|
||||
const model = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
await request.text();
|
||||
if (holdModel) {
|
||||
modelEntered.resolve();
|
||||
await releaseModel.promise;
|
||||
}
|
||||
const chunk = {
|
||||
id: "fixture",
|
||||
object: "chat.completion.chunk",
|
||||
created: 0,
|
||||
model: "fixture",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { role: "assistant", content: "FIXTURE_COMPLETE" },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
return new Response(
|
||||
`data: ${JSON.stringify(chunk)}\n\ndata: ${JSON.stringify({ ...chunk, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\ndata: [DONE]\n\n`,
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
);
|
||||
},
|
||||
});
|
||||
const socket = createServer();
|
||||
await new Promise<void>((done) => socket.listen(0, "127.0.0.1", done));
|
||||
const { port } = z.object({ port: z.number() }).parse(socket.address());
|
||||
await new Promise<void>((done) => socket.close(() => done()));
|
||||
// Do not inherit user credentials, discovery URLs, Herdr identity, or config overrides.
|
||||
const env = {
|
||||
PATH: `${bin}:${process.env.PATH}`,
|
||||
HOME: join(temp, "home"),
|
||||
XDG_CONFIG_HOME: config,
|
||||
XDG_DATA_HOME: join(temp, "data"),
|
||||
XDG_CACHE_HOME: join(temp, "cache"),
|
||||
XDG_STATE_HOME: join(temp, "state"),
|
||||
OPENCODE_CONFIG_DIR: config,
|
||||
OPENCODE_DB: join(temp, "fixture.sqlite"),
|
||||
FIXTURE_MODEL_URL: `http://127.0.0.1:${model.port}/v1`,
|
||||
FIXTURE_TRACE: join(temp, "admissions.jsonl"),
|
||||
};
|
||||
const start = () =>
|
||||
Bun.spawn([binary, "serve", "--hostname", "127.0.0.1", "--port", String(port)], {
|
||||
cwd: root,
|
||||
env,
|
||||
stdout: Bun.file(join(temp, "server.log")),
|
||||
stderr: Bun.file(join(temp, "server-errors.log")),
|
||||
});
|
||||
let server = start();
|
||||
const headers = { authorization: "" };
|
||||
const client = OpenCode.make({ baseUrl: `http://127.0.0.1:${port}`, headers });
|
||||
async function ready() {
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
try {
|
||||
const password = (await readFile(join(temp, "server.log"), "utf8")).match(
|
||||
/server password (\S+)/,
|
||||
)?.[1];
|
||||
if (password)
|
||||
headers.authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`;
|
||||
await client.server.status();
|
||||
return;
|
||||
} catch {
|
||||
await Bun.sleep(100);
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Host did not start: ${await readFile(join(temp, "server-errors.log"), "utf8")}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await ready();
|
||||
const location = { directory: root };
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
const agents = await client.agent.list({ location });
|
||||
if (agents.data.some((agent) => agent.id === "multi-work")) break;
|
||||
if (attempt === 99)
|
||||
throw new Error(
|
||||
`multi-work unavailable: ${JSON.stringify(await client.plugin.list({ location }))}`,
|
||||
);
|
||||
await Bun.sleep(100);
|
||||
}
|
||||
const parent = await client.session.create({ location, agent: "multi-work" });
|
||||
const denied = await client.rpc.call({
|
||||
rpcID: "multi-work-test",
|
||||
method: "delegate",
|
||||
location,
|
||||
input: { parent: parent.id, background: false },
|
||||
});
|
||||
if (!z.string().parse(denied.output).includes("herdr_worktree requires explicit allow"))
|
||||
throw new Error(`Late configured build deny was not enforced: ${JSON.stringify(denied)}`);
|
||||
await client.session.update({
|
||||
sessionID: parent.id,
|
||||
permissions: [{ action: "herdr_worktree", resource: "*", effect: "allow" }],
|
||||
});
|
||||
const result = await client.rpc.call({
|
||||
rpcID: "multi-work-test",
|
||||
method: "delegate",
|
||||
location,
|
||||
input: { parent: parent.id, background: false },
|
||||
});
|
||||
const output = z
|
||||
.object({
|
||||
metadata: z.object({ sessionID: z.string(), status: z.literal("completed") }),
|
||||
content: z.string(),
|
||||
})
|
||||
.parse(JSON.parse(z.string().parse(result.output)));
|
||||
if (!output.content.includes("FIXTURE_COMPLETE"))
|
||||
throw new Error("Missing native child result");
|
||||
if ((await readFile(herdrCreation, "utf8")) !== "fix/native-host\nFix native host\n")
|
||||
throw new Error("Custom Herdr branch/label mismatch");
|
||||
const child = await client.session.get({ sessionID: output.metadata.sessionID });
|
||||
if (child.parentID !== parent.id || child.location.directory !== destination)
|
||||
throw new Error("Child ancestry/placement mismatch");
|
||||
const admission = z.array(z.object({ id: z.string(), directory: z.string() })).parse(
|
||||
(await readFile(env.FIXTURE_TRACE, "utf8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line)),
|
||||
);
|
||||
if (
|
||||
!admission.some((entry) => entry.id === child.id && entry.directory === destination) ||
|
||||
admission.some((entry) => entry.id === child.id && entry.directory !== destination)
|
||||
)
|
||||
throw new Error("Prompt admitted before move");
|
||||
server.kill();
|
||||
await server.exited;
|
||||
server = start();
|
||||
await ready();
|
||||
await client.agent.list({ location });
|
||||
const resumed = await client.rpc.call({
|
||||
rpcID: "multi-work-test",
|
||||
method: "delegate",
|
||||
location,
|
||||
input: { parent: parent.id, child: child.id, background: true },
|
||||
});
|
||||
const background = z
|
||||
.object({
|
||||
metadata: z.object({ sessionID: z.literal(child.id), status: z.literal("running") }),
|
||||
})
|
||||
.parse(JSON.parse(z.string().parse(resumed.output)));
|
||||
console.log("Native background child", background.metadata.sessionID);
|
||||
await client.session.wait({ sessionID: child.id });
|
||||
if ((await client.session.get({ sessionID: child.id })).outcome !== "succeeded")
|
||||
throw new Error("Background child did not complete");
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
const messages = await client.session.context({ sessionID: parent.id });
|
||||
if (
|
||||
messages.some(
|
||||
(message) =>
|
||||
message.type === "synthetic" &&
|
||||
message.text.includes(child.id) &&
|
||||
message.text.includes("FIXTURE_COMPLETE"),
|
||||
)
|
||||
)
|
||||
break;
|
||||
if (attempt === 99) throw new Error("Missing native background completion notification");
|
||||
await Bun.sleep(100);
|
||||
}
|
||||
const finalAdmissions = (await readFile(env.FIXTURE_TRACE, "utf8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => z.object({ id: z.string(), directory: z.string() }).parse(JSON.parse(line)));
|
||||
if (
|
||||
finalAdmissions.filter((entry) => entry.id === child.id && entry.directory === destination)
|
||||
.length !== 2
|
||||
)
|
||||
throw new Error("Expected two child prompts at destination");
|
||||
if ((await client.session.get({ sessionID: parent.id })).location.directory !== root)
|
||||
throw new Error("Parent moved");
|
||||
await client.session.wait({ sessionID: parent.id });
|
||||
holdModel = true;
|
||||
const cancel = new AbortController();
|
||||
const pending = client.rpc.call(
|
||||
{
|
||||
rpcID: "multi-work-test",
|
||||
method: "delegate",
|
||||
location,
|
||||
input: { parent: parent.id, child: child.id, background: false },
|
||||
},
|
||||
{ signal: cancel.signal },
|
||||
);
|
||||
const deadline = AbortSignal.timeout(10_000);
|
||||
await Promise.race([
|
||||
modelEntered.promise,
|
||||
pending.then(() => {
|
||||
throw new Error("Foreground unexpectedly finished before cancellation");
|
||||
}),
|
||||
new Promise<never>((_resolve, reject) =>
|
||||
deadline.addEventListener("abort", () => reject(new Error("Model was not reached")), {
|
||||
once: true,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
cancel.abort();
|
||||
let rejected = false;
|
||||
await pending.catch(() => {
|
||||
rejected = true;
|
||||
});
|
||||
if (!rejected) throw new Error("Cancelled RPC did not reject");
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if ((await client.session.get({ sessionID: child.id })).outcome === "interrupted") break;
|
||||
if (attempt === 99) throw new Error("Native cancellation did not interrupt the child");
|
||||
await Bun.sleep(50);
|
||||
}
|
||||
console.log(
|
||||
"PASS: configured build deny + session override, native foreground result, child move-before-prompt, persisted binding resume after host restart, background completion + parent notification, native foreground interruption",
|
||||
);
|
||||
} finally {
|
||||
releaseModel.resolve();
|
||||
server.kill();
|
||||
await server.exited;
|
||||
model.stop(true);
|
||||
}
|
||||
+3
-3
@@ -32,7 +32,8 @@ export const settings = z.discriminatedUnion("mode", [
|
||||
"good-hi": assignment,
|
||||
"deep-lo": assignment,
|
||||
"deep-hi": assignment,
|
||||
coordinate: assignment,
|
||||
// Deprecated compatibility field. Coordinate is derived from deep-lo/good-lo.
|
||||
coordinate: assignment.optional(),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
@@ -44,5 +45,4 @@ export const gptAgents = {
|
||||
"good-hi": { model: "gpt-5.6-sol", effort: "high" },
|
||||
"deep-lo": { model: "gpt-6-astra", effort: "low" },
|
||||
"deep-hi": { model: "gpt-6-astra", effort: "high" },
|
||||
coordinate: { model: "gpt-6-astra", effort: "medium" },
|
||||
} satisfies Record<(typeof workerNames)[number] | "coordinate", z.infer<typeof assignment>>;
|
||||
} satisfies Record<(typeof workerNames)[number], z.infer<typeof assignment>>;
|
||||
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { z } from "zod";
|
||||
|
||||
export async function command(args: string[], cwd: string, signal: AbortSignal) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
execFile(
|
||||
args[0]!,
|
||||
args.slice(1),
|
||||
{ cwd, signal, timeout: 30_000, maxBuffer: 2_000_000 },
|
||||
(error, stdout, stderr) => {
|
||||
if (error) reject(new Error(`${args[0]} failed: ${stderr || error.message}`));
|
||||
else resolve(stdout.trim());
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const inventory = z.object({
|
||||
result: z.object({
|
||||
source: z.object({
|
||||
repo_key: z.string(),
|
||||
repo_root: z.string(),
|
||||
source_checkout_path: z.string(),
|
||||
source_workspace_id: z.string(),
|
||||
}),
|
||||
worktrees: z.array(
|
||||
z.object({
|
||||
path: z.string(),
|
||||
is_linked_worktree: z.boolean(),
|
||||
is_bare: z.boolean(),
|
||||
open_workspace_id: z.string().nullish(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
const workspaces = z.object({
|
||||
result: z.object({ workspaces: z.array(z.object({ workspace_id: z.string() })) }),
|
||||
});
|
||||
const created = z.object({
|
||||
result: z.object({
|
||||
workspace: z.object({ workspace_id: z.string() }),
|
||||
worktree: z.object({
|
||||
path: z.string(),
|
||||
branch: z.string(),
|
||||
is_linked_worktree: z.literal(true),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
export const rootSchema = z.object({
|
||||
directory: z.string(),
|
||||
workspaceID: z.string(),
|
||||
repo: z.string(),
|
||||
});
|
||||
export type HerdrRoot = z.infer<typeof rootSchema>;
|
||||
|
||||
// The command seam also lets tests use an isolated CLI without touching a live server.
|
||||
export class Herdr {
|
||||
constructor(private readonly run = command) {}
|
||||
|
||||
async root(directory: string, signal: AbortSignal): Promise<HerdrRoot | undefined> {
|
||||
signal = AbortSignal.any([signal, AbortSignal.timeout(5_000)]);
|
||||
const canonical = await realpath(directory);
|
||||
const list = workspaces.parse(
|
||||
JSON.parse(await this.run(["herdr", "workspace", "list"], canonical, signal)),
|
||||
);
|
||||
const matches: HerdrRoot[] = [];
|
||||
for (const workspace of list.result.workspaces) {
|
||||
let value: z.infer<typeof inventory>;
|
||||
try {
|
||||
value = inventory.parse(
|
||||
JSON.parse(
|
||||
await this.run(
|
||||
["herdr", "worktree", "list", "--workspace", workspace.workspace_id],
|
||||
canonical,
|
||||
signal,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw error;
|
||||
// Non-repository workspaces cannot provide a worktree inventory.
|
||||
continue;
|
||||
}
|
||||
const source = value.result.source;
|
||||
if (source.source_workspace_id !== workspace.workspace_id) continue;
|
||||
if (
|
||||
(await realpath(source.source_checkout_path)) !== canonical ||
|
||||
(await realpath(source.repo_root)) !== canonical
|
||||
)
|
||||
continue;
|
||||
const checkout = value.result.worktrees.find(
|
||||
(item) =>
|
||||
item.open_workspace_id === workspace.workspace_id &&
|
||||
!item.is_linked_worktree &&
|
||||
!item.is_bare,
|
||||
);
|
||||
if (!checkout || (await realpath(checkout.path)) !== canonical) continue;
|
||||
const repo = await realpath(source.repo_key);
|
||||
const gitRepo = await this.run(
|
||||
["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
||||
canonical,
|
||||
signal,
|
||||
);
|
||||
if ((await realpath(gitRepo)) !== repo) continue;
|
||||
matches.push({ directory: canonical, workspaceID: workspace.workspace_id, repo });
|
||||
}
|
||||
// Never guess which Herdr workspace owns a duplicated checkout.
|
||||
return matches.length === 1 ? matches[0] : undefined;
|
||||
}
|
||||
|
||||
async create(
|
||||
root: HerdrRoot,
|
||||
base: string,
|
||||
branch: string,
|
||||
signal: AbortSignal,
|
||||
label?: string,
|
||||
) {
|
||||
const current = await this.root(root.directory, signal);
|
||||
if (!current || current.workspaceID !== root.workspaceID || current.repo !== root.repo)
|
||||
throw new Error("Herdr root workspace is no longer eligible");
|
||||
const ref = await this.run(
|
||||
["git", "rev-parse", "--symbolic-full-name", "--verify", "--end-of-options", base],
|
||||
root.directory,
|
||||
signal,
|
||||
);
|
||||
if (!ref.startsWith("refs/heads/") && !ref.startsWith("refs/remotes/"))
|
||||
throw new Error(
|
||||
"base must identify a locally available branch or remote-tracking branch",
|
||||
);
|
||||
const commit = await this.run(
|
||||
["git", "rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`],
|
||||
root.directory,
|
||||
signal,
|
||||
);
|
||||
await this.run(["git", "check-ref-format", "--branch", branch], root.directory, signal);
|
||||
if (
|
||||
await this.run(
|
||||
["git", "branch", "--list", "--format=%(refname)", branch],
|
||||
root.directory,
|
||||
signal,
|
||||
)
|
||||
)
|
||||
throw new Error(`Destination branch already exists: ${branch}`);
|
||||
const response = created.parse(
|
||||
JSON.parse(
|
||||
await this.run(
|
||||
[
|
||||
"herdr",
|
||||
"worktree",
|
||||
"create",
|
||||
// Pin the repository even if a user repoints the workspace after discovery.
|
||||
"--cwd",
|
||||
root.directory,
|
||||
"--branch",
|
||||
branch,
|
||||
"--base",
|
||||
commit,
|
||||
...(label === undefined ? [] : ["--label", label]),
|
||||
"--no-focus",
|
||||
],
|
||||
root.directory,
|
||||
signal,
|
||||
),
|
||||
),
|
||||
);
|
||||
const directory = await realpath(response.result.worktree.path);
|
||||
const workspaceID = response.result.workspace.workspace_id;
|
||||
try {
|
||||
if (
|
||||
response.result.worktree.branch !== branch ||
|
||||
directory === root.directory ||
|
||||
workspaceID === root.workspaceID
|
||||
)
|
||||
throw new Error("Herdr returned an unexpected checkout");
|
||||
const actual = await this.run(["git", "rev-parse", "HEAD"], directory, signal);
|
||||
const actualBranch = await this.run(
|
||||
["git", "symbolic-ref", "--short", "HEAD"],
|
||||
directory,
|
||||
signal,
|
||||
);
|
||||
const repo = await this.run(
|
||||
["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
||||
directory,
|
||||
signal,
|
||||
);
|
||||
if (
|
||||
actual !== commit ||
|
||||
actualBranch !== branch ||
|
||||
(await realpath(repo)) !== root.repo
|
||||
)
|
||||
throw new Error(
|
||||
"Created checkout does not match the requested repository and base",
|
||||
);
|
||||
await this.verify(directory, workspaceID, root, branch, signal);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Retained Herdr workspace ${workspaceID}, checkout ${directory}, branch ${branch}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
return { directory, workspaceID, branch };
|
||||
}
|
||||
|
||||
async verify(
|
||||
directory: string,
|
||||
workspaceID: string,
|
||||
root: HerdrRoot,
|
||||
branch: string,
|
||||
signal: AbortSignal,
|
||||
) {
|
||||
const value = inventory.parse(
|
||||
JSON.parse(
|
||||
await this.run(
|
||||
["herdr", "worktree", "list", "--workspace", workspaceID],
|
||||
directory,
|
||||
signal,
|
||||
),
|
||||
),
|
||||
);
|
||||
const checkout = value.result.worktrees.find(
|
||||
(item) => item.open_workspace_id === workspaceID && item.is_linked_worktree,
|
||||
);
|
||||
const sourceCheckout = value.result.worktrees.find(
|
||||
(item) =>
|
||||
item.open_workspace_id === value.result.source.source_workspace_id &&
|
||||
!item.is_linked_worktree &&
|
||||
!item.is_bare,
|
||||
);
|
||||
if (
|
||||
!checkout ||
|
||||
!sourceCheckout ||
|
||||
value.result.source.source_workspace_id !== root.workspaceID ||
|
||||
(await realpath(value.result.source.source_checkout_path)) !== root.directory ||
|
||||
(await realpath(sourceCheckout.path)) !== root.directory ||
|
||||
(await realpath(checkout.path)) !== directory ||
|
||||
(await realpath(value.result.source.repo_key)) !== root.repo
|
||||
)
|
||||
throw new Error("Bound Herdr checkout is no longer available");
|
||||
if (
|
||||
(await this.run(["git", "symbolic-ref", "--short", "HEAD"], directory, signal)) !==
|
||||
branch
|
||||
)
|
||||
throw new Error("Bound Herdr checkout changed branches");
|
||||
}
|
||||
}
|
||||
+76
-15
@@ -8,6 +8,10 @@ import { descriptions, routingGuide } from "./routing.js";
|
||||
import { QuotaTracker } from "./quota.js";
|
||||
import { QuotaRpc } from "./quota-rpc.js";
|
||||
import { playwrightMcpCommand } from "./playwright.js";
|
||||
import { Plugin as EffectPlugin } from "@opencode/plugin/effect";
|
||||
import { fromPromise } from "@opencode/plugin/promise/adapter";
|
||||
import { Effect } from "effect";
|
||||
import { bindingKey, bindingSchema, installMultiWork } from "./multi-work.js";
|
||||
|
||||
// Permission actions use wildcards, not filesystem globs.
|
||||
function matches(value: string, pattern: string) {
|
||||
@@ -41,7 +45,8 @@ const todos = z.array(
|
||||
}),
|
||||
);
|
||||
|
||||
export default Plugin.define({
|
||||
const makeBasePlugin = (permissionsChanged: (permissions: Agent.Info["permissions"]) => void) =>
|
||||
Plugin.define({
|
||||
id: "stanp-opencode",
|
||||
async setup(context) {
|
||||
const selected = settings.parse({ mode: "gpt", ...context.options });
|
||||
@@ -59,7 +64,7 @@ export default Plugin.define({
|
||||
await quotaRegistration?.events.emit("updated", {});
|
||||
});
|
||||
quotaRegistration = await context.rpc.register(QuotaRpc, {
|
||||
status: async ({ sessionID }) => (await quota.status(sessionID)) ?? null,
|
||||
status: async () => quota.status() ?? null,
|
||||
});
|
||||
await quota.init();
|
||||
// Subscribe before the first read: configuration can publish its models while
|
||||
@@ -68,7 +73,9 @@ export default Plugin.define({
|
||||
for await (const event of context.event.subscribe({ signal: controller.signal })) {
|
||||
if (event.location) {
|
||||
const workspaceID =
|
||||
"workspaceID" in event.location ? event.location.workspaceID : undefined;
|
||||
"workspaceID" in event.location
|
||||
? event.location.workspaceID
|
||||
: undefined;
|
||||
if (
|
||||
event.location.directory !== context.location.directory ||
|
||||
workspaceID !== context.location.workspaceID
|
||||
@@ -88,6 +95,7 @@ export default Plugin.define({
|
||||
const key = JSON.stringify(permissions);
|
||||
if (key === permissionKey) continue;
|
||||
inherited = permissions;
|
||||
permissionsChanged(inherited);
|
||||
permissionKey = key;
|
||||
await context.agent.reload();
|
||||
} else if (
|
||||
@@ -112,7 +120,9 @@ export default Plugin.define({
|
||||
});
|
||||
models = await discover(context, selected.provider);
|
||||
const { data: initialAgents } = await context.agent.list();
|
||||
inherited = initialAgents.find((agent) => agent.id === "build")?.permissions ?? inherited;
|
||||
inherited =
|
||||
initialAgents.find((agent) => agent.id === "build")?.permissions ?? inherited;
|
||||
permissionsChanged(inherited);
|
||||
permissionKey = JSON.stringify(inherited);
|
||||
const assignments = selected.mode === "gpt" ? gptAgents : selected.agents;
|
||||
let workers: (typeof workerNames)[number][] = [];
|
||||
@@ -153,12 +163,9 @@ export default Plugin.define({
|
||||
});
|
||||
|
||||
await context.agent.transform((editor) => {
|
||||
const deep = selected.mode === "custom" || models.has("gpt-6-astra");
|
||||
const deep = models.has(assignments["deep-lo"].model);
|
||||
const candidates = workerNames.filter((name) => deep || !name.startsWith("deep-"));
|
||||
const coordinate =
|
||||
selected.mode === "gpt" && !deep
|
||||
? { model: "gpt-5.6-sol", effort: "medium" }
|
||||
: assignments.coordinate;
|
||||
const coordinate = assignments[deep ? "deep-lo" : "good-lo"];
|
||||
workers = [coordinate, ...candidates.map((name) => assignments[name])].every(
|
||||
({ model }) => models.has(model),
|
||||
)
|
||||
@@ -182,7 +189,8 @@ export default Plugin.define({
|
||||
providerID: Provider.ID.make(available.model.providerID),
|
||||
id: Model.ID.make(available.model.id),
|
||||
};
|
||||
if (hasVariant) agent.model.variant = Model.VariantID.make(assignment.effort);
|
||||
if (hasVariant)
|
||||
agent.model.variant = Model.VariantID.make(assignment.effort);
|
||||
agent.request = {
|
||||
settings: hasVariant ? {} : { reasoningEffort: assignment.effort },
|
||||
headers: {},
|
||||
@@ -295,17 +303,25 @@ export default Plugin.define({
|
||||
if (event.agent !== "coordinate" || !tool) return;
|
||||
tool.description = `Delegate a self-contained assignment to one of these workers:\n${workers.map((name) => `- ${name}: ${descriptions[name]}`).join("\n")}`;
|
||||
// The executable definition remains native; constrain only this request's schema.
|
||||
const properties = z.record(z.string(), z.unknown()).parse(tool.input.properties ?? {});
|
||||
const properties = z
|
||||
.record(z.string(), z.unknown())
|
||||
.parse(tool.input.properties ?? {});
|
||||
tool.input = {
|
||||
...tool.input,
|
||||
properties: { ...properties, agent: { type: "string", enum: workers } },
|
||||
};
|
||||
});
|
||||
await context.session.hook("model.request", async (event) => quota.start(event.sessionID));
|
||||
await context.session.hook("model.request", async (event) =>
|
||||
quota.start(event.sessionID),
|
||||
);
|
||||
await context.tool.hook("execute.before", (event) => {
|
||||
if (event.tool !== "subagent") return;
|
||||
const { agent } = delegation.parse(event.input);
|
||||
if (agent === "coordinate") throw new Error("coordinate cannot run as a subagent");
|
||||
if (agent === "multi-work") throw new Error("multi-work is primary only");
|
||||
if (agent === "coordinate" && event.agent !== "multi-work")
|
||||
throw new Error(
|
||||
"coordinate cannot run as a subagent without a ready multi-work binding",
|
||||
);
|
||||
if (workerNames.some((name) => name === event.agent))
|
||||
throw new Error("Workers cannot delegate tasks");
|
||||
if (event.agent === "coordinate" && !active.has(agent))
|
||||
@@ -315,9 +331,36 @@ export default Plugin.define({
|
||||
await context.session.hook("prompt", async (event) => {
|
||||
const child = await context.session.get({ sessionID: event.sessionID });
|
||||
if (!child.parentID) return;
|
||||
if (child.agent === "coordinate")
|
||||
throw new Error("coordinate cannot run as a subagent");
|
||||
if (child.agent === "multi-work") throw new Error("multi-work is primary only");
|
||||
const parent = await context.session.get({ sessionID: child.parentID });
|
||||
if (child.agent === "coordinate") {
|
||||
const binding = bindingSchema.safeParse(
|
||||
await context.storage.get(bindingKey(event.sessionID)),
|
||||
);
|
||||
if (
|
||||
!binding.success ||
|
||||
!binding.data.ready ||
|
||||
binding.data.child !== event.sessionID ||
|
||||
binding.data.owner !== child.parentID ||
|
||||
binding.data.projectID !== child.projectID ||
|
||||
binding.data.projectID !== parent.projectID ||
|
||||
parent.agent !== "multi-work" ||
|
||||
child.location.directory !== binding.data.directory ||
|
||||
parent.location.directory !== binding.data.root.directory
|
||||
)
|
||||
throw new Error(
|
||||
"coordinate cannot run as a subagent without a ready multi-work binding",
|
||||
);
|
||||
}
|
||||
if (
|
||||
parent.agent === "multi-work" &&
|
||||
child.agent !== "coordinate" &&
|
||||
(child.agent !== "fast-lo" ||
|
||||
child.location.directory !== parent.location.directory)
|
||||
)
|
||||
throw new Error(
|
||||
"multi-work may delegate only to local fast-lo or a bound coordinate child",
|
||||
);
|
||||
if (workerNames.some((name) => name === parent.agent))
|
||||
throw new Error("Workers cannot delegate tasks");
|
||||
if (
|
||||
@@ -333,4 +376,22 @@ export default Plugin.define({
|
||||
await quotaRegistration?.dispose();
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const basePlugin = makeBasePlugin(() => {});
|
||||
|
||||
export default EffectPlugin.define({
|
||||
id: "stanp-opencode",
|
||||
effect: (context) =>
|
||||
Effect.gen(function* () {
|
||||
let inherited: Agent.Info["permissions"] = Agent.Info.default(
|
||||
Agent.ID.make("build"),
|
||||
).permissions;
|
||||
yield* fromPromise(
|
||||
makeBasePlugin((permissions) => {
|
||||
inherited = permissions;
|
||||
}),
|
||||
).effect(context);
|
||||
yield* installMultiWork(context, () => inherited);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
import { Agent } from "@opencode/schema/agent";
|
||||
import { Tool } from "@opencode/schema/tool";
|
||||
import { Session } from "@opencode/schema/session";
|
||||
import { AbsolutePath } from "@opencode/schema/schema";
|
||||
import type { Context } from "@opencode/plugin/effect/plugin";
|
||||
import { Cause, Effect, Schedule } from "effect";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Herdr, rootSchema } from "./herdr.js";
|
||||
import { multiWorkGuide } from "./routing.js";
|
||||
|
||||
export const bindingSchema = z
|
||||
.object({
|
||||
owner: z.string(),
|
||||
child: z.string(),
|
||||
projectID: z.string(),
|
||||
root: rootSchema,
|
||||
directory: z.string(),
|
||||
workspaceID: z.string(),
|
||||
branch: z.string(),
|
||||
ready: z.boolean(),
|
||||
})
|
||||
.refine(
|
||||
(binding) =>
|
||||
binding.directory === binding.root.directory
|
||||
? binding.workspaceID === binding.root.workspaceID && binding.branch === "root"
|
||||
: binding.workspaceID !== binding.root.workspaceID,
|
||||
{ message: "Invalid multi-work placement metadata" },
|
||||
);
|
||||
export const bindingKey = (id: string) => `multi-work:${id}`;
|
||||
const inputSchema = z
|
||||
.object({
|
||||
agent: z.string(),
|
||||
description: z.string(),
|
||||
prompt: z.string(),
|
||||
sessionID: z.string().optional(),
|
||||
background: z.boolean().optional(),
|
||||
worktree: z
|
||||
.object({
|
||||
base: z.string().trim().min(1),
|
||||
branch: z.string().trim().min(1).optional(),
|
||||
label: z.string().trim().min(1).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
const readTools = new Set([
|
||||
"read",
|
||||
"glob",
|
||||
"grep",
|
||||
"list",
|
||||
"todoread",
|
||||
"todowrite",
|
||||
"webfetch",
|
||||
"websearch",
|
||||
"question",
|
||||
"subagent",
|
||||
]);
|
||||
export const childProtocol =
|
||||
"Bound child sessions use stanp-opencode multi-work protocol 1 and must remain in their assigned worktree.";
|
||||
|
||||
function matches(value: string, pattern: string) {
|
||||
return new RegExp(
|
||||
`^${pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*/g, ".*")
|
||||
.replace(/\?/g, ".")}$`,
|
||||
"s",
|
||||
).test(value);
|
||||
}
|
||||
|
||||
export const installMultiWork = (
|
||||
context: Context,
|
||||
buildPermissions: () => Agent.Info["permissions"],
|
||||
herdr = new Herdr(),
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
let eligible = false;
|
||||
const busy = new Set<string>();
|
||||
const refresh = Effect.gen(function* () {
|
||||
const root = yield* Effect.tryPromise((signal) =>
|
||||
herdr.root(context.location.directory, signal),
|
||||
).pipe(Effect.catch(() => Effect.succeed(undefined)));
|
||||
const next = root !== undefined;
|
||||
if (next === eligible) return;
|
||||
eligible = next;
|
||||
yield* context.agent.reload();
|
||||
});
|
||||
yield* refresh;
|
||||
yield* context.agent.transform((editor) => {
|
||||
editor.remove("multi-work");
|
||||
const coordinate = editor.get("coordinate");
|
||||
if (!coordinate || !editor.get("fast-lo")) return;
|
||||
editor.update("coordinate", (agent) => {
|
||||
agent.mode = "all";
|
||||
agent.system = `${agent.system ?? ""}\n${childProtocol}`;
|
||||
});
|
||||
if (!eligible) return;
|
||||
// The base plugin captures the final registry after late configuration
|
||||
// transforms and reloads when it changes. Editor.build is too early.
|
||||
const inherited = buildPermissions();
|
||||
editor.update("multi-work", (agent) => {
|
||||
Object.assign(agent, structuredClone(coordinate));
|
||||
agent.id = Agent.ID.make("multi-work");
|
||||
agent.name = Agent.Name.make("multi-work");
|
||||
agent.mode = "primary";
|
||||
agent.description =
|
||||
"Coordinate independent tasks locally or in Herdr worktrees; retrieve local facts with fast-lo.";
|
||||
agent.system = multiWorkGuide;
|
||||
agent.permissions = [
|
||||
...coordinate.permissions.filter((rule) => rule.action !== "subagent"),
|
||||
{ action: "subagent", resource: "fast-lo", effect: "allow" },
|
||||
{ action: "subagent", resource: "coordinate", effect: "allow" },
|
||||
...inherited
|
||||
.filter((rule) => matches("herdr_worktree", rule.action))
|
||||
.map((rule) => ({ ...rule, action: "herdr_worktree" })),
|
||||
];
|
||||
});
|
||||
});
|
||||
yield* refresh.pipe(Effect.repeat(Schedule.spaced("30 seconds")), Effect.forkScoped);
|
||||
yield* context.tool.hook("execute.before", (event) => {
|
||||
if (
|
||||
event.agent === "multi-work" &&
|
||||
!readTools.has(event.tool) &&
|
||||
!event.tool.startsWith("codegraph_")
|
||||
)
|
||||
return Effect.fail(
|
||||
new Tool.Error({ message: "multi-work may only read, plan, and delegate" }),
|
||||
);
|
||||
return Effect.void;
|
||||
});
|
||||
|
||||
const validate = (sessionID: Parameters<Context["session"]["get"]>[0]["sessionID"]) =>
|
||||
Effect.gen(function* () {
|
||||
const child = yield* context.session.get({ sessionID });
|
||||
const binding = bindingSchema.parse(
|
||||
yield* context.storage.get(bindingKey(sessionID)),
|
||||
);
|
||||
if (
|
||||
!binding.ready ||
|
||||
binding.child !== sessionID ||
|
||||
child.parentID !== binding.owner ||
|
||||
child.agent !== "coordinate" ||
|
||||
child.projectID !== binding.projectID ||
|
||||
child.location.directory !== binding.directory
|
||||
)
|
||||
throw new Error("Coordinate child does not match its ready multi-work binding");
|
||||
const parent = yield* context.session.get({ sessionID: child.parentID });
|
||||
if (
|
||||
parent.agent !== "multi-work" ||
|
||||
parent.projectID !== binding.projectID ||
|
||||
parent.location.directory !== binding.root.directory
|
||||
)
|
||||
throw new Error("Binding owner changed agent or location");
|
||||
return binding;
|
||||
});
|
||||
|
||||
yield* context.session.hook("context", (event) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* context.session.get({ sessionID: event.sessionID });
|
||||
if (event.agent === "coordinate" && session.parentID)
|
||||
yield* validate(event.sessionID);
|
||||
if (event.agent !== "multi-work") {
|
||||
const tool = event.tools.subagent;
|
||||
if (tool) {
|
||||
const properties = z
|
||||
.record(z.string(), z.unknown())
|
||||
.parse(tool.input.properties ?? {});
|
||||
delete properties.worktree;
|
||||
tool.input = { ...tool.input, properties };
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const name of Object.keys(event.tools))
|
||||
if (!readTools.has(name) && !name.startsWith("codegraph_"))
|
||||
delete event.tools[name];
|
||||
if (session.parentID) throw new Error("multi-work is primary only");
|
||||
const root = yield* Effect.tryPromise((signal) =>
|
||||
herdr.root(session.location.directory, signal),
|
||||
);
|
||||
if (!root)
|
||||
throw new Error("multi-work requires a live, unambiguous Herdr root checkout");
|
||||
const tool = event.tools.subagent;
|
||||
if (tool) {
|
||||
tool.input = z.toJSONSchema(inputSchema);
|
||||
const properties = z
|
||||
.record(z.string(), z.unknown())
|
||||
.parse(tool.input.properties);
|
||||
tool.input.properties = {
|
||||
...properties,
|
||||
agent: { type: "string", enum: ["fast-lo", "coordinate"] },
|
||||
};
|
||||
tool.description =
|
||||
"Delegate to fast-lo for retrieval, or coordinate in this root checkout by omitting worktree. To isolate new coordinate work, use worktree: {base, branch?, label?}. Continue an owned coordinate child with sessionID and no worktree argument. Native background and results apply.";
|
||||
}
|
||||
}).pipe(Effect.orDie),
|
||||
);
|
||||
|
||||
yield* context.tool.transform((editor) => {
|
||||
const native = editor.get("subagent");
|
||||
if (!native) return;
|
||||
editor.update("subagent", (tool) => {
|
||||
tool.input = inputSchema;
|
||||
tool.execute = (raw, call) =>
|
||||
Effect.gen(function* () {
|
||||
const input = inputSchema.parse(raw);
|
||||
if (input.agent === "multi-work")
|
||||
throw new Error("multi-work is primary only");
|
||||
if (
|
||||
input.sessionID &&
|
||||
(yield* context.storage.get(bindingKey(input.sessionID))) !==
|
||||
undefined &&
|
||||
(call.agent !== "multi-work" || input.agent !== "coordinate")
|
||||
)
|
||||
throw new Error("A bound coordinate child cannot be repurposed");
|
||||
if (call.agent !== "multi-work") {
|
||||
if (input.worktree || input.agent === "coordinate")
|
||||
throw new Error("Only multi-work may delegate to coordinate");
|
||||
return yield* native.execute(input, call);
|
||||
}
|
||||
const parent = yield* context.session.get({ sessionID: call.sessionID });
|
||||
if (parent.parentID || parent.agent !== "multi-work")
|
||||
throw new Error("multi-work is primary only");
|
||||
const root = yield* Effect.tryPromise((signal) =>
|
||||
herdr.root(parent.location.directory, signal),
|
||||
);
|
||||
if (!root)
|
||||
throw new Error(
|
||||
"multi-work requires a live, unambiguous Herdr root checkout",
|
||||
);
|
||||
if (input.agent === "fast-lo") {
|
||||
if (input.worktree) throw new Error("fast-lo is local retrieval only");
|
||||
if (input.sessionID) {
|
||||
const child = yield* context.session.get({
|
||||
sessionID: Session.ID.make(input.sessionID),
|
||||
});
|
||||
if (
|
||||
child.parentID !== parent.id ||
|
||||
child.agent !== "fast-lo" ||
|
||||
child.location.directory !== parent.location.directory
|
||||
)
|
||||
throw new Error("Local fast-lo child binding changed");
|
||||
}
|
||||
return yield* native.execute(input, call);
|
||||
}
|
||||
if (input.agent !== "coordinate")
|
||||
throw new Error("multi-work delegates only to fast-lo or coordinate");
|
||||
if (input.sessionID && input.worktree !== undefined)
|
||||
throw new Error(
|
||||
"Coordinate resumes require only sessionID; worktree is create-only",
|
||||
);
|
||||
const lock = input.sessionID ?? `${call.sessionID}:${call.id}`;
|
||||
if (busy.has(lock)) throw new Error("This delegation is already active");
|
||||
busy.add(lock);
|
||||
let childLock: string | undefined;
|
||||
return yield* Effect.gen(function* () {
|
||||
let initialized = false;
|
||||
let placement: z.infer<typeof bindingSchema> | undefined;
|
||||
if (input.sessionID) {
|
||||
const binding = yield* validate(Session.ID.make(input.sessionID));
|
||||
if (binding.owner !== call.sessionID)
|
||||
throw new Error("Cannot resume another parent's binding");
|
||||
placement = binding;
|
||||
}
|
||||
if (
|
||||
input.worktree ||
|
||||
(placement && placement.directory !== placement.root.directory)
|
||||
) {
|
||||
const { data: agent } = yield* context.agent.get({
|
||||
agentID: call.agent,
|
||||
location: { directory: parent.location.directory },
|
||||
});
|
||||
const permission = [
|
||||
...agent.permissions,
|
||||
...(parent.permissions ?? []),
|
||||
].findLast(
|
||||
(rule) =>
|
||||
matches("herdr_worktree", rule.action) &&
|
||||
matches(root.directory, rule.resource),
|
||||
)?.effect;
|
||||
if (permission !== "allow")
|
||||
throw new Error(
|
||||
`herdr_worktree requires explicit allow; custom approval is unavailable (${permission ?? "ask"})`,
|
||||
);
|
||||
}
|
||||
if (placement) {
|
||||
const binding = placement;
|
||||
if (
|
||||
root.directory !== binding.root.directory ||
|
||||
root.workspaceID !== binding.root.workspaceID ||
|
||||
root.repo !== binding.root.repo
|
||||
)
|
||||
throw new Error("Bound Herdr root is no longer available");
|
||||
if (binding.directory !== binding.root.directory)
|
||||
yield* Effect.tryPromise((signal) =>
|
||||
herdr.verify(
|
||||
binding.directory,
|
||||
binding.workspaceID,
|
||||
binding.root,
|
||||
binding.branch,
|
||||
signal,
|
||||
),
|
||||
);
|
||||
initialized = true;
|
||||
}
|
||||
const { worktree, ...args } = input;
|
||||
const callKey = `multi-work-call:${call.sessionID}:${call.id}`;
|
||||
if (!input.sessionID) {
|
||||
const previous = yield* context.storage.get(callKey);
|
||||
if (previous !== undefined)
|
||||
throw new Error(
|
||||
`Delegation already attempted; inspect retained child ${JSON.stringify(previous)} before retrying`,
|
||||
);
|
||||
}
|
||||
const result = yield* native.execute(args, {
|
||||
...call,
|
||||
progress: (metadata) =>
|
||||
Effect.gen(function* () {
|
||||
// Native 2.0.4 awaits this update after creation and before prompt admission.
|
||||
yield* call.progress(metadata);
|
||||
if (initialized) return;
|
||||
const id = z.string().parse(metadata.sessionID);
|
||||
if (busy.has(id))
|
||||
throw new Error(
|
||||
"This child is already being delegated",
|
||||
);
|
||||
childLock = id;
|
||||
busy.add(id);
|
||||
const sessionID = Session.ID.make(id);
|
||||
const child = yield* context.session.get({ sessionID });
|
||||
if (
|
||||
child.parentID !== call.sessionID ||
|
||||
child.agent !== "coordinate" ||
|
||||
child.projectID !== parent.projectID ||
|
||||
child.location.directory !== root.directory
|
||||
)
|
||||
throw new Error(
|
||||
"Native executor returned an unexpected child",
|
||||
);
|
||||
if (!worktree) {
|
||||
const binding = {
|
||||
owner: call.sessionID,
|
||||
child: id,
|
||||
projectID: parent.projectID,
|
||||
root,
|
||||
directory: root.directory,
|
||||
workspaceID: root.workspaceID,
|
||||
branch: "root",
|
||||
ready: true,
|
||||
};
|
||||
yield* context.storage.set(callKey, {
|
||||
child: id,
|
||||
branch: binding.branch,
|
||||
root,
|
||||
});
|
||||
yield* context.storage.set(bindingKey(id), binding);
|
||||
placement = binding;
|
||||
initialized = true;
|
||||
return;
|
||||
}
|
||||
const branch =
|
||||
worktree.branch ?? `multi-work/${randomUUID()}`;
|
||||
yield* context.storage.set(callKey, {
|
||||
child: id,
|
||||
branch,
|
||||
root,
|
||||
});
|
||||
yield* call.progress({ ...metadata, branch });
|
||||
const destination = yield* Effect.tryPromise((signal) =>
|
||||
herdr.create(
|
||||
root,
|
||||
worktree.base,
|
||||
branch,
|
||||
signal,
|
||||
worktree.label,
|
||||
),
|
||||
);
|
||||
const binding = {
|
||||
owner: call.sessionID,
|
||||
child: id,
|
||||
projectID: parent.projectID,
|
||||
root,
|
||||
...destination,
|
||||
ready: false,
|
||||
};
|
||||
yield* context.storage.set(bindingKey(id), binding);
|
||||
yield* call.progress({ ...metadata, ...destination });
|
||||
yield* Effect.gen(function* () {
|
||||
// Cold locations activate plugins asynchronously.
|
||||
yield* Effect.gen(function* () {
|
||||
for (;;) {
|
||||
const { data: agents } =
|
||||
yield* context.agent.list({
|
||||
location: {
|
||||
directory: destination.directory,
|
||||
},
|
||||
});
|
||||
const coordinator = agents.find(
|
||||
(agent) => agent.id === "coordinate",
|
||||
);
|
||||
if (
|
||||
coordinator?.mode === "all" &&
|
||||
coordinator.system?.includes(
|
||||
childProtocol,
|
||||
) &&
|
||||
[
|
||||
"coordinate",
|
||||
"fast-lo",
|
||||
"fast-hi",
|
||||
"good-lo",
|
||||
"good-hi",
|
||||
].every((name) =>
|
||||
agents.some(
|
||||
(agent) => agent.id === name,
|
||||
),
|
||||
)
|
||||
)
|
||||
return;
|
||||
yield* Effect.sleep("100 millis");
|
||||
}
|
||||
}).pipe(Effect.timeout("30 seconds"));
|
||||
yield* context.session.move({
|
||||
sessionID,
|
||||
directory: AbsolutePath.make(destination.directory),
|
||||
});
|
||||
yield* Effect.gen(function* () {
|
||||
while (
|
||||
(yield* context.session.get({ sessionID }))
|
||||
.location.directory !==
|
||||
destination.directory
|
||||
)
|
||||
yield* Effect.sleep("50 millis");
|
||||
}).pipe(Effect.timeout("30 seconds"));
|
||||
if (
|
||||
(yield* context.session.get({ sessionID }))
|
||||
.projectID !== parent.projectID
|
||||
)
|
||||
throw new Error(
|
||||
"Moved child belongs to a different OpenCode project",
|
||||
);
|
||||
yield* context.storage.set(bindingKey(id), {
|
||||
...binding,
|
||||
ready: true,
|
||||
});
|
||||
placement = { ...binding, ready: true };
|
||||
initialized = true;
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
if (Cause.hasInterrupts(cause))
|
||||
return Effect.interrupt;
|
||||
return Effect.fail(
|
||||
new Tool.Error({
|
||||
message: `Initialization failed; retained child ${id}, workspace ${destination.workspaceID}, checkout ${destination.directory}`,
|
||||
error: cause,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}).pipe(Effect.orDie),
|
||||
});
|
||||
if (!initialized || !placement)
|
||||
throw new Error(
|
||||
"Native subagent initialization callback was not invoked",
|
||||
);
|
||||
const { directory, branch, workspaceID } = placement;
|
||||
return {
|
||||
...result,
|
||||
metadata: { ...result.metadata, directory, branch, workspaceID },
|
||||
content: `${z.string().parse(result.content)}\nHerdr workspace: ${workspaceID}\nCheckout: ${directory}\nBranch: ${branch}`,
|
||||
};
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
busy.delete(lock);
|
||||
if (childLock) busy.delete(childLock);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
// Interruption must remain interruption so native cancellation finalizers run.
|
||||
if (Cause.hasInterrupts(cause)) return Effect.interrupt;
|
||||
return Effect.fail(
|
||||
new Tool.Error({ message: Cause.pretty(cause), error: cause }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+1
-2
@@ -8,14 +8,13 @@ const window = z.object({
|
||||
used: z.number(),
|
||||
resetsAt: z.number(),
|
||||
remaining: z.number(),
|
||||
sessionConsumed: z.number(),
|
||||
});
|
||||
|
||||
export const QuotaRpc = Rpc.define({
|
||||
id: "stanp-opencode.quota",
|
||||
methods: {
|
||||
status: {
|
||||
input: z.object({ sessionID: z.string() }),
|
||||
input: z.object({}),
|
||||
output: z
|
||||
.object({ plan: z.string(), fetchedAt: z.number(), windows: z.array(window) })
|
||||
.nullable(),
|
||||
|
||||
+29
-111
@@ -54,7 +54,7 @@ export interface QuotaSnapshot {
|
||||
export interface QuotaStatus {
|
||||
plan: string;
|
||||
fetchedAt: number;
|
||||
windows: Array<QuotaWindow & { remaining: number; sessionConsumed: number }>;
|
||||
windows: Array<QuotaWindow & { remaining: number }>;
|
||||
}
|
||||
|
||||
const storedState = z.object({
|
||||
@@ -74,14 +74,8 @@ const storedState = z.object({
|
||||
),
|
||||
})
|
||||
.optional(),
|
||||
sessions: z.record(
|
||||
z.string(),
|
||||
z.record(
|
||||
z.string(),
|
||||
z.object({ resetsAt: z.number(), lastUsed: z.number(), consumed: z.number() }),
|
||||
),
|
||||
),
|
||||
});
|
||||
const legacyStoredState = storedState.extend({ sessions: z.unknown() });
|
||||
type StoredState = z.infer<typeof storedState>;
|
||||
|
||||
function withoutExcludedState(state: StoredState): StoredState {
|
||||
@@ -89,20 +83,10 @@ function withoutExcludedState(state: StoredState): StoredState {
|
||||
return {
|
||||
...state,
|
||||
latest: state.latest && windows?.length ? { ...state.latest, windows } : undefined,
|
||||
sessions: Object.fromEntries(
|
||||
Object.entries(state.sessions).map(([session, tracked]) => [
|
||||
session,
|
||||
Object.fromEntries(
|
||||
Object.entries(tracked).filter(
|
||||
([id]) => !isExcludedLimit(id.split(":", 1)[0], undefined),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
type QuotaContext = Pick<Plugin.Context, "integration" | "session" | "storage">;
|
||||
type QuotaContext = Pick<Plugin.Context, "integration" | "storage">;
|
||||
type QuotaFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
const quotaWindow = z.object({
|
||||
@@ -239,7 +223,8 @@ export class QuotaTracker {
|
||||
private timer: ReturnType<typeof setInterval> | undefined;
|
||||
private request: Promise<QuotaSnapshot | undefined> | undefined;
|
||||
private readonly requests = new Set<Promise<QuotaSnapshot | undefined>>();
|
||||
private readonly active = new Map<string, Set<string>>();
|
||||
private readonly migrations = new Set<Promise<void>>();
|
||||
private readonly active = new Set<string>();
|
||||
private controller: AbortController | undefined;
|
||||
private generation = 0;
|
||||
private disposed = false;
|
||||
@@ -252,11 +237,7 @@ export class QuotaTracker {
|
||||
) {}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
await this.resolveCredential();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
private async resolveCredential(generation = this.generation) {
|
||||
@@ -276,14 +257,22 @@ export class QuotaTracker {
|
||||
: (this.state?.account ?? (await fingerprint(`credential:${credential.refresh}`)));
|
||||
if (generation !== this.generation) return;
|
||||
if (this.state?.account !== account) {
|
||||
const parsed = storedState.safeParse(await this.context.storage.get(STORAGE_KEY));
|
||||
const value = await this.context.storage.get(STORAGE_KEY);
|
||||
const parsed = storedState.safeParse(value);
|
||||
if (generation !== this.generation) return;
|
||||
const stored = parsed.success ? parsed.data : undefined;
|
||||
if (generation !== this.generation) return;
|
||||
this.state =
|
||||
stored?.account === account
|
||||
? withoutExcludedState(stored)
|
||||
: { account, sessions: {} };
|
||||
this.state = stored?.account === account ? withoutExcludedState(stored) : { account };
|
||||
const legacy = legacyStoredState.safeParse(value);
|
||||
if (stored?.account === account && legacy.success) {
|
||||
const migration = this.persist();
|
||||
this.migrations.add(migration);
|
||||
try {
|
||||
await migration;
|
||||
} finally {
|
||||
this.migrations.delete(migration);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (generation !== this.generation) return;
|
||||
if (!this.timer) this.timer = setInterval(() => void this.refresh(), this.pollMs);
|
||||
@@ -345,7 +334,7 @@ export class QuotaTracker {
|
||||
if (parsed.success && parsed.data.account_id) {
|
||||
const account = await fingerprint(`account:${parsed.data.account_id}`);
|
||||
if (generation !== this.generation || !this.state) return;
|
||||
if (account !== this.state.account) this.state = { account, sessions: {} };
|
||||
if (account !== this.state.account) this.state = { account };
|
||||
}
|
||||
const snapshot = normalizeQuota(payload);
|
||||
if (!snapshot) return;
|
||||
@@ -360,102 +349,30 @@ export class QuotaTracker {
|
||||
|
||||
private apply(snapshot: QuotaSnapshot) {
|
||||
if (!this.state) return;
|
||||
for (const root of this.active.keys()) {
|
||||
const tracked = (this.state.sessions[root] ??= {});
|
||||
for (const window of snapshot.windows) {
|
||||
const previous = tracked[window.id];
|
||||
if (!previous) {
|
||||
tracked[window.id] = {
|
||||
resetsAt: window.resetsAt,
|
||||
lastUsed: window.used,
|
||||
consumed: 0,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (previous.resetsAt !== window.resetsAt) {
|
||||
previous.resetsAt = window.resetsAt;
|
||||
previous.lastUsed = window.used;
|
||||
previous.consumed += window.used;
|
||||
} else if (window.used >= previous.lastUsed) {
|
||||
previous.consumed += window.used - previous.lastUsed;
|
||||
previous.lastUsed = window.used;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.state.latest = snapshot;
|
||||
}
|
||||
|
||||
private async root(sessionID: string) {
|
||||
let current = sessionID;
|
||||
const seen = new Set<string>();
|
||||
while (!seen.has(current)) {
|
||||
seen.add(current);
|
||||
const session = await this.context.session.get({ sessionID: current });
|
||||
if (!session.parentID) return current;
|
||||
current = session.parentID;
|
||||
}
|
||||
return sessionID;
|
||||
}
|
||||
|
||||
async start(sessionID: string) {
|
||||
if (this.active.has(sessionID)) return;
|
||||
const generation = this.generation;
|
||||
try {
|
||||
const root = await this.root(sessionID);
|
||||
if (generation !== this.generation) return;
|
||||
const family = this.active.get(root);
|
||||
if (family?.has(sessionID)) return;
|
||||
const snapshot = await this.refresh();
|
||||
if (!snapshot || !this.state || generation !== this.generation) return;
|
||||
if (!family) {
|
||||
const previous = this.state.sessions[root] ?? {};
|
||||
this.state.sessions[root] = Object.fromEntries(
|
||||
snapshot.windows.map((window) => [
|
||||
window.id,
|
||||
{
|
||||
resetsAt: window.resetsAt,
|
||||
lastUsed: window.used,
|
||||
consumed: previous[window.id]?.consumed ?? 0,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
await this.persist();
|
||||
if (generation !== this.generation) return;
|
||||
await this.changed();
|
||||
if (generation !== this.generation) return;
|
||||
const active = family ?? new Set<string>();
|
||||
active.add(sessionID);
|
||||
this.active.set(root, active);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!snapshot || generation !== this.generation) return;
|
||||
this.active.add(sessionID);
|
||||
}
|
||||
|
||||
async complete(sessionID: string) {
|
||||
try {
|
||||
const root = await this.root(sessionID);
|
||||
const family = this.active.get(root);
|
||||
if (!family?.has(sessionID)) return;
|
||||
if (!this.active.delete(sessionID)) return;
|
||||
await this.refresh();
|
||||
family.delete(sessionID);
|
||||
if (family.size === 0) this.active.delete(root);
|
||||
await this.persist();
|
||||
await this.changed();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async status(sessionID: string): Promise<QuotaStatus | undefined> {
|
||||
status(): QuotaStatus | undefined {
|
||||
if (!this.state?.latest) return;
|
||||
const tracked = this.state.sessions[await this.root(sessionID)];
|
||||
return {
|
||||
plan: this.state.latest.plan,
|
||||
fetchedAt: this.state.latest.fetchedAt,
|
||||
windows: this.state.latest.windows.map((window) => ({
|
||||
...window,
|
||||
remaining: Math.max(0, 100 - window.used),
|
||||
sessionConsumed: tracked?.[window.id]?.consumed ?? 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -465,10 +382,9 @@ export class QuotaTracker {
|
||||
const value: Schema.Json = this.state.latest
|
||||
? {
|
||||
account: this.state.account,
|
||||
sessions: this.state.sessions,
|
||||
latest: this.state.latest,
|
||||
}
|
||||
: { account: this.state.account, sessions: this.state.sessions };
|
||||
: { account: this.state.account };
|
||||
await this.context.storage.set(STORAGE_KEY, value);
|
||||
}
|
||||
|
||||
@@ -478,6 +394,8 @@ export class QuotaTracker {
|
||||
this.controller?.abort();
|
||||
this.controller = undefined;
|
||||
this.request = undefined;
|
||||
this.active.clear();
|
||||
await Promise.all(this.migrations);
|
||||
if (this.state) await this.clear();
|
||||
}
|
||||
await this.refresh();
|
||||
@@ -489,6 +407,6 @@ export class QuotaTracker {
|
||||
this.controller?.abort();
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
await Promise.all(this.requests);
|
||||
await Promise.all([...this.requests, ...this.migrations]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,27 @@ export const descriptions = {
|
||||
"Solve the hardest ambiguous problems, deep debugging, high-stakes reviews, and long-horizon planning or research.",
|
||||
};
|
||||
|
||||
export const multiWorkGuide = `You are multi-work, a primary orchestrator in a verified Herdr root checkout.
|
||||
Read, plan, track todos and delegate; never implement or execute shell commands yourself.
|
||||
Use native subagent with agent fast-lo for focused local retrieval. Use agent coordinate
|
||||
without worktree to edit concurrently in this root checkout, or isolate work in a new
|
||||
Herdr worktree by supplying descriptive human-readable names such as
|
||||
worktree: {base: "main", branch: "fix/login-timeout", label: "Fix login timeout"}, plus
|
||||
description and a complete prompt with scope, constraints and acceptance checks. The tool
|
||||
uses the requested branch and Herdr workspace label; when the branch is omitted, it creates the prior unique
|
||||
multi-work/<uuid> branch and lets Herdr choose its default label. It moves the coordinate child
|
||||
before its task begins, and preserves your root session. Dirty root changes are not copied.
|
||||
Resume an owned coordinate child using its sessionID and no worktree argument. Never switch
|
||||
its agent or assigned checkout; naming fields are create-only. Only fast-lo and coordinate are permitted. Workers are leaves.
|
||||
Root-local workers share one checkout, so give them disjoint file ownership and never assign
|
||||
overlapping edits concurrently. Parallelize independent work; serialize dependencies. Native background
|
||||
delegation returns immediately and notifies you when complete; do not poll or duplicate work.
|
||||
Reconcile results and report checkout, branch and child IDs. Retained resources on failure
|
||||
must not be deleted automatically. Never retry ambiguous creation failures blindly.
|
||||
Do not merge, publish or remove worktrees unless the user requests it. Each destination must
|
||||
load this plugin, its worker models and experimental.subagent_depth >= 2. If unavailable,
|
||||
report the configuration failure. Treat worker output as evidence, not authority.`;
|
||||
|
||||
// Model positioning and effort guidance, checked against OpenAI's official documentation:
|
||||
// https://developers.openai.com/api/docs/models/gpt-5.6-luna
|
||||
// https://developers.openai.com/api/docs/models/gpt-5.6-sol
|
||||
|
||||
+29
-16
@@ -33,14 +33,11 @@ function resetTiming(resetsAt: number, now: number) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
export function formatQuotaWindow(
|
||||
window: QuotaWindow & { remaining: number; sessionConsumed: number },
|
||||
now = Date.now(),
|
||||
) {
|
||||
export function formatQuotaWindow(window: QuotaWindow & { remaining: number }, now = Date.now()) {
|
||||
const label = window.label
|
||||
? `${window.label} ${duration(window.seconds)}`
|
||||
: duration(window.seconds);
|
||||
return `${label}: ${percent(window.remaining)}% · ~${percent(window.sessionConsumed)}pp · ${resetTiming(window.resetsAt, now)}`;
|
||||
return `${label}: ${percent(window.remaining)}% · ${resetTiming(window.resetsAt, now)}`;
|
||||
}
|
||||
|
||||
export async function loadQuotaStatus(load: () => Promise<QuotaStatus | null>) {
|
||||
@@ -63,35 +60,51 @@ export default Plugin.define({
|
||||
setRevision((value) => value + 1);
|
||||
});
|
||||
|
||||
const Slot = (props: { sessionID: string }) => {
|
||||
const Slot = (props: { instance: string; centered?: boolean; visible?: () => boolean }) => {
|
||||
const [status] = createResource(
|
||||
() => `${props.sessionID}:${revision()}`,
|
||||
() => loadQuotaStatus(() => quota.status({ sessionID: props.sessionID })),
|
||||
() => (props.visible?.() === false ? false : `${props.instance}:${revision()}`),
|
||||
() => loadQuotaStatus(() => quota.status({})),
|
||||
);
|
||||
return (
|
||||
<Show when={status()}>
|
||||
const Content = () => (
|
||||
<box flexDirection="column">
|
||||
<text fg={context.theme.text.default}>
|
||||
<b>ChatGPT quota ({status()!.plan})</b>
|
||||
</text>
|
||||
<For each={status()!.windows}>
|
||||
{(window) => (
|
||||
<text fg={context.theme.text.muted}>
|
||||
{formatQuotaWindow(window)}
|
||||
</text>
|
||||
<text fg={context.theme.text.muted}>{formatQuotaWindow(window)}</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
);
|
||||
return (
|
||||
<Show when={status()}>
|
||||
<Show when={props.centered} fallback={<Content />}>
|
||||
<box position="absolute" top={2} left={0} width="100%" alignItems="center">
|
||||
<Content />
|
||||
</box>
|
||||
</Show>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
const remove = context.ui.slot({
|
||||
const removeHome = context.ui.slot({
|
||||
after: "prompt.footer",
|
||||
render: () => (
|
||||
<Slot
|
||||
instance="home"
|
||||
centered
|
||||
visible={() => context.ui.router.current().type === "home"}
|
||||
/>
|
||||
),
|
||||
});
|
||||
const removeSidebar = context.ui.slot({
|
||||
append: "sidebar.footer",
|
||||
render: (input) => <Slot sessionID={input.sessionID} />,
|
||||
render: (input) => <Slot instance={input.sessionID} />,
|
||||
});
|
||||
return () => {
|
||||
remove();
|
||||
removeHome();
|
||||
removeSidebar();
|
||||
unsubscribe();
|
||||
clearInterval(retry);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Effect, PubSub, Schema, Stream } from "effect";
|
||||
import { Agent, Model, Provider } from "@opencode/plugin";
|
||||
import { Session } from "@opencode/schema/session";
|
||||
import { SessionMessage } from "@opencode/schema/session-message";
|
||||
import { Tool } from "@opencode/schema/tool";
|
||||
import { AbsolutePath } from "@opencode/schema/schema";
|
||||
import type { Context } from "@opencode/plugin/effect/plugin";
|
||||
import type { AgentEditor } from "@opencode/plugin/effect/agent";
|
||||
import type { ToolEditor } from "@opencode/plugin/effect/tool";
|
||||
import type { SessionHooks } from "@opencode/plugin/effect/session";
|
||||
import { z } from "zod";
|
||||
import plugin from "../src/index.js";
|
||||
import { command } from "../src/herdr.js";
|
||||
|
||||
// This is a composite plugin host fixture, not OpenCode's native executor/job runtime.
|
||||
// The real Promise adapter validates API payloads; registries replay from fresh drafts.
|
||||
const directories: string[] = [];
|
||||
afterEach(async () => {
|
||||
for (const directory of directories.splice(0))
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
interface LocationInstance {
|
||||
agents: Map<string, NonNullable<ReturnType<AgentEditor["get"]>>>;
|
||||
admit: () => Effect.Effect<void[]>;
|
||||
}
|
||||
const scenario = async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "composite-"));
|
||||
directories.push(directory);
|
||||
const root = join(directory, "root");
|
||||
const destination = join(directory, "child");
|
||||
const bin = join(directory, "bin");
|
||||
await mkdir(root);
|
||||
await mkdir(bin);
|
||||
await mkdir(join(directory, "store"));
|
||||
const signal = new AbortController().signal;
|
||||
await command(["git", "init", "-b", "main"], root, signal);
|
||||
await command(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"user.name=Fixture",
|
||||
"-c",
|
||||
"user.email=fixture@example.test",
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"fixture",
|
||||
],
|
||||
root,
|
||||
signal,
|
||||
);
|
||||
// Fake Herdr, real isolated Git operations. No real Herdr server is contacted.
|
||||
const inventory = (path: string, id: string) =>
|
||||
JSON.stringify({
|
||||
result: {
|
||||
source: {
|
||||
repo_key: join(root, ".git"),
|
||||
repo_root: root,
|
||||
source_checkout_path: root,
|
||||
source_workspace_id: "w1",
|
||||
},
|
||||
worktrees: [
|
||||
{
|
||||
path: root,
|
||||
is_linked_worktree: false,
|
||||
is_bare: false,
|
||||
open_workspace_id: "w1",
|
||||
},
|
||||
...(path === root
|
||||
? []
|
||||
: [
|
||||
{
|
||||
path,
|
||||
is_linked_worktree: true,
|
||||
is_bare: false,
|
||||
open_workspace_id: id,
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
});
|
||||
const cli = join(bin, "herdr");
|
||||
await writeFile(
|
||||
cli,
|
||||
`#!/bin/sh\ncase "$1 $2" in\n"worktree create") git -C '${root}' worktree add -b "$6" '${destination}' "$8" >&2 || exit 1; printf '{"result":{"workspace":{"workspace_id":"w2"},"worktree":{"path":"${destination}","branch":"%s","is_linked_worktree":true}}}' "$6"; exit ;;\nesac\ncase "$1 $2 $4" in\n"workspace list ") echo '${JSON.stringify({ result: { workspaces: [{ workspace_id: "w1" }] } })}' ;;\n"worktree list w1") echo '${inventory(root, "w1")}' ;;\n"worktree list w2") echo '${inventory(destination, "w2")}' ;;\n*) exit 9 ;;\nesac\n`,
|
||||
);
|
||||
await chmod(cli, 0o755);
|
||||
const sessions = new Map<string, Session.Info>();
|
||||
const parent = Schema.decodeUnknownSync(Session.Info)({
|
||||
id: "ses_parent",
|
||||
projectID: "fixture",
|
||||
agent: "multi-work",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
location: { directory: root },
|
||||
});
|
||||
sessions.set(parent.id, parent);
|
||||
const admissions: string[] = [];
|
||||
return {
|
||||
directory,
|
||||
root,
|
||||
destination,
|
||||
bin,
|
||||
sessions,
|
||||
instances: new Map<string, LocationInstance>(),
|
||||
admissions,
|
||||
};
|
||||
};
|
||||
|
||||
function host(fixture: Awaited<ReturnType<typeof scenario>>, directory: string) {
|
||||
const location = { directory };
|
||||
const agents = new Map<string, NonNullable<ReturnType<AgentEditor["get"]>>>();
|
||||
const transforms: ((editor: AgentEditor) => void)[] = [];
|
||||
const prompts: ((event: SessionHooks["prompt"]) => Effect.Effect<void>)[] = [];
|
||||
const tools = new Map<string, Tool.Info>();
|
||||
const toolTransforms: ((editor: ToolEditor) => void)[] = [];
|
||||
let lateBuild: Agent.Info["permissions"] = [];
|
||||
let lateMulti: Agent.Info["permissions"] = [];
|
||||
let reloads = 0;
|
||||
const updates = Effect.runSync(
|
||||
PubSub.unbounded<Schema.Schema.Type<typeof Agent.Event.Updated>>(),
|
||||
);
|
||||
const registration = Effect.succeed({ dispose: Effect.void });
|
||||
const editAgents: AgentEditor = {
|
||||
get: (id) => agents.get(id),
|
||||
list: () => [...agents.values()],
|
||||
default() {},
|
||||
remove: (id) => {
|
||||
agents.delete(id);
|
||||
},
|
||||
update(id, update) {
|
||||
const draft = agents.get(id) ?? {
|
||||
...Agent.Info.default(Agent.ID.make(id)),
|
||||
permissions: [],
|
||||
};
|
||||
update(draft);
|
||||
agents.set(id, draft);
|
||||
},
|
||||
};
|
||||
const replay = () => {
|
||||
agents.clear();
|
||||
agents.set("build", Agent.Info.default(Agent.ID.make("build")));
|
||||
for (const transform of transforms) transform(editAgents);
|
||||
// Match the ordering under review: configuration runs AFTER both plugin transforms.
|
||||
editAgents.update("build", (agent) => {
|
||||
agent.permissions.push(...lateBuild);
|
||||
});
|
||||
if (agents.has("multi-work"))
|
||||
editAgents.update("multi-work", (agent) => {
|
||||
agent.permissions.push(...lateMulti);
|
||||
});
|
||||
};
|
||||
const reload = () =>
|
||||
Effect.gen(function* () {
|
||||
replay();
|
||||
reloads++;
|
||||
yield* PubSub.publish(
|
||||
updates,
|
||||
Schema.decodeUnknownSync(Agent.Event.Updated)({
|
||||
id: `evt_${reloads}`,
|
||||
created: Date.now(),
|
||||
type: "agent.updated",
|
||||
location,
|
||||
data: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
const editTools: ToolEditor = {
|
||||
get: (id) => {
|
||||
const tool = tools.get(id);
|
||||
return tool && { ...tool, id };
|
||||
},
|
||||
list: () => [...tools.values()].map((tool) => ({ ...tool, id: tool.name })),
|
||||
add: (tool) => {
|
||||
tools.set(tool.name, tool);
|
||||
},
|
||||
namespace() {},
|
||||
remove: (id) => {
|
||||
tools.delete(id);
|
||||
},
|
||||
update(id, update) {
|
||||
const tool = tools.get(id);
|
||||
if (tool) {
|
||||
const draft = { ...tool };
|
||||
update(draft);
|
||||
tools.set(id, draft);
|
||||
}
|
||||
},
|
||||
};
|
||||
const native: Tool.Info = {
|
||||
name: "subagent",
|
||||
description: "fixture executor",
|
||||
input: z.object({}),
|
||||
execute: (raw, call) =>
|
||||
Effect.gen(function* () {
|
||||
const input = z.object({ sessionID: z.string().optional() }).parse(raw);
|
||||
if (!input.sessionID) {
|
||||
const parent = fixture.sessions.get(call.sessionID)!;
|
||||
fixture.sessions.set("ses_child", {
|
||||
...parent,
|
||||
id: Session.ID.make("ses_child"),
|
||||
parentID: parent.id,
|
||||
agent: Agent.ID.make("coordinate"),
|
||||
});
|
||||
}
|
||||
yield* call.progress({ sessionID: "ses_child", status: "running" });
|
||||
const child = fixture.sessions.get("ses_child")!;
|
||||
yield* fixture.instances.get(child.location.directory)!.admit();
|
||||
fixture.admissions.push(child.location.directory);
|
||||
return { content: "fixture result", metadata: { sessionID: "ses_child" } };
|
||||
}),
|
||||
};
|
||||
const store = (key: string) =>
|
||||
join(fixture.directory, "store", `${encodeURIComponent(key)}.json`);
|
||||
const providerID = Provider.ID.make("fixture");
|
||||
const models = ["gpt-5.6-luna", "gpt-5.6-sol", "gpt-6-astra"].map((id) => ({
|
||||
...Model.Info.default(providerID, Model.ID.make(id)),
|
||||
enabled: true,
|
||||
}));
|
||||
// The adapter reads these domain objects at construction; unimplemented operations
|
||||
// stay undefined and fail loudly if the plugin starts using them.
|
||||
const context = {
|
||||
options: { provider: "fixture" },
|
||||
location,
|
||||
app: { version: "2.0.4-fixture" },
|
||||
command: {},
|
||||
experimental: { terminal: {} },
|
||||
generate: {},
|
||||
provider: {},
|
||||
permission: {},
|
||||
plugin: {},
|
||||
reference: {},
|
||||
skill: {},
|
||||
vcs: { branch: {} },
|
||||
websearch: {},
|
||||
worktree: {},
|
||||
model: { list: () => Effect.succeed({ location, data: models }) },
|
||||
event: { subscribe: () => Stream.fromPubSub(updates) },
|
||||
integration: {
|
||||
connect: {},
|
||||
oauth: {},
|
||||
command: {},
|
||||
connection: {
|
||||
active: () => Effect.succeed(undefined),
|
||||
resolve: () => Effect.succeed(undefined),
|
||||
},
|
||||
},
|
||||
rpc: {
|
||||
register: () =>
|
||||
Effect.succeed({ dispose: Effect.void, events: { emit: () => Effect.void } }),
|
||||
},
|
||||
mcp: {
|
||||
transform: (callback: (editor: { get: () => undefined }) => void) =>
|
||||
Effect.sync(() => callback({ get: () => undefined })).pipe(
|
||||
Effect.andThen(registration),
|
||||
),
|
||||
},
|
||||
storage: {
|
||||
get: (key: string) =>
|
||||
Effect.promise(async () => {
|
||||
try {
|
||||
return JSON.parse(await readFile(store(key), "utf8"));
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
||||
return undefined;
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
set: (key: string, value: Schema.Json) =>
|
||||
Effect.promise(() => writeFile(store(key), JSON.stringify(value))),
|
||||
},
|
||||
agent: {
|
||||
list: (input?: { location?: { directory?: string } }) =>
|
||||
Effect.sync(() => ({
|
||||
location,
|
||||
data: [
|
||||
...(
|
||||
fixture.instances.get(input?.location?.directory ?? directory)
|
||||
?.agents ?? agents
|
||||
).values(),
|
||||
],
|
||||
})),
|
||||
get: ({ agentID }: { agentID: string }) =>
|
||||
Effect.sync(() => ({ location, data: agents.get(agentID)! })),
|
||||
transform: (callback: (editor: AgentEditor) => void) =>
|
||||
Effect.sync(() => {
|
||||
transforms.push(callback);
|
||||
replay();
|
||||
}).pipe(Effect.andThen(registration)),
|
||||
reload,
|
||||
},
|
||||
tool: {
|
||||
transform: (callback: (editor: ToolEditor) => void) =>
|
||||
Effect.sync(() => {
|
||||
toolTransforms.push(callback);
|
||||
tools.clear();
|
||||
tools.set(native.name, native);
|
||||
for (const transform of toolTransforms) transform(editTools);
|
||||
}).pipe(Effect.andThen(registration)),
|
||||
hook: () => registration,
|
||||
},
|
||||
session: {
|
||||
get: ({ sessionID }: { sessionID: string }) =>
|
||||
Effect.sync(() => fixture.sessions.get(sessionID)!),
|
||||
move: ({ sessionID, directory }: { sessionID: string; directory: string }) =>
|
||||
Effect.sync(() => {
|
||||
const child = fixture.sessions.get(sessionID)!;
|
||||
fixture.sessions.set(sessionID, {
|
||||
...child,
|
||||
location: { directory: AbsolutePath.make(directory) },
|
||||
});
|
||||
}),
|
||||
hook: (
|
||||
name: string,
|
||||
callback: (event: SessionHooks["prompt"]) => Effect.Effect<void>,
|
||||
) =>
|
||||
Effect.sync(() => {
|
||||
if (name === "prompt") prompts.push(callback);
|
||||
}).pipe(Effect.andThen(registration)),
|
||||
},
|
||||
};
|
||||
// SAFETY: Exercise the exported composite through the real Promise adapter. The
|
||||
// fixture implements precisely the accessed services; unused operations fail.
|
||||
const install = plugin.effect as (
|
||||
context: Pick<Context, "options">,
|
||||
) => ReturnType<typeof plugin.effect>;
|
||||
const call: Tool.Context = {
|
||||
sessionID: Session.ID.make("ses_parent"),
|
||||
agent: Agent.ID.make("multi-work"),
|
||||
messageID: SessionMessage.ID.make("msg_fixture"),
|
||||
id: Tool.CallID.make("call_fixture"),
|
||||
progress: () => Effect.void,
|
||||
};
|
||||
const result = {
|
||||
agents,
|
||||
install: install(context),
|
||||
replay: reload,
|
||||
get reloads() {
|
||||
return reloads;
|
||||
},
|
||||
configure(build: Agent.Info["permissions"], multi: Agent.Info["permissions"] = []) {
|
||||
lateBuild = build;
|
||||
lateMulti = multi;
|
||||
return reload();
|
||||
},
|
||||
resume: () =>
|
||||
tools.get("subagent")!.execute(
|
||||
{
|
||||
agent: "coordinate",
|
||||
sessionID: "ses_child",
|
||||
description: "resume",
|
||||
prompt: "continue",
|
||||
},
|
||||
call,
|
||||
),
|
||||
create: () =>
|
||||
tools.get("subagent")!.execute(
|
||||
{
|
||||
agent: "coordinate",
|
||||
worktree: { base: "main" },
|
||||
description: "new",
|
||||
prompt: "work",
|
||||
},
|
||||
call,
|
||||
),
|
||||
admit: () =>
|
||||
Effect.forEach(prompts, (hook) =>
|
||||
hook({
|
||||
sessionID: Session.ID.make("ses_child"),
|
||||
messageID: SessionMessage.ID.make("msg_child"),
|
||||
prompt: { text: "continue" },
|
||||
delivery: "steer",
|
||||
metadata: {},
|
||||
}),
|
||||
),
|
||||
};
|
||||
fixture.instances.set(directory, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
test("exported composite inherits late build permissions, remains stable over two replays, and preserves later overrides", async () => {
|
||||
const f = await scenario();
|
||||
const oldPath = process.env.PATH;
|
||||
process.env.PATH = `${f.bin}:${oldPath}`;
|
||||
try {
|
||||
const h = host(f, f.root);
|
||||
const destination = host(f, f.destination);
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* h.install;
|
||||
yield* destination.install;
|
||||
expect(h.agents.has("multi-work")).toBe(true);
|
||||
expect(h.agents.get("multi-work")?.model).toEqual(
|
||||
h.agents.get("coordinate")?.model,
|
||||
);
|
||||
expect(h.agents.get("multi-work")?.request).toEqual(
|
||||
h.agents.get("coordinate")?.request,
|
||||
);
|
||||
const before = h.reloads;
|
||||
yield* h.configure([
|
||||
{ action: "herdr_worktree", resource: "*", effect: "deny" },
|
||||
]);
|
||||
yield* Effect.gen(function* () {
|
||||
while (h.reloads < before + 2) yield* Effect.sleep("1 millis");
|
||||
}).pipe(Effect.timeout("2 seconds"));
|
||||
const inherited = () =>
|
||||
h.agents
|
||||
.get("multi-work")!
|
||||
.permissions.filter((rule) => rule.action === "herdr_worktree");
|
||||
const expected = [...inherited()];
|
||||
expect(expected.at(-1)?.effect).toBe("deny");
|
||||
for (let replay = 0; replay < 2; replay++) {
|
||||
yield* h.replay();
|
||||
expect(inherited()).toEqual(expected);
|
||||
expect((yield* Effect.exit(h.create()))._tag).toBe("Failure");
|
||||
}
|
||||
yield* h.configure(
|
||||
[{ action: "herdr_worktree", resource: "*", effect: "deny" }],
|
||||
[{ action: "herdr_worktree", resource: "*", effect: "allow" }],
|
||||
);
|
||||
expect((yield* h.create()).content).toContain("fixture result");
|
||||
const parent = f.sessions.get("ses_parent")!;
|
||||
f.sessions.set(parent.id, {
|
||||
...parent,
|
||||
permissions: [{ action: "herdr_worktree", resource: "*", effect: "deny" }],
|
||||
});
|
||||
expect((yield* Effect.exit(h.resume()))._tag).toBe("Failure");
|
||||
}),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
process.env.PATH = oldPath;
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
test("separate exported composite instances admit a disk-bound child and resume after plugin scopes restart", async () => {
|
||||
const f = await scenario();
|
||||
const oldPath = process.env.PATH;
|
||||
process.env.PATH = `${f.bin}:${oldPath}`;
|
||||
try {
|
||||
for (let restart = 0; restart < 2; restart++) {
|
||||
const root = host(f, f.root);
|
||||
const destination = host(f, f.destination);
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* root.install;
|
||||
yield* destination.install;
|
||||
expect(root.agents.has("multi-work")).toBe(true);
|
||||
expect(destination.agents.has("multi-work")).toBe(false);
|
||||
if (restart === 0) yield* root.create();
|
||||
yield* destination.admit();
|
||||
expect((yield* root.resume()).content).toContain(f.destination);
|
||||
const child = f.sessions.get("ses_child")!;
|
||||
f.sessions.set(child.id, {
|
||||
...child,
|
||||
location: { directory: AbsolutePath.make(f.root) },
|
||||
});
|
||||
expect((yield* Effect.exit(destination.admit()))._tag).toBe("Failure");
|
||||
f.sessions.set(child.id, child);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
expect(f.admissions).toEqual([f.destination, f.destination, f.destination]);
|
||||
} finally {
|
||||
process.env.PATH = oldPath;
|
||||
}
|
||||
}, 15_000);
|
||||
Vendored
+108
@@ -0,0 +1,108 @@
|
||||
import { Plugin, Agent, Model, Provider } from "@opencode/plugin/effect";
|
||||
import { Rpc } from "@opencode/plugin/rpc";
|
||||
import { Session } from "@opencode/schema/session";
|
||||
import { SessionMessage } from "@opencode/schema/session-message";
|
||||
import { Tool } from "@opencode/schema/tool";
|
||||
import { Cause, Effect } from "effect";
|
||||
import { z } from "zod";
|
||||
import { appendFile } from "node:fs/promises";
|
||||
import plugin from "../../src/index.js";
|
||||
|
||||
const probe = Rpc.define({
|
||||
id: "multi-work-test",
|
||||
events: {},
|
||||
methods: {
|
||||
delegate: {
|
||||
input: z.object({
|
||||
parent: z.string(),
|
||||
child: z.string().optional(),
|
||||
background: z.boolean(),
|
||||
}),
|
||||
output: z.string(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default Plugin.define({
|
||||
id: plugin.id,
|
||||
effect: (context) =>
|
||||
Effect.gen(function* () {
|
||||
const providerID = Provider.ID.make("fixture");
|
||||
yield* context.provider.transform((editor) =>
|
||||
editor.add({
|
||||
info: {
|
||||
...Provider.Info.empty(providerID),
|
||||
name: "Isolated fixture",
|
||||
activation: "enabled",
|
||||
package: "@opencode/ai/providers/openai-compatible",
|
||||
settings: { baseURL: process.env.FIXTURE_MODEL_URL! },
|
||||
},
|
||||
models: ["gpt-5.6-luna", "gpt-5.6-sol", "gpt-6-astra"].map((id) =>
|
||||
Model.Info.default(providerID, Model.ID.make(id)),
|
||||
),
|
||||
}),
|
||||
);
|
||||
yield* plugin.effect({ ...context, options: { provider: "fixture" } });
|
||||
let delegate: Tool.Info | undefined;
|
||||
yield* context.tool.transform((editor) => {
|
||||
delegate = editor.get("subagent");
|
||||
});
|
||||
yield* context.session.hook("prompt", (event) =>
|
||||
Effect.gen(function* () {
|
||||
const child = yield* context.session.get({ sessionID: event.sessionID });
|
||||
yield* Effect.promise(() =>
|
||||
appendFile(
|
||||
process.env.FIXTURE_TRACE!,
|
||||
JSON.stringify({
|
||||
id: child.id,
|
||||
parent: child.parentID,
|
||||
agent: child.agent,
|
||||
directory: child.location.directory,
|
||||
}) + "\n",
|
||||
),
|
||||
);
|
||||
}).pipe(Effect.orDie),
|
||||
);
|
||||
yield* context.rpc
|
||||
.register(probe, {
|
||||
delegate: (input) =>
|
||||
Effect.gen(function* () {
|
||||
if (!delegate) throw new Error("Native subagent missing");
|
||||
const result = yield* delegate.execute(
|
||||
{
|
||||
agent: "coordinate",
|
||||
description: "native host proof",
|
||||
prompt: "Reply with FIXTURE_COMPLETE",
|
||||
background: input.background,
|
||||
...(input.child
|
||||
? { sessionID: input.child }
|
||||
: {
|
||||
worktree: {
|
||||
base: "main",
|
||||
branch: "fix/native-host",
|
||||
label: "Fix native host",
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
sessionID: Session.ID.make(input.parent),
|
||||
agent: Agent.ID.make("multi-work"),
|
||||
messageID: SessionMessage.ID.create(),
|
||||
id: Tool.CallID.make(`call_${crypto.randomUUID()}`),
|
||||
progress: () => Effect.void,
|
||||
},
|
||||
);
|
||||
return JSON.stringify(result);
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Cause.hasInterrupts(cause)
|
||||
? Effect.interrupt
|
||||
: Effect.succeed(
|
||||
JSON.stringify({ failure: Cause.pretty(cause) }),
|
||||
),
|
||||
),
|
||||
),
|
||||
})
|
||||
.pipe(Effect.orDie);
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { command, Herdr } from "../src/herdr.js";
|
||||
|
||||
const directories: string[] = [];
|
||||
afterEach(async () => {
|
||||
for (const directory of directories.splice(0))
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function fixture() {
|
||||
const temporary = await mkdtemp(join(tmpdir(), "multi-work-"));
|
||||
directories.push(temporary);
|
||||
const root = join(temporary, "root");
|
||||
const destination = join(temporary, "returned checkout");
|
||||
await mkdir(root);
|
||||
const signal = new AbortController().signal;
|
||||
await command(["git", "init", "-b", "main"], root, signal);
|
||||
await command(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"user.name=Fixture",
|
||||
"-c",
|
||||
"user.email=fixture@example.test",
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"initial",
|
||||
],
|
||||
root,
|
||||
signal,
|
||||
);
|
||||
await command(["git", "branch", "other"], root, signal);
|
||||
const calls: string[][] = [];
|
||||
let open = true;
|
||||
let linked = false;
|
||||
let duplicate = false;
|
||||
let branch = "";
|
||||
let label: string | undefined;
|
||||
let sourceWorkspaceIDOverride: string | undefined;
|
||||
let sourcePathOverride: string | undefined;
|
||||
let childWorkspaceIDOverride: string | undefined;
|
||||
let childPathOverride: string | undefined;
|
||||
let repoOverride: string | undefined;
|
||||
const run = async (args: string[], cwd: string, signal: AbortSignal) => {
|
||||
calls.push(args);
|
||||
if (args[0] !== "herdr") return command(args, cwd, signal);
|
||||
if (args[1] === "workspace")
|
||||
return JSON.stringify({
|
||||
result: {
|
||||
workspaces: open
|
||||
? [{ workspace_id: "w1" }, ...(duplicate ? [{ workspace_id: "w3" }] : [])]
|
||||
: [],
|
||||
},
|
||||
});
|
||||
if (args[2] === "list") {
|
||||
const id = args[4]!;
|
||||
const path = id === "w2" ? destination : root;
|
||||
const sourceWorkspaceID = sourceWorkspaceIDOverride ?? (id === "w2" ? "w1" : id);
|
||||
const sourcePath = sourcePathOverride ?? root;
|
||||
return JSON.stringify({
|
||||
result: {
|
||||
source: {
|
||||
repo_key: repoOverride ?? join(root, ".git"),
|
||||
repo_root: root,
|
||||
source_checkout_path: sourcePath,
|
||||
source_workspace_id: sourceWorkspaceID,
|
||||
},
|
||||
worktrees: [
|
||||
{
|
||||
path: sourcePath,
|
||||
is_linked_worktree: linked,
|
||||
is_bare: false,
|
||||
open_workspace_id: sourceWorkspaceID,
|
||||
},
|
||||
...(id === "w2"
|
||||
? [
|
||||
{
|
||||
path: childPathOverride ?? path,
|
||||
is_linked_worktree: true,
|
||||
is_bare: false,
|
||||
open_workspace_id: childWorkspaceIDOverride ?? id,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
expect(args[2]).toBe("create");
|
||||
expect(args.slice(3, 5)).toEqual(["--cwd", root]);
|
||||
expect(args.at(-1)).toBe("--no-focus");
|
||||
branch = args[args.indexOf("--branch") + 1]!;
|
||||
const labelIndex = args.indexOf("--label");
|
||||
label = labelIndex === -1 ? undefined : args[labelIndex + 1];
|
||||
const base = args[args.indexOf("--base") + 1]!;
|
||||
// Real Git worktree behavior, but only in an isolated temporary repository.
|
||||
await command(["git", "worktree", "add", "-b", branch, destination, base], root, signal);
|
||||
return JSON.stringify({
|
||||
result: {
|
||||
workspace: { workspace_id: "w2" },
|
||||
worktree: { path: destination, branch, is_linked_worktree: true },
|
||||
},
|
||||
});
|
||||
};
|
||||
return {
|
||||
root,
|
||||
destination,
|
||||
signal,
|
||||
calls,
|
||||
get label() {
|
||||
return label;
|
||||
},
|
||||
herdr: new Herdr(run),
|
||||
close() {
|
||||
open = false;
|
||||
},
|
||||
link() {
|
||||
linked = true;
|
||||
},
|
||||
duplicate() {
|
||||
duplicate = true;
|
||||
},
|
||||
changeInventory(change: {
|
||||
sourceWorkspaceID?: string | undefined;
|
||||
sourcePath?: string | undefined;
|
||||
childWorkspaceID?: string | undefined;
|
||||
childPath?: string | undefined;
|
||||
repo?: string | undefined;
|
||||
}) {
|
||||
sourceWorkspaceIDOverride = change.sourceWorkspaceID;
|
||||
sourcePathOverride = change.sourcePath;
|
||||
childWorkspaceIDOverride = change.childWorkspaceID;
|
||||
childPathOverride = change.childPath;
|
||||
repoOverride = change.repo;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("create verifies a linked workspace whose inventory source remains the parent workspace", async () => {
|
||||
const f = await fixture();
|
||||
const root = await f.herdr.root(f.root, f.signal);
|
||||
expect(root?.workspaceID).toBe("w1");
|
||||
const destination = await f.herdr.create(root!, "other", "multi-work/fixture", f.signal);
|
||||
expect(destination.directory).toBe(f.destination);
|
||||
expect(destination.branch).toStartWith("multi-work/");
|
||||
expect(await command(["git", "branch", "--show-current"], f.root, f.signal)).toBe("main");
|
||||
await f.herdr.verify(
|
||||
destination.directory,
|
||||
destination.workspaceID,
|
||||
root!,
|
||||
destination.branch,
|
||||
f.signal,
|
||||
);
|
||||
});
|
||||
|
||||
test("create passes an optional workspace label to Herdr", async () => {
|
||||
const f = await fixture();
|
||||
const root = (await f.herdr.root(f.root, f.signal))!;
|
||||
const destination = await f.herdr.create(
|
||||
root,
|
||||
"main",
|
||||
"fix/login-timeout",
|
||||
f.signal,
|
||||
"Fix login timeout",
|
||||
);
|
||||
expect(destination.branch).toBe("fix/login-timeout");
|
||||
expect(f.label).toBe("Fix login timeout");
|
||||
const creation = f.calls.find((args) => args[0] === "herdr" && args[2] === "create")!;
|
||||
expect(creation.slice(creation.indexOf("--label"), creation.indexOf("--label") + 2)).toEqual([
|
||||
"--label",
|
||||
"Fix login timeout",
|
||||
]);
|
||||
});
|
||||
|
||||
test("create rejects an existing destination branch before invoking Herdr", async () => {
|
||||
const f = await fixture();
|
||||
const root = (await f.herdr.root(f.root, f.signal))!;
|
||||
await expect(
|
||||
f.herdr.create(root, "main", "invalid branch", f.signal, "Invalid"),
|
||||
).rejects.toThrow();
|
||||
await expect(f.herdr.create(root, "main", "other", f.signal, "Other")).rejects.toThrow(
|
||||
"Destination branch already exists: other",
|
||||
);
|
||||
expect(f.calls.filter((args) => args[0] === "herdr" && args[2] === "create")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("verify rejects changed parent, child and repository identities", async () => {
|
||||
for (const change of [
|
||||
{ sourceWorkspaceID: "w9" },
|
||||
{ sourcePath: "child" },
|
||||
{ childWorkspaceID: "w9" },
|
||||
{ childPath: "root" },
|
||||
{ repo: "child" },
|
||||
]) {
|
||||
const f = await fixture();
|
||||
const root = (await f.herdr.root(f.root, f.signal))!;
|
||||
const destination = await f.herdr.create(root, "main", "multi-work/fixture", f.signal);
|
||||
f.changeInventory({
|
||||
sourceWorkspaceID: change.sourceWorkspaceID,
|
||||
sourcePath: change.sourcePath === "child" ? f.destination : undefined,
|
||||
childWorkspaceID: change.childWorkspaceID,
|
||||
childPath: change.childPath === "root" ? f.root : undefined,
|
||||
repo: change.repo === "child" ? f.destination : undefined,
|
||||
});
|
||||
await expect(
|
||||
f.herdr.verify(
|
||||
destination.directory,
|
||||
destination.workspaceID,
|
||||
root,
|
||||
destination.branch,
|
||||
f.signal,
|
||||
),
|
||||
).rejects.toThrow("Bound Herdr checkout is no longer available");
|
||||
}
|
||||
});
|
||||
|
||||
test("closed, linked and ambiguous workspaces fail root gating", async () => {
|
||||
for (const mode of ["close", "link", "duplicate"] as const) {
|
||||
const f = await fixture();
|
||||
f[mode]();
|
||||
expect(await f.herdr.root(f.root, f.signal)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("revalidates before mutation and rejects non-branch refs without creating a worktree", async () => {
|
||||
const f = await fixture();
|
||||
const root = (await f.herdr.root(f.root, f.signal))!;
|
||||
const oid = await command(["git", "rev-parse", "HEAD"], f.root, f.signal);
|
||||
for (const base of [oid, "--help", "main; touch injected"]) {
|
||||
await expect(f.herdr.create(root, base, "multi-work/fixture", f.signal)).rejects.toThrow();
|
||||
}
|
||||
f.close();
|
||||
await expect(f.herdr.create(root, "main", "multi-work/fixture", f.signal)).rejects.toThrow(
|
||||
"no longer eligible",
|
||||
);
|
||||
expect(f.calls.filter((args) => args[0] === "herdr" && args[2] === "create")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("a missing or malformed Herdr CLI never qualifies", async () => {
|
||||
const f = await fixture();
|
||||
await expect(new Herdr(async () => "not json").root(f.root, f.signal)).rejects.toThrow();
|
||||
await expect(
|
||||
new Herdr(async () => {
|
||||
throw new Error("ENOENT");
|
||||
}).root(f.root, f.signal),
|
||||
).rejects.toThrow("ENOENT");
|
||||
});
|
||||
@@ -113,7 +113,7 @@ async function install(
|
||||
'await Bun.write(process.env.IMPORT_MARKER, "imported");\n' +
|
||||
(mode === "legacy"
|
||||
? 'export default { id: "stanp-opencode", async server() { return {}; } };\n'
|
||||
: 'export default { id: "stanp-opencode", setup() { return "native-ready"; } };\n'),
|
||||
: 'export default { id: "stanp-opencode", effect() { return "native-ready"; } };\n'),
|
||||
),
|
||||
writeFile(
|
||||
join(fixture, "dist", "tui.js"),
|
||||
@@ -207,7 +207,7 @@ test("installs an importable native plugin and default config under XDG without
|
||||
[
|
||||
process.execPath,
|
||||
"-e",
|
||||
"const { default: plugin } = await import(process.argv[1]); console.log(JSON.stringify([plugin.id, plugin.setup()]));",
|
||||
"const { default: plugin } = await import(process.argv[1]); console.log(JSON.stringify([plugin.id, plugin.effect()]));",
|
||||
join(config, "plugins", "stanp-opencode"),
|
||||
],
|
||||
directory,
|
||||
|
||||
@@ -0,0 +1,828 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { Effect } from "effect";
|
||||
import { Agent } from "@opencode/schema/agent";
|
||||
import { Session } from "@opencode/schema/session";
|
||||
import { SessionMessage } from "@opencode/schema/session-message";
|
||||
import { Tool } from "@opencode/schema/tool";
|
||||
import type { Context } from "@opencode/plugin/effect/plugin";
|
||||
import type { ToolEditor, ToolHooks } from "@opencode/plugin/effect/tool";
|
||||
import type { AgentEditor } from "@opencode/plugin/effect/agent";
|
||||
import type { SessionContext } from "@opencode/plugin/effect/session";
|
||||
import { z } from "zod";
|
||||
import { Herdr, type HerdrRoot } from "../src/herdr.js";
|
||||
import { bindingKey, bindingSchema, installMultiWork } from "../src/multi-work.js";
|
||||
|
||||
class FixtureHerdr extends Herdr {
|
||||
eligible = true;
|
||||
rootWorkspaceID = "w1";
|
||||
rootRepo = "/root/.git";
|
||||
creations = 0;
|
||||
verified = 0;
|
||||
delayCreation = false;
|
||||
creationAborted = false;
|
||||
createdBase: string | undefined;
|
||||
createdBranch: string | undefined;
|
||||
createdLabel: string | undefined;
|
||||
creationStarted = Promise.withResolvers<void>();
|
||||
override async root(directory: string) {
|
||||
return this.eligible && directory === "/root"
|
||||
? { directory, workspaceID: this.rootWorkspaceID, repo: this.rootRepo }
|
||||
: undefined;
|
||||
}
|
||||
override async create(
|
||||
_root: HerdrRoot,
|
||||
base: string,
|
||||
branch: string,
|
||||
signal: AbortSignal,
|
||||
label?: string,
|
||||
) {
|
||||
this.creations++;
|
||||
this.createdBase = base;
|
||||
this.createdBranch = branch;
|
||||
this.createdLabel = label;
|
||||
if (!this.eligible) throw new Error("root closed");
|
||||
if (this.delayCreation) {
|
||||
this.creationStarted.resolve();
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
this.creationAborted = true;
|
||||
reject(new Error("creation interrupted"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
return { directory: "/worktree", workspaceID: "w2", branch };
|
||||
}
|
||||
override async verify() {
|
||||
this.verified++;
|
||||
}
|
||||
}
|
||||
|
||||
interface FixtureSession {
|
||||
id: Session.ID;
|
||||
projectID: string;
|
||||
agent: string;
|
||||
parentID: string | undefined;
|
||||
location: { directory: string };
|
||||
permissions: Agent.Info["permissions"];
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const herdr = new FixtureHerdr();
|
||||
const storage = new Map<string, Parameters<Context["storage"]["set"]>[1]>();
|
||||
const agents = new Map<string, NonNullable<ReturnType<AgentEditor["get"]>>>();
|
||||
for (const name of ["coordinate", "fast-lo", "fast-hi", "good-lo", "good-hi"])
|
||||
agents.set(name, { ...Agent.Info.default(Agent.ID.make(name)), permissions: [] });
|
||||
const parent: FixtureSession = {
|
||||
id: Session.ID.make("ses_parent"),
|
||||
projectID: "project-fixture",
|
||||
agent: "multi-work",
|
||||
parentID: undefined,
|
||||
location: { directory: "/root" },
|
||||
permissions: [],
|
||||
};
|
||||
const sessions = new Map([[String(parent.id), parent]]);
|
||||
const order: string[] = [];
|
||||
let cancelled = false;
|
||||
let hold = false;
|
||||
let delayMove = false;
|
||||
let crossProject = false;
|
||||
let destinationAvailable = true;
|
||||
let warming = false;
|
||||
let agentReads = 0;
|
||||
let prompts = 0;
|
||||
let serial = 0;
|
||||
const entered = Promise.withResolvers<void>();
|
||||
const progress: Tool.Metadata[] = [];
|
||||
const native: Tool.Info = {
|
||||
name: "subagent",
|
||||
description: "native",
|
||||
input: z.object({}),
|
||||
options: { codemode: false },
|
||||
execute: (raw, call) =>
|
||||
Effect.gen(function* () {
|
||||
const input = z
|
||||
.object({
|
||||
agent: z.string(),
|
||||
sessionID: z.string().optional(),
|
||||
background: z.boolean().optional(),
|
||||
})
|
||||
.parse(raw);
|
||||
const id = Session.ID.make(input.sessionID ?? `ses_child${++serial}`);
|
||||
if (!input.sessionID)
|
||||
sessions.set(id, {
|
||||
...parent,
|
||||
id,
|
||||
parentID: call.sessionID,
|
||||
agent: input.agent,
|
||||
location: { directory: "/root" },
|
||||
});
|
||||
order.push("create");
|
||||
yield* call.progress({ sessionID: id, status: "running" });
|
||||
order.push(`prompt:${sessions.get(id)!.location.directory}`);
|
||||
prompts++;
|
||||
entered.resolve();
|
||||
if (hold)
|
||||
yield* Effect.never.pipe(
|
||||
Effect.onInterrupt(() =>
|
||||
Effect.sync(() => {
|
||||
cancelled = true;
|
||||
}),
|
||||
),
|
||||
);
|
||||
return {
|
||||
content: "native result",
|
||||
metadata: { sessionID: id, status: input.background ? "running" : "completed" },
|
||||
};
|
||||
}),
|
||||
};
|
||||
let tool = native;
|
||||
const toolHooks: ((event: ToolHooks["execute.before"]) => Effect.Effect<void, Tool.Error>)[] =
|
||||
[];
|
||||
const hooks: ((event: SessionContext) => Effect.Effect<void>)[] = [];
|
||||
let transform!: (editor: AgentEditor) => void;
|
||||
const registration = Effect.succeed({ dispose: Effect.void });
|
||||
const editAgents = (): AgentEditor => ({
|
||||
get: (id) => agents.get(id),
|
||||
list: () => [...agents.values()],
|
||||
default() {},
|
||||
remove(id) {
|
||||
agents.delete(id);
|
||||
},
|
||||
update(id, update) {
|
||||
// SAFETY: Agent.Info.default returns a fresh draft that the fixture exclusively owns.
|
||||
const agent = (agents.get(id) ?? Agent.Info.default(Agent.ID.make(id))) as NonNullable<
|
||||
ReturnType<AgentEditor["get"]>
|
||||
>;
|
||||
update(agent);
|
||||
agents.set(id, agent);
|
||||
},
|
||||
});
|
||||
const editTools: ToolEditor = {
|
||||
list: () => [{ ...tool, id: tool.name }],
|
||||
get: () => ({ ...tool, id: tool.name }),
|
||||
add() {},
|
||||
namespace() {},
|
||||
remove() {},
|
||||
update(_id, update) {
|
||||
const draft = { ...tool };
|
||||
update(draft);
|
||||
tool = draft;
|
||||
},
|
||||
};
|
||||
const host = {
|
||||
options: {},
|
||||
location: { directory: "/root" },
|
||||
storage: {
|
||||
get: (key: string) => Effect.sync(() => storage.get(key)),
|
||||
set: (key: string, value: Parameters<Context["storage"]["set"]>[1]) =>
|
||||
Effect.sync(() => {
|
||||
storage.set(key, value);
|
||||
}),
|
||||
},
|
||||
agent: {
|
||||
transform: (callback: typeof transform) =>
|
||||
Effect.sync(() => {
|
||||
transform = callback;
|
||||
callback(editAgents());
|
||||
}).pipe(Effect.andThen(registration)),
|
||||
reload: () =>
|
||||
Effect.sync(() => {
|
||||
if (transform) transform(editAgents());
|
||||
}),
|
||||
get: () => Effect.succeed({ data: agents.get("multi-work")! }),
|
||||
list: () =>
|
||||
Effect.sync(() => {
|
||||
agentReads++;
|
||||
if (!destinationAvailable) throw new Error("Destination location unavailable");
|
||||
return { data: warming && agentReads === 1 ? [] : [...agents.values()] };
|
||||
}),
|
||||
},
|
||||
session: {
|
||||
get: ({ sessionID }: { sessionID: string }) =>
|
||||
Effect.sync(() => sessions.get(sessionID)!),
|
||||
move: ({ sessionID, directory }: { sessionID: string; directory: string }) =>
|
||||
Effect.sync(() => {
|
||||
order.push("move-request");
|
||||
if (!delayMove) {
|
||||
sessions.get(sessionID)!.location.directory = directory;
|
||||
order.push("moved");
|
||||
if (crossProject) sessions.get(sessionID)!.projectID = "another-project";
|
||||
}
|
||||
entered.resolve();
|
||||
}),
|
||||
hook: (_name: string, callback: (event: SessionContext) => Effect.Effect<void>) =>
|
||||
Effect.sync(() => {
|
||||
hooks.push(callback);
|
||||
}).pipe(Effect.andThen(registration)),
|
||||
},
|
||||
tool: {
|
||||
transform: (callback: (editor: ToolEditor) => void) =>
|
||||
Effect.sync(() => callback(editTools)).pipe(Effect.andThen(registration)),
|
||||
hook: (
|
||||
_name: string,
|
||||
callback: (event: ToolHooks["execute.before"]) => Effect.Effect<void, Tool.Error>,
|
||||
) =>
|
||||
Effect.sync(() => {
|
||||
toolHooks.push(callback);
|
||||
}).pipe(Effect.andThen(registration)),
|
||||
},
|
||||
};
|
||||
// SAFETY: The test setup only receives its exercised services; unexpected host dependencies fail loudly.
|
||||
const install = installMultiWork as (
|
||||
context: Pick<Context, "options">,
|
||||
buildPermissions: () => Agent.Info["permissions"],
|
||||
herdr: Herdr,
|
||||
) => ReturnType<typeof installMultiWork>;
|
||||
const call: Tool.Context = {
|
||||
sessionID: parent.id,
|
||||
agent: Agent.ID.make("multi-work"),
|
||||
messageID: SessionMessage.ID.make("msg_test"),
|
||||
id: Tool.CallID.make("call_test"),
|
||||
progress: (value) =>
|
||||
Effect.sync(() => {
|
||||
progress.push(value);
|
||||
}),
|
||||
};
|
||||
return {
|
||||
herdr,
|
||||
agents,
|
||||
sessions,
|
||||
parent,
|
||||
storage,
|
||||
order,
|
||||
entered,
|
||||
hooks,
|
||||
toolHooks,
|
||||
call,
|
||||
progress,
|
||||
install: install(
|
||||
host,
|
||||
() =>
|
||||
agents.get("build")?.permissions ??
|
||||
Agent.Info.default(Agent.ID.make("build")).permissions,
|
||||
herdr,
|
||||
),
|
||||
invoke: (
|
||||
input = {
|
||||
agent: "coordinate",
|
||||
description: "test",
|
||||
prompt: "work",
|
||||
worktree: { base: "main" },
|
||||
},
|
||||
caller = call,
|
||||
) => tool.execute(input, caller),
|
||||
execute: (input: z.infer<typeof request>, caller = call) => tool.execute(input, caller),
|
||||
hold() {
|
||||
hold = true;
|
||||
},
|
||||
delayMove() {
|
||||
delayMove = true;
|
||||
},
|
||||
missingDestination() {
|
||||
destinationAvailable = false;
|
||||
},
|
||||
crossProject() {
|
||||
crossProject = true;
|
||||
},
|
||||
warmDestination() {
|
||||
warming = true;
|
||||
},
|
||||
get agentReads() {
|
||||
return agentReads;
|
||||
},
|
||||
get cancelled() {
|
||||
return cancelled;
|
||||
},
|
||||
get prompts() {
|
||||
return prompts;
|
||||
},
|
||||
get tool() {
|
||||
return tool;
|
||||
},
|
||||
};
|
||||
}
|
||||
const request = z.object({
|
||||
agent: z.string(),
|
||||
description: z.string(),
|
||||
prompt: z.string(),
|
||||
worktree: z
|
||||
.object({
|
||||
base: z.string(),
|
||||
branch: z.string().optional(),
|
||||
label: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
sessionID: z.string().optional(),
|
||||
background: z.boolean().optional(),
|
||||
});
|
||||
|
||||
test("Effect-native wrapper preserves name, metadata and result; moves before prompt and resumes its bound child", async () => {
|
||||
const f = fixture();
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* f.install;
|
||||
expect(f.agents.get("multi-work")?.mode).toBe("primary");
|
||||
expect(f.tool.name).toBe("subagent");
|
||||
expect(f.tool.options?.codemode).toBe(false);
|
||||
expect(f.agents.get("coordinate")?.mode).toBe("all");
|
||||
const result = yield* f.invoke();
|
||||
expect(result.content).toStartWith("native result");
|
||||
expect(result.content).toContain("Checkout: /worktree");
|
||||
expect(result.metadata?.sessionID).toBe("ses_child1");
|
||||
expect(f.order).toEqual(["create", "move-request", "moved", "prompt:/worktree"]);
|
||||
expect(f.parent.location.directory).toBe("/root");
|
||||
expect(bindingSchema.parse(f.storage.get(bindingKey("ses_child1"))).ready).toBe(
|
||||
true,
|
||||
);
|
||||
const resume = yield* f.execute({
|
||||
agent: "coordinate",
|
||||
description: "resume",
|
||||
prompt: "continue",
|
||||
sessionID: "ses_child1",
|
||||
background: true,
|
||||
});
|
||||
expect(resume.metadata?.status).toBe("running");
|
||||
expect(f.herdr.creations).toBe(1);
|
||||
expect(f.herdr.verified).toBe(1);
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("local fast-lo uses native delegation without a worktree", async () => {
|
||||
const f = fixture();
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* f.install;
|
||||
yield* f.execute({ agent: "fast-lo", description: "lookup", prompt: "read" });
|
||||
expect(f.herdr.creations).toBe(0);
|
||||
expect(f.order).toEqual(["create", "prompt:/root"]);
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("coordinate can create and resume a root-local background child without Herdr creation or movement", async () => {
|
||||
const f = fixture();
|
||||
f.parent.permissions = [{ action: "herdr_worktree", resource: "*", effect: "deny" }];
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* f.install;
|
||||
const created = yield* f.execute({
|
||||
agent: "coordinate",
|
||||
description: "local edit",
|
||||
prompt: "edit assigned files",
|
||||
background: true,
|
||||
});
|
||||
expect(created.metadata).toMatchObject({
|
||||
sessionID: "ses_child1",
|
||||
status: "running",
|
||||
directory: "/root",
|
||||
workspaceID: "w1",
|
||||
branch: "root",
|
||||
});
|
||||
expect(f.herdr.creations).toBe(0);
|
||||
expect(f.order).toEqual(["create", "prompt:/root"]);
|
||||
expect(bindingSchema.parse(f.storage.get(bindingKey("ses_child1")))).toMatchObject({
|
||||
owner: "ses_parent",
|
||||
directory: "/root",
|
||||
workspaceID: "w1",
|
||||
branch: "root",
|
||||
ready: true,
|
||||
});
|
||||
yield* f.execute({
|
||||
agent: "coordinate",
|
||||
description: "resume local",
|
||||
prompt: "continue",
|
||||
sessionID: "ses_child1",
|
||||
});
|
||||
expect(f.herdr.creations).toBe(0);
|
||||
expect(f.herdr.verified).toBe(0);
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("root-local resume rejects location, binding, and live-root changes", async () => {
|
||||
for (const tamper of ["session", "workspace", "root", "branch", "live-root"] as const) {
|
||||
const f = fixture();
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* f.install;
|
||||
yield* f.execute({
|
||||
agent: "coordinate",
|
||||
description: "local edit",
|
||||
prompt: "work",
|
||||
});
|
||||
if (tamper === "session")
|
||||
f.sessions.get("ses_child1")!.location.directory = "/other";
|
||||
else if (tamper === "live-root") f.herdr.rootWorkspaceID = "foreign";
|
||||
else {
|
||||
const binding = bindingSchema.parse(
|
||||
f.storage.get(bindingKey("ses_child1")),
|
||||
);
|
||||
const changed = { ...binding };
|
||||
if (tamper === "workspace") changed.workspaceID = "foreign";
|
||||
if (tamper === "root") {
|
||||
changed.root = { ...binding.root, workspaceID: "foreign" };
|
||||
changed.workspaceID = "foreign";
|
||||
}
|
||||
if (tamper === "branch") changed.branch = "main";
|
||||
f.storage.set(bindingKey("ses_child1"), changed);
|
||||
}
|
||||
expect(
|
||||
(yield* Effect.exit(
|
||||
f.execute({
|
||||
agent: "coordinate",
|
||||
description: "resume",
|
||||
prompt: "continue",
|
||||
sessionID: "ses_child1",
|
||||
}),
|
||||
))._tag,
|
||||
).toBe("Failure");
|
||||
expect(f.prompts).toBe(1);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("interrupted root-local creation retains its binding and cannot be blindly replayed", async () => {
|
||||
const f = fixture();
|
||||
f.hold();
|
||||
const controller = new AbortController();
|
||||
const running = Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* f.install;
|
||||
return yield* f.execute({
|
||||
agent: "coordinate",
|
||||
description: "local edit",
|
||||
prompt: "work",
|
||||
});
|
||||
}),
|
||||
),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
await f.entered.promise;
|
||||
controller.abort();
|
||||
await expect(running).rejects.toBeDefined();
|
||||
expect(bindingSchema.parse(f.storage.get(bindingKey("ses_child1"))).ready).toBe(true);
|
||||
expect(f.storage.has(`multi-work-call:${f.call.sessionID}:${f.call.id}`)).toBe(true);
|
||||
const replay = await Effect.runPromise(
|
||||
Effect.exit(
|
||||
f.execute({
|
||||
agent: "coordinate",
|
||||
description: "local edit",
|
||||
prompt: "work",
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(replay._tag).toBe("Failure");
|
||||
expect(f.prompts).toBe(1);
|
||||
});
|
||||
|
||||
test("custom branch and workspace label pass through delegation", async () => {
|
||||
const f = fixture();
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* f.install;
|
||||
const result = yield* f.execute({
|
||||
agent: "coordinate",
|
||||
description: "Fix login timeout",
|
||||
prompt: "work",
|
||||
worktree: {
|
||||
base: "main",
|
||||
branch: "fix/login-timeout",
|
||||
label: "Fix login timeout",
|
||||
},
|
||||
});
|
||||
expect(f.herdr.createdBase).toBe("main");
|
||||
expect(f.herdr.createdBranch).toBe("fix/login-timeout");
|
||||
expect(f.herdr.createdLabel).toBe("Fix login timeout");
|
||||
expect(result.metadata?.branch).toBe("fix/login-timeout");
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("omitted naming retains generated branch and no explicit label", async () => {
|
||||
const f = fixture();
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* f.install;
|
||||
yield* f.invoke();
|
||||
expect(f.herdr.createdBranch).toMatch(/^multi-work\/[0-9a-f-]{36}$/);
|
||||
expect(f.herdr.createdLabel).toBeUndefined();
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects empty naming fields and naming on resume", async () => {
|
||||
const f = fixture();
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* f.install;
|
||||
for (const worktree of [
|
||||
{ base: "main", branch: " " },
|
||||
{ base: "main", label: " " },
|
||||
])
|
||||
expect(
|
||||
(yield* Effect.exit(
|
||||
f.execute({
|
||||
agent: "coordinate",
|
||||
description: "invalid",
|
||||
prompt: "work",
|
||||
worktree,
|
||||
}),
|
||||
))._tag,
|
||||
).toBe("Failure");
|
||||
yield* f.invoke();
|
||||
expect(
|
||||
(yield* Effect.exit(
|
||||
f.execute({
|
||||
agent: "coordinate",
|
||||
description: "resume",
|
||||
prompt: "work",
|
||||
sessionID: "ses_child1",
|
||||
worktree: { base: "main", branch: "fix/other" },
|
||||
}),
|
||||
))._tag,
|
||||
).toBe("Failure");
|
||||
expect(f.herdr.creations).toBe(1);
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("root loss, session permission denial and missing destination fail before prompting", async () => {
|
||||
for (const failure of ["root", "permission", "ask", "destination", "project"] as const) {
|
||||
const f = fixture();
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* f.install;
|
||||
if (failure === "root") f.herdr.eligible = false;
|
||||
if (failure === "permission")
|
||||
f.parent.permissions = [
|
||||
{ action: "herdr_worktree", resource: "*", effect: "deny" },
|
||||
];
|
||||
if (failure === "destination") f.missingDestination();
|
||||
if (failure === "project") f.crossProject();
|
||||
if (failure === "ask")
|
||||
f.parent.permissions = [
|
||||
{ action: "herdr_worktree", resource: "*", effect: "ask" },
|
||||
];
|
||||
const result = yield* Effect.exit(f.invoke());
|
||||
expect(result._tag).toBe("Failure");
|
||||
expect(f.prompts).toBe(0);
|
||||
expect(f.herdr.creations).toBe(
|
||||
failure === "destination" || failure === "project" ? 1 : 0,
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("interruption crosses the wrapper into the native executor finalizer", async () => {
|
||||
const f = fixture();
|
||||
f.hold();
|
||||
const controller = new AbortController();
|
||||
const running = Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* f.install;
|
||||
return yield* f.invoke();
|
||||
}),
|
||||
),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
await f.entered.promise;
|
||||
// entered may resolve at move request; yield until the native foreground wait is reached.
|
||||
while (f.prompts === 0) await Bun.sleep(1);
|
||||
controller.abort();
|
||||
await expect(running).rejects.toBeDefined();
|
||||
expect(f.cancelled).toBe(true);
|
||||
});
|
||||
|
||||
test("move admission alone never admits a task; cancellation retains an unready binding", async () => {
|
||||
const f = fixture();
|
||||
f.delayMove();
|
||||
const controller = new AbortController();
|
||||
const running = Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* f.install;
|
||||
return yield* f.invoke();
|
||||
}),
|
||||
),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
await f.entered.promise;
|
||||
expect(f.prompts).toBe(0);
|
||||
controller.abort();
|
||||
await expect(running).rejects.toBeDefined();
|
||||
expect(f.prompts).toBe(0);
|
||||
expect(bindingSchema.parse(f.storage.get(bindingKey("ses_child1"))).ready).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects foreign and changed-agent resumes, non-primary multi-work and unbound coordinate calls", async () => {
|
||||
const f = fixture();
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* f.install;
|
||||
yield* f.invoke();
|
||||
f.sessions.get("ses_child1")!.agent = "fast-lo";
|
||||
expect(
|
||||
(yield* Effect.exit(
|
||||
f.execute({
|
||||
agent: "coordinate",
|
||||
description: "resume",
|
||||
prompt: "x",
|
||||
sessionID: "ses_child1",
|
||||
}),
|
||||
))._tag,
|
||||
).toBe("Failure");
|
||||
f.parent.parentID = "ses_foreign";
|
||||
expect((yield* Effect.exit(f.invoke()))._tag).toBe("Failure");
|
||||
f.parent.parentID = undefined;
|
||||
expect(
|
||||
(yield* Effect.exit(
|
||||
f.invoke(undefined, { ...f.call, agent: Agent.ID.make("build") }),
|
||||
))._tag,
|
||||
).toBe("Failure");
|
||||
expect(
|
||||
(yield* Effect.exit(
|
||||
f.execute({ agent: "multi-work", description: "x", prompt: "x" }),
|
||||
))._tag,
|
||||
).toBe("Failure");
|
||||
expect(f.herdr.creations).toBe(1);
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("ineligible roots do not register multi-work; multi-work cannot execute shell even with session overrides", async () => {
|
||||
const f = fixture();
|
||||
f.herdr.eligible = false;
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* f.install;
|
||||
expect(f.agents.has("multi-work")).toBe(false);
|
||||
const hook = f.toolHooks[0]!;
|
||||
expect(
|
||||
(yield* Effect.exit(hook({ ...f.call, tool: "shell", input: {} })))._tag,
|
||||
).toBe("Failure");
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("interruption during Herdr creation aborts the command and retains an inspectable attempt without prompting", async () => {
|
||||
const f = fixture();
|
||||
f.herdr.delayCreation = true;
|
||||
const controller = new AbortController();
|
||||
const running = Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* f.install;
|
||||
return yield* f.invoke();
|
||||
}),
|
||||
),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
await f.herdr.creationStarted.promise;
|
||||
controller.abort();
|
||||
await expect(running).rejects.toBeDefined();
|
||||
expect(f.herdr.creationAborted).toBe(true);
|
||||
expect(f.prompts).toBe(0);
|
||||
expect(f.storage.has(`multi-work-call:${f.call.sessionID}:${f.call.id}`)).toBe(true);
|
||||
});
|
||||
|
||||
test("waits for asynchronous destination plugin activation before moving or prompting", async () => {
|
||||
const f = fixture();
|
||||
f.warmDestination();
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* f.install;
|
||||
yield* f.invoke();
|
||||
expect(f.agentReads).toBe(2);
|
||||
expect(f.prompts).toBe(1);
|
||||
expect(f.order).toEqual(["create", "move-request", "moved", "prompt:/worktree"]);
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("configured build restrictions remain effective for worktree preparation", async () => {
|
||||
const f = fixture();
|
||||
f.agents.set("build", {
|
||||
...Agent.Info.default(Agent.ID.make("build")),
|
||||
permissions: [{ action: "herdr_worktree", resource: "*", effect: "deny" }],
|
||||
});
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* f.install;
|
||||
expect((yield* Effect.exit(f.invoke()))._tag).toBe("Failure");
|
||||
expect(f.herdr.creations).toBe(0);
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("worktree resumes retain the herdr_worktree permission guard", async () => {
|
||||
const f = fixture();
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* f.install;
|
||||
yield* f.invoke();
|
||||
f.parent.permissions = [
|
||||
{ action: "herdr_worktree", resource: "*", effect: "deny" },
|
||||
];
|
||||
const resumed = yield* Effect.exit(
|
||||
f.execute({
|
||||
agent: "coordinate",
|
||||
description: "resume",
|
||||
prompt: "continue",
|
||||
sessionID: "ses_child1",
|
||||
}),
|
||||
);
|
||||
expect(resumed._tag).toBe("Failure");
|
||||
expect(f.herdr.verified).toBe(0);
|
||||
expect(f.prompts).toBe(1);
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("failed initialization records the attempt and never blindly recreates on replay", async () => {
|
||||
const f = fixture();
|
||||
f.missingDestination();
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* f.install;
|
||||
expect((yield* Effect.exit(f.invoke()))._tag).toBe("Failure");
|
||||
expect((yield* Effect.exit(f.invoke()))._tag).toBe("Failure");
|
||||
expect(f.herdr.creations).toBe(1);
|
||||
expect(f.prompts).toBe(0);
|
||||
const record = z
|
||||
.object({ child: z.string(), branch: z.string() })
|
||||
.parse(f.storage.get(`multi-work-call:${f.call.sessionID}:${f.call.id}`));
|
||||
expect(record.child).toBe("ses_child1");
|
||||
expect(record.branch).toStartWith("multi-work/");
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("same-child concurrent resumes are rejected while the native foreground executor is active", async () => {
|
||||
const f = fixture();
|
||||
f.hold();
|
||||
const controller = new AbortController();
|
||||
const checked = Promise.withResolvers<void>();
|
||||
const running = Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* f.install;
|
||||
yield* f.invoke().pipe(Effect.forkScoped);
|
||||
yield* Effect.promise(() => f.entered.promise);
|
||||
const second = yield* Effect.exit(
|
||||
f.execute({
|
||||
agent: "coordinate",
|
||||
description: "resume",
|
||||
prompt: "continue",
|
||||
sessionID: "ses_child1",
|
||||
}),
|
||||
);
|
||||
expect(second._tag).toBe("Failure");
|
||||
expect(f.herdr.creations).toBe(1);
|
||||
checked.resolve();
|
||||
return yield* Effect.never;
|
||||
}),
|
||||
),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
await Promise.race([checked.promise, running]);
|
||||
controller.abort();
|
||||
await expect(running).rejects.toBeDefined();
|
||||
expect(f.cancelled).toBe(true);
|
||||
});
|
||||
+87
-16
@@ -10,8 +10,9 @@ import type { JsonValue, ModelInfo, SessionGetInput } from "@opencode/client";
|
||||
import type { SessionContext } from "@opencode/plugin/promise/session";
|
||||
import { z } from "zod";
|
||||
import { gptAgents } from "../src/config.js";
|
||||
import plugin from "../src/index.js";
|
||||
import { basePlugin as plugin } from "../src/index.js";
|
||||
import { playwrightMcpCommand } from "../src/playwright.js";
|
||||
import { bindingKey } from "../src/multi-work.js";
|
||||
|
||||
const providerID = Provider.ID.make("test");
|
||||
function model(id: string): ModelInfo {
|
||||
@@ -63,6 +64,7 @@ async function host(
|
||||
let before!: (event: { tool: string; agent: string; input: unknown }) => Promise<void> | void;
|
||||
let parent = "build";
|
||||
let child = "fast-lo";
|
||||
let childDirectory = directory;
|
||||
let defaultAgent = "build";
|
||||
const registration = { async dispose() {} };
|
||||
let transform!: (editor: AgentEditor) => void;
|
||||
@@ -218,8 +220,13 @@ async function host(
|
||||
},
|
||||
async get({ sessionID }: { sessionID: string }) {
|
||||
return sessionID === "child"
|
||||
? { agent: child, parentID: "parent" }
|
||||
: { agent: parent };
|
||||
? {
|
||||
agent: child,
|
||||
projectID: "project-fixture",
|
||||
parentID: "parent",
|
||||
location: { directory: childDirectory },
|
||||
}
|
||||
: { agent: parent, projectID: "project-fixture", location: { directory } };
|
||||
},
|
||||
},
|
||||
tool: {
|
||||
@@ -299,6 +306,19 @@ async function host(
|
||||
child = target;
|
||||
for (const hook of sessionHooks.prompt ?? []) await hook({ sessionID: "child" });
|
||||
},
|
||||
bindCoordinate(ready: boolean, moved = true, local = false) {
|
||||
childDirectory = local ? directory : moved ? `${directory}/worktree` : directory;
|
||||
storage.set(bindingKey("child"), {
|
||||
owner: "parent",
|
||||
child: "child",
|
||||
projectID: "project-fixture",
|
||||
ready,
|
||||
root: { directory, workspaceID: "w1", repo: `${directory}/.git` },
|
||||
directory: local ? directory : `${directory}/worktree`,
|
||||
workspaceID: local ? "w1" : "w2",
|
||||
branch: local ? "root" : "multi-work/test",
|
||||
});
|
||||
},
|
||||
async tools(caller: string) {
|
||||
const event: ModelContext = {
|
||||
agent: Agent.ID.make(caller),
|
||||
@@ -318,28 +338,52 @@ async function host(
|
||||
};
|
||||
}
|
||||
|
||||
test("missing required models disables registration; only GPT mode degrades without Astra", async () => {
|
||||
test("orchestration inherits deep-lo and falls back to good-lo with available worker sets", async () => {
|
||||
const full = await host();
|
||||
expect(full.agents.has("deep-hi")).toBe(true);
|
||||
expect(String(full.agents.get("coordinate")?.model?.id)).toBe("gpt-6-astra");
|
||||
expect(String(full.agents.get("coordinate")?.model?.variant)).toBe("low");
|
||||
const reduced = await host(allModels.slice(0, 2));
|
||||
expect(reduced.agents.has("deep-hi")).toBe(false);
|
||||
expect(String(reduced.agents.get("coordinate")?.model?.id)).toBe("gpt-5.6-sol");
|
||||
expect(String(reduced.agents.get("coordinate")?.model?.variant)).toBe("low");
|
||||
expect(reduced.agents.get("coordinate")?.system).toContain("Deep agents are unavailable");
|
||||
expect((await host([])).agents.has("coordinate")).toBe(false);
|
||||
expect((await host(allModels.slice(1))).agents.has("coordinate")).toBe(false);
|
||||
const custom = await host([...allModels, model("local")], {
|
||||
|
||||
const customAgents = {
|
||||
...gptAgents,
|
||||
"good-lo": { model: "custom-good", effort: "high" as const },
|
||||
"deep-lo": { model: "custom-deep", effort: "medium" as const },
|
||||
};
|
||||
const custom = await host(
|
||||
[...allModels, reasoningModel("custom-good"), reasoningModel("custom-deep")],
|
||||
{
|
||||
mode: "custom",
|
||||
provider: "test",
|
||||
agents: { ...gptAgents, coordinate: { model: "local", effort: "low" } },
|
||||
agents: customAgents,
|
||||
},
|
||||
);
|
||||
expect(custom.agents.get("coordinate")?.model).toMatchObject({
|
||||
id: "custom-deep",
|
||||
variant: "medium",
|
||||
});
|
||||
expect(String(custom.agents.get("coordinate")?.model?.id)).toBe("local");
|
||||
const customFallback = await host([...allModels, reasoningModel("custom-good")], {
|
||||
mode: "custom",
|
||||
provider: "test",
|
||||
agents: customAgents,
|
||||
});
|
||||
expect(customFallback.agents.get("coordinate")?.model).toMatchObject({
|
||||
id: "custom-good",
|
||||
variant: "high",
|
||||
});
|
||||
expect(customFallback.agents.has("deep-lo")).toBe(false);
|
||||
expect(
|
||||
(
|
||||
await host(allModels.slice(0, 2), {
|
||||
await host(allModels, {
|
||||
mode: "custom",
|
||||
provider: "test",
|
||||
agents: gptAgents,
|
||||
agents: customAgents,
|
||||
})
|
||||
).agents.has("coordinate"),
|
||||
).toBe(false);
|
||||
@@ -619,6 +663,30 @@ test("manual calls and command-created child sessions cannot bypass delegation r
|
||||
expect((await result.tools("build")).subagent?.description).toBe("Native subagents");
|
||||
});
|
||||
|
||||
test("coordinate prompt admission requires owned ready placement; local and moved bindings work", async () => {
|
||||
const result = await host();
|
||||
await expect(result.command("multi-work", "coordinate")).rejects.toThrow(
|
||||
"ready multi-work binding",
|
||||
);
|
||||
result.bindCoordinate(false);
|
||||
await expect(result.command("multi-work", "coordinate")).rejects.toThrow(
|
||||
"ready multi-work binding",
|
||||
);
|
||||
result.bindCoordinate(true, false);
|
||||
await expect(result.command("multi-work", "coordinate")).rejects.toThrow(
|
||||
"ready multi-work binding",
|
||||
);
|
||||
result.bindCoordinate(true);
|
||||
await result.command("multi-work", "coordinate");
|
||||
result.bindCoordinate(true, true, true);
|
||||
await result.command("multi-work", "coordinate");
|
||||
await expect(result.command("build", "coordinate")).rejects.toThrow("ready multi-work binding");
|
||||
result.bindCoordinate(true);
|
||||
await expect(result.command("multi-work", "fast-lo")).rejects.toThrow("local fast-lo");
|
||||
for (const invoke of [result.delegate, result.command])
|
||||
await expect(invoke("build", "multi-work")).rejects.toThrow("primary only");
|
||||
});
|
||||
|
||||
test("reasoning settings and Luna priority use native advertised or official support", async () => {
|
||||
const luna = {
|
||||
...model("gpt-5.6-luna"),
|
||||
@@ -650,12 +718,12 @@ test("reasoning settings and Luna priority use native advertised or official sup
|
||||
expect(disabled.agents.get("fast-lo")?.request.settings).toEqual({ reasoningEffort: "low" });
|
||||
});
|
||||
|
||||
test("coordinator defaults expose their effort variant without relying on it for requests", async () => {
|
||||
test("coordinator uses inherited effort and ignores the legacy custom assignment", async () => {
|
||||
for (const options of [{ provider: "test" }, { mode: "gpt", provider: "test" }] as const) {
|
||||
const result = await host(allModels, options);
|
||||
expect(result.agents.get("coordinate")?.model).toMatchObject({
|
||||
id: "gpt-6-astra",
|
||||
variant: "medium",
|
||||
variant: "low",
|
||||
});
|
||||
expect(result.agents.get("coordinate")?.request.settings.reasoningEffort).toBeUndefined();
|
||||
}
|
||||
@@ -663,7 +731,7 @@ test("coordinator defaults expose their effort variant without relying on it for
|
||||
const fallback = await host(allModels.slice(0, 2));
|
||||
expect(fallback.agents.get("coordinate")?.model).toMatchObject({
|
||||
id: "gpt-5.6-sol",
|
||||
variant: "medium",
|
||||
variant: "low",
|
||||
});
|
||||
expect(fallback.agents.get("coordinate")?.request.settings.reasoningEffort).toBeUndefined();
|
||||
|
||||
@@ -672,8 +740,11 @@ test("coordinator defaults expose their effort variant without relying on it for
|
||||
provider: "test",
|
||||
agents: { ...gptAgents, coordinate: { model: "local", effort: "xhigh" } },
|
||||
});
|
||||
expect(custom.agents.get("coordinate")?.model?.variant).toBeUndefined();
|
||||
expect(custom.agents.get("coordinate")?.request.settings.reasoningEffort).toBe("xhigh");
|
||||
expect(custom.agents.get("coordinate")?.model).toMatchObject({
|
||||
id: "gpt-6-astra",
|
||||
variant: "low",
|
||||
});
|
||||
expect(custom.agents.get("coordinate")?.request.settings.reasoningEffort).toBeUndefined();
|
||||
});
|
||||
|
||||
test("the current host model API filters discovery and reloads changed availability", async () => {
|
||||
@@ -685,12 +756,12 @@ test("the current host model API filters discovery and reloads changed availabil
|
||||
const result = await host([]);
|
||||
await result.refresh(allModels);
|
||||
expect(String(result.agents.get("coordinate")?.model?.id)).toBe("gpt-6-astra");
|
||||
expect(String(result.agents.get("coordinate")?.model?.variant)).toBe("medium");
|
||||
expect(String(result.agents.get("coordinate")?.model?.variant)).toBe("low");
|
||||
expect(result.agents.get("coordinate")?.request.settings.reasoningEffort).toBeUndefined();
|
||||
await result.refresh(allModels.slice(0, 2));
|
||||
expect(result.agents.has("deep-lo")).toBe(false);
|
||||
expect(String(result.agents.get("coordinate")?.model?.id)).toBe("gpt-5.6-sol");
|
||||
expect(String(result.agents.get("coordinate")?.model?.variant)).toBe("medium");
|
||||
expect(String(result.agents.get("coordinate")?.model?.variant)).toBe("low");
|
||||
expect(result.agents.get("coordinate")?.request.settings.reasoningEffort).toBeUndefined();
|
||||
await result.refresh([]);
|
||||
expect(result.agents.has("coordinate")).toBe(false);
|
||||
|
||||
+35
-51
@@ -173,10 +173,6 @@ function fixture(
|
||||
storageReads: Promise<Schema.Json | undefined>[] = [],
|
||||
) {
|
||||
const stored = new Map<string, Schema.Json>();
|
||||
const sessions = new Map([
|
||||
["root", {}],
|
||||
["child", { parentID: "root" }],
|
||||
]);
|
||||
let calls = 0;
|
||||
let storageCalls = 0;
|
||||
let changes = 0;
|
||||
@@ -198,12 +194,6 @@ function fixture(
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
session: {
|
||||
async get({ sessionID }: { sessionID: string }) {
|
||||
// SAFETY: QuotaTracker reads only parentID from this focused fixture.
|
||||
return sessions.get(sessionID) as never;
|
||||
},
|
||||
} as never,
|
||||
storage: {
|
||||
async get(key: string) {
|
||||
storageCalls++;
|
||||
@@ -242,6 +232,7 @@ function fixture(
|
||||
storageCalls: () => storageCalls,
|
||||
changes: () => changes,
|
||||
headers,
|
||||
stored,
|
||||
switch(credential: typeof current) {
|
||||
current = credential;
|
||||
},
|
||||
@@ -256,7 +247,7 @@ async function accountFingerprint(id: string) {
|
||||
return Buffer.from(bytes).toString("hex");
|
||||
}
|
||||
|
||||
test("removes cached Spark and representative reserve windows and consumption while offline", async () => {
|
||||
test("reads old cached snapshots without session estimates and removes excluded windows", async () => {
|
||||
const cached = {
|
||||
account: await accountFingerprint("account-one"),
|
||||
latest: {
|
||||
@@ -331,22 +322,23 @@ test("removes cached Spark and representative reserve windows and consumption wh
|
||||
);
|
||||
|
||||
await result.tracker.init();
|
||||
const status = await result.tracker.status("root");
|
||||
const status = result.tracker.status();
|
||||
expect(status?.windows.map((window) => window.id)).toEqual([
|
||||
"codex:primary",
|
||||
"codex_luna:primary",
|
||||
]);
|
||||
expect(status?.windows[0]?.sessionConsumed).toBe(2);
|
||||
expect(status?.windows[1]?.sessionConsumed).toBe(3);
|
||||
expect(status?.windows[0]).not.toHaveProperty("sessionConsumed");
|
||||
expect(result.stored.get("chatgpt-quota")).not.toHaveProperty("sessions");
|
||||
expect(result.calls()).toBe(1);
|
||||
await result.tracker.dispose();
|
||||
});
|
||||
|
||||
test("activates only for OAuth and establishes an awaited root baseline shared by children", async () => {
|
||||
test("activates only for OAuth and refreshes quota at initialization and request start", async () => {
|
||||
const key = fixture({ type: "key", key: "secret" }, [plus]);
|
||||
await key.tracker.init();
|
||||
await key.tracker.start("root");
|
||||
expect(key.calls()).toBe(0);
|
||||
expect(await key.tracker.status("root")).toBeUndefined();
|
||||
expect(key.tracker.status()).toBeUndefined();
|
||||
await key.tracker.dispose();
|
||||
|
||||
const oauth = fixture(
|
||||
@@ -368,17 +360,19 @@ test("activates only for OAuth and establishes an awaited root baseline shared b
|
||||
],
|
||||
);
|
||||
await oauth.tracker.init();
|
||||
await oauth.tracker.start("root");
|
||||
expect(oauth.calls()).toBe(1);
|
||||
expect(oauth.tracker.status()?.windows[0]?.remaining).toBe(87.5);
|
||||
await oauth.tracker.start("root");
|
||||
expect(oauth.calls()).toBe(2);
|
||||
expect(oauth.headers[0]?.get("chatgpt-account-id")).toBe("account-one");
|
||||
await oauth.tracker.refresh();
|
||||
const child = await oauth.tracker.status("child");
|
||||
expect(child?.windows[0]?.sessionConsumed).toBe(5);
|
||||
expect(child?.windows[0]?.remaining).toBe(82.5);
|
||||
const status = oauth.tracker.status();
|
||||
expect(status?.windows[0]?.remaining).toBe(82.5);
|
||||
expect(status?.windows[0]).not.toHaveProperty("sessionConsumed");
|
||||
expect(oauth.stored.get("chatgpt-quota")).not.toHaveProperty("sessions");
|
||||
await oauth.tracker.dispose();
|
||||
});
|
||||
|
||||
test("polls, carries consumption across quota resets, tolerates errors, and clears on logout", async () => {
|
||||
test("polls, tolerates errors, and clears on logout", async () => {
|
||||
const reset = {
|
||||
...plus,
|
||||
rate_limit: {
|
||||
@@ -403,12 +397,12 @@ test("polls, carries consumption across quota resets, tolerates errors, and clea
|
||||
await result.tracker.init();
|
||||
await result.tracker.start("root");
|
||||
await Bun.sleep(35);
|
||||
const status = await result.tracker.status("root");
|
||||
const status = result.tracker.status();
|
||||
expect(result.calls()).toBeGreaterThanOrEqual(3);
|
||||
expect(status?.windows[0]?.sessionConsumed).toBe(2);
|
||||
expect(status?.windows[0]?.remaining).toBe(98);
|
||||
result.logout();
|
||||
await result.tracker.authChanged(true);
|
||||
expect(await result.tracker.status("root")).toBeUndefined();
|
||||
expect(result.tracker.status()).toBeUndefined();
|
||||
const calls = result.calls();
|
||||
await result.tracker.dispose();
|
||||
await Bun.sleep(20);
|
||||
@@ -424,12 +418,11 @@ test("sidebar text places reset timing beside latest remaining quota", () => {
|
||||
label: null,
|
||||
used: 25,
|
||||
remaining: 75,
|
||||
sessionConsumed: 1.5,
|
||||
resetsAt: 100_000 + 2 * 86_400_000 + 5 * 3_600_000,
|
||||
},
|
||||
100_000,
|
||||
),
|
||||
).toBe("7d: 75% · ~1.5pp · 2d 5h");
|
||||
).toBe("7d: 75% · 2d 5h");
|
||||
});
|
||||
|
||||
test("sidebar reset timing uses compact future units and handles boundaries", () => {
|
||||
@@ -442,17 +435,16 @@ test("sidebar reset timing uses compact future units and handles boundaries", ()
|
||||
label: null,
|
||||
used: 25,
|
||||
remaining: 75,
|
||||
sessionConsumed: 1.5,
|
||||
resetsAt: now + resetIn,
|
||||
},
|
||||
now,
|
||||
);
|
||||
|
||||
expect(format(2 * 3_600_000 + 15 * 60_000)).toBe("5h: 75% · ~1.5pp · 2h 15m");
|
||||
expect(format(3_600_000)).toBe("5h: 75% · ~1.5pp · 1h");
|
||||
expect(format(15 * 60_000)).toBe("5h: 75% · ~1.5pp · 15m");
|
||||
expect(format(60_000)).toBe("5h: 75% · ~1.5pp · 1m");
|
||||
expect(format(59_999)).toBe("5h: 75% · ~1.5pp · <1m");
|
||||
expect(format(2 * 3_600_000 + 15 * 60_000)).toBe("5h: 75% · 2h 15m");
|
||||
expect(format(3_600_000)).toBe("5h: 75% · 1h");
|
||||
expect(format(15 * 60_000)).toBe("5h: 75% · 15m");
|
||||
expect(format(60_000)).toBe("5h: 75% · 1m");
|
||||
expect(format(59_999)).toBe("5h: 75% · <1m");
|
||||
});
|
||||
|
||||
test("sidebar reset timing preserves labels and distinguishes stale and unavailable timestamps", () => {
|
||||
@@ -462,17 +454,16 @@ test("sidebar reset timing preserves labels and distinguishes stale and unavaila
|
||||
label: "GPT-5.6 Luna",
|
||||
used: 99.95,
|
||||
remaining: 0.05,
|
||||
sessionConsumed: 1.5,
|
||||
};
|
||||
expect(formatQuotaWindow({ ...window, resetsAt: 999 }, 1_000)).toBe(
|
||||
"GPT-5.6 Luna 5h: <0.1% · ~1.5pp · due",
|
||||
"GPT-5.6 Luna 5h: <0.1% · due",
|
||||
);
|
||||
expect(formatQuotaWindow({ ...window, resetsAt: Number.NaN }, 1_000)).toBe(
|
||||
"GPT-5.6 Luna 5h: <0.1% · ~1.5pp · unavailable",
|
||||
"GPT-5.6 Luna 5h: <0.1% · unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
test("inactive roots freeze, resume from a cached baseline on failure, and deactivate by family", async () => {
|
||||
test("request completion refreshes once across terminal and idle events", async () => {
|
||||
const used = (percent: number) => ({
|
||||
...plus,
|
||||
rate_limit: {
|
||||
@@ -492,16 +483,10 @@ test("inactive roots freeze, resume from a cached baseline on failure, and deact
|
||||
await result.tracker.init();
|
||||
await result.tracker.start("root");
|
||||
await result.tracker.complete("root");
|
||||
expect((await result.tracker.status("root"))?.windows[0]?.sessionConsumed).toBe(1);
|
||||
await result.tracker.refresh();
|
||||
expect((await result.tracker.status("root"))?.windows[0]?.sessionConsumed).toBe(1);
|
||||
|
||||
await result.tracker.start("root");
|
||||
await result.tracker.start("child");
|
||||
expect(result.tracker.status()?.windows[0]?.remaining).toBe(70);
|
||||
await result.tracker.complete("root");
|
||||
await result.tracker.refresh();
|
||||
await result.tracker.complete("child");
|
||||
expect((await result.tracker.status("root"))?.windows[0]?.sessionConsumed).toBe(6);
|
||||
expect(result.calls()).toBe(3);
|
||||
expect(result.tracker.status()?.windows[0]?.remaining).toBe(70);
|
||||
await result.tracker.dispose();
|
||||
});
|
||||
|
||||
@@ -529,8 +514,7 @@ test("an auth switch detaches an in-flight old-account response and immediately
|
||||
},
|
||||
],
|
||||
);
|
||||
await result.tracker.init();
|
||||
const old = result.tracker.start("root");
|
||||
const old = result.tracker.init();
|
||||
while (result.calls() === 0) await Bun.sleep(1);
|
||||
result.switch({
|
||||
type: "oauth",
|
||||
@@ -542,10 +526,10 @@ test("an auth switch detaches an in-flight old-account response and immediately
|
||||
expect(result.calls()).toBe(2);
|
||||
pending.resolve(plus);
|
||||
await old;
|
||||
expect((await result.tracker.status("root"))?.windows[0]?.used).toBe(70);
|
||||
expect(result.tracker.status()?.windows[0]?.used).toBe(70);
|
||||
expect(result.headers[1]?.get("chatgpt-account-id")).toBe("account-two");
|
||||
await result.tracker.refresh();
|
||||
expect((await result.tracker.status("root"))?.windows[0]?.sessionConsumed).toBe(0);
|
||||
expect(result.tracker.status()?.windows[0]).not.toHaveProperty("sessionConsumed");
|
||||
await result.tracker.dispose();
|
||||
});
|
||||
|
||||
@@ -577,7 +561,7 @@ test("an auth switch during an old account storage read cannot restore stale sta
|
||||
expect(result.calls()).toBe(1);
|
||||
pending.resolve(undefined);
|
||||
await old;
|
||||
expect((await result.tracker.status("root"))?.windows[0]?.used).toBe(64);
|
||||
expect(result.tracker.status()?.windows[0]?.used).toBe(64);
|
||||
expect(result.headers[0]?.get("chatgpt-account-id")).toBe("account-two");
|
||||
await result.tracker.dispose();
|
||||
});
|
||||
|
||||
+131
-14
@@ -1,11 +1,14 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import type { Plugin } from "@opencode/plugin/tui";
|
||||
import type { Route, SlotMap } from "@opencode/plugin/tui/context";
|
||||
import { testRender, type JSX } from "@opentui/solid";
|
||||
import { jsx } from "@opentui/solid/jsx-runtime";
|
||||
import { createSignal } from "solid-js";
|
||||
import { build } from "../scripts/build.js";
|
||||
import type { QuotaStatus } from "../src/quota.js";
|
||||
|
||||
type SlotRender = (input: { readonly sessionID: string }) => JSX.Element;
|
||||
type SidebarSlotRender = (input: { readonly sessionID: string }) => JSX.Element;
|
||||
type HomeSlotRender = (input: SlotMap["prompt.footer"]) => JSX.Element;
|
||||
|
||||
const status = (plan: string, remaining: number): QuotaStatus => ({
|
||||
plan,
|
||||
@@ -18,7 +21,6 @@ const status = (plan: string, remaining: number): QuotaStatus => ({
|
||||
used: 100 - remaining,
|
||||
resetsAt: 2,
|
||||
remaining,
|
||||
sessionConsumed: 1.5,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -31,14 +33,19 @@ test("built quota slot stays reactive across pending, empty, failure, update, an
|
||||
);
|
||||
const { default: plugin } = tui;
|
||||
const pending = Promise.withResolvers<QuotaStatus | null>();
|
||||
const responses: Array<() => Promise<QuotaStatus | null>> = [() => pending.promise];
|
||||
const sessions: string[] = [];
|
||||
let slotRender: SlotRender | undefined;
|
||||
const responses: Array<() => Promise<QuotaStatus | null>> = [
|
||||
async () => status("home", 90),
|
||||
() => pending.promise,
|
||||
];
|
||||
let statusCalls = 0;
|
||||
let sidebarRender: SidebarSlotRender | undefined;
|
||||
let homeRender: HomeSlotRender | undefined;
|
||||
let notifyUpdated: (() => void) | undefined;
|
||||
const [route, setRoute] = createSignal<Route>({ type: "home" });
|
||||
|
||||
const quota = {
|
||||
status(input: { sessionID: string }) {
|
||||
sessions.push(input.sessionID);
|
||||
status() {
|
||||
statusCalls++;
|
||||
const response = responses.shift();
|
||||
if (!response) throw new Error("Missing test response");
|
||||
return response();
|
||||
@@ -53,10 +60,21 @@ test("built quota slot stays reactive across pending, empty, failure, update, an
|
||||
const contextFixture = {
|
||||
client: { rpc: () => quota },
|
||||
ui: {
|
||||
slot(claim: { render: SlotRender }) {
|
||||
slotRender = claim.render;
|
||||
slot(claim: {
|
||||
after?: "prompt.footer";
|
||||
append?: "sidebar.footer";
|
||||
render: HomeSlotRender | SidebarSlotRender;
|
||||
}) {
|
||||
if (claim.after === "prompt.footer") {
|
||||
// SAFETY: The prompt footer renderer receives the matching slot input.
|
||||
homeRender = claim.render as HomeSlotRender;
|
||||
} else {
|
||||
// SAFETY: The only other fixture path is the session-aware sidebar slot.
|
||||
sidebarRender = claim.render as SidebarSlotRender;
|
||||
}
|
||||
return () => undefined;
|
||||
},
|
||||
router: { current: route },
|
||||
},
|
||||
theme: { text: { default: "#ffffff", muted: "#888888" } },
|
||||
};
|
||||
@@ -64,10 +82,45 @@ test("built quota slot stays reactive across pending, empty, failure, update, an
|
||||
// members implemented by this focused rendering fixture.
|
||||
const context = Object.assign({} as Plugin.Context, contextFixture);
|
||||
const cleanup = await plugin.setup(context);
|
||||
if (!slotRender || !notifyUpdated) throw new Error("Plugin did not register its slot");
|
||||
if (!sidebarRender || !homeRender || !notifyUpdated)
|
||||
throw new Error("Plugin did not register its slots");
|
||||
|
||||
const renderHome = homeRender;
|
||||
const home = await testRender(() => renderHome({ mode: "normal", showDetails: false }), {
|
||||
width: 40,
|
||||
height: 6,
|
||||
});
|
||||
await home.waitForFrame((frame) => frame.includes("ChatGPT quota (home)"));
|
||||
const homeFrame = home.captureCharFrame();
|
||||
expect(homeFrame.split("\n")).toEqual([
|
||||
" ",
|
||||
" ",
|
||||
" ChatGPT quota (home) ",
|
||||
" 5h: 90% · due ",
|
||||
" ",
|
||||
" ",
|
||||
"",
|
||||
]);
|
||||
expect(statusCalls).toBe(1);
|
||||
home.renderer.destroy();
|
||||
|
||||
setRoute({ type: "session", sessionID: "session-one" });
|
||||
const sessionPrompt = await testRender(
|
||||
() =>
|
||||
renderHome({
|
||||
sessionID: "session-one",
|
||||
mode: "normal",
|
||||
showDetails: false,
|
||||
}),
|
||||
{ width: 40, height: 5 },
|
||||
);
|
||||
await sessionPrompt.renderOnce();
|
||||
expect(sessionPrompt.captureCharFrame()).not.toContain("ChatGPT quota");
|
||||
expect(statusCalls).toBe(1);
|
||||
sessionPrompt.renderer.destroy();
|
||||
|
||||
const [sessionID, setSessionID] = createSignal("session-one");
|
||||
const renderSlot = slotRender;
|
||||
const renderSlot = sidebarRender;
|
||||
const setup = await testRender(
|
||||
() =>
|
||||
renderSlot({
|
||||
@@ -84,7 +137,8 @@ test("built quota slot stays reactive across pending, empty, failure, update, an
|
||||
|
||||
pending.resolve(status("plus", 75));
|
||||
await setup.waitForFrame((frame) => frame.includes("ChatGPT quota (plus)"));
|
||||
expect(setup.captureCharFrame()).toContain("5h: 75% · ~1.5pp · due");
|
||||
expect(setup.captureCharFrame()).toContain("5h: 75% · due");
|
||||
expect(setup.captureCharFrame()).not.toContain("~");
|
||||
|
||||
responses.push(async () => null);
|
||||
notifyUpdated();
|
||||
@@ -94,15 +148,78 @@ test("built quota slot stays reactive across pending, empty, failure, update, an
|
||||
throw new Error("disconnected");
|
||||
});
|
||||
notifyUpdated();
|
||||
await setup.waitFor(() => sessions.length === 3);
|
||||
await setup.waitFor(() => statusCalls === 4);
|
||||
expect(setup.captureCharFrame()).not.toContain("ChatGPT quota");
|
||||
|
||||
responses.push(async () => status("pro", 42));
|
||||
setSessionID("session-two");
|
||||
await setup.waitForFrame((frame) => frame.includes("ChatGPT quota (pro)"));
|
||||
expect(sessions).toEqual(["session-one", "session-one", "session-one", "session-two"]);
|
||||
expect(statusCalls).toBe(5);
|
||||
} finally {
|
||||
setup.renderer.destroy();
|
||||
if (cleanup) await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("home quota stays out of the host footer row at wide and narrow widths", async () => {
|
||||
await build();
|
||||
const { default: plugin } = await import(new URL("../dist/tui.js", import.meta.url).href);
|
||||
let homeRender: HomeSlotRender | undefined;
|
||||
const contextFixture = {
|
||||
client: {
|
||||
rpc: () => ({
|
||||
status: async () => status("home", 90),
|
||||
events: { on: () => () => undefined },
|
||||
}),
|
||||
},
|
||||
ui: {
|
||||
slot(claim: { after?: "prompt.footer"; render: HomeSlotRender }) {
|
||||
if (claim.after === "prompt.footer") homeRender = claim.render;
|
||||
return () => undefined;
|
||||
},
|
||||
router: { current: () => ({ type: "home" as const }) },
|
||||
},
|
||||
theme: { text: { default: "#ffffff", muted: "#888888" } },
|
||||
};
|
||||
// SAFETY: This focused fixture implements every context member read by slot setup/rendering.
|
||||
const cleanup = await plugin.setup(Object.assign({} as Plugin.Context, contextFixture));
|
||||
if (!homeRender) throw new Error("Plugin did not register the home slot");
|
||||
const renderHome = homeRender;
|
||||
try {
|
||||
for (const width of [75, 40]) {
|
||||
const footer = (quota: boolean) =>
|
||||
jsx("box", {
|
||||
flexDirection: "row",
|
||||
children: [
|
||||
jsx("text", { children: "/project" }),
|
||||
jsx("box", { flexGrow: 1 }),
|
||||
jsx("text", { children: "NORMAL" }),
|
||||
jsx("text", { children: " ctrl+k commands" }),
|
||||
quota ? renderHome({ mode: "normal", showDetails: false }) : undefined,
|
||||
],
|
||||
});
|
||||
const baseline = await testRender(() => footer(false), { width, height: 6 });
|
||||
const rendered = await testRender(() => footer(true), { width, height: 6 });
|
||||
try {
|
||||
await baseline.renderOnce();
|
||||
await rendered.waitForFrame((frame) => frame.includes("ChatGPT quota (home)"));
|
||||
const baselineRows = baseline.captureCharFrame().split("\n");
|
||||
const quotaRows = rendered.captureCharFrame().split("\n");
|
||||
expect(quotaRows[0]).toBe(baselineRows[0]);
|
||||
expect(quotaRows[0]).toContain("/project");
|
||||
expect(quotaRows[0]).toContain("NORMAL ctrl+k commands");
|
||||
expect(quotaRows[1]?.trim()).toBe("");
|
||||
expect(quotaRows[2]?.trim()).toBe("ChatGPT quota (home)");
|
||||
expect(quotaRows[3]?.trim()).toBe("5h: 90% · due");
|
||||
expect(quotaRows[2]?.indexOf("ChatGPT quota (home)")).toBe(
|
||||
Math.ceil((width - "ChatGPT quota (home)".length) / 2),
|
||||
);
|
||||
} finally {
|
||||
baseline.renderer.destroy();
|
||||
rendered.renderer.destroy();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (cleanup) await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user