Skip to content

Commit 79b41cf

Browse files
committed
feat: background local environment if worktree focused
1 parent 15e35dd commit 79b41cf

33 files changed

Lines changed: 2152 additions & 736 deletions

File tree

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export const sessionConfigSchema = z.object({
2323
sdkSessionId: z.string().optional(),
2424
model: z.string().optional(),
2525
executionMode: executionModeSchema.optional(),
26+
/** Additional directories Claude can access beyond cwd (for worktree support) */
27+
additionalDirectories: z.array(z.string()).optional(),
2628
});
2729

2830
export type SessionConfig = z.infer<typeof sessionConfigSchema>;
@@ -42,6 +44,8 @@ export const startSessionInput = z.object({
4244
executionMode: z.enum(["plan", "acceptEdits", "default"]).optional(),
4345
runMode: z.enum(["local", "cloud"]).optional(),
4446
createPR: z.boolean().optional(),
47+
/** Additional directories Claude can access beyond cwd (for worktree support) */
48+
additionalDirectories: z.array(z.string()).optional(),
4549
});
4650

4751
export type StartSessionInput = z.infer<typeof startSessionInput>;
@@ -71,6 +75,11 @@ export type PromptInput = z.infer<typeof promptInput>;
7175

7276
export const promptOutput = z.object({
7377
stopReason: z.string(),
78+
_meta: z
79+
.object({
80+
interruptReason: z.string().optional(),
81+
})
82+
.optional(),
7483
});
7584

7685
export type PromptOutput = z.infer<typeof promptOutput>;
@@ -80,9 +89,18 @@ export const cancelSessionInput = z.object({
8089
sessionId: z.string(),
8190
});
8291

92+
// Interrupt reason schema
93+
export const interruptReasonSchema = z.enum([
94+
"user_request",
95+
"moving_to_worktree",
96+
"moving_to_local",
97+
]);
98+
export type InterruptReason = z.infer<typeof interruptReasonSchema>;
99+
83100
// Cancel prompt input
84101
export const cancelPromptInput = z.object({
85102
sessionId: z.string(),
103+
reason: interruptReasonSchema.optional(),
86104
});
87105

88106
// Reconnect session input
@@ -95,6 +113,8 @@ export const reconnectSessionInput = z.object({
95113
projectId: z.number(),
96114
logUrl: z.string().optional(),
97115
sdkSessionId: z.string().optional(),
116+
/** Additional directories Claude can access beyond cwd (for worktree support) */
117+
additionalDirectories: z.array(z.string()).optional(),
98118
});
99119

100120
export type ReconnectSessionInput = z.infer<typeof reconnectSessionInput>;
@@ -173,3 +193,22 @@ export const cancelPermissionInput = z.object({
173193
});
174194

175195
export type CancelPermissionInput = z.infer<typeof cancelPermissionInput>;
196+
197+
export const listSessionsInput = z.object({
198+
taskId: z.string(),
199+
});
200+
201+
export const notifyCwdChangeInput = z.object({
202+
sessionId: z.string(),
203+
newPath: z.string(),
204+
reason: z.enum(["moving_to_worktree", "moving_to_local"]),
205+
});
206+
207+
export type NotifyCwdChangeInput = z.infer<typeof notifyCwdChangeInput>;
208+
209+
export const sessionInfoSchema = z.object({
210+
taskRunId: z.string(),
211+
repoPath: z.string(),
212+
});
213+
214+
export const listSessionsOutput = z.array(sessionInfoSchema);

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

Lines changed: 154 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,15 @@ import {
2020
AgentServiceEvent,
2121
type AgentServiceEvents,
2222
type Credentials,
23+
type InterruptReason,
2324
type PromptOutput,
2425
type ReconnectSessionInput,
2526
type SessionResponse,
2627
type StartSessionInput,
2728
} from "./schemas.js";
2829

30+
export type { InterruptReason };
31+
2932
const log = logger.scope("agent-service");
3033

