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
4 changes: 2 additions & 2 deletions .github/skills/coc-knowledge/references/dashboard-spa.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ spa/client/react/
│ └── terminal/ # Terminal UI: TerminalView, pin/unpin
├── processes/ # Process detail, DAG visualization
├── queue/ # Queue management (EnqueueDialog, QueueView)
├── repos/ # Repository views, file explorer, Monaco editor
├── repos/ # Repository views, clone/add dialogs, file explorer, Monaco editor
├── shared/ # Feature-level shared (MarkdownView, RichTextInput, SourceEditor)
├── tasks/ # Task/plan management, inline comments
├── ui/ # UI primitives (Button, Card, Dialog, Spinner, Badge, Toast)
Expand Down Expand Up @@ -194,4 +194,4 @@ Local React hooks (`fetchApi`, `useWebSocket`, `seenStateApi`) wrap the client f

## Pull Request Suggestions

The Pull Requests tab exposes PR review suggestions behind the `pullRequests.suggestions` config flag. The `For You` filter includes a `Generate suggestions`/`Refresh` action that first refreshes review history, then asks the server to rank open PRs. The UI shows inline progress, empty-state guidance, and recovery messages for missing review history or provider errors.
The Pull Requests tab is enabled by default through `pullRequests.enabled`; PR review suggestions remain behind the separate `pullRequests.suggestions` config flag. The `For You` filter includes a `Generate suggestions`/`Refresh` action that first refreshes review history, then asks the server to rank open PRs. The UI shows inline progress, empty-state guidance, and recovery messages for missing review history or provider errors.
2 changes: 1 addition & 1 deletion .github/skills/coc-knowledge/references/remote-servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ For a persistent Windows service, use:

## Dashboard-side registration

In the CoC dashboard, the Servers view supports two remote server kinds:
In the CoC dashboard, the Servers view is enabled by default through `servers.enabled` and supports two remote server kinds:

- **Direct URL**: stores a fixed `http://` or `https://` CoC server URL.
- **DevTunnel ID**: stores a DevTunnel ID and lets CoC establish a local tunnel connection.
Expand Down
14 changes: 14 additions & 0 deletions .github/skills/coc-knowledge/references/rest-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ CoC server exposes HTTP endpoints organized by domain. All routes are registered
| GET | `/api/fs/browse-helper` | Same-origin helper page for container-mode directory browsing |
| GET | `/api/fs/blob?path=<absolute>` | Read a single file when the absolute path is under CoC trusted data directories or inside any registered workspace/repo root; rejects arbitrary filesystem paths |

## Git

| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/git/clone` | Clone an arbitrary git URL into a parent directory using the server process's git credentials; returns `clonedPath` on success and `{ error }` on clone failure |

## Processes

| Method | Path | Description |
Expand Down Expand Up @@ -180,6 +186,14 @@ Users can add up to **10** additional notes roots per workspace — subfolders i
| GET | `/api/repos/:repoId/pull-requests/suggestions` | Read cached AI-ranked PR suggestions |
| POST | `/api/repos/:repoId/pull-requests/suggestions/refresh` | Rank open PRs using cached review history |

## Diff Classification

| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/repos/:repoId/classify-diff` | Trigger AI hunk classification. Body: `{ type: 'pr'\|'commit'\|'branch-range', identifier, workspaceId?, model?, provider? }`. Returns `{ status: 'started'\|'ready'\|'running', … }`. |
| GET | `/api/repos/:repoId/classify-diff` | Poll for a single classification result. Query: `type`, `identifier`, `workspaceId?`. Returns `{ status: 'none'\|'ready'\|'running', result? }`. |
| GET | `/api/repos/:repoId/classify-diff/batch-status` | Batch-check whether multiple identifiers have a stored result. Query: `type`, `identifiers` (comma-separated, max 200), `workspaceId?`. Returns `{ statuses: { [identifier]: 'none'\|'ready'\|'running' } }`. Read-only — never triggers a new classification task. |

## Loops

