@agent-link/agent 0.1.214 → 0.1.215

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, 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';
22
+ import { handleChat as claudeHandleChat, addSendFn, 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, addOnSessionStarted } from './runtime.js';
23
+ import { listSessions, readSessionMessages, deleteSession, renameSession, 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';
@@ -48,13 +48,6 @@ const state = {
48
48
  };
49
49
  let unsubscribeSend = null;
50
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
- }
58
51
  let heartbeatInterval = null;
59
52
  let heartbeatTimeout = null;
60
53
  // Wire Entra timer module to this connection's state and send fn.
@@ -78,14 +71,6 @@ export function connect(config) {
78
71
  state.workDir = config.dir;
79
72
  state.config = config;
80
73
  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();
89
74
  // Initialize E2E encryption key
90
75
  const ignoreConfig = !!process.env.AGENTLINK_NO_STATE;
91
76
  state.e2eKey = getOrCreateE2EKey(ignoreConfig);
@@ -392,13 +377,6 @@ export function disconnect() {
392
377
  state.ws.close();
393
378
  state.ws = null;
394
379
  }
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();
402
380
  }
403
381
  let sendQueue = Promise.resolve();
404
382
  export function send(msg) {
@@ -519,7 +497,7 @@ function handleServerMessage(msg) {
519
497
  const projectName = msg.projectName;
520
498
  const icmId = msg.icmId;
521
499
  const actionItemId = msg.actionItemId;
522
- const explicitWorkDir = msg.workDir;
500
+ const chatWorkDir = existingConv?.workDir || state.workDir;
523
501
  const effectiveBrainMode = !!isBrainMode;
524
502
  console.log(`[AgentLink] chat: conversationId=${chatConvId}, existingConv.planMode=${existingConv?.planMode}, brainMode=${effectiveBrainMode}`);
525
503
  const chatOptions = {
@@ -549,56 +527,19 @@ function handleServerMessage(msg) {
549
527
  if (icmId) {
550
528
  chatOptions.icmId = String(icmId);
551
529
  }
552
- const chatDir = explicitWorkDir
553
- ?? existingConv?.workDir
554
- ?? ((recapId || briefingDate || devopsEntityType || projectName || icmId) ? BRAIN_DATA_DIR : null)
555
- ?? state.workDir;
530
+ const chatDir = (recapId || briefingDate || devopsEntityType || projectName || icmId) ? BRAIN_DATA_DIR : (existingConv?.workDir || state.workDir);
556
531
  // Track actionItemId for linking to Claude session on session_started
557
532
  if (actionItemId && chatConvId) {
558
533
  pendingActionItems.set(chatConvId, actionItemId);
559
534
  }
560
- // PR4-B: route through AgentRuntime. ClaudeBackend forwards `metadata`
561
- // (brainMode/recapId/briefingDate/devops*/projectName/icmId) as
562
- // HandleChatOptions to claude.handleChat. .catch() so any rejection
563
- // from ensureSession/backend.startTurn is logged rather than becoming
564
- // an unhandled rejection (Codex review #1, high). handleServerMessage
565
- // is sync, so we can't await; the caller in ws.on('message') has its
566
- // own try/catch but it would miss async rejections from this call.
567
- // Use 'default' as the conv key when missing — matches claude.ts's
568
- // DEFAULT_CONVERSATION_ID so the runtime map and backend's internal
569
- // map agree. Empty string would create a divergent mapping (Copilot
570
- // round 1).
571
- const startConvId = chatConvId ?? 'default';
572
- if (!runtime) {
573
- console.warn('[AgentLink] chat received after runtime shutdown — ignoring');
574
- break;
575
- }
576
- runtime.startTurn(startConvId, {
577
- text: msg.prompt,
578
- files: msg.files,
579
- metadata: chatOptions,
580
- }, { workDir: chatDir, resumeSessionId: chatOptions.resumeSessionId }).catch((err) => {
581
- console.error('[AgentLink] runtime.startTurn failed:', err.message);
582
- });
535
+ claudeHandleChat(chatConvId, msg.prompt, chatDir, chatOptions, msg.files);
583
536
  break;
584
537
  }
585
- case 'cancel_execution': {
586
- const cancelConvId = msg.conversationId;
587
- // 'default' default matches claude.ts (Copilot round 1).
588
- if (!runtime) {
589
- console.warn('[AgentLink] cancel received after runtime shutdown — ignoring');
590
- break;
591
- }
592
- runtime.interruptTurn(cancelConvId ?? 'default').catch((err) => {
593
- console.error('[AgentLink] runtime.interruptTurn failed:', err.message);
594
- });
538
+ case 'cancel_execution':
539
+ claudeCancelExecution(msg.conversationId);
595
540
  break;
596
- }
597
541
  case 'list_sessions':
598
- handleListSessions().catch((err) => {
599
- console.error('[AgentLink] handleListSessions failed:', err);
600
- send({ type: 'sessions_list', sessions: [], workDir: state.workDir });
601
- });
542
+ handleListSessions();
602
543
  break;
603
544
  case 'list_recent_sessions':
604
545
  handleListRecentSessions(msg);
@@ -663,10 +604,6 @@ function handleServerMessage(msg) {
663
604
  // Backward compat: old web client sends this to reset the single conversation
664
605
  abortClaude();
665
606
  clearSessionId('default');
666
- // Evict the runtime's cached ref so the next chat re-ensures (otherwise
667
- // ClaudeBackend would compute resumeSessionId from the stale ref and
668
- // resume the just-cleared session). Copilot round 3 fix.
669
- runtime?.evictConversation('default');
670
607
  console.log('[AgentLink] New conversation — session cleared');
671
608
  break;
672
609
  case 'resume_conversation': {
@@ -684,148 +621,93 @@ function handleServerMessage(msg) {
684
621
  // (handles page refresh where web client generates a new UUID)
685
622
  rebindConversation(m.claudeSessionId, convId);
686
623
  }
687
- // History reads went async in PR4-D (runtime.readHistory is async).
688
- // Wrap the rest of the handler in an async IIFE so we can await without
689
- // making the entire switch dispatch async. .catch() ensures backend
690
- // rejections don't become unhandled promise rejections.
691
- (async () => {
692
- // Try current workDir first; fall back to BRAIN_DATA_DIR for recap/briefing chat sessions
693
- let resolvedWorkDir = state.workDir;
694
- let history = runtime
695
- ? await runtime.readHistory(state.workDir, m.claudeSessionId)
696
- : [];
697
- if (history.length === 0) {
698
- const sessionMeta_ = loadSessionMetadata(m.claudeSessionId);
699
- if (sessionMeta_.recapId || sessionMeta_.briefingDate || sessionMeta_.devopsEntityType || sessionMeta_.projectName || sessionMeta_.icmId) {
700
- history = runtime
701
- ? await runtime.readHistory(BRAIN_DATA_DIR, m.claudeSessionId)
702
- : [];
703
- if (history.length > 0)
704
- resolvedWorkDir = BRAIN_DATA_DIR;
705
- }
706
- }
707
- // Fallback for BrainCore-spawned action-item sessions: try user home directory.
708
- // Action-item Claude sessions are spawned by BrainCore with cwd=$HOME and have
709
- // no AgentLink session-metadata sidecar, so neither lookup above finds them.
710
- if (history.length === 0) {
711
- history = runtime
712
- ? await runtime.readHistory(os.homedir(), m.claudeSessionId)
713
- : [];
714
- if (history.length > 0)
715
- resolvedWorkDir = os.homedir();
624
+ // Try current workDir first; fall back to BRAIN_DATA_DIR for recap/briefing chat sessions
625
+ let history = readSessionMessages(state.workDir, m.claudeSessionId);
626
+ if (history.length === 0) {
627
+ const sessionMeta_ = loadSessionMetadata(m.claudeSessionId);
628
+ if (sessionMeta_.recapId || sessionMeta_.briefingDate || sessionMeta_.devopsEntityType || sessionMeta_.projectName || sessionMeta_.icmId) {
629
+ history = readSessionMessages(BRAIN_DATA_DIR, m.claudeSessionId);
716
630
  }
717
- console.log(`[AgentLink] → conversation_resumed (${history.length} messages, session ${m.claudeSessionId.slice(0, 8)})`);
718
- // Include live status so the web client can restore compacting/processing state
719
- // In multi-session mode, look up by conversationId; in single-session mode, use default
720
- const currentConv = convId ? getConversation(convId) : getConversation();
721
- const isSameSession = currentConv?.claudeSessionId === m.claudeSessionId
722
- || currentConv?.lastClaudeSessionId === m.claudeSessionId;
723
- // Restore persisted permission mode (falls back to 'normal' if not found)
724
- const persistedPermission = getConversationPermission(m.claudeSessionId) || 'normal';
725
- if (currentConv) {
726
- const currentMode = currentConv.permissionMode ?? (currentConv.planMode ? 'plan' : 'normal');
727
- const persistedMode = persistedPermission;
728
- const modeChanged = currentMode !== persistedMode;
729
- currentConv.planMode = persistedMode === 'plan';
730
- currentConv.permissionMode = persistedMode;
731
- // Only restart when the persisted mode differs from what's running,
732
- // and never interrupt an active turn.
733
- if (modeChanged && currentConv.child && !currentConv.turnActive) {
734
- const cid = convId ?? 'default';
735
- runtime?.restartConversation(cid, { planMode: currentConv.planMode, permissionMode: currentConv.permissionMode });
736
- // Re-seed runtime mapping with the resumed session id so the next
737
- // chat resumes m.claudeSessionId rather than starting fresh after
738
- // restartConversation evicts the cache. (Codex review round 1.)
739
- runtime?.ensureConversation(cid, { workDir: resolvedWorkDir, resumeSessionId: m.claudeSessionId }).catch(() => { });
740
- }
741
- // Remember the cwd the JSONL was found in so follow-up chats resume
742
- // in the same directory (Claude refuses --resume across cwds).
743
- currentConv.workDir = resolvedWorkDir;
631
+ }
632
+ // Fallback for BrainCore-spawned action-item sessions: try user home directory.
633
+ // Action-item Claude sessions are spawned by BrainCore with cwd=$HOME and have
634
+ // no AgentLink session-metadata sidecar, so neither lookup above finds them.
635
+ if (history.length === 0) {
636
+ history = readSessionMessages(os.homedir(), m.claudeSessionId);
637
+ }
638
+ console.log(`[AgentLink] conversation_resumed (${history.length} messages, session ${m.claudeSessionId.slice(0, 8)})`);
639
+ // Include live status so the web client can restore compacting/processing state
640
+ // In multi-session mode, look up by conversationId; in single-session mode, use default
641
+ const currentConv = convId ? getConversation(convId) : getConversation();
642
+ const isSameSession = currentConv?.claudeSessionId === m.claudeSessionId
643
+ || currentConv?.lastClaudeSessionId === m.claudeSessionId;
644
+ // Restore persisted permission mode (falls back to 'normal' if not found)
645
+ const persistedPermission = getConversationPermission(m.claudeSessionId) || 'normal';
646
+ if (currentConv) {
647
+ const currentMode = currentConv.permissionMode ?? (currentConv.planMode ? 'plan' : 'normal');
648
+ const persistedMode = persistedPermission;
649
+ const modeChanged = currentMode !== persistedMode;
650
+ currentConv.planMode = persistedMode === 'plan';
651
+ currentConv.permissionMode = persistedMode;
652
+ // Only restart when the persisted mode differs from what's running,
653
+ // and never interrupt an active turn.
654
+ if (modeChanged && currentConv.child && !currentConv.turnActive) {
655
+ restartConversation(convId, { planMode: currentConv.planMode, permissionMode: currentConv.permissionMode });
744
656
  }
745
- // Always seed the runtime's convToSession map with the resumed session id
746
- // so the next `chat` for this conversation resumes the right Claude session
747
- // instead of spawning a fresh one. The web client only sends `resumeSessionId`
748
- // on the `resume_conversation` message — follow-up `chat` messages don't
749
- // re-send it, so the agent must remember it. Without this, ClaudeBackend's
750
- // ensureSession returns a `pending:` placeholder and startTurn drops the
751
- // resume id, causing each new message to start a new Claude session.
752
- // Skipped when there's already an active turn (would race with the live
753
- // process's own session id).
754
- const seedCid = convId ?? 'default';
755
- const seedConv = currentConv;
756
- if (!seedConv?.turnActive) {
757
- runtime?.ensureConversation(seedCid, {
758
- workDir: resolvedWorkDir,
759
- resumeSessionId: m.claudeSessionId,
760
- }).catch(() => { });
657
+ }
658
+ send({
659
+ type: 'conversation_resumed',
660
+ conversationId: convId,
661
+ claudeSessionId: m.claudeSessionId,
662
+ history,
663
+ isCompacting: isSameSession && (convId ? getIsCompacting(convId) : getIsCompacting()),
664
+ isProcessing: isSameSession && currentConv?.turnActive === true,
665
+ planMode: persistedPermission === 'plan',
666
+ permissionMode: persistedPermission,
667
+ ...(typeof m.scrollToMessageIdx === 'number' ? { scrollToMessageIdx: m.scrollToMessageIdx } : {}),
668
+ ...(m.searchQuery ? { searchQuery: m.searchQuery } : {}),
669
+ });
670
+ // Re-deliver any pending AskUserQuestion requests so the refreshed web client
671
+ // can display the question card and allow the user to answer.
672
+ if (convId && isSameSession) {
673
+ const pending = getPendingQuestions(convId);
674
+ for (const pq of pending) {
675
+ send({
676
+ type: 'ask_user_question',
677
+ conversationId: convId,
678
+ requestId: pq.requestId,
679
+ questions: pq.questions,
680
+ });
761
681
  }
762
- send({
763
- type: 'conversation_resumed',
764
- conversationId: convId,
765
- claudeSessionId: m.claudeSessionId,
766
- history,
767
- isCompacting: isSameSession && (runtime?.isCompacting(convId) ?? false),
768
- isProcessing: isSameSession && currentConv?.turnActive === true,
769
- planMode: persistedPermission === 'plan',
770
- permissionMode: persistedPermission,
771
- ...(typeof m.scrollToMessageIdx === 'number' ? { scrollToMessageIdx: m.scrollToMessageIdx } : {}),
772
- ...(m.searchQuery ? { searchQuery: m.searchQuery } : {}),
773
- });
774
- // Re-deliver any pending AskUserQuestion requests so the refreshed web client
775
- // can display the question card and allow the user to answer.
776
- if (convId && isSameSession) {
777
- const pending = getPendingQuestions(convId);
778
- for (const pq of pending) {
779
- send({
780
- type: 'ask_user_question',
781
- conversationId: convId,
782
- requestId: pq.requestId,
783
- questions: pq.questions,
784
- });
785
- }
786
- // Re-deliver pending tool permission requests (same pattern as ask_user_question)
787
- const pendingPerms = getPendingToolPermissions(convId);
788
- for (const pp of pendingPerms) {
789
- send({
790
- type: 'tool_permission_request',
791
- conversationId: convId,
792
- requestId: pp.requestId,
793
- toolName: pp.toolName,
794
- displayName: pp.displayName,
795
- input: pp.input,
796
- decisionReason: pp.decisionReason,
797
- });
798
- }
682
+ // Re-deliver pending tool permission requests (same pattern as ask_user_question)
683
+ const pendingPerms = getPendingToolPermissions(convId);
684
+ for (const pp of pendingPerms) {
685
+ send({
686
+ type: 'tool_permission_request',
687
+ conversationId: convId,
688
+ requestId: pp.requestId,
689
+ toolName: pp.toolName,
690
+ displayName: pp.displayName,
691
+ input: pp.input,
692
+ decisionReason: pp.decisionReason,
693
+ });
799
694
  }
800
- })().catch((err) => {
801
- console.error('[AgentLink] resume_conversation failed:', err);
802
- send({ type: 'error', message: 'Failed to resume conversation.' });
803
- });
695
+ }
804
696
  break;
805
697
  }
806
698
  case 'ask_user_answer': {
807
699
  const m = msg;
808
- if (!runtime) {
809
- console.warn('[AgentLink] ask_user_answer received after runtime shutdown — ignoring');
810
- break;
811
- }
812
- runtime.answerUserInput(m.requestId, m.answers);
700
+ handleUserAnswer(m.requestId, m.answers);
813
701
  break;
814
702
  }
815
703
  case 'delete_session': {
816
704
  const m = msg;
817
- handleDeleteSession(m.sessionId).catch((err) => {
818
- console.error('[AgentLink] handleDeleteSession failed:', err);
819
- send({ type: 'error', message: 'Session not found or could not be deleted.' });
820
- });
705
+ handleDeleteSession(m.sessionId);
821
706
  break;
822
707
  }
823
708
  case 'rename_session': {
824
709
  const m = msg;
825
- handleRenameSession(m.sessionId, m.newTitle).catch((err) => {
826
- console.error('[AgentLink] handleRenameSession failed:', err);
827
- send({ type: 'error', message: 'Session not found or could not be renamed.' });
828
- });
710
+ handleRenameSession(m.sessionId, m.newTitle);
829
711
  break;
830
712
  }
831
713
  case 'query_active_conversations': {
@@ -836,7 +718,7 @@ function handleServerMessage(msg) {
836
718
  conversationId: convId,
837
719
  claudeSessionId: conv.claudeSessionId,
838
720
  isProcessing: true,
839
- isCompacting: runtime?.isCompacting(convId) ?? false,
721
+ isCompacting: getIsCompacting(convId),
840
722
  });
841
723
  }
842
724
  else if (conv.claudeSessionId) {
@@ -950,11 +832,11 @@ function handleServerMessage(msg) {
950
832
  break;
951
833
  }
952
834
  console.log(`[AgentLink] set_model: model=${model}, conversationId=${conversationId}`);
953
- runtime?.setModel(conversationId ?? null, model);
835
+ setModel(conversationId, model);
954
836
  // Kill current Claude process so next message spawns with new --model flag
955
837
  const conv = getConversation(conversationId);
956
838
  if (conv) {
957
- runtime?.restartConversation(conversationId ?? 'default');
839
+ restartConversation(conversationId);
958
840
  }
959
841
  send({ type: 'model_changed', model, conversationId });
960
842
  break;
@@ -962,10 +844,17 @@ function handleServerMessage(msg) {
962
844
  case 'set_plan_mode': {
963
845
  const { enabled, conversationId } = msg;
964
846
  console.log(`[AgentLink] set_plan_mode: enabled=${enabled}, conversationId=${conversationId}`);
965
- const convId = conversationId ?? 'default';
966
- const result = runtime?.setPlanMode(convId, enabled, state.workDir) ?? null;
967
- if (result?.wasTurnActive) {
968
- send({ type: 'execution_cancelled', conversationId });
847
+ const conv = getConversation(conversationId);
848
+ if (conv) {
849
+ // Restart with new mode
850
+ const result = restartConversation(conversationId, { planMode: enabled });
851
+ if (result.wasTurnActive) {
852
+ send({ type: 'execution_cancelled', conversationId });
853
+ }
854
+ }
855
+ else {
856
+ // No conversation yet — create placeholder
857
+ createPlaceholderConversation(conversationId, { planMode: enabled });
969
858
  }
970
859
  send({ type: 'plan_mode_changed', enabled, conversationId });
971
860
  break;
@@ -973,10 +862,22 @@ function handleServerMessage(msg) {
973
862
  case 'set_permission_mode': {
974
863
  const { mode, conversationId } = msg;
975
864
  console.log(`[AgentLink] set_permission_mode: mode=${mode}, conversationId=${conversationId}`);
976
- const convId = conversationId ?? 'default';
977
- const result = runtime?.setPermissionMode(convId, mode, state.workDir) ?? null;
978
- if (result?.wasTurnActive) {
979
- send({ type: 'execution_cancelled', conversationId });
865
+ const planMode = mode === 'plan';
866
+ const conv = getConversation(conversationId);
867
+ if (conv) {
868
+ const result = restartConversation(conversationId, {
869
+ planMode,
870
+ permissionMode: mode,
871
+ });
872
+ if (result.wasTurnActive) {
873
+ send({ type: 'execution_cancelled', conversationId });
874
+ }
875
+ }
876
+ else {
877
+ createPlaceholderConversation(conversationId, {
878
+ planMode,
879
+ permissionMode: mode,
880
+ });
980
881
  }
981
882
  send({ type: 'permission_mode_changed', mode, conversationId });
982
883
  // Persist permission mode for this conversation's session
@@ -993,20 +894,23 @@ function handleServerMessage(msg) {
993
894
  case 'tool_permission_response': {
994
895
  const { requestId, behavior } = msg;
995
896
  console.log(`[AgentLink] tool_permission_response: ${behavior} (${requestId})`);
996
- if (!runtime) {
997
- console.warn('[AgentLink] tool_permission_response received after runtime shutdown — ignoring');
998
- break;
999
- }
1000
- runtime.answerApproval(requestId, behavior);
897
+ handleToolPermissionResponse(requestId, behavior);
1001
898
  break;
1002
899
  }
1003
900
  case 'set_brain_mode': {
1004
901
  const { enabled, conversationId } = msg;
1005
902
  console.log(`[AgentLink] set_brain_mode: enabled=${enabled}, conversationId=${conversationId}`);
1006
- const convId = conversationId ?? 'default';
1007
- const result = runtime?.setBrainMode(convId, enabled, state.workDir) ?? null;
1008
- if (result?.wasTurnActive) {
1009
- send({ type: 'execution_cancelled', conversationId });
903
+ const conv = getConversation(conversationId);
904
+ if (conv) {
905
+ // Restart with new brain mode
906
+ const result = restartConversation(conversationId, { brainMode: enabled });
907
+ if (result.wasTurnActive) {
908
+ send({ type: 'execution_cancelled', conversationId });
909
+ }
910
+ }
911
+ else {
912
+ // No conversation yet — create placeholder
913
+ createPlaceholderConversation(conversationId, { brainMode: enabled });
1010
914
  }
1011
915
  send({ type: 'brain_mode_changed', enabled, conversationId });
1012
916
  break;
@@ -1404,38 +1308,18 @@ async function handleBrainChatDeleteSession(msg) {
1404
1308
  send({ type: 'brainchat_error', message: String(err) });
1405
1309
  }
1406
1310
  }
1407
- async function handleListSessions() {
1311
+ function handleListSessions() {
1408
1312
  try {
1409
- if (!runtime) {
1410
- send({ type: 'sessions_list', sessions: [], workDir: state.workDir });
1411
- return;
1412
- }
1413
- // PR4-D: runtime.listHistory returns BackendSessionInfo rows; map to the
1414
- // flat wire shape connection.ts has always sent ({sessionId, title, ...}).
1415
- const rows = await runtime.listHistory(state.workDir);
1416
- const sessions = rows.map(r => ({
1417
- sessionId: r.session.backendSessionId,
1418
- title: r.title,
1419
- customTitle: r.customTitle,
1420
- preview: r.preview,
1421
- lastModified: r.lastModified,
1422
- }));
1313
+ const sessions = listSessions(state.workDir);
1423
1314
  const metaMap = loadAllSessionMetadata();
1424
1315
  const enriched = sessions.map(s => ({
1425
1316
  ...s,
1426
1317
  ...metaMap.get(s.sessionId),
1427
1318
  }));
1428
1319
  // Always merge recap/briefing/devops chat sessions from BrainData directory — they live under a
1429
- // different Claude project folder, so listHistory(state.workDir) misses them.
1320
+ // different Claude project folder, so listSessions(state.workDir) misses them.
1430
1321
  // These sessions should be visible regardless of the current workDir.
1431
- const brainRows = await runtime.listHistory(BRAIN_DATA_DIR);
1432
- const brainSessions = brainRows.map(r => ({
1433
- sessionId: r.session.backendSessionId,
1434
- title: r.title,
1435
- customTitle: r.customTitle,
1436
- preview: r.preview,
1437
- lastModified: r.lastModified,
1438
- }));
1322
+ const brainSessions = listSessions(BRAIN_DATA_DIR);
1439
1323
  const existingIds = new Set(enriched.map(s => s.sessionId));
1440
1324
  for (const bs of brainSessions) {
1441
1325
  if (existingIds.has(bs.sessionId))
@@ -1520,22 +1404,18 @@ function handleCancelSearch(msg) {
1520
1404
  searchControllers.delete(msg.searchId);
1521
1405
  }
1522
1406
  }
1523
- async function handleDeleteSession(sessionId) {
1407
+ function handleDeleteSession(sessionId) {
1524
1408
  // Evict any idle conversation holding this session; block if busy
1525
1409
  if (evictByClaudeSessionId(sessionId)) {
1526
1410
  send({ type: 'error', message: 'Cannot delete a session while it is processing.' });
1527
1411
  return;
1528
1412
  }
1529
- if (!runtime) {
1530
- send({ type: 'error', message: 'Session not found or could not be deleted.' });
1531
- return;
1532
- }
1533
1413
  // Try current workDir first; if not found, check if it's a recap/briefing/devops/project session in BrainData
1534
- let deleted = await runtime.deleteHistorySession(state.workDir, sessionId);
1414
+ let deleted = deleteSession(state.workDir, sessionId);
1535
1415
  if (!deleted) {
1536
1416
  const meta = loadSessionMetadata(sessionId);
1537
1417
  if (meta.recapId || meta.briefingDate || meta.devopsEntityType || meta.projectName || meta.icmId) {
1538
- deleted = await runtime.deleteHistorySession(BRAIN_DATA_DIR, sessionId);
1418
+ deleted = deleteSession(BRAIN_DATA_DIR, sessionId);
1539
1419
  }
1540
1420
  }
1541
1421
  if (deleted) {
@@ -1546,17 +1426,13 @@ async function handleDeleteSession(sessionId) {
1546
1426
  send({ type: 'error', message: 'Session not found or could not be deleted.' });
1547
1427
  }
1548
1428
  }
1549
- async function handleRenameSession(sessionId, newTitle) {
1550
- if (!runtime) {
1551
- send({ type: 'error', message: 'Session not found or could not be renamed.' });
1552
- return;
1553
- }
1429
+ function handleRenameSession(sessionId, newTitle) {
1554
1430
  // Try current workDir first; if not found, check if it's a recap/briefing/devops/project session in BrainData
1555
- let renamed = await runtime.renameHistorySession(state.workDir, sessionId, newTitle);
1431
+ let renamed = renameSession(state.workDir, sessionId, newTitle);
1556
1432
  if (!renamed) {
1557
1433
  const meta = loadSessionMetadata(sessionId);
1558
1434
  if (meta.recapId || meta.briefingDate || meta.devopsEntityType || meta.projectName || meta.icmId) {
1559
- renamed = await runtime.renameHistorySession(BRAIN_DATA_DIR, sessionId, newTitle);
1435
+ renamed = renameSession(BRAIN_DATA_DIR, sessionId, newTitle);
1560
1436
  }
1561
1437
  }
1562
1438
  if (renamed) {
@@ -1566,20 +1442,15 @@ async function handleRenameSession(sessionId, newTitle) {
1566
1442
  // Session file may not exist on disk yet (e.g. IcM/project chat still streaming first response).
1567
1443
  // Retry once after a short delay; if it still fails, send an error so the UI can recover.
1568
1444
  setTimeout(() => {
1569
- (async () => {
1570
- const retried = (await runtime.renameHistorySession(state.workDir, sessionId, newTitle))
1571
- || (await runtime.renameHistorySession(BRAIN_DATA_DIR, sessionId, newTitle));
1572
- if (retried) {
1573
- send({ type: 'session_renamed', sessionId, newTitle });
1574
- }
1575
- else {
1576
- console.warn(`[AgentLink] rename_session: session ${sessionId.slice(0, 8)} not found after retry`);
1577
- send({ type: 'error', message: 'Session not found or could not be renamed.' });
1578
- }
1579
- })().catch((err) => {
1580
- console.error('[AgentLink] rename_session retry failed:', err);
1445
+ const retried = renameSession(state.workDir, sessionId, newTitle)
1446
+ || renameSession(BRAIN_DATA_DIR, sessionId, newTitle);
1447
+ if (retried) {
1448
+ send({ type: 'session_renamed', sessionId, newTitle });
1449
+ }
1450
+ else {
1451
+ console.warn(`[AgentLink] rename_session: session ${sessionId.slice(0, 8)} not found after retry`);
1581
1452
  send({ type: 'error', message: 'Session not found or could not be renamed.' });
1582
- });
1453
+ }
1583
1454
  }, 1500);
1584
1455
  }
1585
1456
  }