@love-moon/ai-sdk 0.8.0 → 0.10.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 (45) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/built-in-backends.d.ts +1 -0
  3. package/dist/built-in-backends.js +6 -0
  4. package/dist/context-files.d.ts +16 -0
  5. package/dist/context-files.js +86 -0
  6. package/dist/index.d.ts +4 -1
  7. package/dist/index.js +4 -1
  8. package/dist/manager/index.d.ts +1 -0
  9. package/dist/manager/index.js +1 -0
  10. package/dist/manager/install.js +29 -2
  11. package/dist/manager/manager.d.ts +5 -1
  12. package/dist/manager/manager.js +8 -0
  13. package/dist/manager/network.js +4 -2
  14. package/dist/manager/quota/dsh.d.ts +55 -0
  15. package/dist/manager/quota/dsh.js +149 -0
  16. package/dist/manager/types.d.ts +25 -1
  17. package/dist/media-adapters.d.ts +34 -0
  18. package/dist/media-adapters.js +57 -0
  19. package/dist/media-input.d.ts +21 -0
  20. package/dist/media-input.js +197 -0
  21. package/dist/providers/chat-web-session.d.ts +7 -1
  22. package/dist/providers/chat-web-session.js +12 -1
  23. package/dist/providers/claude-agent-sdk-session.d.ts +10 -1
  24. package/dist/providers/claude-agent-sdk-session.js +24 -4
  25. package/dist/providers/codex-app-server-session.d.ts +19 -21
  26. package/dist/providers/codex-app-server-session.js +124 -12
  27. package/dist/providers/codex-exec-session.d.ts +8 -3
  28. package/dist/providers/codex-exec-session.js +13 -13
  29. package/dist/providers/copilot-sdk-session.d.ts +6 -1
  30. package/dist/providers/copilot-sdk-session.js +10 -3
  31. package/dist/providers/dsh-sdk-session.d.ts +194 -0
  32. package/dist/providers/dsh-sdk-session.js +951 -0
  33. package/dist/providers/kimi-cli-session.d.ts +6 -1
  34. package/dist/providers/kimi-cli-session.js +13 -3
  35. package/dist/providers/kimi-print-session.d.ts +10 -3
  36. package/dist/providers/kimi-print-session.js +23 -53
  37. package/dist/providers/opencode-sdk-session.d.ts +6 -1
  38. package/dist/providers/opencode-sdk-session.js +11 -9
  39. package/dist/resume/dsh.d.ts +23 -0
  40. package/dist/resume/dsh.js +122 -0
  41. package/dist/resume/index.js +2 -0
  42. package/dist/session-factory.d.ts +3 -1
  43. package/dist/session-factory.js +5 -1
  44. package/dsh/cordis.yml +100 -0
  45. package/package.json +24 -3
