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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions changelog.d/herdr-core-agent-terminal-platform.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
### Fixed
- On herdr-core, a pane on another machine is named after its agent the way that machine's own
platform names programs: a Windows machine's `letta.cmd` shows as Letta when Ovis runs on Linux,
and a Linux machine's Command Code (`cmd`) is no longer hidden as the Windows command prompt when
Ovis runs on Windows. Where the machine's platform cannot be told, such a pane names no agent
rather than a wrong one. A terminal started directly as an agent is named from that start only
while the process it started is still the one in front, so a pane started as `claude` that now
runs Codex is Codex.
37 changes: 37 additions & 0 deletions server/agent-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,43 @@ describe("which agent a pane runs", () => {
expect(paneAgent({ foreground: ["C:\\Program Files\\Claude\\Claude.EXE"] }, "windows")).toBe("claude");
});

// a terminal whose platform nothing tells (another machine's, without a folder), or only POSIX's (#3319)
it("names nothing a platform would decide when it is unknown, and no shim off Windows", () => {
expect([["claude"], ["node", "/home/u/.bin/letta"], ["cmd"], ["C:\\Users\\u\\letta.cmd"], ["C:\\Program Files\\Claude\\Claude.EXE"]].map((foreground) => paneAgent({ foreground }, null)))
.toEqual(["claude", "letta", null, null, null]);
expect([["cmd"], ["/mnt/c/Users/u/letta.cmd"]].map((foreground) => paneAgent({ foreground }, "posix"))).toEqual(["cmd", null]);
});

// a backslash is part of a POSIX file name: only Windows reads it as a separator (#3319 review)
it("splits a name at a backslash only where the platform does", () => {
expect((["posix", "linux", "macos"] as const).map((platform) => paneAgent({ foreground: ["/home/u/a\\claude"] }, platform))).toEqual([null, null, null]);
// Windows reads the same word as a folder `a` holding Claude
expect(paneAgent({ foreground: ["/home/u/a\\claude"] }, "windows")).toBe("claude");
// a platform nothing tells: the two readings differ, so it names nobody
expect(paneAgent({ foreground: ["/home/u/a\\claude"] }, null)).toBeNull();
});

// hc tells a terminal's own pid and the pid in front of it, never a process start (#3319 review)
it("names a terminal from its start only while that same process is still in front", () => {
// the runtime names no foreground program: the start does, while the pids match
expect(paneAgent({ foregroundPid: 7, launcher: { argv: ["node", "/home/u/.bin/letta"], pid: 7 } }, "linux")).toBe("letta");
expect(paneAgent({ foreground: [], foregroundPid: 7, launcher: { argv: ["C:\\Users\\u\\letta.cmd"], pid: 7 } }, "windows")).toBe("letta");
// a foreground the runtime names wins, even as the same pid: a claude start that exec()ed bash is bash now
expect(paneAgent({ foreground: ["bash"], foregroundPid: 7, launcher: { argv: ["claude"], pid: 7 } }, "linux")).toBeNull();
expect(paneAgent({ foreground: ["cmd.exe"], foregroundPid: 7, launcher: { argv: ["C:\\Users\\u\\letta.cmd"], pid: 7 } }, "windows")).toBeNull();
// a terminal started as claude that now runs codex: what is in front is no longer that start
expect(paneAgent({ launcher: { argv: ["claude"], pid: 7 }, foreground: ["codex"], foregroundPid: 9 }, "linux")).toBe("codex");
expect(paneAgent({ launcher: { argv: ["claude"], pid: 7 }, foreground: ["bash"], foregroundPid: 9 }, "linux")).toBeNull();
// no pid in front, or none told for the start: nothing proves the start still runs
for (const facts of [
{ launcher: { argv: ["claude"], pid: 7 } },
{ launcher: { argv: ["claude"], pid: 7 }, foreground: null, foregroundPid: null },
{ launcher: { argv: ["claude"] }, foregroundPid: 7 },
]) expect(paneAgent(facts, "linux")).toBeNull();
// the start is a plain shell, still in front: it names no agent
expect(paneAgent({ launcher: { argv: ["bash"], pid: 7 }, foregroundPid: 7 }, "linux")).toBeNull();
});

