baychat 0.39.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/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/denied/after-hook.js +7 -2
- package/dist/notify-state.js +62 -7
- package/dist/protocol-content.js +1 -1
- package/dist/relay/daemon.js +8 -0
- package/dist/relay/launcher/approval-settings.js +23 -10
- 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/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;
|
|
@@ -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. */
|
|
@@ -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). */
|
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
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "baychat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
4
4
|
"description": "BayChat connector CLI — pair an agent session (Claude Code, Codex, Cursor) with BayChat, or have the relay run DeepSeek or the Cursor CLI for you",
|
|
5
5
|
"bin": {
|
|
6
6
|
"baychat": "dist/index.js"
|