@borgee/agents-host 0.2.0 → 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.
package/README.md CHANGED
@@ -315,13 +315,19 @@ Each Borgee channel is mapped 1:1 to a provider-native session while it stays
315
315
  active:
316
316
 
317
317
  - **Claude**: first turn for a channel uses `--session-id <uuid>`; every turn
318
- after uses `-r/--resume <uuid>`.
318
+ after uses `-r/--resume <uuid>`. The host persists the channel→session map in
319
+ its state root, serializes cross-channel map rewrites, and retries once with a
320
+ fresh session immediately if Claude reports a stale `--resume` target.
319
321
  - **Copilot**: one persistent `copilot --acp` subprocess is shared by a single
320
322
  `AgentsHost`, with one ACP session per Borgee channel. Same-channel turns are
321
- serialized; different channels keep isolated ACP sessions.
322
-
323
- There is no separate history store on our side. If a runner restarts, that
324
- agent's provider-side channel sessions start fresh.
323
+ serialized; different channels keep isolated ACP sessions. The host persists
324
+ the channel→ACP-session map in its state root and restores same-host
325
+ continuity with ACP `session/resume` when available, otherwise `session/load`
326
+ plus local replay cleanup on runtimes that advertise only load support.
327
+
328
+ There is no separate transcript/history store on our side. Claude and Copilot
329
+ only persist the native session ids they should try to resume later; cross-host
330
+ continuity still does not exist in this runtime.
325
331
 
326
332
  ## Testing
327
333
 
