Skip to content
Open
52 changes: 47 additions & 5 deletions packages/core/src/services/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,32 @@ export function logCopilotDebug(message: string): void {
process.stderr.write(`[agentrc:copilot] ${message}\n`);
}

/**
* Parse a positive-integer environment variable. Accepts only a base-10
* integer literal (after trimming surrounding whitespace) so surprising
* inputs such as `"1.5"`, `"1e3"`, or `"0x10"` are rejected rather than
* silently coerced. Returns the parsed value when it is a safe integer > 0;
* otherwise returns `undefined` so callers can apply their own default.
*
* An unset, empty, or whitespace-only value is treated as "not configured"
* and returns `undefined` silently. A value that is present and non-blank
* but not a valid positive integer is rejected and logged via
* `logCopilotDebug`, so users running with `AGENTRC_DEBUG_COPILOT=1` can see
* when their override was ignored.
*/
export function parsePositiveIntEnv(name: string): number | undefined {
const raw = process.env[name];
if (raw === undefined) return undefined;
const trimmed = raw.trim();
if (trimmed === "") return undefined;
if (/^\d+$/u.test(trimmed)) {
const parsed = Number(trimmed);
if (Number.isSafeInteger(parsed) && parsed > 0) return parsed;
}
logCopilotDebug(`ignoring invalid ${name}=${JSON.stringify(raw)}`);
return undefined;
}
Comment thread
martooo1936 marked this conversation as resolved.
Comment thread
martooo1936 marked this conversation as resolved.

