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
3 changes: 3 additions & 0 deletions packages/cli-js/src/commands/implement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { parseFilesFromPlan } from "../utils/plan-files.js";
import { getProjectContext } from "../utils/project-context.js";
import { loadMergedContext } from "../utils/context.js";
import { loadConfig } from "../config/load.js";
import { resolveImplementerStreamTimeoutSec } from "../config/timeout.js";
import { getImplementerRunner } from "../providers/registry.js";

const MAX_CODE_CONTEXT_CHARS = 12000;
Expand Down Expand Up @@ -179,6 +180,7 @@ export async function runImplement(args: string[], opts?: ImplementCliOpts): Pro
const recentCommitsPerFile =
filesToChange.length > 0 ? buildRecentCommitsForFiles(projectRoot, filesToChange) : undefined;

const streamTimeoutSec = resolveImplementerStreamTimeoutSec(config.implementer);
try {
const result = await runner.runImplement(prompt, {
cwd: projectRoot,
Expand All @@ -189,6 +191,7 @@ export async function runImplement(args: string[], opts?: ImplementCliOpts): Pro
projectContext,
projectContextSource,
recentCommitsPerFile,
streamTimeoutMs: streamTimeoutSec === 0 ? 0 : streamTimeoutSec * 1000,
});
const extracted = extractFilesFromOutput(result);
const root = resolve(projectRoot);
Expand Down
3 changes: 3 additions & 0 deletions packages/cli-js/src/commands/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { getProjectContext } from "../utils/project-context.js";
import { loadMergedContext } from "../utils/context.js";
import { fetchUrlsContext } from "../utils/url-fetch.js";
import { loadConfig } from "../config/load.js";
import { resolvePlannerStreamTimeoutSec } from "../config/timeout.js";
import { getPlannerRunner } from "../providers/registry.js";

/** Characters disallowed in filenames on Windows / macOS / Linux */
Expand Down Expand Up @@ -131,13 +132,15 @@ export async function runPlan(args: string[], opts?: PlanCliOpts): Promise<void>
config.planner.provider
);

