@agent-link/agent 0.1.204 → 0.1.206

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.
@@ -19,8 +19,8 @@ import { createTunnelHandler } from './tunnel.js';
19
19
  import { createTerminalManager } from './terminal.js';
20
20
  const require = createRequire(import.meta.url);
21
21
  const pkg = require('../package.json');
22
- import { handleChat as claudeHandleChat, setSendFn, abort as abortClaude, abortAll as abortAllClaude, cancelExecution as claudeCancelExecution, handleUserAnswer, handleToolPermissionResponse, handleBtwQuestion, getConversation, getConversations, getIsCompacting, clearSessionId, evictByClaudeSessionId, rebindConversation, addOutputObserver, removeOutputObserver, addCloseObserver, removeCloseObserver, setOutputObserver, clearOutputObserver, setCloseObserver, clearCloseObserver, restartConversation, createPlaceholderConversation, getPendingQuestions, getPendingToolPermissions, setModel, setOnSessionStarted } from './claude.js';
23
- import { listSessions, readSessionMessages, deleteSession, renameSession, listAllRecentSessions, getSessionCacheStats } from './history.js';
22
+ import { handleChat as claudeHandleChat, addSendFn, abort as abortClaude, abortAll as abortAllClaude, cancelExecution as claudeCancelExecution, handleBtwQuestion, getConversation, getConversations, clearSessionId, evictByClaudeSessionId, rebindConversation, addOutputObserver, removeOutputObserver, addCloseObserver, removeCloseObserver, setOutputObserver, clearOutputObserver, setCloseObserver, clearCloseObserver, getPendingQuestions, getPendingToolPermissions, addOnSessionStarted, getBackend, AgentRuntime } from './runtime.js';
23
+ import { listAllRecentSessions, getSessionCacheStats } from './history.js';
24
24
  import { searchSessions } from './search-sessions.js';
25
25
  import { listMemoryFiles, updateMemoryFile, deleteMemoryFile } from './memory.js';
26
26
  import { decodeKey, parseMessage, encryptAndSend, e2eEncrypt, e2eDecrypt, isE2EEncrypted } from './encryption.js';
@@ -46,6 +46,15 @@ const state = {
46
46
  config: null,
47
47
  entraToken: null,
48
48
  };
49
+ let unsubscribeSend = null;
50
+ let unsubscribeSessionStarted = null;
51
+ /** PR4-B: connection-owned AgentRuntime. Shares the backend with the
52
+ * process-wide getBackend() singleton until PR-W1 deletes the singleton. */
53
+ let runtime = null;
54
+ /** Test-only accessor for the active runtime instance. */
55
+ export function _getRuntimeForTests() {
56
+ return runtime;
57
+ }
49
58
  let heartbeatInterval = null;
50
59
  let heartbeatTimeout = null;
51
60
  // Wire Entra timer module to this connection's state and send fn.
