@agent-link/agent 0.1.202 → 0.1.206
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.
- package/dist/backends/claude.d.ts +90 -0
- package/dist/backends/claude.js +356 -0
- package/dist/backends/claude.js.map +1 -0
- package/dist/backends/index.d.ts +27 -0
- package/dist/backends/index.js +52 -0
- package/dist/backends/index.js.map +1 -0
- package/dist/backends/types.d.ts +379 -0
- package/dist/backends/types.js +120 -0
- package/dist/backends/types.js.map +1 -0
- package/dist/claude.d.ts +22 -2
- package/dist/claude.js +54 -6
- package/dist/claude.js.map +1 -1
- package/dist/cli.js +62 -2
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +5 -0
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/connection.d.ts +4 -1
- package/dist/connection.js +335 -132
- package/dist/connection.js.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/recap.d.ts +1 -0
- package/dist/recap.js.map +1 -1
- package/dist/runtime.d.ts +176 -0
- package/dist/runtime.js +286 -0
- package/dist/runtime.js.map +1 -0
- package/dist/search-sessions.d.ts +32 -0
- package/dist/search-sessions.js +247 -0
- package/dist/search-sessions.js.map +1 -0
- package/package.json +1 -1
package/dist/connection.js
CHANGED
|
@@ -19,8 +19,9 @@ import { createTunnelHandler } from './tunnel.js';
|
|
|
19
19
|
import { createTerminalManager } from './terminal.js';
|
|
20
20
|
const require = createRequire(import.meta.url);
|
|
21
21
|
const pkg = require('../package.json');
|
|
22
|
-
import { handleChat as claudeHandleChat,
|
|
23
|
-
import {
|
|
22
|
+
import { handleChat as claudeHandleChat, addSendFn, abort as abortClaude, abortAll as abortAllClaude, cancelExecution as claudeCancelExecution, handleBtwQuestion, getConversation, getConversations, clearSessionId, evictByClaudeSessionId, rebindConversation, addOutputObserver, removeOutputObserver, addCloseObserver, removeCloseObserver, setOutputObserver, clearOutputObserver, setCloseObserver, clearCloseObserver, getPendingQuestions, getPendingToolPermissions, addOnSessionStarted, getBackend, AgentRuntime } from './runtime.js';
|
|
23
|
+
import { listAllRecentSessions, getSessionCacheStats } from './history.js';
|
|
24
|
+
import { searchSessions } from './search-sessions.js';
|
|
24
25
|
import { listMemoryFiles, updateMemoryFile, deleteMemoryFile } from './memory.js';
|
|
25
26
|
import { decodeKey, parseMessage, encryptAndSend, e2eEncrypt, e2eDecrypt, isE2EEncrypted } from './encryption.js';
|
|
26
27
|
import { setTeamSendFn, setTeamClaudeFns, getActiveTeam, serializeTeam } from './team.js';
|
|
@@ -45,6 +46,15 @@ const state = {
|
|
|
45
46
|
config: null,
|
|
46
47
|
entraToken: null,
|
|
47
48
|
};
|
|
49
|
+
let unsubscribeSend = null;
|
|
50
|
+
let unsubscribeSessionStarted = null;
|
|
51
|
+
/** PR4-B: connection-owned AgentRuntime. Shares the backend with the
|
|
52
|
+
* process-wide getBackend() singleton until PR-W1 deletes the singleton. */
|
|
53
|
+
let runtime = null;
|
|
54
|
+
/** Test-only accessor for the active runtime instance. */
|
|
55
|
+
export function _getRuntimeForTests() {
|
|
56
|
+
return runtime;
|
|
57
|
+
}
|
|
48
58
|
let heartbeatInterval = null;
|
|
49
59
|
let heartbeatTimeout = null;
|
|
50
60
|
// Wire Entra timer module to this connection's state and send fn.
|
|
@@ -68,6 +78,14 @@ export function connect(config) {
|
|
|
68
78
|
state.workDir = config.dir;
|
|
69
79
|
state.config = config;
|
|
70
80
|
state.shouldReconnect = true;
|
|
81
|
+
// PR4-B: construct the connection-owned runtime. Pass getBackend() (NOT
|
|
82
|
+
// createBackend()) so this runtime and the still-existing module singleton
|
|
83
|
+
// share one ClaudeBackend instance — otherwise the addSendFn /
|
|
84
|
+
// addOnSessionStarted re-exports below and the runtime would each register
|
|
85
|
+
// on different backend instances and fan out events twice. PR-W1 deletes
|
|
86
|
+
// getBackend(); at that point the runtime owns the backend exclusively.
|
|
87
|
+
runtime = new AgentRuntime(getBackend());
|
|
88
|
+
runtime.start();
|
|
71
89
|
// Initialize E2E encryption key
|
|
72
90
|
const ignoreConfig = !!process.env.AGENTLINK_NO_STATE;
|
|
73
91
|
state.e2eKey = getOrCreateE2EKey(ignoreConfig);
|
|
@@ -77,10 +95,24 @@ export function connect(config) {
|
|
|
77
95
|
state.sessionId = prev.sessionId;
|
|
78
96
|
console.log(`[AgentLink] Restoring session: ${prev.sessionId}`);
|
|
79
97
|
}
|
|
80
|
-
// Wire up the Claude module to send messages through our WebSocket
|
|
81
|
-
|
|
98
|
+
// Wire up the Claude module to send messages through our WebSocket.
|
|
99
|
+
// Use additive subscribers so the ClaudeBackend adapter (PR3+) can coexist;
|
|
100
|
+
// capture unsubscribes so disconnect() can detach without leaks.
|
|
101
|
+
if (unsubscribeSend) {
|
|
102
|
+
try {
|
|
103
|
+
unsubscribeSend();
|
|
104
|
+
}
|
|
105
|
+
catch { /* ignore */ }
|
|
106
|
+
}
|
|
107
|
+
unsubscribeSend = addSendFn(send);
|
|
82
108
|
// Wire up action item session linkage
|
|
83
|
-
|
|
109
|
+
if (unsubscribeSessionStarted) {
|
|
110
|
+
try {
|
|
111
|
+
unsubscribeSessionStarted();
|
|
112
|
+
}
|
|
113
|
+
catch { /* ignore */ }
|
|
114
|
+
}
|
|
115
|
+
unsubscribeSessionStarted = addOnSessionStarted(checkPendingActionItem);
|
|
84
116
|
// Initialize the tunnel handler for port proxy
|
|
85
117
|
tunnelHandler = createTunnelHandler(send);
|
|
86
118
|
// Initialize the terminal manager for web terminal
|
|
@@ -338,6 +370,20 @@ export function disconnect() {
|
|
|
338
370
|
stopEntraRetryTimer();
|
|
339
371
|
cancelEntraRefresh();
|
|
340
372
|
abortAllClaude();
|
|
373
|
+
if (unsubscribeSend) {
|
|
374
|
+
try {
|
|
375
|
+
unsubscribeSend();
|
|
376
|
+
}
|
|
377
|
+
catch { /* ignore */ }
|
|
378
|
+
unsubscribeSend = null;
|
|
379
|
+
}
|
|
380
|
+
if (unsubscribeSessionStarted) {
|
|
381
|
+
try {
|
|
382
|
+
unsubscribeSessionStarted();
|
|
383
|
+
}
|
|
384
|
+
catch { /* ignore */ }
|
|
385
|
+
unsubscribeSessionStarted = null;
|
|
386
|
+
}
|
|
341
387
|
if (tunnelHandler)
|
|
342
388
|
tunnelHandler.cleanup();
|
|
343
389
|
if (terminalManager)
|
|
@@ -346,6 +392,13 @@ export function disconnect() {
|
|
|
346
392
|
state.ws.close();
|
|
347
393
|
state.ws = null;
|
|
348
394
|
}
|
|
395
|
+
// PR4-B: tear down the connection-owned runtime. Awaited here so any future
|
|
396
|
+
// backend (e.g. Codex) can do real async cleanup; the SIGINT/SIGTERM path
|
|
397
|
+
// in index.ts uses shutdownSyncBestEffort() instead because it
|
|
398
|
+
// process.exit(0)s immediately and cannot await.
|
|
399
|
+
const r = runtime;
|
|
400
|
+
runtime = null;
|
|
401
|
+
return r ? r.shutdown() : Promise.resolve();
|
|
349
402
|
}
|
|
350
403
|
let sendQueue = Promise.resolve();
|
|
351
404
|
export function send(msg) {
|
|
@@ -404,6 +457,9 @@ function buildWsUrl(config) {
|
|
|
404
457
|
if (config.entra) {
|
|
405
458
|
params.set('entra', '1');
|
|
406
459
|
}
|
|
460
|
+
// Forward selected backend so the server can route accordingly. Default
|
|
461
|
+
// 'claude' makes this a no-op for existing servers that ignore the param.
|
|
462
|
+
params.set('backendType', config.agent ?? 'claude');
|
|
407
463
|
return `${base}/?${params}`;
|
|
408
464
|
}
|
|
409
465
|
function scheduleReconnect(config) {
|
|
@@ -498,18 +554,58 @@ function handleServerMessage(msg) {
|
|
|
498
554
|
if (actionItemId && chatConvId) {
|
|
499
555
|
pendingActionItems.set(chatConvId, actionItemId);
|
|
500
556
|
}
|
|
501
|
-
|
|
557
|
+
// PR4-B: route through AgentRuntime. ClaudeBackend forwards `metadata`
|
|
558
|
+
// (brainMode/recapId/briefingDate/devops*/projectName/icmId) as
|
|
559
|
+
// HandleChatOptions to claude.handleChat. .catch() so any rejection
|
|
560
|
+
// from ensureSession/backend.startTurn is logged rather than becoming
|
|
561
|
+
// an unhandled rejection (Codex review #1, high). handleServerMessage
|
|
562
|
+
// is sync, so we can't await; the caller in ws.on('message') has its
|
|
563
|
+
// own try/catch but it would miss async rejections from this call.
|
|
564
|
+
// Use 'default' as the conv key when missing — matches claude.ts's
|
|
565
|
+
// DEFAULT_CONVERSATION_ID so the runtime map and backend's internal
|
|
566
|
+
// map agree. Empty string would create a divergent mapping (Copilot
|
|
567
|
+
// round 1).
|
|
568
|
+
const startConvId = chatConvId ?? 'default';
|
|
569
|
+
if (!runtime) {
|
|
570
|
+
console.warn('[AgentLink] chat received after runtime shutdown — ignoring');
|
|
571
|
+
break;
|
|
572
|
+
}
|
|
573
|
+
runtime.startTurn(startConvId, {
|
|
574
|
+
text: msg.prompt,
|
|
575
|
+
files: msg.files,
|
|
576
|
+
metadata: chatOptions,
|
|
577
|
+
}, { workDir: chatDir, resumeSessionId: chatOptions.resumeSessionId }).catch((err) => {
|
|
578
|
+
console.error('[AgentLink] runtime.startTurn failed:', err.message);
|
|
579
|
+
});
|
|
502
580
|
break;
|
|
503
581
|
}
|
|
504
|
-
case 'cancel_execution':
|
|
505
|
-
|
|
582
|
+
case 'cancel_execution': {
|
|
583
|
+
const cancelConvId = msg.conversationId;
|
|
584
|
+
// 'default' default matches claude.ts (Copilot round 1).
|
|
585
|
+
if (!runtime) {
|
|
586
|
+
console.warn('[AgentLink] cancel received after runtime shutdown — ignoring');
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
runtime.interruptTurn(cancelConvId ?? 'default').catch((err) => {
|
|
590
|
+
console.error('[AgentLink] runtime.interruptTurn failed:', err.message);
|
|
591
|
+
});
|
|
506
592
|
break;
|
|
593
|
+
}
|
|
507
594
|
case 'list_sessions':
|
|
508
|
-
handleListSessions()
|
|
595
|
+
handleListSessions().catch((err) => {
|
|
596
|
+
console.error('[AgentLink] handleListSessions failed:', err);
|
|
597
|
+
send({ type: 'sessions_list', sessions: [], workDir: state.workDir });
|
|
598
|
+
});
|
|
509
599
|
break;
|
|
510
600
|
case 'list_recent_sessions':
|
|
511
601
|
handleListRecentSessions(msg);
|
|
512
602
|
break;
|
|
603
|
+
case 'search_sessions':
|
|
604
|
+
handleSearchSessions(msg);
|
|
605
|
+
break;
|
|
606
|
+
case 'cancel_search':
|
|
607
|
+
handleCancelSearch(msg);
|
|
608
|
+
break;
|
|
513
609
|
case 'list_directory':
|
|
514
610
|
handleListDirectory(msg, state.workDir, send);
|
|
515
611
|
break;
|
|
@@ -564,6 +660,10 @@ function handleServerMessage(msg) {
|
|
|
564
660
|
// Backward compat: old web client sends this to reset the single conversation
|
|
565
661
|
abortClaude();
|
|
566
662
|
clearSessionId('default');
|
|
663
|
+
// Evict the runtime's cached ref so the next chat re-ensures (otherwise
|
|
664
|
+
// ClaudeBackend would compute resumeSessionId from the stale ref and
|
|
665
|
+
// resume the just-cleared session). Copilot round 3 fix.
|
|
666
|
+
runtime?.evictConversation('default');
|
|
567
667
|
console.log('[AgentLink] New conversation — session cleared');
|
|
568
668
|
break;
|
|
569
669
|
case 'resume_conversation': {
|
|
@@ -581,85 +681,123 @@ function handleServerMessage(msg) {
|
|
|
581
681
|
// (handles page refresh where web client generates a new UUID)
|
|
582
682
|
rebindConversation(m.claudeSessionId, convId);
|
|
583
683
|
}
|
|
584
|
-
//
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
684
|
+
// History reads went async in PR4-D (runtime.readHistory is async).
|
|
685
|
+
// Wrap the rest of the handler in an async IIFE so we can await without
|
|
686
|
+
// making the entire switch dispatch async. .catch() ensures backend
|
|
687
|
+
// rejections don't become unhandled promise rejections.
|
|
688
|
+
(async () => {
|
|
689
|
+
// Try current workDir first; fall back to BRAIN_DATA_DIR for recap/briefing chat sessions
|
|
690
|
+
let history = runtime
|
|
691
|
+
? await runtime.readHistory(state.workDir, m.claudeSessionId)
|
|
692
|
+
: [];
|
|
693
|
+
if (history.length === 0) {
|
|
694
|
+
const sessionMeta_ = loadSessionMetadata(m.claudeSessionId);
|
|
695
|
+
if (sessionMeta_.recapId || sessionMeta_.briefingDate || sessionMeta_.devopsEntityType || sessionMeta_.projectName || sessionMeta_.icmId) {
|
|
696
|
+
history = runtime
|
|
697
|
+
? await runtime.readHistory(BRAIN_DATA_DIR, m.claudeSessionId)
|
|
698
|
+
: [];
|
|
699
|
+
}
|
|
590
700
|
}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
// Restore persisted permission mode (falls back to 'normal' if not found)
|
|
599
|
-
const persistedPermission = getConversationPermission(m.claudeSessionId) || 'normal';
|
|
600
|
-
if (currentConv) {
|
|
601
|
-
const currentMode = currentConv.permissionMode ?? (currentConv.planMode ? 'plan' : 'normal');
|
|
602
|
-
const persistedMode = persistedPermission;
|
|
603
|
-
const modeChanged = currentMode !== persistedMode;
|
|
604
|
-
currentConv.planMode = persistedMode === 'plan';
|
|
605
|
-
currentConv.permissionMode = persistedMode;
|
|
606
|
-
// Only restart when the persisted mode differs from what's running,
|
|
607
|
-
// and never interrupt an active turn.
|
|
608
|
-
if (modeChanged && currentConv.child && !currentConv.turnActive) {
|
|
609
|
-
restartConversation(convId, { planMode: currentConv.planMode, permissionMode: currentConv.permissionMode });
|
|
701
|
+
// Fallback for BrainCore-spawned action-item sessions: try user home directory.
|
|
702
|
+
// Action-item Claude sessions are spawned by BrainCore with cwd=$HOME and have
|
|
703
|
+
// no AgentLink session-metadata sidecar, so neither lookup above finds them.
|
|
704
|
+
if (history.length === 0) {
|
|
705
|
+
history = runtime
|
|
706
|
+
? await runtime.readHistory(os.homedir(), m.claudeSessionId)
|
|
707
|
+
: [];
|
|
610
708
|
}
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
claudeSessionId
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
709
|
+
console.log(`[AgentLink] → conversation_resumed (${history.length} messages, session ${m.claudeSessionId.slice(0, 8)})`);
|
|
710
|
+
// Include live status so the web client can restore compacting/processing state
|
|
711
|
+
// In multi-session mode, look up by conversationId; in single-session mode, use default
|
|
712
|
+
const currentConv = convId ? getConversation(convId) : getConversation();
|
|
713
|
+
const isSameSession = currentConv?.claudeSessionId === m.claudeSessionId
|
|
714
|
+
|| currentConv?.lastClaudeSessionId === m.claudeSessionId;
|
|
715
|
+
// Restore persisted permission mode (falls back to 'normal' if not found)
|
|
716
|
+
const persistedPermission = getConversationPermission(m.claudeSessionId) || 'normal';
|
|
717
|
+
if (currentConv) {
|
|
718
|
+
const currentMode = currentConv.permissionMode ?? (currentConv.planMode ? 'plan' : 'normal');
|
|
719
|
+
const persistedMode = persistedPermission;
|
|
720
|
+
const modeChanged = currentMode !== persistedMode;
|
|
721
|
+
currentConv.planMode = persistedMode === 'plan';
|
|
722
|
+
currentConv.permissionMode = persistedMode;
|
|
723
|
+
// Only restart when the persisted mode differs from what's running,
|
|
724
|
+
// and never interrupt an active turn.
|
|
725
|
+
if (modeChanged && currentConv.child && !currentConv.turnActive) {
|
|
726
|
+
const cid = convId ?? 'default';
|
|
727
|
+
runtime?.restartConversation(cid, { planMode: currentConv.planMode, permissionMode: currentConv.permissionMode });
|
|
728
|
+
// Re-seed runtime mapping with the resumed session id so the next
|
|
729
|
+
// chat resumes m.claudeSessionId rather than starting fresh after
|
|
730
|
+
// restartConversation evicts the cache. (Codex review round 1.)
|
|
731
|
+
runtime?.ensureConversation(cid, { workDir: state.workDir, resumeSessionId: m.claudeSessionId }).catch(() => { });
|
|
732
|
+
}
|
|
633
733
|
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
734
|
+
send({
|
|
735
|
+
type: 'conversation_resumed',
|
|
736
|
+
conversationId: convId,
|
|
737
|
+
claudeSessionId: m.claudeSessionId,
|
|
738
|
+
history,
|
|
739
|
+
isCompacting: isSameSession && (runtime?.isCompacting(convId) ?? false),
|
|
740
|
+
isProcessing: isSameSession && currentConv?.turnActive === true,
|
|
741
|
+
planMode: persistedPermission === 'plan',
|
|
742
|
+
permissionMode: persistedPermission,
|
|
743
|
+
...(typeof m.scrollToMessageIdx === 'number' ? { scrollToMessageIdx: m.scrollToMessageIdx } : {}),
|
|
744
|
+
...(m.searchQuery ? { searchQuery: m.searchQuery } : {}),
|
|
745
|
+
});
|
|
746
|
+
// Re-deliver any pending AskUserQuestion requests so the refreshed web client
|
|
747
|
+
// can display the question card and allow the user to answer.
|
|
748
|
+
if (convId && isSameSession) {
|
|
749
|
+
const pending = getPendingQuestions(convId);
|
|
750
|
+
for (const pq of pending) {
|
|
751
|
+
send({
|
|
752
|
+
type: 'ask_user_question',
|
|
753
|
+
conversationId: convId,
|
|
754
|
+
requestId: pq.requestId,
|
|
755
|
+
questions: pq.questions,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
// Re-deliver pending tool permission requests (same pattern as ask_user_question)
|
|
759
|
+
const pendingPerms = getPendingToolPermissions(convId);
|
|
760
|
+
for (const pp of pendingPerms) {
|
|
761
|
+
send({
|
|
762
|
+
type: 'tool_permission_request',
|
|
763
|
+
conversationId: convId,
|
|
764
|
+
requestId: pp.requestId,
|
|
765
|
+
toolName: pp.toolName,
|
|
766
|
+
displayName: pp.displayName,
|
|
767
|
+
input: pp.input,
|
|
768
|
+
decisionReason: pp.decisionReason,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
646
771
|
}
|
|
647
|
-
}
|
|
772
|
+
})().catch((err) => {
|
|
773
|
+
console.error('[AgentLink] resume_conversation failed:', err);
|
|
774
|
+
send({ type: 'error', message: 'Failed to resume conversation.' });
|
|
775
|
+
});
|
|
648
776
|
break;
|
|
649
777
|
}
|
|
650
778
|
case 'ask_user_answer': {
|
|
651
779
|
const m = msg;
|
|
652
|
-
|
|
780
|
+
if (!runtime) {
|
|
781
|
+
console.warn('[AgentLink] ask_user_answer received after runtime shutdown — ignoring');
|
|
782
|
+
break;
|
|
783
|
+
}
|
|
784
|
+
runtime.answerUserInput(m.requestId, m.answers);
|
|
653
785
|
break;
|
|
654
786
|
}
|
|
655
787
|
case 'delete_session': {
|
|
656
788
|
const m = msg;
|
|
657
|
-
handleDeleteSession(m.sessionId)
|
|
789
|
+
handleDeleteSession(m.sessionId).catch((err) => {
|
|
790
|
+
console.error('[AgentLink] handleDeleteSession failed:', err);
|
|
791
|
+
send({ type: 'error', message: 'Session not found or could not be deleted.' });
|
|
792
|
+
});
|
|
658
793
|
break;
|
|
659
794
|
}
|
|
660
795
|
case 'rename_session': {
|
|
661
796
|
const m = msg;
|
|
662
|
-
handleRenameSession(m.sessionId, m.newTitle)
|
|
797
|
+
handleRenameSession(m.sessionId, m.newTitle).catch((err) => {
|
|
798
|
+
console.error('[AgentLink] handleRenameSession failed:', err);
|
|
799
|
+
send({ type: 'error', message: 'Session not found or could not be renamed.' });
|
|
800
|
+
});
|
|
663
801
|
break;
|
|
664
802
|
}
|
|
665
803
|
case 'query_active_conversations': {
|
|
@@ -670,7 +808,7 @@ function handleServerMessage(msg) {
|
|
|
670
808
|
conversationId: convId,
|
|
671
809
|
claudeSessionId: conv.claudeSessionId,
|
|
672
810
|
isProcessing: true,
|
|
673
|
-
isCompacting:
|
|
811
|
+
isCompacting: runtime?.isCompacting(convId) ?? false,
|
|
674
812
|
});
|
|
675
813
|
}
|
|
676
814
|
else if (conv.claudeSessionId) {
|
|
@@ -784,11 +922,11 @@ function handleServerMessage(msg) {
|
|
|
784
922
|
break;
|
|
785
923
|
}
|
|
786
924
|
console.log(`[AgentLink] set_model: model=${model}, conversationId=${conversationId}`);
|
|
787
|
-
setModel(conversationId, model);
|
|
925
|
+
runtime?.setModel(conversationId ?? null, model);
|
|
788
926
|
// Kill current Claude process so next message spawns with new --model flag
|
|
789
927
|
const conv = getConversation(conversationId);
|
|
790
928
|
if (conv) {
|
|
791
|
-
restartConversation(conversationId);
|
|
929
|
+
runtime?.restartConversation(conversationId ?? 'default');
|
|
792
930
|
}
|
|
793
931
|
send({ type: 'model_changed', model, conversationId });
|
|
794
932
|
break;
|
|
@@ -796,17 +934,10 @@ function handleServerMessage(msg) {
|
|
|
796
934
|
case 'set_plan_mode': {
|
|
797
935
|
const { enabled, conversationId } = msg;
|
|
798
936
|
console.log(`[AgentLink] set_plan_mode: enabled=${enabled}, conversationId=${conversationId}`);
|
|
799
|
-
const
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
if (result.wasTurnActive) {
|
|
804
|
-
send({ type: 'execution_cancelled', conversationId });
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
else {
|
|
808
|
-
// No conversation yet — create placeholder
|
|
809
|
-
createPlaceholderConversation(conversationId, { planMode: enabled });
|
|
937
|
+
const convId = conversationId ?? 'default';
|
|
938
|
+
const result = runtime?.setPlanMode(convId, enabled, state.workDir) ?? null;
|
|
939
|
+
if (result?.wasTurnActive) {
|
|
940
|
+
send({ type: 'execution_cancelled', conversationId });
|
|
810
941
|
}
|
|
811
942
|
send({ type: 'plan_mode_changed', enabled, conversationId });
|
|
812
943
|
break;
|
|
@@ -814,22 +945,10 @@ function handleServerMessage(msg) {
|
|
|
814
945
|
case 'set_permission_mode': {
|
|
815
946
|
const { mode, conversationId } = msg;
|
|
816
947
|
console.log(`[AgentLink] set_permission_mode: mode=${mode}, conversationId=${conversationId}`);
|
|
817
|
-
const
|
|
818
|
-
const
|
|
819
|
-
if (
|
|
820
|
-
|
|
821
|
-
planMode,
|
|
822
|
-
permissionMode: mode,
|
|
823
|
-
});
|
|
824
|
-
if (result.wasTurnActive) {
|
|
825
|
-
send({ type: 'execution_cancelled', conversationId });
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
else {
|
|
829
|
-
createPlaceholderConversation(conversationId, {
|
|
830
|
-
planMode,
|
|
831
|
-
permissionMode: mode,
|
|
832
|
-
});
|
|
948
|
+
const convId = conversationId ?? 'default';
|
|
949
|
+
const result = runtime?.setPermissionMode(convId, mode, state.workDir) ?? null;
|
|
950
|
+
if (result?.wasTurnActive) {
|
|
951
|
+
send({ type: 'execution_cancelled', conversationId });
|
|
833
952
|
}
|
|
834
953
|
send({ type: 'permission_mode_changed', mode, conversationId });
|
|
835
954
|
// Persist permission mode for this conversation's session
|
|
@@ -846,23 +965,20 @@ function handleServerMessage(msg) {
|
|
|
846
965
|
case 'tool_permission_response': {
|
|
847
966
|
const { requestId, behavior } = msg;
|
|
848
967
|
console.log(`[AgentLink] tool_permission_response: ${behavior} (${requestId})`);
|
|
849
|
-
|
|
968
|
+
if (!runtime) {
|
|
969
|
+
console.warn('[AgentLink] tool_permission_response received after runtime shutdown — ignoring');
|
|
970
|
+
break;
|
|
971
|
+
}
|
|
972
|
+
runtime.answerApproval(requestId, behavior);
|
|
850
973
|
break;
|
|
851
974
|
}
|
|
852
975
|
case 'set_brain_mode': {
|
|
853
976
|
const { enabled, conversationId } = msg;
|
|
854
977
|
console.log(`[AgentLink] set_brain_mode: enabled=${enabled}, conversationId=${conversationId}`);
|
|
855
|
-
const
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
if (result.wasTurnActive) {
|
|
860
|
-
send({ type: 'execution_cancelled', conversationId });
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
else {
|
|
864
|
-
// No conversation yet — create placeholder
|
|
865
|
-
createPlaceholderConversation(conversationId, { brainMode: enabled });
|
|
978
|
+
const convId = conversationId ?? 'default';
|
|
979
|
+
const result = runtime?.setBrainMode(convId, enabled, state.workDir) ?? null;
|
|
980
|
+
if (result?.wasTurnActive) {
|
|
981
|
+
send({ type: 'execution_cancelled', conversationId });
|
|
866
982
|
}
|
|
867
983
|
send({ type: 'brain_mode_changed', enabled, conversationId });
|
|
868
984
|
break;
|
|
@@ -1260,18 +1376,38 @@ async function handleBrainChatDeleteSession(msg) {
|
|
|
1260
1376
|
send({ type: 'brainchat_error', message: String(err) });
|
|
1261
1377
|
}
|
|
1262
1378
|
}
|
|
1263
|
-
function handleListSessions() {
|
|
1379
|
+
async function handleListSessions() {
|
|
1264
1380
|
try {
|
|
1265
|
-
|
|
1381
|
+
if (!runtime) {
|
|
1382
|
+
send({ type: 'sessions_list', sessions: [], workDir: state.workDir });
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
// PR4-D: runtime.listHistory returns BackendSessionInfo rows; map to the
|
|
1386
|
+
// flat wire shape connection.ts has always sent ({sessionId, title, ...}).
|
|
1387
|
+
const rows = await runtime.listHistory(state.workDir);
|
|
1388
|
+
const sessions = rows.map(r => ({
|
|
1389
|
+
sessionId: r.session.backendSessionId,
|
|
1390
|
+
title: r.title,
|
|
1391
|
+
customTitle: r.customTitle,
|
|
1392
|
+
preview: r.preview,
|
|
1393
|
+
lastModified: r.lastModified,
|
|
1394
|
+
}));
|
|
1266
1395
|
const metaMap = loadAllSessionMetadata();
|
|
1267
1396
|
const enriched = sessions.map(s => ({
|
|
1268
1397
|
...s,
|
|
1269
1398
|
...metaMap.get(s.sessionId),
|
|
1270
1399
|
}));
|
|
1271
1400
|
// Always merge recap/briefing/devops chat sessions from BrainData directory — they live under a
|
|
1272
|
-
// different Claude project folder, so
|
|
1401
|
+
// different Claude project folder, so listHistory(state.workDir) misses them.
|
|
1273
1402
|
// These sessions should be visible regardless of the current workDir.
|
|
1274
|
-
const
|
|
1403
|
+
const brainRows = await runtime.listHistory(BRAIN_DATA_DIR);
|
|
1404
|
+
const brainSessions = brainRows.map(r => ({
|
|
1405
|
+
sessionId: r.session.backendSessionId,
|
|
1406
|
+
title: r.title,
|
|
1407
|
+
customTitle: r.customTitle,
|
|
1408
|
+
preview: r.preview,
|
|
1409
|
+
lastModified: r.lastModified,
|
|
1410
|
+
}));
|
|
1275
1411
|
const existingIds = new Set(enriched.map(s => s.sessionId));
|
|
1276
1412
|
for (const bs of brainSessions) {
|
|
1277
1413
|
if (existingIds.has(bs.sessionId))
|
|
@@ -1302,18 +1438,76 @@ async function handleListRecentSessions(msg) {
|
|
|
1302
1438
|
send({ type: 'recent_sessions_list', sessions: [] });
|
|
1303
1439
|
}
|
|
1304
1440
|
}
|
|
1305
|
-
|
|
1441
|
+
// ── Session content search ───────────────────────────────────────────────
|
|
1442
|
+
// Single in-flight search at a time. Newer searches abort older ones; explicit
|
|
1443
|
+
// cancel_search messages also abort. The web client filters stale results by
|
|
1444
|
+
// echoing the searchId.
|
|
1445
|
+
const searchControllers = new Map();
|
|
1446
|
+
function handleSearchSessions(msg) {
|
|
1447
|
+
const { searchId, query, limit } = msg;
|
|
1448
|
+
if (!searchId)
|
|
1449
|
+
return;
|
|
1450
|
+
// Abort any prior in-flight search (only one at a time).
|
|
1451
|
+
for (const [id, ctrl] of searchControllers) {
|
|
1452
|
+
ctrl.abort();
|
|
1453
|
+
searchControllers.delete(id);
|
|
1454
|
+
}
|
|
1455
|
+
const controller = new AbortController();
|
|
1456
|
+
searchControllers.set(searchId, controller);
|
|
1457
|
+
// searchSessions is sync but we yield to the event loop so a fast follow-up
|
|
1458
|
+
// cancel_search has a chance to abort before we walk the file system.
|
|
1459
|
+
setImmediate(() => {
|
|
1460
|
+
if (controller.signal.aborted) {
|
|
1461
|
+
searchControllers.delete(searchId);
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
try {
|
|
1465
|
+
const result = searchSessions(state.workDir, query, { limit, signal: controller.signal });
|
|
1466
|
+
if (controller.signal.aborted)
|
|
1467
|
+
return;
|
|
1468
|
+
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)`);
|
|
1469
|
+
send({
|
|
1470
|
+
type: 'session_search_results',
|
|
1471
|
+
searchId,
|
|
1472
|
+
...result,
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
catch (err) {
|
|
1476
|
+
console.error('[AgentLink] searchSessions failed:', err);
|
|
1477
|
+
send({
|
|
1478
|
+
type: 'session_search_error',
|
|
1479
|
+
searchId,
|
|
1480
|
+
message: String(err),
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
finally {
|
|
1484
|
+
searchControllers.delete(searchId);
|
|
1485
|
+
}
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
function handleCancelSearch(msg) {
|
|
1489
|
+
const ctrl = searchControllers.get(msg.searchId);
|
|
1490
|
+
if (ctrl) {
|
|
1491
|
+
ctrl.abort();
|
|
1492
|
+
searchControllers.delete(msg.searchId);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
async function handleDeleteSession(sessionId) {
|
|
1306
1496
|
// Evict any idle conversation holding this session; block if busy
|
|
1307
1497
|
if (evictByClaudeSessionId(sessionId)) {
|
|
1308
1498
|
send({ type: 'error', message: 'Cannot delete a session while it is processing.' });
|
|
1309
1499
|
return;
|
|
1310
1500
|
}
|
|
1501
|
+
if (!runtime) {
|
|
1502
|
+
send({ type: 'error', message: 'Session not found or could not be deleted.' });
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1311
1505
|
// Try current workDir first; if not found, check if it's a recap/briefing/devops/project session in BrainData
|
|
1312
|
-
let deleted =
|
|
1506
|
+
let deleted = await runtime.deleteHistorySession(state.workDir, sessionId);
|
|
1313
1507
|
if (!deleted) {
|
|
1314
1508
|
const meta = loadSessionMetadata(sessionId);
|
|
1315
1509
|
if (meta.recapId || meta.briefingDate || meta.devopsEntityType || meta.projectName || meta.icmId) {
|
|
1316
|
-
deleted =
|
|
1510
|
+
deleted = await runtime.deleteHistorySession(BRAIN_DATA_DIR, sessionId);
|
|
1317
1511
|
}
|
|
1318
1512
|
}
|
|
1319
1513
|
if (deleted) {
|
|
@@ -1324,13 +1518,17 @@ function handleDeleteSession(sessionId) {
|
|
|
1324
1518
|
send({ type: 'error', message: 'Session not found or could not be deleted.' });
|
|
1325
1519
|
}
|
|
1326
1520
|
}
|
|
1327
|
-
function handleRenameSession(sessionId, newTitle) {
|
|
1521
|
+
async function handleRenameSession(sessionId, newTitle) {
|
|
1522
|
+
if (!runtime) {
|
|
1523
|
+
send({ type: 'error', message: 'Session not found or could not be renamed.' });
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1328
1526
|
// Try current workDir first; if not found, check if it's a recap/briefing/devops/project session in BrainData
|
|
1329
|
-
let renamed =
|
|
1527
|
+
let renamed = await runtime.renameHistorySession(state.workDir, sessionId, newTitle);
|
|
1330
1528
|
if (!renamed) {
|
|
1331
1529
|
const meta = loadSessionMetadata(sessionId);
|
|
1332
1530
|
if (meta.recapId || meta.briefingDate || meta.devopsEntityType || meta.projectName || meta.icmId) {
|
|
1333
|
-
renamed =
|
|
1531
|
+
renamed = await runtime.renameHistorySession(BRAIN_DATA_DIR, sessionId, newTitle);
|
|
1334
1532
|
}
|
|
1335
1533
|
}
|
|
1336
1534
|
if (renamed) {
|
|
@@ -1340,15 +1538,20 @@ function handleRenameSession(sessionId, newTitle) {
|
|
|
1340
1538
|
// Session file may not exist on disk yet (e.g. IcM/project chat still streaming first response).
|
|
1341
1539
|
// Retry once after a short delay; if it still fails, send an error so the UI can recover.
|
|
1342
1540
|
setTimeout(() => {
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1541
|
+
(async () => {
|
|
1542
|
+
const retried = (await runtime.renameHistorySession(state.workDir, sessionId, newTitle))
|
|
1543
|
+
|| (await runtime.renameHistorySession(BRAIN_DATA_DIR, sessionId, newTitle));
|
|
1544
|
+
if (retried) {
|
|
1545
|
+
send({ type: 'session_renamed', sessionId, newTitle });
|
|
1546
|
+
}
|
|
1547
|
+
else {
|
|
1548
|
+
console.warn(`[AgentLink] rename_session: session ${sessionId.slice(0, 8)} not found after retry`);
|
|
1549
|
+
send({ type: 'error', message: 'Session not found or could not be renamed.' });
|
|
1550
|
+
}
|
|
1551
|
+
})().catch((err) => {
|
|
1552
|
+
console.error('[AgentLink] rename_session retry failed:', err);
|
|
1350
1553
|
send({ type: 'error', message: 'Session not found or could not be renamed.' });
|
|
1351
|
-
}
|
|
1554
|
+
});
|
|
1352
1555
|
}, 1500);
|
|
1353
1556
|
}
|
|
1354
1557
|
}
|