@agent-link/agent 0.1.211 → 0.1.213

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.
@@ -3,7 +3,7 @@ import os from 'os';
3
3
  import path from 'path';
4
4
  import { createRequire } from 'module';
5
5
  import { loadRuntimeState, saveRuntimeState, getBrainDataDir, getConversationPermission, setConversationPermission, clearConversationPermission, getOrCreateE2EKey, encodeBase64Url } from './config.js';
6
- import { handleListDirectory, handleReadFile, handleChangeWorkDir, handleUpdateFile, handleCreateFile, handleCreateDirectory, handleDeleteFile, handleUploadFile, handleUploadFileChunk, handleDownloadFile, handleDownloadChunkAck, validateWorkDir } from './directory-handlers.js';
6
+ import { handleListDirectory, handleReadFile, handleChangeWorkDir, handleUpdateFile, handleCreateFile, handleCreateDirectory, handleDeleteFile, handleUploadFile, handleUploadFileChunk, handleDownloadFile, handleDownloadChunkAck } from './directory-handlers.js';
7
7
  import { handleGitStatus, handleGitDiff, handleGitStage, handleGitUnstage, handleGitDiscard, handleGitCommit, handleGitWorktreeList, handleGitStash, handleGitPull, handleGitPush, handleGitDeleteFile, resolveToRepoRoot } from './git-handlers.js';
8
8
  import { handleCreateTeam, handleDissolveTeam, handleListTeams, handleGetTeam, handleGetTeamAgentHistory, handleDeleteTeam, handleRenameTeam } from './team-handlers.js';
9
9
  import { handleCreateLoop, handleUpdateLoop, handleDeleteLoop, handleListLoops, handleGetLoop, handleRunLoop, handleCancelLoopExecution, handleListLoopExecutions, handleGetLoopExecutionMessages, handleQueryLoopStatus } from './loop-handlers.js';
@@ -20,7 +20,7 @@ import { createTerminalManager } from './terminal.js';
20
20
  const require = createRequire(import.meta.url);
21
21
  const pkg = require('../package.json');
22
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, findSessionProjectPath } from './history.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';
@@ -34,25 +34,6 @@ const RECONNECT_BASE_DELAY = 1000;
34
34
  const RECONNECT_MAX_DELAY = 10_000;
35
35
  const HEARTBEAT_INTERVAL = 45_000; // Send ping every 45s (staggered from server's 30s)
36
36
  const HEARTBEAT_TIMEOUT = 15_000; // Max wait for pong before declaring dead
