codex-relay 1.0.3 → 1.0.5

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/dist/src.js CHANGED
@@ -1,16 +1,16 @@
1
- import { A as PairResponseSchema, At as stripPromptSkillMentions, C as ListQueuedThreadInputsResponseSchema, D as ListWorkspaceFilesResponseSchema, E as ListWorkspaceDirectoriesResponseSchema, G as ResolveApprovalRequestSchema, K as ResolveApprovalResponseSchema, Q as RuntimePreferencesSchema, S as ListModelsResponseSchema, St as createOpenApiDocument, T as ListThreadsResponseSchema, Tt as promptMarkdownWithSkills, U as RateLimitsResponseSchema, X as RuntimePreferencesByWorkspacePathSchema, Z as RuntimePreferencesResponseSchema, _ as EncryptedPayloadSchema, a as ArchiveThreadResponseSchema, at as ThreadContextWindowResponseSchema, b as InterruptThreadRunResponseSchema, bt as apiPaths, ct as ThreadMessageDetailResponseSchema, d as CheckoutWorkspaceBranchRequestSchema, dt as ThreadSummarySchema, et as StatusResponseSchema, ft as UpdateRuntimePreferencesRequestSchema, h as CreateThreadRequestSchema, ht as WorkspaceChangesResponseSchema, k as PairRequestSchema, l as ChatMessageSchema, m as ContextWindowUsageSchema, nt as StreamThreadRunRequestSchema, ot as ThreadDetailResponseSchema, p as CommitPushWorkspaceRequestSchema, pt as VersionResponseSchema, q as RunThreadRequestSchema, rt as SubmitThreadInputResponseSchema, st as ThreadMessageDetailFieldSchema, tt as StreamThreadRunEventSchema, vt as WorkspaceGitActionResponseSchema, w as ListSkillsResponseSchema, wt as normalizePromptContext, xt as chatMessageDetailsFromPromptContext, y as ImageAttachmentUploadResponseSchema, z as QueuedThreadInputActionResponseSchema } from "./api-schema2.js";
2
- import { r as legacyCodexRelayDataPath, t as codexRelayDataPath } from "./paths.js";
1
+ import { A as PairResponseSchema, C as ListQueuedThreadInputsResponseSchema, D as ListWorkspaceFilesResponseSchema, Dt as apiPaths, E as ListWorkspaceDirectoriesResponseSchema, G as ResolveApprovalRequestSchema, K as ResolveApprovalResponseSchema, Lt as stripPromptSkillMentions, Mt as promptMarkdownWithSkills, Ot as chatMessageDetailsFromPromptContext, Q as RuntimePreferencesSchema, S as ListModelsResponseSchema, St as WorkspaceGitActionResponseSchema, T as ListThreadsResponseSchema, U as RateLimitsResponseSchema, X as RuntimePreferencesByWorkspacePathSchema, Z as RuntimePreferencesResponseSchema, _ as EncryptedPayloadSchema, a as ArchiveThreadResponseSchema, at as ThreadContextWindowResponseSchema, b as InterruptThreadRunResponseSchema, bt as WorkspaceFileContentResponseSchema, ct as ThreadMessageDetailResponseSchema, d as CheckoutWorkspaceBranchRequestSchema, dt as ThreadSummarySchema, et as StatusResponseSchema, ft as UpdateRuntimePreferencesRequestSchema, h as CreateThreadRequestSchema, jt as normalizePromptContext, k as PairRequestSchema, kt as createOpenApiDocument, l as ChatMessageSchema, m as ContextWindowUsageSchema, mt as VersionResponseSchema, nt as StreamThreadRunRequestSchema, ot as ThreadDetailResponseSchema, p as CommitPushWorkspaceRequestSchema, pt as UpdateWorkspaceFileContentRequestSchema, q as RunThreadRequestSchema, rt as SubmitThreadInputResponseSchema, st as ThreadMessageDetailFieldSchema, tt as StreamThreadRunEventSchema, vt as WorkspaceChangesResponseSchema, w as ListSkillsResponseSchema, y as ImageAttachmentUploadResponseSchema, z as QueuedThreadInputActionResponseSchema } from "./api-schema2.js";
2
+ import { a as getConnectUrlCandidates, i as createPairingQrPayload, o as getConnectUrlGuidance, r as legacyCodexRelayDataPath, s as createTursoPairingSessionStore, t as codexRelayDataPath } from "./paths.js";
3
3
  import { createRequire } from "node:module";
4
4
  import qrcode from "qrcode-terminal";
5
- import { execFile, execFileSync, spawn } from "node:child_process";
5
+ import { execFile, spawn } from "node:child_process";
6
6
  import fs, { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
7
- import { access, appendFile, copyFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
8
- import path, { basename, dirname, extname, join, relative, resolve } from "node:path";
7
+ import { access, appendFile, copyFile, mkdir, open, readFile, readdir, stat, writeFile } from "node:fs/promises";
8
+ import path, { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { z } from "zod";
11
- import os, { homedir, hostname, networkInterfaces } from "node:os";
12
- import { serve } from "@hono/node-server";
13
11
  import { fromByteArray, toByteArray } from "base64-js";
12
+ import os, { homedir, hostname } from "node:os";
13
+ import { serve } from "@hono/node-server";
14
14
  import { createHash, randomBytes, randomUUID } from "node:crypto";
15
15
  import pc from "picocolors";
16
16
  import { openRepository } from "es-git";
@@ -24,7 +24,6 @@ import { randomBytes as randomBytes$1, utf8ToBytes } from "@noble/ciphers/utils.
24
24
  import { ed25519, x25519 } from "@noble/curves/ed25519.js";
25
25
  import { hkdf } from "@noble/hashes/hkdf.js";
26
26
  import { sha256 } from "@noble/hashes/sha2.js";
27
- import { connect } from "@tursodatabase/database";
28
27
  //#region src/app-server.ts
29
28
  var CodexAppServerClient = class {
30
29
  child;
@@ -539,14 +538,16 @@ function updatePreferencesFile(current, patch, workspacePath) {
539
538
  });
540
539
  }
541
540
  function mergeRuntimePreferences(current, patch) {
542
- const { model, reasoningEffort, threadId: _threadId, workspacePath: _workspacePath, ...rest } = UpdateRuntimePreferencesRequestSchema.parse(patch);
541
+ const { model, serviceTier, reasoningEffort, threadId: _threadId, workspacePath: _workspacePath, ...rest } = UpdateRuntimePreferencesRequestSchema.parse(patch);
543
542
  const next = {
544
543
  ...current,
545
544
  ...rest,
546
545
  ...model === null ? {} : { model },
546
+ ...serviceTier === null ? {} : { serviceTier },
547
547
  ...reasoningEffort === null ? {} : { reasoningEffort }
548
548
  };
549
549
  if (model === null) delete next.model;
550
+ if (serviceTier === null) delete next.serviceTier;
550
551
  if (reasoningEffort === null) delete next.reasoningEffort;
551
552
  return RuntimePreferencesSchema.parse({ ...next });
552
553
  }
@@ -798,6 +799,9 @@ const defaultWorkspacePath = process.cwd();
798
799
  const defaultCodexModel = "gpt-5.5";
799
800
  const execFileAsync = promisify(execFile);
800
801
  const IMAGE_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024;
802
+ const WORKSPACE_FILE_PREVIEW_MAX_BYTES = 256 * 1024;
803
+ const LOCAL_MARKDOWN_IMAGE_PATTERN = /!\[([^\]]*)\]\(([^)]*)\)/g;
804
+ const LOCAL_IMAGE_REFERENCE_PATTERN = /\.(gif|heic|heif|jpe?g|png|webp)$/i;
801
805
  const imageAttachmentDirectory = codexRelayDataPath("attachments/images");
802
806
  const relayPackage = createRequire(import.meta.url)("../package.json");
