@agent-link/agent 0.1.202 → 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,9 @@ 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
+ import { searchSessions } from './search-sessions.js';
24
25
  import { listMemoryFiles, updateMemoryFile, deleteMemoryFile } from './memory.js';
25
26
  import { decodeKey, parseMessage, encryptAndSend, e2eEncrypt, e2eDecrypt, isE2EEncrypted } from './encryption.js';
26
27
  import { setTeamSendFn, setTeamClaudeFns, getActiveTeam, serializeTeam } from './team.js';
@@ -45,6 +46,15 @@ const state = {
45
46
  config: null,
46
47
  entraToken: null,
47
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
+ }
48
58
  let heartbeatInterval = null;
49
59
  let heartbeatTimeout = null;
50
60
  // Wire Entra timer module to this connection's state and send fn.
@@ -68,6 +78,14 @@ export function connect(config) {
68
78
  state.workDir = config.dir;
69
79
  state.config = config;
70
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();
71
89
  // Initialize E2E encryption key
72
90
  const ignoreConfig = !!process.env.AGENTLINK_NO_STATE;
73
91
  state.e2eKey = getOrCreateE2EKey(ignoreConfig);
@@ -77,10 +95,24 @@ export function connect(config) {
77
95
  state.sessionId = prev.sessionId;
78
96
  console.log(`[AgentLink] Restoring session: ${prev.sessionId}`);
79
97
  }
80
- // Wire up the Claude module to send messages through our WebSocket
81
- 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);
82
108
  // Wire up action item session linkage
83
- setOnSessionStarted(checkPendingActionItem);
109
+ if (unsubscribeSessionStarted) {
110
+ try {
111
+ unsubscribeSessionStarted();
112
+ }
113
+ catch { /* ignore */ }
114
+ }
115
+ unsubscribeSessionStarted = addOnSessionStarted(checkPendingActionItem);
84
116
  // Initialize the tunnel handler for port proxy
85
117
  tunnelHandler = createTunnelHandler(send);
86
118
  // Initialize the terminal manager for web terminal
@@ -338,6 +370,20 @@ export function disconnect() {
338
370
  stopEntraRetryTimer();
339
371
  cancelEntraRefresh();
340
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
+ }
341
387
  if (tunnelHandler)
342
388
  tunnelHandler.cleanup();
343
389
  if (terminalManager)
@@ -346,6 +392,13 @@ export function disconnect() {
346
392
  state.ws.close();
347
393
  state.ws = null;
348
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();
349
402
  }
350
403
  let sendQueue = Promise.resolve();
351
404
  export function send(msg) {
@@ -404,6 +457,9 @@ function buildWsUrl(config) {
404
457
  if (config.entra) {
405
458
  params.set('entra', '1');
406
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');
407
463
  return `${base}/?${params}`;
408
464
  }
409
465
  function scheduleReconnect(config) {
@@ -498,18 +554,58 @@ function handleServerMessage(msg) {
498
554
  if (actionItemId && chatConvId) {
499
555
  pendingActionItems.set(chatConvId, actionItemId);
500
556
  }
501
- 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
+ });
502
580
  break;
503
581
  }
504
- case 'cancel_execution':
505
- 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
+ });
506
592
  break;
593
+ }
507
594
  case 'list_sessions':
508
- handleListSessions();
595
+ handleListSessions().catch((err) => {
596
+ console.error('[AgentLink] handleListSessions failed:', err);
597
+ send({ type: 'sessions_list', sessions: [], workDir: state.workDir });
598
+ });
509
599
  break;
510
600
  case 'list_recent_sessions':
511
601
  handleListRecentSessions(msg);
512
602
  break;
603
+ case 'search_sessions':
604
+ handleSearchSessions(msg);
605
+ break;
606
+ case 'cancel_search':
607
+ handleCancelSearch(msg);
608
+ break;
513
609
  case 'list_directory':
514
610
  handleListDirectory(msg, state.workDir, send);
515
611
  break;
