baychat 0.39.0 → 0.41.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.39.0",
3
+ "version": "0.41.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
@@ -918,6 +918,10 @@ instruction):
918
918
  | `get_note` | Read one note in full (and the version to edit from) |
919
919
  | `create_note` | Write a note for the project or the chat; it lands on the shelf (people pin) |
920
920
  | `update_note` | Edit a note from the version you read; if someone saved since, nothing is overwritten and you get theirs to merge |
921
+ | `list_board_cards` | The to-dos on the boards of the chat's project, by column, with the board's table fields and each to-do's values; `mine` marks the ones you may move or edit |
922
+ | `add_board_card` | Add a to-do: yours by default (claim the work you are doing); one for a person or nobody is only a suggestion until a person accepts it |
923
+ | `move_board_card` | Move one of your to-dos to another column (Doing, Done, Blocked) |
924
+ | `update_board_card` | Edit the title, notes, dates or table values of one of your to-dos (money in integer cents) |
921
925
  | `get_conversation_summary` | The rolling catch-up summary (decisions, tasks, questions, facts + source ids) |
922
926
  | `get_messages` | Recent messages enriched with sender, mentions, and `shouldRespond` |
923
927
  | `send_message` | Send a message into a conversation |
@@ -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
+ }
@@ -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);
@@ -176,7 +176,9 @@ function trustProblemText(reason, file) {
176
176
  }
177
177
  /**
178
178
  * Set `projects[folder].hasTrustDialogAccepted = true` and nothing else. `folder` should be the
179
- * realpath (the spot's stored path). Called ONLY from `baychat launch add` after a person said y.
179
+ * realpath (the spot's stored path). Called from `baychat launch add` after a person said y, and from
180
+ * the relay for the owner's "Allow… in this folder" on their phone — only for a folder that person
181
+ * opened to it (`phoneMayAllow`, relay/launcher/spot-request.ts).
180
182
  */
