@agent-link/agent 0.1.208 → 0.1.209

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