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.
- package/README.md +53 -1
- package/dist/approve-hook.js +5 -2
- package/dist/commands.js +6 -4
- package/dist/connect-dsh.js +165 -0
- package/dist/connect-plan.js +16 -1
- package/dist/connect.js +7 -0
- package/dist/doctor-command.js +7 -0
- package/dist/doctor.js +94 -1
- package/dist/dsh-config.js +147 -0
- package/dist/index.js +22 -0
- package/dist/relay/acp/agents.js +186 -0
- package/dist/relay/acp/approval.js +67 -0
- package/dist/relay/acp/client.js +253 -0
- package/dist/relay/acp/commands.js +69 -0
- package/dist/relay/acp/daemon-glue.js +201 -0
- package/dist/relay/acp/dump-config.js +31 -0
- package/dist/relay/acp/modes.js +36 -0
- package/dist/relay/acp/permissions.js +42 -0
- package/dist/relay/acp/policy.js +137 -0
- package/dist/relay/acp/presence.js +87 -0
- package/dist/relay/acp/prompt.js +69 -0
- package/dist/relay/acp/runner.js +311 -0
- package/dist/relay/acp/sdk.js +19 -0
- package/dist/relay/acp/turn-queue.js +163 -0
- package/dist/relay/acp/types.js +2 -0
- package/dist/relay/commands.js +28 -0
- package/dist/relay/daemon.js +195 -0
- package/dist/relay/mailbox.js +1 -0
- package/dist/relay/profiles.js +22 -0
- package/dist/relay/registry.js +46 -1
- package/dist/relay/session-commands.js +89 -0
- package/dist/relay/session-reply.js +25 -0
- package/dist/relay/terminal-pane.js +197 -0
- package/dist/relay/types.js +0 -9
- package/dist/runtimes.js +13 -0
- package/dist/session-command.js +16 -3
- package/dist/skill-bootstrap.js +16 -8
- package/dist/start-command.js +235 -0
- package/dist/update-command.js +201 -0
- package/package.json +3 -1
package/dist/relay/daemon.js
CHANGED
|
@@ -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 });
|
package/dist/relay/mailbox.js
CHANGED
|
@@ -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.
|
package/dist/relay/profiles.js
CHANGED
|
@@ -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",
|
package/dist/relay/registry.js
CHANGED
|
@@ -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
|
+
}
|