@love-moon/ai-sdk 0.3.2 → 0.4.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/built-in-backends.d.ts +1 -0
  3. package/dist/built-in-backends.js +6 -0
  4. package/dist/client.d.ts +15 -0
  5. package/dist/client.js +103 -1
  6. package/dist/external-provider-registry.js +4 -0
  7. package/dist/index.d.ts +3 -0
  8. package/dist/index.js +3 -0
  9. package/dist/manager/account.d.ts +6 -0
  10. package/dist/manager/account.js +121 -0
  11. package/dist/manager/auth-parser.d.ts +27 -0
  12. package/dist/manager/auth-parser.js +54 -0
  13. package/dist/manager/config.d.ts +6 -0
  14. package/dist/manager/config.js +32 -0
  15. package/dist/manager/index.d.ts +12 -0
  16. package/dist/manager/index.js +11 -0
  17. package/dist/manager/install.d.ts +9 -0
  18. package/dist/manager/install.js +117 -0
  19. package/dist/manager/manager.d.ts +51 -0
  20. package/dist/manager/manager.js +105 -0
  21. package/dist/manager/network.d.ts +8 -0
  22. package/dist/manager/network.js +46 -0
  23. package/dist/manager/paths.d.ts +6 -0
  24. package/dist/manager/paths.js +16 -0
  25. package/dist/manager/quota/cache.d.ts +9 -0
  26. package/dist/manager/quota/cache.js +33 -0
  27. package/dist/manager/quota/claude.d.ts +19 -0
  28. package/dist/manager/quota/claude.js +193 -0
  29. package/dist/manager/quota/codex.d.ts +27 -0
  30. package/dist/manager/quota/codex.js +182 -0
  31. package/dist/manager/quota/copilot.d.ts +64 -0
  32. package/dist/manager/quota/copilot.js +718 -0
  33. package/dist/manager/quota/external.d.ts +29 -0
  34. package/dist/manager/quota/external.js +176 -0
  35. package/dist/manager/quota/headers.d.ts +5 -0
  36. package/dist/manager/quota/headers.js +29 -0
  37. package/dist/manager/quota/kimi.d.ts +24 -0
  38. package/dist/manager/quota/kimi.js +230 -0
  39. package/dist/manager/types.d.ts +166 -0
  40. package/dist/manager/types.js +1 -0
  41. package/dist/providers/chat-web-session.d.ts +218 -0
  42. package/dist/providers/chat-web-session.js +593 -0
  43. package/dist/providers/claude-agent-sdk-session.d.ts +35 -1
  44. package/dist/providers/claude-agent-sdk-session.js +109 -1
  45. package/dist/providers/codex-app-server-session.d.ts +107 -0
  46. package/dist/providers/codex-app-server-session.js +479 -9
  47. package/dist/providers/copilot-sdk-session.d.ts +1 -1
  48. package/dist/resume/chat-web.d.ts +20 -0
  49. package/dist/resume/chat-web.js +44 -0
  50. package/dist/resume/index.js +2 -0
  51. package/dist/session-factory.d.ts +3 -1
  52. package/dist/session-factory.js +17 -4
  53. package/dist/shared.d.ts +159 -0
  54. package/dist/shared.js +111 -0
  55. package/dist/transports/codex-app-server-transport.d.ts +1 -0
  56. package/dist/transports/codex-app-server-transport.js +45 -1
  57. package/dist/worker.js +19 -5
  58. package/package.json +10 -3
