Skip to content

Commit 466cc8f

Browse files
committed
feat: Support ACP additional directories
1 parent 90b5164 commit 466cc8f

10 files changed

Lines changed: 384 additions & 23 deletions

src/CodexAcpClient.ts

Lines changed: 128 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import type {
3131
ReviewTarget,
3232
SkillsListParams,
3333
SkillsListResponse,
34+
SandboxPolicy,
3435
Thread,
3536
ThreadSourceKind,
3637
TurnCompletedNotification,
@@ -51,6 +52,7 @@ export class CodexAcpClient {
5152
private pendingLoginCompleted: Promise<AccountLoginCompletedNotification> | null = null;
5253
private pendingAccountUpdated: Promise<AccountUpdatedNotification> | null = null;
5354
private readonly sessionNotificationQueues = new Map<string, Promise<void>>();
55+
private skillExtraRoots: string[] = [];
5456

5557

5658
constructor(codexClient: CodexAppServerClient, codexConfig?: JsonObject, modelProvider?: string) {
@@ -203,10 +205,11 @@ export class CodexAcpClient {
203205
}
204206

205207
async resumeSession(request: acp.ResumeSessionRequest, onSubscribed?: () => void): Promise<SessionMetadata> {
206-
await this.refreshSkills(request.cwd, request._meta);
208+
const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories);
209+
await this.refreshSkills(request.cwd, additionalDirectories, request._meta);
207210

208211
const response = await this.codexClient.threadResume({
209-
config: await this.createSessionConfig(request.cwd, request.mcpServers ?? []),
212+
config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []),
210213
cwd: request.cwd,
211214
modelProvider: this.getResumeModelProvider(),
212215
threadId: request.sessionId,
@@ -219,12 +222,16 @@ export class CodexAcpClient {
219222
currentModelId: currentModelId,
220223
models: codexModels,
221224
currentServiceTier: response.serviceTier as ServiceTier ?? null,
225+
additionalDirectories,
222226
}
223227
}
224228

225229
async loadSession(request: acp.LoadSessionRequest, onSubscribed?: () => void): Promise<SessionMetadataWithThread> {
230+
const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories);
231+
await this.refreshSkills(request.cwd, additionalDirectories, request._meta);
232+
226233
const response = await this.codexClient.threadResume({
227-
config: await this.createSessionConfig(request.cwd, request.mcpServers ?? []),
234+
config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []),
228235
cwd: request.cwd,
229236
modelProvider: this.getResumeModelProvider(),
230237
threadId: request.sessionId,
@@ -238,14 +245,16 @@ export class CodexAcpClient {
238245
models: codexModels,
239246
currentServiceTier: response.serviceTier as ServiceTier ?? null,
240247
thread: response.thread,
248+
additionalDirectories,
241249
};
242250
}
243251

244252
async newSession(request: acp.NewSessionRequest): Promise<SessionMetadata> {
245-
await this.refreshSkills(request.cwd, request._meta);
253+
const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories);
254+
await this.refreshSkills(request.cwd, additionalDirectories, request._meta);
246255

247256
const response = await this.codexClient.threadStart({
248-
config: await this.createSessionConfig(request.cwd, request.mcpServers),
257+
config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers),
249258
modelProvider: this.getModelProvider(),
250259
cwd: request.cwd,
251260
});
@@ -260,6 +269,7 @@ export class CodexAcpClient {
260269
currentModelId: currentModelId,
261270
models: codexModels,
262271
currentServiceTier: response.serviceTier as ServiceTier ?? null,
272+
additionalDirectories,
263273
};
264274
}
265275

@@ -299,17 +309,21 @@ export class CodexAcpClient {
299309
return this.codexClient.getMcpServerStartupVersion();
300310
}
301311

302-
private async createSessionConfig(projectPath: string, mcpServers: Array<McpServer>): Promise<JsonObject> {
312+
private async createSessionConfig(
313+
projectPath: string,
314+
additionalDirectories: string[],
315+
mcpServers: Array<McpServer>
316+
): Promise<JsonObject> {
317+
const sessionRoots = [projectPath, ...additionalDirectories];
303318
const mergedConfig = {
304319
...mergeGatewayConfig(this.config, this.gatewayConfig),
305-
projects: {
306-
[projectPath]: {
307-
trust_level: "trusted",
308-
}
309-
},
320+
projects: Object.fromEntries(sessionRoots.map(root => [root, {
321+
trust_level: "trusted",
322+
}])),
310323
};
324+
const configWithWorkspaceRoots = mergeSandboxWorkspaceWriteRoots(mergedConfig, additionalDirectories);
311325
if (mcpServers.length === 0) {
312-
return mergedConfig;
326+
return configWithWorkspaceRoots;
313327
}
314328

315329
// Deduplicates new servers against existing config to prevent Codex from deep-merging
@@ -321,11 +335,11 @@ export class CodexAcpClient {
321335
}));
322336
const uniqueServers = requestedServers.filter(mcp => !existingNames.has(mcp.name));
323337
if (uniqueServers.length === 0) {
324-
return mergedConfig;
338+
return configWithWorkspaceRoots;
325339
}
326340

327341
return {
328-
...mergedConfig,
342+
...configWithWorkspaceRoots,
329343
"mcp_servers": Object.fromEntries(uniqueServers.map(mcp => [mcp.name, this.createMcpSeverConfig(mcp.server)])),
330344
};
331345
}
@@ -349,17 +363,25 @@ export class CodexAcpClient {
349363
return this.getModelProvider() ?? "openai";
350364
}
351365

