baychat 0.21.5 → 0.22.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 (40) hide show
  1. package/README.md +53 -1
  2. package/dist/approve-hook.js +5 -2
  3. package/dist/commands.js +6 -4
  4. package/dist/connect-dsh.js +165 -0
  5. package/dist/connect-plan.js +16 -1
  6. package/dist/connect.js +7 -0
  7. package/dist/doctor-command.js +7 -0
  8. package/dist/doctor.js +94 -1
  9. package/dist/dsh-config.js +147 -0
  10. package/dist/index.js +22 -0
  11. package/dist/relay/acp/agents.js +186 -0
  12. package/dist/relay/acp/approval.js +67 -0
  13. package/dist/relay/acp/client.js +253 -0
  14. package/dist/relay/acp/commands.js +69 -0
  15. package/dist/relay/acp/daemon-glue.js +201 -0
  16. package/dist/relay/acp/dump-config.js +31 -0
  17. package/dist/relay/acp/modes.js +36 -0
  18. package/dist/relay/acp/permissions.js +42 -0
  19. package/dist/relay/acp/policy.js +137 -0
  20. package/dist/relay/acp/presence.js +87 -0
  21. package/dist/relay/acp/prompt.js +69 -0
  22. package/dist/relay/acp/runner.js +311 -0
  23. package/dist/relay/acp/sdk.js +19 -0
  24. package/dist/relay/acp/turn-queue.js +163 -0
  25. package/dist/relay/acp/types.js +2 -0
  26. package/dist/relay/commands.js +28 -0
  27. package/dist/relay/daemon.js +195 -0
  28. package/dist/relay/mailbox.js +1 -0
  29. package/dist/relay/profiles.js +22 -0
  30. package/dist/relay/registry.js +46 -1
  31. package/dist/relay/session-commands.js +89 -0
  32. package/dist/relay/session-reply.js +25 -0
  33. package/dist/relay/terminal-pane.js +197 -0
  34. package/dist/relay/types.js +0 -9
  35. package/dist/runtimes.js +13 -0
  36. package/dist/session-command.js +16 -3
  37. package/dist/skill-bootstrap.js +16 -8
  38. package/dist/start-command.js +235 -0
  39. package/dist/update-command.js +201 -0
  40. package/package.json +3 -1