@@ -0,0 +1,21 @@
1
+ export function normalizeMediaInputs(value: any): {
2
+ kind: any;
3
+ path: any;
4
+ mimeType: any;
5
+ name: string;
6
+ sizeBytes: number;
7
+ }[];
8
+ export function resolveTurnMedia(sessionOptions: any, turnOptions?: {}): {
9
+ kind: any;
10
+ path: any;
11
+ mimeType: any;
12
+ name: string;
13
+ sizeBytes: number;
14
+ }[];
15
+ export function assertMediaCapabilities(media: any, backend: any, capabilities: any): void;
16
+ export function fileMediaToDataUri(media: any): string;
17
+ export function fileMediaToBase64(media: any): string;
18
+ export function createMediaInputError(message: any, extras?: {}): Error;
19
+ export function defaultPromptForMedia(media: any): string;
20
+ export const MEDIA_CAPABILITY_NATIVE: "native";
21
+ export const MEDIA_CAPABILITY_UNSUPPORTED: "unsupported";
@@ -0,0 +1,197 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ const SUPPORTED_KINDS = new Set(["image"]);
4
+ const SUPPORTED_MIME_TYPES = new Set([
5
+ "image/gif",
6
+ "image/jpeg",
7
+ "image/png",
8
+ "image/webp",
9
+ ]);
10
+ const MAX_MEDIA_ITEMS = 20;
11
+ const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
12
+ const MAX_TOTAL_MEDIA_BYTES = 100 * 1024 * 1024;
13
+ export const MEDIA_CAPABILITY_NATIVE = "native";
14
+ export const MEDIA_CAPABILITY_UNSUPPORTED = "unsupported";
15
+ function inferKind(mimeType) {
16
+ if (mimeType.startsWith("image/"))
17
+ return "image";
18
+ return "";
19
+ }
20
+ function inferMimeType(filePath, kind) {
21
+ const extension = path.extname(filePath).toLowerCase();
22
+ const known = {
23
+ ".bmp": "image/bmp",
24
+ ".gif": "image/gif",
25
+ ".heic": "image/heic",
26
+ ".jpeg": "image/jpeg",
27
+ ".jpg": "image/jpeg",
28
+ ".png": "image/png",
29
+ ".webp": "image/webp",
30
+ };
31
+ return known[extension] || `${kind}/unknown`;
32
+ }
33
+ function hasExpectedSignature(filePath, mimeType) {
34
+ const handle = fs.openSync(filePath, "r");
35
+ try {
36
+ const header = Buffer.alloc(12);
37
+ const bytesRead = fs.readSync(handle, header, 0, header.length, 0);
38
+ if (bytesRead < 4)
39
+ return false;
40
+ if (mimeType === "image/png")
41
+ return header.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex"));
42
+ if (mimeType === "image/jpeg")
43
+ return header[0] === 0xff && header[1] === 0xd8 && header[2] === 0xff;
44
+ if (mimeType === "image/gif")
45
+ return header.subarray(0, 6).toString("ascii") === "GIF87a" || header.subarray(0, 6).toString("ascii") === "GIF89a";
46
+ if (mimeType === "image/webp")
47
+ return header.subarray(0, 4).toString("ascii") === "RIFF" && header.subarray(8, 12).toString("ascii") === "WEBP";
48
+ return false;
49
+ }
50
+ finally {
51
+ fs.closeSync(handle);
52
+ }
53
+ }
54
+ export function normalizeMediaInputs(value) {
55
+ if (value === undefined || value === null)
56
+ return [];
57
+ if (!Array.isArray(value)) {
58
+ throw createMediaInputError("media must be an array", { reason: "invalid_media_input" });
59
+ }
60
+ if (value.length > MAX_MEDIA_ITEMS) {
61
+ throw createMediaInputError(`media cannot contain more than ${MAX_MEDIA_ITEMS} files`, {
62
+ reason: "media_limit_exceeded",
63
+ });
64
+ }
65
+ let totalBytes = 0;
66
+ return value.map((item, index) => {
67
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
68
+ throw createMediaInputError(`media[${index}] must be an object`, {
69
+ reason: "invalid_media_input",
70
+ mediaIndex: index,
71
+ });
72
+ }
73
+ const filePath = typeof item.path === "string" ? item.path.trim() : "";
74
+ const mimeType = typeof item.mimeType === "string" ? item.mimeType.trim().toLowerCase() : "";
75
+ const explicitKind = typeof item.kind === "string" ? item.kind.trim().toLowerCase() : "";
76
+ const kind = explicitKind || inferKind(mimeType);
77
+ if (!SUPPORTED_KINDS.has(kind)) {
78
+ throw createMediaInputError(`media[${index}] kind must be image`, {
79
+ reason: "invalid_media_input",
80
+ mediaIndex: index,
81
+ });
82
+ }
83
+ if (!filePath || !path.isAbsolute(filePath)) {
84
+ throw createMediaInputError(`media[${index}] path must be absolute`, {
85
+ reason: "invalid_media_input",
86
+ mediaIndex: index,
87
+ });
88
+ }
89
+ let stat;
90
+ try {
91
+ const linkStat = fs.lstatSync(filePath);
92
+ if (linkStat.isSymbolicLink()) {
93
+ throw new Error("symbolic links are not allowed");
94
+ }
95
+ stat = fs.statSync(filePath);
96
+ }
97
+ catch {
98
+ throw createMediaInputError(`media[${index}] file does not exist`, {
99
+ reason: "invalid_media_input",
100
+ mediaIndex: index,
101
+ });
102
+ }
103
+ if (!stat.isFile()) {
104
+ throw createMediaInputError(`media[${index}] path must reference a file`, {
105
+ reason: "invalid_media_input",
106
+ mediaIndex: index,
107
+ });
108
+ }
109
+ const normalizedMimeType = mimeType || inferMimeType(filePath, kind);
110
+ if (!SUPPORTED_MIME_TYPES.has(normalizedMimeType) || inferKind(normalizedMimeType) !== kind) {
111
+ throw createMediaInputError(`media[${index}] kind does not match mimeType`, {
112
+ reason: "invalid_media_input",
113
+ mediaIndex: index,
114
+ });
115
+ }
116
+ if (!hasExpectedSignature(filePath, normalizedMimeType)) {
117
+ throw createMediaInputError(`media[${index}] content does not match mimeType`, {
118
+ reason: "invalid_media_input",
119
+ mediaIndex: index,
120
+ });
121
+ }
122
+ const itemLimit = MAX_IMAGE_BYTES;
123
+ if (stat.size > itemLimit) {
124
+ throw createMediaInputError(`media[${index}] exceeds the ${kind} size limit`, {
125
+ reason: "media_limit_exceeded",
126
+ mediaIndex: index,
127
+ maxBytes: itemLimit,
128
+ });
129
+ }
130
+ totalBytes += stat.size;
131
+ if (totalBytes > MAX_TOTAL_MEDIA_BYTES) {
132
+ throw createMediaInputError("media exceeds the total size limit", {
133
+ reason: "media_limit_exceeded",
134
+ maxBytes: MAX_TOTAL_MEDIA_BYTES,
135
+ });
136
+ }
137
+ return {
138
+ kind,
139
+ path: filePath,
140
+ mimeType: normalizedMimeType,
141
+ name: typeof item.name === "string" && item.name.trim()
142
+ ? path.basename(item.name.trim())
143
+ : path.basename(filePath),
144
+ sizeBytes: stat.size,
145
+ };
146
+ });
147
+ }
148
+ export function resolveTurnMedia(sessionOptions, turnOptions = {}) {
149
+ const media = normalizeMediaInputs(turnOptions.media);
150
+ if (!turnOptions.useInitialImages || !Array.isArray(sessionOptions?.initialImages)) {
151
+ return media;
152
+ }
153
+ const legacy = sessionOptions.initialImages
154
+ .filter((item) => typeof item === "string" && item.trim())
155
+ .map((filePath) => ({
156
+ kind: "image",
157
+ path: path.resolve(sessionOptions?.cwd || process.cwd(), filePath.trim()),
158
+ mimeType: inferMimeType(filePath.trim(), "image"),
159
+ }));
160
+ const combined = [...normalizeMediaInputs(legacy), ...media];
161
+ const seen = new Set();
162
+ return combined.filter((item) => {
163
+ const key = `${item.kind}:${item.path}`;
164
+ if (seen.has(key))
165
+ return false;
166
+ seen.add(key);
167
+ return true;
168
+ });
169
+ }
170
+ export function assertMediaCapabilities(media, backend, capabilities) {
171
+ for (const [index, item] of media.entries()) {
172
+ if (capabilities?.[item.kind] !== MEDIA_CAPABILITY_NATIVE) {
173
+ throw createMediaInputError(`${backend} does not support ${item.kind} input`, {
174
+ reason: "unsupported_media",
175
+ backend,
176
+ mediaKind: item.kind,
177
+ mediaIndex: index,
178
+ });
179
+ }
180
+ }
181
+ }
182
+ export function fileMediaToDataUri(media) {
183
+ return `data:${media.mimeType};base64,${fs.readFileSync(media.path).toString("base64")}`;
184
+ }
185
+ export function fileMediaToBase64(media) {
186
+ return fs.readFileSync(media.path).toString("base64");
187
+ }
188
+ export function createMediaInputError(message, extras = {}) {
189
+ const error = new Error(message);
190
+ for (const [key, value] of Object.entries(extras)) {
191
+ error[key] = value;
192
+ }
193
+ return error;
194
+ }
195
+ export function defaultPromptForMedia(media) {
196
+ return "Analyze the attached images.";
197
+ }
@@ -109,6 +109,11 @@ export class ChatWebSession extends EventEmitter<[never]> {
109
109
  session_file_path: undefined;
110
110
  updated_at: string;
111
111
  } | null;
