baychat 0.38.0 → 0.40.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/.claude-plugin/plugin.json +1 -1
- package/README.md +9 -4
- package/dist/approval-wait.js +15 -3
- package/dist/approve-hook.js +96 -11
- package/dist/card-after.js +47 -0
- package/dist/card-key.js +27 -0
- package/dist/cli-help.js +9 -4
- package/dist/denied/after-hook.js +7 -2
- package/dist/main.js +11 -5
- package/dist/notify-state.js +62 -7
- package/dist/protocol-content.js +1 -1
- package/dist/relay/daemon.js +15 -0
- package/dist/relay/launcher/approval-settings.js +23 -10
- package/dist/relay/launcher/loop.js +12 -1
- package/dist/relay/launcher/session-spots.js +17 -2
- package/dist/relay/pane-interrupt.js +113 -0
- package/dist/relay/session-commands.js +1 -1
- package/dist/relay/terminal-stop.js +41 -0
- package/dist/relay/updates.js +3 -1
- package/dist/setup-command.js +126 -0
- package/dist/setup-facts.js +44 -0
- package/dist/setup-plan.js +94 -0
- package/dist/start-command.js +1 -1
- package/dist/tool-defs.js +8 -4
- package/package.json +1 -1
package/dist/relay/daemon.js
CHANGED
|
@@ -48,6 +48,7 @@ const bay_deleted_1 = require("../bay-deleted");
|
|
|
48
48
|
const context_report_1 = require("../context-report");
|
|
49
49
|
const codex_context_1 = require("./codex-context");
|
|
50
50
|
const session_commands_1 = require("./session-commands");
|
|
51
|
+
const terminal_stop_1 = require("./terminal-stop");
|
|
51
52
|
const terminal_pane_1 = require("./terminal-pane");
|
|
52
53
|
const compact_command_1 = require("./compact-command");
|
|
53
54
|
const fresh_start_1 = require("./fresh-start");
|
|
@@ -651,6 +652,13 @@ class RelayDaemon {
|
|
|
651
652
|
// own app-server daemon, for the thread the join corroborated (codex-control.ts).
|
|
652
653
|
if (await this.runJoinedCodexCommand(session, target, command, compact.focus, say))
|
|
653
654
|
return;
|
|
655
|
+
// `/stop` for a session in someone's own terminal (the app's Stop): owner only, one interrupt
|
|
656
|
+
// through `interruptPane` (terminal-stop.ts). Relay-run sessions keep their own `/stop` below.
|
|
657
|
+
if (command.id === "stop" && !target.acp && !target.codex) {
|
|
658
|
+
if (target.delivery === "channel" && !this.channelLinked(session))
|
|
659
|
+
return say((0, channel_sessions_1.channelGoneReason)(session));
|
|
660
|
+
return (0, terminal_stop_1.runTerminalStop)(session, target, message, say);
|
|
661
|
+
}
|
|
654
662
|
// `/mode-*`, `/stop`, and what `/status` `/exit` `/clear` `/new` mean with no terminal.
|
|
655
663
|
if (await (0, commands_1.runAcpSessionCommand)(this.acp, session, target, message, command, say))
|
|
656
664
|
return;
|
|
@@ -1887,6 +1895,7 @@ class RelayDaemon {
|
|
|
1887
1895
|
*/
|
|
1888
1896
|
registerChannel(frame, sock) {
|
|
1889
1897
|
const previous = this.channels.get(frame.session);
|
|
1898
|
+
const before = this.registry.get(frame.session);
|
|
1890
1899
|
if (previous && previous !== sock) {
|
|
1891
1900
|
this.log(`replacing channel for ${frame.session}: dropping the previous one`);
|
|
1892
1901
|
(0, socket_1.writeFrame)(previous, { type: "error", message: channel_sessions_1.CHANNEL_REPLACED });
|
|
@@ -1905,6 +1914,12 @@ class RelayDaemon {
|
|
|
1905
1914
|
(0, socket_1.writeFrame)(sock, { type: "attached", session: frame.session, delivery: "channel" });
|
|
1906
1915
|
const where = frame.pane?.kind === "tmux" ? `tmux pane ${frame.pane.pane}` : (frame.pane?.kind ?? "no multiplexer");
|
|
1907
1916
|
this.log(`channel registered: ${frame.session} (${frame.runtime}, commands only, ${where})`);
|
|
1917
|
+
// Another Claude Code process took this name (a new terminal, 2026-10-30): its memory starts
|
|
1918
|
+
// empty, so the server must not keep showing the old process's reading and tidy count.
|
|
1919
|
+
// The same process re-registering (a reconnect) keeps its pid and sends nothing.
|
|
1920
|
+
if (before?.ownerPid && frame.ownerPid && before.ownerPid !== frame.ownerPid) {
|
|
1921
|
+
this.reportContext(frame.session, { runtime: "claude-code", event: "session_start", source: "exact" });
|
|
1922
|
+
}
|
|
1908
1923
|
}
|
|
1909
1924
|
/**
|
|
1910
1925
|
* Forget every channel session that is DEAD: its channel link is closed and its Claude Code
|
|
@@ -114,19 +114,26 @@ function permissionHook(cli, flags) {
|
|
|
114
114
|
],
|
|
115
115
|
},
|
|
116
116
|
],
|
|
117
|
+
...afterHooks(cli),
|
|
117
118
|
};
|
|
118
119
|
}
|
|
119
|
-
/**
|
|
120
|
-
|
|
120
|
+
/**
|
|
121
|
+
* The tools the after-hook watches: every tool that can draw a permission dialog — a card answered at
|
|
122
|
+
* the keyboard is closed from here (card-after.ts) — which covers the once-rule tools too
|
|
123
|
+
* (permission-rule.ts: Bash, WebFetch, MCP).
|
|
124
|
+
*/
|
|
125
|
+
const AFTER_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit|WebFetch|WebSearch|mcp__.*";
|
|
121
126
|
/** The after-hook is async and returns in milliseconds; this bounds only a wedged one. */
|
|
122
|
-
const
|
|
127
|
+
const AFTER_TIMEOUT_SECONDS = 30;
|
|
123
128
|
/**
|
|
124
|
-
* The denied-
|
|
125
|
-
*
|
|
129
|
+
* The async after-hook (`denied-after`): takes an "Allow once" rule back out, and closes a permission
|
|
130
|
+
* card answered in the terminal instead (fix/needs-you-closes-itself — measured on Claude Code
|
|
131
|
+
* 2.1.316, a "Yes" at the keyboard leaves approve-hook parked and its card open). In every file that
|
|
132
|
+
* has approve-hook, whatever the denied-card switch says.
|
|
126
133
|
*/
|
|
127
|
-
function
|
|
134
|
+
function afterHooks(cli) {
|
|
128
135
|
const after = {
|
|
129
|
-
matcher:
|
|
136
|
+
matcher: AFTER_MATCHER,
|
|
130
137
|
hooks: [
|
|
131
138
|
{
|
|
132
139
|
type: "command",
|
|
@@ -134,10 +141,17 @@ function deniedCardHooks(cli, flags) {
|
|
|
134
141
|
args: [cli.entry, "denied-after"],
|
|
135
142
|
// Never in a tool call's way: Claude Code starts it and carries on.
|
|
136
143
|
async: true,
|
|
137
|
-
timeout:
|
|
144
|
+
timeout: AFTER_TIMEOUT_SECONDS,
|
|
138
145
|
},
|
|
139
146
|
],
|
|
140
147
|
};
|
|
148
|
+
return { PostToolUse: [after], PostToolUseFailure: [after] };
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The denied-command card's hooks (docs/features/DENIED_COMMAND_CARD.md). `flags` are the same
|
|
152
|
+
* session flags approve-hook gets in that file.
|
|
153
|
+
*/
|
|
154
|
+
function deniedCardHooks(cli, flags) {
|
|
141
155
|
return {
|
|
142
156
|
PermissionDenied: [
|
|
143
157
|
{
|
|
@@ -155,8 +169,7 @@ function deniedCardHooks(cli, flags) {
|
|
|
155
169
|
],
|
|
156
170
|
},
|
|
157
171
|
],
|
|
158
|
-
|
|
159
|
-
PostToolUseFailure: [after],
|
|
172
|
+
...afterHooks(cli),
|
|
160
173
|
};
|
|
161
174
|
}
|
|
162
175
|
/** `settings` with the denied-command card's hooks added beside its own. Pure. */
|
|
@@ -133,7 +133,18 @@ async function reportSessionSpots(deps, auth, signal, last, log) {
|
|
|
133
133
|
if (key === last)
|
|
134
134
|
return last;
|
|
135
135
|
try {
|
|
136
|
-
|
|
136
|
+
try {
|
|
137
|
+
await deps.request(auth, "PUT", "/api/device-api/launch-sessions", { sessions }, signal);
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
// A server older than the `withinSpotId` hint (2026-10-30) refuses the whole report as
|
|
141
|
+
// INVALID_SESSION_SPOTS: send it again without the hints, so "Start again" places still land.
|
|
142
|
+
const hinted = sessions.some((s) => s.withinSpotId !== undefined);
|
|
143
|
+
if (!(hinted && err instanceof api_1.ApiError && err.status === 400))
|
|
144
|
+
throw err;
|
|
145
|
+
const plain = sessions.map(({ withinSpotId: _hint, ...rest }) => rest);
|
|
146
|
+
await deps.request(auth, "PUT", "/api/device-api/launch-sessions", { sessions: plain }, signal);
|
|
147
|
+
}
|
|
137
148
|
return key;
|
|
138
149
|
}
|
|
139
150
|
catch (err) {
|
|
@@ -37,6 +37,7 @@ exports.MAX_SESSION_SPOTS = void 0;
|
|
|
37
37
|
exports.sessionSpots = sessionSpots;
|
|
38
38
|
exports.realRealpath = realRealpath;
|
|
39
39
|
const fs = __importStar(require("fs"));
|
|
40
|
+
const path = __importStar(require("path"));
|
|
40
41
|
const policy_1 = require("../../launch/policy");
|
|
41
42
|
const argv_1 = require("./argv");
|
|
42
43
|
/** The server takes at most this many per report. */
|
|
@@ -56,13 +57,27 @@ function sessionSpots(sessions, policy, deps) {
|
|
|
56
57
|
continue;
|
|
57
58
|
const real = deps.realpath(dir);
|
|
58
59
|
const runtime = session.runtime;
|
|
59
|
-
const
|
|
60
|
-
|
|
60
|
+
const offered = folders.filter((f) => f.spot.runtimes.includes(runtime));
|
|
61
|
+
const match = real ? offered.find((f) => f.path === real) : undefined;
|
|
62
|
+
// The deepest spot folder above it: the longest path that is a proper prefix at a separator.
|
|
63
|
+
const within = real && !match ? offered.filter((f) => isInside(real, f.path)).sort((a, b) => b.path.length - a.path.length)[0] : undefined;
|
|
64
|
+
out.push({
|
|
65
|
+
name: session.name,
|
|
66
|
+
spotId: match?.spot.id ?? null,
|
|
67
|
+
...(within ? { withinSpotId: within.spot.id } : {}),
|
|
68
|
+
runtime,
|
|
69
|
+
listen: session.delivery === "channel" ? "channel" : "monitor",
|
|
70
|
+
});
|
|
61
71
|
if (out.length === exports.MAX_SESSION_SPOTS)
|
|
62
72
|
break;
|
|
63
73
|
}
|
|
64
74
|
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
65
75
|
}
|
|
76
|
+
/** Is `dir` strictly inside `parent` (both realpaths)? `/work/web2` is not inside `/work/web`. */
|
|
77
|
+
function isInside(dir, parent) {
|
|
78
|
+
const prefix = parent.endsWith(path.sep) ? parent : parent + path.sep;
|
|
79
|
+
return dir.startsWith(prefix);
|
|
80
|
+
}
|
|
66
81
|
function realRealpath(dir) {
|
|
67
82
|
try {
|
|
68
83
|
return fs.realpathSync(dir);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.INTERRUPT_KEYS = void 0;
|
|
4
|
+
exports.isBusy = isBusy;
|
|
5
|
+
exports.interruptPane = interruptPane;
|
|
6
|
+
const child_process_1 = require("child_process");
|
|
7
|
+
const terminal_pane_1 = require("./terminal-pane");
|
|
8
|
+
/**
|
|
9
|
+
* Stop: interrupt the turn a terminal session is working on (docs/design/AGENT_STOP_UNDO.md §2.5).
|
|
10
|
+
*
|
|
11
|
+
* ── THE ONLY KEYSTROKE PATH THAT IS NOT TEXT ─────────────────────────────────────────
|
|
12
|
+
*
|
|
13
|
+
* `typeIntoPane` types TEXT, always with `-l`, so nothing it sends can ever be read as a key name.
|
|
14
|
+
* Stop needs one real key, so it lives here, apart, and is narrower still:
|
|
15
|
+
*
|
|
16
|
+
* 1. the same pane checks as every keystroke (`verifyPane`: this user's socket, the runtime still
|
|
17
|
+
* in the foreground — a shell left behind is never typed into);
|
|
18
|
+
* 2. TWO looks 700 ms apart must both show the runtime's own "working" line — no work, no key;
|
|
19
|
+
* 3. ONE key from a frozen table, by key name, with no `-l` and no Enter;
|
|
20
|
+
* 4. one more look after 1 s: the working line gone is DONE, still there is FAILED. No second key.
|
|
21
|
+
*
|
|
22
|
+
* Escape, not Ctrl-C: in Claude Code and Codex, Esc interrupts the current turn and keeps the
|
|
23
|
+
* session; Ctrl-C twice EXITS Claude Code, so a doubled Ctrl-C would be an End by accident. The wire
|
|
24
|
+
* carries the command name `interrupt`, never a key: the key is decided here and only here.
|
|
25
|
+
*/
|
|
26
|
+
exports.INTERRUPT_KEYS = Object.freeze({ claude: "Escape", codex: "Escape" });
|
|
27
|
+
/**
|
|
28
|
+
* The line each runtime shows while a turn runs. A runtime without a row is never busy.
|
|
29
|
+
*
|
|
30
|
+
* Claude Code, captured on this Mac 1 Nov 2026 (native build, a turn running):
|
|
31
|
+
* "· Infusing… (3m 25s · ↓ 12.3k tokens · thought for 1s)"
|
|
32
|
+
* — a spinner glyph, a gerund ending in "…", then "(" and the elapsed time. Older builds added
|
|
33
|
+
* "· esc to interrupt"; the current one does not, so the time is what proves a turn runs. A finished
|
|
34
|
+
* turn reads "✻ Worked for 3m 25s" (no "…", no "("), which must NOT match.
|
|
35
|
+
* Codex: "• Working (8s • esc to interrupt)".
|
|
36
|
+
*/
|
|
37
|
+
const BUSY_LINES = Object.freeze({
|
|
38
|
+
claude: /^\s*\S\s+\S+…\s+\((?:\d+h\s+)?(?:\d+m\s+)?\d+s\b|esc to interrupt/im,
|
|
39
|
+
codex: /esc to interrupt/i,
|
|
40
|
+
});
|
|
41
|
+
/** The first look, the second look, and the check after the key, in ms. */
|
|
42
|
+
const SECOND_LOOK_MS = 700;
|
|
43
|
+
const AFTER_KEY_MS = 1000;
|
|
44
|
+
/**
|
|
45
|
+
* How many lines at the bottom of the pane are read. The working line sits right above the input
|
|
46
|
+
* box (captured 1 Nov: 8 lines from the bottom, under a two-line tip). Anything higher up is the
|
|
47
|
+
* conversation — and a chat, a log or a review can QUOTE a working line there while the session is
|
|
48
|
+
* idle (vps, review of fc377a96e), which must never earn an Escape.
|
|
49
|
+
*/
|
|
50
|
+
const BOTTOM_LINES = 12;
|
|
51
|
+
/** Does this capture show `runtime` working on a turn right now? Only its bottom counts. */
|
|
52
|
+
function isBusy(runtime, capture) {
|
|
53
|
+
const line = BUSY_LINES[runtime];
|
|
54
|
+
if (!line)
|
|
55
|
+
return false;
|
|
56
|
+
const lines = capture.split("\n");
|
|
57
|
+
// capture-pane pads to the pane's height with empty lines; the bottom is the last line drawn.
|
|
58
|
+
while (lines.length > 0 && lines[lines.length - 1].trim() === "")
|
|
59
|
+
lines.pop();
|
|
60
|
+
return line.test(lines.slice(-BOTTOM_LINES).join("\n"));
|
|
61
|
+
}
|
|
62
|
+
/** Interrupt the session's current turn, or say honestly why not. Never throws. */
|
|
63
|
+
async function interruptPane(handle, runtime, deps = {}) {
|
|
64
|
+
if (handle.kind !== "tmux") {
|
|
65
|
+
return { outcome: "UNREACHABLE", reason: `${handle.kind} can't be stopped from BayChat yet — tmux can.` };
|
|
66
|
+
}
|
|
67
|
+
const key = exports.INTERRUPT_KEYS[runtime];
|
|
68
|
+
if (!key)
|
|
69
|
+
return { outcome: "UNREACHABLE", reason: `${runtime} can't be stopped from BayChat.` };
|
|
70
|
+
const check = (deps.verify ?? terminal_pane_1.verifyPane)(handle, runtime, undefined, deps.ownerPid);
|
|
71
|
+
if (!check.ok)
|
|
72
|
+
return { outcome: "UNREACHABLE", reason: check.reason };
|
|
73
|
+
const capture = deps.capture ?? capturePane;
|
|
74
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
75
|
+
const exec = deps.exec ?? runQuietly;
|
|
76
|
+
try {
|
|
77
|
+
if (!isBusy(runtime, await capture(handle)))
|
|
78
|
+
return { outcome: "NOTHING_RUNNING" };
|
|
79
|
+
await sleep(SECOND_LOOK_MS);
|
|
80
|
+
if (!isBusy(runtime, await capture(handle)))
|
|
81
|
+
return { outcome: "NOTHING_RUNNING" };
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
return { outcome: "UNREACHABLE", reason: `the pane could not be read: ${errText(err)}` };
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
// A KEY NAME, deliberately without `-l`; and no Enter.
|
|
88
|
+
await exec("tmux", ["-S", handle.socket, "send-keys", "-t", handle.pane, key]);
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
return { outcome: "FAILED", reason: `tmux refused the key: ${errText(err)}` };
|
|
92
|
+
}
|
|
93
|
+
await sleep(AFTER_KEY_MS);
|
|
94
|
+
try {
|
|
95
|
+
return isBusy(runtime, await capture(handle)) ? { outcome: "FAILED", reason: "it is still working" } : { outcome: "DONE" };
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
return { outcome: "FAILED", reason: `the pane could not be read after the key: ${errText(err)}` };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function capturePane(handle) {
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
(0, child_process_1.execFile)("tmux", ["-S", handle.socket, "capture-pane", "-p", "-t", handle.pane], { timeout: 5_000, encoding: "utf8" }, (err, out) => err ? reject(err) : resolve(String(out)));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
function runQuietly(file, args) {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
(0, child_process_1.execFile)(file, args, { timeout: 5_000 }, (err) => (err ? reject(err) : resolve()));
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
function errText(err) {
|
|
112
|
+
return err instanceof Error ? err.message : String(err);
|
|
113
|
+
}
|
|
@@ -29,7 +29,7 @@ exports.SESSION_COMMANDS = [
|
|
|
29
29
|
{ id: "mode-read", trigger: "/mode-read", mode: "read", summary: "Relay-run agents: may read files, no commands, no writes. Owner or anyone with command access." },
|
|
30
30
|
{ id: "mode-ask", trigger: "/mode-ask", mode: "ask", summary: "Relay-run agents: may run commands; writes ask on your phone first. Owner or anyone with command access." },
|
|
31
31
|
{ id: "mode-full", trigger: "/mode-full", mode: "full", summary: "Relay-run agents: may write inside its folder without asking. Owner or anyone with command access." },
|
|
32
|
-
{ id: "stop", trigger: "/stop", summary: "
|
|
32
|
+
{ id: "stop", trigger: "/stop", summary: "Stop the turn that is running now (its owner; a terminal session needs tmux)." },
|
|
33
33
|
{
|
|
34
34
|
id: "exit",
|
|
35
35
|
trigger: "/exit",
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runTerminalStop = runTerminalStop;
|
|
4
|
+
const pane_interrupt_1 = require("./pane-interrupt");
|
|
5
|
+
/**
|
|
6
|
+
* `/stop` for a session running in someone's own terminal (AGENT_STOP_UNDO.md §2) — what the app's
|
|
7
|
+
* Stop button sends, and what the owner may type in a chat.
|
|
8
|
+
*
|
|
9
|
+
* OWNER ONLY. Stop is a keystroke into a terminal on the owner's own computer; a person she granted
|
|
10
|
+
* command access may tidy a session, never stop it (§2.2). `senderIsOwner` is decided by the server
|
|
11
|
+
* at hand-over, so this is the second check after the app's route.
|
|
12
|
+
*
|
|
13
|
+
* One interrupt (`interruptPane`: two looks, one Escape, one look after) and one reply that says what
|
|
14
|
+
* the pane showed. Relay-run sessions (ACP, Codex) keep their own `/stop`; this is only reached for a
|
|
15
|
+
* session in a terminal.
|
|
16
|
+
*/
|
|
17
|
+
async function runTerminalStop(session, target, message, say, deps = {}) {
|
|
18
|
+
if (message.senderIsOwner !== true) {
|
|
19
|
+
await say(`Only the owner of "${session}" can stop it.`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (!target.pane) {
|
|
23
|
+
await say(`"${session}" isn't in a tmux terminal, so BayChat can't stop it. Stop it in its own terminal.`);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const interrupt = deps.interrupt ?? ((handle, runtime, opts) => (0, pane_interrupt_1.interruptPane)(handle, runtime, opts));
|
|
27
|
+
const result = await interrupt(target.pane, target.runtime, { ownerPid: target.ownerPid });
|
|
28
|
+
await say(stopReply(session, result));
|
|
29
|
+
}
|
|
30
|
+
function stopReply(session, result) {
|
|
31
|
+
switch (result.outcome) {
|
|
32
|
+
case "DONE":
|
|
33
|
+
return `Stopped "${session}". It is waiting for you; anything it already did stays done.`;
|
|
34
|
+
case "NOTHING_RUNNING":
|
|
35
|
+
return `"${session}" was not working on anything, so nothing was sent.`;
|
|
36
|
+
case "FAILED":
|
|
37
|
+
return `"${session}" did not stop (${result.reason ?? "it is still working"}). Stop it in its own terminal.`;
|
|
38
|
+
default:
|
|
39
|
+
return `"${session}" can't be stopped from BayChat (${result.reason ?? "its terminal can't be reached"}). Stop it in its own terminal.`;
|
|
40
|
+
}
|
|
41
|
+
}
|
package/dist/relay/updates.js
CHANGED
|
@@ -22,11 +22,13 @@ const project_notes_1 = require("./project-notes");
|
|
|
22
22
|
* app offers it, and BayChat starts one itself, only while every polling relay says so.
|
|
23
23
|
* - `notice`: it shows sessions BayChat's 90% memory notice (context-notice.ts, protocol 1.17),
|
|
24
24
|
* so it may be handed it — and have it marked sent.
|
|
25
|
+
* - `interrupt`: its `/stop` reaches a session in a tmux terminal too (terminal-stop.ts: owner
|
|
26
|
+
* only, one Escape) — the app offers Stop only while every polling relay says so.
|
|
25
27
|
*
|
|
26
28
|
* ONE list for every door: this long-poll's query, the WebSocket `hello` (ws.ts splits this same
|
|
27
29
|
* string) and so the device feed's feature record.
|
|
28
30
|
*/
|
|
29
|
-
exports.RELAY_FEATURES = "brief,compact,freshstart,notes,notice";
|
|
31
|
+
exports.RELAY_FEATURES = "brief,compact,freshstart,notes,notice,interrupt";
|
|
30
32
|
/** Server clamps `wait` into [1s, 30s]; 25s stays under the usual 60s proxy idle timeout. */
|
|
31
33
|
const WAIT_SECONDS = 25;
|
|
32
34
|
/** Backoff bounds for transient failures (5xx, 429, network). */
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// `npx baychat` and `baychat setup` — ONE COMMAND (Karmen + Lorenzo, 31 Oct 2026; plan in BAYCHAT, vps-reviewed).
|
|
3
|
+
//
|
|
4
|
+
// Bare `baychat`: the first time (no "set up" mark yet), sign in once and set up every agent app found (setup-plan.ts),
|
|
5
|
+
// print one checklist, then start a session — only inside a project (vps: never in the home folder, where a session
|
|
6
|
+
// could read and write everything). Once set up, bare `baychat` goes straight to the start, as before, without asking
|
|
7
|
+
// every app again. `baychat setup` always looks again (after installing a new app).
|
|
8
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
9
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.runSetup = runSetup;
|
|
13
|
+
exports.runBare = runBare;
|
|
14
|
+
exports.runSetupCommand = runSetupCommand;
|
|
15
|
+
exports.setupMarkPath = setupMarkPath;
|
|
16
|
+
exports.realSetupDeps = realSetupDeps;
|
|
17
|
+
const fs_1 = __importDefault(require("fs"));
|
|
18
|
+
const os_1 = __importDefault(require("os"));
|
|
19
|
+
const path_1 = __importDefault(require("path"));
|
|
20
|
+
const config_1 = require("./config");
|
|
21
|
+
const commands_1 = require("./commands");
|
|
22
|
+
const connect_1 = require("./connect");
|
|
23
|
+
const connect_claude_1 = require("./connect-claude");
|
|
24
|
+
const doctor_1 = require("./doctor");
|
|
25
|
+
const doctor_command_1 = require("./doctor-command");
|
|
26
|
+
const machine_name_1 = require("./machine-name");
|
|
27
|
+
const multiplexer_launch_1 = require("./multiplexer-launch");
|
|
28
|
+
const setup_facts_1 = require("./setup-facts");
|
|
29
|
+
const start_command_1 = require("./start-command");
|
|
30
|
+
const setup_plan_1 = require("./setup-plan");
|
|
31
|
+
/** Look, set up what is missing, and say what is ready. Returns 2 when the login was not approved. */
|
|
32
|
+
async function runSetup(deps) {
|
|
33
|
+
const facts = await deps.facts();
|
|
34
|
+
const steps = (0, setup_plan_1.planSetup)(facts);
|
|
35
|
+
const done = new Set();
|
|
36
|
+
const failed = new Set();
|
|
37
|
+
for (const step of steps) {
|
|
38
|
+
if (step.kind === "login") {
|
|
39
|
+
deps.log("Sign this computer in to BayChat (once): scan the code with the BayChat app.");
|
|
40
|
+
if (!(await deps.login()))
|
|
41
|
+
return 2;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
deps.log(`Setting up ${step.client}…`);
|
|
45
|
+
if ((await deps.connect(step.client)) === 0)
|
|
46
|
+
done.add(step.client);
|
|
47
|
+
else
|
|
48
|
+
failed.add(step.client);
|
|
49
|
+
}
|
|
50
|
+
deps.log("");
|
|
51
|
+
for (const line of (0, setup_plan_1.setupChecklist)(facts, { done, failed }))
|
|
52
|
+
deps.log(` ${line}`);
|
|
53
|
+
deps.markSetUp();
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Bare `baychat`: set up the first time, then start — in a project only. Once set up, a project starts straight away;
|
|
58
|
+
* any other folder (the home folder above all) asks first, default no (vps, 31 Oct: nobody gives an agent all of home
|
|
59
|
+
* by accident).
|
|
60
|
+
*/
|
|
61
|
+
async function runBare(deps) {
|
|
62
|
+
if (deps.wasSetUp()) {
|
|
63
|
+
if (deps.inProject())
|
|
64
|
+
return deps.start();
|
|
65
|
+
if (await deps.confirm("This isn't a project folder. Start a session here anyway? (y/N) "))
|
|
66
|
+
return deps.start();
|
|
67
|
+
deps.log(" Not started. cd into a project and run npx baychat again (or start a session from the BayChat app).");
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
const code = await runSetup(deps);
|
|
71
|
+
if (code !== 0)
|
|
72
|
+
return code;
|
|
73
|
+
return startOrPoint(deps);
|
|
74
|
+
}
|
|
75
|
+
/** `baychat setup`: always look again; start only when asked to and in a project. */
|
|
76
|
+
async function runSetupCommand(deps) {
|
|
77
|
+
const code = await runSetup(deps);
|
|
78
|
+
if (code !== 0)
|
|
79
|
+
return code;
|
|
80
|
+
deps.log("");
|
|
81
|
+
deps.log(" Next: run npx baychat in a project folder to start a session (or start one from the BayChat app).");
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
async function startOrPoint(deps) {
|
|
85
|
+
deps.log("");
|
|
86
|
+
if (deps.inProject())
|
|
87
|
+
return deps.start();
|
|
88
|
+
deps.log(" Next: cd into a project and run npx baychat again (or start a session from the BayChat app).");
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
/** Where the "set up" mark lives: beside the credentials, removed with them. */
|
|
92
|
+
function setupMarkPath() {
|
|
93
|
+
return path_1.default.join((0, config_1.configDir)(), "setup-done.json");
|
|
94
|
+
}
|
|
95
|
+
/** The real dependencies. `wsl` only for tests: a test run inside WSL still exercises the Claude Desktop path. */
|
|
96
|
+
function realSetupDeps(over = {}) {
|
|
97
|
+
const exists = (p) => fs_1.default.existsSync(p);
|
|
98
|
+
return {
|
|
99
|
+
facts: async () => {
|
|
100
|
+
const env = await (0, doctor_command_1.currentDoctorEnv)();
|
|
101
|
+
return (0, setup_facts_1.setupFactsFrom)(env, (0, doctor_1.buildReport)(env), { wsl: over.wsl ?? (0, machine_name_1.isWsl)(), exists });
|
|
102
|
+
},
|
|
103
|
+
login: () => (0, commands_1.cmdLogin)({ registerClaude: false, hint: false }),
|
|
104
|
+
connect: (client) => (client === "claude" ? (0, connect_claude_1.cmdConnectClaude)({}) : (0, connect_1.cmdConnect)(client, {})),
|
|
105
|
+
start: () => (0, start_command_1.cmdStart)([]),
|
|
106
|
+
inProject: () => (0, setup_plan_1.isProjectDir)(process.cwd(), exists, os_1.default.homedir()),
|
|
107
|
+
confirm: async (question) => {
|
|
108
|
+
if (!process.stdin.isTTY)
|
|
109
|
+
return false;
|
|
110
|
+
return /^y(es)?$/i.test((await (0, multiplexer_launch_1.askLine)(question)).trim());
|
|
111
|
+
},
|
|
112
|
+
// Signed out since (or the credentials were removed): look again, which signs in.
|
|
113
|
+
wasSetUp: () => exists(setupMarkPath()) && (0, config_1.loadDeviceCredentials)() !== null,
|
|
114
|
+
markSetUp: () => {
|
|
115
|
+
try {
|
|
116
|
+
fs_1.default.mkdirSync(path_1.default.dirname(setupMarkPath()), { recursive: true });
|
|
117
|
+
fs_1.default.writeFileSync(setupMarkPath(), JSON.stringify({ at: new Date().toISOString() }) + "\n");
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
// Only costs a second look next time.
|
|
121
|
+
console.warn(`Could not record that setup is done: ${err instanceof Error ? err.message : String(err)}`);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
log: (line) => console.log(line),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// The facts `planSetup` decides from, read off this computer through what `baychat doctor` already knows: its report
|
|
3
|
+
// says whether BayChat is in each app (it asks `claude mcp list` / `codex mcp list` themselves, and reads every file
|
|
4
|
+
// Claude Desktop or Cursor may open), and its binary resolver says what is installed. Nothing new is guessed here.
|
|
5
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
6
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
7
|
+
};
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.configuredFrom = configuredFrom;
|
|
10
|
+
exports.installedFrom = installedFrom;
|
|
11
|
+
exports.setupFactsFrom = setupFactsFrom;
|
|
12
|
+
const path_1 = __importDefault(require("path"));
|
|
13
|
+
const client_paths_1 = require("./client-paths");
|
|
14
|
+
const setup_plan_1 = require("./setup-plan");
|
|
15
|
+
/** Is BayChat in this app already? Doctor's MCP check passes for it (an unknown answer counts as no: connect again). */
|
|
16
|
+
function configuredFrom(report) {
|
|
17
|
+
const out = {};
|
|
18
|
+
for (const client of setup_plan_1.SETUP_CLIENTS) {
|
|
19
|
+
const runtime = report.runtimes.find((r) => r.runtime === client);
|
|
20
|
+
out[client] = Boolean(runtime?.checks.some((c) => c.name === "mcp" && c.status === "pass"));
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
/** Is the app on this computer? A CLI is a resolvable binary; a GUI app is the folder its config lives in. */
|
|
25
|
+
function installedFrom(env, exists) {
|
|
26
|
+
const pathEnv = env.pathEnv ?? { home: env.home, platform: env.platform };
|
|
27
|
+
return {
|
|
28
|
+
claude: env.resolveBinary("claude").ok,
|
|
29
|
+
desktop: (0, client_paths_1.configPathsFor)("desktop", pathEnv).some((file) => exists((0, client_paths_1.configDirOf)(file))),
|
|
30
|
+
codex: env.resolveBinary("codex").ok || exists(path_1.default.join(pathEnv.codexHome ?? path_1.default.join(env.home, ".codex"))),
|
|
31
|
+
cursor: exists(path_1.default.join(env.home, ".cursor")) || env.resolveBinary("cursor-agent").ok,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** Every fact `planSetup` needs. */
|
|
35
|
+
function setupFactsFrom(env, report, host) {
|
|
36
|
+
const expires = env.device ? Date.parse(env.device.expiresAt) : NaN;
|
|
37
|
+
return {
|
|
38
|
+
signedIn: env.device !== null && Number.isFinite(expires) && expires > env.now,
|
|
39
|
+
installed: installedFrom(env, host.exists),
|
|
40
|
+
configured: configuredFrom(report),
|
|
41
|
+
wsl: host.wsl,
|
|
42
|
+
hermesFound: env.resolveBinary("hermes").ok || host.exists(path_1.default.join(env.home, ".hermes")),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// What `npx baychat` (and `baychat setup`) should DO, decided before anything is done.
|
|
3
|
+
//
|
|
4
|
+
// ONE COMMAND (Karmen + Lorenzo, 31 Oct 2026): people did not know which of `login`, `connect desktop`,
|
|
5
|
+
// `connect codex`… was theirs. Now the one line signs this computer in ONCE (the phone QR) and sets up every agent
|
|
6
|
+
// app it finds that is not set up yet — through the very same `connect` paths, so nothing new is written anywhere.
|
|
7
|
+
// Hermes is named, not set up: it gets an agent of its own, chosen on the phone (a second QR), which is not "one".
|
|
8
|
+
//
|
|
9
|
+
// Facts in, steps out — like `connect-plan.ts` — so the order is testable without a phone, a network or the apps.
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.SETUP_CLIENTS = void 0;
|
|
12
|
+
exports.planSetup = planSetup;
|
|
13
|
+
exports.setupChecklist = setupChecklist;
|
|
14
|
+
exports.isProjectDir = isProjectDir;
|
|
15
|
+
/** The apps one command sets up, in the order it does them. */
|
|
16
|
+
exports.SETUP_CLIENTS = ["claude", "desktop", "codex", "cursor"];
|
|
17
|
+
const LABELS = {
|
|
18
|
+
claude: "Claude Code",
|
|
19
|
+
desktop: "Claude Desktop",
|
|
20
|
+
codex: "Codex",
|
|
21
|
+
cursor: "Cursor",
|
|
22
|
+
};
|
|
23
|
+
/** The steps: the one login when there is none, then every app found and not yet set up. */
|
|
24
|
+
function planSetup(facts) {
|
|
25
|
+
const steps = facts.signedIn ? [] : [{ kind: "login" }];
|
|
26
|
+
for (const client of exports.SETUP_CLIENTS) {
|
|
27
|
+
if (client === "desktop" && facts.wsl)
|
|
28
|
+
continue;
|
|
29
|
+
if (facts.installed[client] && !facts.configured[client])
|
|
30
|
+
steps.push({ kind: "connect", client });
|
|
31
|
+
}
|
|
32
|
+
return steps;
|
|
33
|
+
}
|
|
34
|
+
/** The closing list, one line per app: ready, set up now, not here, or not finished (with what to run). */
|
|
35
|
+
function setupChecklist(facts, outcome) {
|
|
36
|
+
const lines = exports.SETUP_CLIENTS.map((client) => {
|
|
37
|
+
const label = LABELS[client];
|
|
38
|
+
if (client === "desktop" && facts.wsl)
|
|
39
|
+
return `– ${label} (it runs on Windows: run npx baychat once in Windows PowerShell)`;
|
|
40
|
+
if (outcome.failed.has(client))
|
|
41
|
+
return `✗ ${label} (did not finish; run npx baychat connect ${client})`;
|
|
42
|
+
if (outcome.done.has(client))
|
|
43
|
+
return client === "desktop" ? `✓ ${label} (just set up; restart it once)` : `✓ ${label} (just set up)`;
|
|
44
|
+
if (facts.configured[client])
|
|
45
|
+
return `✓ ${label}`;
|
|
46
|
+
return `– ${label} (not on this computer)`;
|
|
47
|
+
});
|
|
48
|
+
if (facts.hermesFound)
|
|
49
|
+
lines.push("• Hermes found: npx baychat connect hermes (it gets its own agent, chosen on your phone)");
|
|
50
|
+
return lines;
|
|
51
|
+
}
|
|
52
|
+
/** Files that make a folder a project worth starting an agent in. */
|
|
53
|
+
const PROJECT_MARKERS = [
|
|
54
|
+
"package.json",
|
|
55
|
+
"pyproject.toml",
|
|
56
|
+
"requirements.txt",
|
|
57
|
+
"Cargo.toml",
|
|
58
|
+
"go.mod",
|
|
59
|
+
"Gemfile",
|
|
60
|
+
"pom.xml",
|
|
61
|
+
"build.gradle",
|
|
62
|
+
"composer.json",
|
|
63
|
+
"deno.json",
|
|
64
|
+
"Package.swift",
|
|
65
|
+
"mix.exs",
|
|
66
|
+
];
|
|
67
|
+
/**
|
|
68
|
+
* May a session start here? vps, 31 Oct: a first-time user often runs `npx baychat` in the home folder, and a session
|
|
69
|
+
* started there can read and write all of it. So only in a project: a folder with a project file, or inside a git
|
|
70
|
+
* repository (found by walking up, stopping BEFORE the home folder). The home folder itself never counts.
|
|
71
|
+
*/
|
|
72
|
+
function isProjectDir(cwd, exists, home) {
|
|
73
|
+
const sep = cwd.includes("\\") && !cwd.includes("/") ? "\\" : "/";
|
|
74
|
+
const trim = (p) => (p.length > 1 && (p.endsWith("/") || p.endsWith("\\")) ? p.slice(0, -1) : p);
|
|
75
|
+
const here = trim(cwd);
|
|
76
|
+
const stop = trim(home);
|
|
77
|
+
if (here === stop)
|
|
78
|
+
return false;
|
|
79
|
+
const join = (dir, name) => `${dir}${sep}${name}`;
|
|
80
|
+
if (PROJECT_MARKERS.some((marker) => exists(join(here, marker))))
|
|
81
|
+
return true;
|
|
82
|
+
for (let dir = here; dir !== stop;) {
|
|
83
|
+
if (exists(join(dir, ".git")))
|
|
84
|
+
return true;
|
|
85
|
+
const cut = dir.lastIndexOf(sep);
|
|
86
|
+
if (cut <= 0 || dir.slice(0, cut) === dir)
|
|
87
|
+
break;
|
|
88
|
+
const parent = dir.slice(0, cut);
|
|
89
|
+
if (parent.length < stop.length)
|
|
90
|
+
break;
|
|
91
|
+
dir = parent;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
package/dist/start-command.js
CHANGED
|
@@ -95,7 +95,7 @@ function startRefusal(runtime) {
|
|
|
95
95
|
}
|
|
96
96
|
async function cmdStart(args) {
|
|
97
97
|
if (!(0, config_1.loadDeviceCredentials)()) {
|
|
98
|
-
console.error("This machine is not signed in yet. Run `npx baychat
|
|
98
|
+
console.error("This machine is not signed in yet. Run `npx baychat` first: it signs in and sets up your agent apps.");
|
|
99
99
|
return 1;
|
|
100
100
|
}
|
|
101
101
|
// THE UPDATE NOBODY HAS TO REMEMBER.
|
package/dist/tool-defs.js
CHANGED
|
@@ -224,8 +224,8 @@ exports.CONVERSATION_TOOL_DEFS = [
|
|
|
224
224
|
"acknowledge (react instead). Be concise; @mention a person only when you need them (it " +
|
|
225
225
|
"reaches them even if muted). Answering a specific message: pass replyToMessageId, which " +
|
|
226
226
|
"quotes it and marks it ✅ for you (a progress update: done=false). To relay a " +
|
|
227
|
-
"calendar event or an email pass `card`, with `content` as a one-line
|
|
228
|
-
"Decisions go through request_approval, not here.",
|
|
227
|
+
"calendar event or an email, or to show a chart or a page, pass `card`, with `content` as a one-line " +
|
|
228
|
+
"plain-text fallback. Decisions go through request_approval, not here.",
|
|
229
229
|
inputSchema: {
|
|
230
230
|
conversationId: zod_1.z.string().describe(CONVERSATION_ID),
|
|
231
231
|
content: zod_1.z.string().describe("Message text."),
|
|
@@ -255,13 +255,17 @@ exports.CONVERSATION_TOOL_DEFS = [
|
|
|
255
255
|
.describe("Ids of files you already uploaded, attached in order."),
|
|
256
256
|
card: zod_1.z
|
|
257
257
|
.object({
|
|
258
|
-
type: zod_1.z.enum(["card.calendar", "card.email"]),
|
|
258
|
+
type: zod_1.z.enum(["card.calendar", "card.email", "card.chart", "card.page"]),
|
|
259
259
|
payload: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()),
|
|
260
260
|
})
|
|
261
261
|
.optional()
|
|
262
262
|
.describe("Render as a card. card.calendar: title, start, end (ISO 8601), location, attendees " +
|
|
263
263
|
"[{name,address}], notes. card.email: subject, from {name,address} (display only), " +
|
|
264
|
-
"snippet, body, timestamp.
|
|
264
|
+
"snippet, body, timestamp. card.chart: title, kind bar|line, labels[2-31], " +
|
|
265
|
+
"series[1-3]{name?, values[] (one per label), highlight?[] (indexes)}, unit?, note?, " +
|
|
266
|
+
"key? (same key again = its next version, v2). Data only: BayChat draws it. " +
|
|
267
|
+
"card.page: title, body (markdown, ≤20,000 chars; no HTML, images show as links), key?. " +
|
|
268
|
+
"A card OR attachments, never both."),
|
|
265
269
|
},
|
|
266
270
|
_meta: exports.ALWAYS_LOAD_META,
|
|
267
271
|
},
|