baychat 0.35.4 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/approve-hook.js +18 -4
- package/dist/channel/channel-check.js +53 -0
- package/dist/channel/claude-link.js +9 -4
- package/dist/channel/feed.js +59 -5
- package/dist/channel/server.js +60 -12
- package/dist/denied/claims.js +75 -0
- package/dist/denied/compound-command.js +51 -0
- package/dist/denied/opt-in.js +30 -16
- package/dist/denied/permission-rule.js +10 -9
- package/dist/denied-hook.js +26 -1
- package/dist/launch/command.js +15 -4
- package/dist/launch/policy.js +12 -2
- package/dist/main.js +4 -0
- package/dist/mcp.js +3 -0
- package/dist/permissions-command.js +8 -5
- package/dist/protocol-content.js +1 -1
- package/dist/relay/launcher/approval-settings.js +2 -2
- package/dist/relay/launcher/handle.js +41 -0
- package/dist/relay/launcher/loop.js +9 -2
- package/dist/runtimes.js +4 -0
- package/dist/tool-defs.js +20 -3
- package/hooks/hooks.json +30 -0
- package/package.json +2 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "baychat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.0",
|
|
4
4
|
"displayName": "BayChat",
|
|
5
5
|
"description": "Wakes this Claude Code session the moment a BayChat message arrives: no Monitor, no 30-minute deadline. Uses the baychat CLI already on this computer (npm install -g baychat, then baychat login).",
|
|
6
6
|
"author": {
|
package/dist/approve-hook.js
CHANGED
|
@@ -40,6 +40,7 @@ exports.describeToolRequest = describeToolRequest;
|
|
|
40
40
|
exports.composeQuestion = composeQuestion;
|
|
41
41
|
exports.decideApproval = decideApproval;
|
|
42
42
|
exports.sessionFromClaude = sessionFromClaude;
|
|
43
|
+
exports.linkFromClaude = linkFromClaude;
|
|
43
44
|
exports.procStatFields = procStatFields;
|
|
44
45
|
exports.procStartTicks = procStartTicks;
|
|
45
46
|
exports.psParentOf = psParentOf;
|
|
@@ -103,6 +104,7 @@ const approval_wait_1 = require("./approval-wait");
|
|
|
103
104
|
const config_1 = require("./config");
|
|
104
105
|
const hook_stdin_1 = require("./hook-stdin");
|
|
105
106
|
const claude_link_1 = require("./channel/claude-link");
|
|
107
|
+
const compound_command_1 = require("./denied/compound-command");
|
|
106
108
|
function allowDecision() {
|
|
107
109
|
return {
|
|
108
110
|
hookSpecificOutput: {
|
|
@@ -210,6 +212,12 @@ function describeToolRequest(toolName, toolInput) {
|
|
|
210
212
|
return "(its parameters could not be displayed)";
|
|
211
213
|
}
|
|
212
214
|
}
|
|
215
|
+
/** A shell command that is more than one, as the shell reads it (denied/compound-command.ts). */
|
|
216
|
+
function isChained(toolName, toolInput) {
|
|
217
|
+
if (toolName !== "Bash")
|
|
218
|
+
return false;
|
|
219
|
+
return (0, compound_command_1.compoundKind)(str((toolInput ?? {}).command)) !== null;
|
|
220
|
+
}
|
|
213
221
|
/** The question the card asks. Blunt on purpose: this is a shell on a server, read on a phone,
|
|
214
222
|
* and the safe answer has to be the obvious one. */
|
|
215
223
|
function composeQuestion(params) {
|
|
@@ -225,6 +233,7 @@ function composeQuestion(params) {
|
|
|
225
233
|
params.cwd ? `Working directory: ${params.cwd}` : "",
|
|
226
234
|
params.permissionMode ? `Permission mode: ${params.permissionMode}` : "",
|
|
227
235
|
"",
|
|
236
|
+
isChained(params.toolName, params.toolInput) ? compound_command_1.CHAINED_LINE : "",
|
|
228
237
|
"Allowing runs it on that machine, now. If you did not expect this, deny.",
|
|
229
238
|
].filter((l) => l !== undefined);
|
|
230
239
|
return clip(lines.join("\n").replace(/\n{3,}/g, "\n\n").trim(), QUESTION_MAX);
|
|
@@ -291,7 +300,7 @@ async function decideApproval(rawStdin, argv = [], deps = {}) {
|
|
|
291
300
|
// re-arms across bounded holds until somebody taps (approval-wait.ts). Every outcome that is
|
|
292
301
|
// not "answered with index 0" becomes a deny below.
|
|
293
302
|
const outcome = await (0, approval_wait_1.askOwnerAndWait)(device, { session: opts.session, question, options: [...exports.APPROVE_OPTIONS] }, deadline, { now, sleep, log }, "baychat approve-hook");
|
|
294
|
-
return verdictOf(outcome, opts.waitSeconds);
|
|
303
|
+
return verdictOf(outcome, opts.waitSeconds, isChained(toolName, input.tool_input));
|
|
295
304
|
}
|
|
296
305
|
catch (err) {
|
|
297
306
|
// The catch that makes the guarantee true. Anything at all — a thrown non-Error, an OOM in a
|
|
@@ -309,12 +318,13 @@ async function decideApproval(rawStdin, argv = [], deps = {}) {
|
|
|
309
318
|
* asymmetry is the point: a future server change can make this hook over-refuse, and can never
|
|
310
319
|
* make it over-permit.
|
|
311
320
|
*/
|
|
312
|
-
function verdictOf(outcome, waitSeconds) {
|
|
321
|
+
function verdictOf(outcome, waitSeconds, chained = false) {
|
|
313
322
|
switch (outcome.kind) {
|
|
314
323
|
case "answered":
|
|
315
324
|
if (outcome.index === ALLOW_INDEX)
|
|
316
325
|
return allowDecision();
|
|
317
|
-
|
|
326
|
+
// The deny's message is what Claude reads: the way out of a refused chain, not a retry of it.
|
|
327
|
+
return deny(`answered "${outcome.answer ?? exports.APPROVE_OPTIONS[1]}" in BayChat.`, chained ? compound_command_1.SPLIT_HINT : "");
|
|
318
328
|
case "cancelled":
|
|
319
329
|
return deny("the decision was withdrawn in BayChat without an answer.");
|
|
320
330
|
case "deadline":
|
|
@@ -341,6 +351,10 @@ exports.MAX_CLAUDE_HOPS = 4;
|
|
|
341
351
|
* Claude Code's own dialog.
|
|
342
352
|
*/
|
|
343
353
|
function sessionFromClaude(deps = {}) {
|
|
354
|
+
return linkFromClaude(deps)?.session;
|
|
355
|
+
}
|
|
356
|
+
/** The channel's record for the Claude Code this hook runs under (claude-link.ts). Never throws. */
|
|
357
|
+
function linkFromClaude(deps = {}) {
|
|
344
358
|
const parentOf = deps.parentOf ?? psParentOf;
|
|
345
359
|
const isAlive = deps.isAlive ?? claude_link_1.pidAlive;
|
|
346
360
|
const readLink = deps.readLink ?? ((pid) => (0, claude_link_1.readClaudeLink)(pid));
|
|
@@ -357,7 +371,7 @@ function sessionFromClaude(deps = {}) {
|
|
|
357
371
|
const written = Date.parse(link.writtenAt);
|
|
358
372
|
const stale = age !== undefined && Number.isFinite(written) && written < now() - age - 5_000;
|
|
359
373
|
if (!stale)
|
|
360
|
-
return link
|
|
374
|
+
return link;
|
|
361
375
|
}
|
|
362
376
|
pid = parentOf(pid);
|
|
363
377
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FEED_RETRYING_HINT = exports.FEED_DOWN_HINT = void 0;
|
|
4
|
+
exports.channelCheckLine = channelCheckLine;
|
|
5
|
+
exports.cmdChannelCheck = cmdChannelCheck;
|
|
6
|
+
/**
|
|
7
|
+
* `baychat channel-check` — the SessionStart hook (matcher `compact`) that says when this session's
|
|
8
|
+
* BayChat feed is down after a compact (Chat_claude, 29 Oct: the feed died silently at a compact and
|
|
9
|
+
* 15 minutes of @mentions went unheard). A SessionStart hook's output reaches the model; a
|
|
10
|
+
* PostCompact one's does not, and `context-hook` runs async and prints nothing on purpose.
|
|
11
|
+
*
|
|
12
|
+
* Only for a Claude Code started with the BayChat channel (detect.ts). Its channel server records
|
|
13
|
+
* the feed's health in `claude-sessions/<pid>.json` (claude-link.ts): no record, or one that says
|
|
14
|
+
* `retrying`, prints one line telling the session to call `listen`. NEVER FAILS OR BLOCKS: any
|
|
15
|
+
* error reading either is treated as "down" for a channel session and prints the hint; exit is 0.
|
|
16
|
+
*/
|
|
17
|
+
const approve_hook_1 = require("../approve-hook");
|
|
18
|
+
const detect_1 = require("./detect");
|
|
19
|
+
exports.FEED_DOWN_HINT = "BayChat feed down: call the BayChat channel's listen tool with your session name, then get_messages to catch up.";
|
|
20
|
+
exports.FEED_RETRYING_HINT = "BayChat feed down (retrying in the background): call the BayChat channel's listen tool with your session name, then get_messages to catch up.";
|
|
21
|
+
/** The line to print, or undefined when there is nothing to say. Never throws. */
|
|
22
|
+
function channelCheckLine(deps = {}) {
|
|
23
|
+
let active = false;
|
|
24
|
+
try {
|
|
25
|
+
active = (deps.detect ?? (() => (0, detect_1.detectChannel)()))().active;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
if (!active)
|
|
31
|
+
return undefined;
|
|
32
|
+
let link;
|
|
33
|
+
try {
|
|
34
|
+
link = (deps.link ?? approve_hook_1.linkFromClaude)();
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return exports.FEED_DOWN_HINT;
|
|
38
|
+
}
|
|
39
|
+
if (!link)
|
|
40
|
+
return exports.FEED_DOWN_HINT;
|
|
41
|
+
return link.feed === "retrying" ? exports.FEED_RETRYING_HINT : undefined;
|
|
42
|
+
}
|
|
43
|
+
async function cmdChannelCheck(deps = {}) {
|
|
44
|
+
try {
|
|
45
|
+
const line = channelCheckLine(deps);
|
|
46
|
+
if (line)
|
|
47
|
+
(deps.out ?? ((l) => console.log(l)))(line);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Nothing: a hook that fails must never stand in the way of the session.
|
|
51
|
+
}
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
@@ -69,7 +69,11 @@ function writeClaudeLink(pid, session, opts = {}) {
|
|
|
69
69
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
70
70
|
sweepDeadLinks(dir, opts.isAlive ?? pidAlive);
|
|
71
71
|
const file = claudeLinkPath(pid, dir);
|
|
72
|
-
const record = {
|
|
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
|
-
|
|
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.
|
package/dist/channel/feed.js
CHANGED
|
@@ -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.
|
|
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})`);
|
package/dist/channel/server.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.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
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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
|
-
|
|
336
|
-
const started = await
|
|
353
|
+
live?.expectReplacement();
|
|
354
|
+
const started = await this.startWithRetries(feed, session);
|
|
337
355
|
if (!started.ok) {
|
|
338
|
-
this.
|
|
339
|
-
|
|
356
|
+
this.deps.log(`listen ${session}: the feed did not start: ${started.detail}`);
|
|
357
|
+
if (live) {
|
|
358
|
+
// The feed that was running carries on; only the new one is given up.
|
|
359
|
+
live.cancelReplacement();
|
|
360
|
+
return toolResult("active", `Active: a new feed could not start (${started.detail}), so the feed already running for session "${session}" ` +
|
|
361
|
+
`keeps going. Messages still arrive as <channel> events; do NOT open a Monitor or call listen_messages.`, { session, ...(bayName ? { bay: bayName } : {}), kept: true, detail: started.detail });
|
|
362
|
+
}
|
|
363
|
+
if (!started.fatal) {
|
|
364
|
+
// Nothing else runs for this session: retry in the background rather than go quiet.
|
|
365
|
+
this.feeds.set(key, feed);
|
|
366
|
+
this.last = { key, session, bay };
|
|
367
|
+
feed.retryInBackground(started.detail);
|
|
368
|
+
return toolResult("retrying", `Retrying: the BayChat feed for session "${session}" could not start (${started.detail}). It keeps retrying in the ` +
|
|
369
|
+
`background, and a <channel event="reset"> says when it is back — then catch up with get_messages. Until then, ` +
|
|
370
|
+
`read get_messages when you expect a message. Do NOT open a Monitor: it would compete with this feed.`, { session, ...(bayName ? { bay: bayName } : {}), stage: "feed", detail: started.detail });
|
|
371
|
+
}
|
|
372
|
+
if (!reused)
|
|
373
|
+
this.closeCommandLink(key, link);
|
|
340
374
|
if (this.last?.key === key)
|
|
341
375
|
this.last = undefined;
|
|
342
|
-
this.deps.log(`listen ${session}: the feed did not start: ${started.detail}`);
|
|
343
376
|
return toolResult("failed", `Failed: ${started.detail} Use listen_messages and a native Monitor instead.`, { session, stage: "feed", detail: started.detail });
|
|
344
377
|
}
|
|
378
|
+
this.feeds.set(key, feed);
|
|
379
|
+
live?.stop();
|
|
345
380
|
this.last = { key, session, bay };
|
|
346
381
|
// Approvals for `baychat claude` / `baychat start` (approve-hook --session-from-claude): this
|
|
347
382
|
// Claude Code is now this session. Only for the default Bay, the one the hook's device login
|
|
348
383
|
// asks; a session on another Bay keeps its prompts in the terminal (and clears an older link,
|
|
349
384
|
// so a card never goes out in a name this Claude Code has moved on from).
|
|
350
385
|
if (bay === undefined)
|
|
351
|
-
this.deps.claudeLink?.write(session);
|
|
386
|
+
this.deps.claudeLink?.write(session, "open");
|
|
352
387
|
else
|
|
353
388
|
this.deps.claudeLink?.remove(undefined);
|
|
354
389
|
return toolResult("active", `Active: session "${session}"${bayName ? ` (${bayName})` : ""} now hears the Bay through the channel. New messages ` +
|
|
@@ -357,6 +392,19 @@ class ChannelService {
|
|
|
357
392
|
`to catch up on anything from before listening.` +
|
|
358
393
|
(link ? ` ${link.describeReach()}` : ""), { session, ...(bayName ? { bay: bayName } : {}), how: evidence.how, commands: link?.state.kind ?? "off" });
|
|
359
394
|
}
|
|
395
|
+
/** `feed.start()`, again after a failure for now; a final one (a gone session) answers at once. */
|
|
396
|
+
async startWithRetries(feed, session) {
|
|
397
|
+
const sleep = this.deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
398
|
+
let started = await feed.start();
|
|
399
|
+
for (const wait of exports.LISTEN_RETRY_MS) {
|
|
400
|
+
if (started.ok || started.fatal)
|
|
401
|
+
return started;
|
|
402
|
+
this.deps.log(`listen ${session}: ${started.detail}; trying again in ${wait / 1000}s`);
|
|
403
|
+
await sleep(wait);
|
|
404
|
+
started = await feed.start();
|
|
405
|
+
}
|
|
406
|
+
return started;
|
|
407
|
+
}
|
|
360
408
|
/**
|
|
361
409
|
* Register this session's commands with the relay (relay-link.ts). A fresh start's note
|
|
362
410
|
* request comes back over it and is handed to the session as an event — the relay drops a
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.claimDeniedEvent = claimDeniedEvent;
|
|
37
|
+
// One card per denial. A session BayChat launched has its own PermissionDenied hook (in its
|
|
38
|
+
// `--settings` file) AND the BayChat plugin's (hooks/hooks.json), and Claude Code runs both on
|
|
39
|
+
// the same event: the first to create the event's claim file asks; the other leaves it.
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const path = __importStar(require("path"));
|
|
42
|
+
const config_1 = require("../config");
|
|
43
|
+
/** Claims older than this are swept on the next claim: a denial is answered within its hook's wait. */
|
|
44
|
+
const CLAIM_TTL_MS = 2 * 24 * 3600_000;
|
|
45
|
+
const SAFE_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
46
|
+
const claimsDir = () => path.join((0, config_1.configDir)(), "denied-claims");
|
|
47
|
+
/** True for the first hook to claim `toolUseId`. An id that is no safe file name claims nothing. */
|
|
48
|
+
function claimDeniedEvent(toolUseId, nowMs = Date.now()) {
|
|
49
|
+
if (!SAFE_ID.test(toolUseId))
|
|
50
|
+
return false;
|
|
51
|
+
const dir = claimsDir();
|
|
52
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
53
|
+
sweep(dir, nowMs);
|
|
54
|
+
try {
|
|
55
|
+
fs.writeFileSync(path.join(dir, toolUseId), "", { flag: "wx", mode: 0o600 });
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
if (err.code === "EEXIST")
|
|
60
|
+
return false;
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function sweep(dir, nowMs) {
|
|
65
|
+
for (const name of fs.readdirSync(dir)) {
|
|
66
|
+
try {
|
|
67
|
+
const file = path.join(dir, name);
|
|
68
|
+
if (nowMs - fs.statSync(file).mtimeMs > CLAIM_TTL_MS)
|
|
69
|
+
fs.rmSync(file, { force: true });
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
/* gone already, or another hook's: the next sweep tries again */
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Is a shell command ONE command, or several? Read the way a shell reads it — quotes and
|
|
3
|
+
// backslashes first — never by searching the text for `&&`: `echo "a && b"` is one command.
|
|
4
|
+
//
|
|
5
|
+
// It matters because Claude Code checks a chain subcommand by subcommand (permissions docs,
|
|
6
|
+
// "Compound commands"): a chain can only be allowed once, and the chain as typed never matches
|
|
7
|
+
// the rules saved for it. Pure: no files, no network.
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.SPLIT_HINT = exports.CHAINED_LINE = void 0;
|
|
10
|
+
exports.compoundKind = compoundKind;
|
|
11
|
+
/** Why `command` is more than one command ("nested" wins over "chain"), or null when it is one. Never throws. */
|
|
12
|
+
function compoundKind(command) {
|
|
13
|
+
let quote = null;
|
|
14
|
+
let chain = false;
|
|
15
|
+
for (let i = 0; i < command.length; i++) {
|
|
16
|
+
const c = command[i];
|
|
17
|
+
const next = command[i + 1];
|
|
18
|
+
if (quote === "'") {
|
|
19
|
+
if (c === "'")
|
|
20
|
+
quote = null;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (c === "\\") {
|
|
24
|
+
i++;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
// The shell still runs a substitution inside double quotes.
|
|
28
|
+
if (c === "`" || (c === "$" && next === "("))
|
|
29
|
+
return "nested";
|
|
30
|
+
if (quote === '"') {
|
|
31
|
+
if (c === '"')
|
|
32
|
+
quote = null;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (c === "'" || c === '"')
|
|
36
|
+
quote = c;
|
|
37
|
+
else if (c === "(" || c === ")")
|
|
38
|
+
return "nested";
|
|
39
|
+
else if (c === ";" || c === "|" || c === "\n" || c === "\r")
|
|
40
|
+
chain = true;
|
|
41
|
+
// `&&` is a chain; a lone `&` sends to the background; `2>&1` and `&>` are redirections.
|
|
42
|
+
else if (c === "&" && (next === "&" || (command[i - 1] !== ">" && next !== ">")))
|
|
43
|
+
chain = true;
|
|
44
|
+
}
|
|
45
|
+
return chain ? "chain" : null;
|
|
46
|
+
}
|
|
47
|
+
/** The card's line for a command that is more than one (approve-hook, permission-rule). */
|
|
48
|
+
exports.CHAINED_LINE = "This is a chained command: it can only be allowed once.";
|
|
49
|
+
/** What the agent is told when a chain is refused: Claude Code reads a PermissionRequest deny's message. */
|
|
50
|
+
exports.SPLIT_HINT = "It was a chained command: do not retry the chain. Run single commands, or write a script to a file " +
|
|
51
|
+
"and run it with one command, using absolute paths.";
|
package/dist/denied/opt-in.js
CHANGED
|
@@ -33,36 +33,50 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.DENIED_CARD_DEFAULT = void 0;
|
|
37
|
+
exports.deniedCardMode = deniedCardMode;
|
|
36
38
|
exports.deniedCardEnabled = deniedCardEnabled;
|
|
37
39
|
exports.enableDeniedCard = enableDeniedCard;
|
|
38
40
|
exports.disableDeniedCard = disableDeniedCard;
|
|
39
41
|
// Whether this computer's owner turned the denied-command card on (`baychat permissions on`).
|
|
40
42
|
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// `~/.baychat`, read when BayChat's
|
|
44
|
-
// (relay/launcher/approval-settings.ts),
|
|
43
|
+
// "ALLOW ONCE" UNTIL SOMEBODY ASKS FOR MORE: the card lets a phone add a permission rule on this
|
|
44
|
+
// computer, so keeping one ("Always") needs a deliberate act at the terminal; `DENIED_CARD_DEFAULT`
|
|
45
|
+
// is the owner's call for "nobody chose". The switch is a file in `~/.baychat`, read when BayChat's
|
|
46
|
+
// launcher writes a session's `--settings` file (relay/launcher/approval-settings.ts), and by the
|
|
47
|
+
// plugin's own hook on each denial (denied-hook --plugin), for sessions started by hand.
|
|
45
48
|
const fs = __importStar(require("fs"));
|
|
46
49
|
const path = __importStar(require("path"));
|
|
47
50
|
const config_1 = require("../config");
|
|
48
51
|
const optInFile = () => path.join((0, config_1.configDir)(), "denied-card.json");
|
|
49
|
-
/**
|
|
50
|
-
|
|
52
|
+
/** WHEN NOBODY CHOSE (no switch file): the owner's decision, kept as one line. Karmen, 2026-10-29: on,
|
|
53
|
+
* "Allow once" only — each tap allows one exact command one time; "Always" needs `baychat permissions on`. */
|
|
54
|
+
exports.DENIED_CARD_DEFAULT = "once";
|
|
55
|
+
/** The switch: what its file says, the default without one, off on any doubt (unreadable, malformed). */
|
|
56
|
+
function deniedCardMode() {
|
|
51
57
|
try {
|
|
52
|
-
const
|
|
53
|
-
return
|
|
58
|
+
const enabled = JSON.parse(fs.readFileSync(optInFile(), "utf8"))?.enabled;
|
|
59
|
+
return enabled === true ? "on" : "off";
|
|
54
60
|
}
|
|
55
61
|
catch (err) {
|
|
56
|
-
if (err.code
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
return
|
|
62
|
+
if (err.code === "ENOENT")
|
|
63
|
+
return exports.DENIED_CARD_DEFAULT;
|
|
64
|
+
process.stderr.write(`baychat: ${optInFile()} could not be read, so the denied-command card is off.\n`);
|
|
65
|
+
return "off";
|
|
60
66
|
}
|
|
61
67
|
}
|
|
68
|
+
/** True only when the owner turned the card on, Always included. */
|
|
69
|
+
function deniedCardEnabled() {
|
|
70
|
+
return deniedCardMode() === "on";
|
|
71
|
+
}
|
|
62
72
|
function enableDeniedCard(now = new Date()) {
|
|
63
|
-
|
|
64
|
-
|
|
73
|
+
writeSwitch(true, now);
|
|
74
|
+
}
|
|
75
|
+
/** Written, not deleted: an explicit off outranks any default. */
|
|
76
|
+
function disableDeniedCard(now = new Date()) {
|
|
77
|
+
writeSwitch(false, now);
|
|
65
78
|
}
|
|
66
|
-
function
|
|
67
|
-
fs.
|
|
79
|
+
function writeSwitch(enabled, now) {
|
|
80
|
+
fs.mkdirSync((0, config_1.configDir)(), { recursive: true, mode: 0o700 });
|
|
81
|
+
fs.writeFileSync(optInFile(), `${JSON.stringify({ enabled, since: now.toISOString() }, null, 2)}\n`, { mode: 0o600 });
|
|
68
82
|
}
|