Skip to content

Commit 2a89612

Browse files
Merge branch 'main' into ryan/upgrade_to_zod_4
2 parents 604696d + c532d71 commit 2a89612

59 files changed

Lines changed: 3711 additions & 1282 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export const startSessionInput = z.object({
4949
additionalDirectories: z.array(z.string()).optional(),
5050
customInstructions: z.string().max(2000).optional(),
5151
effort: effortLevelSchema.optional(),
52+
model: z.string().optional(),
5253
});
5354

5455
export type StartSessionInput = z.infer<typeof startSessionInput>;
@@ -292,3 +293,10 @@ export const getGatewayModelsInput = z.object({
292293
});
293294

294295
export const getGatewayModelsOutput = z.array(modelOptionSchema);
296+
297+
export const getPreviewConfigOptionsInput = z.object({
298+
apiHost: z.string(),
299+
adapter: z.enum(["claude", "codex"]),
300+
});
301+
302+
export const getPreviewConfigOptionsOutput = z.array(sessionConfigOptionSchema);

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

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,16 @@ import {
1414
} from "@agentclientprotocol/sdk";
1515
import { isMcpToolReadOnly } from "@posthog/agent";
1616
import { hydrateSessionJsonl } from "@posthog/agent/adapters/claude/session/jsonl-hydration";
17+
import { getEffortOptions } from "@posthog/agent/adapters/claude/session/models";
1718
import { Agent } from "@posthog/agent/agent";
19+
import { getAvailableModes } from "@posthog/agent/execution-mode";
1820
import {
21+
DEFAULT_GATEWAY_MODEL,
1922
fetchGatewayModels,
2023
formatGatewayModelName,
2124
getProviderName,
25+
isAnthropicModel,
26+
isOpenAIModel,
2227
} from "@posthog/agent/gateway-models";
2328
import { getLlmGatewayUrl } from "@posthog/agent/posthog-api";
2429
import type { OnLogCallback } from "@posthog/agent/types";
@@ -189,6 +194,8 @@ interface SessionConfig {
189194
customInstructions?: string;
190195
/** Effort level for Claude sessions */
191196
effort?: EffortLevel;
197+
/** Model to use for the session (e.g. "claude-sonnet-4-6") */
198+
model?: string;
192199
}
193200

194201
interface ManagedSession {
@@ -465,9 +472,10 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
465472
permissionMode,
466473
customInstructions,
467474
effort,
475+
model,
468476
} = config;
469477

470-
// Preview sessions don't need a real repo — use a temp directory
478+
// Preview config doesn't need a real repo — use a temp directory
471479
const repoPath = taskId === "__preview__" ? tmpdir() : rawRepoPath;
472480

