copilot-relay 0.4.6 → 0.4.8

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/README.md CHANGED
@@ -55,7 +55,7 @@ running daemon. Add `--details` for safe failure evidence and private replay hin
55
55
  Use `copilot-relay status --deep` for daemon health, and `copilot-relay stop` when finished.
56
56
 
57
57
  Check how well prompt caching works per model and upstream route. The report reads
58
- only local logs and flags rows below a hit-rate goal, 95% by default:
58
+ only local logs and shows a hit rate below the goal, 95% by default, in red:
59
59
 
60
60
  ```sh
61
61
  copilot-relay cache # last 24 hours
@@ -72,7 +72,7 @@ scripts. It asks GitHub with the stored token, so no relay needs to run.
72
72
 
73
73
  - [Configure models and deep checks](wiki/EN-Configuration.md) · [中文](wiki/ZH-Configuration.md)
74
74
  - Run at login: [macOS](wiki/EN-macOS-LaunchAgent.md) · [Windows](wiki/EN-Windows-Service.md) · [Linux](wiki/EN-Linux-systemd.md)
75
- - [Troubleshoot](wiki/EN-Logging-Troubleshooting.md) · [Understand the architecture](wiki/EN-Architecture.md) · [Contribute](wiki/EN-Development.md)
75
+ - [Commands](wiki/EN-Commands.md) · [Prompt caching](wiki/EN-Prompt-Caching.md) · [Troubleshoot](wiki/EN-Logging-Troubleshooting.md) · [Understand the architecture](wiki/EN-Architecture.md) · [Contribute](wiki/EN-Development.md)
76
76
 
77
77
  Unofficial research project; not affiliated with GitHub or Anthropic. Upstream
78
78
  services and compatibility can change. Model and tool support are not guaranteed;
package/dist/main.js CHANGED
@@ -49,6 +49,24 @@ var PromptTooLongError = class extends HTTPError {
49
49
  }
50
50
  };
51
51
 
52
+ //#endregion
53
+ //#region src/lib/file-identity.ts
54
+ /** True when two stats of a path name the same file: the same device and file index. */
55
+ const sameFile$1 = (left, right) => left.dev === right.dev && left.ino === right.ino;
56
+ /**
57
+ * True when a stat of an open handle and a stat of its path name the same file.
58
+ *
59
+ * On Windows the two can disagree on `dev` for the same file: on Node 22.13.1 a path stat
60
+ * reports `dev` 0 while a handle stat reports the volume serial number. There a `dev` of 0 on
61
+ * either side counts as unreported and the file index (`ino`) alone decides, which is weaker
62
+ * evidence than both. Two nonzero `dev` values must still match, and other platforms compare both.
63
+ */
64
+ const sameOpenedFile = (left, right, platform = process.platform) => {
65
+ if (left.ino !== right.ino) return false;
66
+ if (platform === "win32" && (left.dev === 0 || right.dev === 0)) return true;
67
+ return left.dev === right.dev;
68
+ };
69
+
52
70
  //#endregion
53
71
  //#region src/lib/paths.ts
54
72
  const homeDir = os.homedir();
@@ -220,7 +238,7 @@ const formatUpstreamProxyForDisplay = (upstreamProxy) => {
220
238
  * `status` prints config values directly to stdout. A config string carrying
221
239
  * raw control bytes could clear the line and paint status rows the relay never
222
240
  * produced. The config file is user-owned, so this is hardening rather than a
223
- * vulnerability — but it costs nothing to make the output non-forgeable. #47.
241
+ * vulnerability — but it costs nothing to make the output non-forgeable.
224
242
  */
225
243
  const sanitizeTerminalString = (text$1) => text$1.replace(ansiEscapePattern, "").replace(controlBytePattern, "");
226
244
  /**
@@ -371,7 +389,6 @@ const formatLogValue = (value) => typeof value === "string" ? value : inspect(va
371
389
  });
372
390
  const escapeLogLineSeparators = (value) => value.replaceAll("\r", "\\r").replaceAll("\n", "\\n").replaceAll(String.fromCodePoint(8232), "\\u2028").replaceAll(String.fromCodePoint(8233), "\\u2029");
373
391
  const isMissing$2 = (error) => error.code === "ENOENT";
374
- const sameFile$2 = (left, right) => left.dev === right.dev && left.ino === right.ino;
375
392
  const ensurePrivateDirectory = async (directory) => {
376
393
  await fs.mkdir(directory, { mode: 448 }).catch((error) => {
377
394
  if (error.code !== "EEXIST") throw error;
@@ -382,7 +399,7 @@ const ensurePrivateDirectory = async (directory) => {
382
399
  const handle = await fs.open(directory, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
383
400
  try {
384
401
  const opened = await handle.stat();
385
- if (!opened.isDirectory() || !sameFile$2(observed, opened)) throw new Error("Log directory changed while opening");
402
+ if (!opened.isDirectory() || !sameOpenedFile(observed, opened)) throw new Error("Log directory changed while opening");
386
403
  await handle.chmod(448);
387
404
  } finally {
388
405
  await handle.close();
@@ -401,10 +418,10 @@ let activeLog;
401
418
  let logIdleClose;
402
419
  const maxLogWriteBytes = 256 * 1024;
403
420
  const hasMode = (stats, mode) => process.platform === "win32" || (stats.mode & 511) === mode;
404
- const isSameDirectory = (current, checked) => current !== void 0 && current.isDirectory() && sameFile$2(current, checked) && hasMode(current, 448);
421
+ const isSameDirectory = (current, checked) => current !== void 0 && current.isDirectory() && sameFile$1(current, checked) && hasMode(current, 448);
405
422
  const isStillActive = async (active) => {
406
423
  const [current, ...directories] = await Promise.all([active.filePath, ...logDirectories].map((target) => fs.lstat(target).catch(() => void 0)));
407
- return current !== void 0 && current.isFile() && current.nlink === 1 && sameFile$2(current, active.identity) && hasMode(current, 384) && active.directories.every((checked, index) => isSameDirectory(directories[index], checked));
424
+ return current !== void 0 && current.isFile() && current.nlink === 1 && sameOpenedFile(current, active.identity) && hasMode(current, 384) && active.directories.every((checked, index) => isSameDirectory(directories[index], checked));
408
425
  };
409
426
  const openPrivateLog = async (filePath, directories) => {
410
427
  const observed = await fs.lstat(filePath).catch((error) => {
@@ -416,7 +433,7 @@ const openPrivateLog = async (filePath, directories) => {
416
433
  try {
417
434
  const opened = await handle.stat();
418
435
  const current = await fs.lstat(filePath);
419
- if (!opened.isFile() || opened.nlink !== 1 || current.isSymbolicLink() || !sameFile$2(opened, current) || observed && !sameFile$2(observed, opened)) throw new Error("Log file changed while opening");
436
+ if (!opened.isFile() || opened.nlink !== 1 || current.isSymbolicLink() || !sameOpenedFile(opened, current) || observed && !sameOpenedFile(observed, opened)) throw new Error("Log file changed while opening");
420
437
  if (process.platform !== "win32") await handle.chmod(384);
421
438
  return {
422
439
  filePath,
@@ -513,7 +530,7 @@ const cleanupLogsIfDue = async () => {
513
530
  * Each argument is rendered exactly once and scrubbed once, and the identical
514
531
  * array of strings goes to both the file and the console. Scrubbing only on the
515
532
  * way to disk would leave the original object on the console - the screen a
516
- * user screenshots into an issue - so the two sinks must not diverge. See #47.
533
+ * user screenshots into an issue - so the two sinks must not diverge.
517
534
  *
518
535
  * Rendering is skipped only when neither sink would emit, so the cost matches
519
536
  * the old behavior at info level. The console gate is read from consola rather
@@ -595,7 +612,7 @@ var InvalidProxyEnvironmentError = class extends Error {
595
612
  /**
596
613
  * Builds the dispatcher for one resolved upstreamProxy value.
597
614
  *
598
- * Empty connects directly, as every release before #153 did. "env" hands HTTPS_PROXY, HTTP_PROXY
615
+ * Empty connects directly. "env" hands HTTPS_PROXY, HTTP_PROXY
599
616
  * and NO_PROXY, in upper or lower case, to EnvHttpProxyAgent. Anything else is a proxy URL that
600
617
  * normalizeUpstreamProxy has already validated.
601
618
  */
@@ -1373,7 +1390,7 @@ const normalizeApiKey = (value) => {
1373
1390
  * through. None of them can be found again in arbitrary log text: practical URL
1374
1391
  * detection anchors on a literal "://", and loosening that to chase a bare
1375
1392
  * "https:" would start matching ordinary prose. Requiring the authority prefix
1376
- * is the one boundary where this has a definite answer. See #47.
1393
+ * is the one boundary where this has a definite answer.
1377
1394
  */
1378
1395
  const conventionalHttpUrlPattern = /^https?:\/\//i;
1379
1396
  /**
@@ -1390,7 +1407,7 @@ const conventionalHttpUrlPattern = /^https?:\/\//i;
1390
1407
  *
1391
1408
  * Rejecting the raw byte costs nothing: every one of these has a
1392
1409
  * percent-encoded spelling that is unambiguous in log text and is accepted
1393
- * here unchanged. See #47.
1410
+ * here unchanged.
1394
1411
  */
1395
1412
  const unsafeRawDelimiterPattern = /['"`<>\s\u0000-\u001F\u007F]/;
