fix: track opencode subagent status per pane

refs #1362
This commit is contained in:
Ogulcan Celik
2026-09-18 18:44:02 +03:00
parent 241063f7ff
commit f358549c2c
10 changed files with 701 additions and 67 deletions
@@ -227,6 +227,10 @@ Install registers the V1 TUI plugin in `tui.jsonc` and the V2 TUI plugin in `cli
V2 lifecycle reporting runs in the pane-local TUI, which associates events with its selected root session even when multiple panes share one OpenCode server. Completion and interruption clear the working state; pending permission requests, pending forms, and failed executions keep the pane blocked. V2 Mini and headless clients do not run the TUI plugin and therefore do not provide this lifecycle reporting.
V1 TUI reporting follows the pane's selected session and its descendants. The pane stays working while any of them is busy or retrying, and blocked while any has a pending permission or question. Attaching loads existing activity; cancelled tool requests do not become blockers again after reconnecting. Browsing a subagent keeps the original session family, while directly attaching to a child tracks that child's subtree rather than its siblings.
Local V1 `opencode run` and `opencode --mini` retain their legacy server hooks; this change does not add pane-local lifecycle reporting to Mini/headless clients. After updating the integration, restart shared OpenCode servers as well as their TUIs so the old server reporter cannot overwrite pane-local state.
The plugin reports lifecycle state and session identity while OpenCode runs inside a Herdr pane. After OpenCode emits a session-bearing event, Herdr can use the reported session id to resume the pane with `opencode --session <id>`. Native screen manifest detection remains available when the plugin is not installed.
## Kilo Code CLI
@@ -229,6 +229,10 @@ herdr integration install opencode
V2 のライフサイクル報告はペイン内の TUI で実行され、複数のペインが同じ OpenCode サーバーを共有する場合も、選択されたルートセッションにイベントを対応付けます。完了時または中断時に working 状態を解除し、未処理の権限要求やフォーム、実行失敗は blocked 状態を維持します。V2 Mini とヘッドレスクライアントは TUI プラグインを実行しないため、このライフサイクル報告は利用できません。
V1 TUI はペインで選択したセッションとその子孫を追跡します。いずれかが実行中・再試行中なら working、権限要求や質問が未処理なら blocked を維持します。接続時に既存の状態を読み込み、キャンセル済みツールの要求が再接続後に blocked を復活させることはありません。サブエージェントの閲覧では元のセッション群を維持し、子セッションへ直接接続した場合は兄弟ではなくその子孫だけを追跡します。
ローカル V1 の `opencode run` と `opencode --mini` は従来のサーバーフックを維持します。この変更は Mini やヘッドレスクライアントにペイン固有のライフサイクル報告を追加するものではありません。連携の更新後は TUI と共有 OpenCode サーバーの両方を再起動し、古いサーバー報告がペインの状態を上書きしないようにしてください。
このプラグインは、OpenCode が Herdr のペイン内で動いている間、ライフサイクル状態とセッション識別を報告します。OpenCode がセッション情報を含むイベントを発行した後、Herdr は報告されたセッション id を使って `opencode --session <id>` でペインを resume できます。プラグインがインストールされていないときは、スクリーンマニフェスト検出が引き続き利用できます。
## Kilo Code CLI
@@ -229,6 +229,10 @@ herdr integration install opencode
V2 生命周期上报在窗格本地的 TUI 中运行。即使多个窗格共享同一个 OpenCode 服务端,事件也只归属于该 TUI 选中的根会话。完成和中断会清除工作状态;待处理的权限请求、表单以及执行失败会使窗格保持阻塞。V2 Mini 和无界面客户端不运行 TUI 插件,因此不提供此生命周期上报。
V1 TUI 跟踪窗格选中的会话及其后代。只要其中任何会话仍在忙碌或重试,就保持 working;存在待处理的权限请求或问题时保持 blocked。连接时会读取已有活动,已取消工具的请求不会在重新连接后再次变成阻塞项。查看子代理时保留原来的会话范围;直接连接子会话时只跟踪它及其后代,不包括兄弟会话。
本地 V1 的 `opencode run` 和 `opencode --mini` 保留原有服务端钩子;此变更不会为 Mini 或无界面客户端添加窗格本地的生命周期上报。更新集成后,请同时重启共享 OpenCode 服务器及其 TUI,避免旧服务端上报覆盖窗格本地状态。
该插件在 OpenCode 运行于 Herdr 窗格内时上报生命周期状态和会话身份。在 OpenCode 发出携带会话信息的事件后,Herdr 可以用上报的会话 id 通过 `opencode --session <id>` 恢复该窗格。插件未安装时,屏幕清单检测仍然可用。
## Kilo Code CLI
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
const originalPlatform = process.platform;
const originalArgv = process.argv;
const originalCreateConnection = net.createConnection;
const originalEnvironment = {
HERDR_ENV: process.env.HERDR_ENV,
@@ -35,6 +36,7 @@ afterEach(async () => {
Object.defineProperty(process, "platform", { value: originalPlatform });
net.createConnection = originalCreateConnection;
process.argv = originalArgv;
for (const [name, value] of Object.entries(originalEnvironment)) {
if (value === undefined) {
delete process.env[name];
@@ -135,6 +137,7 @@ for (const socketPlugin of socketPlugins) {
Object.defineProperty(process, "platform", { value: "win32" });
const connectedEndpoint = captureConnectionEndpoint();
process.argv = ["bun", "/$bunfs/root/src/index.js", "run"];
const { HerdrAgentStatePlugin } = await importFresh(socketPlugin.modulePath);
const plugin = await HerdrAgentStatePlugin();
await plugin.event({
@@ -2,7 +2,7 @@
// managed by herdr; reinstalling or updating the integration overwrites this file.
// add custom hooks/plugins beside this file instead of editing it.
// HERDR_INTEGRATION_ID=opencode
// HERDR_INTEGRATION_VERSION=12
// HERDR_INTEGRATION_VERSION=13
import net from "node:net";
@@ -118,8 +118,23 @@ function reportState(state, sessionID) {
return request("pane.report_agent", params);
}
function ownsLocalLifecycle() {
const args = process.argv.slice(2);
const separator = args.indexOf("--");
if (separator !== -1) args.splice(separator);
if (args.some((arg) => arg === "--attach" || arg.startsWith("--attach="))) return false;
while (args[0] === "--print-logs" || args[0] === "--log-level" || args[0]?.startsWith("--log-level=")) {
args.splice(0, args[0] === "--log-level" ? 2 : 1);
}
// These local clients have no TUI plugin. Shared servers and the TUI worker
// cannot identify their attached panes; their lifecycle belongs to each TUI.
return args[0] === "run" ||
(!["serve", "web", "attach"].includes(args[0]) && args.includes("--mini"));
}
export const HerdrAgentStatePlugin = async () => {
if (
!ownsLocalLifecycle() ||
process.env.HERDR_ENV !== "1" ||
!process.env.HERDR_SOCKET_PATH ||
!process.env.HERDR_PANE_ID
@@ -200,9 +215,8 @@ export const HerdrAgentStatePlugin = async () => {
};
};
// V1 (1.18.29+) calls server(). V2 calls setup() instead. Its shared server
// cannot attribute sessions using its process environment: the pane-local TUI
// owns both selection and lifecycle reporting there, including remote servers.
// V1 local run/Mini retain their server hooks. V1/V2 full TUIs own both
// selection and lifecycle, including when attached to a shared remote server.
export default {
id: "herdr.opencode",
server: HerdrAgentStatePlugin,
@@ -1,4 +1,7 @@
import { beforeEach, expect, mock, test } from "bun:test";
import { afterEach, beforeEach, expect, mock, test } from "bun:test";
const originalArgv = process.argv;
afterEach(() => { process.argv = originalArgv; });
const requests: unknown[] = [];
const clients: FakeClient[] = [];
@@ -43,6 +46,7 @@ beforeEach(() => {
clients.length = 0;
requestWaiters.length = 0;
autoAcknowledge = true;
process.argv = ["bun", "/$bunfs/root/src/index.js", "run"];
process.env.HERDR_ENV = "1";
process.env.HERDR_SOCKET_PATH = "test.sock";
process.env.HERDR_PANE_ID = "test:p1";
@@ -223,6 +227,27 @@ test("routes nested child prompts to their own root, not the last active root",
]);
});
test("only local run and Mini own server lifecycle, never shared servers or TUI workers", async () => {
for (const args of [
["run"], ["run", "--session", "existing"], ["--mini"], ["--mini", "--session", "existing"],
["--print-logs", "--log-level", "DEBUG", "run"], ["run", "--", "--attach"],
]) {
process.argv = ["bun", "/$bunfs/root/src/index.js", ...args];
expect((await loadPlugin()).event).toBeFunction();
}
for (const args of [
[], ["--session", "existing"], ["serve"], ["web"], ["attach", "http://localhost:4096"],
["run", "--attach", "http://localhost:4096"], ["--mini", "--attach=http://localhost:4096"],
["serve", "--", "--mini"],
]) {
process.argv = ["bun", "/$bunfs/root/src/index.js", ...args];
expect(await loadPlugin()).toEqual({});
}
process.argv = ["bun", "/$bunfs/root/src/cli/tui/worker.js"];
expect(await loadPlugin()).toEqual({});
expect(requests).toHaveLength(0);
});
function requestMethod(request: unknown): unknown {
return isRecord(request) ? request.method : undefined;
}
@@ -1,7 +1,7 @@
// installed by herdr
// managed by herdr; reinstalling or updating the integration overwrites this file.
// HERDR_INTEGRATION_ID=opencode-tui
// HERDR_INTEGRATION_VERSION=12
// HERDR_INTEGRATION_VERSION=13
import net from "node:net";
@@ -67,65 +67,341 @@ export default {
// Keep this plain object dependency-free: V1 and V2 expose different SDK
// packages, but both loaders accept their own lifecycle entry on this object.
setup,
tui: async (api) => {
if (
process.env.HERDR_ENV !== "1" ||
!process.env.HERDR_SOCKET_PATH ||
!process.env.HERDR_PANE_ID
) {
tui,
};
async function tui(api) {
if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_SOCKET_PATH || !process.env.HERDR_PANE_ID) return;
let disposed = false;
let context;
let sequence = Date.now() * 1000;
let chain = Promise.resolve();
function routeID() {
const route = api.route.current;
return route?.name === "session" ? route.params?.sessionID : undefined;
}
function current(ctx) {
return !disposed && context === ctx && routeID() === ctx.route;
}
async function read(ctx, request) {
const result = await request({
signal: AbortSignal.any([ctx.controller.signal, AbortSignal.timeout(5_000)]),
throwOnError: true,
});
if (!current(ctx) || result?.data === undefined) throw new Error("session data unavailable");
return result.data;
}
// The first selected session owns this pane's subtree. Browsing descendants
// keeps that boundary; directly attaching to a child does not claim siblings.
function root(ctx, id) {
const seen = new Set();
while (typeof id === "string" && !seen.has(id) && !ctx.deleted.has(id)) {
seen.add(id);
if (id === ctx.boundary) return id;
const session = ctx.sessions.get(id) ?? api.state.session.get(id);
if (!session) return;
if (!session.parentID) return id;
id = session.parentID;
}
}
async function resolveRoot(ctx, id) {
const seen = new Set();
while (typeof id === "string" && !seen.has(id) && !ctx.deleted.has(id)) {
seen.add(id);
if (id === ctx.boundary) return id;
let session = ctx.sessions.get(id) ?? api.state.session.get(id);
if (!session) {
if (!ctx.lookups.has(id)) {
const requestedID = id;
const lookup = read(ctx, (options) => api.client.session.get({ sessionID: requestedID }, options));
ctx.lookups.set(id, lookup);
lookup.finally(() => ctx.lookups.delete(requestedID)).catch(() => {});
}
session = await ctx.lookups.get(id);
if (ctx.deleted.has(id)) return;
session = ctx.sessions.get(id) ?? session;
if (session.id !== id) throw new Error("unexpected session identity");
ctx.sessions.set(id, session);
}
if (!session.parentID) return id;
id = session.parentID;
}
}
function owners(ctx) {
return new Set([...ctx.statuses.keys(), ...[...ctx.blockers.values()].map((r) => r.sessionID)]);
}
function state(ctx) {
if (ctx.settled) return "idle";
for (const request of ctx.blockers.values()) {
if (root(ctx, request.sessionID) === ctx.selected) return "blocked";
}
if (ctx.errors.has(ctx.selected)) return "blocked";
for (const id of ctx.statuses.keys()) {
if (root(ctx, id) === ctx.selected) return "working";
}
if (ctx.hydrated && [...owners(ctx)].every((id) => root(ctx, id) !== undefined)) return "idle";
}
function publish(ctx, selection = false) {
if (!current(ctx) || !ctx.selected) return;
ctx.selectionPending ||= selection;
if (ctx.queued) return;
ctx.queued = true;
chain = chain.then(async () => {
ctx.queued = false;
const selected = ctx.selected;
const isCurrent = () => current(ctx) && !!selected && ctx.selected === selected &&
(ctx.settled || root(ctx, ctx.route) === selected);
if (!isCurrent()) return;
if (ctx.selectionPending) {
if (!await requestOnce(selected, undefined, undefined, isCurrent)) {
ctx.retryAt = Date.now() + 500;
return;
}
if (!isCurrent()) return;
ctx.selectionPending = false;
ctx.lastState = undefined;
}
const value = state(ctx);
if (value === undefined || value === ctx.lastState) return;
const delivered = await requestOnce(selected, value, ++sequence, isCurrent);
if (!isCurrent()) return;
ctx.lastState = delivered ? value : undefined;
if (!delivered) ctx.retryAt = Date.now() + 500;
}).catch(() => {
if (current(ctx)) {
ctx.lastState = undefined;
ctx.retryAt = Date.now() + 500;
}
});
}
function retire(ctx, route) {
ctx?.controller?.abort();
context = ctx?.selected
? { route, selected: ctx.selected, settled: true, retryAt: Infinity }
: undefined;
if (context) publish(context);
}
function reconcile(ctx) {
if (!current(ctx)) return;
publish(ctx);
if (ctx.resolving) {
ctx.resolveAgain = true;
return;
}
ctx.resolving = true;
void (async () => {
const ancestor = await resolveRoot(ctx, ctx.route);
if (!current(ctx) || !ancestor) return;
const selected = ancestor === ctx.boundary ? ctx.boundary : ctx.route;
if (selected !== ctx.selected) {
ctx.boundary = selected;
ctx.selected = selected;
publish(ctx, true);
}
// Fetch ancestry only for active/request-bearing sessions, not historical trees.
await Promise.all([...owners(ctx)].map((id) => resolveRoot(ctx, id)));
if (current(ctx)) publish(ctx);
})().catch(() => {
if (current(ctx)) ctx.retryAt = Date.now() + 500;
}).finally(() => {
ctx.resolving = false;
if (ctx.resolveAgain) {
ctx.resolveAgain = false;
reconcile(ctx);
}
});
}
let selectedSessionID;
let retryIndex = 0;
let nextReportAt = 0;
let reportPending = false;
const syncSelectedSession = async () => {
const route = api.route.current;
const sessionID = route?.name === "session" ? route.params?.sessionID : undefined;
const session =
typeof sessionID === "string" && sessionID
? api.state.session.get(sessionID)
: undefined;
if (!session || session.parentID) {
selectedSessionID = undefined;
retryIndex = 0;
nextReportAt = 0;
function clearRequests(ctx, matches) {
for (const [key, request] of ctx.blockers) if (matches(request)) ctx.blockers.delete(key);
}
function terminalTool(part) {
return part?.type === "tool" && ["completed", "error"].includes(part.state?.status);
}
function apply(ctx, event) {
const data = event.properties;
if (!data) return;
if (event.type === "message.part.updated") {
const part = data.part;
if (terminalTool(part)) {
clearRequests(ctx, (r) => r.sessionID === part.sessionID &&
r.tool?.messageID === part.messageID && r.tool?.callID === part.callID);
}
return;
}
const id = data.sessionID ?? data.info?.id;
if (typeof id !== "string" || ctx.deleted.has(id)) return;
switch (event.type) {
case "session.created":
case "session.updated":
if (data.info) ctx.sessions.set(id, data.info);
break;
case "session.deleted":
ctx.deleted.add(id);
ctx.sessions.delete(id);
ctx.statuses.delete(id);
ctx.errors.delete(id);
clearRequests(ctx, (r) => r.sessionID === id);
if (ctx.selected === id) retire(ctx, ctx.route);
break;
case "session.status":
case "session.idle": {
const status = event.type === "session.idle" ? "idle" : data.status?.type;
if (status === "busy" || status === "retry") {
ctx.statuses.set(id, status);
ctx.errors.delete(id);
} else if (status === "idle") {
ctx.statuses.delete(id);
ctx.errors.delete(id);
}
break;
}
case "session.error":
if (data.error?.name !== "MessageAbortedError") ctx.errors.add(id);
break;
case "permission.asked":
case "question.asked":
if (typeof data.id === "string") ctx.blockers.set(`${event.type.split(".")[0]}:${data.id}`, data);
break;
case "permission.replied":
case "question.replied":
case "question.rejected":
ctx.blockers.delete(`${event.type.split(".")[0]}:${data.requestID}`);
break;
}
}
async function hydrate(ctx) {
if (!current(ctx) || ctx.loading) return;
ctx.loading = true;
try {
const [statuses, permissions, questions] = await Promise.all([
read(ctx, (options) => api.client.session.status(undefined, options)),
read(ctx, (options) => api.client.permission.list(undefined, options)),
read(ctx, (options) => api.client.question.list(undefined, options)),
]);
if (!statuses || typeof statuses !== "object" || Array.isArray(statuses) ||
!Object.values(statuses).every((s) => ["busy", "retry", "idle"].includes(s?.type)) ||
!Array.isArray(permissions) || !Array.isArray(questions)) {
throw new Error("incomplete session snapshot");
}
const messages = new Map();
const blockers = new Map();
let validated = true;
await Promise.all([["permission", permissions], ["question", questions]].flatMap(([kind, requests]) =>
requests.map(async (request) => {
if (ctx.deleted.has(request.sessionID)) return;
if (request.tool) {
const { messageID, callID } = request.tool;
const key = `${request.sessionID}:${messageID}`;
if (!messages.has(key)) messages.set(key, read(ctx, (options) => api.client.session.message({
sessionID: request.sessionID, messageID,
}, options)));
try {
const message = await messages.get(key);
const part = message.parts?.find((p) => p.type === "tool" && p.callID === callID);
// OpenCode can retain pending requests after abort. Status alone is
// insufficient: the same session may already be running a new turn.
if (terminalTool(part)) return;
} catch {
validated = false;
}
}
if (!ctx.deleted.has(request.sessionID)) blockers.set(`${kind}:${request.id}`, request);
})));
if (!current(ctx)) return;
ctx.statuses = new Map(Object.entries(statuses)
.filter(([id, status]) => !ctx.deleted.has(id) && (status.type === "busy" || status.type === "retry")));
ctx.blockers = blockers;
// Preserve deltas until hydration succeeds, including retry backoff:
// a later snapshot must not revive a replied or cancelled request.
for (const event of ctx.events) apply(ctx, event);
ctx.hydrated = validated;
if (!validated) ctx.retryAt = Date.now() + 500;
reconcile(ctx);
} catch {
if (current(ctx)) ctx.retryAt = Date.now() + 500;
} finally {
ctx.loading = false;
if (ctx.hydrated) ctx.events = [];
}
}
function syncSelection(reset = false) {
if (disposed) return;
const id = routeID();
if (reset || id !== context?.route) {
if (typeof id !== "string" || !id) {
retire(context, id);
return;
}
if (sessionID !== selectedSessionID) {
selectedSessionID = sessionID;
retryIndex = 0;
nextReportAt = 0;
}
if (reportPending || Date.now() < nextReportAt) {
return;
const boundary = context?.settled ? undefined : context?.selected;
context?.controller?.abort();
const ctx = {
route: id, boundary, controller: new AbortController(), sessions: new Map(), lookups: new Map(),
statuses: new Map(), blockers: new Map(), errors: new Set(), deleted: new Set(),
events: [], hydrated: false, loading: false, resolving: false,
retryAt: Infinity, selectionAt: 0, retryIndex: 0,
};
context = ctx;
reconcile(ctx);
void hydrate(ctx);
}
const ctx = context;
if (!ctx) return;
if (!ctx.settled && ctx.selected && Date.now() >= ctx.selectionAt) {
publish(ctx, true);
const delay = SELECTION_RETRY_DELAYS_MS[ctx.retryIndex++];
ctx.selectionAt = delay === undefined ? Infinity : Date.now() + delay;
}
if (Date.now() >= ctx.retryAt) {
ctx.retryAt = Infinity;
if (ctx.settled) publish(ctx);
else {
if (!ctx.hydrated) void hydrate(ctx);
reconcile(ctx);
}
}
}
const reportingSessionID = sessionID;
reportPending = true;
try {
await requestOnce(reportingSessionID);
} catch {
// Best-effort reporting retries below while the selected route remains active.
} finally {
reportPending = false;
}
if (selectedSessionID !== reportingSessionID) {
retryIndex = 0;
nextReportAt = 0;
return;
}
const retryDelay = SELECTION_RETRY_DELAYS_MS[retryIndex];
retryIndex += 1;
nextReportAt = retryDelay === undefined ? Number.POSITIVE_INFINITY : Date.now() + retryDelay;
};
await syncSelectedSession();
const routePoll = setInterval(() => void syncSelectedSession(), ROUTE_POLL_INTERVAL_MS);
api.lifecycle.onDispose(() => clearInterval(routePoll));
},
};
const subscriptions = [
"session.created", "session.updated", "session.deleted", "session.status", "session.idle", "session.error",
"permission.asked", "permission.replied", "question.asked", "question.replied", "question.rejected",
"message.part.updated",
].map((type) => api.event.on(type, (event) => {
if (type === "message.part.updated" && !terminalTool(event.properties?.part)) return;
syncSelection();
const ctx = context;
if (!ctx || ctx.settled) return;
if (ctx.loading || !ctx.hydrated) ctx.events.push(event);
apply(ctx, event);
reconcile(ctx);
}));
for (const type of ["server.connected", "server.instance.disposed", "global.disposed"]) {
subscriptions.push(api.event.on(type, () => syncSelection(true)));
}
const poll = setInterval(syncSelection, ROUTE_POLL_INTERVAL_MS);
api.lifecycle.onDispose(() => {
disposed = true;
context?.controller?.abort();
clearInterval(poll);
for (const unsubscribe of subscriptions) unsubscribe();
});
syncSelection();
}
function setup(api) {
if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_SOCKET_PATH || !process.env.HERDR_PANE_ID) return;
@@ -70,12 +70,48 @@ async function loadPlugin() {
function fakeApi() {
const sessions = new Map<string, { id: string; parentID?: string }>();
const statuses: Record<string, { type: string }> = {};
const permissions: Array<{ id: string; sessionID: string; tool?: { messageID: string; callID: string } }> = [];
const questions: typeof permissions = [];
const messages = new Map<string, { info?: { error?: { name: string } }; parts: Array<object> }>();
const listeners = new Map<string, Set<(event: object) => void>>();
const calls: string[] = [];
let current: { name: string; params?: { sessionID: string } } = { name: "home" };
let dispose: (() => void) | undefined;
activeDisposers.push(() => dispose?.());
return {
statuses, permissions, questions, messages, listeners, calls,
emit(type: string, properties: object) {
for (const receive of listeners.get(type) ?? []) receive({ type, properties });
},
api: {
client: {
session: {
async get({ sessionID }: { sessionID: string }) {
calls.push(`get:${sessionID}`);
const data = sessions.get(sessionID);
if (!data) throw new Error("session not found");
return { data };
},
async status() { calls.push("status"); return { data: { ...statuses } }; },
async message({ messageID }: { sessionID: string; messageID: string }) {
calls.push(`message:${messageID}`);
const data = messages.get(messageID);
if (!data) throw new Error("message unavailable");
return { data };
},
},
permission: { async list() { return { data: [...permissions] }; } },
question: { async list() { return { data: [...questions] }; } },
},
event: {
on(type: string, receive: (event: object) => void) {
if (!listeners.has(type)) listeners.set(type, new Set());
listeners.get(type)!.add(receive);
return () => listeners.get(type)!.delete(receive);
},
},
route: {
get current() {
return current;
@@ -101,6 +137,7 @@ function fakeApi() {
select(sessionID: string) {
current = { name: "session", params: { sessionID } };
},
home() { current = { name: "home" }; },
dispose() {
dispose?.();
},
@@ -140,10 +177,9 @@ test("retries an initial selection while Herdr detects the process", async () =>
await plugin.tui(tui.api);
await new Promise((resolve) => setTimeout(resolve, 125));
expect(requests.map((request) => requestParam(request, "agent_session_id"))).toEqual([
"session-a",
"session-a",
]);
const selections = requests.filter((request) => requestParam(request, "state") === undefined);
expect(selections.length).toBeGreaterThanOrEqual(2);
expect(selections.every((request) => requestParam(request, "agent_session_id") === "session-a")).toBe(true);
});
test("does not report root sessions not selected by this TUI", async () => {
@@ -169,13 +205,13 @@ test("does not replace the root session with a selected child session", async ()
tui.addSession({ id: "child-session", parentID: "root-session" });
tui.select("root-session");
await plugin.tui(tui.api);
expect(requests).toHaveLength(1);
await flushReports();
tui.select("child-session");
await new Promise((resolve) => setTimeout(resolve, 125));
expect(requests).toHaveLength(1);
expect(requestParam(requests[0], "agent_session_id")).toBe("root-session");
expect(requests.length).toBeGreaterThan(0);
expect(requests.every((r) => requestParam(r, "agent_session_id") === "root-session")).toBe(true);
});
test("stops route polling when the TUI plugin is disposed", async () => {
@@ -245,6 +281,274 @@ const flushReports = () => new Promise((resolve) => setTimeout(resolve, 10));
const states = () => requests.filter((r) => requestParam(r, "state") !== undefined)
.map((r) => requestParam(r, "state"));
function familyApi() {
const tui = fakeApi();
tui.addSession({ id: "root" });
tui.addSession({ id: "child", parentID: "root" });
tui.addSession({ id: "sibling", parentID: "root" });
tui.addSession({ id: "grandchild", parentID: "child" });
tui.addSession({ id: "other" });
tui.select("root");
return tui;
}
test("V1 hydrates active descendants and keeps working until the whole family settles", async () => {
const tui = familyApi();
tui.statuses.child = { type: "busy" };
tui.statuses.grandchild = { type: "retry" };
tui.statuses.other = { type: "busy" };
await (await loadPlugin()).tui(tui.api);
await flushReports();
expect(states().at(-1)).toBe("working");
tui.emit("session.status", { sessionID: "child", status: { type: "idle" } });
await flushReports();
expect(states().at(-1)).toBe("working");
tui.emit("session.status", { sessionID: "grandchild", status: { type: "idle" } });
await flushReports();
expect(states().at(-1)).toBe("idle");
expect(requests.every((r) => requestParam(r, "agent_session_id") === "root")).toBe(true);
});
test("V1 retains sibling blockers and cancellation does not finish an active parent", async () => {
const tui = familyApi();
tui.statuses.root = { type: "busy" };
await (await loadPlugin()).tui(tui.api);
await flushReports();
tui.emit("permission.asked", { id: "p", sessionID: "child" });
tui.emit("question.asked", { id: "q", sessionID: "sibling", tool: { messageID: "m", callID: "call" } });
await flushReports();
expect(states().at(-1)).toBe("blocked");
tui.emit("permission.replied", { requestID: "p", sessionID: "child" });
await flushReports();
expect(states().at(-1)).toBe("blocked");
tui.emit("session.idle", { sessionID: "sibling" });
await flushReports();
expect(states().at(-1)).toBe("blocked");
tui.emit("message.part.updated", { part: {
type: "tool", sessionID: "sibling", messageID: "m", callID: "call", state: { status: "error" },
} });
await flushReports();
expect(states().at(-1)).toBe("working");
tui.emit("session.idle", { sessionID: "root" });
await flushReports();
expect(states().at(-1)).toBe("idle");
});
test("V1 hydration rejects aborted tool requests even if their session is busy again", async () => {
for (const kind of ["permissions", "questions"] as const) {
const tui = familyApi();
tui.statuses.child = { type: "busy" };
tui[kind].push({ id: "stale", sessionID: "child", tool: { messageID: "m", callID: "call" } });
tui.messages.set("m", { info: { error: { name: "MessageAbortedError" } }, parts: [
{ type: "tool", callID: "call", state: { status: "error" } },
] });
await (await loadPlugin()).tui(tui.api);
await flushReports();
expect(states().at(-1)).toBe("working");
tui.dispose();
}
});
test("V1 validates pending tools instead of assuming an idle owner has no requests", async () => {
const tui = familyApi();
tui.permissions.push({ id: "pending", sessionID: "child", tool: { messageID: "m", callID: "call" } });
tui.messages.set("m", { parts: [{ type: "tool", callID: "call", state: { status: "running" } }] });
await (await loadPlugin()).tui(tui.api);
await flushReports();
expect(states().at(-1)).toBe("blocked");
tui.emit("message.part.updated", { part: {
type: "tool", sessionID: "child", messageID: "m", callID: "call", state: { status: "error" },
} });
await flushReports();
expect(states().at(-1)).toBe("idle");
});
test("V1 replays completion and replies over a late initial snapshot", async () => {
const tui = familyApi();
let resolveStatus!: (result: { data: Record<string, { type: string }> }) => void;
tui.api.client.session.status = () => new Promise((resolve) => { resolveStatus = resolve; });
tui.permissions.push({ id: "p", sessionID: "child" });
await (await loadPlugin()).tui(tui.api);
await flushReports();
expect(states()).not.toContain("idle");
tui.emit("session.idle", { sessionID: "child" });
tui.emit("permission.replied", { sessionID: "child", requestID: "p" });
resolveStatus({ data: { child: { type: "busy" } } });
await flushReports();
expect(states().at(-1)).toBe("idle");
});
test("V1 retains replies received between failed hydration and its retry", async () => {
const tui = familyApi();
tui.permissions.push({ id: "p", sessionID: "child" });
const status = tui.api.client.session.status;
tui.api.client.session.status = async () => {
tui.api.client.session.status = status;
throw new Error("temporarily unavailable");
};
await (await loadPlugin()).tui(tui.api);
await flushReports();
tui.emit("permission.replied", { sessionID: "child", requestID: "p" });
await new Promise((resolve) => setTimeout(resolve, 650));
expect(states().at(-1)).toBe("idle");
expect(states()).not.toContain("blocked");
});
test("V1 deleted sessions cannot return through a late hydration snapshot", async () => {
const tui = familyApi();
let resolveStatus!: (result: { data: Record<string, { type: string }> }) => void;
tui.api.client.session.status = () => new Promise((resolve) => { resolveStatus = resolve; });
tui.permissions.push({ id: "p", sessionID: "child" });
await (await loadPlugin()).tui(tui.api);
await flushReports();
tui.emit("session.deleted", { info: { id: "child", parentID: "root" } });
resolveStatus({ data: { child: { type: "busy" } } });
await flushReports();
expect(states().at(-1)).toBe("idle");
});
test("V1 ignores stale hydration across A/B/A route changes and disposal", async () => {
const tui = familyApi();
let resolveOld!: (result: { data: Record<string, { type: string }> }) => void;
const status = tui.api.client.session.status;
tui.api.client.session.status = () => {
tui.api.client.session.status = status;
return new Promise((resolve) => { resolveOld = resolve; });
};
await (await loadPlugin()).tui(tui.api);
tui.select("other");
tui.emit("session.updated", { info: { id: "other" } });
await flushReports();
tui.select("root");
tui.emit("session.updated", { info: { id: "root" } });
await flushReports();
requests.length = 0;
resolveOld({ data: { root: { type: "busy" } } });
await flushReports();
expect(states()).not.toContain("working");
tui.dispose();
expect([...tui.listeners.values()].every((set) => set.size === 0)).toBe(true);
});
test("V1 a directly attached child owns its descendants, not its parent or siblings", async () => {
const tui = familyApi();
tui.select("child");
tui.statuses.root = { type: "busy" };
tui.statuses.sibling = { type: "busy" };
await (await loadPlugin()).tui(tui.api);
await flushReports();
expect(states().at(-1)).toBe("idle");
tui.emit("session.status", { sessionID: "grandchild", status: { type: "busy" } });
await flushReports();
expect(states().at(-1)).toBe("working");
tui.select("grandchild");
tui.emit("session.updated", { info: { id: "grandchild", parentID: "child" } });
await flushReports();
expect(requests.every((r) => requestParam(r, "agent_session_id") === "child")).toBe(true);
});
test("V1 a terminal tool event during hydration cannot revive a cancelled blocker", async () => {
const tui = familyApi();
tui.questions.push({ id: "q", sessionID: "child", tool: { messageID: "m", callID: "call" } });
let resolveMessage!: (result: { data: { parts: Array<object> } }) => void;
tui.api.client.session.message = () => new Promise((resolve) => { resolveMessage = resolve; });
await (await loadPlugin()).tui(tui.api);
await flushReports();
tui.emit("message.part.updated", { part: {
type: "tool", sessionID: "child", messageID: "m", callID: "call", state: { status: "error" },
} });
resolveMessage({ data: { parts: [{ type: "tool", callID: "call", state: { status: "running" } }] } });
await flushReports();
expect(states().at(-1)).toBe("idle");
});
test("V1 unknown tool evidence remains blocked and retries failed message reads", async () => {
const tui = familyApi();
tui.permissions.push({ id: "p", sessionID: "child", tool: { messageID: "m", callID: "call" } });
await (await loadPlugin()).tui(tui.api);
await flushReports();
expect(states().at(-1)).toBe("blocked");
tui.messages.set("m", { parts: [{ type: "tool", callID: "call", state: { status: "error" } }] });
await new Promise((resolve) => setTimeout(resolve, 650));
expect(states().at(-1)).toBe("idle");
});
test("V1 request validation matches the exact tool and deduplicates message reads", async () => {
const tui = familyApi();
tui.permissions.push({ id: "p", sessionID: "child", tool: { messageID: "m", callID: "running" } });
tui.questions.push({ id: "q", sessionID: "child", tool: { messageID: "m", callID: "finished" } });
tui.messages.set("m", { parts: [
{ type: "tool", callID: "running", state: { status: "running" } },
{ type: "tool", callID: "finished", state: { status: "completed" } },
] });
await (await loadPlugin()).tui(tui.api);
await flushReports();
expect(states().at(-1)).toBe("blocked");
expect(tui.calls.filter((call) => call === "message:m")).toHaveLength(1);
tui.emit("permission.replied", { sessionID: "child", requestID: "p" });
await flushReports();
expect(states().at(-1)).toBe("idle");
});
test("V1 reselecting an aborted request does not resurrect it or poll completed tools", async () => {
const tui = familyApi();
tui.permissions.push({ id: "p", sessionID: "child", tool: { messageID: "m", callID: "call" } });
tui.messages.set("m", { parts: [{ type: "tool", callID: "call", state: { status: "error" } }] });
await (await loadPlugin()).tui(tui.api);
await flushReports();
tui.select("other");
tui.emit("session.updated", { info: { id: "other" } });
await flushReports();
tui.select("root");
tui.emit("session.updated", { info: { id: "root" } });
await flushReports();
expect(states()).not.toContain("blocked");
const reads = tui.calls.length;
await new Promise((resolve) => setTimeout(resolve, 650));
expect(tui.calls).toHaveLength(reads);
});
test("V1 home and selected-session deletion settle authority and retry a dropped idle report", async () => {
for (const action of ["home", "delete"]) {
const tui = familyApi();
tui.statuses.root = { type: "busy" };
await (await loadPlugin()).tui(tui.api);
await flushReports();
expect(states().at(-1)).toBe("working");
failConnections = true;
if (action === "home") {
tui.home();
tui.emit("session.updated", { info: { id: "root" } });
} else tui.emit("session.deleted", { info: { id: "root" } });
await flushReports();
failConnections = false;
await new Promise((resolve) => setTimeout(resolve, 650));
expect(states().at(-1)).toBe("idle");
tui.dispose();
}
});
test("V1 a delayed home settlement cannot overwrite the next selected session", async () => {
const tui = familyApi();
tui.statuses.root = { type: "busy" };
await (await loadPlugin()).tui(tui.api);
await flushReports();
requests.length = 0;
holdConnections = true;
tui.home();
tui.emit("session.updated", { info: { id: "root" } });
await flushReports();
expect(connections.length).toBeGreaterThan(0);
tui.select("other");
tui.emit("session.updated", { info: { id: "other" } });
holdConnections = false;
for (const connect of connections.splice(0)) connect();
await flushReports();
expect(requests.length).toBeGreaterThan(0);
expect(requests.every((r) => requestParam(r, "agent_session_id") === "other")).toBe(true);
expect(states().at(-1)).toBe("idle");
});
test("V2 ignores events without data", async () => {
const plugin = await loadPlugin();
const tui = v2Api();
+1 -1
View File
@@ -1,5 +1,5 @@
// installed by herdr
// HERDR_INTEGRATION_ID=opencode-tui-v2
// HERDR_INTEGRATION_VERSION=12
// HERDR_INTEGRATION_VERSION=13
// V2 resolves the directory's tui entrypoint; V1 uses the original file.
export { default } from "../herdr-tui-session.js";
+1 -1
View File
@@ -184,7 +184,7 @@ const OPENCODE_TUI_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-tui-
const OPENCODE_V2_TUI_PLUGIN_DIR: &str = "herdr-opencode";
const OPENCODE_V2_TUI_PLUGIN_SPEC: &str = "./herdr-opencode";
const OPENCODE_V2_TUI_PLUGIN_ASSET: &str = include_str!("assets/opencode/tui.js");
const OPENCODE_INTEGRATION_VERSION: u32 = 12;
const OPENCODE_INTEGRATION_VERSION: u32 = 13;
const KILO_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js";
const KILO_PLUGIN_ASSET: &str = include_str!("assets/kilo/herdr-agent-state.js");
const KILO_INTEGRATION_VERSION: u32 = 4;