@@ -69,6 +78,14 @@ export function connect(config) {
69
78
  state.workDir = config.dir;
70
79
  state.config = config;
71
80
  state.shouldReconnect = true;
81
+ // PR4-B: construct the connection-owned runtime. Pass getBackend() (NOT
82
+ // createBackend()) so this runtime and the still-existing module singleton
83
+ // share one ClaudeBackend instance — otherwise the addSendFn /
84
+ // addOnSessionStarted re-exports below and the runtime would each register
85
+ // on different backend instances and fan out events twice. PR-W1 deletes
86
+ // getBackend(); at that point the runtime owns the backend exclusively.
87
+ runtime = new AgentRuntime(getBackend());
88
+ runtime.start();
72
89
  // Initialize E2E encryption key
73
90
  const ignoreConfig = !!process.env.AGENTLINK_NO_STATE;
74
91
  state.e2eKey = getOrCreateE2EKey(ignoreConfig);
@@ -78,10 +95,24 @@ export function connect(config) {
78
95
  state.sessionId = prev.sessionId;
79
96
  console.log(`[AgentLink] Restoring session: ${prev.sessionId}`);
80
97
  }
81
- // Wire up the Claude module to send messages through our WebSocket
82
- setSendFn(send);
98
+ // Wire up the Claude module to send messages through our WebSocket.
99
+ // Use additive subscribers so the ClaudeBackend adapter (PR3+) can coexist;
100
+ // capture unsubscribes so disconnect() can detach without leaks.
101
+ if (unsubscribeSend) {
102
+ try {
103
+ unsubscribeSend();
104
+ }
105
+ catch { /* ignore */ }
106
+ }
107
+ unsubscribeSend = addSendFn(send);
83
108
  // Wire up action item session linkage
84
- setOnSessionStarted(checkPendingActionItem);
109
+ if (unsubscribeSessionStarted) {
110
+ try {
111
+ unsubscribeSessionStarted();
112
+ }
113
+ catch { /* ignore */ }
114
+ }
115
+ unsubscribeSessionStarted = addOnSessionStarted(checkPendingActionItem);
85
116
  // Initialize the tunnel handler for port proxy
86
117
  tunnelHandler = createTunnelHandler(send);
87
118
  // Initialize the terminal manager for web terminal
@@ -339,6 +370,20 @@ export function disconnect() {
339
370
  stopEntraRetryTimer();
340
371
  cancelEntraRefresh();
341
372
  abortAllClaude();
373
+ if (unsubscribeSend) {
374
+ try {
375
+ unsubscribeSend();
376
+ }
377
+ catch { /* ignore */ }
378
+ unsubscribeSend = null;
379
+ }
380
+ if (unsubscribeSessionStarted) {
381
+ try {
382
+ unsubscribeSessionStarted();
383
+ }
384
+ catch { /* ignore */ }
385
+ unsubscribeSessionStarted = null;
386
+ }
342
387
  if (tunnelHandler)
343
388
  tunnelHandler.cleanup();
344
389
  if (terminalManager)
@@ -347,6 +392,13 @@ export function disconnect() {
347
392
  state.ws.close();
348
393
  state.ws = null;
349
394
  }
395
+ // PR4-B: tear down the connection-owned runtime. Awaited here so any future
396
+ // backend (e.g. Codex) can do real async cleanup; the SIGINT/SIGTERM path
397
+ // in index.ts uses shutdownSyncBestEffort() instead because it
398
+ // process.exit(0)s immediately and cannot await.
399
+ const r = runtime;
400
+ runtime = null;
401
+ return r ? r.shutdown() : Promise.resolve();
350
402
  }
351
403
  let sendQueue = Promise.resolve();
352
404
  export function send(msg) {
@@ -405,6 +457,9 @@ function buildWsUrl(config) {
405
457
  if (config.entra) {
406
458
  params.set('entra', '1');
407
459
  }
460
+ // Forward selected backend so the server can route accordingly. Default
461
+ // 'claude' makes this a no-op for existing servers that ignore the param.
462
+ params.set('backendType', config.agent ?? 'claude');
408
463
  return `${base}/?${params}`;
409
464
  }
410
465
  function scheduleReconnect(config) {
@@ -499,14 +554,48 @@ function handleServerMessage(msg) {
499
554
  if (actionItemId && chatConvId) {
500
555
  pendingActionItems.set(chatConvId, actionItemId);
501
556
  }
502
- claudeHandleChat(chatConvId, msg.prompt, chatDir, chatOptions, msg.files);
557
+ // PR4-B: route through AgentRuntime. ClaudeBackend forwards `metadata`
558
+ // (brainMode/recapId/briefingDate/devops*/projectName/icmId) as
559
+ // HandleChatOptions to claude.handleChat. .catch() so any rejection
560
+ // from ensureSession/backend.startTurn is logged rather than becoming
561
+ // an unhandled rejection (Codex review #1, high). handleServerMessage
562
+ // is sync, so we can't await; the caller in ws.on('message') has its
563
+ // own try/catch but it would miss async rejections from this call.
564
+ // Use 'default' as the conv key when missing — matches claude.ts's
565
+ // DEFAULT_CONVERSATION_ID so the runtime map and backend's internal
566
+ // map agree. Empty string would create a divergent mapping (Copilot
567
+ // round 1).
568
+ const startConvId = chatConvId ?? 'default';
569
+ if (!runtime) {
570
+ console.warn('[AgentLink] chat received after runtime shutdown — ignoring');
571
+ break;
572
+ }
573
+ runtime.startTurn(startConvId, {
574
+ text: msg.prompt,
575
+ files: msg.files,
576
+ metadata: chatOptions,
577
+ }, { workDir: chatDir, resumeSessionId: chatOptions.resumeSessionId }).catch((err) => {
578
+ console.error('[AgentLink] runtime.startTurn failed:', err.message);
579
+ });
503
580
  break;
504
581
  }
505
- case 'cancel_execution':
506
- claudeCancelExecution(msg.conversationId);
582
+ case 'cancel_execution': {
583
+ const cancelConvId = msg.conversationId;
584
+ // 'default' default matches claude.ts (Copilot round 1).
585
+ if (!runtime) {
586
+ console.warn('[AgentLink] cancel received after runtime shutdown — ignoring');
587
+ break;
588
+ }
589
+ runtime.interruptTurn(cancelConvId ?? 'default').catch((err) => {
590
+ console.error('[AgentLink] runtime.interruptTurn failed:', err.message);
591
+ });
507
592
  break;
593
+ }
508
594
  case 'list_sessions':
509
- handleListSessions();
595
+ handleListSessions().catch((err) => {
596
+ console.error('[AgentLink] handleListSessions failed:', err);
597
+ send({ type: 'sessions_list', sessions: [], workDir: state.workDir });
598
+ });
510
599
  break;
511
600
  case 'list_recent_sessions':
512
601
  handleListRecentSessions(msg);
@@ -571,6 +660,10 @@ function handleServerMessage(msg) {
571
660
  // Backward compat: old web client sends this to reset the single conversation
572
661
  abortClaude();
573
662
  clearSessionId('default');
663
+ // Evict the runtime's cached ref so the next chat re-ensures (otherwise
664
+ // ClaudeBackend would compute resumeSessionId from the stale ref and
665
+ // resume the just-cleared session). Copilot round 3 fix.
666
+ runtime?.evictConversation('default');
574
667
  console.log('[AgentLink] New conversation — session cleared');
575
668
  break;
576
669
  case 'resume_conversation': {
@@ -588,87 +681,123 @@ function handleServerMessage(msg) {
588
681
  // (handles page refresh where web client generates a new UUID)
589
682
  rebindConversation(m.claudeSessionId, convId);
590
683
  }
591
- // Try current workDir first; fall back to BRAIN_DATA_DIR for recap/briefing chat sessions
592
- let history = readSessionMessages(state.workDir, m.claudeSessionId);
593
- if (history.length === 0) {
594
- const sessionMeta_ = loadSessionMetadata(m.claudeSessionId);
595
- if (sessionMeta_.recapId || sessionMeta_.briefingDate || sessionMeta_.devopsEntityType || sessionMeta_.projectName || sessionMeta_.icmId) {
596
- history = readSessionMessages(BRAIN_DATA_DIR, m.claudeSessionId);
684
+ // History reads went async in PR4-D (runtime.readHistory is async).
685
+ // Wrap the rest of the handler in an async IIFE so we can await without
686
+ // making the entire switch dispatch async. .catch() ensures backend
687
+ // rejections don't become unhandled promise rejections.
688
+ (async () => {
689
+ // Try current workDir first; fall back to BRAIN_DATA_DIR for recap/briefing chat sessions
690
+ let history = runtime
691
+ ? await runtime.readHistory(state.workDir, m.claudeSessionId)
692
+ : [];
693
+ if (history.length === 0) {
694
+ const sessionMeta_ = loadSessionMetadata(m.claudeSessionId);
695
+ if (sessionMeta_.recapId || sessionMeta_.briefingDate || sessionMeta_.devopsEntityType || sessionMeta_.projectName || sessionMeta_.icmId) {
696
+ history = runtime
697
+ ? await runtime.readHistory(BRAIN_DATA_DIR, m.claudeSessionId)
698
+ : [];
699
+ }
597
700
  }
598
- }
599
- console.log(`[AgentLink] conversation_resumed (${history.length} messages, session ${m.claudeSessionId.slice(0, 8)})`);
600
- // Include live status so the web client can restore compacting/processing state
601
- // In multi-session mode, look up by conversationId; in single-session mode, use default
602
- const currentConv = convId ? getConversation(convId) : getConversation();
603
- const isSameSession = currentConv?.claudeSessionId === m.claudeSessionId
604
- || currentConv?.lastClaudeSessionId === m.claudeSessionId;
605
- // Restore persisted permission mode (falls back to 'normal' if not found)
606
- const persistedPermission = getConversationPermission(m.claudeSessionId) || 'normal';
607
- if (currentConv) {
608
- const currentMode = currentConv.permissionMode ?? (currentConv.planMode ? 'plan' : 'normal');
609
- const persistedMode = persistedPermission;
610
- const modeChanged = currentMode !== persistedMode;
611
- currentConv.planMode = persistedMode === 'plan';
612
- currentConv.permissionMode = persistedMode;
613
- // Only restart when the persisted mode differs from what's running,
614
- // and never interrupt an active turn.
615
- if (modeChanged && currentConv.child && !currentConv.turnActive) {
616
- restartConversation(convId, { planMode: currentConv.planMode, permissionMode: currentConv.permissionMode });
701
+ // Fallback for BrainCore-spawned action-item sessions: try user home directory.
702
+ // Action-item Claude sessions are spawned by BrainCore with cwd=$HOME and have
703
+ // no AgentLink session-metadata sidecar, so neither lookup above finds them.
704
+ if (history.length === 0) {
705
+ history = runtime
706
+ ? await runtime.readHistory(os.homedir(), m.claudeSessionId)
707
+ : [];
617
708
  }
618
- }
619
- send({
620
- type: 'conversation_resumed',
621
- conversationId: convId,
622
- claudeSessionId: m.claudeSessionId,
623
- history,
624
- isCompacting: isSameSession && (convId ? getIsCompacting(convId) : getIsCompacting()),
625
- isProcessing: isSameSession && currentConv?.turnActive === true,
626
- planMode: persistedPermission === 'plan',
627
- permissionMode: persistedPermission,
628
- ...(typeof m.scrollToMessageIdx === 'number' ? { scrollToMessageIdx: m.scrollToMessageIdx } : {}),
629
- ...(m.searchQuery ? { searchQuery: m.searchQuery } : {}),
630
- });
631
- // Re-deliver any pending AskUserQuestion requests so the refreshed web client
632
- // can display the question card and allow the user to answer.
633
- if (convId && isSameSession) {
634
- const pending = getPendingQuestions(convId);
635
- for (const pq of pending) {
636
- send({
637
- type: 'ask_user_question',
638
- conversationId: convId,
639
- requestId: pq.requestId,
640
- questions: pq.questions,
641
- });
709
+ console.log(`[AgentLink] → conversation_resumed (${history.length} messages, session ${m.claudeSessionId.slice(0, 8)})`);
710
+ // Include live status so the web client can restore compacting/processing state
711
+ // In multi-session mode, look up by conversationId; in single-session mode, use default
712
+ const currentConv = convId ? getConversation(convId) : getConversation();
713
+ const isSameSession = currentConv?.claudeSessionId === m.claudeSessionId
714
+ || currentConv?.lastClaudeSessionId === m.claudeSessionId;
715
+ // Restore persisted permission mode (falls back to 'normal' if not found)
716
+ const persistedPermission = getConversationPermission(m.claudeSessionId) || 'normal';
717
+ if (currentConv) {
718
+ const currentMode = currentConv.permissionMode ?? (currentConv.planMode ? 'plan' : 'normal');
719
+ const persistedMode = persistedPermission;
720
+ const modeChanged = currentMode !== persistedMode;
721
+ currentConv.planMode = persistedMode === 'plan';
722
+ currentConv.permissionMode = persistedMode;
723
+ // Only restart when the persisted mode differs from what's running,
724
+ // and never interrupt an active turn.
725
+ if (modeChanged && currentConv.child && !currentConv.turnActive) {
726
+ const cid = convId ?? 'default';
727
+ runtime?.restartConversation(cid, { planMode: currentConv.planMode, permissionMode: currentConv.permissionMode });
728
+ // Re-seed runtime mapping with the resumed session id so the next
729
+ // chat resumes m.claudeSessionId rather than starting fresh after
730
+ // restartConversation evicts the cache. (Codex review round 1.)
731
+ runtime?.ensureConversation(cid, { workDir: state.workDir, resumeSessionId: m.claudeSessionId }).catch(() => { });
732
+ }
642
733
  }
643
- // Re-deliver pending tool permission requests (same pattern as ask_user_question)
644
- const pendingPerms = getPendingToolPermissions(convId);
645
- for (const pp of pendingPerms) {
646
- send({
647
- type: 'tool_permission_request',
648
- conversationId: convId,
649
- requestId: pp.requestId,
650
- toolName: pp.toolName,
651
- displayName: pp.displayName,
652
- input: pp.input,
653
- decisionReason: pp.decisionReason,
654
- });
734
+ send({
735
+ type: 'conversation_resumed',
736
+ conversationId: convId,
737
+ claudeSessionId: m.claudeSessionId,
738
+ history,
739
+ isCompacting: isSameSession && (runtime?.isCompacting(convId) ?? false),
740
+ isProcessing: isSameSession && currentConv?.turnActive === true,
741
+ planMode: persistedPermission === 'plan',
742
+ permissionMode: persistedPermission,
743
+ ...(typeof m.scrollToMessageIdx === 'number' ? { scrollToMessageIdx: m.scrollToMessageIdx } : {}),
744
+ ...(m.searchQuery ? { searchQuery: m.searchQuery } : {}),
745
+ });
746
+ // Re-deliver any pending AskUserQuestion requests so the refreshed web client
747
+ // can display the question card and allow the user to answer.
748
+ if (convId && isSameSession) {
749
+ const pending = getPendingQuestions(convId);
750
+ for (const pq of pending) {
751
+ send({
752
+ type: 'ask_user_question',
753
+ conversationId: convId,
754
+ requestId: pq.requestId,
755
+ questions: pq.questions,
756
+ });
757
+ }
758
+ // Re-deliver pending tool permission requests (same pattern as ask_user_question)
759
+ const pendingPerms = getPendingToolPermissions(convId);
760
+ for (const pp of pendingPerms) {
761
+ send({
762
+ type: 'tool_permission_request',
763
+ conversationId: convId,
764
+ requestId: pp.requestId,
765
+ toolName: pp.toolName,
766
+ displayName: pp.displayName,
767
+ input: pp.input,
768
+ decisionReason: pp.decisionReason,
769
+ });
770
+ }
655
771
  }
656
- }
772
+ })().catch((err) => {
773
+ console.error('[AgentLink] resume_conversation failed:', err);
774
+ send({ type: 'error', message: 'Failed to resume conversation.' });
775
+ });
657
776
  break;
658
777
  }
659
778
  case 'ask_user_answer': {
660
779
  const m = msg;
661
- handleUserAnswer(m.requestId, m.answers);
780
+ if (!runtime) {
781
+ console.warn('[AgentLink] ask_user_answer received after runtime shutdown — ignoring');
782
+ break;
783
+ }
784
+ runtime.answerUserInput(m.requestId, m.answers);
662
785
  break;
663
786
  }
664
787
  case 'delete_session': {
665
788
  const m = msg;
666
- handleDeleteSession(m.sessionId);
789
+ handleDeleteSession(m.sessionId).catch((err) => {
790
+ console.error('[AgentLink] handleDeleteSession failed:', err);
791
+ send({ type: 'error', message: 'Session not found or could not be deleted.' });
792
+ });
667
793
  break;
668
794
  }
669
795
  case 'rename_session': {
670
796
  const m = msg;
671
- handleRenameSession(m.sessionId, m.newTitle);
797
+ handleRenameSession(m.sessionId, m.newTitle).catch((err) => {
798
+ console.error('[AgentLink] handleRenameSession failed:', err);
799
+ send({ type: 'error', message: 'Session not found or could not be renamed.' });
800
+ });
672
801
  break;
673
802
  }
674
803
  case 'query_active_conversations': {
@@ -679,7 +808,7 @@ function handleServerMessage(msg) {
679
808
  conversationId: convId,
680
809
  claudeSessionId: conv.claudeSessionId,
681
810
  isProcessing: true,
682
- isCompacting: getIsCompacting(convId),
811
+ isCompacting: runtime?.isCompacting(convId) ?? false,
683
812
  });
684
813
  }
685
814
  else if (conv.claudeSessionId) {
@@ -793,11 +922,11 @@ function handleServerMessage(msg) {
793
922
  break;
794
923
  }
795
924
  console.log(`[AgentLink] set_model: model=${model}, conversationId=${conversationId}`);
796
- setModel(conversationId, model);
925
+ runtime?.setModel(conversationId ?? null, model);
797
926
  // Kill current Claude process so next message spawns with new --model flag
798
927
  const conv = getConversation(conversationId);
799
928
  if (conv) {
800
- restartConversation(conversationId);
929
+ runtime?.restartConversation(conversationId ?? 'default');
801
930
  }
802
931
  send({ type: 'model_changed', model, conversationId });
803
932
  break;
@@ -805,17 +934,10 @@ function handleServerMessage(msg) {
805
934
  case 'set_plan_mode': {
806
935
  const { enabled, conversationId } = msg;
807
936
  console.log(`[AgentLink] set_plan_mode: enabled=${enabled}, conversationId=${conversationId}`);
808
- const conv = getConversation(conversationId);
809
- if (conv) {
810
- // Restart with new mode
811
- const result = restartConversation(conversationId, { planMode: enabled });
812
- if (result.wasTurnActive) {
813
- send({ type: 'execution_cancelled', conversationId });
814
- }
815
- }
816
- else {
817
- // No conversation yet — create placeholder
818
- createPlaceholderConversation(conversationId, { planMode: enabled });
937
+ const convId = conversationId ?? 'default';
938
+ const result = runtime?.setPlanMode(convId, enabled, state.workDir) ?? null;
939
+ if (result?.wasTurnActive) {
940
+ send({ type: 'execution_cancelled', conversationId });
819
941
  }
820
942
  send({ type: 'plan_mode_changed', enabled, conversationId });
821
943
  break;
@@ -823,22 +945,10 @@ function handleServerMessage(msg) {
823
945
  case 'set_permission_mode': {
824
946
  const { mode, conversationId } = msg;
825
947
  console.log(`[AgentLink] set_permission_mode: mode=${mode}, conversationId=${conversationId}`);
826
- const planMode = mode === 'plan';
827
- const conv = getConversation(conversationId);
828
- if (conv) {
829
- const result = restartConversation(conversationId, {
830
- planMode,
831
- permissionMode: mode,
832
- });
833
- if (result.wasTurnActive) {
834
- send({ type: 'execution_cancelled', conversationId });
835
- }
836
- }
837
- else {
838
- createPlaceholderConversation(conversationId, {
839
- planMode,
840
- permissionMode: mode,
841
- });
948
+ const convId = conversationId ?? 'default';
949
+ const result = runtime?.setPermissionMode(convId, mode, state.workDir) ?? null;
950
+ if (result?.wasTurnActive) {
951
+ send({ type: 'execution_cancelled', conversationId });
842
952
  }
843
953
  send({ type: 'permission_mode_changed', mode, conversationId });
844
954
  // Persist permission mode for this conversation's session
@@ -855,23 +965,20 @@ function handleServerMessage(msg) {
855
965
  case 'tool_permission_response': {
856
966
  const { requestId, behavior } = msg;
857
967
  console.log(`[AgentLink] tool_permission_response: ${behavior} (${requestId})`);
858
- handleToolPermissionResponse(requestId, behavior);
968
+ if (!runtime) {
969
+ console.warn('[AgentLink] tool_permission_response received after runtime shutdown — ignoring');
970
+ break;
971
+ }
972
+ runtime.answerApproval(requestId, behavior);
859
973
  break;
860
974
  }
861
975
  case 'set_brain_mode': {
862
976
  const { enabled, conversationId } = msg;
863
977
  console.log(`[AgentLink] set_brain_mode: enabled=${enabled}, conversationId=${conversationId}`);
864
- const conv = getConversation(conversationId);
865
- if (conv) {
866
- // Restart with new brain mode
867
- const result = restartConversation(conversationId, { brainMode: enabled });
868
- if (result.wasTurnActive) {
869
- send({ type: 'execution_cancelled', conversationId });
870
- }
871
- }
872
- else {
873
- // No conversation yet — create placeholder
874
- createPlaceholderConversation(conversationId, { brainMode: enabled });
978
+ const convId = conversationId ?? 'default';
979
+ const result = runtime?.setBrainMode(convId, enabled, state.workDir) ?? null;
980
+ if (result?.wasTurnActive) {
981
+ send({ type: 'execution_cancelled', conversationId });
875
982
  }
876
983
  send({ type: 'brain_mode_changed', enabled, conversationId });
877
984
  break;
@@ -1269,18 +1376,38 @@ async function handleBrainChatDeleteSession(msg) {
1269
1376
  send({ type: 'brainchat_error', message: String(err) });
1270
1377
  }
1271
1378
  }
1272
- function handleListSessions() {
1379
+ async function handleListSessions() {
1273
1380
  try {
1274
- const sessions = listSessions(state.workDir);
1381
+ if (!runtime) {
1382
+ send({ type: 'sessions_list', sessions: [], workDir: state.workDir });
1383
+ return;
1384
+ }
1385
+ // PR4-D: runtime.listHistory returns BackendSessionInfo rows; map to the
1386
+ // flat wire shape connection.ts has always sent ({sessionId, title, ...}).
1387
+ const rows = await runtime.listHistory(state.workDir);
1388
+ const sessions = rows.map(r => ({
1389
+ sessionId: r.session.backendSessionId,
1390
+ title: r.title,
1391
+ customTitle: r.customTitle,
1392
+ preview: r.preview,
1393
+ lastModified: r.lastModified,
1394
+ }));
1275
1395
  const metaMap = loadAllSessionMetadata();
1276
1396
  const enriched = sessions.map(s => ({
1277
1397
  ...s,
1278
1398
  ...metaMap.get(s.sessionId),
1279
1399
  }));
1280
1400
  // Always merge recap/briefing/devops chat sessions from BrainData directory — they live under a
1281
- // different Claude project folder, so listSessions(state.workDir) misses them.
1401
+ // different Claude project folder, so listHistory(state.workDir) misses them.
1282
1402
  // These sessions should be visible regardless of the current workDir.
1283
- const brainSessions = listSessions(BRAIN_DATA_DIR);
1403
+ const brainRows = await runtime.listHistory(BRAIN_DATA_DIR);
1404
+ const brainSessions = brainRows.map(r => ({
1405
+ sessionId: r.session.backendSessionId,
1406
+ title: r.title,
1407
+ customTitle: r.customTitle,
1408
+ preview: r.preview,
1409
+ lastModified: r.lastModified,
1410
+ }));
1284
1411
  const existingIds = new Set(enriched.map(s => s.sessionId));
1285
1412
  for (const bs of brainSessions) {
1286
1413
  if (existingIds.has(bs.sessionId))
@@ -1365,18 +1492,22 @@ function handleCancelSearch(msg) {
1365
1492
  searchControllers.delete(msg.searchId);
1366
1493
  }
1367
1494
  }
1368
- function handleDeleteSession(sessionId) {
1495
+ async function handleDeleteSession(sessionId) {
1369
1496
  // Evict any idle conversation holding this session; block if busy
1370
1497
  if (evictByClaudeSessionId(sessionId)) {
1371
1498
  send({ type: 'error', message: 'Cannot delete a session while it is processing.' });
1372
1499
  return;
1373
1500
  }
1501
+ if (!runtime) {
1502
+ send({ type: 'error', message: 'Session not found or could not be deleted.' });
1503
+ return;
1504
+ }
1374
1505
  // Try current workDir first; if not found, check if it's a recap/briefing/devops/project session in BrainData
1375
- let deleted = deleteSession(state.workDir, sessionId);
1506
+ let deleted = await runtime.deleteHistorySession(state.workDir, sessionId);
1376
1507
  if (!deleted) {
1377
1508
  const meta = loadSessionMetadata(sessionId);
1378
1509
  if (meta.recapId || meta.briefingDate || meta.devopsEntityType || meta.projectName || meta.icmId) {
1379
- deleted = deleteSession(BRAIN_DATA_DIR, sessionId);
1510
+ deleted = await runtime.deleteHistorySession(BRAIN_DATA_DIR, sessionId);
1380
1511
  }
1381
1512
  }
1382
1513
  if (deleted) {
@@ -1387,13 +1518,17 @@ function handleDeleteSession(sessionId) {
1387
1518
  send({ type: 'error', message: 'Session not found or could not be deleted.' });
1388
1519
  }
1389
1520
  }
1390
- function handleRenameSession(sessionId, newTitle) {
1521
+ async function handleRenameSession(sessionId, newTitle) {
1522
+ if (!runtime) {
1523
+ send({ type: 'error', message: 'Session not found or could not be renamed.' });
1524
+ return;
1525
+ }
1391
1526
  // Try current workDir first; if not found, check if it's a recap/briefing/devops/project session in BrainData
1392
- let renamed = renameSession(state.workDir, sessionId, newTitle);
1527
+ let renamed = await runtime.renameHistorySession(state.workDir, sessionId, newTitle);
1393
1528
  if (!renamed) {
1394
1529
  const meta = loadSessionMetadata(sessionId);
1395
1530
  if (meta.recapId || meta.briefingDate || meta.devopsEntityType || meta.projectName || meta.icmId) {
1396
- renamed = renameSession(BRAIN_DATA_DIR, sessionId, newTitle);
1531
+ renamed = await runtime.renameHistorySession(BRAIN_DATA_DIR, sessionId, newTitle);
1397
1532
  }
1398
1533
  }
1399
1534
  if (renamed) {
@@ -1403,15 +1538,20 @@ function handleRenameSession(sessionId, newTitle) {
1403
1538
  // Session file may not exist on disk yet (e.g. IcM/project chat still streaming first response).
1404
1539
  // Retry once after a short delay; if it still fails, send an error so the UI can recover.
1405
1540
  setTimeout(() => {
1406
- const retried = renameSession(state.workDir, sessionId, newTitle)
1407
- || renameSession(BRAIN_DATA_DIR, sessionId, newTitle);
1408
- if (retried) {
1409
- send({ type: 'session_renamed', sessionId, newTitle });
1410
- }
1411
- else {
1412
- console.warn(`[AgentLink] rename_session: session ${sessionId.slice(0, 8)} not found after retry`);
1541
+ (async () => {
1542
+ const retried = (await runtime.renameHistorySession(state.workDir, sessionId, newTitle))
1543
+ || (await runtime.renameHistorySession(BRAIN_DATA_DIR, sessionId, newTitle));
1544
+ if (retried) {
1545
+ send({ type: 'session_renamed', sessionId, newTitle });
1546
+ }
1547
+ else {
1548
+ console.warn(`[AgentLink] rename_session: session ${sessionId.slice(0, 8)} not found after retry`);
1549
+ send({ type: 'error', message: 'Session not found or could not be renamed.' });
1550
+ }
1551
+ })().catch((err) => {
1552
+ console.error('[AgentLink] rename_session retry failed:', err);
1413
1553
  send({ type: 'error', message: 'Session not found or could not be renamed.' });
1414
- }
1554
+ });
1415
1555
  }, 1500);
1416
1556
  }
1417
1557
  }