baychat 0.21.5 → 0.22.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.
Files changed (40) hide show
  1. package/README.md +53 -1
  2. package/dist/approve-hook.js +5 -2
  3. package/dist/commands.js +6 -4
  4. package/dist/connect-dsh.js +165 -0
  5. package/dist/connect-plan.js +16 -1
  6. package/dist/connect.js +7 -0
  7. package/dist/doctor-command.js +7 -0
  8. package/dist/doctor.js +94 -1
  9. package/dist/dsh-config.js +147 -0
  10. package/dist/index.js +22 -0
  11. package/dist/relay/acp/agents.js +186 -0
  12. package/dist/relay/acp/approval.js +67 -0
  13. package/dist/relay/acp/client.js +253 -0
  14. package/dist/relay/acp/commands.js +69 -0
  15. package/dist/relay/acp/daemon-glue.js +201 -0
  16. package/dist/relay/acp/dump-config.js +31 -0
  17. package/dist/relay/acp/modes.js +36 -0
  18. package/dist/relay/acp/permissions.js +42 -0
  19. package/dist/relay/acp/policy.js +137 -0
  20. package/dist/relay/acp/presence.js +87 -0
  21. package/dist/relay/acp/prompt.js +69 -0
  22. package/dist/relay/acp/runner.js +311 -0
  23. package/dist/relay/acp/sdk.js +19 -0
  24. package/dist/relay/acp/turn-queue.js +163 -0
  25. package/dist/relay/acp/types.js +2 -0
  26. package/dist/relay/commands.js +28 -0
  27. package/dist/relay/daemon.js +195 -0
  28. package/dist/relay/mailbox.js +1 -0
  29. package/dist/relay/profiles.js +22 -0
  30. package/dist/relay/registry.js +46 -1
  31. package/dist/relay/session-commands.js +89 -0
  32. package/dist/relay/session-reply.js +25 -0
  33. package/dist/relay/terminal-pane.js +197 -0
  34. package/dist/relay/types.js +0 -9
  35. package/dist/runtimes.js +13 -0
  36. package/dist/session-command.js +16 -3
  37. package/dist/skill-bootstrap.js +16 -8
  38. package/dist/start-command.js +235 -0
  39. package/dist/update-command.js +201 -0
  40. package/package.json +3 -1
package/README.md CHANGED
@@ -186,6 +186,7 @@ baychat login # once per computer: the QR
186
186
  baychat connect # list the apps it can configure
187
187
  baychat connect claude # refresh Claude Code's skill (no QR, no re-pairing)
188
188
  baychat connect codex # configure Codex — restart Codex afterwards
189
+ baychat connect dsh # a DeepSeek Harness session the relay runs for you — see below
189
190
  baychat relay start # the process that wakes your sessions
190
191
  baychat relay status # transport, sessions, anything pending
191
192
  baychat doctor # checks every link and prints what to type
@@ -350,7 +351,9 @@ everything we need.
350
351
  right conversation. This needs the agent to be able to say which session it is, and to have a
351
352
  way to resume that session without a UI (`--resume <id>` and friends).
352
353
 
353
- Adapters ship for **Claude Code**, **Codex**, **Cursor** and **Hermes**. Many others — Gemini CLI,
354
+ Adapters ship for **Claude Code**, **Codex**, **Cursor**, **Hermes** and **DeepSeek Harness**
355
+ (`baychat connect dsh`, run by the relay over ACP rather than woken in a terminal — see above and
356
+ [`ACP_RELAY.md`](../../docs/features/ACP_RELAY.md)). Many others — Gemini CLI,
354
357
  Copilot CLI, Goose, OpenCode/Crush, Qwen Code, Kimi Code CLI, CodeBuddy, iFlow, Trae, Aider — look
355
358
  compatible on paper, with per-agent detail, exact flags, known bugs and a **date on every row** in