@@ -27,6 +27,7 @@ export declare class AgentsHost {
27
27
  private readonly progressChannelQueues;
28
28
  private readonly progressDrafts;
29
29
  private selfAgentId;
30
+ private selfAgentIdPromise;
30
31
  private started;
31
32
  private controlPlaneClosed;
32
33
  constructor(config: AgentsHostConfig, deps?: {
@@ -39,4 +40,5 @@ export declare class AgentsHost {
39
40
  private trackActiveTurn;
40
41
  private enqueueProgressChannelTurn;
41
42
  private ensureProgressDraft;
43
+ private ensureSelfAgentId;
42
44
  }
@@ -1,6 +1,13 @@
1
+ import { chmod, mkdir } from 'node:fs/promises';
1
2
  import { SdkChatControlPlane } from './chat/sdk-chat-control-plane.js';
3
+ import { createDurableCursorStore } from './durable-cursor-store.js';
2
4
  import { createProvider } from './providers/create-provider.js';
5
+ const PRIVATE_STATE_ROOT_MODE = 0o700;
3
6
  const STREAM_PROGRESS_EDIT_THROTTLE_MS = 150;
7
+ async function ensurePrivateStateRoot(path) {
8
+ await mkdir(path, { recursive: true, mode: PRIVATE_STATE_ROOT_MODE });
9
+ await chmod(path, PRIVATE_STATE_ROOT_MODE);
10
+ }
4
11
  function hasVisibleText(value) {
5
12
  return value.trim().length > 0;
6
13
  }
@@ -128,29 +135,37 @@ export class AgentsHost {
128
135
  progressChannelQueues = new Map();
129
136
  progressDrafts = new Map();
130
137
  selfAgentId = null;
138
+ selfAgentIdPromise = null;
131
139
  started = false;
132
140
  controlPlaneClosed = false;
133
141
  constructor(config, deps) {
134
142
  this.config = config;
135
143
  this.provider = deps?.provider ?? createProvider({
136
144
  provider: config.agent.provider,
145
+ stateRootDir: config.stateRootDir,
146
+ resolveStableAgentId: () => this.selfAgentId ?? undefined,
137
147
  claudeCommand: config.claudeCommand,
138
148
  claudeArgs: config.claudeArgs,
139
149
  copilotCommand: config.copilotCommand,
140
150
  copilotArgs: config.copilotArgs,
141
151
  copilotSessionTtlMinutes: config.copilotSessionTtlMinutes,
142
152
  });
143
- this.borgee = deps?.borgee ?? new SdkChatControlPlane(config.borgeeBaseUrl, config.agent.agentApiKey, undefined,
144
- // Lets the server know which runtime/provider actually connected
145
- // (internal/bpp ConnectHandler → users.last_connected_plugin_id), so
146
- // the web UI can show real state instead of a client-side guess.
147
- `agents-host:${config.agent.provider}`);
153
+ this.borgee = deps?.borgee ?? new SdkChatControlPlane(config.borgeeBaseUrl, config.agent.agentApiKey, undefined, {
154
+ cursorStore: createDurableCursorStore({
155
+ stateRootDir: config.stateRootDir,
156
+ }),
157
+ // Lets the server know which runtime/provider actually connected
158
+ // (internal/bpp ConnectHandler → users.last_connected_plugin_id), so
159
+ // the web UI can show real state instead of a client-side guess.
160
+ pluginId: `agents-host:${config.agent.provider}`,
161
+ });
148
162
  }
149
163
  async start() {
150
164
  if (this.started)
151
165
  return;
152
166
  this.started = true;
153
167
  this.controlPlaneClosed = false;
168
+ await ensurePrivateStateRoot(this.config.stateRootDir);
154
169
  await this.borgee.connect((message) => {
155
170
  if (!this.started) {
156
171
  return;
@@ -159,11 +174,11 @@ export class AgentsHost {
159
174
  ? this.enqueueProgressChannelTurn(message.channel_id, () => this.handleMessage(message))
160
175
  : this.handleMessage(message);
161
176
  this.trackActiveTurn(task);
177
+ return task;
162
178
  });
163
- const me = await this.borgee.getMe();
164
- this.selfAgentId = me.id;
179
+ const agentId = await this.ensureSelfAgentId();
165
180
  console.log('[agents-host] connected', {
166
- agentId: me.id,
181
+ agentId,
167
182
  agentName: this.config.agent.agentName,
168
183
  provider: this.config.agent.provider,
169
184
  });
@@ -187,8 +202,9 @@ export class AgentsHost {
187
202
  agentName: this.config.agent.agentName,
188
203
  channelId: msg.channel_id,
189
204
  });
205
+ const selfAgentId = await this.ensureSelfAgentId();
190
206
  const authorId = String(msg.user_id ?? msg.sender_id ?? 'unknown');
191
- if (this.selfAgentId && authorId === this.selfAgentId) {
207
+ if (authorId === selfAgentId) {
192
208
  return;
193
209
  }
194
210
  const content = String(msg.content ?? msg.body ?? '').trim();
@@ -276,4 +292,19 @@ export class AgentsHost {
276
292
  }
277
293
  return draft;
278
294
  }
295
+ async ensureSelfAgentId() {
296
+ if (this.selfAgentId) {
297
+ return this.selfAgentId;
298
+ }
299
+ if (!this.selfAgentIdPromise) {
300
+ this.selfAgentIdPromise = this.borgee.getMe().then((me) => {
301
+ this.selfAgentId = me.id;
302
+ return me.id;
303
+ }).catch((error) => {
304
+ this.selfAgentIdPromise = null;
305
+ throw error;
306
+ });
307
+ }
308
+ return this.selfAgentIdPromise;
309
+ }
279
310
  }
@@ -1,6 +1,6 @@
1
1
  import type { ChannelMessageEvent, MeResponseUser, PostedMessage } from '../types.js';
2
2
  export interface ChatControlPlane {
3
- connect(onMessage: (message: ChannelMessageEvent) => void): Promise<void>;
3
+ connect(onMessage: (message: ChannelMessageEvent) => void | Promise<void>): Promise<void>;
4
4
  close(): Promise<void>;
5
5
  postMessage(channelId: string, content: string): Promise<PostedMessage>;
6
6
  editMessage(messageId: string, content: string): Promise<void>;
@@ -3,6 +3,7 @@ import type { ChannelMessageEvent, MeResponseUser, PostedMessage } from '../type
3
3
  import type { ChatControlPlane } from './chat-control-plane.js';
4
4
  type PluginClientLike = Pick<BorgeePluginClient, 'agentId' | 'close' | 'connect' | 'deleteMessage' | 'editMessage' | 'getMe' | 'on' | 'sendMessage' | 'startTyping'>;
5
5
  type PluginClientFactory = (options: BorgeePluginOptions) => PluginClientLike;
6
+ type SdkChatControlPlaneOptions = Pick<BorgeePluginOptions, 'cursorStore' | 'pluginId'>;
6
7
  /**
7
8
  * Thin adapter over `@borgee/plugin-sdk` (the same BPP/`/ws/plugin` SDK used by
8
9
  * the OpenClaw plugin) that implements the minimal `ChatControlPlane` surface
@@ -10,12 +11,11 @@ type PluginClientFactory = (options: BorgeePluginOptions) => PluginClientLike;
10
11
  */
11
12
  export declare class SdkChatControlPlane implements ChatControlPlane {
12
13
  private readonly client;
13
- private pendingMessages;
14
14
  private unsubscribe;
15
15
  private connected;
16
16
  private me;
17
- constructor(baseUrl: string, apiKey: string, createClient?: PluginClientFactory, pluginId?: string);
18
- connect(onMessage: (message: ChannelMessageEvent) => void): Promise<void>;
17
+ constructor(baseUrl: string, apiKey: string, createClient?: PluginClientFactory, options?: SdkChatControlPlaneOptions);
18
+ connect(onMessage: (message: ChannelMessageEvent) => void | Promise<void>): Promise<void>;
19
19
  close(): Promise<void>;
20
20
  postMessage(channelId: string, content: string): Promise<PostedMessage>;
21
21
  editMessage(messageId: string, content: string): Promise<void>;
@@ -6,43 +6,35 @@ import { createBorgeePlugin, } from '@borgee/plugin-sdk';
6
6
  */
7
7
  export class SdkChatControlPlane {
8
8
  client;
9
- pendingMessages = [];
10
9
  unsubscribe = null;
11
10
  connected = false;
12
11
  me = null;
13
- constructor(baseUrl, apiKey, createClient = createBorgeePlugin, pluginId) {
14
- this.client = createClient({ baseUrl, apiKey, pluginId });
12
+ constructor(baseUrl, apiKey, createClient = createBorgeePlugin, options = {}) {
13
+ this.client = createClient({ baseUrl, apiKey, ...options });
15
14
  }
16
15
  async connect(onMessage) {
17
- this.unsubscribe = this.client.on('message', (event) => {
18
- const message = mapInboundToChannelMessage(event);
19
- if (!this.connected) {
20
- this.pendingMessages.push(message);
21
- return;
22
- }
23
- onMessage(message);
16
+ this.unsubscribe = this.client.on('message', async (event) => {
17
+ await onMessage(mapInboundToChannelMessage(event));
24
18
  });
25
19
  try {
26
- await this.client.connect();
20
+ // The SDK resolves connect after session.resume_ack, before every replay
21
+ // frame necessarily runs. Mark the consumer ready first so replay ACKs
22
+ // wait for the real handler instead of acknowledging an in-memory buffer.
27
23
  this.connected = true;
24
+ await this.client.connect();
28
25
  if (this.client.agentId) {
29
26
  this.me = { id: this.client.agentId };
30
27
  }
31
- for (const message of this.pendingMessages) {
32
- onMessage(message);
33
- }
34
- this.pendingMessages = [];
35
28
  }
36
29
  catch (error) {
30
+ this.connected = false;
37
31
  this.unsubscribe?.();
38
32
  this.unsubscribe = null;
39
- this.pendingMessages = [];
40
33
  throw error;
41
34
  }
42
35
  }
43
36
  async close() {
44
37
  this.connected = false;
45
- this.pendingMessages = [];
46
38
  this.unsubscribe?.();
47
39
  this.unsubscribe = null;
48
40
  await this.client.close();
package/dist/cli.js CHANGED
File without changes
package/dist/config.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { resolveSingleAgentStateRoot } from './state-paths.js';
1
2
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
2
3
  export const MAX_COPILOT_SESSION_TTL_MINUTES = MAX_TIMER_DELAY_MS / 60_000;
3
4
  export const DEFAULT_COPILOT_SESSION_TTL_MINUTES = 2 * 24 * 60;
@@ -74,6 +75,7 @@ export function loadConfigFromEnv(env = process.env) {
74
75
  const provider = resolveProvider(envOr('RUNTIME_PROVIDER', 'claude', env), 'Unsupported RUNTIME_PROVIDER');
75
76
  return {
76
77
  borgeeBaseUrl: requireEnv('BORGEE_BASE_URL', env),
78
+ stateRootDir: resolveSingleAgentStateRoot(env),
77
79
  ...resolveProviderCommandConfig({
78
80
  claudeCommand: envOr('CLAUDE_COMMAND', DEFAULT_PROVIDER_COMMAND_CONFIG.claudeCommand, env),
79
81
  claudeArgs: parseArgs(envOr('CLAUDE_ARGS', DEFAULT_PROVIDER_COMMAND_CONFIG.claudeArgs.join(' '), env)),
@@ -0,0 +1,5 @@
1
+ import { type CursorStore } from '@borgee/plugin-sdk';
2
+ export interface DurableCursorStoreOptions {
3
+ stateRootDir: string;
4
+ }
5
+ export declare function createDurableCursorStore(options: DurableCursorStoreOptions): CursorStore;
@@ -0,0 +1,7 @@
1
+ import { FileCursorStore } from '@borgee/plugin-sdk';
2
+ import { resolveAgentCursorPath } from './state-paths.js';
3
+ export function createDurableCursorStore(options) {
4
+ return new FileCursorStore({
5
+ resolvePath: (agentId) => resolveAgentCursorPath(options.stateRootDir, agentId),
6
+ });
7
+ }
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto';
3
3
  import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path';
4
4
  import { parseDocument, stringify } from 'yaml';
5
5
  import { optionalNonEmptyString, optionalStringArray, parseCopilotSessionTtlMinutesValue, requireNonEmptyString, resolveProvider, resolveProviderCommandConfig, } from './config.js';
6
+ import { resolveLocalConfigAgentStateRoot } from './state-paths.js';
6
7
  const SUPPORTED_CONFIG_EXTENSIONS = new Set(['.json', '.yaml', '.yml']);
7
8
  export const DEFAULT_LOCAL_HOST_CONFIG_FILENAME = 'agents-host.yaml';
8
9
  export const DEFAULT_LOCAL_AGENTS_DIRNAME = 'agents';
@@ -131,6 +132,9 @@ function parseHostConfigFile(hostConfigPath, value) {
131
132
  function toGeneratedAgentConfigFilename(key) {
132
133
  return `${encodeURIComponent(key)}.yaml`;
133
134
  }
135
+ function toCaseInsensitiveFilesystemIdentity(path) {
136
+ return path.toLowerCase();
137
+ }
134
138
  function parseGenerateHostConfigFile(sourceLabel, value) {
135
139
  if (value.agentsDir !== undefined) {
136
140
  throw new Error(`Invalid generate-config host spec in ${sourceLabel}: agentsDir is not supported; generate-config always writes the canonical default layout`);
@@ -573,13 +577,14 @@ function renderAgentConfigYaml(agent) {
573
577
  }
574
578
  return stringify(config);
575
579
  }
576
- function buildManagedAgentSnapshot(host, sourcePath, agent) {
580
+ function buildManagedAgentSnapshot(host, stateRootBaseDir, sourcePath, agent) {
577
581
  const providerConfig = resolveProviderCommandConfig({
578
582
  ...host.defaults,
579
583
  ...parseProviderCommandOverrides(agent, `agent config ${sourcePath}`),
580
584
  });
581
585
  const config = {
582
586
  borgeeBaseUrl: host.borgeeBaseUrl,
587
+ stateRootDir: resolveLocalConfigAgentStateRoot(stateRootBaseDir, agent.key),
583
588
  ...providerConfig,
584
589
  agent: {
585
590
  agentApiKey: agent.apiKey,
@@ -610,6 +615,7 @@ export async function loadLocalConfigSnapshot(hostConfigPath, deps = {}) {
610
615
  const resolvedHostConfigDir = dirname(resolvedHostConfigPath);
611
616
  const hostConfigRecord = await loadParsedDocument(fileSystem, resolvedHostConfigPath, 'host config');
612
617
  const hostConfig = parseHostConfigFile(absoluteHostConfigPath, hostConfigRecord);
618
+ const stateRootBaseDir = managedGeneration.managedRootPath ?? hostConfigDir;
613
619
  const agentsDir = toAgentsDir(absoluteHostConfigPath, hostConfig.agentsDir);
614
620
  const resolvedAgentsDir = resolve(fileSystem.realPath
615
621
  ? await fileSystem.realPath(toAgentsDir(resolvedHostConfigPath, hostConfig.agentsDir))
@@ -643,7 +649,7 @@ export async function loadLocalConfigSnapshot(hostConfigPath, deps = {}) {
643
649
  resolvedAgentsDir,
644
650
  agents: parsedAgents
645
651
  .filter((entry) => entry.config.enabled !== false)
646
- .map((entry) => buildManagedAgentSnapshot(hostConfig, entry.sourcePath, entry.config)),
652
+ .map((entry) => buildManagedAgentSnapshot(hostConfig, stateRootBaseDir, entry.sourcePath, entry.config)),
647
653
  };
648
654
  }
649
655
  finally {
@@ -1,5 +1,6 @@
1
1
  import spawn from 'cross-spawn';
2
2
  import type { ProviderGenerateOptions } from '../../types.js';
3
+ import type { ClaudeChannelSessionStore } from './session-store.js';
3
4
  interface ClaudeCliRuntime {
4
5
  spawn: typeof spawn;
5
6
  }
@@ -14,15 +15,30 @@ interface ClaudeCliRuntime {
14
15
  export declare class ClaudeCliClient {
15
16
  private readonly command;
16
17
  private readonly args;
18
+ private readonly sessionStore?;
19
+ private readonly resolveSessionStoreAgentId;
17
20
  private readonly runtime;
18
21
  private readonly channels;
22
+ private readonly persistedSessions;
23
+ private loadedSessionStoreAgentId;
19
24
  private stopped;
20
- constructor(command: string, args: string[], runtimeOverrides?: Partial<ClaudeCliRuntime>);
25
+ private sessionStoreLoadPromise;
26
+ private sessionStoreWriteQueue;
27
+ constructor(command: string, args: string[], runtimeOverrides?: Partial<ClaudeCliRuntime>, sessionStore?: ClaudeChannelSessionStore | undefined, resolveSessionStoreAgentId?: () => string | undefined);
21
28
  generateReply(channelId: string, prompt: string, options?: ProviderGenerateOptions): Promise<string>;
22
29
  dispose(): Promise<void>;
23
30
  private getOrCreateChannelState;
24
31
  private processChannelQueue;
25
32
  private runTurn;
33
+ private runTurnAttempt;
34
+ private currentSessionStoreAgentId;
35
+ private ensureSessionStoreLoaded;
36
+ private hydrateChannelState;
37
+ private persistSession;
38
+ private persistSessionBestEffort;
39
+ private resetPersistedSession;
40
+ private resetPersistedSessionBestEffort;
41
+ private enqueueSessionStoreWrite;
26
42
  private run;
27
43
  }
28
44
  export {};
@@ -4,6 +4,10 @@ const DEFAULT_RUNTIME = {
4
4
  spawn,
5
5
  };
6
6
  const STREAM_JSON_ARGS = ['--verbose', '--output-format', 'stream-json', '--include-partial-messages'];
7
+ function isStaleResumeFailure(error) {
8
+ const message = normalizeError(error).message.toLowerCase();
9
+ return message.includes('session not found') || message.includes('cannot resume');
10
+ }
7
11
  function normalizeError(error) {
8
12
  return error instanceof Error ? error : new Error(String(error));
9
13
  }
@@ -157,12 +161,20 @@ class ClaudeStreamCollector {
157
161
  export class ClaudeCliClient {
158
162
  command;
159
163
  args;
164
+ sessionStore;
165
+ resolveSessionStoreAgentId;
160
166
  runtime;
161
167
  channels = new Map();
168
+ persistedSessions = new Map();
169
+ loadedSessionStoreAgentId = null;
162
170
  stopped = false;
163
- constructor(command, args, runtimeOverrides = {}) {
171
+ sessionStoreLoadPromise = null;
172
+ sessionStoreWriteQueue = Promise.resolve();
173
+ constructor(command, args, runtimeOverrides = {}, sessionStore, resolveSessionStoreAgentId = () => undefined) {
164
174
  this.command = command;
165
175
  this.args = args;
176
+ this.sessionStore = sessionStore;
177
+ this.resolveSessionStoreAgentId = resolveSessionStoreAgentId;
166
178
  this.runtime = { ...DEFAULT_RUNTIME, ...runtimeOverrides };
167
179
  }
168
180
  async generateReply(channelId, prompt, options) {
@@ -195,6 +207,7 @@ export class ClaudeCliClient {
195
207
  state = {
196
208
  processing: false,
197
209
  queue: [],
210
+ sessionHydrated: false,
198
211
  sessionEstablished: false,
199
212
  };
200
213
  this.channels.set(channelId, state);
@@ -209,13 +222,26 @@ export class ClaudeCliClient {
209
222
  void (async () => {
210
223
  try {
211
224
  while (!this.stopped) {
225
+ const queuedTurn = state.queue[0];
226
+ if (!queuedTurn) {
227
+ return;
228
+ }
229
+ try {
230
+ if (await this.ensureSessionStoreLoaded()) {
231
+ this.hydrateChannelState(channelId, state);
232
+ }
233
+ }
234
+ catch (error) {
235
+ state.queue.shift()?.reject(error);
236
+ continue;
237
+ }
212
238
  const turn = state.queue.shift();
213
239
  if (!turn) {
214
240
  return;
215
241
  }
216
242
  state.activeTurn = turn;
217
243
  try {
218
- const text = await this.runTurn(state, turn);
244
+ const text = await this.runTurn(channelId, state, turn);
219
245
  turn.resolve(text);
220
246
  }
221
247
  catch (error) {
@@ -234,16 +260,138 @@ export class ClaudeCliClient {
234
260
  }
235
261
  })();
236
262
  }
237
- async runTurn(state, turn) {
263
+ async runTurn(channelId, state, turn) {
264
+ return this.runTurnAttempt(channelId, state, turn, true);
265
+ }
266
+ async runTurnAttempt(channelId, state, turn, allowFreshRetryAfterStaleResume) {
238
267
  if (this.stopped) {
239
268
  throw new Error('Claude CLI backend stopped');
240
269
  }
241
270
  const sessionId = state.sessionEstablished ? state.sessionId : randomUUID();
242
271
  const sessionArgs = state.sessionEstablished ? ['--resume', sessionId] : ['--session-id', sessionId];
243
- const text = await this.run(state, [...this.args, ...sessionArgs, ...STREAM_JSON_ARGS], turn.prompt, turn.options);
244
- state.sessionId = sessionId;
272
+ try {
273
+ const text = await this.run(state, [...this.args, ...sessionArgs, ...STREAM_JSON_ARGS], turn.prompt, turn.options);
274
+ state.sessionId = sessionId;
275
+ state.sessionEstablished = true;
276
+ await this.persistSessionBestEffort(channelId, sessionId);
277
+ return text;
278
+ }
279
+ catch (error) {
280
+ if (state.sessionEstablished && isStaleResumeFailure(error)) {
281
+ await this.resetPersistedSessionBestEffort(channelId, state);
282
+ if (allowFreshRetryAfterStaleResume) {
283
+ return this.runTurnAttempt(channelId, state, turn, false);
284
+ }
285
+ }
286
+ throw error;
287
+ }
288
+ }
289
+ currentSessionStoreAgentId() {
290
+ const agentId = this.resolveSessionStoreAgentId()?.trim();
291
+ return agentId && agentId.length > 0 ? agentId : null;
292
+ }
293
+ async ensureSessionStoreLoaded() {
294
+ if (!this.sessionStore) {
295
+ return true;
296
+ }
297
+ const agentId = this.currentSessionStoreAgentId();
298
+ if (!agentId) {
299
+ return false;
300
+ }
301
+ if (this.loadedSessionStoreAgentId && this.loadedSessionStoreAgentId !== agentId) {
302
+ this.loadedSessionStoreAgentId = null;
303
+ this.sessionStoreLoadPromise = null;
304
+ this.persistedSessions.clear();
305
+ for (const state of this.channels.values()) {
306
+ state.sessionHydrated = false;
307
+ }
308
+ }
309
+ if (!this.sessionStoreLoadPromise) {
310
+ this.sessionStoreLoadPromise = (async () => {
311
+ try {
312
+ const stored = await this.sessionStore.load(agentId);
313
+ this.persistedSessions.clear();
314
+ for (const [channelId, sessionId] of Object.entries(stored)) {
315
+ this.persistedSessions.set(channelId, sessionId);
316
+ }
317
+ this.loadedSessionStoreAgentId = agentId;
318
+ }
319
+ catch (error) {
320
+ this.sessionStoreLoadPromise = null;
321
+ throw error;
322
+ }
323
+ })();
324
+ }
325
+ await this.sessionStoreLoadPromise;
326
+ return true;
327
+ }
328
+ hydrateChannelState(channelId, state) {
329
+ if (state.sessionHydrated) {
330
+ return;
331
+ }
332
+ state.sessionHydrated = true;
333
+ const persistedSessionId = this.persistedSessions.get(channelId);
334
+ if (!persistedSessionId) {
335
+ return;
336
+ }
337
+ state.sessionId = persistedSessionId;
245
338
  state.sessionEstablished = true;
246
- return text;
339
+ }
340
+ async persistSession(channelId, sessionId) {
341
+ if (!this.sessionStore || !this.loadedSessionStoreAgentId) {
342
+ return;
343
+ }
344
+ await this.enqueueSessionStoreWrite(async () => {
345
+ if (this.persistedSessions.get(channelId) === sessionId) {
346
+ return;
347
+ }
348
+ this.persistedSessions.set(channelId, sessionId);
349
+ await this.sessionStore.save(this.loadedSessionStoreAgentId, Object.fromEntries(this.persistedSessions));
350
+ });
351
+ }
352
+ async persistSessionBestEffort(channelId, sessionId) {
353
+ try {
354
+ await this.persistSession(channelId, sessionId);
355
+ }
356
+ catch (error) {
357
+ console.error('[agents-host] failed to persist Claude session map; keeping reply delivery', {
358
+ agentId: this.loadedSessionStoreAgentId,
359
+ channelId,
360
+ sessionId,
361
+ error,
362
+ });
363
+ }
364
+ }
365
+ async resetPersistedSession(channelId, state) {
366
+ state.sessionEstablished = false;
367
+ state.sessionId = undefined;
368
+ if (!this.sessionStore || !this.loadedSessionStoreAgentId) {
369
+ return;
370
+ }
371
+ await this.enqueueSessionStoreWrite(async () => {
372
+ if (!this.persistedSessions.has(channelId)) {
373
+ return;
374
+ }
375
+ this.persistedSessions.delete(channelId);
376
+ await this.sessionStore.save(this.loadedSessionStoreAgentId, Object.fromEntries(this.persistedSessions));
377
+ });
378
+ }
379
+ async resetPersistedSessionBestEffort(channelId, state) {
380
+ try {
381
+ await this.resetPersistedSession(channelId, state);
382
+ }
383
+ catch (error) {
384
+ console.error('[agents-host] failed to clear stale Claude session map; retrying in-memory only', {
385
+ agentId: this.loadedSessionStoreAgentId,
386
+ channelId,
387
+ error,
388
+ });
389
+ }
390
+ }
391
+ async enqueueSessionStoreWrite(writeOperation) {
392
+ const queuedWrite = this.sessionStoreWriteQueue.then(writeOperation);
393
+ this.sessionStoreWriteQueue = queuedWrite.catch(() => undefined);
394
+ await queuedWrite;
247
395
  }
248
396
  async run(state, args, prompt, options) {
249
397
  return new Promise((resolve, reject) => {
@@ -0,0 +1,14 @@
1
+ export interface ClaudeChannelSessionStore {
2
+ load(agentId: string): Promise<Record<string, string>>;
3
+ save(agentId: string, sessions: Record<string, string>): Promise<void>;
4
+ }
5
+ export interface FileClaudeChannelSessionStoreOptions {
6
+ resolvePath(agentId: string): string;
7
+ }
8
+ export declare class FileClaudeChannelSessionStore implements ClaudeChannelSessionStore {
9
+ private readonly options;
10
+ constructor(options: FileClaudeChannelSessionStoreOptions);
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,103 @@
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 Claude 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 Claude session file: ${filePath}`);
22
+ }
23
+ sessions[channelId] = sessionId;
24
+ }
25
+ return sessions;
26
+ }
27
+ export class FileClaudeChannelSessionStore {
28
+ options;
29
+ constructor(options) {
30
+ this.options = options;
31
+ }
32
+ async loadFromPath(filePath) {
33
+ let raw;
34
+ try {
35
+ raw = await readFile(filePath, 'utf8');
36
+ }
37
+ catch (error) {
38
+ throw error;
39
+ }
40
+ return normalizePersistedSessions(JSON.parse(raw), filePath);
41
+ }
42
+ async load(agentId) {
43
+ const filePath = this.options.resolvePath(agentId);
44
+ try {
45
+ return await this.loadFromPath(filePath);
46
+ }
47
+ catch (error) {
48
+ if (error.code !== 'ENOENT') {
49
+ throw error;
50
+ }
51
+ return {};
52
+ }
53
+ }
54
+ async save(agentId, sessions) {
55
+ const filePath = this.options.resolvePath(agentId);
56
+ const parentPath = dirname(filePath);
57
+ await mkdir(parentPath, { recursive: true, mode: 0o700 });
58
+ const entries = Object.entries(sessions).sort(([left], [right]) => left.localeCompare(right));
59
+ if (entries.length === 0) {
60
+ try {
61
+ await unlink(filePath);
62
+ }
63
+ catch (error) {
64
+ if (error.code !== 'ENOENT') {
65
+ throw error;
66
+ }
67
+ return;
68
+ }
69
+ await syncDirectory(parentPath);
70
+ return;
71
+ }
72
+ const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
73
+ let temporaryHandle;
74
+ try {
75
+ temporaryHandle = await open(temporaryPath, 'wx', 0o600);
76
+ await temporaryHandle.writeFile(JSON.stringify(Object.fromEntries(entries)), {
77
+ encoding: 'utf8',
78
+ });
79
+ await temporaryHandle.sync();
80
+ await temporaryHandle.close();
81
+ temporaryHandle = undefined;
82
+ await rename(temporaryPath, filePath);
83
+ await syncDirectory(parentPath);
84
+ }
85
+ catch (error) {
86
+ if (temporaryHandle) {
87
+ try {
88
+ await temporaryHandle.close();
89
+ }
90
+ catch {
91
+ // Preserve the original durability failure.
92
+ }
93
+ }
94
+ try {
95
+ await unlink(temporaryPath);
96
+ }
97
+ catch {
98
+ // Cleanup is best effort.
99
+ }
100
+ throw error;
101
+ }
102
+ }
103
+ }
@@ -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,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:
@@ -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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@borgee/agents-host",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -8,7 +8,7 @@
8
8
  "type": "module",
9
9
  "description": "Minimal local agents host: connects a local Claude/Copilot CLI to a Borgee agent over @borgee/plugin-sdk",
10
10
  "bin": {
11
- "agents-host": "./dist/cli.js"
11
+ "agents-host": "dist/cli.js"
12
12
  },
13
13
  "files": [
14
14
  "dist",
@@ -20,19 +20,6 @@
20
20
  "directory": "packages/agents-host"
21
21
  },
22
22
  "license": "MIT",
23
- "dependencies": {
24
- "@agentclientprotocol/sdk": "^1.2.1",
25
- "cross-spawn": "^7.0.6",
26
- "yaml": "^2.8.1",
27
- "@borgee/plugin-sdk": "0.1.2"
28
- },
29
- "devDependencies": {
30
- "@types/cross-spawn": "^6.0.6",
31
- "@types/node": "^24.3.0",
32
- "tsx": "^4.20.5",
33
- "typescript": "^5.9.3",
34
- "vitest": "^4.1.5"
35
- },
36
23
  "scripts": {
37
24
  "predev": "pnpm --filter @borgee/plugin-sdk build",
38
25
  "dev": "tsx src/index.ts",
@@ -46,5 +33,18 @@
46
33
  "pretest": "pnpm --filter @borgee/plugin-sdk build",
47
34
  "test": "vitest run --testTimeout=10000",
48
35
  "pretypecheck": "pnpm --filter @borgee/plugin-sdk build"
36
+ },
37
+ "dependencies": {
38
+ "@agentclientprotocol/sdk": "^1.2.1",
39
+ "@borgee/plugin-sdk": "0.1.4",
40
+ "cross-spawn": "^7.0.6",
41
+ "yaml": "^2.8.1"
42
+ },
43
+ "devDependencies": {
44
+ "@types/cross-spawn": "^6.0.6",
45
+ "@types/node": "^24.3.0",
46
+ "tsx": "^4.20.5",
47
+ "typescript": "^5.9.3",
48
+ "vitest": "^4.1.5"
49
49
  }
50
- }
50
+ }