Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog.d/opencode-subagents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### Added
- An OpenCode 2 pane's subagents are background tasks, as a Claude Code pane's are: the message
box's button and the sidebar badge count those running, the list shows them with the newest ten
that ended in the last day, a turn that ends while they run reads BG with its finish alerted
once, and one whose pane no longer runs that session reads as lost. A subagent run in the
background leaves a card in the chat with its answer, and a foreground one's answer no longer
shows OpenCode's `<subagent>` tag.
2 changes: 1 addition & 1 deletion docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ model's window, which is the file pi reads its own providers from; a model it st
for shows no ring rather than a guessed one, because pi answers those from a catalogue or a
running llama.cpp server that this app cannot ask. The model and reasoning effort come from what the session recorded, never from answer text. A todo list shows where the agent recorded it, in the turn's work block: Claude Code's `TodoWrite`, Codex's `update_plan`, or omp, omo and gjc todo calls. Plain-text plans are not reconstructed, and Claude Code `TaskCreate` / `TaskUpdate` calls are not drawn in the work block: the task list they build is the pane's plan, below. Details and verification are in the [chat-mode audit](chat-mode-audit.md).

Background tasks have a button by the message box, with a count while some run: an OmO pane's `task` children, and a Claude Code pane's subagents (the `Agent` tool), read from the session's own files. A Claude Code pane's background commands (`run_in_background`, Ctrl+B, or one moved there by its timeout) are listed with them. It lists what runs and the newest ten that ended in the last day, and the sidebar badge shows the running count. A Claude Code turn that ends while work it started still runs reads **BG**, not DONE: that work's notice starts the next turn by itself, and the finish is alerted once, when that turn ends (a turn is held at most 30 minutes). A subagent that ends leaves a card in the chat with its answer, and a Claude Code subagent whose pane no longer runs that session reads as lost.
Background tasks have a button by the message box, with a count while some run: an OmO pane's `task` children, a Claude Code pane's subagents (the `Agent` tool), read from the session's own files, and an OpenCode 2 pane's subagents, the child sessions OpenCode keeps of the session in its store (one whose OpenCode service went away mid-run, without ending it, reads as lost once another service starts or the pane leaves that session; on Windows, where a service's start cannot be read, only once the pane leaves that session). A Claude Code pane's background commands (`run_in_background`, Ctrl+B, or one moved there by its timeout) are listed with them. It lists what runs and the newest ten that ended in the last day, and the sidebar badge shows the running count. A Claude Code or OpenCode turn that ends while work it started still runs reads **BG**, not DONE: that work's notice starts the next turn by itself, and the finish is alerted once, when that turn ends (a turn is held at most 30 minutes). A subagent that ends leaves a card in the chat with its answer (for OpenCode, one run in the background; a foreground one's answer is its call's output), and a Claude Code or OpenCode subagent whose pane no longer runs that session reads as lost.

A pane's plan is the task list a Claude Code agent keeps (`TaskCreate` / `TaskUpdate`) or the checklist Codex keeps (`update_plan`), read back from the session's transcript. While there is one, the background-task button by the message box starts with **Plan** and how many of its steps are done (`2/5`); the same button opens one list with the plan's steps first and the background tasks below. Steps are listed so a step comes after the steps it waits on (Claude Code's `blockedBy`; a Codex step waits on the one before it). The running step says what it does now, and each step shows how long it took. The sidebar row carries the same count. Claude Code empties its task list once every task is done, and so does this: the first task after that starts a new plan. A teammate of an agent team that changes a task writes that in its own transcript, which is not read.

Expand Down
25 changes: 25 additions & 0 deletions server/background-wait.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,31 @@ describe("BackgroundWait", () => {
expect(waits.waiting("p1")).toBe(true);
});

it("keeps, past another agent's lifetime, only the rests seen since the agent that followed began", () => {
const time = clock();
const waits = new BackgroundWait(time.now);
// one agent's turn rests on its work
waits.status("p", "working");
waits.running("p", 1, null);
waits.status("p", "done");
expect(waits.waiting("p")).toBe(true);
time.pass(1000);
const since = time.now();
// the next agent's turn rests before its tracker knows its session
waits.status("p", "working");
time.pass(1000);
waits.status("p", "idle");
waits.resetBefore("p", since);
expect(waits.waiting("p")).toBe(false);
// its tracker counts what that turn started: the turn holds
waits.running("p", 1, since);
expect(waits.waiting("p")).toBe(true);
// a reset keeps no rest
waits.reset("p");
waits.running("p", 1, since);
expect(waits.waiting("p")).toBe(false);
});