export type CopilotCliConfig = {
cliPath: string;
cliArgs?: string[];
Expand All @@ -28,6 +54,25 @@ let cachedCliConfig: CopilotCliConfig | null = null;
let cachedCliConfigTimestamp = 0;
const CLI_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes

/**
* Timeout for the `--headless --version` readiness probe. npx may need to
* download `@github/copilot` on first run, so it gets a longer budget; the
* `.bat`-shim / Node cold start on Windows still routinely exceeds 5 s, so
* the non-npx path uses 20 s. Overridable via `AGENTRC_COPILOT_PROBE_TIMEOUT_MS`.
*
* @internal Exported only for unit testing of the selection logic; not part of
* the public API and intentionally excluded from the `@agentrc/core` barrel.
*/
export function getHeadlessProbeTimeoutMs(config: CopilotCliConfig): number {
const override = parsePositiveIntEnv("AGENTRC_COPILOT_PROBE_TIMEOUT_MS");
if (override !== undefined) return override;
const isNpx =
config.cliArgs?.some(
(arg) => arg === "@github/copilot" || arg.startsWith("@github/copilot@")
) ?? false;
return isNpx ? 30000 : 20000;
}
Comment thread
martooo1936 marked this conversation as resolved.
Comment thread
martooo1936 marked this conversation as resolved.
Comment thread
martooo1936 marked this conversation as resolved.
Comment thread
martooo1936 marked this conversation as resolved.

function cacheConfig(config: CopilotCliConfig): CopilotCliConfig {
cachedCliConfig = config;
cachedCliConfigTimestamp = Date.now();
Expand All @@ -40,8 +85,7 @@ export async function assertCopilotCliReady(): Promise<CopilotCliConfig> {
logCopilotDebug(`validating CLI compatibility with ${desc}`);

try {
const isNpx = config.cliArgs?.includes("@github/copilot") ?? false;
const timeout = isNpx ? 30000 : 5000;
const timeout = getHeadlessProbeTimeoutMs(config);
const [cmd, args] = buildExecArgs(config, ["--headless", "--version"]);
await execFileAsync(cmd, args, { timeout });
} catch {
Expand Down Expand Up @@ -285,9 +329,7 @@ async function findFirstCompatibleCandidate(
}

async function isHeadlessCompatible(config: CopilotCliConfig): Promise<boolean> {
// npx may need to download the package on first run, so allow a longer timeout
const isNpx = config.cliArgs?.includes("@github/copilot") ?? false;
const timeout = isNpx ? 30000 : 5000;
const timeout = getHeadlessProbeTimeoutMs(config);
try {
const [cmd, args] = buildExecArgs(config, ["--headless", "--version"]);
await execFileAsync(cmd, args, { timeout });
Expand Down
32 changes: 31 additions & 1 deletion packages/core/src/services/copilotSdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,36 @@ import { buildExecArgs, logCopilotDebug, type CopilotCliConfig } from "./copilot

export type CopilotSdkModule = typeof CopilotSdk;

/**
* Permission response kinds the bundled Copilot CLI 1.0.x accepts.
* Mirrors the SDK 0.3.0 vocabulary (https://github.com/github/copilot-sdk/releases/tag/v0.3.0).
*
* Note: this is the *response* kind sent back from a permission handler,
* not the *request* kind (`"read"`, `"shell"`, ...) the SDK passes in.
*/
export type WirePermissionResponseKind =
| "approve-once"
| "approve-for-session"
| "approve-for-location"
| "reject"
| "user-not-available";

/**
* Build the wire-shape permission response the Copilot CLI expects.
*
* The bundled CLI 1.0.x speaks the SDK 0.3.0+ vocabulary, but agentrc pins
* `@github/copilot-sdk` to ^0.2.0 whose type declarations still describe the
* older `"approved"` / `"denied-..."` kinds. This helper isolates the
* necessary unchecked cast in one place so the rest of the codebase can stay
* strict; remove it and use `{ kind } as const` once the SDK dependency is
* bumped.
*/
export function wirePermissionResponse(
kind: WirePermissionResponseKind
): ReturnType<CopilotSdk.PermissionHandler> {
return { kind } as unknown as ReturnType<CopilotSdk.PermissionHandler>;
}

let cachedSdkModule: Promise<CopilotSdkModule> | null = null;

function normalizeSdkLoadError(error: unknown): Error {
Expand Down Expand Up @@ -156,7 +186,7 @@ export type PatchedCopilotClient = Omit<
export function attachDefaultPermissionHandler(
client: InstanceType<CopilotSdkModule["CopilotClient"]>
): void {
const approveAll: CopilotSdk.PermissionHandler = () => ({ kind: "approved" as const });
const approveAll: CopilotSdk.PermissionHandler = () => wirePermissionResponse("approve-once");
const originalCreateSession = client.createSession.bind(client);
// Override createSession so onPermissionRequest is optional at call sites.
// The cast targets our PatchedCopilotClient createSession signature which
Expand Down
43 changes: 33 additions & 10 deletions packages/core/src/services/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import { ensureDir, safeWriteFile } from "../utils/fs";

import type { Area, InstructionStrategy } from "./analyzer";
import { sanitizeAreaName } from "./analyzer";
import { assertCopilotCliReady } from "./copilot";
import { createCopilotClient, loadCopilotSdk } from "./copilotSdk";
import { assertCopilotCliReady, parsePositiveIntEnv } from "./copilot";
import { createCopilotClient, loadCopilotSdk, wirePermissionResponse } from "./copilotSdk";
import type { FileAction } from "./generator";
import { getSkillDirectory } from "./skills";

Expand Down Expand Up @@ -224,14 +224,27 @@ const INSTRUCTION_GENERATION_EXCLUDED_TOOLS = [
"str_replace_editor"
];

/**
* Upper bound for a single `session.sendAndWait` call during instruction
* generation. Generation drives an agent loop with many tool calls and on
* slower endpoints or larger repos the total run can exceed the SDK's default
* 3-minute budget. This is a ceiling, not an idle wait — fast runs complete
* well under a minute. Overridable via `AGENTRC_INSTRUCTION_TIMEOUT_MS` for
* environments with slow networks or very large repositories.
*
* Implemented as a function (rather than a module-load constant) so test code
* can stub the env var via `vi.stubEnv` after the module has been imported.
*/
function getInstructionGenerationTimeoutMs(): number {
return parsePositiveIntEnv("AGENTRC_INSTRUCTION_TIMEOUT_MS") ?? 600_000;
}

const READ_ONLY_PERMISSION_HANDLER: PermissionHandler = (request) => {
if (request.kind === "read" || request.kind === "custom-tool") {
return { kind: "approved" };
return wirePermissionResponse("approve-once");
}

return {
kind: "denied-no-approval-rule-and-could-not-request-from-user"
};
return wirePermissionResponse("user-not-available");
};
Comment thread
martooo1936 marked this conversation as resolved.

function getSessionError(errorMsg: string): Error {
Expand Down Expand Up @@ -338,6 +351,8 @@ export async function generateCopilotInstructions(
skillDirectories: [rootSkillDir]
});

const timeoutMs = getInstructionGenerationTimeoutMs();

await trySetAutopilot(session);

let content = "";
Expand Down Expand Up @@ -368,7 +383,7 @@ ${existingSection}`;
progress("Analyzing codebase...");
let sendError: unknown;
try {
await session.sendAndWait({ prompt }, 180000);
await session.sendAndWait({ prompt }, timeoutMs);
} catch (err) {
sendError = err;
} finally {
Expand Down Expand Up @@ -439,6 +454,8 @@ export async function generateAreaInstructions(
skillDirectories: [areaSkillDir]
});

const timeoutMs = getInstructionGenerationTimeoutMs();

await trySetAutopilot(session);

let content = "";
Expand Down Expand Up @@ -471,7 +488,7 @@ ${existingSection ? `\nDo NOT duplicate content already covered by existing inst
progress(`Analyzing area "${area.name}"...`);
let sendError: unknown;
try {
await session.sendAndWait({ prompt }, 180000);
await session.sendAndWait({ prompt }, timeoutMs);
} catch (err) {
sendError = err;
} finally {
Expand Down Expand Up @@ -699,6 +716,7 @@ async function generateNestedHub(
area?: Area;
childAreas?: Area[];
model?: string;
timeoutMs: number;
onProgress?: (message: string) => void;
}
): Promise<HubResult> {
Expand Down Expand Up @@ -772,7 +790,7 @@ ${existingSection ? `\nDo NOT duplicate content from existing instruction files\

let sendError: unknown;
try {
await session.sendAndWait({ prompt }, 180000);
await session.sendAndWait({ prompt }, options.timeoutMs);
} catch (err) {
sendError = err;
} finally {
Expand All @@ -799,6 +817,7 @@ async function generateNestedDetail(
topic: NestedTopic;
area?: Area;
model?: string;
timeoutMs: number;
onProgress?: (message: string) => void;
}
): Promise<string> {
Expand Down Expand Up @@ -856,7 +875,7 @@ Description: ${options.topic.description}`;

let sendError: unknown;
try {
await session.sendAndWait({ prompt }, 180000);
await session.sendAndWait({ prompt }, options.timeoutMs);
} catch (err) {
sendError = err;
} finally {
Expand Down Expand Up @@ -895,6 +914,8 @@ export async function generateNestedInstructions(
progress("Starting Copilot SDK...");
const client = await createCopilotClient(cliConfig);

const timeoutMs = getInstructionGenerationTimeoutMs();

try {
// Step 1: Generate hub
const { hubContent, topics } = await generateNestedHub(client, {
Expand All @@ -903,6 +924,7 @@ export async function generateNestedInstructions(
area: options.area,
childAreas: options.childAreas,
model: options.model,
timeoutMs,
onProgress: options.onProgress
});

Expand Down Expand Up @@ -931,6 +953,7 @@ export async function generateNestedInstructions(
topic,
area: options.area,
model: options.model,
timeoutMs,
onProgress: options.onProgress
});
if (detailContent) {
Expand Down
Loading