baychat 0.21.5 → 0.23.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 (44) hide show
  1. package/README.md +75 -1
  2. package/dist/approve-hook.js +5 -2
  3. package/dist/commands.js +6 -4
  4. package/dist/connect-acp.js +165 -0
  5. package/dist/connect-cursor.js +65 -0
  6. package/dist/connect-dsh.js +45 -0
  7. package/dist/connect-plan.js +16 -1
  8. package/dist/connect.js +17 -0
  9. package/dist/doctor-command.js +7 -0
  10. package/dist/doctor.js +93 -1
  11. package/dist/dsh-config.js +147 -0
  12. package/dist/index.js +27 -0
  13. package/dist/relay/acp/agents.js +231 -0
  14. package/dist/relay/acp/approval.js +67 -0
  15. package/dist/relay/acp/client.js +265 -0
  16. package/dist/relay/acp/commands.js +69 -0
  17. package/dist/relay/acp/cursor.js +271 -0
  18. package/dist/relay/acp/daemon-glue.js +198 -0
  19. package/dist/relay/acp/dump-config.js +31 -0
  20. package/dist/relay/acp/modes.js +36 -0
  21. package/dist/relay/acp/permissions.js +104 -0
  22. package/dist/relay/acp/policy.js +138 -0
  23. package/dist/relay/acp/presence.js +87 -0
  24. package/dist/relay/acp/prompt.js +68 -0
  25. package/dist/relay/acp/runner.js +364 -0
  26. package/dist/relay/acp/sdk.js +19 -0
  27. package/dist/relay/acp/turn-queue.js +163 -0
  28. package/dist/relay/acp/types.js +5 -0
  29. package/dist/relay/commands.js +28 -0
  30. package/dist/relay/daemon.js +195 -0
  31. package/dist/relay/mailbox.js +1 -0
  32. package/dist/relay/profiles.js +22 -0
  33. package/dist/relay/registry.js +46 -1
  34. package/dist/relay/session-commands.js +89 -0
  35. package/dist/relay/session-reply.js +25 -0
  36. package/dist/relay/terminal-pane.js +197 -0
  37. package/dist/relay/types.js +0 -9
  38. package/dist/runtime-binary.js +17 -0
  39. package/dist/runtimes.js +13 -0
  40. package/dist/session-command.js +16 -3
  41. package/dist/skill-bootstrap.js +16 -8
  42. package/dist/start-command.js +235 -0
  43. package/dist/update-command.js +201 -0
  44. package/package.json +6 -2