it("names none for a shell, an unknown program, or a program merely given an agent's name", () => {
for (const foreground of [["bash"], ["-zsh"], ["vim", "letta"], ["sh", "-c", "letta"], ["node", "/srv/app/server.js"], ["python3", "-m", "letta"], ["node", "-e", "letta"], ["node", "--", "-letta"], ["node", "/srv/project/pi.js"], ["bun", "letta.ts"], ["/opt/x/letta.cmd"], []]) {
expect(paneAgent({ foreground }, "linux")).toBeNull();
Expand Down
68 changes: 56 additions & 12 deletions server/agent-identity.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import nodePath from "node:path";
import { AGENT_CATALOG, catalogPlatform, type CatalogPlatform } from "../shared/agent-catalog.ts";
import { AGENT_CATALOG, CATALOG_PLATFORMS, type CatalogPlatform } from "../shared/agent-catalog.ts";
import type { AgentSessionInfo, AgentStatus } from "../shared/herdr-api.generated.ts";

/**
Expand All @@ -12,6 +12,21 @@ export interface PaneFacts {
reported?: string | null;
/** the argv of the process in front of the pane; its name alone when the runtime gives no argv */
foreground?: readonly string[] | null;
/** the pid of the process in front, when the runtime tells it (hc's `Foreground.pid`) */
foregroundPid?: number | null;
/** how the pane's terminal was started: the argv it was given, and the pid of the process that ran them (hc's `Terminal.pid`) */
launcher?: { readonly argv: readonly string[]; readonly pid?: number | null } | null;
}

/**
* The platform a pane's programs run on, as far as it is known: one platform, `posix` for Linux or
* macOS without saying which, or null for none known.
*/
export type ProgramPlatform = CatalogPlatform | "posix" | null;

function candidatesOf(platform: ProgramPlatform): readonly CatalogPlatform[] {
if (platform === null) return CATALOG_PLATFORMS;
return platform === "posix" ? ["linux", "macos"] : [platform];
}

/** Runtimes' names for agents herdr names otherwise: the rest of the server knows herdr's (`claude`, `agy`). */
Expand All @@ -31,11 +46,12 @@ export function reportedAgent(app: string | null | undefined): string | null {
return Object.hasOwn(REPORTED_NAMES, app) ? REPORTED_NAMES[app]! : app;
}

/** A path's last part as `platform` names files: on Windows in any case, a program without its `.exe` or `.cmd`. */
function fileName(word: string, platform: CatalogPlatform | null, program: boolean): string {
const name = nodePath.posix.basename(word.replace(/\\/g, "/"));
if (platform !== "windows") return name;
return program ? name.toLowerCase().replace(/\.(?:exe|cmd)$/, "") : name.toLowerCase();
/** A path's last part as `platform` names files: on Windows at either slash and in any case, a program without its `.exe` or `.cmd`. */
function fileName(word: string, platform: CatalogPlatform, program: boolean): string {
// a backslash separates a path on Windows alone: elsewhere it is part of the file's name
if (platform !== "windows") return nodePath.posix.basename(word);
const name = nodePath.posix.basename(word.replace(/\\/g, "/")).toLowerCase();
return program ? name.replace(/\.(?:exe|cmd)$/, "") : name;
}

/** Interpreter options after which the next word is code or a module, not a script file. */
Expand All @@ -54,13 +70,22 @@ function scriptOf(argv: readonly string[]): string | null {
* The catalog agent a foreground process is: one of its detect binaries run directly, or the
* extensionless script by that name an interpreter runs (npm's bin, a Python entry point), never
* any `pi.js` that shares a name. A binary whose name proves nothing on `platform` (Windows' own
* cmd) names none there.
* cmd) names none there. `platform` is the one the pane's terminal runs on, not this server's: a
* Windows terminal started directly as `letta.cmd` (npm's shim) is Letta, as Windows runs
* `letta` from that shim, while `letta.cmd` on Linux or macOS is a file named so and names none.
* Where the platform is not known for sure, a name counts only when every platform it may be
* reads it alike: neither `cmd` nor a shim names anyone there.
*/
export function processAgent(argv: readonly string[], platform: CatalogPlatform | null): string | null {
export function processAgent(argv: readonly string[], platform: ProgramPlatform): string | null {
const named = new Set(candidatesOf(platform).map((candidate) => agentOn(argv, candidate)));
return named.size === 1 ? [...named][0]! : null;
}

function agentOn(argv: readonly string[], platform: CatalogPlatform): string | null {
const first = fileName(argv[0] ?? "", platform, true);
const program = INTERPRETERS.has(first) ? fileName(scriptOf(argv) ?? "", platform, false) : first;
if (!program) return null;
const entry = AGENT_CATALOG.find((candidate) => candidate.detect.binaries.includes(program) && !(platform && candidate.detect.unreliable_on?.[platform]));
const entry = AGENT_CATALOG.find((candidate) => candidate.detect.binaries.includes(program) && !candidate.detect.unreliable_on?.[platform]);
return entry?.id ?? null;
}

Expand Down Expand Up @@ -116,7 +141,26 @@ export function paneSession(agent: string | null, facts: SessionFacts): AgentSes
return titled.length === 1 && (facts.named ?? []).includes(titled[0]!) ? { agent: "opencode", source: "herdr:opencode", kind: "id", value: titled[0]! } : null;
}

/** The agent a pane runs: the program's own report first, else the process in front of it; null for none. */
export function paneAgent(facts: PaneFacts, platform: CatalogPlatform | null = catalogPlatform(process.platform)): string | null {
return reportedAgent(facts.reported) ?? (facts.foreground?.length ? processAgent(facts.foreground, platform) : null);
/**
* The agent a pane runs: the program's own report first, else the process in front of it on
* `platform`, its terminal's. Only when the runtime names no program in front, the command the
* terminal was started with, which names an agent only when it runs one directly (a shell's `-c letta` or `cmd /c letta.cmd`
* names none) and only while the process it started is still the one in front: a terminal started
* as `claude` whose pane now runs `codex` names Codex, never Claude. No runtime tells a process's
* start time (hc names a terminal's own pid and the pid in front, herdr's process rows pids), so
* the same pid is what proves it. Null for none.
*/
export function paneAgent(facts: PaneFacts, platform: ProgramPlatform): string | null {
const reported = reportedAgent(facts.reported);
if (reported) return reported;
// a foreground the runtime names is the program in front, even as the start's pid (a start that exec()ed a shell)
if (facts.foreground?.length) return processAgent(facts.foreground, platform);
const launcher = facts.launcher;
if (!launcher?.argv.length || !startedInFront(facts.foregroundPid, launcher.pid)) return null;
return processAgent(launcher.argv, platform);
}

/** Whether the process a terminal started with is the one in front now: one live pid, the only proof a runtime gives. */
function startedInFront(front: number | null | undefined, started: number | null | undefined): boolean {
return typeof front === "number" && front > 0 && front === started;
}
36 changes: 36 additions & 0 deletions server/herdr-core/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1608,6 +1608,42 @@ describe("another machine of the core, as its own PC (#2497)", () => {
}
});

// hc names no platform: that machine's is read off its folders, the process in front's first (#3319)
it("names that machine's process in front by its own platform, and again as a foreground event tells a new folder", async () => {
let channel = 0;
const shim = { pid: 42, name: "letta.cmd", argv: ["letta.cmd"] };
const { socket, cores } = await setup(listed(), {
"events.subscribe": () => {
channel = cores.at(-1)!.channel();
return { channel, seq: 1, terminals: [terminal("lab/t1", { cwd: "", foreground: shim })] };
},
}, undefined, "lab");
const frames: EventFrame[] = [];
const started = Promise.withResolvers<void>();
const ended = Promise.withResolvers<void>();
const subscription = subscribeEvents([{ type: "pane.updated" }, { type: "pane.agent_status_changed", pane_id: "t1" }], {
onEvent: (frame) => frames.push(frame),
onStarted: () => started.resolve(),
onClose: () => ended.resolve(),
}, socket);
cleanups.push(() => subscription.close());
await started.promise;
const core = cores.at(-1)!;
// no folder yet: a shim names no one
core.notify(channel, "event", { seq: 2, type: "status", terminal: "lab/t1", data: { status: { state: "working" } } });
core.notify(channel, "event", { seq: 3, type: "foreground", terminal: "lab/t1", data: { ...shim, pid: 43, cwd: "C:\\Users\\u" } });
core.notify(channel, "event", { seq: 4, type: "foreground", terminal: "lab/t1", data: { pid: 44, name: "cmd", argv: ["cmd"], cwd: "/home/u" } });
core.notify(channel, "missed", { seq: 9, terminals: [] });
await ended.promise;
expect(frames).toEqual([
{ event: "pane.agent_status_changed", data: { pane_id: "t1", agent_status: "working", agent: null } },
{ event: "pane.updated", data: { type: "pane_updated", pane_id: "t1" } },
{ event: "pane.agent_status_changed", data: { pane_id: "t1", agent_status: "working", agent: "letta" } },
{ event: "pane.updated", data: { type: "pane_updated", pane_id: "t1" } },
{ event: "pane.agent_status_changed", data: { pane_id: "t1", agent_status: "working", agent: "cmd" } },
]);
});

it("streams that machine's events as its own, and none of this PC's or another machine's", async () => {
let channel = 0;
const { socket, cores } = await setup(listed(), {
Expand Down
Loading
Loading