See [loops.md](loops.md) for the full subsystem. Gated by `loops.enabled` (default `false`).
Expand Down
2 changes: 1 addition & 1 deletion .github/skills/coc-knowledge/references/sdk-wrapper.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ Claude Code expects hyphenated model IDs for version aliases (for example, `clau

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.
Claude Code permission mode is mapped at the provider boundary: CoC `autopilot` sends `permissionMode: 'bypassPermissions'` plus `allowDangerouslySkipPermissions: true`, CoC `plan` sends `permissionMode: 'plan'`, and all other modes (interactive/ask/undefined) send `permissionMode: 'acceptEdits'`. This ensures ask-mode sessions can create directories and write files within allowed working directories without blocking on permission prompts.

`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
2 changes: 1 addition & 1 deletion packages/coc-agent-sdk/src/claude-sdk-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -949,7 +949,7 @@ export class ClaudeSDKService implements ISDKService {
if (mode === 'plan') {
return { permissionMode: 'plan' };
}
return {};
return { permissionMode: 'acceptEdits' };
}

public async transform<T = string>(
Expand Down
15 changes: 13 additions & 2 deletions packages/coc-agent-sdk/test/ai/claude-sdk-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -781,14 +781,25 @@ describe('ClaudeSDKService.sendMessage', () => {
expect(queryFn.mock.calls[0][0].options.allowDangerouslySkipPermissions).toBeUndefined();
});

it('does not set Claude permission mode for interactive mode', async () => {
it('uses acceptEdits permission mode for interactive mode', async () => {
queryFn.mockReturnValueOnce(makeMessages([
{ type: 'result', subtype: 'success' },
]));

await svc.sendMessage({ prompt: 'answer this', mode: 'interactive' });

expect(queryFn.mock.calls[0][0].options.permissionMode).toBeUndefined();
expect(queryFn.mock.calls[0][0].options.permissionMode).toBe('acceptEdits');
expect(queryFn.mock.calls[0][0].options.allowDangerouslySkipPermissions).toBeUndefined();
});

it('uses acceptEdits permission mode when mode is undefined', async () => {
queryFn.mockReturnValueOnce(makeMessages([
{ type: 'result', subtype: 'success' },
]));

await svc.sendMessage({ prompt: 'answer this' });

expect(queryFn.mock.calls[0][0].options.permissionMode).toBe('acceptEdits');
expect(queryFn.mock.calls[0][0].options.allowDangerouslySkipPermissions).toBeUndefined();
});

Expand Down
6 changes: 3 additions & 3 deletions packages/coc/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,7 +492,7 @@ export const DEFAULT_CONFIG: ResolvedCLIConfig = {
count: 3,
},
askUser: {
enabled: false,
enabled: true,
},
},
serve: {
Expand Down Expand Up @@ -521,11 +521,11 @@ export const DEFAULT_CONFIG: ResolvedCLIConfig = {
enabled: false,
},
pullRequests: {
enabled: false,
enabled: true,
suggestions: false,
},
servers: {
enabled: false,
enabled: true,
},
ralph: {
enabled: false,
Expand Down
4 changes: 2 additions & 2 deletions packages/coc/src/config/namespace-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,12 +241,12 @@ export function createConfigNamespaceRegistry(defaultBundledSkills: readonly str
{
name: 'pullRequests',
sourceDescriptors: [source('pullRequests.', ['pullRequests'], PULL_REQUESTS_SOURCE_KEYS)],
merge: (base, override) => ({ pullRequests: { enabled: override?.pullRequests?.enabled ?? base.pullRequests?.enabled ?? false, suggestions: override?.pullRequests?.suggestions ?? base.pullRequests?.suggestions ?? false } }),
merge: (base, override) => ({ pullRequests: { enabled: override?.pullRequests?.enabled ?? base.pullRequests?.enabled ?? true, suggestions: override?.pullRequests?.suggestions ?? base.pullRequests?.suggestions ?? false } }),
},
{
name: 'servers',
sourceDescriptors: [source('servers.', ['servers'], SERVERS_SOURCE_KEYS)],
merge: (base, override) => ({ servers: { enabled: override?.servers?.enabled ?? base.servers?.enabled ?? false } }),
merge: (base, override) => ({ servers: { enabled: override?.servers?.enabled ?? base.servers?.enabled ?? true } }),
},
{
name: 'ralph',
Expand Down
71 changes: 68 additions & 3 deletions packages/coc/src/server/repos/generic-classification-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,61 @@ export function registerGenericClassificationRoutes(routes: Route[], opts: Gener
},
});

// -- GET: Batch status (must be registered before the single-item GET) ------

routes.push({
method: 'GET',
pattern: /^\/api\/repos\/([^/]+)\/classify-diff\/batch-status$/,
handler: async (req, res, match) => {
try {
const repoId = decodeURIComponent(match![1]);
const url = new URL(req.url ?? '', `http://${req.headers.host ?? 'localhost'}`);
const type = url.searchParams.get('type') as ClassificationType | null;
const rawIdentifiers = url.searchParams.get('identifiers');
const workspaceIdParam = url.searchParams.get('workspaceId');
const workspaceId = workspaceIdParam || repoId;

if (!type || !['pr', 'commit', 'branch-range'].includes(type)) {
return send400(res, 'Missing or invalid query parameter: type');
}
if (!rawIdentifiers) {
return sendJson(res, { statuses: {} });
}

const identifiers = rawIdentifiers
.split(',')
.map(s => s.trim())
.filter(Boolean);

if (identifiers.length > 200) {
return send400(res, 'Too many identifiers: max 200 per request');
}
if (identifiers.length === 0) {
return sendJson(res, { statuses: {} });
}

const statuses: Record<string, 'none' | 'ready' | 'running'> = {};
for (const identifier of identifiers) {
const cached = readClassificationGeneric(dataDir, workspaceId, repoId, type, identifier);
if (cached) {
statuses[identifier] = 'ready';
continue;
}
const pending = readPendingGeneric(dataDir, workspaceId, repoId, type, identifier);
if (pending && isTaskAlive(pending.processId, bridge)) {
statuses[identifier] = 'running';
} else {
statuses[identifier] = 'none';
}
}

sendJson(res, { statuses });
} catch (err) {
send500(res, err instanceof Error ? err.message : String(err));
}
},
});

// -- GET: Poll / get cached result ----------------------------------------

routes.push({
Expand Down Expand Up @@ -322,7 +377,15 @@ function buildDisplayName(type: ClassificationType, identifier: string): string
return `Classify branch range ${identifier}`;
}

/** Extract extra payload fields for backward compat with ClassificationExecutor. */
/**
* Extract extra payload fields for backward compat with ClassificationExecutor.
*
* For non-PR types, `prId` and `headSha` are included using the same two-part
* key scheme as `splitIdentifier` so that `ClassificationExecutor` can resolve
* the classification context and inject the `saveClassification` tool.
* Without these fields the tool guard in the executor is skipped and results
* are never persisted.
*/
function extractPayloadFields(type: ClassificationType, identifier: string): Record<string, string> {
if (type === 'pr') {
const colonIdx = identifier.indexOf(':');
Expand All @@ -332,7 +395,9 @@ function extractPayloadFields(type: ClassificationType, identifier: string): Rec
return { prId: identifier, headSha: 'unknown' };
}
if (type === 'commit') {
return { commitHash: identifier };
// prId/_headSha mirror splitIdentifier so the executor resolves the same store key.
return { commitHash: identifier, prId: '_commit', headSha: identifier };
}
return { branchRange: identifier };
// branch-range
return { branchRange: identifier, prId: '_branch-range', headSha: identifier };
}
104 changes: 104 additions & 0 deletions packages/coc/src/server/routes/api-git-clone-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Git Clone REST API Routes
*
* Endpoint for cloning arbitrary git URLs into a user-selected parent folder.
*/

import * as path from 'path';
import { execFile } from 'child_process';
import { handleAPIError, missingFields } from '../errors';
import { parseBodyOrReject } from '../shared/handler-utils';
import { sendJSON } from '../core/api-handler';
import type { ApiRouteContext } from './api-shared';
import { GIT_MAX_BUFFER } from './api-shared';
import { createRoute } from './route-utils';

interface ExecFileError extends Error {
stdout?: string | Buffer;
stderr?: string | Buffer;
}

function outputToString(output: string | Buffer | undefined): string {
if (Buffer.isBuffer(output)) {
return output.toString('utf8');
}
return output ?? '';
}

function buildCloneErrorMessage(error: ExecFileError, stdout: string | Buffer, stderr: string | Buffer): string {
const output = [outputToString(stderr), outputToString(stdout)]
.map(part => part.trim())
.filter(Boolean)
.join('\n');
return output || error.message;
}

export function deriveDefaultCloneDirectoryName(gitUrl: string): string {
const trimmed = gitUrl.trim().replace(/[?#].*$/, '').replace(/[\/\\]+$/, '');
const lastSeparator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'), trimmed.lastIndexOf(':'));
const lastPart = trimmed.slice(lastSeparator + 1);
return lastPart.endsWith('.git') ? lastPart.slice(0, -4) : lastPart;
}

function cloneRepository(url: string, parentDir: string): Promise<void> {
return new Promise((resolve, reject) => {
execFile(
'git',
['clone', url],
{ cwd: parentDir, maxBuffer: GIT_MAX_BUFFER },
(error, stdout, stderr) => {
if (error) {
const execError = error as ExecFileError;
execError.stdout = execError.stdout ?? stdout;
execError.stderr = execError.stderr ?? stderr;
reject(execError);
return;
}
resolve();
},
);
});
}

export function registerGitCloneRoutes(ctx: ApiRouteContext): void {
const { routes } = ctx;

// POST /api/git/clone — Clone an arbitrary git URL into a parent directory.
routes.push(createRoute({
method: 'POST',
pattern: '/api/git/clone',
handler: async ({ req, res }) => {
const body = await parseBodyOrReject(req, res);
if (body === null) {
return;
}

const missing: string[] = [];
if (typeof body.url !== 'string' || body.url.trim() === '') {
missing.push('url');
}
if (typeof body.parentDir !== 'string' || body.parentDir.trim() === '') {
missing.push('parentDir');
}
if (missing.length > 0) {
return void handleAPIError(res, missingFields(missing));
}

const gitUrl = body.url.trim();
const parentDir = path.resolve(body.parentDir);
const cloneDirName = deriveDefaultCloneDirectoryName(gitUrl);

try {
await cloneRepository(gitUrl, parentDir);
} catch (error) {
const execError = error as ExecFileError;
sendJSON(res, 500, {
error: buildCloneErrorMessage(execError, execError.stdout ?? '', execError.stderr ?? ''),
});
return;
}

return { clonedPath: path.join(parentDir, cloneDirName) };
},
}));
}
2 changes: 2 additions & 0 deletions packages/coc/src/server/routes/api-git-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import { registerGitCommitRoutes } from './api-git-commit-routes';
import { registerGitBranchRangeRoutes } from './api-git-branch-range-routes';
import { registerGitBranchRoutes } from './api-git-branch-routes';
import { registerGitWorkingTreeRoutes } from './api-git-working-tree-routes';
import { registerGitCloneRoutes } from './api-git-clone-routes';

export function registerApiGitRoutes(ctx: ApiRouteContext): void {
registerGitCloneRoutes(ctx);
registerGitCommitRoutes(ctx);
registerGitBranchRangeRoutes(ctx);
registerGitBranchRoutes(ctx);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import type { BranchRangeInfo } from './branches/BranchChanges';
import { buildFixupGroups } from './fixup-utils';
import { rankSkillsByRecency, MRU_SKILL_LIMIT } from './skill-menu-ranking';
import { isGitCommitLookupEnabled } from '../../utils/config';
import { useCommitClassificationStatus } from './hooks/useCommitClassificationStatus';

/**
* Best-effort rebind of commit-chat binding when a hash changes.
Expand Down Expand Up @@ -208,6 +209,17 @@ export function RepoGitTab({ workspaceId }: RepoGitTabProps) {
// Reorder state: pendingReorder holds the new commit order before user confirms
const [pendingReorder, setPendingReorder] = useState<GitCommitItem[] | null>(null);

// Classification status — checked in bulk so each commit row can show a ✓ badge.
const visibleCommitHashes = useMemo(
() => (pendingReorder || commits).map(c => c.hash),
[pendingReorder, commits],
);
const { classifiedHashes, refresh: refreshClassificationStatus } = useCommitClassificationStatus(
workspaceId,
workspaceId,
visibleCommitHashes,
);

const fetchRepoState = useCallback(() => {
getSpaCocClient().git.getRepoState(workspaceId)
.then(data => setRepoState(data))
Expand Down Expand Up @@ -1552,6 +1564,7 @@ export function RepoGitTab({ workspaceId }: RepoGitTabProps) {
isMobileSelecting={isMobileSelecting}
onMobileSelectingChange={handleMobileSelectingChange}
onSwipeAction={handleSwipeAction}
classifiedHashes={classifiedHashes}
/>
);

Expand All @@ -1561,6 +1574,7 @@ export function RepoGitTab({ workspaceId }: RepoGitTabProps) {
workspaceId={workspaceId}
hash={rightPanelView.commit.hash}
commit={rightPanelView.commit}
onClassified={refreshClassificationStatus}
/>
) : rightPanelView?.type === 'commit-file' ? (
<FileDiffPanel
Expand Down
Loading
Loading