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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/code/src/main/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { DatabaseService } from "../db/service";
import { AgentService } from "../services/agent/service";
import { AppLifecycleService } from "../services/app-lifecycle/service";
import { ArchiveService } from "../services/archive/service";
import { AuthProxyService } from "../services/auth-proxy/service";
import { CloudTaskService } from "../services/cloud-task/service";
import { ConnectivityService } from "../services/connectivity/service";
import { ContextMenuService } from "../services/context-menu/service";
Expand Down Expand Up @@ -50,6 +51,7 @@ container.bind(MAIN_TOKENS.WorktreeRepository).to(WorktreeRepository);
container.bind(MAIN_TOKENS.ArchiveRepository).to(ArchiveRepository);
container.bind(MAIN_TOKENS.SuspensionRepository).to(SuspensionRepositoryImpl);
container.bind(MAIN_TOKENS.AgentService).to(AgentService);
container.bind(MAIN_TOKENS.AuthProxyService).to(AuthProxyService);
container.bind(MAIN_TOKENS.ArchiveService).to(ArchiveService);
container.bind(MAIN_TOKENS.SuspensionService).to(SuspensionService);
container.bind(MAIN_TOKENS.AppLifecycleService).to(AppLifecycleService);
Expand Down
1 change: 1 addition & 0 deletions apps/code/src/main/di/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const MAIN_TOKENS = Object.freeze({

// Services
AgentService: Symbol.for("Main.AgentService"),
AuthProxyService: Symbol.for("Main.AuthProxyService"),
ArchiveService: Symbol.for("Main.ArchiveService"),
SuspensionService: Symbol.for("Main.SuspensionService"),
AppLifecycleService: Symbol.for("Main.AppLifecycleService"),
Expand Down
14 changes: 0 additions & 14 deletions apps/code/src/main/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
} from "electron";
import { container } from "./di/container";
import { MAIN_TOKENS } from "./di/tokens";
import type { AgentService } from "./services/agent/service";
import type { UIService } from "./services/ui/service";
import type { UpdatesService } from "./services/updates/service";
import { isDevBuild } from "./utils/env";
Expand Down Expand Up @@ -130,19 +129,6 @@ function buildFileMenu(): MenuItemConstructorOptions {
container.get<UIService>(MAIN_TOKENS.UIService).invalidateToken();
},
},
{
label: "Mark all agent sessions for recreation",
click: () => {
const count = container
.get<AgentService>(MAIN_TOKENS.AgentService)
.markAllSessionsForRecreation();
dialog.showMessageBox({
type: "info",
title: "Sessions Marked",
message: `Marked ${count} session(s) for recreation.\n\nThey will be recreated on the next prompt.`,
});
},
},
{ type: "separator" },
{
label: "Clear application storage",
Expand Down
9 changes: 8 additions & 1 deletion apps/code/src/main/services/agent/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,13 @@ function createMockDependencies() {
posthogPluginService: {
getPluginPath: vi.fn(() => "/mock/plugin"),
},
authProxy: {
start: vi.fn().mockResolvedValue("http://127.0.0.1:9999"),
stop: vi.fn().mockResolvedValue(undefined),
updateToken: vi.fn(),
getProxyUrl: vi.fn(() => "http://127.0.0.1:9999"),
isRunning: vi.fn(() => false),
},
};
}

Expand Down Expand Up @@ -182,6 +189,7 @@ describe("AgentService", () => {
deps.sleepService as never,
deps.fsService as never,
deps.posthogPluginService as never,
deps.authProxy as never,
);
});

Expand Down Expand Up @@ -304,7 +312,6 @@ describe("AgentService", () => {
createdAt: Date.now(),
lastActivityAt: Date.now(),
config: {},
needsRecreation: false,
promptPending: false,
...overrides,
});
Expand Down
155 changes: 31 additions & 124 deletions apps/code/src/main/services/agent/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { MAIN_TOKENS } from "../../di/tokens";
import { isDevBuild } from "../../utils/env";
import { logger } from "../../utils/logger";
import { TypedEventEmitter } from "../../utils/typed-event-emitter";
import type { AuthProxyService } from "../auth-proxy/service";
import type { FsService } from "../fs/service";
import type { PosthogPluginService } from "../posthog-plugin/service";
import type { ProcessTrackingService } from "../process-tracking/service";
Expand Down Expand Up @@ -58,21 +59,6 @@ function getMockNodeDir(): string {
}

