@borgee/agents-host 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -5
- package/dist/agents-host.d.ts +2 -0
- package/dist/agents-host.js +39 -9
- package/dist/chat/sdk-chat-control-plane.d.ts +2 -1
- package/dist/chat/sdk-chat-control-plane.js +2 -2
- package/dist/cli.js +0 -0
- package/dist/config.js +2 -0
- package/dist/durable-cursor-store.d.ts +5 -0
- package/dist/durable-cursor-store.js +7 -0
- package/dist/local-config.js +8 -2
- package/dist/providers/claude/cli-client.d.ts +17 -1
- package/dist/providers/claude/cli-client.js +154 -6
- package/dist/providers/claude/session-store.d.ts +37 -0
- package/dist/providers/claude/session-store.js +156 -0
- package/dist/providers/copilot/cli-client.d.ts +19 -1
- package/dist/providers/copilot/cli-client.js +180 -3
- package/dist/providers/copilot/session-store.d.ts +37 -0
- package/dist/providers/copilot/session-store.js +150 -0
- package/dist/providers/create-provider.js +9 -2
- package/dist/state-paths.d.ts +6 -0
- package/dist/state-paths.js +46 -0
- package/dist/types.d.ts +3 -0
- package/package.json +16 -16
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
|
-
|
|
324
|
-
|
|
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
|
|
package/dist/agents-host.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/agents-host.js
CHANGED
|
@@ -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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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;
|
|
@@ -160,10 +175,9 @@ export class AgentsHost {
|
|
|
160
175
|
: this.handleMessage(message);
|
|
161
176
|
this.trackActiveTurn(task);
|
|
162
177
|
});
|
|
163
|
-
const
|
|
164
|
-
this.selfAgentId = me.id;
|
|
178
|
+
const agentId = await this.ensureSelfAgentId();
|
|
165
179
|
console.log('[agents-host] connected', {
|
|
166
|
-
agentId
|
|
180
|
+
agentId,
|
|
167
181
|
agentName: this.config.agent.agentName,
|
|
168
182
|
provider: this.config.agent.provider,
|
|
169
183
|
});
|
|
@@ -187,8 +201,9 @@ export class AgentsHost {
|
|
|
187
201
|
agentName: this.config.agent.agentName,
|
|
188
202
|
channelId: msg.channel_id,
|
|
189
203
|
});
|
|
204
|
+
const selfAgentId = await this.ensureSelfAgentId();
|
|
190
205
|
const authorId = String(msg.user_id ?? msg.sender_id ?? 'unknown');
|
|
191
|
-
if (
|
|
206
|
+
if (authorId === selfAgentId) {
|
|
192
207
|
return;
|
|
193
208
|
}
|
|
194
209
|
const content = String(msg.content ?? msg.body ?? '').trim();
|
|
@@ -276,4 +291,19 @@ export class AgentsHost {
|
|
|
276
291
|
}
|
|
277
292
|
return draft;
|
|
278
293
|
}
|
|
294
|
+
async ensureSelfAgentId() {
|
|
295
|
+
if (this.selfAgentId) {
|
|
296
|
+
return this.selfAgentId;
|
|
297
|
+
}
|
|
298
|
+
if (!this.selfAgentIdPromise) {
|
|
299
|
+
this.selfAgentIdPromise = this.borgee.getMe().then((me) => {
|
|
300
|
+
this.selfAgentId = me.id;
|
|
301
|
+
return me.id;
|
|
302
|
+
}).catch((error) => {
|
|
303
|
+
this.selfAgentIdPromise = null;
|
|
304
|
+
throw error;
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
return this.selfAgentIdPromise;
|
|
308
|
+
}
|
|
279
309
|
}
|
|
@@ -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
|
|
@@ -14,7 +15,7 @@ export declare class SdkChatControlPlane implements ChatControlPlane {
|
|
|
14
15
|
private unsubscribe;
|
|
15
16
|
private connected;
|
|
16
17
|
private me;
|
|
17
|
-
constructor(baseUrl: string, apiKey: string, createClient?: PluginClientFactory,
|
|
18
|
+
constructor(baseUrl: string, apiKey: string, createClient?: PluginClientFactory, options?: SdkChatControlPlaneOptions);
|
|
18
19
|
connect(onMessage: (message: ChannelMessageEvent) => void): Promise<void>;
|
|
19
20
|
close(): Promise<void>;
|
|
20
21
|
postMessage(channelId: string, content: string): Promise<PostedMessage>;
|
|
@@ -10,8 +10,8 @@ export class SdkChatControlPlane {
|
|
|
10
10
|
unsubscribe = null;
|
|
11
11
|
connected = false;
|
|
12
12
|
me = null;
|
|
13
|
-
constructor(baseUrl, apiKey, createClient = createBorgeePlugin,
|
|
14
|
-
this.client = createClient({ baseUrl, apiKey,
|
|
13
|
+
constructor(baseUrl, apiKey, createClient = createBorgeePlugin, options = {}) {
|
|
14
|
+
this.client = createClient({ baseUrl, apiKey, ...options });
|
|
15
15
|
}
|
|
16
16
|
async connect(onMessage) {
|
|
17
17
|
this.unsubscribe = this.client.on('message', (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,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
|
+
}
|
package/dist/local-config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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,37 @@
|
|
|
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
|
+
fileSystem?: ClaudeSessionStoreFileSystem;
|
|
8
|
+
platform?: NodeJS.Platform;
|
|
9
|
+
}
|
|
10
|
+
interface ClaudeSessionStoreDirectoryHandle {
|
|
11
|
+
sync(): Promise<void>;
|
|
12
|
+
close(): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
interface ClaudeSessionStoreFileHandle extends ClaudeSessionStoreDirectoryHandle {
|
|
15
|
+
writeFile(data: string, options: {
|
|
16
|
+
encoding: BufferEncoding;
|
|
17
|
+
}): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
interface ClaudeSessionStoreFileSystem {
|
|
20
|
+
mkdir(path: string, options: {
|
|
21
|
+
recursive: true;
|
|
22
|
+
mode: number;
|
|
23
|
+
}): Promise<void>;
|
|
24
|
+
openDirectory(path: string): Promise<ClaudeSessionStoreDirectoryHandle>;
|
|
25
|
+
openFile(path: string, flags: string, mode: number): Promise<ClaudeSessionStoreFileHandle>;
|
|
26
|
+
readFile(path: string, encoding: BufferEncoding): Promise<string>;
|
|
27
|
+
rename(from: string, to: string): Promise<void>;
|
|
28
|
+
unlink(path: string): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
export declare class FileClaudeChannelSessionStore implements ClaudeChannelSessionStore {
|
|
31
|
+
private readonly options;
|
|
32
|
+
constructor(options: FileClaudeChannelSessionStoreOptions);
|
|
33
|
+
private loadFromPath;
|
|
34
|
+
load(agentId: string): Promise<Record<string, string>>;
|
|
35
|
+
save(agentId: string, sessions: Record<string, string>): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
export {};
|