baychat 0.37.0 → 0.39.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.37.0",
3
+ "version": "0.39.0",
4
4
  "displayName": "BayChat",
5
5
  "description": "Wakes this Claude Code session the moment a BayChat message arrives: no Monitor, no 30-minute deadline. Uses the baychat CLI already on this computer (npm install -g baychat, then baychat login).",
6
6
  "author": {
package/README.md CHANGED
@@ -6,15 +6,20 @@
6
6
 
7
7
  **The BayChat connector.** Bring Claude Code, Codex, Cursor or an agent you built yourself into one
8
8
  group chat with you and your team. Your agents work together, ask before they act, and wait for your
9
- answer, from your phone if you're out. [baychat.io](https://baychat.io) · free with three agents.
9
+ answer, from your phone if you're out. [baychat.io](https://baychat.io) · free with one agent.
10
10
 
11
- ## Connect an agent in one line
11
+ ## One command
12
12
 
13
13
  ```bash
14
- npx baychat connect claude # Claude Code (also: codex · cursor · desktop)
14
+ npx baychat
15
15
  ```
16
16
 
17
- The first time, it shows a QR: approve it in the BayChat app and this computer is yours. Then, inside
17
+ The first time, it shows a QR: approve it in the BayChat app and this computer is yours. Then it sets up
18
+ every agent app it finds on this computer (Claude Code, Claude Desktop, Codex, Cursor), lists what is
19
+ ready, and starts a session when you run it inside a project folder. After that, `npx baychat` just starts
20
+ a session. Installed another app later? `npx baychat setup` looks again.
21
+
22
+ One app at a time still works: `npx baychat connect claude` (also `codex` · `cursor` · `desktop`). Inside
18
23
  Claude Code, join a room with `/baychat Scout "Launch week"`.
19
24
 
20
25
  **Your own agent**, anything that can run a shell command: in the app, *Add agent → Any agent via the CLI*
@@ -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,7 +33,7 @@ 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;
@@ -47,6 +47,7 @@ exports.psParentOf = psParentOf;
47
47
  exports.psAgeMs = psAgeMs;
48
48
  exports.parseEtime = parseEtime;
49
49
  exports.decideApprovalOrDefer = decideApprovalOrDefer;
50
+ exports.noDecisionNotice = noDecisionNotice;
50
51
  exports.cmdApproveHook = cmdApproveHook;
51
52
  // `baychat approve-hook` — the Claude Code PermissionRequest hook that moves a terminal
52
53
  // permission prompt onto a phone, as a card in the owner's private chat with the session.
@@ -89,7 +90,8 @@ exports.cmdApproveHook = cmdApproveHook;
89
90
  // THE ONE EXCEPTION: `--session-from-claude` before the session has joined. `baychat claude` and
90
91
  // `baychat start` install this hook before the session has a name; until the channel records the
91
92
  // name (channel/claude-link.ts) there is no session a card could belong to. Then — and only then —
92
- // 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
93
95
  // allow: it is the ordinary permission prompt, answered by the person who just ran the command at
94
96
  // that very terminal. Every other path, including every failure once a session WAS found, still
95
97
  // ends in an explicit decision.
@@ -104,7 +106,9 @@ const approval_wait_1 = require("./approval-wait");
104
106
  const config_1 = require("./config");
105
107
  const hook_stdin_1 = require("./hook-stdin");
106
108
  const claude_link_1 = require("./channel/claude-link");
109
+ const detect_1 = require("./channel/detect");
107
110
  const compound_command_1 = require("./denied/compound-command");
111
+ const notify_state_1 = require("./notify-state");
108
112
  function allowDecision() {
109
113
  return {
110
114
  hookSpecificOutput: {
@@ -166,6 +170,17 @@ function str(value) {
166
170
  function clip(text, max) {
167
171
  return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
168
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
+ }
169
184
  /**
170
185
  * What this tool call would actually do, in the words the person on the phone needs.
171
186
  *
@@ -197,6 +212,18 @@ function describeToolRequest(toolName, toolInput) {
197
212
  return str(input.url);
198
213
  case "WebSearch":
199
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
+ }
200
227
  default:
201
228
  return "";
202
229
  }
@@ -299,7 +326,17 @@ async function decideApproval(rawStdin, argv = [], deps = {}) {
299
326
  // The card goes to the private chat between this session and its owner, and the wait
300
327
  // re-arms across bounded holds until somebody taps (approval-wait.ts). Every outcome that is
301
328
  // not "answered with index 0" becomes a deny below.
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");
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
+ }
303
340
  return verdictOf(outcome, opts.waitSeconds, isChained(toolName, input.tool_input));
304
341
  }
305
342
  catch (err) {
@@ -448,13 +485,29 @@ async function decideApprovalOrDefer(rawStdin, argv = [], deps = {}) {
448
485
  return null;
449
486
  return decideApproval(rawStdin, ["--session", session, ...rest], deps);
450
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
+ }
451
504
  /**
452
505
  * `baychat approve-hook [--session <name> | --session-from-claude] [--timeout <seconds>]`
453
506
  *
454
507
  * Always returns 0. A non-zero exit is a non-blocking error to Claude Code, which means the tool
455
508
  * call PROCEEDS — so exiting 1 on failure would be precisely backwards.
456
509
  */
457
- async function cmdApproveHook(argv = [], stdin = process.stdin) {
510
+ async function cmdApproveHook(argv = [], stdin = process.stdin, deps = {}) {
458
511
  let decision;
459
512
  try {
460
513
  decision = await decideApprovalOrDefer(await (0, hook_stdin_1.readStdin)(stdin), argv);
@@ -465,10 +518,15 @@ async function cmdApproveHook(argv = [], stdin = process.stdin) {
465
518
  process.stderr.write(`baychat approve-hook: ${err instanceof Error ? err.message : String(err)}\n`);
466
519
  decision = denyClosed("the approval gate could not run");
467
520
  }
468
- // No decision: `--session-from-claude` before the session joined — Claude Code's own dialog.
469
- 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`);
470
527
  return 0;
471
- // 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).
472
530
  process.stdout.write(`${JSON.stringify(decision)}\n`);
473
531
  return 0;
474
532
  }
package/dist/cli-help.js CHANGED
@@ -19,10 +19,15 @@ Exit 0: step succeeded; 2: awaiting approval or expired; 1: error.
19
19
  exports.HELP = `baychat — BayChat connector CLI for agent sessions (Claude Code, Codex)
20
20
 
21
21
  Usage:
22
- baychat Start a session. This is the whole thing: it runs
23
- your agent somewhere BayChat can reach, so /clear
24
- and /new from the app work. Sign in once with
25
- \`baychat login\` first
22
+ baychat Start a session: the one command. The first time it
23
+ signs this computer in (one QR on your phone) and sets
24
+ up every agent app it finds — Claude Code, Claude
25
+ Desktop, Codex, Cursor — then starts a session if you
26
+ are in a project folder. After that it just starts a
27
+ session, somewhere BayChat can reach, so /clear and
28
+ /new from the app work
29
+ baychat setup Look again and set up any agent app installed since;
30
+ starts nothing
26
31
  baychat start [name] [--runtime <runtime>]
27
32
  Start a session (what bare \`baychat\` does), with
28
33
  a name for it
@@ -174,6 +179,11 @@ Usage:
174
179
  project's .claude/settings.local.json and let
175
180
  the session retry. Prints the retry or nothing;
176
181
  every error leaves the denial standing
182
+ baychat notify-hook [--dry-run] Claude Code Notification hook (the plugin's): when a
183
+ permission dialog or an MCP form waits in a
184
+ terminal, put one line on your phone saying which
185
+ session waits and on what. Watch-only; prints
186
+ nothing. --dry-run: the line to stderr instead
177
187
  baychat permissions [list | remove <id> | on | off]
178
188
  The denied-command card: on/off for sessions
179
189
  BayChat starts (OFF by default), and the rules
@@ -226,7 +236,7 @@ function helpRequest(command, args) {
226
236
  * - `claude` passes every argument to Claude Code — `baychat claude --help` is Claude Code's help;
227
237
  * - `send` and `search` take free text, which may say "--help";
228
238
  * - `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;
239
+ * `denied-hook` / `denied-after` / `notify-hook` must never print help where Claude Code reads a hook's output;
230
240
  * - `help` (and its spellings) already prints help, and its arguments are a question.
231
241
  */
232
242
  const ARGS_ARE_NOT_OURS = new Set([
@@ -236,6 +246,8 @@ const ARGS_ARE_NOT_OURS = new Set([
236
246
  "approve-hook",
237
247
  "denied-hook",
238
248
  "denied-after",
249
+ "notify-hook",
250
+ "notify-answer",
239
251
  "help",
240
252
  "--help",
241
253
  "-h",
package/dist/main.js CHANGED
@@ -7,8 +7,11 @@ const denied_hook_1 = require("./denied-hook");
7
7
  const permissions_command_1 = require("./permissions-command");
8
8
  const context_hooks_command_1 = require("./context-hooks-command");
9
9
  const channel_check_1 = require("./channel/channel-check");
10
+ const notify_hook_1 = require("./notify-hook");
11
+ const notify_answer_1 = require("./notify-answer");
10
12
  const session_name_1 = require("./session-name");
11
13
  const start_command_1 = require("./start-command");
14
+ const setup_command_1 = require("./setup-command");
12
15
  const update_command_1 = require("./update-command");
13
16
  const notice_1 = require("./auto-update/notice");
14
17
  const session_command_1 = require("./session-command");
@@ -56,13 +59,14 @@ function numberFlag(args, name) {
56
59
  }
57
60
  async function main() {
58
61
  const [command, ...args] = process.argv.slice(2);
59
- // BARE `baychat` STARTS A SESSION. The bar is "download BayChat, start a session, nothing
60
- // else", and a usage dump printed at somebody who typed the product's own name is the
61
- // "something else". `--help` still prints the help, and `start` refuses with one sentence
62
- // when this machine is not signed in, so nothing is launched from an unusable state.
62
+ // BARE `baychat` IS THE ONE COMMAND (31 Oct 2026, setup-command.ts). The first time it signs this
63
+ // computer in once and sets up every agent app it finds, then starts a session — only inside a
64
+ // project, never in the home folder. Once set up it starts straight away, as it always did. A usage
65
+ // dump printed at somebody who typed the product's own name would be "something else"; `--help`
66
+ // still prints the help.
63
67
  if (command === undefined) {
64
68
  (0, notice_1.printUpdateNotice)(command);
65
- return (0, start_command_1.cmdStart)([]);
69
+ return (0, setup_command_1.runBare)((0, setup_command_1.realSetupDeps)());
66
70
  }
67
71
  // "BayChat updated itself to …": the relay's one line for the terminal, shown once, on a command
68
72
  // a person typed (never into a hook's or an MCP server's output).
@@ -83,6 +87,10 @@ async function main() {
83
87
  return (0, session_command_1.cmdJoinSession)(args);
84
88
  case "start":
85
89
  return (0, start_command_1.cmdStart)(args);
90
+ case "setup":
91
+ // Look again and set up what is new (an app installed since); starts nothing.
92
+ (0, args_1.rejectUnknownFlags)(args, [], "baychat setup");
93
+ return (0, setup_command_1.runSetupCommand)((0, setup_command_1.realSetupDeps)());
86
94
  case "update":
87
95
  // `--quiet` is how an old version hands a `start`-driven update to the new one.
88
96
  (0, args_1.rejectUnknownFlags)(args, ["--quiet", "--auto"], "baychat update [--auto on|off]");
@@ -338,6 +346,13 @@ async function main() {
338
346
  return await (0, permissions_command_1.cmdPermissions)(args);
339
347
  case "context-hooks":
340
348
  return await (0, context_hooks_command_1.cmdContextHooks)(args);
349
+ case "notify-hook":
350
+ // The plugin's Notification hook: a question stuck in the terminal → one line on the owner's
351
+ // phone. Never throws, always 0, prints nothing (notify-hook.ts).
352
+ return await (0, notify_hook_1.cmdNotifyHook)(args);
353
+ case "notify-answer":
354
+ // Started detached by notify-hook: answers one terminal dialog from the phone (notify-answer.ts).
355
+ return await (0, notify_answer_1.cmdNotifyAnswer)(args);
341
356
  case "channel-check":
342
357
  // The plugin's SessionStart (compact) hook. Never throws, always 0 (channel/channel-check.ts).
343
358
  return await (0, channel_check_1.cmdChannelCheck)();
@@ -0,0 +1,196 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SETTLE_MS = exports.ANSWER_DEADLINE_MS = void 0;
4
+ exports.readDialog = readDialog;
5
+ exports.answerFromPhone = answerFromPhone;
6
+ exports.paneAnswerDeps = paneAnswerDeps;
7
+ exports.cmdNotifyAnswer = cmdNotifyAnswer;
8
+ // `baychat notify-answer` — answering a terminal dialog from the phone (vps, 29 Oct: "the keys").
9
+ //
10
+ // notify-hook starts this, detached, when the stuck question is in a session BayChat knows (its
11
+ // claude-sessions link) running in a tmux pane, and the pane shows a numbered dialog. It puts the
12
+ // dialog's own question and answers on a card in the session's private chat with its owner, and
13
+ // on a tap presses that ONE number in the pane — measured on the real dialog (29 Oct): a digit
14
+ // selects and confirms, no Enter.
15
+ //
16
+ // THE RULE THE WHOLE THING RESTS ON: a key is pressed only while `capture-pane` still shows THE
17
+ // SAME question. A `1` landing in an ordinary input line would be typed as text and sent as a
18
+ // message. So the pane is read again right before the key; a different screen sends nothing and
19
+ // says "already answered in the terminal". While waiting, the pane is looked at between windows:
20
+ // answered at the keyboard first → the card is taken back. Keys: the digits of the dialog's own
21
+ // options and nothing else — never text, never Enter, never a key a message could name. The card
22
+ // is answerable by the owner only (the device credential's own user), and the pane must still be
23
+ // running Claude Code (`verifyPane`).
24
+ const child_process_1 = require("child_process");
25
+ const api_1 = require("./api");
26
+ const approval_wait_1 = require("./approval-wait");
27
+ const config_1 = require("./config");
28
+ const terminal_pane_1 = require("./relay/terminal-pane");
29
+ /** How long the phone may take: the same hour a permission card waits. */
30
+ exports.ANSWER_DEADLINE_MS = 60 * 60_000;
31
+ /** One wait on the card between looks at the terminal. */
32
+ const WINDOW_MS = 20_000;
33
+ /** A breath between looks, so a card answered and a screen changing at the same moment settle. */
34
+ const GAP_MS = 1_000;
35
+ /**
36
+ * Between the two looks right before a key. Found on the real dialog (29 Oct): a screen read just
37
+ * after a key can still show the old dialog, and a `1` then landed in the input line.
38
+ */
39
+ exports.SETTLE_MS = 700;
40
+ const OPTION = /^\s*(?:❯\s*)?([1-9])\.\s+(\S.*?)\s*$/;
41
+ const DECORATION = /^[\s│╌─━┃┄┈|]*$/;
42
+ /**
43
+ * The open dialog on a captured screen, or undefined. A dialog is a run of options numbered from
44
+ * 1 with "Esc to cancel" right under it, and the question is the nearest line above ending in "?"
45
+ * (a conversation's own numbered list has no "Esc to cancel" under it, so it is never one).
46
+ */
47
+ function readDialog(screen) {
48
+ const lines = screen.split("\n");
49
+ for (let end = lines.length - 1; end >= 0; end--) {
50
+ if (!/Esc to cancel/.test(lines[end]))
51
+ continue;
52
+ // The options: the numbered run directly above "Esc to cancel".
53
+ const options = [];
54
+ let i = end - 1;
55
+ while (i >= 0 && DECORATION.test(lines[i]) && lines[i].trim() === "")
56
+ i--;
57
+ let first = i;
58
+ while (i >= 0 && OPTION.test(lines[i])) {
59
+ first = i;
60
+ i--;
61
+ }
62
+ for (let k = first; k < end; k++) {
63
+ const m = OPTION.exec(lines[k]);
64
+ if (m)
65
+ options.push(m[2]);
66
+ }
67
+ if (options.length === 0)
68
+ return undefined;
69
+ // In order from 1: "1. …", "2. …".
70
+ for (let k = first, n = 1; k < end; k++) {
71
+ const m = OPTION.exec(lines[k]);
72
+ if (!m)
73
+ continue;
74
+ if (Number(m[1]) !== n++)
75
+ return undefined;
76
+ }
77
+ for (let q = first - 1; q >= Math.max(0, first - 15); q--) {
78
+ const text = lines[q].trim();
79
+ if (text.endsWith("?") && !text.startsWith("│"))
80
+ return { question: text, options };
81
+ }
82
+ return undefined;
83
+ }
84
+ return undefined;
85
+ }
86
+ const sameDialog = (screen, dialog) => readDialog(screen)?.question === dialog.question;
87
+ /**
88
+ * One dialog, answered from the phone or left to the terminal. Returns what happened, for the log.
89
+ * NEVER THROWS (a failing capture counts as "the dialog is gone": send nothing).
90
+ */
91
+ async function answerFromPhone(run, deps) {
92
+ const { session, dialog } = run;
93
+ const capture = async () => {
94
+ try {
95
+ return await deps.capture();
96
+ }
97
+ catch {
98
+ return "";
99
+ }
100
+ };
101
+ const until = deps.now() + exports.ANSWER_DEADLINE_MS;
102
+ let outcome = await deps.ask({ session, question: `${session} is waiting on a question in its terminal:\n${dialog.question}`, options: dialog.options }, Math.min(until, deps.now() + WINDOW_MS));
103
+ for (;;) {
104
+ if (outcome.kind === "answered") {
105
+ const index = outcome.index;
106
+ if (index === null || !Number.isInteger(index) || index < 0 || index >= dialog.options.length)
107
+ return "not an answer";
108
+ // Right before the key: the same question on screen on two looks a settle apart, or nothing
109
+ // is sent.
110
+ const first = sameDialog(await capture(), dialog);
111
+ if (first)
112
+ await deps.sleep(exports.SETTLE_MS);
113
+ if (!first || !sameDialog(await capture(), dialog)) {
114
+ await deps.notice(`${session}: your answer was not sent. The question was already answered in the terminal.`);
115
+ return "already answered";
116
+ }
117
+ const key = String(index + 1);
118
+ const pressed = await deps.press(key);
119
+ return pressed.ok ? `sent ${key}` : `refused: ${pressed.reason}`;
120
+ }
121
+ if (outcome.kind !== "deadline")
122
+ return outcome.kind === "failed" ? `failed: ${outcome.reason}` : outcome.kind;
123
+ // Nobody tapped yet. Answered at the keyboard meanwhile? Then the card goes.
124
+ if (!sameDialog(await capture(), dialog)) {
125
+ await deps.withdraw(outcome.requestId);
126
+ return "answered in the terminal";
127
+ }
128
+ if (deps.now() >= until) {
129
+ await deps.withdraw(outcome.requestId);
130
+ return "no answer";
131
+ }
132
+ await deps.sleep(GAP_MS);
133
+ outcome = await deps.poll(outcome.requestId, Math.min(until, deps.now() + WINDOW_MS));
134
+ }
135
+ }
136
+ function tmux(handle, args) {
137
+ return new Promise((resolve, reject) => {
138
+ (0, child_process_1.execFile)("tmux", ["-S", handle.socket, ...args], { timeout: 5_000, encoding: "utf8" }, (err, out) => (err ? reject(err) : resolve(String(out))));
139
+ });
140
+ }
141
+ /** The real dependencies, for a tmux pane. */
142
+ function paneAnswerDeps(device, handle) {
143
+ const log = (line) => process.stderr.write(`${line}\n`);
144
+ const waitDeps = { now: Date.now, sleep: (ms) => new Promise((r) => setTimeout(r, ms)), log };
145
+ return {
146
+ capture: () => tmux(handle, ["capture-pane", "-p", "-t", handle.pane]),
147
+ press: async (key) => {
148
+ if (!/^[1-9]$/.test(key))
149
+ return { ok: false, reason: `"${key}" is not a dialog answer` };
150
+ const check = (0, terminal_pane_1.verifyPane)(handle, "claude");
151
+ if (!check.ok)
152
+ return check;
153
+ try {
154
+ // `-l`: the digit as a character, never read as a key name.
155
+ await tmux(handle, ["send-keys", "-t", handle.pane, "-l", "--", key]);
156
+ return { ok: true };
157
+ }
158
+ catch (err) {
159
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) };
160
+ }
161
+ },
162
+ ask: (q, deadline) => (0, approval_wait_1.askOwnerAndWait)(device, q, deadline, waitDeps, "baychat notify-answer"),
163
+ poll: (id, deadline) => (0, approval_wait_1.waitOnOwnerQuestion)(device, id, deadline, waitDeps, "baychat notify-answer"),
164
+ withdraw: (id) => (0, approval_wait_1.withdrawOwnerQuestion)(device, id, log),
165
+ notice: async (text) => {
166
+ try {
167
+ await (0, api_1.apiRequest)(device, "POST", "/api/device-api/notify", { text });
168
+ }
169
+ catch {
170
+ // The answer was not sent either way; the note is a courtesy.
171
+ }
172
+ },
173
+ now: Date.now,
174
+ sleep: waitDeps.sleep,
175
+ };
176
+ }
177
+ /**
178
+ * `baychat notify-answer <session> <base64 JSON dialog>` — started detached by notify-hook inside
179
+ * the session's own environment (so `$TMUX_PANE` is the session's pane). Exits 0 always.
180
+ */
181
+ async function cmdNotifyAnswer(argv = []) {
182
+ try {
183
+ const [session, encoded] = argv;
184
+ const dialog = JSON.parse(Buffer.from(encoded ?? "", "base64").toString("utf8"));
185
+ const handle = (0, terminal_pane_1.detectPane)();
186
+ const device = (0, config_1.loadDeviceCredentials)();
187
+ if (!session || !device || handle?.kind !== "tmux" || !dialog?.question || !Array.isArray(dialog.options))
188
+ return 0;
189
+ const outcome = await answerFromPhone({ session, dialog }, paneAnswerDeps(device, handle));
190
+ process.stderr.write(`baychat notify-answer: ${outcome}\n`);
191
+ }
192
+ catch (err) {
193
+ process.stderr.write(`baychat notify-answer: ${err instanceof Error ? err.message : String(err)}\n`);
194
+ }
195
+ return 0;
196
+ }