1396
1413
  /**
@@ -1404,7 +1421,7 @@ const copilotBaseUrlDelimiterMessage = "Invalid copilotBaseUrl: quotes, backtick
1404
1421
  /**
1405
1422
  * Validates copilotBaseUrl as a conventional HTTP(S) URL without credentials.
1406
1423
  *
1407
- * Four rules, all learned from #47:
1424
+ * Four rules:
1408
1425
  *
1409
1426
  * The value is concatenated as `${base}${path}` for every upstream call, so it
1410
1427
  * must be something Undici will accept. A relative or non-HTTP value fails at
@@ -2146,36 +2163,6 @@ const dataColumns = [
2146
2163
  alignRight: true,
2147
2164
  cell: (row) => formatCount(row.requests)
2148
2165
  },
2149
- {
2150
- header: "UNKNOWN",
2151
- alignRight: true,
2152
- cell: (row) => formatCount(row.unknownCacheRequests)
2153
- },
2154
- {
2155
- header: "0-READ",
2156
- alignRight: true,
2157
- cell: (row) => formatCount(row.zeroCacheReadRequests)
2158
- },
2159
- {
2160
- header: "INPUT",
2161
- alignRight: true,
2162
- cell: (row) => formatCount(row.totalInputTokens)
2163
- },
2164
- {
2165
- header: "CACHE READ",
2166
- alignRight: true,
2167
- cell: (row) => formatCount(row.cacheReadTokens)
2168
- },
2169
- {
2170
- header: "UNCACHED",
2171
- alignRight: true,
2172
- cell: (row) => formatCount(row.uncachedInputTokens)
2173
- },
2174
- {
2175
- header: "CACHE WRITE",
2176
- alignRight: true,
2177
- cell: (row) => row.cacheWriteTokens === null ? "-" : formatCount(row.cacheWriteTokens)
2178
- },
2179
2166
  {
2180
2167
  header: "HIT RATE",
2181
2168
  alignRight: true,
@@ -2190,12 +2177,10 @@ const renderTable = (rows, view, color) => {
2190
2177
  const pad = (text$1, index) => columns[index].alignRight ? text$1.padStart(widths[index]) : text$1.padEnd(widths[index]);
2191
2178
  const header = columns.map((column, index) => pad(column.header, index)).join(" ");
2192
2179
  const body = rows.map((row, rowIndex) => {
2193
- const rendered = columns.map((column, index) => {
2180
+ return ` ${columns.map((column, index) => {
2194
2181
  const text$1 = pad(cells[rowIndex][index], index);
2195
2182
  return column.tone === void 0 ? text$1 : colorText(text$1, column.tone(row), color);
2196
- });
2197
- const marker = row.belowGoal ? ` ${colorText("below goal", "bad", color)}` : "";
2198
- return ` ${rendered.join(" ")}${marker}`;
2183
+ }).join(" ")}`;
2199
2184
  });
2200
2185
  return [` ${header}`, ...body];
2201
2186
  };
@@ -2208,7 +2193,7 @@ const formatLocalTime = (date) => `${formatLogDate(date)} ${twoDigits(date.getHo
2208
2193
  const describeScope = (options) => {
2209
2194
  return `${options.since === void 0 ? "across all retained logs" : `since ${formatLocalTime(options.since)} local time`}${options.model === void 0 ? "" : `, for models matching "${terminalText(options.model)}"`}`;
2210
2195
  };
2211
- const unknownNote = "UNKNOWN calls logged no cache_read_input_tokens; they are left out of the token columns and HIT RATE.";
2196
+ const unknownNote = (calls) => `HIT RATE leaves out ${formatCount(calls)} ${calls === 1 ? "call" : "calls"} that logged no cache_read_input_tokens.`;
2212
2197
  /** The report as text lines. Kept free of IO, so it is tested without a terminal. */
