baychat 0.21.4 → 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 (44) hide show
  1. package/README.md +74 -13
  2. package/dist/approve-hook.js +5 -2
  3. package/dist/claude-onboarding.js +6 -12
  4. package/dist/commands.js +11 -6
  5. package/dist/connect-claude.js +19 -4
  6. package/dist/connect-dsh.js +165 -0
  7. package/dist/connect-plan.js +47 -1
  8. package/dist/connect.js +40 -9
  9. package/dist/doctor-command.js +7 -0
  10. package/dist/doctor.js +94 -1
  11. package/dist/dsh-config.js +147 -0
  12. package/dist/index.js +37 -2
  13. package/dist/relay/acp/agents.js +186 -0
  14. package/dist/relay/acp/approval.js +67 -0
  15. package/dist/relay/acp/client.js +253 -0
  16. package/dist/relay/acp/commands.js +69 -0
  17. package/dist/relay/acp/daemon-glue.js +201 -0
  18. package/dist/relay/acp/dump-config.js +31 -0
  19. package/dist/relay/acp/modes.js +36 -0
  20. package/dist/relay/acp/permissions.js +42 -0
  21. package/dist/relay/acp/policy.js +137 -0
  22. package/dist/relay/acp/presence.js +87 -0
  23. package/dist/relay/acp/prompt.js +69 -0
  24. package/dist/relay/acp/runner.js +311 -0
  25. package/dist/relay/acp/sdk.js +19 -0
  26. package/dist/relay/acp/turn-queue.js +163 -0
  27. package/dist/relay/acp/types.js +2 -0
  28. package/dist/relay/commands.js +28 -0
  29. package/dist/relay/daemon.js +195 -0
  30. package/dist/relay/mailbox.js +1 -0
  31. package/dist/relay/profiles.js +22 -0
  32. package/dist/relay/registry.js +46 -1
  33. package/dist/relay/session-commands.js +89 -0
  34. package/dist/relay/session-reply.js +25 -0
  35. package/dist/relay/terminal-pane.js +197 -0
  36. package/dist/relay/types.js +0 -9
  37. package/dist/runtime-install.js +9 -2
  38. package/dist/runtimes.js +16 -0
  39. package/dist/session-command.js +16 -3
  40. package/dist/session-setup.js +38 -0
  41. package/dist/skill-bootstrap.js +47 -0
  42. package/dist/start-command.js +235 -0
  43. package/dist/update-command.js +201 -0
  44. package/package.json +3 -1
@@ -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,
@@ -34,10 +34,19 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.RelayDaemon = void 0;
37
+ exports.splitSessionCommands = splitSessionCommands;
38
+ const child_process_1 = require("child_process");
37
39
  const fs = __importStar(require("fs"));
38
40
  const net = __importStar(require("net"));
39
41
  const path = __importStar(require("path"));
40
42
  const config_1 = require("../config");
43
+ const session_commands_1 = require("./session-commands");
44
+ const terminal_pane_1 = require("./terminal-pane");
45
+ const commands_1 = require("./acp/commands");
46
+ const daemon_glue_1 = require("./acp/daemon-glue");
47
+ const runner_1 = require("./acp/runner");
48
+ const turn_queue_1 = require("./acp/turn-queue");
49
+ const session_reply_1 = require("./session-reply");
41
50
  const runtime_binary_1 = require("../runtime-binary");
42
51
  const adapters_1 = require("./adapters");
43
52
  const parent_watch_1 = require("./parent-watch");
@@ -60,6 +69,10 @@ const watermarks_1 = require("./watermarks");
60
69
  const DISCOVERY_INTERVAL_MS = 10 * 60_000;
