baychat 0.36.0 → 0.38.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.36.0",
3
+ "version": "0.38.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": {
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.askOwnerAndWait = askOwnerAndWait;
4
+ exports.waitOnOwnerQuestion = waitOnOwnerQuestion;
4
5
  exports.withdrawOwnerQuestion = withdrawOwnerQuestion;
5
6
  exports.reasonOf = reasonOf;
6
7
  // Asking this machine's owner a decision card, and waiting for the tap.
@@ -114,6 +115,19 @@ async function waitForAnswer(device, requestId, first, deadlineMs, deps, label)
114
115
  }
115
116
  }
116
117
  }
118
+ /**
119
+ * Park on a card that already exists until it is answered, withdrawn, unreachable or past
120
+ * `deadlineMs` — the re-arm half of `askOwnerAndWait`, for a waiter that checks something else
121
+ * between windows (notify-answer.ts looks at the terminal). NEVER THROWS.
122
+ */
123
+ async function waitOnOwnerQuestion(device, requestId, deadlineMs, deps, label) {
124
+ try {
125
+ return await waitForAnswer(device, requestId, { status: "waiting" }, deadlineMs, deps, label);
126
+ }
127
+ catch (err) {
128
+ return { kind: "failed", reason: `waiting for BayChat failed unexpectedly — ${reasonOf(err)}` };
129
+ }
130
+ }
117
131
  /**
118
132
  * Take back a card nobody will act on any more (the waiting process is about to give up). Best
119
133
  * effort and NEVER THROWS: a card left pending is harmless — the answer would reach nobody — so
@@ -33,19 +33,21 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.MAX_CLAUDE_HOPS = exports.readStdin = exports.APPROVE_OPTIONS = exports.RECOMMENDED_HANDLER_TIMEOUT_SECONDS = exports.DEFAULT_WAIT_SECONDS = void 0;
36
+ exports.LINK_LOST_NOTICE = exports.MAX_CLAUDE_HOPS = exports.readStdin = exports.APPROVE_OPTIONS = exports.RECOMMENDED_HANDLER_TIMEOUT_SECONDS = exports.DEFAULT_WAIT_SECONDS = void 0;
37
37
  exports.allowDecision = allowDecision;
38
38
  exports.denyDecision = denyDecision;
39
39
  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;
46
47
  exports.psAgeMs = psAgeMs;
47
48
  exports.parseEtime = parseEtime;
48
49
  exports.decideApprovalOrDefer = decideApprovalOrDefer;
50
+ exports.noDecisionNotice = noDecisionNotice;
49
51
  exports.cmdApproveHook = cmdApproveHook;
50
52
  // `baychat approve-hook` — the Claude Code PermissionRequest hook that moves a terminal
51
53
  // permission prompt onto a phone, as a card in the owner's private chat with the session.
@@ -88,7 +90,8 @@ exports.cmdApproveHook = cmdApproveHook;
88
90
  // THE ONE EXCEPTION: `--session-from-claude` before the session has joined. `baychat claude` and
89
91
  // `baychat start` install this hook before the session has a name; until the channel records the
90
92
  // name (channel/claude-link.ts) there is no session a card could belong to. Then — and only then —
91
- // this prints NOTHING and exits 0, and Claude Code shows its own terminal dialog. That is not an
93
+ // this prints no decision and exits 0, and Claude Code shows its own terminal dialog (in a channel
94
+ // session that lost its link, with a `systemMessage` saying why: `noDecisionNotice`). That is not an
92
95
  // allow: it is the ordinary permission prompt, answered by the person who just ran the command at
93
96
  // that very terminal. Every other path, including every failure once a session WAS found, still
94
97
  // ends in an explicit decision.
@@ -103,7 +106,9 @@ const approval_wait_1 = require("./approval-wait");
103
106
  const config_1 = require("./config");
104
107
  const hook_stdin_1 = require("./hook-stdin");
105
108
  const claude_link_1 = require("./channel/claude-link");
109
+ const detect_1 = require("./channel/detect");
106
110
  const compound_command_1 = require("./denied/compound-command");
111
+ const notify_state_1 = require("./notify-state");
107
112
  function allowDecision() {
108
113
  return {
109
114
  hookSpecificOutput: {
@@ -165,6 +170,17 @@ function str(value) {
165
170
  function clip(text, max) {
166
171
  return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
167
172
  }
173
+ /** `https://claude.ai/artifact/abc?x=1` → `claude.ai/artifact`; not a URL → "". */
174
+ function pageOf(url) {
175
+ try {
176
+ const u = new URL(url);
177
+ const first = u.pathname.split("/").find(Boolean);
178
+ return first ? `${u.host}/${first}` : u.host;
179
+ }
180
+ catch {
181
+ return "";
182
+ }
183
+ }
168
184
  /**
169
185
  * What this tool call would actually do, in the words the person on the phone needs.
170
186
  *
@@ -196,6 +212,18 @@ function describeToolRequest(toolName, toolInput) {
196
212
  return str(input.url);
197
213
  case "WebSearch":
198
214
  return str(input.query);
215
+ case "Artifact": {
216
+ // A shared page names itself by host and first path part only (vps, 29 Oct): enough to tell
217
+ // which page; a query string or the rest of the path can carry a key.
218
+ const action = str(input.action) || "use";
219
+ const url = str(input.url);
220
+ if (url) {
221
+ const page = pageOf(url);
222
+ return action === "read" ? (page ? `read the shared page ${page}` : "read a shared page") : `${action} ${page || "a shared page"}`;
223
+ }
224
+ const file = str(input.file_path);
225
+ return file ? `${action} ${file}` : action;
226
+ }
199
227
  default:
200
228
  return "";
201
229
  }
@@ -298,7 +326,17 @@ async function decideApproval(rawStdin, argv = [], deps = {}) {
298
326
  // The card goes to the private chat between this session and its owner, and the wait
299
327
  // re-arms across bounded holds until somebody taps (approval-wait.ts). Every outcome that is
300
328
  // not "answered with index 0" becomes a deny below.
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");
329
+ // While the card is on the phone, notify-hook stays quiet for this session: one prompt, one
330
+ // thing on the phone (notify-state.ts).
331
+ const claudeSession = str(input.session_id);
332
+ (0, notify_state_1.markCardPending)(claudeSession);
333
+ let outcome;
334
+ 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");
336
+ }
337
+ finally {
338
+ (0, notify_state_1.clearCardPending)(claudeSession);
339
+ }
302
340
  return verdictOf(outcome, opts.waitSeconds, isChained(toolName, input.tool_input));
303
341
  }
304
342
  catch (err) {
@@ -350,6 +388,10 @@ exports.MAX_CLAUDE_HOPS = 4;
350
388
  * Claude Code's own dialog.
351
389
  */