@@ -0,0 +1,311 @@
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.AcpSessionRunner = void 0;
37
+ exports.effectiveMode = effectiveMode;
38
+ const os = __importStar(require("os"));
39
+ const spawn_env_1 = require("../spawn-env");
40
+ const agents_1 = require("./agents");
41
+ const approval_1 = require("./approval");
42
+ const client_1 = require("./client");
43
+ const modes_1 = require("./modes");
44
+ const permissions_1 = require("./permissions");
45
+ const policy_1 = require("./policy");
46
+ const presence_1 = require("./presence");
47
+ const prompt_1 = require("./prompt");
48
+ /**
49
+ * How much power ONE turn gets.
50
+ *
51
+ * The session's mode is a maximum, not a grant. It applies only when EVERY addressed message in
52
+ * the batch is from someone with command access to this session — the owner, or a Bay owner/admin
53
+ * the owner granted (the server's `commandAuthorized`, re-checked on every message; `senderIsOwner`
54
+ * also counts; absent = no). One message from anyone else makes the whole turn `chat`, because a
55
+ * coalesced prompt cannot give half of itself tools. (Owner-only until 19 Sep 2026 — Karmen's call.)
56
+ * Then the rule file's ceiling, then what this platform can actually enforce.
57
+ */
58
+ function effectiveMode(sessionMode, ceiling, allAuthorized, allowedHere) {
59
+ if (!allAuthorized)
60
+ return "chat";
61
+ const wanted = (0, modes_1.clampMode)(sessionMode, ceiling);
62
+ const usable = modes_1.ACP_MODES.filter((m) => allowedHere.includes(m) && (0, modes_1.modeRank)(m) <= (0, modes_1.modeRank)(wanted));
63
+ return usable.at(-1) ?? "chat";
64
+ }
65
+ const RETRY_MAX_MS = 60_000;
66
+ const errText = (err) => (err instanceof Error ? err.message : String(err));
67
+ /**
68
+ * Runs an ACP session's turns ONE AT A TIME, in arrival order.
69
+ *
70
+ * `handle` never blocks: it files the batch and makes sure a drain loop is running. That is what
71
+ * lets a `/stop` typed during a long turn be processed at all — the daemon's per-session delivery
72
+ * lock is already free again.
73
+ */
74
+ class AcpSessionRunner {
75
+ deps;
76
+ running = new Map();
77
+ loops = new Map();
78
+ said = new Map();
79
+ backoff = new Map();
80
+ retryTimers = new Set();
81
+ closed = false;
82
+ constructor(deps) {
83
+ this.deps = deps;
84
+ }
85
+ handle(session, batch) {
86
+ const { dropped } = this.deps.queue.add(session, batch);
87
+ if (dropped > 0)
88
+ this.deps.log(`acp: ${session} has too many messages waiting — ${dropped} not queued`);
89
+ this.kick(session);
90
+ }
91
+ stop(session) {
92
+ const turn = this.running.get(session);
93
+ if (!turn)
94
+ return false;
95
+ turn.abort();
96
+ return true;
97
+ }
98
+ reset(session) {
99
+ this.deps.registry.setAcpSession(session, undefined);
100
+ }
101
+ setMode(session, wanted) {
102
+ const target = this.deps.registry.get(session);
103
+ if (!target?.acp)
104
+ return { ok: false, reason: `"${session}" is not run by the relay, so it has no mode.` };
105
+ const row = (0, agents_1.acpAgent)(target.acp.agent);
106
+ if (!row)
107
+ return { ok: false, reason: `This relay does not know the agent "${target.acp.agent}".` };
108
+ const folder = (0, policy_1.checkFolder)((this.deps.loadPolicy ?? policy_1.loadPolicy)(), row.id, target.acp.cwd);
109
+ if (!folder.ok)
110
+ return { ok: false, reason: folder.reason };
111
+ const allowed = (0, agents_1.modeAllowedHere)(row, wanted, folder.maxMode, this.deps.platform ?? process.platform);
112
+ if (!allowed.ok)
113
+ return allowed;
114
+ this.deps.registry.setAcpMode(session, wanted);
115
+ return { ok: true, mode: wanted };
116
+ }
117
+ status(session) {
118
+ return { running: this.running.has(session), queued: this.deps.queue.depth(session) };
119
+ }
120
+ async idle() {
121
+ while (this.loops.size > 0)
122
+ await Promise.all([...this.loops.values()]);
123
+ }
124
+ shutdown() {
125
+ this.closed = true;
126
+ for (const t of this.retryTimers)
127
+ clearTimeout(t);
128
+ this.retryTimers.clear();
129
+ for (const turn of this.running.values())
130
+ turn.abort();
131
+ }
132
+ kick(session) {
133
+ if (this.closed || this.loops.has(session))
134
+ return;
135
+ const loop = this.drain(session)
136
+ .catch((err) => {
137
+ this.deps.log(`acp: ${session} drain failed — ${errText(err)}`);
138
+ return "failed";
139
+ })
140
+ .then((end) => {
141
+ this.loops.delete(session);
142
+ // A `handle` that landed after the drain saw an empty queue found this loop still
143
+ // registered and did not start one; pick its batch up now rather than at the next message.
144
+ if (end === "empty" && !this.closed && this.deps.queue.depth(session) > 0)
145
+ this.kick(session);
146
+ });
147
+ this.loops.set(session, loop);
148
+ }
149
+ async drain(session) {
150
+ for (;;) {
151
+ if (this.closed)
152
+ return "closed";
153
+ const turn = this.deps.queue.take(session);
154
+ if (!turn)
155
+ return "empty";
156
+ let next;
157
+ try {
158
+ next = await this.runOne(session, turn);
159
+ }
160
+ catch (err) {
161
+ // A bug or a disk error, not an outcome. The turn was taken off the queue, so it is
162
+ // recorded pending (never silently lost) and the loop moves on to the next one.
163
+ this.deps.log(`acp: ${session} turn failed unexpectedly — ${errText(err)}`);
164
+ this.deps.onPending(session, turn.addressed, "the relay failed while running this turn");
165
+ next = "next";
166
+ }
167
+ if (next === "retry-later")
168
+ return next;
169
+ }
170
+ }
171
+ async sayOnce(session, key, conversationId, text) {
172
+ const seen = this.said.get(session) ?? new Set();
173
+ if (seen.has(key))
174
+ return;
175
+ seen.add(key);
176
+ this.said.set(session, seen);
177
+ try {
178
+ await this.deps.say(conversationId, session, text);
179
+ }
180
+ catch (err) {
181
+ this.deps.log(`acp: could not tell the room about ${session} (${key}) — ${errText(err)}`);
182
+ }
183
+ }
184
+ async runOne(session, turn) {
185
+ const { deps } = this;
186
+ const room = turn.addressed.at(-1).conversationId;
187
+ const drop = async (key, reason) => {
188
+ deps.onPending(session, turn.addressed, reason);
189
+ await this.sayOnce(session, key, room, reason);
190
+ return "next";
191
+ };
192
+ const target = deps.registry.get(session);
193
+ const acp = target?.acp;
194
+ const row = acp ? (0, agents_1.acpAgent)(acp.agent) : undefined;
195
+ if (!target || !acp || !row)
196
+ return drop("setup", `"${session}" is not set up on that computer any more. Run \`baychat connect dsh\` there.`);
197
+ const auth = deps.auth();
198
+ if (!auth)
199
+ return drop("setup", `The relay on that computer is not logged in to BayChat. Run \`baychat connect ${row.id}\` there.`);
200
+ if (!target.runtimeBin)
201
+ return drop("spawn", `${row.label} is not installed where the relay can find it. ${row.installHint}`);
202
+ const folder = (0, policy_1.checkFolder)((deps.loadPolicy ?? policy_1.loadPolicy)(), row.id, acp.cwd);
203
+ if (!folder.ok)
204
+ return drop("policy", folder.reason);
205
+ const platform = deps.platform ?? process.platform;
206
+ const allAuthorized = turn.addressed.every((m) => m.senderIsOwner === true || m.commandAuthorized === true);
207
+ const mode = effectiveMode(acp.mode, folder.maxMode, allAuthorized, row.modesFor(platform));
208
+ const patchDir = deps.patchDir ?? (0, agents_1.acpPatchDir)();
209
+ (0, agents_1.writeModePatches)(row, patchDir); // cheap, and an upgrade that changes a patch takes effect at once
210
+ const launch = row.launch(mode, patchDir);
211
+ const onPermission = async (call) => {
212
+ const host = (deps.hostname ?? os.hostname)();
213
+ const decision = (0, permissions_1.decidePermission)(mode, call, { cwd: acp.cwd, host, agentLabel: row.label });
214
+ if (decision.kind === "refuse") {
215
+ deps.log(`acp: ${session} permission REFUSED — ${decision.reason}`);
216
+ return "reject";
217
+ }
218
+ typing?.pause(); // the card is the signal while a person decides
219
+ const answer = await (deps.askPhone ?? approval_1.askPhone)({ auth, session, conversationId: room, question: decision.question });
220
+ typing?.resume(); // a no-op once the turn has stopped typing — `stop()` is final
221
+ deps.log(`acp: ${session} permission ${answer.allowed ? "ALLOWED" : "REFUSED"} — ${answer.reason}`);
222
+ return answer.allowed ? "allow" : "reject";
223
+ };
224
+ const caller = (deps.caller ?? presence_1.deviceMcpCaller)(auth);
225
+ const abort = new AbortController();
226
+ let typing;
227
+ let outcome;
228
+ try {
229
+ this.running.set(session, abort);
230
+ typing = (0, presence_1.startTyping)(caller, session, room, { log: deps.log });
231
+ deps.log(`acp: ${session} turn — ${turn.addressed.length} addressed, ${turn.digest.messages.length} context, mode ${mode}${allAuthorized ? "" : " (not all from someone with command access)"}`);
232
+ outcome = await (deps.runTurn ?? client_1.runAcpTurn)({
233
+ binaryPath: target.runtimeBin,
234
+ args: launch.args,
235
+ env: { ...(deps.env ?? spawn_env_1.headlessSpawnEnv)(), ...launch.env },
236
+ cwd: acp.cwd,
237
+ sessionId: acp.sessionId,
238
+ mcpServers: [{ type: "http", name: "baychat", url: `${auth.baseUrl.replace(/\/$/, "")}/api/mcp`, headers: [{ name: "Authorization", value: `Bearer ${auth.token}` }] }],
239
+ prompt: `Mode for this turn: ${mode}.\n\n${(0, prompt_1.turnPrompt)(turn)}`,
240
+ freshPreamble: (0, prompt_1.setupPreamble)(session, mode),
241
+ onPermission,
242
+ // Its message is in the room; a refreshed indicator after that would be a ghost.
243
+ onToolCall: (call) => {
244
+ if (call.title === "mcp__baychat__send_message")
245
+ typing?.stop();
246
+ },
247
+ classifyError: (m) => row.classifyError(m),
248
+ signal: abort.signal,
249
+ }, { spawn: deps.spawn, log: deps.log, platform });
250
+ }
251
+ finally {
252
+ // Every exit — outcome or throw — ends typing and frees `/stop`, or `/stop` would lie.
253
+ typing?.stop();
254
+ this.running.delete(session);
255
+ }
256
+ if (outcome.kind === "completed") {
257
+ deps.registry.setAcpSession(session, outcome.sessionId);
258
+ deps.onDelivered(session, turn.addressed);
259
+ this.said.delete(session);
260
+ this.backoff.delete(session);
261
+ if (outcome.memoryLost) {
262
+ await this.sayOnce(session, "session", room, `${row.label}'s earlier memory could not be reopened on that computer, so this conversation started fresh.`);
263
+ this.said.delete(session);
264
+ }
265
+ return "next";
266
+ }
267
+ if (outcome.cause === "baychat-unreachable")
268
+ return this.retryLater(session, turn);
269
+ if (outcome.cause === "cancelled") {
270
+ // Aborted by `shutdown`, not by a person: the batch is still owed an answer after restart.
271
+ if (this.closed) {
272
+ deps.queue.putBack(session, turn);
273
+ return "next";
274
+ }
275
+ deps.onPending(session, turn.addressed, "stopped from the chat");
276
+ return "next";
277
+ }
278
+ const reasons = {
279
+ "missing-key": row.keyHint,
280
+ busy: `${row.label} has this conversation open in a terminal on that computer. Close it there, or use a different session name for the terminal.`,
281
+ spawn: `${row.label} could not be started on that computer: ${outcome.reason}. ${row.installHint}`,
282
+ };
283
+ const named = reasons[outcome.cause];
284
+ if (named)
285
+ return drop(outcome.cause, named);
286
+ // Unclassified: the raw reason can name paths on that computer, so the room gets a generic
287
+ // line and the full (already redacted) reason goes to the log only (Ruling R29).
288
+ deps.log(`acp: ${session} could not answer (${outcome.cause}) — ${outcome.reason}`);
289
+ return drop(outcome.cause, `${row.label} could not answer this time. The details are in the relay's log on that computer.`);
290
+ }
291
+ /**
292
+ * The one retryable cause: nothing is wrong with the session, and the room cannot be told
293
+ * anyway. Put the turn back and try again, slower each time.
294
+ */
295
+ retryLater(session, turn) {
296
+ this.deps.queue.putBack(session, turn);
297
+ if (this.closed)
298
+ return "retry-later"; // kept for the restart; no timer outlives shutdown
299
+ const wait = Math.min((this.backoff.get(session) ?? (this.deps.retryBaseMs ?? 5_000) / 2) * 2, RETRY_MAX_MS);
300
+ this.backoff.set(session, wait);
301
+ this.deps.log(`acp: ${session} could not reach BayChat — retrying in ${Math.round(wait / 1000)}s`);
302
+ const timer = setTimeout(() => {
303
+ this.retryTimers.delete(timer);
304
+ this.kick(session);
305
+ }, wait);
306
+ timer.unref?.();
307
+ this.retryTimers.add(timer);
308
+ return "retry-later";
309
+ }
310
+ }
311
+ exports.AcpSessionRunner = AcpSessionRunner;
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ // The one door to `@agentclientprotocol/sdk`.
3
+ //
4
+ // WHY A DOOR. The SDK is published ESM-only (`"type": "module"`, no `require` export) and this
5
+ // package compiles to CommonJS (`module: node16`). A static `import` of it is a compile error
6
+ // (TS1479) and a `require` is a runtime error on Node 20. A dynamic `import()` is the one form
7
+ // that TypeScript preserves under node16 and that Node resolves from CJS — so every use of the
8
+ // SDK goes through here, and a second entry point cannot quietly reintroduce the problem.
9
+ //
10
+ // Types are imported where needed with:
11
+ // import type { X } from "@agentclientprotocol/sdk" with { "resolution-mode": "import" };
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.loadAcpSdk = loadAcpSdk;
14
+ let cached;
15
+ /** Load the SDK once per process. Rejects only if the package is missing from the install. */
16
+ function loadAcpSdk() {
17
+ cached ??= import("@agentclientprotocol/sdk");
18
+ return cached;
19
+ }
@@ -0,0 +1,163 @@
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.AcpTurnQueue = void 0;
37
+ exports.acpQueuePath = acpQueuePath;
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
40
+ const config_1 = require("../../config");
41
+ const DEFAULT_CAPS = { digestMessages: 20, digestChars: 4_000, maxAddressed: 50 };
42
+ function acpQueuePath() {
43
+ return path.join((0, config_1.configDir)(), "acp-queue.json");
44
+ }
45
+ /**
46
+ * The minimum shape `trimDigest`/`take`/`putBack` rely on: an `id`/`conversationId` to
47
+ * de-duplicate and route by, and `content` because `trimDigest` measures `m.content.length`.
48
+ * A parseable-but-wrong entry (e.g. `{}`) is not a RelayMessage and must be dropped on load,
49
+ * not trusted — otherwise the first digest trim after a restart throws.
50
+ */
51
+ function isRelayMessage(value) {
52
+ const m = value;
53
+ return (!!m &&
54
+ typeof m === "object" &&
55
+ typeof m.id === "string" &&
56
+ typeof m.conversationId === "string" &&
57
+ typeof m.content === "string");
58
+ }
59
+ class AcpTurnQueue {
60
+ file;
61
+ caps;
62
+ sessions = new Map();
63
+ constructor(file = acpQueuePath(), caps = DEFAULT_CAPS) {
64
+ this.file = file;
65
+ this.caps = caps;
66
+ }
67
+ load() {
68
+ let raw;
69
+ try {
70
+ raw = JSON.parse(fs.readFileSync(this.file, "utf8"));
71
+ }
72
+ catch {
73
+ return; // absent or corrupt: an empty queue. There is nothing to recover from either.
74
+ }
75
+ if (!raw || typeof raw !== "object")
76
+ return;
77
+ for (const [name, value] of Object.entries(raw)) {
78
+ if (!value || !Array.isArray(value.addressed) || !Array.isArray(value.digest))
79
+ continue;
80
+ const addressed = value.addressed.filter(isRelayMessage);
81
+ const digest = value.digest.filter(isRelayMessage);
82
+ const malformed = value.addressed.length - addressed.length + (value.digest.length - digest.length);
83
+ if (malformed > 0) {
84
+ // Same convention as save()'s failure line: say it once, to stderr, and keep going —
85
+ // a queue that quietly ate messages is worse than one that says so and moves on.
86
+ process.stderr.write(`baychat relay: dropped ${malformed} malformed message(s) from the ACP queue for session "${name}"\n`);
87
+ }
88
+ const rawOmitted = Number(value.omitted);
89
+ const omitted = Number.isFinite(rawOmitted) && rawOmitted > 0 ? Math.floor(rawOmitted) : 0;
90
+ this.sessions.set(name, { addressed, digest, omitted });
91
+ }
92
+ }
93
+ add(session, batch) {
94
+ const state = this.state(session);
95
+ let addressed = 0;
96
+ let dropped = 0;
97
+ for (const message of batch) {
98
+ if (message.shouldRespond === true) {
99
+ if (state.addressed.length >= this.caps.maxAddressed)
100
+ dropped += 1;
101
+ else {
102
+ state.addressed.push(message);
103
+ addressed += 1;
104
+ }
105
+ }
106
+ else
107
+ state.digest.push(message);
108
+ }
109
+ this.trimDigest(state);
110
+ this.save();
111
+ return { addressed, dropped };
112
+ }
113
+ take(session) {
114
+ const state = this.sessions.get(session);
115
+ if (!state || state.addressed.length === 0)
116
+ return undefined;
117
+ const turn = { addressed: state.addressed, digest: { messages: state.digest, omitted: state.omitted } };
118
+ this.sessions.set(session, { addressed: [], digest: [], omitted: 0 });
119
+ this.save();
120
+ return turn;
121
+ }
122
+ putBack(session, turn) {
123
+ const state = this.state(session);
124
+ state.addressed = [...turn.addressed, ...state.addressed];
125
+ state.digest = [...turn.digest.messages, ...state.digest];
126
+ state.omitted += turn.digest.omitted;
127
+ this.trimDigest(state);
128
+ this.save();
129
+ }
130
+ depth(session) {
131
+ return this.sessions.get(session)?.addressed.length ?? 0;
132
+ }
133
+ clear(session) {
134
+ this.sessions.delete(session);
135
+ this.save();
136
+ }
137
+ state(session) {
138
+ let s = this.sessions.get(session);
139
+ if (!s)
140
+ this.sessions.set(session, (s = { addressed: [], digest: [], omitted: 0 }));
141
+ return s;
142
+ }
143
+ trimDigest(state) {
144
+ const size = () => state.digest.reduce((n, m) => n + m.content.length, 0);
145
+ while (state.digest.length > this.caps.digestMessages || (state.digest.length > 1 && size() > this.caps.digestChars)) {
146
+ state.digest.shift();
147
+ state.omitted += 1;
148
+ }
149
+ }
150
+ save() {
151
+ try {
152
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
153
+ fs.writeFileSync(this.file, JSON.stringify(Object.fromEntries(this.sessions)), { mode: 0o600 });
154
+ fs.chmodSync(this.file, 0o600);
155
+ }
156
+ catch (err) {
157
+ // The queue still works in memory; what is lost is surviving a restart. Say so once per
158
+ // failure rather than hide it — a silently unpersisted queue is a message lost later.
159
+ process.stderr.write(`baychat relay: could not persist the ACP queue — ${err instanceof Error ? err.message : String(err)}\n`);
160
+ }
161
+ }
162
+ }
163
+ exports.AcpTurnQueue = AcpTurnQueue;
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.cmdRelayStart = cmdRelayStart;
37
37
  exports.ensureRelayInstalled = ensureRelayInstalled;
