@myrialabs/clopen 0.1.8 → 0.1.10

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.
Files changed (39) hide show
  1. package/backend/index.ts +5 -1
  2. package/backend/lib/chat/stream-manager.ts +4 -1
  3. package/backend/lib/database/migrations/023_create_user_unread_sessions_table.ts +32 -0
  4. package/backend/lib/database/migrations/index.ts +7 -0
  5. package/backend/lib/database/queries/session-queries.ts +50 -0
  6. package/backend/lib/database/queries/snapshot-queries.ts +1 -1
  7. package/backend/lib/engine/adapters/opencode/server.ts +8 -0
  8. package/backend/lib/engine/adapters/opencode/stream.ts +175 -1
  9. package/backend/lib/snapshot/helpers.ts +22 -49
  10. package/backend/lib/snapshot/snapshot-service.ts +148 -83
  11. package/backend/ws/chat/stream.ts +13 -0
  12. package/backend/ws/sessions/crud.ts +34 -2
  13. package/backend/ws/snapshot/restore.ts +111 -12
  14. package/backend/ws/snapshot/timeline.ts +56 -29
  15. package/backend/ws/user/crud.ts +8 -4
  16. package/bin/clopen.ts +17 -1
  17. package/frontend/lib/components/chat/input/ChatInput.svelte +1 -2
  18. package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +2 -2
  19. package/frontend/lib/components/chat/input/composables/use-chat-actions.svelte.ts +4 -4
  20. package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +11 -19
  21. package/frontend/lib/components/checkpoint/TimelineModal.svelte +15 -3
  22. package/frontend/lib/components/checkpoint/timeline/TimelineNode.svelte +30 -19
  23. package/frontend/lib/components/checkpoint/timeline/types.ts +4 -0
  24. package/frontend/lib/components/git/CommitForm.svelte +6 -4
  25. package/frontend/lib/components/git/GitLog.svelte +26 -12
  26. package/frontend/lib/components/history/HistoryModal.svelte +1 -1
  27. package/frontend/lib/components/history/HistoryView.svelte +1 -1
  28. package/frontend/lib/components/preview/browser/components/Toolbar.svelte +81 -72
  29. package/frontend/lib/components/settings/SettingsModal.svelte +1 -8
  30. package/frontend/lib/components/terminal/Terminal.svelte +1 -1
  31. package/frontend/lib/components/terminal/TerminalTabs.svelte +28 -26
  32. package/frontend/lib/components/workspace/PanelHeader.svelte +1 -1
  33. package/frontend/lib/components/workspace/WorkspaceLayout.svelte +3 -2
  34. package/frontend/lib/components/workspace/panels/ChatPanel.svelte +3 -2
  35. package/frontend/lib/stores/core/app.svelte.ts +46 -0
  36. package/frontend/lib/stores/core/sessions.svelte.ts +39 -4
  37. package/frontend/lib/stores/ui/update.svelte.ts +0 -12
  38. package/frontend/lib/stores/ui/workspace.svelte.ts +4 -4
  39. package/package.json +1 -1
