@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 +12 -5
- package/dist/agents-host.d.ts +9 -0
- package/dist/agents-host.js +221 -12
- package/dist/chat/chat-control-plane.d.ts +6 -3
- package/dist/chat/sdk-chat-control-plane.d.ts +10 -6
- package/dist/chat/sdk-chat-control-plane.js +28 -18
- 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/adapter.d.ts +3 -2
- package/dist/providers/claude/adapter.js +7 -2
- package/dist/providers/claude/cli-client.d.ts +34 -11
- package/dist/providers/claude/cli-client.js +438 -37
- package/dist/providers/claude/session-store.d.ts +14 -0
- package/dist/providers/claude/session-store.js +103 -0
- package/dist/providers/copilot/adapter.d.ts +2 -2
- package/dist/providers/copilot/adapter.js +4 -2
- package/dist/providers/copilot/cli-client.d.ts +21 -2
- package/dist/providers/copilot/cli-client.js +270 -14
- package/dist/providers/copilot/session-store.d.ts +14 -0
- package/dist/providers/copilot/session-store.js +97 -0
- package/dist/providers/create-provider.js +9 -2
- package/dist/providers/provider-adapter.d.ts +2 -2
- package/dist/state-paths.d.ts +6 -0
- package/dist/state-paths.js +46 -0
- package/dist/types.d.ts +12 -0
- package/package.json +16 -16
|
@@ -1,68 +1,469 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import spawn from 'cross-spawn';
|
|
3
|
+
const DEFAULT_RUNTIME = {
|
|
4
|
+
spawn,
|
|
5
|
+
};
|
|
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
|
+
}
|
|
11
|
+
function normalizeError(error) {
|
|
12
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
13
|
+
}
|
|
14
|
+
function createDeferredTurn(prompt, options) {
|
|
15
|
+
let settled = false;
|
|
16
|
+
let resolvePromise;
|
|
17
|
+
let rejectPromise;
|
|
18
|
+
const promise = new Promise((resolve, reject) => {
|
|
19
|
+
resolvePromise = resolve;
|
|
20
|
+
rejectPromise = reject;
|
|
21
|
+
});
|
|
22
|
+
return {
|
|
23
|
+
prompt,
|
|
24
|
+
options,
|
|
25
|
+
promise,
|
|
26
|
+
resolve(value) {
|
|
27
|
+
if (settled)
|
|
28
|
+
return;
|
|
29
|
+
settled = true;
|
|
30
|
+
resolvePromise(value);
|
|
31
|
+
},
|
|
32
|
+
reject(error) {
|
|
33
|
+
if (settled)
|
|
34
|
+
return;
|
|
35
|
+
settled = true;
|
|
36
|
+
rejectPromise(normalizeError(error));
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function asObject(value) {
|
|
41
|
+
return typeof value === 'object' && value !== null ? value : null;
|
|
42
|
+
}
|
|
43
|
+
function asString(value) {
|
|
44
|
+
return typeof value === 'string' ? value : undefined;
|
|
45
|
+
}
|
|
46
|
+
function hasVisibleText(value) {
|
|
47
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
48
|
+
}
|
|
49
|
+
function readTextBlocks(blocks) {
|
|
50
|
+
if (!Array.isArray(blocks)) {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
const segments = [];
|
|
54
|
+
for (const entry of blocks) {
|
|
55
|
+
const block = asObject(entry);
|
|
56
|
+
if (!block)
|
|
57
|
+
continue;
|
|
58
|
+
if (block.type === 'text' && hasVisibleText(asString(block.text))) {
|
|
59
|
+
segments.push(asString(block.text));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return segments;
|
|
63
|
+
}
|
|
64
|
+
function extractTextDelta(event) {
|
|
65
|
+
if (event.type === 'content_block_delta') {
|
|
66
|
+
const delta = asObject(event.delta);
|
|
67
|
+
if (!delta)
|
|
68
|
+
return null;
|
|
69
|
+
const deltaType = asString(delta.type);
|
|
70
|
+
if (deltaType !== undefined && deltaType !== 'text_delta') {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
return asString(delta.text) ?? null;
|
|
74
|
+
}
|
|
75
|
+
if (event.type === 'content_block_start') {
|
|
76
|
+
const block = asObject(event.content_block) ?? asObject(event.block);
|
|
77
|
+
if (!block || block.type !== 'text') {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return asString(block.text) ?? null;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
function extractToolSummary(event) {
|
|
85
|
+
if (event.type === 'tool_use') {
|
|
86
|
+
const toolName = asString(event.name) ?? asString(event.tool_name);
|
|
87
|
+
return hasVisibleText(toolName) ? `Running ${toolName}…` : 'Running tool…';
|
|
88
|
+
}
|
|
89
|
+
if (event.type === 'content_block_start') {
|
|
90
|
+
const block = asObject(event.content_block) ?? asObject(event.block);
|
|
91
|
+
if (!block || block.type !== 'tool_use') {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
const toolName = asString(block.name);
|
|
95
|
+
return hasVisibleText(toolName) ? `Running ${toolName}…` : 'Running tool…';
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
function extractFinalText(event) {
|
|
100
|
+
if (event.type === 'result') {
|
|
101
|
+
const result = asString(event.result);
|
|
102
|
+
if (result !== undefined) {
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (event.type === 'message' || event.type === 'assistant') {
|
|
107
|
+
const message = asObject(event.message) ?? event;
|
|
108
|
+
const joined = readTextBlocks(message.content).join('');
|
|
109
|
+
if (joined.length > 0) {
|
|
110
|
+
return joined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
class ClaudeStreamCollector {
|
|
116
|
+
onProgress;
|
|
117
|
+
publicText = '';
|
|
118
|
+
finalText = null;
|
|
119
|
+
lastPublished = null;
|
|
120
|
+
constructor(onProgress) {
|
|
121
|
+
this.onProgress = onProgress;
|
|
122
|
+
}
|
|
123
|
+
consume(event) {
|
|
124
|
+
const payload = event.type === 'stream_event' ? asObject(event.event) ?? event : event;
|
|
125
|
+
const textDelta = extractTextDelta(payload);
|
|
126
|
+
if (textDelta !== null) {
|
|
127
|
+
this.publicText += textDelta;
|
|
128
|
+
this.publish(this.publicText);
|
|
129
|
+
}
|
|
130
|
+
if (!hasVisibleText(this.publicText)) {
|
|
131
|
+
const toolSummary = extractToolSummary(payload);
|
|
132
|
+
if (toolSummary) {
|
|
133
|
+
this.publish(toolSummary);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const finalText = extractFinalText(event) ?? extractFinalText(payload);
|
|
137
|
+
if (finalText !== null) {
|
|
138
|
+
this.finalText = finalText;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
getFinalText() {
|
|
142
|
+
const text = this.finalText ?? this.publicText;
|
|
143
|
+
return hasVisibleText(text) ? text : '';
|
|
144
|
+
}
|
|
145
|
+
publish(text) {
|
|
146
|
+
if (!this.onProgress || !hasVisibleText(text) || text === this.lastPublished) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
this.lastPublished = text;
|
|
150
|
+
this.onProgress({ text });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
3
153
|
/**
|
|
4
154
|
* CLI client for the Claude Code CLI (`claude`) non-interactive mode, with
|
|
5
155
|
* native per-channel session continuity.
|
|
6
156
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* These are documented as distinct operations, so this client tracks which
|
|
11
|
-
* channels have already started a session and switches from `--session-id`
|
|
12
|
-
* (first turn) to `--resume` (every turn after) accordingly. No message
|
|
13
|
-
* history is kept on our side — the CLI's own session storage is the single
|
|
14
|
-
* source of truth for conversation memory.
|
|
157
|
+
* Claude session memory remains entirely inside the Claude CLI. This client
|
|
158
|
+
* only pins one native session id per Borgee channel and serializes turns per
|
|
159
|
+
* channel so `--resume` is never called concurrently for the same session.
|
|
15
160
|
*/
|
|
16
161
|
export class ClaudeCliClient {
|
|
17
162
|
command;
|
|
18
163
|
args;
|
|
19
|
-
|
|
20
|
-
|
|
164
|
+
sessionStore;
|
|
165
|
+
resolveSessionStoreAgentId;
|
|
166
|
+
runtime;
|
|
167
|
+
channels = new Map();
|
|
168
|
+
persistedSessions = new Map();
|
|
169
|
+
loadedSessionStoreAgentId = null;
|
|
170
|
+
stopped = false;
|
|
171
|
+
sessionStoreLoadPromise = null;
|
|
172
|
+
sessionStoreWriteQueue = Promise.resolve();
|
|
173
|
+
constructor(command, args, runtimeOverrides = {}, sessionStore, resolveSessionStoreAgentId = () => undefined) {
|
|
21
174
|
this.command = command;
|
|
22
175
|
this.args = args;
|
|
176
|
+
this.sessionStore = sessionStore;
|
|
177
|
+
this.resolveSessionStoreAgentId = resolveSessionStoreAgentId;
|
|
178
|
+
this.runtime = { ...DEFAULT_RUNTIME, ...runtimeOverrides };
|
|
179
|
+
}
|
|
180
|
+
async generateReply(channelId, prompt, options) {
|
|
181
|
+
if (this.stopped) {
|
|
182
|
+
throw new Error('Claude CLI backend stopped');
|
|
183
|
+
}
|
|
184
|
+
const state = this.getOrCreateChannelState(channelId);
|
|
185
|
+
const turn = createDeferredTurn(prompt, options);
|
|
186
|
+
state.queue.push(turn);
|
|
187
|
+
this.processChannelQueue(channelId, state);
|
|
188
|
+
return turn.promise;
|
|
23
189
|
}
|
|
24
|
-
async
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
190
|
+
async dispose() {
|
|
191
|
+
if (this.stopped)
|
|
192
|
+
return;
|
|
193
|
+
this.stopped = true;
|
|
194
|
+
const stoppedError = new Error('Claude CLI backend stopped');
|
|
195
|
+
for (const state of this.channels.values()) {
|
|
196
|
+
state.activeTurn?.reject(stoppedError);
|
|
197
|
+
for (const queuedTurn of state.queue) {
|
|
198
|
+
queuedTurn.reject(stoppedError);
|
|
199
|
+
}
|
|
200
|
+
state.queue = [];
|
|
201
|
+
state.activeChild?.kill('SIGTERM');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
getOrCreateChannelState(channelId) {
|
|
205
|
+
let state = this.channels.get(channelId);
|
|
206
|
+
if (!state) {
|
|
207
|
+
state = {
|
|
208
|
+
processing: false,
|
|
209
|
+
queue: [],
|
|
210
|
+
sessionHydrated: false,
|
|
211
|
+
sessionEstablished: false,
|
|
212
|
+
};
|
|
213
|
+
this.channels.set(channelId, state);
|
|
214
|
+
}
|
|
215
|
+
return state;
|
|
216
|
+
}
|
|
217
|
+
processChannelQueue(channelId, state) {
|
|
218
|
+
if (state.processing) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
state.processing = true;
|
|
222
|
+
void (async () => {
|
|
223
|
+
try {
|
|
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
|
+
}
|
|
238
|
+
const turn = state.queue.shift();
|
|
239
|
+
if (!turn) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
state.activeTurn = turn;
|
|
243
|
+
try {
|
|
244
|
+
const text = await this.runTurn(channelId, state, turn);
|
|
245
|
+
turn.resolve(text);
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
turn.reject(error);
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
state.activeTurn = undefined;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
state.processing = false;
|
|
257
|
+
if (state.queue.length > 0 && !this.stopped) {
|
|
258
|
+
this.processChannelQueue(channelId, state);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
})();
|
|
262
|
+
}
|
|
263
|
+
async runTurn(channelId, state, turn) {
|
|
264
|
+
return this.runTurnAttempt(channelId, state, turn, true);
|
|
265
|
+
}
|
|
266
|
+
async runTurnAttempt(channelId, state, turn, allowFreshRetryAfterStaleResume) {
|
|
267
|
+
if (this.stopped) {
|
|
268
|
+
throw new Error('Claude CLI backend stopped');
|
|
269
|
+
}
|
|
270
|
+
const sessionId = state.sessionEstablished ? state.sessionId : randomUUID();
|
|
271
|
+
const sessionArgs = state.sessionEstablished ? ['--resume', sessionId] : ['--session-id', 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;
|
|
338
|
+
state.sessionEstablished = true;
|
|
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;
|
|
395
|
+
}
|
|
396
|
+
async run(state, args, prompt, options) {
|
|
38
397
|
return new Promise((resolve, reject) => {
|
|
39
|
-
const child = spawn(this.command, args, {
|
|
398
|
+
const child = this.runtime.spawn(this.command, args, {
|
|
40
399
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
41
400
|
});
|
|
42
|
-
|
|
401
|
+
state.activeChild = child;
|
|
402
|
+
const collector = new ClaudeStreamCollector(options?.onProgress);
|
|
43
403
|
let stderr = '';
|
|
404
|
+
let lineBuffer = '';
|
|
405
|
+
let settled = false;
|
|
406
|
+
const settleReject = (error) => {
|
|
407
|
+
if (settled)
|
|
408
|
+
return;
|
|
409
|
+
settled = true;
|
|
410
|
+
reject(normalizeError(error));
|
|
411
|
+
};
|
|
412
|
+
const settleResolve = (value) => {
|
|
413
|
+
if (settled)
|
|
414
|
+
return;
|
|
415
|
+
settled = true;
|
|
416
|
+
resolve(value);
|
|
417
|
+
};
|
|
44
418
|
child.stdout.setEncoding('utf8');
|
|
45
419
|
child.stderr.setEncoding('utf8');
|
|
46
420
|
child.stdout.on('data', (chunk) => {
|
|
47
|
-
|
|
421
|
+
lineBuffer += chunk;
|
|
422
|
+
while (true) {
|
|
423
|
+
const newlineIndex = lineBuffer.indexOf('\n');
|
|
424
|
+
if (newlineIndex === -1) {
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
const line = lineBuffer.slice(0, newlineIndex).trim();
|
|
428
|
+
lineBuffer = lineBuffer.slice(newlineIndex + 1);
|
|
429
|
+
if (!line)
|
|
430
|
+
continue;
|
|
431
|
+
try {
|
|
432
|
+
const event = JSON.parse(line);
|
|
433
|
+
collector.consume(event);
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
settleReject(new Error(`Claude stream-json parse failed: ${normalizeError(error).message}`));
|
|
437
|
+
child.kill('SIGTERM');
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
48
441
|
});
|
|
49
442
|
child.stderr.on('data', (chunk) => {
|
|
50
443
|
stderr += chunk;
|
|
51
444
|
});
|
|
52
|
-
child.
|
|
53
|
-
|
|
445
|
+
child.once('error', (error) => {
|
|
446
|
+
settleReject(error);
|
|
54
447
|
});
|
|
55
|
-
child.
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
|
|
448
|
+
child.once('close', (code) => {
|
|
449
|
+
if (state.activeChild === child) {
|
|
450
|
+
state.activeChild = undefined;
|
|
451
|
+
}
|
|
452
|
+
if (lineBuffer.trim().length > 0 && !settled) {
|
|
453
|
+
try {
|
|
454
|
+
collector.consume(JSON.parse(lineBuffer.trim()));
|
|
455
|
+
lineBuffer = '';
|
|
456
|
+
}
|
|
457
|
+
catch (error) {
|
|
458
|
+
settleReject(new Error(`Claude stream-json parse failed: ${normalizeError(error).message}`));
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
59
461
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
reject(new Error('Claude CLI returned empty output'));
|
|
462
|
+
if (code !== 0) {
|
|
463
|
+
settleReject(new Error(`Claude CLI failed with code ${code}: ${stderr.trim()}`));
|
|
63
464
|
return;
|
|
64
465
|
}
|
|
65
|
-
|
|
466
|
+
settleResolve(collector.getFinalText());
|
|
66
467
|
});
|
|
67
468
|
child.stdin.write(prompt);
|
|
68
469
|
child.stdin.end();
|
|
@@ -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,9 +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 { CopilotCliClient } from './cli-client.js';
|
|
4
4
|
export declare class CopilotProviderAdapter implements ProviderAdapter {
|
|
5
5
|
private readonly cli;
|
|
6
6
|
constructor(cli: CopilotCliClient);
|
|
7
|
-
generateReply(input: ProviderInput): Promise<ProviderReply>;
|
|
7
|
+
generateReply(input: ProviderInput, options?: ProviderGenerateOptions): Promise<ProviderReply>;
|
|
8
8
|
dispose(): Promise<void>;
|
|
9
9
|
}
|
|
@@ -4,7 +4,7 @@ export class CopilotProviderAdapter {
|
|
|
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,9 @@ export class CopilotProviderAdapter {
|
|
|
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
|
}
|
|
18
20
|
async dispose() {
|