baychat 0.30.0 → 0.31.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/README.md +36 -3
- package/dist/approval-wait.js +46 -33
- package/dist/approve-hook.js +35 -1
- package/dist/auto-update/describe.js +66 -0
- package/dist/auto-update/notice.js +40 -0
- package/dist/auto-update/policy.js +110 -0
- package/dist/auto-update/relay-glue.js +143 -0
- package/dist/auto-update/store.js +140 -0
- package/dist/auto-update/updater.js +324 -0
- package/dist/channel/permission.js +6 -0
- package/dist/cli-help.js +6 -1
- package/dist/denied/after-hook.js +8 -2
- package/dist/denied/confirm.js +131 -0
- package/dist/denied/grants.js +60 -2
- package/dist/denied/hook-log.js +75 -0
- package/dist/denied/live-session.js +154 -0
- package/dist/denied/notice.js +29 -0
- package/dist/denied/permission-rule.js +9 -6
- package/dist/denied/settings-rules.js +61 -0
- package/dist/denied/transcript.js +39 -16
- package/dist/denied-hook.js +132 -30
- package/dist/doctor-command.js +7 -1
- package/dist/doctor.js +28 -0
- package/dist/main.js +11 -2
- package/dist/permissions-command.js +18 -1
- package/dist/refresh-clients.js +36 -0
- package/dist/relay/acp/runner.js +4 -0
- package/dist/relay/commands.js +19 -3
- package/dist/relay/daemon.js +37 -1
- package/dist/relay/launcher/loop.js +4 -1
- package/dist/relay/self-restart.js +149 -0
- package/dist/relay/terminal-pane.js +74 -2
- package/dist/self-upgrade.js +92 -27
- package/dist/start-command.js +15 -1
- package/dist/update-command.js +51 -31
- package/dist/waiting-cards.js +107 -0
- package/package.json +4 -1
package/dist/denied-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.RELOAD_SETTLE_MS = void 0;
|
|
36
|
+
exports.RELOAD_SETTLE_MS = exports.deniedHookLogPath = void 0;
|
|
37
37
|
exports.retryOutput = retryOutput;
|
|
38
38
|
exports.composeDeniedQuestion = composeDeniedQuestion;
|
|
39
39
|
exports.offeredMeanings = offeredMeanings;
|
|
@@ -62,9 +62,17 @@ exports.cmdDeniedHook = cmdDeniedHook;
|
|
|
62
62
|
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
63
63
|
//
|
|
64
64
|
// Unlike approve-hook, silence is safe: no output leaves the denial standing. Every abnormal path
|
|
65
|
-
// — no credential, no
|
|
66
|
-
//
|
|
67
|
-
//
|
|
65
|
+
// — no credential, no proof, BayChat unreachable, an unanswered card, a settings file we cannot
|
|
66
|
+
// parse, an exception — prints nothing and writes no rule. A rule is written only after the card's
|
|
67
|
+
// owner tapped a yes, and `retry` is printed only after the rule was written.
|
|
68
|
+
//
|
|
69
|
+
// PROOF, IN TWO HALVES. Claude Code 2.1.299 writes the denied call to the transcript only after
|
|
70
|
+
// this hook returns (measured; denied/transcript.ts), so the transcript is looked at only briefly.
|
|
71
|
+
// Found → proven. Found as a DIFFERENT call → refused. Not there yet → the event must come from
|
|
72
|
+
// the live Claude Code of the session it names (denied/live-session.ts), and a yes is recorded as
|
|
73
|
+
// UNCONFIRMED and checked against the transcript afterwards by a detached
|
|
74
|
+
// `baychat permissions confirm` (denied/confirm.ts), which takes the rule back out — and tells the
|
|
75
|
+
// owner — when the call never shows up.
|
|
68
76
|
//
|
|
69
77
|
// THE PROCESS BLOCKS THE SESSION WHILE IT WAITS, like approve-hook: hooks are awaited, so the
|
|
70
78
|
// session stops until the owner answers or our deadline passes. That is the reliable shape: the
|
|
@@ -80,10 +88,16 @@ const approve_hook_1 = require("./approve-hook");
|
|
|
80
88
|
const approval_wait_1 = require("./approval-wait");
|
|
81
89
|
const config_1 = require("./config");
|
|
82
90
|
const hook_stdin_1 = require("./hook-stdin");
|
|
91
|
+
const confirm_1 = require("./denied/confirm");
|
|
83
92
|
const grants_1 = require("./denied/grants");
|
|
93
|
+
const hook_log_1 = require("./denied/hook-log");
|
|
94
|
+
const live_session_1 = require("./denied/live-session");
|
|
95
|
+
const notice_1 = require("./denied/notice");
|
|
84
96
|
const permission_rule_1 = require("./denied/permission-rule");
|
|
85
97
|
const settings_rules_1 = require("./denied/settings-rules");
|
|
86
98
|
const transcript_1 = require("./denied/transcript");
|
|
99
|
+
var hook_log_2 = require("./denied/hook-log");
|
|
100
|
+
Object.defineProperty(exports, "deniedHookLogPath", { enumerable: true, get: function () { return hook_log_2.deniedHookLogPath; } });
|
|
87
101
|
function retryOutput() {
|
|
88
102
|
return { hookSpecificOutput: { hookEventName: "PermissionDenied", retry: true } };
|
|
89
103
|
}
|
|
@@ -94,8 +108,6 @@ const LABEL = {
|
|
|
94
108
|
always: "Always allow this exact command",
|
|
95
109
|
no: "No",
|
|
96
110
|
};
|
|
97
|
-
/** The single button of a card that only informs: nothing can be allowed from BayChat. */
|
|
98
|
-
const NOTICE_OPTIONS = ["OK"];
|
|
99
111
|
/** The card's own limits (apps/api/src/lib/cards.ts), used only to TRIM. */
|
|
100
112
|
const QUESTION_MAX = 2000;
|
|
101
113
|
const SHOWN_MAX = 900;
|
|
@@ -117,11 +129,21 @@ function isNoVerdict(reason) {
|
|
|
117
129
|
/** The card's question. A person decides from this text alone, on a lock screen. */
|
|
118
130
|
function composeDeniedQuestion(q) {
|
|
119
131
|
const shown = q.plan.shown.length <= SHOWN_MAX ? q.plan.shown : `${q.plan.shown.slice(0, SHOWN_MAX - 1)}…`;
|
|
132
|
+
const one = q.plan.onceRules.length === 1;
|
|
133
|
+
const user = q.target.scope === "user";
|
|
134
|
+
// The user scope reaches every project on this computer, so the card says where the rule goes
|
|
135
|
+
// and why — never only a path the person has to decode on a lock screen.
|
|
136
|
+
const where = user ? `your user settings (${tildePath(q.target.file)})` : q.target.file;
|
|
120
137
|
const offers = q.plan.onceRules.length
|
|
121
138
|
? [
|
|
122
|
-
`Allow once: BayChat adds ${
|
|
123
|
-
|
|
124
|
-
q.plan.alwaysAllowed
|
|
139
|
+
`Allow once: BayChat adds ${one ? "this exact rule" : "these exact rules"} to ${where} for the retry, ` +
|
|
140
|
+
`then takes ${one ? "it" : "them"} out${user ? " — the project folder isn't writable for you" : ""}.`,
|
|
141
|
+
q.plan.alwaysAllowed
|
|
142
|
+
? user
|
|
143
|
+
? "Always: BayChat keeps the rule in your user settings, so it applies to every project on this computer " +
|
|
144
|
+
"(`baychat permissions list` shows it)."
|
|
145
|
+
: "Always: BayChat keeps the rule there, for this project (`baychat permissions list` shows it)."
|
|
146
|
+
: "",
|
|
125
147
|
]
|
|
126
148
|
: [];
|
|
127
149
|
const lines = [
|
|
@@ -138,6 +160,11 @@ function composeDeniedQuestion(q) {
|
|
|
138
160
|
const text = lines.filter((l) => l !== undefined).join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
139
161
|
return text.length <= QUESTION_MAX ? text : `${text.slice(0, QUESTION_MAX - 1)}…`;
|
|
140
162
|
}
|
|
163
|
+
/** `/home/k/.claude/settings.json` → `~/.claude/settings.json`. */
|
|
164
|
+
function tildePath(file) {
|
|
165
|
+
const home = os.homedir();
|
|
166
|
+
return file === home || file.startsWith(home + path.sep) ? `~${file.slice(home.length)}` : file;
|
|
167
|
+
}
|
|
141
168
|
/** The buttons for a plan: every option it allows, and always a way to say no. */
|
|
142
169
|
function offeredMeanings(plan) {
|
|
143
170
|
if (plan.onceRules.length === 0)
|
|
@@ -205,23 +232,44 @@ async function decideDenied(rawStdin, argv = [], deps = {}) {
|
|
|
205
232
|
log(`${LOG_LABEL}: this machine is not logged in to BayChat (\`baychat login\`); the denial stands.`);
|
|
206
233
|
return null;
|
|
207
234
|
}
|
|
235
|
+
// Every run takes back what is overdue first, so a stale grant never covers this call.
|
|
236
|
+
try {
|
|
237
|
+
await (deps.sweepGrants ?? defaultSweepGrants)(device);
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
log(`${LOG_LABEL}: the grants sweep failed (${(0, approval_wait_1.reasonOf)(err)}).`);
|
|
241
|
+
}
|
|
208
242
|
// Only a call the model really made may put a card on anybody's phone.
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
if (
|
|
217
|
-
log(`${LOG_LABEL}:
|
|
243
|
+
const transcriptPath = str(input.transcript_path);
|
|
244
|
+
const agentId = str(input.agent_id) || undefined;
|
|
245
|
+
const toolUseId = str(input.tool_use_id);
|
|
246
|
+
const proofStarted = now();
|
|
247
|
+
const proof = await (deps.verify ?? transcript_1.verifyDeniedCall)({ transcriptPath, agentId, toolUseId, toolName, toolInput: input.tool_input });
|
|
248
|
+
// Set when the call is accepted before it is in the transcript: a yes is then confirmed later.
|
|
249
|
+
let unconfirmed;
|
|
250
|
+
if (proof.ok) {
|
|
251
|
+
log(`${LOG_LABEL}: proven — the call is in the transcript (after ${now() - proofStarted} ms).`);
|
|
252
|
+
}
|
|
253
|
+
else if (!proof.absent || !toolUseId || !call) {
|
|
254
|
+
log(`${LOG_LABEL}: refused — ${proof.why} (after ${now() - proofStarted} ms); no card was posted and the denial stands.`);
|
|
218
255
|
return null;
|
|
219
256
|
}
|
|
257
|
+
else {
|
|
258
|
+
const live = (deps.liveSession ?? live_session_1.proveLiveSession)({ sessionId: claudeSessionId, transcriptPath, agentId });
|
|
259
|
+
if (!live.ok) {
|
|
260
|
+
log(`${LOG_LABEL}: refused — the call is not in the transcript yet (after ${now() - proofStarted} ms) and ` +
|
|
261
|
+
`the event is not from this session's live Claude Code: ${live.why}; no card was posted and the denial stands.`);
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
log(`${LOG_LABEL}: accepted — the call is not in the transcript yet (after ${now() - proofStarted} ms), and the ` +
|
|
265
|
+
`event comes from this session's live Claude Code (pid ${live.pid}); a yes is confirmed against the transcript afterwards.`);
|
|
266
|
+
unconfirmed = { transcriptPath, ...(agentId ? { agentId } : {}), toolUseId, toolName };
|
|
267
|
+
}
|
|
220
268
|
const host = (deps.hostname ?? os.hostname)();
|
|
221
269
|
const who = session ? `Session "${session}"` : "A Claude Code session";
|
|
222
270
|
const waitDeps = { now, sleep, log };
|
|
223
271
|
// A card that only informs: posted, never waited on (its deadline is already now).
|
|
224
|
-
const notify = (question) => (0, approval_wait_1.askOwnerAndWait)(device, { session, question, options: NOTICE_OPTIONS }, now(), waitDeps, LOG_LABEL);
|
|
272
|
+
const notify = (question) => (0, approval_wait_1.askOwnerAndWait)(device, { session, question, options: notice_1.NOTICE_OPTIONS }, now(), waitDeps, LOG_LABEL);
|
|
225
273
|
// ── Already allowed, and denied again: the reload race, or a rule that does not match.
|
|
226
274
|
const covering = call ? (0, grants_1.coveringGrant)(claudeSessionId, call, now()) : undefined;
|
|
227
275
|
if (covering) {
|
|
@@ -242,9 +290,28 @@ async function decideDenied(rawStdin, argv = [], deps = {}) {
|
|
|
242
290
|
log(`${LOG_LABEL}: no project directory to keep a rule in; the denial stands.`);
|
|
243
291
|
return null;
|
|
244
292
|
}
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
const
|
|
293
|
+
// Decided BEFORE the card: an Allow button for a file this user cannot write can only fail.
|
|
294
|
+
const choice = (deps.settingsChoiceFor ?? settings_rules_1.chooseSettingsFile)(projectDir);
|
|
295
|
+
const planned = (0, permission_rule_1.planDeniedCall)(toolName, input.tool_input);
|
|
296
|
+
const plan = choice.scope === "none" && planned.onceRules.length > 0
|
|
297
|
+
? {
|
|
298
|
+
...planned,
|
|
299
|
+
onceRules: [],
|
|
300
|
+
alwaysAllowed: false,
|
|
301
|
+
limitation: `BayChat can't allow it from here: ${choice.why}, so no rule can be saved. ` +
|
|
302
|
+
"Approve it at the computer (/permissions → Recently denied).",
|
|
303
|
+
}
|
|
304
|
+
: planned;
|
|
305
|
+
const target = choice.scope === "none" ? undefined : choice;
|
|
306
|
+
const question = composeDeniedQuestion({
|
|
307
|
+
who,
|
|
308
|
+
host,
|
|
309
|
+
plan,
|
|
310
|
+
reason,
|
|
311
|
+
cwd: str(input.cwd),
|
|
312
|
+
// Any target will do for a card that offers nothing: no write line is drawn.
|
|
313
|
+
target: target ?? { scope: "project", file: "" },
|
|
314
|
+
});
|
|
248
315
|
const meanings = offeredMeanings(plan);
|
|
249
316
|
if (meanings.length === 0) {
|
|
250
317
|
// Nothing can be allowed from BayChat; the owner still learns why the session stopped.
|
|
@@ -268,18 +335,20 @@ async function decideDenied(rawStdin, argv = [], deps = {}) {
|
|
|
268
335
|
return null;
|
|
269
336
|
}
|
|
270
337
|
const meaning = (outcome.index !== null ? meanings[outcome.index] : undefined) ?? "no";
|
|
271
|
-
if (meaning === "no" || !plan.call)
|
|
338
|
+
if (meaning === "no" || !plan.call || !target)
|
|
272
339
|
return null;
|
|
273
340
|
let grant;
|
|
274
341
|
try {
|
|
275
342
|
grant = await (0, grants_1.grantRules)({
|
|
276
343
|
kind: meaning,
|
|
277
344
|
rules: plan.onceRules,
|
|
278
|
-
settingsFile,
|
|
345
|
+
settingsFile: target.file,
|
|
346
|
+
scope: target.scope,
|
|
279
347
|
call: plan.call,
|
|
280
348
|
shown: plan.shown,
|
|
281
349
|
claudeSessionId,
|
|
282
350
|
session,
|
|
351
|
+
...(unconfirmed ? { unconfirmed } : {}),
|
|
283
352
|
}, now());
|
|
284
353
|
}
|
|
285
354
|
catch (err) {
|
|
@@ -290,6 +359,11 @@ async function decideDenied(rawStdin, argv = [], deps = {}) {
|
|
|
290
359
|
if (grant.kind === "once" && grant.expiresAt) {
|
|
291
360
|
(deps.scheduleSweep ?? scheduleSweep)(Date.parse(grant.expiresAt));
|
|
292
361
|
}
|
|
362
|
+
if (grant.unconfirmed) {
|
|
363
|
+
// Started before `retry`, so it holds the grant before the retried call can use it up.
|
|
364
|
+
(deps.scheduleConfirm ?? scheduleConfirm)(grant.id);
|
|
365
|
+
log(`${LOG_LABEL}: grant ${grant.id} (${grant.kind}) is unconfirmed; its confirmation was started.`);
|
|
366
|
+
}
|
|
293
367
|
await sleep(exports.RELOAD_SETTLE_MS);
|
|
294
368
|
return retryOutput();
|
|
295
369
|
}
|
|
@@ -304,26 +378,54 @@ async function decideDenied(rawStdin, argv = [], deps = {}) {
|
|
|
304
378
|
* after-hook and every later hook run sweep too.
|
|
305
379
|
*/
|
|
306
380
|
function scheduleSweep(atMs) {
|
|
381
|
+
spawnDetached(["permissions", "sweep", "--at", String(atMs)], "schedule the once-rule's removal");
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Start `baychat permissions confirm --grant <id>` detached: it waits for the denied call to reach
|
|
385
|
+
* the transcript and takes the grant back when it does not (denied/confirm.ts). Best effort: a
|
|
386
|
+
* confirmation that never runs is caught as a stale unconfirmed grant by every later hook run.
|
|
387
|
+
*/
|
|
388
|
+
function scheduleConfirm(grantId) {
|
|
389
|
+
spawnDetached(["permissions", "confirm", "--grant", grantId], "start the grant's confirmation");
|
|
390
|
+
}
|
|
391
|
+
function spawnDetached(args, what) {
|
|
307
392
|
try {
|
|
308
393
|
const entry = fs.realpathSync(process.argv[1] ?? "");
|
|
309
|
-
const child = (0, child_process_1.spawn)(process.execPath, [entry,
|
|
310
|
-
detached: true,
|
|
311
|
-
stdio: "ignore",
|
|
312
|
-
});
|
|
394
|
+
const child = (0, child_process_1.spawn)(process.execPath, [entry, ...args], { detached: true, stdio: "ignore" });
|
|
313
395
|
child.unref();
|
|
314
396
|
}
|
|
315
397
|
catch (err) {
|
|
316
|
-
process.stderr.write(`${LOG_LABEL}: could not
|
|
398
|
+
process.stderr.write(`${LOG_LABEL}: could not ${what} (${(0, approval_wait_1.reasonOf)(err)}).\n`);
|
|
399
|
+
(0, hook_log_1.appendHookLog)(`${LOG_LABEL}: could not ${what} (${(0, approval_wait_1.reasonOf)(err)}).`);
|
|
317
400
|
}
|
|
318
401
|
}
|
|
402
|
+
async function defaultSweepGrants(device) {
|
|
403
|
+
await (0, grants_1.sweepExpiredGrants)();
|
|
404
|
+
await (0, confirm_1.takeBackStaleUnconfirmed)({ loadDevice: () => device, log: hook_log_1.logBoth });
|
|
405
|
+
}
|
|
319
406
|
/**
|
|
320
407
|
* `baychat denied-hook [--session <name> | --session-from-claude] [--timeout <seconds>]`
|
|
321
408
|
*
|
|
322
409
|
* Always returns 0 (Claude Code ignores this event's exit code). Prints `retry` or nothing.
|
|
323
410
|
*/
|
|
324
411
|
async function cmdDeniedHook(argv = [], stdin = process.stdin) {
|
|
325
|
-
const
|
|
412
|
+
const raw = await (0, hook_stdin_1.readStdin)(stdin);
|
|
413
|
+
const log = hook_log_1.logBoth;
|
|
414
|
+
(0, hook_log_1.appendHookLog)(`${LOG_LABEL}: called (${eventSummary(raw)})`);
|
|
415
|
+
const output = await decideDenied(raw, argv, { log });
|
|
416
|
+
(0, hook_log_1.appendHookLog)(`${LOG_LABEL}: ${output ? "retry" : "no retry"}`);
|
|
326
417
|
if (output)
|
|
327
418
|
process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
328
419
|
return 0;
|
|
329
420
|
}
|
|
421
|
+
// ─── The hook's own log (denied/hook-log.ts) ──────────────────────────────────
|
|
422
|
+
/** Event name, tool, session id and which fields came — never the tool input itself. */
|
|
423
|
+
function eventSummary(raw) {
|
|
424
|
+
try {
|
|
425
|
+
const e = JSON.parse(raw);
|
|
426
|
+
return `event=${String(e.hook_event_name)} tool=${String(e.tool_name)} session=${String(e.session_id)} call=${String(e.tool_use_id)} keys=${Object.keys(e).join(",")}`;
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
return `unreadable stdin, ${raw.length} bytes`;
|
|
430
|
+
}
|
|
431
|
+
}
|
package/dist/doctor-command.js
CHANGED
|
@@ -55,6 +55,9 @@ const credential_refresh_1 = require("./credential-refresh");
|
|
|
55
55
|
const client_paths_1 = require("./client-paths");
|
|
56
56
|
const runtime_binary_1 = require("./runtime-binary");
|
|
57
57
|
const ws_1 = require("./relay/ws");
|
|
58
|
+
const describe_1 = require("./auto-update/describe");
|
|
59
|
+
const store_1 = require("./auto-update/store");
|
|
60
|
+
const self_upgrade_1 = require("./self-upgrade");
|
|
58
61
|
/** How long `claude mcp list` may take before we treat it as unanswerable. */
|
|
59
62
|
const MCP_LIST_TIMEOUT_MS = 15_000;
|
|
60
63
|
/**
|
|
@@ -88,6 +91,7 @@ async function currentDoctorEnv() {
|
|
|
88
91
|
bayRelayAlive[bay.bayId] = await (0, socket_1.probeSocket)((0, bay_relays_1.bayRelayScope)(bay.bayId, (0, config_1.configDir)(), (0, socket_1.socketPath)()).socket);
|
|
89
92
|
}
|
|
90
93
|
}
|
|
94
|
+
const relay = await (0, commands_1.tryRelayStatus)();
|
|
91
95
|
return {
|
|
92
96
|
bays,
|
|
93
97
|
bayRelayAlive,
|
|
@@ -95,7 +99,9 @@ async function currentDoctorEnv() {
|
|
|
95
99
|
platform: process.platform,
|
|
96
100
|
now: Date.now(),
|
|
97
101
|
device: (0, config_1.loadDeviceCredentials)(),
|
|
98
|
-
relay
|
|
102
|
+
relay,
|
|
103
|
+
// The running relay's own account when it gives one; else what its files say.
|
|
104
|
+
autoUpdate: relay?.autoUpdate ?? (0, describe_1.statusFromFiles)({ state: (0, store_1.loadAutoUpdateState)(), setting: (0, store_1.loadAutoSetting)(), env: process.env, current: (0, self_upgrade_1.ownVersion)() }),
|
|
99
105
|
node: { version: process.version, webSocket: (0, ws_1.hasBuiltInWebSocket)() },
|
|
100
106
|
// APPDATA, LOCALAPPDATA and a directory lister: without them a Windows report cannot
|
|
101
107
|
// see Claude Desktop's config at all, Store copy or classic.
|
package/dist/doctor.js
CHANGED
|
@@ -61,6 +61,7 @@ exports.acpChecks = acpChecks;
|
|
|
61
61
|
exports.exitCodeFor = exitCodeFor;
|
|
62
62
|
exports.renderReport = renderReport;
|
|
63
63
|
exports.bayChecks = bayChecks;
|
|
64
|
+
exports.autoUpdateCheck = autoUpdateCheck;
|
|
64
65
|
const fs = __importStar(require("fs"));
|
|
65
66
|
const os = __importStar(require("os"));
|
|
66
67
|
const path = __importStar(require("path"));
|
|
@@ -76,6 +77,8 @@ const launch_1 = require("./channel/launch");
|
|
|
76
77
|
const plugin_1 = require("./channel/plugin");
|
|
77
78
|
const transport_1 = require("./relay/transport");
|
|
78
79
|
const ws_1 = require("./relay/ws");
|
|
80
|
+
const describe_1 = require("./auto-update/describe");
|
|
81
|
+
const self_upgrade_1 = require("./self-upgrade");
|
|
79
82
|
/**
|
|
80
83
|
* The runtimes worth reporting on.
|
|
81
84
|
*
|
|
@@ -122,6 +125,7 @@ function buildReport(env) {
|
|
|
122
125
|
credentials: credentialsCheck(env),
|
|
123
126
|
bays: bayChecks(env),
|
|
124
127
|
relay: relayCheck(env),
|
|
128
|
+
autoUpdate: autoUpdateCheck(env),
|
|
125
129
|
mailbox: mailboxCheck(),
|
|
126
130
|
staleCredentials: staleCredentialCheck(env),
|
|
127
131
|
runtimes: REPORTED.map((runtime) => runtimeReport(runtime, env)),
|
|
@@ -293,6 +297,7 @@ function renderReport(report) {
|
|
|
293
297
|
lines.push(...renderSummaryCheck(check));
|
|
294
298
|
lines.push(...renderSummaryCheck(report.staleCredentials));
|
|
295
299
|
lines.push(...renderSummaryCheck(report.relay));
|
|
300
|
+
lines.push(...renderSummaryCheck(report.autoUpdate));
|
|
296
301
|
lines.push(...renderSummaryCheck(report.mailbox));
|
|
297
302
|
for (const block of report.runtimes) {
|
|
298
303
|
const [first, ...rest] = block.checks;
|
|
@@ -443,6 +448,28 @@ function relayCheck(env) {
|
|
|
443
448
|
}
|
|
444
449
|
return { name: "relay", status: "pass", detail: `running (pid ${env.relay.pid})${transport}` };
|
|
445
450
|
}
|
|
451
|
+
/**
|
|
452
|
+
* Is this computer keeping itself current? A warning only when something needs the person: a
|
|
453
|
+
* newer version this copy cannot install by itself, a card waiting on the phone, or an automatic
|
|
454
|
+
* install that failed. Off is a choice, not a fault, so it is reported and never nagged about.
|
|
455
|
+
*/
|
|
456
|
+
function autoUpdateCheck(env) {
|
|
457
|
+
const name = "auto-update";
|
|
458
|
+
const status = env.autoUpdate;
|
|
459
|
+
if (!status)
|
|
460
|
+
return { name, status: "skip", detail: "not inspected" };
|
|
461
|
+
const detail = (0, describe_1.describeAutoUpdate)(status, env.now);
|
|
462
|
+
const behind = !!status.latest && (0, self_upgrade_1.compareVersions)(status.current, status.latest) === -1;
|
|
463
|
+
if (status.mode === "off" || status.mode === "none")
|
|
464
|
+
return { name, status: "skip", detail };
|
|
465
|
+
if (status.failure)
|
|
466
|
+
return { name, status: "warn", detail, remedy: "baychat update" };
|
|
467
|
+
if (status.cardVersion)
|
|
468
|
+
return { name, status: "warn", detail, remedy: "answer the card in BayChat, or run baychat update" };
|
|
469
|
+
if (status.mode === "notify" && behind)
|
|
470
|
+
return { name, status: "warn", detail, remedy: "baychat update" };
|
|
471
|
+
return { name, status: "pass", detail };
|
|
472
|
+
}
|
|
446
473
|
function pendingCheck(env) {
|
|
447
474
|
if (!env.relay) {
|
|
448
475
|
return { name: "pending", status: "skip", detail: "relay not running — nothing to report" };
|
|
@@ -783,6 +810,7 @@ function allChecks(report) {
|
|
|
783
810
|
...report.bays,
|
|
784
811
|
report.staleCredentials,
|
|
785
812
|
report.relay,
|
|
813
|
+
report.autoUpdate,
|
|
786
814
|
report.mailbox,
|
|
787
815
|
...report.runtimes.flatMap((r) => r.checks),
|
|
788
816
|
...report.agents,
|
package/dist/main.js
CHANGED
|
@@ -9,6 +9,7 @@ const context_hooks_command_1 = require("./context-hooks-command");
|
|
|
9
9
|
const session_name_1 = require("./session-name");
|
|
10
10
|
const start_command_1 = require("./start-command");
|
|
11
11
|
const update_command_1 = require("./update-command");
|
|
12
|
+
const notice_1 = require("./auto-update/notice");
|
|
12
13
|
const session_command_1 = require("./session-command");
|
|
13
14
|
const doctor_command_1 = require("./doctor-command");
|
|
14
15
|
const bays_command_1 = require("./bays-command");
|
|
@@ -56,8 +57,13 @@ async function main() {
|
|
|
56
57
|
// else", and a usage dump printed at somebody who typed the product's own name is the
|
|
57
58
|
// "something else". `--help` still prints the help, and `start` refuses with one sentence
|
|
58
59
|
// when this machine is not signed in, so nothing is launched from an unusable state.
|
|
59
|
-
if (command === undefined)
|
|
60
|
+
if (command === undefined) {
|
|
61
|
+
(0, notice_1.printUpdateNotice)(command);
|
|
60
62
|
return (0, start_command_1.cmdStart)([]);
|
|
63
|
+
}
|
|
64
|
+
// "BayChat updated itself to …": the relay's one line for the terminal, shown once, on a command
|
|
65
|
+
// a person typed (never into a hook's or an MCP server's output).
|
|
66
|
+
(0, notice_1.printUpdateNotice)(command);
|
|
61
67
|
// Asking for help never acts: `--help`/`-h` on any command prints its usage and runs nothing
|
|
62
68
|
// (cli-help.ts says which commands' arguments are not ours to read — `claude`, `send`, …).
|
|
63
69
|
const help = (0, cli_help_1.helpRequest)(command, args);
|
|
@@ -76,7 +82,10 @@ async function main() {
|
|
|
76
82
|
return (0, start_command_1.cmdStart)(args);
|
|
77
83
|
case "update":
|
|
78
84
|
// `--quiet` is how an old version hands a `start`-driven update to the new one.
|
|
79
|
-
|
|
85
|
+
(0, args_1.rejectUnknownFlags)(args, ["--quiet", "--auto"], "baychat update [--auto on|off]");
|
|
86
|
+
if (args.includes("--auto") && !(0, args_1.flag)(args, "--auto"))
|
|
87
|
+
throw new Error("Usage: baychat update --auto on|off");
|
|
88
|
+
return (0, update_command_1.cmdUpdate)({ quiet: args.includes("--quiet"), auto: (0, args_1.flag)(args, "--auto") });
|
|
80
89
|
case "skill": {
|
|
81
90
|
const runtime = args[1];
|
|
82
91
|
if (args.length !== 2 ||
|
|
@@ -7,10 +7,15 @@ exports.cmdPermissions = cmdPermissions;
|
|
|
7
7
|
// baychat permissions remove <id> take one back out of its settings file
|
|
8
8
|
// baychat permissions on | off the card itself, for sessions BayChat starts from now on
|
|
9
9
|
// baychat permissions sweep (internal) take back once-rules whose time is up
|
|
10
|
+
// baychat permissions confirm --grant <id>
|
|
11
|
+
// (internal) confirm an unconfirmed grant against the
|
|
12
|
+
// session's transcript, or take it back (denied/confirm.ts)
|
|
10
13
|
//
|
|
11
14
|
// Only rules BayChat wrote are listed or removed. A rule the person wrote themselves never
|
|
12
15
|
// appears here, because a rule that was already in the file was never recorded (denied/grants.ts).
|
|
16
|
+
const confirm_1 = require("./denied/confirm");
|
|
13
17
|
const grants_1 = require("./denied/grants");
|
|
18
|
+
const hook_log_1 = require("./denied/hook-log");
|
|
14
19
|
const opt_in_1 = require("./denied/opt-in");
|
|
15
20
|
/** The longest a scheduled sweep may sleep: a once-rule's TTL is minutes, never hours. */
|
|
16
21
|
const MAX_SWEEP_DELAY_MS = 60 * 60_000;
|
|
@@ -71,6 +76,17 @@ async function cmdPermissions(args, deps = {}) {
|
|
|
71
76
|
await (deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))))(delay + 1000);
|
|
72
77
|
}
|
|
73
78
|
await (0, grants_1.sweepExpiredGrants)(now());
|
|
79
|
+
await (0, confirm_1.takeBackStaleUnconfirmed)({ log: hook_log_1.logBoth, ...deps.confirm });
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
case "confirm": {
|
|
83
|
+
// Started detached by denied-hook (never by a person); not in USAGE on purpose.
|
|
84
|
+
const id = rest[rest.indexOf("--grant") + 1];
|
|
85
|
+
if (!rest.includes("--grant") || !id) {
|
|
86
|
+
err("Usage: baychat permissions confirm --grant <id>");
|
|
87
|
+
return 1;
|
|
88
|
+
}
|
|
89
|
+
await (0, confirm_1.confirmGrant)(id, { log: hook_log_1.logBoth, ...deps.confirm });
|
|
74
90
|
return 0;
|
|
75
91
|
}
|
|
76
92
|
default:
|
|
@@ -83,5 +99,6 @@ function describeGrant(grant) {
|
|
|
83
99
|
// A once-rule can carry a secret from the command (it is never offered as "always" then), so it
|
|
84
100
|
// is listed as the card showed it, masked; an always-rule never holds one and is listed as written.
|
|
85
101
|
const rules = grant.kind === "once" ? grant.shown : grant.rules.length ? grant.rules.join(", ") : "(the rule was already there)";
|
|
86
|
-
|
|
102
|
+
const scope = grant.scope === "user" ? "user settings, every project" : "this project";
|
|
103
|
+
return `${grant.id} ${rules}\n ${when} · ${scope} · ${grant.settingsFile}${grant.session ? ` · ${grant.session}` : ""}`;
|
|
87
104
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.refreshConnectedClients = refreshConnectedClients;
|
|
4
|
+
const child_process_1 = require("child_process");
|
|
5
|
+
const client_paths_1 = require("./client-paths");
|
|
6
|
+
const commands_1 = require("./commands");
|
|
7
|
+
const credential_refresh_1 = require("./credential-refresh");
|
|
8
|
+
/**
|
|
9
|
+
* Steps 2 and 3 of an update, shared with the relay's automatic update (which runs them from the
|
|
10
|
+
* NEW code once it has restarted onto it): every connected client's copy of the credential and
|
|
11
|
+
* endpoint, then Claude Code, which is not an MCP-config client and registers through its own CLI.
|
|
12
|
+
* Returns what was done, one line each. Never throws: the steps are independent, and a machine
|
|
13
|
+
* that failed one is better off doing the other than stopping at the first.
|
|
14
|
+
*/
|
|
15
|
+
function refreshConnectedClients(device, say) {
|
|
16
|
+
const done = [];
|
|
17
|
+
try {
|
|
18
|
+
const updated = (0, credential_refresh_1.refreshExistingClientConfigs)({ url: `${device.baseUrl}/api/mcp`, token: device.token }, commands_1.realConfigIo, (0, client_paths_1.currentPathEnv)());
|
|
19
|
+
for (const file of updated)
|
|
20
|
+
done.push(`refreshed ${file}`);
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
say(` Could not refresh client configs: ${errText(err)}`);
|
|
24
|
+
}
|
|
25
|
+
if (hasBinary("claude") && (0, commands_1.registerWithClaude)(device.baseUrl, device.token)) {
|
|
26
|
+
done.push("re-registered BayChat with Claude Code");
|
|
27
|
+
}
|
|
28
|
+
return done;
|
|
29
|
+
}
|
|
30
|
+
function hasBinary(name) {
|
|
31
|
+
const probe = (0, child_process_1.spawnSync)(name, ["--version"], { stdio: "ignore", timeout: 15_000 });
|
|
32
|
+
return !probe.error && probe.status === 0;
|
|
33
|
+
}
|
|
34
|
+
function errText(err) {
|
|
35
|
+
return err instanceof Error ? err.message : String(err);
|
|
36
|
+
}
|
package/dist/relay/acp/runner.js
CHANGED
|
@@ -201,6 +201,10 @@ class AcpSessionRunner {
|
|
|
201
201
|
status(session) {
|
|
202
202
|
return { running: this.running.has(session), queued: this.deps.queue.depth(session) };
|
|
203
203
|
}
|
|
204
|
+
/** Sessions with a turn running or waiting to run. */
|
|
205
|
+
activeSessions() {
|
|
206
|
+
return [...this.loops.keys()];
|
|
207
|
+
}
|
|
204
208
|
async idle() {
|
|
205
209
|
while (this.loops.size > 0)
|
|
206
210
|
await Promise.all([...this.loops.values()]);
|
package/dist/relay/commands.js
CHANGED
|
@@ -62,6 +62,9 @@ const terminal_pane_1 = require("./terminal-pane");
|
|
|
62
62
|
const owner_pid_1 = require("./owner-pid");
|
|
63
63
|
const parent_watch_1 = require("./parent-watch");
|
|
64
64
|
const daemon_1 = require("./daemon");
|
|
65
|
+
const relay_glue_1 = require("../auto-update/relay-glue");
|
|
66
|
+
const describe_1 = require("../auto-update/describe");
|
|
67
|
+
const self_upgrade_1 = require("../self-upgrade");
|
|
65
68
|
const bay_relays_1 = require("./bay-relays");
|
|
66
69
|
const config_1 = require("../config");
|
|
67
70
|
const resume_1 = require("./resume");
|
|
@@ -135,11 +138,22 @@ async function cmdRelayStart(opts = {}) {
|
|
|
135
138
|
baseConfigDir: (0, config_1.configDir)(),
|
|
136
139
|
baseSocket: (0, socket_1.socketPath)(),
|
|
137
140
|
});
|
|
138
|
-
|
|
139
|
-
|
|
141
|
+
// The automatic update (docs/features/CLI_AUTO_UPDATE.md) runs in the supervising relay only;
|
|
142
|
+
// a per-Bay child restarts with its parent. Started once this relay owns its socket.
|
|
143
|
+
let updater;
|
|
144
|
+
const daemon = new daemon_1.RelayDaemon({
|
|
145
|
+
onListening: () => {
|
|
146
|
+
supervisor?.start();
|
|
147
|
+
if (!child)
|
|
148
|
+
updater = (0, relay_glue_1.startRelayUpdater)({ daemon, log: (line) => console.log(`[relay] ${line}`), shutdown });
|
|
149
|
+
},
|
|
150
|
+
autoUpdateStatus: child ? undefined : () => updater?.status() ?? { mode: "none", reason: "starting", current: (0, self_upgrade_1.ownVersion)() },
|
|
151
|
+
});
|
|
152
|
+
function shutdown() {
|
|
153
|
+
updater?.stop();
|
|
140
154
|
supervisor?.stop();
|
|
141
155
|
void daemon.stop().then(() => process.exit(0));
|
|
142
|
-
}
|
|
156
|
+
}
|
|
143
157
|
process.on("SIGINT", shutdown);
|
|
144
158
|
process.on("SIGTERM", shutdown);
|
|
145
159
|
const parentPid = Number(process.env[bay_relays_1.BAY_RELAY_PARENT_ENV]);
|
|
@@ -294,6 +308,8 @@ async function cmdRelayStatus() {
|
|
|
294
308
|
console.log(` polls: ${status.polls} delivered: ${status.delivered} cursor: ${status.cursor ?? "—"}`);
|
|
295
309
|
if (status.lastError)
|
|
296
310
|
console.log(` last error: ${status.lastError}`);
|
|
311
|
+
if (status.autoUpdate)
|
|
312
|
+
console.log(` auto-update: ${(0, describe_1.describeAutoUpdate)(status.autoUpdate, Date.now())}`);
|
|
297
313
|
for (const line of await bayRelayLines())
|
|
298
314
|
console.log(line);
|
|
299
315
|
// Computed before the session list so a line can report its own last failure.
|
package/dist/relay/daemon.js
CHANGED
|
@@ -193,10 +193,16 @@ class RelayDaemon {
|
|
|
193
193
|
startLauncherFn;
|
|
194
194
|
/** Aborted on stop(): ends the launcher's long-poll. */
|
|
195
195
|
launcherAbort = new AbortController();
|
|
196
|
+
autoUpdateStatus;
|
|
197
|
+
/** When a message last reached this relay or was delivered — the automatic update's quiet window. */
|
|
198
|
+
lastActivityAt;
|
|
199
|
+
/** A launch from BayChat is being carried out (the launcher's `onLaunch`). */
|
|
200
|
+
launching = false;
|
|
196
201
|
constructor(opts = {}) {
|
|
197
202
|
this.log = opts.log ?? ((line) => console.log(`[relay] ${line}`));
|
|
198
203
|
this.onListening = opts.onListening;
|
|
199
204
|
this.startLauncherFn = opts.startLauncher ?? loop_1.startLauncher;
|
|
205
|
+
this.autoUpdateStatus = opts.autoUpdateStatus;
|
|
200
206
|
// EVERY candidate root, because the daemon cannot know which one the agent's
|
|
201
207
|
// sandbox turned out to permit — it may have fallen back past the one this
|
|
202
208
|
// process would have chosen.
|
|
@@ -341,7 +347,14 @@ class RelayDaemon {
|
|
|
341
347
|
this.codexContext.start();
|
|
342
348
|
if (!(0, bay_relays_1.isBayRelayChild)()) {
|
|
343
349
|
// Never awaited and never fatal: the launcher retries its own network failures until stop().
|
|
344
|
-
void this.startLauncherFn({
|
|
350
|
+
void this.startLauncherFn({
|
|
351
|
+
auth,
|
|
352
|
+
log: (line) => this.log(line),
|
|
353
|
+
signal: this.launcherAbort.signal,
|
|
354
|
+
onLaunch: (busy) => {
|
|
355
|
+
this.launching = busy;
|
|
356
|
+
},
|
|
357
|
+
}).catch((err) => this.log(`launcher stopped: ${err instanceof Error ? err.message : String(err)}`));
|
|
345
358
|
}
|
|
346
359
|
this.log(`listening on ${sockPath} (pid ${process.pid}) as device "${device.user.name}"`);
|
|
347
360
|
this.log(`${this.registry.all().length} known session target(s)`);
|
|
@@ -406,6 +419,7 @@ class RelayDaemon {
|
|
|
406
419
|
// behind must not have its recovery window truncated by a session that was
|
|
407
420
|
// ahead. An event with no session still registers the room — see Watermarks.
|
|
408
421
|
this.watermarks.record(sessionName, conversationId, message.createdAt);
|
|
422
|
+
this.lastActivityAt = Date.now();
|
|
409
423
|
if (!sessionName) {
|
|
410
424
|
this.log(`event ${message.id} carries no session (renamed or ended mid-poll) — ignoring`);
|
|
411
425
|
return;
|
|
@@ -1330,6 +1344,7 @@ class RelayDaemon {
|
|
|
1330
1344
|
return updated;
|
|
1331
1345
|
}
|
|
1332
1346
|
record(outcome, session, batch) {
|
|
1347
|
+
this.lastActivityAt = Date.now();
|
|
1333
1348
|
if (outcome.kind === "woken") {
|
|
1334
1349
|
this.delivered += batch.length;
|
|
1335
1350
|
const room = batch.at(-1)?.conversationId;
|
|
@@ -1635,8 +1650,29 @@ class RelayDaemon {
|
|
|
1635
1650
|
pending: this.pending,
|
|
1636
1651
|
held: this.held.summary(),
|
|
1637
1652
|
lastError: this.lastError,
|
|
1653
|
+
autoUpdate: this.autoUpdateStatus?.(),
|
|
1638
1654
|
};
|
|
1639
1655
|
}
|
|
1656
|
+
/** What this relay is doing right now, for deciding whether it may restart (auto-update/policy.ts). */
|
|
1657
|
+
activity() {
|
|
1658
|
+
return {
|
|
1659
|
+
deliveries: this.queue.activeSessions(),
|
|
1660
|
+
headless: [...this.headlessInFlight],
|
|
1661
|
+
acp: this.acp.activeSessions(),
|
|
1662
|
+
heldAt: this.held.summary().map((h) => h.heldAt),
|
|
1663
|
+
launching: this.launching,
|
|
1664
|
+
lastActivityAt: this.lastActivityAt,
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
/**
|
|
1668
|
+
* Sessions running on THIS computer that the server lists as live — the ones a card can be asked
|
|
1669
|
+
* through. Attached ones first: their owner is most likely looking at them.
|
|
1670
|
+
*/
|
|
1671
|
+
hostedLiveSessions() {
|
|
1672
|
+
const hosted = this.registry.all().filter((t) => this.liveSessions.includes(t.name));
|
|
1673
|
+
const reachable = (name) => this.attached.has(name) || this.mailboxes.has(name) || this.channelLinked(name);
|
|
1674
|
+
return hosted.sort((a, b) => Number(reachable(b.name)) - Number(reachable(a.name))).map((t) => t.name);
|
|
1675
|
+
}
|
|
1640
1676
|
async stop() {
|
|
1641
1677
|
clearInterval(this.acpKeepAlive);
|
|
1642
1678
|
this.launcherAbort.abort();
|
|
@@ -66,7 +66,7 @@ const BACKOFF_MAX_MS = 60_000;
|
|
|
66
66
|
const CAPABILITIES_REFRESH_MS = 10 * 60_000;
|
|
67
67
|
/** A long-poll holds for WAIT_SECONDS; give the request this much beyond it before calling it dead. */
|
|
68
68
|
const REQUEST_GRACE_MS = 15_000;
|
|
69
|
-
async function startLauncher({ auth, log, signal, deps: overrides }) {
|
|
69
|
+
async function startLauncher({ auth, log, signal, onLaunch, deps: overrides }) {
|
|
70
70
|
const deps = { ...realLoopDeps(log, signal), ...overrides };
|
|
71
71
|
let advertised;
|
|
72
72
|
let unreported;
|
|
@@ -76,6 +76,7 @@ async function startLauncher({ auth, log, signal, deps: overrides }) {
|
|
|
76
76
|
if (unreported) {
|
|
77
77
|
await deps.request(auth, "POST", `/api/device-api/launches/${encodeURIComponent(unreported.id)}/report`, unreported.report, signal);
|
|
78
78
|
unreported = undefined;
|
|
79
|
+
onLaunch?.(false);
|
|
79
80
|
}
|
|
80
81
|
const stamp = deps.policyStamp();
|
|
81
82
|
if (!advertised || advertised.stamp !== stamp || deps.now() - advertised.at >= CAPABILITIES_REFRESH_MS) {
|
|
@@ -93,9 +94,11 @@ async function startLauncher({ auth, log, signal, deps: overrides }) {
|
|
|
93
94
|
else {
|
|
94
95
|
// Kept until delivered: a report lost to the network is sent before the next poll.
|
|
95
96
|
const io = launchIo(deps, auth, id, signal);
|
|
97
|
+
onLaunch?.(true);
|
|
96
98
|
unreported = { id, report: launch ? await safeHandle(deps, launch, io, log) : { status: "REFUSED", reason: "BAD_REQUEST" } };
|
|
97
99
|
await deps.request(auth, "POST", `/api/device-api/launches/${encodeURIComponent(id)}/report`, unreported.report, signal);
|
|
98
100
|
unreported = undefined;
|
|
101
|
+
onLaunch?.(false);
|
|
99
102
|
}
|
|
100
103
|
}
|
|
101
104
|
backoff = BACKOFF_MIN_MS;
|