Skip to content
Merged

Dev #11

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: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ on:

env:
NODE_VERSION_PRIMARY: '24'
NODE_VERSION_SERVER: '22'

jobs:
# ── Lint ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -188,7 +189,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION_PRIMARY }}
node-version: ${{ env.NODE_VERSION_SERVER }}
cache: 'npm'
cache-dependency-path: package-lock.json
- run: npm ci
Expand Down
61 changes: 56 additions & 5 deletions shared/supervision-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ export const SUPERVISION_DEFAULT_TIMEOUT_MS = 12_000;
export const SUPERVISION_DEFAULT_MAX_PARSE_RETRIES = 1;
export const SUPERVISION_DEFAULT_AUDIT_MODE: SupervisionAuditMode = 'audit';
export const SUPERVISION_DEFAULT_MAX_AUDIT_LOOPS = 2;
export const SUPERVISION_DEFAULT_MAX_AUTO_CONTINUE_STREAK = 2;
export const SUPERVISION_DEFAULT_MAX_AUTO_CONTINUE_TOTAL = 0;
export const SUPERVISION_DEFAULT_PROMPT_VERSION = SUPERVISION_CONTRACT_IDS.DECISION;
export const SUPERVISION_DEFAULT_TASK_RUN_PROMPT_VERSION = SUPERVISION_CONTRACT_IDS.TASK_RUN_STATUS;

Expand Down Expand Up @@ -107,6 +109,8 @@ export type SessionSupervisionSnapshotIssue =
| 'invalid_global_custom_instructions'
| 'invalid_preset'
| 'invalid_max_parse_retries'
| 'invalid_max_auto_continue_streak'
| 'invalid_max_auto_continue_total'
| 'missing_audit_mode'
| 'invalid_audit_mode'
| 'invalid_max_audit_loops'
Expand All @@ -126,6 +130,8 @@ export interface SupervisorDefaultConfig {
model: string;
timeoutMs: number;
promptVersion: string;
maxAutoContinueStreak: number;
maxAutoContinueTotal: number;
/**
* Optional global supervision custom instructions. Free text appended to the
* supervisor prompt for every Auto-enabled session that does not set
Expand Down Expand Up @@ -165,6 +171,8 @@ export interface SessionSupervisionSnapshot extends SupervisorDefaultConfig {
*/
globalCustomInstructions?: string;
maxParseRetries: number;
maxAutoContinueStreak: number;
maxAutoContinueTotal: number;
auditMode: SupervisionAuditMode;
maxAuditLoops: number;
taskRunPromptVersion: string;
Expand All @@ -186,6 +194,12 @@ function normalizePositiveInteger(value: unknown, fallback: number, minimum = 1)
return int >= minimum ? int : fallback;
}

function normalizeNonNegativeInteger(value: unknown, fallback: number): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
const int = Math.floor(value);
return int >= 0 ? int : fallback;
}