473481
if (!isRetry) {
@@ -638,6 +646,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
638646
sessionId: existingSessionId,
639647
systemPrompt,
640648
...(permissionMode && { permissionMode }),
649+
...(model != null && { model }),
641650
claudeCode: {
642651
options: {
643652
...(additionalDirectories?.length && {
@@ -669,6 +678,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
669678
taskRunId,
670679
systemPrompt,
671680
...(permissionMode && { permissionMode }),
681+
...(model != null && { model }),
672682
claudeCode: {
673683
options: {
674684
...(additionalDirectories?.length && { additionalDirectories }),
@@ -1362,6 +1372,7 @@ For git operations while detached:
13621372
customInstructions:
13631373
"customInstructions" in params ? params.customInstructions : undefined,
13641374
effort: "effort" in params ? params.effort : undefined,
1375+
model: "model" in params ? params.model : undefined,
13651376
};
13661377
}
13671378

@@ -1513,4 +1524,98 @@ For git operations while detached:
15131524
return getModelTier(a.modelId) - getModelTier(b.modelId);
15141525
});
15151526
}
1527+
1528+
async getPreviewConfigOptions(
1529+
apiHost: string,
1530+
adapter: "claude" | "codex" = "claude",
1531+
): Promise<SessionConfigOption[]> {
1532+
const gatewayUrl = getLlmGatewayUrl(apiHost);
1533+
const gatewayModels = await fetchGatewayModels({ gatewayUrl });
1534+
1535+
const modelFilter = adapter === "codex" ? isOpenAIModel : isAnthropicModel;
1536+
1537+
const modelOptions = gatewayModels
1538+
.filter((model) => modelFilter(model))
1539+
.map((model) => ({
1540+
value: model.id,
1541+
name: formatGatewayModelName(model),
1542+
description: `Context: ${model.context_window.toLocaleString()} tokens`,
1543+
}));
1544+
1545+
const defaultModel =
1546+
adapter === "codex"
1547+
? (modelOptions[0]?.value ?? "")
1548+
: DEFAULT_GATEWAY_MODEL;
1549+
1550+
const resolvedModelId = modelOptions.some((o) => o.value === defaultModel)
1551+
? defaultModel
1552+
: (modelOptions[0]?.value ?? defaultModel);
1553+
1554+
if (!modelOptions.some((o) => o.value === resolvedModelId)) {
1555+
modelOptions.unshift({
1556+
value: resolvedModelId,
1557+
name: resolvedModelId,
1558+
description: "Custom model",
1559+
});
1560+
}
1561+
1562+
const modeOptions = getAvailableModes().map((mode) => ({
1563+
value: mode.id,
1564+
name: mode.name,
1565+
description: mode.description ?? undefined,
1566+
}));
1567+
1568+
const configOptions: SessionConfigOption[] = [
1569+
{
1570+
id: "mode",
1571+
name: "Approval Preset",
1572+
type: "select",
1573+
currentValue: "plan",
1574+
options: modeOptions,
1575+
category: "mode",
1576+
description:
1577+
"Choose an approval and sandboxing preset for your session",
1578+
},
1579+
{
1580+
id: "model",
1581+
name: "Model",
1582+
type: "select",
1583+
currentValue: resolvedModelId,
1584+
options: modelOptions,
1585+
category: "model",
1586+
description: "Choose which model Claude should use",
1587+
},
1588+
];
1589+
1590+
if (adapter === "codex") {
1591+
configOptions.push({
1592+
id: "reasoning_effort",
1593+
name: "Reasoning Level",
1594+
type: "select",
1595+
currentValue: "high",
1596+
options: [
1597+
{ value: "low", name: "Low" },
1598+
{ value: "medium", name: "Medium" },
1599+
{ value: "high", name: "High" },
1600+
],
1601+
category: "thought_level",
1602+
description: "Controls how much reasoning effort the model uses",
1603+
});
1604+
} else {
1605+
const effortOpts = getEffortOptions(resolvedModelId);
1606+
if (effortOpts) {
1607+
configOptions.push({
1608+
id: "effort",
1609+
name: "Effort",
1610+
type: "select",
1611+
currentValue: "high",
1612+
options: effortOpts,
1613+
category: "thought_level",
1614+
description: "Controls how much effort Claude puts into its response",
1615+
});
1616+
}
1617+
}
1618+
1619+
return configOptions;
1620+
}
15161621
}

apps/code/src/main/services/git/create-pr-saga.ts

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -66,25 +66,28 @@ export class CreatePrSaga extends Saga<CreatePrSagaInput, CreatePrSagaOutput> {
6666
let { commitMessage, prTitle, prBody } = input;
6767

6868
if (input.branchName) {
69-
this.deps.onProgress(
70-
"creating-branch",
71-
`Creating branch ${input.branchName}...`,
69+
const currentBranch = await this.readOnlyStep("get-original-branch", () =>
70+
this.deps.getCurrentBranch(directoryPath),
7271
);
7372

74-
const originalBranch = await this.readOnlyStep(
75-
"get-original-branch",
76-
() => this.deps.getCurrentBranch(directoryPath),
77-
);
73+
// on retry, do not attempt to re-create the branch
74+
if (currentBranch !== input.branchName) {
75+
this.deps.onProgress(
76+
"creating-branch",
77+
`Creating branch ${input.branchName}...`,
78+
);
7879

79-
await this.step({
80-
name: "creating-branch",
81-
execute: () => this.deps.createBranch(directoryPath, input.branchName!),
82-
rollback: async () => {
83-
if (originalBranch) {
84-
await this.deps.checkoutBranch(directoryPath, originalBranch);
85-
}
86-
},
87-
});
80+
await this.step({
81+
name: "creating-branch",
82+
execute: () =>
83+
this.deps.createBranch(directoryPath, input.branchName!),
84+
rollback: async () => {
85+
if (currentBranch) {
86+
await this.deps.checkoutBranch(directoryPath, currentBranch);
87+
}
88+
},
89+
});
90+
}
8891
}
8992

9093
const changedFiles = await this.readOnlyStep("check-changes", () =>

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
cancelSessionInput,
99
getGatewayModelsInput,
1010
getGatewayModelsOutput,
11+
getPreviewConfigOptionsInput,
12+
getPreviewConfigOptionsOutput,
1113
listSessionsInput,
1214
listSessionsOutput,
1315
notifySessionContextInput,
@@ -193,4 +195,11 @@ export const agentRouter = router({
193195
.input(getGatewayModelsInput)
194196
.output(getGatewayModelsOutput)
195197
.query(({ input }) => getService().getGatewayModels(input.apiHost)),
198+
199+
getPreviewConfigOptions: publicProcedure
200+
.input(getPreviewConfigOptionsInput)
201+
.output(getPreviewConfigOptionsOutput)
202+
.query(({ input }) =>
203+
getService().getPreviewConfigOptions(input.apiHost, input.adapter),
204+
),
196205
});

apps/code/src/main/utils/fixPath.ts

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,24 @@
33
* (/usr/bin:/bin:/usr/sbin:/sbin) instead of the user's shell PATH which
44
* includes /opt/homebrew/bin, ~/.local/bin, etc.
55
*
6-
* This reads the PATH from the user's default shell (in interactive login mode)
7-
* and applies it to process.env.PATH so child processes have access to
6+
* This reads the PATH from the user's default shell (in login mode) and
7+
* applies it to process.env.PATH so child processes have access to
88
* user-installed binaries.
9+
*
10+
* IMPORTANT: We use `-lc` (login, non-interactive) instead of `-ilc`
11+
* (interactive login) to avoid loading the user's full .zshrc which may
12+
* include heavy plugins (Oh My Zsh, NVM, thefuck, etc.) that spawn
13+
* subprocesses and cause zombie process chains when the timeout kills
14+
* only the parent shell.
15+
*
16+
* See: https://github.com/PostHog/code/issues/1399
917
*/
1018

11-
import { execSync } from "node:child_process";
19+
import { spawnSync } from "node:child_process";
20+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1221
import { userInfo } from "node:os";
22+
import { dirname, join } from "node:path";
23+
import { app } from "electron";
1324

1425
const DELIMITER = "_SHELL_ENV_DELIMITER_";
1526

@@ -25,6 +36,9 @@ const ANSI_REGEX =
2536
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional for ANSI stripping
2637
/[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))/g;
2738

39+
/** Max age of cached PATH before re-resolving (1 hour) */
40+
const CACHE_MAX_AGE_MS = 60 * 60 * 1000;
41+
2842
function stripAnsi(str: string): string {
2943
return str.replace(ANSI_REGEX, "");
3044
}
@@ -50,20 +64,75 @@ function detectDefaultShell(): string {
5064
return process.env.SHELL || "/bin/sh";
5165
}
5266

67+
function getCachePath(): string {
68+
return join(app.getPath("userData"), "shell-env-cache.json");
69+
}
70+
71+
function readCachedPath(): string | undefined {
72+
try {
73+
const cachePath = getCachePath();
74+
if (!existsSync(cachePath)) {
75+
return undefined;
76+
}
77+
78+
const raw = readFileSync(cachePath, "utf-8");
79+
const cache = JSON.parse(raw) as { path: string; timestamp: number };
80+
81+
if (Date.now() - cache.timestamp > CACHE_MAX_AGE_MS) {
82+
return undefined;
83+
}
84+
85+
return cache.path;
86+
} catch {
87+
return undefined;
88+
}
89+
}
90+
91+
function writeCachedPath(resolvedPath: string): void {
92+
try {
93+
const cachePath = getCachePath();
94+
const dir = dirname(cachePath);
95+
if (!existsSync(dir)) {
96+
mkdirSync(dir, { recursive: true });
97+
}
98+
writeFileSync(
99+
cachePath,
100+
JSON.stringify({ path: resolvedPath, timestamp: Date.now() }),
101+
"utf-8",
102+
);
103+
} catch {
104+
// Cache write failure is non-fatal
105+
}
106+
}
107+
53108
function executeShell(shell: string): string | undefined {
54109
const command = `echo -n "${DELIMITER}"; env; echo -n "${DELIMITER}"; exit`;
55110

56111
try {
57-
return execSync(`${shell} -ilc '${command}'`, {
112+
const result = spawnSync(shell, ["-lc", command], {
58113
encoding: "utf-8",
59114
timeout: 5000,
60115
stdio: ["ignore", "pipe", "ignore"],
116+
// Kill the entire process group on timeout, not just the parent shell.
117+
// This prevents orphaned children (node -v, printf, tail, sed) from
118+
// surviving as zombies.
119+
killSignal: "SIGKILL",
61120
env: {
62121
...process.env,
63122
// Disable Oh My Zsh auto-update which can block
64123
DISABLE_AUTO_UPDATE: "true",
124+
// Signal to user's shell config that we're resolving the environment.
125+
// Users with heavy configs can check this and fast-exit:
126+
// [[ -n "$POSTHOG_CODE_RESOLVING_ENVIRONMENT" ]] && return
127+
POSTHOG_CODE_RESOLVING_ENVIRONMENT: "1",
65128
},
66129
});
130+
131+
if (result.status !== 0 && !result.stdout) {
132+
return undefined;
133+
}
134+
135+
return result.stdout || undefined;
67136
} catch {
68137
return undefined;
69138
}
@@ -110,11 +179,20 @@ export function fixPath(): void {
110179
return;
111180
}
112181

182+
// Try cached PATH first (instant, no shell spawn)
183+
const cached = readCachedPath();
184+
if (cached) {
185+
process.env.PATH = cached;
186+
return;
187+
}
188+
113189
const shell = detectDefaultShell();
114190
const shellPath = getShellPath(shell);
115191

116192
if (shellPath) {
117-
process.env.PATH = stripAnsi(shellPath);
193+
const cleaned = stripAnsi(shellPath);
194+
process.env.PATH = cleaned;
195+
writeCachedPath(cleaned);
118196
} else {
119197
process.env.PATH = buildFallbackPath();
120198
}

0 commit comments

Comments
 (0)