3134
function isAuthError(error: unknown): boolean {
@@ -130,6 +133,8 @@ interface SessionConfig {
130133
sdkSessionId?: string;
131134
model?: string;
132135
executionMode?: "plan" | "acceptEdits" | "default";
136+
/** Additional directories Claude can access beyond cwd (for worktree support) */
137+
additionalDirectories?: string[];
133138
}
134139

135140
interface ManagedSession {
@@ -143,7 +148,10 @@ interface ManagedSession {
143148
lastActivityAt: number;
144149
mockNodeDir: string;
145150
config: SessionConfig;
151+
interruptReason?: InterruptReason;
146152
needsRecreation: boolean;
153+
promptPending: boolean;
154+
pendingCwdContext?: string;
147155
}
148156

149157
function getClaudeCliPath(): string {
@@ -318,6 +326,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
318326
sdkSessionId,
319327
model,
320328
executionMode,
329+
additionalDirectories,
321330
} = config;
322331

323332
if (!isRetry) {
@@ -369,6 +378,11 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
369378
persistence: { taskId, runId: taskRunId, logUrl },
370379
}),
371380
...(sdkSessionId && { sdkSessionId }),
381+
...(additionalDirectories?.length && {
382+
claudeCode: {
383+
options: { additionalDirectories },
384+
},
385+
}),
372386
},
373387
});
374388
} else {
@@ -379,6 +393,11 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
379393
sessionId: taskRunId,
380394
model,
381395
...(executionMode && { initialModeId: executionMode }),
396+
...(additionalDirectories?.length && {
397+
claudeCode: {
398+
options: { additionalDirectories },
399+
},
400+
}),
382401
},
383402
});
384403
}
@@ -395,6 +414,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
395414
mockNodeDir,
396415
config,
397416
needsRecreation: false,
417+
promptPending: false,
398418
};
399419

400420
this.sessions.set(taskRunId, session);
@@ -428,14 +448,22 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
428448

429449
log.info("Recreating session", { taskRunId });
430450

451+
// Preserve state that should survive recreation
431452
const config = existing.config;
453+
const pendingCwdContext = existing.pendingCwdContext;
454+
432455
this.cleanupSession(taskRunId);
433456

434457
const newSession = await this.getOrCreateSession(config, true);
435458
if (!newSession) {
436459
throw new Error(`Failed to recreate session: ${taskRunId}`);
437460
}
438461

462+
// Restore preserved state
463+
if (pendingCwdContext) {
464+
newSession.pendingCwdContext = pendingCwdContext;
465+
}
466+
439467
return newSession;
440468
}
441469

@@ -456,25 +484,45 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
456484
session = await this.recreateSession(sessionId);
457485
}
458486

487+
// Prepend pending CWD context if present
488+
let finalPrompt = prompt;
489+
if (session.pendingCwdContext) {
490+
log.info("Prepending CWD context to prompt", { sessionId });
491+
finalPrompt = [
492+
{ type: "text", text: `_${session.pendingCwdContext}_\n\n` },
493+
...prompt,
494+
];
495+
session.pendingCwdContext = undefined;
496+
}
497+
459498
session.lastActivityAt = Date.now();
499+
session.promptPending = true;
460500

461501
try {
462502
const result = await session.connection.prompt({
463503
sessionId,
464-
prompt,
504+
prompt: finalPrompt,
465505
});
466-
return { stopReason: result.stopReason };
506+
return {
507+
stopReason: result.stopReason,
508+
_meta: result._meta as PromptOutput["_meta"],
509+
};
467510
} catch (err) {
468511
if (isAuthError(err)) {
469512
log.warn("Auth error during prompt, recreating session", { sessionId });
470513
session = await this.recreateSession(sessionId);
471514
const result = await session.connection.prompt({
472515
sessionId,
473-
prompt,
516+
prompt: finalPrompt,
474517
});
475-
return { stopReason: result.stopReason };
518+
return {
519+
stopReason: result.stopReason,
520+
_meta: result._meta as PromptOutput["_meta"],
521+
};
476522
}
477523
throw err;
524+
} finally {
525+
session.promptPending = false;
478526
}
479527
}
480528

@@ -492,12 +540,22 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
492540
}
493541
}
494542

