dsh-connect-workbuddy 5.4.0 → 5.5.0

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