112
+ capabilities: {
113
+ media: Readonly<{
114
+ image: "unsupported";
115
+ }>;
116
+ };
112
117
  chatWebProvider: string;
113
118
  providerConversationId: any;
114
119
  providerUrl: string | undefined;
@@ -173,7 +178,8 @@ export class ChatWebSession extends EventEmitter<[never]> {
173
178
  * render "open in ChatGPT" / "open in AI Studio" links.
174
179
  */
175
180
  providerConversationUrl(): string | undefined;
176
- runTurn(promptText: any, { onProgress }?: {
181
+ runTurn(promptText: any, { useInitialImages, media: mediaInput, contextFiles, onProgress }?: {
182
+ useInitialImages?: boolean | undefined;
177
183
  onProgress?: null | undefined;
178
184
  }): Promise<{
179
185
  text: string;
@@ -1,5 +1,8 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { CHAT_WEB_SESSION_VARIANT } from "../built-in-backends.js";
3
+ import { PROVIDER_MEDIA_CAPABILITIES } from "../media-adapters.js";
4
+ import { assertMediaCapabilities, resolveTurnMedia } from "../media-input.js";
5
+ import { normalizeContextFiles } from "../context-files.js";
3
6
  import { emitLog, normalizeLogger } from "../shared.js";
4
7
  const SUPPORTED_CHAT_WEB_PROVIDERS = new Set(["chatgpt", "gemini"]);
5
8
  const DEFAULT_CHAT_WEB_PROVIDER = "chatgpt";
@@ -209,6 +212,7 @@ export class ChatWebSession extends EventEmitter {
209
212
  ? { ready: true, command: this.providerConversationUrl() }
210
213
  : null,
211
214
  currentTurnStatus: this.getCurrentTurnStatus(),
215
+ capabilities: { media: PROVIDER_MEDIA_CAPABILITIES[CHAT_WEB_SESSION_VARIANT] },
212
216
  chatWebProvider: this.chatWebProvider,
213
217
  providerConversationId: this.providerConversationId,
214
218
  providerUrl: this.providerConversationUrl(),
@@ -401,7 +405,14 @@ export class ChatWebSession extends EventEmitter {
401
405
  return undefined;
402
406
  }
403
407
  }
404
- async runTurn(promptText, { onProgress = null } = {}) {
408
+ async runTurn(promptText, { useInitialImages = false, media: mediaInput, contextFiles, onProgress = null } = {}) {
409
+ if (normalizeContextFiles(contextFiles).length > 0) {
410
+ const error = new Error("chat-web does not support context file input");
411
+ error.reason = "unsupported_context_files";
412
+ throw error;
413
+ }
414
+ const media = resolveTurnMedia(this.options, { useInitialImages, media: mediaInput });
415
+ assertMediaCapabilities(media, this.backend, PROVIDER_MEDIA_CAPABILITIES[CHAT_WEB_SESSION_VARIANT]);
405
416
  const prompt = String(promptText || "").trim();
406
417
  if (!prompt) {
407
418
  return {
@@ -1,10 +1,16 @@
1
1
  export class ClaudeAgentSdkSession extends EventEmitter<[never]> {
2
2
  static capabilities: Readonly<{
3
3
  goal: true;
4
+ media: Readonly<{
5
+ image: "native";
6
+ }>;
4
7
  }>;
5
8
  constructor(backend: any, options?: {});
6
9
  getCapabilities(): {
7
10
  goal: true;
11
+ media: Readonly<{
12
+ image: "native";
13
+ }>;
8
14
  };
9
15
  backend: string;
10
16
  options: {};
@@ -68,6 +74,9 @@ export class ClaudeAgentSdkSession extends EventEmitter<[never]> {
68
74
  currentTurnStatus: any;
69
75
  capabilities: {
70
76
  goal: true;
77
+ media: Readonly<{
78
+ image: "native";
79
+ }>;
71
80
  };
72
81
  };
73
82
  getSessionInfo(): {
@@ -131,7 +140,7 @@ export class ClaudeAgentSdkSession extends EventEmitter<[never]> {
131
140
  onProgress: any;
132
141
  }): Promise<void>;
133
142
  interruptCurrentTurn(): Promise<boolean>;
134
- runTurn(promptText: any, { useInitialImages, onProgress, jsonSchema }?: {
143
+ runTurn(promptText: any, { useInitialImages, media: mediaInput, contextFiles, onProgress, jsonSchema }?: {
135
144
  useInitialImages?: boolean | undefined;
136
145
  onProgress?: null | undefined;
137
146
  jsonSchema?: null | undefined;
@@ -1,5 +1,8 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { CLAUDE_AGENT_SDK_VARIANT as CLAUDE_PROVIDER_VARIANT } from "../built-in-backends.js";
3
+ import { appendContextFilesToPrompt } from "../context-files.js";
4
+ import { PROVIDER_MEDIA_CAPABILITIES, buildClaudeContent } from "../media-adapters.js";
5
+ import { assertMediaCapabilities, defaultPromptForMedia, resolveTurnMedia, } from "../media-input.js";
3
6
  import { emitLog, extractLongFlagFromCommandLine, getBoundedEnvInt, isGoalStatus, loadEnvConfig, normalizeLogger, proxyToEnv, sanitizeForLog, } from "../shared.js";
4
7
  const DEFAULT_TURN_DEADLINE_MS = 12 * 60 * 1000;
5
8
  const MIN_TURN_DEADLINE_MS = 30 * 1000;
@@ -8,6 +11,16 @@ const DEFAULT_SETTING_SOURCES = ["user", "project", "local"];
8
11
  function waitForever() {
9
12
  return new Promise(() => { });
10
13
  }
14
+ async function* buildClaudeInput(promptText, media) {
15
+ yield {
16
+ type: "user",
17
+ message: {
18
+ role: "user",
19
+ content: buildClaudeContent(promptText, media),
20
+ },
21
+ parent_tool_use_id: null,
22
+ };
23
+ }
11
24
  function createTurnError(message, extras = {}) {
12
25
  const error = new Error(message);
13
26
  for (const [key, value] of Object.entries(extras)) {
@@ -129,7 +142,10 @@ export class ClaudeAgentSdkSession extends EventEmitter {
129
142
  // Capability advertised via getSnapshot().capabilities so worker proxies
130
143
  // can short-circuit runGoal without an IPC round trip. Claude exposes
131
144
  // native `/goal` slash command, so this is true.
132
- static capabilities = Object.freeze({ goal: true });
145
+ static capabilities = Object.freeze({
146
+ goal: true,
147
+ media: PROVIDER_MEDIA_CAPABILITIES[CLAUDE_PROVIDER_VARIANT],
148
+ });
133
149
  getCapabilities() {
134
150
  return { ...ClaudeAgentSdkSession.capabilities };
135
151
  }
@@ -722,11 +738,15 @@ export class ClaudeAgentSdkSession extends EventEmitter {
722
738
  }
723
739
  return true;
724
740
  }
725
- async runTurn(promptText, { useInitialImages = false, onProgress = null, jsonSchema = null } = {}) {
741
+ async runTurn(promptText, { useInitialImages = false, media: mediaInput, contextFiles, onProgress = null, jsonSchema = null } = {}) {
726
742
  if (this.closeRequested) {
727
743
  throw this.createSessionClosedError();
728
744
  }
729
- const effectivePrompt = this.buildPrompt(promptText, { useInitialImages });
745
+ const media = resolveTurnMedia(this.options, { useInitialImages, media: mediaInput });
746
+ assertMediaCapabilities(media, this.backend, PROVIDER_MEDIA_CAPABILITIES[CLAUDE_PROVIDER_VARIANT]);
747
+ let effectivePrompt = this.buildPrompt(promptText, { useInitialImages: false }) ||
748
+ (media.length ? defaultPromptForMedia(media) : "");
749
+ effectivePrompt = appendContextFilesToPrompt(effectivePrompt, contextFiles).prompt;
730
750
  if (!effectivePrompt) {
731
751
  return {
732
752
  text: "",
@@ -776,7 +796,7 @@ export class ClaudeAgentSdkSession extends EventEmitter {
776
796
  status_line: "claude is working",
777
797
  }, onProgress);
778
798
  const query = sdkModule.query({
779
- prompt: effectivePrompt,
799
+ prompt: media.length ? buildClaudeInput(effectivePrompt, media) : effectivePrompt,
780
800
  options: this.buildSdkOptions(abortController),
781
801
  });
782
802
  currentTurn.query = query;
@@ -53,6 +53,7 @@ export class CodexAppServerSession extends EventEmitter<[never]> {
53
53
  } | null;
54
54
  bootPromise: Promise<void> | null;
55
55
  booted: boolean;
56
+ _contextRecoveryInFlight: boolean;
56
57
  goalMode: boolean;
57
58
  currentGoal: (import("../shared.js").GoalState & {
58
59
  raw?: unknown;
@@ -127,6 +128,22 @@ export class CodexAppServerSession extends EventEmitter<[never]> {
127
128
  }): void;
128
129
  applySessionConfigured(params: any): void;
129
130
  resolveContextUsagePercent(): number | undefined;
131
+ /**
132
+ * Whether a turn failure was caused by the provider thread outgrowing the
133
+ * model context window. Such failures are recoverable by rolling onto a fresh
134
+ * thread; ordinary failures (timeouts, closes, tool errors) are not.
135
+ *
136
+ * @param {any} error
137
+ * @returns {boolean}
138
+ */
139
+ isRecoverableContextOverflow(error: any): boolean;
140
+ /**
141
+ * Reset thread-bound state so the next {@link boot} starts a brand-new Codex
142
+ * thread, seeded with the most-recent {@link RECOVERY_HISTORY_LIMIT} history
143
+ * entries as a prompt preface. The oversized thread is abandoned (never
144
+ * resumed) so its context can no longer overflow.
145
+ */
146
+ rollOntoFreshThread(): void;
130
147
  normalizeWorkingStatusPayload(payload: any): {
131
148
  source: string;
132
149
  reply_in_progress: boolean;
@@ -223,29 +240,10 @@ export class CodexAppServerSession extends EventEmitter<[never]> {
223
240
  handleTransportExit(payload: any): void;
224
241
  maybeEmitAuthRequired(error: any): void;
225
242
  interruptCurrentTurn(): Promise<boolean>;
226
- runTurn(promptText: any, { useInitialImages, jsonSchema }?: {
243
+ runTurn(promptText: any, { useInitialImages, media: mediaInput, contextFiles, jsonSchema }?: {
227
244
  useInitialImages?: boolean | undefined;
228
245
  jsonSchema?: null | undefined;
229
- }): Promise<{
230
- text: string;
231
- usage: null;
232
- items: never[];
233
- events: never[];
234
- provider?: undefined;
235
- metadata?: undefined;
236
- } | {
237
- text: string;
238
- usage: any;
239
- items: any;
240
- events: never[];
241
- provider: string;
242
- metadata: {
243
- source: string;
244
- threadId: any;
245
- threadPath: string | undefined;
246
- nativeSessionId: string | undefined;
247
- };
248
- }>;
246
+ }): any;
249
247
  /**
250
248
  * Normalize a goal payload from a codex `thread/goal/*` notification into
251
249
  * the cross-layer {@link GoalState} shape.
@@ -1,5 +1,8 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { CODEX_APP_SERVER_VARIANT } from "../built-in-backends.js";
3
+ import { appendContextFilesToPrompt } from "../context-files.js";
4
+ import { PROVIDER_MEDIA_CAPABILITIES, buildCodexAppServerInput } from "../media-adapters.js";
5
+ import { assertMediaCapabilities, defaultPromptForMedia, resolveTurnMedia, } from "../media-input.js";
3
6
  import { CodexAppServerTransport } from "../transports/codex-app-server-transport.js";
4
7
  import { TERMINAL_GOAL_STATUSES, emitLog, getBoundedEnvInt, loadEnvConfig, normalizeLogger, proxyToEnv, sanitizeForLog, } from "../shared.js";
5
8
  const TERMINAL_GOAL_STATUSES_LOCAL = new Set(TERMINAL_GOAL_STATUSES);
@@ -9,6 +12,15 @@ function isTerminalGoalStatusLocal(status) {
9
12
  const DEFAULT_TURN_DEADLINE_MS = 12 * 60 * 1000;
10
13
  const MIN_TURN_DEADLINE_MS = 30 * 1000;
11
14
  const MAX_TURN_DEADLINE_MS = 30 * 60 * 1000;
15
+ // Oversized-thread recovery: when a Codex turn fails because the provider
16
+ // thread outgrew the model context window (and remote compaction could not
17
+ // recover it), roll the same logical chat onto a fresh thread seeded with this
18
+ // many most-recent history entries and retry the prompt once.
19
+ const RECOVERY_HISTORY_LIMIT = 12;
20
+ // Matches the various ways Codex / the underlying model report a context-window
21
+ // overflow. Kept broad but anchored on "context"/"token" wording so ordinary
22
+ // failures are not misclassified as recoverable.
23
+ const CONTEXT_OVERFLOW_PATTERN = /context[ _-]?(?:length|window|limit)|context[ _-]?length[ _-]?exceeded|maximum[ _-]?context|exceed(?:s|ed)?[^.]{0,40}context|too many tokens|token[ _-]?limit|input[^.]{0,20}too long|prompt[^.]{0,20}too long/i;
12
24
  function waitForever() {
13
25
  return new Promise(() => { });
14
26
  }
@@ -35,14 +47,6 @@ function extractSessionConfiguredValue(params, ...keys) {
35
47
  }
36
48
  return undefined;
37
49
  }
38
- function buildTurnInput(promptText) {
39
- return [
40
- {
41
- type: "text",
42
- text: promptText,
43
- },
44
- ];
45
- }
46
50
  function injectJsonSchemaPrompt(promptText, jsonSchema) {
47
51
  const schemaText = typeof jsonSchema === "string" ? jsonSchema : JSON.stringify(jsonSchema, null, 2);
48
52
  return `You must respond with valid JSON that strictly conforms to the following JSON Schema. Do not include any markdown formatting or explanation outside the JSON object.
@@ -179,7 +183,10 @@ export class CodexAppServerSession extends EventEmitter {
179
183
  * Treat as read-only.
180
184
  * @type {Readonly<import("../shared.js").SessionCapabilities>}
181
185
  */
182
- static capabilities = Object.freeze({ goal: true });
186
+ static capabilities = Object.freeze({
187
+ goal: true,
188
+ media: PROVIDER_MEDIA_CAPABILITIES[CODEX_APP_SERVER_VARIANT],
189
+ });
183
190
  /**
184
191
  * @returns {import("../shared.js").SessionCapabilities}
185
192
  */
@@ -219,6 +226,7 @@ export class CodexAppServerSession extends EventEmitter {
219
226
  this.currentTurn = null;
220
227
  this.bootPromise = null;
221
228
  this.booted = false;
229
+ this._contextRecoveryInFlight = false;
222
230
  this.goalMode = options.goalMode === true;
223
231
  this.currentGoal = null;
224
232
  // Goal-mode lifecycle is decoupled from `currentTurn` so that goal events
@@ -467,6 +475,61 @@ export class CodexAppServerSession extends EventEmitter {
467
475
  }
468
476
  return Math.max(0, Math.min(100, Math.round((totalTokens / contextWindow) * 100)));
469
477
  }
478
+ /**
479
+ * Whether a turn failure was caused by the provider thread outgrowing the
480
+ * model context window. Such failures are recoverable by rolling onto a fresh
481
+ * thread; ordinary failures (timeouts, closes, tool errors) are not.
482
+ *
483
+ * @param {any} error
484
+ * @returns {boolean}
485
+ */
486
+ isRecoverableContextOverflow(error) {
487
+ if (!error || error.reason === "session_closed" || error.reason === "turn_timeout") {
488
+ return false;
489
+ }
490
+ const parts = [];
491
+ if (typeof error.message === "string")
492
+ parts.push(error.message);
493
+ if (typeof error.turnStatus === "string")
494
+ parts.push(error.turnStatus);
495
+ const turnError = error.turnError;
496
+ if (typeof turnError === "string") {
497
+ parts.push(turnError);
498
+ }
499
+ else if (turnError && typeof turnError === "object") {
500
+ for (const key of ["message", "code", "type", "reason"]) {
501
+ if (typeof turnError[key] === "string")
502
+ parts.push(turnError[key]);
503
+ }
504
+ try {
505
+ parts.push(JSON.stringify(turnError));
506
+ }
507
+ catch {
508
+ // Ignore non-serializable provider payloads.
509
+ }
510
+ }
511
+ return CONTEXT_OVERFLOW_PATTERN.test(parts.join(" | "));
512
+ }
513
+ /**
514
+ * Reset thread-bound state so the next {@link boot} starts a brand-new Codex
515
+ * thread, seeded with the most-recent {@link RECOVERY_HISTORY_LIMIT} history
516
+ * entries as a prompt preface. The oversized thread is abandoned (never
517
+ * resumed) so its context can no longer overflow.
518
+ */
519
+ rollOntoFreshThread() {
520
+ const recent = this.history.slice(-RECOVERY_HISTORY_LIMIT);
521
+ this.history = recent;
522
+ this.pendingHistorySeed = recent.length > 0;
523
+ this.sessionId = "";
524
+ this.threadPath = "";
525
+ this.nativeSessionId = "";
526
+ this.resumeSessionId = "";
527
+ this.manualResumeReady = false;
528
+ this.sessionInfo = null;
529
+ this.tokenUsage = null;
530
+ this.booted = false;
531
+ this.bootPromise = null;
532
+ }
470
533
  normalizeWorkingStatusPayload(payload) {
471
534
  return {
472
535
  source: CODEX_APP_SERVER_VARIANT,
@@ -1029,11 +1092,15 @@ export class CodexAppServerSession extends EventEmitter {
1029
1092
  }
1030
1093
  return false;
1031
1094
  }
1032
- async runTurn(promptText, { useInitialImages = false, jsonSchema = null } = {}) {
1095
+ async runTurn(promptText, { useInitialImages = false, media: mediaInput, contextFiles, jsonSchema = null } = {}) {
1033
1096
  if (this.closeRequested) {
1034
1097
  throw this.createSessionClosedError();
1035
1098
  }
1036
- let effectivePrompt = this.buildPrompt(promptText, { useInitialImages });
1099
+ const media = resolveTurnMedia(this.options, { useInitialImages, media: mediaInput });
1100
+ assertMediaCapabilities(media, this.backend, PROVIDER_MEDIA_CAPABILITIES[CODEX_APP_SERVER_VARIANT]);
1101
+ let effectivePrompt = this.buildPrompt(promptText, { useInitialImages: false }) ||
1102
+ (media.length ? defaultPromptForMedia(media) : "");
1103
+ effectivePrompt = appendContextFilesToPrompt(effectivePrompt, contextFiles).prompt;
1037
1104
  if (jsonSchema && typeof jsonSchema === "object" && effectivePrompt) {
1038
1105
  effectivePrompt = injectJsonSchemaPrompt(effectivePrompt, jsonSchema);
1039
1106
  }
@@ -1081,7 +1148,7 @@ export class CodexAppServerSession extends EventEmitter {
1081
1148
  threadId: this.sessionId,
1082
1149
  cwd: this.cwd,
1083
1150
  approvalPolicy: "never",
1084
- input: buildTurnInput(effectivePrompt),
1151
+ input: buildCodexAppServerInput(effectivePrompt, media),
1085
1152
  });
1086
1153
  const turnResult = await Promise.race([
1087
1154
  (async () => {
@@ -1123,6 +1190,51 @@ export class CodexAppServerSession extends EventEmitter {
1123
1190
  if (error?.reason === "turn_timeout") {
1124
1191
  await this.interruptCurrentTurn();
1125
1192
  }
1193
+ // Oversized-thread recovery: the provider thread outgrew the context
1194
+ // window and remote compaction could not save it. Roll the same logical
1195
+ // chat onto a fresh thread seeded with bounded recent history and retry
1196
+ // the prompt exactly once, rather than surfacing a dead turn. The
1197
+ // in-flight guard ensures a single retry: if the fresh thread also
1198
+ // overflows, the recursive call skips recovery and fails normally.
1199
+ if (!this._contextRecoveryInFlight &&
1200
+ !this.closeRequested &&
1201
+ !currentTurn.goalMode &&
1202
+ this.isRecoverableContextOverflow(error)) {
1203
+ // Drop the user message pushed for the failed attempt; the retry
1204
+ // re-adds it (and folds prior history in as a preface via buildPrompt).
1205
+ if (this.history.length > 0 &&
1206
+ String(this.history[this.history.length - 1]?.role || "").toLowerCase() === "user") {
1207
+ this.history.pop();
1208
+ }
1209
+ if (this.currentTurn === currentTurn) {
1210
+ this.currentTurn = null;
1211
+ }
1212
+ this._contextRecoveryInFlight = true;
1213
+ // Release the outer turn's guards before recursing so their timers do
1214
+ // not linger for the duration of the retry. Both cleanups are
1215
+ // idempotent; the outer `finally` calls them again harmlessly.
1216
+ closeGuard.cleanup();
1217
+ turnTimeoutGuard.cleanup();
1218
+ try {
1219
+ this.rollOntoFreshThread();
1220
+ this.trace("context overflow; rolling onto a fresh thread and retrying the turn");
1221
+ await this.emitWorkingStatus({
1222
+ phase: "working",
1223
+ reply_in_progress: true,
1224
+ status_line: "codex context full; retrying on a fresh thread",
1225
+ });
1226
+ this.emit("thread_recovered", { reason: "context_overflow", backend: this.backend });
1227
+ return await this.runTurn(promptText, {
1228
+ useInitialImages,
1229
+ media: mediaInput,
1230
+ contextFiles,
1231
+ jsonSchema,
1232
+ });
1233
+ }
1234
+ finally {
1235
+ this._contextRecoveryInFlight = false;
1236
+ }
1237
+ }
1126
1238
  if (!this.closeRequested && error?.reason !== "session_closed") {
1127
1239
  await this.emitWorkingStatus({
1128
1240
  phase: "turn_failed",
@@ -52,6 +52,11 @@ export class CodexExecSession extends EventEmitter<[never]> {
52
52
  resumeReady: boolean;
53
53
  manualResume: null;
54
54
  currentTurnStatus: any;
55
+ capabilities: {
56
+ media: Readonly<{
57
+ image: "native";
58
+ }>;
59
+ };
55
60
  pid: any;
56
61
  };
57
62
  getSessionInfo(): {
@@ -85,13 +90,13 @@ export class CodexExecSession extends EventEmitter<[never]> {
85
90
  emitWorkingStatus(payload: any, onProgress?: null): Promise<any>;
86
91
  emitAssistantMessage(text: any): Promise<void>;
87
92
  buildPrompt(promptText: any): string;
88
- buildExecArgs({ useInitialImages, schemaFilePath, lastMessageFilePath }?: {
89
- useInitialImages?: boolean | undefined;
93
+ buildExecArgs({ media, schemaFilePath, lastMessageFilePath }?: {
94
+ media?: never[] | undefined;
90
95
  schemaFilePath?: string | undefined;
91
96
  lastMessageFilePath?: string | undefined;
92
97
  }): string[];
93
98
  maybeEmitAuthRequired(stderrTail: any): void;
94
- runTurn(promptText: any, { useInitialImages, onProgress, jsonSchema }?: {
99
+ runTurn(promptText: any, { useInitialImages, media: mediaInput, contextFiles, onProgress, jsonSchema }?: {
95
100
  useInitialImages?: boolean | undefined;
96
101
  onProgress?: null | undefined;
97
102
  jsonSchema?: null | undefined;