@agent-link/agent 0.1.204 → 0.1.207

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) {
@@ -464,7 +519,7 @@ function handleServerMessage(msg) {
464
519
  const projectName = msg.projectName;
465
520
  const icmId = msg.icmId;
466
521
  const actionItemId = msg.actionItemId;
467
- const chatWorkDir = existingConv?.workDir || state.workDir;
522
+ const explicitWorkDir = msg.workDir;
468
523
  const effectiveBrainMode = !!isBrainMode;
469
524
  console.log(`[AgentLink] chat: conversationId=${chatConvId}, existingConv.planMode=${existingConv?.planMode}, brainMode=${effectiveBrainMode}`);
470
525
  const chatOptions = {
@@ -494,19 +549,56 @@ function handleServerMessage(msg) {
494
549
  if (icmId) {
495
550
  chatOptions.icmId = String(icmId);
496
551
  }
497
- const chatDir = (recapId || briefingDate || devopsEntityType || projectName || icmId) ? BRAIN_DATA_DIR : (existingConv?.workDir || state.workDir);
552
+ const chatDir = explicitWorkDir
553
+ ?? existingConv?.workDir
554
+ ?? ((recapId || briefingDate || devopsEntityType || projectName || icmId) ? BRAIN_DATA_DIR : null)
555
+ ?? state.workDir;
498
556
  // Track actionItemId for linking to Claude session on session_started
499
557
  if (actionItemId && chatConvId) {
500
558
  pendingActionItems.set(chatConvId, actionItemId);
501
559
  }
502
- claudeHandleChat(chatConvId, msg.prompt, chatDir, chatOptions, msg.files);
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
+ });
503
583
  break;
504
584
  }
505
- case 'cancel_execution':
506
- claudeCancelExecution(msg.conversationId);
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
+ });
507
595
  break;
596
+ }
508
597
  case 'list_sessions':
509
- handleListSessions();
598
+ handleListSessions().catch((err) => {
599
+ console.error('[AgentLink] handleListSessions failed:', err);
600
+ send({ type: 'sessions_list', sessions: [], workDir: state.workDir });
601
+ });
510
602
  break;
511
603
  case 'list_recent_sessions':
512
604
  handleListRecentSessions(msg);
@@ -571,6 +663,10 @@ function handleServerMessage(msg) {
571
663
  // Backward compat: old web client sends this to reset the single conversation
572
664
  abortClaude();
573
665
  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');
574
670
  console.log('[AgentLink] New conversation — session cleared');
575
671
  break;
576
672
  case 'resume_conversation': {
@@ -588,87 +684,148 @@ function handleServerMessage(msg) {
588
684
  // (handles page refresh where web client generates a new UUID)
589
685
  rebindConversation(m.claudeSessionId, convId);
590
686
  }
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);
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
+ }
597
706
  }
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 });
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();
617
716
  }
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
- });
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;
642
744
  }
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
- });
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(() => { });
655
761
  }
656
- }
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
+ }
799
+ }
800
+ })().catch((err) => {
801
+ console.error('[AgentLink] resume_conversation failed:', err);
802
+ send({ type: 'error', message: 'Failed to resume conversation.' });
803
+ });
657
804
  break;
658
805
  }
659
806
  case 'ask_user_answer': {
660
807
  const m = msg;
661
- handleUserAnswer(m.requestId, m.answers);
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);
662
813
  break;
663
814
  }
664
815
  case 'delete_session': {
665
816
  const m = msg;
666
- handleDeleteSession(m.sessionId);
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
+ });
667
821
  break;
668
822
  }
669
823
  case 'rename_session': {
670
824
  const m = msg;
671
- handleRenameSession(m.sessionId, m.newTitle);
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
+ });
672
829
  break;
673
830
  }
674
831
  case 'query_active_conversations': {
@@ -679,7 +836,7 @@ function handleServerMessage(msg) {
679
836
  conversationId: convId,
680
837
  claudeSessionId: conv.claudeSessionId,
681
838
  isProcessing: true,
682
- isCompacting: getIsCompacting(convId),
839
+ isCompacting: runtime?.isCompacting(convId) ?? false,
683
840
  });
684
841
  }
685
842
  else if (conv.claudeSessionId) {
@@ -793,11 +950,11 @@ function handleServerMessage(msg) {
793
950
  break;
794
951
  }
795
952
  console.log(`[AgentLink] set_model: model=${model}, conversationId=${conversationId}`);
796
- setModel(conversationId, model);
953
+ runtime?.setModel(conversationId ?? null, model);
797
954
  // Kill current Claude process so next message spawns with new --model flag
798
955
  const conv = getConversation(conversationId);
799
956
  if (conv) {
800
- restartConversation(conversationId);
957
+ runtime?.restartConversation(conversationId ?? 'default');
801
958
  }
802
959
  send({ type: 'model_changed', model, conversationId });
803
960
  break;
@@ -805,17 +962,10 @@ function handleServerMessage(msg) {
805
962
  case 'set_plan_mode': {
806
963
  const { enabled, conversationId } = msg;
807
964
  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 });
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 });
819
969
  }
820
970
  send({ type: 'plan_mode_changed', enabled, conversationId });