package/backend/index.ts CHANGED
@@ -118,7 +118,11 @@ async function startServer() {
118
118
  port: actualPort,
119
119
  hostname: HOST
120
120
  }, () => {
121
- console.log(`🚀 Clopen running at http://localhost:${actualPort}`);
121
+ if (isDevelopment) {
122
+ console.log('🚀 Backend ready — waiting for frontend...');
123
+ } else {
124
+ console.log(`🚀 Clopen running at http://localhost:${actualPort}`);
125
+ }
122
126
  if (HOST === '0.0.0.0') {
123
127
  const ips = getLocalIps();
124
128
  for (const ip of ips) {
@@ -891,7 +891,10 @@ class StreamManager extends EventEmitter {
891
891
  const { projectPath, projectId, chatSessionId } = requestData;
892
892
  if (projectPath && projectId && chatSessionId && userMessageId) {
893
893
  snapshotService.captureSnapshot(projectPath, projectId, chatSessionId, userMessageId)
894
- .then(() => debug.log('chat', `Stream-end snapshot captured for message: ${userMessageId}`))
894
+ .then(() => {
895
+ debug.log('chat', `Stream-end snapshot captured for message: ${userMessageId}`);
896
+ this.emit('snapshot:captured', { projectId, chatSessionId });
897
+ })
895
898
  .catch(err => debug.error('chat', 'Failed to capture stream-end snapshot:', err));
896
899
  }
897
900
  }
@@ -0,0 +1,32 @@
1
+ import type { DatabaseConnection } from '$shared/types/database/connection';
2
+ import { debug } from '$shared/utils/logger';
3
+
4
+ export const description = 'Create user_unread_sessions table for persisting per-user unread session state';
5
+
6
+ export const up = (db: DatabaseConnection): void => {
7
+ debug.log('migration', 'Creating user_unread_sessions table...');
8
+
9
+ db.exec(`
10
+ CREATE TABLE IF NOT EXISTS user_unread_sessions (
11
+ user_id TEXT NOT NULL,
12
+ session_id TEXT NOT NULL,
13
+ project_id TEXT NOT NULL,
14
+ marked_at TEXT NOT NULL,
15
+ PRIMARY KEY (user_id, session_id),
16
+ FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE
17
+ )
18
+ `);
19
+
20
+ db.exec(`
21
+ CREATE INDEX IF NOT EXISTS idx_user_unread_sessions_user_project
22
+ ON user_unread_sessions(user_id, project_id)
23
+ `);
24
+
25
+ debug.log('migration', 'user_unread_sessions table created');
26
+ };
27
+
28
+ export const down = (db: DatabaseConnection): void => {
29
+ debug.log('migration', 'Dropping user_unread_sessions table...');
30
+ db.exec('DROP TABLE IF EXISTS user_unread_sessions');
31
+ debug.log('migration', 'user_unread_sessions table dropped');
32
+ };
@@ -21,6 +21,7 @@ import * as migration019 from './019_add_claude_account_to_sessions';
21
21
  import * as migration020 from './020_add_snapshot_tree_hash';
22
22
  import * as migration021 from './021_drop_prompt_templates_table';
23
23
  import * as migration022 from './022_add_snapshot_changes_column';
24
+ import * as migration023 from './023_create_user_unread_sessions_table';
24
25
 
25
26
  // Export all migrations in order
26
27
  export const migrations = [
@@ -155,6 +156,12 @@ export const migrations = [
155
156
  description: migration022.description,
156
157
  up: migration022.up,
157
158
  down: migration022.down
159
+ },
160
+ {
161
+ id: '023',
162
+ description: migration023.description,
163
+ up: migration023.up,
164
+ down: migration023.down
158
165
  }
159
166
  ];
160
167
 
@@ -188,6 +188,19 @@ export const sessionQueries = {
188
188
  `).run(messageId, sessionId);
189
189
  },
190
190
 
191
+ /**
192
+ * Clear the HEAD pointer (set to NULL).
193
+ * Used when restoring to the initial state (before any messages).
194
+ */
195
+ clearHead(sessionId: string): void {
196
+ const db = getDatabase();
197
+ db.prepare(`
198
+ UPDATE chat_sessions
199
+ SET current_head_message_id = NULL
200
+ WHERE id = ?
201
+ `).run(sessionId);
202
+ },
203
+
191
204
  /**
192
205
  * Get current HEAD message ID for a session
193
206
  */
@@ -267,5 +280,42 @@ export const sessionQueries = {
267
280
  SET head_message_id = ?
268
281
  WHERE session_id = ? AND branch_name = ?
269
282
  `).run(newHeadMessageId, sessionId, branchName);
283
+ },
284
+
285
+ // ==================== PER-USER UNREAD SESSION TRACKING ====================
286
+
287
+ /**
288
+ * Mark a session as unread for a specific user
289
+ */
290
+ markUnread(userId: string, sessionId: string, projectId: string): void {
291
+ const db = getDatabase();
292
+ const now = new Date().toISOString();
293
+ db.prepare(`
294
+ INSERT OR IGNORE INTO user_unread_sessions (user_id, session_id, project_id, marked_at)
295
+ VALUES (?, ?, ?, ?)
296
+ `).run(userId, sessionId, projectId, now);
297
+ },
298
+
299
+ /**
300
+ * Mark a session as read for a specific user
301
+ */
302
+ markRead(userId: string, sessionId: string): void {
303
+ const db = getDatabase();
304
+ db.prepare(`
305
+ DELETE FROM user_unread_sessions
306
+ WHERE user_id = ? AND session_id = ?
307
+ `).run(userId, sessionId);
308
+ },
309
+
310
+ /**
311
+ * Get all unread session IDs for a user within a project
312
+ * Returns array of { sessionId, projectId }
313
+ */
314
+ getUnreadSessions(userId: string, projectId: string): { session_id: string; project_id: string }[] {
315
+ const db = getDatabase();
316
+ return db.prepare(`
317
+ SELECT session_id, project_id FROM user_unread_sessions
318
+ WHERE user_id = ? AND project_id = ?
319
+ `).all(userId, projectId) as { session_id: string; project_id: string }[];
270
320
  }
271
321
  };
@@ -114,7 +114,7 @@ export const snapshotQueries = {
114
114
  const db = getDatabase();
115
115
  const snapshots = db.prepare(`
116
116
  SELECT * FROM message_snapshots
117
- WHERE session_id = ?
117
+ WHERE session_id = ? AND (is_deleted IS NULL OR is_deleted = 0)
118
118
  ORDER BY created_at ASC
119
119
  `).all(sessionId) as MessageSnapshot[];
120
120
 
@@ -81,6 +81,14 @@ export function getClient(): OpencodeClient | null {
81
81
  return ready ? client : null;
82
82
  }
83
83
 
84
+ /**
85
+ * Get the OpenCode server base URL (e.g. "http://127.0.0.1:4096").
86
+ * Used for direct HTTP calls to v2 endpoints not available on the v1 client.
87
+ */
88
+ export function getServerUrl(): string | null {
89
+ return serverHandle?.url ?? null;
90
+ }
91
+
84
92
  /**
85
93
  * Dispose the OpenCode client and stop the server.
86
94
  * Called during full server shutdown (disposeAllEngines).
@@ -40,7 +40,7 @@ import {
40
40
  convertSubtaskToolUseOnly,
41
41
  getToolInput,
42
42
  } from './message-converter';
43
- import { ensureClient, getClient } from './server';
43
+ import { ensureClient, getClient, getServerUrl } from './server';
44
44
  import { debug } from '$shared/utils/logger';
45
45
 
46
46
  /** Map SDK Model.status to our category */
@@ -75,6 +75,8 @@ export class OpenCodeEngine implements AIEngine {
75
75
  private activeAbortController: AbortController | null = null;
76
76
  private activeSessionId: string | null = null;
77
77
  private activeProjectPath: string | null = null;
78
+ /** Pending question requests keyed by tool callID → { requestId, questions } */
79
+ private pendingQuestions = new Map<string, { requestId: string; questions: Array<{ question: string }> }>();
78
80
 
79
81
  get isInitialized(): boolean {
80
82
  return this._isInitialized;
@@ -102,6 +104,7 @@ export class OpenCodeEngine implements AIEngine {
102
104
  */
103
105
  async dispose(): Promise<void> {
104
106
  await this.cancel();
107
+ this.pendingQuestions.clear();
105
108
  this._isInitialized = false;
106
109
  debug.log('engine', 'Open Code engine instance disposed');
107
110
  }
@@ -664,6 +667,39 @@ export class OpenCodeEngine implements AIEngine {
664
667
  break;
665
668
  }
666
669
 
670
+ // v2 question event — emitted when the question tool needs user input
671
+ case 'question.asked': {
672
+ const props = evt.properties as {
673
+ id: string;
674
+ sessionID: string;
675
+ questions: Array<{ question: string; header: string; options: Array<{ label: string; description: string }> }>;
676
+ tool?: { messageID: string; callID: string };
677
+ };
678
+ if (props.sessionID !== sessionId) break;
679
+ if (props.tool?.callID) {
680
+ this.pendingQuestions.set(props.tool.callID, {
681
+ requestId: props.id,
682
+ questions: props.questions,
683
+ });
684
+ debug.log('engine', `[OC] question.asked: stored question ${props.id} for callID ${props.tool.callID}`);
685
+ }
686
+ break;
687
+ }
688
+
689
+ // v2 permission event — auto-approve to avoid blocking the session
690
+ // (tool permissions like file_write, bash, etc. are bypassed)
691
+ case 'permission.asked':
692
+ case 'permission.updated': {
693
+ const props = evt.properties as {
694
+ id: string;
695
+ sessionID: string;
696
+ callID?: string;
697
+ };
698
+ if (props.sessionID !== sessionId) break;
699
+ this.autoApprovePermission(props.id, props.sessionID);
700
+ break;
701
+ }
702
+
667
703
  case 'session.error': {
668
704
  const errorProps = (event as EventSessionError).properties;
669
705
  // Only handle errors for our session (sessionID is optional on errors)
@@ -729,6 +765,7 @@ export class OpenCodeEngine implements AIEngine {
729
765
  this.activeAbortController = null;
730
766
  this.activeSessionId = null;
731
767
  this.activeProjectPath = null;
768
+ this.pendingQuestions.clear();
732
769
  }
733
770
  }
734
771
 
@@ -754,6 +791,7 @@ export class OpenCodeEngine implements AIEngine {
754
791
  this._isActive = false;
755
792
  this.activeSessionId = null;
756
793
  this.activeProjectPath = null;
794
+ this.pendingQuestions.clear();
757
795
  }
758
796
 
759
797
  /**
@@ -779,6 +817,142 @@ export class OpenCodeEngine implements AIEngine {
779
817
  await this.cancel();
780
818
  }
781
819
 
820
+ /**
821
+ * Resolve a pending AskUserQuestion by replying via the OpenCode question API.
822
+ *
823
+ * Flow:
824
+ * 1. If a `question.asked` event was received → use stored requestId to reply
825
+ * 2. Fallback → fetch pending questions from GET /question and match by callID
826
+ *
827
+ * The reply is sent to POST /question/{requestID}/reply with answers
828
+ * ordered by the original questions array.
829
+ */
830
+ resolveUserAnswer(toolUseId: string, answers: Record<string, string>): boolean {
831
+ const pending = this.pendingQuestions.get(toolUseId);
832
+
833
+ if (pending) {
834
+ // Convert Record<questionText, answerLabel> → Array<Array<string>> ordered by questions
835
+ const orderedAnswers = pending.questions.map(q => {
836
+ const answer = answers[q.question];
837
+ return answer ? [answer] : [];
838
+ });
839
+ this.replyToQuestion(pending.requestId, orderedAnswers);
840
+ this.pendingQuestions.delete(toolUseId);
841
+ return true;
842
+ }
843
+
844
+ // Fallback: fetch pending questions from the API and find matching one
845
+ debug.log('engine', `resolveUserAnswer: No stored question for toolUseId ${toolUseId}, fetching from API...`);
846
+ this.fetchAndReplyToQuestion(toolUseId, answers);
847
+ return true;
848
+ }
849
+
850
+ /**
851
+ * POST /question/{requestID}/reply to send user answers back to the OpenCode server.
852
+ */
853
+ private replyToQuestion(requestId: string, orderedAnswers: string[][]): void {
854
+ const serverUrl = getServerUrl();
855
+ if (!serverUrl) {
856
+ debug.warn('engine', 'replyToQuestion: Server URL not available');
857
+ return;
858
+ }
859
+
860
+ const dirParam = this.activeProjectPath ? `?directory=${encodeURIComponent(this.activeProjectPath)}` : '';
861
+ const url = `${serverUrl}/question/${requestId}/reply${dirParam}`;
862
+ debug.log('engine', `Replying to question ${requestId}:`, orderedAnswers);
863
+
864
+ fetch(url, {
865
+ method: 'POST',
866
+ headers: { 'Content-Type': 'application/json' },
867
+ body: JSON.stringify({ answers: orderedAnswers }),
868
+ }).then(async res => {
869
+ if (res.ok) {
870
+ debug.log('engine', `Question reply accepted: ${requestId} (${res.status})`);
871
+ } else {
872
+ const body = await res.text().catch(() => '');
873
+ debug.error('engine', `Question reply failed: ${res.status} ${res.statusText}`, body);
874
+ }
875
+ }).catch(error => {
876
+ debug.error('engine', 'Failed to reply to question:', error);
877
+ });
878
+ }
879
+
880
+ /**
881
+ * Fallback: GET /question to list pending questions, find the matching one, and reply.
882
+ */
883
+ private async fetchAndReplyToQuestion(toolUseId: string, answers: Record<string, string>): Promise<void> {
884
+ const serverUrl = getServerUrl();
885
+ if (!serverUrl) {
886
+ debug.warn('engine', 'fetchAndReplyToQuestion: Server URL not available');
887
+ return;
888
+ }
889
+
890
+ try {
891
+ const dirParam = this.activeProjectPath ? `?directory=${encodeURIComponent(this.activeProjectPath)}` : '';
892
+ const res = await fetch(`${serverUrl}/question${dirParam}`);
893
+ if (!res.ok) {
894
+ debug.error('engine', `Failed to list pending questions: ${res.status}`);
895
+ return;
896
+ }
897
+
898
+ const questions = await res.json() as Array<{
899
+ id: string;
900
+ questions: Array<{ question: string }>;
901
+ tool?: { callID: string };
902
+ }>;
903
+
904
+ const matching = questions.find(q => q.tool?.callID === toolUseId);
905
+ if (!matching) {
906
+ debug.warn('engine', 'fetchAndReplyToQuestion: No matching question for toolUseId:', toolUseId);
907
+ return;
908
+ }
909
+
910
+ const orderedAnswers = matching.questions.map(q => {
911
+ const answer = answers[q.question];
912
+ return answer ? [answer] : [];
913
+ });
914
+ this.replyToQuestion(matching.id, orderedAnswers);
915
+ } catch (error) {
916
+ debug.error('engine', 'Failed to fetch and reply to question:', error);
917
+ }
918
+ }
919
+
920
+ /**
921
+ * Auto-approve a permission request to avoid blocking the session.
922
+ * Uses direct HTTP since the v1 client may not have the v2 permission.reply method.
923
+ */
924
+ private autoApprovePermission(permissionId: string, sessionId: string): void {
925
+ const serverUrl = getServerUrl();
926
+ if (!serverUrl) return;
927
+
928
+ // Try v2 endpoint first (/permission/{requestID}/reply), fall back to v1
929
+ fetch(`${serverUrl}/permission/${permissionId}/reply`, {
930
+ method: 'POST',
931
+ headers: { 'Content-Type': 'application/json' },
932
+ body: JSON.stringify({ reply: 'once' }),
933
+ }).then(res => {
934
+ if (res.ok) {
935
+ debug.log('engine', `[OC] auto-approved permission ${permissionId} (v2)`);
936
+ return;
937
+ }
938
+ // v2 endpoint not available — try v1
939
+ const client = getClient();
940
+ if (client) {
941
+ client.postSessionIdPermissionsPermissionId({
942
+ path: { id: sessionId, permissionID: permissionId },
943
+ body: { response: 'once' },
944
+ ...(this.activeProjectPath && { query: { directory: this.activeProjectPath } }),
945
+ }).then(() => {
946
+ debug.log('engine', `[OC] auto-approved permission ${permissionId} (v1)`);
947
+ }).catch(err => {
948
+ debug.error('engine', 'Failed to auto-approve permission (v1):', err);
949
+ });
950
+ }
951
+ }).catch(error => {
952
+ debug.error('engine', 'Failed to auto-approve permission:', error);
953
+ });
954
+ }
955
+
782
956
  /**
783
957
  * Extract prompt parts (text + file attachments) from SDKUserMessage.
784
958
  * Converts Claude-format image/document blocks to OpenCode FilePartInput format.
@@ -7,6 +7,9 @@ import type { DatabaseMessage } from '$shared/types/database/schema';
7
7
  * Snapshot domain helper functions
8
8
  */
9
9
 
10
+ /** Sentinel ID for the "initial state" node (before any chat messages) */
11
+ export const INITIAL_NODE_ID = '__initial__';
12
+
10
13
  export interface CheckpointNode {
11
14
  id: string;
12
15
  messageId: string;
@@ -18,6 +21,7 @@ export interface CheckpointNode {
18
21
  isOrphaned: boolean; // descendant of current active checkpoint
19
22
  isCurrent: boolean; // this is the current active checkpoint
20
23
  hasSnapshot: boolean;
24
+ isInitial?: boolean; // true for the "initial state" node
21
25
  senderName?: string | null;
22
26
  // File change statistics (git-like)
23
27
  filesChanged?: number;
@@ -335,62 +339,31 @@ export function isDescendant(
335
339
  }
336
340
 
337
341
  /**
338
- * Get file change stats for a checkpoint by looking at snapshots
339
- * between this checkpoint and the next.
342
+ * Get file change stats for a checkpoint.
343
+ * The snapshot associated with the checkpoint message itself contains the stats
344
+ * (file changes the assistant made in response to this user message).
340
345
  */
341
346
  export function getCheckpointFileStats(
342
- checkpointMsg: DatabaseMessage,
343
- allMessages: DatabaseMessage[],
344
- nextCheckpointTimestamp?: string
347
+ checkpointMsg: DatabaseMessage
345
348
  ): { filesChanged: number; insertions: number; deletions: number } {
346
- let filesChanged = 0;
347
- let insertions = 0;
348
- let deletions = 0;
349
-
350
- const checkpointTimestamp = checkpointMsg.timestamp;
351
-
352
- const laterMessages = allMessages
353
- .filter(m => {
354
- if (m.timestamp <= checkpointTimestamp) return false;
355
- if (nextCheckpointTimestamp && m.timestamp >= nextCheckpointTimestamp) return false;
356
- return true;
357
- })
358
- .sort((a, b) => a.timestamp.localeCompare(b.timestamp));
349
+ const snapshot = snapshotQueries.getByMessageId(checkpointMsg.id);
350
+ if (!snapshot) {
351
+ return { filesChanged: 0, insertions: 0, deletions: 0 };
352
+ }
359
353
 
360
- const allChangedFiles = new Set<string>();
361
- const statsInRange: Array<{ files: number; ins: number; del: number }> = [];
354
+ let filesChanged = snapshot.files_changed || 0;
355
+ const insertions = snapshot.insertions || 0;
356
+ const deletions = snapshot.deletions || 0;
362
357
 
363
- for (const msg of laterMessages) {
358
+ // Try to get a more accurate file count from session_changes
359
+ if (snapshot.session_changes) {
364
360
  try {
365
- const sdkMsg = JSON.parse(msg.sdk_message) as SDKMessage;
366
- if (sdkMsg.type !== 'user') continue;
367
-
368
- const userSnapshot = snapshotQueries.getByMessageId(msg.id);
369
- if (!userSnapshot) continue;
370
-
371
- const fc = userSnapshot.files_changed || 0;
372
- const ins = userSnapshot.insertions || 0;
373
- const del = userSnapshot.deletions || 0;
374
-
375
- if (fc > 0 || ins > 0 || del > 0) {
376
- statsInRange.push({ files: fc, ins, del });
377
- }
378
-
379
- if (userSnapshot.delta_changes) {
380
- try {
381
- const delta = JSON.parse(userSnapshot.delta_changes);
382
- if (delta.added) Object.keys(delta.added).forEach(f => allChangedFiles.add(f));
383
- if (delta.modified) Object.keys(delta.modified).forEach(f => allChangedFiles.add(f));
384
- if (delta.deleted && Array.isArray(delta.deleted)) delta.deleted.forEach((f: string) => allChangedFiles.add(f));
385
- } catch { /* skip */ }
361
+ const changes = JSON.parse(snapshot.session_changes as string);
362
+ const changeCount = Object.keys(changes).length;
363
+ if (changeCount > 0) {
364
+ filesChanged = changeCount;
386
365
  }
387
- } catch { /* skip */ }
388
- }
389
-
390
- if (statsInRange.length > 0) {
391
- filesChanged = allChangedFiles.size > 0 ? allChangedFiles.size : Math.max(...statsInRange.map(s => s.files));
392
- insertions = statsInRange.reduce((sum, s) => sum + s.ins, 0);
393
- deletions = statsInRange.reduce((sum, s) => sum + s.del, 0);
366
+ } catch { /* use files_changed from DB */ }
394
367
  }
395
368
 
396
369
  return { filesChanged, insertions, deletions };