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,253 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// One ACP turn: spawn the agent, open its session, send one prompt, report what happened.
|
|
3
|
+
//
|
|
4
|
+
// WHY A PROCESS PER TURN. Same trade `codex-app-server.ts` made and for the same reasons: a held
|
|
5
|
+
// agent process buys ~0.6 s and costs a supervision problem — restarts, health, a wedged child
|
|
6
|
+
// holding a session's queue, cleanup at shutdown — for a path that runs at human speed. ACP lets
|
|
7
|
+
// us pay that nothing: `session/resume` reopens the conversation with its memory by id.
|
|
8
|
+
//
|
|
9
|
+
// NEVER THROWS. Every failure is an outcome with a cause the runner can act on and a reason a
|
|
10
|
+
// person can read. NO SHELL, EVER: the prompt carries text written by other people in a room.
|
|
11
|
+
//
|
|
12
|
+
// THE CREDENTIAL. BayChat's MCP endpoint and the device token reach the agent inside the
|
|
13
|
+
// `session/new` / `session/resume` request, i.e. on its stdin. Not argv (visible in `ps`), not
|
|
14
|
+
// env (inherited by every command the agent runs), not a file.
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.runAcpTurn = runAcpTurn;
|
|
17
|
+
const stream_1 = require("stream");
|
|
18
|
+
const runtime_binary_1 = require("../../runtime-binary");
|
|
19
|
+
const sdk_1 = require("./sdk");
|
|
20
|
+
const TURN_TIMEOUT_MS = 10 * 60_000;
|
|
21
|
+
const HANDSHAKE_TIMEOUT_MS = 30_000;
|
|
22
|
+
/** An error that already knows its cause — thrown inside `drive`, never out of `runAcpTurn`. */
|
|
23
|
+
class TurnFailure extends Error {
|
|
24
|
+
cause;
|
|
25
|
+
constructor(cause, message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.cause = cause;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const isRecord = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
31
|
+
function errorText(err) {
|
|
32
|
+
if (!(err instanceof Error))
|
|
33
|
+
return String(err);
|
|
34
|
+
const data = err.data;
|
|
35
|
+
const details = isRecord(data) ? data.details : undefined;
|
|
36
|
+
return typeof details === "string" ? `${err.message}: ${details}` : err.message;
|
|
37
|
+
}
|
|
38
|
+
/** Reasons and logs carry agent text, and reasons are posted in the room: never the credential. */
|
|
39
|
+
const redact = (text) => text.replace(/Bearer\s+\S+/g, "Bearer [redacted]").replace(/bay_[A-Za-z0-9_]+/g, "bay_[redacted]");
|
|
40
|
+
async function runAcpTurn(req, deps) {
|
|
41
|
+
const log = (line) => deps.log?.(redact(line));
|
|
42
|
+
const failed = (cause, reason, sessionId) => ({
|
|
43
|
+
kind: "failed",
|
|
44
|
+
cause,
|
|
45
|
+
reason: redact(reason),
|
|
46
|
+
...(sessionId ? { sessionId } : {}),
|
|
47
|
+
});
|
|
48
|
+
// Stopped before it began: start nothing. A real spawn reports a bad binary as a LATER
|
|
49
|
+
// 'error' event, and with no listener yet that event would crash the daemon.
|
|
50
|
+
if (req.signal?.aborted)
|
|
51
|
+
return failed("cancelled", "stopped from the chat");
|
|
52
|
+
const turnTimeout = deps.turnTimeoutMs ?? TURN_TIMEOUT_MS;
|
|
53
|
+
const handshakeTimeout = deps.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS;
|
|
54
|
+
// A classifier that throws must not strand the turn with its process still running.
|
|
55
|
+
const classify = (message) => {
|
|
56
|
+
try {
|
|
57
|
+
return req.classifyError(message);
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
log(`acp: error classifier failed — ${errorText(err)}`);
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
let sdk;
|
|
65
|
+
try {
|
|
66
|
+
sdk = await (0, sdk_1.loadAcpSdk)();
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
return failed("spawn", `the ACP library could not be loaded: ${errorText(err)}`);
|
|
70
|
+
}
|
|
71
|
+
let child;
|
|
72
|
+
try {
|
|
73
|
+
// A Windows `.cmd` shim needs its interpreter named — same fix as `codex-app-server.ts`.
|
|
74
|
+
const plan = (0, runtime_binary_1.spawnPlanFor)(req.binaryPath, deps.platform ?? process.platform);
|
|
75
|
+
child = deps.spawn(plan.file, [...plan.prefixArgs, ...req.args], {
|
|
76
|
+
cwd: req.cwd,
|
|
77
|
+
env: req.env,
|
|
78
|
+
shell: false,
|
|
79
|
+
windowsHide: true,
|
|
80
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
return failed("spawn", `the agent could not be started: ${errorText(err)}`);
|
|
85
|
+
}
|
|
86
|
+
if (!child.stdin || !child.stdout) {
|
|
87
|
+
child.kill("SIGTERM");
|
|
88
|
+
return failed("spawn", "the agent started without stdio pipes");
|
|
89
|
+
}
|
|
90
|
+
const stdin = child.stdin;
|
|
91
|
+
const stdout = child.stdout;
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
let settled = false;
|
|
94
|
+
let phase = "handshake";
|
|
95
|
+
let sessionId;
|
|
96
|
+
/** True while `session/load` replays old history — those updates are not this turn's. */
|
|
97
|
+
let replaying = false;
|
|
98
|
+
let stderrTail = "";
|
|
99
|
+
let timer;
|
|
100
|
+
/** Permission questions waiting on a person. The turn clock runs only while this is zero. */
|
|
101
|
+
let asking = 0;
|
|
102
|
+
const calls = new Map();
|
|
103
|
+
const finish = (outcome) => {
|
|
104
|
+
if (settled)
|
|
105
|
+
return;
|
|
106
|
+
settled = true;
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
req.signal?.removeEventListener("abort", onAbort);
|
|
109
|
+
child.kill("SIGTERM");
|
|
110
|
+
// A child that ignores the polite signal must not outlive the turn and keep acting.
|
|
111
|
+
setTimeout(() => child.kill("SIGKILL"), 5_000).unref();
|
|
112
|
+
resolve(outcome);
|
|
113
|
+
};
|
|
114
|
+
const fail = (cause, reason) => finish(failed(cause, reason, sessionId));
|
|
115
|
+
const arm = (ms, cause, reason) => {
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
// Never after the end, never while a person is being asked: a stray timer would hold the
|
|
118
|
+
// event loop open for the whole turn budget and fire into a finished turn.
|
|
119
|
+
if (settled || asking > 0)
|
|
120
|
+
return;
|
|
121
|
+
timer = setTimeout(() => fail(cause, reason), ms);
|
|
122
|
+
};
|
|
123
|
+
const onAbort = () => fail("cancelled", "stopped from the chat");
|
|
124
|
+
child.on("error", (err) => fail("spawn", `the agent could not be started: ${errorText(err)}`));
|
|
125
|
+
child.on("exit", (code) => fail(phase === "handshake" ? "handshake" : "turn", `the agent exited (code ${code ?? "?"}) before the turn finished${stderrTail ? `: ${stderrTail.trim().slice(-300)}` : ""}`));
|
|
126
|
+
// Writing into a pipe whose reader died is an EPIPE 'error' event; unheard, it crashes the daemon.
|
|
127
|
+
stdin.on("error", (err) => log(`acp: agent stdin closed — ${errorText(err)}`));
|
|
128
|
+
child.stderr?.on("data", (d) => {
|
|
129
|
+
stderrTail = (stderrTail + d.toString()).slice(-600);
|
|
130
|
+
});
|
|
131
|
+
// Aborted while the SDK loaded: the child exists now, so stop only after its listeners are on.
|
|
132
|
+
if (req.signal?.aborted)
|
|
133
|
+
return onAbort();
|
|
134
|
+
req.signal?.addEventListener("abort", onAbort, { once: true });
|
|
135
|
+
const remember = (update) => {
|
|
136
|
+
const id = update.toolCallId;
|
|
137
|
+
if (typeof id !== "string")
|
|
138
|
+
return;
|
|
139
|
+
const known = calls.get(id);
|
|
140
|
+
const call = {
|
|
141
|
+
toolCallId: id,
|
|
142
|
+
title: typeof update.title === "string" ? update.title : (known?.title ?? ""),
|
|
143
|
+
rawInput: isRecord(update.rawInput) ? update.rawInput : (known?.rawInput ?? {}),
|
|
144
|
+
};
|
|
145
|
+
calls.set(id, call);
|
|
146
|
+
if (known)
|
|
147
|
+
return;
|
|
148
|
+
try {
|
|
149
|
+
req.onToolCall?.(call);
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
log(`acp: tool-call observer failed — ${errorText(err)}`);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
const client = {
|
|
156
|
+
async sessionUpdate(params) {
|
|
157
|
+
if (replaying || !isRecord(params.update))
|
|
158
|
+
return;
|
|
159
|
+
const kind = params.update.sessionUpdate;
|
|
160
|
+
if (kind === "tool_call" || kind === "tool_call_update")
|
|
161
|
+
remember(params.update);
|
|
162
|
+
},
|
|
163
|
+
async requestPermission(params) {
|
|
164
|
+
// The clock stops while a person is being asked: a card may wait an hour, and that is
|
|
165
|
+
// the owner's time, not a wedged agent's.
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
asking++;
|
|
168
|
+
let verdict = "reject";
|
|
169
|
+
try {
|
|
170
|
+
verdict = await req.onPermission(calls.get(params.toolCall?.toolCallId ?? ""));
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
log(`acp: permission handler failed, refusing — ${errorText(err)}`);
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
asking--;
|
|
177
|
+
}
|
|
178
|
+
arm(turnTimeout, "timeout", `the turn did not finish within ${Math.round(turnTimeout / 1000)}s`);
|
|
179
|
+
// ONLY the single-use kinds. `allow_always` would turn one tap into a standing grant.
|
|
180
|
+
const want = verdict === "allow" ? "allow_once" : "reject_once";
|
|
181
|
+
const option = (params.options ?? []).find((o) => o.kind === want);
|
|
182
|
+
return option
|
|
183
|
+
? { outcome: { outcome: "selected", optionId: option.optionId } }
|
|
184
|
+
: { outcome: { outcome: "cancelled" } };
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
const drive = async () => {
|
|
188
|
+
const conn = new sdk.ClientSideConnection(() => client, sdk.ndJsonStream(stream_1.Writable.toWeb(stdin), stream_1.Readable.toWeb(stdout)));
|
|
189
|
+
arm(handshakeTimeout, "handshake", `the agent did not complete the ACP handshake within ${Math.round(handshakeTimeout / 1000)}s`);
|
|
190
|
+
const init = await conn.initialize({
|
|
191
|
+
protocolVersion: sdk.PROTOCOL_VERSION,
|
|
192
|
+
// We offer the agent nothing of ours: no file access through us, no terminal through us.
|
|
193
|
+
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
|
|
194
|
+
});
|
|
195
|
+
const caps = (init.agentCapabilities ?? {});
|
|
196
|
+
if (!caps.mcpCapabilities?.http)
|
|
197
|
+
throw new TurnFailure("unsupported", "this agent cannot connect to an HTTP MCP server, so it could never reach BayChat's tools");
|
|
198
|
+
const canResume = caps.sessionCapabilities?.resume !== undefined && caps.sessionCapabilities.resume !== null;
|
|
199
|
+
const canLoad = caps.loadSession === true;
|
|
200
|
+
phase = "session";
|
|
201
|
+
let fresh = false;
|
|
202
|
+
let memoryLost = false;
|
|
203
|
+
const open = { cwd: req.cwd, mcpServers: req.mcpServers };
|
|
204
|
+
if (req.sessionId) {
|
|
205
|
+
if (!canResume && !canLoad) {
|
|
206
|
+
// Starting fresh on every message would answer the room as an agent with no memory of
|
|
207
|
+
// it, every time — worse than not answering.
|
|
208
|
+
throw new TurnFailure("unsupported", "this agent can neither resume nor load a session, so it cannot keep a conversation between messages");
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
if (canResume)
|
|
212
|
+
await conn.resumeSession({ sessionId: req.sessionId, ...open });
|
|
213
|
+
else {
|
|
214
|
+
replaying = true;
|
|
215
|
+
await conn.loadSession({ sessionId: req.sessionId, ...open });
|
|
216
|
+
}
|
|
217
|
+
sessionId = req.sessionId;
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
const text = errorText(err);
|
|
221
|
+
const cause = classify(text);
|
|
222
|
+
// A KNOWN reason (busy in a terminal, wrong folder, BayChat unreachable, no key) is an
|
|
223
|
+
// answer about this session and must not be papered over with a fresh one.
|
|
224
|
+
if (cause) {
|
|
225
|
+
sessionId = req.sessionId;
|
|
226
|
+
throw new TurnFailure(cause, text);
|
|
227
|
+
}
|
|
228
|
+
log(`acp: could not reopen session ${req.sessionId} (${text}) — starting a new one`);
|
|
229
|
+
memoryLost = true;
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
replaying = false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (!sessionId) {
|
|
236
|
+
const created = await conn.newSession(open);
|
|
237
|
+
sessionId = created.sessionId;
|
|
238
|
+
fresh = true;
|
|
239
|
+
}
|
|
240
|
+
phase = "turn";
|
|
241
|
+
arm(turnTimeout, "timeout", `the turn did not finish within ${Math.round(turnTimeout / 1000)}s`);
|
|
242
|
+
const text = fresh && req.freshPreamble ? `${req.freshPreamble}\n\n${req.prompt}` : req.prompt;
|
|
243
|
+
const result = await conn.prompt({ sessionId, prompt: [{ type: "text", text }] });
|
|
244
|
+
return { kind: "completed", sessionId, stopReason: String(result.stopReason ?? "unknown"), fresh, memoryLost };
|
|
245
|
+
};
|
|
246
|
+
drive().then(finish, (err) => {
|
|
247
|
+
if (err instanceof TurnFailure)
|
|
248
|
+
return fail(err.cause, err.message);
|
|
249
|
+
const text = errorText(err);
|
|
250
|
+
fail(classify(text) ?? (phase === "turn" ? "turn" : phase), text);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runAcpSessionCommand = runAcpSessionCommand;
|
|
4
|
+
const agents_1 = require("./agents");
|
|
5
|
+
const modes_1 = require("./modes");
|
|
6
|
+
/**
|
|
7
|
+
* The session commands that mean something different — or only mean something — for a session
|
|
8
|
+
* the relay runs over ACP. Kept out of `daemon.ts`, which only calls in.
|
|
9
|
+
*
|
|
10
|
+
* Returns true when the command was answered here; false hands it back to the daemon's own
|
|
11
|
+
* branches, which are then exactly what they were before ACP existed. A non-ACP session only
|
|
12
|
+
* ever reaches this for `/mode-*` and `/stop`, and gets a refusal that says why.
|
|
13
|
+
*
|
|
14
|
+
* Every path replies, as in `runSessionCommand`: a command produces no agent turn, so silence
|
|
15
|
+
* would be indistinguishable from failure.
|
|
16
|
+
*/
|
|
17
|
+
async function runAcpSessionCommand(acp, session, target, message, command, say) {
|
|
18
|
+
if (command.mode) {
|
|
19
|
+
// Anyone with COMMAND ACCESS — the owner, or a Bay owner/admin she granted (Karmen's
|
|
20
|
+
// decision, 19 Sep 2026; owner-only before). The daemon only routes a command here when the
|
|
21
|
+
// server said `commandAuthorized === true`, so this is a second, defensive check, not the
|
|
22
|
+
// gate. Absent is no. The rule-file ceiling and the platform still cap it, in `setMode`.
|
|
23
|
+
if (message.commandAuthorized !== true && message.senderIsOwner !== true) {
|
|
24
|
+
await say(`You need command access (from the owner of "${session}") to change what it may do.`);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
// `setMode` holds the one rule (rule-file ceiling, then platform) — not re-implemented here.
|
|
28
|
+
const result = acp.setMode(session, command.mode);
|
|
29
|
+
await say(result.ok ? `"${session}" is now in ${(0, modes_1.describeMode)(result.mode)}` : result.reason);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
if (command.id === "stop") {
|
|
33
|
+
if (!target.acp) {
|
|
34
|
+
// Not "is not doing anything": the relay cannot see a terminal agent's turn at all.
|
|
35
|
+
await say(`"${session}" is not run by the relay, so /stop cannot reach it. Stop it in its own terminal.`);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
await say(acp.stop(session)
|
|
39
|
+
? `Stopped "${session}". Anything it had already done stays done.`
|
|
40
|
+
: `"${session}" is not doing anything right now.`);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
if (command.id === "status" && target.acp) {
|
|
44
|
+
const row = (0, agents_1.acpAgent)(target.acp.agent);
|
|
45
|
+
const s = acp.status(session);
|
|
46
|
+
await say([
|
|
47
|
+
`Session "${session}" — run by the relay, no terminal`,
|
|
48
|
+
` agent: ${row?.label ?? target.acp.agent}${row ? ` (tested with ${row.testedVersion})` : ""}`,
|
|
49
|
+
` mode: ${(0, modes_1.describeMode)(target.acp.mode)}`,
|
|
50
|
+
` folder: ${target.acp.cwd}`,
|
|
51
|
+
` now: ${s.running ? "working on a turn" : "idle"}, ${s.queued} message(s) waiting`,
|
|
52
|
+
` memory: ${target.acp.sessionId ? "kept between messages" : "fresh on the next message"}`,
|
|
53
|
+
].join("\n"));
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
if (command.id === "exit" && target.relayOwned) {
|
|
57
|
+
await say(`"${session}" is run by the relay on that computer and has no terminal to detach. Use /stop to cancel a running turn.`);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
if (target.acp && (command.id === "clear" || command.id === "new")) {
|
|
61
|
+
// There is no terminal to type into: forgetting the ACP session id IS the fresh chat. The
|
|
62
|
+
// running turn is stopped first, or it would finish into the memory being closed.
|
|
63
|
+
acp.stop(session);
|
|
64
|
+
acp.reset(session);
|
|
65
|
+
await say(`"${session}" will start a fresh conversation with your next message. Its earlier memory is closed.`);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
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.ACP_LAPSED_FLOOR_MS = exports.ACP_KEEPALIVE_MS = void 0;
|
|
37
|
+
exports.keepAcpSessionsAlive = keepAcpSessionsAlive;
|
|
38
|
+
exports.startAcpUpkeep = startAcpUpkeep;
|
|
39
|
+
exports.hasLapsedAcpSession = hasLapsedAcpSession;
|
|
40
|
+
exports.acpKeepAliveScheduler = acpKeepAliveScheduler;
|
|
41
|
+
exports.registerAcpFrame = registerAcpFrame;
|
|
42
|
+
exports.acpAttachRefusal = acpAttachRefusal;
|
|
43
|
+
const fs = __importStar(require("fs"));
|
|
44
|
+
const path = __importStar(require("path"));
|
|
45
|
+
const agents_1 = require("./agents");
|
|
46
|
+
const modes_1 = require("./modes");
|
|
47
|
+
const policy_1 = require("./policy");
|
|
48
|
+
const presence_1 = require("./presence");
|
|
49
|
+
/**
|
|
50
|
+
* The daemon's ACP upkeep, kept out of `daemon.ts` so that file only calls in.
|
|
51
|
+
*
|
|
52
|
+
* Everything here is reached from a thin call site in the daemon: start-up (patches and the
|
|
53
|
+
* keep-alive), the live-session list (a lapsed relay-owned session), and the `acp-register`
|
|
54
|
+
* socket frame. None of it runs for a session without `acp`.
|
|
55
|
+
*/
|
|
56
|
+
/** The server stops feeding a session idle for 24 h, and only an MCP call refreshes it. */
|
|
57
|
+
exports.ACP_KEEPALIVE_MS = 12 * 60 * 60_000;
|
|
58
|
+
const errText = (err) => (err instanceof Error ? err.message : String(err));
|
|
59
|
+
/** Refresh (or re-join) every relay-owned session. Failures are logged, never thrown. */
|
|
60
|
+
async function keepAcpSessionsAlive(deps) {
|
|
61
|
+
if (!deps.auth)
|
|
62
|
+
return;
|
|
63
|
+
const caller = (deps.caller ?? presence_1.deviceMcpCaller)(deps.auth);
|
|
64
|
+
for (const target of deps.registry.all()) {
|
|
65
|
+
if (!target.relayOwned)
|
|
66
|
+
continue;
|
|
67
|
+
try {
|
|
68
|
+
const how = await (0, presence_1.keepSessionAlive)(caller, target.name);
|
|
69
|
+
if (how === "rejoined")
|
|
70
|
+
deps.log(`acp: re-joined ${target.name}`);
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
deps.log(`acp: could not keep ${target.name} alive — ${errText(err)}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Daemon start: (re)write every agent's mode patches, refresh relay-owned sessions now, and
|
|
79
|
+
* again every 12 h — a quiet relay-owned session would otherwise go deaf overnight. Returns
|
|
80
|
+
* the timer for `stop()` to clear; it is unref'd so it never holds the process open.
|
|
81
|
+
*/
|
|
82
|
+
function startAcpUpkeep(keepAlive, log) {
|
|
83
|
+
for (const row of agents_1.ACP_AGENTS) {
|
|
84
|
+
try {
|
|
85
|
+
(0, agents_1.writeModePatches)(row, (0, agents_1.acpPatchDir)());
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
// Not fatal: the runner rewrites them before every turn and reports its own failure.
|
|
89
|
+
log(`acp: could not write ${row.id}'s mode patches — ${errText(err)}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
void keepAlive();
|
|
93
|
+
const timer = setInterval(() => void keepAlive(), exports.ACP_KEEPALIVE_MS);
|
|
94
|
+
timer.unref();
|
|
95
|
+
return timer;
|
|
96
|
+
}
|
|
97
|
+
/** A relay-owned session missing from the server's live list has lapsed: re-join it now. */
|
|
98
|
+
function hasLapsedAcpSession(registry, liveNames) {
|
|
99
|
+
return registry.all().some((t) => t.relayOwned && !liveNames.includes(t.name));
|
|
100
|
+
}
|
|
101
|
+
/** A lapsed session is re-joined at most this often: the feed reports live sessions every poll. */
|
|
102
|
+
exports.ACP_LAPSED_FLOOR_MS = 5 * 60_000;
|
|
103
|
+
/**
|
|
104
|
+
* One keep-alive at a time, and the lapsed trigger throttled.
|
|
105
|
+
*
|
|
106
|
+
* `run` is the start-up and 12 h path: never throttled, but a call while a run is in flight joins
|
|
107
|
+
* that run instead of starting a second. `lapsed` is the per-poll path: while a relay-owned
|
|
108
|
+
* session stays missing from the live list it would otherwise fire on EVERY poll (~25 s).
|
|
109
|
+
*/
|
|
110
|
+
function acpKeepAliveScheduler(keepAlive, now = Date.now) {
|
|
111
|
+
let inFlight;
|
|
112
|
+
let lastLapsed = -Infinity;
|
|
113
|
+
const run = () => (inFlight ??= keepAlive().finally(() => {
|
|
114
|
+
inFlight = undefined;
|
|
115
|
+
}));
|
|
116
|
+
return {
|
|
117
|
+
run,
|
|
118
|
+
lapsed(registry, liveNames) {
|
|
119
|
+
if (!hasLapsedAcpSession(registry, liveNames) || now() - lastLapsed < exports.ACP_LAPSED_FLOOR_MS)
|
|
120
|
+
return Promise.resolve();
|
|
121
|
+
lastLapsed = now();
|
|
122
|
+
return run();
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Register a relay-owned ACP session from an `acp-register` frame — or refuse it.
|
|
128
|
+
*
|
|
129
|
+
* The socket is this user's own, but the frame still cannot grant more than the rule file: the
|
|
130
|
+
* same folder check the runner makes before every turn is made before registering, and the mode
|
|
131
|
+
* must be an ACP mode. Nor can the frame choose what runs or raise what runs (Ruling R25): its
|
|
132
|
+
* `runtimeBin` is IGNORED — the daemon resolves the agent's binary itself, and an existing
|
|
133
|
+
* session keeps the one it has — and an existing session's mode may be kept or lowered here,
|
|
134
|
+
* never raised; raising is a `/mode-…` from the chat (the owner, or anyone with command access). The frame is parsed JSON, so its
|
|
135
|
+
* shape is checked here too rather than trusted from the type.
|
|
136
|
+
*/
|
|
137
|
+
function registerAcpFrame(frame, deps) {
|
|
138
|
+
const refused = { ok: false, message: "That ACP session could not be registered." };
|
|
139
|
+
const acp = frame.acp;
|
|
140
|
+
if (!acp || typeof frame.session !== "string" || frame.session === "" || typeof acp.agent !== "string" || typeof acp.cwd !== "string") {
|
|
141
|
+
return refused;
|
|
142
|
+
}
|
|
143
|
+
const row = (0, agents_1.acpAgent)(acp.agent);
|
|
144
|
+
if (!row || !(0, modes_1.isAcpMode)(acp.mode) || !path.isAbsolute(acp.cwd))
|
|
145
|
+
return refused;
|
|
146
|
+
const folder = (0, policy_1.checkFolder)((deps.loadPolicy ?? policy_1.loadPolicy)(), row.id, acp.cwd);
|
|
147
|
+
if (!folder.ok)
|
|
148
|
+
return { ok: false, message: folder.reason };
|
|
149
|
+
let cwd;
|
|
150
|
+
try {
|
|
151
|
+
cwd = fs.realpathSync(acp.cwd); // stored resolved: a link swapped later cannot move the session
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return refused; // checkFolder has just resolved it, so only a race lands here
|
|
155
|
+
}
|
|
156
|
+
// `upsert` MERGES, so registering over a terminal session (Claude, Codex…) would keep its
|
|
157
|
+
// resume id, add `acp`, and hand its name to this agent from then on. Refused instead.
|
|
158
|
+
const current = deps.registry.get(frame.session);
|
|
159
|
+
if (current && !current.acp) {
|
|
160
|
+
return { ok: false, message: `A session named "${frame.session}" already exists on this computer and is not run by the relay. Choose another name (baychat connect dsh --session <name>).` };
|
|
161
|
+
}
|
|
162
|
+
const existing = current?.acp;
|
|
163
|
+
if (existing && (0, modes_1.modeRank)(acp.mode) > (0, modes_1.modeRank)(existing.mode)) {
|
|
164
|
+
return { ok: false, message: `A running session's mode can only be raised from the chat, with /mode-${acp.mode} (you, or anyone you gave command access).` };
|
|
165
|
+
}
|
|
166
|
+
// The same rule `/mode` applies: a registration cannot start above the folder's ceiling or in
|
|
167
|
+
// a mode this platform's sandbox cannot enforce. Checked for a kept mode too — the ceiling may
|
|
168
|
+
// have been lowered since.
|
|
169
|
+
const allowed = (0, agents_1.modeAllowedHere)(row, acp.mode, folder.maxMode, deps.platform ?? process.platform);
|
|
170
|
+
if (!allowed.ok)
|
|
171
|
+
return { ok: false, message: allowed.reason };
|
|
172
|
+
let runtimeBin = current?.runtimeBin;
|
|
173
|
+
if (!runtimeBin) {
|
|
174
|
+
const found = deps.resolveBinary(row.bin);
|
|
175
|
+
if (!found.ok || !path.isAbsolute(found.path)) {
|
|
176
|
+
return { ok: false, message: `${row.label} is not installed where the relay can find it. ${row.installHint}` };
|
|
177
|
+
}
|
|
178
|
+
runtimeBin = found.path;
|
|
179
|
+
}
|
|
180
|
+
// The agent's own session id survives a re-register in the same folder; a moved folder
|
|
181
|
+
// starts fresh, because that id names a conversation held in the old one.
|
|
182
|
+
const keep = existing?.cwd === cwd && existing.sessionId ? { sessionId: existing.sessionId } : {};
|
|
183
|
+
deps.registry.upsert({
|
|
184
|
+
name: frame.session,
|
|
185
|
+
runtime: row.id, // from the agent row, never from the frame
|
|
186
|
+
runtimeBin,
|
|
187
|
+
relayOwned: true,
|
|
188
|
+
acp: { agent: row.id, cwd, mode: acp.mode, ...keep },
|
|
189
|
+
});
|
|
190
|
+
deps.log(`acp: registered ${frame.session} (${row.id}, ${acp.mode}, ${cwd})`);
|
|
191
|
+
return { ok: true };
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* The refusal for an `attach` frame that names a relay-run session, or undefined to attach as
|
|
195
|
+
* ever. `upsert` merges, so an attach over it would keep `acp` and give the name two drivers.
|
|
196
|
+
*/
|
|
197
|
+
function acpAttachRefusal(registry, session) {
|
|
198
|
+
return registry.get(session)?.acp
|
|
199
|
+
? `"${session}" is run by the relay on this computer; attach a terminal under a different session name.`
|
|
200
|
+
: undefined;
|
|
201
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Reads a `dsh --dump-config` YAML dump (a flat list of plugin entries) and answers, for one
|
|
3
|
+
// plugin id, whether ITS OWN entry carries `disabled: true`.
|
|
4
|
+
//
|
|
5
|
+
// WHY NOT A SINGLE REGEX OVER THE WHOLE TEXT. `id: tool-subagent` is a PREFIX of
|
|
6
|
+
// `id: tool-subagent-control`, `id: tool-subagent-list-agents` and `id: tool-subagent-fork` — and
|
|
7
|
+
// `\b` (a word/non-word boundary) sits right at the hyphen after "subagent", so a regex like
|
|
8
|
+
// `id: ${id}\b[\s\S]{0,200}?disabled: true` matches the START of any of those longer ids too. In
|
|
9
|
+
// the recorded chat-mode dump every id in that family happens to be disabled together, so the bug
|
|
10
|
+
// passed by coincidence — it was checking "is SOME entry starting with this prefix disabled",
|
|
11
|
+
// not "is THIS entry disabled". This module finds the entry by its own exact `- id: <id>` line
|
|
12
|
+
// and looks only inside that entry's own lines (up to the next top-level `- ` list item).
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.pluginDisabled = pluginDisabled;
|
|
15
|
+
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
16
|
+
/**
|
|
17
|
+
* Is the plugin whose dump entry starts `- id: <id>` disabled in `dumpText`?
|
|
18
|
+
*
|
|
19
|
+
* `undefined` means the id has no entry at all in this dump — distinct from `false`, which means
|
|
20
|
+
* the entry exists and is not disabled.
|
|
21
|
+
*/
|
|
22
|
+
function pluginDisabled(dumpText, id) {
|
|
23
|
+
const idLine = new RegExp(`^- id: ${escapeRegExp(id)}\\s*$`, "m");
|
|
24
|
+
const start = idLine.exec(dumpText);
|
|
25
|
+
if (!start)
|
|
26
|
+
return undefined;
|
|
27
|
+
const rest = dumpText.slice(start.index + start[0].length);
|
|
28
|
+
const nextEntry = /^- /m.exec(rest);
|
|
29
|
+
const entry = nextEntry ? rest.slice(0, nextEntry.index) : rest;
|
|
30
|
+
return /disabled: true/.test(entry);
|
|
31
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ACP_MODES = void 0;
|
|
4
|
+
exports.isAcpMode = isAcpMode;
|
|
5
|
+
exports.modeRank = modeRank;
|
|
6
|
+
exports.clampMode = clampMode;
|
|
7
|
+
exports.usesLocalTools = usesLocalTools;
|
|
8
|
+
exports.describeMode = describeMode;
|
|
9
|
+
/** Weakest first. The ORDER is the security model: a ceiling is "no higher than this index". */
|
|
10
|
+
exports.ACP_MODES = ["chat", "read", "ask", "full"];
|
|
11
|
+
function isAcpMode(value) {
|
|
12
|
+
return typeof value === "string" && exports.ACP_MODES.includes(value);
|
|
13
|
+
}
|
|
14
|
+
function modeRank(mode) {
|
|
15
|
+
return exports.ACP_MODES.indexOf(mode);
|
|
16
|
+
}
|
|
17
|
+
/** The lower of the two. A ceiling can only ever take power away. */
|
|
18
|
+
function clampMode(wanted, ceiling) {
|
|
19
|
+
return modeRank(wanted) <= modeRank(ceiling) ? wanted : ceiling;
|
|
20
|
+
}
|
|
21
|
+
function usesLocalTools(mode) {
|
|
22
|
+
return mode !== "chat";
|
|
23
|
+
}
|
|
24
|
+
// Said in the room whenever a mode is set, and printed by `connect`. The limit is stated every
|
|
25
|
+
// time because the agent's sandbox confines WRITES and never reads — a person choosing `read`
|
|
26
|
+
// must not believe it means "only this folder".
|
|
27
|
+
const READS_ANYTHING = "It can read any file your account can, not only this folder — only `chat` protects private files.";
|
|
28
|
+
const DESCRIPTIONS = {
|
|
29
|
+
chat: "chat — BayChat tools only. It cannot read, write or run anything on this computer.",
|
|
30
|
+
read: `read — it can read and search files, and cannot run commands or write. ${READS_ANYTHING}`,
|
|
31
|
+
ask: `ask — it can run commands; every write, and anything needing more rights, asks you on your phone first. Commands that only read run without asking. ${READS_ANYTHING}`,
|
|
32
|
+
full: `full — it can write and run commands inside its folder without asking. ${READS_ANYTHING}`,
|
|
33
|
+
};
|
|
34
|
+
function describeMode(mode) {
|
|
35
|
+
return DESCRIPTIONS[mode];
|
|
36
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.decidePermission = decidePermission;
|
|
4
|
+
const approve_hook_1 = require("../../approve-hook");
|
|
5
|
+
/**
|
|
6
|
+
* What to do with an agent's permission request. PURE — the asking is the caller's.
|
|
7
|
+
*
|
|
8
|
+
* dsh asks only when a call ESCALATES out of its sandbox, and the model names the level it
|
|
9
|
+
* wants in `rawInput.sandbox_permissions` [run]. "Allow once" approves that whole level for the
|
|
10
|
+
* call, so the level is what we gate on:
|
|
11
|
+
*
|
|
12
|
+
* - `workspace-write`, in `ask` mode → the owner decides, on their phone.
|
|
13
|
+
* - `danger-full-access` → refused without a card, always. At that level the command runs with
|
|
14
|
+
* no sandbox at all; it is not a thing a chat message may ever be the cause of, and showing
|
|
15
|
+
* it on a card would make one tired tap the only thing between a room and the whole disk.
|
|
16
|
+
* - missing, unknown, or a call we never saw → refused. We do not approve what we cannot name.
|
|
17
|
+
*
|
|
18
|
+
* `ctx.agentLabel` (Ruling R9) is who the card says is asking — `composeQuestion` defaults to
|
|
19
|
+
* "Claude Code" when it is absent, which would misname a dsh session.
|
|
20
|
+
*/
|
|
21
|
+
function decidePermission(mode, call, ctx) {
|
|
22
|
+
if (!call)
|
|
23
|
+
return { kind: "refuse", reason: "the agent asked permission for a tool call this relay never saw" };
|
|
24
|
+
const level = call.rawInput.sandbox_permissions;
|
|
25
|
+
if (level !== "workspace-write") {
|
|
26
|
+
const asked = typeof level === "string" ? level : "an unnamed level of access";
|
|
27
|
+
return { kind: "refuse", reason: `"${call.title}" asked for ${asked}, which is never granted from a chat` };
|
|
28
|
+
}
|
|
29
|
+
if (mode !== "ask")
|
|
30
|
+
return { kind: "refuse", reason: `"${call.title}" needs write access, and this session's mode (${mode}) does not ask` };
|
|
31
|
+
return {
|
|
32
|
+
kind: "card",
|
|
33
|
+
question: (0, approve_hook_1.composeQuestion)({
|
|
34
|
+
toolName: call.title,
|
|
35
|
+
toolInput: call.rawInput,
|
|
36
|
+
cwd: ctx.cwd,
|
|
37
|
+
host: ctx.host,
|
|
38
|
+
permissionMode: "ask",
|
|
39
|
+
agentLabel: ctx.agentLabel,
|
|
40
|
+
}),
|
|
41
|
+
};
|
|
42
|
+
}
|