@@ -0,0 +1,593 @@
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` (headed 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
+ // Match chat-web SDK's own default (headed). Per chat-web/core/browser.ts
123
+ // resolveHeadless(), headed mode is the documented safe default for
124
+ // anti-bot heuristics — ChatGPT/AI Studio routinely serve unauthenticated
125
+ // or challenge pages to chrome-headless-shell even when profile cookies
126
+ // are valid, which masquerades as "not logged in" downstream. The old
127
+ // `options.headless !== false` defaulted to true and effectively neutered
128
+ // chat-web's anti-bot stance whenever the caller (daemon, serve-ai)
129
+ // didn't explicitly pass a value. Users who actually want headless must
130
+ // now opt in with an explicit `headless: true`.
131
+ this.headless = options.headless === true;
132
+ // Optional: use a specific Chromium-family binary (system Chrome /
133
+ // Edge / explicit path) instead of Playwright's bundled
134
+ // `chrome-headless-shell`. Useful when the user's network treats
135
+ // chrome-headless-shell differently from real Chrome. Note:
136
+ // this does NOT bypass Google's WAA anti-abuse on AI Studio.
137
+ this.browserChannel =
138
+ typeof options.browserChannel === "string" && options.browserChannel.trim()
139
+ ? options.browserChannel.trim()
140
+ : "";
141
+ this.browserExecutablePath =
142
+ typeof options.browserExecutablePath === "string" && options.browserExecutablePath.trim()
143
+ ? options.browserExecutablePath.trim()
144
+ : "";
145
+ this.turnTimeoutMs =
146
+ Number.isFinite(options.turnTimeoutMs) && options.turnTimeoutMs > 0
147
+ ? Math.round(options.turnTimeoutMs)
148
+ : DEFAULT_TURN_TIMEOUT_MS;
149
+ this.cwd =
150
+ typeof options.cwd === "string" && options.cwd.trim()
151
+ ? options.cwd.trim()
152
+ : process.cwd();
153
+ // Synthetic id used until the provider exposes its real conversation
154
+ // id (e.g. ChatGPT navigates to /c/{uuid} after the first turn). The
155
+ // synthetic value lets callers thread on `sessionId` immediately,
156
+ // before the browser has even opened.
157
+ this.sessionId =
158
+ typeof options.resumeSessionId === "string" && options.resumeSessionId.trim()
159
+ ? options.resumeSessionId.trim()
160
+ : `chat-web-${this.chatWebProvider}-${Date.now().toString(36)}`;
161
+ /** Real provider-side conversation id, populated after the first turn lands. */
162
+ this.providerConversationId = undefined;
163
+ this.sessionInfo = {
164
+ backend: this.backend,
165
+ sessionId: this.sessionId,
166
+ model: this.chatWebProvider,
167
+ modelProvider: "chat-web",
168
+ };
169
+ this.chatSession = null;
170
+ this.booted = false;
171
+ this.bootPromise = null;
172
+ this.closeRequested = false;
173
+ this.closed = false;
174
+ this.currentTurn = null;
175
+ this.currentTurnStatus = null;
176
+ this.sessionMessageHandler = null;
177
+ this.workingStatusHandler = null;
178
+ this.activeReplyTarget = "";
179
+ this.lastReplyTarget = "";
180
+ this.history = Array.isArray(options.initialHistory)
181
+ ? [...options.initialHistory]
182
+ : [];
183
+ }
184
+ writeLog(message) {
185
+ emitLog(this.logger, message);
186
+ }
187
+ trace(message) {
188
+ this.writeLog(`[${this.backend}] [chat-web] ${message}`);
189
+ }
190
+ get threadId() {
191
+ return this.sessionId;
192
+ }
193
+ get threadOptions() {
194
+ return {
195
+ model: this.chatWebProvider,
196
+ modelProvider: "chat-web",
197
+ };
198
+ }
199
+ getSnapshot() {
200
+ return {
201
+ backend: this.backend,
202
+ provider: CHAT_WEB_SESSION_VARIANT,
203
+ cwd: this.cwd,
204
+ sessionId: this.sessionId,
205
+ sessionInfo: this.getSessionInfo(),
206
+ useSessionFileReplyStream: this.usesSessionFileReplyStream(),
207
+ resumeReady: Boolean(this.providerConversationId),
208
+ manualResume: this.providerConversationUrl()
209
+ ? { ready: true, command: this.providerConversationUrl() }
210
+ : null,
211
+ currentTurnStatus: this.getCurrentTurnStatus(),
212
+ chatWebProvider: this.chatWebProvider,
213
+ providerConversationId: this.providerConversationId,
214
+ providerUrl: this.providerConversationUrl(),
215
+ };
216
+ }
217
+ getSessionInfo() {
218
+ if (!this.sessionInfo)
219
+ return null;
220
+ // chat-web's "real" session id is the provider-side conversation id
221
+ // (e.g. ChatGPT /c/{uuid}), which only lands AFTER the first turn.
222
+ // Until then, our synthetic "chat-web-{provider}-{ts}" id is just a
223
+ // local handle — surfacing it to the daemon would lead to ugly UI
224
+ // copy like "web-chatgpt session started: chat-web-chatgpt-mpgw7cd1".
225
+ //
226
+ // We expose `sessionIdDeferred: true` so the fire-side announce can
227
+ // hold off until the real id arrives, then re-announce.
228
+ const hasRealId = Boolean(this.providerConversationId);
229
+ return {
230
+ ...this.sessionInfo,
231
+ sessionIdDeferred: !hasRealId,
232
+ };
233
+ }
234
+ getCurrentTurnStatus() {
235
+ return this.currentTurnStatus ? { ...this.currentTurnStatus } : null;
236
+ }
237
+ usesSessionFileReplyStream() {
238
+ // chat-web doesn't persist a JSONL session file; replies are emitted
239
+ // in-process via the assistant_message event.
240
+ return false;
241
+ }
242
+ setSessionMessageHandler(handler) {
243
+ this.sessionMessageHandler = typeof handler === "function" ? handler : null;
244
+ }
245
+ setWorkingStatusHandler(handler) {
246
+ this.workingStatusHandler = typeof handler === "function" ? handler : null;
247
+ }
248
+ setSessionReplyTarget(replyTo) {
249
+ const normalized = typeof replyTo === "string" ? replyTo.trim() : "";
250
+ this.activeReplyTarget = normalized;
251
+ if (normalized) {
252
+ this.lastReplyTarget = normalized;
253
+ }
254
+ }
255
+ getCurrentReplyTarget() {
256
+ return this.activeReplyTarget || this.lastReplyTarget || undefined;
257
+ }
258
+ async ensureSessionInfo() {
259
+ await this.boot();
260
+ return this.getSessionInfo();
261
+ }
262
+ async getSessionUsageSummary() {
263
+ // chat-web has no token / cost telemetry — it's a browser puppeteer,
264
+ // not an API client.
265
+ return {
266
+ sessionId: this.sessionId,
267
+ sessionFilePath: undefined,
268
+ totalCostUsd: undefined,
269
+ usage: null,
270
+ rateLimits: null,
271
+ manualResume: null,
272
+ };
273
+ }
274
+ async getChatWebModule() {
275
+ if (this.options.chatWebModule && typeof this.options.chatWebModule === "object") {
276
+ return this.options.chatWebModule;
277
+ }
278
+ try {
279
+ return await import("@love-moon/chat-web");
280
+ }
281
+ catch (error) {
282
+ if (isPlaywrightMissingError(error)) {
283
+ const enriched = new Error(`@love-moon/chat-web requires Playwright Chromium. Run: npx playwright install chromium`);
284
+ enriched.cause = error;
285
+ throw enriched;
286
+ }
287
+ const enriched = new Error(`Failed to load @love-moon/chat-web: ${extractErrorMessage(error)}. ` +
288
+ `Install it with: npm install @love-moon/chat-web playwright`);
289
+ enriched.cause = error;
290
+ throw enriched;
291
+ }
292
+ }
293
+ async boot() {
294
+ if (this.booted)
295
+ return;
296
+ if (this.bootPromise)
297
+ return this.bootPromise;
298
+ this.bootPromise = this.bootInternal();
299
+ try {
300
+ await this.bootPromise;
301
+ this.booted = true;
302
+ }
303
+ finally {
304
+ this.bootPromise = null;
305
+ }
306
+ }
307
+ async bootInternal() {
308
+ if (this.closeRequested) {
309
+ throw this.createSessionClosedError();
310
+ }
311
+ const mod = await this.getChatWebModule();
312
+ if (typeof mod.registerBuiltinProviders === "function") {
313
+ mod.registerBuiltinProviders();
314
+ }
315
+ if (typeof mod.ChatSession?.open !== "function") {
316
+ throw new Error("Loaded @love-moon/chat-web is missing ChatSession.open");
317
+ }
318
+ // Forward optional browser-binary overrides to chat-web. The env vars
319
+ // CHAT_WEB_BROWSER_CHANNEL / CHAT_WEB_BROWSER_EXECUTABLE are also
320
+ // honoured by chat-web directly, so they apply even when nothing is
321
+ // passed here.
322
+ const launch = {};
323
+ if (this.browserChannel)
324
+ launch.channel = this.browserChannel;
325
+ if (this.browserExecutablePath)
326
+ launch.executablePath = this.browserExecutablePath;
327
+ this.chatSession = await mod.ChatSession.open(this.chatWebProvider, {
328
+ headless: this.headless,
329
+ // IMPORTANT: chat-web's logger contract is `{ error, warn, info, debug }`;
330
+ // ai-sdk's normalised logger is `{ log }`. Passing the ai-sdk logger
331
+ // through verbatim crashes chat-web mid-session with
332
+ // "this.logger.debug is not a function". Adapt to chat-web's shape.
333
+ logger: adaptLoggerForChatWeb(this.logger),
334
+ ...(Object.keys(launch).length > 0 ? { launch } : {}),
335
+ });
336
+ if (this.closeRequested) {
337
+ await this.chatSession.close().catch(() => undefined);
338
+ this.chatSession = null;
339
+ throw this.createSessionClosedError();
340
+ }
341
+ const loggedIn = await this.chatSession.isLoggedIn().catch(() => false);
342
+ if (!loggedIn) {
343
+ const error = new Error(`chat-web provider "${this.chatWebProvider}" is not logged in. Run: chat-web login ${this.chatWebProvider}`);
344
+ error.reason = "not_logged_in";
345
+ this.emit("auth_required", {
346
+ reason: "login_required",
347
+ message: error.message,
348
+ });
349
+ throw error;
350
+ }
351
+ this.trace(`session ready provider=${this.chatWebProvider} id=${this.sessionId}`);
352
+ this.emit("session", this.getSessionInfo());
353
+ }
354
+ /**
355
+ * Adopt the provider-side conversation id (ChatGPT /c/{uuid} or
356
+ * equivalent) as our canonical sessionId, update sessionInfo, and emit
357
+ * a fresh `session` event so downstream consumers (ai-sdk runner,
358
+ * conductor daemon, web UI) pick up the change.
359
+ *
360
+ * Idempotent on identical values. Once promoted, the id stays stable
361
+ * for the rest of this ChatWebSession (next runTurn won't re-promote
362
+ * to something else unless the provider session truly changes).
363
+ */
364
+ applyProviderConversationId(conversationId) {
365
+ const normalized = typeof conversationId === "string" ? conversationId.trim() : "";
366
+ if (!normalized)
367
+ return;
368
+ if (normalized === this.providerConversationId)
369
+ return;
370
+ this.providerConversationId = normalized;
371
+ this.sessionId = normalized;
372
+ this.sessionInfo = {
373
+ ...(this.sessionInfo || {}),
374
+ backend: this.backend,
375
+ sessionId: normalized,
376
+ model: this.chatWebProvider,
377
+ modelProvider: "chat-web",
378
+ providerConversationId: normalized,
379
+ providerUrl: this.providerConversationUrl(),
380
+ };
381
+ this.trace(`adopted provider conversation id ${normalized}`);
382
+ this.emit("session", this.getSessionInfo());
383
+ }
384
+ /**
385
+ * Build a deep-link to the provider's conversation page so the UI can
386
+ * render "open in ChatGPT" / "open in AI Studio" links.
387
+ */
388
+ providerConversationUrl() {
389
+ if (!this.providerConversationId)
390
+ return undefined;
391
+ switch (this.chatWebProvider) {
392
+ case "chatgpt":
393
+ return `https://chatgpt.com/c/${this.providerConversationId}`;
394
+ case "gemini":
395
+ // chat-web's `gemini` provider targets AI Studio
396
+ // (aistudio.google.com/prompts/new_chat) — that's the free web
397
+ // chat surface users call "Gemini". Once a prompt is saved,
398
+ // the URL becomes /prompts/{slug}.
399
+ return `https://aistudio.google.com/prompts/${this.providerConversationId}`;
400
+ default:
401
+ return undefined;
402
+ }
403
+ }
404
+ async runTurn(promptText, { onProgress = null } = {}) {
405
+ const prompt = String(promptText || "").trim();
406
+ if (!prompt) {
407
+ return {
408
+ text: "",
409
+ usage: null,
410
+ items: [],
411
+ events: [],
412
+ provider: this.backend,
413
+ metadata: {
414
+ source: CHAT_WEB_SESSION_VARIANT,
415
+ sessionId: this.sessionId,
416
+ chatWebProvider: this.chatWebProvider,
417
+ },
418
+ };
419
+ }
420
+ if (this.currentTurn) {
421
+ const error = new Error("chat-web turn already in progress");
422
+ error.reason = "turn_already_running";
423
+ throw error;
424
+ }
425
+ this.currentTurn = { aborted: false };
426
+ await this.emitWorkingStatus({
427
+ phase: "turn_started",
428
+ reply_in_progress: true,
429
+ status_line: `chat-web (${this.chatWebProvider}) is working`,
430
+ }, onProgress);
431
+ try {
432
+ await this.boot();
433
+ if (this.closeRequested)
434
+ throw this.createSessionClosedError();
435
+ const result = await this.chatSession.send(prompt, {
436
+ timeoutMs: this.turnTimeoutMs,
437
+ onProgress: (text) => {
438
+ // chat-web's onProgress fires while streaming text grows; we
439
+ // forward those as working_status updates with a short preview.
440
+ void this.emitWorkingStatus({
441
+ phase: "message_aggregation",
442
+ reply_in_progress: true,
443
+ status_line: `chat-web (${this.chatWebProvider}) streaming`,
444
+ reply_preview: typeof text === "string" ? text.slice(-120) : undefined,
445
+ }, onProgress);
446
+ },
447
+ });
448
+ const text = String(result?.response ?? "").trim();
449
+ // Adopt the provider's real conversation id once it lands (e.g.
450
+ // ChatGPT's /c/{uuid}). This replaces the synthetic
451
+ // "chat-web-{provider}-{ts}" id we minted at construction so
452
+ // downstream callers (UI, daemon, persistence) see the real
453
+ // provider-side id and can deep-link straight to chatgpt.com/c/...
454
+ const conversationId = typeof result?.conversationId === "string" && result.conversationId.trim()
455
+ ? result.conversationId.trim()
456
+ : typeof this.chatSession?.conversationId === "string" && this.chatSession.conversationId.trim()
457
+ ? this.chatSession.conversationId.trim()
458
+ : "";
459
+ if (conversationId && conversationId !== this.sessionId) {
460
+ this.applyProviderConversationId(conversationId);
461
+ }
462
+ if (text) {
463
+ this.history.push({ role: "assistant", content: text });
464
+ await this.emitAssistantMessage(text);
465
+ }
466
+ await this.emitTerminalWorkingStatus({
467
+ phase: this.currentTurn.aborted ? "turn_interrupted" : "turn_completed",
468
+ status_done_line: this.currentTurn.aborted
469
+ ? `chat-web (${this.chatWebProvider}) interrupted`
470
+ : `chat-web (${this.chatWebProvider}) finished`,
471
+ }, onProgress);
472
+ return {
473
+ text,
474
+ usage: null,
475
+ items: [],
476
+ events: [],
477
+ provider: this.backend,
478
+ metadata: {
479
+ source: CHAT_WEB_SESSION_VARIANT,
480
+ sessionId: this.sessionId,
481
+ chatWebProvider: this.chatWebProvider,
482
+ conversationId: this.providerConversationId,
483
+ providerUrl: this.providerConversationUrl(),
484
+ turnIndex: result?.turnIndex,
485
+ durationMs: result?.durationMs,
486
+ },
487
+ };
488
+ }
489
+ catch (error) {
490
+ const message = extractErrorMessage(error);
491
+ const code = typeof error?.code === "string" ? error.code : "";
492
+ // Surface chat-web's typed "needs API key" / "permission denied"
493
+ // errors as auth_required so the UI / daemon can route them
494
+ // through the same flow as ChatGPT's "not logged in" — they're
495
+ // all "operator action required" failures, not transient errors.
496
+ if (code === "PROVIDER_API_KEY_REQUIRED" || code === "PROVIDER_PERMISSION_DENIED") {
497
+ this.emit("auth_required", {
498
+ reason: code === "PROVIDER_API_KEY_REQUIRED" ? "api_key_required" : "permission_denied",
499
+ message,
500
+ provider: this.chatWebProvider,
501
+ hint: error?.hint,
502
+ });
503
+ }
504
+ await this.emitTerminalWorkingStatus({
505
+ phase: this.currentTurn?.aborted ? "turn_interrupted" : "turn_failed",
506
+ status_done_line: message,
507
+ }, onProgress);
508
+ throw error;
509
+ }
510
+ finally {
511
+ this.activeReplyTarget = "";
512
+ this.currentTurn = null;
513
+ }
514
+ }
515
+ async interruptCurrentTurn() {
516
+ // chat-web's ChatSession doesn't expose a turn abort — the underlying
517
+ // Chromium tab is still busy with the model. We mark the turn as
518
+ // aborted so the next status emission is "interrupted", but the
519
+ // assistant_message may still arrive once the model finishes.
520
+ if (this.currentTurn) {
521
+ this.currentTurn.aborted = true;
522
+ }
523
+ }
524
+ async close() {
525
+ if (this.closed)
526
+ return;
527
+ this.closed = true;
528
+ this.closeRequested = true;
529
+ if (this.chatSession) {
530
+ try {
531
+ await this.chatSession.close();
532
+ }
533
+ catch {
534
+ // best effort
535
+ }
536
+ this.chatSession = null;
537
+ }
538
+ }
539
+ createSessionClosedError() {
540
+ const error = new Error("chat-web session closed");
541
+ error.reason = "session_closed";
542
+ return error;
543
+ }
544
+ async emitWorkingStatus(payload, onProgress = null) {
545
+ const updatedAtMs = Date.now();
546
+ const normalized = {
547
+ source: CHAT_WEB_SESSION_VARIANT,
548
+ reply_in_progress: Boolean(payload?.reply_in_progress),
549
+ replyTo: payload?.replyTo || this.getCurrentReplyTarget(),
550
+ state: payload?.state,
551
+ phase: payload?.phase,
552
+ status_line: payload?.status_line,
553
+ status_done_line: payload?.status_done_line,
554
+ reply_preview: payload?.reply_preview,
555
+ thread_id: this.sessionId,
556
+ session_id: this.sessionId,
557
+ session_file_path: undefined,
558
+ updated_at: new Date(updatedAtMs).toISOString(),
559
+ };
560
+ this.currentTurnStatus = normalized;
561
+ if (typeof onProgress === "function") {
562
+ try {
563
+ onProgress(normalized);
564
+ }
565
+ catch {
566
+ // best effort
567
+ }
568
+ }
569
+ if (typeof this.workingStatusHandler === "function") {
570
+ await this.workingStatusHandler(normalized);
571
+ }
572
+ this.emit("working_status", normalized);
573
+ }
574
+ async emitTerminalWorkingStatus(payload, onProgress = null) {
575
+ await this.emitWorkingStatus({ ...payload, reply_in_progress: false }, onProgress);
576
+ }
577
+ async emitAssistantMessage(text) {
578
+ const payload = {
579
+ text,
580
+ preserveWhitespace: true,
581
+ source: CHAT_WEB_SESSION_VARIANT,
582
+ replyTo: this.getCurrentReplyTarget(),
583
+ sessionId: this.sessionId,
584
+ sessionFilePath: undefined,
585
+ timestamp: new Date().toISOString(),
586
+ };
587
+ if (typeof this.sessionMessageHandler === "function") {
588
+ await this.sessionMessageHandler(payload);
589
+ }
590
+ this.emit("assistant_message", payload);
591
+ }
592
+ }
593
+ 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" | "acceptEdits" | "bypassPermissions" | "plan" | "dontAsk";
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";