dsh-codex-subscription 3.0.0-beta.3 → 3.0.0-beta.4

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
@@ -9,9 +9,9 @@ import { promisify } from "node:util";
9
9
  import { HttpsProxyAgent } from "https-proxy-agent";
10
10
  import WebSocket from "ws";
11
11
  import { clientRequestSchema } from "@deepseek-ai/dsh-client-connection";
12
- import { lstat, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
13
- import { constants, readFileSync, statSync } from "node:fs";
14
- import { dirname, isAbsolute, join, resolve } from "node:path";
12
+ 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 { pathToFileURL } from "node:url";
16
16
  import { defineTool } from "@deepseek-ai/dsh-tools";
17
17
  import { LlmError, createUserMessage } from "@deepseek-ai/dsh-llm";
@@ -845,6 +845,17 @@ function createCodexNetworkTransport(options = {}) {
845
845
  }
846
846
  //#endregion
847
847
  //#region src/subscription-connection.js
848
+ /**
849
+ * Who a bearer token belongs to, stable across refreshes: the ChatGPT account id inside the token, so a
850
+ * refreshed token keeps the same prompt-cache key and socket. A token without one falls back to itself.
851
+ */
852
+ function tokenScope(apiKey) {
853
+ try {
854
+ const account = JSON.parse(Buffer.from(String(apiKey).split(".")[1], "base64url").toString("utf8"))?.["https://api.openai.com/auth"]?.chatgpt_account_id;
855
+ if (typeof account === "string" && account.length > 0 && account.length <= 128) return `account:${account}`;
856
+ } catch {}
857
+ return `token:${apiKey}`;
858
+ }
848
859
  function createSubscriptionConnection({ resolveMode = () => "sse", resolveProxy = resolveCodexOAuthProxy } = {}) {
849
860
  const namespace = randomUUID();
850
861
  const sessions = /* @__PURE__ */ new Set();
@@ -873,7 +884,7 @@ function createSubscriptionConnection({ resolveMode = () => "sse", resolveProxy
873
884
  const sessionId = options.sessionId && `dsh-${createHash("sha256").update(JSON.stringify([
874
885
  namespace,
875
886
  options.sessionId,
876
- options.apiKey,
887
+ tokenScope(options.apiKey),
877
888
  proxy
878
889
  ])).digest("hex").slice(0, 56)}`;
879
890
  if (sessionId) sessions.add(sessionId);
@@ -1124,6 +1135,8 @@ const RPC_ENDPOINTS = Object.freeze([
1124
1135
  "remote/enable",
1125
1136
  "remote/disable",
1126
1137
  "remote/pair",
1138
+ "remote/clients",
1139
+ "remote/revoke",
1127
1140
  "reset-credit/inspect",
1128
1141
  "reset-credit/prepare",
1129
1142
  "reset-credit/consume",
@@ -2062,7 +2075,7 @@ function inheritedOriginalImageRef(session, assetId) {
2062
2075
  }
2063
2076
  //#endregion
2064
2077
  //#region src/version.js
2065
- const PACKAGE_VERSION = "3.0.0-beta.3";
2078
+ const PACKAGE_VERSION = "3.0.0-beta.4";
2066
2079
  const USER_AGENT = `dsh-codex-subscription/${PACKAGE_VERSION}`;
2067
2080
  //#endregion
2068
2081
  //#region src/codex-images.js
@@ -2260,7 +2273,7 @@ function imageContent(value) {
2260
2273
  const label = typeof value.size === "string" && value.size.length > 0 ? `Generated a ${value.size} image.` : "Generated an image.";
2261
2274
  return [{
2262
2275
  type: "text",
2263
- text: value.localPath === void 0 ? label : `${label}\nOriginal PNG saved on the DSH host at: ${JSON.stringify(value.localPath)}. Read this file or copy it to the workspace with a .png extension; this host path is not a browser URL.`
2276
+ text: value.localPath === void 0 ? label : `${label}\nOriginal PNG saved on the DSH host at: ${JSON.stringify(value.localPath)}. Read this file or copy it to the workspace with a .png extension; this host path is not a browser URL. The image is already shown to the user, so do not embed it again; copy it rather than move it, and leave the original in place unless the user asks you to delete it.`
2264
2277
  }, {
2265
2278
  type: "image",
2266
2279
  attachment: imageReference(value.image)
@@ -2364,6 +2377,41 @@ function imageOutputSchema() {
2364
2377
  }
2365
2378
  };
2366
2379
  }
2380
+ const SUPPORT_ID = /^[A-Za-z0-9._-]{1,80}$/u;
2381
+ /** The request id Codex returns for an image call, kept on failures so a report can be traced. */
2382
+ function supportId(response) {
2383
+ const id = response.headers?.get?.("x-codex-imagegen-request-id");
2384
+ return typeof id === "string" && SUPPORT_ID.test(id) ? ` (request ${id})` : "";
2385
+ }
2386
+ /** What a 429 from the image endpoint says: when the limit resets, when the response tells us. */
2387
+ async function limitMessage(response, now = Date.now()) {
2388
+ const fallback = "Codex image generation quota is unavailable";
2389
+ let error;
2390
+ try {
2391
+ error = JSON.parse(await readTextWithin(response, 16 * 1024))?.error;
2392
+ } catch {
2393
+ return fallback;
2394
+ }
2395
+ if (!record$4(error)) return fallback;
2396
+ 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;
2397
+ if (at === void 0) return fallback;
2398
+ const minutes = Math.max(1, Math.ceil((at - now) / 6e4));
2399
+ return `Codex image generation limit reached; it resets in about ${minutes >= 120 ? `${Math.round(minutes / 60)} hours` : `${minutes} minutes`}`;
2400
+ }
2401
+ async function readTextWithin(response, maximumBytes) {
2402
+ if (response.body === null || response.body === void 0) return "";
2403
+ const reader = response.body.getReader();
2404
+ const chunks = [];
2405
+ let bytes = 0;
2406
+ while (bytes < maximumBytes) {
2407
+ const { done, value } = await reader.read();
2408
+ if (done) break;
2409
+ bytes += value.byteLength;
2410
+ chunks.push(value);
2411
+ }
2412
+ await reader.cancel().catch(() => {});
2413
+ return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString("utf8").slice(0, maximumBytes);
2414
+ }
2367
2415
  function responseMetadata(value) {
2368
2416
  const data = Array.isArray(value?.data) ? value.data[0] : void 0;
2369
2417
  const encoded = record$4(data) ? data.b64_json : void 0;
@@ -2533,8 +2581,9 @@ function createCodexImageTool(options) {
2533
2581
  }
2534
2582
  if (!response.ok) {
2535
2583
  if (response.status === 401 || response.status === 403) throw new Error("ChatGPT sign-in needs to be renewed");
2536
- if (response.status === 429) throw new Error("Codex image generation quota is unavailable");
2537
- throw new Error(`Codex image ${editing ? "edit" : "generation"} failed (HTTP ${response.status})`);
2584
+ const requestId = supportId(response);
2585
+ if (response.status === 429) throw new Error(`${await limitMessage(response)}${requestId}`);
2586
+ throw new Error(`Codex image ${editing ? "edit" : "generation"} failed (HTTP ${response.status})${requestId}`);
2538
2587
  }
2539
2588
  const metadata = responseMetadata(await readJsonWithin(response, encodedLimit(maximumBytes) + RESPONSE_ENVELOPE_BYTES));
2540
2589
  const data = decodeCodexPng(metadata.encoded, maximumBytes);
@@ -2896,6 +2945,21 @@ const CONFLICT_RENEW_MS = 15 * 6e4;
2896
2945
  const MAX_RECONNECT_MS = 3e4;
2897
2946
  const REQUEST_TIMEOUT_MS = 3e4;
2898
2947
  const REFRESH_MARGIN_MS = 5 * 6e4;
2948
+ const PING_MS = 1e4;
2949
+ const PONG_TIMEOUT_MS = 6e4;
2950
+ const STABLE_MS = 6e4;
2951
+ const MAX_BUFFERED_ITEMS = 2e3;
2952
+ const MAX_BUFFERED_BYTES = 32 * 1024 * 1024;
2953
+ const BUFFER_IDLE_MS = 30 * 6e4;
2954
+ const RETRY_AFTER_JITTER_MS = 3e4;
2955
+ /** Milliseconds a server asked us to wait (Retry-After as seconds or a date), when it did. */
2956
+ function retryAfterMs$1(value, at = Date.now()) {
2957
+ if (typeof value !== "string" || value.trim() === "") return void 0;
2958
+ const seconds = Number(value);
2959
+ if (Number.isFinite(seconds)) return Math.max(0, Math.round(seconds * 1e3));
2960
+ const date = Date.parse(value);
2961
+ return Number.isNaN(date) ? void 0 : Math.max(0, date - at);
2962
+ }
2899
2963
  var RemoteControlError = class extends Error {
2900
2964
  constructor(code, message) {
2901
2965
  super(message);
@@ -2919,6 +2983,7 @@ function remoteControlEndpoints(base = REMOTE_CONTROL_BASE_URL) {
2919
2983
  enroll: at("wham/remote/control/server/enroll"),
2920
2984
  refresh: at("wham/remote/control/server/refresh"),
2921
2985
  pair: at("wham/remote/control/server/pair"),
2986
+ clients: (environment) => at(`wham/remote/control/environments/${encodeURIComponent(environment)}/clients`),
2922
2987
  websocket
2923
2988
  });
2924
2989
  }
@@ -2969,6 +3034,8 @@ function createRemoteControlRelay(options) {
2969
3034
  const clients = /* @__PURE__ */ new Map();
2970
3035
  const assemblies = /* @__PURE__ */ new Map();
2971
3036
  const sequences = /* @__PURE__ */ new Map();
3037
+ const outbound = /* @__PURE__ */ new Map();
3038
+ const lastInbound = /* @__PURE__ */ new Map();
2972
3039
  const state = {
2973
3040
  status: "stopped",
2974
3041
  connectedAt: void 0,
@@ -2981,9 +3048,19 @@ function createRemoteControlRelay(options) {
2981
3048
  redirect: "error",
2982
3049
  signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]) : AbortSignal.timeout(REQUEST_TIMEOUT_MS)
2983
3050
  });
2984
- if (!response.ok) throw new RemoteControlError(response.status === 401 || response.status === 403 ? "unauthorized" : "http-error", `Remote Control request failed (HTTP ${response.status})`);
3051
+ if (!response.ok) {
3052
+ const error = new RemoteControlError(response.status === 401 || response.status === 403 ? "unauthorized" : "http-error", `Remote Control request failed (HTTP ${response.status})`);
3053
+ if (response.status === 429 || response.status >= 500) error.retryAfterMs = retryAfterMs$1(response.headers?.get?.("retry-after"), now());
3054
+ throw error;
3055
+ }
2985
3056
  return response.json();
2986
3057
  };
3058
+ const accountHeaders = (access, accountId) => ({
3059
+ authorization: `Bearer ${access}`,
3060
+ "chatgpt-account-id": accountId,
3061
+ accept: "application/json",
3062
+ "user-agent": options.userAgent
3063
+ });
2987
3064
  const ensureEnrollment = async (signal) => {
2988
3065
  if (enrolled && enrolled.expiresAt > now() + REFRESH_MARGIN_MS) return enrolled;
2989
3066
  const { access, accountId } = await options.credentials(signal);
@@ -3036,45 +3113,134 @@ function createRemoteControlRelay(options) {
3036
3113
  expiresAt
3037
3114
  };
3038
3115
  };
3116
+ /** The phones paired with this host, from the same account endpoint Codex uses. */
3117
+ const listClients = async (signal) => {
3118
+ const current = await ensureEnrollment(signal);
3119
+ const { access, accountId } = await options.credentials(signal);
3120
+ if (!access || !accountId) throw new RemoteControlError("not-signed-in", "ChatGPT subscription is not signed in");
3121
+ const value = await request(endpoints.clients(current.environmentId), {
3122
+ method: "GET",
3123
+ headers: accountHeaders(access, accountId)
3124
+ }, signal);
3125
+ return (Array.isArray(value?.items) ? value.items : []).filter((item) => text(item?.client_id)).map((item) => ({
3126
+ clientId: item.client_id,
3127
+ name: text(item.display_name) ?? text(item.device_model) ?? null,
3128
+ platform: text(item.platform) ?? null,
3129
+ osVersion: text(item.os_version) ?? null,
3130
+ appVersion: text(item.app_version) ?? null,
3131
+ lastSeenAt: Number.isFinite(Date.parse(item.last_seen_at)) ? Date.parse(item.last_seen_at) : null
3132
+ }));
3133
+ };
3134
+ /** Remove one paired phone; it has to be paired again to come back. */
3135
+ const revokeClient = async (clientId, signal) => {
3136
+ if (typeof clientId !== "string" || clientId === "" || clientId.length > 200) throw new RemoteControlError("bad-request", "A client id is required");
3137
+ const current = await ensureEnrollment(signal);
3138
+ const { access, accountId } = await options.credentials(signal);
3139
+ if (!access || !accountId) throw new RemoteControlError("not-signed-in", "ChatGPT subscription is not signed in");
3140
+ const target = new URL(`${endpoints.clients(current.environmentId).href}/${encodeURIComponent(clientId)}`);
3141
+ const response = await doFetch(target, {
3142
+ method: "DELETE",
3143
+ headers: accountHeaders(access, accountId),
3144
+ redirect: "error",
3145
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]) : AbortSignal.timeout(REQUEST_TIMEOUT_MS)
3146
+ });
3147
+ if (!response.ok) throw new RemoteControlError(response.status === 401 || response.status === 403 ? "unauthorized" : "http-error", `Remote Control request failed (HTTP ${response.status})`);
3148
+ closeClient(clientId);
3149
+ };
3039
3150
  const sendEnvelope = async (envelope) => {
3040
3151
  if (socket?.readyState !== 1) throw new RemoteControlError("not-connected", "Remote Control is not connected");
3041
3152
  await new Promise((resolve, reject) => socket.send(JSON.stringify(envelope), (error) => error ? reject(error) : resolve()));
3042
3153
  };
3154
+ const remember = (key, sequence, segment, encoded) => {
3155
+ const stream = outbound.get(key) ?? {
3156
+ items: [],
3157
+ bytes: 0,
3158
+ touched: now()
3159
+ };
3160
+ outbound.set(key, stream);
3161
+ stream.items.push({
3162
+ sequence,
3163
+ segment,
3164
+ encoded
3165
+ });
3166
+ stream.bytes += encoded.length;
3167
+ stream.touched = now();
3168
+ while (stream.items.length > MAX_BUFFERED_ITEMS || stream.bytes > MAX_BUFFERED_BYTES) stream.bytes -= stream.items.shift().encoded.length;
3169
+ };
3170
+ const acknowledge = (key, sequence, segment) => {
3171
+ const stream = outbound.get(key);
3172
+ if (!stream) return;
3173
+ stream.items = stream.items.filter((item) => !(item.sequence < sequence || item.sequence === sequence && (segment === void 0 || (item.segment ?? 0) <= segment)));
3174
+ stream.bytes = stream.items.reduce((total, item) => total + item.encoded.length, 0);
3175
+ if (stream.items.length === 0) outbound.delete(key);
3176
+ };
3177
+ const replay = (ws) => {
3178
+ for (const [key, stream] of outbound) {
3179
+ if (now() - stream.touched > BUFFER_IDLE_MS) {
3180
+ outbound.delete(key);
3181
+ continue;
3182
+ }
3183
+ for (const item of stream.items) ws.send(item.encoded, () => {});
3184
+ }
3185
+ };
3043
3186
  const sendServerMessage = async (clientId, streamId, message) => {
3044
3187
  const key = `${clientId}\u0000${streamId}`;
3188
+ const isRequest = message?.method !== void 0 && message?.id !== void 0;
3189
+ if (isRequest && socket?.readyState !== 1) throw new RemoteControlError("not-connected", "Remote Control is not connected");
3045
3190
  const sequence = sequences.get(key) ?? 1;
3046
3191
  sequences.set(key, sequence + 1);
3047
- const envelope = {
3192
+ const encoded = JSON.stringify({
3048
3193
  client_id: clientId,
3049
3194
  message,
3050
3195
  seq_id: sequence,
3051
3196
  stream_id: streamId,
3052
3197
  type: "server_message"
3198
+ });
3199
+ const put = async (text) => {
3200
+ if (isRequest) return new Promise((resolve, reject) => socket.send(text, (error) => error ? reject(error) : resolve()));
3201
+ remember(key, sequence, void 0, text);
3202
+ if (socket?.readyState === 1) await new Promise((resolve) => socket.send(text, () => resolve()));
3053
3203
  };
3054
- const encoded = JSON.stringify(envelope);
3055
- if (Buffer.byteLength(encoded) <= MAX_SEGMENT_BYTES) return sendEnvelope(envelope);
3204
+ if (Buffer.byteLength(encoded) <= MAX_SEGMENT_BYTES) return put(encoded);
3056
3205
  const bytes = Buffer.from(JSON.stringify(message));
3057
3206
  if (bytes.byteLength > MAX_MESSAGE_BYTES) throw new RemoteControlError("too-large", "Remote Control message is too large");
3058
3207
  const chunks = [];
3059
3208
  for (let offset = 0; offset < bytes.byteLength; offset += TARGET_SEGMENT_BYTES) chunks.push(bytes.subarray(offset, offset + TARGET_SEGMENT_BYTES).toString("base64"));
3060
- await Promise.all(chunks.map((chunk, segmentId) => sendEnvelope({
3061
- client_id: clientId,
3062
- message_chunk_base64: chunk,
3063
- message_size_bytes: bytes.byteLength,
3064
- segment_count: chunks.length,
3065
- segment_id: segmentId,
3066
- seq_id: sequence,
3067
- stream_id: streamId,
3068
- type: "server_message_chunk"
3069
- })));
3209
+ for (const [segmentId, chunk] of chunks.entries()) {
3210
+ const text = JSON.stringify({
3211
+ client_id: clientId,
3212
+ message_chunk_base64: chunk,
3213
+ message_size_bytes: bytes.byteLength,
3214
+ segment_count: chunks.length,
3215
+ segment_id: segmentId,
3216
+ seq_id: sequence,
3217
+ stream_id: streamId,
3218
+ type: "server_message_chunk"
3219
+ });
3220
+ if (isRequest) await put(text);
3221
+ else {
3222
+ remember(key, sequence, segmentId, text);
3223
+ if (socket?.readyState === 1) await new Promise((resolve) => socket.send(text, () => resolve()));
3224
+ }
3225
+ }
3070
3226
  };
3071
3227
  const closeClient = (clientId, streamId) => {
3072
3228
  for (const [key, client] of clients) if (key.startsWith(`${clientId}\u0000`) && (streamId === void 0 || key === `${clientId}\u0000${streamId}`)) {
3073
3229
  clients.delete(key);
3074
3230
  sequences.delete(key);
3231
+ outbound.delete(key);
3232
+ lastInbound.delete(key);
3075
3233
  client.close();
3076
3234
  }
3077
3235
  };
3236
+ const resetStreams = () => {
3237
+ for (const [, client] of clients) client.close();
3238
+ clients.clear();
3239
+ sequences.clear();
3240
+ outbound.clear();
3241
+ lastInbound.clear();
3242
+ assemblies.clear();
3243
+ };
3078
3244
  const deliver = async (envelope) => {
3079
3245
  const streamId = envelope.stream_id ?? randomUUID();
3080
3246
  const key = `${envelope.client_id}\u0000${streamId}`;
@@ -3099,7 +3265,10 @@ function createRemoteControlRelay(options) {
3099
3265
  };
3100
3266
  clients.set(key, client);
3101
3267
  }
3102
- client.receive(envelope.message);
3268
+ if (!(Number.isInteger(envelope.seq_id) && (lastInbound.get(key) ?? -1) >= envelope.seq_id)) {
3269
+ if (Number.isInteger(envelope.seq_id)) lastInbound.set(key, envelope.seq_id);
3270
+ client.receive(envelope.message);
3271
+ }
3103
3272
  if (Number.isInteger(envelope.seq_id)) await sendEnvelope({
3104
3273
  client_id: envelope.client_id,
3105
3274
  seq_id: envelope.seq_id,
@@ -3144,6 +3313,8 @@ function createRemoteControlRelay(options) {
3144
3313
  else if (envelope.type === "client_message_chunk") {
3145
3314
  const message = receiveChunk(envelope);
3146
3315
  if (message) await deliver(message);
3316
+ } else if (envelope.type === "ack") {
3317
+ 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);
3147
3318
  } else if (envelope.type === "client_closed") closeClient(envelope.client_id, envelope.stream_id);
3148
3319
  else if (envelope.type === "ping") await sendEnvelope({
3149
3320
  client_id: envelope.client_id,
@@ -3168,25 +3339,60 @@ function createRemoteControlRelay(options) {
3168
3339
  let queue = Promise.resolve();
3169
3340
  const onAbort = () => ws.close();
3170
3341
  signal.addEventListener("abort", onAbort, { once: true });
3342
+ let ping;
3343
+ let silent;
3344
+ const hearFrom = () => {
3345
+ clearTimeout(silent);
3346
+ silent = setTimeout(() => {
3347
+ state.lastError = "timeout";
3348
+ try {
3349
+ (ws.terminate ?? ws.close).call(ws);
3350
+ } catch {}
3351
+ }, options.pongTimeoutMs ?? PONG_TIMEOUT_MS);
3352
+ };
3353
+ const stopBeat = () => {
3354
+ clearInterval(ping);
3355
+ clearTimeout(silent);
3356
+ };
3171
3357
  ws.on("open", () => {
3172
3358
  state.status = "connected";
3173
3359
  state.connectedAt = now();
3174
3360
  state.lastError = void 0;
3361
+ replay(ws);
3362
+ if (typeof ws.ping === "function") {
3363
+ hearFrom();
3364
+ ping = setInterval(() => {
3365
+ try {
3366
+ ws.ping();
3367
+ } catch {}
3368
+ }, options.pingMs ?? PING_MS);
3369
+ }
3370
+ });
3371
+ ws.on("pong", hearFrom);
3372
+ ws.on("unexpected-response", (request, response) => {
3373
+ stopBeat();
3374
+ const error = new RemoteControlError("socket", `Unexpected server response: ${response?.statusCode}`);
3375
+ if (response?.statusCode === 429 || response?.statusCode >= 500) error.retryAfterMs = retryAfterMs$1(String(response.headers?.["retry-after"] ?? ""), now());
3376
+ try {
3377
+ request?.destroy?.();
3378
+ } catch {}
3379
+ reject(error);
3175
3380
  });
3176
3381
  ws.on("message", (data) => {
3177
3382
  queue = queue.then(() => receive(String(data))).catch((error) => {
3178
3383
  state.lastError = error?.code ?? "protocol";
3179
3384
  });
3180
3385
  });
3181
- ws.on("error", (error) => reject(new RemoteControlError("socket", error?.message ?? "socket error")));
3386
+ ws.on("error", (error) => {
3387
+ stopBeat();
3388
+ reject(new RemoteControlError("socket", error?.message ?? "socket error"));
3389
+ });
3182
3390
  ws.on("close", () => {
3391
+ stopBeat();
3183
3392
  signal.removeEventListener("abort", onAbort);
3184
3393
  resolve();
3185
3394
  });
3186
3395
  });
3187
- for (const [, client] of clients) client.close();
3188
- clients.clear();
3189
- sequences.clear();
3190
3396
  assemblies.clear();
3191
3397
  socket = void 0;
3192
3398
  };
@@ -3194,12 +3400,15 @@ function createRemoteControlRelay(options) {
3194
3400
  let delay = INITIAL_RECONNECT_MS;
3195
3401
  let conflicts = 0;
3196
3402
  let conflictSince = 0;
3403
+ let asked;
3197
3404
  while (!signal.aborted) {
3198
3405
  state.status = "connecting";
3199
3406
  try {
3407
+ state.connectedAt = void 0;
3200
3408
  await connectOnce(signal);
3201
- delay = INITIAL_RECONNECT_MS;
3409
+ if (state.connectedAt !== void 0 && now() - state.connectedAt >= STABLE_MS) delay = INITIAL_RECONNECT_MS;
3202
3410
  } catch (error) {
3411
+ asked = error?.retryAfterMs;
3203
3412
  state.lastError = error?.code ?? "network";
3204
3413
  if (options.debug) state.detail = String(error?.message ?? error).slice(0, 160);
3205
3414
  state.status = "error";
@@ -3210,6 +3419,7 @@ function createRemoteControlRelay(options) {
3210
3419
  installation = await options.renewInstallation();
3211
3420
  enrolled = void 0;
3212
3421
  conflicts = 0;
3422
+ resetStreams();
3213
3423
  }
3214
3424
  if (error?.code === "not-signed-in") {
3215
3425
  state.status = "stopped";
@@ -3218,12 +3428,16 @@ function createRemoteControlRelay(options) {
3218
3428
  }
3219
3429
  if (signal.aborted) break;
3220
3430
  state.reconnects += 1;
3221
- await wait(delay, signal);
3431
+ const spread = Math.round(delay * (.5 + (options.random ?? Math.random)() / 2));
3432
+ await wait(asked === void 0 ? spread : Math.max(spread, asked + Math.round((options.random ?? Math.random)() * RETRY_AFTER_JITTER_MS)), signal);
3433
+ asked = void 0;
3222
3434
  delay = Math.min(delay * 2, MAX_RECONNECT_MS);
3223
3435
  }
3224
3436
  };
3225
3437
  return Object.freeze({
3226
3438
  pair,
3439
+ listClients,
3440
+ revokeClient,
3227
3441
  status: () => ({
3228
3442
  ...state,
3229
3443
  enrolled: enrolled !== void 0,
@@ -3244,6 +3458,7 @@ function createRemoteControlRelay(options) {
3244
3458
  stop?.abort();
3245
3459
  socket?.close();
3246
3460
  await running;
3461
+ resetStreams();
3247
3462
  state.status = "stopped";
3248
3463
  }
3249
3464
  });
@@ -3322,6 +3537,13 @@ function createRemoteControl(options) {
3322
3537
  async pair(signal) {
3323
3538
  return (await ensureRelay()).pair(signal);
3324
3539
  },
3540
+ async clients(signal) {
3541
+ return (await ensureRelay()).listClients(signal);
3542
+ },
3543
+ async revoke(clientId, signal) {
3544
+ await (await ensureRelay()).revokeClient(clientId, signal);
3545
+ return this.clients(signal);
3546
+ },
3325
3547
  status() {
3326
3548
  return {
3327
3549
  ...relay?.status() ?? {
@@ -3338,6 +3560,212 @@ function createRemoteControl(options) {
3338
3560
  });
3339
3561
  }
3340
3562
  //#endregion
3563
+ //#region src/remote-control-git.js
3564
+ const DEFAULT_TIMEOUT_MS$2 = 15e3;
3565
+ const MAX_TIMEOUT_MS = 3e4;
3566
+ const DEFAULT_OUTPUT_BYTES = 1024 * 1024;
3567
+ const MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
3568
+ const READ_ONLY = /* @__PURE__ */ new Set([
3569
+ "status",
3570
+ "diff",
3571
+ "rev-parse",
3572
+ "ls-files",
3573
+ "show",
3574
+ "log",
3575
+ "rev-list",
3576
+ "merge-base",
3577
+ "diff-tree",
3578
+ "ls-tree",
3579
+ "cat-file",
3580
+ "describe",
3581
+ "shortlog",
3582
+ "blame",
3583
+ "diff-files",
3584
+ "diff-index",
3585
+ "for-each-ref",
3586
+ "count-objects"
3587
+ ]);
3588
+ const EXACT = [
3589
+ ["branch", "--show-current"],
3590
+ ["remote"],
3591
+ ["remote", "-v"],
3592
+ ["symbolic-ref", "HEAD"],
3593
+ [
3594
+ "symbolic-ref",
3595
+ "--short",
3596
+ "HEAD"
3597
+ ],
3598
+ [
3599
+ "symbolic-ref",
3600
+ "-q",
3601
+ "HEAD"
3602
+ ]
3603
+ ];
3604
+ const FORBIDDEN = /^(?:-o$|--output(?:=|$)|--ext-diff|--textconv|--exec|--upload-pack|--receive-pack|--git-dir|--work-tree|--namespace|--super-prefix|--open-files-in-pager|-O|--paginate|-c$|-C$|--config)/u;
3605
+ const TEXTUAL = /* @__PURE__ */ new Set([
3606
+ "diff",
3607
+ "show",
3608
+ "log",
3609
+ "blame",
3610
+ "diff-tree",
3611
+ "diff-files",
3612
+ "diff-index"
3613
+ ]);
3614
+ /** The argv to run for a phone request, or a reason it is refused. */
3615
+ function gitArguments(command) {
3616
+ if (!Array.isArray(command) || command.length < 2 || command.some((part) => typeof part !== "string" || part.length > 4096 || part.includes("\0"))) return { error: "not a git command" };
3617
+ if (!/^git(?:\.exe)?$/iu.test(basename(command[0]))) return { error: "only git is available" };
3618
+ let rest = command.slice(1);
3619
+ if (rest[0] === "--no-pager") rest = rest.slice(1);
3620
+ const [subcommand, ...args] = rest;
3621
+ if (subcommand === void 0) return { error: "no git subcommand" };
3622
+ if (args.some((arg) => FORBIDDEN.test(arg))) return { error: `option not allowed with git ${subcommand}` };
3623
+ const exact = EXACT.some((form) => form.length === rest.length && form.every((part, index) => part === rest[index]));
3624
+ if (!READ_ONLY.has(subcommand) && !exact) return { error: `git ${subcommand} is not available` };
3625
+ return { argv: [
3626
+ "-c",
3627
+ "core.fsmonitor=false",
3628
+ "-c",
3629
+ "core.pager=cat",
3630
+ "-c",
3631
+ "diff.external=",
3632
+ "--no-pager",
3633
+ subcommand,
3634
+ ...TEXTUAL.has(subcommand) ? ["--no-ext-diff", "--no-textconv"] : [],
3635
+ ...args
3636
+ ] };
3637
+ }
3638
+ const inside = (folder, root) => {
3639
+ const path = relative(resolve(root), resolve(folder));
3640
+ return path === "" || !path.startsWith("..") && !path.startsWith(`..${sep}`) && !/^[A-Za-z]:/u.test(path);
3641
+ };
3642
+ /**
3643
+ * @param {{ command: string[], cwd?: string, timeoutMs?: number, outputBytesCap?: number, tty?: boolean, streamStdin?: boolean, streamStdoutStderr?: boolean }} params
3644
+ * @param {{ folders: string[], run?: typeof execFile }} options folders: the conversation folders git may run in
3645
+ */
3646
+ function runGit(params, { folders, run = execFile }) {
3647
+ const parsed = gitArguments(params?.command);
3648
+ if (parsed.error) return Promise.reject(Object.assign(new Error(parsed.error), { code: "not-allowed" }));
3649
+ if (params.tty || params.streamStdin || params.streamStdoutStderr) return Promise.reject(Object.assign(/* @__PURE__ */ new Error("streaming is not available"), { code: "not-allowed" }));
3650
+ const cwd = localFolder(params.cwd);
3651
+ if (!cwd || !folders.some((root) => inside(cwd, root))) return Promise.reject(Object.assign(/* @__PURE__ */ new Error("git only runs in a DSH conversation folder"), { code: "not-allowed" }));
3652
+ const timeout = Math.min(Number.isFinite(params.timeoutMs) && params.timeoutMs > 0 ? params.timeoutMs : DEFAULT_TIMEOUT_MS$2, MAX_TIMEOUT_MS);
3653
+ const cap = Math.min(Number.isInteger(params.outputBytesCap) && params.outputBytesCap > 0 ? params.outputBytesCap : DEFAULT_OUTPUT_BYTES, MAX_OUTPUT_BYTES);
3654
+ const env = {
3655
+ ...process.env,
3656
+ GIT_OPTIONAL_LOCKS: "0",
3657
+ GIT_TERMINAL_PROMPT: "0",
3658
+ GIT_PAGER: "cat",
3659
+ GIT_EXTERNAL_DIFF: "",
3660
+ GIT_ASKPASS: "",
3661
+ GCM_INTERACTIVE: "never"
3662
+ };
3663
+ return new Promise((resolvePromise) => {
3664
+ run("git", parsed.argv, {
3665
+ cwd,
3666
+ env,
3667
+ timeout,
3668
+ maxBuffer: cap,
3669
+ windowsHide: true,
3670
+ encoding: "utf8"
3671
+ }, (error, stdout, stderr) => {
3672
+ resolvePromise({
3673
+ exitCode: error ? typeof error.code === "number" ? error.code : 1 : 0,
3674
+ stdout: String(stdout ?? "").slice(0, cap),
3675
+ stderr: error && typeof error.code !== "number" ? String(error.message).slice(0, 500) : String(stderr ?? "").slice(0, cap)
3676
+ });
3677
+ });
3678
+ });
3679
+ }
3680
+ //#endregion
3681
+ //#region src/remote-control-files.js
3682
+ const SKIP = /* @__PURE__ */ new Set([
3683
+ "node_modules",
3684
+ ".git",
3685
+ ".hg",
3686
+ ".svn",
3687
+ "dist",
3688
+ "build",
3689
+ "target",
3690
+ "__pycache__",
3691
+ ".venv",
3692
+ "venv",
3693
+ ".next",
3694
+ ".cache",
3695
+ "coverage"
3696
+ ]);
3697
+ const MAX_ENTRIES = 3e4;
3698
+ const MAX_DEPTH = 8;
3699
+ const BUDGET_MS = 2e3;
3700
+ const RESULTS = 50;
3701
+ /** Subsequence match with a bonus for runs, word starts and the file name; undefined when the query is not in the text. */
3702
+ function fuzzyScore(query, text) {
3703
+ const needle = query.toLowerCase();
3704
+ const hay = text.toLowerCase();
3705
+ const indices = [];
3706
+ let from = 0;
3707
+ let score = 0;
3708
+ let previous = -2;
3709
+ const nameStart = hay.lastIndexOf("/") + 1;
3710
+ for (const char of needle) {
3711
+ const at = hay.indexOf(char, from);
3712
+ if (at < 0) return void 0;
3713
+ indices.push(at);
3714
+ score += 1 + (at === previous + 1 ? 5 : 0) + (at === 0 || "/_-. ".includes(hay[at - 1]) ? 4 : 0) + (at >= nameStart ? 2 : 0);
3715
+ previous = at;
3716
+ from = at + 1;
3717
+ }
3718
+ return {
3719
+ score: score * 10 - Math.floor(Math.min(text.length, 200) / 10),
3720
+ indices
3721
+ };
3722
+ }
3723
+ const within = (folder, target) => {
3724
+ const path = relative(resolve(folder), resolve(target));
3725
+ return path === "" || !path.startsWith("..") && !/^[A-Za-z]:/u.test(path);
3726
+ };
3727
+ /** Matches for `query` under each root; roots outside `folders` are ignored. */
3728
+ async function searchFiles({ roots, query, folders, now = Date.now }) {
3729
+ const found = [];
3730
+ const started = now();
3731
+ let seen = 0;
3732
+ for (const given of roots) {
3733
+ const root = localFolder(given);
3734
+ if (!root || !folders.some((folder) => within(folder, root))) continue;
3735
+ const queue = [[root, 0]];
3736
+ while (queue.length > 0 && seen < MAX_ENTRIES && now() - started < BUDGET_MS) {
3737
+ const [folder, depth] = queue.shift();
3738
+ let entries;
3739
+ try {
3740
+ entries = await readdir(folder, { withFileTypes: true });
3741
+ } catch {
3742
+ continue;
3743
+ }
3744
+ for (const entry of entries) {
3745
+ if (entry.name.startsWith(".") && entry.name !== ".github") continue;
3746
+ const directory = entry.isDirectory();
3747
+ if (!directory && !entry.isFile()) continue;
3748
+ seen += 1;
3749
+ const path = relative(root, join(folder, entry.name)).split(sep).join("/");
3750
+ const match = query === "" ? {
3751
+ score: 0,
3752
+ indices: []
3753
+ } : fuzzyScore(query, path);
3754
+ if (match) found.push({
3755
+ root,
3756
+ path,
3757
+ match_type: directory ? "directory" : "file",
3758
+ file_name: entry.name,
3759
+ score: match.score,
3760
+ indices: match.indices
3761
+ });
3762
+ if (directory && !SKIP.has(entry.name) && depth < MAX_DEPTH) queue.push([join(folder, entry.name), depth + 1]);
3763
+ }
3764
+ }
3765
+ }
3766
+ return found.sort((a, b) => b.score - a.score || a.path.length - b.path.length).slice(0, RESULTS);
3767
+ }
3768
+ //#endregion
3341
3769
  //#region src/remote-control-dsh.js
3342
3770
  /** A folder the phone named, only when it is an existing absolute folder on this machine. */
3343
3771
  function localFolder(value) {
@@ -3349,6 +3777,84 @@ function localFolder(value) {
3349
3777
  return;
3350
3778
  }
3351
3779
  }
3780
+ /** One image the phone attached, sent inline as a data URL (its own file paths mean nothing on this machine). */
3781
+ function dataImage(url) {
3782
+ const match = typeof url === "string" ? /^data:(image\/[A-Za-z0-9.+-]+);base64,([A-Za-z0-9+/=\s]+)$/u.exec(url) : null;
3783
+ return match ? {
3784
+ mediaType: match[1].toLowerCase(),
3785
+ data: new Uint8Array(Buffer.from(match[2], "base64"))
3786
+ } : void 0;
3787
+ }
3788
+ const inputImages = (input) => (Array.isArray(input) ? input : []).filter((part) => part?.type === "image" && typeof part.url === "string");
3789
+ const MAX_PHONE_IMAGES = 8;
3790
+ /** Facts about a real local file or folder the phone asks about, so its folder picker shows what is on this machine. */
3791
+ function realMetadata(value) {
3792
+ if (typeof value !== "string" || !isAbsolute(value)) return void 0;
3793
+ try {
3794
+ const stat = statSync(value);
3795
+ return {
3796
+ isDirectory: stat.isDirectory(),
3797
+ isFile: stat.isFile(),
3798
+ isSymlink: false,
3799
+ createdAtMs: Math.floor(stat.birthtimeMs),
3800
+ modifiedAtMs: Math.floor(stat.mtimeMs)
3801
+ };
3802
+ } catch {
3803
+ return;
3804
+ }
3805
+ }
3806
+ /** Names inside a real local folder (folders first, hidden ones left out, bounded); nothing for anything else. */
3807
+ function readFolder(value) {
3808
+ const folder = localFolder(value);
3809
+ if (!folder) return [];
3810
+ try {
3811
+ return readdirSync(folder, { withFileTypes: true }).filter((entry) => !entry.name.startsWith(".") && (entry.isDirectory() || entry.isFile())).map((entry) => ({
3812
+ fileName: entry.name,
3813
+ isDirectory: entry.isDirectory(),
3814
+ isFile: entry.isFile()
3815
+ })).sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.fileName.localeCompare(b.fileName)).slice(0, 500);
3816
+ } catch {
3817
+ return [];
3818
+ }
3819
+ }
3820
+ /** Offset paging over a full list, the way the phone asks for it (cursor is the next offset). */
3821
+ function page(list, params) {
3822
+ const start = Number.isInteger(Number(params?.cursor)) ? Math.max(0, Number(params.cursor)) : 0;
3823
+ const limit = Number.isInteger(params?.limit) && params.limit > 0 ? params.limit : list.length;
3824
+ return {
3825
+ data: list.slice(start, start + limit),
3826
+ nextCursor: start + limit < list.length ? String(start + limit) : null,
3827
+ backwardsCursor: null
3828
+ };
3829
+ }
3830
+ /** Codex usage as the phone's rate-limit snapshot (primary and secondary windows of the main limit). */
3831
+ function rateLimits(read) {
3832
+ const toWindow = (entry) => entry && {
3833
+ usedPercent: Math.round(entry.usedPercent),
3834
+ windowDurationMins: Math.round(entry.windowSeconds / 60),
3835
+ resetsAt: entry.resetsAt ?? null
3836
+ };
3837
+ const snapshot = (limit) => ({
3838
+ limitId: limit?.id ?? "codex",
3839
+ limitName: limit?.name ?? null,
3840
+ normalModelSlug: null,
3841
+ primary: toWindow(limit?.windows?.[0]) ?? null,
3842
+ secondary: toWindow(limit?.windows?.[1]) ?? null,
3843
+ credits: null,
3844
+ individualLimit: null,
3845
+ spendControlReached: read?.spendControlReached ?? null,
3846
+ planType: read?.planType ?? null,
3847
+ rateLimitReachedType: read?.rateLimitReachedType ?? null
3848
+ });
3849
+ return {
3850
+ ordinaryUsageAllowed: null,
3851
+ rateLimits: snapshot(read?.rateLimits?.find((limit) => limit.id === "codex") ?? read?.rateLimits?.[0]),
3852
+ rateLimitsByLimitId: read ? Object.fromEntries((read.rateLimits ?? []).map((limit) => [limit.id, snapshot(limit)])) : null,
3853
+ rateLimitResetCredits: null,
3854
+ accountId: null,
3855
+ rateLimitUpsell: null
3856
+ };
3857
+ }
3352
3858
  const seconds = (value) => Math.floor((Number.isFinite(value) ? value : Date.now()) / 1e3);
3353
3859
  const emptyTurn = () => ({
3354
3860
  id: randomUUID(),
@@ -3362,6 +3868,8 @@ const emptyTurn = () => ({
3362
3868
  });
3363
3869
  const textOf = (content) => (content ?? []).map((block) => block?.type === "text" ? block.text : "").filter(Boolean).join("\n");
3364
3870
  const inputText = (input) => (Array.isArray(input) ? input : []).map((part) => part?.type === "text" ? part.text : "").filter(Boolean).join("\n");
3871
+ /** Skills the phone picked, written the way DSH invokes a skill: a /name gesture ahead of the text. */
3872
+ const skillGestures = (input) => (Array.isArray(input) ? input : []).filter((part) => part?.type === "skill" && /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(String(part.name))).map((part) => `/${part.name}`);
3365
3873
  const notFound = (id) => new RpcError(-32602, `Thread not found: ${String(id).slice(0, 80)}`);
3366
3874
  const modelKey = (selection) => `${encodeURIComponent(selection.provider)}/${encodeURIComponent(selection.model)}`;
3367
3875
  function parseModelKey(value) {
@@ -3388,8 +3896,98 @@ function parseArguments(value) {
3388
3896
  const visibleText = (content) => textOf(content).replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gu, "").trim();
3389
3897
  /** Text of a user-role message the person wrote; DSH's own injected ones (runtime context, notices) carry another source kind. */
3390
3898
  const typedText = (message) => message.role === "user" && (message.source === void 0 || message.source?.kind === "user") ? visibleText(message.content) : "";
3899
+ const lines = (text) => String(text ?? "").split("\n");
3900
+ const added = (text) => lines(text).map((line) => `+${line}`).join("\n");
3901
+ const removed = (text) => lines(text).map((line) => `-${line}`).join("\n");
3902
+ /**
3903
+ * How a DSH tool call looks on the phone: shell commands and file edits use the phone's own
3904
+ * command and diff views; everything else stays a generic tool call.
3905
+ */
3906
+ function toolItem(block, cwd) {
3907
+ const args = parseArguments(block.arguments);
3908
+ const base = {
3909
+ id: block.id,
3910
+ status: "inProgress"
3911
+ };
3912
+ if (/^(bash|pwsh)(_persistent)?$/u.test(block.name) && typeof args?.command === "string") return {
3913
+ type: "commandExecution",
3914
+ ...base,
3915
+ command: args.command,
3916
+ cwd: typeof args.workdir === "string" ? args.workdir : cwd ?? "",
3917
+ processId: null,
3918
+ source: "agent",
3919
+ commandActions: [],
3920
+ aggregatedOutput: null,
3921
+ exitCode: null,
3922
+ durationMs: null
3923
+ };
3924
+ if (block.name === "edit" && typeof args?.file_path === "string") return {
3925
+ type: "fileChange",
3926
+ ...base,
3927
+ changes: [{
3928
+ path: args.file_path,
3929
+ kind: {
3930
+ type: "update",
3931
+ move_path: null
3932
+ },
3933
+ diff: `@@\n${removed(args.old_string)}\n${added(args.new_string)}`
3934
+ }]
3935
+ };
3936
+ if (block.name === "write" && typeof args?.file_path === "string") return {
3937
+ type: "fileChange",
3938
+ ...base,
3939
+ changes: [{
3940
+ path: args.file_path,
3941
+ kind: { type: "add" },
3942
+ diff: added(args.content)
3943
+ }]
3944
+ };
3945
+ if (block.name === "codex_image_generate") return {
3946
+ type: "imageGeneration",
3947
+ ...base,
3948
+ revisedPrompt: typeof args?.prompt === "string" ? args.prompt : null,
3949
+ result: ""
3950
+ };
3951
+ return {
3952
+ type: "dynamicToolCall",
3953
+ ...base,
3954
+ namespace: null,
3955
+ tool: block.name,
3956
+ arguments: args,
3957
+ contentItems: null,
3958
+ success: null,
3959
+ durationMs: null
3960
+ };
3961
+ }
3962
+ /** Record a tool's result on the item that showed the call. */
3963
+ function finishTool(item, message, imageUrls) {
3964
+ const failed = Boolean(message.isError);
3965
+ item.status = failed ? "failed" : "completed";
3966
+ if (item.type === "commandExecution") {
3967
+ item.aggregatedOutput = textOf(message.content);
3968
+ item.exitCode = failed ? 1 : 0;
3969
+ } else if (item.type === "imageGeneration") {
3970
+ const url = imageBlocks(message).map((block) => imageUrls.get(block.attachment.attachmentId)).find(Boolean);
3971
+ item.result = url ? url.slice(url.indexOf(",") + 1) : "";
3972
+ if (!url) item.status = "failed";
3973
+ } else if (item.type === "dynamicToolCall") {
3974
+ item.contentItems = toolOutput(message, imageUrls);
3975
+ item.success = !failed;
3976
+ }
3977
+ }
3978
+ const imageBlocks = (message) => (message.content ?? []).filter((block) => block?.type === "image" && block.attachment);
3979
+ const toolOutput = (message, imageUrls) => [{
3980
+ type: "inputText",
3981
+ text: textOf(message.content)
3982
+ }, ...imageBlocks(message).map((block) => imageUrls.get(block.attachment.attachmentId)).filter(Boolean).map((imageUrl) => ({
3983
+ type: "inputImage",
3984
+ imageUrl
3985
+ }))];
3391
3986
  /** One user message starts a turn; assistant blocks and tool results fill it. */
3392
- function projectTurns(messages, at = Date.now()) {
3987
+ /** Images the person attached to a message of their own. */
3988
+ const typedImages = (message) => message.role === "user" && (message.source === void 0 || message.source?.kind === "user") ? (message.content ?? []).filter((block) => block?.type === "image" && block.attachment) : [];
3989
+ /** `imageUrls` maps an attachment id to a data URL the phone can show; images without one are left out. */
3990
+ function projectTurns(messages, at = Date.now(), imageUrls = /* @__PURE__ */ new Map(), cwd = void 0) {
3393
3991
  const turns = [];
3394
3992
  let turn;
3395
3993
  const calls = /* @__PURE__ */ new Map();
@@ -3403,7 +4001,8 @@ function projectTurns(messages, at = Date.now()) {
3403
4001
  };
3404
4002
  for (const message of messages) if (message.role === "user") {
3405
4003
  const text = typedText(message);
3406
- if (text === "") continue;
4004
+ const pictures = typedImages(message).map((block) => imageUrls.get(block.attachment.attachmentId)).filter(Boolean);
4005
+ if (text === "" && pictures.length === 0) continue;
3407
4006
  close();
3408
4007
  turn = {
3409
4008
  ...emptyTurn(),
@@ -3414,11 +4013,14 @@ function projectTurns(messages, at = Date.now()) {
3414
4013
  type: "userMessage",
3415
4014
  id: message.id,
3416
4015
  clientId: null,
3417
- content: [{
4016
+ content: [...text === "" ? [] : [{
3418
4017
  type: "text",
3419
4018
  text,
3420
4019
  text_elements: []
3421
- }]
4020
+ }], ...pictures.map((url) => ({
4021
+ type: "image",
4022
+ url
4023
+ }))]
3422
4024
  });
3423
4025
  } else if (message.role === "assistant") {
3424
4026
  turn ??= {
@@ -3442,36 +4044,19 @@ function projectTurns(messages, at = Date.now()) {
3442
4044
  content: [block.text]
3443
4045
  });
3444
4046
  else if (block.type === "tool-call") {
3445
- const item = {
3446
- type: "dynamicToolCall",
3447
- id: block.id,
3448
- namespace: null,
3449
- tool: block.name,
3450
- arguments: parseArguments(block.arguments),
3451
- status: "inProgress",
3452
- contentItems: null,
3453
- success: null,
3454
- durationMs: null
3455
- };
4047
+ const item = toolItem(block, cwd);
3456
4048
  calls.set(block.id, item);
3457
4049
  turn.items.push(item);
3458
4050
  }
3459
4051
  }
3460
4052
  } else if (message.role === "tool") {
3461
4053
  const item = calls.get(message.toolCallId);
3462
- if (item) {
3463
- item.contentItems = [{
3464
- type: "inputText",
3465
- text: textOf(message.content)
3466
- }];
3467
- item.status = message.isError ? "failed" : "completed";
3468
- item.success = !message.isError;
3469
- }
4054
+ if (item) finishTool(item, message, imageUrls);
3470
4055
  }
3471
4056
  close();
3472
4057
  return turns;
3473
4058
  }
3474
- function thread({ id, title, preview, cwd, createdAt, updatedAt, running, turns = [] }) {
4059
+ function thread({ id, title, preview, cwd, createdAt, updatedAt, running, turns = [], projectId = null }) {
3475
4060
  return {
3476
4061
  id,
3477
4062
  sessionId: id,
@@ -3500,7 +4085,7 @@ function thread({ id, title, preview, cwd, createdAt, updatedAt, running, turns
3500
4085
  historyMode: "legacy",
3501
4086
  section: null,
3502
4087
  sectionEnteredAt: null,
3503
- projectId: null,
4088
+ projectId,
3504
4089
  canAcceptDirectInput: true,
3505
4090
  extra: null
3506
4091
  };
@@ -3509,7 +4094,7 @@ function thread({ id, title, preview, cwd, createdAt, updatedAt, running, turns
3509
4094
  * @param {{ controller: () => any, agents: () => any, permissions?: () => any, userAgent: string }} options
3510
4095
  */
3511
4096
  const PHONE_RETURN_MS = 12e4;
3512
- function createDshRemoteControl({ controller, agents, permissions = () => void 0, workspaces = () => void 0, trace = () => {}, phoneReturnMs = PHONE_RETURN_MS, userAgent }) {
4097
+ function createDshRemoteControl({ controller, agents, permissions = () => void 0, workspaces = () => void 0, attachments = () => void 0, usage = () => void 0, projections = () => void 0, skills = () => void 0, llm = () => void 0, trace = () => {}, phoneReturnMs = PHONE_RETURN_MS, userAgent }) {
3513
4098
  const subscribers = /* @__PURE__ */ new Map();
3514
4099
  const active = /* @__PURE__ */ new Map();
3515
4100
  const queues = /* @__PURE__ */ new Map();
@@ -3581,6 +4166,69 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
3581
4166
  turnsBackwardsCursor: null
3582
4167
  };
3583
4168
  };
4169
+ const searches = /* @__PURE__ */ new Map();
4170
+ /** Folders of DSH workspaces and conversations: the only places the phone may search or run git. */
4171
+ const conversationFolders = async () => {
4172
+ const items = (await service().list({}).catch(() => ({ items: [] }))).items ?? [];
4173
+ return [...workspaceList().map((entry) => entry.path), ...items.map((item) => item.cwd)].filter((folder) => typeof folder === "string" && folder !== "");
4174
+ };
4175
+ /** DSH's token and context-window projections as the phone's token-usage ring. */
4176
+ const tokenUsage = (session) => {
4177
+ try {
4178
+ const values = projections()?.snapshot?.(session, ["tokenUsage", "contextPressure"])?.values;
4179
+ const used = values?.tokenUsage;
4180
+ if (!used) return void 0;
4181
+ const breakdown = (total, input, cached, output) => ({
4182
+ totalTokens: total,
4183
+ inputTokens: input,
4184
+ cachedInputTokens: cached,
4185
+ cacheWriteInputTokens: 0,
4186
+ outputTokens: output,
4187
+ reasoningOutputTokens: 0
4188
+ });
4189
+ const input = (used.uncachedInputTokens ?? 0) + (used.cacheReadTokens ?? 0) + (used.cacheWriteTokens ?? 0);
4190
+ const pressure = values.contextPressure?.pressureTokens ?? 0;
4191
+ return {
4192
+ total: breakdown(input + (used.outputTokens ?? 0), input, used.cacheReadTokens ?? 0, used.outputTokens ?? 0),
4193
+ last: breakdown(pressure, pressure, 0, 0),
4194
+ modelContextWindow: values.contextPressure?.contextWindow ?? null
4195
+ };
4196
+ } catch {
4197
+ return;
4198
+ }
4199
+ };
4200
+ const workspaceList = () => {
4201
+ try {
4202
+ return workspaces()?.list?.() ?? [];
4203
+ } catch {
4204
+ return [];
4205
+ }
4206
+ };
4207
+ const projectIdOf = (sessionId) => workspaceList().find((entry) => entry.sessionIds?.includes(sessionId))?.id ?? null;
4208
+ const projectOf = (entry, position) => ({
4209
+ id: String(entry.id),
4210
+ name: entry.title || String(entry.path).split(/[\/]/u).filter(Boolean).at(-1) || "Workspace",
4211
+ roots: [{ path: entry.path }],
4212
+ metadata: {},
4213
+ position,
4214
+ createdAt: seconds(entry.createdAt),
4215
+ updatedAt: seconds(entry.updatedAt),
4216
+ recencyAt: entry.sessionIds?.length ? seconds(entry.updatedAt) : null
4217
+ });
4218
+ /** Data URLs for the images in a conversation (newest first, bounded), so the phone can show them. */
4219
+ const imageUrlsOf = async (messages) => {
4220
+ const urls = /* @__PURE__ */ new Map();
4221
+ const store = attachments();
4222
+ if (!store) return urls;
4223
+ const wanted = messages.flatMap((message) => message.role === "tool" || typedImages(message).length > 0 ? imageBlocks(message) : []).toReversed().slice(0, 12);
4224
+ await Promise.all(wanted.map(async (block) => {
4225
+ try {
4226
+ const stored = await store.readImage(block.attachment, AbortSignal.timeout(1e4));
4227
+ urls.set(block.attachment.attachmentId, `data:${stored.ref.mediaType};base64,${Buffer.from(stored.data).toString("base64")}`);
4228
+ } catch {}
4229
+ }));
4230
+ return urls;
4231
+ };
3584
4232
  const openThread = async (id, notify) => {
3585
4233
  const agent = await agentFor(id);
3586
4234
  if (notify) subscribe(id, notify);
@@ -3588,6 +4236,7 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
3588
4236
  const session = agent.session;
3589
4237
  const messages = session.deriveMessages();
3590
4238
  const first = messages.find((message) => typedText(message) !== "");
4239
+ const imageUrls = await imageUrlsOf(messages);
3591
4240
  return {
3592
4241
  agent,
3593
4242
  thread: thread({
@@ -3595,8 +4244,9 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
3595
4244
  cwd: session.meta?.cwd,
3596
4245
  createdAt: session.meta?.createdAt,
3597
4246
  running: active.has(id),
4247
+ projectId: projectIdOf(id),
3598
4248
  preview: first ? visibleText(first.content).slice(0, 200) : "",
3599
- turns: projectTurns(messages, session.meta?.createdAt)
4249
+ turns: projectTurns(messages, session.meta?.createdAt, imageUrls, session.meta?.cwd)
3600
4250
  })
3601
4251
  };
3602
4252
  };
@@ -3703,9 +4353,33 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
3703
4353
  completedAt: null
3704
4354
  } };
3705
4355
  };
4356
+ /** Message content for DSH from what the phone sent: its text plus any inline images (DSH admits and stores them itself). */
4357
+ const contentOf = (input) => {
4358
+ const text = [...skillGestures(input), inputText(input)].filter(Boolean).join(" ");
4359
+ const pictures = inputImages(input);
4360
+ if (text === "" && pictures.length === 0) throw invalidParams("Send some text or an image");
4361
+ if (pictures.length > MAX_PHONE_IMAGES) throw invalidParams(`At most ${MAX_PHONE_IMAGES} images per message`);
4362
+ const images = pictures.map((picture) => {
4363
+ const image = dataImage(picture.url);
4364
+ if (!image) throw invalidParams("Only images sent inline are supported");
4365
+ return {
4366
+ type: "image",
4367
+ data: Buffer.from(image.data).toString("base64"),
4368
+ mediaType: image.mediaType,
4369
+ name: "phone-image"
4370
+ };
4371
+ });
4372
+ return {
4373
+ text,
4374
+ pictures,
4375
+ content: [...text === "" ? [] : [{
4376
+ type: "text",
4377
+ text
4378
+ }], ...images]
4379
+ };
4380
+ };
3706
4381
  async function startTurn(params, notify) {
3707
- const text = inputText(params?.input);
3708
- if (text === "") throw invalidParams("Only text input is supported");
4382
+ const { text, pictures, content } = contentOf(params?.input);
3709
4383
  lastPrompt.text = text;
3710
4384
  const id = params?.threadId;
3711
4385
  const agent = await agentFor(id);
@@ -3722,16 +4396,26 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
3722
4396
  type: "userMessage",
3723
4397
  id: randomUUID(),
3724
4398
  clientId: params.clientUserMessageId ?? null,
3725
- content: [{
4399
+ content: [...text === "" ? [] : [{
3726
4400
  type: "text",
3727
4401
  text,
3728
4402
  text_elements: []
3729
- }]
4403
+ }], ...pictures.map((picture) => ({
4404
+ type: "image",
4405
+ url: picture.url
4406
+ }))]
3730
4407
  };
3731
4408
  emit(id, "turn/started", {
3732
4409
  threadId: id,
3733
4410
  turn
3734
4411
  });
4412
+ emit(id, "thread/status/changed", {
4413
+ threadId: id,
4414
+ status: {
4415
+ type: "active",
4416
+ activeFlags: []
4417
+ }
4418
+ });
3735
4419
  emit(id, "item/started", {
3736
4420
  threadId: id,
3737
4421
  turnId: turn.id,
@@ -3749,10 +4433,7 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
3749
4433
  requestId: randomUUID(),
3750
4434
  sessionId: id,
3751
4435
  mode: "queue",
3752
- content: [{
3753
- type: "text",
3754
- text
3755
- }]
4436
+ content
3756
4437
  }, AbortSignal.timeout(3e4));
3757
4438
  } catch (error) {
3758
4439
  active.delete(id);
@@ -3777,10 +4458,20 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
3777
4458
  }
3778
4459
  turn.completedAt = seconds(Date.now());
3779
4460
  active.delete(id);
4461
+ const usageNow = tokenUsage(agent.session);
4462
+ if (usageNow) emit(id, "thread/tokenUsage/updated", {
4463
+ threadId: id,
4464
+ turnId: turn.id,
4465
+ tokenUsage: usageNow
4466
+ });
3780
4467
  emit(id, "turn/completed", {
3781
4468
  threadId: id,
3782
4469
  turn
3783
4470
  });
4471
+ emit(id, "thread/status/changed", {
4472
+ threadId: id,
4473
+ status: { type: "idle" }
4474
+ });
3784
4475
  const [next, ...rest] = queues.get(id) ?? [];
3785
4476
  if (next) {
3786
4477
  queues.set(id, rest);
@@ -3804,11 +4495,11 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
3804
4495
  }[process.platform] ?? process.platform,
3805
4496
  userAgent
3806
4497
  }),
3807
- "account/read": () => ({
4498
+ "account/read": async () => ({
3808
4499
  account: {
3809
4500
  type: "chatgpt",
3810
4501
  email: null,
3811
- planType: "unknown"
4502
+ planType: (await usage()?.read?.().catch(() => void 0))?.planType ?? "unknown"
3812
4503
  },
3813
4504
  requiresOpenaiAuth: false
3814
4505
  }),
@@ -3821,36 +4512,42 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
3821
4512
  const value = await loadCatalog();
3822
4513
  const defaultKey = value.default ? modelKey(value.default) : void 0;
3823
4514
  const data = [];
3824
- for (const group of value.groups ?? []) for (const model of group.models ?? []) {
3825
- const key = modelKey({
3826
- provider: group.id,
3827
- model: model.id
3828
- });
3829
- const efforts = (model.reasoning?.efforts ?? []).map((effort) => ({
3830
- reasoningEffort: effort.id,
3831
- description: effort.name ?? effort.id
3832
- }));
3833
- const supported = efforts.length > 0 ? efforts : [{
3834
- reasoningEffort: "medium",
3835
- description: "Default"
3836
- }];
3837
- data.push({
3838
- id: key,
3839
- model: key,
3840
- displayName: model.name ?? model.id,
3841
- description: group.name ?? "",
3842
- hidden: false,
3843
- isDefault: key === defaultKey,
3844
- supportedReasoningEfforts: supported,
3845
- defaultReasoningEffort: model.reasoning?.defaultEffort ?? supported[0].reasoningEffort,
3846
- inputModalities: ["text"],
3847
- supportsPersonality: false,
3848
- additionalSpeedTiers: [],
3849
- upgrade: null,
3850
- upgradeInfo: null,
3851
- availabilityNux: null,
3852
- serviceTiers: []
3853
- });
4515
+ for (const group of value.groups ?? []) {
4516
+ const modalities = new Map(await Promise.all((group.models ?? []).map(async (model) => {
4517
+ const info = await Promise.resolve(llm()?.resolveModelInfo?.(group.id, model.id)).catch(() => void 0);
4518
+ return [model.id, (Array.isArray(info?.inputModalities) ? info.inputModalities : ["text"]).filter((kind) => kind === "text" || kind === "image")];
4519
+ })));
4520
+ for (const model of group.models ?? []) {
4521
+ const key = modelKey({
4522
+ provider: group.id,
4523
+ model: model.id
4524
+ });
4525
+ const efforts = (model.reasoning?.efforts ?? []).map((effort) => ({
4526
+ reasoningEffort: effort.id,
4527
+ description: effort.name ?? effort.id
4528
+ }));
4529
+ const supported = efforts.length > 0 ? efforts : [{
4530
+ reasoningEffort: "medium",
4531
+ description: "Default"
4532
+ }];
4533
+ data.push({
4534
+ id: key,
4535
+ model: key,
4536
+ displayName: model.name ?? model.id,
4537
+ description: group.name ?? "",
4538
+ hidden: false,
4539
+ isDefault: key === defaultKey,
4540
+ supportedReasoningEfforts: supported,
4541
+ defaultReasoningEffort: model.reasoning?.defaultEffort ?? supported[0].reasoningEffort,
4542
+ inputModalities: modalities.get(model.id)?.length ? modalities.get(model.id) : ["text"],
4543
+ supportsPersonality: false,
4544
+ additionalSpeedTiers: [],
4545
+ upgrade: null,
4546
+ upgradeInfo: null,
4547
+ availabilityNux: null,
4548
+ serviceTiers: []
4549
+ });
4550
+ }
3854
4551
  }
3855
4552
  return {
3856
4553
  data,
@@ -3878,22 +4575,32 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
3878
4575
  filePath: process.platform === "win32" ? "C:dshconfig.toml" : "/dsh/config.toml",
3879
4576
  overriddenMetadata: null
3880
4577
  }),
3881
- "command/exec": () => {
3882
- throw new RpcError(-32600, "Running commands directly is not available on a DSH host; ask in the conversation instead");
4578
+ "command/exec": async (params) => {
4579
+ const folders = await conversationFolders();
4580
+ try {
4581
+ return await runGit(params, { folders });
4582
+ } catch (error) {
4583
+ trace({
4584
+ method: "command/exec:refused",
4585
+ reason: String(error?.message).slice(0, 80),
4586
+ command: Array.isArray(params?.command) ? params.command.slice(0, 3).map((part) => String(part).slice(0, 40)) : null
4587
+ });
4588
+ throw new RpcError(-32600, error?.code === "not-allowed" ? `Not available on a DSH host: ${error.message}` : "Could not run the command");
4589
+ }
3883
4590
  },
3884
4591
  "plugin/installed": () => ({
3885
4592
  marketplaceLoadErrors: [],
3886
4593
  marketplaces: []
3887
4594
  }),
3888
4595
  "fs/createDirectory": () => ({}),
3889
- "fs/getMetadata": () => ({
4596
+ "fs/getMetadata": (params) => realMetadata(params?.path) ?? {
3890
4597
  isDirectory: true,
3891
4598
  isFile: false,
3892
4599
  isSymlink: false,
3893
4600
  createdAtMs: Date.now(),
3894
4601
  modifiedAtMs: Date.now()
3895
- }),
3896
- "fs/readDirectory": () => ({ entries: [] }),
4602
+ },
4603
+ "fs/readDirectory": (params) => ({ entries: readFolder(params?.path) }),
3897
4604
  "collaborationMode/list": () => ({ data: [] }),
3898
4605
  "permissionProfile/list": () => ({
3899
4606
  data: [],
@@ -3908,7 +4615,6 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
3908
4615
  data: [],
3909
4616
  nextCursor: null
3910
4617
  }),
3911
- "skills/list": () => ({ data: [] }),
3912
4618
  "config/read": () => ({
3913
4619
  config: {},
3914
4620
  origins: {},
@@ -3923,19 +4629,114 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
3923
4629
  const value = await service().list({}, signal);
3924
4630
  const archived = new Set(workspaces()?.archivedSessionIds ?? []);
3925
4631
  const wantArchived = params?.archived === true;
4632
+ const excluded = new Set(Array.isArray(params?.excludedThreadIds) ? params.excludedThreadIds : []);
4633
+ const folders = [params?.cwd].flat().filter((entry) => typeof entry === "string");
4634
+ const members = typeof params?.projectId === "string" ? new Set(workspaceList().find((entry) => String(entry.id) === params.projectId)?.sessionIds ?? []) : void 0;
4635
+ const needle = params?.searchTerm ? String(params.searchTerm).toLocaleLowerCase() : "";
4636
+ return page((value.items ?? []).filter((item) => item.origin !== "subagent" && !item.blank && archived.has(item.sessionId) === wantArchived && !excluded.has(item.sessionId) && (folders.length === 0 || folders.includes(item.cwd)) && (members === void 0 || members.has(item.sessionId))).map((item) => thread({
4637
+ id: item.sessionId,
4638
+ cwd: item.cwd,
4639
+ createdAt: item.updatedAt,
4640
+ updatedAt: item.updatedAt,
4641
+ projectId: projectIdOf(item.sessionId),
4642
+ running: item.running,
4643
+ title: item.projections?.values?.title ?? null
4644
+ })).filter((entry) => !needle || `${entry.name ?? ""} ${entry.preview}`.toLocaleLowerCase().includes(needle)), params);
4645
+ },
4646
+ "project/list": (params) => page(workspaceList().map(projectOf), params),
4647
+ "project/read": async (params) => {
4648
+ const list = workspaceList();
4649
+ const index = list.findIndex((entry) => String(entry.id) === params?.projectId);
4650
+ if (index < 0) throw new RpcError(-32602, `Project not found: ${String(params?.projectId).slice(0, 80)}`);
4651
+ return { project: projectOf(list[index], index) };
4652
+ },
4653
+ "account/rateLimits/read": async () => rateLimits(await usage()?.read?.().catch(() => void 0)),
4654
+ "thread/turns/list": async (params) => {
4655
+ const { thread: value } = await openThread(params?.threadId);
4656
+ const ordered = params?.sortDirection === "asc" ? value.turns : value.turns.toReversed();
4657
+ return {
4658
+ ...page(ordered, params),
4659
+ backwardsCursor: ordered.length ? String(0) : null
4660
+ };
4661
+ },
4662
+ "thread/fork": async (params, { notify }) => {
4663
+ const forked = await service().fork({ sessionId: params?.threadId });
4664
+ const agent = await agentFor(forked.sessionId);
4665
+ await applySelection(agent, forked.sessionId, params);
4666
+ const { thread: value } = await openThread(forked.sessionId, notify);
4667
+ emit(forked.sessionId, "thread/started", { thread: value });
3926
4668
  return {
3927
- data: (value.items ?? []).filter((item) => item.origin !== "subagent" && !item.blank && archived.has(item.sessionId) === wantArchived).map((item) => thread({
3928
- id: item.sessionId,
3929
- cwd: item.cwd,
3930
- createdAt: item.updatedAt,
3931
- updatedAt: item.updatedAt,
3932
- running: item.running,
3933
- title: item.projections?.values?.title ?? null
3934
- })).filter((entry) => !params?.searchTerm || `${entry.name ?? ""} ${entry.preview}`.toLocaleLowerCase().includes(String(params.searchTerm).toLocaleLowerCase())),
3935
- nextCursor: null,
3936
- backwardsCursor: null
4669
+ ...settings(agent, value.cwd),
4670
+ thread: value
3937
4671
  };
3938
4672
  },
4673
+ "thread/readState/update": () => ({}),
4674
+ fuzzyFileSearch: async (params) => ({ files: await searchFiles({
4675
+ roots: params?.roots ?? [],
4676
+ query: String(params?.query ?? ""),
4677
+ folders: await conversationFolders()
4678
+ }) }),
4679
+ "fuzzyFileSearch/sessionStart": (params, { notify }) => {
4680
+ searches.set(params?.sessionId, {
4681
+ roots: params?.roots ?? [],
4682
+ notify
4683
+ });
4684
+ return {};
4685
+ },
4686
+ "fuzzyFileSearch/sessionUpdate": async (params) => {
4687
+ const search = searches.get(params?.sessionId);
4688
+ if (search) {
4689
+ const query = String(params?.query ?? "");
4690
+ const files = await searchFiles({
4691
+ roots: search.roots,
4692
+ query,
4693
+ folders: await conversationFolders()
4694
+ });
4695
+ search.notify("fuzzyFileSearch/sessionUpdated", {
4696
+ sessionId: params.sessionId,
4697
+ query,
4698
+ files
4699
+ }).catch(() => {});
4700
+ search.notify("fuzzyFileSearch/sessionCompleted", { sessionId: params.sessionId }).catch(() => {});
4701
+ }
4702
+ return {};
4703
+ },
4704
+ "fuzzyFileSearch/sessionStop": (params) => {
4705
+ searches.delete(params?.sessionId);
4706
+ return {};
4707
+ },
4708
+ "skills/list": async (params, { signal }) => {
4709
+ const registry = skills();
4710
+ const folders = Array.isArray(params?.cwds) && params.cwds.length > 0 ? params.cwds : [workspaceList()[0]?.path ?? ""];
4711
+ return { data: await Promise.all(folders.map(async (cwd) => {
4712
+ try {
4713
+ return {
4714
+ cwd,
4715
+ errors: [],
4716
+ skills: (await registry?.list?.({
4717
+ cwd: cwd || void 0,
4718
+ signal
4719
+ }) ?? []).filter((skill) => skill?.invocation?.userInvocable !== false).map((skill) => ({
4720
+ name: skill.name,
4721
+ description: skill.description ?? "",
4722
+ shortDescription: null,
4723
+ interface: null,
4724
+ dependencies: null,
4725
+ path: skill.path ?? skill.name,
4726
+ scope: "user",
4727
+ enabled: true,
4728
+ pluginId: null
4729
+ }))
4730
+ };
4731
+ } catch {
4732
+ return {
4733
+ cwd,
4734
+ skills: [],
4735
+ errors: []
4736
+ };
4737
+ }
4738
+ })) };
4739
+ },
3939
4740
  "thread/archive": async (params) => {
3940
4741
  const registry = workspaces();
3941
4742
  if (!registry) throw new RpcError(-32603, "DSH archive is unavailable in this profile");
@@ -3964,7 +4765,8 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
3964
4765
  "thread/start": async (params, { notify }) => {
3965
4766
  if (params?.ephemeral === true) return titleThread(params);
3966
4767
  const cwd = localFolder(params?.cwd);
3967
- const created = await service().create(cwd ? { cwd } : {});
4768
+ const project = typeof params?.projectId === "string" ? workspaceList().find((entry) => String(entry.id) === params.projectId) : void 0;
4769
+ const created = await service().create(project ? { workspaceId: project.id } : cwd ? { cwd } : {});
3968
4770
  const agent = await agentFor(created.sessionId);
3969
4771
  await applySelection(agent, created.sessionId, params);
3970
4772
  const { thread: value } = await openThread(created.sessionId, notify);
@@ -3980,16 +4782,12 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
3980
4782
  return {};
3981
4783
  },
3982
4784
  "turn/steer": async (params) => {
3983
- const text = inputText(params?.input);
3984
- if (text === "") throw invalidParams("Only text input is supported");
4785
+ const { content } = contentOf(params?.input);
3985
4786
  await service().prompt({
3986
4787
  requestId: randomUUID(),
3987
4788
  sessionId: params?.threadId,
3988
4789
  mode: "steer",
3989
- content: [{
3990
- type: "text",
3991
- text
3992
- }]
4790
+ content
3993
4791
  }, AbortSignal.timeout(3e4));
3994
4792
  return { turnId: params?.expectedTurnId ?? active.get(params?.threadId)?.turn.id ?? "" };
3995
4793
  },
@@ -4087,6 +4885,26 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
4087
4885
  */
4088
4886
  const askPhone = async (request, method, params) => {
4089
4887
  const sessionId = request.agent.session.id;
4888
+ emit(sessionId, "thread/status/changed", {
4889
+ threadId: sessionId,
4890
+ status: {
4891
+ type: "active",
4892
+ activeFlags: [method === "item/tool/requestUserInput" ? "waitingOnUserInput" : "waitingOnApproval"]
4893
+ }
4894
+ });
4895
+ try {
4896
+ return await asked(request, method, params, sessionId);
4897
+ } finally {
4898
+ if (active.has(sessionId)) emit(sessionId, "thread/status/changed", {
4899
+ threadId: sessionId,
4900
+ status: {
4901
+ type: "active",
4902
+ activeFlags: []
4903
+ }
4904
+ });
4905
+ }
4906
+ };
4907
+ const asked = async (request, method, params, sessionId) => {
4090
4908
  for (;;) {
4091
4909
  const phone = phoneFor(request)?.phone;
4092
4910
  if (!phone) throw new RpcError(-32800, "No phone");
@@ -4117,6 +4935,7 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
4117
4935
  threadId: session.id,
4118
4936
  turnId: running.turn.id,
4119
4937
  itemId: request.wait?.callId ?? randomUUID(),
4938
+ isBlocking: true,
4120
4939
  questions: request.questions.map((question) => ({
4121
4940
  id: question.id,
4122
4941
  header: question.header ?? "",
@@ -4149,21 +4968,30 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
4149
4968
  });
4150
4969
  if (!found) return next();
4151
4970
  const { session, running } = found;
4152
- return askPhone(request, "item/commandExecution/requestApproval", {
4971
+ const call = request.callId ? callOf(session, request.callId) : void 0;
4972
+ const reason = request.displayReason?.en ?? request.reason ?? null;
4973
+ const common = {
4153
4974
  threadId: session.id,
4154
4975
  turnId: running.turn.id,
4155
4976
  itemId: request.callId ?? randomUUID(),
4156
- startedAtMs: Date.now(),
4977
+ startedAtMs: Date.now()
4978
+ };
4979
+ return (call && (call.name === "edit" || call.name === "write") ? askPhone(request, "item/fileChange/requestApproval", {
4980
+ ...common,
4981
+ reason,
4982
+ grantRoot: null
4983
+ }) : askPhone(request, "item/commandExecution/requestApproval", {
4984
+ ...common,
4157
4985
  command: commandText(session, request),
4158
4986
  cwd: session.meta?.cwd ?? null,
4159
- reason: request.displayReason?.en ?? request.reason ?? null,
4987
+ reason,
4160
4988
  commandActions: [],
4161
4989
  availableDecisions: [
4162
4990
  "accept",
4163
4991
  "decline",
4164
4992
  "cancel"
4165
4993
  ]
4166
- }).then((answer) => {
4994
+ })).then((answer) => {
4167
4995
  const decision = typeof answer?.decision === "string" ? answer.decision : "";
4168
4996
  return decision === "accept" || decision === "acceptForSession" ? "allowed-once" : decision === "cancel" ? "cancelled" : "rejected";
4169
4997
  }, () => next());
@@ -4225,6 +5053,10 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
4225
5053
  };
4226
5054
  /** Forward committed assistant and tool events of a phone-started turn as items. */
4227
5055
  const onSessionEvent = (session, event) => {
5056
+ if (event.type === "session/title" && typeof event.data?.title === "string" && subscribers.has(session.id)) emit(session.id, "thread/name/updated", {
5057
+ threadId: session.id,
5058
+ threadName: event.data.title
5059
+ });
4228
5060
  const running = active.get(session.id);
4229
5061
  if (!running || !subscribers.has(session.id)) return;
4230
5062
  if (event.type === "turn/end") {
@@ -4234,20 +5066,19 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
4234
5066
  if (event.type !== "assistant/message" && event.type !== "tool/result") return;
4235
5067
  const message = session.deriveMessages().at(-1);
4236
5068
  if (message?.role === "tool") {
4237
- const item = running.turn.items.find((entry) => entry.type === "dynamicToolCall" && entry.id === message.toolCallId);
5069
+ const item = running.turn.items.find((entry) => entry.id === message.toolCallId && entry.type !== "agentMessage");
4238
5070
  if (item) {
4239
- item.contentItems = [{
4240
- type: "inputText",
4241
- text: textOf(message.content)
4242
- }];
4243
- item.status = message.isError ? "failed" : "completed";
4244
- item.success = !message.isError;
4245
- emit(session.id, "item/completed", {
4246
- threadId: session.id,
4247
- turnId: running.turn.id,
4248
- item,
4249
- completedAtMs: Date.now()
4250
- });
5071
+ const finish = (imageUrls) => {
5072
+ finishTool(item, message, imageUrls);
5073
+ emit(session.id, "item/completed", {
5074
+ threadId: session.id,
5075
+ turnId: running.turn.id,
5076
+ item,
5077
+ completedAtMs: Date.now()
5078
+ });
5079
+ };
5080
+ if (imageBlocks(message).length > 0) imageUrlsOf([message]).then(finish, () => finish(/* @__PURE__ */ new Map()));
5081
+ else finish(/* @__PURE__ */ new Map());
4251
5082
  }
4252
5083
  return;
4253
5084
  }
@@ -4259,10 +5090,29 @@ function createDshRemoteControl({ controller, agents, permissions = () => void 0
4259
5090
  type: "text",
4260
5091
  text: "x"
4261
5092
  }]
4262
- }, message]).flatMap((turn) => turn.items.slice(1));
5093
+ }, message], void 0, void 0, session.meta?.cwd).flatMap((turn) => turn.items.slice(1));
4263
5094
  const stream = streams.get(session.id);
4264
5095
  streams.delete(session.id);
4265
5096
  let reuse = stream?.item;
5097
+ for (const block of message.content) {
5098
+ if (block.type !== "tool-call" || block.name !== "todo_write") continue;
5099
+ const todos = parseArguments(block.arguments)?.todos;
5100
+ if (!Array.isArray(todos)) continue;
5101
+ const status = {
5102
+ pending: "pending",
5103
+ in_progress: "inProgress",
5104
+ completed: "completed"
5105
+ };
5106
+ emit(session.id, "turn/plan/updated", {
5107
+ threadId: session.id,
5108
+ turnId: running.turn.id,
5109
+ explanation: null,
5110
+ plan: todos.filter((todo) => typeof todo?.content === "string").map((todo) => ({
5111
+ step: todo.content,
5112
+ status: status[todo.status] ?? "pending"
5113
+ }))
5114
+ });
5115
+ }
4266
5116
  for (const item of items) {
4267
5117
  if (reuse && item.type === "agentMessage") {
4268
5118
  const shown = reuse;
@@ -6570,21 +7420,35 @@ function creditExpiry({ planType, balance, apiExpiresAt }, now = Date.now()) {
6570
7420
  };
6571
7421
  }
6572
7422
  //#endregion
7423
+ //#region src/quota-cadence.js
7424
+ /** Milliseconds until the next quota read, from the highest used percentage of the windows that matter. */
7425
+ function quotaRefreshMs(percent) {
7426
+ if (!Number.isFinite(percent)) return 6e4;
7427
+ if (percent >= 99) return 5e3;
7428
+ if (percent >= 90) return 15e3;
7429
+ if (percent >= 75) return 3e4;
7430
+ return 6e4;
7431
+ }
7432
+ /** Highest used percentage among windows (objects with usedPercent), or undefined when there are none. */
7433
+ function highestUsed(windows) {
7434
+ const values = (Array.isArray(windows) ? windows : []).map((window) => window?.usedPercent).filter(Number.isFinite);
7435
+ return values.length === 0 ? void 0 : Math.max(...values);
7436
+ }
7437
+ //#endregion
6573
7438
  //#region src/usage.js
6574
7439
  const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
6575
- const DEFAULT_TTL_MS = 6e4;
6576
7440
  const DEFAULT_TIMEOUT_MS$1 = 15e3;
6577
7441
  const DEFAULT_FAILURE_TTL_MS = 5e3;
6578
7442
  const DEFAULT_MAX_RETRY_AFTER_MS = 5 * 6e4;
6579
7443
  const record$1 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
6580
- function windowOf(value) {
7444
+ function windowOf(value, nowSeconds) {
6581
7445
  if (value === void 0 || value === null) return void 0;
6582
7446
  if (!record$1(value)) throw new Error("Codex returned a malformed rate-limit window");
6583
7447
  const used = value.used_percent;
6584
7448
  const seconds = value.limit_window_seconds;
6585
7449
  if (!Number.isFinite(used) || used < 0 || used > 100) throw new Error("Codex returned an invalid used percentage");
6586
7450
  if (!Number.isInteger(seconds) || seconds <= 0) throw new Error("Codex returned an invalid window duration");
6587
- const resetsAt = epochSeconds(value.reset_at, "rate-limit reset time");
7451
+ const resetsAt = epochSeconds(value.reset_at, "rate-limit reset time") ?? (Number.isInteger(value.reset_after_seconds) && value.reset_after_seconds > 0 && Number.isFinite(nowSeconds) ? nowSeconds + value.reset_after_seconds : void 0);
6588
7452
  return {
6589
7453
  usedPercent: used,
6590
7454
  remainingPercent: 100 - used,
@@ -6592,10 +7456,10 @@ function windowOf(value) {
6592
7456
  ...resetsAt === void 0 ? {} : { resetsAt }
6593
7457
  };
6594
7458
  }
6595
- function limitOf(id, name, value) {
7459
+ function limitOf(id, name, value, nowSeconds) {
6596
7460
  if (value === void 0 || value === null) return void 0;
6597
7461
  if (!record$1(value)) throw new Error("Codex returned malformed rate-limit details");
6598
- const windows = [windowOf(value.primary_window), windowOf(value.secondary_window)].filter(Boolean);
7462
+ const windows = [windowOf(value.primary_window, nowSeconds), windowOf(value.secondary_window, nowSeconds)].filter(Boolean);
6599
7463
  return windows.length === 0 ? void 0 : {
6600
7464
  id,
6601
7465
  ...name ? { name } : {},
@@ -6695,7 +7559,8 @@ function resetCreditsOf(value) {
6695
7559
  };
6696
7560
  }
6697
7561
  /** Reduce the provider payload to a browser-safe quota projection. */
6698
- function parseCodexUsage(value) {
7562
+ function parseCodexUsage(value, nowMs = Date.now()) {
7563
+ const nowSeconds = Math.floor(nowMs / 1e3);
6699
7564
  if (!record$1(value)) throw new Error("Codex returned a malformed usage response");
6700
7565
  const rateLimits = [];
6701
7566
  const seenLimitIds = /* @__PURE__ */ new Set();
@@ -6704,21 +7569,27 @@ function parseCodexUsage(value) {
6704
7569
  seenLimitIds.add(limit.id);
6705
7570
  rateLimits.push(limit);
6706
7571
  };
6707
- addLimit(limitOf("codex", "Codex", value.rate_limit));
7572
+ addLimit(limitOf("codex", "Codex", value.rate_limit, nowSeconds));
6708
7573
  if (value.additional_rate_limits !== void 0 && value.additional_rate_limits !== null && !Array.isArray(value.additional_rate_limits)) throw new Error("Codex returned malformed additional rate limits");
6709
7574
  for (const entry of value.additional_rate_limits ?? []) {
6710
7575
  if (!record$1(entry) || typeof entry.metered_feature !== "string" || entry.metered_feature.length === 0) throw new Error("Codex returned a malformed additional rate limit");
6711
7576
  if (entry.limit_name !== void 0 && entry.limit_name !== null && typeof entry.limit_name !== "string") throw new Error("Codex returned an invalid additional rate-limit name");
6712
- addLimit(limitOf(entry.metered_feature, entry.limit_name || void 0, entry.rate_limit));
7577
+ addLimit(limitOf(entry.metered_feature, entry.limit_name || void 0, entry.rate_limit, nowSeconds));
6713
7578
  }
6714
- addLimit(limitOf("code_review", "Code review", value.code_review_rate_limit));
7579
+ addLimit(limitOf("code_review", "Code review", value.code_review_rate_limit, nowSeconds));
6715
7580
  const chatPass = chatPassOf(value.chatpass);
6716
7581
  const credits = creditsOf(value.credits, typeof value.plan_type === "string" ? value.plan_type : void 0);
6717
7582
  const individualLimit = individualOf(value.spend_control);
6718
7583
  const spendControlReached = spendControlReachedOf(value.spend_control);
6719
7584
  const resetCredits = resetCreditsOf(value.rate_limit_reset_credits);
7585
+ const planType = typeof value.plan_type === "string" && /^[a-z][a-z_]{0,31}$/u.test(value.plan_type) ? value.plan_type : void 0;
7586
+ const reached = value.rate_limit_reached_type;
7587
+ const reachedRaw = typeof reached === "string" ? reached : record$1(reached) ? reached.type : void 0;
7588
+ const rateLimitReachedType = typeof reachedRaw === "string" && /^[a-z][a-z_]{0,63}$/u.test(reachedRaw) ? reachedRaw : void 0;
6720
7589
  return {
6721
7590
  rateLimits,
7591
+ ...rateLimitReachedType === void 0 ? {} : { rateLimitReachedType },
7592
+ ...planType === void 0 ? {} : { planType },
6722
7593
  ...chatPass === void 0 ? {} : { chatPass },
6723
7594
  ...credits === void 0 ? {} : { credits },
6724
7595
  ...individualLimit === void 0 ? {} : { individualLimit },
@@ -6749,7 +7620,7 @@ function createCodexUsageReader(options) {
6749
7620
  const readCredential = options.readCredential;
6750
7621
  const fetchUsage = options.fetch ?? fetch;
6751
7622
  const now = options.now ?? Date.now;
6752
- const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
7623
+ const ttlFor = (value) => options.ttlMs ?? quotaRefreshMs(highestUsed(value?.rateLimits?.find((limit) => limit.id === "codex")?.windows));
6753
7624
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS$1;
6754
7625
  const failureTtlMs = options.failureTtlMs ?? DEFAULT_FAILURE_TTL_MS;
6755
7626
  const maxRetryAfterMs = options.maxRetryAfterMs ?? DEFAULT_MAX_RETRY_AFTER_MS;
@@ -6784,14 +7655,14 @@ function createCodexUsageReader(options) {
6784
7655
  throw new Error("ChatGPT returned an unreadable usage response");
6785
7656
  }
6786
7657
  return {
6787
- ...parseCodexUsage(value),
7658
+ ...parseCodexUsage(value, now()),
6788
7659
  fetchedAt: now()
6789
7660
  };
6790
7661
  };
6791
7662
  return Object.freeze({
6792
7663
  read({ force = false, signal } = {}) {
6793
7664
  if (failed !== void 0 && now() < failed.retryAt) return Promise.reject(new Error(failed.message));
6794
- if (!force && cached !== void 0 && now() - cached.fetchedAt < ttlMs) return Promise.resolve(structuredClone(cached));
7665
+ if (!force && cached !== void 0 && now() - cached.fetchedAt < ttlFor(cached)) return Promise.resolve(structuredClone(cached));
6795
7666
  if (inFlight !== void 0) return inFlight.then(structuredClone);
6796
7667
  const currentGeneration = generation;
6797
7668
  const current = load(signal).then((value) => {
@@ -6875,6 +7746,7 @@ function createWakeSignal() {
6875
7746
  };
6876
7747
  }
6877
7748
  function shortResetDelay(usage, nowMs, maxWaitMs, resetMarginMs) {
7749
+ if (typeof usage?.rateLimitReachedType === "string" && /^(?:workspace_|.*credits_depleted)/u.test(usage.rateLimitReachedType)) return void 0;
6878
7750
  let latestDelayMs;
6879
7751
  for (const limit of usage?.rateLimits ?? []) {
6880
7752
  if (limit?.id !== "codex") continue;
@@ -7709,14 +8581,16 @@ function createSubscriptionRpcHandler({ authHandler, usageReader, resetCreditSer
7709
8581
  "remote/status",
7710
8582
  "remote/enable",
7711
8583
  "remote/disable",
7712
- "remote/pair"
8584
+ "remote/pair",
8585
+ "remote/clients",
8586
+ "remote/revoke"
7713
8587
  ].includes(endpoint)) try {
7714
8588
  signal.throwIfAborted();
7715
8589
  if (!remoteControl) return publicError("unavailable", "Remote Control is unavailable");
7716
8590
  const action = endpoint.split("/")[1];
7717
8591
  return {
7718
8592
  ok: true,
7719
- value: action === "status" ? remoteControl.status() : action === "enable" ? await remoteControl.enable() : action === "disable" ? await remoteControl.disable() : await remoteControl.pair(signal)
8593
+ value: action === "status" ? remoteControl.status() : action === "enable" ? await remoteControl.enable() : action === "disable" ? await remoteControl.disable() : action === "clients" ? await remoteControl.clients(signal) : action === "revoke" ? await remoteControl.revoke(payload?.clientId, signal) : await remoteControl.pair(signal)
7720
8594
  };
7721
8595
  } catch (error) {
7722
8596
  if (signal.aborted) throw error;
@@ -8249,6 +9123,11 @@ function apply(ctx, config = {}) {
8249
9123
  agents: () => ctx.get?.("agents"),
8250
9124
  permissions: () => ctx.get?.("permissionPresets"),
8251
9125
  workspaces: () => ctx.get?.("workspaceRegistry"),
9126
+ attachments: () => ctx.attachments,
9127
+ usage: () => usageReader,
9128
+ projections: () => ctx.get?.("sessionProjections"),
9129
+ skills: () => ctx.get?.("skills"),
9130
+ llm: () => ctx.llm,
8252
9131
  trace: (entry) => {
8253
9132
  try {
8254
9133
  remoteControl.host.note(entry);
@@ -8266,6 +9145,7 @@ function apply(ctx, config = {}) {
8266
9145
  stateFile: dshHomePath("state", "codex-subscription", "remote-control.json"),
8267
9146
  hostName: `DSH (${hostname()})`,
8268
9147
  userAgent: USER_AGENT,
9148
+ base: process.env.DSH_CODEX_REMOTE_BASE || void 0,
8269
9149
  methods: remoteBridge.methods,
8270
9150
  onClose: (_client, notify) => remoteBridge.forget(notify)
8271
9151
  });