@borgee/agents-host 0.1.8 → 0.2.1

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.
@@ -1,5 +1,7 @@
1
1
  import spawn from 'cross-spawn';
2
2
  import { PROTOCOL_VERSION, client, methods, ndJsonStream } from '@agentclientprotocol/sdk';
3
+ import type { ProviderGenerateOptions } from '../../types.js';
4
+ import type { CopilotChannelSessionStore } from './session-store.js';
3
5
  interface CopilotAcpRuntime {
4
6
  spawn: typeof spawn;
5
7
  client: typeof client;
@@ -21,8 +23,11 @@ interface CopilotAcpRuntime {
21
23
  */
22
24
  export declare class CopilotCliClient {
23
25
  private readonly command;
26
+ private readonly sessionStore?;
27
+ private readonly resolveSessionStoreAgentId;
24
28
  private readonly runtime;
25
29
  private readonly channels;
30
+ private readonly persistedSessions;
26
31
  private readonly closingSessions;
27
32
  private readonly pendingSessionStarts;
28
33
  private readonly pendingSessionCloses;
@@ -37,14 +42,22 @@ export declare class CopilotCliClient {
37
42
  private fatalError;
38
43
  private disposing;
39
44
  private backendClosed;
40
- constructor(command: string, _ignoredArgs?: string[], runtimeOverrides?: Partial<CopilotAcpRuntime>);
41
- generateReply(channelId: string, prompt: string): Promise<string>;
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);
50
+ generateReply(channelId: string, prompt: string, options?: ProviderGenerateOptions): Promise<string>;
42
51
  dispose(): Promise<void>;
43
52
  private ensureStarted;
44
53
  private startBackend;
45
54
  private getOrCreateChannelState;
46
55
  private processChannelQueue;
47
56
  private getOrCreateSession;
57
+ private startFreshSession;
58
+ private restoreOrCreateSession;
59
+ private restoreSession;
60
+ private clearBufferedSessionReplay;
48
61
  private runTurn;
49
62
  private raceWithFatal;
50
63
  private failAll;
@@ -53,6 +66,12 @@ export declare class CopilotCliClient {
53
66
  private rejectQueuedTurnsAfterSessionTaint;
54
67
  private clearIdleTimer;
55
68
  private reconcileIdleChannelState;
69
+ private ensureSessionStoreLoaded;
70
+ private persistSession;
71
+ private persistSessionBestEffort;
72
+ private clearPersistedSession;
73
+ private clearPersistedSessionBestEffort;
74
+ private flushSessionStore;
56
75
  private evictIdleChannel;
57
76
  private shutdownBackend;
58
77
  private waitForPendingSessionStarts;
@@ -17,7 +17,92 @@ const DEFAULT_RUNTIME = {
17
17
  shutdownGracePeriodMs: DEFAULT_SHUTDOWN_GRACE_PERIOD_MS,
18
18
  shutdownForceKillWaitMs: DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS,
19
19
  };
20
- function createDeferredTurn(prompt) {
20
+ const DEFAULT_SESSION_CAPABILITIES = {
21
+ loadSession: false,
22
+ resumeSession: false,
23
+ };
24
+ function hasVisibleText(value) {
25
+ return typeof value === 'string' && value.trim().length > 0;
26
+ }
27
+ function formatToolProgress(title, status) {
28
+ const normalizedTitle = hasVisibleText(title) ? title.trim() : null;
29
+ switch (status) {
30
+ case 'completed':
31
+ return normalizedTitle ? `Completed ${normalizedTitle}` : 'Completed tool call';
32
+ case 'failed':
33
+ return normalizedTitle ? `Failed ${normalizedTitle}` : 'Tool call failed';
34
+ case 'pending':
35
+ case 'in_progress':
36
+ case undefined:
37
+ case null:
38
+ return normalizedTitle ? `Running ${normalizedTitle}…` : 'Running tool…';
39
+ default:
40
+ return normalizedTitle ? `${status} ${normalizedTitle}` : status;
41
+ }
42
+ }
43
+ function formatPlanProgress(entries) {
44
+ const current = entries.find((entry) => entry.status === 'in_progress') ??
45
+ entries.find((entry) => entry.status === 'pending') ??
46
+ entries[0];
47
+ return hasVisibleText(current?.content) ? `Plan: ${current.content.trim()}` : null;
48
+ }
49
+ class CopilotProgressCollector {
50
+ onProgress;
51
+ publicText = '';
52
+ lastPublished = null;
53
+ toolTitles = new Map();
54
+ constructor(onProgress) {
55
+ this.onProgress = onProgress;
56
+ }
57
+ consume(update) {
58
+ switch (update.update.sessionUpdate) {
59
+ case 'agent_message_chunk':
60
+ if (update.update.content.type !== 'text') {
61
+ return;
62
+ }
63
+ this.publicText += update.update.content.text;
64
+ this.publish(this.publicText);
65
+ return;
66
+ case 'tool_call':
67
+ this.toolTitles.set(update.update.toolCallId, update.update.title);
68
+ this.publishFallback(formatToolProgress(update.update.title, update.update.status));
69
+ return;
70
+ case 'tool_call_update': {
71
+ const nextTitle = update.update.title ?? this.toolTitles.get(update.update.toolCallId);
72
+ if (hasVisibleText(nextTitle)) {
73
+ this.toolTitles.set(update.update.toolCallId, nextTitle);
74
+ }
75
+ this.publishFallback(formatToolProgress(nextTitle, update.update.status));
76
+ return;
77
+ }
78
+ case 'plan':
79
+ this.publishFallback(formatPlanProgress(update.update.entries.map((entry) => ({
80
+ content: entry.content,
81
+ status: entry.status,
82
+ }))));
83
+ return;
84
+ default:
85
+ return;
86
+ }
87
+ }
88
+ getFinalText() {
89
+ return this.publicText.trim();
90
+ }
91
+ publishFallback(text) {
92
+ if (hasVisibleText(this.publicText)) {
93
+ return;
94
+ }
95
+ this.publish(text);
96
+ }
97
+ publish(text) {
98
+ if (!this.onProgress || !hasVisibleText(text) || text === this.lastPublished) {
99
+ return;
100
+ }
101
+ this.lastPublished = text;
102
+ this.onProgress({ text });
103
+ }
104
+ }
105
+ function createDeferredTurn(prompt, options) {
21
106
  let settled = false;
22
107
  let resolvePromise;
23
108
  let rejectPromise;
@@ -27,6 +112,7 @@ function createDeferredTurn(prompt) {
27
112
  });
28
113
  return {
29
114
  prompt,
115
+ options,
30
116
  promise,
31
117
  resolve(value) {
32
118
  if (settled)
@@ -45,6 +131,24 @@ function createDeferredTurn(prompt) {
45
131
  function normalizeError(error) {
46
132
  return error instanceof Error ? error : new Error(String(error));
47
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
+ }
48
152
  function markSessionTainted(error) {
49
153
  const normalized = normalizeError(error);
50
154
  SESSION_TAINTED_ERRORS.add(normalized);
@@ -71,8 +175,11 @@ function selectPermissionOption(options) {
71
175
  */
72
176
  export class CopilotCliClient {
73
177
  command;
178
+ sessionStore;
179
+ resolveSessionStoreAgentId;
74
180
  runtime;
75
181
  channels = new Map();
182
+ persistedSessions = new Map();
76
183
  closingSessions = new WeakSet();
77
184
  pendingSessionStarts = new Set();
78
185
  pendingSessionCloses = new Set();
@@ -87,21 +194,27 @@ export class CopilotCliClient {
87
194
  fatalError = null;
88
195
  disposing = false;
89
196
  backendClosed = false;
90
- 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) {
91
202
  this.command = command;
203
+ this.sessionStore = sessionStore;
204
+ this.resolveSessionStoreAgentId = resolveSessionStoreAgentId;
92
205
  this.runtime = { ...DEFAULT_RUNTIME, ...runtimeOverrides };
93
206
  this.fatalPromise = new Promise((_, reject) => {
94
207
  this.rejectFatalPromise = reject;
95
208
  });
96
209
  void this.fatalPromise.catch(() => { });
97
210
  }
98
- async generateReply(channelId, prompt) {
211
+ async generateReply(channelId, prompt, options) {
99
212
  if (this.fatalError) {
100
213
  throw this.fatalError;
101
214
  }
102
215
  const state = this.getOrCreateChannelState(channelId);
103
216
  this.clearIdleTimer(state);
104
- const turn = createDeferredTurn(prompt);
217
+ const turn = createDeferredTurn(prompt, options);
105
218
  state.queue.push(turn);
106
219
  this.processChannelQueue(channelId, state);
107
220
  return turn.promise;
@@ -166,7 +279,7 @@ export class CopilotCliClient {
166
279
  }
167
280
  });
168
281
  try {
169
- await connection.agent.request(this.runtime.methods.agent.initialize, {
282
+ const initializeResponse = await connection.agent.request(this.runtime.methods.agent.initialize, {
170
283
  protocolVersion: this.runtime.protocolVersion,
171
284
  clientCapabilities: {},
172
285
  clientInfo: {
@@ -174,6 +287,7 @@ export class CopilotCliClient {
174
287
  version: '0.1.6',
175
288
  },
176
289
  });
290
+ this.sessionCapabilities = readSessionCapabilities(initializeResponse);
177
291
  }
178
292
  catch (error) {
179
293
  const normalized = new Error(`Copilot ACP initialize failed: ${normalizeError(error).message}`);
@@ -211,7 +325,7 @@ export class CopilotCliClient {
211
325
  const session = await this.getOrCreateSession(channelId, state);
212
326
  let reply;
213
327
  try {
214
- reply = await this.runTurn(session, turn.prompt);
328
+ reply = await this.runTurn(session, turn.prompt, turn.options);
215
329
  }
216
330
  catch (error) {
217
331
  if (isSessionTainted(error)) {
@@ -246,7 +360,11 @@ export class CopilotCliClient {
246
360
  if (!this.connection) {
247
361
  throw new Error('Copilot ACP connection is not available');
248
362
  }
249
- 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);
250
368
  this.pendingSessionStarts.add(sessionPromise);
251
369
  state.sessionPromise = sessionPromise;
252
370
  let sessionAdopted = false;
@@ -281,12 +399,75 @@ export class CopilotCliClient {
281
399
  }
282
400
  }
283
401
  }
284
- async runTurn(session, prompt) {
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
+ }
465
+ async runTurn(session, prompt, options) {
285
466
  const promptPromise = this.raceWithFatal(session.prompt(prompt));
286
467
  const promptFailure = new Promise((_, reject) => {
287
468
  void promptPromise.catch((error) => reject(markSessionTainted(error)));
288
469
  });
289
- let text = '';
470
+ const collector = new CopilotProgressCollector(options?.onProgress);
290
471
  for (;;) {
291
472
  let update;
292
473
  try {
@@ -309,16 +490,13 @@ export class CopilotCliClient {
309
490
  if (response.stopReason !== 'end_turn') {
310
491
  throw new Error(`Copilot ACP turn stopped with stopReason "${response.stopReason}"`);
311
492
  }
312
- const output = text.trim();
493
+ const output = collector.getFinalText();
313
494
  if (!output) {
314
495
  throw new Error('Copilot ACP returned empty output');
315
496
  }
316
497
  return output;
317
498
  }
318
- if (update.update.sessionUpdate === 'agent_message_chunk' &&
319
- update.update.content.type === 'text') {
320
- text += update.update.content.text;
321
- }
499
+ collector.consume(update);
322
500
  }
323
501
  }
324
502
  async raceWithFatal(promise) {
@@ -359,6 +537,12 @@ export class CopilotCliClient {
359
537
  state.session = undefined;
360
538
  }
361
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
+ }
362
546
  }
363
547
  closeSession(session) {
364
548
  if (this.closingSessions.has(session)) {
@@ -428,6 +612,78 @@ export class CopilotCliClient {
428
612
  this.evictIdleChannel(channelId, state, generation);
429
613
  }, this.runtime.idleSessionTtlMs);
430
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
+ }
431
687
  evictIdleChannel(channelId, state, generation) {
432
688
  if (this.disposing || this.fatalError || this.backendClosed) {
433
689
  return;
@@ -0,0 +1,14 @@
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
+ }
8
+ export declare class FileCopilotChannelSessionStore implements CopilotChannelSessionStore {
9
+ private readonly options;
10
+ constructor(options: FileCopilotChannelSessionStoreOptions);
11
+ private loadFromPath;
12
+ load(agentId: string): Promise<Record<string, string>>;
13
+ save(agentId: string, sessions: Record<string, string>): Promise<void>;
14
+ }
@@ -0,0 +1,97 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises';
3
+ import { dirname } from 'node:path';
4
+ async function syncDirectory(path) {
5
+ const directoryHandle = await open(path, 'r');
6
+ try {
7
+ await directoryHandle.sync();
8
+ }
9
+ finally {
10
+ await directoryHandle.close();
11
+ }
12
+ }
13
+ function normalizePersistedSessions(raw, filePath) {
14
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
15
+ throw new Error(`invalid Copilot session file: ${filePath}`);
16
+ }
17
+ const entries = Object.entries(raw);
18
+ const sessions = {};
19
+ for (const [channelId, sessionId] of entries) {
20
+ if (typeof sessionId !== 'string' || sessionId.trim().length === 0) {
21
+ throw new Error(`invalid Copilot session file: ${filePath}`);
22
+ }
23
+ sessions[channelId] = sessionId;
24
+ }
25
+ return sessions;
26
+ }
27
+ export class FileCopilotChannelSessionStore {
28
+ options;
29
+ constructor(options) {
30
+ this.options = options;
31
+ }
32
+ async loadFromPath(filePath) {
33
+ const raw = await readFile(filePath, 'utf8');
34
+ return normalizePersistedSessions(JSON.parse(raw), filePath);
35
+ }
36
+ async load(agentId) {
37
+ const filePath = this.options.resolvePath(agentId);
38
+ try {
39
+ return await this.loadFromPath(filePath);
40
+ }
41
+ catch (error) {
42
+ if (error.code !== 'ENOENT') {
43
+ throw error;
44
+ }
45
+ return {};
46
+ }
47
+ }
48
+ async save(agentId, sessions) {
49
+ const filePath = this.options.resolvePath(agentId);
50
+ const parentPath = dirname(filePath);
51
+ await mkdir(parentPath, { recursive: true, mode: 0o700 });
52
+ const entries = Object.entries(sessions).sort(([left], [right]) => left.localeCompare(right));
53
+ if (entries.length === 0) {
54
+ try {
55
+ await unlink(filePath);
56
+ }
57
+ catch (error) {
58
+ if (error.code !== 'ENOENT') {
59
+ throw error;
60
+ }
61
+ return;
62
+ }
63
+ await syncDirectory(parentPath);
64
+ return;
65
+ }
66
+ const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
67
+ let temporaryHandle;
68
+ try {
69
+ temporaryHandle = await open(temporaryPath, 'wx', 0o600);
70
+ await temporaryHandle.writeFile(JSON.stringify(Object.fromEntries(entries)), {
71
+ encoding: 'utf8',
72
+ });
73
+ await temporaryHandle.sync();
74
+ await temporaryHandle.close();
75
+ temporaryHandle = undefined;
76
+ await rename(temporaryPath, filePath);
77
+ await syncDirectory(parentPath);
78
+ }
79
+ catch (error) {
80
+ if (temporaryHandle) {
81
+ try {
82
+ await temporaryHandle.close();
83
+ }
84
+ catch {
85
+ // Preserve the original durability failure.
86
+ }
87
+ }
88
+ try {
89
+ await unlink(temporaryPath);
90
+ }
91
+ catch {
92
+ // Cleanup is best effort.
93
+ }
94
+ throw error;
95
+ }
96
+ }
97
+ }
@@ -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:
@@ -1,5 +1,5 @@
1
- import type { ProviderInput, ProviderReply } from '../types.js';
1
+ import type { ProviderGenerateOptions, ProviderInput, ProviderReply } from '../types.js';
2
2
  export interface ProviderAdapter {
3
- generateReply(input: ProviderInput): Promise<ProviderReply>;
3
+ generateReply(input: ProviderInput, options?: ProviderGenerateOptions): Promise<ProviderReply>;
4
4
  dispose?(): Promise<void>;
5
5
  }
@@ -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;
@@ -0,0 +1,46 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { homedir } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+ const SINGLE_AGENT_HOME_ROOT = '.borgee';
5
+ const AGENTS_HOST_ROOT = 'agents-host';
6
+ const SINGLE_AGENT_NAMESPACE = 'single-agent';
7
+ const MANAGED_STATE_DIRNAME = '.state';
8
+ const STATE_ROOT_LABEL_MAX_LENGTH = 48;
9
+ function encodeSegment(value) {
10
+ return encodeURIComponent(value);
11
+ }
12
+ function sanitizeStateRootLabel(agentKey) {
13
+ const sanitized = encodeSegment(agentKey)
14
+ .toLowerCase()
15
+ .replace(/%/g, '-')
16
+ .replace(/[^a-z0-9._-]+/g, '-')
17
+ .replace(/-+/g, '-')
18
+ .replace(/^-|-$/g, '')
19
+ .slice(0, STATE_ROOT_LABEL_MAX_LENGTH);
20
+ return sanitized.length > 0 ? sanitized : 'agent';
21
+ }
22
+ function hashStateRootKey(agentKey) {
23
+ return createHash('sha256').update(agentKey).digest('hex').slice(0, 12);
24
+ }
25
+ export function resolveSingleAgentStateRoot(env = process.env, resolvedHomeDir = homedir()) {
26
+ const home = env.HOME?.trim() || resolvedHomeDir.trim();
27
+ if (!home) {
28
+ throw new Error('Unable to resolve a user home directory for agents-host state');
29
+ }
30
+ return join(home, SINGLE_AGENT_HOME_ROOT, AGENTS_HOST_ROOT, SINGLE_AGENT_NAMESPACE);
31
+ }
32
+ export function resolveManagedStateRoot(rootPath) {
33
+ return join(resolve(rootPath), MANAGED_STATE_DIRNAME);
34
+ }
35
+ export function resolveLocalConfigAgentStateRoot(rootPath, agentKey) {
36
+ return join(resolveManagedStateRoot(rootPath), `${sanitizeStateRootLabel(agentKey)}-${hashStateRootKey(agentKey)}`);
37
+ }
38
+ export function resolveAgentCursorPath(stateRootDir, agentId) {
39
+ return join(stateRootDir, `bpp-cursor-${encodeSegment(agentId)}.json`);
40
+ }
41
+ export function resolveClaudeSessionMapPath(stateRootDir, agentId) {
42
+ return join(stateRootDir, `claude-channel-sessions-${encodeSegment(agentId)}.json`);
43
+ }
44
+ export function resolveCopilotSessionMapPath(stateRootDir, agentId) {
45
+ return join(stateRootDir, `copilot-channel-sessions-${encodeSegment(agentId)}.json`);
46
+ }
package/dist/types.d.ts CHANGED
@@ -8,6 +8,8 @@ export interface ProviderCommandConfig {
8
8
  }
9
9
  export interface ProviderRuntimeConfig extends ProviderCommandConfig {
10
10
  provider: ProviderKind;
11
+ stateRootDir: string;
12
+ resolveStableAgentId?: () => string | undefined;
11
13
  }
12
14
  export interface HostedAgentConfig {
13
15
  agentApiKey: string;
@@ -16,6 +18,7 @@ export interface HostedAgentConfig {
16
18
  }
17
19
  export interface AgentsHostConfig extends ProviderCommandConfig {
18
20
  borgeeBaseUrl: string;
21
+ stateRootDir: string;
19
22
  agent: HostedAgentConfig;
20
23
  }
21
24
  export interface LocalHostConfigFile {
@@ -106,3 +109,12 @@ export interface ProviderInput {
106
109
  export interface ProviderReply {
107
110
  text: string;
108
111
  }
112
+ export interface PostedMessage {
113
+ messageId: string;
114
+ }
115
+ export interface ProviderProgressUpdate {
116
+ text: string;
117
+ }
118
+ export interface ProviderGenerateOptions {
119
+ onProgress?: (update: ProviderProgressUpdate) => void;
120
+ }