diff --git a/apps/cli/src/lib/history-session-catalog-client.ts b/apps/cli/src/lib/history-session-catalog-client.ts index 2ae409bbe..476f6a466 100644 --- a/apps/cli/src/lib/history-session-catalog-client.ts +++ b/apps/cli/src/lib/history-session-catalog-client.ts @@ -24,6 +24,8 @@ import { type ACPSessionId, getLocalProjectHistoryProviderKey, type LocalProjectHistoryProvider, + type CustomAcpLaunchSpec, + type BuiltinRuntimeOverrides, } from '@lody/shared'; import { LODY_EXTENSION_METHODS } from 'acp-extension-core'; @@ -126,19 +128,33 @@ type ResolvedHistoryACPProcessLaunch = Omit & { env: NodeJS.ProcessEnv; }; +export type HistoryLaunchProvider = LocalProjectHistoryProvider & { + customAcp?: CustomAcpLaunchSpec; + runtimeOverrides?: BuiltinRuntimeOverrides; + env?: NodeJS.ProcessEnv; +}; + export async function resolveHistoryACPProcessLaunch(args: { - provider: LocalProjectHistoryProvider; + provider: HistoryLaunchProvider; env?: NodeJS.ProcessEnv; }): Promise { - const launch = await resolveACPProcessLaunchAsync(args.provider); + const launch = await resolveACPProcessLaunchAsync({ + cliType: args.provider.cliType, + agentType: args.provider.agentType, + customAcp: args.provider.customAcp, + runtimeOverrides: args.provider.runtimeOverrides, + }); return { ...launch, - env: mergeACPProcessEnv(launch, args.env ?? process.env), + env: mergeACPProcessEnv(launch, { + ...(args.env ?? process.env), + ...(args.provider.env ?? {}), + }), }; } async function createHistoryAcpConnection(args: { - provider: LocalProjectHistoryProvider; + provider: HistoryLaunchProvider; workdir: string; logger: Logger; }): Promise { @@ -327,7 +343,7 @@ export function dedupeHistorySessionsById(sessions: SessionInfo[]): SessionInfo[ } export async function listHistorySessionsForLocalProject(args: { - provider: LocalProjectHistoryProvider; + provider: HistoryLaunchProvider; rootPath: string; logger: Logger; requiredSessionIds?: readonly string[]; @@ -376,7 +392,7 @@ export async function listHistorySessionsForLocalProject(args: { } export async function loadHistorySessionReplay(args: { - provider: LocalProjectHistoryProvider; + provider: HistoryLaunchProvider; rootPath: string; acpSessionId: ACPSessionId; logger: Logger; diff --git a/apps/cli/src/lib/local-project-history-sync-service.ts b/apps/cli/src/lib/local-project-history-sync-service.ts index cb75527fc..3e6b573fd 100644 --- a/apps/cli/src/lib/local-project-history-sync-service.ts +++ b/apps/cli/src/lib/local-project-history-sync-service.ts @@ -35,8 +35,10 @@ import { import type { LoroDocumentManager, SessionDocument } from '@/lib/loro/doc'; import { readMachineLocalProjects, upsertMachineLocalProject } from '@/lib/local-project-meta'; +import { listMergedAgentConfigs } from '@/lib/agent-config-machine-flock'; import { listHistorySessionsForLocalProject, + type HistoryLaunchProvider, loadHistorySessionReplay, MAX_LOCAL_PROJECT_HISTORY_CATALOG_SESSIONS, } from './history-session-catalog-client'; @@ -546,6 +548,7 @@ function buildExternalHistoryMeta(args: { export class LocalProjectHistorySyncService { private readonly provider: LocalProjectHistoryProvider; private readonly providerKey: string; + private launchProvider: HistoryLaunchProvider; constructor( private readonly manager: LoroDocumentManager, @@ -559,6 +562,29 @@ export class LocalProjectHistorySyncService { ) { this.provider = provider; this.providerKey = getLocalProjectHistoryProviderKey(provider); + this.launchProvider = provider; + } + + private async resolveLaunchProvider(): Promise { + if (this.provider.cliType !== 'custom') { + return this.provider; + } + const configs = await listMergedAgentConfigs(this.manager.repo, this.context.workspaceId, [ + this.context.machineId, + ]); + const config = configs.find( + (candidate) => + candidate.machineId === this.context.machineId && + candidate.cliType === this.provider.cliType && + candidate.agentType === this.provider.agentType + ); + const resolved = { + ...this.provider, + customAcp: config?.customAcp, + env: config?.env, + }; + this.launchProvider = resolved; + return resolved; } async syncLocalProject(args: { @@ -585,7 +611,8 @@ export class LocalProjectHistorySyncService { localProjectId: LocalProjectId; rootPath: string; }): Promise { - const snapshot = await this.listCatalogSnapshot(args); + this.launchProvider = await this.resolveLaunchProvider(); + const snapshot = await this.listCatalogSnapshot(args, this.launchProvider); return await this.writeCatalogResult({ localProjectId: args.localProjectId, sessions: snapshot.sessions, @@ -644,6 +671,7 @@ export class LocalProjectHistorySyncService { const summary = emptySummary(); const selectedIds = [...new Set(args.acpSessionIds)]; summary.listed = selectedIds.length; + this.launchProvider = await this.resolveLaunchProvider(); const snapshot = await this.listCatalogSnapshot({ ...args, requiredSessionIds: selectedIds, @@ -672,7 +700,7 @@ export class LocalProjectHistorySyncService { snapshot.existingByImportKey.get(importKey); if (!existing) { const replayNotifications = await loadHistorySessionReplay({ - provider: this.provider, + provider: this.launchProvider, rootPath: args.rootPath, acpSessionId, logger: this.logger, @@ -730,6 +758,7 @@ export class LocalProjectHistorySyncService { sessionId: SessionId; acpSessionId: string; }): Promise { + this.launchProvider = await this.resolveLaunchProvider(); const snapshot = await this.listCatalogSnapshot({ ...args, requiredSessionIds: [args.acpSessionId], @@ -812,7 +841,7 @@ export class LocalProjectHistorySyncService { const acpSessionId = args.acpSessionId as unknown as ACPSessionId; const replayNotifications = await loadHistorySessionReplay({ - provider: this.provider, + provider: this.launchProvider, rootPath: args.rootPath, acpSessionId, logger: this.logger, @@ -906,13 +935,16 @@ export class LocalProjectHistorySyncService { }); } - private async listCatalogSnapshot(args: { - localProjectId: LocalProjectId; - rootPath: string; - requiredSessionIds?: readonly string[]; - }): Promise { + private async listCatalogSnapshot( + args: { + localProjectId: LocalProjectId; + rootPath: string; + requiredSessionIds?: readonly string[]; + }, + provider: HistoryLaunchProvider = this.launchProvider + ): Promise { const catalog = await listHistorySessionsForLocalProject({ - provider: this.provider, + provider, rootPath: args.rootPath, logger: this.logger, requiredSessionIds: args.requiredSessionIds, @@ -1124,7 +1156,7 @@ export class LocalProjectHistorySyncService { } const replayNotifications = await loadHistorySessionReplay({ - provider: this.provider, + provider: this.launchProvider, rootPath: args.rootPath, acpSessionId: args.acpSessionId, logger: this.logger, diff --git a/apps/cli/tests/history-session-catalog-client.test.ts b/apps/cli/tests/history-session-catalog-client.test.ts index a03c29990..f7d4c4d14 100644 --- a/apps/cli/tests/history-session-catalog-client.test.ts +++ b/apps/cli/tests/history-session-catalog-client.test.ts @@ -217,6 +217,47 @@ describe('resolveHistoryACPProcessLaunch', () => { expect(historyLaunch.env.PATH).toBe('/usr/bin'); }); + it('resolves a custom ACP launch from the provider launch spec', async () => { + const historyLaunch = await resolveHistoryACPProcessLaunch({ + provider: { + cliType: 'custom', + agentType: 'my-agent', + customAcp: { + command: process.execPath, + args: ['custom-acp-server.mjs', '--stdio'], + }, + }, + env: { PATH: '/usr/bin' }, + }); + + expect(historyLaunch.command).toBe(process.execPath); + expect(historyLaunch.args).toEqual(['custom-acp-server.mjs', '--stdio']); + expect(historyLaunch.env.PATH).toBe('/usr/bin'); + }); + + it('merges the authoritative custom ACP environment before spawning history', async () => { + const historyLaunch = await resolveHistoryACPProcessLaunch({ + provider: { + cliType: 'custom', + agentType: 'my-agent', + customAcp: { command: process.execPath }, + env: { + ACP_API_KEY: 'config-secret', + ACP_BASE_URL: 'https://config.example.test', + PATH: '/config/bin', + }, + }, + env: { + ACP_API_KEY: 'caller-secret', + PATH: '/caller/bin', + }, + }); + + expect(historyLaunch.env.ACP_API_KEY).toBe('config-secret'); + expect(historyLaunch.env.ACP_BASE_URL).toBe('https://config.example.test'); + expect(historyLaunch.env.PATH).toBe('/config/bin'); + }); + it('uses the same registry Interactive Claude npx launch as normal sessions', async () => { const provider = { cliType: 'registry', agentType: 'claude-p' } as const; const sessionLaunch = resolveACPProcessLaunch(provider); diff --git a/packages/shared/src/node/local-project-control.ts b/packages/shared/src/node/local-project-control.ts index f103379ef..9eeda6deb 100644 --- a/packages/shared/src/node/local-project-control.ts +++ b/packages/shared/src/node/local-project-control.ts @@ -39,7 +39,7 @@ function isStringArray(value: unknown): value is string[] { function isLocalProjectHistoryProvider(value: unknown): boolean { return ( isObjectRecord(value) && - (value.cliType === 'builtin' || value.cliType === 'registry') && + (value.cliType === 'builtin' || value.cliType === 'registry' || value.cliType === 'custom') && typeof value.agentType === 'string' && value.agentType.trim().length > 0 ); diff --git a/packages/shared/tests/local-project-control.test.ts b/packages/shared/tests/local-project-control.test.ts index c58614b5d..4cf6c44a0 100644 --- a/packages/shared/tests/local-project-control.test.ts +++ b/packages/shared/tests/local-project-control.test.ts @@ -10,6 +10,10 @@ import { const codexProvider = { cliType: 'builtin', agentType: 'codex' } as const; const claudeProvider = { cliType: 'builtin', agentType: 'claude' } as const; +const customProvider = { + cliType: 'custom', + agentType: 'custom-agent', +} as const; describe('local project control request schema', () => { it('parses add request', () => { @@ -198,6 +202,28 @@ describe('local project control request schema', () => { }); }); + it('parses custom provider history sync requests', () => { + const parsed = safeParseLocalProjectControlRequest( + JSON.stringify({ + type: 'local-project/sync-history', + machineId: 'machine-1', + workspaceId: 'workspace-1', + localProjectId: 'project-1', + provider: customProvider, + }) + ); + + expect(parsed.success).toBe(true); + if (!parsed.success) { + return; + } + if (parsed.data.type !== 'local-project/sync-history') { + throw new Error(`Unexpected request type: ${parsed.data.type}`); + } + + expect(parsed.data.provider).toEqual(customProvider); + }); + it('parses Codex provider history import request with selected sessions', () => { const parsed = safeParseLocalProjectControlRequest( JSON.stringify({