356
359
  [the compatibility table](https://baychat.io/runtimes.md).
@@ -435,6 +438,55 @@ relay also falls back to it automatically when app-server cannot be used at
435
438
  all — but not when the failure is a real answer about your session, because
436
439
  retrying that would only repeat it with the reason replaced by an exit code.
437
440
 
441
+ ## `baychat connect dsh` — DeepSeek Harness, run by the relay
442
+
443
+ ```
444
+ baychat connect dsh [--folder <path>] [--max-mode <mode>] [--mode <mode>] [--session <name>] [--terminal]
445
+ ```
446
+
447
+ Unlike every other runtime in this file, DeepSeek Harness (`dsh`) is not woken in your terminal —
448
+ the relay spawns it directly and drives it over **ACP** (Agent Client Protocol), one process per
449
+ turn. Full detail — the four modes, their honest limits, the rule file, known limits — is in
450
+ [`docs/features/ACP_RELAY.md`](../../docs/features/ACP_RELAY.md).
451
+
452
+ > ⚠️ **DeepSeek Harness is experimental and has not been security-audited.** DeepSeek recommends
453
+ > running it in a disposable VM or container, not on your everyday machine. `connect dsh` and
454
+ > `baychat doctor` both print this reminder every time.
455
+
456
+ | Flag | Default | Meaning |
457
+ |---|---|---|
458
+ | `--folder <path>` | asked interactively, defaulting to the current directory | the folder DeepSeek may work in — resolved and stored in `~/.baychat/relay-policy.json` |
459
+ | `--max-mode <mode>` | asked interactively, defaulting to `ask` | the ceiling: `/mode-*` from the chat can never go higher than this, ever |
460
+ | `--mode <mode>` | `chat` | the mode the session starts in |
461
+ | `--session <name>` | `DeepSeek` | the BayChat session name it registers and joins as |
462
+ | `--terminal` | off | ALSO set up `dsh` for use by hand in a terminal: the BayChat skill and a BayChat MCP entry in `~/.dsh/cordis.patch.yml`. That writes your BayChat token into that file (mode `0600`), and it applies to every dsh profile — so it is opt-in. The relay-run session needs none of it. |
463
+
464
+ Modes, weakest to strongest: `chat` (default — BayChat tools only), `read` (+ file read/search),
465
+ `ask` (+ shell; a write asks on your phone first), `full` (+ writes without asking, in its
466
+ folder). Only `chat` protects private files — every other mode can read anything your account can.
467
+ Windows offers `chat` and `read` only.
468
+
469
+ Who gets that power: **you, or a Bay owner/admin you gave command access**. Their messages run in
470
+ the session's mode and they may use `/mode-*`; anyone else's message makes that turn `chat`. The
471
+ ceiling still caps everything, and in `ask` the approval card still comes to **your** phone only.
472
+ Revoke the grant (or demote the admin) to take it away.
473
+
474
+ Re-running `connect dsh` changes the ceiling, and re-registers the session at the starting mode
475
+ (`--mode`, default `chat`) — it does **not** keep a mode you raised from the chat. A running relay
476
+ refuses to raise a live session's mode (it keeps or lowers it only), and `connect` then exits with
477
+ an error; to raise it now, use `/mode-…` from the chat. The starting mode `connect` wrote itself
478
+ still applies once the relay reloads its sessions (for example after a restart) — that is you, on
479
+ this computer, and it is still capped by the folder's ceiling. Re-running with a different `--folder`
480
+ **adds** that folder to the allowed folders; the old ones stay allowed until you remove them from
481
+ `~/.baychat/relay-policy.json`.
482
+
483
+ A `dsh` you run by hand in a terminal is never woken headlessly, and `baychat start` does not
484
+ launch `dsh` — the relay-run session is the one whose modes are enforced.
485
+
486
+ It also needs its own API key on that computer, separate from BayChat: run `dsh web` there and add
487
+ it on the Models page. `baychat doctor` reports whether the key exists (never its contents) and
488
+ whether the installed `dsh` matches the version BayChat was tested against.
489
+
438
490
  ## Group instructions
439
491
 
440
492
  Group conversations carry a short, server-authored **primer** — who's in the
@@ -207,9 +207,12 @@ function describeToolRequest(toolName, toolInput) {
207
207
  /** The question the card asks. Blunt on purpose: this is a shell on a server, read on a phone,
208
208
  * and the safe answer has to be the obvious one. */
209
209
  function composeQuestion(params) {
210
+ const who = params.agentLabel ?? "Claude Code";
211
+ const context = params.agentLabel
212
+ ? "It is running in BayChat's relay; nobody is at a terminal."
213
+ : "This would normally be a prompt in the terminal.";
210
214
  const lines = [
211
- `Claude Code on ${params.host} is asking permission to use ${params.toolName}. ` +
212
- `This would normally be a prompt in the terminal.`,
215
+ `${who} on ${params.host} is asking permission to use ${params.toolName}. ` + context,
213
216
  "",
214
217
  describeToolRequest(params.toolName, params.toolInput),
215
218
  "",
package/dist/commands.js CHANGED
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.CATCHUP_UNTRUSTED_REMINDER = void 0;
6
+ exports.realConfigIo = exports.CATCHUP_UNTRUSTED_REMINDER = void 0;
7
7
  exports.requireCredentials = requireCredentials;
8
8
  exports.cmdPair = cmdPair;
9
9
  exports.cmdWhoami = cmdWhoami;
@@ -653,7 +653,7 @@ function claudeMcpAddSpawn(platform, baseUrl, token) {
653
653
  */
654
654
  function refreshConnectedClients(baseUrl, token) {
655
655
  try {
656
- const updated = (0, credential_refresh_1.refreshExistingClientConfigs)({ url: `${baseUrl}/api/mcp`, token }, realConfigIo, (0, client_paths_1.currentPathEnv)());
656
+ const updated = (0, credential_refresh_1.refreshExistingClientConfigs)({ url: `${baseUrl}/api/mcp`, token }, exports.realConfigIo, (0, client_paths_1.currentPathEnv)());
657
657
  for (const path of updated)
658
658
  console.log(`✓ Updated ${path} with the new credential`);
659
659
  }
@@ -663,8 +663,10 @@ function refreshConnectedClients(baseUrl, token) {
663
663
  console.log(` Could not refresh client configs: ${err instanceof Error ? err.message : String(err)}`);
664
664
  }
665
665
  }
666
- /** Real filesystem IO for the refresh. Mirrors `connect.ts`'s writer. */
667
- const realConfigIo = {
666
+ /** Real filesystem IO for the refresh. Mirrors `connect.ts`'s writer.
667
+ * Exported so `baychat update` refreshes configs through the SAME writer a login does —
668
+ * two spellings of "write a client config" would eventually disagree about permissions. */
669
+ exports.realConfigIo = {
668
670
  readFile: (p) => {
669
671
  try {
670
672
  return node_fs_1.default.readFileSync(p, "utf8");
@@ -0,0 +1,165 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.cmdConnectDsh = cmdConnectDsh;
37
+ const readline = __importStar(require("readline"));
38
+ const commands_1 = require("./commands");
39
+ const config_1 = require("./config");
40
+ const agents_1 = require("./relay/acp/agents");
41
+ const modes_1 = require("./relay/acp/modes");
42
+ const policy_1 = require("./relay/acp/policy");
43
+ const presence_1 = require("./relay/acp/presence");
44
+ const commands_2 = require("./relay/commands");
45
+ const registry_1 = require("./relay/registry");
46
+ const dsh_config_1 = require("./dsh-config");
47
+ const runtime_install_1 = require("./runtime-install");
48
+ const runtime_binary_1 = require("./runtime-binary");
49
+ function askOnTerminal(question) {
50
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
51
+ return new Promise((resolve) => rl.question(question, (answer) => (rl.close(), resolve(answer.trim()))));
52
+ }
53
+ function realDeps() {
54
+ return {
55
+ device: config_1.loadDeviceCredentials,
56
+ login: (base) => (0, commands_1.cmdLogin)({ base, hint: false }),
57
+ resolveBinary: (name) => (0, runtime_binary_1.resolveRuntimeBinary)(name, (0, runtime_binary_1.currentBinaryEnv)()),
58
+ join: async (session) => {
59
+ const device = (0, config_1.loadDeviceCredentials)();
60
+ if (!device)
61
+ throw new Error("not logged in");
62
+ await (0, presence_1.joinSession)((0, presence_1.deviceMcpCaller)(device), session);
63
+ },
64
+ registerWithRelay: commands_2.registerAcpSession,
65
+ ensureRelay: commands_2.ensureRelayInstalled,
66
+ installTerminalPath: (device) => {
67
+ const lines = (0, runtime_install_1.describeInstall)((0, runtime_install_1.installRuntimeCommand)("dsh"));
68
+ // writeDshMcpEntry can throw (RULING R22: a damaged ~/.dsh/cordis.patch.yml block). By this
69
+ // point the relay-run session has already been created — policy saved, mode patches
70
+ // written, joined, registered — so a throw here must not escape and abort cmdConnectDsh:
71
+ // it would report the whole connect as failed even though the chat-driven agent works
72
+ // fine. Report the terminal-path failure as a line instead.
73
+ try {
74
+ const mcp = (0, dsh_config_1.writeDshMcpEntry)({ url: `${device.baseUrl.replace(/\/$/, "")}/api/mcp`, token: device.token });
75
+ lines.push(`Terminal use: BayChat tools added to ${mcp.file}${mcp.backup ? ` (backup: ${mcp.backup})` : ""} — in dsh, type /baychat <name>`);
76
+ }
77
+ catch (err) {
78
+ lines.push(`Terminal use: FAILED — ${err instanceof Error ? err.message : String(err)}`);
79
+ }
80
+ return lines;
81
+ },
82
+ ask: askOnTerminal,
83
+ print: (line) => console.log(line),
84
+ platform: process.platform,
85
+ };
86
+ }
87
+ /**
88
+ * `baychat connect dsh` — a DeepSeek session the relay runs by itself.
89
+ *
90
+ * ORDER MATTERS: everything that can refuse (no login, no binary, a bad folder, a bad mode,
91
+ * a session name already taken by something the relay does not run) refuses BEFORE anything
92
+ * is written, so a failed connect leaves no half-made rule file.
93
+ */
94
+ async function cmdConnectDsh(opts = {}, deps = realDeps()) {
95
+ const row = (0, agents_1.acpAgent)("dsh");
96
+ const say = deps.print;
97
+ let device = deps.device();
98
+ if (!device) {
99
+ say("Connect DeepSeek — approve the QR in BayChat.");
100
+ if (!(await deps.login(opts.base)))
101
+ return 2;
102
+ device = deps.device();
103
+ }
104
+ if (!device) {
105
+ say("No device credential was saved. Retry `baychat connect dsh`.");
106
+ return 1;
107
+ }
108
+ const binary = deps.resolveBinary(row.bin);
109
+ if (!binary.ok) {
110
+ say(`${row.label} is not installed here. ${row.installHint}`);
111
+ return 1;
112
+ }
113
+ const folderInput = opts.folder ?? ((await deps.ask(`Folder ${row.label} may work in [${process.cwd()}]: `)) || process.cwd());
114
+ const folder = (0, policy_1.validateNewRoot)(folderInput);
115
+ if (!folder.ok) {
116
+ say(folder.reason);
117
+ return 1;
118
+ }
119
+ const offered = row.modesFor(deps.platform);
120
+ const maxInput = opts.maxMode ?? ((await deps.ask(`Highest mode the chat may ever switch it to (${offered.join(" / ")}) [${offered.includes("ask") ? "ask" : offered.at(-1)}]: `)) || (offered.includes("ask") ? "ask" : offered.at(-1)));
121
+ if (!(0, modes_1.isAcpMode)(maxInput) || !offered.includes(maxInput)) {
122
+ say(`"${maxInput}" is not a mode offered on this computer. Choose one of: ${offered.join(", ")}.`);
123
+ return 1;
124
+ }
125
+ const mode = opts.agentMode ?? "chat";
126
+ if (!(0, modes_1.isAcpMode)(mode) || (0, modes_1.modeRank)(mode) > (0, modes_1.modeRank)(maxInput)) {
127
+ say(`The starting mode must be one of ${offered.join(", ")} and no higher than ${maxInput}.`);
128
+ return 1;
129
+ }
130
+ const session = opts.session?.trim() || "DeepSeek";
131
+ const registry = new registry_1.SessionRegistry();
132
+ registry.load();
133
+ const existing = registry.get(session);
134
+ if (existing && !existing.acp) {
135
+ say(`A session named "${session}" already exists on this computer and is not run by the relay. Choose another name with --session <name>.`);
136
+ return 1;
137
+ }
138
+ // ── Nothing above this line wrote anything. ─────────────────────────────────────────────
139
+ (0, policy_1.savePolicy)((0, policy_1.upsertRoot)((0, policy_1.loadPolicy)(), row.id, folder.path, maxInput));
140
+ (0, agents_1.writeModePatches)(row, (0, agents_1.acpPatchDir)());
141
+ await deps.join(session);
142
+ const acp = { agent: row.id, cwd: folder.path, mode };
143
+ registry.upsert({ name: session, runtime: row.id, runtimeBin: binary.path, relayOwned: true, acp });
144
+ const live = await deps.registerWithRelay({ type: "acp-register", session, runtime: row.id, runtimeBin: binary.path, acp });
145
+ say("");
146
+ say(` ${row.label}: ${binary.path} (${binary.version})`);
147
+ if (!(0, agents_1.matchesTestedVersion)(binary.version, row.testedVersion))
148
+ say(` Note: BayChat was tested with ${row.testedVersion}. ${row.label} is a preview and changes often — if something misbehaves, run \`baychat doctor\`.`);
149
+ say(` Safety: ${row.label} is experimental and has not been security-audited. DeepSeek recommends running it in a disposable VM or container, not on your everyday computer.`);
150
+ say(` Folder: ${folder.path}`);
151
+ say(` Mode: ${(0, modes_1.describeMode)(mode)}`);
152
+ say(` Ceiling: ${maxInput} — the chat can never switch it higher. Change it by running this command again.`);
153
+ // Opt-in (Ruling R26): the terminal path writes the BayChat token into a file every dsh
154
+ // profile reads, and the relay-run path needs none of it.
155
+ if (opts.terminal)
156
+ for (const line of deps.installTerminalPath(device))
157
+ say(` ${line}`);
158
+ else
159
+ say(" Terminal use: not set up (add --terminal to also use BayChat from dsh by hand — it writes your BayChat token into ~/.dsh/cordis.patch.yml, which applies to every dsh profile).");
160
+ say(` ${await deps.ensureRelay()}${live ? "" : " (it will pick this session up when it starts)"}`);
161
+ say("");
162
+ say(` Message "${session}" in BayChat. It needs a DeepSeek API key on this computer: run \`dsh web\` and add it on the Models page.`);
163
+ say(" From the chat (you, or anyone you gave command access): /mode-chat /mode-read /mode-ask /mode-full · /stop /new /status.");
164
+ return 0;
165
+ }
@@ -57,8 +57,23 @@ function parseConnectArgs(args) {
57
57
  throw new Error("Choose --start or --finish once.");
58
58
  result.mode = arg === "--start" ? "start" : "finish";
59
59
  }
60
+ else if (arg === "--folder" || arg === "--max-mode" || arg === "--mode" || arg === "--session") {
61
+ const value = args[++index];
62
+ if (!value?.trim() || value.startsWith("--"))
63
+ throw new Error(`Supply ${arg} with a value.`);
64
+ const key = arg === "--folder" ? "folder" : arg === "--max-mode" ? "maxMode" : arg === "--mode" ? "agentMode" : "session";
65
+ if (result[key])
66
+ throw new Error(`Supply ${arg} once.`);
67
+ result[key] = value;
68
+ }
69
+ else if (arg === "--terminal") {
70
+ if (result.terminal)
71
+ throw new Error("Supply --terminal once.");
72
+ result.terminal = true;
73
+ }
60
74
  else if (arg.startsWith("-") || result.client || !arg.trim()) {
61
- throw new Error("Usage: baychat connect <runtime> [--start | --finish] [--base <url>]");
75
+ throw new Error("Usage: baychat connect <runtime> [--start | --finish] [--base <url>] " +
76
+ "[--folder <path>] [--max-mode <mode>] [--mode <mode>] [--session <name>] [--terminal]");
62
77
  }
63
78
  else
64
79
  result.client = arg;
package/dist/connect.js CHANGED
@@ -52,6 +52,7 @@ const runtime_binary_1 = require("./runtime-binary");
52
52
  const connect_claude_1 = require("./connect-claude");
53
53
  var connect_claude_2 = require("./connect-claude");
54
54
  Object.defineProperty(exports, "cmdConnectClaude", { enumerable: true, get: function () { return connect_claude_2.cmdConnectClaude; } });
55
+ const connect_dsh_1 = require("./connect-dsh");
55
56
  const connect_hermes_1 = require("./connect-hermes");
56
57
  /** Clients `connect` can configure. `claude` is an alias users reach for. */
57
58
  const CLIENT_ALIASES = {
@@ -69,6 +70,7 @@ function renderConnectMenu() {
69
70
  ...rows,
70
71
  " npx baychat connect claude → Claude Code (login, MCP and skill)",
71
72
  " npx baychat connect hermes → Hermes (pair and enable its persistent agent)",
73
+ " npx baychat connect dsh → DeepSeek Harness (the relay runs it; talk to it from the app)",
72
74
  "",
73
75
  "You approve a QR on your phone. After that, in any coding session:",
74
76
  "",
@@ -198,6 +200,11 @@ async function cmdConnect(clientArg, opts = {}) {
198
200
  throw new Error("Hermes pairing does not support --start/--finish.");
199
201
  return (0, connect_hermes_1.cmdConnectHermes)(opts);
200
202
  }
203
+ if (clientArg.trim().toLowerCase() === "dsh" || clientArg.trim().toLowerCase() === "deepseek") {
204
+ if (opts.mode)
205
+ throw new Error("DeepSeek setup does not support --start/--finish.");
206
+ return (0, connect_dsh_1.cmdConnectDsh)(opts);
207
+ }
201
208
  const client = parseConnectClient(clientArg);
202
209
  if (opts.mode === "finish") {
203
210
  // Let the pending approval select its server unless one was explicitly supplied.
@@ -48,6 +48,7 @@ const os = __importStar(require("os"));
48
48
  const config_1 = require("./config");
49
49
  const doctor_1 = require("./doctor");
50
50
  const commands_1 = require("./relay/commands");
51
+ const registry_1 = require("./relay/registry");
51
52
  const credential_refresh_1 = require("./credential-refresh");
52
53
  const client_paths_1 = require("./client-paths");
53
54
  const runtime_binary_1 = require("./runtime-binary");
@@ -69,12 +70,18 @@ function codexPath() {
69
70
  return resolved.ok ? resolved.path : undefined;
70
71
  }
71
72
  async function currentDoctorEnv() {
73
+ // Read directly, not through `relay.sessions`: a relay-owned dsh session is worth
74
+ // diagnosing precisely when the relay is NOT running, which is often why `doctor` was
75
+ // reached for in the first place.
76
+ const registry = new registry_1.SessionRegistry();
77
+ registry.load();
72
78
  return {
73
79
  home: os.homedir(),
74
80
  platform: process.platform,
75
81
  now: Date.now(),
76
82
  device: (0, config_1.loadDeviceCredentials)(),
77
83
  relay: await (0, commands_1.tryRelayStatus)(),
84
+ targets: registry.all(),
78
85
  readText(filePath) {
79
86
  try {
80
87
  return fs.readFileSync(filePath, "utf8");
package/dist/doctor.js CHANGED
@@ -57,13 +57,17 @@ var __importStar = (this && this.__importStar) || (function () {
57
57
  Object.defineProperty(exports, "__esModule", { value: true });
58
58
  exports.buildReport = buildReport;
59
59
  exports.mailboxCheck = mailboxCheck;
60
+ exports.acpChecks = acpChecks;
60
61
  exports.exitCodeFor = exitCodeFor;
61
62
  exports.renderReport = renderReport;
62
63
  const fs = __importStar(require("fs"));
64
+ const os = __importStar(require("os"));
63
65
  const path = __importStar(require("path"));
64
66
  const mailbox_1 = require("./relay/mailbox");
65
67
  const parent_watch_1 = require("./relay/parent-watch");
66
68
  const client_paths_1 = require("./client-paths");
69
+ const agents_1 = require("./relay/acp/agents");
70
+ const policy_1 = require("./relay/acp/policy");
67
71
  const runtime_binary_1 = require("./runtime-binary");
68
72
  const runtimes_1 = require("./runtimes");
69
73
  /**
@@ -106,6 +110,7 @@ function buildReport(env) {
106
110
  mailbox: mailboxCheck(),
107
111
  staleCredentials: staleCredentialCheck(env),
108
112
  runtimes: REPORTED.map((runtime) => runtimeReport(runtime, env)),
113
+ agents: acpChecks({ resolveBinary: env.resolveBinary, targets: env.targets, homedir: env.home }),
109
114
  pending: pendingCheck(env),
110
115
  };
111
116
  }
@@ -168,6 +173,84 @@ function mailboxCheck() {
168
173
  // where the truth is established, honestly, as pending-or-delivered.
169
174
  return { name: "mailbox", status: "pass", detail: `registered a mailbox FIFO: ${live.join(", ")}` };
170
175
  }
176
+ /**
177
+ * Relay-run (ACP) agents: is each one startable, inside its rules, and able to reach a model?
178
+ *
179
+ * Takes `targets` rather than the full `DoctorEnv` for the same testability reason every other
180
+ * check here does — see `DoctorEnv.targets` for why these come from the persisted registry and
181
+ * not from `relay.sessions`.
182
+ *
183
+ * ONE finding per relay-owned session (not one report for the feature), because a machine
184
+ * running two dsh sessions in two folders can have one healthy and one not, and collapsing them
185
+ * would hide whichever came second.
186
+ *
187
+ * DeepSeek Harness is experimental and has not been security-audited (R11/R17): every line that
188
+ * gets far enough to say a version repeats DeepSeek's own recommendation to run it disposably —
189
+ * one short clause, not a paragraph, because the reader has already seen it once from `connect`.
190
+ */
191
+ function acpChecks(env) {
192
+ const owned = env.targets.filter((t) => t.relayOwned && t.acp);
193
+ if (owned.length === 0) {
194
+ return [{ name: "relay-run agents", status: "skip", detail: "none — `baychat connect dsh` sets one up" }];
195
+ }
196
+ const policy = (0, policy_1.loadPolicy)();
197
+ return owned.map((t) => {
198
+ const name = `agent ${t.name}`;
199
+ const row = (0, agents_1.acpAgent)(t.acp.agent);
200
+ if (!row) {
201
+ return {
202
+ name,
203
+ status: "fail",
204
+ detail: `unknown agent "${t.acp.agent}"`,
205
+ remedy: "Update baychat, then run `baychat connect dsh` again.",
206
+ };
207
+ }
208
+ const bin = env.resolveBinary(row.bin);
209
+ if (!bin.ok) {
210
+ return {
211
+ name,
212
+ status: "fail",
213
+ detail: `${row.label} is not installed where the relay can find it`,
214
+ remedy: row.installHint,
215
+ };
216
+ }
217
+ const folder = (0, policy_1.checkFolder)(policy, row.id, t.acp.cwd);
218
+ if (!folder.ok) {
219
+ return {
220
+ name,
221
+ status: "fail",
222
+ detail: folder.reason,
223
+ remedy: "Run `baychat connect dsh` in the folder it should work in.",
224
+ };
225
+ }
226
+ const home = env.homedir ?? os.homedir();
227
+ // EXISTENCE only. The key is dsh's; BayChat never opens the file that holds it, and never
228
+ // logs it — the check is "is there a file there", not "what does it say".
229
+ const keySeen = Boolean(process.env.DEEPSEEK_API_KEY) ||
230
+ fs.existsSync(path.join(process.env.DSH_HOME ?? path.join(home, ".dsh"), ".credentials.yaml"));
231
+ const line = `${row.label} ${bin.version} · ${t.acp.cwd} · mode ${t.acp.mode} (ceiling ${folder.maxMode})` +
232
+ " — DeepSeek recommends a disposable VM or container";
233
+ if (!keySeen) {
234
+ return {
235
+ name,
236
+ status: "warn",
237
+ detail: `${line} — no DeepSeek key found`,
238
+ remedy: "Run `dsh web` and add the key on the Models page.",
239
+ };
240
+ }
241
+ // R20: a substring check lets a later release ("0.1.5-rc.20") pass silently as the tested
242
+ // one ("0.1.5-rc.2"). `matchesTestedVersion` bounds the match to a whole version token.
243
+ if (!(0, agents_1.matchesTestedVersion)(bin.version, row.testedVersion)) {
244
+ return {
245
+ name,
246
+ status: "warn",
247
+ detail: `${line} — BayChat was tested with ${row.testedVersion}`,
248
+ remedy: "If it misbehaves, install the tested version or update baychat.",
249
+ };
250
+ }
251
+ return { name, status: "pass", detail: line };
252
+ });
253
+ }
171
254
  /**
172
255
  * The process exit code for a report.
173
256
  *
@@ -198,6 +281,8 @@ function renderReport(report) {
198
281
  for (const check of rest)
199
282
  lines.push(...renderGroupCheck("", check));
200
283
  }
284
+ for (const check of report.agents)
285
+ lines.push(...renderSummaryCheck(check));
201
286
  lines.push(...renderSummaryCheck(report.pending));
202
287
  return lines;
203
288
  }
@@ -513,7 +598,15 @@ function withRemedy(line, check) {
513
598
  return [line, `${" ".repeat(LABEL_WIDTH + 2 + CHECK_WIDTH)}→ ${check.remedy}`];
514
599
  }
515
600
  function allChecks(report) {
516
- return [report.credentials, report.staleCredentials, report.relay, report.mailbox, ...report.runtimes.flatMap((r) => r.checks), report.pending];
601
+ return [
602
+ report.credentials,
603
+ report.staleCredentials,
604
+ report.relay,
605
+ report.mailbox,
606
+ ...report.runtimes.flatMap((r) => r.checks),
607
+ ...report.agents,
608
+ report.pending,
609
+ ];
517
610
  }
518
611
  /** Shorten a path under the user's home, which is where nearly all of these are. */
519
612
  function display(filePath, env) {
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.mergeBayChatEntry = mergeBayChatEntry;
37
+ exports.writeDshMcpEntry = writeDshMcpEntry;
38
+ const fs = __importStar(require("fs"));
39
+ const os = __importStar(require("os"));
40
+ const path = __importStar(require("path"));
41
+ const crypto_1 = require("crypto");
42
+ // dsh composes every profile from layers, and `~/.dsh/cordis.patch.yml` applies to all of them —
43
+ // its equivalent of `~/.codex/config.toml`. The entry below was hand-wired and RUN on
44
+ // 2026-09-18 (docs/planning/2026-09-18-deepseek-harness-step-by-step.md).
45
+ //
46
+ // WHY A MARKED TEXT BLOCK AND NOT A YAML MERGE. The client's file may contain dsh's `!!js` tags,
47
+ // which a generic YAML parser rejects or, worse, round-trips wrongly. We never parse their file:
48
+ // we own the lines between two markers and nothing else, and we back the file up before writing.
49
+ //
50
+ // The token is a literal, as it is in Codex's config — the file is 0600. `connect` rewrites the
51
+ // block on every run, so a re-issued token replaces the old one rather than leaving it behind.
52
+ const START = "# >>> baychat (managed by `baychat connect dsh` — edits between these lines are overwritten)";
53
+ const END = "# <<< baychat";
54
+ const CORRUPT_BLOCK_MESSAGE = "~/.dsh/cordis.patch.yml has a damaged BayChat block (a start marker without its end, or " +
55
+ "duplicates). Remove the lines between '# >>> baychat' and '# <<< baychat' by hand, then run " +
56
+ "`baychat connect dsh` again.";
57
+ /**
58
+ * Refuse to interpolate anything that could break out of the block's YAML.
59
+ *
60
+ * Device tokens are `bay_u_` + hex and the MCP url is ours, so neither check should ever fire in
61
+ * practice — but a value we did not generate should never be trusted to hold still inside a
62
+ * double-quoted YAML scalar just because it happens to today.
63
+ */
64
+ function validateEntry(entry) {
65
+ if (!/^[A-Za-z0-9_]+$/.test(entry.token)) {
66
+ throw new Error("Refusing to write a BayChat MCP entry: the device token contains characters other than letters, digits and underscore.");
67
+ }
68
+ if (/["'\s]/.test(entry.url)) {
69
+ throw new Error("Refusing to write a BayChat MCP entry: the MCP URL contains a quote, newline or whitespace.");
70
+ }
71
+ }
72
+ /**
73
+ * Find the single well-formed managed block in `existing`, or `null` when there is none at all.
74
+ *
75
+ * RULING R22: a START with no END after it, an END with no START, or more than one of either
76
+ * marker is CORRUPT — the file was hand-edited into a shape we cannot safely replace. Silently
77
+ * appending a second block on top of that would leave the old one, stale token included, in the
78
+ * file forever, so this throws instead of guessing.
79
+ */
80
+ function findManagedBlock(existing) {
81
+ const firstStart = existing.indexOf(START);
82
+ const lastStart = existing.lastIndexOf(START);
83
+ const firstEnd = existing.indexOf(END);
84
+ const lastEnd = existing.lastIndexOf(END);
85
+ const hasStart = firstStart >= 0;
86
+ const hasEnd = firstEnd >= 0;
87
+ if (!hasStart && !hasEnd)
88
+ return null;
89
+ if (hasStart !== hasEnd)
90
+ throw new Error(CORRUPT_BLOCK_MESSAGE);
91
+ if (firstStart !== lastStart || firstEnd !== lastEnd)
92
+ throw new Error(CORRUPT_BLOCK_MESSAGE);
93
+ if (firstEnd < firstStart)
94
+ throw new Error(CORRUPT_BLOCK_MESSAGE);
95
+ return { from: firstStart, to: firstEnd };
96
+ }
97
+ function mergeBayChatEntry(existing, entry) {
98
+ validateEntry(entry);
99
+ const block = [
100
+ START,
101
+ "- insert:",
102
+ " - id: mcp-baychat",
103
+ " name: '@deepseek-ai/dsh-mcp-client'",
104
+ " config:",
105
+ " serverName: baychat",
106
+ " transport: streamable-http",
107
+ ` url: ${entry.url}`,
108
+ " headers:",
109
+ ` Authorization: "Bearer ${entry.token}"`,
110
+ END,
111
+ "",
112
+ ].join("\n");
113
+ const found = findManagedBlock(existing);
114
+ if (found) {
115
+ const after = existing.slice(found.to + END.length).replace(/^\n/, "");
116
+ return `${existing.slice(0, found.from)}${block}${after}`;
117
+ }
118
+ const head = existing === "" || existing.endsWith("\n") ? existing : `${existing}\n`;
119
+ return `${head}${block}`;
120
+ }
121
+ function writeDshMcpEntry(entry, home = os.homedir()) {
122
+ const file = path.join(home, ".dsh", "cordis.patch.yml");
123
+ fs.mkdirSync(path.dirname(file), { recursive: true });
124
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
125
+ // Computed BEFORE anything is written: a corrupt block, or an unsafe entry, throws here and
126
+ // leaves the file — and any backup — untouched.
127
+ const next = mergeBayChatEntry(existing, entry);
128
+ let backup;
129
+ if (existing) {
130
+ backup = `${file}.baychat-backup`;
131
+ fs.writeFileSync(backup, existing, { mode: 0o600 });
132
+ }
133
+ // Atomic: write to a temp file in the same directory, then rename over the target. A reader —
134
+ // or a crash mid-write — never sees a half-written config. Same pattern as `writeModePatches`
135
+ // in relay/acp/agents.ts and `writeHermesMcp` in hermes-mcp.ts.
136
+ const temp = `${file}.${process.pid}.${(0, crypto_1.randomUUID)()}.tmp`;
137
+ try {
138
+ fs.writeFileSync(temp, next, { mode: 0o600, flag: "wx" });
139
+ fs.chmodSync(temp, 0o600); // `mode` above is filtered by the umask
140
+ fs.renameSync(temp, file);
141
+ }
142
+ catch (err) {
143
+ fs.rmSync(temp, { force: true });
144
+ throw err;
145
+ }
146
+ return { file, backup };
147
+ }