Skip to content

Commit 15c84f3

Browse files
committed
feat: Support ACP additional directories
1 parent 1d76251 commit 15c84f3

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
@@ -30,6 +30,7 @@ import type {
3030
Model,
3131
SkillsListParams,
3232
SkillsListResponse,
33+
SandboxPolicy,
3334
Thread,
3435
ThreadSourceKind,
3536
TurnCompletedNotification,
@@ -50,6 +51,7 @@ export class CodexAcpClient {
5051
private pendingLoginCompleted: Promise<AccountLoginCompletedNotification> | null = null;
5152
private pendingAccountUpdated: Promise<AccountUpdatedNotification> | null = null;
5253
private readonly sessionNotificationQueues = new Map<string, Promise<void>>();
54+
private skillExtraRoots: string[] = [];
5355

5456

5557
constructor(codexClient: CodexAppServerClient, codexConfig?: JsonObject, modelProvider?: string) {
@@ -202,10 +204,11 @@ export class CodexAcpClient {
202204
}
203205

204206
async resumeSession(request: acp.ResumeSessionRequest, onSubscribed?: () => void): Promise<SessionMetadata> {
205-
await this.refreshSkills(request.cwd, request._meta);
207+
const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories);
208+
await this.refreshSkills(request.cwd, additionalDirectories, request._meta);
206209

207210
const response = await this.codexClient.threadResume({
208-
config: await this.createSessionConfig(request.cwd, request.mcpServers ?? []),
211+
config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []),
209212
cwd: request.cwd,
210213
modelProvider: this.getResumeModelProvider(),
211214
threadId: request.sessionId,
@@ -218,12 +221,16 @@ export class CodexAcpClient {
218221
currentModelId: currentModelId,
219222
models: codexModels,
220223
currentServiceTier: response.serviceTier as ServiceTier ?? null,
224+
additionalDirectories,
221225
}
222226
}
223227

224228
async loadSession(request: acp.LoadSessionRequest, onSubscribed?: () => void): Promise<SessionMetadataWithThread> {
229+
const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories);
230+
await this.refreshSkills(request.cwd, additionalDirectories, request._meta);
231+
225232
const response = await this.codexClient.threadResume({
226-
config: await this.createSessionConfig(request.cwd, request.mcpServers ?? []),
233+
config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []),
227234
cwd: request.cwd,
228235
modelProvider: this.getResumeModelProvider(),
229236
threadId: request.sessionId,
@@ -237,14 +244,16 @@ export class CodexAcpClient {
237244
models: codexModels,
238245
currentServiceTier: response.serviceTier as ServiceTier ?? null,
239246
thread: response.thread,
247+
additionalDirectories,
240248
};
241249
}
242250

243251
async newSession(request: acp.NewSessionRequest): Promise<SessionMetadata> {
244-
await this.refreshSkills(request.cwd, request._meta);
252+
const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories);
253+
await this.refreshSkills(request.cwd, additionalDirectories, request._meta);
245254

246255
const response = await this.codexClient.threadStart({
247-
config: await this.createSessionConfig(request.cwd, request.mcpServers),
256+
config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers),
248257
modelProvider: this.getModelProvider(),
249258
cwd: request.cwd,
250259
});
@@ -259,6 +268,7 @@ export class CodexAcpClient {
259268
currentModelId: currentModelId,
260269
models: codexModels,
261270
currentServiceTier: response.serviceTier as ServiceTier ?? null,
271+
additionalDirectories,
262272
};
263273
}
264274

@@ -278,17 +288,21 @@ export class CodexAcpClient {
278288
return this.codexClient.getMcpServerStartupVersion();
279289
}
280290

281-
private async createSessionConfig(projectPath: string, mcpServers: Array<McpServer>): Promise<JsonObject> {
291+
private async createSessionConfig(
292+
projectPath: string,
293+
additionalDirectories: string[],
294+
mcpServers: Array<McpServer>
295+
): Promise<JsonObject> {
296+
const sessionRoots = [projectPath, ...additionalDirectories];
282297
const mergedConfig = {
283298
...mergeGatewayConfig(this.config, this.gatewayConfig),
284-
projects: {
285-
[projectPath]: {
286-
trust_level: "trusted",
287-
}
288-
},
299+
projects: Object.fromEntries(sessionRoots.map(root => [root, {
300+
trust_level: "trusted",
301+
}])),
289302
};
303+
const configWithWorkspaceRoots = mergeSandboxWorkspaceWriteRoots(mergedConfig, additionalDirectories);
290304
if (mcpServers.length === 0) {
291-
return mergedConfig;
305+
return configWithWorkspaceRoots;
292306
}
293307

294308
// Deduplicates new servers against existing config to prevent Codex from deep-merging
@@ -300,11 +314,11 @@ export class CodexAcpClient {
300314
}));
301315
const uniqueServers = requestedServers.filter(mcp => !existingNames.has(mcp.name));
302316
if (uniqueServers.length === 0) {
303-
return mergedConfig;
317+
return configWithWorkspaceRoots;
304318
}
305319

306320
return {
307-
...mergedConfig,
321+
...configWithWorkspaceRoots,
308322
"mcp_servers": Object.fromEntries(uniqueServers.map(mcp => [mcp.name, this.createMcpSeverConfig(mcp.server)])),
309323
};
310324
}
@@ -328,17 +342,25 @@ export class CodexAcpClient {
328342
return this.getModelProvider() ?? "openai";
329343
}
330344