export function isSupportedSupervisionBackend(value: string | null | undefined): value is SharedContextRuntimeBackend {
const trimmed = trimString(value);
return !!trimmed && SUPERVISION_SUPPORTED_BACKENDS.includes(trimmed as SharedContextRuntimeBackend);
Expand Down Expand Up @@ -238,6 +252,8 @@ export function normalizeSupervisorDefaultConfig(
model,
timeoutMs: normalizePositiveInteger(merged.timeoutMs, SUPERVISION_DEFAULT_TIMEOUT_MS, 1),
promptVersion: trimString(merged.promptVersion) ?? SUPERVISION_DEFAULT_PROMPT_VERSION,
maxAutoContinueStreak: normalizeNonNegativeInteger(merged.maxAutoContinueStreak, SUPERVISION_DEFAULT_MAX_AUTO_CONTINUE_STREAK),
maxAutoContinueTotal: normalizeNonNegativeInteger(merged.maxAutoContinueTotal, SUPERVISION_DEFAULT_MAX_AUTO_CONTINUE_TOTAL),
...(customInstructions ? { customInstructions } : {}),
...(preset ? { preset } : {}),
};
Expand Down Expand Up @@ -296,17 +312,46 @@ export function getSessionSupervisionSnapshotIssues(
if (record.globalCustomInstructions != null && typeof record.globalCustomInstructions !== 'string') {
issues.push('invalid_global_custom_instructions');
}
if (typeof record.maxParseRetries !== 'number' || !Number.isFinite(record.maxParseRetries) || Math.floor(record.maxParseRetries) < 1) {
if (
record.maxParseRetries != null
&& (typeof record.maxParseRetries !== 'number' || !Number.isFinite(record.maxParseRetries) || Math.floor(record.maxParseRetries) < 1)
) {
issues.push('invalid_max_parse_retries');
}
if (
record.maxAutoContinueStreak != null
&& (
typeof record.maxAutoContinueStreak !== 'number'
|| !Number.isFinite(record.maxAutoContinueStreak)
|| Math.floor(record.maxAutoContinueStreak) < 0
)
) {
issues.push('invalid_max_auto_continue_streak');
}
if (
record.maxAutoContinueTotal != null
&& (
typeof record.maxAutoContinueTotal !== 'number'
|| !Number.isFinite(record.maxAutoContinueTotal)
|| Math.floor(record.maxAutoContinueTotal) < 0
)
) {
issues.push('invalid_max_auto_continue_total');
}

if (mode === SUPERVISION_MODE.SUPERVISED_AUDIT) {
if (record.auditMode == null || record.auditMode === '') issues.push('missing_audit_mode');
else if (!isSupportedSupervisionAuditMode(String(record.auditMode))) issues.push('invalid_audit_mode');
if (typeof record.maxAuditLoops !== 'number' || !Number.isFinite(record.maxAuditLoops) || Math.floor(record.maxAuditLoops) < 1) {
if (record.auditMode != null && record.auditMode !== '' && !isSupportedSupervisionAuditMode(String(record.auditMode))) {
issues.push('invalid_audit_mode');
}
if (
record.maxAuditLoops != null
&& (typeof record.maxAuditLoops !== 'number' || !Number.isFinite(record.maxAuditLoops) || Math.floor(record.maxAuditLoops) < 1)
) {
issues.push('invalid_max_audit_loops');
}
if (!trimString(record.taskRunPromptVersion)) issues.push('invalid_task_run_prompt_version');
if (record.taskRunPromptVersion != null && !trimString(record.taskRunPromptVersion)) {
issues.push('invalid_task_run_prompt_version');
}
}

return issues;
Expand All @@ -329,6 +374,8 @@ export function normalizeSessionSupervisionSnapshot(
: false;
const globalCustomInstructions = trimString(merged.globalCustomInstructions);
const maxParseRetries = normalizePositiveInteger(merged.maxParseRetries, SUPERVISION_DEFAULT_MAX_PARSE_RETRIES, 1);
const maxAutoContinueStreak = normalizeNonNegativeInteger(merged.maxAutoContinueStreak, SUPERVISION_DEFAULT_MAX_AUTO_CONTINUE_STREAK);
const maxAutoContinueTotal = normalizeNonNegativeInteger(merged.maxAutoContinueTotal, SUPERVISION_DEFAULT_MAX_AUTO_CONTINUE_TOTAL);
const auditMode = isSupportedSupervisionAuditMode(merged.auditMode) ? merged.auditMode : SUPERVISION_DEFAULT_AUDIT_MODE;
const maxAuditLoops = normalizePositiveInteger(merged.maxAuditLoops, SUPERVISION_DEFAULT_MAX_AUDIT_LOOPS, 1);
return {
Expand All @@ -340,6 +387,8 @@ export function normalizeSessionSupervisionSnapshot(
...(customInstructionsOverride ? { customInstructionsOverride: true } : {}),
...(globalCustomInstructions ? { globalCustomInstructions } : {}),
maxParseRetries,
maxAutoContinueStreak,
maxAutoContinueTotal,
auditMode,
maxAuditLoops,
taskRunPromptVersion: trimString(merged.taskRunPromptVersion) ?? SUPERVISION_DEFAULT_TASK_RUN_PROMPT_VERSION,
Expand Down Expand Up @@ -479,6 +528,8 @@ export const TASK_RUN_PROMPT_VERSION = SUPERVISION_DEFAULT_TASK_RUN_PROMPT_VERSI
export const DEFAULT_SUPERVISION_AUDIT_MODE = SUPERVISION_DEFAULT_AUDIT_MODE;
export const DEFAULT_SUPERVISION_MAX_AUDIT_LOOPS = SUPERVISION_DEFAULT_MAX_AUDIT_LOOPS;
export const DEFAULT_SUPERVISION_MAX_PARSE_RETRIES = SUPERVISION_DEFAULT_MAX_PARSE_RETRIES;
export const DEFAULT_SUPERVISION_MAX_AUTO_CONTINUE_STREAK = SUPERVISION_DEFAULT_MAX_AUTO_CONTINUE_STREAK;
export const DEFAULT_SUPERVISION_MAX_AUTO_CONTINUE_TOTAL = SUPERVISION_DEFAULT_MAX_AUTO_CONTINUE_TOTAL;

export function parseTaskRunTerminalStateDetailsFromText(text: string): ParsedTaskRunTerminalState {
const matches = [...text.matchAll(/<!--\s*IMCODES_TASK_RUN:\s*(COMPLETE|NEEDS_INPUT|BLOCKED)\s*-->/g)];
Expand Down
28 changes: 8 additions & 20 deletions src/agent/providers/claude-code-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import type { ProviderContextPayload } from '../../../shared/context-types.js';
import type { TransportAttachment } from '../../../shared/transport-attachments.js';
import logger from '../../util/logger.js';
import { CLAUDE_SDK_EFFORT_LEVELS, type TransportEffortLevel } from '../../../shared/effort-levels.js';
import { normalizeTransportCwd, resolveExecutableForSpawn } from '../transport-paths.js';
import { normalizeTransportCwd, resolveClaudeCodePathForSdk, resolveExecutableForSpawn } from '../transport-paths.js';

const CLAUDE_BIN = 'claude';
const DEFAULT_PERMISSION_MODE: PermissionMode = 'bypassPermissions';
Expand Down Expand Up @@ -114,7 +114,7 @@ export class ClaudeCodeSdkProvider implements TransportProvider {
private statusCallbacks: Array<(sessionId: string, status: ProviderStatusUpdate) => void> = [];

async connect(config: ProviderConfig): Promise<void> {
const binaryPath = this.resolveBinaryPath(config);
const binaryPath = this.getConfiguredBinaryPath(config);
const resolved = resolveExecutableForSpawn(binaryPath);
await access(resolved.executable, fsConstants.X_OK).catch(async () => {
const { execFile } = await import('node:child_process');
Expand Down Expand Up @@ -300,11 +300,7 @@ export class ClaudeCodeSdkProvider implements TransportProvider {
} : {}),
};
options.spawnClaudeCodeProcess = (req: { command: string; args: string[]; cwd?: string; env?: Record<string, string>; signal?: AbortSignal }) => {
const finalCommand = this.windowsSpawnOverride?.executable ?? req.command;
const finalArgs = this.windowsSpawnOverride
? [...this.windowsSpawnOverride.prependArgs, ...req.args]
: req.args;
const child = spawn(finalCommand, finalArgs, {
const child = spawn(req.command, req.args, {
cwd: req.cwd,
env: req.env,
signal: req.signal,
Expand Down Expand Up @@ -575,21 +571,13 @@ export class ClaudeCodeSdkProvider implements TransportProvider {
return DEFAULT_PERMISSION_MODE;
}

private getConfiguredBinaryPath(config: ProviderConfig | null): string {
return typeof config?.binaryPath === 'string' && config.binaryPath.trim() ? config.binaryPath : CLAUDE_BIN;
}

private resolveBinaryPath(config: ProviderConfig | null): string {
const raw = typeof config?.binaryPath === 'string' && config.binaryPath.trim() ? config.binaryPath : CLAUDE_BIN;
// For windows + .cmd shim, the SDK can't spawn this directly.
// We pass it to the SDK as a marker, then override spawn via
// spawnClaudeCodeProcess (see send()).
if (process.platform === 'win32') {
const resolved = resolveExecutableForSpawn(raw);
this.windowsSpawnOverride = resolved.prependArgs.length > 0
? { executable: resolved.executable, prependArgs: resolved.prependArgs }
: null;
return resolved.executable;
}
return raw;
return resolveClaudeCodePathForSdk(this.getConfiguredBinaryPath(config));
}
private windowsSpawnOverride: { executable: string; prependArgs: string[] } | null = null;

private emitSessionInfo(sessionId: string, info: SessionInfoUpdate): void {
for (const cb of this.sessionInfoCallbacks) cb(sessionId, info);
Expand Down
49 changes: 48 additions & 1 deletion src/agent/transport-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ export function resolveBinaryOnWindows(name: string): string {
// OS delimiter (':' on Linux), which breaks tests that fake
// `process.platform = 'win32'` on a posix CI runner.
const WIN_DELIMITER = ';';
const pathDirs = (process.env.PATH ?? '').split(WIN_DELIMITER).filter(Boolean);
const pathDirs = uniqueNonEmpty([
...(process.env.PATH ?? '').split(WIN_DELIMITER),
...getWindowsGlobalCliDirs(),
]);
const pathExtRaw = process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD';
const exts = pathExtRaw.split(WIN_DELIMITER).filter(Boolean);
const hasExt = exts.some((e) => name.toLowerCase().endsWith(e.toLowerCase()));
Expand All @@ -50,6 +53,33 @@ export function resolveBinaryOnWindows(name: string): string {
return name;
}

function uniqueNonEmpty(values: Array<string | undefined>): string[] {
return [...new Set(values.filter((value): value is string => typeof value === 'string' && value.trim().length > 0))];
}

function getWindowsGlobalCliDirs(): string[] {
return uniqueNonEmpty([
process.env.APPDATA ? path.join(process.env.APPDATA, 'npm') : undefined,
process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'AppData', 'Roaming', 'npm') : undefined,
]);
}

function getWindowsClaudeInstallCandidates(name: string): string[] {
const basename = path.basename(name);
const hasExt = /\.[^\\/]+$/.test(basename);
const fileNames = hasExt ? [basename] : [basename, `${basename}.exe`, `${basename}.cmd`, `${basename}.bat`];
const dirs = uniqueNonEmpty([
...getWindowsGlobalCliDirs(),
process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'Programs', 'Claude') : undefined,
process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'Programs', 'Claude Code') : undefined,
process.env.ProgramFiles ? path.join(process.env.ProgramFiles, 'Claude') : undefined,
process.env.ProgramFiles ? path.join(process.env.ProgramFiles, 'Claude Code') : undefined,
process.env['ProgramFiles(x86)'] ? path.join(process.env['ProgramFiles(x86)'], 'Claude') : undefined,
process.env['ProgramFiles(x86)'] ? path.join(process.env['ProgramFiles(x86)'], 'Claude Code') : undefined,
]);
return dirs.flatMap((dir) => fileNames.map((fileName) => path.join(dir, fileName)));
}

export function resolveBinaryWithWindowsFallbacks(name: string, windowsCandidates: string[] = []): string {
if (process.platform !== 'win32') return name;
for (const candidate of windowsCandidates) {
Expand All @@ -58,6 +88,23 @@ export function resolveBinaryWithWindowsFallbacks(name: string, windowsCandidate
return resolveBinaryOnWindows(name);
}

/** Resolve a CLI path suitable for passing to an SDK option like
* `pathToClaudeCodeExecutable`.
*
* On Windows, npm global installs usually expose `claude.cmd`, but SDKs that
* spawn the path directly without `shell: true` need either a real `.exe` or
* the underlying `.js` entrypoint. This helper converts npm shims to their
* script path and also searches common per-user install locations when the
* daemon service PATH is sparse. */
export function resolveClaudeCodePathForSdk(name = 'claude'): string {
if (process.platform !== 'win32') return name;
const resolved = resolveBinaryWithWindowsFallbacks(name, getWindowsClaudeInstallCandidates(name));
if (/\.(cmd|bat)$/i.test(resolved)) {
return parseNpmCmdShim(resolved) ?? resolved;
}
return resolved;
}

/** Result of resolving a binary that may be an npm .cmd shim.
* When the resolved path is a real .exe, just `{ executable }`.
* When it's a Windows .cmd shim, returns the underlying node script so
Expand Down
7 changes: 6 additions & 1 deletion src/context/summary-compressor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { TransportProvider, ProviderError } from '../agent/transport-provid
import type { AgentMessage } from '../../shared/agent-message.js';
import { randomUUID } from 'node:crypto';
import logger from '../util/logger.js';
import { resolveClaudeCodePathForSdk } from '../agent/transport-paths.js';
import {
resolveProcessingProviderSessionConfig,
type ProcessingBackendSelection as CompressionBackendSelection,
Expand Down Expand Up @@ -482,9 +483,13 @@ async function sendViaSdkQuery(prompt: string): Promise<string> {
delete process.env.CLAUDECODE;
try {
let result = '';
const pathToClaudeCodeExecutable = resolveClaudeCodePathForSdk();
for await (const msg of query({
prompt: COMPRESSOR_SYSTEM_PROMPT + '\n\n' + prompt,
options: { maxTurns: 1 },
options: {
maxTurns: 1,
pathToClaudeCodeExecutable,
},
})) {
if (msg.type === 'assistant') {
const content = (msg as { message?: { content?: unknown } }).message?.content;
Expand Down
Loading
Loading