mirror of
https://github.com/pnx/dotfiles
synced 2026-10-14 20:26:48 +02:00
add herdr
This commit is contained in:
parent
38989f191a
commit
b738eefbca
9 changed files with 1027 additions and 0 deletions
4
herdr/.gitignore
vendored
Normal file
4
herdr/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
*.sock
|
||||||
|
session.json
|
||||||
|
session-snapshots/
|
||||||
|
plugins/
|
||||||
0
herdr/.plugins.lock
Normal file
0
herdr/.plugins.lock
Normal file
62
herdr/config.toml
Normal file
62
herdr/config.toml
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
onboarding = false
|
||||||
|
|
||||||
|
[keys]
|
||||||
|
prefix = "ctrl+a"
|
||||||
|
detach = "prefix+d"
|
||||||
|
|
||||||
|
[[keys.command]]
|
||||||
|
key = "prefix+s"
|
||||||
|
type = "plugin_action"
|
||||||
|
command = "simple-switcher.switch-workspace"
|
||||||
|
description = "switch workspace"
|
||||||
|
|
||||||
|
[[keys.command]]
|
||||||
|
key = "prefix+e"
|
||||||
|
type = "plugin_action"
|
||||||
|
command = "simple-switcher.open-project"
|
||||||
|
description = "open project"
|
||||||
|
|
||||||
|
[[keys.command]]
|
||||||
|
key = "prefix+a"
|
||||||
|
type = "plugin_action"
|
||||||
|
command = "simple-switcher.switch-agent"
|
||||||
|
description = "switch agent"
|
||||||
|
|
||||||
|
[theme]
|
||||||
|
name = "terminal"
|
||||||
|
auto_switch = false
|
||||||
|
|
||||||
|
[theme.custom]
|
||||||
|
accent="blue"
|
||||||
|
sidebar_bg="#0C0D14"
|
||||||
|
panel_bg="#0D0F17"
|
||||||
|
surface0="#313244"
|
||||||
|
surface_dim="#1A1E2E"
|
||||||
|
selection_bg="#323958"
|
||||||
|
active_row_bg="#1A1E2E"
|
||||||
|
overlay0="#ffffff"
|
||||||
|
overlay1="white"
|
||||||
|
text="#cdd6f4"
|
||||||
|
subtext0="#bac2de"
|
||||||
|
|
||||||
|
[ui]
|
||||||
|
agent_panel_sort = "spaces"
|
||||||
|
|
||||||
|
status_indicators = "symbols"
|
||||||
|
show_agent_labels_on_pane_borders = true
|
||||||
|
|
||||||
|
window_title= "herdr - {workspace}"
|
||||||
|
pane_gaps = true
|
||||||
|
pane_outer_borders = true
|
||||||
|
tab_bar_right_separator = " · "
|
||||||
|
tab_bar_right = [
|
||||||
|
{ type = "command", command = "printf 'CPU %s%%\n' \"$(awk '/^cpu / {print int(($2+$4)*100/($2+$4+$5))}' /proc/stat)\"", interval_seconds = 2, timeout_seconds = 1 },
|
||||||
|
{ type = "command", command = "free | awk '/^Mem:/ {printf \"MEM %.0f%%\\n\", $3/$2 * 100}'", interval_seconds = 2, timeout_seconds = 1 },
|
||||||
|
{ type = "hostname" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[ui.sound]
|
||||||
|
enabled = false
|
||||||
|
|
||||||
|
[ui.toast]
|
||||||
|
delivery = "herdr"
|
||||||
120
herdr/plugins.json
Normal file
120
herdr/plugins.json
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"plugin_id": "simple-switcher",
|
||||||
|
"name": "Simple Switcher",
|
||||||
|
"version": "0.4.0",
|
||||||
|
"min_herdr_version": "0.7.0",
|
||||||
|
"description": "Fuzzy switch between workspaces, tabs, agents, and projects with fzf",
|
||||||
|
"manifest_path": "/home/pnx/.config/herdr/plugins/github/simple-switcher-387ae32386ae/herdr-plugin.toml",
|
||||||
|
"plugin_root": "/home/pnx/.config/herdr/plugins/github/simple-switcher-387ae32386ae",
|
||||||
|
"enabled": true,
|
||||||
|
"platforms": [
|
||||||
|
"linux",
|
||||||
|
"macos"
|
||||||
|
],
|
||||||
|
"startup": [
|
||||||
|
{
|
||||||
|
"command": [
|
||||||
|
"bash",
|
||||||
|
"scripts/startup-check.sh"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "open-project",
|
||||||
|
"title": "Open Project",
|
||||||
|
"contexts": [
|
||||||
|
"workspace"
|
||||||
|
],
|
||||||
|
"command": [
|
||||||
|
"bash",
|
||||||
|
"scripts/open-pane.sh",
|
||||||
|
"open-project"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "switch-agent",
|
||||||
|
"title": "Switch Agent",
|
||||||
|
"contexts": [
|
||||||
|
"workspace"
|
||||||
|
],
|
||||||
|
"command": [
|
||||||
|
"bash",
|
||||||
|
"scripts/open-pane.sh",
|
||||||
|
"switch-agent"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "switch-tab",
|
||||||
|
"title": "Switch Tab",
|
||||||
|
"contexts": [
|
||||||
|
"workspace"
|
||||||
|
],
|
||||||
|
"command": [
|
||||||
|
"bash",
|
||||||
|
"scripts/open-pane.sh",
|
||||||
|
"switch-tab"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "switch-workspace",
|
||||||
|
"title": "Switch Workspace",
|
||||||
|
"contexts": [
|
||||||
|
"workspace"
|
||||||
|
],
|
||||||
|
"command": [
|
||||||
|
"bash",
|
||||||
|
"scripts/open-pane.sh",
|
||||||
|
"switch-workspace"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"panes": [
|
||||||
|
{
|
||||||
|
"id": "open-project",
|
||||||
|
"title": "Open Project",
|
||||||
|
"placement": "popup",
|
||||||
|
"command": [
|
||||||
|
"bash",
|
||||||
|
"scripts/open-project.sh"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "switch-agent",
|
||||||
|
"title": "Switch Agent",
|
||||||
|
"placement": "popup",
|
||||||
|
"command": [
|
||||||
|
"bash",
|
||||||
|
"scripts/switch-agent.sh"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "switch-tab",
|
||||||
|
"title": "Switch Tab",
|
||||||
|
"placement": "popup",
|
||||||
|
"command": [
|
||||||
|
"bash",
|
||||||
|
"scripts/switch-tab.sh"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "switch-workspace",
|
||||||
|
"title": "Switch Workspace",
|
||||||
|
"placement": "popup",
|
||||||
|
"command": [
|
||||||
|
"bash",
|
||||||
|
"scripts/switch-workspace.sh"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": {
|
||||||
|
"kind": "github",
|
||||||
|
"owner": "pnx",
|
||||||
|
"repo": "herdr-simple-switcher",
|
||||||
|
"resolved_commit": "01458dc2f0be9672d5a39b69293d5002f22e0f01",
|
||||||
|
"managed_path": "/home/pnx/.config/herdr/plugins/github/simple-switcher-387ae32386ae",
|
||||||
|
"installed_unix_ms": 1790804757595
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
5
herdr/release-notes.json
Normal file
5
herdr/release-notes.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"version": "0.9.3",
|
||||||
|
"body": "This is a hotfix release for v0.9.2. See the v0.9.2 notes for the full feature release: https://github.com/herdrdev/herdr/releases/tag/v0.9.2\n\n### Fixed\n- Terminal shortcuts that send Escape followed by a key work again in panes. On macOS, Option+Left/Right and Option+Backspace from Ghostty's defaults or iTerm2's Natural Text Editing preset move and delete by word again, instead of typing `b` and `f` or deleting one character. Escape-based Shift+Enter bindings insert a newline in Claude Code instead of submitting. Clicking a pane still doesn't send a stray Escape. (#4751)\n- Alt+[ followed quickly by another key no longer merges into a different key. (#4751)",
|
||||||
|
"show_on_startup": true
|
||||||
|
}
|
||||||
5
opencode/herdr-opencode/tui.js
Normal file
5
opencode/herdr-opencode/tui.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
// installed by herdr
|
||||||
|
// HERDR_INTEGRATION_ID=opencode-tui-v2
|
||||||
|
// HERDR_INTEGRATION_VERSION=13
|
||||||
|
// V2 resolves the directory's tui entrypoint; V1 uses the original file.
|
||||||
|
export { default } from "../herdr-tui-session.js";
|
||||||
604
opencode/herdr-tui-session.js
Normal file
604
opencode/herdr-tui-session.js
Normal file
|
|
@ -0,0 +1,604 @@
|
||||||
|
// installed by herdr
|
||||||
|
// managed by herdr; reinstalling or updating the integration overwrites this file.
|
||||||
|
// HERDR_INTEGRATION_ID=opencode-tui
|
||||||
|
// HERDR_INTEGRATION_VERSION=13
|
||||||
|
|
||||||
|
import net from "node:net";
|
||||||
|
|
||||||
|
const SOURCE = "herdr:opencode";
|
||||||
|
const AGENT = "opencode";
|
||||||
|
const ROUTE_POLL_INTERVAL_MS = 100;
|
||||||
|
const SELECTION_RETRY_DELAYS_MS = [100, 400, 1_000];
|
||||||
|
|
||||||
|
function requestOnce(sessionID, state, seq, isCurrent = () => true) {
|
||||||
|
const paneId = process.env.HERDR_PANE_ID;
|
||||||
|
const socketPath = process.env.HERDR_SOCKET_PATH;
|
||||||
|
if (!paneId || !socketPath) {
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const socketEndpoint =
|
||||||
|
process.platform === "win32" ? `\\\\.\\pipe\\${socketPath}` : socketPath;
|
||||||
|
const request = {
|
||||||
|
id: `${SOURCE}:tui:${Date.now()}:${Math.floor(Math.random() * 1_000_000)
|
||||||
|
.toString()
|
||||||
|
.padStart(6, "0")}`,
|
||||||
|
method: state === undefined ? "pane.report_agent_session" : "pane.report_agent",
|
||||||
|
params: {
|
||||||
|
pane_id: paneId,
|
||||||
|
source: SOURCE,
|
||||||
|
agent: AGENT,
|
||||||
|
agent_session_id: sessionID,
|
||||||
|
...(state === undefined ? { session_start_source: "select" } : { state, seq }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
let timer;
|
||||||
|
const settle = (delivered) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
client.destroy();
|
||||||
|
resolve(delivered);
|
||||||
|
};
|
||||||
|
const client = net.createConnection(socketEndpoint, () => {
|
||||||
|
if (!isCurrent()) {
|
||||||
|
settle(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
client.write(`${JSON.stringify(request)}\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A plain timer, not socket.setTimeout, so a connection that never finishes
|
||||||
|
// connecting still settles and cannot block later reports behind the queue.
|
||||||
|
timer = setTimeout(() => settle(false), 500);
|
||||||
|
timer.unref?.();
|
||||||
|
client.on("data", () => settle(true));
|
||||||
|
client.on("error", () => settle(false));
|
||||||
|
client.on("end", () => settle(false));
|
||||||
|
client.on("close", () => settle(false));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "herdr.opencode.session-selection",
|
||||||
|
// 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 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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 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;
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
let selected;
|
||||||
|
let generation = 0;
|
||||||
|
let sequence = Date.now() * 1000;
|
||||||
|
let chain = Promise.resolve();
|
||||||
|
let retryIndex = 0;
|
||||||
|
let nextSelectionAt = 0;
|
||||||
|
let state = "idle";
|
||||||
|
let retryTimer;
|
||||||
|
const sessions = new Map();
|
||||||
|
let blockers = new Map();
|
||||||
|
// Event callbacks may precede cache updates. Retain each delta until the
|
||||||
|
// cache reflects it, so late hydration cannot undo a reply or lose an ask.
|
||||||
|
const blockerChanges = new Map();
|
||||||
|
|
||||||
|
function root(id) {
|
||||||
|
const seen = new Set();
|
||||||
|
while (typeof id === "string" && !seen.has(id)) {
|
||||||
|
seen.add(id);
|
||||||
|
const session = api.data.session.get(id) ?? sessions.get(id);
|
||||||
|
if (!session) return;
|
||||||
|
if (!session.parentID) return id;
|
||||||
|
id = session.parentID;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function current() {
|
||||||
|
const route = api.ui.router.current();
|
||||||
|
return route.type === "session" ? root(route.sessionID) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection and lifecycle use one queue. Recheck attribution at dispatch,
|
||||||
|
// not just when receiving the event, and reject A -> B -> A stale work too.
|
||||||
|
function enqueue(value) {
|
||||||
|
const sessionID = selected;
|
||||||
|
const revision = generation;
|
||||||
|
const isCurrent = () => !disposed && revision === generation && !!sessionID && current() === sessionID;
|
||||||
|
chain = chain.then(async () => {
|
||||||
|
if (!isCurrent()) return;
|
||||||
|
const delivered = await requestOnce(sessionID, value, value === undefined ? undefined : ++sequence, isCurrent);
|
||||||
|
if (!delivered) scheduleStateRetry();
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dropped report must not strand the pane on a stale state once the
|
||||||
|
// selection retry schedule has run out: resend the latest state until the
|
||||||
|
// socket accepts it or the selection is no longer current.
|
||||||
|
function scheduleStateRetry() {
|
||||||
|
if (disposed || retryTimer) return;
|
||||||
|
retryTimer = setTimeout(() => {
|
||||||
|
retryTimer = undefined;
|
||||||
|
publish();
|
||||||
|
}, 500);
|
||||||
|
retryTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function publish() {
|
||||||
|
enqueue(blockers.size ? "blocked" : state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeBlocker(id, kind, requestID, present) {
|
||||||
|
if (typeof requestID !== "string") return;
|
||||||
|
const key = `${kind}:${requestID}`;
|
||||||
|
blockerChanges.set(key, { id, kind, present });
|
||||||
|
if (present) blockers.set(key, id);
|
||||||
|
else blockers.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconcileBlockers() {
|
||||||
|
const next = new Map();
|
||||||
|
const hydrated = new Set();
|
||||||
|
const members = new Set([selected, ...api.data.session.family(selected), ...blockers.values()]);
|
||||||
|
for (const member of members) {
|
||||||
|
if (root(member) !== selected) continue;
|
||||||
|
for (const kind of ["permission", "form"]) {
|
||||||
|
const items = api.data.session[kind].list(member);
|
||||||
|
if (items === undefined) {
|
||||||
|
for (const [key, owner] of blockers) {
|
||||||
|
if (owner === member && key.startsWith(`${kind}:`)) next.set(key, owner);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
hydrated.add(`${kind}:${member}`);
|
||||||
|
for (const item of items) next.set(`${kind}:${item.id}`, member);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [key, change] of blockerChanges) {
|
||||||
|
if (hydrated.has(`${change.kind}:${change.id}`) && next.has(key) === change.present) {
|
||||||
|
blockerChanges.delete(key);
|
||||||
|
} else if (change.present) {
|
||||||
|
next.set(key, change.id);
|
||||||
|
} else {
|
||||||
|
next.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const changed = (blockers.size > 0) !== (next.size > 0);
|
||||||
|
blockers = next;
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSelection() {
|
||||||
|
if (disposed) return;
|
||||||
|
const id = current();
|
||||||
|
if (id !== selected) {
|
||||||
|
selected = id;
|
||||||
|
generation += 1;
|
||||||
|
retryIndex = 0;
|
||||||
|
nextSelectionAt = 0;
|
||||||
|
blockers.clear();
|
||||||
|
blockerChanges.clear();
|
||||||
|
if (id) {
|
||||||
|
state = api.data.session.status(id) === "running" ? "working" : "idle";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!id) return;
|
||||||
|
const blockersChanged = reconcileBlockers();
|
||||||
|
if (Date.now() < nextSelectionAt) {
|
||||||
|
if (blockersChanged) publish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
enqueue(undefined);
|
||||||
|
publish();
|
||||||
|
const delay = SELECTION_RETRY_DELAYS_MS[retryIndex++];
|
||||||
|
nextSelectionAt = delay === undefined ? Number.POSITIVE_INFINITY : Date.now() + delay;
|
||||||
|
}
|
||||||
|
|
||||||
|
function receive({ details: event }) {
|
||||||
|
if (disposed) return;
|
||||||
|
const data = event.data;
|
||||||
|
if (data == null) return;
|
||||||
|
if (event.type === "session.created") {
|
||||||
|
sessions.set(data.sessionID, { id: data.sessionID, parentID: data.parentID });
|
||||||
|
}
|
||||||
|
if (event.type === "session.deleted") {
|
||||||
|
const affected = data.sessionID === selected || [...blockers.values()].includes(data.sessionID);
|
||||||
|
sessions.delete(data.sessionID);
|
||||||
|
// Deletion is delivered after the cache can remove the session. Use
|
||||||
|
// stored ownership rather than looking up the deleted child's ancestry.
|
||||||
|
for (const [key, owner] of blockers) if (owner === data.sessionID) blockers.delete(key);
|
||||||
|
for (const [key, change] of blockerChanges) {
|
||||||
|
if (change.id === data.sessionID) blockerChanges.delete(key);
|
||||||
|
}
|
||||||
|
syncSelection();
|
||||||
|
if (selected && affected) publish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
syncSelection();
|
||||||
|
const id = event.type === "form.created" ? data.form.sessionID : data.sessionID;
|
||||||
|
if (!selected || root(id) !== selected) return;
|
||||||
|
switch (event.type) {
|
||||||
|
case "permission.asked":
|
||||||
|
changeBlocker(id, "permission", data.id, true);
|
||||||
|
break;
|
||||||
|
case "permission.replied":
|
||||||
|
changeBlocker(id, "permission", data.requestID, false);
|
||||||
|
break;
|
||||||
|
case "form.created":
|
||||||
|
changeBlocker(id, "form", data.form.id, true);
|
||||||
|
break;
|
||||||
|
case "form.replied":
|
||||||
|
case "form.cancelled":
|
||||||
|
changeBlocker(id, "form", data.id, false);
|
||||||
|
break;
|
||||||
|
case "session.execution.started":
|
||||||
|
if (id !== selected) return;
|
||||||
|
state = "working";
|
||||||
|
break;
|
||||||
|
case "session.execution.succeeded":
|
||||||
|
case "session.execution.interrupted":
|
||||||
|
if (id !== selected) return;
|
||||||
|
state = "idle";
|
||||||
|
break;
|
||||||
|
case "session.execution.failed":
|
||||||
|
if (id !== selected) return;
|
||||||
|
state = "blocked";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
publish();
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribe = api.data.listen(receive);
|
||||||
|
syncSelection();
|
||||||
|
const poll = setInterval(syncSelection, ROUTE_POLL_INTERVAL_MS);
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
generation += 1;
|
||||||
|
clearTimeout(retryTimer);
|
||||||
|
clearInterval(poll);
|
||||||
|
unsubscribe();
|
||||||
|
sessions.clear();
|
||||||
|
blockers.clear();
|
||||||
|
blockerChanges.clear();
|
||||||
|
};
|
||||||
|
}
|
||||||
224
opencode/plugins/herdr-agent-state.js
Normal file
224
opencode/plugins/herdr-agent-state.js
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
// installed by herdr
|
||||||
|
// 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=13
|
||||||
|
|
||||||
|
import net from "node:net";
|
||||||
|
|
||||||
|
const SOURCE = "herdr:opencode";
|
||||||
|
const AGENT = "opencode";
|
||||||
|
let reportSeq = Date.now() * 1000;
|
||||||
|
let requestChain = Promise.resolve();
|
||||||
|
let reportedRootSessionID;
|
||||||
|
|
||||||
|
// Track child sessions so their events cannot replace the pane's root session.
|
||||||
|
// User prompts carry the root id to preserve its identity and cross-talk guard.
|
||||||
|
const childSessions = new Map();
|
||||||
|
const CHILD_EVENT_STATES = new Map([
|
||||||
|
["permission.asked", "blocked"],
|
||||||
|
["question.asked", "blocked"],
|
||||||
|
["permission.replied", "working"],
|
||||||
|
["question.replied", "working"],
|
||||||
|
["question.rejected", "working"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
function nextReportSeq() {
|
||||||
|
reportSeq += 1;
|
||||||
|
return reportSeq;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionIDFromProperties(properties) {
|
||||||
|
return typeof properties?.sessionID === "string" && properties.sessionID
|
||||||
|
? properties.sessionID
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SESSION_STATE_BY_STATUS = new Map([
|
||||||
|
["idle", "idle"],
|
||||||
|
["active", "working"],
|
||||||
|
["busy", "working"],
|
||||||
|
["pending", "working"],
|
||||||
|
["retry", "working"],
|
||||||
|
["running", "working"],
|
||||||
|
["streaming", "working"],
|
||||||
|
["working", "working"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
function stateFromSessionStatus(status) {
|
||||||
|
const kind = typeof status === "string" ? status : status?.type;
|
||||||
|
return typeof kind === "string"
|
||||||
|
? SESSION_STATE_BY_STATUS.get(kind.toLowerCase())
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(method, params) {
|
||||||
|
const pending = requestChain.then(() => requestOnce(method, params));
|
||||||
|
requestChain = pending.catch(() => {});
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestOnce(method, params) {
|
||||||
|
const paneId = process.env.HERDR_PANE_ID;
|
||||||
|
const socketPath = process.env.HERDR_SOCKET_PATH;
|
||||||
|
|
||||||
|
if (!paneId || !socketPath) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
const socketEndpoint =
|
||||||
|
process.platform === "win32" ? `\\\\.\\pipe\\${socketPath}` : socketPath;
|
||||||
|
|
||||||
|
const requestId = `${SOURCE}:${Date.now()}:${Math.floor(Math.random() * 1_000_000)
|
||||||
|
.toString()
|
||||||
|
.padStart(6, "0")}`;
|
||||||
|
const request = {
|
||||||
|
id: requestId,
|
||||||
|
method,
|
||||||
|
params: {
|
||||||
|
pane_id: paneId,
|
||||||
|
source: SOURCE,
|
||||||
|
agent: AGENT,
|
||||||
|
seq: nextReportSeq(),
|
||||||
|
...params,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const client = net.createConnection(socketEndpoint, () => {
|
||||||
|
client.write(`${JSON.stringify(request)}\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
client.destroy();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
client.setTimeout(500, finish);
|
||||||
|
client.on("data", finish);
|
||||||
|
client.on("error", finish);
|
||||||
|
client.on("end", finish);
|
||||||
|
client.on("close", resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportSession(sessionID) {
|
||||||
|
if (!sessionID) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return request("pane.report_agent_session", { agent_session_id: sessionID });
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportState(state, sessionID) {
|
||||||
|
const params = { state };
|
||||||
|
if (sessionID) {
|
||||||
|
reportedRootSessionID = sessionID;
|
||||||
|
params.agent_session_id = 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
|
||||||
|
) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"chat.message": async ({ sessionID }) => {
|
||||||
|
if (sessionID && childSessions.has(sessionID)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await reportState("working", sessionID);
|
||||||
|
},
|
||||||
|
event: async ({ event }) => {
|
||||||
|
const type = event?.type;
|
||||||
|
const properties = event?.properties ?? {};
|
||||||
|
const sessionID = sessionIDFromProperties(properties);
|
||||||
|
|
||||||
|
const info = properties.info;
|
||||||
|
if (info?.id && info.parentID) {
|
||||||
|
childSessions.set(info.id, info.parentID);
|
||||||
|
}
|
||||||
|
if (sessionID && childSessions.has(sessionID)) {
|
||||||
|
const state = CHILD_EVENT_STATES.get(type);
|
||||||
|
if (state) {
|
||||||
|
let rootSessionID = sessionID;
|
||||||
|
while (childSessions.has(rootSessionID)) {
|
||||||
|
rootSessionID = childSessions.get(rootSessionID);
|
||||||
|
}
|
||||||
|
await reportState(state, rootSessionID);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "session.created":
|
||||||
|
// Creation is server-global, so an attached client may own it. The
|
||||||
|
// TUI plugin separately reports the root selected in this pane.
|
||||||
|
reportedRootSessionID = sessionID;
|
||||||
|
break;
|
||||||
|
case "session.updated":
|
||||||
|
if (sessionID && sessionID !== reportedRootSessionID) {
|
||||||
|
await reportSession(sessionID);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "session.status": {
|
||||||
|
const state = stateFromSessionStatus(properties.status);
|
||||||
|
if (state) {
|
||||||
|
await reportState(state, sessionID);
|
||||||
|
} else {
|
||||||
|
await reportSession(sessionID);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "tool.execute.before":
|
||||||
|
case "tool.execute.after":
|
||||||
|
case "permission.replied":
|
||||||
|
case "question.replied":
|
||||||
|
case "question.rejected":
|
||||||
|
case "session.compacted":
|
||||||
|
await reportState("working", sessionID);
|
||||||
|
break;
|
||||||
|
case "permission.asked":
|
||||||
|
case "question.asked":
|
||||||
|
case "session.error":
|
||||||
|
await reportState("blocked", sessionID);
|
||||||
|
break;
|
||||||
|
case "session.idle":
|
||||||
|
await reportState("idle", sessionID);
|
||||||
|
break;
|
||||||
|
case "session.deleted":
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
setup() {},
|
||||||
|
};
|
||||||
3
opencode/tui.jsonc
Normal file
3
opencode/tui.jsonc
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"plugin": ["./herdr-tui-session.js"]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue