dsh-workbuddy-connect 0.3.2 → 0.4.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.
package/lib/index.js CHANGED
@@ -1,12 +1,15 @@
1
- import { S as workbuddyOwnAuthPath, _ as WORKBUDDY_AUTH_FILE_ENV, a as readHostHeartbeat, b as defaultDesktopAuthPath, d as normalizeCredits, f as prepareChatBody, g as WORKBUDDY_AUTH_FILENAME, h as WorkBuddyCatalog, i as processStartTimeMs, l as WorkBuddyUpstreamClient, m as FALLBACK_WORKBUDDY_MODELS, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as regionOf, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as classifyUpstreamError, v as WorkBuddyCredentialStore, x as parseWorkBuddyAuth, y as defaultDesktopAuthCandidates } from "./host-heartbeat-BSsKUoB8.js";
1
+ import { C as defaultDesktopAuthPath, S as defaultDesktopAuthCandidates, T as workbuddyOwnAuthPath, _ as FALLBACK_WORKBUDDY_MODELS, a as readHostHeartbeat, b as WORKBUDDY_AUTH_FILE_ENV, c as WORKBUDDY_CONNECT_VERSION, d as normalizeCredits, f as prepareChatBody, g as randomSentinel, h as probeModel, i as processStartTimeMs, l as WorkBuddyUpstreamClient, m as PROBE_EFFORT_CANDIDATES, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as regionOf, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as classifyUpstreamError, v as WorkBuddyCatalog, w as parseWorkBuddyAuth, x as WorkBuddyCredentialStore, y as WORKBUDDY_AUTH_FILENAME } from "./host-heartbeat-0UAWqWac.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
3
5
  import { createProvider } from "@earendil-works/pi-ai";
4
6
  import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
5
7
  import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
6
8
  import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
7
- import { randomBytes, timingSafeEqual } from "node:crypto";
9
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
8
10
  import { createServer } from "node:http";
9
11
  import { Readable } from "node:stream";
12
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
13
  //#region src/adapter.ts
11
14
  /**
12
15
  * The `workbuddy` pi-ai provider: one loopback-backed adapter registered
@@ -107,29 +110,40 @@ function withCatalogDisplay(name, info) {
107
110
  * `thinkingLevelMap` (every level pinned to its wire spelling or `null` for
108
111
  * unsupported), mirroring `dsh-llm-pi-ai`'s own `resolveModelReasoning`.
109
112
  *
110
- * Declared sets only: a thinking control is offered exactly when the upstream
111
- * catalog declares a `supportedEfforts` list, and it offers exactly the
112
- * declared values. Rows without a list (the older `{effort, summary}` shape)
113
- * get no control at all — their selectable set is client-side knowledge the
114
- * catalog does not carry (the desktop app differs per model there: GLM-5.2
115
- * gets a thinking control while MiniMax-M3 and Kimi-K2.6 do not, though their
116
- * catalog rows are identical), and another implementation against the same
117
- * upstream (workbuddy2api) gates on the declared set and downgrades
118
- * out-of-set values rather than passing them through, so sending an
119
- * undeclared value risks a 400. Such models never carry `reasoning_effort`
120
- * on the wire; the upstream applies its own default.
121
- * `off` is offered only when the model explicitly reports thinking can be
122
- * disabled (`canDisableThinking === true`).
113
+ * Two sources, strictly ordered (`docs/reasoning-effort-probe-plan.md` §5):
114
+ *
115
+ * 1. **The declared set.** When the upstream declares a non-empty
116
+ * `supportedEfforts`, exactly those values are offered and nothing else.
117
+ * This always wins: an observation never widens or narrows a declared set.
118
+ * 2. **A local observation.** Rows without a declared set (the older
119
+ * `{effort, summary}` shape) normally get no control at all — their
120
+ * selectable set is client-side knowledge the catalog does not carry, and
121
+ * the desktop app differs per model there. If the user authorized a probe
122
+ * and it established that the upstream *validates* the parameter, the
123
+ * verified spellings are offered.
124
+ *
125
+ * A `non-validating` observation deliberately yields no control: the upstream
126
+ * accepts values that cannot exist (measured on `glm-5.2`), so every per-level
127
+ * acceptance it produced would be a false positive.
128
+ *
129
+ * `off` is offered only when the upstream declares `canDisableThinking: true`.
130
+ * It is never probed — disabling thinking is a separate capability, and the
131
+ * per-model acceptance of `off` cannot be inferred from the row's shape.
132
+ *
133
+ * The offered set is described internally as "verified accepted", never as
134
+ * "verified effective": acceptance proves the upstream did not reject the
135
+ * spelling, not that it changes what the model does.
123
136
  */