61
70
  class RelayDaemon {
62
71
  registry = new registry_1.SessionRegistry();
72
+ acpQueue = new turn_queue_1.AcpTurnQueue();
73
+ acp;
74
+ acpAlive;
75
+ acpKeepAlive;
63
76
  queue;
64
77
  abort = new AbortController();
65
78
  /** Live attach sockets by session name. */
@@ -83,6 +96,8 @@ class RelayDaemon {
83
96
  liveSessions = [];
84
97
  /** Why discovery last refused, per session, and when — throttles the rescan. */
85
98
  discoveryReasons = new Map();
99
+ /** Set at `start()`. Undefined only before the daemon is running. */
100
+ auth;
86
101
  discovery;
87
102
  spawnHeadless;
88
103
  isAlive;
@@ -145,6 +160,11 @@ class RelayDaemon {
145
160
  this.mailboxWatchers = (0, mailbox_1.mailboxRootCandidates)().map((root) => new mailbox_watcher_1.MailboxWatcher({
146
161
  root,
147
162
  onRegister: (reg) => {
163
+ // A relay-run session's program is the daemon's to choose (Ruling R32): `upsert`
164
+ // merges, so a mailbox file would keep `acp` and swap the binary the runner spawns.
165
+ const relayRun = (0, daemon_glue_1.acpAttachRefusal)(this.registry, reg.session);
166
+ if (relayRun)
167
+ return this.log(`mailbox registration ignored: ${relayRun}`);
148
168
  this.registry.upsert((0, mailbox_1.registrationToTarget)(reg));
149
169
  // A session that names itself supersedes anything discovery guessed,
150
170
  // exactly as an attach frame does.
@@ -185,6 +205,22 @@ class RelayDaemon {
185
205
  this.lastError = `delivery failed for ${session}: ${errText(err)}`;
186
206
  this.log(this.lastError);
187
207
  });
208
+ this.acpAlive = (0, daemon_glue_1.acpKeepAliveScheduler)(() => (0, daemon_glue_1.keepAcpSessionsAlive)({ registry: this.registry, auth: this.auth, caller: opts.acpCaller, log: this.log }));
209
+ this.acp = new runner_1.AcpSessionRunner({
210
+ registry: this.registry,
211
+ queue: this.acpQueue,
212
+ auth: () => this.auth,
213
+ spawn: child_process_1.spawn,
214
+ say: async (conversationId, session, text) => {
215
+ if (this.auth)
216
+ await (0, session_reply_1.postSessionReply)(this.auth, conversationId, session, text);
217
+ },
218
+ log: (line) => this.log(line),
219
+ onDelivered: (session, batch) => this.record({ kind: "woken", via: "acp", session }, session, batch),
220
+ onPending: (session, batch, reason) => this.record({ kind: "pending", session, reason }, session, batch),
221
+ runTurn: opts.acpRunTurn,
222
+ caller: opts.acpCaller,
223
+ });
188
224
  }
189
225
  async start() {
190
226
  // The DEVICE credential, not an agent token. A laptop has one device login
@@ -199,11 +235,16 @@ class RelayDaemon {
199
235
  throw new Error(`device credential expired ${device.expiresAt} — run \`npx baychat login\``);
200
236
  }
201
237
  const auth = { baseUrl: device.baseUrl, token: device.token };
238
+ // Kept for session-command replies. Every other API call the daemon makes is inside
239
+ // the feed, which closes over `auth`; a command's reply happens in `deliver`, which
240
+ // does not.
241
+ this.auth = auth;
202
242
  this.registry.load();
203
243
  // Held messages outlive the process that held them. Loaded before the socket
204
244
  // opens, so a session that re-arms in the first moments of a new daemon is
205
245
  // handed what the previous one was holding rather than a clean slate.
206
246
  this.held.load();
247
+ this.acpQueue.load();
207
248
  const sockPath = (0, socket_1.socketPath)();
208
249
  if (await (0, socket_1.probeSocket)(sockPath)) {
209
250
  throw new Error(`a relay is already listening on ${sockPath} — run \`baychat relay status\``);
@@ -229,6 +270,8 @@ class RelayDaemon {
229
270
  });
230
271
  });
231
272
  writePidFile();
273
+ // After the socket is ours: a relay refused as a duplicate must not keep sessions alive.
274
+ this.acpKeepAlive = (0, daemon_glue_1.startAcpUpkeep)(this.acpAlive.run, this.log);
232
275
  this.log(`listening on ${sockPath} (pid ${process.pid}) as device "${device.user.name}"`);
233
276
  this.log(`${this.registry.all().length} known session target(s)`);
234
277
  // The event SOURCE is pluggable (socket preferred, long-poll floor); every
@@ -249,6 +292,7 @@ class RelayDaemon {
249
292
  const dropped = this.registry.pruneToLive(names);
250
293
  for (const name of dropped)
251
294
  this.log(`session ended server-side, dropped: ${name}`);
295
+ void this.acpAlive.lapsed(this.registry, names);
252
296
  },
253
297
  onPoll: (cursor) => {
254
298
  this.cursor = cursor;
@@ -329,6 +373,96 @@ class RelayDaemon {
329
373
  this.log(`delivered ${room.messages.length} held message(s) to ${session} in ${room.conversationId} on re-attach`);
330
374
  }
331
375
  }
376
+ /**
377
+ * Run one session command and say in the room what happened.
378
+ *
379
+ * ── EVERY PATH REPLIES, INCLUDING EVERY REFUSAL ───────────────────────────────────────
380
+ * A command produces no agent turn, so silence is the only thing the sender can tell
381
+ * apart from silence. A refusal that names its reason is the whole difference between
382
+ * "this feature is broken" and "start your agent inside tmux".
383
+ */
384
+ async runSessionCommand(session, message, command) {
385
+ const say = async (text) => {
386
+ if (!this.auth)
387
+ return;
388
+ try {
389
+ await (0, session_reply_1.postSessionReply)(this.auth, message.conversationId, session, text);
390
+ }
391
+ catch (err) {
392
+ // Never an empty catch: a command that ran and could not say so is exactly the
393
+ // state this reply exists to prevent, so it has to be visible somewhere.
394
+ this.log(`could not report ${command.trigger} for ${session}: ${errText(err)}`);
395
+ }
396
+ };
397
+ const target = this.registry.get(session);
398
+ if (!target) {
399
+ await say(`This relay does not know a session called "${session}" on this machine.`);
400
+ return;
401
+ }
402
+ this.log(`session command ${command.trigger} for ${session} from ${message.senderId}`);
403
+ if (command.id === "help") {
404
+ await say((0, session_commands_1.sessionCommandHelp)());
405
+ return;
406
+ }
407
+ // `/mode-*`, `/stop`, and what `/status` `/exit` `/clear` `/new` mean with no terminal.
408
+ if (await (0, commands_1.runAcpSessionCommand)(this.acp, session, target, message, command, say))
409
+ return;
410
+ if (command.id === "status") {
411
+ const where = target.pane
412
+ ? target.pane.kind === "tmux"
413
+ ? `tmux pane ${target.pane.pane}`
414
+ : target.pane.kind
415
+ : "no multiplexer — /clear and /new cannot reach it";
416
+ await say([
417
+ `Session "${session}"`,
418
+ ` runtime: ${target.runtime}`,
419
+ ` terminal: ${where}`,
420
+ ` attached: ${target.attached ? `yes (${target.transport ?? "socket"})` : "no"}`,
421
+ ` can resume: ${target.resumeId ? `yes (${target.resumeSource ?? "recorded"})` : "no"}`,
422
+ ].join("\n"));
423
+ return;
424
+ }
425
+ if (command.id === "exit") {
426
+ // DETACH, never kill. Ending somebody's terminal agent from a phone is not a thing
427
+ // the relay should be able to do by itself, and "stop being reachable" is the part it
428
+ // genuinely owns and can undo. The reply says which one happened, because "exit" will
429
+ // read to most people as the other one.
430
+ const sock = this.attached.get(session);
431
+ if (sock && !sock.destroyed)
432
+ sock.end();
433
+ this.attached.delete(session);
434
+ this.mailboxes.delete(session);
435
+ this.registry.setAttached(session, false);
436
+ await say(`"${session}" is no longer reachable from the Bay. Its terminal is untouched and still open — run the join command there to bring it back.`);
437
+ return;
438
+ }
439
+ // What is left types into the terminal.
440
+ if (!target.pane) {
441
+ await say((0, session_commands_1.noTerminalReason)());
442
+ return;
443
+ }
444
+ const keys = command.keys?.(target.runtime);
445
+ if (!keys) {
446
+ await say(`${command.trigger} has nothing to type for a ${target.runtime} session.`);
447
+ return;
448
+ }
449
+ // `ownerPid` is how a SCREEN session is verified — screen cannot be asked what is
450
+ // running in a window, so the question goes to the process instead. tmux ignores it and
451
+ // asks tmux.
452
+ const typed = await (0, terminal_pane_1.typeIntoPane)(target.pane, target.runtime, keys, {
453
+ ownerPid: target.ownerPid,
454
+ });
455
+ if (!typed.ok) {
456
+ await say((0, session_commands_1.noTerminalReason)(`${command.trigger} could not run: ${typed.reason}`));
457
+ return;
458
+ }
459
+ if (command.id === "clear" || command.id === "new") {
460
+ // See `SessionRegistry.clearResume`: the live session's transcript is gone, so the
461
+ // recorded resume id now points at something no half of this session has.
462
+ this.registry.clearResume(session);
463
+ }
464
+ await say(`Typed ${keys} into "${session}". Its context is cleared.`);
465
+ }
332
466
  /**
333
467
  * Wait for a session to re-arm its socket, up to `graceMs`.
334
468
  *
@@ -353,6 +487,20 @@ class RelayDaemon {
353
487
  * flight for the same session at once.
354
488
  */
355
489
  async deliver(session, batch) {
490
+ // COMMANDS FIRST, and separated from chat rather than chosen between.
491
+ //
492
+ // A batch is coalesced, so it can hold a command and a sentence at once. Dropping the
493
+ // sentence would lose a message; handing the command to the agent would ask a MODEL to
494
+ // obey something its HARNESS owns — `/clear` is read by the runtime's TUI, and no model
495
+ // can run it for itself. So the commands are executed here and everything else wakes
496
+ // the session exactly as it always did.
497
+ const { commands, chat } = splitSessionCommands(batch);
498
+ for (const { message, command } of commands) {
499
+ await this.runSessionCommand(session, message, command);
500
+ }
501
+ if (chat.length === 0)
502
+ return;
503
+ batch = chat;
356
504
  const target = this.registry.get(session);
357
505
  if (!target) {
358
506
  // NOT reported as pending, though an earlier version of this did.
@@ -432,6 +580,12 @@ class RelayDaemon {
432
580
  }
433
581
  }
434
582
  }
583
+ if (target.acp) {
584
+ // The relay drives this session itself: no attach to wait for, no resume id to discover.
585
+ // The runner files the batch and returns at once, so this lock is free again for a `/stop`.
586
+ this.acp.handle(session, batch);
587
+ return;
588
+ }
435
589
  const adapter = (0, adapters_1.adapterFor)(target.runtime);
436
590
  // ORDERED ABOVE THE FIFO, deliberately.
437
591
  //
@@ -882,6 +1036,9 @@ class RelayDaemon {
882
1036
  let session;
883
1037
  const read = (0, socket_1.createFrameReader)((frame) => {
884
1038
  if (frame.type === "attach") {
1039
+ const relayRun = (0, daemon_glue_1.acpAttachRefusal)(this.registry, frame.session);
1040
+ if (relayRun)
1041
+ return void (0, socket_1.writeFrame)(sock, { type: "error", message: relayRun });
885
1042
  if (frame.delivery === "queue" &&
886
1043
  (frame.runtime !== "codex" || !frame.resumeId || !frame.runtimeBin)) {
887
1044
  (0, socket_1.writeFrame)(sock, {
@@ -902,6 +1059,7 @@ class RelayDaemon {
902
1059
  cwd: frame.cwd,
903
1060
  runtimeBin: frame.runtimeBin,
904
1061
  ownerPid: frame.ownerPid,
1062
+ pane: frame.pane,
905
1063
  providerUrl: frame.providerUrl,
906
1064
  providerVar: frame.providerVar,
907
1065
  });
@@ -953,6 +1111,11 @@ class RelayDaemon {
953
1111
  (0, socket_1.writeFrame)(sock, { type: "status-result", status: this.status() });
954
1112
  return;
955
1113
  }
1114
+ if (frame.type === "acp-register") {
1115
+ const result = (0, daemon_glue_1.registerAcpFrame)(frame, { registry: this.registry, log: this.log, resolveBinary: this.resolveBinary });
1116
+ (0, socket_1.writeFrame)(sock, result.ok ? { type: "status-result", status: this.status() } : { type: "error", message: result.message });
1117
+ return;
1118
+ }
956
1119
  if (frame.type === "stop") {
957
1120
  this.log("stop requested over socket");
958
1121
  (0, socket_1.writeFrame)(sock, { type: "status-result", status: this.status() });
@@ -1000,11 +1163,14 @@ class RelayDaemon {
1000
1163
  };
1001
1164
  }
1002
1165
  async stop() {
1166
+ clearInterval(this.acpKeepAlive);
1167
+ this.acp.shutdown();
1003
1168
  this.abort.abort();
1004
1169
  for (const sock of this.attached.values())
1005
1170
  sock.destroy();
1006
1171
  this.attached.clear();
1007
1172
  await this.queue.idle();
1173
+ await this.acp.idle();
1008
1174
  for (const w of this.mailboxWatchers)
1009
1175
  w.stop();
1010
1176
  await new Promise((resolve) => this.server ? this.server.close(() => resolve()) : resolve());
@@ -1019,6 +1185,35 @@ class RelayDaemon {
1019
1185
  }
1020
1186
  }
1021
1187
  exports.RelayDaemon = RelayDaemon;
1188
+ /**
1189
+ * Separate the session commands in a batch from the ordinary chat in it.
1190
+ *
1191
+ * THREE CONDITIONS, ALL REQUIRED, and each closes a different door:
1192
+ *
1193
+ * - `shouldRespond` — the message is addressed to THIS session. Without it, a `/clear`
1194
+ * typed into a group chat that the session merely watches would clear it.
1195
+ * - `commandAuthorized` — the SERVER says this sender may command this session. The relay
1196
+ * never decides that itself (see `RelayMessage.commandAuthorized`), and an absent field
1197
+ * is a no, so an older server can only ever under-permit.
1198
+ * - an exact trigger match — "can you /clear please" is a sentence ABOUT a command and
1199
+ * must reach the agent as one.
1200
+ *
1201
+ * Anything failing any of them is chat, and wakes the session exactly as it does today.
1202
+ */
1203
+ function splitSessionCommands(batch) {
1204
+ const commands = [];
1205
+ const chat = [];
1206
+ for (const message of batch) {
1207
+ const command = message.shouldRespond === true && message.commandAuthorized === true
1208
+ ? (0, session_commands_1.parseSessionCommand)(message.content)
1209
+ : undefined;
1210
+ if (command)
1211
+ commands.push({ message, command });
1212
+ else
1213
+ chat.push(message);
1214
+ }
1215
+ return { commands, chat };
1216
+ }
1022
1217
  function writePidFile() {
1023
1218
  try {
1024
1219
  fs.writeFileSync((0, socket_1.pidFilePath)(), String(process.pid), { mode: 0o600 });
@@ -186,6 +186,7 @@ function registrationToTarget(reg) {
186
186
  cwd: reg.cwd,
187
187
  runtimeBin: reg.runtimeBin,
188
188
  ownerPid: reg.ownerPid,
189
+ pane: reg.pane,
189
190
  // Persisted, so a LATER headless resume still knows this session is
190
191
  // sandboxed and must be told to re-arm in the foreground. `status()`
191
192
  // recomputes the live rung for display; this is the remembered one.
@@ -171,6 +171,28 @@ exports.RUNTIME_PROFILES = [
171
171
  checked: "2026-08-31",
172
172
  source: "https://aider.chat/docs/config/options.html",
173
173
  },
174
+ {
175
+ id: "dsh",
176
+ label: "DeepSeek Harness",
177
+ bin: "dsh",
178
+ // dsh sets DSH_SESSION_ID for every command the model runs (dsh-shell-env) [source]. This
179
+ // table's own invariant below restricts `kind: "env"` to a row that has been RUN against a
180
+ // live session — only `pi` qualifies so far, and this row is `docs`, not `run` (see the
181
+ // header comment: "Only claude and codex are run"). So the variable is reported here as
182
+ // `flag-only`, not `env`: dsh's docs show the id exists, but whether a running session can
183
+ // read its own back and pass it to `--session-id` has not been executed here.
184
+ sessionId: {
185
+ kind: "flag-only",
186
+ how: "DSH_SESSION_ID is set for every command dsh's model runs (dsh-shell-env docs); whether a running session can read its own back to resume itself is unconfirmed.",
187
+ },
188
+ // No headless rung (Ruling R27): a headless `dsh --profile headless` turn would run with
189
+ // dsh's own permissions, outside the modes the relay enforces. dsh is driven by the relay
190
+ // over ACP instead (`relay/acp/`), which is where its modes live.
191
+ noHeadlessReason: "DeepSeek Harness is run by the relay over ACP (`baychat connect dsh`), which enforces modes; a terminal dsh session is not woken headlessly.",
192
+ confidence: "docs",
193
+ checked: "2026-09-19",
194
+ source: "https://www.npmjs.com/package/@deepseek-ai/dsh (0.1.5-rc.2, dsh-shell-env + dsh-acp README)",
195
+ },
174
196
  {
175
197
  id: "pi",
176
198
  label: "Pi",
@@ -113,6 +113,28 @@ class SessionRegistry {
113
113
  get(name) {
114
114
  return this.sessions.get(name);
115
115
  }
116
+ /**
117
+ * Forget how to resume this session — what `/clear` means to the headless path.
118
+ *
119
+ * A separate method because `upsert` strips undefined, deliberately: every other caller
120
+ * is reporting what it LEARNED, and a field it did not learn must not erase one that was.
121
+ * This caller is the exception — it is reporting what stopped being true.
122
+ *
123
+ * Why `/clear` must do this at all: it clears the LIVE session's context by typing into
124
+ * its terminal, and the recorded `resumeId` still points at the conversation that just
125
+ * stopped existing. Left in place, a later headless wake would resume a transcript the
126
+ * live session no longer has — the two halves of one session disagreeing about what was
127
+ * said, which is worse than either being empty.
128
+ */
129
+ clearResume(name) {
130
+ const existing = this.sessions.get(name);
131
+ if (!existing)
132
+ return;
133
+ delete existing.resumeId;
134
+ delete existing.resumeSource;
135
+ delete existing.resumeEvidence;
136
+ this.save();
137
+ }
116
138
  all() {
117
139
  return [...this.sessions.values()];
118
140
  }
@@ -141,12 +163,17 @@ class SessionRegistry {
141
163
  *
142
164
  * Attached sessions are never pruned: a live socket outranks a poll, which
143
165
  * may simply have raced a join that hasn't been stamped live yet.
166
+ *
167
+ * Relay-owned ACP sessions are never pruned either: there is no terminal
168
+ * behind them to have ended, and the server's live list never mentions them
169
+ * as something a poll would confirm — the relay itself is the only thing
170
+ * that keeps them alive, so it must be the only thing that can drop them.
144
171
  */
145
172
  pruneToLive(liveNames) {
146
173
  const live = new Set(liveNames);
147
174
  const dropped = [];
148
175
  for (const [name, target] of this.sessions) {
149
- if (!live.has(name) && !target.attached) {
176
+ if (!live.has(name) && !target.attached && !target.relayOwned) {
150
177
  this.sessions.delete(name);
151
178
  dropped.push(name);
152
179
  }
@@ -155,6 +182,24 @@ class SessionRegistry {
155
182
  this.save();
156
183
  return dropped;
157
184
  }
185
+ /** Record (or, with `undefined`, forget) the agent's own ACP session id. */
186
+ setAcpSession(name, sessionId) {
187
+ const t = this.sessions.get(name);
188
+ if (!t?.acp)
189
+ return;
190
+ if (sessionId === undefined)
191
+ delete t.acp.sessionId;
192
+ else
193
+ t.acp.sessionId = sessionId;
194
+ this.save();
195
+ }
196
+ setAcpMode(name, mode) {
197
+ const t = this.sessions.get(name);
198
+ if (!t?.acp)
199
+ return;
200
+ t.acp.mode = mode;
201
+ this.save();
202
+ }
158
203
  }
159
204
  exports.SessionRegistry = SessionRegistry;
160
205
  function stripUndefined(obj) {