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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baychat",
3
- "version": "0.38.0",
3
+ "version": "0.40.0",
4
4
  "displayName": "BayChat",
5
5
  "description": "Wakes this Claude Code session the moment a BayChat message arrives: no Monitor, no 30-minute deadline. Uses the baychat CLI already on this computer (npm install -g baychat, then baychat login).",
6
6
  "author": {
package/README.md CHANGED
@@ -6,15 +6,20 @@
6
6
 
7
7
  **The BayChat connector.** Bring Claude Code, Codex, Cursor or an agent you built yourself into one
8
8
  group chat with you and your team. Your agents work together, ask before they act, and wait for your
9
- answer, from your phone if you're out. [baychat.io](https://baychat.io) · free with three agents.
9
+ answer, from your phone if you're out. [baychat.io](https://baychat.io) · free with one agent.
10
10
 
11
- ## Connect an agent in one line
11
+ ## One command
12
12
 
13
13
  ```bash
14
- npx baychat connect claude # Claude Code (also: codex · cursor · desktop)
14
+ npx baychat
15
15
  ```
16
16
 
17
- The first time, it shows a QR: approve it in the BayChat app and this computer is yours. Then, inside
17
+ The first time, it shows a QR: approve it in the BayChat app and this computer is yours. Then it sets up
18
+ every agent app it finds on this computer (Claude Code, Claude Desktop, Codex, Cursor), lists what is
19
+ ready, and starts a session when you run it inside a project folder. After that, `npx baychat` just starts
20
+ a session. Installed another app later? `npx baychat setup` looks again.
21
+
22
+ One app at a time still works: `npx baychat connect claude` (also `codex` · `cursor` · `desktop`). Inside
18
23
  Claude Code, join a room with `/baychat Scout "Launch week"`.
19
24
 
20
25
  **Your own agent**, anything that can run a shell command: in the app, *Add agent → Any agent via the CLI*
@@ -53,7 +53,11 @@ async function askOwnerAndWait(device, ask, deadlineMs, deps, label) {
53
53
  options: ask.options,
54
54
  ...(ask.guardedOptions?.length ? { guardedOptions: ask.guardedOptions } : {}),
55
55
  direct: true,
56
- wait: holdSeconds(deadlineMs - deps.now()),
56
+ wait: deps.onCard ? 1 : holdSeconds(deadlineMs - deps.now()),
57
+ // When THIS process stops waiting (AGENT_STOP_UNDO.md T14): a Bay's undo window holds an
58
+ // answer only if it can still be delivered before then — otherwise the server sends it at
59
+ // once, so a short `--timeout` never turns a phone's "yes" into a deny. Older servers ignore it.
60
+ waitUntil: new Date(deadlineMs).toISOString(),
57
61
  });
58
62
  }
