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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions overlay/packages/opencode/src/plugin/athena.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions overlay/packages/opencode/src/session/agent/session-takeover.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// 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 { 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
// 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)
}

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 }
| { 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
fallbackWorkspace?: 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") {
const cwd = launchWorkspace(workspace, params.fallbackWorkspace)
return { status: "terminal", agent, sessionId, workspace: cwd, ...takeoverResumeCommand(agent, sessionId, cwd) }
}
return { status: "in_app", agent, sessionId, workspace }
}
18 changes: 18 additions & 0 deletions overlay/packages/opencode/src/session/memory/sessionindex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,24 @@ export function athenaOwnedSessionIds(ids: string[]): Set<string> {
}
}

// 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.
Expand Down
88 changes: 88 additions & 0 deletions overlay/packages/opencode/src/tool/agent-local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -180,6 +181,93 @@ 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,
fallbackWorkspace: normalizeWorktree(context),
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: {},
Expand Down
111 changes: 111 additions & 0 deletions test/agent-local.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -403,3 +405,112 @@ 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-")
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: claudeWs,
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: 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-")
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",
})
})