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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/next/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
- Experimental pane graphics now support bounded named layers, acknowledged full-RGBA primary-layer direct file frames on audited local terminals, owned BGRA fallback, exact pixel mouse input, and placement-only resize replay.

### Fixed
- OpenCode panes now track the root conversation selected in their own TUI for native restore without adopting activity from attached clients. (#2450)
- `herdr config check` now reports unknown built-in theme names instead of silently accepting them. (#2452)
- macOS `herdr --remote` clients now keep the accepted bridge socket blocking, preventing an immediate disconnect after the protocol handshake. (#2478, thanks @mathijshenquet)
- Prefix keybindings now preserve Shift in WezTerm Kitty keyboard mode, so commands such as config reload no longer trigger their unshifted action. (#2435)
Expand Down
1 change: 1 addition & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ website-build:
integration-assets-test:
bun test src/integration/assets/herdr-agent-state.test.ts
bun test src/integration/assets/opencode/herdr-agent-state.test.ts
bun test src/integration/assets/opencode/herdr-tui-session.test.ts

# Run plugin marketplace Worker tests
plugin-marketplace-test:
Expand Down
6 changes: 5 additions & 1 deletion src/agent_resume.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ pub fn session_ref_from_report(

pub fn normalize_session_start_source(value: Option<String>) -> Option<String> {
match value.as_deref().map(str::trim) {
Some(source @ ("startup" | "resume" | "clear" | "compact" | "new" | "fork")) => {
Some(source @ ("startup" | "resume" | "clear" | "compact" | "new" | "fork" | "select")) => {
Some(source.to_string())
}
_ => None,
Expand Down Expand Up @@ -608,6 +608,10 @@ mod tests {
normalize_session_start_source(Some("fork".into())),
Some("fork".into())
);
assert_eq!(
normalize_session_start_source(Some("select".into())),
Some("select".into())
);
assert_eq!(
normalize_session_start_source(Some(" resume ".into())),
Some("resume".into())
Expand Down
7 changes: 7 additions & 0 deletions src/cli/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ fn integration_status(args: &[String]) -> std::io::Result<i32> {
crate::integration::IntegrationStatusKind::Current => {
format!("current ({version})")
}
crate::integration::IntegrationStatusKind::Outdated
if status
.installed_version
.is_some_and(|installed| installed >= status.expected_version) =>
{
format!("needs repair ({version})")
}
crate::integration::IntegrationStatusKind::Outdated => {
format!("outdated ({version} < v{})", status.expected_version)
}
Expand Down
46 changes: 37 additions & 9 deletions src/integration/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,20 @@ fn install_target_inner(target: crate::api::schema::IntegrationTarget) -> io::Re
}
crate::api::schema::IntegrationTarget::Opencode => {
let installed = install_opencode()?;
vec![format!(
"installed opencode integration plugin to {}",
installed.plugin_path.display()
)]
vec![
format!(
"installed opencode integration plugin to {}",
installed.plugin_path.display()
),
format!(
"installed opencode tui integration plugin to {}",
installed.tui_plugin_path.display()
),
format!(
"ensured opencode tui plugin config at {}",
installed.tui_config_path.display()
),
]
}
crate::api::schema::IntegrationTarget::Kilo => {
let installed = install_kilo()?;
Expand Down Expand Up @@ -451,17 +461,35 @@ pub(crate) fn uninstall_target(
}
crate::api::schema::IntegrationTarget::Opencode => {
let result = uninstall_opencode()?;
if result.removed_plugin {
vec![format!(
let mut messages = vec![if result.removed_plugin {
format!(
"removed opencode integration plugin at {}",
result.plugin_path.display()
)]
)
} else {
vec![format!(
format!(
"no opencode integration plugin found at {}",
result.plugin_path.display()
)]
)
}];
messages.push(if result.removed_tui_plugin {
format!(
"removed opencode tui integration plugin at {}",
result.tui_plugin_path.display()
)
} else {
format!(
"no opencode tui integration plugin found at {}",
result.tui_plugin_path.display()
)
});
if result.updated_tui_config {
messages.push(format!(
"removed herdr opencode plugin entry from {}",
result.tui_config_path.display()
));
}
messages
}
crate::api::schema::IntegrationTarget::Kilo => {
let result = uninstall_kilo()?;
Expand Down
17 changes: 6 additions & 11 deletions src/integration/assets/opencode/herdr-agent-state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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=9
// HERDR_INTEGRATION_VERSION=10

import net from "node:net";

Expand Down Expand Up @@ -102,15 +102,11 @@ function requestOnce(method, params) {
});
}

function reportSession(sessionID, sessionStartSource) {
function reportSession(sessionID) {
if (!sessionID) {
return Promise.resolve();
}
const params = { agent_session_id: sessionID };
if (sessionStartSource) {
params.session_start_source = sessionStartSource;
}
return request("pane.report_agent_session", params);
return request("pane.report_agent_session", { agent_session_id: sessionID });
}

function reportState(state, sessionID) {
Expand Down Expand Up @@ -157,10 +153,9 @@ export const HerdrAgentStatePlugin = async () => {

switch (type) {
case "session.created":
// A root session.created is a genuine new-session start (subagent
// creates are dropped above). Signal it so herdr replaces the pane's
// prior session id instead of treating the change as cross-talk.
await reportSession(sessionID, "new");
// 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) {
Expand Down
31 changes: 31 additions & 0 deletions src/integration/assets/opencode/herdr-agent-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,37 @@ test("suppresses redundant same-session updates", async () => {
expect(requests.map(requestSessionID)).toEqual(["root-session", "replacement-session"]);
});

test("does not classify server activity in another root session as a selection", async () => {
const plugin = await loadPlugin();

await plugin["chat.message"]({ sessionID: "visible-session" });
await plugin["chat.message"]({ sessionID: "attached-client-session" });

expect(requests.map(requestMethod)).toEqual([
"pane.report_agent",
"pane.report_agent",
]);
expect(requests.map(requestSessionID)).toEqual([
"visible-session",
"attached-client-session",
]);
});

test("does not classify server-global root creation as a local selection", async () => {
const plugin = await loadPlugin();

await plugin.event({
event: { type: "session.created", properties: { sessionID: "attached-session" } },
});
await plugin.event({
event: { type: "session.updated", properties: { sessionID: "attached-session" } },
});
await plugin["chat.message"]({ sessionID: "attached-session" });

expect(requests.map(requestMethod)).toEqual(["pane.report_agent"]);
expect(requests.map(requestSessionID)).toEqual(["attached-session"]);
});

test("reports retry status as working", async () => {
const plugin = await loadPlugin();

Expand Down
113 changes: 113 additions & 0 deletions src/integration/assets/opencode/herdr-tui-session.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// installed by herdr
// managed by herdr; reinstalling or updating the integration overwrites this file.
// HERDR_INTEGRATION_ID=opencode-tui
// HERDR_INTEGRATION_VERSION=10

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) {
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 request = {
id: `${SOURCE}:tui:${Date.now()}:${Math.floor(Math.random() * 1_000_000)
.toString()
.padStart(6, "0")}`,
method: "pane.report_agent_session",
params: {
pane_id: paneId,
source: SOURCE,
agent: AGENT,
agent_session_id: sessionID,
session_start_source: "select",
},
};

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);
});
}

export default {
id: "herdr.opencode.session-selection",
tui: async (api) => {
if (
process.env.HERDR_ENV !== "1" ||
!process.env.HERDR_SOCKET_PATH ||
!process.env.HERDR_PANE_ID
) {
return;
}

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;
return;
}
if (sessionID !== selectedSessionID) {
selectedSessionID = sessionID;
retryIndex = 0;
nextReportAt = 0;
}
if (reportPending || Date.now() < nextReportAt) {
return;
}

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));
},
};
Loading
Loading