331-
private async refreshSkills(cwd: string, meta?: Record<string, unknown> | null): Promise<void> {
345+
private async refreshSkills(
346+
cwd: string,
347+
additionalDirectories: string[],
348+
meta?: Record<string, unknown> | null
349+
): Promise<void> {
332350
if (!cwd) {
333351
return;
334352
}
335353

336-
const additionalRoots = readAdditionalRoots(meta).map(root => path.join(root, ".agents", "skills"));
337-
if (additionalRoots.length > 0) {
354+
const additionalRoots = uniqueStrings([
355+
...readAdditionalRoots(meta),
356+
...additionalDirectories,
357+
]).map(root => path.join(root, ".agents", "skills"));
358+
if (!arraysEqual(this.skillExtraRoots, additionalRoots)) {
338359
await this.codexClient.skillsExtraRootsSet({ extraRoots: additionalRoots });
360+
this.skillExtraRoots = additionalRoots;
339361
}
340362
await this.codexClient.listSkills({
341-
cwds: [cwd],
363+
cwds: [cwd, ...additionalDirectories],
342364
forceReload: true,
343365
});
344366
}
@@ -444,21 +466,22 @@ export class CodexAcpClient {
444466
serviceTier: ServiceTier | null,
445467
disableSummary: boolean,
446468
cwd: string,
469+
additionalDirectories: string[],
447470
onTurnStarted?: (turnId: string) => void,
448471
shouldCancel?: () => boolean,
449472
): Promise<TurnCompletedNotification | null> {
450473
const input = buildPromptItems(request.prompt);
451474
const effort = modelId.effort as ReasoningEffort | null; //TODO remove unsafe conversion
452475

453-
await this.refreshSkills(cwd, request._meta);
476+
await this.refreshSkills(cwd, additionalDirectories, request._meta);
454477
if (shouldCancel?.()) {
455478
return null;
456479
}
457480
return await this.codexClient.runTurn({
458481
threadId: request.sessionId,
459482
input: input,
460483
approvalPolicy: agentMode.approvalPolicy,
461-
sandboxPolicy: agentMode.sandboxPolicy,
484+
sandboxPolicy: addAdditionalDirectoriesToSandboxPolicy(agentMode.sandboxPolicy, additionalDirectories),
462485
summary: disableSummary ? "none" : null,
463486
effort: effort,
464487
model: modelId.model,
@@ -641,6 +664,7 @@ export type SessionMetadata = {
641664
currentModelId: string,
642665
models: Model[],
643666
currentServiceTier?: ServiceTier | null,
667+
additionalDirectories: string[],
644668
}
645669

646670
export type SessionMetadataWithThread = SessionMetadata & {
@@ -701,10 +725,92 @@ function readAdditionalRoots(meta: Record<string, unknown> | null | undefined):
701725
return [];
702726
}
703727

704-
return Array.from(new Set(rawRoots
728+
return uniqueStrings(rawRoots
705729
.filter((value): value is string => typeof value === "string")
706730
.map(value => value.trim())
707-
.filter(value => value.length > 0)));
731+
.filter(value => value.length > 0));
732+
}
733+
734+
function readAdditionalDirectories(cwd: string, rawDirectories: unknown): string[] {
735+
if (rawDirectories === undefined) {
736+
return [];
737+
}
738+
if (rawDirectories === null) {
739+
throw RequestError.invalidParams(undefined, "additionalDirectories must be an array");
740+
}
741+
if (!Array.isArray(rawDirectories)) {
742+
throw RequestError.invalidParams(undefined, "additionalDirectories must be an array");
743+
}
744+
745+
const directories: string[] = [];
746+
const seen = new Set<string>([cwd]);
747+
for (const directory of rawDirectories) {
748+
if (typeof directory !== "string") {
749+
throw RequestError.invalidParams(undefined, "additionalDirectories entries must be strings");
750+
}
751+
if (directory.length === 0) {
752+
throw RequestError.invalidParams(undefined, "additionalDirectories entries must not be empty");
753+
}
754+
if (!path.isAbsolute(directory)) {
755+
throw RequestError.invalidParams(undefined, "additionalDirectories entries must be absolute paths");
756+
}
757+
if (!seen.has(directory)) {
758+
seen.add(directory);
759+
directories.push(directory);
760+
}
761+
}
762+
763+
return directories;
764+
}
765+
766+
function mergeSandboxWorkspaceWriteRoots(config: JsonObject, roots: string[]): JsonObject {
767+
if (roots.length === 0) {
768+
return config;
769+
}
770+
771+
const existingSandboxConfig = isJsonObject(config["sandbox_workspace_write"])
772+
? config["sandbox_workspace_write"]
773+
: {};
774+
const existingWritableRoots = Array.isArray(existingSandboxConfig["writable_roots"])
775+
? existingSandboxConfig["writable_roots"].filter((value): value is string => typeof value === "string")
776+
: [];
777+
778+
return {
779+
...config,
780+
sandbox_workspace_write: {
781+
...existingSandboxConfig,
782+
writable_roots: uniqueStrings([...existingWritableRoots, ...roots]),
783+
},
784+
};
785+
}
786+
787+
function addAdditionalDirectoriesToSandboxPolicy(
788+
sandboxPolicy: SandboxPolicy,
789+
additionalDirectories: string[]
790+
): SandboxPolicy {
791+
if (additionalDirectories.length === 0 || sandboxPolicy.type !== "workspaceWrite") {
792+
return sandboxPolicy;
793+
}
794+
795+
return {
796+
...sandboxPolicy,
797+
writableRoots: uniqueStrings([...sandboxPolicy.writableRoots, ...additionalDirectories]),
798+
};
799+
}
800+
801+
function uniqueStrings(values: string[]): string[] {
802+
return Array.from(new Set(values));
803+
}
804+
805+
function arraysEqual(left: string[], right: string[]): boolean {
806+
if (left.length !== right.length) {
807+
return false;
808+
}
809+
return left.every((value, index) => value === right[index]);
810+
}
811+
812+
function isJsonObject(value: JsonValue | undefined): value is JsonObject {
813+
return value !== null && typeof value === "object" && !Array.isArray(value);
708814
}
709815

710816
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
@@ -65,6 +65,7 @@ export interface SessionState {
6565
rateLimits: RateLimitsMap | null;
6666
account: Account | null;
6767
cwd: string;
68+
additionalDirectories: string[];
6869
fastModeEnabled: boolean;
6970
currentModelSupportsFast: boolean;
7071
sessionMcpServers?: Array<string>;
@@ -160,6 +161,7 @@ export class CodexAcpServer implements acp.Agent {
160161
resume: { },
161162
list: { },
162163
close: { },
164+
additionalDirectories: {},
163165
},
164166
mcpCapabilities: {
165167
acp: false,
@@ -346,6 +348,7 @@ export class CodexAcpServer implements acp.Agent {
346348
rateLimits: null,
347349
account: account,
348350
cwd: request.cwd,
351+
additionalDirectories: sessionMetadata.additionalDirectories,
349352
fastModeEnabled: sessionMetadata.currentServiceTier === "fast",
350353
currentModelSupportsFast: currentModelSupportsFast,
351354
sessionMcpServers: sessionMcpServers,
@@ -418,7 +421,20 @@ export class CodexAcpServer implements acp.Agent {
418421
async listSessions(params: acp.ListSessionsRequest): Promise<acp.ListSessionsResponse> {
419422
logger.log("Listing sessions...", {cwd: params.cwd, cursor: params.cursor});
420423
await this.checkAuthorization();
421-
return await this.runWithProcessCheck(() => this.codexAcpClient.listSessions(params));
424+
const response = await this.runWithProcessCheck(() => this.codexAcpClient.listSessions(params));
425+
return {
426+
...response,
427+
sessions: response.sessions.map((session) => {
428+
const activeSession = this.sessions.get(session.sessionId);
429+
if (!activeSession || activeSession.additionalDirectories.length === 0) {
430+
return session;
431+
}
432+
return {
433+
...session,
434+
additionalDirectories: activeSession.additionalDirectories,
435+
};
436+
}),
437+
};
422438
}
423439

424440
async closeSession(params: acp.CloseSessionRequest): Promise<acp.CloseSessionResponse> {
@@ -735,6 +751,7 @@ export class CodexAcpServer implements acp.Agent {
735751
rateLimits: null,
736752
account: account,
737753
cwd: request.cwd,
754+
additionalDirectories: sessionMetadata.additionalDirectories,
738755
fastModeEnabled: sessionMetadata.currentServiceTier === "fast",
739756
currentModelSupportsFast: currentModelSupportsFast,
740757
sessionMcpServers: sessionMcpServers,
@@ -1270,6 +1287,7 @@ export class CodexAcpServer implements acp.Agent {
12701287
serviceTier,
12711288
disableSummary,
12721289
sessionState.cwd,
1290+
sessionState.additionalDirectories,
12731291
(turnId) => {
12741292
if (this.promptIsClosedOrStale(params.sessionId, activePrompt)) {
12751293
this.interruptLateStartedTurn(params.sessionId, turnId);

0 commit comments

Comments
 (0)