@love-moon/ai-sdk 0.3.1 → 0.4.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/CHANGELOG.md +18 -0
- package/dist/built-in-backends.d.ts +1 -0
- package/dist/built-in-backends.js +6 -0
- package/dist/client.d.ts +15 -0
- package/dist/client.js +103 -1
- package/dist/external-provider-registry.js +4 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/manager/account.d.ts +6 -0
- package/dist/manager/account.js +121 -0
- package/dist/manager/auth-parser.d.ts +27 -0
- package/dist/manager/auth-parser.js +54 -0
- package/dist/manager/config.d.ts +6 -0
- package/dist/manager/config.js +32 -0
- package/dist/manager/index.d.ts +12 -0
- package/dist/manager/index.js +11 -0
- package/dist/manager/install.d.ts +9 -0
- package/dist/manager/install.js +117 -0
- package/dist/manager/manager.d.ts +51 -0
- package/dist/manager/manager.js +105 -0
- package/dist/manager/network.d.ts +8 -0
- package/dist/manager/network.js +46 -0
- package/dist/manager/paths.d.ts +6 -0
- package/dist/manager/paths.js +16 -0
- package/dist/manager/quota/cache.d.ts +9 -0
- package/dist/manager/quota/cache.js +33 -0
- package/dist/manager/quota/claude.d.ts +19 -0
- package/dist/manager/quota/claude.js +193 -0
- package/dist/manager/quota/codex.d.ts +27 -0
- package/dist/manager/quota/codex.js +182 -0
- package/dist/manager/quota/copilot.d.ts +64 -0
- package/dist/manager/quota/copilot.js +718 -0
- package/dist/manager/quota/external.d.ts +29 -0
- package/dist/manager/quota/external.js +176 -0
- package/dist/manager/quota/headers.d.ts +5 -0
- package/dist/manager/quota/headers.js +29 -0
- package/dist/manager/quota/kimi.d.ts +24 -0
- package/dist/manager/quota/kimi.js +230 -0
- package/dist/manager/types.d.ts +166 -0
- package/dist/manager/types.js +1 -0
- package/dist/providers/chat-web-session.d.ts +218 -0
- package/dist/providers/chat-web-session.js +584 -0
- package/dist/providers/claude-agent-sdk-session.d.ts +35 -1
- package/dist/providers/claude-agent-sdk-session.js +109 -1
- package/dist/providers/codex-app-server-session.d.ts +107 -0
- package/dist/providers/codex-app-server-session.js +479 -9
- package/dist/providers/copilot-sdk-session.d.ts +9 -1
- package/dist/providers/copilot-sdk-session.js +48 -0
- package/dist/resume/chat-web.d.ts +20 -0
- package/dist/resume/chat-web.js +44 -0
- package/dist/resume/index.js +2 -0
- package/dist/session-factory.d.ts +3 -1
- package/dist/session-factory.js +17 -4
- package/dist/shared.d.ts +159 -0
- package/dist/shared.js +111 -0
- package/dist/transports/codex-app-server-transport.d.ts +1 -0
- package/dist/transports/codex-app-server-transport.js +45 -1
- package/dist/worker.js +19 -5
- package/package.json +10 -3
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { CHAT_WEB_SESSION_VARIANT } from "../built-in-backends.js";
|
|
3
|
+
import { emitLog, normalizeLogger } from "../shared.js";
|
|
4
|
+
const SUPPORTED_CHAT_WEB_PROVIDERS = new Set(["chatgpt", "gemini"]);
|
|
5
|
+
const DEFAULT_CHAT_WEB_PROVIDER = "chatgpt";
|
|
6
|
+
const DEFAULT_TURN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
7
|
+
function normalizeChatWebProvider(value) {
|
|
8
|
+
const raw = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
9
|
+
if (!raw)
|
|
10
|
+
return "";
|
|
11
|
+
// Friendly aliases. "openai" / "gpt" → chatgpt, "google" → gemini.
|
|
12
|
+
if (raw === "openai" || raw === "gpt" || raw === "chat-gpt")
|
|
13
|
+
return "chatgpt";
|
|
14
|
+
if (raw === "google" || raw === "aistudio" || raw === "ai-studio")
|
|
15
|
+
return "gemini";
|
|
16
|
+
return raw;
|
|
17
|
+
}
|
|
18
|
+
function resolveChatWebProvider(options = {}) {
|
|
19
|
+
const candidates = [options.chatWebProvider, options.provider, options.model];
|
|
20
|
+
for (const candidate of candidates) {
|
|
21
|
+
const normalized = normalizeChatWebProvider(candidate);
|
|
22
|
+
if (SUPPORTED_CHAT_WEB_PROVIDERS.has(normalized)) {
|
|
23
|
+
return normalized;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return DEFAULT_CHAT_WEB_PROVIDER;
|
|
27
|
+
}
|
|
28
|
+
function extractErrorMessage(error) {
|
|
29
|
+
if (typeof error?.message === "string" && error.message.trim()) {
|
|
30
|
+
return error.message.trim();
|
|
31
|
+
}
|
|
32
|
+
if (typeof error === "string" && error.trim()) {
|
|
33
|
+
return error.trim();
|
|
34
|
+
}
|
|
35
|
+
return "chat-web turn failed";
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Bridges the ai-sdk logger shape (`{ log(msg) }`) to chat-web's shape
|
|
39
|
+
* (`{ error, warn, info, debug }`). Chat-web internally calls
|
|
40
|
+
* `logger.debug(...)` and friends during session lifecycle; if we pass
|
|
41
|
+
* the ai-sdk logger straight through, those calls explode with
|
|
42
|
+
* "this.logger.debug is not a function" mid-turn.
|
|
43
|
+
*
|
|
44
|
+
* Routes every chat-web level into the ai-sdk logger's single `log`
|
|
45
|
+
* channel, prefixed with the level so downstream observers can still
|
|
46
|
+
* grep the structure. Safe against undefined / partial loggers.
|
|
47
|
+
*/
|
|
48
|
+
function adaptLoggerForChatWeb(aiSdkLogger) {
|
|
49
|
+
const sinkLog = typeof aiSdkLogger?.log === "function" ? aiSdkLogger.log.bind(aiSdkLogger) : null;
|
|
50
|
+
const at = (level) => (...args) => {
|
|
51
|
+
if (!sinkLog)
|
|
52
|
+
return;
|
|
53
|
+
try {
|
|
54
|
+
sinkLog(`[chat-web ${level}] ${args.map(formatLoggerArg).join(" ")}`);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// best effort
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
return {
|
|
61
|
+
level: "info",
|
|
62
|
+
error: at("error"),
|
|
63
|
+
warn: at("warn"),
|
|
64
|
+
info: at("info"),
|
|
65
|
+
debug: at("debug"),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function formatLoggerArg(value) {
|
|
69
|
+
if (value === undefined)
|
|
70
|
+
return "undefined";
|
|
71
|
+
if (value === null)
|
|
72
|
+
return "null";
|
|
73
|
+
if (typeof value === "string")
|
|
74
|
+
return value;
|
|
75
|
+
if (value instanceof Error)
|
|
76
|
+
return value.stack || value.message;
|
|
77
|
+
try {
|
|
78
|
+
return JSON.stringify(value);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return String(value);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function isPlaywrightMissingError(error) {
|
|
85
|
+
const msg = String(error?.message || "").toLowerCase();
|
|
86
|
+
return (msg.includes("cannot find package 'playwright") ||
|
|
87
|
+
msg.includes("cannot find module 'playwright") ||
|
|
88
|
+
msg.includes("npx playwright install"));
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* AI SDK provider that delegates to the chat-web runtime
|
|
92
|
+
* (`@love-moon/chat-web`), which automates a real Chromium browser against
|
|
93
|
+
* ChatGPT / Gemini / DeepSeek and ferries conversations through their web
|
|
94
|
+
* UIs. Choose the underlying chat-web provider via:
|
|
95
|
+
*
|
|
96
|
+
* - `options.chatWebProvider`: "chatgpt" | "gemini" (preferred)
|
|
97
|
+
* - `options.provider`: same surface
|
|
98
|
+
* - `options.model`: same surface (fallback for ergonomic
|
|
99
|
+
* `createAiSession("chat-web", { model: "gemini" })`)
|
|
100
|
+
*
|
|
101
|
+
* Defaults to "chatgpt". Aliases: "openai"/"gpt" → chatgpt, "google" → gemini.
|
|
102
|
+
*
|
|
103
|
+
* Lifecycle:
|
|
104
|
+
* - `boot()` lazily imports `@love-moon/chat-web`, registers its built-in
|
|
105
|
+
* providers, and opens a long-lived `ChatSession` (headless by default).
|
|
106
|
+
* - `runTurn(prompt)` calls `session.send(prompt)` and emits a single
|
|
107
|
+
* `assistant_message` with the model's reply.
|
|
108
|
+
* - `close()` tears the Chromium context down.
|
|
109
|
+
*
|
|
110
|
+
* Resume: chat-web's "session" is a Chromium browser context, not a
|
|
111
|
+
* conversation ID — there is no native cross-process resume. We synthesise
|
|
112
|
+
* an id so the rest of ai-sdk has something stable to thread on, but
|
|
113
|
+
* passing `resumeSessionId` does not reattach to a prior conversation.
|
|
114
|
+
*/
|
|
115
|
+
export class ChatWebSession extends EventEmitter {
|
|
116
|
+
constructor(backend, options = {}) {
|
|
117
|
+
super();
|
|
118
|
+
this.backend = "chat-web";
|
|
119
|
+
this.options = options;
|
|
120
|
+
this.logger = normalizeLogger(options.logger);
|
|
121
|
+
this.chatWebProvider = resolveChatWebProvider(options);
|
|
122
|
+
this.headless = options.headless !== false;
|
|
123
|
+
// Optional: use a specific Chromium-family binary (system Chrome /
|
|
124
|
+
// Edge / explicit path) instead of Playwright's bundled
|
|
125
|
+
// `chrome-headless-shell`. Useful when the user's network treats
|
|
126
|
+
// chrome-headless-shell differently from real Chrome. Note:
|
|
127
|
+
// this does NOT bypass Google's WAA anti-abuse on AI Studio.
|
|
128
|
+
this.browserChannel =
|
|
129
|
+
typeof options.browserChannel === "string" && options.browserChannel.trim()
|
|
130
|
+
? options.browserChannel.trim()
|
|
131
|
+
: "";
|
|
132
|
+
this.browserExecutablePath =
|
|
133
|
+
typeof options.browserExecutablePath === "string" && options.browserExecutablePath.trim()
|
|
134
|
+
? options.browserExecutablePath.trim()
|
|
135
|
+
: "";
|
|
136
|
+
this.turnTimeoutMs =
|
|
137
|
+
Number.isFinite(options.turnTimeoutMs) && options.turnTimeoutMs > 0
|
|
138
|
+
? Math.round(options.turnTimeoutMs)
|
|
139
|
+
: DEFAULT_TURN_TIMEOUT_MS;
|
|
140
|
+
this.cwd =
|
|
141
|
+
typeof options.cwd === "string" && options.cwd.trim()
|
|
142
|
+
? options.cwd.trim()
|
|
143
|
+
: process.cwd();
|
|
144
|
+
// Synthetic id used until the provider exposes its real conversation
|
|
145
|
+
// id (e.g. ChatGPT navigates to /c/{uuid} after the first turn). The
|
|
146
|
+
// synthetic value lets callers thread on `sessionId` immediately,
|
|
147
|
+
// before the browser has even opened.
|
|
148
|
+
this.sessionId =
|
|
149
|
+
typeof options.resumeSessionId === "string" && options.resumeSessionId.trim()
|
|
150
|
+
? options.resumeSessionId.trim()
|
|
151
|
+
: `chat-web-${this.chatWebProvider}-${Date.now().toString(36)}`;
|
|
152
|
+
/** Real provider-side conversation id, populated after the first turn lands. */
|
|
153
|
+
this.providerConversationId = undefined;
|
|
154
|
+
this.sessionInfo = {
|
|
155
|
+
backend: this.backend,
|
|
156
|
+
sessionId: this.sessionId,
|
|
157
|
+
model: this.chatWebProvider,
|
|
158
|
+
modelProvider: "chat-web",
|
|
159
|
+
};
|
|
160
|
+
this.chatSession = null;
|
|
161
|
+
this.booted = false;
|
|
162
|
+
this.bootPromise = null;
|
|
163
|
+
this.closeRequested = false;
|
|
164
|
+
this.closed = false;
|
|
165
|
+
this.currentTurn = null;
|
|
166
|
+
this.currentTurnStatus = null;
|
|
167
|
+
this.sessionMessageHandler = null;
|
|
168
|
+
this.workingStatusHandler = null;
|
|
169
|
+
this.activeReplyTarget = "";
|
|
170
|
+
this.lastReplyTarget = "";
|
|
171
|
+
this.history = Array.isArray(options.initialHistory)
|
|
172
|
+
? [...options.initialHistory]
|
|
173
|
+
: [];
|
|
174
|
+
}
|
|
175
|
+
writeLog(message) {
|
|
176
|
+
emitLog(this.logger, message);
|
|
177
|
+
}
|
|
178
|
+
trace(message) {
|
|
179
|
+
this.writeLog(`[${this.backend}] [chat-web] ${message}`);
|
|
180
|
+
}
|
|
181
|
+
get threadId() {
|
|
182
|
+
return this.sessionId;
|
|
183
|
+
}
|
|
184
|
+
get threadOptions() {
|
|
185
|
+
return {
|
|
186
|
+
model: this.chatWebProvider,
|
|
187
|
+
modelProvider: "chat-web",
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
getSnapshot() {
|
|
191
|
+
return {
|
|
192
|
+
backend: this.backend,
|
|
193
|
+
provider: CHAT_WEB_SESSION_VARIANT,
|
|
194
|
+
cwd: this.cwd,
|
|
195
|
+
sessionId: this.sessionId,
|
|
196
|
+
sessionInfo: this.getSessionInfo(),
|
|
197
|
+
useSessionFileReplyStream: this.usesSessionFileReplyStream(),
|
|
198
|
+
resumeReady: Boolean(this.providerConversationId),
|
|
199
|
+
manualResume: this.providerConversationUrl()
|
|
200
|
+
? { ready: true, command: this.providerConversationUrl() }
|
|
201
|
+
: null,
|
|
202
|
+
currentTurnStatus: this.getCurrentTurnStatus(),
|
|
203
|
+
chatWebProvider: this.chatWebProvider,
|
|
204
|
+
providerConversationId: this.providerConversationId,
|
|
205
|
+
providerUrl: this.providerConversationUrl(),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
getSessionInfo() {
|
|
209
|
+
if (!this.sessionInfo)
|
|
210
|
+
return null;
|
|
211
|
+
// chat-web's "real" session id is the provider-side conversation id
|
|
212
|
+
// (e.g. ChatGPT /c/{uuid}), which only lands AFTER the first turn.
|
|
213
|
+
// Until then, our synthetic "chat-web-{provider}-{ts}" id is just a
|
|
214
|
+
// local handle — surfacing it to the daemon would lead to ugly UI
|
|
215
|
+
// copy like "web-chatgpt session started: chat-web-chatgpt-mpgw7cd1".
|
|
216
|
+
//
|
|
217
|
+
// We expose `sessionIdDeferred: true` so the fire-side announce can
|
|
218
|
+
// hold off until the real id arrives, then re-announce.
|
|
219
|
+
const hasRealId = Boolean(this.providerConversationId);
|
|
220
|
+
return {
|
|
221
|
+
...this.sessionInfo,
|
|
222
|
+
sessionIdDeferred: !hasRealId,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
getCurrentTurnStatus() {
|
|
226
|
+
return this.currentTurnStatus ? { ...this.currentTurnStatus } : null;
|
|
227
|
+
}
|
|
228
|
+
usesSessionFileReplyStream() {
|
|
229
|
+
// chat-web doesn't persist a JSONL session file; replies are emitted
|
|
230
|
+
// in-process via the assistant_message event.
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
setSessionMessageHandler(handler) {
|
|
234
|
+
this.sessionMessageHandler = typeof handler === "function" ? handler : null;
|
|
235
|
+
}
|
|
236
|
+
setWorkingStatusHandler(handler) {
|
|
237
|
+
this.workingStatusHandler = typeof handler === "function" ? handler : null;
|
|
238
|
+
}
|
|
239
|
+
setSessionReplyTarget(replyTo) {
|
|
240
|
+
const normalized = typeof replyTo === "string" ? replyTo.trim() : "";
|
|
241
|
+
this.activeReplyTarget = normalized;
|
|
242
|
+
if (normalized) {
|
|
243
|
+
this.lastReplyTarget = normalized;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
getCurrentReplyTarget() {
|
|
247
|
+
return this.activeReplyTarget || this.lastReplyTarget || undefined;
|
|
248
|
+
}
|
|
249
|
+
async ensureSessionInfo() {
|
|
250
|
+
await this.boot();
|
|
251
|
+
return this.getSessionInfo();
|
|
252
|
+
}
|
|
253
|
+
async getSessionUsageSummary() {
|
|
254
|
+
// chat-web has no token / cost telemetry — it's a browser puppeteer,
|
|
255
|
+
// not an API client.
|
|
256
|
+
return {
|
|
257
|
+
sessionId: this.sessionId,
|
|
258
|
+
sessionFilePath: undefined,
|
|
259
|
+
totalCostUsd: undefined,
|
|
260
|
+
usage: null,
|
|
261
|
+
rateLimits: null,
|
|
262
|
+
manualResume: null,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
async getChatWebModule() {
|
|
266
|
+
if (this.options.chatWebModule && typeof this.options.chatWebModule === "object") {
|
|
267
|
+
return this.options.chatWebModule;
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
return await import("@love-moon/chat-web");
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
if (isPlaywrightMissingError(error)) {
|
|
274
|
+
const enriched = new Error(`@love-moon/chat-web requires Playwright Chromium. Run: npx playwright install chromium`);
|
|
275
|
+
enriched.cause = error;
|
|
276
|
+
throw enriched;
|
|
277
|
+
}
|
|
278
|
+
const enriched = new Error(`Failed to load @love-moon/chat-web: ${extractErrorMessage(error)}. ` +
|
|
279
|
+
`Install it with: npm install @love-moon/chat-web playwright`);
|
|
280
|
+
enriched.cause = error;
|
|
281
|
+
throw enriched;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
async boot() {
|
|
285
|
+
if (this.booted)
|
|
286
|
+
return;
|
|
287
|
+
if (this.bootPromise)
|
|
288
|
+
return this.bootPromise;
|
|
289
|
+
this.bootPromise = this.bootInternal();
|
|
290
|
+
try {
|
|
291
|
+
await this.bootPromise;
|
|
292
|
+
this.booted = true;
|
|
293
|
+
}
|
|
294
|
+
finally {
|
|
295
|
+
this.bootPromise = null;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
async bootInternal() {
|
|
299
|
+
if (this.closeRequested) {
|
|
300
|
+
throw this.createSessionClosedError();
|
|
301
|
+
}
|
|
302
|
+
const mod = await this.getChatWebModule();
|
|
303
|
+
if (typeof mod.registerBuiltinProviders === "function") {
|
|
304
|
+
mod.registerBuiltinProviders();
|
|
305
|
+
}
|
|
306
|
+
if (typeof mod.ChatSession?.open !== "function") {
|
|
307
|
+
throw new Error("Loaded @love-moon/chat-web is missing ChatSession.open");
|
|
308
|
+
}
|
|
309
|
+
// Forward optional browser-binary overrides to chat-web. The env vars
|
|
310
|
+
// CHAT_WEB_BROWSER_CHANNEL / CHAT_WEB_BROWSER_EXECUTABLE are also
|
|
311
|
+
// honoured by chat-web directly, so they apply even when nothing is
|
|
312
|
+
// passed here.
|
|
313
|
+
const launch = {};
|
|
314
|
+
if (this.browserChannel)
|
|
315
|
+
launch.channel = this.browserChannel;
|
|
316
|
+
if (this.browserExecutablePath)
|
|
317
|
+
launch.executablePath = this.browserExecutablePath;
|
|
318
|
+
this.chatSession = await mod.ChatSession.open(this.chatWebProvider, {
|
|
319
|
+
headless: this.headless,
|
|
320
|
+
// IMPORTANT: chat-web's logger contract is `{ error, warn, info, debug }`;
|
|
321
|
+
// ai-sdk's normalised logger is `{ log }`. Passing the ai-sdk logger
|
|
322
|
+
// through verbatim crashes chat-web mid-session with
|
|
323
|
+
// "this.logger.debug is not a function". Adapt to chat-web's shape.
|
|
324
|
+
logger: adaptLoggerForChatWeb(this.logger),
|
|
325
|
+
...(Object.keys(launch).length > 0 ? { launch } : {}),
|
|
326
|
+
});
|
|
327
|
+
if (this.closeRequested) {
|
|
328
|
+
await this.chatSession.close().catch(() => undefined);
|
|
329
|
+
this.chatSession = null;
|
|
330
|
+
throw this.createSessionClosedError();
|
|
331
|
+
}
|
|
332
|
+
const loggedIn = await this.chatSession.isLoggedIn().catch(() => false);
|
|
333
|
+
if (!loggedIn) {
|
|
334
|
+
const error = new Error(`chat-web provider "${this.chatWebProvider}" is not logged in. Run: chat-web login ${this.chatWebProvider}`);
|
|
335
|
+
error.reason = "not_logged_in";
|
|
336
|
+
this.emit("auth_required", {
|
|
337
|
+
reason: "login_required",
|
|
338
|
+
message: error.message,
|
|
339
|
+
});
|
|
340
|
+
throw error;
|
|
341
|
+
}
|
|
342
|
+
this.trace(`session ready provider=${this.chatWebProvider} id=${this.sessionId}`);
|
|
343
|
+
this.emit("session", this.getSessionInfo());
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Adopt the provider-side conversation id (ChatGPT /c/{uuid} or
|
|
347
|
+
* equivalent) as our canonical sessionId, update sessionInfo, and emit
|
|
348
|
+
* a fresh `session` event so downstream consumers (ai-sdk runner,
|
|
349
|
+
* conductor daemon, web UI) pick up the change.
|
|
350
|
+
*
|
|
351
|
+
* Idempotent on identical values. Once promoted, the id stays stable
|
|
352
|
+
* for the rest of this ChatWebSession (next runTurn won't re-promote
|
|
353
|
+
* to something else unless the provider session truly changes).
|
|
354
|
+
*/
|
|
355
|
+
applyProviderConversationId(conversationId) {
|
|
356
|
+
const normalized = typeof conversationId === "string" ? conversationId.trim() : "";
|
|
357
|
+
if (!normalized)
|
|
358
|
+
return;
|
|
359
|
+
if (normalized === this.providerConversationId)
|
|
360
|
+
return;
|
|
361
|
+
this.providerConversationId = normalized;
|
|
362
|
+
this.sessionId = normalized;
|
|
363
|
+
this.sessionInfo = {
|
|
364
|
+
...(this.sessionInfo || {}),
|
|
365
|
+
backend: this.backend,
|
|
366
|
+
sessionId: normalized,
|
|
367
|
+
model: this.chatWebProvider,
|
|
368
|
+
modelProvider: "chat-web",
|
|
369
|
+
providerConversationId: normalized,
|
|
370
|
+
providerUrl: this.providerConversationUrl(),
|
|
371
|
+
};
|
|
372
|
+
this.trace(`adopted provider conversation id ${normalized}`);
|
|
373
|
+
this.emit("session", this.getSessionInfo());
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Build a deep-link to the provider's conversation page so the UI can
|
|
377
|
+
* render "open in ChatGPT" / "open in AI Studio" links.
|
|
378
|
+
*/
|
|
379
|
+
providerConversationUrl() {
|
|
380
|
+
if (!this.providerConversationId)
|
|
381
|
+
return undefined;
|
|
382
|
+
switch (this.chatWebProvider) {
|
|
383
|
+
case "chatgpt":
|
|
384
|
+
return `https://chatgpt.com/c/${this.providerConversationId}`;
|
|
385
|
+
case "gemini":
|
|
386
|
+
// chat-web's `gemini` provider targets AI Studio
|
|
387
|
+
// (aistudio.google.com/prompts/new_chat) — that's the free web
|
|
388
|
+
// chat surface users call "Gemini". Once a prompt is saved,
|
|
389
|
+
// the URL becomes /prompts/{slug}.
|
|
390
|
+
return `https://aistudio.google.com/prompts/${this.providerConversationId}`;
|
|
391
|
+
default:
|
|
392
|
+
return undefined;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
async runTurn(promptText, { onProgress = null } = {}) {
|
|
396
|
+
const prompt = String(promptText || "").trim();
|
|
397
|
+
if (!prompt) {
|
|
398
|
+
return {
|
|
399
|
+
text: "",
|
|
400
|
+
usage: null,
|
|
401
|
+
items: [],
|
|
402
|
+
events: [],
|
|
403
|
+
provider: this.backend,
|
|
404
|
+
metadata: {
|
|
405
|
+
source: CHAT_WEB_SESSION_VARIANT,
|
|
406
|
+
sessionId: this.sessionId,
|
|
407
|
+
chatWebProvider: this.chatWebProvider,
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
if (this.currentTurn) {
|
|
412
|
+
const error = new Error("chat-web turn already in progress");
|
|
413
|
+
error.reason = "turn_already_running";
|
|
414
|
+
throw error;
|
|
415
|
+
}
|
|
416
|
+
this.currentTurn = { aborted: false };
|
|
417
|
+
await this.emitWorkingStatus({
|
|
418
|
+
phase: "turn_started",
|
|
419
|
+
reply_in_progress: true,
|
|
420
|
+
status_line: `chat-web (${this.chatWebProvider}) is working`,
|
|
421
|
+
}, onProgress);
|
|
422
|
+
try {
|
|
423
|
+
await this.boot();
|
|
424
|
+
if (this.closeRequested)
|
|
425
|
+
throw this.createSessionClosedError();
|
|
426
|
+
const result = await this.chatSession.send(prompt, {
|
|
427
|
+
timeoutMs: this.turnTimeoutMs,
|
|
428
|
+
onProgress: (text) => {
|
|
429
|
+
// chat-web's onProgress fires while streaming text grows; we
|
|
430
|
+
// forward those as working_status updates with a short preview.
|
|
431
|
+
void this.emitWorkingStatus({
|
|
432
|
+
phase: "message_aggregation",
|
|
433
|
+
reply_in_progress: true,
|
|
434
|
+
status_line: `chat-web (${this.chatWebProvider}) streaming`,
|
|
435
|
+
reply_preview: typeof text === "string" ? text.slice(-120) : undefined,
|
|
436
|
+
}, onProgress);
|
|
437
|
+
},
|
|
438
|
+
});
|
|
439
|
+
const text = String(result?.response ?? "").trim();
|
|
440
|
+
// Adopt the provider's real conversation id once it lands (e.g.
|
|
441
|
+
// ChatGPT's /c/{uuid}). This replaces the synthetic
|
|
442
|
+
// "chat-web-{provider}-{ts}" id we minted at construction so
|
|
443
|
+
// downstream callers (UI, daemon, persistence) see the real
|
|
444
|
+
// provider-side id and can deep-link straight to chatgpt.com/c/...
|
|
445
|
+
const conversationId = typeof result?.conversationId === "string" && result.conversationId.trim()
|
|
446
|
+
? result.conversationId.trim()
|
|
447
|
+
: typeof this.chatSession?.conversationId === "string" && this.chatSession.conversationId.trim()
|
|
448
|
+
? this.chatSession.conversationId.trim()
|
|
449
|
+
: "";
|
|
450
|
+
if (conversationId && conversationId !== this.sessionId) {
|
|
451
|
+
this.applyProviderConversationId(conversationId);
|
|
452
|
+
}
|
|
453
|
+
if (text) {
|
|
454
|
+
this.history.push({ role: "assistant", content: text });
|
|
455
|
+
await this.emitAssistantMessage(text);
|
|
456
|
+
}
|
|
457
|
+
await this.emitTerminalWorkingStatus({
|
|
458
|
+
phase: this.currentTurn.aborted ? "turn_interrupted" : "turn_completed",
|
|
459
|
+
status_done_line: this.currentTurn.aborted
|
|
460
|
+
? `chat-web (${this.chatWebProvider}) interrupted`
|
|
461
|
+
: `chat-web (${this.chatWebProvider}) finished`,
|
|
462
|
+
}, onProgress);
|
|
463
|
+
return {
|
|
464
|
+
text,
|
|
465
|
+
usage: null,
|
|
466
|
+
items: [],
|
|
467
|
+
events: [],
|
|
468
|
+
provider: this.backend,
|
|
469
|
+
metadata: {
|
|
470
|
+
source: CHAT_WEB_SESSION_VARIANT,
|
|
471
|
+
sessionId: this.sessionId,
|
|
472
|
+
chatWebProvider: this.chatWebProvider,
|
|
473
|
+
conversationId: this.providerConversationId,
|
|
474
|
+
providerUrl: this.providerConversationUrl(),
|
|
475
|
+
turnIndex: result?.turnIndex,
|
|
476
|
+
durationMs: result?.durationMs,
|
|
477
|
+
},
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
const message = extractErrorMessage(error);
|
|
482
|
+
const code = typeof error?.code === "string" ? error.code : "";
|
|
483
|
+
// Surface chat-web's typed "needs API key" / "permission denied"
|
|
484
|
+
// errors as auth_required so the UI / daemon can route them
|
|
485
|
+
// through the same flow as ChatGPT's "not logged in" — they're
|
|
486
|
+
// all "operator action required" failures, not transient errors.
|
|
487
|
+
if (code === "PROVIDER_API_KEY_REQUIRED" || code === "PROVIDER_PERMISSION_DENIED") {
|
|
488
|
+
this.emit("auth_required", {
|
|
489
|
+
reason: code === "PROVIDER_API_KEY_REQUIRED" ? "api_key_required" : "permission_denied",
|
|
490
|
+
message,
|
|
491
|
+
provider: this.chatWebProvider,
|
|
492
|
+
hint: error?.hint,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
await this.emitTerminalWorkingStatus({
|
|
496
|
+
phase: this.currentTurn?.aborted ? "turn_interrupted" : "turn_failed",
|
|
497
|
+
status_done_line: message,
|
|
498
|
+
}, onProgress);
|
|
499
|
+
throw error;
|
|
500
|
+
}
|
|
501
|
+
finally {
|
|
502
|
+
this.activeReplyTarget = "";
|
|
503
|
+
this.currentTurn = null;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
async interruptCurrentTurn() {
|
|
507
|
+
// chat-web's ChatSession doesn't expose a turn abort — the underlying
|
|
508
|
+
// Chromium tab is still busy with the model. We mark the turn as
|
|
509
|
+
// aborted so the next status emission is "interrupted", but the
|
|
510
|
+
// assistant_message may still arrive once the model finishes.
|
|
511
|
+
if (this.currentTurn) {
|
|
512
|
+
this.currentTurn.aborted = true;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
async close() {
|
|
516
|
+
if (this.closed)
|
|
517
|
+
return;
|
|
518
|
+
this.closed = true;
|
|
519
|
+
this.closeRequested = true;
|
|
520
|
+
if (this.chatSession) {
|
|
521
|
+
try {
|
|
522
|
+
await this.chatSession.close();
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
// best effort
|
|
526
|
+
}
|
|
527
|
+
this.chatSession = null;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
createSessionClosedError() {
|
|
531
|
+
const error = new Error("chat-web session closed");
|
|
532
|
+
error.reason = "session_closed";
|
|
533
|
+
return error;
|
|
534
|
+
}
|
|
535
|
+
async emitWorkingStatus(payload, onProgress = null) {
|
|
536
|
+
const updatedAtMs = Date.now();
|
|
537
|
+
const normalized = {
|
|
538
|
+
source: CHAT_WEB_SESSION_VARIANT,
|
|
539
|
+
reply_in_progress: Boolean(payload?.reply_in_progress),
|
|
540
|
+
replyTo: payload?.replyTo || this.getCurrentReplyTarget(),
|
|
541
|
+
state: payload?.state,
|
|
542
|
+
phase: payload?.phase,
|
|
543
|
+
status_line: payload?.status_line,
|
|
544
|
+
status_done_line: payload?.status_done_line,
|
|
545
|
+
reply_preview: payload?.reply_preview,
|
|
546
|
+
thread_id: this.sessionId,
|
|
547
|
+
session_id: this.sessionId,
|
|
548
|
+
session_file_path: undefined,
|
|
549
|
+
updated_at: new Date(updatedAtMs).toISOString(),
|
|
550
|
+
};
|
|
551
|
+
this.currentTurnStatus = normalized;
|
|
552
|
+
if (typeof onProgress === "function") {
|
|
553
|
+
try {
|
|
554
|
+
onProgress(normalized);
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
// best effort
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (typeof this.workingStatusHandler === "function") {
|
|
561
|
+
await this.workingStatusHandler(normalized);
|
|
562
|
+
}
|
|
563
|
+
this.emit("working_status", normalized);
|
|
564
|
+
}
|
|
565
|
+
async emitTerminalWorkingStatus(payload, onProgress = null) {
|
|
566
|
+
await this.emitWorkingStatus({ ...payload, reply_in_progress: false }, onProgress);
|
|
567
|
+
}
|
|
568
|
+
async emitAssistantMessage(text) {
|
|
569
|
+
const payload = {
|
|
570
|
+
text,
|
|
571
|
+
preserveWhitespace: true,
|
|
572
|
+
source: CHAT_WEB_SESSION_VARIANT,
|
|
573
|
+
replyTo: this.getCurrentReplyTarget(),
|
|
574
|
+
sessionId: this.sessionId,
|
|
575
|
+
sessionFilePath: undefined,
|
|
576
|
+
timestamp: new Date().toISOString(),
|
|
577
|
+
};
|
|
578
|
+
if (typeof this.sessionMessageHandler === "function") {
|
|
579
|
+
await this.sessionMessageHandler(payload);
|
|
580
|
+
}
|
|
581
|
+
this.emit("assistant_message", payload);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
export { SUPPORTED_CHAT_WEB_PROVIDERS, DEFAULT_CHAT_WEB_PROVIDER };
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
export class ClaudeAgentSdkSession extends EventEmitter<[never]> {
|
|
2
|
+
static capabilities: Readonly<{
|
|
3
|
+
goal: true;
|
|
4
|
+
}>;
|
|
2
5
|
constructor(backend: any, options?: {});
|
|
6
|
+
getCapabilities(): {
|
|
7
|
+
goal: true;
|
|
8
|
+
};
|
|
3
9
|
backend: string;
|
|
4
10
|
options: {};
|
|
5
11
|
logger: any;
|
|
@@ -60,6 +66,9 @@ export class ClaudeAgentSdkSession extends EventEmitter<[never]> {
|
|
|
60
66
|
command: string;
|
|
61
67
|
} | null;
|
|
62
68
|
currentTurnStatus: any;
|
|
69
|
+
capabilities: {
|
|
70
|
+
goal: true;
|
|
71
|
+
};
|
|
63
72
|
};
|
|
64
73
|
getSessionInfo(): {
|
|
65
74
|
backend: string;
|
|
@@ -113,7 +122,7 @@ export class ClaudeAgentSdkSession extends EventEmitter<[never]> {
|
|
|
113
122
|
abortController: any;
|
|
114
123
|
cwd: any;
|
|
115
124
|
env: any;
|
|
116
|
-
permissionMode: "default" | "
|
|
125
|
+
permissionMode: "default" | "plan" | "acceptEdits" | "bypassPermissions" | "dontAsk";
|
|
117
126
|
settingSources: string[];
|
|
118
127
|
persistSession: boolean;
|
|
119
128
|
};
|
|
@@ -147,6 +156,31 @@ export class ClaudeAgentSdkSession extends EventEmitter<[never]> {
|
|
|
147
156
|
structuredOutput: any;
|
|
148
157
|
};
|
|
149
158
|
}>;
|
|
159
|
+
/**
|
|
160
|
+
* Trigger Claude's native `/goal` slash command. Implemented by sending the
|
|
161
|
+
* prompt `"/goal <objective>"` through {@link runTurn}, which the Claude
|
|
162
|
+
* Agent SDK natively recognizes as a long-running goal request.
|
|
163
|
+
*
|
|
164
|
+
* Callers should detect support via `typeof session.runGoal === "function"`.
|
|
165
|
+
* When a provider does not support goals it should leave this method
|
|
166
|
+
* undefined; callers MUST surface a clear error rather than silently falling
|
|
167
|
+
* back to {@link runTurn}.
|
|
168
|
+
*
|
|
169
|
+
* Implementation note (N8): Claude's `/goal` slash command resolves the
|
|
170
|
+
* entire goal within a single `query()` iteration. {@link runTurn} drains
|
|
171
|
+
* the SDK iterator to completion (`for await (const message of query)`)
|
|
172
|
+
* before returning, so `turnResult.items` is the full, terminal sequence of
|
|
173
|
+
* messages. If the SDK emits multiple `goal_status` items (e.g. an
|
|
174
|
+
* intermediate "active" followed by a terminal "complete"), we select the
|
|
175
|
+
* LAST one rather than the first so the goal's final state is reflected.
|
|
176
|
+
* When no `goal_status` item is present we fall back to "active" and emit a
|
|
177
|
+
* debug log; that fallback is a signal that the contract has drifted.
|
|
178
|
+
*
|
|
179
|
+
* @param {import("../shared.js").GoalRequest} goal
|
|
180
|
+
* @param {object} [options]
|
|
181
|
+
* @returns {Promise<import("../shared.js").GoalResult>}
|
|
182
|
+
*/
|
|
183
|
+
runGoal(goal: import("../shared.js").GoalRequest, options?: object): Promise<import("../shared.js").GoalResult>;
|
|
150
184
|
close(): Promise<void>;
|
|
151
185
|
}
|
|
152
186
|
import { EventEmitter } from "node:events";
|