352-
private async refreshSkills(cwd: string, meta?: Record<string, unknown> | null): Promise<void> {
366+
private async refreshSkills(
367+
cwd: string,
368+
additionalDirectories: string[],
369+
meta?: Record<string, unknown> | null
370+
): Promise<void> {
353371
if (!cwd) {
354372
return;
355373
}
356374

357-
const additionalRoots = readAdditionalRoots(meta).map(root => path.join(root, ".agents", "skills"));
358-
if (additionalRoots.length > 0) {
375+
const additionalRoots = uniqueStrings([
376+
...readAdditionalRoots(meta),
377+
...additionalDirectories,
378+
]).map(root => path.join(root, ".agents", "skills"));
379+
if (!arraysEqual(this.skillExtraRoots, additionalRoots)) {
359380
await this.codexClient.skillsExtraRootsSet({ extraRoots: additionalRoots });
381+
this.skillExtraRoots = additionalRoots;
360382
}
361383
await this.codexClient.listSkills({
362-
cwds: [cwd],
384+
cwds: [cwd, ...additionalDirectories],
363385
forceReload: true,
364386
});
365387
}
@@ -465,21 +487,22 @@ export class CodexAcpClient {
465487
serviceTier: ServiceTier | null,
466488
disableSummary: boolean,
467489
cwd: string,
490+
additionalDirectories: string[],
468491
onTurnStarted?: (turnId: string) => void,
469492
shouldCancel?: () => boolean,
470493
): Promise<TurnCompletedNotification | null> {
471494
const input = buildPromptItems(request.prompt);
472495
const effort = modelId.effort as ReasoningEffort | null; //TODO remove unsafe conversion
473496

474-
await this.refreshSkills(cwd, request._meta);
497+
await this.refreshSkills(cwd, additionalDirectories, request._meta);
475498
if (shouldCancel?.()) {
476499
return null;
477500
}
478501
return await this.codexClient.runTurn({
479502
threadId: request.sessionId,
480503
input: input,
481504
approvalPolicy: agentMode.approvalPolicy,
482-
sandboxPolicy: agentMode.sandboxPolicy,
505+
sandboxPolicy: addAdditionalDirectoriesToSandboxPolicy(agentMode.sandboxPolicy, additionalDirectories),
483506
summary: disableSummary ? "none" : "auto",
484507
effort: effort,
485508
model: modelId.model,
@@ -662,6 +685,7 @@ export type SessionMetadata = {
662685
currentModelId: string,
663686
models: Model[],
664687
currentServiceTier?: ServiceTier | null,
688+
additionalDirectories: string[],
665689
}
666690

667691
export type SessionMetadataWithThread = SessionMetadata & {
@@ -722,10 +746,92 @@ function readAdditionalRoots(meta: Record<string, unknown> | null | undefined):
722746
return [];
723747
}
724748

725-
return Array.from(new Set(rawRoots
749+
return uniqueStrings(rawRoots
726750
.filter((value): value is string => typeof value === "string")
727751
.map(value => value.trim())
728-
.filter(value => value.length > 0)));
752+
.filter(value => value.length > 0));
753+
}
754+
755+
function readAdditionalDirectories(cwd: string, rawDirectories: unknown): string[] {
756+
if (rawDirectories === undefined) {
757+
return [];
758+
}
759+
if (rawDirectories === null) {
760+
throw RequestError.invalidParams(undefined, "additionalDirectories must be an array");
761+
}
762+
if (!Array.isArray(rawDirectories)) {
763+
throw RequestError.invalidParams(undefined, "additionalDirectories must be an array");
764+
}
765+
766+
const directories: string[] = [];
767+
const seen = new Set<string>([cwd]);
768+
for (const directory of rawDirectories) {
769+
if (typeof directory !== "string") {
770+
throw RequestError.invalidParams(undefined, "additionalDirectories entries must be strings");
771+
}
772+
if (directory.length === 0) {
773+
throw RequestError.invalidParams(undefined, "additionalDirectories entries must not be empty");
774+
}
775+
if (!path.isAbsolute(directory)) {
776+
throw RequestError.invalidParams(undefined, "additionalDirectories entries must be absolute paths");
777+
}
778+
if (!seen.has(directory)) {
779+
seen.add(directory);
780+
directories.push(directory);
781+
}
782+
}
783+
784+
return directories;
785+
}
786+
787+
function mergeSandboxWorkspaceWriteRoots(config: JsonObject, roots: string[]): JsonObject {
788+
if (roots.length === 0) {
789+
return config;
790+
}
791+
792+
const existingSandboxConfig = isJsonObject(config["sandbox_workspace_write"])
793+
? config["sandbox_workspace_write"]
794+
: {};
795+
const existingWritableRoots = Array.isArray(existingSandboxConfig["writable_roots"])
796+
? existingSandboxConfig["writable_roots"].filter((value): value is string => typeof value === "string")
797+
: [];
798+
799+
return {
800+
...config,
801+
sandbox_workspace_write: {
802+
...existingSandboxConfig,
803+
writable_roots: uniqueStrings([...existingWritableRoots, ...roots]),
804+
},
805+
};
806+
}
807+
808+
function addAdditionalDirectoriesToSandboxPolicy(
809+
sandboxPolicy: SandboxPolicy,
810+
additionalDirectories: string[]
811+
): SandboxPolicy {
812+
if (additionalDirectories.length === 0 || sandboxPolicy.type !== "workspaceWrite") {
813+
return sandboxPolicy;
814+
}
815+
816+
return {
817+
...sandboxPolicy,
818+
writableRoots: uniqueStrings([...sandboxPolicy.writableRoots, ...additionalDirectories]),
819+
};
820+
}
821+
822+
function uniqueStrings(values: string[]): string[] {
823+
return Array.from(new Set(values));
824+
}
825+
826+
function arraysEqual(left: string[], right: string[]): boolean {
827+
if (left.length !== right.length) {
828+
return false;
829+
}
830+
return left.every((value, index) => value === right[index]);
831+
}
832+
833+
function isJsonObject(value: JsonValue | undefined): value is JsonObject {
834+
return value !== null && typeof value === "object" && !Array.isArray(value);
729835
}
730836

731837
function mergeGatewayConfig(config: JsonObject, gatewayConfig: GatewayConfig | null): JsonObject {

src/CodexAcpServer.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export interface SessionState {
6868
rateLimits: RateLimitsMap | null;
6969
account: Account | null;
7070
cwd: string;
71+
additionalDirectories: string[];
7172
fastModeEnabled: boolean;
7273
currentModelSupportsFast: boolean;
7374
sessionMcpServers?: Array<string>;
@@ -164,6 +165,7 @@ export class CodexAcpServer implements acp.Agent {
164165
list: { },
165166
close: { },
166167
delete: { },
168+
additionalDirectories: {},
167169
},
168170
mcpCapabilities: {
169171
acp: false,
@@ -350,6 +352,7 @@ export class CodexAcpServer implements acp.Agent {
350352
rateLimits: null,
351353
account: account,
352354
cwd: request.cwd,
355+
additionalDirectories: sessionMetadata.additionalDirectories,
353356
fastModeEnabled: sessionMetadata.currentServiceTier === "fast",
354357
currentModelSupportsFast: currentModelSupportsFast,
355358
sessionMcpServers: sessionMcpServers,
@@ -422,7 +425,20 @@ export class CodexAcpServer implements acp.Agent {
422425
async listSessions(params: acp.ListSessionsRequest): Promise<acp.ListSessionsResponse> {
423426
logger.log("Listing sessions...", {cwd: params.cwd, cursor: params.cursor});
424427
await this.checkAuthorization();
425-
return await this.runWithProcessCheck(() => this.codexAcpClient.listSessions(params));
428+
const response = await this.runWithProcessCheck(() => this.codexAcpClient.listSessions(params));
429+
return {
430+
...response,
431+
sessions: response.sessions.map((session) => {
432+
const activeSession = this.sessions.get(session.sessionId);
433+
if (!activeSession || activeSession.additionalDirectories.length === 0) {
434+
return session;
435+
}
436+
return {
437+
...session,
438+
additionalDirectories: activeSession.additionalDirectories,
439+
};
440+
}),
441+
};
426442
}
427443

428444
async closeSession(params: acp.CloseSessionRequest): Promise<acp.CloseSessionResponse> {
@@ -774,6 +790,7 @@ export class CodexAcpServer implements acp.Agent {
774790
rateLimits: null,
775791
account: account,
776792
cwd: request.cwd,
793+
additionalDirectories: sessionMetadata.additionalDirectories,
777794
fastModeEnabled: sessionMetadata.currentServiceTier === "fast",
778795
currentModelSupportsFast: currentModelSupportsFast,
779796
sessionMcpServers: sessionMcpServers,
@@ -1296,6 +1313,7 @@ export class CodexAcpServer implements acp.Agent {
12961313
serviceTier,
12971314
disableSummary,
12981315
sessionState.cwd,
1316+
sessionState.additionalDirectories,
12991317
(turnId) => {
13001318
if (this.promptIsClosedOrStale(params.sessionId, activePrompt)) {
13011319
this.interruptLateStartedTurn(params.sessionId, turnId);

0 commit comments

Comments
 (0)