dsh-connect-workbuddy 5.8.1 → 5.8.3

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,4 +1,4 @@
1
- import { $ as parseCreditMultiplier, A as isEncryptedCredentialError, At as isWorkbuddyBundle, B as WorkBuddyCredentialRejectedError, C as WorkBuddyCredentialStore, Ct as clearAtRestKeyCache, D as defaultDesktopAuthDirs, Dt as findWorkbuddyAppExecutable, E as defaultDesktopAuthCandidates, Et as fetchAtRestKeyPayload, F as workbuddyAccountId, Ft as workbuddyAppExecutableCandidates, G as forceNeutralHead, H as channelGateMemberOf, I as workbuddyOwnAuthPath, J as isCredentialRejectedError, K as isContextTooLongBody, L as CONTEXT_OVERFLOW_MARKER, Lt as resolveDshHome, M as legacyWorkbuddyOwnAuthPath, Mt as macosNestedAppBundles, N as parseWorkBuddyAuth, Nt as openEncryptedField, O as defaultDesktopAuthPath, P as preferredAccountName, Pt as readAtRestKey, Q as openingEvidenceOfBody, R as CREDENTIAL_REJECTED_CODE, Rt as withFileLock, S as WORKBUDDY_AUTH_FILE_ENV, St as WORKBUDDY_APP_EXECUTABLE_ENV, T as authFileName, Tt as deriveAtRestKeyId, U as classifyUpstreamError, V as WorkBuddyUpstreamClient, W as declaredTools, X as knownRegionOf, Y as isFirstMessageGateBody, Z as modelIdOfBody, _ as deriveCatalog, _t as parseQueueAnswer, a as readHostHeartbeat, at as requestEvidenceOf, b as ENCRYPTED_CREDENTIAL_CODE, bt as imageDefaultFor, c as WORKBUDDY_CONNECT_VERSION, ct as defaultOffModelIds, dt as offRefused, et as parseReasoning, f as piAiRuntimeInfo, ft as recordRefusal, g as applyContextBudgets, gt as pickImageModel, h as WorkBuddyCatalog, ht as WorkBuddyImageError, i as processStartTimeMs, it as regionOf, j as isVaultAccountId, jt as macosBundleExecutable, k as hasEncryptedCredentialFields, kt as isEncryptedFieldWrapper, l as createFileRosterSeenStore, lt as lastRefusalFor, m as FALLBACK_WORKBUDDY_MODELS_GLOBAL, mt as DEFAULT_IMAGE_SIZE, n as clearHostHeartbeat, nt as perturbChannelFingerprints, o as workbuddyHostHeartbeatPath, ot as attachBusyLevels, p as FALLBACK_WORKBUDDY_MODELS, pt as withEffectiveOff, q as isConversationIdGateBody, r as isHeartbeatProcessAlive, rt as prepareChatBody, s as writeHostHeartbeat, st as REFUSES_OFF_MODEL_IDS, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as parseUpstreamModel, ut as offDefaultFor, v as fallbackModelsFor, vt as queueNoticeText, w as WorkBuddyEncryptedCredentialError, wt as deriveAtRestKey, x as WORKBUDDY_AUTH_FILENAME, xt as nativeModalityOf, y as withoutRouters, yt as NATIVE_MODALITY_BY_MODEL_ID, z as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, zt as writeFileAtomic } from "./host-heartbeat-CM-09T4U.js";
1
+ import { $ as parseCreditMultiplier, A as isEncryptedCredentialError, At as isEncryptedFieldWrapper, B as WorkBuddyCredentialRejectedError, Bt as writeFileAtomic, C as WorkBuddyCredentialStore, Ct as WORKBUDDY_APP_EXECUTABLE_ENV, D as defaultDesktopAuthDirs, Dt as fetchAtRestKeyPayload, E as defaultDesktopAuthCandidates, Et as deriveAtRestKeyId, F as workbuddyAccountId, Ft as readAtRestKey, G as forceNeutralHead, H as channelGateMemberOf, I as workbuddyOwnAuthPath, It as workbuddyAppExecutableCandidates, J as isCredentialRejectedError, K as isContextTooLongBody, L as CONTEXT_OVERFLOW_MARKER, M as legacyWorkbuddyOwnAuthPath, Mt as macosBundleExecutable, N as parseWorkBuddyAuth, Nt as macosNestedAppBundles, O as defaultDesktopAuthPath, Ot as findWorkbuddyAppExecutable, P as preferredAccountName, Pt as openEncryptedField, Q as openingEvidenceOfBody, R as CREDENTIAL_REJECTED_CODE, Rt as resolveDshHome, S as WORKBUDDY_AUTH_FILE_ENV, St as nativeModalityOf, T as authFileName, Tt as deriveAtRestKey, U as classifyUpstreamError, V as WorkBuddyUpstreamClient, W as declaredTools, X as knownRegionOf, Y as isFirstMessageGateBody, Z as modelIdOfBody, _ as deriveCatalog, _t as pickImageModel, a as readHostHeartbeat, at as requestEvidenceOf, b as ENCRYPTED_CREDENTIAL_CODE, bt as NATIVE_MODALITY_BY_MODEL_ID, c as WORKBUDDY_CONNECT_VERSION, ct as REFUSES_OFF_MODEL_IDS, dt as offDefaultFor, et as parseReasoning, f as piAiRuntimeInfo, ft as offRefused, g as applyContextBudgets, gt as WorkBuddyImageError, h as WorkBuddyCatalog, ht as DEFAULT_IMAGE_SIZE, i as processStartTimeMs, it as regionOf, j as isVaultAccountId, jt as isWorkbuddyBundle, k as hasEncryptedCredentialFields, l as createFileRosterSeenStore, lt as defaultOffModelIds, m as FALLBACK_WORKBUDDY_MODELS_GLOBAL, mt as withEffectiveOff, n as clearHostHeartbeat, nt as perturbChannelFingerprints, o as workbuddyHostHeartbeatPath, ot as rewriteSessionTitleModel, p as FALLBACK_WORKBUDDY_MODELS, pt as recordRefusal, q as isConversationIdGateBody, r as isHeartbeatProcessAlive, rt as prepareChatBody, s as writeHostHeartbeat, st as attachBusyLevels, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as parseUpstreamModel, ut as lastRefusalFor, v as fallbackModelsFor, vt as parseQueueAnswer, w as WorkBuddyEncryptedCredentialError, wt as clearAtRestKeyCache, x as WORKBUDDY_AUTH_FILENAME, xt as imageDefaultFor, y as withoutRouters, yt as queueNoticeText, z as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, zt as withFileLock } from "./host-heartbeat-DzfWuVsm.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { appendFile, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
@@ -4229,6 +4229,8 @@ function createWorkBuddyShim(options) {
4229
4229
  * silent difference after an upgrade.
4230
4230
  */
4231
4231
  const keepPromptLastEnabled = options.keepPromptLast === true;
4232
+ const extraInjectedPrefixes = Array.isArray(options.extraInjectedPrefixes) ? options.extraInjectedPrefixes.filter((entry) => typeof entry === "string" && entry !== "") : [];
4233
+ const sessionTitleOptions = options.sessionTitle ?? {};
4232
4234
  const SHARED_SECRET = randomBytes(32).toString("base64url");
4233
4235
  /**
4234
4236
  * Whether this process's prompts have been PROVEN to need the invisible
@@ -4382,7 +4384,10 @@ function createWorkBuddyShim(options) {
4382
4384
  return;
4383
4385
  }
4384
4386
  const raw = (await readBody(req)).toString("utf8");
4385
- const prepared = prepareChatBody(raw, { keepPromptLast: keepPromptLastEnabled });
4387
+ const prepared = rewriteSessionTitleModel(prepareChatBody(raw, {
4388
+ keepPromptLast: keepPromptLastEnabled,
4389
+ extraInjectedPrefixes
4390
+ }), sessionTitleOptions);
4386
4391
  const requestModelId = modelIdOfBody(prepared);
4387
4392
  if (onRequestBody !== void 0) try {
4388
4393
  onRequestBody(prepared);
@@ -5956,12 +5961,77 @@ function isInside(parent, child) {
5956
5961
  if (inner === outer) return true;
5957
5962
  return inner.startsWith(outer.endsWith(sep) ? outer : outer + sep);
5958
5963
  }
5959
- /** The session directory this tool call came from, when the Host supplies one. */
5960
- function sessionDirOf(execution) {
5961
- const cwd = execution?.agent?.session?.header?.cwd;
5962
- if (typeof cwd !== "string") return "";
5963
- const trimmed = cwd.trim();
5964
- return trimmed !== "" && isAbsolute(trimmed) ? trimmed : "";
5964
+ /** The session directory this call came from, when the Host supplies one.
5965
+ *
5966
+ * THE ORDER INSIDE THIS FUNCTION IS THE FIX. The session's live projection is
5967
+ * read FIRST, because it is the directory the human and the model are actually
5968
+ * in; the session header is the fallback, because it is where that projection
5969
+ * starts from (`init: header => header.cwd ?? null`) and because a session that
5970
+ * never committed a projection has nothing else.
5971
+ *
5972
+ * Both sources are re-validated here: the session service already rejects
5973
+ * relative headers, but a hand-built agent in a test (or a Host with a looser
5974
+ * contract) must not be able to smuggle a relative path through either one.
5975
+ */
5976
+ function sessionDirOf(execution, directoryOf) {
5977
+ const dirs = sessionDirCandidates(execution, directoryOf);
5978
+ return dirs.projection !== "" ? dirs.projection : dirs.header;
5979
+ }
5980
+ /**
5981
+ * Both session-directory sources, kept APART so the caller can tell them — and
5982
+ * therefore fall back between them — by validity, not just by presence.
5983
+ *
5984
+ * {@link sessionDirOf} collapses these into one answer, which is right for "where
5985
+ * are we" but loses the information "the projection said X, the header says Y".
5986
+ * The ladder in {@link resolveWorkingDir} needs the pair: a projection can go
5987
+ * STALE (the directory was deleted, moved, or is an offline mount) while the
5988
+ * creation-time header still points at a perfectly good directory. Without the
5989
+ * split, a stale projection is a hard refusal even though a usable directory is
5990
+ * one field away.
5991
+ *
5992
+ * Both sources are re-validated the same way as in {@link sessionDirOf}: absolute
5993
+ * and non-blank, so a hand-built session cannot smuggle a relative path through
5994
+ * either field.
5995
+ */
5996
+ function sessionDirCandidates(execution, directoryOf) {
5997
+ const session = execution?.agent?.session;
5998
+ let projection = "";
5999
+ if (session !== void 0 && directoryOf !== void 0) {
6000
+ const live = directoryOf(session);
6001
+ if (typeof live === "string") {
6002
+ const trimmed = live.trim();
6003
+ if (trimmed !== "" && isAbsolute(trimmed)) projection = trimmed;
6004
+ }
6005
+ }
6006
+ const cwd = session?.header?.cwd;
6007
+ let header = "";
6008
+ if (typeof cwd === "string") {
6009
+ const trimmed = cwd.trim();
6010
+ if (trimmed !== "" && isAbsolute(trimmed)) header = trimmed;
6011
+ }
6012
+ return {
6013
+ projection,
6014
+ header
6015
+ };
6016
+ }
6017
+ /**
6018
+ * Why `path` cannot be a turn's working directory, or `''` when it can.
6019
+ *
6020
+ * The three checks the ladder has always applied (exists / is a directory /
6021
+ * readable), stated once so the projection→header fallback in
6022
+ * {@link resolveWorkingDir} re-runs exactly the same gate instead of a weaker
6023
+ * private copy.
6024
+ */
6025
+ function dirRejection(path) {
6026
+ if (!existsSync(path)) return `working_dir "${path}" 不存在。`;
6027
+ let isDir = false;
6028
+ try {
6029
+ isDir = statSync(path).isDirectory();
6030
+ } catch (error) {
6031
+ return `working_dir "${path}" 无法读取:${error instanceof Error ? error.message : String(error)}`;
6032
+ }
6033
+ if (!isDir) return `working_dir "${path}" 不是一个目录。`;
6034
+ return "";
5965
6035
  }
5966
6036
  /**
5967
6037
  * Decide the directory one turn runs in.
@@ -5980,8 +6050,10 @@ function sessionDirOf(execution) {
5980
6050
  * A scratch turn is exempt by construction: it is a fresh empty temp directory
5981
6051
  * the bridge just created, so there is nothing in it to leak.
5982
6052
  */
5983
- function resolveWorkingDir(args, options, execution) {
5984
- const sessionDir = sessionDirOf(execution);
6053
+ function resolveWorkingDir(args, options, execution, directoryOf) {
6054
+ const dirs = sessionDirCandidates(execution, directoryOf);
6055
+ const primarySessionDir = dirs.projection !== "" && dirs.projection !== dirs.header ? dirs.projection : dirs.header;
6056
+ const fallbackSessionDir = primarySessionDir !== dirs.header && dirs.header !== "" ? dirs.header : "";
5985
6057
  const explicit = typeof args["working_dir"] === "string" ? args["working_dir"].trim() : "";
5986
6058
  let chosen = "";
5987
6059
  let fromArgument = false;
@@ -5994,7 +6066,7 @@ function resolveWorkingDir(args, options, execution) {
5994
6066
  };
5995
6067
  chosen = explicit;
5996
6068
  fromArgument = true;
5997
- } else if (sessionDir !== "") chosen = sessionDir;
6069
+ } else if (primarySessionDir !== "") chosen = primarySessionDir;
5998
6070
  else if (options.workingDir !== "") chosen = options.workingDir;
5999
6071
  else if (args["allow_unattended"] === true || options.allowUnattended) return {
6000
6072
  ok: true,
@@ -6008,31 +6080,17 @@ function resolveWorkingDir(args, options, execution) {
6008
6080
  unattended: false,
6009
6081
  reason: "no working directory。宿主目前不把会话 cwd 传给插件工具,所以这个值必须由调用方给:① 传 working_dir 指定一个已存在的绝对路径目录;② 在插件配置里设 cliBridge.workingDir;③ 若本回合只是通用问答、不需要任何目录,传 allow_unattended: true(会在一个临时目录里跑,该目录是它的读写边界,不碰你的 home 或会话工作区)。"
6010
6082
  };
6011
- if (!existsSync(chosen)) return {
6012
- ok: false,
6013
- dir: "",
6014
- unattended: false,
6015
- reason: `working_dir "${chosen}" 不存在。`
6016
- };
6017
- let isDir = false;
6018
- try {
6019
- isDir = statSync(chosen).isDirectory();
6020
- } catch (error) {
6021
- return {
6022
- ok: false,
6023
- dir: "",
6024
- unattended: false,
6025
- reason: `working_dir "${chosen}" 无法读取:${error instanceof Error ? error.message : String(error)}`
6026
- };
6027
- }
6028
- if (!isDir) return {
6083
+ const rejection = dirRejection(chosen);
6084
+ if (rejection !== "" && !fromArgument && chosen === primarySessionDir && fallbackSessionDir !== "" && dirRejection(fallbackSessionDir) === "") chosen = fallbackSessionDir;
6085
+ else if (rejection !== "") return {
6029
6086
  ok: false,
6030
6087
  dir: "",
6031
6088
  unattended: false,
6032
- reason: `working_dir "${chosen}" 不是一个目录。`
6089
+ reason: !fromArgument && chosen === primarySessionDir && fallbackSessionDir !== "" && dirRejection(fallbackSessionDir) !== "" ? `${rejection}(回退的会话目录 "${fallbackSessionDir}" 同样不可用,工作目录投影与会话 header 都失效了。)` : rejection
6033
6090
  };
6034
6091
  const roots = [];
6035
- if (sessionDir !== "") roots.push(sessionDir);
6092
+ if (primarySessionDir !== "") roots.push(primarySessionDir);
6093
+ if (fallbackSessionDir !== "") roots.push(fallbackSessionDir);
6036
6094
  if (options.workingDir !== "") roots.push(options.workingDir);
6037
6095
  roots.push(...options.allowedDirs);
6038
6096
  if (!roots.some((root) => root !== "" && isInside(root, chosen))) {
@@ -6249,7 +6307,7 @@ async function runAsk(args, execution, deps, entrance = "tool") {
6249
6307
  ok: false,
6250
6308
  error: "message 不能为空:要说清楚这一轮让 CodeBuddy 做什么。"
6251
6309
  });
6252
- const dir = resolveWorkingDir(args, options, execution);
6310
+ const dir = resolveWorkingDir(args, options, execution, deps.directoryOf);
6253
6311
  if (!dir.ok) return result({
6254
6312
  ok: false,
6255
6313
  error: dir.reason
@@ -6497,6 +6555,20 @@ function formatCommandOutcome(value, extra = {}) {
6497
6555
  return head + denied + footer + truncated + delivery;
6498
6556
  }
6499
6557
  /**
6558
+ * The command's slice of the invocation the tool's resolver needs.
6559
+ *
6560
+ * The command's agent IS a live agent (the Host's command service passes the
6561
+ * whole thing, not a narrowed subset), so it satisfies `BridgeToolExecution`
6562
+ * structurally. The `signal` field is carried so the shared resolver sees the
6563
+ * same object shape it sees from a tool call.
6564
+ */
6565
+ function sessionDirOfCommand(invocation, directoryOf) {
6566
+ return sessionDirOf({
6567
+ ...invocation.agent !== void 0 ? { agent: invocation.agent } : {},
6568
+ ...invocation.signal !== void 0 ? { signal: invocation.signal } : {}
6569
+ }, directoryOf);
6570
+ }
6571
+ /**
6500
6572
  * Register `/codebuddy`, returning the disposer.
6501
6573
  *
6502
6574
  * `commands` is passed in rather than looked up so a Host without the service
@@ -6519,10 +6591,10 @@ function mountCodebuddyCommand(commands, deps) {
6519
6591
  description: "用 CodeBuddy 在当前工作区跑一轮;留空 = 评审未提交改动;以 --edit 开头 = 这一轮允许改文件。注意:默认(cliBridge.injectResult=true)跑完会把结论回注给本会话的模型,那一次唤醒会真的消耗 DSH token;设 injectResult=false 才完全不碰模型。CodeBuddy 那一侧的账号额度每次都会消耗。",
6520
6592
  input: { hint: "留空 = 评审未提交改动;写要求 = 只读执行(如\"看一下这个函数为什么崩\");要以 --edit 开头 = 允许改文件(如\"--edit 修一下这个空指针\")" },
6521
6593
  handler: async (invocation) => {
6522
- const cwd = invocation.agent?.session?.header?.cwd;
6523
- if (typeof cwd !== "string" || cwd.trim() === "") return {
6594
+ const cwd = sessionDirOfCommand(invocation, deps.directoryOf);
6595
+ if (cwd === "") return {
6524
6596
  kind: "error",
6525
- text: "这个会话没有工作区路径(session.header.cwd 为空),无法确定 CodeBuddy 该在哪里跑。请在一个打开过目录的会话里再试。"
6597
+ text: "这个会话没有工作区路径(会话的工作目录投影为空,回退的 session.header.cwd 也为空),无法确定 CodeBuddy 该在哪里跑。请在一个打开过目录的会话里再试。"
6526
6598
  };
6527
6599
  const raw = typeof invocation.rawInput === "string" ? invocation.rawInput : "";
6528
6600
  const parsed = parseCommandInput(raw);
@@ -6750,7 +6822,7 @@ function normalizeCliBridgeOptions(settings, onRejected) {
6750
6822
  * Returning `undefined` rather than an enabled flag keeps every caller honest:
6751
6823
  * there is no "mounted but disabled" state to accidentally treat as live.
6752
6824
  */
6753
- function createCliBridgeRuntime(settings, regions, log) {
6825
+ function createCliBridgeRuntime(settings, regions, log, directoryOf) {
6754
6826
  const options = normalizeCliBridgeOptions(settings, (message) => log(message));
6755
6827
  if (!options.enabled) return void 0;
6756
6828
  const live = createLiveBoard();
@@ -6761,7 +6833,8 @@ function createCliBridgeRuntime(settings, regions, log) {
6761
6833
  regions,
6762
6834
  log,
6763
6835
  gate: gateFor(options),
6764
- live
6836
+ live,
6837
+ ...directoryOf !== void 0 ? { directoryOf } : {}
6765
6838
  },
6766
6839
  live
6767
6840
  };
@@ -8528,6 +8601,26 @@ async function writePoolProbes(region, updates) {
8528
8601
  function safeMessage(error) {
8529
8602
  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);
8530
8603
  }
8604
+ /**
8605
+ * The upstream-identity switches the card may write through `__save`.
8606
+ *
8607
+ * These are TOP-LEVEL config fields (see the schema in `index.ts`), not a
8608
+ * nested block, so each is set on its own path and no merge base is involved.
8609
+ * The list is a constant here rather than derived from the schema because this
8610
+ * endpoint must stay a closed allow-list: deriving it would let an unrelated
8611
+ * schema addition quietly widen what the browser can write.
8612
+ *
8613
+ * Every entry is a switch the user can already set by hand in
8614
+ * `cordis.patch.yml`; this list only adds a UI route to the same fields (issue
8615
+ * #133 suggestions 2 and 3), so it can never expose anything the config file
8616
+ * does not already expose.
8617
+ */
8618
+ const UPSTREAM_SWITCH_FIELDS = /* @__PURE__ */ new Set([
8619
+ "usageIdentity",
8620
+ "keepPromptLast",
8621
+ "extraInjectedPrefixes",
8622
+ "sessionTitle"
8623
+ ]);
8531
8624
  /** A non-null, non-array object: the only shape the slot merge applies to. */
8532
8625
  function isPlainRecord(value) {
8533
8626
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -9373,7 +9466,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
9373
9466
  req.on("error", reject);
9374
9467
  });
9375
9468
  const field = body.field;
9376
- if (field !== "regions" && field !== "accounts" && field !== "cliBridge") return json(res, 400, { error: "field must be regions, accounts or cliBridge" });
9469
+ if (field !== "regions" && field !== "accounts" && field !== "cliBridge" && !(typeof field === "string" && UPSTREAM_SWITCH_FIELDS.has(field))) return json(res, 400, { error: "field must be regions, accounts, cliBridge, usageIdentity, keepPromptLast, extraInjectedPrefixes or sessionTitle" });
9377
9470
  const row = settings.describe().find((r) => String(r.ns).includes("workbuddy"));
9378
9471
  if (row === void 0) return json(res, 503, { error: "workbuddy namespace missing from describe()" });
9379
9472
  const current = field === "regions" ? regionsMapOf(row.value?.[field]) : unwrapVolatileDeep(row.value?.[field] ?? {});
@@ -9391,6 +9484,17 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
9391
9484
  value: mergedBridge
9392
9485
  });
9393
9486
  }
9487
+ if (UPSTREAM_SWITCH_FIELDS.has(field)) {
9488
+ await settings.mutate(row.ns, [{
9489
+ op: "set",
9490
+ path: [field],
9491
+ value: body.value
9492
+ }], void 0);
9493
+ return json(res, 200, {
9494
+ ok: true,
9495
+ value: { [field]: body.value }
9496
+ });
9497
+ }
9394
9498
  const merged = { ...current };
9395
9499
  for (const [key, slot] of Object.entries(incoming)) {
9396
9500
  const prior = current[key];
@@ -9746,7 +9850,12 @@ const Config = z.object({
9746
9850
  z.const("always")
9747
9851
  ]).default("failure").description("When to perturb scanned messages with invisible characters: \"off\" only rescues after a channel-gate refusal, \"failure\" (default) learns from a successful rescue and starts later requests perturbed, \"always\" starts every request with the dense rescue for a gateway whose plain attempt is guaranteed to fail.")),
9748
9852
  usageIdentity: asVolatile(z.boolean().default(false).description("Make the upstream's usage page record these calls like the official client's: add the X-IDE-* identity headers (which fill the 使用端 column) and send the conversation ids as 32-hex UUIDv7. Off by default — it is a display alignment measured by a reporter, not by this plugin, and the chat channel identity (User-Agent) and request body never change.")),
9749
- keepPromptLast: asVolatile(z.boolean().default(false).description("Move the harness-injected runtime context above the real prompt, so the WorkBuddy usage page records what you actually typed instead of the injected block. Message count and text are preserved; the one edit is the recorded prompt's own leading <acp> marker. Off by default because it changes the message order the upstream counts."))
9853
+ keepPromptLast: asVolatile(z.boolean().default(false).description("Move the harness-injected runtime context above the real prompt, so the WorkBuddy usage page records what you actually typed instead of the injected block. Message count and text are preserved; the one edit is the recorded prompt's own leading <acp> marker. Off by default because it changes the message order the upstream counts.")),
9854
+ extraInjectedPrefixes: asVolatile(z.array(z.string()).default([]).description("Extra literal prefixes (issue #133) treated like the harness-injected context markers when keepPromptLast is on — for blocks a companion plugin injects that the built-in list does not recognize. Each entry matches the start of a trailing user message; read only while keepPromptLast is enabled. Sampled when the plugin mounts: reload the plugin (or restart the host) after changing it.")),
9855
+ sessionTitle: asVolatile(z.object({
9856
+ enabled: z.boolean().default(false),
9857
+ model: z.string().default("wb-lite")
9858
+ }).default({}).description("Move the host session-title request onto a hidden lightweight model (issues #132/#133). The host fires one real upstream request per new session either way; enabled re-points it at `model` (default wb-lite, measured 0 credits by the reporter) instead of billing the main paid model. Request volume still counts, and the lightweight model is reporter-measured, not reproduced here. Sampled when the plugin mounts: reload the plugin (or restart the host) after changing it."))
9750
9859
  });
9751
9860
  /**
9752
9861
  * One region's saved model state. A config written before the region split has
@@ -9909,6 +10018,8 @@ function apply(ctx, config) {
9909
10018
  logger: ctx.logger,
9910
10019
  channelPerturbation: unwrapVolatile(config.channelPerturbation) ?? "failure",
9911
10020
  keepPromptLast: unwrapVolatile(config.keepPromptLast) === true,
10021
+ extraInjectedPrefixes: unwrapVolatile(config.extraInjectedPrefixes) ?? [],
10022
+ sessionTitle: unwrapVolatile(config.sessionTitle) ?? {},
9912
10023
  /**
9913
10024
  * Pool routing, bound to THIS region.
9914
10025
  *
@@ -10259,6 +10370,8 @@ function apply(ctx, config) {
10259
10370
  });
10260
10371
  let cliTools;
10261
10372
  let cliCommands;
10373
+ let cliDirectoryOf;
10374
+ const cliDirectoryProbe = (session) => cliDirectoryOf?.(session) ?? null;
10262
10375
  let cliRuntime;
10263
10376
  let cliRuntimeKey = "";
10264
10377
  let disposeCliTool;
@@ -10279,7 +10392,7 @@ function apply(ctx, config) {
10279
10392
  cliRuntime = createCliBridgeRuntime(settings, REGION_KEYS.map((region) => ({
10280
10393
  region,
10281
10394
  store: stacks[region].store
10282
- })), (message) => ctx.logger.warn(`dsh-connect-workbuddy: ${message}`));
10395
+ })), (message) => ctx.logger.warn(`dsh-connect-workbuddy: ${message}`), cliDirectoryProbe);
10283
10396
  }
10284
10397
  if (cliRuntime === void 0) {
10285
10398
  disposeCliSurfaces();
@@ -10308,6 +10421,42 @@ function apply(ctx, config) {
10308
10421
  cliCommands = void 0;
10309
10422
  });
10310
10423
  });
10424
+ ctx.inject(["sessionProjections"], (projectionCtx) => {
10425
+ const reader = projectionCtx.sessionProjections;
10426
+ if (reader === void 0 || typeof reader.stateOf !== "function") return;
10427
+ /**
10428
+ * The one place the projection key is spelled out.
10429
+ *
10430
+ * It must match the Host's own registration — key
10431
+ * `"workingDirectory"`, stateVersion 1, in the Host's
10432
+ * @deepseek-ai/dsh-working-directory package. If the Host renames the key, this is
10433
+ * the line that has to follow, and the comment is what tells the next reader
10434
+ * where to look.
10435
+ *
10436
+ * Returns null for `"no committed directory"`, which is the same signal the header
10437
+ * path produces when it has nothing — so the ladder below it behaves identically.
10438
+ * NOTE what it deliberately does NOT do: fall back to the service's own default.
10439
+ * The Host's get() answers stateOf(...) ?? defaultDirectory, and that fallback is
10440
+ * the Host process's cwd, i.e. a directory nobody chose. Substituting it here would
10441
+ * quietly turn `"we do not know"` into `"use this one"`, which is the one change this
10442
+ * whole refactor must not make.
10443
+ */
10444
+ const WORKING_DIRECTORY_PROJECTION_KEY = "workingDirectory";
10445
+ cliDirectoryOf = (session) => {
10446
+ let state;
10447
+ try {
10448
+ state = reader.stateOf(session, WORKING_DIRECTORY_PROJECTION_KEY);
10449
+ } catch {
10450
+ return null;
10451
+ }
10452
+ if (typeof state !== "string") return null;
10453
+ const trimmed = state.trim();
10454
+ return trimmed === "" ? null : trimmed;
10455
+ };
10456
+ projectionCtx.effect(() => () => {
10457
+ cliDirectoryOf = void 0;
10458
+ });
10459
+ });
10311
10460
  ctx.inject(["webServer"], (webCtx) => registerWorkBuddyStatusRoute(webCtx, {
10312
10461
  store: (region) => stacks[region].store,
10313
10462
  client,
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-workbuddy",
3
3
  "displayName": "DSH Connect WorkBuddy",
4
4
  "description": "把本机登录的 WorkBuddy 模型(国内版与国际版)接入 DeepSeek Harness:每次刷新按上游目录自动读取每个模型的积分倍率,支持逐模型图片输入开关、多账号切换与账号池自动换号,并提供每日签到、卡片与输入框里的胶囊积分读数等只读积分概览。",
5
- "version": "5.8.1",
5
+ "version": "5.8.3",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",