Skip to content
16 changes: 16 additions & 0 deletions apps/code/src/main/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,22 @@ container.bind(LOGS_SERVICE).toDynamicValue((ctx) => {
ws.localLogs.readTail.query({ taskRunId, maxBytes }),
writeLocalLogs: (taskRunId: string, content: string) =>
ws.localLogs.write.mutate({ taskRunId, content }),
seedLocalLogs: (taskRunId: string, content: string) =>
ws.localLogs.seed.mutate({ taskRunId, content }),
cloneLocalLogs: async (
sourceTaskRunId: string,
targetTaskRunId: string,
) => {
const content = await ws.localLogs.read.query({
taskRunId: sourceTaskRunId,
});
if (content) {
await ws.localLogs.seed.mutate({
taskRunId: targetTaskRunId,
content,
});
}
},
};
});
container.bind(MAIN_ENCRYPTION_SERVICE).to(EncryptionService);
Expand Down
3 changes: 3 additions & 0 deletions apps/code/src/renderer/di/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,12 @@ import {
import {
TASK_CREATION_EFFECTS,
TASK_CREATION_HOST,
TASK_FORK_SERVICE,
WORKSPACE_SETUP_SAGA,
} from "@posthog/core/task-detail/identifiers";
import type { TaskCreationEffects } from "@posthog/core/task-detail/taskCreationEffects";
import type { ITaskCreationHost } from "@posthog/core/task-detail/taskCreationHost";
import type { TaskForkService } from "@posthog/core/task-detail/taskForkService";
import {
TASK_SERVICE,
type TaskService,
Expand Down Expand Up @@ -299,6 +301,7 @@ export interface RendererBindings {
[TASK_CREATION_EFFECTS]: TaskCreationEffects;
[RENDERER_TASK_SERVICE]: TaskService;
[TASK_SERVICE]: TaskService;
[TASK_FORK_SERVICE]: TaskForkService;
[WORKSPACE_SETUP_SAGA]: WorkspaceSetupSaga;
[SESSION_SERVICE]: SessionService;
[LOCAL_HANDOFF_HOST]: LocalHandoffHost;
Expand Down
6 changes: 6 additions & 0 deletions apps/code/src/renderer/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,11 @@ import type { SkillsWorkspaceClient } from "@posthog/core/skills/teamSkillsServi
import {
TASK_CREATION_EFFECTS,
TASK_CREATION_HOST,
TASK_FORK_SERVICE,
WORKSPACE_SETUP_SAGA,
} from "@posthog/core/task-detail/identifiers";
import type { ITaskCreationHost } from "@posthog/core/task-detail/taskCreationHost";
import { TaskForkService } from "@posthog/core/task-detail/taskForkService";
import {
TASK_SERVICE,
TaskService,
Expand Down Expand Up @@ -302,6 +304,10 @@ container.load(piRuntimeModule);
container.bind(TASK_CREATION_EFFECTS).toConstantValue(taskCreationEffects);
container.bind<TaskService>(RENDERER_TASK_SERVICE).to(TaskService);
container.bind<TaskService>(TASK_SERVICE).toService(RENDERER_TASK_SERVICE);
container
.bind<TaskForkService>(TASK_FORK_SERVICE)
.to(TaskForkService)
.inSingletonScope();
container
.bind<WorkspaceSetupSaga>(WORKSPACE_SETUP_SAGA)
.to(WorkspaceSetupSaga)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ function makeQueryHandle(): SdkQueryHandle {
}

const createdQueries: SdkQueryHandle[] = [];
const forkSession = vi.fn().mockResolvedValue({
sessionId: "0197a000-0000-7000-8000-0000000000f0",
});

vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
query: vi.fn(() => {
Expand All @@ -46,6 +49,7 @@ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
return handle;
}),
getSessionMessages: vi.fn().mockResolvedValue([]),
forkSession,
listSessions: vi.fn().mockResolvedValue([]),
createSdkMcpServer: vi.fn(() => ({
type: "sdk",
Expand Down Expand Up @@ -235,4 +239,19 @@ describe("ClaudeAcpAgent session model on resume", () => {

expect(createdQueries[0]?.close).toHaveBeenCalledTimes(1);
});

it("copies the complete transcript before resuming the fork", async () => {
const agent = makeAgent();
const sourceSessionId = "0197a000-0000-7000-8000-0000000000aa";

const response = await agent.unstable_forkSession({
sessionId: sourceSessionId,
cwd,
mcpServers: [],
_meta: { taskRunId: "run-fork" },
});

expect(forkSession).toHaveBeenCalledWith(sourceSessionId);
expect(response.sessionId).toBe("0197a000-0000-7000-8000-0000000000f0");
});
});
4 changes: 3 additions & 1 deletion packages/agent/src/adapters/claude/claude-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
import {
type CanUseTool,
type FastModeState,
forkSession as forkClaudeSession,
getSessionInfo,
getSessionMessages,
listSessions,
Expand Down Expand Up @@ -360,14 +361,15 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
async unstable_forkSession(
params: ForkSessionRequest,
): Promise<ForkSessionResponse> {
const forked = await forkClaudeSession(params.sessionId);
return this.createSession(
{
cwd: params.cwd,
mcpServers: params.mcpServers ?? [],
additionalDirectories: params.additionalDirectories,
_meta: params._meta,
},
{ resume: params.sessionId, forkSession: true },
{ resume: forked.sessionId },
);
}

Expand Down
2 changes: 2 additions & 0 deletions packages/api-client/src/posthog-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ describe("PostHogAPIClient", () => {
model: "gpt-5.4",
reasoningLevel: "high",
initialPermissionMode: "auto",
resumeFromRunId: "run-source",
}),
).resolves.toEqual({ id: "run-123", environment: "cloud" });

Expand All @@ -238,6 +239,7 @@ describe("PostHogAPIClient", () => {
model: "gpt-5.4",
reasoning_effort: "high",
initial_permission_mode: "auto",
resume_from_run_id: "run-source",
environment: "cloud",
}),
},
Expand Down
1 change: 1 addition & 0 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,7 @@ interface CreateTaskRunOptions extends CloudRunOptions {
environment?: "local" | "cloud";
mode?: "interactive" | "background";
branch?: string | null;
resumeFromRunId?: string;
}

interface StartTaskRunOptions {
Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/context-menu/context-menu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,30 @@ describe("ContextMenuService.showTaskContextMenu", () => {
expect(labels(idle.lastItems)).not.toContain("Stop task");
});

it("offers fork and source navigation when available", async () => {
const forkMenu = new FakeContextMenu();
const forkResult = makeService(forkMenu).showTaskContextMenu({
...baseTask,
canFork: true,
hasSourceTask: true,
});
await forkMenu.shown;

expect(labels(forkMenu.lastItems)).toContain("Fork task");
expect(labels(forkMenu.lastItems)).toContain("Open source task");
findItem(forkMenu.lastItems, "Fork task").click();
expect(await forkResult).toEqual({ action: { type: "fork" } });

const sourceMenu = new FakeContextMenu();
const sourceResult = makeService(sourceMenu).showTaskContextMenu({
...baseTask,
hasSourceTask: true,
});
await sourceMenu.shown;
findItem(sourceMenu.lastItems, "Open source task").click();
expect(await sourceResult).toEqual({ action: { type: "open-source" } });
});

it("hides Add to Command Center when already in it", async () => {
const inCc = new FakeContextMenu();
makeService(inCc).showTaskContextMenu({
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/context-menu/context-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ export class ContextMenuService {
isPinned,
isSuspended,
canStop,
canFork,
hasSourceTask,
isInCommandCenter,
hasEmptyCommandCenterCell,
channels,
Expand Down Expand Up @@ -142,6 +144,21 @@ export class ContextMenuService {
return this.showMenu<TaskAction>([
this.item(isPinned ? "Unpin" : "Pin", { type: "pin" }),
this.item("Rename", { type: "rename" }),
...(canFork || hasSourceTask
? [
this.separator(),
...(canFork
? [this.item("Fork task", { type: "fork" as const })]
: []),
...(hasSourceTask
? [
this.item("Open source task", {
type: "open-source" as const,
}),
]
: []),
]
: []),
...(canStop
? [this.separator(), this.item("Stop task", { type: "stop" as const })]
: []),
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/context-menu/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export const taskContextMenuInput = z.object({
isPinned: z.boolean().optional(),
isSuspended: z.boolean().optional(),
canStop: z.boolean().optional(),
canFork: z.boolean().optional(),
hasSourceTask: z.boolean().optional(),
isInCommandCenter: z.boolean().optional(),
hasEmptyCommandCenterCell: z.boolean().optional(),
// Top-level desktop_file_system channels available as "File to…" targets.
Expand Down Expand Up @@ -47,6 +49,8 @@ const taskAction = z.discriminatedUnion("type", [
z.object({ type: z.literal("pin") }),
z.object({ type: z.literal("suspend") }),
z.object({ type: z.literal("stop") }),
z.object({ type: z.literal("fork") }),
z.object({ type: z.literal("open-source") }),
z.object({ type: z.literal("archive") }),
z.object({ type: z.literal("archive-prior") }),
z.object({ type: z.literal("delete") }),
Expand Down
113 changes: 113 additions & 0 deletions packages/core/src/sessions/sessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ type TrpcSubscription = {
export interface SessionTrpc {
agent: {
start: TrpcMutation;
fork: TrpcMutation;
flushLogs: TrpcMutation;
reconnect: TrpcMutation;
cancel: TrpcMutation;
prompt: TrpcMutation;
Expand Down Expand Up @@ -197,6 +199,8 @@ export interface SessionTrpc {
readLocalLogsTail?: TrpcQuery;
fetchS3Logs: TrpcQuery;
writeLocalLogs: TrpcMutation;
seedLocalLogs: TrpcMutation;
cloneLocalLogs: TrpcMutation;
};
os: { openExternal: TrpcMutation };
}
Expand Down Expand Up @@ -1503,6 +1507,115 @@ export class SessionService {
}
}

async forkLocalTask(params: {
sourceTaskId: string;
task: Task;
repoPath: string;
}): Promise<void> {
const source = this.d.store.getSessionByTaskId(params.sourceTaskId);
if (!source || source.isCloud) {
throw new Error("The source task session is not available");
}
if (source.isPromptPending) {
throw new Error("Wait for the source task to finish before forking it");
}

const authStatus = await this.getAuthCredentialsStatus();
if (authStatus.kind !== "ready") {
throw new Error("Unable to reach server. Please check your connection.");
}

const taskRun = await authStatus.auth.client.createTaskRun(params.task.id);
if (!taskRun?.id) {
throw new Error("Failed to create task run. Please try again.");
}
params.task.latest_run = taskRun;
const sourceTaskRunId = source.taskRunId;

await this.d.trpc.agent.flushLogs.mutate({
taskRunId: sourceTaskRunId,
});
await this.d.trpc.logs.cloneLocalLogs.mutate({
sourceTaskRunId,
targetTaskRunId: taskRun.id,
});

params.task.latest_run = await authStatus.auth.client.updateTaskRun(
params.task.id,
taskRun.id,
{
state: {
...taskRun.state,
forked_from_task_id: params.sourceTaskId,
forked_from_run_id: sourceTaskRunId,
},
},
);

const { customInstructions, rtkEnabledLocal, spokenNarrationEnabled } =
this.d.settings;
const result = await this.d.trpc.agent.fork.mutate({
sourceTaskRunId,
taskId: params.task.id,
taskRunId: taskRun.id,
repoPath: params.repoPath,
apiHost: authStatus.auth.apiHost,
projectId: authStatus.auth.projectId,
permissionMode: source.executionMode,
adapter: source.adapter,
customInstructions: customInstructions || undefined,
rtkEnabled: rtkEnabledLocal,
spokenNarration: spokenNarrationEnabled === true,
effort: effortLevelSchema.safeParse(source.reasoningLevel).success
? (source.reasoningLevel as EffortLevel)
: undefined,
model: source.model ?? this.d.DEFAULT_GATEWAY_MODEL,
});
const { rawEntries } = await this.fetchSessionLogs(undefined, taskRun.id);

const session = createBaseSession(
taskRun.id,
params.task.id,
params.task.title,
);
session.channel = result.channel;
session.status = "connected";
session.events =
rawEntries.length > 0
? convertStoredEntriesToEvents(rawEntries)
: [...source.events];
session.adapter = source.adapter;
session.model = source.model;
session.executionMode = source.executionMode;
session.reasoningLevel = source.reasoningLevel;
session.configOptions = result.configOptions as
| SessionConfigOption[]
| undefined;
session.steering = readSteering(result);

if (session.configOptions) {
this.d.setPersistedConfigOptions(taskRun.id, session.configOptions);
}
if (source.adapter) {
this.d.adapterStore.setAdapter(taskRun.id, source.adapter);
}

this.localRepoPaths.set(params.task.id, params.repoPath);
this.d.store.setSession(session);
this.subscribeToChannel(taskRun.id);

this.d.track(ANALYTICS_EVENTS.TASK_RUN_STARTED, {
task_id: params.task.id,
execution_type: "local",
initial_mode: source.executionMode,
adapter: source.adapter,
});
}

getSessionByTaskId(taskId: string): AgentSession | undefined {
return this.d.store.getSessionByTaskId(taskId);
}

async loadLogsOnly(params: {
taskId: string;
taskRunId: string;
Expand Down
Loading