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.
Files changed (42) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +36 -3
  3. package/dist/approval-wait.js +46 -33
  4. package/dist/approve-hook.js +35 -1
  5. package/dist/auto-update/describe.js +66 -0
  6. package/dist/auto-update/notice.js +40 -0
  7. package/dist/auto-update/policy.js +110 -0
  8. package/dist/auto-update/relay-glue.js +143 -0
  9. package/dist/auto-update/store.js +140 -0
  10. package/dist/auto-update/updater.js +324 -0
  11. package/dist/channel/feed.js +50 -1
  12. package/dist/channel/permission.js +6 -0
  13. package/dist/channel/server.js +1 -0
  14. package/dist/claude-onboarding.js +3 -1
  15. package/dist/cli-help.js +6 -1
  16. package/dist/denied/after-hook.js +8 -2
  17. package/dist/denied/confirm.js +131 -0
  18. package/dist/denied/grants.js +60 -2
  19. package/dist/denied/hook-log.js +75 -0
  20. package/dist/denied/live-session.js +154 -0
  21. package/dist/denied/notice.js +29 -0
  22. package/dist/denied/permission-rule.js +9 -6
  23. package/dist/denied/settings-rules.js +61 -0
  24. package/dist/denied/transcript.js +39 -16
  25. package/dist/denied-hook.js +132 -30
  26. package/dist/doctor-command.js +7 -1
  27. package/dist/doctor.js +28 -0
  28. package/dist/main.js +11 -2
  29. package/dist/permissions-command.js +18 -1
  30. package/dist/protocol-content.js +1 -1
  31. package/dist/refresh-clients.js +36 -0
  32. package/dist/relay/acp/runner.js +4 -0
  33. package/dist/relay/commands.js +19 -3
  34. package/dist/relay/daemon.js +37 -1
  35. package/dist/relay/launcher/loop.js +4 -1
  36. package/dist/relay/self-restart.js +149 -0
  37. package/dist/relay/terminal-pane.js +74 -2
  38. package/dist/self-upgrade.js +92 -27
  39. package/dist/start-command.js +15 -1
  40. package/dist/update-command.js +51 -31
  41. package/dist/waiting-cards.js +107 -0
  42. package/package.json +4 -1
