diff --git a/src/agents/athena/agent.ts b/src/agents/athena/agent.ts
index 61c5eb3e2..9f56d7d64 100644
--- a/src/agents/athena/agent.ts
+++ b/src/agents/athena/agent.ts
@@ -316,7 +316,7 @@ Present the synthesis as a direct answer — the synthesis IS the deliverable. A
---------------------------
-The switch_agent tool switches the active agent. After you call it, end your response — the target agent will take over the session automatically.
+The switch_agent tool creates a new session with the target agent. First announce the handoff to the user (e.g., "Switching to Hephaestus — see you on the other side."), then call switch_agent. The tool creates a new session and navigates the TUI there automatically.
## Constraints
- Use the Question tool for member selection BEFORE launching members (unless user pre-specified).
diff --git a/src/features/agent-switch/applier.test.ts b/src/features/agent-switch/applier.test.ts
deleted file mode 100644
index cf6fb54f3..000000000
--- a/src/features/agent-switch/applier.test.ts
+++ /dev/null
@@ -1,229 +0,0 @@
-///
-
-import { beforeEach, describe, expect, test } from "bun:test"
-import { _resetForTesting, getPendingSwitch, setPendingSwitch } from "./state"
-import {
- _resetApplierForTesting,
- applyPendingSwitch,
- clearPendingSwitchRuntime,
-} from "./applier"
-import { schedulePendingSwitchApply } from "./scheduler"
-
-function createMockClient(overrides?: {
- onPrompt?: (input: { path: { id: string }; body: { agent: string } }) => void
- onCreate?: () => Record
- onMessages?: () => Record
- onStatus?: () => Record
-}) {
- return {
- session: {
- create: async () => overrides?.onCreate?.() ?? { data: { id: "new-ses" } },
- promptAsync: async (input: { path: { id: string }; body: { agent: string } }) => {
- overrides?.onPrompt?.(input)
- },
- messages: async () => overrides?.onMessages?.() ?? ({ data: [] }),
- status: overrides?.onStatus ? async () => overrides.onStatus!() : undefined,
- },
- }
-}
-
-describe("agent-switch applier", () => {
- beforeEach(() => {
- _resetForTesting()
- _resetApplierForTesting()
- })
-
- describe("#given fresh session creation flow", () => {
- test("#when scheduled apply runs, #then creates new session and prompts it", async () => {
- const promptedSessions: string[] = []
- const promptedAgents: string[] = []
- const client = createMockClient({
- onCreate: () => ({ data: { id: "fresh-ses-1" } }),
- onPrompt: (input) => {
- promptedSessions.push(input.path.id)
- promptedAgents.push(input.body.agent)
- },
- })
-
- setPendingSwitch("ses-1", "prometheus", "create plan")
- schedulePendingSwitchApply({
- sessionID: "ses-1",
- client: client as any,
- })
-
- await new Promise((resolve) => setTimeout(resolve, 300))
-
- expect(promptedSessions).toEqual(["fresh-ses-1"])
- expect(promptedAgents).toEqual(["Prometheus (Plan Builder)"])
- expect(getPendingSwitch("ses-1")).toBeUndefined()
- })
-
- test("#when apply runs directly, #then creates fresh session with parentID linking to source", async () => {
- let createInput: Record | undefined
- const client = {
- session: {
- create: async (input?: { body?: Record }) => {
- createInput = input?.body
- return { data: { id: "fresh-ses-2" } }
- },
- promptAsync: async () => {},
- messages: async () => ({ data: [] }),
- },
- }
-
- setPendingSwitch("ses-2", "atlas", "fix now")
- await applyPendingSwitch({
- sessionID: "ses-2",
- client: client as any,
- source: "idle",
- })
-
- expect(createInput).toEqual({
- parentID: "ses-2",
- title: "atlas (handoff)",
- })
- expect(getPendingSwitch("ses-2")).toBeUndefined()
- })
- })
-
- describe("#given agent name normalization", () => {
- test("#when agent has canonical display name, #then normalizes for prompt", async () => {
- const promptedAgents: string[] = []
- const client = createMockClient({
- onPrompt: (input) => {
- promptedAgents.push(input.body.agent)
- },
- })
-
- setPendingSwitch("ses-3", "Prometheus (Plan Builder)", "create plan")
- await applyPendingSwitch({
- sessionID: "ses-3",
- client: client as any,
- source: "idle",
- })
-
- expect(promptedAgents).toEqual(["Prometheus (Plan Builder)"])
- expect(getPendingSwitch("ses-3")).toBeUndefined()
- })
- })
-
- describe("#given transient failures", () => {
- test("#when create fails transiently, #then retries and eventually succeeds", async () => {
- let createAttempts = 0
- const client = {
- session: {
- create: async () => {
- createAttempts += 1
- if (createAttempts < 3) {
- throw new Error("temporary failure")
- }
- return { data: { id: "fresh-ses-retry" } }
- },
- promptAsync: async () => {},
- messages: async () => ({ data: [] }),
- },
- }
-
- setPendingSwitch("ses-4", "atlas", "fix this")
- await applyPendingSwitch({
- sessionID: "ses-4",
- client: client as any,
- source: "idle",
- })
-
- await new Promise((resolve) => setTimeout(resolve, 800))
-
- expect(createAttempts).toBe(3)
- expect(getPendingSwitch("ses-4")).toBeUndefined()
- })
- })
-
- describe("#given session idle wait", () => {
- test("#when session is busy, #then waits for idle before creating fresh session", async () => {
- let statusChecks = 0
- let createCalled = false
- const client = {
- session: {
- status: async () => {
- statusChecks += 1
- return {
- "ses-5": { type: statusChecks < 3 ? "running" : "idle" },
- }
- },
- create: async () => {
- createCalled = true
- return { data: { id: "fresh-ses-idle" } }
- },
- promptAsync: async () => {},
- messages: async () => ({ data: [] }),
- },
- }
-
- setPendingSwitch("ses-5", "atlas", "fix now")
- await applyPendingSwitch({
- sessionID: "ses-5",
- client: client as any,
- source: "idle",
- })
-
- expect(statusChecks).toBeGreaterThanOrEqual(3)
- expect(createCalled).toBe(true)
- expect(getPendingSwitch("ses-5")).toBeUndefined()
- })
- })
-
- describe("#given runtime cancellation", () => {
- test("#when clearPendingSwitchRuntime called, #then cancels pending retries", async () => {
- let attempts = 0
- const client = {
- session: {
- create: async () => {
- attempts += 1
- throw new Error("always failing")
- },
- promptAsync: async () => {},
- messages: async () => ({ data: [] }),
- },
- }
-
- setPendingSwitch("ses-6", "atlas", "fix this")
- await applyPendingSwitch({
- sessionID: "ses-6",
- client: client as any,
- source: "idle",
- })
-
- clearPendingSwitchRuntime("ses-6")
-
- const attemptsAfterClear = attempts
-
- await new Promise((resolve) => setTimeout(resolve, 300))
-
- expect(attempts).toBe(attemptsAfterClear)
- expect(getPendingSwitch("ses-6")).toBeUndefined()
- })
- })
-
- describe("#given create not available on client", () => {
- test("#when session.create is missing, #then enters retry path", async () => {
- const client = {
- session: {
- promptAsync: async () => {},
- messages: async () => ({ data: [] }),
- },
- }
-
- setPendingSwitch("ses-7", "atlas", "fix now")
- await applyPendingSwitch({
- sessionID: "ses-7",
- client: client as any,
- source: "idle",
- })
-
- expect(getPendingSwitch("ses-7")).toBeDefined()
-
- clearPendingSwitchRuntime("ses-7")
- expect(getPendingSwitch("ses-7")).toBeUndefined()
- })
- })
-})
diff --git a/src/features/agent-switch/applier.ts b/src/features/agent-switch/applier.ts
deleted file mode 100644
index e68a4118d..000000000
--- a/src/features/agent-switch/applier.ts
+++ /dev/null
@@ -1,197 +0,0 @@
-import { normalizeAgentForPrompt } from "../../shared/agent-display-names"
-import { log } from "../../shared/logger"
-import { clearPendingSwitch, getPendingSwitch } from "./state"
-import { waitForSessionIdle } from "./session-status"
-import { shouldClearAsAlreadyApplied } from "./apply-verification"
-import { createFreshSession } from "./session-creator"
-import {
- clearInFlight,
- clearRetryState,
- isApplyInFlight,
- markApplyInFlight,
- resetRetryStateForTesting,
- scheduleRetry,
-} from "./retry-state"
-
-type SessionClient = {
- session: {
- prompt?: (input: {
- path: { id: string }
- body: { agent: string; parts: Array<{ type: "text"; text: string }> }
- }) => Promise
- promptAsync: (input: {
- path: { id: string }
- body: { agent: string; parts: Array<{ type: "text"; text: string }> }
- }) => Promise
- create?: (input?: { body?: { parentID?: string; title?: string } }) => Promise
- messages: (input: { path: { id: string } }) => Promise
- status?: () => Promise
- }
- app?: {
- agents?: () => Promise
- }
- tui?: {
- publish?: (input: {
- body: {
- type: "tui.command.execute"
- properties: { command: string }
- }
- }) => Promise
- }
-}
-
-async function tryPromptWithCandidates(args: {
- client: SessionClient
- sessionID: string
- agent: string
- context: string
- source: string
-}): Promise {
- const { client, sessionID, agent, context, source } = args
- const targetAgent = normalizeAgentForPrompt(agent)
- if (!targetAgent) {
- throw new Error(`invalid target agent for switch prompt: ${agent}`)
- }
-
- try {
- const promptInput = {
- path: { id: sessionID },
- body: {
- agent: targetAgent,
- parts: [{ type: "text" as const, text: context }],
- },
- }
-
- if (client.session.prompt) {
- await client.session.prompt(promptInput)
- } else {
- await client.session.promptAsync(promptInput)
- }
-
- if (targetAgent !== agent) {
- log("[agent-switch] Normalized pending switch agent for prompt", {
- sessionID,
- source,
- requestedAgent: agent,
- usedAgent: targetAgent,
- })
- }
-
- return targetAgent
- } catch (error) {
- log("[agent-switch] Prompt attempt failed", {
- sessionID,
- source,
- requestedAgent: agent,
- attemptedAgent: targetAgent,
- error: String(error),
- })
- throw error
- }
-}
-
-export async function applyPendingSwitch(args: {
- sessionID: string
- client: SessionClient
- source: string
-}): Promise {
- const { sessionID, client, source } = args
- const pending = getPendingSwitch(sessionID)
- if (!pending) {
- clearRetryState(sessionID)
- return
- }
-
- if (isApplyInFlight(sessionID)) {
- return
- }
-
- markApplyInFlight(sessionID)
- log("[agent-switch] Applying pending switch", {
- sessionID,
- source,
- agent: pending.agent,
- })
-
- try {
- const alreadyApplied = await shouldClearAsAlreadyApplied({
- client,
- sessionID,
- targetAgent: pending.agent,
- })
- if (alreadyApplied) {
- clearPendingSwitch(sessionID)
- clearRetryState(sessionID)
- log("[agent-switch] Pending switch already applied by user-turn evidence; clearing state", {
- sessionID,
- source,
- agent: pending.agent,
- })
- return
- }
-
- const idleReady = await waitForSessionIdle({ client, sessionID })
- if (!idleReady) {
- throw new Error("session not idle before applying agent switch")
- }
-
- const newSessionID = await createFreshSession({
- client,
- sourceSessionID: sessionID,
- targetAgent: pending.agent,
- })
-
- await tryPromptWithCandidates({
- client,
- sessionID: newSessionID,
- agent: pending.agent,
- context: pending.context,
- source,
- })
-
- clearPendingSwitch(sessionID)
- clearRetryState(sessionID)
-
- log("[agent-switch] Pending switch applied via fresh session", {
- sourceSessionID: sessionID,
- newSessionID,
- source,
- agent: pending.agent,
- })
- } catch (error) {
- clearInFlight(sessionID)
- log("[agent-switch] Pending switch apply failed", {
- sessionID,
- source,
- error: String(error),
- })
- scheduleRetry({
- sessionID,
- source,
- onLimitReached: (attempts) => {
- log("[agent-switch] Retry limit reached; waiting for next trigger", {
- sessionID,
- attempts,
- source,
- })
- },
- retryFn: (attemptNumber) => {
- void applyPendingSwitch({
- sessionID,
- client,
- source: `retry:${attemptNumber}`,
- })
- },
- })
- }
-}
-
-export function clearPendingSwitchRuntime(sessionID: string): void {
- clearPendingSwitch(sessionID)
- clearRetryState(sessionID)
-}
-
-/** @internal For testing only */
-export function _resetApplierForTesting(): void {
- resetRetryStateForTesting()
-}
diff --git a/src/features/agent-switch/apply-verification.ts b/src/features/agent-switch/apply-verification.ts
deleted file mode 100644
index 4cfa81afe..000000000
--- a/src/features/agent-switch/apply-verification.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import { extractMessageList, hasNewUserTurnForTargetAgent, hasRecentUserTurnForTargetAgent } from "./message-inspection"
-import { log } from "../../shared/logger"
-import { sleepWithDelay } from "./session-status"
-
-type SessionClient = {
- session: {
- messages: (input: { path: { id: string } }) => Promise
- }
-}
-
-export async function fetchMessages(args: {
- client: SessionClient
- sessionID: string
-}): Promise>> {
- const response = await args.client.session.messages({ path: { id: args.sessionID } })
- return extractMessageList(response)
-}
-
-export async function verifySwitchObserved(args: {
- client: SessionClient
- sessionID: string
- targetAgent: string
- baselineCount: number
-}): Promise {
- const { client, sessionID, targetAgent, baselineCount } = args
- const delays = [100, 300, 800, 1500] as const
-
- for (const delay of delays) {
- await sleepWithDelay(delay)
- try {
- const messages = await fetchMessages({ client, sessionID })
- if (hasNewUserTurnForTargetAgent({ messages, targetAgent, baselineCount })) {
- return true
- }
- } catch (error) {
- log("[agent-switch] Verification read failed", {
- sessionID,
- error: String(error),
- })
- }
- }
-
- return false
-}
-
-export async function shouldClearAsAlreadyApplied(args: {
- client: SessionClient
- sessionID: string
- targetAgent: string
-}): Promise {
- const { client, sessionID, targetAgent } = args
-
- try {
- const messages = await fetchMessages({ client, sessionID })
- return hasRecentUserTurnForTargetAgent({ messages, targetAgent })
- } catch {
- return false
- }
-}
diff --git a/src/features/agent-switch/index.ts b/src/features/agent-switch/index.ts
deleted file mode 100644
index 15be54231..000000000
--- a/src/features/agent-switch/index.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-export {
- setPendingSwitch,
- getPendingSwitch,
- clearPendingSwitch,
- consumePendingSwitch,
- _resetForTesting,
-} from "./state"
-export type { PendingSwitch } from "./state"
diff --git a/src/features/agent-switch/message-inspection.ts b/src/features/agent-switch/message-inspection.ts
deleted file mode 100644
index 4a2fc9d6c..000000000
--- a/src/features/agent-switch/message-inspection.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-import { getAgentConfigKey } from "../../shared/agent-display-names"
-
-export interface MessageRoleAgent {
- role: string
- agent: string
-}
-
-export function extractMessageList(response: unknown): Array> {
- if (Array.isArray(response)) {
- return response.filter((item): item is Record => typeof item === "object" && item !== null)
- }
- if (typeof response === "object" && response !== null) {
- const data = (response as Record).data
- if (Array.isArray(data)) {
- return data.filter((item): item is Record => typeof item === "object" && item !== null)
- }
- }
- return []
-}
-
-function getRoleAgent(message: Record): MessageRoleAgent | undefined {
- const info = message.info
- if (typeof info !== "object" || info === null) {
- return undefined
- }
-
- const role = (info as Record).role
- const agent = (info as Record).agent
- if (typeof role !== "string" || typeof agent !== "string") {
- return undefined
- }
-
- return { role, agent }
-}
-
-export function getLatestUserAgent(messages: Array>): string | undefined {
- for (let index = messages.length - 1; index >= 0; index -= 1) {
- const message = messages[index]
- if (!message) {
- continue
- }
-
- const roleAgent = getRoleAgent(message)
- if (!roleAgent || roleAgent.role !== "user") {
- continue
- }
-
- return roleAgent.agent
- }
-
- return undefined
-}
-
-export function hasRecentUserTurnForTargetAgent(args: {
- messages: Array>
- targetAgent: string
- lookback?: number
-}): boolean {
- const { messages, targetAgent, lookback = 8 } = args
- const targetKey = getAgentConfigKey(targetAgent)
- const start = Math.max(0, messages.length - lookback)
-
- for (let index = messages.length - 1; index >= start; index -= 1) {
- const message = messages[index]
- if (!message) {
- continue
- }
-
- const roleAgent = getRoleAgent(message)
- if (!roleAgent || roleAgent.role !== "user") {
- continue
- }
-
- if (getAgentConfigKey(roleAgent.agent) === targetKey) {
- return true
- }
- }
-
- return false
-}
-
-export function hasNewUserTurnForTargetAgent(args: {
- messages: Array>
- targetAgent: string
- baselineCount: number
-}): boolean {
- const { messages, targetAgent, baselineCount } = args
- const targetKey = getAgentConfigKey(targetAgent)
-
- if (messages.length <= baselineCount) {
- return false
- }
-
- const newMessages = messages.slice(Math.max(0, baselineCount))
- for (const message of newMessages) {
- const roleAgent = getRoleAgent(message)
- if (!roleAgent || roleAgent.role !== "user") {
- continue
- }
-
- if (getAgentConfigKey(roleAgent.agent) === targetKey) {
- return true
- }
- }
-
- return false
-}
diff --git a/src/features/agent-switch/retry-state.ts b/src/features/agent-switch/retry-state.ts
deleted file mode 100644
index 05dfc086f..000000000
--- a/src/features/agent-switch/retry-state.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-const RETRY_DELAYS_MS = [50, 250, 500, 1000, 2000, 5000] as const
-
-const inFlightSessions = new Set()
-const retryAttempts = new Map()
-const retryTimers = new Map>()
-
-export function isApplyInFlight(sessionID: string): boolean {
- return inFlightSessions.has(sessionID)
-}
-
-export function markApplyInFlight(sessionID: string): void {
- inFlightSessions.add(sessionID)
-}
-
-export function clearRetryState(sessionID: string): void {
- const timer = retryTimers.get(sessionID)
- if (timer) {
- clearTimeout(timer)
- retryTimers.delete(sessionID)
- }
- retryAttempts.delete(sessionID)
- inFlightSessions.delete(sessionID)
-}
-
-export function clearInFlight(sessionID: string): void {
- inFlightSessions.delete(sessionID)
-}
-
-export function scheduleRetry(args: {
- sessionID: string
- source: string
- retryFn: (attemptNumber: number) => void
- onLimitReached: (attempts: number) => void
-}): void {
- const { sessionID, retryFn, onLimitReached } = args
- const attempts = retryAttempts.get(sessionID) ?? 0
- if (attempts >= RETRY_DELAYS_MS.length) {
- onLimitReached(attempts)
- return
- }
-
- const delay = RETRY_DELAYS_MS[attempts]
- retryAttempts.set(sessionID, attempts + 1)
-
- const existing = retryTimers.get(sessionID)
- if (existing) {
- clearTimeout(existing)
- }
-
- const timer = setTimeout(() => {
- retryTimers.delete(sessionID)
- retryFn(attempts + 1)
- }, delay)
-
- retryTimers.set(sessionID, timer)
-}
-
-/** @internal For testing only */
-export function resetRetryStateForTesting(): void {
- for (const timer of retryTimers.values()) {
- clearTimeout(timer)
- }
- retryTimers.clear()
- retryAttempts.clear()
- inFlightSessions.clear()
-}
diff --git a/src/features/agent-switch/scheduler.ts b/src/features/agent-switch/scheduler.ts
deleted file mode 100644
index c964086cd..000000000
--- a/src/features/agent-switch/scheduler.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { log } from "../../shared/logger"
-import { scheduleRetry } from "./retry-state"
-import { applyPendingSwitch } from "./applier"
-
-type SessionClient = {
- session: {
- prompt?: (input: {
- path: { id: string }
- body: { agent: string; parts: Array<{ type: "text"; text: string }> }
- }) => Promise
- promptAsync: (input: {
- path: { id: string }
- body: { agent: string; parts: Array<{ type: "text"; text: string }> }
- }) => Promise
- create?: (input?: { body?: { parentID?: string; title?: string } }) => Promise
- messages: (input: { path: { id: string } }) => Promise
- status?: () => Promise
- }
-}
-
-export function schedulePendingSwitchApply(args: {
- sessionID: string
- client: SessionClient
-}): void {
- const { sessionID, client } = args
- scheduleRetry({
- sessionID,
- source: "tool",
- onLimitReached: (attempts) => {
- log("[agent-switch] Retry limit reached; waiting for next trigger", {
- sessionID,
- attempts,
- source: "tool",
- })
- },
- retryFn: (attemptNumber) => {
- void applyPendingSwitch({
- sessionID,
- client,
- source: `retry:${attemptNumber}`,
- })
- },
- })
-}
diff --git a/src/features/agent-switch/session-creator.test.ts b/src/features/agent-switch/session-creator.test.ts
deleted file mode 100644
index 40fe20f16..000000000
--- a/src/features/agent-switch/session-creator.test.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-///
-
-import { describe, expect, test } from "bun:test"
-import { createFreshSession } from "./session-creator"
-
-describe("session-creator", () => {
- describe("#given SDK response with data wrapper", () => {
- test("#when create returns { data: { id } }, #then extracts session ID", async () => {
- const client = {
- session: {
- create: async () => ({ data: { id: "new-session-123" } }),
- },
- }
-
- const result = await createFreshSession({
- client,
- sourceSessionID: "source-ses",
- targetAgent: "atlas",
- })
-
- expect(result).toBe("new-session-123")
- })
- })
-
- describe("#given SDK response without data wrapper", () => {
- test("#when create returns { id } directly, #then extracts session ID", async () => {
- const client = {
- session: {
- create: async () => ({ id: "direct-session-456" }),
- },
- }
-
- const result = await createFreshSession({
- client,
- sourceSessionID: "source-ses",
- targetAgent: "prometheus",
- })
-
- expect(result).toBe("direct-session-456")
- })
- })
-
- describe("#given create not available", () => {
- test("#when session.create is undefined, #then throws", async () => {
- const client = { session: {} }
-
- await expect(
- createFreshSession({
- client: client as any,
- sourceSessionID: "source-ses",
- targetAgent: "atlas",
- }),
- ).rejects.toThrow("session.create not available")
- })
- })
-
- describe("#given invalid response", () => {
- test("#when create returns no id, #then throws", async () => {
- const client = {
- session: {
- create: async () => ({ data: {} }),
- },
- }
-
- await expect(
- createFreshSession({
- client,
- sourceSessionID: "source-ses",
- targetAgent: "atlas",
- }),
- ).rejects.toThrow("failed to extract session ID")
- })
- })
-
- describe("#given parentID and title", () => {
- test("#when creating session, #then passes sourceSessionID as parentID", async () => {
- let capturedInput: Record | undefined
- const client = {
- session: {
- create: async (input?: { body?: Record }) => {
- capturedInput = input?.body
- return { id: "new-ses" }
- },
- },
- }
-
- await createFreshSession({
- client,
- sourceSessionID: "parent-ses-id",
- targetAgent: "atlas",
- })
-
- expect(capturedInput).toEqual({
- parentID: "parent-ses-id",
- title: "atlas (handoff)",
- })
- })
- })
-})
diff --git a/src/features/agent-switch/session-creator.ts b/src/features/agent-switch/session-creator.ts
deleted file mode 100644
index 5851b6c2c..000000000
--- a/src/features/agent-switch/session-creator.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { log } from "../../shared/logger"
-
-type CreateClient = {
- session: {
- create?: (input?: { body?: { parentID?: string; title?: string } }) => Promise
- }
-}
-
-function extractSessionId(response: unknown): string | undefined {
- if (typeof response !== "object" || response === null) {
- return undefined
- }
-
- const root = response as Record
-
- if (typeof root.id === "string" && root.id.length > 0) {
- return root.id
- }
-
- const data = root.data
- if (typeof data === "object" && data !== null) {
- const dataRecord = data as Record
- if (typeof dataRecord.id === "string" && dataRecord.id.length > 0) {
- return dataRecord.id
- }
- }
-
- return undefined
-}
-
-export async function createFreshSession(args: {
- client: CreateClient
- sourceSessionID: string
- targetAgent: string
-}): Promise {
- const { client, sourceSessionID, targetAgent } = args
-
- if (!client.session.create) {
- throw new Error("session.create not available on SDK client")
- }
-
- const response = await client.session.create({
- body: {
- parentID: sourceSessionID,
- title: `${targetAgent} (handoff)`,
- },
- })
-
- const newSessionID = extractSessionId(response)
- if (!newSessionID) {
- throw new Error(`failed to extract session ID from create response: ${JSON.stringify(response)}`)
- }
-
- log("[agent-switch] Created fresh session for handoff", {
- sourceSessionID,
- newSessionID,
- targetAgent,
- })
-
- return newSessionID
-}
diff --git a/src/features/agent-switch/session-status.ts b/src/features/agent-switch/session-status.ts
deleted file mode 100644
index dfdc09f94..000000000
--- a/src/features/agent-switch/session-status.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { log } from "../../shared/logger"
-
-type SessionClient = {
- session: {
- status?: () => Promise
- }
-}
-
-function sleep(ms: number): Promise {
- return new Promise((resolve) => setTimeout(resolve, ms))
-}
-
-function getSessionStatusType(statusResponse: unknown, sessionID: string): string | undefined {
- if (typeof statusResponse !== "object" || statusResponse === null) {
- return undefined
- }
-
- const root = statusResponse as Record
- const data = (typeof root.data === "object" && root.data !== null)
- ? root.data as Record
- : root
-
- const entry = data[sessionID]
- if (typeof entry !== "object" || entry === null) {
- return undefined
- }
-
- const entryType = (entry as Record).type
- return typeof entryType === "string" ? entryType : undefined
-}
-
-export async function waitForSessionIdle(args: {
- client: SessionClient
- sessionID: string
- timeoutMs?: number
-}): Promise {
- const { client, sessionID, timeoutMs = 15000 } = args
- if (!client.session.status) {
- return true
- }
-
- const start = Date.now()
- while (Date.now() - start < timeoutMs) {
- try {
- const statusResponse = await client.session.status()
- const statusType = getSessionStatusType(statusResponse, sessionID)
- // /session/status only tracks non-idle sessions in SessionStatus.list().
- // Missing entry means idle.
- if (!statusType || statusType === "idle") {
- return true
- }
- } catch (error) {
- log("[agent-switch] Session status check failed", {
- sessionID,
- error: String(error),
- })
- return true
- }
-
- await sleep(200)
- }
-
- return false
-}
-
-export async function sleepWithDelay(ms: number): Promise {
- await sleep(ms)
-}
diff --git a/src/features/agent-switch/state.test.ts b/src/features/agent-switch/state.test.ts
deleted file mode 100644
index 00f879bec..000000000
--- a/src/features/agent-switch/state.test.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-const { describe, test, expect, beforeEach } = require("bun:test")
-import {
- setPendingSwitch,
- getPendingSwitch,
- clearPendingSwitch,
- consumePendingSwitch,
- _resetForTesting,
-} from "./state"
-
-describe("agent-switch state", () => {
- beforeEach(() => {
- _resetForTesting()
- })
-
- //#given a pending switch is set
- //#when consumePendingSwitch is called
- //#then it returns the switch and removes it
- test("should store and consume a pending switch", () => {
- setPendingSwitch("session-1", "atlas", "Fix these findings")
-
- const entry = consumePendingSwitch("session-1")
-
- expect(entry).toEqual({ agent: "atlas", context: "Fix these findings" })
- expect(consumePendingSwitch("session-1")).toBeUndefined()
- })
-
- //#given no pending switch exists
- //#when consumePendingSwitch is called
- //#then it returns undefined
- test("should return undefined when no switch is pending", () => {
- expect(consumePendingSwitch("session-1")).toBeUndefined()
- })
-
- //#given a pending switch is set
- //#when a new switch is set for the same session
- //#then the latest switch wins
- test("should overwrite previous switch for same session", () => {
- setPendingSwitch("session-1", "atlas", "Fix A")
- setPendingSwitch("session-1", "prometheus", "Plan B")
-
- const entry = consumePendingSwitch("session-1")
-
- expect(entry).toEqual({ agent: "prometheus", context: "Plan B" })
- })
-
- //#given switches for different sessions
- //#when consumed separately
- //#then each session gets its own switch
- test("should isolate switches by session", () => {
- setPendingSwitch("session-1", "atlas", "Fix A")
- setPendingSwitch("session-2", "prometheus", "Plan B")
-
- expect(consumePendingSwitch("session-1")).toEqual({ agent: "atlas", context: "Fix A" })
- expect(consumePendingSwitch("session-2")).toEqual({ agent: "prometheus", context: "Plan B" })
- })
-
- test("should allow reading without consuming", () => {
- setPendingSwitch("session-1", "atlas", "Fix A")
-
- expect(getPendingSwitch("session-1")).toEqual({ agent: "atlas", context: "Fix A" })
- expect(getPendingSwitch("session-1")).toEqual({ agent: "atlas", context: "Fix A" })
- })
-
- test("should clear pending switch explicitly", () => {
- setPendingSwitch("session-1", "atlas", "Fix A")
-
- clearPendingSwitch("session-1")
-
- expect(getPendingSwitch("session-1")).toBeUndefined()
- })
-})
-
-export {}
diff --git a/src/features/agent-switch/state.ts b/src/features/agent-switch/state.ts
deleted file mode 100644
index 17f956122..000000000
--- a/src/features/agent-switch/state.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"
-import { join } from "node:path"
-import { tmpdir } from "node:os"
-
-export interface PendingSwitch {
- agent: string
- context: string
-}
-
-const PENDING_SWITCH_STATE_FILE = process.platform === "win32"
- ? join(tmpdir(), "oh-my-opencode-agent-switch.json")
- : "/tmp/oh-my-opencode-agent-switch.json"
-
-const pendingSwitches = new Map()
-
-function isPendingSwitch(value: unknown): value is PendingSwitch {
- if (typeof value !== "object" || value === null) return false
- const entry = value as Record
- return typeof entry.agent === "string" && typeof entry.context === "string"
-}
-
-function readPersistentState(): Record {
- try {
- if (!existsSync(PENDING_SWITCH_STATE_FILE)) {
- return {}
- }
-
- const raw = readFileSync(PENDING_SWITCH_STATE_FILE, "utf8")
- const parsed = JSON.parse(raw)
- if (typeof parsed !== "object" || parsed === null) {
- return {}
- }
-
- const state: Record = {}
- for (const [sessionID, value] of Object.entries(parsed)) {
- if (isPendingSwitch(value)) {
- state[sessionID] = value
- }
- }
-
- return state
- } catch {
- return {}
- }
-}
-
-function writePersistentState(state: Record): void {
- try {
- const keys = Object.keys(state)
- if (keys.length === 0) {
- rmSync(PENDING_SWITCH_STATE_FILE, { force: true })
- return
- }
-
- writeFileSync(PENDING_SWITCH_STATE_FILE, JSON.stringify(state), "utf8")
- } catch {
- // ignore persistence errors
- }
-}
-
-export function setPendingSwitch(sessionID: string, agent: string, context: string): void {
- const entry = { agent, context }
- pendingSwitches.set(sessionID, entry)
-
- const state = readPersistentState()
- state[sessionID] = entry
- writePersistentState(state)
-}
-
-export function getPendingSwitch(sessionID: string): PendingSwitch | undefined {
- const inMemory = pendingSwitches.get(sessionID)
- if (inMemory) {
- return inMemory
- }
-
- const state = readPersistentState()
- const fromDisk = state[sessionID]
- if (fromDisk) {
- pendingSwitches.set(sessionID, fromDisk)
- }
- return fromDisk
-}
-
-export function clearPendingSwitch(sessionID: string): void {
- pendingSwitches.delete(sessionID)
-
- const state = readPersistentState()
- delete state[sessionID]
- writePersistentState(state)
-}
-
-export function consumePendingSwitch(sessionID: string): PendingSwitch | undefined {
- const entry = getPendingSwitch(sessionID)
- clearPendingSwitch(sessionID)
- return entry
-}
-
-/** @internal For testing only */
-export function _resetForTesting(): void {
- pendingSwitches.clear()
- rmSync(PENDING_SWITCH_STATE_FILE, { force: true })
-}
diff --git a/src/features/agent-switch/tui-agent-sync.ts b/src/features/agent-switch/tui-agent-sync.ts
deleted file mode 100644
index d4764ef39..000000000
--- a/src/features/agent-switch/tui-agent-sync.ts
+++ /dev/null
@@ -1,132 +0,0 @@
-import { getAgentConfigKey } from "../../shared/agent-display-names"
-import { log, normalizeSDKResponse } from "../../shared"
-
-type TuiClient = {
- app?: {
- agents?: () => Promise
- }
- tui?: {
- publish?: (input: {
- body: {
- type: "tui.command.execute"
- properties: { command: string }
- }
- }) => Promise
- }
-}
-
-type AgentInfo = {
- name?: string
- mode?: "subagent" | "primary" | "all"
- hidden?: boolean
-}
-
-function isCliClient(): boolean {
- return (process.env["OPENCODE_CLIENT"] ?? "cli") === "cli"
-}
-
-function resolveCyclePlan(args: {
- orderedAgentNames: string[]
- sourceAgent: string
- targetAgent: string
-}): { command: "agent.cycle" | "agent.cycle.reverse"; steps: number } | undefined {
- const { orderedAgentNames, sourceAgent, targetAgent } = args
- if (orderedAgentNames.length < 2) {
- return undefined
- }
-
- const orderedKeys = orderedAgentNames.map((name) => getAgentConfigKey(name))
- const sourceKey = getAgentConfigKey(sourceAgent)
- const targetKey = getAgentConfigKey(targetAgent)
-
- const sourceIndex = orderedKeys.indexOf(sourceKey)
- const targetIndex = orderedKeys.indexOf(targetKey)
- if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) {
- return undefined
- }
-
- const size = orderedKeys.length
- const forward = (targetIndex - sourceIndex + size) % size
- const backward = (sourceIndex - targetIndex + size) % size
-
- if (forward <= backward) {
- return { command: "agent.cycle", steps: forward }
- }
-
- return { command: "agent.cycle.reverse", steps: backward }
-}
-
-export async function syncCliTuiAgentSelectionAfterSwitch(args: {
- client: TuiClient
- sessionID: string
- sourceAgent: string | undefined
- targetAgent: string
- source: string
-}): Promise {
- const { client, sessionID, sourceAgent, targetAgent, source } = args
-
- if (!isCliClient()) {
- return
- }
-
- if (!sourceAgent || !client.app?.agents || !client.tui?.publish) {
- return
- }
-
- const sourceKey = getAgentConfigKey(sourceAgent)
- const targetKey = getAgentConfigKey(targetAgent)
-
- // Scope to Athena handoffs where CLI TUI can show stale local-agent selection.
- if (sourceKey !== "athena" || (targetKey !== "atlas" && targetKey !== "prometheus")) {
- return
- }
-
- try {
- const response = await client.app.agents()
- const agents = normalizeSDKResponse(response, [] as AgentInfo[], {
- preferResponseOnMissingData: true,
- })
-
- const orderedPrimaryAgents = agents
- .filter((agent) => typeof agent.name === "string" && agent.mode !== "subagent" && agent.hidden !== true)
- .map((agent) => agent.name as string)
-
- const plan = resolveCyclePlan({
- orderedAgentNames: orderedPrimaryAgents,
- sourceAgent,
- targetAgent,
- })
-
- if (!plan || plan.steps <= 0) {
- return
- }
-
- for (let step = 0; step < plan.steps; step += 1) {
- await client.tui.publish({
- body: {
- type: "tui.command.execute",
- properties: {
- command: plan.command,
- },
- },
- })
- }
-
- log("[agent-switch] Synced CLI TUI local agent after handoff", {
- sessionID,
- source,
- sourceAgent,
- targetAgent,
- command: plan.command,
- steps: plan.steps,
- })
- } catch (error) {
- log("[agent-switch] Failed syncing CLI TUI local agent after handoff", {
- sessionID,
- source,
- sourceAgent,
- targetAgent,
- error: String(error),
- })
- }
-}
diff --git a/src/hooks/agent-switch/hook.test.ts b/src/hooks/agent-switch/hook.test.ts
deleted file mode 100644
index 1d55cd543..000000000
--- a/src/hooks/agent-switch/hook.test.ts
+++ /dev/null
@@ -1,363 +0,0 @@
-///
-
-import { beforeEach, describe, expect, test } from "bun:test"
-import { createAgentSwitchHook } from "./hook"
-import {
- _resetForTesting,
- getPendingSwitch,
- setPendingSwitch,
-} from "../../features/agent-switch"
-import { _resetApplierForTesting, clearPendingSwitchRuntime } from "../../features/agent-switch/applier"
-
-describe("agent-switch hook", () => {
- beforeEach(() => {
- _resetForTesting()
- _resetApplierForTesting()
- })
-
- test("consumes pending switch only after successful promptAsync", async () => {
- const promptAsyncCalls: Array> = []
- let switched = false
- const ctx = {
- client: {
- session: {
- create: async () => ({ data: { id: "fresh-ses-1" } }),
- promptAsync: async (args: Record) => {
- promptAsyncCalls.push(args)
- switched = true
- },
- messages: async () => switched
- ? ({ data: [{ info: { role: "user", agent: "Prometheus (Plan Builder)" } }] })
- : ({ data: [] }),
- message: async () => ({ data: { parts: [] } }),
- },
- },
- } as any
-
- setPendingSwitch("ses-1", "prometheus", "plan this")
- const hook = createAgentSwitchHook(ctx)
-
- await hook.event({
- event: {
- type: "session.idle",
- properties: { sessionID: "ses-1" },
- },
- })
-
- expect(promptAsyncCalls).toHaveLength(1)
- expect(getPendingSwitch("ses-1")).toBeUndefined()
- })
-
- test("keeps pending switch when promptAsync fails", async () => {
- const ctx = {
- client: {
- session: {
- create: async () => ({ data: { id: "fresh-ses-2" } }),
- promptAsync: async () => {
- throw new Error("temporary failure")
- },
- messages: async () => ({ data: [] }),
- message: async () => ({ data: { parts: [] } }),
- },
- },
- } as any
-
- setPendingSwitch("ses-2", "atlas", "fix this")
- const hook = createAgentSwitchHook(ctx)
-
- await hook.event({
- event: {
- type: "session.idle",
- properties: { sessionID: "ses-2" },
- },
- })
-
- expect(getPendingSwitch("ses-2")).toEqual({
- agent: "atlas",
- context: "fix this",
- })
-
- clearPendingSwitchRuntime("ses-2")
- })
-
- test("retries after transient failure and eventually clears pending switch", async () => {
- let attempts = 0
- let switched = false
- const ctx = {
- client: {
- session: {
- create: async () => ({ data: { id: "fresh-ses-3" } }),
- promptAsync: async () => {
- attempts += 1
- if (attempts === 1) {
- throw new Error("temporary failure")
- }
- switched = true
- },
- messages: async () => switched
- ? ({ data: [{ info: { role: "user", agent: "Prometheus (Plan Builder)" } }] })
- : ({ data: [] }),
- message: async () => ({ data: { parts: [] } }),
- },
- },
- } as any
-
- setPendingSwitch("ses-3", "prometheus", "plan this")
- const hook = createAgentSwitchHook(ctx)
-
- await hook.event({
- event: {
- type: "session.idle",
- properties: { sessionID: "ses-3" },
- },
- })
-
- await new Promise((resolve) => setTimeout(resolve, 350))
-
- expect(attempts).toBe(2)
- expect(getPendingSwitch("ses-3")).toBeUndefined()
- })
-
- test("clears pending switch on session.deleted", async () => {
- const ctx = {
- client: {
- session: {
- promptAsync: async () => {},
- messages: async () => ({ data: [] }),
- message: async () => ({ data: { parts: [] } }),
- },
- },
- } as any
-
- setPendingSwitch("ses-4", "atlas", "fix this")
- const hook = createAgentSwitchHook(ctx)
-
- await hook.event({
- event: {
- type: "session.deleted",
- properties: { info: { id: "ses-4" } },
- },
- })
-
- expect(getPendingSwitch("ses-4")).toBeUndefined()
- })
-
- test("clears pending switch on session.error with info.id", async () => {
- const ctx = {
- client: {
- session: {
- promptAsync: async () => {},
- messages: async () => ({ data: [] }),
- message: async () => ({ data: { parts: [] } }),
- },
- },
- } as any
-
- setPendingSwitch("ses-10", "atlas", "fix this")
- const hook = createAgentSwitchHook(ctx)
-
- await hook.event({
- event: {
- type: "session.error",
- properties: { info: { id: "ses-10" } },
- },
- })
-
- expect(getPendingSwitch("ses-10")).toBeUndefined()
- })
-
- test("clears pending switch on session.error with sessionID property", async () => {
- const ctx = {
- client: {
- session: {
- promptAsync: async () => {},
- messages: async () => ({ data: [] }),
- message: async () => ({ data: { parts: [] } }),
- },
- },
- } as any
-
- setPendingSwitch("ses-11", "atlas", "fix this")
- const hook = createAgentSwitchHook(ctx)
-
- await hook.event({
- event: {
- type: "session.error",
- properties: { sessionID: "ses-11" },
- },
- })
-
- expect(getPendingSwitch("ses-11")).toBeUndefined()
- })
-
- test("applies queued pending switch on terminal message.updated", async () => {
- const promptAsyncCalls: Array> = []
- let switched = false
- const ctx = {
- client: {
- session: {
- create: async () => ({ data: { id: "fresh-ses-6" } }),
- promptAsync: async (args: Record) => {
- promptAsyncCalls.push(args)
- switched = true
- },
- messages: async () => switched
- ? ({ data: [{ info: { role: "user", agent: "Atlas (Plan Executor)" } }] })
- : ({ data: [] }),
- message: async () => ({ data: { parts: [] } }),
- },
- },
- } as any
-
- setPendingSwitch("ses-6", "atlas", "fix now")
- const hook = createAgentSwitchHook(ctx)
-
- await hook.event({
- event: {
- type: "message.updated",
- properties: {
- info: {
- id: "msg-6",
- sessionID: "ses-6",
- role: "assistant",
- agent: "Athena (Council)",
- finish: "stop",
- },
- },
- },
- })
-
- expect(promptAsyncCalls).toHaveLength(1)
- const body = promptAsyncCalls[0]?.body as { agent?: string } | undefined
- expect(body?.agent).toBe("Atlas (Plan Executor)")
- expect(getPendingSwitch("ses-6")).toBeUndefined()
- })
-
- test("applies queued pending switch on terminal message.updated even when role is missing", async () => {
- const promptAsyncCalls: Array> = []
- let switched = false
- const ctx = {
- client: {
- session: {
- create: async () => ({ data: { id: "fresh-ses-8" } }),
- promptAsync: async (args: Record) => {
- promptAsyncCalls.push(args)
- switched = true
- },
- messages: async () => switched
- ? ({ data: [{ info: { role: "user", agent: "Atlas (Plan Executor)" } }] })
- : ({ data: [] }),
- message: async () => ({ data: { parts: [] } }),
- },
- },
- } as any
-
- setPendingSwitch("ses-8", "atlas", "fix now")
- const hook = createAgentSwitchHook(ctx)
-
- await hook.event({
- event: {
- type: "message.updated",
- properties: {
- info: {
- id: "msg-8",
- sessionID: "ses-8",
- agent: "Athena (Council)",
- finish: true,
- },
- },
- },
- })
-
- expect(promptAsyncCalls).toHaveLength(1)
- const body = promptAsyncCalls[0]?.body as { agent?: string } | undefined
- expect(body?.agent).toBe("Atlas (Plan Executor)")
- expect(getPendingSwitch("ses-8")).toBeUndefined()
- })
-
- test("applies queued pending switch on terminal message.part.updated step-finish", async () => {
- const promptAsyncCalls: Array> = []
- let switched = false
- const ctx = {
- client: {
- session: {
- create: async () => ({ data: { id: "fresh-ses-7" } }),
- promptAsync: async (args: Record) => {
- promptAsyncCalls.push(args)
- switched = true
- },
- messages: async () => switched
- ? ({ data: [{ info: { role: "user", agent: "Atlas (Plan Executor)" } }] })
- : ({ data: [] }),
- message: async () => ({ data: { parts: [] } }),
- },
- },
- } as any
-
- setPendingSwitch("ses-7", "atlas", "fix now")
- const hook = createAgentSwitchHook(ctx)
-
- await hook.event({
- event: {
- type: "message.part.updated",
- properties: {
- info: {
- sessionID: "ses-7",
- role: "assistant",
- },
- part: {
- id: "part-finish-1",
- sessionID: "ses-7",
- type: "step-finish",
- reason: "stop",
- },
- },
- },
- })
-
- expect(promptAsyncCalls).toHaveLength(1)
- const body = promptAsyncCalls[0]?.body as { agent?: string } | undefined
- expect(body?.agent).toBe("Atlas (Plan Executor)")
- expect(getPendingSwitch("ses-7")).toBeUndefined()
- })
-
- test("applies queued pending switch on session.status idle", async () => {
- const promptAsyncCalls: Array> = []
- let switched = false
- const ctx = {
- client: {
- session: {
- create: async () => ({ data: { id: "fresh-ses-9" } }),
- promptAsync: async (args: Record) => {
- promptAsyncCalls.push(args)
- switched = true
- },
- messages: async () => switched
- ? ({ data: [{ info: { role: "user", agent: "Atlas (Plan Executor)" } }] })
- : ({ data: [] }),
- message: async () => ({ data: { parts: [] } }),
- },
- },
- } as any
-
- setPendingSwitch("ses-9", "atlas", "fix now")
- const hook = createAgentSwitchHook(ctx)
-
- await hook.event({
- event: {
- type: "session.status",
- properties: {
- sessionID: "ses-9",
- status: {
- type: "idle",
- },
- },
- },
- })
-
- expect(promptAsyncCalls).toHaveLength(1)
- const body = promptAsyncCalls[0]?.body as { agent?: string } | undefined
- expect(body?.agent).toBe("Atlas (Plan Executor)")
- expect(getPendingSwitch("ses-9")).toBeUndefined()
- })
-})
diff --git a/src/hooks/agent-switch/hook.ts b/src/hooks/agent-switch/hook.ts
deleted file mode 100644
index df151b2d5..000000000
--- a/src/hooks/agent-switch/hook.ts
+++ /dev/null
@@ -1,141 +0,0 @@
-import type { PluginInput } from "@opencode-ai/plugin"
-import { getPendingSwitch } from "../../features/agent-switch"
-import { applyPendingSwitch, clearPendingSwitchRuntime } from "../../features/agent-switch/applier"
-import {
- isTerminalFinishValue,
- isTerminalStepFinishPart,
-} from "./terminal-detection"
-
-function getSessionIDFromStatusEvent(input: { event: { properties?: Record } }): string | undefined {
- const props = input.event.properties as Record | undefined
- const fromProps = typeof props?.sessionID === "string" ? props.sessionID : undefined
- if (fromProps) {
- return fromProps
- }
-
- const status = props?.status as Record | undefined
- const fromStatus = typeof status?.sessionID === "string" ? status.sessionID : undefined
- return fromStatus
-}
-
-function getStatusTypeFromEvent(input: { event: { properties?: Record } }): string | undefined {
- const props = input.event.properties as Record | undefined
- const directType = typeof props?.type === "string" ? props.type : undefined
- if (directType) {
- return directType
- }
-
- const status = props?.status as Record | undefined
- const statusType = typeof status?.type === "string" ? status.type : undefined
- return statusType
-}
-
-export function createAgentSwitchHook(ctx: PluginInput) {
- return {
- event: async (input: { event: { type: string; properties?: Record } }): Promise => {
- if (input.event.type === "session.deleted") {
- const props = input.event.properties as Record | undefined
- const info = props?.info as Record | undefined
- const deletedSessionID = info?.id
- if (typeof deletedSessionID === "string") {
- clearPendingSwitchRuntime(deletedSessionID)
- }
- return
- }
-
- if (input.event.type === "session.error") {
- const props = input.event.properties as Record | undefined
- const info = props?.info as Record | undefined
- const erroredSessionID = info?.id ?? props?.sessionID
- if (typeof erroredSessionID === "string") {
- clearPendingSwitchRuntime(erroredSessionID)
- }
- return
- }
-
- if (input.event.type === "message.updated") {
- const props = input.event.properties as Record | undefined
- const info = props?.info as Record | undefined
- const sessionID = typeof info?.sessionID === "string" ? info.sessionID : undefined
- const finish = info?.finish
-
- if (!sessionID) {
- return
- }
-
- const isTerminalAssistantUpdate = isTerminalFinishValue(finish)
- if (!isTerminalAssistantUpdate) {
- return
- }
-
- // Primary path: if switch_agent queued a pending switch, apply it as soon as
- // assistant turn is terminal (no reliance on session.idle timing).
- if (getPendingSwitch(sessionID)) {
- await applyPendingSwitch({
- sessionID,
- client: ctx.client,
- source: "message-updated",
- })
- }
-
- return
- }
-
- if (input.event.type === "message.part.updated") {
- const props = input.event.properties as Record | undefined
- const part = props?.part
- const info = props?.info as Record | undefined
- const sessionIDFromPart = typeof (part as Record | undefined)?.sessionID === "string"
- ? ((part as Record).sessionID as string)
- : undefined
- const sessionIDFromInfo = typeof info?.sessionID === "string" ? info.sessionID : undefined
- const sessionID = sessionIDFromPart ?? sessionIDFromInfo
- if (!sessionID) {
- return
- }
-
- if (!isTerminalStepFinishPart(part)) {
- return
- }
-
- if (!getPendingSwitch(sessionID)) {
- return
- }
-
- await applyPendingSwitch({
- sessionID,
- client: ctx.client,
- source: "message-part-step-finish",
- })
- return
- }
-
- if (input.event.type === "session.idle") {
- const props = input.event.properties as Record | undefined
- const sessionID = props?.sessionID as string | undefined
- if (!sessionID) return
-
- await applyPendingSwitch({
- sessionID,
- client: ctx.client,
- source: "idle",
- })
- return
- }
-
- if (input.event.type === "session.status") {
- const sessionID = getSessionIDFromStatusEvent(input)
- const statusType = getStatusTypeFromEvent(input)
- if (!sessionID || statusType !== "idle") {
- return
- }
-
- await applyPendingSwitch({
- sessionID,
- client: ctx.client,
- source: "status-idle",
- })
- }
- },
- }
-}
diff --git a/src/hooks/agent-switch/index.ts b/src/hooks/agent-switch/index.ts
deleted file mode 100644
index 735a0aafb..000000000
--- a/src/hooks/agent-switch/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { createAgentSwitchHook } from "./hook"
diff --git a/src/hooks/agent-switch/terminal-detection.ts b/src/hooks/agent-switch/terminal-detection.ts
deleted file mode 100644
index 926e7845d..000000000
--- a/src/hooks/agent-switch/terminal-detection.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-export function isTerminalFinishValue(finish: unknown): boolean {
- if (typeof finish === "boolean") {
- return finish
- }
-
- if (typeof finish === "string") {
- const normalized = finish.toLowerCase()
- return normalized !== "" && normalized !== "tool-calls" && normalized !== "unknown"
- }
-
- if (typeof finish === "object" && finish !== null) {
- const record = finish as Record
- const kind = record.type ?? record.reason
- if (typeof kind === "string") {
- const normalized = kind.toLowerCase()
- return normalized !== "" && normalized !== "tool-calls" && normalized !== "unknown"
- }
- }
-
- return false
-}
-
-export function isTerminalStepFinishPart(part: unknown): boolean {
- if (typeof part !== "object" || part === null) {
- return false
- }
-
- const record = part as Record
- if (record.type !== "step-finish") {
- return false
- }
-
- return isTerminalFinishValue(record.reason)
-}
-
-
diff --git a/src/plugin/hooks/create-continuation-hooks.ts b/src/plugin/hooks/create-continuation-hooks.ts
index 3dae6415f..4c835fe37 100644
--- a/src/plugin/hooks/create-continuation-hooks.ts
+++ b/src/plugin/hooks/create-continuation-hooks.ts
@@ -9,7 +9,6 @@ import {
createCompactionContextInjector,
createCompactionTodoPreserverHook,
createAtlasHook,
- createAgentSwitchHook,
} from "../../hooks"
import { safeCreateHook } from "../../shared/safe-create-hook"
import { createUnstableAgentBabysitter } from "../unstable-agent-babysitter"
@@ -22,7 +21,6 @@ export type ContinuationHooks = {
unstableAgentBabysitter: ReturnType | null
backgroundNotificationHook: ReturnType | null
atlasHook: ReturnType | null
- agentSwitchHook: ReturnType | null
}
type SessionRecovery = {
@@ -118,9 +116,6 @@ export function createContinuationHooks(args: {
}))
: null
- const agentSwitchHook = isHookEnabled("agent-switch")
- ? safeHook("agent-switch", () => createAgentSwitchHook(ctx))
- : null
return {
stopContinuationGuard,
@@ -130,6 +125,5 @@ export function createContinuationHooks(args: {
unstableAgentBabysitter,
backgroundNotificationHook,
atlasHook,
- agentSwitchHook,
}
}
diff --git a/src/tools/switch-agent/tools.test.ts b/src/tools/switch-agent/tools.test.ts
index 248e43262..4dee970ee 100644
--- a/src/tools/switch-agent/tools.test.ts
+++ b/src/tools/switch-agent/tools.test.ts
@@ -2,8 +2,6 @@
import { describe, test, expect, beforeEach } from "bun:test"
import { createSwitchAgentTool } from "./tools"
-import { consumePendingSwitch, _resetForTesting as resetSwitch } from "../../features/agent-switch"
-import { getSessionAgent, _resetForTesting as resetSession } from "../../features/claude-code-session-state"
describe("switch_agent tool", () => {
const sessionID = "test-session-123"
@@ -17,40 +15,38 @@ describe("switch_agent tool", () => {
abort: new AbortController().signal,
}
+ let createdSessions: Array<{ body?: { parentID?: string; title?: string } }>
+ let promptedSessions: Array<{ path: { id: string }; body: { agent?: string; parts: Array<{ type: "text"; text: string }> } }>
+
beforeEach(() => {
- resetSwitch()
- resetSession()
+ createdSessions = []
+ promptedSessions = []
})
- function createToolWithMockClient(promptImpl?: () => Promise) {
+ function createToolWithMockClient(overrides?: {
+ createImpl?: () => Promise
+ promptAsyncImpl?: (input: any) => Promise
+ }) {
const client = {
session: {
- promptAsync:
- promptImpl ??
- (async () => {
- return undefined
- }),
- messages: async () => ({ data: [] }),
+ create: overrides?.createImpl ?? (async (input?: { body?: { parentID?: string; title?: string } }) => {
+ createdSessions.push(input ?? {})
+ return { data: { id: "new-session-abc" } }
+ }),
+ promptAsync: overrides?.promptAsyncImpl ?? (async (input: any) => {
+ promptedSessions.push(input)
+ return undefined
+ }),
},
}
- return createSwitchAgentTool({
- client: client as unknown as {
- session: {
- promptAsync: (input: {
- path: { id: string }
- body: { agent: string; parts: Array<{ type: "text"; text: string }> }
- }) => Promise
- messages: (input: { path: { id: string } }) => Promise
- }
- },
- })
+ return createSwitchAgentTool({ client })
}
//#given valid atlas switch args
//#when execute is called
- //#then it stores pending switch and updates session agent
- test("should queue switch to atlas", async () => {
+ //#then it creates a new session and prompts with the target agent
+ test("should create session and prompt for atlas switch", async () => {
const tool = createToolWithMockClient()
const result = await tool.execute(
{ agent: "atlas", context: "Fix the auth bug based on council findings" },
@@ -58,21 +54,18 @@ describe("switch_agent tool", () => {
)
expect(result).toContain("atlas")
- expect(result).toContain("switch")
-
- const entry = consumePendingSwitch(sessionID)
- expect(entry).toEqual({
- agent: "atlas",
- context: "Fix the auth bug based on council findings",
- })
-
- expect(getSessionAgent(sessionID)).toBe("atlas")
+ expect(result).toContain("new-session-abc")
+ expect(createdSessions).toHaveLength(1)
+ expect(promptedSessions).toHaveLength(1)
+ expect(promptedSessions[0]!.path.id).toBe("new-session-abc")
+ expect(promptedSessions[0]!.body.agent).toContain("Atlas")
+ expect(promptedSessions[0]!.body.parts[0]!.text).toBe("Fix the auth bug based on council findings")
})
//#given valid prometheus switch args
//#when execute is called
- //#then it stores pending switch for prometheus
- test("should queue switch to prometheus", async () => {
+ //#then it creates a new session and prompts with prometheus agent
+ test("should create session and prompt for prometheus switch", async () => {
const tool = createToolWithMockClient()
const result = await tool.execute(
{ agent: "Prometheus", context: "Create a plan for the refactoring" },
@@ -80,16 +73,14 @@ describe("switch_agent tool", () => {
)
expect(result).toContain("prometheus")
- expect(result).toContain("switch")
-
- const entry = consumePendingSwitch(sessionID)
- expect(entry?.agent).toBe("prometheus")
+ expect(promptedSessions).toHaveLength(1)
+ expect(promptedSessions[0]!.body.parts[0]!.text).toBe("Create a plan for the refactoring")
})
//#given valid hephaestus switch args
//#when execute is called
- //#then it stores pending switch for hephaestus
- test("should queue switch to hephaestus", async () => {
+ //#then it creates a new session for hephaestus
+ test("should create session and prompt for hephaestus switch", async () => {
const tool = createToolWithMockClient()
const result = await tool.execute(
{ agent: "Hephaestus", context: "Implement the selected diagnosis fix" },
@@ -97,16 +88,14 @@ describe("switch_agent tool", () => {
)
expect(result).toContain("hephaestus")
- expect(result).toContain("switch")
-
- const entry = consumePendingSwitch(sessionID)
- expect(entry?.agent).toBe("hephaestus")
+ expect(createdSessions).toHaveLength(1)
+ expect(promptedSessions).toHaveLength(1)
})
//#given valid sisyphus switch args
//#when execute is called
- //#then it stores pending switch for sisyphus
- test("should queue switch to sisyphus", async () => {
+ //#then it creates a new session for sisyphus
+ test("should create session and prompt for sisyphus switch", async () => {
const tool = createToolWithMockClient()
const result = await tool.execute(
{ agent: "Sisyphus", context: "Implement the selected diagnosis fix" },
@@ -114,15 +103,13 @@ describe("switch_agent tool", () => {
)
expect(result).toContain("sisyphus")
- expect(result).toContain("switch")
-
- const entry = consumePendingSwitch(sessionID)
- expect(entry?.agent).toBe("sisyphus")
+ expect(createdSessions).toHaveLength(1)
+ expect(promptedSessions).toHaveLength(1)
})
//#given an invalid agent name
//#when execute is called
- //#then it returns an error
+ //#then it returns an error without creating a session
test("should reject invalid agent names", async () => {
const tool = createToolWithMockClient()
const result = await tool.execute(
@@ -132,21 +119,72 @@ describe("switch_agent tool", () => {
expect(result).toContain("Invalid switch target")
expect(result).toContain("librarian")
- expect(consumePendingSwitch(sessionID)).toBeUndefined()
+ expect(createdSessions).toHaveLength(0)
+ expect(promptedSessions).toHaveLength(0)
})
//#given agent name with different casing
//#when execute is called
- //#then it normalizes to lowercase
+ //#then it normalizes to lowercase and creates session
test("should handle case-insensitive agent names", async () => {
const tool = createToolWithMockClient()
- await tool.execute(
+ const result = await tool.execute(
{ agent: "ATLAS", context: "Fix things" },
toolContext
)
- const entry = consumePendingSwitch(sessionID)
- expect(entry?.agent).toBe("atlas")
- expect(getSessionAgent(sessionID)).toBe("atlas")
+ expect(result).toContain("atlas")
+ expect(createdSessions).toHaveLength(1)
+ expect(promptedSessions).toHaveLength(1)
+ })
+
+ //#given session.create fails
+ //#when execute is called
+ //#then it returns an error message
+ test("should handle session creation failure gracefully", async () => {
+ const tool = createToolWithMockClient({
+ createImpl: async () => { throw new Error("connection refused") },
+ })
+ const result = await tool.execute(
+ { agent: "atlas", context: "Fix things" },
+ toolContext
+ )
+
+ expect(result).toContain("Failed to create handoff session")
+ expect(result).toContain("connection refused")
+ expect(promptedSessions).toHaveLength(0)
+ })
+
+ //#given promptAsync fails
+ //#when execute is called
+ //#then it returns a warning but still reports session created
+ test("should handle prompt delivery failure gracefully", async () => {
+ const tool = createToolWithMockClient({
+ promptAsyncImpl: async () => { throw new Error("prompt failed") },
+ })
+ const result = await tool.execute(
+ { agent: "atlas", context: "Fix things" },
+ toolContext
+ )
+
+ expect(result).toContain("new-session-abc")
+ expect(result).toContain("warning: prompt delivery failed")
+ expect(createdSessions).toHaveLength(1)
+ })
+
+ //#given session.create returns response with id at root level
+ //#when execute is called
+ //#then it extracts the session ID correctly
+ test("should extract session ID from root-level response", async () => {
+ const tool = createToolWithMockClient({
+ createImpl: async () => ({ id: "direct-id-123" }),
+ })
+ const result = await tool.execute(
+ { agent: "atlas", context: "Fix things" },
+ toolContext
+ )
+
+ expect(result).toContain("direct-id-123")
+ expect(promptedSessions[0]!.path.id).toBe("direct-id-123")
})
})
diff --git a/src/tools/switch-agent/tools.ts b/src/tools/switch-agent/tools.ts
index 076cfecee..9eebabe89 100644
--- a/src/tools/switch-agent/tools.ts
+++ b/src/tools/switch-agent/tools.ts
@@ -1,7 +1,6 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
-import { setPendingSwitch } from "../../features/agent-switch"
-import { schedulePendingSwitchApply } from "../../features/agent-switch/scheduler"
-import { updateSessionAgent } from "../../features/claude-code-session-state"
+import { normalizeAgentForPrompt } from "../../shared/agent-display-names"
+import { log } from "../../shared/logger"
import type { SwitchAgentArgs } from "./types"
const DESCRIPTION =
@@ -13,17 +12,46 @@ const ALLOWED_AGENTS = new Set(["atlas", "prometheus", "sisyphus", "hephaestus"]
type SessionClient = {
session: {
- prompt?: (input: {
- path: { id: string }
- body: { agent: string; parts: Array<{ type: "text"; text: string }> }
- }) => Promise
+ create: (input?: { body?: { parentID?: string; title?: string } }) => Promise
promptAsync: (input: {
path: { id: string }
- body: { agent: string; parts: Array<{ type: "text"; text: string }> }
+ body: { agent?: string; parts: Array<{ type: "text"; text: string }> }
}) => Promise
- create?: (input?: { body?: { parentID?: string; title?: string } }) => Promise
- messages: (input: { path: { id: string } }) => Promise
- status?: () => Promise
+ }
+}
+
+function extractSessionId(response: unknown): string | undefined {
+ if (typeof response !== "object" || response === null) {
+ return undefined
+ }
+
+ const root = response as Record
+
+ if (typeof root.id === "string" && root.id.length > 0) {
+ return root.id
+ }
+
+ const data = root.data
+ if (typeof data === "object" && data !== null) {
+ const dataRecord = data as Record
+ if (typeof dataRecord.id === "string" && dataRecord.id.length > 0) {
+ return dataRecord.id
+ }
+ }
+
+ return undefined
+}
+
+async function navigateTuiToSession(client: SessionClient, sessionID: string): Promise {
+ try {
+ await (client as any)._client.post({
+ url: "/tui/select-session",
+ body: { sessionID },
+ headers: { "Content-Type": "application/json" },
+ })
+ return true
+ } catch {
+ return false
}
}
@@ -49,14 +77,53 @@ export function createSwitchAgentTool(args: {
return `Invalid switch target: "${args.agent}". Allowed agents: ${[...ALLOWED_AGENTS].join(", ")}`
}
- updateSessionAgent(toolContext.sessionID, agentName)
- setPendingSwitch(toolContext.sessionID, agentName, args.context)
- schedulePendingSwitchApply({
- sessionID: toolContext.sessionID,
- client,
+ const targetAgent = normalizeAgentForPrompt(agentName)
+ if (!targetAgent) {
+ return `Invalid switch target: "${args.agent}". Could not resolve agent name.`
+ }
+
+ const errors: string[] = []
+
+ const response = await client.session.create().catch((error: unknown) => {
+ errors.push(`session.create failed: ${error instanceof Error ? error.message : String(error)}`)
+ return null
})
- return `Agent switch queued. Session will switch to ${agentName} when your turn completes.`
+ if (!response) {
+ return `Failed to create handoff session. ${errors.join("; ")}`
+ }
+
+ const newSessionID = extractSessionId(response)
+ if (!newSessionID) {
+ return `Failed to extract session ID from create response: ${JSON.stringify(response)}`
+ }
+
+ const promptResult = await client.session.promptAsync({
+ path: { id: newSessionID },
+ body: {
+ agent: targetAgent,
+ parts: [{ type: "text", text: args.context }],
+ },
+ }).catch((error: unknown) => {
+ errors.push(`promptAsync failed: ${error instanceof Error ? error.message : String(error)}`)
+ return null
+ })
+
+ const tuiNavigated = await navigateTuiToSession(client, newSessionID)
+
+ log("[switch-agent] Agent switch applied via fresh session", {
+ sourceSessionID: toolContext.sessionID,
+ newSessionID,
+ agent: targetAgent,
+ tuiNavigated,
+ promptDelivered: promptResult !== null,
+ })
+
+ const parts = [`Agent switch to ${agentName} initiated. New session: ${newSessionID}`]
+ if (!promptResult) parts.push("(warning: prompt delivery failed)")
+ if (tuiNavigated) parts.push("Navigated TUI to new session.")
+ if (errors.length > 0) parts.push(`Errors: ${errors.join("; ")}`)
+ return parts.join(" ")
},
})
}