dsh-connect-workbuddy 5.2.2 → 5.3.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,14 @@
1
- import { $ as regionOf, A as parseWorkBuddyAuth, At as workbuddyAppExecutableCandidates, B as forceNeutralHead, C as defaultDesktopAuthCandidates, D as isEncryptedCredentialError, Dt as macosNestedAppBundles, E as hasEncryptedCredentialFields, Et as macosBundleExecutable, F as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, G as knownRegionOf, H as isConversationIdGateBody, I as WorkBuddyCredentialRejectedError, J as parseCreditMultiplier, K as modelIdOfBody, L as WorkBuddyUpstreamClient, M as workbuddyOwnAuthPath, Mt as resolveDshHome, N as CONTEXT_OVERFLOW_MARKER, Nt as withFileLock, O as isVaultAccountId, Ot as openEncryptedField, P as CREDENTIAL_REJECTED_CODE, Pt as writeFileAtomic, Q as prepareChatBody, R as classifyUpstreamError, S as authFileName, St as findWorkbuddyAppExecutable, T as defaultDesktopAuthPath, Tt as isWorkbuddyBundle, U as isCredentialRejectedError, V as isContextTooLongBody, W as isFirstMessageGateBody, X as parseUpstreamModel, Y as parseReasoning, Z as perturbChannelFingerprints, _ as ENCRYPTED_CREDENTIAL_CODE, _t as WORKBUDDY_APP_EXECUTABLE_ENV, a as readHostHeartbeat, at as offDefaultFor, b as WorkBuddyCredentialStore, bt as deriveAtRestKeyId, c as WORKBUDDY_CONNECT_VERSION, ct as withEffectiveOff, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, dt as pickImageModel, et as attachBusyLevels, f as WorkBuddyCatalog, ft as parseQueueAnswer, g as withoutRouters, gt as nativeModalityOf, h as fallbackModelsFor, ht as imageDefaultFor, i as processStartTimeMs, it as lastRefusalFor, j as workbuddyAccountId, k as legacyWorkbuddyOwnAuthPath, kt as readAtRestKey, l as piAiRuntimeInfo, lt as DEFAULT_IMAGE_SIZE, m as deriveCatalog, mt as NATIVE_MODALITY_BY_MODEL_ID, n as clearHostHeartbeat, nt as REFUSES_OFF_MODEL_IDS, o as workbuddyHostHeartbeatPath, ot as offRefused, p as applyContextBudgets, pt as queueNoticeText, q as openingEvidenceOfBody, r as isHeartbeatProcessAlive, rt as defaultOffModelIds, s as writeHostHeartbeat, st as recordRefusal, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as busyDisplaySuffix, u as FALLBACK_WORKBUDDY_MODELS, ut as WorkBuddyImageError, v as WORKBUDDY_AUTH_FILENAME, vt as clearAtRestKeyCache, w as defaultDesktopAuthDirs, wt as isEncryptedFieldWrapper, x as WorkBuddyEncryptedCredentialError, xt as fetchAtRestKeyPayload, y as WORKBUDDY_AUTH_FILE_ENV, yt as deriveAtRestKey, z as declaredTools } from "./host-heartbeat-r1M_OXJI.js";
1
+ import { $ as regionOf, A as parseWorkBuddyAuth, At as workbuddyAppExecutableCandidates, B as forceNeutralHead, C as defaultDesktopAuthCandidates, D as isEncryptedCredentialError, Dt as macosNestedAppBundles, E as hasEncryptedCredentialFields, Et as macosBundleExecutable, F as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, G as knownRegionOf, H as isConversationIdGateBody, I as WorkBuddyCredentialRejectedError, J as parseCreditMultiplier, K as modelIdOfBody, L as WorkBuddyUpstreamClient, M as workbuddyOwnAuthPath, Mt as resolveDshHome, N as CONTEXT_OVERFLOW_MARKER, Nt as withFileLock, O as isVaultAccountId, Ot as openEncryptedField, P as CREDENTIAL_REJECTED_CODE, Pt as writeFileAtomic, Q as prepareChatBody, R as classifyUpstreamError, S as authFileName, St as findWorkbuddyAppExecutable, T as defaultDesktopAuthPath, Tt as isWorkbuddyBundle, U as isCredentialRejectedError, V as isContextTooLongBody, W as isFirstMessageGateBody, X as parseUpstreamModel, Y as parseReasoning, Z as perturbChannelFingerprints, _ as ENCRYPTED_CREDENTIAL_CODE, _t as WORKBUDDY_APP_EXECUTABLE_ENV, a as readHostHeartbeat, at as offDefaultFor, b as WorkBuddyCredentialStore, bt as deriveAtRestKeyId, c as WORKBUDDY_CONNECT_VERSION, ct as withEffectiveOff, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, dt as pickImageModel, et as attachBusyLevels, f as WorkBuddyCatalog, ft as parseQueueAnswer, g as withoutRouters, gt as nativeModalityOf, h as fallbackModelsFor, ht as imageDefaultFor, i as processStartTimeMs, it as lastRefusalFor, j as workbuddyAccountId, k as legacyWorkbuddyOwnAuthPath, kt as readAtRestKey, l as piAiRuntimeInfo, lt as DEFAULT_IMAGE_SIZE, m as deriveCatalog, mt as NATIVE_MODALITY_BY_MODEL_ID, n as clearHostHeartbeat, nt as REFUSES_OFF_MODEL_IDS, o as workbuddyHostHeartbeatPath, ot as offRefused, p as applyContextBudgets, pt as queueNoticeText, q as openingEvidenceOfBody, r as isHeartbeatProcessAlive, rt as defaultOffModelIds, s as writeHostHeartbeat, st as recordRefusal, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as busyDisplaySuffix, u as FALLBACK_WORKBUDDY_MODELS, ut as WorkBuddyImageError, v as WORKBUDDY_AUTH_FILENAME, vt as clearAtRestKeyCache, w as defaultDesktopAuthDirs, wt as isEncryptedFieldWrapper, x as WorkBuddyEncryptedCredentialError, xt as fetchAtRestKeyPayload, y as WORKBUDDY_AUTH_FILE_ENV, yt as deriveAtRestKey, z as declaredTools } from "./host-heartbeat-_EvGiAek.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
5
- import { tmpdir } from "node:os";
6
- import { join } from "node:path";
5
+ import { homedir, tmpdir } from "node:os";
6
+ import { isAbsolute, join, resolve, sep } from "node:path";
7
+ import { spawn } from "node:child_process";
8
+ import { existsSync, mkdirSync, readdirSync, realpathSync, statSync } from "node:fs";
7
9
  import { createAssistantMessageEventStream, createProvider, isContextOverflow } from "@earendil-works/pi-ai";
8
10
  import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
