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
39 changes: 39 additions & 0 deletions apps/array/src/main/services/agent/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ export type Credentials = z.infer<typeof credentialsSchema>;
export const agentFrameworkSchema = z.enum(["claude", "codex"]);
export type AgentFramework = z.infer<typeof agentFrameworkSchema>;

// Execution mode schema
export const executionModeSchema = z.enum(["plan"]);
export type ExecutionMode = z.infer<typeof executionModeSchema>;

// Session config schema
export const sessionConfigSchema = z.object({
taskId: z.string(),
Expand All @@ -23,6 +27,7 @@ export const sessionConfigSchema = z.object({
sdkSessionId: z.string().optional(),
model: z.string().optional(),
framework: agentFrameworkSchema.optional(),
executionMode: executionModeSchema.optional(),
});

export type SessionConfig = z.infer<typeof sessionConfigSchema>;
Expand Down Expand Up @@ -120,13 +125,47 @@ export const subscribeSessionInput = z.object({
// Agent events
export const AgentServiceEvent = {
SessionEvent: "session-event",
PermissionRequest: "permission-request",
} as const;

export interface AgentSessionEventPayload {
sessionId: string;
payload: unknown;
}

export interface PermissionOption {
kind: "allow_once" | "allow_always" | "reject_once" | "reject_always";
name: string;
optionId: string;
description?: string;
}

export interface PermissionRequestPayload {
sessionId: string;
toolCallId: string;
title: string;
options: PermissionOption[];
rawInput: unknown;
}

export interface AgentServiceEvents {
[AgentServiceEvent.SessionEvent]: AgentSessionEventPayload;
[AgentServiceEvent.PermissionRequest]: PermissionRequestPayload;
}

// Permission response input for tRPC
export const respondToPermissionInput = z.object({
sessionId: z.string(),
toolCallId: z.string(),
optionId: z.string(),
});

export type RespondToPermissionInput = z.infer<typeof respondToPermissionInput>;

// Permission cancellation input for tRPC
export const cancelPermissionInput = z.object({
sessionId: z.string(),
toolCallId: z.string(),
});

export type CancelPermissionInput = z.infer<typeof cancelPermissionInput>;
138 changes: 137 additions & 1 deletion apps/array/src/main/services/agent/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ interface SessionConfig {
sdkSessionId?: string;
model?: string;
framework?: "claude" | "codex";
executionMode?: "plan";
}

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

interface PendingPermission {
resolve: (response: RequestPermissionResponse) => void;
reject: (error: Error) => void;
sessionId: string;
toolCallId: string;
}

@injectable()
export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
private sessions = new Map<string, ManagedSession>();
private currentToken: string | null = null;
private pendingPermissions = new Map<string, PendingPermission>();

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

/**
* Respond to a pending permission request from the UI.
* This resolves the promise that the agent is waiting on.
*/
public respondToPermission(
sessionId: string,
toolCallId: string,
optionId: string,
): void {
const key = `${sessionId}:${toolCallId}`;
const pending = this.pendingPermissions.get(key);

if (!pending) {
log.warn("No pending permission found", { sessionId, toolCallId });
return;
}

log.info("Permission response received", {
sessionId,
toolCallId,
optionId,
});

pending.resolve({
outcome: {
outcome: "selected",
optionId,
},
});

this.pendingPermissions.delete(key);
}

/**
* Cancel a pending permission request.
* This resolves the promise with a "cancelled" outcome per ACP spec.
*/
public cancelPermission(sessionId: string, toolCallId: string): void {
const key = `${sessionId}:${toolCallId}`;
const pending = this.pendingPermissions.get(key);

if (!pending) {
log.warn("No pending permission found to cancel", {
sessionId,
toolCallId,
});
return;
}

log.info("Permission cancelled", { sessionId, toolCallId });

pending.resolve({
outcome: {
outcome: "cancelled",
},
});

this.pendingPermissions.delete(key);
}

private getToken(fallback: string): string {
return this.currentToken || fallback;
}
Expand Down Expand Up @@ -232,6 +301,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
sdkSessionId,
model,
framework,
executionMode,
} = config;

if (!isRetry) {
Expand Down Expand Up @@ -289,7 +359,11 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
await connection.newSession({
cwd: repoPath,
mcpServers,
_meta: { sessionId: taskRunId, model },
_meta: {
sessionId: taskRunId,
model,
...(executionMode && { initialModeId: executionMode }),
},
});
}

Expand Down Expand Up @@ -515,6 +589,9 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
_channel: string,
clientStreams: { readable: ReadableStream; writable: WritableStream },
): ClientSideConnection {
// Capture service reference for use in client callbacks
const service = this;

const emitToRenderer = (payload: unknown) => {
// Emit event via TypedEventEmitter for tRPC subscription
this.emit(AgentServiceEvent.SessionEvent, {
Expand Down Expand Up @@ -546,6 +623,63 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
async requestPermission(
params: RequestPermissionRequest,
): Promise<RequestPermissionResponse> {
const toolName =
(params.toolCall?.rawInput as { toolName?: string } | undefined)
?.toolName || "";
const toolCallId = params.toolCall?.toolCallId || "";

log.info("requestPermission called", {
sessionId: taskRunId,
toolCallId,
toolName,
title: params.toolCall?.title,
optionCount: params.options.length,
});

// If we have a toolCallId, always prompt the user for permission.
// The claude.ts adapter only calls requestPermission when user input is needed.
// (It handles auto-approve internally for acceptEdits/bypassPermissions modes)
if (toolCallId) {
log.info("Permission request requires user input", {
sessionId: taskRunId,
toolCallId,
toolName,
title: params.toolCall?.title,
});

return new Promise((resolve, reject) => {
const key = `${taskRunId}:${toolCallId}`;
service.pendingPermissions.set(key, {
resolve,
reject,
sessionId: taskRunId,
toolCallId,
});

log.info("Emitting permission request to renderer", {
sessionId: taskRunId,
toolCallId,
});
service.emit(AgentServiceEvent.PermissionRequest, {
sessionId: taskRunId,
toolCallId,
title: params.toolCall?.title || "Permission Required",
options: params.options.map((o) => ({
kind: o.kind,
name: o.name,
optionId: o.optionId,
description: (o as { description?: string }).description,
})),
rawInput: params.toolCall?.rawInput,
});
});
}

// Fallback: no toolCallId means we can't track the response, auto-approve
log.warn("No toolCallId in permission request, auto-approving", {
sessionId: taskRunId,
toolName,
});
const allowOption = params.options.find(
(o) => o.kind === "allow_once" || o.kind === "allow_always",
);
Expand Down Expand Up @@ -611,6 +745,8 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
sdkSessionId: "sdkSessionId" in params ? params.sdkSessionId : undefined,
model: "model" in params ? params.model : undefined,
framework: "framework" in params ? params.framework : "claude",
executionMode:
"executionMode" in params ? params.executionMode : undefined,
};
}

Expand Down
23 changes: 23 additions & 0 deletions apps/array/src/main/services/workspace/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export interface WorkspaceServiceEvents {
@injectable()
export class WorkspaceService extends TypedEventEmitter<WorkspaceServiceEvents> {
private scriptRunner: ScriptRunner;
private creatingWorkspaces = new Map<string, Promise<WorkspaceInfo>>();

constructor() {
super();
Expand All @@ -100,6 +101,28 @@ export class WorkspaceService extends TypedEventEmitter<WorkspaceServiceEvents>
}

async createWorkspace(options: CreateWorkspaceInput): Promise<WorkspaceInfo> {
// Prevent concurrent workspace creation for the same task
const existingPromise = this.creatingWorkspaces.get(options.taskId);
if (existingPromise) {
log.warn(
`Workspace creation already in progress for task ${options.taskId}, waiting for existing operation`,
);
return existingPromise;
}

const promise = this.doCreateWorkspace(options);
this.creatingWorkspaces.set(options.taskId, promise);

try {
return await promise;
} finally {
this.creatingWorkspaces.delete(options.taskId);
}
}

private async doCreateWorkspace(
options: CreateWorkspaceInput,
): Promise<WorkspaceInfo> {
const { taskId, mainRepoPath, folderId, folderPath, mode, branch } =
options;
log.info(
Expand Down
37 changes: 37 additions & 0 deletions apps/array/src/main/trpc/routers/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { container } from "../../di/container.js";
import { MAIN_TOKENS } from "../../di/tokens.js";
import {
AgentServiceEvent,
cancelPermissionInput,
cancelPromptInput,
cancelSessionInput,
promptInput,
promptOutput,
reconnectSessionInput,
respondToPermissionInput,
sessionResponseSchema,
setModelInput,
startSessionInput,
Expand Down Expand Up @@ -72,4 +74,39 @@ export const agentRouter = router({
}
}
}),

// Permission request subscription - yields when tools need user input
onPermissionRequest: publicProcedure
.input(subscribeSessionInput)
.subscription(async function* (opts) {
const service = getService();
const targetSessionId = opts.input.sessionId;
const iterable = service.toIterable(AgentServiceEvent.PermissionRequest, {
signal: opts.signal,
});

for await (const event of iterable) {
if (event.sessionId === targetSessionId) {
yield event;
}
}
}),

// Respond to a permission request from the UI
respondToPermission: publicProcedure
.input(respondToPermissionInput)
.mutation(({ input }) =>
getService().respondToPermission(
input.sessionId,
input.toolCallId,
input.optionId,
),
),

// Cancel a permission request (e.g., user pressed Escape)
cancelPermission: publicProcedure
.input(cancelPermissionInput)
.mutation(({ input }) =>
getService().cancelPermission(input.sessionId, input.toolCallId),
),
});
Loading