baychat 0.38.0 → 0.40.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.
@@ -48,6 +48,7 @@ const bay_deleted_1 = require("../bay-deleted");
48
48
  const context_report_1 = require("../context-report");
49
49
  const codex_context_1 = require("./codex-context");
50
50
  const session_commands_1 = require("./session-commands");
51
+ const terminal_stop_1 = require("./terminal-stop");
51
52
  const terminal_pane_1 = require("./terminal-pane");
52
53
  const compact_command_1 = require("./compact-command");
53
54
  const fresh_start_1 = require("./fresh-start");
@@ -651,6 +652,13 @@ class RelayDaemon {
651
652
  // own app-server daemon, for the thread the join corroborated (codex-control.ts).
652
653
  if (await this.runJoinedCodexCommand(session, target, command, compact.focus, say))
653
654
  return;
655
+ // `/stop` for a session in someone's own terminal (the app's Stop): owner only, one interrupt
656
+ // through `interruptPane` (terminal-stop.ts). Relay-run sessions keep their own `/stop` below.
657
+ if (command.id === "stop" && !target.acp && !target.codex) {
658
+ if (target.delivery === "channel" && !this.channelLinked(session))
659
+ return say((0, channel_sessions_1.channelGoneReason)(session));
660
+ return (0, terminal_stop_1.runTerminalStop)(session, target, message, say);
661
+ }
654
662
  // `/mode-*`, `/stop`, and what `/status` `/exit` `/clear` `/new` mean with no terminal.
655
663
  if (await (0, commands_1.runAcpSessionCommand)(this.acp, session, target, message, command, say))
656
664
  return;
@@ -1887,6 +1895,7 @@ class RelayDaemon {
1887
1895
  */
1888
1896
  registerChannel(frame, sock) {
1889
1897
  const previous = this.channels.get(frame.session);
1898
+ const before = this.registry.get(frame.session);
1890
1899
  if (previous && previous !== sock) {
1891
1900
  this.log(`replacing channel for ${frame.session}: dropping the previous one`);
1892
1901
  (0, socket_1.writeFrame)(previous, { type: "error", message: channel_sessions_1.CHANNEL_REPLACED });
@@ -1905,6 +1914,12 @@ class RelayDaemon {
1905
1914
  (0, socket_1.writeFrame)(sock, { type: "attached", session: frame.session, delivery: "channel" });
1906
1915
  const where = frame.pane?.kind === "tmux" ? `tmux pane ${frame.pane.pane}` : (frame.pane?.kind ?? "no multiplexer");
1907
1916
  this.log(`channel registered: ${frame.session} (${frame.runtime}, commands only, ${where})`);
1917
+ // Another Claude Code process took this name (a new terminal, 2026-10-30): its memory starts
1918
+ // empty, so the server must not keep showing the old process's reading and tidy count.
1919
+ // The same process re-registering (a reconnect) keeps its pid and sends nothing.
1920
+ if (before?.ownerPid && frame.ownerPid && before.ownerPid !== frame.ownerPid) {
1921
+ this.reportContext(frame.session, { runtime: "claude-code", event: "session_start", source: "exact" });
1922
+ }
1908
1923
  }
1909
1924
  /**
1910
1925
  * Forget every channel session that is DEAD: its channel link is closed and its Claude Code
@@ -114,19 +114,26 @@ function permissionHook(cli, flags) {
114
114
  ],
115
115
  },
116
116
  ],
117
+ ...afterHooks(cli),
117
118
  };
118
119
  }
119
- /** The tools the after-hook watches: the ones a once-rule can be written for (permission-rule.ts). */
120
- const DENIED_AFTER_MATCHER = "Bash|WebFetch|mcp__.*";
120
+ /**
121
+ * The tools the after-hook watches: every tool that can draw a permission dialog — a card answered at
122
+ * the keyboard is closed from here (card-after.ts) — which covers the once-rule tools too
123
+ * (permission-rule.ts: Bash, WebFetch, MCP).
124
+ */
125
+ const AFTER_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit|WebFetch|WebSearch|mcp__.*";
121
126
  /** The after-hook is async and returns in milliseconds; this bounds only a wedged one. */
122
- const DENIED_AFTER_TIMEOUT_SECONDS = 30;
127
+ const AFTER_TIMEOUT_SECONDS = 30;
123
128
  /**
124
- * The denied-command card's hooks (docs/features/DENIED_COMMAND_CARD.md). `flags` are the same
125
- * session flags approve-hook gets in that file.
129
+ * The async after-hook (`denied-after`): takes an "Allow once" rule back out, and closes a permission
130
+ * card answered in the terminal instead (fix/needs-you-closes-itself — measured on Claude Code
131
+ * 2.1.316, a "Yes" at the keyboard leaves approve-hook parked and its card open). In every file that
132
+ * has approve-hook, whatever the denied-card switch says.
126
133
  */
127
- function deniedCardHooks(cli, flags) {
134
+ function afterHooks(cli) {
128
135
  const after = {
129
- matcher: DENIED_AFTER_MATCHER,
136
+ matcher: AFTER_MATCHER,
130
137
  hooks: [
131
138
  {
132
139
  type: "command",
@@ -134,10 +141,17 @@ function deniedCardHooks(cli, flags) {
134
141
  args: [cli.entry, "denied-after"],
135
142
  // Never in a tool call's way: Claude Code starts it and carries on.
136
143
  async: true,
137
- timeout: DENIED_AFTER_TIMEOUT_SECONDS,
144
+ timeout: AFTER_TIMEOUT_SECONDS,
138
145
  },
139
146
  ],
140
147
  };
148
+ return { PostToolUse: [after], PostToolUseFailure: [after] };
149
+ }
150
+ /**
151
+ * The denied-command card's hooks (docs/features/DENIED_COMMAND_CARD.md). `flags` are the same
152
+ * session flags approve-hook gets in that file.
153
+ */
154
+ function deniedCardHooks(cli, flags) {
141
155
  return {
142
156
  PermissionDenied: [
143
157
  {
@@ -155,8 +169,7 @@ function deniedCardHooks(cli, flags) {
155
169
  ],
156
170
  },
157
171
  ],
158
- PostToolUse: [after],
159
- PostToolUseFailure: [after],
172
+ ...afterHooks(cli),
160
173
  };
161
174
  }
162
175
  /** `settings` with the denied-command card's hooks added beside its own. Pure. */
@@ -133,7 +133,18 @@ async function reportSessionSpots(deps, auth, signal, last, log) {
133
133
  if (key === last)
134
134
  return last;
135
135
  try {
136
- await deps.request(auth, "PUT", "/api/device-api/launch-sessions", { sessions }, signal);
136
+ try {
137
+ await deps.request(auth, "PUT", "/api/device-api/launch-sessions", { sessions }, signal);
138
+ }
139
+ catch (err) {
140
+ // A server older than the `withinSpotId` hint (2026-10-30) refuses the whole report as
141
+ // INVALID_SESSION_SPOTS: send it again without the hints, so "Start again" places still land.
142
+ const hinted = sessions.some((s) => s.withinSpotId !== undefined);
143
+ if (!(hinted && err instanceof api_1.ApiError && err.status === 400))
144
+ throw err;
145
+ const plain = sessions.map(({ withinSpotId: _hint, ...rest }) => rest);
146
+ await deps.request(auth, "PUT", "/api/device-api/launch-sessions", { sessions: plain }, signal);
147
+ }
137
148
  return key;
138
149
  }
139
150
  catch (err) {
@@ -37,6 +37,7 @@ exports.MAX_SESSION_SPOTS = void 0;
37
37
  exports.sessionSpots = sessionSpots;
38
38
  exports.realRealpath = realRealpath;
39
39
  const fs = __importStar(require("fs"));
40
+ const path = __importStar(require("path"));
40
41
  const policy_1 = require("../../launch/policy");
41
42
  const argv_1 = require("./argv");
42
43
  /** The server takes at most this many per report. */
@@ -56,13 +57,27 @@ function sessionSpots(sessions, policy, deps) {
56
57
  continue;
57
58
  const real = deps.realpath(dir);
58
59
  const runtime = session.runtime;
59
- const match = real ? folders.find((f) => f.path === real && f.spot.runtimes.includes(runtime)) : undefined;
60
- out.push({ name: session.name, spotId: match?.spot.id ?? null, runtime, listen: session.delivery === "channel" ? "channel" : "monitor" });
60
+ const offered = folders.filter((f) => f.spot.runtimes.includes(runtime));
61
+ const match = real ? offered.find((f) => f.path === real) : undefined;
62
+ // The deepest spot folder above it: the longest path that is a proper prefix at a separator.
63
+ const within = real && !match ? offered.filter((f) => isInside(real, f.path)).sort((a, b) => b.path.length - a.path.length)[0] : undefined;
64
+ out.push({
65
+ name: session.name,
66
+ spotId: match?.spot.id ?? null,
67
+ ...(within ? { withinSpotId: within.spot.id } : {}),
68
+ runtime,
69
+ listen: session.delivery === "channel" ? "channel" : "monitor",
70
+ });
61
71
  if (out.length === exports.MAX_SESSION_SPOTS)
62
72
  break;
63
73
  }
64
74
  return out.sort((a, b) => a.name.localeCompare(b.name));
65
75
  }
76
+ /** Is `dir` strictly inside `parent` (both realpaths)? `/work/web2` is not inside `/work/web`. */
77
+ function isInside(dir, parent) {
78
+ const prefix = parent.endsWith(path.sep) ? parent : parent + path.sep;
79
+ return dir.startsWith(prefix);
80
+ }
66
81
  function realRealpath(dir) {
67
82
  try {
68
83
  return fs.realpathSync(dir);
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.INTERRUPT_KEYS = void 0;
4
+ exports.isBusy = isBusy;
5
+ exports.interruptPane = interruptPane;
6
+ const child_process_1 = require("child_process");
7
+ const terminal_pane_1 = require("./terminal-pane");
8
+ /**
9
+ * Stop: interrupt the turn a terminal session is working on (docs/design/AGENT_STOP_UNDO.md §2.5).
10
+ *
11
+ * ── THE ONLY KEYSTROKE PATH THAT IS NOT TEXT ─────────────────────────────────────────
12
+ *
13
+ * `typeIntoPane` types TEXT, always with `-l`, so nothing it sends can ever be read as a key name.
14
+ * Stop needs one real key, so it lives here, apart, and is narrower still:
15
+ *
16
+ * 1. the same pane checks as every keystroke (`verifyPane`: this user's socket, the runtime still
17
+ * in the foreground — a shell left behind is never typed into);
18
+ * 2. TWO looks 700 ms apart must both show the runtime's own "working" line — no work, no key;
19
+ * 3. ONE key from a frozen table, by key name, with no `-l` and no Enter;
20
+ * 4. one more look after 1 s: the working line gone is DONE, still there is FAILED. No second key.
21
+ *
22
+ * Escape, not Ctrl-C: in Claude Code and Codex, Esc interrupts the current turn and keeps the
23
+ * session; Ctrl-C twice EXITS Claude Code, so a doubled Ctrl-C would be an End by accident. The wire
24
+ * carries the command name `interrupt`, never a key: the key is decided here and only here.
25
+ */
26
+ exports.INTERRUPT_KEYS = Object.freeze({ claude: "Escape", codex: "Escape" });
27
+ /**
28
+ * The line each runtime shows while a turn runs. A runtime without a row is never busy.
29
+ *
30
+ * Claude Code, captured on this Mac 1 Nov 2026 (native build, a turn running):
31
+ * "· Infusing… (3m 25s · ↓ 12.3k tokens · thought for 1s)"
32
+ * — a spinner glyph, a gerund ending in "…", then "(" and the elapsed time. Older builds added
33
+ * "· esc to interrupt"; the current one does not, so the time is what proves a turn runs. A finished
34
+ * turn reads "✻ Worked for 3m 25s" (no "…", no "("), which must NOT match.
35
+ * Codex: "• Working (8s • esc to interrupt)".
36
+ */
37
+ const BUSY_LINES = Object.freeze({
38
+ claude: /^\s*\S\s+\S+…\s+\((?:\d+h\s+)?(?:\d+m\s+)?\d+s\b|esc to interrupt/im,
39
+ codex: /esc to interrupt/i,
40
+ });
41
+ /** The first look, the second look, and the check after the key, in ms. */
42
+ const SECOND_LOOK_MS = 700;
43
+ const AFTER_KEY_MS = 1000;
44
+ /**
45
+ * How many lines at the bottom of the pane are read. The working line sits right above the input
46
+ * box (captured 1 Nov: 8 lines from the bottom, under a two-line tip). Anything higher up is the
47
+ * conversation — and a chat, a log or a review can QUOTE a working line there while the session is
48
+ * idle (vps, review of fc377a96e), which must never earn an Escape.
49
+ */
50
+ const BOTTOM_LINES = 12;
51
+ /** Does this capture show `runtime` working on a turn right now? Only its bottom counts. */
52
+ function isBusy(runtime, capture) {
53
+ const line = BUSY_LINES[runtime];
54
+ if (!line)
55
+ return false;
56
+ const lines = capture.split("\n");
57
+ // capture-pane pads to the pane's height with empty lines; the bottom is the last line drawn.
58
+ while (lines.length > 0 && lines[lines.length - 1].trim() === "")
59
+ lines.pop();
60
+ return line.test(lines.slice(-BOTTOM_LINES).join("\n"));
61
+ }
62
+ /** Interrupt the session's current turn, or say honestly why not. Never throws. */
63
+ async function interruptPane(handle, runtime, deps = {}) {
64
+ if (handle.kind !== "tmux") {
65
+ return { outcome: "UNREACHABLE", reason: `${handle.kind} can't be stopped from BayChat yet — tmux can.` };
66
+ }
67
+ const key = exports.INTERRUPT_KEYS[runtime];
68
+ if (!key)
69
+ return { outcome: "UNREACHABLE", reason: `${runtime} can't be stopped from BayChat.` };
70
+ const check = (deps.verify ?? terminal_pane_1.verifyPane)(handle, runtime, undefined, deps.ownerPid);
71
+ if (!check.ok)
72
+ return { outcome: "UNREACHABLE", reason: check.reason };
73
+ const capture = deps.capture ?? capturePane;
74
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
75
+ const exec = deps.exec ?? runQuietly;
76
+ try {
77
+ if (!isBusy(runtime, await capture(handle)))
78
+ return { outcome: "NOTHING_RUNNING" };
79
+ await sleep(SECOND_LOOK_MS);
80
+ if (!isBusy(runtime, await capture(handle)))
81
+ return { outcome: "NOTHING_RUNNING" };
82
+ }
83
+ catch (err) {
84
+ return { outcome: "UNREACHABLE", reason: `the pane could not be read: ${errText(err)}` };
85
+ }
86
+ try {
87
+ // A KEY NAME, deliberately without `-l`; and no Enter.
88
+ await exec("tmux", ["-S", handle.socket, "send-keys", "-t", handle.pane, key]);
89
+ }
90
+ catch (err) {
91
+ return { outcome: "FAILED", reason: `tmux refused the key: ${errText(err)}` };
92
+ }
93
+ await sleep(AFTER_KEY_MS);
94
+ try {
95
+ return isBusy(runtime, await capture(handle)) ? { outcome: "FAILED", reason: "it is still working" } : { outcome: "DONE" };
96
+ }
97
+ catch (err) {
98
+ return { outcome: "FAILED", reason: `the pane could not be read after the key: ${errText(err)}` };
99
+ }
100
+ }
101
+ function capturePane(handle) {
102
+ return new Promise((resolve, reject) => {
103
+ (0, child_process_1.execFile)("tmux", ["-S", handle.socket, "capture-pane", "-p", "-t", handle.pane], { timeout: 5_000, encoding: "utf8" }, (err, out) => err ? reject(err) : resolve(String(out)));
104
+ });
105
+ }
106
+ function runQuietly(file, args) {
107
+ return new Promise((resolve, reject) => {
108
+ (0, child_process_1.execFile)(file, args, { timeout: 5_000 }, (err) => (err ? reject(err) : resolve()));
109
+ });
110
+ }
111
+ function errText(err) {
112
+ return err instanceof Error ? err.message : String(err);
113
+ }
@@ -29,7 +29,7 @@ exports.SESSION_COMMANDS = [
29
29
  { id: "mode-read", trigger: "/mode-read", mode: "read", summary: "Relay-run agents: may read files, no commands, no writes. Owner or anyone with command access." },
30
30
  { id: "mode-ask", trigger: "/mode-ask", mode: "ask", summary: "Relay-run agents: may run commands; writes ask on your phone first. Owner or anyone with command access." },
31
31
  { id: "mode-full", trigger: "/mode-full", mode: "full", summary: "Relay-run agents: may write inside its folder without asking. Owner or anyone with command access." },
32
- { id: "stop", trigger: "/stop", summary: "Relay-run agents: stop the turn that is running now." },
32
+ { id: "stop", trigger: "/stop", summary: "Stop the turn that is running now (its owner; a terminal session needs tmux)." },
33
33
  {
34
34
  id: "exit",
35
35
  trigger: "/exit",
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runTerminalStop = runTerminalStop;
4
+ const pane_interrupt_1 = require("./pane-interrupt");
5
+ /**
6
+ * `/stop` for a session running in someone's own terminal (AGENT_STOP_UNDO.md §2) — what the app's
7
+ * Stop button sends, and what the owner may type in a chat.
8
+ *
9
+ * OWNER ONLY. Stop is a keystroke into a terminal on the owner's own computer; a person she granted
10
+ * command access may tidy a session, never stop it (§2.2). `senderIsOwner` is decided by the server
11
+ * at hand-over, so this is the second check after the app's route.
12
+ *
13
+ * One interrupt (`interruptPane`: two looks, one Escape, one look after) and one reply that says what
14
+ * the pane showed. Relay-run sessions (ACP, Codex) keep their own `/stop`; this is only reached for a
15
+ * session in a terminal.
16
+ */
17
+ async function runTerminalStop(session, target, message, say, deps = {}) {
18
+ if (message.senderIsOwner !== true) {
19
+ await say(`Only the owner of "${session}" can stop it.`);
20
+ return;
21
+ }
22
+ if (!target.pane) {
23
+ await say(`"${session}" isn't in a tmux terminal, so BayChat can't stop it. Stop it in its own terminal.`);
24
+ return;
25
+ }
26
+ const interrupt = deps.interrupt ?? ((handle, runtime, opts) => (0, pane_interrupt_1.interruptPane)(handle, runtime, opts));
27
+ const result = await interrupt(target.pane, target.runtime, { ownerPid: target.ownerPid });
28
+ await say(stopReply(session, result));
29
+ }
30
+ function stopReply(session, result) {
31
+ switch (result.outcome) {
32
+ case "DONE":
33
+ return `Stopped "${session}". It is waiting for you; anything it already did stays done.`;
34
+ case "NOTHING_RUNNING":
35
+ return `"${session}" was not working on anything, so nothing was sent.`;
36
+ case "FAILED":
37
+ return `"${session}" did not stop (${result.reason ?? "it is still working"}). Stop it in its own terminal.`;
38
+ default:
39
+ return `"${session}" can't be stopped from BayChat (${result.reason ?? "its terminal can't be reached"}). Stop it in its own terminal.`;
40
+ }
41
+ }
@@ -22,11 +22,13 @@ const project_notes_1 = require("./project-notes");
22
22
  * app offers it, and BayChat starts one itself, only while every polling relay says so.
23
23
  * - `notice`: it shows sessions BayChat's 90% memory notice (context-notice.ts, protocol 1.17),
24
24
  * so it may be handed it — and have it marked sent.
25
+ * - `interrupt`: its `/stop` reaches a session in a tmux terminal too (terminal-stop.ts: owner
26
+ * only, one Escape) — the app offers Stop only while every polling relay says so.
25
27
  *
26
28
  * ONE list for every door: this long-poll's query, the WebSocket `hello` (ws.ts splits this same
27
29
  * string) and so the device feed's feature record.
28
30
  */
29
- exports.RELAY_FEATURES = "brief,compact,freshstart,notes,notice";
31
+ exports.RELAY_FEATURES = "brief,compact,freshstart,notes,notice,interrupt";
30
32
  /** Server clamps `wait` into [1s, 30s]; 25s stays under the usual 60s proxy idle timeout. */
31
33
  const WAIT_SECONDS = 25;
32
34
  /** Backoff bounds for transient failures (5xx, 429, network). */
@@ -0,0 +1,126 @@
1
+ "use strict";
2
+ // `npx baychat` and `baychat setup` — ONE COMMAND (Karmen + Lorenzo, 31 Oct 2026; plan in BAYCHAT, vps-reviewed).
3
+ //
4
+ // Bare `baychat`: the first time (no "set up" mark yet), sign in once and set up every agent app found (setup-plan.ts),
5
+ // print one checklist, then start a session — only inside a project (vps: never in the home folder, where a session
6
+ // could read and write everything). Once set up, bare `baychat` goes straight to the start, as before, without asking
7
+ // every app again. `baychat setup` always looks again (after installing a new app).
8
+ var __importDefault = (this && this.__importDefault) || function (mod) {
9
+ return (mod && mod.__esModule) ? mod : { "default": mod };
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.runSetup = runSetup;
13
+ exports.runBare = runBare;
14
+ exports.runSetupCommand = runSetupCommand;
15
+ exports.setupMarkPath = setupMarkPath;
16
+ exports.realSetupDeps = realSetupDeps;
17
+ const fs_1 = __importDefault(require("fs"));
18
+ const os_1 = __importDefault(require("os"));
19
+ const path_1 = __importDefault(require("path"));
20
+ const config_1 = require("./config");
21
+ const commands_1 = require("./commands");
22
+ const connect_1 = require("./connect");
23
+ const connect_claude_1 = require("./connect-claude");
24
+ const doctor_1 = require("./doctor");
25
+ const doctor_command_1 = require("./doctor-command");
26
+ const machine_name_1 = require("./machine-name");
27
+ const multiplexer_launch_1 = require("./multiplexer-launch");
28
+ const setup_facts_1 = require("./setup-facts");
29
+ const start_command_1 = require("./start-command");
30
+ const setup_plan_1 = require("./setup-plan");
31
+ /** Look, set up what is missing, and say what is ready. Returns 2 when the login was not approved. */
32
+ async function runSetup(deps) {
33
+ const facts = await deps.facts();
34
+ const steps = (0, setup_plan_1.planSetup)(facts);
35
+ const done = new Set();
36
+ const failed = new Set();
37
+ for (const step of steps) {
38
+ if (step.kind === "login") {
39
+ deps.log("Sign this computer in to BayChat (once): scan the code with the BayChat app.");
40
+ if (!(await deps.login()))
41
+ return 2;
42
+ continue;
43
+ }
44
+ deps.log(`Setting up ${step.client}…`);
45
+ if ((await deps.connect(step.client)) === 0)
46
+ done.add(step.client);
47
+ else
48
+ failed.add(step.client);
49
+ }
50
+ deps.log("");
51
+ for (const line of (0, setup_plan_1.setupChecklist)(facts, { done, failed }))
52
+ deps.log(` ${line}`);
53
+ deps.markSetUp();
54
+ return 0;
55
+ }
56
+ /**
57
+ * Bare `baychat`: set up the first time, then start — in a project only. Once set up, a project starts straight away;
58
+ * any other folder (the home folder above all) asks first, default no (vps, 31 Oct: nobody gives an agent all of home
59
+ * by accident).
60
+ */
61
+ async function runBare(deps) {
62
+ if (deps.wasSetUp()) {
63
+ if (deps.inProject())
64
+ return deps.start();
65
+ if (await deps.confirm("This isn't a project folder. Start a session here anyway? (y/N) "))
66
+ return deps.start();
67
+ deps.log(" Not started. cd into a project and run npx baychat again (or start a session from the BayChat app).");
68
+ return 0;
69
+ }
70
+ const code = await runSetup(deps);
71
+ if (code !== 0)
72
+ return code;
73
+ return startOrPoint(deps);
74
+ }
75
+ /** `baychat setup`: always look again; start only when asked to and in a project. */
76
+ async function runSetupCommand(deps) {
77
+ const code = await runSetup(deps);
78
+ if (code !== 0)
79
+ return code;
80
+ deps.log("");
81
+ deps.log(" Next: run npx baychat in a project folder to start a session (or start one from the BayChat app).");
82
+ return 0;
83
+ }
84
+ async function startOrPoint(deps) {
85
+ deps.log("");
86
+ if (deps.inProject())
87
+ return deps.start();
88
+ deps.log(" Next: cd into a project and run npx baychat again (or start a session from the BayChat app).");
89
+ return 0;
90
+ }
91
+ /** Where the "set up" mark lives: beside the credentials, removed with them. */
92
+ function setupMarkPath() {
93
+ return path_1.default.join((0, config_1.configDir)(), "setup-done.json");
94
+ }
95
+ /** The real dependencies. `wsl` only for tests: a test run inside WSL still exercises the Claude Desktop path. */
96
+ function realSetupDeps(over = {}) {
97
+ const exists = (p) => fs_1.default.existsSync(p);
98
+ return {
99
+ facts: async () => {
100
+ const env = await (0, doctor_command_1.currentDoctorEnv)();
101
+ return (0, setup_facts_1.setupFactsFrom)(env, (0, doctor_1.buildReport)(env), { wsl: over.wsl ?? (0, machine_name_1.isWsl)(), exists });
102
+ },
103
+ login: () => (0, commands_1.cmdLogin)({ registerClaude: false, hint: false }),
104
+ connect: (client) => (client === "claude" ? (0, connect_claude_1.cmdConnectClaude)({}) : (0, connect_1.cmdConnect)(client, {})),
105
+ start: () => (0, start_command_1.cmdStart)([]),
106
+ inProject: () => (0, setup_plan_1.isProjectDir)(process.cwd(), exists, os_1.default.homedir()),
107
+ confirm: async (question) => {
108
+ if (!process.stdin.isTTY)
109
+ return false;
110
+ return /^y(es)?$/i.test((await (0, multiplexer_launch_1.askLine)(question)).trim());
111
+ },
112
+ // Signed out since (or the credentials were removed): look again, which signs in.
113
+ wasSetUp: () => exists(setupMarkPath()) && (0, config_1.loadDeviceCredentials)() !== null,
114
+ markSetUp: () => {
115
+ try {
116
+ fs_1.default.mkdirSync(path_1.default.dirname(setupMarkPath()), { recursive: true });
117
+ fs_1.default.writeFileSync(setupMarkPath(), JSON.stringify({ at: new Date().toISOString() }) + "\n");
118
+ }
119
+ catch (err) {
120
+ // Only costs a second look next time.
121
+ console.warn(`Could not record that setup is done: ${err instanceof Error ? err.message : String(err)}`);
122
+ }
123
+ },
124
+ log: (line) => console.log(line),
125
+ };
126
+ }
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ // The facts `planSetup` decides from, read off this computer through what `baychat doctor` already knows: its report
3
+ // says whether BayChat is in each app (it asks `claude mcp list` / `codex mcp list` themselves, and reads every file
4
+ // Claude Desktop or Cursor may open), and its binary resolver says what is installed. Nothing new is guessed here.
5
+ var __importDefault = (this && this.__importDefault) || function (mod) {
6
+ return (mod && mod.__esModule) ? mod : { "default": mod };
7
+ };
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.configuredFrom = configuredFrom;
10
+ exports.installedFrom = installedFrom;
11
+ exports.setupFactsFrom = setupFactsFrom;
12
+ const path_1 = __importDefault(require("path"));
13
+ const client_paths_1 = require("./client-paths");
14
+ const setup_plan_1 = require("./setup-plan");
15
+ /** Is BayChat in this app already? Doctor's MCP check passes for it (an unknown answer counts as no: connect again). */
16
+ function configuredFrom(report) {
17
+ const out = {};
18
+ for (const client of setup_plan_1.SETUP_CLIENTS) {
19
+ const runtime = report.runtimes.find((r) => r.runtime === client);
20
+ out[client] = Boolean(runtime?.checks.some((c) => c.name === "mcp" && c.status === "pass"));
21
+ }
22
+ return out;
23
+ }
24
+ /** Is the app on this computer? A CLI is a resolvable binary; a GUI app is the folder its config lives in. */
25
+ function installedFrom(env, exists) {
26
+ const pathEnv = env.pathEnv ?? { home: env.home, platform: env.platform };
27
+ return {
28
+ claude: env.resolveBinary("claude").ok,
29
+ desktop: (0, client_paths_1.configPathsFor)("desktop", pathEnv).some((file) => exists((0, client_paths_1.configDirOf)(file))),
30
+ codex: env.resolveBinary("codex").ok || exists(path_1.default.join(pathEnv.codexHome ?? path_1.default.join(env.home, ".codex"))),
31
+ cursor: exists(path_1.default.join(env.home, ".cursor")) || env.resolveBinary("cursor-agent").ok,
32
+ };
33
+ }
34
+ /** Every fact `planSetup` needs. */
35
+ function setupFactsFrom(env, report, host) {
36
+ const expires = env.device ? Date.parse(env.device.expiresAt) : NaN;
37
+ return {
38
+ signedIn: env.device !== null && Number.isFinite(expires) && expires > env.now,
39
+ installed: installedFrom(env, host.exists),
40
+ configured: configuredFrom(report),
41
+ wsl: host.wsl,
42
+ hermesFound: env.resolveBinary("hermes").ok || host.exists(path_1.default.join(env.home, ".hermes")),
43
+ };
44
+ }
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ // What `npx baychat` (and `baychat setup`) should DO, decided before anything is done.
3
+ //
4
+ // ONE COMMAND (Karmen + Lorenzo, 31 Oct 2026): people did not know which of `login`, `connect desktop`,
5
+ // `connect codex`… was theirs. Now the one line signs this computer in ONCE (the phone QR) and sets up every agent
6
+ // app it finds that is not set up yet — through the very same `connect` paths, so nothing new is written anywhere.
7
+ // Hermes is named, not set up: it gets an agent of its own, chosen on the phone (a second QR), which is not "one".
8
+ //
9
+ // Facts in, steps out — like `connect-plan.ts` — so the order is testable without a phone, a network or the apps.
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.SETUP_CLIENTS = void 0;
12
+ exports.planSetup = planSetup;
13
+ exports.setupChecklist = setupChecklist;
14
+ exports.isProjectDir = isProjectDir;
15
+ /** The apps one command sets up, in the order it does them. */
16
+ exports.SETUP_CLIENTS = ["claude", "desktop", "codex", "cursor"];
17
+ const LABELS = {
18
+ claude: "Claude Code",
19
+ desktop: "Claude Desktop",
20
+ codex: "Codex",
21
+ cursor: "Cursor",
22
+ };
23
+ /** The steps: the one login when there is none, then every app found and not yet set up. */
24
+ function planSetup(facts) {
25
+ const steps = facts.signedIn ? [] : [{ kind: "login" }];
26
+ for (const client of exports.SETUP_CLIENTS) {
27
+ if (client === "desktop" && facts.wsl)
28
+ continue;
29
+ if (facts.installed[client] && !facts.configured[client])
30
+ steps.push({ kind: "connect", client });
31
+ }
32
+ return steps;
33
+ }
34
+ /** The closing list, one line per app: ready, set up now, not here, or not finished (with what to run). */
35
+ function setupChecklist(facts, outcome) {
36
+ const lines = exports.SETUP_CLIENTS.map((client) => {
37
+ const label = LABELS[client];
38
+ if (client === "desktop" && facts.wsl)
39
+ return `– ${label} (it runs on Windows: run npx baychat once in Windows PowerShell)`;
40
+ if (outcome.failed.has(client))
41
+ return `✗ ${label} (did not finish; run npx baychat connect ${client})`;
42
+ if (outcome.done.has(client))
43
+ return client === "desktop" ? `✓ ${label} (just set up; restart it once)` : `✓ ${label} (just set up)`;
44
+ if (facts.configured[client])
45
+ return `✓ ${label}`;
46
+ return `– ${label} (not on this computer)`;
47
+ });
48
+ if (facts.hermesFound)
49
+ lines.push("• Hermes found: npx baychat connect hermes (it gets its own agent, chosen on your phone)");
50
+ return lines;
51
+ }
52
+ /** Files that make a folder a project worth starting an agent in. */
53
+ const PROJECT_MARKERS = [
54
+ "package.json",
55
+ "pyproject.toml",
56
+ "requirements.txt",
57
+ "Cargo.toml",
58
+ "go.mod",
59
+ "Gemfile",
60
+ "pom.xml",
61
+ "build.gradle",
62
+ "composer.json",
63
+ "deno.json",
64
+ "Package.swift",
65
+ "mix.exs",
66
+ ];
67
+ /**
68
+ * May a session start here? vps, 31 Oct: a first-time user often runs `npx baychat` in the home folder, and a session
69
+ * started there can read and write all of it. So only in a project: a folder with a project file, or inside a git
70
+ * repository (found by walking up, stopping BEFORE the home folder). The home folder itself never counts.
71
+ */
72
+ function isProjectDir(cwd, exists, home) {
73
+ const sep = cwd.includes("\\") && !cwd.includes("/") ? "\\" : "/";
74
+ const trim = (p) => (p.length > 1 && (p.endsWith("/") || p.endsWith("\\")) ? p.slice(0, -1) : p);
75
+ const here = trim(cwd);
76
+ const stop = trim(home);
77
+ if (here === stop)
78
+ return false;
79
+ const join = (dir, name) => `${dir}${sep}${name}`;
80
+ if (PROJECT_MARKERS.some((marker) => exists(join(here, marker))))
81
+ return true;
82
+ for (let dir = here; dir !== stop;) {
83
+ if (exists(join(dir, ".git")))
84
+ return true;
85
+ const cut = dir.lastIndexOf(sep);
86
+ if (cut <= 0 || dir.slice(0, cut) === dir)
87
+ break;
88
+ const parent = dir.slice(0, cut);
89
+ if (parent.length < stop.length)
90
+ break;
91
+ dir = parent;
92
+ }
93
+ return false;
94
+ }
@@ -95,7 +95,7 @@ function startRefusal(runtime) {
95
95
  }
96
96
  async function cmdStart(args) {
97
97
  if (!(0, config_1.loadDeviceCredentials)()) {
98
- console.error("This machine is not signed in yet. Run `npx baychat login` first.");
98
+ console.error("This machine is not signed in yet. Run `npx baychat` first: it signs in and sets up your agent apps.");
99
99
  return 1;
100
100
  }
101
101
  // THE UPDATE NOBODY HAS TO REMEMBER.
package/dist/tool-defs.js CHANGED
@@ -224,8 +224,8 @@ exports.CONVERSATION_TOOL_DEFS = [
224
224
  "acknowledge (react instead). Be concise; @mention a person only when you need them (it " +
225
225
  "reaches them even if muted). Answering a specific message: pass replyToMessageId, which " +
226
226
  "quotes it and marks it ✅ for you (a progress update: done=false). To relay a " +
227
- "calendar event or an email pass `card`, with `content` as a one-line plain-text fallback. " +
228
- "Decisions go through request_approval, not here.",
227
+ "calendar event or an email, or to show a chart or a page, pass `card`, with `content` as a one-line " +
228
+ "plain-text fallback. Decisions go through request_approval, not here.",
229
229
  inputSchema: {
230
230
  conversationId: zod_1.z.string().describe(CONVERSATION_ID),
231
231
  content: zod_1.z.string().describe("Message text."),
@@ -255,13 +255,17 @@ exports.CONVERSATION_TOOL_DEFS = [
255
255
  .describe("Ids of files you already uploaded, attached in order."),
256
256
  card: zod_1.z
257
257
  .object({
258
- type: zod_1.z.enum(["card.calendar", "card.email"]),
258
+ type: zod_1.z.enum(["card.calendar", "card.email", "card.chart", "card.page"]),
259
259
  payload: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()),
260
260
  })
261
261
  .optional()
262
262
  .describe("Render as a card. card.calendar: title, start, end (ISO 8601), location, attendees " +
263
263
  "[{name,address}], notes. card.email: subject, from {name,address} (display only), " +
264
- "snippet, body, timestamp. A card OR attachments, never both."),
264
+ "snippet, body, timestamp. card.chart: title, kind bar|line, labels[2-31], " +
265
+ "series[1-3]{name?, values[] (one per label), highlight?[] (indexes)}, unit?, note?, " +
266
+ "key? (same key again = its next version, v2). Data only: BayChat draws it. " +
267
+ "card.page: title, body (markdown, ≤20,000 chars; no HTML, images show as links), key?. " +
268
+ "A card OR attachments, never both."),
265
269
  },
266
270
  _meta: exports.ALWAYS_LOAD_META,
267
271
  },