@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.
package/README.md CHANGED
@@ -26,6 +26,7 @@ Borgee channel message
26
26
  - local-config multi-agent startup
27
27
  - one isolated `AgentsHost` per effective agent key
28
28
  - per-channel conversation memory via each provider's native session mechanism
29
+ - one draft progress message per turn when a provider emits public progress
29
30
  - hot reload for host config and agent file add / update / remove
30
31
 
31
32
  **Out of scope:**
@@ -314,13 +315,19 @@ Each Borgee channel is mapped 1:1 to a provider-native session while it stays
314
315
  active:
315
316
 
316
317
  - **Claude**: first turn for a channel uses `--session-id <uuid>`; every turn
317
- 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.
318
321
  - **Copilot**: one persistent `copilot --acp` subprocess is shared by a single
319
322
  `AgentsHost`, with one ACP session per Borgee channel. Same-channel turns are
320
- serialized; different channels keep isolated ACP sessions.
321
-
322
- There is no separate history store on our side. If a runner restarts, that
323
- 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.
324
331
 
325
332
  ## Testing
326
333
 
@@ -23,8 +23,13 @@ export declare class AgentsHost {
23
23
  private readonly config;
24
24
  private readonly provider;
25
25
  private readonly borgee;
26
+ private readonly activeTurns;
27
+ private readonly progressChannelQueues;
28
+ private readonly progressDrafts;
26
29
  private selfAgentId;
30
+ private selfAgentIdPromise;
27
31
  private started;
32
+ private controlPlaneClosed;
28
33
  constructor(config: AgentsHostConfig, deps?: {
29
34
  borgee?: ChatControlPlane;
30
35
  provider?: ProviderAdapter;
@@ -32,4 +37,8 @@ export declare class AgentsHost {
32
37
  start(): Promise<void>;
33
38
  stop(): Promise<void>;
34
39
  private handleMessage;
40
+ private trackActiveTurn;
41
+ private enqueueProgressChannelTurn;
42
+ private ensureProgressDraft;
43
+ private ensureSelfAgentId;
35
44
  }
@@ -1,5 +1,114 @@
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;
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
+ }
11
+ function hasVisibleText(value) {
12
+ return value.trim().length > 0;
13
+ }
14
+ function providerUsesDraftProgress(provider) {
15
+ return provider === 'claude' || provider === 'copilot';
16
+ }
17
+ class DraftMessageController {
18
+ borgee;
19
+ channelId;
20
+ canWrite;
21
+ messageId = null;
22
+ currentBody = '';
23
+ pendingBody = null;
24
+ timer = null;
25
+ sealed = false;
26
+ writeChain = Promise.resolve();
27
+ constructor(borgee, channelId, canWrite) {
28
+ this.borgee = borgee;
29
+ this.channelId = channelId;
30
+ this.canWrite = canWrite;
31
+ }
32
+ update(text) {
33
+ if (this.sealed || !hasVisibleText(text)) {
34
+ return;
35
+ }
36
+ this.pendingBody = text;
37
+ if (this.timer) {
38
+ return;
39
+ }
40
+ this.timer = setTimeout(() => {
41
+ this.timer = null;
42
+ void this.flushPending().catch((error) => {
43
+ console.error('[agents-host] failed to flush draft progress:', error);
44
+ });
45
+ }, STREAM_PROGRESS_EDIT_THROTTLE_MS);
46
+ }
47
+ cancelProgress() {
48
+ if (this.timer) {
49
+ clearTimeout(this.timer);
50
+ this.timer = null;
51
+ }
52
+ this.sealed = true;
53
+ }
54
+ async finalize(finalText) {
55
+ this.sealed = true;
56
+ if (this.timer) {
57
+ clearTimeout(this.timer);
58
+ this.timer = null;
59
+ }
60
+ if (hasVisibleText(finalText)) {
61
+ this.pendingBody = finalText;
62
+ await this.flushPending();
63
+ return;
64
+ }
65
+ await this.enqueueWrite(async () => {
66
+ if (!this.canWrite() || !this.messageId) {
67
+ return;
68
+ }
69
+ await this.borgee.deleteMessage(this.messageId);
70
+ this.messageId = null;
71
+ this.currentBody = '';
72
+ this.pendingBody = null;
73
+ });
74
+ }
75
+ async discard() {
76
+ this.sealed = true;
77
+ if (this.timer) {
78
+ clearTimeout(this.timer);
79
+ this.timer = null;
80
+ }
81
+ this.pendingBody = null;
82
+ await this.enqueueWrite(async () => {
83
+ if (!this.canWrite() || !this.messageId) {
84
+ return;
85
+ }
86
+ await this.borgee.deleteMessage(this.messageId);
87
+ this.messageId = null;
88
+ this.currentBody = '';
89
+ });
90
+ }
91
+ async flushPending() {
92
+ await this.enqueueWrite(async () => {
93
+ const body = this.pendingBody;
94
+ if (!this.canWrite() || !body || body === this.currentBody) {
95
+ return;
96
+ }
97
+ if (this.messageId) {
98
+ await this.borgee.editMessage(this.messageId, body);
99
+ }
100
+ else {
101
+ const posted = await this.borgee.postMessage(this.channelId, body);
102
+ this.messageId = posted.messageId;
103
+ }
104
+ this.currentBody = body;
105
+ });
106
+ }
107
+ enqueueWrite(operation) {
108
+ this.writeChain = this.writeChain.then(operation, operation);
109
+ return this.writeChain;
110
+ }
111
+ }
3
112
  /**
4
113
  * Minimal single-agent agents host: connects one local Claude/Copilot CLI
5
114
  * to exactly one Borgee agent over `@borgee/plugin-sdk` (BPP / `/ws/plugin`).
@@ -22,35 +131,54 @@ export class AgentsHost {
22
131
  config;
23
132
  provider;
24
133
  borgee;
134
+ activeTurns = new Set();
135
+ progressChannelQueues = new Map();
136
+ progressDrafts = new Map();
25
137
  selfAgentId = null;
138
+ selfAgentIdPromise = null;
26
139
  started = false;
140
+ controlPlaneClosed = false;
27
141
  constructor(config, deps) {
28
142
  this.config = config;
29
143
  this.provider = deps?.provider ?? createProvider({
30
144
  provider: config.agent.provider,
145
+ stateRootDir: config.stateRootDir,
146
+ resolveStableAgentId: () => this.selfAgentId ?? undefined,
31
147
  claudeCommand: config.claudeCommand,
32
148
  claudeArgs: config.claudeArgs,
33
149
  copilotCommand: config.copilotCommand,
34
150
  copilotArgs: config.copilotArgs,
35
151
  copilotSessionTtlMinutes: config.copilotSessionTtlMinutes,
36
152
  });
37
- this.borgee = deps?.borgee ?? new SdkChatControlPlane(config.borgeeBaseUrl, config.agent.agentApiKey, undefined,
38
- // Lets the server know which runtime/provider actually connected
39
- // (internal/bpp ConnectHandler → users.last_connected_plugin_id), so
40
- // the web UI can show real state instead of a client-side guess.
41
- `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
+ });
42
162
  }
43
163
  async start() {
44
164
  if (this.started)
45
165
  return;
46
166
  this.started = true;
167
+ this.controlPlaneClosed = false;
168
+ await ensurePrivateStateRoot(this.config.stateRootDir);
47
169
  await this.borgee.connect((message) => {
48
- void this.handleMessage(message);
170
+ if (!this.started) {
171
+ return;
172
+ }
173
+ const task = providerUsesDraftProgress(this.config.agent.provider)
174
+ ? this.enqueueProgressChannelTurn(message.channel_id, () => this.handleMessage(message))
175
+ : this.handleMessage(message);
176
+ this.trackActiveTurn(task);
177
+ return task;
49
178
  });
50
- const me = await this.borgee.getMe();
51
- this.selfAgentId = me.id;
179
+ const agentId = await this.ensureSelfAgentId();
52
180
  console.log('[agents-host] connected', {
53
- agentId: me.id,
181
+ agentId,
54
182
  agentName: this.config.agent.agentName,
55
183
  provider: this.config.agent.provider,
56
184
  });
@@ -59,8 +187,13 @@ export class AgentsHost {
59
187
  if (!this.started)
60
188
  return;
61
189
  this.started = false;
190
+ for (const draft of this.progressDrafts.values()) {
191
+ draft.cancelProgress();
192
+ }
193
+ this.controlPlaneClosed = true;
62
194
  await this.borgee.close();
63
195
  await this.provider.dispose?.();
196
+ await Promise.allSettled([...this.activeTurns]);
64
197
  }
65
198
  async handleMessage(msg) {
66
199
  console.log('[agents-host] received message', {
@@ -69,8 +202,9 @@ export class AgentsHost {
69
202
  agentName: this.config.agent.agentName,
70
203
  channelId: msg.channel_id,
71
204
  });
205
+ const selfAgentId = await this.ensureSelfAgentId();
72
206
  const authorId = String(msg.user_id ?? msg.sender_id ?? 'unknown');
73
- if (this.selfAgentId && authorId === this.selfAgentId) {
207
+ if (authorId === selfAgentId) {
74
208
  return;
75
209
  }
76
210
  const content = String(msg.content ?? msg.body ?? '').trim();
@@ -80,6 +214,8 @@ export class AgentsHost {
80
214
  // messages an agent receives (per-agent + per-channel require_mention is
81
215
  // enforced at BPP fan-out via AgentReceivesChannelMessage). If a channel
82
216
  // message reaches this host at all, the agent is meant to handle it.
217
+ const stopTyping = this.borgee.startTyping(msg.channel_id);
218
+ const useDraftProgress = providerUsesDraftProgress(this.config.agent.provider);
83
219
  try {
84
220
  const reply = await this.provider.generateReply({
85
221
  agentName: this.config.agent.agentName,
@@ -87,15 +223,88 @@ export class AgentsHost {
87
223
  channelId: msg.channel_id,
88
224
  incomingAuthorId: authorId,
89
225
  incomingContent: content,
90
- });
91
- await this.borgee.postMessage(msg.channel_id, reply.text);
226
+ }, useDraftProgress
227
+ ? {
228
+ onProgress: (update) => {
229
+ if (!this.started || this.controlPlaneClosed) {
230
+ return;
231
+ }
232
+ this.ensureProgressDraft(msg.channel_id).update(update.text);
233
+ },
234
+ }
235
+ : undefined);
236
+ if (useDraftProgress) {
237
+ const draft = this.progressDrafts.get(msg.channel_id);
238
+ if (!this.started) {
239
+ await draft?.discard().catch(() => { });
240
+ this.progressDrafts.delete(msg.channel_id);
241
+ return;
242
+ }
243
+ await (draft ?? this.ensureProgressDraft(msg.channel_id)).finalize(reply.text);
244
+ this.progressDrafts.delete(msg.channel_id);
245
+ }
246
+ else if (this.started && !this.controlPlaneClosed) {
247
+ await this.borgee.postMessage(msg.channel_id, reply.text);
248
+ }
92
249
  }
93
250
  catch (error) {
251
+ if (useDraftProgress) {
252
+ const draft = this.progressDrafts.get(msg.channel_id);
253
+ if (draft) {
254
+ await draft.discard().catch(() => { });
255
+ this.progressDrafts.delete(msg.channel_id);
256
+ }
257
+ }
94
258
  console.error('[agents-host] failed to generate or send reply:', {
95
259
  provider: this.config.agent.provider,
96
260
  agentName: this.config.agent.agentName,
97
261
  error,
98
262
  });
99
263
  }
264
+ finally {
265
+ stopTyping();
266
+ }
267
+ }
268
+ trackActiveTurn(task) {
269
+ this.activeTurns.add(task);
270
+ void task.finally(() => {
271
+ this.activeTurns.delete(task);
272
+ });
273
+ }
274
+ enqueueProgressChannelTurn(channelId, task) {
275
+ const previous = this.progressChannelQueues.get(channelId) ?? Promise.resolve();
276
+ const next = previous
277
+ .catch(() => { })
278
+ .then(task)
279
+ .finally(() => {
280
+ if (this.progressChannelQueues.get(channelId) === next) {
281
+ this.progressChannelQueues.delete(channelId);
282
+ }
283
+ });
284
+ this.progressChannelQueues.set(channelId, next);
285
+ return next;
286
+ }
287
+ ensureProgressDraft(channelId) {
288
+ let draft = this.progressDrafts.get(channelId);
289
+ if (!draft) {
290
+ draft = new DraftMessageController(this.borgee, channelId, () => !this.controlPlaneClosed);
291
+ this.progressDrafts.set(channelId, draft);
292
+ }
293
+ return draft;
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;
100
309
  }
101
310
  }
@@ -1,7 +1,10 @@
1
- import type { ChannelMessageEvent, MeResponseUser } from '../types.js';
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
- postMessage(channelId: string, content: string): Promise<void>;
5
+ postMessage(channelId: string, content: string): Promise<PostedMessage>;
6
+ editMessage(messageId: string, content: string): Promise<void>;
7
+ deleteMessage(messageId: string): Promise<void>;
8
+ startTyping(channelId: string): () => void;
6
9
  getMe(): Promise<MeResponseUser>;
7
10
  }
@@ -1,8 +1,9 @@
1
1
  import { type BorgeePluginClient, type BorgeePluginOptions, type InboundMessageEvent } from '@borgee/plugin-sdk';
2
- import type { ChannelMessageEvent, MeResponseUser } from '../types.js';
2
+ import type { ChannelMessageEvent, MeResponseUser, PostedMessage } from '../types.js';
3
3
  import type { ChatControlPlane } from './chat-control-plane.js';
4
- type PluginClientLike = Pick<BorgeePluginClient, 'close' | 'connect' | 'getMe' | 'on' | 'sendMessage'>;
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,13 +11,16 @@ 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
- constructor(baseUrl: string, apiKey: string, createClient?: PluginClientFactory, pluginId?: string);
17
- connect(onMessage: (message: ChannelMessageEvent) => void): Promise<void>;
16
+ private me;
17
+ constructor(baseUrl: string, apiKey: string, createClient?: PluginClientFactory, options?: SdkChatControlPlaneOptions);
18
+ connect(onMessage: (message: ChannelMessageEvent) => void | Promise<void>): Promise<void>;
18
19
  close(): Promise<void>;
19
- postMessage(channelId: string, content: string): Promise<void>;
20
+ postMessage(channelId: string, content: string): Promise<PostedMessage>;
21
+ editMessage(messageId: string, content: string): Promise<void>;
22
+ deleteMessage(messageId: string): Promise<void>;
23
+ startTyping(channelId: string): () => void;
20
24
  getMe(): Promise<MeResponseUser>;
21
25
  }
22
26
  export declare function mapInboundToChannelMessage(event: InboundMessageEvent): ChannelMessageEvent;
@@ -6,53 +6,63 @@ 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
- constructor(baseUrl, apiKey, createClient = createBorgeePlugin, pluginId) {
13
- this.client = createClient({ baseUrl, apiKey, pluginId });
11
+ me = null;
12
+ constructor(baseUrl, apiKey, createClient = createBorgeePlugin, options = {}) {
13
+ this.client = createClient({ baseUrl, apiKey, ...options });
14
14
  }
15
15
  async connect(onMessage) {
16
- this.unsubscribe = this.client.on('message', (event) => {
17
- const message = mapInboundToChannelMessage(event);
18
- if (!this.connected) {
19
- this.pendingMessages.push(message);
20
- return;
21
- }
22
- onMessage(message);
16
+ this.unsubscribe = this.client.on('message', async (event) => {
17
+ await onMessage(mapInboundToChannelMessage(event));
23
18
  });
24
19
  try {
25
- 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.
26
23
  this.connected = true;
27
- for (const message of this.pendingMessages) {
28
- onMessage(message);
24
+ await this.client.connect();
25
+ if (this.client.agentId) {
26
+ this.me = { id: this.client.agentId };
29
27
  }
30
- this.pendingMessages = [];
31
28
  }
32
29
  catch (error) {
30
+ this.connected = false;
33
31
  this.unsubscribe?.();
34
32
  this.unsubscribe = null;
35
- this.pendingMessages = [];
36
33
  throw error;
37
34
  }
38
35
  }
39
36
  async close() {
40
37
  this.connected = false;
41
- this.pendingMessages = [];
42
38
  this.unsubscribe?.();
43
39
  this.unsubscribe = null;
44
40
  await this.client.close();
45
41
  }
46
42
  async postMessage(channelId, content) {
47
- await this.client.sendMessage({ channelId, body: content });
43
+ const sent = await this.client.sendMessage({ channelId, body: content });
44
+ return { messageId: sent.messageId };
45
+ }
46
+ async editMessage(messageId, content) {
47
+ await this.client.editMessage({ messageId, body: content });
48
+ }
49
+ async deleteMessage(messageId) {
50
+ await this.client.deleteMessage({ messageId });
51
+ }
52
+ startTyping(channelId) {
53
+ return this.client.startTyping(channelId);
48
54
  }
49
55
  async getMe() {
56
+ if (this.me) {
57
+ return this.me;
58
+ }
50
59
  const me = await this.client.getMe();
51
- return {
60
+ this.me = {
52
61
  id: me.id,
53
62
  display_name: me.displayName,
54
63
  role: me.kind,
55
64
  };
65
+ return this.me;
56
66
  }
57
67
  }
58
68
  export function mapInboundToChannelMessage(event) {
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,8 +1,9 @@
1
1
  import type { ProviderAdapter } from '../provider-adapter.js';
2
- import type { ProviderInput, ProviderReply } from '../../types.js';
2
+ import type { ProviderGenerateOptions, ProviderInput, ProviderReply } from '../../types.js';
3
3
  import { ClaudeCliClient } from './cli-client.js';
4
4
  export declare class ClaudeProviderAdapter implements ProviderAdapter {
5
5
  private readonly cli;
6
6
  constructor(cli: ClaudeCliClient);
7
- generateReply(input: ProviderInput): Promise<ProviderReply>;
7
+ generateReply(input: ProviderInput, options?: ProviderGenerateOptions): Promise<ProviderReply>;
8
+ dispose(): Promise<void>;
8
9
  }
@@ -4,7 +4,7 @@ export class ClaudeProviderAdapter {
4
4
  constructor(cli) {
5
5
  this.cli = cli;
6
6
  }
7
- async generateReply(input) {
7
+ async generateReply(input, options) {
8
8
  const prompt = buildPrompt({
9
9
  agentName: input.agentName,
10
10
  provider: input.provider,
@@ -12,7 +12,12 @@ export class ClaudeProviderAdapter {
12
12
  incomingAuthorId: input.incomingAuthorId,
13
13
  incomingContent: input.incomingContent,
14
14
  });
15
- const text = await this.cli.generateReply(input.channelId, prompt);
15
+ const text = await this.cli.generateReply(input.channelId, prompt, {
16
+ onProgress: options?.onProgress,
17
+ });
16
18
  return { text };
17
19
  }
20
+ async dispose() {
21
+ await this.cli.dispose();
22
+ }
18
23
  }
@@ -1,21 +1,44 @@
1
+ import spawn from 'cross-spawn';
2
+ import type { ProviderGenerateOptions } from '../../types.js';
3
+ import type { ClaudeChannelSessionStore } from './session-store.js';
4
+ interface ClaudeCliRuntime {
5
+ spawn: typeof spawn;
6
+ }
1
7
  /**
2
8
  * CLI client for the Claude Code CLI (`claude`) non-interactive mode, with
3
9
  * native per-channel session continuity.
4
10
  *
5
- * Verified CLI behavior (`claude --help`):
6
- * - `--session-id <uuid>` starts a *new* conversation pinned to that UUID.
7
- * - `-r/--resume <uuid>` resumes an *existing* conversation by session ID.
8
- * These are documented as distinct operations, so this client tracks which
9
- * channels have already started a session and switches from `--session-id`
10
- * (first turn) to `--resume` (every turn after) accordingly. No message
11
- * history is kept on our side — the CLI's own session storage is the single
12
- * source of truth for conversation memory.
11
+ * Claude session memory remains entirely inside the Claude CLI. This client
12
+ * only pins one native session id per Borgee channel and serializes turns per
13
+ * channel so `--resume` is never called concurrently for the same session.
13
14
  */
14
15
  export declare class ClaudeCliClient {
15
16
  private readonly command;
16
17
  private readonly args;
17
- private readonly sessionIdsByChannel;
18
- constructor(command: string, args: string[]);
19
- generateReply(channelId: string, prompt: string): Promise<string>;
18
+ private readonly sessionStore?;
19
+ private readonly resolveSessionStoreAgentId;
20
+ private readonly runtime;
21
+ private readonly channels;
22
+ private readonly persistedSessions;
23
+ private loadedSessionStoreAgentId;
24
+ private stopped;
25
+ private sessionStoreLoadPromise;
26
+ private sessionStoreWriteQueue;
27
+ constructor(command: string, args: string[], runtimeOverrides?: Partial<ClaudeCliRuntime>, sessionStore?: ClaudeChannelSessionStore | undefined, resolveSessionStoreAgentId?: () => string | undefined);
28
+ generateReply(channelId: string, prompt: string, options?: ProviderGenerateOptions): Promise<string>;
29
+ dispose(): Promise<void>;
30
+ private getOrCreateChannelState;
31
+ private processChannelQueue;
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;
20
42
  private run;
21
43
  }
44
+ export {};