baychat 0.30.0 → 0.32.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/feed.js +50 -1
- package/dist/channel/permission.js +6 -0
- package/dist/channel/server.js +1 -0
- package/dist/claude-onboarding.js +3 -1
- 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/protocol-content.js +1 -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
|
@@ -33,34 +33,59 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.TRANSCRIPT_WAIT_MS = void 0;
|
|
36
|
+
exports.CONFIRM_WAIT_MS = exports.TRANSCRIPT_WAIT_MS = void 0;
|
|
37
|
+
exports.claudeConfigDir = claudeConfigDir;
|
|
38
|
+
exports.transcriptFileOf = transcriptFileOf;
|
|
37
39
|
exports.verifyDeniedCall = verifyDeniedCall;
|
|
38
40
|
exports.findToolUse = findToolUse;
|
|
39
41
|
// Proof that a denial really happened: the denied call must be in the session's own transcript.
|
|
40
42
|
//
|
|
41
43
|
// WHY. `baychat denied-hook` reads its event from stdin, and stdin is something anybody can
|
|
42
44
|
// write — including the agent, by running `echo '{…}' | baychat denied-hook` itself. A forged
|
|
43
|
-
// event would put a card on the owner's phone claiming a block that never occurred. So
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
45
|
+
// event would put a card on the owner's phone claiming a block that never occurred. So the call
|
|
46
|
+
// the event names (`tool_use_id`, `tool_name`, and the command or target) must appear as a tool
|
|
47
|
+
// call the MODEL made, in the transcript Claude Code keeps under `~/.claude/projects/` — a
|
|
48
|
+
// protected path the agent cannot write without a prompt. Before the card when it is already
|
|
49
|
+
// there; otherwise after the owner's yes, which is then taken back when it never appears.
|
|
47
50
|
//
|
|
48
51
|
// It is defence in depth, not the boundary: the owner's answer is. A card the agent somehow did
|
|
49
52
|
// cause still writes nothing unless the owner taps a yes.
|
|
50
53
|
//
|
|
51
|
-
// THE
|
|
52
|
-
//
|
|
54
|
+
// THE CALL IS NOT THERE YET WHILE THE HOOK RUNS. Measured on Claude Code 2.1.299 (2026-10-14, a
|
|
55
|
+
// file watcher on the transcript): the assistant line holding the denied `tool_use` — and the
|
|
56
|
+
// denial's result — reach the file only AFTER the PermissionDenied hook returns. The hook gave up
|
|
57
|
+
// at 15:32:46.742Z after waiting 45 s; the line landed at 15:32:47.029Z. Same for an MCP call. So
|
|
58
|
+
// waiting longer can never prove a denial up front. The hook now looks only briefly
|
|
59
|
+
// (TRANSCRIPT_WAIT_MS, for a Claude Code that does write first), and an absent call is proven the
|
|
60
|
+
// other way — the event came from the live Claude Code of that very session (live-session.ts) —
|
|
61
|
+
// and checked here AFTERWARDS, by `baychat permissions confirm` (confirm.ts, CONFIRM_WAIT_MS),
|
|
62
|
+
// which takes the rule back out when the call never shows up.
|
|
63
|
+
//
|
|
53
64
|
// For a subagent's call the hook still gets the MAIN transcript (with `agent_id`); its calls are
|
|
54
65
|
// in `<session>/subagents/agent-<id>.jsonl` beside it.
|
|
55
66
|
const fs = __importStar(require("fs"));
|
|
56
67
|
const os = __importStar(require("os"));
|
|
57
68
|
const path = __importStar(require("path"));
|
|
58
69
|
const permission_rule_1 = require("./permission-rule");
|
|
59
|
-
/**
|
|
60
|
-
|
|
70
|
+
/** The hook's own look at the transcript: brief, because Claude Code 2.1.299 writes the denied call
|
|
71
|
+
* only after the hook returns (above), so this succeeds only on a Claude Code that writes first. */
|
|
72
|
+
exports.TRANSCRIPT_WAIT_MS = 2_000;
|
|
73
|
+
/** How long the deferred confirmation (`baychat permissions confirm`) waits for the call to reach
|
|
74
|
+
* the transcript after the owner's yes. Measured: well under a second after the hook returns. */
|
|
75
|
+
exports.CONFIRM_WAIT_MS = 90_000;
|
|
61
76
|
const TRANSCRIPT_POLL_MS = 250;
|
|
62
77
|
/** Only the end of the file is read: the denied call is the newest thing in it. */
|
|
63
78
|
const TAIL_BYTES = 4 * 1024 * 1024;
|
|
79
|
+
/** Claude Code's config directory: `$CLAUDE_CONFIG_DIR`, else `~/.claude`. */
|
|
80
|
+
function claudeConfigDir(claudeDir) {
|
|
81
|
+
return claudeDir ?? process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
|
82
|
+
}
|
|
83
|
+
/** The file a call is written to: the main transcript, or a subagent's own beside it. */
|
|
84
|
+
function transcriptFileOf(transcriptPath, agentId) {
|
|
85
|
+
return agentId
|
|
86
|
+
? path.join(transcriptPath.slice(0, -".jsonl".length), "subagents", `${agentId.startsWith("agent-") ? agentId : `agent-${agentId}`}.jsonl`)
|
|
87
|
+
: transcriptPath;
|
|
88
|
+
}
|
|
64
89
|
/** Did the model really make this call in this session? NEVER THROWS. */
|
|
65
90
|
async function verifyDeniedCall(ref, deps = {}) {
|
|
66
91
|
const now = deps.now ?? Date.now;
|
|
@@ -68,17 +93,15 @@ async function verifyDeniedCall(ref, deps = {}) {
|
|
|
68
93
|
const realpath = deps.realpath ?? ((f) => fs.realpathSync(f));
|
|
69
94
|
const readTail = deps.readTail ?? readFileTail;
|
|
70
95
|
try {
|
|
71
|
-
const projects = realpath(path.join(deps.claudeDir
|
|
72
|
-
const wanted = (0, permission_rule_1.callKeyOf)(ref.toolName, ref.toolInput);
|
|
96
|
+
const projects = realpath(path.join(claudeConfigDir(deps.claudeDir), "projects"));
|
|
97
|
+
const wanted = ref.call ?? (0, permission_rule_1.callKeyOf)(ref.toolName, ref.toolInput);
|
|
73
98
|
if (!ref.toolUseId || !wanted)
|
|
74
99
|
return { ok: false, why: "the event names no call that can be checked" };
|
|
75
100
|
if (!path.isAbsolute(ref.transcriptPath) || !ref.transcriptPath.endsWith(".jsonl")) {
|
|
76
101
|
return { ok: false, why: "the event carries no transcript" };
|
|
77
102
|
}
|
|
78
|
-
const file = ref.agentId
|
|
79
|
-
|
|
80
|
-
: ref.transcriptPath;
|
|
81
|
-
const giveUpAt = now() + exports.TRANSCRIPT_WAIT_MS;
|
|
103
|
+
const file = transcriptFileOf(ref.transcriptPath, ref.agentId);
|
|
104
|
+
const giveUpAt = now() + (deps.waitMs ?? exports.TRANSCRIPT_WAIT_MS);
|
|
82
105
|
for (;;) {
|
|
83
106
|
let real;
|
|
84
107
|
try {
|
|
@@ -99,7 +122,7 @@ async function verifyDeniedCall(ref, deps = {}) {
|
|
|
99
122
|
: { ok: false, why: "the transcript's call is not the one the event names" };
|
|
100
123
|
}
|
|
101
124
|
if (now() >= giveUpAt)
|
|
102
|
-
return { ok: false, why: "the call is not in the session's transcript" };
|
|
125
|
+
return { ok: false, why: "the call is not in the session's transcript", absent: true };
|
|
103
126
|
await sleep(TRANSCRIPT_POLL_MS);
|
|
104
127
|
}
|
|
105
128
|
}
|
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
|
}
|