Skip to content

Commit 27fb950

Browse files
authored
feat: Support ACP additional directories (#191)
* feat: Support ACP additional directories * Fix test * Prioritize the spec ones over the meta ones * cleanup tests
1 parent 196152b commit 27fb950

11 files changed

Lines changed: 379 additions & 32 deletions

src/CodexAcpClient.ts

Lines changed: 122 additions & 26 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, request._meta);
209+
await this.refreshSkills(request.cwd, additionalDirectories);
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, request._meta);
231+
await this.refreshSkills(request.cwd, additionalDirectories);
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, request._meta);
254+
await this.refreshSkills(request.cwd, additionalDirectories);
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,21 @@ 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+
additionalRoots: string[]
369+
): Promise<void> {
353370
if (!cwd) {
354371
return;
355372
}
356373

357-
const additionalRoots = readAdditionalRoots(meta).map(root => path.join(root, ".agents", "skills"));
358-
if (additionalRoots.length > 0) {
359-
await this.codexClient.skillsExtraRootsSet({ extraRoots: additionalRoots });
374+
const skillExtraRoots = additionalRoots.map(root => path.join(root, ".agents", "skills"));
375+
if (!arraysEqual(this.skillExtraRoots, skillExtraRoots)) {
376+
await this.codexClient.skillsExtraRootsSet({ extraRoots: skillExtraRoots });
377+
this.skillExtraRoots = skillExtraRoots;
360378
}
361379
await this.codexClient.listSkills({
362-
cwds: [cwd],
380+
cwds: [cwd, ...additionalRoots],
363381
forceReload: true,
364382
});
365383
}
@@ -465,21 +483,21 @@ export class CodexAcpClient {
465483
serviceTier: ServiceTier | null,
466484
disableSummary: boolean,
467485
cwd: string,
486+
additionalDirectories: string[],
468487
onTurnStarted?: (turnId: string) => void,
469488
shouldCancel?: () => boolean,
470489
): Promise<TurnCompletedNotification | null> {
471490
const input = buildPromptItems(request.prompt);
472491
const effort = modelId.effort as ReasoningEffort | null; //TODO remove unsafe conversion
473-
474-
await this.refreshSkills(cwd, request._meta);
492+
await this.refreshSkills(cwd, additionalDirectories);
475493
if (shouldCancel?.()) {
476494
return null;
477495
}
478496
return await this.codexClient.runTurn({
479497
threadId: request.sessionId,
480498
input: input,
481499
approvalPolicy: agentMode.approvalPolicy,
482-
sandboxPolicy: agentMode.sandboxPolicy,
500+
sandboxPolicy: addAdditionalDirectoriesToSandboxPolicy(agentMode.sandboxPolicy, additionalDirectories),
483501
summary: disableSummary ? "none" : "auto",
484502
effort: effort,
485503
model: modelId.model,
@@ -662,6 +680,7 @@ export type SessionMetadata = {
662680
currentModelId: string,
663681
models: Model[],
664682
currentServiceTier?: ServiceTier | null,
683+
additionalDirectories: string[],
665684
}
666685

667686
export type SessionMetadataWithThread = SessionMetadata & {
@@ -726,16 +745,93 @@ interface GatewayConfig {
726745
}
727746
}
728747

729-
function readAdditionalRoots(meta: Record<string, unknown> | null | undefined): string[] {
748+
function readMetaAdditionalRoots(meta?: Record<string, unknown> | null): string[] | undefined {
730749
const rawRoots = meta?.["additionalRoots"];
731750
if (!Array.isArray(rawRoots)) {
732-
return [];
751+
return undefined;
733752
}
734753

735-
return Array.from(new Set(rawRoots
754+
return uniqueStrings(rawRoots
736755
.filter((value): value is string => typeof value === "string")
737756
.map(value => value.trim())
738-
.filter(value => value.length > 0)));
757+
.filter(value => value.length > 0));
758+
}
759+
760+
function readAdditionalDirectories(cwd: string, additionalDirectories?: string[], meta?: Record<string, unknown> | null): string[] {
761+
const rawDirectories = additionalDirectories ?? readMetaAdditionalRoots(meta);
762+
if (!rawDirectories) {
763+
return [];
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);
739835
}
740836

741837
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)