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.
- package/README.md +74 -13
- package/dist/approve-hook.js +5 -2
- package/dist/claude-onboarding.js +6 -12
- package/dist/commands.js +11 -6
- package/dist/connect-claude.js +19 -4
- package/dist/connect-dsh.js +165 -0
- package/dist/connect-plan.js +47 -1
- package/dist/connect.js +40 -9
- package/dist/doctor-command.js +7 -0
- package/dist/doctor.js +94 -1
- package/dist/dsh-config.js +147 -0
- package/dist/index.js +37 -2
- 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/runtime-install.js +9 -2
- package/dist/runtimes.js +16 -0
- package/dist/session-command.js +16 -3
- package/dist/session-setup.js +38 -0
- package/dist/skill-bootstrap.js +47 -0
- package/dist/start-command.js +235 -0
- package/dist/update-command.js +201 -0
- 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;
|
package/dist/relay/commands.js
CHANGED
|
@@ -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,
|
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) {
|