@@ -0,0 +1,197 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.detectPane = detectPane;
4
+ exports.verifyPane = verifyPane;
5
+ exports.typeIntoPane = typeIntoPane;
6
+ const child_process_1 = require("child_process");
7
+ const profiles_1 = require("./profiles");
8
+ /**
9
+ * Read the pane out of the environment — INSIDE the session, never in the daemon.
10
+ *
11
+ * Same principle as `runtimeBin` and `resumeId`: the process that is actually in the
12
+ * terminal is the only one that can answer, and the daemon guessing is how this codebase
13
+ * has been bitten before. `relay attach` runs in the session; its environment carries
14
+ * `$TMUX` and `$TMUX_PANE` (measured: `/tmp/tmux-1003/default,1381496,1` and `%1`, matching
15
+ * `display-message -p '#{pane_id}'` from outside).
16
+ *
17
+ * Every multiplexer we can RECOGNISE is returned, including ones we cannot yet type into.
18
+ * That is deliberate: "you are in screen, and tmux is what this supports" is a far better
19
+ * answer than the silence of not looking.
20
+ */
21
+ function detectPane(env = process.env) {
22
+ const tmux = env.TMUX;
23
+ const tmuxPane = env.TMUX_PANE;
24
+ if (tmux && tmuxPane) {
25
+ // "<socket>,<server pid>,<session>" — only the first field addresses the server.
26
+ const socket = tmux.split(",")[0];
27
+ if (socket)
28
+ return { kind: "tmux", socket, pane: tmuxPane };
29
+ }
30
+ if (env.STY)
31
+ return { kind: "screen", session: env.STY, window: env.WINDOW ?? "0" };
32
+ if (env.ZELLIJ)
33
+ return { kind: "zellij", pane: env.ZELLIJ_PANE_ID };
34
+ return undefined;
35
+ }
36
+ /** The binary whose name must still be in the pane for a send to be allowed. */
37
+ function expectedCommand(runtime) {
38
+ return (0, profiles_1.profileFor)(runtime)?.bin ?? runtime;
39
+ }
40
+ /**
41
+ * Is this pane still the session we recorded?
42
+ *
43
+ * ⚠️ THE CHECK THAT KEEPS THIS FROM BEING A REMOTE SHELL. A pane outlives the program in
44
+ * it: an agent that exited leaves a **shell prompt** on the same pane id, and typing into a
45
+ * shell is running a command. So the foreground command is compared against the runtime
46
+ * recorded at attach, and anything else — dead pane, different program, tmux not answering
47
+ * — is a refusal.
48
+ *
49
+ * `#{pane_current_command}` is the FOREGROUND process, which is what a keystroke would
50
+ * actually reach. Using it also means a pane sitting in a subprocess refuses rather than
51
+ * typing into that subprocess, which is fail-closed in the direction we want.
52
+ */
53
+ function verifyPane(handle, runtime, run = (file, args) => (0, child_process_1.execFileSync)(file, args, { encoding: "utf8", timeout: 5_000 }), ownerPid) {
54
+ if (handle.kind === "zellij") {
55
+ return {
56
+ ok: false,
57
+ reason: "this session is running in zellij, which cannot be typed into yet — tmux and screen can.",
58
+ };
59
+ }
60
+ if (handle.kind === "screen") {
61
+ // SCREEN CANNOT BE ASKED WHAT IS RUNNING IN A WINDOW. tmux answers
62
+ // `#{pane_current_command}`; screen has no equivalent, and `screen -ls` says only that
63
+ // a session exists. So the same question is put to the PROCESS instead: is the runtime
64
+ // this session recorded at attach still alive, and still that runtime?
65
+ //
66
+ // This is a weaker check than tmux's in one specific way, and it is worth naming: it
67
+ // proves the agent is RUNNING, not that it is the foreground program of that window.
68
+ // A human who switched that window to a shell would be typed at. The window number is
69
+ // pinned from `$WINDOW` precisely to keep that to the window the agent started in.
70
+ if (ownerPid === undefined) {
71
+ return { ok: false, reason: "this screen session did not record its runtime process, so it cannot be typed into safely." };
72
+ }
73
+ return runtimeStillRunning(ownerPid, runtime, run);
74
+ }
75
+ let out;
76
+ try {
77
+ out = run("tmux", [
78
+ "-S",
79
+ handle.socket,
80
+ "display-message",
81
+ "-p",
82
+ "-t",
83
+ handle.pane,
84
+ "#{pane_dead} #{pane_current_command}",
85
+ ]).trim();
86
+ }
87
+ catch (err) {
88
+ return {
89
+ ok: false,
90
+ reason: `tmux could not find pane ${handle.pane} any more (${errText(err)}).`,
91
+ };
92
+ }
93
+ const [dead, current] = out.split(/\s+/);
94
+ if (dead !== "0")
95
+ return { ok: false, reason: `pane ${handle.pane} has exited.` };
96
+ const want = expectedCommand(runtime);
97
+ if (current !== want) {
98
+ return {
99
+ ok: false,
100
+ reason: `pane ${handle.pane} is running ${current ?? "nothing"}, not ${want} — refusing to type into it.`,
101
+ };
102
+ }
103
+ return { ok: true };
104
+ }
105
+ /**
106
+ * Is `pid` still running, and still the runtime we recorded?
107
+ *
108
+ * Both halves matter. A pid alone proves nothing — pids are reused, and the reused one is
109
+ * exactly as likely to be a shell as anything else. The command name is what makes it an
110
+ * identification rather than a hope.
111
+ */
112
+ function runtimeStillRunning(pid, runtime, run) {
113
+ const want = expectedCommand(runtime);
114
+ let comm;
115
+ try {
116
+ comm = run("ps", ["-p", String(pid), "-o", "comm="]).trim();
117
+ }
118
+ catch {
119
+ return { ok: false, reason: `the ${want} process for this session is no longer running.` };
120
+ }
121
+ if (!comm)
122
+ return { ok: false, reason: `the ${want} process for this session has exited.` };
123
+ // `comm` is truncated to 15 characters by the kernel, so compare on that basis rather
124
+ // than demanding equality — "claude" and "codex" are short, but a longer runtime name
125
+ // would otherwise never match itself.
126
+ if (comm !== want && !want.startsWith(comm)) {
127
+ return { ok: false, reason: `that process is ${comm}, not ${want} — refusing to type into it.` };
128
+ }
129
+ return { ok: true };
130
+ }
131
+ /**
132
+ * ⚠️ Characters that must never reach a multiplexer's "type this" command.
133
+ *
134
+ * `screen -X stuff` parses `^X` as a control character and `\` as an escape, so a string
135
+ * containing either would send something other than itself. Our text comes from a fixed
136
+ * table and contains neither — this refuses anyway, because the day somebody adds a row
137
+ * with a backslash in it is the day that stops being true, and a test failing is a much
138
+ * better outcome than a control character being typed into a terminal.
139
+ */
140
+ const UNSAFE_TO_TYPE = /[\^\\\r\n\x00-\x1f]/;
141
+ /**
142
+ * Type one line into the pane and press Enter.
143
+ *
144
+ * ⚠️ `-l` IS LOAD-BEARING. Without it `send-keys` reads its arguments as KEY NAMES, so text
145
+ * containing `C-c` or `Escape` would be a signal rather than characters. With it the string
146
+ * is sent literally and Enter is a separate, deliberate key.
147
+ *
148
+ * `text` must come from the command table in `session-commands.ts` and NEVER from a chat
149
+ * message. That is the rule the whole feature rests on: the wire carries a command NAME,
150
+ * and the keystrokes are ours. There is deliberately no argument here that a message could
151
+ * reach.
152
+ */
153
+ async function typeIntoPane(handle, runtime, text, deps = {}) {
154
+ // Checked BEFORE the pane, so a bad row fails on every machine rather than only on the
155
+ // multiplexer that would have mangled it.
156
+ if (UNSAFE_TO_TYPE.test(text)) {
157
+ return { ok: false, reason: "that command contains characters that must never be typed into a terminal." };
158
+ }
159
+ const verify = deps.verify ?? verifyPane;
160
+ const check = verify(handle, runtime, undefined, deps.ownerPid);
161
+ if (!check.ok)
162
+ return check;
163
+ const exec = deps.exec ?? runQuietly;
164
+ try {
165
+ if (handle.kind === "tmux") {
166
+ // `-l` sends the string LITERALLY. Without it send-keys reads its arguments as key
167
+ // NAMES, so text containing `C-c` would be a signal rather than characters.
168
+ await exec("tmux", ["-S", handle.socket, "send-keys", "-t", handle.pane, "-l", "--", text]);
169
+ await exec("tmux", ["-S", handle.socket, "send-keys", "-t", handle.pane, "Enter"]);
170
+ return { ok: true };
171
+ }
172
+ if (handle.kind === "screen") {
173
+ // `-p <window>` pins the window the agent started in; screen's "current window" is
174
+ // whichever the human last looked at, which is not the same thing.
175
+ //
176
+ // Enter goes as its own `stuff` of `^M`, which screen reads as a carriage return.
177
+ // That is the one place a `^` is deliberate — and it is ours, never a message's:
178
+ // `UNSAFE_TO_TYPE` above guarantees the text itself carries none.
179
+ const at = ["-S", handle.session, "-p", handle.window, "-X", "stuff"];
180
+ await exec("screen", [...at, text]);
181
+ await exec("screen", [...at, "^M"]);
182
+ return { ok: true };
183
+ }
184
+ return { ok: false, reason: `${handle.kind} cannot be typed into yet.` };
185
+ }
186
+ catch (err) {
187
+ return { ok: false, reason: `${handle.kind} refused the keystrokes: ${errText(err)}` };
188
+ }
189
+ }
190
+ function runQuietly(file, args) {
191
+ return new Promise((resolve, reject) => {
192
+ (0, child_process_1.execFile)(file, args, { timeout: 5_000 }, (err) => (err ? reject(err) : resolve()));
193
+ });
194
+ }
195
+ function errText(err) {
196
+ return err instanceof Error ? err.message : String(err);
197
+ }
@@ -1,11 +1,2 @@
1
1
  "use strict";