9
- import { ReasoningEffortId, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
11
+ import { ReasoningEffortId, createUserMessage, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
10
12
  import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
11
13
  import { pathToFileURL } from "node:url";
12
14
  import { createServer } from "node:http";
@@ -206,6 +208,16 @@ const WORKBUDDY_PROVIDERS = {
206
208
  */
207
209
  const WORKBUDDY_MODEL_IDENTITY = "workbuddy";
208
210
  /**
211
+ * The api string `dsh-llm-pi-ai` stamps on a replayed message it could not
212
+ * rebuild natively (`foreignAssistant()`), i.e. a message that went through the
213
+ * host's fallback path rather than this plugin's own request builder.
214
+ *
215
+ * It is the ONLY api value {@link alignAssistantIdentities} repairs, because it
216
+ * is the only one that proves the field is a host marker instead of a shape this
217
+ * plugin really produced.
218
+ */
219
+ const HOST_FOREIGN_API = "dsh-foreign";
220
+ /**
209
221
  * Registry keys an adapter serving `providerId` must publish so that every
210
222
  * stamp its models carry resolves inside its own region (issue #75).
211
223
  *
@@ -621,12 +633,33 @@ function transcript(context) {
621
633
  * or the destination changes: `baseUrl` comes from the model, not from the
622
634
  * message.
623
635
  *
624
- * Only `provider` is rewritten. `api` is left alone on purpose: a legacy
625
- * (`dsh-foreign`) message carries an api this plugin never sends, and claiming
626
- * `openai-completions` for it would assert a wire shape that message was never
627
- * built for. The `provider` mismatch is what decides the downgrade, and fixing
628
- * it is enough — `isSameModel` still requires the model id and api to match, so
629
- * a genuinely different model keeps degrading (covered by a test).
636
+ * `provider` alone is NOT enough, because the HOST drops the message to
637
+ * `api: 'dsh-foreign'` first. `dsh-llm-pi-ai`'s `replayedAssistant` rejects a
638
+ * replay whose recorded provider differs from `source.provider` and falls back
639
+ * to `foreignAssistant()`, which stamps `api: 'dsh-foreign'` and takes
640
+ * `provider` from `source.provider` — the ROUTE id. That is why the provider
641
+ * mismatch is not merely cosmetic: it also costs the message its native api, and
642
+ * `isSameModel` then fails on the SECOND comparison even after `provider` is
643
+ * repaired. Measured on a real 0.2.1-rc.2 global session, aligning `provider`
644
+ * only still flattened a 1514-character thinking block into 1514 characters of
645
+ * text; aligning both restored `thinking`.
646
+ *
647
+ * So `api` is repaired too, but ONLY from the one value that proves it is a
648
+ * host fallback and not this plugin's own wire format: `dsh-foreign`. Any other
649
+ * api is left exactly as it is.
650
+ *
651
+ * WHY THAT IS NOT FORGING A SIGNATURE. A `foreignAssistant()` message carries
652
+ * thinking blocks with NO `thinkingSignature` (`dsh-llm-pi-ai` builds them as
653
+ * `{type:'thinking', thinking}`), and pi-ai's request builder only emits
654
+ * `reasoning_content` when a signature exists
655
+ * (`api/openai-completions.js`, the `if (signature && isOpenAICompletionsReasoningField(...))`
656
+ * branch). The block is therefore OMITTED from the request, never replayed as
657
+ * signed reasoning — the same thing that happens today, minus the visible text.
658
+ * The message's own `api` field is not read by the request builder at all
659
+ * (grep: only `model.api` is), so this changes no wire shape.
660
+ *
661
+ * A genuinely different model still degrades: `isSameModel` continues to
662
+ * require the model id to match (covered by a test).
630
663
  *
631
664
  * Returns the same object when nothing changed, so the common case (an
632
665
  * already-normalized transcript) allocates nothing.
@@ -639,12 +672,14 @@ function alignAssistantIdentities(context, identity = WORKBUDDY_MODEL_IDENTITY)
639
672
  if (typeof message !== "object" || message === null) return message;
640
673
  const record = message;
641
674
  if (record["role"] !== "assistant") return message;
642
- if (record["provider"] === identity) return message;
675
+ const providerAligned = record["provider"] === identity;
676
+ const apiAligned = record["api"] !== HOST_FOREIGN_API;
677
+ if (providerAligned && apiAligned) return message;
643
678
  changed = true;
644
- return {
645
- ...record,
646
- provider: identity
647
- };
679
+ const aligned = { ...record };
680
+ if (!providerAligned) aligned["provider"] = identity;
681
+ if (!apiAligned) aligned["api"] = "openai-completions";
682
+ return aligned;
648
683
  });
649
684
  return changed ? {
650
685
  ...context,
@@ -3936,6 +3971,1663 @@ function registerWorkBuddyImageTool(tools, deps) {
3936
3971
  }
3937
3972
  }
3938
3973
  //#endregion
3974
+ //#region src/cli-bridge/redact.ts
3975
+ /**
3976
+ * Scrubbing CLI output before it can reach a conversation.
3977
+ *
3978
+ * WHY THIS IS NOT `redactUpstreamText`. That helper lives in `src/probe.ts` and
3979
+ * ends with `.slice(0, PROBE_MESSAGE_LIMIT)` — 300 characters, enough for a
3980
+ * probe failure body and far too little for a CodeBuddy reply. Reusing it here
3981
+ * would silently truncate every review to its first paragraph, so this module
3982
+ * carries its own implementation of the same two patterns plus the one thing
3983
+ * the probe route does not have: an explicit secret list.
3984
+ *
3985
+ * WHY THE SECRET LIST MATTERS MORE THAN THE PATTERNS. The bridge hands the CLI
3986
+ * its credential through `CODEBUDDY_AUTH_TOKEN`, which means the token is in
3987
+ * that child's environment and the child is capable of echoing it back (a stack
3988
+ * trace, a debug line, an error body). No regex knows what a WorkBuddy token
3989
+ * looks like from the outside; the only definition that cannot be wrong is
3990
+ * "replace the exact string I put in the environment". The patterns stay as a
3991
+ * second net for tokens this process never held (a nested service's token, an
3992
+ * `Authorization` header copied into an error).
3993
+ *
3994
+ * @module dsh-connect-workbuddy/cli-bridge/redact
3995
+ */
3996
+ /** Longest a redacted block may stay before it is treated as one blob. */
3997
+ const MAX_SECRET_LENGTH = 4096;
3998
+ const PATTERNS = [
3999
+ [/\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\b/gu, "[redacted token]"],
4000
+ [/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/giu, "Bearer [redacted]"],
4001
+ [/\b(?:sk|ak|pk)-[A-Za-z0-9_-]{12,}\b/gu, "[redacted key]"],
4002
+ [/(\b(?:access_token|refresh_token|api_key|apikey|auth_token|token|authorization)\b["']?\s*[=:]\s*["']?)[^"'\s,}&]{8,}/giu, "$1[redacted]"]
4003
+ ];
4004
+ /**
4005
+ * Remove token-shaped content from CLI text.
4006
+ *
4007
+ * `secrets` are exact strings to scrub first (case-sensitive, literal): the
4008
+ * credential this turn handed the child. Empty strings are ignored because
4009
+ * `String.prototype.split('')` would explode the text into characters.
4010
+ *
4011
+ * Never throws, never shortens the text. Length is the caller's business — a
4012
+ * reply that is still too long is truncated by the caller with a marker, not
4013
+ * here where the reason would be lost.
4014
+ */
4015
+ function redactCliText(text, secrets = []) {
4016
+ let out = text;
4017
+ for (const secret of secrets) {
4018
+ if (typeof secret !== "string") continue;
4019
+ if (secret.length < 8 || secret.length > MAX_SECRET_LENGTH) continue;
4020
+ out = out.split(secret).join("[redacted credential]");
4021
+ }
4022
+ for (const [pattern, replacement] of PATTERNS) {
4023
+ pattern.lastIndex = 0;
4024
+ out = out.replace(pattern, replacement);
4025
+ }
4026
+ return out;
4027
+ }
4028
+ //#endregion
4029
+ //#region src/cli-bridge/inject.ts
4030
+ /**
4031
+ * Handing a finished CLI turn back to the session's model.
4032
+ *
4033
+ * WHY THIS EXISTS. `/codebuddy` runs in the Host: the model never sees the line,
4034
+ * never sees CodeBuddy's answer, and — worse — never learns that files in its own
4035
+ * workspace may have just changed. A model that believes it is looking at an
4036
+ * untouched tree will reason confidently about code that is no longer there. So
4037
+ * after a successful turn the outcome is queued for the model as ONE message
4038
+ * that names CodeBuddy and the directory it ran in (see `composeContinuation`).
4039
+ *
4040
+ * WHY `inject` AND WHY SOMETIMES `steer`. DSH's public `Agent.inject(message)`
4041
+ * queues model-facing context for the next pre-step **without waking the
4042
+ * driver** — which is exactly right when a turn is already running (the message
4043
+ * lands at the next step boundary, no interruption) and exactly wrong when the
4044
+ * session is idle (the message would sit in the inbox forever and the command
4045
+ * would look like it silently did nothing). The phase split is therefore:
4046
+ * running → `inject()`, idle → `steer()` (same call, one argument different:
4047
+ * it wakes the driver and starts the single turn that reads this message).
4048
+ *
4049
+ * "Accepted by the driver" is NOT "the model has read it": a cancellation or a
4050
+ * disposal can still discard pending context. The outcome says which of the two
4051
+ * happened and never claims more.
4052
+ *
4053
+ * @module dsh-connect-workbuddy/cli-bridge/inject
4054
+ */
4055
+ /**
4056
+ * A second scrubbing pass on the way into the transcript.
4057
+ *
4058
+ * `runAgent` already scrubbed what it returned, so why twice? Because this is the
4059
+ * last point before the text becomes a permanent message in the session history,
4060
+ * and the path from the CLI to here goes through a result object that other code
4061
+ * (now or later) can populate. Scrubbing at the boundary that matters is cheap;
4062
+ * discovering a token in a saved transcript is not.
4063
+ */
4064
+ function redactInjected(text) {
4065
+ return redactCliText(text);
4066
+ }
4067
+ /** Bound on one injected message. A reply can be long; a runaway one is a bug. */
4068
+ const INJECT_TEXT_LIMIT = 2e5;
4069
+ /**
4070
+ * The `source.kind` stamped on every message this module writes.
4071
+ *
4072
+ * The Host's `MessageSource` is a CLOSED union — `model | user | tool |
4073
+ * system-prompt` — and only `user` is available for a message that must be read
4074
+ * as an incoming turn: `tool` requires a `callId` from a real tool call this
4075
+ * plugin did not make, `model` claims a provider produced it, `system-prompt`
4076
+ * would put it in the system slot. So the tag cannot say "a plugin wrote this",
4077
+ * and pretending otherwise would mean shipping a type that lies.
4078
+ *
4079
+ * WHAT COMPENSATES FOR IT: the message opens with `PROVENANCE_LINE`, a bracketed
4080
+ * line that names the plugin and the command. A human scanning the transcript
4081
+ * can tell an injected result from something they typed, and so can the model —
4082
+ * which matters because a model that mistakes it for a human instruction will
4083
+ * act on it as one.
4084
+ */
4085
+ const INJECT_SOURCE_KIND = "user";
4086
+ /** The first line of every injected message. Not decoration: it is the only
4087
+ * provenance signal the closed `source` union leaves available. */
4088
+ const PROVENANCE_LINE = "[dsh-connect-workbuddy · 由 /codebuddy 自动回注,不是用户输入]";
4089
+ /**
4090
+ * An instruction appended so a model does not turn the result into a loop.
4091
+ *
4092
+ * The first draft ended with 「据此继续」, which is an instruction to keep going —
4093
+ * and the obvious way to "keep going" after a CodeBuddy review is to ask
4094
+ * CodeBuddy again. Each lap spends another account quota. The wording below
4095
+ * closes that door explicitly: read the result, use it, and do not re-run the
4096
+ * same bridge to chase it.
4097
+ */
4098
+ const NO_LOOP_NOTE = "这条结论已经完成,不要为了\"再确认一次\"而重复调用 codebuddy_ask 或 /codebuddy —— 每一次调用都会真实消耗一份 CodeBuddy 额度。只有出现新的、不重复的问题时才再发起一轮。";
4099
+ /** Every refusal this module can produce, by name. */
4100
+ const REFUSALS = {
4101
+ "agent-not-live": "拿不到这个会话的 agent,结论没有交给模型",
4102
+ "text-required": "这一轮没有可交给模型的输出",
4103
+ "text-too-large": `输出超过 ${String(INJECT_TEXT_LIMIT)} 字符,没有交给模型`,
4104
+ "inject-failed": "把结论交给模型时出错"
4105
+ };
4106
+ /** A named refusal — never a silent no-op (the failure has to be sayable). */
4107
+ function refuse(code, detail) {
4108
+ const base = REFUSALS[code];
4109
+ return {
4110
+ ok: false,
4111
+ code,
4112
+ error: detail !== void 0 && detail !== "" ? `${base}:${detail}` : base
4113
+ };
4114
+ }
4115
+ /**
4116
+ * The message handed to the model after a CodeBuddy turn.
4117
+ *
4118
+ * Two things it must carry and one it must not claim. It carries WHERE the turn
4119
+ * ran and WHAT it was asked to do (without them the model reads a conclusion it
4120
+ * cannot place), and it says the turn may have changed files — that is the whole
4121
+ * reason a model needs to hear about it. It does NOT claim the human asked the
4122
+ * model to do anything: the command was human-to-CodeBuddy, so "continue your
4123
+ * original task" would be a fiction. The model is told to use the result if it
4124
+ * bears on the work and to stay quiet otherwise.
4125
+ */
4126
+ function composeContinuation(reply, meta = {}) {
4127
+ const dir = typeof meta.dir === "string" && meta.dir.trim() !== "" ? meta.dir.trim() : "";
4128
+ const scope = typeof meta.scope === "string" && meta.scope.trim() !== "" ? meta.scope.trim() : "";
4129
+ const tools = Array.isArray(meta.tools) ? meta.tools : [];
4130
+ const toolList = tools.length > 0 ? tools.map((tool) => tool.count > 1 ? `${tool.name}×${String(tool.count)}` : tool.name).join("、") : "无";
4131
+ const toolCalls = typeof meta.toolCalls === "number" ? meta.toolCalls : 0;
4132
+ return [
4133
+ PROVENANCE_LINE,
4134
+ "CodeBuddy(另一个独立的 AI 编程助手,跑在本机真实的 CodeBuddy CLI 上)刚完成一次任务。",
4135
+ dir === "" ? null : `工作目录:${dir}`,
4136
+ scope === "" ? null : `交给它的指令:${scope}`,
4137
+ "",
4138
+ "--- CodeBuddy 输出开始 ---",
4139
+ reply,
4140
+ "--- CodeBuddy 输出结束 ---",
4141
+ "",
4142
+ `这一轮它用了 ${String(toolCalls)} 次工具调用(${toolList}),可能已经改动了工作区里的文件。`,
4143
+ "如果这与当前任务有关,就用这些结论继续;如果无关,用一两句话向用户说明即可,不要重复它已经做过的事。",
4144
+ NO_LOOP_NOTE
4145
+ ].filter((line) => line !== null).join("\n");
4146
+ }
4147
+ /**
4148
+ * Deliver the "CodeBuddy finished, here is what it said" message to the agent.
4149
+ *
4150
+ * An ABSENT status is read as running — the conservative direction. A message
4151
+ * that parks is recoverable (the next turn picks it up); an extra model turn
4152
+ * costs money and cannot be taken back. Only a positive `'idle'` earns the wake.
4153
+ */
4154
+ function injectOutcome(agent, input) {
4155
+ if (agent === void 0) return refuse("agent-not-live");
4156
+ if (typeof input.reply !== "string" || input.reply.trim() === "") return refuse("text-required");
4157
+ if (input.reply.length > 2e5) return refuse("text-too-large");
4158
+ const scope = typeof input.scope === "string" ? input.scope : "";
4159
+ const text = composeContinuation(redactInjected(input.reply.trim()), {
4160
+ ...typeof input.dir === "string" ? { dir: input.dir } : {},
4161
+ ...scope !== "" ? { scope: redactInjected(scope) } : {},
4162
+ ...typeof input.toolCalls === "number" ? { toolCalls: input.toolCalls } : {},
4163
+ ...Array.isArray(input.tools) ? { tools: input.tools } : {}
4164
+ });
4165
+ try {
4166
+ const message = createUserMessage({
4167
+ content: [{
4168
+ type: "text",
4169
+ text
4170
+ }],
4171
+ source: { kind: INJECT_SOURCE_KIND }
4172
+ });
4173
+ const idle = agent.status === "idle";
4174
+ if (idle) agent.steer(message);
4175
+ else agent.inject(message);
4176
+ return {
4177
+ ok: true,
4178
+ delivered: idle ? "new-turn" : "next-step",
4179
+ woke: idle,
4180
+ messageId: String(message.id ?? "")
4181
+ };
4182
+ } catch (error) {
4183
+ return refuse("inject-failed", error instanceof Error ? error.message : String(error));
4184
+ }
4185
+ }
4186
+ /**
4187
+ * Render a delivery outcome as the one line a command result carries.
4188
+ *
4189
+ * The human pressed enter and must learn whether the model was told — "the
4190
+ * command worked" and "the model knows" are different facts, and conflating
4191
+ * them is how a silent failure goes unnoticed for a week.
4192
+ */
4193
+ function formatInjection(outcome) {
4194
+ if (!outcome.ok) return `[未能通知模型继续:${outcome.code} — ${outcome.error}]`;
4195
+ return outcome.woke ? "[已唤醒会话模型:CodeBuddy 的结论已交给它继续]" : "[结论已排队交给模型,随这一轮的下一个步骤或下一轮生效]";
4196
+ }
4197
+ //#endregion
4198
+ //#region src/cli-bridge/credentials.ts
4199
+ /** Environment variable that short-circuits the vault entirely. */
4200
+ const AUTH_TOKEN_ENV = "CODEBUDDY_AUTH_TOKEN";
4201
+ /**
4202
+ * Walk the vault in region order and return every usable token, best first.
4203
+ *
4204
+ * Expired tokens are kept, at the end of their region: `expiresAt` is a claim
4205
+ * the upstream does not maintain, and a token that "expired" yesterday is
4206
+ * routinely still accepted. Refusing it locally would turn a working sign-in
4207
+ * into "not signed in".
4208
+ *
4209
+ * @throws when nothing at all is available — the caller reports that as a turn
4210
+ * that never started, not as a CLI failure.
4211
+ */
4212
+ async function resolveCredentials(regions, regionOrder, log) {
4213
+ const fromEnv = process.env[AUTH_TOKEN_ENV];
4214
+ if (typeof fromEnv === "string" && fromEnv.trim() !== "") return [{
4215
+ token: fromEnv.trim(),
4216
+ source: AUTH_TOKEN_ENV,
4217
+ region: "env",
4218
+ expired: false
4219
+ }];
4220
+ const out = [];
4221
+ const ordered = [...regions].sort((left, right) => rank(regionOrder, left.region) - rank(regionOrder, right.region));
4222
+ for (const { region, store } of ordered) {
4223
+ let accounts;
4224
+ try {
4225
+ accounts = await store.accounts();
4226
+ } catch (error) {
4227
+ log(`reading the ${region} vault failed: ${error instanceof Error ? error.message : String(error)}`);
4228
+ continue;
4229
+ }
4230
+ const ranked = [...accounts].sort((left, right) => right.tokenExpiresAtMs - left.tokenExpiresAtMs);
4231
+ for (const account of ranked) {
4232
+ const credential = await store.credentialFor(account.id);
4233
+ if (credential === void 0 || credential.accessToken === "") continue;
4234
+ out.push({
4235
+ token: credential.accessToken,
4236
+ source: `${region} 账号 ${account.id.slice(0, 6)}`,
4237
+ region,
4238
+ expired: credential.expiresAtMs > 0 && credential.expiresAtMs <= Date.now()
4239
+ });
4240
+ }
4241
+ }
4242
+ if (out.length === 0) throw new Error(`没有可用的 WorkBuddy 登录凭据:插件配置里没读到你登录过的账号。先在 WorkBuddy / CodeBuddy 里登录,或设置环境变量 ${AUTH_TOKEN_ENV}。`);
4243
+ return out;
4244
+ }
4245
+ /** Position of a region in the preference list; unknown regions sort last. */
4246
+ function rank(order, region) {
4247
+ const index = order.indexOf(region);
4248
+ return index === -1 ? order.length : index;
4249
+ }
4250
+ //#endregion
4251
+ //#region src/cli-bridge/gate.ts
4252
+ /** Hour in milliseconds. */
4253
+ const WINDOW_MS = 36e5;
4254
+ /**
4255
+ * Tracks what has been spent in the last hour.
4256
+ *
4257
+ * Deliberately in-memory and per-process: a restart forgets the history, which
4258
+ * is the right failure direction (a restart cannot lock anyone out of their own
4259
+ * tool) and the honest scope for a plugin that cannot see other processes.
4260
+ */
4261
+ var TurnGate = class {
4262
+ limits;
4263
+ now;
4264
+ /** Session key → arrival timestamps inside the window, oldest first. */
4265
+ perSession = /* @__PURE__ */ new Map();
4266
+ /** Every arrival in the window, oldest first. */
4267
+ global = [];
4268
+ /** Session keys with a turn currently running. */
4269
+ running = /* @__PURE__ */ new Set();
4270
+ constructor(limits, now = Date.now) {
4271
+ this.limits = limits;
4272
+ this.now = now;
4273
+ }
4274
+ /** Drop everything older than the window. Called before every decision. */
4275
+ prune(at) {
4276
+ const cutoff = at - WINDOW_MS;
4277
+ while (this.global.length > 0 && (this.global[0] ?? 0) <= cutoff) this.global.shift();
4278
+ for (const [key, arrivals] of this.perSession) {
4279
+ while (arrivals.length > 0 && (arrivals[0] ?? 0) <= cutoff) arrivals.shift();
4280
+ if (arrivals.length === 0) this.perSession.delete(key);
4281
+ }
4282
+ }
4283
+ /** Whether a turn is running in this session right now. */
4284
+ isBusy(sessionKey) {
4285
+ return this.running.has(sessionKey);
4286
+ }
4287
+ /** How many turns this session has started inside the window. */
4288
+ spentBySession(sessionKey) {
4289
+ this.prune(this.now());
4290
+ return this.perSession.get(sessionKey)?.length ?? 0;
4291
+ }
4292
+ /** How many turns this process has started inside the window. */
4293
+ spentTotal() {
4294
+ this.prune(this.now());
4295
+ return this.global.length;
4296
+ }
4297
+ /**
4298
+ * Ask to start a turn.
4299
+ *
4300
+ * On success the caller OWNS the slot until `release()` runs; on refusal
4301
+ * nothing was charged and nothing needs releasing.
4302
+ */
4303
+ tryEnter(sessionKey, onRefused) {
4304
+ const at = this.now();
4305
+ this.prune(at);
4306
+ if (!this.limits.allowConcurrentPerSession && this.running.has(sessionKey)) return {
4307
+ ok: false,
4308
+ code: "busy",
4309
+ error: "这个会话已有一个 CodeBuddy 回合在跑,等它结束再发一次。"
4310
+ };
4311
+ const { perSessionPerHour, globalPerHour } = this.limits;
4312
+ if (perSessionPerHour > 0) {
4313
+ const spent = this.perSession.get(sessionKey)?.length ?? 0;
4314
+ if (spent >= perSessionPerHour) {
4315
+ const message = `本会话最近一小时已经跑了 ${String(spent)} 次 CodeBuddy(上限 ${String(perSessionPerHour)} 次/小时)。等窗口过去,或调高插件配置 cliBridge.maxTurnsPerSessionHour。`;
4316
+ onRefused(message);
4317
+ return {
4318
+ ok: false,
4319
+ code: "session-quota",
4320
+ error: message
4321
+ };
4322
+ }
4323
+ }
4324
+ if (globalPerHour > 0 && this.global.length >= globalPerHour) {
4325
+ const message = `最近一小时全场已经跑了 ${String(this.global.length)} 次 CodeBuddy(上限 ${String(globalPerHour)} 次/小时)。等窗口过去,或调高插件配置 cliBridge.maxTurnsPerHour。`;
4326
+ onRefused(message);
4327
+ return {
4328
+ ok: false,
4329
+ code: "global-quota",
4330
+ error: message
4331
+ };
4332
+ }
4333
+ const arrivals = this.perSession.get(sessionKey) ?? [];
4334
+ arrivals.push(at);
4335
+ this.perSession.set(sessionKey, arrivals);
4336
+ this.global.push(at);
4337
+ this.running.add(sessionKey);
4338
+ let released = false;
4339
+ return {
4340
+ ok: true,
4341
+ release: () => {
4342
+ if (released) return;
4343
+ released = true;
4344
+ this.running.delete(sessionKey);
4345
+ }
4346
+ };
4347
+ }
4348
+ /** Forget every counter. Used by the mount's disposer. */
4349
+ clear() {
4350
+ this.perSession.clear();
4351
+ this.global.length = 0;
4352
+ this.running.clear();
4353
+ }
4354
+ };
4355
+ //#endregion
4356
+ //#region src/cli-bridge/runner.ts
4357
+ /**
4358
+ * One bridged CodeBuddy turn, and the credential walk around it.
4359
+ *
4360
+ * WHAT THIS ROUTE ACTUALLY ESCAPES — the honest version. The provider route has
4361
+ * to look like a CodeBuddy client to get past the gateway's channel check (code
4362
+ * 11128), which is an arms race the upstream fights with zero-width-space
4363
+ * perturbation. This route does not impersonate a client at all: the process
4364
+ * that talks to the gateway really is the CodeBuddy CLI. **That is a CLIENT
4365
+ * FINGERPRINT win, not an account win.** The credential this module hands the
4366
+ * CLI comes from the very same local vault the provider route uses, so an
4367
+ * account that the upstream restricted (code 11140, 「账号存在异常行为」) is
4368
+ * restricted on this route too. Do not describe it as a way around a ban.
4369
+ *
4370
+ * THE INPUT PROTOCOL, which is less obvious than it looks. `stream-json` is not
4371
+ * a one-way pipe. The CLI asks the driver for permission over the SAME channel
4372
+ * (`{"type":"control_request","request":{"subtype":"can_use_tool",…}}`) and
4373
+ * waits for a reply; if stdin was closed after the prompt was written, nobody
4374
+ * answers and in `plan` mode the process waits forever. So stdin stays OPEN
4375
+ * until the turn ends, every permission request is denied, and stdin is only
4376
+ * closed once a `result` frame arrives. The denial carries `interrupt: true`
4377
+ * ONLY for `ExitPlanMode`: without it `plan` hangs even after a denial, and
4378
+ * with it a denied Bash aborts the whole turn instead of letting the CLI pick a
4379
+ * different tool. Both directions were measured. See `denyPermission`.
4380
+ *
4381
+ * Two more decisions are load-bearing and easy to undo by accident:
4382
+ *
4383
+ * 1. **The prompt travels on stdin, never on argv**, and neither does anything
4384
+ * else user-controlled: `--model` and `--append-system-prompt` come from the
4385
+ * plugin config, and going through `cmd.exe` would let `&` / `|` / `>` in
4386
+ * them become command separators.
4387
+ *
4388
+ * 2. **There is no shell, and no PATH fallback.** The CLI entry is always run as
4389
+ * `node <entry>`, so no `.cmd` shim and no `shell: true` exists anywhere in
4390
+ * this module. If the package cannot be located, the turn is refused and the
4391
+ * user is told to set `cliBridge.binary` — because the alternative is
4392
+ * spawning whatever `codebuddy` happens to be first on PATH, and that process
4393
+ * would inherit a WorkBuddy token.
4394
+ *
4395
+ * @module dsh-connect-workbuddy/cli-bridge/runner
4396
+ */
4397
+ /** Package that owns the CLI, under a global `node_modules`. */
4398
+ const CLI_PACKAGE_PATH = join("@tencent-ai", "codebuddy-code", "bin", "codebuddy");
4399
+ /** A single NDJSON line bigger than this is a broken build, not a big answer. */
4400
+ const MAX_FRAME_BYTES = 8388608;
4401
+ /** How much assistant text is accumulated before the turn is failed. */
4402
+ const MAX_ASSISTANT_CHARS = 2e6;
4403
+ /** How long the CLI gets to exit on its own after a `result` frame. */
4404
+ const EXIT_GRACE_MS = 3e3;
4405
+ /**
4406
+ * The reason sent with every denial.
4407
+ *
4408
+ * It must NOT start with one of `SYNTHETIC_DENY_PREFIXES`: the CLI treats those
4409
+ * as a cancelled prompt rather than a decision and lets the tool proceed, which
4410
+ * would turn "deny everything" into "allow everything" while looking correct.
4411
+ */
4412
+ const DENY_REASON = "dsh-connect-workbuddy runs this turn unattended; no human is at the terminal to approve tool use";
4413
+ /**
4414
+ * Roots a global npm install of the CLI can live under, most likely first.
4415
+ *
4416
+ * WHY THE LIST IS THIS LONG. The first draft knew only about `%APPDATA%\npm`
4417
+ * and the two `/usr/*` prefixes, so every nvm, fnm, volta, pnpm, yarn, brew,
4418
+ * `~/.local` and `/opt/homebrew` user got "CLI not found" even though it was
4419
+ * installed. The version-manager roots cannot be listed statically (they carry
4420
+ * a version or a hash in the path), so `versionedCliEntries` globs one level.
4421
+ *
4422
+ * None of these are probed through PATH, and `cbc` is not a separate target: it
4423
+ * is an alias of the same `bin/codebuddy` file inside the package.
4424
+ */
4425
+ function cliEntryRoots() {
4426
+ const home = homedir();
4427
+ const roots = [];
4428
+ const appData = process.env["APPDATA"];
4429
+ if (typeof appData === "string" && appData !== "") roots.push(join(appData, "npm"));
4430
+ roots.push(join(home, ".npm-global"), "/usr/local", "/opt/homebrew", join(home, ".local"));
4431
+ roots.push(join(home, "AppData", "Roaming", "npm"));
4432
+ roots.push(join(home, ".local", "share", "pnpm", "global", "5"), join(home, ".config", "yarn", "global"));
4433
+ roots.push(join(home, ".volta", "tools", "image"));
4434
+ return roots.map((root) => join(root, "lib", "node_modules"));
4435
+ }
4436
+ /** Directories whose children are node version roots (`.../versions/node/<v>`). */
4437
+ function versionedCliRoots() {
4438
+ const home = homedir();
4439
+ return [
4440
+ join(home, ".nvm", "versions", "node"),
4441
+ join(home, "n", "versions", "node"),
4442
+ join(home, ".fnm", "node-versions"),
4443
+ join(home, ".local", "share", "fnm", "node-versions")
4444
+ ];
4445
+ }
4446
+ /** Expand one directory's children into CLI entries, ignoring unreadable ones. */
4447
+ function childrenOf(dir, separator) {
4448
+ const out = [];
4449
+ try {
4450
+ for (const child of readdirSync(dir)) out.push(join(dir, child, ...separator));
4451
+ } catch {}
4452
+ return out;
4453
+ }
4454
+ /** Every path a global install of the CLI could live at, most likely first. */
4455
+ function cliEntryCandidates() {
4456
+ const out = [...cliEntryRoots().map((root) => join(root, CLI_PACKAGE_PATH))];
4457
+ for (const versions of versionedCliRoots()) {
4458
+ out.push(...childrenOf(versions, [
4459
+ "lib",
4460
+ "node_modules",
4461
+ CLI_PACKAGE_PATH
4462
+ ]));
4463
+ out.push(...childrenOf(join(versions), [
4464
+ "installation",
4465
+ "lib",
4466
+ "node_modules",
4467
+ CLI_PACKAGE_PATH
4468
+ ]));
4469
+ }
4470
+ return out;
4471
+ }
4472
+ /**
4473
+ * Locate the CLI's JS entry.
4474
+ *
4475
+ * An explicit `binary` from config wins and is taken literally — never
4476
+ * second-guessed with a fallback, because a typo answered by a different
4477
+ * working executable is a bug nobody can reproduce. A `.cmd`/`.bat` path is
4478
+ * REJECTED rather than executed, because running one means going through a
4479
+ * shell, and that is the exact injection surface this module exists to avoid.
4480
+ */
4481
+ function resolveCliEntry(binary) {
4482
+ if (binary !== "") {
4483
+ if (process.platform === "win32" && /\.(?:cmd|bat)$/iu.test(binary)) return {
4484
+ ok: false,
4485
+ entry: "",
4486
+ configured: true,
4487
+ reason: `cliBridge.binary 指向了 ${binary},那是一个批处理包装,运行它要经过 cmd.exe,会把配置里的 --model / --append-system-prompt 当成命令解释。请改成同一个包里的 JS 入口,例如 ${join(binary.replace(/\.(?:cmd|bat)$/iu, ""), "..", "node_modules", CLI_PACKAGE_PATH)}。`
4488
+ };
4489
+ if (!existsSync(binary)) return {
4490
+ ok: false,
4491
+ entry: "",
4492
+ configured: true,
4493
+ reason: `cliBridge.binary 指定的路径不存在:${binary}`
4494
+ };
4495
+ return {
4496
+ ok: true,
4497
+ entry: binary,
4498
+ configured: true,
4499
+ reason: ""
4500
+ };
4501
+ }
4502
+ for (const candidate of cliEntryCandidates()) if (existsSync(candidate)) return {
4503
+ ok: true,
4504
+ entry: candidate,
4505
+ configured: false,
4506
+ reason: ""
4507
+ };
4508
+ return {
4509
+ ok: false,
4510
+ entry: "",
4511
+ configured: false,
4512
+ reason: "没有找到本机安装的 CodeBuddy CLI(已查过 npm/pnpm/yarn/volta 全局目录与 nvm/fnm 的版本目录)。请先安装 @tencent-ai/codebuddy-code,或在插件配置里用 cliBridge.binary 指定它的绝对路径(要指向包内的 bin/codebuddy,不要指向 .cmd 包装)。不会回退到 PATH 上的 codebuddy —— 那样会把 WorkBuddy 凭据交给一个来源不明的程序。"
4513
+ };
4514
+ }
4515
+ /**
4516
+ * Whether a finished-but-failed run failed specifically because the credential
4517
+ * was refused.
4518
+ *
4519
+ * ONLY the CLI's own error channel is read. The first draft also scanned the
4520
+ * assistant text, which meant a review that merely QUOTED `HTTP 401` looked like
4521
+ * a rejected token: the bridge then discarded a perfectly good turn and re-ran
4522
+ * the whole thing on the next account. `text` is deliberately not a parameter
4523
+ * here so that cannot come back.
4524
+ */
4525
+ function looksLikeAuthFailure(out) {
4526
+ if (!out.isError) return false;
4527
+ const channel = `${out.errors.join("\n")}\n${out.subtype ?? ""}`;
4528
+ return /\b401\b|Authentication required|\/login command|invalid[_-]?api[_-]?key/i.test(channel);
4529
+ }
4530
+ function blocksOf(frame) {
4531
+ const fromMessage = frame.message?.content;
4532
+ if (Array.isArray(fromMessage)) return fromMessage;
4533
+ const topLevel = frame.content;
4534
+ if (Array.isArray(topLevel)) return topLevel;
4535
+ return [];
4536
+ }
4537
+ /** Whether `pid` may be used as a process-GROUP id for `process.kill(-pid, …)`. */
4538
+ function usableGroupPid(pid) {
4539
+ return typeof pid === "number" && Number.isInteger(pid) && pid > 1;
4540
+ }
4541
+ /** Kill a child AND everything it spawned (git, npm, a test runner). */
4542
+ function killTree(child) {
4543
+ const pid = child.pid;
4544
+ if (!usableGroupPid(pid)) {
4545
+ try {
4546
+ child.kill();
4547
+ } catch {}
4548
+ return;
4549
+ }
4550
+ if (process.platform === "win32") {
4551
+ try {
4552
+ spawn("taskkill", [
4553
+ "/pid",
4554
+ String(pid),
4555
+ "/T",
4556
+ "/F"
4557
+ ], {
4558
+ windowsHide: true,
4559
+ stdio: "ignore"
4560
+ }).on("error", () => {});
4561
+ } catch {}
4562
+ try {
4563
+ child.kill();
4564
+ } catch {}
4565
+ return;
4566
+ }
4567
+ try {
4568
+ process.kill(-pid, "SIGTERM");
4569
+ } catch {
4570
+ try {
4571
+ child.kill("SIGTERM");
4572
+ } catch {
4573
+ return;
4574
+ }
4575
+ }
4576
+ setTimeout(() => {
4577
+ try {
4578
+ process.kill(-pid, "SIGKILL");
4579
+ } catch {}
4580
+ }, 1e3).unref();
4581
+ }
4582
+ /** Run exactly one CLI turn with exactly one token. */
4583
+ function runOneTurn(options, token) {
4584
+ const entryChoice = resolveCliEntry(options.binary);
4585
+ if (!entryChoice.ok) return Promise.reject(new Error(entryChoice.reason));
4586
+ const args = [
4587
+ entryChoice.entry,
4588
+ "-p",
4589
+ "--input-format",
4590
+ "stream-json",
4591
+ "--output-format",
4592
+ "stream-json",
4593
+ "--verbose",
4594
+ "--max-turns",
4595
+ String(options.maxTurns),
4596
+ "--permission-mode",
4597
+ options.permissionMode
4598
+ ];
4599
+ if (options.model !== "") args.push("--model", options.model);
4600
+ if (options.effort !== "") args.push("--effort", options.effort);
4601
+ if (options.appendSystemPrompt !== "") args.push("--append-system-prompt", options.appendSystemPrompt);
4602
+ if (options.sessionId !== void 0 && options.sessionId !== "") args.push("--session-id", options.sessionId);
4603
+ if (options.continueSession === true) args.push("--continue");
4604
+ const env = {
4605
+ ...process.env,
4606
+ CODEBUDDY_AUTH_TOKEN: token,
4607
+ CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS: "1"
4608
+ };
4609
+ return new Promise((resolve, reject) => {
4610
+ const child = spawn(process.execPath, args, {
4611
+ cwd: options.cwd,
4612
+ env,
4613
+ shell: false,
4614
+ stdio: [
4615
+ "pipe",
4616
+ "pipe",
4617
+ "pipe"
4618
+ ],
4619
+ windowsHide: true,
4620
+ detached: process.platform !== "win32"
4621
+ });
4622
+ let settled = false;
4623
+ const assistantText = [];
4624
+ let assistantChars = 0;
4625
+ const toolCounts = /* @__PURE__ */ new Map();
4626
+ const deniedTools = [];
4627
+ let stderr = "";
4628
+ let buffer = "";
4629
+ let timer;
4630
+ let graceTimer;
4631
+ const redact = (text) => redactCliText(text, [token]);
4632
+ const cleanup = () => {
4633
+ if (timer !== void 0) clearTimeout(timer);
4634
+ if (options.signal !== void 0) options.signal.removeEventListener("abort", onAbort);
4635
+ };
4636
+ const closeStdin = () => {
4637
+ try {
4638
+ child.stdin.end();
4639
+ } catch {}
4640
+ };
4641
+ /** After a settle, give the CLI a moment to exit; then take the tree down. */
4642
+ const scheduleReaper = () => {
4643
+ graceTimer = setTimeout(() => {
4644
+ killTree(child);
4645
+ }, EXIT_GRACE_MS);
4646
+ graceTimer.unref();
4647
+ };
4648
+ const fail = (error) => {
4649
+ if (settled) return;
4650
+ settled = true;
4651
+ cleanup();
4652
+ closeStdin();
4653
+ killTree(child);
4654
+ reject(error);
4655
+ };
4656
+ const onAbort = () => {
4657
+ fail(/* @__PURE__ */ new Error("cancelled by the host"));
4658
+ };
4659
+ const settle = (frame) => {
4660
+ if (settled) return;
4661
+ settled = true;
4662
+ cleanup();
4663
+ closeStdin();
4664
+ scheduleReaper();
4665
+ const errors = Array.isArray(frame.errors) ? frame.errors.filter((value) => typeof value === "string").map(redact) : [];
4666
+ resolve({
4667
+ text: redact(assistantText.join("\n").trim()),
4668
+ toolCounts,
4669
+ deniedTools,
4670
+ sessionId: typeof frame.session_id === "string" ? frame.session_id : void 0,
4671
+ usage: frame.usage,
4672
+ numTurns: typeof frame.num_turns === "number" ? frame.num_turns : void 0,
4673
+ isError: frame.is_error === true,
4674
+ errors,
4675
+ subtype: typeof frame.subtype === "string" ? frame.subtype : void 0,
4676
+ stderr: redact(stderr)
4677
+ });
4678
+ };
4679
+ /**
4680
+ * Answer one permission request. Always a denial; see `DENY_REASON`.
4681
+ *
4682
+ * `interrupt` IS PER-TOOL, NOT A BLANKET FLAG. Measured against the real CLI:
4683
+ *
4684
+ * - `plan` asks for approval of `ExitPlanMode`. A plain denial there is
4685
+ * answered by waiting for yet another decision and the process never ends
4686
+ * (>70s, until `timeoutSecs`). `interrupt: true` ends it immediately.
4687
+ * - Every other tool (Bash, Write, …) is fine to deny WITHOUT `interrupt`:
4688
+ * the CLI records the denial and tries a different tool, so the turn still
4689
+ * produces a normal reply. Adding `interrupt: true` there aborts the whole
4690
+ * turn instead — measured: `acceptEdits` + Bash denial → `is_error: true`,
4691
+ * empty reply, `Permission denied for tool(s): Bash`, 4.2s.
4692
+ *
4693
+ * So `interrupt` rides along only when the request is `ExitPlanMode`. That
4694
+ * keeps `plan` from hanging without turning every blocked Bash into a failed
4695
+ * turn.
4696
+ */
4697
+ const denyPermission = (frame) => {
4698
+ const toolName = typeof frame.request?.tool_name === "string" ? frame.request.tool_name : "";
4699
+ if (toolName !== "") deniedTools.push(toolName);
4700
+ const requestId = typeof frame.request_id === "string" ? frame.request_id : "";
4701
+ if (requestId === "") return;
4702
+ const mustInterrupt = toolName === "ExitPlanMode";
4703
+ try {
4704
+ child.stdin.write(`${JSON.stringify({
4705
+ type: "control_response",
4706
+ response: {
4707
+ subtype: "success",
4708
+ request_id: requestId,
4709
+ response: {
4710
+ allowed: false,
4711
+ reason: DENY_REASON,
4712
+ ...mustInterrupt ? { interrupt: true } : {}
4713
+ }
4714
+ }
4715
+ })}\n`);
4716
+ } catch {}
4717
+ };
4718
+ if (options.signal !== void 0) {
4719
+ if (options.signal.aborted) {
4720
+ fail(/* @__PURE__ */ new Error("cancelled by the host"));
4721
+ return;
4722
+ }
4723
+ options.signal.addEventListener("abort", onAbort, { once: true });
4724
+ }
4725
+ timer = setTimeout(() => {
4726
+ fail(/* @__PURE__ */ new Error(`codebuddy exceeded its ${Math.round(options.timeoutMs / 1e3)}s budget`));
4727
+ }, options.timeoutMs);
4728
+ child.stdin.on("error", () => {});
4729
+ child.stdin.write(`${JSON.stringify({
4730
+ type: "user",
4731
+ message: {
4732
+ role: "user",
4733
+ content: [{
4734
+ type: "text",
4735
+ text: options.prompt
4736
+ }]
4737
+ }
4738
+ })}\n`);
4739
+ child.stdout.setEncoding("utf8");
4740
+ child.stdout.on("data", (chunk) => {
4741
+ if (settled) return;
4742
+ buffer += chunk;
4743
+ if (buffer.length > 8388608) {
4744
+ fail(/* @__PURE__ */ new Error(`codebuddy emitted a single line longer than ${String(MAX_FRAME_BYTES)} bytes; the CLI build is probably broken`));
4745
+ return;
4746
+ }
4747
+ const lines = buffer.split("\n");
4748
+ buffer = lines.pop() ?? "";
4749
+ for (const line of lines) {
4750
+ const trimmed = line.trim();
4751
+ if (trimmed === "") continue;
4752
+ let frame;
4753
+ try {
4754
+ frame = JSON.parse(trimmed);
4755
+ } catch {
4756
+ continue;
4757
+ }
4758
+ if (frame.type === "control_request") {
4759
+ denyPermission(frame);
4760
+ continue;
4761
+ }
4762
+ if (frame.type === "assistant") {
4763
+ for (const block of blocksOf(frame)) if (block.type === "text" && typeof block.text === "string") {
4764
+ assistantChars += block.text.length;
4765
+ if (assistantChars > 2e6) {
4766
+ fail(/* @__PURE__ */ new Error(`codebuddy produced more than ${String(MAX_ASSISTANT_CHARS)} characters of assistant text in one turn`));
4767
+ return;
4768
+ }
4769
+ assistantText.push(block.text);
4770
+ } else if (block.type === "tool_use" && typeof block.name === "string") toolCounts.set(block.name, (toolCounts.get(block.name) ?? 0) + 1);
4771
+ }
4772
+ if (frame.type === "result") {
4773
+ settle(frame);
4774
+ return;
4775
+ }
4776
+ }
4777
+ });
4778
+ child.stderr.setEncoding("utf8");
4779
+ child.stderr.on("data", (chunk) => {
4780
+ stderr += chunk;
4781
+ if (stderr.length > 32768) stderr = stderr.slice(-16384);
4782
+ });
4783
+ child.on("error", (error) => fail(error));
4784
+ child.on("close", (code) => {
4785
+ if (settled) return;
4786
+ const tail = redact(stderr.trim().split("\n").slice(-6).join("\n"));
4787
+ fail(/* @__PURE__ */ new Error(`codebuddy exited with code ${String(code)} and emitted no result frame` + (tail !== "" ? `\n${tail}` : "")));
4788
+ });
4789
+ });
4790
+ }
4791
+ /**
4792
+ * Run one bridged turn, walking the credential list until a turn completes or
4793
+ * the list runs out.
4794
+ *
4795
+ * WHEN IT MOVES ON, AND WHEN IT STOPS. A wrong-region token 401s, and the only
4796
+ * way to know which region this CLI build targets is to ask it — one wasted turn
4797
+ * is cheaper than making the user choose a region they cannot know. But ONLY a
4798
+ * recognised credential failure is worth another account. The first draft also
4799
+ * continued after timeouts, crashes and ENOENT, which with ten vault accounts
4800
+ * meant a single bad call could spend 10 × 900s; those errors are identical for
4801
+ * every account, so they are raised on the first attempt.
4802
+ */
4803
+ async function runAgent(options, credentials, log) {
4804
+ const startedAt = Date.now();
4805
+ let lastError;
4806
+ for (let index = 0; index < credentials.length; index += 1) {
4807
+ const candidate = credentials[index];
4808
+ if (candidate === void 0) continue;
4809
+ if (candidate.expired) log(`${candidate.source} is past its expiry; trying it anyway`);
4810
+ const out = await runOneTurn(options, candidate.token);
4811
+ const isLast = index === credentials.length - 1;
4812
+ if (looksLikeAuthFailure(out) && !isLast) {
4813
+ log(`${candidate.source} was refused (401); trying the next credential`);
4814
+ lastError = new Error(out.errors[0] ?? "401");
4815
+ continue;
4816
+ }
4817
+ return {
4818
+ ok: !out.isError,
4819
+ text: out.text,
4820
+ toolCounts: out.toolCounts,
4821
+ deniedTools: out.deniedTools,
4822
+ sessionId: out.sessionId,
4823
+ usage: out.usage,
4824
+ numTurns: out.numTurns,
4825
+ credential: candidate,
4826
+ elapsedMs: Date.now() - startedAt,
4827
+ error: out.isError ? out.errors.join("\n") || out.subtype || "the turn reported an error" : void 0
4828
+ };
4829
+ }
4830
+ throw lastError ?? /* @__PURE__ */ new Error("no credential worked");
4831
+ }
4832
+ //#endregion
4833
+ //#region src/cli-bridge/tool.ts
4834
+ /**
4835
+ * `codebuddy_ask` — the model-side entrance to the CLI route.
4836
+ *
4837
+ * WHAT THIS ROUTE DOES AND DOES NOT ESCAPE. The provider route has to look like
4838
+ * a CodeBuddy client to get past the gateway's channel check, which is a
4839
+ * fingerprint arms race. Here the process that talks to the gateway really is
4840
+ * the CodeBuddy CLI, so there is no client to impersonate. But the credential is
4841
+ * the same local vault account, so this is a CLIENT-FINGERPRINT win only: an
4842
+ * account restricted upstream (code 11140) is restricted here too. See the
4843
+ * module comment in `./runner.ts` for the long version.
4844
+ *
4845
+ * THE PRICE, stated plainly: a bridged turn is a SEPARATE agent. It does not see
4846
+ * this session's conversation, so it re-explores the workspace from scratch. That
4847
+ * is fine for "review this module" and wrong for "continue what we were doing" —
4848
+ * which is why the tool takes a self-contained `message`.
4849
+ *
4850
+ * THE DIRECTORY RULE IS THE IMPORTANT PART OF THIS FILE. A bridged turn spawns
4851
+ * an agent that can edit files, and the directory it runs in is that agent's
4852
+ * whole world. So the directory is never taken on faith: it must exist, be a
4853
+ * directory, and resolve to somewhere this plugin is allowed to point a
4854
+ * file-editing agent at. A model that asks for `C:\Users\...\.ssh` gets a
4855
+ * refusal that says why, not a turn.
4856
+ *
4857
+ * @module dsh-connect-workbuddy/cli-bridge/tool
4858
+ */
4859
+ /** Permission modes the CLI accepts for `--permission-mode`. */
4860
+ const PERMISSION_MODES = [
4861
+ "default",
4862
+ "acceptEdits",
4863
+ "bypassPermissions",
4864
+ "plan",
4865
+ "dontAsk",
4866
+ "auto"
4867
+ ];
4868
+ /** Reasoning levels the CLI accepts for `--effort`. */
4869
+ const EFFORT_LEVELS = [
4870
+ "minimal",
4871
+ "low",
4872
+ "medium",
4873
+ "high",
4874
+ "xhigh",
4875
+ "max"
4876
+ ];
4877
+ /** Hard ceiling on one turn's wall clock, in seconds. */
4878
+ const TIMEOUT_SECS_CAP = 1800;
4879
+ /** Hard ceiling on the reply length, in characters. */
4880
+ const MAX_REPLY_CHARS_CAP = 4e5;
4881
+ /** Every field defaulted, so the Host's closed schema always validates. */
4882
+ function result(partial) {
4883
+ return {
4884
+ ok: partial.ok === true,
4885
+ reply: partial.reply ?? "",
4886
+ session_id: partial.session_id ?? "",
4887
+ tools: partial.tools ?? [],
4888
+ denied_tools: partial.denied_tools ?? [],
4889
+ tool_calls: partial.tool_calls ?? 0,
4890
+ num_turns: partial.num_turns ?? 0,
4891
+ tokens_in: partial.tokens_in ?? 0,
4892
+ tokens_out: partial.tokens_out ?? 0,
4893
+ cache_read_tokens: partial.cache_read_tokens ?? 0,
4894
+ credential_region: partial.credential_region ?? "",
4895
+ working_dir: partial.working_dir ?? "",
4896
+ unattended: partial.unattended === true,
4897
+ elapsed_ms: partial.elapsed_ms ?? 0,
4898
+ truncated: partial.truncated === true,
4899
+ error: partial.error ?? ""
4900
+ };
4901
+ }
4902
+ /** Clamp an optional numeric argument to a config default and a hard ceiling. */
4903
+ function clampInt$1(value, fallback, cap) {
4904
+ return Math.max(1, Math.min(cap, typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : fallback));
4905
+ }
4906
+ /** The throwaway directory used when the caller explicitly allows no workspace. */
4907
+ function scratchDir() {
4908
+ const dir = join(tmpdir(), "dsh-codebuddy-scratch");
4909
+ mkdirSync(dir, { recursive: true });
4910
+ return dir;
4911
+ }
4912
+ /** Case-fold a path on the platforms whose filesystems ignore case. */
4913
+ function foldCase(path) {
4914
+ return process.platform === "win32" || process.platform === "darwin" ? path.toLowerCase() : path;
4915
+ }
4916
+ /** Resolve symlinks when we can; fall back to the lexical path when we cannot. */
4917
+ function canonical(path) {
4918
+ try {
4919
+ return realpathSync(path);
4920
+ } catch {
4921
+ return resolve(path);
4922
+ }
4923
+ }
4924
+ /** True when `child` is `parent` itself or lives underneath it. */
4925
+ function isInside(parent, child) {
4926
+ const outer = foldCase(canonical(parent));
4927
+ const inner = foldCase(canonical(child));
4928
+ if (inner === outer) return true;
4929
+ return inner.startsWith(outer.endsWith(sep) ? outer : outer + sep);
4930
+ }
4931
+ /** The session directory this tool call came from, when the Host supplies one. */
4932
+ function sessionDirOf(execution) {
4933
+ const cwd = execution?.agent?.session?.header?.cwd;
4934
+ if (typeof cwd !== "string") return "";
4935
+ const trimmed = cwd.trim();
4936
+ return trimmed !== "" && isAbsolute(trimmed) ? trimmed : "";
4937
+ }
4938
+ /**
4939
+ * Decide the directory one turn runs in.
4940
+ *
4941
+ * THE ORDER IS A LADDER, and the last rung is a refusal rather than a guess.
4942
+ * Explicit argument → the calling agent's session cwd → the configured default →
4943
+ * an explicitly allowed scratch dir → refuse.
4944
+ *
4945
+ * THE LADDER IS NOT THE SAFETY PROPERTY; the check after it is. Whatever rung
4946
+ * wins, the directory must (a) already exist, (b) be a directory, and (c) resolve
4947
+ * inside an allowed root — the session it was called from, the configured
4948
+ * default, or one of `allowedDirs`. That third clause is what stops a model from
4949
+ * asking for `/` or `~/.ssh`: those exist and are directories, so existence
4950
+ * alone would happily accept them.
4951
+ *
4952
+ * A scratch turn is exempt by construction: it is a fresh empty temp directory
4953
+ * the bridge just created, so there is nothing in it to leak.
4954
+ */
4955
+ function resolveWorkingDir(args, options, execution) {
4956
+ const sessionDir = sessionDirOf(execution);
4957
+ const explicit = typeof args["working_dir"] === "string" ? args["working_dir"].trim() : "";
4958
+ let chosen = "";
4959
+ let fromArgument = false;
4960
+ if (explicit !== "") {
4961
+ if (!isAbsolute(explicit)) return {
4962
+ ok: false,
4963
+ dir: "",
4964
+ unattended: false,
4965
+ reason: `working_dir "${explicit}" 不是绝对路径。请传一个绝对路径(宿主的工具调用不携带会话 cwd)。`
4966
+ };
4967
+ chosen = explicit;
4968
+ fromArgument = true;
4969
+ } else if (sessionDir !== "") chosen = sessionDir;
4970
+ else if (options.workingDir !== "") chosen = options.workingDir;
4971
+ else if (args["allow_unattended"] === true || options.allowUnattended) return {
4972
+ ok: true,
4973
+ dir: scratchDir(),
4974
+ unattended: true,
4975
+ reason: ""
4976
+ };
4977
+ else return {
4978
+ ok: false,
4979
+ dir: "",
4980
+ unattended: false,
4981
+ reason: "no working directory。宿主目前不把会话 cwd 传给插件工具,所以这个值必须由调用方给:① 传 working_dir 指定一个已存在的绝对路径目录;② 在插件配置里设 cliBridge.workingDir;③ 若本回合只是通用问答、不需要任何目录,传 allow_unattended: true(会在一个临时目录里跑,该目录是它的读写边界,不碰你的 home 或会话工作区)。"
4982
+ };
4983
+ if (!existsSync(chosen)) return {
4984
+ ok: false,
4985
+ dir: "",
4986
+ unattended: false,
4987
+ reason: `working_dir "${chosen}" 不存在。`
4988
+ };
4989
+ let isDir = false;
4990
+ try {
4991
+ isDir = statSync(chosen).isDirectory();
4992
+ } catch (error) {
4993
+ return {
4994
+ ok: false,
4995
+ dir: "",
4996
+ unattended: false,
4997
+ reason: `working_dir "${chosen}" 无法读取:${error instanceof Error ? error.message : String(error)}`
4998
+ };
4999
+ }
5000
+ if (!isDir) return {
5001
+ ok: false,
5002
+ dir: "",
5003
+ unattended: false,
5004
+ reason: `working_dir "${chosen}" 不是一个目录。`
5005
+ };
5006
+ const roots = [];
5007
+ if (sessionDir !== "") roots.push(sessionDir);
5008
+ if (options.workingDir !== "") roots.push(options.workingDir);
5009
+ roots.push(...options.allowedDirs);
5010
+ if (!roots.some((root) => root !== "" && isInside(root, chosen))) {
5011
+ const listed = roots.filter((root) => root !== "");
5012
+ return {
5013
+ ok: false,
5014
+ dir: "",
5015
+ unattended: false,
5016
+ reason: `working_dir "${chosen}" 不在本回合允许的范围内。` + (fromArgument ? "工具不能把可写文件的 agent 指向任意目录——请从下面这些根里选一个(或子目录),要放开别的目录得在插件配置 cliBridge.allowedDirs 里显式列出。" : "") + (listed.length > 0 ? `允许的根:${listed.join(" / ")}。` : "当前没有任何允许的根:本会话没有 cwd,配置里也没有 cliBridge.workingDir 或 cliBridge.allowedDirs。")
5017
+ };
5018
+ }
5019
+ return {
5020
+ ok: true,
5021
+ dir: canonical(chosen),
5022
+ unattended: false,
5023
+ reason: ""
5024
+ };
5025
+ }
5026
+ /** The closed output schema the Host validates every result against. */
5027
+ const OUTPUT_SCHEMA = {
5028
+ type: "object",
5029
+ properties: {
5030
+ ok: {
5031
+ type: "boolean",
5032
+ description: "本回合是否正常完成"
5033
+ },
5034
+ reply: {
5035
+ type: "string",
5036
+ description: "CodeBuddy 的最终回复文本"
5037
+ },
5038
+ session_id: {
5039
+ type: "string",
5040
+ description: "本回合的 CLI 会话 id;空字符串表示未报"
5041
+ },
5042
+ tools: {
5043
+ type: "array",
5044
+ description: `本回合用过的工具及次数,最多 ${String(12)} 条(超出见 tool_calls,它是真实总数)`,
5045
+ items: {
5046
+ type: "object",
5047
+ properties: {
5048
+ name: { type: "string" },
5049
+ count: { type: "number" }
5050
+ },
5051
+ required: ["name", "count"],
5052
+ additionalProperties: false
5053
+ }
5054
+ },
5055
+ denied_tools: {
5056
+ type: "array",
5057
+ description: "本回合 CLI 请求过、但被本插件拒绝的工具。非空说明它想动手但没被允许",
5058
+ items: { type: "string" }
5059
+ },
5060
+ tool_calls: {
5061
+ type: "number",
5062
+ description: "本回合工具调用总次数(tools 数组可能被截断,这个数是真实的)"
5063
+ },
5064
+ num_turns: {
5065
+ type: "number",
5066
+ description: "CLI 自报的 agent 轮数"
5067
+ },
5068
+ tokens_in: {
5069
+ type: "number",
5070
+ description: "本回合输入 token 数(含缓存命中部分)"
5071
+ },
5072
+ tokens_out: {
5073
+ type: "number",
5074
+ description: "本回合输出 token 数"
5075
+ },
5076
+ cache_read_tokens: {
5077
+ type: "number",
5078
+ description: "输入里命中提示缓存的部分。它通常占 tokens_in 的绝大部分——名义输入看着很大,新增计费其实很小"
5079
+ },
5080
+ credential_region: {
5081
+ type: "string",
5082
+ description: "本次实际用的凭据区域(cn / global / env)"
5083
+ },
5084
+ working_dir: {
5085
+ type: "string",
5086
+ description: "本回合实际使用的工作目录(它的读写边界);空字符串表示本回合在拒绝前就结束了"
5087
+ },
5088
+ unattended: {
5089
+ type: "boolean",
5090
+ description: "本回合是否因为调用方传了 allow_unattended 而在临时目录里跑(工具可能在那里写文件)"
5091
+ },
5092
+ elapsed_ms: {
5093
+ type: "number",
5094
+ description: "本回合墙钟毫秒数"
5095
+ },
5096
+ truncated: {
5097
+ type: "boolean",
5098
+ description: "reply 超过上限被截断(截断处有标记);长内容应让它落盘再读"
5099
+ },
5100
+ error: {
5101
+ type: "string",
5102
+ description: "失败原因;空字符串表示没有错误"
5103
+ }
5104
+ },
5105
+ required: [
5106
+ "ok",
5107
+ "reply",
5108
+ "session_id",
5109
+ "tools",
5110
+ "denied_tools",
5111
+ "tool_calls",
5112
+ "num_turns",
5113
+ "tokens_in",
5114
+ "tokens_out",
5115
+ "cache_read_tokens",
5116
+ "credential_region",
5117
+ "working_dir",
5118
+ "unattended",
5119
+ "elapsed_ms",
5120
+ "truncated",
5121
+ "error"
5122
+ ],
5123
+ additionalProperties: false
5124
+ };
5125
+ /** The gate this call must pass, built once per normalized option set. */
5126
+ function gateFor(options) {
5127
+ return new TurnGate({
5128
+ perSessionPerHour: options.maxTurnsPerSessionHour,
5129
+ globalPerHour: options.maxTurnsPerHour,
5130
+ allowConcurrentPerSession: false
5131
+ });
5132
+ }
5133
+ /** The session key a call is charged to, from whichever handle it arrived on. */
5134
+ function sessionKeyOf(execution, fallback) {
5135
+ const id = execution?.agent?.id;
5136
+ return typeof id === "string" && id !== "" ? id : fallback;
5137
+ }
5138
+ /**
5139
+ * Run one bridged turn and return the complete result object.
5140
+ *
5141
+ * Exported because the `/codebuddy` slash command needs the same turn with the
5142
+ * same validation — a command that re-implemented this would drift from the tool
5143
+ * the first time either side gained a knob.
5144
+ */
5145
+ async function runAsk(args, execution, deps) {
5146
+ const startedAt = Date.now();
5147
+ const { options, regions, log } = deps;
5148
+ const mode = typeof args["permission_mode"] === "string" ? args["permission_mode"].trim() : "";
5149
+ if (mode !== "" && !PERMISSION_MODES.includes(mode)) return result({
5150
+ ok: false,
5151
+ error: `permission_mode "${mode}" 不是合法值;合法值:${PERMISSION_MODES.join(", ")}`
5152
+ });
5153
+ const effort = typeof args["effort"] === "string" ? args["effort"].trim() : "";
5154
+ if (effort !== "" && !EFFORT_LEVELS.includes(effort)) return result({
5155
+ ok: false,
5156
+ error: `effort "${effort}" 不是合法值;合法值:${EFFORT_LEVELS.join(", ")}`
5157
+ });
5158
+ const message = typeof args["message"] === "string" ? args["message"] : "";
5159
+ if (message.trim() === "") return result({
5160
+ ok: false,
5161
+ error: "message 不能为空:要说清楚这一轮让 CodeBuddy 做什么。"
5162
+ });
5163
+ const dir = resolveWorkingDir(args, options, execution);
5164
+ if (!dir.ok) return result({
5165
+ ok: false,
5166
+ error: dir.reason
5167
+ });
5168
+ const entry = (deps.gate ?? gateFor(options)).tryEnter(sessionKeyOf(execution, "codebuddy_ask"), (message) => log(message));
5169
+ if (!entry.ok) return result({
5170
+ ok: false,
5171
+ error: entry.error
5172
+ });
5173
+ try {
5174
+ let credentials;
5175
+ try {
5176
+ credentials = await resolveCredentials(regions, options.regionOrder, log);
5177
+ } catch (error) {
5178
+ return result({
5179
+ ok: false,
5180
+ error: error instanceof Error ? error.message : String(error)
5181
+ });
5182
+ }
5183
+ const modelArg = typeof args["model"] === "string" ? args["model"].trim() : "";
5184
+ const sessionIdArg = typeof args["session_id"] === "string" ? args["session_id"].trim() : "";
5185
+ const signal = execution?.signal;
5186
+ const outcome = await runAgent({
5187
+ prompt: message,
5188
+ cwd: dir.dir,
5189
+ binary: options.binary,
5190
+ model: modelArg !== "" ? modelArg : options.model,
5191
+ permissionMode: mode !== "" ? mode : options.permissionMode,
5192
+ effort: effort !== "" ? effort : options.effort,
5193
+ appendSystemPrompt: options.appendSystemPrompt,
5194
+ maxTurns: clampInt$1(args["max_turns"], options.maxTurns, 100),
5195
+ timeoutMs: clampInt$1(args["timeout_secs"], options.timeoutSecs, TIMEOUT_SECS_CAP) * 1e3,
5196
+ ...sessionIdArg !== "" ? { sessionId: sessionIdArg } : {},
5197
+ ...signal !== void 0 ? { signal } : {}
5198
+ }, credentials, log);
5199
+ let reply = outcome.text;
5200
+ let truncated = false;
5201
+ if (reply.length > options.maxReplyChars) {
5202
+ const omitted = reply.length - options.maxReplyChars;
5203
+ reply = `${reply.slice(0, options.maxReplyChars)}\n\n[truncated by dsh-connect-workbuddy: ${String(omitted)} more characters. Have CodeBuddy write the full text to a file and read it back.]`;
5204
+ truncated = true;
5205
+ }
5206
+ const tools = [...outcome.toolCounts.entries()].slice(0, 12).map(([name, count]) => ({
5207
+ name,
5208
+ count
5209
+ }));
5210
+ return result({
5211
+ ok: outcome.ok,
5212
+ reply,
5213
+ session_id: outcome.sessionId ?? "",
5214
+ tools,
5215
+ denied_tools: [...outcome.deniedTools],
5216
+ tool_calls: [...outcome.toolCounts.values()].reduce((sum, count) => sum + count, 0),
5217
+ num_turns: outcome.numTurns ?? 0,
5218
+ tokens_in: outcome.usage?.input_tokens ?? 0,
5219
+ tokens_out: outcome.usage?.output_tokens ?? 0,
5220
+ cache_read_tokens: outcome.usage?.cache_read_input_tokens ?? 0,
5221
+ credential_region: outcome.credential.region,
5222
+ working_dir: dir.dir,
5223
+ unattended: dir.unattended,
5224
+ elapsed_ms: Date.now() - startedAt,
5225
+ truncated,
5226
+ error: outcome.error ?? ""
5227
+ });
5228
+ } catch (error) {
5229
+ return result({
5230
+ ok: false,
5231
+ error: error instanceof Error ? error.message : String(error),
5232
+ elapsed_ms: Date.now() - startedAt
5233
+ });
5234
+ } finally {
5235
+ entry.release();
5236
+ }
5237
+ }
5238
+ /**
5239
+ * Register `codebuddy_ask`, returning the disposer.
5240
+ *
5241
+ * `tools` is passed in rather than looked up so a Host without the service
5242
+ * degrades to "no tool" instead of failing the mount.
5243
+ */
5244
+ function mountAskTool(tools, deps) {
5245
+ if (tools === void 0 || typeof tools.register !== "function") return () => {};
5246
+ try {
5247
+ return tools.register({
5248
+ name: "codebuddy_ask",
5249
+ description: `把一个独立的任务交给本机的 CodeBuddy CLI 跑一轮完整 agent(它自己读文件、改文件、跑命令),返回它的结论。适合"独立审一块代码 / 跑一轮调查 / 让另一个 agent 复核",不适合"接着我们刚才的思路改"——它看不到本会话的对话,会重新探索工作区。每次调用都会真实消耗一份 CodeBuddy 账号额度(一轮通常几万输入 token,多数命中缓存);本会话每小时上限 ${String(deps.options.maxTurnsPerSessionHour === 0 ? "不限" : deps.options.maxTurnsPerSessionHour)} 次、全场每小时上限 ${String(deps.options.maxTurnsPerHour === 0 ? "不限" : deps.options.maxTurnsPerHour)} 次,超了会被具名拒绝。默认只能在本会话的工作区(或在配置里显式列出的目录)里跑;要指定别处的目录得先把它加进 cliBridge.allowedDirs。`,
5250
+ timeoutMs: (deps.options.timeoutSecs + 30) * 1e3,
5251
+ parameters: {
5252
+ type: "object",
5253
+ properties: {
5254
+ message: {
5255
+ type: "string",
5256
+ description: "交给 CodeBuddy 的完整任务描述。它看不到本会话,所以要自包含:目标、范围、验收标准。"
5257
+ },
5258
+ working_dir: {
5259
+ type: "string",
5260
+ description: "本回合的工作目录(它的读写边界),必须是已存在的绝对路径,且落在本会话工作区或配置的 allowedDirs 之内。不传则用本会话的工作区。"
5261
+ },
5262
+ model: {
5263
+ type: "string",
5264
+ description: `可选:本回合模型 id,默认取插件配置(当前 ${deps.options.model === "" ? "CLI 自己的设置" : deps.options.model})。`
5265
+ },
5266
+ permission_mode: {
5267
+ type: "string",
5268
+ description: "可选:本回合权限模式。默认取插件配置(dontAsk = 只读工具放行、写工具被拒,且不会卡住等审批);plan = 只读,纯审查可用(注意 plan 在 -p 下会请求 ExitPlanMode 审批,被拒才结束);acceptEdits = 允许改文件;bypassPermissions = 全部放行。default / auto 也接受。传其它值会被拒绝并列出合法值。"
5269
+ },
5270
+ effort: {
5271
+ type: "string",
5272
+ description: `可选:本回合推理力度。合法值:${EFFORT_LEVELS.join(", ")}。不传取插件配置。`
5273
+ },
5274
+ max_turns: {
5275
+ type: "number",
5276
+ description: `可选:本回合 agent 轮数上限,默认取插件配置(当前 ${String(deps.options.maxTurns)}),上限 ${String(100)}。用尽会标记为截断,不当作成功。`
5277
+ },
5278
+ timeout_secs: {
5279
+ type: "number",
5280
+ description: `可选:本回合墙钟超时秒数,默认取插件配置(当前 ${String(deps.options.timeoutSecs)}),上限 ${String(TIMEOUT_SECS_CAP)};超时会被杀掉并报错。`
5281
+ },
5282
+ session_id: {
5283
+ type: "string",
5284
+ description: "可选:指定 CLI 会话 id,让后续调用接着同一个会话。不传则每次开新会话。"
5285
+ },
5286
+ allow_unattended: {
5287
+ type: "boolean",
5288
+ description: "可选:本回合不需要任何目录时设为 true,会在一个临时目录里跑(该目录是它的读写边界)。默认 false:一个能改文件的 agent 不该被指向没人选过的目录。"
5289
+ }
5290
+ },
5291
+ required: ["message"]
5292
+ },
5293
+ output: {
5294
+ schema: OUTPUT_SCHEMA,
5295
+ render(_args, value) {
5296
+ const typed = value;
5297
+ if (!typed.ok) return [{
5298
+ type: "text",
5299
+ text: `CodeBuddy 调用失败: ${typed.error === "" ? "(未给出原因)" : typed.error}`
5300
+ }];
5301
+ const lines = [
5302
+ "CodeBuddy 回复:",
5303
+ typed.reply === "" ? "(空回复)" : typed.reply,
5304
+ ""
5305
+ ];
5306
+ if (typed.truncated) lines.push("[注意:reply 已截断。让 CodeBuddy 把完整内容写到文件里再读,别只靠这一份摘要]", "");
5307
+ if (typed.denied_tools.length > 0) lines.push(`[注意:本回合 CodeBuddy 请求过这些工具但被拒绝:${typed.denied_tools.join(", ")}。如果任务本来就要求它们动手,说明默认权限模式(dontAsk)挡住了——明确传 permission_mode 再试]`, "");
5308
+ if (typed.unattended) lines.push(`[注意:本回合未指定 working_dir,按调用方的 allow_unattended 在临时目录里执行(${typed.working_dir})。它读不到你的仓库;要针对某个仓库请显式传 working_dir]`, "");
5309
+ const toolList = typed.tools.length > 0 ? typed.tools.map((tool) => tool.count > 1 ? `${tool.name}×${String(tool.count)}` : tool.name).join(", ") : "无";
5310
+ lines.push(`[session_id=${typed.session_id === "" ? "?" : typed.session_id} turns=${String(typed.num_turns)} tool_calls=${String(typed.tool_calls)} tools=${toolList} tokens=${String(typed.tokens_in)}in/${String(typed.tokens_out)}out (cache_read=${String(typed.cache_read_tokens)}) elapsed_ms=${String(typed.elapsed_ms)} cred=${typed.credential_region}` + (typed.working_dir !== "" ? ` dir=${typed.working_dir}` : "") + "]");
5311
+ return [{
5312
+ type: "text",
5313
+ text: lines.join("\n")
5314
+ }];
5315
+ }
5316
+ },
5317
+ async execute(args, execution) {
5318
+ return runAsk(typeof args === "object" && args !== null ? args : {}, execution, deps);
5319
+ }
5320
+ });
5321
+ } catch (error) {
5322
+ deps.log(`tool registration failed: ${error instanceof Error ? error.message : String(error)}`);
5323
+ return () => {};
5324
+ }
5325
+ }
5326
+ //#endregion
5327
+ //#region src/cli-bridge/command.ts
5328
+ /**
5329
+ * `/codebuddy` — one CodeBuddy turn started from the composer.
5330
+ *
5331
+ * WHY A COMMAND AND NOT JUST THE TOOL. `codebuddy_ask` only runs when the model
5332
+ * decides to call it, which costs a DSH turn and a decision. A slash command is
5333
+ * the one host channel that executes against the receiving agent WITHOUT sending
5334
+ * the line to the model, so the WORK costs no DSH turns.
5335
+ *
5336
+ * THE ONE THING IT DOES SPEND BY DEFAULT. On success the result is handed back to
5337
+ * the session's model so the conversation knows what happened, and that wake-up
5338
+ * is a real DSH turn with real tokens. The first draft's description claimed the
5339
+ * command "does not consume DSH quota", which is false under the default
5340
+ * `injectResult: true`; the description now says so. With `injectResult: false`
5341
+ * the promise holds: nothing but the human sees the result.
5342
+ *
5343
+ * WHAT AN EMPTY LINE MEANS. `/codebuddy` alone is a review: the built-in brief
5344
+ * asks CodeBuddy to read the uncommitted changes and report problems. Anything
5345
+ * the human types is sent verbatim instead.
5346
+ *
5347
+ * @module dsh-connect-workbuddy/cli-bridge/command
5348
+ */
5349
+ /** The command name as registered (no leading slash). */
5350
+ const COMMAND_NAME = "codebuddy";
5351
+ /**
5352
+ * The mode a review runs in.
5353
+ *
5354
+ * NOT `plan`. `plan` under `-p` answers a write attempt by asking the driver to
5355
+ * approve an `ExitPlanMode` hand-off, and the only answer an unattended bridge
5356
+ * can give is a denial — so a "review" in plan mode tends to end as a failed
5357
+ * turn with no findings, which is the least useful possible outcome. `dontAsk`
5358
+ * gets the read half (Read/Glob/Grep/Bash) auto-approved and refuses the write
5359
+ * half, which is exactly what a review needs. Measured both ways; see the module
5360
+ * comment in `./runner.ts`.
5361
+ */
5362
+ const REVIEW_PERMISSION_MODE = "dontAsk";
5363
+ /**
5364
+ * The brief an empty `/codebuddy` line expands to.
5365
+ *
5366
+ * It names the scope AND the failure mode it wants avoided ("只报你确实看见的
5367
+ * 问题"): a review that guesses is worse than one that says less, because the
5368
+ * human cannot tell the two apart in the output.
5369
+ */
5370
+ const REVIEW_BRIEF = "评审当前工作区里未提交的改动(git 未提交改动;没有未提交改动时看最近一次提交)。自己运行 git 命令确认范围,必要时读相关文件理解上下文,然后给出评审结论:哪些地方有问题、为什么、怎么改。只报你确实看见的问题,不要猜。";
5371
+ /**
5372
+ * What the composer line becomes.
5373
+ *
5374
+ * Empty → the review brief. Non-empty → the text verbatim, because a human who
5375
+ * typed something meant it, and silently prepending a review brief to "改一下
5376
+ * 这个 bug" is how a command does something other than what it was asked.
5377
+ */
5378
+ function composeCommandPrompt(raw) {
5379
+ const note = raw.trim();
5380
+ return note === "" ? REVIEW_BRIEF : note;
5381
+ }
5382
+ /** A one-line label saying how the human's words were used. */
5383
+ function commandScopeLabel(raw) {
5384
+ const note = raw.trim();
5385
+ return note === "" ? "(未提交改动)" : `(指令:${note})`;
5386
+ }
5387
+ /** Whether this invocation is the built-in review (empty line). */
5388
+ function isReviewInvocation(raw) {
5389
+ return raw.trim() === "";
5390
+ }
5391
+ /** The permission mode this invocation runs in. */
5392
+ function permissionModeForInvocation(raw) {
5393
+ return isReviewInvocation(raw) ? REVIEW_PERMISSION_MODE : "";
5394
+ }
5395
+ /**
5396
+ * Render one turn outcome as the plain text a command result carries.
5397
+ *
5398
+ * The footer is the same shape the tool renders, because a human reading a
5399
+ * command result wants the same facts: which session, how many turns, what it
5400
+ * cost, and where it ran. The one thing added here is the working directory —
5401
+ * a command result is the only place the human learns WHICH tree was reviewed.
5402
+ *
5403
+ * `injection` is the delivery outcome of handing the result to the session's
5404
+ * model. It is rendered on its own line because "the command ran" and "the model
5405
+ * was told" are different facts and the human is entitled to both; omitting it
5406
+ * means no delivery was attempted (failed turn, or disabled).
5407
+ */
5408
+ function formatCommandOutcome(value, extra = {}) {
5409
+ const scope = commandScopeLabel(extra.raw ?? "");
5410
+ if (!value.ok) return `CodeBuddy 失败${scope}:${value.error === "" ? "未知错误" : value.error}`;
5411
+ const body = value.reply.trim();
5412
+ const head = body === "" ? `CodeBuddy 完成${scope}:没有输出任何结论。` : `CodeBuddy 完成${scope}:\n\n${body}`;
5413
+ const toolList = value.tools.length > 0 ? value.tools.map((tool) => tool.count > 1 ? `${tool.name}×${String(tool.count)}` : tool.name).join(", ") : "无";
5414
+ const denied = value.denied_tools.length > 0 ? `\n\n[注意:CodeBuddy 请求过这些工具但被拒绝:${value.denied_tools.join(", ")}。评审类任务读到这些就该够了;要它真动手请写明要求并显式指定权限模式]` : "";
5415
+ const footer = `\n\n[session_id=${value.session_id === "" ? "?" : value.session_id} turns=${String(value.num_turns)} tool_calls=${String(value.tool_calls)} tools=${toolList} tokens=${String(value.tokens_in)}in/${String(value.tokens_out)}out (cache_read=${String(value.cache_read_tokens)}) elapsed_ms=${String(value.elapsed_ms)} cred=${value.credential_region}` + (value.working_dir !== "" ? ` dir=${value.working_dir}` : "") + "]";
5416
+ const truncated = value.truncated ? "\n\n[注意:输出已截断。让 CodeBuddy 把完整内容写到文件里再读,别只靠这一份摘要]" : "";
5417
+ const delivery = extra.injection === void 0 ? "" : `\n\n${formatInjection(extra.injection)}`;
5418
+ return head + denied + footer + truncated + delivery;
5419
+ }
5420
+ /**
5421
+ * Register `/codebuddy`, returning the disposer.
5422
+ *
5423
+ * `commands` is passed in rather than looked up so a Host without the service
5424
+ * degrades to "no command" instead of failing the mount — the command is an
5425
+ * addition, not a load-bearing part of the plugin.
5426
+ *
5427
+ * CONCURRENCY IS NOT TRACKED HERE ANY MORE. The first draft kept its own
5428
+ * `inFlight` set, which covered `/codebuddy` calls against each other and nothing
5429
+ * else: a tool call and a command could run two agents in the same workspace at
5430
+ * once, and neither counted against any budget. Both entrances now go through
5431
+ * `runAsk`, so the shared `TurnGate` in `./gate.ts` is the single place that
5432
+ * decides "one at a time per session" and "N per hour".
5433
+ */
5434
+ function mountCodebuddyCommand(commands, deps) {
5435
+ if (commands === void 0 || typeof commands.register !== "function") return () => {};
5436
+ const { options, log } = deps;
5437
+ try {
5438
+ return commands.register({
5439
+ name: COMMAND_NAME,
5440
+ description: "用 CodeBuddy 在当前工作区跑一轮;留空 = 评审未提交改动。注意:默认(cliBridge.injectResult=true)跑完会把结论回注给本会话的模型,那一次唤醒会真的消耗 DSH token;设 injectResult=false 才完全不碰模型。CodeBuddy 那一侧的账号额度每次都会消耗。",
5441
+ input: { hint: "可留空:直接评审当前工作区未提交的改动;或写你的要求(如\"修一下这个空指针\")" },
5442
+ handler: async (invocation) => {
5443
+ const cwd = invocation.agent?.session?.header?.cwd;
5444
+ if (typeof cwd !== "string" || cwd.trim() === "") return {
5445
+ kind: "error",
5446
+ text: "这个会话没有工作区路径(session.header.cwd 为空),无法确定 CodeBuddy 该在哪里跑。请在一个打开过目录的会话里再试。"
5447
+ };
5448
+ const raw = typeof invocation.rawInput === "string" ? invocation.rawInput : "";
5449
+ const message = composeCommandPrompt(raw);
5450
+ const review = isReviewInvocation(raw);
5451
+ const startedAt = Date.now();
5452
+ try {
5453
+ const value = await runAsk({
5454
+ message,
5455
+ working_dir: cwd,
5456
+ ...review ? { permission_mode: permissionModeForInvocation(raw) } : {}
5457
+ }, {
5458
+ ...invocation.agent !== void 0 ? { agent: invocation.agent } : {},
5459
+ ...invocation.signal !== void 0 ? { signal: invocation.signal } : {}
5460
+ }, deps);
5461
+ log(`command /${COMMAND_NAME} finished in ${String(Date.now() - startedAt)}ms (ok=${String(value.ok)})`);
5462
+ const injection = value.ok && options.injectResult ? injectOutcome(invocation.agent, {
5463
+ reply: value.reply,
5464
+ dir: cwd,
5465
+ scope: commandScopeLabel(raw),
5466
+ toolCalls: value.tool_calls,
5467
+ tools: value.tools
5468
+ }) : void 0;
5469
+ return {
5470
+ kind: value.ok ? "success" : "error",
5471
+ text: formatCommandOutcome(value, {
5472
+ raw,
5473
+ ...injection !== void 0 ? { injection } : {}
5474
+ })
5475
+ };
5476
+ } catch (error) {
5477
+ const text = error instanceof Error ? error.message : String(error);
5478
+ log(`command /${COMMAND_NAME} threw: ${text}`);
5479
+ return {
5480
+ kind: "error",
5481
+ text: `CodeBuddy 异常终止:${text}`
5482
+ };
5483
+ }
5484
+ }
5485
+ });
5486
+ } catch (error) {
5487
+ log(`command registration failed: ${error instanceof Error ? error.message : String(error)}`);
5488
+ return () => {};
5489
+ }
5490
+ }
5491
+ //#endregion
5492
+ //#region src/cli-bridge/mount.ts
5493
+ /**
5494
+ * Wiring the CLI route into the plugin, and normalizing its settings.
5495
+ *
5496
+ * THE ROUTE IS OPT-IN AND NON-BREAKING. It is `enabled: false` by default, and
5497
+ * its two host surfaces are mounted reactively by the caller (`ctx.inject`), not
5498
+ * sampled once at apply time. That combination is deliberate: this plugin is
5499
+ * installed by people whose only interest is the provider, and a new hard
5500
+ * dependency would either fail their mount or silently remove their models.
5501
+ *
5502
+ * THE MOUNT IS TWO INDEPENDENT SURFACES ON PURPOSE. `codebuddy_ask` and
5503
+ * `/codebuddy` come from two different host services that become available at
5504
+ * different moments (and can come and go). So the settings are normalized ONCE
5505
+ * into a `CliBridgeRuntime` — options plus the shared turn gate — and each
5506
+ * surface mounts and disposes on its own. That is why the mid-sized fact "the
5507
+ * tool and the command share one budget" survives a re-mount: the gate lives on
5508
+ * the runtime, not on either surface.
5509
+ *
5510
+ * WHY THE ROUTE EXISTS AT ALL, stated honestly. The provider route has to look
5511
+ * like a CodeBuddy client to get past the gateway's channel check, and that is an
5512
+ * arms race the upstream fights with zero-width-space perturbation (see
5513
+ * `perturbChannelFingerprints`). This route does not impersonate anything — but
5514
+ * it uses the SAME vault account, so it is a client-fingerprint win, NOT a way
5515
+ * around an account restriction (code 11140). The first draft's README claimed
5516
+ * otherwise; do not let that claim come back.
5517
+ *
5518
+ * @module dsh-connect-workbuddy/cli-bridge/mount
5519
+ */
5520
+ /**
5521
+ * Defaults, mirroring the schema so a missing key behaves the same either way.
5522
+ *
5523
+ * `permissionMode` is `dontAsk`, NOT `acceptEdits` and not `plan`. Measured
5524
+ * against the real CLI under `-p`: `acceptEdits` lets the agent edit files in a
5525
+ * directory a model chose; `plan` asks the driver to approve an `ExitPlanMode`
5526
+ * hand-off and a bridge can only deny it, so the turn dies without findings;
5527
+ * `dontAsk` auto-approves the read half and refuses the write half without ever
5528
+ * waiting for a human. That is the only one of the three that is both safe and
5529
+ * useful when nobody is watching.
5530
+ */
5531
+ const CLI_BRIDGE_DEFAULTS = {
5532
+ enabled: false,
5533
+ binary: "",
5534
+ model: "",
5535
+ permissionMode: "dontAsk",
5536
+ effort: "",
5537
+ maxTurns: 10,
5538
+ timeoutSecs: 900,
5539
+ regionOrder: "cn,global",
5540
+ workingDir: "",
5541
+ allowedDirs: "",
5542
+ appendSystemPrompt: "",
5543
+ allowUnattended: false,
5544
+ maxReplyChars: 6e4,
5545
+ injectResult: true,
5546
+ maxTurnsPerSessionHour: 6,
5547
+ maxTurnsPerHour: 20
5548
+ };
5549
+ /** Clamp an optional number to a default and a hard ceiling. */
5550
+ function clampInt(value, fallback, cap) {
5551
+ return Math.max(1, Math.min(cap, typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : fallback));
5552
+ }
5553
+ /** Read a non-negative integer setting where 0 legitimately means "unbounded". */
5554
+ function clampQuota(value, fallback) {
5555
+ if (value === 0) return 0;
5556
+ return Math.max(0, typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : fallback);
5557
+ }
5558
+ /** Read a string setting, trimmed, falling back when it is not a string. */
5559
+ function text(value, fallback) {
5560
+ return typeof value === "string" ? value.trim() : fallback;
5561
+ }
5562
+ /** Split a comma-separated list, dropping empties. */
5563
+ function list(value) {
5564
+ return value.split(",").map((part) => part.trim()).filter((part) => part !== "");
5565
+ }
5566
+ /**
5567
+ * Turn raw settings into the normalized options one turn runs on.
5568
+ *
5569
+ * An out-of-range enum is REPORTED and then replaced by the default rather than
5570
+ * rejected: a typo in `permissionMode` should not take the whole plugin down, but
5571
+ * it must not silently run under a permission level the user did not pick
5572
+ * either. `onRejected` is how the caller turns that into a log line.
5573
+ */
5574
+ function normalizeCliBridgeOptions(settings, onRejected) {
5575
+ const raw = settings ?? {};
5576
+ const reject = onRejected ?? (() => {});
5577
+ const permissionMode = text(raw.permissionMode, CLI_BRIDGE_DEFAULTS.permissionMode);
5578
+ const safePermissionMode = PERMISSION_MODES.includes(permissionMode) ? permissionMode : CLI_BRIDGE_DEFAULTS.permissionMode;
5579
+ if (safePermissionMode !== permissionMode) reject(`permissionMode "${permissionMode}" 不是合法值,已回退到 ${safePermissionMode};合法值:${PERMISSION_MODES.join(", ")}`);
5580
+ const effort = text(raw.effort, CLI_BRIDGE_DEFAULTS.effort);
5581
+ const safeEffort = effort === "" || EFFORT_LEVELS.includes(effort) ? effort : "";
5582
+ if (safeEffort !== effort) reject(`effort "${effort}" 不是合法值,已回退到 CLI 默认;合法值:${EFFORT_LEVELS.join(", ")}`);
5583
+ const regionOrder = text(raw.regionOrder, CLI_BRIDGE_DEFAULTS.regionOrder);
5584
+ return {
5585
+ enabled: raw.enabled === true,
5586
+ binary: text(raw.binary, CLI_BRIDGE_DEFAULTS.binary),
5587
+ model: text(raw.model, CLI_BRIDGE_DEFAULTS.model),
5588
+ permissionMode: safePermissionMode,
5589
+ effort: safeEffort,
5590
+ maxTurns: clampInt(raw.maxTurns, CLI_BRIDGE_DEFAULTS.maxTurns, 100),
5591
+ timeoutSecs: clampInt(raw.timeoutSecs, CLI_BRIDGE_DEFAULTS.timeoutSecs, TIMEOUT_SECS_CAP),
5592
+ regionOrder: list(regionOrder),
5593
+ workingDir: text(raw.workingDir, CLI_BRIDGE_DEFAULTS.workingDir),
5594
+ allowedDirs: list(text(raw.allowedDirs, CLI_BRIDGE_DEFAULTS.allowedDirs)),
5595
+ appendSystemPrompt: text(raw.appendSystemPrompt, CLI_BRIDGE_DEFAULTS.appendSystemPrompt),
5596
+ allowUnattended: raw.allowUnattended === true,
5597
+ maxReplyChars: clampInt(raw.maxReplyChars, CLI_BRIDGE_DEFAULTS.maxReplyChars, MAX_REPLY_CHARS_CAP),
5598
+ injectResult: raw.injectResult !== false,
5599
+ maxTurnsPerSessionHour: clampQuota(raw.maxTurnsPerSessionHour, CLI_BRIDGE_DEFAULTS.maxTurnsPerSessionHour),
5600
+ maxTurnsPerHour: clampQuota(raw.maxTurnsPerHour, CLI_BRIDGE_DEFAULTS.maxTurnsPerHour)
5601
+ };
5602
+ }
5603
+ /**
5604
+ * Build the runtime, or `undefined` when the route is disabled.
5605
+ *
5606
+ * Returning `undefined` rather than an enabled flag keeps every caller honest:
5607
+ * there is no "mounted but disabled" state to accidentally treat as live.
5608
+ */
5609
+ function createCliBridgeRuntime(settings, regions, log) {
5610
+ const options = normalizeCliBridgeOptions(settings, (message) => log(message));
5611
+ if (!options.enabled) return void 0;
5612
+ return {
5613
+ options,
5614
+ deps: {
5615
+ options,
5616
+ regions,
5617
+ log,
5618
+ gate: gateFor(options)
5619
+ }
5620
+ };
5621
+ }
5622
+ /** Mount `codebuddy_ask` on a tools service, returning its disposer. */
5623
+ function mountToolSurface(runtime, tools) {
5624
+ return mountAskTool(tools, runtime.deps);
5625
+ }
5626
+ /** Mount `/codebuddy` on a commands service, returning its disposer. */
5627
+ function mountCommandSurface(runtime, commands) {
5628
+ return mountCodebuddyCommand(commands, runtime.deps);
5629
+ }
5630
+ //#endregion
3939
5631
  //#region src/probe.ts
3940
5632
  /**
3941
5633
  * How long to bench a MODEL that answered 14003 with no stated time.
@@ -5958,6 +7650,19 @@ function safeMessage(error) {
5958
7650
  function isPlainRecord(value) {
5959
7651
  return typeof value === "object" && value !== null && !Array.isArray(value);
5960
7652
  }
7653
+ /**
7654
+ * Merge a `cliBridge` patch into the committed settings object.
7655
+ *
7656
+ * ONE level on purpose: every field in that block is a scalar (a boolean, a cap,
7657
+ * a path), so the only thing a deeper merge could produce is a half-written entry.
7658
+ * Sibling keys survive — flipping the switch must not drop the user's tuned caps.
7659
+ */
7660
+ function nextCliBridgeSettings(current, patch) {
7661
+ const base = isPlainRecord(current) ? { ...current } : {};
7662
+ if (!isPlainRecord(patch)) return base;
7663
+ for (const [key, value] of Object.entries(patch)) base[key] = value;
7664
+ return base;
7665
+ }
5961
7666
  function json(res, status, body) {
5962
7667
  const payload = JSON.stringify(body);
5963
7668
  res.writeHead(status, {
@@ -6193,6 +7898,7 @@ async function workBuddyWebStatus(deps, region, options = {}) {
6193
7898
  const showCreditsInMainUi = deps.showCreditsInMainUi?.(region) ?? true;
6194
7899
  const creditPosition = deps.creditPosition?.(region) ?? "composer";
6195
7900
  const imageToolEnabled = deps.imageToolEnabled?.(region) ?? false;
7901
+ const cliBridgeEnabled = deps.cliBridgeEnabled?.() === true;
6196
7902
  const authStatus = await store.status();
6197
7903
  const selectionExplicit = store.hasExplicitSelection();
6198
7904
  if (authStatus.state !== "signed-out" && accounts.length === 0) return {
@@ -6201,7 +7907,8 @@ async function workBuddyWebStatus(deps, region, options = {}) {
6201
7907
  selectionExplicit,
6202
7908
  enabled,
6203
7909
  showCreditsInMainUi,
6204
- imageToolEnabled
7910
+ imageToolEnabled,
7911
+ cliBridgeEnabled
6205
7912
  };
6206
7913
  let credential;
6207
7914
  try {
@@ -6218,6 +7925,7 @@ async function workBuddyWebStatus(deps, region, options = {}) {
6218
7925
  showCreditsInMainUi,
6219
7926
  creditPosition,
6220
7927
  imageToolEnabled,
7928
+ cliBridgeEnabled,
6221
7929
  ...await store.selectionLost() ? { selectionLost: true } : {},
6222
7930
  ...webAccounts.length === 0 ? await searchedPaths(store) : {}
6223
7931
  };
@@ -6235,6 +7943,7 @@ async function workBuddyWebStatus(deps, region, options = {}) {
6235
7943
  showCreditsInMainUi,
6236
7944
  creditPosition,
6237
7945
  imageToolEnabled,
7946
+ cliBridgeEnabled,
6238
7947
  accounts: accounts.map(toWebAccount),
6239
7948
  models: deps.displayModels(region).map((model) => toWebModel(model, deps.contextBudgets(region))).map((model) => model.reasoning === void 0 || lastRefusalFor(model.id) === void 0 ? model : {
6240
7949
  ...model,
@@ -6756,11 +8465,24 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
6756
8465
  req.on("error", reject);
6757
8466
  });
6758
8467
  const field = body.field;
6759
- if (field !== "regions" && field !== "accounts") return json(res, 400, { error: "field must be regions or accounts" });
8468
+ if (field !== "regions" && field !== "accounts" && field !== "cliBridge") return json(res, 400, { error: "field must be regions, accounts or cliBridge" });
6760
8469
  const row = settings.describe().find((r) => String(r.ns).includes("workbuddy"));
6761
8470
  if (row === void 0) return json(res, 503, { error: "workbuddy namespace missing from describe()" });
6762
8471
  const current = unwrapVolatileDeep(row.value?.[field] ?? {});
6763
8472
  const incoming = body.value ?? {};
8473
+ if (field === "cliBridge") {
8474
+ if (!isPlainRecord(incoming)) return json(res, 400, { error: "cliBridge value must be an object" });
8475
+ const mergedBridge = nextCliBridgeSettings(current, incoming);
8476
+ await settings.mutate(row.ns, [{
8477
+ op: "set",
8478
+ path: [field],
8479
+ value: mergedBridge
8480
+ }], void 0);
8481
+ return json(res, 200, {
8482
+ ok: true,
8483
+ value: mergedBridge
8484
+ });
8485
+ }
6764
8486
  const merged = { ...current };
6765
8487
  for (const [key, slot] of Object.entries(incoming)) {
6766
8488
  const prior = current[key];
@@ -7044,6 +8766,24 @@ function unwrapVolatile(value) {
7044
8766
  * region. It is imported and re-exported from here so the Host entry keeps
7045
8767
  * exposing it as part of its public API.
7046
8768
  */
8769
+ const cliBridgeConfig = z.object({
8770
+ enabled: z.boolean().default(false).description("Mount the optional CodeBuddy CLI route (tool codebuddy_ask + /codebuddy). Off by default; the provider stacks are unaffected either way."),
8771
+ binary: z.string().default("").description("Absolute path to the CodeBuddy CLI JS entry (the package's bin/codebuddy, not its .cmd shim). Empty means auto-detect in the usual global npm/pnpm/yarn/volta/nvm/fnm locations. There is deliberately no fallback to whatever \"codebuddy\" is on PATH."),
8772
+ model: z.string().default("").description("Model id passed as --model. Empty means the CLI's own configured default."),
8773
+ permissionMode: z.string().default("dontAsk").description("Permission mode passed as --permission-mode: dontAsk (default: read tools allowed, writes refused, never blocks), plan / default / auto, acceptEdits (may edit files), or bypassPermissions (allows everything)."),
8774
+ effort: z.string().default("").description("Reasoning effort passed as --effort: minimal, low, medium, high, xhigh or max. Empty means unset."),
8775
+ maxTurns: z.number().step(1).min(1).default(10).description("Agent turn budget for one bridged call. Clamped to 100."),
8776
+ timeoutSecs: z.number().step(1).min(1).default(900).description("Wall-clock budget for one bridged call, in seconds. Clamped to 1800."),
8777
+ regionOrder: z.string().default("cn,global").description("Credential region preference for the CLI, comma separated. Domestic tokens are accepted by copilot.tencent.com; global tokens are refused there with 401."),
8778
+ workingDir: z.string().default("").description("Default working directory for a bridged call when the caller names none. Must exist and be a directory. Empty means a call has to name one (or come from a session that has one)."),
8779
+ allowedDirs: z.string().default("").description("Extra roots a bridged call may be pointed at, comma separated absolute paths. A call can always run inside its own session workspace; anything else has to be listed here. Empty means only the session workspace."),
8780
+ appendSystemPrompt: z.string().default("").description("Extra system prompt appended to every bridged call. Passed as --append-system-prompt, never through a shell."),
8781
+ allowUnattended: z.boolean().default(false).description("Allow a bridged call with no directory at all to run in a throwaway temp directory. Off by default: an agent that can edit files should not be aimed at a directory nobody chose."),
8782
+ maxReplyChars: z.number().step(1).min(1).default(6e4).description("Reply length cap handed back to the model, in characters. Clamped to 400000."),
8783
+ injectResult: z.boolean().default(true).description("Hand a successful /codebuddy result to the session model so the conversation knows about it. That wake-up costs a real DSH turn; set false to keep results purely local."),
8784
+ maxTurnsPerSessionHour: z.number().step(1).min(0).default(6).description("Bridged calls allowed per hour in one session. 0 disables the limit."),
8785
+ maxTurnsPerHour: z.number().step(1).min(0).default(20).description("Bridged calls allowed per hour across the whole process. 0 disables the limit.")
8786
+ });
7047
8787
  const Config = z.object({
7048
8788
  authFile: asVolatile(z.string().description("WorkBuddy desktop auth file (defaults to the app's own location)")),
7049
8789
  accountId: z.string().description("Deprecated: pre-split account selector, attributed to its own region"),
@@ -7052,7 +8792,8 @@ const Config = z.object({
7052
8792
  lastCatalog: z.array(modelConfig).description("Deprecated: pre-region-split CN model directory"),
7053
8793
  enabledModelIds: z.array(z.string()).default([]).description("Deprecated: pre-region-split CN selection"),
7054
8794
  imageModelIds: z.array(z.string()).default([]).description("Deprecated: pre-region-split CN image opt-in"),
7055
- contextBudgets: z.dict(z.number().step(1).min(1)).default({}).description("Deprecated: pre-region-split CN context budgets")
8795
+ contextBudgets: z.dict(z.number().step(1).min(1)).default({}).description("Deprecated: pre-region-split CN context budgets"),
8796
+ cliBridge: asVolatile(cliBridgeConfig.default({}).description("Optional CodeBuddy CLI route; off by default and independent of the providers"))
7056
8797
  });
7057
8798
  /**
7058
8799
  * One region's saved model state. A config written before the region split has
@@ -7335,6 +9076,7 @@ function apply(ctx, config) {
7335
9076
  invalidateCatalog();
7336
9077
  syncRegionRegistration(value);
7337
9078
  syncImageTool(value);
9079
+ syncCliBridge(value);
7338
9080
  refreshRegionUsability();
7339
9081
  };
7340
9082
  /**
@@ -7431,6 +9173,57 @@ function apply(ctx, config) {
7431
9173
  imageTools = void 0;
7432
9174
  });
7433
9175
  });
9176
+ let cliTools;
9177
+ let cliCommands;
9178
+ let cliRuntime;
9179
+ let cliRuntimeKey = "";
9180
+ let disposeCliTool;
9181
+ let disposeCliCommand;
9182
+ const disposeCliSurfaces = () => {
9183
+ disposeCliTool?.();
9184
+ disposeCliTool = void 0;
9185
+ disposeCliCommand?.();
9186
+ disposeCliCommand = void 0;
9187
+ };
9188
+ const syncCliBridge = (value) => {
9189
+ const settings = unwrapVolatile(value.cliBridge);
9190
+ const key = JSON.stringify(settings ?? {});
9191
+ if (key !== cliRuntimeKey) {
9192
+ cliRuntimeKey = key;
9193
+ disposeCliSurfaces();
9194
+ cliRuntime?.deps.gate?.clear();
9195
+ cliRuntime = createCliBridgeRuntime(settings, REGION_KEYS.map((region) => ({
9196
+ region,
9197
+ store: stacks[region].store
9198
+ })), (message) => ctx.logger.warn(`dsh-connect-workbuddy: ${message}`));
9199
+ }
9200
+ if (cliRuntime === void 0) {
9201
+ disposeCliSurfaces();
9202
+ return;
9203
+ }
9204
+ if (cliTools !== void 0 && disposeCliTool === void 0) disposeCliTool = mountToolSurface(cliRuntime, cliTools);
9205
+ if (cliCommands !== void 0 && disposeCliCommand === void 0) disposeCliCommand = mountCommandSurface(cliRuntime, cliCommands);
9206
+ };
9207
+ ctx.inject(["tools"], (toolsCtx) => {
9208
+ cliTools = toolsCtx.tools;
9209
+ if (cliTools === void 0) return;
9210
+ syncCliBridge(current());
9211
+ toolsCtx.effect(() => () => {
9212
+ disposeCliTool?.();
9213
+ disposeCliTool = void 0;
9214
+ cliTools = void 0;
9215
+ });
9216
+ });
9217
+ ctx.inject(["commands"], (commandsCtx) => {
9218
+ cliCommands = commandsCtx.commands;
9219
+ if (cliCommands === void 0) return;
9220
+ syncCliBridge(current());
9221
+ commandsCtx.effect(() => () => {
9222
+ disposeCliCommand?.();
9223
+ disposeCliCommand = void 0;
9224
+ cliCommands = void 0;
9225
+ });
9226
+ });
7434
9227
  ctx.inject(["webServer"], (webCtx) => registerWorkBuddyStatusRoute(webCtx, {
7435
9228
  store: (region) => stacks[region].store,
7436
9229
  client,
@@ -7475,6 +9268,16 @@ function apply(ctx, config) {
7475
9268
  */
7476
9269
  imageToolEnabled: (region) => regionStateOf(current(), region).imageToolEnabled === true,
7477
9270
  /**
9271
+ * Whether the optional CodeBuddy CLI route is mounted (`codebuddy_ask` tool
9272
+ * + `/codebuddy` command).
9273
+ *
9274
+ * GLOBAL, so it takes no region. `=== true` like `imageToolEnabled`, not
9275
+ * `!== false`: switching this on lets a child process edit files and spend
9276
+ * CodeBuddy quota, so an absent value must read as OFF rather than as
9277
+ * consent. `cliBridge` is a volatile field, hence the unwrap.
9278
+ */
9279
+ cliBridgeEnabled: () => unwrapVolatile(current().cliBridge)?.enabled === true,
9280
+ /**
7478
9281
  * WHERE the readout sits, resolved here rather than read from the browser
7479
9282
  * settings mirror, for the same measured reason as the flag above: on the
7480
9283
  * affected 0.1.7 deployments that mirror never picks up this plugin's