@love-moon/ai-sdk 0.3.2 → 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.
Files changed (58) hide show
  1. package/CHANGELOG.md +11 -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 +584 -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,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" | "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";