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
@@ -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) {
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SESSION_COMMANDS = void 0;
4
+ exports.parseSessionCommand = parseSessionCommand;
5
+ exports.sessionCommandHelp = sessionCommandHelp;
6
+ exports.noTerminalReason = noTerminalReason;
7
+ /** Claude has no `/new`; `/clear` is how it starts a fresh chat. */
8
+ const freshChat = (runtime) => (runtime === "codex" ? "/new" : "/clear");
9
+ exports.SESSION_COMMANDS = [
10
+ {
11
+ id: "clear",
12
+ trigger: "/clear",
13
+ summary: "Clear this session's context, exactly as typing /clear in its terminal does.",
14
+ keys: () => "/clear",
15
+ },
16
+ {
17
+ id: "new",
18
+ trigger: "/new",
19
+ summary: "Start a fresh chat in this session, keeping the terminal open.",
20
+ keys: freshChat,
21
+ },
22
+ // ── ACP sessions (run by the relay, no terminal) ──────────────────────────────────────
23
+ // Four ROWS rather than `/mode <x>`, on purpose. The rule above says the wire carries a NAME
24
+ // and never an argument; an enum argument would be the first crack in it. These select a row
25
+ // like every other command, type nothing, and are answered by the relay.
26
+ { id: "mode-chat", trigger: "/mode-chat", mode: "chat", summary: "Relay-run agents: BayChat tools only — nothing on the computer. Owner or anyone with command access." },
27
+ { id: "mode-read", trigger: "/mode-read", mode: "read", summary: "Relay-run agents: may read files, no commands, no writes. Owner or anyone with command access." },
28
+ { id: "mode-ask", trigger: "/mode-ask", mode: "ask", summary: "Relay-run agents: may run commands; writes ask on your phone first. Owner or anyone with command access." },
29
+ { id: "mode-full", trigger: "/mode-full", mode: "full", summary: "Relay-run agents: may write inside its folder without asking. Owner or anyone with command access." },
30
+ { id: "stop", trigger: "/stop", summary: "Relay-run agents: stop the turn that is running now." },
31
+ {
32
+ id: "exit",
33
+ trigger: "/exit",
34
+ summary: "Stop this session being reachable from the Bay. Its terminal stays open — this does not close it.",
35
+ },
36
+ {
37
+ id: "status",
38
+ trigger: "/status",
39
+ summary: "What this relay knows about the session: runtime, terminal, and whether it is live.",
40
+ },
41
+ {
42
+ id: "help",
43
+ trigger: "/help",
44
+ summary: "This list.",
45
+ },
46
+ ];
47
+ /**
48
+ * Is this message one of the commands, or is it chat?
49
+ *
50
+ * EXACT MATCH on the trimmed text, deliberately. "can you /clear please" is a sentence
51
+ * about a command and must reach the agent as one; anything looser would turn discussing
52
+ * the feature into using it. Case-insensitive because a phone keyboard capitalises the
53
+ * start of a line without being asked.
54
+ *
55
+ * Says nothing about whether the sender MAY run it — that is the server's answer, checked
56
+ * by the caller. Parsing and permission are kept apart so neither can be mistaken for the
57
+ * other.
58
+ */
59
+ function parseSessionCommand(text) {
60
+ const normalised = text.trim().toLowerCase();
61
+ if (!normalised.startsWith("/"))
62
+ return undefined;
63
+ return exports.SESSION_COMMANDS.find((command) => command.trigger === normalised);
64
+ }
65
+ /** The reply to `/help`, and the thing to say when a command cannot run. */
66
+ function sessionCommandHelp() {
67
+ return [
68
+ "Commands this session understands:",
69
+ ...exports.SESSION_COMMANDS.map((command) => ` ${command.trigger} — ${command.summary}`),
70
+ "",
71
+ "Only you, and anyone you have given permission to, can run them.",
72
+ ].join("\n");
73
+ }
74
+ /**
75
+ * The refusal for a session whose terminal cannot be typed into.
76
+ *
77
+ * NAMES THE FIX. A person whose session is not in a multiplexer has done nothing wrong and
78
+ * cannot be expected to know why a chat command needs one; "it did not work" would leave
79
+ * them with no next step, which is the failure this whole codebase keeps writing comments
80
+ * about.
81
+ */
82
+ function noTerminalReason(detail) {
83
+ return [
84
+ detail ??
85
+ "This session is not running inside a terminal multiplexer, so there is nothing to type into.",
86
+ "Start it with `baychat` next time and they will work — that runs your agent somewhere these commands can reach.",
87
+ "/status, /exit and /help work either way.",
88
+ ].join(" ");
89
+ }
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.postSessionReply = postSessionReply;
4
+ const api_1 = require("../api");
5
+ /**
6
+ * Say, in the room, what a session command did.
7
+ *
8
+ * ── WHY A REPLY IS PART OF THE FEATURE AND NOT A NICETY ───────────────────────────────
9
+ *
10
+ * Everything else the relay does is witnessed by the agent answering: you know the wake
11
+ * landed because a reply appears. A session command produces NO agent turn — `/clear` is
12
+ * absorbed by the runtime's own TUI and says nothing back — so without this the sender
13
+ * sees precisely the same thing whether the command cleared their session, refused because
14
+ * the terminal is not in tmux, or was silently ignored because the relay is a version
15
+ * behind. That is the failure mode this codebase keeps writing comments about, and it would
16
+ * be the default here.
17
+ *
18
+ * So every outcome is reported, refusals included, and the wording distinguishes them. The
19
+ * message is posted AS THE SESSION, which is right: the session is the thing that did (or
20
+ * did not do) it, and a reply from anyone else would be a second voice in the room
21
+ * explaining somebody's terminal.
22
+ */
23
+ async function postSessionReply(auth, conversationId, sessionName, text, request = api_1.apiRequest) {
24
+ await request(auth, "POST", `/api/device-api/conversations/${encodeURIComponent(conversationId)}/messages`, { session: sessionName, content: text });
25
+ }