Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 97 additions & 4 deletions apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ function createRuntime() {
},
sessionService: {
get: vi.fn(),
updateMeta: vi.fn(),
readTranscriptTail: vi.fn(() => "")
},
sessionDeltaService: {
Expand Down Expand Up @@ -2446,6 +2447,8 @@ describe("adeRpcServer", () => {
provider: "codex",
permissionMode: "edit",
initialInput: "fix failing tests",
modelId: "openai/gpt-5.5",
reasoningEffort: "xhigh",
cols: 90,
rows: 24,
});
Expand All @@ -2454,7 +2457,7 @@ describe("adeRpcServer", () => {
expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith(
expect.objectContaining({
laneId: "lane-1",
title: "Codex",
title: "Fix failing tests",
toolType: "codex",
cols: 90,
rows: 24,
Expand All @@ -2465,7 +2468,15 @@ describe("adeRpcServer", () => {
}),
}),
);
expect(fixture.runtime.ptyService.writeBySessionId).toHaveBeenCalledWith("session-1", "fix failing tests\r");
const createCall = fixture.runtime.ptyService.create.mock.calls.at(-1)?.[0];
expect(createCall?.args).toEqual(expect.arrayContaining(["--model", "gpt-5.5", "-c", "model_reasoning_effort=\"xhigh\""]));
expect(createCall?.args.at(-1)).toContain("fix failing tests");
expect(fixture.runtime.ptyService.writeBySessionId).not.toHaveBeenCalled();
expect(fixture.runtime.sessionService.updateMeta).toHaveBeenCalledWith(expect.objectContaining({
sessionId: "session-1",
goal: "fix failing tests",
title: "Fix failing tests",
}));
expect(response.structuredContent).toMatchObject({
provider: "codex",
laneId: "lane-1",
Expand Down Expand Up @@ -2503,8 +2514,61 @@ describe("adeRpcServer", () => {
expect.objectContaining({
cols: 400,
rows: 200,
startupCommand: expect.stringContaining("codex --no-alt-screen --sandbox workspace-write --ask-for-approval on-request"),
}),
);
expect(response.structuredContent.startupCommand).not.toContain("--full-auto");
});

it("starts shell CLI sessions without reading user shell startup files", async () => {
const fixture = createRuntime();
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });

const response = await withEnv({ SHELL: "/bin/zsh" }, async () => {
await initialize(handler, { role: "orchestrator" });
return await callTool(handler, "start_cli_session", {
laneId: "lane-1",
provider: "shell",
});
});

expect(response?.isError).toBeUndefined();
expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith(
expect.objectContaining({
laneId: "lane-1",
title: "Shell",
toolType: "shell",
command: "/bin/zsh",
args: ["-f"],
env: { ZDOTDIR: "/var/empty" },
}),
);
});

it("starts Codex spawn_agent with current default permission flags", async () => {
const fixture = createRuntime();
const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-spawn-bin-"));
createFakePathExecutable(binDir, "codex");
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });

const response = await withEnv({ PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, SHELL: "/bin/sh" }, async () => {
await initialize(handler, { role: "orchestrator" });
return await callTool(handler, "spawn_agent", {
laneId: "lane-1",
provider: "codex",
prompt: "Check the mobile CLI path",
});
});

expect(response?.isError).toBeUndefined();
expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith(
expect.objectContaining({
command: expect.stringMatching(/codex$/),
args: expect.arrayContaining(["--sandbox", "workspace-write", "--ask-for-approval", "on-request"]),
startupCommand: expect.stringContaining("codex --sandbox workspace-write --ask-for-approval on-request"),
}),
);
expect(response.structuredContent.startupCommand).not.toContain("--full-auto");
});