181
183
  function trustFolder(folder, file = claudeConfigPath()) {
182
184
  // Write through a symlinked ~/.claude.json (dotfile managers) instead of replacing the link.
@@ -0,0 +1,177 @@
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.codexConfigPath = void 0;
37
+ exports.isCodexFolderTrusted = isCodexFolderTrusted;
38
+ exports.codexTrustProblemText = codexTrustProblemText;
39
+ exports.trustCodexFolder = trustCodexFolder;
40
+ const fs = __importStar(require("fs"));
41
+ const path = __importStar(require("path"));
42
+ const codex_launch_1 = require("../relay/launcher/codex-launch");
43
+ Object.defineProperty(exports, "codexConfigPath", { enumerable: true, get: function () { return codex_launch_1.codexConfigPath; } });
44
+ function read(file) {
45
+ try {
46
+ return { ok: true, raw: fs.readFileSync(file, "utf8") };
47
+ }
48
+ catch (err) {
49
+ return { ok: false, reason: err.code === "ENOENT" ? "CONFIG_MISSING" : "CONFIG_UNREADABLE" };
50
+ }
51
+ }
52
+ /** Read-only, by the relay's own rule: the folder's own table says trusted. */
53
+ function isCodexFolderTrusted(folder, file = (0, codex_launch_1.codexConfigPath)()) {
54
+ const r = read(file);
55
+ if (!r.ok)
56
+ return false;
57
+ // codexLaunchProblem also wants the MCP entry; ask it only about trust by handing it a config that has one.
58
+ const withEntry = `[mcp_servers.baychat]\n${r.raw}`;
59
+ return (0, codex_launch_1.codexLaunchProblem)(folder, () => withEntry) === undefined;
60
+ }
61
+ /** A sentence for each problem, for the person at the terminal. */
62
+ function codexTrustProblemText(reason, file) {
63
+ switch (reason) {
64
+ case "CONFIG_MISSING":
65
+ return `${file} does not exist yet (Codex has not run on this computer).`;
66
+ case "CONFIG_UNREADABLE":
67
+ return `${file} could not be read.`;
68
+ case "PATH_UNSAFE":
69
+ return "the folder's name has a line break, or both a quote and a backslash, which BayChat will not write into Codex's config.";
70
+ case "CONFIG_UNKNOWN_FORM":
71
+ return `${file} already names this folder in a form BayChat does not edit, so it was left alone. Set the trust in Codex itself.`;
72
+ case "CONFIG_CHANGING":
73
+ return `${file} kept changing while BayChat tried to write it (is Codex busy?).`;
74
+ case "WRITE_FAILED":
75
+ return `${file} could not be written.`;
76
+ }
77
+ }
78
+ /** The folder as a TOML key: a basic string, else a literal string, else null (neither can hold it). */
79
+ function tomlKey(folder) {
80
+ if (/[\n\r]/.test(folder))
81
+ return null;
82
+ if (!/["\\]/.test(folder))
83
+ return `"${folder}"`;
84
+ if (!folder.includes("'"))
85
+ return `'${folder}'`;
86
+ return null;
87
+ }
88
+ /**
89
+ * The config with `folder` trusted: its table's level set in place, or one table appended — or null when the
90
+ * folder is already a key in some other form, where an appended table would be a duplicate key.
91
+ */
92
+ function withTrust(raw, folder) {
93
+ const header = new RegExp(`^[ \\t]*\\[projects\\.${(0, codex_launch_1.codexProjectKeyRe)(folder)}\\][ \\t]*$`, "m");
94
+ const found = header.exec(raw);
95
+ if (found) {
96
+ const start = found.index + found[0].length;
97
+ const next = raw.slice(start).search(/^[ \t]*\[/m);
98
+ const end = next === -1 ? raw.length : start + next;
99
+ const body = raw.slice(start, end);
100
+ const level = /^([ \t]*trust_level[ \t]*=[ \t]*)("[^"\n]*"|'[^'\n]*')/m;
101
+ const nextBody = level.test(body) ? body.replace(level, '$1"trusted"') : `\ntrust_level = "trusted"${body}`;
102
+ return raw.slice(0, start) + nextBody + raw.slice(end);
103
+ }
104
+ const otherForm = new RegExp(`${(0, codex_launch_1.codexProjectKeyRe)(folder)}\\s*(?:=|\\.)`);
105
+ if (otherForm.test(raw))
106
+ return null;
107
+ const sep = raw === "" || raw.endsWith("\n") ? "" : "\n";
108
+ return `${raw}${sep}\n[projects.${tomlKey(folder)}]\ntrust_level = "trusted"\n`;
109
+ }
110
+ /**
111
+ * Trust `folder` (the spot's stored realpath) for Codex. Called from `baychat launch add` after a person at this
112
+ * computer said y, and from the relay for the owner's "Allow… in this folder" on their phone — only for a folder
113
+ * that person opened to it (`phoneMayAllow`, relay/launcher/spot-request.ts).
114
+ */
115
+ function trustCodexFolder(folder, file = (0, codex_launch_1.codexConfigPath)()) {
116
+ if (tomlKey(folder) === null)
117
+ return { ok: false, reason: "PATH_UNSAFE" };
118
+ // Write through a symlinked config (dotfile managers) instead of replacing the link.
119
+ let target = file;
120
+ try {
121
+ target = fs.realpathSync(file);
122
+ }
123
+ catch {
124
+ // Missing: read() reports it below.
125
+ }
126
+ for (let attempt = 0; attempt < 3; attempt++) {
127
+ const r = read(target);
128
+ // A fresh Codex install has its folder but no config.toml yet (vps, 4 Nov): create it holding only this
129
+ // table, 0600, and only if nothing appeared there meanwhile (`wx`). No folder: Codex has not run here.
130
+ if (!r.ok && r.reason === "CONFIG_MISSING" && fs.existsSync(path.dirname(target))) {
131
+ try {
132
+ fs.writeFileSync(target, (withTrust("", folder) ?? "").replace(/^\n/, ""), { mode: 0o600, flag: "wx" });
133
+ fs.chmodSync(target, 0o600); // writeFile's mode is masked by the umask
134
+ return { ok: true, changed: true };
135
+ }
136
+ catch (err) {
137
+ if (err.code === "EEXIST")
138
+ continue; // Codex wrote it first: start over.
139
+ return { ok: false, reason: "WRITE_FAILED" };
140
+ }
141
+ }
142
+ if (!r.ok)
143
+ return r;
144
+ if (isCodexFolderTrusted(folder, target))
145
+ return { ok: true, changed: false };
146
+ const text = withTrust(r.raw, folder);
147
+ if (text === null)
148
+ return { ok: false, reason: "CONFIG_UNKNOWN_FORM" };
149
+ let mode;
150
+ try {
151
+ mode = fs.statSync(target).mode & 0o777;
152
+ const bak = `${target}.bak`;
153
+ fs.copyFileSync(target, bak);
154
+ fs.chmodSync(bak, mode);
155
+ }
156
+ catch {
157
+ return { ok: false, reason: "WRITE_FAILED" };
158
+ }
159
+ const tmp = path.join(path.dirname(target), `.${path.basename(target)}.baychat-${process.pid}-${Date.now()}.tmp`);
160
+ try {
161
+ fs.writeFileSync(tmp, text, { mode });
162
+ fs.chmodSync(tmp, mode); // writeFile's mode is masked by the umask
163
+ // If Codex rewrote it since we read it, start over rather than rename our older copy over its newer one.
164
+ if (fs.readFileSync(target, "utf8") !== r.raw) {
165
+ fs.rmSync(tmp, { force: true });
166
+ continue;
167
+ }
168
+ fs.renameSync(tmp, target);
169
+ return { ok: true, changed: true };
170
+ }
171
+ catch {
172
+ fs.rmSync(tmp, { force: true });
173
+ return { ok: false, reason: "WRITE_FAILED" };
174
+ }
175
+ }
176
+ return { ok: false, reason: "CONFIG_CHANGING" };
177
+ }