821
971
  break;
@@ -823,22 +973,10 @@ function handleServerMessage(msg) {
823
973
  case 'set_permission_mode': {
824
974
  const { mode, conversationId } = msg;
825
975
  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
- });
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 });
842
980
  }
843
981
  send({ type: 'permission_mode_changed', mode, conversationId });
844
982
  // Persist permission mode for this conversation's session
@@ -855,23 +993,20 @@ function handleServerMessage(msg) {
855
993
  case 'tool_permission_response': {
856
994
  const { requestId, behavior } = msg;
857
995
  console.log(`[AgentLink] tool_permission_response: ${behavior} (${requestId})`);
858
- handleToolPermissionResponse(requestId, behavior);
996
+ if (!runtime) {
997
+ console.warn('[AgentLink] tool_permission_response received after runtime shutdown — ignoring');
998
+ break;
999
+ }
1000
+ runtime.answerApproval(requestId, behavior);
859
1001
  break;
860
1002
  }
861
1003
  case 'set_brain_mode': {
862
1004
  const { enabled, conversationId } = msg;
863
1005
  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 });
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 });
875
1010
  }
876
1011
  send({ type: 'brain_mode_changed', enabled, conversationId });
877
1012
  break;
@@ -1269,18 +1404,38 @@ async function handleBrainChatDeleteSession(msg) {
1269
1404
  send({ type: 'brainchat_error', message: String(err) });
1270
1405
  }
1271
1406
  }
1272
- function handleListSessions() {
1407
+ async function handleListSessions() {
1273
1408
  try {
1274
- const sessions = listSessions(state.workDir);
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
+ }));
1275
1423
  const metaMap = loadAllSessionMetadata();
1276
1424
  const enriched = sessions.map(s => ({
1277
1425
  ...s,
1278
1426
  ...metaMap.get(s.sessionId),
1279
1427
  }));
1280
1428
  // 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.
1429
+ // different Claude project folder, so listHistory(state.workDir) misses them.
1282
1430
  // These sessions should be visible regardless of the current workDir.
1283
- const brainSessions = listSessions(BRAIN_DATA_DIR);
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
+ }));
1284
1439
  const existingIds = new Set(enriched.map(s => s.sessionId));
1285
1440
  for (const bs of brainSessions) {
1286
1441
  if (existingIds.has(bs.sessionId))
@@ -1365,18 +1520,22 @@ function handleCancelSearch(msg) {
1365
1520
  searchControllers.delete(msg.searchId);
1366
1521
  }
1367
1522
  }
1368
- function handleDeleteSession(sessionId) {
1523
+ async function handleDeleteSession(sessionId) {
1369
1524
  // Evict any idle conversation holding this session; block if busy
1370
1525
  if (evictByClaudeSessionId(sessionId)) {
1371
1526
  send({ type: 'error', message: 'Cannot delete a session while it is processing.' });
1372
1527
  return;
1373
1528
  }
1529
+ if (!runtime) {
1530
+ send({ type: 'error', message: 'Session not found or could not be deleted.' });
1531
+ return;
1532
+ }
1374
1533
  // 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);
1534
+ let deleted = await runtime.deleteHistorySession(state.workDir, sessionId);
1376
1535
  if (!deleted) {
1377
1536
  const meta = loadSessionMetadata(sessionId);
1378
1537
  if (meta.recapId || meta.briefingDate || meta.devopsEntityType || meta.projectName || meta.icmId) {
1379
- deleted = deleteSession(BRAIN_DATA_DIR, sessionId);
1538
+ deleted = await runtime.deleteHistorySession(BRAIN_DATA_DIR, sessionId);
1380
1539
  }
1381
1540
  }
1382
1541
  if (deleted) {
@@ -1387,13 +1546,17 @@ function handleDeleteSession(sessionId) {
1387
1546
  send({ type: 'error', message: 'Session not found or could not be deleted.' });
1388
1547
  }
1389
1548
  }
1390
- function handleRenameSession(sessionId, newTitle) {
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
+ }
1391
1554
  // 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);
1555
+ let renamed = await runtime.renameHistorySession(state.workDir, sessionId, newTitle);
1393
1556
  if (!renamed) {
1394
1557
  const meta = loadSessionMetadata(sessionId);
1395
1558
  if (meta.recapId || meta.briefingDate || meta.devopsEntityType || meta.projectName || meta.icmId) {
1396
- renamed = renameSession(BRAIN_DATA_DIR, sessionId, newTitle);
1559
+ renamed = await runtime.renameHistorySession(BRAIN_DATA_DIR, sessionId, newTitle);
1397
1560
  }
1398
1561
  }
1399
1562
  if (renamed) {
@@ -1403,15 +1566,20 @@ function handleRenameSession(sessionId, newTitle) {
1403
1566
  // Session file may not exist on disk yet (e.g. IcM/project chat still streaming first response).
1404
1567
  // Retry once after a short delay; if it still fails, send an error so the UI can recover.
1405
1568
  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`);
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);
1413
1581
  send({ type: 'error', message: 'Session not found or could not be renamed.' });
1414
- }
1582
+ });
1415
1583
  }, 1500);
1416
1584
  }
1417
1585
  }