59
63
  catch (err) {
@@ -62,6 +66,12 @@ async function askOwnerAndWait(device, ask, deadlineMs, deps, label) {
62
66
  const requestId = typeof reply.requestId === "string" ? reply.requestId : "";
63
67
  if (!requestId)
64
68
  return { kind: "failed", reason: "BayChat did not return an approval id" };
69
+ try {
70
+ deps.onCard?.(requestId);
71
+ }
72
+ catch (err) {
73
+ deps.log(`${label}: could not note the card (${reasonOf(err)})`);
74
+ }
65
75
  // Wait. Re-arm across the bounded holds exactly as `await_approval` does. Marked while it
66
76
  // waits, so the relay's automatic update never restarts BayChat under an open question.
67
77
  const unmark = (0, waiting_cards_1.markWaitingCard)(requestId);
@@ -133,9 +143,11 @@ async function waitOnOwnerQuestion(device, requestId, deadlineMs, deps, label) {
133
143
  * effort and NEVER THROWS: a card left pending is harmless — the answer would reach nobody — so
134
144
  * a failure here is only logged.
135
145
  */
136
- async function withdrawOwnerQuestion(device, requestId, log) {
146
+ async function withdrawOwnerQuestion(device, requestId, log,
147
+ /** "answered_elsewhere": it was answered in the session's own terminal — the card says so. */
148
+ reason) {
137
149
  try {
138
- await (0, api_1.apiRequest)(device, "POST", `/api/device-api/approvals/${encodeURIComponent(requestId)}/withdraw`, {});
150
+ await (0, api_1.apiRequest)(device, "POST", `/api/device-api/approvals/${encodeURIComponent(requestId)}/withdraw`, reason ? { reason } : {});
139
151
  }
140
152
  catch (err) {
141
153
  log(`could not withdraw the card ${requestId} (${reasonOf(err)})`);
@@ -38,6 +38,8 @@ exports.allowDecision = allowDecision;
38
38
  exports.denyDecision = denyDecision;
39
39
  exports.describeToolRequest = describeToolRequest;
40
40
  exports.composeQuestion = composeQuestion;
41
+ exports.answeredInTerminal = answeredInTerminal;
42
+ exports.setHeldCardForTest = setHeldCardForTest;
41
43
  exports.decideApproval = decideApproval;
42
44
  exports.sessionFromClaude = sessionFromClaude;
43
45
  exports.linkFromClaude = linkFromClaude;
@@ -62,8 +64,9 @@ exports.cmdApproveHook = cmdApproveHook;
62
64
  // THE ONE RULE: EVERY PATH OUT OF HERE PRINTS A DECISION.
63
65
  // ─────────────────────────────────────────────────────────────────────────────────────────────
64
66
  //
65
- // Claude Code hooks FAIL OPEN. Verified against the current hooks reference, all of these mean
66
- // "the tool call proceeds through the normal permission flow":
67
+ // A broken hook decides NOTHING. All of these render no decision, and Claude Code then shows its
68
+ // own terminal permission prompt — the "normal permission flow" (a timeout and an exit 1 with
69
+ // non-JSON output were tested 30 Oct with Claude Code 2.1.314: the prompt came up, nothing ran):
67
70
  //
68
71
  // • the hook times out • it exits 1 (or any code but 2)
69
72
  // • stdout is empty, or is not JSON • stdout is JSON that fails schema validation
@@ -76,12 +79,14 @@ exports.cmdApproveHook = cmdApproveHook;
76
79
  // So the fail-closed guarantee is entirely this file's. Every abnormal path — no credential, DNS
77
80
  // failure, connection refused, 500, 401, unparseable body, ambiguous session, our own deadline,
78
81
  // an exception nobody predicted — is converted into an explicit DENY object and exit 0. There is
79
- // no `process.exit(1)` anywhere below, because exit 1 ALLOWS.
82
+ // no `process.exit(1)` anywhere below: exit 1 decides nothing and leaves the terminal prompt, which
83
+ // nobody at a phone can answer.
80
84
  //
81
85
  // Two consequences worth stating plainly:
82
86
  //
83
- // 1. OUR WAIT MUST END BEFORE THE HANDLER'S `timeout`. A hook that times out allows, so a
84
- // deadline we control (which denies) must always be reached first. Hence
87
+ // 1. OUR WAIT MUST END BEFORE THE HANDLER'S `timeout`. A hook that times out renders no
88
+ // decision (the terminal prompt comes back; tested 30 Oct with Claude Code 2.1.314), so a deadline we control,
89
+ // which denies explicitly and says why, must always be reached first. Hence
85
90
  // DEFAULT_WAIT_SECONDS < RECOMMENDED_HANDLER_TIMEOUT_SECONDS, and the docs say to raise
86
91
  // both together or neither.
87
92
  // 2. STDOUT IS THE DECISION CHANNEL. Nothing else may ever be written to it — "Stdout must
@@ -98,11 +103,13 @@ exports.cmdApproveHook = cmdApproveHook;
98
103
  //
99
104
  // The card itself never expires: the ApprovalRequest row stays PENDING for as long as it takes,
100
105
  // exactly like a terminal prompt. Our deadline bounds only how long THIS PROCESS waits, and it
101
- // exists solely because the alternative (being killed by the handler timeout) would allow.
106
+ // exists because the alternative (being killed by the handler timeout) decides nothing: the
107
+ // terminal prompt reappears with no word to the phone about why.
102
108
  const child_process_1 = require("child_process");
103
109
  const fs = __importStar(require("fs"));
104
110
  const os = __importStar(require("os"));
105
111
  const approval_wait_1 = require("./approval-wait");
112
+ const card_key_1 = require("./card-key");
106
113
  const config_1 = require("./config");
107
114
  const hook_stdin_1 = require("./hook-stdin");
108
115
  const claude_link_1 = require("./channel/claude-link");
@@ -151,8 +158,8 @@ function denyClosed(reason) {
151
158
  /**
152
159
  * How long this process waits before denying, in seconds.
153
160
  *
154
- * An hour, not forever: the handler's own `timeout` would otherwise kill us first, and a
155
- * killed hook ALLOWS. This number and the `timeout` in settings.json are one setting in two
161
+ * An hour, not forever: the handler's own `timeout` would otherwise kill us first, and a killed
162
+ * hook decides nothing (the terminal prompt comes back; tested 30 Oct with Claude Code 2.1.314). This number and the `timeout` in settings.json are one setting in two
156
163
  * places — see the doc.
157
164
  */
158
165
  exports.DEFAULT_WAIT_SECONDS = 3600;
@@ -271,6 +278,45 @@ exports.APPROVE_OPTIONS = ["Allow this once", "Deny"];
271
278
  /** The ONLY index that means yes. Anything else — a second option, a third, an index the server
272
279
  * invented — is a deny, so a mismatch between these labels and the answer can only fail safe. */
273
280
  const ALLOW_INDEX = 0;
281
+ /**
282
+ * The card this process is waiting on right now, for `answeredInTerminal`: the credential, and the
283
+ * card's id once BayChat has made it. One hook run per process, so one card.
284
+ */
285
+ let heldCard = null;
286
+ /** How long a terminal answer waits for a card id still on its way, and for the withdrawal itself. */
287
+ const SIGNAL_GRACE_MS = 2_000;
288
+ /**
289
+ * Claude Code ended this hook mid-wait — measured on 2.1.316: a "No" at the terminal sends SIGTERM
290
+ * (a "Yes" leaves the hook parked; the after-hook closes that card). Close the card "answered at the
291
+ * computer" so it leaves Needs you, then the deny: EVERY PATH PRINTS A DECISION, even one Claude Code
292
+ * no longer reads. Bounded: a slow BayChat only leaves the card for "Mark done". NEVER THROWS.
293
+ */
294
+ async function answeredInTerminal(deps = {}) {
295
+ try {
296
+ const held = heldCard;
297
+ if (held) {
298
+ const grace = deps.graceMs ?? SIGNAL_GRACE_MS;
299
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
300
+ // A card made in the hook's first second may not have its id yet.
301
+ for (let waited = 0; !held.cardId && waited < grace; waited += 100)
302
+ await sleep(100);
303
+ if (held.cardId) {
304
+ await Promise.race([
305
+ (deps.withdraw ?? approval_wait_1.withdrawOwnerQuestion)(held.device, held.cardId, held.log, "answered_elsewhere"),
306
+ sleep(grace),
307
+ ]);
308
+ }
309
+ }
310
+ }
311
+ catch {
312
+ // The deny below is the guarantee; the withdrawal is a courtesy to the phone.
313
+ }
314
+ return deny("it was answered in the terminal.");
315
+ }
316
+ /** Test seam: what `answeredInTerminal` would close. */
317
+ function setHeldCardForTest(card) {
318
+ heldCard = card;
319
+ }
274
320
  function parseArgs(argv) {
275
321
  const at = (name) => {
276
322
  const i = argv.indexOf(name);
@@ -328,13 +374,32 @@ async function decideApproval(rawStdin, argv = [], deps = {}) {
328
374
  // not "answered with index 0" becomes a deny below.
329
375
  // While the card is on the phone, notify-hook stays quiet for this session: one prompt, one
330
376
  // thing on the phone (notify-state.ts).
377
+ // The mark also names the call, and the card once it exists: answered at the keyboard instead,
378
+ // the after-hook closes it (card-after.ts), and a SIGTERM closes it here (`answeredInTerminal`).
331
379
  const claudeSession = str(input.session_id);
332
- (0, notify_state_1.markCardPending)(claudeSession);
380
+ const key = (0, card_key_1.cardKeyOf)(toolName, input.tool_input);
381
+ (0, notify_state_1.markCardPending)(claudeSession, {}, key ? { key } : undefined);
382
+ heldCard = { device, cardId: undefined, log };
333
383
  let outcome;
334
384
  try {
335
- outcome = await (0, approval_wait_1.askOwnerAndWait)(device, { session: opts.session, question, options: [...exports.APPROVE_OPTIONS] }, deadline, { now, sleep, log }, "baychat approve-hook");
385
+ outcome = await (0, approval_wait_1.askOwnerAndWait)(device, { session: opts.session, question, options: [...exports.APPROVE_OPTIONS] }, deadline, {
386
+ now,
387
+ sleep,
388
+ log,
389
+ onCard: (cardId) => {
390
+ if (heldCard)
391
+ heldCard.cardId = cardId;
392
+ (0, notify_state_1.markCardPending)(claudeSession, {}, { cardId, key });
393
+ // Already answered at the keyboard, before the card had an id: close it now. The wait
394
+ // then sees "withdrawn" and this prints its deny.
395
+ if (key && (0, notify_state_1.takeSettled)(claudeSession) === key) {
396
+ void (deps.withdraw ?? approval_wait_1.withdrawOwnerQuestion)(device, cardId, log, "answered_elsewhere");
397
+ }
398
+ },
399
+ }, "baychat approve-hook");
336
400
  }
337
401
  finally {
402
+ heldCard = null;
338
403
  (0, notify_state_1.clearCardPending)(claudeSession);
339
404
  }
340
405
  return verdictOf(outcome, opts.waitSeconds, isChained(toolName, input.tool_input));
@@ -366,7 +431,7 @@ function verdictOf(outcome, waitSeconds, chained = false) {
366
431
  return deny("the decision was withdrawn in BayChat without an answer.");
367
432
  case "deadline":
368
433
  return deny(`nobody answered within ${waitSeconds}s.`, "The decision card is still in BayChat; this call was refused because waiting longer " +
369
- "would have let the hook time out, and a timed-out hook silently ALLOWS.");
434
+ "would have let the hook time out, which hands the decision back to the terminal prompt.");
370
435
  case "failed":
371
436
  return denyClosed(outcome.reason);
372
437
  case "unrecognised":
@@ -508,6 +573,20 @@ function noDecisionNotice(deps = {}) {
508
573
  * call PROCEEDS — so exiting 1 on failure would be precisely backwards.
509
574
  */
510
575
  async function cmdApproveHook(argv = [], stdin = process.stdin, deps = {}) {
576
+ // Ended mid-wait (a "No" at the terminal sends SIGTERM): close the card, then print the deny — once.
577
+ let printed = false;
578
+ const onSignal = () => {
579
+ if (printed)
580
+ return;
581
+ printed = true;
582
+ void answeredInTerminal().then((denied) => {
583
+ process.stdout.write(`${JSON.stringify(denied)}\n`);
584
+ process.exit(0);
585
+ });
586
+ };
587
+ const signals = ["SIGTERM", "SIGINT", "SIGHUP"];
588
+ for (const sig of signals)
589
+ process.on(sig, onSignal);
511
590
  let decision;
512
591
  try {
513
592
  decision = await decideApprovalOrDefer(await (0, hook_stdin_1.readStdin)(stdin), argv);
@@ -518,6 +597,12 @@ async function cmdApproveHook(argv = [], stdin = process.stdin, deps = {}) {
518
597
  process.stderr.write(`baychat approve-hook: ${err instanceof Error ? err.message : String(err)}\n`);
519
598
  decision = denyClosed("the approval gate could not run");
520
599
  }
600
+ for (const sig of signals)
601
+ process.off(sig, onSignal);
602
+ // A signal already printed its deny; one decision on stdout, never two.
603
+ if (printed)
604
+ return 0;
605
+ printed = true;
521
606
  // No decision: `--session-from-claude` with no session found — Claude Code's own dialog, plus a
522
607
  // line saying why when this is a channel session that lost its link.
523
608
  if (decision === null) {
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.settleAnsweredCard = settleAnsweredCard;
4
+ // The after-hook's half of "Needs you closes itself" (fix/needs-you-closes-itself, 2 Nov 2026).
5
+ //
6
+ // Measured on Claude Code 2.1.316: while approve-hook waits on its card, the terminal dialog is open
7
+ // too. Answered "Yes" at the keyboard, the tool runs and PostToolUse fires — and approve-hook is NOT
8
+ // stopped, so its card would stay in Needs you for up to an hour. Here the finished call is compared
9
+ // with the session's pending card (notify-state.ts, written by approve-hook): the same call closes the
10
+ // card "answered at the computer"; approve-hook then sees "withdrawn", prints its deny and leaves.
11
+ //
12
+ // It runs on every matching tool call (inside the async `denied-after` hook), so it reads ONE local
13
+ // file first and touches the network only when that file names this very call. Loads no CLI.
14
+ const approval_wait_1 = require("./approval-wait");
15
+ const card_key_1 = require("./card-key");
16
+ const config_1 = require("./config");
17
+ const notify_state_1 = require("./notify-state");
18
+ /** Close the session's card if THIS finished call is the one it asked about. NEVER THROWS. */
19
+ async function settleAnsweredCard(input, deps = {}) {
20
+ const log = deps.log ?? ((line) => process.stderr.write(`${line}\n`));
21
+ try {
22
+ const call = (input && typeof input === "object" ? input : {});
23
+ const session = typeof call.session_id === "string" ? call.session_id : "";
24
+ if (!session)
25
+ return "none";
26
+ const pending = (0, notify_state_1.readPendingCard)(session, { dir: deps.dir });
27
+ if (!pending)
28
+ return "none";
29
+ const key = (0, card_key_1.cardKeyOf)(typeof call.tool_name === "string" ? call.tool_name : "", call.tool_input);
30
+ if (!key || pending.key !== key)
31
+ return "other-call";
32
+ if (!pending.cardId) {
33
+ // Answered at the keyboard before BayChat had even made the card: approve-hook closes it.
34
+ (0, notify_state_1.markSettled)(session, key, { dir: deps.dir });
35
+ return "settled-early";
36
+ }
37
+ const device = (deps.loadDevice ?? config_1.loadDeviceCredentials)();
38
+ if (!device)
39
+ return "none";
40
+ await (deps.withdraw ?? ((d, id) => (0, approval_wait_1.withdrawOwnerQuestion)(d, id, log, "answered_elsewhere")))(device, pending.cardId);
41
+ return "withdrawn";
42
+ }
43
+ catch (err) {
44
+ log(`baychat after-hook: could not close an answered card (${err instanceof Error ? err.message : String(err)})`);
45
+ return "none";
46
+ }
47
+ }
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ // Which call a permission card asks about, as a key both of Claude Code's events can produce
3
+ // (fix/needs-you-closes-itself). The PermissionRequest hook gets no `tool_use_id` — only the tool and
4
+ // its input — and PostToolUse gets the same two, so the key is built from those alone. Pure.
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.cardKeyOf = cardKeyOf;
7
+ /** `Bash:<command>`; any other tool `<name>:<its input, keys sorted>`; undefined when it cannot be named. */
8
+ function cardKeyOf(toolName, toolInput) {
9
+ if (!toolName)
10
+ return undefined;
11
+ const input = toolInput && typeof toolInput === "object" && !Array.isArray(toolInput) ? toolInput : {};
12
+ if (toolName === "Bash") {
13
+ // Only the command: Claude Code may add a description or a timeout on one side and not the other.
14
+ const command = typeof input.command === "string" ? input.command.trim() : "";
15
+ return command ? `Bash:${command}` : undefined;
16
+ }
17
+ return `${toolName}:${stable(input)}`;
18
+ }
19
+ function stable(value) {
20
+ if (Array.isArray(value))
21
+ return `[${value.map(stable).join(",")}]`;
22
+ if (value && typeof value === "object") {
23
+ const entries = Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
24
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stable(v)}`).join(",")}}`;
25
+ }
26
+ return JSON.stringify(value) ?? "null";
27
+ }
package/dist/cli-help.js CHANGED
@@ -19,10 +19,15 @@ Exit 0: step succeeded; 2: awaiting approval or expired; 1: error.
19
19
  exports.HELP = `baychat — BayChat connector CLI for agent sessions (Claude Code, Codex)
20
20
 
21
21
  Usage:
22
- baychat Start a session. This is the whole thing: it runs
23
- your agent somewhere BayChat can reach, so /clear
24
- and /new from the app work. Sign in once with
25
- \`baychat login\` first
22
+ baychat Start a session: the one command. The first time it
23
+ signs this computer in (one QR on your phone) and sets
24
+ up every agent app it finds — Claude Code, Claude
25
+ Desktop, Codex, Cursor — then starts a session if you
26
+ are in a project folder. After that it just starts a
27
+ session, somewhere BayChat can reach, so /clear and
28
+ /new from the app work
29
+ baychat setup Look again and set up any agent app installed since;
30
+ starts nothing
26
31
  baychat start [name] [--runtime <runtime>]
27
32
  Start a session (what bare \`baychat\` does), with
28
33
  a name for it
@@ -3,7 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.afterCall = afterCall;
4
4
  exports.cmdDeniedAfter = cmdDeniedAfter;
5
5
  // `baychat denied-after` — the PostToolUse / PostToolUseFailure hook that takes an "Allow once"
6
- // rule back out as soon as the retried call has run.
6
+ // rule back out as soon as the retried call has run, and closes a permission card whose call was
7
+ // answered at the keyboard instead (card-after.ts, fix/needs-you-closes-itself).
7
8
  //
8
9
  // It is installed beside `denied-hook` (relay/launcher/approval-settings.ts) as an ASYNC hook:
9
10
  // Claude Code starts it and moves on, so it adds nothing to any tool call's latency, and it prints
@@ -14,6 +15,7 @@ exports.cmdDeniedAfter = cmdDeniedAfter;
14
15
  // and every run here sweeps too — including a grant whose deferred confirmation never finished,
15
16
  // which is taken back and the owner told (confirm.ts; it loads only the card client and config,
16
17
  // no MCP SDK and none of the CLI's commands).
18
+ const card_after_1 = require("../card-after");
17
19
  const hook_stdin_1 = require("../hook-stdin");
18
20
  const confirm_1 = require("./confirm");
19
21
  const grants_1 = require("./grants");
@@ -21,9 +23,12 @@ const permission_rule_1 = require("./permission-rule");
21
23
  /** Handle one finished call. NEVER THROWS; diagnostics go to stderr. */
22
24
  async function afterCall(rawStdin, log = defaultLog) {
23
25
  try {
26
+ const input = JSON.parse(rawStdin);
27
+ // A permission card answered at the keyboard instead: close it (card-after.ts — one local file
28
+ // read unless this very call has a card on the phone).
29
+ await (0, card_after_1.settleAnsweredCard)(input, { log });
24
30
  if (!(0, grants_1.hasGrantsToSweep)())
25
31
  return;
26
- const input = JSON.parse(rawStdin);
27
32
  const call = typeof input.tool_name === "string" ? (0, permission_rule_1.callKeyOf)(input.tool_name, input.tool_input) : undefined;
28
33
  if (call && typeof input.session_id === "string")
29
34
  await (0, grants_1.consumeOnceGrants)(input.session_id, call);
package/dist/main.js CHANGED
@@ -11,6 +11,7 @@ const notify_hook_1 = require("./notify-hook");
11
11
  const notify_answer_1 = require("./notify-answer");
12
12
  const session_name_1 = require("./session-name");
13
13
  const start_command_1 = require("./start-command");
14
+ const setup_command_1 = require("./setup-command");
14
15
  const update_command_1 = require("./update-command");
15
16
  const notice_1 = require("./auto-update/notice");
16
17
  const session_command_1 = require("./session-command");
@@ -58,13 +59,14 @@ function numberFlag(args, name) {
58
59
  }
59
60
  async function main() {
60
61
  const [command, ...args] = process.argv.slice(2);
61
- // BARE `baychat` STARTS A SESSION. The bar is "download BayChat, start a session, nothing
62
- // else", and a usage dump printed at somebody who typed the product's own name is the
63
- // "something else". `--help` still prints the help, and `start` refuses with one sentence
64
- // when this machine is not signed in, so nothing is launched from an unusable state.
62
+ // BARE `baychat` IS THE ONE COMMAND (31 Oct 2026, setup-command.ts). The first time it signs this
63
+ // computer in once and sets up every agent app it finds, then starts a session — only inside a
64
+ // project, never in the home folder. Once set up it starts straight away, as it always did. A usage
65
+ // dump printed at somebody who typed the product's own name would be "something else"; `--help`
66
+ // still prints the help.
65
67
  if (command === undefined) {
66
68
  (0, notice_1.printUpdateNotice)(command);
67
- return (0, start_command_1.cmdStart)([]);
69
+ return (0, setup_command_1.runBare)((0, setup_command_1.realSetupDeps)());
68
70
  }
69
71
  // "BayChat updated itself to …": the relay's one line for the terminal, shown once, on a command
70
72
  // a person typed (never into a hook's or an MCP server's output).
@@ -85,6 +87,10 @@ async function main() {
85
87
  return (0, session_command_1.cmdJoinSession)(args);
86
88
  case "start":
87
89
  return (0, start_command_1.cmdStart)(args);
90
+ case "setup":
91
+ // Look again and set up what is new (an app installed since); starts nothing.
92
+ (0, args_1.rejectUnknownFlags)(args, [], "baychat setup");
93
+ return (0, setup_command_1.runSetupCommand)((0, setup_command_1.realSetupDeps)());
88
94
  case "update":
89
95
  // `--quiet` is how an old version hands a `start`-driven update to the new one.
90
96
  (0, args_1.rejectUnknownFlags)(args, ["--quiet", "--auto"], "baychat update [--auto on|off]");
@@ -36,7 +36,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.notifyStateDir = void 0;
37
37
  exports.stateKey = stateKey;
38
38
  exports.markCardPending = markCardPending;
39
+ exports.readPendingCard = readPendingCard;
39
40
  exports.clearCardPending = clearCardPending;
41
+ exports.markSettled = markSettled;
42
+ exports.takeSettled = takeSettled;
40
43
  exports.cardPendingSince = cardPendingSince;
41
44
  exports.readLastNotice = readLastNotice;
42
45
  exports.writeLastNotice = writeLastNotice;
@@ -74,18 +77,48 @@ function file(sessionId, ext, dir) {
74
77
  return undefined;
75
78
  }
76
79
  }
77
- function markCardPending(sessionId, opts = {}) {
80
+ function markCardPending(sessionId, opts = {}, card) {
78
81
  const f = file(sessionId, "card", opts.dir);
79
82
  if (!f)
80
83
  return;
81
84
  try {
82
85
  fs.mkdirSync(path.dirname(f), { recursive: true, mode: 0o700 });
83
- fs.writeFileSync(f, String((opts.now ?? Date.now)()), { mode: 0o600 });
86
+ const at = (opts.now ?? Date.now)();
87
+ const body = card ? JSON.stringify({ at, ...(card.cardId ? { cardId: card.cardId } : {}), ...(card.key ? { key: card.key } : {}) }) : String(at);
88
+ fs.writeFileSync(f, body, { mode: 0o600 });
84
89
  }
85
90
  catch {
86
91
  // Bookkeeping only.
87
92
  }
88
93
  }
94
+ /** The session's pending card, or undefined when there is none (or the mark is unreadable). */
95
+ function readPendingCard(sessionId, opts = {}) {
96
+ const f = file(sessionId, "card", opts.dir);
97
+ if (!f)
98
+ return undefined;
99
+ let raw;
100
+ try {
101
+ raw = fs.readFileSync(f, "utf8").trim();
102
+ }
103
+ catch {
104
+ return undefined;
105
+ }
106
+ if (/^\d+$/.test(raw))
107
+ return { at: Number(raw) };
108
+ try {
109
+ const v = JSON.parse(raw);
110
+ if (typeof v.at !== "number" || !Number.isFinite(v.at))
111
+ return undefined;
112
+ return {
113
+ at: v.at,
114
+ ...(typeof v.cardId === "string" && v.cardId ? { cardId: v.cardId } : {}),
115
+ ...(typeof v.key === "string" && v.key ? { key: v.key } : {}),
116
+ };
117
+ }
118
+ catch {
119
+ return undefined;
120
+ }
121
+ }
89
122
  function clearCardPending(sessionId, opts = {}) {
90
123
  const f = file(sessionId, "card", opts.dir);
91
124
  if (!f)
@@ -97,19 +130,41 @@ function clearCardPending(sessionId, opts = {}) {
97
130
  // Bookkeeping only.
98
131
  }
99
132
  }
100
- /** When the pending card was marked, or undefined when there is none. */
101
- function cardPendingSince(sessionId, opts = {}) {
102
- const f = file(sessionId, "card", opts.dir);
133
+ /**
134
+ * `<session_id>.settled` — the after-hook saw the call run while the card had no id yet (answered at
135
+ * the keyboard within the hook's first second). approve-hook takes it once it has the id and closes
136
+ * the card itself. Holds the call's key; read once and removed.
137
+ */
138
+ function markSettled(sessionId, key, opts = {}) {
139
+ const f = file(sessionId, "settled", opts.dir);
140
+ if (!f)
141
+ return;
142
+ try {
143
+ fs.mkdirSync(path.dirname(f), { recursive: true, mode: 0o700 });
144
+ fs.writeFileSync(f, key, { mode: 0o600 });
145
+ }
146
+ catch {
147
+ // Bookkeeping only: at worst the card waits for "Mark done".
148
+ }
149
+ }
150
+ /** The settled call's key, removed as it is read; undefined when there is none. */
151
+ function takeSettled(sessionId, opts = {}) {
152
+ const f = file(sessionId, "settled", opts.dir);
103
153
  if (!f)
104
154
  return undefined;
105
155
  try {
106
- const at = Number(fs.readFileSync(f, "utf8").trim());
107
- return Number.isFinite(at) ? at : undefined;
156
+ const key = fs.readFileSync(f, "utf8");
157
+ fs.rmSync(f, { force: true });
158
+ return key || undefined;
108
159
  }
109
160
  catch {
110
161
  return undefined;
111
162
  }
112
163
  }
164
+ /** When the pending card was marked, or undefined when there is none. */
165
+ function cardPendingSince(sessionId, opts = {}) {
166
+ return readPendingCard(sessionId, opts)?.at;
167
+ }
113
168
  function readLastNotice(sessionId, opts = {}) {
114
169
  const f = file(sessionId, "json", opts.dir);
115
170
  if (!f)