@yeaft/webchat-agent 1.0.268 → 1.0.270

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.
@@ -1,13 +1,24 @@
1
1
  import { randomUUID } from 'crypto';
2
2
  import { CONFIG } from '../config.js';
3
- import { sessionDb, messageDb, userDb, yeaftSessionDb } from '../database.js';
3
+ import {
4
+ sessionDb,
5
+ messageDb,
6
+ userDb,
7
+ yeaftSessionDb,
8
+ sessionUiMetadataDb,
9
+ } from '../database.js';
4
10
  import { agents, pendingFiles, trackUserTurn, webClients } from '../context.js';
5
11
  import {
6
12
  sendToWebClient, forwardToAgent,
7
- broadcastAgentList, verifyConversationOwnership, verifyAgentOwnership
13
+ broadcastAgentList, broadcastSessionCatalog,
14
+ verifyConversationOwnership, verifyAgentOwnership
8
15
  } from '../ws-utils.js';
9
16
  import { routeSessionPin } from './session-pin-router.js';
10
17
  import { recordPerfTraceEvent } from '../perf-trace.js';
18
+ import {
19
+ chatCatalogKey,
20
+ yeaftCatalogKey,
21
+ } from '../session-catalog.js';
11
22
 
12
23
 
13
24
  function isRetiredCollabSessionId(id) {
@@ -33,6 +44,22 @@ async function broadcastSessionPin(userId, payload) {
33
44
  }
34
45
  }
35
46
 