803
807
  const collaborationModeTemplateNames = [
@@ -834,6 +838,7 @@ function createApp(options = {}) {
834
838
  const appServerHistoryLoadsByThreadId = /* @__PURE__ */ new Map();
835
839
  const steeringThreads = /* @__PURE__ */ new Set();
836
840
  const secureSessionsByTokenHash = /* @__PURE__ */ new Map();
841
+ const activeStreamControllers = /* @__PURE__ */ new Set();
837
842
  const threadOptions = { workingDirectory: workspacePath };
838
843
  const scheduleAppServerHistoryLoad = (threadId, cachedMessages) => {
839
844
  if (!appServer || appServerHistoryLoadsByThreadId.has(threadId)) return;
@@ -865,7 +870,7 @@ function createApp(options = {}) {
865
870
  };
866
871
  app.use("*", cors());
867
872
  app.use("*", async (c, next) => {
868
- if (!options.pairing || c.req.method === "OPTIONS" || c.req.path === apiPaths.version || c.req.path.startsWith(`${apiPaths.imageAttachments}/`) || c.req.path.startsWith(apiPaths.pair)) {
873
+ if (!options.pairing || c.req.method === "OPTIONS" || c.req.path === apiPaths.version || c.req.path.startsWith(`${apiPaths.imageAttachments}/`) || c.req.path === apiPaths.sessionsClear || c.req.path.startsWith(apiPaths.pair)) {
869
874
  await next();
870
875
  return;
871
876
  }
@@ -973,6 +978,18 @@ function createApp(options = {}) {
973
978
  });
974
979
  return c.json({ ok: true });
975
980
  });
981
+ app.post(apiPaths.sessionsClear, async (c) => {
982
+ if (!options.pairing) return c.json(apiError("pairing_disabled", "Pairing is not enabled on this server."), 404);
983
+ if (options.pairing.approvalSecret && c.req.header("x-codex-relay-approve-secret") !== options.pairing.approvalSecret) return c.json(apiError("unauthorized", "Pairing clear must come from this machine."), 401);
984
+ const result = await options.pairing.sessions.clearAll();
985
+ secureSessionsByTokenHash.clear();
986
+ closeActiveStreamControllers(activeStreamControllers);
987
+ options.pairing.onPairingsCleared?.(result);
988
+ return c.json({
989
+ ok: true,
990
+ ...result
991
+ });
992
+ });
976
993
  app.post(apiPaths.sessionRefresh, async (c) => {
977
994
  if (!options.pairing) return c.json(apiError("pairing_disabled", "Pairing is not enabled on this server."), 404);
978
995
  const oldToken = parseBearerToken(c.req.header("authorization"));
@@ -1157,8 +1174,12 @@ function createApp(options = {}) {
1157
1174
  const selectedWorkspacePath = await validateThreadWorkspacePath(workspacePath, c.req.query("workspacePath"));
1158
1175
  if (!selectedWorkspacePath.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("invalid_workspace_path", selectedWorkspacePath.error), 400);
1159
1176
  const query = c.req.query("query")?.trim() ?? "";
1177
+ const directoryPath = normalizeWorkspaceDirectoryPath(c.req.query("directory") ?? "");
1178
+ if (!directoryPath.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("invalid_workspace_file_path", directoryPath.error), 400);
1160
1179
  const response = ListWorkspaceFilesResponseSchema.parse({
1161
- files: await listWorkspaceFiles(selectedWorkspacePath.path, query),
1180
+ directory: directoryPath.path,
1181
+ files: await listWorkspaceFiles(selectedWorkspacePath.path, query, directoryPath.path),
1182
+ parentDirectory: parentWorkspaceDirectory(directoryPath.path),
1162
1183
  query,
1163
1184
  workspacePath: selectedWorkspacePath.path
1164
1185
  });
@@ -1167,6 +1188,40 @@ function createApp(options = {}) {
1167
1188
  return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("workspace_files_unavailable", errorMessage(error)), 502);
1168
1189
  }
1169
1190
  });
1191
+ app.get(apiPaths.workspaceFileContent, async (c) => {
1192
+ try {
1193
+ const selectedWorkspacePath = await validateThreadWorkspacePath(workspacePath, c.req.query("workspacePath"));
1194
+ if (!selectedWorkspacePath.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("invalid_workspace_path", selectedWorkspacePath.error), 400);
1195
+ const requestedPath = c.req.query("path")?.trim();
1196
+ if (!requestedPath) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("missing_workspace_file_path", "Workspace file path is required."), 400);
1197
+ const file = await readWorkspaceFileContent(selectedWorkspacePath.path, requestedPath);
1198
+ if (!file.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError(file.code, file.error), file.status);
1199
+ const response = WorkspaceFileContentResponseSchema.parse({
1200
+ workspacePath: selectedWorkspacePath.path,
1201
+ ...file.content
1202
+ });
1203
+ return secureJson(c, options.pairing, secureSessionsByTokenHash, response);
1204
+ } catch (error) {
1205
+ return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("workspace_file_unavailable", errorMessage(error)), 502);
1206
+ }
1207
+ });
1208
+ app.put(apiPaths.workspaceFileContent, async (c) => {
1209
+ const parsed = await parseRequestJson(c, options.pairing, secureSessionsByTokenHash, UpdateWorkspaceFileContentRequestSchema);
1210
+ if (!parsed.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, validationError(parsed.error), 400);
1211
+ try {
1212
+ const selectedWorkspacePath = await validateThreadWorkspacePath(workspacePath, parsed.data.workspacePath);
1213
+ if (!selectedWorkspacePath.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("invalid_workspace_path", selectedWorkspacePath.error), 400);
1214
+ const file = await updateWorkspaceFileContent(selectedWorkspacePath.path, parsed.data);
1215
+ if (!file.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError(file.code, file.error), file.status);
1216
+ const response = WorkspaceFileContentResponseSchema.parse({
1217
+ workspacePath: selectedWorkspacePath.path,
1218
+ ...file.content
1219
+ });
1220
+ return secureJson(c, options.pairing, secureSessionsByTokenHash, response);
1221
+ } catch (error) {
1222
+ return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("workspace_file_update_unavailable", errorMessage(error)), 502);
1223
+ }
1224
+ });
1170
1225
  app.get(apiPaths.rateLimits, async (c) => {
1171
1226
  if (!appServer) return secureJson(c, options.pairing, secureSessionsByTokenHash, RateLimitsResponseSchema.parse({ buckets: [] }));
1172
1227
  try {
@@ -1299,7 +1354,7 @@ function createApp(options = {}) {
1299
1354
  let loadedMessages = false;
1300
1355
  let messages = cachedMessages;
1301
1356
  let responseThread = preserveKnownRunningThreadState(mappedThread, wasKnownRunning);
1302
- const rolloutHistory = readRolloutThreadMessages(threadId);
1357
+ const rolloutHistory = readRolloutThreadMessages(threadId, workspacePath);
1303
1358
  if (rolloutHistory.messages.length > 0) {
1304
1359
  messages = mergeThreadMessagePages(rolloutHistory.messages, cachedMessages);
1305
1360
  responseThread = rememberRolloutThreadMessages(threads, responseThread, messages, rolloutHistory.messageCountLowerBound);
@@ -1338,7 +1393,7 @@ function createApp(options = {}) {
1338
1393
  });
1339
1394
  }
1340
1395
  const cachedMessages = messagesByThreadId.get(threadId) ?? [];
1341
- const rolloutHistory = readRolloutThreadMessages(threadId);
1396
+ const rolloutHistory = readRolloutThreadMessages(threadId, workspacePath);
1342
1397
  const baseThread = threads.get(threadId) ?? knownThread ?? (rolloutHistory.rolloutPath ? rolloutThreadMetadata(threadId, workspacePath, rolloutHistory.rolloutPath, [...rolloutHistory.messages, ...cachedMessages]) : void 0);
1343
1398
  if (baseThread && (cachedMessages.length > 0 || rolloutHistory.messages.length > 0)) {
1344
1399
  const messages = rolloutHistory.messages.length > 0 ? mergeThreadMessagePages(rolloutHistory.messages, cachedMessages) : dedupeThreadMessages(cachedMessages);
@@ -1705,9 +1760,12 @@ function createApp(options = {}) {
1705
1760
  });
1706
1761
  const encoder = new TextEncoder();
1707
1762
  const secureSession = getSecureSessionForRequest(c, options.pairing, secureSessionsByTokenHash);
1763
+ let streamController;
1708
1764
  let streamSettled = false;
1709
1765
  const stream = new ReadableStream({
1710
1766
  start(controller) {
1767
+ streamController = controller;
1768
+ activeStreamControllers.add(controller);
1711
1769
  relayDebugLog("thread.stream.started", {
1712
1770
  mode: !runOptions.prompt && appServer ? "attach" : "run",
1713
1771
  threadId
@@ -1738,6 +1796,7 @@ function createApp(options = {}) {
1738
1796
  mode: "attach",
1739
1797
  threadId
1740
1798
  });
1799
+ activeStreamControllers.delete(controller);
1741
1800
  stopPreviewMonitor();
1742
1801
  });
1743
1802
  return;
@@ -1755,6 +1814,7 @@ function createApp(options = {}) {
1755
1814
  mode: "unsupported",
1756
1815
  threadId
1757
1816
  });
1817
+ activeStreamControllers.delete(controller);
1758
1818
  stopPreviewMonitor();
1759
1819
  return;
1760
1820
  }
@@ -1787,10 +1847,12 @@ function createApp(options = {}) {
1787
1847
  mode: "run",
1788
1848
  threadId
1789
1849
  });
1850
+ activeStreamControllers.delete(controller);
1790
1851
  stopPreviewMonitor();
1791
1852
  });
1792
1853
  },
1793
1854
  cancel(reason) {
1855
+ if (streamController) activeStreamControllers.delete(streamController);
1794
1856
  relayDebugLog("thread.stream.cancelled_by_client", {
1795
1857
  reason: debugReason(reason),
1796
1858
  settled: streamSettled,
@@ -1880,7 +1942,8 @@ async function createAppServerThreadRecord(input) {
1880
1942
  experimentalRawEvents: false,
1881
1943
  model: input.options.model ?? null,
1882
1944
  persistExtendedHistory: true,
1883
- sandbox: runtime.sandbox
1945
+ sandbox: runtime.sandbox,
1946
+ serviceTier: input.options.serviceTier ?? null
1884
1947
  });
1885
1948
  const metadata = ThreadSummarySchema.parse({
1886
1949
  ...mapAppServerThread({
@@ -2133,6 +2196,7 @@ async function startAppServerTurn(appServer, threadId, input) {
2133
2196
  input: appServerTurnInput(input.prompt, input.attachments, input.skills),
2134
2197
  model: input.runOptions.model ?? null,
2135
2198
  sandboxPolicy: runtime.sandboxPolicy,
2199
+ serviceTier: input.runOptions.serviceTier ?? null,
2136
2200
  threadId
2137
2201
  };
2138
2202
  await resumeAppServerThreadIfNeeded(appServer, threadId, input, runtime);
@@ -2164,6 +2228,7 @@ async function resumeAppServerThread(appServer, threadId, input, runtime) {
2164
2228
  model: input.runOptions.model ?? null,
2165
2229
  persistExtendedHistory: true,
2166
2230
  sandbox: runtime.sandbox,
2231
+ serviceTier: input.runOptions.serviceTier ?? null,
2167
2232
  threadId
2168
2233
  });
2169
2234
  }
@@ -2918,7 +2983,8 @@ async function recoverMissingAppServerThread(input) {
2918
2983
  experimentalRawEvents: false,
2919
2984
  model: input.runOptions.model ?? null,
2920
2985
  persistExtendedHistory: true,
2921
- sandbox: runtime.sandbox
2986
+ sandbox: runtime.sandbox,
2987
+ serviceTier: input.runOptions.serviceTier ?? null
2922
2988
  });
2923
2989
  const recoveredThread = mapAppServerThread({
2924
2990
  ...thread,
@@ -3109,6 +3175,9 @@ function appServerUserMessageText(item) {
3109
3175
  case "text": return content.text;
3110
3176
  case "image":
3111
3177
  case "localImage":
3178
+ case "document":
3179
+ case "file":
3180
+ case "localFile":
3112
3181
  case "mention":
3113
3182
  case "skill": return "";
3114
3183
  }
@@ -3127,11 +3196,86 @@ function appServerUserMessageDetails(item) {
3127
3196
  type: "image"
3128
3197
  }];
3129
3198
  case "localImage": return localImageAttachmentDetails(content.path);
3199
+ case "document":
3200
+ case "file":
3201
+ case "localFile": return appServerDocumentAttachmentDetails(content);
3130
3202
  default: return [];
3131
3203
  }
3132
3204
  });
3133
3205
  return attachments.length > 0 ? { attachments } : void 0;
3134
3206
  }
