@borgee/agents-host 0.1.5 → 0.1.7
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 +290 -49
- package/dist/agents-host-supervisor.d.ts +54 -0
- package/dist/agents-host-supervisor.js +313 -0
- package/dist/agents-host.d.ts +4 -4
- package/dist/agents-host.js +6 -4
- package/dist/cli-args.d.ts +35 -12
- package/dist/cli-args.js +225 -35
- package/dist/cli.d.ts +13 -1
- package/dist/cli.js +92 -26
- package/dist/config.d.ts +12 -7
- package/dist/config.js +80 -27
- package/dist/local-config.d.ts +51 -0
- package/dist/local-config.js +772 -0
- package/dist/providers/copilot/adapter.d.ts +1 -0
- package/dist/providers/copilot/adapter.js +3 -0
- package/dist/providers/copilot/cli-client.d.ts +55 -17
- package/dist/providers/copilot/cli-client.js +493 -50
- package/dist/providers/create-provider.js +3 -1
- package/dist/providers/provider-adapter.d.ts +1 -0
- package/dist/run.d.ts +27 -7
- package/dist/run.js +48 -10
- package/dist/types.d.ts +61 -5
- package/package.json +5 -2
|
@@ -1,24 +1,62 @@
|
|
|
1
|
+
import spawn from 'cross-spawn';
|
|
2
|
+
import { PROTOCOL_VERSION, client, methods, ndJsonStream } from '@agentclientprotocol/sdk';
|
|
3
|
+
interface CopilotAcpRuntime {
|
|
4
|
+
spawn: typeof spawn;
|
|
5
|
+
client: typeof client;
|
|
6
|
+
ndJsonStream: typeof ndJsonStream;
|
|
7
|
+
methods: typeof methods;
|
|
8
|
+
protocolVersion: typeof PROTOCOL_VERSION;
|
|
9
|
+
cwd: string;
|
|
10
|
+
idleSessionTtlMs: number;
|
|
11
|
+
shutdownGracePeriodMs: number;
|
|
12
|
+
shutdownForceKillWaitMs: number;
|
|
13
|
+
}
|
|
1
14
|
/**
|
|
2
|
-
*
|
|
3
|
-
* with native per-channel session continuity.
|
|
4
|
-
*
|
|
5
|
-
* Unlike the Claude CLI, Copilot's non-interactive mode takes the prompt as
|
|
6
|
-
* a `-p/--prompt` argument rather than reading it from stdin, so the prompt
|
|
7
|
-
* is appended to the configured base args on every invocation instead of
|
|
8
|
-
* being written to the child process's stdin.
|
|
15
|
+
* Persistent ACP-backed client for the GitHub Copilot CLI (`copilot --acp`).
|
|
9
16
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* is kept on our side — the CLI's own session storage is the single source
|
|
15
|
-
* of truth for conversation memory.
|
|
17
|
+
* The legacy one-shot `COPILOT_ARGS` / constructor args are intentionally
|
|
18
|
+
* ignored here: the prototype always launches the child as `copilot --acp`,
|
|
19
|
+
* performs the ACP initialize handshake once, then reuses one ACP session per
|
|
20
|
+
* Borgee channel for the process lifetime.
|
|
16
21
|
*/
|
|
17
22
|
export declare class CopilotCliClient {
|
|
18
23
|
private readonly command;
|
|
19
|
-
private readonly
|
|
20
|
-
private readonly
|
|
21
|
-
|
|
24
|
+
private readonly runtime;
|
|
25
|
+
private readonly channels;
|
|
26
|
+
private readonly closingSessions;
|
|
27
|
+
private readonly pendingSessionStarts;
|
|
28
|
+
private readonly pendingSessionCloses;
|
|
29
|
+
private readonly fatalPromise;
|
|
30
|
+
private rejectFatalPromise;
|
|
31
|
+
private child?;
|
|
32
|
+
private connection?;
|
|
33
|
+
private startPromise?;
|
|
34
|
+
private shutdownPromise?;
|
|
35
|
+
private childExitPromise?;
|
|
36
|
+
private resolveChildExit?;
|
|
37
|
+
private fatalError;
|
|
38
|
+
private disposing;
|
|
39
|
+
private backendClosed;
|
|
40
|
+
constructor(command: string, _ignoredArgs?: string[], runtimeOverrides?: Partial<CopilotAcpRuntime>);
|
|
22
41
|
generateReply(channelId: string, prompt: string): Promise<string>;
|
|
23
|
-
|
|
42
|
+
dispose(): Promise<void>;
|
|
43
|
+
private ensureStarted;
|
|
44
|
+
private startBackend;
|
|
45
|
+
private getOrCreateChannelState;
|
|
46
|
+
private processChannelQueue;
|
|
47
|
+
private getOrCreateSession;
|
|
48
|
+
private runTurn;
|
|
49
|
+
private raceWithFatal;
|
|
50
|
+
private failAll;
|
|
51
|
+
private invalidateSession;
|
|
52
|
+
private closeSession;
|
|
53
|
+
private rejectQueuedTurnsAfterSessionTaint;
|
|
54
|
+
private clearIdleTimer;
|
|
55
|
+
private reconcileIdleChannelState;
|
|
56
|
+
private evictIdleChannel;
|
|
57
|
+
private shutdownBackend;
|
|
58
|
+
private waitForPendingSessionStarts;
|
|
59
|
+
private waitForPendingSessionCloses;
|
|
60
|
+
private waitForChildExit;
|
|
24
61
|
}
|
|
62
|
+
export {};
|
|
@@ -1,65 +1,508 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Readable, Writable } from 'node:stream';
|
|
2
2
|
import spawn from 'cross-spawn';
|
|
3
|
+
import { PROTOCOL_VERSION, client, methods, ndJsonStream, } from '@agentclientprotocol/sdk';
|
|
4
|
+
const SESSION_TAINTED_ERRORS = new WeakSet();
|
|
5
|
+
const DEFAULT_IDLE_SESSION_TTL_MS = 2 * 24 * 60 * 60 * 1000;
|
|
6
|
+
const DEFAULT_SHUTDOWN_GRACE_PERIOD_MS = 250;
|
|
7
|
+
const DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS = 250;
|
|
8
|
+
const QUEUED_TURN_DROPPED_MESSAGE = 'Copilot ACP session was reset after a failed turn; queued turns were dropped instead of replaying them on a fresh session';
|
|
9
|
+
const DEFAULT_RUNTIME = {
|
|
10
|
+
spawn,
|
|
11
|
+
client,
|
|
12
|
+
ndJsonStream,
|
|
13
|
+
methods,
|
|
14
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
15
|
+
cwd: process.cwd(),
|
|
16
|
+
idleSessionTtlMs: DEFAULT_IDLE_SESSION_TTL_MS,
|
|
17
|
+
shutdownGracePeriodMs: DEFAULT_SHUTDOWN_GRACE_PERIOD_MS,
|
|
18
|
+
shutdownForceKillWaitMs: DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS,
|
|
19
|
+
};
|
|
20
|
+
function createDeferredTurn(prompt) {
|
|
21
|
+
let settled = false;
|
|
22
|
+
let resolvePromise;
|
|
23
|
+
let rejectPromise;
|
|
24
|
+
const promise = new Promise((resolve, reject) => {
|
|
25
|
+
resolvePromise = resolve;
|
|
26
|
+
rejectPromise = reject;
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
prompt,
|
|
30
|
+
promise,
|
|
31
|
+
resolve(value) {
|
|
32
|
+
if (settled)
|
|
33
|
+
return;
|
|
34
|
+
settled = true;
|
|
35
|
+
resolvePromise(value);
|
|
36
|
+
},
|
|
37
|
+
reject(error) {
|
|
38
|
+
if (settled)
|
|
39
|
+
return;
|
|
40
|
+
settled = true;
|
|
41
|
+
rejectPromise(normalizeError(error));
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function normalizeError(error) {
|
|
46
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
47
|
+
}
|
|
48
|
+
function markSessionTainted(error) {
|
|
49
|
+
const normalized = normalizeError(error);
|
|
50
|
+
SESSION_TAINTED_ERRORS.add(normalized);
|
|
51
|
+
return normalized;
|
|
52
|
+
}
|
|
53
|
+
function isSessionTainted(error) {
|
|
54
|
+
return error instanceof Error && SESSION_TAINTED_ERRORS.has(error);
|
|
55
|
+
}
|
|
56
|
+
function selectPermissionOption(options) {
|
|
57
|
+
const option = options.find((candidate) => candidate.kind === 'allow_always') ??
|
|
58
|
+
options.find((candidate) => candidate.kind === 'allow_once');
|
|
59
|
+
if (!option) {
|
|
60
|
+
throw new Error('Copilot ACP requested permission but did not offer an allow option');
|
|
61
|
+
}
|
|
62
|
+
return option.optionId;
|
|
63
|
+
}
|
|
3
64
|
/**
|
|
4
|
-
*
|
|
5
|
-
* with native per-channel session continuity.
|
|
65
|
+
* Persistent ACP-backed client for the GitHub Copilot CLI (`copilot --acp`).
|
|
6
66
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* Verified CLI behavior (`copilot --help` + empirical check): `--session-id
|
|
13
|
-
* <uuid>` both *creates* a new session pinned to that UUID (first call) and
|
|
14
|
-
* *resumes* it (subsequent calls with the same UUID) — one flag covers both
|
|
15
|
-
* cases, unlike Claude's `--session-id`/`--resume` split. No message history
|
|
16
|
-
* is kept on our side — the CLI's own session storage is the single source
|
|
17
|
-
* of truth for conversation memory.
|
|
67
|
+
* The legacy one-shot `COPILOT_ARGS` / constructor args are intentionally
|
|
68
|
+
* ignored here: the prototype always launches the child as `copilot --acp`,
|
|
69
|
+
* performs the ACP initialize handshake once, then reuses one ACP session per
|
|
70
|
+
* Borgee channel for the process lifetime.
|
|
18
71
|
*/
|
|
19
72
|
export class CopilotCliClient {
|
|
20
73
|
command;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
74
|
+
runtime;
|
|
75
|
+
channels = new Map();
|
|
76
|
+
closingSessions = new WeakSet();
|
|
77
|
+
pendingSessionStarts = new Set();
|
|
78
|
+
pendingSessionCloses = new Set();
|
|
79
|
+
fatalPromise;
|
|
80
|
+
rejectFatalPromise;
|
|
81
|
+
child;
|
|
82
|
+
connection;
|
|
83
|
+
startPromise;
|
|
84
|
+
shutdownPromise;
|
|
85
|
+
childExitPromise;
|
|
86
|
+
resolveChildExit;
|
|
87
|
+
fatalError = null;
|
|
88
|
+
disposing = false;
|
|
89
|
+
backendClosed = false;
|
|
90
|
+
constructor(command, _ignoredArgs = [], runtimeOverrides = {}) {
|
|
24
91
|
this.command = command;
|
|
25
|
-
this.
|
|
92
|
+
this.runtime = { ...DEFAULT_RUNTIME, ...runtimeOverrides };
|
|
93
|
+
this.fatalPromise = new Promise((_, reject) => {
|
|
94
|
+
this.rejectFatalPromise = reject;
|
|
95
|
+
});
|
|
96
|
+
void this.fatalPromise.catch(() => { });
|
|
26
97
|
}
|
|
27
98
|
async generateReply(channelId, prompt) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
99
|
+
if (this.fatalError) {
|
|
100
|
+
throw this.fatalError;
|
|
101
|
+
}
|
|
102
|
+
const state = this.getOrCreateChannelState(channelId);
|
|
103
|
+
this.clearIdleTimer(state);
|
|
104
|
+
const turn = createDeferredTurn(prompt);
|
|
105
|
+
state.queue.push(turn);
|
|
106
|
+
this.processChannelQueue(channelId, state);
|
|
107
|
+
return turn.promise;
|
|
108
|
+
}
|
|
109
|
+
async dispose() {
|
|
110
|
+
const error = new Error('Copilot ACP backend stopped');
|
|
111
|
+
this.disposing = true;
|
|
112
|
+
const closed = this.connection?.closed ?? Promise.resolve();
|
|
113
|
+
this.failAll(error);
|
|
114
|
+
await Promise.all([closed, this.shutdownPromise ?? Promise.resolve()]);
|
|
115
|
+
}
|
|
116
|
+
async ensureStarted() {
|
|
117
|
+
if (this.fatalError) {
|
|
118
|
+
throw this.fatalError;
|
|
119
|
+
}
|
|
120
|
+
if (!this.startPromise) {
|
|
121
|
+
this.startPromise = this.startBackend();
|
|
122
|
+
}
|
|
123
|
+
await this.startPromise;
|
|
124
|
+
if (this.fatalError) {
|
|
125
|
+
throw this.fatalError;
|
|
126
|
+
}
|
|
127
|
+
if (!this.connection) {
|
|
128
|
+
throw new Error('Copilot ACP connection is not available');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async startBackend() {
|
|
132
|
+
const child = this.runtime.spawn(this.command, ['--acp'], {
|
|
133
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
134
|
+
});
|
|
135
|
+
this.child = child;
|
|
136
|
+
child.stderr.resume();
|
|
137
|
+
child.once('error', (error) => {
|
|
138
|
+
this.failAll(new Error(`Copilot ACP process error: ${normalizeError(error).message}`));
|
|
139
|
+
});
|
|
140
|
+
child.once('exit', (code, signal) => {
|
|
141
|
+
this.resolveChildExit?.();
|
|
142
|
+
if (this.disposing)
|
|
143
|
+
return;
|
|
144
|
+
const suffix = signal ? `signal ${signal}` : `code ${String(code ?? 'unknown')}`;
|
|
145
|
+
this.failAll(new Error(`Copilot ACP process exited unexpectedly (${suffix})`));
|
|
146
|
+
});
|
|
147
|
+
this.childExitPromise = new Promise((resolve) => {
|
|
148
|
+
this.resolveChildExit = resolve;
|
|
149
|
+
});
|
|
150
|
+
const output = Writable.toWeb(child.stdin);
|
|
151
|
+
const input = Readable.toWeb(child.stdout);
|
|
152
|
+
const stream = this.runtime.ndJsonStream(output, input);
|
|
153
|
+
const app = this.runtime
|
|
154
|
+
.client({ name: 'borgee-agents-host' })
|
|
155
|
+
.onRequest(this.runtime.methods.client.session.requestPermission, ({ params }) => ({
|
|
156
|
+
outcome: {
|
|
157
|
+
outcome: 'selected',
|
|
158
|
+
optionId: selectPermissionOption(params.options),
|
|
159
|
+
},
|
|
160
|
+
}));
|
|
161
|
+
const connection = app.connect(stream);
|
|
162
|
+
this.connection = connection;
|
|
163
|
+
void connection.closed.then(() => {
|
|
164
|
+
if (!this.disposing) {
|
|
165
|
+
this.failAll(new Error('Copilot ACP connection closed unexpectedly'));
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
try {
|
|
169
|
+
await connection.agent.request(this.runtime.methods.agent.initialize, {
|
|
170
|
+
protocolVersion: this.runtime.protocolVersion,
|
|
171
|
+
clientCapabilities: {},
|
|
172
|
+
clientInfo: {
|
|
173
|
+
name: '@borgee/agents-host',
|
|
174
|
+
version: '0.1.6',
|
|
175
|
+
},
|
|
50
176
|
});
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
const normalized = new Error(`Copilot ACP initialize failed: ${normalizeError(error).message}`);
|
|
180
|
+
this.failAll(normalized);
|
|
181
|
+
throw normalized;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
getOrCreateChannelState(channelId) {
|
|
185
|
+
let state = this.channels.get(channelId);
|
|
186
|
+
if (!state) {
|
|
187
|
+
state = {
|
|
188
|
+
idleTimerGeneration: 0,
|
|
189
|
+
processing: false,
|
|
190
|
+
queue: [],
|
|
191
|
+
};
|
|
192
|
+
this.channels.set(channelId, state);
|
|
193
|
+
}
|
|
194
|
+
return state;
|
|
195
|
+
}
|
|
196
|
+
processChannelQueue(channelId, state) {
|
|
197
|
+
if (state.processing) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
state.processing = true;
|
|
201
|
+
void (async () => {
|
|
202
|
+
try {
|
|
203
|
+
while (!this.fatalError) {
|
|
204
|
+
const turn = state.queue.shift();
|
|
205
|
+
if (!turn) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
state.activeTurn = turn;
|
|
209
|
+
try {
|
|
210
|
+
await this.ensureStarted();
|
|
211
|
+
const session = await this.getOrCreateSession(channelId, state);
|
|
212
|
+
let reply;
|
|
213
|
+
try {
|
|
214
|
+
reply = await this.runTurn(session, turn.prompt);
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
if (isSessionTainted(error)) {
|
|
218
|
+
this.invalidateSession(state, session);
|
|
219
|
+
this.rejectQueuedTurnsAfterSessionTaint(state, error);
|
|
220
|
+
}
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
turn.resolve(reply);
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
turn.reject(error);
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
state.activeTurn = undefined;
|
|
230
|
+
}
|
|
55
231
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
232
|
+
}
|
|
233
|
+
finally {
|
|
234
|
+
state.processing = false;
|
|
235
|
+
this.reconcileIdleChannelState(channelId, state);
|
|
236
|
+
}
|
|
237
|
+
})();
|
|
238
|
+
}
|
|
239
|
+
async getOrCreateSession(channelId, state) {
|
|
240
|
+
if (state.session) {
|
|
241
|
+
return state.session;
|
|
242
|
+
}
|
|
243
|
+
if (state.sessionPromise) {
|
|
244
|
+
return state.sessionPromise;
|
|
245
|
+
}
|
|
246
|
+
if (!this.connection) {
|
|
247
|
+
throw new Error('Copilot ACP connection is not available');
|
|
248
|
+
}
|
|
249
|
+
const sessionPromise = this.connection.agent.buildSession(this.runtime.cwd).start();
|
|
250
|
+
this.pendingSessionStarts.add(sessionPromise);
|
|
251
|
+
state.sessionPromise = sessionPromise;
|
|
252
|
+
let sessionAdopted = false;
|
|
253
|
+
void sessionPromise
|
|
254
|
+
.then((session) => {
|
|
255
|
+
if (!sessionAdopted &&
|
|
256
|
+
(this.fatalError !== null || this.disposing || state.sessionPromise !== sessionPromise)) {
|
|
257
|
+
this.closeSession(session);
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
.finally(() => {
|
|
261
|
+
this.pendingSessionStarts.delete(sessionPromise);
|
|
262
|
+
})
|
|
263
|
+
.catch(() => { });
|
|
264
|
+
try {
|
|
265
|
+
const session = await this.raceWithFatal(sessionPromise);
|
|
266
|
+
if (this.fatalError) {
|
|
267
|
+
this.closeSession(session);
|
|
268
|
+
throw this.fatalError;
|
|
269
|
+
}
|
|
270
|
+
sessionAdopted = true;
|
|
271
|
+
state.session = session;
|
|
272
|
+
return session;
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
state.session = undefined;
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
finally {
|
|
279
|
+
if (state.sessionPromise === sessionPromise) {
|
|
280
|
+
state.sessionPromise = undefined;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
async runTurn(session, prompt) {
|
|
285
|
+
const promptPromise = this.raceWithFatal(session.prompt(prompt));
|
|
286
|
+
const promptFailure = new Promise((_, reject) => {
|
|
287
|
+
void promptPromise.catch((error) => reject(markSessionTainted(error)));
|
|
288
|
+
});
|
|
289
|
+
let text = '';
|
|
290
|
+
for (;;) {
|
|
291
|
+
let update;
|
|
292
|
+
try {
|
|
293
|
+
update = await this.raceWithFatal(Promise.race([session.nextUpdate(), promptFailure]));
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
if (this.fatalError && error === this.fatalError) {
|
|
297
|
+
throw error;
|
|
60
298
|
}
|
|
61
|
-
|
|
62
|
-
}
|
|
299
|
+
throw markSessionTainted(error);
|
|
300
|
+
}
|
|
301
|
+
if (update.kind === 'stop') {
|
|
302
|
+
let response;
|
|
303
|
+
try {
|
|
304
|
+
response = await promptPromise;
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
throw markSessionTainted(error);
|
|
308
|
+
}
|
|
309
|
+
if (response.stopReason !== 'end_turn') {
|
|
310
|
+
throw new Error(`Copilot ACP turn stopped with stopReason "${response.stopReason}"`);
|
|
311
|
+
}
|
|
312
|
+
const output = text.trim();
|
|
313
|
+
if (!output) {
|
|
314
|
+
throw new Error('Copilot ACP returned empty output');
|
|
315
|
+
}
|
|
316
|
+
return output;
|
|
317
|
+
}
|
|
318
|
+
if (update.update.sessionUpdate === 'agent_message_chunk' &&
|
|
319
|
+
update.update.content.type === 'text') {
|
|
320
|
+
text += update.update.content.text;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
async raceWithFatal(promise) {
|
|
325
|
+
if (this.fatalError) {
|
|
326
|
+
throw this.fatalError;
|
|
327
|
+
}
|
|
328
|
+
return Promise.race([promise, this.fatalPromise]);
|
|
329
|
+
}
|
|
330
|
+
failAll(error) {
|
|
331
|
+
if (this.fatalError) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
this.fatalError = error;
|
|
335
|
+
this.rejectFatalPromise(error);
|
|
336
|
+
for (const [channelId, state] of this.channels.entries()) {
|
|
337
|
+
this.clearIdleTimer(state);
|
|
338
|
+
state.activeTurn?.reject(error);
|
|
339
|
+
for (const turn of state.queue) {
|
|
340
|
+
turn.reject(error);
|
|
341
|
+
}
|
|
342
|
+
state.queue.length = 0;
|
|
343
|
+
if (state.session) {
|
|
344
|
+
this.closeSession(state.session);
|
|
345
|
+
}
|
|
346
|
+
state.session = undefined;
|
|
347
|
+
state.sessionPromise = undefined;
|
|
348
|
+
state.activeTurn = undefined;
|
|
349
|
+
if (!state.processing) {
|
|
350
|
+
this.channels.delete(channelId);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
this.channels.clear();
|
|
354
|
+
this.shutdownPromise = this.shutdownBackend(error);
|
|
355
|
+
}
|
|
356
|
+
invalidateSession(state, session) {
|
|
357
|
+
this.clearIdleTimer(state);
|
|
358
|
+
if (state.session === session) {
|
|
359
|
+
state.session = undefined;
|
|
360
|
+
}
|
|
361
|
+
this.closeSession(session);
|
|
362
|
+
}
|
|
363
|
+
closeSession(session) {
|
|
364
|
+
if (this.closingSessions.has(session)) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
this.closingSessions.add(session);
|
|
368
|
+
const agent = this.connection?.agent;
|
|
369
|
+
const closeMethod = this.runtime.methods.agent.session?.close;
|
|
370
|
+
const closePromise = typeof agent?.closeSession === 'function'
|
|
371
|
+
? agent.closeSession({ sessionId: session.sessionId })
|
|
372
|
+
: closeMethod
|
|
373
|
+
? this.connection?.agent.request(closeMethod, { sessionId: session.sessionId })
|
|
374
|
+
: undefined;
|
|
375
|
+
session.dispose();
|
|
376
|
+
if (!closePromise) {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
let trackedClosePromise;
|
|
380
|
+
trackedClosePromise = Promise.resolve(closePromise)
|
|
381
|
+
.then(() => undefined)
|
|
382
|
+
.catch(() => { })
|
|
383
|
+
.finally(() => {
|
|
384
|
+
this.pendingSessionCloses.delete(trackedClosePromise);
|
|
385
|
+
});
|
|
386
|
+
this.pendingSessionCloses.add(trackedClosePromise);
|
|
387
|
+
}
|
|
388
|
+
rejectQueuedTurnsAfterSessionTaint(state, error) {
|
|
389
|
+
if (state.queue.length === 0) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const rejection = new Error(QUEUED_TURN_DROPPED_MESSAGE, {
|
|
393
|
+
cause: normalizeError(error),
|
|
63
394
|
});
|
|
395
|
+
for (const turn of state.queue) {
|
|
396
|
+
turn.reject(rejection);
|
|
397
|
+
}
|
|
398
|
+
state.queue.length = 0;
|
|
399
|
+
}
|
|
400
|
+
clearIdleTimer(state) {
|
|
401
|
+
state.idleTimerGeneration += 1;
|
|
402
|
+
if (!state.idleTimer) {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
clearTimeout(state.idleTimer);
|
|
406
|
+
state.idleTimer = undefined;
|
|
407
|
+
}
|
|
408
|
+
reconcileIdleChannelState(channelId, state) {
|
|
409
|
+
if (this.channels.get(channelId) !== state) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (this.disposing || this.fatalError || this.backendClosed) {
|
|
413
|
+
this.clearIdleTimer(state);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (state.activeTurn || state.queue.length > 0 || state.sessionPromise || state.processing) {
|
|
417
|
+
this.clearIdleTimer(state);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (!state.session) {
|
|
421
|
+
this.clearIdleTimer(state);
|
|
422
|
+
this.channels.delete(channelId);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
this.clearIdleTimer(state);
|
|
426
|
+
const generation = state.idleTimerGeneration;
|
|
427
|
+
state.idleTimer = setTimeout(() => {
|
|
428
|
+
this.evictIdleChannel(channelId, state, generation);
|
|
429
|
+
}, this.runtime.idleSessionTtlMs);
|
|
430
|
+
}
|
|
431
|
+
evictIdleChannel(channelId, state, generation) {
|
|
432
|
+
if (this.disposing || this.fatalError || this.backendClosed) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (this.channels.get(channelId) !== state || state.idleTimerGeneration !== generation) {
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
state.idleTimer = undefined;
|
|
439
|
+
if (state.activeTurn || state.queue.length > 0 || state.sessionPromise || state.processing) {
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
const session = state.session;
|
|
443
|
+
state.session = undefined;
|
|
444
|
+
if (session) {
|
|
445
|
+
this.closeSession(session);
|
|
446
|
+
}
|
|
447
|
+
this.channels.delete(channelId);
|
|
448
|
+
}
|
|
449
|
+
async shutdownBackend(error) {
|
|
450
|
+
if (this.backendClosed) {
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
this.backendClosed = true;
|
|
454
|
+
const connection = this.connection;
|
|
455
|
+
const child = this.child;
|
|
456
|
+
await this.waitForPendingSessionStarts();
|
|
457
|
+
await this.waitForPendingSessionCloses();
|
|
458
|
+
connection?.close(error);
|
|
459
|
+
this.connection = undefined;
|
|
460
|
+
if (!child) {
|
|
461
|
+
this.child = undefined;
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const childExitPromise = this.childExitPromise ?? Promise.resolve();
|
|
465
|
+
child.kill('SIGTERM');
|
|
466
|
+
const exitedAfterTerm = await this.waitForChildExit(childExitPromise, this.runtime.shutdownGracePeriodMs);
|
|
467
|
+
if (exitedAfterTerm) {
|
|
468
|
+
this.child = undefined;
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
child.kill('SIGKILL');
|
|
472
|
+
await this.waitForChildExit(childExitPromise, this.runtime.shutdownForceKillWaitMs);
|
|
473
|
+
this.child = undefined;
|
|
474
|
+
}
|
|
475
|
+
async waitForPendingSessionStarts() {
|
|
476
|
+
if (this.pendingSessionStarts.size === 0) {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
await Promise.race([
|
|
480
|
+
Promise.allSettled([...this.pendingSessionStarts]).then(() => undefined),
|
|
481
|
+
new Promise((resolve) => {
|
|
482
|
+
setTimeout(resolve, this.runtime.shutdownGracePeriodMs);
|
|
483
|
+
}),
|
|
484
|
+
]);
|
|
485
|
+
}
|
|
486
|
+
async waitForPendingSessionCloses() {
|
|
487
|
+
if (this.pendingSessionCloses.size === 0) {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
await Promise.race([
|
|
491
|
+
Promise.allSettled([...this.pendingSessionCloses]).then(() => undefined),
|
|
492
|
+
new Promise((resolve) => {
|
|
493
|
+
setTimeout(resolve, this.runtime.shutdownGracePeriodMs);
|
|
494
|
+
}),
|
|
495
|
+
]);
|
|
496
|
+
}
|
|
497
|
+
async waitForChildExit(childExitPromise, timeoutMs) {
|
|
498
|
+
if (timeoutMs <= 0) {
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
return Promise.race([
|
|
502
|
+
childExitPromise.then(() => true),
|
|
503
|
+
new Promise((resolve) => {
|
|
504
|
+
setTimeout(() => resolve(false), timeoutMs);
|
|
505
|
+
}),
|
|
506
|
+
]);
|
|
64
507
|
}
|
|
65
508
|
}
|
|
@@ -9,7 +9,9 @@ export function createProvider(config) {
|
|
|
9
9
|
return new ClaudeProviderAdapter(cli);
|
|
10
10
|
}
|
|
11
11
|
case 'copilot': {
|
|
12
|
-
const cli = new CopilotCliClient(config.copilotCommand, config.copilotArgs
|
|
12
|
+
const cli = new CopilotCliClient(config.copilotCommand, config.copilotArgs, {
|
|
13
|
+
idleSessionTtlMs: config.copilotSessionTtlMinutes * 60 * 1000,
|
|
14
|
+
});
|
|
13
15
|
return new CopilotProviderAdapter(cli);
|
|
14
16
|
}
|
|
15
17
|
default:
|