352
390
  function sessionFromClaude(deps = {}) {
391
+ return linkFromClaude(deps)?.session;
392
+ }
393
+ /** The channel's record for the Claude Code this hook runs under (claude-link.ts). Never throws. */
394
+ function linkFromClaude(deps = {}) {
353
395
  const parentOf = deps.parentOf ?? psParentOf;
354
396
  const isAlive = deps.isAlive ?? claude_link_1.pidAlive;
355
397
  const readLink = deps.readLink ?? ((pid) => (0, claude_link_1.readClaudeLink)(pid));
@@ -366,7 +408,7 @@ function sessionFromClaude(deps = {}) {
366
408
  const written = Date.parse(link.writtenAt);
367
409
  const stale = age !== undefined && Number.isFinite(written) && written < now() - age - 5_000;
368
410
  if (!stale)
369
- return link.session;
411
+ return link;
370
412
  }
371
413
  pid = parentOf(pid);
372
414
  }
@@ -443,13 +485,29 @@ async function decideApprovalOrDefer(rawStdin, argv = [], deps = {}) {
443
485
  return null;
444
486
  return decideApproval(rawStdin, ["--session", session, ...rest], deps);
445
487
  }
488
+ /**
489
+ * What a hook with NO decision tells the person at the terminal (Branding, 29 Oct): a session
490
+ * started with BayChat's channel whose link is gone (claude-link.ts) still asks in the terminal,
491
+ * but now says why its question never reached the phone and what fixes it. A session that never
492
+ * joined BayChat's channel says nothing, as before. `systemMessage` only: no decision object, so
493
+ * Claude Code's own dialog still asks — never an allow by this code. Never throws.
494
+ */
495
+ exports.LINK_LOST_NOTICE = "BayChat can't reach your phone for this question: the session lost its link. Answer here, then type: re-listen to BayChat";
496
+ function noDecisionNotice(deps = {}) {
497
+ try {
498
+ return (deps.detect ?? (() => (0, detect_1.detectChannel)()))().active ? { systemMessage: exports.LINK_LOST_NOTICE } : undefined;
499
+ }
500
+ catch {
501
+ return undefined;
502
+ }
503
+ }
446
504
  /**
447
505
  * `baychat approve-hook [--session <name> | --session-from-claude] [--timeout <seconds>]`
448
506
  *
449
507
  * Always returns 0. A non-zero exit is a non-blocking error to Claude Code, which means the tool
450
508
  * call PROCEEDS — so exiting 1 on failure would be precisely backwards.
451
509
  */
