diff --git a/herdr/.gitignore b/herdr/.gitignore new file mode 100644 index 0000000..8c8b206 --- /dev/null +++ b/herdr/.gitignore @@ -0,0 +1,4 @@ +*.sock +session.json +session-snapshots/ +plugins/ diff --git a/herdr/.plugins.lock b/herdr/.plugins.lock new file mode 100644 index 0000000..e69de29 diff --git a/herdr/config.toml b/herdr/config.toml new file mode 100644 index 0000000..4af370b --- /dev/null +++ b/herdr/config.toml @@ -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" diff --git a/herdr/plugins.json b/herdr/plugins.json new file mode 100644 index 0000000..49ca317 --- /dev/null +++ b/herdr/plugins.json @@ -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 + } + } +] \ No newline at end of file diff --git a/herdr/release-notes.json b/herdr/release-notes.json new file mode 100644 index 0000000..f186e17 --- /dev/null +++ b/herdr/release-notes.json @@ -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 +} \ No newline at end of file diff --git a/opencode/herdr-opencode/tui.js b/opencode/herdr-opencode/tui.js new file mode 100644 index 0000000..92423a8 --- /dev/null +++ b/opencode/herdr-opencode/tui.js @@ -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"; diff --git a/opencode/herdr-tui-session.js b/opencode/herdr-tui-session.js new file mode 100644 index 0000000..d21c1f7 --- /dev/null +++ b/opencode/herdr-tui-session.js @@ -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(); + }; +} diff --git a/opencode/plugins/herdr-agent-state.js b/opencode/plugins/herdr-agent-state.js new file mode 100644 index 0000000..09c0070 --- /dev/null +++ b/opencode/plugins/herdr-agent-state.js @@ -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() {}, +}; diff --git a/opencode/tui.jsonc b/opencode/tui.jsonc new file mode 100644 index 0000000..dc6e387 --- /dev/null +++ b/opencode/tui.jsonc @@ -0,0 +1,3 @@ +{ + "plugin": ["./herdr-tui-session.js"] +}