@@ -0,0 +1,140 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.loadAutoUpdateState = loadAutoUpdateState;
37
+ exports.saveAutoUpdateState = saveAutoUpdateState;
38
+ exports.loadAutoSetting = loadAutoSetting;
39
+ exports.saveAutoSetting = saveAutoSetting;
40
+ exports.writeUpdateNotice = writeUpdateNotice;
41
+ exports.takeUpdateNotice = takeUpdateNotice;
42
+ exports.acquireUpdateLock = acquireUpdateLock;
43
+ const fs = __importStar(require("fs"));
44
+ const path = __importStar(require("path"));
45
+ const config_1 = require("../config");
46
+ const parent_watch_1 = require("../relay/parent-watch");
47
+ const STATE_FILE = "auto-update.json";
48
+ const SETTING_FILE = "auto-update-setting.json";
49
+ const NOTICE_FILE = "update-notice.json";
50
+ const LOCK_FILE = "update.lock";
51
+ function loadAutoUpdateState() {
52
+ const raw = readJson(STATE_FILE);
53
+ return raw && typeof raw === "object" ? raw : {};
54
+ }
55
+ function saveAutoUpdateState(state) {
56
+ writeJsonAtomic(STATE_FILE, state);
57
+ }
58
+ function loadAutoSetting() {
59
+ const raw = readJson(SETTING_FILE);
60
+ return raw?.auto === "off" ? "off" : "on";
61
+ }
62
+ function saveAutoSetting(auto) {
63
+ writeJsonAtomic(SETTING_FILE, { auto });
64
+ }
65
+ /** Leave one line for the next `baychat` command to print. Replaces any earlier one. */
66
+ function writeUpdateNotice(text) {
67
+ writeJsonAtomic(NOTICE_FILE, { text, at: Date.now() });
68
+ }
69
+ /**
70
+ * The waiting notice, taken: read and deleted, so it is printed exactly once. Undefined when
71
+ * there is none, or when it cannot be deleted (printing it again on every command would be worse
72
+ * than not printing it).
73
+ */
74
+ function takeUpdateNotice() {
75
+ const file = path.join((0, config_1.configDir)(), NOTICE_FILE);
76
+ const raw = readJson(NOTICE_FILE);
77
+ if (typeof raw?.text !== "string" || !raw.text.trim())
78
+ return undefined;
79
+ try {
80
+ fs.rmSync(file);
81
+ }
82
+ catch {
83
+ return undefined;
84
+ }
85
+ return raw.text.trim();
86
+ }
87
+ /**
88
+ * Hold the install lock: one npm install over this computer's baychat at a time, whether the
89
+ * relay or a person started it. Returns the release, or undefined when a live process holds it.
90
+ * A lock whose holder is gone (a crash) is taken over.
91
+ */
92
+ function acquireUpdateLock(pid = process.pid) {
93
+ const file = path.join((0, config_1.configDir)(), LOCK_FILE);
94
+ for (let attempt = 0; attempt < 2; attempt++) {
95
+ try {
96
+ fs.mkdirSync((0, config_1.configDir)(), { recursive: true });
97
+ fs.writeFileSync(file, String(pid), { flag: "wx", mode: 0o600 });
98
+ return () => fs.rmSync(file, { force: true });
99
+ }
100
+ catch (err) {
101
+ if (err.code !== "EEXIST")
102
+ return undefined;
103
+ const holder = Number(readText(file));
104
+ if (Number.isInteger(holder) && holder > 1 && holder !== pid && (0, parent_watch_1.processIsAlive)(holder))
105
+ return undefined;
106
+ // The holder is gone: the lock is stale. Removed and tried once more.
107
+ fs.rmSync(file, { force: true });
108
+ }
109
+ }
110
+ return undefined;
111
+ }
112
+ function readJson(name) {
113
+ const text = readText(path.join((0, config_1.configDir)(), name));
114
+ if (text === undefined)
115
+ return undefined;
116
+ try {
117
+ return JSON.parse(text);
118
+ }
119
+ catch {
120
+ // A torn or hand-edited file reads as absent: every reader has a safe default.
121
+ return undefined;
122
+ }
123
+ }
124
+ function readText(file) {
125
+ try {
126
+ return fs.readFileSync(file, "utf8");
127
+ }
128
+ catch {
129
+ // Missing is the ordinary first-run case; unreadable reads the same way.
130
+ return undefined;
131
+ }
132
+ }
133
+ /** Write via a temporary file and a rename, so a reader never sees half a file. */
134
+ function writeJsonAtomic(name, value) {
135
+ const file = path.join((0, config_1.configDir)(), name);
136
+ const tmp = `${file}.${process.pid}.tmp`;
137
+ fs.mkdirSync((0, config_1.configDir)(), { recursive: true });
138
+ fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
139
+ fs.renameSync(tmp, file);
140
+ }
@@ -0,0 +1,324 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AutoUpdater = exports.TICK_MS = exports.UPDATE_NOW_INDEX = exports.UPDATE_OPTIONS = void 0;
4
+ const self_upgrade_1 = require("../self-upgrade");
5
+ const policy_1 = require("./policy");
6
+ const self_restart_1 = require("../relay/self-restart");
7
+ exports.UPDATE_OPTIONS = ["Update now", "Later"];
8
+ exports.UPDATE_NOW_INDEX = 0;
9
+ /** How often the state is looked at. Cheap: one small file and the clock. */
10
+ exports.TICK_MS = 60_000;
11
+ class AutoUpdater {
12
+ deps;
13
+ startedAt;
14
+ /** Spreads this relay's checks; fixed for its lifetime so the schedule does not wander. */
15
+ spread;
16
+ timer;
17
+ running = false;
18
+ stopped = false;
19
+ lastCardPoll = 0;
20
+ constructor(deps) {
21
+ this.deps = deps;
22
+ this.startedAt = deps.now();
23
+ this.spread = deps.random();
24
+ }
25
+ /** Finish an update the previous relay installed, then look at the state once a minute. */
26
+ start() {
27
+ void this.runExclusive(() => this.finishPendingUpdate());
28
+ this.timer = setInterval(() => void this.tick(), exports.TICK_MS);
29
+ this.timer.unref();
30
+ }
31
+ stop() {
32
+ this.stopped = true;
33
+ clearInterval(this.timer);
34
+ }
35
+ status() {
36
+ const state = this.deps.loadState();
37
+ const { mode, reason } = this.mode();
38
+ return {
39
+ mode,
40
+ reason,
41
+ current: this.deps.current,
42
+ latest: state.latest,
43
+ lastCheckAt: state.lastCheckAt,
44
+ waitingFor: state.waitingFor,
45
+ cardVersion: state.card?.requestId ? state.card.version : undefined,
46
+ failure: state.failure?.reason,
47
+ };
48
+ }
49
+ /** One look at the state. Never throws; never runs twice at once. */
50
+ async tick() {
51
+ await this.runExclusive(async () => {
52
+ if (this.stopped)
53
+ return;
54
+ const state = this.deps.loadState();
55
+ const { mode, reason } = this.mode();
56
+ if (state.mode !== mode || state.modeReason !== reason || state.current !== this.deps.current) {
57
+ Object.assign(state, { mode, modeReason: reason, current: this.deps.current });
58
+ this.deps.saveState(state);
59
+ }
60
+ // New files are on disk under old code: restarting onto them comes before anything else,
61
+ // whatever the setting says now — running half old, half new is the one state to avoid.
62
+ if (state.finish)
63
+ return this.finishPendingUpdate();
64
+ if (mode === "off" || mode === "none")
65
+ return;
66
+ const now = this.deps.now();
67
+ if (now >= (0, policy_1.nextCheckAt)(state, this.startedAt, this.spread))
68
+ await this.check(state, mode);
69
+ if (mode !== "auto")
70
+ return;
71
+ if (state.card?.requestId && now - this.lastCardPoll >= policy_1.CARD_POLL_MS)
72
+ await this.pollCard(state);
73
+ if (state.target)
74
+ await this.installTarget(state);
75
+ });
76
+ }
77
+ mode() {
78
+ const off = (0, policy_1.autoUpdateOffReason)(this.deps.env, this.deps.setting());
79
+ if (off)
80
+ return { mode: "off", reason: off };
81
+ const { install, supervised, root } = this.deps.ability;
82
+ if (install.kind === "source")
83
+ return { mode: "none", reason: "running from a checkout" };
84
+ if (install.kind === "npx")
85
+ return { mode: "notify", reason: "for an npx copy (use `npx baychat@latest`)" };
86
+ if (install.kind === "local")
87
+ return { mode: "notify", reason: "for a project install (update it there with `npm i baychat@latest`)" };
88
+ if (root)
89
+ return { mode: "notify", reason: "as root (install with `baychat update`)" };
90
+ if (!(0, self_restart_1.selfUpdateAllowedOn)(this.deps.ability.platform))
91
+ return { mode: "notify", reason: "on Windows (install with `baychat update`)" };
92
+ if (!supervised)
93
+ return { mode: "notify", reason: "for a relay started by hand (install with `baychat update`)" };
94
+ return { mode: "auto" };
95
+ }
96
+ /** Ask npm, and decide. */
97
+ async check(state, mode) {
98
+ const looked = await this.deps.lookupLatest();
99
+ state.lastCheckAt = this.deps.now();
100
+ if (!looked.ok) {
101
+ // Offline, npm down: quiet. Tried again within the hour, then less often.
102
+ state.checkFailures = (state.checkFailures ?? 0) + 1;
103
+ state.lastCheckError = looked.reason;
104
+ this.deps.log(`auto-update: could not ask npm (${looked.reason})`);
105
+ return this.deps.saveState(state);
106
+ }
107
+ state.checkFailures = 0;
108
+ state.lastCheckError = undefined;
109
+ state.latest = looked.release.version;
110
+ const plan = (0, policy_1.planUpdate)(this.deps.current, looked.release);
111
+ if (plan.kind === "current") {
112
+ // Updated some other way (`baychat update`), or nothing new: nothing may stay on the phone.
113
+ await this.dropCard(state);
114
+ state.target = undefined;
115
+ return this.deps.saveState(state);
116
+ }
117
+ if (mode === "notify") {
118
+ this.notifyOnce(state, `available:${plan.version}`, this.availableLine(plan.version));
119
+ return this.deps.saveState(state);
120
+ }
121
+ if (plan.kind === "auto") {
122
+ if (state.target?.version !== plan.version)
123
+ state.target = { version: plan.version, via: "auto" };
124
+ return this.deps.saveState(state);
125
+ }
126
+ await this.askOwner(state, plan.version, plan.reason);
127
+ this.deps.saveState(state);
128
+ }
129
+ /** The card for a version that needs the person. At most one per version, never while one waits. */
130
+ async askOwner(state, version, reason) {
131
+ if (state.target?.version === version)
132
+ return; // already said "Update now"
133
+ const card = state.card;
134
+ if (card?.version === version && (card.requestId || (card.laterUntil ?? 0) > this.deps.now()))
135
+ return;
136
+ if (card && card.version !== version)
137
+ await this.dropCard(state); // a newer version supersedes it
138
+ this.notifyOnce(state, `ask:${version}`, `baychat ${version} needs you: ${reason}. Run \`baychat update\` when you are ready.`);
139
+ const asked = await this.deps.cards.ask(`New BayChat for your computer (${version}): ${reason}`);
140
+ if (!asked) {
141
+ this.deps.log(`auto-update: ${version} needs the owner, and no card could be put up yet`);
142
+ return;
143
+ }
144
+ state.card = { version, reason, askedAt: this.deps.now(), ...asked };
145
+ this.deps.log(`auto-update: asked the owner about ${version} (${reason})`);
146
+ }
147
+ async pollCard(state) {
148
+ this.lastCardPoll = this.deps.now();
149
+ const card = state.card;
150
+ const answer = await this.deps.cards.answer(card.requestId);
151
+ if (answer.kind === "waiting")
152
+ return;
153
+ if (answer.kind === "update") {
154
+ this.deps.log(`auto-update: the owner chose to update to ${card.version}`);
155
+ state.target = { version: card.version, via: "card", conversationId: card.conversationId, session: card.session };
156
+ state.card = undefined;
157
+ }
158
+ else {
159
+ // "Later", or the card is gone (withdrawn, deleted): ask again in a few days, not before.
160
+ state.card = { ...card, requestId: undefined, laterUntil: this.deps.now() + policy_1.ASK_AGAIN_MS };
161
+ }
162
+ this.deps.saveState(state);
163
+ }
164
+ /** Install the decided version in a quiet moment, then restart onto it in another. */
165
+ async installTarget(state) {
166
+ const target = state.target;
167
+ if ((0, self_upgrade_1.compareVersions)(this.deps.current, target.version) !== -1) {
168
+ state.target = undefined;
169
+ return this.deps.saveState(state);
170
+ }
171
+ if (this.deps.now() < (0, policy_1.installAllowedAt)(state.failure, target.version))
172
+ return;
173
+ const busy = this.deps.busyReason();
174
+ if (busy)
175
+ return this.wait(state, busy);
176
+ this.deps.log(`auto-update: installing baychat ${target.version} (from ${this.deps.current})`);
177
+ const installed = await this.deps.installVersion(target.version);
178
+ if (!installed.ok) {
179
+ const count = state.failure?.version === target.version ? state.failure.count + 1 : 1;
180
+ state.failure = { version: target.version, count, at: this.deps.now(), reason: installed.reason };
181
+ state.waitingFor = undefined;
182
+ this.deps.log(`auto-update: install of ${target.version} failed (${installed.reason}); ${this.deps.current} keeps running`);
183
+ this.notifyOnce(state, `failed:${target.version}`, `BayChat could not update itself to ${target.version}: ${installed.reason}`);
184
+ return this.deps.saveState(state);
185
+ }
186
+ // The new code is on disk. From here the NEW relay finishes, whoever restarts it.
187
+ state.finish = { from: this.deps.current, to: installed.to, via: target.via, conversationId: target.conversationId, session: target.session };
188
+ state.target = undefined;
189
+ state.failure = undefined;
190
+ state.waitingFor = undefined;
191
+ this.deps.saveState(state);
192
+ await this.restartWhenQuiet();
193
+ }
194
+ /** Restart now if nothing is going on, else on a later tick (the `finish` step marks it owed). */
195
+ async restartWhenQuiet() {
196
+ const state = this.deps.loadState();
197
+ if (!state.finish)
198
+ return;
199
+ const busy = this.deps.busyReason();
200
+ if (busy)
201
+ return this.wait(state, busy);
202
+ state.finish.restarts = (state.finish.restarts ?? 0) + 1;
203
+ state.waitingFor = undefined;
204
+ this.deps.saveState(state);
205
+ this.deps.log(`auto-update: restarting onto baychat ${state.finish.to}`);
206
+ await this.deps.restart();
207
+ }
208
+ wait(state, busy) {
209
+ if (state.waitingFor === busy)
210
+ return;
211
+ state.waitingFor = busy;
212
+ this.deps.saveState(state);
213
+ }
214
+ /**
215
+ * Run at start. If the previous relay installed a version and this IS that version, finish it.
216
+ * If this is still the old code (its restart came up on the old files somehow), the install
217
+ * evidently did not land: forget it, and the next check decides again.
218
+ */
219
+ async finishPendingUpdate() {
220
+ const state = this.deps.loadState();
221
+ const finish = state.finish;
222
+ if (!finish)
223
+ return this.dropStaleCard(state);
224
+ if ((0, self_upgrade_1.compareVersions)(this.deps.current, finish.to) === -1) {
225
+ // Still the old code. Never restarted yet (the old relay died first): restart onto the new
226
+ // files. Restarted already and STILL old: the init system runs a different copy than the one
227
+ // updated. Give up on it rather than restart forever; the failure backs the next try off.
228
+ if (!finish.restarts)
229
+ return this.restartWhenQuiet();
230
+ const reason = `the relay restarted but still runs ${this.deps.current}`;
231
+ state.finish = undefined;
232
+ state.failure = { version: finish.to, count: 1, at: this.deps.now(), reason };
233
+ this.deps.log(`auto-update: ${reason}, not ${finish.to}`);
234
+ this.notifyOnce(state, `failed:${finish.to}`, `BayChat could not update itself to ${finish.to}: ${reason}. Run \`baychat update\`.`);
235
+ return this.deps.saveState(state);
236
+ }
237
+ state.finish = undefined;
238
+ state.lastUpdate = { from: finish.from, to: finish.to, at: this.deps.now() };
239
+ this.deps.saveState(state);
240
+ await this.refreshClientsQuietly();
241
+ this.notifyOnce(state, `updated:${finish.to}`, `BayChat updated itself to ${finish.to} (from ${finish.from}).`);
242
+ this.deps.saveState(state);
243
+ this.deps.log(`auto-update: now running baychat ${finish.to} (from ${finish.from})`);
244
+ await this.sayDone(finish);
245
+ await this.dropStaleCard(this.deps.loadState());
246
+ }
247
+ async refreshClientsQuietly() {
248
+ try {
249
+ await this.deps.refreshClients();
250
+ }
251
+ catch (err) {
252
+ this.deps.log(`auto-update: could not refresh client configs (${errText(err)})`);
253
+ }
254
+ }
255
+ /** The owner asked for this one: tell them, once, where they asked. A routine update stays quiet. */
256
+ async sayDone(finish) {
257
+ if (finish.via !== "card" || !finish.conversationId || !finish.session)
258
+ return;
259
+ try {
260
+ await this.deps.cards.say(finish.conversationId, finish.session, `Updated BayChat on this computer to ${finish.to}.`);
261
+ }
262
+ catch (err) {
263
+ this.deps.log(`auto-update: could not say the update was done (${errText(err)})`);
264
+ }
265
+ }
266
+ /** A card about a version this relay already runs (updated by hand meanwhile) comes off the phone. */
267
+ async dropStaleCard(state) {
268
+ if (!state.card || (0, self_upgrade_1.compareVersions)(this.deps.current, state.card.version) === -1)
269
+ return;
270
+ await this.dropCard(state);
271
+ this.deps.saveState(state);
272
+ }
273
+ async dropCard(state) {
274
+ const requestId = state.card?.requestId;
275
+ state.card = undefined;
276
+ if (!requestId)
277
+ return;
278
+ try {
279
+ await this.deps.cards.withdraw(requestId);
280
+ }
281
+ catch (err) {
282
+ this.deps.log(`auto-update: could not withdraw the update card (${errText(err)})`);
283
+ }
284
+ }
285
+ /** One line for the terminal, and never the same line twice. */
286
+ notifyOnce(state, key, text) {
287
+ if (state.noticeKey === key)
288
+ return;
289
+ state.noticeKey = key;
290
+ try {
291
+ this.deps.notify(text);
292
+ }
293
+ catch (err) {
294
+ this.deps.log(`auto-update: could not leave the terminal notice (${errText(err)})`);
295
+ }
296
+ }
297
+ availableLine(version) {
298
+ const { install } = this.deps.ability;
299
+ if (install.kind === "npx")
300
+ return `baychat ${version} is available. Use \`npx baychat@latest\`, or install it once with \`npm i -g baychat\` so it updates itself.`;
301
+ if (install.kind === "local")
302
+ return `baychat ${version} is available. Update it in ${install.packageRoot} with \`npm i baychat@latest\`.`;
303
+ return `baychat ${version} is available. Run \`baychat update\`.`;
304
+ }
305
+ async runExclusive(work) {
306
+ if (this.running)
307
+ return;
308
+ this.running = true;
309
+ try {
310
+ await work();
311
+ }
312
+ catch (err) {
313
+ // A bug in one look must not end the loop: the next tick starts from the state on disk.
314
+ this.deps.log(`auto-update: ${errText(err)}`);
315
+ }
316
+ finally {
317
+ this.running = false;
318
+ }
319
+ }
320
+ }
321
+ exports.AutoUpdater = AutoUpdater;
322
+ function errText(err) {
323
+ return err instanceof Error ? err.message : String(err);
324
+ }
@@ -9,6 +9,9 @@ const READY_TIMEOUT_MS = 10_000;
9
9
  const WATCHDOG_MS = 2 * 60_000;
