@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.
@@ -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
- constructor(command: string, _ignoredArgs?: string[], runtimeOverrides?: Partial<CopilotAcpRuntime>);
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
- constructor(command, _ignoredArgs = [], runtimeOverrides = {}) {
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
- const sessionPromise = this.connection.agent.buildSession(this.runtime.cwd).start();
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;