agentus 0.8.1 → 0.10.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/package.json +4 -2
- package/packages/server/src/acp/session-manager.ts +33 -9
- package/packages/server/src/settings.ts +74 -6
- package/packages/web/dist/assets/{index-CrlN0hrH.js → index-B2YR8mN4.js} +60 -57
- package/packages/web/dist/assets/index-CDd0dpAg.css +32 -0
- package/packages/web/dist/assets/ort-wasm-simd-threaded-DcHrbrbl.wasm +0 -0
- package/packages/web/dist/assets/ort.wasm.bundle.min-kEi4lvn9.js +7 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/vad/silero_vad.onnx +0 -0
- package/packages/web/dist/assets/index-D2rwXFNG.css +0 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Multi-session web cockpit for ACP-speaking coding agents (Qoder CLI / Hermes). UI has no intelligence — the agent lives in spawned CLI subprocesses.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -83,7 +83,9 @@
|
|
|
83
83
|
"resume-tail": "tsx scripts/qa/resume-tail.mts",
|
|
84
84
|
"render-lag-sweep": "node scripts/qa/render-lag-sweep.mjs",
|
|
85
85
|
"replay-window": "node scripts/qa/replay-window.mjs",
|
|
86
|
-
"think-cadence": "node scripts/qa/think-cadence.mjs"
|
|
86
|
+
"think-cadence": "node scripts/qa/think-cadence.mjs",
|
|
87
|
+
"vad": "tsx scripts/qa/vad.mts",
|
|
88
|
+
"vad-silero": "tsx scripts/qa/vad-silero.mts"
|
|
87
89
|
},
|
|
88
90
|
"engines": {
|
|
89
91
|
"node": ">=22.5"
|
|
@@ -49,7 +49,7 @@ import type {
|
|
|
49
49
|
} from "../../../shared/src/index.js";
|
|
50
50
|
import { acceptsToolWrite, acceptsWriteFrom, clampExplanation, clampTitle, demoteInProgress, foldPlanUpdate, hasUnfinished, normalizeItems, v2PlanEnabled } from "../plan/plan.js";
|
|
51
51
|
import { PLAN_GUIDANCE_ACTIVE, PLAN_GUIDANCE_INTRO, PLAN_GUIDANCE_NATIVE } from "../../../shared/src/index.js";
|
|
52
|
-
import { PREFS_DEFAULT, agentSettings, type PlanHintSettings } from "../settings.js";
|
|
52
|
+
import { AGENT_DEFAULT, PREFS_DEFAULT, agentSettings, type PlanHintSettings } from "../settings.js";
|
|
53
53
|
|
|
54
54
|
// The cockpit CLI an agent is handed (issue #15). Resolved from THIS module's location, so it points
|
|
55
55
|
// at the same tree the server runs from — a checkout and an installed package alike (`bin/` is in the
|
|
@@ -1334,12 +1334,18 @@ export class SessionManager {
|
|
|
1334
1334
|
* these three. Rendering it into a prompt, an `echo`, or a `ps` line is the one thing the CLI's own
|
|
1335
1335
|
* `capabilities` forbids, and the token is worth nothing once this process is gone. */
|
|
1336
1336
|
#cockpitEnv(env: NodeJS.ProcessEnv, sessionId: string, token: string): NodeJS.ProcessEnv {
|
|
1337
|
+
// 召回条目的两个上限跟着设置一起下发给 agent(issue #37 的容量):provider 读这两个环境变量,
|
|
1338
|
+
// 与 server 的 `statusItemsOf` 同源。只在这一处传,是因为「一轮发多少字」是**宿主**的存储决定,
|
|
1339
|
+
// 不该要求用户去改 agent 自己的 config.json,也不该让两边各有一套默认值。
|
|
1340
|
+
const agent = agentSettings();
|
|
1337
1341
|
return {
|
|
1338
1342
|
...env,
|
|
1339
1343
|
AGENTUS_COCKPIT_ENDPOINT: `http://127.0.0.1:${process.env.AGENTUS_PORT ?? 8788}`,
|
|
1340
1344
|
AGENTUS_COCKPIT_TOKEN: token,
|
|
1341
1345
|
AGENTUS_COCKPIT_SESSION: sessionId,
|
|
1342
1346
|
AGENTUS_COCKPIT_BIN: COCKPIT_BIN,
|
|
1347
|
+
HINDSIGHT_RECALL_ITEM_CHARS: String(agent.recallItemChars || AGENT_DEFAULT.recallItemChars),
|
|
1348
|
+
HINDSIGHT_RECALL_TOTAL_CHARS: String(agent.recallTotalChars || AGENT_DEFAULT.recallTotalChars),
|
|
1343
1349
|
};
|
|
1344
1350
|
}
|
|
1345
1351
|
|
|
@@ -2765,21 +2771,38 @@ export function visibleBackgroundTasks(tasks: BackgroundTask[]): BackgroundTask[
|
|
|
2765
2771
|
*
|
|
2766
2772
|
* 形状(fork `acp_adapter/events.py:make_status_cb`):与状态句同一条 thought 帧,
|
|
2767
2773
|
* `items: [{text, tags?, type?}]`、`truncated` 说明 agent 侧已经截断过。**在这里限一次量**
|
|
2768
|
-
* 是因为这串东西来自 agent 进程、还要落库:provider
|
|
2769
|
-
*
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2774
|
+
* 是因为这串东西来自 agent 进程、还要落库:provider 也会截,但那是 agent 的承诺,不是我们的保证,
|
|
2775
|
+
* 上限与前端展开的容量对齐,绝不把一帧 200KB 的 memory 写进 SQLite。
|
|
2776
|
+
*
|
|
2777
|
+
* 闸门是**总字符数**,不是条数(操作者 2026-10-21:「条数不限制,按照 12K 总数来」):按条数截会
|
|
2778
|
+
* 在预算还没用完时就把整条短记忆丢掉,而真正花 wire 和 SQLite 的是字节。两个数都可配
|
|
2779
|
+
* (`agent.recallItemChars` / `agent.recallTotalChars`),并且以环境变量交给 agent 侧的 provider,
|
|
2780
|
+
* 让两边同源 —— 否则界面说 12K、agent 按 6K 发,用户永远对不上。 */
|
|
2781
|
+
export const NOTE_ITEM_CHARS = AGENT_DEFAULT.recallItemChars;
|
|
2782
|
+
export const NOTE_TOTAL_CHARS = AGENT_DEFAULT.recallTotalChars;
|
|
2773
2783
|
|
|
2774
2784
|
export function statusItemsOf(u: Record<string, unknown>): { items: StatusNoteItem[]; truncated: boolean } | undefined {
|
|
2775
2785
|
const raw = hermesPayload(u).items;
|
|
2776
2786
|
if (!Array.isArray(raw)) return undefined;
|
|
2787
|
+
const caps = agentSettings();
|
|
2788
|
+
const itemCap = caps.recallItemChars > 0 ? caps.recallItemChars : NOTE_ITEM_CHARS;
|
|
2789
|
+
const totalCap = caps.recallTotalChars > 0 ? caps.recallTotalChars : NOTE_TOTAL_CHARS;
|
|
2777
2790
|
const items: StatusNoteItem[] = [];
|
|
2791
|
+
let used = 0;
|
|
2792
|
+
let cut = false;
|
|
2778
2793
|
for (const entry of raw) {
|
|
2779
|
-
if (items.length >= NOTE_ITEMS_MAX) break;
|
|
2780
2794
|
const text = typeof entry === "object" && entry ? (entry as { text?: unknown }).text : undefined;
|
|
2781
2795
|
if (typeof text !== "string" || !text.trim()) continue;
|
|
2782
|
-
|
|
2796
|
+
let body = text.trim();
|
|
2797
|
+
if (body.length > itemCap) {
|
|
2798
|
+
body = body.slice(0, itemCap);
|
|
2799
|
+
cut = true;
|
|
2800
|
+
}
|
|
2801
|
+
if (used + body.length > totalCap) {
|
|
2802
|
+
cut = true;
|
|
2803
|
+
break;
|
|
2804
|
+
}
|
|
2805
|
+
const item: StatusNoteItem = { text: body };
|
|
2783
2806
|
const tags = (entry as { tags?: unknown }).tags;
|
|
2784
2807
|
if (Array.isArray(tags)) {
|
|
2785
2808
|
const clean = tags.filter((t): t is string => typeof t === "string" && !!t).slice(0, 8);
|
|
@@ -2788,9 +2811,10 @@ export function statusItemsOf(u: Record<string, unknown>): { items: StatusNoteIt
|
|
|
2788
2811
|
const kind = (entry as { type?: unknown }).type;
|
|
2789
2812
|
if (typeof kind === "string" && kind) item.type = kind;
|
|
2790
2813
|
items.push(item);
|
|
2814
|
+
used += body.length;
|
|
2791
2815
|
}
|
|
2792
2816
|
if (!items.length) return undefined;
|
|
2793
|
-
return { items, truncated: hermesPayload(u).truncated === true || raw.length > items.length };
|
|
2817
|
+
return { items, truncated: cut || hermesPayload(u).truncated === true || raw.length > items.length };
|
|
2794
2818
|
}
|
|
2795
2819
|
|
|
2796
2820
|
function statusKindOf(u: Record<string, unknown>): string | undefined {
|
|
@@ -62,7 +62,7 @@ export const THEME_DEFAULT: ThemeSettings = { mode: "system", accent: "" };
|
|
|
62
62
|
* what a phone's own loudspeaker leaks back into its microphone is nothing like a headset
|
|
63
63
|
* in a quiet room — so the defaults are a starting point, not a truth. The call page owns
|
|
64
64
|
* the panel; the settings page reads the same numbers. */
|
|
65
|
-
export type InterruptMode = "voice" | "button";
|
|
65
|
+
export type InterruptMode = "voice" | "button" | "keyword";
|
|
66
66
|
|
|
67
67
|
export interface CallSettings {
|
|
68
68
|
/** WHO gets to interrupt the agent, and the operator picks:
|
|
@@ -85,6 +85,10 @@ export interface CallSettings {
|
|
|
85
85
|
* as a turn (1 = no limit). A tap on the orb always sends what is there — that gesture
|
|
86
86
|
* is an explicit instruction, not a guess about where a sentence ended. */
|
|
87
87
|
minChars: number;
|
|
88
|
+
/** 关键词打断 — the phrase that must be HEARD before the floor changes hands (opt-in; empty means
|
|
89
|
+
* the mode is inert). No shipped default on purpose: nobody should be able to interrupt this
|
|
90
|
+
* agent by guessing a word we chose for them. */
|
|
91
|
+
wakePhrase: string;
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
/** How the operator wants to TALK to the agent. These were browser-local (localStorage) and
|
|
@@ -150,14 +154,32 @@ export const PREFS_DEFAULT: PrefsSettings = {
|
|
|
150
154
|
planHint: { on: true, everyTurn: true, instruction: "" },
|
|
151
155
|
};
|
|
152
156
|
|
|
157
|
+
/** The longest wake phrase, in CODE POINTS (an emoji is one, not two). One number, one place — the
|
|
158
|
+
* web side has its own copy (`WAKE_MAX` in callSettings.ts) and the two must keep saying 24. */
|
|
159
|
+
const WAKE_MAX = 24;
|
|
160
|
+
|
|
161
|
+
/** Drop half-pairs left by a client that counted UTF-16 units and cut in the middle of an emoji.
|
|
162
|
+
* `for...of` walks code points, so a lone surrogate arrives as a one-character string and is easy
|
|
163
|
+
* to spot; storing it would put U+FFFD in the database. */
|
|
164
|
+
function stripLone(s: string): string {
|
|
165
|
+
let out = "";
|
|
166
|
+
for (const ch of s) {
|
|
167
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
168
|
+
if (cp >= 0xD800 && cp <= 0xDFFF) continue; // a surrogate that did not pair with its other half
|
|
169
|
+
out += ch;
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
153
174
|
export const CALL_DEFAULT: CallSettings = {
|
|
154
175
|
/* 说完停顿 1.5s (was 1.2): the operator reads a pause as "it stopped listening" before 1.2 s,
|
|
155
176
|
and a long sentence with a breath in the middle got cut. Both ends of the range still let
|
|
156
177
|
the room decide — this is only where a fresh install starts. */
|
|
157
178
|
interruptMode: "voice", bargeSensitivity: 60, bargeMs: 300, silenceMs: 1500, minChars: 3,
|
|
179
|
+
wakePhrase: "",
|
|
158
180
|
};
|
|
159
181
|
|
|
160
|
-
export const INTERRUPT_MODES: InterruptMode[] = ["voice", "button"];
|
|
182
|
+
export const INTERRUPT_MODES: InterruptMode[] = ["voice", "button", "keyword"];
|
|
161
183
|
|
|
162
184
|
/** How the cockpit treats AGENT PROCESSES — as opposed to what a conversation contains. AionUi
|
|
163
185
|
* ships the same knob in 设置 → 系统 as 「Agent 空闲超时(分钟)」 (default 5, 1–60, step 5) with the
|
|
@@ -194,15 +216,26 @@ export interface AgentSettings {
|
|
|
194
216
|
* agent knows it is there, the operator does not need to. Turned ON, every running process
|
|
195
217
|
* shows, which is what the fork's original "a snapshot is the only witness" design wanted. */
|
|
196
218
|
showSilentBackground: boolean;
|
|
219
|
+
/** 记忆召回条目:单条最多多少字符(issue #37 的容量)。These items are a per-turn status frame
|
|
220
|
+
* that also lands in SQLite, so the number is a cost knob, not a formatting preference: it is
|
|
221
|
+
* handed to the agent as `HINDSIGHT_RECALL_ITEM_CHARS`, and the server applies the same limit
|
|
222
|
+
* when it re-caps what arrives. 400 = the operator's 2026-10-21 decision (was 200). */
|
|
223
|
+
recallItemChars: number;
|
|
224
|
+
/** 记忆召回条目:一轮最多多少字符。**这是唯一的条数闸门** —— 不按条数截,短记忆本来就该多
|
|
225
|
+
* 进来几条,按条数截会连预算都没用完就把整条记忆丢掉。Handed to the agent as
|
|
226
|
+
* `HINDSIGHT_RECALL_TOTAL_CHARS`(12K = 操作者 2026-10-21 的决定,原来是 6K)。 */
|
|
227
|
+
recallTotalChars: number;
|
|
197
228
|
}
|
|
198
229
|
|
|
199
230
|
export const AGENT_DEFAULT: AgentSettings = {
|
|
200
231
|
idleKillMin: 5, warmSlots: 5, warmTtlHours: 24, cockpitSend: false, completionWake: true,
|
|
201
|
-
showSilentBackground: false,
|
|
232
|
+
showSilentBackground: false, recallItemChars: 400, recallTotalChars: 12000,
|
|
202
233
|
};
|
|
203
234
|
|
|
204
235
|
/** The numeric half of `AgentSettings` — the keys the range-checked helpers may write. */
|
|
205
|
-
type AgentNumberKey =
|
|
236
|
+
type AgentNumberKey =
|
|
237
|
+
| "idleKillMin" | "warmSlots" | "warmTtlHours" | "recallItemChars" | "recallTotalChars";
|
|
238
|
+
|
|
206
239
|
|
|
207
240
|
/** What the settings page may offer: the ends of the number field. Server-side enforcement uses
|
|
208
241
|
* the same pair, so a hand-written request cannot store a nonsense value. */
|
|
@@ -212,6 +245,11 @@ export const AGENT_IDLE_RANGE: [number, number] = [0, 60];
|
|
|
212
245
|
export const AGENT_WARM_RANGE: [number, number] = [0, 20];
|
|
213
246
|
/** 0 = 不过期; a week is the longest span worth offering on the page. */
|
|
214
247
|
export const AGENT_WARM_TTL_RANGE: [number, number] = [0, 168];
|
|
248
|
+
/** 召回条目单条字符数:下限是为了别把一条记忆截成半句话,上限是「一整帧别超过一次 prompt 的量」。
|
|
249
|
+
* 与 provider 的默认同值(400),页面改这里,spawn 时用环境变量覆盖 agent 侧同一对常量。 */
|
|
250
|
+
export const AGENT_RECALL_ITEM_RANGE: [number, number] = [80, 2000];
|
|
251
|
+
/** 一轮条目的总字符数。这就是条数闸门本身,所以下限不能太小(2000 ≈ 5 条长记忆)。 */
|
|
252
|
+
export const AGENT_RECALL_TOTAL_RANGE: [number, number] = [2000, 60000];
|
|
215
253
|
|
|
216
254
|
/** 观察智能体 (`工作播报`, issue #16): ONE long-lived ACP session per observed session, spawned by the
|
|
217
255
|
* cockpit, in its own scratch directory, read-only, out of the rail. It is the thing that turns
|
|
@@ -353,7 +391,11 @@ function callAtShippedDefaults(r: Record<string, unknown>): boolean {
|
|
|
353
391
|
// The mode counts too: a row that only says `interruptMode: "button"` IS a deliberate choice and
|
|
354
392
|
// must not be reset along with a section that happens to still carry the old numbers.
|
|
355
393
|
const modeShipped = r.interruptMode === undefined || r.interruptMode === CALL_DEFAULT.interruptMode;
|
|
356
|
-
|
|
394
|
+
// …and a stored wake phrase is a deliberate choice too: without this, setting one and switching
|
|
395
|
+
// back to 语音打断 would make the whole section look untouched and the phrase would be dropped.
|
|
396
|
+
const wakeShipped = r.wakePhrase === undefined || r.wakePhrase === CALL_DEFAULT.wakePhrase;
|
|
397
|
+
return sens && modeShipped && wakeShipped
|
|
398
|
+
&& Number(r.bargeMs) === 300 && Number(r.silenceMs) === 1200 && Number(r.minChars) === 1;
|
|
357
399
|
}
|
|
358
400
|
|
|
359
401
|
/** What each knob may be. The ranges are the panel's slider ends; the server is the one
|
|
@@ -487,6 +529,12 @@ function pickCall(raw: unknown): CallSettings {
|
|
|
487
529
|
if (callAtShippedDefaults(raw as Record<string, unknown>)) return out;
|
|
488
530
|
const mode = (raw as Record<string, unknown>).interruptMode;
|
|
489
531
|
if (INTERRUPT_MODES.includes(mode as InterruptMode)) out.interruptMode = mode as InterruptMode;
|
|
532
|
+
const wake = (raw as Record<string, unknown>).wakePhrase;
|
|
533
|
+
// NOT length-capped here on purpose: this is what somebody ELSE stored (curl, a script, a previous
|
|
534
|
+
// version). Cutting it on the way out would then be written straight back by the `persist()` that
|
|
535
|
+
// runs at boot — silently destroying the operator's phrase with no log and nothing on screen.
|
|
536
|
+
// Length belongs to the write path (`saveCall`), which is where a human typed it.
|
|
537
|
+
if (typeof wake === "string") out.wakePhrase = wake.replace(/\s+/g, " ").trim();
|
|
490
538
|
for (const k of CALL_NUMERIC) {
|
|
491
539
|
const v = Number((raw as Record<string, unknown>)[k]);
|
|
492
540
|
const [lo, hi] = CALL_RANGE[k];
|
|
@@ -514,6 +562,8 @@ function pickAgent(raw: unknown): AgentSettings {
|
|
|
514
562
|
take("idleKillMin", AGENT_IDLE_RANGE);
|
|
515
563
|
take("warmSlots", AGENT_WARM_RANGE);
|
|
516
564
|
take("warmTtlHours", AGENT_WARM_TTL_RANGE);
|
|
565
|
+
take("recallItemChars", AGENT_RECALL_ITEM_RANGE);
|
|
566
|
+
take("recallTotalChars", AGENT_RECALL_TOTAL_RANGE);
|
|
517
567
|
if (typeof r.cockpitSend === "boolean") out.cockpitSend = r.cockpitSend;
|
|
518
568
|
if (typeof r.completionWake === "boolean") out.completionWake = r.completionWake;
|
|
519
569
|
if (typeof r.showSilentBackground === "boolean") out.showSilentBackground = r.showSilentBackground;
|
|
@@ -631,6 +681,8 @@ export function publicSettings(): Record<string, unknown> {
|
|
|
631
681
|
agentIdleRange: AGENT_IDLE_RANGE,
|
|
632
682
|
agentWarmRange: AGENT_WARM_RANGE,
|
|
633
683
|
agentWarmTtlRange: AGENT_WARM_TTL_RANGE,
|
|
684
|
+
agentRecallItemRange: AGENT_RECALL_ITEM_RANGE,
|
|
685
|
+
agentRecallTotalRange: AGENT_RECALL_TOTAL_RANGE,
|
|
634
686
|
observer: cache.observer,
|
|
635
687
|
observerDefaults: OBSERVER_DEFAULT,
|
|
636
688
|
observerPromptDefault: OBSERVER_PROMPT_DEFAULT,
|
|
@@ -714,10 +766,24 @@ export function saveCall(patch: Record<string, unknown>): Settings {
|
|
|
714
766
|
if ("interruptMode" in patch) {
|
|
715
767
|
const mode = patch.interruptMode;
|
|
716
768
|
if (!INTERRUPT_MODES.includes(mode as InterruptMode)) {
|
|
717
|
-
throw new Error(`invalid interruptMode: ${JSON.stringify(mode)} (expected voice|button)`);
|
|
769
|
+
throw new Error(`invalid interruptMode: ${JSON.stringify(mode)} (expected voice|button|keyword)`);
|
|
718
770
|
}
|
|
719
771
|
c.interruptMode = mode as InterruptMode;
|
|
720
772
|
}
|
|
773
|
+
if ("wakePhrase" in patch) {
|
|
774
|
+
if (typeof patch.wakePhrase !== "string") throw new Error("invalid wakePhrase: expected a string");
|
|
775
|
+
// Truncated, not rejected: this field arrives in the same body as the sliders, and refusing it
|
|
776
|
+
// would throw away the operator's OTHER changes with it (and the panel would then say "saved
|
|
777
|
+
// locally, not on the server" for a phrase nobody was trying to abuse). Same rule as pickCall.
|
|
778
|
+
const wake = stripLone(patch.wakePhrase.replace(/\s+/g, " ").trim()); // an old client can slice a surrogate in half
|
|
779
|
+
const kept = [...wake];
|
|
780
|
+
if (kept.length > WAKE_MAX) {
|
|
781
|
+
// Truncated rather than refused, but never silently: one line in the log is the difference
|
|
782
|
+
// between 「我设过这个词」 and 「它自己变短了」 when a script writes 40 characters.
|
|
783
|
+
console.warn(`[agentus] wakePhrase truncated: ${kept.length} → ${WAKE_MAX} characters`);
|
|
784
|
+
}
|
|
785
|
+
c.wakePhrase = kept.slice(0, WAKE_MAX).join("");
|
|
786
|
+
}
|
|
721
787
|
for (const key of CALL_NUMERIC) {
|
|
722
788
|
if (!(key in patch)) continue;
|
|
723
789
|
const raw = typeof patch[key] === "number" ? patch[key] as number : Number(patch[key]);
|
|
@@ -823,6 +889,8 @@ export function saveAgent(patch: Record<string, unknown>): Settings {
|
|
|
823
889
|
set("idleKillMin", AGENT_IDLE_RANGE, "minutes", "0 = off");
|
|
824
890
|
set("warmSlots", AGENT_WARM_RANGE, "slots", "0 = off");
|
|
825
891
|
set("warmTtlHours", AGENT_WARM_TTL_RANGE, "hours", "0 = never expires");
|
|
892
|
+
set("recallItemChars", AGENT_RECALL_ITEM_RANGE, "chars", "no 0 — a cap must stay a cap");
|
|
893
|
+
set("recallTotalChars", AGENT_RECALL_TOTAL_RANGE, "chars", "no 0 — a cap must stay a cap");
|
|
826
894
|
// A boolean next to three numbers: spelled out rather than forced through the numeric `set`.
|
|
827
895
|
if ("cockpitSend" in patch) {
|
|
828
896
|
if (typeof patch.cockpitSend !== "boolean") throw new Error("invalid cockpitSend: expected true or false");
|