dsh-codex-subscription 3.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -2,6 +2,8 @@ import { createRequire } from "node:module";
2
2
  import { createHash, randomBytes, randomUUID } from "node:crypto";
3
3
  import { closeOpenAICodexWebSocketSessions, getOpenAICodexWebSocketDebugStats, resetOpenAICodexWebSocketDebugStats } from "@earendil-works/pi-ai/api/openai-codex-responses";
4
4
  import { execFile, spawn } from "node:child_process";
5
+ import { appendFileSync, constants, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
6
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
7
  import { request } from "node:https";
6
8
  import { AsyncLocalStorage } from "node:async_hooks";
7
9
  import { PassThrough, Readable } from "node:stream";
@@ -10,8 +12,6 @@ import { HttpsProxyAgent } from "https-proxy-agent";
10
12
  import WebSocket from "ws";
11
13
  import { clientRequestSchema } from "@deepseek-ai/dsh-client-connection";
12
14
  import { lstat, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
13
- import { constants, readFileSync, readdirSync, statSync } from "node:fs";
14
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
15
15
  import { fileURLToPath, pathToFileURL } from "node:url";
16
16
  import { defineTool } from "@deepseek-ai/dsh-tools";
17
17
  import * as dshLlm from "@deepseek-ai/dsh-llm";
@@ -340,6 +340,51 @@ const OUTPUT_VERBOSITY_DEFAULT = "default";
340
340
  const OUTPUT_VERBOSITY_MEDIUM = "medium";
341
341
  const OUTPUT_VERBOSITY_HIGH = "high";
342
342
  const DEFAULT_OUTPUT_VERBOSITY = OUTPUT_VERBOSITY_DEFAULT;
343
+ const VOICE_CALL_FIELD = "voiceCall";
344
+ const VOICE_VOICE_FIELD = "voiceVoice";
345
+ const VOICE_VOICES = Object.freeze([
346
+ "cove",
347
+ "juniper",
348
+ "maple",
349
+ "spruce",
350
+ "ember",
351
+ "vale",
352
+ "breeze",
353
+ "arbor",
354
+ "sol",
355
+ "aube",
356
+ "bubbie",
357
+ "haetsal",
358
+ "hanul",
359
+ "himari",
360
+ "leher",
361
+ "miko",
362
+ "neer",
363
+ "porto",
364
+ "rindo",
365
+ "rio",
366
+ "rivage",
367
+ "selva",
368
+ "sonna",
369
+ "tinta",
370
+ "viola"
371
+ ]);
372
+ const VOICE_LANGUAGE_FIELD = "voiceLanguage";
373
+ const VOICE_LANGUAGES = Object.freeze([
374
+ "auto",
375
+ "zh",
376
+ "en",
377
+ "ja",
378
+ "ko",
379
+ "es",
380
+ "fr",
381
+ "de",
382
+ "pt",
383
+ "ru"
384
+ ]);
385
+ const normalizeVoiceCall = (value) => value === "off" ? "off" : "on";
386
+ const normalizeVoiceVoice = (value) => VOICE_VOICES.includes(value) ? value : "cove";
387
+ const normalizeVoiceLanguage = (value) => VOICE_LANGUAGES.includes(value) ? value : "auto";
343
388
  const REASONING_SUMMARY_FIELD = "reasoningSummary";
344
389
  const REASONING_SUMMARIES = Object.freeze([
345
390
  "auto",
@@ -572,6 +617,21 @@ const PREFERENCE_FIELDS = Object.freeze({
572
617
  default: 10,
573
618
  error: "Invalid stream idle timeout preference"
574
619
  },
620
+ [VOICE_CALL_FIELD]: {
621
+ choices: ["off", "on"],
622
+ default: "on",
623
+ error: "Invalid voice call setting"
624
+ },
625
+ [VOICE_VOICE_FIELD]: {
626
+ choices: VOICE_VOICES,
627
+ default: "cove",
628
+ error: "Invalid voice preference"
629
+ },
630
+ [VOICE_LANGUAGE_FIELD]: {
631
+ choices: VOICE_LANGUAGES,
632
+ default: "auto",
633
+ error: "Invalid voice language preference"
634
+ },
575
635
  [CONTEXT_MODE_FIELD]: {
576
636
  choices: [
577
637
  CONTEXT_MODE_STANDARD,
@@ -631,6 +691,108 @@ function createDiagnosticEvents({ limit = 32, now = Date.now } = {}) {
631
691
  };
632
692
  }
633
693
  //#endregion
694
+ //#region src/diagnostic-log-format.js
695
+ const MAX_MESSAGE = 300;
696
+ function sanitizeLogText(value) {
697
+ let text = typeof value === "string" ? value : String(value?.message ?? value ?? "");
698
+ text = text.replace(/Bearer\s+[\w.~+/=-]+/giu, "Bearer ***").replace(/\beyJ[\w-]{8,}\.[\w-]{8,}(?:\.[\w-]*)?/gu, "***").replace(/\b(token|access_token|refresh_token|id_token|code|key|secret|password|authorization)=[^\s&"']+/giu, "$1=***").replace(/(["']?(?:access|refresh|id)_?token["']?\s*[:=]\s*)["'][^"']+["']/giu, "$1\"***\"").replace(/[\w.+-]+@[\w-]+(?:\.[\w-]+)+/gu, "***@***").replace(/https?:\/\/[^\s"'<>)]+/giu, (url) => url.split(/[?#]/u)[0]).replace(/[A-Za-z]:[\\/]Users[\\/][^\\/\s"']+/gu, "~").replace(/\/(?:home|Users)\/[^/\s"']+/gu, "~").replace(/\b[0-9a-f]{32,}\b/giu, "***").replace(/\b[A-Za-z0-9_-]{40,}\b/gu, "***").replace(/\s+/gu, " ").trim();
699
+ return text.length > MAX_MESSAGE ? `${text.slice(0, MAX_MESSAGE)}…` : text;
700
+ }
701
+ //#endregion
702
+ //#region src/diagnostic-log.js
703
+ const LEVELS$1 = [
704
+ "info",
705
+ "warn",
706
+ "error"
707
+ ];
708
+ const MAX_ENTRIES$1 = 400;
709
+ const FILE_LIMIT = 256 * 1024;
710
+ function createDiagnosticLog({ now = Date.now, limit = MAX_ENTRIES$1 } = {}) {
711
+ let entries = [];
712
+ let dropped = 0;
713
+ let sequence = 0;
714
+ let file;
715
+ let lastWritten = "";
716
+ const persist = (entry) => {
717
+ if (file === void 0) return;
718
+ try {
719
+ try {
720
+ if (statSync(file).size > FILE_LIMIT) renameSync(file, `${file}.1`);
721
+ } catch {}
722
+ appendFileSync(file, `${JSON.stringify(entry)}\n`);
723
+ } catch {}
724
+ };
725
+ const log = (level, area, message, detail) => {
726
+ try {
727
+ if (!LEVELS$1.includes(level)) return;
728
+ const text = sanitizeLogText(message);
729
+ if (text === "" && detail === void 0) return;
730
+ const entry = {
731
+ sequence: ++sequence,
732
+ time: now(),
733
+ level,
734
+ area: String(area).slice(0, 24),
735
+ message: text
736
+ };
737
+ if (detail !== void 0) entry.detail = sanitizeLogText(detail);
738
+ const signature = `${level}|${entry.area}|${text}|${entry.detail ?? ""}`;
739
+ const previous = entries.at(-1);
740
+ if (previous !== void 0 && signature === lastWritten) {
741
+ previous.count = (previous.count ?? 1) + 1;
742
+ previous.lastTime = entry.time;
743
+ return;
744
+ }
745
+ lastWritten = signature;
746
+ entries.push(entry);
747
+ if (entries.length > limit) {
748
+ entries = entries.slice(-limit);
749
+ dropped++;
750
+ }
751
+ persist(entry);
752
+ } catch {}
753
+ };
754
+ return {
755
+ log,
756
+ info: (area, message, detail) => log("info", area, message, detail),
757
+ warn: (area, message, detail) => log("warn", area, message, detail),
758
+ error: (area, message, detail) => log("error", area, message, detail),
759
+ /** Keep a copy on disk, and start from what the last run left behind. */
760
+ attach(path) {
761
+ try {
762
+ mkdirSync(dirname(path), { recursive: true });
763
+ file = path;
764
+ entries = [...readFileSync(path, "utf8").trim().split("\n").slice(-Math.floor(limit / 2)).flatMap((line) => {
765
+ try {
766
+ const value = JSON.parse(line);
767
+ return LEVELS$1.includes(value?.level) && Number.isSafeInteger(value?.time) && typeof value?.message === "string" ? [{
768
+ ...value,
769
+ sequence: ++sequence,
770
+ area: String(value.area ?? "").slice(0, 24),
771
+ message: sanitizeLogText(value.message),
772
+ earlier: true
773
+ }] : [];
774
+ } catch {
775
+ return [];
776
+ }
777
+ }), ...entries];
778
+ } catch {
779
+ file = file ?? path;
780
+ }
781
+ },
782
+ snapshot: (max = 200) => ({
783
+ dropped,
784
+ entries: entries.slice(-max).map((value) => ({ ...value }))
785
+ }),
786
+ clear() {
787
+ entries = [];
788
+ dropped = 0;
789
+ lastWritten = "";
790
+ }
791
+ };
792
+ }
793
+ /** The log every part of the plugin writes to. */
794
+ const diagnosticLog = createDiagnosticLog();
795
+ //#endregion
634
796
  //#region src/oauth-network.js
635
797
  const execFileAsync = promisify(execFile);
636
798
  const CODEX_AUTH_HOST = "auth.openai.com";
@@ -876,8 +1038,13 @@ function createCodexNetworkTransport(options = {}) {
876
1038
  const now = options.now ?? Date.now;
877
1039
  const evidence = createDiagnosticEvents({ now });
878
1040
  const remember = (area, value) => {
1041
+ const before = attempts.get(area);
879
1042
  attempts.set(area, value);
880
1043
  evidence.record(area, value);
1044
+ if (value.status === "failed") {
1045
+ const what = value.stage === "http" ? `HTTP ${value.httpStatus}` : `network ${value.code}`;
1046
+ diagnosticLog.log(value.stage === "http" && value.httpStatus < 500 && value.httpStatus !== 401 && value.httpStatus !== 403 ? "warn" : "error", area, `request failed: ${what} (${value.route}, ${value.elapsed})`);
1047
+ } else if (before?.status === "failed") diagnosticLog.info(area, "request works again");
881
1048
  };
882
1049
  const run = async (area, operation, connection = {}) => {
883
1050
  const startedAt = now();
@@ -1215,6 +1382,11 @@ const RPC_ENDPOINTS = Object.freeze([
1215
1382
  "runtime/cancel",
1216
1383
  "storage/status",
1217
1384
  "storage/clear-forecast",
1385
+ "voice/start",
1386
+ "voice/stop",
1387
+ "voice/status",
1388
+ "voice/preview",
1389
+ "voice/catalog",
1218
1390
  "remote/status",
1219
1391
  "remote/enable",
1220
1392
  "remote/disable",
@@ -2182,10 +2354,14 @@ function inheritedOriginalImageRef(session, assetId) {
2182
2354
  }
2183
2355
  //#endregion
2184
2356
  //#region src/version.js
2185
- const PACKAGE_VERSION = "3.0.0";
2357
+ const PACKAGE_VERSION = "3.0.1";
2186
2358
  const USER_AGENT = `dsh-codex-subscription/${PACKAGE_VERSION}`;
2187
2359
  //#endregion
2188
2360
  //#region src/http-failure.js
2361
+ let logAuthFailure;
2362
+ const setAuthFailureLogger = (fn) => {
2363
+ logAuthFailure = fn;
2364
+ };
2189
2365
  const SIGN_IN_RENEW = "ChatGPT sign-in needs to be renewed";
2190
2366
  const REQUEST_BLOCKED = "ChatGPT blocked this request before it reached your account (HTTP 403); your sign-in is fine, try again later or check your network, proxy or VPN";
2191
2367
  const CHALLENGE_MARKERS = /just a moment|cf-chl|_cf_chl_opt|attention required|enable javascript and cookies|challenge-platform/iu;
@@ -2207,9 +2383,16 @@ async function isBlockedByNetwork(response) {
2207
2383
  }
2208
2384
  /** The error message for a 401 or 403 answer, or undefined for any other status. */
2209
2385
  async function authFailureMessage(response) {
2210
- if (response?.status === 401) return SIGN_IN_RENEW;
2211
- if (response?.status !== 403) return void 0;
2212
- return await isBlockedByNetwork(response) ? REQUEST_BLOCKED : SIGN_IN_RENEW;
2386
+ if (response?.status !== 401 && response?.status !== 403) return void 0;
2387
+ const blocked = response.status === 403 && await isBlockedByNetwork(response);
2388
+ await noteAuthFailure(response, blocked);
2389
+ return blocked ? REQUEST_BLOCKED : SIGN_IN_RENEW;
2390
+ }
2391
+ async function noteAuthFailure(response, blocked) {
2392
+ try {
2393
+ const text = blocked || typeof response.clone !== "function" ? "" : (await response.clone().text()).slice(0, 400);
2394
+ logAuthFailure?.(blocked ? "HTTP 403 from the network layer in front of ChatGPT" : `ChatGPT answered HTTP ${response.status}`, text === "" ? void 0 : text);
2395
+ } catch {}
2213
2396
  }
2214
2397
  //#endregion
2215
2398
  //#region src/codex-images.js
@@ -2237,7 +2420,7 @@ const FULL_ATTACHMENT_ID = /^sha256:[0-9a-f]{64}$/u;
2237
2420
  const BARE_ATTACHMENT_DIGEST = /^[0-9a-f]{64}$/iu;
2238
2421
  const PATH_LIKE_ATTACHMENT_ID = /[\\/]/u;
2239
2422
  const FILE_NAME_ATTACHMENT_ID = /\.[A-Za-z0-9]{1,16}$/u;
2240
- const record$7 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
2423
+ const record$8 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
2241
2424
  const nonEmpty$2 = (value) => typeof value === "string" && value.trim().length > 0 ? value.trim() : void 0;
2242
2425
  const NETWORK_REASONS = {
2243
2426
  UND_ERR_HEADERS_TIMEOUT: "the image service sent no response within 5 minutes",
@@ -2323,7 +2506,7 @@ function decodeCodexPng(value, maximumBytes) {
2323
2506
  return new Uint8Array(data);
2324
2507
  }
2325
2508
  function imageReference(value) {
2326
- const originalDimensions = record$7(value.originalDimensions) ? {
2509
+ const originalDimensions = record$8(value.originalDimensions) ? {
2327
2510
  width: value.originalDimensions.width,
2328
2511
  height: value.originalDimensions.height
2329
2512
  } : void 0;
@@ -2351,7 +2534,7 @@ function invalidAttachmentId(value) {
2351
2534
  function referenceOf(value, attachments) {
2352
2535
  const attachmentId = normalizeAttachmentId(value?.attachmentId);
2353
2536
  if (attachmentId === void 0) invalidAttachmentId(value);
2354
- if (!record$7(value) || !attachments.imageLimits.mediaTypes.includes(value.mediaType) || !Number.isSafeInteger(value.bytes) || value.bytes <= 0 || !Number.isSafeInteger(value.width) || value.width <= 0 || !Number.isSafeInteger(value.height) || value.height <= 0 || value.name !== void 0 && (typeof value.name !== "string" || value.name.length > 256) || value.originalDimensions !== void 0 && (!record$7(value.originalDimensions) || !Number.isSafeInteger(value.originalDimensions.width) || value.originalDimensions.width <= 0 || !Number.isSafeInteger(value.originalDimensions.height) || value.originalDimensions.height <= 0)) throw new Error("referenceImages contains an invalid image reference");
2537
+ if (!record$8(value) || !attachments.imageLimits.mediaTypes.includes(value.mediaType) || !Number.isSafeInteger(value.bytes) || value.bytes <= 0 || !Number.isSafeInteger(value.width) || value.width <= 0 || !Number.isSafeInteger(value.height) || value.height <= 0 || value.name !== void 0 && (typeof value.name !== "string" || value.name.length > 256) || value.originalDimensions !== void 0 && (!record$8(value.originalDimensions) || !Number.isSafeInteger(value.originalDimensions.width) || value.originalDimensions.width <= 0 || !Number.isSafeInteger(value.originalDimensions.height) || value.originalDimensions.height <= 0)) throw new Error("referenceImages contains an invalid image reference");
2355
2538
  return imageReference({
2356
2539
  ...value,
2357
2540
  attachmentId
@@ -2361,7 +2544,7 @@ function sessionImageReferences(messages) {
2361
2544
  const references = /* @__PURE__ */ new Map();
2362
2545
  const visit = (content) => {
2363
2546
  if (!Array.isArray(content)) return;
2364
- for (const block of content) if (block?.type === "image" && record$7(block.attachment)) {
2547
+ for (const block of content) if (block?.type === "image" && record$8(block.attachment)) {
2365
2548
  const id = normalizeAttachmentId(block.attachment.attachmentId);
2366
2549
  if (id !== void 0) references.set(id, block.attachment);
2367
2550
  } else if (block?.type === "tool-result") visit(block.content);
@@ -2526,7 +2709,7 @@ async function limitMessage(response, now = Date.now()) {
2526
2709
  } catch {
2527
2710
  return fallback;
2528
2711
  }
2529
- if (!record$7(error)) return fallback;
2712
+ if (!record$8(error)) return fallback;
2530
2713
  const at = Number.isSafeInteger(error.resets_at) && error.resets_at > 0 ? error.resets_at * 1e3 : Number.isSafeInteger(error.resets_in_seconds) && error.resets_in_seconds > 0 ? now + error.resets_in_seconds * 1e3 : void 0;
2531
2714
  if (at === void 0) return fallback;
2532
2715
  const minutes = Math.max(1, Math.ceil((at - now) / 6e4));
@@ -2548,7 +2731,7 @@ async function readTextWithin(response, maximumBytes) {
2548
2731
  }
2549
2732
  function responseMetadata(value) {
2550
2733
  const data = Array.isArray(value?.data) ? value.data[0] : void 0;
2551
- const encoded = record$7(data) ? data.b64_json : void 0;
2734
+ const encoded = record$8(data) ? data.b64_json : void 0;
2552
2735
  if (typeof encoded !== "string") throw new Error("Codex returned no image data");
2553
2736
  return {
2554
2737
  encoded,
@@ -2883,7 +3066,8 @@ const METHOD_NOT_FOUND = -32601;
2883
3066
  const INVALID_PARAMS = -32602;
2884
3067
  const INTERNAL_ERROR = -32603;
2885
3068
  const MAX_SEEN = 128;
2886
- const QUIET = /* @__PURE__ */ new Set([
3069
+ const STREAMING = /delta$|outputDelta|tokenUsage|summaryPartAdded/u;
3070
+ const QUIET$1 = /* @__PURE__ */ new Set([
2887
3071
  "command/exec",
2888
3072
  "thread/attachment/list",
2889
3073
  "thread/list",
@@ -2903,16 +3087,21 @@ const invalidParams = (message) => new RpcError(INVALID_PARAMS, message);
2903
3087
  * One host per relay; each remote client gets its own connection.
2904
3088
  * @param {{ methods: Record<string, (params: any, context: { client: object, signal: AbortSignal, notify: (method: string, params?: object) => Promise<void> }) => any>, notifications?: Record<string, Function>, onConnection?: (client: object) => void, onClose?: (client: object) => void }} options
2905
3089
  */
2906
- function createRemoteControlHost({ methods, notifications = {}, onConnection, onActive, onClose, debug = false }) {
3090
+ function createRemoteControlHost({ methods, notifications = {}, onConnection, onActive, onClose, debug = false, debugFile }) {
2907
3091
  const seen = /* @__PURE__ */ new Map();
2908
3092
  const trace = [];
2909
3093
  const record = (entry) => {
2910
3094
  if (!debug) return;
2911
- trace.push({
3095
+ const value = {
2912
3096
  at: Date.now(),
2913
3097
  ...entry
2914
- });
3098
+ };
3099
+ trace.push(value);
2915
3100
  if (trace.length > 60) trace.shift();
3101
+ if (debugFile !== void 0) try {
3102
+ appendFileSync(debugFile, `${JSON.stringify(value)}
3103
+ `);
3104
+ } catch {}
2916
3105
  };
2917
3106
  const note = (kind, method) => {
2918
3107
  const key = `${kind}:${method}`;
@@ -2932,6 +3121,14 @@ function createRemoteControlHost({ methods, notifications = {}, onConnection, on
2932
3121
  let initialized = false;
2933
3122
  let active = false;
2934
3123
  const notify = async (method, params) => {
3124
+ if (debug && !STREAMING.test(method)) record({
3125
+ method: `notify:${method}`,
3126
+ params: JSON.stringify({
3127
+ threadId: params?.threadId,
3128
+ status: params?.status?.type,
3129
+ message: params?.message
3130
+ })
3131
+ });
2935
3132
  await client.send(params === void 0 ? { method } : {
2936
3133
  method,
2937
3134
  params
@@ -3029,16 +3226,16 @@ function createRemoteControlHost({ methods, notifications = {}, onConnection, on
3029
3226
  active = true;
3030
3227
  onActive?.(client, notify);
3031
3228
  }
3032
- if (debug && !QUIET.has(method)) record({
3229
+ if (debug && !QUIET$1.has(method)) record({
3033
3230
  method: `request:${method}`,
3034
- params: JSON.stringify(params ?? null).slice(0, 700)
3231
+ params: JSON.stringify(params ?? null).slice(0, /realtime/u.test(method) ? 8e3 : 700)
3035
3232
  });
3036
3233
  const handler = methods[method];
3037
3234
  if (!handler) {
3038
3235
  record({
3039
3236
  method,
3040
3237
  missing: true,
3041
- params: JSON.stringify(params ?? null).slice(0, 600)
3238
+ params: JSON.stringify(params ?? null).slice(0, /realtime/u.test(method) ? 8e3 : 600)
3042
3239
  });
3043
3240
  fail(id, new RpcError(METHOD_NOT_FOUND, `Method not found: ${method}`)).catch(() => {});
3044
3241
  return;
@@ -3050,7 +3247,7 @@ function createRemoteControlHost({ methods, notifications = {}, onConnection, on
3050
3247
  ...context,
3051
3248
  signal: controller.signal
3052
3249
  })).then((result) => reply(id, result ?? {}), (error) => {
3053
- if (!QUIET.has(method)) record({
3250
+ if (!QUIET$1.has(method)) record({
3054
3251
  method,
3055
3252
  error: String(error?.message ?? error).slice(0, 300),
3056
3253
  params: JSON.stringify(params ?? null).slice(0, 600)
@@ -3081,6 +3278,7 @@ const CONFLICT_RENEW_MS = 15 * 6e4;
3081
3278
  const MAX_RECONNECT_MS = 3e4;
3082
3279
  const REQUEST_TIMEOUT_MS = 3e4;
3083
3280
  const REFRESH_MARGIN_MS = 5 * 6e4;
3281
+ const CLIENT_IDLE_MS = 10 * 6e4;
3084
3282
  const PING_MS = 1e4;
3085
3283
  const PONG_TIMEOUT_MS = 6e4;
3086
3284
  const STABLE_MS = 6e4;
@@ -3167,6 +3365,7 @@ function createRemoteControlRelay(options) {
3167
3365
  let stop;
3168
3366
  let running;
3169
3367
  let socket;
3368
+ let subscribeCursor;
3170
3369
  const clients = /* @__PURE__ */ new Map();
3171
3370
  const assemblies = /* @__PURE__ */ new Map();
3172
3371
  const sequences = /* @__PURE__ */ new Map();
@@ -3213,7 +3412,7 @@ function createRemoteControlRelay(options) {
3213
3412
  installation_id: installation,
3214
3413
  server_id: enrolled.serverId
3215
3414
  } : {
3216
- app_server_version: options.userAgent.split("/").at(-1),
3415
+ app_server_version: options.appServerVersion ?? options.userAgent.split("/").at(-1),
3217
3416
  arch: process.arch,
3218
3417
  installation_id: installation,
3219
3418
  name: options.hostName,
@@ -3380,7 +3579,17 @@ function createRemoteControlRelay(options) {
3380
3579
  const deliver = async (envelope) => {
3381
3580
  const streamId = envelope.stream_id ?? randomUUID();
3382
3581
  const key = `${envelope.client_id}\u0000${streamId}`;
3582
+ const starts = envelope.message?.method === "initialize" && envelope.message?.id !== void 0;
3383
3583
  let client = clients.get(key);
3584
+ if (client && starts) {
3585
+ clients.delete(key);
3586
+ lastInbound.delete(key);
3587
+ sequences.delete(key);
3588
+ outbound.delete(key);
3589
+ client.close();
3590
+ client = void 0;
3591
+ }
3592
+ if (!client && !starts) return;
3384
3593
  if (!client) {
3385
3594
  let closed = false;
3386
3595
  const connection = options.serve({
@@ -3396,6 +3605,7 @@ function createRemoteControlRelay(options) {
3396
3605
  });
3397
3606
  client = {
3398
3607
  receive: connection.receive,
3608
+ lastActivity: now(),
3399
3609
  close: () => {
3400
3610
  closed = true;
3401
3611
  connection.close();
@@ -3403,16 +3613,11 @@ function createRemoteControlRelay(options) {
3403
3613
  };
3404
3614
  clients.set(key, client);
3405
3615
  }
3406
- if (!(Number.isInteger(envelope.seq_id) && (lastInbound.get(key) ?? -1) >= envelope.seq_id)) {
3616
+ if (!(!starts && Number.isInteger(envelope.seq_id) && (lastInbound.get(key) ?? -1) >= envelope.seq_id)) {
3407
3617
  if (Number.isInteger(envelope.seq_id)) lastInbound.set(key, envelope.seq_id);
3408
3618
  client.receive(envelope.message);
3409
3619
  }
3410
- if (Number.isInteger(envelope.seq_id)) await sendEnvelope({
3411
- client_id: envelope.client_id,
3412
- seq_id: envelope.seq_id,
3413
- stream_id: streamId,
3414
- type: "ack"
3415
- });
3620
+ client.lastActivity = now();
3416
3621
  };
3417
3622
  const receiveChunk = (envelope) => {
3418
3623
  const streamId = envelope.stream_id ?? randomUUID();
@@ -3444,6 +3649,15 @@ function createRemoteControlRelay(options) {
3444
3649
  type: "client_message"
3445
3650
  };
3446
3651
  };
3652
+ const sweepIdle = () => {
3653
+ for (const [key, client] of clients) if (now() - client.lastActivity >= CLIENT_IDLE_MS) {
3654
+ clients.delete(key);
3655
+ sequences.delete(key);
3656
+ outbound.delete(key);
3657
+ lastInbound.delete(key);
3658
+ client.close();
3659
+ }
3660
+ };
3447
3661
  const receive = async (raw) => {
3448
3662
  const envelope = JSON.parse(raw);
3449
3663
  if (typeof envelope?.client_id !== "string" || envelope.client_id.length === 0) return;
@@ -3454,13 +3668,18 @@ function createRemoteControlRelay(options) {
3454
3668
  } else if (envelope.type === "ack") {
3455
3669
  if (typeof envelope.stream_id === "string" && Number.isInteger(envelope.seq_id)) acknowledge(`${envelope.client_id}\u0000${envelope.stream_id}`, envelope.seq_id, Number.isInteger(envelope.segment_id) ? envelope.segment_id : void 0);
3456
3670
  } else if (envelope.type === "client_closed") closeClient(envelope.client_id, envelope.stream_id);
3457
- else if (envelope.type === "ping") await sendEnvelope({
3458
- client_id: envelope.client_id,
3459
- seq_id: envelope.seq_id ?? 0,
3460
- status: "active",
3461
- stream_id: envelope.stream_id ?? randomUUID(),
3462
- type: "pong"
3463
- });
3671
+ else if (envelope.type === "ping") {
3672
+ const known = typeof envelope.stream_id === "string" ? clients.get(`${envelope.client_id}\u0000${envelope.stream_id}`) : void 0;
3673
+ if (known) known.lastActivity = now();
3674
+ await sendEnvelope({
3675
+ client_id: envelope.client_id,
3676
+ seq_id: envelope.seq_id ?? 0,
3677
+ status: known ? "active" : "unknown",
3678
+ stream_id: envelope.stream_id ?? randomUUID(),
3679
+ type: "pong"
3680
+ });
3681
+ }
3682
+ if (typeof envelope.cursor === "string" && envelope.cursor !== "") subscribeCursor = envelope.cursor;
3464
3683
  };
3465
3684
  const connectOnce = async (signal) => {
3466
3685
  const current = await ensureEnrollment(signal);
@@ -3471,7 +3690,8 @@ function createRemoteControlRelay(options) {
3471
3690
  "x-codex-installation-id": installation,
3472
3691
  "x-codex-name": Buffer.from(options.hostName).toString("base64"),
3473
3692
  "x-codex-protocol-version": PROTOCOL_VERSION,
3474
- "x-codex-server-id": current.serverId
3693
+ "x-codex-server-id": current.serverId,
3694
+ ...subscribeCursor === void 0 ? {} : { "x-codex-subscribe-cursor": subscribeCursor }
3475
3695
  } });
3476
3696
  socket = ws;
3477
3697
  let queue = Promise.resolve();
@@ -3496,10 +3716,12 @@ function createRemoteControlRelay(options) {
3496
3716
  state.status = "connected";
3497
3717
  state.connectedAt = now();
3498
3718
  state.lastError = void 0;
3719
+ diagnosticLog.info("phone", "connected to the Codex relay");
3499
3720
  replay(ws);
3500
3721
  if (typeof ws.ping === "function") {
3501
3722
  hearFrom();
3502
3723
  ping = setInterval(() => {
3724
+ sweepIdle();
3503
3725
  try {
3504
3726
  ws.ping();
3505
3727
  } catch {}
@@ -3509,7 +3731,8 @@ function createRemoteControlRelay(options) {
3509
3731
  ws.on("pong", hearFrom);
3510
3732
  ws.on("unexpected-response", (request, response) => {
3511
3733
  stopBeat();
3512
- const error = new RemoteControlError("socket", `Unexpected server response: ${response?.statusCode}`);
3734
+ const error = new RemoteControlError(response?.statusCode === 401 || response?.statusCode === 403 ? "unauthorized" : "socket", `Unexpected server response: ${response?.statusCode}`);
3735
+ if (response?.statusCode === 404) enrolled = void 0;
3513
3736
  if (response?.statusCode === 429 || response?.statusCode >= 500) error.retryAfterMs = retryAfterMs$1(String(response.headers?.["retry-after"] ?? ""), now());
3514
3737
  try {
3515
3738
  request?.destroy?.();
@@ -3556,6 +3779,7 @@ function createRemoteControlRelay(options) {
3556
3779
  state.status = "waiting";
3557
3780
  state.lastError = "conflict";
3558
3781
  }
3782
+ diagnosticLog.log(conflicts > 0 ? "warn" : "error", "phone", conflicts > 0 ? "relay refused the connection: another host with the same identity is connected (409)" : `connection to the relay failed (${state.lastError})`, conflicts > 0 ? void 0 : error?.message);
3559
3783
  if (error?.code === "unauthorized" || conflicts >= 3) enrolled = void 0;
3560
3784
  if (conflicts >= 3 && now() - conflictSince >= (options.conflictRenewMs ?? CONFLICT_RENEW_MS) && options.renewInstallation) {
3561
3785
  installation = await options.renewInstallation();
@@ -3642,7 +3866,8 @@ function createRemoteControl(options) {
3642
3866
  phones.delete(client.id);
3643
3867
  options.onClose?.(client, notify);
3644
3868
  },
3645
- debug: process.env.DSH_CODEX_REMOTE_DEBUG === "1"
3869
+ debug: process.env.DSH_CODEX_REMOTE_DEBUG === "1",
3870
+ debugFile: options.debugFile
3646
3871
  });
3647
3872
  let relay;
3648
3873
  let starting;
@@ -3656,6 +3881,7 @@ function createRemoteControl(options) {
3656
3881
  renewInstallation: () => installationId(options.stateFile, true),
3657
3882
  hostName: options.hostName,
3658
3883
  userAgent: options.userAgent,
3884
+ appServerVersion: options.appServerVersion,
3659
3885
  base: options.base,
3660
3886
  serve: (client) => host.serve(client)
3661
3887
  });
@@ -3909,6 +4135,447 @@ async function searchFiles({ roots, query, folders, now = Date.now }) {
3909
4135
  return found.sort((a, b) => b.score - a.score || a.path.length - b.path.length).slice(0, RESULTS);
3910
4136
  }
3911
4137
  //#endregion
4138
+ //#region src/remote-control-realtime.js
4139
+ const SIDEBAND_BASES = ["wss://api.openai.com/v1/live", "wss://realtime.chatgpt.com/v1/live"];
4140
+ const ACTIVATION_RETRY_MS = [
4141
+ 100,
4142
+ 300,
4143
+ 800
4144
+ ];
4145
+ const CONTEXT_APPEND_MAX_BYTES = 500;
4146
+ const CHANNEL_PREFIXES = {
4147
+ final: ["[FINAL]", "[COMPLETE]"],
4148
+ commentary: ["[COMMENTARY]", "[STATUS]"],
4149
+ analysis: ["[ANALYSIS]"]
4150
+ };
4151
+ const DELEGATION_FIELD_MAX = 4 * 1024;
4152
+ const bounded = (text) => text.length > DELEGATION_FIELD_MAX ? `${text.slice(0, DELEGATION_FIELD_MAX - 1)}…` : text;
4153
+ /** A spoken request reaches the agent, and so the conversation, as the plain message it is. */
4154
+ const delegationText = (input) => bounded(input).trim();
4155
+ const dialogue = (transcript) => transcript.map((entry) => `${entry.role === "user" ? "You" : "Voice"}: ${entry.text.replace(/\s+/gu, " ").trim()}`).join("\n");
4156
+ /** What the conversation keeps of a finished call: the dialogue, and a plain request to wrap it up. */
4157
+ function callRecord(transcript) {
4158
+ return `${dialogue(transcript)}\n\nThe voice call has ended. Summarise it in a sentence or two, and carry on with anything that was left unfinished.`;
4159
+ }
4160
+ /** The same dialogue as a note for the conversation's context: kept for the next message, with no turn of its own. */
4161
+ function callNote(transcript) {
4162
+ return `Part of the voice call that was not handed over as a request:\n${dialogue(transcript)}`;
4163
+ }
4164
+ /** Which side channel an agent message belongs on: progress is commentary, an answer is spoken. */
4165
+ function channelOf(text, prefixes = CHANNEL_PREFIXES) {
4166
+ for (const [name, list] of Object.entries(prefixes)) if (Array.isArray(list) && list.some((prefix) => prefix !== "" && text.startsWith(prefix))) return name === "final" ? "speakable" : name === "analysis" ? void 0 : "commentary";
4167
+ return "speakable";
4168
+ }
4169
+ /** Cut a long text on character boundaries into pieces the voice service accepts as one append. */
4170
+ function appendChunks(text, limit = CONTEXT_APPEND_MAX_BYTES) {
4171
+ const chunks = [];
4172
+ let current = "";
4173
+ let bytes = 0;
4174
+ for (const char of text) {
4175
+ const size = Buffer.byteLength(char);
4176
+ if (bytes + size > limit) {
4177
+ chunks.push(current);
4178
+ current = "";
4179
+ bytes = 0;
4180
+ }
4181
+ current += char;
4182
+ bytes += size;
4183
+ }
4184
+ if (current !== "") chunks.push(current);
4185
+ return chunks;
4186
+ }
4187
+ /**
4188
+ * A short utterance that could be a goodbye or a request to hang up, in the languages the voice speaks. It only decides when the agent
4189
+ * is asked; the agent decides whether the person really means to end the call (as in Codex), so a word inside other talk does no harm.
4190
+ */
4191
+ const isGoodbye = (text) => {
4192
+ const said = String(text).trim().split(" ").filter(Boolean).join(" ");
4193
+ return said.length > 0 && said.length <= 30 && /(拜拜|再见|再會|(?<![a-z])bye(?![a-z])|goodbye|hang up|end (the )?call|挂断|結束通話|结束通话|结束语音|さようなら|バイバイ|またね|じゃあね|안녕히|잘 가|adiós|au revoir|tschüss|tchau|до свидания)/iu.test(said);
4194
+ };
4195
+ const content = (text) => [{
4196
+ type: "input_text",
4197
+ text
4198
+ }];
4199
+ const outbound = {
4200
+ sessionAppend: (text, channel) => ({
4201
+ type: "session.context.append",
4202
+ ...channel ? { channel } : {},
4203
+ content: content(text)
4204
+ }),
4205
+ delegationAppend: (delegationItemId, text, channel) => ({
4206
+ type: "delegation.context.append",
4207
+ delegation_item_id: delegationItemId,
4208
+ ...channel ? { channel } : {},
4209
+ content: content(text)
4210
+ }),
4211
+ close: () => ({ type: "session.close" })
4212
+ };
4213
+ /** What the voice service sends, as the phone-facing notifications and the work to do. */
4214
+ function parseEvent(payload) {
4215
+ let value;
4216
+ try {
4217
+ value = JSON.parse(payload);
4218
+ } catch {
4219
+ return;
4220
+ }
4221
+ switch (value?.type) {
4222
+ case "session.started":
4223
+ case "session.updated": return { kind: "session" };
4224
+ case "input_transcript.added": return typeof value.item?.text === "string" ? {
4225
+ kind: "delta",
4226
+ role: "user",
4227
+ text: value.item.text
4228
+ } : void 0;
4229
+ case "output_transcript.added": return typeof value.item?.text === "string" ? {
4230
+ kind: "delta",
4231
+ role: "assistant",
4232
+ text: value.item.text
4233
+ } : void 0;
4234
+ case "turn.done": {
4235
+ const role = value.turn?.role;
4236
+ return (role === "user" || role === "assistant") && typeof value.turn?.transcript === "string" ? {
4237
+ kind: "done",
4238
+ role,
4239
+ text: value.turn.transcript
4240
+ } : void 0;
4241
+ }
4242
+ case "delegation.created": {
4243
+ const item = value.item;
4244
+ if (item?.type !== "delegation" || item?.target !== "client" || typeof item.id !== "string") return void 0;
4245
+ const text = (Array.isArray(item.content) ? item.content : []).filter((part) => part?.type === "input_text" && typeof part.text === "string").map((part) => part.text).join("");
4246
+ return {
4247
+ kind: "delegation",
4248
+ id: item.id,
4249
+ text
4250
+ };
4251
+ }
4252
+ case "error": return {
4253
+ kind: "error",
4254
+ message: String(value.error?.message ?? value.message ?? "The voice service reported an error").slice(0, 300)
4255
+ };
4256
+ default: return;
4257
+ }
4258
+ }
4259
+ /**
4260
+ * @param {{ WebSocket: Function, credentials: (signal?: AbortSignal) => Promise<{ access: string, accountId: string }>, userAgent: string, bases?: string[], log?: { info: Function, warn: Function, error: Function }, trace?: Function, delegate: (threadId: string, text: string, onMessage: (text: string) => void) => Promise<void> }} options
4261
+ */
4262
+ /**
4263
+ * Ask the ChatGPT backend for a voice call: the browser's WebRTC offer goes in with the session settings, the answer
4264
+ * comes back as plain SDP and the call id in the Location header. The same request the phone makes for its own call.
4265
+ */
4266
+ async function createVoiceCall({ fetch, credentials, userAgent, sdp, session, base = "https://chatgpt.com/backend-api", threadId }) {
4267
+ const { access, accountId } = await credentials();
4268
+ const response = await fetch(`${base}/wham/realtime/calls?intent=quicksilver&architecture=avas`, {
4269
+ method: "POST",
4270
+ headers: {
4271
+ authorization: `Bearer ${access}`,
4272
+ "chatgpt-account-id": accountId,
4273
+ "content-type": "application/json",
4274
+ accept: "application/sdp, application/json",
4275
+ "openai-alpha": "quicksilver=v2",
4276
+ "session-id": crypto.randomUUID(),
4277
+ "thread-id": threadId ?? crypto.randomUUID(),
4278
+ "user-agent": userAgent
4279
+ },
4280
+ body: JSON.stringify({
4281
+ sdp,
4282
+ session
4283
+ }),
4284
+ signal: AbortSignal.timeout(2e4)
4285
+ });
4286
+ const answer = await response.text();
4287
+ if (!response.ok) throw new Error(`the voice backend refused the call (HTTP ${response.status}): ${answer.slice(0, 200)}`);
4288
+ const callId = String(response.headers?.get?.("location") ?? "").replace(/[?#].*$/u, "").split("/").filter(Boolean).at(-1);
4289
+ if (!/^rtc_[A-Za-z0-9_-]{8,80}$/u.test(callId ?? "")) throw new Error("the voice backend returned no call id");
4290
+ if (!answer.startsWith("v=0")) throw new Error("the voice backend returned no session description");
4291
+ return {
4292
+ callId,
4293
+ answer
4294
+ };
4295
+ }
4296
+ const LANGUAGE_NAMES = {
4297
+ zh: "Chinese (Mandarin)",
4298
+ en: "English",
4299
+ ja: "Japanese",
4300
+ ko: "Korean",
4301
+ es: "Spanish",
4302
+ fr: "French",
4303
+ de: "German",
4304
+ pt: "Portuguese",
4305
+ ru: "Russian"
4306
+ };
4307
+ /** What the voice model is told when the call starts from the computer. */
4308
+ function desktopInstructions(language) {
4309
+ const spoken = LANGUAGE_NAMES[language];
4310
+ return [
4311
+ "You are the voice of DeepSeek Harness (DSH), a coding agent that works on the user's computer.",
4312
+ "Talk like a calm, brief colleague: short sentences, no lists, no markup.",
4313
+ "You cannot read files, run commands, search or change anything yourself. Hand every request that needs the computer, files, code, the web or any tool to the agent by delegating it, and say in a few words what you are handing over.",
4314
+ "Chat that needs nothing from the computer (greetings, small talk, questions you can answer) you answer yourself. You cannot hang up yourself. When the user may want to end the call (goodbye, that is all, hang up), do not say that you are hanging up: hand their own words over to the agent and let it decide.",
4315
+ "When the agent reports back, tell the user the result in your own words. Messages that start with [STATUS] are progress; messages that start with [COMPLETE] are final answers.",
4316
+ spoken ? `Speak and answer in ${spoken} unless the user clearly speaks another language.` : "Answer in the language the user speaks."
4317
+ ].join(" ");
4318
+ }
4319
+ function createRealtimeVoice(options) {
4320
+ const bases = options.bases ?? SIDEBAND_BASES;
4321
+ const log = options.log ?? {
4322
+ info() {},
4323
+ warn() {},
4324
+ error() {}
4325
+ };
4326
+ const sessions = /* @__PURE__ */ new Map();
4327
+ const connect = (base, callId, headers) => new Promise((resolve, reject) => {
4328
+ const url = `${base.replace(/\/$/, "")}/${encodeURIComponent(callId)}`;
4329
+ const ws = new options.WebSocket(url, { headers });
4330
+ let settled = false;
4331
+ const fail = (error) => {
4332
+ if (!settled) {
4333
+ settled = true;
4334
+ reject(error);
4335
+ }
4336
+ };
4337
+ ws.on("open", () => {
4338
+ if (!settled) {
4339
+ settled = true;
4340
+ resolve(ws);
4341
+ }
4342
+ });
4343
+ ws.on("unexpected-response", (request, response) => {
4344
+ try {
4345
+ request?.destroy?.();
4346
+ } catch {}
4347
+ const error = /* @__PURE__ */ new Error(`HTTP ${response?.statusCode}`);
4348
+ error.status = response?.statusCode;
4349
+ fail(error);
4350
+ });
4351
+ ws.on("error", (error) => fail(error));
4352
+ ws.on("close", () => fail(/* @__PURE__ */ new Error("closed before it opened")));
4353
+ });
4354
+ async function join(callId, headers) {
4355
+ let last;
4356
+ for (const base of bases) {
4357
+ const host = new URL(base).host;
4358
+ for (let attempt = 0; attempt <= ACTIVATION_RETRY_MS.length; attempt++) try {
4359
+ const ws = await connect(base, callId, headers);
4360
+ log.info("voice", `joined the voice call on ${host}`);
4361
+ return ws;
4362
+ } catch (error) {
4363
+ last = error;
4364
+ if (error?.status === 404 && attempt < ACTIVATION_RETRY_MS.length) {
4365
+ await new Promise((resolve) => setTimeout(resolve, ACTIVATION_RETRY_MS[attempt]));
4366
+ continue;
4367
+ }
4368
+ log.warn("voice", `could not join the voice call on ${host}`, String(error?.message ?? error));
4369
+ break;
4370
+ }
4371
+ }
4372
+ throw last ?? /* @__PURE__ */ new Error("no voice endpoint answered");
4373
+ }
4374
+ const flushTail = (threadId, session) => {
4375
+ if (!session.flushTail || session.tail.length === 0) return;
4376
+ const transcript = session.tail.splice(0);
4377
+ const save = session.silent && options.record ? () => options.record(threadId, callNote(transcript)) : () => options.delegate(threadId, callRecord(transcript), () => {});
4378
+ session.chain = session.chain.then(save).catch((error) => log.warn("voice", "could not save the voice transcript to the conversation", String(error?.message ?? error)));
4379
+ };
4380
+ const stop = (threadId, reason = "requested") => {
4381
+ const session = sessions.get(threadId);
4382
+ if (!session) return false;
4383
+ sessions.delete(threadId);
4384
+ session.ended = true;
4385
+ clearTimeout(session.goodbye);
4386
+ clearTimeout(session.closeTimer);
4387
+ flushTail(threadId, session);
4388
+ try {
4389
+ session.ws.send(JSON.stringify(outbound.close()));
4390
+ } catch {}
4391
+ try {
4392
+ session.ws.close();
4393
+ } catch {}
4394
+ session.notify("thread/realtime/closed", {
4395
+ threadId,
4396
+ reason
4397
+ }).catch(() => {});
4398
+ return true;
4399
+ };
4400
+ async function start(params, notify) {
4401
+ const threadId = typeof params?.threadId === "string" ? params.threadId : "";
4402
+ const callId = params?.transport?.type === "existingCall" ? params.transport.callId : void 0;
4403
+ if (threadId === "") throw new Error("The voice start has no thread");
4404
+ if (typeof callId !== "string" || callId === "") throw new Error("Live voice needs a call the phone has already set up");
4405
+ stop(threadId, "replaced");
4406
+ const { access, accountId } = await options.credentials();
4407
+ const ws = await join(callId, {
4408
+ authorization: `Bearer ${access}`,
4409
+ "chatgpt-account-id": accountId,
4410
+ "openai-alpha": "quicksilver=v2",
4411
+ "user-agent": options.userAgent,
4412
+ ...typeof params.realtimeSessionId === "string" ? { "x-session-id": params.realtimeSessionId } : {},
4413
+ ...typeof params.voiceSessionId === "string" ? { "voice-session-id": params.voiceSessionId } : {}
4414
+ });
4415
+ const session = {
4416
+ ws,
4417
+ notify,
4418
+ ended: false,
4419
+ chain: Promise.resolve(),
4420
+ tail: [],
4421
+ lines: [],
4422
+ working: 0,
4423
+ flushTail: params.flushTranscriptTailOnSessionEnd !== false,
4424
+ silent: params.silentTailRecord === true,
4425
+ hangUp: params.hangUpOnGoodbye === true
4426
+ };
4427
+ sessions.set(threadId, session);
4428
+ const send = (message) => {
4429
+ try {
4430
+ ws.send(JSON.stringify(message));
4431
+ } catch (error) {
4432
+ log.warn("voice", "could not send to the voice call", String(error?.message ?? error));
4433
+ }
4434
+ };
4435
+ const speak = (delegationId, text) => {
4436
+ const channel = channelOf(text, params.codexResponseHandoffChannelPrefixes ?? CHANNEL_PREFIXES);
4437
+ if (channel === void 0) return;
4438
+ for (const piece of appendChunks(text)) send(delegationId ? outbound.delegationAppend(delegationId, piece, channel) : outbound.sessionAppend(piece, channel));
4439
+ };
4440
+ session.speak = speak;
4441
+ const runRequest = (input, delegationId) => {
4442
+ session.chain = session.chain.then(async () => {
4443
+ if (session.ended) return;
4444
+ try {
4445
+ session.working += 1;
4446
+ try {
4447
+ await options.delegate(threadId, delegationText(input), (text) => {
4448
+ if (!session.ended) speak(delegationId, text);
4449
+ });
4450
+ } finally {
4451
+ session.working -= 1;
4452
+ }
4453
+ } catch (error) {
4454
+ log.warn("voice", "the agent could not take the voice request", String(error?.message ?? error));
4455
+ if (!session.ended) speak(delegationId, `[COMPLETE] I could not do that: ${String(error?.message ?? "the task could not start").slice(0, 160)}`);
4456
+ }
4457
+ });
4458
+ };
4459
+ const seenTypes = /* @__PURE__ */ new Set();
4460
+ ws.on("message", (data) => {
4461
+ const event = parseEvent(String(data));
4462
+ if (!event) try {
4463
+ const type = JSON.parse(String(data))?.type;
4464
+ if (typeof type === "string" && !seenTypes.has(type) && seenTypes.size < 40) {
4465
+ seenTypes.add(type);
4466
+ log.info("voice", `voice service message: ${type}`);
4467
+ }
4468
+ } catch {}
4469
+ if (!event || session.ended) return;
4470
+ if (event.kind === "delta") notify("thread/realtime/transcript/delta", {
4471
+ threadId,
4472
+ role: event.role,
4473
+ delta: event.text
4474
+ }).catch(() => {});
4475
+ else if (event.kind === "done") {
4476
+ if (event.text.trim() !== "") {
4477
+ session.tail.push({
4478
+ role: event.role,
4479
+ text: event.text
4480
+ });
4481
+ if (session.tail.length > 200) session.tail.shift();
4482
+ session.lines.push({
4483
+ role: event.role,
4484
+ text: event.text.slice(0, 400)
4485
+ });
4486
+ if (session.lines.length > 12) session.lines.shift();
4487
+ }
4488
+ notify("thread/realtime/transcript/done", {
4489
+ threadId,
4490
+ role: event.role,
4491
+ text: event.text
4492
+ }).catch(() => {});
4493
+ if (session.hangUp && event.role === "user" && isGoodbye(event.text)) {
4494
+ clearTimeout(session.goodbye);
4495
+ const said = Date.now();
4496
+ session.goodbye = setTimeout(() => {
4497
+ session.goodbye = void 0;
4498
+ if (!session.ended && !(session.delegatedAt > said - 4e3)) runRequest(event.text.trim(), void 0);
4499
+ }, 2500);
4500
+ }
4501
+ } else if (event.kind === "error") {
4502
+ log.error("voice", "the voice service reported an error", event.message);
4503
+ notify("thread/realtime/error", {
4504
+ threadId,
4505
+ message: event.message
4506
+ }).catch(() => {});
4507
+ } else if (event.kind === "delegation") {
4508
+ if (event.text === "") return;
4509
+ clearTimeout(session.goodbye);
4510
+ session.goodbye = void 0;
4511
+ session.delegatedAt = Date.now();
4512
+ runRequest(event.text, event.id);
4513
+ }
4514
+ });
4515
+ ws.on("close", () => {
4516
+ if (session.ended || sessions.get(threadId) !== session) return;
4517
+ sessions.delete(threadId);
4518
+ session.ended = true;
4519
+ flushTail(threadId, session);
4520
+ log.warn("voice", "the voice call closed");
4521
+ notify("thread/realtime/closed", {
4522
+ threadId,
4523
+ reason: "transportClosed"
4524
+ }).catch(() => {});
4525
+ });
4526
+ ws.on("error", (error) => log.warn("voice", "the voice connection failed", String(error?.message ?? error)));
4527
+ notify("thread/realtime/started", {
4528
+ threadId,
4529
+ version: params.version ?? "v3",
4530
+ realtimeSessionId: params.realtimeSessionId ?? null
4531
+ }).catch(() => {});
4532
+ return {};
4533
+ }
4534
+ const append = (threadId, text, channel) => {
4535
+ const session = sessions.get(threadId);
4536
+ if (!session || typeof text !== "string" || text === "") return false;
4537
+ for (const piece of appendChunks(text)) try {
4538
+ session.ws.send(JSON.stringify(outbound.sessionAppend(piece, channel)));
4539
+ } catch {
4540
+ return false;
4541
+ }
4542
+ return true;
4543
+ };
4544
+ return {
4545
+ start,
4546
+ stop,
4547
+ appendText: (threadId, text) => append(threadId, text, void 0),
4548
+ appendSpeech: (threadId, text) => append(threadId, text, "speakable"),
4549
+ active: (threadId) => sessions.has(threadId),
4550
+ /** Ask for the call to end once the voice has finished speaking (the window does it); closes by itself after a while if nobody does. */
4551
+ requestClose(threadId) {
4552
+ const session = sessions.get(threadId);
4553
+ if (!session) return false;
4554
+ session.closing = true;
4555
+ session.closeTimer ??= setTimeout(() => stop(threadId, "requested"), 2e4);
4556
+ return true;
4557
+ },
4558
+ /** What the voice stage shows: the last spoken lines, whether the agent is working on a request, whether the call is about to end. */
4559
+ status(threadId) {
4560
+ const session = sessions.get(threadId);
4561
+ return session ? {
4562
+ active: true,
4563
+ working: session.working > 0,
4564
+ closing: session.closing === true,
4565
+ lines: session.lines.slice(-6)
4566
+ } : {
4567
+ active: false,
4568
+ working: false,
4569
+ closing: false,
4570
+ lines: []
4571
+ };
4572
+ },
4573
+ closeAll: () => {
4574
+ for (const threadId of [...sessions.keys()]) stop(threadId, "shutdown");
4575
+ }
4576
+ };
4577
+ }
4578
+ //#endregion
3912
4579
  //#region src/remote-control-dsh.js
3913
4580
  /** A folder the phone named, only when it is an existing absolute folder on this machine. */
3914
4581
  function localFolder(value) {
@@ -4259,7 +4926,7 @@ function projectTurns(messages, at = Date.now(), imageUrls = /* @__PURE__ */ new
4259
4926
  close();
4260
4927
  return turns;
4261
4928
  }
4262
- function thread({ id, title, preview, cwd, createdAt, updatedAt, running, turns = [], projectId = null }) {
4929
+ function thread({ id, title, preview, cwd, createdAt, updatedAt, running, turns = [], projectId = null, threadSource = "dsh" }) {
4263
4930
  return {
4264
4931
  id,
4265
4932
  sessionId: id,
@@ -4279,7 +4946,7 @@ function thread({ id, title, preview, cwd, createdAt, updatedAt, running, turns
4279
4946
  cwd: cwd ?? "",
4280
4947
  cliVersion: "dsh",
4281
4948
  source: { custom: "dsh" },
4282
- threadSource: "dsh",
4949
+ threadSource,
4283
4950
  agentNickname: null,
4284
4951
  agentRole: null,
4285
4952
  gitInfo: null,
@@ -4298,8 +4965,94 @@ function thread({ id, title, preview, cwd, createdAt, updatedAt, running, turns
4298
4965
  */
4299
4966
  const PHONE_RETURN_MS = 12e4;
4300
4967
  const ADOPT_WITHIN_MS = 6e5;
4301
- function createDshRemoteControl({ controller, agents, permissions = () => void 0, workspaces = () => void 0, attachments = () => void 0, usage = () => void 0, usageProfile = () => void 0, projections = () => void 0, skills = () => void 0, commands = () => void 0, llm = () => void 0, trace = () => {}, phoneReturnMs = PHONE_RETURN_MS, userAgent }) {
4968
+ const V1_VOICES = [
4969
+ "juniper",
4970
+ "maple",
4971
+ "spruce",
4972
+ "ember",
4973
+ "vale",
4974
+ "breeze",
4975
+ "arbor",
4976
+ "sol",
4977
+ "cove"
4978
+ ];
4979
+ const REALTIME_VOICES = {
4980
+ v1: V1_VOICES,
4981
+ v2: [
4982
+ "alloy",
4983
+ "ash",
4984
+ "ballad",
4985
+ "coral",
4986
+ "echo",
4987
+ "sage",
4988
+ "shimmer",
4989
+ "verse",
4990
+ "marin",
4991
+ "cedar"
4992
+ ],
4993
+ v3: [
4994
+ ...V1_VOICES,
4995
+ "aube",
4996
+ "bubbie",
4997
+ "haetsal",
4998
+ "hanul",
4999
+ "himari",
5000
+ "leher",
5001
+ "miko",
5002
+ "neer",
5003
+ "porto",
5004
+ "rindo",
5005
+ "rio",
5006
+ "rivage",
5007
+ "selva",
5008
+ "sonna",
5009
+ "tinta",
5010
+ "viola"
5011
+ ],
5012
+ defaultV1: "cove",
5013
+ defaultV2: "marin"
5014
+ };
5015
+ /** The phone's saved defaults, nested like a config file and kept across restarts. */
5016
+ function createPhoneConfig(file) {
5017
+ let value = {};
5018
+ try {
5019
+ if (file) value = JSON.parse(readFileSync(file, "utf8"));
5020
+ } catch {}
5021
+ if (value === null || typeof value !== "object" || Array.isArray(value)) value = {};
5022
+ const save = () => {
5023
+ if (!file) return;
5024
+ try {
5025
+ mkdirSync(dirname(file), { recursive: true });
5026
+ writeFileSync(file, JSON.stringify(value));
5027
+ } catch {}
5028
+ };
5029
+ const FORBIDDEN = /* @__PURE__ */ new Set([
5030
+ "__proto__",
5031
+ "constructor",
5032
+ "prototype"
5033
+ ]);
5034
+ return {
5035
+ read: () => structuredClone(value),
5036
+ write(edits) {
5037
+ if (!Array.isArray(edits)) return;
5038
+ for (const edit of edits.slice(0, 50)) {
5039
+ const path = typeof edit?.keyPath === "string" ? edit.keyPath.split(".").filter(Boolean) : [];
5040
+ if (path.length === 0 || path.length > 6 || path.some((part) => FORBIDDEN.has(part))) continue;
5041
+ let node = value;
5042
+ for (const part of path.slice(0, -1)) {
5043
+ if (node[part] === null || typeof node[part] !== "object") node[part] = {};
5044
+ node = node[part];
5045
+ }
5046
+ if (edit.value === null || edit.value === void 0) delete node[path.at(-1)];
5047
+ else node[path.at(-1)] = edit.value;
5048
+ }
5049
+ save();
5050
+ }
5051
+ };
5052
+ }
5053
+ function createDshRemoteControl({ controller, agents, permissions = () => void 0, workspaces = () => void 0, attachments = () => void 0, usage = () => void 0, usageProfile = () => void 0, projections = () => void 0, skills = () => void 0, commands = () => void 0, llm = () => void 0, speed = void 0, trace = () => {}, phoneReturnMs = PHONE_RETURN_MS, userAgent, appServerUserAgent = userAgent, configFile = void 0, voice = void 0 }) {
4302
5054
  const subscribers = /* @__PURE__ */ new Map();
5055
+ const threadSources = /* @__PURE__ */ new Map();
4303
5056
  const active = /* @__PURE__ */ new Map();
4304
5057
  const queues = /* @__PURE__ */ new Map();
4305
5058
  let catalog;
@@ -4312,6 +5065,9 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
4312
5065
  const openedBy = /* @__PURE__ */ new Map();
4313
5066
  const subscribe = (threadId, notify) => {
4314
5067
  const set = subscribers.get(threadId) ?? /* @__PURE__ */ new Set();
5068
+ if (notify.clientId) {
5069
+ for (const other of [...set]) if (other !== notify && other.clientId === notify.clientId) set.delete(other);
5070
+ }
4315
5071
  set.add(notify);
4316
5072
  subscribers.set(threadId, set);
4317
5073
  if (notify.clientId) {
@@ -4404,7 +5160,7 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
4404
5160
  reasoningEffort: current?.reasoningEffort ?? null,
4405
5161
  runtimeWorkspaceRoots: cwd ? [cwd] : [],
4406
5162
  sandbox: sandboxFor(preset, cwd),
4407
- serviceTier: null,
5163
+ serviceTier: shownTier(),
4408
5164
  turnsBackwardsCursor: null
4409
5165
  };
4410
5166
  };
@@ -4472,7 +5228,21 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
4472
5228
  return urls;
4473
5229
  };
4474
5230
  /** The turn DSH is running, as the phone should see it now: what is committed, plus the reply still being written. */
4475
- const withLiveTurn = (id, turns) => {
5231
+ const aliases = /* @__PURE__ */ new Map();
5232
+ const keepShownId = (threadId, committed, shown) => {
5233
+ if (committed === shown) return;
5234
+ aliases.set(`${threadId}\0${committed}`, shown);
5235
+ if (aliases.size > 2e3) aliases.delete(aliases.keys().next().value);
5236
+ };
5237
+ const withShownIds = (id, turns) => aliases.size === 0 ? turns : turns.map((turn) => ({
5238
+ ...turn,
5239
+ items: turn.items.map((item) => aliases.has(`${id}\0${item.id}`) ? {
5240
+ ...item,
5241
+ id: aliases.get(`${id}\0${item.id}`)
5242
+ } : item)
5243
+ }));
5244
+ const withLiveTurn = (id, shownTurns) => {
5245
+ const turns = withShownIds(id, shownTurns);
4476
5246
  const live = active.get(id);
4477
5247
  if (!live) return turns;
4478
5248
  const typed = (item) => (item?.content ?? []).filter((part) => part.type === "text").map((part) => part.text).join("");
@@ -4483,7 +5253,11 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
4483
5253
  }] : [];
4484
5254
  const last = turns.at(-1);
4485
5255
  const ours = last?.items[0]?.type === "userMessage" && typed(last.items[0]) === typed(live.userItem);
4486
- const items = ours ? [...last.items, ...tail] : [live.userItem, ...tail];
5256
+ const items = ours ? [
5257
+ live.userItem,
5258
+ ...last.items.slice(1),
5259
+ ...tail
5260
+ ] : [live.userItem, ...tail];
4487
5261
  return [...ours ? turns.slice(0, -1) : turns, {
4488
5262
  ...live.turn,
4489
5263
  items,
@@ -4510,12 +5284,44 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
4510
5284
  createdAt: session.meta?.createdAt,
4511
5285
  running: active.has(id),
4512
5286
  projectId: projectIdOf(id),
5287
+ threadSource: threadSources.get(id),
4513
5288
  preview: first ? visibleText(first.content).slice(0, 200) : "",
4514
5289
  turns: withLiveTurn(id, projectTurns(messages, session.meta?.createdAt, imageUrls, cwd))
4515
5290
  })
4516
5291
  };
4517
5292
  };
4518
5293
  /** Apply a model or effort the phone picked; DSH validates it against its own catalog. */
5294
+ const TIER_OF = {
5295
+ fast: "priority",
5296
+ ultrafast: "ultrafast"
5297
+ };
5298
+ const MODE_OF = {
5299
+ priority: "fast",
5300
+ fast: "fast",
5301
+ ultrafast: "ultrafast",
5302
+ default: "standard"
5303
+ };
5304
+ const tiersOf = (provider, model) => {
5305
+ try {
5306
+ return speed?.tiers?.(provider, model) ?? [];
5307
+ } catch {
5308
+ return [];
5309
+ }
5310
+ };
5311
+ const shownTier = () => {
5312
+ try {
5313
+ return TIER_OF[speed?.current?.()] ?? null;
5314
+ } catch {
5315
+ return null;
5316
+ }
5317
+ };
5318
+ /** A tier the phone picked: a string id, "default" or null for standard speed; undefined leaves the setting alone. */
5319
+ const applySpeed = async (tier) => {
5320
+ if (tier === void 0 || !speed?.set) return;
5321
+ const mode = tier === null ? "standard" : MODE_OF[tier];
5322
+ if (mode === void 0 || mode === speed.current?.()) return;
5323
+ await speed.set(mode);
5324
+ };
4519
5325
  const applySelection = async (agent, sessionId, params) => {
4520
5326
  const picked = parseModelKey(params?.model);
4521
5327
  const effort = typeof params?.effort === "string" ? params.effort : void 0;
@@ -4656,6 +5462,9 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
4656
5462
  subscribe(id, notify);
4657
5463
  await applySelection(agent, id, params);
4658
5464
  applyPermissions(agent, params);
5465
+ const speedBefore = speed?.current?.();
5466
+ const speedForTurn = typeof params?.serviceTierForTurn === "string" ? params.serviceTierForTurn : void 0;
5467
+ await applySpeed(speedForTurn ?? (Object.hasOwn(params ?? {}, "serviceTier") ? params.serviceTier : void 0));
4659
5468
  const turn = emptyTurn();
4660
5469
  const userItem = {
4661
5470
  type: "userMessage",
@@ -4727,6 +5536,12 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
4727
5536
  });
4728
5537
  }
4729
5538
  turn.completedAt = seconds(Date.now());
5539
+ if (speedForTurn !== void 0 && speedBefore !== void 0 && speed?.current?.() !== speedBefore) Promise.resolve(speed.set?.(speedBefore)).catch(() => {});
5540
+ const sentText = visibleText(userItem.content.filter((part) => part.type === "text"));
5541
+ try {
5542
+ const committedUser = agent.session.deriveMessages().findLast((message) => typedText(message) !== "" && typedText(message) === sentText);
5543
+ if (committedUser) keepShownId(id, committedUser.id, userItem.id);
5544
+ } catch {}
4730
5545
  active.delete(id);
4731
5546
  compactions.delete(id);
4732
5547
  const usageNow = tokenUsage(agent.session);
@@ -4791,6 +5606,54 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
4791
5606
  const needle = typeof term === "string" ? term.trim().toLocaleLowerCase() : "";
4792
5607
  return needle === "" || snippets.has(entry.id) || `${entry.name ?? ""} ${entry.preview}`.toLocaleLowerCase().includes(needle);
4793
5608
  };
5609
+ /** Run a request the voice model handed over as a turn, and pass each message the agent finishes back to it. */
5610
+ const delegateToAgent = async (threadId, text, onMessage) => {
5611
+ for (let waited = 0; active.has(threadId) && waited < 6e5; waited += 500) await new Promise((resolve) => setTimeout(resolve, 500));
5612
+ let finish;
5613
+ const finished = new Promise((resolve) => {
5614
+ finish = resolve;
5615
+ });
5616
+ const observer = async (method, params) => {
5617
+ if (params?.threadId !== threadId) return;
5618
+ if (method === "item/completed" && params.item?.type === "agentMessage" && typeof params.item.text === "string" && params.item.text.trim() !== "") onMessage(params.item.text);
5619
+ else if (method === "turn/completed") finish();
5620
+ };
5621
+ try {
5622
+ await startTurn({
5623
+ threadId,
5624
+ input: [{
5625
+ type: "text",
5626
+ text,
5627
+ text_elements: []
5628
+ }]
5629
+ }, observer);
5630
+ await finished;
5631
+ } finally {
5632
+ subscribers.get(threadId)?.delete(observer);
5633
+ }
5634
+ };
5635
+ /** Leave text in the conversation's context for its next message, without starting a turn. */
5636
+ const recordToAgent = async (threadId, text) => {
5637
+ const agent = await agentFor(threadId);
5638
+ if (typeof agent.inject !== "function") return;
5639
+ agent.inject({
5640
+ id: randomUUID(),
5641
+ role: "user",
5642
+ content: [{
5643
+ type: "text",
5644
+ text
5645
+ }],
5646
+ source: { kind: "user" }
5647
+ });
5648
+ };
5649
+ const realtimeVoice = voice ? createRealtimeVoice({
5650
+ ...voice,
5651
+ userAgent,
5652
+ log: diagnosticLog,
5653
+ delegate: delegateToAgent,
5654
+ record: recordToAgent
5655
+ }) : void 0;
5656
+ const phoneConfig = createPhoneConfig(configFile);
4794
5657
  const methods = {
4795
5658
  initialize: () => ({
4796
5659
  codexHome: "",
@@ -4799,7 +5662,7 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
4799
5662
  darwin: "macos",
4800
5663
  win32: "windows"
4801
5664
  }[process.platform] ?? process.platform,
4802
- userAgent
5665
+ userAgent: appServerUserAgent
4803
5666
  }),
4804
5667
  "account/read": async () => ({
4805
5668
  account: {
@@ -4847,11 +5710,12 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
4847
5710
  defaultReasoningEffort: model.reasoning?.defaultEffort ?? supported[0].reasoningEffort,
4848
5711
  inputModalities: modalities.get(model.id)?.length ? modalities.get(model.id) : ["text"],
4849
5712
  supportsPersonality: false,
4850
- additionalSpeedTiers: [],
5713
+ additionalSpeedTiers: tiersOf(group.id, model.id).map((tier) => tier.id),
4851
5714
  upgrade: null,
4852
5715
  upgradeInfo: null,
4853
5716
  availabilityNux: null,
4854
- serviceTiers: []
5717
+ serviceTiers: tiersOf(group.id, model.id),
5718
+ defaultServiceTier: null
4855
5719
  });
4856
5720
  }
4857
5721
  }
@@ -4869,18 +5733,22 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
4869
5733
  const agent = await agentFor(params?.threadId);
4870
5734
  await applySelection(agent, params.threadId, params);
4871
5735
  applyPermissions(agent, params);
5736
+ if (Object.hasOwn(params ?? {}, "serviceTier")) await applySpeed(params.serviceTier);
4872
5737
  return {};
4873
5738
  },
4874
5739
  "thread/attachment/list": () => ({
4875
5740
  data: [],
4876
5741
  nextCursor: null
4877
5742
  }),
4878
- "config/batchWrite": () => ({
4879
- status: "ok",
4880
- version: "1",
4881
- filePath: process.platform === "win32" ? "C:dshconfig.toml" : "/dsh/config.toml",
4882
- overriddenMetadata: null
4883
- }),
5743
+ "config/batchWrite": (params) => {
5744
+ phoneConfig.write(params?.edits);
5745
+ return {
5746
+ status: "ok",
5747
+ version: "1",
5748
+ filePath: process.platform === "win32" ? "C:dshconfig.toml" : "/dsh/config.toml",
5749
+ overriddenMetadata: null
5750
+ };
5751
+ },
4884
5752
  "command/exec": async (params) => {
4885
5753
  const folders = await conversationFolders();
4886
5754
  try {
@@ -4908,6 +5776,51 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
4908
5776
  },
4909
5777
  "fs/readDirectory": (params) => ({ entries: readFolder(params?.path) }),
4910
5778
  "collaborationMode/list": () => ({ data: [] }),
5779
+ "thread/realtime/listVoices": () => {
5780
+ return { voices: REALTIME_VOICES };
5781
+ },
5782
+ "thread/realtime/start": async (params, { notify }) => {
5783
+ const threadId = typeof params?.threadId === "string" ? params.threadId : "";
5784
+ const fail = (message) => {
5785
+ setTimeout(() => {
5786
+ notify("thread/realtime/error", {
5787
+ threadId,
5788
+ message
5789
+ }).catch(() => {});
5790
+ notify("thread/realtime/closed", {
5791
+ threadId,
5792
+ reason: "error"
5793
+ }).catch(() => {});
5794
+ }, 50);
5795
+ return {};
5796
+ };
5797
+ if (!realtimeVoice) return fail("Live voice is not available on this DSH");
5798
+ subscribe(threadId, notify);
5799
+ realtimeVoice.start(params, notify).catch((error) => {
5800
+ diagnosticLog.error("voice", "live voice could not start", String(error?.message ?? error));
5801
+ notify("thread/realtime/error", {
5802
+ threadId,
5803
+ message: "Live voice could not connect to the voice service"
5804
+ }).catch(() => {});
5805
+ notify("thread/realtime/closed", {
5806
+ threadId,
5807
+ reason: "error"
5808
+ }).catch(() => {});
5809
+ });
5810
+ return {};
5811
+ },
5812
+ "thread/realtime/stop": (params) => {
5813
+ realtimeVoice?.stop(params?.threadId);
5814
+ return {};
5815
+ },
5816
+ "thread/realtime/appendText": (params) => {
5817
+ realtimeVoice?.appendText(params?.threadId, params?.text);
5818
+ return {};
5819
+ },
5820
+ "thread/realtime/appendSpeech": (params) => {
5821
+ realtimeVoice?.appendSpeech(params?.threadId, params?.text);
5822
+ return {};
5823
+ },
4911
5824
  "permissionProfile/list": () => ({
4912
5825
  data: [],
4913
5826
  nextCursor: null
@@ -4922,7 +5835,7 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
4922
5835
  nextCursor: null
4923
5836
  }),
4924
5837
  "config/read": () => ({
4925
- config: {},
5838
+ config: phoneConfig.read(),
4926
5839
  origins: {},
4927
5840
  layers: null
4928
5841
  }),
@@ -5106,8 +6019,14 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
5106
6019
  const cwd = localFolder(params?.cwd);
5107
6020
  const project = typeof params?.projectId === "string" ? workspaceList().find((entry) => String(entry.id) === params.projectId) : void 0;
5108
6021
  const created = await service().create(project ? { workspaceId: project.id } : cwd ? { cwd } : {});
6022
+ if (typeof params?.threadSource === "string" && params.threadSource !== "") threadSources.set(created.sessionId, params.threadSource.slice(0, 64));
5109
6023
  const agent = await agentFor(created.sessionId);
5110
- await applySelection(agent, created.sessionId, params);
6024
+ const saved = phoneConfig.read();
6025
+ await applySelection(agent, created.sessionId, {
6026
+ ...params,
6027
+ model: params?.model ?? saved.model,
6028
+ effort: params?.effort ?? saved.model_reasoning_effort
6029
+ });
5111
6030
  const { thread: value } = await openThread(created.sessionId, notify);
5112
6031
  emit(created.sessionId, "thread/started", { thread: value });
5113
6032
  return {
@@ -5221,6 +6140,10 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
5221
6140
  sessionId: params?.threadId,
5222
6141
  title: String(params?.name ?? "")
5223
6142
  });
6143
+ emit(params?.threadId, "thread/name/updated", {
6144
+ threadId: params?.threadId,
6145
+ threadName: String(params?.name ?? "")
6146
+ });
5224
6147
  return {};
5225
6148
  },
5226
6149
  "thread/queue/list": (params) => ({
@@ -5569,6 +6492,7 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
5569
6492
  if (reuse && item.type === "agentMessage") {
5570
6493
  const shown = reuse;
5571
6494
  reuse = void 0;
6495
+ keepShownId(session.id, item.id, shown.id);
5572
6496
  shown.text = item.text;
5573
6497
  emit(session.id, "item/completed", {
5574
6498
  threadId: session.id,
@@ -5614,6 +6538,42 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
5614
6538
  };
5615
6539
  return {
5616
6540
  methods,
6541
+ desktopVoice: realtimeVoice === void 0 ? void 0 : Object.freeze({
6542
+ async start({ sessionId, sdp, voice: voiceName, language }) {
6543
+ if (typeof sdp !== "string" || !sdp.startsWith("v=0") || sdp.length > 64 * 1024) throw new RpcError(-32602, "Invalid voice offer");
6544
+ const agent = await agentFor(sessionId);
6545
+ diagnosticLog.info("voice", "the conversation can take a context note", String(typeof agent.inject === "function"));
6546
+ const session = {
6547
+ audio: { output: { voice: typeof voiceName === "string" && /^[a-z]{2,16}$/u.test(voiceName) ? voiceName : "cove" } },
6548
+ delegation: { type: "client" },
6549
+ instructions: desktopInstructions(language),
6550
+ model: "gpt-live-1.1-codex"
6551
+ };
6552
+ const { callId, answer } = await createVoiceCall({
6553
+ fetch: voice.fetch,
6554
+ credentials: voice.credentials,
6555
+ userAgent,
6556
+ sdp,
6557
+ session,
6558
+ threadId: sessionId
6559
+ });
6560
+ await realtimeVoice.start({
6561
+ threadId: sessionId,
6562
+ transport: {
6563
+ type: "existingCall",
6564
+ callId
6565
+ },
6566
+ flushTranscriptTailOnSessionEnd: true,
6567
+ silentTailRecord: true,
6568
+ hangUpOnGoodbye: true,
6569
+ version: "v3"
6570
+ }, async () => {});
6571
+ return { answer };
6572
+ },
6573
+ stop: (sessionId) => realtimeVoice.stop(sessionId),
6574
+ end: (sessionId) => realtimeVoice.requestClose(sessionId),
6575
+ status: (sessionId) => realtimeVoice.status(sessionId)
6576
+ }),
5617
6577
  onSessionEvent,
5618
6578
  onStream,
5619
6579
  onApproval,
@@ -6431,6 +7391,7 @@ var CodexLoginCoordinator = class {
6431
7391
  return;
6432
7392
  }
6433
7393
  } catch {}
7394
+ diagnosticLog.error("login", `sign-in failed (${classifyLoginFailure(error)})`, error);
6434
7395
  session.view = {
6435
7396
  id,
6436
7397
  provider: "openai-codex",
@@ -6866,6 +7827,7 @@ function openaiCodexSubscriptionProvider({ resolveSpeedMode = () => void 0, reso
6866
7827
  };
6867
7828
  const noteStreamFailure = (stage, error, source) => {
6868
7829
  streamFailure.count += 1;
7830
+ diagnosticLog.error("model", `request failed while ${stage === "prepare" ? "preparing" : stage === "request" ? "sending" : stage === "payload" ? "building the payload" : "streaming"}`, error);
6869
7831
  streamFailure.stage = stage;
6870
7832
  try {
6871
7833
  streamFailure.request = source === void 0 ? void 0 : redactRequest(source);
@@ -7022,11 +7984,11 @@ const LEVELS = [
7022
7984
  "max"
7023
7985
  ];
7024
7986
  const DEFAULT_REFRESH_TIMEOUT_MS = 1e4;
7025
- const record$6 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
7987
+ const record$7 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
7026
7988
  const nonEmpty$1 = (value) => typeof value === "string" && value.trim().length > 0 ? value.trim() : void 0;
7027
7989
  const positiveInteger = (value) => Number.isSafeInteger(value) && value > 0 ? value : void 0;
7028
7990
  function upgradeModelId(value) {
7029
- return (typeof value.upgrade === "string" ? nonEmpty$1(value.upgrade) : record$6(value.upgrade) ? nonEmpty$1(value.upgrade.model) : void 0) ?? (record$6(value.upgrade_info) ? nonEmpty$1(value.upgrade_info.model) : void 0) ?? (record$6(value.upgradeInfo) ? nonEmpty$1(value.upgradeInfo.model) : void 0);
7991
+ return (typeof value.upgrade === "string" ? nonEmpty$1(value.upgrade) : record$7(value.upgrade) ? nonEmpty$1(value.upgrade.model) : void 0) ?? (record$7(value.upgrade_info) ? nonEmpty$1(value.upgrade_info.model) : void 0) ?? (record$7(value.upgradeInfo) ? nonEmpty$1(value.upgradeInfo.model) : void 0);
7030
7992
  }
7031
7993
  function retirementMillis(value) {
7032
7994
  for (const source of [
@@ -7035,7 +7997,7 @@ function retirementMillis(value) {
7035
7997
  value.upgrade_info,
7036
7998
  value.upgradeInfo
7037
7999
  ]) {
7038
- if (!record$6(source)) continue;
8000
+ if (!record$7(source)) continue;
7039
8001
  for (const raw of [source.retirement_at, source.retirementAt]) {
7040
8002
  const seconds = positiveInteger(raw);
7041
8003
  if (seconds !== void 0 && Number.isSafeInteger(seconds * 1e3)) return seconds * 1e3;
@@ -7047,7 +8009,7 @@ function retirementMillis(value) {
7047
8009
  }
7048
8010
  }
7049
8011
  function retirementNotice(value, now = Date.now(), targetDisplayName) {
7050
- if (!record$6(value) || !Number.isFinite(now)) return void 0;
8012
+ if (!record$7(value) || !Number.isFinite(now)) return void 0;
7051
8013
  const at = retirementMillis(value);
7052
8014
  if (at === void 0 || at <= now) return void 0;
7053
8015
  const date = new Date(at);
@@ -7063,7 +8025,7 @@ function retirementNotice(value, now = Date.now(), targetDisplayName) {
7063
8025
  };
7064
8026
  }
7065
8027
  function reasoningMap(levels) {
7066
- const supported = new Set((Array.isArray(levels) ? levels : []).map((level) => nonEmpty$1(record$6(level) ? level.effort : void 0)).filter(Boolean));
8028
+ const supported = new Set((Array.isArray(levels) ? levels : []).map((level) => nonEmpty$1(record$7(level) ? level.effort : void 0)).filter(Boolean));
7067
8029
  const map = Object.fromEntries(LEVELS.map((level) => [level, null]));
7068
8030
  if (supported.has("none")) map.off = "none";
7069
8031
  for (const level of LEVELS.slice(1)) if (supported.has(level)) map[level] = level;
@@ -7071,6 +8033,24 @@ function reasoningMap(levels) {
7071
8033
  }
7072
8034
  const capabilityNames = (values) => [...new Set(values.filter((value) => typeof value === "string" && /^[a-z][a-z0-9_-]{0,31}$/u.test(value)))].sort().slice(0, 16);
7073
8035
  const speedTierIds = (value) => [...Array.isArray(value.additional_speed_tiers) ? value.additional_speed_tiers : [], ...Array.isArray(value.service_tiers) ? value.service_tiers.map((tier) => tier?.id) : []];
8036
+ const HONOURED_TIERS = /* @__PURE__ */ new Set([
8037
+ "priority",
8038
+ "fast",
8039
+ "ultrafast"
8040
+ ]);
8041
+ function serviceTierEntries(value) {
8042
+ const found = (Array.isArray(value.service_tiers) ? value.service_tiers : []).flatMap((tier) => record$7(tier) && typeof tier.id === "string" && HONOURED_TIERS.has(tier.id) ? [{
8043
+ id: tier.id,
8044
+ name: nonEmpty$1(tier.name) ?? tier.id,
8045
+ description: nonEmpty$1(tier.description) ?? ""
8046
+ }] : []);
8047
+ for (const id of Array.isArray(value.additional_speed_tiers) ? value.additional_speed_tiers : []) if (typeof id === "string" && HONOURED_TIERS.has(id) && !found.some((tier) => tier.id === id)) found.push({
8048
+ id,
8049
+ name: id === "ultrafast" ? "Ultrafast" : "Fast",
8050
+ description: ""
8051
+ });
8052
+ return found;
8053
+ }
7074
8054
  function unsupportedCapabilities(value) {
7075
8055
  const reasoning = capabilityNames((value.supported_reasoning_levels ?? []).map((item) => item?.effort)).filter((level) => !["none", ...LEVELS.slice(1)].includes(level));
7076
8056
  const inputs = capabilityNames(Array.isArray(value.input_modalities) ? value.input_modalities : []).filter((input) => !["text", "image"].includes(input));
@@ -7089,7 +8069,7 @@ function unsupportedCapabilities(value) {
7089
8069
  };
7090
8070
  }
7091
8071
  function visibleModel(value, displayNames, now) {
7092
- if (!record$6(value)) return void 0;
8072
+ if (!record$7(value)) return void 0;
7093
8073
  const id = nonEmpty$1(value.slug);
7094
8074
  const reserve = id === "gpt-reserve" && ["list", "hide"].includes(value.visibility);
7095
8075
  if (id === void 0 || value.visibility !== "list" && !reserve) return void 0;
@@ -7124,6 +8104,7 @@ function visibleModel(value, displayNames, now) {
7124
8104
  "medium",
7125
8105
  "high"
7126
8106
  ].includes(value.default_verbosity) ? value.default_verbosity : void 0,
8107
+ serviceTiers: serviceTierEntries(value),
7127
8108
  supportsFast: speedTierIds(value).some((tier) => tier === "fast" || tier === "priority"),
7128
8109
  supportsUltrafast: speedTierIds(value).includes("ultrafast")
7129
8110
  };
@@ -7134,22 +8115,22 @@ function visibleModel(value, displayNames, now) {
7134
8115
  * visibility), with low reasoning when the reviewer supports it.
7135
8116
  */
7136
8117
  function officialReviewModel(value, parentId) {
7137
- if (!record$6(value) || !Array.isArray(value.models)) return void 0;
7138
- const parent = value.models.find((model) => record$6(model) && model.slug === parentId);
8118
+ if (!record$7(value) || !Array.isArray(value.models)) return void 0;
8119
+ const parent = value.models.find((model) => record$7(model) && model.slug === parentId);
7139
8120
  const id = nonEmpty$1(parent?.auto_review_model_override) ?? "codex-auto-review";
7140
- const preset = value.models.find((model) => record$6(model) && model.slug === id);
8121
+ const preset = value.models.find((model) => record$7(model) && model.slug === id);
7141
8122
  if (preset === void 0) return void 0;
7142
- const effort = (Array.isArray(preset.supported_reasoning_levels) ? preset.supported_reasoning_levels : []).some((level) => (record$6(level) ? level.effort : level) === "low") ? "low" : nonEmpty$1(preset.default_reasoning_level);
8123
+ const effort = (Array.isArray(preset.supported_reasoning_levels) ? preset.supported_reasoning_levels : []).some((level) => (record$7(level) ? level.effort : level) === "low") ? "low" : nonEmpty$1(preset.default_reasoning_level);
7143
8124
  return {
7144
8125
  id,
7145
8126
  ...effort === void 0 ? {} : { effort }
7146
8127
  };
7147
8128
  }
7148
8129
  function parseOfficialModelCatalog(value, now = Date.now()) {
7149
- if (!record$6(value) || !Array.isArray(value.models)) throw new Error("Codex returned a malformed model catalog");
8130
+ if (!record$7(value) || !Array.isArray(value.models)) throw new Error("Codex returned a malformed model catalog");
7150
8131
  const displayNames = /* @__PURE__ */ new Map();
7151
8132
  for (const model of value.models) {
7152
- if (!record$6(model)) continue;
8133
+ if (!record$7(model)) continue;
7153
8134
  const id = nonEmpty$1(model.slug);
7154
8135
  const name = nonEmpty$1(model.display_name);
7155
8136
  if (id !== void 0 && name !== void 0 && !displayNames.has(id)) displayNames.set(id, name);
@@ -7312,6 +8293,115 @@ function createOfficialModelCatalog(options = {}) {
7312
8293
  });
7313
8294
  }
7314
8295
  //#endregion
8296
+ //#region src/deepseek-account-search.js
8297
+ const DSH_ACCOUNT_SEARCH_PROVIDER_ID = "codex-subscription-dsh";
8298
+ const DEFAULT_BASE_URL = "https://api.deepseek.com/v1/tools";
8299
+ const DEFAULT_COUNT = 10;
8300
+ const MAX_QUESTION = 128;
8301
+ const record$6 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
8302
+ const missing = (message) => new WebError(message, "WEB_PROVIDER_CREDENTIAL_MISSING");
8303
+ function parseSources(value) {
8304
+ if (!record$6(value) || !Array.isArray(value.results)) throw new WebError("DeepSeek returned an invalid search response", "WEB_PROVIDER_ERROR");
8305
+ return {
8306
+ sources: value.results.flatMap((item) => {
8307
+ if (!record$6(item) || typeof item.url !== "string" || item.url.length === 0) return [];
8308
+ return [{
8309
+ url: item.url,
8310
+ ...typeof item.title === "string" && item.title.length > 0 ? { title: item.title } : {},
8311
+ ...typeof item.snippet === "string" && item.snippet.length > 0 ? { snippet: item.snippet } : {},
8312
+ ...typeof item.date === "string" && item.date.length > 0 ? { publishedAt: item.date } : {}
8313
+ }];
8314
+ }),
8315
+ truncated: false
8316
+ };
8317
+ }
8318
+ /**
8319
+ * Search through DeepSeek's Search API with the signed-in DeepSeek account, for conversations whose
8320
+ * model is not a DeepSeek Account one (DSH itself only hands the account token to those).
8321
+ */
8322
+ async function searchWithDeepSeekAccount(options, request, signal) {
8323
+ signal?.throwIfAborted();
8324
+ const account = options.resolveAccount?.();
8325
+ if (typeof account?.resolveToken !== "function") throw missing("DeepSeek account sign-in is not available");
8326
+ const config = options.resolveConfig?.() ?? {};
8327
+ const baseURL = typeof config.baseURL === "string" && config.baseURL.length > 0 ? config.baseURL : DEFAULT_BASE_URL;
8328
+ const count = Number.isInteger(config.count) && config.count > 0 ? config.count : DEFAULT_COUNT;
8329
+ let endpoint;
8330
+ try {
8331
+ endpoint = `${new URL(baseURL).href.replace(/\/+$/u, "")}/search`;
8332
+ } catch {
8333
+ throw new WebError("DeepSeek search base URL is invalid", "WEB_PROVIDER_ERROR");
8334
+ }
8335
+ let token;
8336
+ try {
8337
+ token = await account.resolveToken(endpoint);
8338
+ } catch {
8339
+ throw missing("DeepSeek account sign-in could not be read");
8340
+ }
8341
+ if (typeof token !== "string" || token.length === 0) throw missing("DeepSeek account is not signed in");
8342
+ const question = config.sendOriginalQuestion === false ? void 0 : Array.from((options.resolveQuestion?.() ?? "").replace(/\s+/gu, " ").trim()).slice(0, MAX_QUESTION).join("");
8343
+ let response;
8344
+ try {
8345
+ response = await (options.fetch ?? fetch)(endpoint, {
8346
+ method: "POST",
8347
+ redirect: "error",
8348
+ headers: {
8349
+ "x-dsh-auth-token": token,
8350
+ "content-type": "application/json",
8351
+ accept: "application/json",
8352
+ "user-agent": USER_AGENT
8353
+ },
8354
+ body: JSON.stringify({
8355
+ query: request.query,
8356
+ count: Math.min(count, request.maxResults ?? count),
8357
+ need_content: false,
8358
+ ...question ? { ori_question: question } : {}
8359
+ }),
8360
+ signal
8361
+ });
8362
+ } catch (error) {
8363
+ if (signal?.aborted || error?.name === "AbortError") throw new WebError("DeepSeek web request aborted", "WEB_ABORTED", { cause: error });
8364
+ throw new WebError("DeepSeek web request failed", "WEB_PROVIDER_ERROR", { cause: error });
8365
+ }
8366
+ if (!response.ok) throw response.status === 401 ? new WebError("DeepSeek rejected the account sign-in used for web search; sign in to DeepSeek again", "WEB_PROVIDER_ERROR") : new WebError(`DeepSeek search request failed (HTTP ${response.status})`, "WEB_PROVIDER_ERROR");
8367
+ let body;
8368
+ try {
8369
+ body = await response.json();
8370
+ } catch (error) {
8371
+ throw new WebError("DeepSeek returned an unreadable search response", "WEB_PROVIDER_ERROR", { cause: error });
8372
+ }
8373
+ return parseSources(body);
8374
+ }
8375
+ /**
8376
+ * The DSH search source with the account fallback: DSH's own provider goes first, and only when it
8377
+ * reports a missing credential (no API key, model not a DeepSeek Account one) the account token is used.
8378
+ */
8379
+ function createDshAccountSearchProvider(options) {
8380
+ return Object.freeze({
8381
+ id: DSH_ACCOUNT_SEARCH_PROVIDER_ID,
8382
+ available: () => true,
8383
+ async search(request, signal) {
8384
+ const provider = options.resolveDshProvider?.();
8385
+ if (provider === void 0 || provider.id === "codex-subscription-dsh" || provider.available() !== true) throw new WebError("DSH default search is unavailable", "WEB_PROVIDER_UNAVAILABLE");
8386
+ try {
8387
+ const result = await provider.search(request, signal);
8388
+ options.report?.(`answered by DSH provider ${provider.id}`);
8389
+ return result;
8390
+ } catch (error) {
8391
+ if (error?.code !== "WEB_PROVIDER_CREDENTIAL_MISSING" || signal?.aborted) throw error;
8392
+ try {
8393
+ const result = await searchWithDeepSeekAccount(options, request, signal);
8394
+ options.report?.("answered by DeepSeek account fallback");
8395
+ return result;
8396
+ } catch (fallback) {
8397
+ options.report?.(`DeepSeek account fallback failed: ${fallback?.message}`);
8398
+ throw fallback?.code === "WEB_PROVIDER_CREDENTIAL_MISSING" ? error : fallback;
8399
+ }
8400
+ }
8401
+ }
8402
+ });
8403
+ }
8404
+ //#endregion
7315
8405
  //#region src/codex-search.js
7316
8406
  const CODEX_SEARCH_PROVIDER_ID = "codex-subscription";
7317
8407
  const CODEX_AUTO_SEARCH_PROVIDER_ID = "codex-subscription-auto";
@@ -7437,16 +8527,43 @@ function createCodexSearchProvider(options) {
7437
8527
  }
7438
8528
  });
7439
8529
  }
7440
- /** Route each request by its initiating model without changing the user's explicit overrides. */
8530
+ const UNAVAILABLE_CODES = /* @__PURE__ */ new Set([
8531
+ "WEB_PROVIDER_CREDENTIAL_MISSING",
8532
+ "WEB_PROVIDER_UNAVAILABLE",
8533
+ "WEB_PROVIDER_CONFIGURED_MISSING",
8534
+ "WEB_PROVIDER_CONFIGURED_UNAVAILABLE"
8535
+ ]);
8536
+ const unavailable = (error) => typeof error?.code === "string" && UNAVAILABLE_CODES.has(error.code);
8537
+ /** Route each request by its initiating model; when that side has no sign-in or key, use the other one. */
7441
8538
  function createCodexAutoSearchProvider(options) {
8539
+ const dsh = () => {
8540
+ const provider = options.resolveDshProvider?.();
8541
+ if (provider === void 0 || provider.id === "codex-subscription-auto" || provider.id === "codex-subscription" || provider.available() !== true) throw new WebError("DSH default search is unavailable", "WEB_PROVIDER_UNAVAILABLE");
8542
+ return provider;
8543
+ };
8544
+ const viaDsh = (request, signal) => {
8545
+ try {
8546
+ return Promise.resolve(dsh().search(request, signal));
8547
+ } catch (error) {
8548
+ return Promise.reject(error);
8549
+ }
8550
+ };
8551
+ const viaCodex = (request, signal) => options.codex.search(request, signal);
7442
8552
  return Object.freeze({
7443
8553
  id: CODEX_AUTO_SEARCH_PROVIDER_ID,
7444
8554
  available: () => true,
7445
8555
  async search(request, signal) {
7446
- if (options.resolveModelProvider?.() === "openai-codex") return options.codex.search(request, signal);
7447
- const provider = options.resolveDshProvider?.();
7448
- if (provider === void 0 || provider.id === "codex-subscription-auto" || provider.id === "codex-subscription" || provider.available() !== true) throw new WebError("DSH default search is unavailable", "WEB_PROVIDER_UNAVAILABLE");
7449
- return provider.search(request, signal);
8556
+ const [first, second] = options.resolveModelProvider?.() === "openai-codex" ? [viaCodex, viaDsh] : [viaDsh, viaCodex];
8557
+ try {
8558
+ return await first(request, signal);
8559
+ } catch (error) {
8560
+ if (!unavailable(error) || signal?.aborted) throw error;
8561
+ try {
8562
+ return await second(request, signal);
8563
+ } catch (fallbackError) {
8564
+ throw unavailable(fallbackError) ? error : fallbackError;
8565
+ }
8566
+ }
7450
8567
  }
7451
8568
  });
7452
8569
  }
@@ -7810,6 +8927,11 @@ const actions = Object.freeze({
7810
8927
  codex_image_generate: "images",
7811
8928
  codex_sketch: "sketch"
7812
8929
  });
8930
+ const QUIET = /* @__PURE__ */ new Set([
8931
+ "usage",
8932
+ "storage/status",
8933
+ "preferences/models"
8934
+ ]);
7813
8935
  function safeOperations(raw) {
7814
8936
  if (!Array.isArray(raw?.events)) return void 0;
7815
8937
  return {
@@ -7849,6 +8971,8 @@ function createDiagnosticOperations({ now = Date.now } = {}) {
7849
8971
  status,
7850
8972
  observedAt: now()
7851
8973
  });
8974
+ if (status === "failed") diagnosticLog.error(actions[action], `${action} failed`);
8975
+ else if (status === "completed" && !QUIET.has(action)) diagnosticLog.info(actions[action], `${action} completed`);
7852
8976
  if (events.length > 32) {
7853
8977
  events.shift();
7854
8978
  dropped++;
@@ -7863,12 +8987,21 @@ function createDiagnosticOperations({ now = Date.now } = {}) {
7863
8987
  return result;
7864
8988
  } catch (error) {
7865
8989
  record(endpoint, signal?.aborted ? "cancelled" : "failed");
8990
+ if (!signal?.aborted) diagnosticLog.error(actions[endpoint] ?? "rpc", `${endpoint} threw`, error);
7866
8991
  throw error;
7867
8992
  }
7868
8993
  },
7869
8994
  tool: (exec, result) => {
7870
8995
  if (!["codex_image_generate", "codex_sketch"].includes(exec?.name)) return;
7871
8996
  record(exec.name, exec.signal?.aborted ? "cancelled" : result?.isError === true ? "failed" : result?.isError === false ? "completed" : "failed", "tool");
8997
+ if (result?.isError === true && !exec.signal?.aborted) {
8998
+ const text = [
8999
+ result.content,
9000
+ result.output,
9001
+ result.error
9002
+ ].flat().map((part) => typeof part === "string" ? part : part?.text).find((part) => typeof part === "string" && part !== "");
9003
+ if (text !== void 0) diagnosticLog.error(actions[exec.name], `${exec.name} said`, text);
9004
+ }
7872
9005
  },
7873
9006
  snapshot: () => ({
7874
9007
  scope: "plugin-process",
@@ -8141,6 +9274,7 @@ async function createSubscriptionDiagnostics({ auth, preferences, login = { phas
8141
9274
  account,
8142
9275
  login,
8143
9276
  requests: collected.requests ?? {},
9277
+ log: diagnosticLog.snapshot(300),
8144
9278
  ...collected.history ? { requestHistory: collected.history } : {},
8145
9279
  ...collected.operations ? { operations: collected.operations } : {},
8146
9280
  ...collected.websocket ? { websocket: collected.websocket } : {},
@@ -9946,7 +11080,7 @@ async function settlesLive(preferences, patch, signal, { attempts = 8, delayMs =
9946
11080
  }
9947
11081
  return live();
9948
11082
  }
9949
- function createSubscriptionRpcHandler({ authHandler, usageReader, accountUsageReader, planHistoryReader, usageProfileReader, resetCreditService, preferences, defaultModel, runtimeManagement, remoteControl, diagnosticsReader, modelCatalog, originalImages, resolveInheritedOriginal, closeConnections, onAccountChanged, onCleanupFailure }) {
11083
+ function createSubscriptionRpcHandler({ authHandler, usageReader, accountUsageReader, planHistoryReader, usageProfileReader, resetCreditService, preferences, defaultModel, runtimeManagement, remoteControl, desktopVoice, voicePreview, diagnosticsReader, modelCatalog, originalImages, resolveInheritedOriginal, closeConnections, onAccountChanged, onCleanupFailure }) {
9950
11084
  return async (endpoint, payload, signal) => {
9951
11085
  if (endpoint === "default-model/status" || endpoint === "default-model/select") try {
9952
11086
  signal.throwIfAborted();
@@ -9961,6 +11095,59 @@ function createSubscriptionRpcHandler({ authHandler, usageReader, accountUsageRe
9961
11095
  if (error?.code === "unavailable") return publicError("unavailable", endpoint === "default-model/status" ? "The default model is unavailable" : "Could not save the default model");
9962
11096
  return publicError("internal", endpoint === "default-model/status" ? "Could not read the default model" : "Could not save the default model");
9963
11097
  }
11098
+ if (endpoint === "voice/status") try {
11099
+ signal.throwIfAborted();
11100
+ if (!desktopVoice || typeof payload?.sessionId !== "string") return publicError("unavailable", "Voice is unavailable");
11101
+ return {
11102
+ ok: true,
11103
+ value: desktopVoice.status(payload.sessionId)
11104
+ };
11105
+ } catch (error) {
11106
+ if (signal.aborted) throw error;
11107
+ return publicError("operation-error", "Could not read the voice status");
11108
+ }
11109
+ if (endpoint === "voice/start" || endpoint === "voice/stop") try {
11110
+ signal.throwIfAborted();
11111
+ if (!desktopVoice) return publicError("unavailable", "Voice is unavailable");
11112
+ if (typeof payload?.sessionId !== "string" || payload.sessionId.length === 0 || payload.sessionId.length > 512) return publicError("invalid-input", "Invalid voice request");
11113
+ if (endpoint === "voice/stop") {
11114
+ desktopVoice.stop(payload.sessionId);
11115
+ return {
11116
+ ok: true,
11117
+ value: {}
11118
+ };
11119
+ }
11120
+ const current = preferences?.status?.() ?? {};
11121
+ if (current.voiceCall !== "on") return publicError("unavailable", "Voice is turned off");
11122
+ return {
11123
+ ok: true,
11124
+ value: await desktopVoice.start({
11125
+ sessionId: payload.sessionId,
11126
+ sdp: payload.sdp,
11127
+ voice: current.voiceVoice,
11128
+ language: current.voiceLanguage
11129
+ })
11130
+ };
11131
+ } catch (error) {
11132
+ if (signal.aborted) throw error;
11133
+ return publicError("operation-error", "Could not start the voice call");
11134
+ }
11135
+ if (endpoint === "voice/catalog" || endpoint === "voice/preview") try {
11136
+ signal.throwIfAborted();
11137
+ if (!voicePreview) return publicError("unavailable", "Voice preview is unavailable");
11138
+ if (endpoint === "voice/catalog") return {
11139
+ ok: true,
11140
+ value: { voices: await voicePreview.available() }
11141
+ };
11142
+ const value = await voicePreview.preview(payload?.voice);
11143
+ return value === void 0 ? publicError("not-found", "No sample for this voice") : {
11144
+ ok: true,
11145
+ value
11146
+ };
11147
+ } catch (error) {
11148
+ if (signal.aborted) throw error;
11149
+ return publicError("operation-error", "Could not load the voice sample");
11150
+ }
9964
11151
  if ([
9965
11152
  "remote/status",
9966
11153
  "remote/enable",
@@ -10226,6 +11413,130 @@ function createSubscriptionRpcHandler({ authHandler, usageReader, accountUsageRe
10226
11413
  };
10227
11414
  }
10228
11415
  //#endregion
11416
+ //#region src/voice-preview.js
11417
+ const BASE = "https://chatgpt.com/backend-api";
11418
+ const CATALOG_TTL_MS = 10 * 6e4;
11419
+ const MAX_CLIP_BYTES = 2 * 1024 * 1024;
11420
+ const PREVIEW_HOSTS = /^https:\/\/[a-z0-9.-]*oaistatic\.com\//u;
11421
+ function createVoicePreview({ fetch, credentials, userAgent, now = Date.now, base = BASE }) {
11422
+ let catalog;
11423
+ const clips = /* @__PURE__ */ new Map();
11424
+ const readCatalog = async () => {
11425
+ if (catalog && now() - catalog.at < CATALOG_TTL_MS) return catalog.voices;
11426
+ const { access, accountId } = await credentials();
11427
+ const response = await fetch(`${base}/settings/voices`, {
11428
+ headers: {
11429
+ authorization: `Bearer ${access}`,
11430
+ "chatgpt-account-id": accountId,
11431
+ accept: "application/json",
11432
+ "user-agent": userAgent
11433
+ },
11434
+ signal: AbortSignal.timeout(15e3)
11435
+ });
11436
+ if (!response.ok) throw new Error(`voice catalog HTTP ${response.status}`);
11437
+ const value = await response.json();
11438
+ const voices = (Array.isArray(value?.voices) ? value.voices : []).filter((entry) => typeof entry?.name === "string" && typeof entry?.preview_url === "string" && PREVIEW_HOSTS.test(entry.preview_url));
11439
+ catalog = {
11440
+ at: now(),
11441
+ voices
11442
+ };
11443
+ return voices;
11444
+ };
11445
+ return {
11446
+ /** Which voices ChatGPT's own catalog offers right now (lower-case names), so the settings can mark the others unavailable. */
11447
+ async available() {
11448
+ return (await readCatalog()).map((entry) => entry.name.toLowerCase());
11449
+ },
11450
+ /** The clip of one voice (by its Codex name, e.g. "spruce") as a data URL, with the catalog's description. */
11451
+ async preview(voice) {
11452
+ if (typeof voice !== "string" || !/^[a-z]{2,16}$/u.test(voice)) return void 0;
11453
+ if (clips.has(voice)) return clips.get(voice);
11454
+ const entry = (await readCatalog()).find((item) => item.name.toLowerCase() === voice || item.voice === voice);
11455
+ if (!entry) return void 0;
11456
+ const response = await fetch(entry.preview_url, { signal: AbortSignal.timeout(15e3) });
11457
+ if (!response.ok) throw new Error(`voice sample HTTP ${response.status}`);
11458
+ const bytes = Buffer.from(await response.arrayBuffer());
11459
+ if (bytes.byteLength === 0 || bytes.byteLength > MAX_CLIP_BYTES) throw new Error("voice sample has an unexpected size");
11460
+ const type = /\.mp3(?:$|\?)/u.test(entry.preview_url) ? "audio/mpeg" : "audio/mp4";
11461
+ const result = {
11462
+ name: entry.name,
11463
+ description: typeof entry.description === "string" ? entry.description.slice(0, 120) : "",
11464
+ accent: typeof entry.accent === "string" ? entry.accent.slice(0, 60) : "",
11465
+ audio: `data:${type};base64,${bytes.toString("base64")}`
11466
+ };
11467
+ if (clips.size < 40) clips.set(voice, result);
11468
+ return result;
11469
+ }
11470
+ };
11471
+ }
11472
+ //#endregion
11473
+ //#region src/voice-tools.js
11474
+ /**
11475
+ * The one tool the agent gets while a voice call is running, like Codex's end_realtime_voice_call: the voice model hands
11476
+ * "the user said goodbye" over as a request, and the agent hangs up. The voice model itself has no tools.
11477
+ */
11478
+ function createEndVoiceCallTool({ end, isActive }) {
11479
+ return defineTool({
11480
+ name: "end_voice_call",
11481
+ description: "End the current voice call. The user is talking to you through a live voice call, so a message that is or ends with a farewell (bye, goodbye, 拜拜, 再见, I am leaving, 先走了) or asks to end the call is a request to end it: call this tool, then say a short goodbye. The words may be rough or have a little more after them. Stopping work, stopping speech or pausing does not end a call. Never call it from a text conversation.",
11482
+ parameters: {},
11483
+ isConcurrencySafe: () => false,
11484
+ async execute(_args, exec) {
11485
+ const sessionId = exec.agent?.id;
11486
+ if (typeof sessionId !== "string" || !isActive(sessionId)) return {
11487
+ ended: false,
11488
+ message: "There is no voice call running in this conversation."
11489
+ };
11490
+ end(sessionId);
11491
+ return {
11492
+ ended: true,
11493
+ message: "The voice call is ending; it closes once the voice has finished speaking. Say a short goodbye."
11494
+ };
11495
+ },
11496
+ output: {
11497
+ schema: {
11498
+ type: "object",
11499
+ additionalProperties: true
11500
+ },
11501
+ render: (_args, value) => [{
11502
+ type: "text",
11503
+ text: value.message
11504
+ }]
11505
+ }
11506
+ });
11507
+ }
11508
+ /** The desktop voice object, with the end-call tool registered for as long as at least one call is open. */
11509
+ function withEndCallTool(desktopVoice, register) {
11510
+ if (!desktopVoice) return desktopVoice;
11511
+ const open = /* @__PURE__ */ new Set();
11512
+ let dispose;
11513
+ const release = (sessionId) => {
11514
+ open.delete(sessionId);
11515
+ if (open.size === 0 && dispose) {
11516
+ dispose();
11517
+ dispose = void 0;
11518
+ }
11519
+ };
11520
+ return Object.freeze({
11521
+ async start(params) {
11522
+ const result = await desktopVoice.start(params);
11523
+ open.add(params.sessionId);
11524
+ dispose ??= register(createEndVoiceCallTool({
11525
+ end: (id) => desktopVoice.end(id),
11526
+ isActive: (id) => desktopVoice.status(id).active
11527
+ }));
11528
+ return result;
11529
+ },
11530
+ stop(sessionId) {
11531
+ const result = desktopVoice.stop(sessionId);
11532
+ release(sessionId);
11533
+ return result;
11534
+ },
11535
+ end: (sessionId) => desktopVoice.end(sessionId),
11536
+ status: (sessionId) => desktopVoice.status(sessionId)
11537
+ });
11538
+ }
11539
+ //#endregion
10229
11540
  //#region src/index.js
10230
11541
  const z = createRequire(import.meta.resolve("@deepseek-ai/dsh-settings"))("@deepseek-ai/schemastery");
10231
11542
  const name = "codex-subscription";
@@ -10251,25 +11562,28 @@ const REQUEST_IMAGE_MAX_BYTES = 1024 * 1024;
10251
11562
  function createSearchProviderSwitcher(loader) {
10252
11563
  const webEntry = () => [...loader.entries()].find((entry) => entry.options?.id === WEB_ENTRY_ID);
10253
11564
  const dshProviderId = () => {
10254
- const baseConfig = webEntry()?.options?.config ?? {};
10255
- return typeof baseConfig.searchProvider === "string" && baseConfig.searchProvider.length > 0 ? baseConfig.searchProvider : DSH_SEARCH_PROVIDER_FALLBACK;
10256
- };
11565
+ const configured = webEntry()?.options?.config?.searchProvider;
11566
+ return typeof configured === "string" && configured.length > 0 ? configured : DSH_SEARCH_PROVIDER_FALLBACK;
11567
+ };
11568
+ const current = () => {
11569
+ const value = webEntry()?.fiber?.config?.searchProvider;
11570
+ return typeof value === "string" ? value : value?.get?.();
11571
+ };
11572
+ const writeProvider = async (provider) => {
11573
+ const entry = webEntry();
11574
+ const fiber = entry?.fiber;
11575
+ if (entry === void 0 || fiber === void 0 || typeof fiber.update !== "function") throw new Error("DSH web runtime is unavailable");
11576
+ if (current() === provider) return;
11577
+ await fiber.update({
11578
+ ...entry.options?.config ?? {},
11579
+ searchProvider: provider
11580
+ }, true);
11581
+ };
11582
+ const select = (selection) => writeProvider(selection === "codex" ? CODEX_SEARCH_PROVIDER_ID : selection === "auto" ? CODEX_AUTO_SEARCH_PROVIDER_ID : DSH_ACCOUNT_SEARCH_PROVIDER_ID);
10257
11583
  return Object.freeze({
10258
11584
  dshProviderId,
10259
- async select(selection) {
10260
- const entry = webEntry();
10261
- const fiber = entry?.fiber;
10262
- if (entry === void 0 || fiber === void 0 || typeof fiber.update !== "function") throw new Error("DSH web runtime is unavailable");
10263
- const baseConfig = entry.options?.config ?? {};
10264
- const currentConfig = fiber.config ?? baseConfig;
10265
- const dshProvider = dshProviderId();
10266
- const provider = selection === "codex" ? CODEX_SEARCH_PROVIDER_ID : selection === "auto" ? CODEX_AUTO_SEARCH_PROVIDER_ID : dshProvider;
10267
- if (currentConfig.searchProvider === provider) return;
10268
- await fiber.update({
10269
- ...currentConfig,
10270
- searchProvider: provider
10271
- }, true);
10272
- }
11585
+ current,
11586
+ select
10273
11587
  });
10274
11588
  }
10275
11589
  const settingsFields = {
@@ -10299,6 +11613,9 @@ const Config = z.object(Object.fromEntries(Object.entries(settingsFields).map(([
10299
11613
  function apply(ctx, config = {}) {
10300
11614
  const settings = createSettingsAdapter(ctx, config);
10301
11615
  const searchProvider = createSearchProviderSwitcher(ctx.loader);
11616
+ if (!process.env.NODE_TEST_CONTEXT) diagnosticLog.attach(dshHomePath("state", "codex-subscription", "diagnostic-log.jsonl"));
11617
+ diagnosticLog.info("plugin", `loaded ${PACKAGE_VERSION}`, `DSH ${hostDshVersion() ?? "unknown"}, node ${process.version}, ${process.platform}`);
11618
+ setAuthFailureLogger((message, detail) => diagnosticLog.error("auth", message, detail));
10302
11619
  const network = createCodexNetworkTransport();
10303
11620
  const diagnosticOperations = createDiagnosticOperations();
10304
11621
  ctx.effect(() => ctx.on?.("tools/result", (exec, result) => diagnosticOperations.tool(exec, result)), "codex-subscription: operation evidence");
@@ -10385,6 +11702,9 @@ function apply(ctx, config = {}) {
10385
11702
  [OUTPUT_VERBOSITY_FIELD]: normalizeOutputVerbosity(settings.get()[OUTPUT_VERBOSITY_FIELD]),
10386
11703
  [INPUT_IMAGE_DETAIL_FIELD]: normalizeInputImageDetail(settings.get()[INPUT_IMAGE_DETAIL_FIELD]),
10387
11704
  [REASONING_SUMMARY_FIELD]: normalizeReasoningSummary(settings.get()[REASONING_SUMMARY_FIELD]),
11705
+ [VOICE_CALL_FIELD]: normalizeVoiceCall(settings.get()[VOICE_CALL_FIELD]),
11706
+ [VOICE_VOICE_FIELD]: normalizeVoiceVoice(settings.get()[VOICE_VOICE_FIELD]),
11707
+ [VOICE_LANGUAGE_FIELD]: normalizeVoiceLanguage(settings.get()[VOICE_LANGUAGE_FIELD]),
10388
11708
  [RETRY_POLICY_FIELD]: normalizeRetryPolicy(settings.get()[RETRY_POLICY_FIELD]),
10389
11709
  [CONNECTORS_FIELD]: normalizeConnectorMode(settings.get()[CONNECTORS_FIELD]),
10390
11710
  [STREAM_IDLE_TIMEOUT_MINUTES_FIELD]: normalizeStreamIdleTimeoutMinutes(settings.get()[STREAM_IDLE_TIMEOUT_MINUTES_FIELD]),
@@ -10580,18 +11900,43 @@ function apply(ctx, config = {}) {
10580
11900
  resolveSessionId: () => currentAgent()?.session.id,
10581
11901
  fetch: (input, init) => network.fetch("search", input, init)
10582
11902
  });
10583
- ctx.web.registerSearchProvider(codexSearch);
11903
+ const searchTrace = (route) => diagnosticLog.info("search", `web search: ${route}`);
11904
+ const tracedCodexSearch = Object.freeze({
11905
+ id: codexSearch.id,
11906
+ available: () => codexSearch.available(),
11907
+ async search(request, signal) {
11908
+ const result = await codexSearch.search(request, signal);
11909
+ searchTrace("answered by Codex subscription");
11910
+ return result;
11911
+ }
11912
+ });
11913
+ ctx.web.registerSearchProvider(tracedCodexSearch);
11914
+ const dshSearch = createDshAccountSearchProvider({
11915
+ report: searchTrace,
11916
+ resolveDshProvider: () => ctx.web.searchProviders?.get(searchProvider.dshProviderId()),
11917
+ resolveAccount: () => ctx.get?.("deepseekAccount"),
11918
+ resolveConfig: () => {
11919
+ const config = [...ctx.loader.entries()].find((entry) => entry.options?.id === "web-deepseek-api")?.options?.config;
11920
+ return config !== null && typeof config === "object" ? config : {};
11921
+ },
11922
+ resolveQuestion: () => {
11923
+ return (currentAgent()?.session.deriveMessages?.() ?? []).findLast((message) => message.role === "user" && message.source?.kind === "user")?.content?.flatMap((block) => block.type === "text" ? [block.text] : []).join(" ");
11924
+ }
11925
+ });
11926
+ ctx.web.registerSearchProvider(dshSearch);
10584
11927
  ctx.web.registerSearchProvider(createCodexAutoSearchProvider({
10585
- codex: codexSearch,
11928
+ codex: tracedCodexSearch,
10586
11929
  resolveModelProvider: () => currentAgent()?.session.requestContext?.()?.provider,
10587
- resolveDshProvider: () => ctx.web.searchProviders?.get(searchProvider.dshProviderId())
11930
+ resolveDshProvider: () => dshSearch
10588
11931
  }));
10589
11932
  ctx.effect(() => {
10590
11933
  const select = async (value) => {
10591
11934
  try {
10592
11935
  await searchProvider.select(value[SEARCH_PROVIDER_FIELD]);
11936
+ diagnosticLog.info("search", `web search source is ${searchProvider.current()}`);
10593
11937
  } catch (error) {
10594
11938
  ctx.logger?.warn?.("could not select the configured web search provider: %s", error.message);
11939
+ diagnosticLog.warn("search", "could not select the web search provider", error.message);
10595
11940
  }
10596
11941
  };
10597
11942
  select(settings.get());
@@ -10619,12 +11964,26 @@ function apply(ctx, config = {}) {
10619
11964
  skills: () => ctx.get?.("skills"),
10620
11965
  commands: () => ctx.get?.("commands"),
10621
11966
  llm: () => ctx.llm,
11967
+ speed: {
11968
+ tiers: (provider, model) => provider === PROVIDER ? modelCatalog.metadata(model)?.serviceTiers ?? [] : [],
11969
+ current: () => settings.get()[SPEED_MODE_FIELD],
11970
+ set: (mode) => settings.update({ [SPEED_MODE_FIELD]: mode })
11971
+ },
10622
11972
  trace: (entry) => {
10623
11973
  try {
10624
11974
  remoteControl.host.note(entry);
10625
11975
  } catch {}
10626
11976
  },
10627
- userAgent: USER_AGENT
11977
+ userAgent: USER_AGENT,
11978
+ configFile: dshHomePath("state", "codex-subscription", "remote-config.json"),
11979
+ voice: {
11980
+ WebSocket,
11981
+ fetch: (input, init) => network.fetch("remote", input, init),
11982
+ credentials: (signal) => readSubscriptionCredentials(resolveAuth, (options) => store.read(PROVIDER, {
11983
+ ...options,
11984
+ signal
11985
+ }), signal)
11986
+ }
10628
11987
  });
10629
11988
  const remoteControl = createRemoteControl({
10630
11989
  credentials: (signal) => readSubscriptionCredentials(resolveAuth, (options) => store.read(PROVIDER, {
@@ -10634,6 +11993,7 @@ function apply(ctx, config = {}) {
10634
11993
  WebSocket,
10635
11994
  fetch: (input, init) => network.fetch("remote", input, init),
10636
11995
  stateFile: dshHomePath("state", "codex-subscription", "remote-control.json"),
11996
+ debugFile: dshHomePath("state", "codex-subscription", "remote-debug.jsonl"),
10637
11997
  hostName: `DSH (${hostname()})`,
10638
11998
  userAgent: USER_AGENT,
10639
11999
  base: process.env.DSH_CODEX_REMOTE_BASE || void 0,
@@ -10792,6 +12152,15 @@ function apply(ctx, config = {}) {
10792
12152
  defaultModel,
10793
12153
  runtimeManagement,
10794
12154
  remoteControl,
12155
+ desktopVoice: withEndCallTool(remoteBridge.desktopVoice, (tool) => ctx.tools.register(tool)),
12156
+ voicePreview: createVoicePreview({
12157
+ fetch: (input, init) => network.fetch("remote", input, init),
12158
+ credentials: (signal) => readSubscriptionCredentials(resolveAuth, (options) => store.read(PROVIDER, {
12159
+ ...options,
12160
+ signal
12161
+ }), signal),
12162
+ userAgent: USER_AGENT
12163
+ }),
10795
12164
  onAccountChanged: () => {
10796
12165
  connectorsClient.clear();
10797
12166
  usageProfileReader.clear();