it("passes selected models to fresh Claude Code terminal launches", async () => {
Expand Down Expand Up @@ -2547,6 +2611,35 @@ describe("adeRpcServer", () => {
expect(response.structuredContent.model).toBe("anthropic/claude-opus-4-7-1m");
});

it("passes Claude auto permission mode to fresh Claude Code terminal launches", async () => {
const fixture = createRuntime();
fixture.runtime.sessionService.get.mockReturnValue({
id: "session-1",
laneId: "lane-1",
ptyId: "pty-1",
tracked: true,
toolType: "claude",
title: "Claude Code",
status: "running",
resumeCommand: null,
resumeMetadata: null,
});
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });

await initialize(handler, { role: "orchestrator" });
const response = await callTool(handler, "start_cli_session", {
laneId: "lane-1",
provider: "claude",
permissionMode: "auto",
});

expect(response?.isError).toBeUndefined();
const createCall = fixture.runtime.ptyService.create.mock.calls[0]?.[0] as { args?: string[]; startupCommand?: string };
expect(createCall.args).toEqual(expect.arrayContaining(["--permission-mode", "auto"]));
expect(createCall.startupCommand).toContain("--permission-mode auto");
expect(response.structuredContent.permissionMode).toBe("auto");
});

it("confirms Claude Code initial input after startup", async () => {
vi.useFakeTimers();
try {
Expand Down Expand Up @@ -2596,7 +2689,7 @@ describe("adeRpcServer", () => {
await initialize(handler, { role: "orchestrator" });
const response = await callTool(handler, "start_cli_session", {
laneId: "lane-1",
provider: "codex",
provider: "cursor",
initialInput: "fix failing tests",
});

Expand Down Expand Up @@ -2661,7 +2754,7 @@ describe("adeRpcServer", () => {

expect(response.isError).toBe(true);
expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain(
"permissionMode must be one of default, plan, edit, full-auto, or config-toml",
"permissionMode must be one of default, auto, plan, edit, full-auto, or config-toml",
);
expect(fixture.runtime.ptyService.create).not.toHaveBeenCalled();
});
Expand Down
66 changes: 57 additions & 9 deletions apps/ade-cli/src/adeRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,12 @@ import type { LinearConnectionStatus } from "../../desktop/src/shared/types/line
import { resolveAdeLayout } from "../../desktop/src/shared/adeLayout";
import {
buildTrackedCliLaunchCommand,
deriveTrackedCliInitialInputSessionMeta,
isLaunchProfile,
isTrackedCliPermissionMode,
LAUNCH_PROFILE_TITLE,
LAUNCH_PROFILE_TOOL_TYPE,
resolveCleanShellLaunchFields,
validateLaunchProfilePermissionMode,
type CliProvider,
type LaunchProfile,
Expand Down Expand Up @@ -225,7 +227,7 @@ const TOOL_SPECS: ToolSpec[] = [
runId: { type: "string" },
stepId: { type: "string" },
attemptId: { type: "string" },
permissionMode: { type: "string", enum: ["default", "plan", "edit", "full-auto", "config-toml"], default: "default" },
permissionMode: { type: "string", enum: ["default", "auto", "plan", "edit", "full-auto", "config-toml"], default: "default" },
toolWhitelist: { type: "array", items: { type: "string" }, maxItems: 24 },
maxPromptChars: { type: "number", minimum: 256, maximum: 12000 },
contextFilePath: { type: "string" },
Expand Down Expand Up @@ -322,13 +324,14 @@ const TOOL_SPECS: ToolSpec[] = [
properties: {
laneId: { type: "string", minLength: 1 },
provider: { type: "string", enum: ["claude", "codex", "cursor", "droid", "opencode", "shell"] },
permissionMode: { type: "string", enum: ["default", "plan", "edit", "full-auto", "config-toml"], default: "default" },
permissionMode: { type: "string", enum: ["default", "auto", "plan", "edit", "full-auto", "config-toml"], default: "default" },
title: { type: "string" },
initialInput: { type: "string" },
cols: { type: "number", minimum: 20, maximum: 400, default: 120 },
rows: { type: "number", minimum: 4, maximum: 200, default: 36 },
model: { type: "string" },
modelId: { type: "string" },
reasoningEffort: { type: "string" },
cwd: { type: "string" },
chatSessionId: { type: "string" },
tracked: { type: "boolean", default: true }
Expand Down Expand Up @@ -1738,7 +1741,7 @@ const CTO_OPERATOR_TOOL_SPECS: ToolSpec[] = [
laneId: { type: "string" },
modelId: { type: "string" },
reasoningEffort: { type: "string" },
permissionMode: { type: "string", enum: ["default", "plan", "edit", "full-auto", "config-toml"] },
permissionMode: { type: "string", enum: ["default", "auto", "plan", "edit", "full-auto", "config-toml"] },
droidPermissionMode: { type: "string", enum: ["read-only", "auto-low", "auto-medium", "auto-high"] },
title: { type: "string" },
initialPrompt: { type: "string" },
Expand Down Expand Up @@ -2491,7 +2494,7 @@ function parseCliSessionPermissionMode(value: unknown): AgentChatPermissionMode
if (isTrackedCliPermissionMode(mode)) return mode;
throw new JsonRpcError(
JsonRpcErrorCode.invalidParams,
"permissionMode must be one of default, plan, edit, full-auto, or config-toml",
"permissionMode must be one of default, auto, plan, edit, full-auto, or config-toml",
);
}

Expand Down Expand Up @@ -3201,11 +3204,11 @@ function sha256Text(value: string): string {
return createHash("sha256").update(value).digest("hex");
}

type SpawnPermissionMode = "default" | "plan" | "edit" | "full-auto" | "config-toml";
type SpawnPermissionMode = "default" | "auto" | "plan" | "edit" | "full-auto" | "config-toml";

function parseSpawnPermissionMode(value: unknown): SpawnPermissionMode {
const normalized = asTrimmedString(value).toLowerCase();
if (normalized === "plan" || normalized === "edit" || normalized === "full-auto" || normalized === "config-toml") return normalized;
if (normalized === "auto" || normalized === "plan" || normalized === "edit" || normalized === "full-auto" || normalized === "config-toml") return normalized;
return "default";
}

Expand Down Expand Up @@ -5171,16 +5174,37 @@ async function runTool(args: {
}
const cols = clampInteger(toolArgs.cols, DEFAULT_PTY_COLS, 20, 400);
const rows = clampInteger(toolArgs.rows, DEFAULT_PTY_ROWS, 4, 200);
const title = asOptionalTrimmedString(toolArgs.title) ?? LAUNCH_PROFILE_TITLE[provider];
const initialInput = asOptionalTrimmedString(toolArgs.initialInput)?.slice(0, 20_000) ?? null;
const model = asOptionalTrimmedString(toolArgs.model) ?? asOptionalTrimmedString(toolArgs.modelId);
const reasoningEffort = asOptionalTrimmedString(toolArgs.reasoningEffort);
const initialInputMeta = deriveTrackedCliInitialInputSessionMeta({
provider,
title: asOptionalTrimmedString(toolArgs.title),
initialInput,
});
const title = initialInputMeta.title || LAUNCH_PROFILE_TITLE[provider];
const ptyService = runtime.ptyService;
const preassignedSessionId = provider === "claude" ? randomUUID() : undefined;
const laneWorktreePath = resolveLaneWorktreePath(runtime, laneId);

const launchFields: { startupCommand?: string; command?: string; args?: string[]; env?: Record<string, string> } = (() => {
if (provider === "shell") {
return resolveCleanShellLaunchFields({
platform: process.platform,
shell: process.env.SHELL,
comSpec: process.env.ComSpec,
});
}
if (!isCliProvider(provider)) return {};
return buildTrackedCliLaunchCommand({ provider, permissionMode, sessionId: preassignedSessionId, model, laneWorktreePath });
return buildTrackedCliLaunchCommand({
provider,
permissionMode,
sessionId: preassignedSessionId,
model,
reasoningEffort,
initialPrompt: provider === "codex" ? initialInput : null,
laneWorktreePath,
});
})();

const created = await ptyService.create({
Expand All @@ -5198,7 +5222,9 @@ async function runTool(args: {
});

let initialInputWritten = false;
if (initialInput && isCliProvider(provider)) {
if (initialInput && provider === "codex") {
initialInputWritten = true;
} else if (initialInput && isCliProvider(provider)) {
initialInputWritten = ptyService.writeBySessionId(created.sessionId, `${initialInput}\r`);
if (!initialInputWritten) {
try {
Expand All @@ -5225,6 +5251,19 @@ async function runTool(args: {
}
}

const autoTitleApplied = Boolean(initialInputMeta.promptTitle) && title === initialInputMeta.promptTitle;
if (initialInputMeta.goal || autoTitleApplied) {
const session = runtime.sessionService.get(created.sessionId) as TerminalSessionSummary | null;
const metaPatch: Parameters<typeof runtime.sessionService.updateMeta>[0] = {
sessionId: created.sessionId,
...(initialInputMeta.goal && !session?.goal?.trim().length ? { goal: initialInputMeta.goal } : {}),
...(autoTitleApplied ? { title: initialInputMeta.promptTitle!, manuallyNamed: false } : {}),
};
if (metaPatch.goal !== undefined || metaPatch.title !== undefined || metaPatch.manuallyNamed !== undefined) {
runtime.sessionService.updateMeta(metaPatch);
}
}

const session = runtime.sessionService.get(created.sessionId) as TerminalSessionSummary | null;
const enrichedSession = session ? ptyService.enrichSessions([session])[0] ?? session : session;
return {
Expand Down Expand Up @@ -6855,6 +6894,12 @@ async function runTool(args: {
"permissionMode config-toml is only supported for Codex spawn_agent sessions.",
);
}
if (provider === "codex" && permissionMode === "auto") {
throw new JsonRpcError(
JsonRpcErrorCode.invalidParams,
"permissionMode auto is only supported for Claude spawn_agent sessions.",
);
}
const maxPromptChars = Math.max(256, Math.min(12000, Math.floor(asNumber(toolArgs.maxPromptChars, 2800))));
const prompt = asOptionalTrimmedString(toolArgs.prompt);
const runId = asOptionalTrimmedString(toolArgs.runId);
Expand Down Expand Up @@ -6934,6 +6979,9 @@ async function runTool(args: {
case "edit":
claudePermission = "acceptEdits";
break;
case "auto":
claudePermission = "auto";
break;
default:
claudePermission = "default";
}
Expand Down
9 changes: 8 additions & 1 deletion apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import { createMemoryService } from "../../desktop/src/main/services/memory/memo
import { createCtoStateService } from "../../desktop/src/main/services/cto/ctoStateService";
import { createWorkerAgentService } from "../../desktop/src/main/services/cto/workerAgentService";
import { createWorkerBudgetService } from "../../desktop/src/main/services/cto/workerBudgetService";
import type { createWorkerRevisionService } from "../../desktop/src/main/services/cto/workerRevisionService";
import { createWorkerRevisionService } from "../../desktop/src/main/services/cto/workerRevisionService";
import type { createWorkerHeartbeatService } from "../../desktop/src/main/services/cto/workerHeartbeatService";
import type { createWorkerTaskSessionService } from "../../desktop/src/main/services/cto/workerTaskSessionService";
import type { createLinearCredentialService } from "../../desktop/src/main/services/cto/linearCredentialService";
Expand Down Expand Up @@ -708,6 +708,11 @@ export async function createAdeRuntime(args: {
workerAgentService,
projectConfigService,
});
const workerRevisionService = createWorkerRevisionService({
db,
projectId,
workerAgentService,
});
const missionBudgetService = createMissionBudgetService({
db,
logger,
Expand Down Expand Up @@ -1077,6 +1082,7 @@ export async function createAdeRuntime(args: {
agentChatService,
workerAgentService,
workerBudgetService,
workerRevisionService,
workerHeartbeatService: headlessLinearServices.workerHeartbeatService,
ctoStateService,
flowPolicyService: headlessLinearServices.flowPolicyService,
Expand Down Expand Up @@ -1158,6 +1164,7 @@ export async function createAdeRuntime(args: {
workerAgentService,
adeProjectService,
workerBudgetService,
workerRevisionService,
githubService: headlessLinearServices.githubService as never,
workerTaskSessionService: headlessLinearServices.workerTaskSessionService,
workerHeartbeatService: headlessLinearServices.workerHeartbeatService,
Expand Down
22 changes: 22 additions & 0 deletions apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2237,6 +2237,28 @@ describe("ADE CLI", () => {
});
});

it("accepts Claude auto permission mode for provider CLI launches", () => {
const plan = buildCliPlan([
"shell",
"start-cli",
"claude",
"--lane",
"lane-1",
"--permission-mode",
"auto",
]);
expect(plan.kind).toBe("execute");
if (plan.kind !== "execute") return;
expect(plan.steps[0]?.params).toMatchObject({
name: "start_cli_session",
arguments: {
laneId: "lane-1",
provider: "claude",
permissionMode: "auto",
},
});
});

it("accepts --provider on shell start as the CLI-session launcher", () => {
const plan = buildCliPlan([
"shell",
Expand Down
Loading
Loading