2
- /**
3
- * Shared types for the relay daemon.
4
- *
5
- * The relay's job is narrow: hold `/updates`, decide which local session an
6
- * event belongs to, and wake that session — once, never twice concurrently.
7
- * It deliberately does NOT decide whether the session should reply. That
8
- * judgement is the server's (`shouldRespond`) and the woken session's; the
9
- * relay only carries the message across the process boundary.
10
- */
11
2
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.resolveRuntimeBinary = resolveRuntimeBinary;
37
37
  exports.describeResolution = describeResolution;
38
38
  exports.spawnPlanFor = spawnPlanFor;
39
+ exports.probeBinaryVersion = probeBinaryVersion;
39
40
  exports.summarizeProbe = summarizeProbe;
40
41
  exports.currentBinaryEnv = currentBinaryEnv;
41
42
  exports.summarizeResolutionFailure = summarizeResolutionFailure;
@@ -213,6 +214,22 @@ function spawnPlanFor(candidate, platform) {
213
214
  }
214
215
  /** How long a `--version` probe may run before it is treated as broken. */
215
216
  const PROBE_TIMEOUT_MS = 5_000;
217
+ /**
218
+ * `<binary> --version`, without blocking the event loop — for a caller that probes a known path
219
+ * while other work runs (the relay, before starting an agent). Same rules as the resolution
220
+ * probe: no shell, the environment the agent will get, `summarizeProbe` decides.
221
+ */
222
+ function probeBinaryVersion(binaryPath, env) {
223
+ const plan = spawnPlanFor(binaryPath, process.platform);
224
+ return new Promise((resolve) => {
225
+ (0, child_process_1.execFile)(plan.file, [...plan.prefixArgs, "--version"], { timeout: PROBE_TIMEOUT_MS, windowsHide: true, shell: false, env, encoding: "utf8" }, (err, stdout, stderr) => {
226
+ const status = err ? (typeof err.code === "number" ? err.code : null) : 0;
227
+ // A non-zero exit is still a run: only a failure to run at all is an `error`.
228
+ const failedToRun = err && typeof err.code !== "number" ? err : undefined;
229
+ resolve(summarizeProbe({ status, stdout: stdout ?? "", stderr: stderr ?? "", error: failedToRun }));
230
+ });
231
+ });
232
+ }
216
233
  /** The most stderr worth quoting back to a user in a rejection reason. */
