@borgee/agents-host 0.2.0 → 0.2.2
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/README.md +11 -5
- package/dist/agents-host.d.ts +2 -0
- package/dist/agents-host.js +39 -9
- package/dist/chat/sdk-chat-control-plane.d.ts +2 -1
- package/dist/chat/sdk-chat-control-plane.js +2 -2
- package/dist/cli.js +0 -0
- package/dist/config.js +2 -0
- package/dist/durable-cursor-store.d.ts +5 -0
- package/dist/durable-cursor-store.js +7 -0
- package/dist/local-config.js +8 -2
- package/dist/providers/claude/cli-client.d.ts +17 -1
- package/dist/providers/claude/cli-client.js +154 -6
- package/dist/providers/claude/session-store.d.ts +37 -0
- package/dist/providers/claude/session-store.js +156 -0
- package/dist/providers/copilot/cli-client.d.ts +19 -1
- package/dist/providers/copilot/cli-client.js +180 -3
- package/dist/providers/copilot/session-store.d.ts +37 -0
- package/dist/providers/copilot/session-store.js +150 -0
- package/dist/providers/create-provider.js +9 -2
- package/dist/state-paths.d.ts +6 -0
- package/dist/state-paths.js +46 -0
- package/dist/types.d.ts +3 -0
- package/package.json +16 -16
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
const nodeFileSystem = {
|
|
5
|
+
async mkdir(path, options) {
|
|
6
|
+
await mkdir(path, options);
|
|
7
|
+
},
|
|
8
|
+
async openDirectory(path) {
|
|
9
|
+
return open(path, 'r');
|
|
10
|
+
},
|
|
11
|
+
async openFile(path, flags, mode) {
|
|
12
|
+
return open(path, flags, mode);
|
|
13
|
+
},
|
|
14
|
+
async readFile(path, encoding) {
|
|
15
|
+
return readFile(path, encoding);
|
|
16
|
+
},
|
|
17
|
+
async rename(from, to) {
|
|
18
|
+
await rename(from, to);
|
|
19
|
+
},
|
|
20
|
+
async unlink(path) {
|
|
21
|
+
await unlink(path);
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
function shouldIgnoreDirectorySyncError(platform, error) {
|
|
25
|
+
const code = error.code;
|
|
26
|
+
return platform === 'win32' && (code === 'EPERM' || code === 'EINVAL');
|
|
27
|
+
}
|
|
28
|
+
async function syncDirectory(path, fileSystem, platform) {
|
|
29
|
+
let directoryHandle;
|
|
30
|
+
try {
|
|
31
|
+
try {
|
|
32
|
+
directoryHandle = await fileSystem.openDirectory(path);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (!shouldIgnoreDirectorySyncError(platform, error)) {
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
await directoryHandle.sync();
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (!shouldIgnoreDirectorySyncError(platform, error)) {
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (directoryHandle) {
|
|
51
|
+
try {
|
|
52
|
+
await directoryHandle.close();
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Preserve the original directory durability failure.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
if (directoryHandle) {
|
|
61
|
+
await directoryHandle.close();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function normalizePersistedSessions(raw, filePath) {
|
|
65
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
66
|
+
throw new Error(`invalid Claude session file: ${filePath}`);
|
|
67
|
+
}
|
|
68
|
+
const entries = Object.entries(raw);
|
|
69
|
+
const sessions = {};
|
|
70
|
+
for (const [channelId, sessionId] of entries) {
|
|
71
|
+
if (typeof sessionId !== 'string' || sessionId.trim().length === 0) {
|
|
72
|
+
throw new Error(`invalid Claude session file: ${filePath}`);
|
|
73
|
+
}
|
|
74
|
+
sessions[channelId] = sessionId;
|
|
75
|
+
}
|
|
76
|
+
return sessions;
|
|
77
|
+
}
|
|
78
|
+
export class FileClaudeChannelSessionStore {
|
|
79
|
+
options;
|
|
80
|
+
constructor(options) {
|
|
81
|
+
this.options = options;
|
|
82
|
+
}
|
|
83
|
+
async loadFromPath(filePath) {
|
|
84
|
+
let raw;
|
|
85
|
+
try {
|
|
86
|
+
raw = await (this.options.fileSystem ?? nodeFileSystem).readFile(filePath, 'utf8');
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
return normalizePersistedSessions(JSON.parse(raw), filePath);
|
|
92
|
+
}
|
|
93
|
+
async load(agentId) {
|
|
94
|
+
const filePath = this.options.resolvePath(agentId);
|
|
95
|
+
try {
|
|
96
|
+
return await this.loadFromPath(filePath);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
if (error.code !== 'ENOENT') {
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
return {};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async save(agentId, sessions) {
|
|
106
|
+
const filePath = this.options.resolvePath(agentId);
|
|
107
|
+
const parentPath = dirname(filePath);
|
|
108
|
+
const fileSystem = this.options.fileSystem ?? nodeFileSystem;
|
|
109
|
+
const platform = this.options.platform ?? process.platform;
|
|
110
|
+
await fileSystem.mkdir(parentPath, { recursive: true, mode: 0o700 });
|
|
111
|
+
const entries = Object.entries(sessions).sort(([left], [right]) => left.localeCompare(right));
|
|
112
|
+
if (entries.length === 0) {
|
|
113
|
+
try {
|
|
114
|
+
await fileSystem.unlink(filePath);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (error.code !== 'ENOENT') {
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
await syncDirectory(parentPath, fileSystem, platform);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
126
|
+
let temporaryHandle;
|
|
127
|
+
try {
|
|
128
|
+
temporaryHandle = await fileSystem.openFile(temporaryPath, 'wx', 0o600);
|
|
129
|
+
await temporaryHandle.writeFile(JSON.stringify(Object.fromEntries(entries)), {
|
|
130
|
+
encoding: 'utf8',
|
|
131
|
+
});
|
|
132
|
+
await temporaryHandle.sync();
|
|
133
|
+
await temporaryHandle.close();
|
|
134
|
+
temporaryHandle = undefined;
|
|
135
|
+
await fileSystem.rename(temporaryPath, filePath);
|
|
136
|
+
await syncDirectory(parentPath, fileSystem, platform);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (temporaryHandle) {
|
|
140
|
+
try {
|
|
141
|
+
await temporaryHandle.close();
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// Preserve the original durability failure.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
await fileSystem.unlink(temporaryPath);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// Cleanup is best effort.
|
|
152
|
+
}
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import spawn from 'cross-spawn';
|
|
2
2
|
import { PROTOCOL_VERSION, client, methods, ndJsonStream } from '@agentclientprotocol/sdk';
|
|
3
3
|
import type { ProviderGenerateOptions } from '../../types.js';
|
|
4
|
+
import type { CopilotChannelSessionStore } from './session-store.js';
|
|
4
5
|
interface CopilotAcpRuntime {
|
|
5
6
|
spawn: typeof spawn;
|
|
6
7
|
client: typeof client;
|
|
@@ -22,8 +23,11 @@ interface CopilotAcpRuntime {
|
|
|
22
23
|
*/
|
|
23
24
|
export declare class CopilotCliClient {
|
|
24
25
|
private readonly command;
|
|
26
|
+
private readonly sessionStore?;
|
|
27
|
+
private readonly resolveSessionStoreAgentId;
|
|
25
28
|
private readonly runtime;
|
|
26
29
|
private readonly channels;
|
|
30
|
+
private readonly persistedSessions;
|
|
27
31
|
private readonly closingSessions;
|
|
28
32
|
private readonly pendingSessionStarts;
|
|
29
33
|
private readonly pendingSessionCloses;
|
|
@@ -38,7 +42,11 @@ export declare class CopilotCliClient {
|
|
|
38
42
|
private fatalError;
|
|
39
43
|
private disposing;
|
|
40
44
|
private backendClosed;
|
|
41
|
-
|
|
45
|
+
private loadedSessionStoreAgentId;
|
|
46
|
+
private sessionStoreLoadPromise;
|
|
47
|
+
private sessionStoreWriteQueue;
|
|
48
|
+
private sessionCapabilities;
|
|
49
|
+
constructor(command: string, _ignoredArgs?: string[], runtimeOverrides?: Partial<CopilotAcpRuntime>, sessionStore?: CopilotChannelSessionStore | undefined, resolveSessionStoreAgentId?: () => string | undefined);
|
|
42
50
|
generateReply(channelId: string, prompt: string, options?: ProviderGenerateOptions): Promise<string>;
|
|
43
51
|
dispose(): Promise<void>;
|
|
44
52
|
private ensureStarted;
|
|
@@ -46,6 +54,10 @@ export declare class CopilotCliClient {
|
|
|
46
54
|
private getOrCreateChannelState;
|
|
47
55
|
private processChannelQueue;
|
|
48
56
|
private getOrCreateSession;
|
|
57
|
+
private startFreshSession;
|
|
58
|
+
private restoreOrCreateSession;
|
|
59
|
+
private restoreSession;
|
|
60
|
+
private clearBufferedSessionReplay;
|
|
49
61
|
private runTurn;
|
|
50
62
|
private raceWithFatal;
|
|
51
63
|
private failAll;
|
|
@@ -54,6 +66,12 @@ export declare class CopilotCliClient {
|
|
|
54
66
|
private rejectQueuedTurnsAfterSessionTaint;
|
|
55
67
|
private clearIdleTimer;
|
|
56
68
|
private reconcileIdleChannelState;
|
|
69
|
+
private ensureSessionStoreLoaded;
|
|
70
|
+
private persistSession;
|
|
71
|
+
private persistSessionBestEffort;
|
|
72
|
+
private clearPersistedSession;
|
|
73
|
+
private clearPersistedSessionBestEffort;
|
|
74
|
+
private flushSessionStore;
|
|
57
75
|
private evictIdleChannel;
|
|
58
76
|
private shutdownBackend;
|
|
59
77
|
private waitForPendingSessionStarts;
|
|
@@ -17,6 +17,10 @@ const DEFAULT_RUNTIME = {
|
|
|
17
17
|
shutdownGracePeriodMs: DEFAULT_SHUTDOWN_GRACE_PERIOD_MS,
|
|
18
18
|
shutdownForceKillWaitMs: DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS,
|
|
19
19
|
};
|
|
20
|
+
const DEFAULT_SESSION_CAPABILITIES = {
|
|
21
|
+
loadSession: false,
|
|
22
|
+
resumeSession: false,
|
|
23
|
+
};
|
|
20
24
|
function hasVisibleText(value) {
|
|
21
25
|
return typeof value === 'string' && value.trim().length > 0;
|
|
22
26
|
}
|
|
@@ -127,6 +131,24 @@ function createDeferredTurn(prompt, options) {
|
|
|
127
131
|
function normalizeError(error) {
|
|
128
132
|
return error instanceof Error ? error : new Error(String(error));
|
|
129
133
|
}
|
|
134
|
+
function asObject(value) {
|
|
135
|
+
return typeof value === 'object' && value !== null ? value : null;
|
|
136
|
+
}
|
|
137
|
+
function isStaleRestoreFailure(error) {
|
|
138
|
+
const message = normalizeError(error).message.toLowerCase();
|
|
139
|
+
return message.includes('session not found')
|
|
140
|
+
|| message.includes('unknown session')
|
|
141
|
+
|| message.includes('cannot resume')
|
|
142
|
+
|| message.includes('not found');
|
|
143
|
+
}
|
|
144
|
+
function readSessionCapabilities(response) {
|
|
145
|
+
const agentCapabilities = asObject(asObject(response)?.agentCapabilities);
|
|
146
|
+
const sessionCapabilities = asObject(agentCapabilities?.sessionCapabilities ?? agentCapabilities?.session);
|
|
147
|
+
return {
|
|
148
|
+
loadSession: agentCapabilities?.loadSession === true,
|
|
149
|
+
resumeSession: asObject(sessionCapabilities?.resume) !== null,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
130
152
|
function markSessionTainted(error) {
|
|
131
153
|
const normalized = normalizeError(error);
|
|
132
154
|
SESSION_TAINTED_ERRORS.add(normalized);
|
|
@@ -153,8 +175,11 @@ function selectPermissionOption(options) {
|
|
|
153
175
|
*/
|
|
154
176
|
export class CopilotCliClient {
|
|
155
177
|
command;
|
|
178
|
+
sessionStore;
|
|
179
|
+
resolveSessionStoreAgentId;
|
|
156
180
|
runtime;
|
|
157
181
|
channels = new Map();
|
|
182
|
+
persistedSessions = new Map();
|
|
158
183
|
closingSessions = new WeakSet();
|
|
159
184
|
pendingSessionStarts = new Set();
|
|
160
185
|
pendingSessionCloses = new Set();
|
|
@@ -169,8 +194,14 @@ export class CopilotCliClient {
|
|
|
169
194
|
fatalError = null;
|
|
170
195
|
disposing = false;
|
|
171
196
|
backendClosed = false;
|
|
172
|
-
|
|
197
|
+
loadedSessionStoreAgentId = null;
|
|
198
|
+
sessionStoreLoadPromise = null;
|
|
199
|
+
sessionStoreWriteQueue = Promise.resolve();
|
|
200
|
+
sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
|
|
201
|
+
constructor(command, _ignoredArgs = [], runtimeOverrides = {}, sessionStore, resolveSessionStoreAgentId = () => undefined) {
|
|
173
202
|
this.command = command;
|
|
203
|
+
this.sessionStore = sessionStore;
|
|
204
|
+
this.resolveSessionStoreAgentId = resolveSessionStoreAgentId;
|
|
174
205
|
this.runtime = { ...DEFAULT_RUNTIME, ...runtimeOverrides };
|
|
175
206
|
this.fatalPromise = new Promise((_, reject) => {
|
|
176
207
|
this.rejectFatalPromise = reject;
|
|
@@ -248,7 +279,7 @@ export class CopilotCliClient {
|
|
|
248
279
|
}
|
|
249
280
|
});
|
|
250
281
|
try {
|
|
251
|
-
await connection.agent.request(this.runtime.methods.agent.initialize, {
|
|
282
|
+
const initializeResponse = await connection.agent.request(this.runtime.methods.agent.initialize, {
|
|
252
283
|
protocolVersion: this.runtime.protocolVersion,
|
|
253
284
|
clientCapabilities: {},
|
|
254
285
|
clientInfo: {
|
|
@@ -256,6 +287,7 @@ export class CopilotCliClient {
|
|
|
256
287
|
version: '0.1.6',
|
|
257
288
|
},
|
|
258
289
|
});
|
|
290
|
+
this.sessionCapabilities = readSessionCapabilities(initializeResponse);
|
|
259
291
|
}
|
|
260
292
|
catch (error) {
|
|
261
293
|
const normalized = new Error(`Copilot ACP initialize failed: ${normalizeError(error).message}`);
|
|
@@ -328,7 +360,11 @@ export class CopilotCliClient {
|
|
|
328
360
|
if (!this.connection) {
|
|
329
361
|
throw new Error('Copilot ACP connection is not available');
|
|
330
362
|
}
|
|
331
|
-
|
|
363
|
+
await this.ensureSessionStoreLoaded();
|
|
364
|
+
const persistedSessionId = this.persistedSessions.get(channelId);
|
|
365
|
+
const sessionPromise = persistedSessionId
|
|
366
|
+
? this.restoreOrCreateSession(channelId, persistedSessionId)
|
|
367
|
+
: this.startFreshSession(channelId);
|
|
332
368
|
this.pendingSessionStarts.add(sessionPromise);
|
|
333
369
|
state.sessionPromise = sessionPromise;
|
|
334
370
|
let sessionAdopted = false;
|
|
@@ -363,6 +399,69 @@ export class CopilotCliClient {
|
|
|
363
399
|
}
|
|
364
400
|
}
|
|
365
401
|
}
|
|
402
|
+
async startFreshSession(channelId) {
|
|
403
|
+
if (!this.connection) {
|
|
404
|
+
throw new Error('Copilot ACP connection is not available');
|
|
405
|
+
}
|
|
406
|
+
const session = await this.connection.agent.buildSession(this.runtime.cwd).start();
|
|
407
|
+
await this.persistSessionBestEffort(channelId, session.sessionId);
|
|
408
|
+
return session;
|
|
409
|
+
}
|
|
410
|
+
async restoreOrCreateSession(channelId, sessionId) {
|
|
411
|
+
if (!this.sessionCapabilities.resumeSession && !this.sessionCapabilities.loadSession) {
|
|
412
|
+
return this.startFreshSession(channelId);
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
return await this.restoreSession(sessionId);
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
if (!isStaleRestoreFailure(error)) {
|
|
419
|
+
throw normalizeError(error);
|
|
420
|
+
}
|
|
421
|
+
await this.clearPersistedSessionBestEffort(channelId);
|
|
422
|
+
return this.startFreshSession(channelId);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
async restoreSession(sessionId) {
|
|
426
|
+
if (!this.connection) {
|
|
427
|
+
throw new Error('Copilot ACP connection is not available');
|
|
428
|
+
}
|
|
429
|
+
const agent = this.connection.agent;
|
|
430
|
+
if (typeof agent.attachSession !== 'function') {
|
|
431
|
+
throw new Error('Copilot ACP SDK does not expose session attachment helpers');
|
|
432
|
+
}
|
|
433
|
+
const session = agent.attachSession({ sessionId });
|
|
434
|
+
try {
|
|
435
|
+
if (this.sessionCapabilities.resumeSession) {
|
|
436
|
+
await this.connection.agent.request(this.runtime.methods.agent.session.resume, {
|
|
437
|
+
sessionId,
|
|
438
|
+
cwd: this.runtime.cwd,
|
|
439
|
+
mcpServers: [],
|
|
440
|
+
});
|
|
441
|
+
return session;
|
|
442
|
+
}
|
|
443
|
+
if (this.sessionCapabilities.loadSession) {
|
|
444
|
+
await this.connection.agent.request(this.runtime.methods.agent.session.load, {
|
|
445
|
+
sessionId,
|
|
446
|
+
cwd: this.runtime.cwd,
|
|
447
|
+
mcpServers: [],
|
|
448
|
+
});
|
|
449
|
+
this.clearBufferedSessionReplay(session);
|
|
450
|
+
return session;
|
|
451
|
+
}
|
|
452
|
+
throw new Error('Copilot ACP agent does not advertise session restore capabilities');
|
|
453
|
+
}
|
|
454
|
+
catch (error) {
|
|
455
|
+
session.dispose();
|
|
456
|
+
throw normalizeError(error);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
clearBufferedSessionReplay(session) {
|
|
460
|
+
const updates = session.updates;
|
|
461
|
+
if (updates && Array.isArray(updates.values)) {
|
|
462
|
+
updates.values = [];
|
|
463
|
+
}
|
|
464
|
+
}
|
|
366
465
|
async runTurn(session, prompt, options) {
|
|
367
466
|
const promptPromise = this.raceWithFatal(session.prompt(prompt));
|
|
368
467
|
const promptFailure = new Promise((_, reject) => {
|
|
@@ -438,6 +537,12 @@ export class CopilotCliClient {
|
|
|
438
537
|
state.session = undefined;
|
|
439
538
|
}
|
|
440
539
|
this.closeSession(session);
|
|
540
|
+
for (const [channelId, candidate] of this.channels.entries()) {
|
|
541
|
+
if (candidate === state) {
|
|
542
|
+
void this.clearPersistedSessionBestEffort(channelId);
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
441
546
|
}
|
|
442
547
|
closeSession(session) {
|
|
443
548
|
if (this.closingSessions.has(session)) {
|
|
@@ -507,6 +612,78 @@ export class CopilotCliClient {
|
|
|
507
612
|
this.evictIdleChannel(channelId, state, generation);
|
|
508
613
|
}, this.runtime.idleSessionTtlMs);
|
|
509
614
|
}
|
|
615
|
+
async ensureSessionStoreLoaded() {
|
|
616
|
+
if (!this.sessionStore) {
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
const agentId = this.resolveSessionStoreAgentId()?.trim();
|
|
620
|
+
if (!agentId) {
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (this.loadedSessionStoreAgentId === agentId) {
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
if (!this.sessionStoreLoadPromise) {
|
|
627
|
+
this.sessionStoreLoadPromise = (async () => {
|
|
628
|
+
const loaded = await this.sessionStore.load(agentId);
|
|
629
|
+
this.persistedSessions.clear();
|
|
630
|
+
for (const [channelId, sessionId] of Object.entries(loaded)) {
|
|
631
|
+
this.persistedSessions.set(channelId, sessionId);
|
|
632
|
+
}
|
|
633
|
+
this.loadedSessionStoreAgentId = agentId;
|
|
634
|
+
})().finally(() => {
|
|
635
|
+
this.sessionStoreLoadPromise = null;
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
await this.sessionStoreLoadPromise;
|
|
639
|
+
}
|
|
640
|
+
async persistSession(channelId, sessionId) {
|
|
641
|
+
await this.ensureSessionStoreLoaded();
|
|
642
|
+
this.persistedSessions.set(channelId, sessionId);
|
|
643
|
+
await this.flushSessionStore();
|
|
644
|
+
}
|
|
645
|
+
async persistSessionBestEffort(channelId, sessionId) {
|
|
646
|
+
try {
|
|
647
|
+
await this.persistSession(channelId, sessionId);
|
|
648
|
+
}
|
|
649
|
+
catch (error) {
|
|
650
|
+
console.error('[agents-host] failed to persist Copilot session map; keeping reply delivery', {
|
|
651
|
+
agentId: this.loadedSessionStoreAgentId,
|
|
652
|
+
channelId,
|
|
653
|
+
sessionId,
|
|
654
|
+
error,
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
async clearPersistedSession(channelId) {
|
|
659
|
+
await this.ensureSessionStoreLoaded();
|
|
660
|
+
if (!this.persistedSessions.delete(channelId)) {
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
await this.flushSessionStore();
|
|
664
|
+
}
|
|
665
|
+
async clearPersistedSessionBestEffort(channelId) {
|
|
666
|
+
try {
|
|
667
|
+
await this.clearPersistedSession(channelId);
|
|
668
|
+
}
|
|
669
|
+
catch (error) {
|
|
670
|
+
console.error('[agents-host] failed to clear Copilot session map; retrying in-memory only', {
|
|
671
|
+
agentId: this.loadedSessionStoreAgentId,
|
|
672
|
+
channelId,
|
|
673
|
+
error,
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
async flushSessionStore() {
|
|
678
|
+
if (!this.sessionStore || !this.loadedSessionStoreAgentId) {
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
const agentId = this.loadedSessionStoreAgentId;
|
|
682
|
+
const snapshot = Object.fromEntries(this.persistedSessions.entries());
|
|
683
|
+
const write = this.sessionStoreWriteQueue.then(() => this.sessionStore.save(agentId, snapshot));
|
|
684
|
+
this.sessionStoreWriteQueue = write.catch(() => { });
|
|
685
|
+
await write;
|
|
686
|
+
}
|
|
510
687
|
evictIdleChannel(channelId, state, generation) {
|
|
511
688
|
if (this.disposing || this.fatalError || this.backendClosed) {
|
|
512
689
|
return;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface CopilotChannelSessionStore {
|
|
2
|
+
load(agentId: string): Promise<Record<string, string>>;
|
|
3
|
+
save(agentId: string, sessions: Record<string, string>): Promise<void>;
|
|
4
|
+
}
|
|
5
|
+
export interface FileCopilotChannelSessionStoreOptions {
|
|
6
|
+
resolvePath(agentId: string): string;
|
|
7
|
+
fileSystem?: CopilotSessionStoreFileSystem;
|
|
8
|
+
platform?: NodeJS.Platform;
|
|
9
|
+
}
|
|
10
|
+
interface CopilotSessionStoreDirectoryHandle {
|
|
11
|
+
sync(): Promise<void>;
|
|
12
|
+
close(): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
interface CopilotSessionStoreFileHandle extends CopilotSessionStoreDirectoryHandle {
|
|
15
|
+
writeFile(data: string, options: {
|
|
16
|
+
encoding: BufferEncoding;
|
|
17
|
+
}): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
interface CopilotSessionStoreFileSystem {
|
|
20
|
+
mkdir(path: string, options: {
|
|
21
|
+
recursive: true;
|
|
22
|
+
mode: number;
|
|
23
|
+
}): Promise<void>;
|
|
24
|
+
openDirectory(path: string): Promise<CopilotSessionStoreDirectoryHandle>;
|
|
25
|
+
openFile(path: string, flags: string, mode: number): Promise<CopilotSessionStoreFileHandle>;
|
|
26
|
+
readFile(path: string, encoding: BufferEncoding): Promise<string>;
|
|
27
|
+
rename(from: string, to: string): Promise<void>;
|
|
28
|
+
unlink(path: string): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
export declare class FileCopilotChannelSessionStore implements CopilotChannelSessionStore {
|
|
31
|
+
private readonly options;
|
|
32
|
+
constructor(options: FileCopilotChannelSessionStoreOptions);
|
|
33
|
+
private loadFromPath;
|
|
34
|
+
load(agentId: string): Promise<Record<string, string>>;
|
|
35
|
+
save(agentId: string, sessions: Record<string, string>): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
const nodeFileSystem = {
|
|
5
|
+
async mkdir(path, options) {
|
|
6
|
+
await mkdir(path, options);
|
|
7
|
+
},
|
|
8
|
+
async openDirectory(path) {
|
|
9
|
+
return open(path, 'r');
|
|
10
|
+
},
|
|
11
|
+
async openFile(path, flags, mode) {
|
|
12
|
+
return open(path, flags, mode);
|
|
13
|
+
},
|
|
14
|
+
async readFile(path, encoding) {
|
|
15
|
+
return readFile(path, encoding);
|
|
16
|
+
},
|
|
17
|
+
async rename(from, to) {
|
|
18
|
+
await rename(from, to);
|
|
19
|
+
},
|
|
20
|
+
async unlink(path) {
|
|
21
|
+
await unlink(path);
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
function shouldIgnoreDirectorySyncError(platform, error) {
|
|
25
|
+
const code = error.code;
|
|
26
|
+
return platform === 'win32' && (code === 'EPERM' || code === 'EINVAL');
|
|
27
|
+
}
|
|
28
|
+
async function syncDirectory(path, fileSystem, platform) {
|
|
29
|
+
let directoryHandle;
|
|
30
|
+
try {
|
|
31
|
+
try {
|
|
32
|
+
directoryHandle = await fileSystem.openDirectory(path);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (!shouldIgnoreDirectorySyncError(platform, error)) {
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
await directoryHandle.sync();
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (!shouldIgnoreDirectorySyncError(platform, error)) {
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (directoryHandle) {
|
|
51
|
+
try {
|
|
52
|
+
await directoryHandle.close();
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Preserve the original directory durability failure.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
if (directoryHandle) {
|
|
61
|
+
await directoryHandle.close();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function normalizePersistedSessions(raw, filePath) {
|
|
65
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
66
|
+
throw new Error(`invalid Copilot session file: ${filePath}`);
|
|
67
|
+
}
|
|
68
|
+
const entries = Object.entries(raw);
|
|
69
|
+
const sessions = {};
|
|
70
|
+
for (const [channelId, sessionId] of entries) {
|
|
71
|
+
if (typeof sessionId !== 'string' || sessionId.trim().length === 0) {
|
|
72
|
+
throw new Error(`invalid Copilot session file: ${filePath}`);
|
|
73
|
+
}
|
|
74
|
+
sessions[channelId] = sessionId;
|
|
75
|
+
}
|
|
76
|
+
return sessions;
|
|
77
|
+
}
|
|
78
|
+
export class FileCopilotChannelSessionStore {
|
|
79
|
+
options;
|
|
80
|
+
constructor(options) {
|
|
81
|
+
this.options = options;
|
|
82
|
+
}
|
|
83
|
+
async loadFromPath(filePath) {
|
|
84
|
+
const raw = await (this.options.fileSystem ?? nodeFileSystem).readFile(filePath, 'utf8');
|
|
85
|
+
return normalizePersistedSessions(JSON.parse(raw), filePath);
|
|
86
|
+
}
|
|
87
|
+
async load(agentId) {
|
|
88
|
+
const filePath = this.options.resolvePath(agentId);
|
|
89
|
+
try {
|
|
90
|
+
return await this.loadFromPath(filePath);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (error.code !== 'ENOENT') {
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
return {};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async save(agentId, sessions) {
|
|
100
|
+
const filePath = this.options.resolvePath(agentId);
|
|
101
|
+
const parentPath = dirname(filePath);
|
|
102
|
+
const fileSystem = this.options.fileSystem ?? nodeFileSystem;
|
|
103
|
+
const platform = this.options.platform ?? process.platform;
|
|
104
|
+
await fileSystem.mkdir(parentPath, { recursive: true, mode: 0o700 });
|
|
105
|
+
const entries = Object.entries(sessions).sort(([left], [right]) => left.localeCompare(right));
|
|
106
|
+
if (entries.length === 0) {
|
|
107
|
+
try {
|
|
108
|
+
await fileSystem.unlink(filePath);
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
if (error.code !== 'ENOENT') {
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
await syncDirectory(parentPath, fileSystem, platform);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
120
|
+
let temporaryHandle;
|
|
121
|
+
try {
|
|
122
|
+
temporaryHandle = await fileSystem.openFile(temporaryPath, 'wx', 0o600);
|
|
123
|
+
await temporaryHandle.writeFile(JSON.stringify(Object.fromEntries(entries)), {
|
|
124
|
+
encoding: 'utf8',
|
|
125
|
+
});
|
|
126
|
+
await temporaryHandle.sync();
|
|
127
|
+
await temporaryHandle.close();
|
|
128
|
+
temporaryHandle = undefined;
|
|
129
|
+
await fileSystem.rename(temporaryPath, filePath);
|
|
130
|
+
await syncDirectory(parentPath, fileSystem, platform);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (temporaryHandle) {
|
|
134
|
+
try {
|
|
135
|
+
await temporaryHandle.close();
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// Preserve the original durability failure.
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
await fileSystem.unlink(temporaryPath);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// Cleanup is best effort.
|
|
146
|
+
}
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
import { ClaudeCliClient } from './claude/cli-client.js';
|
|
2
2
|
import { ClaudeProviderAdapter } from './claude/adapter.js';
|
|
3
|
+
import { FileClaudeChannelSessionStore } from './claude/session-store.js';
|
|
3
4
|
import { CopilotCliClient } from './copilot/cli-client.js';
|
|
4
5
|
import { CopilotProviderAdapter } from './copilot/adapter.js';
|
|
6
|
+
import { FileCopilotChannelSessionStore } from './copilot/session-store.js';
|
|
7
|
+
import { resolveClaudeSessionMapPath, resolveCopilotSessionMapPath } from '../state-paths.js';
|
|
5
8
|
export function createProvider(config) {
|
|
6
9
|
switch (config.provider) {
|
|
7
10
|
case 'claude': {
|
|
8
|
-
const cli = new ClaudeCliClient(config.claudeCommand, config.claudeArgs
|
|
11
|
+
const cli = new ClaudeCliClient(config.claudeCommand, config.claudeArgs, {}, new FileClaudeChannelSessionStore({
|
|
12
|
+
resolvePath: (agentId) => resolveClaudeSessionMapPath(config.stateRootDir, agentId),
|
|
13
|
+
}), config.resolveStableAgentId);
|
|
9
14
|
return new ClaudeProviderAdapter(cli);
|
|
10
15
|
}
|
|
11
16
|
case 'copilot': {
|
|
12
17
|
const cli = new CopilotCliClient(config.copilotCommand, config.copilotArgs, {
|
|
13
18
|
idleSessionTtlMs: config.copilotSessionTtlMinutes * 60 * 1000,
|
|
14
|
-
}
|
|
19
|
+
}, new FileCopilotChannelSessionStore({
|
|
20
|
+
resolvePath: (agentId) => resolveCopilotSessionMapPath(config.stateRootDir, agentId),
|
|
21
|
+
}), config.resolveStableAgentId);
|
|
15
22
|
return new CopilotProviderAdapter(cli);
|
|
16
23
|
}
|
|
17
24
|
default:
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function resolveSingleAgentStateRoot(env?: NodeJS.ProcessEnv, resolvedHomeDir?: string): string;
|
|
2
|
+
export declare function resolveManagedStateRoot(rootPath: string): string;
|
|
3
|
+
export declare function resolveLocalConfigAgentStateRoot(rootPath: string, agentKey: string): string;
|
|
4
|
+
export declare function resolveAgentCursorPath(stateRootDir: string, agentId: string): string;
|
|
5
|
+
export declare function resolveClaudeSessionMapPath(stateRootDir: string, agentId: string): string;
|
|
6
|
+
export declare function resolveCopilotSessionMapPath(stateRootDir: string, agentId: string): string;
|