452
- async function cmdApproveHook(argv = [], stdin = process.stdin) {
510
+ async function cmdApproveHook(argv = [], stdin = process.stdin, deps = {}) {
453
511
  let decision;
454
512
  try {
455
513
  decision = await decideApprovalOrDefer(await (0, hook_stdin_1.readStdin)(stdin), argv);
@@ -460,10 +518,15 @@ async function cmdApproveHook(argv = [], stdin = process.stdin) {
460
518
  process.stderr.write(`baychat approve-hook: ${err instanceof Error ? err.message : String(err)}\n`);
461
519
  decision = denyClosed("the approval gate could not run");
462
520
  }
463
- // No decision: `--session-from-claude` before the session joined — Claude Code's own dialog.
464
- if (decision === null)
521
+ // No decision: `--session-from-claude` with no session found — Claude Code's own dialog, plus a
522
+ // line saying why when this is a channel session that lost its link.
523
+ if (decision === null) {
524
+ const notice = noDecisionNotice(deps);
525
+ if (notice)
526
+ process.stdout.write(`${JSON.stringify(notice)}\n`);
465
527
  return 0;
466
- // The single write to stdout, and the only one in this file.
528
+ }
529
+ // The decision's write to stdout (the notice above is the only other, and carries no decision).
467
530
  process.stdout.write(`${JSON.stringify(decision)}\n`);
468
531
  return 0;
469
532
  }
@@ -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
package/dist/cli-help.js CHANGED
@@ -174,6 +174,11 @@ Usage:
174
174
  project's .claude/settings.local.json and let
175
175
  the session retry. Prints the retry or nothing;
176
176
  every error leaves the denial standing
177
+ baychat notify-hook [--dry-run] Claude Code Notification hook (the plugin's): when a
178
+ permission dialog or an MCP form waits in a
179
+ terminal, put one line on your phone saying which
180
+ session waits and on what. Watch-only; prints
181
+ nothing. --dry-run: the line to stderr instead
177
182
  baychat permissions [list | remove <id> | on | off]
178
183
  The denied-command card: on/off for sessions
179
184
  BayChat starts (OFF by default), and the rules
@@ -226,7 +231,7 @@ function helpRequest(command, args) {
226
231
  * - `claude` passes every argument to Claude Code — `baychat claude --help` is Claude Code's help;
227
232
  * - `send` and `search` take free text, which may say "--help";
228
233
  * - `approve-hook` must write exactly one decision object to stdout, whatever it is given, and
229
- * `denied-hook` / `denied-after` must never print help where Claude Code reads a hook's output;
234
+ * `denied-hook` / `denied-after` / `notify-hook` must never print help where Claude Code reads a hook's output;
230
235
  * - `help` (and its spellings) already prints help, and its arguments are a question.
231
236
  */
232
237
  const ARGS_ARE_NOT_OURS = new Set([
@@ -236,6 +241,8 @@ const ARGS_ARE_NOT_OURS = new Set([
236
241
  "approve-hook",
237
242
  "denied-hook",
238
243
  "denied-after",
244
+ "notify-hook",
245
+ "notify-answer",
239
246
  "help",
240
247
  "--help",
241
248
  "-h",
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.trustByHand = exports.CODEX_UNATTENDED_NOTICE = exports.APPROVALS_NOTICE = exports.LAUNCH_USAGE = void 0;
36
+ exports.trustByHand = exports.CODEX_UNATTENDED_NOTICE = exports.AGENT_STARTS_NOTICE = exports.APPROVALS_NOTICE = exports.LAUNCH_USAGE = void 0;
37
37
  exports.cmdLaunch = cmdLaunch;
38
38
  const path = __importStar(require("path"));
39
39
  const args_1 = require("../args");
@@ -41,13 +41,18 @@ const multiplexer_launch_1 = require("../multiplexer-launch");
41
41
  const policy_1 = require("../relay/acp/policy");
42
42
  const claude_trust_1 = require("./claude-trust");
43
43
  const policy_2 = require("./policy");
44
- exports.LAUNCH_USAGE = `Usage: baychat launch add <folder> [--label <label>] [--runtime claude,codex] [--max <n>] [--unattended]
44
+ exports.LAUNCH_USAGE = `Usage: baychat launch add <folder> [--label <label>] [--runtime claude,codex] [--max <n>] [--unattended] [--agent-starts]
45
45
  Let BayChat start sessions in <folder> from the app
46
46
  (--unattended: also let it start Codex there, which then never asks)
47
+ (--agent-starts: agents you allowed "Always" may start sessions there without asking you each time)
47
48
  baychat launch list The folders BayChat may start sessions in
48
49
  baychat launch remove <id> Stop offering one of them`;
49
50
  /** Printed by every `add`: the one thing a person must know before saying yes. */
50
51
  exports.APPROVALS_NOTICE = "Claude Code sessions started here from BayChat ask for approvals in BayChat.";
52
+ /** Printed by an `add … --agent-starts`: what the second key opens, and what it never does. */
53
+ exports.AGENT_STARTS_NOTICE = "Agents you allowed with \"Always for this agent here\" on their card may start sessions here without asking you each time — " +
54
+ "only for this folder, computer and runtime, for 30 days, and only with no task or one of your own messages as the task: a task an agent or anyone else writes still asks. " +
55
+ "Those sessions ask for approvals in BayChat like any other; they never start Codex unattended. Revoke an agent in Settings → Devices.";
51
56
  /** Printed when Codex is offered: what it may do without asking, and what it asks on the phone. */
52
57
  exports.CODEX_UNATTENDED_NOTICE = "Codex started here from BayChat is run by the relay (Codex 0.160 or newer): it can change files in this folder without asking, with no network, " +
53
58
  "and asks for approval in BayChat — on the session owner's phone — before anything more (a command outside its sandbox, network access, files elsewhere). " +
@@ -89,6 +94,7 @@ async function addSpot(args, deps) {
89
94
  const runtimeList = (0, args_1.flag)(args, "--runtime");
90
95
  const maxRaw = (0, args_1.flag)(args, "--max");
91
96
  const unattended = args.includes("--unattended");
97
+ const agentStarts = args.includes("--agent-starts");
92
98
  const valued = new Set([label, runtimeList, maxRaw].filter((v) => v !== undefined));
93
99
  const folderArg = args.find((a) => !a.startsWith("--") && !valued.has(a));
94
100
  if (unattended && !(runtimeList ?? "").split(",").map((r) => r.trim()).includes("codex")) {
@@ -127,11 +133,14 @@ async function addSpot(args, deps) {
127
133
  runtimes: [...new Set(requested)],
128
134
  maxSessions,
129
135
  ...(unattended ? { unattendedCodex: true } : {}),
136
+ ...(agentStarts ? { agentStarts: true } : {}),
130
137
  };
131
138
  (0, policy_2.saveLaunchPolicy)((0, policy_2.upsertSpot)(policy, spot), deps.policyFile);
132
139
  deps.print(`${existing ? "Updated" : "Added"} "${spot.label}" (${spot.id}): ${spot.path}`);
133
140
  deps.print(` Agents: ${spot.runtimes.join(", ")} · at most ${spot.maxSessions} at once`);
134
141
  deps.print(` ${exports.APPROVALS_NOTICE}`);
142
+ if (spot.agentStarts)
143
+ deps.print(` ${exports.AGENT_STARTS_NOTICE}`);
135
144
  if (spot.runtimes.includes("codex")) {
136
145
  deps.print(spot.unattendedCodex
137
146
  ? ` ${exports.CODEX_UNATTENDED_NOTICE}`
@@ -176,8 +185,10 @@ function listSpots(deps) {
176
185
  deps.print("BayChat may not start sessions on this computer. Add a folder with `baychat launch add <folder>`.");
177
186
  return 0;
178
187
  }
179
- for (const s of policy.spots)
180
- deps.print(`${s.id} "${s.label}" ${s.path} [${s.runtimes.join(", ")}] max ${s.maxSessions}`);
188
+ for (const s of policy.spots) {
189
+ const flags = [s.unattendedCodex ? "unattended Codex" : "", s.agentStarts ? "agent starts" : ""].filter(Boolean);
190
+ deps.print(`${s.id} "${s.label}" ${s.path} [${s.runtimes.join(", ")}] max ${s.maxSessions}${flags.length ? ` (${flags.join(", ")})` : ""}`);
191
+ }
181
192
  return 0;
182
193
  }
183
194
  function removeSpotById(args, deps) {