baychat 0.21.5 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -1
- package/dist/approve-hook.js +5 -2
- package/dist/commands.js +6 -4
- package/dist/connect-dsh.js +165 -0
- package/dist/connect-plan.js +16 -1
- package/dist/connect.js +7 -0
- package/dist/doctor-command.js +7 -0
- package/dist/doctor.js +94 -1
- package/dist/dsh-config.js +147 -0
- package/dist/index.js +22 -0
- package/dist/relay/acp/agents.js +186 -0
- package/dist/relay/acp/approval.js +67 -0
- package/dist/relay/acp/client.js +253 -0
- package/dist/relay/acp/commands.js +69 -0
- package/dist/relay/acp/daemon-glue.js +201 -0
- package/dist/relay/acp/dump-config.js +31 -0
- package/dist/relay/acp/modes.js +36 -0
- package/dist/relay/acp/permissions.js +42 -0
- package/dist/relay/acp/policy.js +137 -0
- package/dist/relay/acp/presence.js +87 -0
- package/dist/relay/acp/prompt.js +69 -0
- package/dist/relay/acp/runner.js +311 -0
- package/dist/relay/acp/sdk.js +19 -0
- package/dist/relay/acp/turn-queue.js +163 -0
- package/dist/relay/acp/types.js +2 -0
- package/dist/relay/commands.js +28 -0
- package/dist/relay/daemon.js +195 -0
- package/dist/relay/mailbox.js +1 -0
- package/dist/relay/profiles.js +22 -0
- package/dist/relay/registry.js +46 -1
- package/dist/relay/session-commands.js +89 -0
- package/dist/relay/session-reply.js +25 -0
- package/dist/relay/terminal-pane.js +197 -0
- package/dist/relay/types.js +0 -9
- package/dist/runtimes.js +13 -0
- package/dist/session-command.js +16 -3
- package/dist/skill-bootstrap.js +16 -8
- package/dist/start-command.js +235 -0
- package/dist/update-command.js +201 -0
- package/package.json +3 -1
package/dist/index.js
CHANGED
|
@@ -4,6 +4,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
4
4
|
const commands_1 = require("./commands");
|
|
5
5
|
const approve_hook_1 = require("./approve-hook");
|
|
6
6
|
const session_name_1 = require("./session-name");
|
|
7
|
+
const start_command_1 = require("./start-command");
|
|
8
|
+
const update_command_1 = require("./update-command");
|
|
7
9
|
const session_command_1 = require("./session-command");
|
|
8
10
|
const doctor_command_1 = require("./doctor-command");
|
|
9
11
|
const hermes_1 = require("./hermes");
|
|
@@ -31,6 +33,16 @@ Exit 0: step succeeded; 2: awaiting approval or expired; 1: error.
|
|
|
31
33
|
const HELP = `baychat — BayChat connector CLI for agent sessions (Claude Code, Codex)
|
|
32
34
|
|
|
33
35
|
Usage:
|
|
36
|
+
baychat Start a session. This is the whole thing: it runs
|
|
37
|
+
your agent somewhere BayChat can reach, so /clear
|
|
38
|
+
and /new from the app work. Sign in once with
|
|
39
|
+
\`baychat login\` first
|
|
40
|
+
baychat start [name] [--runtime <runtime>]
|
|
41
|
+
The same, with a name for the session
|
|
42
|
+
baychat update Bring this machine onto the current version —
|
|
43
|
+
the package, every connected client's config, and
|
|
44
|
+
the relay daemon (which otherwise keeps running
|
|
45
|
+
old code). \`baychat\` does this for you once a day
|
|
34
46
|
baychat join [name] [group] [--sessions | --private | --group <title>] [--runtime <runtime>]
|
|
35
47
|
baychat join --session <name> --group <title> --runtime codex
|
|
36
48
|
baychat skill --runtime codex|claude Read the current packaged skill workflow
|
|
@@ -170,6 +182,12 @@ function numberFlag(args, name) {
|
|
|
170
182
|
}
|
|
171
183
|
async function main() {
|
|
172
184
|
const [command, ...args] = process.argv.slice(2);
|
|
185
|
+
// BARE `baychat` STARTS A SESSION. The bar is "download BayChat, start a session, nothing
|
|
186
|
+
// else", and a usage dump printed at somebody who typed the product's own name is the
|
|
187
|
+
// "something else". `--help` still prints the help, and `start` refuses with one sentence
|
|
188
|
+
// when this machine is not signed in, so nothing is launched from an unusable state.
|
|
189
|
+
if (command === undefined)
|
|
190
|
+
return (0, start_command_1.cmdStart)([]);
|
|
173
191
|
// Setup help must be side-effect free. Do not apply this to chat commands:
|
|
174
192
|
// a sent message may legitimately contain the literal text "--help".
|
|
175
193
|
if (["login", "link", "connect", "pair", "join", "skill"].includes(command) &&
|
|
@@ -184,6 +202,10 @@ async function main() {
|
|
|
184
202
|
return 0;
|
|
185
203
|
case "join":
|
|
186
204
|
return (0, session_command_1.cmdJoinSession)(args);
|
|
205
|
+
case "start":
|
|
206
|
+
return (0, start_command_1.cmdStart)(args);
|
|
207
|
+
case "update":
|
|
208
|
+
return (0, update_command_1.cmdUpdate)();
|
|
187
209
|
case "skill": {
|
|
188
210
|
const runtime = args[1];
|
|
189
211
|
if (args.length !== 2 ||
|
|
@@ -0,0 +1,186 @@
|
|
|
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_AGENTS = exports.DSH_LOCAL_TOOLS = void 0;
|
|
37
|
+
exports.acpAgent = acpAgent;
|
|
38
|
+
exports.matchesTestedVersion = matchesTestedVersion;
|
|
39
|
+
exports.modeAllowedHere = modeAllowedHere;
|
|
40
|
+
exports.acpPatchDir = acpPatchDir;
|
|
41
|
+
exports.patchPath = patchPath;
|
|
42
|
+
exports.writeModePatches = writeModePatches;
|
|
43
|
+
const fs = __importStar(require("fs"));
|
|
44
|
+
const path = __importStar(require("path"));
|
|
45
|
+
const config_1 = require("../../config");
|
|
46
|
+
const modes_1 = require("./modes");
|
|
47
|
+
// Every tool plugin in dsh's `acp` profile (`--profile acp --dump-config`, 0.1.5-rc.2) [run].
|
|
48
|
+
// MCP clients are mounted per ACP session by dsh-acp itself and are untouched by these.
|
|
49
|
+
// Confirmed against docs/planning/2026-09-19-acp-research/dump-acp-default.yml — the tool-* ids
|
|
50
|
+
// there match this list exactly, in the same order.
|
|
51
|
+
exports.DSH_LOCAL_TOOLS = [
|
|
52
|
+
"tool-bash", "tool-pwsh", "tool-jobs", "tool-fs", "tool-fs-search", "tool-skill",
|
|
53
|
+
"tool-subagent-control", "tool-subagent-list-agents", "tool-subagent", "tool-subagent-fork",
|
|
54
|
+
"tool-workflow", "tool-todo", "tool-goal", "tool-ralph", "tool-web",
|
|
55
|
+
];
|
|
56
|
+
const disable = (ids, why) => [`# Written by BayChat. ${why}`, ...ids.flatMap((id) => [`- id: ${id}`, " disabled: true"]), ""].join("\n");
|
|
57
|
+
const dsh = {
|
|
58
|
+
id: "dsh",
|
|
59
|
+
label: "DeepSeek Harness",
|
|
60
|
+
bin: "dsh",
|
|
61
|
+
patches: {
|
|
62
|
+
chat: disable(exports.DSH_LOCAL_TOOLS, "chat mode: no local tools at all."),
|
|
63
|
+
read: disable(exports.DSH_LOCAL_TOOLS.filter((id) => id !== "tool-fs" && id !== "tool-fs-search"), "read mode: file read and search only — no shell, no subagents, no web."),
|
|
64
|
+
},
|
|
65
|
+
launch(mode, patchDir) {
|
|
66
|
+
const args = ["--profile", "acp"];
|
|
67
|
+
if (this.patches[mode])
|
|
68
|
+
args.push("--patch", patchPath(this, mode, patchDir));
|
|
69
|
+
// `danger-full-access` is deliberately unreachable from here: no mode maps to it.
|
|
70
|
+
return { args, env: { DSH_PERMISSION_MODE: mode === "full" ? "workspace-write" : "read-only" } };
|
|
71
|
+
},
|
|
72
|
+
// dsh's Windows sandbox reports itself `partial` (Everyone-writable objects, NTFS hard links)
|
|
73
|
+
// [source], so the modes that RELY on it are withheld there. `chat` needs no sandbox and
|
|
74
|
+
// `read`'s write fence is an in-process check.
|
|
75
|
+
modesFor: (platform) => (platform === "win32" ? ["chat", "read"] : ["chat", "read", "ask", "full"]),
|
|
76
|
+
classifyError(message) {
|
|
77
|
+
if (/no API key|MISSING_CREDENTIAL|INVALID_CREDENTIAL/i.test(message))
|
|
78
|
+
return "missing-key";
|
|
79
|
+
if (/mcp-client\(baychat\)/i.test(message))
|
|
80
|
+
return "baychat-unreachable";
|
|
81
|
+
if (/already active|session is active/i.test(message))
|
|
82
|
+
return "busy";
|
|
83
|
+
if (/session cwd does not match/i.test(message))
|
|
84
|
+
return "session";
|
|
85
|
+
return undefined;
|
|
86
|
+
},
|
|
87
|
+
keyHint: "DeepSeek has no API key on that computer. There, run `dsh web` and add the key on the Models page.",
|
|
88
|
+
installHint: "Install it with `npm install -g @deepseek-ai/dsh`, then run `baychat connect dsh` again.",
|
|
89
|
+
confidence: "run",
|
|
90
|
+
checked: "2026-09-19",
|
|
91
|
+
testedVersion: "0.1.5-rc.2",
|
|
92
|
+
};
|
|
93
|
+
exports.ACP_AGENTS = [dsh];
|
|
94
|
+
function acpAgent(id) {
|
|
95
|
+
return exports.ACP_AGENTS.find((row) => row.id === id);
|
|
96
|
+
}
|
|
97
|
+
/** A version-string character: digits, letters, dot, plus, hyphen — the semver/rc alphabet. */
|
|
98
|
+
const VERSION_TOKEN_CHAR = /[0-9A-Za-z.+-]/;
|
|
99
|
+
/**
|
|
100
|
+
* Does `version` (raw `--version` output) contain `tested` as a WHOLE version token, not merely
|
|
101
|
+
* as a substring?
|
|
102
|
+
*
|
|
103
|
+
* `"0.1.5-rc.20".includes("0.1.5-rc.2")` is `true` — a plain substring check silently passes a
|
|
104
|
+
* different release and the mismatch warning this exists for never shows. So `tested` must be
|
|
105
|
+
* bounded: the character before it is the start of the string, something that is not part of a
|
|
106
|
+
* version token, or a leading `v` (allowed unconditionally — a version prefix, not a token
|
|
107
|
+
* character to fence against); the character after it is the end of the string or something that
|
|
108
|
+
* is not part of a version token. `--version` output varies by CLI — `"0.1.5-rc.2"`,
|
|
109
|
+
* `"dsh 0.1.5-rc.2"`, `"v0.1.5-rc.2"`, `"0.1.5-rc.2 (linux-x64)"` — and this matches all of them
|
|
110
|
+
* while refusing `"0.1.5-rc.20"`, `"10.1.5-rc.2"`, `"0.1.5-rc.2.1"` and `"0.1.5-rc.2-beta"`.
|
|
111
|
+
*/
|
|
112
|
+
function matchesTestedVersion(version, tested) {
|
|
113
|
+
if (!tested)
|
|
114
|
+
return false;
|
|
115
|
+
let from = 0;
|
|
116
|
+
for (;;) {
|
|
117
|
+
const at = version.indexOf(tested, from);
|
|
118
|
+
if (at === -1)
|
|
119
|
+
return false;
|
|
120
|
+
const before = at === 0 ? undefined : version[at - 1];
|
|
121
|
+
const after = version[at + tested.length];
|
|
122
|
+
const boundedBefore = before === undefined || before === "v" || !VERSION_TOKEN_CHAR.test(before);
|
|
123
|
+
const boundedAfter = after === undefined || !VERSION_TOKEN_CHAR.test(after);
|
|
124
|
+
if (boundedBefore && boundedAfter)
|
|
125
|
+
return true;
|
|
126
|
+
from = at + 1;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* May a session of this agent run in `mode`, in a folder whose ceiling is `maxMode`, on this
|
|
131
|
+
* platform? The ONE rule: `/mode` from the chat and `acp-register` from `connect` both ask it.
|
|
132
|
+
*/
|
|
133
|
+
function modeAllowedHere(row, mode, maxMode, platform) {
|
|
134
|
+
if ((0, modes_1.modeRank)(mode) > (0, modes_1.modeRank)(maxMode)) {
|
|
135
|
+
return { ok: false, reason: `${mode} is above the highest mode allowed for that folder (${maxMode}). That ceiling is set on the computer, never from a chat: run \`baychat connect ${row.id}\` there to change it.` };
|
|
136
|
+
}
|
|
137
|
+
if (!row.modesFor(platform).includes(mode)) {
|
|
138
|
+
return { ok: false, reason: `${mode} is not offered on that computer's operating system, because ${row.label}'s sandbox cannot fully enforce it there.` };
|
|
139
|
+
}
|
|
140
|
+
return { ok: true };
|
|
141
|
+
}
|
|
142
|
+
/** Where mode patches live: `~/.baychat/acp/`. */
|
|
143
|
+
function acpPatchDir() {
|
|
144
|
+
return path.join((0, config_1.configDir)(), "acp");
|
|
145
|
+
}
|
|
146
|
+
function patchPath(row, mode, dir) {
|
|
147
|
+
return path.join(dir, `${row.id}-${mode}.yml`);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* (Re)write this agent's mode patches. Idempotent; called by `connect`, at daemon start and
|
|
151
|
+
* before every turn.
|
|
152
|
+
*
|
|
153
|
+
* NEVER TRUNCATE IN PLACE. Two sessions drain concurrently, so one session's rewrite can land
|
|
154
|
+
* while another's freshly spawned agent is reading the same patch — and an empty `chat` patch is
|
|
155
|
+
* an agent with every local tool. A patch that is already right is left alone; one that is not is
|
|
156
|
+
* written beside it and renamed over it, so a reader sees the old file or the new, never half.
|
|
157
|
+
*/
|
|
158
|
+
function writeModePatches(row, dir) {
|
|
159
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
160
|
+
for (const [mode, content] of Object.entries(row.patches)) {
|
|
161
|
+
const file = patchPath(row, mode, dir);
|
|
162
|
+
if (readOrUndefined(file) === content) {
|
|
163
|
+
fs.chmodSync(file, 0o600); // permissions only — the content and the inode stay as they are
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
167
|
+
try {
|
|
168
|
+
fs.writeFileSync(tmp, content, { mode: 0o600, flag: "wx" });
|
|
169
|
+
fs.chmodSync(tmp, 0o600); // `mode` is filtered by the umask
|
|
170
|
+
fs.renameSync(tmp, file);
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
fs.rmSync(tmp, { force: true });
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/** A read error (absent, unreadable) means "write it". */
|
|
179
|
+
function readOrUndefined(file) {
|
|
180
|
+
try {
|
|
181
|
+
return fs.readFileSync(file, "utf8");
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.askPhone = askPhone;
|
|
4
|
+
const api_1 = require("../../api");
|
|
5
|
+
const approve_hook_1 = require("../../approve-hook");
|
|
6
|
+
const HOLD_SECONDS = 25; // under the usual 60s proxy idle timeout — same as approve-hook
|
|
7
|
+
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
8
|
+
const RETRY_DELAY_MS = 2_000;
|
|
9
|
+
const ALLOW_INDEX = 0; // the ONLY index that means yes
|
|
10
|
+
/**
|
|
11
|
+
* Put a decision card on the owner's phone and wait for the tap.
|
|
12
|
+
*
|
|
13
|
+
* NEVER THROWS, and every path that is not an explicit "Allow this once" is a refusal: an
|
|
14
|
+
* unreachable server, a status we do not know, a withdrawn card, the deadline. The card itself
|
|
15
|
+
* never expires server-side; our deadline exists so a turn cannot hold a process forever, and
|
|
16
|
+
* reaching it REFUSES. Silence is never a yes.
|
|
17
|
+
*/
|
|
18
|
+
async function askPhone(params, deps = {}) {
|
|
19
|
+
const request = deps.request ?? api_1.apiRequest;
|
|
20
|
+
const now = deps.now ?? Date.now;
|
|
21
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
22
|
+
const waitSeconds = params.waitSeconds ?? approve_hook_1.DEFAULT_WAIT_SECONDS;
|
|
23
|
+
const deadline = now() + waitSeconds * 1000;
|
|
24
|
+
const hold = () => Math.max(1, Math.min(HOLD_SECONDS, Math.ceil((deadline - now()) / 1000)));
|
|
25
|
+
const no = (reason) => ({ allowed: false, reason });
|
|
26
|
+
try {
|
|
27
|
+
let reply = await request(params.auth, "POST", "/api/device-api/approvals", {
|
|
28
|
+
session: params.session,
|
|
29
|
+
conversationId: params.conversationId,
|
|
30
|
+
question: params.question,
|
|
31
|
+
options: [...approve_hook_1.APPROVE_OPTIONS],
|
|
32
|
+
wait: hold(),
|
|
33
|
+
});
|
|
34
|
+
const requestId = typeof reply.requestId === "string" ? reply.requestId : "";
|
|
35
|
+
if (!requestId)
|
|
36
|
+
return no("BayChat did not return an approval id");
|
|
37
|
+
let failures = 0;
|
|
38
|
+
for (;;) {
|
|
39
|
+
if (reply.status === "answered") {
|
|
40
|
+
return reply.answerIndex === ALLOW_INDEX
|
|
41
|
+
? { allowed: true, reason: "approved in BayChat" }
|
|
42
|
+
: no(`answered "${reply.answer ?? approve_hook_1.APPROVE_OPTIONS[1]}" in BayChat`);
|
|
43
|
+
}
|
|
44
|
+
if (reply.status === "cancelled")
|
|
45
|
+
return no("the decision was withdrawn in BayChat");
|
|
46
|
+
if (reply.status !== "waiting")
|
|
47
|
+
return no(`BayChat answered with a status this relay does not understand ("${String(reply.status)}")`);
|
|
48
|
+
if (deadline - now() <= 0)
|
|
49
|
+
return no(`nobody answered within ${waitSeconds}s — the card is still in BayChat, and this request was refused`);
|
|
50
|
+
try {
|
|
51
|
+
reply = await request(params.auth, "GET", `/api/device-api/approvals/${encodeURIComponent(requestId)}?wait=${hold()}`);
|
|
52
|
+
failures = 0;
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
if (err instanceof api_1.ApiError && err.status < 500 && err.status !== 429)
|
|
56
|
+
return no(`BayChat refused the wait — ${err.message}`);
|
|
57
|
+
failures += 1;
|
|
58
|
+
if (failures >= MAX_CONSECUTIVE_FAILURES)
|
|
59
|
+
return no(`BayChat became unreachable while waiting — ${err instanceof Error ? err.message : String(err)}`);
|
|
60
|
+
await sleep(RETRY_DELAY_MS);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
return no(`BayChat could not be asked — ${err instanceof Error ? err.message : String(err)}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -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
|
+
}
|