47
+ function persistSessionPin(userId, routeRef, pinned) {
48
+ const { runtimeProvider, agentId, sessionId } = routeRef;
49
+ const catalogKey = runtimeProvider === 'yeaft'
50
+ ? yeaftCatalogKey(agentId, sessionId)
51
+ : chatCatalogKey(sessionId);
52
+ const current = sessionUiMetadataDb.get(userId, catalogKey);
53
+ return sessionUiMetadataDb.applyBatch(userId, [{
54
+ catalogKey,
55
+ runtimeProvider,
56
+ agentId,
57
+ sessionId,
58
+ pinned,
59
+ sortRank: current?.sortRank ?? null,
60
+ }]);
61
+ }
62
+
36
63
  export function groupOnlineYeaftSessions(rows, agentRegistry = agents) {
37
64
  const byAgent = {};
38
65
  for (const row of Array.isArray(rows) ? rows : []) {
@@ -289,44 +316,58 @@ export async function handleClientConversation(clientId, client, msg, checkAgent
289
316
  }
290
317
 
291
318
  case 'delete_conversation': {
292
- if (!client.currentAgent) return;
293
-
294
- // DB cleanup: always execute regardless of agent online status
295
- // Use verifyConversationOwnership (checks DB) instead of checkAgentAccess (requires agent in memory)
296
- // This ensures close/delete works even when the agent is offline
297
- if (!CONFIG.skipAuth && !verifyConversationOwnership(msg.conversationId, client.userId)) {
298
- console.warn(`[Security] User ${client.userId} attempted to delete conversation ${msg.conversationId} they don't own`);
299
- await sendToWebClient(client, { type: 'error', message: 'Permission denied' });
319
+ const persisted = sessionDb.get(msg.conversationId);
320
+ const deleteAgentId = msg.agentId || persisted?.agent_id || client.currentAgent;
321
+ if (!deleteAgentId) return;
322
+ if (!CONFIG.skipAuth && (!verifyConversationOwnership(msg.conversationId, client.userId, client.role)
323
+ || (agents.has(deleteAgentId) && !verifyAgentOwnership(deleteAgentId, client.userId, client.role))
324
+ || (persisted?.agent_id && persisted.agent_id !== deleteAgentId))) {
325
+ console.warn(`[Security] User ${client.userId} attempted to delete conversation ${msg.conversationId} on agent ${deleteAgentId}`);
326
+ await sendToWebClient(client, {
327
+ type: 'conversation_delete_result',
328
+ requestId: msg.requestId || null,
329
+ conversationId: msg.conversationId,
330
+ agentId: deleteAgentId,
331
+ ok: false,
332
+ error: 'Permission denied',
333
+ });
300
334
  return;
301
335
  }
302
336
 
303
- // Always deactivate in DB — this is the critical fix
304
337
  try {
305
338
  sessionDb.setActive(msg.conversationId, false);
339
+ const deleteAgent = agents.get(deleteAgentId);
340
+ deleteAgent?.conversations.delete(msg.conversationId);
341
+ await broadcastAgentList();
342
+ if (deleteAgent?.ws?.readyState === 1) {
343
+ await forwardToAgent(deleteAgentId, {
344
+ type: 'delete_conversation',
345
+ conversationId: msg.conversationId,
346
+ });
347
+ }
348
+ await sendToWebClient(client, {
349
+ type: 'conversation_delete_result',
350
+ requestId: msg.requestId || null,
351
+ conversationId: msg.conversationId,
352
+ agentId: deleteAgentId,
353
+ ok: true,
354
+ });
306
355
  } catch (e) {
307
356
  console.error('Failed to deactivate session in database:', e.message);
308
- }
309
-
310
- // Remove from agent's in-memory conversations (if agent is online)
311
- const deleteAgent = agents.get(client.currentAgent);
312
- if (deleteAgent) {
313
- deleteAgent.conversations.delete(msg.conversationId);
314
- }
315
- await broadcastAgentList();
316
-
317
- // Forward to agent for resource cleanup (terminals, processes, etc.) — best effort
318
- // Only attempt if agent is online with an open WebSocket
319
- if (deleteAgent?.ws?.readyState === 1) {
320
- await forwardToAgent(client.currentAgent, {
321
- type: 'delete_conversation',
322
- conversationId: msg.conversationId
357
+ await sendToWebClient(client, {
358
+ type: 'conversation_delete_result',
359
+ requestId: msg.requestId || null,
360
+ conversationId: msg.conversationId,
361
+ agentId: deleteAgentId,
362
+ ok: false,
363
+ error: e.message,
323
364
  });
324
365
  }
325
366
  break;
326
367
  }
327
368
 
328
369
  case 'select_conversation':
329
- if (!CONFIG.skipAuth && !verifyConversationOwnership(msg.conversationId, client.userId)) {
370
+ if (!CONFIG.skipAuth && !verifyConversationOwnership(msg.conversationId, client.userId, client.role)) {
330
371
  console.warn(`[Security] User ${client.userId} attempted to select conversation ${msg.conversationId} they don't own`);
331
372
  await sendToWebClient(client, { type: 'error', message: 'Permission denied' });
332
373
  return;
@@ -361,6 +402,7 @@ export async function handleClientConversation(clientId, client, msg, checkAgent
361
402
  } catch (e) {
362
403
  console.warn('[Server] yeaftSessionDb reorder failed:', e?.message || e);
363
404
  }
405
+ if (ok) await broadcastSessionCatalog(client.userId);
364
406
  await sendToWebClient(client, {
365
407
  type: 'session_crud_result',
366
408
  op: 'reorder',
@@ -371,6 +413,109 @@ export async function handleClientConversation(clientId, client, msg, checkAgent
371
413
  break;
372
414
  }
373
415
 
416
+ case 'reorder_session_catalog': {
417
+ if (!client.userId || !Array.isArray(msg.sessions)) break;
418
+ const updates = [];
419
+ for (const [index, item] of msg.sessions.entries()) {
420
+ const routeRef = item?.routeRef;
421
+ if (!item?.catalogKey || !routeRef?.runtimeProvider) { updates.length = 0; break; }
422
+ const { runtimeProvider, agentId, sessionId } = routeRef;
423
+ let expectedCatalogKey = null;
424
+ if (runtimeProvider === 'yeaft') {
425
+ if (!agentId || !sessionId || !yeaftSessionDb.getForAgent(client.userId, agentId, sessionId)) {
426
+ updates.length = 0;
427
+ break;
428
+ }
429
+ expectedCatalogKey = yeaftCatalogKey(agentId, sessionId);
430
+ } else if (runtimeProvider === 'claude-code' || runtimeProvider === 'copilot') {
431
+ if (!agentId || !sessionId
432
+ || (!CONFIG.skipAuth && !verifyConversationOwnership(sessionId, client.userId, client.role))) {
433
+ updates.length = 0;
434
+ break;
435
+ }
436
+ const row = sessionDb.get(sessionId);
437
+ if (!row || row.agent_id !== agentId || (row.provider || 'claude-code') !== runtimeProvider) {
438
+ updates.length = 0;
439
+ break;
440
+ }
441
+ expectedCatalogKey = chatCatalogKey(sessionId);
442
+ }
443
+ if (expectedCatalogKey !== item.catalogKey) { updates.length = 0; break; }
444
+ updates.push({
445
+ catalogKey: expectedCatalogKey,
446
+ runtimeProvider,
447
+ agentId,
448
+ sessionId,
449
+ pinned: item.pinned === true,
450
+ sortRank: index,
451
+ });
452
+ }
453
+ let persisted = false;
454
+ if (updates.length === msg.sessions.length && updates.length > 0) {
455
+ try {
456
+ persisted = sessionUiMetadataDb.applyBatch(client.userId, updates);
457
+ if (persisted) await broadcastSessionCatalog(client.userId);
458
+ } catch (e) {
459
+ console.warn('[Server] Session catalog reorder failed:', e?.message || e);
460
+ }
461
+ }
462
+ await sendToWebClient(client, {
463
+ type: 'session_catalog_reorder_result',
464
+ requestId: msg.requestId || null,
465
+ ok: persisted,
466
+ ...(!persisted ? { error: 'Permission denied or stale Session route' } : {}),
467
+ });
468
+ break;
469
+ }
470
+
471
+ case 'set_session_ui_metadata': {
472
+ if (!client.userId || !msg.catalogKey || !msg.routeRef?.runtimeProvider) break;
473
+ const { runtimeProvider, agentId, sessionId } = msg.routeRef;
474
+ let expectedCatalogKey = null;
475
+ if (runtimeProvider === 'yeaft') {
476
+ if (agentId && sessionId && yeaftSessionDb.getForAgent(client.userId, agentId, sessionId)) {
477
+ expectedCatalogKey = yeaftCatalogKey(agentId, sessionId);
478
+ }
479
+ } else if (runtimeProvider === 'claude-code' || runtimeProvider === 'copilot') {
480
+ const row = sessionId ? sessionDb.get(sessionId) : null;
481
+ if (agentId && sessionId
482
+ && (CONFIG.skipAuth || verifyConversationOwnership(sessionId, client.userId, client.role))
483
+ && row?.agent_id === agentId
484
+ && (row.provider || 'claude-code') === runtimeProvider) {
485
+ expectedCatalogKey = chatCatalogKey(sessionId);
486
+ }
487
+ }
488
+ const authorized = expectedCatalogKey === msg.catalogKey;
489
+ let persisted = false;
490
+ if (authorized) {
491
+ try {
492
+ persisted = sessionUiMetadataDb.applyBatch(client.userId, [{
493
+ catalogKey: expectedCatalogKey,
494
+ runtimeProvider,
495
+ agentId,
496
+ sessionId,
497
+ pinned: msg.pinned === true,
498
+ sortRank: Number.isFinite(msg.sortRank) ? msg.sortRank : null,
499
+ }]);
500
+ if (persisted) await broadcastSessionCatalog(client.userId);
501
+ } catch (e) {
502
+ console.warn('[Server] Session metadata update failed:', e?.message || e);
503
+ }
504
+ }
505
+ await sendToWebClient(client, {
506
+ type: 'session_ui_metadata_updated',
507
+ requestId: msg.requestId || null,
508
+ ok: persisted,
509
+ catalogKey: msg.catalogKey,
510
+ routeRef: msg.routeRef,
511
+ ...(persisted ? {
512
+ pinned: msg.pinned === true,
513
+ sortRank: Number.isFinite(msg.sortRank) ? msg.sortRank : null,
514
+ } : { error: 'Permission denied or stale Session route' }),
515
+ });
516
+ break;
517
+ }
518
+
374
519
  case 'pin_session':
375
520
  case 'unpin_session': {
376
521
  // fix-yeaft-session-list-and-menu: yeaft sessions live in a
@@ -388,22 +533,17 @@ export async function handleClientConversation(clientId, client, msg, checkAgent
388
533
  }
389
534
  const isPinned = msg.type === 'pin_session';
390
535
  try {
391
- const ok = yeaftSessionDb.setPinnedForAgent(
392
- client.userId,
393
- explicitYeaftAgentId,
394
- {
395
- id: msg.conversationId,
396
- name: msg.sessionName || msg.conversationId,
397
- workDir: msg.workDir || '',
398
- },
399
- isPinned,
400
- );
401
- if (!ok) {
536
+ const row = yeaftSessionDb.getForAgent(client.userId, explicitYeaftAgentId, msg.conversationId);
537
+ if (!row || !persistSessionPin(client.userId, {
538
+ runtimeProvider: 'yeaft',
539
+ agentId: explicitYeaftAgentId,
540
+ sessionId: msg.conversationId,
541
+ }, isPinned)) {
402
542
  console.warn(`[Server] Unauthorized yeaft pin ${msg.conversationId} by ${client.userId}`);
403
543
  break;
404
544
  }
405
545
  } catch (e) {
406
- console.warn(`[Server] yeaftSessionDb.setPinnedForAgent failed for ${msg.conversationId}:`, e?.message || e);
546
+ console.warn(`[Server] Yeaft Session pin failed for ${msg.conversationId}:`, e?.message || e);
407
547
  break;
408
548
  }
409
549
  await broadcastSessionPin(client.userId, {
@@ -413,13 +553,14 @@ export async function handleClientConversation(clientId, client, msg, checkAgent
413
553
  sessionKind: 'yeaft',
414
554
  pinned: isPinned,
415
555
  });
556
+ await broadcastSessionCatalog(client.userId);
416
557
  break;
417
558
  }
418
559
 
419
560
  const route = routeSessionPin(
420
561
  {
421
- getYeaftRow: (id) => yeaftSessionDb.get(id),
422
- verifyChatOwnership: (id, userId) => verifyConversationOwnership(id, userId),
562
+ getYeaftRows: (id) => yeaftSessionDb.getAllById(id),
563
+ verifyChatOwnership: (id, userId) => verifyConversationOwnership(id, userId, client.role),
423
564
  skipAuth: CONFIG.skipAuth,
424
565
  },
425
566
  client,
@@ -433,24 +574,45 @@ export async function handleClientConversation(clientId, client, msg, checkAgent
433
574
  break;
434
575
  }
435
576
  if (route.kind === 'yeaft') {
436
- try { yeaftSessionDb.setPinned(route.id, route.isPinned); }
437
- catch (e) { console.warn(`[Server] yeaftSessionDb.setPinned failed for ${route.id}:`, e?.message || e); }
438
- await sendToWebClient(client, { type: 'session_pinned', conversationId: route.id, pinned: route.isPinned });
577
+ try {
578
+ persistSessionPin(client.userId, {
579
+ runtimeProvider: 'yeaft',
580
+ agentId: route.agentId,
581
+ sessionId: route.id,
582
+ }, route.isPinned);
583
+ } catch (e) {
584
+ console.warn(`[Server] Yeaft Session pin failed for ${route.id}:`, e?.message || e);
585
+ break;
586
+ }
587
+ await sendToWebClient(client, {
588
+ type: 'session_pinned',
589
+ conversationId: route.id,
590
+ agentId: route.agentId,
591
+ sessionKind: 'yeaft',
592
+ pinned: route.isPinned,
593
+ });
594
+ await broadcastSessionCatalog(client.userId);
439
595
  break;
440
596
  }
441
597
  // route.kind === 'chat'
442
598
  try {
443
- sessionDb.setPinned(route.id, route.isPinned);
599
+ const row = sessionDb.get(route.id);
600
+ persistSessionPin(client.userId, {
601
+ runtimeProvider: row?.provider || 'claude-code',
602
+ agentId: row?.agent_id || null,
603
+ sessionId: route.id,
604
+ }, route.isPinned);
444
605
  // If pinning, also ensure session is active (reactivate if it was auto-deactivated)
445
606
  if (route.isPinned) sessionDb.setActive(route.id, true);
446
- } catch (e) { /* ignore */ }
607
+ } catch (e) { break; }
447
608
  await sendToWebClient(client, { type: 'session_pinned', conversationId: route.id, pinned: route.isPinned });
609
+ await broadcastSessionCatalog(client.userId);
448
610
  break;
449
611
  }
450
612
 
451
613
  case 'sync_messages':
452
614
  if (msg.conversationId) {
453
- if (!CONFIG.skipAuth && !verifyConversationOwnership(msg.conversationId, client.userId)) {
615
+ if (!CONFIG.skipAuth && !verifyConversationOwnership(msg.conversationId, client.userId, client.role)) {
454
616
  console.warn(`[Security] User ${client.userId} attempted to sync messages for conversation ${msg.conversationId} they don't own`);
455
617
  return;
456
618
  }
@@ -486,9 +648,17 @@ export async function handleClientConversation(clientId, client, msg, checkAgent
486
648
 
487
649
  const total = messageDb.getCount(msg.conversationId);
488
650
  console.log(`[sync_messages] Found ${messages.length} messages (total=${total}, hasMore=${hasMore})`);
651
+ const mode = msg.afterMessageId !== undefined && msg.afterMessageId !== null
652
+ ? 'delta'
653
+ : (msg.beforeId ? 'older' : 'recent');
489
654
  await sendToWebClient(client, {
490
655
  type: 'sync_messages_result',
491
656
  conversationId: msg.conversationId,
657
+ catalogKey: chatCatalogKey(msg.conversationId),
658
+ requestId: msg.requestId || null,
659
+ mode,
660
+ cursor: msg.beforeId ?? msg.afterMessageId ?? null,
661
+ afterMessageId: msg.afterMessageId ?? null,
492
662
  messages,
493
663
  hasMore,
494
664
  total
@@ -509,7 +679,7 @@ export async function handleClientConversation(clientId, client, msg, checkAgent
509
679
 
510
680
  // Ownership check when explicit conversationId is provided
511
681
  if (msg.conversationId && !CONFIG.skipAuth) {
512
- if (!verifyConversationOwnership(msg.conversationId, client.userId)) {
682
+ if (!verifyConversationOwnership(msg.conversationId, client.userId, client.role)) {
513
683
  await sendToWebClient(client, { type: 'error', message: 'Permission denied' });
514
684
  return;
515
685
  }
@@ -684,7 +854,7 @@ export async function handleClientConversation(clientId, client, msg, checkAgent
684
854
  if (!client.currentAgent) return;
685
855
  if (!await checkAgentAccess(client.currentAgent)) return;
686
856
  const cancelConvId = msg.conversationId || client.currentConversation;
687
- if (!CONFIG.skipAuth && !verifyConversationOwnership(cancelConvId, client.userId)) {
857
+ if (!CONFIG.skipAuth && !verifyConversationOwnership(cancelConvId, client.userId, client.role)) {
688
858
  console.warn(`[Security] User ${client.userId} cancel denied for ${cancelConvId}`);
689
859
  await sendToWebClient(client, { type: 'error', message: 'Permission denied' });
690
860
  return;
@@ -701,7 +871,7 @@ export async function handleClientConversation(clientId, client, msg, checkAgent
701
871
  if (!refreshAgent) return;
702
872
  if (!await checkAgentAccess(refreshAgent)) return;
703
873
  const refreshConvId = msg.conversationId || client.currentConversation;
704
- if (!CONFIG.skipAuth && !verifyConversationOwnership(refreshConvId, client.userId)) {
874
+ if (!CONFIG.skipAuth && !verifyConversationOwnership(refreshConvId, client.userId, client.role)) {
705
875
  console.warn(`[Security] User ${client.userId} refresh denied for ${refreshConvId}`);
706
876
  await sendToWebClient(client, { type: 'error', message: 'Permission denied' });
707
877
  return;
@@ -746,11 +916,14 @@ export async function handleClientConversation(clientId, client, msg, checkAgent
746
916
  }
747
917
 
748
918
  case 'update_conversation_settings': {
749
- if (!client.currentAgent) return;
750
- if (!await checkAgentAccess(client.currentAgent)) return;
751
919
  const settingsConvId = msg.conversationId || client.currentConversation;
752
- if (!settingsConvId) return;
753
- if (!CONFIG.skipAuth && !verifyConversationOwnership(settingsConvId, client.userId)) {
920
+ const settingsRow = settingsConvId ? sessionDb.get(settingsConvId) : null;
921
+ const settingsAgentId = msg.agentId || settingsRow?.agent_id || client.currentAgent;
922
+ if (!settingsConvId || !settingsAgentId) return;
923
+ if (msg.disallowedTools !== undefined && !await checkAgentAccess(settingsAgentId)) return;
924
+ if (!CONFIG.skipAuth && (!verifyConversationOwnership(settingsConvId, client.userId, client.role)
925
+ || (agents.has(settingsAgentId) && !verifyAgentOwnership(settingsAgentId, client.userId, client.role))
926
+ || (settingsRow?.agent_id && settingsRow.agent_id !== settingsAgentId))) {
754
927
  console.warn(`[Security] User ${client.userId} settings update denied for ${settingsConvId}`);
755
928
  await sendToWebClient(client, { type: 'error', message: 'Permission denied' });
756
929
  return;
@@ -762,7 +935,7 @@ export async function handleClientConversation(clientId, client, msg, checkAgent
762
935
  // line 351 silently clobbers the user's renamed title the next
763
936
  // time `convInfo.customTitle` is reset to undefined.
764
937
  if (msg.title !== undefined) {
765
- const titleAgent = agents.get(client.currentAgent);
938
+ const titleAgent = agents.get(settingsAgentId);
766
939
  const titleConvInfo = titleAgent?.conversations.get(settingsConvId);
767
940
  if (msg.title) {
768
941
  sessionDb.update(settingsConvId, { title: msg.title, isCustomTitle: 1 });
@@ -776,12 +949,13 @@ export async function handleClientConversation(clientId, client, msg, checkAgent
776
949
  }
777
950
  // Only forward to agent if disallowedTools present
778
951
  if (msg.disallowedTools) {
779
- await forwardToAgent(client.currentAgent, {
952
+ await forwardToAgent(settingsAgentId, {
780
953
  type: 'update_conversation_settings',
781
954
  conversationId: settingsConvId,
782
955
  disallowedTools: msg.disallowedTools
783
956
  });
784
957
  }
958
+ if (msg.title !== undefined) await broadcastSessionCatalog(client.userId);
785
959
  break;
786
960
  }
787
961
 
@@ -790,7 +964,7 @@ export async function handleClientConversation(clientId, client, msg, checkAgent
790
964
  if (!await checkAgentAccess(client.currentAgent)) return;
791
965
  const answerConvId = msg.conversationId || client.currentConversation;
792
966
  if (!answerConvId) return;
793
- if (!CONFIG.skipAuth && !verifyConversationOwnership(answerConvId, client.userId)) {
967
+ if (!CONFIG.skipAuth && !verifyConversationOwnership(answerConvId, client.userId, client.role)) {
794
968
  console.warn(`[Security] User ${client.userId} ask_user_answer denied for ${answerConvId}`);
795
969
  return;
796
970
  }
@@ -841,7 +1015,7 @@ export async function handleClientConversation(clientId, client, msg, checkAgent
841
1015
  await sendToWebClient(client, { type: 'btw_error', error: 'No conversation selected' });
842
1016
  return;
843
1017
  }
844
- if (!CONFIG.skipAuth && !verifyConversationOwnership(btwConvId, client.userId)) {
1018
+ if (!CONFIG.skipAuth && !verifyConversationOwnership(btwConvId, client.userId, client.role)) {
845
1019
  console.warn(`[Security] User ${client.userId} btw_question denied for ${btwConvId}`);
846
1020
  await sendToWebClient(client, { type: 'btw_error', conversationId: btwConvId, error: 'Permission denied' });
847
1021
  return;
@@ -37,7 +37,7 @@ export async function handleClientWorkbench(clientId, client, msg, checkAgentAcc
37
37
  if (!await checkAgentAccess(termAgentId)) return;
38
38
  const termConvId = msg.conversationId || client.currentConversation;
39
39
  if (!termConvId) return;
40
- if (!CONFIG.skipAuth && !isYeaftVirtualConversation(termConvId) && !verifyConversationOwnership(termConvId, client.userId)) {
40
+ if (!CONFIG.skipAuth && !isYeaftVirtualConversation(termConvId) && !verifyConversationOwnership(termConvId, client.userId, client.role)) {
41
41
  console.warn(`[Security] User ${client.userId} terminal access denied for ${termConvId}`);
42
42
  await sendToWebClient(client, { type: 'error', message: 'Permission denied' });
43
43
  return;
@@ -68,7 +68,7 @@ export async function handleClientWorkbench(clientId, client, msg, checkAgentAcc
68
68
  const writeConvId = msg.conversationId || client.currentConversation || '_explorer';
69
69
  const isAgentLevelWrite = writeConvId.startsWith('_') || isYeaftVirtualConversation(writeConvId);
70
70
  if (!isAgentLevelWrite) {
71
- if (!CONFIG.skipAuth && !verifyConversationOwnership(writeConvId, client.userId)) {
71
+ if (!CONFIG.skipAuth && !verifyConversationOwnership(writeConvId, client.userId, client.role)) {
72
72
  console.warn(`[Security] User ${client.userId} file write denied for ${writeConvId}`);
73
73
  await sendToWebClient(client, { type: 'error', message: 'Permission denied' });
74
74
  return;
@@ -24,7 +24,7 @@
24
24
 
25
25
  /**
26
26
  * @typedef {Object} PinRouteDeps
27
- * @property {(id: string) => ({ userId?: string|null }|null)} getYeaftRow Reads a yeaft session row (or returns null if not yeaft-owned).
27
+ * @property {(id: string) => Array<{ userId?: string|null, agentId?: string|null }>} getYeaftRows Reads all matching Yeaft Session rows.
28
28
  * @property {(id: string, userId: string) => boolean} verifyChatOwnership Returns true if `userId` owns the chat conversation `id`.
29
29
  * @property {boolean} [skipAuth] Skip both ownership checks (dev / single-user).
30
30
  */
@@ -47,12 +47,13 @@ export function routeSessionPin(deps, client, msg) {
47
47
  const id = msg && msg.conversationId;
48
48
  if (!id) return { kind: 'noop' };
49
49
  const isPinned = msg.type === 'pin_session';
50
- const yeaftRow = deps.getYeaftRow(id);
51
- if (yeaftRow) {
52
- if (!deps.skipAuth && yeaftRow.userId && yeaftRow.userId !== client.userId) {
53
- return { kind: 'denied', id, reason: 'yeaft-foreign' };
50
+ const yeaftRows = deps.getYeaftRows(id);
51
+ if (yeaftRows.length > 0) {
52
+ const owned = yeaftRows.filter(row => deps.skipAuth || row.userId === client.userId);
53
+ if (owned.length !== 1 || yeaftRows.length !== 1) {
54
+ return { kind: 'denied', id, reason: owned.length === 0 ? 'yeaft-foreign' : 'yeaft-ambiguous' };
54
55
  }
55
- return { kind: 'yeaft', id, isPinned };
56
+ return { kind: 'yeaft', id, agentId: owned[0].agentId, isPinned };
56
57
  }
57
58
  if (!deps.skipAuth && !deps.verifyChatOwnership(id, client.userId)) {
58
59
  return { kind: 'denied', id, reason: 'chat-foreign' };
@@ -0,0 +1,91 @@
1
+ const CHAT_RUNTIME_PROVIDERS = new Set(['claude-code', 'copilot']);
2
+
3
+ export function normalizeChatRuntimeProvider(provider) {
4
+ if (provider == null || provider === '') return 'claude-code';
5
+ if (CHAT_RUNTIME_PROVIDERS.has(provider)) return provider;
6
+ throw new Error(`Unknown Chat runtime provider: ${provider}`);
7
+ }
8
+
9
+ export function chatCatalogKey(conversationId) {
10
+ if (typeof conversationId !== 'string' || !conversationId) {
11
+ throw new Error('Chat catalog key requires conversationId');
12
+ }
13
+ return `chat:${conversationId}`;
14
+ }
15
+
16
+ export function yeaftCatalogKey(agentId, sessionId) {
17
+ if (typeof agentId !== 'string' || !agentId || typeof sessionId !== 'string' || !sessionId) {
18
+ throw new Error('Yeaft catalog key requires agentId and sessionId');
19
+ }
20
+ return `yeaft:${agentId}:${sessionId}`;
21
+ }
22
+
23
+ function timestampValue(value) {
24
+ if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
25
+ if (typeof value !== 'string' || !value) return 0;
26
+ const numeric = Number(value);
27
+ if (Number.isFinite(numeric)) return numeric;
28
+ const parsed = Date.parse(value);
29
+ return Number.isFinite(parsed) ? parsed : 0;
30
+ }
31
+
32
+ export function projectSessionCatalog({
33
+ chatSessions = [],
34
+ yeaftSessions = [],
35
+ metadata = [],
36
+ onlineAgentIds = new Set(),
37
+ } = {}) {
38
+ const metadataByKey = new Map(metadata.map(row => [row.catalogKey, row]));
39
+ const onlineAgents = onlineAgentIds instanceof Set ? onlineAgentIds : new Set(onlineAgentIds);
40
+ const rows = [];
41
+
42
+ for (const session of chatSessions) {
43
+ if (session.is_active === 0) continue;
44
+ const catalogKey = chatCatalogKey(session.id);
45
+ const runtimeProvider = normalizeChatRuntimeProvider(session.provider);
46
+ const meta = metadataByKey.get(catalogKey) || {};
47
+ rows.push({
48
+ catalogKey,
49
+ runtimeProvider,
50
+ routeRef: { runtimeProvider, agentId: session.agent_id, sessionId: session.id },
51
+ title: session.title || session.id,
52
+ workDir: session.work_dir || '',
53
+ agentId: session.agent_id,
54
+ agentName: session.agent_name || '',
55
+ availability: onlineAgents.has(session.agent_id) ? 'online' : 'offline',
56
+ pinned: meta.pinned ?? session.is_pinned === 1,
57
+ sortRank: meta.sortRank ?? null,
58
+ createdAt: session.created_at || null,
59
+ updatedAt: session.updated_at || null,
60
+ });
61
+ }
62
+
63
+ for (const session of yeaftSessions) {
64
+ const catalogKey = yeaftCatalogKey(session.agentId, session.id);
65
+ const meta = metadataByKey.get(catalogKey) || {};
66
+ rows.push({
67
+ catalogKey,
68
+ runtimeProvider: 'yeaft',
69
+ routeRef: { runtimeProvider: 'yeaft', agentId: session.agentId, sessionId: session.id },
70
+ title: session.name || session.id,
71
+ workDir: session.workDir || '',
72
+ agentId: session.agentId,
73
+ agentName: session.agentName || '',
74
+ availability: onlineAgents.has(session.agentId) ? 'online' : 'offline',
75
+ pinned: meta.pinned ?? !!session.pinned,
76
+ sortRank: meta.sortRank ?? session.sortOrder ?? null,
77
+ createdAt: session.createdAt || null,
78
+ updatedAt: session.updatedAt || null,
79
+ });
80
+ }
81
+
82
+ return rows.sort((left, right) => {
83
+ if (left.pinned !== right.pinned) return left.pinned ? -1 : 1;
84
+ const leftRank = Number.isFinite(left.sortRank) ? left.sortRank : Number.MAX_SAFE_INTEGER;
85
+ const rightRank = Number.isFinite(right.sortRank) ? right.sortRank : Number.MAX_SAFE_INTEGER;
86
+ if (leftRank !== rightRank) return leftRank - rightRank;
87
+ const creationDelta = timestampValue(right.createdAt) - timestampValue(left.createdAt);
88
+ if (creationDelta !== 0) return creationDelta;
89
+ return left.catalogKey.localeCompare(right.catalogKey);
90
+ });
91
+ }