@agent-link/agent 0.1.210 → 0.1.212

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