diff --git a/fixtures/harness/opencode/spawned.json b/fixtures/harness/opencode/spawned.json new file mode 100644 index 000000000..481fc3731 --- /dev/null +++ b/fixtures/harness/opencode/spawned.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/packages/agent-manager/src/AgentManager.ts b/packages/agent-manager/src/AgentManager.ts index ea5852e8c..f82d6e85f 100644 --- a/packages/agent-manager/src/AgentManager.ts +++ b/packages/agent-manager/src/AgentManager.ts @@ -27,6 +27,7 @@ import { snapshotOptions, type ProcessSnapshotCapture, } from "./utils/process.js"; +import { UNREGISTERED_PREFIX } from "./utils/matching.js"; import { findMatchingHerdrPane, fetchHerdrAgentPanes, @@ -34,6 +35,9 @@ import { type HerdrAgentPane, } from "./runtime/herdr/HerdrAgentDiscovery.js"; +/** Generated `-` 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[]; @@ -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(); + const byCwd = new Map(); + const addUnique = ( + map: Map, + 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(); + 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:-" 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; diff --git a/packages/agent-manager/src/__tests__/AgentManager.test.ts b/packages/agent-manager/src/__tests__/AgentManager.test.ts index c9c13e951..9ab2fe3f3 100644 --- a/packages/agent-manager/src/__tests__/AgentManager.test.ts +++ b/packages/agent-manager/src/__tests__/AgentManager.test.ts @@ -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", @@ -689,7 +689,7 @@ describe("AgentManager", () => { paneId: "w31:p1", workspaceId: "w31", tabId: "w31:t1", - agentName: "ai-devkit-44290", + agentName: "unregistered:ai-devkit-44290", }, }); }); @@ -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", }, }); }); @@ -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( diff --git a/packages/agent-manager/src/__tests__/harnesses/opencode/OpenCodeAdapter.test.ts b/packages/agent-manager/src/__tests__/harnesses/opencode/OpenCodeAdapter.test.ts index 5dd701665..c7b9e7215 100644 --- a/packages/agent-manager/src/__tests__/harnesses/opencode/OpenCodeAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/harnesses/opencode/OpenCodeAdapter.test.ts @@ -30,6 +30,45 @@ describe("OpenCodeAdapter", () => { expect(adapter.canHandle({ pid: 3, command: "node /repo/opencode-plugin.js" })).toBe(false); }); + it("folds spawned child opencode processes into their parent agent (issue #402)", async () => { + // Chatting in an OpenCode session can spawn another session — the child + // process is that session's backend, not a new agent, so the manager must + // not list it under its raw auto-generated name. + const now = Date.now(); + writeDatabase(dbPath, { + sessions: [ + { id: "sess-parent", directory: "/repo", timeCreated: now - 60_000, title: "main session" }, + { + id: "sess-child", + directory: "/repo", + timeCreated: now - 10_000, + title: "slug-lib-75800", + }, + ], + }); + + const parent: ProcessInfo = { pid: 100, command: "opencode", cwd: "/repo", tty: "ttys001" }; + const child: ProcessInfo = { + pid: 75800, + ppid: 100, + command: "opencode", + cwd: "/repo", + tty: "ttys001", + }; + const agents = await adapter.detectAgents({ processes: [parent, child] }); + + expect(agents.map((a) => a.pid)).toEqual([100]); + expect(agents).toHaveLength(1); + }); + + it("keeps sibling opencode processes with no ancestry link as separate agents", async () => { + const a: ProcessInfo = { pid: 100, command: "opencode", cwd: "/repo", tty: "ttys001" }; + const b: ProcessInfo = { pid: 200, command: "opencode", cwd: "/repo", tty: "ttys002" }; + const agents = await adapter.detectAgents({ processes: [a, b] }); + + expect(agents.map((agent) => agent.pid).sort()).toEqual([100, 200]); + }); + it("returns process-only agents when the database is unavailable", async () => { const agents = await adapter.detectAgents({ processes: [{ pid: 100, command: "opencode", cwd: "/repo", tty: "ttys001" }], @@ -93,6 +132,47 @@ describe("OpenCodeAdapter", () => { }); }); + it("keeps the session title as summary metadata, never as the agent name", async () => { + // Issue #402: authenticated OpenCode writes an auto-generated title like + // "slug-lib-75800" into session.title. Detection must leave the harness + // title out of `name` (registry assigns identity in the manager merge). + const now = Date.now(); + writeDatabase(dbPath, { + sessions: [ + { + id: "sess-titled", + directory: "/repo", + timeCreated: now - 60_000, + title: "slug-lib-75800", + }, + ], + }); + + const process: ProcessInfo = { pid: 75800, command: "opencode", cwd: "/repo", tty: "ttys009" }; + const [agent] = await adapter.detectAgents({ processes: [process] }); + + expect(agent.name).toBe("repo-75800"); + expect(agent.name).not.toBe("slug-lib-75800"); + expect(agent.summary).toBe("slug-lib-75800"); + + const [summary] = await adapter.listSessions(); + expect(summary.firstUserMessage).toBe("slug-lib-75800"); + }); + + it("detects agents against a session table without a title column", async () => { + const now = Date.now(); + writeDatabase(dbPath, { + legacySchema: true, + sessions: [{ id: "sess-legacy", directory: "/repo", timeCreated: now - 60_000 }], + }); + + const process: ProcessInfo = { pid: 300, command: "opencode", cwd: "/repo", tty: "ttys010" }; + const [agent] = await adapter.detectAgents({ processes: [process] }); + + expect(agent.sessionId).toBe("sess-legacy"); + expect(agent.summary).toBe("OpenCode session active"); + }); + it("returns conversation messages for encoded OpenCode session refs", () => { writeDatabase(dbPath, { sessions: [{ id: "sess-abc", directory: "/repo", timeCreated: Date.now() }], @@ -144,7 +224,9 @@ describe("OpenCodeAdapter", () => { function writeDatabase( filePath: string, data: { - sessions: Array<{ id: string; directory: string; timeCreated: number }>; + sessions: Array<{ id: string; directory: string; timeCreated: number; title?: string }>; + /** When true, create the pre-title schema (no `title` column). */ + legacySchema?: boolean; messages?: Array<{ id: string; sessionId: string; @@ -165,17 +247,26 @@ function writeDatabase( }, ): void { const db = new Database(filePath); + const sessionTable = data.legacySchema + ? "CREATE TABLE session (id TEXT, directory TEXT, time_created INTEGER)" + : "CREATE TABLE session (id TEXT, directory TEXT, title TEXT, time_created INTEGER)"; db.exec(` - CREATE TABLE session (id TEXT, directory TEXT, time_created INTEGER); + ${sessionTable}; CREATE TABLE message (id TEXT, session_id TEXT, data TEXT, time_created INTEGER, time_updated INTEGER); CREATE TABLE part (message_id TEXT, session_id TEXT, data TEXT, time_created INTEGER); `); const insertSession = db.prepare( - "INSERT INTO session (id, directory, time_created) VALUES (?, ?, ?)", + data.legacySchema + ? "INSERT INTO session (id, directory, time_created) VALUES (?, ?, ?)" + : "INSERT INTO session (id, directory, title, time_created) VALUES (?, ?, ?, ?)", ); for (const session of data.sessions) { - insertSession.run(session.id, session.directory, session.timeCreated); + if (data.legacySchema) { + insertSession.run(session.id, session.directory, session.timeCreated); + } else { + insertSession.run(session.id, session.directory, session.title ?? null, session.timeCreated); + } } const insertMessage = db.prepare( diff --git a/packages/agent-manager/src/__tests__/runtime/ManagedAgentRuntime.test.ts b/packages/agent-manager/src/__tests__/runtime/ManagedAgentRuntime.test.ts index 6d9c7f4ef..5d7d5398f 100644 --- a/packages/agent-manager/src/__tests__/runtime/ManagedAgentRuntime.test.ts +++ b/packages/agent-manager/src/__tests__/runtime/ManagedAgentRuntime.test.ts @@ -46,11 +46,14 @@ function makeTmux(over: Partial = {}): TmuxManager { } function makeRegistry(over: Partial = {}): AgentRegistry { + const store = new Map(); return { prune: vi.fn(), - lookup: vi.fn().mockReturnValue(null), - list: vi.fn().mockReturnValue([]), - register: vi.fn(), + lookup: vi.fn((name: string) => store.get(name) ?? null), + list: vi.fn(() => [...store.values()]), + register: vi.fn((entry: RegistryEntry) => { + store.set(entry.name, entry); + }), isAlive: vi.fn().mockReturnValue(false), ...over, } as unknown as AgentRegistry; @@ -569,17 +572,71 @@ describe("startAgent", () => { expect(tmux.killSession).toHaveBeenLastCalledWith("agent1"); }); + it("refuses to start under a divergent HOME when the account store exists (issue #402)", async () => { + // Fake-HOME repro: the registry/daemon live under the account home, so a + // sandboxed start would write where no client looks — fail loudly before + // creating any session instead of a false success. + const accountHome = fs.mkdtempSync(path.join(os.tmpdir(), "real-home-")); + fs.mkdirSync(path.join(accountHome, ".ai-devkit"), { recursive: true }); + const tmux = makeTmux(); + const registry = makeRegistry(); + + await expect( + startAgent(startOpts, { + tmux, + registry, + checkHomeScope: () => ({ envHome: "/tmp/fakehome", accountHome }), + }), + ).rejects.toThrow(/divergent HOME/); + expect(tmux.createSession).not.toHaveBeenCalled(); + + fs.rmSync(accountHome, { recursive: true, force: true }); + }); + + it("warns but proceeds under a divergent HOME when the account has no store", async () => { + const accountHome = fs.mkdtempSync(path.join(os.tmpdir(), "fresh-home-")); + const tmux = makeTmux(); + const registry = makeRegistry(); + const onWarning = vi.fn(); + + await startAgent(startOpts, { + tmux, + registry, + onWarning, + checkHomeScope: () => ({ envHome: "/tmp/fakehome", accountHome }), + }); + + expect(tmux.createSession).toHaveBeenCalledWith("agent1", "/work"); + expect(onWarning).toHaveBeenCalledWith(expect.stringContaining("/tmp/fakehome")); + + fs.rmSync(accountHome, { recursive: true, force: true }); + }); + + it("fails loudly when the registry write does not round-trip", async () => { + const tmux = makeTmux(); + const registry = makeRegistry({ + lookup: vi.fn().mockReturnValue(null), + }); + + await expect(startAgent(startOpts, { tmux, registry })).rejects.toThrow(/round-trip/); + }); + it("prunes registry before checking for name collision", async () => { const tmux = makeTmux(); const registry = makeRegistry(); const order: string[] = []; (registry.prune as any).mockImplementation(() => order.push("prune")); - (registry.lookup as any).mockImplementation(() => { + (registry.lookup as any).mockImplementation((name: string) => { order.push("lookup"); - return null; + return ( + (registry.register as any).mock.calls + .map((call: RegistryEntry[]) => call[0]) + .find((entry: RegistryEntry) => entry.name === name) ?? null + ); }); await startAgent(startOpts, { tmux, registry }); - expect(order).toEqual(["prune", "lookup"]); + // First lookup checks name collision; second verifies the write round-trip. + expect(order).toEqual(["prune", "lookup", "lookup"]); }); }); diff --git a/packages/agent-manager/src/harnesses/opencode/OpenCodeAdapter.ts b/packages/agent-manager/src/harnesses/opencode/OpenCodeAdapter.ts index 5ef37920d..112566688 100644 --- a/packages/agent-manager/src/harnesses/opencode/OpenCodeAdapter.ts +++ b/packages/agent-manager/src/harnesses/opencode/OpenCodeAdapter.ts @@ -24,7 +24,7 @@ import type { import { OpenCodeAgentMapper } from "./OpenCodeAgentMapper.js"; import { OpenCodeSessionLocator } from "./OpenCodeSessionLocator.js"; import { OpenCodeSessionParser } from "./OpenCodeSessionParser.js"; -import { findHarnessProcesses, matchesExecutable } from "../shared.js"; +import { dropDescendantProcesses, findHarnessProcesses, matchesExecutable } from "../shared.js"; const SESSION_REF_SEP = "::"; @@ -65,13 +65,16 @@ export class OpenCodeAdapter implements AgentAdapter { async detectAgents(context?: AgentDetectionContext): Promise { const { processes } = await findHarnessProcesses(this, context); - if (processes.length === 0) return []; + // A session that spawns another session mid-chat surfaces as a child + // opencode process; it is that session's backend, not a separate agent. + const roots = dropDescendantProcesses(processes); + if (roots.length === 0) return []; const db = this.locator.openDb(); - if (!db) return processes.map((process) => this.mapper.mapProcessOnlyAgent(process)); + if (!db) return roots.map((process) => this.mapper.mapProcessOnlyAgent(process)); const agents: AgentInfo[] = []; - for (const proc of processes) { + for (const proc of roots) { if (!proc.cwd) { agents.push(this.mapper.mapProcessOnlyAgent(proc)); continue; diff --git a/packages/agent-manager/src/harnesses/opencode/OpenCodeAgentMapper.ts b/packages/agent-manager/src/harnesses/opencode/OpenCodeAgentMapper.ts index 53c0acee3..5d6e5b70d 100644 --- a/packages/agent-manager/src/harnesses/opencode/OpenCodeAgentMapper.ts +++ b/packages/agent-manager/src/harnesses/opencode/OpenCodeAgentMapper.ts @@ -26,7 +26,7 @@ export class OpenCodeAgentMapper { name: generateAgentName(session.directory || proc.cwd || "", proc.pid), type: "opencode", status: this.determineStatus(stats, lastActive), - summary: stats.summary || "OpenCode session active", + summary: stats.summary || session.title || "OpenCode session active", pid: proc.pid, projectPath: session.directory || proc.cwd || "", sessionId: session.sessionId, diff --git a/packages/agent-manager/src/harnesses/opencode/OpenCodeSessionLocator.ts b/packages/agent-manager/src/harnesses/opencode/OpenCodeSessionLocator.ts index 24a4f2e62..346c0093a 100644 --- a/packages/agent-manager/src/harnesses/opencode/OpenCodeSessionLocator.ts +++ b/packages/agent-manager/src/harnesses/opencode/OpenCodeSessionLocator.ts @@ -15,12 +15,15 @@ export interface OpenCodeSession { sessionId: string; directory: string; timeCreated: number; + /** OpenCode's auto-generated session title (e.g. "slug-lib-75800") — metadata only, never identity. */ + title?: string; } interface OpenCodeSessionRow { id: string; directory: string; timeCreated: number; + title?: string; } export class OpenCodeSessionLocator { @@ -64,22 +67,43 @@ export class OpenCodeSessionLocator { } findSessionForDirectory(db: Database.Database, directory: string): OpenCodeSession | null { - try { - const row = db - .prepare<[string], { id: string; directory: string; time_created: number }>(` - SELECT id, directory, time_created - FROM session - WHERE directory = ? - ORDER BY time_created DESC - LIMIT 1 - `) - .get(directory); - - if (!row) return null; - return { sessionId: row.id, directory: row.directory, timeCreated: row.time_created }; - } catch { - return null; + const row = this.querySession( + db, + "WHERE directory = ? ORDER BY time_created DESC LIMIT 1", + directory, + ); + if (!row) return null; + return { + sessionId: row.id, + directory: row.directory, + timeCreated: row.time_created ?? 0, + title: row.title ?? undefined, + }; + } + + /** + * Newer OpenCode schemas carry a `title` column (auto-generated names like + * "slug-lib-75800"); older ones do not. Try the wide select first, then + * fall back so either schema works. + */ + private querySession( + db: Database.Database, + where: string, + ...params: string[] + ): { id: string; directory: string; time_created: number; title: string | null } | undefined { + for (const cols of ["id, directory, time_created, title", "id, directory, time_created"]) { + try { + return db + .prepare< + string[], + { id: string; directory: string; time_created: number; title: string | null } + >(`SELECT ${cols} FROM session ${where}`) + .get(...params); + } catch { + /* retry with the narrower column set */ + } } + return undefined; } listSessions(opts?: ListSessionsOptions): SessionSummary[] { @@ -87,13 +111,7 @@ export class OpenCodeSessionLocator { if (!db) return []; try { - const rows = db - .prepare<[], OpenCodeSessionRow>(` - SELECT id, directory, time_created AS timeCreated - FROM session - ORDER BY time_created DESC - `) - .all(); + const rows = this.querySessions("ORDER BY time_created DESC"); const summaries: SessionSummary[] = []; @@ -115,13 +133,8 @@ export class OpenCodeSessionLocator { if (!db) return []; try { - const row = db - .prepare<[string], OpenCodeSessionRow>(` - SELECT id, directory, time_created AS timeCreated - FROM session - WHERE id = ? - `) - .get(sessionId); + const rows = this.querySessions("WHERE id = ?", sessionId); + const row = rows[0]; return row ? [this.toSessionSummary(db, row)] : []; } catch { this.close(); @@ -129,6 +142,24 @@ export class OpenCodeSessionLocator { } } + private querySessions(where: string, ...params: string[]): OpenCodeSessionRow[] { + const db = this.openDb(); + if (!db) return []; + for (const cols of [ + "id, directory, title, time_created AS timeCreated", + "id, directory, time_created AS timeCreated", + ]) { + try { + return db + .prepare(`SELECT ${cols} FROM session ${where}`) + .all(...params); + } catch { + /* retry with the narrower column set */ + } + } + return []; + } + private toSessionSummary(db: Database.Database, row: OpenCodeSessionRow): SessionSummary { const stats = this.parser.getSessionStats(db, row.id); const lastActive = @@ -138,7 +169,7 @@ export class OpenCodeSessionLocator { type: "opencode", sessionId: row.id, cwd: row.directory, - firstUserMessage: stats.summary, + firstUserMessage: stats.summary || row.title || "", lastActive, startedAt: new Date(row.timeCreated), sessionFilePath: encodeOpenCodeSessionRef(this.dbPath, row.id), diff --git a/packages/agent-manager/src/harnesses/shared.ts b/packages/agent-manager/src/harnesses/shared.ts index 784fb1cd9..2a083f0f6 100644 --- a/packages/agent-manager/src/harnesses/shared.ts +++ b/packages/agent-manager/src/harnesses/shared.ts @@ -59,6 +59,29 @@ export async function findHarnessProcesses( return { relevant, processes: Array.from(byPid.values()) }; } +/** + * Drop processes that descend from another process in the list — e.g. an + * OpenCode session spawning a child session mid-chat (issue #402). The + * ancestor is the agent; the descendant is that session's backend, not a + * separate agent. Ancestry is resolved by walking `ppid` links within the + * list itself, so a child separated by an unlisted intermediate process is + * left alone rather than guessed at. + */ +export function dropDescendantProcesses(processes: readonly T[]): T[] { + const byPid = new Map(processes.map((process) => [process.pid, process])); + const hasListedAncestor = (proc: T): boolean => { + const seen = new Set([proc.pid]); + let current = proc.ppid; + while (current !== undefined && !seen.has(current)) { + seen.add(current); + if (byPid.has(current)) return true; + current = byPid.get(current)?.ppid; + } + return false; + }; + return processes.filter((proc) => !hasListedAncestor(proc)); +} + /** Whether argv[0]'s basename is `name` or `name.exe`. */ export function matchesExecutable(command: string, name: string): boolean { const base = executableBasename(command); diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index 9bd15f1e8..5e8ac6ec8 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -56,6 +56,7 @@ export { AgentPidPollTimeoutError, AgentRuntimeUnavailableError, AgentTerminalNotFoundError, + AgentHomeDivergenceError, DEFAULT_PID_POLL_TIMEOUT_MS, } from "./runtime/ManagedAgentRuntime.js"; diff --git a/packages/agent-manager/src/runtime/ManagedAgentRuntime.ts b/packages/agent-manager/src/runtime/ManagedAgentRuntime.ts index 318e3f43e..952faf476 100644 --- a/packages/agent-manager/src/runtime/ManagedAgentRuntime.ts +++ b/packages/agent-manager/src/runtime/ManagedAgentRuntime.ts @@ -12,14 +12,18 @@ import { DEFAULT_PID_POLL_INTERVAL_MS, DEFAULT_PID_POLL_TIMEOUT_MS } from "./tmu import { TmuxAgentRuntime } from "./tmux/TmuxAgentRuntime.js"; import type { InteractiveAgentRuntime } from "./types.js"; import { + AgentHomeDivergenceError, AgentNameInUseError, AgentPidPollTimeoutError, AgentRuntimeUnavailableError, AgentTerminalNotFoundError, TmuxUnavailableError, } from "./errors.js"; +import { homeScopeMismatch, identityStoreExistsUnder } from "../utils/homeScope.js"; +import type { HomeScopeMismatch } from "../utils/homeScope.js"; export { + AgentHomeDivergenceError, AgentNameInUseError, AgentPidPollTimeoutError, AgentRuntimeUnavailableError, @@ -43,6 +47,8 @@ export interface StartAgentDeps { registry?: AgentRegistry; /** Called for non-fatal events (e.g., replacing an orphan tmux session). */ onWarning?: (message: string) => void; + /** Injectable for tests — defaults to the passwd-home vs $HOME comparison. */ + checkHomeScope?: () => HomeScopeMismatch | null; } export interface StopAgentDeps { @@ -110,6 +116,26 @@ export async function startAgent( throw new AgentNameInUseError(opts.name, existing.pid); } + // Split-brain guard (issue #402): registry and daemon socket paths resolve + // from this process's $HOME. Under a sandboxed/nested start with a divergent + // HOME the write would land where the daemon and other clients never look — + // a false success. Refuse when the authoritative store lives elsewhere; + // warn when the account has no store yet (self-consistent sandbox). + const mismatch = (deps.checkHomeScope ?? homeScopeMismatch)(); + if (mismatch) { + const message = + `agent start is running under HOME="${mismatch.envHome}" while the account home is ` + + `"${mismatch.accountHome}". `; + if (identityStoreExistsUnder(mismatch.accountHome)) { + throw new AgentHomeDivergenceError(mismatch.envHome, mismatch.accountHome); + } + deps.onWarning?.( + message + + "Agents registered here are only visible to clients sharing this HOME — " + + "other environments will list them as unregistered.", + ); + } + if (provider === "tmux" && tmux && (await tmux.sessionExists(opts.name))) { deps.onWarning?.( `tmux session "${opts.name}" already exists but has no live registry entry — it will be replaced.`, @@ -144,6 +170,17 @@ export async function startAgent( pinned: false, }; registry.register(entry); + + // Round-trip the identity write through the same authority list/send/kill + // use: if the registered name cannot be read back, report failure rather + // than a false success (issue #402). + const roundTripped = registry.lookup(opts.name); + if (!roundTripped || roundTripped.pid !== result.pid) { + throw new Error( + `Agent "${opts.name}" started (PID ${result.pid}) but the registry write did not ` + + "round-trip — the agent will appear under a synthesized unregistered identity.", + ); + } return entry; } diff --git a/packages/agent-manager/src/runtime/errors.ts b/packages/agent-manager/src/runtime/errors.ts index 7c4e08539..3861e6973 100644 --- a/packages/agent-manager/src/runtime/errors.ts +++ b/packages/agent-manager/src/runtime/errors.ts @@ -48,3 +48,19 @@ export class AgentTerminalNotFoundError extends Error { this.name = "AgentTerminalNotFoundError"; } } + +export class AgentHomeDivergenceError extends Error { + constructor( + public envHome: string, + public accountHome: string, + ) { + super( + `Refusing to start under divergent HOME: this process resolves HOME="${envHome}" ` + + `but the account home is "${accountHome}" — where the agent registry and devkitd ` + + "socket live. A registry write here would be invisible to the daemon and other " + + 'clients, so the agent would list under a synthesized "unregistered:-" ' + + "identity. Unset HOME or start the agent from a non-sandboxed environment.", + ); + this.name = "AgentHomeDivergenceError"; + } +} diff --git a/packages/agent-manager/src/utils/AgentRegistry.ts b/packages/agent-manager/src/utils/AgentRegistry.ts index 4ca569828..664a032e2 100644 --- a/packages/agent-manager/src/utils/AgentRegistry.ts +++ b/packages/agent-manager/src/utils/AgentRegistry.ts @@ -339,6 +339,17 @@ export class AgentRegistry { return this.findByName(name) ?? null; } + /** + * Delete a single entry by (type, pid) identity. Used when an entry is + * re-homed onto a different pid so the name keeps a single owner. + */ + remove(type: AgentType, pid: number): void { + if (this.readonly) { + throw new Error("Agent registry is readonly; cannot remove entries."); + } + this.db.execute("DELETE FROM agents WHERE type = ? AND pid = ?", [type, pid]); + } + list(): RegistryEntry[] { const rows = this.db.query( "SELECT * FROM agents ORDER BY started_at ASC, name ASC", diff --git a/packages/agent-manager/src/utils/homeScope.ts b/packages/agent-manager/src/utils/homeScope.ts new file mode 100644 index 000000000..5f40da535 --- /dev/null +++ b/packages/agent-manager/src/utils/homeScope.ts @@ -0,0 +1,41 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; + +export interface HomeScopeMismatch { + /** HOME the current process resolves (env var — drives registry + socket paths). */ + envHome: string; + /** The account's real home (getpwuid — what the daemon and normal clients use). */ + accountHome: string; +} + +/** + * Every identity-store path — the agent registry db and the devkitd socket — + * is resolved from `$HOME`, which is just an env var. A sandboxed or nested + * `agent start` can overlay it while the daemon and other clients run under + * the account's real home, producing split-brain identity: the write lands + * where nobody looks (issue #402). This compares `$HOME` with the passwd + * home, which ignores the env. + */ +export function homeScopeMismatch( + envHome: string | undefined = process.env.HOME, + accountHome: string | undefined = safeAccountHome(), +): HomeScopeMismatch | null { + if (!envHome || !accountHome) return null; + return path.resolve(envHome) === path.resolve(accountHome) + ? null + : { envHome, accountHome }; +} + +function safeAccountHome(): string | undefined { + try { + return os.userInfo().homedir; + } catch { + return undefined; + } +} + +/** Whether the `.ai-devkit` store (registry db, daemon socket) exists under `home`. */ +export function identityStoreExistsUnder(home: string): boolean { + return fs.existsSync(path.join(home, ".ai-devkit")); +} diff --git a/packages/agent-manager/src/utils/matching.ts b/packages/agent-manager/src/utils/matching.ts index ece70895f..2653e026b 100644 --- a/packages/agent-manager/src/utils/matching.ts +++ b/packages/agent-manager/src/utils/matching.ts @@ -81,6 +81,13 @@ export function matchProcessesToSessions( return results; } +/** + * Prefix marking a discovery-synthesized identity — shown when no registry + * entry claims the agent (never started through the manager, or registered + * under a divergent HOME, issue #402). + */ +export const UNREGISTERED_PREFIX = "unregistered:"; + /** * Generate a deterministic agent name from CWD and PID. * diff --git a/packages/cli/src/commands/agent/start.command.ts b/packages/cli/src/commands/agent/start.command.ts index 69664f1fc..891689b61 100644 --- a/packages/cli/src/commands/agent/start.command.ts +++ b/packages/cli/src/commands/agent/start.command.ts @@ -7,6 +7,7 @@ import { parseTmuxRuntimeRef, startAgent, TmuxUnavailableError, + AgentHomeDivergenceError, AgentNameInUseError, AgentPidPollTimeoutError, AgentRuntimeUnavailableError, @@ -20,6 +21,7 @@ import { AGENT_NAME_REGEX, generateAgentName } from "../../util/agent.js"; import { resolveTmuxInstallInstructions } from "../../util/tmux.js"; import { createTmuxInspectionDeps } from "../../util/tmux-deps.js"; import { ConfigManager } from "../../lib/Config.js"; +import { ensureDaemon } from "@ai-devkit/daemon-client"; import { createDurableAgentService } from "./factory.js"; import { formatCwd, formatPrintProvider } from "./render.js"; @@ -29,6 +31,8 @@ async function reportStartError(err: unknown): Promise { ui.error(`tmux is not installed or not in PATH. ${instructions.message}`); } else if (err instanceof AgentRuntimeUnavailableError) { ui.error(`Herdr runtime is unavailable (${err.reason}): ${err.detail}`); + } else if (err instanceof AgentHomeDivergenceError) { + ui.error(err.message); } else if (err instanceof AgentNameInUseError) { ui.error( `Agent "${err.agentName}" is already running (PID ${err.pid}). Choose a different name.`, @@ -110,6 +114,43 @@ export function registerAgentStartCommand(agentCommand: Command): void { return; } const runtimeProvider = await new ConfigManager().getAgentRuntimeProvider(); + + // Preferred path: the daemon performs start inside its own identity + // scope (issue #402), so the registry write lands where every client + // reads it. Falls back to local start when the daemon is absent or + // too old to know agent.start. + if (runtimeProvider === "tmux") { + const client = await ensureDaemon(); + if (client) { + try { + const entry = (await client.agentStart({ + type: agentType, + name: agentName, + cwd, + })) as { + name: string; + type: string; + pid: number; + runtimeRef?: { session?: string }; + }; + ui.success(`Agent "${entry.name}" started (${entry.type}, PID ${entry.pid})`); + ui.text(`Working directory: ${formatCwd(cwd)}`); + const session = entry.runtimeRef?.session; + if (session) ui.text(`Attach: tmux attach -t ${session}`); + return; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (!message.includes("unknown method")) { + ui.error(message); + process.exit(1); + } + ui.warning("Daemon does not support agent.start; starting locally."); + } finally { + client.close(); + } + } + } + const entry = await startAgent( { type: agentType as StartableAgentType, diff --git a/packages/daemon-client/src/autostart.ts b/packages/daemon-client/src/autostart.ts index 2bccd9e40..8a5a488c5 100644 --- a/packages/daemon-client/src/autostart.ts +++ b/packages/daemon-client/src/autostart.ts @@ -2,6 +2,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { spawn } from "node:child_process"; import { DaemonClient, daemonSocketPath } from "./client.js"; +import { homeScopeMismatch } from "./homeScope.js"; import { resolveDaemonBinary } from "./binary.js"; /** @@ -19,6 +20,18 @@ export async function ensureDaemon(opts: { waitMs?: number } = {}): Promise> { + return (await this.request("agent.start", params)) as Record; + } + request(method: string, params: unknown = {}): Promise { if (!this.socket) return Promise.reject(new Error("not connected")); const id = this.nextId++; diff --git a/packages/daemon-client/src/homeScope.ts b/packages/daemon-client/src/homeScope.ts new file mode 100644 index 000000000..5e83cbda2 --- /dev/null +++ b/packages/daemon-client/src/homeScope.ts @@ -0,0 +1,27 @@ +import * as os from "node:os"; +import * as path from "node:path"; + +/** + * Mirrors agent-manager/src/utils/homeScope.ts (daemon-client cannot depend + * on agent-manager): $HOME is env-overridable while every identity path — + * registry db, daemon socket legacy path — resolves from it. A sandboxed or + * nested process can overlay it; the account home (getpwuid) is the env- + * independent reference (issue #402). + */ +export function homeScopeMismatch( + envHome: string | undefined = process.env.HOME, + accountHome: string | undefined = safeAccountHome(), +): { envHome: string; accountHome: string } | null { + if (!envHome || !accountHome) return null; + return path.resolve(envHome) === path.resolve(accountHome) + ? null + : { envHome, accountHome }; +} + +function safeAccountHome(): string | undefined { + try { + return os.userInfo().homedir; + } catch { + return undefined; + } +} diff --git a/packages/daemon-client/src/index.ts b/packages/daemon-client/src/index.ts index 434741752..1f3eb972a 100644 --- a/packages/daemon-client/src/index.ts +++ b/packages/daemon-client/src/index.ts @@ -1,4 +1,5 @@ export { DaemonClient, daemonSocketPath } from "./client.js"; +export { homeScopeMismatch } from "./homeScope.js"; export type { DaemonEvent } from "./client.js"; export type { EnrichedAgent, Event } from "./gen/index.js"; export { resolveDaemonBinary } from "./binary.js"; diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 64e59c320..db3f0e58f 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -118,6 +118,7 @@ dependencies = [ "devkit-core", "devkit-harness", "libc", + "rusqlite", "serde_json", "tokio", "tracing", diff --git a/rust/crates/devkit-harness/src/opencode/locator.rs b/rust/crates/devkit-harness/src/opencode/locator.rs index 6944c07eb..f64db1a01 100644 --- a/rust/crates/devkit-harness/src/opencode/locator.rs +++ b/rust/crates/devkit-harness/src/opencode/locator.rs @@ -6,11 +6,14 @@ use std::path::{Path, PathBuf}; /// `::` session ref separator. pub const SESSION_REF_SEP: &str = "::"; -/// `OpenCodeSession` — id + directory + time_created. +/// `OpenCodeSession` — id + directory + time_created (+ optional title on +/// newer schemas; the auto-generated title like "slug-lib-75800" is metadata +/// only, never identity). pub struct Session { pub session_id: String, pub directory: String, pub time_created_ms: i64, + pub title: Option, } /// `resolveDbPath` — `XDG_DATA_HOME` (empty falls back) or @@ -42,19 +45,32 @@ pub fn open_db(db_path: &Path) -> Option { .ok() } -/// `findSessionForDirectory` — newest session row for the directory. +/// `findSessionForDirectory` — newest session row for the directory. Newer +/// schemas carry `title`; older ones do not — wide select first, then narrow. pub fn find_session_for_directory(db: &Connection, directory: &str) -> Option { - db.query_row( - "SELECT id, directory, time_created FROM session - WHERE directory = ?1 ORDER BY time_created DESC LIMIT 1", - [directory], - |r| { - Ok(Session { - session_id: r.get::<_, String>(0)?, - directory: r.get::<_, String>(1)?, - time_created_ms: r.get::<_, i64>(2)?, - }) - }, - ) - .ok() + for cols in [ + "id, directory, time_created, title", + "id, directory, time_created", + ] { + let sql = format!( + "SELECT {cols} FROM session WHERE directory = ?1 ORDER BY time_created DESC LIMIT 1" + ); + if let Ok(mut stmt) = db.prepare(&sql) { + return stmt + .query_row([directory], |r| { + Ok(Session { + session_id: r.get::<_, String>(0)?, + directory: r.get::<_, String>(1)?, + time_created_ms: r.get::<_, i64>(2)?, + title: if cols.ends_with("title") { + r.get::<_, Option>(3)? + } else { + None + }, + }) + }) + .ok(); + } + } + None } diff --git a/rust/crates/devkit-harness/src/opencode/mod.rs b/rust/crates/devkit-harness/src/opencode/mod.rs index 248afc06b..11ca53092 100644 --- a/rust/crates/devkit-harness/src/opencode/mod.rs +++ b/rust/crates/devkit-harness/src/opencode/mod.rs @@ -56,10 +56,12 @@ impl OpenCodeAdapter { name: shared::generate_agent_name(&directory, proc.pid), agent_type: "opencode".into(), status: determine_status(stats, last_active_ms, now_ms).into(), - summary: if stats.summary.is_empty() { - "OpenCode session active".into() - } else { + summary: if !stats.summary.is_empty() { stats.summary.clone() + } else if let Some(title) = &session.title { + title.clone() + } else { + "OpenCode session active".into() }, pid: proc.pid as u64, project_path: directory, @@ -90,7 +92,7 @@ impl HarnessAdapter for OpenCodeAdapter { // `findHarnessProcesses`: processNames pool filter, then canHandle // deduped by pid (first wins). let mut seen = HashSet::new(); - let processes: Vec<&AgentProc> = ctx + let matching: Vec<&AgentProc> = ctx .processes .iter() .filter(|p| { @@ -98,6 +100,9 @@ impl HarnessAdapter for OpenCodeAdapter { }) .filter(|p| self.can_handle(p) && seen.insert(p.pid)) .collect(); + // A session that spawns another session mid-chat surfaces as a child + // opencode process; it is that session's backend, not a separate agent. + let processes = shared::drop_descendant_processes(&matching); if processes.is_empty() { return Vec::new(); } diff --git a/rust/crates/devkit-harness/src/shared.rs b/rust/crates/devkit-harness/src/shared.rs index 5cf9bae26..815d17b9a 100644 --- a/rust/crates/devkit-harness/src/shared.rs +++ b/rust/crates/devkit-harness/src/shared.rs @@ -57,6 +57,35 @@ pub fn executable_basename(command: &str) -> String { path_basename(&executable_path(command)) } +/// `dropDescendantProcesses` — drop procs that descend from another proc in +/// the list (issue #402): a session spawning a child session mid-chat shows +/// up as a child proc that is the session's backend, not a separate agent. +/// Ancestry walks `ppid` links within the list only — a child separated by an +/// unlisted intermediate proc is left alone rather than guessed at. +pub fn drop_descendant_processes<'a>(processes: &[&'a AgentProc]) -> Vec<&'a AgentProc> { + let by_pid: std::collections::HashMap = + processes.iter().map(|p| (p.pid, *p)).collect(); + let has_listed_ancestor = |proc: &AgentProc| { + let mut seen = std::collections::HashSet::from([proc.pid]); + let mut current = proc.ppid; + while let Some(ppid) = current { + if !seen.insert(ppid) { + return false; + } + if by_pid.contains_key(&ppid) { + return true; + } + current = by_pid.get(&ppid).and_then(|p| p.ppid); + } + false + }; + processes + .iter() + .filter(|p| !has_listed_ancestor(p)) + .copied() + .collect() +} + /// `matchesExecutable` — argv[0]'s basename is `name` or `name.exe`. pub fn matches_executable(command: &str, name: &str) -> bool { let base = executable_basename(command); diff --git a/rust/crates/devkitd/Cargo.toml b/rust/crates/devkitd/Cargo.toml index 25f436555..875fc243f 100644 --- a/rust/crates/devkitd/Cargo.toml +++ b/rust/crates/devkitd/Cargo.toml @@ -11,5 +11,6 @@ tokio.workspace = true serde_json.workspace = true anyhow.workspace = true libc.workspace = true +rusqlite.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/rust/crates/devkitd/src/agent_start.rs b/rust/crates/devkitd/src/agent_start.rs new file mode 100644 index 000000000..cea76116c --- /dev/null +++ b/rust/crates/devkitd/src/agent_start.rs @@ -0,0 +1,270 @@ +//! `agent.start` RPC — starts an agent inside the daemon's own identity +//! scope (issue #402). The CLI's `agent start` delegates here when the socket +//! is reachable so the registry write, tmux session, and pid registration all +//! happen under the daemon's HOME — the same authority `agent list`, `send`, +//! `kill`, and the console read. Clients on a divergent/sandboxed HOME get a +//! real, addressable agent instead of a silently split-brain row. +//! +//! The registry db (`/agents.db`) shares the TS agent-manager +//! schema: the daemon writes only the columns present since schema v1 +//! (type, pid, name, tmux_session, cwd, started_at, session_id, +//! session_file_path, updated_at); runtime/pinned/runtime_ref defaults come +//! from later migrations which the TS side applies on next open. + +use anyhow::{anyhow, bail, Context, Result}; +use serde_json::{json, Value}; +use std::path::Path; +use std::process::Command; +use std::time::{Duration, Instant}; + +/// `AGENT_NAME_REGEX` — lowercase alphanumeric + hyphens, 2–64 chars, must +/// start and end with a letter or digit. +fn valid_agent_name(name: &str) -> bool { + let bytes = name.as_bytes(); + let ok = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit(); + bytes.len() >= 2 + && bytes.len() <= 64 + && ok(bytes[0]) + && ok(bytes[bytes.len() - 1]) + && bytes.iter().all(|&b| ok(b) || b == b'-') +} + +/// `HARNESS_RUNTIME_PROFILES` — executable command per startable type plus +/// the basenames a pane-tree pid scan accepts. Mirrors TS `runtimeProfiles`. +pub fn profile_for(agent_type: &str) -> Option<(&'static str, &'static [&'static str])> { + match agent_type { + "claude" => Some(("claude", &["claude"])), + "codex" => Some(("codex", &["codex"])), + "copilot" => Some(("copilot-cli", &["copilot-cli"])), + "gemini_cli" => Some(("gemini", &["gemini"])), + "grok_cli" => Some(("grok", &["grok"])), + "antigravity_cli" => Some(("agy", &["agy"])), + "opencode" => Some(("opencode", &["opencode"])), + "pi" => Some(("pi", &["pi"])), + "kiro" => Some(("kiro-cli", &["kiro-cli", "kiro"])), + "devin" => Some(("devin", &["devin"])), + "muse" => Some(("muse", &["muse"])), + _ => None, + } +} + +fn tmux(args: &[&str]) -> Result { + let out = Command::new("tmux") + .args(args) + .output() + .context("failed to run tmux — is it installed?")?; + if !out.status.success() { + bail!( + "tmux {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(String::from_utf8_lossy(&out.stdout).to_string()) +} + +fn pane_pid(session: &str) -> Option { + let out = tmux(&["list-panes", "-t", session, "-F", "#{pane_pid}"]).ok()?; + out.trim().lines().next()?.parse().ok() +} + +fn pgrep_children(pid: i64) -> Vec { + let out = Command::new("pgrep") + .arg("-P") + .arg(pid.to_string()) + .output(); + match out { + Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout) + .split_whitespace() + .filter_map(|s| s.parse().ok()) + .collect(), + _ => Vec::new(), + } +} + +fn process_command(pid: i64) -> Option { + let out = Command::new("ps") + .args(["-p", &pid.to_string(), "-o", "command="]) + .output() + .ok()?; + let cmd = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if cmd.is_empty() { + None + } else { + Some(cmd) + } +} + +/// `TmuxManager.findAgentPid` — deepest matching pid in the pane's process tree. +fn find_agent_pid(session: &str, basenames: &[&str]) -> Option { + let root = pane_pid(session)?; + let mut deepest = None; + let mut visited = std::collections::HashSet::new(); + let mut queue = vec![root]; + while let Some(pid) = queue.pop() { + if !visited.insert(pid) { + continue; + } + if let Some(cmd) = process_command(pid) { + if devkit_harness::shared::matches_executable_name(&cmd, basenames) { + deepest = Some(pid); + } + } + queue.extend(pgrep_children(pid)); + } + deepest +} + +/// `pollForPid` — require 5 consecutive identical hits or time out (15s). +fn poll_for_pid(session: &str, basenames: &[&str], timeout: Duration) -> Option { + let deadline = Instant::now() + timeout; + let mut candidate = None; + let mut stable = 0; + while Instant::now() < deadline { + if let Some(pid) = find_agent_pid(session, basenames) { + if candidate == Some(pid) { + stable += 1; + } else { + candidate = Some(pid); + stable = 1; + } + if stable >= 5 { + return Some(pid); + } + } + std::thread::sleep(Duration::from_millis(500)); + } + None +} + +fn pid_alive(pid: i64) -> bool { + // kill(pid, 0): Ok = alive; EPERM = alive; ESRCH = dead. + match unsafe { libc::kill(pid as libc::pid_t, 0) } { + 0 => true, + _ => std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH), + } +} + +/// Open (or create at schema v1 so the TS migrations take it forward) the +/// shared registry db and insert the agent row. +fn register_agent(data_dir: &Path, entry: &Value) -> Result<()> { + let db_path = data_dir.join("agents.db"); + let existed = db_path.exists(); + let conn = rusqlite::Connection::open(&db_path)?; + if !existed { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS agents ( + type TEXT NOT NULL, + pid INTEGER NOT NULL, + name TEXT NOT NULL UNIQUE, + tmux_session TEXT NOT NULL DEFAULT '', + cwd TEXT NOT NULL DEFAULT '', + started_at TEXT NOT NULL, + session_id TEXT NOT NULL DEFAULT '', + session_file_path TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL, + PRIMARY KEY (type, pid) + ); + PRAGMA user_version = 1;", + )?; + } + // Name-in-use: a live registered agent already owns this name. + let conflict: Option = conn + .query_row( + "SELECT pid FROM agents WHERE name = ?1", + [entry["name"].as_str().unwrap_or("")], + |r| r.get(0), + ) + .ok(); + if let Some(pid) = conflict { + if pid_alive(pid) { + bail!( + "Agent \"{}\" is already running (PID {pid}). Choose a different name.", + entry["name"] + ); + } + conn.execute( + "DELETE FROM agents WHERE name = ?1", + [entry["name"].as_str().unwrap_or("")], + )?; + } + conn.execute( + "INSERT INTO agents (type, pid, name, tmux_session, cwd, started_at, session_id, session_file_path, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, '', '', ?7)", + rusqlite::params![ + entry["type"].as_str().unwrap_or_default(), + entry["pid"].as_i64().unwrap_or_default(), + entry["name"].as_str().unwrap_or_default(), + entry["tmuxSession"].as_str().unwrap_or_default(), + entry["cwd"].as_str().unwrap_or_default(), + entry["startedAt"].as_str().unwrap_or_default(), + entry["startedAt"].as_str().unwrap_or_default(), + ], + )?; + Ok(()) +} + +/// `agent.start` — params {type, name, cwd}; result = the registry entry. +/// Blocking is fine: requests are dispatched per-connection and pid polling +/// is bounded. +pub fn handle(data_dir: &Path, params: &Value) -> Result { + let agent_type = params["type"].as_str().unwrap_or(""); + let name = params["name"].as_str().unwrap_or(""); + let cwd = params["cwd"].as_str().unwrap_or(""); + + let (command, basenames) = profile_for(agent_type) + .ok_or_else(|| anyhow!("Unsupported agent type \"{agent_type}\"."))?; + if !valid_agent_name(name) { + bail!("Invalid name \"{name}\". Use lowercase letters, digits, and hyphens only; 2-64 characters."); + } + if !Path::new(cwd).is_dir() { + bail!("Directory \"{cwd}\" does not exist."); + } + tmux(&["-V"]).map_err(|_| anyhow!("tmux is not installed or not in PATH."))?; + if tmux(&["has-session", "-t", name]).is_ok() { + let _ = tmux(&["kill-session", "-t", name]); + } + tmux(&["new-session", "-d", "-s", name, "-c", cwd])?; + if let Err(e) = tmux(&["send-keys", "-t", name, command, "Enter"]) { + let _ = tmux(&["kill-session", "-t", name]); + return Err(e); + } + let pid = match poll_for_pid(name, basenames, Duration::from_secs(15)) { + Some(pid) => pid, + None => { + let _ = tmux(&["kill-session", "-t", name]); + bail!("Agent process not found after 15s. Verify that \"{command}\" is in PATH inside the tmux environment."); + } + }; + + let entry = json!({ + "name": name, + "type": agent_type, + "pid": pid, + "runtime": "tmux", + "runtimeRef": { "session": name }, + "tmuxSession": name, + "cwd": cwd, + "startedAt": devkit_harness::shared::iso_utc(devkit_core::store::chrono_now()), + }); + if let Err(e) = register_agent(data_dir, &entry) { + let _ = tmux(&["kill-session", "-t", name]); + return Err(e.context("agent started but registry write failed")); + } + Ok(entry) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn name_validation_matches_ts_regex() { + for ok in ["tests", "a1", "agent-one", "ab", "x-9"] { + assert!(valid_agent_name(ok), "{ok} should be valid"); + } + for bad in ["a", "-a", "a-", "A-b", "has space", "a_b", ""] { + assert!(!valid_agent_name(bad), "{bad} should be invalid"); + } + } +} diff --git a/rust/crates/devkitd/src/main.rs b/rust/crates/devkitd/src/main.rs index 7bc4720cb..a5af85d5b 100644 --- a/rust/crates/devkitd/src/main.rs +++ b/rust/crates/devkitd/src/main.rs @@ -1,3 +1,4 @@ +mod agent_start; mod server; use anyhow::Result; diff --git a/rust/crates/devkitd/src/server.rs b/rust/crates/devkitd/src/server.rs index b4efed2fe..47dbfea9e 100644 --- a/rust/crates/devkitd/src/server.rs +++ b/rust/crates/devkitd/src/server.rs @@ -26,6 +26,8 @@ pub struct Daemon { pub enriched: RwLock>, /// User home dir adapters resolve session trees (~/.claude, …) against. pub home: PathBuf, + /// Daemon data dir — agents.db registry writes for `agent.start` land here. + pub data_dir: PathBuf, } impl Daemon { @@ -63,6 +65,7 @@ impl Daemon { enricher: devkit_harness::default_registry(&home), enriched: RwLock::new(Vec::new()), home, + data_dir: data_dir.to_path_buf(), }) } @@ -100,6 +103,20 @@ impl Daemon { let cached = self.enriched.read().unwrap().clone(); Response::ok(id, serde_json::to_value(cached).unwrap()) } + "agent.start" => { + // Blocking start (tmux session + pid poll + registry write) + // runs on a blocking thread so a slow launch doesn't stall + // other requests on this connection. + let data_dir = self.data_dir.clone(); + let params = req.params.clone(); + match std::thread::spawn(move || crate::agent_start::handle(&data_dir, ¶ms)) + .join() + { + Ok(Ok(entry)) => Response::ok(id, entry), + Ok(Err(e)) => Response::err(id, e.to_string()), + Err(_) => Response::err(id, "agent.start panicked".to_string()), + } + } "shutdown" => { // Caller expects a response before exit; schedule exit. tokio::spawn(async {