@makefinks/daemon 0.9.1 → 0.11.0
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 +60 -14
- package/package.json +4 -2
- package/src/ai/copilot-client.ts +775 -0
- package/src/ai/daemon-ai.ts +32 -234
- package/src/ai/model-config.ts +55 -14
- package/src/ai/providers/capabilities.ts +16 -0
- package/src/ai/providers/copilot-provider.ts +632 -0
- package/src/ai/providers/openrouter-provider.ts +217 -0
- package/src/ai/providers/registry.ts +14 -0
- package/src/ai/providers/types.ts +31 -0
- package/src/ai/system-prompt.ts +16 -0
- package/src/ai/tools/subagents.ts +1 -1
- package/src/ai/tools/tool-registry.ts +22 -1
- package/src/ai/tools/write-file.ts +51 -0
- package/src/app/components/AppOverlays.tsx +9 -1
- package/src/app/components/ConversationPane.tsx +8 -2
- package/src/components/ModelMenu.tsx +202 -140
- package/src/components/OnboardingOverlay.tsx +147 -1
- package/src/components/SettingsMenu.tsx +27 -1
- package/src/components/TokenUsageDisplay.tsx +5 -3
- package/src/components/tool-layouts/layouts/index.ts +1 -0
- package/src/components/tool-layouts/layouts/write-file.tsx +117 -0
- package/src/hooks/daemon-event-handlers.ts +61 -14
- package/src/hooks/keyboard-handlers.ts +109 -28
- package/src/hooks/use-app-callbacks.ts +141 -43
- package/src/hooks/use-app-context-builder.ts +5 -0
- package/src/hooks/use-app-controller.ts +31 -2
- package/src/hooks/use-app-copilot-models-loader.ts +45 -0
- package/src/hooks/use-app-display-state.ts +24 -2
- package/src/hooks/use-app-model.ts +103 -17
- package/src/hooks/use-app-preferences-bootstrap.ts +54 -10
- package/src/hooks/use-bootstrap-controller.ts +5 -0
- package/src/hooks/use-daemon-events.ts +8 -2
- package/src/hooks/use-daemon-keyboard.ts +19 -6
- package/src/hooks/use-daemon-runtime-controller.ts +4 -0
- package/src/hooks/use-menu-keyboard.ts +6 -1
- package/src/state/app-context.tsx +6 -0
- package/src/types/index.ts +24 -1
- package/src/utils/copilot-models.ts +77 -0
- package/src/utils/preferences.ts +3 -0
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
import { CopilotClient, defineTool } from "@github/copilot-sdk";
|
|
2
|
+
import type {
|
|
3
|
+
CopilotClientOptions,
|
|
4
|
+
CopilotSession,
|
|
5
|
+
GetAuthStatusResponse,
|
|
6
|
+
ModelInfo,
|
|
7
|
+
SessionConfig,
|
|
8
|
+
Tool as CopilotTool,
|
|
9
|
+
ToolResultObject,
|
|
10
|
+
ToolInvocation,
|
|
11
|
+
} from "@github/copilot-sdk";
|
|
12
|
+
import type { ToolSet } from "ai";
|
|
13
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
19
|
+
import type { ZodTypeAny } from "zod";
|
|
20
|
+
import type { StreamCallbacks, ToolApprovalRequest, ToolApprovalResponse } from "../types";
|
|
21
|
+
import { debug } from "../utils/debug-logger";
|
|
22
|
+
|
|
23
|
+
interface CopilotClientRuntime {
|
|
24
|
+
fingerprint: string;
|
|
25
|
+
client: CopilotClient;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let runtime: CopilotClientRuntime | null = null;
|
|
29
|
+
let startupPromise: Promise<CopilotClient> | null = null;
|
|
30
|
+
const modelScopedSessionIdByKey = new Map<string, string>();
|
|
31
|
+
|
|
32
|
+
const DEFAULT_CLIENT_START_TIMEOUT_MS = 15000;
|
|
33
|
+
const DEFAULT_AUTH_STATUS_TIMEOUT_MS = 10000;
|
|
34
|
+
const DEFAULT_SESSION_TIMEOUT_MS = 15000;
|
|
35
|
+
const DEFAULT_MODEL_LIST_TIMEOUT_MS = 15000;
|
|
36
|
+
const GH_AUTH_CACHE_TTL_MS = 30000;
|
|
37
|
+
const BUN_RUNTIME_NAME = "bun";
|
|
38
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
39
|
+
|
|
40
|
+
const KNOWN_CA_BUNDLE_PATHS = [
|
|
41
|
+
"/etc/ssl/cert.pem",
|
|
42
|
+
"/etc/ssl/certs/ca-certificates.crt",
|
|
43
|
+
"/etc/pki/tls/certs/ca-bundle.crt",
|
|
44
|
+
"/opt/homebrew/etc/openssl@3/cert.pem",
|
|
45
|
+
"/usr/local/etc/openssl@3/cert.pem",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
let ghAuthStatusCache: { checkedAt: number; authenticated: boolean } | null = null;
|
|
49
|
+
let cachedGitSslCaInfo: string | null = null;
|
|
50
|
+
|
|
51
|
+
function parseBooleanFlag(value: string | undefined, fallback: boolean): boolean {
|
|
52
|
+
if (!value) return fallback;
|
|
53
|
+
const normalized = value.trim().toLowerCase();
|
|
54
|
+
if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") return true;
|
|
55
|
+
if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off")
|
|
56
|
+
return false;
|
|
57
|
+
return fallback;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isBunRuntime(): boolean {
|
|
61
|
+
return typeof process.versions[BUN_RUNTIME_NAME] === "string";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseTimeoutMs(value: string | undefined, fallback: number): number {
|
|
65
|
+
if (!value) return fallback;
|
|
66
|
+
const parsed = Number.parseInt(value, 10);
|
|
67
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
|
68
|
+
return parsed;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isUuid(value: string | null | undefined): boolean {
|
|
72
|
+
if (!value) return false;
|
|
73
|
+
return UUID_PATTERN.test(value.trim());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeCopilotSessionId(sessionId: string): string {
|
|
77
|
+
const trimmed = sessionId.trim();
|
|
78
|
+
if (isUuid(trimmed)) return trimmed;
|
|
79
|
+
const generated = randomUUID();
|
|
80
|
+
debug.warn("copilot-session-id-normalized", {
|
|
81
|
+
originalSessionId: sessionId,
|
|
82
|
+
normalizedSessionId: generated,
|
|
83
|
+
});
|
|
84
|
+
return generated;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function resolveModelScopedSessionId(sessionId: string, modelId: string | undefined): string {
|
|
88
|
+
const trimmedSessionId = sessionId.trim();
|
|
89
|
+
const trimmedModelId = modelId?.trim();
|
|
90
|
+
if (!trimmedModelId) {
|
|
91
|
+
return trimmedSessionId;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const key = `${trimmedSessionId}::${trimmedModelId}`;
|
|
95
|
+
const existing = modelScopedSessionIdByKey.get(key);
|
|
96
|
+
if (existing) {
|
|
97
|
+
return existing;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const generated = randomUUID();
|
|
101
|
+
modelScopedSessionIdByKey.set(key, generated);
|
|
102
|
+
debug.info("copilot-model-scoped-session-created", {
|
|
103
|
+
sessionId: trimmedSessionId,
|
|
104
|
+
modelId: trimmedModelId,
|
|
105
|
+
scopedSessionId: generated,
|
|
106
|
+
});
|
|
107
|
+
return generated;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isModelListFailure(error: Error): boolean {
|
|
111
|
+
const message = error.message.toLowerCase();
|
|
112
|
+
return (
|
|
113
|
+
message.includes("failed to list models") ||
|
|
114
|
+
message.includes("failed to list available models") ||
|
|
115
|
+
message.includes("models.list")
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function supportsReasoningEffort(model: ModelInfo | undefined): boolean {
|
|
120
|
+
return model?.capabilities?.supports?.reasoningEffort === true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function normalizeSessionConfigForModel(
|
|
124
|
+
config: Omit<SessionConfig, "sessionId">,
|
|
125
|
+
models: ModelInfo[]
|
|
126
|
+
): Omit<SessionConfig, "sessionId"> {
|
|
127
|
+
if (!config.reasoningEffort) {
|
|
128
|
+
return config;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const modelId = typeof config.model === "string" ? config.model.trim() : "";
|
|
132
|
+
if (!modelId) {
|
|
133
|
+
return config;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const selectedModel = models.find((model) => model.id === modelId);
|
|
137
|
+
if (supportsReasoningEffort(selectedModel)) {
|
|
138
|
+
return config;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
debug.warn("copilot-reasoning-effort-omitted", {
|
|
142
|
+
model: modelId,
|
|
143
|
+
reasoningEffort: config.reasoningEffort,
|
|
144
|
+
modelFound: Boolean(selectedModel),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
...config,
|
|
149
|
+
reasoningEffort: undefined,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function resolveNodeExecutablePath(): string | undefined {
|
|
154
|
+
const envOverride = process.env.COPILOT_NODE_PATH?.trim();
|
|
155
|
+
if (envOverride && existsSync(envOverride)) {
|
|
156
|
+
return envOverride;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const probe = spawnSync("node", ["-p", "process.execPath"], {
|
|
160
|
+
encoding: "utf8",
|
|
161
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
162
|
+
timeout: 2000,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
if (probe.status !== 0) return undefined;
|
|
166
|
+
const candidate = probe.stdout.trim();
|
|
167
|
+
if (!candidate) return undefined;
|
|
168
|
+
return existsSync(candidate) ? candidate : undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function resolveGitSslCaInfo(): string | undefined {
|
|
172
|
+
if (cachedGitSslCaInfo !== null) {
|
|
173
|
+
return cachedGitSslCaInfo || undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const probe = spawnSync("git", ["config", "--get", "http.sslCAInfo"], {
|
|
177
|
+
encoding: "utf8",
|
|
178
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
179
|
+
timeout: 2000,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
if (probe.status !== 0) {
|
|
183
|
+
cachedGitSslCaInfo = "";
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const candidate = probe.stdout.trim();
|
|
188
|
+
cachedGitSslCaInfo = candidate;
|
|
189
|
+
return candidate || undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function resolveAutoDetectedCaBundlePath(): string | undefined {
|
|
193
|
+
const candidates = [
|
|
194
|
+
process.env.COPILOT_NODE_EXTRA_CA_CERTS?.trim(),
|
|
195
|
+
process.env.NODE_EXTRA_CA_CERTS?.trim(),
|
|
196
|
+
process.env.SSL_CERT_FILE?.trim(),
|
|
197
|
+
process.env.CURL_CA_BUNDLE?.trim(),
|
|
198
|
+
resolveGitSslCaInfo(),
|
|
199
|
+
...KNOWN_CA_BUNDLE_PATHS,
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
for (const candidate of candidates) {
|
|
203
|
+
if (!candidate) continue;
|
|
204
|
+
if (existsSync(candidate)) {
|
|
205
|
+
return candidate;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function resolveBundledCopilotCliPath(): string | undefined {
|
|
213
|
+
try {
|
|
214
|
+
const sdkUrl = import.meta.resolve("@github/copilot/sdk");
|
|
215
|
+
const sdkPath = fileURLToPath(sdkUrl);
|
|
216
|
+
const cliPath = join(dirname(dirname(sdkPath)), "index.js");
|
|
217
|
+
return existsSync(cliPath) ? cliPath : undefined;
|
|
218
|
+
} catch {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function resolveRuntimeCliOverrides(): Pick<CopilotClientOptions, "cliArgs" | "cliPath"> {
|
|
224
|
+
const explicitCliPath = process.env.COPILOT_CLI_PATH?.trim();
|
|
225
|
+
if (explicitCliPath) {
|
|
226
|
+
return { cliPath: explicitCliPath };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Copilot SDK defaults to spawning the bundled CLI JS with process.execPath.
|
|
230
|
+
// Under Bun, that means executing the CLI with Bun, which can hang auth/session RPC calls.
|
|
231
|
+
if (!isBunRuntime()) {
|
|
232
|
+
return {};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const nodePath = resolveNodeExecutablePath();
|
|
236
|
+
const bundledCliPath = resolveBundledCopilotCliPath();
|
|
237
|
+
if (!nodePath || !bundledCliPath) {
|
|
238
|
+
return {};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
cliPath: nodePath,
|
|
243
|
+
cliArgs: [bundledCliPath],
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function resolveCopilotAuthConfig(): Pick<CopilotClientOptions, "useLoggedInUser"> {
|
|
248
|
+
// DAEMON uses logged-in-user mode only for Copilot auth.
|
|
249
|
+
return {
|
|
250
|
+
useLoggedInUser: true,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function buildCopilotChildEnv(
|
|
255
|
+
authConfig: Pick<CopilotClientOptions, "useLoggedInUser">
|
|
256
|
+
): Record<string, string | undefined> {
|
|
257
|
+
const env = { ...process.env };
|
|
258
|
+
if (authConfig.useLoggedInUser) {
|
|
259
|
+
// Prevent explicit Copilot SDK token env from overriding logged-in-user auth.
|
|
260
|
+
env.COPILOT_SDK_AUTH_TOKEN = undefined;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const useSystemCa = parseBooleanFlag(process.env.COPILOT_USE_SYSTEM_CA, true);
|
|
264
|
+
if (useSystemCa) {
|
|
265
|
+
const existingNodeOptions = env.NODE_OPTIONS?.trim();
|
|
266
|
+
const hasSystemCaFlag = Boolean(existingNodeOptions?.split(/\s+/).includes("--use-system-ca"));
|
|
267
|
+
if (!hasSystemCaFlag) {
|
|
268
|
+
env.NODE_OPTIONS = existingNodeOptions ? `${existingNodeOptions} --use-system-ca` : "--use-system-ca";
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const autoDetectCa = parseBooleanFlag(process.env.COPILOT_AUTO_DETECT_CA_CERTS, true);
|
|
273
|
+
const extraCaPath =
|
|
274
|
+
process.env.COPILOT_NODE_EXTRA_CA_CERTS?.trim() ??
|
|
275
|
+
(autoDetectCa ? resolveAutoDetectedCaBundlePath() : undefined);
|
|
276
|
+
if (extraCaPath && !env.NODE_EXTRA_CA_CERTS) {
|
|
277
|
+
env.NODE_EXTRA_CA_CERTS = extraCaPath;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return env;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
|
284
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
285
|
+
try {
|
|
286
|
+
return await Promise.race([
|
|
287
|
+
promise,
|
|
288
|
+
new Promise<T>((_, reject) => {
|
|
289
|
+
timeoutId = setTimeout(() => {
|
|
290
|
+
reject(new Error(message));
|
|
291
|
+
}, timeoutMs);
|
|
292
|
+
}),
|
|
293
|
+
]);
|
|
294
|
+
} finally {
|
|
295
|
+
if (timeoutId) {
|
|
296
|
+
clearTimeout(timeoutId);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function hasGhCliAuth(): Promise<boolean> {
|
|
302
|
+
const now = Date.now();
|
|
303
|
+
if (ghAuthStatusCache && now - ghAuthStatusCache.checkedAt < GH_AUTH_CACHE_TTL_MS) {
|
|
304
|
+
return ghAuthStatusCache.authenticated;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const authenticated = await new Promise<boolean>((resolve) => {
|
|
308
|
+
let settled = false;
|
|
309
|
+
const finish = (value: boolean) => {
|
|
310
|
+
if (settled) return;
|
|
311
|
+
settled = true;
|
|
312
|
+
resolve(value);
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const child = spawn("gh", ["auth", "status"], {
|
|
316
|
+
stdio: "ignore",
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const timeout = setTimeout(() => {
|
|
320
|
+
child.kill();
|
|
321
|
+
finish(false);
|
|
322
|
+
}, 2000);
|
|
323
|
+
|
|
324
|
+
child.on("error", () => {
|
|
325
|
+
clearTimeout(timeout);
|
|
326
|
+
finish(false);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
child.on("exit", (code) => {
|
|
330
|
+
clearTimeout(timeout);
|
|
331
|
+
finish(code === 0);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
ghAuthStatusCache = {
|
|
336
|
+
checkedAt: now,
|
|
337
|
+
authenticated,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
return authenticated;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export async function hasCopilotCliAuthSafe(): Promise<boolean> {
|
|
344
|
+
try {
|
|
345
|
+
return await hasGhCliAuth();
|
|
346
|
+
} catch {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function buildClientOptions(): CopilotClientOptions {
|
|
352
|
+
const authConfig = resolveCopilotAuthConfig();
|
|
353
|
+
const cliOverrides = resolveRuntimeCliOverrides();
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
...cliOverrides,
|
|
357
|
+
env: buildCopilotChildEnv(authConfig),
|
|
358
|
+
useLoggedInUser: authConfig.useLoggedInUser,
|
|
359
|
+
autoStart: false,
|
|
360
|
+
autoRestart: true,
|
|
361
|
+
logLevel: "warning",
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function getFingerprint(options: CopilotClientOptions): string {
|
|
366
|
+
return JSON.stringify({
|
|
367
|
+
cliPath: options.cliPath ?? "default",
|
|
368
|
+
cliArgs: options.cliArgs ?? [],
|
|
369
|
+
useLoggedInUser: options.useLoggedInUser ?? true,
|
|
370
|
+
nodeOptions: options.env?.NODE_OPTIONS ?? null,
|
|
371
|
+
extraCaCerts: options.env?.NODE_EXTRA_CA_CERTS ?? null,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function stopRuntimeClientIfPresent(): Promise<void> {
|
|
376
|
+
if (!runtime) return;
|
|
377
|
+
try {
|
|
378
|
+
await runtime.client.stop();
|
|
379
|
+
} catch {
|
|
380
|
+
// Ignore shutdown errors.
|
|
381
|
+
} finally {
|
|
382
|
+
runtime = null;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function ensureClient(): Promise<CopilotClient> {
|
|
387
|
+
const options = buildClientOptions();
|
|
388
|
+
const nextFingerprint = getFingerprint(options);
|
|
389
|
+
|
|
390
|
+
if (runtime && runtime.fingerprint === nextFingerprint) {
|
|
391
|
+
return runtime.client;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (startupPromise) {
|
|
395
|
+
return startupPromise;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
startupPromise = (async () => {
|
|
399
|
+
await stopRuntimeClientIfPresent();
|
|
400
|
+
|
|
401
|
+
debug.info("copilot-client-start", {
|
|
402
|
+
useLoggedInUser: options.useLoggedInUser ?? true,
|
|
403
|
+
cliPath: options.cliPath ?? "default",
|
|
404
|
+
cliArgs: options.cliArgs ?? [],
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const client = new CopilotClient(options);
|
|
408
|
+
try {
|
|
409
|
+
await withTimeout(
|
|
410
|
+
client.start(),
|
|
411
|
+
parseTimeoutMs(process.env.COPILOT_CLIENT_START_TIMEOUT_MS, DEFAULT_CLIENT_START_TIMEOUT_MS),
|
|
412
|
+
"Timed out while starting Copilot client."
|
|
413
|
+
);
|
|
414
|
+
} catch (error) {
|
|
415
|
+
await client.stop().catch(() => {});
|
|
416
|
+
throw error;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
runtime = {
|
|
420
|
+
fingerprint: nextFingerprint,
|
|
421
|
+
client,
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
return client;
|
|
425
|
+
})()
|
|
426
|
+
.catch((error) => {
|
|
427
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
428
|
+
debug.error("copilot-client-start-failed", { message: err.message });
|
|
429
|
+
throw err;
|
|
430
|
+
})
|
|
431
|
+
.finally(() => {
|
|
432
|
+
startupPromise = null;
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
return startupPromise;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export async function resetCopilotClient(): Promise<void> {
|
|
439
|
+
await stopRuntimeClientIfPresent();
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export async function getCopilotAuthStatusSafe(): Promise<GetAuthStatusResponse & { error?: string }> {
|
|
443
|
+
try {
|
|
444
|
+
const timeoutMs = parseTimeoutMs(process.env.COPILOT_AUTH_TIMEOUT_MS, DEFAULT_AUTH_STATUS_TIMEOUT_MS);
|
|
445
|
+
const client = await withTimeout(
|
|
446
|
+
ensureClient(),
|
|
447
|
+
timeoutMs,
|
|
448
|
+
"Timed out while preparing Copilot authentication."
|
|
449
|
+
);
|
|
450
|
+
const status = await withTimeout(
|
|
451
|
+
client.getAuthStatus(),
|
|
452
|
+
timeoutMs,
|
|
453
|
+
"Timed out while checking Copilot authentication."
|
|
454
|
+
);
|
|
455
|
+
if (!status.isAuthenticated) {
|
|
456
|
+
const hasGhAuth = await hasGhCliAuth();
|
|
457
|
+
const statusMessage =
|
|
458
|
+
typeof status.statusMessage === "string" && status.statusMessage.trim().length > 0
|
|
459
|
+
? status.statusMessage
|
|
460
|
+
: hasGhAuth
|
|
461
|
+
? "GitHub CLI is authenticated, but Copilot SDK is not. Complete Copilot sign-in and retry."
|
|
462
|
+
: "Copilot SDK is not authenticated.";
|
|
463
|
+
return {
|
|
464
|
+
...status,
|
|
465
|
+
statusMessage,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
return status;
|
|
469
|
+
} catch (error) {
|
|
470
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
471
|
+
return {
|
|
472
|
+
isAuthenticated: false,
|
|
473
|
+
statusMessage: err.message,
|
|
474
|
+
error: err.message,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export async function listCopilotModelsSafe(): Promise<ModelInfo[]> {
|
|
480
|
+
const timeoutMs = parseTimeoutMs(process.env.COPILOT_MODELS_TIMEOUT_MS, DEFAULT_MODEL_LIST_TIMEOUT_MS);
|
|
481
|
+
|
|
482
|
+
let lastError: Error | null = null;
|
|
483
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
484
|
+
const client = await withTimeout(
|
|
485
|
+
ensureClient(),
|
|
486
|
+
timeoutMs,
|
|
487
|
+
"Timed out while preparing Copilot model listing."
|
|
488
|
+
);
|
|
489
|
+
try {
|
|
490
|
+
const authStatus = await withTimeout(
|
|
491
|
+
client.getAuthStatus(),
|
|
492
|
+
timeoutMs,
|
|
493
|
+
"Timed out while validating Copilot authentication before model listing."
|
|
494
|
+
);
|
|
495
|
+
if (!authStatus.isAuthenticated) {
|
|
496
|
+
const statusMessage =
|
|
497
|
+
typeof authStatus.statusMessage === "string" && authStatus.statusMessage.trim().length > 0
|
|
498
|
+
? authStatus.statusMessage
|
|
499
|
+
: "Copilot SDK is not authenticated.";
|
|
500
|
+
throw new Error(
|
|
501
|
+
`Copilot SDK is not authenticated while listing models: ${statusMessage}. Authenticate via GitHub and retry.`
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return await withTimeout(client.listModels(), timeoutMs, "Timed out while listing Copilot models.");
|
|
506
|
+
} catch (error) {
|
|
507
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
508
|
+
lastError = err;
|
|
509
|
+
if (attempt === 0 && isModelListFailure(err)) {
|
|
510
|
+
debug.warn("copilot-model-list-retry", { message: err.message });
|
|
511
|
+
await resetCopilotClient();
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
throw err;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
throw lastError ?? new Error("Failed to list Copilot models.");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export async function getOrCreateCopilotSession(
|
|
522
|
+
sessionId: string,
|
|
523
|
+
config: Omit<SessionConfig, "sessionId">
|
|
524
|
+
): Promise<{ session: CopilotSession; created: boolean }> {
|
|
525
|
+
// Warm model cache and fail fast with an explicit auth/models error before session RPCs.
|
|
526
|
+
const models = await listCopilotModelsSafe();
|
|
527
|
+
const normalizedConfig = normalizeSessionConfigForModel(config, models);
|
|
528
|
+
const requestedModelId =
|
|
529
|
+
typeof normalizedConfig.model === "string" && normalizedConfig.model.trim().length > 0
|
|
530
|
+
? normalizedConfig.model.trim()
|
|
531
|
+
: undefined;
|
|
532
|
+
|
|
533
|
+
const timeoutMs = parseTimeoutMs(process.env.COPILOT_SESSION_TIMEOUT_MS, DEFAULT_SESSION_TIMEOUT_MS);
|
|
534
|
+
const scopedSessionId = resolveModelScopedSessionId(sessionId, requestedModelId);
|
|
535
|
+
const normalizedSessionId = normalizeCopilotSessionId(scopedSessionId);
|
|
536
|
+
|
|
537
|
+
let lastError: Error | null = null;
|
|
538
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
539
|
+
const client = await withTimeout(ensureClient(), timeoutMs, "Timed out while preparing Copilot session.");
|
|
540
|
+
let resumeError: Error | null = null;
|
|
541
|
+
|
|
542
|
+
try {
|
|
543
|
+
const session = await withTimeout(
|
|
544
|
+
client.resumeSession(normalizedSessionId, normalizedConfig),
|
|
545
|
+
timeoutMs,
|
|
546
|
+
"Timed out while resuming Copilot session."
|
|
547
|
+
);
|
|
548
|
+
return { session, created: false };
|
|
549
|
+
} catch (error) {
|
|
550
|
+
resumeError = error instanceof Error ? error : new Error(String(error));
|
|
551
|
+
debug.error("copilot-session-resume-failed", {
|
|
552
|
+
sessionId: normalizedSessionId,
|
|
553
|
+
message: resumeError.message,
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
if (attempt === 0 && isModelListFailure(resumeError)) {
|
|
557
|
+
debug.warn("copilot-session-resume-retry-after-model-list-failure", {
|
|
558
|
+
message: resumeError.message,
|
|
559
|
+
});
|
|
560
|
+
await resetCopilotClient();
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const resumeFailure = resumeError ?? new Error("Unknown Copilot session resume failure.");
|
|
566
|
+
const fallbackSessionId = resumeFailure.message.includes("Timed out while resuming Copilot session.")
|
|
567
|
+
? randomUUID()
|
|
568
|
+
: normalizedSessionId;
|
|
569
|
+
|
|
570
|
+
try {
|
|
571
|
+
const session = await withTimeout(
|
|
572
|
+
client.createSession({
|
|
573
|
+
...normalizedConfig,
|
|
574
|
+
sessionId: fallbackSessionId,
|
|
575
|
+
}),
|
|
576
|
+
timeoutMs,
|
|
577
|
+
"Timed out while creating Copilot session."
|
|
578
|
+
);
|
|
579
|
+
return { session, created: true };
|
|
580
|
+
} catch (error) {
|
|
581
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
582
|
+
err.message = `${err.message} (resume fallback: ${resumeFailure.message})`;
|
|
583
|
+
lastError = err;
|
|
584
|
+
if (attempt === 0 && isModelListFailure(err)) {
|
|
585
|
+
debug.warn("copilot-session-create-retry-after-model-list-failure", {
|
|
586
|
+
message: err.message,
|
|
587
|
+
});
|
|
588
|
+
await resetCopilotClient();
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
throw err;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
throw lastError ?? new Error("Failed to create Copilot session.");
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function toFailureResult(
|
|
599
|
+
message: string,
|
|
600
|
+
error?: string,
|
|
601
|
+
resultType: ToolResultObject["resultType"] = "failure"
|
|
602
|
+
) {
|
|
603
|
+
return {
|
|
604
|
+
textResultForLlm: message,
|
|
605
|
+
resultType,
|
|
606
|
+
error,
|
|
607
|
+
toolTelemetry: {},
|
|
608
|
+
} satisfies ToolResultObject;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async function resolveApproval(
|
|
612
|
+
callbacks: StreamCallbacks,
|
|
613
|
+
request: ToolApprovalRequest
|
|
614
|
+
): Promise<{ approved: boolean; reason?: string }> {
|
|
615
|
+
callbacks.onToolApprovalRequest?.(request);
|
|
616
|
+
|
|
617
|
+
if (!callbacks.onAwaitingApprovals) {
|
|
618
|
+
return {
|
|
619
|
+
approved: false,
|
|
620
|
+
reason: "Tool execution was denied because no approval handler is registered.",
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const responses = await new Promise<ToolApprovalResponse[]>((resolve) => {
|
|
625
|
+
callbacks.onAwaitingApprovals?.([request], (value) => resolve(value));
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
const response = responses.find((item) => item.approvalId === request.approvalId);
|
|
629
|
+
if (!response) {
|
|
630
|
+
return {
|
|
631
|
+
approved: false,
|
|
632
|
+
reason: "Tool execution was denied because no approval decision was returned.",
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return {
|
|
637
|
+
approved: response.approved,
|
|
638
|
+
reason: response.reason,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function toJsonSchema(parameters: unknown): Record<string, unknown> | undefined {
|
|
643
|
+
if (!parameters || typeof parameters !== "object") return undefined;
|
|
644
|
+
|
|
645
|
+
if ("toJSONSchema" in parameters && typeof parameters.toJSONSchema === "function") {
|
|
646
|
+
try {
|
|
647
|
+
return parameters.toJSONSchema() as Record<string, unknown>;
|
|
648
|
+
} catch {
|
|
649
|
+
// Fall through to zod conversion.
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if ("safeParse" in parameters || "safeParseAsync" in parameters) {
|
|
654
|
+
try {
|
|
655
|
+
return zodToJsonSchema(parameters as ZodTypeAny, {
|
|
656
|
+
target: "jsonSchema7",
|
|
657
|
+
$refStrategy: "none",
|
|
658
|
+
}) as Record<string, unknown>;
|
|
659
|
+
} catch {
|
|
660
|
+
return undefined;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
return undefined;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async function parseToolInput(
|
|
668
|
+
schema: unknown,
|
|
669
|
+
input: unknown
|
|
670
|
+
): Promise<
|
|
671
|
+
| { ok: true; value: unknown }
|
|
672
|
+
| {
|
|
673
|
+
ok: false;
|
|
674
|
+
error: string;
|
|
675
|
+
}
|
|
676
|
+
> {
|
|
677
|
+
if (!schema || typeof schema !== "object") {
|
|
678
|
+
return { ok: true, value: input };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if ("safeParseAsync" in schema && typeof schema.safeParseAsync === "function") {
|
|
682
|
+
const parsed = await schema.safeParseAsync(input);
|
|
683
|
+
if (!parsed.success) {
|
|
684
|
+
return { ok: false, error: parsed.error.message };
|
|
685
|
+
}
|
|
686
|
+
return { ok: true, value: parsed.data };
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if ("safeParse" in schema && typeof schema.safeParse === "function") {
|
|
690
|
+
const parsed = schema.safeParse(input);
|
|
691
|
+
if (!parsed.success) {
|
|
692
|
+
return { ok: false, error: parsed.error.message };
|
|
693
|
+
}
|
|
694
|
+
return { ok: true, value: parsed.data };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
return { ok: true, value: input };
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
export function convertToolSetToCopilotTools(tools: ToolSet, callbacks: StreamCallbacks): CopilotTool[] {
|
|
701
|
+
return Object.entries(tools).map(([name, tool]) => {
|
|
702
|
+
const inputSchema = (tool as { inputSchema?: unknown }).inputSchema;
|
|
703
|
+
const execute = (tool as { execute?: (input: unknown, context?: unknown) => Promise<unknown> | unknown })
|
|
704
|
+
.execute;
|
|
705
|
+
const needsApproval = (tool as { needsApproval?: (input: unknown) => Promise<boolean> | boolean })
|
|
706
|
+
.needsApproval;
|
|
707
|
+
|
|
708
|
+
return defineTool(name, {
|
|
709
|
+
description: (tool as { description?: string }).description,
|
|
710
|
+
parameters: toJsonSchema(inputSchema),
|
|
711
|
+
handler: async (rawInput: unknown, invocation: ToolInvocation) => {
|
|
712
|
+
const parsed = await parseToolInput(inputSchema, rawInput);
|
|
713
|
+
if (!parsed.ok) {
|
|
714
|
+
return toFailureResult(
|
|
715
|
+
"Tool input validation failed. The provided arguments are invalid.",
|
|
716
|
+
parsed.error
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (needsApproval) {
|
|
721
|
+
let approved = false;
|
|
722
|
+
let reason: string | undefined;
|
|
723
|
+
|
|
724
|
+
try {
|
|
725
|
+
const shouldAsk = await needsApproval(parsed.value);
|
|
726
|
+
if (!shouldAsk) {
|
|
727
|
+
approved = true;
|
|
728
|
+
} else {
|
|
729
|
+
const request: ToolApprovalRequest = {
|
|
730
|
+
approvalId: `approval-${invocation.toolCallId}-${Date.now()}`,
|
|
731
|
+
toolName: name,
|
|
732
|
+
toolCallId: invocation.toolCallId,
|
|
733
|
+
input: parsed.value,
|
|
734
|
+
};
|
|
735
|
+
const result = await resolveApproval(callbacks, request);
|
|
736
|
+
approved = result.approved;
|
|
737
|
+
reason = result.reason;
|
|
738
|
+
}
|
|
739
|
+
} catch (error) {
|
|
740
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
741
|
+
return toFailureResult(
|
|
742
|
+
"Tool approval failed. The command was not executed.",
|
|
743
|
+
err.message,
|
|
744
|
+
"denied"
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (!approved) {
|
|
749
|
+
return toFailureResult(
|
|
750
|
+
`[DENIED] ${reason ?? "Tool execution was denied by the user."}`,
|
|
751
|
+
reason,
|
|
752
|
+
"denied"
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
if (!execute) {
|
|
758
|
+
return toFailureResult(`Tool '${name}' is missing an execute handler.`);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
try {
|
|
762
|
+
return await execute(parsed.value, {
|
|
763
|
+
toolCallId: invocation.toolCallId,
|
|
764
|
+
});
|
|
765
|
+
} catch (error) {
|
|
766
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
767
|
+
return toFailureResult(
|
|
768
|
+
"Invoking this tool produced an error. Detailed information is not available.",
|
|
769
|
+
err.message
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
},
|
|
773
|
+
});
|
|
774
|
+
});
|
|
775
|
+
}
|