baychat 0.36.0 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/approval-wait.js +14 -0
- package/dist/approve-hook.js +71 -8
- 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/cli-help.js +8 -1
- package/dist/launch/command.js +15 -4
- package/dist/launch/policy.js +12 -2
- package/dist/main.js +13 -0
- package/dist/notify-answer.js +196 -0
- package/dist/notify-hook.js +227 -0
- package/dist/notify-state.js +136 -0
- package/dist/protocol-content.js +1 -1
- package/dist/relay/launcher/handle.js +41 -0
- package/dist/relay/launcher/loop.js +9 -2
- package/hooks/hooks.json +25 -1
- 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,19 +33,21 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.MAX_CLAUDE_HOPS = exports.readStdin = exports.APPROVE_OPTIONS = exports.RECOMMENDED_HANDLER_TIMEOUT_SECONDS = exports.DEFAULT_WAIT_SECONDS = void 0;
|
|
36
|
+
exports.LINK_LOST_NOTICE = exports.MAX_CLAUDE_HOPS = exports.readStdin = exports.APPROVE_OPTIONS = exports.RECOMMENDED_HANDLER_TIMEOUT_SECONDS = exports.DEFAULT_WAIT_SECONDS = void 0;
|
|
37
37
|
exports.allowDecision = allowDecision;
|
|
38
38
|
exports.denyDecision = denyDecision;
|
|
39
39
|
exports.describeToolRequest = describeToolRequest;
|
|
40
40
|
exports.composeQuestion = composeQuestion;
|
|
41
41
|
exports.decideApproval = decideApproval;
|
|
42
42
|
exports.sessionFromClaude = sessionFromClaude;
|
|
43
|
+
exports.linkFromClaude = linkFromClaude;
|
|
43
44
|
exports.procStatFields = procStatFields;
|
|
44
45
|
exports.procStartTicks = procStartTicks;
|
|
45
46
|
exports.psParentOf = psParentOf;
|
|
46
47
|
exports.psAgeMs = psAgeMs;
|
|
47
48
|
exports.parseEtime = parseEtime;
|
|
48
49
|
exports.decideApprovalOrDefer = decideApprovalOrDefer;
|
|
50
|
+
exports.noDecisionNotice = noDecisionNotice;
|
|
49
51
|
exports.cmdApproveHook = cmdApproveHook;
|
|
50
52
|
// `baychat approve-hook` — the Claude Code PermissionRequest hook that moves a terminal
|
|
51
53
|
// permission prompt onto a phone, as a card in the owner's private chat with the session.
|
|
@@ -88,7 +90,8 @@ exports.cmdApproveHook = cmdApproveHook;
|
|
|
88
90
|
// THE ONE EXCEPTION: `--session-from-claude` before the session has joined. `baychat claude` and
|
|
89
91
|
// `baychat start` install this hook before the session has a name; until the channel records the
|
|
90
92
|
// name (channel/claude-link.ts) there is no session a card could belong to. Then — and only then —
|
|
91
|
-
// this prints
|
|
93
|
+
// this prints no decision and exits 0, and Claude Code shows its own terminal dialog (in a channel
|
|
94
|
+
// session that lost its link, with a `systemMessage` saying why: `noDecisionNotice`). That is not an
|
|
92
95
|
// allow: it is the ordinary permission prompt, answered by the person who just ran the command at
|
|
93
96
|
// that very terminal. Every other path, including every failure once a session WAS found, still
|
|
94
97
|
// ends in an explicit decision.
|
|
@@ -103,7 +106,9 @@ const approval_wait_1 = require("./approval-wait");
|
|
|
103
106
|
const config_1 = require("./config");
|
|
104
107
|
const hook_stdin_1 = require("./hook-stdin");
|
|
105
108
|
const claude_link_1 = require("./channel/claude-link");
|
|
109
|
+
const detect_1 = require("./channel/detect");
|
|
106
110
|
const compound_command_1 = require("./denied/compound-command");
|
|
111
|
+
const notify_state_1 = require("./notify-state");
|
|
107
112
|
function allowDecision() {
|
|
108
113
|
return {
|
|
109
114
|
hookSpecificOutput: {
|
|
@@ -165,6 +170,17 @@ function str(value) {
|
|
|
165
170
|
function clip(text, max) {
|
|
166
171
|
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
167
172
|
}
|
|
173
|
+
/** `https://claude.ai/artifact/abc?x=1` → `claude.ai/artifact`; not a URL → "". */
|
|
174
|
+
function pageOf(url) {
|
|
175
|
+
try {
|
|
176
|
+
const u = new URL(url);
|
|
177
|
+
const first = u.pathname.split("/").find(Boolean);
|
|
178
|
+
return first ? `${u.host}/${first}` : u.host;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return "";
|
|
182
|
+
}
|
|
183
|
+
}
|
|
168
184
|
/**
|
|
169
185
|
* What this tool call would actually do, in the words the person on the phone needs.
|
|
170
186
|
*
|
|
@@ -196,6 +212,18 @@ function describeToolRequest(toolName, toolInput) {
|
|
|
196
212
|
return str(input.url);
|
|
197
213
|
case "WebSearch":
|
|
198
214
|
return str(input.query);
|
|
215
|
+
case "Artifact": {
|
|
216
|
+
// A shared page names itself by host and first path part only (vps, 29 Oct): enough to tell
|
|
217
|
+
// which page; a query string or the rest of the path can carry a key.
|
|
218
|
+
const action = str(input.action) || "use";
|
|
219
|
+
const url = str(input.url);
|
|
220
|
+
if (url) {
|
|
221
|
+
const page = pageOf(url);
|
|
222
|
+
return action === "read" ? (page ? `read the shared page ${page}` : "read a shared page") : `${action} ${page || "a shared page"}`;
|
|
223
|
+
}
|
|
224
|
+
const file = str(input.file_path);
|
|
225
|
+
return file ? `${action} ${file}` : action;
|
|
226
|
+
}
|
|
199
227
|
default:
|
|
200
228
|
return "";
|
|
201
229
|
}
|
|
@@ -298,7 +326,17 @@ async function decideApproval(rawStdin, argv = [], deps = {}) {
|
|
|
298
326
|
// The card goes to the private chat between this session and its owner, and the wait
|
|
299
327
|
// re-arms across bounded holds until somebody taps (approval-wait.ts). Every outcome that is
|
|
300
328
|
// not "answered with index 0" becomes a deny below.
|
|
301
|
-
|
|
329
|
+
// While the card is on the phone, notify-hook stays quiet for this session: one prompt, one
|
|
330
|
+
// thing on the phone (notify-state.ts).
|
|
331
|
+
const claudeSession = str(input.session_id);
|
|
332
|
+
(0, notify_state_1.markCardPending)(claudeSession);
|
|
333
|
+
let outcome;
|
|
334
|
+
try {
|
|
335
|
+
outcome = await (0, approval_wait_1.askOwnerAndWait)(device, { session: opts.session, question, options: [...exports.APPROVE_OPTIONS] }, deadline, { now, sleep, log }, "baychat approve-hook");
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
(0, notify_state_1.clearCardPending)(claudeSession);
|
|
339
|
+
}
|
|
302
340
|
return verdictOf(outcome, opts.waitSeconds, isChained(toolName, input.tool_input));
|
|
303
341
|
}
|
|
304
342
|
catch (err) {
|
|
@@ -350,6 +388,10 @@ exports.MAX_CLAUDE_HOPS = 4;
|
|
|
350
388
|
* Claude Code's own dialog.
|
|
351
389
|
*/
|
|
352
390
|
function sessionFromClaude(deps = {}) {
|
|
391
|
+
return linkFromClaude(deps)?.session;
|
|
392
|
+
}
|
|
393
|
+
/** The channel's record for the Claude Code this hook runs under (claude-link.ts). Never throws. */
|
|
394
|
+
function linkFromClaude(deps = {}) {
|
|
353
395
|
const parentOf = deps.parentOf ?? psParentOf;
|
|
354
396
|
const isAlive = deps.isAlive ?? claude_link_1.pidAlive;
|
|
355
397
|
const readLink = deps.readLink ?? ((pid) => (0, claude_link_1.readClaudeLink)(pid));
|
|
@@ -366,7 +408,7 @@ function sessionFromClaude(deps = {}) {
|
|
|
366
408
|
const written = Date.parse(link.writtenAt);
|
|
367
409
|
const stale = age !== undefined && Number.isFinite(written) && written < now() - age - 5_000;
|
|
368
410
|
if (!stale)
|
|
369
|
-
return link
|
|
411
|
+
return link;
|
|
370
412
|
}
|
|
371
413
|
pid = parentOf(pid);
|
|
372
414
|
}
|
|
@@ -443,13 +485,29 @@ async function decideApprovalOrDefer(rawStdin, argv = [], deps = {}) {
|
|
|
443
485
|
return null;
|
|
444
486
|
return decideApproval(rawStdin, ["--session", session, ...rest], deps);
|
|
445
487
|
}
|
|
488
|
+
/**
|
|
489
|
+
* What a hook with NO decision tells the person at the terminal (Branding, 29 Oct): a session
|
|
490
|
+
* started with BayChat's channel whose link is gone (claude-link.ts) still asks in the terminal,
|
|
491
|
+
* but now says why its question never reached the phone and what fixes it. A session that never
|
|
492
|
+
* joined BayChat's channel says nothing, as before. `systemMessage` only: no decision object, so
|
|
493
|
+
* Claude Code's own dialog still asks — never an allow by this code. Never throws.
|
|
494
|
+
*/
|
|
495
|
+
exports.LINK_LOST_NOTICE = "BayChat can't reach your phone for this question: the session lost its link. Answer here, then type: re-listen to BayChat";
|
|
496
|
+
function noDecisionNotice(deps = {}) {
|
|
497
|
+
try {
|
|
498
|
+
return (deps.detect ?? (() => (0, detect_1.detectChannel)()))().active ? { systemMessage: exports.LINK_LOST_NOTICE } : undefined;
|
|
499
|
+
}
|
|
500
|
+
catch {
|
|
501
|
+
return undefined;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
446
504
|
/**
|
|
447
505
|
* `baychat approve-hook [--session <name> | --session-from-claude] [--timeout <seconds>]`
|
|
448
506
|
*
|
|
449
507
|
* Always returns 0. A non-zero exit is a non-blocking error to Claude Code, which means the tool
|
|
450
508
|
* call PROCEEDS — so exiting 1 on failure would be precisely backwards.
|
|
451
509
|
*/
|
|
452
|
-
async function cmdApproveHook(argv = [], stdin = process.stdin) {
|
|
510
|
+
async function cmdApproveHook(argv = [], stdin = process.stdin, deps = {}) {
|
|
453
511
|
let decision;
|
|
454
512
|
try {
|
|
455
513
|
decision = await decideApprovalOrDefer(await (0, hook_stdin_1.readStdin)(stdin), argv);
|
|
@@ -460,10 +518,15 @@ async function cmdApproveHook(argv = [], stdin = process.stdin) {
|
|
|
460
518
|
process.stderr.write(`baychat approve-hook: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
461
519
|
decision = denyClosed("the approval gate could not run");
|
|
462
520
|
}
|
|
463
|
-
// No decision: `--session-from-claude`
|
|
464
|
-
|
|
521
|
+
// No decision: `--session-from-claude` with no session found — Claude Code's own dialog, plus a
|
|
522
|
+
// line saying why when this is a channel session that lost its link.
|
|
523
|
+
if (decision === null) {
|
|
524
|
+
const notice = noDecisionNotice(deps);
|
|
525
|
+
if (notice)
|
|
526
|
+
process.stdout.write(`${JSON.stringify(notice)}\n`);
|
|
465
527
|
return 0;
|
|
466
|
-
|
|
528
|
+
}
|
|
529
|
+
// The decision's write to stdout (the notice above is the only other, and carries no decision).
|
|
467
530
|
process.stdout.write(`${JSON.stringify(decision)}\n`);
|
|
468
531
|
return 0;
|
|
469
532
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FEED_RETRYING_HINT = exports.FEED_DOWN_HINT = void 0;
|
|
4
|
+
exports.channelCheckLine = channelCheckLine;
|
|
5
|
+
exports.cmdChannelCheck = cmdChannelCheck;
|
|
6
|
+
/**
|
|
7
|
+
* `baychat channel-check` — the SessionStart hook (matcher `compact`) that says when this session's
|
|
8
|
+
* BayChat feed is down after a compact (Chat_claude, 29 Oct: the feed died silently at a compact and
|
|
9
|
+
* 15 minutes of @mentions went unheard). A SessionStart hook's output reaches the model; a
|
|
10
|
+
* PostCompact one's does not, and `context-hook` runs async and prints nothing on purpose.
|
|
11
|
+
*
|
|
12
|
+
* Only for a Claude Code started with the BayChat channel (detect.ts). Its channel server records
|
|
13
|
+
* the feed's health in `claude-sessions/<pid>.json` (claude-link.ts): no record, or one that says
|
|
14
|
+
* `retrying`, prints one line telling the session to call `listen`. NEVER FAILS OR BLOCKS: any
|
|
15
|
+
* error reading either is treated as "down" for a channel session and prints the hint; exit is 0.
|
|
16
|
+
*/
|
|
17
|
+
const approve_hook_1 = require("../approve-hook");
|
|
18
|
+
const detect_1 = require("./detect");
|
|
19
|
+
exports.FEED_DOWN_HINT = "BayChat feed down: call the BayChat channel's listen tool with your session name, then get_messages to catch up.";
|
|
20
|
+
exports.FEED_RETRYING_HINT = "BayChat feed down (retrying in the background): call the BayChat channel's listen tool with your session name, then get_messages to catch up.";
|
|
21
|
+
/** The line to print, or undefined when there is nothing to say. Never throws. */
|
|
22
|
+
function channelCheckLine(deps = {}) {
|
|
23
|
+
let active = false;
|
|
24
|
+
try {
|
|
25
|
+
active = (deps.detect ?? (() => (0, detect_1.detectChannel)()))().active;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
if (!active)
|
|
31
|
+
return undefined;
|
|
32
|
+
let link;
|
|
33
|
+
try {
|
|
34
|
+
link = (deps.link ?? approve_hook_1.linkFromClaude)();
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return exports.FEED_DOWN_HINT;
|
|
38
|
+
}
|
|
39
|
+
if (!link)
|
|
40
|
+
return exports.FEED_DOWN_HINT;
|
|
41
|
+
return link.feed === "retrying" ? exports.FEED_RETRYING_HINT : undefined;
|
|
42
|
+
}
|
|
43
|
+
async function cmdChannelCheck(deps = {}) {
|
|
44
|
+
try {
|
|
45
|
+
const line = channelCheckLine(deps);
|
|
46
|
+
if (line)
|
|
47
|
+
(deps.out ?? ((l) => console.log(l)))(line);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Nothing: a hook that fails must never stand in the way of the session.
|
|
51
|
+
}
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
@@ -69,7 +69,11 @@ function writeClaudeLink(pid, session, opts = {}) {
|
|
|
69
69
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
70
70
|
sweepDeadLinks(dir, opts.isAlive ?? pidAlive);
|
|
71
71
|
const file = claudeLinkPath(pid, dir);
|
|
72
|
-
const record = {
|
|
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
|
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/launch/command.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.trustByHand = exports.CODEX_UNATTENDED_NOTICE = exports.APPROVALS_NOTICE = exports.LAUNCH_USAGE = void 0;
|
|
36
|
+
exports.trustByHand = exports.CODEX_UNATTENDED_NOTICE = exports.AGENT_STARTS_NOTICE = exports.APPROVALS_NOTICE = exports.LAUNCH_USAGE = void 0;
|
|
37
37
|
exports.cmdLaunch = cmdLaunch;
|
|
38
38
|
const path = __importStar(require("path"));
|
|
39
39
|
const args_1 = require("../args");
|
|
@@ -41,13 +41,18 @@ const multiplexer_launch_1 = require("../multiplexer-launch");
|
|
|
41
41
|
const policy_1 = require("../relay/acp/policy");
|
|
42
42
|
const claude_trust_1 = require("./claude-trust");
|
|
43
43
|
const policy_2 = require("./policy");
|
|
44
|
-
exports.LAUNCH_USAGE = `Usage: baychat launch add <folder> [--label <label>] [--runtime claude,codex] [--max <n>] [--unattended]
|
|
44
|
+
exports.LAUNCH_USAGE = `Usage: baychat launch add <folder> [--label <label>] [--runtime claude,codex] [--max <n>] [--unattended] [--agent-starts]
|
|
45
45
|
Let BayChat start sessions in <folder> from the app
|
|
46
46
|
(--unattended: also let it start Codex there, which then never asks)
|
|
47
|
+
(--agent-starts: agents you allowed "Always" may start sessions there without asking you each time)
|
|
47
48
|
baychat launch list The folders BayChat may start sessions in
|
|
48
49
|
baychat launch remove <id> Stop offering one of them`;
|
|
49
50
|
/** Printed by every `add`: the one thing a person must know before saying yes. */
|
|
50
51
|
exports.APPROVALS_NOTICE = "Claude Code sessions started here from BayChat ask for approvals in BayChat.";
|
|
52
|
+
/** Printed by an `add … --agent-starts`: what the second key opens, and what it never does. */
|
|
53
|
+
exports.AGENT_STARTS_NOTICE = "Agents you allowed with \"Always for this agent here\" on their card may start sessions here without asking you each time — " +
|
|
54
|
+
"only for this folder, computer and runtime, for 30 days, and only with no task or one of your own messages as the task: a task an agent or anyone else writes still asks. " +
|
|
55
|
+
"Those sessions ask for approvals in BayChat like any other; they never start Codex unattended. Revoke an agent in Settings → Devices.";
|
|
51
56
|
/** Printed when Codex is offered: what it may do without asking, and what it asks on the phone. */
|
|
52
57
|
exports.CODEX_UNATTENDED_NOTICE = "Codex started here from BayChat is run by the relay (Codex 0.160 or newer): it can change files in this folder without asking, with no network, " +
|
|
53
58
|
"and asks for approval in BayChat — on the session owner's phone — before anything more (a command outside its sandbox, network access, files elsewhere). " +
|
|
@@ -89,6 +94,7 @@ async function addSpot(args, deps) {
|
|
|
89
94
|
const runtimeList = (0, args_1.flag)(args, "--runtime");
|
|
90
95
|
const maxRaw = (0, args_1.flag)(args, "--max");
|
|
91
96
|
const unattended = args.includes("--unattended");
|
|
97
|
+
const agentStarts = args.includes("--agent-starts");
|
|
92
98
|
const valued = new Set([label, runtimeList, maxRaw].filter((v) => v !== undefined));
|
|
93
99
|
const folderArg = args.find((a) => !a.startsWith("--") && !valued.has(a));
|
|
94
100
|
if (unattended && !(runtimeList ?? "").split(",").map((r) => r.trim()).includes("codex")) {
|
|
@@ -127,11 +133,14 @@ async function addSpot(args, deps) {
|
|
|
127
133
|
runtimes: [...new Set(requested)],
|
|
128
134
|
maxSessions,
|
|
129
135
|
...(unattended ? { unattendedCodex: true } : {}),
|
|
136
|
+
...(agentStarts ? { agentStarts: true } : {}),
|
|
130
137
|
};
|
|
131
138
|
(0, policy_2.saveLaunchPolicy)((0, policy_2.upsertSpot)(policy, spot), deps.policyFile);
|
|
132
139
|
deps.print(`${existing ? "Updated" : "Added"} "${spot.label}" (${spot.id}): ${spot.path}`);
|
|
133
140
|
deps.print(` Agents: ${spot.runtimes.join(", ")} · at most ${spot.maxSessions} at once`);
|
|
134
141
|
deps.print(` ${exports.APPROVALS_NOTICE}`);
|
|
142
|
+
if (spot.agentStarts)
|
|
143
|
+
deps.print(` ${exports.AGENT_STARTS_NOTICE}`);
|
|
135
144
|
if (spot.runtimes.includes("codex")) {
|
|
136
145
|
deps.print(spot.unattendedCodex
|
|
137
146
|
? ` ${exports.CODEX_UNATTENDED_NOTICE}`
|
|
@@ -176,8 +185,10 @@ function listSpots(deps) {
|
|
|
176
185
|
deps.print("BayChat may not start sessions on this computer. Add a folder with `baychat launch add <folder>`.");
|
|
177
186
|
return 0;
|
|
178
187
|
}
|
|
179
|
-
for (const s of policy.spots)
|
|
180
|
-
|
|
188
|
+
for (const s of policy.spots) {
|
|
189
|
+
const flags = [s.unattendedCodex ? "unattended Codex" : "", s.agentStarts ? "agent starts" : ""].filter(Boolean);
|
|
190
|
+
deps.print(`${s.id} "${s.label}" ${s.path} [${s.runtimes.join(", ")}] max ${s.maxSessions}${flags.length ? ` (${flags.join(", ")})` : ""}`);
|
|
191
|
+
}
|
|
181
192
|
return 0;
|
|
182
193
|
}
|
|
183
194
|
function removeSpotById(args, deps) {
|