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/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-D5FWM_8k.js";
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, mkdirSync, readdirSync, realpathSync, statSync } from "node:fs";
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 === "object" && nested !== null && !Array.isArray(nested)) return 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
- * the browser half (`src/client/`), but the repair that heals a STALE saved
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` field, preserving
562
- * every other key of that region's slot and the whole sibling region.
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 field = unwrapVolatileDeep(current) ?? {};
570
- const regions = field["regions"] ?? {};
571
- const slot = regions[region] ?? {};
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
- ...field,
574
- regions: {
575
- ...regions,
576
- [region]: {
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
- * The Host's `MessageSource` is a CLOSED union — `model | user | tool |
4667
- * system-prompt` — and only `user` is available for a message that must be read
4668
- * as an incoming turn: `tool` requires a `callId` from a real tool call this
4669
- * plugin did not make, `model` claims a provider produced it, `system-prompt`
4670
- * would put it in the system slot. So the tag cannot say "a plugin wrote this",
4671
- * and pretending otherwise would mean shipping a type that lies.
4672
- *
4673
- * WHAT COMPENSATES FOR IT: the message opens with `PROVENANCE_LINE`, a bracketed
4674
- * line that names the plugin and the command. A human scanning the transcript
4675
- * can tell an injected result from something they typed, and so can the model —
4676
- * which matters because a model that mistakes it for a human instruction will
4677
- * act on it as one.
4678
- */
4679
- const INJECT_SOURCE_KIND = "user";
4680
- /** The first line of every injected message. Not decoration: it is the only
4681
- * provenance signal the closed `source` union leaves available. */
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
- * Roots a global npm install of the CLI can live under, most likely first.
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 `versionedCliEntries` globs one level.
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 cliEntryRoots() {
5276
+ function cliModuleDirs() {
5020
5277
  const home = homedir();
5021
- const roots = [];
5278
+ const dirs = [];
5022
5279
  const appData = process.env["APPDATA"];
5023
- if (typeof appData === "string" && appData !== "") roots.push(join(appData, "npm"));
5024
- roots.push(join(home, ".npm-global"), "/usr/local", "/opt/homebrew", join(home, ".local"));
5025
- roots.push(join(home, "AppData", "Roaming", "npm"));
5026
- roots.push(join(home, ".local", "share", "pnpm", "global", "5"), join(home, ".config", "yarn", "global"));
5027
- roots.push(join(home, ".volta", "tools", "image"));
5028
- return roots.map((root) => join(root, "lib", "node_modules"));
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
- return [
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 = [...cliEntryRoots().map((root) => join(root, CLI_PACKAGE_PATH))];
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(join(versions), [
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
- } else if (block.type === "tool_use" && typeof block.name === "string") toolCounts.set(block.name, (toolCounts.get(block.name) ?? 0) + 1);
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
- /** The throwaway directory used when the caller explicitly allows no workspace. */
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
- const dir = join(tmpdir(), "dsh-codebuddy-scratch");
5503
- mkdirSync(dir, { recursive: true });
5504
- return dir;
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
- * Run one bridged turn and return the complete result object.
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) return result({
5764
- ok: false,
5765
- error: entry.error
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
- let credentials;
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
- credentials = await resolveCredentials(regions, options.regionOrder, log);
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: "可选:指定 CLI 会话 id,让后续调用接着同一个会话。不传则每次开新会话。"
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
- * What the composer line becomes.
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 composeCommandPrompt(raw) {
6362
+ function parseCommandInput(raw) {
5973
6363
  const note = raw.trim();
5974
- return note === "" ? REVIEW_BRIEF : note;
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
- const note = raw.trim();
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 在当前工作区跑一轮;留空 = 评审未提交改动。注意:默认(cliBridge.injectResult=true)跑完会把结论回注给本会话的模型,那一次唤醒会真的消耗 DSH token;设 injectResult=false 才完全不碰模型。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 message = composeCommandPrompt(raw);
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
- ...review ? { permission_mode: permissionModeForInvocation(raw) } : {}
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 = unwrapVolatile(config.regions)?.[region];
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) === catalogFingerprint(saved)) return;
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 };