@myrialabs/clopen 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +5 -0
- package/.env.example +2 -5
- package/CONTRIBUTING.md +4 -0
- package/README.md +4 -2
- package/backend/database/queries/message-queries.ts +42 -0
- package/backend/database/utils/connection.ts +5 -5
- package/backend/engine/adapters/claude/environment.ts +3 -4
- package/backend/engine/adapters/claude/stream.ts +107 -0
- package/backend/engine/adapters/opencode/server.ts +7 -1
- package/backend/engine/adapters/opencode/stream.ts +81 -1
- package/backend/engine/types.ts +17 -0
- package/backend/git/git-executor.ts +2 -1
- package/backend/git/git-service.ts +2 -1
- package/backend/index.ts +10 -10
- package/backend/snapshot/blob-store.ts +2 -2
- package/backend/utils/env.ts +13 -15
- package/backend/utils/index.ts +4 -1
- package/backend/utils/paths.ts +11 -0
- package/backend/utils/port-utils.ts +19 -6
- package/backend/ws/git/commit-message.ts +108 -0
- package/backend/ws/git/index.ts +3 -1
- package/backend/ws/messages/crud.ts +52 -0
- package/backend/ws/system/index.ts +7 -1
- package/backend/ws/system/operations.ts +28 -2
- package/bin/clopen.ts +15 -15
- package/docker-compose.yml +31 -0
- package/frontend/App.svelte +3 -0
- package/frontend/components/auth/SetupPage.svelte +45 -13
- package/frontend/components/chat/input/ChatInput.svelte +1 -1
- package/frontend/components/chat/message/ChatMessage.svelte +64 -16
- package/frontend/components/chat/widgets/FloatingTodoList.svelte +124 -10
- package/frontend/components/checkpoint/ConflictResolutionModal.svelte +189 -0
- package/frontend/components/checkpoint/TimelineModal.svelte +7 -162
- package/frontend/components/common/feedback/RestartRequiredModal.svelte +53 -0
- package/frontend/components/common/feedback/UpdateBanner.svelte +19 -8
- package/frontend/components/git/BranchManager.svelte +143 -155
- package/frontend/components/git/CommitForm.svelte +61 -11
- package/frontend/components/history/HistoryModal.svelte +30 -78
- package/frontend/components/history/HistoryView.svelte +45 -92
- package/frontend/components/settings/SettingsModal.svelte +1 -1
- package/frontend/components/settings/SettingsView.svelte +1 -1
- package/frontend/components/settings/appearance/AppearanceSettings.svelte +2 -2
- package/frontend/components/settings/engines/AIEnginesSettings.svelte +2 -2
- package/frontend/components/settings/general/UpdateSettings.svelte +10 -3
- package/frontend/components/settings/git/GitSettings.svelte +392 -0
- package/frontend/components/settings/model/EngineModelPicker.svelte +275 -0
- package/frontend/components/settings/model/ModelSettings.svelte +172 -289
- package/frontend/components/workspace/PanelHeader.svelte +1 -3
- package/frontend/components/workspace/WorkspaceLayout.svelte +11 -1
- package/frontend/components/workspace/panels/FilesPanel.svelte +41 -3
- package/frontend/components/workspace/panels/GitPanel.svelte +53 -8
- package/frontend/main.ts +4 -0
- package/frontend/stores/features/auth.svelte.ts +28 -0
- package/frontend/stores/features/settings.svelte.ts +13 -2
- package/frontend/stores/ui/settings-modal.svelte.ts +9 -9
- package/frontend/stores/ui/update.svelte.ts +51 -4
- package/package.json +2 -2
- package/scripts/dev.ts +3 -2
- package/scripts/start.ts +24 -0
- package/shared/types/git.ts +15 -0
- package/shared/types/stores/settings.ts +12 -0
- package/vite.config.ts +2 -2
package/.env.example
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
# Node Environment (development | production)
|
|
2
|
-
NODE_ENV=production
|
|
3
|
-
|
|
4
1
|
# Server configuration
|
|
5
2
|
HOST=localhost
|
|
6
3
|
|
|
@@ -8,5 +5,5 @@ HOST=localhost
|
|
|
8
5
|
PORT=9141
|
|
9
6
|
|
|
10
7
|
# Development ports (two ports — Vite proxies to Elysia)
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
PORT_FRONTEND=9151
|
|
9
|
+
PORT_BACKEND=9161
|
package/CONTRIBUTING.md
CHANGED
|
@@ -22,6 +22,10 @@ bun install
|
|
|
22
22
|
bun run check
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
### Data Directory
|
|
26
|
+
|
|
27
|
+
When running `bun run dev`, Clopen stores data in `~/.clopen-dev` instead of `~/.clopen`. This keeps development data separate from any production instance — especially important since Clopen can be used to develop itself.
|
|
28
|
+
|
|
25
29
|
### Keep Updated
|
|
26
30
|
|
|
27
31
|
```bash
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
## Features
|
|
11
11
|
|
|
12
|
-
- **Multi-Account Claude Code** - Manage multiple accounts (personal, work, team) and switch instantly per session, isolated under `~/.clopen/claude/user/` without touching system-level Claude config
|
|
12
|
+
- **Multi-Account Claude Code** - Manage multiple accounts (personal, work, team) and switch instantly per session, isolated under `~/.clopen/claude/user/` (or `~/.clopen-dev/` in development) without touching system-level Claude config
|
|
13
13
|
- **Multi-Engine Support** - Switch between Claude Code and OpenCode
|
|
14
14
|
- **AI Chat Interface** - Streaming responses with tool use visualization
|
|
15
15
|
- **Background Processing** - Chat, terminal, and other processes continue running even when you close the browser — come back later and pick up where you left off
|
|
@@ -92,6 +92,8 @@ bun run dev # Start development server
|
|
|
92
92
|
bun run check # Type checking
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
+
When running in development mode, Clopen uses `~/.clopen-dev` instead of `~/.clopen`, keeping dev data separate from any production instance.
|
|
96
|
+
|
|
95
97
|
---
|
|
96
98
|
|
|
97
99
|
## Architecture
|
|
@@ -123,7 +125,7 @@ Clopen uses an engine-agnostic adapter pattern — both engines normalize output
|
|
|
123
125
|
### Port 9141 Already in Use
|
|
124
126
|
|
|
125
127
|
```bash
|
|
126
|
-
clopen --port
|
|
128
|
+
clopen --port 9145
|
|
127
129
|
```
|
|
128
130
|
|
|
129
131
|
Or kill the existing process:
|
|
@@ -42,6 +42,48 @@ export const messageQueries = {
|
|
|
42
42
|
});
|
|
43
43
|
},
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Get minimal preview data for a session: first user msg, last assistant msg, and counts.
|
|
47
|
+
* Used by the Sessions/History modal to avoid loading all messages.
|
|
48
|
+
*/
|
|
49
|
+
getSessionPreview(sessionId: string): {
|
|
50
|
+
firstUserMessage: DatabaseMessage | null;
|
|
51
|
+
lastAssistantMessage: DatabaseMessage | null;
|
|
52
|
+
userCount: number;
|
|
53
|
+
assistantCount: number;
|
|
54
|
+
} {
|
|
55
|
+
const db = getDatabase();
|
|
56
|
+
|
|
57
|
+
const firstUserMessage = db.prepare(`
|
|
58
|
+
SELECT * FROM messages
|
|
59
|
+
WHERE session_id = ? AND json_extract(sdk_message, '$.type') = 'user'
|
|
60
|
+
ORDER BY timestamp ASC
|
|
61
|
+
LIMIT 1
|
|
62
|
+
`).get(sessionId) as DatabaseMessage | null;
|
|
63
|
+
|
|
64
|
+
const lastAssistantMessage = db.prepare(`
|
|
65
|
+
SELECT * FROM messages
|
|
66
|
+
WHERE session_id = ? AND json_extract(sdk_message, '$.type') = 'assistant'
|
|
67
|
+
ORDER BY timestamp DESC
|
|
68
|
+
LIMIT 1
|
|
69
|
+
`).get(sessionId) as DatabaseMessage | null;
|
|
70
|
+
|
|
71
|
+
const counts = db.prepare(`
|
|
72
|
+
SELECT
|
|
73
|
+
SUM(CASE WHEN json_extract(sdk_message, '$.type') = 'user' THEN 1 ELSE 0 END) AS user_count,
|
|
74
|
+
SUM(CASE WHEN json_extract(sdk_message, '$.type') = 'assistant' THEN 1 ELSE 0 END) AS assistant_count
|
|
75
|
+
FROM messages
|
|
76
|
+
WHERE session_id = ?
|
|
77
|
+
`).get(sessionId) as { user_count: number; assistant_count: number } | null;
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
firstUserMessage,
|
|
81
|
+
lastAssistantMessage,
|
|
82
|
+
userCount: counts?.user_count ?? 0,
|
|
83
|
+
assistantCount: counts?.assistant_count ?? 0
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
|
|
45
87
|
/**
|
|
46
88
|
* Get all messages for a session including deleted ones (for timeline view)
|
|
47
89
|
*/
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { join } from 'path';
|
|
2
|
-
import { homedir } from 'os';
|
|
3
2
|
import { Database } from 'bun:sqlite';
|
|
4
3
|
import type { DatabaseConnection } from '$shared/types/database/connection';
|
|
5
4
|
|
|
6
5
|
import { debug } from '$shared/utils/logger';
|
|
6
|
+
import { getClopenDir } from '../../utils/index.js';
|
|
7
|
+
|
|
7
8
|
export class DatabaseManager {
|
|
8
9
|
private static instance: DatabaseManager | null = null;
|
|
9
10
|
private db: DatabaseConnection | null = null;
|
|
10
11
|
private readonly dbPath: string;
|
|
11
12
|
|
|
12
13
|
private constructor() {
|
|
13
|
-
|
|
14
|
-
this.dbPath = join(clopenDir, 'app.db');
|
|
14
|
+
this.dbPath = join(getClopenDir(), 'app.db');
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
static getInstance(): DatabaseManager {
|
|
@@ -29,8 +29,8 @@ export class DatabaseManager {
|
|
|
29
29
|
debug.log('database', '🔗 Connecting to database...');
|
|
30
30
|
|
|
31
31
|
try {
|
|
32
|
-
// Create
|
|
33
|
-
const clopenDir =
|
|
32
|
+
// Create clopen directory if it doesn't exist
|
|
33
|
+
const clopenDir = getClopenDir();
|
|
34
34
|
const dirFile = Bun.file(clopenDir);
|
|
35
35
|
|
|
36
36
|
// Check if directory exists, if not create it
|
|
@@ -7,22 +7,21 @@
|
|
|
7
7
|
* stream concurrently.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { homedir } from 'os';
|
|
11
10
|
import { join } from 'path';
|
|
12
11
|
import { isWindows, findGitBash } from '../../../terminal/shell-utils.js';
|
|
13
12
|
import { engineQueries } from '../../../database/queries';
|
|
14
13
|
import { debug } from '$shared/utils/logger';
|
|
15
|
-
import { getCleanSpawnEnv } from '../../../utils/
|
|
14
|
+
import { getCleanSpawnEnv, getClopenDir } from '../../../utils/index.js';
|
|
16
15
|
|
|
17
16
|
let _ready = false;
|
|
18
17
|
let _initPromise: Promise<void> | null = null;
|
|
19
18
|
let _envOverrides: Record<string, string> = {};
|
|
20
19
|
|
|
21
20
|
/**
|
|
22
|
-
* Returns the isolated Claude config directory under
|
|
21
|
+
* Returns the isolated Claude config directory under {clopenDir}/claude/user/
|
|
23
22
|
*/
|
|
24
23
|
export function getClaudeUserConfigDir(): string {
|
|
25
|
-
return join(
|
|
24
|
+
return join(getClopenDir(), 'claude', 'user');
|
|
26
25
|
}
|
|
27
26
|
|
|
28
27
|
/**
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { query, type SDKMessage, type EngineSDKMessage, type Options, type Query, type SDKUserMessage } from '$shared/types/messaging';
|
|
10
10
|
import type { PermissionMode, PermissionResult } from "@anthropic-ai/claude-agent-sdk";
|
|
11
|
+
import type { StructuredGenerationOptions } from '../../types';
|
|
11
12
|
import { normalizePath } from './path-utils';
|
|
12
13
|
import { setupEnvironmentOnce, getEngineEnv } from './environment';
|
|
13
14
|
import { handleStreamError } from './error-handler';
|
|
@@ -238,4 +239,110 @@ export class ClaudeCodeEngine implements AIEngine {
|
|
|
238
239
|
this.pendingUserAnswers.delete(toolUseId);
|
|
239
240
|
return true;
|
|
240
241
|
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* One-shot structured JSON generation.
|
|
245
|
+
* Uses query() with no tools, outputFormat, and maxTurns: 1.
|
|
246
|
+
*/
|
|
247
|
+
async generateStructured<T = unknown>(options: StructuredGenerationOptions): Promise<T> {
|
|
248
|
+
const {
|
|
249
|
+
prompt,
|
|
250
|
+
model = 'haiku',
|
|
251
|
+
schema,
|
|
252
|
+
projectPath,
|
|
253
|
+
abortController,
|
|
254
|
+
claudeAccountId
|
|
255
|
+
} = options;
|
|
256
|
+
|
|
257
|
+
if (!this._isInitialized) {
|
|
258
|
+
await this.initialize();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const controller = abortController || new AbortController();
|
|
262
|
+
const normalizedPath = normalizePath(projectPath);
|
|
263
|
+
|
|
264
|
+
// Optimized for one-shot structured generation:
|
|
265
|
+
// - tools: [] prevents tool use (no agentic loops)
|
|
266
|
+
// - persistSession: false skips writing session to disk
|
|
267
|
+
// - effort: 'low' reduces processing overhead for simple tasks
|
|
268
|
+
// - thinking disabled removes reasoning overhead
|
|
269
|
+
// - minimal systemPrompt avoids loading heavy defaults
|
|
270
|
+
// - no maxTurns: structured output has its own retry limit
|
|
271
|
+
const sdkOptions: Options = {
|
|
272
|
+
permissionMode: 'bypassPermissions' as PermissionMode,
|
|
273
|
+
allowDangerouslySkipPermissions: true,
|
|
274
|
+
cwd: normalizedPath,
|
|
275
|
+
env: getEngineEnv(claudeAccountId),
|
|
276
|
+
systemPrompt: 'You are a structured data generator. Return JSON matching the provided schema.',
|
|
277
|
+
tools: [],
|
|
278
|
+
outputFormat: {
|
|
279
|
+
type: 'json_schema',
|
|
280
|
+
schema
|
|
281
|
+
},
|
|
282
|
+
persistSession: false,
|
|
283
|
+
effort: 'low',
|
|
284
|
+
thinking: { type: 'disabled' },
|
|
285
|
+
...(model && { model }),
|
|
286
|
+
abortController: controller
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
// Use plain string prompt — simpler and faster than AsyncIterable
|
|
290
|
+
const queryInstance = query({
|
|
291
|
+
prompt,
|
|
292
|
+
options: sdkOptions
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
let structuredOutput: unknown = null;
|
|
296
|
+
let resultText = '';
|
|
297
|
+
let lastError = '';
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
for await (const message of queryInstance) {
|
|
301
|
+
debug.log('engine', `[structured] message type=${message.type}, subtype=${'subtype' in message ? message.subtype : 'n/a'}`);
|
|
302
|
+
|
|
303
|
+
if (message.type === 'result') {
|
|
304
|
+
if (message.subtype === 'success') {
|
|
305
|
+
const result = message as any;
|
|
306
|
+
structuredOutput = result.structured_output;
|
|
307
|
+
resultText = result.result || '';
|
|
308
|
+
debug.log('engine', `[structured] success: structured_output=${!!structuredOutput}, resultLen=${resultText.length}`);
|
|
309
|
+
} else {
|
|
310
|
+
const errResult = message as any;
|
|
311
|
+
lastError = errResult.errors?.join('; ') || '';
|
|
312
|
+
const subtype = errResult.subtype || '';
|
|
313
|
+
|
|
314
|
+
// Map SDK error subtypes to user-friendly messages
|
|
315
|
+
if (subtype === 'error_max_structured_output_retries') {
|
|
316
|
+
lastError = 'Failed to generate valid structured output after multiple attempts';
|
|
317
|
+
} else if (subtype === 'error_max_turns') {
|
|
318
|
+
lastError = 'Generation exceeded turn limit';
|
|
319
|
+
} else if (!lastError) {
|
|
320
|
+
lastError = subtype || 'unknown error';
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
debug.error('engine', `[structured] result error: ${lastError}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
} catch (error) {
|
|
328
|
+
handleStreamError(error);
|
|
329
|
+
// handleStreamError swallows AbortError — if we reach here without throw, it was cancelled
|
|
330
|
+
throw new Error('Generation was cancelled');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (structuredOutput) {
|
|
334
|
+
return structuredOutput as T;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Fallback: parse the text result as JSON
|
|
338
|
+
if (resultText) {
|
|
339
|
+
try {
|
|
340
|
+
return JSON.parse(resultText) as T;
|
|
341
|
+
} catch {
|
|
342
|
+
debug.warn('engine', `[structured] result text is not valid JSON: ${resultText.slice(0, 200)}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
throw new Error(lastError || 'Claude Code did not return valid structured output');
|
|
347
|
+
}
|
|
241
348
|
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import type { OpencodeClient } from '@opencode-ai/sdk';
|
|
12
12
|
import { getOpenCodeMcpConfig } from '../../../mcp';
|
|
13
13
|
import { debug } from '$shared/utils/logger';
|
|
14
|
+
import { findAvailablePort } from '../../../utils/port-utils';
|
|
14
15
|
|
|
15
16
|
const OPENCODE_PORT = 4096;
|
|
16
17
|
const OPENCODE_HOST = '127.0.0.1';
|
|
@@ -58,9 +59,14 @@ async function init(): Promise<void> {
|
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
const actualPort = await findAvailablePort(OPENCODE_PORT);
|
|
63
|
+
if (actualPort !== OPENCODE_PORT) {
|
|
64
|
+
debug.log('engine', `Open Code port ${OPENCODE_PORT} in use, using ${actualPort} instead`);
|
|
65
|
+
}
|
|
66
|
+
|
|
61
67
|
const result = await createOpencode({
|
|
62
68
|
hostname: OPENCODE_HOST,
|
|
63
|
-
port:
|
|
69
|
+
port: actualPort,
|
|
64
70
|
...(Object.keys(mcpConfig).length > 0 && {
|
|
65
71
|
config: { mcp: mcpConfig },
|
|
66
72
|
}),
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { SDKMessage, SDKUserMessage, EngineSDKMessage } from '$shared/types/messaging';
|
|
13
|
-
import type { AIEngine, EngineQueryOptions } from '../../types';
|
|
13
|
+
import type { AIEngine, EngineQueryOptions, StructuredGenerationOptions } from '../../types';
|
|
14
14
|
import type { EngineModel } from '$shared/types/engine';
|
|
15
15
|
import type {
|
|
16
16
|
Provider,
|
|
@@ -1010,4 +1010,84 @@ export class OpenCodeEngine implements AIEngine {
|
|
|
1010
1010
|
|
|
1011
1011
|
return [{ type: 'text', text: '' }];
|
|
1012
1012
|
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* One-shot structured JSON generation.
|
|
1016
|
+
* Uses the v1 SDK client.session.prompt() (synchronous) with prompt
|
|
1017
|
+
* engineering for JSON output since v1 doesn't support format option.
|
|
1018
|
+
*/
|
|
1019
|
+
async generateStructured<T = unknown>(options: StructuredGenerationOptions): Promise<T> {
|
|
1020
|
+
const {
|
|
1021
|
+
prompt,
|
|
1022
|
+
model = 'claude-sonnet',
|
|
1023
|
+
schema,
|
|
1024
|
+
projectPath,
|
|
1025
|
+
abortController
|
|
1026
|
+
} = options;
|
|
1027
|
+
|
|
1028
|
+
if (!this._isInitialized) {
|
|
1029
|
+
await this.initialize();
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const client = await ensureClient();
|
|
1033
|
+
|
|
1034
|
+
// Create a temporary session for this one-shot request
|
|
1035
|
+
const sessionResult = await client.session.create({
|
|
1036
|
+
query: { directory: projectPath }
|
|
1037
|
+
});
|
|
1038
|
+
const sessionId = sessionResult.data?.id;
|
|
1039
|
+
if (!sessionId) {
|
|
1040
|
+
throw new Error('Failed to create OpenCode session');
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// Parse model into providerID/modelID
|
|
1044
|
+
const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : ['', model];
|
|
1045
|
+
|
|
1046
|
+
// Wrap prompt with JSON instruction since v1 doesn't support format option
|
|
1047
|
+
const jsonPrompt = `${prompt}
|
|
1048
|
+
|
|
1049
|
+
IMPORTANT: You MUST respond with ONLY a valid JSON object matching this schema, no other text:
|
|
1050
|
+
${JSON.stringify(schema, null, 2)}`;
|
|
1051
|
+
|
|
1052
|
+
debug.log('engine', `[OC structured] Sending prompt to session ${sessionId}, model=${model}`);
|
|
1053
|
+
|
|
1054
|
+
// Use v1 SDK synchronous prompt method — waits for completion
|
|
1055
|
+
const response = await client.session.prompt({
|
|
1056
|
+
path: { id: sessionId },
|
|
1057
|
+
body: {
|
|
1058
|
+
parts: [{ type: 'text', text: jsonPrompt }],
|
|
1059
|
+
...(providerID && modelID ? { model: { providerID, modelID } } : {}),
|
|
1060
|
+
tools: {}
|
|
1061
|
+
},
|
|
1062
|
+
query: { directory: projectPath },
|
|
1063
|
+
...(abortController?.signal && { signal: abortController.signal })
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
const data = response.data;
|
|
1067
|
+
if (!data) {
|
|
1068
|
+
throw new Error('OpenCode returned empty response');
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
debug.log('engine', `[OC structured] Got response with ${data.parts?.length || 0} parts`);
|
|
1072
|
+
|
|
1073
|
+
// Extract text content from response parts and parse as JSON
|
|
1074
|
+
const textParts = (data.parts || []).filter((p: any) => p.type === 'text');
|
|
1075
|
+
const fullText = textParts.map((p: any) => p.text || '').join('');
|
|
1076
|
+
|
|
1077
|
+
if (!fullText) {
|
|
1078
|
+
throw new Error('OpenCode returned no text content');
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
debug.log('engine', `[OC structured] Raw text: ${fullText.slice(0, 200)}`);
|
|
1082
|
+
|
|
1083
|
+
// Try to extract JSON from the response (may include markdown fences)
|
|
1084
|
+
const jsonMatch = fullText.match(/```(?:json)?\s*([\s\S]*?)```/) || fullText.match(/(\{[\s\S]*\})/);
|
|
1085
|
+
const jsonText = jsonMatch ? jsonMatch[1].trim() : fullText.trim();
|
|
1086
|
+
|
|
1087
|
+
try {
|
|
1088
|
+
return JSON.parse(jsonText) as T;
|
|
1089
|
+
} catch {
|
|
1090
|
+
throw new Error(`OpenCode did not return valid JSON: ${jsonText.slice(0, 200)}`);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1013
1093
|
}
|
package/backend/engine/types.ts
CHANGED
|
@@ -24,6 +24,16 @@ export interface EngineQueryOptions {
|
|
|
24
24
|
claudeAccountId?: number;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** Options for one-shot structured generation (no tools, no streaming) */
|
|
28
|
+
export interface StructuredGenerationOptions {
|
|
29
|
+
prompt: string;
|
|
30
|
+
model?: string;
|
|
31
|
+
schema: Record<string, unknown>;
|
|
32
|
+
projectPath: string;
|
|
33
|
+
abortController?: AbortController;
|
|
34
|
+
claudeAccountId?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
27
37
|
/** The contract every engine adapter must fulfil */
|
|
28
38
|
export interface AIEngine {
|
|
29
39
|
/** Engine identifier */
|
|
@@ -61,4 +71,11 @@ export interface AIEngine {
|
|
|
61
71
|
* Unblocks the canUseTool callback so the SDK can continue.
|
|
62
72
|
*/
|
|
63
73
|
resolveUserAnswer?(toolUseId: string, answers: Record<string, string>): boolean;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* One-shot structured JSON generation (no tools, no streaming).
|
|
77
|
+
* Returns parsed JSON matching the provided schema.
|
|
78
|
+
* Optional — engines that don't support it leave undefined.
|
|
79
|
+
*/
|
|
80
|
+
generateStructured?<T = unknown>(options: StructuredGenerationOptions): Promise<T>;
|
|
64
81
|
}
|
|
@@ -22,7 +22,8 @@ export async function execGit(
|
|
|
22
22
|
): Promise<GitExecResult> {
|
|
23
23
|
debug.log('git', `Executing: git ${args.join(' ')} in ${cwd}`);
|
|
24
24
|
|
|
25
|
-
const
|
|
25
|
+
const safeCwd = cwd.replace(/\\/g, '/');
|
|
26
|
+
const proc = Bun.spawn(['git', '-c', `safe.directory=${safeCwd}`, ...args], {
|
|
26
27
|
cwd,
|
|
27
28
|
stdout: 'pipe',
|
|
28
29
|
stderr: 'pipe',
|
|
@@ -317,7 +317,8 @@ export class GitService {
|
|
|
317
317
|
}
|
|
318
318
|
|
|
319
319
|
async fetch(cwd: string, remote = 'origin'): Promise<string> {
|
|
320
|
-
|
|
320
|
+
// Use explicit refspec to ensure all branches are fetched regardless of clone config
|
|
321
|
+
const result = await execGit(['fetch', remote, `+refs/heads/*:refs/remotes/${remote}/*`, '--prune'], cwd, 60000);
|
|
321
322
|
if (result.exitCode !== 0) {
|
|
322
323
|
throw new Error(`git fetch failed: ${result.stderr}`);
|
|
323
324
|
}
|
package/backend/index.ts
CHANGED
|
@@ -20,7 +20,6 @@ import { loggerMiddleware } from './middleware/logger';
|
|
|
20
20
|
import { initializeDatabase, closeDatabase } from './database';
|
|
21
21
|
import { disposeAllEngines } from './engine';
|
|
22
22
|
import { debug } from '$shared/utils/logger';
|
|
23
|
-
import { findAvailablePort } from './utils/port-utils';
|
|
24
23
|
import { networkInterfaces } from 'os';
|
|
25
24
|
import { resolve } from 'node:path';
|
|
26
25
|
import { statSync } from 'node:fs';
|
|
@@ -45,7 +44,7 @@ wsRouter.setAuthMiddleware(async (conn, action) => {
|
|
|
45
44
|
/**
|
|
46
45
|
* Clopen - Elysia Backend Server
|
|
47
46
|
*
|
|
48
|
-
* Development: Elysia runs on port
|
|
47
|
+
* Development: Elysia runs on port 9161, Vite dev server proxies /api and /ws from port 9151
|
|
49
48
|
* Production: Elysia runs on port 9141, serves static files from dist/ + API + WebSocket
|
|
50
49
|
*/
|
|
51
50
|
|
|
@@ -117,11 +116,12 @@ if (!isDevelopment) {
|
|
|
117
116
|
|
|
118
117
|
// Start server with proper initialization sequence
|
|
119
118
|
async function startServer() {
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
119
|
+
// Port resolution is handled by the caller:
|
|
120
|
+
// - Development: scripts/dev.ts resolves ports and passes via PORT_BACKEND env
|
|
121
|
+
// - Production: scripts/start.ts resolves port and passes via PORT env
|
|
122
|
+
// - CLI: bin/clopen.ts resolves port and passes via PORT env
|
|
123
|
+
// This avoids double port-check race conditions (e.g. zombie processes on
|
|
124
|
+
// Windows causing silent desync between Vite proxy and backend).
|
|
125
125
|
|
|
126
126
|
// Initialize database first before accepting connections
|
|
127
127
|
try {
|
|
@@ -133,18 +133,18 @@ async function startServer() {
|
|
|
133
133
|
|
|
134
134
|
// Start listening after database is ready
|
|
135
135
|
app.listen({
|
|
136
|
-
port:
|
|
136
|
+
port: PORT,
|
|
137
137
|
hostname: HOST
|
|
138
138
|
}, () => {
|
|
139
139
|
if (isDevelopment) {
|
|
140
140
|
console.log('🚀 Backend ready — waiting for frontend...');
|
|
141
141
|
} else {
|
|
142
|
-
console.log(`🚀 Clopen running at http://localhost:${
|
|
142
|
+
console.log(`🚀 Clopen running at http://localhost:${PORT}`);
|
|
143
143
|
}
|
|
144
144
|
if (HOST === '0.0.0.0') {
|
|
145
145
|
const ips = getLocalIps();
|
|
146
146
|
for (const ip of ips) {
|
|
147
|
-
console.log(`🌐 Network access: http://${ip}:${
|
|
147
|
+
console.log(`🌐 Network access: http://${ip}:${PORT}`);
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
150
|
});
|
|
@@ -11,12 +11,12 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { join } from 'path';
|
|
14
|
-
import { homedir } from 'os';
|
|
15
14
|
import fs from 'fs/promises';
|
|
16
15
|
import { gzipSync, gunzipSync } from 'zlib';
|
|
17
16
|
import { debug } from '$shared/utils/logger';
|
|
17
|
+
import { getClopenDir } from '../utils/index.js';
|
|
18
18
|
|
|
19
|
-
const SNAPSHOTS_DIR = join(
|
|
19
|
+
const SNAPSHOTS_DIR = join(getClopenDir(), 'snapshots');
|
|
20
20
|
const BLOBS_DIR = join(SNAPSHOTS_DIR, 'blobs');
|
|
21
21
|
const TREES_DIR = join(SNAPSHOTS_DIR, 'trees');
|
|
22
22
|
|
package/backend/utils/env.ts
CHANGED
|
@@ -22,12 +22,12 @@ const isDev = process.env.NODE_ENV !== 'production';
|
|
|
22
22
|
|
|
23
23
|
export const SERVER_ENV = {
|
|
24
24
|
NODE_ENV: (process.env.NODE_ENV || 'development') as string,
|
|
25
|
-
/** Backend port — dev: PORT_BACKEND (default
|
|
25
|
+
/** Backend port — dev: PORT_BACKEND (default 9161), prod: PORT (default 9141) */
|
|
26
26
|
PORT: isDev
|
|
27
|
-
? (process.env.PORT_BACKEND ? parseInt(process.env.PORT_BACKEND) :
|
|
27
|
+
? (process.env.PORT_BACKEND ? parseInt(process.env.PORT_BACKEND) : 9161)
|
|
28
28
|
: (process.env.PORT ? parseInt(process.env.PORT) : 9141),
|
|
29
29
|
/** Frontend port — only used in dev for Vite proxy coordination */
|
|
30
|
-
PORT_FRONTEND: process.env.PORT_FRONTEND ? parseInt(process.env.PORT_FRONTEND) :
|
|
30
|
+
PORT_FRONTEND: process.env.PORT_FRONTEND ? parseInt(process.env.PORT_FRONTEND) : 9151,
|
|
31
31
|
HOST: (process.env.HOST || 'localhost') as string,
|
|
32
32
|
isDevelopment: isDev,
|
|
33
33
|
} as const;
|
|
@@ -35,15 +35,13 @@ export const SERVER_ENV = {
|
|
|
35
35
|
// ── .env parsing ────────────────────────────────────────────────────
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
* Parse .env file into key-value
|
|
39
|
-
*
|
|
40
|
-
* to determine if Bun's auto-load is still in effect or if the
|
|
41
|
-
* system/runtime changed the value after loading.
|
|
38
|
+
* Parse a .env file at the given path into a key-value record.
|
|
39
|
+
* Returns an empty object if the file doesn't exist or can't be read.
|
|
42
40
|
*/
|
|
43
|
-
function
|
|
44
|
-
const
|
|
41
|
+
export function loadEnvFile(envPath: string): Record<string, string> {
|
|
42
|
+
const result: Record<string, string> = {};
|
|
45
43
|
try {
|
|
46
|
-
const content = readFileSync(
|
|
44
|
+
const content = readFileSync(envPath, 'utf-8');
|
|
47
45
|
for (const line of content.split('\n')) {
|
|
48
46
|
let trimmed = line.trim();
|
|
49
47
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
@@ -51,22 +49,22 @@ function parseDotEnv(): Map<string, string> {
|
|
|
51
49
|
const eqIdx = trimmed.indexOf('=');
|
|
52
50
|
if (eqIdx <= 0) continue;
|
|
53
51
|
const key = trimmed.substring(0, eqIdx).trim();
|
|
54
|
-
// Strip surrounding quotes from value
|
|
55
52
|
let value = trimmed.substring(eqIdx + 1).trim();
|
|
56
53
|
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
57
54
|
(value.startsWith("'") && value.endsWith("'"))) {
|
|
58
55
|
value = value.slice(1, -1);
|
|
59
56
|
}
|
|
60
|
-
|
|
57
|
+
result[key] = value;
|
|
61
58
|
}
|
|
62
59
|
} catch {
|
|
63
60
|
// .env doesn't exist or can't be read
|
|
64
61
|
}
|
|
65
|
-
return
|
|
62
|
+
return result;
|
|
66
63
|
}
|
|
67
64
|
|
|
68
|
-
// Capture once at import time
|
|
69
|
-
|
|
65
|
+
// Capture once at import time — read from CWD which is set to the clopen
|
|
66
|
+
// installation directory when spawned via bin/clopen.ts (cwd: __dirname).
|
|
67
|
+
const dotEnv = new Map(Object.entries(loadEnvFile(join(process.cwd(), '.env'))));
|
|
70
68
|
|
|
71
69
|
// ── Filter definitions ──────────────────────────────────────────────
|
|
72
70
|
|
package/backend/utils/index.ts
CHANGED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Returns the Clopen data directory.
|
|
6
|
+
* - development: ~/.clopen-dev
|
|
7
|
+
* - everything else (production, undefined): ~/.clopen
|
|
8
|
+
*/
|
|
9
|
+
export function getClopenDir(): string {
|
|
10
|
+
return join(homedir(), process.env.NODE_ENV === 'development' ? '.clopen-dev' : '.clopen');
|
|
11
|
+
}
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Port utilities for checking ports before server start.
|
|
3
3
|
* Bun-optimized: uses Bun.connect for fast cross-platform port check.
|
|
4
|
+
*
|
|
5
|
+
* Checks BOTH IPv4 (127.0.0.1) and IPv6 (::1) — on Windows, 'localhost'
|
|
6
|
+
* may resolve to either address. A zombie process listening on [::1] would
|
|
7
|
+
* go undetected by an IPv4-only check, causing the new server to bind to a
|
|
8
|
+
* port that can't actually serve traffic (connections hang indefinitely).
|
|
4
9
|
*/
|
|
5
10
|
|
|
6
|
-
/**
|
|
7
|
-
|
|
11
|
+
/** Try to connect to a specific host:port */
|
|
12
|
+
async function tryConnect(hostname: string, port: number): Promise<boolean> {
|
|
8
13
|
try {
|
|
9
|
-
// Bun-native TCP connect — fast cross-platform check
|
|
10
14
|
const socket = await Bun.connect({
|
|
11
|
-
hostname
|
|
15
|
+
hostname,
|
|
12
16
|
port,
|
|
13
17
|
socket: {
|
|
14
18
|
data() {},
|
|
@@ -18,12 +22,21 @@ export async function isPortInUse(port: number): Promise<boolean> {
|
|
|
18
22
|
}
|
|
19
23
|
});
|
|
20
24
|
socket.end();
|
|
21
|
-
return true;
|
|
25
|
+
return true;
|
|
22
26
|
} catch {
|
|
23
|
-
return false;
|
|
27
|
+
return false;
|
|
24
28
|
}
|
|
25
29
|
}
|
|
26
30
|
|
|
31
|
+
/** Check if a port is currently in use on any localhost address (IPv4 + IPv6) */
|
|
32
|
+
export async function isPortInUse(port: number): Promise<boolean> {
|
|
33
|
+
const [v4, v6] = await Promise.all([
|
|
34
|
+
tryConnect('127.0.0.1', port),
|
|
35
|
+
tryConnect('::1', port),
|
|
36
|
+
]);
|
|
37
|
+
return v4 || v6;
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
/** Find an available port starting from the given port, incrementing on collision */
|
|
28
41
|
export async function findAvailablePort(startPort: number, maxAttempts = 8): Promise<number> {
|
|
29
42
|
let port = startPort;
|