Skip to content

Commit 8e8f9ec

Browse files
committed
feat: Support ACP additional directories
1 parent 196152b commit 8e8f9ec

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 & {
@@ -732,10 +756,92 @@ function readAdditionalRoots(meta: Record<string, unknown> | null | undefined):
732756
return [];
733757
}
734758

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

741847
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
@@ -78,6 +78,7 @@ export interface SessionState {
7878
rateLimits: RateLimitsMap | null;
7979
account: Account | null;
8080
cwd: string;
81+
additionalDirectories: string[];
8182
fastModeEnabled: boolean;
8283
currentModelSupportsFast: boolean;
8384
sessionMcpServers?: Array<string>;
@@ -178,6 +179,7 @@ export class CodexAcpServer implements acp.Agent {
178179
list: { },
179180
close: { },
180181
delete: { },
182+
additionalDirectories: {},
181183
},
182184
mcpCapabilities: {
183185
acp: false,
@@ -366,6 +368,7 @@ export class CodexAcpServer implements acp.Agent {
366368
rateLimits: null,
367369
account: account,
368370
cwd: request.cwd,
371+
additionalDirectories: sessionMetadata.additionalDirectories,
369372
fastModeEnabled: sessionMetadata.currentServiceTier === "fast",
370373
currentModelSupportsFast: currentModelSupportsFast,
371374
sessionMcpServers: sessionMcpServers,
@@ -439,7 +442,20 @@ export class CodexAcpServer implements acp.Agent {
439442
async listSessions(params: acp.ListSessionsRequest): Promise<acp.ListSessionsResponse> {
440443
logger.log("Listing sessions...", {cwd: params.cwd, cursor: params.cursor});
441444
await this.checkAuthorization();
442-
return await this.runWithProcessCheck(() => this.codexAcpClient.listSessions(params));
445+
const response = await this.runWithProcessCheck(() => this.codexAcpClient.listSessions(params));
446+
return {
447+
...response,
448+
sessions: response.sessions.map((session) => {
449+
const activeSession = this.sessions.get(session.sessionId);
450+
if (!activeSession || activeSession.additionalDirectories.length === 0) {
451+
return session;
452+
}
453+
return {
454+
...session,
455+
additionalDirectories: activeSession.additionalDirectories,
456+
};
457+
}),
458+
};
443459
}
444460

445461
async closeSession(params: acp.CloseSessionRequest): Promise<acp.CloseSessionResponse> {
@@ -803,6 +819,7 @@ export class CodexAcpServer implements acp.Agent {
803819
rateLimits: null,
804820
account: account,
805821
cwd: request.cwd,
822+
additionalDirectories: sessionMetadata.additionalDirectories,
806823
fastModeEnabled: sessionMetadata.currentServiceTier === "fast",
807824
currentModelSupportsFast: currentModelSupportsFast,
808825
sessionMcpServers: sessionMcpServers,
@@ -1327,6 +1344,7 @@ export class CodexAcpServer implements acp.Agent {
13271344
serviceTier,
13281345
disableSummary,
13291346
sessionState.cwd,
1347+
sessionState.additionalDirectories,
13301348
(turnId) => {
13311349
if (this.promptIsClosedOrStale(params.sessionId, activePrompt)) {
13321350
this.interruptLateStartedTurn(params.sessionId, turnId);

0 commit comments

Comments
 (0)