baychat 0.35.4 → 0.37.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.4",
3
+ "version": "0.37.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": {
@@ -40,6 +40,7 @@ exports.describeToolRequest = describeToolRequest;
40
40
  exports.composeQuestion = composeQuestion;
41
41
  exports.decideApproval = decideApproval;
42
42
  exports.sessionFromClaude = sessionFromClaude;
43
+ exports.linkFromClaude = linkFromClaude;
43
44
  exports.procStatFields = procStatFields;
44
45
  exports.procStartTicks = procStartTicks;
45
46
  exports.psParentOf = psParentOf;
@@ -103,6 +104,7 @@ const approval_wait_1 = require("./approval-wait");
103
104
  const config_1 = require("./config");
104
105
  const hook_stdin_1 = require("./hook-stdin");
105
106
  const claude_link_1 = require("./channel/claude-link");
107
+ const compound_command_1 = require("./denied/compound-command");
106
108
  function allowDecision() {
107
109
  return {
108
110
  hookSpecificOutput: {
@@ -210,6 +212,12 @@ function describeToolRequest(toolName, toolInput) {
210
212
  return "(its parameters could not be displayed)";
211
213
  }
212
214
  }
215
+ /** A shell command that is more than one, as the shell reads it (denied/compound-command.ts). */
216
+ function isChained(toolName, toolInput) {
217
+ if (toolName !== "Bash")
218
+ return false;
219
+ return (0, compound_command_1.compoundKind)(str((toolInput ?? {}).command)) !== null;
220
+ }
213
221
  /** The question the card asks. Blunt on purpose: this is a shell on a server, read on a phone,
214
222
  * and the safe answer has to be the obvious one. */
215
223
  function composeQuestion(params) {
@@ -225,6 +233,7 @@ function composeQuestion(params) {
225
233
  params.cwd ? `Working directory: ${params.cwd}` : "",
226
234
  params.permissionMode ? `Permission mode: ${params.permissionMode}` : "",
227
235
  "",
236
+ isChained(params.toolName, params.toolInput) ? compound_command_1.CHAINED_LINE : "",
228
237
  "Allowing runs it on that machine, now. If you did not expect this, deny.",
229
238
  ].filter((l) => l !== undefined);
230
239
  return clip(lines.join("\n").replace(/\n{3,}/g, "\n\n").trim(), QUESTION_MAX);
@@ -291,7 +300,7 @@ async function decideApproval(rawStdin, argv = [], deps = {}) {
291
300
  // re-arms across bounded holds until somebody taps (approval-wait.ts). Every outcome that is
292
301
  // not "answered with index 0" becomes a deny below.
293
302
  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);
303
+ return verdictOf(outcome, opts.waitSeconds, isChained(toolName, input.tool_input));
295
304
  }
296
305
  catch (err) {
297
306
  // The catch that makes the guarantee true. Anything at all — a thrown non-Error, an OOM in a
@@ -309,12 +318,13 @@ async function decideApproval(rawStdin, argv = [], deps = {}) {
309
318
  * asymmetry is the point: a future server change can make this hook over-refuse, and can never
310
319
  * make it over-permit.
311
320
  */
312
- function verdictOf(outcome, waitSeconds) {
321
+ function verdictOf(outcome, waitSeconds, chained = false) {
313
322
  switch (outcome.kind) {
314
323
  case "answered":
315
324
  if (outcome.index === ALLOW_INDEX)
316
325
  return allowDecision();
317
- return deny(`answered "${outcome.answer ?? exports.APPROVE_OPTIONS[1]}" in BayChat.`);
326
+ // The deny's message is what Claude reads: the way out of a refused chain, not a retry of it.
327
+ return deny(`answered "${outcome.answer ?? exports.APPROVE_OPTIONS[1]}" in BayChat.`, chained ? compound_command_1.SPLIT_HINT : "");
318
328
  case "cancelled":
319
329
  return deny("the decision was withdrawn in BayChat without an answer.");
320
330
  case "deadline":
@@ -341,6 +351,10 @@ exports.MAX_CLAUDE_HOPS = 4;
341
351
  * Claude Code's own dialog.
342
352
  */
343
353
  function sessionFromClaude(deps = {}) {
354
+ return linkFromClaude(deps)?.session;
355
+ }
356
+ /** The channel's record for the Claude Code this hook runs under (claude-link.ts). Never throws. */
357
+ function linkFromClaude(deps = {}) {
344
358
  const parentOf = deps.parentOf ?? psParentOf;
345
359
  const isAlive = deps.isAlive ?? claude_link_1.pidAlive;
346
360
  const readLink = deps.readLink ?? ((pid) => (0, claude_link_1.readClaudeLink)(pid));
@@ -357,7 +371,7 @@ function sessionFromClaude(deps = {}) {
357
371
  const written = Date.parse(link.writtenAt);
358
372
  const stale = age !== undefined && Number.isFinite(written) && written < now() - age - 5_000;
359
373
  if (!stale)
360
- return link.session;
374
+ return link;
361
375
  }
362
376
  pid = parentOf(pid);
363
377
  }
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FEED_RETRYING_HINT = exports.FEED_DOWN_HINT = void 0;
4
+ exports.channelCheckLine = channelCheckLine;
5
+ exports.cmdChannelCheck = cmdChannelCheck;
6
+ /**
7
+ * `baychat channel-check` — the SessionStart hook (matcher `compact`) that says when this session's
8
+ * BayChat feed is down after a compact (Chat_claude, 29 Oct: the feed died silently at a compact and
9
+ * 15 minutes of @mentions went unheard). A SessionStart hook's output reaches the model; a
10
+ * PostCompact one's does not, and `context-hook` runs async and prints nothing on purpose.
11
+ *
12
+ * Only for a Claude Code started with the BayChat channel (detect.ts). Its channel server records
13
+ * the feed's health in `claude-sessions/<pid>.json` (claude-link.ts): no record, or one that says
14
+ * `retrying`, prints one line telling the session to call `listen`. NEVER FAILS OR BLOCKS: any
15
+ * error reading either is treated as "down" for a channel session and prints the hint; exit is 0.
16
+ */
17
+ const approve_hook_1 = require("../approve-hook");
18
+ const detect_1 = require("./detect");
19
+ exports.FEED_DOWN_HINT = "BayChat feed down: call the BayChat channel's listen tool with your session name, then get_messages to catch up.";
20
+ exports.FEED_RETRYING_HINT = "BayChat feed down (retrying in the background): call the BayChat channel's listen tool with your session name, then get_messages to catch up.";
21
+ /** The line to print, or undefined when there is nothing to say. Never throws. */
22
+ function channelCheckLine(deps = {}) {
23
+ let active = false;
24
+ try {
25
+ active = (deps.detect ?? (() => (0, detect_1.detectChannel)()))().active;
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ if (!active)
31
+ return undefined;
32
+ let link;
33
+ try {
34
+ link = (deps.link ?? approve_hook_1.linkFromClaude)();
35
+ }
36
+ catch {
37
+ return exports.FEED_DOWN_HINT;
38
+ }
39
+ if (!link)
40
+ return exports.FEED_DOWN_HINT;
41
+ return link.feed === "retrying" ? exports.FEED_RETRYING_HINT : undefined;
42
+ }
43
+ async function cmdChannelCheck(deps = {}) {
44
+ try {
45
+ const line = channelCheckLine(deps);
46
+ if (line)
47
+ (deps.out ?? ((l) => console.log(l)))(line);
48
+ }
49
+ catch {
50
+ // Nothing: a hook that fails must never stand in the way of the session.
51
+ }
52
+ return 0;
53
+ }
@@ -69,7 +69,11 @@ function writeClaudeLink(pid, session, opts = {}) {
69
69
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
70
70
  sweepDeadLinks(dir, opts.isAlive ?? pidAlive);
71
71
  const file = claudeLinkPath(pid, dir);
72
- const record = { session, writtenAt: (opts.now ?? (() => new Date()))().toISOString() };
72
+ const record = {
73
+ session,
74
+ writtenAt: (opts.now ?? (() => new Date()))().toISOString(),
75
+ ...(opts.feed ? { feed: opts.feed } : {}),
76
+ };
73
77
  // Write-then-rename, so the hook never reads half a file.
74
78
  const tmp = `${file}.${process.pid}.tmp`;
75
79
  fs.writeFileSync(tmp, `${JSON.stringify(record)}\n`, { mode: 0o600 });
@@ -89,7 +93,8 @@ function readClaudeLink(pid, dir = claudeSessionsDir()) {
89
93
  const raw = JSON.parse(fs.readFileSync(claudeLinkPath(pid, dir), "utf8"));
90
94
  if (typeof raw.session !== "string" || !raw.session.trim() || raw.session.length > 60 || typeof raw.writtenAt !== "string")
91
95
  return undefined;
92
- return { session: raw.session, writtenAt: raw.writtenAt };
96
+ const feed = raw.feed === "retrying" ? "retrying" : raw.feed === "open" ? "open" : undefined;
97
+ return { session: raw.session, writtenAt: raw.writtenAt, ...(feed ? { feed } : {}) };
93
98
  }
94
99
  catch {
95
100
  return undefined;
@@ -124,9 +129,9 @@ function pidAlive(pid) {
124
129
  /** The real link: keyed by this process's parent — the Claude Code that started the channel server. */
125
130
  function realClaudeLink(log, pid = process.ppid) {
126
131
  return {
127
- write: (session) => {
132
+ write: (session, feed) => {
128
133
  try {
129
- writeClaudeLink(pid, session);
134
+ writeClaudeLink(pid, session, feed ? { feed } : {});
130
135
  }
131
136
  catch (err) {
132
137
  // Approvals then stay in the terminal for this session: the hook finds no link and says nothing.
@@ -29,6 +29,18 @@ class ChannelFeed {
29
29
  retryTimer;
30
30
  watchdogTimer;
31
31
  lastTick = 0;
32
+ /**
33
+ * A re-listen is starting a new feed for this session (server.ts). The server closes this one
34
+ * with 4000 once the new one is seated: taken quietly, because the new feed is this session's.
35
+ */
36
+ replacing = false;
37
+ /** The server closed this feed during a replacement that then failed: reconnect on cancel. */
38
+ closedForReplacement = false;
39
+ /**
40
+ * Down with nothing else running (a first start that failed): retried for as long as the
41
+ * channel lives, never given up on, and a `reset` once it is back — so the session catches up.
42
+ */
43
+ down = false;
32
44
  now;
33
45
  setTimer;
34
46
  clearTimer;
@@ -60,13 +72,13 @@ class ChannelFeed {
60
72
  const minted = await this.deps.mint(undefined);
61
73
  if (!minted.ok) {
62
74
  this.state = "stopped";
63
- return { ok: false, detail: minted.detail };
75
+ return { ok: false, detail: minted.detail, fatal: minted.fatal };
64
76
  }
65
77
  const ready = await new Promise((resolve) => {
66
78
  const timer = this.setTimer(() => {
67
79
  this.teardown();
68
80
  this.state = "stopped";
69
- resolve({ ok: false, detail: "The BayChat feed did not answer within 10 seconds." });
81
+ resolve({ ok: false, detail: "The BayChat feed did not answer within 10 seconds.", fatal: false });
70
82
  }, this.deps.readyTimeoutMs ?? READY_TIMEOUT_MS);
71
83
  this.open(minted.ticket, {
72
84
  onReady: () => {
@@ -77,14 +89,41 @@ class ChannelFeed {
77
89
  this.clearTimer(timer);
78
90
  this.teardown();
79
91
  this.state = "stopped";
80
- resolve({ ok: false, detail });
92
+ resolve({ ok: false, detail, fatal: false });
81
93
  },
82
94
  });
83
95
  });
84
- if (ready.ok)
96
+ if (ready.ok) {
85
97
  this.armWatchdog();
98
+ this.deps.onHealth?.(true);
99
+ }
86
100
  return ready;
87
101
  }
102
+ /** A new feed for this session is starting: a 4000 from the server is that, not an ending. */
103
+ expectReplacement() {
104
+ this.replacing = true;
105
+ }
106
+ /** The new feed did not start: this one carries on, and reconnects if the server closed it. */
107
+ cancelReplacement() {
108
+ this.replacing = false;
109
+ if (this.closedForReplacement && this.state !== "stopped") {
110
+ this.closedForReplacement = false;
111
+ this.scheduleReconnect("was closed for a re-listen that did not start");
112
+ }
113
+ }
114
+ /**
115
+ * A first start that failed for now, with no other feed for this session: keep trying in the
116
+ * background (backoff up to a minute, never giving up), say so now, and say `reset` once back.
117
+ */
118
+ retryInBackground(detail) {
119
+ this.down = true;
120
+ this.state = "retrying";
121
+ this.attempt = 0;
122
+ this.deps.onHealth?.(false);
123
+ void this.deps.push(`BayChat's feed for session "${this.deps.session}" is down (${detail}); retrying in the background. ` +
124
+ `Until it is back this session does not hear the Bay. A reset event follows when it is back; then catch up with get_messages.`, this.meta({ event: "stopped" }));
125
+ this.scheduleReconnect(`did not start (${detail})`);
126
+ }
88
127
  /** Stop for good: no reconnect, no watchdog. Used on replace and on process exit. */
89
128
  stop() {
90
129
  const was = this.state;
@@ -174,6 +213,13 @@ class ChannelFeed {
174
213
  this.state = "open";
175
214
  this.attempt = 0;
176
215
  this.log(`feed for ${this.deps.session} ready`);
216
+ if (this.down) {
217
+ this.down = false;
218
+ this.armWatchdog();
219
+ void this.pushReset();
220
+ }
221
+ if (!first)
222
+ this.deps.onHealth?.(true);
177
223
  first?.onReady();
178
224
  return;
179
225
  }
@@ -194,6 +240,12 @@ class ChannelFeed {
194
240
  first.onFailBeforeReady(`The BayChat feed closed before it was ready (${code}${reason ? `: ${reason}` : ""}).`);
195
241
  return;
196
242
  }
243
+ if (code === exports.CLOSE_STOPPED && this.replacing) {
244
+ // The re-listen's new feed was seated: it is this session's now (server.ts stops this one).
245
+ this.log(`feed for ${this.deps.session} closed for its replacement`);
246
+ this.closedForReplacement = true;
247
+ return;
248
+ }
197
249
  if (code === exports.CLOSE_STOPPED) {
198
250
  // Stopped or replaced on purpose — by stop_listening, end_session, or a newer
199
251
  // listener for this session. Reconnecting would fight whichever replaced it.
@@ -217,7 +269,9 @@ class ChannelFeed {
217
269
  scheduleReconnect(why) {
218
270
  if (this.state === "stopped")
219
271
  return;
220
- if (this.attempt >= exports.MAX_FAILED_RECONNECTS) {
272
+ if (this.state === "open")
273
+ this.deps.onHealth?.(false);
274
+ if (!this.down && this.attempt >= exports.MAX_FAILED_RECONNECTS) {
221
275
  this.log(`feed for ${this.deps.session} gave up after ${this.attempt} failed reconnects (${why})`);
222
276
  this.stop();
223
277
  void this.pushStopped(`${this.attempt} reconnects in a row failed (last: ${why})`);
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.ChannelService = exports.nodeFeedConnect = exports.CHANNEL_INSTRUCTIONS = void 0;
36
+ exports.ChannelService = exports.nodeFeedConnect = exports.LISTEN_RETRY_MS = exports.CHANNEL_INSTRUCTIONS = void 0;
37
37
  exports.realCommandLink = realCommandLink;
38
38
  exports.relaySocketFor = relaySocketFor;
39
39
  exports.realLogin = realLogin;
@@ -102,6 +102,12 @@ const LISTEN_TOOL = {
102
102
  required: ["session"],
103
103
  },
104
104
  };
105
+ /**
106
+ * Waits between `listen`'s attempts at a feed that failed for now (a network error, a 5xx): three
107
+ * attempts over about ten seconds before it answers (Chat_claude, 29 Oct: one "fetch failed" right
108
+ * after a compact was the whole answer, and the session heard nothing for 15 minutes).
109
+ */
110
+ exports.LISTEN_RETRY_MS = [2_000, 6_000];
105
111
  /**
106
112
  * The real command link: the relay socket of the session's Bay, and the pane and Claude Code
107
113
  * process THIS process inherited — Claude Code starts the channel server, so its `$TMUX` and its
@@ -297,24 +303,36 @@ class ChannelService {
297
303
  return toolResult("failed", `Failed: ${detail} Use listen_messages and a native Monitor instead.`, { session, stage: "login", detail });
298
304
  }
299
305
  const key = `${bayName ?? ""}\u0000${session}`;
300
- this.feeds.get(key)?.stop();
306
+ // A RE-LISTEN KEEPS WHAT RUNS until its new feed is seated (Chat_claude, 29 Oct): stopping the
307
+ // old feed first meant one failed mint cost the session a working feed. The old feed's relay
308
+ // link is reused too — a new registration would replace it at the relay, failed start or not.
309
+ const previous = this.feeds.get(key);
310
+ const live = previous && previous.currentState !== "stopped" ? previous : undefined;
311
+ const reused = live ? this.links.get(key) : undefined;
301
312
  // COMMANDS FIRST, so the first ticket already says whether this computer's relay runs them.
302
313
  // When that changes (the relay starts, stops or restarts), the feed is reopened so the
303
314
  // server's view follows (realtime/remote-listeners.ts). Never a reason to fail `listen`.
304
- let feed;
305
- const link = this.openCommandLink(key, session, bay, bayName, (registered) => feed?.reopen(registered ? "now has this computer's relay running its commands" : "lost this computer's relay"));
306
- if (link)
315
+ const link = reused ??
316
+ this.openCommandLink(key, session, bay, bayName, (registered) => this.feeds.get(key)?.reopen(registered ? "now has this computer's relay running its commands" : "lost this computer's relay"));
317
+ if (link && !reused)
307
318
  await link.start();
308
- feed = new feed_1.ChannelFeed({
319
+ const feed = new feed_1.ChannelFeed({
309
320
  session,
310
321
  ...(bayName ? { bayName } : {}),
311
322
  mint: (cursor) => mintTicket(this.deps, { bay, session, cursor, ...(link?.registered ? { commands: "relay" } : {}) }),
312
323
  onStop: () => {
324
+ // Replaced by a re-listen: the link and the hook's record are the new feed's now.
325
+ if (this.feeds.get(key) !== feed)
326
+ return;
313
327
  this.closeCommandLink(key, link);
314
328
  // A stopped feed: the hook must not keep sending cards in a name that may be gone.
315
329
  if (bay === undefined)
316
330
  this.deps.claudeLink?.remove(session);
317
331
  },
332
+ onHealth: (up) => {
333
+ if (bay === undefined && this.feeds.get(key) === feed)
334
+ this.deps.claudeLink?.write(session, up ? "open" : "retrying");
335
+ },
318
336
  connect: this.deps.connect,
319
337
  status: async () => {
320
338
  try {
@@ -332,23 +350,40 @@ class ChannelService {
332
350
  log: this.deps.log,
333
351
  ...this.deps.feedOverrides,
334
352
  });
335
- this.feeds.set(key, feed);
336
- const started = await feed.start();
353
+ live?.expectReplacement();
354
+ const started = await this.startWithRetries(feed, session);
337
355
  if (!started.ok) {
338
- this.feeds.delete(key);
339
- this.closeCommandLink(key, link);
356
+ this.deps.log(`listen ${session}: the feed did not start: ${started.detail}`);
357
+ if (live) {
358
+ // The feed that was running carries on; only the new one is given up.
359
+ live.cancelReplacement();
360
+ return toolResult("active", `Active: a new feed could not start (${started.detail}), so the feed already running for session "${session}" ` +
361
+ `keeps going. Messages still arrive as <channel> events; do NOT open a Monitor or call listen_messages.`, { session, ...(bayName ? { bay: bayName } : {}), kept: true, detail: started.detail });
362
+ }
363
+ if (!started.fatal) {
364
+ // Nothing else runs for this session: retry in the background rather than go quiet.
365
+ this.feeds.set(key, feed);
366
+ this.last = { key, session, bay };
367
+ feed.retryInBackground(started.detail);
368
+ return toolResult("retrying", `Retrying: the BayChat feed for session "${session}" could not start (${started.detail}). It keeps retrying in the ` +
369
+ `background, and a <channel event="reset"> says when it is back — then catch up with get_messages. Until then, ` +
370
+ `read get_messages when you expect a message. Do NOT open a Monitor: it would compete with this feed.`, { session, ...(bayName ? { bay: bayName } : {}), stage: "feed", detail: started.detail });
371
+ }
372
+ if (!reused)
373
+ this.closeCommandLink(key, link);
340
374
  if (this.last?.key === key)
341
375
  this.last = undefined;
342
- this.deps.log(`listen ${session}: the feed did not start: ${started.detail}`);
343
376
  return toolResult("failed", `Failed: ${started.detail} Use listen_messages and a native Monitor instead.`, { session, stage: "feed", detail: started.detail });
344
377
  }
378
+ this.feeds.set(key, feed);
379
+ live?.stop();
345
380
  this.last = { key, session, bay };
346
381
  // Approvals for `baychat claude` / `baychat start` (approve-hook --session-from-claude): this
347
382
  // Claude Code is now this session. Only for the default Bay, the one the hook's device login
348
383
  // asks; a session on another Bay keeps its prompts in the terminal (and clears an older link,
349
384
  // so a card never goes out in a name this Claude Code has moved on from).
350
385
  if (bay === undefined)
351
- this.deps.claudeLink?.write(session);
386
+ this.deps.claudeLink?.write(session, "open");
352
387
  else
353
388
  this.deps.claudeLink?.remove(undefined);
354
389
  return toolResult("active", `Active: session "${session}"${bayName ? ` (${bayName})` : ""} now hears the Bay through the channel. New messages ` +
@@ -357,6 +392,19 @@ class ChannelService {
357
392
  `to catch up on anything from before listening.` +
358
393
  (link ? ` ${link.describeReach()}` : ""), { session, ...(bayName ? { bay: bayName } : {}), how: evidence.how, commands: link?.state.kind ?? "off" });
359
394
  }
395
+ /** `feed.start()`, again after a failure for now; a final one (a gone session) answers at once. */
396
+ async startWithRetries(feed, session) {
397
+ const sleep = this.deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
398
+ let started = await feed.start();
399
+ for (const wait of exports.LISTEN_RETRY_MS) {
400
+ if (started.ok || started.fatal)
401
+ return started;
402
+ this.deps.log(`listen ${session}: ${started.detail}; trying again in ${wait / 1000}s`);
403
+ await sleep(wait);
404
+ started = await feed.start();
405
+ }
406
+ return started;
407
+ }
360
408
  /**
361
409
  * Register this session's commands with the relay (relay-link.ts). A fresh start's note
362
410
  * request comes back over it and is handed to the session as an event — the relay drops a
@@ -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
  }