Compare commits

..
Author SHA1 Message Date
StanPonomarev 52e98c9d78 feat: multi-work is able to delegate to root
CI / verify (pull_request) Successful in 9s
2026-09-18 20:13:54 +02:00
9 changed files with 376 additions and 88 deletions
+5 -3
View File
@@ -48,14 +48,16 @@ assignment is accepted for compatibility but deprecated and ignored; orchestrati
## Multi-work ## Multi-work
Select `multi-work` in a verified live Herdr root checkout to delegate local retrieval Select `multi-work` in a verified live Herdr root checkout to delegate local retrieval
to `fast-lo` and independent work to `coordinate` children in new Herdr worktrees. 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, 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 falling back to `good-lo` when `deep-lo` is unavailable. Clients viewing the
same eligible location can see it; eligibility is revalidated before creation. same eligible location can see it; eligibility is revalidated before creation.
Configure `experimental.subagent_depth: 2` at the root and worktree locations. Configure `experimental.subagent_depth: 2` at the root and worktree locations.
The native `subagent` tool accepts `worktree: { base: "source-branch" }` for new For new coordinate tasks, omit `worktree` to share the current root checkout, or pass
coordinate tasks and retains native session/job handling. See [usage, permissions, `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). failure handling, and verification limits](docs/multi-work.md).
## Playwright ## Playwright
+37 -13
View File
@@ -1,13 +1,15 @@
# Multi-work # Multi-work
`multi-work` is a **primary-only** agent for coordinating independent work in `multi-work` is a **primary-only** agent for coordinating independent work in
Herdr-managed Git worktrees. Like `coordinate`, it uses the configured `deep-lo` the current Herdr root checkout or new Herdr-managed Git worktrees. Like
model and effort, falling back to `good-lo` when `deep-lo` is unavailable. No `coordinate`, it uses the configured `deep-lo` model and effort, falling back
extra custom model assignment is required. to `good-lo` when `deep-lo` is unavailable. No extra custom model assignment is
required.
```text ```text
multi-work (root checkout) multi-work (root checkout)
├── fast-lo (local retrieval) ├── fast-lo (local retrieval)
├── coordinate (current root checkout)
└── coordinate (new Herdr worktree) └── coordinate (new Herdr worktree)
└── existing leaf workers (that worktree) └── existing leaf workers (that worktree)
``` ```
@@ -56,7 +58,24 @@ plugins or project configuration.
## Delegation ## Delegation
Multi-work retains the native tool name, `subagent`. It adds one argument: 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 ```js
subagent({ subagent({
@@ -86,17 +105,22 @@ checkout path; the tool parses and verifies the returned checkout/workspace.
Creation explicitly preserves focus with `--no-focus`. Creation explicitly preserves focus with `--no-focus`.
For focused retrieval, use native `subagent` with `agent: "fast-lo"` and no For focused retrieval, use native `subagent` with `agent: "fast-lo"` and no
`worktree`. Resume an owned coordinate child with its `sessionID` and **omit** `worktree`. A new coordinate call without `worktree` means root-local work. Resume
`worktree`. Resumes verify parent, agent, checkout, branch, and live Herdr ownership. 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. Bound coordinate children cannot be repurposed through native delegation.
Neither multi-work nor coordinate has general shell/edit permissions. Multi-work Neither multi-work nor coordinate has general shell/edit permissions. Multi-work
also rejects non-reading/non-delegation tools at execution, including Code Mode, also rejects non-reading/non-delegation tools at execution, including Code Mode,
even if a session permission override would expose them. Worktree preparation even if a session permission override would expose them. Creating or resuming
requires effective `herdr_worktree: allow`, evaluated against the canonical root worktree-backed delegation requires effective `herdr_worktree: allow`, evaluated
directory using ordered agent and session rules. `ask` fails explicitly because against the canonical root directory using ordered agent and session rules. `ask`
the public plugin API cannot request custom-tool approval. Native subagent fails explicitly because the public plugin API cannot request custom-tool approval.
permissions are still asserted by OpenCode before preparation starts. 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 Worktree permissions inherit the base plugin's **effective build-permission
snapshot**, including configuration applied after plugin transforms. Its existing snapshot**, including configuration applied after plugin transforms. Its existing
@@ -118,8 +142,8 @@ child ID, and confirm its actual session location before allowing the task promp
A move-request acknowledgement alone is insufficient. Confirmation is bounded to A move-request acknowledgement alone is insufficient. Confirmation is bounded to
30 seconds and remains interruptible. The parent session does not move. 30 seconds and remains interruptible. The parent session does not move.
Coordinate is child-capable only through an owned, ready worktree binding. Prompt Coordinate is child-capable only through an owned, ready multi-work binding. Prompt
admission and model-context guards reject unbound or moved coordinate children. admission and model-context guards reject unbound or relocated coordinate children.
Multi-work cannot be a child, and the existing workers remain leaves. Multi-work cannot be a child, and the existing workers remain leaves.
Normal delegation cards and child navigation recognize the retained `subagent` Normal delegation cards and child navigation recognize the retained `subagent`
+2 -2
View File
@@ -320,7 +320,7 @@ const makeBasePlugin = (permissionsChanged: (permissions: Agent.Info["permission
if (agent === "multi-work") throw new Error("multi-work is primary only"); if (agent === "multi-work") throw new Error("multi-work is primary only");
if (agent === "coordinate" && event.agent !== "multi-work") if (agent === "coordinate" && event.agent !== "multi-work")
throw new Error( throw new Error(
"coordinate cannot run as a subagent without a ready worktree binding", "coordinate cannot run as a subagent without a ready multi-work binding",
); );
if (workerNames.some((name) => name === event.agent)) if (workerNames.some((name) => name === event.agent))
throw new Error("Workers cannot delegate tasks"); throw new Error("Workers cannot delegate tasks");
@@ -349,7 +349,7 @@ const makeBasePlugin = (permissionsChanged: (permissions: Agent.Info["permission
parent.location.directory !== binding.data.root.directory parent.location.directory !== binding.data.root.directory
) )
throw new Error( throw new Error(
"coordinate cannot run as a subagent without a ready worktree binding", "coordinate cannot run as a subagent without a ready multi-work binding",
); );
} }
if ( if (
+65 -29
View File
@@ -9,7 +9,8 @@ import { randomUUID } from "node:crypto";
import { Herdr, rootSchema } from "./herdr.js"; import { Herdr, rootSchema } from "./herdr.js";
import { multiWorkGuide } from "./routing.js"; import { multiWorkGuide } from "./routing.js";
export const bindingSchema = z.object({ export const bindingSchema = z
.object({
owner: z.string(), owner: z.string(),
child: z.string(), child: z.string(),
projectID: z.string(), projectID: z.string(),
@@ -18,7 +19,14 @@ export const bindingSchema = z.object({
workspaceID: z.string(), workspaceID: z.string(),
branch: z.string(), branch: z.string(),
ready: z.boolean(), 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}`; export const bindingKey = (id: string) => `multi-work:${id}`;
const inputSchema = z const inputSchema = z
.object({ .object({
@@ -98,7 +106,7 @@ export const installMultiWork = (
agent.name = Agent.Name.make("multi-work"); agent.name = Agent.Name.make("multi-work");
agent.mode = "primary"; agent.mode = "primary";
agent.description = agent.description =
"Coordinate independent tasks in Herdr worktrees; retrieve local facts with fast-lo."; "Coordinate independent tasks locally or in Herdr worktrees; retrieve local facts with fast-lo.";
agent.system = multiWorkGuide; agent.system = multiWorkGuide;
agent.permissions = [ agent.permissions = [
...coordinate.permissions.filter((rule) => rule.action !== "subagent"), ...coordinate.permissions.filter((rule) => rule.action !== "subagent"),
@@ -137,14 +145,14 @@ export const installMultiWork = (
child.projectID !== binding.projectID || child.projectID !== binding.projectID ||
child.location.directory !== binding.directory child.location.directory !== binding.directory
) )
throw new Error("Coordinate child does not match its ready worktree binding"); throw new Error("Coordinate child does not match its ready multi-work binding");
const parent = yield* context.session.get({ sessionID: child.parentID }); const parent = yield* context.session.get({ sessionID: child.parentID });
if ( if (
parent.agent !== "multi-work" || parent.agent !== "multi-work" ||
parent.projectID !== binding.projectID || parent.projectID !== binding.projectID ||
parent.location.directory !== binding.root.directory parent.location.directory !== binding.root.directory
) )
throw new Error("Worktree owner changed agent or location"); throw new Error("Binding owner changed agent or location");
return binding; return binding;
}); });
@@ -184,7 +192,7 @@ export const installMultiWork = (
agent: { type: "string", enum: ["fast-lo", "coordinate"] }, agent: { type: "string", enum: ["fast-lo", "coordinate"] },
}; };
tool.description = tool.description =
"Delegate to fast-lo locally, or coordinate in a new Herdr worktree using worktree: {base, branch?, label?}. Prefer descriptive human-readable branch and label values, for example {base:'main',branch:'fix/login-timeout',label:'Fix login timeout'}. Continue an owned coordinate child with sessionID and no worktree argument; naming fields are create-only. Native background and results apply."; "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), }).pipe(Effect.orDie),
); );
@@ -208,9 +216,7 @@ export const installMultiWork = (
throw new Error("A bound coordinate child cannot be repurposed"); throw new Error("A bound coordinate child cannot be repurposed");
if (call.agent !== "multi-work") { if (call.agent !== "multi-work") {
if (input.worktree || input.agent === "coordinate") if (input.worktree || input.agent === "coordinate")
throw new Error( throw new Error("Only multi-work may delegate to coordinate");
"Only multi-work may initialize coordinate worktrees",
);
return yield* native.execute(input, call); return yield* native.execute(input, call);
} }
const parent = yield* context.session.get({ sessionID: call.sessionID }); const parent = yield* context.session.get({ sessionID: call.sessionID });
@@ -240,14 +246,27 @@ export const installMultiWork = (
} }
if (input.agent !== "coordinate") if (input.agent !== "coordinate")
throw new Error("multi-work delegates only to fast-lo or coordinate"); throw new Error("multi-work delegates only to fast-lo or coordinate");
if ( if (input.sessionID && input.worktree !== undefined)
input.sessionID
? input.worktree !== undefined
: input.worktree === undefined
)
throw new Error( throw new Error(
"New coordinate work requires worktree: {base}; resumes require only sessionID", "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({ const { data: agent } = yield* context.agent.get({
agentID: call.agent, agentID: call.agent,
location: { directory: parent.location.directory }, location: { directory: parent.location.directory },
@@ -264,17 +283,16 @@ export const installMultiWork = (
throw new Error( throw new Error(
`herdr_worktree requires explicit allow; custom approval is unavailable (${permission ?? "ask"})`, `herdr_worktree requires explicit allow; custom approval is unavailable (${permission ?? "ask"})`,
); );
const lock = input.sessionID ?? `${call.sessionID}:${call.id}`; }
if (busy.has(lock)) throw new Error("This delegation is already active"); if (placement) {
busy.add(lock); const binding = placement;
let childLock: string | undefined; if (
return yield* Effect.gen(function* () { root.directory !== binding.root.directory ||
let initialized = false; root.workspaceID !== binding.root.workspaceID ||
let placement: z.infer<typeof bindingSchema> | undefined; root.repo !== binding.root.repo
if (input.sessionID) { )
const binding = yield* validate(Session.ID.make(input.sessionID)); throw new Error("Bound Herdr root is no longer available");
if (binding.owner !== call.sessionID) if (binding.directory !== binding.root.directory)
throw new Error("Cannot resume another parent's worktree");
yield* Effect.tryPromise((signal) => yield* Effect.tryPromise((signal) =>
herdr.verify( herdr.verify(
binding.directory, binding.directory,
@@ -284,7 +302,6 @@ export const installMultiWork = (
signal, signal,
), ),
); );
placement = binding;
initialized = true; initialized = true;
} }
const { worktree, ...args } = input; const { worktree, ...args } = input;
@@ -310,8 +327,6 @@ export const installMultiWork = (
); );
childLock = id; childLock = id;
busy.add(id); busy.add(id);
if (!worktree)
throw new Error("Missing worktree initialization");
const sessionID = Session.ID.make(id); const sessionID = Session.ID.make(id);
const child = yield* context.session.get({ sessionID }); const child = yield* context.session.get({ sessionID });
if ( if (
@@ -323,6 +338,27 @@ export const installMultiWork = (
throw new Error( throw new Error(
"Native executor returned an unexpected child", "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 = const branch =
worktree.branch ?? `multi-work/${randomUUID()}`; worktree.branch ?? `multi-work/${randomUUID()}`;
yield* context.storage.set(callKey, { yield* context.storage.set(callKey, {
+6 -4
View File
@@ -16,15 +16,17 @@ export const descriptions = {
export const multiWorkGuide = `You are multi-work, a primary orchestrator in a verified Herdr root checkout. 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. 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 Use native subagent with agent fast-lo for focused local retrieval. Use agent coordinate
for work in a new Herdr worktree, supplying descriptive human-readable names such as 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 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 description and a complete prompt with scope, constraints and acceptance checks. The tool
uses the requested branch and Herdr workspace label; when omitted, it creates the prior unique 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 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. 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 Resume an owned coordinate child using its sessionID and no worktree argument. Never switch
its agent or location; naming fields are create-only. Only fast-lo and coordinate are permitted. Workers are leaves. its agent or assigned checkout; naming fields are create-only. Only fast-lo and coordinate are permitted. Workers are leaves.
Parallelize independent work with disjoint ownership; serialize dependencies. Native background 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. 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 Reconcile results and report checkout, branch and child IDs. Retained resources on failure
must not be deleted automatically. Never retry ambiguous creation failures blindly. must not be deleted automatically. Never retry ambiguous creation failures blindly.
+1 -1
View File
@@ -80,7 +80,7 @@ export default Plugin.define({
return ( return (
<Show when={status()}> <Show when={status()}>
<Show when={props.centered} fallback={<Content />}> <Show when={props.centered} fallback={<Content />}>
<box width="100%" alignItems="center"> <box position="absolute" top={2} left={0} width="100%" alignItems="center">
<Content /> <Content />
</box> </box>
</Show> </Show>
+151 -1
View File
@@ -14,6 +14,8 @@ import { bindingKey, bindingSchema, installMultiWork } from "../src/multi-work.j
class FixtureHerdr extends Herdr { class FixtureHerdr extends Herdr {
eligible = true; eligible = true;
rootWorkspaceID = "w1";
rootRepo = "/root/.git";
creations = 0; creations = 0;
verified = 0; verified = 0;
delayCreation = false; delayCreation = false;
@@ -24,7 +26,7 @@ class FixtureHerdr extends Herdr {
creationStarted = Promise.withResolvers<void>(); creationStarted = Promise.withResolvers<void>();
override async root(directory: string) { override async root(directory: string) {
return this.eligible && directory === "/root" return this.eligible && directory === "/root"
? { directory, workspaceID: "w1", repo: "/root/.git" } ? { directory, workspaceID: this.rootWorkspaceID, repo: this.rootRepo }
: undefined; : undefined;
} }
override async create( override async create(
@@ -366,6 +368,128 @@ test("local fast-lo uses native delegation without a worktree", async () => {
); );
}); });
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 () => { test("custom branch and workspace label pass through delegation", async () => {
const f = fixture(); const f = fixture();
await Effect.runPromise( await Effect.runPromise(
@@ -623,6 +747,32 @@ test("configured build restrictions remain effective for worktree preparation",
); );
}); });
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 () => { test("failed initialization records the attempt and never blindly recreates on replay", async () => {
const f = fixture(); const f = fixture();
f.missingDestination(); f.missingDestination();
+13 -10
View File
@@ -306,17 +306,17 @@ async function host(
child = target; child = target;
for (const hook of sessionHooks.prompt ?? []) await hook({ sessionID: "child" }); for (const hook of sessionHooks.prompt ?? []) await hook({ sessionID: "child" });
}, },
bindCoordinate(ready: boolean, moved = true) { bindCoordinate(ready: boolean, moved = true, local = false) {
childDirectory = moved ? `${directory}/worktree` : directory; childDirectory = local ? directory : moved ? `${directory}/worktree` : directory;
storage.set(bindingKey("child"), { storage.set(bindingKey("child"), {
owner: "parent", owner: "parent",
child: "child", child: "child",
projectID: "project-fixture", projectID: "project-fixture",
ready, ready,
root: { directory, workspaceID: "w1", repo: `${directory}/.git` }, root: { directory, workspaceID: "w1", repo: `${directory}/.git` },
directory: `${directory}/worktree`, directory: local ? directory : `${directory}/worktree`,
workspaceID: "w2", workspaceID: local ? "w1" : "w2",
branch: "multi-work/test", branch: local ? "root" : "multi-work/test",
}); });
}, },
async tools(caller: string) { async tools(caller: string) {
@@ -663,22 +663,25 @@ test("manual calls and command-created child sessions cannot bypass delegation r
expect((await result.tools("build")).subagent?.description).toBe("Native subagents"); expect((await result.tools("build")).subagent?.description).toBe("Native subagents");
}); });
test("coordinate prompt admission requires both owned provenance and a completed move; multi-work cannot be a child", async () => { test("coordinate prompt admission requires owned ready placement; local and moved bindings work", async () => {
const result = await host(); const result = await host();
await expect(result.command("multi-work", "coordinate")).rejects.toThrow( await expect(result.command("multi-work", "coordinate")).rejects.toThrow(
"ready worktree binding", "ready multi-work binding",
); );
result.bindCoordinate(false); result.bindCoordinate(false);
await expect(result.command("multi-work", "coordinate")).rejects.toThrow( await expect(result.command("multi-work", "coordinate")).rejects.toThrow(
"ready worktree binding", "ready multi-work binding",
); );
result.bindCoordinate(true, false); result.bindCoordinate(true, false);
await expect(result.command("multi-work", "coordinate")).rejects.toThrow( await expect(result.command("multi-work", "coordinate")).rejects.toThrow(
"ready worktree binding", "ready multi-work binding",
); );
result.bindCoordinate(true); result.bindCoordinate(true);
await result.command("multi-work", "coordinate"); await result.command("multi-work", "coordinate");
await expect(result.command("build", "coordinate")).rejects.toThrow("ready worktree binding"); 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"); await expect(result.command("multi-work", "fast-lo")).rejects.toThrow("local fast-lo");
for (const invoke of [result.delegate, result.command]) for (const invoke of [result.delegate, result.command])
await expect(invoke("build", "multi-work")).rejects.toThrow("primary only"); await expect(invoke("build", "multi-work")).rejects.toThrow("primary only");
+74 -3
View File
@@ -2,6 +2,7 @@ import { expect, test } from "bun:test";
import type { Plugin } from "@opencode/plugin/tui"; import type { Plugin } from "@opencode/plugin/tui";
import type { Route, SlotMap } from "@opencode/plugin/tui/context"; import type { Route, SlotMap } from "@opencode/plugin/tui/context";
import { testRender, type JSX } from "@opentui/solid"; import { testRender, type JSX } from "@opentui/solid";
import { jsx } from "@opentui/solid/jsx-runtime";
import { createSignal } from "solid-js"; import { createSignal } from "solid-js";
import { build } from "../scripts/build.js"; import { build } from "../scripts/build.js";
import type { QuotaStatus } from "../src/quota.js"; import type { QuotaStatus } from "../src/quota.js";
@@ -87,12 +88,19 @@ test("built quota slot stays reactive across pending, empty, failure, update, an
const renderHome = homeRender; const renderHome = homeRender;
const home = await testRender(() => renderHome({ mode: "normal", showDetails: false }), { const home = await testRender(() => renderHome({ mode: "normal", showDetails: false }), {
width: 40, width: 40,
height: 5, height: 6,
}); });
await home.waitForFrame((frame) => frame.includes("ChatGPT quota (home)")); await home.waitForFrame((frame) => frame.includes("ChatGPT quota (home)"));
const homeFrame = home.captureCharFrame(); const homeFrame = home.captureCharFrame();
expect(homeFrame).toContain(" ChatGPT quota (home)"); expect(homeFrame.split("\n")).toEqual([
expect(homeFrame).toContain(" 5h: 90% · due"); " ",
" ",
" ChatGPT quota (home) ",
" 5h: 90% · due ",
" ",
" ",
"",
]);
expect(statusCalls).toBe(1); expect(statusCalls).toBe(1);
home.renderer.destroy(); home.renderer.destroy();
@@ -152,3 +160,66 @@ test("built quota slot stays reactive across pending, empty, failure, update, an
if (cleanup) await cleanup(); 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();
}
});