diff --git a/changelog.d/herdr-core-agent-terminal-platform.md b/changelog.d/herdr-core-agent-terminal-platform.md new file mode 100644 index 000000000..dcb2126c4 --- /dev/null +++ b/changelog.d/herdr-core-agent-terminal-platform.md @@ -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. diff --git a/server/agent-identity.test.ts b/server/agent-identity.test.ts index 89fd1da2e..9ae78fb2d 100644 --- a/server/agent-identity.test.ts +++ b/server/agent-identity.test.ts @@ -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(); diff --git a/server/agent-identity.ts b/server/agent-identity.ts index cc8ef7aa2..d2764e30e 100644 --- a/server/agent-identity.ts +++ b/server/agent-identity.ts @@ -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"; /** @@ -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`). */ @@ -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. */ @@ -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; } @@ -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; } diff --git a/server/herdr-core/adapter.test.ts b/server/herdr-core/adapter.test.ts index 50ab5200b..e172465cc 100644 --- a/server/herdr-core/adapter.test.ts +++ b/server/herdr-core/adapter.test.ts @@ -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(); + const ended = Promise.withResolvers(); + 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(), { diff --git a/server/herdr-core/adapter.ts b/server/herdr-core/adapter.ts index 7d1c4a8d4..52ecc935e 100644 --- a/server/herdr-core/adapter.ts +++ b/server/herdr-core/adapter.ts @@ -9,7 +9,7 @@ import { descendantProcesses, type ProcessRow, windowsProcessTable } from "../wi import { type HcConnection, HcError } from "./connection.ts"; import { foreignLayout, leaves, neighbour, OVIS_LAYOUT_DOC, type OvisLayout, type PaneDirection, resizeLeaf, setRatio, splitLeaf, swapLeaves, type TabLayout } from "./layout-doc.ts"; import { paneSession, titleStatus } from "../agent-identity.ts"; -import { agentInfoOf, agentOf, agentOfStatus, agentStatusOf, foregroundOf, type HcForeground, buildSnapshot, type FirstTurn, keptLayout, opencodeRecords, type SessionOf, reportedFacts, type HcStatus, type HcTerminal, hcKeyName, HERDR_PROTOCOL, isRecord, keyBytes, LAYOUT_DOC, onOtherMachine, OTHER_WORKSPACE_ID, PANE_NAME_LABEL, processInfoOf, SEEN_LABEL, shownStatusOf, SPACE_LABEL, TAB_LABEL, TAB_NAME_LABEL, WORKSPACE_NAME_LABEL } from "./snapshot.ts"; +import { agentInfoOf, agentOf, agentOfStatus, type CoreHost, coreHost, agentStatusOf, foregroundOf, type HcForeground, buildSnapshot, type FirstTurn, keptLayout, opencodeRecords, type SessionOf, reportedFacts, type HcStatus, type HcTerminal, hcKeyName, HERDR_PROTOCOL, isRecord, keyBytes, LAYOUT_DOC, onOtherMachine, OTHER_WORKSPACE_ID, PANE_NAME_LABEL, processInfoOf, SEEN_LABEL, shownStatusOf, SPACE_LABEL, TAB_LABEL, TAB_NAME_LABEL, WORKSPACE_NAME_LABEL } from "./snapshot.ts"; import { checkoutHolding, defaultCheckoutPath, factsOf, git, gitDirIdentity, labeledWorktree, realPath, type Checkout, type Repository, repositoryOf, WORKTREE_LABEL, WorktreeError, worktreeFolder, worktreeLabel } from "./worktrees.ts"; import type { AgentStatus, SessionSnapshot } from "../../shared/herdr-api.generated.ts"; import type { WorktreeInfo } from "../herdr/client.ts"; @@ -174,7 +174,7 @@ export async function startHerdrCoreAdapter(hc: HcConnection, socketPath: string first = false; const request = parseRequest(line); if (!request) { send(socket, `${JSON.stringify({ id: "", error: { code: "bad_frame", message: "not a herdr request" } })}\n`, true); return; } - if (request.method === "events.subscribe") { void subscribe(hc, socket, request, firstTurn, observeSeen, noteSession); return; } + if (request.method === "events.subscribe") { void subscribe(hc, socket, request, firstTurn, coreHost(options.otherMachine, options.platform), observeSeen, noteSession); return; } void answers(request).then((reply) => send(socket, `${JSON.stringify({ id: request.id, ...reply })}\n`, true)); }, () => socket.end()), }; @@ -422,7 +422,7 @@ function createAnswers(hc: HcConnection, firstTurn: FirstTurn, forgetTurns: (liv hc.hello(), ]); forgetTurns(new Set(list.terminals.map((terminal) => terminal.address))); - return { shown: buildSnapshot(list.terminals, doc.value, hello.version, firstTurn, ovis.value, sessionOf), own: ownsLayout(doc.value) }; + return { shown: buildSnapshot(list.terminals, doc.value, hello.version, firstTurn, ovis.value, sessionOf, coreHost(options.otherMachine, platform)), own: ownsLayout(doc.value) }; }; const snapshot = async (): Promise => (await read()).shown; /** The snapshot, for a call that changes the layout Ovis keeps in labels: refused where the Herdr app arranges this core. */ @@ -1263,7 +1263,7 @@ function labelsOf(labels: unknown): Record { /** What an event says of a terminal's OpenCode session (startHerdrCoreAdapter `noteSession`), so a turn too short for any read is still remembered. */ type SessionNote = (address: string, status: HcStatus | null | undefined, records: unknown) => unknown; -async function subscribe(hc: HcConnection, socket: Socket, request: Request, firstTurn: FirstTurn, observeSeen?: SeenObserver, noteSession?: SessionNote): Promise { +async function subscribe(hc: HcConnection, socket: Socket, request: Request, firstTurn: FirstTurn, host: CoreHost, observeSeen?: SeenObserver, noteSession?: SessionNote): Promise { const subscriptions = Array.isArray(request.params["subscriptions"]) ? request.params["subscriptions"].filter(isRecord) : []; const statusPanes = new Set(subscriptions.flatMap((entry) => entry["type"] === STATUS_EVENT && typeof entry["pane_id"] === "string" ? [entry["pane_id"]] : [])); const structure = subscriptions.some((entry) => typeof entry["type"] === "string" && !NOT_STRUCTURE.has(entry["type"])); @@ -1300,7 +1300,10 @@ async function subscribe(hc: HcConnection, socket: Socket, requ * `shownStatusOf`): its program's word, its turn and its labels, from hc's snapshot and then its * events, with the status last told. Until hc's snapshot is in, events wait in `early`. */ - const known = new Map; shown: AgentStatus }>(); + type Known = { status: HcStatus | null; front: HcForeground | null; cwd: string | undefined; argv: string[] | undefined; pid: number | null; title: string | null; turn: number; first: number; labels: Record; shown: AgentStatus }; + const known = new Map(); + /** the agent a terminal runs, read as the snapshot reads it (snapshot.ts `agentOf`) */ + const agentNow = (status: HcStatus | null, entry: Known | undefined) => agentOf({ status, foreground: entry?.front ?? null, cwd: entry?.cwd, argv: entry?.argv, pid: entry?.pid }, host); let early: HcEvent[] | null = []; const remember = (record: unknown) => { if (!isRecord(record) || typeof record["address"] !== "string") return; @@ -1309,11 +1312,12 @@ async function subscribe(hc: HcConnection, socket: Socket, requ const labels = labelsOf(record["labels"]); const first = firstTurn({ address: record["address"], turn }); noteSession?.(record["address"], status, record["records"]); - known.set(record["address"], { status, front: foregroundOf(record["foreground"]), title: typeof record["title"] === "string" ? record["title"] : null, turn, first, labels, shown: shownStatusOf(status, turn, labels, first) }); + known.set(record["address"], { status, front: foregroundOf(record["foreground"]), cwd: typeof record["cwd"] === "string" ? record["cwd"] : undefined, argv: Array.isArray(record["argv"]) && record["argv"].every((word) => typeof word === "string") ? record["argv"] : undefined, pid: typeof record["pid"] === "number" ? record["pid"] : null, title: typeof record["title"] === "string" ? record["title"] : null, turn, first, labels, shown: shownStatusOf(status, turn, labels, first) }); observeSeen?.(record["address"], turn, labels); }; const tell = (paneId: string, status: HcStatus | null, shown: AgentStatus) => { - emit(STATUS_EVENT, { pane_id: paneId, agent_status: shown, agent: agentOf({ status, foreground: known.get(paneId)?.front ?? null }) }); + const entry = known.get(paneId); + emit(STATUS_EVENT, { pane_id: paneId, agent_status: shown, agent: agentNow(status, entry) }); }; /** A turn or a look (the `herdr.seen` label) that changes what a terminal shows is told as its status. */ const retell = (paneId: string) => { @@ -1399,9 +1403,9 @@ async function subscribe(hc: HcConnection, socket: Socket, requ // a program that reports nothing is named from the process in front of it (snapshot.ts `agentOf`) const entry = paneId ? known.get(paneId) : undefined; if (!entry) return; - const before = agentOf({ status: entry.status, foreground: entry.front }); + const before = agentNow(entry.status, entry); entry.front = foregroundOf(event.data); - if (agentOf({ status: entry.status, foreground: entry.front }) === before) return; + if (agentNow(entry.status, entry) === before) return; lifecycle("pane.updated", "pane_updated", paneId); if (statusPanes.has(paneId!)) tell(paneId!, entry.status, entry.shown); return; @@ -1413,7 +1417,7 @@ async function subscribe(hc: HcConnection, socket: Socket, requ if (!entry) return; const title = typeof event.data?.["title"] === "string" ? event.data["title"] : null; // only where no report gives a status: none, or a word that says nothing (an error) - const agent = agentStatusOf(entry.status) === "unknown" ? agentOf({ status: entry.status, foreground: entry.front }) : null; + const agent = agentStatusOf(entry.status) === "unknown" ? agentNow(entry.status, entry) : null; const before = titleStatus(agent, entry.title); entry.title = title; if (titleStatus(agent, title) !== before) lifecycle("pane.updated", "pane_updated", paneId); diff --git a/server/herdr-core/snapshot.test.ts b/server/herdr-core/snapshot.test.ts index 9378bca88..957408a9a 100644 --- a/server/herdr-core/snapshot.test.ts +++ b/server/herdr-core/snapshot.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import type { HerdrPane } from "../../shared/protocol.ts"; -import { buildSnapshot, type HcTerminal, keyBytes, opencodeRecords, OTHER_WORKSPACE_ID } from "./snapshot.ts"; +import type { ProgramPlatform } from "../agent-identity.ts"; +import { agentOf, buildSnapshot, type HcTerminal, keyBytes, opencodeRecords, OTHER_WORKSPACE_ID, terminalPlatform } from "./snapshot.ts"; function terminal(address: string, extra: Partial = {}): HcTerminal { return { address, cols: 120, rows: 40, cwd: "/home/u", state: "live", ...extra }; @@ -153,6 +154,49 @@ describe("buildSnapshot", () => { expect(snapshot.agents.map((a) => [a.pane_id, a.agent])).toEqual([["t1", "letta"], ["t2", "devin"], ["t3", "grok"]]); }); + // hc names no platform, and a terminal may be another machine's (machine-scope.ts) (#3319) + it("takes a terminal's platform as this PC's on its own core, and by its folder's shape on another machine", () => { + for (const here of ["linux", "macos", "windows", null] as const) expect(terminalPlatform("C:\\Users\\u", here)).toBe(here); + // `//server/share` is a share to Windows and a root to POSIX: either; WSL's `/mnt/c` is Linux's + const shapes: Record = { "C:\\Users\\u": "windows", "c:/work": "windows", "\\\\server\\share\\x": "windows", "\\\\?\\C:\\work": "windows", "//server/share": null, "/mnt/c/Users/u": "posix", "/home/u": "posix", "": null, "work": null, "~": null }; + expect(Object.keys(shapes).map((cwd) => terminalPlatform(cwd, "elsewhere"))).toEqual(Object.values(shapes)); + expect(terminalPlatform(undefined, "elsewhere")).toBeNull(); + }); + + it("names another machine's process in front by that machine's platform, and nothing a platform decides when its folder tells none", () => { + const front = (argv: string[], cwd?: string) => ({ status: null, foreground: { pid: 11, name: argv[0]!, argv, ...(cwd === undefined ? {} : { cwd }) } }); + expect(agentOf({ cwd: "C:\\Users\\u", ...front(["C:\\Users\\u\\letta.cmd"]) }, "elsewhere")).toBe("letta"); + expect(agentOf({ cwd: "/home/u", ...front(["/home/u/.local/bin/cmd"]) }, "elsewhere")).toBe("cmd"); + expect(agentOf({ cwd: "C:\\Users\\u", ...front(["C:\\Windows\\System32\\cmd.exe"]) }, "elsewhere")).toBeNull(); + // the process in front's own folder goes first + expect(agentOf({ cwd: "", ...front(["letta.cmd"], "C:\\Users\\u") }, "elsewhere")).toBe("letta"); + // no folder: neither Windows' cmd nor a shim names anyone, an agent's own name still does + for (const argv of [["cmd"], ["letta.cmd"]]) expect(agentOf({ cwd: "", ...front(argv) }, "elsewhere")).toBeNull(); + expect(agentOf(front(["claude"]), "elsewhere")).toBe("claude"); + // this PC's own core: its platform, whatever the folder + expect(agentOf({ cwd: "C:\\Users\\u", ...front(["cmd"]) }, "linux")).toBe("cmd"); + }); + + // hc tells a terminal's own pid and the pid in front of it, never a process start (#3319 review) + it("names a terminal started directly as an agent while the process it started is still in front, and none a shell started", () => { + // the core names the pid in front but no program for it: the start names it while the pids match + const started = (argv: string[], cwd: string, front: string | null = null) => ({ status: null, pid: 7, foreground: { pid: 7, name: front ?? "", argv: front ? [front] : [] }, argv, cwd }); + expect(agentOf(started(["C:\\Users\\u\\letta.cmd"], "C:\\Users\\u"), "windows")).toBe("letta"); + expect(agentOf(started(["C:\\Users\\u\\letta.cmd"], "C:\\Users\\u"), "elsewhere")).toBe("letta"); + expect(agentOf(started(["node", "/home/u/.bin/letta"], "/home/u"), "linux")).toBe("letta"); + // a program the core names in front wins, even as the start's pid: a claude start that exec()ed bash is bash + expect(agentOf(started(["claude"], "/home/u", "bash"), "linux")).toBeNull(); + expect(agentOf(started(["C:\\Users\\u\\letta.cmd"], "C:\\Users\\u", "cmd.exe"), "windows")).toBeNull(); + for (const argv of [["cmd.exe", "/c", "letta.cmd"], ["pwsh", "-c", "letta"]]) expect(agentOf(started(argv, "C:\\Users\\u", "cmd.exe"), "windows")).toBeNull(); + expect(agentOf(started(["bash", "-lc", "letta"], "/home/u"), "linux")).toBeNull(); + // the process in front is no longer the one the terminal started, so the start names nobody + expect(agentOf({ status: null, pid: 7, argv: ["claude"], cwd: "/home/u", foreground: { pid: 9, name: "codex", argv: ["codex"] } }, "linux")).toBe("codex"); + expect(agentOf({ status: null, pid: 7, argv: ["claude"], cwd: "/home/u", foreground: { pid: 9, name: "bash", argv: ["bash"] } }, "linux")).toBeNull(); + // no process in front told, or no pid for the start: nothing proves the start still runs + expect(agentOf({ status: null, pid: 7, argv: ["letta"], cwd: "/home/u", foreground: null }, "linux")).toBeNull(); + expect(agentOf({ status: null, argv: ["letta"], cwd: "/home/u", foreground: { pid: 9, name: "bash", argv: ["bash"] } }, "linux")).toBeNull(); + }); + it("cuts the tab top down by the app's tree and weights, whatever size the terminals are, with a divider per split", () => { const weighted = { ...block("t1"), size: { weight: 3 } }; const layout = { spaces: [{ id: "s1", name: "a", tabs: [{ id: "s1t1", name: "", root: { type: "row", id: "r", children: [weighted, { type: "column", id: "c", children: [block("t2"), block("t3")] }] } }] }] }; diff --git a/server/herdr-core/snapshot.ts b/server/herdr-core/snapshot.ts index 5bc5e3c84..fdec0b5f9 100644 --- a/server/herdr-core/snapshot.ts +++ b/server/herdr-core/snapshot.ts @@ -4,7 +4,8 @@ import type { HerdrPane } from "../../shared/protocol.ts"; import type { Foreground, Status, Terminal } from "./hc-api.generated.ts"; import { clampRatio, type Grouping, type LayoutNode, LAYOUT_AREA, parseOvisLayout, place, reconcile, sideBySide } from "./layout-doc.ts"; import { worktreeOfLabels } from "./worktrees.ts"; -import { paneAgent, reportedAgent, reportedStatus } from "../agent-identity.ts"; +import { paneAgent, type ProgramPlatform, reportedAgent, reportedStatus } from "../agent-identity.ts"; +import { catalogPlatform, type CatalogPlatform } from "../../shared/agent-catalog.ts"; /** * How herdr-core's terminals look to the rest of the server: herdr's session snapshot, built from @@ -99,17 +100,47 @@ export function agentOfStatus(status: HcStatus | null | undefined): string | nul return reportedAgent(status?.app); } -/** The agent a terminal runs (agent-identity.ts): its program's report, else the process in front of it. */ -export function agentOf(terminal: Pick): string | null { +/** + * Where a core's terminals run: this PC, by its platform (null for one the catalog does not know), + * or another machine the core reaches (machine-scope.ts, `elsewhere`). + */ +export type CoreHost = CatalogPlatform | null | "elsewhere"; + +/** The host of a server's core: this PC's own, unless it shows another machine (adapter.ts `otherMachine`). */ +export function coreHost(otherMachine: boolean | undefined, platform: NodeJS.Platform = process.platform): CoreHost { + return otherMachine ? "elsewhere" : catalogPlatform(platform); +} + +/** + * The platform a terminal's programs run on. A terminal of this PC's core runs on this PC. hc names + * no platform, so another machine's is read off a folder's shape: a drive letter or a `\\` share + * is Windows, a `/` path is Linux's or macOS's (WSL's `/mnt/c` too: its shell is Linux). A missing + * or relative folder tells nothing, nor does `//server/share`, a share to Windows and a root to POSIX. + */ +export function terminalPlatform(cwd: string | undefined, host: CoreHost): ProgramPlatform { + if (host !== "elsewhere") return host; + if (cwd === undefined || cwd.startsWith("//")) return null; + if (windowsPath(cwd)) return "windows"; + return cwd.startsWith("/") ? "posix" : null; +} + +/** + * The agent a terminal runs (agent-identity.ts): its program's report, else the process in front of + * it, else the command it started with while the process that ran it is still in front, as its own + * platform names programs: by the process in front's folder, else the terminal's. + */ +export function agentOf(terminal: Pick & { cwd?: string | undefined; argv?: readonly string[] | undefined; pid?: number | null }, host: CoreHost): string | null { const front = terminal.foreground; - return paneAgent({ reported: terminal.status?.app, foreground: front ? (front.argv?.length ? front.argv : [front.name]) : null }); + const platform = terminalPlatform(front?.cwd || terminal.cwd, host); + return paneAgent({ reported: terminal.status?.app, foreground: front?.argv?.length ? front.argv : front?.name ? [front.name] : null, foregroundPid: front?.pid ?? null, launcher: terminal.argv?.length ? { argv: terminal.argv, pid: terminal.pid ?? null } : null }, platform); } /** hc's record of the process in front of a terminal (a record's `foreground`, a `foreground` event's data); null for none or another shape. */ export function foregroundOf(value: unknown): HcForeground | null { if (!isRecord(value) || typeof value["pid"] !== "number" || typeof value["name"] !== "string") return null; const argv = value["argv"]; - return { pid: value["pid"], name: value["name"], ...(Array.isArray(argv) && argv.every((word) => typeof word === "string") ? { argv } : {}) }; + const cwd = value["cwd"]; + return { pid: value["pid"], name: value["name"], ...(Array.isArray(argv) && argv.every((word) => typeof word === "string") ? { argv } : {}), ...(typeof cwd === "string" ? { cwd } : {}) }; } /** @@ -177,6 +208,11 @@ function appTree(node: unknown, take: (address: string) => HcTerminal | null, he return parts.length === 0 ? null : chain(parts, node["type"] === "column" ? "down" : "right"); } +/** Whether a path is Windows-shaped: a drive letter or a UNC prefix. */ +function windowsPath(path: string): boolean { + return /^(?:[A-Za-z]:[\\/]|\\\\)/.test(path); +} + /** * A folder's last name. A Windows path (a drive letter or a UNC prefix) splits at either slash and * any other at `/` only, by the path's shape and not this host's platform: a server can show @@ -185,7 +221,7 @@ function appTree(node: unknown, take: (address: string) => HcTerminal | null, he */ function basename(path: string): string { if (/^(?:\\\\[?.]\\)?[A-Za-z]:[\\/]+$/.test(path)) return path; - const parts = path.split(/^(?:[A-Za-z]:[\\/]|\\\\)/.test(path) ? /[\\/]/ : "/").filter(Boolean); + const parts = path.split(windowsPath(path) ? /[\\/]/ : "/").filter(Boolean); return parts[parts.length - 1] ?? path; } @@ -361,7 +397,7 @@ export function reportedFacts(status: HcStatus | null | undefined): Pick, firstTurn?: FirstTurn, sessionOf?: SessionOf): HerdrPane { +function paneOf(terminal: HcTerminal, workspaceId: string, tabId: string, shown: ReadonlySet, host: CoreHost, firstTurn?: FirstTurn, sessionOf?: SessionOf): HerdrPane { const cwd = terminal.foreground?.cwd ?? terminal.cwd; const session = sessionOf?.(terminal) ?? null; return { @@ -375,7 +411,7 @@ function paneOf(terminal: HcTerminal, workspaceId: string, tabId: string, shown: grid: { cols: terminal.cols, rows: terminal.rows }, workspace_id: workspaceId, tab_id: tabId, - agent: agentOf(terminal), + agent: agentOf(terminal, host), agent_status: shownStatusOf(terminal.status, terminal.turn, terminal.labels, firstTurn?.(terminal)), cwd, foreground_cwd: cwd, @@ -457,9 +493,10 @@ function arranged(placed: Group[], ovisDoc: unknown): { groups: Group[]; zoomed: * window, and no pane is in front of anyone the way a herdr TUI's is. Each tab is the fixed * LAYOUT_AREA cut top down by its tree (the app's, or Ovis's own document `ovisDoc` where no Herdr * app arranges the core), never by its terminals' grids: a viewer sizes a terminal to its pane's - * cell, and a grid read back as the pane made panes shrink each other (#2812). + * cell, and a grid read back as the pane made panes shrink each other (#2812). `host`: where the + * core's terminals run (agentOf). */ -export function buildSnapshot(terminals: readonly HcTerminal[], layout: unknown, version: string, firstTurn?: FirstTurn, ovisDoc?: unknown, sessionOf?: SessionOf): SessionSnapshot { +export function buildSnapshot(terminals: readonly HcTerminal[], layout: unknown, version: string, firstTurn?: FirstTurn, ovisDoc?: unknown, sessionOf?: SessionOf, host: CoreHost = coreHost(false)): SessionSnapshot { const workspaces: WorkspaceInfo[] = []; const tabs: TabInfo[] = []; const panes: PaneInfo[] = []; @@ -473,7 +510,7 @@ export function buildSnapshot(terminals: readonly HcTerminal[], layout: unknown, for (const [tabIndex, tab] of group.tabs.entries()) { const byAddress = new Map(tab.terminals.map((terminal) => [terminal.address, terminal])); const cut = place(tab.root ?? sideBySide(tab.terminals.map((terminal) => terminal.address))); - const tabPanes = cut.panes.map(({ pane_id }) => paneOf(byAddress.get(pane_id)!, group.id, tab.id, shown, firstTurn, sessionOf)); + const tabPanes = cut.panes.map(({ pane_id }) => paneOf(byAddress.get(pane_id)!, group.id, tab.id, shown, host, firstTurn, sessionOf)); groupPanes.push(...tabPanes); const alone = zoomed.get(tab.id); const focused = alone ?? tabPanes[0]!.pane_id;