it("forgets a pane", () => {
const waits = new BackgroundWait(clock().now);
waits.running("p1", 1);
Expand Down
12 changes: 12 additions & 0 deletions server/background-wait.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,18 @@ export class BackgroundWait {
pane.waiting = false;
}

/**
* reset, keeping the rests observed from `since` on: another agent's lifetime ended there, and the one that
* followed was seen rest before its tracker knew its session. Its counts come afresh from that tracker.
*/
resetBefore(paneId: string, since: number): void {
const pane = this.panes.get(paneId);
if (!pane) return;
const rests = pane.rests.filter((at) => at >= since);
this.reset(paneId);
pane.rests = rests;
}

forget(paneId: string): void {
this.panes.delete(paneId);
}
Expand Down
5 changes: 3 additions & 2 deletions server/core/server-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export function createCore({ options, devices, preferences, usage, usageLogin, T
const { pending, holdPending, holdPendingPane, drainPending, pendingFrames, queueFirstMessage, attached, closeConnection, reconcilePiDeliveries } = createPendingDelivery({ options, clients, attachments, sight, serialize, lastTyped, submitText, authorizeSocket: (client) => authorizeSocket(client), clientSnapshot: () => clientSnapshot(), omo: { sessionOf: (paneId) => omo.sessionOf(paneId) }, SUBMIT_DEADLINE_MS, SUBMIT_DELAY_MS, TYPED_SETTLE_MS });

const { mayType, authorizeSocket, admit, setRole, watchPane, unwatchPane, killWatches, end } = createConnections({ clients, present, pending: { close: closeConnection }, devices, detachClaimed, dropClaims });
const { push, omo, gjc, claudeAgents, plans, clientSnapshot, countVisit, machines, broadcastAll, logPushError, collector, waitTimer } = createStatusHub({ options, devices, preferences, clients, present, pending, drainPending, holdPendingPane, usage, usageLogin });
const { push, omo, gjc, claudeAgents, opencodeAgents, plans, clientSnapshot, countVisit, machines, broadcastAll, logPushError, collector, waitTimer } = createStatusHub({ options, devices, preferences, clients, present, pending, drainPending, holdPendingPane, usage, usageLogin });
const websocket = createSocket({
clients, present, stopped: () => stopped, admit, setRole, watchPane, unwatchPane, end, holdPending, attachments, clientSnapshot, countVisit, drainOutput,
// each frame type by name, with the handler of the module that owns it: anything else on a frame object never
Expand All @@ -100,7 +100,7 @@ export function createCore({ options, devices, preferences, usage, usageLogin, T
let outputTimer: ReturnType<typeof setInterval> | undefined;

return {
sidecar, serialize, push, logPushError, omo, claudeAgents, plans, clientSnapshot, machines, broadcastAll, collector, websocket, queueFirstMessage,
sidecar, serialize, push, logPushError, omo, claudeAgents, opencodeAgents, plans, clientSnapshot, machines, broadcastAll, collector, websocket, queueFirstMessage,
/** once the server listens */
start(): void {
// ACKs can stop arriving entirely (a suspended tab). Bound the pause even then.
Expand All @@ -119,6 +119,7 @@ export function createCore({ options, devices, preferences, usage, usageLogin, T
omo.stop();
gjc.stop();
claudeAgents.stop();
opencodeAgents.stop();
clearInterval(waitTimer);
plans.stop();
machines?.stop();
Expand Down
166 changes: 166 additions & 0 deletions server/core/status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { afterAll, afterEach, describe, expect, it, mock, spyOn } from "bun:test";
import { Database } from "bun:sqlite";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import type { HerdrPane } from "../../shared/protocol.ts";
import * as collector from "../collector.ts";
import * as conversation from "../conversation.ts";
import * as herdr from "../herdr/client.ts";
import * as service from "../opencode-service.ts";
import { connectionData, type Client } from "./connections.ts";

// herdr's status events come from the test, as the collector would hand them over
type Handlers = { onStatus: (paneId: string, status: string, agent: string) => void; onBaseline: (panes: unknown[]) => void };
let handlers: Handlers;
const originalCollector = collector.startStatusCollector;
mock.module("../collector.ts", () => ({
...collector,
startStatusCollector: (seen: Handlers) => {
handlers = seen;
return { ready: Promise.resolve(), structureReady: Promise.resolve(), focusReady: Promise.resolve(), trackPane() {}, stop() {} };
},
}));
const { createStatusHub } = await import("./status.ts");

const root = mkdtempSync(join(tmpdir(), "herdr-status-hub-"));
afterAll(() => {
mock.module("../collector.ts", () => ({ ...collector, startStatusCollector: originalCollector }));
rmSync(root, { recursive: true, force: true });
});
const stubs: { mockRestore(): void }[] = [];
afterEach(() => { for (const stub of stubs.splice(0)) stub.mockRestore(); });

// an OpenCode parent session whose one child runs, and a resting Claude session with a background subagent its prompt started
const at = Date.now() - 10_000;
const store = join(root, "opencode.db");
{
const db = new Database(store, { create: true });
db.exec(`CREATE TABLE session_v2 (id text PRIMARY KEY, parent_id text, time_created integer);
CREATE TABLE session_message (id text PRIMARY KEY, session_id text, type text, seq integer, time_created integer, data text)`);
db.query("INSERT INTO session_v2 VALUES ('ses_child', 'ses_parent', ?)").run(at + 1000);
const row = db.query("INSERT INTO session_message VALUES (?, ?, 'user', 1, ?, ?)");
row.run("msg_parent", "ses_parent", at, JSON.stringify({ time: { created: at } }));
row.run("msg_child", "ses_child", at + 1000, JSON.stringify({ time: { created: at + 1000 } }));
db.close();
}
const CLAUDE = "11111111-1111-4111-8111-111111111111";
const transcript = join(root, `${CLAUDE}.jsonl`);
{
const line = (value: unknown) => `${JSON.stringify(value)}\n`;
mkdirSync(join(root, CLAUDE, "subagents"), { recursive: true });
writeFileSync(transcript, line({ type: "user", origin: { kind: "human" }, timestamp: new Date(at - 1000).toISOString(), permissionMode: "default", message: { role: "user", content: "go" } }));
writeFileSync(join(root, CLAUDE, "subagents", "agent-a1.meta.json"), JSON.stringify({ agentType: "reviewer", description: "review", toolUseId: "toolu_a1", requestShape: "background" }));
writeFileSync(join(root, CLAUDE, "subagents", "agent-a1.jsonl"), line({ isSidechain: true, sessionId: CLAUDE, agentId: "a1", type: "user", timestamp: new Date(at).toISOString(), message: { role: "user", content: "review" } }));
}

const base = { pane_id: "w1:p1", workspace_id: "w1", tab_id: "w1:t1", agent_status: "idle" };
const opencode = (session: string | null) => ({ ...base, agent: "opencode", ...(session ? { agent_session: { agent: "opencode", source: "herdr:opencode", kind: "id", value: session } } : {}) });
const claude = { ...base, agent: "claude", agent_session: { agent: "claude", source: "hook", kind: "id", value: CLAUDE } };

let hubs = 0;
/** The status hub with herdr, OpenCode's service and the Claude lookup stood in for; what a browser and web push are sent. */
function hub() {
let panes: unknown[] = [];
let gate: Promise<void> | null = null;
const answer = () => ({ agents: [], layouts: [], panes: structuredClone(panes), protocol: 1, tabs: [], version: "test", workspaces: [] });
stubs.push(
spyOn(herdr, "sessionSnapshot").mockImplementation(async () => { const read = answer(); if (gate) await gate; return read as never; }),
spyOn(herdr, "paneGet").mockImplementation(async () => structuredClone(panes[0]) as never),
spyOn(service, "readOpenCodeService").mockReturnValue(null),
spyOn(conversation, "claudePaneSession").mockResolvedValue({ path: transcript, startedAt: null, pid: null } as never),
spyOn(conversation, "claudePanePid").mockResolvedValue(null as never),
);
const frames: { type: string; agent_status?: string; background_tasks?: number; background_wait?: boolean }[] = [];
const client = { data: connectionData({ deviceId: undefined, readOnly: false }), getBufferedAmount: () => 0, send: (encoded: string) => { frames.push(JSON.parse(encoded)); return 1; } } as unknown as Client;
const status = createStatusHub({
options: { stateDir: join(root, `state${++hubs}`), opencodeDb: store, machines: false },
devices: { gated: false, has: () => true } as never, preferences: { get: () => ({ muted_panes: [] }), subscribe: () => () => {} } as never,
clients: new Set([client]), present: new Set(), pending: { status() {}, forget() {} } as never, drainPending() {}, holdPendingPane() {},
usage: { invalidate() {} } as never, usageLogin: { ended: () => false } as never,
});
const alerts: string[] = [];
stubs.push(spyOn(status.push, "onStatus").mockImplementation(async (_paneId: string, alert: string) => { alerts.push(alert); }));
stubs.push({ mockRestore: () => { status.collector.stop(); status.omo.stop(); status.gjc.stop(); status.claudeAgents.stop(); status.opencodeAgents.stop(); status.plans.stop(); clearInterval(status.waitTimer); } });
return {
alerts,
/** herdr's panes from now on, as a snapshot or a pane lookup reads them */
herdrSays: (pane: unknown) => { panes = [pane]; },
/** a snapshot, both trackers reading it (Claude's lookup awaited) */
snapshot: async () => { await status.clientSnapshot(); await status.claudeAgents.refresh(panes as HerdrPane[]); },
/** a snapshot whose read begins now and is answered when `open` is called */
heldSnapshot: async () => {
let open!: () => void;
gate = new Promise((resolve) => { open = resolve; });
const read = status.clientSnapshot();
await new Promise((resolve) => setTimeout(resolve, 0));
gate = null;
return async () => { open(); await read; };
},
/** the pane's last status frame */
last: () => {
const frame = frames.filter((seen) => seen.type === "pane-status").at(-1);
return { status: frame?.agent_status, tasks: frame?.background_tasks ?? 0, bg: frame?.background_wait === true };
},
/** the lookups and reads a status event set off */
settle: () => new Promise((resolve) => setTimeout(resolve, 0)),
};
}

describe("the status hub's OpenCode subagents", () => {
it("holds an OpenCode turn after Claude Code's on its subagent though its session is found after the turn, and calls its finish off", async () => {
const h = hub();
h.herdrSays(claude);
handlers.onBaseline([claude]);
await h.snapshot();
handlers.onStatus("w1:p1", "working", "claude");
handlers.onStatus("w1:p1", "idle", "claude");
expect(h.last()).toEqual({ status: "done", tasks: 1, bg: true });
// OpenCode's first turn there starts a subagent and ends before any snapshot
h.herdrSays(opencode("ses_parent"));
const from = h.alerts.length;
handlers.onStatus("w1:p1", "working", "opencode");
handlers.onStatus("w1:p1", "idle", "opencode");
await h.settle();
expect(h.last()).toEqual({ status: "done", tasks: 1, bg: true });
expect(h.alerts.slice(from).at(-1)).toBe("working");
// the snapshot that follows, Claude's tracker letting go of the pane, keeps it
await h.snapshot();
expect(h.last()).toEqual({ status: "done", tasks: 1, bg: true });
});

it("keeps an OpenCode turn held though a snapshot read before its session was found is answered after", async () => {
const h = hub();
h.herdrSays(claude);
handlers.onBaseline([claude]);
await h.snapshot();
handlers.onStatus("w1:p1", "working", "claude");
handlers.onStatus("w1:p1", "idle", "claude");
// what herdr says as a snapshot's read begins: OpenCode's home screen
h.herdrSays(opencode(null));
const answer = await h.heldSnapshot();
h.herdrSays(opencode("ses_parent"));
handlers.onStatus("w1:p1", "working", "opencode");
await h.settle();
await answer();
handlers.onStatus("w1:p1", "idle", "opencode");
await h.settle();
expect(h.last()).toEqual({ status: "done", tasks: 1, bg: true });
});

it("lets a Claude Code session that follows OpenCode's in the pane hold nothing of OpenCode's", async () => {
const h = hub();
h.herdrSays(opencode("ses_parent"));
handlers.onBaseline([opencode("ses_parent")]);
await h.snapshot();
handlers.onStatus("w1:p1", "working", "opencode");
handlers.onStatus("w1:p1", "idle", "opencode");
expect(h.last()).toEqual({ status: "done", tasks: 1, bg: true });
// a resting Claude session with a subagent running from before
h.herdrSays(claude);
await h.snapshot();
handlers.onStatus("w1:p1", "idle", "claude");
expect(h.last().bg).toBe(false);
});
});
Loading
Loading