const streamTimeoutSec = resolvePlannerStreamTimeoutSec(config.planner);
try {
const planBody = await runner.runPlan(goal, {
cwd: projectRoot,
context,
repoContext,
projectContext,
projectContextSource,
streamTimeoutMs: streamTimeoutSec === 0 ? 0 : streamTimeoutSec * 1000,
});
const bodyToWrite = stripFilenameSlugLine(planBody);
let slug: string;
Expand Down
19 changes: 8 additions & 11 deletions packages/cli-js/src/config/load.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,16 @@
/**
* loadConfig: runtime only. Reads planforge.json; throws if missing (no template fallback).
* getDefaultConfig: used only by init and config suggest. Reads templates/config/default-*.json.
* loadConfig: runtime only. Reads planforge.json and merges with template (default-*.json) by installed providers.
* getDefaultConfig: reads templates/config/default-*.json for init, config suggest, and as merge base in loadConfig.
*/

import { existsSync, readFileSync } from "fs";
import fs from "fs-extra";
import { resolve } from "path";
import { getTemplatesRoot } from "../utils/paths.js";
import { checkClaude } from "../providers/claude.js";
import { checkCodex } from "../providers/codex.js";
import type { PlanForgeConfig } from "./types.js";

/** Inline defaults used only when merging partial planforge.json (file exists). Not used when template is required. */
const MERGE_DEFAULTS: PlanForgeConfig = {
planner: { provider: "claude", model: "claude-opus-4-6" },
implementer: { provider: "codex", model: "gpt-5.4" },
};

/**
* Default config when planforge.json is missing. Reads from templates/config/default-*.json.
* Throws if the template file is missing or invalid.
Expand Down Expand Up @@ -66,19 +62,20 @@ export function getDefaultDoctorAiConfig(hasClaude: boolean, hasCodex: boolean):
}

/**
* Load planforge.json for runtime commands (plan, implement, doctor). No template fallback.
* Load planforge.json for runtime commands (plan, implement, doctor). Merges with template (default-*.json) by installed providers.
* Throws if planforge.json is missing; caller should direct user to planforge init.
*/
export async function loadConfig(projectRoot: string): Promise<PlanForgeConfig> {
const configPath = resolve(projectRoot, "planforge.json");
if (!(await fs.pathExists(configPath))) {
throw new Error("planforge.json not found. Run planforge init.");
}
const mergeBase = getDefaultConfig(checkClaude(), checkCodex());
const loaded = (await fs.readJson(configPath)) as Partial<PlanForgeConfig>;
const planner = (loaded.planner ?? {}) as Partial<PlanForgeConfig["planner"]>;
const implementer = (loaded.implementer ?? {}) as Partial<PlanForgeConfig["implementer"]>;
return {
planner: { ...MERGE_DEFAULTS.planner, ...planner, provider: planner.provider ?? MERGE_DEFAULTS.planner.provider },
implementer: { ...MERGE_DEFAULTS.implementer, ...implementer, provider: implementer.provider ?? MERGE_DEFAULTS.implementer.provider },
planner: { ...mergeBase.planner, ...planner, provider: planner.provider ?? mergeBase.planner.provider },
implementer: { ...mergeBase.implementer, ...implementer, provider: implementer.provider ?? mergeBase.implementer.provider },
};
}
37 changes: 37 additions & 0 deletions packages/cli-js/src/config/timeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Resolve stream timeout (seconds) from planner/implementer config.
* 0 means no timeout. When streamTimeoutSec is not set, use effort-based default (planner) or 300 (implementer).
*/

import type { PlanForgeConfig } from "./types.js";

/** Default seconds by planner effort when streamTimeoutSec is not set. */
const PLANNER_EFFORT_DEFAULT_SEC: Record<string, number> = {
high: 300,
medium: 180,
low: 120,
};

const IMPLEMENTER_DEFAULT_SEC = 300;

/**
* Resolve planner stream timeout in seconds. 0 = no timeout.
*/
export function resolvePlannerStreamTimeoutSec(planner: PlanForgeConfig["planner"]): number {
if (planner.streamTimeoutSec !== undefined && planner.streamTimeoutSec !== null) {
return Math.max(0, Number(planner.streamTimeoutSec));
}
const effort = (planner.effort ?? "").toLowerCase();
return PLANNER_EFFORT_DEFAULT_SEC[effort] ?? 120;
}

/**
* Resolve implementer stream timeout in seconds. 0 = no timeout.
*/
export function resolveImplementerStreamTimeoutSec(implementer: PlanForgeConfig["implementer"]): number {
if (implementer.streamTimeoutSec !== undefined && implementer.streamTimeoutSec !== null) {
return Math.max(0, Number(implementer.streamTimeoutSec));
}
const effort = (implementer.effort ?? "").toLowerCase();
return PLANNER_EFFORT_DEFAULT_SEC[effort] ?? IMPLEMENTER_DEFAULT_SEC;
}
4 changes: 2 additions & 2 deletions packages/cli-js/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
*/

export interface PlanForgeConfig {
planner: { provider: string; model: string; effort?: string; reasoning?: string; asciiSlug?: boolean };
implementer: { provider: string; model: string; effort?: string; reasoning?: string };
planner: { provider: string; model: string; effort?: string; reasoning?: string; asciiSlug?: boolean; streamTimeoutSec?: number };
implementer: { provider: string; model: string; effort?: string; reasoning?: string; streamTimeoutSec?: number };
}
37 changes: 22 additions & 15 deletions packages/cli-js/src/providers/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export interface CompleteOneTurnOpts {

interface StreamOpts extends CompleteOneTurnOpts {
writeStdout?: boolean;
/** Stream timeout in ms. 0 or undefined = no timeout. */
streamTimeoutMs?: number;
}

const CLAUDE_ONE_TURN_TIMEOUT_MS = 120_000;
Expand Down Expand Up @@ -162,7 +164,7 @@ export async function runPlan(goal: string, opts?: PlanOpts): Promise<string> {
const fullPrompt = body + "\n\n---\n\nUser goal: " + goal;

try {
return await runClaudeStreaming(fullPrompt, cwd);
return await runClaudeStreaming(fullPrompt, cwd, { streamTimeoutMs: opts?.streamTimeoutMs });
} catch (err) {
const msg = (err as { stdout?: string; stderr?: string; message?: string }).stdout
?? (err as { stderr?: string }).stderr
Expand Down Expand Up @@ -204,7 +206,7 @@ export async function runImplement(prompt: string, opts?: ImplementOpts): Promis
const fullPrompt = body + "\n\n---\n\nUser request: " + prompt;

try {
return await runClaudeStreaming(fullPrompt, cwd);
return await runClaudeStreaming(fullPrompt, cwd, { streamTimeoutMs: opts?.streamTimeoutMs });
} catch (err) {
const msg = (err as { stdout?: string; stderr?: string; message?: string }).stdout
?? (err as { stderr?: string }).stderr
Expand All @@ -221,7 +223,7 @@ export async function runImplement(prompt: string, opts?: ImplementOpts): Promis
function runClaudeStreaming(
fullPrompt: string,
cwd: string,
opts?: Pick<StreamOpts, "writeStdout">,
opts?: Pick<StreamOpts, "writeStdout" | "streamTimeoutMs">,
onChunk?: (chunk: string) => void
): Promise<string> {
const exe = resolveClaudeExe();
Expand All @@ -232,6 +234,8 @@ function runClaudeStreaming(
)
);
}
const timeoutMs = opts?.streamTimeoutMs;
const useTimeout = timeoutMs !== undefined && timeoutMs !== 0;
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
Expand Down Expand Up @@ -264,6 +268,15 @@ function runClaudeStreaming(
process.stderr.write(chunk);
};

const scheduleTimeout = (child: ReturnType<typeof spawn>) => {
if (!useTimeout) return () => {};
const t = setTimeout(() => {
child.kill();
finishReject(`Claude streaming timed out after ${Math.floor(timeoutMs! / 1000)}s`);
}, timeoutMs!);
return () => clearTimeout(t);
};

if (process.platform === "win32") {
const tempPath = join(tmpdir(), "planforge-claude-" + randomBytes(8).toString("hex") + ".txt");
writeFileSync(tempPath, fullPrompt, "utf-8");
Expand All @@ -274,12 +287,9 @@ function runClaudeStreaming(
cwd,
stdio: ["ignore", "pipe", "pipe"],
});
const timeout = setTimeout(() => {
child.kill();
finishReject("Claude streaming timed out after 120s");
}, CLAUDE_ONE_TURN_TIMEOUT_MS);
const clearTimeoutRef = scheduleTimeout(child);
child.on("close", (code) => {
clearTimeout(timeout);
clearTimeoutRef();
try {
unlinkSync(tempPath);
} catch {
Expand All @@ -296,7 +306,7 @@ function runClaudeStreaming(
child.stdout?.on("data", handleStdout);
child.stderr?.on("data", handleStderr);
child.on("error", (err) => {
clearTimeout(timeout);
clearTimeoutRef();
finishReject(err.message);
});
return;
Expand All @@ -313,14 +323,11 @@ function runClaudeStreaming(
}
child.stdin?.end();
});
const timeout = setTimeout(() => {
child.kill();
finishReject("Claude streaming timed out after 120s");
}, CLAUDE_ONE_TURN_TIMEOUT_MS);
const clearTimeoutRef = scheduleTimeout(child);
child.stdout?.on("data", handleStdout);
child.stderr?.on("data", handleStderr);
child.on("close", (code) => {
clearTimeout(timeout);
clearTimeoutRef();
if (settled) return;
if (code !== 0) {
const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
Expand All @@ -330,7 +337,7 @@ function runClaudeStreaming(
finishResolve();
});
child.on("error", (err) => {
clearTimeout(timeout);
clearTimeoutRef();
finishReject(err.message);
});
});
Expand Down
48 changes: 28 additions & 20 deletions packages/cli-js/src/providers/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,13 +172,29 @@ function runCodexExecStreaming(
const exe = resolveCodexExe();
if (!exe) return Promise.reject(new Error(CODEX_NOT_FOUND_MSG));

const timeoutMs = streamOpts?.timeoutMs;
const useTimeout = timeoutMs === undefined ? true : timeoutMs !== 0;
const effectiveMs = timeoutMs === undefined ? CODEX_ONE_TURN_TIMEOUT_MS : timeoutMs === 0 ? 0 : timeoutMs;

return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
const opts = { cwd };
const writeStdout = streamOpts?.writeStdout ?? true;
let settled = false;

const scheduleTimeout = (child: ReturnType<typeof spawn>) => {
if (!useTimeout || effectiveMs === 0) return () => {};
const t = setTimeout(() => {
child.kill();
if (!settled) {
settled = true;
reject(new Error(`Codex streaming timed out after ${Math.floor(effectiveMs / 1000)}s`));
}
}, effectiveMs);
return () => clearTimeout(t);
};

const finish = (code: number | null) => {
if (settled) return;
const out = Buffer.concat(chunks).toString("utf-8").trim();
Expand Down Expand Up @@ -222,15 +238,9 @@ function runCodexExecStreaming(
...opts,
stdio: ["ignore", "pipe", "pipe"],
});
const timeout = setTimeout(() => {
child.kill();
if (!settled) {
settled = true;
reject(new Error(`Codex streaming timed out after ${Math.floor((streamOpts?.timeoutMs ?? CODEX_ONE_TURN_TIMEOUT_MS) / 1000)}s`));
}
}, streamOpts?.timeoutMs ?? CODEX_ONE_TURN_TIMEOUT_MS);
const clearTimeoutRef = scheduleTimeout(child);
child.on("close", (code) => {
clearTimeout(timeout);
clearTimeoutRef();
try {
unlinkSync(tempPath);
} catch {
Expand All @@ -241,7 +251,7 @@ function runCodexExecStreaming(
child.stdout?.on("data", handleStdout);
child.stderr?.on("data", handleStderr);
child.on("error", (err) => {
clearTimeout(timeout);
clearTimeoutRef();
if (!settled) {
settled = true;
reject(err);
Expand All @@ -254,21 +264,15 @@ function runCodexExecStreaming(
...opts,
stdio: ["ignore", "pipe", "pipe"],
});
const timeout = setTimeout(() => {
child.kill();
if (!settled) {
settled = true;
reject(new Error(`Codex streaming timed out after ${Math.floor((streamOpts?.timeoutMs ?? CODEX_ONE_TURN_TIMEOUT_MS) / 1000)}s`));
}
}, streamOpts?.timeoutMs ?? CODEX_ONE_TURN_TIMEOUT_MS);
const clearTimeoutRef = scheduleTimeout(child);
child.stdout?.on("data", handleStdout);
child.stderr?.on("data", handleStderr);
child.on("close", (code) => {
clearTimeout(timeout);
clearTimeoutRef();
finish(code);
});
child.on("error", (err) => {
clearTimeout(timeout);
clearTimeoutRef();
if (!settled) {
settled = true;
reject(err);
Expand Down Expand Up @@ -304,7 +308,9 @@ export async function runPlan(goal: string, opts?: PlanOpts): Promise<string> {
const fullPrompt = body + "\n\n---\n\nUser goal: " + goal;

try {
return await runCodexExecStreaming(fullPrompt, cwd, true);
return await runCodexExecStreaming(fullPrompt, cwd, true, {
timeoutMs: opts?.streamTimeoutMs,
});
} catch (err) {
const msg = (err as { stdout?: string; stderr?: string; message?: string }).stdout
?? (err as { stderr?: string }).stderr
Expand Down Expand Up @@ -346,7 +352,9 @@ export async function runImplement(prompt: string, opts?: ImplementOpts): Promis
const fullPrompt = body + "\n\n---\n\nUser request: " + prompt;

try {
return await runCodexExecStreaming(fullPrompt, cwd);
return await runCodexExecStreaming(fullPrompt, cwd, false, {
timeoutMs: opts?.streamTimeoutMs,
});
} catch (err) {
const msg = (err as { stdout?: string; stderr?: string; message?: string }).stdout
?? (err as { stderr?: string }).stderr
Expand Down
4 changes: 4 additions & 0 deletions packages/cli-js/src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface PlanOpts {
projectContext?: string;
/** Source file for projectContext, e.g. AGENTS.md or CLAUDE.md. */
projectContextSource?: string;
/** Stream timeout in ms. 0 = no timeout. */
streamTimeoutMs?: number;
}

export interface ImplementOpts {
Expand All @@ -33,6 +35,8 @@ export interface ImplementOpts {
projectContextSource?: string;
/** Recent commit (oneline) per file for files to focus on. Capped in size. */
recentCommitsPerFile?: string;
/** Stream timeout in ms. 0 = no timeout. */
streamTimeoutMs?: number;
}

export interface PlannerRunner {
Expand Down
Loading
Loading