From dbf9c48a3ff3366fe74c0997dbeb6669b4c13a79 Mon Sep 17 00:00:00 2001
From: Arul Sharma <31745423+arul28@users.noreply.github.com>
Date: Thu, 23 Apr 2026 15:01:29 -0400
Subject: [PATCH 1/5] ship: checkpoint before automate/finalize
Bundle in-progress CTO/chat/sync work so automate and finalize agents
run against a known committed baseline.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../services/ai/tools/ctoOperatorTools.ts | 2 +-
.../services/chat/agentChatService.test.ts | 24 +++++++--
.../main/services/chat/agentChatService.ts | 29 ++++++-----
.../chat/identitySessionPolicy.test.ts | 19 +++++++
.../services/chat/identitySessionPolicy.ts | 31 ++++++++++++
.../src/main/services/ipc/registerIpc.ts | 10 ++--
.../sync/syncRemoteCommandService.test.ts | 50 +++++++++++++++++--
.../services/sync/syncRemoteCommandService.ts | 14 +++---
.../chat/AgentChatComposer.test.tsx | 9 ++++
.../components/chat/AgentChatComposer.tsx | 6 +++
.../components/chat/AgentChatPane.tsx | 26 ++++++----
.../src/renderer/components/cto/CtoPage.tsx | 31 ++++++------
.../cto/ctoSessionViewState.test.ts | 22 ++++++++
.../components/cto/ctoSessionViewState.ts | 3 ++
apps/desktop/src/shared/types/agents.ts | 2 -
apps/desktop/src/shared/types/cto.ts | 1 -
16 files changed, 216 insertions(+), 63 deletions(-)
create mode 100644 apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts
create mode 100644 apps/desktop/src/main/services/chat/identitySessionPolicy.ts
create mode 100644 apps/desktop/src/renderer/components/cto/ctoSessionViewState.test.ts
create mode 100644 apps/desktop/src/renderer/components/cto/ctoSessionViewState.ts
diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts
index ad827c268..6b41b589d 100644
--- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts
+++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts
@@ -773,7 +773,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record {
});
expect(session.laneId).toBe("lane-1");
- expect(session.permissionMode).toBe("plan");
+ expect(session.permissionMode).toBe("full-auto");
});
it("does not reuse a foreign-lane identity session or auto-close it during migration", async () => {
@@ -1982,7 +1982,7 @@ describe("createAgentChatService", () => {
expect(reused.laneId).toBe("lane-1");
});
- it("records headShaStart for the selected execution lane instead of the canonical host lane", async () => {
+ it("pins CTO execution state to the primary lane even when a foreign lane is requested", async () => {
vi.mocked(runGit).mockImplementation(async (_args, opts) => ({
stdout: String(opts?.cwd ?? "").includes(path.join(tmpRoot, "lane-2")) ? "lane-2-sha\n" : "lane-1-sha\n",
stderr: "",
@@ -1995,7 +1995,25 @@ describe("createAgentChatService", () => {
laneId: "lane-2",
});
- expect(sessionService.setHeadShaStart).toHaveBeenLastCalledWith(session.id, "lane-2-sha");
+ expect(sessionService.setHeadShaStart).toHaveBeenLastCalledWith(session.id, "lane-1-sha");
+ });
+
+ it("pins worker identity execution state to the primary lane", async () => {
+ vi.mocked(runGit).mockImplementation(async (_args, opts) => ({
+ stdout: String(opts?.cwd ?? "").includes(path.join(tmpRoot, "lane-2")) ? "lane-2-sha\n" : "lane-1-sha\n",
+ stderr: "",
+ exitCode: 0,
+ }));
+
+ const { service, sessionService } = createService();
+ const session = await service.ensureIdentitySession({
+ identityKey: "agent:worker-1",
+ laneId: "lane-2",
+ });
+
+ expect(session.laneId).toBe("lane-1");
+ expect(session.permissionMode).toBe("full-auto");
+ expect(sessionService.setHeadShaStart).toHaveBeenLastCalledWith(session.id, "lane-1-sha");
});
});
diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts
index 5bc9f2160..9a0847fec 100644
--- a/apps/desktop/src/main/services/chat/agentChatService.ts
+++ b/apps/desktop/src/main/services/chat/agentChatService.ts
@@ -37,6 +37,10 @@ import {
shouldFlushBufferedAssistantTextForEvent,
type BufferedAssistantText,
} from "./chatTextBatching";
+import {
+ normalizeIdentityPermissionMode,
+ resolveIdentityExecutionLane,
+} from "./identitySessionPolicy";
import type { Logger } from "../logging/logger";
import type { createLaneService } from "../lanes/laneService";
import { resolveLaneLaunchContext, type LaneLaunchContext } from "../lanes/laneLaunchContext";
@@ -2416,17 +2420,6 @@ function inferCapabilityMode(provider: AgentChatProvider): CtoCapabilityMode {
return provider === "codex" || provider === "claude" || provider === "cursor" || provider === "opencode" ? "full_tooling" : "fallback";
}
-function guardedIdentityPermissionModeForProvider(_provider: AgentChatProvider): AgentChatSession["permissionMode"] {
- return "plan";
-}
-
-function normalizeIdentityPermissionMode(
- mode: AgentChatSession["permissionMode"] | undefined,
- provider: AgentChatProvider,
-): AgentChatSession["permissionMode"] {
- return mode === "plan" ? "plan" : guardedIdentityPermissionModeForProvider(provider);
-}
-
function isLightweightSession(session: Pick): boolean {
return session.sessionProfile === "light";
}
@@ -10380,7 +10373,7 @@ export function createAgentChatService(args: {
const normalizedCursorConfigValues = normalizeCursorConfigValueRecord(requestedCursorConfigValues);
const capabilityMode = inferCapabilityMode(effectiveProvider);
let effectivePermissionMode = identityKey
- ? normalizeIdentityPermissionMode(requestedPermMode, effectiveProvider)
+ ? normalizeIdentityPermissionMode(identityKey, requestedPermMode, effectiveProvider)
: requestedPermMode;
const chatConfig = resolveChatConfig();
let requestedOpenCodePermissionMode = requestedOpenCodePermissionModeArg;
@@ -12554,7 +12547,11 @@ export function createAgentChatService(args: {
}
const canonicalLaneId = await resolvePrimaryIdentityLane();
- const selectedExecutionLaneId = requestedLaneId || null;
+ const selectedExecutionLaneId = resolveIdentityExecutionLane(
+ args.identityKey,
+ requestedLaneId,
+ canonicalLaneId,
+ );
const existing = await listSessions(undefined, { includeIdentity: true });
const identitySessions = existing
.filter((entry) => entry.identityKey === args.identityKey)
@@ -12573,6 +12570,7 @@ export function createAgentChatService(args: {
managed.session.reasoningEffort = normalizeReasoningEffort(args.reasoningEffort);
}
managed.session.permissionMode = normalizeIdentityPermissionMode(
+ args.identityKey,
args.permissionMode ?? managed.session.permissionMode,
managed.session.provider,
);
@@ -12640,7 +12638,7 @@ export function createAgentChatService(args: {
model: preferredModel,
...(resolvedModelId ? { modelId: resolvedModelId } : {}),
reasoningEffort: args.reasoningEffort ?? pref?.reasoningEffort ?? null,
- permissionMode: args.permissionMode ?? "plan",
+ ...(args.permissionMode ? { permissionMode: args.permissionMode } : {}),
identityKey: args.identityKey
});
@@ -13338,6 +13336,7 @@ export function createAgentChatService(args: {
if (isIdentitySession) {
managed.session.permissionMode = normalizeIdentityPermissionMode(
+ managed.session.identityKey,
managed.session.permissionMode,
nextProvider,
);
@@ -13393,7 +13392,7 @@ export function createAgentChatService(args: {
if (permissionMode !== undefined) {
managed.session.permissionMode = isIdentitySession
- ? normalizeIdentityPermissionMode(permissionMode, managed.session.provider)
+ ? normalizeIdentityPermissionMode(managed.session.identityKey, permissionMode, managed.session.provider)
: permissionMode;
applyLegacyPermissionModeToNativeControls(managed.session, managed.session.permissionMode);
}
diff --git a/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts b/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts
new file mode 100644
index 000000000..4bfd2e449
--- /dev/null
+++ b/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest";
+import {
+ normalizeIdentityPermissionMode,
+ resolveIdentityExecutionLane,
+} from "./identitySessionPolicy";
+
+describe("identitySessionPolicy", () => {
+ it("forces CTO and worker sessions into full-auto permission mode", () => {
+ expect(normalizeIdentityPermissionMode("cto", "plan", "claude")).toBe("full-auto");
+ expect(normalizeIdentityPermissionMode("cto", undefined, "codex")).toBe("full-auto");
+ expect(normalizeIdentityPermissionMode("agent:worker-1", undefined, "codex")).toBe("full-auto");
+ expect(normalizeIdentityPermissionMode("agent:worker-1", "plan", "claude")).toBe("full-auto");
+ });
+
+ it("pins CTO and worker execution to the canonical lane", () => {
+ expect(resolveIdentityExecutionLane("cto", "lane-feature", "lane-primary")).toBe("lane-primary");
+ expect(resolveIdentityExecutionLane("agent:worker-1", "lane-feature", "lane-primary")).toBe("lane-primary");
+ });
+});
diff --git a/apps/desktop/src/main/services/chat/identitySessionPolicy.ts b/apps/desktop/src/main/services/chat/identitySessionPolicy.ts
new file mode 100644
index 000000000..896d868c8
--- /dev/null
+++ b/apps/desktop/src/main/services/chat/identitySessionPolicy.ts
@@ -0,0 +1,31 @@
+import type { AgentChatIdentityKey, AgentChatProvider, AgentChatSession } from "../../../shared/types";
+
+export function guardedIdentityPermissionModeForProvider(_provider: AgentChatProvider): AgentChatSession["permissionMode"] {
+ return "plan";
+}
+
+function isPrimaryPinnedIdentity(identityKey: AgentChatIdentityKey | undefined): boolean {
+ return identityKey === "cto" || Boolean(identityKey?.startsWith("agent:"));
+}
+
+export function normalizeIdentityPermissionMode(
+ identityKey: AgentChatIdentityKey | undefined,
+ mode: AgentChatSession["permissionMode"] | undefined,
+ provider: AgentChatProvider,
+): AgentChatSession["permissionMode"] {
+ if (isPrimaryPinnedIdentity(identityKey)) {
+ return "full-auto";
+ }
+ return mode === "plan" ? "plan" : guardedIdentityPermissionModeForProvider(provider);
+}
+
+export function resolveIdentityExecutionLane(
+ identityKey: AgentChatIdentityKey,
+ requestedLaneId: string,
+ canonicalLaneId: string,
+): string | null {
+ if (isPrimaryPinnedIdentity(identityKey)) {
+ return canonicalLaneId;
+ }
+ return requestedLaneId || null;
+}
diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts
index c2225c8ec..e81f2f47a 100644
--- a/apps/desktop/src/main/services/ipc/registerIpc.ts
+++ b/apps/desktop/src/main/services/ipc/registerIpc.ts
@@ -6272,15 +6272,16 @@ export function registerIpc({
ipcMain.handle(IPC.ctoEnsureSession, async (_event, arg: CtoEnsureSessionArgs = {}): Promise => {
const ctx = getCtx();
- const laneId = await resolveFirstAvailableLaneId(ctx, arg.laneId);
+ const laneId = await resolveFirstAvailableLaneId(ctx, null);
if (!laneId) {
- throw new Error("No active lane is available to host the CTO chat session.");
+ throw new Error("No primary lane is available to host the CTO chat session.");
}
return ctx.agentChatService.ensureIdentitySession({
identityKey: "cto",
laneId,
modelId: arg.modelId ?? null,
reasoningEffort: arg.reasoningEffort ?? null,
+ permissionMode: "full-auto",
});
});
@@ -6343,13 +6344,14 @@ export function registerIpc({
ipcMain.handle(IPC.ctoEnsureAgentSession, async (_event, arg: CtoEnsureAgentSessionArgs): Promise => {
const ctx = getCtx();
if (!ctx.agentChatService) throw new Error("Agent chat service is not available.");
- const laneId = await resolveFirstAvailableLaneId(ctx, arg.laneId);
- if (!laneId) throw new Error("No lane available for agent session.");
+ const laneId = await resolveFirstAvailableLaneId(ctx, null);
+ if (!laneId) throw new Error("No primary lane is available to host the agent chat session.");
return ctx.agentChatService.ensureIdentitySession({
identityKey: `agent:${arg.agentId}`,
laneId,
modelId: arg.modelId ?? null,
reasoningEffort: arg.reasoningEffort ?? null,
+ permissionMode: "full-auto",
});
});
diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts
index 3b2c1bbb4..47b52331e 100644
--- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts
+++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts
@@ -1769,25 +1769,30 @@ describe("createSyncRemoteCommandService", () => {
laneId: "lane-primary",
modelId: "claude-opus-4",
reasoningEffort: "high",
+ permissionMode: "full-auto",
});
expect(agentChatService.getSessionSummary).toHaveBeenCalledWith("chat-identity-1");
expect(result).toEqual(expect.objectContaining({ sessionId: "chat-1" }));
});
- it("cto.ensureSession uses the requested laneId when provided", async () => {
+ it("cto.ensureSession ignores requested lane overrides and still uses primary", async () => {
+ laneService.list.mockResolvedValueOnce([
+ { id: "lane-primary", laneType: "primary" },
+ ]);
await service.execute(makePayload("cto.ensureSession", { laneId: "lane-explicit" }));
expect(agentChatService.ensureIdentitySession).toHaveBeenCalledWith({
identityKey: "cto",
- laneId: "lane-explicit",
+ laneId: "lane-primary",
modelId: null,
reasoningEffort: null,
+ permissionMode: "full-auto",
});
});
it("cto.ensureSession throws when no lane is available", async () => {
laneService.list.mockResolvedValueOnce([]);
await expect(service.execute(makePayload("cto.ensureSession", {})))
- .rejects.toThrow("No active lane is available to host the CTO chat session.");
+ .rejects.toThrow("No primary lane is available to host the CTO chat session.");
});
it("cto.ensureAgentSession requires agentId", async () => {
@@ -1795,7 +1800,7 @@ describe("createSyncRemoteCommandService", () => {
.rejects.toThrow("cto.ensureAgentSession requires agentId.");
});
- it("cto.ensureAgentSession delegates to agentChatService with agent: identityKey", async () => {
+ it("cto.ensureAgentSession delegates to agentChatService with agent: identityKey on primary", async () => {
laneService.list.mockResolvedValueOnce([
{ id: "lane-primary", laneType: "primary" },
]);
@@ -1813,10 +1818,34 @@ describe("createSyncRemoteCommandService", () => {
laneId: "lane-primary",
modelId: null,
reasoningEffort: null,
+ permissionMode: "full-auto",
});
expect(result).toEqual(expect.objectContaining({ sessionId: "chat-1" }));
});
+ it("cto.ensureAgentSession ignores requested lane overrides and still uses primary", async () => {
+ laneService.list.mockResolvedValueOnce([
+ { id: "lane-primary", laneType: "primary" },
+ ]);
+ workerAgentService.getAgent.mockReturnValueOnce({
+ id: "worker-42",
+ name: "Mobile Droid",
+ slug: "mobile-droid",
+ status: "running",
+ });
+ await service.execute(makePayload("cto.ensureAgentSession", {
+ agentId: "worker-42",
+ laneId: "lane-explicit",
+ }));
+ expect(agentChatService.ensureIdentitySession).toHaveBeenCalledWith({
+ identityKey: "agent:worker-42",
+ laneId: "lane-primary",
+ modelId: null,
+ reasoningEffort: null,
+ permissionMode: "full-auto",
+ });
+ });
+
it("cto.ensureAgentSession rejects unknown agentIds without creating a session", async () => {
workerAgentService.getAgent.mockReturnValueOnce(null);
workerAgentService.listAgents.mockReturnValueOnce([]);
@@ -1826,6 +1855,19 @@ describe("createSyncRemoteCommandService", () => {
expect(agentChatService.ensureIdentitySession).not.toHaveBeenCalled();
});
+ it("cto.ensureAgentSession throws when no primary lane is available", async () => {
+ laneService.list.mockResolvedValueOnce([]);
+ workerAgentService.getAgent.mockReturnValueOnce({
+ id: "worker-42",
+ name: "Mobile Droid",
+ slug: "mobile-droid",
+ status: "running",
+ });
+ await expect(service.execute(makePayload("cto.ensureAgentSession", {
+ agentId: "worker-42",
+ }))).rejects.toThrow("No primary lane is available to host the agent chat session.");
+ });
+
it("cto.ensureSession returns the same session on repeat calls (canonical lane reuse)", async () => {
// Both calls resolve the same primary lane; ensureIdentitySession is a
// mock that always returns the same session id, so the handler must
diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts
index cca8c10fb..414f75624 100644
--- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts
+++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts
@@ -1720,12 +1720,8 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg
});
register("cto.ensureSession", { viewerAllowed: true }, async (payload) => {
const agentChatService = requireService(args.agentChatService, "Agent chat service not available.");
- // When no override is given, resolve the primary lane the same way
- // agentChatService.ensureIdentitySession does internally (it reuses a
- // session only when session.laneId === canonicalLaneId). Passing the
- // canonical lane guarantees the same session is returned on repeat calls.
- const laneId = await resolveFirstAvailableLaneIdForSync(args, asTrimmedString(payload.laneId));
- if (!laneId) throw new Error("No active lane is available to host the CTO chat session.");
+ const laneId = await resolveFirstAvailableLaneIdForSync(args, null);
+ if (!laneId) throw new Error("No primary lane is available to host the CTO chat session.");
const modelId = asTrimmedString(payload.modelId);
const reasoningEffort = asTrimmedString(payload.reasoningEffort);
const session = await agentChatService.ensureIdentitySession({
@@ -1733,6 +1729,7 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg
laneId,
modelId: modelId ?? null,
reasoningEffort: reasoningEffort ?? null,
+ permissionMode: "full-auto",
});
return summarizeChatSessionForRemote(agentChatService, session);
});
@@ -1749,8 +1746,8 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg
if (!agent) {
throw new Error(`cto.ensureAgentSession: unknown agentId '${agentId}'`);
}
- const laneId = await resolveFirstAvailableLaneIdForSync(args, asTrimmedString(payload.laneId));
- if (!laneId) throw new Error("No active lane is available to host the agent chat session.");
+ const laneId = await resolveFirstAvailableLaneIdForSync(args, null);
+ if (!laneId) throw new Error("No primary lane is available to host the agent chat session.");
const modelId = asTrimmedString(payload.modelId);
const reasoningEffort = asTrimmedString(payload.reasoningEffort);
const session = await agentChatService.ensureIdentitySession({
@@ -1758,6 +1755,7 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg
laneId,
modelId: modelId ?? null,
reasoningEffort: reasoningEffort ?? null,
+ permissionMode: "full-auto",
});
return summarizeChatSessionForRemote(agentChatService, session);
});
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx
index 651ce4c9e..8210f9ff1 100644
--- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx
+++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx
@@ -187,6 +187,15 @@ describe("AgentChatComposer", () => {
});
});
+ it("can hide native permission controls for fixed-mode surfaces", () => {
+ renderComposer({
+ sessionProvider: "codex",
+ hideNativeControls: true,
+ });
+
+ expect(screen.queryByRole("button", { name: "Codex approval preset" })).toBeNull();
+ });
+
it("avoids promising option chips when a pending question is freeform only", () => {
renderComposer({
pendingInput: {
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx
index 2467c1933..de789f2f0 100644
--- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx
+++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx
@@ -344,6 +344,7 @@ export function AgentChatComposer({
executionModeOptions = [],
modelSelectionLocked = false,
permissionModeLocked = false,
+ hideNativeControls = false,
messagePlaceholder,
onModelChange,
onReasoningEffortChange,
@@ -409,6 +410,7 @@ export function AgentChatComposer({
executionModeOptions?: ExecutionModeOption[];
modelSelectionLocked?: boolean;
permissionModeLocked?: boolean;
+ hideNativeControls?: boolean;
messagePlaceholder?: string;
onModelChange: (modelId: string) => void;
onReasoningEffortChange: (reasoningEffort: string | null) => void;
@@ -742,6 +744,9 @@ export function AgentChatComposer({
return codexPresetOptions.find((option) => option.value === codexPreset)?.detail ?? null;
}, [codexCustomSummary, codexPreset, codexPresetOptions, hoveredCodexPreset, sessionProvider]);
const nativeControlPanel = useMemo(() => {
+ if (hideNativeControls) {
+ return null;
+ }
const renderButtonGroup = (
label: string,
value: T | undefined,
@@ -1109,6 +1114,7 @@ export function AgentChatComposer({
hoveredClaudeMode,
hoveredCodexPreset,
nativeControlsDisabled,
+ hideNativeControls,
onClaudeModeChange,
onClaudePermissionModeChange,
onInteractionModeChange,
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx
index 5c18768dc..a06a5b29a 100644
--- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx
+++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx
@@ -671,6 +671,8 @@ export function AgentChatPane({
initialSessionSummary,
lockSessionId,
hideSessionTabs = false,
+ hideNativeControls = false,
+ hideWorkspaceChrome = false,
forceNewSession = false,
forceDraftMode = false,
availableModelIdsOverride,
@@ -691,6 +693,8 @@ export function AgentChatPane({
initialSessionSummary?: AgentChatSessionSummary | null;
lockSessionId?: string | null;
hideSessionTabs?: boolean;
+ hideNativeControls?: boolean;
+ hideWorkspaceChrome?: boolean;
forceNewSession?: boolean;
forceDraftMode?: boolean;
availableModelIdsOverride?: string[];
@@ -723,6 +727,7 @@ export function AgentChatPane({
const preferDraftStart = !lockSessionId && !initialSessionId && !forceNewSession;
const surfaceProfile: ChatSurfaceProfile = presentation?.profile ?? "standard";
const isPersistentIdentitySurface = surfaceProfile === "persistent_identity";
+ const showWorkspaceChrome = !hideWorkspaceChrome;
const modelSwitchPolicy = presentation?.modelSwitchPolicy ?? "same-family-after-launch";
const initialNativeControls = useMemo(() => defaultNativeControls(surfaceProfile), [surfaceProfile]);
const [sessions, setSessions] = useState([]);
@@ -2606,10 +2611,10 @@ export function AgentChatPane({
) : null}
- {laneId ? : null}
+ {showWorkspaceChrome && laneId ? : null}
- {laneId ? setTerminalDrawerOpen((v) => !v)} /> : null}
+ {showWorkspaceChrome && laneId ? setTerminalDrawerOpen((v) => !v)} /> : null}
{resolvedChips.map((chip) => (
{ void updateNativeControls({ interactionMode: value }); }}
@@ -3095,11 +3101,13 @@ export function AgentChatPane({
sessionId={selectedSessionId}
/>
) : null}
- setTerminalDrawerOpen((v) => !v)}
- laneId={laneId}
- />
+ {showWorkspaceChrome ? (
+ setTerminalDrawerOpen((v) => !v)}
+ laneId={laneId}
+ />
+ ) : null}
{/* Proof panel (push) */}
@@ -3147,7 +3155,7 @@ export function AgentChatPane({
{/* Lane selector pill */}
- {availableLanes && availableLanes.length > 0 && onLaneChange ? (
+ {showWorkspaceChrome && availableLanes && availableLanes.length > 0 && onLaneChange ? (
@@ -3172,7 +3180,7 @@ export function AgentChatPane({
))}
- ) : laneDisplayLabel ? (
+ ) : showWorkspaceChrome && laneDisplayLabel ? (
s.lanes);
- const selectedLaneId = useAppStore((s) => s.selectedLaneId);
const [activeTab, setActiveTab] = useState("chat");
const [session, setSession] = useState(null);
@@ -126,10 +126,7 @@ export function CtoPage() {
const lastBudgetLoadAtRef = useRef(0);
const ctoDisplayName = "CTO";
- const laneId = useMemo(() => {
- if (selectedLaneId && lanes.some((lane) => lane.id === selectedLaneId)) return selectedLaneId;
- return lanes.find((lane) => lane.laneType === "primary")?.id ?? lanes[0]?.id ?? null;
- }, [lanes, selectedLaneId]);
+ const primaryLaneId = useMemo(() => resolveCtoPrimaryLaneId(lanes), [lanes]);
const selectedWorker = useMemo(
() => (selectedAgentId ? agents.find((a) => a.id === selectedAgentId) ?? null : null),
@@ -294,18 +291,18 @@ export function CtoPage() {
setSession(null);
return;
}
- if (!laneId) { setSession(null); return; }
+ if (!primaryLaneId) { setSession(null); return; }
let cancelled = false;
setLoading(true); setError(null);
const promise = selectedAgentId
- ? window.ade.cto.ensureAgentSession({ agentId: selectedAgentId, laneId })
- : window.ade.cto.ensureSession({ laneId });
+ ? window.ade.cto.ensureAgentSession({ agentId: selectedAgentId })
+ : window.ade.cto.ensureSession();
void promise
.then((next) => { if (!cancelled) setSession(next); })
.catch((err) => { if (!cancelled) { setError(err instanceof Error ? err.message : String(err)); setSession(null); } })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
- }, [activeTab, laneId, needsOnboarding, onboardingState, selectedAgentId, showOnboarding]);
+ }, [activeTab, needsOnboarding, onboardingState, primaryLaneId, selectedAgentId, showOnboarding]);
// Deep links for guided setup flows
useEffect(() => {
@@ -333,15 +330,15 @@ export function CtoPage() {
/* ── Callbacks ── */
const refreshPersistentCtoSession = useCallback(async () => {
- if (!window.ade?.cto || !laneId || showOnboarding || needsOnboarding) {
+ if (!window.ade?.cto || !primaryLaneId || showOnboarding || needsOnboarding) {
return null;
}
- const next = await window.ade.cto.ensureSession({ laneId });
+ const next = await window.ade.cto.ensureSession();
if (!selectedAgentId) {
setSession(next);
}
return next;
- }, [laneId, needsOnboarding, selectedAgentId, showOnboarding]);
+ }, [needsOnboarding, primaryLaneId, selectedAgentId, showOnboarding]);
const handleSaveCoreMemory = useCallback(async (patch: Record) => {
if (!window.ade?.cto) throw new Error("CTO bridge unavailable.");
@@ -682,9 +679,9 @@ export function CtoPage() {
{loading &&
Connecting persistent session...
}
{error &&
{error}
}
- {!laneId && (
+ {!primaryLaneId && (
- Create a lane to start the persistent CTO session.
+ ADE could not resolve the primary workspace for the persistent CTO session.
)}
@@ -717,10 +714,12 @@ export function CtoPage() {
) : (
)}
@@ -863,7 +862,7 @@ export function CtoPage() {
{/* Linear tab */}
{activeTab === "workflows" && (
-
+
)}
diff --git a/apps/desktop/src/renderer/components/cto/ctoSessionViewState.test.ts b/apps/desktop/src/renderer/components/cto/ctoSessionViewState.test.ts
new file mode 100644
index 000000000..196ebc99a
--- /dev/null
+++ b/apps/desktop/src/renderer/components/cto/ctoSessionViewState.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from "vitest";
+import { resolveCtoPrimaryLaneId } from "./ctoSessionViewState";
+
+describe("resolveCtoPrimaryLaneId", () => {
+ it("prefers the primary lane even when another lane is selected elsewhere in the app", () => {
+ expect(resolveCtoPrimaryLaneId([
+ { id: "lane-feature", laneType: "worktree" },
+ { id: "lane-primary", laneType: "primary" },
+ ])).toBe("lane-primary");
+ });
+
+ it("falls back to the first lane when a primary lane has not been materialized yet", () => {
+ expect(resolveCtoPrimaryLaneId([
+ { id: "lane-feature", laneType: "worktree" },
+ { id: "lane-bugfix", laneType: "worktree" },
+ ])).toBe("lane-feature");
+ });
+
+ it("returns null when no lanes are available", () => {
+ expect(resolveCtoPrimaryLaneId([])).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/renderer/components/cto/ctoSessionViewState.ts b/apps/desktop/src/renderer/components/cto/ctoSessionViewState.ts
new file mode 100644
index 000000000..e4549c6d3
--- /dev/null
+++ b/apps/desktop/src/renderer/components/cto/ctoSessionViewState.ts
@@ -0,0 +1,3 @@
+export function resolveCtoPrimaryLaneId(lanes: Array<{ id: string; laneType?: string | null }>): string | null {
+ return lanes.find((lane) => lane.laneType === "primary")?.id ?? lanes[0]?.id ?? null;
+}
diff --git a/apps/desktop/src/shared/types/agents.ts b/apps/desktop/src/shared/types/agents.ts
index d8e80e75f..8724b4f6e 100644
--- a/apps/desktop/src/shared/types/agents.ts
+++ b/apps/desktop/src/shared/types/agents.ts
@@ -337,10 +337,8 @@ export type CtoRollbackAgentRevisionArgs = {
export type CtoEnsureAgentSessionArgs = {
agentId: string;
- laneId?: string | null;
modelId?: ModelId | null;
reasoningEffort?: string | null;
- taskKey?: string | null;
};
export type CtoListAgentTaskSessionsArgs = {
diff --git a/apps/desktop/src/shared/types/cto.ts b/apps/desktop/src/shared/types/cto.ts
index 5eac4e04c..401adc7a1 100644
--- a/apps/desktop/src/shared/types/cto.ts
+++ b/apps/desktop/src/shared/types/cto.ts
@@ -91,7 +91,6 @@ export type CtoGetStateArgs = {
};
export type CtoEnsureSessionArgs = {
- laneId?: string | null;
modelId?: ModelId | null;
reasoningEffort?: string | null;
};
From 05bd5c1647e269db16673782e20bc17d867a3164 Mon Sep 17 00:00:00 2001
From: Arul Sharma <31745423+arul28@users.noreply.github.com>
Date: Thu, 23 Apr 2026 15:03:16 -0400
Subject: [PATCH 2/5] Cover non-identity fallback in identity session policy
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../src/main/services/chat/identitySessionPolicy.test.ts | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts b/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts
index 4bfd2e449..5c046309d 100644
--- a/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts
+++ b/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts
@@ -16,4 +16,10 @@ describe("identitySessionPolicy", () => {
expect(resolveIdentityExecutionLane("cto", "lane-feature", "lane-primary")).toBe("lane-primary");
expect(resolveIdentityExecutionLane("agent:worker-1", "lane-feature", "lane-primary")).toBe("lane-primary");
});
+
+ it("falls back to plan/guarded mode for non-identity sessions", () => {
+ expect(normalizeIdentityPermissionMode(undefined, "plan", "claude")).toBe("plan");
+ expect(normalizeIdentityPermissionMode(undefined, "full-auto", "claude")).toBe("plan");
+ expect(normalizeIdentityPermissionMode(undefined, undefined, "codex")).toBe("plan");
+ });
});
From 20bc2a58e266f18beac3e463a33941e2022ba1dd Mon Sep 17 00:00:00 2001
From: Arul Sharma <31745423+arul28@users.noreply.github.com>
Date: Thu, 23 Apr 2026 15:33:36 -0400
Subject: [PATCH 3/5] Enforce strict primary-lane + full-auto policy on
identity sessions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Addresses CodeRabbit/Greptile/capy-ai/Copilot review on #179.
- registerIpc.ts / syncRemoteCommandService.ts: replace the lanes[0]
fallback paths with resolvePrimaryLaneIdOnly(...) strict resolvers
for cto/agent identity ensure-session flows — no primary lane means
throw "No primary lane…", not silently bind to a foreign lane.
- agentChatService.ts: resolvePrimaryIdentityLane drops the ?? lanes[0]
fallback. createSession strips claudePermissionMode, codexApprovalPolicy,
codexSandbox, codexConfigSource, opencodePermissionMode, and
interactionMode before nativePermissionFields runs when the identity is
pinned, so full-auto derivation wins. updateSession gates the same
fields behind !identityPinned so stricter native modes can't layer on
over IPC. resumeSession detects a pinned-identity session row whose
laneId drifted to a foreign lane, rewrites it via sessionService and
re-hydrates before spinning up runtime.
- sessionService.updateMeta / UpdateSessionMetaArgs: typed laneId field
so the resume-time migration can actually persist.
- identitySessionPolicy.ts: isPrimaryPinnedIdentity requires a non-empty
trimmed agent: suffix. guardedIdentityPermissionModeForProvider
un-exported (module-local only). resolveIdentityExecutionLane params
widened to string | null | undefined / string | null to match usage.
- CtoPage.tsx: "primary workspace" fallback copy → "primary lane" to
match main-process error strings.
- .gitignore: anchor .asc/artifacts/ with leading / to avoid matching
nested paths.
Tests added in identitySessionPolicy.test.ts, agentChatService.test.ts,
syncRemoteCommandService.test.ts; all affected test files pass.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.gitignore | 2 +-
.../services/chat/agentChatService.test.ts | 82 +++++++++++++++++++
.../main/services/chat/agentChatService.ts | 76 +++++++++++++----
.../chat/identitySessionPolicy.test.ts | 23 ++++++
.../services/chat/identitySessionPolicy.ts | 18 ++--
.../src/main/services/ipc/registerIpc.ts | 19 +++--
.../main/services/sessions/sessionService.ts | 8 ++
.../sync/syncRemoteCommandService.test.ts | 28 +++++++
.../services/sync/syncRemoteCommandService.ts | 19 +++--
.../src/renderer/components/cto/CtoPage.tsx | 2 +-
apps/desktop/src/shared/types/sessions.ts | 6 ++
11 files changed, 240 insertions(+), 43 deletions(-)
diff --git a/.gitignore b/.gitignore
index 333cafaea..705f05441 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,7 +48,7 @@ xcuserdata/
apps/ios/.dry-run-derived-data/
apps/ios/build/
ios-signing/
-.asc/artifacts/
+/.asc/artifacts/
# Tool configs (personal)
.codex/
diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts
index 057ef2924..26d70069f 100644
--- a/apps/desktop/src/main/services/chat/agentChatService.test.ts
+++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts
@@ -2015,6 +2015,88 @@ describe("createAgentChatService", () => {
expect(session.permissionMode).toBe("full-auto");
expect(sessionService.setHeadShaStart).toHaveBeenLastCalledWith(session.id, "lane-1-sha");
});
+
+ it("ignores native provider permission overrides for pinned identities on create", async () => {
+ const { service } = createService();
+ // Callers over IPC could previously pass through `claudePermissionMode:
+ // "plan"` to keep a CTO session from ever running automatically — the
+ // identity pin must strip these so full-auto still wins.
+ const session = await service.createSession({
+ laneId: "lane-1",
+ provider: "claude",
+ model: "claude-sonnet-4-7",
+ modelId: "claude-sonnet-4-7",
+ identityKey: "cto",
+ claudePermissionMode: "plan",
+ interactionMode: "plan",
+ });
+
+ // `plan` must never be persisted on the claude native fields for a
+ // pinned identity — otherwise the runtime will ignore full-auto at turn
+ // start. We do not assert on the synthesized permissionMode here because
+ // the top-level mock for mapPermissionToClaude collapses to "plan" in
+ // this test file; the native fields are the real source of truth the
+ // runtime consults.
+ expect(session.claudePermissionMode).not.toBe("plan");
+ expect(session.interactionMode).not.toBe("plan");
+ });
+
+ it("ignores native codex permission overrides for worker identities on create", async () => {
+ // Locally map modes so full-auto => danger-full-access / never and the
+ // default mapping (used when no permissionMode is passed) stays on the
+ // on-request / read-only baseline. This lets us prove the IPC-provided
+ // `codexApprovalPolicy: "untrusted"` / `codexSandbox: "read-only"` never
+ // land on the session — the full-auto derivation is used instead.
+ vi.mocked(mapPermissionToCodex).mockImplementation((mode) => {
+ if (mode === "full-auto") return { approvalPolicy: "never", sandbox: "danger-full-access" };
+ if (mode === "edit") return { approvalPolicy: "untrusted", sandbox: "workspace-write" };
+ return { approvalPolicy: "on-request", sandbox: "read-only" };
+ });
+
+ const { service } = createService();
+ const session = await service.createSession({
+ laneId: "lane-1",
+ provider: "codex",
+ model: "gpt-5-codex",
+ modelId: "gpt-5-codex",
+ identityKey: "agent:worker-1",
+ codexApprovalPolicy: "untrusted",
+ codexSandbox: "read-only",
+ });
+
+ expect(session.codexApprovalPolicy).toBe("never");
+ expect(session.codexSandbox).toBe("danger-full-access");
+ });
+
+ it("ignores native permission overrides for pinned identities on update", async () => {
+ const { service } = createService();
+ const session = await service.ensureIdentitySession({
+ identityKey: "cto",
+ laneId: "lane-1",
+ });
+ const claudeBefore = session.claudePermissionMode;
+ const opencodeBefore = session.opencodePermissionMode;
+
+ const updated = await service.updateSession({
+ sessionId: session.id,
+ claudePermissionMode: "plan",
+ interactionMode: "plan",
+ codexApprovalPolicy: "untrusted",
+ codexSandbox: "read-only",
+ opencodePermissionMode: "plan",
+ });
+
+ // None of the stricter native modes should have landed on the session.
+ expect(updated.interactionMode).not.toBe("plan");
+ if (claudeBefore !== undefined) {
+ expect(updated.claudePermissionMode).toBe(claudeBefore);
+ }
+ if (opencodeBefore !== undefined) {
+ expect(updated.opencodePermissionMode).toBe(opencodeBefore);
+ }
+ expect(updated.codexApprovalPolicy).not.toBe("untrusted");
+ expect(updated.codexSandbox).not.toBe("read-only");
+ });
});
describe("identity continuity", () => {
diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts
index 9a0847fec..3e98d556e 100644
--- a/apps/desktop/src/main/services/chat/agentChatService.ts
+++ b/apps/desktop/src/main/services/chat/agentChatService.ts
@@ -38,6 +38,7 @@ import {
type BufferedAssistantText,
} from "./chatTextBatching";
import {
+ isPrimaryPinnedIdentity,
normalizeIdentityPermissionMode,
resolveIdentityExecutionLane,
} from "./identitySessionPolicy";
@@ -4784,9 +4785,12 @@ export function createAgentChatService(args: {
const resolvePrimaryIdentityLane = async (): Promise => {
await laneService.ensurePrimaryLane?.().catch(() => {});
const lanes = await laneService.list({ includeArchived: false, includeStatus: false });
- const primary = lanes.find((lane) => lane.laneType === "primary") ?? lanes[0] ?? null;
+ // Identity sessions (CTO + worker agents) must pin to the actual primary
+ // lane. Never fall back to lanes[0] — that would silently land the
+ // identity on a foreign lane, defeating the whole contract.
+ const primary = lanes.find((lane) => lane.laneType === "primary") ?? null;
if (!primary?.id) {
- throw new Error("No lane is available to host the canonical identity chat session.");
+ throw new Error("No primary lane is available to host the canonical identity chat session.");
}
return primary.id;
};
@@ -10372,11 +10376,22 @@ export function createAgentChatService(args: {
: undefined;
const normalizedCursorConfigValues = normalizeCursorConfigValueRecord(requestedCursorConfigValues);
const capabilityMode = inferCapabilityMode(effectiveProvider);
+ // Identity-pinned sessions (CTO + worker agents) are locked to full-auto.
+ // Discard the native provider permission overrides supplied by callers so
+ // they cannot smuggle `plan` / `ask` / read-only sandboxes past the lock
+ // via claudePermissionMode / codexApprovalPolicy / codexSandbox /
+ // opencodePermissionMode.
+ const identityPinned = isPrimaryPinnedIdentity(identityKey);
+ const effectiveInteractionMode = identityPinned ? undefined : requestedInteractionMode;
+ const effectiveClaudePermissionMode = identityPinned ? undefined : requestedClaudePermissionMode;
+ const effectiveCodexApprovalPolicy = identityPinned ? undefined : requestedCodexApprovalPolicy;
+ const effectiveCodexSandbox = identityPinned ? undefined : requestedCodexSandbox;
+ const effectiveCodexConfigSource = identityPinned ? undefined : requestedCodexConfigSource;
let effectivePermissionMode = identityKey
? normalizeIdentityPermissionMode(identityKey, requestedPermMode, effectiveProvider)
: requestedPermMode;
const chatConfig = resolveChatConfig();
- let requestedOpenCodePermissionMode = requestedOpenCodePermissionModeArg;
+ let requestedOpenCodePermissionMode = identityPinned ? undefined : requestedOpenCodePermissionModeArg;
const localHarnessPermissions = applyLocalHarnessPermissionMode({
descriptor: resolvedDescriptor,
requestedPermissionMode: effectivePermissionMode,
@@ -10387,14 +10402,14 @@ export function createAgentChatService(args: {
const nativePermissionFields = (() => {
if (effectiveProvider === "claude") {
- const interactionMode = requestedInteractionMode
- ?? (requestedClaudePermissionMode === "plan" ? "plan" : undefined)
+ const interactionMode = effectiveInteractionMode
+ ?? (effectiveClaudePermissionMode === "plan" ? "plan" : undefined)
?? (effectivePermissionMode === "plan" ? "plan" : undefined)
?? (chatConfig.claudePermissionMode === "plan" ? "plan" : undefined)
?? "default";
- const claudePermissionMode = requestedClaudePermissionMode
+ const claudePermissionMode = effectiveClaudePermissionMode
? resolveSessionClaudeAccessMode(
- { claudePermissionMode: requestedClaudePermissionMode, permissionMode: undefined },
+ { claudePermissionMode: effectiveClaudePermissionMode, permissionMode: undefined },
chatConfig.claudePermissionMode,
)
: resolveSessionClaudeAccessMode(
@@ -10404,17 +10419,17 @@ export function createAgentChatService(args: {
return { interactionMode, claudePermissionMode };
}
if (effectiveProvider === "codex") {
- const codexConfigSource = requestedCodexConfigSource
+ const codexConfigSource = effectiveCodexConfigSource
?? legacyPermissionModeToCodexConfigSource(effectivePermissionMode)
?? "flags";
if (codexConfigSource === "config-toml") {
return { codexConfigSource };
}
return {
- codexApprovalPolicy: requestedCodexApprovalPolicy
+ codexApprovalPolicy: effectiveCodexApprovalPolicy
?? legacyPermissionModeToCodexApprovalPolicy(effectivePermissionMode)
?? chatConfig.codexApprovalPolicy,
- codexSandbox: requestedCodexSandbox
+ codexSandbox: effectiveCodexSandbox
?? legacyPermissionModeToCodexSandbox(effectivePermissionMode)
?? chatConfig.codexSandboxMode,
codexConfigSource,
@@ -12293,7 +12308,21 @@ export function createAgentChatService(args: {
};
const resumeSession = async ({ sessionId }: { sessionId: string }): Promise => {
- const managed = ensureManagedSession(sessionId);
+ let managed = ensureManagedSession(sessionId);
+
+ // Identity-pinned sessions (CTO + worker agents) must always run on the
+ // canonical primary lane. If a persisted row points at a foreign lane
+ // (e.g. pre-pinning session, or the previous primary was archived),
+ // migrate it to the canonical lane before we spin up a runtime.
+ if (isPrimaryPinnedIdentity(managed.session.identityKey) && managed.session.laneId) {
+ const canonicalLaneId = await resolvePrimaryIdentityLane();
+ if (canonicalLaneId && managed.session.laneId !== canonicalLaneId) {
+ sessionService.updateMeta({ sessionId, laneId: canonicalLaneId });
+ managedSessions.delete(sessionId);
+ managed = ensureManagedSession(sessionId);
+ }
+ }
+
refreshManagedLaneLaunchContext(managed, { purpose: "resume this chat" });
const persisted = readPersistedState(sessionId);
managed.session.capabilityMode = managed.session.capabilityMode ?? inferCapabilityMode(managed.session.provider);
@@ -12563,6 +12592,14 @@ export function createAgentChatService(args: {
const preferred = canonicalExisting;
if (preferred) {
+ // Defensive guard: if the selected canonical session ever drifted onto
+ // a foreign lane (e.g. from a legacy DB write), rewrite it to the
+ // canonical lane before we hydrate/resume so identity sessions never
+ // run on a non-primary lane.
+ if (preferred.laneId !== canonicalLaneId && isPrimaryPinnedIdentity(args.identityKey)) {
+ sessionService.updateMeta({ sessionId: preferred.sessionId, laneId: canonicalLaneId });
+ managedSessions.delete(preferred.sessionId);
+ }
const managed = ensureManagedSession(preferred.sessionId);
managed.session.identityKey = args.identityKey;
managed.session.capabilityMode = inferCapabilityMode(managed.session.provider);
@@ -13270,6 +13307,7 @@ export function createAgentChatService(args: {
const managed = ensureManagedSession(sessionId);
const chatConfig = resolveChatConfig();
const isIdentitySession = Boolean(managed.session.identityKey);
+ const identityPinned = isPrimaryPinnedIdentity(managed.session.identityKey);
const hasConversation = managed.recentConversationEntries.length > 0 || readTranscriptConversationEntries(managed).length > 0;
const prevCodexApprovalPolicy = managed.session.codexApprovalPolicy;
const prevCodexSandbox = managed.session.codexSandbox;
@@ -13397,11 +13435,15 @@ export function createAgentChatService(args: {
applyLegacyPermissionModeToNativeControls(managed.session, managed.session.permissionMode);
}
- if (interactionMode !== undefined) {
+ // Identity-pinned sessions (CTO + worker agents) are locked to full-auto.
+ // Ignore incoming native permission overrides — applyLegacyPermissionMode-
+ // ToNativeControls() has already derived the correct native fields from
+ // full-auto, and we must not let callers layer a stricter mode on top.
+ if (interactionMode !== undefined && !identityPinned) {
managed.session.interactionMode = interactionMode;
}
- if (claudePermissionMode !== undefined) {
+ if (claudePermissionMode !== undefined && !identityPinned) {
if (claudePermissionMode === "plan") {
managed.session.interactionMode = "plan";
} else {
@@ -13409,19 +13451,19 @@ export function createAgentChatService(args: {
}
}
- if (codexApprovalPolicy !== undefined) {
+ if (codexApprovalPolicy !== undefined && !identityPinned) {
managed.session.codexApprovalPolicy = codexApprovalPolicy;
}
- if (codexSandbox !== undefined) {
+ if (codexSandbox !== undefined && !identityPinned) {
managed.session.codexSandbox = codexSandbox;
}
- if (codexConfigSource !== undefined) {
+ if (codexConfigSource !== undefined && !identityPinned) {
managed.session.codexConfigSource = codexConfigSource;
}
- if (opencodePermissionMode !== undefined) {
+ if (opencodePermissionMode !== undefined && !identityPinned) {
managed.session.opencodePermissionMode = opencodePermissionMode;
}
diff --git a/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts b/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts
index 5c046309d..b594c504b 100644
--- a/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts
+++ b/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
+ isPrimaryPinnedIdentity,
normalizeIdentityPermissionMode,
resolveIdentityExecutionLane,
} from "./identitySessionPolicy";
@@ -22,4 +23,26 @@ describe("identitySessionPolicy", () => {
expect(normalizeIdentityPermissionMode(undefined, "full-auto", "claude")).toBe("plan");
expect(normalizeIdentityPermissionMode(undefined, undefined, "codex")).toBe("plan");
});
+
+ it("treats empty or whitespace-only agent suffixes as non-pinned", () => {
+ expect(isPrimaryPinnedIdentity("cto")).toBe(true);
+ expect(isPrimaryPinnedIdentity("agent:worker-1")).toBe(true);
+ // Cast through unknown so the test can probe malformed identity keys that
+ // ideally should never reach the helper but still could arrive via IPC.
+ expect(isPrimaryPinnedIdentity("agent:" as never)).toBe(false);
+ expect(isPrimaryPinnedIdentity("agent: " as never)).toBe(false);
+ expect(isPrimaryPinnedIdentity(undefined)).toBe(false);
+
+ // Pinned-identity pathways should fall through to the guarded default for
+ // malformed agent suffixes so a caller cannot smuggle full-auto in by
+ // passing `agent: `.
+ expect(normalizeIdentityPermissionMode("agent: " as never, undefined, "claude")).toBe("plan");
+ expect(normalizeIdentityPermissionMode("agent:" as never, "plan", "codex")).toBe("plan");
+ });
+
+ it("returns the canonical lane (including null) for pinned identities", () => {
+ expect(resolveIdentityExecutionLane("cto", undefined, "lane-primary")).toBe("lane-primary");
+ expect(resolveIdentityExecutionLane("cto", null, "lane-primary")).toBe("lane-primary");
+ expect(resolveIdentityExecutionLane("cto", "lane-feature", null)).toBe(null);
+ });
});
diff --git a/apps/desktop/src/main/services/chat/identitySessionPolicy.ts b/apps/desktop/src/main/services/chat/identitySessionPolicy.ts
index 896d868c8..f269497cf 100644
--- a/apps/desktop/src/main/services/chat/identitySessionPolicy.ts
+++ b/apps/desktop/src/main/services/chat/identitySessionPolicy.ts
@@ -1,11 +1,16 @@
import type { AgentChatIdentityKey, AgentChatProvider, AgentChatSession } from "../../../shared/types";
-export function guardedIdentityPermissionModeForProvider(_provider: AgentChatProvider): AgentChatSession["permissionMode"] {
+function guardedIdentityPermissionModeForProvider(_provider: AgentChatProvider): AgentChatSession["permissionMode"] {
return "plan";
}
-function isPrimaryPinnedIdentity(identityKey: AgentChatIdentityKey | undefined): boolean {
- return identityKey === "cto" || Boolean(identityKey?.startsWith("agent:"));
+export function isPrimaryPinnedIdentity(identityKey: AgentChatIdentityKey | undefined): boolean {
+ if (identityKey === "cto") return true;
+ if (!identityKey || !identityKey.startsWith("agent:")) return false;
+ // Require a non-empty trimmed suffix so `agent:` and `agent: ` do not
+ // masquerade as a worker identity. Matches the stricter checks in
+ // resolveWorkerIdentityAgentId / normalizeIdentityKey.
+ return identityKey.slice("agent:".length).trim().length > 0;
}
export function normalizeIdentityPermissionMode(
@@ -21,11 +26,12 @@ export function normalizeIdentityPermissionMode(
export function resolveIdentityExecutionLane(
identityKey: AgentChatIdentityKey,
- requestedLaneId: string,
- canonicalLaneId: string,
+ requestedLaneId: string | null | undefined,
+ canonicalLaneId: string | null,
): string | null {
if (isPrimaryPinnedIdentity(identityKey)) {
return canonicalLaneId;
}
- return requestedLaneId || null;
+ const trimmedRequested = typeof requestedLaneId === "string" ? requestedLaneId.trim() : "";
+ return trimmedRequested.length ? trimmedRequested : null;
}
diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts
index e81f2f47a..a231b2e82 100644
--- a/apps/desktop/src/main/services/ipc/registerIpc.ts
+++ b/apps/desktop/src/main/services/ipc/registerIpc.ts
@@ -1180,15 +1180,16 @@ function getMemoryHealthStats(ctx: AppContext) {
return stats;
}
-async function resolveFirstAvailableLaneId(
- ctx: AppContext,
- requestedLaneId: string | undefined | null
-): Promise {
- const laneId = typeof requestedLaneId === "string" ? requestedLaneId.trim() : "";
- if (laneId) return laneId;
+/**
+ * Strict resolver for identity-pinned sessions (CTO + worker agents). Requires
+ * an actual primary lane and never slips a foreign lane through via a
+ * `lanes[0]` fallback — if there is no primary lane the caller must surface
+ * the error rather than silently landing the identity on a non-primary lane.
+ */
+async function resolvePrimaryLaneIdOnly(ctx: AppContext): Promise {
await ctx.laneService.ensurePrimaryLane().catch(() => {});
const lanes = await ctx.laneService.list({ includeArchived: false, includeStatus: false });
- return (lanes.find((lane) => lane.laneType === "primary") ?? lanes[0])?.id ?? "";
+ return lanes.find((lane) => lane.laneType === "primary")?.id ?? "";
}
async function resolveLaneOverlayContext(ctx: AppContext, laneId: string) {
@@ -6272,7 +6273,7 @@ export function registerIpc({
ipcMain.handle(IPC.ctoEnsureSession, async (_event, arg: CtoEnsureSessionArgs = {}): Promise => {
const ctx = getCtx();
- const laneId = await resolveFirstAvailableLaneId(ctx, null);
+ const laneId = await resolvePrimaryLaneIdOnly(ctx);
if (!laneId) {
throw new Error("No primary lane is available to host the CTO chat session.");
}
@@ -6344,7 +6345,7 @@ export function registerIpc({
ipcMain.handle(IPC.ctoEnsureAgentSession, async (_event, arg: CtoEnsureAgentSessionArgs): Promise => {
const ctx = getCtx();
if (!ctx.agentChatService) throw new Error("Agent chat service is not available.");
- const laneId = await resolveFirstAvailableLaneId(ctx, null);
+ const laneId = await resolvePrimaryLaneIdOnly(ctx);
if (!laneId) throw new Error("No primary lane is available to host the agent chat session.");
return ctx.agentChatService.ensureIdentitySession({
identityKey: `agent:${arg.agentId}`,
diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts
index 87f7e28d1..fae823408 100644
--- a/apps/desktop/src/main/services/sessions/sessionService.ts
+++ b/apps/desktop/src/main/services/sessions/sessionService.ts
@@ -335,6 +335,14 @@ export function createSessionService({ db }: { db: AdeDb }) {
params.push(args.manuallyNamed ? 1 : 0);
}
+ if (typeof args.laneId === "string") {
+ const nextLaneId = args.laneId.trim();
+ if (nextLaneId.length) {
+ sets.push("lane_id = ?");
+ params.push(nextLaneId);
+ }
+ }
+
if (args.title !== undefined) {
const nextTitle = typeof args.title === "string" ? args.title.trim() : "";
if (nextTitle.length) {
diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts
index 47b52331e..121354c67 100644
--- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts
+++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts
@@ -1795,6 +1795,18 @@ describe("createSyncRemoteCommandService", () => {
.rejects.toThrow("No primary lane is available to host the CTO chat session.");
});
+ it("cto.ensureSession refuses to fall back to lanes[0] when no primary exists", async () => {
+ // Only non-primary lanes are available; identity-pinned sessions must
+ // not silently land on a foreign lane via the lanes[0] fallback.
+ laneService.list.mockResolvedValueOnce([
+ { id: "lane-feature", laneType: "feature" },
+ { id: "lane-scratch", laneType: "feature" },
+ ]);
+ await expect(service.execute(makePayload("cto.ensureSession", {})))
+ .rejects.toThrow("No primary lane is available to host the CTO chat session.");
+ expect(agentChatService.ensureIdentitySession).not.toHaveBeenCalled();
+ });
+
it("cto.ensureAgentSession requires agentId", async () => {
await expect(service.execute(makePayload("cto.ensureAgentSession", {})))
.rejects.toThrow("cto.ensureAgentSession requires agentId.");
@@ -1868,6 +1880,22 @@ describe("createSyncRemoteCommandService", () => {
}))).rejects.toThrow("No primary lane is available to host the agent chat session.");
});
+ it("cto.ensureAgentSession refuses to fall back to lanes[0] when no primary exists", async () => {
+ laneService.list.mockResolvedValueOnce([
+ { id: "lane-feature", laneType: "feature" },
+ ]);
+ workerAgentService.getAgent.mockReturnValueOnce({
+ id: "worker-42",
+ name: "Mobile Droid",
+ slug: "mobile-droid",
+ status: "running",
+ });
+ await expect(service.execute(makePayload("cto.ensureAgentSession", {
+ agentId: "worker-42",
+ }))).rejects.toThrow("No primary lane is available to host the agent chat session.");
+ expect(agentChatService.ensureIdentitySession).not.toHaveBeenCalled();
+ });
+
it("cto.ensureSession returns the same session on repeat calls (canonical lane reuse)", async () => {
// Both calls resolve the same primary lane; ensureIdentitySession is a
// mock that always returns the same session id, so the handler must
diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts
index 414f75624..c7d14d872 100644
--- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts
+++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts
@@ -1242,15 +1242,16 @@ function applyLeaseToOverrides(
};
}
-async function resolveFirstAvailableLaneIdForSync(
- args: SyncRemoteCommandServiceArgs,
- requestedLaneId: string | null,
-): Promise {
- const laneId = typeof requestedLaneId === "string" ? requestedLaneId.trim() : "";
- if (laneId) return laneId;
+/**
+ * Strict resolver for identity-pinned sessions (CTO + worker agents). Never
+ * slips a foreign lane through via a `lanes[0]` fallback — if no primary lane
+ * exists, the caller must error out rather than silently host the identity on
+ * a non-primary lane.
+ */
+async function resolvePrimaryLaneIdOnlyForSync(args: SyncRemoteCommandServiceArgs): Promise {
await args.laneService.ensurePrimaryLane?.().catch(() => {});
const lanes = await args.laneService.list({ includeArchived: false, includeStatus: false });
- return (lanes.find((lane) => lane.laneType === "primary") ?? lanes[0])?.id ?? "";
+ return lanes.find((lane) => lane.laneType === "primary")?.id ?? "";
}
async function resolveLaneOverlayContext(args: SyncRemoteCommandServiceArgs, laneId: string) {
@@ -1720,7 +1721,7 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg
});
register("cto.ensureSession", { viewerAllowed: true }, async (payload) => {
const agentChatService = requireService(args.agentChatService, "Agent chat service not available.");
- const laneId = await resolveFirstAvailableLaneIdForSync(args, null);
+ const laneId = await resolvePrimaryLaneIdOnlyForSync(args);
if (!laneId) throw new Error("No primary lane is available to host the CTO chat session.");
const modelId = asTrimmedString(payload.modelId);
const reasoningEffort = asTrimmedString(payload.reasoningEffort);
@@ -1746,7 +1747,7 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg
if (!agent) {
throw new Error(`cto.ensureAgentSession: unknown agentId '${agentId}'`);
}
- const laneId = await resolveFirstAvailableLaneIdForSync(args, null);
+ const laneId = await resolvePrimaryLaneIdOnlyForSync(args);
if (!laneId) throw new Error("No primary lane is available to host the agent chat session.");
const modelId = asTrimmedString(payload.modelId);
const reasoningEffort = asTrimmedString(payload.reasoningEffort);
diff --git a/apps/desktop/src/renderer/components/cto/CtoPage.tsx b/apps/desktop/src/renderer/components/cto/CtoPage.tsx
index 4f9351b6d..e9314b949 100644
--- a/apps/desktop/src/renderer/components/cto/CtoPage.tsx
+++ b/apps/desktop/src/renderer/components/cto/CtoPage.tsx
@@ -681,7 +681,7 @@ export function CtoPage() {
{error && {error}
}
{!primaryLaneId && (
- ADE could not resolve the primary workspace for the persistent CTO session.
+ ADE could not resolve the primary lane for the persistent CTO session.
)}
diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts
index fe94fd37c..4c46a6733 100644
--- a/apps/desktop/src/shared/types/sessions.ts
+++ b/apps/desktop/src/shared/types/sessions.ts
@@ -144,6 +144,12 @@ export type UpdateSessionMetaArgs = {
toolType?: TerminalToolType | null;
resumeCommand?: string | null;
resumeMetadata?: TerminalResumeMetadata | null;
+ /**
+ * Migrate the session to a different lane. Used by identity sessions (CTO /
+ * worker) that must remain pinned to the canonical primary lane even if
+ * they were previously persisted against a foreign lane.
+ */
+ laneId?: string;
};
export type ReadTranscriptTailArgs = {
From 495b64cbe3853bdcb22abc527c3e6492714348a3 Mon Sep 17 00:00:00 2001
From: Arul Sharma <31745423+arul28@users.noreply.github.com>
Date: Thu, 23 Apr 2026 15:55:58 -0400
Subject: [PATCH 4/5] =?UTF-8?q?ship:=20iteration=203=20=E2=80=94=20address?=
=?UTF-8?q?=20copilot=20follow-ups?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/main/services/chat/agentChatService.ts | 13 +++++--------
1 file changed, 5 insertions(+), 8 deletions(-)
diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts
index 3e98d556e..2c5469745 100644
--- a/apps/desktop/src/main/services/chat/agentChatService.ts
+++ b/apps/desktop/src/main/services/chat/agentChatService.ts
@@ -12592,14 +12592,11 @@ export function createAgentChatService(args: {
const preferred = canonicalExisting;
if (preferred) {
- // Defensive guard: if the selected canonical session ever drifted onto
- // a foreign lane (e.g. from a legacy DB write), rewrite it to the
- // canonical lane before we hydrate/resume so identity sessions never
- // run on a non-primary lane.
- if (preferred.laneId !== canonicalLaneId && isPrimaryPinnedIdentity(args.identityKey)) {
- sessionService.updateMeta({ sessionId: preferred.sessionId, laneId: canonicalLaneId });
- managedSessions.delete(preferred.sessionId);
- }
+ // `canonicalExisting` is already filtered to `entry.laneId === canonicalLaneId`,
+ // so `preferred` is guaranteed to be on the canonical lane here — no
+ // migration guard needed. Foreign-lane legacy sessions are left untouched
+ // (see the `does not reuse a foreign-lane identity session` test); a
+ // fresh canonical session will be created below when none is found.
const managed = ensureManagedSession(preferred.sessionId);
managed.session.identityKey = args.identityKey;
managed.session.capabilityMode = inferCapabilityMode(managed.session.provider);
From cba27202dd79242b59889de4fc022b1716b0f5f2 Mon Sep 17 00:00:00 2001
From: Arul Sharma <31745423+arul28@users.noreply.github.com>
Date: Thu, 23 Apr 2026 15:56:08 -0400
Subject: [PATCH 5/5] =?UTF-8?q?ship:=20iteration=203=20=E2=80=94=20address?=
=?UTF-8?q?=20copilot=20follow-ups?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/main/services/chat/identitySessionPolicy.test.ts | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts b/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts
index b594c504b..a7f79bc7c 100644
--- a/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts
+++ b/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts
@@ -45,4 +45,10 @@ describe("identitySessionPolicy", () => {
expect(resolveIdentityExecutionLane("cto", null, "lane-primary")).toBe("lane-primary");
expect(resolveIdentityExecutionLane("cto", "lane-feature", null)).toBe(null);
});
+
+ it("passes through requested lanes for non-pinned identities", () => {
+ expect(resolveIdentityExecutionLane("assistant" as never, "lane-feature", "lane-primary")).toBe("lane-feature");
+ expect(resolveIdentityExecutionLane("assistant" as never, " lane-feature ", "lane-primary")).toBe("lane-feature");
+ expect(resolveIdentityExecutionLane("assistant" as never, " ", "lane-primary")).toBe(null);
+ });
});