Skip to content
Closed
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
68 changes: 68 additions & 0 deletions fixtures/harness/opencode/spawned.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
{
"adapter": "opencode",
"capturedAt": "2026-10-17T12:00:00.000Z",
"frozenNow": 1792238400000,
"processes": [
{
"pid": 71001,
"ppid": 1,
"tty": "ttys010",
"command": "opencode",
"cwd": "/proj/alpha",
"startTime": "2026-10-17T11:40:00.000Z"
},
{
"pid": 75800,
"ppid": 71001,
"tty": "ttys010",
"command": "opencode",
"cwd": "/proj/alpha",
"startTime": "2026-10-17T11:55:00.000Z"
},
{
"pid": 71002,
"ppid": 1,
"tty": "ttys011",
"command": "opencode",
"cwd": "/proj/beta",
"startTime": "2026-10-17T11:45:00.000Z"
}
],
"home": {},
"sqlite": {
".local/share/opencode/opencode.db": [
"CREATE TABLE \"session\" (\"id\" TEXT PRIMARY KEY, \"directory\" TEXT NOT NULL, \"title\" TEXT, \"time_created\" INTEGER NOT NULL);",
"CREATE TABLE \"message\" (\"id\" TEXT PRIMARY KEY, \"session_id\" TEXT NOT NULL, \"time_created\" INTEGER NOT NULL, \"time_updated\" INTEGER NOT NULL, \"data\" TEXT NOT NULL);",
"CREATE TABLE \"part\" (\"id\" TEXT PRIMARY KEY, \"message_id\" TEXT NOT NULL, \"session_id\" TEXT NOT NULL, \"time_created\" INTEGER NOT NULL, \"time_updated\" INTEGER NOT NULL, \"data\" TEXT NOT NULL);",
"INSERT INTO \"session\" VALUES ('ses-alpha','/proj/alpha','alpha work',1792236600000);",
"INSERT INTO \"session\" VALUES ('ses-spawn','/proj/alpha','slug-lib-75800',1792238160000);",
"INSERT INTO \"session\" VALUES ('ses-beta','/proj/beta','beta session',1792237800000);",
"INSERT INTO \"message\" VALUES ('m-b1','ses-beta',1792237800000,1792237800000,'{\"role\":\"user\"}');",
"INSERT INTO \"part\" VALUES ('p-b1','m-b1','ses-beta',1792237801000,1792237801000,'{\"type\":\"text\",\"text\":\"beta ask\"}');"
]
},
"expected": [
{
"name": "alpha-71001",
"type": "opencode",
"status": "running",
"summary": "slug-lib-75800",
"pid": 71001,
"projectPath": "/proj/alpha",
"sessionId": "ses-spawn",
"lastActive": "2026-10-17T11:56:00.000Z",
"sessionFilePath": "$FIXTURE_HOME/.local/share/opencode/opencode.db::ses-spawn"
},
{
"name": "beta-71002",
"type": "opencode",
"status": "idle",
"summary": "beta ask",
"pid": 71002,
"projectPath": "/proj/beta",
"sessionId": "ses-beta",
"lastActive": "2026-10-17T11:50:00.000Z",
"sessionFilePath": "$FIXTURE_HOME/.local/share/opencode/opencode.db::ses-beta"
}
]
}
69 changes: 60 additions & 9 deletions packages/agent-manager/src/AgentManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,17 @@ import {
snapshotOptions,
type ProcessSnapshotCapture,
} from "./utils/process.js";
import { UNREGISTERED_PREFIX } from "./utils/matching.js";
import {
findMatchingHerdrPane,
fetchHerdrAgentPanes,
herdrPaneToRuntimeRef,
type HerdrAgentPane,
} from "./runtime/herdr/HerdrAgentDiscovery.js";

/** Generated `<folder>-<pid>` names — the shape a discovery-synthesized identity takes. */
const SYNTHESIZED_NAME = /-\d+$/;