495-
async cancelPrompt(sessionId: string): Promise<boolean> {
543+
async cancelPrompt(
544+
sessionId: string,
545+
reason?: InterruptReason,
546+
): Promise<boolean> {
496547
const session = this.sessions.get(sessionId);
497548
if (!session) return false;
498549

499550
try {
500-
await session.connection.cancel({ sessionId });
551+
await session.connection.cancel({
552+
sessionId,
553+
_meta: reason ? { interruptReason: reason } : undefined,
554+
});
555+
if (reason) {
556+
session.interruptReason = reason;
557+
log.info("Session interrupted", { sessionId, reason });
558+
}
501559
return true;
502560
} catch (err) {
503561
log.error("Failed to cancel prompt", { sessionId, err });
@@ -550,6 +608,92 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
550608
return taskId ? all.filter((s) => s.taskId === taskId) : all;
551609
}
552610

611+
/**
612+
* Get sessions that were interrupted for a specific reason.
613+
* Optionally filter by repoPath to get only sessions for a specific repo.
614+
*/
615+
getInterruptedSessions(
616+
reason: InterruptReason,
617+
repoPath?: string,
618+
): ManagedSession[] {
619+
return Array.from(this.sessions.values()).filter(
620+
(s) =>
621+
s.interruptReason === reason &&
622+
(repoPath === undefined || s.repoPath === repoPath),
623+
);
624+
}
625+
626+
/**
627+
* Resume an interrupted session by clearing the interrupt reason
628+
* and sending a continue prompt.
629+
*/
630+
async resumeInterruptedSession(sessionId: string): Promise<PromptOutput> {
631+
const session = this.sessions.get(sessionId);
632+
if (!session) {
633+
throw new Error(`Session not found: ${sessionId}`);
634+
}
635+
636+
if (!session.interruptReason) {
637+
throw new Error(`Session ${sessionId} was not interrupted`);
638+
}
639+
640+
log.info("Resuming interrupted session", {
641+
sessionId,
642+
reason: session.interruptReason,
643+
});
644+
645+
// Clear the interrupt reason
646+
session.interruptReason = undefined;
647+
648+
// Send a continue prompt
649+
return this.prompt(sessionId, [
650+
{ type: "text", text: "Continue where you left off." },
651+
]);
652+
}
653+
654+
/**
655+
* Notify a session that its working directory context has changed.
656+
* Used when focusing/unfocusing worktrees - the agent doesn't need to respawn
657+
* because it has additionalDirectories configured, but it should know about the change.
658+
*/
659+
async notifyCwdChange(
660+
sessionId: string,
661+
newPath: string,
662+
reason: "moving_to_worktree" | "moving_to_local",
663+
): Promise<void> {
664+
const session = this.sessions.get(sessionId);
665+
if (!session) {
666+
log.warn("Session not found for CWD notification", { sessionId });
667+
return;
668+
}
669+
670+
const contextMessage =
671+
reason === "moving_to_worktree"
672+
? `Your working directory has been moved to a worktree at ${newPath} because the user focused on another task.`
673+
: `Your working directory has been returned to the main repository at ${newPath}.`;
674+
675+
// Check if session is currently busy
676+
if (session.promptPending) {
677+
// Active session: send immediately with continue instruction
678+
this.prompt(sessionId, [
679+
{
680+
type: "text",
681+
text: `${contextMessage} Continue where you left off.`,
682+
},
683+
]);
684+
} else {
685+
// Idle session: store for prepending to next user message
686+
session.pendingCwdContext = contextMessage;
687+
}
688+
689+
log.info("Notified session of CWD change", {
690+
sessionId,
691+
newPath,
692+
reason,
693+
wasPromptPending: session.promptPending,
694+
});
695+
}
696+
553697
async cleanupAll(): Promise<void> {
554698
log.info("Cleaning up all agent sessions", {
555699
sessionCount: this.sessions.size,
@@ -813,6 +957,10 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
813957
model: "model" in params ? params.model : undefined,
814958
executionMode:
815959
"executionMode" in params ? params.executionMode : undefined,
960+
additionalDirectories:
961+
"additionalDirectories" in params
962+
? params.additionalDirectories
963+
: undefined,
816964
};
817965
}
818966

0 commit comments

Comments
 (0)