@myrialabs/clopen 0.2.13 → 0.2.14

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.
@@ -479,9 +479,6 @@ export class SnapshotService {
479
479
  let restoredFiles = 0;
480
480
  let skippedFiles = 0;
481
481
 
482
- // Update in-memory baseline as we restore
483
- const baseline = this.sessionBaselines.get(sessionId) || {};
484
-
485
482
  for (const [filepath, expectedHash] of expectedState) {
486
483
  // Check conflict resolution
487
484
  if (conflictResolutions && conflictResolutions[filepath] === 'keep') {
@@ -509,7 +506,6 @@ export class SnapshotService {
509
506
  // File should not exist at the target → delete it
510
507
  try {
511
508
  await fs.unlink(fullPath);
512
- delete baseline[filepath];
513
509
  debug.log('snapshot', `Deleted: ${filepath}`);
514
510
  restoredFiles++;
515
511
  } catch {
@@ -522,7 +518,6 @@ export class SnapshotService {
522
518
  const dir = path.dirname(fullPath);
523
519
  await fs.mkdir(dir, { recursive: true });
524
520
  await fs.writeFile(fullPath, content);
525
- baseline[filepath] = expectedHash;
526
521
  debug.log('snapshot', `Restored: ${filepath}`);
527
522
  restoredFiles++;
528
523
  } catch (err) {
@@ -532,8 +527,15 @@ export class SnapshotService {
532
527
  }
533
528
  }
534
529
 
535
- // Update in-memory baseline to reflect restored state
536
- this.sessionBaselines.set(sessionId, baseline);
530
+ // Force re-initialize baseline from actual disk state.
531
+ // This is critical because:
532
+ // 1. Files already at expected state were skipped (baseline not updated for them)
533
+ // 2. After server restart, baseline starts empty — only restored files get entries
534
+ // 3. Files not mentioned in any snapshot are missing from the partial baseline
535
+ // Without this, subsequent captures would compute oldHash='' for files missing
536
+ // from the baseline, causing future restores to incorrectly delete those files.
537
+ this.sessionBaselines.delete(sessionId);
538
+ await this.initializeSessionBaseline(projectPath, sessionId);
537
539
 
538
540
  debug.log('snapshot', `Restore complete: ${restoredFiles} restored, ${skippedFiles} skipped`);
539
541
  return { restoredFiles, skippedFiles };
@@ -126,9 +126,13 @@ export const timelineHandler = createRouter()
126
126
  const isOnActivePath = activePathIds.has(cp.id);
127
127
  const isCurrent = cp.id === activeCheckpointId;
128
128
 
129
- // Orphaned = descendant of active checkpoint in the checkpoint tree
129
+ // Orphaned = in the "future" relative to the current checkpoint.
130
+ // At initial state: ALL checkpoints are orphaned (we've gone back before any messages).
131
+ // Otherwise: only descendants of the active checkpoint that are not on the active path.
130
132
  let isOrphaned = false;
131
- if (activeCheckpointId && !isOnActivePath) {
133
+ if (isAtInitialState) {
134
+ isOrphaned = true;
135
+ } else if (activeCheckpointId && !isOnActivePath) {
132
136
  isOrphaned = isDescendant(cp.id, activeCheckpointId, childrenMap);
133
137
  }
134
138
 
package/bin/clopen.ts CHANGED
@@ -33,6 +33,7 @@ interface CLIOptions {
33
33
  version?: boolean;
34
34
  update?: boolean;
35
35
  resetPat?: boolean;
36
+ clearData?: boolean;
36
37
  }
37
38
 
38
39
  // Get version from package.json
@@ -46,47 +47,274 @@ function getVersion(): string {
46
47
  }
47
48
  }
48
49
 
49
- // Simple loading indicator
50
- let loadingInterval: Timer | null = null;
51
- let currentMessage = '';
50
+ // ============================================================
51
+ // Simple single-line spinner (used for update / clear-data)
52
+ // ============================================================
52
53
 
53
- async function delay(ms: number = 500) {
54
- await new Promise(resolve => setTimeout(resolve, ms));
55
- }
54
+ let spinnerInterval: Timer | null = null;
55
+ let spinnerMessage = '';
56
56
 
57
- function updateLoading(message: string) {
58
- currentMessage = message;
59
- if (!loadingInterval) {
57
+ function startSpinner(message: string) {
58
+ spinnerMessage = message;
59
+ if (!spinnerInterval) {
60
60
  const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
61
61
  let i = 0;
62
- loadingInterval = setInterval(() => {
63
- // Clear line and write new message to avoid text overlap
64
- process.stdout.write(`\r\x1b[K${frames[i]} ${currentMessage}`);
62
+ spinnerInterval = setInterval(() => {
63
+ process.stdout.write(`\r\x1b[K${frames[i]} ${spinnerMessage}`);
65
64
  i = (i + 1) % frames.length;
66
65
  }, 80);
67
66
  }
68
67
  }
69
68
 
70
- function stopLoading() {
71
- if (loadingInterval) {
72
- clearInterval(loadingInterval);
73
- loadingInterval = null;
74
- process.stdout.write('\r\x1b[K'); // Clear line
69
+ function updateSpinner(message: string) {
70
+ spinnerMessage = message;
71
+ }
72
+
73
+ function stopSpinner() {
74
+ if (spinnerInterval) {
75
+ clearInterval(spinnerInterval);
76
+ spinnerInterval = null;
77
+ process.stdout.write('\r\x1b[K');
78
+ }
79
+ }
80
+
81
+ // ============================================================
82
+ // Multi-step progress display (used for startup sequence)
83
+ // ============================================================
84
+
85
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
86
+ const MAX_OUTPUT_LINES = 5;
87
+ const OUTPUT_TRUNCATE = 72;
88
+
89
+ type StepState = 'pending' | 'running' | 'done' | 'skipped' | 'error';
90
+
91
+ interface ProgressStep {
92
+ id: string;
93
+ label: string;
94
+ activeLabel: string;
95
+ }
96
+
97
+ interface StepStatus {
98
+ state: StepState;
99
+ startTime?: number;
100
+ outputLines: string[];
101
+ }
102
+
103
+ const isTTY = process.stdout.isTTY === true;
104
+ let progressSteps: ProgressStep[] = [];
105
+ const stepStatuses = new Map<string, StepStatus>();
106
+ let frameIdx = 0;
107
+ let renderTimer: Timer | null = null;
108
+ let drawnLines = 0;
109
+
110
+ function stripAnsi(str: string): string {
111
+ return str.replace(/\x1b\[[0-9;]*[mGKHFABCDJhls]/g, '');
112
+ }
113
+
114
+ function trunc(str: string, max: number): string {
115
+ if (str.length <= max) return str;
116
+ return str.slice(0, max - 1) + '…';
117
+ }
118
+
119
+ function initProgress(steps: ProgressStep[]) {
120
+ progressSteps = steps;
121
+ stepStatuses.clear();
122
+ drawnLines = 0;
123
+ frameIdx = 0;
124
+ for (const s of steps) {
125
+ stepStatuses.set(s.id, { state: 'pending', outputLines: [] });
126
+ }
127
+ if (isTTY) {
128
+ process.stdout.write('\x1b[?25l'); // hide cursor
129
+ renderTimer = setInterval(renderProgress, 80);
130
+ renderProgress();
131
+ }
132
+ }
133
+
134
+ function renderProgress() {
135
+ if (!isTTY) return;
136
+
137
+ if (drawnLines > 0) {
138
+ process.stdout.write(`\x1b[${drawnLines}A\x1b[J`);
139
+ }
140
+
141
+ let output = '';
142
+ let lines = 0;
143
+
144
+ for (const step of progressSteps) {
145
+ const status = stepStatuses.get(step.id)!;
146
+ let icon: string;
147
+ let label: string;
148
+
149
+ switch (status.state) {
150
+ case 'pending':
151
+ icon = '\x1b[90m·\x1b[0m';
152
+ label = `\x1b[90m${step.label}\x1b[0m`;
153
+ break;
154
+ case 'running': {
155
+ const elapsed = Math.floor((Date.now() - (status.startTime ?? Date.now())) / 1000);
156
+ icon = `\x1b[36m${SPINNER_FRAMES[frameIdx]}\x1b[0m`;
157
+ const timer = elapsed >= 3 ? ` \x1b[90m(${elapsed}s)\x1b[0m` : '';
158
+ label = `${step.activeLabel}${timer}`;
159
+ break;
160
+ }
161
+ case 'done':
162
+ icon = '\x1b[32m✓\x1b[0m';
163
+ label = step.label;
164
+ break;
165
+ case 'skipped':
166
+ icon = '\x1b[90m✓\x1b[0m';
167
+ label = `\x1b[90m${step.label} (up to date)\x1b[0m`;
168
+ break;
169
+ case 'error':
170
+ icon = '\x1b[31m✗\x1b[0m';
171
+ label = `\x1b[31m${step.label}\x1b[0m`;
172
+ break;
173
+ }
174
+
175
+ output += `\x1b[K ${icon} ${label}\n`;
176
+ lines++;
177
+
178
+ // Show last N output lines for the currently running step
179
+ if (status.state === 'running' && status.outputLines.length > 0) {
180
+ const visible = status.outputLines.slice(-MAX_OUTPUT_LINES);
181
+ for (const line of visible) {
182
+ output += `\x1b[K \x1b[90m│ ${trunc(line, OUTPUT_TRUNCATE)}\x1b[0m\n`;
183
+ lines++;
184
+ }
185
+ }
186
+ }
187
+
188
+ process.stdout.write(output);
189
+ drawnLines = lines;
190
+ frameIdx = (frameIdx + 1) % SPINNER_FRAMES.length;
191
+ }
192
+
193
+ function stopProgress() {
194
+ if (renderTimer) {
195
+ clearInterval(renderTimer);
196
+ renderTimer = null;
197
+ }
198
+ if (isTTY) {
199
+ renderProgress(); // final render
200
+ process.stdout.write('\x1b[?25h'); // show cursor
201
+ }
202
+ }
203
+
204
+ function clearProgress() {
205
+ if (renderTimer) {
206
+ clearInterval(renderTimer);
207
+ renderTimer = null;
208
+ }
209
+ if (isTTY) {
210
+ if (drawnLines > 0) {
211
+ process.stdout.write(`\x1b[${drawnLines}A\x1b[J`);
212
+ drawnLines = 0;
213
+ }
214
+ process.stdout.write('\x1b[?25h');
215
+ }
216
+ }
217
+
218
+ function progressStepStart(id: string) {
219
+ const s = stepStatuses.get(id);
220
+ if (!s) return;
221
+ s.state = 'running';
222
+ s.startTime = Date.now();
223
+ s.outputLines = [];
224
+ if (!isTTY) {
225
+ const step = progressSteps.find(p => p.id === id);
226
+ process.stdout.write(` ${step?.activeLabel ?? id}...\n`);
227
+ }
228
+ }
229
+
230
+ function progressStepDone(id: string) {
231
+ const s = stepStatuses.get(id);
232
+ if (!s) return;
233
+ s.state = 'done';
234
+ if (!isTTY) {
235
+ const step = progressSteps.find(p => p.id === id);
236
+ process.stdout.write(` ✓ ${step?.label ?? id}\n`);
237
+ }
238
+ }
239
+
240
+ function progressStepSkip(id: string) {
241
+ const s = stepStatuses.get(id);
242
+ if (!s) return;
243
+ s.state = 'skipped';
244
+ if (!isTTY) {
245
+ const step = progressSteps.find(p => p.id === id);
246
+ process.stdout.write(` ✓ ${step?.label ?? id} (up to date)\n`);
247
+ }
248
+ }
249
+
250
+ function progressStepFail(id: string) {
251
+ const s = stepStatuses.get(id);
252
+ if (!s) return;
253
+ s.state = 'error';
254
+ }
255
+
256
+ function progressAppendOutput(id: string, line: string) {
257
+ const s = stepStatuses.get(id);
258
+ if (!s || s.state !== 'running') return;
259
+ s.outputLines.push(stripAnsi(line).trim());
260
+ }
261
+
262
+ async function streamSubprocess(stream: ReadableStream<Uint8Array>, stepId: string): Promise<void> {
263
+ const reader = stream.getReader();
264
+ const decoder = new TextDecoder();
265
+ let buffer = '';
266
+ try {
267
+ for (;;) {
268
+ const { done, value } = await reader.read();
269
+ if (done) break;
270
+ buffer += decoder.decode(value, { stream: true });
271
+ const parts = buffer.split('\n');
272
+ buffer = parts.pop() ?? '';
273
+ for (const part of parts) {
274
+ const line = part.trim();
275
+ if (line) progressAppendOutput(stepId, line);
276
+ }
277
+ }
278
+ if (buffer.trim()) progressAppendOutput(stepId, buffer.trim());
279
+ } catch {
280
+ // stream closed or error — fine
281
+ } finally {
282
+ reader.releaseLock();
75
283
  }
76
284
  }
77
285
 
286
+ // Restore cursor on unexpected exit
287
+ process.on('exit', () => {
288
+ if (renderTimer) clearInterval(renderTimer);
289
+ if (spinnerInterval) clearInterval(spinnerInterval);
290
+ if (isTTY) process.stdout.write('\x1b[?25h');
291
+ });
292
+
293
+ // ============================================================
294
+ // Constants
295
+ // ============================================================
296
+
78
297
  const __dirname = join(import.meta.dir, '..');
79
298
  const ENV_EXAMPLE = join(__dirname, '.env.example');
80
299
  const ENV_FILE = join(__dirname, '.env');
81
300
  const DIST_DIR = join(__dirname, 'dist');
82
301
  const BUILD_VERSION_FILE = join(DIST_DIR, '.build-version');
83
302
 
84
- // Default values
85
303
  const DEFAULT_PORT = 9141;
86
304
  const DEFAULT_HOST = 'localhost';
87
305
  const MIN_PORT = 1024;
88
306
  const MAX_PORT = 65535;
89
307
 
308
+ const STARTUP_STEPS: ProgressStep[] = [
309
+ { id: 'deps', label: 'Dependencies', activeLabel: 'Installing dependencies' },
310
+ { id: 'build', label: 'Build', activeLabel: 'Building' },
311
+ { id: 'server', label: 'Server', activeLabel: 'Starting server' },
312
+ ];
313
+
314
+ // ============================================================
315
+ // CLI helpers
316
+ // ============================================================
317
+
90
318
  function showHelp() {
91
319
  console.log(`
92
320
  Clopen - All-in-one web workspace for Claude Code & OpenCode
@@ -98,6 +326,7 @@ USAGE:
98
326
  COMMANDS:
99
327
  update Update clopen to the latest version
100
328
  reset-pat Regenerate admin Personal Access Token
329
+ clear-data Delete all projects, sessions, and settings
101
330
 
102
331
  OPTIONS:
103
332
  -p, --port <number> Port to run the server on (default: ${DEFAULT_PORT})
@@ -106,12 +335,10 @@ OPTIONS:
106
335
  -h, --help Show this help message
107
336
 
108
337
  EXAMPLES:
109
- clopen # Start with default settings (port ${DEFAULT_PORT})
110
- clopen --port 9145 # Start on port 9145
111
- clopen --host 0.0.0.0 # Bind to all network interfaces
112
- clopen update # Update to the latest version
113
- clopen reset-pat # Regenerate admin login token
114
- clopen --version # Show version
338
+ clopen # Start with default settings
339
+ clopen --port 9145 # Start on custom port
340
+ clopen -v # Show version
341
+ clopen update # Update to latest version
115
342
 
116
343
  For more information, visit: https://github.com/myrialabs/clopen
117
344
  `);
@@ -175,6 +402,10 @@ function parseArguments(): CLIOptions {
175
402
  options.resetPat = true;
176
403
  break;
177
404
 
405
+ case 'clear-data':
406
+ options.clearData = true;
407
+ break;
408
+
178
409
  default:
179
410
  console.error(`❌ Error: Unknown option "${arg}"`);
180
411
  console.log('Run "clopen --help" for usage information');
@@ -199,30 +430,33 @@ function isNewerVersion(current: string, latest: string): boolean {
199
430
  return false;
200
431
  }
201
432
 
433
+ // ============================================================
434
+ // Commands
435
+ // ============================================================
436
+
202
437
  async function runUpdate() {
203
438
  const currentVersion = getVersion();
204
439
  console.log(`\x1b[36mClopen\x1b[0m v${currentVersion}\n`);
205
440
 
206
- // Check for latest version
207
- updateLoading('Checking for updates...');
441
+ startSpinner('Checking for updates...');
208
442
 
209
443
  let latestVersion: string;
210
444
  try {
211
445
  const response = await fetch('https://registry.npmjs.org/@myrialabs/clopen/latest');
212
446
  if (!response.ok) {
213
- stopLoading();
447
+ stopSpinner();
214
448
  console.error(`❌ Failed to check for updates (HTTP ${response.status})`);
215
449
  process.exit(1);
216
450
  }
217
451
  const data = await response.json() as { version: string };
218
452
  latestVersion = data.version;
219
453
  } catch (err) {
220
- stopLoading();
454
+ stopSpinner();
221
455
  console.error('❌ Failed to reach npm registry:', err instanceof Error ? err.message : err);
222
456
  process.exit(1);
223
457
  }
224
458
 
225
- stopLoading();
459
+ stopSpinner();
226
460
 
227
461
  if (!isNewerVersion(currentVersion, latestVersion)) {
228
462
  console.log(`✓ Already up to date (v${currentVersion})`);
@@ -231,8 +465,7 @@ async function runUpdate() {
231
465
 
232
466
  console.log(` New version available: v${currentVersion} → \x1b[32mv${latestVersion}\x1b[0m\n`);
233
467
 
234
- // Run update
235
- updateLoading(`Updating to v${latestVersion}...`);
468
+ startSpinner(`Updating to v${latestVersion}...`);
236
469
 
237
470
  const proc = Bun.spawn(['bun', 'add', '-g', '@myrialabs/clopen@latest'], {
238
471
  stdout: 'pipe',
@@ -245,7 +478,7 @@ async function runUpdate() {
245
478
  ]);
246
479
 
247
480
  const exitCode = await proc.exited;
248
- stopLoading();
481
+ stopSpinner();
249
482
 
250
483
  if (exitCode !== 0) {
251
484
  const output = (stdout + '\n' + stderr).trim();
@@ -262,7 +495,6 @@ async function recoverAdminToken() {
262
495
  const version = getVersion();
263
496
  console.log(`\x1b[36mClopen\x1b[0m v${version} — Admin Token Recovery\n`);
264
497
 
265
- // Initialize database (import dynamically to avoid loading full backend)
266
498
  const { initializeDatabase } = await import('../backend/database/index');
267
499
  const { listUsers, regeneratePAT } = await import('../backend/auth/auth-service');
268
500
 
@@ -283,8 +515,56 @@ async function recoverAdminToken() {
283
515
  console.log(`\n Use this token to log in. Keep it safe — it won't be shown again.`);
284
516
  }
285
517
 
518
+ async function clearAllData() {
519
+ const version = getVersion();
520
+ console.log(`\x1b[36mClopen\x1b[0m v${version} — Clear All Data\n`);
521
+
522
+ console.log('\x1b[31m⚠ WARNING: This will permanently delete all projects, sessions, and settings.\x1b[0m');
523
+ console.log(' This action cannot be undone.\n');
524
+
525
+ process.stdout.write('Are you sure? Type "yes" to confirm: ');
526
+
527
+ const response = await new Promise<string>(resolve => {
528
+ const chunks: Buffer[] = [];
529
+ process.stdin.once('data', (data: Buffer) => {
530
+ chunks.push(data);
531
+ resolve(Buffer.concat(chunks).toString().trim());
532
+ });
533
+ });
534
+
535
+ if (response !== 'yes') {
536
+ console.log('\nAborted. No data was deleted.');
537
+ process.exit(0);
538
+ }
539
+
540
+ console.log('');
541
+ startSpinner('Clearing all data...');
542
+
543
+ const { initializeDatabase, closeDatabase } = await import('../backend/database/index');
544
+ const { getClopenDir } = await import('../backend/utils/paths');
545
+ const { resetEnvironment } = await import('../backend/engine/adapters/claude/environment');
546
+ const fs = await import('node:fs/promises');
547
+
548
+ await initializeDatabase();
549
+ closeDatabase();
550
+
551
+ const clopenDir = getClopenDir();
552
+ await fs.rm(clopenDir, { recursive: true, force: true });
553
+
554
+ resetEnvironment();
555
+
556
+ await initializeDatabase();
557
+
558
+ stopSpinner();
559
+ console.log('✓ All data cleared successfully.');
560
+ console.log(' Database has been reinitialized with a fresh state.');
561
+ }
562
+
563
+ // ============================================================
564
+ // Startup sequence
565
+ // ============================================================
566
+
286
567
  async function setupEnvironment() {
287
- // Check if .env exists, if not copy from .env.example
288
568
  if (!existsSync(ENV_FILE)) {
289
569
  if (existsSync(ENV_EXAMPLE)) {
290
570
  copyFileSync(ENV_EXAMPLE, ENV_FILE);
@@ -293,43 +573,35 @@ async function setupEnvironment() {
293
573
  }
294
574
 
295
575
  async function installDependencies() {
296
- // Always run bun install to ensure dependencies are up to date
297
- // Bun is fast and will skip if nothing changed
298
- updateLoading('Checking dependencies...');
299
- await delay();
576
+ progressStepStart('deps');
300
577
 
301
- const installProc = Bun.spawn(['bun', 'install', '--silent'], {
578
+ const proc = Bun.spawn(['bun', 'install'], {
302
579
  cwd: __dirname,
303
580
  stdout: 'pipe',
304
- stderr: 'pipe'
581
+ stderr: 'pipe',
305
582
  });
306
583
 
307
- // If install takes longer than 3 seconds, it's actually installing
308
- const updateMessageTimeout = setTimeout(() => {
309
- updateLoading('Installing dependencies...');
310
- }, 3000);
311
-
312
- const exitCode = await installProc.exited;
313
- clearTimeout(updateMessageTimeout);
584
+ const [exitCode] = await Promise.all([
585
+ proc.exited,
586
+ streamSubprocess(proc.stdout as ReadableStream<Uint8Array>, 'deps'),
587
+ streamSubprocess(proc.stderr as ReadableStream<Uint8Array>, 'deps'),
588
+ ]);
314
589
 
315
590
  if (exitCode !== 0) {
316
- stopLoading();
317
- // Show error output only if failed
318
- const errorText = await new Response(installProc.stderr).text();
591
+ progressStepFail('deps');
592
+ clearProgress();
593
+ const status = stepStatuses.get('deps');
319
594
  console.error('❌ Dependency installation failed:');
320
- console.error(errorText);
595
+ if (status?.outputLines.length) console.error(status.outputLines.join('\n'));
321
596
  process.exit(exitCode);
322
597
  }
598
+
599
+ progressStepDone('deps');
323
600
  }
324
601
 
325
602
  function needsBuild(): boolean {
326
- // No dist directory — must build
327
603
  if (!existsSync(DIST_DIR)) return true;
328
-
329
- // No build version file — must build
330
604
  if (!existsSync(BUILD_VERSION_FILE)) return true;
331
-
332
- // Compare built version with current version
333
605
  try {
334
606
  const builtVersion = readFileSync(BUILD_VERSION_FILE, 'utf-8').trim();
335
607
  return builtVersion !== getVersion();
@@ -339,44 +611,46 @@ function needsBuild(): boolean {
339
611
  }
340
612
 
341
613
  async function verifyBuild() {
342
- if (needsBuild()) {
343
- updateLoading('Building...');
344
- await delay();
345
-
346
- const buildProc = Bun.spawn(['bun', 'run', 'build'], {
347
- cwd: __dirname,
348
- stdout: 'pipe',
349
- stderr: 'pipe'
350
- });
614
+ if (!needsBuild()) {
615
+ progressStepSkip('build');
616
+ return;
617
+ }
351
618
 
352
- const exitCode = await buildProc.exited;
619
+ progressStepStart('build');
353
620
 
354
- if (exitCode !== 0) {
355
- stopLoading();
356
- const errorText = await new Response(buildProc.stderr).text();
357
- console.error('❌ Build failed:');
358
- console.error(errorText);
359
- process.exit(exitCode);
360
- }
621
+ const proc = Bun.spawn(['bun', 'run', 'build'], {
622
+ cwd: __dirname,
623
+ stdout: 'pipe',
624
+ stderr: 'pipe',
625
+ });
361
626
 
362
- // Write current version to build version file
363
- writeFileSync(BUILD_VERSION_FILE, getVersion());
627
+ const [exitCode] = await Promise.all([
628
+ proc.exited,
629
+ streamSubprocess(proc.stdout as ReadableStream<Uint8Array>, 'build'),
630
+ streamSubprocess(proc.stderr as ReadableStream<Uint8Array>, 'build'),
631
+ ]);
632
+
633
+ if (exitCode !== 0) {
634
+ progressStepFail('build');
635
+ clearProgress();
636
+ const status = stepStatuses.get('build');
637
+ console.error('❌ Build failed:');
638
+ if (status?.outputLines.length) console.error(status.outputLines.join('\n'));
639
+ process.exit(exitCode);
364
640
  }
641
+
642
+ writeFileSync(BUILD_VERSION_FILE, getVersion());
643
+ progressStepDone('build');
365
644
  }
366
645
 
367
646
  async function startServer(options: CLIOptions) {
368
- updateLoading('Starting server...');
369
- await delay();
647
+ progressStepStart('server');
648
+ progressStepDone('server');
649
+ stopProgress();
650
+ console.log('');
370
651
 
371
- // Delegate to scripts/start.ts — handles port resolution (IPv4 + IPv6
372
- // zombie detection) and starts backend in a single consistent path.
373
652
  const startScript = join(__dirname, 'scripts/start.ts');
374
653
 
375
- stopLoading();
376
-
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
654
  const env = { ...process.env, ...loadEnvFile(ENV_FILE) };
381
655
  if (options.port) env.PORT = options.port.toString();
382
656
  if (options.host) env.HOST = options.host;
@@ -386,19 +660,20 @@ async function startServer(options: CLIOptions) {
386
660
  stdout: 'inherit',
387
661
  stderr: 'inherit',
388
662
  stdin: 'inherit',
389
- env
663
+ env,
390
664
  });
391
665
 
392
- // Wait for server process
393
666
  await serverProc.exited;
394
667
  }
395
668
 
669
+ // ============================================================
670
+ // Main
671
+ // ============================================================
672
+
396
673
  async function main() {
397
674
  try {
398
- // Parse CLI arguments
399
675
  const options = parseArguments();
400
676
 
401
- // Show version if requested
402
677
  if (options.version) {
403
678
  const currentVersion = getVersion();
404
679
  console.log(`v${currentVersion}`);
@@ -420,42 +695,44 @@ async function main() {
420
695
  process.exit(0);
421
696
  }
422
697
 
423
- // Show help if requested
424
698
  if (options.help) {
425
699
  showHelp();
426
700
  process.exit(0);
427
701
  }
428
702
 
429
- // Run update if requested
430
703
  if (options.update) {
431
704
  await runUpdate();
432
705
  process.exit(0);
433
706
  }
434
707
 
435
- // Recover admin token if requested
436
708
  if (options.resetPat) {
437
709
  await setupEnvironment();
438
710
  await recoverAdminToken();
439
711
  process.exit(0);
440
712
  }
441
713
 
442
- // 1. Setup environment variables
443
- await setupEnvironment();
714
+ if (options.clearData) {
715
+ await setupEnvironment();
716
+ await clearAllData();
717
+ process.exit(0);
718
+ }
444
719
 
445
- // 2. Install dependencies if needed
446
- await installDependencies();
720
+ // Print header
721
+ console.log(`\n\x1b[36mClopen\x1b[0m v${getVersion()}\n`);
447
722
 
448
- // 3. Verify/build frontend
449
- await verifyBuild();
723
+ // Init multi-step progress
724
+ initProgress(STARTUP_STEPS);
450
725
 
451
- // 4. Start server
726
+ await setupEnvironment();
727
+ await installDependencies();
728
+ await verifyBuild();
452
729
  await startServer(options);
453
730
 
454
731
  } catch (error) {
732
+ clearProgress();
455
733
  console.error('❌ Failed to start Clopen:', error);
456
734
  process.exit(1);
457
735
  }
458
736
  }
459
737
 
460
- // Run CLI
461
738
  main();
@@ -13,12 +13,12 @@
13
13
 
14
14
  {#if appState.isLoading}
15
15
  <div
16
- class="absolute z-20 {isWelcomeState ? '-top-16' : '-top-14'} left-0 right-0 flex justify-center pointer-events-none"
16
+ class="absolute z-20 h-9 {isWelcomeState ? '-top-16' : '-top-14'} left-0 right-0 flex justify-center pointer-events-none"
17
17
  transition:fly={{ y: 100, duration: 300 }}
18
18
  >
19
19
  {#if appState.isWaitingInput}
20
20
  <!-- Waiting for user input state -->
21
- <div class="flex items-center gap-2.5 px-4 py-2 bg-amber-50 dark:bg-amber-900/30 rounded-full border border-amber-300 dark:border-amber-700 shadow-sm">
21
+ <div class="flex items-center gap-2.5 px-4 py-2 bg-amber-50 dark:bg-amber-950 rounded-full border border-amber-200 dark:border-amber-900 shadow-sm">
22
22
  <Icon name="lucide:message-circle-question-mark" class="w-4 h-4 text-amber-600 dark:text-amber-400" />
23
23
  <span class="text-sm font-medium text-amber-700 dark:text-amber-300">
24
24
  Waiting for your input...
@@ -247,9 +247,11 @@
247
247
  const previousCurrentId = timelineData?.currentHeadId;
248
248
  if (timelineData) {
249
249
  timelineData.currentHeadId = node.id;
250
+ const isInitialRestore = !!node.checkpoint.isInitial;
250
251
  graphNodes = graphNodes.map(n => ({
251
252
  ...n,
252
- isCurrent: n.id === node.id
253
+ isCurrent: n.id === node.id,
254
+ isOrphaned: isInitialRestore ? n.id !== node.id : n.isOrphaned
253
255
  }));
254
256
  }
255
257
 
@@ -201,7 +201,7 @@
201
201
  </div>
202
202
  {/if}
203
203
 
204
- <div class="flex-1 space-y-2">
204
+ <div class="flex-1 space-y-1">
205
205
  <h3 id="dialog-title" class="text-lg font-semibold {colors.text}">
206
206
  {title}
207
207
  </h3>
@@ -227,7 +227,7 @@
227
227
  </div>
228
228
  {/if}
229
229
 
230
- {#if !children}
230
+ {#if !children || onConfirm}
231
231
  <div class="flex justify-end gap-3 pt-2">
232
232
  {#if showCancel}
233
233
  <button
@@ -299,6 +299,13 @@
299
299
  }
300
300
  });
301
301
 
302
+ // Sync isNavigating back to active tab (Canvas resets this on first frame after navigation)
303
+ $effect(() => {
304
+ if (activeTabId && activeTab && activeTab.isNavigating !== isNavigating) {
305
+ tabManager.updateTab(activeTabId, { isNavigating });
306
+ }
307
+ });
308
+
302
309
  // Watch scale changes and send to backend
303
310
  let lastSentScale = 1;
304
311
  $effect(() => {
@@ -573,6 +573,14 @@
573
573
  isReconnecting = false;
574
574
  }, 300);
575
575
  }
576
+
577
+ // Reset navigation state when first frame arrives after navigation.
578
+ // The preview:browser-navigation event that normally resets this can be
579
+ // missed during stream reconnect (listeners are removed/re-registered),
580
+ // so use the first rendered frame as definitive signal that navigation completed.
581
+ if (isNavigating) {
582
+ isNavigating = false;
583
+ }
576
584
  });
577
585
 
578
586
  // Setup cursor change handler
@@ -73,7 +73,7 @@
73
73
  let openCodeCommandCopiedTimer: ReturnType<typeof setTimeout> | null = null;
74
74
 
75
75
  // Debug PTY (xterm.js)
76
- const showDebug = $state(true);
76
+ const showDebug = $state(false);
77
77
  let debugTermContainer = $state<HTMLDivElement>();
78
78
  let debugTerminal: Terminal | null = null;
79
79
  let debugFitAddon: FitAddon | null = null;
@@ -188,7 +188,7 @@
188
188
  <Icon name="lucide:triangle-alert" class="w-6 h-6 text-amber-600 dark:text-amber-400" />
189
189
  </div>
190
190
 
191
- <div class="flex-1 space-y-3">
191
+ <div class="flex-1 space-y-1">
192
192
  <h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100">
193
193
  Change Authentication Mode
194
194
  </h3>
@@ -197,7 +197,7 @@
197
197
  </p>
198
198
 
199
199
  <!-- PAT Display -->
200
- <div class="p-3.5 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800">
200
+ <div class="mt-3 p-3.5 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800">
201
201
  <div class="flex items-center gap-2 text-sm font-semibold text-amber-800 dark:text-amber-200 mb-2">
202
202
  <Icon name="lucide:key-round" class="w-4 h-4" />
203
203
  <span>Your Personal Access Token</span>
@@ -15,7 +15,7 @@
15
15
  // Info box visibility - load from localStorage
16
16
  let showInfoBox = $state(
17
17
  typeof window !== 'undefined'
18
- ? localStorage.getItem('tunnel-info-dismissed') !== 'true'
18
+ ? localStorage.getItem('clopen-tunnel-info-dismissed') !== 'true'
19
19
  : true
20
20
  );
21
21
 
@@ -32,7 +32,7 @@
32
32
 
33
33
  // Save "don't show again" preference
34
34
  if (dontShowWarningAgain) {
35
- localStorage.setItem('tunnel-warning-dismissed', 'true');
35
+ localStorage.setItem('clopen-tunnel-warning-dismissed', 'true');
36
36
  }
37
37
 
38
38
  // Close modal immediately
@@ -59,7 +59,7 @@
59
59
  warningDismissed = false;
60
60
 
61
61
  // Check if user has dismissed warning permanently
62
- const securityWarningDismissed = localStorage.getItem('tunnel-warning-dismissed') === 'true';
62
+ const securityWarningDismissed = localStorage.getItem('clopen-tunnel-warning-dismissed') === 'true';
63
63
  if (securityWarningDismissed) {
64
64
  // Skip warning and start tunnel directly
65
65
  handleStartTunnel();
@@ -70,7 +70,7 @@
70
70
 
71
71
  function closeInfoBox() {
72
72
  showInfoBox = false;
73
- localStorage.setItem('tunnel-info-dismissed', 'true');
73
+ localStorage.setItem('clopen-tunnel-info-dismissed', 'true');
74
74
  }
75
75
 
76
76
  function dismissError() {
@@ -59,7 +59,50 @@ class ChatService {
59
59
  'Create a full-stack e-commerce platform with Next.js, Stripe, and PostgreSQL',
60
60
  'Build a real-time chat application using Socket.io with room support and typing indicators',
61
61
  'Create a SaaS dashboard with user management, billing, and analytics',
62
- // ... (keep the same placeholder texts as before)
62
+ 'Build a REST API with authentication, rate limiting, and Swagger documentation',
63
+ 'Create a CLI tool in TypeScript that scaffolds new projects with custom templates',
64
+ // Debugging & fixing
65
+ 'Debug a memory leak in a Node.js service causing it to crash every 24 hours',
66
+ 'Fix race conditions in a concurrent queue processor causing duplicate jobs',
67
+ 'Fix a CORS issue blocking requests between a frontend and backend on different origins',
68
+ 'Fix broken JWT refresh logic that logs users out unexpectedly',
69
+ 'Fix flaky tests that pass locally but fail randomly in CI',
70
+ // Code review & refactoring
71
+ 'Refactor a 1000-line monolithic function into clean, testable modules',
72
+ 'Convert a class-based React codebase to functional components with hooks',
73
+ 'Migrate a JavaScript project to TypeScript with strict mode enabled',
74
+ 'Refactor database queries to use an ORM with proper migrations',
75
+ 'Clean up and standardize error handling across an entire Express application',
76
+ // Writing tests
77
+ 'Write unit tests for a payment processing module with 100% coverage',
78
+ 'Add end-to-end tests using Playwright for a multi-step checkout flow',
79
+ 'Set up integration tests for a REST API using a real test database',
80
+ 'Write property-based tests to find edge cases in a data validation library',
81
+ 'Set up test coverage reporting and enforce a minimum threshold in CI',
82
+ // Performance & optimization
83
+ 'Optimize a slow PostgreSQL query that runs 10 seconds on a 5M-row table',
84
+ 'Implement Redis caching to reduce database load by 80%',
85
+ 'Reduce bundle size of a React app from 4MB to under 500KB',
86
+ 'Profile and optimize a Python data pipeline processing 1M records per hour',
87
+ 'Add lazy loading and virtualization to a list rendering 10,000 items',
88
+ // Architecture & design
89
+ 'Design a scalable event-driven architecture using Kafka for a high-traffic app',
90
+ 'Plan a migration from a monolith to microservices without downtime',
91
+ 'Design a multi-tenant SaaS architecture with data isolation per customer',
92
+ 'Create an authentication system supporting SSO, OAuth, and MFA',
93
+ 'Architect a real-time notification system using WebSockets and a message queue',
94
+ // DevOps & infrastructure
95
+ 'Write a Dockerfile and docker-compose setup for a full-stack app with hot reload',
96
+ 'Set up a GitHub Actions CI/CD pipeline with testing, linting, and auto-deploy',
97
+ 'Configure Nginx as a reverse proxy with SSL termination and load balancing',
98
+ 'Create Terraform scripts to provision a production-ready AWS infrastructure',
99
+ 'Set up monitoring and alerting using Prometheus and Grafana',
100
+ // AI & data
101
+ 'Build a RAG pipeline using LangChain, embeddings, and a vector database',
102
+ 'Create a sentiment analysis API using a fine-tuned transformer model',
103
+ 'Build a data scraper that extracts and structures product data at scale',
104
+ 'Implement a recommendation engine using collaborative filtering',
105
+ 'Create a real-time data dashboard ingesting from multiple streaming sources',
63
106
  ];
64
107
 
65
108
  constructor() {
@@ -50,7 +50,7 @@ export const settingsSections: SettingsSectionMeta[] = [
50
50
  },
51
51
  {
52
52
  id: 'account',
53
- label: 'Account',
53
+ label: 'User Profile',
54
54
  icon: 'lucide:user',
55
55
  description: 'Your profile and access'
56
56
  },
@@ -3,7 +3,7 @@ import type { Theme } from '$shared/types/ui';
3
3
  // Theme store using Svelte 5 runes
4
4
  export const themeStore = $state({
5
5
  current: {
6
- name: 'claude-modern',
6
+ name: 'clopen-modern',
7
7
  primary: '#D97757',
8
8
  secondary: '#4F46E5',
9
9
  background: '#F9FAFB',
@@ -26,7 +26,7 @@ export function isDarkMode() {
26
26
  // Theme presets
27
27
  export const themes: Theme[] = [
28
28
  {
29
- name: 'claude-modern',
29
+ name: 'clopen-modern',
30
30
  primary: '#D97757',
31
31
  secondary: '#4F46E5',
32
32
  background: '#F9FAFB',
@@ -34,7 +34,7 @@ export const themes: Theme[] = [
34
34
  mode: 'light'
35
35
  },
36
36
  {
37
- name: 'claude-dark',
37
+ name: 'clopen-dark',
38
38
  primary: '#D97757',
39
39
  secondary: '#4F46E5',
40
40
  background: '#111827',
@@ -83,7 +83,7 @@ export function setTheme(theme: Theme) {
83
83
  }
84
84
 
85
85
  // Save to localStorage
86
- localStorage.setItem('claude-theme', JSON.stringify(theme));
86
+ localStorage.setItem('clopen-theme', JSON.stringify(theme));
87
87
  }
88
88
 
89
89
  // Helper function to update theme color meta tag
@@ -105,12 +105,12 @@ export function toggleDarkMode() {
105
105
  const newMode: 'light' | 'dark' = themeStore.isDark ? 'light' : 'dark';
106
106
 
107
107
  // Use predefined themes for consistency
108
- const newTheme = newMode === 'dark' ? themes[1] : themes[0]; // claude-dark or claude-modern
108
+ const newTheme = newMode === 'dark' ? themes[1] : themes[0]; // clopen-dark or clopen-modern
109
109
 
110
110
  setTheme(newTheme);
111
111
 
112
112
  // Mark as manual theme choice
113
- localStorage.setItem('claude-theme-manual', 'true');
113
+ localStorage.setItem('clopen-theme-manual', 'true');
114
114
  }
115
115
 
116
116
  export function initializeTheme() {
@@ -121,8 +121,8 @@ export function initializeTheme() {
121
121
  themeStore.isSystemDark = isSystemDark;
122
122
 
123
123
  // Check for saved theme preference
124
- const savedTheme = localStorage.getItem('claude-theme');
125
- const isManualTheme = localStorage.getItem('claude-theme-manual') === 'true';
124
+ const savedTheme = localStorage.getItem('clopen-theme');
125
+ const isManualTheme = localStorage.getItem('clopen-theme-manual') === 'true';
126
126
 
127
127
  let initialTheme: Theme;
128
128
 
@@ -160,7 +160,7 @@ export function initializeTheme() {
160
160
  themeStore.isSystemDark = e.matches;
161
161
 
162
162
  // Only follow system if no manual theme was set
163
- if (!localStorage.getItem('claude-theme-manual')) {
163
+ if (!localStorage.getItem('clopen-theme-manual')) {
164
164
  const newTheme = e.matches ? themes[1] : themes[0];
165
165
  setTheme(newTheme);
166
166
  }
@@ -169,11 +169,11 @@ export function initializeTheme() {
169
169
 
170
170
  export function setManualTheme(theme: Theme) {
171
171
  setTheme(theme);
172
- localStorage.setItem('claude-theme-manual', 'true');
172
+ localStorage.setItem('clopen-theme-manual', 'true');
173
173
  }
174
174
 
175
175
  export function useSystemTheme() {
176
- localStorage.removeItem('claude-theme-manual');
176
+ localStorage.removeItem('clopen-theme-manual');
177
177
  const defaultTheme = themeStore.isSystemDark ? themes[1] : themes[0];
178
178
  setTheme(defaultTheme);
179
179
  }
@@ -751,7 +751,7 @@ export function setActiveMobilePanel(panelId: PanelId): void {
751
751
  // PERSISTENCE
752
752
  // ============================================
753
753
 
754
- const STORAGE_KEY = 'claude-workspace-layout';
754
+ const STORAGE_KEY = 'clopen-workspace-layout';
755
755
 
756
756
  export function saveWorkspaceState(): void {
757
757
  try {
package/index.html CHANGED
@@ -20,8 +20,8 @@
20
20
  (function() {
21
21
  // Check for saved theme preference
22
22
  try {
23
- const savedTheme = localStorage.getItem('claude-theme');
24
- const isManualTheme = localStorage.getItem('claude-theme-manual') === 'true';
23
+ const savedTheme = localStorage.getItem('clopen-theme');
24
+ const isManualTheme = localStorage.getItem('clopen-theme-manual') === 'true';
25
25
 
26
26
  let isDark = false;
27
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myrialabs/clopen",
3
- "version": "0.2.13",
3
+ "version": "0.2.14",
4
4
  "description": "All-in-one web workspace for Claude Code & OpenCode — chat, terminal, git, browser preview, checkpoints, and real-time collaboration",
5
5
  "author": "Myria Labs",
6
6
  "license": "MIT",
@@ -56,7 +56,7 @@ export async function getOrCreateAnonymousUser(): Promise<AnonymousUser | null>
56
56
  return null;
57
57
  }
58
58
 
59
- const stored = localStorage.getItem('claude-anonymous-user');
59
+ const stored = localStorage.getItem('clopen-anonymous-user');
60
60
 
61
61
  if (stored) {
62
62
  try {
@@ -76,7 +76,7 @@ export async function getOrCreateAnonymousUser(): Promise<AnonymousUser | null>
76
76
  const newUser = await generateAnonymousUserFromServer();
77
77
 
78
78
  if (newUser) {
79
- localStorage.setItem('claude-anonymous-user', JSON.stringify(newUser));
79
+ localStorage.setItem('clopen-anonymous-user', JSON.stringify(newUser));
80
80
  return newUser;
81
81
  }
82
82
 
@@ -93,7 +93,7 @@ export function getCurrentAnonymousUser(): AnonymousUser | null {
93
93
  return null;
94
94
  }
95
95
 
96
- const stored = localStorage.getItem('claude-anonymous-user');
96
+ const stored = localStorage.getItem('clopen-anonymous-user');
97
97
 
98
98
  if (stored) {
99
99
  try {
@@ -146,7 +146,7 @@ export async function updateAnonymousUserName(newName: string): Promise<Anonymou
146
146
  });
147
147
 
148
148
  // Save to localStorage
149
- localStorage.setItem('claude-anonymous-user', JSON.stringify(response));
149
+ localStorage.setItem('clopen-anonymous-user', JSON.stringify(response));
150
150
  debug.log('user', '✅ Updated user name:', response.name);
151
151
  return response;
152
152
  } catch (error) {