baychat 0.21.5 → 0.23.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 +75 -1
- package/dist/approve-hook.js +5 -2
- package/dist/commands.js +6 -4
- package/dist/connect-acp.js +165 -0
- package/dist/connect-cursor.js +65 -0
- package/dist/connect-dsh.js +45 -0
- package/dist/connect-plan.js +16 -1
- package/dist/connect.js +17 -0
- package/dist/doctor-command.js +7 -0
- package/dist/doctor.js +93 -1
- package/dist/dsh-config.js +147 -0
- package/dist/index.js +27 -0
- package/dist/relay/acp/agents.js +231 -0
- package/dist/relay/acp/approval.js +67 -0
- package/dist/relay/acp/client.js +265 -0
- package/dist/relay/acp/commands.js +69 -0
- package/dist/relay/acp/cursor.js +271 -0
- package/dist/relay/acp/daemon-glue.js +198 -0
- package/dist/relay/acp/dump-config.js +31 -0
- package/dist/relay/acp/modes.js +36 -0
- package/dist/relay/acp/permissions.js +104 -0
- package/dist/relay/acp/policy.js +138 -0
- package/dist/relay/acp/presence.js +87 -0
- package/dist/relay/acp/prompt.js +68 -0
- package/dist/relay/acp/runner.js +364 -0
- package/dist/relay/acp/sdk.js +19 -0
- package/dist/relay/acp/turn-queue.js +163 -0
- package/dist/relay/acp/types.js +5 -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-binary.js +17 -0
- 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 +6 -2
|
@@ -0,0 +1,265 @@
|
|
|
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
|
+
// Cursor announces a call bare ("MCP: tool", no input) and names it in a later update, so
|
|
147
|
+
// an update that carries a title or an input is news; a status-only update is not.
|
|
148
|
+
if (known && typeof update.title !== "string" && !isRecord(update.rawInput))
|
|
149
|
+
return;
|
|
150
|
+
try {
|
|
151
|
+
req.onToolCall?.(call);
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
log(`acp: tool-call observer failed — ${errorText(err)}`);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
const client = {
|
|
158
|
+
async sessionUpdate(params) {
|
|
159
|
+
if (replaying || !isRecord(params.update))
|
|
160
|
+
return;
|
|
161
|
+
const kind = params.update.sessionUpdate;
|
|
162
|
+
if (kind === "tool_call" || kind === "tool_call_update")
|
|
163
|
+
remember(params.update);
|
|
164
|
+
},
|
|
165
|
+
async requestPermission(params) {
|
|
166
|
+
// The clock stops while a person is being asked: a card may wait an hour, and that is
|
|
167
|
+
// the owner's time, not a wedged agent's.
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
asking++;
|
|
170
|
+
let verdict = "reject";
|
|
171
|
+
try {
|
|
172
|
+
verdict = await req.onPermission(calls.get(params.toolCall?.toolCallId ?? ""));
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
log(`acp: permission handler failed, refusing — ${errorText(err)}`);
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
asking--;
|
|
179
|
+
}
|
|
180
|
+
arm(turnTimeout, "timeout", `the turn did not finish within ${Math.round(turnTimeout / 1000)}s`);
|
|
181
|
+
// ONLY the single-use kinds. `allow_always` would turn one tap into a standing grant.
|
|
182
|
+
const want = verdict === "allow" ? "allow_once" : "reject_once";
|
|
183
|
+
const option = (params.options ?? []).find((o) => o.kind === want);
|
|
184
|
+
return option
|
|
185
|
+
? { outcome: { outcome: "selected", optionId: option.optionId } }
|
|
186
|
+
: { outcome: { outcome: "cancelled" } };
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
const drive = async () => {
|
|
190
|
+
const conn = new sdk.ClientSideConnection(() => client, sdk.ndJsonStream(stream_1.Writable.toWeb(stdin), stream_1.Readable.toWeb(stdout)));
|
|
191
|
+
arm(handshakeTimeout, "handshake", `the agent did not complete the ACP handshake within ${Math.round(handshakeTimeout / 1000)}s`);
|
|
192
|
+
const init = await conn.initialize({
|
|
193
|
+
protocolVersion: sdk.PROTOCOL_VERSION,
|
|
194
|
+
// We offer the agent nothing of ours: no file access through us, no terminal through us.
|
|
195
|
+
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
|
|
196
|
+
});
|
|
197
|
+
const caps = (init.agentCapabilities ?? {});
|
|
198
|
+
if (!caps.mcpCapabilities?.http)
|
|
199
|
+
throw new TurnFailure("unsupported", "this agent cannot connect to an HTTP MCP server, so it could never reach BayChat's tools");
|
|
200
|
+
const canResume = caps.sessionCapabilities?.resume !== undefined && caps.sessionCapabilities.resume !== null;
|
|
201
|
+
const canLoad = caps.loadSession === true;
|
|
202
|
+
phase = "session";
|
|
203
|
+
let fresh = false;
|
|
204
|
+
let memoryLost = false;
|
|
205
|
+
const open = { cwd: req.cwd, mcpServers: req.mcpServers };
|
|
206
|
+
if (req.sessionId) {
|
|
207
|
+
if (!canResume && !canLoad) {
|
|
208
|
+
// Starting fresh on every message would answer the room as an agent with no memory of
|
|
209
|
+
// it, every time — worse than not answering.
|
|
210
|
+
throw new TurnFailure("unsupported", "this agent can neither resume nor load a session, so it cannot keep a conversation between messages");
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
if (canResume)
|
|
214
|
+
await conn.resumeSession({ sessionId: req.sessionId, ...open });
|
|
215
|
+
else {
|
|
216
|
+
replaying = true;
|
|
217
|
+
await conn.loadSession({ sessionId: req.sessionId, ...open });
|
|
218
|
+
}
|
|
219
|
+
sessionId = req.sessionId;
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
const text = errorText(err);
|
|
223
|
+
const cause = classify(text);
|
|
224
|
+
// A KNOWN reason (busy in a terminal, wrong folder, BayChat unreachable, no key) is an
|
|
225
|
+
// answer about this session and must not be papered over with a fresh one.
|
|
226
|
+
if (cause) {
|
|
227
|
+
sessionId = req.sessionId;
|
|
228
|
+
throw new TurnFailure(cause, text);
|
|
229
|
+
}
|
|
230
|
+
log(`acp: could not reopen session ${req.sessionId} (${text}) — starting a new one`);
|
|
231
|
+
memoryLost = true;
|
|
232
|
+
}
|
|
233
|
+
finally {
|
|
234
|
+
replaying = false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (!sessionId) {
|
|
238
|
+
const created = await conn.newSession(open);
|
|
239
|
+
sessionId = created.sessionId;
|
|
240
|
+
fresh = true;
|
|
241
|
+
}
|
|
242
|
+
if (req.sessionModeId) {
|
|
243
|
+
// Never prompt in a mode we did not choose: an agent left in another mode (Cursor's
|
|
244
|
+
// `ask` refuses BayChat's own write tools) would fail the room in a way nobody sees.
|
|
245
|
+
try {
|
|
246
|
+
await conn.setSessionMode({ sessionId, modeId: req.sessionModeId });
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
throw new TurnFailure("session", `the agent would not switch to its "${req.sessionModeId}" mode: ${errorText(err)}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
phase = "turn";
|
|
253
|
+
arm(turnTimeout, "timeout", `the turn did not finish within ${Math.round(turnTimeout / 1000)}s`);
|
|
254
|
+
const text = fresh && req.freshPreamble ? `${req.freshPreamble}\n\n${req.prompt}` : req.prompt;
|
|
255
|
+
const result = await conn.prompt({ sessionId, prompt: [{ type: "text", text }] });
|
|
256
|
+
return { kind: "completed", sessionId, stopReason: String(result.stopReason ?? "unknown"), fresh, memoryLost };
|
|
257
|
+
};
|
|
258
|
+
drive().then(finish, (err) => {
|
|
259
|
+
if (err instanceof TurnFailure)
|
|
260
|
+
return fail(err.cause, err.message);
|
|
261
|
+
const text = errorText(err);
|
|
262
|
+
fail(classify(text) ?? (phase === "turn" ? "turn" : phase), text);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
}
|
|
@@ -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
|
+
/**
|
|
6
|
+
* The session commands that mean something different — or only mean something — for a session
|
|
7
|
+
* the relay runs over ACP. Kept out of `daemon.ts`, which only calls in.
|
|
8
|
+
*
|
|
9
|
+
* Returns true when the command was answered here; false hands it back to the daemon's own
|
|
10
|
+
* branches, which are then exactly what they were before ACP existed. A non-ACP session only
|
|
11
|
+
* ever reaches this for `/mode-*` and `/stop`, and gets a refusal that says why.
|
|
12
|
+
*
|
|
13
|
+
* Every path replies, as in `runSessionCommand`: a command produces no agent turn, so silence
|
|
14
|
+
* would be indistinguishable from failure.
|
|
15
|
+
*/
|
|
16
|
+
async function runAcpSessionCommand(acp, session, target, message, command, say) {
|
|
17
|
+
if (command.mode) {
|
|
18
|
+
// Anyone with COMMAND ACCESS — the owner, or a Bay owner/admin she granted (Karmen's
|
|
19
|
+
// decision, 19 Sep 2026; owner-only before). The daemon only routes a command here when the
|
|
20
|
+
// server said `commandAuthorized === true`, so this is a second, defensive check, not the
|
|
21
|
+
// gate. Absent is no. The rule-file ceiling and the platform still cap it, in `setMode`.
|
|
22
|
+
if (message.commandAuthorized !== true && message.senderIsOwner !== true) {
|
|
23
|
+
await say(`You need command access (from the owner of "${session}") to change what it may do.`);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
// `setMode` holds the one rule (rule-file ceiling, then platform) — not re-implemented here.
|
|
27
|
+
const result = acp.setMode(session, command.mode);
|
|
28
|
+
const row = target.acp ? (0, agents_1.acpAgent)(target.acp.agent) : undefined;
|
|
29
|
+
await say(result.ok ? `"${session}" is now in ${(0, agents_1.describeAgentMode)(row, 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, agents_1.describeAgentMode)(row, 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,271 @@
|
|
|
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.cursor = void 0;
|
|
37
|
+
exports.cursorSessionDir = cursorSessionDir;
|
|
38
|
+
exports.cursorOverrideRefusal = cursorOverrideRefusal;
|
|
39
|
+
exports.watchCursorOverrides = watchCursorOverrides;
|
|
40
|
+
const crypto_1 = require("crypto");
|
|
41
|
+
const fs = __importStar(require("fs"));
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
const modes_1 = require("./modes");
|
|
44
|
+
const permissions_1 = require("./permissions");
|
|
45
|
+
const types_1 = require("./types");
|
|
46
|
+
// Cursor's CLI (`agent acp`) [run, 19 Sep 2026, CLI 2026.09.18-9a7762b — the spikes and Karmen's
|
|
47
|
+
// decision are in docs/superpowers/plans/2026-09-19-acp-cursor.md]. Its ACP modes are not ours:
|
|
48
|
+
// its own `ask` still READ files inside and outside the folder, so every mode runs Cursor as its
|
|
49
|
+
// `agent` and takes tools away with a config deny list instead. Deny beats allow within that one
|
|
50
|
+
// file, and BayChat's allowlist is empty, so every shell command raises a permission request.
|
|
51
|
+
//
|
|
52
|
+
// Cursor reads that config from `CURSOR_CONFIG_DIR` (undocumented; found in the CLI bundle and
|
|
53
|
+
// RUN). The same folder holds Cursor's ACP chat store (`acp-sessions/`) [run, Spike 3], so it is
|
|
54
|
+
// one folder per SESSION — never per mode (a `/mode-*` would lose the conversation) and never
|
|
55
|
+
// per turn (every turn would). Its `cli-config.json` is rewritten for the turn's mode before each
|
|
56
|
+
// turn. Nothing is written into the person's project, and the "Allow always" rules of their own
|
|
57
|
+
// Cursor never reach a relay session. The sandbox is off because, run on Linux, it confined
|
|
58
|
+
// nothing — the deny list and the permission rule are the whole boundary.
|
|
59
|
+
const CURSOR_DENY = {
|
|
60
|
+
chat: ["Read(**)", "Read(/**)", "Write(**)", "Write(/**)", "Shell(*)", "WebFetch(*)"],
|
|
61
|
+
read: ["Write(**)", "Write(/**)", "Shell(*)", "WebFetch(*)"],
|
|
62
|
+
// `.cursor/**`: its edit tool must not plant an override file (below) [run, Spike 3: refused
|
|
63
|
+
// with "Write permission denied"; the shell fallback asked, and the permission rule refuses it].
|
|
64
|
+
ask: ["Write(.cursor/**)", "Write(**/.cursor/**)", "WebFetch(*)"],
|
|
65
|
+
};
|
|
66
|
+
const cursorConfig = (deny) => `${JSON.stringify({ version: 1, approvalMode: "allowlist", sandbox: { mode: "disabled" }, permissions: { allow: [], deny } }, null, 2)}\n`;
|
|
67
|
+
/** Characters a session name keeps as they are in its folder name. Lower case only: see below. */
|
|
68
|
+
const KEPT = /^[a-z0-9_-]$/;
|
|
69
|
+
/**
|
|
70
|
+
* The folder name for a session's Cursor config. INJECTIVE and path-safe on every filesystem:
|
|
71
|
+
* every UTF-8 byte outside `[a-z0-9_-]` — upper case included, because macOS and Windows fold
|
|
72
|
+
* case, and `%` itself — becomes `%xx`, so two names can never share a folder and no name can
|
|
73
|
+
* spell `..`, a separator or a reserved device name (the `s-` prefix sees to `con`, `nul`, …).
|
|
74
|
+
*/
|
|
75
|
+
function cursorSessionDir(session) {
|
|
76
|
+
let encoded = "s-";
|
|
77
|
+
for (const byte of Buffer.from(session, "utf8")) {
|
|
78
|
+
const char = String.fromCharCode(byte);
|
|
79
|
+
encoded += KEPT.test(char) ? char : `%${byte.toString(16).padStart(2, "0")}`;
|
|
80
|
+
}
|
|
81
|
+
// A folder name longer than the filesystems allow would fail late and obscurely; say why now.
|
|
82
|
+
if (encoded.length > 200)
|
|
83
|
+
throw new Error(`The session name "${session.slice(0, 40)}…" is too long for its Cursor config folder. Choose a shorter one.`);
|
|
84
|
+
return encoded;
|
|
85
|
+
}
|
|
86
|
+
exports.cursor = {
|
|
87
|
+
id: "cursor",
|
|
88
|
+
label: "Cursor",
|
|
89
|
+
bin: "agent",
|
|
90
|
+
patches: Object.fromEntries(Object.entries(CURSOR_DENY).map(([mode, deny]) => [mode, cursorConfig(deny)])),
|
|
91
|
+
patchFile: (_mode, session) => path.join("cursor", cursorSessionDir(session), "cli-config.json"),
|
|
92
|
+
// Cursor rewrites this file itself (it adds `model`, `privacyCache`, …) and keeps `permissions`
|
|
93
|
+
// exactly [run]. Comparing bytes would rewrite it before every turn; comparing nothing would
|
|
94
|
+
// trust whatever is there. So: the fields BayChat's safety rests on, and nothing else.
|
|
95
|
+
patchIsCurrent: (onDisk, wanted) => {
|
|
96
|
+
const current = cursorSecurityFields(onDisk);
|
|
97
|
+
return current !== undefined && current === cursorSecurityFields(wanted);
|
|
98
|
+
},
|
|
99
|
+
launch(mode, patchDir, session) {
|
|
100
|
+
// No mode maps to `full`: `modesFor` never offers it, and a launch that reached here anyway
|
|
101
|
+
// must not quietly run with some other mode's config.
|
|
102
|
+
if (!this.patches[mode])
|
|
103
|
+
throw new Error(`Cursor has no ${mode} mode in BayChat`);
|
|
104
|
+
return { args: ["acp"], env: { CURSOR_CONFIG_DIR: path.join(patchDir, path.dirname(this.patchFile(mode, session))) } };
|
|
105
|
+
},
|
|
106
|
+
// Its `ask` mode refuses BayChat's own write tools ("I'm in Ask mode") [run], so an agent that
|
|
107
|
+
// must answer the room runs as `agent`, with the deny list doing the limiting.
|
|
108
|
+
acpModeId: "agent",
|
|
109
|
+
// A deny list does not rest on an OS sandbox, so `chat` and `read` hold everywhere. `ask` was
|
|
110
|
+
// only RUN on Linux, and a row ships only what has been run.
|
|
111
|
+
modesFor: (platform) => (platform === "linux" ? ["chat", "read", "ask"] : ["chat", "read"]),
|
|
112
|
+
// Only these two were named in the spike; Cursor's texts for no plan/quota, a busy session and
|
|
113
|
+
// a bad folder were not collected, so they stay unclassified (a generic line in the room).
|
|
114
|
+
classifyError(message) {
|
|
115
|
+
if (/not logged in|authenticat|login required/i.test(message))
|
|
116
|
+
return "missing-key";
|
|
117
|
+
if (/mcp.*baychat|baychat.*(unreachable|failed)/i.test(message))
|
|
118
|
+
return "baychat-unreachable";
|
|
119
|
+
return undefined;
|
|
120
|
+
},
|
|
121
|
+
// Cursor names the server of every MCP call it asks about, and the rule approves by that name.
|
|
122
|
+
// A project's own `.cursor/mcp.json` could define a server called "baychat" (in Spike 3 it was
|
|
123
|
+
// not started — but that is not a guarantee), so the name is random and new every turn.
|
|
124
|
+
mcpServerName: () => `${types_1.BAYCHAT_MCP_SERVER}-${(0, crypto_1.randomBytes)(4).toString("hex")}`,
|
|
125
|
+
toolNote: (server) => `Your BayChat tools this turn are on the MCP server "${server}" (shown as "${server}: send_message" and so on). Wherever your instructions say mcp__baychat__<tool>, use that server's <tool>. A BayChat server under any other name is not this turn's, and its calls are refused.`,
|
|
126
|
+
turnRefusal: cursorOverrideRefusal,
|
|
127
|
+
guardTurn: (cwd, onBreach) => watchCursorOverrides(cwd, onBreach),
|
|
128
|
+
decidePermission: permissions_1.decideCursorPermission,
|
|
129
|
+
keyHint: "Cursor is not logged in on that computer. There, run `agent login`.",
|
|
130
|
+
installHint: "Install it with `curl https://cursor.com/install -fsS | bash`, then run `baychat connect cursor-cli` again.",
|
|
131
|
+
// `agent` is a name any program could have. Cursor's CLI answers `--version` with a dated build
|
|
132
|
+
// id alone ("2026.09.18-9a7762b" [run]), so anything else is not started.
|
|
133
|
+
versionPattern: /^\d{4}\.\d{2}\.\d{2}-[0-9a-f]{7,}$/,
|
|
134
|
+
// Not `connect cursor`: that word already configures the Cursor EDITOR's MCP file, and the
|
|
135
|
+
// relay-run CLI must not take it over (it would silently stop doing what it does today).
|
|
136
|
+
connectCommand: "baychat connect cursor-cli",
|
|
137
|
+
// Edits cannot be ASKED in Cursor, only allowed or denied [run], so its `ask` is not dsh's.
|
|
138
|
+
modeDescriptions: {
|
|
139
|
+
ask: `ask — it edits files inside its folder without asking; edits outside it are refused; every command asks you on your phone first. ${modes_1.READS_ANYTHING}`,
|
|
140
|
+
},
|
|
141
|
+
confidence: "run",
|
|
142
|
+
checked: "2026-09-19",
|
|
143
|
+
testedVersion: "2026.09.18-9a7762b",
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Project files under `.cursor/` that Cursor reads beside BayChat's config, and why each is refused.
|
|
147
|
+
*
|
|
148
|
+
* `cli.json` [run, Spike 3]: a project `.cursor/cli.json` allowing `Shell(*)` OVERRODE the relay's
|
|
149
|
+
* config — with BayChat's chat config (deny read, write, shell, fetch) Cursor ran a command with
|
|
150
|
+
* no permission request. "Deny beats allow" holds only inside one file. Cursor looks in the
|
|
151
|
+
* folder and its parents, so every ancestor is checked. `hooks.json`: hooks run commands on
|
|
152
|
+
* Cursor's events; not run against the relay, refused to be safe.
|
|
153
|
+
*/
|
|
154
|
+
const CURSOR_OVERRIDES = [
|
|
155
|
+
{ name: "cli.json", why: "Cursor lets that project file override BayChat's limits for this session" },
|
|
156
|
+
{
|
|
157
|
+
name: "hooks.json",
|
|
158
|
+
why: "Cursor hooks run commands on its events, which could override BayChat's limits (this has not been tested with the relay, so it is refused to be safe)",
|
|
159
|
+
},
|
|
160
|
+
];
|
|
161
|
+
/**
|
|
162
|
+
* Why Cursor must not start in `cwd`: one of its override files exists there or in any folder
|
|
163
|
+
* above it, up to the filesystem root — or could not be checked. Undefined when none is there.
|
|
164
|
+
*/
|
|
165
|
+
function cursorOverrideRefusal(cwd) {
|
|
166
|
+
const found = findCursorOverride(cwd, presence);
|
|
167
|
+
return found && `Cursor was not started: ${found}`;
|
|
168
|
+
}
|
|
169
|
+
/** What was found, as a sentence that names the file and the way out — or undefined. */
|
|
170
|
+
function findCursorOverride(cwd, look) {
|
|
171
|
+
for (let dir = path.resolve(cwd);; dir = path.dirname(dir)) {
|
|
172
|
+
for (const { name, why } of CURSOR_OVERRIDES) {
|
|
173
|
+
const file = path.join(dir, ".cursor", name);
|
|
174
|
+
const seen = look(file);
|
|
175
|
+
if (seen === "present")
|
|
176
|
+
return `${file} exists, and ${why}. Remove or rename it to use this session.`;
|
|
177
|
+
if (seen !== "absent")
|
|
178
|
+
return `could not check whether ${file} exists (${seen}). A file there could override BayChat's limits, so Cursor is not run until it can be checked.`;
|
|
179
|
+
}
|
|
180
|
+
if (path.dirname(dir) === dir)
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** How often a turn's guard looks, when no filesystem event has told it to look sooner. */
|
|
185
|
+
const GUARD_POLL_MS = 250;
|
|
186
|
+
/**
|
|
187
|
+
* For the length of ONE turn, look for what `cursorOverrideRefusal` refuses — the refusal only
|
|
188
|
+
* sees the disk as it was before the turn, and Cursor could write the file during it. Calls
|
|
189
|
+
* `onBreach` once with what it found, then stops looking. Returns how to stop.
|
|
190
|
+
*
|
|
191
|
+
* A poll is the guarantee; filesystem events on the session folder and its `.cursor` (when
|
|
192
|
+
* there) only make it sooner. Nothing is created to be watched: the folder is the person's.
|
|
193
|
+
*/
|
|
194
|
+
function watchCursorOverrides(cwd, onBreach, deps = REAL_WATCH) {
|
|
195
|
+
let done = false;
|
|
196
|
+
const unwatch = [];
|
|
197
|
+
let watchingDotCursor = false;
|
|
198
|
+
const stop = () => {
|
|
199
|
+
done = true;
|
|
200
|
+
clearInterval(timer);
|
|
201
|
+
for (const close of unwatch.splice(0))
|
|
202
|
+
close();
|
|
203
|
+
};
|
|
204
|
+
const check = () => {
|
|
205
|
+
if (done)
|
|
206
|
+
return;
|
|
207
|
+
if (!watchingDotCursor && deps.look(path.join(cwd, ".cursor")) === "present") {
|
|
208
|
+
watchingDotCursor = true;
|
|
209
|
+
unwatch.push(deps.watch(path.join(cwd, ".cursor"), check));
|
|
210
|
+
}
|
|
211
|
+
const found = findCursorOverride(cwd, deps.look);
|
|
212
|
+
if (!found)
|
|
213
|
+
return;
|
|
214
|
+
stop();
|
|
215
|
+
onBreach(found);
|
|
216
|
+
};
|
|
217
|
+
const timer = setInterval(check, deps.pollMs);
|
|
218
|
+
timer.unref?.();
|
|
219
|
+
unwatch.push(deps.watch(cwd, check));
|
|
220
|
+
check();
|
|
221
|
+
return stop;
|
|
222
|
+
}
|
|
223
|
+
const REAL_WATCH = {
|
|
224
|
+
look: presence,
|
|
225
|
+
watch(dir, onEvent) {
|
|
226
|
+
try {
|
|
227
|
+
const watcher = fs.watch(dir, { persistent: false }, onEvent);
|
|
228
|
+
watcher.on("error", () => watcher.close()); // the poll carries on without it
|
|
229
|
+
return () => watcher.close();
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return () => undefined; // unwatchable here (some filesystems): the poll is the guarantee
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
pollMs: GUARD_POLL_MS,
|
|
236
|
+
};
|
|
237
|
+
/**
|
|
238
|
+
* Is there anything at `file`? `lstat`, so a symlink counts even when it leads nowhere. Only
|
|
239
|
+
* "no such file" is absence: a folder we may not look into could hold the file (RECURRING_MISTAKES
|
|
240
|
+
* §26), so any other error is returned as its code.
|
|
241
|
+
*/
|
|
242
|
+
function presence(file) {
|
|
243
|
+
try {
|
|
244
|
+
fs.lstatSync(file);
|
|
245
|
+
return "present";
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
const code = err.code;
|
|
249
|
+
return code === "ENOENT" || code === "ENOTDIR" ? "absent" : (code ?? "unknown error");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/** The security fields of a Cursor config, in one canonical text — or undefined if unparseable. */
|
|
253
|
+
function cursorSecurityFields(text) {
|
|
254
|
+
let config;
|
|
255
|
+
try {
|
|
256
|
+
config = JSON.parse(text);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return undefined; // unparseable is "not current": it is rewritten
|
|
260
|
+
}
|
|
261
|
+
if (!config || typeof config !== "object" || Array.isArray(config))
|
|
262
|
+
return undefined;
|
|
263
|
+
const { approvalMode, sandbox, permissions } = config;
|
|
264
|
+
return canonicalJson({ approvalMode, sandbox, permissions });
|
|
265
|
+
}
|
|
266
|
+
/** JSON with every object's keys sorted, so key order never reads as a difference. */
|
|
267
|
+
function canonicalJson(value) {
|
|
268
|
+
return JSON.stringify(value, (_key, v) => v && typeof v === "object" && !Array.isArray(v)
|
|
269
|
+
? Object.fromEntries(Object.entries(v).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
|
|
270
|
+
: v);
|
|
271
|
+
}
|