38
38
  exports.tryRelayStatus = tryRelayStatus;
39
+ exports.registerAcpSession = registerAcpSession;
39
40
  exports.cmdRelayStatus = cmdRelayStatus;
40
41
  exports.cmdRelayStop = cmdRelayStop;
41
42
  exports.shouldFallBackToMailbox = shouldFallBackToMailbox;
@@ -56,6 +57,7 @@ const profiles_1 = require("./profiles");
56
57
  const provider_env_1 = require("./provider-env");
57
58
  const autostart_1 = require("./autostart");
58
59
  const runtime_binary_1 = require("../runtime-binary");
60
+ const terminal_pane_1 = require("./terminal-pane");
59
61
  const owner_pid_1 = require("./owner-pid");
60
62
  const parent_watch_1 = require("./parent-watch");
61
63
  const daemon_1 = require("./daemon");
@@ -203,6 +205,24 @@ async function tryRelayStatus() {
203
205
  return null;
204
206
  }
205
207
  }
208
+ /** Tell a RUNNING relay about a relay-owned ACP session. Returns false when no relay answers. */
209
+ async function registerAcpSession(frame) {
210
+ try {
211
+ await request(await connectOrFail(), frame);
212
+ return true;
213
+ }
214
+ catch (err) {
215
+ // No relay yet is ordinary during a first `connect` — the registry file written beside this
216
+ // call is what a relay started afterwards reads. A refusal, though, must be seen, and so
217
+ // must a denied or wedged relay: only the exact "absent" wording `connectOrFail` produces
218
+ // (or the socket vanishing between its probe and the connect) counts as no relay.
219
+ const absent = (0, socket_1.describeProbeFailure)((0, socket_1.socketPath)(), { alive: false, reason: "absent" });
220
+ const code = err.code;
221
+ if (err instanceof Error && (err.message === absent || code === "ENOENT" || code === "ECONNREFUSED"))
222
+ return false;
223
+ throw err;
224
+ }
225
+ }
206
226
  async function cmdRelayStatus() {
207
227
  let status;
208
228
  try {
@@ -446,6 +466,9 @@ async function attachViaMailbox(opts) {
446
466
  // seconds — recording either told the daemon a live session had gone, and it
447
467
  // spawned a headless duplicate. See `currentOwnerPid`.
448
468
  ownerPid: opts.ownerPid ?? (0, owner_pid_1.currentOwnerPid)(opts.runtime),
469
+ // Recorded on BOTH transports: a sandboxed session on the fifo rung is still running
470
+ // in somebody's terminal, and typing into it does not go through the sandbox at all.
471
+ pane: (0, terminal_pane_1.detectPane)(),
449
472
  registeredAt: new Date().toISOString(),
450
473
  });
451
474
  const timeoutMs = opts.timeoutMs;
@@ -766,6 +789,11 @@ async function cmdRelayAttach(opts) {
766
789
  // found by walking past the launching shell, which does not outlive the
767
790
  // wake and so cannot witness anything.
768
791
  ownerPid: opts.ownerPid ?? (0, owner_pid_1.currentOwnerPid)(runtime),
792
+ // WHERE TO TYPE. Read from this process's environment because this process is
793
+ // inside the session's terminal and the daemon is not — the same rule `runtimeBin`
794
+ // above follows. Undefined for a session in a bare terminal, which is not a failure:
795
+ // it is the one honest answer, and the commands that need a pane refuse by name.
796
+ pane: (0, terminal_pane_1.detectPane)(),
769
797
  // The URL only. `provider-env.ts` is the module that guarantees no
770
798
  // credential travels with it.
771
799
  providerUrl: provider?.url,