Skip to content

Commit 1a2db1b

Browse files
authored
Array Plan Mode (#411)
1 parent 3b76891 commit 1a2db1b

39 files changed

+1957
-1384
lines changed

apps/array/src/main/services/agent/schemas.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ export type Credentials = z.infer<typeof credentialsSchema>;
1313
export const agentFrameworkSchema = z.enum(["claude", "codex"]);
1414
export type AgentFramework = z.infer<typeof agentFrameworkSchema>;
1515

16+
// Execution mode schema
17+
export const executionModeSchema = z.enum(["plan"]);
18+
export type ExecutionMode = z.infer<typeof executionModeSchema>;
19+
1620
// Session config schema
1721
export const sessionConfigSchema = z.object({
1822
taskId: z.string(),
@@ -23,6 +27,7 @@ export const sessionConfigSchema = z.object({
2327
sdkSessionId: z.string().optional(),
2428
model: z.string().optional(),
2529
framework: agentFrameworkSchema.optional(),
30+
executionMode: executionModeSchema.optional(),
2631
});
2732

2833
export type SessionConfig = z.infer<typeof sessionConfigSchema>;
@@ -120,13 +125,47 @@ export const subscribeSessionInput = z.object({
120125
// Agent events
121126
export const AgentServiceEvent = {
122127
SessionEvent: "session-event",
128+
PermissionRequest: "permission-request",
123129
} as const;
124130

125131
export interface AgentSessionEventPayload {
126132
sessionId: string;
127133
payload: unknown;
128134
}
129135

136+
export interface PermissionOption {
137+
kind: "allow_once" | "allow_always" | "reject_once" | "reject_always";
138+
name: string;
139+
optionId: string;
140+
description?: string;
141+
}
142+
143+
export interface PermissionRequestPayload {
144+
sessionId: string;
145+
toolCallId: string;
146+
title: string;
147+
options: PermissionOption[];
148+
rawInput: unknown;
149+
}
150+
130151
export interface AgentServiceEvents {
131152
[AgentServiceEvent.SessionEvent]: AgentSessionEventPayload;
153+
[AgentServiceEvent.PermissionRequest]: PermissionRequestPayload;
132154
}
155+
156+
// Permission response input for tRPC
157+
export const respondToPermissionInput = z.object({
158+
sessionId: z.string(),
159+
toolCallId: z.string(),
160+
optionId: z.string(),
161+
});
162+
163+
export type RespondToPermissionInput = z.infer<typeof respondToPermissionInput>;
164+
165+
// Permission cancellation input for tRPC
166+
export const cancelPermissionInput = z.object({
167+
sessionId: z.string(),
168+
toolCallId: z.string(),
169+
});
170+
171+
export type CancelPermissionInput = z.infer<typeof cancelPermissionInput>;

apps/array/src/main/services/agent/service.ts

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ interface SessionConfig {
130130
sdkSessionId?: string;
131131
model?: string;
132132
framework?: "claude" | "codex";
133+
executionMode?: "plan";
133134
}
134135

135136
interface ManagedSession {
@@ -152,16 +153,84 @@ function getClaudeCliPath(): string {
152153
: join(appPath, ".vite/build/claude-cli/cli.js");
153154
}
154155

156+
interface PendingPermission {
157+
resolve: (response: RequestPermissionResponse) => void;
158+
reject: (error: Error) => void;
159+
sessionId: string;
160+
toolCallId: string;
161+
}
162+
155163
@injectable()
156164
export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
157165
private sessions = new Map<string, ManagedSession>();
158166
private currentToken: string | null = null;
167+
private pendingPermissions = new Map<string, PendingPermission>();
159168

160169
public updateToken(newToken: string): void {
161170
this.currentToken = newToken;
162171
log.info("Session token updated");
163172
}
164173

174+
/**
175+
* Respond to a pending permission request from the UI.
176+
* This resolves the promise that the agent is waiting on.
177+
*/
178+
public respondToPermission(
179+
sessionId: string,
180+
toolCallId: string,
181+
optionId: string,
182+
): void {
183+
const key = `${sessionId}:${toolCallId}`;
184+
const pending = this.pendingPermissions.get(key);
185+
186+
if (!pending) {
187+
log.warn("No pending permission found", { sessionId, toolCallId });
188+
return;
189+
}
190+
191+
log.info("Permission response received", {
192+
sessionId,
193+
toolCallId,
194+
optionId,
195+
});
196+
197+
pending.resolve({
198+
outcome: {
199+
outcome: "selected",
200+
optionId,
201+
},
202+
});
203+
204+
this.pendingPermissions.delete(key);
205+
}
206+
207+
/**
208+
* Cancel a pending permission request.
209+
* This resolves the promise with a "cancelled" outcome per ACP spec.
210+
*/
211+
public cancelPermission(sessionId: string, toolCallId: string): void {
212+
const key = `${sessionId}:${toolCallId}`;
213+
const pending = this.pendingPermissions.get(key);
214+
215+
if (!pending) {
216+
log.warn("No pending permission found to cancel", {
217+
sessionId,
218+
toolCallId,
219+
});
220+
return;
221+
}
222+
223+
log.info("Permission cancelled", { sessionId, toolCallId });
224+
225+
pending.resolve({
226+
outcome: {
227+
outcome: "cancelled",
228+
},
229+
});
230+
231+
this.pendingPermissions.delete(key);
232+
}
233+
165234
private getToken(fallback: string): string {
166235
return this.currentToken || fallback;
167236
}
@@ -232,6 +301,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
232301
sdkSessionId,
233302
model,
234303
framework,
304+
executionMode,
235305
} = config;
236306

237307
if (!isRetry) {
@@ -289,7 +359,11 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
289359
await connection.newSession({
290360
cwd: repoPath,
291361
mcpServers,
292-
_meta: { sessionId: taskRunId, model },
362+
_meta: {
363+
sessionId: taskRunId,
364+
model,
365+
...(executionMode && { initialModeId: executionMode }),
366+
},
293367
});
294368
}
295369

@@ -515,6 +589,9 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
515589
_channel: string,
516590
clientStreams: { readable: ReadableStream; writable: WritableStream },
517591
): ClientSideConnection {
592+
// Capture service reference for use in client callbacks
593+
const service = this;
594+
518595
const emitToRenderer = (payload: unknown) => {
519596
// Emit event via TypedEventEmitter for tRPC subscription
520597
this.emit(AgentServiceEvent.SessionEvent, {
@@ -546,6 +623,63 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
546623
async requestPermission(
547624
params: RequestPermissionRequest,
548625
): Promise<RequestPermissionResponse> {
626+
const toolName =
627+
(params.toolCall?.rawInput as { toolName?: string } | undefined)
628+
?.toolName || "";
629+
const toolCallId = params.toolCall?.toolCallId || "";
630+
631+
log.info("requestPermission called", {
632+
sessionId: taskRunId,
633+
toolCallId,
634+
toolName,
635+
title: params.toolCall?.title,
636+
optionCount: params.options.length,
637+
});
638+
639+
// If we have a toolCallId, always prompt the user for permission.
640+
// The claude.ts adapter only calls requestPermission when user input is needed.
641+
// (It handles auto-approve internally for acceptEdits/bypassPermissions modes)
642+
if (toolCallId) {
643+
log.info("Permission request requires user input", {
644+
sessionId: taskRunId,
645+
toolCallId,
646+
toolName,
647+
title: params.toolCall?.title,
648+
});
649+
650+
return new Promise((resolve, reject) => {
651+
const key = `${taskRunId}:${toolCallId}`;
652+
service.pendingPermissions.set(key, {
653+
resolve,
654+
reject,
655+
sessionId: taskRunId,
656+
toolCallId,
657+
});
658+
659+
log.info("Emitting permission request to renderer", {
660+
sessionId: taskRunId,
661+
toolCallId,
662+
});
663+
service.emit(AgentServiceEvent.PermissionRequest, {
664+
sessionId: taskRunId,
665+
toolCallId,
666+
title: params.toolCall?.title || "Permission Required",
667+
options: params.options.map((o) => ({
668+
kind: o.kind,
669+
name: o.name,
670+
optionId: o.optionId,
671+
description: (o as { description?: string }).description,
672+
})),
673+
rawInput: params.toolCall?.rawInput,
674+
});
675+
});
676+
}
677+
678+
// Fallback: no toolCallId means we can't track the response, auto-approve
679+
log.warn("No toolCallId in permission request, auto-approving", {
680+
sessionId: taskRunId,
681+
toolName,
682+
});
549683
const allowOption = params.options.find(
550684
(o) => o.kind === "allow_once" || o.kind === "allow_always",
551685
);
@@ -611,6 +745,8 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
611745
sdkSessionId: "sdkSessionId" in params ? params.sdkSessionId : undefined,
612746
model: "model" in params ? params.model : undefined,
613747
framework: "framework" in params ? params.framework : "claude",
748+
executionMode:
749+
"executionMode" in params ? params.executionMode : undefined,
614750
};
615751
}
616752

apps/array/src/main/services/workspace/service.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export interface WorkspaceServiceEvents {
8989
@injectable()
9090
export class WorkspaceService extends TypedEventEmitter<WorkspaceServiceEvents> {
9191
private scriptRunner: ScriptRunner;
92+
private creatingWorkspaces = new Map<string, Promise<WorkspaceInfo>>();
9293

9394
constructor() {
9495
super();
@@ -100,6 +101,28 @@ export class WorkspaceService extends TypedEventEmitter<WorkspaceServiceEvents>
100101
}
101102

102103
async createWorkspace(options: CreateWorkspaceInput): Promise<WorkspaceInfo> {
104+
// Prevent concurrent workspace creation for the same task
105+
const existingPromise = this.creatingWorkspaces.get(options.taskId);
106+
if (existingPromise) {
107+
log.warn(
108+
`Workspace creation already in progress for task ${options.taskId}, waiting for existing operation`,
109+
);
110+
return existingPromise;
111+
}
112+
113+
const promise = this.doCreateWorkspace(options);
114+
this.creatingWorkspaces.set(options.taskId, promise);
115+
116+
try {
117+
return await promise;
118+
} finally {
119+
this.creatingWorkspaces.delete(options.taskId);
120+
}
121+
}
122+
123+
private async doCreateWorkspace(
124+
options: CreateWorkspaceInput,
125+
): Promise<WorkspaceInfo> {
103126
const { taskId, mainRepoPath, folderId, folderPath, mode, branch } =
104127
options;
105128
log.info(

apps/array/src/main/trpc/routers/agent.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import { container } from "../../di/container.js";
33
import { MAIN_TOKENS } from "../../di/tokens.js";
44
import {
55
AgentServiceEvent,
6+
cancelPermissionInput,
67
cancelPromptInput,
78
cancelSessionInput,
89
promptInput,
910
promptOutput,
1011
reconnectSessionInput,
12+
respondToPermissionInput,
1113
sessionResponseSchema,
1214
setModelInput,
1315
startSessionInput,
@@ -72,4 +74,39 @@ export const agentRouter = router({
7274
}
7375
}
7476
}),
77+
78+
// Permission request subscription - yields when tools need user input
79+
onPermissionRequest: publicProcedure
80+
.input(subscribeSessionInput)
81+
.subscription(async function* (opts) {
82+
const service = getService();
83+
const targetSessionId = opts.input.sessionId;
84+
const iterable = service.toIterable(AgentServiceEvent.PermissionRequest, {
85+
signal: opts.signal,
86+
});
87+
88+
for await (const event of iterable) {
89+
if (event.sessionId === targetSessionId) {
90+
yield event;
91+
}
92+
}
93+
}),
94+
95+
// Respond to a permission request from the UI
96+
respondToPermission: publicProcedure
97+
.input(respondToPermissionInput)
98+
.mutation(({ input }) =>
99+
getService().respondToPermission(
100+
input.sessionId,
101+
input.toolCallId,
102+
input.optionId,
103+
),
104+
),
105+
106+
// Cancel a permission request (e.g., user pressed Escape)
107+
cancelPermission: publicProcedure
108+
.input(cancelPermissionInput)
109+
.mutation(({ input }) =>
110+
getService().cancelPermission(input.sessionId, input.toolCallId),
111+
),
75112
});

0 commit comments

Comments
 (0)