dsh-connect-workbuddy 5.13.0 → 5.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1039,6 +1039,47 @@ function imageDefaultFor(info) {
1039
1039
  if (info.supportsImages === false) return false;
1040
1040
  return NATIVE_MODALITY_BY_MODEL_ID[info.id] === "multimodal";
1041
1041
  }
1042
+ /**
1043
+ * Delete the oldest files in `dir`, keeping the newest `keep`.
1044
+ *
1045
+ * Ordering is by mtime, not filename: the image name embeds an ISO timestamp
1046
+ * and the video name embeds one plus a URL tail, so neither sorts reliably as a
1047
+ * string once more than one generator writes into the directory.
1048
+ *
1049
+ * @returns how many files were removed (for tests and for callers that want to
1050
+ * log a real number); `0` when the directory is absent, unreadable, small
1051
+ * enough, or when every removal failed.
1052
+ */
1053
+ async function pruneTempDir(dir, keep = 20, extensions = [".png", ".mp4"]) {
1054
+ let names;
1055
+ try {
1056
+ names = (await readdir(dir)).filter((name) => extensions.some((ext) => name.endsWith(ext)));
1057
+ } catch {
1058
+ return 0;
1059
+ }
1060
+ if (names.length <= keep) return 0;
1061
+ const dated = await Promise.all(names.map(async (name) => {
1062
+ const path = join(dir, name);
1063
+ try {
1064
+ return {
1065
+ path,
1066
+ mtimeMs: (await stat(path)).mtimeMs
1067
+ };
1068
+ } catch {
1069
+ return {
1070
+ path,
1071
+ mtimeMs: 0
1072
+ };
1073
+ }
1074
+ }));
1075
+ dated.sort((a, b) => b.mtimeMs - a.mtimeMs);
1076
+ let removed = 0;
1077
+ for (const entry of dated.slice(Math.max(0, keep))) try {
1078
+ await rm(entry.path, { force: true });
1079
+ removed += 1;
1080
+ } catch {}
1081
+ return removed;
1082
+ }
1042
1083
  /** Everything the app treats as a queue condition (`isQueueError`). */