217
234
  const PROBE_DETAIL_LIMIT = 200;
218
235
  /**
package/dist/runtimes.js CHANGED
@@ -39,6 +39,7 @@ exports.RUNTIMES = [
39
39
  "desktop",
40
40
  "pi",
41
41
  "hermes",
42
+ "dsh",
42
43
  "generic",
43
44
  ];
44
45
  const GENERIC_RESUME_NOTE = `The relay can only wake this session while \`attach\` is running. Re-arm it after
@@ -483,6 +484,18 @@ DELIVERY PENDING and waits for a human — re-arm attach after every wake, witho
483
484
  fallback: "Hermes is a self-hosted agent: it authenticates with its own agent token and long-polls for messages, so there is nothing to install on this machine. Pair it from the app, then add BayChat under `mcp_servers:` in ~/.hermes/config.yaml to give it tools.",
484
485
  needsRestart: false,
485
486
  },
487
+ dsh: {
488
+ id: "dsh",
489
+ label: "DeepSeek Harness",
490
+ mcp: { kind: "manual", describe: "merged into ~/.dsh/cordis.patch.yml by `baychat connect dsh`" },
491
+ // ~/.dsh/skills is rank 400 in dsh's skill search and ~/.agents/skills is 500 [source], so
492
+ // this copy SHADOWS a Codex skill a client already has — which would tell dsh to attach as
493
+ // `--runtime codex`. It must be written here, never shared with the Codex file.
494
+ command: { dir: ".dsh/skills/baychat", file: "SKILL.md", frontmatter: true, render: (ctx) => renderCommand(ctx, true) },
495
+ invocation: '/baychat <name> ["<Group Title>"]',
496
+ relay: { runtime: "dsh", reArm: "background-every-wake", resumeNote: GENERIC_RESUME_NOTE },
497
+ needsRestart: true,
498
+ },
486
499
  generic: {
487
500
  id: "generic",
488
501
  label: "Generic MCP client",
@@ -7,6 +7,7 @@ const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/client/streamable
7
7
  const config_1 = require("./config");
8
8
  const session_name_1 = require("./session-name");
9
9
  const owner_pid_1 = require("./relay/owner-pid");
10
+ const terminal_pane_1 = require("./relay/terminal-pane");
10
11
  const profiles_1 = require("./relay/profiles");
11
12
  const message_format_1 = require("./relay/message-format");
12
13
  const commands_1 = require("./relay/commands");
@@ -69,15 +70,16 @@ async function cmdJoinSession(args) {
69
70
  const joining = Boolean(options.session || options.group || options.destination);
70
71
  const runtime = options.runtime ?? (0, owner_pid_1.detectRuntime)(profiles_1.RUNTIME_PROFILES);
71
72
  if (joining && !runtime)
72
- throw new Error("Cannot detect this runtime. Pass --runtime codex or --runtime claude.");
73
+ throw new Error("Cannot detect this runtime. Pass --runtime codex, --runtime claude, --runtime cursor, or --runtime dsh.");
73
74
  if (runtime === "hermes") {
74
75
  throw new Error("Hermes is a persistent agent. Use baychat connect hermes.");
75
76
  }
76
77
  if (joining &&
77
78
  runtime !== "codex" &&
78
79
  runtime !== "claude" &&
79
- runtime !== "cursor") {
80
- throw new Error("Supported coding runtimes: codex, claude, cursor.");
80
+ runtime !== "cursor" &&
81
+ runtime !== "dsh") {
82
+ throw new Error("Supported coding runtimes: codex, claude, cursor, dsh.");
81
83
  }
82
84
  let name = joining
83
85
  ? (options.session ?? (await (0, session_name_1.automaticSessionName)(runtime)))
@@ -189,6 +191,17 @@ async function cmdJoinSession(args) {
189
191
  return 1;
190
192
  }
191
193
  console.log("Connecting incoming messages…");
194
+ // SAID AT JOIN, NOT AT FAILURE. `/clear` and `/new` from the app work by typing into
195
+ // this session's terminal, which needs a multiplexer pane to address (see
196
+ // relay/terminal-pane.ts). Somebody who learns that only when a command refuses has
197
+ // already been let down once; one line here is the whole difference.
198
+ // ONE LINE, AND ONLY WHEN IT IS ACTIONABLE. `baychat start` puts an agent somewhere these
199
+ // commands can reach without anybody learning what a multiplexer is, so the note names
200
+ // the one word that fixes it and never the machinery underneath.
201
+ const pane = (0, terminal_pane_1.detectPane)();
202
+ if (!pane) {
203
+ console.log("Note: /clear and /new from the app cannot reach this session. Start it with `baychat` next time and they will. Everything else works either way.");
204
+ }
192
205
  const attach = () => (0, commands_1.cmdRelayAttach)({
193
206
  session: name,
194
207
  runtime: runtime,
@@ -23,14 +23,22 @@ instructions; it does not log in, join a room, or start a receiver. Do not inspe
23
23
  installed source or credential files. Package updates supply the new workflow
24
24
  automatically; there is no separate skill-refresh command for the user.
25
25
 
26
- For an update request (\`--update\`), first run \`npm install --global baychat@latest\`
27
- yourself. On an update-only request, run \`baychat --version\`, confirm it briefly,
28
- and stop without loading the join workflow, joining or sending messages. If a join
29
- was also requested, load the new workflow and keep the original name and group.
30
- If BayChat is missing, install it with the same npm command and retry the loader.
31
- Install only once per request; stop on failure with its short error. Do not retry
32
- installations or change npm permissions. No skill refresh or host restart is
33
- needed just to load newer workflow instructions from the package.
26
+ For an update request (\`--update\`), run \`baychat update\` and report what it prints.
27
+ That one command does every part: the npm package, every connected client's config, and
28
+ a relay restart. The relay is the step that matters most and the easiest to miss — a
29
+ running daemon holds its old code in memory, so an npm install alone leaves the machine
30
+ on the previous version with nothing saying so.
31
+
32
+ Do not run npm yourself, and do not run the steps separately. A sequence the model has
33
+ to remember is a sequence the model will one day do half of; \`baychat update\` is
34
+ idempotent, refreshes only what is already connected, and never sets anything up.
35
+ If BayChat is not installed at all, install it once with
36
+ \`npm install --global baychat@latest\` and retry. Stop on failure with its short error;
37
+ do not retry installs or change npm permissions.
38
+
39
+ On an update-only request, stop after reporting — do not load the join workflow, join, or
40
+ send messages. If a join was also requested, load the new workflow afterwards and keep the
41
+ original name and group.
34
42
 
35
43
  Missing MCP setup is handled by the returned workflow inside this session.
36
44
  Do not ask the user to run connect. Authentication approvals and host reloads
@@ -0,0 +1,235 @@
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.START_RUNTIMES = void 0;
37
+ exports.startRefusal = startRefusal;
38
+ exports.cmdStart = cmdStart;
39
+ const child_process_1 = require("child_process");
40
+ const fs = __importStar(require("fs"));
41
+ const path = __importStar(require("path"));
42
+ const readline = __importStar(require("readline"));
43
+ const config_1 = require("./config");
44
+ const terminal_pane_1 = require("./relay/terminal-pane");
45
+ const profiles_1 = require("./relay/profiles");
46
+ const runtime_binary_1 = require("./runtime-binary");
47
+ const update_command_1 = require("./update-command");
48
+ /**
49
+ * `baychat start [name]` — the whole product in one command.
50
+ *
51
+ * ── THE BAR THIS EXISTS TO MEET ───────────────────────────────────────────────────────
52
+ *
53
+ * Karmen's, 2026-09-14: *"everything we do we have to make very easy, basically just
54
+ * download baychat, and then start session, nothing else."*
55
+ *
56
+ * So: this launches the person's agent, inside a terminal multiplexer when one is
57
+ * available, and says nothing about multiplexers at all. They type one word. `/clear` and
58
+ * `/new` from the app then work, because `relay attach` finds `$TMUX`/`$TMUX_PANE` waiting
59
+ * for it — and if no multiplexer is installed, the agent still starts and everything except
60
+ * those two commands still works. **The fallback is silent on purpose.** A warning about a
61
+ * program they have never heard of, at the moment they are trying to start work, is exactly
62
+ * the "something else" this command exists to remove.
63
+ *
64
+ * ── WHY NOT A PSEUDO-TERMINAL OF OUR OWN ──────────────────────────────────────────────
65
+ *
66
+ * `node-pty` would remove the multiplexer requirement completely and work on Windows too.
67
+ * It is 64 MB unpacked and compiles through node-gyp on install, so `npx baychat` would
68
+ * start demanding a C++ toolchain — which fails for everyone, not only the people who
69
+ * wanted `/clear`. Checked 2026-09-14 and rejected on that basis, not on taste.
70
+ *
71
+ * ── WHY IT DOES NOT JUST JOIN, TOO ────────────────────────────────────────────────────
72
+ *
73
+ * Joining happens INSIDE the agent (the installed skill runs `baychat join`), because that
74
+ * is the only place the session's own runtime id, binary and pane can be read. This command
75
+ * gets the agent into a terminal we can reach; the agent does the rest, exactly as it does
76
+ * today.
77
+ */
78
+ const PREFERENCE_FILE = "start-runtime.json";
79
+ /**
80
+ * Runtimes `start` never probes for or launches. dsh (Ruling R27): it is experimental and
81
+ * meant to run only in a VM, and BayChat runs it through the relay over ACP
82
+ * (`baychat connect dsh`), which enforces its modes — a terminal launch would not.
83
+ */
84
+ const NOT_STARTED = {
85
+ dsh: "DeepSeek Harness is not started from here: the relay runs it over ACP. Set it up with `baychat connect dsh`.",
86
+ };
87
+ /** Runtimes worth probing for, best-supported first. */
88
+ exports.START_RUNTIMES = ["claude", "codex", ...profiles_1.RUNTIME_PROFILES.map((p) => p.id)].filter((id) => !Object.hasOwn(NOT_STARTED, id));
89
+ /** Why `start` will not launch `runtime`, or undefined when it will. */
90
+ function startRefusal(runtime) {
91
+ return Object.hasOwn(NOT_STARTED, runtime) ? NOT_STARTED[runtime] : undefined;
92
+ }
93
+ async function cmdStart(args) {
94
+ if (!(0, config_1.loadDeviceCredentials)()) {
95
+ console.error("This machine is not signed in yet. Run `npx baychat login` first.");
96
+ return 1;
97
+ }
98
+ // THE UPDATE NOBODY HAS TO REMEMBER.
99
+ //
100
+ // Shipping a CLI change reaches nobody until the package is refetched, every connected
101
+ // client's config is rewritten, and the relay is restarted — a live daemon holds old code
102
+ // in memory indefinitely. Three steps, in different places, and the failure is silence.
103
+ // So the one command people actually run does them, once a day.
104
+ //
105
+ // ⚠️ NEVER BLOCKS STARTING WORK. Any failure — offline, npm down, a permissions problem —
106
+ // is swallowed: the agent starts anyway. An update is an improvement to a session, never
107
+ // a precondition for one, and a person who cannot start their agent because a background
108
+ // refresh failed would rightly never trust this command again.
109
+ if (!args.includes("--no-update") && (0, update_command_1.autoUpdateDue)()) {
110
+ try {
111
+ await (0, update_command_1.cmdUpdate)({ quiet: true });
112
+ }
113
+ catch {
114
+ /* Starting the agent matters more than being current. */
115
+ }
116
+ }
117
+ const runtimeFlag = valueAfter(args, "--runtime");
118
+ const positional = args.filter((a) => !a.startsWith("--") && a !== runtimeFlag);
119
+ const name = positional[0]?.trim();
120
+ const runtime = runtimeFlag ?? (await chooseRuntime());
121
+ if (!runtime)
122
+ return 1;
123
+ const refusal = startRefusal(runtime);
124
+ if (refusal) {
125
+ console.error(refusal);
126
+ return 1;
127
+ }
128
+ const binary = (0, runtime_binary_1.resolveRuntimeBinary)(runtime, (0, runtime_binary_1.currentBinaryEnv)());
129
+ if (!binary.ok) {
130
+ console.error(`Could not start ${runtime}: ${binary.reason}`);
131
+ return 1;
132
+ }
133
+ // ALREADY INSIDE ONE? Then just run the agent. Nesting multiplexers sends every prefix
134
+ // key to the wrong one, and the person already has everything this command provides.
135
+ const inside = (0, terminal_pane_1.detectPane)();
136
+ const multiplexer = inside ? undefined : firstAvailable(["tmux", "screen"]);
137
+ if (!multiplexer) {
138
+ const result = (0, child_process_1.spawnSync)(binary.path, [], { stdio: "inherit" });
139
+ return result.status ?? 0;
140
+ }
141
+ const sessionName = name || "baychat";
142
+ const argv = multiplexer === "tmux"
143
+ ? // `-A` attaches to an existing session of that name instead of failing, so running
144
+ // this twice returns you to your agent rather than erroring at you.
145
+ ["new", "-A", "-s", sessionName, binary.path]
146
+ : ["-S", sessionName, "-R", binary.path];
147
+ const result = (0, child_process_1.spawnSync)(multiplexer, argv, { stdio: "inherit" });
148
+ if (result.error) {
149
+ // Falling back rather than failing: the multiplexer is an enhancement, and refusing to
150
+ // start somebody's agent because of one would be the tail wagging the dog.
151
+ const direct = (0, child_process_1.spawnSync)(binary.path, [], { stdio: "inherit" });
152
+ return direct.status ?? 0;
153
+ }
154
+ return result.status ?? 0;
155
+ }
156
+ /**
157
+ * Which agent to start.
158
+ *
159
+ * Asks only when it genuinely cannot tell — one installed runtime needs no question — and
160
+ * remembers the answer, because being asked the same thing every morning is "something
161
+ * else" by another name.
162
+ */
163
+ async function chooseRuntime() {
164
+ const remembered = readPreference();
165
+ if (remembered)
166
+ return remembered;
167
+ const env = (0, runtime_binary_1.currentBinaryEnv)();
168
+ const installed = [...new Set(exports.START_RUNTIMES)].filter((id) => (0, runtime_binary_1.resolveRuntimeBinary)(id, env).ok);
169
+ if (installed.length === 0) {
170
+ console.error("No coding agent found on this machine. Install one (Claude Code or Codex) and run this again.");
171
+ return undefined;
172
+ }
173
+ if (installed.length === 1) {
174
+ writePreference(installed[0]);
175
+ return installed[0];
176
+ }
177
+ console.log("Which agent should BayChat start?");
178
+ installed.forEach((id, index) => console.log(` ${index + 1}. ${id}`));
179
+ const answer = await ask(`Choose 1-${installed.length}: `);
180
+ const chosen = installed[Number(answer.trim()) - 1];
181
+ if (!chosen) {
182
+ console.error("Not one of the options.");
183
+ return undefined;
184
+ }
185
+ writePreference(chosen);
186
+ return chosen;
187
+ }
188
+ function ask(question) {
189
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
190
+ return new Promise((resolve) => rl.question(question, (answer) => {
191
+ rl.close();
192
+ resolve(answer);
193
+ }));
194
+ }
195
+ function preferencePath() {
196
+ return path.join((0, config_1.configDir)(), PREFERENCE_FILE);
197
+ }
198
+ function readPreference() {
199
+ try {
200
+ const raw = JSON.parse(fs.readFileSync(preferencePath(), "utf8"));
201
+ return typeof raw.runtime === "string" ? raw.runtime : undefined;
202
+ }
203
+ catch {
204
+ // Absent or unreadable is simply "not chosen yet" — never an error worth showing.
205
+ return undefined;
206
+ }
207
+ }
208
+ function writePreference(runtime) {
209
+ try {
210
+ fs.mkdirSync((0, config_1.configDir)(), { recursive: true });
211
+ fs.writeFileSync(preferencePath(), JSON.stringify({ runtime }, null, 2));
212
+ }
213
+ catch {
214
+ // Not remembering costs one question next time. Never worth failing a start for.
215
+ }
216
+ }
217
+ /**
218
+ * The first of these that RUNS, not the first on the PATH.
219
+ *
220
+ * The distinction has already cost this codebase a week: the relay's runtime probe picked a
221
+ * binary that existed and could not start, and resolution fell through to a version too old
222
+ * for the feature it was being asked for.
223
+ */
224
+ function firstAvailable(candidates) {
225
+ for (const candidate of candidates) {
226
+ const probe = (0, child_process_1.spawnSync)(candidate, ["--version"], { stdio: "ignore" });
227
+ if (!probe.error && probe.status === 0)
228
+ return candidate;
229
+ }
230
+ return undefined;
231
+ }
232
+ function valueAfter(args, flagName) {
233
+ const index = args.indexOf(flagName);
234
+ return index === -1 ? undefined : args[index + 1];
235
+ }