dsh-connect-workbuddy 5.4.0 → 5.5.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/CHANGELOG.md +81 -0
- package/README.en.md +25 -9
- package/README.md +29 -12
- package/docs/WINDOWS.md +1 -0
- package/lib/bin.js +1 -1
- package/lib/client.js +716 -155
- package/lib/{host-heartbeat-D5FWM_8k.js → host-heartbeat-CVnH8S3J.js} +11 -1
- package/lib/index.d.ts +27 -0
- package/lib/index.js +432 -131
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { $ as prepareChatBody, A as parseWorkBuddyAuth, At as readAtRestKey, B as declaredTools, C as defaultDesktopAuthCandidates, Ct as findWorkbuddyAppExecutable, D as isEncryptedCredentialError, Dt as macosBundleExecutable, E as hasEncryptedCredentialFields, Et as isWorkbuddyBundle, F as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, Ft as writeFileAtomic, G as isFirstMessageGateBody, H as isContextTooLongBody, I as WorkBuddyCredentialRejectedError, J as openingEvidenceOfBody, K as knownRegionOf, L as WorkBuddyUpstreamClient, M as workbuddyOwnAuthPath, N as CONTEXT_OVERFLOW_MARKER, Nt as resolveDshHome, O as isVaultAccountId, Ot as macosNestedAppBundles, P as CREDENTIAL_REJECTED_CODE, Pt as withFileLock, Q as perturbChannelFingerprints, R as channelGateMemberOf, S as authFileName, St as fetchAtRestKeyPayload, T as defaultDesktopAuthPath, Tt as isEncryptedFieldWrapper, U as isConversationIdGateBody, V as forceNeutralHead, W as isCredentialRejectedError, X as parseReasoning, Y as parseCreditMultiplier, Z as parseUpstreamModel, _ as ENCRYPTED_CREDENTIAL_CODE, _t as nativeModalityOf, a as readHostHeartbeat, at as lastRefusalFor, b as WorkBuddyCredentialStore, bt as deriveAtRestKey, c as WORKBUDDY_CONNECT_VERSION, ct as recordRefusal, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, dt as WorkBuddyImageError, et as regionOf, f as WorkBuddyCatalog, ft as pickImageModel, g as withoutRouters, gt as imageDefaultFor, h as fallbackModelsFor, ht as NATIVE_MODALITY_BY_MODEL_ID, i as processStartTimeMs, it as defaultOffModelIds, j as workbuddyAccountId, jt as workbuddyAppExecutableCandidates, k as legacyWorkbuddyOwnAuthPath, kt as openEncryptedField, l as piAiRuntimeInfo, lt as withEffectiveOff, m as deriveCatalog, mt as queueNoticeText, n as clearHostHeartbeat, nt as attachBusyLevels, o as workbuddyHostHeartbeatPath, ot as offDefaultFor, p as applyContextBudgets, pt as parseQueueAnswer, q as modelIdOfBody, r as isHeartbeatProcessAlive, rt as REFUSES_OFF_MODEL_IDS, s as writeHostHeartbeat, st as offRefused, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as requestEvidenceOf, u as FALLBACK_WORKBUDDY_MODELS, ut as DEFAULT_IMAGE_SIZE, v as WORKBUDDY_AUTH_FILENAME, vt as WORKBUDDY_APP_EXECUTABLE_ENV, w as defaultDesktopAuthDirs, x as WorkBuddyEncryptedCredentialError, xt as deriveAtRestKeyId, y as WORKBUDDY_AUTH_FILE_ENV, yt as clearAtRestKeyCache, z as classifyUpstreamError } from "./host-heartbeat-
|
|
1
|
+
import { $ as prepareChatBody, A as parseWorkBuddyAuth, At as readAtRestKey, B as declaredTools, C as defaultDesktopAuthCandidates, Ct as findWorkbuddyAppExecutable, D as isEncryptedCredentialError, Dt as macosBundleExecutable, E as hasEncryptedCredentialFields, Et as isWorkbuddyBundle, F as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, Ft as writeFileAtomic, G as isFirstMessageGateBody, H as isContextTooLongBody, I as WorkBuddyCredentialRejectedError, J as openingEvidenceOfBody, K as knownRegionOf, L as WorkBuddyUpstreamClient, M as workbuddyOwnAuthPath, N as CONTEXT_OVERFLOW_MARKER, Nt as resolveDshHome, O as isVaultAccountId, Ot as macosNestedAppBundles, P as CREDENTIAL_REJECTED_CODE, Pt as withFileLock, Q as perturbChannelFingerprints, R as channelGateMemberOf, S as authFileName, St as fetchAtRestKeyPayload, T as defaultDesktopAuthPath, Tt as isEncryptedFieldWrapper, U as isConversationIdGateBody, V as forceNeutralHead, W as isCredentialRejectedError, X as parseReasoning, Y as parseCreditMultiplier, Z as parseUpstreamModel, _ as ENCRYPTED_CREDENTIAL_CODE, _t as nativeModalityOf, a as readHostHeartbeat, at as lastRefusalFor, b as WorkBuddyCredentialStore, bt as deriveAtRestKey, c as WORKBUDDY_CONNECT_VERSION, ct as recordRefusal, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, dt as WorkBuddyImageError, et as regionOf, f as WorkBuddyCatalog, ft as pickImageModel, g as withoutRouters, gt as imageDefaultFor, h as fallbackModelsFor, ht as NATIVE_MODALITY_BY_MODEL_ID, i as processStartTimeMs, it as defaultOffModelIds, j as workbuddyAccountId, jt as workbuddyAppExecutableCandidates, k as legacyWorkbuddyOwnAuthPath, kt as openEncryptedField, l as piAiRuntimeInfo, lt as withEffectiveOff, m as deriveCatalog, mt as queueNoticeText, n as clearHostHeartbeat, nt as attachBusyLevels, o as workbuddyHostHeartbeatPath, ot as offDefaultFor, p as applyContextBudgets, pt as parseQueueAnswer, q as modelIdOfBody, r as isHeartbeatProcessAlive, rt as REFUSES_OFF_MODEL_IDS, s as writeHostHeartbeat, st as offRefused, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as requestEvidenceOf, u as FALLBACK_WORKBUDDY_MODELS, ut as DEFAULT_IMAGE_SIZE, v as WORKBUDDY_AUTH_FILENAME, vt as WORKBUDDY_APP_EXECUTABLE_ENV, w as defaultDesktopAuthDirs, x as WorkBuddyEncryptedCredentialError, xt as deriveAtRestKeyId, y as WORKBUDDY_AUTH_FILE_ENV, yt as clearAtRestKeyCache, z as classifyUpstreamError } from "./host-heartbeat-CVnH8S3J.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
5
|
import { homedir, tmpdir } from "node:os";
|
|
6
6
|
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
8
|
-
import { existsSync,
|
|
8
|
+
import { existsSync, mkdtempSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
9
9
|
import { createAssistantMessageEventStream, createProvider, isContextOverflow } from "@earendil-works/pi-ai";
|
|
10
10
|
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
11
11
|
import { ReasoningEffortId, createUserMessage, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
|
|
@@ -347,6 +347,19 @@ const WORKBUDDY_POOL_ACCOUNT_PARAM = "accountId";
|
|
|
347
347
|
* arbitrary user data and export carries token material.
|
|
348
348
|
*/
|
|
349
349
|
const WORKBUDDY_TRANSFER_PATH = "/plugins/dsh-connect-workbuddy/transfer";
|
|
350
|
+
/**
|
|
351
|
+
* The CLI route's live turns, for the composer readout.
|
|
352
|
+
*
|
|
353
|
+
* A GET with no parameters and no region: a live turn is not scoped to a
|
|
354
|
+
* credential region (it is one spawned process), and the chip polls this on an
|
|
355
|
+
* interval, so it must stay cheap — the handler reads an in-memory board and
|
|
356
|
+
* serializes it, touching no upstream and no disk.
|
|
357
|
+
*
|
|
358
|
+
* The document is `{ turns: LiveTurnView[], serverTime: number }`. `serverTime`
|
|
359
|
+
* is the host's clock at serialization so the browser can derive an age from
|
|
360
|
+
* `startedAt` without trusting the two clocks to agree.
|
|
361
|
+
*/
|
|
362
|
+
const WORKBUDDY_CLI_LIVE_PATH = "/plugins/dsh-connect-workbuddy/cli-live";
|
|
350
363
|
/** Read the transfer action off a request URL; unknown/absent means undefined. */
|
|
351
364
|
function transferActionOf(url) {
|
|
352
365
|
const at = url.indexOf("?");
|
|
@@ -426,14 +439,27 @@ function regionOfStatusUrl(url) {
|
|
|
426
439
|
* it is then `undefined`: the enabled flag reads back as "on" forever (the very
|
|
427
440
|
* symptom described above), and a merge that spreads this map silently drops
|
|
428
441
|
* the region it was not editing. Unwrap before narrowing.
|
|
442
|
+
*
|
|
443
|
+
* THE HOP IS CONDITIONAL, and that is the whole point (a saved manual pool
|
|
444
|
+
* order "disappearing"). "Has a `regions` key" is NOT sufficient evidence that
|
|
445
|
+
* the caller passed the whole section: the `regions` MAP can legitimately carry
|
|
446
|
+
* a `regions` key of its own, because an earlier build could write a sibling
|
|
447
|
+
* field name into it — a live profile was found with `accounts`, `regions` and
|
|
448
|
+
* `cliBridge` sitting beside `cn` / `global`. Hopping there means reading a
|
|
449
|
+
* DIFFERENT, older copy of the same region: measured, the same document returned
|
|
450
|
+
* `['c','a','b']` when read as a section and `[]` when read as a map. So a value
|
|
451
|
+
* counts as the whole section only when it shows a key that is neither a region
|
|
452
|
+
* id nor a known sibling — i.e. it looks like a settings document. A plain (or
|
|
453
|
+
* dirty) `cn` / `global` map is returned as-is.
|
|
429
454
|
*/
|
|
430
455
|
function regionsMapOf(value) {
|
|
431
456
|
const unwrapped = unwrapVolatileDeep(value);
|
|
432
457
|
if (typeof unwrapped !== "object" || unwrapped === null || Array.isArray(unwrapped)) return {};
|
|
433
458
|
const record = unwrapped;
|
|
434
459
|
const nested = record["regions"];
|
|
435
|
-
if (typeof nested
|
|
436
|
-
|
|
460
|
+
if (typeof nested !== "object" || nested === null || Array.isArray(nested)) return record;
|
|
461
|
+
const regionIds = WORKBUDDY_REGIONS;
|
|
462
|
+
return Object.keys(record).some((key) => regionIds.includes(key)) ? record : nested;
|
|
437
463
|
}
|
|
438
464
|
/** One region's stored slot as a plain object; any other shape reads as empty. */
|
|
439
465
|
function regionSlotOf(value, region) {
|
|
@@ -4663,22 +4689,26 @@ const INJECT_TEXT_LIMIT = 2e5;
|
|
|
4663
4689
|
/**
|
|
4664
4690
|
* The `source.kind` stamped on every message this module writes.
|
|
4665
4691
|
*
|
|
4666
|
-
*
|
|
4667
|
-
*
|
|
4668
|
-
*
|
|
4669
|
-
*
|
|
4670
|
-
*
|
|
4671
|
-
*
|
|
4672
|
-
*
|
|
4673
|
-
*
|
|
4674
|
-
*
|
|
4675
|
-
*
|
|
4676
|
-
*
|
|
4677
|
-
*
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
*
|
|
4692
|
+
* This used to be `'user'`, on the reading that `MessageSource` is a closed
|
|
4693
|
+
* union and `'user'` is the only slot a plugin-written turn message can occupy.
|
|
4694
|
+
* That reading was wrong. `MessageSourceMap` is MERGE-EXTENSIBLE — its own doc
|
|
4695
|
+
* comment says "each producer declares its own `kind` in its own module; there
|
|
4696
|
+
* is no shared catch-all `plugin` kind", and consumers fall through unknown
|
|
4697
|
+
* kinds — so `'user'` was never the only option, it was the dishonest one: the
|
|
4698
|
+
* transcript rendered the injected CodeBuddy result as a user bubble, and the
|
|
4699
|
+
* UI had no way to label the row as a notification. `dsh-connect-atomcode`
|
|
4700
|
+
* stamps `'atomcode-bridge'` for exactly this reason.
|
|
4701
|
+
*
|
|
4702
|
+
* The declaration that makes the union accept it lives in
|
|
4703
|
+
* `src/dsh-llm-augment.d.ts`.
|
|
4704
|
+
*
|
|
4705
|
+
* `PROVENANCE_LINE` still opens the text: the kind is what the UI and later
|
|
4706
|
+
* readers key on, but the text is what the MODEL reads, and a model cannot see
|
|
4707
|
+
* `source`.
|
|
4708
|
+
*/
|
|
4709
|
+
const INJECT_SOURCE_KIND = "codebuddy-bridge";
|
|
4710
|
+
/** The first line of every injected message. Not decoration: the model never
|
|
4711
|
+
* sees `source.kind`, so this line is its only provenance signal. */
|
|
4682
4712
|
const PROVENANCE_LINE = "[dsh-connect-workbuddy · 由 /codebuddy 自动回注,不是用户输入]";
|
|
4683
4713
|
/**
|
|
4684
4714
|
* An instruction appended so a model does not turn the result into a loop.
|
|
@@ -5005,37 +5035,62 @@ const EXIT_GRACE_MS = 3e3;
|
|
|
5005
5035
|
*/
|
|
5006
5036
|
const DENY_REASON = "dsh-connect-workbuddy runs this turn unattended; no human is at the terminal to approve tool use";
|
|
5007
5037
|
/**
|
|
5008
|
-
*
|
|
5038
|
+
* Directories that ARE a `node_modules` root a global install can live in,
|
|
5039
|
+
* most likely first.
|
|
5009
5040
|
*
|
|
5010
5041
|
* WHY THE LIST IS THIS LONG. The first draft knew only about `%APPDATA%\npm`
|
|
5011
5042
|
* and the two `/usr/*` prefixes, so every nvm, fnm, volta, pnpm, yarn, brew,
|
|
5012
5043
|
* `~/.local` and `/opt/homebrew` user got "CLI not found" even though it was
|
|
5013
5044
|
* installed. The version-manager roots cannot be listed statically (they carry
|
|
5014
|
-
* a version or a hash in the path), so `
|
|
5045
|
+
* a version or a hash in the path), so `versionedCliRoots` globs one level.
|
|
5046
|
+
*
|
|
5047
|
+
* WHY EVERY ENTRY SPELLS OUT ITS OWN LAYOUT. A global npm prefix is not shaped
|
|
5048
|
+
* the same way on every platform. On POSIX a prefix holds
|
|
5049
|
+
* `<prefix>/lib/node_modules`; on Windows the prefix `%APPDATA%\npm` holds
|
|
5050
|
+
* `<prefix>\node_modules` directly and has no `lib` level at all. Appending
|
|
5051
|
+
* `lib/node_modules` to every root — which an earlier revision of this list
|
|
5052
|
+
* did — produced paths that exist on no Windows machine, so auto-detection
|
|
5053
|
+
* failed for every Windows user while the POSIX roots kept working. The
|
|
5054
|
+
* pnpm and yarn stores are their own `node_modules` root for the same reason.
|
|
5015
5055
|
*
|
|
5016
5056
|
* None of these are probed through PATH, and `cbc` is not a separate target: it
|
|
5017
5057
|
* is an alias of the same `bin/codebuddy` file inside the package.
|
|
5018
5058
|
*/
|
|
5019
|
-
function
|
|
5059
|
+
function cliModuleDirs() {
|
|
5020
5060
|
const home = homedir();
|
|
5021
|
-
const
|
|
5061
|
+
const dirs = [];
|
|
5022
5062
|
const appData = process.env["APPDATA"];
|
|
5023
|
-
if (typeof appData === "string" && appData !== "")
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
|
|
5028
|
-
|
|
5063
|
+
if (typeof appData === "string" && appData !== "") dirs.push(join(appData, "npm", "node_modules"));
|
|
5064
|
+
dirs.push(join(home, "AppData", "Roaming", "npm", "node_modules"));
|
|
5065
|
+
for (const prefix of [
|
|
5066
|
+
join(home, ".npm-global"),
|
|
5067
|
+
"/usr/local",
|
|
5068
|
+
"/opt/homebrew",
|
|
5069
|
+
join(home, ".local")
|
|
5070
|
+
]) dirs.push(join(prefix, "lib", "node_modules"));
|
|
5071
|
+
dirs.push(join(home, ".local", "share", "pnpm", "global", "5", "node_modules"));
|
|
5072
|
+
dirs.push(join(home, "Library", "pnpm", "global", "5", "node_modules"));
|
|
5073
|
+
const localAppData = process.env["LOCALAPPDATA"];
|
|
5074
|
+
if (typeof localAppData === "string" && localAppData !== "") {
|
|
5075
|
+
dirs.push(join(localAppData, "pnpm", "global", "5", "node_modules"));
|
|
5076
|
+
dirs.push(join(localAppData, "Yarn", "Data", "global", "node_modules"));
|
|
5077
|
+
}
|
|
5078
|
+
dirs.push(join(home, ".config", "yarn", "global", "node_modules"));
|
|
5079
|
+
return dirs;
|
|
5029
5080
|
}
|
|
5030
5081
|
/** Directories whose children are node version roots (`.../versions/node/<v>`). */
|
|
5031
5082
|
function versionedCliRoots() {
|
|
5032
5083
|
const home = homedir();
|
|
5033
|
-
|
|
5084
|
+
const roots = [
|
|
5034
5085
|
join(home, ".nvm", "versions", "node"),
|
|
5035
5086
|
join(home, "n", "versions", "node"),
|
|
5036
5087
|
join(home, ".fnm", "node-versions"),
|
|
5037
|
-
join(home, ".local", "share", "fnm", "node-versions")
|
|
5088
|
+
join(home, ".local", "share", "fnm", "node-versions"),
|
|
5089
|
+
join(home, ".volta", "tools", "image", "node")
|
|
5038
5090
|
];
|
|
5091
|
+
const appData = process.env["APPDATA"];
|
|
5092
|
+
if (typeof appData === "string" && appData !== "") roots.push(join(appData, "fnm", "node-versions"));
|
|
5093
|
+
return roots;
|
|
5039
5094
|
}
|
|
5040
5095
|
/** Expand one directory's children into CLI entries, ignoring unreadable ones. */
|
|
5041
5096
|
function childrenOf(dir, separator) {
|
|
@@ -5047,21 +5102,21 @@ function childrenOf(dir, separator) {
|
|
|
5047
5102
|
}
|
|
5048
5103
|
/** Every path a global install of the CLI could live at, most likely first. */
|
|
5049
5104
|
function cliEntryCandidates() {
|
|
5050
|
-
const out = [...
|
|
5105
|
+
const out = [...cliModuleDirs().map((dir) => join(dir, CLI_PACKAGE_PATH))];
|
|
5051
5106
|
for (const versions of versionedCliRoots()) {
|
|
5052
5107
|
out.push(...childrenOf(versions, [
|
|
5053
5108
|
"lib",
|
|
5054
5109
|
"node_modules",
|
|
5055
5110
|
CLI_PACKAGE_PATH
|
|
5056
5111
|
]));
|
|
5057
|
-
out.push(...childrenOf(
|
|
5112
|
+
out.push(...childrenOf(versions, [
|
|
5058
5113
|
"installation",
|
|
5059
5114
|
"lib",
|
|
5060
5115
|
"node_modules",
|
|
5061
5116
|
CLI_PACKAGE_PATH
|
|
5062
5117
|
]));
|
|
5063
5118
|
}
|
|
5064
|
-
return out;
|
|
5119
|
+
return [...new Set(out)];
|
|
5065
5120
|
}
|
|
5066
5121
|
/**
|
|
5067
5122
|
* Locate the CLI's JS entry.
|
|
@@ -5217,6 +5272,8 @@ function runOneTurn(options, token) {
|
|
|
5217
5272
|
const assistantText = [];
|
|
5218
5273
|
let assistantChars = 0;
|
|
5219
5274
|
const toolCounts = /* @__PURE__ */ new Map();
|
|
5275
|
+
let liveToolCalls = 0;
|
|
5276
|
+
let lastToolName = "";
|
|
5220
5277
|
const deniedTools = [];
|
|
5221
5278
|
let stderr = "";
|
|
5222
5279
|
let buffer = "";
|
|
@@ -5361,7 +5418,21 @@ function runOneTurn(options, token) {
|
|
|
5361
5418
|
return;
|
|
5362
5419
|
}
|
|
5363
5420
|
assistantText.push(block.text);
|
|
5364
|
-
|
|
5421
|
+
options.onProgress?.({
|
|
5422
|
+
toolCalls: liveToolCalls,
|
|
5423
|
+
lastTool: lastToolName,
|
|
5424
|
+
chars: assistantChars
|
|
5425
|
+
});
|
|
5426
|
+
} else if (block.type === "tool_use" && typeof block.name === "string") {
|
|
5427
|
+
toolCounts.set(block.name, (toolCounts.get(block.name) ?? 0) + 1);
|
|
5428
|
+
liveToolCalls += 1;
|
|
5429
|
+
lastToolName = block.name;
|
|
5430
|
+
options.onProgress?.({
|
|
5431
|
+
toolCalls: liveToolCalls,
|
|
5432
|
+
lastTool: lastToolName,
|
|
5433
|
+
chars: assistantChars
|
|
5434
|
+
});
|
|
5435
|
+
}
|
|
5365
5436
|
}
|
|
5366
5437
|
if (frame.type === "result") {
|
|
5367
5438
|
settle(frame);
|
|
@@ -5472,6 +5543,16 @@ const EFFORT_LEVELS = [
|
|
|
5472
5543
|
const TIMEOUT_SECS_CAP = 1800;
|
|
5473
5544
|
/** Hard ceiling on the reply length, in characters. */
|
|
5474
5545
|
const MAX_REPLY_CHARS_CAP = 4e5;
|
|
5546
|
+
/**
|
|
5547
|
+
* What `cliBridge.sessionContinuity` accepts.
|
|
5548
|
+
*
|
|
5549
|
+
* `auto` threads the previous CLI session into the next call; `off` leaves every
|
|
5550
|
+
* call independent unless the caller passes `session_id` itself. There is no
|
|
5551
|
+
* `on`-with-a-different-mechanism value: the one that was measured to work is
|
|
5552
|
+
* `--session-id <existing>`, and offering `--continue` (whatever ran last in the
|
|
5553
|
+
* cwd) next to it would make two concurrent sessions race for one conversation.
|
|
5554
|
+
*/
|
|
5555
|
+
const SESSION_CONTINUITY_MODES = ["auto", "off"];
|
|
5475
5556
|
/** Every field defaulted, so the Host's closed schema always validates. */
|
|
5476
5557
|
function result(partial) {
|
|
5477
5558
|
return {
|
|
@@ -5497,11 +5578,62 @@ function result(partial) {
|
|
|
5497
5578
|
function clampInt$1(value, fallback, cap) {
|
|
5498
5579
|
return Math.max(1, Math.min(cap, typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : fallback));
|
|
5499
5580
|
}
|
|
5500
|
-
/**
|
|
5581
|
+
/**
|
|
5582
|
+
* Last CLI session id per DSH session, for automatic continuation.
|
|
5583
|
+
*
|
|
5584
|
+
* WHY A MAP AND NOT JUST A TOOL ARGUMENT. `--session-id <existing>` really does
|
|
5585
|
+
* resume the conversation — measured against the installed CLI: a number sent in
|
|
5586
|
+
* turn 1 came back from turn 2, and the CLI echoed the same id both times. But
|
|
5587
|
+
* that id only reaches the CLI when the CALLER passes it back as `session_id`,
|
|
5588
|
+
* which asks the model to copy a uuid out of the previous result. Best effort,
|
|
5589
|
+
* not a guarantee. Remembering the id here makes continuation the default rather
|
|
5590
|
+
* than a habit.
|
|
5591
|
+
*
|
|
5592
|
+
* IN MEMORY ON PURPOSE. The value is a uuid owned by the CLI, and an unknown id
|
|
5593
|
+
* is not an error there — the CLI silently starts a fresh session under it. So
|
|
5594
|
+
* the failure mode of a lost or stale entry is one new conversation, never a
|
|
5595
|
+
* mixed-up one, which is the right trade for a file the plugin would otherwise
|
|
5596
|
+
* have to garbage-collect. Bounded (FIFO) so a long-lived process cannot grow it
|
|
5597
|
+
* without limit.
|
|
5598
|
+
*/
|
|
5599
|
+
const CLI_SESSION_MEMORY = /* @__PURE__ */ new Map();
|
|
5600
|
+
const CLI_SESSION_MEMORY_CAP = 256;
|
|
5601
|
+
/** Remember the CLI session a DSH session should continue next time. */
|
|
5602
|
+
function rememberCliSession(sessionKey, cliSessionId) {
|
|
5603
|
+
if (sessionKey === "" || cliSessionId === "") return;
|
|
5604
|
+
CLI_SESSION_MEMORY.delete(sessionKey);
|
|
5605
|
+
CLI_SESSION_MEMORY.set(sessionKey, cliSessionId);
|
|
5606
|
+
while (CLI_SESSION_MEMORY.size > CLI_SESSION_MEMORY_CAP) {
|
|
5607
|
+
const oldest = CLI_SESSION_MEMORY.keys().next();
|
|
5608
|
+
if (oldest.done === true) break;
|
|
5609
|
+
CLI_SESSION_MEMORY.delete(oldest.value);
|
|
5610
|
+
}
|
|
5611
|
+
}
|
|
5612
|
+
/** The CLI session to continue for this DSH session, or `''` for a fresh one. */
|
|
5613
|
+
function recallCliSession(sessionKey) {
|
|
5614
|
+
return CLI_SESSION_MEMORY.get(sessionKey) ?? "";
|
|
5615
|
+
}
|
|
5616
|
+
/**
|
|
5617
|
+
* A throwaway directory for ONE unattended turn.
|
|
5618
|
+
*
|
|
5619
|
+
* ONE DIRECTORY PER CALL, not a fixed `dsh-codebuddy-scratch`. A single shared
|
|
5620
|
+
* path meant two concurrent unattended turns ran in the same writable space —
|
|
5621
|
+
* each could read and overwrite the other's files, and anything a previous turn
|
|
5622
|
+
* left behind was still there. `mkdtemp` guarantees a fresh, empty, uniquely
|
|
5623
|
+
* named directory, which is what makes the "exempt by construction" argument in
|
|
5624
|
+
* `resolveWorkingDir` actually true.
|
|
5625
|
+
*/
|
|
5501
5626
|
function scratchDir() {
|
|
5502
|
-
|
|
5503
|
-
|
|
5504
|
-
|
|
5627
|
+
return mkdtempSync(join(tmpdir(), "dsh-codebuddy-scratch-"));
|
|
5628
|
+
}
|
|
5629
|
+
/** Best-effort removal: a locked file must not turn a finished turn into an error. */
|
|
5630
|
+
function removeScratch(dir) {
|
|
5631
|
+
try {
|
|
5632
|
+
rmSync(dir, {
|
|
5633
|
+
recursive: true,
|
|
5634
|
+
force: true
|
|
5635
|
+
});
|
|
5636
|
+
} catch {}
|
|
5505
5637
|
}
|
|
5506
5638
|
/** Case-fold a path on the platforms whose filesystems ignore case. */
|
|
5507
5639
|
function foldCase(path) {
|
|
@@ -5729,16 +5861,77 @@ function sessionKeyOf(execution, fallback) {
|
|
|
5729
5861
|
const id = execution?.agent?.id;
|
|
5730
5862
|
return typeof id === "string" && id !== "" ? id : fallback;
|
|
5731
5863
|
}
|
|
5732
|
-
/**
|
|
5733
|
-
|
|
5734
|
-
|
|
5735
|
-
* Exported because the `/codebuddy` slash command needs the same turn with the
|
|
5736
|
-
* same validation — a command that re-implemented this would drift from the tool
|
|
5737
|
-
* the first time either side gained a knob.
|
|
5738
|
-
*/
|
|
5739
|
-
async function runAsk(args, execution, deps) {
|
|
5740
|
-
const startedAt = Date.now();
|
|
5864
|
+
/** The part of a turn that runs once the budget and the directory are settled. */
|
|
5865
|
+
async function runAskTurn(args, execution, deps, context) {
|
|
5866
|
+
const { startedAt, dir, message, effort, modelArg, effectiveMode, liveTurn } = context;
|
|
5741
5867
|
const { options, regions, log } = deps;
|
|
5868
|
+
let credentials;
|
|
5869
|
+
try {
|
|
5870
|
+
credentials = await resolveCredentials(regions, options.regionOrder, log);
|
|
5871
|
+
} catch (error) {
|
|
5872
|
+
return result({
|
|
5873
|
+
ok: false,
|
|
5874
|
+
error: error instanceof Error ? error.message : String(error)
|
|
5875
|
+
});
|
|
5876
|
+
}
|
|
5877
|
+
const sessionIdArg = typeof args["session_id"] === "string" ? args["session_id"].trim() : "";
|
|
5878
|
+
const signal = execution?.signal;
|
|
5879
|
+
const sessionKey = sessionKeyOf(execution, "codebuddy_ask");
|
|
5880
|
+
const continuedSessionId = sessionIdArg !== "" ? sessionIdArg : options.sessionContinuity === "auto" ? recallCliSession(sessionKey) : "";
|
|
5881
|
+
if (liveTurn !== void 0) liveTurn.phase = "running";
|
|
5882
|
+
const outcome = await runAgent({
|
|
5883
|
+
prompt: message,
|
|
5884
|
+
cwd: dir.dir,
|
|
5885
|
+
binary: options.binary,
|
|
5886
|
+
model: modelArg !== "" ? modelArg : options.model,
|
|
5887
|
+
permissionMode: effectiveMode,
|
|
5888
|
+
effort: effort !== "" ? effort : options.effort,
|
|
5889
|
+
appendSystemPrompt: options.appendSystemPrompt,
|
|
5890
|
+
maxTurns: clampInt$1(args["max_turns"], options.maxTurns, 100),
|
|
5891
|
+
timeoutMs: clampInt$1(args["timeout_secs"], options.timeoutSecs, TIMEOUT_SECS_CAP) * 1e3,
|
|
5892
|
+
...continuedSessionId !== "" ? { sessionId: continuedSessionId } : {},
|
|
5893
|
+
...signal !== void 0 ? { signal } : {},
|
|
5894
|
+
...liveTurn === void 0 ? {} : { onProgress: (progress) => {
|
|
5895
|
+
liveTurn.toolCalls = progress.toolCalls;
|
|
5896
|
+
liveTurn.lastTool = progress.lastTool;
|
|
5897
|
+
liveTurn.chars = progress.chars;
|
|
5898
|
+
} }
|
|
5899
|
+
}, credentials, log);
|
|
5900
|
+
if (liveTurn !== void 0) liveTurn.phase = "finishing";
|
|
5901
|
+
let reply = outcome.text;
|
|
5902
|
+
let truncated = false;
|
|
5903
|
+
if (reply.length > options.maxReplyChars) {
|
|
5904
|
+
const omitted = reply.length - options.maxReplyChars;
|
|
5905
|
+
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.]`;
|
|
5906
|
+
truncated = true;
|
|
5907
|
+
}
|
|
5908
|
+
const tools = [...outcome.toolCounts.entries()].slice(0, 12).map(([name, count]) => ({
|
|
5909
|
+
name,
|
|
5910
|
+
count
|
|
5911
|
+
}));
|
|
5912
|
+
rememberCliSession(sessionKey, outcome.sessionId ?? "");
|
|
5913
|
+
return result({
|
|
5914
|
+
ok: outcome.ok,
|
|
5915
|
+
reply,
|
|
5916
|
+
session_id: outcome.sessionId ?? "",
|
|
5917
|
+
tools,
|
|
5918
|
+
denied_tools: [...outcome.deniedTools],
|
|
5919
|
+
tool_calls: [...outcome.toolCounts.values()].reduce((sum, count) => sum + count, 0),
|
|
5920
|
+
num_turns: outcome.numTurns ?? 0,
|
|
5921
|
+
tokens_in: outcome.usage?.input_tokens ?? 0,
|
|
5922
|
+
tokens_out: outcome.usage?.output_tokens ?? 0,
|
|
5923
|
+
cache_read_tokens: outcome.usage?.cache_read_input_tokens ?? 0,
|
|
5924
|
+
credential_region: outcome.credential.region,
|
|
5925
|
+
working_dir: dir.dir,
|
|
5926
|
+
unattended: dir.unattended,
|
|
5927
|
+
elapsed_ms: Date.now() - startedAt,
|
|
5928
|
+
truncated,
|
|
5929
|
+
error: outcome.error ?? ""
|
|
5930
|
+
});
|
|
5931
|
+
}
|
|
5932
|
+
async function runAsk(args, execution, deps, entrance = "tool") {
|
|
5933
|
+
const startedAt = Date.now();
|
|
5934
|
+
const { options, log } = deps;
|
|
5742
5935
|
const mode = typeof args["permission_mode"] === "string" ? args["permission_mode"].trim() : "";
|
|
5743
5936
|
if (mode !== "" && !PERMISSION_MODES.includes(mode)) return result({
|
|
5744
5937
|
ok: false,
|
|
@@ -5760,73 +5953,48 @@ async function runAsk(args, execution, deps) {
|
|
|
5760
5953
|
error: dir.reason
|
|
5761
5954
|
});
|
|
5762
5955
|
const entry = (deps.gate ?? gateFor(options)).tryEnter(sessionKeyOf(execution, "codebuddy_ask"), (message) => log(message));
|
|
5763
|
-
if (!entry.ok)
|
|
5764
|
-
|
|
5765
|
-
|
|
5766
|
-
|
|
5956
|
+
if (!entry.ok) {
|
|
5957
|
+
if (dir.unattended) removeScratch(dir.dir);
|
|
5958
|
+
return result({
|
|
5959
|
+
ok: false,
|
|
5960
|
+
error: entry.error
|
|
5961
|
+
});
|
|
5962
|
+
}
|
|
5767
5963
|
try {
|
|
5768
|
-
|
|
5964
|
+
const modelArg = typeof args["model"] === "string" ? args["model"].trim() : "";
|
|
5965
|
+
const effectiveMode = mode !== "" ? mode : options.permissionMode;
|
|
5966
|
+
const liveTurn = deps.live?.begin({
|
|
5967
|
+
entrance,
|
|
5968
|
+
workingDir: dir.dir,
|
|
5969
|
+
unattended: dir.unattended,
|
|
5970
|
+
permissionMode: effectiveMode,
|
|
5971
|
+
model: modelArg !== "" ? modelArg : options.model,
|
|
5972
|
+
...execution?.agent?.id !== void 0 ? { sessionId: execution.agent.id } : {}
|
|
5973
|
+
});
|
|
5769
5974
|
try {
|
|
5770
|
-
|
|
5975
|
+
return await runAskTurn(args, execution, deps, {
|
|
5976
|
+
startedAt,
|
|
5977
|
+
dir,
|
|
5978
|
+
message,
|
|
5979
|
+
effort,
|
|
5980
|
+
mode,
|
|
5981
|
+
modelArg,
|
|
5982
|
+
effectiveMode,
|
|
5983
|
+
entry,
|
|
5984
|
+
...liveTurn === void 0 ? {} : { liveTurn }
|
|
5985
|
+
});
|
|
5771
5986
|
} catch (error) {
|
|
5772
5987
|
return result({
|
|
5773
5988
|
ok: false,
|
|
5774
|
-
error: error instanceof Error ? error.message : String(error)
|
|
5989
|
+
error: error instanceof Error ? error.message : String(error),
|
|
5990
|
+
elapsed_ms: Date.now() - startedAt
|
|
5775
5991
|
});
|
|
5992
|
+
} finally {
|
|
5993
|
+
if (liveTurn !== void 0) deps.live?.end(liveTurn);
|
|
5776
5994
|
}
|
|
5777
|
-
const modelArg = typeof args["model"] === "string" ? args["model"].trim() : "";
|
|
5778
|
-
const sessionIdArg = typeof args["session_id"] === "string" ? args["session_id"].trim() : "";
|
|
5779
|
-
const signal = execution?.signal;
|
|
5780
|
-
const outcome = await runAgent({
|
|
5781
|
-
prompt: message,
|
|
5782
|
-
cwd: dir.dir,
|
|
5783
|
-
binary: options.binary,
|
|
5784
|
-
model: modelArg !== "" ? modelArg : options.model,
|
|
5785
|
-
permissionMode: mode !== "" ? mode : options.permissionMode,
|
|
5786
|
-
effort: effort !== "" ? effort : options.effort,
|
|
5787
|
-
appendSystemPrompt: options.appendSystemPrompt,
|
|
5788
|
-
maxTurns: clampInt$1(args["max_turns"], options.maxTurns, 100),
|
|
5789
|
-
timeoutMs: clampInt$1(args["timeout_secs"], options.timeoutSecs, TIMEOUT_SECS_CAP) * 1e3,
|
|
5790
|
-
...sessionIdArg !== "" ? { sessionId: sessionIdArg } : {},
|
|
5791
|
-
...signal !== void 0 ? { signal } : {}
|
|
5792
|
-
}, credentials, log);
|
|
5793
|
-
let reply = outcome.text;
|
|
5794
|
-
let truncated = false;
|
|
5795
|
-
if (reply.length > options.maxReplyChars) {
|
|
5796
|
-
const omitted = reply.length - options.maxReplyChars;
|
|
5797
|
-
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.]`;
|
|
5798
|
-
truncated = true;
|
|
5799
|
-
}
|
|
5800
|
-
const tools = [...outcome.toolCounts.entries()].slice(0, 12).map(([name, count]) => ({
|
|
5801
|
-
name,
|
|
5802
|
-
count
|
|
5803
|
-
}));
|
|
5804
|
-
return result({
|
|
5805
|
-
ok: outcome.ok,
|
|
5806
|
-
reply,
|
|
5807
|
-
session_id: outcome.sessionId ?? "",
|
|
5808
|
-
tools,
|
|
5809
|
-
denied_tools: [...outcome.deniedTools],
|
|
5810
|
-
tool_calls: [...outcome.toolCounts.values()].reduce((sum, count) => sum + count, 0),
|
|
5811
|
-
num_turns: outcome.numTurns ?? 0,
|
|
5812
|
-
tokens_in: outcome.usage?.input_tokens ?? 0,
|
|
5813
|
-
tokens_out: outcome.usage?.output_tokens ?? 0,
|
|
5814
|
-
cache_read_tokens: outcome.usage?.cache_read_input_tokens ?? 0,
|
|
5815
|
-
credential_region: outcome.credential.region,
|
|
5816
|
-
working_dir: dir.dir,
|
|
5817
|
-
unattended: dir.unattended,
|
|
5818
|
-
elapsed_ms: Date.now() - startedAt,
|
|
5819
|
-
truncated,
|
|
5820
|
-
error: outcome.error ?? ""
|
|
5821
|
-
});
|
|
5822
|
-
} catch (error) {
|
|
5823
|
-
return result({
|
|
5824
|
-
ok: false,
|
|
5825
|
-
error: error instanceof Error ? error.message : String(error),
|
|
5826
|
-
elapsed_ms: Date.now() - startedAt
|
|
5827
|
-
});
|
|
5828
5995
|
} finally {
|
|
5829
5996
|
entry.release();
|
|
5997
|
+
if (dir.unattended) removeScratch(dir.dir);
|
|
5830
5998
|
}
|
|
5831
5999
|
}
|
|
5832
6000
|
/**
|
|
@@ -5875,7 +6043,7 @@ function mountAskTool(tools, deps) {
|
|
|
5875
6043
|
},
|
|
5876
6044
|
session_id: {
|
|
5877
6045
|
type: "string",
|
|
5878
|
-
description: "
|
|
6046
|
+
description: "可选:要续接的 CLI 会话 id。留空 = 由插件自动续接本会话上一轮的 CodeBuddy 对话(cliBridge.sessionContinuity=auto 时;=off 时留空就是每次开新会话)。只有想故意开一条新对话、或切到另一条对话时才显式传。"
|
|
5879
6047
|
},
|
|
5880
6048
|
allow_unattended: {
|
|
5881
6049
|
type: "boolean",
|
|
@@ -5962,29 +6130,44 @@ const REVIEW_PERMISSION_MODE = "dontAsk";
|
|
|
5962
6130
|
* human cannot tell the two apart in the output.
|
|
5963
6131
|
*/
|
|
5964
6132
|
const REVIEW_BRIEF = "评审当前工作区里未提交的改动(git 未提交改动;没有未提交改动时看最近一次提交)。自己运行 git 命令确认范围,必要时读相关文件理解上下文,然后给出评审结论:哪些地方有问题、为什么、怎么改。只报你确实看见的问题,不要猜。";
|
|
6133
|
+
/** The mode `--edit` selects for the one turn it appears on. */
|
|
6134
|
+
const EDIT_PERMISSION_MODE = "acceptEdits";
|
|
5965
6135
|
/**
|
|
5966
|
-
*
|
|
6136
|
+
* Read the composer line.
|
|
5967
6137
|
*
|
|
5968
6138
|
* Empty → the review brief. Non-empty → the text verbatim, because a human who
|
|
5969
6139
|
* typed something meant it, and silently prepending a review brief to "改一下
|
|
5970
6140
|
* 这个 bug" is how a command does something other than what it was asked.
|
|
6141
|
+
*
|
|
6142
|
+
* `--edit` is stripped and remembered rather than forwarded: CodeBuddy must not
|
|
6143
|
+
* see a flag that only means something to this bridge.
|
|
5971
6144
|
*/
|
|
5972
|
-
function
|
|
6145
|
+
function parseCommandInput(raw) {
|
|
5973
6146
|
const note = raw.trim();
|
|
5974
|
-
|
|
6147
|
+
if (note === "") return {
|
|
6148
|
+
prompt: REVIEW_BRIEF,
|
|
6149
|
+
label: "(未提交改动)",
|
|
6150
|
+
review: true,
|
|
6151
|
+
permissionMode: REVIEW_PERMISSION_MODE
|
|
6152
|
+
};
|
|
6153
|
+
const edit = /^--edit(?:\s|$)/u.test(note);
|
|
6154
|
+
const body = edit ? note.replace(/^--edit\s*/u, "").trim() : note;
|
|
6155
|
+
if (body === "") return {
|
|
6156
|
+
prompt: REVIEW_BRIEF,
|
|
6157
|
+
label: "(未提交改动,可写)",
|
|
6158
|
+
review: true,
|
|
6159
|
+
permissionMode: EDIT_PERMISSION_MODE
|
|
6160
|
+
};
|
|
6161
|
+
return {
|
|
6162
|
+
prompt: body,
|
|
6163
|
+
label: edit ? `(可写:${body})` : `(指令:${body})`,
|
|
6164
|
+
review: false,
|
|
6165
|
+
permissionMode: edit ? EDIT_PERMISSION_MODE : ""
|
|
6166
|
+
};
|
|
5975
6167
|
}
|
|
5976
6168
|
/** A one-line label saying how the human's words were used. */
|
|
5977
6169
|
function commandScopeLabel(raw) {
|
|
5978
|
-
|
|
5979
|
-
return note === "" ? "(未提交改动)" : `(指令:${note})`;
|
|
5980
|
-
}
|
|
5981
|
-
/** Whether this invocation is the built-in review (empty line). */
|
|
5982
|
-
function isReviewInvocation(raw) {
|
|
5983
|
-
return raw.trim() === "";
|
|
5984
|
-
}
|
|
5985
|
-
/** The permission mode this invocation runs in. */
|
|
5986
|
-
function permissionModeForInvocation(raw) {
|
|
5987
|
-
return isReviewInvocation(raw) ? REVIEW_PERMISSION_MODE : "";
|
|
6170
|
+
return parseCommandInput(raw).label;
|
|
5988
6171
|
}
|
|
5989
6172
|
/**
|
|
5990
6173
|
* Render one turn outcome as the plain text a command result carries.
|
|
@@ -6005,7 +6188,7 @@ function formatCommandOutcome(value, extra = {}) {
|
|
|
6005
6188
|
const body = value.reply.trim();
|
|
6006
6189
|
const head = body === "" ? `CodeBuddy 完成${scope}:没有输出任何结论。` : `CodeBuddy 完成${scope}:\n\n${body}`;
|
|
6007
6190
|
const toolList = value.tools.length > 0 ? value.tools.map((tool) => tool.count > 1 ? `${tool.name}×${String(tool.count)}` : tool.name).join(", ") : "无";
|
|
6008
|
-
const denied = value.denied_tools.length > 0 ? `\n\n[注意:CodeBuddy 请求过这些工具但被拒绝:${value.denied_tools.join(", ")}
|
|
6191
|
+
const denied = value.denied_tools.length > 0 ? `\n\n[注意:CodeBuddy 请求过这些工具但被拒绝:${value.denied_tools.join(", ")}。评审类任务读到这些就该够了;要它真动手,在指令前加 --edit(只影响这一轮),或在配置里设 cliBridge.permissionMode=acceptEdits(影响之后每一轮)]` : "";
|
|
6009
6192
|
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}` : "") + "]";
|
|
6010
6193
|
const truncated = value.truncated ? "\n\n[注意:输出已截断。让 CodeBuddy 把完整内容写到文件里再读,别只靠这一份摘要]" : "";
|
|
6011
6194
|
const delivery = extra.injection === void 0 ? "" : `\n\n${formatInjection(extra.injection)}`;
|
|
@@ -6031,8 +6214,8 @@ function mountCodebuddyCommand(commands, deps) {
|
|
|
6031
6214
|
try {
|
|
6032
6215
|
return commands.register({
|
|
6033
6216
|
name: COMMAND_NAME,
|
|
6034
|
-
description: "用 CodeBuddy 在当前工作区跑一轮;留空 =
|
|
6035
|
-
input: { hint: "
|
|
6217
|
+
description: "用 CodeBuddy 在当前工作区跑一轮;留空 = 评审未提交改动;以 --edit 开头 = 这一轮允许改文件。注意:默认(cliBridge.injectResult=true)跑完会把结论回注给本会话的模型,那一次唤醒会真的消耗 DSH token;设 injectResult=false 才完全不碰模型。CodeBuddy 那一侧的账号额度每次都会消耗。",
|
|
6218
|
+
input: { hint: "留空 = 评审未提交改动;写要求 = 只读执行(如\"看一下这个函数为什么崩\");要以 --edit 开头 = 允许改文件(如\"--edit 修一下这个空指针\")" },
|
|
6036
6219
|
handler: async (invocation) => {
|
|
6037
6220
|
const cwd = invocation.agent?.session?.header?.cwd;
|
|
6038
6221
|
if (typeof cwd !== "string" || cwd.trim() === "") return {
|
|
@@ -6040,18 +6223,17 @@ function mountCodebuddyCommand(commands, deps) {
|
|
|
6040
6223
|
text: "这个会话没有工作区路径(session.header.cwd 为空),无法确定 CodeBuddy 该在哪里跑。请在一个打开过目录的会话里再试。"
|
|
6041
6224
|
};
|
|
6042
6225
|
const raw = typeof invocation.rawInput === "string" ? invocation.rawInput : "";
|
|
6043
|
-
const
|
|
6044
|
-
const review = isReviewInvocation(raw);
|
|
6226
|
+
const parsed = parseCommandInput(raw);
|
|
6045
6227
|
const startedAt = Date.now();
|
|
6046
6228
|
try {
|
|
6047
6229
|
const value = await runAsk({
|
|
6048
|
-
message,
|
|
6230
|
+
message: parsed.prompt,
|
|
6049
6231
|
working_dir: cwd,
|
|
6050
|
-
...
|
|
6232
|
+
...parsed.permissionMode !== "" ? { permission_mode: parsed.permissionMode } : {}
|
|
6051
6233
|
}, {
|
|
6052
6234
|
...invocation.agent !== void 0 ? { agent: invocation.agent } : {},
|
|
6053
6235
|
...invocation.signal !== void 0 ? { signal: invocation.signal } : {}
|
|
6054
|
-
}, deps);
|
|
6236
|
+
}, deps, "command");
|
|
6055
6237
|
log(`command /${COMMAND_NAME} finished in ${String(Date.now() - startedAt)}ms (ok=${String(value.ok)})`);
|
|
6056
6238
|
const injection = value.ok && options.injectResult ? injectOutcome(invocation.agent, {
|
|
6057
6239
|
reply: value.reply,
|
|
@@ -6083,6 +6265,67 @@ function mountCodebuddyCommand(commands, deps) {
|
|
|
6083
6265
|
}
|
|
6084
6266
|
}
|
|
6085
6267
|
//#endregion
|
|
6268
|
+
//#region src/cli-bridge/live.ts
|
|
6269
|
+
/**
|
|
6270
|
+
* Build a board.
|
|
6271
|
+
*
|
|
6272
|
+
* Ordering is "oldest first", which is the order a human reads a queue in; the
|
|
6273
|
+
* chip draws the newest as the headline because that is the one the last click
|
|
6274
|
+
* started.
|
|
6275
|
+
*/
|
|
6276
|
+
function createLiveBoard() {
|
|
6277
|
+
const turns = [];
|
|
6278
|
+
let counter = 0;
|
|
6279
|
+
const begin = (input) => {
|
|
6280
|
+
counter += 1;
|
|
6281
|
+
const startedAt = Date.now();
|
|
6282
|
+
const turn = {
|
|
6283
|
+
id: `${startedAt.toString(36)}-${String(counter)}`,
|
|
6284
|
+
startedAt,
|
|
6285
|
+
entrance: input.entrance,
|
|
6286
|
+
workingDir: input.workingDir,
|
|
6287
|
+
unattended: input.unattended,
|
|
6288
|
+
permissionMode: input.permissionMode,
|
|
6289
|
+
model: input.model,
|
|
6290
|
+
sessionId: input.sessionId ?? "",
|
|
6291
|
+
phase: "starting",
|
|
6292
|
+
toolCalls: 0,
|
|
6293
|
+
lastTool: "",
|
|
6294
|
+
chars: 0
|
|
6295
|
+
};
|
|
6296
|
+
turns.push(turn);
|
|
6297
|
+
return turn;
|
|
6298
|
+
};
|
|
6299
|
+
const end = (turn) => {
|
|
6300
|
+
const at = turns.indexOf(turn);
|
|
6301
|
+
if (at >= 0) turns.splice(at, 1);
|
|
6302
|
+
};
|
|
6303
|
+
const snapshot = (now = Date.now()) => ({
|
|
6304
|
+
turns: turns.map((turn) => ({
|
|
6305
|
+
id: turn.id,
|
|
6306
|
+
startedAt: turn.startedAt,
|
|
6307
|
+
elapsedMs: Math.max(0, now - turn.startedAt),
|
|
6308
|
+
entrance: turn.entrance,
|
|
6309
|
+
workingDir: turn.workingDir,
|
|
6310
|
+
unattended: turn.unattended,
|
|
6311
|
+
permissionMode: turn.permissionMode,
|
|
6312
|
+
model: turn.model,
|
|
6313
|
+
sessionId: turn.sessionId,
|
|
6314
|
+
phase: turn.phase,
|
|
6315
|
+
toolCalls: turn.toolCalls,
|
|
6316
|
+
lastTool: turn.lastTool,
|
|
6317
|
+
chars: turn.chars
|
|
6318
|
+
})),
|
|
6319
|
+
serverTime: now
|
|
6320
|
+
});
|
|
6321
|
+
return {
|
|
6322
|
+
begin,
|
|
6323
|
+
end,
|
|
6324
|
+
snapshot,
|
|
6325
|
+
size: () => turns.length
|
|
6326
|
+
};
|
|
6327
|
+
}
|
|
6328
|
+
//#endregion
|
|
6086
6329
|
//#region src/cli-bridge/mount.ts
|
|
6087
6330
|
/**
|
|
6088
6331
|
* Wiring the CLI route into the plugin, and normalizing its settings.
|
|
@@ -6137,6 +6380,7 @@ const CLI_BRIDGE_DEFAULTS = {
|
|
|
6137
6380
|
allowUnattended: false,
|
|
6138
6381
|
maxReplyChars: 6e4,
|
|
6139
6382
|
injectResult: true,
|
|
6383
|
+
sessionContinuity: "auto",
|
|
6140
6384
|
maxTurnsPerSessionHour: 6,
|
|
6141
6385
|
maxTurnsPerHour: 20
|
|
6142
6386
|
};
|
|
@@ -6175,6 +6419,9 @@ function normalizeCliBridgeOptions(settings, onRejected) {
|
|
|
6175
6419
|
const safeEffort = effort === "" || EFFORT_LEVELS.includes(effort) ? effort : "";
|
|
6176
6420
|
if (safeEffort !== effort) reject(`effort "${effort}" 不是合法值,已回退到 CLI 默认;合法值:${EFFORT_LEVELS.join(", ")}`);
|
|
6177
6421
|
const regionOrder = text(raw.regionOrder, CLI_BRIDGE_DEFAULTS.regionOrder);
|
|
6422
|
+
const sessionContinuity = text(raw.sessionContinuity, CLI_BRIDGE_DEFAULTS.sessionContinuity);
|
|
6423
|
+
const safeSessionContinuity = SESSION_CONTINUITY_MODES.includes(sessionContinuity) ? sessionContinuity : CLI_BRIDGE_DEFAULTS.sessionContinuity;
|
|
6424
|
+
if (safeSessionContinuity !== sessionContinuity) reject(`sessionContinuity "${sessionContinuity}" 不是合法值,已回退到 ${CLI_BRIDGE_DEFAULTS.sessionContinuity};合法值:${SESSION_CONTINUITY_MODES.join(", ")}`);
|
|
6178
6425
|
return {
|
|
6179
6426
|
enabled: raw.enabled === true,
|
|
6180
6427
|
binary: text(raw.binary, CLI_BRIDGE_DEFAULTS.binary),
|
|
@@ -6190,6 +6437,7 @@ function normalizeCliBridgeOptions(settings, onRejected) {
|
|
|
6190
6437
|
allowUnattended: raw.allowUnattended === true,
|
|
6191
6438
|
maxReplyChars: clampInt(raw.maxReplyChars, CLI_BRIDGE_DEFAULTS.maxReplyChars, MAX_REPLY_CHARS_CAP),
|
|
6192
6439
|
injectResult: raw.injectResult !== false,
|
|
6440
|
+
sessionContinuity: safeSessionContinuity,
|
|
6193
6441
|
maxTurnsPerSessionHour: clampQuota(raw.maxTurnsPerSessionHour, CLI_BRIDGE_DEFAULTS.maxTurnsPerSessionHour),
|
|
6194
6442
|
maxTurnsPerHour: clampQuota(raw.maxTurnsPerHour, CLI_BRIDGE_DEFAULTS.maxTurnsPerHour)
|
|
6195
6443
|
};
|
|
@@ -6203,14 +6451,17 @@ function normalizeCliBridgeOptions(settings, onRejected) {
|
|
|
6203
6451
|
function createCliBridgeRuntime(settings, regions, log) {
|
|
6204
6452
|
const options = normalizeCliBridgeOptions(settings, (message) => log(message));
|
|
6205
6453
|
if (!options.enabled) return void 0;
|
|
6454
|
+
const live = createLiveBoard();
|
|
6206
6455
|
return {
|
|
6207
6456
|
options,
|
|
6208
6457
|
deps: {
|
|
6209
6458
|
options,
|
|
6210
6459
|
regions,
|
|
6211
6460
|
log,
|
|
6212
|
-
gate: gateFor(options)
|
|
6213
|
-
|
|
6461
|
+
gate: gateFor(options),
|
|
6462
|
+
live
|
|
6463
|
+
},
|
|
6464
|
+
live
|
|
6214
6465
|
};
|
|
6215
6466
|
}
|
|
6216
6467
|
/** Mount `codebuddy_ask` on a tools service, returning its disposer. */
|
|
@@ -8896,6 +9147,41 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
8896
9147
|
}
|
|
8897
9148
|
}
|
|
8898
9149
|
});
|
|
9150
|
+
/**
|
|
9151
|
+
* The CLI route's live turns.
|
|
9152
|
+
*
|
|
9153
|
+
* Registered even when the bridge is switched off, and answers an empty
|
|
9154
|
+
* document in that case: the composer chip polls this path unconditionally
|
|
9155
|
+
* (it cannot know the setting — the settings mirror is not readable from a
|
|
9156
|
+
* session component), so "off" has to be a 200 with nothing in it rather
|
|
9157
|
+
* than a 404 the chip would have to special-case.
|
|
9158
|
+
*
|
|
9159
|
+
* No region parameter: a live turn is not scoped to a credential region, and
|
|
9160
|
+
* `requestRegion` would 400 a perfectly valid read.
|
|
9161
|
+
*
|
|
9162
|
+
* A 404 is not an option here, and that is a second reason for the
|
|
9163
|
+
* unconditional registration: the chip keeps showing the last document it
|
|
9164
|
+
* read while a poll fails (it cannot tell "the route is gone" from "the
|
|
9165
|
+
* request dropped"), so a route that vanished on a settings change would
|
|
9166
|
+
* leave a ghost pill describing a turn that already ended. Answering an
|
|
9167
|
+
* empty document keeps "off" inside the same code path as "no turns".
|
|
9168
|
+
*/
|
|
9169
|
+
const disposeCliLive = ctx.webServer.register({
|
|
9170
|
+
kind: "exact",
|
|
9171
|
+
path: WORKBUDDY_CLI_LIVE_PATH,
|
|
9172
|
+
handler: (req, res) => {
|
|
9173
|
+
if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
|
|
9174
|
+
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
9175
|
+
try {
|
|
9176
|
+
json(res, 200, deps.cliLive?.() ?? {
|
|
9177
|
+
turns: [],
|
|
9178
|
+
serverTime: Date.now()
|
|
9179
|
+
});
|
|
9180
|
+
} catch (error) {
|
|
9181
|
+
json(res, 500, { error: safeMessage(error) });
|
|
9182
|
+
}
|
|
9183
|
+
}
|
|
9184
|
+
});
|
|
8899
9185
|
return () => {
|
|
8900
9186
|
disposeRefresh();
|
|
8901
9187
|
disposeCheckin();
|
|
@@ -8908,6 +9194,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
8908
9194
|
disposeOAuth();
|
|
8909
9195
|
disposeTransfer();
|
|
8910
9196
|
disposeDiagWrite();
|
|
9197
|
+
disposeCliLive();
|
|
8911
9198
|
};
|
|
8912
9199
|
}, "dsh-connect-workbuddy: Web status route");
|
|
8913
9200
|
}
|
|
@@ -9096,6 +9383,7 @@ const cliBridgeConfig = z.object({
|
|
|
9096
9383
|
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."),
|
|
9097
9384
|
maxReplyChars: z.number().step(1).min(1).default(6e4).description("Reply length cap handed back to the model, in characters. Clamped to 400000."),
|
|
9098
9385
|
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."),
|
|
9386
|
+
sessionContinuity: z.string().default("auto").description("Whether a second /codebuddy (or codebuddy_ask) in the same session continues the previous CodeBuddy conversation: auto (default) resumes it via --session-id, off starts a fresh one every time. An explicit session_id argument always wins over this setting."),
|
|
9099
9387
|
maxTurnsPerSessionHour: z.number().step(1).min(0).default(6).description("Bridged calls allowed per hour in one session. 0 disables the limit."),
|
|
9100
9388
|
maxTurnsPerHour: z.number().step(1).min(0).default(20).description("Bridged calls allowed per hour across the whole process. 0 disables the limit.")
|
|
9101
9389
|
});
|
|
@@ -9650,6 +9938,19 @@ function apply(ctx, config) {
|
|
|
9650
9938
|
client,
|
|
9651
9939
|
accountUsable: accountUsabilityProbe,
|
|
9652
9940
|
/**
|
|
9941
|
+
* The CLI route's live turns, for the composer readout.
|
|
9942
|
+
*
|
|
9943
|
+
* Read through `cliRuntime` rather than captured once: the runtime is rebuilt
|
|
9944
|
+
* whenever `cliBridge` settings change, and the board belongs to the runtime.
|
|
9945
|
+
* A captured board would keep answering for a runtime nothing drives any
|
|
9946
|
+
* more. Switched off means `undefined`, which the route reports as an empty
|
|
9947
|
+
* document.
|
|
9948
|
+
*/
|
|
9949
|
+
cliLive: () => cliRuntime?.live.snapshot() ?? {
|
|
9950
|
+
turns: [],
|
|
9951
|
+
serverTime: Date.now()
|
|
9952
|
+
},
|
|
9953
|
+
/**
|
|
9653
9954
|
* Why each of the region's accounts cannot serve right now, for the composer
|
|
9654
9955
|
* panel's table.
|
|
9655
9956
|
*
|