@@ -564,6 +660,10 @@ function handleServerMessage(msg) {
564
660
  // Backward compat: old web client sends this to reset the single conversation
565
661
  abortClaude();
566
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');
567
667
  console.log('[AgentLink] New conversation — session cleared');
568
668
  break;
569
669
  case 'resume_conversation': {
@@ -581,85 +681,123 @@ function handleServerMessage(msg) {
581
681
  // (handles page refresh where web client generates a new UUID)
582
682
  rebindConversation(m.claudeSessionId, convId);
583
683
  }
584
- // Try current workDir first; fall back to BRAIN_DATA_DIR for recap/briefing chat sessions
585
- let history = readSessionMessages(state.workDir, m.claudeSessionId);
586
- if (history.length === 0) {
587
- const sessionMeta_ = loadSessionMetadata(m.claudeSessionId);
588
- if (sessionMeta_.recapId || sessionMeta_.briefingDate || sessionMeta_.devopsEntityType || sessionMeta_.projectName || sessionMeta_.icmId) {
589
- 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
+ }
590
700
  }
591
- }
592
- console.log(`[AgentLink] conversation_resumed (${history.length} messages, session ${m.claudeSessionId.slice(0, 8)})`);
593
- // Include live status so the web client can restore compacting/processing state
594
- // In multi-session mode, look up by conversationId; in single-session mode, use default
595
- const currentConv = convId ? getConversation(convId) : getConversation();
596
- const isSameSession = currentConv?.claudeSessionId === m.claudeSessionId
597
- || currentConv?.lastClaudeSessionId === m.claudeSessionId;
598
- // Restore persisted permission mode (falls back to 'normal' if not found)
599
- const persistedPermission = getConversationPermission(m.claudeSessionId) || 'normal';
600
- if (currentConv) {
601
- const currentMode = currentConv.permissionMode ?? (currentConv.planMode ? 'plan' : 'normal');
602
- const persistedMode = persistedPermission;
603
- const modeChanged = currentMode !== persistedMode;
604
- currentConv.planMode = persistedMode === 'plan';
605
- currentConv.permissionMode = persistedMode;
606
- // Only restart when the persisted mode differs from what's running,
607
- // and never interrupt an active turn.
608
- if (modeChanged && currentConv.child && !currentConv.turnActive) {
609
- 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
+ : [];
610
708
  }
611
- }
612
- send({
613
- type: 'conversation_resumed',
614
- conversationId: convId,
615
- claudeSessionId: m.claudeSessionId,
616
- history,
617
- isCompacting: isSameSession && (convId ? getIsCompacting(convId) : getIsCompacting()),
618
- isProcessing: isSameSession && currentConv?.turnActive === true,
619
- planMode: persistedPermission === 'plan',
620
- permissionMode: persistedPermission,
621
- });
622
- // Re-deliver any pending AskUserQuestion requests so the refreshed web client
623
- // can display the question card and allow the user to answer.
624
- if (convId && isSameSession) {
625
- const pending = getPendingQuestions(convId);
626
- for (const pq of pending) {
627
- send({
628
- type: 'ask_user_question',
629
- conversationId: convId,
630
- requestId: pq.requestId,
631
- questions: pq.questions,
632
- });
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
+ }
633
733
  }
634
- // Re-deliver pending tool permission requests (same pattern as ask_user_question)
635
- const pendingPerms = getPendingToolPermissions(convId);
636
- for (const pp of pendingPerms) {
637
- send({
638
- type: 'tool_permission_request',
639
- conversationId: convId,
640
- requestId: pp.requestId,
641
- toolName: pp.toolName,
642
- displayName: pp.displayName,
643
- input: pp.input,
644
- decisionReason: pp.decisionReason,
645
- });
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
+ }
646
771
  }
647
- }
772
+ })().catch((err) => {
773
+ console.error('[AgentLink] resume_conversation failed:', err);
774
+ send({ type: 'error', message: 'Failed to resume conversation.' });
775
+ });
648
776
  break;
649
777
  }
650
778
  case 'ask_user_answer': {
651
779
  const m = msg;
652
- 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);
653
785
  break;
654
786
  }
655
787
  case 'delete_session': {
656
788
  const m = msg;
657
- 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
+ });
658
793
  break;
659
794
  }
660
795
  case 'rename_session': {
661
796
  const m = msg;
662
- 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
+ });
663
801
  break;
664
802
  }
665
803
  case 'query_active_conversations': {
@@ -670,7 +808,7 @@ function handleServerMessage(msg) {
670
808
  conversationId: convId,
671
809
  claudeSessionId: conv.claudeSessionId,
672
810
  isProcessing: true,
673
- isCompacting: getIsCompacting(convId),
811
+ isCompacting: runtime?.isCompacting(convId) ?? false,
674
812
  });
675
813
  }