37
- /**
38
- * Resolve the workDir for an inbound message. If the message carries a
39
- * `conversationId` and the runtime has a per-conv workDir mapped for it,
40
- * use that; otherwise fall back to the agent-global `state.workDir`.
41
- */
42
- function resolveWorkDir(msg, st, rt) {
43
- const convId = msg?.conversationId;
44
- if (typeof convId === 'string' && convId && rt) {
45
- const perConv = rt.getConversationWorkDir(convId);
46
- if (perConv)
47
- return perConv;
48
- }
49
- return st.workDir;
50
- }
51
- function tag(sendFn, convId) {
52
- if (!convId)
53
- return sendFn;
54
- return (m) => sendFn({ ...m, conversationId: convId });
55
- }
56
37
  const state = {
57
38
  ws: null,
58
39
  sessionId: null,
@@ -420,19 +401,7 @@ export function disconnect() {
420
401
  return r ? r.shutdown() : Promise.resolve();
421
402
  }
422
403
  let sendQueue = Promise.resolve();
423
- /** Test-only override: when set, send() routes through this fn instead of WebSocket. */
424
- let testSendOverride = null;
425
- export function _setTestSendOverride(fn) {
426
- testSendOverride = fn;
427
- }
428
404
  export function send(msg) {
429
- if (testSendOverride) {
430
- try {
431
- testSendOverride(msg);
432
- }
433
- catch { /* swallow in tests */ }
434
- return;
435
- }
436
405
  if (state.ws && state.ws.readyState === WebSocket.OPEN) {
437
406
  sendQueue = sendQueue.then(async () => {
438
407
  if (!state.ws || state.ws.readyState !== WebSocket.OPEN)
@@ -471,9 +440,7 @@ function buildWsUrl(config) {
471
440
  name: config.name,
472
441
  workDir: state.workDir,
473
442
  hostname: os.hostname(),
474
- // E2E test hook: AGENTLINK_FAKE_VERSION lets tc_75 pin an old version
475
- // so the web's MIN_AGENT_VERSION_PER_CONV_WORKDIR gate triggers fallback.
476
- version: process.env.AGENTLINK_FAKE_VERSION || pkg.version,
443
+ version: pkg.version,
477
444
  });
478
445
  // On reconnect, send previous sessionId so the URL stays valid
479
446
  if (state.sessionId) {
@@ -515,21 +482,6 @@ const BRAIN_SERVER = 'http://localhost:8001';
515
482
  // Pending action item IDs: conversationId → actionItemId
516
483
  // Used to link a chat's Claude session to an action item for CLI start
517
484
  const pendingActionItems = new Map();
518
- /** Test-only: invoke the dispatch directly. */
519
- export function _handleServerMessageForTests(msg) {
520
- handleServerMessage(msg);
521
- }
522
- /** Test-only: read/write the connection-level workDir state. */
523
- export function _setStateWorkDirForTests(wd) {
524
- state.workDir = wd;
525
- }
526
- export function _getStateWorkDirForTests() {
527
- return state.workDir;
528
- }
529
- /** Test-only: install a runtime instance (bypasses connect()). */
530
- export function _setRuntimeForTests(rt) {
531
- runtime = rt;
532
- }
533
485
  function handleServerMessage(msg) {
534
486
  console.log(`[AgentLink] ← ${msg.type}`);
535
487
  switch (msg.type) {
@@ -600,7 +552,7 @@ function handleServerMessage(msg) {
600
552
  const chatDir = explicitWorkDir
601
553
  ?? existingConv?.workDir
602
554
  ?? ((recapId || briefingDate || devopsEntityType || projectName || icmId) ? BRAIN_DATA_DIR : null)
603
- ?? resolveWorkDir(msg, state, runtime);
555
+ ?? state.workDir;
604
556
  // Track actionItemId for linking to Claude session on session_started
605
557
  if (actionItemId && chatConvId) {
606
558
  pendingActionItems.set(chatConvId, actionItemId);
@@ -621,18 +573,12 @@ function handleServerMessage(msg) {
621
573
  console.warn('[AgentLink] chat received after runtime shutdown — ignoring');
622
574
  break;
623
575
  }
624
- // Seed per-conv workDir map so subsequent resolveWorkDir calls for this
625
- // conversation see the same dir (no eviction — this is the active turn).
626
- runtime.setConversationWorkDir(startConvId, chatDir);
627
576
  runtime.startTurn(startConvId, {
628
577
  text: msg.prompt,
629
578
  files: msg.files,
630
579
  metadata: chatOptions,
631
580
  }, { workDir: chatDir, resumeSessionId: chatOptions.resumeSessionId }).catch((err) => {
632
- const message = err.message;
633
- console.error('[AgentLink] runtime.startTurn failed:', message);
634
- send({ type: 'error', conversationId: startConvId, message: `Failed to start turn: ${message}` });
635
- send({ type: 'turn_completed', conversationId: startConvId });
581
+ console.error('[AgentLink] runtime.startTurn failed:', err.message);
636
582
  });
637
583
  break;
638
584
  }
@@ -648,18 +594,12 @@ function handleServerMessage(msg) {
648
594
  });
649
595
  break;
650
596
  }
651
- case 'list_sessions': {
652
- const m = msg;
653
- const wd = resolveWorkDir(msg, state, runtime);
654
- handleListSessions(wd, m.conversationId).catch((err) => {
597
+ case 'list_sessions':
598
+ handleListSessions().catch((err) => {
655
599
  console.error('[AgentLink] handleListSessions failed:', err);
656
- const out = { type: 'sessions_list', sessions: [], workDir: wd };
657
- if (m.conversationId)
658
- out.conversationId = m.conversationId;
659
- send(out);
600
+ send({ type: 'sessions_list', sessions: [], workDir: state.workDir });
660
601
  });
661
602
  break;
662
- }
663
603
  case 'list_recent_sessions':
664
604
  handleListRecentSessions(msg);
665
605
  break;
@@ -669,102 +609,56 @@ function handleServerMessage(msg) {
669
609
  case 'cancel_search':
670
610
  handleCancelSearch(msg);
671
611
  break;
672
- case 'list_directory': {
673
- const m = msg;
674
- handleListDirectory(m, resolveWorkDir(msg, state, runtime), tag(send, m.conversationId));
612
+ case 'list_directory':
613
+ handleListDirectory(msg, state.workDir, send);
675
614
  break;
676
- }
677
- case 'read_file': {
678
- const m = msg;
679
- handleReadFile(m, resolveWorkDir(msg, state, runtime), tag(send, m.conversationId));
615
+ case 'read_file':
616
+ handleReadFile(msg, state.workDir, send);
680
617
  break;
681
- }
682
- case 'git_read_file': {
683
- const m = msg;
684
- const tagged = tag(send, m.conversationId);
685
- resolveToRepoRoot(m, resolveWorkDir(msg, state, runtime))
618
+ case 'git_read_file':
619
+ resolveToRepoRoot(msg, state.workDir)
686
620
  .then((repoRoot) => {
687
- const filePath = m.filePath;
621
+ const filePath = msg.filePath;
688
622
  // Security: reject absolute paths and .. traversal
689
623
  if (path.isAbsolute(filePath) || filePath.includes('..')) {
690
- tagged({ type: 'file_content', filePath, error: 'Invalid file path' });
624
+ send({ type: 'file_content', filePath, error: 'Invalid file path' });
691
625
  return;
692
626
  }
693
627
  const resolved = path.resolve(repoRoot, filePath);
694
628
  if (!resolved.startsWith(repoRoot)) {
695
- tagged({ type: 'file_content', filePath, error: 'Invalid file path' });
629
+ send({ type: 'file_content', filePath, error: 'Invalid file path' });
696
630
  return;
697
631
  }
698
- handleReadFile({ filePath }, repoRoot, tagged);
632
+ handleReadFile({ filePath }, repoRoot, send);
699
633
  });
700
634
  break;
701
- }
702
- case 'update_file': {
703
- const m = msg;
704
- handleUpdateFile(m, resolveWorkDir(msg, state, runtime), tag(send, m.conversationId));
635
+ case 'update_file':
636
+ handleUpdateFile(msg, state.workDir, send);
705
637
  break;
706
- }
707
- case 'create_file': {
708
- const m = msg;
709
- handleCreateFile(m, resolveWorkDir(msg, state, runtime), tag(send, m.conversationId));
638
+ case 'create_file':
639
+ handleCreateFile(msg, state.workDir, send);
710
640
  break;
711
- }
712
- case 'create_directory': {
713
- const m = msg;
714
- handleCreateDirectory(m, resolveWorkDir(msg, state, runtime), tag(send, m.conversationId));
641
+ case 'create_directory':
642
+ handleCreateDirectory(msg, state.workDir, send);
715
643
  break;
716
- }
717
- case 'delete_file': {
718
- const m = msg;
719
- handleDeleteFile(m, resolveWorkDir(msg, state, runtime), tag(send, m.conversationId));
644
+ case 'delete_file':
645
+ handleDeleteFile(msg, state.workDir, send);
720
646
  break;
721
- }
722
- case 'upload_file': {
723
- const m = msg;
724
- handleUploadFile(m, resolveWorkDir(msg, state, runtime), tag(send, m.conversationId));
647
+ case 'upload_file':
648
+ handleUploadFile(msg, state.workDir, send);
725
649
  break;
726
- }
727
- case 'upload_file_chunk': {
728
- const m = msg;
729
- handleUploadFileChunk(m, resolveWorkDir(msg, state, runtime), tag(send, m.conversationId));
650
+ case 'upload_file_chunk':
651
+ handleUploadFileChunk(msg, state.workDir, send);
730
652
  break;
731
- }
732
- case 'download_file': {
733
- const m = msg;
734
- handleDownloadFile(m, resolveWorkDir(msg, state, runtime), tag(send, m.conversationId));
653
+ case 'download_file':
654
+ handleDownloadFile(msg, state.workDir, send);
735
655
  break;
736
- }
737
656
  case 'download_chunk_ack':
738
657
  handleDownloadChunkAck(msg);
739
658
  break;
740
659
  case 'change_workdir':
741
- handleChangeWorkDir(msg, state, send, () => {
742
- handleListSessions(state.workDir).catch((err) => {
743
- console.error('[AgentLink] handleListSessions failed:', err);
744
- send({ type: 'sessions_list', sessions: [], workDir: state.workDir });
745
- });
746
- });
747
- break;
748
- case 'set_conversation_workdir': {
749
- const m = msg;
750
- const convId = m.conversationId;
751
- if (typeof convId !== 'string' || !convId) {
752
- send({ type: 'error', message: 'set_conversation_workdir requires conversationId' });
753
- break;
754
- }
755
- const v = validateWorkDir(m.workDir);
756
- if (!v.ok) {
757
- send({ type: 'error', conversationId: convId, message: v.error });
758
- break;
759
- }
760
- runtime?.setConversationWorkDir(convId, v.resolved, { evictSession: true });
761
- // Also update the global default so NEW conversations (with a fresh
762
- // convId that has no per-conv mapping) inherit the user's latest
763
- // directory choice instead of falling back to the agent startup dir.
764
- state.workDir = v.resolved;
765
- send({ type: 'conversation_workdir_changed', conversationId: convId, workDir: v.resolved });
660
+ handleChangeWorkDir(msg, state, send, handleListSessions);
766
661
  break;
767
- }
768
662
  case 'new_conversation':
769
663
  // Backward compat: old web client sends this to reset the single conversation
770
664
  abortClaude();
@@ -795,18 +689,10 @@ function handleServerMessage(msg) {
795
689
  // making the entire switch dispatch async. .catch() ensures backend
796
690
  // rejections don't become unhandled promise rejections.
797
691
  (async () => {
798
- // Try current workDir first; fall back to BRAIN_DATA_DIR for recap/briefing chat sessions.
799
- // Use resolveWorkDir so per-conv workdir is honored when the web client tags the
800
- // resume_conversation message with conversationId (see outboundTag.js).
801
- const initialWorkDir = resolveWorkDir(msg, state, runtime);
802
- // Deeplink fix: when the web client opens a URL like #/chat/<sessionId>
803
- // in a fresh tab, it has no projectPath context, so resolveWorkDir
804
- // returns the agent's startup workDir — which may be wrong for this
805
- // session. Prefer the authoritative `cwd` recorded in the JSONL itself.
806
- const jsonlCwd = await findSessionProjectPath(m.claudeSessionId);
807
- let resolvedWorkDir = jsonlCwd || initialWorkDir;
692
+ // Try current workDir first; fall back to BRAIN_DATA_DIR for recap/briefing chat sessions
693
+ let resolvedWorkDir = state.workDir;
808
694
  let history = runtime
809
- ? await runtime.readHistory(resolvedWorkDir, m.claudeSessionId)
695
+ ? await runtime.readHistory(state.workDir, m.claudeSessionId)
810
696
  : [];
811
697
  if (history.length === 0) {
812
698
  const sessionMeta_ = loadSessionMetadata(m.claudeSessionId);
@@ -867,13 +753,8 @@ function handleServerMessage(msg) {
867
753
  // process's own session id).
868
754
  const seedCid = convId ?? 'default';
869
755
  const seedConv = currentConv;
870
- if (!seedConv?.turnActive && runtime) {
871
- // Await so per-conv workDir is set BEFORE we send conversation_resumed.
872
- // The web client re-requests list_sessions immediately after receiving
873
- // conversation_resumed (when workDir changed), and list_sessions uses
874
- // resolveWorkDir which reads from this per-conv map. Without awaiting,
875
- // the map is still empty and resolveWorkDir falls back to startup dir.
876
- await runtime.ensureConversation(seedCid, {
756
+ if (!seedConv?.turnActive) {
757
+ runtime?.ensureConversation(seedCid, {
877
758
  workDir: resolvedWorkDir,
878
759
  resumeSessionId: m.claudeSessionId,
879
760
  }).catch(() => { });
@@ -882,7 +763,6 @@ function handleServerMessage(msg) {
882
763
  type: 'conversation_resumed',
883
764
  conversationId: convId,
884
765
  claudeSessionId: m.claudeSessionId,
885
- workDir: resolvedWorkDir,
886
766
  history,
887
767
  isCompacting: isSameSession && (runtime?.isCompacting(convId) ?? false),
888
768
  isProcessing: isSameSession && currentConv?.turnActive === true,
@@ -934,23 +814,17 @@ function handleServerMessage(msg) {
934
814
  }
935
815
  case 'delete_session': {
936
816
  const m = msg;
937
- handleDeleteSession(m.sessionId, resolveWorkDir(msg, state, runtime), m.conversationId).catch((err) => {
817
+ handleDeleteSession(m.sessionId).catch((err) => {
938
818
  console.error('[AgentLink] handleDeleteSession failed:', err);
939
- const out = { type: 'error', message: 'Session not found or could not be deleted.' };
940
- if (m.conversationId)
941
- out.conversationId = m.conversationId;
942
- send(out);
819
+ send({ type: 'error', message: 'Session not found or could not be deleted.' });
943
820
  });
944
821
  break;
945
822
  }
946
823
  case 'rename_session': {
947
824
  const m = msg;
948
- handleRenameSession(m.sessionId, m.newTitle, resolveWorkDir(msg, state, runtime), m.conversationId).catch((err) => {
825
+ handleRenameSession(m.sessionId, m.newTitle).catch((err) => {
949
826
  console.error('[AgentLink] handleRenameSession failed:', err);
950
- const out = { type: 'error', message: 'Session not found or could not be renamed.' };
951
- if (m.conversationId)
952
- out.conversationId = m.conversationId;
953
- send(out);
827
+ send({ type: 'error', message: 'Session not found or could not be renamed.' });
954
828
  });
955
829
  break;
956
830
  }
@@ -991,19 +865,15 @@ function handleServerMessage(msg) {
991
865
  });
992
866
  break;
993
867
  }
994
- case 'create_team': {
995
- const m = msg;
996
- handleCreateTeam(m, resolveWorkDir(msg, state, runtime), tag(send, m.conversationId));
868
+ case 'create_team':
869
+ handleCreateTeam(msg, state.workDir, send);
997
870
  break;
998
- }
999
871
  case 'dissolve_team':
1000
872
  handleDissolveTeam();
1001
873
  break;
1002
- case 'list_teams': {
1003
- const m = msg;
1004
- handleListTeams(resolveWorkDir(msg, state, runtime), tag(send, m.conversationId));
874
+ case 'list_teams':
875
+ handleListTeams(state.workDir, send);
1005
876
  break;
1006
- }
1007
877
  case 'get_team':
1008
878
  handleGetTeam(msg, send);
1009
879
  break;
@@ -1017,22 +887,18 @@ function handleServerMessage(msg) {
1017
887
  handleRenameTeam(msg, send);
1018
888
  break;
1019
889
  // ── Loop (Scheduled Tasks) handlers ────────────────────────────────
1020
- case 'create_loop': {
1021
- const m = msg;
1022
- handleCreateLoop(m, resolveWorkDir(msg, state, runtime), tag(send, m.conversationId));
890
+ case 'create_loop':
891
+ handleCreateLoop(msg, state.workDir, send);
1023
892
  break;
1024
- }
1025
893
  case 'update_loop':
1026
894
  handleUpdateLoop(msg, send);
1027
895
  break;
1028
896
  case 'delete_loop':
1029
897
  handleDeleteLoop(msg, send);
1030
898
  break;
1031
- case 'list_loops': {
1032
- const m = msg;
1033
- handleListLoops(m.showAll ? undefined : resolveWorkDir(msg, state, runtime), tag(send, m.conversationId));
899
+ case 'list_loops':
900
+ handleListLoops(msg.showAll ? undefined : state.workDir, send);
1034
901
  break;
1035
- }
1036
902
  case 'get_loop':
1037
903
  handleGetLoop(msg, send);
1038
904
  break;
@@ -1055,26 +921,25 @@ function handleServerMessage(msg) {
1055
921
  send({ type: 'pong', ts: msg.ts });
1056
922
  break;
1057
923
  case 'list_memory': {
1058
- const m = msg;
1059
- const result = listMemoryFiles(resolveWorkDir(msg, state, runtime));
1060
- tag(send, m.conversationId)({ type: 'memory_list', memoryDir: result.memoryDir, files: result.files });
924
+ const result = listMemoryFiles(state.workDir);
925
+ send({ type: 'memory_list', memoryDir: result.memoryDir, files: result.files });
1061
926
  break;
1062
927
  }
1063
928
  case 'update_memory': {
1064
- const { filename, content, conversationId } = msg;
1065
- const result = updateMemoryFile(resolveWorkDir(msg, state, runtime), filename, content);
1066
- tag(send, conversationId)({ type: 'memory_updated', filename, ...result });
929
+ const { filename, content } = msg;
930
+ const result = updateMemoryFile(state.workDir, filename, content);
931
+ send({ type: 'memory_updated', filename, ...result });
1067
932
  break;
1068
933
  }
1069
934
  case 'delete_memory': {
1070
- const { filename, conversationId } = msg;
1071
- const result = deleteMemoryFile(resolveWorkDir(msg, state, runtime), filename);
1072
- tag(send, conversationId)({ type: 'memory_deleted', filename, ...result });
935
+ const { filename } = msg;
936
+ const result = deleteMemoryFile(state.workDir, filename);
937
+ send({ type: 'memory_deleted', filename, ...result });
1073
938
  break;
1074
939
  }
1075
940
  case 'btw_question': {
1076
941
  const { question, conversationId, claudeSessionId } = msg;
1077
- handleBtwQuestion(question, conversationId, resolveWorkDir(msg, state, runtime), tag(send, conversationId), claudeSessionId);
942
+ handleBtwQuestion(question, conversationId, state.workDir, send, claudeSessionId);
1078
943
  break;
1079
944
  }
1080
945
  case 'set_model': {
@@ -1098,7 +963,7 @@ function handleServerMessage(msg) {
1098
963
  const { enabled, conversationId } = msg;
1099
964
  console.log(`[AgentLink] set_plan_mode: enabled=${enabled}, conversationId=${conversationId}`);
1100
965
  const convId = conversationId ?? 'default';
1101
- const result = runtime?.setPlanMode(convId, enabled, resolveWorkDir(msg, state, runtime)) ?? null;
966
+ const result = runtime?.setPlanMode(convId, enabled, state.workDir) ?? null;
1102
967
  if (result?.wasTurnActive) {
1103
968
  send({ type: 'execution_cancelled', conversationId });
1104
969
  }
@@ -1109,7 +974,7 @@ function handleServerMessage(msg) {
1109
974
  const { mode, conversationId } = msg;
1110
975
  console.log(`[AgentLink] set_permission_mode: mode=${mode}, conversationId=${conversationId}`);
1111
976
  const convId = conversationId ?? 'default';
1112
- const result = runtime?.setPermissionMode(convId, mode, resolveWorkDir(msg, state, runtime)) ?? null;
977
+ const result = runtime?.setPermissionMode(convId, mode, state.workDir) ?? null;
1113
978
  if (result?.wasTurnActive) {
1114
979
  send({ type: 'execution_cancelled', conversationId });
1115
980
  }
@@ -1139,68 +1004,46 @@ function handleServerMessage(msg) {
1139
1004
  const { enabled, conversationId } = msg;
1140
1005
  console.log(`[AgentLink] set_brain_mode: enabled=${enabled}, conversationId=${conversationId}`);
1141
1006
  const convId = conversationId ?? 'default';
1142
- const result = runtime?.setBrainMode(convId, enabled, resolveWorkDir(msg, state, runtime)) ?? null;
1007
+ const result = runtime?.setBrainMode(convId, enabled, state.workDir) ?? null;
1143
1008
  if (result?.wasTurnActive) {
1144
1009
  send({ type: 'execution_cancelled', conversationId });
1145
1010
  }
1146
1011
  send({ type: 'brain_mode_changed', enabled, conversationId });
1147
1012
  break;
1148
1013
  }
1149
- case 'git_worktree_list': {
1150
- const m = msg;
1151
- handleGitWorktreeList(msg, resolveWorkDir(msg, state, runtime), tag(send, m.conversationId));
1014
+ case 'git_worktree_list':
1015
+ handleGitWorktreeList(msg, state.workDir, send);
1152
1016
  break;
1153
- }
1154
- case 'git_status': {
1155
- const m = msg;
1156
- handleGitStatus(msg, resolveWorkDir(msg, state, runtime), tag(send, m.conversationId));
1017
+ case 'git_status':
1018
+ handleGitStatus(msg, state.workDir, send);
1157
1019
  break;
1158
- }
1159
- case 'git_diff': {
1160
- const m = msg;
1161
- handleGitDiff(m, resolveWorkDir(msg, state, runtime), tag(send, m.conversationId));
1020
+ case 'git_diff':
1021
+ handleGitDiff(msg, state.workDir, send);
1162
1022
  break;
1163
- }
1164
- case 'git_stage': {
1165
- const m = msg;
1166
- handleGitStage(m, resolveWorkDir(msg, state, runtime), tag(send, m.conversationId));
1023
+ case 'git_stage':
1024
+ handleGitStage(msg, state.workDir, send);
1167
1025
  break;
1168
- }
1169
- case 'git_unstage': {
1170
- const m = msg;
1171
- handleGitUnstage(m, resolveWorkDir(msg, state, runtime), tag(send, m.conversationId));
1026
+ case 'git_unstage':
1027
+ handleGitUnstage(msg, state.workDir, send);
1172
1028
  break;
1173
- }
1174
- case 'git_discard': {
1175
- const m = msg;
1176
- handleGitDiscard(m, resolveWorkDir(msg, state, runtime), tag(send, m.conversationId));
1029
+ case 'git_discard':
1030
+ handleGitDiscard(msg, state.workDir, send);
1177
1031
  break;
1178
- }
1179
- case 'git_commit': {
1180
- const m = msg;
1181
- handleGitCommit(m, resolveWorkDir(msg, state, runtime), tag(send, m.conversationId));
1032
+ case 'git_commit':
1033
+ handleGitCommit(msg, state.workDir, send);
1182
1034
  break;
1183
- }
1184
- case 'git_stash': {
1185
- const m = msg;
1186
- handleGitStash(m, resolveWorkDir(msg, state, runtime), tag(send, m.conversationId));
1035
+ case 'git_stash':
1036
+ handleGitStash(msg, state.workDir, send);
1187
1037
  break;
1188
- }
1189
- case 'git_pull': {
1190
- const m = msg;
1191
- handleGitPull(m, resolveWorkDir(msg, state, runtime), tag(send, m.conversationId));
1038
+ case 'git_pull':
1039
+ handleGitPull(msg, state.workDir, send);
1192
1040
  break;
1193
- }
1194
- case 'git_push': {
1195
- const m = msg;
1196
- handleGitPush(m, resolveWorkDir(msg, state, runtime), tag(send, m.conversationId));
1041
+ case 'git_push':
1042
+ handleGitPush(msg, state.workDir, send);
1197
1043
  break;
1198
- }
1199
- case 'git_delete_file': {
1200
- const m = msg;
1201
- handleGitDeleteFile(m, resolveWorkDir(msg, state, runtime), tag(send, m.conversationId));
1044
+ case 'git_delete_file':
1045
+ handleGitDeleteFile(msg, state.workDir, send);
1202
1046
  break;
1203
- }
1204
1047
  case 'list_recaps':
1205
1048
  handleListRecaps();
1206
1049
  break;
@@ -1296,7 +1139,7 @@ function handleServerMessage(msg) {
1296
1139
  // ── Terminal handlers ──────────────────────────────────────────────
1297
1140
  case 'terminal_open':
1298
1141
  if (terminalManager)
1299
- terminalManager.open(msg.cols, msg.rows, resolveWorkDir(msg, state, runtime));
1142
+ terminalManager.open(msg.cols, msg.rows, state.workDir);
1300
1143
  break;
1301
1144
  case 'terminal_input':
1302
1145
  if (terminalManager)
@@ -1561,30 +1404,21 @@ async function handleBrainChatDeleteSession(msg) {
1561
1404
  send({ type: 'brainchat_error', message: String(err) });
1562
1405
  }
1563
1406
  }
1564
- async function handleListSessions(resolvedWorkDir, conversationId) {
1565
- const reply = (m) => {
1566
- if (conversationId)
1567
- m.conversationId = conversationId;
1568
- send(m);
1569
- };
1407
+ async function handleListSessions() {
1570
1408
  try {
1571
1409
  if (!runtime) {
1572
- reply({ type: 'sessions_list', sessions: [], workDir: resolvedWorkDir });
1410
+ send({ type: 'sessions_list', sessions: [], workDir: state.workDir });
1573
1411
  return;
1574
1412
  }
1575
1413
  // PR4-D: runtime.listHistory returns BackendSessionInfo rows; map to the
1576
1414
  // flat wire shape connection.ts has always sent ({sessionId, title, ...}).
1577
- const rows = await runtime.listHistory(resolvedWorkDir);
1415
+ const rows = await runtime.listHistory(state.workDir);
1578
1416
  const sessions = rows.map(r => ({
1579
1417
  sessionId: r.session.backendSessionId,
1580
1418
  title: r.title,
1581
1419
  customTitle: r.customTitle,
1582
1420
  preview: r.preview,
1583
1421
  lastModified: r.lastModified,
1584
- // PROJECT PATH: tag each session with the workdir its JSONL lives under,
1585
- // so the web client can `set_conversation_workdir` before resuming
1586
- // (otherwise per-conv resume reads the wrong directory). See sidebar.js.
1587
- projectPath: resolvedWorkDir,
1588
1422
  }));
1589
1423
  const metaMap = loadAllSessionMetadata();
1590
1424
  const enriched = sessions.map(s => ({
@@ -1592,7 +1426,7 @@ async function handleListSessions(resolvedWorkDir, conversationId) {
1592
1426
  ...metaMap.get(s.sessionId),
1593
1427
  }));
1594
1428
  // Always merge recap/briefing/devops chat sessions from BrainData directory — they live under a
1595
- // different Claude project folder, so listHistory(resolvedWorkDir) misses them.
1429
+ // different Claude project folder, so listHistory(state.workDir) misses them.
1596
1430
  // These sessions should be visible regardless of the current workDir.
1597
1431
  const brainRows = await runtime.listHistory(BRAIN_DATA_DIR);
1598
1432
  const brainSessions = brainRows.map(r => ({
@@ -1601,7 +1435,6 @@ async function handleListSessions(resolvedWorkDir, conversationId) {
1601
1435
  customTitle: r.customTitle,
1602
1436
  preview: r.preview,
1603
1437
  lastModified: r.lastModified,
1604
- projectPath: BRAIN_DATA_DIR,
1605
1438
  }));
1606
1439
  const existingIds = new Set(enriched.map(s => s.sessionId));
1607
1440
  for (const bs of brainSessions) {
@@ -1613,12 +1446,12 @@ async function handleListSessions(resolvedWorkDir, conversationId) {
1613
1446
  }
1614
1447
  }
1615
1448
  const cs = getSessionCacheStats();
1616
- console.log(`[AgentLink] → sessions_list (${enriched.length} sessions for ${resolvedWorkDir}) cache size=${cs.size} hits=${cs.hits} misses=${cs.misses} stale=${cs.staleMisses} ttlSkips=${cs.ttlSkips} evictions=${cs.evictions}`);
1617
- reply({ type: 'sessions_list', sessions: enriched, workDir: resolvedWorkDir });
1449
+ console.log(`[AgentLink] → sessions_list (${enriched.length} sessions for ${state.workDir}) cache size=${cs.size} hits=${cs.hits} misses=${cs.misses} stale=${cs.staleMisses} ttlSkips=${cs.ttlSkips} evictions=${cs.evictions}`);
1450
+ send({ type: 'sessions_list', sessions: enriched, workDir: state.workDir });
1618
1451
  }
1619
1452
  catch (err) {
1620
1453
  console.error(`[AgentLink] listSessions failed:`, err);
1621
- reply({ type: 'sessions_list', sessions: [], workDir: resolvedWorkDir });
1454
+ send({ type: 'sessions_list', sessions: [], workDir: state.workDir });
1622
1455
  }
1623
1456
  }
1624
1457
  async function handleListRecentSessions(msg) {
@@ -1639,11 +1472,9 @@ async function handleListRecentSessions(msg) {
1639
1472
  // echoing the searchId.
1640
1473
  const searchControllers = new Map();
1641
1474
  function handleSearchSessions(msg) {
1642
- const { searchId, query, limit, conversationId } = msg;
1475
+ const { searchId, query, limit } = msg;
1643
1476
  if (!searchId)
1644
1477
  return;
1645
- const tagged = tag(send, conversationId);
1646
- const resolvedWorkDir = resolveWorkDir(msg, state, runtime);
1647
1478
  // Abort any prior in-flight search (only one at a time).
1648
1479
  for (const [id, ctrl] of searchControllers) {
1649
1480
  ctrl.abort();
@@ -1659,11 +1490,11 @@ function handleSearchSessions(msg) {
1659
1490
  return;
1660
1491
  }
1661
1492
  try {
1662
- const result = searchSessions(resolvedWorkDir, query, { limit, signal: controller.signal });
1493
+ const result = searchSessions(state.workDir, query, { limit, signal: controller.signal });
1663
1494
  if (controller.signal.aborted)
1664
1495
  return;
1665
1496
  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)`);
1666
- tagged({
1497
+ send({
1667
1498
  type: 'session_search_results',
1668
1499
  searchId,
1669
1500
  ...result,
@@ -1671,7 +1502,7 @@ function handleSearchSessions(msg) {
1671
1502
  }
1672
1503
  catch (err) {
1673
1504
  console.error('[AgentLink] searchSessions failed:', err);
1674
- tagged({
1505
+ send({
1675
1506
  type: 'session_search_error',
1676
1507
  searchId,
1677
1508
  message: String(err),
@@ -1689,19 +1520,18 @@ function handleCancelSearch(msg) {
1689
1520
  searchControllers.delete(msg.searchId);
1690
1521
  }
1691
1522
  }
1692
- async function handleDeleteSession(sessionId, resolvedWorkDir, conversationId) {
1693
- const reply = tag(send, conversationId);
1523
+ async function handleDeleteSession(sessionId) {
1694
1524
  // Evict any idle conversation holding this session; block if busy
1695
1525
  if (evictByClaudeSessionId(sessionId)) {
1696
- reply({ type: 'error', message: 'Cannot delete a session while it is processing.' });
1526
+ send({ type: 'error', message: 'Cannot delete a session while it is processing.' });
1697
1527
  return;
1698
1528
  }
1699
1529
  if (!runtime) {
1700
- reply({ type: 'error', message: 'Session not found or could not be deleted.' });
1530
+ send({ type: 'error', message: 'Session not found or could not be deleted.' });
1701
1531
  return;
1702
1532
  }
1703
1533
  // Try current workDir first; if not found, check if it's a recap/briefing/devops/project session in BrainData
1704
- let deleted = await runtime.deleteHistorySession(resolvedWorkDir, sessionId);
1534
+ let deleted = await runtime.deleteHistorySession(state.workDir, sessionId);
1705
1535
  if (!deleted) {
1706
1536
  const meta = loadSessionMetadata(sessionId);
1707
1537
  if (meta.recapId || meta.briefingDate || meta.devopsEntityType || meta.projectName || meta.icmId) {
@@ -1710,20 +1540,19 @@ async function handleDeleteSession(sessionId, resolvedWorkDir, conversationId) {
1710
1540
  }
1711
1541
  if (deleted) {
1712
1542
  deleteSessionMetadata(sessionId);
1713
- reply({ type: 'session_deleted', sessionId });
1543
+ send({ type: 'session_deleted', sessionId });
1714
1544
  }
1715
1545
  else {
1716
- reply({ type: 'error', message: 'Session not found or could not be deleted.' });
1546
+ send({ type: 'error', message: 'Session not found or could not be deleted.' });
1717
1547
  }
1718
1548
  }
1719
- async function handleRenameSession(sessionId, newTitle, resolvedWorkDir, conversationId) {
1720
- const reply = tag(send, conversationId);
1549
+ async function handleRenameSession(sessionId, newTitle) {
1721
1550
  if (!runtime) {
1722
- reply({ type: 'error', message: 'Session not found or could not be renamed.' });
1551
+ send({ type: 'error', message: 'Session not found or could not be renamed.' });
1723
1552
  return;
1724
1553
  }
1725
1554
  // Try current workDir first; if not found, check if it's a recap/briefing/devops/project session in BrainData
1726
- let renamed = await runtime.renameHistorySession(resolvedWorkDir, sessionId, newTitle);
1555
+ let renamed = await runtime.renameHistorySession(state.workDir, sessionId, newTitle);
1727
1556
  if (!renamed) {
1728
1557
  const meta = loadSessionMetadata(sessionId);
1729
1558
  if (meta.recapId || meta.briefingDate || meta.devopsEntityType || meta.projectName || meta.icmId) {
@@ -1731,25 +1560,25 @@ async function handleRenameSession(sessionId, newTitle, resolvedWorkDir, convers
1731
1560
  }
1732
1561
  }
1733
1562
  if (renamed) {
1734
- reply({ type: 'session_renamed', sessionId, newTitle });
1563
+ send({ type: 'session_renamed', sessionId, newTitle });
1735
1564
  }
1736
1565
  else {
1737
1566
  // Session file may not exist on disk yet (e.g. IcM/project chat still streaming first response).
1738
1567
  // Retry once after a short delay; if it still fails, send an error so the UI can recover.
1739
1568
  setTimeout(() => {
1740
1569
  (async () => {
1741
- const retried = (await runtime.renameHistorySession(resolvedWorkDir, sessionId, newTitle))
1570
+ const retried = (await runtime.renameHistorySession(state.workDir, sessionId, newTitle))
1742
1571
  || (await runtime.renameHistorySession(BRAIN_DATA_DIR, sessionId, newTitle));
1743
1572
  if (retried) {
1744
- reply({ type: 'session_renamed', sessionId, newTitle });
1573
+ send({ type: 'session_renamed', sessionId, newTitle });
1745
1574
  }
1746
1575
  else {
1747
1576
  console.warn(`[AgentLink] rename_session: session ${sessionId.slice(0, 8)} not found after retry`);
1748
- reply({ type: 'error', message: 'Session not found or could not be renamed.' });
1577
+ send({ type: 'error', message: 'Session not found or could not be renamed.' });
1749
1578
  }
1750
1579
  })().catch((err) => {
1751
1580
  console.error('[AgentLink] rename_session retry failed:', err);
1752
- reply({ type: 'error', message: 'Session not found or could not be renamed.' });
1581
+ send({ type: 'error', message: 'Session not found or could not be renamed.' });
1753
1582
  });
1754
1583
  }, 1500);
1755
1584
  }