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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,24 @@ To use Claude Code's full functionality, you'll need to manually enable tools:

**Recommended approach**: Start with basic tools enabled and add more as needed. You can always adjust these settings later.

### Codex Permission Defaults

Codex chat supports three permission modes:

- `default` — uses Codex's `workspace-write` sandbox with the `untrusted` approval policy.
- `acceptEdits` — uses the `workspace-write` sandbox with approval disabled, so Codex can apply workspace edits without prompting while staying sandboxed.
- `bypassPermissions` — uses `danger-full-access` with no approval. Use it only in trusted local or sandboxed workspaces.

The UI sends the selected mode with each Codex request. For self-hosted deployments that need a server-side fallback when a client does not send a mode, set `CLOUDCLI_CODEX_PERMISSION_MODE` to one of those values. If unset or invalid, CloudCLI uses `default`.

Examples:

```bash
CLOUDCLI_CODEX_PERMISSION_MODE=default npx @cloudcli-ai/cloudcli
CLOUDCLI_CODEX_PERMISSION_MODE=acceptEdits npx @cloudcli-ai/cloudcli
CLOUDCLI_CODEX_PERMISSION_MODE=bypassPermissions npx @cloudcli-ai/cloudcli
```

---

## Plugins
Expand Down
33 changes: 32 additions & 1 deletion server/claude-sdk.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@ const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEO

const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion', 'ExitPlanMode']);

/**
* Extracts the prompt text from a Task subagent tool_use input.
* Mirrors the logic in claude-sessions.provider.ts extractSubagentPrompt().
*/
function extractSubagentPrompt(toolInput) {
if (!toolInput || typeof toolInput !== 'object') return null;
const prompt = typeof toolInput.prompt === 'string' ? toolInput.prompt : null;
if (!prompt) return null;
return prompt.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim();
}

function createRequestId() {
if (typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
Expand Down Expand Up @@ -507,6 +518,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
let sessionCreatedSent = false;
let tempImagePaths = [];
let tempDir = null;
const streamingSubagentPrompts = new Set();

const emitNotification = (event) => {
notifyUserIfEnabled({
Expand Down Expand Up @@ -699,8 +711,27 @@ async function queryClaudeSDK(command, options = {}, ws) {
const transformedMessage = transformMessage(message);
const sid = capturedSessionId || sessionId || null;

// Collect Task subagent prompts so they can be filtered during normalization
if (message.type === 'assistant' || transformedMessage.message?.role === 'assistant') {
if (Array.isArray(transformedMessage.message?.content)) {
for (const part of transformedMessage.message.content) {
if (part.type === 'tool_use' && part.name === 'Task') {
const prompt = extractSubagentPrompt(part.input);
if (prompt) {
streamingSubagentPrompts.add(prompt);
}
}
}
}
}

// Use adapter to normalize SDK events into NormalizedMessage[]
const normalized = sessionsService.normalizeMessage('claude', transformedMessage, sid);
const normalized = sessionsService.normalizeMessage(
'claude',
transformedMessage,
sid,
streamingSubagentPrompts.size > 0 ? streamingSubagentPrompts : null,
);
for (const msg of normalized) {
// Preserve parentToolUseId from SDK wrapper for subagent tool grouping
if (transformedMessage.parentToolUseId && !msg.parentToolUseId) {
Expand Down
8 changes: 4 additions & 4 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ app.locals.wss = wss;

app.use(cors({ exposedHeaders: ['X-Refreshed-Token'] }));
app.use(express.json({
limit: '50mb',
limit: '10mb',
type: (req) => {
// Skip multipart/form-data requests (for file uploads like images)
const contentType = req.headers['content-type'] || '';
Expand All @@ -146,7 +146,7 @@ app.use(express.json({
return contentType.includes('json');
}
}));
app.use(express.urlencoded({ limit: '50mb', extended: true }));
app.use(express.urlencoded({ limit: '10mb', extended: true }));

// Public health check endpoint (no authentication required)
app.get('/health', (req, res) => {
Expand Down Expand Up @@ -892,7 +892,7 @@ const uploadFilesHandler = async (req, res) => {
}
}),
limits: {
fileSize: 50 * 1024 * 1024, // 50MB limit
fileSize: 200 * 1024 * 1024, // 200MB limit
files: 20 // Max 20 files at once
}
});
Expand All @@ -902,7 +902,7 @@ const uploadFilesHandler = async (req, res) => {
if (err) {
console.error('Multer error:', err);
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
return res.status(400).json({ error: 'File too large. Maximum size is 200MB.' });
}
if (err.code === 'LIMIT_FILE_COUNT') {
return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' });
Expand Down
18 changes: 17 additions & 1 deletion server/modules/database/repositories/sessions.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export const sessionsDb = {
ON CONFLICT(session_id) DO UPDATE SET
provider = excluded.provider,
updated_at = excluded.updated_at,
project_path = excluded.project_path,
project_path = COALESCE(NULLIF(excluded.project_path, ''), sessions.project_path),
jsonl_path = excluded.jsonl_path,
isArchived = 0,
custom_name = COALESCE(excluded.custom_name, sessions.custom_name)`
Expand Down Expand Up @@ -222,4 +222,20 @@ export const sessionsDb = {
const db = getConnection();
return db.prepare('DELETE FROM sessions WHERE session_id = ?').run(sessionId).changes > 0;
},

/**
* Returns all sessions that have a non-empty custom_name so the service layer
* can sync them to the provider's history file on startup.
*/
getSessionsWithCustomName(): Array<{ session_id: string; custom_name: string; project_path: string | null; jsonl_path: string | null; created_at: string }> {
const db = getConnection();
return db
.prepare(
`SELECT session_id, custom_name, project_path, jsonl_path, created_at
FROM sessions
WHERE custom_name IS NOT NULL AND custom_name != ''
ORDER BY updated_at DESC`
)
.all() as Array<{ session_id: string; custom_name: string; project_path: string | null; jsonl_path: string | null; created_at: string }>;
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ type SessionSummary = {
summary: string;
messageCount: number;
lastActivity: string;
projectPath?: string;
};

type SessionsByProvider = Record<'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode', SessionSummary[]>;

type SessionRepositoryRow = {
provider: string;
session_id: string;
project_path?: string | null;
custom_name?: string | null;
updated_at?: string | null;
created_at?: string | null;
Expand Down Expand Up @@ -132,6 +134,7 @@ function mapSessionRowToSummary(row: SessionRepositoryRow): SessionSummary {
summary: row.custom_name || '',
messageCount: 0,
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
projectPath: row.project_path ?? undefined,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {

let processed = 0;
for (const filePath of files) {
// Skip subagent JSONL files — they share the parent session's sessionId
// and would overwrite the correct jsonl_path with the subagent path
const pathSegments = path.normalize(filePath).split(path.sep);
if (pathSegments.includes('subagents')) {
continue;
}


const parsed = await this.processSessionFile(filePath, nameMap);
if (!parsed) {
continue;
Expand Down Expand Up @@ -66,6 +74,13 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
if (!filePath.endsWith('.jsonl')) {
return null;
}
// PATCH (kamioj): 同 synchronize(),单文件路径走 watcher 进来时也要排除 subagents/
if (
filePath.includes(`${path.sep}subagents${path.sep}`) ||
filePath.replace(/\\/g, '/').includes('/subagents/')
) {
return null;
}

const nameMap = await buildLookupMap(path.join(this.claudeHome, 'history.jsonl'), 'sessionId', 'display');
const parsed = await this.processSessionFile(filePath, nameMap);
Expand Down
Loading