From 1eefbe0a775b1d70e751401023753b338283c9a0 Mon Sep 17 00:00:00 2001 From: luckeyfaraday Date: Tue, 16 Jun 2026 18:38:24 +0200 Subject: [PATCH 1/2] Add session_takeover tool for taking over recalled sessions in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent_takeover can only hand over sessions this process spawned: it is gated on an in-memory handle in the agents map. A session found via session_recall has no handle, so the model had no in-place takeover tool for historical sessions and fell back to spawning a new terminal window. The in-place machinery (execResume / resumeCommand / the athena.agent.takeover event the TUI listens for) was already fully general, taking a plain { agent, sessionId, workspace }. session_takeover bridges recall to that same event for any indexed session, no live handle required. - session/agent/session-takeover.ts: pure planSessionTakeover() validates the recalled { agent, session_id } against the cross-agent index, then builds an in-app handover or a visible-terminal resume. Keeping it side-effect-free lets it be unit-tested without the plugin/bus glue. - sessionindex.ts: indexedSessionWorkspace() — a stale id yields a clean not-found error instead of a silently dropped handover, and the session's own indexed workspace is used so it resumes in its own repo. - Supports all five agents including athena (resumed through process.execPath, which localAgentResumeCommand omits). - The tool() wrapper in tool/agent-local.ts only emits / launches; registered as session_takeover in the overlay plugin tool map. Co-Authored-By: Claude Opus 4.8 --- .../packages/opencode/src/plugin/athena.ts | 2 + .../src/session/agent/session-takeover.ts | 63 ++++++++++++++ .../src/session/memory/sessionindex.ts | 18 ++++ .../packages/opencode/src/tool/agent-local.ts | 87 +++++++++++++++++++ test/agent-local.test.ts | 86 ++++++++++++++++++ 5 files changed, 256 insertions(+) create mode 100644 overlay/packages/opencode/src/session/agent/session-takeover.ts diff --git a/overlay/packages/opencode/src/plugin/athena.ts b/overlay/packages/opencode/src/plugin/athena.ts index f9e61cb..026b8e0 100644 --- a/overlay/packages/opencode/src/plugin/athena.ts +++ b/overlay/packages/opencode/src/plugin/athena.ts @@ -10,6 +10,7 @@ import { AgentStopTool, AgentTakeoverTool, AgentWaitTool, + SessionTakeoverTool, } from "../tool/agent-local" import { frozenSnapshotSystem } from "../session/memory/snapshot" import { recallSystemEntry } from "../session/memory/recall" @@ -62,6 +63,7 @@ export const AthenaPlugin: Plugin = async (input) => { agent_output: AgentOutputTool, agent_wait: AgentWaitTool, agent_takeover: AgentTakeoverTool, + session_takeover: SessionTakeoverTool, }, "experimental.chat.messages.transform": async (_input, output) => { const msgs = output.messages diff --git a/overlay/packages/opencode/src/session/agent/session-takeover.ts b/overlay/packages/opencode/src/session/agent/session-takeover.ts new file mode 100644 index 0000000..aed8eeb --- /dev/null +++ b/overlay/packages/opencode/src/session/agent/session-takeover.ts @@ -0,0 +1,63 @@ +// Plan a takeover of a past session found via session_recall (or /find-sessions): +// the session_takeover tool turns a recalled { agent, session_id } into either an +// in-app handover (suspend this pane and resume the session in place via the +// athena.agent.takeover event the TUI already listens for) or a new visible +// terminal. Unlike agent_takeover, no live spawned-agent handle is required. +// +// Kept as a pure, side-effect-free planner so the validation and resume-command +// logic is unit-testable without the plugin/bus glue: the tool wrapper in +// tool/agent-local.ts performs the GlobalBus.emit / openVisibleTerminal. + +import { localAgentResumeCommand, type LocalAgentKind } from "./local" +import { indexedSessionWorkspace } from "../memory/sessionindex" + +// Every agent the cross-agent index can hold: athena's own sessions plus the +// four spawnable agents. A superset of LocalAgentKind because athena sessions +// are resumable here even though they are never spawned as subprocess agents. +const TAKEOVER_AGENTS = ["athena", "claude", "codex", "opencode", "hermes"] as const +export type TakeoverAgent = (typeof TAKEOVER_AGENTS)[number] + +function isTakeoverAgent(value: string): value is TakeoverAgent { + return (TAKEOVER_AGENTS as readonly string[]).includes(value) +} + +// Native resume invocation for any indexed session, mirroring the TUI's +// resumeCommand table (packages/tui/src/util/athena-sessions.ts). athena's own +// sessions resume through this very binary; localAgentResumeCommand omits them +// because they are not spawnable subprocess agents. +function takeoverResumeCommand( + agent: TakeoverAgent, + sessionId: string, + workspace: string, +): { command: string; args: string[] } { + if (agent === "athena") return { command: process.execPath, args: [workspace, "--session", sessionId] } + return localAgentResumeCommand(agent, sessionId, workspace) +} + +export type SessionTakeoverPlan = + | { status: "unknown-agent"; agent: string } + | { status: "not-found"; agent: TakeoverAgent; sessionId: string } + | { status: "in_app"; agent: TakeoverAgent; sessionId: string; workspace: string } + | { status: "terminal"; agent: TakeoverAgent; sessionId: string; workspace: string; command: string; args: string[] } + +// Resolve a recalled session into a takeover plan. The session id is validated +// against the cross-agent index (a stale id yields "not-found" rather than a +// silently dropped handover), and its recorded workspace is used when the caller +// does not override it, so the session resumes in its own repo. +export function planSessionTakeover(params: { + agent: string + sessionId: string + workspace?: string + where?: "in_app" | "terminal" +}): SessionTakeoverPlan { + const agent = params.agent.trim().toLowerCase() + if (!isTakeoverAgent(agent)) return { status: "unknown-agent", agent } + const sessionId = params.sessionId.trim() + const indexedWorkspace = indexedSessionWorkspace(agent, sessionId) + if (indexedWorkspace === null) return { status: "not-found", agent, sessionId } + const workspace = params.workspace?.trim() || indexedWorkspace + if (params.where === "terminal") { + return { status: "terminal", agent, sessionId, workspace, ...takeoverResumeCommand(agent, sessionId, workspace) } + } + return { status: "in_app", agent, sessionId, workspace } +} diff --git a/overlay/packages/opencode/src/session/memory/sessionindex.ts b/overlay/packages/opencode/src/session/memory/sessionindex.ts index 97267eb..d356d7a 100644 --- a/overlay/packages/opencode/src/session/memory/sessionindex.ts +++ b/overlay/packages/opencode/src/session/memory/sessionindex.ts @@ -219,6 +219,24 @@ export function athenaOwnedSessionIds(ids: string[]): Set { } } +// Workspace recorded in the cross-agent index for (agent, sessionId), or null +// when no such session is indexed. session_takeover uses this to validate a +// recalled session id before handing the terminal over — a stale id returns a +// clean error instead of a silently dropped handover — and to resume the +// session in its own original workspace rather than the current one. +export function indexedSessionWorkspace(agent: string, sessionId: string): string | null { + const db = openReadonly() + if (!db) return null + try { + const row = db + .query("SELECT workspace FROM messages WHERE agent = ? AND session_id = ? LIMIT 1") + .get(agent, sessionId) as { workspace: string } | null + return row?.workspace ?? null + } finally { + db.close() + } +} + // Index a batch of scanned prior sessions for one agent. Message inserts are // INSERT OR IGNORE, so rescanning an append-only session file adds only the // new turns; the source fingerprint is upserted alongside. diff --git a/overlay/packages/opencode/src/tool/agent-local.ts b/overlay/packages/opencode/src/tool/agent-local.ts index 05e0598..4a950be 100644 --- a/overlay/packages/opencode/src/tool/agent-local.ts +++ b/overlay/packages/opencode/src/tool/agent-local.ts @@ -19,6 +19,7 @@ import { type LocalAgentRecord, } from "../session/agent/local" import { openVisibleTerminal } from "../session/agent/terminal" +import { planSessionTakeover } from "../session/agent/session-takeover" const AGENTS = ["claude", "codex", "opencode", "hermes"] as const @@ -180,6 +181,92 @@ export const AgentTakeoverTool = tool({ }, }) +// Sibling of AgentTakeoverTool for sessions the model found via session_recall +// rather than spawned itself: those have no in-memory handle, so this resolves +// the recalled { agent, session_id } against the cross-agent index and reuses +// the very same in-app handover event (or opens a visible terminal). +export const SessionTakeoverTool = tool({ + description: + "Take over a past session found via session_recall (or one the user names) in THIS terminal: the current " + + "Athena Code pane suspends and resumes that session in place, returning here when the user exits it. Use for " + + '"take over here", "continue this here", "open it here", or "resume that session" about a recalled or historical ' + + "session. Unlike agent_takeover, this does NOT require a live agent_spawn handle — it works for any indexed " + + "session across athena, claude, codex, opencode, and hermes. where=in_app (default) swaps this pane; " + + "where=terminal opens the resumed session in a new visible terminal window instead.", + args: { + agent: tool.schema + .string() + .describe('Agent that owns the session, from session_recall: "athena", "claude", "codex", "opencode", or "hermes".'), + session_id: tool.schema.string().describe("Session id to resume, as reported by session_recall."), + workspace: tool.schema + .string() + .optional() + .describe("Session workspace from session_recall. Omit to resume in the workspace recorded for that session."), + where: tool.schema + .enum(["in_app", "terminal"]) + .optional() + .describe( + '"in_app" (default) suspends this terminal and resumes the session in place; "terminal" opens it in a new visible terminal window.', + ), + }, + async execute(args, context) { + const plan = planSessionTakeover({ + agent: args.agent, + sessionId: args.session_id, + workspace: args.workspace, + where: args.where, + }) + switch (plan.status) { + case "unknown-agent": + return { + title: "Unknown agent", + metadata: { error: true, agent: plan.agent }, + output: `Unknown agent ${JSON.stringify(args.agent)}. Use one of: athena, claude, codex, opencode, hermes.`, + } + case "not-found": + return { + title: "Session not found", + metadata: { error: true, agent: plan.agent, sessionId: plan.sessionId }, + output: + `No indexed ${plan.agent} session ${plan.sessionId}. The id may be stale or mistyped — run session_recall ` + + `again for a current id, or /find-sessions to locate it manually.`, + } + case "terminal": { + const launch = openVisibleTerminal([plan.command, ...plan.args], plan.workspace) + if (!launch.ok) { + return { title: "Terminal launch failed", metadata: { error: true }, output: launch.error ?? "Could not open a terminal window." } + } + return { + title: "Session opened in terminal", + metadata: { agent: plan.agent, sessionId: plan.sessionId, terminal: launch.terminal, workspace: plan.workspace }, + output: `Resumed ${plan.agent} session ${plan.sessionId} in a new ${launch.terminal} window (workspace ${plan.workspace}).`, + } + } + case "in_app": + GlobalBus.emit("event", { + directory: context.directory, + payload: { + type: TAKEOVER_EVENT, + properties: { + agent: plan.agent, + sessionId: plan.sessionId, + workspace: plan.workspace, + requestSessionID: context.sessionID, + }, + }, + }) + return { + title: "Handing over session", + metadata: { agent: plan.agent, sessionId: plan.sessionId, workspace: plan.workspace }, + output: + `Suspending this terminal into ${plan.agent} session ${plan.sessionId} (workspace ${plan.workspace}). ` + + `Athena Code returns when the user exits ${plan.agent}. If nothing happens (e.g. no TUI is attached), ` + + `the user can run /find-sessions instead.`, + } + } + }, +}) + export const AgentListTool = tool({ description: "List local coding agents spawned by this Athena Code process. Use when the user asks which agents are spawned, running, active, or available to message.", args: {}, diff --git a/test/agent-local.test.ts b/test/agent-local.test.ts index 4cb6c0e..d44adff 100644 --- a/test/agent-local.test.ts +++ b/test/agent-local.test.ts @@ -20,6 +20,8 @@ import { waitLocalAgent, } from "../overlay/packages/opencode/src/session/agent/local" import { openVisibleTerminal } from "../overlay/packages/opencode/src/session/agent/terminal" +import { planSessionTakeover } from "../overlay/packages/opencode/src/session/agent/session-takeover" +import { indexMessages, indexScannedSessions } from "../overlay/packages/opencode/src/session/memory/sessionindex" function workspace(prefix: string): string { return mkdtempSync(join(tmpdir(), prefix)) @@ -403,3 +405,87 @@ test("output offsets stay absolute across buffer truncation", async () => { expect(stale.text.length).toBe(max) expect(stale.nextOffset).toBe(total) }) + +// session_takeover splits into a pure planner (validation + resume command + +// the exact properties later emitted on the takeover event) and a thin tool +// wrapper that performs GlobalBus.emit / openVisibleTerminal. The wrapper pulls +// in @opencode-ai/plugin and the bus, which are absent from this standalone +// overlay, so these tests exercise the planner — the in_app plan's fields are +// the takeover event payload verbatim. + +test("session takeover plans an in-app handover and resolves the indexed workspace", () => { + process.env.ATHENA_CODE_HOME = workspace("athhome-takeover-inapp-") + const ws = workspace("athtakeover-ws-") + indexMessages(ws, [{ sessionId: "live-1", role: "user", ts: "msg-1", text: "wire the exporter into the gateway" }]) + + expect(planSessionTakeover({ agent: "athena", sessionId: "live-1" })).toEqual({ + status: "in_app", + agent: "athena", + sessionId: "live-1", + workspace: ws, + }) +}) + +test("session takeover terminal mode resumes athena through this binary and delegates others", () => { + process.env.ATHENA_CODE_HOME = workspace("athhome-takeover-term-") + const ws = workspace("athtakeover-ws-") + indexMessages(ws, [{ sessionId: "live-2", role: "user", ts: "msg-1", text: "draft the migration plan" }]) + indexScannedSessions("claude", [ + { + sourceId: "claude-1", + sourcePath: "/fixtures/claude/claude-1.jsonl", + fingerprint: "1:1", + workspace: "/home/x/proj", + messages: [{ role: "user", ts: "t1", text: "tighten the rate limiter" }], + }, + ]) + + // athena is not a spawnable agent, so localAgentResumeCommand omits it; the + // planner resumes it through this binary (process.execPath), as the TUI does. + expect(planSessionTakeover({ agent: "athena", sessionId: "live-2", where: "terminal" })).toEqual({ + status: "terminal", + agent: "athena", + sessionId: "live-2", + workspace: ws, + command: process.execPath, + args: [ws, "--session", "live-2"], + }) + expect(planSessionTakeover({ agent: "claude", sessionId: "claude-1", where: "terminal" })).toEqual({ + status: "terminal", + agent: "claude", + sessionId: "claude-1", + workspace: "/home/x/proj", + command: "claude", + args: ["--resume", "claude-1"], + }) +}) + +test("session takeover honors an explicit workspace override", () => { + process.env.ATHENA_CODE_HOME = workspace("athhome-takeover-override-") + const ws = workspace("athtakeover-ws-") + indexMessages(ws, [{ sessionId: "live-3", role: "user", ts: "msg-1", text: "check the deploy" }]) + + expect(planSessionTakeover({ agent: "athena", sessionId: "live-3", workspace: "/elsewhere" })).toEqual({ + status: "in_app", + agent: "athena", + sessionId: "live-3", + workspace: "/elsewhere", + }) +}) + +test("session takeover rejects unknown agents and stale session ids", () => { + process.env.ATHENA_CODE_HOME = workspace("athhome-takeover-reject-") + const ws = workspace("athtakeover-ws-") + indexMessages(ws, [{ sessionId: "live-4", role: "user", ts: "msg-1", text: "anything indexed" }]) + + expect(planSessionTakeover({ agent: "gemini", sessionId: "live-4" })).toEqual({ + status: "unknown-agent", + agent: "gemini", + }) + // A known agent but an id absent from the index (a stale recall result). + expect(planSessionTakeover({ agent: "athena", sessionId: "ghost" })).toEqual({ + status: "not-found", + agent: "athena", + sessionId: "ghost", + }) +}) From 624c12adc9bcb9b8ac736a4395a8e007da18458b Mon Sep 17 00:00:00 2001 From: luckeyfaraday Date: Wed, 17 Jun 2026 00:34:50 +0200 Subject: [PATCH 2/2] Fix session_takeover stale workspace fallback --- .../src/session/agent/session-takeover.ts | 21 ++++++++++++-- .../packages/opencode/src/tool/agent-local.ts | 1 + test/agent-local.test.ts | 29 +++++++++++++++++-- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/overlay/packages/opencode/src/session/agent/session-takeover.ts b/overlay/packages/opencode/src/session/agent/session-takeover.ts index aed8eeb..356128d 100644 --- a/overlay/packages/opencode/src/session/agent/session-takeover.ts +++ b/overlay/packages/opencode/src/session/agent/session-takeover.ts @@ -8,7 +8,8 @@ // logic is unit-testable without the plugin/bus glue: the tool wrapper in // tool/agent-local.ts performs the GlobalBus.emit / openVisibleTerminal. -import { localAgentResumeCommand, type LocalAgentKind } from "./local" +import { statSync } from "node:fs" +import { localAgentResumeCommand } from "./local" import { indexedSessionWorkspace } from "../memory/sessionindex" // Every agent the cross-agent index can hold: athena's own sessions plus the @@ -34,6 +35,20 @@ function takeoverResumeCommand( return localAgentResumeCommand(agent, sessionId, workspace) } +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory() + } catch { + return false + } +} + +function launchWorkspace(workspace: string, fallbackWorkspace?: string): string { + if (workspace && isDirectory(workspace)) return workspace + const fallback = fallbackWorkspace?.trim() || process.cwd() + return fallback && isDirectory(fallback) ? fallback : process.cwd() +} + export type SessionTakeoverPlan = | { status: "unknown-agent"; agent: string } | { status: "not-found"; agent: TakeoverAgent; sessionId: string } @@ -48,6 +63,7 @@ export function planSessionTakeover(params: { agent: string sessionId: string workspace?: string + fallbackWorkspace?: string where?: "in_app" | "terminal" }): SessionTakeoverPlan { const agent = params.agent.trim().toLowerCase() @@ -57,7 +73,8 @@ export function planSessionTakeover(params: { if (indexedWorkspace === null) return { status: "not-found", agent, sessionId } const workspace = params.workspace?.trim() || indexedWorkspace if (params.where === "terminal") { - return { status: "terminal", agent, sessionId, workspace, ...takeoverResumeCommand(agent, sessionId, workspace) } + const cwd = launchWorkspace(workspace, params.fallbackWorkspace) + return { status: "terminal", agent, sessionId, workspace: cwd, ...takeoverResumeCommand(agent, sessionId, cwd) } } return { status: "in_app", agent, sessionId, workspace } } diff --git a/overlay/packages/opencode/src/tool/agent-local.ts b/overlay/packages/opencode/src/tool/agent-local.ts index 4a950be..d5fb9d6 100644 --- a/overlay/packages/opencode/src/tool/agent-local.ts +++ b/overlay/packages/opencode/src/tool/agent-local.ts @@ -214,6 +214,7 @@ export const SessionTakeoverTool = tool({ agent: args.agent, sessionId: args.session_id, workspace: args.workspace, + fallbackWorkspace: normalizeWorktree(context), where: args.where, }) switch (plan.status) { diff --git a/test/agent-local.test.ts b/test/agent-local.test.ts index d44adff..f4c5c1f 100644 --- a/test/agent-local.test.ts +++ b/test/agent-local.test.ts @@ -429,13 +429,14 @@ test("session takeover plans an in-app handover and resolves the indexed workspa test("session takeover terminal mode resumes athena through this binary and delegates others", () => { process.env.ATHENA_CODE_HOME = workspace("athhome-takeover-term-") const ws = workspace("athtakeover-ws-") + const claudeWs = workspace("athtakeover-claude-") indexMessages(ws, [{ sessionId: "live-2", role: "user", ts: "msg-1", text: "draft the migration plan" }]) indexScannedSessions("claude", [ { sourceId: "claude-1", sourcePath: "/fixtures/claude/claude-1.jsonl", fingerprint: "1:1", - workspace: "/home/x/proj", + workspace: claudeWs, messages: [{ role: "user", ts: "t1", text: "tighten the rate limiter" }], }, ]) @@ -454,12 +455,36 @@ test("session takeover terminal mode resumes athena through this binary and dele status: "terminal", agent: "claude", sessionId: "claude-1", - workspace: "/home/x/proj", + workspace: claudeWs, command: "claude", args: ["--resume", "claude-1"], }) }) +test("session takeover terminal mode falls back when the indexed workspace is stale", () => { + process.env.ATHENA_CODE_HOME = workspace("athhome-takeover-stale-") + const fallback = workspace("athtakeover-fallback-") + const stale = join(tmpdir(), `athena-missing-${Date.now()}-${Math.random().toString(36).slice(2)}`) + indexScannedSessions("codex", [ + { + sourceId: "codex-stale", + sourcePath: "/fixtures/codex/codex-stale.jsonl", + fingerprint: "1:1", + workspace: stale, + messages: [{ role: "user", ts: "t1", text: "resume from a stale workspace" }], + }, + ]) + + expect(planSessionTakeover({ agent: "codex", sessionId: "codex-stale", where: "terminal", fallbackWorkspace: fallback })).toEqual({ + status: "terminal", + agent: "codex", + sessionId: "codex-stale", + workspace: fallback, + command: "codex", + args: ["resume", "--cd", fallback, "codex-stale"], + }) +}) + test("session takeover honors an explicit workspace override", () => { process.env.ATHENA_CODE_HOME = workspace("athhome-takeover-override-") const ws = workspace("athtakeover-ws-")