/** Mark all content blocks as hidden so the renderer doesn't show a duplicate user message on retry */
function hidePromptBlocks(prompt: ContentBlock[]): ContentBlock[] {
return prompt.map((block) => {
const existing = (
block as ContentBlock & { _meta?: { ui?: Record<string, unknown> } }
)._meta;
return {
...block,
_meta: {
...existing,
ui: { ...existing?.ui, hidden: true },
},
};
});
}

type MessageCallback = (message: unknown) => void;

class NdJsonTap {
Expand Down Expand Up @@ -216,8 +202,6 @@ interface ManagedSession {
lastActivityAt: number;
config: SessionConfig;
interruptReason?: InterruptReason;
needsRecreation: boolean;
recreationPromise?: Promise<ManagedSession>;
promptPending: boolean;
pendingContext?: string;
configOptions?: SessionConfigOption[];
Expand Down Expand Up @@ -269,6 +253,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
private sleepService: SleepService;
private fsService: FsService;
private posthogPluginService: PosthogPluginService;
private authProxy: AuthProxyService;

constructor(
@inject(MAIN_TOKENS.ProcessTrackingService)
Expand All @@ -279,45 +264,36 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
fsService: FsService,
@inject(MAIN_TOKENS.PosthogPluginService)
posthogPluginService: PosthogPluginService,
@inject(MAIN_TOKENS.AuthProxyService)
authProxy: AuthProxyService,
) {
super();
this.processTracking = processTracking;
this.sleepService = sleepService;
this.fsService = fsService;
this.posthogPluginService = posthogPluginService;
this.authProxy = authProxy;

powerMonitor.on("resume", () => this.checkIdleDeadlines());
}

public updateToken(newToken: string): void {
this.currentToken = newToken;

// Mark all sessions for recreation - they'll be recreated before the next prompt.
// We don't recreate immediately because the subprocess may be mid-response or
// waiting on a permission prompt. Recreation happens at a safe point.
for (const session of this.sessions.values()) {
session.needsRecreation = true;
if (this.authProxy.isRunning()) {
this.authProxy.updateToken(newToken);
}

log.info("Token updated, marked sessions for recreation", {
sessionCount: this.sessions.size,
});
}
process.env.ANTHROPIC_API_KEY = newToken;
process.env.ANTHROPIC_AUTH_TOKEN = newToken;
process.env.OPENAI_API_KEY = newToken;
process.env.POSTHOG_API_KEY = newToken;
process.env.POSTHOG_AUTH_HEADER = `Bearer ${newToken}`;

/**
* Mark all sessions for recreation (developer tool for testing token refresh).
* Sessions will be recreated before their next prompt.
*/
public markAllSessionsForRecreation(): number {
let count = 0;
for (const session of this.sessions.values()) {
session.needsRecreation = true;
count++;
}
log.info("Marked all sessions for recreation (dev tool)", {
sessionCount: count,
log.info("Token updated (proxy + env vars)", {
sessionCount: this.sessions.size,
proxyRunning: this.authProxy.isRunning(),
});
return count;
}

/**
Expand Down Expand Up @@ -656,9 +632,9 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {

const channel = `agent-event:${taskRunId}`;
const mockNodeDir = this.setupMockNodeEnvironment();
this.setupEnvironment(credentials, mockNodeDir);
const proxyUrl = await this.ensureAuthProxy(credentials);
this.setupEnvironment(credentials, mockNodeDir, proxyUrl);

// Preview sessions don't persist logs — no real task exists
const isPreview = taskId === "__preview__";

const agent = new Agent({
Expand All @@ -677,6 +653,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
try {
const acpConnection = await agent.run(taskId, taskRunId, {
adapter,
gatewayUrl: proxyUrl,
codexBinaryPath: adapter === "codex" ? getCodexBinaryPath() : undefined,
processCallbacks: {
onProcessSpawned: (info) => {
Expand Down Expand Up @@ -838,7 +815,6 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
createdAt: Date.now(),
lastActivityAt: Date.now(),
config,
needsRecreation: false,
promptPending: false,
configOptions,
};
Expand Down Expand Up @@ -884,76 +860,15 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
}
}

private async recreateSession(taskRunId: string): Promise<ManagedSession> {
const existing = this.sessions.get(taskRunId);
if (!existing) {
throw new Error(`Session not found for recreation: ${taskRunId}`);
}

log.info("Recreating session", { taskRunId });

// Preserve state that should survive recreation
const config = existing.config;
const pendingContext = existing.pendingContext;
const configOptions = existing.configOptions;

await this.cleanupSession(taskRunId);

const newSession = await this.getOrCreateSession(config, true);
if (!newSession) {
throw new Error(`Failed to recreate session: ${taskRunId}`);
}

if (pendingContext) {
newSession.pendingContext = pendingContext;
}

if (configOptions) {
await Promise.all(
configOptions.map((opt) =>
this.setSessionConfigOption(
taskRunId,
opt.id,
opt.currentValue,
).catch((err) => {
log.warn("Failed to restore config option during recreation", {
taskRunId,
configId: opt.id,
err,
});
}),
),
);
}

return newSession;
}

async prompt(
sessionId: string,
prompt: ContentBlock[],
): Promise<PromptOutput> {
let session = this.sessions.get(sessionId);
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Session not found: ${sessionId}`);
}

// Recreate session if marked (token was refreshed while session was active)
if (session.needsRecreation) {
if (!session.recreationPromise) {
log.info("Recreating session before prompt (token refreshed)", {
sessionId,
});
session.recreationPromise = this.recreateSession(sessionId).finally(
() => {
const s = this.sessions.get(sessionId);
if (s) s.recreationPromise = undefined;
},
);
}
session = await session.recreationPromise;
}

// Prepend pending context if present
let finalPrompt = prompt;
if (session.pendingContext) {
Expand Down Expand Up @@ -991,20 +906,6 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
stopReason: result.stopReason,
_meta: result._meta as PromptOutput["_meta"],
};
} catch (err) {
if (isAuthError(err)) {
log.warn("Auth error during prompt, recreating session", { sessionId });
session = await this.recreateSession(sessionId);
const result = await session.clientSideConnection.prompt({
sessionId: getAgentSessionId(session),
prompt: hidePromptBlocks(finalPrompt),
});
return {
stopReason: result.stopReason,
_meta: result._meta as PromptOutput["_meta"],
};
}
throw err;
} finally {
session.promptPending = false;
session.lastActivityAt = Date.now();
Expand Down Expand Up @@ -1222,9 +1123,16 @@ For git operations while detached:
log.info("All agent sessions cleaned up");
}

private async ensureAuthProxy(credentials: Credentials): Promise<string> {
const token = this.getToken(credentials.apiKey);
const llmGatewayUrl = getLlmGatewayUrl(credentials.apiHost);
return this.authProxy.start(llmGatewayUrl, token);
}

private setupEnvironment(
credentials: Credentials,
mockNodeDir: string,
proxyUrl: string,
): void {
const token = this.getToken(credentials.apiKey);
const currentPath = process.env.PATH || "";
Expand All @@ -1235,15 +1143,14 @@ For git operations while detached:
process.env.ANTHROPIC_API_KEY = token;
process.env.ANTHROPIC_AUTH_TOKEN = token;

const llmGatewayUrl = getLlmGatewayUrl(credentials.apiHost);
process.env.ANTHROPIC_BASE_URL = llmGatewayUrl;
process.env.ANTHROPIC_BASE_URL = proxyUrl;

const openaiBaseUrl = llmGatewayUrl.endsWith("/v1")
? llmGatewayUrl
: `${llmGatewayUrl}/v1`;
const openaiBaseUrl = proxyUrl.endsWith("/v1")
? proxyUrl
: `${proxyUrl}/v1`;
process.env.OPENAI_BASE_URL = openaiBaseUrl;
process.env.OPENAI_API_KEY = token;
process.env.LLM_GATEWAY_URL = llmGatewayUrl;
process.env.LLM_GATEWAY_URL = proxyUrl;

process.env.CLAUDE_CODE_EXECUTABLE = getClaudeCliPath();

Expand Down
Loading
Loading