@myrialabs/clopen 0.2.2 → 0.2.3
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/opencode/server.ts +7 -1
- package/backend/git/git-executor.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/messages/crud.ts +52 -0
- package/bin/clopen.ts +15 -15
- package/docker-compose.yml +31 -0
- package/frontend/components/auth/SetupPage.svelte +43 -11
- package/frontend/components/chat/widgets/FloatingTodoList.svelte +124 -10
- package/frontend/components/common/feedback/UpdateBanner.svelte +2 -2
- package/frontend/components/history/HistoryModal.svelte +30 -78
- package/frontend/components/history/HistoryView.svelte +45 -92
- package/frontend/components/settings/appearance/AppearanceSettings.svelte +2 -2
- package/frontend/components/workspace/panels/FilesPanel.svelte +41 -3
- package/frontend/components/workspace/panels/GitPanel.svelte +41 -3
- package/frontend/stores/features/auth.svelte.ts +28 -0
- package/frontend/stores/ui/update.svelte.ts +6 -0
- package/package.json +2 -2
- package/scripts/dev.ts +3 -2
- package/scripts/start.ts +24 -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
|
/**
|
|
@@ -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
|
}),
|
|
@@ -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',
|
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;
|
|
@@ -12,6 +12,15 @@ import { createRouter } from '$shared/utils/ws-server';
|
|
|
12
12
|
import { messageQueries } from '../../database/queries';
|
|
13
13
|
import { formatDatabaseMessage } from '$shared/utils/message-formatter';
|
|
14
14
|
|
|
15
|
+
function extractTextContent(content: unknown): string {
|
|
16
|
+
if (typeof content === 'string') return content;
|
|
17
|
+
if (!Array.isArray(content)) return '';
|
|
18
|
+
return content
|
|
19
|
+
.filter((c: any) => c.type === 'text')
|
|
20
|
+
.map((b: any) => b.text || '')
|
|
21
|
+
.join(' ');
|
|
22
|
+
}
|
|
23
|
+
|
|
15
24
|
export const crudHandler = createRouter()
|
|
16
25
|
// List messages
|
|
17
26
|
.http('messages:list', {
|
|
@@ -33,6 +42,49 @@ export const crudHandler = createRouter()
|
|
|
33
42
|
}
|
|
34
43
|
})
|
|
35
44
|
|
|
45
|
+
// Bulk session preview — returns title, summary, and message counts for multiple sessions
|
|
46
|
+
// without loading all messages. Used by the Sessions/History modal.
|
|
47
|
+
.http('sessions:preview', {
|
|
48
|
+
data: t.Object({
|
|
49
|
+
session_ids: t.Array(t.String())
|
|
50
|
+
}),
|
|
51
|
+
response: t.Array(t.Any())
|
|
52
|
+
}, ({ data }) => {
|
|
53
|
+
return data.session_ids.map(sessionId => {
|
|
54
|
+
const preview = messageQueries.getSessionPreview(sessionId);
|
|
55
|
+
|
|
56
|
+
// Title from first user message
|
|
57
|
+
let title = 'New Conversation';
|
|
58
|
+
if (preview.firstUserMessage) {
|
|
59
|
+
const sdk = JSON.parse(preview.firstUserMessage.sdk_message);
|
|
60
|
+
const textContent = extractTextContent(sdk.message?.content).trim();
|
|
61
|
+
if (textContent) {
|
|
62
|
+
title = textContent.slice(0, 60) + (textContent.length > 60 ? '...' : '');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Summary from last assistant message
|
|
67
|
+
let summary = 'No messages yet';
|
|
68
|
+
if (preview.lastAssistantMessage) {
|
|
69
|
+
const sdk = JSON.parse(preview.lastAssistantMessage.sdk_message);
|
|
70
|
+
const rawText = extractTextContent(sdk.message?.content);
|
|
71
|
+
const cleanText = rawText.replace(/```[\s\S]*?```/g, '').trim();
|
|
72
|
+
if (cleanText) {
|
|
73
|
+
summary = cleanText.slice(0, 100) + (cleanText.length > 100 ? '...' : '');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
session_id: sessionId,
|
|
79
|
+
title,
|
|
80
|
+
summary,
|
|
81
|
+
userCount: preview.userCount,
|
|
82
|
+
assistantCount: preview.assistantCount,
|
|
83
|
+
count: preview.userCount + preview.assistantCount
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
})
|
|
87
|
+
|
|
36
88
|
// Get message by ID
|
|
37
89
|
.http('messages:get', {
|
|
38
90
|
data: t.Object({
|
package/bin/clopen.ts
CHANGED
|
@@ -23,6 +23,7 @@ if (typeof globalThis.Bun === 'undefined') {
|
|
|
23
23
|
|
|
24
24
|
import { existsSync, copyFileSync, readFileSync, writeFileSync } from 'fs';
|
|
25
25
|
import { join } from 'path';
|
|
26
|
+
import { loadEnvFile } from '../backend/utils/env';
|
|
26
27
|
|
|
27
28
|
// CLI Options interface
|
|
28
29
|
interface CLIOptions {
|
|
@@ -88,7 +89,7 @@ const MAX_PORT = 65535;
|
|
|
88
89
|
|
|
89
90
|
function showHelp() {
|
|
90
91
|
console.log(`
|
|
91
|
-
Clopen -
|
|
92
|
+
Clopen - All-in-one web workspace for Claude Code & OpenCode
|
|
92
93
|
|
|
93
94
|
USAGE:
|
|
94
95
|
clopen [OPTIONS]
|
|
@@ -106,7 +107,7 @@ OPTIONS:
|
|
|
106
107
|
|
|
107
108
|
EXAMPLES:
|
|
108
109
|
clopen # Start with default settings (port ${DEFAULT_PORT})
|
|
109
|
-
clopen --port
|
|
110
|
+
clopen --port 9145 # Start on port 9145
|
|
110
111
|
clopen --host 0.0.0.0 # Bind to all network interfaces
|
|
111
112
|
clopen update # Update to the latest version
|
|
112
113
|
clopen reset-pat # Regenerate admin login token
|
|
@@ -262,8 +263,8 @@ async function recoverAdminToken() {
|
|
|
262
263
|
console.log(`\x1b[36mClopen\x1b[0m v${version} — Admin Token Recovery\n`);
|
|
263
264
|
|
|
264
265
|
// Initialize database (import dynamically to avoid loading full backend)
|
|
265
|
-
const { initializeDatabase } = await import('../backend/
|
|
266
|
-
const { listUsers, regeneratePAT } = await import('../backend/
|
|
266
|
+
const { initializeDatabase } = await import('../backend/database/index');
|
|
267
|
+
const { listUsers, regeneratePAT } = await import('../backend/auth/auth-service');
|
|
267
268
|
|
|
268
269
|
await initializeDatabase();
|
|
269
270
|
|
|
@@ -367,21 +368,20 @@ async function startServer(options: CLIOptions) {
|
|
|
367
368
|
updateLoading('Starting server...');
|
|
368
369
|
await delay();
|
|
369
370
|
|
|
370
|
-
//
|
|
371
|
-
|
|
371
|
+
// Delegate to scripts/start.ts — handles port resolution (IPv4 + IPv6
|
|
372
|
+
// zombie detection) and starts backend in a single consistent path.
|
|
373
|
+
const startScript = join(__dirname, 'scripts/start.ts');
|
|
372
374
|
|
|
373
375
|
stopLoading();
|
|
374
376
|
|
|
375
|
-
//
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
if (options.host)
|
|
381
|
-
env.HOST = options.host;
|
|
382
|
-
}
|
|
377
|
+
// Overlay clopen's own .env on top of process.env to override any
|
|
378
|
+
// pollution from a .env file in the directory where `clopen` was invoked.
|
|
379
|
+
// CLI args take highest priority on top of that.
|
|
380
|
+
const env = { ...process.env, ...loadEnvFile(ENV_FILE) };
|
|
381
|
+
if (options.port) env.PORT = options.port.toString();
|
|
382
|
+
if (options.host) env.HOST = options.host;
|
|
383
383
|
|
|
384
|
-
const serverProc = Bun.spawn(['bun',
|
|
384
|
+
const serverProc = Bun.spawn(['bun', startScript], {
|
|
385
385
|
cwd: __dirname,
|
|
386
386
|
stdout: 'inherit',
|
|
387
387
|
stderr: 'inherit',
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
services:
|
|
2
|
+
clopen:
|
|
3
|
+
build:
|
|
4
|
+
context: .
|
|
5
|
+
dockerfile_inline: |
|
|
6
|
+
FROM oven/bun:latest
|
|
7
|
+
USER root
|
|
8
|
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
9
|
+
git \
|
|
10
|
+
curl \
|
|
11
|
+
chromium \
|
|
12
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
13
|
+
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
|
|
14
|
+
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
|
15
|
+
RUN curl -fsSL https://claude.ai/install.sh | bash
|
|
16
|
+
ENV PATH="/root/.claude/local:$PATH"
|
|
17
|
+
RUN curl -fsSL https://opencode.ai/install | bash
|
|
18
|
+
RUN bun add -g @myrialabs/clopen
|
|
19
|
+
EXPOSE 9141
|
|
20
|
+
CMD ["clopen", "--host", "0.0.0.0"]
|
|
21
|
+
restart: unless-stopped
|
|
22
|
+
ports:
|
|
23
|
+
- "${PORT:-9141}:9141"
|
|
24
|
+
volumes:
|
|
25
|
+
- clopen_data:/root/.clopen
|
|
26
|
+
- ${PROJECTS_DIR:-./projects}:/root/projects
|
|
27
|
+
environment:
|
|
28
|
+
- NODE_ENV=production
|
|
29
|
+
|
|
30
|
+
volumes:
|
|
31
|
+
clopen_data:
|
|
@@ -100,14 +100,41 @@
|
|
|
100
100
|
authModeLoading = true;
|
|
101
101
|
|
|
102
102
|
try {
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
103
|
+
if (!isExistingUser) {
|
|
104
|
+
// Fresh setup — existing behavior
|
|
105
|
+
if (selectedAuthMode === 'none') {
|
|
106
|
+
await authStore.setupNoAuth();
|
|
107
|
+
completedSteps.add('auth-mode');
|
|
108
|
+
completedSteps.add('admin-account');
|
|
109
|
+
completedSteps = new Set(completedSteps);
|
|
110
|
+
currentStep = 'engines';
|
|
111
|
+
} else {
|
|
112
|
+
goToNextStep();
|
|
113
|
+
}
|
|
109
114
|
} else {
|
|
110
|
-
|
|
115
|
+
// Returning user (wizard shown again after refresh) — apply selected mode
|
|
116
|
+
const previousMode = authStore.authMode;
|
|
117
|
+
if (selectedAuthMode === 'none' && previousMode !== 'none') {
|
|
118
|
+
// with-auth → no-auth: update mode, skip admin-account
|
|
119
|
+
await authStore.switchToNoAuth();
|
|
120
|
+
completedSteps.add('auth-mode');
|
|
121
|
+
completedSteps.add('admin-account');
|
|
122
|
+
completedSteps = new Set(completedSteps);
|
|
123
|
+
currentStep = 'engines';
|
|
124
|
+
} else if (selectedAuthMode === 'required' && previousMode !== 'required') {
|
|
125
|
+
// no-auth → with-auth: update mode, regenerate PAT, go to admin-account
|
|
126
|
+
await authStore.switchToWithAuth();
|
|
127
|
+
goToNextStep();
|
|
128
|
+
} else if (selectedAuthMode === 'none') {
|
|
129
|
+
// Same mode (none) — skip admin-account, go to engines
|
|
130
|
+
completedSteps.add('auth-mode');
|
|
131
|
+
completedSteps.add('admin-account');
|
|
132
|
+
completedSteps = new Set(completedSteps);
|
|
133
|
+
currentStep = 'engines';
|
|
134
|
+
} else {
|
|
135
|
+
// Same mode (required) — advance to admin-account
|
|
136
|
+
goToNextStep();
|
|
137
|
+
}
|
|
111
138
|
}
|
|
112
139
|
} catch (err) {
|
|
113
140
|
authModeError = err instanceof Error ? err.message : 'Setup failed';
|
|
@@ -133,11 +160,16 @@
|
|
|
133
160
|
adminLoading = true;
|
|
134
161
|
try {
|
|
135
162
|
if (isExistingUser) {
|
|
136
|
-
// Existing user —
|
|
163
|
+
// Existing user — update name if changed
|
|
137
164
|
if (adminName.trim() !== existingUserName) {
|
|
138
165
|
await authStore.updateName(adminName.trim());
|
|
139
166
|
}
|
|
140
|
-
|
|
167
|
+
// If a PAT was just generated (e.g. switched from no-auth to with-auth), show it
|
|
168
|
+
if (authStore.personalAccessToken) {
|
|
169
|
+
showPAT = true;
|
|
170
|
+
} else {
|
|
171
|
+
goToNextStep();
|
|
172
|
+
}
|
|
141
173
|
} else {
|
|
142
174
|
await authStore.setup(adminName.trim());
|
|
143
175
|
showPAT = true;
|
|
@@ -324,8 +356,8 @@
|
|
|
324
356
|
});
|
|
325
357
|
|
|
326
358
|
// ─── Step 4: Preferences ───
|
|
327
|
-
const FONT_SIZE_MIN =
|
|
328
|
-
const FONT_SIZE_MAX =
|
|
359
|
+
const FONT_SIZE_MIN = 8;
|
|
360
|
+
const FONT_SIZE_MAX = 24;
|
|
329
361
|
|
|
330
362
|
function handleFontSizeChange(e: Event) {
|
|
331
363
|
const value = Number((e.target as HTMLInputElement).value);
|