baychat 0.37.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.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/approval-wait.js +14 -0
- package/dist/approve-hook.js +65 -7
- package/dist/cli-help.js +8 -1
- package/dist/main.js +9 -0
- package/dist/notify-answer.js +196 -0
- package/dist/notify-hook.js +227 -0
- package/dist/notify-state.js +136 -0
- package/hooks/hooks.json +14 -2
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "baychat",
|
|
3
|
-
"version": "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": {
|
package/dist/approval-wait.js
CHANGED
|
@@ -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
|
package/dist/approve-hook.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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`
|
|
469
|
-
|
|
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
|
-
|
|
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
|
@@ -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",
|
package/dist/main.js
CHANGED
|
@@ -7,6 +7,8 @@ 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");
|
|
12
14
|
const update_command_1 = require("./update-command");
|
|
@@ -338,6 +340,13 @@ async function main() {
|
|
|
338
340
|
return await (0, permissions_command_1.cmdPermissions)(args);
|
|
339
341
|
case "context-hooks":
|
|
340
342
|
return await (0, context_hooks_command_1.cmdContextHooks)(args);
|
|
343
|
+
case "notify-hook":
|
|
344
|
+
// The plugin's Notification hook: a question stuck in the terminal → one line on the owner's
|
|
345
|
+
// phone. Never throws, always 0, prints nothing (notify-hook.ts).
|
|
346
|
+
return await (0, notify_hook_1.cmdNotifyHook)(args);
|
|
347
|
+
case "notify-answer":
|
|
348
|
+
// Started detached by notify-hook: answers one terminal dialog from the phone (notify-answer.ts).
|
|
349
|
+
return await (0, notify_answer_1.cmdNotifyAnswer)(args);
|
|
341
350
|
case "channel-check":
|
|
342
351
|
// The plugin's SessionStart (compact) hook. Never throws, always 0 (channel/channel-check.ts).
|
|
343
352
|
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
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
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.NOTICE_MAX = exports.CARD_PENDING_MAX_MS = exports.NOTIFY_MIN_GAP_MS = exports.NOTIFY_TYPES = void 0;
|
|
37
|
+
exports.lastToolUse = lastToolUse;
|
|
38
|
+
exports.decideNotify = decideNotify;
|
|
39
|
+
exports.cmdNotifyHook = cmdNotifyHook;
|
|
40
|
+
// `baychat notify-hook` — the stuck-question safety net (29 Oct).
|
|
41
|
+
//
|
|
42
|
+
// WHY THIS EXISTS. Branding's Claude Code sat on a terminal dialog ("Jelly Fish" was made by
|
|
43
|
+
// someone else. Let Claude read it?) and nothing reached Karmen's phone: its approve-hook could not
|
|
44
|
+
// name its session (the claude-sessions link was gone), so it left the prompt to the terminal in
|
|
45
|
+
// silence. Messages piled up unread, because Claude Code reads nothing while a question is open.
|
|
46
|
+
//
|
|
47
|
+
// WHAT CLAUDE CODE GIVES US: the `Notification` event, in every session, joined or not, about 6 s
|
|
48
|
+
// after a permission dialog or an MCP form appears (`permission_prompt`, `elicitation_dialog`,
|
|
49
|
+
// `elicitation_url_dialog`; reproduced 29 Oct). Watch-only: its output answers nothing, and its
|
|
50
|
+
// `message` does not hold the question ("Claude needs your permission"), so the question is read
|
|
51
|
+
// from the model's last tool call in the transcript. `idle_prompt` is not taken: it fires a minute
|
|
52
|
+
// after every normal answer.
|
|
53
|
+
//
|
|
54
|
+
// WHAT IT DOES: one line to the owner's phone — "<session> (<folder>) is waiting on a question in its
|
|
55
|
+
// terminal: <what>" — through `POST /api/device-api/notify` (the owner's private chat with BayBrain,
|
|
56
|
+
// a system line and a push). Once per prompt, at most one a minute per session, and nothing while
|
|
57
|
+
// approve-hook's own card for this session is on the phone (notify-state.ts). Prints nothing and
|
|
58
|
+
// exits 0 on every path: a Notification hook can block nothing, and must never stand in the way.
|
|
59
|
+
const child_process_1 = require("child_process");
|
|
60
|
+
const fs = __importStar(require("fs"));
|
|
61
|
+
const os = __importStar(require("os"));
|
|
62
|
+
const api_1 = require("./api");
|
|
63
|
+
const approve_hook_1 = require("./approve-hook");
|
|
64
|
+
const config_1 = require("./config");
|
|
65
|
+
const hook_stdin_1 = require("./hook-stdin");
|
|
66
|
+
const notify_answer_1 = require("./notify-answer");
|
|
67
|
+
const notify_state_1 = require("./notify-state");
|
|
68
|
+
const terminal_pane_1 = require("./relay/terminal-pane");
|
|
69
|
+
/** The notifications that mean "a question is open in the terminal". */
|
|
70
|
+
exports.NOTIFY_TYPES = ["permission_prompt", "elicitation_dialog", "elicitation_url_dialog"];
|
|
71
|
+
/** At most one notice a minute per Claude Code session. */
|
|
72
|
+
exports.NOTIFY_MIN_GAP_MS = 60_000;
|
|
73
|
+
/** A card marker older than this is a hook that died mid-wait, not a card on the phone. */
|
|
74
|
+
exports.CARD_PENDING_MAX_MS = 60 * 60_000;
|
|
75
|
+
/** The server's cap (terminal-notice.service.ts). */
|
|
76
|
+
exports.NOTICE_MAX = 300;
|
|
77
|
+
/** How much of the transcript's end is read for the last tool call. */
|
|
78
|
+
const TAIL_BYTES = 256 * 1024;
|
|
79
|
+
/** The model's last tool call in transcript JSONL text, or undefined. */
|
|
80
|
+
function lastToolUse(text) {
|
|
81
|
+
const lines = text.split("\n");
|
|
82
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
83
|
+
const line = lines[i];
|
|
84
|
+
if (!line.includes('"tool_use"'))
|
|
85
|
+
continue;
|
|
86
|
+
let entry;
|
|
87
|
+
try {
|
|
88
|
+
entry = JSON.parse(line);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
continue; // the cut-off first line of a tail, or a line being written
|
|
92
|
+
}
|
|
93
|
+
if (entry.type !== "assistant" || !Array.isArray(entry.message?.content))
|
|
94
|
+
continue;
|
|
95
|
+
const blocks = entry.message.content;
|
|
96
|
+
for (let j = blocks.length - 1; j >= 0; j--) {
|
|
97
|
+
const b = blocks[j];
|
|
98
|
+
if (b?.type === "tool_use" && typeof b.id === "string" && typeof b.name === "string")
|
|
99
|
+
return { id: b.id, name: b.name, input: b.input };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
function readTail(file) {
|
|
105
|
+
let fd;
|
|
106
|
+
try {
|
|
107
|
+
fd = fs.openSync(file, "r");
|
|
108
|
+
const size = fs.fstatSync(fd).size;
|
|
109
|
+
const length = Math.min(size, TAIL_BYTES);
|
|
110
|
+
const buf = Buffer.alloc(length);
|
|
111
|
+
fs.readSync(fd, buf, 0, length, size - length);
|
|
112
|
+
return buf.toString("utf8");
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
if (fd !== undefined)
|
|
119
|
+
fs.closeSync(fd);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const str = (v) => (typeof v === "string" ? v : "");
|
|
123
|
+
/** `/home/k/projects/branding` → `~/projects/branding`. */
|
|
124
|
+
function shortPath(cwd) {
|
|
125
|
+
const home = os.homedir();
|
|
126
|
+
return cwd && home && (cwd === home || cwd.startsWith(`${home}/`)) ? `~${cwd.slice(home.length)}` : cwd;
|
|
127
|
+
}
|
|
128
|
+
function clip(text, max) {
|
|
129
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
130
|
+
}
|
|
131
|
+
async function postNotice(device, text) {
|
|
132
|
+
await (0, api_1.apiRequest)(device, "POST", "/api/device-api/notify", { text });
|
|
133
|
+
}
|
|
134
|
+
/** This process's own tmux pane (the hook runs inside the session), as text; undefined elsewhere. */
|
|
135
|
+
function readOwnPane() {
|
|
136
|
+
const handle = (0, terminal_pane_1.detectPane)();
|
|
137
|
+
if (handle?.kind !== "tmux")
|
|
138
|
+
return Promise.resolve(undefined);
|
|
139
|
+
return new Promise((resolve) => {
|
|
140
|
+
(0, child_process_1.execFile)("tmux", ["-S", handle.socket, "capture-pane", "-p", "-t", handle.pane], { timeout: 5_000, encoding: "utf8" }, (err, out) => resolve(err ? undefined : String(out)));
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/** `baychat notify-answer <session> <dialog>`, detached, in this session's environment (its pane). */
|
|
144
|
+
function startAnswerProcess(session, dialog) {
|
|
145
|
+
const entry = process.argv[1];
|
|
146
|
+
if (!entry)
|
|
147
|
+
return false;
|
|
148
|
+
try {
|
|
149
|
+
const encoded = Buffer.from(JSON.stringify(dialog), "utf8").toString("base64");
|
|
150
|
+
(0, child_process_1.spawn)(process.execPath, [entry, "notify-answer", session, encoded], { detached: true, stdio: "ignore", env: process.env }).unref();
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** One hook run. NEVER THROWS: every failure is a skip with its reason. */
|
|
158
|
+
async function decideNotify(rawStdin, deps = {}) {
|
|
159
|
+
const now = deps.now ?? Date.now;
|
|
160
|
+
const opts = { dir: deps.stateDir };
|
|
161
|
+
try {
|
|
162
|
+
let input;
|
|
163
|
+
try {
|
|
164
|
+
input = JSON.parse(rawStdin);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return { skipped: "not a question" };
|
|
168
|
+
}
|
|
169
|
+
const type = str(input.notification_type);
|
|
170
|
+
if (input.hook_event_name !== "Notification" || !exports.NOTIFY_TYPES.includes(type))
|
|
171
|
+
return { skipped: "not a question" };
|
|
172
|
+
const sessionId = str(input.session_id);
|
|
173
|
+
const card = (0, notify_state_1.cardPendingSince)(sessionId, opts);
|
|
174
|
+
if (card !== undefined && now() - card < exports.CARD_PENDING_MAX_MS)
|
|
175
|
+
return { skipped: "a card is already on the phone" };
|
|
176
|
+
// What is being asked: the model's last tool call for a permission dialog, else Claude Code's own words.
|
|
177
|
+
const tool = type === "permission_prompt" ? lastToolUse(readTail(str(input.transcript_path)) ?? "") : undefined;
|
|
178
|
+
const what = tool ? (0, approve_hook_1.describeToolRequest)(tool.name, tool.input) : str(input.message) || "a question";
|
|
179
|
+
const key = tool ? `tool:${tool.id}` : `${type}:${str(input.message)}`;
|
|
180
|
+
const last = (0, notify_state_1.readLastNotice)(sessionId, opts);
|
|
181
|
+
if (last?.lastKey === key)
|
|
182
|
+
return { skipped: "already sent for this prompt" };
|
|
183
|
+
if (last && now() - last.lastAt < exports.NOTIFY_MIN_GAP_MS)
|
|
184
|
+
return { skipped: "one a minute per session" };
|
|
185
|
+
const device = (deps.loadDevice ?? config_1.loadDeviceCredentials)();
|
|
186
|
+
if (!device)
|
|
187
|
+
return { skipped: "not logged in" };
|
|
188
|
+
const session = (deps.link ?? approve_hook_1.linkFromClaude)()?.session;
|
|
189
|
+
// A session BayChat knows, in a tmux pane showing a numbered dialog: a card the owner can
|
|
190
|
+
// ANSWER, by a detached `notify-answer` that presses the chosen number (notify-answer.ts).
|
|
191
|
+
if (session) {
|
|
192
|
+
const screen = await (deps.readScreen ?? readOwnPane)();
|
|
193
|
+
const dialog = screen ? (0, notify_answer_1.readDialog)(screen) : undefined;
|
|
194
|
+
if (dialog && (deps.startAnswer ?? startAnswerProcess)(session, dialog)) {
|
|
195
|
+
(0, notify_state_1.writeLastNotice)(sessionId, { lastAt: now(), lastKey: key }, opts);
|
|
196
|
+
return { sent: true, answerable: true };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const who = session || "A Claude Code session";
|
|
200
|
+
const where = shortPath(str(input.cwd));
|
|
201
|
+
const head = `${who}${where ? ` (${where})` : ""} is waiting on a question in its terminal: `;
|
|
202
|
+
const text = clip(`${head}${what.replace(/\s+/g, " ").trim()}`, exports.NOTICE_MAX);
|
|
203
|
+
await (deps.post ?? postNotice)(device, text);
|
|
204
|
+
(0, notify_state_1.writeLastNotice)(sessionId, { lastAt: now(), lastKey: key }, opts);
|
|
205
|
+
return { sent: true };
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
return { skipped: err instanceof Error ? err.message : String(err) };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* `baychat notify-hook [--dry-run]` — always exits 0 and prints nothing; why it skipped goes to
|
|
213
|
+
* stderr. `--dry-run` writes the line it would send to stderr instead of sending it (and still
|
|
214
|
+
* needs a login, so it shows the real path up to the request).
|
|
215
|
+
*/
|
|
216
|
+
async function cmdNotifyHook(argv = [], stdin = process.stdin) {
|
|
217
|
+
try {
|
|
218
|
+
const dryRun = argv.includes("--dry-run");
|
|
219
|
+
const outcome = await decideNotify(await (0, hook_stdin_1.readStdin)(stdin), dryRun ? { post: async (_d, text) => void process.stderr.write(`baychat notify-hook (dry run): ${text}\n`) } : {});
|
|
220
|
+
if ("skipped" in outcome && outcome.skipped !== "not a question")
|
|
221
|
+
process.stderr.write(`baychat notify-hook: ${outcome.skipped}\n`);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
// Nothing: a Notification hook must never stand in the way of the session.
|
|
225
|
+
}
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
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.notifyStateDir = void 0;
|
|
37
|
+
exports.stateKey = stateKey;
|
|
38
|
+
exports.markCardPending = markCardPending;
|
|
39
|
+
exports.clearCardPending = clearCardPending;
|
|
40
|
+
exports.cardPendingSince = cardPendingSince;
|
|
41
|
+
exports.readLastNotice = readLastNotice;
|
|
42
|
+
exports.writeLastNotice = writeLastNotice;
|
|
43
|
+
/**
|
|
44
|
+
* What notify-hook remembers per Claude Code session, under `<config>/notify-state/`:
|
|
45
|
+
*
|
|
46
|
+
* - `<session_id>.json` — the last notice sent: `{ lastAt, lastKey }`, for "once per prompt, at
|
|
47
|
+
* most once a minute" (notify-hook.ts).
|
|
48
|
+
* - `<session_id>.card` — approve-hook has a card on the owner's phone for this session right now.
|
|
49
|
+
* Written when the card goes out and removed when the hook is done, so the same prompt never
|
|
50
|
+
* reaches the phone twice (a card AND a "waiting" line). Its age bounds it: a hook killed
|
|
51
|
+
* mid-wait leaves a file that stops counting after an hour.
|
|
52
|
+
*
|
|
53
|
+
* Small files, private to the user, never read by anything else. Every function swallows its own
|
|
54
|
+
* errors: a hook's bookkeeping must never stand in the way of the session.
|
|
55
|
+
*/
|
|
56
|
+
const fs = __importStar(require("fs"));
|
|
57
|
+
const path = __importStar(require("path"));
|
|
58
|
+
const config_1 = require("./config");
|
|
59
|
+
const notifyStateDir = () => path.join((0, config_1.configDir)(), "notify-state");
|
|
60
|
+
exports.notifyStateDir = notifyStateDir;
|
|
61
|
+
/** A Claude Code session id as a file name: its own characters only, or nothing. */
|
|
62
|
+
function stateKey(sessionId) {
|
|
63
|
+
return /^[A-Za-z0-9_-]{1,128}$/.test(sessionId) ? sessionId : undefined;
|
|
64
|
+
}
|
|
65
|
+
function file(sessionId, ext, dir) {
|
|
66
|
+
const key = stateKey(sessionId);
|
|
67
|
+
if (!key)
|
|
68
|
+
return undefined;
|
|
69
|
+
try {
|
|
70
|
+
return path.join(dir ?? (0, exports.notifyStateDir)(), `${key}.${ext}`);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// No config directory to keep it in: no bookkeeping, never an error on approve-hook's path.
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function markCardPending(sessionId, opts = {}) {
|
|
78
|
+
const f = file(sessionId, "card", opts.dir);
|
|
79
|
+
if (!f)
|
|
80
|
+
return;
|
|
81
|
+
try {
|
|
82
|
+
fs.mkdirSync(path.dirname(f), { recursive: true, mode: 0o700 });
|
|
83
|
+
fs.writeFileSync(f, String((opts.now ?? Date.now)()), { mode: 0o600 });
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Bookkeeping only.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function clearCardPending(sessionId, opts = {}) {
|
|
90
|
+
const f = file(sessionId, "card", opts.dir);
|
|
91
|
+
if (!f)
|
|
92
|
+
return;
|
|
93
|
+
try {
|
|
94
|
+
fs.rmSync(f, { force: true });
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Bookkeeping only.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** When the pending card was marked, or undefined when there is none. */
|
|
101
|
+
function cardPendingSince(sessionId, opts = {}) {
|
|
102
|
+
const f = file(sessionId, "card", opts.dir);
|
|
103
|
+
if (!f)
|
|
104
|
+
return undefined;
|
|
105
|
+
try {
|
|
106
|
+
const at = Number(fs.readFileSync(f, "utf8").trim());
|
|
107
|
+
return Number.isFinite(at) ? at : undefined;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function readLastNotice(sessionId, opts = {}) {
|
|
114
|
+
const f = file(sessionId, "json", opts.dir);
|
|
115
|
+
if (!f)
|
|
116
|
+
return undefined;
|
|
117
|
+
try {
|
|
118
|
+
const v = JSON.parse(fs.readFileSync(f, "utf8"));
|
|
119
|
+
return typeof v.lastAt === "number" && typeof v.lastKey === "string" ? { lastAt: v.lastAt, lastKey: v.lastKey } : undefined;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function writeLastNotice(sessionId, notice, opts = {}) {
|
|
126
|
+
const f = file(sessionId, "json", opts.dir);
|
|
127
|
+
if (!f)
|
|
128
|
+
return;
|
|
129
|
+
try {
|
|
130
|
+
fs.mkdirSync(path.dirname(f), { recursive: true, mode: 0o700 });
|
|
131
|
+
fs.writeFileSync(f, JSON.stringify(notice), { mode: 0o600 });
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// Bookkeeping only: at worst the next prompt's notice is not rate-limited.
|
|
135
|
+
}
|
|
136
|
+
}
|
package/hooks/hooks.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "BayChat's denied-command card for every session, also ones started by hand: an auto-mode denial becomes a card in the session's BayChat chat once it has joined (docs/features/DENIED_COMMAND_CARD.md). After a compact, a channel session is told when its BayChat feed is down (channel-check). No PostToolUse hook: it would start baychat on every tool call.",
|
|
2
|
+
"description": "BayChat's denied-command card for every session, also ones started by hand: an auto-mode denial becomes a card in the session's BayChat chat once it has joined (docs/features/DENIED_COMMAND_CARD.md). After a compact, a channel session is told when its BayChat feed is down (channel-check). A question left open in the terminal (a permission dialog, an MCP form) becomes one line on the owner's phone (notify-hook). No PostToolUse hook: it would start baychat on every tool call.",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"PermissionDenied": [
|
|
5
5
|
{
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"type": "command",
|
|
10
10
|
"command": "baychat denied-hook --session-from-claude --plugin --timeout 3600",
|
|
11
11
|
"timeout": 3900,
|
|
12
|
-
"statusMessage": "Blocked by auto mode
|
|
12
|
+
"statusMessage": "Blocked by auto mode — asking in BayChat…"
|
|
13
13
|
}
|
|
14
14
|
]
|
|
15
15
|
}
|
|
@@ -25,6 +25,18 @@
|
|
|
25
25
|
}
|
|
26
26
|
]
|
|
27
27
|
}
|
|
28
|
+
],
|
|
29
|
+
"Notification": [
|
|
30
|
+
{
|
|
31
|
+
"matcher": "permission_prompt|elicitation_dialog|elicitation_url_dialog",
|
|
32
|
+
"hooks": [
|
|
33
|
+
{
|
|
34
|
+
"type": "command",
|
|
35
|
+
"command": "baychat notify-hook",
|
|
36
|
+
"timeout": 30
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
}
|
|
28
40
|
]
|
|
29
41
|
}
|
|
30
42
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "baychat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "BayChat connector CLI — pair an agent session (Claude Code, Codex, Cursor) with BayChat, or have the relay run DeepSeek or the Cursor CLI for you",
|
|
5
5
|
"bin": {
|
|
6
6
|
"baychat": "dist/index.js"
|