676
814
  else if (conv.claudeSessionId) {
@@ -784,11 +922,11 @@ function handleServerMessage(msg) {
784
922
  break;
785
923
  }
786
924
  console.log(`[AgentLink] set_model: model=${model}, conversationId=${conversationId}`);
787
- setModel(conversationId, model);
925
+ runtime?.setModel(conversationId ?? null, model);
788
926
  // Kill current Claude process so next message spawns with new --model flag
789
927
  const conv = getConversation(conversationId);
790
928
  if (conv) {
791
- restartConversation(conversationId);
929
+ runtime?.restartConversation(conversationId ?? 'default');
792
930
  }
793
931
  send({ type: 'model_changed', model, conversationId });
794
932
  break;
@@ -796,17 +934,10 @@ function handleServerMessage(msg) {
796
934
  case 'set_plan_mode': {
797
935
  const { enabled, conversationId } = msg;
798
936
  console.log(`[AgentLink] set_plan_mode: enabled=${enabled}, conversationId=${conversationId}`);
799
- const conv = getConversation(conversationId);
800
- if (conv) {
801
- // Restart with new mode
802
- const result = restartConversation(conversationId, { planMode: enabled });
803
- if (result.wasTurnActive) {
804
- send({ type: 'execution_cancelled', conversationId });
805
- }
806
- }
807
- else {
808
- // No conversation yet — create placeholder
809
- 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 });
810
941
  }
811
942
  send({ type: 'plan_mode_changed', enabled, conversationId });
812
943
  break;
@@ -814,22 +945,10 @@ function handleServerMessage(msg) {
814
945
  case 'set_permission_mode': {
815
946
  const { mode, conversationId } = msg;
816
947
  console.log(`[AgentLink] set_permission_mode: mode=${mode}, conversationId=${conversationId}`);
817
- const planMode = mode === 'plan';
818
- const conv = getConversation(conversationId);
819
- if (conv) {
820
- const result = restartConversation(conversationId, {
821
- planMode,
822
- permissionMode: mode,
823
- });
824
- if (result.wasTurnActive) {
825
- send({ type: 'execution_cancelled', conversationId });
826
- }
827
- }
828
- else {
829
- createPlaceholderConversation(conversationId, {
830
- planMode,
831
- permissionMode: mode,
832
- });
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 });
833
952
  }
834
953
  send({ type: 'permission_mode_changed', mode, conversationId });
835
954
  // Persist permission mode for this conversation's session
@@ -846,23 +965,20 @@ function handleServerMessage(msg) {
846
965
  case 'tool_permission_response': {
847
966
  const { requestId, behavior } = msg;
848
967
  console.log(`[AgentLink] tool_permission_response: ${behavior} (${requestId})`);
849
- 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);
850
973
  break;
851
974
  }
852
975
  case 'set_brain_mode': {
853
976
  const { enabled, conversationId } = msg;
854
977
  console.log(`[AgentLink] set_brain_mode: enabled=${enabled}, conversationId=${conversationId}`);
855
- const conv = getConversation(conversationId);
856
- if (conv) {
857
- // Restart with new brain mode
858
- const result = restartConversation(conversationId, { brainMode: enabled });
859
- if (result.wasTurnActive) {
860
- send({ type: 'execution_cancelled', conversationId });
861
- }
862
- }
863
- else {
864
- // No conversation yet — create placeholder
865
- 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 });
866
982
  }
867
983
  send({ type: 'brain_mode_changed', enabled, conversationId });
868
984
  break;
@@ -1260,18 +1376,38 @@ async function handleBrainChatDeleteSession(msg) {
1260
1376
  send({ type: 'brainchat_error', message: String(err) });
1261
1377
  }
1262
1378
  }
1263
- function handleListSessions() {
1379
+ async function handleListSessions() {
1264
1380
  try {
1265
- 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
+ }));
1266
1395
  const metaMap = loadAllSessionMetadata();
1267
1396
  const enriched = sessions.map(s => ({
1268
1397
  ...s,
1269
1398
  ...metaMap.get(s.sessionId),
1270
1399
  }));
1271
1400
  // Always merge recap/briefing/devops chat sessions from BrainData directory — they live under a
1272
- // different Claude project folder, so listSessions(state.workDir) misses them.
1401
+ // different Claude project folder, so listHistory(state.workDir) misses them.
1273
1402
  // These sessions should be visible regardless of the current workDir.
1274
- 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
+ }));
1275
1411
  const existingIds = new Set(enriched.map(s => s.sessionId));
1276
1412
  for (const bs of brainSessions) {
1277
1413
  if (existingIds.has(bs.sessionId))
@@ -1302,18 +1438,76 @@ async function handleListRecentSessions(msg) {
1302
1438
  send({ type: 'recent_sessions_list', sessions: [] });
1303
1439
  }
1304
1440
  }