10
10
  /** A timer that fires this much later than asked means the computer slept: reconnect at once. */
11
11
  const SLEEP_GAP_MS = 60_000;
12
+ /** The card's words, as one short line: they are the session's own, but this is paid output. */
13
+ const APPROVAL_TEXT_MAX = 160;
14
+ const REQUEST_ID = /^[A-Za-z0-9_-]{1,64}$/;
12
15
  class ChannelFeed {
13
16
  deps;
14
17
  state = "connecting";
@@ -248,7 +251,20 @@ class ChannelFeed {
248
251
  this.armWatchdog();
249
252
  }
250
253
  async pushMessages(frame) {
251
- const items = (frame.messages ?? []).filter((m) => typeof m?.conversationId === "string" && typeof m?.messageId === "string");
254
+ const entries = (frame.messages ?? []).filter((m) => typeof m?.conversationId === "string" && typeof m?.messageId === "string");
255
+ // An answered decision is said as itself; anything that does not read as one stays an
256
+ // ordinary wake, so a malformed entry can never make the session miss a message.
257
+ const approvals = [];
258
+ const items = [];
259
+ for (const entry of entries) {
260
+ const approval = answeredApproval(entry);
261
+ if (approval)
262
+ approvals.push(approval);
263
+ else
264
+ items.push(entry);
265
+ }
266
+ for (const approval of approvals)
267
+ await this.pushApproval(approval);
252
268
  if (!items.length)
253
269
  return;
254
270
  const conversations = [...new Set(items.map((m) => m.conversationId))];
@@ -262,6 +278,15 @@ class ChannelFeed {
262
278
  message_ids: items.map((m) => m.messageId).join(","),
263
279
  }));