3207
+ function appServerAgentMessageParts(item) {
3208
+ return agentMessageParts(item.text);
3209
+ }
3210
+ function agentMessageParts(text) {
3211
+ const imageReferences = localMarkdownImageReferences(text);
3212
+ if (imageReferences.length === 0) return {
3213
+ content: text,
3214
+ details: void 0
3215
+ };
3216
+ const materializedReferences = /* @__PURE__ */ new Set();
3217
+ const attachments = imageReferences.flatMap((reference) => {
3218
+ const name = reference.alt || basename(localImageFilePath(reference.destination) ?? reference.destination);
3219
+ const details = localImageAttachmentDetails(reference.destination, name);
3220
+ if (details.length > 0) materializedReferences.add(reference.destination);
3221
+ return details;
3222
+ });
3223
+ return {
3224
+ content: attachments.length > 0 ? stripMaterializedMarkdownImages(text, materializedReferences) : text,
3225
+ details: attachments.length > 0 ? { attachments } : void 0
3226
+ };
3227
+ }
3228
+ function localMarkdownImageReferences(markdown) {
3229
+ const references = [];
3230
+ const seen = /* @__PURE__ */ new Set();
3231
+ for (const match of markdown.matchAll(LOCAL_MARKDOWN_IMAGE_PATTERN)) {
3232
+ const destination = markdownImageDestination(match[2] ?? "");
3233
+ if (!destination || seen.has(destination) || !isLocalMarkdownImageReference(destination)) continue;
3234
+ seen.add(destination);
3235
+ references.push({
3236
+ alt: (match[1] ?? "").trim(),
3237
+ destination
3238
+ });
3239
+ }
3240
+ return references;
3241
+ }
3242
+ function stripMaterializedMarkdownImages(markdown, destinations) {
3243
+ return markdown.replace(LOCAL_MARKDOWN_IMAGE_PATTERN, (match, _alt, rawDestination) => {
3244
+ const destination = markdownImageDestination(rawDestination);
3245
+ return destination && destinations.has(destination) ? "" : match;
3246
+ }).replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
3247
+ }
3248
+ function markdownImageDestination(value) {
3249
+ let destination = value.trim();
3250
+ if (destination.startsWith("<")) {
3251
+ const endIndex = destination.indexOf(">");
3252
+ if (endIndex > 0) destination = destination.slice(1, endIndex);
3253
+ } else {
3254
+ const titleIndex = destination.search(/\s+["']/);
3255
+ if (titleIndex > 0) destination = destination.slice(0, titleIndex);
3256
+ }
3257
+ try {
3258
+ return decodeURI(destination);
3259
+ } catch {
3260
+ return destination;
3261
+ }
3262
+ }
3263
+ function isLocalMarkdownImageReference(reference) {
3264
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(reference) && !reference.startsWith("file://")) return false;
3265
+ const filePath = localImageFilePath(reference);
3266
+ return Boolean(filePath && LOCAL_IMAGE_REFERENCE_PATTERN.test(filePath));
3267
+ }
3268
+ function appServerDocumentAttachmentDetails(input) {
3269
+ const reference = input.path ?? input.url;
3270
+ if (!reference) return [];
3271
+ return [{
3272
+ mimeType: input.mimeType,
3273
+ name: input.name ?? basename(reference),
3274
+ path: input.path,
3275
+ type: "document",
3276
+ url: input.url
3277
+ }];
3278
+ }
3135
3279
  async function saveUploadedImageAttachment(file) {
3136
3280
  if (!file.type.startsWith("image/")) throw new Error(`Unsupported attachment type: ${file.type || "unknown"}`);
3137
3281
  if (file.size > IMAGE_ATTACHMENT_MAX_BYTES) throw new Error(`Image ${file.name || "attachment"} is too large.`);
@@ -3351,10 +3495,32 @@ function updateThread(threads, messagesByThreadId, threadId, update) {
3351
3495
  }
3352
3496
  function sendSse(controller, encoder, secureSession, event) {
3353
3497
  const parsed = StreamThreadRunEventSchema.parse(event);
3498
+ const threadId = threadIdFromStreamEvent(parsed);
3499
+ relayDebugLog("thread.stream.sse", {
3500
+ direction: "server_to_mobile",
3501
+ eventType: parsed.type,
3502
+ threadId,
3503
+ payload: parsed
3504
+ });
3354
3505
  const data = secureSession ? EncryptedPayloadSchema.parse(encryptForMobile(secureSession.session, JSON.stringify(parsed))) : parsed;
3355
3506
  if (secureSession) secureSession.persist().catch(() => void 0);
3356
- if (!enqueueSseChunk(controller, encoder.encode(`event: ${parsed.type}\n`))) return;
3357
- enqueueSseChunk(controller, encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
3507
+ if (!enqueueSseChunk(controller, encoder.encode(`event: ${parsed.type}\n`))) {
3508
+ relayDebugLog("thread.stream.sse.enqueue_failed", {
3509
+ eventType: parsed.type,
3510
+ stage: "event",
3511
+ threadId
3512
+ });
3513
+ return;
3514
+ }
3515
+ if (!enqueueSseChunk(controller, encoder.encode(`data: ${JSON.stringify(data)}\n\n`))) relayDebugLog("thread.stream.sse.enqueue_failed", {
3516
+ eventType: parsed.type,
3517
+ stage: "data",
3518
+ threadId
3519
+ });
3520
+ }
3521
+ function threadIdFromStreamEvent(event) {
3522
+ if ("threadId" in event && typeof event.threadId === "string") return event.threadId;
3523
+ if ("thread" in event && event.thread) return event.thread.id;
3358
3524
  }
3359
3525
  function enqueueSseChunk(controller, chunk) {
3360
3526
  try {
@@ -3374,6 +3540,10 @@ function closeSseController(controller) {
3374
3540
  throw error;
3375
3541
  }
3376
3542
  }
3543
+ function closeActiveStreamControllers(controllers) {
3544
+ for (const controller of controllers) closeSseController(controller);
3545
+ controllers.clear();
3546
+ }
3377
3547
  function isClosedStreamControllerError(error) {
3378
3548
  return error instanceof TypeError && error.code === "ERR_INVALID_STATE" && error.message.includes("Controller is already closed");
3379
3549
  }
@@ -3556,6 +3726,7 @@ function runtimeMetadataFromOptions(options) {
3556
3726
  const sandboxMode = options.sandboxMode ?? runtime.sandboxMode;
3557
3727
  return {
3558
3728
  ...options.model ? { model: options.model } : {},
3729
+ ...options.serviceTier ? { serviceTier: options.serviceTier } : {},
3559
3730
  ...options.runtimeMode ? { runtimeMode: options.runtimeMode } : {},
3560
3731
  ...options.collaborationMode ? { collaborationMode: options.collaborationMode } : {},
3561
3732
  ...approvalPolicy ? { approvalPolicy } : {},
@@ -3568,6 +3739,7 @@ function withRuntimePreferences(preferences, options) {
3568
3739
  ...options,
3569
3740
  approvalPolicy: options.approvalPolicy,
3570
3741
  model: options.model ?? preferences.model,
3742
+ serviceTier: options.serviceTier ?? preferences.serviceTier,
3571
3743
  reasoningEffort: options.reasoningEffort ?? preferences.reasoningEffort,
3572
3744
  runtimeMode: options.runtimeMode ?? preferences.runtimeMode,
3573
3745
  sandboxMode: options.sandboxMode
@@ -3615,7 +3787,7 @@ function sandboxPolicyForMode(sandboxMode, workspacePath) {
3615
3787
  };
3616
3788
  }
3617
3789
  function hasExplicitRunOptions(options) {
3618
- return Boolean(options.model || options.reasoningEffort || options.approvalPolicy || options.sandboxMode || options.collaborationMode === "plan" || options.runtimeMode);
3790
+ return Boolean(options.model || options.serviceTier || options.reasoningEffort || options.approvalPolicy || options.sandboxMode || options.collaborationMode === "plan" || options.runtimeMode);
3619
3791
  }
3620
3792
  function mapAppServerThread(thread, fallbackMessageCount) {
3621
3793
  const createdAt = fromUnixSeconds(thread.createdAt);
@@ -3791,7 +3963,7 @@ function readSessionIndexThreadTitle(threadId) {
3791
3963
  } catch {}
3792
3964
  }
3793
3965
  }
3794
- function readRolloutThreadMessages(threadId) {
3966
+ function readRolloutThreadMessages(threadId, workspacePath = defaultWorkspacePath) {
3795
3967
  const rolloutPath = findRolloutFileForThread(threadId);
3796
3968
  if (!rolloutPath) return {
3797
3969
  messageCountLowerBound: 0,
@@ -3799,6 +3971,8 @@ function readRolloutThreadMessages(threadId) {
3799
3971
  rolloutPath
3800
3972
  };
3801
3973
  const collected = [];
3974
+ const applyPatchInputs = /* @__PURE__ */ new Map();
3975
+ const pendingApplyPatchChanges = [];
3802
3976
  const lines = readFileSync(rolloutPath, "utf8").split("\n");
3803
3977
  for (let index = 0; index < lines.length; index += 1) {
3804
3978
  const line = lines[index];
@@ -3806,7 +3980,15 @@ function readRolloutThreadMessages(threadId) {
3806
3980
  if (!line.trim()) continue;
3807
3981
  if (!isRolloutMessageLine(line)) continue;
3808
3982
  try {
3809
- const message = rolloutRecordMessage(threadId, JSON.parse(line), `rollout:${lineNumber}`);
3983
+ const record = JSON.parse(line);
3984
+ rememberRolloutApplyPatchInput(record, applyPatchInputs);
3985
+ collectRolloutApplyPatchOutput(record, workspacePath, applyPatchInputs, pendingApplyPatchChanges);
3986
+ if (isRolloutTaskComplete(record) && pendingApplyPatchChanges.length > 0) {
3987
+ collected.push(rolloutApplyPatchSummaryMessage(threadId, record, `rollout:${lineNumber}:apply_patch`, pendingApplyPatchChanges));
3988
+ pendingApplyPatchChanges.length = 0;
3989
+ continue;
3990
+ }
3991
+ const message = rolloutRecordMessage(threadId, record, `rollout:${lineNumber}`, workspacePath);
3810
3992
  if (!message) continue;
3811
3993
  collected.push(message);
3812
3994
  } catch {}
@@ -3833,7 +4015,10 @@ function findRolloutFileForThread(threadId) {
3833
4015
  }
3834
4016
  }
3835
4017
  }
3836
- function rolloutRecordMessage(threadId, record, messageKey) {
4018
+ function isRolloutTaskComplete(record) {
4019
+ return record.type === "event_msg" && record.payload?.type === "task_complete";
4020
+ }
4021
+ function rolloutRecordMessage(threadId, record, messageKey, workspacePath = defaultWorkspacePath) {
3837
4022
  const timestamp = typeof record.timestamp === "string" ? record.timestamp : (/* @__PURE__ */ new Date()).toISOString();
3838
4023
  const payload = record.payload;
3839
4024
  if (!payload) return;
@@ -3853,15 +4038,37 @@ function rolloutRecordMessage(threadId, record, messageKey) {
3853
4038
  if (record.type === "event_msg" && payload.type === "agent_message") {
3854
4039
  const content = firstString(payload, ["message"]);
3855
4040
  if (!content) return;
4041
+ const messageParts = agentMessageParts(content);
3856
4042
  return ChatMessageSchema.parse({
3857
4043
  id: `${messageKey}:assistant`,
3858
4044
  threadId,
3859
4045
  role: "assistant",
3860
- content,
4046
+ content: messageParts.content,
4047
+ details: messageParts.details,
3861
4048
  createdAt: timestamp,
3862
4049
  state: "completed"
3863
4050
  });
3864
4051
  }
4052
+ if (record.type === "event_msg" && payload.type === "patch_apply_end") {
4053
+ const changes = rolloutPatchApplyChanges(payload.changes, workspacePath);
4054
+ if (changes.length === 0) return;
4055
+ const patchPreview = largeTextPreview(changes.map((change) => change.patch).filter((patch) => Boolean(patch)).join("\n"));
4056
+ return ChatMessageSchema.parse({
4057
+ id: `${messageKey}:patch:${firstString(payload, ["call_id"]) ?? ""}`,
4058
+ threadId,
4059
+ role: "tool",
4060
+ kind: "fileChange",
4061
+ content: summarizeFileChanges(changes),
4062
+ createdAt: timestamp,
4063
+ state: "completed",
4064
+ details: {
4065
+ changes: changes.map(({ patch: _patch, ...change }) => change),
4066
+ patch: patchPreview?.text,
4067
+ patchOriginalLength: patchPreview?.originalLength,
4068
+ patchTruncated: patchPreview?.truncated
4069
+ }
4070
+ });
4071
+ }
3865
4072
  if (record.type === "event_msg" && payload.type === "exec_command_end") {
3866
4073
  const command = Array.isArray(payload.command) ? payload.command.map((part) => String(part)).join(" ") : firstString(payload, ["command"]) || "Command";
3867
4074
  const outputPreview = largeTextPreview(firstString(payload, ["aggregated_output"]));
@@ -3916,8 +4123,114 @@ function rolloutImageAttachments(value, source) {
3916
4123
  return localImageAttachmentDetails(image, name);
3917
4124
  });
3918
4125
  }
4126
+ function rememberRolloutApplyPatchInput(record, applyPatchInputs) {
4127
+ const payload = record.payload;
4128
+ if (record.type !== "response_item" || payload?.type !== "custom_tool_call" || firstString(payload, ["name"]) !== "apply_patch") return;
4129
+ const callId = firstString(payload, ["call_id"]);
4130
+ const input = firstString(payload, ["input"]);
4131
+ if (callId && input) applyPatchInputs.set(callId, input);
4132
+ }
4133
+ function collectRolloutApplyPatchOutput(record, workspacePath, applyPatchInputs, pendingApplyPatchChanges) {
4134
+ const payload = record.payload;
4135
+ if (record.type !== "response_item" || payload?.type !== "custom_tool_call_output" || !firstString(payload, ["call_id"])) return;
4136
+ const changes = rolloutApplyPatchOutputChanges(rolloutCustomToolOutputText(payload), workspacePath);
4137
+ if (changes.length === 0) return;
4138
+ const callId = firstString(payload, ["call_id"]);
4139
+ const patch = callId ? applyPatchInputs.get(callId) : void 0;
4140
+ for (const change of changes) pendingApplyPatchChanges.push({
4141
+ ...change,
4142
+ patch
4143
+ });
4144
+ }
4145
+ function rolloutApplyPatchSummaryMessage(threadId, record, messageKey, pendingApplyPatchChanges) {
4146
+ const timestamp = typeof record.timestamp === "string" ? record.timestamp : (/* @__PURE__ */ new Date()).toISOString();
4147
+ const patchPreview = largeTextPreview([...new Set(pendingApplyPatchChanges.map((change) => change.patch))].filter((patch) => Boolean(patch)).join("\n"));
4148
+ return ChatMessageSchema.parse({
4149
+ id: `${messageKey}:summary`,
4150
+ threadId,
4151
+ role: "tool",
4152
+ kind: "fileChange",
4153
+ content: summarizeFileChanges(pendingApplyPatchChanges),
4154
+ createdAt: timestamp,
4155
+ state: "completed",
4156
+ details: {
4157
+ changes: pendingApplyPatchChanges.map(({ patch: _patch, ...change }) => change),
4158
+ patch: patchPreview?.text,
4159
+ patchOriginalLength: patchPreview?.originalLength,
4160
+ patchTruncated: patchPreview?.truncated
4161
+ }
4162
+ });
4163
+ }
4164
+ function rolloutCustomToolOutputText(payload) {
4165
+ const rawOutput = firstString(payload, ["output"]);
4166
+ if (!rawOutput) return;
4167
+ try {
4168
+ const parsed = JSON.parse(rawOutput);
4169
+ return typeof parsed.output === "string" ? parsed.output : rawOutput;
4170
+ } catch {
4171
+ return rawOutput;
4172
+ }
4173
+ }
4174
+ function rolloutApplyPatchOutputChanges(value, workspacePath) {
4175
+ if (!value || !value.includes("Updated the following files")) return [];
4176
+ return value.split("\n").flatMap((line) => {
4177
+ const match = line.match(/^\s*([ADM])\s+(.+?)\s*$/);
4178
+ if (!match) return [];
4179
+ return [{
4180
+ kind: rolloutPatchChangeKind(match[1] === "A" ? "added" : match[1] === "D" ? "deleted" : "modified"),
4181
+ path: rolloutPatchDisplayPath(match[2] ?? "", workspacePath)
4182
+ }];
4183
+ });
4184
+ }
4185
+ function rolloutPatchApplyChanges(value, workspacePath = defaultWorkspacePath) {
4186
+ if (!value || typeof value !== "object" || Array.isArray(value)) return [];
4187
+ return Object.entries(value).flatMap(([rawPath, rawChange]) => {
4188
+ if (!rawChange || typeof rawChange !== "object") return [];
4189
+ const record = rawChange;
4190
+ return [{
4191
+ kind: rolloutPatchChangeKind(firstString(record, ["type"]) ?? "modified"),
4192
+ patch: firstString(record, ["unified_diff", "patch"]),
4193
+ path: rolloutPatchDisplayPath(rawPath, workspacePath)
4194
+ }];
4195
+ });
4196
+ }
4197
+ function rolloutPatchChangeKind(type) {
4198
+ const normalized = type.toLowerCase();
4199
+ if ([
4200
+ "add",
4201
+ "added",
4202
+ "create",
4203
+ "created"
4204
+ ].includes(normalized)) return "added";
4205
+ if ([
4206
+ "delete",
4207
+ "deleted",
4208
+ "remove",
4209
+ "removed"
4210
+ ].includes(normalized)) return "deleted";
4211
+ if ([
4212
+ "move",
4213
+ "moved",
4214
+ "rename",
4215
+ "renamed"
4216
+ ].includes(normalized)) return "renamed";
4217
+ return "modified";
4218
+ }
4219
+ function rolloutPatchDisplayPath(value, workspacePath = defaultWorkspacePath) {
4220
+ const filePath = value.startsWith("file://") ? fileUrlPath(value) : value;
4221
+ if (!isAbsolute(filePath)) return filePath;
4222
+ const relativePath = relative(resolve(workspacePath), filePath);
4223
+ return relativePath && !relativePath.startsWith("..") && !isAbsolute(relativePath) ? relativePath.split("\\").join("/") : filePath;
4224
+ }
4225
+ function fileUrlPath(value) {
4226
+ try {
4227
+ return fileURLToPath(value);
4228
+ } catch {
4229
+ return value;
4230
+ }
4231
+ }
3919
4232
  function isRolloutMessageLine(line) {
3920
- return line.includes("\"type\":\"event_msg\"") && (line.includes("\"type\":\"user_message\"") || line.includes("\"type\":\"agent_message\"") || line.includes("\"type\":\"exec_command_end\"") || line.includes("\"type\":\"mcp_tool_call_end\""));
4233
+ return line.includes("\"type\":\"event_msg\"") && (line.includes("\"type\":\"user_message\"") || line.includes("\"type\":\"agent_message\"") || line.includes("\"type\":\"patch_apply_end\"") || line.includes("\"type\":\"task_complete\"") || line.includes("\"type\":\"exec_command_end\"") || line.includes("\"type\":\"mcp_tool_call_end\"")) || line.includes("\"type\":\"response_item\"") && (line.includes("\"type\":\"custom_tool_call\"") || line.includes("\"type\":\"custom_tool_call_output\""));
3921
4234
  }
3922
4235
  function threadDetailResponse(input) {
3923
4236
  return ThreadDetailResponseSchema.parse({
@@ -3996,12 +4309,13 @@ function mapAppServerItem(threadId, turn, item) {
3996
4309
  case "agentMessage": {
3997
4310
  const agentItem = item;
3998
4311
  const planContent = proposedPlanContent(agentItem.text);
4312
+ const messageParts = appServerAgentMessageParts(agentItem);
3999
4313
  return ChatMessageSchema.parse({
4000
4314
  ...base,
4001
4315
  role: "assistant",
4002
4316
  kind: planContent ? "plan" : void 0,
4003
- content: planContent ?? agentItem.text,
4004
- details: planContent ? { raw: agentItem.text } : void 0
4317
+ content: planContent ?? messageParts.content,
4318
+ details: planContent ? { raw: agentItem.text } : messageParts.details
4005
4319
  });
4006
4320
  }
4007
4321
  case "reasoning": {
@@ -4115,6 +4429,11 @@ function appServerTurnShell(turnId) {
4115
4429
  };
4116
4430
  }
4117
4431
  function mapAppServerModel(model) {
4432
+ const serviceTiers = model.serviceTiers ?? model.additionalSpeedTiers?.map((tier) => ({
4433
+ id: tier,
4434
+ name: tier === "fast" ? "Fast" : tier,
4435
+ description: void 0
4436
+ })) ?? [];
4118
4437
  return {
4119
4438
  id: model.id,
4120
4439
  model: model.model,
@@ -4122,7 +4441,8 @@ function mapAppServerModel(model) {
4122
4441
  description: model.description,
4123
4442
  isDefault: Boolean(model.isDefault),
4124
4443
  defaultReasoningEffort: model.defaultReasoningEffort,
4125
- supportedReasoningEfforts: model.supportedReasoningEfforts?.map((effort) => effort.reasoningEffort) ?? []
4444
+ supportedReasoningEfforts: model.supportedReasoningEfforts?.map((effort) => effort.reasoningEffort) ?? [],
4445
+ serviceTiers
4126
4446
  };
4127
4447
  }
4128
4448
  function normalizeRateLimitBuckets(rateLimits) {
@@ -4183,7 +4503,13 @@ function fallbackModels() {
4183
4503
  { reasoningEffort: "medium" },
4184
4504
  { reasoningEffort: "high" },
4185
4505
  { reasoningEffort: "xhigh" }
4186
- ]
4506
+ ],
4507
+ additionalSpeedTiers: ["fast"],
4508
+ serviceTiers: [{
4509
+ id: "priority",
4510
+ name: "Fast",
4511
+ description: "1.5x speed, increased usage"
4512
+ }]
4187
4513
  }];
4188
4514
  }
4189
4515
  function mapAppServerThreadState(status, turns) {
@@ -4887,53 +5213,253 @@ async function git(cwd, args) {
4887
5213
  });
4888
5214
  return stdout.trimEnd();
4889
5215
  }
4890
- async function listWorkspaceFiles(workspacePath, query) {
5216
+ async function listWorkspaceFiles(workspacePath, query, directory) {
4891
5217
  const normalizedQuery = query.toLowerCase();
4892
- const filePaths = await workspaceFilePaths(workspacePath);
5218
+ const isIgnored = await workspaceIgnoreMatcher(workspacePath);
5219
+ if (directory && isWorkspacePathIgnored(directory, isIgnored)) return [];
5220
+ const filePaths = await workspaceFilePaths(workspacePath, isIgnored);
4893
5221
  const entriesByPath = /* @__PURE__ */ new Map();
5222
+ for (const entry of await workspaceDirectoryEntries(workspacePath, directory, isIgnored)) entriesByPath.set(entry.path, entry);
4894
5223
  for (const path of filePaths) {
4895
5224
  if (!path || path.startsWith("../") || path.includes("/.git/")) continue;
4896
- entriesByPath.set(path, {
4897
- directory: dirname(path) === "." ? "" : dirname(path),
5225
+ if (!directory && normalizedQuery) {
5226
+ entriesByPath.set(path, {
5227
+ directory: dirname(path) === "." ? "" : dirname(path),
5228
+ kind: "file",
5229
+ name: path.split("/").pop() ?? path,
5230
+ path
5231
+ });
5232
+ const parts = path.split("/");
5233
+ for (let index = 1; index < parts.length; index += 1) {
5234
+ const directoryPath = parts.slice(0, index).join("/");
5235
+ if (isWorkspacePathIgnored(directoryPath, isIgnored)) continue;
5236
+ entriesByPath.set(directoryPath, {
5237
+ directory: dirname(directoryPath) === "." ? "" : dirname(directoryPath),
5238
+ kind: "directory",
5239
+ name: parts[index - 1],
5240
+ path: directoryPath
5241
+ });
5242
+ }
5243
+ continue;
5244
+ }
5245
+ const prefix = directory ? `${directory}/` : "";
5246
+ if (!path.startsWith(prefix)) continue;
5247
+ const childParts = path.slice(prefix.length).split("/").filter(Boolean);
5248
+ if (childParts.length === 0) continue;
5249
+ if (childParts.length === 1) entriesByPath.set(path, {
5250
+ directory,
4898
5251
  kind: "file",
4899
- name: path.split("/").pop() ?? path,
5252
+ name: childParts[0],
4900
5253
  path
4901
5254
  });
4902
- const parts = path.split("/");
4903
- for (let index = 1; index < parts.length; index += 1) {
4904
- const directoryPath = parts.slice(0, index).join("/");
5255
+ else {
5256
+ const directoryPath = [...directory ? directory.split("/") : [], childParts[0]].join("/");
5257
+ if (isWorkspacePathIgnored(directoryPath, isIgnored)) continue;
4905
5258
  entriesByPath.set(directoryPath, {
4906
- directory: dirname(directoryPath) === "." ? "" : dirname(directoryPath),
5259
+ directory,
4907
5260
  kind: "directory",
4908
- name: parts[index - 1],
5261
+ name: childParts[0],
4909
5262
  path: directoryPath
4910
5263
  });
4911
5264
  }
4912
5265
  }
4913
5266
  return [...entriesByPath.values()].filter((entry) => {
4914
5267
  if (!normalizedQuery) return true;
4915
- return entry.path.toLowerCase().includes(normalizedQuery);
5268
+ return entry.name.toLowerCase().includes(normalizedQuery) || entry.path.toLowerCase().includes(normalizedQuery);
4916
5269
  }).sort((left, right) => {
4917
5270
  const leftScore = workspaceFileScore(left.path, normalizedQuery);
4918
5271
  const rightScore = workspaceFileScore(right.path, normalizedQuery);
4919
5272
  if (leftScore !== rightScore) return leftScore - rightScore;
4920
5273
  if (left.kind !== right.kind) return left.kind === "directory" ? -1 : 1;
4921
5274
  return left.path.localeCompare(right.path);
4922
- }).slice(0, 80);
5275
+ }).slice(0, directory || !normalizedQuery ? 160 : 80);
5276
+ }
5277
+ async function readWorkspaceFileContent(workspacePath, requestedPath) {
5278
+ const relativePath = normalizeWorkspaceRelativePath(requestedPath);
5279
+ if (!relativePath.success) return {
5280
+ code: "invalid_workspace_file_path",
5281
+ error: relativePath.error,
5282
+ status: 400,
5283
+ success: false
5284
+ };
5285
+ const rootPath = resolve(workspacePath);
5286
+ const absolutePath = resolve(rootPath, relativePath.path);
5287
+ if (!isPathInside(rootPath, absolutePath)) return {
5288
+ code: "invalid_workspace_file_path",
5289
+ error: "Workspace file path must stay inside the workspace.",
5290
+ status: 400,
5291
+ success: false
5292
+ };
5293
+ const fileStat = await stat(absolutePath).catch(() => null);
5294
+ if (!fileStat?.isFile()) return {
5295
+ code: "workspace_file_not_found",
5296
+ error: "Workspace file was not found.",
5297
+ status: 404,
5298
+ success: false
5299
+ };
5300
+ const bytesToRead = Math.min(fileStat.size, WORKSPACE_FILE_PREVIEW_MAX_BYTES);
5301
+ const buffer = Buffer.alloc(bytesToRead);
5302
+ if (bytesToRead > 0) {
5303
+ const handle = await open(absolutePath, "r");
5304
+ try {
5305
+ await handle.read(buffer, 0, bytesToRead, 0);
5306
+ } finally {
5307
+ await handle.close();
5308
+ }
5309
+ }
5310
+ const binary = buffer.includes(0);
5311
+ return {
5312
+ content: {
5313
+ binary,
5314
+ content: binary ? "" : buffer.toString("utf8"),
5315
+ directory: dirname(relativePath.path) === "." ? "" : dirname(relativePath.path),
5316
+ language: languageFromWorkspaceFile(relativePath.path),
5317
+ name: basename(relativePath.path),
5318
+ path: relativePath.path,
5319
+ size: fileStat.size,
5320
+ truncated: fileStat.size > WORKSPACE_FILE_PREVIEW_MAX_BYTES
5321
+ },
5322
+ success: true
5323
+ };
5324
+ }
5325
+ async function updateWorkspaceFileContent(workspacePath, input) {
5326
+ const relativePath = normalizeWorkspaceRelativePath(input.path);
5327
+ if (!relativePath.success) return {
5328
+ code: "invalid_workspace_file_path",
5329
+ error: relativePath.error,
5330
+ status: 400,
5331
+ success: false
5332
+ };
5333
+ const rootPath = resolve(workspacePath);
5334
+ const absolutePath = resolve(rootPath, relativePath.path);
5335
+ if (!isPathInside(rootPath, absolutePath)) return {
5336
+ code: "invalid_workspace_file_path",
5337
+ error: "Workspace file path must stay inside the workspace.",
5338
+ status: 400,
5339
+ success: false
5340
+ };
5341
+ if (!(await stat(absolutePath).catch(() => null))?.isFile()) return {
5342
+ code: "workspace_file_not_found",
5343
+ error: "Workspace file was not found.",
5344
+ status: 404,
5345
+ success: false
5346
+ };
5347
+ await writeFile(absolutePath, input.content, "utf8");
5348
+ return readWorkspaceFileContent(workspacePath, relativePath.path);
5349
+ }
5350
+ function normalizeWorkspaceRelativePath(requestedPath) {
5351
+ const normalized = requestedPath.trim().replaceAll("\\", "/").replace(/^\.\/+/, "");
5352
+ if (!normalized) return {
5353
+ error: "Workspace file path is required.",
5354
+ success: false
5355
+ };
5356
+ if (isAbsolute(normalized)) return {
5357
+ error: "Workspace file path must be relative.",
5358
+ success: false
5359
+ };
5360
+ const segments = normalized.split("/").filter(Boolean);
5361
+ if (segments.some((segment) => segment === "." || segment === ".." || segment === ".git")) return {
5362
+ error: "Workspace file path contains an unsupported segment.",
5363
+ success: false
5364
+ };
5365
+ return {
5366
+ path: segments.join("/"),
5367
+ success: true
5368
+ };
5369
+ }
5370
+ function normalizeWorkspaceDirectoryPath(requestedPath) {
5371
+ const normalized = requestedPath.trim().replaceAll("\\", "/").replace(/^\.\/+/, "").replace(/\/+$/, "");
5372
+ if (!normalized) return {
5373
+ path: "",
5374
+ success: true
5375
+ };
5376
+ if (isAbsolute(normalized)) return {
5377
+ error: "Workspace directory path must be relative.",
5378
+ success: false
5379
+ };
5380
+ const segments = normalized.split("/").filter(Boolean);
5381
+ if (segments.some((segment) => segment === "." || segment === ".." || segment === ".git")) return {
5382
+ error: "Workspace directory path contains an unsupported segment.",
5383
+ success: false
5384
+ };
5385
+ return {
5386
+ path: segments.join("/"),
5387
+ success: true
5388
+ };
4923
5389
  }
4924
- async function workspaceFilePaths(workspacePath) {
5390
+ function parentWorkspaceDirectory(directory) {
5391
+ if (!directory) return null;
5392
+ const parent = dirname(directory);
5393
+ return parent === "." ? "" : parent;
5394
+ }
5395
+ function isPathInside(rootPath, targetPath) {
5396
+ const pathFromRoot = relative(rootPath, targetPath);
5397
+ return Boolean(pathFromRoot) && !pathFromRoot.startsWith("..") && !isAbsolute(pathFromRoot);
5398
+ }
5399
+ function languageFromWorkspaceFile(path) {
5400
+ const name = basename(path).toLowerCase();
5401
+ const extension = extname(name).replace(/^\./, "");
5402
+ if (name === "dockerfile") return "dockerfile";
5403
+ if (name === "makefile") return "make";
5404
+ if (name === "package.json") return "json";
5405
+ return {
5406
+ cjs: "javascript",
5407
+ css: "css",
5408
+ diff: "diff",
5409
+ go: "go",
5410
+ htm: "html",
5411
+ html: "html",
5412
+ java: "java",
5413
+ js: "javascript",
5414
+ json: "json",
5415
+ jsx: "jsx",
5416
+ kt: "kotlin",
5417
+ lock: "yaml",
5418
+ log: "text",
5419
+ md: "markdown",
5420
+ mdx: "markdown",
5421
+ mjs: "javascript",
5422
+ plist: "xml",
5423
+ py: "python",
5424
+ rb: "ruby",
5425
+ rs: "rust",
5426
+ sh: "bash",
5427
+ swift: "swift",
5428
+ ts: "typescript",
5429
+ tsx: "tsx",
5430
+ txt: "text",
5431
+ xml: "xml",
5432
+ yaml: "yaml",
5433
+ yml: "yaml"
5434
+ }[extension] ?? extension;
5435
+ }
5436
+ async function workspaceFilePaths(workspacePath, isIgnored) {
4925
5437
  try {
4926
5438
  return (await git(workspacePath, [
4927
5439
  "ls-files",
4928
5440
  "--cached",
4929
5441
  "--others",
4930
5442
  "--exclude-standard"
4931
- ])).split("\n").filter(Boolean);
5443
+ ])).split("\n").filter(Boolean).filter((path) => !isWorkspacePathIgnored(path, isIgnored));
4932
5444
  } catch {
4933
- return recursiveWorkspaceFilePaths(workspacePath);
5445
+ return recursiveWorkspaceFilePaths(workspacePath, isIgnored);
4934
5446
  }
4935
5447
  }
4936
- async function recursiveWorkspaceFilePaths(rootPath) {
5448
+ async function workspaceDirectoryEntries(workspacePath, directory, isIgnored) {
5449
+ const rootPath = resolve(workspacePath);
5450
+ const absoluteDirectory = resolve(rootPath, directory);
5451
+ if (absoluteDirectory !== rootPath && !isPathInside(rootPath, absoluteDirectory)) return [];
5452
+ return (await readdir(absoluteDirectory, { withFileTypes: true })).filter((entry) => entry.isDirectory() && entry.name !== ".git").map((entry) => {
5453
+ const path = [...directory ? directory.split("/") : [], entry.name].join("/");
5454
+ return {
5455
+ directory,
5456
+ kind: "directory",
5457
+ name: entry.name,
5458
+ path
5459
+ };
5460
+ }).filter((entry) => !isWorkspacePathIgnored(entry.path, isIgnored));
5461
+ }
5462
+ async function recursiveWorkspaceFilePaths(rootPath, isIgnored) {
4937
5463
  const ignoredDirectories = new Set([
4938
5464
  ".git",
4939
5465
  ".expo",
@@ -4947,17 +5473,56 @@ async function recursiveWorkspaceFilePaths(rootPath) {
4947
5473
  for (const entry of entries) {
4948
5474
  if (entry.name.startsWith(".") && entry.name !== ".github") continue;
4949
5475
  const absolutePath = resolve(directory, entry.name);
5476
+ const relativePath = relative(rootPath, absolutePath).split("\\").join("/");
5477
+ if (isWorkspacePathIgnored(relativePath, isIgnored)) continue;
4950
5478
  if (entry.isDirectory()) {
4951
5479
  if (ignoredDirectories.has(entry.name)) continue;
4952
5480
  await visit(absolutePath);
4953
5481
  continue;
4954
5482
  }
4955
- if (entry.isFile()) results.push(relative(rootPath, absolutePath).split("\\").join("/"));
5483
+ if (entry.isFile()) results.push(relativePath);
4956
5484
  }
4957
5485
  }
4958
5486
  await visit(rootPath);
4959
5487
  return results;
4960
5488
  }
5489
+ async function workspaceIgnoreMatcher(workspacePath) {
5490
+ const matchers = (await readFile(join(workspacePath, ".gitignore"), "utf8").catch(() => "")).split(/\r?\n/).map((line) => gitignorePatternMatcher(line)).filter((matcher) => Boolean(matcher));
5491
+ return (path) => matchers.some((matcher) => matcher(path.split("\\").join("/")));
5492
+ }
5493
+ function isWorkspacePathIgnored(path, isIgnored) {
5494
+ const normalizedPath = path.replace(/^\/+/, "").replaceAll("\\", "/").replace(/\/+$/, "");
5495
+ if (!normalizedPath) return false;
5496
+ if (isIgnored(normalizedPath) || isIgnored(`${normalizedPath}/`)) return true;
5497
+ const parts = normalizedPath.split("/");
5498
+ for (let index = 1; index < parts.length; index += 1) {
5499
+ const ancestorPath = parts.slice(0, index).join("/");
5500
+ if (isIgnored(ancestorPath) || isIgnored(`${ancestorPath}/`)) return true;
5501
+ }
5502
+ return false;
5503
+ }
5504
+ function gitignorePatternMatcher(line) {
5505
+ const trimmed = line.trim();
5506
+ if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("!")) return;
5507
+ const directoryOnly = trimmed.endsWith("/");
5508
+ const anchored = trimmed.startsWith("/");
5509
+ const pattern = trimmed.replace(/^\/+/, "").replace(/\/+$/, "");
5510
+ if (!pattern) return;
5511
+ const matcher = gitignoreGlobMatcher(pattern);
5512
+ const hasSlash = pattern.includes("/");
5513
+ return (path) => {
5514
+ const normalizedPath = path.replace(/^\/+/, "");
5515
+ return (anchored || hasSlash ? [normalizedPath] : normalizedPath.split("/")).some((candidate) => {
5516
+ if (directoryOnly) return matcher(candidate);
5517
+ return matcher(candidate);
5518
+ });
5519
+ };
5520
+ }
5521
+ function gitignoreGlobMatcher(pattern) {
5522
+ const expression = pattern.split("**").map((part) => part.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*")).join(".*").replace(/\?/g, "[^/]");
5523
+ const regex = new RegExp(`^${expression}(?:/.*)?$`);
5524
+ return (path) => regex.test(path);
5525
+ }
4961
5526
  function workspaceFileScore(path, query) {
4962
5527
  if (!query) return path.split("/").length;
4963
5528
  const lowerPath = path.toLowerCase();
@@ -4982,238 +5547,6 @@ function errorMessage(error) {
4982
5547
  return error instanceof Error ? error.message : "Codex run failed.";
4983
5548
  }
4984
5549
  //#endregion
4985
- //#region src/pairing-store.ts
4986
- async function createTursoPairingSessionStore(path) {
4987
- if (path !== ":memory:") await mkdir(dirname(path), { recursive: true });
4988
- const db = await connect(path);
4989
- await db.exec(`
4990
- CREATE TABLE IF NOT EXISTS pairing_sessions (
4991
- token_hash TEXT PRIMARY KEY,
4992
- client_session_id TEXT,
4993
- client_name TEXT,
4994
- expires_at INTEGER NOT NULL,
4995
- key_epoch INTEGER,
4996
- mobile_to_server_key TEXT,
4997
- server_to_mobile_key TEXT,
4998
- last_mobile_counter INTEGER,
4999
- next_server_counter INTEGER,
5000
- created_at INTEGER NOT NULL,
5001
- updated_at INTEGER NOT NULL
5002
- );
5003
-
5004
- CREATE TABLE IF NOT EXISTS pending_pairings (
5005
- approval_code TEXT PRIMARY KEY,
5006
- client_session_id TEXT,
5007
- client_name TEXT,
5008
- client_ephemeral_public_key TEXT NOT NULL,
5009
- client_nonce TEXT NOT NULL,
5010
- server_url TEXT NOT NULL,
5011
- approved INTEGER NOT NULL DEFAULT 0,
5012
- expires_at INTEGER NOT NULL,
5013
- created_at INTEGER NOT NULL,
5014
- updated_at INTEGER NOT NULL
5015
- );
5016
- `);
5017
- await ensurePairingSessionColumns();
5018
- async function countActive(now) {
5019
- const row = await db.prepare("SELECT COUNT(DISTINCT COALESCE(client_session_id, token_hash)) AS count FROM pairing_sessions WHERE expires_at > ?").get(now);
5020
- return Number(row?.count ?? 0);
5021
- }
5022
- async function deleteSession(tokenHash) {
5023
- await db.prepare("DELETE FROM pairing_sessions WHERE token_hash = ?").run(tokenHash);
5024
- }
5025
- async function deletePendingPairing(approvalCode) {
5026
- await db.prepare("DELETE FROM pending_pairings WHERE approval_code = ?").run(approvalCode);
5027
- }
5028
- async function getPendingPairing(approvalCode, now) {
5029
- const row = await db.prepare(`SELECT approval_code AS approvalCode,
5030
- client_session_id AS clientSessionId,
5031
- client_name AS clientName,
5032
- client_ephemeral_public_key AS clientEphemeralPublicKey,
5033
- client_nonce AS clientNonce,
5034
- server_url AS serverUrl,
5035
- approved,
5036
- expires_at AS expiresAt
5037
- FROM pending_pairings
5038
- WHERE approval_code = ?`).get(approvalCode);
5039
- if (!row) return;
5040
- const expiresAt = Number(row.expiresAt);
5041
- if (now > expiresAt) {
5042
- await deletePendingPairing(approvalCode);
5043
- return;
5044
- }
5045
- return {
5046
- approvalCode: String(row.approvalCode),
5047
- approved: Number(row.approved) === 1,
5048
- clientEphemeralPublicKey: String(row.clientEphemeralPublicKey),
5049
- clientSessionId: typeof row.clientSessionId === "string" ? row.clientSessionId : void 0,
5050
- clientName: typeof row.clientName === "string" ? row.clientName : void 0,
5051
- clientNonce: String(row.clientNonce),
5052
- expiresAt,
5053
- serverUrl: String(row.serverUrl)
5054
- };
5055
- }
5056
- return {
5057
- async approvePendingPairing(approvalCode, now) {
5058
- const pending = await getPendingPairing(approvalCode, now);
5059
- if (!pending) return;
5060
- await db.prepare("UPDATE pending_pairings SET approved = 1, updated_at = ? WHERE approval_code = ?").run(now, approvalCode);
5061
- return {
5062
- ...pending,
5063
- approved: true
5064
- };
5065
- },
5066
- countActive,
5067
- async createPendingPairing(pairing) {
5068
- const now = Date.now();
5069
- await db.prepare(`INSERT INTO pending_pairings (
5070
- approval_code,
5071
- client_session_id,
5072
- client_name,
5073
- client_ephemeral_public_key,
5074
- client_nonce,
5075
- server_url,
5076
- approved,
5077
- expires_at,
5078
- created_at,
5079
- updated_at
5080
- )
5081
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(pairing.approvalCode, pairing.clientSessionId ?? null, pairing.clientName ?? null, pairing.clientEphemeralPublicKey, pairing.clientNonce, pairing.serverUrl, pairing.approved ? 1 : 0, pairing.expiresAt, now, now);
5082
- },
5083
- async createSession(tokenHash, session) {
5084
- const now = Date.now();
5085
- const secure = encodeSecureSession(session.secureSession);
5086
- if (session.clientSessionId) {
5087
- await db.prepare("DELETE FROM pairing_sessions WHERE client_session_id = ?").run(session.clientSessionId);
5088
- if (session.clientName) await db.prepare("DELETE FROM pairing_sessions WHERE client_session_id IS NULL AND client_name = ?").run(session.clientName);
5089
- }
5090
- await db.prepare(`INSERT INTO pairing_sessions (
5091
- token_hash,
5092
- client_session_id,
5093
- client_name,
5094
- expires_at,
5095
- key_epoch,
5096
- mobile_to_server_key,
5097
- server_to_mobile_key,
5098
- last_mobile_counter,
5099
- next_server_counter,
5100
- created_at,
5101
- updated_at
5102
- )
5103
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(tokenHash, session.clientSessionId ?? null, session.clientName ?? null, session.expiresAt, secure?.keyEpoch ?? null, secure?.mobileToServerKey ?? null, secure?.serverToMobileKey ?? null, secure?.lastMobileCounter ?? null, secure?.nextServerCounter ?? null, now, now);
5104
- return countActive(now);
5105
- },
5106
- deleteSession,
5107
- deletePendingPairing,
5108
- getPendingPairing,
5109
- async getValidSession(tokenHash, now) {
5110
- const row = await db.prepare(`SELECT client_name AS clientName,
5111
- client_session_id AS clientSessionId,
5112
- expires_at AS expiresAt,
5113
- key_epoch AS keyEpoch,
5114
- mobile_to_server_key AS mobileToServerKey,
5115
- server_to_mobile_key AS serverToMobileKey,
5116
- last_mobile_counter AS lastMobileCounter,
5117
- next_server_counter AS nextServerCounter
5118
- FROM pairing_sessions
5119
- WHERE token_hash = ?`).get(tokenHash);
5120
- if (!row) return;
5121
- const expiresAt = Number(row.expiresAt);
5122
- if (now > expiresAt) {
5123
- await deleteSession(tokenHash);
5124
- return;
5125
- }
5126
- return {
5127
- clientSessionId: typeof row.clientSessionId === "string" ? row.clientSessionId : void 0,
5128
- clientName: typeof row.clientName === "string" ? row.clientName : void 0,
5129
- expiresAt,
5130
- secureSession: decodeSecureSession(row)
5131
- };
5132
- },
5133
- async pruneExpired(now) {
5134
- await db.prepare("DELETE FROM pairing_sessions WHERE expires_at <= ?").run(now);
5135
- await db.prepare("DELETE FROM pending_pairings WHERE expires_at <= ?").run(now);
5136
- },
5137
- async rotateSession(oldTokenHash, newTokenHash, session) {
5138
- const now = Date.now();
5139
- const secure = encodeSecureSession(session.secureSession);
5140
- await db.transaction(async () => {
5141
- await db.prepare("DELETE FROM pairing_sessions WHERE token_hash = ?").run(oldTokenHash);
5142
- if (session.clientSessionId) {
5143
- await db.prepare("DELETE FROM pairing_sessions WHERE client_session_id = ?").run(session.clientSessionId);
5144
- if (session.clientName) await db.prepare("DELETE FROM pairing_sessions WHERE client_session_id IS NULL AND client_name = ?").run(session.clientName);
5145
- }
5146
- await db.prepare(`INSERT INTO pairing_sessions (
5147
- token_hash,
5148
- client_session_id,
5149
- client_name,
5150
- expires_at,
5151
- key_epoch,
5152
- mobile_to_server_key,
5153
- server_to_mobile_key,
5154
- last_mobile_counter,
5155
- next_server_counter,
5156
- created_at,
5157
- updated_at
5158
- )
5159
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newTokenHash, session.clientSessionId ?? null, session.clientName ?? null, session.expiresAt, secure?.keyEpoch ?? null, secure?.mobileToServerKey ?? null, secure?.serverToMobileKey ?? null, secure?.lastMobileCounter ?? null, secure?.nextServerCounter ?? null, now, now);
5160
- })();
5161
- return countActive(now);
5162
- },
5163
- async updateSecureSession(tokenHash, secureSession) {
5164
- const secure = encodeSecureSession(secureSession);
5165
- const now = Date.now();
5166
- await db.prepare(`UPDATE pairing_sessions
5167
- SET key_epoch = ?,
5168
- mobile_to_server_key = ?,
5169
- server_to_mobile_key = ?,
5170
- last_mobile_counter = ?,
5171
- next_server_counter = ?,
5172
- updated_at = ?
5173
- WHERE token_hash = ?`).run(secure.keyEpoch, secure.mobileToServerKey, secure.serverToMobileKey, secure.lastMobileCounter, secure.nextServerCounter, now, tokenHash);
5174
- }
5175
- };
5176
- async function ensurePairingSessionColumns() {
5177
- const rows = await db.prepare("PRAGMA table_info(pairing_sessions)").all();
5178
- const columns = new Set(resultRows(rows).map((row) => String(row.name)));
5179
- for (const [column, sql] of [
5180
- ["client_session_id", "ALTER TABLE pairing_sessions ADD COLUMN client_session_id TEXT"],
5181
- ["key_epoch", "ALTER TABLE pairing_sessions ADD COLUMN key_epoch INTEGER"],
5182
- ["mobile_to_server_key", "ALTER TABLE pairing_sessions ADD COLUMN mobile_to_server_key TEXT"],
5183
- ["server_to_mobile_key", "ALTER TABLE pairing_sessions ADD COLUMN server_to_mobile_key TEXT"],
5184
- ["last_mobile_counter", "ALTER TABLE pairing_sessions ADD COLUMN last_mobile_counter INTEGER"],
5185
- ["next_server_counter", "ALTER TABLE pairing_sessions ADD COLUMN next_server_counter INTEGER"]
5186
- ]) if (!columns.has(column)) await db.exec(sql);
5187
- const pendingRows = await db.prepare("PRAGMA table_info(pending_pairings)").all();
5188
- if (!new Set(resultRows(pendingRows).map((row) => String(row.name))).has("client_session_id")) await db.exec("ALTER TABLE pending_pairings ADD COLUMN client_session_id TEXT");
5189
- }
5190
- }
5191
- function encodeSecureSession(session) {
5192
- if (!session) return;
5193
- return {
5194
- keyEpoch: session.keyEpoch,
5195
- lastMobileCounter: session.lastMobileCounter,
5196
- mobileToServerKey: fromByteArray(session.mobileToServerKey),
5197
- nextServerCounter: session.nextServerCounter,
5198
- serverToMobileKey: fromByteArray(session.serverToMobileKey)
5199
- };
5200
- }
5201
- function decodeSecureSession(row) {
5202
- if (typeof row.mobileToServerKey !== "string" || typeof row.serverToMobileKey !== "string" || row.keyEpoch === null || row.lastMobileCounter === null || row.nextServerCounter === null) return;
5203
- return {
5204
- keyEpoch: Number(row.keyEpoch),
5205
- lastMobileCounter: Number(row.lastMobileCounter),
5206
- mobileToServerKey: toByteArray(row.mobileToServerKey),
5207
- nextServerCounter: Number(row.nextServerCounter),
5208
- serverToMobileKey: toByteArray(row.serverToMobileKey)
5209
- };
5210
- }
5211
- function resultRows(result) {
5212
- if (Array.isArray(result)) return result;
5213
- if (result && typeof result === "object" && Array.isArray(result.rows)) return result.rows;
5214
- return [];
5215
- }
5216
- //#endregion
5217
5550
  //#region src/index.ts
5218
5551
  const port = Number(process.env.PORT ?? 8787);
5219
5552
  const hostname$1 = process.env.HOST ?? "0.0.0.0";
@@ -5264,6 +5597,9 @@ serve({
5264
5597
  onPairApproved: ({ clientName }) => {
5265
5598
  logRuntimeEvent("Approved", `Pairing request approved${clientName ? ` for ${clientName}` : ""}. Waiting for secure session pickup.`);
5266
5599
  },
5600
+ onPairingsCleared: ({ pendingPairingsCleared, sessionsCleared }) => {
5601
+ logRuntimeEvent("Cleared", `Signed out ${sessionsCleared} mobile session${sessionsCleared === 1 ? "" : "s"} and removed ${pendingPairingsCleared} pending pairing request${pendingPairingsCleared === 1 ? "" : "s"}.`);
5602
+ },
5267
5603
  onTokenRefreshed: ({ clientName, tokenCount }) => {
5268
5604
  logRuntimeEvent("Refreshed", `Mobile session rotated${clientName ? ` for ${clientName}` : ""}; ${formatClientCount(tokenCount)} active.`);
5269
5605
  }
@@ -5274,10 +5610,19 @@ serve({
5274
5610
  port
5275
5611
  }, (info) => {
5276
5612
  const listenUrl = `http://${info.address}:${info.port}`;
5277
- const connectUrl = getConfiguredConnectUrl() ?? getTailscaleConnectUrl(info.port) ?? getLocalNetworkConnectUrl(info.port) ?? listenUrl;
5278
- const pairingPayload = createPairingQrPayload(connectUrl);
5613
+ const connectUrlCandidates = getConnectUrlCandidates({
5614
+ listenUrl,
5615
+ port: info.port
5616
+ });
5617
+ const connectUrl = connectUrlCandidates[0]?.url ?? listenUrl;
5618
+ const connectUrls = connectUrlCandidates.map((candidate) => candidate.url);
5619
+ const pairingPayload = createPairingQrPayload({
5620
+ serverPublicKey: serverIdentity.publicKey,
5621
+ serverUrls: connectUrls.length > 0 ? connectUrls : [connectUrl]
5622
+ });
5279
5623
  writeServerState({
5280
5624
  connectUrl,
5625
+ connectUrlCandidates,
5281
5626
  host: hostname$1,
5282
5627
  listenUrl,
5283
5628
  pairingPayload,
@@ -5288,6 +5633,7 @@ serve({
5288
5633
  logRuntimeEvent("Debug", `Writing diagnostics to ${debugLogPath}`);
5289
5634
  relayDebugLog("relay.started", {
5290
5635
  connectUrl,
5636
+ connectUrlCandidates,
5291
5637
  listenUrl,
5292
5638
  port: info.port,
5293
5639
  workspacePath: process.env.CODEX_RELAY_WORKSPACE_PATH ?? process.cwd()
@@ -5297,6 +5643,7 @@ serve({
5297
5643
  qrcode.generate(pairingPayload, { small: true });
5298
5644
  console.log(formatStartupInstructions({
5299
5645
  connectUrl,
5646
+ connectUrlCandidates,
5300
5647
  dangerouslyAutoApprove,
5301
5648
  listenUrl,
5302
5649
  pairingPayload,
@@ -5310,6 +5657,8 @@ function formatStartupInstructions(details) {
5310
5657
  `${color.prompt("›")} Scan the QR code above to pair ${color.brand("Codex Relay mobile")}.`,
5311
5658
  "",
5312
5659
  `${color.prompt("›")} Mobile: ${color.url(details.connectUrl)}`,
5660
+ ...formatConnectUrlGuidance(details.connectUrl),
5661
+ ...formatConnectUrlCandidates(details.connectUrlCandidates),
5313
5662
  `${color.prompt("›")} Server: ${color.muted(details.listenUrl)}`,
5314
5663
  "",
5315
5664
  `${color.prompt("›")} Pairing: ${color.url(details.pairingPayload)}`,
@@ -5326,34 +5675,23 @@ function formatStartupInstructions(details) {
5326
5675
  ""
5327
5676
  ].join("\n");
5328
5677
  }
5678
+ function formatConnectUrlGuidance(connectUrl) {
5679
+ const guidance = getConnectUrlGuidance(connectUrl);
5680
+ return guidance ? [`${color.prompt("›")} Network: ${guidance}`] : [];
5681
+ }
5682
+ function formatConnectUrlCandidates(candidates) {
5683
+ if (candidates.length <= 1) return [];
5684
+ return [`${color.prompt("›")} QR includes ${candidates.length} candidate addresses; the app will use the first reachable one.`, ...candidates.slice(1).map((candidate) => ` ${color.muted(candidate.label)} ${color.url(candidate.url)}`)];
5685
+ }
5329
5686
  function logRuntimeEvent(label, message) {
5330
5687
  console.log(`${color.prompt("›")} ${color.event(label.padEnd(8))} ${message}`);
5331
5688
  }
5332
5689
  function formatClientCount(tokenCount) {
5333
5690
  return `${tokenCount} client${tokenCount === 1 ? "" : "s"}`;
5334
5691
  }
5335
- function createPairingQrPayload(serverUrl) {
5336
- const url = new URL("codex-relay://pair");
5337
- url.searchParams.set("serverUrl", serverUrl);
5338
- url.searchParams.set("serverPublicKey", serverIdentity.publicKey);
5339
- return url.toString();
5340
- }
5341
5692
  function hashClientToken(token) {
5342
5693
  return createHash("sha256").update(token).digest("base64url");
5343
5694
  }
5344
- function getConfiguredConnectUrl() {
5345
- const configuredUrl = normalizeUrl(process.env.CODEX_RELAY_PUBLIC_URL);
5346
- if (configuredUrl) return configuredUrl;
5347
- }
5348
- function getTailscaleConnectUrl(port) {
5349
- const status = getTailscaleStatus();
5350
- const tailscaleIp = status?.Self?.TailscaleIPs?.find((ip) => ip.startsWith("100.") && ip.includes("."));
5351
- if (tailscaleIp) return `http://${tailscaleIp}:${port}`;
5352
- const dnsName = status?.Self?.DNSName?.replace(/\.$/, "");
5353
- if (dnsName) return getTailscaleServeHttpsUrl(dnsName, port) ?? `http://${dnsName}:${port}`;
5354
- const tailscaleHost = status?.Self?.TailscaleIPs?.find((ip) => ip.includes("."));
5355
- return tailscaleHost ? `http://${tailscaleHost}:${port}` : void 0;
5356
- }
5357
5695
  async function getApprovalSecret() {
5358
5696
  if (process.env.CODEX_RELAY_APPROVAL_SECRET) return process.env.CODEX_RELAY_APPROVAL_SECRET;
5359
5697
  const path = await prepareCodexRelayDataPath("approval-secret");
@@ -5406,58 +5744,5 @@ async function writeBackgroundPid() {
5406
5744
  function formatApprovalCommand(approvalCode, activePort) {
5407
5745
  return activePort === 8787 ? `${npxCommand} approve ${approvalCode}` : `PORT=${activePort} ${npxCommand} approve ${approvalCode}`;
5408
5746
  }
5409
- function getLocalNetworkConnectUrl(port) {
5410
- for (const addresses of Object.values(networkInterfaces())) for (const address of addresses ?? []) if (address.family === "IPv4" && !address.internal) return `http://${address.address}:${port}`;
5411
- }
5412
- function normalizeUrl(value) {
5413
- if (!value) return;
5414
- const trimmed = value.trim().replace(/\/$/, "");
5415
- if (!trimmed) return;
5416
- try {
5417
- const url = new URL(trimmed);
5418
- return url.protocol === "http:" || url.protocol === "https:" ? url.toString().replace(/\/$/, "") : void 0;
5419
- } catch {
5420
- return;
5421
- }
5422
- }
5423
- function getTailscaleStatus() {
5424
- try {
5425
- const output = execFileSync("tailscale", ["status", "--json"], {
5426
- encoding: "utf8",
5427
- stdio: [
5428
- "ignore",
5429
- "pipe",
5430
- "ignore"
5431
- ],
5432
- timeout: 1500
5433
- });
5434
- return JSON.parse(output);
5435
- } catch {
5436
- return;
5437
- }
5438
- }
5439
- function getTailscaleServeHttpsUrl(dnsName, port) {
5440
- try {
5441
- const output = execFileSync("tailscale", [
5442
- "serve",
5443
- "status",
5444
- "--json"
5445
- ], {
5446
- encoding: "utf8",
5447
- stdio: [
5448
- "ignore",
5449
- "pipe",
5450
- "ignore"
5451
- ],
5452
- timeout: 1500
5453
- });
5454
- const serveStatus = JSON.parse(output);
5455
- const portKey = String(port);
5456
- const hostPort = `${dnsName}:${portKey}`;
5457
- return serveStatus.TCP?.[portKey]?.HTTPS && serveStatus.Web?.[hostPort] ? `https://${hostPort}` : void 0;
5458
- } catch {
5459
- return;
5460
- }
5461
- }
5462
5747
  //#endregion
5463
5748
  export {};