1305
- function handleDeleteSession(sessionId) {
1441
+ // ── Session content search ───────────────────────────────────────────────
1442
+ // Single in-flight search at a time. Newer searches abort older ones; explicit
1443
+ // cancel_search messages also abort. The web client filters stale results by
1444
+ // echoing the searchId.
1445
+ const searchControllers = new Map();
1446
+ function handleSearchSessions(msg) {
1447
+ const { searchId, query, limit } = msg;
1448
+ if (!searchId)
1449
+ return;
1450
+ // Abort any prior in-flight search (only one at a time).
1451
+ for (const [id, ctrl] of searchControllers) {
1452
+ ctrl.abort();
1453
+ searchControllers.delete(id);
1454
+ }
1455
+ const controller = new AbortController();
1456
+ searchControllers.set(searchId, controller);
1457
+ // searchSessions is sync but we yield to the event loop so a fast follow-up
1458
+ // cancel_search has a chance to abort before we walk the file system.
1459
+ setImmediate(() => {
1460
+ if (controller.signal.aborted) {
1461
+ searchControllers.delete(searchId);
1462
+ return;
1463
+ }
1464
+ try {
1465
+ const result = searchSessions(state.workDir, query, { limit, signal: controller.signal });
1466
+ if (controller.signal.aborted)
1467
+ return;
1468
+ console.log(`[AgentLink] → session_search_results (q="${query}" hits=${result.results.reduce((s, r) => s + r.hits.length, 0)} scanned=${result.scannedFiles} matched=${result.matchedFiles} ${result.durationMs}ms)`);
1469
+ send({
1470
+ type: 'session_search_results',
1471
+ searchId,
1472
+ ...result,
1473
+ });
1474
+ }
1475
+ catch (err) {
1476
+ console.error('[AgentLink] searchSessions failed:', err);
1477
+ send({
1478
+ type: 'session_search_error',
1479
+ searchId,
1480
+ message: String(err),
1481
+ });
1482
+ }
1483
+ finally {
1484
+ searchControllers.delete(searchId);
1485
+ }
1486
+ });
1487
+ }
1488
+ function handleCancelSearch(msg) {
1489
+ const ctrl = searchControllers.get(msg.searchId);
1490
+ if (ctrl) {
1491
+ ctrl.abort();
1492
+ searchControllers.delete(msg.searchId);
1493
+ }
1494
+ }
1495
+ async function handleDeleteSession(sessionId) {
1306
1496
  // Evict any idle conversation holding this session; block if busy
1307
1497
  if (evictByClaudeSessionId(sessionId)) {
1308
1498
  send({ type: 'error', message: 'Cannot delete a session while it is processing.' });
1309
1499
  return;
1310
1500
  }
1501
+ if (!runtime) {
1502
+ send({ type: 'error', message: 'Session not found or could not be deleted.' });
1503
+ return;
1504
+ }
1311
1505
  // Try current workDir first; if not found, check if it's a recap/briefing/devops/project session in BrainData
1312
- let deleted = deleteSession(state.workDir, sessionId);
1506
+ let deleted = await runtime.deleteHistorySession(state.workDir, sessionId);
1313
1507
  if (!deleted) {
1314
1508
  const meta = loadSessionMetadata(sessionId);
1315
1509
  if (meta.recapId || meta.briefingDate || meta.devopsEntityType || meta.projectName || meta.icmId) {
1316
- deleted = deleteSession(BRAIN_DATA_DIR, sessionId);
1510
+ deleted = await runtime.deleteHistorySession(BRAIN_DATA_DIR, sessionId);
1317
1511
  }
1318
1512
  }
1319
1513
  if (deleted) {
@@ -1324,13 +1518,17 @@ function handleDeleteSession(sessionId) {
1324
1518
  send({ type: 'error', message: 'Session not found or could not be deleted.' });
1325
1519
  }
1326
1520
  }
1327
- 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
+ }
1328
1526
  // Try current workDir first; if not found, check if it's a recap/briefing/devops/project session in BrainData
1329
- let renamed = renameSession(state.workDir, sessionId, newTitle);
1527
+ let renamed = await runtime.renameHistorySession(state.workDir, sessionId, newTitle);
1330
1528
  if (!renamed) {
1331
1529
  const meta = loadSessionMetadata(sessionId);
1332
1530
  if (meta.recapId || meta.briefingDate || meta.devopsEntityType || meta.projectName || meta.icmId) {
1333
- renamed = renameSession(BRAIN_DATA_DIR, sessionId, newTitle);
1531
+ renamed = await runtime.renameHistorySession(BRAIN_DATA_DIR, sessionId, newTitle);
1334
1532
  }
1335
1533
  }
1336
1534
  if (renamed) {
@@ -1340,15 +1538,20 @@ function handleRenameSession(sessionId, newTitle) {
1340
1538
  // Session file may not exist on disk yet (e.g. IcM/project chat still streaming first response).
1341
1539
  // Retry once after a short delay; if it still fails, send an error so the UI can recover.
1342
1540
  setTimeout(() => {
1343
- const retried = renameSession(state.workDir, sessionId, newTitle)
1344
- || renameSession(BRAIN_DATA_DIR, sessionId, newTitle);
1345
- if (retried) {
1346
- send({ type: 'session_renamed', sessionId, newTitle });
1347
- }
1348
- else {
1349
- 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);
1350
1553
  send({ type: 'error', message: 'Session not found or could not be renamed.' });
1351
- }
1554
+ });
1352
1555
  }, 1500);
1353
1556
  }
1354
1557
  }