2213
2198
  const renderCacheReport = (rows, options, color = false) => {
2214
2199
  if (rows.length === 0) return [
@@ -2221,7 +2206,8 @@ const renderCacheReport = (rows, options, color = false) => {
2221
2206
  "",
2222
2207
  ...renderTable(rows, options.view, color)
2223
2208
  ];
2224
- if (rows.some((row) => row.unknownCacheRequests > 0)) lines.push("", unknownNote);
2209
+ const unknownCalls = rows.reduce((total, row) => total + row.unknownCacheRequests, 0);
2210
+ if (unknownCalls > 0) lines.push("", unknownNote(unknownCalls));
2225
2211
  return lines;
2226
2212
  };
2227
2213
 
@@ -2255,7 +2241,7 @@ const cache = defineCommand({
2255
2241
  },
2256
2242
  goal: {
2257
2243
  default: String(defaultGoal),
2258
- description: "Hit-rate goal in percent, with at most two decimals. Rows below it are flagged.",
2244
+ description: "Hit-rate goal in percent, with at most two decimals. A hit rate below it is shown in red.",
2259
2245
  type: "string"
2260
2246
  }
2261
2247
  },
@@ -2327,7 +2313,6 @@ const markDiscardedResponse = (response) => {
2327
2313
  };
2328
2314
  const credentialPattern = /(?:gh[pousr]_|github_pat_|sk-|eyJ)[A-Za-z0-9_-]+/;
2329
2315
  const uuidPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
2330
- const sameFile$1 = (left, right) => left.dev === right.dev && left.ino === right.ino;
2331
2316
  const noFollow = process.platform === "win32" ? 0 : fs.constants.O_NOFOLLOW;
2332
2317
  const safeValue = (value) => typeof value === "string" && /^[A-Za-z0-9_.:/[\]-]{1,160}$/.test(value) && !/(?:gh[pousr]_|github_pat_|sk-|eyJ)[A-Za-z0-9_-]+/.test(value) ? value : void 0;
2333
2318
  const errorName = (error) => error instanceof Error && [
@@ -2475,7 +2460,7 @@ const privateDirectory = async (directory) => {
2475
2460
  const handle = await fs.open(directory, fs.constants.O_RDONLY | noFollow);
2476
2461
  try {
2477
2462
  const opened = await handle.stat();
2478
- if (!opened.isDirectory() || !sameFile$1(observed, opened)) throw new Error("Capture directory changed.");
2463
+ if (!opened.isDirectory() || !sameOpenedFile(observed, opened)) throw new Error("Capture directory changed.");
2479
2464
  await handle.chmod(448);
2480
2465
  return opened;
2481
2466
  } finally {
@@ -2487,7 +2472,7 @@ const createPrivateFile = async (file) => {
2487
2472
  try {
2488
2473
  const opened = await handle.stat();
2489
2474
  const current = await fs.lstat(file);
2490
- if (!opened.isFile() || opened.nlink !== 1 || current.isSymbolicLink() || !sameFile$1(opened, current)) throw new Error("Unsafe capture file.");
2475
+ if (!opened.isFile() || opened.nlink !== 1 || current.isSymbolicLink() || !sameOpenedFile(opened, current)) throw new Error("Unsafe capture file.");
2491
2476
  return handle;
2492
2477
  } catch (error) {
2493
2478
  await handle.close();
@@ -2945,7 +2930,7 @@ var RequestTrace = class RequestTrace {
2945
2930
  }
2946
2931
  const opened = await handle.stat();
2947
2932
  const current = await fs.lstat(file);
2948
- if (!current.isFile() || current.isSymbolicLink() || opened.nlink !== 1 || !sameFile$1(opened, current)) throw new Error("Capture file changed.");
2933
+ if (!current.isFile() || current.isSymbolicLink() || opened.nlink !== 1 || !sameOpenedFile(opened, current)) throw new Error("Capture file changed.");
2949
2934
  await handle.writeFile(buffer);
2950
2935
  }).catch(() => {
2951
2936
  this.manifest.captureError = "capture_write_failed";
@@ -2998,7 +2983,7 @@ var RequestTrace = class RequestTrace {
2998
2983
  await this.checkDirectories();
2999
2984
  const manifest = path.join(this.captureDirectory, "meta.json");
3000
2985
  const current = await fs.lstat(manifest);
3001
- if (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1 || !this.metadataStat || !sameFile$1(current, this.metadataStat) || current.size !== this.metadataStat.size || current.mtimeMs !== this.metadataStat.mtimeMs || current.ctimeMs !== this.metadataStat.ctimeMs) throw new Error("Unsafe capture metadata.");
2986
+ if (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1 || !this.metadataStat || !sameOpenedFile(current, this.metadataStat) || current.size !== this.metadataStat.size || current.mtimeMs !== this.metadataStat.mtimeMs || current.ctimeMs !== this.metadataStat.ctimeMs) throw new Error("Unsafe capture metadata.");
3002
2987
  await fs.rename(path.join(this.captureDirectory, "meta.next.json"), manifest);
3003
2988
  }
3004
2989
  }).catch(() => {
@@ -3036,7 +3021,7 @@ const readRetentionManifest = async (file) => {
3036
3021
  const handle = await fs.open(file, fs.constants.O_RDONLY | noFollow);
3037
3022
  try {
3038
3023
  const opened = await handle.stat();
3039
- if (!sameFile$1(observed, opened) || opened.nlink !== 1 || opened.size !== observed.size) throw new Error("Capture metadata changed.");
3024
+ if (!sameOpenedFile(observed, opened) || opened.nlink !== 1 || opened.size !== observed.size) throw new Error("Capture metadata changed.");
3040
3025
  const bytes = Buffer.alloc(opened.size);
3041
3026
  let offset = 0;
3042
3027
  while (offset < bytes.length) {
@@ -3045,7 +3030,7 @@ const readRetentionManifest = async (file) => {
3045
3030
  offset += bytesRead;
3046
3031
  }
3047
3032
  const current = await fs.lstat(file);
3048
- if (!sameFile$1(opened, current) || current.size !== opened.size || current.mtimeMs !== opened.mtimeMs) throw new Error("Capture metadata changed.");
3033
+ if (!sameOpenedFile(opened, current) || current.size !== opened.size || current.mtimeMs !== opened.mtimeMs) throw new Error("Capture metadata changed.");
3049
3034
  return JSON.parse(bytes.toString("utf8"));
3050
3035
  } finally {
3051
3036
  await handle.close();
@@ -3358,10 +3343,10 @@ const pinCopilotModelCatalog = (config) => {
3358
3343
  pinnedCatalogs.set(config, config.modelCatalog);
3359
3344
  };
3360
3345
  const pendingCatalogs = /* @__PURE__ */ new WeakMap();
3361
- const isRecord$4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3346
+ const isRecord$5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3362
3347
  const isPositiveInteger = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
3363
3348
  function parseModelTokenLimits(value) {
3364
- if (!isRecord$4(value)) return;
3349
+ if (!isRecord$5(value)) return;
3365
3350
  const context = value.max_context_window_tokens;
3366
3351
  const prompt = value.max_prompt_tokens;
3367
3352
  const output = value.max_output_tokens;
@@ -3417,13 +3402,13 @@ async function loadCopilotModelCatalog(config) {
3417
3402
  });
3418
3403
  if (!response.ok) throw new HTTPError("Failed to validate upstream models", response);
3419
3404
  const payload = await readCopilotJson(response, signal, config.upstreamTimeoutMs);
3420
- if (!isRecord$4(payload) || !Array.isArray(payload.data)) throw new Error("Copilot /models returned an invalid model catalog.");
3405
+ if (!isRecord$5(payload) || !Array.isArray(payload.data)) throw new Error("Copilot /models returned an invalid model catalog.");
3421
3406
  const models$1 = /* @__PURE__ */ new Map();
3422
3407
  for (const model of payload.data) {
3423
- if (!isRecord$4(model) || typeof model.id !== "string" || !model.id) continue;
3424
- const capabilities = isRecord$4(model.capabilities) ? model.capabilities : void 0;
3408
+ if (!isRecord$5(model) || typeof model.id !== "string" || !model.id) continue;
3409
+ const capabilities = isRecord$5(model.capabilities) ? model.capabilities : void 0;
3425
3410
  const limits = parseModelTokenLimits(capabilities?.limits);
3426
- const supports = isRecord$4(capabilities?.supports) ? capabilities.supports : void 0;
3411
+ const supports = isRecord$5(capabilities?.supports) ? capabilities.supports : void 0;
3427
3412
  const supportedEndpoints = parseStringArray(model.supported_endpoints);
3428
3413
  const reasoningEfforts = parseReasoningEfforts(supports);
3429
3414
  models$1.set(model.id, {
@@ -3671,11 +3656,15 @@ const probeHint = (code) => {
3671
3656
  //#endregion
3672
3657
  //#region src/claude/billing-line.ts
3673
3658
  const billingLinePrefix = "x-anthropic-billing-header:";
3674
- const afterBillingLine = (text$1) => {
3659
+ const leadingBillingLine = (text$1) => {
3675
3660
  if (!text$1.startsWith(billingLinePrefix)) return;
3676
3661
  const lineEnd = text$1.indexOf("\n");
3677
- if (lineEnd === -1) return "";
3678
- return text$1.slice(lineEnd + 1).replace(/^(?:\r?\n)+/, "");
3662
+ return lineEnd === -1 ? text$1 : text$1.slice(0, lineEnd);
3663
+ };
3664
+ const afterBillingLine = (text$1) => {
3665
+ const line = leadingBillingLine(text$1);
3666
+ if (line === void 0) return;
3667
+ return text$1.slice(line.length).replace(/^(?:\r?\n)+/, "");
3679
3668
  };
3680
3669
  const isTextBlock = (block) => typeof block === "object" && block !== null && block.type === "text" && typeof block.text === "string";
3681
3670
  const withSystem = (payload, system) => {
@@ -3711,6 +3700,120 @@ const removeBillingLine = (payload) => {
3711
3700
  if (!changed) return payload;
3712
3701
  return withSystem(payload, kept.length > 0 ? kept : void 0);
3713
3702
  };
3703
+ const systemTexts$1 = (system) => {
3704
+ if (typeof system === "string") return [system];
3705
+ return Array.isArray(system) ? system.filter(isTextBlock).map((block) => block.text) : [];
3706
+ };
3707
+ const readBillingLineField = (payload, name) => {
3708
+ if (typeof payload !== "object" || payload === null) return;
3709
+ const line = systemTexts$1(payload.system).map((text$1) => leadingBillingLine(text$1)).find((text$1) => text$1 !== void 0);
3710
+ if (line === void 0) return;
3711
+ for (const field of line.slice(27).split(";")) {
3712
+ const separator = field.indexOf("=");
3713
+ if (separator !== -1 && field.slice(0, separator).trim() === name) return field.slice(separator + 1).trim();
3714
+ }
3715
+ };
3716
+
3717
+ //#endregion
3718
+ //#region src/claude/initiator.ts
3719
+ const ownRequestSystemPrompts = ["You are a helpful AI assistant tasked with summarizing conversations.", "You are naming a coding session"];
3720
+ const compactionGuard = "CRITICAL: Respond with TEXT ONLY.";
3721
+ const compactionInstruction = "Your task is to create a detailed summary of the conversation so far";
3722
+ const personDeliveries = [
3723
+ "The user sent a new message while you were working:",
3724
+ "A message arrived in the bound thread while you were working:",
3725
+ "Messages arrived in the bound thread while you were working:"
3726
+ ];
3727
+ const claudeCodeTriggers = [
3728
+ "This session is being continued from a previous conversation",
3729
+ "The summarized conversation included Artifact content",
3730
+ "The previous response failed to produce a valid tool call.",
3731
+ "Your tool call was malformed and could not be parsed.",
3732
+ "[Your previous response had no visible output.",
3733
+ "[structured-output-enforce]",
3734
+ "[projects-reply-gate]",
3735
+ "Output token limit hit",
3736
+ "Your response above was cut off mid-stream",
3737
+ "Your response above was stopped by a safety classifier",
3738
+ "Activity in the bound conversation",
3739
+ "Another Claude session sent a message",
3740
+ "Your background observer",
3741
+ "[MESSAGE FROM NON-USER SOURCE",
3742
+ "[SCHEDULED TASK",
3743
+ "<channel source=",
3744
+ "<teammate-message"
3745
+ ];
3746
+ const claudeCodeTriggerPatterns = [/^\S+ hook (?:feedback|blocking error from command):/, /^The \S+ plugin sent a message/];
3747
+ const claudeCodeContext = [
3748
+ "[Request interrupted by user",
3749
+ "[Tool call ",
3750
+ "The user doesn't want to take this action right now.",
3751
+ "Base directory for this skill:"
3752
+ ];
3753
+ const claudeCodeContextPatterns = [/^\S+ hook additional context:/];
3754
+ const statusProbePrompt = "Reply with the single word: ok";
3755
+ const statusProbeMaxTokens = 16;
3756
+ const isRecord$4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3757
+ const innermostReminder = /<system-reminder>(?:(?!<system-reminder>)[\s\S])*?<\/system-reminder>/g;
3758
+ const withoutReminders = (text$1) => {
3759
+ let previous = text$1;
3760
+ let remaining = text$1.replace(innermostReminder, "");
3761
+ while (remaining !== previous) {
3762
+ previous = remaining;
3763
+ remaining = remaining.replace(innermostReminder, "");
3764
+ }
3765
+ return remaining.trim();
3766
+ };
3767
+ const startsWithAny = (text$1, prefixes) => prefixes.some((prefix) => text$1.startsWith(prefix));
3768
+ const matchesAny = (text$1, prefixes, patterns) => startsWithAny(text$1, prefixes) || patterns.some((pattern) => pattern.test(text$1));
3769
+ const contentBlocks = (content) => {
3770
+ if (typeof content === "string") return [{
3771
+ type: "text",
3772
+ text: content
3773
+ }];
3774
+ return Array.isArray(content) ? content.filter(isRecord$4) : [];
3775
+ };
3776
+ const originatingTurn = (messages) => {
3777
+ const blocks = [];
3778
+ let sawUser = false;
3779
+ for (let index = messages.length - 1; index >= 0; index--) {
3780
+ const message = messages[index];
3781
+ if (!isRecord$4(message) || message.role === "system") continue;
3782
+ if (message.role !== "user") break;
3783
+ sawUser = true;
3784
+ blocks.unshift(...contentBlocks(message.content));
3785
+ }
3786
+ return sawUser ? blocks : void 0;
3787
+ };
3788
+ const blockOrigin = (block) => {
3789
+ if (block.type === "tool_result") return "agent";
3790
+ if (block.type === "image" || block.type === "document") return "user";
3791
+ if (block.type !== "text" || typeof block.text !== "string") return;
3792
+ const text$1 = withoutReminders(block.text);
3793
+ if (text$1 === "" || matchesAny(text$1, claudeCodeContext, claudeCodeContextPatterns)) return;
3794
+ if (startsWithAny(text$1, personDeliveries)) return "user";
3795
+ if (text$1.includes(compactionGuard) && text$1.includes(compactionInstruction) || matchesAny(text$1, claudeCodeTriggers, claudeCodeTriggerPatterns)) return "agent";
3796
+ return "user";
3797
+ };
3798
+ const isStatusProbe = (body) => {
3799
+ if (Object.keys(body).sort().join(",") !== "max_tokens,messages,model" || body.max_tokens !== statusProbeMaxTokens) return false;
3800
+ const messages = body.messages;
3801
+ return Array.isArray(messages) && messages.length === 1 && isRecord$4(messages[0]) && messages[0].role === "user" && messages[0].content === statusProbePrompt;
3802
+ };
3803
+ const systemTexts = (system) => contentBlocks(system).flatMap((block) => block.type === "text" && typeof block.text === "string" ? [block.text] : []);
3804
+ const getClaudeRequestInitiator = (body) => {
3805
+ if (!isRecord$4(body) || !Array.isArray(body.messages) || isStatusProbe(body)) return "agent";
3806
+ const payload = body;
3807
+ if (readBillingLineField(payload, "cc_is_subagent") === "true" || readBillingLineField(payload, "cc_workload") === "cron") return "agent";
3808
+ if (systemTexts(removeBillingLine(payload).system).some((text$1) => startsWithAny(text$1.trimStart(), ownRequestSystemPrompts))) return "agent";
3809
+ const turn = originatingTurn(body.messages);
3810
+ if (turn === void 0) return "agent";
3811
+ for (let index = turn.length - 1; index >= 0; index--) {
3812
+ const origin = blockOrigin(turn[index]);
3813
+ if (origin !== void 0) return origin;
3814
+ }
3815
+ return "agent";
3816
+ };
3714
3817
 
3715
3818
  //#endregion
3716
3819
  //#region src/claude/utils.ts
@@ -3741,7 +3844,7 @@ function mapOpenAIStopReasonToClaude(finishReason) {
3741
3844
  }
3742
3845
  /**
3743
3846
  * Upstream sends an empty `arguments` string for tools that take no
3744
- * parameters (#114). Blank text is the empty object; anything else must be a
3847
+ * parameters. Blank text is the empty object; anything else must be a
3745
3848
  * JSON object, and a failure names the tool rather than surfacing a bare
3746
3849
  * SyntaxError. Argument text is never echoed: it can carry user data.
3747
3850
  */
@@ -5690,7 +5793,6 @@ const normalizeFinalAssistantPrefill = (payload) => {
5690
5793
  messages
5691
5794
  };
5692
5795
  };
5693
- const isAgentInitiator = (messages) => messages.some((message) => message.role === "assistant" || message.role === "tool") ? "agent" : "user";
5694
5796
  const holdsImage = (message) => Array.isArray(message.content) && message.content.some((part) => part.type === "image_url");
5695
5797
  const messagesIncludeImage = (messages) => messages.some(holdsImage);
5696
5798
  const isToolImageMessage = (message) => message.role === "tool" && holdsImage(message);
@@ -5767,7 +5869,7 @@ const createChatCompletions = async (config, payload, options = {}) => {
5767
5869
  const compatiblePayload = useResponsesApi ? upstreamPayload : normalizeFinalAssistantPrefill(moveToolImagesToUserMessages(upstreamPayload));
5768
5870
  const provider = getCopilotProviderContext(config);
5769
5871
  const enableVision = messagesIncludeImage(compatiblePayload.messages);
5770
- const initiator = isAgentInitiator(compatiblePayload.messages);
5872
+ const initiator = options.initiator ?? "agent";
5771
5873
  const requestPayload = buildRequestPayload(compatiblePayload);
5772
5874
  const effectiveEffortLabel = requestPayload.reasoning_effort ?? "omitted";
5773
5875
  log.info(sanitizeTerminalString([
@@ -5778,7 +5880,8 @@ const createChatCompletions = async (config, payload, options = {}) => {
5778
5880
  `upstream_model=${compatiblePayload.model}`,
5779
5881
  `requested_think_effort=${requestedThinkEffort}`,
5780
5882
  `requested_thinking=${requestedThinking}`,
5781
- `effective_think_effort=${effectiveEffortLabel}`
5883
+ `effective_think_effort=${effectiveEffortLabel}`,
5884
+ `initiator=${initiator}`
5782
5885
  ].join(" ")));
5783
5886
  if (useResponsesApi) return completeResponse(await createResponses(provider, compatiblePayload, {
5784
5887
  vision: enableVision,
@@ -6012,7 +6115,8 @@ async function createNativeMessages(config, payload, options) {
6012
6115
  const beta = options.headers?.get("anthropic-beta");
6013
6116
  if (beta) headers["anthropic-beta"] = beta;
6014
6117
  const effortLabel = effort === void 0 ? "omitted" : getClaudeTurnEffort(body).effective;
6015
- log.info(sanitizeTerminalString(`request_id=${options.requestId} Model request client=claude requested_model=${payload.model} upstream_model=${payload.model} upstream_api=messages effective_think_effort=${effortLabel}`));
6118
+ const initiator = options.initiator ?? "agent";
6119
+ log.info(sanitizeTerminalString(`request_id=${options.requestId} Model request client=claude requested_model=${payload.model} upstream_model=${payload.model} upstream_api=messages effective_think_effort=${effortLabel} initiator=${initiator}`));
6016
6120
  const response = await fetchCopilot(getCopilotProviderContext(config), "/v1/messages", {
6017
6121
  method: "POST",
6018
6122
  headers,
@@ -6021,7 +6125,7 @@ async function createNativeMessages(config, payload, options) {
6021
6125
  requestId: options.requestId,
6022
6126
  signal: options.signal,
6023
6127
  timeoutMs: config.upstreamTimeoutMs,
6024
- initiator: payload.messages.some((message) => message.role === "assistant") ? "agent" : "user"
6128
+ initiator
6025
6129
  });
6026
6130
  if (!response.ok) {
6027
6131
  const detail = await readCopilotText(response, options.signal, config.upstreamTimeoutMs);
@@ -6294,7 +6398,8 @@ async function handleNativeMessages(config, payload, options, write) {
6294
6398
  ]
6295
6399
  }, {
6296
6400
  ...options,
6297
- signal
6401
+ signal,
6402
+ initiator: "agent"
6298
6403
  });
6299
6404
  const offset = combined.length;
6300
6405
  let finalHeldFrom;
@@ -6497,13 +6602,14 @@ const translationEndpoint = (config, model) => {
6497
6602
  const selection = selectCopilotEndpoint(config, normalizeCopilotModelId(model));
6498
6603
  return selection.endpoint && !selection.responsesFallback ? selection.endpoint : void 0;
6499
6604
  };
6500
- const handleClaudeMessageRequest = async (config, claudePayload, requestSignal, requestId, writeEvent, requestHeaders) => {
6605
+ const handleClaudeMessageRequest = async (config, claudePayload, initiator, requestSignal, requestId, writeEvent, requestHeaders) => {
6501
6606
  const upstreamModel = translateModelName(claudePayload.model);
6502
6607
  if (shouldUseNativeMessages(config, upstreamModel)) return handleNativeMessages(config, {
6503
6608
  ...claudePayload,
6504
6609
  model: upstreamModel
6505
6610
  }, {
6506
6611
  requestId,
6612
+ initiator,
6507
6613
  signal: requestSignal,
6508
6614
  headers: requestHeaders
6509
6615
  }, writeEvent);
@@ -6515,6 +6621,7 @@ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal,
6515
6621
  if (shouldLetModelDecideWebSearch && !canStreamWebSearchDecision) openAIPayload.stream = false;
6516
6622
  const response = await createChatCompletions(config, openAIPayload, {
6517
6623
  client: "claude",
6624
+ initiator,
6518
6625
  requestedModel: claudePayload.model,
6519
6626
  requestedThinkEffort: getClaudeRequestedThinkEffort(claudePayload),
6520
6627
  requestedThinking: getClaudeRequestedThinking(claudePayload),
@@ -6588,6 +6695,7 @@ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal,
6588
6695
  else {
6589
6696
  const finalResponse = await createChatCompletions(config, createFinalWebSearchPayload(openAIPayload, search, toolNameMapper), {
6590
6697
  client: "claude",
6698
+ initiator: "agent",
6591
6699
  requestedModel: claudePayload.model,
6592
6700
  requestedThinkEffort: getClaudeRequestedThinkEffort(claudePayload),
6593
6701
  requestedThinking: getClaudeRequestedThinking(claudePayload),
@@ -6665,7 +6773,9 @@ claudeRoutes.get("/models", (c) => c.json({
6665
6773
  claudeRoutes.post("/messages", async (c) => {
6666
6774
  const config = c.get("config");
6667
6775
  const requestId = c.get("requestId");
6668
- const claudePayload = removeBillingLine(await c.req.json());
6776
+ const receivedPayload = await c.req.json();
6777
+ const initiator = c.env?.relayProbe === true ? "agent" : getClaudeRequestInitiator(receivedPayload);
6778
+ const claudePayload = removeBillingLine(receivedPayload);
6669
6779
  const requestSignal = createCopilotRequestSignal(c.req.raw.signal, config.upstreamTimeoutMs);
6670
6780
  try {
6671
6781
  getRequestReasoningEffort(claudePayload);
@@ -6716,7 +6826,7 @@ claudeRoutes.post("/messages", async (c) => {
6716
6826
  data: JSON.stringify(event)
6717
6827
  }));
6718
6828
  try {
6719
- await handleClaudeMessageRequest(config, claudePayload, requestSignal, requestId, writeEvent, c.req.raw.headers);
6829
+ await handleClaudeMessageRequest(config, claudePayload, initiator, requestSignal, requestId, writeEvent, c.req.raw.headers);
6720
6830
  } catch (error) {
6721
6831
  log.error(`request_id=${requestId} Error during Claude stream request:`, error);
6722
6832
  if (error instanceof UpstreamToolInputError) trace?.recordFailure("invalid-tool-input");
@@ -6730,7 +6840,7 @@ claudeRoutes.post("/messages", async (c) => {
6730
6840
  });
6731
6841
  }
6732
6842
  try {
6733
- return c.json(await handleClaudeMessageRequest(config, claudePayload, requestSignal, requestId, void 0, c.req.raw.headers));
6843
+ return c.json(await handleClaudeMessageRequest(config, claudePayload, initiator, requestSignal, requestId, void 0, c.req.raw.headers));
6734
6844
  } catch (error) {
6735
6845
  if (error instanceof ProxyNotImplementedError) {
6736
6846
  c.set("requestErrorMessage", error.message);
@@ -7140,7 +7250,7 @@ async function probeModels(config, entries, options) {
7140
7250
  content: "Reply with OK only."
7141
7251
  }]
7142
7252
  })
7143
- }));
7253
+ }), { relayProbe: true });
7144
7254
  return {
7145
7255
  response,
7146
7256
  body: await response.json().catch(() => void 0)
@@ -7647,7 +7757,7 @@ const readCaptureFile = async (directory, file, maximum, optional = false) => {
7647
7757
  const handle = await fs.open(filePath, fs.constants.O_RDONLY | (process.platform === "win32" ? 0 : fs.constants.O_NOFOLLOW));
7648
7758
  try {
7649
7759
  const opened = await handle.stat();
7650
- requireValid(opened.isFile() && opened.ino === before.ino && opened.dev === before.dev && opened.size === before.size);
7760
+ requireValid(opened.isFile() && sameOpenedFile(opened, before) && opened.size === before.size);
7651
7761
  const bytes = Buffer.alloc(opened.size);
7652
7762
  let offset = 0;
7653
7763
  while (offset < bytes.length) {
@@ -7656,7 +7766,7 @@ const readCaptureFile = async (directory, file, maximum, optional = false) => {
7656
7766
  offset += read.bytesRead;
7657
7767
  }
7658
7768
  const after = await fs.lstat(filePath);
7659
- requireValid(!after.isSymbolicLink() && after.ino === opened.ino && after.dev === opened.dev && after.size === opened.size && after.mtimeMs === opened.mtimeMs);
7769
+ requireValid(!after.isSymbolicLink() && sameOpenedFile(after, opened) && after.size === opened.size && after.mtimeMs === opened.mtimeMs);
7660
7770
  return bytes;
7661
7771
  } finally {
7662
7772
  await handle.close();
@@ -8820,7 +8930,7 @@ const restart = defineCommand({
8820
8930
  * for different responses - restart the service versus re-authenticate.
8821
8931
  *
8822
8932
  * A failed health probe is a 2, not a 0. Printing FAILED while exiting 0 would
8823
- * make every scripted caller treat an unreachable relay as fine. See #34.
8933
+ * make every scripted caller treat an unreachable relay as fine.
8824
8934
  */
8825
8935
  const exitCodes = {
8826
8936
  notRunning: 1,
@@ -8852,9 +8962,9 @@ const hasVersionMismatch = (status$1) => status$1.running && status$1.daemonVers
8852
8962
  *
8853
8963
  * 0 requires a live process *and* a passing health probe. Printing FAILED while
8854
8964
  * exiting 0 would make every scripted caller treat an unreachable relay as
8855
- * fine. See #34.
8965
+ * fine.
8856
8966
  *
8857
- * A version mismatch stays 0 on purpose (#43). The relay works; it is just not
8967
+ * A version mismatch stays 0 on purpose. The relay works; it is just not
8858
8968
  * the build that was installed. Mapping it to 2 would overstate it and break
8859
8969
  * every scripted caller that treats non-zero as broken. It is surfaced in the
8860
8970
  * text instead.
@@ -8994,7 +9104,7 @@ const describeError = (error) => {
8994
9104
  * The version rides along on a probe `status` already makes, so asking what is
8995
9105
  * running costs nothing extra. Absent for a daemon older than v0.3.1, which is
8996
9106
  * itself informative, so it stays optional rather than defaulting to the
8997
- * caller's version - that substitution is the bug. See #43.
9107
+ * caller's version - that substitution is the bug.
8998
9108
  */
8999
9109
  const checkHealth = async (baseUrl) => {
9000
9110
  try {
@@ -9037,9 +9147,9 @@ const checkDeep = async (baseUrl, model, apiKey) => {
9037
9147
  try {
9038
9148
  const result = await probe(`${baseUrl}/v1/messages`, {
9039
9149
  body: JSON.stringify({
9040
- max_tokens: 16,
9150
+ max_tokens: statusProbeMaxTokens,
9041
9151
  messages: [{
9042
- content: "Reply with the single word: ok",
9152
+ content: statusProbePrompt,
9043
9153
  role: "user"
9044
9154
  }],
9045
9155
  model