/** Discovery patterns of one adapter: exact names plus basename prefixes. */
function adapterPatterns(adapter: AgentAdapter): {
names: string[];
Expand Down Expand Up @@ -289,22 +293,69 @@ export class AgentManager {
}

const identityKey = (type: string, pid: number): string => `${type}:${pid}`;
const registryEntries = this.registry.list();
const preExistingByIdentity = new Map(
this.registry.list().map((entry) => [identityKey(entry.type, entry.pid), entry]),
registryEntries.map((entry) => [identityKey(entry.type, entry.pid), entry]),
);
const detectedKeys = new Set(allAgents.map((agent) => identityKey(agent.type, agent.pid)));

// Fallback indexes let an assigned name survive pid drift — e.g. opencode
// re-execs or the pid captured at `agent start` was a short-lived child
// (issue #402). Entries whose pid is itself detected already win by
// identity, so they never participate; a key resolves only when a single
// entry claims it, and each entry is adopted by at most one agent.
const bySession = new Map<string, RegistryEntry | null>();
const byCwd = new Map<string, RegistryEntry | null>();
const addUnique = (
map: Map<string, RegistryEntry | null>,
key: string,
entry: RegistryEntry,
): void => {
if (map.has(key)) {
map.set(key, null);
} else {
map.set(key, entry);
}
};
for (const entry of registryEntries) {
if (detectedKeys.has(identityKey(entry.type, entry.pid))) continue;
if (entry.sessionId) addUnique(bySession, `${entry.type}:${entry.sessionId}`, entry);
if (entry.cwd) addUnique(byCwd, `${entry.type}:${entry.cwd}`, entry);
}
const claimedEntries = new Set<RegistryEntry>();
const matchRegistryEntry = (agent: AgentInfo): RegistryEntry | undefined => {
const direct = preExistingByIdentity.get(identityKey(agent.type, agent.pid));
if (direct) return direct;
const fallback =
(agent.sessionId ? bySession.get(`${agent.type}:${agent.sessionId}`) : undefined) ??
(agent.projectPath ? byCwd.get(`${agent.type}:${agent.projectPath}`) : undefined);
if (!fallback || claimedEntries.has(fallback)) return undefined;
claimedEntries.add(fallback);
return fallback;
};

const herdrPanes = allAgents.length > 0 ? await this.resolveHerdrPanes() : [];
const entries = allAgents.map((agent) =>
this.toRegistryEntry(
agent,
preExistingByIdentity.get(identityKey(agent.type, agent.pid)),
herdrPanes,
),
const matched = allAgents.map((agent) => ({ agent, entry: matchRegistryEntry(agent) }));
// No registry entry claims this agent — it was never registered through
// the manager (or was registered under a divergent HOME, issue #402).
// Mark the synthesized identity so it can't masquerade as a name the
// user assigned: "unregistered:<folder>-<pid>" is self-explaining in
// every surface that shows it.
for (const { agent, entry } of matched) {
if (!entry && SYNTHESIZED_NAME.test(agent.name) && !agent.name.startsWith(UNREGISTERED_PREFIX)) {
agent.name = `${UNREGISTERED_PREFIX}${agent.name}`;
}
}
const entries = matched.map(({ agent, entry }) =>
this.toRegistryEntry(agent, entry, herdrPanes),
);
if (entries.length > 0) this.registry.registerBatch(entries);
for (const { agent, entry } of matched) {
if (entry && entry.pid !== agent.pid) this.registry.remove(entry.type, entry.pid);
}
this.registry.pruneIfDue();

for (const agent of allAgents) {
const entry = preExistingByIdentity.get(identityKey(agent.type, agent.pid));
for (const { agent, entry } of matched) {
if (entry) {
agent.name = entry.name;
agent.pinned = entry.pinned;
Expand Down
159 changes: 155 additions & 4 deletions packages/agent-manager/src/__tests__/AgentManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -680,7 +680,7 @@ describe("AgentManager", () => {
await scopedManager.listAgents();

expect(registry.list()[0]).toMatchObject({
name: "ai-devkit-44290",
name: "unregistered:ai-devkit-44290",
type: "codex",
pid: process.pid,
runtime: "herdr",
Expand All @@ -689,7 +689,7 @@ describe("AgentManager", () => {
paneId: "w31:p1",
workspaceId: "w31",
tabId: "w31:t1",
agentName: "ai-devkit-44290",
agentName: "unregistered:ai-devkit-44290",
},
});
});
Expand Down Expand Up @@ -815,12 +815,12 @@ describe("AgentManager", () => {
await scopedManager.listAgents();

expect(fetchHerdrPanes).toHaveBeenCalledOnce();
expect(registry.lookup("ai-devkit-64904")).toMatchObject({
expect(registry.lookup("unregistered:ai-devkit-64904")).toMatchObject({
runtime: "herdr",
runtimeRef: {
session: "default",
paneId: "w32:p1",
agentName: "ai-devkit-64904",
agentName: "unregistered:ai-devkit-64904",
},
});
});
Expand Down Expand Up @@ -991,6 +991,157 @@ describe("AgentManager", () => {
});
});

it("keeps the assigned --name when detection reports a different pid (issue #402)", async () => {
// `agent start --type opencode --name tests` registers the pid found at
// start, but later detection reports the agent under another pid
// (opencode re-execs / hands off to a server child). The registry name
// must still win over the generated `folder-pid` fallback name.
registry.register({
name: "tests",
type: "opencode",
pid: 999_999,
runtime: "tmux",
runtimeRef: { session: "tests" },
cwd: "/cwd/slug-lib",
startedAt: "2026-10-17T09:00:00.000Z",
sessionId: "",
sessionFilePath: "",
});
scopedManager.registerAdapter(
new MockAdapter("opencode", [
createMockAgent({
name: "slug-lib-75800",
type: "opencode",
pid: process.pid,
projectPath: "/cwd/slug-lib",
sessionId: "sess-42",
sessionFilePath: "/db::sess-42",
}),
]),
);

const agents = await scopedManager.listAgents();

expect(agents.map((a) => a.name)).toEqual(["tests"]);
// Every surface resolves the adopted identity: name match, and the
// re-homed registry row keeps its tmux runtimeRef for send/kill.
expect(scopedManager.resolveAgent("tests", agents)).toBe(agents[0]);
expect(registry.list()).toHaveLength(1);
expect(registry.lookup("tests")).toMatchObject({
pid: process.pid,
sessionId: "sess-42",
sessionFilePath: "/db::sess-42",
runtimeRef: { session: "tests" },
startedAt: "2026-10-17T09:00:00.000Z",
});
});

it("re-homes a registry entry onto the same session detected under a new pid", async () => {
// The session id captured by a previous sweep still identifies the
// agent even when both pid and cwd drifted (moved worktree, re-exec).
registry.register({
name: "tests",
type: "opencode",
pid: 999_999,
runtime: "tmux",
runtimeRef: { session: "tests" },
cwd: "/old/path",
startedAt: "2026-10-17T09:00:00.000Z",
sessionId: "sess-42",
sessionFilePath: "/db::sess-42",
});
scopedManager.registerAdapter(
new MockAdapter("opencode", [
createMockAgent({
name: "new-path-75800",
type: "opencode",
pid: process.pid,
projectPath: "/new/path",
sessionId: "sess-42",
sessionFilePath: "/db::sess-42",
}),
]),
);

const agents = await scopedManager.listAgents();

expect(agents[0].name).toBe("tests");
expect(registry.lookup("tests")).toMatchObject({ pid: process.pid, sessionId: "sess-42" });
});

it("does not adopt a name when the type+cwd pair is ambiguous", async () => {
for (const [name, pid] of [
["tests-a", 999_998],
["tests-b", 999_997],
] as const) {
registry.register({
name,
type: "opencode",
pid,
runtime: "tmux",
runtimeRef: null,
cwd: "/cwd/slug-lib",
startedAt: "2026-10-17T09:00:00.000Z",
sessionId: "",
sessionFilePath: "",
});
}
scopedManager.registerAdapter(
new MockAdapter("opencode", [
createMockAgent({
name: "slug-lib-75800",
type: "opencode",
pid: process.pid,
projectPath: "/cwd/slug-lib",
sessionId: "sess-42",
}),
]),
);

const agents = await scopedManager.listAgents();

expect(agents[0].name).toBe("unregistered:slug-lib-75800");
});

it("does not steal the name when the registered pid is itself detected", async () => {
// Two opencode procs share a project dir: the registered pid keeps the
// name; the unregistered sibling must not adopt it via the cwd fallback.
registry.register({
name: "tests",
type: "opencode",
pid: process.pid,
runtime: "tmux",
runtimeRef: { session: "tests" },
cwd: "/cwd/slug-lib",
startedAt: "2026-10-17T09:00:00.000Z",
sessionId: "sess-41",
sessionFilePath: "",
});
scopedManager.registerAdapter(
new MockAdapter("opencode", [
createMockAgent({
name: "slug-lib-75800",
type: "opencode",
pid: process.pid,
projectPath: "/cwd/slug-lib",
sessionId: "sess-41",
}),
createMockAgent({
name: "slug-lib-99998",
type: "opencode",
pid: 999_998,
projectPath: "/cwd/slug-lib",
sessionId: "sess-40",
}),
]),
);

const agents = await scopedManager.listAgents();

expect(agents.find((a) => a.pid === process.pid)?.name).toBe("tests");
expect(agents.find((a) => a.pid === 999_998)?.name).toBe("unregistered:slug-lib-99998");
});

it("writes a fresh startedAt for new entries", async () => {
const before = new Date().toISOString();
scopedManager.registerAdapter(
Expand Down
Loading
Loading