1043
1084
  const WORKBUDDY_QUEUE_CODES = [
1044
1085
  6020,
@@ -3503,6 +3544,104 @@ function scansOpening(role) {
3503
3544
  return name === "system" || name === "assistant";
3504
3545
  }
3505
3546
  /**
3547
+ * The gateway's current content fingerprints, and same-meaning rewrites for
3548
+ * them (issue #163).
3549
+ *
3550
+ * WHY REWRITE INSTEAD OF ZERO-WIDTH. On 2026-10-29 two independent probes
3551
+ * measured that the CN gateway normalizes invisible characters before matching:
3552
+ * the DSH identity with U+200B at every word boundary still answers 11128.
3553
+ * `breakIdentityClauses` and both perturbation rungs therefore cannot help this
3554
+ * family. A real wording change is what the same probes measured as 200.
3555
+ *
3556
+ * WHY SENTENCE-LEVEL, NOT ONE WHOLE-PARAGRAPH LITERAL. The third fingerprint is
3557
+ * an eight-sentence, roughly 800-character @-path paragraph. Two reporters
3558
+ * measured that each sentence alone answers 200 and the whole paragraph answers
3559
+ * 400 — it is a COMBINATION, not a single listed sentence. Replacing the
3560
+ * paragraph as one literal would stop matching the moment the host changes one
3561
+ * word; replacing every sentence breaks the combination and keeps working when
3562
+ * only some of the text moves. The rows below are the table proposed in issue
3563
+ * #163 (yicneggima-glitch, 18:56; QIYU-PLUS, 18:57), with the identity and Web
3564
+ * GUI rows both probes measured.
3565
+ *
3566
+ * `from` strings are matched ANYWHERE, not just at an opening, because the
3567
+ * measured rule is position-independent. Only a hit is replaced; every other
3568
+ * byte is left alone, so an ordinary conversation is unchanged.
3569
+ *
3570
+ * WHICH HOST PACKAGE TO WATCH WHEN A ROW STOPS MATCHING (issue #164 traced the
3571
+ * three paragraphs to their owners in the DSH install): the identity sentence
3572
+ * comes from `@deepseek-ai/dsh-system-prompt`, the @-path paragraph from
3573
+ * `@deepseek-ai/dsh-file-reference`, and the Web GUI paragraph from
3574
+ * `@deepseek-ai/dsh-harness-dev-context`. #164 also bisected the MINIMUM
3575
+ * triggering span: the @-path paragraph already fails at its first two sentences
3576
+ * (112 chars) and the Web GUI paragraph at its first two (217 chars), each
3577
+ * sentence alone answering 200 — so a row must clear the truncated shape too,
3578
+ * which is exactly what sentence-level rows do. Pinned by
3579
+ * `tests/issue163-sentence-rewrite.spec.ts`.
3580
+ */
3581
+ const CHANNEL_GATE_PHRASES = [
3582
+ ["You are an AI agent powered by DeepSeek Harness", "You are an AI agent running inside DeepSeek Harness"],
3583
+ ["You are a coding agent powered by the ", "You are a coding agent running on the "],
3584
+ ["You are interacting with the user through the DeepSeek Harness Web GUI", "You are working with the user inside the DeepSeek Harness Web GUI"],
3585
+ ["Agent Teams is available in this session", "Agent Teams is enabled for this session"],
3586
+ ["Tokens prefixed with @ are paths the user explicitly referenced.", "Paths that begin with @ are the ones the user explicitly referenced."],
3587
+ ["Relative paths resolve from the workspace root;", "Relative paths are resolved against the workspace root;"],
3588
+ ["absolute paths identify files or directories on the host.", "absolute paths point at files or directories on the host."],
3589
+ ["A trailing slash marks a directory:", "A trailing slash indicates a directory:"],
3590
+ ["list it when its contents matter.", "list it whenever its contents matter."],
3591
+ ["Anything else is a file:", "Everything else is a file:"],
3592
+ ["use the read tool when its contents are needed,", "use the read tool whenever you need its contents,"],
3593
+ ["and do not claim to have inspected it before reading.", "and never claim to have inspected it before reading it."],
3594
+ ["@\"...\" quotes a path containing spaces.", "A path containing spaces is quoted with @\"...\"."],
3595
+ ["without naming another target, they mean this GUI.", "and names no other target, the interface described here is meant."],
3596
+ ["they mean this GUI.", "the interface described here is meant."]
3597
+ ];
3598
+ /**
3599
+ * Rewrite the measured gate phrases in one text part.
3600
+ *
3601
+ * Exported for the tests and for callers that build a body themselves. It is
3602
+ * idempotent: every `to` string is different from its `from` string and no
3603
+ * rewrite recreates another `from` string, so a second pass is a no-op.
3604
+ */
3605
+ function rewriteChannelFingerprintPhrases(text) {
3606
+ let out = text;
3607
+ for (const [from, to] of CHANNEL_GATE_PHRASES) if (out.includes(from)) out = out.replaceAll(from, to);
3608
+ return out;
3609
+ }
3610
+ /**
3611
+ * Apply {@link rewriteChannelFingerprintPhrases} to every scanned text part.
3612
+ *
3613
+ * `system` and `assistant` only (the roles the gate reads). `user` and `tool`
3614
+ * are deliberately untouched: QIYU-PLUS re-measured both on 2026-10-29 and
3615
+ * both answer 200 with the DSH identity at an opening, and rewriting a user's
3616
+ * own words would change the conversation for no gate.
3617
+ */
3618
+ function rewriteChannelFingerprintPhrasesInBody(obj) {
3619
+ const messages = obj["messages"];
3620
+ if (!Array.isArray(messages)) return;
3621
+ for (const value of messages) {
3622
+ if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
3623
+ const message = value;
3624
+ if (!scansOpening(message["role"])) continue;
3625
+ const content = message["content"];
3626
+ if (typeof content === "string") {
3627
+ const next = rewriteChannelFingerprintPhrases(content);
3628
+ if (next !== content) message["content"] = next;
3629
+ continue;
3630
+ }
3631
+ if (!Array.isArray(content)) continue;
3632
+ for (const part of content) {
3633
+ if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
3634
+ const record = part;
3635
+ const type = record["type"];
3636
+ if (typeof type !== "string" || type.trim().toLowerCase() !== "text") continue;
3637
+ const text = record["text"];
3638
+ if (typeof text !== "string") continue;
3639
+ const next = rewriteChannelFingerprintPhrases(text);
3640
+ if (next !== text) record["text"] = next;
3641
+ }
3642
+ }
3643
+ }
3644
+ /**
3506
3645
  * Move every scanned message (`system` and `assistant`) off the content
3507
3646
  * fingerprint above by prepending the placeholder head.
3508
3647
  *
@@ -4153,6 +4292,7 @@ function prepareChatBody(source, options) {
4153
4292
  }
4154
4293
  ensureSystemHead(obj);
4155
4294
  if (options?.keepPromptLast === true && Array.isArray(obj["messages"])) keepPromptLast(obj["messages"], injectedPrefixPattern(options.extraInjectedPrefixes));
4295
+ rewriteChannelFingerprintPhrasesInBody(obj);
4156
4296
  neutralizeChannelFingerprints(obj);
4157
4297
  normalizeToolChoice(obj);
4158
4298
  dropRefusedReasoning(obj);
@@ -5158,6 +5298,7 @@ var WorkBuddyUpstreamClient = class {
5158
5298
  const safeUrl = url.replace(/[^A-Za-z0-9._-]+/g, "_").slice(-32);
5159
5299
  const path = join(dir, `video-${stamp}-${safeUrl}.mp4`);
5160
5300
  await writeFile(path, buffer);
5301
+ pruneTempDir(dir, 20, [".mp4"]);
5161
5302
  return path;
5162
5303
  }
5163
5304
  /** Query today's check-in status without changing account state. */
@@ -5544,8 +5685,38 @@ function mergeCredentialDisplayNames(previous, next) {
5544
5685
  const WORKBUDDY_AUTH_FILENAME = ".workbuddy-auth.json";
5545
5686
  /** Env variable that overrides the desktop auth-file location. */
5546
5687
  const WORKBUDDY_AUTH_FILE_ENV = "WORKBUDDY_AUTH_FILE";
5547
- /** Basename of the live WorkBuddy desktop auth file. */
5548
- const WORKBUDDY_LIVE_FILENAME = "workbuddy-desktop.info";
5688
+ /**
5689
+ * Basenames the live WorkBuddy desktop auth file is known to use.
5690
+ *
5691
+ * ISSUE #174: the constant used to be ONE name, `workbuddy-desktop.info`, and
5692
+ * the 5.7.x Windows app writes `workbuddy-desktop-ai.info` instead — the same
5693
+ * directory, the same payload, a different filename (measured by the reporter:
5694
+ * `%LOCALAPPDATA%\CodeBuddyExtension\Data\Public\auth\` held
5695
+ * `workbuddy-desktop-ai.info` plus timestamped backups of that same name, and no
5696
+ * file carrying the constant's name at all). The consequences were both silent:
5697
+ * the "live file always wins" rule in {@link fileRank} never matched, so
5698
+ * ordering fell back to `lastRefreshAtMs` (correct by luck, not by rule), and
5699
+ * `doctor` reported `desktopAuthFile.present: false` with a hint to set
5700
+ * `WORKBUDDY_AUTH_FILE` — on a machine whose credential had just been read
5701
+ * successfully from the default directory.
5702
+ *
5703
+ * Ordered, not a set: {@link WORKBUDDY_LIVE_FILENAME} stays the one this build
5704
+ * treats as canonical for writing, and the alternatives are probed after it. The
5705
+ * FIRST entry is what {@link defaultDesktopAuthCandidates} reports as the
5706
+ * expected path, so keep it there.
5707
+ */
5708
+ const WORKBUDDY_LIVE_FILENAMES = ["workbuddy-desktop.info", "workbuddy-desktop-ai.info"];
5709
+ WORKBUDDY_LIVE_FILENAMES[0];
5710
+ /**
5711
+ * Whether one basename is a LIVE desktop auth file (any of the known names).
5712
+ *
5713
+ * Every place that asked "is this the live file?" now asks this, so a new
5714
+ * spelling is added in one list instead of being chased through the reader, the
5715
+ * ranker and `doctor` separately — which is exactly how the three drifted apart.
5716
+ */
5717
+ function isLiveDesktopAuthFileName(name) {
5718
+ return WORKBUDDY_LIVE_FILENAMES.includes(name);
5719
+ }
5549
5720
  /** Prefix of the plugin-owned per-region credential copies (legacy, read-only). */
5550
5721
  const WORKBUDDY_OWN_PREFIX = ".workbuddy-auth";
5551
5722
  /**
@@ -5789,9 +5960,17 @@ function defaultDesktopAuthDirs(platform = process.platform, home = homedir(), e
5789
5960
  function nonEmptyEnv(value) {
5790
5961
  return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
5791
5962
  }
5792
- /** The live auth file's platform candidates, in probe order. */
5963
+ /**
5964
+ * The live auth file's platform candidates, in probe order.
5965
+ *
5966
+ * Every known basename for every platform directory: a machine can hold the old
5967
+ * and the new spelling side by side during an app upgrade, and probing only the
5968
+ * canonical one is the defect issue #174 reports.
5969
+ */
5793
5970
  function defaultDesktopAuthCandidates() {
5794
- return defaultDesktopAuthDirs().map((dir) => join(dir, WORKBUDDY_LIVE_FILENAME));
5971
+ const candidates = [];
5972
+ for (const dir of defaultDesktopAuthDirs()) for (const name of WORKBUDDY_LIVE_FILENAMES) candidates.push(join(dir, name));
5973
+ return candidates;
5795
5974
  }
5796
5975
  /** First platform-default candidate; see {@link defaultDesktopAuthCandidates}. */
5797
5976
  function defaultDesktopAuthPath() {
@@ -5939,7 +6118,7 @@ function hasEncryptedCredentialFields(text) {
5939
6118
  * backups, never the primary ordering.
5940
6119
  */
5941
6120
  function fileRank(path) {
5942
- return authFileName(path) === WORKBUDDY_LIVE_FILENAME ? 0 : 1;
6121
+ return isLiveDesktopAuthFileName(authFileName(path)) ? 0 : 1;
5943
6122
  }
5944
6123
  /**
5945
6124
  * Whether `candidate` is a better pick than `incumbent` for the same account.
@@ -6328,7 +6507,7 @@ var WorkBuddyCredentialStore = class {
6328
6507
  const fromEnv = process.env[WORKBUDDY_AUTH_FILE_ENV];
6329
6508
  const explicit = this.desktopPathOverride ?? (fromEnv !== void 0 && fromEnv.trim() !== "" ? fromEnv : void 0);
6330
6509
  if (explicit !== void 0) return [explicit];
6331
- return defaultDesktopAuthCandidates();
6510
+ return (this.authDirs ?? defaultDesktopAuthDirs()).flatMap((dir) => WORKBUDDY_LIVE_FILENAMES.map((name) => join(dir, name)));
6332
6511
  }
6333
6512
  /** The resolved desktop auth-file path, for diagnostics. */
6334
6513
  desktopAuthPath() {
@@ -6355,10 +6534,20 @@ var WorkBuddyCredentialStore = class {
6355
6534
  return files;
6356
6535
  }
6357
6536
  const dirs = this.authDirs ?? defaultDesktopAuthDirs();
6537
+ const seen = /* @__PURE__ */ new Set();
6358
6538
  for (const dir of dirs) {
6359
- const live = join(dir, WORKBUDDY_LIVE_FILENAME);
6360
- files.push(live);
6361
- for (const backup of await this.backupsBeside(live)) files.push(backup);
6539
+ for (const name of WORKBUDDY_LIVE_FILENAMES) {
6540
+ const live = join(dir, name);
6541
+ if (!seen.has(live)) {
6542
+ seen.add(live);
6543
+ files.push(live);
6544
+ }
6545
+ }
6546
+ for (const name of WORKBUDDY_LIVE_FILENAMES) for (const backup of await this.backupsBeside(join(dir, name))) {
6547
+ if (seen.has(backup)) continue;
6548
+ seen.add(backup);
6549
+ files.push(backup);
6550
+ }
6362
6551
  }
6363
6552
  return files;
6364
6553
  }
@@ -6866,8 +7055,33 @@ var WorkBuddyCredentialStore = class {
6866
7055
  */
6867
7056
  async diagnose() {
6868
7057
  const candidates = [];
6869
- for (const path of await this.candidateFiles()) {
6870
- const probe = await probeAuthFile(path, this.resolveAtRestKey);
7058
+ const desktopTried = this.resolveDesktopCandidates();
7059
+ const probed = [];
7060
+ for (const path of await this.candidateFiles()) probed.push({
7061
+ path,
7062
+ probe: await probeAuthFile(path, this.resolveAtRestKey)
7063
+ });
7064
+ /**
7065
+ * Directories that need no `missing` complaint (issue #174).
7066
+ *
7067
+ * A directory is "accounted for" when it either yielded a readable
7068
+ * credential OR produced some other, more specific complaint. The first case
7069
+ * is the healthy machine whose app writes only one of the two known
7070
+ * spellings; the second is a machine holding a broken file — and telling
7071
+ * that user "no auth file at the expected path" would be both false and
7072
+ * less useful than the `invalid` line they already have.
7073
+ */
7074
+ const accountedDirs = /* @__PURE__ */ new Set();
7075
+ for (const entry of probed) {
7076
+ if ("credential" in entry.probe) {
7077
+ accountedDirs.add(dirname(entry.path));
7078
+ continue;
7079
+ }
7080
+ if (entry.probe.failure.reason !== "missing") accountedDirs.add(dirname(entry.path));
7081
+ }
7082
+ /** Directories whose `missing` complaint has already been made. */
7083
+ const missingReported = /* @__PURE__ */ new Set();
7084
+ for (const { path, probe } of probed) {
6871
7085
  if ("credential" in probe) {
6872
7086
  if (!this.matchesRegion(probe.credential.domain)) candidates.push({
6873
7087
  path,
@@ -6877,6 +7091,11 @@ var WorkBuddyCredentialStore = class {
6877
7091
  });
6878
7092
  continue;
6879
7093
  }
7094
+ if (probe.failure.reason === "missing") {
7095
+ const dir = dirname(path);
7096
+ if (accountedDirs.has(dir) || missingReported.has(dir)) continue;
7097
+ missingReported.add(dir);
7098
+ }
6880
7099
  candidates.push({
6881
7100
  path,
6882
7101
  source: "desktop",
@@ -6935,7 +7154,7 @@ var WorkBuddyCredentialStore = class {
6935
7154
  }
6936
7155
  } catch {}
6937
7156
  return {
6938
- tried,
7157
+ tried: [...desktopTried, ...this.ownCandidates()],
6939
7158
  failures: candidates
6940
7159
  };
6941
7160
  }
@@ -7711,7 +7930,7 @@ function describeRosterSeenPayload(raw) {
7711
7930
  //#endregion
7712
7931
  //#region src/version.ts
7713
7932
  /** The npm package version this build was produced from. */
7714
- const WORKBUDDY_CONNECT_VERSION = "5.13.0";
7933
+ const WORKBUDDY_CONNECT_VERSION = "5.14.0";
7715
7934
  //#endregion
7716
7935
  //#region src/host-heartbeat.ts
7717
7936
  /**
@@ -7729,8 +7948,18 @@ const WORKBUDDY_CONNECT_VERSION = "5.13.0";
7729
7948
  */
7730
7949
  /** Basename of the host heartbeat file inside the Harness home. */
7731
7950
  const WORKBUDDY_HOST_HEARTBEAT_FILENAME = ".workbuddy-host-heartbeat.json";
7732
- /** Current on-disk heartbeat format; readers reject others. */
7733
- const HEARTBEAT_FORMAT_VERSION = 1;
7951
+ /**
7952
+ * On-disk heartbeat formats this build WRITES and READS.
7953
+ *
7954
+ * 1 → 2 added `hostStartedAt` (issue #174). Version 1 is still READ, on purpose:
7955
+ * rejecting it would turn the first `doctor` after an upgrade into a spurious
7956
+ * "no heartbeat at all", which is a worse diagnosis than the imprecise
7957
+ * comparison version 1 supports. Anything newer than {@link HEARTBEAT_FORMAT_VERSION}
7958
+ * is rejected, because a future field could change the meaning of the ones read
7959
+ * here.
7960
+ */
7961
+ const HEARTBEAT_FORMAT_VERSION = 2;
7962
+ const READABLE_HEARTBEAT_VERSIONS = [1, 2];
7734
7963
  /** Absolute path of the host heartbeat file. */
7735
7964
  function workbuddyHostHeartbeatPath() {
7736
7965
  return join(resolveDshHome(), WORKBUDDY_HOST_HEARTBEAT_FILENAME);
@@ -7815,10 +8044,40 @@ function processStartTimeMs(pid) {
7815
8044
  }
7816
8045
  }
7817
8046
  /**
8047
+ * How close a recorded start time must be to the live process's start time for
8048
+ * the two to be considered the SAME process.
8049
+ *
8050
+ * Two tolerances, for two different comparisons:
8051
+ *
8052
+ * - {@link HOST_START_REFERENCE_TOLERANCE_MS} when the heartbeat records
8053
+ * `hostStartedAt` — both sides are then the SAME quantity (a process start
8054
+ * time), so the window only has to absorb the coarse resolution of
8055
+ * `ps -o lstart=` (whole seconds) and PowerShell's string round-trip.
8056
+ * - {@link LEGACY_REFERENCE_TOLERANCE_MS} when it does not, and the only
8057
+ * available reference is `registeredAt`. That is a different quantity
8058
+ * (when the plugin mounted, not when the process started), so it is a
8059
+ * lower bound rather than an answer: a host that started more than this
8060
+ * before the plugin mounted would have looked dead. Kept wide on purpose —
8061
+ * a missed reuse is far less harmful than calling a live host stale.
8062
+ */
8063
+ const HOST_START_REFERENCE_TOLERANCE_MS = 12e4;
8064
+ const LEGACY_REFERENCE_TOLERANCE_MS = 432e5;
8065
+ /**
7818
8066
  * Whether the recorded host process still matches the heartbeat's PID.
7819
8067
  *
7820
8068
  * A PID can be reused after a crash, so the recorded start time is compared
7821
8069
  * against the live process: a different start time means a different process.
8070
+ *
8071
+ * ISSUE #174 — the reference used to be `registeredAt`, the moment the plugin
8072
+ * WROTE the heartbeat, and the comparison was `|startedAt − registeredAt| < 60s`.
8073
+ * Those are two different quantities: `registeredAt` is when the host finished
8074
+ * booting and mounted this plugin, which on a real machine is minutes after the
8075
+ * process started. Measured by the reporter on Windows 11: process start
8076
+ * 19:47:20, heartbeat written 19:58:45 — 684,817 ms apart — so `doctor` and
8077
+ * `status` reported `hostBundle: "stale"` for a host that was alive and had
8078
+ * just written the file. The `hostStartedAt` field exists so the comparison has
8079
+ * the right reference; `registeredAt` is only read for heartbeats written by an
8080
+ * older build, where it is the sole (and merely near) signal available.
7822
8081
  */
7823
8082
  function isHeartbeatProcessAlive(heartbeat) {
7824
8083
  if (!Number.isInteger(heartbeat.pid) || heartbeat.pid <= 0) return false;
@@ -7829,7 +8088,9 @@ function isHeartbeatProcessAlive(heartbeat) {
7829
8088
  }
7830
8089
  const startedAt = processStartTimeMs(heartbeat.pid);
7831
8090
  if (startedAt === void 0) return true;
7832
- return Math.abs(startedAt - heartbeat.registeredAt) < 6e4;
8091
+ const recorded = heartbeat.hostStartedAt;
8092
+ if (recorded !== void 0 && Number.isFinite(recorded)) return Math.abs(startedAt - recorded) < HOST_START_REFERENCE_TOLERANCE_MS;
8093
+ return Math.abs(startedAt - heartbeat.registeredAt) < LEGACY_REFERENCE_TOLERANCE_MS;
7833
8094
  }
7834
8095
  /** Read the heartbeat; absent or unparsable files report undefined. */
7835
8096
  async function readHostHeartbeat() {
@@ -7837,30 +8098,41 @@ async function readHostHeartbeat() {
7837
8098
  const parsed = JSON.parse(await readFile(workbuddyHostHeartbeatPath(), "utf8"));
7838
8099
  if (typeof parsed !== "object" || parsed === null) return void 0;
7839
8100
  const document = parsed;
7840
- if (document["version"] !== HEARTBEAT_FORMAT_VERSION) return void 0;
8101
+ if (!READABLE_HEARTBEAT_VERSIONS.includes(document["version"])) return void 0;
7841
8102
  if (document["package"] !== "dsh-connect-workbuddy") return void 0;
7842
8103
  const pid = document["pid"];
7843
8104
  const registeredAt = document["registeredAt"];
7844
8105
  if (typeof pid !== "number" || typeof registeredAt !== "number") return void 0;
8106
+ const hostStartedAt = document["hostStartedAt"];
7845
8107
  return {
7846
- version: HEARTBEAT_FORMAT_VERSION,
8108
+ version: document["version"],
7847
8109
  package: "dsh-connect-workbuddy",
7848
8110
  pluginVersion: typeof document["pluginVersion"] === "string" ? document["pluginVersion"] : WORKBUDDY_CONNECT_VERSION,
7849
8111
  registeredAt,
7850
- pid
8112
+ pid,
8113
+ ...typeof hostStartedAt === "number" && Number.isFinite(hostStartedAt) ? { hostStartedAt } : {}
7851
8114
  };
7852
8115
  } catch {
7853
8116
  return;
7854
8117
  }
7855
8118
  }
7856
- /** Write the heartbeat for the current process. */
8119
+ /**
8120
+ * Write the heartbeat for the current process.
8121
+ *
8122
+ * `hostStartedAt` is captured HERE, from this process, so the later staleness
8123
+ * check compares like with like (issue #174). Sampled on every write rather
8124
+ * than cached: the value is cheap, and a module-level cache would be wrong the
8125
+ * moment this function is exercised from a child process.
8126
+ */
7857
8127
  async function writeHostHeartbeat() {
8128
+ const hostStartedAt = processStartTimeMs(process.pid);
7858
8129
  const heartbeat = {
7859
8130
  version: HEARTBEAT_FORMAT_VERSION,
7860
8131
  package: "dsh-connect-workbuddy",
7861
8132
  pluginVersion: WORKBUDDY_CONNECT_VERSION,
7862
8133
  registeredAt: Date.now(),
7863
- pid: process.pid
8134
+ pid: process.pid,
8135
+ ...hostStartedAt === void 0 ? {} : { hostStartedAt }
7864
8136
  };
7865
8137
  await writeFile(workbuddyHostHeartbeatPath(), `${JSON.stringify(heartbeat, null, 2)}\n`, { mode: 384 });
7866
8138
  }
@@ -7869,4 +8141,4 @@ async function clearHostHeartbeat() {
7869
8141
  await rm(workbuddyHostHeartbeatPath(), { force: true });
7870
8142
  }
7871
8143
  //#endregion
7872
- export { parseCreditMultiplier as $, withFileLock as $t, isEncryptedCredentialError as A, pickMediaModel as At, WorkBuddyCredentialRejectedError as B, deriveAtRestKeyId as Bt, WorkBuddyCredentialStore as C, IMAGE_TAG_TEXT_TO_IMAGE as Ct, defaultDesktopAuthDirs as D, imageMimeForPath as Dt, defaultDesktopAuthCandidates as E, imageDataUrl as Et, workbuddyAccountId as F, imageDefaultFor as Ft, forceNeutralHead as G, isWorkbuddyBundle as Gt, channelGateMemberOf as H, findWorkbuddyAppExecutable as Ht, workbuddyOwnAuthPath as I, nativeModalityOf as It, isCredentialRejectedError as J, openEncryptedField as Jt, isContextTooLongBody as K, macosBundleExecutable as Kt, CONTEXT_OVERFLOW_MARKER as L, WORKBUDDY_APP_EXECUTABLE_ENV as Lt, legacyWorkbuddyOwnAuthPath as M, parseQueueAnswer as Mt, parseWorkBuddyAuth as N, queueNoticeText as Nt, defaultDesktopAuthPath as O, isBareBase64Image as Ot, preferredAccountName as P, NATIVE_MODALITY_BY_MODEL_ID as Pt, openingEvidenceOfBody as Q, resolveDshHome as Qt, CREDENTIAL_REJECTED_CODE as R, clearAtRestKeyCache as Rt, WORKBUDDY_AUTH_FILE_ENV as S, IMAGE_TAG_IMAGE_TO_IMAGE as St, authFileName as T, WorkBuddyImageError as Tt, classifyUpstreamError as U, findWorkbuddyAppExecutableWithSource as Ut, WorkBuddyUpstreamClient as V, fetchAtRestKeyPayload as Vt, declaredTools as W, isEncryptedFieldWrapper as Wt, knownRegionOf as X, workbuddyAppExecutableCandidates as Xt, isFirstMessageGateBody as Y, readAtRestKey as Yt, modelIdOfBody as Z, hostPeersInfo as Zt, deriveCatalog as _, WorkBuddyVideoError as _t, readHostHeartbeat as a, requestEvidenceOf as at, ENCRYPTED_CREDENTIAL_CODE as b, resolveVideoParams as bt, WORKBUDDY_CONNECT_VERSION as c, REFUSES_OFF_MODEL_IDS as ct, rosterSeenPath as d, offDefaultFor as dt, writeFileAtomic as en, parseReasoning as et, piAiRuntimeInfo as f, offRefused as ft, applyContextBudgets as g, VIDEO_TAG_TEXT_TO_VIDEO as gt, WorkBuddyCatalog as h, VIDEO_TAG_IMAGE_TO_VIDEO as ht, processStartTimeMs as i, regionOf as it, isVaultAccountId as j, sniffImageMime as jt, hasEncryptedCredentialFields as k, parseImageDataUrl as kt, createFileRosterSeenStore as l, defaultOffModelIds as lt, FALLBACK_WORKBUDDY_MODELS_GLOBAL as m, withEffectiveOff as mt, clearHostHeartbeat as n, perturbChannelFingerprints as nt, workbuddyHostHeartbeatPath as o, rewriteSessionTitleModel as ot, FALLBACK_WORKBUDDY_MODELS as p, recordRefusal as pt, isConversationIdGateBody as q, macosNestedAppBundles as qt, isHeartbeatProcessAlive as r, prepareChatBody as rt, writeHostHeartbeat as s, attachBusyLevels as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, parseUpstreamModel as tt, describeRosterSeenPayload as u, lastRefusalFor as ut, fallbackModelsFor as v, parseSupportedSecondsRange as vt, WorkBuddyEncryptedCredentialError as w, MAX_INPUT_IMAGE_BYTES as wt, WORKBUDDY_AUTH_FILENAME as x, DEFAULT_IMAGE_SIZE as xt, withoutRouters as y, pickVideoModel as yt, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as z, deriveAtRestKey as zt };
8144
+ export { parseCreditMultiplier as $, resolveDshHome as $t, isEncryptedCredentialError as A, pickMediaModel as At, WorkBuddyCredentialRejectedError as B, deriveAtRestKey as Bt, WorkBuddyCredentialStore as C, IMAGE_TAG_TEXT_TO_IMAGE as Ct, defaultDesktopAuthDirs as D, imageMimeForPath as Dt, defaultDesktopAuthCandidates as E, imageDataUrl as Et, workbuddyAccountId as F, NATIVE_MODALITY_BY_MODEL_ID as Ft, forceNeutralHead as G, isEncryptedFieldWrapper as Gt, channelGateMemberOf as H, fetchAtRestKeyPayload as Ht, workbuddyOwnAuthPath as I, imageDefaultFor as It, isCredentialRejectedError as J, macosNestedAppBundles as Jt, isContextTooLongBody as K, isWorkbuddyBundle as Kt, CONTEXT_OVERFLOW_MARKER as L, nativeModalityOf as Lt, legacyWorkbuddyOwnAuthPath as M, parseQueueAnswer as Mt, parseWorkBuddyAuth as N, queueNoticeText as Nt, defaultDesktopAuthPath as O, isBareBase64Image as Ot, preferredAccountName as P, pruneTempDir as Pt, openingEvidenceOfBody as Q, hostPeersInfo as Qt, CREDENTIAL_REJECTED_CODE as R, WORKBUDDY_APP_EXECUTABLE_ENV as Rt, WORKBUDDY_AUTH_FILE_ENV as S, IMAGE_TAG_IMAGE_TO_IMAGE as St, authFileName as T, WorkBuddyImageError as Tt, classifyUpstreamError as U, findWorkbuddyAppExecutable as Ut, WorkBuddyUpstreamClient as V, deriveAtRestKeyId as Vt, declaredTools as W, findWorkbuddyAppExecutableWithSource as Wt, knownRegionOf as X, readAtRestKey as Xt, isFirstMessageGateBody as Y, openEncryptedField as Yt, modelIdOfBody as Z, workbuddyAppExecutableCandidates as Zt, deriveCatalog as _, WorkBuddyVideoError as _t, readHostHeartbeat as a, requestEvidenceOf as at, ENCRYPTED_CREDENTIAL_CODE as b, resolveVideoParams as bt, WORKBUDDY_CONNECT_VERSION as c, REFUSES_OFF_MODEL_IDS as ct, rosterSeenPath as d, offDefaultFor as dt, withFileLock as en, parseReasoning as et, piAiRuntimeInfo as f, offRefused as ft, applyContextBudgets as g, VIDEO_TAG_TEXT_TO_VIDEO as gt, WorkBuddyCatalog as h, VIDEO_TAG_IMAGE_TO_VIDEO as ht, processStartTimeMs as i, regionOf as it, isVaultAccountId as j, sniffImageMime as jt, hasEncryptedCredentialFields as k, parseImageDataUrl as kt, createFileRosterSeenStore as l, defaultOffModelIds as lt, FALLBACK_WORKBUDDY_MODELS_GLOBAL as m, withEffectiveOff as mt, clearHostHeartbeat as n, perturbChannelFingerprints as nt, workbuddyHostHeartbeatPath as o, rewriteSessionTitleModel as ot, FALLBACK_WORKBUDDY_MODELS as p, recordRefusal as pt, isConversationIdGateBody as q, macosBundleExecutable as qt, isHeartbeatProcessAlive as r, prepareChatBody as rt, writeHostHeartbeat as s, attachBusyLevels as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, writeFileAtomic as tn, parseUpstreamModel as tt, describeRosterSeenPayload as u, lastRefusalFor as ut, fallbackModelsFor as v, parseSupportedSecondsRange as vt, WorkBuddyEncryptedCredentialError as w, MAX_INPUT_IMAGE_BYTES as wt, WORKBUDDY_AUTH_FILENAME as x, DEFAULT_IMAGE_SIZE as xt, withoutRouters as y, pickVideoModel as yt, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as z, clearAtRestKeyCache as zt };
package/lib/index.d.ts CHANGED
@@ -208,7 +208,13 @@ declare function legacyWorkbuddyOwnAuthPath(): string;
208
208
  * testable on any host without touching a real machine.
209
209
  */
210
210
  declare function defaultDesktopAuthDirs(platform?: NodeJS.Platform, home?: string, env?: NodeJS.ProcessEnv): string[];
211
- /** The live auth file's platform candidates, in probe order. */
211
+ /**
212
+ * The live auth file's platform candidates, in probe order.
213
+ *
214
+ * Every known basename for every platform directory: a machine can hold the old
215
+ * and the new spelling side by side during an app upgrade, and probing only the
216
+ * canonical one is the defect issue #174 reports.
217
+ */
212
218
  declare function defaultDesktopAuthCandidates(): string[];
213
219
  /** First platform-default candidate; see {@link defaultDesktopAuthCandidates}. */
214
220
  declare function defaultDesktopAuthPath(): string | undefined;
@@ -2334,12 +2340,21 @@ interface WorkBuddyAdapterOptions {
2334
2340
  */
2335
2341
  piAiGeneration?: PiAiGeneration;
2336
2342
  /**
2337
- * Drop foreign thinking blocks pi-ai would publish as prose (issue #147).
2343
+ * Drop SAME-ROUTE foreign thinking blocks pi-ai would publish as prose
2344
+ * (issue #147).
2345
+ *
2346
+ * CROSS-PROVIDER turns are dropped unconditionally: after a provider switch
2347
+ * the host rebuilds the other provider's assistant turns as `dsh-foreign`,
2348
+ * their unsigned thinking blocks can never be replayed, and pi-ai would
2349
+ * otherwise flatten them into assistant prose that the WorkBuddy gateway
2350
+ * refuses with 11128 (issue #163's measured scene). This switch only widens
2351
+ * the repair to a model switch INSIDE one of this bundle's own routes.
2338
2352
  *
2339
- * OFF by default, and read LIVE (a getter, not a boolean) so the switch takes
2340
- * effect on the next request without reloading the plugin: turning it on
2341
- * mid-session is the whole point — the user sees the working-notes turn,
2342
- * flips the switch, and the next turn is answered normally.
2353
+ * OFF by default for that same-route leg, and read LIVE (a getter, not a
2354
+ * boolean) so the switch takes effect on the next request without reloading
2355
+ * the plugin: turning it on mid-session is the whole point — the user sees
2356
+ * the working-notes turn, flips the switch, and the next turn is answered
2357
+ * normally.
2343
2358
  */
2344
2359
  dropForeignThinking?: () => boolean;
2345
2360
  /**
@@ -2731,17 +2746,31 @@ declare function resolveCredentialRecovery(options: {
2731
2746
  */
2732
2747
  /** Basename of the host heartbeat file inside the Harness home. */
2733
2748
  declare const WORKBUDDY_HOST_HEARTBEAT_FILENAME = ".workbuddy-host-heartbeat.json";
2734
- /** Current on-disk heartbeat format; readers reject others. */
2735
- declare const HEARTBEAT_FORMAT_VERSION = 1;
2749
+ declare const READABLE_HEARTBEAT_VERSIONS: readonly number[];
2736
2750
  /** On-disk shape of the heartbeat. */
2737
2751
  interface WorkBuddyHostHeartbeat {
2738
- version: typeof HEARTBEAT_FORMAT_VERSION;
2752
+ /** The format actually on disk; this build writes {@link HEARTBEAT_FORMAT_VERSION}. */
2753
+ version: (typeof READABLE_HEARTBEAT_VERSIONS)[number];
2739
2754
  package: 'dsh-connect-workbuddy';
2740
2755
  pluginVersion: string;
2741
2756
  /** Epoch milliseconds when the host registered the provider. */
2742
2757
  registeredAt: number;
2743
2758
  /** Host process PID, to distinguish a stale heartbeat after a crash. */
2744
2759
  pid: number;
2760
+ /**
2761
+ * Epoch milliseconds when THIS process started (issue #174).
2762
+ *
2763
+ * The staleness check needs to recognise a REUSED PID: `process.kill(pid, 0)`
2764
+ * only says "some process has this number", so after a crash the heartbeat
2765
+ * can describe a process that died while the number now belongs to something
2766
+ * else. The way to tell is to compare the live process's start time against
2767
+ * the one recorded HERE.
2768
+ *
2769
+ * Absent on heartbeats written by older builds (`version: 1`): the reader
2770
+ * then falls back to {@link WorkBuddyHostHeartbeat.registeredAt}, which is
2771
+ * what those builds wrote, and is only a last-resort approximation.
2772
+ */
2773
+ hostStartedAt?: number;
2745
2774
  }
2746
2775
  /** Absolute path of the host heartbeat file. */
2747
2776
  declare function workbuddyHostHeartbeatPath(): string;
@@ -2759,11 +2788,29 @@ declare function processStartTimeMs(pid: number): number | undefined;
2759
2788
  *
2760
2789
  * A PID can be reused after a crash, so the recorded start time is compared
2761
2790
  * against the live process: a different start time means a different process.
2791
+ *
2792
+ * ISSUE #174 — the reference used to be `registeredAt`, the moment the plugin
2793
+ * WROTE the heartbeat, and the comparison was `|startedAt − registeredAt| < 60s`.
2794
+ * Those are two different quantities: `registeredAt` is when the host finished
2795
+ * booting and mounted this plugin, which on a real machine is minutes after the
2796
+ * process started. Measured by the reporter on Windows 11: process start
2797
+ * 19:47:20, heartbeat written 19:58:45 — 684,817 ms apart — so `doctor` and
2798
+ * `status` reported `hostBundle: "stale"` for a host that was alive and had
2799
+ * just written the file. The `hostStartedAt` field exists so the comparison has
2800
+ * the right reference; `registeredAt` is only read for heartbeats written by an
2801
+ * older build, where it is the sole (and merely near) signal available.
2762
2802
  */
2763
2803
  declare function isHeartbeatProcessAlive(heartbeat: WorkBuddyHostHeartbeat): boolean;
2764
2804
  /** Read the heartbeat; absent or unparsable files report undefined. */
2765
2805
  declare function readHostHeartbeat(): Promise<WorkBuddyHostHeartbeat | undefined>;
2766
- /** Write the heartbeat for the current process. */
2806
+ /**
2807
+ * Write the heartbeat for the current process.
2808
+ *
2809
+ * `hostStartedAt` is captured HERE, from this process, so the later staleness
2810
+ * check compares like with like (issue #174). Sampled on every write rather
2811
+ * than cached: the value is cheap, and a module-level cache would be wrong the
2812
+ * moment this function is exercised from a child process.
2813
+ */
2767
2814
  declare function writeHostHeartbeat(): Promise<void>;
2768
2815
  /** Remove the heartbeat; called when the plugin is disposed. */
2769
2816
  declare function clearHostHeartbeat(): Promise<void>;
@@ -4981,8 +5028,35 @@ interface WorkBuddyRegionState {
4981
5028
  lastCatalog?: WorkBuddyModelInfo[];
4982
5029
  /** The user's selection in this region, as model ids. */
4983
5030
  enabledModelIds?: string[];
4984
- /** Model ids the user explicitly opted into image input. */
5031
+ /**
5032
+ * Model ids the user opted into image input for this region.
5033
+ *
5034
+ * Every id here is honoured. What the SAVED array cannot express is the
5035
+ * difference between "I looked at this model and unticked it" and "this model
5036
+ * did not exist when the list was last refreshed" — so the runtime also adds
5037
+ * a model this build's vendor table classifies as verified-multimodal, UNLESS
5038
+ * {@link imageSelectionExplicit} says the user has edited these boxes. See
5039
+ * `effectiveImageIds` (issue #168); the seed never rewrites this array.
5040
+ */
4985
5041
  imageModelIds?: string[];
5042
+ /**
5043
+ * Whether the image boxes have been edited by a human (issue #168).
5044
+ *
5045
+ * `imageModelIds` alone cannot distinguish a DECISION from a leftover refresh
5046
+ * snapshot: the array is written both by the card's save (a judgement) and by
5047
+ * a catalog refresh (what the roster said on that day). Without that
5048
+ * distinction a model the roster gained after the last refresh was stamped
5049
+ * `multimodal: false` purely by its ABSENCE, `workBuddyModelInput` answered
5050
+ * `['text']`, and the host refused the attachment with
5051
+ * `MODEL_DOES_NOT_SUPPORT_IMAGES` — 「这个模型不支持图片」 — for a model the
5052
+ * plugin's own table calls verified-multimodal.
5053
+ *
5054
+ * `false`/absent means "nobody has chosen yet, so the table may fill the gaps";
5055
+ * a card save sets `true`, after which this array is the whole answer — which
5056
+ * is what keeps an explicit UNTICK of a verified-multimodal model from being
5057
+ * silently re-ticked on the next read.
5058
+ */
5059
+ imageSelectionExplicit?: boolean;
4986
5060
  /**
4987
5061
  * Model ids whose `off` thinking level is offered (issue #34). Seeded from
4988
5062
  * the built-in rule (upstream declaration minus the models known to reject
@@ -5206,12 +5280,13 @@ interface Config {
5206
5280
  *
5207
5281
  * 这条失败**不换号也不重试**(`isFailoverWorthy` 刻意把它排除在外:网关匹配的
5208
5282
  * 是消息开头,换账号只会每个账号白烧一次请求),所以这一轮就此结束,用户看到的
5209
- * 是「本轮运行失败400: … 被网关的渠道校验拦截 …」。而新开一个回合会让宿主重新
5210
- * 组装 surface,确实可能通过 —— 这正是 `docs/PINNED-11128-RETRY.md` 里那条
5211
- * 「把这条消息再发一次」的手动做法。
5283
+ * 是「本轮运行失败400: … 被网关的渠道校验拦截 …」。固定指纹由 `prepareChatBody`
5284
+ * 的按句改词处理;这条开关只是**最后兜底**:只有被拒内容刚好随轮次变化的窄情况
5285
+ * 才可能靠新回合碰过去。固定 system 指纹每轮相同,重发也救不了(#163 实测)。
5212
5286
  *
5213
5287
  * 默认关闭的理由与 {@link Config.usageIdentity} 相同:它会在用户没有发消息的
5214
- * 情况下花掉真实请求,属于必须显式授权的开销。上限按用户要求默认 3 次。
5288
+ * 情况下花掉真实请求,属于必须显式授权的开销。上限默认 1 次:1 次救不了,3 次也
5289
+ * 救不了,旧默认 3 只会把同一个 400 付三遍。
5215
5290
  */
5216
5291
  channelRejectContinue?: {
5217
5292
  /**
@@ -5219,7 +5294,7 @@ interface Config {
5219
5294
  */
5220
5295
  enabled?: boolean;
5221
5296
  /**
5222
- * 连续自动续发的次数上限(默认 3,夹在 1..10)。
5297
+ * 连续自动续发的次数上限(默认 1,夹在 1..10)。
5223
5298
  *
5224
5299
  * 「连续」是关键:一次成功回合、或者用户自己发了一条消息,计数立刻清零。
5225
5300
  * 所以上限拦的是「一直过不去就一直试」,而不是这个会话一生的总次数。