baychat 0.35.3 → 0.36.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.35.3",
3
+ "version": "0.36.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": {
@@ -103,6 +103,7 @@ const approval_wait_1 = require("./approval-wait");
103
103
  const config_1 = require("./config");
104
104
  const hook_stdin_1 = require("./hook-stdin");
105
105
  const claude_link_1 = require("./channel/claude-link");
106
+ const compound_command_1 = require("./denied/compound-command");
106
107
  function allowDecision() {
107
108
  return {
108
109
  hookSpecificOutput: {
@@ -210,6 +211,12 @@ function describeToolRequest(toolName, toolInput) {
210
211
  return "(its parameters could not be displayed)";
211
212
  }
212
213
  }
214
+ /** A shell command that is more than one, as the shell reads it (denied/compound-command.ts). */
215
+ function isChained(toolName, toolInput) {
216
+ if (toolName !== "Bash")
217
+ return false;
218
+ return (0, compound_command_1.compoundKind)(str((toolInput ?? {}).command)) !== null;
219
+ }
213
220
  /** The question the card asks. Blunt on purpose: this is a shell on a server, read on a phone,
214
221
  * and the safe answer has to be the obvious one. */
215
222
  function composeQuestion(params) {
@@ -225,6 +232,7 @@ function composeQuestion(params) {
225
232
  params.cwd ? `Working directory: ${params.cwd}` : "",
226
233
  params.permissionMode ? `Permission mode: ${params.permissionMode}` : "",
227
234
  "",
235
+ isChained(params.toolName, params.toolInput) ? compound_command_1.CHAINED_LINE : "",
228
236
  "Allowing runs it on that machine, now. If you did not expect this, deny.",
229
237
  ].filter((l) => l !== undefined);
230
238
  return clip(lines.join("\n").replace(/\n{3,}/g, "\n\n").trim(), QUESTION_MAX);
@@ -291,7 +299,7 @@ async function decideApproval(rawStdin, argv = [], deps = {}) {
291
299
  // re-arms across bounded holds until somebody taps (approval-wait.ts). Every outcome that is
292
300
  // not "answered with index 0" becomes a deny below.
293
301
  const outcome = await (0, approval_wait_1.askOwnerAndWait)(device, { session: opts.session, question, options: [...exports.APPROVE_OPTIONS] }, deadline, { now, sleep, log }, "baychat approve-hook");
294
- return verdictOf(outcome, opts.waitSeconds);
302
+ return verdictOf(outcome, opts.waitSeconds, isChained(toolName, input.tool_input));
295
303
  }
296
304
  catch (err) {
297
305
  // The catch that makes the guarantee true. Anything at all — a thrown non-Error, an OOM in a
@@ -309,12 +317,13 @@ async function decideApproval(rawStdin, argv = [], deps = {}) {
309
317
  * asymmetry is the point: a future server change can make this hook over-refuse, and can never
310
318
  * make it over-permit.
311
319
  */
312
- function verdictOf(outcome, waitSeconds) {
320
+ function verdictOf(outcome, waitSeconds, chained = false) {
313
321
  switch (outcome.kind) {
314
322
  case "answered":
315
323
  if (outcome.index === ALLOW_INDEX)
316
324
  return allowDecision();
317
- return deny(`answered "${outcome.answer ?? exports.APPROVE_OPTIONS[1]}" in BayChat.`);
325
+ // The deny's message is what Claude reads: the way out of a refused chain, not a retry of it.
326
+ return deny(`answered "${outcome.answer ?? exports.APPROVE_OPTIONS[1]}" in BayChat.`, chained ? compound_command_1.SPLIT_HINT : "");
318
327
  case "cancelled":
319
328
  return deny("the decision was withdrawn in BayChat without an answer.");
320
329
  case "deadline":
@@ -0,0 +1,75 @@
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.claimDeniedEvent = claimDeniedEvent;
37
+ // One card per denial. A session BayChat launched has its own PermissionDenied hook (in its
38
+ // `--settings` file) AND the BayChat plugin's (hooks/hooks.json), and Claude Code runs both on
39
+ // the same event: the first to create the event's claim file asks; the other leaves it.
40
+ const fs = __importStar(require("fs"));
41
+ const path = __importStar(require("path"));
42
+ const config_1 = require("../config");
43
+ /** Claims older than this are swept on the next claim: a denial is answered within its hook's wait. */
44
+ const CLAIM_TTL_MS = 2 * 24 * 3600_000;
45
+ const SAFE_ID = /^[A-Za-z0-9_-]{1,128}$/;
46
+ const claimsDir = () => path.join((0, config_1.configDir)(), "denied-claims");
47
+ /** True for the first hook to claim `toolUseId`. An id that is no safe file name claims nothing. */
48
+ function claimDeniedEvent(toolUseId, nowMs = Date.now()) {
49
+ if (!SAFE_ID.test(toolUseId))
50
+ return false;
51
+ const dir = claimsDir();
52
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
53
+ sweep(dir, nowMs);
54
+ try {
55
+ fs.writeFileSync(path.join(dir, toolUseId), "", { flag: "wx", mode: 0o600 });
56
+ return true;
57
+ }
58
+ catch (err) {
59
+ if (err.code === "EEXIST")
60
+ return false;
61
+ throw err;
62
+ }
63
+ }
64
+ function sweep(dir, nowMs) {
65
+ for (const name of fs.readdirSync(dir)) {
66
+ try {
67
+ const file = path.join(dir, name);
68
+ if (nowMs - fs.statSync(file).mtimeMs > CLAIM_TTL_MS)
69
+ fs.rmSync(file, { force: true });
70
+ }
71
+ catch {
72
+ /* gone already, or another hook's: the next sweep tries again */
73
+ }
74
+ }
75
+ }
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ // Is a shell command ONE command, or several? Read the way a shell reads it — quotes and
3
+ // backslashes first — never by searching the text for `&&`: `echo "a && b"` is one command.
4
+ //
5
+ // It matters because Claude Code checks a chain subcommand by subcommand (permissions docs,
6
+ // "Compound commands"): a chain can only be allowed once, and the chain as typed never matches
7
+ // the rules saved for it. Pure: no files, no network.
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.SPLIT_HINT = exports.CHAINED_LINE = void 0;
10
+ exports.compoundKind = compoundKind;
11
+ /** Why `command` is more than one command ("nested" wins over "chain"), or null when it is one. Never throws. */
12
+ function compoundKind(command) {
13
+ let quote = null;
14
+ let chain = false;
15
+ for (let i = 0; i < command.length; i++) {
16
+ const c = command[i];
17
+ const next = command[i + 1];
18
+ if (quote === "'") {
19
+ if (c === "'")
20
+ quote = null;
21
+ continue;
22
+ }
23
+ if (c === "\\") {
24
+ i++;
25
+ continue;
26
+ }
27
+ // The shell still runs a substitution inside double quotes.
28
+ if (c === "`" || (c === "$" && next === "("))
29
+ return "nested";
30
+ if (quote === '"') {
31
+ if (c === '"')
32
+ quote = null;
33
+ continue;
34
+ }
35
+ if (c === "'" || c === '"')
36
+ quote = c;
37
+ else if (c === "(" || c === ")")
38
+ return "nested";
39
+ else if (c === ";" || c === "|" || c === "\n" || c === "\r")
40
+ chain = true;
41
+ // `&&` is a chain; a lone `&` sends to the background; `2>&1` and `&>` are redirections.
42
+ else if (c === "&" && (next === "&" || (command[i - 1] !== ">" && next !== ">")))
43
+ chain = true;
44
+ }
45
+ return chain ? "chain" : null;
46
+ }
47
+ /** The card's line for a command that is more than one (approve-hook, permission-rule). */
48
+ exports.CHAINED_LINE = "This is a chained command: it can only be allowed once.";
49
+ /** What the agent is told when a chain is refused: Claude Code reads a PermissionRequest deny's message. */
50
+ exports.SPLIT_HINT = "It was a chained command: do not retry the chain. Run single commands, or write a script to a file " +
51
+ "and run it with one command, using absolute paths.";
@@ -33,36 +33,50 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.DENIED_CARD_DEFAULT = void 0;
37
+ exports.deniedCardMode = deniedCardMode;
36
38
  exports.deniedCardEnabled = deniedCardEnabled;
37
39
  exports.enableDeniedCard = enableDeniedCard;
38
40
  exports.disableDeniedCard = disableDeniedCard;
39
41
  // Whether this computer's owner turned the denied-command card on (`baychat permissions on`).
40
42
  //
41
- // OFF UNTIL SOMEBODY ASKS FOR IT, like approve-hook was: the card lets a phone add a permission
42
- // rule on this computer, so it exists only after a deliberate act. The switch is a file in
43
- // `~/.baychat`, read when BayChat's launcher writes a session's `--settings` file
44
- // (relay/launcher/approval-settings.ts), so it takes effect for sessions started after it changes.
43
+ // "ALLOW ONCE" UNTIL SOMEBODY ASKS FOR MORE: the card lets a phone add a permission rule on this
44
+ // computer, so keeping one ("Always") needs a deliberate act at the terminal; `DENIED_CARD_DEFAULT`
45
+ // is the owner's call for "nobody chose". The switch is a file in `~/.baychat`, read when BayChat's
46
+ // launcher writes a session's `--settings` file (relay/launcher/approval-settings.ts), and by the
47
+ // plugin's own hook on each denial (denied-hook --plugin), for sessions started by hand.
45
48
  const fs = __importStar(require("fs"));
46
49
  const path = __importStar(require("path"));
47
50
  const config_1 = require("../config");
48
51
  const optInFile = () => path.join((0, config_1.configDir)(), "denied-card.json");
49
- /** True only when the switch file says so. Any doubt — missing, unreadable, malformed — is off. */
50
- function deniedCardEnabled() {
52
+ /** WHEN NOBODY CHOSE (no switch file): the owner's decision, kept as one line. Karmen, 2026-10-29: on,
53
+ * "Allow once" only — each tap allows one exact command one time; "Always" needs `baychat permissions on`. */
54
+ exports.DENIED_CARD_DEFAULT = "once";
55
+ /** The switch: what its file says, the default without one, off on any doubt (unreadable, malformed). */
56
+ function deniedCardMode() {
51
57
  try {
52
- const parsed = JSON.parse(fs.readFileSync(optInFile(), "utf8"));
53
- return parsed?.enabled === true;
58
+ const enabled = JSON.parse(fs.readFileSync(optInFile(), "utf8"))?.enabled;
59
+ return enabled === true ? "on" : "off";
54
60
  }
55
61
  catch (err) {
56
- if (err.code !== "ENOENT") {
57
- process.stderr.write(`baychat: ${optInFile()} could not be read, so the denied-command card is off.\n`);
58
- }
59
- return false;
62
+ if (err.code === "ENOENT")
63
+ return exports.DENIED_CARD_DEFAULT;
64
+ process.stderr.write(`baychat: ${optInFile()} could not be read, so the denied-command card is off.\n`);
65
+ return "off";
60
66
  }
61
67
  }
68
+ /** True only when the owner turned the card on, Always included. */
69
+ function deniedCardEnabled() {
70
+ return deniedCardMode() === "on";
71
+ }
62
72
  function enableDeniedCard(now = new Date()) {
63
- fs.mkdirSync((0, config_1.configDir)(), { recursive: true, mode: 0o700 });
64
- fs.writeFileSync(optInFile(), `${JSON.stringify({ enabled: true, since: now.toISOString() }, null, 2)}\n`, { mode: 0o600 });
73
+ writeSwitch(true, now);
74
+ }
75
+ /** Written, not deleted: an explicit off outranks any default. */
76
+ function disableDeniedCard(now = new Date()) {
77
+ writeSwitch(false, now);
65
78
  }
66
- function disableDeniedCard() {
67
- fs.rmSync(optInFile(), { force: true });
79
+ function writeSwitch(enabled, now) {
80
+ fs.mkdirSync((0, config_1.configDir)(), { recursive: true, mode: 0o700 });
81
+ fs.writeFileSync(optInFile(), `${JSON.stringify({ enabled, since: now.toISOString() }, null, 2)}\n`, { mode: 0o600 });
68
82
  }
@@ -22,17 +22,19 @@
22
22
  // The card text is built from `shown`, which masks obvious secrets; the rule is built from the
23
23
  // real text, because a rule that differs from the call matches nothing.
24
24
  Object.defineProperty(exports, "__esModule", { value: true });
25
- exports.MAX_RULE_COMMAND_LENGTH = void 0;
25
+ exports.CHAINED_LIMITATION = exports.MAX_RULE_COMMAND_LENGTH = void 0;
26
26
  exports.planDeniedCall = planDeniedCall;
27
27
  exports.callKeyOf = callKeyOf;
28
28
  exports.sameCall = sameCall;
29
29
  exports.redactSecrets = redactSecrets;
30
+ const compound_command_1 = require("./compound-command");
30
31
  /** Longer than this is not a command a person can check on a phone, nor one a rule should hold. */
31
32
  exports.MAX_RULE_COMMAND_LENGTH = 1000;
32
33
  /** Claude Code itself saves at most five rules for one approved chain (permissions docs). */
33
34
  const MAX_CHAIN_PARTS = 5;
35
+ /** What the card says for every chain: the agent reads it too (the deny message, approve-hook). */
36
+ exports.CHAINED_LIMITATION = `${compound_command_1.CHAINED_LINE} Ask the session to split it into single commands or a script it runs with one command.`;
34
37
  /** `&&`, `||`, `|&`, `;`, `|`, and a lone `&` that is not part of a redirection (`2>&1`, `&>`). */
35
- const CHAIN_OPERATOR = /&&|\|\||\|&|;|\||(?<![>&])&(?![>&])/;
36
38
  const CHAIN_SPLIT = /\s*(?:&&|\|\||\|&|;|\||(?<![>&])&(?![>&]))\s*/;
37
39
  const NESTED_COMMAND = /`|\$\(|[()]/;
38
40
  const MCP_TOOL = /^mcp__[A-Za-z0-9_-]+__[A-Za-z0-9_.-]+$/;
@@ -75,23 +77,22 @@ function planBash(command) {
75
77
  "approve it at the computer.");
76
78
  }
77
79
  const call = { tool: "Bash", subject: command };
78
- if (CHAIN_OPERATOR.test(command)) {
80
+ // A chain as the shell reads it (compound-command.ts): `echo "a && b"` is one command.
81
+ if ((0, compound_command_1.compoundKind)(command) === "chain") {
79
82
  if (/["'\\]/.test(command)) {
80
- return nothing(shown, "It chains several commands and quotes text, so BayChat cannot split it safely; ask the session to " +
81
- "run them one at a time.");
83
+ return nothing(shown, `${exports.CHAINED_LIMITATION} It also quotes text, so BayChat cannot split it safely; approve it at the computer.`);
82
84
  }
83
85
  const parts = command.split(CHAIN_SPLIT);
84
86
  if (parts.some((p) => p === "") || parts.length > MAX_CHAIN_PARTS) {
85
- return nothing(shown, "BayChat cannot split this chain into exact commands; approve it at the computer.");
87
+ return nothing(shown, `${exports.CHAINED_LIMITATION} BayChat cannot split this one; approve it at the computer.`);
86
88
  }
87
89
  return {
88
90
  shown,
89
91
  onceRules: [...new Set(parts.map((p) => `Bash(${p})`))],
90
92
  alwaysAllowed: false,
91
93
  // One plain line on WHY, because the usual cause is the agent's own habit (`cd … && x | tail`)
92
- // and the fix is for the session to run the command by itself.
93
- limitation: `"Always" isn't offered: this chains ${parts.length} commands, and a saved rule only ever covers one plain ` +
94
- `command. Run it on its own (no cd … &&, no | tail) to be offered "Always".`,
94
+ // and the fix is for the session to run single commands.
95
+ limitation: exports.CHAINED_LIMITATION,
95
96
  call,
96
97
  };
97
98
  }
@@ -88,11 +88,13 @@ const approve_hook_1 = require("./approve-hook");
88
88
  const approval_wait_1 = require("./approval-wait");
89
89
  const config_1 = require("./config");
90
90
  const hook_stdin_1 = require("./hook-stdin");
91
+ const claims_1 = require("./denied/claims");
91
92
  const confirm_1 = require("./denied/confirm");
92
93
  const grants_1 = require("./denied/grants");
93
94
  const hook_log_1 = require("./denied/hook-log");
94
95
  const live_session_1 = require("./denied/live-session");
95
96
  const notice_1 = require("./denied/notice");
97
+ const opt_in_1 = require("./denied/opt-in");
96
98
  const permission_rule_1 = require("./denied/permission-rule");
97
99
  const settings_rules_1 = require("./denied/settings-rules");
98
100
  const transcript_1 = require("./denied/transcript");
@@ -180,6 +182,7 @@ function parseArgs(argv) {
180
182
  return {
181
183
  session: at("--session"),
182
184
  fromClaude: argv.includes("--session-from-claude"),
185
+ plugin: argv.includes("--plugin"),
183
186
  waitSeconds: Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : approve_hook_1.DEFAULT_WAIT_SECONDS,
184
187
  };
185
188
  }
@@ -227,6 +230,22 @@ async function decideDenied(rawStdin, argv = [], deps = {}) {
227
230
  log(`${LOG_LABEL}: this session has not joined BayChat yet; the denial stands.`);
228
231
  return null;
229
232
  }
233
+ // The switch decides on every denial: the plugin's hook runs in every session, and a launched
234
+ // session's own hook was written while the switch was not off (it may have changed since).
235
+ const mode = (deps.cardMode ?? opt_in_1.deniedCardMode)();
236
+ if (mode === "off") {
237
+ log(`${LOG_LABEL}: the denied-command card is off on this computer; the denial stands.`);
238
+ return null;
239
+ }
240
+ const eventId = str(input.tool_use_id);
241
+ if (opts.plugin && !eventId) {
242
+ log(`${LOG_LABEL}: the event has no tool_use_id to take it by; the denial stands.`);
243
+ return null;
244
+ }
245
+ if (eventId && !(deps.claimEvent ?? claims_1.claimDeniedEvent)(eventId)) {
246
+ log(`${LOG_LABEL}: another BayChat hook already took this denial.`);
247
+ return null;
248
+ }
230
249
  const device = (deps.loadDevice ?? config_1.loadDeviceCredentials)();
231
250
  if (!device) {
232
251
  log(`${LOG_LABEL}: this machine is not logged in to BayChat (\`baychat login\`); the denial stands.`);
@@ -301,7 +320,13 @@ async function decideDenied(rawStdin, argv = [], deps = {}) {
301
320
  limitation: `BayChat can't allow it from here: ${choice.why}, so no rule can be saved. ` +
302
321
  "Approve it at the computer (/permissions → Recently denied).",
303
322
  }
304
- : planned;
323
+ : mode === "once" && planned.alwaysAllowed
324
+ ? {
325
+ ...planned,
326
+ alwaysAllowed: false,
327
+ limitation: "\"Always\" is off on this computer (`baychat permissions on` at the computer turns it on).",
328
+ }
329
+ : planned;
305
330
  const target = choice.scope === "none" ? undefined : choice;
306
331
  const question = composeDeniedQuestion({
307
332
  who,
package/dist/mcp.js CHANGED
@@ -252,6 +252,9 @@ async function handleSendMessage(args) {
252
252
  // message that quotes nothing must not claim to quote something.
253
253
  ...(args.replyToMessageId ? { replyToMessageId: args.replyToMessageId } : {}),
254
254
  ...(args.card ? { card: args.card } : {}),
255
+ ...(args.threadRootId ? { threadRootId: args.threadRootId } : {}),
256
+ ...(args.handoff !== undefined ? { handoff: args.handoff } : {}),
257
+ ...(args.result !== undefined ? { result: args.result } : {}),
255
258
  });
256
259
  const carried = ids.length > 0 ? ` with ${ids.length} attachment${ids.length === 1 ? "" : "s"}` : "";
257
260
  return (0, mcp_result_1.ok)(`Sent message ${message.id} at ${message.createdAt}${carried}.`, message);
@@ -26,9 +26,12 @@ async function cmdPermissions(args, deps = {}) {
26
26
  const [sub = "list", ...rest] = args;
27
27
  switch (sub) {
28
28
  case "list": {
29
- out((0, opt_in_1.deniedCardEnabled)()
30
- ? "The denied-command card is ON for sessions BayChat starts (`baychat permissions off` turns it off)."
31
- : "The denied-command card is OFF (`baychat permissions on` turns it on).");
29
+ const mode = (0, opt_in_1.deniedCardMode)();
30
+ out(mode === "on"
31
+ ? "The denied-command card is ON, with Always, for sessions joined to BayChat (`baychat permissions off` turns it off)."
32
+ : mode === "once"
33
+ ? "The denied-command card offers Allow once for sessions joined to BayChat (`baychat permissions on` adds Always, `off` turns it off)."
34
+ : "The denied-command card is OFF (`baychat permissions on` turns it on).");
32
35
  const grants = (0, grants_1.listGrants)();
33
36
  if (grants.length === 0) {
34
37
  out("BayChat keeps no permission rules on this computer.");
@@ -60,13 +63,13 @@ async function cmdPermissions(args, deps = {}) {
60
63
  return 1;
61
64
  }
62
65
  (0, opt_in_1.enableDeniedCard)();
63
- out("The denied-command card is ON. Sessions BayChat starts from now on will put a card in BayChat when");
66
+ out("The denied-command card is ON, with Always. A session joined to BayChat puts a card in BayChat when");
64
67
  out("Claude Code's auto mode blocks a call. Read docs/features/DENIED_COMMAND_CARD.md for what it can and can't allow.");
65
68
  return 0;
66
69
  }
67
70
  case "off":
68
71
  (0, opt_in_1.disableDeniedCard)();
69
- out("The denied-command card is OFF for sessions BayChat starts from now on. Rules it kept stay until removed.");
72
+ out("The denied-command card is OFF. Rules it kept stay until removed.");
70
73
  return 0;
71
74
  case "sweep": {
72
75
  const now = deps.now ?? Date.now;