264
280
  }
281
+ async pushApproval(a) {
282
+ await this.deps.push(`Your decision "${a.question}" (${a.requestId}) in conversation ${a.conversationId} was answered: "${a.answer}". ` +
283
+ `Call await_approval with this requestId to confirm, then continue.`, this.meta({
284
+ event: "approval",
285
+ conversation_ids: a.conversationId,
286
+ message_ids: a.messageId,
287
+ request_id: a.requestId,
288
+ }));
289
+ }
265
290
  async pushReset() {
266
291
  await this.deps.push(`BayChat reset the feed for session "${this.deps.session}". Catch up on your joined conversations with get_messages; ` +
267
292
  `a reset never means there were no messages. The channel stays open.`, this.meta({ event: "reset" }));
@@ -282,6 +307,30 @@ class ChannelFeed {
282
307
  }
283
308
  }
284
309
  exports.ChannelFeed = ChannelFeed;
310
+ /** An entry's `approval`, when it is a well-formed answered decision; else undefined. */
311
+ function answeredApproval(entry) {
312
+ const a = entry.approval;
313
+ if (!a || typeof a !== "object" || a.status !== "answered")
314
+ return undefined;
315
+ if (typeof a.requestId !== "string" || !REQUEST_ID.test(a.requestId))
316
+ return undefined;
317
+ if (typeof a.question !== "string" || typeof a.answer !== "string")
318
+ return undefined;
319
+ const question = oneLine(a.question);
320
+ const answer = oneLine(a.answer);
321
+ if (!answer)
322
+ return undefined;
323
+ return { conversationId: entry.conversationId, messageId: entry.messageId, requestId: a.requestId, question, answer };
324
+ }
325
+ /** One line of plain text, no control characters or quotes that would blur where it ends. */
326
+ function oneLine(text) {
327
+ const line = text
328
+ .replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, " ")
329
+ .replace(/"/g, "'")
330
+ .replace(/\s+/g, " ")
331
+ .trim();
332
+ return line.length > APPROVAL_TEXT_MAX ? `${line.slice(0, APPROVAL_TEXT_MAX - 1).trimEnd()}…` : line;
333
+ }
285
334
  function parseFrame(data) {
286
335
  try {
287
336
  const parsed = JSON.parse(data);
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.PermissionRelay = exports.RELAY_OPTIONS = void 0;
4
4
  exports.composeRelayQuestion = composeRelayQuestion;
5
+ const waiting_cards_1 = require("../waiting-cards");
5
6
  /** Button labels, and the ONLY index that means yes. Same as `baychat approve-hook`. */
6
7
  exports.RELAY_OPTIONS = ["Allow this once", "Deny"];
7
8
  const ALLOW_INDEX = 0;
@@ -40,6 +41,7 @@ class PermissionRelay {
40
41
  return; // Nobody is listening through this channel: the terminal alone decides.
41
42
  const pending = { claudeId: req.request_id, login: target.login, retired: false };
42
43
  this.current = pending;
44
+ let unmark = () => undefined;
43
45
  try {
44
46
  let reply = await this.deps.api(target.login, "POST", "/api/device-api/approvals", {
45
47
  session: target.session,
@@ -55,6 +57,9 @@ class PermissionRelay {
55
57
  this.deps.log("permission relay: BayChat returned no card id");
56
58
  return;
57
59
  }
60
+ // Marked while the card waits, so the relay's automatic update never restarts BayChat
61
+ // under an open question (waiting-cards.ts).
62
+ unmark = (0, waiting_cards_1.markWaitingCard)(pending.cardId);
58
63
  let failures = 0;
59
64
  for (;;) {
60
65
  if (pending.retired) {
@@ -87,6 +92,7 @@ class PermissionRelay {
87
92
  this.deps.log(`permission relay: could not ask BayChat (${err instanceof Error ? err.message : String(err)}); the terminal still decides`);
88
93
  }
89
94
  finally {
95
+ unmark();
90
96
  if (this.current === pending)
91
97
  this.current = undefined;
92
98
  }
@@ -83,6 +83,7 @@ exports.CHANNEL_INSTRUCTIONS = `This is BayChat's wake-up channel. After join_se
83
83
  `If listen answers inactive or failed, use listen_messages and a native Monitor as before. ` +
84
84
  `event="reset" means catch up with get_messages; event="stopped" means the feed ended and says why; ` +
85
85
  `event="handoff" is BayChat's own request for a handoff note before a fresh start: do what it says. ` +
86
+ `event="approval" means the owner answered your request_approval card: call await_approval with its request_id, then continue. ` +
86
87
  `Tool-permission prompts are also offered to the session's owner as a BayChat card; answer in the terminal or ` +
87
88
  `on the phone, whichever is first. Answer in BayChat only; in the terminal write at most one short line, ` +
88
89
  `never the answer again (both are paid output).`;
@@ -73,7 +73,9 @@ Read those conversations with \`get_messages\` and answer as below. There is no
73
73
  deadline and nothing to re-arm; the channel reconnects by itself. \`event="reset"\`
74
74
  means catch up with \`get_messages\`; \`event="stopped"\` says why the feed ended
75
75
  (for an expired session: \`join_session\` again with the same name and room, then
76
- \`listen\` again). Never open a Monitor beside an active channel.
76
+ \`listen\` again); \`event="approval"\` means the owner answered your
77
+ \`request_approval\` card: call \`await_approval\` with its \`request_id\`, then
78
+ continue. Never open a Monitor beside an active channel.
77
79
 
78
80
  **With a Monitor:** keep the native Monitor (WebSocket or command) open between messages. On a \`messages\` event,
79
81
  read the indicated conversations with \`get_messages\`, deduplicate message IDs,
package/dist/cli-help.js CHANGED
@@ -29,7 +29,12 @@ Usage:
29
29
  baychat update Bring this machine onto the current version —
30
30
  the package, every connected client's config, and
31
31
  the relay daemon (which otherwise keeps running
32
- old code). \`baychat\` does this for you once a day
32
+ old code). The relay does this by itself about
33
+ once a day, in a quiet moment, and asks on your
34
+ phone first when a version needs you
35
+ baychat update --auto on|off Switch those automatic updates on or off for this
36
+ computer (default on; BAYCHAT_AUTO_UPDATE=0 also
37
+ turns them off)
33
38
  baychat join [name] [group] [--sessions | --private | --group <title>] [--runtime <runtime>] [--bay <Bay>]
34
39
  baychat join --session <name> --group <title> --runtime codex
35
40
  Join and connect incoming messages in this terminal
@@ -11,20 +11,26 @@ exports.cmdDeniedAfter = cmdDeniedAfter;
11
11
  // CLI, and it returns at once when no once-grant exists — the common case by far.
12
12
  //
13
13
  // The TTL sweep (grants.ts, ONCE_TTL_MS) is the backstop for a retried call that never finishes,
14
- // and every run here sweeps too.
14
+ // and every run here sweeps too — including a grant whose deferred confirmation never finished,
15
+ // which is taken back and the owner told (confirm.ts; it loads only the card client and config,
16
+ // no MCP SDK and none of the CLI's commands).
15
17
  const hook_stdin_1 = require("../hook-stdin");
18
+ const confirm_1 = require("./confirm");
16
19
  const grants_1 = require("./grants");
17
20
  const permission_rule_1 = require("./permission-rule");
18
21
  /** Handle one finished call. NEVER THROWS; diagnostics go to stderr. */
19
22
  async function afterCall(rawStdin, log = defaultLog) {
20
23
  try {
21
- if (!(0, grants_1.hasOnceGrants)())
24
+ if (!(0, grants_1.hasGrantsToSweep)())
22
25
  return;
23
26
  const input = JSON.parse(rawStdin);
24
27
  const call = typeof input.tool_name === "string" ? (0, permission_rule_1.callKeyOf)(input.tool_name, input.tool_input) : undefined;
25
28
  if (call && typeof input.session_id === "string")
26
29
  await (0, grants_1.consumeOnceGrants)(input.session_id, call);
27
30
  await (0, grants_1.sweepExpiredGrants)();
31
+ if ((0, grants_1.hasStaleUnconfirmed)()) {
32
+ await (0, confirm_1.takeBackStaleUnconfirmed)({ log });
33
+ }
28
34
  }
29
35
  catch (err) {
30
36
  log(`baychat denied-after: ${err instanceof Error ? err.message : String(err)}`);