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
25 changes: 24 additions & 1 deletion src/main/providerSwitch/duplicateSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,33 @@ export type DuplicateSessionResult = {
export async function duplicateSession(
request: DuplicateSessionRequest,
): Promise<DuplicateSessionResult> {
// WHY explicit fail-loud dispatch instead of `if claude else codex`:
//
// Duplication is a file-format operation — the source transcript on disk has
// a provider-specific shape (Claude JSONL sessions vs. Codex rollout lines)
// and the clone must be written using the same shape. When OpenCode was
// registered as a third `AgentProviderKind`, this function's original shape
// `if (provider === 'claude') return duplicateClaude(...); return duplicateCodex(...)`
// silently ran an OpenCode `Duplicate Agent` command through `duplicateCodex`
// — reading OpenCode's server session id as if it were a Codex rollout id,
// returning a nonsense "clone", and — worst-case — writing malformed data
// over a real Codex rollout uuid collision. Refuse cleanly instead.
//
// This mirrors `switchProvider.ts` (see its throw for unknown pairs). When
// OpenCode's on-disk model is understood well enough to duplicate it, add a
// `duplicateOpencode` implementation and its branch here; until then the
// Duplicate Agent command surface hides itself for OpenCode panes at the
// `when` predicate level, and this throw is the last-mile safety net for
// programmatic callers (MCP orchestration, tests) that bypass the palette.
if (request.provider === 'claude') {
return duplicateClaude(request)
}
return duplicateCodex(request)
if (request.provider === 'codex') {
return duplicateCodex(request)
}
throw new Error(
`duplicateSession: no duplicate implementation for provider "${request.provider}" yet`,
)
}

async function duplicateClaude(
Expand Down
16 changes: 15 additions & 1 deletion src/main/providerSwitch/rewindSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,24 @@ export type RewindSessionResult = {
export async function rewindSession(
request: RewindSessionRequest,
): Promise<RewindSessionResult> {
// Explicit fail-loud dispatch — same rationale as `duplicateSession`. The
// rewind engine reads the source transcript in its provider-specific shape
// and writes a truncated clone in the same shape; running an OpenCode
// request through `rewindCodex` (the previous silent fallback) would
// treat the OpenCode server session id as a Codex rollout uuid and
// produce garbage output. Refuse cleanly until an OpenCode rewind
// implementation is written. The Rewind command's `when` predicate hides
// it from OpenCode panes at the UI level; this throw is the last-mile
// guard for programmatic callers (MCP, tests) that bypass the palette.
if (request.provider === 'claude') {
return rewindClaude(request)
}
return rewindCodex(request)
if (request.provider === 'codex') {
return rewindCodex(request)
}
throw new Error(
`rewindSession: no rewind implementation for provider "${request.provider}" yet`,
)
}

async function rewindClaude(
Expand Down
9 changes: 7 additions & 2 deletions src/renderer/src/features/prompt-templates/templates.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities'
import { DEFAULT_PROVIDER, type AgentProviderKind } from '@shared/types/providerKind'
import { DEFAULT_PROVIDER, isAgentProviderKind, type AgentProviderKind } from '@shared/types/providerKind'
import { formatWorktreeDumpPrompt } from '@renderer/features/worktrees/lib/formatWorktreeDump'
import { loadWorktreeDump } from '@renderer/features/worktrees/lib/loadWorktreeDump'
import { resolveTabSessions } from '@renderer/workspace/queries'
Expand Down Expand Up @@ -86,7 +86,12 @@ function activeTabAgentTranscriptRequests(workspace: Workspace): AgentTranscript
return sessionIds.flatMap(sessionId => {
const meta = workspace.state.sessions[sessionId]
const kind = meta?.kind ?? DEFAULT_PROVIDER
if ((kind !== 'claude' && kind !== 'codex') || !meta?.providerSessionId) {
// Registry-driven: prompt templates that reference "the other agents in
// this tab" must include every agent provider on the tab, not just the
// two-provider pair. A dropped OpenCode session here would mean the
// template LLM never sees that agent — silently narrowing the context
// window in ways the user cannot detect.
if (!isAgentProviderKind(kind) || !meta?.providerSessionId) {
return []
}
return [{
Expand Down
55 changes: 48 additions & 7 deletions src/renderer/src/features/workspace/commands/sessionCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,12 @@ export const sessionCommands: CommandDef[] = [
if (!sessionId) return
const meta = workspace.state.sessions[sessionId]
const kind = meta?.kind ?? DEFAULT_PROVIDER
if ((kind !== 'claude' && kind !== 'codex') || !meta) return
// Runtime narrow that mirrors the `when` predicate exactly (both use
// isAgentProviderKind). The old two-provider check silently dropped
// OpenCode panes even though `when` had already allowed the command to
// run — one of the last stale two-provider literals #394 phase 4
// couldn't finish (issue #394 §7).
if (!isAgentProviderKind(kind) || !meta) return

ui.closePalette()
try {
Expand Down Expand Up @@ -337,7 +342,12 @@ export const sessionCommands: CommandDef[] = [
if (!sessionId) return
const meta = workspace.state.sessions[sessionId]
const kind = meta?.kind ?? DEFAULT_PROVIDER
if ((kind !== 'claude' && kind !== 'codex') || !meta) return
// Runtime narrow that mirrors the `when` predicate exactly (both use
// isAgentProviderKind). The old two-provider check silently dropped
// OpenCode panes even though `when` had already allowed the command to
// run — one of the last stale two-provider literals #394 phase 4
// couldn't finish (issue #394 §7).
if (!isAgentProviderKind(kind) || !meta) return

ui.closePalette()
try {
Expand Down Expand Up @@ -383,7 +393,12 @@ export const sessionCommands: CommandDef[] = [
if (!sessionId) return
const meta = workspace.state.sessions[sessionId]
const kind = meta?.kind ?? DEFAULT_PROVIDER
if ((kind !== 'claude' && kind !== 'codex') || !meta) return
// Runtime narrow that mirrors the `when` predicate exactly (both use
// isAgentProviderKind). The old two-provider check silently dropped
// OpenCode panes even though `when` had already allowed the command to
// run — one of the last stale two-provider literals #394 phase 4
// couldn't finish (issue #394 §7).
if (!isAgentProviderKind(kind) || !meta) return

ui.closePalette()
try {
Expand Down Expand Up @@ -429,7 +444,12 @@ export const sessionCommands: CommandDef[] = [
if (!sessionId) return
const meta = workspace.state.sessions[sessionId]
const kind = meta?.kind ?? DEFAULT_PROVIDER
if ((kind !== 'claude' && kind !== 'codex') || !meta) return
// Runtime narrow that mirrors the `when` predicate exactly (both use
// isAgentProviderKind). The old two-provider check silently dropped
// OpenCode panes even though `when` had already allowed the command to
// run — one of the last stale two-provider literals #394 phase 4
// couldn't finish (issue #394 §7).
if (!isAgentProviderKind(kind) || !meta) return

ui.closePalette()
try {
Expand Down Expand Up @@ -548,7 +568,12 @@ export const sessionCommands: CommandDef[] = [
if (!sessionId) return
const meta = workspace.state.sessions[sessionId]
const kind = meta?.kind ?? DEFAULT_PROVIDER
if ((kind !== 'claude' && kind !== 'codex') || !meta?.providerSessionId) return
// Registry-driven runtime narrow — must match the `when` predicate
// above so a command that visibly enabled doesn't silently no-op on
// OpenCode. `buildProviderResumeCommand` already accepts any
// AgentProviderKind and pulls the CLI shape from the registry identity
// descriptor (#394 phase 2c-2), so no downstream change is needed.
if (!isAgentProviderKind(kind) || !meta?.providerSessionId) return

const command = buildProviderResumeCommand(kind, meta.cwd, meta.providerSessionId)
ui.closePalette()
Expand Down Expand Up @@ -584,8 +609,13 @@ export const sessionCommands: CommandDef[] = [
if (!sessionId) return
const meta = workspace.state.sessions[sessionId]
const kind = meta?.kind ?? DEFAULT_PROVIDER
if (kind !== 'claude' && kind !== 'codex') return
if (!meta?.providerSessionId) return
// Registry-driven runtime narrow — mirrors the `when` predicate. The old
// two-provider literal here was the exact reason "Duplicate Agent"
// silently no-op'd on OpenCode panes even after Phase 7 landed the
// provider. `duplicateSession`'s preload/main handler already takes
// `provider: AgentProviderKind` (src/preload/api/provider.ts:50), so the
// downstream path fans out through the registry — nothing else changes.
if (!isAgentProviderKind(kind) || !meta?.providerSessionId) return
try {
const { newProviderSessionId } = await window.api.duplicateSession({
provider: kind,
Expand All @@ -601,6 +631,17 @@ export const sessionCommands: CommandDef[] = [
// them at once.
await workspace.splitFocused('vertical', kind, newProviderSessionId)
} catch (err) {
// Surface the failure as a pane toast, not just console.warn — an
// OpenCode pane hitting `duplicateSession`'s fail-loud throw (see
// src/main/providerSwitch/duplicateSession.ts) now produces a visible
// "no duplicate implementation for provider 'opencode' yet" message
// instead of silently doing nothing. Any other transport / fs error
// surfaces the same way. Console line is retained for triage.
const message =
err instanceof Error && err.message.length > 0
? err.message
: 'Duplicate agent failed'
workspace.showPaneToast(sessionId, message)
// eslint-disable-next-line no-console
console.warn('[duplicate-agent] failed', err)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DEFAULT_PROVIDER, type AgentProviderKind } from '@shared/types/providerKind'
import { DEFAULT_PROVIDER, isAgentProviderKind, type AgentProviderKind } from '@shared/types/providerKind'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'

import { relativeTime } from '@renderer/lib/relativeTime'
Expand Down Expand Up @@ -135,7 +135,12 @@ export function CloseOldAgentsModal({ open, workspace, onClose }: Props) {
const meta = workspace.state.sessions[sessionId]
if (!meta) continue
const kind = meta.kind ?? DEFAULT_PROVIDER
if (kind !== 'claude' && kind !== 'codex') continue
// Registry-driven: the modal shows every agent provider so a user in
// a big multi-provider session can bulk-close inactive agents across
// Claude, Codex, and OpenCode alike. Skipping OpenCode here would
// silently hide those agents from the modal (and the bulk-close
// preview counter would report the wrong number).
if (!isAgentProviderKind(kind)) continue

const runtime = workspace.runtimes[sessionId]
const running = runtime?.sessionStatus === 'running'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,11 @@ export function NewAgentPlacementOverlay({
: activeTab
const anchorSessionId = placementTab?.focusedSessionId ?? null
const dispatchMode = workspace.dispatchMode !== null
// Both dispatch mode and linked mode are "kind only": pick Claude
// or Codex, no placement, no terminal option.
// Both dispatch mode and linked mode are "kind only": pick any registered
// agent provider — no placement step, no terminal option. The filter below
// pulls the option set from `AGENT_PROVIDER_KINDS` (not a hand-written
// literal) so a newly registered provider becomes selectable here without
// touching this file. See CLAUDE.md and issue #394 phase 4.
const kindOnly = dispatchMode || linkedMode
const kindOptions = useMemo(
() => kindOnly
Expand All @@ -115,7 +118,14 @@ export function NewAgentPlacementOverlay({
// silently did nothing in dispatch mode).
const commitKind = (kind: SessionKind) => {
if (linkedMode && linkedAgentParentId) {
if (kind !== 'claude' && kind !== 'codex') return
// WHY the runtime narrow: `SessionKind` includes 'terminal', which
// createLinkedAgent's signature refuses. The kind picker filters options
// in kind-only modes to `AgentProviderKind` (see kindOptions above), so
// in practice this branch only fires with an agent provider — but the
// event handler is typed against the broader union. Route through the
// registry predicate instead of a hand-written pair so adding a
// provider does not silently drop it here again (#394 phase 4).
if (!isAgentProviderKind(kind)) return
if (committingRef.current) return
committingRef.current = true
void workspace.createLinkedAgent(kind, linkedAgentParentId)
Expand All @@ -130,14 +140,23 @@ export function NewAgentPlacementOverlay({
if (kind === 'terminal') {
// Terminals are the one Dispatch creation kind that still becomes a
// grid leaf: shell lifecycle/persistence is leaf-based, while
// Claude/Codex Dispatch agents are detached rows. splitFocused owns the
// Dispatch-aware target resolution for terminals (#366), including the
// focused tiled lane / global row project context and overlay close.
// provider Dispatch agents are detached rows. splitFocused owns the
// Dispatch-aware target resolution for terminals (#366), including
// the focused tiled lane / global row project context and overlay
// close.
void workspace.splitFocused('vertical', 'terminal')
return
}
if (kind !== 'claude' && kind !== 'codex') return
// Dispatch Mode creates detached Claude/Codex agents on the focused
// WHY registry-driven runtime narrow (not `kind !== 'claude' && kind !==
// 'codex'`): the two-provider literal here silently dropped OpenCode
// clicks with no toast, no spawn, no log — the classic silent-fail path
// #394 §4 warned about. Using the shared predicate keeps this branch
// aligned with the kind picker's option filter (kindOptions above) and
// with every downstream API that takes `AgentProviderKind`
// (createDetachedDispatchAgent, createLinkedAgent, buildProviderResume-
// Command, duplicateSession).
if (!isAgentProviderKind(kind)) return
// Dispatch Mode creates a detached provider agent on the focused
// Dispatch project; there is intentionally no placement step.
// createDetachedDispatchAgent owns its own overlay close.
void workspace.createDetachedDispatchAgent(kind)
Expand Down
9 changes: 7 additions & 2 deletions src/renderer/src/features/worktrees/lib/loadWorktreeDump.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DEFAULT_PROVIDER, type AgentProviderKind } from '@shared/types/providerKind'
import { DEFAULT_PROVIDER, isAgentProviderKind, type AgentProviderKind } from '@shared/types/providerKind'
import type { WorktreeActivityIndexStatus, WorktreeActivitySummary } from '@preload/index'
import type { GitWorktreeStatus, WorktreeIdentity } from '@shared/types/git'
import { matchWorktree } from '@shared/work-context/matching'
Expand Down Expand Up @@ -116,7 +116,12 @@ export function collectLiveAgentsByWorktree(
for (const sessionId of resolveTabSessions(workspace.state, tab.id)) {
const meta = workspace.state.sessions[sessionId]
const kind = meta?.kind ?? DEFAULT_PROVIDER
if (kind !== 'claude' && kind !== 'codex') continue
// Registry-driven: the worktree activity view lists live agents per
// worktree regardless of provider — a running OpenCode session
// consumes the worktree just as much as a Claude or Codex one. The
// hardcoded pair here would silently hide OpenCode sessions from
// per-worktree activity even though they're on-disk in the tab.
if (!isAgentProviderKind(kind)) continue
const runtime = workspace.runtimes[sessionId]
const contextPath = runtime?.workContext?.worktreePath ?? meta?.cwd
const matched = matchWorktree(contextPath, identities)
Expand Down
10 changes: 8 additions & 2 deletions src/renderer/src/workspace/hook/actions/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,10 @@ export function useProviderActions(

const kind = meta.kind ?? DEFAULT_PROVIDER
if (!isAgentProviderKind(kind)) {
showPaneToast(sourceSessionId, 'Only Claude and Codex panes can reload')
// Toast text is registry-driven — "Only agent panes can reload" reads
// right for any current OR future provider set. The previous
// "Claude and Codex" wording rotted the moment OpenCode was registered.
showPaneToast(sourceSessionId, 'Only agent panes can reload')
return
}
const resumeSessionId = resumableProviderSessionId(meta)
Expand Down Expand Up @@ -171,7 +174,10 @@ export function useProviderActions(

const kind = meta.kind ?? DEFAULT_PROVIDER
if (!isAgentProviderKind(kind)) {
showPaneToast(sourceSessionId, 'Only Claude and Codex panes support rewind')
// Registry-driven toast — see the reload-agent counterpart above.
// Terminal panes are the only non-agent kind today; the wording will
// continue to read right when future providers register.
showPaneToast(sourceSessionId, 'Only agent panes support rewind')
return
}
const previousProviderSessionId = resumableProviderSessionId(meta)
Expand Down
10 changes: 8 additions & 2 deletions src/renderer/src/workspace/hook/actions/reader.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DEFAULT_PROVIDER } from '@shared/types/providerKind'
import { DEFAULT_PROVIDER, isAgentProviderKind } from '@shared/types/providerKind'
import { useCallback } from 'react'

import type { SessionId } from '@renderer/workspace/types'
Expand Down Expand Up @@ -37,7 +37,13 @@ export function useReaderActions(
if (prev) return null
if (!target) return prev
const kind = current.sessions[target.sessionId]?.kind ?? DEFAULT_PROVIDER
if (kind !== 'claude' && kind !== 'codex') return prev
// Reader Mode reads the pane's committed transcript through the
// provider-registered renderer/extractor. Registry membership — not a
// hardcoded pair — is the correct predicate: every registered
// AgentProviderKind ships a transcript view (see
// src/providers/<kind>/renderer/), so OpenCode is just as valid a
// Reader target as Claude or Codex.
if (!isAgentProviderKind(kind)) return prev
return {
tabId: target.tabId,
focusedSessionId: target.sessionId,
Expand Down
7 changes: 6 additions & 1 deletion src/renderer/src/workspace/hook/actions/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -968,7 +968,12 @@ export function useSessionActions(
const meta = snapshot.sessions[sessionId]
if (!meta) return null
const kind = meta.kind ?? DEFAULT_PROVIDER
if (kind !== 'claude' && kind !== 'codex') return null
// Soft reload is provider-neutral: it only replaces the renderer's view
// state for THIS session, without touching the underlying backend. The
// registry predicate is the right membership check — any agent provider
// has a renderer view to soft-reload, terminals don't. Prior two-
// provider literal would silently no-op on OpenCode.
if (!isAgentProviderKind(kind)) return null

const hasProviderSession = hasDurableProviderSession(meta)
if (!hasProviderSession) {
Expand Down
9 changes: 7 additions & 2 deletions src/renderer/src/workspace/orchestrationMcp.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DEFAULT_PROVIDER, type AgentProviderKind } from '@shared/types/providerKind'
import { DEFAULT_PROVIDER, isAgentProviderKind, type AgentProviderKind } from '@shared/types/providerKind'
import type {
OrchestrationAgentMessage,
OrchestrationAgentOutput,
Expand Down Expand Up @@ -154,7 +154,12 @@ function matchingOrchestrationSessionIds(
return Object.entries(state.sessions)
.filter(([, meta]) => {
const kind = meta.kind ?? DEFAULT_PROVIDER
if (kind !== 'claude' && kind !== 'codex') return false
// Registry-driven: any registered agent provider (Claude, Codex,
// OpenCode, …) is a valid orchestration child. The old two-provider
// literal here would silently exclude OpenCode children — MCP tools
// like `list_agents` and `close_run` would then miss them, and
// `matchingOrchestrationSessionIds` would return the wrong scope.
if (!isAgentProviderKind(kind)) return false
if (!isVisibleToOrchestrationParent(meta, parentSessionId)) return false
return runId ? meta.orchestrationRunId === runId : true
})
Expand Down