Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
31b0ada
fix: discover claude models via cli initialize
plusplusoneplusplus May 30, 2026
f88cd15
feat(ralph): zero system-prompt injection (AC-01..AC-05)
plusplusoneplusplus May 30, 2026
d921edf
feat(effort-tiers): AC-01 — add effortLevels.enabled admin config flag
plusplusoneplusplus May 30, 2026
ce3193d
feat(effort-tiers): AC-02 per-provider effort-tier storage and REST e…
plusplusoneplusplus May 30, 2026
129a0cb
feat(effort-tiers): AC-03 — Effort Tiers editor on AI Provider page
plusplusoneplusplus May 30, 2026
fd61253
feat(effort-tiers): AC-04 — Effort Tier selector in new-chat composer
plusplusoneplusplus May 30, 2026
dddd9a9
feat(effort-tiers): AC-05 — Effort Tier selector in follow-up composer
plusplusoneplusplus May 30, 2026
16e6059
fix: add OpenAPI docs for effort-tier endpoints and fix missing mock …
plusplusoneplusplus May 30, 2026
88a589a
feat(work-items): add goal leaf type under PBI
plusplusoneplusplus May 30, 2026
4bc3ef9
feat(work-items): mobile add-child button and type-picker (AC-01 to A…
plusplusoneplusplus May 31, 2026
b5c961e
feat(effort-tiers): hardcoded per-provider defaults with source markers
plusplusoneplusplus May 31, 2026
90df05e
feat: add Reasoning Effort to conversation metadata popover
plusplusoneplusplus May 31, 2026
ad911fc
Make diff classification skill-based
plusplusoneplusplus May 31, 2026
d70a719
Fix admin prompt test after classification prompt removal
plusplusoneplusplus May 31, 2026
7b0e639
fix: propagate provider through cold resume in queue-follow-up
plusplusoneplusplus May 31, 2026
e00e768
Show effort tier model in tooltip
plusplusoneplusplus May 31, 2026
89575c0
Map Codex modes to sandbox settings
plusplusoneplusplus May 31, 2026
2629371
Use Claude Opus 4.7 for Claude provider effort tier defaults
plusplusoneplusplus May 31, 2026
295a563
feat(codex): always grant ~/.coc as a Codex accessible directory
plusplusoneplusplus May 31, 2026
a949a48
test: update Codex sandbox and effort-tier tooltip tests for new beha…
plusplusoneplusplus May 31, 2026
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
2 changes: 2 additions & 0 deletions .github/skills/coc-knowledge/references/dashboard-spa.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ New chats use `AgentSelectorChip` to choose a per-chat provider. The initial sel

`EffortPillSelector` drives the per-turn `reasoningEffort` override (Low/Medium/High; `null` = no override, falls back to the persisted per-model effort then the SDK default). The chip is structurally a dropdown menu (`AgentSelectorChip` style): trigger button (bars icon + label + chevron) opens a popover listbox with `Auto`/`Low`/`Medium`/`High` entries. The `Auto` entry explicitly clears the override and is also what the currently-selected level toggles to when re-clicked. New chats persist the selection alongside the draft (`useDraftStore` → `Draft.effortOverride`). Follow-ups thread the choice through `useSendMessage → ProcessMessageRequest.reasoningEffort → POST /api/processes/:id/message` and into either `bridge.enqueue` (queued) or `bridge.executeFollowUp` (direct/buffered). The server mirrors the value into `task.config.reasoningEffort` via `queue-shared.validateAndParseTask`, so executors see it from a single canonical location.

When effort-tier mode is enabled, `EffortTierSelector` tooltips expose the concrete model and reasoning effort mapped to the selected tier and each configured menu option; empty reasoning effort displays as `Auto`, and unconfigured options remain disabled with an Admin configuration tooltip.

The model-picker chip in both `NewChatArea` and `FollowUpInputArea` mirrors the `AgentSelectorChip` style: icon + label + chevron, no inline `✕` clear. When a `modelOverride` is set, `ModelCommandMenu` renders a `Use default` entry at the top of the dropdown that calls `setModelOverride(null)`; clearing flows through the menu rather than a chip-side button. `NoteChatPanel` reuses the same menu without passing `onClearOverride`, so the clear row only appears in the chat composers.

## Top Bar
Expand Down
6 changes: 5 additions & 1 deletion .github/skills/coc-knowledge/references/sdk-wrapper.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ Each `sendMessage()` call creates its **own `CopilotClient`** child process —

Codex quota and model catalog lookups spawn the `@openai/codex` CLI that ships as a dependency of `@openai/codex-sdk`; the bin path is resolved at runtime relative to `coc-agent-sdk`.

Codex SDK thread options do not expose Copilot's native `skillDirectories` or `disabledSkills` fields. CoC maps resolved skill directories to Codex `additionalDirectories` so external/global skill folders are available to the Codex process. For explicitly selected skills, CoC keeps prompts path-based by adding the resolved `SKILL.md` file paths to the `<selected_skills>` directive rather than inlining skill bodies.
Codex SDK thread options do not expose Copilot's native `skillDirectories` or `disabledSkills` fields. CoC maps resolved skill directories (and any caller-supplied `additionalDirectories`) to Codex `additionalDirectories` so external/global skill folders are available to the Codex process, and always appends `~/.coc` (CoC data/skills dir) so out-of-repo data and skill files remain reachable. The `~/.coc` entry is only added when not already present (compared case-insensitively on Windows); caller-supplied paths are preserved verbatim. For explicitly selected skills, CoC keeps prompts path-based by adding the resolved `SKILL.md` file paths to the `<selected_skills>` directive rather than inlining skill bodies.

Codex permission mode is mapped at the provider boundary with `approvalPolicy: 'never'` for every CoC mode. Interactive/ask mode and omitted mode use `sandboxMode: 'read-only'` with network access disabled. Plan mode uses the same full-access Codex sandbox as autopilot (`sandboxMode: 'danger-full-access'`, network access enabled) and relies on CoC's read-only/plan system prompt rather than Codex sandbox enforcement.

**Thread ↔ session mapping:** Every CoC session ID maps to exactly one Codex thread. The mapping is created on the first `sendMessage()` call for a session and removed on abort or dispose.

Expand Down Expand Up @@ -126,6 +128,8 @@ Claude session persistence uses the Claude Code SDK transcript session ID. New `

Claude Code expects hyphenated model IDs for version aliases (for example, `claude-sonnet-4-6`). `ClaudeSDKService` normalizes CoC's shared dotted Claude registry IDs (`claude-sonnet-4.6`, `claude-haiku-4.5`, `claude-opus-4.6`) to that Claude Code form before passing `options.model` to the SDK. Non-Claude model IDs and `claude-provider-default` are omitted so Claude Code can use its configured default.

Claude model catalog discovery spawns the Claude Code CLI in `stream-json` protocol mode and sends a single `control_request` initialize message, then maps `response.response.models` into `IModelInfo`. The resolver prefers the platform-specific native binary bundled beside `@anthropic-ai/claude-agent-sdk` and falls back to `claude` on `PATH`. Discovery uses `--setting-sources=` and `--tools ''` to avoid loading user/project/local settings or tools; malformed output, spawn errors, timeouts, or protocol changes fall back to the curated Claude model list.

Claude Code permission mode is mapped at the provider boundary: CoC `autopilot` sends `permissionMode: 'bypassPermissions'` plus `allowDangerouslySkipPermissions: true`, while CoC `plan` sends `permissionMode: 'plan'`. Interactive/ask mode leaves Claude Code's default permission behavior in place.

`ClaudeSDKService` widens the agent's filesystem permission scope via the SDK's `additionalDirectories` option (`resolveAdditionalDirectories`). It always grants access to `~/.coc` (CoC data/skills dir) and the system temp directory (`os.tmpdir()`) so out-of-repo skill files and temp artifacts remain readable beyond the per-request `workingDirectory`/`cwd`. Any caller-supplied `SendMessageOptions.additionalDirectories` are merged in; all entries are resolved to absolute paths and de-duplicated (case-insensitively on Windows).
Expand Down
164 changes: 144 additions & 20 deletions packages/coc-agent-sdk/src/claude-sdk-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,13 @@ import { getSDKLogger } from './logger';
import { CocToolRuntime } from './llm-tools/coc-tool-runtime';
import { cocToolBridgeServer } from './llm-tools/bridge-server';
import { buildCocLlmToolsMcpConfig, COC_LLM_TOOLS_MCP_SERVER_NAME } from './llm-tools/mcp-config';
import { spawn } from 'child_process';
import { createRequire } from 'module';
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as readline from 'readline';

// ============================================================================
// @anthropic-ai/claude-agent-sdk type stubs
Expand Down Expand Up @@ -212,6 +216,8 @@ interface ActiveClaudeSession {
// ============================================================================

const CLAUDE_AGENT_SDK_PACKAGE = '@anthropic-ai/claude-agent-sdk';
const CLAUDE_MODEL_DISCOVERY_TIMEOUT_MS = 15_000;
const runtimeRequire = createRequire(__filename);

/**
* Provider for the optional `@anthropic-ai/claude-agent-sdk` package.
Expand Down Expand Up @@ -309,7 +315,7 @@ export class ClaudeSDKService implements ISDKService {
// ── Model discovery ───────────────────────────────────────────────────────

/**
* Return available Claude models from the SDK control API when possible.
* Return available Claude models from Claude Code's stream protocol when possible.
* Falls back to a curated baseline list if dynamic model discovery fails.
*/
public async listModels(): Promise<IModelInfo[]> {
Expand All @@ -325,31 +331,149 @@ export class ClaudeSDKService implements ISDKService {
{ id: 'claude-provider-default', name: 'Claude Provider Default' },
];

const queryFn = this.queryFn;
if (!queryFn) return fallbackModels;

try {
const handle = queryFn({ prompt: '' });
const sdkModels = await handle.supportedModels?.();
await handle.return?.();

if (!sdkModels || sdkModels.length === 0) return fallbackModels;

const mapped = sdkModels
.map(model => {
const value = model.value?.trim();
const displayName = model.displayName?.trim();
if (!value || !displayName) return null;
return { id: value, name: displayName } as IModelInfo;
})
.filter((model): model is IModelInfo => model !== null);

return mapped.length > 0 ? mapped : fallbackModels;
const cliModels = await this.listModelsViaClaudeCli();
return cliModels && cliModels.length > 0 ? cliModels : fallbackModels;
} catch {
return fallbackModels;
}
}

private async listModelsViaClaudeCli(): Promise<IModelInfo[] | null> {
const cli = this.resolveClaudeCliCommand();
return new Promise<IModelInfo[]>((resolve, reject) => {
const child = spawn(cli.command, [
...cli.args,
'--output-format',
'stream-json',
'--verbose',
'--input-format',
'stream-json',
'--setting-sources=',
'--tools',
'',
], {
stdio: ['pipe', 'pipe', 'ignore'],
windowsHide: true,
});

if (!child.stdout || !child.stdin) {
child.kill('SIGTERM');
reject(new Error('Claude CLI did not expose stdio pipes'));
return;
}

const rl = readline.createInterface({ input: child.stdout });
let settled = false;

const cleanup = () => {
if (settled) return;
settled = true;
clearTimeout(timer);
rl.close();
child.stdin?.destroy();
child.kill('SIGTERM');
};

const fail = (err: Error) => {
cleanup();
reject(err);
};

const succeed = (models: IModelInfo[]) => {
cleanup();
resolve(models);
};

const timer = setTimeout(() => {
fail(new Error('Claude model discovery timed out'));
}, CLAUDE_MODEL_DISCOVERY_TIMEOUT_MS);

child.on('error', fail);
child.on('exit', () => {
if (!settled) fail(new Error('Claude CLI exited before returning model metadata'));
});

rl.on('line', (line) => {
let msg: unknown;
try {
msg = JSON.parse(line);
} catch {
return;
}

if (!this.isClaudeInitializeResponse(msg)) return;
const models = this.mapClaudeCliModels(msg.response.response.models);
if (!models) {
fail(new Error('Claude CLI initialize response did not include valid model metadata'));
return;
}
succeed(models);
});

try {
child.stdin.write(JSON.stringify({
type: 'control_request',
request_id: 'init-1',
request: { subtype: 'initialize' },
}) + '\n');
child.stdin.end();
} catch (err) {
fail(err instanceof Error ? err : new Error(String(err)));
}
});
}

private resolveClaudeCliCommand(): { command: string; args: string[] } {
const binaryName = process.platform === 'win32' ? 'claude.exe' : 'claude';
const nativePackageName = `${CLAUDE_AGENT_SDK_PACKAGE}-${process.platform}-${process.arch}`;
try {
const packageJsonPath = runtimeRequire.resolve(`${nativePackageName}/package.json`);
const packageDir = path.dirname(packageJsonPath);
for (const candidate of [
path.join(packageDir, binaryName),
path.join(packageDir, 'bin', binaryName),
]) {
if (fs.existsSync(candidate)) {
return { command: candidate, args: [] };
}
}
} catch {
// Fall through to PATH lookup below.
}

return { command: 'claude', args: [] };
}

private isClaudeInitializeResponse(msg: unknown): msg is {
type: 'control_response';
request_id: 'init-1';
response: { response: { models: unknown } };
} {
if (typeof msg !== 'object' || msg === null) return false;
const record = msg as Record<string, unknown>;
if (record.type !== 'control_response' || record.request_id !== 'init-1') return false;
const response = record.response;
if (typeof response !== 'object' || response === null) return false;
const nested = (response as Record<string, unknown>).response;
return typeof nested === 'object' && nested !== null && 'models' in nested;
}

private mapClaudeCliModels(models: unknown): IModelInfo[] | null {
if (!Array.isArray(models)) return null;
const mapped = models
.map(model => {
if (typeof model !== 'object' || model === null) return null;
const record = model as Record<string, unknown>;
const value = typeof record.value === 'string' ? record.value.trim() : '';
const displayName = typeof record.displayName === 'string' ? record.displayName.trim() : '';
if (!value || !displayName) return null;
return { id: value, name: displayName } as IModelInfo;
})
.filter((model): model is IModelInfo => model !== null);
return mapped.length > 0 ? mapped : null;
}

// ── Account quota from Claude rate-limit events ───────────────────────────

public async getAccountQuota(): Promise<IAccountQuotaResult> {
Expand Down
49 changes: 46 additions & 3 deletions packages/coc-agent-sdk/src/codex-sdk-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { spawn } from 'child_process';
import { createRequire } from 'module';
import * as readline from 'readline';
import * as path from 'path';
import * as os from 'os';

// ============================================================================
// Install mode detection
Expand Down Expand Up @@ -803,15 +804,57 @@ export class CodexSDKService implements ISDKService {

private buildThreadOptions(options: SendMessageOptions): CodexStartThreadOptions {
const model = this.normalizeCodexModel(options.model);
const additionalDirectories = this.resolveCodexAdditionalDirectories(options);
return {
...(model ? { model } : {}),
...(options.workingDirectory ? { workingDirectory: options.workingDirectory } : {}),
...(options.skillDirectories?.length ? { additionalDirectories: options.skillDirectories } : {}),
...(additionalDirectories.length ? { additionalDirectories } : {}),
...(options.reasoningEffort ? { reasoningLevel: options.reasoningEffort } : {}),
skipGitRepoCheck: true,
...this.resolveCodexModeOptions(options.mode),
};
}

/**
* Builds the list of directories Codex may access beyond its working
* directory. Always includes `~/.coc` (CoC's data/skills dir) so out-of-repo
* skill and data files are reachable, plus any caller-provided skill or
* additional directories. Caller-provided paths are preserved verbatim;
* `~/.coc` is only appended when not already present (compared
* case-insensitively on Windows).
*/
private resolveCodexAdditionalDirectories(options: SendMessageOptions): string[] {
const dirs = [
...(options.skillDirectories ?? []),
...(options.additionalDirectories ?? []),
].filter((dir): dir is string => !!dir);

const cocDir = path.join(os.homedir(), '.coc');
const sameDir = (a: string, b: string): boolean => {
const ra = path.resolve(a);
const rb = path.resolve(b);
return process.platform === 'win32' ? ra.toLowerCase() === rb.toLowerCase() : ra === rb;
};
if (!dirs.some(dir => sameDir(dir, cocDir))) {
dirs.push(cocDir);
}
return dirs;
}

private resolveCodexModeOptions(
mode: SendMessageOptions['mode'],
): Pick<CodexStartThreadOptions, 'approvalPolicy' | 'sandboxMode' | 'networkAccessEnabled'> {
if (mode === 'plan' || mode === 'autopilot') {
return {
approvalPolicy: 'never',
sandboxMode: 'danger-full-access',
networkAccessEnabled: true,
};
}
return {
approvalPolicy: 'never',
sandboxMode: 'danger-full-access',
networkAccessEnabled: true,
sandboxMode: 'read-only',
networkAccessEnabled: false,
};
}

Expand Down
Loading
Loading