124
- function reasoningFields(info) {
137
+ function reasoningFields(info, observed) {
125
138
  const reasoning = info.reasoning;
126
139
  if (reasoning === void 0 || reasoning.supports !== true) return { reasoning: false };
127
- const efforts = reasoning.supportedEfforts;
128
- if (efforts === void 0 || efforts.length === 0) return { reasoning: false };
140
+ const declared = reasoning.supportedEfforts;
141
+ const efforts = declared !== void 0 && declared.length > 0 ? declared : observed?.validation === "validating" && observed.efforts.length > 0 ? observed.efforts : void 0;
142
+ if (efforts === void 0) return { reasoning: false };
129
143
  return {
130
144
  reasoning: true,
131
145
  thinkingLevelMap: {
132
- off: reasoning.canDisableThinking === true ? "off" : null,
146
+ off: reasoning.canDisableThinking === true && declared !== void 0 && declared.length > 0 ? "off" : null,
133
147
  minimal: null,
134
148
  low: efforts.includes("low") ? "low" : null,
135
149
  medium: efforts.includes("medium") ? "medium" : null,
@@ -140,7 +154,7 @@ function reasoningFields(info) {
140
154
  };
141
155
  }
142
156
  /** Build one pi-ai model descriptor pointing at the loopback shim. */
143
- function toPiModel(info, baseUrl) {
157
+ function toPiModel(info, baseUrl, observed) {
144
158
  return {
145
159
  id: info.id,
146
160
  name: info.name,
@@ -148,7 +162,7 @@ function toPiModel(info, baseUrl) {
148
162
  provider: WORKBUDDY_PROVIDER,
149
163
  baseUrl,
150
164
  input: info.supportsImages === true ? ["text", "image"] : ["text"],
151
- ...reasoningFields(info),
165
+ ...reasoningFields(info, observed),
152
166
  cost: NO_COST,
153
167
  contextWindow: info.contextWindow,
154
168
  maxTokens: info.maxTokens
@@ -167,10 +181,10 @@ function toPiModel(info, baseUrl) {
167
181
  * `modelErrors` since 0.1.5-alpha.2 (#12).
168
182
  */
169
183
  function createWorkBuddyAdapter(options) {
170
- const { shim, store, catalog, resolveAttachments } = options;
184
+ const { shim, store, catalog, resolveAttachments, observe } = options;
171
185
  const buildModels = () => {
172
186
  const baseUrl = `${shim.baseUrl()}/v1`;
173
- return catalog.current().map((info) => toPiModel(info, baseUrl));
187
+ return catalog.current().map((info) => toPiModel(info, baseUrl, observe?.(info.id)));
174
188
  };
175
189
  const provider = {
176
190
  ...createProvider({
@@ -357,7 +371,7 @@ function writeOpenAIError(res, status, kind, message) {
357
371
  } });
358
372
  }
359
373
  /** Read a request body with a size cap; over-limit bodies fail the request. */
360
- function readBody(req) {
374
+ function readBody$1(req) {
361
375
  return new Promise((resolve, reject) => {
362
376
  const chunks = [];
363
377
  let size = 0;
@@ -461,7 +475,7 @@ function createWorkBuddyShim(options) {
461
475
  writeOpenAIError(res, 401, "not_signed_in", String(error));
462
476
  return;
463
477
  }
464
- const raw = (await readBody(req)).toString("utf8");
478
+ const raw = (await readBody$1(req)).toString("utf8");
465
479
  const prepared = prepareChatBody(raw);
466
480
  const controller = new AbortController();
467
481
  req.on("close", () => controller.abort());
@@ -499,17 +513,317 @@ function createWorkBuddyShim(options) {
499
513
  };
500
514
  }
501
515
  //#endregion
516
+ //#region src/probe-store.ts
517
+ /**
518
+ * Local record of reasoning-effort probes.
519
+ *
520
+ * What this stores is an *observation*, never a claim about the upstream: a
521
+ * model's row is only consulted when the catalog carries no explicit
522
+ * `supportedEfforts` set, and it always loses to a declared set. The plan this
523
+ * implements (`docs/reasoning-effort-probe-plan.md` §5) requires that a result
524
+ * is invalidated whenever the model's catalog row changes, so every record
525
+ * carries a fingerprint of the fields the probe depended on.
526
+ *
527
+ * The file lives beside the plugin's own credential copy under `$DSH_HOME`,
528
+ * never in the desktop app's files, and carries no token, prompt, or response
529
+ * body — only model ids, effort spellings, and timestamps.
530
+ *
531
+ * @module dsh-workbuddy-connect/probe-store
532
+ */
533
+ /** Basename of the probe record inside the Harness home. */
534
+ const WORKBUDDY_PROBE_FILENAME = ".workbuddy-probe.json";
535
+ /** On-disk format this reader accepts; other versions are discarded. */
536
+ const PROBE_FORMAT_VERSION = 1;
537
+ /**
538
+ * How long an observation stays usable. Conservative on purpose: the plan's
539
+ * whole argument is that upstream metadata moves fast, so a result that has
540
+ * outlived its fingerprint's usefulness should not quietly keep granting a
541
+ * picker entry.
542
+ */
543
+ const DEFAULT_TTL_MS = 12096e5;
544
+ /** Plugin-owned probe record path inside the Harness home. */
545
+ function workbuddyProbePath() {
546
+ return join(resolveDshHome(), WORKBUDDY_PROBE_FILENAME);
547
+ }
548
+ /**
549
+ * Fingerprint the catalog fields a probe depends on.
550
+ *
551
+ * Deliberately excludes display-only fields (`name`, `billing`, `contextWindow`)
552
+ * so a rename or a promo badge does not throw away a valid observation, and
553
+ * deliberately includes the whole reasoning object so any change to the
554
+ * declared shape re-probes.
555
+ */
556
+ function fingerprintModel(info) {
557
+ const basis = JSON.stringify({
558
+ id: info.id,
559
+ reasoning: info.reasoning ?? null,
560
+ supportsImages: info.supportsImages ?? null
561
+ });
562
+ return createHash("sha256").update(basis).digest("hex").slice(0, 16);
563
+ }
564
+ /** Read-and-validate the documents on disk; anything malformed reads as empty. */
565
+ function readDocument(path) {
566
+ if (!existsSync(path)) return void 0;
567
+ let parsed;
568
+ try {
569
+ parsed = JSON.parse(readFileSync(path, "utf8"));
570
+ } catch {
571
+ return;
572
+ }
573
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
574
+ const wrapped = parsed;
575
+ if (wrapped["version"] !== PROBE_FORMAT_VERSION) return void 0;
576
+ const records = wrapped["records"];
577
+ if (typeof records !== "object" || records === null || Array.isArray(records)) return void 0;
578
+ return parsed;
579
+ }
580
+ /** One record's shape check; a bad row is dropped rather than trusted. */
581
+ function isRecord(value) {
582
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
583
+ const wrapped = value;
584
+ const validation = wrapped["validation"];
585
+ if (validation !== "validating" && validation !== "non-validating" && validation !== "unknown") return false;
586
+ if (typeof wrapped["fingerprint"] !== "string") return false;
587
+ if (typeof wrapped["probedAtMs"] !== "number" || !Number.isFinite(wrapped["probedAtMs"])) return false;
588
+ if (typeof wrapped["pluginVersion"] !== "string") return false;
589
+ const efforts = wrapped["efforts"];
590
+ if (!Array.isArray(efforts) || efforts.some((effort) => typeof effort !== "string")) return false;
591
+ return true;
592
+ }
593
+ /**
594
+ * The plugin's probe records: read once, written atomically, never trusted
595
+ * across a fingerprint change or past the TTL.
596
+ */
597
+ var WorkBuddyProbeStore = class {
598
+ path;
599
+ ttlMs;
600
+ pluginVersion;
601
+ now;
602
+ records;
603
+ constructor(options) {
604
+ const opts = typeof options === "string" ? {
605
+ path: options,
606
+ pluginVersion: "0.0.0"
607
+ } : options;
608
+ this.path = opts.path ?? workbuddyProbePath();
609
+ this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
610
+ this.pluginVersion = opts.pluginVersion;
611
+ this.now = opts.now ?? (() => Date.now());
612
+ }
613
+ /** Resolved state-file path, for the CLI and tests. */
614
+ filePath() {
615
+ return this.path;
616
+ }
617
+ load() {
618
+ if (this.records === void 0) {
619
+ const document = readDocument(this.path);
620
+ const records = {};
621
+ for (const [id, record] of Object.entries(document?.records ?? {})) if (isRecord(record)) records[id] = record;
622
+ this.records = records;
623
+ }
624
+ return this.records;
625
+ }
626
+ /**
627
+ * The usable record for a model, or `undefined` when there is none, it is
628
+ * expired, or it was taken against a different catalog row.
629
+ */
630
+ get(modelId, fingerprint) {
631
+ const record = this.load()[modelId];
632
+ if (record === void 0) return void 0;
633
+ if (record.fingerprint !== fingerprint) return void 0;
634
+ if (this.now() - record.probedAtMs > this.ttlMs) return void 0;
635
+ return record;
636
+ }
637
+ /**
638
+ * Store one observation. Only a decisive answer (`validating` /
639
+ * `non-validating`) replaces an existing decisive record: a transient
640
+ * `unknown` must not erase knowledge the user already paid for.
641
+ */
642
+ set(modelId, record) {
643
+ const records = this.load();
644
+ const existing = records[modelId];
645
+ if (record.validation === "unknown" && existing !== void 0 && existing.fingerprint === record.fingerprint && existing.validation !== "unknown") return;
646
+ records[modelId] = record;
647
+ this.persist();
648
+ }
649
+ /** Drop every record; used by the card's explicit "clear" action. */
650
+ clear() {
651
+ this.records = {};
652
+ this.persist();
653
+ }
654
+ /** Every record currently held, for status display. */
655
+ all() {
656
+ return { ...this.load() };
657
+ }
658
+ /** Build a record stamped with this store's clock and version. */
659
+ record(fingerprint, validation, efforts) {
660
+ return {
661
+ fingerprint,
662
+ validation,
663
+ efforts: validation === "validating" ? [...efforts] : [],
664
+ probedAtMs: this.now(),
665
+ pluginVersion: this.pluginVersion
666
+ };
667
+ }
668
+ /**
669
+ * Write through a temporary file and rename, so a crash mid-write cannot
670
+ * leave a half-parsed document that reads as "no records" and silently drops
671
+ * every observation.
672
+ */
673
+ persist() {
674
+ const directory = dirname(this.path);
675
+ try {
676
+ if (!existsSync(directory)) mkdirSync(directory, { recursive: true });
677
+ const document = {
678
+ version: PROBE_FORMAT_VERSION,
679
+ records: this.load()
680
+ };
681
+ const temporary = resolve(`${this.path}.tmp`);
682
+ writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 384 });
683
+ renameSync(temporary, this.path);
684
+ } catch {}
685
+ }
686
+ };
687
+ /**
688
+ * Order observations newest-first for display.
689
+ *
690
+ * The store keeps insertion order so the file reads chronologically, but the
691
+ * card wants the most recent detection at the top: a sweep the user just ran
692
+ * should not appear below every earlier one, which is what appending to an
693
+ * insertion-ordered list does.
694
+ */
695
+ function newestFirst(records) {
696
+ return [...records].sort((a, b) => b.probedAt - a.probedAt);
697
+ }
698
+ //#endregion
699
+ //#region src/probe-service.ts
700
+ /**
701
+ * Serial probe runner. One instance is shared by the manual API and any
702
+ * future automatic trigger, so the two can never overlap.
703
+ */
704
+ var WorkBuddyProbeService = class {
705
+ options;
706
+ queue = Promise.resolve();
707
+ pending = /* @__PURE__ */ new Map();
708
+ running = false;
709
+ constructor(options) {
710
+ this.options = options;
711
+ }
712
+ /** Whether a sweep is in flight right now. */
713
+ isRunning() {
714
+ return this.running;
715
+ }
716
+ /**
717
+ * The record the adapter may use for this model, or `undefined`.
718
+ *
719
+ * Applies the plan's precedence (§5): a declared set always wins, so a model
720
+ * that declares `supportedEfforts` is never answered from an observation.
721
+ */
722
+ recordFor(modelId) {
723
+ const info = this.options.catalog.current().find((model) => model.id === modelId);
724
+ if (info === void 0) return void 0;
725
+ if (info.reasoning?.supportedEfforts !== void 0 && info.reasoning.supportedEfforts.length > 0) return;
726
+ return this.options.store.get(modelId, fingerprintModel(info));
727
+ }
728
+ /**
729
+ * Probe one model, serially.
730
+ *
731
+ * The authenticated manual route supplies one-request consent after UI
732
+ * confirmation. Other callers must pass the configured consent gate.
733
+ * Manual consent never changes the automatic-probing configuration.
734
+ * Explicit requests bypass historical results, but share an ongoing run.
735
+ */
736
+ async probe(modelId, manualConsent = false) {
737
+ if (!manualConsent && !this.options.consent()) return {
738
+ state: "unavailable",
739
+ reason: "probing is not authorized"
740
+ };
741
+ if (this.options.catalog.current().find((model) => model.id === modelId) === void 0) return {
742
+ state: "unavailable",
743
+ reason: `unknown model: ${modelId}`
744
+ };
745
+ const pending = this.pending.get(modelId);
746
+ if (pending !== void 0) return pending;
747
+ const run = this.queue.then(async () => {
748
+ const current = this.options.catalog.current().find((model) => model.id === modelId);
749
+ if (current === void 0) return {
750
+ state: "unavailable",
751
+ reason: `unknown model: ${modelId}`
752
+ };
753
+ if (!manualConsent && !this.options.consent()) return {
754
+ state: "unavailable",
755
+ reason: "probing is not authorized"
756
+ };
757
+ if (current.reasoning?.supports !== true || (current.reasoning.supportedEfforts?.length ?? 0) > 0) return {
758
+ state: "unavailable",
759
+ reason: "model does not need detection"
760
+ };
761
+ const cached = this.recordFor(modelId);
762
+ if (!manualConsent && cached !== void 0 && cached.validation !== "unknown") return {
763
+ state: "ok",
764
+ validation: cached.validation,
765
+ efforts: cached.efforts,
766
+ requests: 0
767
+ };
768
+ const credential = await this.options.credentials.current();
769
+ if (credential === void 0) return {
770
+ state: "unavailable",
771
+ reason: "no WorkBuddy credential"
772
+ };
773
+ const send = this.options.send === void 0 ? (effort, signal) => this.options.client.probeEffort(credential, modelId, effort, signal) : this.options.send(modelId);
774
+ this.running = true;
775
+ try {
776
+ const outcome = await probeModel({
777
+ send,
778
+ ...this.options.sentinel === void 0 ? {} : { sentinel: this.options.sentinel }
779
+ });
780
+ const record = this.options.store.record(fingerprintModel(current), outcome.validation, outcome.efforts);
781
+ this.options.store.set(modelId, record);
782
+ if (outcome.validation === "unknown") return {
783
+ state: "unavailable",
784
+ reason: outcome.reason
785
+ };
786
+ return {
787
+ state: "ok",
788
+ validation: outcome.validation,
789
+ efforts: record.efforts,
790
+ requests: outcome.requests
791
+ };
792
+ } finally {
793
+ this.running = false;
794
+ }
795
+ });
796
+ this.queue = run.catch(() => void 0);
797
+ this.pending.set(modelId, run);
798
+ try {
799
+ return await run;
800
+ } finally {
801
+ this.pending.delete(modelId);
802
+ }
803
+ }
804
+ };
805
+ //#endregion
502
806
  //#region src/status-paths.ts
503
807
  /** Node-free constants and types shared by the Host and browser halves. */
504
808
  /** Plugin-owned status endpoint consumed by its browser half. */
505
809
  const WORKBUDDY_STATUS_PATH = "/plugins/dsh-workbuddy-connect/status";
810
+ /**
811
+ * Plugin-owned probe control endpoint.
812
+ *
813
+ * Separate from the status route because it accepts writes: the status route's
814
+ * loopback Host/Origin guard protects against a DNS-rebinding *page*, which is
815
+ * not the same as authorizing a state-changing action. This route therefore
816
+ * also requires the in-process key the browser half receives with the status
817
+ * document.
818
+ */
819
+ const WORKBUDDY_PROBE_PATH = "/plugins/dsh-workbuddy-connect/probe";
506
820
  //#endregion
507
821
  //#region src/web-status.ts
508
822
  /** Redact token-like content before it crosses to the browser. */
509
823
  function safeMessage(error) {
510
824
  return (error instanceof Error ? error.message : String(error)).replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted token]").replace(/(\b(?:code|token|refresh_token|access_token)=)[^&\s]+/giu, "$1[redacted]").slice(0, 500);
511
825
  }
512
- function json(res, status, body) {
826
+ function json$1(res, status, body) {
513
827
  const payload = JSON.stringify(body);
514
828
  res.writeHead(status, {
515
829
  "Content-Type": "application/json",
@@ -541,52 +855,58 @@ async function workBuddyWebStatus(deps) {
541
855
  ...authStatus.source === void 0 ? {} : { source: authStatus.source },
542
856
  ...authStatus.expiresAtMs === void 0 ? {} : { expiresAt: authStatus.expiresAtMs }
543
857
  };
544
- const modelsField = deps.models().filter((model) => model.billing?.free === true || (model.billing?.badges?.length ?? 0) > 0).map((model) => {
858
+ const modelsField = deps.models().map((model) => {
545
859
  const rate = normalizeCredits(model.billing?.credits);
546
860
  return {
547
861
  id: model.id,
548
862
  name: model.name,
549
863
  ...model.billing?.free === true ? { free: true } : {},
550
864
  ...model.billing?.badges !== void 0 && model.billing.badges.length > 0 ? { badges: model.billing.badges } : {},
551
- ...rate === void 0 ? {} : { credits: rate }
865
+ ...rate === void 0 ? {} : { credits: rate },
866
+ ...typeof model.contextWindow === "number" && model.contextWindow > 0 ? { contextWindow: model.contextWindow } : {}
552
867
  };
553
868
  });
554
869
  const statusWithModels = modelsField.length > 0 ? {
555
870
  ...status,
556
871
  models: modelsField
557
872
  } : status;
873
+ const probed = deps.probe === void 0 ? statusWithModels : {
874
+ ...statusWithModels,
875
+ probe: deps.probe(),
876
+ ...deps.probeKey === void 0 ? {} : { probeKey: deps.probeKey }
877
+ };
558
878
  try {
559
879
  const credential = await deps.store.current();
560
880
  if (credential !== void 0) {
561
881
  const credits = await deps.client.fetchCredits(credential);
562
882
  return {
563
- ...statusWithModels,
883
+ ...probed,
564
884
  credits
565
885
  };
566
886
  }
567
887
  } catch (error) {
568
888
  return {
569
- ...statusWithModels,
889
+ ...probed,
570
890
  creditsError: safeMessage(error)
571
891
  };
572
892
  }
573
- return statusWithModels;
893
+ return probed;
574
894
  }
575
895
  /** The status route's request handler, extracted so tests can mount it on a bare server. */
576
896
  function workBuddyStatusHandler(deps) {
577
897
  return async (req, res) => {
578
898
  if (req.method !== "GET") {
579
- json(res, 405, { error: "method not allowed" });
899
+ json$1(res, 405, { error: "method not allowed" });
580
900
  return;
581
901
  }
582
902
  if (!loopbackRequest(req)) {
583
- json(res, 403, { error: "request-not-trusted" });
903
+ json$1(res, 403, { error: "request-not-trusted" });
584
904
  return;
585
905
  }
586
906
  try {
587
- json(res, 200, await workBuddyWebStatus(deps));
907
+ json$1(res, 200, await workBuddyWebStatus(deps));
588
908
  } catch (error) {
589
- json(res, 500, { error: safeMessage(error) });
909
+ json$1(res, 500, { error: safeMessage(error) });
590
910
  }
591
911
  };
592
912
  }
@@ -604,6 +924,136 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
604
924
  }, "dsh-workbuddy-connect: Web status route");
605
925
  }
606
926
  //#endregion
927
+ //#region src/probe-route.ts
928
+ /**
929
+ * Probe control route: the only state-changing endpoint the plugin exposes.
930
+ *
931
+ * Two guards, because they stop different things (see `docs/reasoning-effort-probe-plan.md`
932
+ * §6.4 and the v0.3.1 note in AGENTS.md about their exact scope):
933
+ *
934
+ * 1. **Loopback Host + Origin**, shared with the status route. This drops
935
+ * DNS-rebinding pages, whose requests arrive addressed to the attacker's
936
+ * domain.
937
+ * 2. **An in-process random key**, minted per process and handed only to the
938
+ * same-origin card. Loopback alone is *not* authentication — any local
939
+ * process can write `Host: 127.0.0.1` — so a route that spends the user's
940
+ * credit must prove the caller was told the key.
941
+ *
942
+ * The route never accepts a prompt, a model id outside the live catalog, or a
943
+ * sentinel from the browser: a probe request is assembled entirely host-side.
944
+ *
945
+ * @module dsh-workbuddy-connect/probe-route
946
+ */
947
+ /** Largest control body accepted; these payloads are a few dozen bytes. */
948
+ const MAX_BODY_BYTES = 4096;
949
+ /** Mint the per-process control key. */
950
+ function createProbeKey() {
951
+ return randomBytes(24).toString("hex");
952
+ }
953
+ /**
954
+ * Constant-time key comparison; a length mismatch is a failure, not a crash.
955
+ */
956
+ function keyMatches(expected, presented) {
957
+ if (presented === void 0 || presented.length !== expected.length) return false;
958
+ const a = Buffer.from(expected);
959
+ const b = Buffer.from(presented);
960
+ return a.length === b.length && timingSafeEqual(a, b);
961
+ }
962
+ function json(res, status, body) {
963
+ const payload = JSON.stringify(body);
964
+ res.writeHead(status, {
965
+ "Content-Type": "application/json",
966
+ "Content-Length": Buffer.byteLength(payload)
967
+ });
968
+ res.end(payload);
969
+ }
970
+ /** Read the request body with a hard ceiling. */
971
+ async function readBody(req) {
972
+ const chunks = [];
973
+ let total = 0;
974
+ for await (const chunk of req) {
975
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
976
+ total += buffer.length;
977
+ if (total > MAX_BODY_BYTES) return void 0;
978
+ chunks.push(buffer);
979
+ }
980
+ return Buffer.concat(chunks).toString("utf8");
981
+ }
982
+ /** Parse and shape-check an action; unknown fields are ignored, not trusted. */
983
+ function parseAction(text) {
984
+ let parsed;
985
+ try {
986
+ parsed = JSON.parse(text);
987
+ } catch {
988
+ return;
989
+ }
990
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
991
+ const wrapped = parsed;
992
+ const action = wrapped["action"];
993
+ if (action === "clear") return { action: "clear" };
994
+ if (action === "probe") {
995
+ const model = wrapped["model"];
996
+ if (typeof model !== "string" || model.trim() === "") return void 0;
997
+ return {
998
+ action: "probe",
999
+ model: model.trim()
1000
+ };
1001
+ }
1002
+ }
1003
+ /**
1004
+ * The control route's handler, extracted so tests can mount it on a bare
1005
+ * server with a known key.
1006
+ */
1007
+ function workBuddyProbeHandler(deps, key) {
1008
+ return async (req, res) => {
1009
+ if (req.method !== "POST") {
1010
+ json(res, 405, { error: "method not allowed" });
1011
+ return;
1012
+ }
1013
+ if (!hostIsLoopback(req.headers.host) || !originIsLoopback(req.headers.origin)) {
1014
+ json(res, 403, { error: "request-not-trusted" });
1015
+ return;
1016
+ }
1017
+ if (!keyMatches(key, req.headers["x-workbuddy-probe-key"])) {
1018
+ json(res, 403, { error: "invalid-probe-key" });
1019
+ return;
1020
+ }
1021
+ const body = await readBody(req);
1022
+ if (body === void 0) {
1023
+ json(res, 413, { error: "body too large" });
1024
+ return;
1025
+ }
1026
+ const action = parseAction(body);
1027
+ if (action === void 0) {
1028
+ json(res, 400, { error: "invalid action" });
1029
+ return;
1030
+ }
1031
+ try {
1032
+ if (action.action === "clear") {
1033
+ deps.clear();
1034
+ json(res, 200, { state: "cleared" });
1035
+ return;
1036
+ }
1037
+ json(res, 200, await deps.probe(action.model));
1038
+ } catch (error) {
1039
+ json(res, 500, { error: error instanceof Error ? error.message : String(error) });
1040
+ }
1041
+ };
1042
+ }
1043
+ /** Mount the POST probe-control route on an optional webServer context. */
1044
+ function registerWorkBuddyProbeRoute(ctx, deps, key) {
1045
+ ctx.effect(() => {
1046
+ const dispose = ctx.webServer.register({
1047
+ kind: "exact",
1048
+ path: WORKBUDDY_PROBE_PATH,
1049
+ handler: workBuddyProbeHandler(deps, key)
1050
+ });
1051
+ return () => {
1052
+ dispose();
1053
+ };
1054
+ }, "dsh-workbuddy-connect: probe control route");
1055
+ }
1056
+ //#endregion
607
1057
  //#region src/index.ts
608
1058
  /** Stable Cordis plugin name. */
609
1059
  const name = "llm-workbuddy";
@@ -622,7 +1072,10 @@ const inject = ["llm"];
622
1072
  * their namespaces as plain string literals).
623
1073
  */
624
1074
  const WORKBUDDY_SETTINGS_NS = "workbuddy";
625
- const Config = z.object({ authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app's own location)") });
1075
+ const Config = z.object({
1076
+ authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app's own location)"),
1077
+ probeConsent: z.boolean().default(false).description("Authorize reasoning-effort probes (each probe sends real requests that may consume credit)")
1078
+ });
626
1079
  /**
627
1080
  * Start the loopback endpoint, register the `workbuddy` provider, and
628
1081
  * refresh the model catalog from the upstream once credentials allow it.
@@ -642,12 +1095,73 @@ function apply(ctx, config) {
642
1095
  catalog,
643
1096
  logger: ctx.logger
644
1097
  });
645
- ctx.inject(["webServer"], (webCtx) => registerWorkBuddyStatusRoute(webCtx, {
646
- store,
647
- client,
648
- models: () => catalog.current()
649
- }));
650
1098
  let current = () => config;
1099
+ const probeStore = new WorkBuddyProbeStore({ pluginVersion: WORKBUDDY_CONNECT_VERSION });
1100
+ const probeService = new WorkBuddyProbeService({
1101
+ store: probeStore,
1102
+ catalog,
1103
+ credentials: store,
1104
+ client,
1105
+ consent: () => current().probeConsent === true
1106
+ });
1107
+ /**
1108
+ * Whether a model can be probed by hand: it reasons and the upstream declares
1109
+ * no effort set for it.
1110
+ *
1111
+ * Deliberately *not* filtered by whether a result already exists. Dropping a
1112
+ * model once it has been detected made the list shrink with use, so
1113
+ * re-detecting one model — after an upstream change, say — meant clearing
1114
+ * every other result first. The list stays stable and the card marks which
1115
+ * entries already have an answer.
1116
+ */
1117
+ const isProbeCandidate = (info) => {
1118
+ if (info.reasoning?.supports !== true) return false;
1119
+ return (info.reasoning.supportedEfforts?.length ?? 0) === 0;
1120
+ };
1121
+ /** Compact probe state for the card: consent, candidates, observations. */
1122
+ const probeSection = () => {
1123
+ const config = current();
1124
+ const models = catalog.current();
1125
+ const results = models.flatMap((info) => {
1126
+ const record = probeService.recordFor(info.id);
1127
+ if (record === void 0) return [];
1128
+ return [{
1129
+ id: info.id,
1130
+ name: info.name,
1131
+ validation: record.validation,
1132
+ efforts: record.efforts,
1133
+ probedAt: record.probedAtMs
1134
+ }];
1135
+ });
1136
+ return {
1137
+ consent: config.probeConsent === true,
1138
+ running: probeService.isRunning(),
1139
+ candidates: models.filter(isProbeCandidate).map((info) => info.id),
1140
+ results: newestFirst(results)
1141
+ };
1142
+ };
1143
+ const probeKey = createProbeKey();
1144
+ let refreshProbeModels = () => {};
1145
+ ctx.inject(["webServer"], (webCtx) => {
1146
+ registerWorkBuddyStatusRoute(webCtx, {
1147
+ store,
1148
+ client,
1149
+ models: () => catalog.current(),
1150
+ probe: () => probeSection(),
1151
+ probeKey
1152
+ });
1153
+ registerWorkBuddyProbeRoute(webCtx, {
1154
+ probe: async (modelId) => {
1155
+ const result = await probeService.probe(modelId, true);
1156
+ if (result.state === "ok") refreshProbeModels();
1157
+ return result;
1158
+ },
1159
+ clear: () => {
1160
+ probeStore.clear();
1161
+ refreshProbeModels();
1162
+ }
1163
+ }, probeKey);
1164
+ });
651
1165
  ctx.inject(["settings"], (settingsCtx) => {
652
1166
  settingsCtx.settings.installSection(ctx, WORKBUDDY_SETTINGS_NS, Config, config, {
653
1167
  setSource(source) {
@@ -673,9 +1187,15 @@ function apply(ctx, config) {
673
1187
  shim,
674
1188
  store,
675
1189
  catalog,
676
- resolveAttachments: () => ctx.get("attachments")
1190
+ resolveAttachments: () => ctx.get("attachments"),
1191
+ observe: (modelId) => probeService.recordFor(modelId)
677
1192
  });
678
1193
  invalidate = workbuddy.invalidate;
1194
+ refreshProbeModels = () => {
1195
+ if (stopped) return;
1196
+ workbuddy.invalidate();
1197
+ ctx.emit("llm/adapters-updated");
1198
+ };
679
1199
  let releaseAdapter;
680
1200
  let releaseDirectory;
681
1201
  try {
@@ -724,4 +1244,4 @@ function apply(ctx, config) {
724
1244
  });
725
1245
  }
726
1246
  //#endregion
727
- export { Config, FALLBACK_WORKBUDDY_MODELS, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_PROVIDER, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WorkBuddyCatalog, WorkBuddyCredentialStore, WorkBuddyUpstreamClient, apply, classifyUpstreamError, clearHostHeartbeat, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthPath, inject, isHeartbeatProcessAlive, name, normalizeCredits, parseWorkBuddyAuth, prepareChatBody, processStartTimeMs, readHostHeartbeat, regionOf, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath };
1247
+ export { Config, FALLBACK_WORKBUDDY_MODELS, PROBE_EFFORT_CANDIDATES, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_PROBE_FILENAME, WORKBUDDY_PROVIDER, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WorkBuddyCatalog, WorkBuddyCredentialStore, WorkBuddyProbeService, WorkBuddyProbeStore, WorkBuddyUpstreamClient, apply, classifyUpstreamError, clearHostHeartbeat, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthPath, fingerprintModel, inject, isHeartbeatProcessAlive, name, normalizeCredits, parseWorkBuddyAuth, prepareChatBody, probeModel, processStartTimeMs, randomSentinel, readHostHeartbeat, regionOf, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, workbuddyProbePath };