dsh-connect-workbuddy 5.4.0 → 5.5.1
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 +91 -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 +879 -159
- package/lib/{host-heartbeat-D5FWM_8k.js → host-heartbeat-BhgyeZWV.js} +11 -1
- package/lib/index.d.ts +79 -1
- package/lib/index.js +691 -150
- 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-BhgyeZWV.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,196 @@ 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.
|
|
454
|
+
*
|
|
455
|
+
* THE RESULT IS HEALED, not merely narrowed — see {@link healedRegionsMap}.
|
|
456
|
+
* Returning the map "as-is" is what let a corrupted document keep reading as
|
|
457
|
+
* half-empty: the field held `cn` at level 0 and `global` only one level down,
|
|
458
|
+
* so every `global` question answered "never configured" even though the file
|
|
459
|
+
* still had the answer. Reads and writes both come through here, so healing
|
|
460
|
+
* here fixes both halves at once (the Host's `regionStateOf`, the card's
|
|
461
|
+
* `nextRegion*` write bases, and the catalog repair's write).
|
|
429
462
|
*/
|
|
430
463
|
function regionsMapOf(value) {
|
|
464
|
+
const peeled = peelLiveReference(value);
|
|
465
|
+
if (typeof peeled === "object" && peeled !== null && !Array.isArray(peeled)) {
|
|
466
|
+
const candidate = peeled;
|
|
467
|
+
if (!isRecord$1(candidate["regions"]) && !carriesForeignKey(candidate)) return candidate;
|
|
468
|
+
}
|
|
431
469
|
const unwrapped = unwrapVolatileDeep(value);
|
|
432
470
|
if (typeof unwrapped !== "object" || unwrapped === null || Array.isArray(unwrapped)) return {};
|
|
433
471
|
const record = unwrapped;
|
|
434
472
|
const nested = record["regions"];
|
|
435
|
-
if (typeof nested
|
|
436
|
-
return record;
|
|
473
|
+
if (typeof nested !== "object" || nested === null || Array.isArray(nested)) return healedRegionsMap(record);
|
|
474
|
+
return healedRegionsMap(hasRegionKey(record) ? record : nested);
|
|
475
|
+
}
|
|
476
|
+
/** `value` with ONE `{get(): T}` live reference peeled, exactly like a read. */
|
|
477
|
+
function peelLiveReference(value) {
|
|
478
|
+
if (value === null || typeof value !== "object") return value;
|
|
479
|
+
const get = value.get;
|
|
480
|
+
return typeof get === "function" ? get() : value;
|
|
481
|
+
}
|
|
482
|
+
/** Whether the object carries a sibling-field name where a region id belongs. */
|
|
483
|
+
function carriesForeignKey(record) {
|
|
484
|
+
return Object.keys(record).some((key) => REGION_MAP_FOREIGN_KEYS.has(key));
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Names that belong to the settings SECTION (`Config` in `src/index.ts`) or to
|
|
488
|
+
* one region SLOT (`regionStateConfig`) — never to the `regions` map.
|
|
489
|
+
*
|
|
490
|
+
* They are the signature of a data-loss bug and the list that undoes it. From
|
|
491
|
+
* 5.4.0 to 5.5.0 `mergeRegionCatalog` was handed the WHOLE settings section but
|
|
492
|
+
* read it as the `regions` field, so every boot's directory repair wrote
|
|
493
|
+
* `{ accounts, regions, cliBridge }` INTO the field — replacing the region map
|
|
494
|
+
* and burying the real one under a `regions` key. A live profile ended up five
|
|
495
|
+
* levels deep (one per startup) and 258 KB of duplicated slots.
|
|
496
|
+
*
|
|
497
|
+
* Neither a region id (`cn` / `global`) nor any future one can collide with
|
|
498
|
+
* these names, so removing them from the map is safe, idempotent, and does not
|
|
499
|
+
* discard a region that a newer build might add.
|
|
500
|
+
*/
|
|
501
|
+
const REGION_MAP_FOREIGN_KEYS = /* @__PURE__ */ new Set([
|
|
502
|
+
"authFile",
|
|
503
|
+
"accountId",
|
|
504
|
+
"accounts",
|
|
505
|
+
"regions",
|
|
506
|
+
"cliBridge",
|
|
507
|
+
"enabled",
|
|
508
|
+
"showCreditsInMainUi",
|
|
509
|
+
"creditPosition",
|
|
510
|
+
"lastCatalog",
|
|
511
|
+
"enabledModelIds",
|
|
512
|
+
"imageModelIds",
|
|
513
|
+
"imageToolEnabled",
|
|
514
|
+
"offModelIds",
|
|
515
|
+
"contextBudgets",
|
|
516
|
+
"pool"
|
|
517
|
+
]);
|
|
518
|
+
/** A non-null, non-array object: the only shape a slot or a level can have. */
|
|
519
|
+
function isRecord$1(value) {
|
|
520
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
521
|
+
}
|
|
522
|
+
/** Whether a map carries at least one real region id. */
|
|
523
|
+
function hasRegionKey(map) {
|
|
524
|
+
return Object.keys(map).some((key) => WORKBUDDY_REGIONS.includes(key));
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* How much USER CONFIG one candidate slot actually carries.
|
|
528
|
+
*
|
|
529
|
+
* Used only when the map lost a region and the copies left at different depths
|
|
530
|
+
* disagree — and they do: the bug's own directory repair wrote a slot that held
|
|
531
|
+
* nothing but `lastCatalog` (it started from a map where the region was already
|
|
532
|
+
* missing), while the copy further down still holds the pool the user had
|
|
533
|
+
* checked in. "Shallowest wins" would therefore resurrect the EMPTY one and
|
|
534
|
+
* throw away the real configuration. Depth is not age in this wreckage; CONTENT
|
|
535
|
+
* is the only usable signal.
|
|
536
|
+
*
|
|
537
|
+
* Deliberately counts only non-default, user-authored values: resolved slots
|
|
538
|
+
* carry every schema default, so a slot's key count says nothing.
|
|
539
|
+
*/
|
|
540
|
+
function slotConfigWeight(slot) {
|
|
541
|
+
let weight = 0;
|
|
542
|
+
const pool = slot["pool"];
|
|
543
|
+
if (isRecord$1(pool)) {
|
|
544
|
+
if (pool["enabled"] === true) weight += 4;
|
|
545
|
+
if (Array.isArray(pool["memberAccountIds"]) && pool["memberAccountIds"].length > 0) weight += 4;
|
|
546
|
+
if (Array.isArray(pool["orderAccountIds"]) && pool["orderAccountIds"].length > 0) weight += 2;
|
|
547
|
+
if (typeof pool["targetModelId"] === "string" && pool["targetModelId"] !== "") weight += 2;
|
|
548
|
+
if (typeof pool["probeInputTokens"] === "number" && pool["probeInputTokens"] > 0) weight += 1;
|
|
549
|
+
}
|
|
550
|
+
for (const key of [
|
|
551
|
+
"enabledModelIds",
|
|
552
|
+
"imageModelIds",
|
|
553
|
+
"offModelIds"
|
|
554
|
+
]) {
|
|
555
|
+
const list = slot[key];
|
|
556
|
+
if (Array.isArray(list) && list.length > 0) weight += 1;
|
|
557
|
+
}
|
|
558
|
+
const budgets = slot["contextBudgets"];
|
|
559
|
+
if (isRecord$1(budgets) && Object.keys(budgets).length > 0) weight += 1;
|
|
560
|
+
if (slot["enabled"] === false) weight += 1;
|
|
561
|
+
if (slot["showCreditsInMainUi"] === false) weight += 1;
|
|
562
|
+
if (slot["imageToolEnabled"] === true) weight += 1;
|
|
563
|
+
return weight;
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* The `regions` map with a corrupted document's wreckage removed.
|
|
567
|
+
*
|
|
568
|
+
* Two repairs, both keyed on the bug's signature (a foreign key or a nested
|
|
569
|
+
* `regions` copy) so a HEALTHY map passes through untouched:
|
|
570
|
+
*
|
|
571
|
+
* 1. DROP every key that names a section field or a slot field. Those are the
|
|
572
|
+
* copies the bug wrote; keeping them means the next save carries them
|
|
573
|
+
* forward forever.
|
|
574
|
+
* 2. RECOVER a region this level does not have from the copies buried beneath
|
|
575
|
+
* it. A region that IS here is never replaced — the outer copy is the one
|
|
576
|
+
* the surviving writers have been updating, and re-reading a stale inner
|
|
577
|
+
* copy over it is exactly the defect the shape rule above was fixed for.
|
|
578
|
+
*
|
|
579
|
+
* Recovery exists because `global` in the measured profile lived ONLY one level
|
|
580
|
+
* down: without it, the host reads that region as "never configured" and the
|
|
581
|
+
* first write after the fix would delete the user's international pool outright.
|
|
582
|
+
*
|
|
583
|
+
* Among the buried copies of a MISSING region, the one carrying the most user
|
|
584
|
+
* configuration wins rather than the shallowest — see {@link slotConfigWeight}:
|
|
585
|
+
* the bug's own directory repair left `{ lastCatalog }` slots that look newer
|
|
586
|
+
* but hold nothing, one level above the copy with the pool still in it.
|
|
587
|
+
*/
|
|
588
|
+
function healedRegionsMap(map) {
|
|
589
|
+
const healed = {};
|
|
590
|
+
for (const [key, slot] of Object.entries(map)) {
|
|
591
|
+
if (REGION_MAP_FOREIGN_KEYS.has(key)) continue;
|
|
592
|
+
healed[key] = slot;
|
|
593
|
+
}
|
|
594
|
+
const recovered = {};
|
|
595
|
+
let deeper = map;
|
|
596
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
597
|
+
const nested = deeper["regions"];
|
|
598
|
+
if (!isRecord$1(nested)) break;
|
|
599
|
+
for (const region of WORKBUDDY_REGIONS) {
|
|
600
|
+
if (region in map) continue;
|
|
601
|
+
const candidate = nested[region];
|
|
602
|
+
if (!isRecord$1(candidate)) continue;
|
|
603
|
+
const weight = slotConfigWeight(candidate);
|
|
604
|
+
const best = recovered[region];
|
|
605
|
+
if (best === void 0 || weight > best.weight) recovered[region] = {
|
|
606
|
+
slot: candidate,
|
|
607
|
+
weight
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
deeper = nested;
|
|
611
|
+
}
|
|
612
|
+
for (const [region, entry] of Object.entries(recovered)) healed[region] = entry.slot;
|
|
613
|
+
return healed;
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Whether the stored `regions` FIELD still carries the debris described at
|
|
617
|
+
* {@link REGION_MAP_FOREIGN_KEYS}.
|
|
618
|
+
*
|
|
619
|
+
* Pass the field itself (`Config['regions']`), not the whole section: the
|
|
620
|
+
* section legitimately has `accounts` / `cliBridge` beside `regions`, so asking
|
|
621
|
+
* that question of a section would always answer "corrupt".
|
|
622
|
+
*
|
|
623
|
+
* The catalog repair uses it to write even when the model directory already
|
|
624
|
+
* matches: without that, a profile whose directory is in agreement would never
|
|
625
|
+
* be cleaned — the very shape that made the pool look unsaved would outlive the
|
|
626
|
+
* fix that stops producing it.
|
|
627
|
+
*/
|
|
628
|
+
function regionsFieldIsCorrupt(value) {
|
|
629
|
+
const unwrapped = unwrapVolatileDeep(value);
|
|
630
|
+
if (!isRecord$1(unwrapped)) return false;
|
|
631
|
+
return Object.keys(unwrapped).some((key) => REGION_MAP_FOREIGN_KEYS.has(key));
|
|
437
632
|
}
|
|
438
633
|
/** One region's stored slot as a plain object; any other shape reads as empty. */
|
|
439
634
|
function regionSlotOf(value, region) {
|
|
@@ -534,8 +729,8 @@ const WORKBUDDY_SETTINGS_NS = "workbuddy";
|
|
|
534
729
|
/**
|
|
535
730
|
* Writing the model's saved directory back to the Host's settings document.
|
|
536
731
|
*
|
|
537
|
-
* THE HOST-SIDE HALF OF ISSUES #114/#115. The card's own save path lives in
|
|
538
|
-
*
|
|
732
|
+
* THE HOST-SIDE HALF OF ISSUES #114/#115. The card's own save path lives in the
|
|
733
|
+
* browser half (`src/client/`), but the repair that heals a STALE saved
|
|
539
734
|
* directory has to happen where the LIVE roster lands — inside the Host
|
|
540
735
|
* process, right after the upstream answered. So this module writes through
|
|
541
736
|
* the Host's own settings service, using the same merge discipline the
|
|
@@ -548,6 +743,19 @@ const WORKBUDDY_SETTINGS_NS = "workbuddy";
|
|
|
548
743
|
* replaced, every other key of the slot is preserved, and the sibling region
|
|
549
744
|
* is untouched.
|
|
550
745
|
*
|
|
746
|
+
* THE VALUE THIS MODULE WRITES IS THE `regions` FIELD, NOT THE SETTINGS
|
|
747
|
+
* SECTION. That distinction is the whole of this bug — reported as
|
|
748
|
+
* "账号池状态重启就失效". `describe()` hands back the resolved SECTION
|
|
749
|
+
* (`{authFile, accounts, regions, cliBridge, …}`), and
|
|
750
|
+
* `mutate(ns, [{ op: 'set', path: ['regions'], … }])` stores whatever it is
|
|
751
|
+
* given AT that path. Handing it the section therefore wrote the section INTO
|
|
752
|
+
* the field: the real region map was pushed down under a nested `regions` key,
|
|
753
|
+
* its own siblings (`accounts`, `cliBridge`) became fake region slots, and the
|
|
754
|
+
* Host's `regionStateOf(config, 'cn')` — which reads exactly one level — then
|
|
755
|
+
* answered "no slot". Every account-pool preference, model selection and
|
|
756
|
+
* directory looked unsaved after the next restart, and each startup added
|
|
757
|
+
* another level (measured on a live profile: five levels, 258 KB).
|
|
758
|
+
*
|
|
551
759
|
* WHY THE HOST SERVICE RATHER THAN THE SCOPE. The plugin's browser half
|
|
552
760
|
* writes through the Host's settings endpoint because the browser-side
|
|
553
761
|
* `scope.set()` replaces the whole field with the browser's own (possibly
|
|
@@ -558,25 +766,27 @@ const WORKBUDDY_SETTINGS_NS = "workbuddy";
|
|
|
558
766
|
* @module dsh-connect-workbuddy/catalog-write
|
|
559
767
|
*/
|
|
560
768
|
/**
|
|
561
|
-
* Merge one region's repaired directory into the `regions`
|
|
562
|
-
*
|
|
769
|
+
* Merge one region's repaired directory into the `regions` FIELD.
|
|
770
|
+
*
|
|
771
|
+
* `current` may be EITHER the resolved settings section (what `describe()`
|
|
772
|
+
* answers, and what {@link writeRegionCatalog} passes) OR the bare `regions`
|
|
773
|
+
* field; `regionsMapOf` narrows both and heals a document the bug above already
|
|
774
|
+
* corrupted. The RETURN value is always the field itself — the exact shape
|
|
775
|
+
* `path: ['regions']` expects — never the section around it.
|
|
563
776
|
*
|
|
564
777
|
* The merge is done on the AUTHORITATIVE value the settings service holds —
|
|
565
778
|
* not on a snapshot the caller brought — because a snapshot can be stale and
|
|
566
779
|
* a stale merge is how the other region disappears.
|
|
567
780
|
*/
|
|
568
781
|
function mergeRegionCatalog(current, region, catalog) {
|
|
569
|
-
const
|
|
570
|
-
const
|
|
571
|
-
const slot =
|
|
782
|
+
const regions = regionsMapOf(current);
|
|
783
|
+
const stored = regions[region];
|
|
784
|
+
const slot = typeof stored === "object" && stored !== null && !Array.isArray(stored) ? stored : {};
|
|
572
785
|
return {
|
|
573
|
-
...
|
|
574
|
-
|
|
575
|
-
...
|
|
576
|
-
|
|
577
|
-
...slot,
|
|
578
|
-
lastCatalog: catalog
|
|
579
|
-
}
|
|
786
|
+
...regions,
|
|
787
|
+
[region]: {
|
|
788
|
+
...slot,
|
|
789
|
+
lastCatalog: catalog
|
|
580
790
|
}
|
|
581
791
|
};
|
|
582
792
|
}
|
|
@@ -601,6 +811,39 @@ async function writeRegionCatalog(service, ns, region, catalog) {
|
|
|
601
811
|
}], void 0);
|
|
602
812
|
return true;
|
|
603
813
|
}
|
|
814
|
+
/**
|
|
815
|
+
* Clean a `regions` field an older build corrupted, WITHOUT touching any
|
|
816
|
+
* region's content.
|
|
817
|
+
*
|
|
818
|
+
* WHY THIS IS SEPARATE FROM {@link writeRegionCatalog}. The catalog repair is
|
|
819
|
+
* the writer that produced the wreckage, so it is also a natural place to clean
|
|
820
|
+
* it — but it only runs when the live roster arrived (an enabled region, a
|
|
821
|
+
* reachable upstream, a non-empty answer). A profile would therefore stay dirty
|
|
822
|
+
* for as long as the network is down, and a region the user has switched OFF
|
|
823
|
+
* would never be cleaned at all. This write needs neither: it is one settings
|
|
824
|
+
* round-trip at boot, and it is a no-op for every healthy profile (a single key
|
|
825
|
+
* scan, no mutation).
|
|
826
|
+
*
|
|
827
|
+
* It is a pure shape fix, not a merge: `regionsMapOf` already answers the
|
|
828
|
+
* healed map, and the field is replaced with exactly that. Reads are correct
|
|
829
|
+
* without this write (they narrow through the same helper), so failing to write
|
|
830
|
+
* costs tidiness only — the caller logs and carries on.
|
|
831
|
+
*
|
|
832
|
+
* @returns whether the write happened.
|
|
833
|
+
*/
|
|
834
|
+
async function healRegionsField(service, ns) {
|
|
835
|
+
if (service === void 0) return false;
|
|
836
|
+
const row = service.describe().find((entry) => entry.ns === ns);
|
|
837
|
+
if (row === void 0) return false;
|
|
838
|
+
const section = row.value ?? {};
|
|
839
|
+
if (!regionsFieldIsCorrupt(section["regions"])) return false;
|
|
840
|
+
await service.mutate(ns, [{
|
|
841
|
+
op: "set",
|
|
842
|
+
path: ["regions"],
|
|
843
|
+
value: regionsMapOf(section)
|
|
844
|
+
}], void 0);
|
|
845
|
+
return true;
|
|
846
|
+
}
|
|
604
847
|
//#endregion
|
|
605
848
|
//#region src/credential-recovery.ts
|
|
606
849
|
/** How long a verdict stays fresh by default. */
|
|
@@ -4663,22 +4906,26 @@ const INJECT_TEXT_LIMIT = 2e5;
|
|
|
4663
4906
|
/**
|
|
4664
4907
|
* The `source.kind` stamped on every message this module writes.
|
|
4665
4908
|
*
|
|
4666
|
-
*
|
|
4667
|
-
*
|
|
4668
|
-
*
|
|
4669
|
-
*
|
|
4670
|
-
*
|
|
4671
|
-
*
|
|
4672
|
-
*
|
|
4673
|
-
*
|
|
4674
|
-
*
|
|
4675
|
-
*
|
|
4676
|
-
*
|
|
4677
|
-
*
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
*
|
|
4909
|
+
* This used to be `'user'`, on the reading that `MessageSource` is a closed
|
|
4910
|
+
* union and `'user'` is the only slot a plugin-written turn message can occupy.
|
|
4911
|
+
* That reading was wrong. `MessageSourceMap` is MERGE-EXTENSIBLE — its own doc
|
|
4912
|
+
* comment says "each producer declares its own `kind` in its own module; there
|
|
4913
|
+
* is no shared catch-all `plugin` kind", and consumers fall through unknown
|
|
4914
|
+
* kinds — so `'user'` was never the only option, it was the dishonest one: the
|
|
4915
|
+
* transcript rendered the injected CodeBuddy result as a user bubble, and the
|
|
4916
|
+
* UI had no way to label the row as a notification. `dsh-connect-atomcode`
|
|
4917
|
+
* stamps `'atomcode-bridge'` for exactly this reason.
|
|
4918
|
+
*
|
|
4919
|
+
* The declaration that makes the union accept it lives in
|
|
4920
|
+
* `src/dsh-llm-augment.d.ts`.
|
|
4921
|
+
*
|
|
4922
|
+
* `PROVENANCE_LINE` still opens the text: the kind is what the UI and later
|
|
4923
|
+
* readers key on, but the text is what the MODEL reads, and a model cannot see
|
|
4924
|
+
* `source`.
|
|
4925
|
+
*/
|
|
4926
|
+
const INJECT_SOURCE_KIND = "codebuddy-bridge";
|
|
4927
|
+
/** The first line of every injected message. Not decoration: the model never
|
|
4928
|
+
* sees `source.kind`, so this line is its only provenance signal. */
|
|
4682
4929
|
const PROVENANCE_LINE = "[dsh-connect-workbuddy · 由 /codebuddy 自动回注,不是用户输入]";
|
|
4683
4930
|
/**
|
|
4684
4931
|
* An instruction appended so a model does not turn the result into a loop.
|
|
@@ -5005,37 +5252,62 @@ const EXIT_GRACE_MS = 3e3;
|
|
|
5005
5252
|
*/
|
|
5006
5253
|
const DENY_REASON = "dsh-connect-workbuddy runs this turn unattended; no human is at the terminal to approve tool use";
|
|
5007
5254
|
/**
|
|
5008
|
-
*
|
|
5255
|
+
* Directories that ARE a `node_modules` root a global install can live in,
|
|
5256
|
+
* most likely first.
|
|
5009
5257
|
*
|
|
5010
5258
|
* WHY THE LIST IS THIS LONG. The first draft knew only about `%APPDATA%\npm`
|
|
5011
5259
|
* and the two `/usr/*` prefixes, so every nvm, fnm, volta, pnpm, yarn, brew,
|
|
5012
5260
|
* `~/.local` and `/opt/homebrew` user got "CLI not found" even though it was
|
|
5013
5261
|
* installed. The version-manager roots cannot be listed statically (they carry
|
|
5014
|
-
* a version or a hash in the path), so `
|
|
5262
|
+
* a version or a hash in the path), so `versionedCliRoots` globs one level.
|
|
5263
|
+
*
|
|
5264
|
+
* WHY EVERY ENTRY SPELLS OUT ITS OWN LAYOUT. A global npm prefix is not shaped
|
|
5265
|
+
* the same way on every platform. On POSIX a prefix holds
|
|
5266
|
+
* `<prefix>/lib/node_modules`; on Windows the prefix `%APPDATA%\npm` holds
|
|
5267
|
+
* `<prefix>\node_modules` directly and has no `lib` level at all. Appending
|
|
5268
|
+
* `lib/node_modules` to every root — which an earlier revision of this list
|
|
5269
|
+
* did — produced paths that exist on no Windows machine, so auto-detection
|
|
5270
|
+
* failed for every Windows user while the POSIX roots kept working. The
|
|
5271
|
+
* pnpm and yarn stores are their own `node_modules` root for the same reason.
|
|
5015
5272
|
*
|
|
5016
5273
|
* None of these are probed through PATH, and `cbc` is not a separate target: it
|
|
5017
5274
|
* is an alias of the same `bin/codebuddy` file inside the package.
|
|
5018
5275
|
*/
|
|
5019
|
-
function
|
|
5276
|
+
function cliModuleDirs() {
|
|
5020
5277
|
const home = homedir();
|
|
5021
|
-
const
|
|
5278
|
+
const dirs = [];
|
|
5022
5279
|
const appData = process.env["APPDATA"];
|
|
5023
|
-
if (typeof appData === "string" && appData !== "")
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
|
|
5028
|
-
|
|
5280
|
+
if (typeof appData === "string" && appData !== "") dirs.push(join(appData, "npm", "node_modules"));
|
|
5281
|
+
dirs.push(join(home, "AppData", "Roaming", "npm", "node_modules"));
|
|
5282
|
+
for (const prefix of [
|
|
5283
|
+
join(home, ".npm-global"),
|
|
5284
|
+
"/usr/local",
|
|
5285
|
+
"/opt/homebrew",
|
|
5286
|
+
join(home, ".local")
|
|
5287
|
+
]) dirs.push(join(prefix, "lib", "node_modules"));
|
|
5288
|
+
dirs.push(join(home, ".local", "share", "pnpm", "global", "5", "node_modules"));
|
|
5289
|
+
dirs.push(join(home, "Library", "pnpm", "global", "5", "node_modules"));
|
|
5290
|
+
const localAppData = process.env["LOCALAPPDATA"];
|
|
5291
|
+
if (typeof localAppData === "string" && localAppData !== "") {
|
|
5292
|
+
dirs.push(join(localAppData, "pnpm", "global", "5", "node_modules"));
|
|
5293
|
+
dirs.push(join(localAppData, "Yarn", "Data", "global", "node_modules"));
|
|
5294
|
+
}
|
|
5295
|
+
dirs.push(join(home, ".config", "yarn", "global", "node_modules"));
|
|
5296
|
+
return dirs;
|
|
5029
5297
|
}
|
|
5030
5298
|
/** Directories whose children are node version roots (`.../versions/node/<v>`). */
|
|
5031
5299
|
function versionedCliRoots() {
|
|
5032
5300
|
const home = homedir();
|
|
5033
|
-
|
|
5301
|
+
const roots = [
|
|
5034
5302
|
join(home, ".nvm", "versions", "node"),
|
|
5035
5303
|
join(home, "n", "versions", "node"),
|
|
5036
5304
|
join(home, ".fnm", "node-versions"),
|
|
5037
|
-
join(home, ".local", "share", "fnm", "node-versions")
|
|
5305
|
+
join(home, ".local", "share", "fnm", "node-versions"),
|
|
5306
|
+
join(home, ".volta", "tools", "image", "node")
|
|
5038
5307
|
];
|
|
5308
|
+
const appData = process.env["APPDATA"];
|
|
5309
|
+
if (typeof appData === "string" && appData !== "") roots.push(join(appData, "fnm", "node-versions"));
|
|
5310
|
+
return roots;
|
|
5039
5311
|
}
|
|
5040
5312
|
/** Expand one directory's children into CLI entries, ignoring unreadable ones. */
|
|
5041
5313
|
function childrenOf(dir, separator) {
|
|
@@ -5047,21 +5319,21 @@ function childrenOf(dir, separator) {
|
|
|
5047
5319
|
}
|
|
5048
5320
|
/** Every path a global install of the CLI could live at, most likely first. */
|
|
5049
5321
|
function cliEntryCandidates() {
|
|
5050
|
-
const out = [...
|
|
5322
|
+
const out = [...cliModuleDirs().map((dir) => join(dir, CLI_PACKAGE_PATH))];
|
|
5051
5323
|
for (const versions of versionedCliRoots()) {
|
|
5052
5324
|
out.push(...childrenOf(versions, [
|
|
5053
5325
|
"lib",
|
|
5054
5326
|
"node_modules",
|
|
5055
5327
|
CLI_PACKAGE_PATH
|
|
5056
5328
|
]));
|
|
5057
|
-
out.push(...childrenOf(
|
|
5329
|
+
out.push(...childrenOf(versions, [
|
|
5058
5330
|
"installation",
|
|
5059
5331
|
"lib",
|
|
5060
5332
|
"node_modules",
|
|
5061
5333
|
CLI_PACKAGE_PATH
|
|
5062
5334
|
]));
|
|
5063
5335
|
}
|
|
5064
|
-
return out;
|
|
5336
|
+
return [...new Set(out)];
|
|
5065
5337
|
}
|
|
5066
5338
|
/**
|
|
5067
5339
|
* Locate the CLI's JS entry.
|
|
@@ -5217,6 +5489,8 @@ function runOneTurn(options, token) {
|
|
|
5217
5489
|
const assistantText = [];
|
|
5218
5490
|
let assistantChars = 0;
|
|
5219
5491
|
const toolCounts = /* @__PURE__ */ new Map();
|
|
5492
|
+
let liveToolCalls = 0;
|
|
5493
|
+
let lastToolName = "";
|
|
5220
5494
|
const deniedTools = [];
|
|
5221
5495
|
let stderr = "";
|
|
5222
5496
|
let buffer = "";
|
|
@@ -5361,7 +5635,21 @@ function runOneTurn(options, token) {
|
|
|
5361
5635
|
return;
|
|
5362
5636
|
}
|
|
5363
5637
|
assistantText.push(block.text);
|
|
5364
|
-
|
|
5638
|
+
options.onProgress?.({
|
|
5639
|
+
toolCalls: liveToolCalls,
|
|
5640
|
+
lastTool: lastToolName,
|
|
5641
|
+
chars: assistantChars
|
|
5642
|
+
});
|
|
5643
|
+
} else if (block.type === "tool_use" && typeof block.name === "string") {
|
|
5644
|
+
toolCounts.set(block.name, (toolCounts.get(block.name) ?? 0) + 1);
|
|
5645
|
+
liveToolCalls += 1;
|
|
5646
|
+
lastToolName = block.name;
|
|
5647
|
+
options.onProgress?.({
|
|
5648
|
+
toolCalls: liveToolCalls,
|
|
5649
|
+
lastTool: lastToolName,
|
|
5650
|
+
chars: assistantChars
|
|
5651
|
+
});
|
|
5652
|
+
}
|
|
5365
5653
|
}
|
|
5366
5654
|
if (frame.type === "result") {
|
|
5367
5655
|
settle(frame);
|
|
@@ -5472,6 +5760,16 @@ const EFFORT_LEVELS = [
|
|
|
5472
5760
|
const TIMEOUT_SECS_CAP = 1800;
|
|
5473
5761
|
/** Hard ceiling on the reply length, in characters. */
|
|
5474
5762
|
const MAX_REPLY_CHARS_CAP = 4e5;
|
|
5763
|
+
/**
|
|
5764
|
+
* What `cliBridge.sessionContinuity` accepts.
|
|
5765
|
+
*
|
|
5766
|
+
* `auto` threads the previous CLI session into the next call; `off` leaves every
|
|
5767
|
+
* call independent unless the caller passes `session_id` itself. There is no
|
|
5768
|
+
* `on`-with-a-different-mechanism value: the one that was measured to work is
|
|
5769
|
+
* `--session-id <existing>`, and offering `--continue` (whatever ran last in the
|
|
5770
|
+
* cwd) next to it would make two concurrent sessions race for one conversation.
|
|
5771
|
+
*/
|
|
5772
|
+
const SESSION_CONTINUITY_MODES = ["auto", "off"];
|
|
5475
5773
|
/** Every field defaulted, so the Host's closed schema always validates. */
|
|
5476
5774
|
function result(partial) {
|
|
5477
5775
|
return {
|
|
@@ -5497,11 +5795,62 @@ function result(partial) {
|
|
|
5497
5795
|
function clampInt$1(value, fallback, cap) {
|
|
5498
5796
|
return Math.max(1, Math.min(cap, typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : fallback));
|
|
5499
5797
|
}
|
|
5500
|
-
/**
|
|
5798
|
+
/**
|
|
5799
|
+
* Last CLI session id per DSH session, for automatic continuation.
|
|
5800
|
+
*
|
|
5801
|
+
* WHY A MAP AND NOT JUST A TOOL ARGUMENT. `--session-id <existing>` really does
|
|
5802
|
+
* resume the conversation — measured against the installed CLI: a number sent in
|
|
5803
|
+
* turn 1 came back from turn 2, and the CLI echoed the same id both times. But
|
|
5804
|
+
* that id only reaches the CLI when the CALLER passes it back as `session_id`,
|
|
5805
|
+
* which asks the model to copy a uuid out of the previous result. Best effort,
|
|
5806
|
+
* not a guarantee. Remembering the id here makes continuation the default rather
|
|
5807
|
+
* than a habit.
|
|
5808
|
+
*
|
|
5809
|
+
* IN MEMORY ON PURPOSE. The value is a uuid owned by the CLI, and an unknown id
|
|
5810
|
+
* is not an error there — the CLI silently starts a fresh session under it. So
|
|
5811
|
+
* the failure mode of a lost or stale entry is one new conversation, never a
|
|
5812
|
+
* mixed-up one, which is the right trade for a file the plugin would otherwise
|
|
5813
|
+
* have to garbage-collect. Bounded (FIFO) so a long-lived process cannot grow it
|
|
5814
|
+
* without limit.
|
|
5815
|
+
*/
|
|
5816
|
+
const CLI_SESSION_MEMORY = /* @__PURE__ */ new Map();
|
|
5817
|
+
const CLI_SESSION_MEMORY_CAP = 256;
|
|
5818
|
+
/** Remember the CLI session a DSH session should continue next time. */
|
|
5819
|
+
function rememberCliSession(sessionKey, cliSessionId) {
|
|
5820
|
+
if (sessionKey === "" || cliSessionId === "") return;
|
|
5821
|
+
CLI_SESSION_MEMORY.delete(sessionKey);
|
|
5822
|
+
CLI_SESSION_MEMORY.set(sessionKey, cliSessionId);
|
|
5823
|
+
while (CLI_SESSION_MEMORY.size > CLI_SESSION_MEMORY_CAP) {
|
|
5824
|
+
const oldest = CLI_SESSION_MEMORY.keys().next();
|
|
5825
|
+
if (oldest.done === true) break;
|
|
5826
|
+
CLI_SESSION_MEMORY.delete(oldest.value);
|
|
5827
|
+
}
|
|
5828
|
+
}
|
|
5829
|
+
/** The CLI session to continue for this DSH session, or `''` for a fresh one. */
|
|
5830
|
+
function recallCliSession(sessionKey) {
|
|
5831
|
+
return CLI_SESSION_MEMORY.get(sessionKey) ?? "";
|
|
5832
|
+
}
|
|
5833
|
+
/**
|
|
5834
|
+
* A throwaway directory for ONE unattended turn.
|
|
5835
|
+
*
|
|
5836
|
+
* ONE DIRECTORY PER CALL, not a fixed `dsh-codebuddy-scratch`. A single shared
|
|
5837
|
+
* path meant two concurrent unattended turns ran in the same writable space —
|
|
5838
|
+
* each could read and overwrite the other's files, and anything a previous turn
|
|
5839
|
+
* left behind was still there. `mkdtemp` guarantees a fresh, empty, uniquely
|
|
5840
|
+
* named directory, which is what makes the "exempt by construction" argument in
|
|
5841
|
+
* `resolveWorkingDir` actually true.
|
|
5842
|
+
*/
|
|
5501
5843
|
function scratchDir() {
|
|
5502
|
-
|
|
5503
|
-
|
|
5504
|
-
|
|
5844
|
+
return mkdtempSync(join(tmpdir(), "dsh-codebuddy-scratch-"));
|
|
5845
|
+
}
|
|
5846
|
+
/** Best-effort removal: a locked file must not turn a finished turn into an error. */
|
|
5847
|
+
function removeScratch(dir) {
|
|
5848
|
+
try {
|
|
5849
|
+
rmSync(dir, {
|
|
5850
|
+
recursive: true,
|
|
5851
|
+
force: true
|
|
5852
|
+
});
|
|
5853
|
+
} catch {}
|
|
5505
5854
|
}
|
|
5506
5855
|
/** Case-fold a path on the platforms whose filesystems ignore case. */
|
|
5507
5856
|
function foldCase(path) {
|
|
@@ -5729,16 +6078,77 @@ function sessionKeyOf(execution, fallback) {
|
|
|
5729
6078
|
const id = execution?.agent?.id;
|
|
5730
6079
|
return typeof id === "string" && id !== "" ? id : fallback;
|
|
5731
6080
|
}
|
|
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();
|
|
6081
|
+
/** The part of a turn that runs once the budget and the directory are settled. */
|
|
6082
|
+
async function runAskTurn(args, execution, deps, context) {
|
|
6083
|
+
const { startedAt, dir, message, effort, modelArg, effectiveMode, liveTurn } = context;
|
|
5741
6084
|
const { options, regions, log } = deps;
|
|
6085
|
+
let credentials;
|
|
6086
|
+
try {
|
|
6087
|
+
credentials = await resolveCredentials(regions, options.regionOrder, log);
|
|
6088
|
+
} catch (error) {
|
|
6089
|
+
return result({
|
|
6090
|
+
ok: false,
|
|
6091
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6092
|
+
});
|
|
6093
|
+
}
|
|
6094
|
+
const sessionIdArg = typeof args["session_id"] === "string" ? args["session_id"].trim() : "";
|
|
6095
|
+
const signal = execution?.signal;
|
|
6096
|
+
const sessionKey = sessionKeyOf(execution, "codebuddy_ask");
|
|
6097
|
+
const continuedSessionId = sessionIdArg !== "" ? sessionIdArg : options.sessionContinuity === "auto" ? recallCliSession(sessionKey) : "";
|
|
6098
|
+
if (liveTurn !== void 0) liveTurn.phase = "running";
|
|
6099
|
+
const outcome = await runAgent({
|
|
6100
|
+
prompt: message,
|
|
6101
|
+
cwd: dir.dir,
|
|
6102
|
+
binary: options.binary,
|
|
6103
|
+
model: modelArg !== "" ? modelArg : options.model,
|
|
6104
|
+
permissionMode: effectiveMode,
|
|
6105
|
+
effort: effort !== "" ? effort : options.effort,
|
|
6106
|
+
appendSystemPrompt: options.appendSystemPrompt,
|
|
6107
|
+
maxTurns: clampInt$1(args["max_turns"], options.maxTurns, 100),
|
|
6108
|
+
timeoutMs: clampInt$1(args["timeout_secs"], options.timeoutSecs, TIMEOUT_SECS_CAP) * 1e3,
|
|
6109
|
+
...continuedSessionId !== "" ? { sessionId: continuedSessionId } : {},
|
|
6110
|
+
...signal !== void 0 ? { signal } : {},
|
|
6111
|
+
...liveTurn === void 0 ? {} : { onProgress: (progress) => {
|
|
6112
|
+
liveTurn.toolCalls = progress.toolCalls;
|
|
6113
|
+
liveTurn.lastTool = progress.lastTool;
|
|
6114
|
+
liveTurn.chars = progress.chars;
|
|
6115
|
+
} }
|
|
6116
|
+
}, credentials, log);
|
|
6117
|
+
if (liveTurn !== void 0) liveTurn.phase = "finishing";
|
|
6118
|
+
let reply = outcome.text;
|
|
6119
|
+
let truncated = false;
|
|
6120
|
+
if (reply.length > options.maxReplyChars) {
|
|
6121
|
+
const omitted = reply.length - options.maxReplyChars;
|
|
6122
|
+
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.]`;
|
|
6123
|
+
truncated = true;
|
|
6124
|
+
}
|
|
6125
|
+
const tools = [...outcome.toolCounts.entries()].slice(0, 12).map(([name, count]) => ({
|
|
6126
|
+
name,
|
|
6127
|
+
count
|
|
6128
|
+
}));
|
|
6129
|
+
rememberCliSession(sessionKey, outcome.sessionId ?? "");
|
|
6130
|
+
return result({
|
|
6131
|
+
ok: outcome.ok,
|
|
6132
|
+
reply,
|
|
6133
|
+
session_id: outcome.sessionId ?? "",
|
|
6134
|
+
tools,
|
|
6135
|
+
denied_tools: [...outcome.deniedTools],
|
|
6136
|
+
tool_calls: [...outcome.toolCounts.values()].reduce((sum, count) => sum + count, 0),
|
|
6137
|
+
num_turns: outcome.numTurns ?? 0,
|
|
6138
|
+
tokens_in: outcome.usage?.input_tokens ?? 0,
|
|
6139
|
+
tokens_out: outcome.usage?.output_tokens ?? 0,
|
|
6140
|
+
cache_read_tokens: outcome.usage?.cache_read_input_tokens ?? 0,
|
|
6141
|
+
credential_region: outcome.credential.region,
|
|
6142
|
+
working_dir: dir.dir,
|
|
6143
|
+
unattended: dir.unattended,
|
|
6144
|
+
elapsed_ms: Date.now() - startedAt,
|
|
6145
|
+
truncated,
|
|
6146
|
+
error: outcome.error ?? ""
|
|
6147
|
+
});
|
|
6148
|
+
}
|
|
6149
|
+
async function runAsk(args, execution, deps, entrance = "tool") {
|
|
6150
|
+
const startedAt = Date.now();
|
|
6151
|
+
const { options, log } = deps;
|
|
5742
6152
|
const mode = typeof args["permission_mode"] === "string" ? args["permission_mode"].trim() : "";
|
|
5743
6153
|
if (mode !== "" && !PERMISSION_MODES.includes(mode)) return result({
|
|
5744
6154
|
ok: false,
|
|
@@ -5760,73 +6170,48 @@ async function runAsk(args, execution, deps) {
|
|
|
5760
6170
|
error: dir.reason
|
|
5761
6171
|
});
|
|
5762
6172
|
const entry = (deps.gate ?? gateFor(options)).tryEnter(sessionKeyOf(execution, "codebuddy_ask"), (message) => log(message));
|
|
5763
|
-
if (!entry.ok)
|
|
5764
|
-
|
|
5765
|
-
|
|
5766
|
-
|
|
6173
|
+
if (!entry.ok) {
|
|
6174
|
+
if (dir.unattended) removeScratch(dir.dir);
|
|
6175
|
+
return result({
|
|
6176
|
+
ok: false,
|
|
6177
|
+
error: entry.error
|
|
6178
|
+
});
|
|
6179
|
+
}
|
|
5767
6180
|
try {
|
|
5768
|
-
|
|
6181
|
+
const modelArg = typeof args["model"] === "string" ? args["model"].trim() : "";
|
|
6182
|
+
const effectiveMode = mode !== "" ? mode : options.permissionMode;
|
|
6183
|
+
const liveTurn = deps.live?.begin({
|
|
6184
|
+
entrance,
|
|
6185
|
+
workingDir: dir.dir,
|
|
6186
|
+
unattended: dir.unattended,
|
|
6187
|
+
permissionMode: effectiveMode,
|
|
6188
|
+
model: modelArg !== "" ? modelArg : options.model,
|
|
6189
|
+
...execution?.agent?.id !== void 0 ? { sessionId: execution.agent.id } : {}
|
|
6190
|
+
});
|
|
5769
6191
|
try {
|
|
5770
|
-
|
|
6192
|
+
return await runAskTurn(args, execution, deps, {
|
|
6193
|
+
startedAt,
|
|
6194
|
+
dir,
|
|
6195
|
+
message,
|
|
6196
|
+
effort,
|
|
6197
|
+
mode,
|
|
6198
|
+
modelArg,
|
|
6199
|
+
effectiveMode,
|
|
6200
|
+
entry,
|
|
6201
|
+
...liveTurn === void 0 ? {} : { liveTurn }
|
|
6202
|
+
});
|
|
5771
6203
|
} catch (error) {
|
|
5772
6204
|
return result({
|
|
5773
6205
|
ok: false,
|
|
5774
|
-
error: error instanceof Error ? error.message : String(error)
|
|
6206
|
+
error: error instanceof Error ? error.message : String(error),
|
|
6207
|
+
elapsed_ms: Date.now() - startedAt
|
|
5775
6208
|
});
|
|
6209
|
+
} finally {
|
|
6210
|
+
if (liveTurn !== void 0) deps.live?.end(liveTurn);
|
|
5776
6211
|
}
|
|
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
6212
|
} finally {
|
|
5829
6213
|
entry.release();
|
|
6214
|
+
if (dir.unattended) removeScratch(dir.dir);
|
|
5830
6215
|
}
|
|
5831
6216
|
}
|
|
5832
6217
|
/**
|
|
@@ -5875,7 +6260,7 @@ function mountAskTool(tools, deps) {
|
|
|
5875
6260
|
},
|
|
5876
6261
|
session_id: {
|
|
5877
6262
|
type: "string",
|
|
5878
|
-
description: "
|
|
6263
|
+
description: "可选:要续接的 CLI 会话 id。留空 = 由插件自动续接本会话上一轮的 CodeBuddy 对话(cliBridge.sessionContinuity=auto 时;=off 时留空就是每次开新会话)。只有想故意开一条新对话、或切到另一条对话时才显式传。"
|
|
5879
6264
|
},
|
|
5880
6265
|
allow_unattended: {
|
|
5881
6266
|
type: "boolean",
|
|
@@ -5962,29 +6347,44 @@ const REVIEW_PERMISSION_MODE = "dontAsk";
|
|
|
5962
6347
|
* human cannot tell the two apart in the output.
|
|
5963
6348
|
*/
|
|
5964
6349
|
const REVIEW_BRIEF = "评审当前工作区里未提交的改动(git 未提交改动;没有未提交改动时看最近一次提交)。自己运行 git 命令确认范围,必要时读相关文件理解上下文,然后给出评审结论:哪些地方有问题、为什么、怎么改。只报你确实看见的问题,不要猜。";
|
|
6350
|
+
/** The mode `--edit` selects for the one turn it appears on. */
|
|
6351
|
+
const EDIT_PERMISSION_MODE = "acceptEdits";
|
|
5965
6352
|
/**
|
|
5966
|
-
*
|
|
6353
|
+
* Read the composer line.
|
|
5967
6354
|
*
|
|
5968
6355
|
* Empty → the review brief. Non-empty → the text verbatim, because a human who
|
|
5969
6356
|
* typed something meant it, and silently prepending a review brief to "改一下
|
|
5970
6357
|
* 这个 bug" is how a command does something other than what it was asked.
|
|
6358
|
+
*
|
|
6359
|
+
* `--edit` is stripped and remembered rather than forwarded: CodeBuddy must not
|
|
6360
|
+
* see a flag that only means something to this bridge.
|
|
5971
6361
|
*/
|
|
5972
|
-
function
|
|
6362
|
+
function parseCommandInput(raw) {
|
|
5973
6363
|
const note = raw.trim();
|
|
5974
|
-
|
|
6364
|
+
if (note === "") return {
|
|
6365
|
+
prompt: REVIEW_BRIEF,
|
|
6366
|
+
label: "(未提交改动)",
|
|
6367
|
+
review: true,
|
|
6368
|
+
permissionMode: REVIEW_PERMISSION_MODE
|
|
6369
|
+
};
|
|
6370
|
+
const edit = /^--edit(?:\s|$)/u.test(note);
|
|
6371
|
+
const body = edit ? note.replace(/^--edit\s*/u, "").trim() : note;
|
|
6372
|
+
if (body === "") return {
|
|
6373
|
+
prompt: REVIEW_BRIEF,
|
|
6374
|
+
label: "(未提交改动,可写)",
|
|
6375
|
+
review: true,
|
|
6376
|
+
permissionMode: EDIT_PERMISSION_MODE
|
|
6377
|
+
};
|
|
6378
|
+
return {
|
|
6379
|
+
prompt: body,
|
|
6380
|
+
label: edit ? `(可写:${body})` : `(指令:${body})`,
|
|
6381
|
+
review: false,
|
|
6382
|
+
permissionMode: edit ? EDIT_PERMISSION_MODE : ""
|
|
6383
|
+
};
|
|
5975
6384
|
}
|
|
5976
6385
|
/** A one-line label saying how the human's words were used. */
|
|
5977
6386
|
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 : "";
|
|
6387
|
+
return parseCommandInput(raw).label;
|
|
5988
6388
|
}
|
|
5989
6389
|
/**
|
|
5990
6390
|
* Render one turn outcome as the plain text a command result carries.
|
|
@@ -6005,7 +6405,7 @@ function formatCommandOutcome(value, extra = {}) {
|
|
|
6005
6405
|
const body = value.reply.trim();
|
|
6006
6406
|
const head = body === "" ? `CodeBuddy 完成${scope}:没有输出任何结论。` : `CodeBuddy 完成${scope}:\n\n${body}`;
|
|
6007
6407
|
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(", ")}
|
|
6408
|
+
const denied = value.denied_tools.length > 0 ? `\n\n[注意:CodeBuddy 请求过这些工具但被拒绝:${value.denied_tools.join(", ")}。评审类任务读到这些就该够了;要它真动手,在指令前加 --edit(只影响这一轮),或在配置里设 cliBridge.permissionMode=acceptEdits(影响之后每一轮)]` : "";
|
|
6009
6409
|
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
6410
|
const truncated = value.truncated ? "\n\n[注意:输出已截断。让 CodeBuddy 把完整内容写到文件里再读,别只靠这一份摘要]" : "";
|
|
6011
6411
|
const delivery = extra.injection === void 0 ? "" : `\n\n${formatInjection(extra.injection)}`;
|
|
@@ -6031,8 +6431,8 @@ function mountCodebuddyCommand(commands, deps) {
|
|
|
6031
6431
|
try {
|
|
6032
6432
|
return commands.register({
|
|
6033
6433
|
name: COMMAND_NAME,
|
|
6034
|
-
description: "用 CodeBuddy 在当前工作区跑一轮;留空 =
|
|
6035
|
-
input: { hint: "
|
|
6434
|
+
description: "用 CodeBuddy 在当前工作区跑一轮;留空 = 评审未提交改动;以 --edit 开头 = 这一轮允许改文件。注意:默认(cliBridge.injectResult=true)跑完会把结论回注给本会话的模型,那一次唤醒会真的消耗 DSH token;设 injectResult=false 才完全不碰模型。CodeBuddy 那一侧的账号额度每次都会消耗。",
|
|
6435
|
+
input: { hint: "留空 = 评审未提交改动;写要求 = 只读执行(如\"看一下这个函数为什么崩\");要以 --edit 开头 = 允许改文件(如\"--edit 修一下这个空指针\")" },
|
|
6036
6436
|
handler: async (invocation) => {
|
|
6037
6437
|
const cwd = invocation.agent?.session?.header?.cwd;
|
|
6038
6438
|
if (typeof cwd !== "string" || cwd.trim() === "") return {
|
|
@@ -6040,18 +6440,17 @@ function mountCodebuddyCommand(commands, deps) {
|
|
|
6040
6440
|
text: "这个会话没有工作区路径(session.header.cwd 为空),无法确定 CodeBuddy 该在哪里跑。请在一个打开过目录的会话里再试。"
|
|
6041
6441
|
};
|
|
6042
6442
|
const raw = typeof invocation.rawInput === "string" ? invocation.rawInput : "";
|
|
6043
|
-
const
|
|
6044
|
-
const review = isReviewInvocation(raw);
|
|
6443
|
+
const parsed = parseCommandInput(raw);
|
|
6045
6444
|
const startedAt = Date.now();
|
|
6046
6445
|
try {
|
|
6047
6446
|
const value = await runAsk({
|
|
6048
|
-
message,
|
|
6447
|
+
message: parsed.prompt,
|
|
6049
6448
|
working_dir: cwd,
|
|
6050
|
-
...
|
|
6449
|
+
...parsed.permissionMode !== "" ? { permission_mode: parsed.permissionMode } : {}
|
|
6051
6450
|
}, {
|
|
6052
6451
|
...invocation.agent !== void 0 ? { agent: invocation.agent } : {},
|
|
6053
6452
|
...invocation.signal !== void 0 ? { signal: invocation.signal } : {}
|
|
6054
|
-
}, deps);
|
|
6453
|
+
}, deps, "command");
|
|
6055
6454
|
log(`command /${COMMAND_NAME} finished in ${String(Date.now() - startedAt)}ms (ok=${String(value.ok)})`);
|
|
6056
6455
|
const injection = value.ok && options.injectResult ? injectOutcome(invocation.agent, {
|
|
6057
6456
|
reply: value.reply,
|
|
@@ -6083,6 +6482,67 @@ function mountCodebuddyCommand(commands, deps) {
|
|
|
6083
6482
|
}
|
|
6084
6483
|
}
|
|
6085
6484
|
//#endregion
|
|
6485
|
+
//#region src/cli-bridge/live.ts
|
|
6486
|
+
/**
|
|
6487
|
+
* Build a board.
|
|
6488
|
+
*
|
|
6489
|
+
* Ordering is "oldest first", which is the order a human reads a queue in; the
|
|
6490
|
+
* chip draws the newest as the headline because that is the one the last click
|
|
6491
|
+
* started.
|
|
6492
|
+
*/
|
|
6493
|
+
function createLiveBoard() {
|
|
6494
|
+
const turns = [];
|
|
6495
|
+
let counter = 0;
|
|
6496
|
+
const begin = (input) => {
|
|
6497
|
+
counter += 1;
|
|
6498
|
+
const startedAt = Date.now();
|
|
6499
|
+
const turn = {
|
|
6500
|
+
id: `${startedAt.toString(36)}-${String(counter)}`,
|
|
6501
|
+
startedAt,
|
|
6502
|
+
entrance: input.entrance,
|
|
6503
|
+
workingDir: input.workingDir,
|
|
6504
|
+
unattended: input.unattended,
|
|
6505
|
+
permissionMode: input.permissionMode,
|
|
6506
|
+
model: input.model,
|
|
6507
|
+
sessionId: input.sessionId ?? "",
|
|
6508
|
+
phase: "starting",
|
|
6509
|
+
toolCalls: 0,
|
|
6510
|
+
lastTool: "",
|
|
6511
|
+
chars: 0
|
|
6512
|
+
};
|
|
6513
|
+
turns.push(turn);
|
|
6514
|
+
return turn;
|
|
6515
|
+
};
|
|
6516
|
+
const end = (turn) => {
|
|
6517
|
+
const at = turns.indexOf(turn);
|
|
6518
|
+
if (at >= 0) turns.splice(at, 1);
|
|
6519
|
+
};
|
|
6520
|
+
const snapshot = (now = Date.now()) => ({
|
|
6521
|
+
turns: turns.map((turn) => ({
|
|
6522
|
+
id: turn.id,
|
|
6523
|
+
startedAt: turn.startedAt,
|
|
6524
|
+
elapsedMs: Math.max(0, now - turn.startedAt),
|
|
6525
|
+
entrance: turn.entrance,
|
|
6526
|
+
workingDir: turn.workingDir,
|
|
6527
|
+
unattended: turn.unattended,
|
|
6528
|
+
permissionMode: turn.permissionMode,
|
|
6529
|
+
model: turn.model,
|
|
6530
|
+
sessionId: turn.sessionId,
|
|
6531
|
+
phase: turn.phase,
|
|
6532
|
+
toolCalls: turn.toolCalls,
|
|
6533
|
+
lastTool: turn.lastTool,
|
|
6534
|
+
chars: turn.chars
|
|
6535
|
+
})),
|
|
6536
|
+
serverTime: now
|
|
6537
|
+
});
|
|
6538
|
+
return {
|
|
6539
|
+
begin,
|
|
6540
|
+
end,
|
|
6541
|
+
snapshot,
|
|
6542
|
+
size: () => turns.length
|
|
6543
|
+
};
|
|
6544
|
+
}
|
|
6545
|
+
//#endregion
|
|
6086
6546
|
//#region src/cli-bridge/mount.ts
|
|
6087
6547
|
/**
|
|
6088
6548
|
* Wiring the CLI route into the plugin, and normalizing its settings.
|
|
@@ -6137,6 +6597,7 @@ const CLI_BRIDGE_DEFAULTS = {
|
|
|
6137
6597
|
allowUnattended: false,
|
|
6138
6598
|
maxReplyChars: 6e4,
|
|
6139
6599
|
injectResult: true,
|
|
6600
|
+
sessionContinuity: "auto",
|
|
6140
6601
|
maxTurnsPerSessionHour: 6,
|
|
6141
6602
|
maxTurnsPerHour: 20
|
|
6142
6603
|
};
|
|
@@ -6175,6 +6636,9 @@ function normalizeCliBridgeOptions(settings, onRejected) {
|
|
|
6175
6636
|
const safeEffort = effort === "" || EFFORT_LEVELS.includes(effort) ? effort : "";
|
|
6176
6637
|
if (safeEffort !== effort) reject(`effort "${effort}" 不是合法值,已回退到 CLI 默认;合法值:${EFFORT_LEVELS.join(", ")}`);
|
|
6177
6638
|
const regionOrder = text(raw.regionOrder, CLI_BRIDGE_DEFAULTS.regionOrder);
|
|
6639
|
+
const sessionContinuity = text(raw.sessionContinuity, CLI_BRIDGE_DEFAULTS.sessionContinuity);
|
|
6640
|
+
const safeSessionContinuity = SESSION_CONTINUITY_MODES.includes(sessionContinuity) ? sessionContinuity : CLI_BRIDGE_DEFAULTS.sessionContinuity;
|
|
6641
|
+
if (safeSessionContinuity !== sessionContinuity) reject(`sessionContinuity "${sessionContinuity}" 不是合法值,已回退到 ${CLI_BRIDGE_DEFAULTS.sessionContinuity};合法值:${SESSION_CONTINUITY_MODES.join(", ")}`);
|
|
6178
6642
|
return {
|
|
6179
6643
|
enabled: raw.enabled === true,
|
|
6180
6644
|
binary: text(raw.binary, CLI_BRIDGE_DEFAULTS.binary),
|
|
@@ -6190,6 +6654,7 @@ function normalizeCliBridgeOptions(settings, onRejected) {
|
|
|
6190
6654
|
allowUnattended: raw.allowUnattended === true,
|
|
6191
6655
|
maxReplyChars: clampInt(raw.maxReplyChars, CLI_BRIDGE_DEFAULTS.maxReplyChars, MAX_REPLY_CHARS_CAP),
|
|
6192
6656
|
injectResult: raw.injectResult !== false,
|
|
6657
|
+
sessionContinuity: safeSessionContinuity,
|
|
6193
6658
|
maxTurnsPerSessionHour: clampQuota(raw.maxTurnsPerSessionHour, CLI_BRIDGE_DEFAULTS.maxTurnsPerSessionHour),
|
|
6194
6659
|
maxTurnsPerHour: clampQuota(raw.maxTurnsPerHour, CLI_BRIDGE_DEFAULTS.maxTurnsPerHour)
|
|
6195
6660
|
};
|
|
@@ -6203,14 +6668,17 @@ function normalizeCliBridgeOptions(settings, onRejected) {
|
|
|
6203
6668
|
function createCliBridgeRuntime(settings, regions, log) {
|
|
6204
6669
|
const options = normalizeCliBridgeOptions(settings, (message) => log(message));
|
|
6205
6670
|
if (!options.enabled) return void 0;
|
|
6671
|
+
const live = createLiveBoard();
|
|
6206
6672
|
return {
|
|
6207
6673
|
options,
|
|
6208
6674
|
deps: {
|
|
6209
6675
|
options,
|
|
6210
6676
|
regions,
|
|
6211
6677
|
log,
|
|
6212
|
-
gate: gateFor(options)
|
|
6213
|
-
|
|
6678
|
+
gate: gateFor(options),
|
|
6679
|
+
live
|
|
6680
|
+
},
|
|
6681
|
+
live
|
|
6214
6682
|
};
|
|
6215
6683
|
}
|
|
6216
6684
|
/** Mount `codebuddy_ask` on a tools service, returning its disposer. */
|
|
@@ -8782,8 +9250,8 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
8782
9250
|
if (field !== "regions" && field !== "accounts" && field !== "cliBridge") return json(res, 400, { error: "field must be regions, accounts or cliBridge" });
|
|
8783
9251
|
const row = settings.describe().find((r) => String(r.ns).includes("workbuddy"));
|
|
8784
9252
|
if (row === void 0) return json(res, 503, { error: "workbuddy namespace missing from describe()" });
|
|
8785
|
-
const current = unwrapVolatileDeep(row.value?.[field] ?? {});
|
|
8786
|
-
const incoming = body.value ?? {};
|
|
9253
|
+
const current = field === "regions" ? regionsMapOf(row.value?.[field]) : unwrapVolatileDeep(row.value?.[field] ?? {});
|
|
9254
|
+
const incoming = field === "regions" ? regionsMapOf(body.value) : body.value ?? {};
|
|
8787
9255
|
if (field === "cliBridge") {
|
|
8788
9256
|
if (!isPlainRecord(incoming)) return json(res, 400, { error: "cliBridge value must be an object" });
|
|
8789
9257
|
const mergedBridge = nextCliBridgeSettings(current, incoming);
|
|
@@ -8896,6 +9364,41 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
8896
9364
|
}
|
|
8897
9365
|
}
|
|
8898
9366
|
});
|
|
9367
|
+
/**
|
|
9368
|
+
* The CLI route's live turns.
|
|
9369
|
+
*
|
|
9370
|
+
* Registered even when the bridge is switched off, and answers an empty
|
|
9371
|
+
* document in that case: the composer chip polls this path unconditionally
|
|
9372
|
+
* (it cannot know the setting — the settings mirror is not readable from a
|
|
9373
|
+
* session component), so "off" has to be a 200 with nothing in it rather
|
|
9374
|
+
* than a 404 the chip would have to special-case.
|
|
9375
|
+
*
|
|
9376
|
+
* No region parameter: a live turn is not scoped to a credential region, and
|
|
9377
|
+
* `requestRegion` would 400 a perfectly valid read.
|
|
9378
|
+
*
|
|
9379
|
+
* A 404 is not an option here, and that is a second reason for the
|
|
9380
|
+
* unconditional registration: the chip keeps showing the last document it
|
|
9381
|
+
* read while a poll fails (it cannot tell "the route is gone" from "the
|
|
9382
|
+
* request dropped"), so a route that vanished on a settings change would
|
|
9383
|
+
* leave a ghost pill describing a turn that already ended. Answering an
|
|
9384
|
+
* empty document keeps "off" inside the same code path as "no turns".
|
|
9385
|
+
*/
|
|
9386
|
+
const disposeCliLive = ctx.webServer.register({
|
|
9387
|
+
kind: "exact",
|
|
9388
|
+
path: WORKBUDDY_CLI_LIVE_PATH,
|
|
9389
|
+
handler: (req, res) => {
|
|
9390
|
+
if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
|
|
9391
|
+
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
9392
|
+
try {
|
|
9393
|
+
json(res, 200, deps.cliLive?.() ?? {
|
|
9394
|
+
turns: [],
|
|
9395
|
+
serverTime: Date.now()
|
|
9396
|
+
});
|
|
9397
|
+
} catch (error) {
|
|
9398
|
+
json(res, 500, { error: safeMessage(error) });
|
|
9399
|
+
}
|
|
9400
|
+
}
|
|
9401
|
+
});
|
|
8899
9402
|
return () => {
|
|
8900
9403
|
disposeRefresh();
|
|
8901
9404
|
disposeCheckin();
|
|
@@ -8908,6 +9411,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
8908
9411
|
disposeOAuth();
|
|
8909
9412
|
disposeTransfer();
|
|
8910
9413
|
disposeDiagWrite();
|
|
9414
|
+
disposeCliLive();
|
|
8911
9415
|
};
|
|
8912
9416
|
}, "dsh-connect-workbuddy: Web status route");
|
|
8913
9417
|
}
|
|
@@ -9096,6 +9600,7 @@ const cliBridgeConfig = z.object({
|
|
|
9096
9600
|
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
9601
|
maxReplyChars: z.number().step(1).min(1).default(6e4).description("Reply length cap handed back to the model, in characters. Clamped to 400000."),
|
|
9098
9602
|
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."),
|
|
9603
|
+
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
9604
|
maxTurnsPerSessionHour: z.number().step(1).min(0).default(6).description("Bridged calls allowed per hour in one session. 0 disables the limit."),
|
|
9100
9605
|
maxTurnsPerHour: z.number().step(1).min(0).default(20).description("Bridged calls allowed per hour across the whole process. 0 disables the limit.")
|
|
9101
9606
|
});
|
|
@@ -9120,7 +9625,7 @@ const Config = z.object({
|
|
|
9120
9625
|
* picks.
|
|
9121
9626
|
*/
|
|
9122
9627
|
function regionStateOf(config, region) {
|
|
9123
|
-
const stored =
|
|
9628
|
+
const stored = regionsMapOf(config.regions)[region];
|
|
9124
9629
|
if (stored !== void 0 && stored !== null) return stored;
|
|
9125
9630
|
if (region !== "cn") return {};
|
|
9126
9631
|
const lastCatalog = unwrapVolatile(config.lastCatalog);
|
|
@@ -9397,7 +9902,7 @@ function apply(ctx, config) {
|
|
|
9397
9902
|
return rest;
|
|
9398
9903
|
});
|
|
9399
9904
|
if (repaired.length === 0) return;
|
|
9400
|
-
if (catalogFingerprint(repaired)
|
|
9905
|
+
if (!(catalogFingerprint(repaired) !== catalogFingerprint(saved)) && !regionsFieldIsCorrupt(current().regions)) return;
|
|
9401
9906
|
try {
|
|
9402
9907
|
await writeRegionCatalog(hostSettings(), settingsNs, region, repaired);
|
|
9403
9908
|
} catch (error) {
|
|
@@ -9413,6 +9918,27 @@ function apply(ctx, config) {
|
|
|
9413
9918
|
*/
|
|
9414
9919
|
const hostSettings = () => ctx.get?.("settings");
|
|
9415
9920
|
/**
|
|
9921
|
+
* Clean a `regions` field a previous version corrupted, once, at boot.
|
|
9922
|
+
*
|
|
9923
|
+
* The corruption itself is fixed at the source (`src/catalog-write.ts`) and the
|
|
9924
|
+
* read paths heal in memory, so a dirty profile is CORRECT long before this
|
|
9925
|
+
* runs — but it stays dirty in the file, and the next save would carry the
|
|
9926
|
+
* debris forward. The catalog repair writes the same clean shape, yet it only
|
|
9927
|
+
* runs when a live roster arrives: a disabled region, an unreachable upstream
|
|
9928
|
+
* or an empty answer would leave the wreckage in place for as long as those
|
|
9929
|
+
* conditions hold. This needs no upstream at all.
|
|
9930
|
+
*
|
|
9931
|
+
* FAIL-SOFT, exactly like the repair: the read side is already right, so a
|
|
9932
|
+
* write that cannot land costs tidiness and nothing else.
|
|
9933
|
+
*/
|
|
9934
|
+
const healRegionsFieldIfCorrupt = async () => {
|
|
9935
|
+
try {
|
|
9936
|
+
await healRegionsField(hostSettings(), settingsNs);
|
|
9937
|
+
} catch (error) {
|
|
9938
|
+
ctx.logger.warn("dsh-connect-workbuddy: could not clean the shape of the regions setting", error);
|
|
9939
|
+
}
|
|
9940
|
+
};
|
|
9941
|
+
/**
|
|
9416
9942
|
* Legacy migration for the pre-split single `accountId`: its region is
|
|
9417
9943
|
* resolved once from the local account scan and the selection is then
|
|
9418
9944
|
* attributed to that region ONLY — the other region keeps its documented
|
|
@@ -9650,6 +10176,19 @@ function apply(ctx, config) {
|
|
|
9650
10176
|
client,
|
|
9651
10177
|
accountUsable: accountUsabilityProbe,
|
|
9652
10178
|
/**
|
|
10179
|
+
* The CLI route's live turns, for the composer readout.
|
|
10180
|
+
*
|
|
10181
|
+
* Read through `cliRuntime` rather than captured once: the runtime is rebuilt
|
|
10182
|
+
* whenever `cliBridge` settings change, and the board belongs to the runtime.
|
|
10183
|
+
* A captured board would keep answering for a runtime nothing drives any
|
|
10184
|
+
* more. Switched off means `undefined`, which the route reports as an empty
|
|
10185
|
+
* document.
|
|
10186
|
+
*/
|
|
10187
|
+
cliLive: () => cliRuntime?.live.snapshot() ?? {
|
|
10188
|
+
turns: [],
|
|
10189
|
+
serverTime: Date.now()
|
|
10190
|
+
},
|
|
10191
|
+
/**
|
|
9653
10192
|
* Why each of the region's accounts cannot serve right now, for the composer
|
|
9654
10193
|
* panel's table.
|
|
9655
10194
|
*
|
|
@@ -10638,6 +11177,7 @@ function apply(ctx, config) {
|
|
|
10638
11177
|
return;
|
|
10639
11178
|
}
|
|
10640
11179
|
if (stopped) return;
|
|
11180
|
+
const regionsFieldHealed = healRegionsFieldIfCorrupt();
|
|
10641
11181
|
for (const region of REGION_KEYS) {
|
|
10642
11182
|
if (!regionEnabled(current(), region)) continue;
|
|
10643
11183
|
(async () => {
|
|
@@ -10649,6 +11189,7 @@ function apply(ctx, config) {
|
|
|
10649
11189
|
const state = regionStateOf(current(), region);
|
|
10650
11190
|
stacks[region].catalog.set(withEffectiveOff(withImageSelection(deriveCatalog(models, new Set(state.enabledModelIds ?? []), state.contextBudgets ?? {}), new Set(state.imageModelIds ?? [])), new Set(savedOffModelIds(state, models))));
|
|
10651
11191
|
adapters[region].invalidate();
|
|
11192
|
+
await regionsFieldHealed;
|
|
10652
11193
|
await repairRegionCatalog(region, models);
|
|
10653
11194
|
} catch (error) {
|
|
10654
11195
|
ctx.logger.warn(`dsh-connect-workbuddy: dynamic ${region} model catalog unavailable; serving the static fallback list`, error);
|
|
@@ -10660,4 +11201,4 @@ function apply(ctx, config) {
|
|
|
10660
11201
|
});
|
|
10661
11202
|
}
|
|
10662
11203
|
//#endregion
|
|
10663
|
-
export { CREDENTIAL_REJECTED_CODE, Config, DEFAULT_PROBE_INPUT_TOKENS, ENCRYPTED_CREDENTIAL_CODE, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, NATIVE_MODALITY_BY_MODEL_ID, PROBE_INPUT_TOKEN_CHOICES, PROBE_MAX_TOKENS, PROBE_SYSTEM_PROMPT, REFUSES_OFF_MODEL_IDS, WORKBUDDY_ACCOUNTS_REFRESH_PATH, WORKBUDDY_APP_EXECUTABLE_ENV, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CHECKIN_PATH, WORKBUDDY_CONNECT_VERSION, WORKBUDDY_GLOBAL_PROVIDER, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_MODELS_REFRESH_PATH, WORKBUDDY_PROBE_PATH, WORKBUDDY_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_TPS_PATH, WORKBUDDY_USAGE_PATH, WorkBuddyCatalog, WorkBuddyCredentialRejectedError, WorkBuddyCredentialStore, WorkBuddyEncryptedCredentialError, WorkBuddyUpstreamClient, apply, asVolatile, authFileName, classifyUpstreamError, clearAtRestKeyCache, clearHostHeartbeat, cooldownOf, createAccountUsabilityProbe, createWorkBuddyAdapter, createWorkBuddyShim, creditOfStream, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, defaultOffModelIds, deriveAtRestKey, deriveAtRestKeyId, deriveCatalog, fallbackModelsFor, fetchAtRestKeyPayload, findWorkbuddyAppExecutable, hasEncryptedCredentialFields, imageDefaultFor, inject, isCredentialRejectedError, isEncryptedCredentialError, isEncryptedFieldWrapper, isHeartbeatProcessAlive, isWorkbuddyBundle, knownRegionOf, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, macosBundleExecutable, macosNestedAppBundles, modelIdOfBody, name, nativeModalityOf, nextRegionEnabled, nextRegionSlots, offDefaultFor, openEncryptedField, outcomeOfFailure, parseCreditMultiplier, parseReasoning, parseRetryAfter, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, probeModel, probeRequestBody, probeSucceeded, processStartTimeMs, readAtRestKey, readHostHeartbeat, regionCleared, regionEnabled, regionEnabledOf, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, resolveCredentialRecovery, selectAccountFor, settingsNamespaceOf, toPersistedWorkBuddyModel, unwrapVolatileDeep, withEffectiveOff, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyAppExecutableCandidates, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
|
|
11204
|
+
export { CREDENTIAL_REJECTED_CODE, Config, DEFAULT_PROBE_INPUT_TOKENS, ENCRYPTED_CREDENTIAL_CODE, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, NATIVE_MODALITY_BY_MODEL_ID, PROBE_INPUT_TOKEN_CHOICES, PROBE_MAX_TOKENS, PROBE_SYSTEM_PROMPT, REFUSES_OFF_MODEL_IDS, WORKBUDDY_ACCOUNTS_REFRESH_PATH, WORKBUDDY_APP_EXECUTABLE_ENV, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CHECKIN_PATH, WORKBUDDY_CONNECT_VERSION, WORKBUDDY_GLOBAL_PROVIDER, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_MODELS_REFRESH_PATH, WORKBUDDY_PROBE_PATH, WORKBUDDY_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_TPS_PATH, WORKBUDDY_USAGE_PATH, WorkBuddyCatalog, WorkBuddyCredentialRejectedError, WorkBuddyCredentialStore, WorkBuddyEncryptedCredentialError, WorkBuddyUpstreamClient, apply, asVolatile, authFileName, classifyUpstreamError, clearAtRestKeyCache, clearHostHeartbeat, cooldownOf, createAccountUsabilityProbe, createWorkBuddyAdapter, createWorkBuddyShim, creditOfStream, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, defaultOffModelIds, deriveAtRestKey, deriveAtRestKeyId, deriveCatalog, fallbackModelsFor, fetchAtRestKeyPayload, findWorkbuddyAppExecutable, hasEncryptedCredentialFields, imageDefaultFor, inject, isCredentialRejectedError, isEncryptedCredentialError, isEncryptedFieldWrapper, isHeartbeatProcessAlive, isWorkbuddyBundle, knownRegionOf, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, macosBundleExecutable, macosNestedAppBundles, modelIdOfBody, name, nativeModalityOf, nextRegionEnabled, nextRegionSlots, offDefaultFor, openEncryptedField, outcomeOfFailure, parseCreditMultiplier, parseReasoning, parseRetryAfter, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, probeModel, probeRequestBody, probeSucceeded, processStartTimeMs, readAtRestKey, readHostHeartbeat, regionCleared, regionEnabled, regionEnabledOf, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, regionsFieldIsCorrupt, regionsMapOf, registerWorkBuddyStatusRoute, resolveCredentialRecovery, selectAccountFor, settingsNamespaceOf, toPersistedWorkBuddyModel, unwrapVolatileDeep, withEffectiveOff, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyAppExecutableCandidates, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
|