Compare commits

..
Author SHA1 Message Date
bincxz 680eb5eb0f fix(browser): stop idle native pane layout polling 2026-10-18 13:16:59 +08:00
陈大猫 df5c033dfa fix(ai): preserve Catty prompt cache and cache usage (#3714)
* fix(ai): preserve Catty prompt cache and provider usage

* fix(ai): gate explicit caching on the official Anthropic endpoint

* fix(ai): resolve provider defaults before enabling prompt caching
2026-10-18 12:20:18 +08:00
陈大猫 b5fee9632e feat(monitor): enrich and virtualize Vault monitoring views (#3713)
* feat(monitor): enrich and virtualize vault monitoring views

* fix(monitor): preserve outer gutter around sticky terminal actions

* fix(monitor): mask scrolled content behind action column corners

* feat(monitor): customize columns and clarify unavailable hosts

* fix(monitor): collect GPU data without delaying primary samples
2026-10-18 04:19:55 +08:00
陈大猫 fb69f4a48d fix(browser): integrate browser controls and show downloads immediately (#3712)
* fix(browser): unify embedded controls and show download progress immediately

* fix(browser): preserve input focus when background navigation finishes
2026-10-18 02:20:18 +08:00
50 changed files with 4483 additions and 413 deletions

No files matched your search

+8 -3
View File
@@ -24,7 +24,7 @@ export const enMonitorMessages = {
"monitor.stopAll": "Stop all",
"monitor.list": "List",
"monitor.charts": "Charts",
"monitor.search": "Search name, address or group",
"monitor.search": "Search hosts, groups, tags or notes",
"monitor.interval": "Update interval",
"monitor.range": "Trend range",
"monitor.seconds": "Every {count}s",
@@ -34,6 +34,13 @@ export const enMonitorMessages = {
"monitor.sort.name": "Name",
"monitor.selectedCount": "Selected hosts: {count}",
"monitor.noMatches": "No matching hosts",
"monitor.latency": "Latency",
"monitor.responseLatency": "Response latency (includes remote processing)",
"monitor.tags": "Tags",
"monitor.notes": "Notes",
"monitor.visibleColumns": "Visible columns",
"monitor.terminal": "Terminal",
"monitor.resizeColumn": "Resize {column} column",
"monitor.host": "Host",
"monitor.hosts": "Hosts",
"monitor.status": "Status",
@@ -53,8 +60,6 @@ export const enMonitorMessages = {
"monitor.memoryTrend": "Memory trend",
"monitor.downloadTrend": "Download trend",
"monitor.uploadTrend": "Upload trend",
"monitor.memoryOnlyHint":
"Readings stay in memory for up to 30 minutes and disappear when the app closes. Gaps mean no reading. Shared connections use the fastest active update interval.",
"monitor.name": "Panel name",
"monitor.description": "Description (optional)",
"monitor.chooseHosts": "Choose hosts",
+8 -3
View File
@@ -24,7 +24,7 @@ export const esMonitorMessages = {
"monitor.stopAll": "Detener todo",
"monitor.list": "Lista",
"monitor.charts": "Gráficos",
"monitor.search": "Buscar nombre, dirección o grupo",
"monitor.search": "Buscar hosts, grupos, etiquetas o notas",
"monitor.interval": "Intervalo de actualización",
"monitor.range": "Rango de tendencias",
"monitor.seconds": "Cada {count} s",
@@ -34,6 +34,13 @@ export const esMonitorMessages = {
"monitor.sort.name": "Nombre",
"monitor.selectedCount": "Equipos seleccionados: {count}",
"monitor.noMatches": "No hay equipos coincidentes",
"monitor.latency": "Latencia",
"monitor.responseLatency": "Latencia de respuesta (incluye procesamiento remoto)",
"monitor.tags": "Etiquetas",
"monitor.notes": "Notas",
"monitor.visibleColumns": "Columnas visibles",
"monitor.terminal": "Terminal",
"monitor.resizeColumn": "Ajustar la columna {column}",
"monitor.host": "Equipo",
"monitor.hosts": "Equipos",
"monitor.status": "Estado",
@@ -53,8 +60,6 @@ export const esMonitorMessages = {
"monitor.memoryTrend": "Tendencia de memoria",
"monitor.downloadTrend": "Tendencia de descarga",
"monitor.uploadTrend": "Tendencia de subida",
"monitor.memoryOnlyHint":
"Las lecturas se conservan en memoria hasta 30 minutos y desaparecen al cerrar. Los huecos indican datos ausentes. Las conexiones compartidas usan el intervalo activo más rápido.",
"monitor.name": "Nombre del panel",
"monitor.description": "Descripción (opcional)",
"monitor.chooseHosts": "Elegir equipos",
+8 -3
View File
@@ -23,7 +23,7 @@ export const ruMonitorMessages = {
"monitor.stopAll": "Остановить всё",
"monitor.list": "Список",
"monitor.charts": "Графики",
"monitor.search": "Имя, адрес или группа",
"monitor.search": "Поиск узлов, групп, тегов и заметок",
"monitor.interval": "Интервал обновления",
"monitor.range": "Период графика",
"monitor.seconds": "Каждые {count} с",
@@ -33,6 +33,13 @@ export const ruMonitorMessages = {
"monitor.sort.name": "Имя",
"monitor.selectedCount": "Выбрано серверов: {count}",
"monitor.noMatches": "Нет подходящих серверов",
"monitor.latency": "Задержка",
"monitor.responseLatency": "Задержка ответа (включая обработку на сервере)",
"monitor.tags": "Теги",
"monitor.notes": "Заметки",
"monitor.visibleColumns": "Видимые столбцы",
"monitor.terminal": "Терминал",
"monitor.resizeColumn": "Изменить ширину столбца «{column}»",
"monitor.host": "Сервер",
"monitor.hosts": "Серверы",
"monitor.status": "Состояние",
@@ -52,8 +59,6 @@ export const ruMonitorMessages = {
"monitor.memoryTrend": "График памяти",
"monitor.downloadTrend": "График приёма",
"monitor.uploadTrend": "График передачи",
"monitor.memoryOnlyHint":
"Измерения хранятся в памяти до 30 минут и удаляются при выходе. Пробелы означают отсутствие данных. Общие соединения используют наименьший активный интервал.",
"monitor.name": "Название панели",
"monitor.description": "Описание (необязательно)",
"monitor.chooseHosts": "Выбрать серверы",
+8 -3
View File
@@ -22,7 +22,7 @@ export const zhCNMonitorMessages = {
"monitor.stopAll": "全部停止",
"monitor.list": "列表",
"monitor.charts": "图表",
"monitor.search": "搜索名称、地址或分组",
"monitor.search": "搜索主机、分组、标签或备注",
"monitor.interval": "更新频率",
"monitor.range": "趋势范围",
"monitor.seconds": "每 {count} 秒",
@@ -32,6 +32,13 @@ export const zhCNMonitorMessages = {
"monitor.sort.name": "名称",
"monitor.selectedCount": "已选 {count} 台主机",
"monitor.noMatches": "没有符合条件的主机",
"monitor.latency": "延迟",
"monitor.responseLatency": "响应延迟(含远端处理)",
"monitor.tags": "标签",
"monitor.notes": "备注",
"monitor.visibleColumns": "显示的列",
"monitor.terminal": "终端",
"monitor.resizeColumn": "调整「{column}」列宽",
"monitor.host": "主机",
"monitor.hosts": "主机",
"monitor.status": "状态",
@@ -51,8 +58,6 @@ export const zhCNMonitorMessages = {
"monitor.memoryTrend": "内存趋势",
"monitor.downloadTrend": "下载趋势",
"monitor.uploadTrend": "上传趋势",
"monitor.memoryOnlyHint":
"读数只保留在内存中,最多 30 分钟,退出后清空。空白表示没有读数。共享连接按当前最快的更新需求采集。",
"monitor.name": "面板名称",
"monitor.description": "说明(可选)",
"monitor.chooseHosts": "选择主机",
+8 -3
View File
@@ -22,7 +22,7 @@ export const zhTWMonitorMessages = {
"monitor.stopAll": "全部停止",
"monitor.list": "列表",
"monitor.charts": "圖表",
"monitor.search": "搜尋名稱、位址或群組",
"monitor.search": "搜尋主機、群組、標籤或備註",
"monitor.interval": "更新頻率",
"monitor.range": "趨勢範圍",
"monitor.seconds": "每 {count} 秒",
@@ -32,6 +32,13 @@ export const zhTWMonitorMessages = {
"monitor.sort.name": "名稱",
"monitor.selectedCount": "已選 {count} 台主機",
"monitor.noMatches": "沒有符合條件的主機",
"monitor.latency": "延遲",
"monitor.responseLatency": "回應延遲(含遠端處理)",
"monitor.tags": "標籤",
"monitor.notes": "備註",
"monitor.visibleColumns": "顯示的欄位",
"monitor.terminal": "終端",
"monitor.resizeColumn": "調整「{column}」欄寬",
"monitor.host": "主機",
"monitor.hosts": "主機",
"monitor.status": "狀態",
@@ -51,8 +58,6 @@ export const zhTWMonitorMessages = {
"monitor.memoryTrend": "記憶體趨勢",
"monitor.downloadTrend": "下載趨勢",
"monitor.uploadTrend": "上傳趨勢",
"monitor.memoryOnlyHint":
"讀數只保留在記憶體中,最多 30 分鐘,結束後清空。空白表示沒有讀數。共用連線依目前最快的更新需求採集。",
"monitor.name": "面板名稱",
"monitor.description": "說明(選填)",
"monitor.chooseHosts": "選擇主機",
@@ -0,0 +1,49 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
publishMonitorSnapshot,
getMonitorHistoryCursor,
} from "./monitorRuntimeStore";
import type { MonitorSnapshot } from "../../domain/monitorRuntime";
test("renderer cursor acknowledges GPU publication separately from CPU sample and history", () => {
const snapshot: MonitorSnapshot = {
historyMode: "replace",
runtimeId: "gpu-cursor-test",
epoch: 1,
takenAt: 1000,
running: ["panel"],
count: 1,
hostCount: 1,
activeSessions: [],
startedAt: { panel: 0 },
hosts: {
host: {
historyId: "history",
status: "live",
at: 1000,
statsAt: 1001,
points: [
{ at: 1000, cpu: 10, memory: 20, disk: null, rx: null, tx: null },
],
},
},
};
publishMonitorSnapshot(snapshot);
assert.deepEqual(getMonitorHistoryCursor().after.host, {
at: 1000,
statsAt: 1001,
historyId: "history",
});
publishMonitorSnapshot({
...snapshot,
historyMode: "append",
takenAt: 2000,
hosts: { host: { ...snapshot.hosts.host, statsAt: 2001, points: [] } },
});
assert.deepEqual(getMonitorHistoryCursor().after.host, {
at: 1000,
statsAt: 2001,
historyId: "history",
});
});
+1 -1
View File
@@ -22,7 +22,7 @@ export function getMonitorHistoryCursor(): MonitorHistoryCursor {
return {
runtimeId: snapshot.runtimeId,
epoch: snapshot.epoch,
after: Object.fromEntries(Object.entries(snapshot.hosts).map(([id, host]) => [id, { at: host.points.at(-1)?.at ?? 0, statsAt: host.at, historyId: host.historyId }])),
after: Object.fromEntries(Object.entries(snapshot.hosts).map(([id, host]) => [id, { at: host.points.at(-1)?.at ?? 0, statsAt: host.statsAt ?? host.at, historyId: host.historyId }])),
};
}
export function publishMonitorUnavailable() {
+121 -3
View File
@@ -21,6 +21,17 @@ async function fixture(t: test.TestContext) {
value: (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; } });
Object.defineProperty(globalThis, 'cancelAnimationFrame', { configurable: true,
value: (id: number) => frames.delete(id) });
const resizeObservers = new Map<ResizeObserver, Set<Element>>();
const resizeCallbacks = new Map<ResizeObserver, ResizeObserverCallback>();
class LayoutResizeObserver implements ResizeObserver {
constructor(callback: ResizeObserverCallback) { resizeCallbacks.set(this, callback); resizeObservers.set(this, new Set()); }
observe(target: Element) { resizeObservers.get(this)?.add(target); }
unobserve(target: Element) { resizeObservers.get(this)?.delete(target); }
disconnect() { resizeObservers.delete(this); resizeCallbacks.delete(this); }
}
Object.defineProperty(env.window, 'ResizeObserver', { configurable: true, value: LayoutResizeObserver });
const fonts = Object.assign(new env.window.EventTarget(), { status: 'loaded' });
Object.defineProperty(env.document, 'fonts', { configurable: true, value: fonts });
const style = env.document.createElement('div').style;
style.colorScheme = 'dark';
globalThis.getComputedStyle = () => style;
@@ -40,8 +51,11 @@ async function fixture(t: test.TestContext) {
isVisible: visible });
return React.createElement('div', { ref: panel.elementRef });
}
const element = { isConnected: true, closest: () => null,
getBoundingClientRect: () => new env.window.DOMRect(200, 100, 500, 600) };
const element = env.document.createElement('div');
env.document.body.append(element);
let bounds = new env.window.DOMRect(200, 100, 500, 600);
let reads = 0;
element.getBoundingClientRect = () => { reads += 1; return bounds; };
let renderer: ReturnType<typeof create>;
t.after(async () => {
if (renderer) await act(async () => renderer.unmount());
@@ -62,11 +76,15 @@ async function fixture(t: test.TestContext) {
};
const tick = async () => {
await act(async () => {
await Promise.resolve();
const pending = [...frames.values()]; frames.clear();
for (const callback of pending) callback(0);
});
};
return { layouts, overlay, tick, panel: () => panel,
return { layouts, overlay, tick, panel: () => panel, pendingFrames: () => frames.size,
env, element, style, fonts, reads: () => reads, setBounds: (next: DOMRect) => { bounds = next; },
observerCount: () => resizeObservers.size,
resize: () => { for (const [observer, callback] of resizeCallbacks) callback([], observer); },
respond: (callback: typeof respond) => { respond = callback; },
hide: async () => { await act(async () => renderer.update(React.createElement(Panel, { visible: false }))); } };
}
@@ -128,3 +146,103 @@ test('hiding or locking the pane clears pictures without taking another capture'
await f.hide();
assert.deepEqual(f.panel().snapshot, []);
});
test('a stationary visible browser leaves no repeating layout work', async (t) => {
const f = await fixture(t);
for (let index = 0; index < 600; index += 1) await f.tick();
assert.equal(f.layouts.length, 1);
assert.equal(f.pendingFrames(), 0, 'idle browser must stop scheduling document/style/geometry scans');
});
test('layout, scroll, font, stylesheet and theme changes coalesce without resuming idle scans', async (t) => {
const f = await fixture(t);
const reads = f.reads();
f.setBounds(new f.env.window.DOMRect(100, 50, 400, 500));
for (let index = 0; index < 20; index += 1) {
f.resize();
f.env.window.dispatchEvent(new f.env.window.Event('resize'));
f.element.dispatchEvent(new f.env.window.Event('scroll', { bubbles: true }));
}
f.fonts.dispatchEvent(new f.env.window.Event('loadingdone'));
f.env.document.dispatchEvent(new f.env.window.Event('load'));
f.style.colorScheme = 'light';
f.env.document.documentElement.classList.add('light');
assert.equal(f.reads(), reads, 'bursts do not synchronously force repeated layout reads');
await f.tick();
assert.equal(f.reads(), reads + 1);
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 100, y: 50, width: 400, height: 500 });
assert.equal(f.layouts.at(-1)?.theme, 'light');
assert.equal(f.pendingFrames(), 0);
f.env.document.body.setAttribute('inert', ''); await f.tick();
assert.equal(f.layouts.at(-1)?.visible, false);
f.env.document.body.removeAttribute('inert'); await f.tick();
assert.equal(f.layouts.at(-1)?.visible, true);
});
test('resize observation follows a moving overlay and is released while hidden', async (t) => {
const f = await fixture(t);
const popper = f.overlay(tooltip, 10, 10); await f.tick();
assert.equal(f.layouts.at(-1)?.occluded, false);
popper.firstElementChild!.getBoundingClientRect = () => new f.env.window.DOMRect(220, 110, 150, 40);
f.resize(); await f.tick();
assert.equal(f.layouts.at(-1)?.occluded, true);
await f.hide();
assert.equal(f.observerCount(), 0);
const reads = f.reads();
f.overlay('<div role="dialog" aria-modal="true"></div>');
f.env.window.dispatchEvent(new f.env.window.Event('resize'));
f.fonts.dispatchEvent(new f.env.window.Event('loadingdone'));
await f.tick();
assert.equal(f.pendingFrames(), 0);
assert.equal(f.reads(), reads);
});
test('only pane/ancestor and overlay motion follows frames, then stops at settlement', async (t) => {
const f = await fixture(t);
const animation = { pending: false, playState: 'running' };
f.element.getAnimations = () => [animation as unknown as Animation];
f.element.dispatchEvent(new f.env.window.Event('transitionrun', { bubbles: true }));
const reads = f.reads();
for (let index = 0; index < 5; index += 1) {
f.setBounds(new f.env.window.DOMRect(200 + index * 10, 100, 500, 600));
await f.tick();
}
assert.equal(f.reads(), reads + 5);
assert.equal(f.layouts.at(-1)?.bounds?.x, 240);
animation.playState = 'finished'; await f.tick();
assert.equal(f.pendingFrames(), 0);
const spinner = f.env.document.createElement('div'); f.env.document.body.append(spinner); await f.tick();
spinner.getAnimations = () => [animation as unknown as Animation];
const settledReads = f.reads();
spinner.dispatchEvent(new f.env.window.Event('animationstart', { bubbles: true }));
await f.tick();
assert.equal(f.reads(), settledReads, 'unrelated cursor/spinner animation does not wake the browser');
});
test('removing an animating overlay cannot leave an endless layout loop', async (t) => {
const f = await fixture(t);
const popper = f.overlay(tooltip);
popper.firstElementChild!.getAnimations = () => [{ pending: false, playState: 'running' } as Animation];
await f.tick();
assert.equal(f.pendingFrames(), 1);
popper.remove(); await f.tick();
assert.equal(f.pendingFrames(), 0);
assert.equal(f.layouts.at(-1)?.occluded, false);
});
test('an old capture stays obsolete when an identical overlay is opened again', async (t) => {
const f = await fixture(t);
const captures: ((result: Result) => void)[] = [];
f.respond((layout) => layout.occluded ? new Promise((resolve) => { captures.push(resolve); }) : Promise.resolve(true));
const first = f.overlay(tooltip); await f.tick();
first.remove(); await f.tick();
f.overlay(tooltip); await f.tick();
assert.equal(captures.length, 2);
await act(async () => captures[0]({ layers }));
assert.deepEqual(f.panel().snapshot, [], 'the first A capture must not become valid after A -> B -> A');
await act(async () => captures[1]({ layers }));
assert.deepEqual(f.panel().snapshot, layers);
});
+85 -7
View File
@@ -38,9 +38,33 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
useLayoutEffect(() => {
const bridge = netcattyBridge.get();
let frame = 0;
let frame: number | null = null;
let previous = '';
let disposed = false;
let layoutRevision = 0;
const element = elementRef.current;
const doc = element?.ownerDocument;
const view = doc?.defaultView;
const observed = new Set<Element>();
const animations = new Map<Animation, Element>();
const schedule = () => {
if (disposed || frame !== null) return;
frame = requestAnimationFrame(() => {
frame = null;
for (const [animation, target] of animations) {
if (!target.isConnected || (!animation.pending && animation.playState !== 'running')) animations.delete(animation);
}
update();
if (animations.size > 0) schedule();
});
};
const trackAnimations = (target: Element) => {
for (const animation of target.getAnimations?.() ?? []) {
if (!animation.pending && animation.playState !== 'running') continue;
animations.set(animation, target);
}
};
const resizeObserver = isVisible && view?.ResizeObserver ? new view.ResizeObserver(schedule) : null;
const update = () => {
const element = elementRef.current;
const rect = element?.getBoundingClientRect();
@@ -48,9 +72,21 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
// to yield; dialogs are modal even when their content is beside the pane.
const locked = !!document.querySelector('[data-app-lock-overlay]');
const modal = !!document.querySelector('[role="dialog"][aria-modal="true"], [role="alertdialog"]');
const covered = modal || (!!rect && Array.from(document.querySelectorAll(
'[role="dialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *',
)).some((overlay) => overlapsBrowser(rect, overlay)));
const overlays = Array.from(document.querySelectorAll(
'[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *',
));
const covered = modal || (!!rect && overlays.some((overlay) => overlapsBrowser(rect, overlay)));
const targets = new Set<Element>(overlays);
for (let target: Element | null = element; target; target = target.parentElement) targets.add(target);
for (const target of observed) {
if (!targets.has(target)) { resizeObserver?.unobserve(target); observed.delete(target); }
}
for (const target of targets) {
if (observed.has(target)) continue;
observed.add(target);
resizeObserver?.observe(target);
trackAnimations(target);
}
const visible = isVisible && !!element?.isConnected && !!rect
&& rect.width > 0 && rect.height > 0 && !element?.closest('[inert]') && !locked;
if (!visible) setSnapshot(null);
@@ -63,17 +99,59 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
const next = JSON.stringify(layout);
if (next !== previous) {
previous = next;
const revision = ++layoutRevision;
void bridge?.setHostBrowserPanelLayout?.(layout).then((result) => {
if (disposed || previous !== next) return;
if (disposed || layoutRevision !== revision) return;
setSnapshot(visible && covered && typeof result === 'object' ? { browserId, layers: result.layers } : null);
}).catch(() => {});
}
if (isVisible) frame = requestAnimationFrame(update);
};
if (!isVisible) {
setSnapshot(null);
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible: false }).catch(() => {});
return;
}
// Layout, theme and portal changes coalesce into a single frame. Keep
// following only running animations on the pane's ancestors or overlay
// containers; unrelated spinner/cursor animations must not restart polling.
const mutationObserver = view?.MutationObserver ? new view.MutationObserver(schedule) : null;
if (doc) mutationObserver?.observe(doc.documentElement, {
subtree: true, childList: true, attributes: true,
attributeFilter: ['style', 'class', 'hidden', 'inert', 'role', 'aria-modal', 'data-state',
'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'href', 'media', 'disabled'],
});
const onMotion = (event: Event) => {
const target = event.target;
if (!element || !view || !(target instanceof view.Element)) return;
if (!target.contains(element) && !target.matches(
'[role="dialog"], [role="alertdialog"], [role="menu"], [data-app-lock-overlay], '
+ '[data-radix-popper-content-wrapper], [data-radix-popper-content-wrapper] > *',
)) return;
trackAnimations(target);
schedule();
};
const motionEvents = ['transitionrun', 'transitionend', 'transitioncancel', 'animationstart', 'animationend', 'animationcancel'];
for (const event of motionEvents) doc?.addEventListener(event, onMotion, true);
doc?.addEventListener('scroll', schedule, true);
doc?.addEventListener('load', schedule, true);
view?.addEventListener('resize', schedule);
doc?.fonts?.addEventListener('loadingdone', schedule);
doc?.fonts?.addEventListener('loadingerror', schedule);
if (doc?.fonts?.status === 'loading') void doc.fonts.ready.then(schedule);
update();
if (animations.size > 0) schedule();
return () => {
disposed = true;
cancelAnimationFrame(frame);
if (frame !== null) cancelAnimationFrame(frame);
resizeObserver?.disconnect();
mutationObserver?.disconnect();
for (const event of motionEvents) doc?.removeEventListener(event, onMotion, true);
doc?.removeEventListener('scroll', schedule, true);
doc?.removeEventListener('load', schedule, true);
view?.removeEventListener('resize', schedule);
doc?.fonts?.removeEventListener('loadingdone', schedule);
doc?.fonts?.removeEventListener('loadingerror', schedule);
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible: false }).catch(() => {});
};
}, [browserId, isVisible]);
+103
View File
@@ -0,0 +1,103 @@
import { useEffect, useRef, useState, type PointerEvent } from "react";
import {
clampMonitorColumnWidth,
normalizeMonitorColumnWidths,
normalizeMonitorHiddenColumns,
type MonitorColumn,
} from "../../domain/monitorTableLayout";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
STORAGE_KEY_MONITOR_TABLE_COLUMNS,
STORAGE_KEY_MONITOR_HIDDEN_COLUMNS,
} from "../../infrastructure/config/storageKeys";
export function useMonitorTableColumns() {
const [widths, setWidths] = useState(() =>
normalizeMonitorColumnWidths(
localStorageAdapter.read(STORAGE_KEY_MONITOR_TABLE_COLUMNS),
),
);
const [hidden, setHidden] = useState(() =>
normalizeMonitorHiddenColumns(
localStorageAdapter.read(STORAGE_KEY_MONITOR_HIDDEN_COLUMNS),
),
);
const currentHidden = useRef(hidden);
const setVisible = (key: MonitorColumn, visible: boolean) => {
const previous = currentHidden.current;
const next = normalizeMonitorHiddenColumns(
visible ? previous.filter((item) => item !== key) : [...previous, key],
);
currentHidden.current = next;
setHidden(next);
localStorageAdapter.write(STORAGE_KEY_MONITOR_HIDDEN_COLUMNS, next);
};
const current = useRef(widths);
const cleanup = useRef<(() => void) | null>(null);
useEffect(() => () => cleanup.current?.(), []);
const resize = (key: MonitorColumn, width: number, persist = true) => {
current.current = {
...current.current,
[key]: clampMonitorColumnWidth(key, width),
};
setWidths(current.current);
if (persist)
localStorageAdapter.write(
STORAGE_KEY_MONITOR_TABLE_COLUMNS,
current.current,
);
};
const startResize = (
key: MonitorColumn,
event: PointerEvent<HTMLElement>,
) => {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
cleanup.current?.();
const pointerId = event.pointerId;
const handle = event.currentTarget;
handle.setPointerCapture(pointerId);
const start = event.clientX;
const width = current.current[key];
const cursor = document.body.style.cursor;
const userSelect = document.body.style.userSelect;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
const move = (next: globalThis.PointerEvent) => {
if (next.pointerId === pointerId)
resize(key, width + next.clientX - start, false);
};
const finish = (next: globalThis.PointerEvent) => {
if (next.pointerId !== pointerId) return;
localStorageAdapter.write(
STORAGE_KEY_MONITOR_TABLE_COLUMNS,
current.current,
);
cleanup.current?.();
};
const blur = () => {
localStorageAdapter.write(
STORAGE_KEY_MONITOR_TABLE_COLUMNS,
current.current,
);
cleanup.current?.();
};
cleanup.current = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", finish);
window.removeEventListener("pointercancel", finish);
window.removeEventListener("blur", blur);
if (handle.hasPointerCapture(pointerId))
handle.releasePointerCapture(pointerId);
document.body.style.cursor = cursor;
document.body.style.userSelect = userSelect;
cleanup.current = null;
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", finish);
window.addEventListener("pointercancel", finish);
window.addEventListener("blur", blur);
};
return { widths, resize, startResize, hidden, setVisible };
}
+7 -1
View File
@@ -145,10 +145,16 @@ export const AsidePanelHeader: React.FC<AsidePanelHeaderProps> = ({
};
// Content Component (wraps children with scroll)
export const AsidePanelContent: React.FC<{ children: ReactNode; className?: string }> = ({
export const AsidePanelContent: React.FC<{ children: ReactNode; className?: string; scrollMode?: "custom" | "native" }> = ({
children,
className,
scrollMode = "custom",
}) => {
if (scrollMode === "native") return (
<div data-aside-scroll-viewport className={cn("flex-1 min-h-0 min-w-0 overflow-y-auto overscroll-y-contain host-tree-notes-scroll", className)}>
<div className="p-4 space-y-4 min-w-0 overflow-hidden">{children}</div>
</div>
);
return (
<ScrollArea className={cn("flex-1 min-w-0 [&>[data-radix-scroll-area-viewport]>div]:!block [&>[data-radix-scroll-area-viewport]>div]:!min-w-0", className)}>
<div className="p-4 space-y-4 min-w-0 overflow-hidden">
+275
View File
@@ -0,0 +1,275 @@
import React, { useCallback, useLayoutEffect, useRef, useState } from "react";
import { defaultRangeExtractor, useVirtualizer } from "@tanstack/react-virtual";
import {
MONITOR_CARD_HEIGHT,
MONITOR_COLUMNS,
monitorGridColumns,
type MonitorColumn,
} from "../../domain/monitorTableLayout";
import { useMonitorTableColumns } from "../../application/state/useMonitorTableColumns";
import { useI18n } from "../../application/i18n/I18nProvider";
import {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuCheckboxItem,
ContextMenuLabel,
} from "../ui/context-menu";
export function MonitorHostCollection<T extends { id: string }>({
items,
view,
layoutKey,
pinnedId,
renderCard,
renderCells,
rowProps,
renderMenu,
}: {
items: T[];
view: "list" | "charts";
layoutKey: string;
pinnedId?: string;
renderCard: (item: T) => React.ReactNode;
renderCells: (item: T) => Record<MonitorColumn, React.ReactNode>;
rowProps: (item: T) => React.HTMLAttributes<HTMLTableRowElement>;
renderMenu: (item: T) => React.ReactNode;
}) {
const { t } = useI18n();
const scrollRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(1248);
const { widths, resize, startResize, hidden, setVisible } =
useMonitorTableColumns();
const visibleColumns = MONITOR_COLUMNS.filter(
({ key }) => !hidden.includes(key),
);
const columns =
view === "charts" ? monitorGridColumns(width, items.length) : 1;
const scrollMargin = view === "charts" ? 16 : 56;
const pinnedIndex = pinnedId
? items.findIndex((item) => item.id === pinnedId)
: -1;
const getItemKey = useCallback(
(index: number) => items[index * columns]?.id ?? index,
[items, columns],
);
const virtualizer = useVirtualizer<HTMLDivElement, HTMLElement>({
count: Math.ceil(items.length / columns),
getScrollElement: () => scrollRef.current,
estimateSize: () => (view === "charts" ? MONITOR_CARD_HEIGHT : 76),
gap: view === "charts" ? 16 : 8,
overscan: view === "charts" ? 2 : 5,
getItemKey,
scrollMargin,
initialRect: { width: 1280, height: 800 },
rangeExtractor: useCallback(
(range) => {
const visible = defaultRangeExtractor(range);
const pinned = Math.floor(pinnedIndex / columns);
return pinnedIndex < 0 || visible.includes(pinned)
? visible
: [...visible, pinned].sort((a, b) => a - b);
},
[pinnedIndex, columns],
),
});
useLayoutEffect(() => {
const element = scrollRef.current;
if (!element) return;
const measure = () => setWidth(Math.max(0, element.clientWidth));
measure();
const observer = new ResizeObserver(measure);
observer.observe(element);
return () => observer.disconnect();
}, []);
useLayoutEffect(() => {
virtualizer.measure();
}, [columns, view, hidden, virtualizer]);
useLayoutEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = 0;
virtualizer.scrollToOffset(0);
}, [layoutKey, virtualizer]);
const template = visibleColumns
.map((column) => `${widths[column.key]}px`)
.join(" ");
const tableWidth = visibleColumns.reduce(
(sum, column) => sum + widths[column.key],
0,
);
return (
<div className="min-h-0 min-w-0 flex-1 px-4">
<div
ref={scrollRef}
className="h-full min-w-0 overflow-auto"
data-section="vault-monitor-list"
data-monitor-virtual={view}
>
<div className="py-4">
{view === "charts" ? (
<div
className="relative"
style={{ height: virtualizer.getTotalSize() }}
>
{virtualizer.getVirtualItems().map((row) => (
<div
key={row.key}
data-monitor-virtual-row={row.index}
className="absolute left-0 top-0 grid w-full gap-4"
style={{
height: MONITOR_CARD_HEIGHT,
gridTemplateColumns: `repeat(${columns},minmax(0,1fr))`,
transform: `translateY(${row.start - scrollMargin}px)`,
}}
>
{items
.slice(row.index * columns, (row.index + 1) * columns)
.map((item) => (
<ContextMenu key={item.id}>
<ContextMenuTrigger asChild>
{renderCard(item)}
</ContextMenuTrigger>
<ContextMenuContent>
{renderMenu(item)}
</ContextMenuContent>
</ContextMenu>
))}
</div>
))}
</div>
) : (
<table
className="block text-xs"
style={{ width: tableWidth }}
aria-rowcount={items.length + 1}
>
<ContextMenu>
<ContextMenuTrigger asChild>
<thead className="sticky top-0 z-20 block bg-background text-muted-foreground">
<tr
className="grid h-10"
style={{ gridTemplateColumns: template }}
>
{visibleColumns.map((column) => (
<th
key={column.key}
data-monitor-column={column.key}
className={`relative flex min-w-0 items-center px-3 text-left text-[11px] font-medium${column.key === "terminal" ? " sticky right-0 z-10 bg-background" : ""}`}
>
<span
className="truncate"
title={t(`monitor.${column.key}`)}
>
{column.key === "terminal" ? (
<span className="sr-only">
{t("monitor.openTerminal")}
</span>
) : (
t(`monitor.${column.key}`)
)}
</span>
<div
role="separator"
aria-orientation="vertical"
aria-label={t("monitor.resizeColumn", {
column: t(`monitor.${column.key}`),
})}
aria-valuenow={widths[column.key]}
aria-valuemin={column.min}
aria-valuemax={
column.key === "terminal" ? 200 : 640
}
tabIndex={0}
className="absolute inset-y-1 right-0 w-2 cursor-col-resize touch-none outline-none after:absolute after:inset-y-1 after:right-0.5 after:w-px hover:after:bg-primary focus-visible:after:bg-primary"
onPointerDown={(event) =>
startResize(column.key, event)
}
onDoubleClick={() =>
resize(column.key, column.width)
}
onKeyDown={(event) => {
if (
event.key === "ArrowLeft" ||
event.key === "ArrowRight"
) {
event.preventDefault();
resize(
column.key,
widths[column.key] +
(event.key === "ArrowRight" ? 1 : -1) *
(event.shiftKey ? 32 : 8),
);
}
}}
/>
</th>
))}
</tr>
</thead>
</ContextMenuTrigger>
<ContextMenuContent data-monitor-column-menu>
<ContextMenuLabel>
{t("monitor.visibleColumns")}
</ContextMenuLabel>
{MONITOR_COLUMNS.map(({ key }) => (
<ContextMenuCheckboxItem
key={key}
data-monitor-toggle-column={key}
checked={!hidden.includes(key)}
disabled={key === "host" || key === "terminal"}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(checked) =>
setVisible(key, checked === true)
}
>
{t(`monitor.${key}`)}
</ContextMenuCheckboxItem>
))}
</ContextMenuContent>
</ContextMenu>
<tbody
className="relative block"
style={{ height: virtualizer.getTotalSize() }}
>
{virtualizer.getVirtualItems().map((row) => {
const cells = renderCells(items[row.index]);
return (
<ContextMenu key={row.key}>
<ContextMenuTrigger asChild>
<tr
{...rowProps(items[row.index])}
ref={virtualizer.measureElement}
data-index={row.index}
aria-rowindex={row.index + 2}
style={{
display: "grid",
gridTemplateColumns: template,
minHeight: 72,
position: "absolute",
top: 0,
left: 0,
width: tableWidth,
transform: `translateY(${row.start - scrollMargin}px)`,
}}
>
{visibleColumns.map(({ key }) => (
<React.Fragment key={key}>
{cells[key]}
</React.Fragment>
))}
</tr>
</ContextMenuTrigger>
<ContextMenuContent>
{renderMenu(items[row.index])}
</ContextMenuContent>
</ContextMenu>
);
})}
</tbody>
</table>
)}
</div>
</div>
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
import React from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { Badge } from "../ui/badge";
export function MonitorHostLatency({
value,
responseValue,
}: {
value?: number | null;
responseValue?: number | null;
}) {
const { t } = useI18n();
const isValid = (n: number | null | undefined): n is number =>
typeof n === "number" && Number.isFinite(n) && n >= 0;
const usesResponse = !isValid(value) && isValid(responseValue);
const latency = usesResponse ? responseValue : value;
const known = isValid(latency);
const bars = !known ? 0 : latency <= 100 ? 4 : latency <= 300 ? 2 : 1;
const tone = !known
? "text-muted-foreground"
: bars === 4
? "text-emerald-600 dark:text-emerald-400"
: bars === 2
? "text-amber-500 dark:text-amber-400"
: "text-red-600 dark:text-red-400";
const label = `${t(usesResponse ? "monitor.responseLatency" : "monitor.latency")}: ${known ? `${Number(latency.toFixed(1))} ms` : "--"}`;
return (
<Tooltip>
<TooltipTrigger asChild>
<span
role="img"
aria-label={label}
className={`inline-flex h-5 shrink-0 items-center ${tone}`}
data-monitor-latency
data-monitor-signal-bars={bars}
>
<svg width="18" height="14" viewBox="0 0 18 14" aria-hidden="true">
{[4, 7, 10, 13].map((height, index) => (
<rect
key={height}
x={1 + index * 4}
y={14 - height}
width="3"
height={height}
rx="0.8"
fill="currentColor"
opacity={index < bars ? 1 : 0.18}
/>
))}
</svg>
</span>
</TooltipTrigger>
<TooltipContent className={tooltipClass}>{label}</TooltipContent>
</Tooltip>
);
}
const tooltipClass =
"max-w-sm whitespace-pre-wrap break-words bg-popover text-popover-foreground shadow-md ring-1 ring-border/40";
export function MonitorHostTags({ tags = [] }: { tags?: string[] }) {
if (!tags.length)
return (
<span className="inline-flex h-5 items-center text-[10px] text-muted-foreground">
--
</span>
);
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className="flex h-5 min-w-0 max-w-full items-center gap-1 overflow-hidden"
data-monitor-tags
>
<Badge
variant="secondary"
className="inline-block max-w-20 truncate border-0 px-1.5 py-0 text-[10px] font-normal leading-5"
>
{tags[0]}
</Badge>
{tags.length > 1 && (
<span className="shrink-0 text-[10px] text-muted-foreground">
+{tags.length - 1}
</span>
)}
</span>
</TooltipTrigger>
<TooltipContent className={tooltipClass}>
<div className="flex flex-wrap gap-1">
{tags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="max-w-full break-all text-[10px] font-normal"
>
{tag}
</Badge>
))}
</div>
</TooltipContent>
</Tooltip>
);
}
export function MonitorHostNotes({
notes,
focusable = false,
}: {
notes?: string;
focusable?: boolean;
}) {
if (!notes?.trim())
return <span className="text-[10px] text-muted-foreground">--</span>;
return (
<Tooltip>
<TooltipTrigger asChild>
<span
data-monitor-notes
tabIndex={focusable ? 0 : undefined}
className="block min-w-0 truncate text-[10px] text-muted-foreground outline-none focus-visible:ring-1 focus-visible:ring-primary"
>
{notes}
</span>
</TooltipTrigger>
<TooltipContent className={`${tooltipClass} max-h-64 overflow-y-auto`}>
{notes}
</TooltipContent>
</Tooltip>
);
}
+21 -1
View File
@@ -10,6 +10,26 @@ import type { Host } from "../../domain/models";
import type { MonitorPoint } from "../../domain/monitorRuntime";
import { MonitorPanels, MonitorTrend } from "./MonitorPanels";
import { MonitorDiskUsage } from "./MonitorDiskUsage";
import { MonitorHostLatency } from "./MonitorHostMetadata";
import { TooltipProvider } from "../ui/tooltip";
test("latency signals use the stats response when transport ping is unavailable and prefer valid ping readings", () => {
for (const [ping, response, bars, label] of [
[null, 145, "2", "Response latency (includes remote processing): 145 ms"],
[0, 500, "4", "Latency: 0 ms"],
[NaN, 450, "1", "Response latency (includes remote processing): 450 ms"],
[null, null, "0", "Latency: --"],
] as const) {
const document = new JSDOM(renderToStaticMarkup(
<I18nProvider locale="en"><TooltipProvider>
<MonitorHostLatency value={ping} responseValue={response} />
</TooltipProvider></I18nProvider>,
)).window.document;
const signal = document.querySelector("[data-monitor-latency]")!;
assert.equal(signal.getAttribute("data-monitor-signal-bars"), bars);
assert.equal(signal.getAttribute("aria-label"), label);
}
});
const now = 1_800_000_000_000;
const point = (ago: number, cpu: number | null): MonitorPoint => ({
@@ -161,7 +181,7 @@ for (const view of ["list", "charts"] as const) {
assert.equal(document.querySelector("header .lucide-gauge"), null);
assert.equal(document.querySelector(".lucide-activity"), null);
if (view === "list") {
const network = document.querySelector("tbody tr")!.children[5];
const network = document.querySelector("[data-monitor-cell=network]")!;
assert.deepEqual([...network.querySelectorAll(".sr-only")].map((e) => e.textContent), ["Download: ", "Upload: "]);
}
});
+370 -261
View File
@@ -6,6 +6,7 @@ import {
Copy,
Cpu,
Gauge,
Gpu,
HardDrive,
LayoutGrid,
List,
@@ -38,6 +39,7 @@ import type { Host } from "../../domain/models";
import { buildMonitorTrendGeometry, findMonitorChartPoint } from "../../domain/monitorChart";
import { cn } from "../../lib/utils";
import { Button } from "../ui/button";
import { ContextMenuItem } from "../ui/context-menu";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { Textarea } from "../ui/textarea";
@@ -70,6 +72,9 @@ import { ResourceBar } from "../systemManager/ResourceBar";
import { MonitorDiskUsage } from "./MonitorDiskUsage";
import { VaultHeaderSearch, VaultPageHeader, vaultHeaderIconButtonClass, vaultHeaderSecondaryButtonClass } from "./VaultPageHeader";
import { toast } from "../ui/toast";
import { TooltipProvider } from "../ui/tooltip";
import { MonitorHostCollection } from "./MonitorHostCollection";
import { MonitorHostLatency, MonitorHostNotes, MonitorHostTags } from "./MonitorHostMetadata";
const emptyMonitorPoints: MonitorPoint[] = [];
const percent = (n: number | null | undefined) =>
@@ -214,22 +219,15 @@ export function MonitorPanels({
} | null>(null);
const [selecting, setSelecting] = useState(false);
const [detail, setDetail] = useState<string | null>(null);
const [recovery, setRecovery] = useState<{ hostId: string; username: string; password: string } | null>(null);
const [deleting, setDeleting] = useState<MonitorPanel | null>(null);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [login, setLogin] = useState({
hostId: "",
username: "",
password: "",
});
useEffect(
() => setLogin({ hostId: "", username: "", password: "" }),
[detail],
);
const panel = panels.find((p) => p.id === selected) ?? panels[0];
const running = !!panel && runtime.running.includes(panel.id);
const startEdit = (base: MonitorPanel | null) => {
setDetail(null);
setRecovery(null);
setSelecting(false);
setError(null);
setEditor({
@@ -305,28 +303,39 @@ export function MonitorPanels({
toast.error(t("monitor.saveFailed"));
}
};
const hostById = useMemo(
() => new Map(hosts.map((host) => [host.id, host])),
[hosts],
);
const orderedHostIds = useMemo(() => {
if (!panel) return [];
return panel.sort === "manual" ? panel.hostIds : [...panel.hostIds].sort((a, b) =>
(hosts.find((host) => host.id === a)?.label ?? a).localeCompare(hosts.find((host) => host.id === b)?.label ?? b),
);
}, [panel, hosts]);
return panel.sort === "manual"
? panel.hostIds
: [...panel.hostIds].sort((a, b) =>
(hostById.get(a)?.label ?? a).localeCompare(
hostById.get(b)?.label ?? b,
),
);
}, [panel, hostById]);
const rows = useMemo(() => {
if (!panel) return [];
const q = search.trim().toLocaleLowerCase();
const rows = orderedHostIds.map((id) => ({
id,
host: hosts.find((h) => h.id === id),
host: hostById.get(id),
state: monitorStateSince(runtime.hosts[id], runtime.startedAt[panel.id]),
}));
return rows.filter(
({ host }) => !q ||
`${host?.label ?? ""} ${host?.hostname ?? ""} ${host?.group ?? ""}`
({ host }) =>
!q ||
`${host?.label ?? ""} ${host?.hostname ?? ""} ${host?.group ?? ""} ${host?.tags.join(" ") ?? ""} ${host?.notes ?? ""}`
.toLocaleLowerCase()
.includes(q),
);
}, [panel, orderedHostIds, hosts, runtime, search]);
const dragProps = (hostId: string): React.HTMLAttributes<HTMLElement> & { "data-monitor-host": string } => ({
}, [panel, orderedHostIds, hostById, runtime, search]);
const dragProps = (
hostId: string,
): React.HTMLAttributes<HTMLElement> & { "data-monitor-host": string } => ({
draggable: true,
"data-monitor-host": hostId,
onDragStart: (event) => {
@@ -336,7 +345,12 @@ export function MonitorPanels({
setDragging({ panelId: panel.id, hostId });
},
onDragOver: (event) => {
if (!panel || dragging?.panelId !== panel.id || dragging.hostId === hostId) return;
if (
!panel ||
dragging?.panelId !== panel.id ||
dragging.hostId === hostId
)
return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
setDropTarget(hostId);
@@ -344,10 +358,11 @@ export function MonitorPanels({
onDrop: (event) => {
if (!panel || dragging?.panelId !== panel.id) return;
event.preventDefault();
if (dragging.hostId !== hostId) void patchPanel({
hostIds: reorderMonitorHosts(orderedHostIds, dragging.hostId, hostId),
sort: "manual",
});
if (dragging.hostId !== hostId)
void patchPanel({
hostIds: reorderMonitorHosts(orderedHostIds, dragging.hostId, hostId),
sort: "manual",
});
setDragging(null);
setDropTarget(null);
},
@@ -362,18 +377,24 @@ export function MonitorPanels({
: !running
? "stopped"
: state?.status === "live" &&
Date.now() - state.at > Math.max(5000, (panel?.interval ?? DEFAULT_MONITOR_INTERVAL) * 2500)
Date.now() - state.at >
Math.max(
5000,
(panel?.interval ?? DEFAULT_MONITOR_INTERVAL) * 2500,
)
? "stale"
: (state?.status ?? "queued");
const stateBadge = (host: Host | undefined, state?: MonitorHostState) => (
<span
className={cn(
"inline-flex items-center gap-1.5 whitespace-nowrap text-[10px] shrink-0 font-medium",
"inline-flex min-w-0 max-w-full items-center gap-1.5 whitespace-nowrap text-[10px] shrink-0 font-medium",
status(host, state) === "live"
? "text-emerald-700 dark:text-emerald-400"
: ["error", "disconnected", "missing"].includes(status(host, state))
? "text-destructive"
: ["login", "stale", "unsupported", "limit"].includes(status(host, state))
: ["login", "stale", "unsupported", "limit"].includes(
status(host, state),
)
? "text-amber-700 dark:text-amber-400"
: "text-muted-foreground",
)}
@@ -382,7 +403,9 @@ export function MonitorPanels({
aria-hidden="true"
className="h-1.5 w-1.5 rounded-full bg-current"
/>
{t(`monitor.status.${status(host, state)}`)}
<span className="truncate">
{t(`monitor.status.${status(host, state)}`)}
</span>
</span>
);
const renderMetrics = (
@@ -445,6 +468,10 @@ export function MonitorPanels({
};
const hostDetail = hosts.find((h) => h.id === detail);
const detailState = detail && panel ? monitorStateSince(runtime.hosts[detail], runtime.startedAt[panel.id]) : undefined;
const detailAvailable = status(hostDetail, detailState) === "live";
useEffect(() => {
if (detail && !detailAvailable) setDetail(null);
}, [detail, detailAvailable]);
return (
<section
className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden"
@@ -647,35 +674,76 @@ export function MonitorPanels({
</SelectContent>
</Select>
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-auto" data-section="vault-monitor-list">
<div className="p-4">
{!rows.length ? (
<p className="p-8 text-center text-sm text-muted-foreground">
{t("monitor.noMatches")}
</p>
) : panel.view === "charts" ? (
<div className="grid auto-rows-fr grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))] gap-4">
{rows.map(({ id, host, state }) => (
{!rows.length ? (
<div className="flex-1 overflow-auto p-12 text-center text-sm text-muted-foreground">
{t("monitor.noMatches")}
</div>
) : (
<TooltipProvider delayDuration={180}>
<MonitorHostCollection
items={rows}
view={panel.view}
layoutKey={`${panel.id}:${panel.view}:${search}`}
pinnedId={
dragging?.panelId === panel.id ? dragging.hostId : undefined
}
renderMenu={({ id, host, state }) => (
<>
<ContextMenuItem disabled={!host} onSelect={() => host && onConnect(host)}>
{t("monitor.openTerminal")}
</ContextMenuItem>
{host && running && ["login", "error", "disconnected"].includes(status(host, state)) && (
<ContextMenuItem data-monitor-retry={id} onSelect={() => setRecovery({hostId: id, username: host.username ?? "", password: ""})}>
{t("monitor.retry")}
</ContextMenuItem>
)}
</>
)}
rowProps={({ id, host, state }) => ({
...dragProps(id),
className: cn(
"group cursor-default [&>td]:bg-card/70 hover:[&>td]:bg-card [&>td:first-child]:rounded-l-xl [&>td]:min-w-0 [&>td]:px-3 [&>td]:py-3 [&>td]:flex [&>td]:items-center",
dragging?.panelId === panel.id &&
dragging.hostId === id &&
"cursor-grabbing opacity-40",
dropTarget === id && "[&>td]:bg-accent",
status(host, state) !== "live" && "text-muted-foreground opacity-50",
),
})}
renderCard={({ id, host, state }) => (
<article
{...dragProps(id)}
className={cn(
"relative h-full min-w-0 overflow-hidden rounded-xl bg-card/70 shadow-sm shadow-black/[0.02] transition-colors hover:bg-card",
dragging?.panelId === panel.id &&
dragging.hostId === id &&
"cursor-grabbing opacity-40",
dropTarget === id && "bg-accent",
status(host, state) !== "live" && "opacity-50",
)}
>
<button
key={id}
{...dragProps(id)}
className={cn(
"flex h-full min-w-0 cursor-default flex-col overflow-hidden rounded-xl bg-card/70 p-4 text-left shadow-sm shadow-black/[0.02] transition-colors hover:bg-card focus-visible:outline focus-visible:outline-primary",
dragging?.panelId === panel.id && dragging.hostId === id && "cursor-grabbing opacity-40",
dropTarget === id && "bg-accent",
status(host, state) !== "live" &&
"text-muted-foreground",
)}
type="button"
className="flex h-full w-full min-w-0 cursor-default flex-col p-4 text-left outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary"
aria-disabled={status(host, state) !== "live"}
onClick={() => {
if (status(host, state) !== "live") return;
setEditor(null);
setDetail(id);
}}
>
<div className="mb-5 flex items-center gap-3">
<div className="mb-2 flex w-full min-w-0 items-center gap-3 pr-7">
{host ? (
<DistroAvatar host={host} fallback={host.label} size="log" />
<DistroAvatar
host={host}
fallback={host.label}
size="log"
/>
) : (
<Server size={24} className="shrink-0 text-muted-foreground" />
<Server
size={24}
className="shrink-0 text-muted-foreground"
/>
)}
<div className="min-w-0 flex-1">
<h3 className="truncate text-sm font-semibold text-foreground">
@@ -685,20 +753,39 @@ export function MonitorPanels({
{host?.hostname ?? id}
</p>
</div>
{stateBadge(host, state)}
</div>
<div className={cn(status(host, state) !== "live" && "opacity-60")}>
<div className="mb-2 flex h-5 w-full min-w-0 items-center gap-2">
<MonitorHostLatency value={state?.stats?.latencyMs} responseValue={state?.stats?.responseLatencyMs} />
<div className="min-w-0 flex-1">
<MonitorHostTags tags={host?.tags} />
</div>
<div className="min-w-0 max-w-[40%]">
{stateBadge(host, state)}
</div>
</div>
<div
className={cn(
"w-full min-w-0",
status(host, state) !== "live" && "opacity-60",
)}
>
{renderMetrics(state)}
<div className="mt-4">
<div className="mt-3">
<div className="mb-2 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<HardDrive size={13} className="text-amber-600 dark:text-amber-400" />
<HardDrive
size={13}
className="text-amber-600 dark:text-amber-400"
/>
<span>{t("monitor.disks")}</span>
</div>
<MonitorDiskUsage stats={state?.stats} limit={2} />
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px] tabular-nums">
<div className="mt-2 grid grid-cols-2 gap-2 text-[11px] tabular-nums">
<span className="flex items-center gap-1.5 py-1">
<ArrowDown size={13} className="shrink-0 text-sky-600 dark:text-sky-400" />
<ArrowDown
size={13}
className="shrink-0 text-sky-600 dark:text-sky-400"
/>
{rate(
state?.stats?.networkReady
? state.stats.netRxSpeed
@@ -706,7 +793,10 @@ export function MonitorPanels({
)}
</span>
<span className="flex items-center gap-1.5 py-1">
<ArrowUp size={13} className="shrink-0 text-violet-600 dark:text-violet-400" />
<ArrowUp
size={13}
className="shrink-0 text-violet-600 dark:text-violet-400"
/>
{rate(
state?.stats?.networkReady
? state.stats.netTxSpeed
@@ -715,127 +805,169 @@ export function MonitorPanels({
</span>
</div>
</div>
<p className="mt-auto flex items-center gap-1.5 pt-4 text-[10px] text-muted-foreground">
<Clock3 size={11} />
{state?.at
? t("monitor.updatedAt", {
time: new Date(state.at).toLocaleTimeString(),
})
: t("monitor.noSamples")}
</p>
<div className="mt-auto flex w-full min-w-0 items-center gap-2 pt-2 text-[10px] text-muted-foreground">
<div className="min-w-0 flex-1">
<MonitorHostNotes notes={host?.notes} />
</div>
<span className="inline-flex shrink-0 items-center gap-1">
<Clock3 size={11} />
{state?.at
? new Date(state.at).toLocaleTimeString()
: t("monitor.noSamples")}
</span>
</div>
</button>
))}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[840px] border-separate border-spacing-x-0 border-spacing-y-2 text-xs">
<thead className="text-muted-foreground">
<tr>
{[
"host",
"status",
"cpu",
"memory",
"disks",
"network",
"updated",
].map((k) => (
<th
key={k}
className="px-3 py-3 text-left font-medium"
>
{t(`monitor.${k}`)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map(({ id, host, state }) => {
const s = state?.stats;
return (
<tr
key={id}
{...dragProps(id)}
className={cn(
"cursor-default [&>td]:bg-card/70 [&>td]:transition-colors hover:[&>td]:bg-card [&>td:first-child]:rounded-l-xl [&>td:last-child]:rounded-r-xl",
dragging?.panelId === panel.id && dragging.hostId === id && "cursor-grabbing opacity-40",
dropTarget === id && "[&>td]:bg-accent",
status(host, state) !== "live" &&
"text-muted-foreground",
)}
>
<td className="px-3 py-3">
<button
draggable
className="flex cursor-inherit items-center gap-2.5 text-left hover:underline"
onClick={() => {
setEditor(null);
setDetail(id);
}}
>
{host ? (
<DistroAvatar host={host} fallback={host.label} size="md" />
) : (
<Server size={24} className="shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 max-w-52">
<div className="truncate font-medium">
{host?.label ?? t("monitor.missingHost")}
</div>
<div className="truncate text-[10px] text-muted-foreground">
{host?.hostname ?? id}
</div>
</div>
</button>
</td>
<td className="px-3">
{stateBadge(host, state)}
</td>
<td className="px-3 tabular-nums">
<ResourceBar label="" value={s?.cpu} />
</td>
<td className="px-3 tabular-nums">
<ResourceBar
label=""
value={
s?.memTotal && s.memUsed != null
? (s.memUsed / s.memTotal) * 100
: null
}
/>
</td>
<td className="min-w-[220px] px-3 py-3">
<MonitorDiskUsage stats={s} />
</td>
<td className="px-3 tabular-nums whitespace-nowrap">
<div className="flex items-center gap-1.5">
<ArrowDown size={13} className="shrink-0 text-sky-600 dark:text-sky-400" />
<span className="sr-only">{t("monitor.download")}: </span>
{rate(s?.networkReady ? s.netRxSpeed : null)}
</div>
<div className="flex items-center gap-1.5">
<ArrowUp size={13} className="shrink-0 text-violet-600 dark:text-violet-400" />
<span className="sr-only">{t("monitor.upload")}: </span>
{rate(s?.networkReady ? s.netTxSpeed : null)}
</div>
</td>
<td className="px-3 whitespace-nowrap">
{state?.at
? new Date(state.at).toLocaleTimeString()
: "--"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<p className="pt-4 text-[11px] leading-relaxed text-muted-foreground">
{t("monitor.memoryOnlyHint")}
</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-3 top-3 h-7 w-7 text-muted-foreground"
data-monitor-terminal={id}
aria-label={`${t("monitor.openTerminal")}: ${host?.label ?? t("monitor.missingHost")}`}
title={t("monitor.openTerminal")}
disabled={!host}
onClick={(event) => {
event.stopPropagation();
if (host) onConnect(host);
}}
>
<Terminal size={14} />
</Button>
</article>
)}
renderCells={({ id, host, state }) => ({
host: (<td>
<button
type="button"
className="flex w-full min-w-0 items-center gap-2.5 text-left aria-disabled:no-underline hover:underline"
aria-disabled={status(host, state) !== "live"}
onClick={() => {
if (status(host, state) !== "live") return;
setEditor(null);
setDetail(id);
}}
>
{host ? (
<DistroAvatar
host={host}
fallback={host.label}
size="md"
/>
) : (
<Server
size={24}
className="shrink-0 text-muted-foreground"
/>
)}
<div className="min-w-0">
<div className="truncate font-medium">
{host?.label ?? t("monitor.missingHost")}
</div>
<div className="truncate text-[10px] text-muted-foreground">
{host?.hostname ?? id}
</div>
</div>
</button>
</td>),
latency: (<td>
<MonitorHostLatency value={state?.stats?.latencyMs} responseValue={state?.stats?.responseLatencyMs} />
</td>),
tags: (<td>
<MonitorHostTags tags={host?.tags} />
</td>),
notes: (<td>
<div className="min-w-0 w-full">
<MonitorHostNotes notes={host?.notes} focusable />
</div>
</td>),
cpu: (<td className="tabular-nums">
<ResourceBar
className="w-full"
label=""
value={state?.stats?.cpu}
/>
</td>),
memory: (<td className="tabular-nums">
<ResourceBar
className="w-full"
label=""
value={
state?.stats?.memTotal &&
state.stats.memUsed != null
? (state.stats.memUsed / state.stats.memTotal) *
100
: null
}
/>
</td>),
disks: (<td>
<div className="w-full min-w-0">
<MonitorDiskUsage stats={state?.stats} />
</div>
</td>),
network: (<td
data-monitor-cell="network"
className="tabular-nums whitespace-nowrap"
>
<div>
<div className="flex items-center gap-1.5">
<ArrowDown
size={13}
className="shrink-0 text-sky-600 dark:text-sky-400"
/>
<span className="sr-only">
{t("monitor.download")}:{" "}
</span>
{rate(
state?.stats?.networkReady
? state.stats.netRxSpeed
: null,
)}
</div>
<div className="flex items-center gap-1.5">
<ArrowUp
size={13}
className="shrink-0 text-violet-600 dark:text-violet-400"
/>
<span className="sr-only">
{t("monitor.upload")}:{" "}
</span>
{rate(
state?.stats?.networkReady
? state.stats.netTxSpeed
: null,
)}
</div>
</div>
</td>),
updated: (<td className="whitespace-nowrap tabular-nums">
{state?.at
? new Date(state.at).toLocaleTimeString()
: "--"}
</td>),
status: (<td>{stateBadge(host, state)}</td>),
terminal: (<td className="sticky right-0 z-10 justify-center !bg-background before:pointer-events-none before:absolute before:inset-0 before:rounded-r-xl before:bg-card/70 group-hover:before:bg-card">
<Button
type="button"
variant="ghost"
size="icon"
className="relative h-7 w-7 text-muted-foreground"
data-monitor-terminal={id}
aria-label={`${t("monitor.openTerminal")}: ${host?.label ?? t("monitor.missingHost")}`}
title={t("monitor.openTerminal")}
disabled={!host}
onClick={(event) => {
event.stopPropagation();
if (host) onConnect(host);
}}
>
<Terminal size={14} />
</Button>
</td>)
})}
/>
</TooltipProvider>
)}
</>
)}
</div>
@@ -1006,8 +1138,9 @@ export function MonitorPanels({
className={monitorAsideClass}
/>
)}
{detail && (
{detail && detailAvailable && (
<AsidePanel
dataSection="vault-monitor-detail"
open
onClose={() => setDetail(null)}
title={hostDetail?.label ?? t("monitor.missingHost")}
@@ -1019,7 +1152,7 @@ export function MonitorPanels({
width="w-[380px]"
className={monitorAsideClass}
>
<AsidePanelContent>
<AsidePanelContent scrollMode="native">
<div className="flex items-center gap-3">
{hostDetail && <DistroAvatar host={hostDetail} fallback={hostDetail.label} size="lg" />}
<div className="min-w-0 flex-1">
@@ -1027,73 +1160,41 @@ export function MonitorPanels({
{stateBadge(hostDetail, detailState)}
</div>
</div>
{["login", "error", "disconnected"].includes(
detailState?.status ?? "",
) && (
<>
<p className="text-xs text-muted-foreground">
{t("monitor.loginHint")}
</p>
<div className="space-y-2">
<Label htmlFor="monitor-login-user">
{t("monitor.username")}
</Label>
<Input
id="monitor-login-user"
autoComplete="off"
value={
login.hostId === detail
? login.username
: (hostDetail?.username ?? "")
}
onChange={(e) =>
setLogin((prev) => ({
hostId: detail,
username: e.target.value,
password: prev.hostId === detail ? prev.password : "",
}))
}
/>
<Label htmlFor="monitor-login-password">
{t("monitor.password")}
</Label>
<Input
id="monitor-login-password"
type="password"
autoComplete="new-password"
value={login.hostId === detail ? login.password : ""}
onChange={(e) =>
setLogin((prev) => ({
hostId: detail,
username:
prev.hostId === detail
? prev.username
: (hostDetail?.username ?? ""),
password: e.target.value,
}))
}
/>
</div>
<Button
variant="outline"
onClick={() => {
monitorActions.retry(
detail,
login.hostId === detail && login.password
? {
username: login.username,
password: login.password,
}
: undefined,
);
setLogin({ hostId: "", username: "", password: "" });
}}
>
{t("monitor.retry")}
</Button>
</>
)}
{renderMetrics(detailState, true)}
{detailState?.stats && (
detailState.stats.gpu != null ||
detailState.stats.gpuName ||
(detailState.stats.gpuMemTotal ?? 0) > 0
) && (
<section className="space-y-3" data-monitor-gpu>
<h3 className="flex items-center gap-1.5 text-sm font-medium">
<Gpu size={15} className="text-muted-foreground" />
{t("systemManager.tabs.gpu")}
</h3>
{detailState.stats.gpuName && (
<p className="break-words text-xs text-muted-foreground">
{detailState.stats.gpuName}
</p>
)}
<div className="space-y-1.5">
<p className="text-[11px] text-muted-foreground">{t("systemManager.gpu.util")}</p>
<ResourceBar label="" value={detailState.stats.gpu} />
</div>
<div className="space-y-1.5">
<div className="flex justify-between gap-2 text-[11px] text-muted-foreground">
<span>{t("systemManager.gpu.memory")}</span>
<span className="tabular-nums">
{capacity(detailState.stats.gpuMemUsed)} / {capacity(detailState.stats.gpuMemTotal)}
</span>
</div>
<ResourceBar label="" value={
detailState.stats.gpuMemUsed != null && (detailState.stats.gpuMemTotal ?? 0) > 0
? detailState.stats.gpuMemUsed / detailState.stats.gpuMemTotal! * 100
: null
} />
</div>
</section>
)}
<p className="text-xs text-muted-foreground">
{t("monitor.observed", {
count: detailState?.points.length
@@ -1188,24 +1289,6 @@ export function MonitorPanels({
<p>{detailState?.stats?.kernelRelease ?? "--"}</p>
<p>{detailState?.stats?.hostname ?? "--"}</p>
</div>
{detailState?.status === "unsupported" && (
<p className="text-xs text-muted-foreground">
{t("monitor.unsupportedHint")}
</p>
)}
{!hostDetail && panel && (
<Button
variant="outline"
onClick={() => {
void patchPanel({
hostIds: panel.hostIds.filter((id) => id !== detail),
});
setDetail(null);
}}
>
{t("monitor.removeHost")}
</Button>
)}
</AsidePanelContent>
<AsidePanelFooter>
<Button
@@ -1219,6 +1302,32 @@ export function MonitorPanels({
</AsidePanelFooter>
</AsidePanel>
)}
<Dialog open={!!recovery} onOpenChange={(open) => !open && setRecovery(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("monitor.retry")}</DialogTitle>
<DialogDescription>{t("monitor.loginHint")}</DialogDescription>
</DialogHeader>
<form onSubmit={(event) => {
event.preventDefault();
if (!recovery) return;
monitorActions.retry(recovery.hostId, recovery.password ? {username: recovery.username, password: recovery.password} : undefined);
setRecovery(null);
}} className="space-y-4">
<p className="truncate text-sm font-medium">{hostById.get(recovery?.hostId ?? "")?.label}</p>
<div className="space-y-2">
<Label htmlFor="monitor-login-user">{t("monitor.username")}</Label>
<Input id="monitor-login-user" autoComplete="off" value={recovery?.username ?? ""} onChange={(event) => setRecovery(previous => previous && {...previous, username: event.target.value})} />
<Label htmlFor="monitor-login-password">{t("monitor.password")}</Label>
<Input id="monitor-login-password" type="password" autoComplete="new-password" value={recovery?.password ?? ""} onChange={(event) => setRecovery(previous => previous && {...previous, password: event.target.value})} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setRecovery(null)}>{t("common.cancel")}</Button>
<Button type="submit">{t("monitor.retry")}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog
open={!!deleting}
onOpenChange={(open) => !open && setDeleting(null)}
@@ -0,0 +1,63 @@
# Catty prompt cache repair and verification
## Scope and issue history
- Original report: [#3545](https://github.com/binaricat/Netcatty/issues/3545), duplicate [#3546](https://github.com/binaricat/Netcatty/issues/3546).
- [#3547](https://github.com/binaricat/Netcatty/pull/3547) moved static guidelines before scope/session details. That repair remains valid, but its closing comment did not claim a measured DeepSeek result.
- This change starts from main `b5fee9632ea012e5c15fbdae454dec2aa7d32572` and repairs additional cache invalidation and usage-reporting paths.
## Confirmed defects and changes
1. Completed terminal results were rewritten when the next user turn rebuilt history. Keep the already bounded result, with secret redaction, and let context budgeting own necessary reductions.
2. Live SDK results use JSON while saved history uses text. Apply the same bounded compression to both before sending them, avoiding a second, different representation on the next turn.
3. Routine output cleanup inserted the latest goal and pending handles at the start of otherwise intact history. Reserve that reinjection for actual budget pressure or summarization.
4. Large-output handle notices were replaced at the front on every step. Results already containing a handle need no duplicate notice. Announce missing handles once at the tail and preserve prior notices; continue to reserve their budget if older results are pruned.
5. DeepSeek's documented `prompt_cache_hit_tokens` was lost when the OpenAI-compatible SDK parsed responses. Normalize it to the standard cached-token field before parsing, preserving an existing standard value, including zero. Cover streaming and non-streaming responses.
6. Catty omitted cache counts from its usage events and saved assistant messages. Preserve provider counts and attach aggregate usage after the final assistant message has been created.
7. Native Anthropic requests lacked cache breakpoints. Cache the static system block and a bounded conversation tail; leave implicit-cache OpenAI/Google requests untouched and preserve signed reasoning content. Enable these vendor fields only for the official HTTPS Anthropic endpoint (including the SDK's default endpoint), not third-party Messages-compatible gateways. Use the shared resolved SDK address, including OpenRouter/Ollama fallbacks when a stored URL is empty. Review follow-ups add endpoint gating and full-driver gateway/fallback regressions; they do not change the measured DeepSeek path.
## Comparison with mature agents and official guidance
- [Codex prompt-caching tests](https://github.com/openai/codex/blob/8c076942cb70fba8e5c4732c23f1faabc4aed484/codex-rs/core/tests/suite/prompt_caching.rs): exercise stable tools and append-only request prefixes. The new tests compare the actual serialized requests produced by Catty's driver, SDK and bridge adapter across consecutive turns.
- [OpenCode cache policy](https://github.com/anomalyco/opencode/blob/c38d83c0c4f92466ff9c8e12b8a741698e811012/packages/ai/src/cache-policy.ts): use provider-specific explicit cache boundaries where required and leave implicit-cache protocols alone.
- [DeepSeek context caching](https://api-docs.deepseek.com/guides/kv_cache/): exact shared prefixes, asynchronous/best-effort cache persistence and provider-reported hit/miss counts. Live probes wait between turns and use the official endpoint.
- [Anthropic prompt caching](https://platform.claude.com/docs/en/build-with-claude/prompt-caching): explicit boundaries, tool/system/message ordering and a limit of four breakpoints. Catty uses at most three automatic markers.
- [OpenAI prompt caching](https://developers.openai.com/api/docs/guides/prompt-caching): stable shared prefixes. No additional explicit cache-control fields are sent on this path.
## Live verification
The final fixed Catty driver was bundled from the isolated worktree and executed inside the running Electron renderer, using the app's existing IPC and configured `https://api.deepseek.com` service, model `deepseek-v4-flash`. The API key stayed inside the app's credential handling.
Each run contains six consecutive turns for each of three scenarios: plain chat, one command per turn, and a command producing 14,000 synthetic log lines followed by a bounded saved-output read. Model requests and usage are real. Tool execution uses an in-memory fixture; no command is sent to the user's connected SSH hosts. The prompts contain only synthetic session/host information.
The results file records every provider-reported input/cache/output count, per-step counts and errors. The rate is total cached input divided by total input, including all model calls in a turn. It is not an average of rounded percentages.
Two complete runs of the final source produced 36 turns / 72 real model requests, with zero errors:
| Scenario | Turns | Weighted cache hit rate | Per-turn range |
| --- | ---: | ---: | ---: |
| Plain chat | 12 | 98.55% | 98.19%-98.92% |
| Command execution | 12 | 98.59% | 98.12%-99.03% |
| Large command output + saved-output read | 12 | 97.80% | 97.30%-98.32% |
Raw per-turn and per-step counters are in [the JSON evidence file](./catty-prompt-cache-live-2026-10-18.json). These are warm-cache measurements; an earlier probe's first request correctly reported zero cached input before subsequent requests reached about 98%. The earlier probe is excluded from the final-source totals.
The production chat-message component was rendered with the live conversation in Electron. Its usage footer showed input, output, cache and reasoning counts; opening the command entry showed the expected fixture result. No product UI layout was changed.
Limits: live provider evidence covers the configured DeepSeek model. Native Anthropic request formatting and usage preservation are verified with the real SDK and a protocol fixture, not a paid Claude session. Cache hits remain provider-managed; a new prefix, necessary compaction or expired cache can legitimately miss.
## Automated verification
- Full AI/history/UI-related suite after the endpoint review follow-ups: 600 tests passed.
- New regression coverage includes six consecutive plain-chat turns, six command turns, six large-file turns, six large-terminal-output turns, long-output handles, streaming/non-streaming DeepSeek usage, aggregate multi-step usage and native Anthropic boundaries.
- Production build passed.
- Full lint passed with eight existing warnings; final changed-file lint passed without warnings.
- Type checking matches the untouched main baseline: 758 existing errors, zero additional errors after normalizing checkout paths and line numbers.
Full-repository verification also found setup and unrelated timing limits:
- The first run passed 13,968 tests and skipped 36, with one failed file caused by the fresh worktree missing the nested `plugin-cli/node_modules/yauzl` dependency. Linking the existing dependency directory repaired the setup; all 29 CLI tests then passed.
- A repeat full run hit the unchanged `large directory progress coalesces synchronous persistence work` timing assertion (2,638,624 characters persisted against a 588,644-character snapshot). That assertion passed in the prior full run. All 102 tests in its file passed on isolated rerun; the same assertion also passed on untouched main. This is an observed full-run timing fluctuation, not a claimed baseline reproduction or an SFTP repair.
- The Homebrew Node 22 binary is broken locally (missing shared library); verification uses an isolated official npm Node 22 runtime. `TMPDIR=/tmp` selects Netcatty's shorter private per-user temporary directory for Unix-socket tests. The default macOS path exceeded the FIDO test socket limit; no product source was changed to accommodate this environment.
The full suite is therefore not reported as unconditionally green. No Catty/cache test failed in the final implementation.
@@ -0,0 +1,955 @@
{
"date": "2026-10-18",
"endpoint": "https://api.deepseek.com",
"model": "deepseek-v4-flash",
"input": "synthetic prompts and in-memory tool output",
"totalTurns": 36,
"totalModelCalls": 72,
"summary": [
{
"kind": "chat",
"turns": 12,
"inputTokens": 162870,
"cachedTokens": 160512,
"weightedHitRate": 98.55,
"minTurnRate": 98.19,
"maxTurnRate": 98.92
},
{
"kind": "command",
"turns": 12,
"inputTokens": 341856,
"cachedTokens": 337024,
"weightedHitRate": 98.59,
"minTurnRate": 98.12,
"maxTurnRate": 99.03
},
{
"kind": "large-output",
"turns": 12,
"inputTokens": 598639,
"cachedTokens": 585472,
"weightedHitRate": 97.8,
"minTurnRate": 97.3,
"maxTurnRate": 98.32
}
],
"runs": [
{
"model": "deepseek-v4-flash",
"rows": [
{
"kind": "chat",
"round": 1,
"inputTokens": 13500,
"cachedTokens": 13312,
"hitRate": 98.61,
"outputTokens": 21,
"modelCalls": 1,
"toolResults": 0,
"milliseconds": 690
},
{
"kind": "chat",
"round": 2,
"inputTokens": 13529,
"cachedTokens": 13312,
"hitRate": 98.4,
"outputTokens": 4,
"modelCalls": 1,
"toolResults": 0,
"milliseconds": 898
},
{
"kind": "chat",
"round": 3,
"inputTokens": 13558,
"cachedTokens": 13312,
"hitRate": 98.19,
"outputTokens": 4,
"modelCalls": 1,
"toolResults": 0,
"milliseconds": 658
},
{
"kind": "chat",
"round": 4,
"inputTokens": 13587,
"cachedTokens": 13440,
"hitRate": 98.92,
"outputTokens": 4,
"modelCalls": 1,
"toolResults": 0,
"milliseconds": 982
},
{
"kind": "chat",
"round": 5,
"inputTokens": 13616,
"cachedTokens": 13440,
"hitRate": 98.71,
"outputTokens": 4,
"modelCalls": 1,
"toolResults": 0,
"milliseconds": 611
},
{
"kind": "chat",
"round": 6,
"inputTokens": 13645,
"cachedTokens": 13440,
"hitRate": 98.5,
"outputTokens": 4,
"modelCalls": 1,
"toolResults": 0,
"milliseconds": 676
},
{
"kind": "command",
"round": 1,
"inputTokens": 27473,
"cachedTokens": 27008,
"hitRate": 98.31,
"outputTokens": 384,
"modelCalls": 2,
"toolResults": 1,
"milliseconds": 5711
},
{
"kind": "command",
"round": 2,
"inputTokens": 28156,
"cachedTokens": 27776,
"hitRate": 98.65,
"outputTokens": 93,
"modelCalls": 2,
"toolResults": 2,
"milliseconds": 4606
},
{
"kind": "command",
"round": 3,
"inputTokens": 28538,
"cachedTokens": 28160,
"hitRate": 98.68,
"outputTokens": 83,
"modelCalls": 2,
"toolResults": 3,
"milliseconds": 4738
},
{
"kind": "command",
"round": 4,
"inputTokens": 28940,
"cachedTokens": 28544,
"hitRate": 98.63,
"outputTokens": 113,
"modelCalls": 2,
"toolResults": 4,
"milliseconds": 5000
},
{
"kind": "command",
"round": 5,
"inputTokens": 29342,
"cachedTokens": 29056,
"hitRate": 99.03,
"outputTokens": 83,
"modelCalls": 2,
"toolResults": 5,
"milliseconds": 5011
},
{
"kind": "command",
"round": 6,
"inputTokens": 29714,
"cachedTokens": 29312,
"hitRate": 98.65,
"outputTokens": 83,
"modelCalls": 2,
"toolResults": 6,
"milliseconds": 4517
},
{
"kind": "large-output",
"round": 1,
"inputTokens": 42570,
"cachedTokens": 41472,
"hitRate": 97.42,
"outputTokens": 344,
"modelCalls": 3,
"toolResults": 2,
"milliseconds": 6895
},
{
"kind": "large-output",
"round": 2,
"inputTokens": 45647,
"cachedTokens": 44416,
"hitRate": 97.3,
"outputTokens": 163,
"modelCalls": 3,
"toolResults": 4,
"milliseconds": 5644
},
{
"kind": "large-output",
"round": 3,
"inputTokens": 48547,
"cachedTokens": 47488,
"hitRate": 97.82,
"outputTokens": 165,
"modelCalls": 3,
"toolResults": 6,
"milliseconds": 5271
},
{
"kind": "large-output",
"round": 4,
"inputTokens": 51449,
"cachedTokens": 50304,
"hitRate": 97.77,
"outputTokens": 163,
"modelCalls": 3,
"toolResults": 8,
"milliseconds": 5826
},
{
"kind": "large-output",
"round": 5,
"inputTokens": 54353,
"cachedTokens": 53248,
"hitRate": 97.97,
"outputTokens": 166,
"modelCalls": 3,
"toolResults": 10,
"milliseconds": 6235
},
{
"kind": "large-output",
"round": 6,
"inputTokens": 57280,
"cachedTokens": 56320,
"hitRate": 98.32,
"outputTokens": 168,
"modelCalls": 3,
"toolResults": 12,
"milliseconds": 5692
}
],
"steps": [
{
"kind": "chat",
"round": 1,
"promptTokens": 13500,
"cachedTokens": 13312,
"completionTokens": 21
},
{
"kind": "chat",
"round": 2,
"promptTokens": 13529,
"cachedTokens": 13312,
"completionTokens": 4
},
{
"kind": "chat",
"round": 3,
"promptTokens": 13558,
"cachedTokens": 13312,
"completionTokens": 4
},
{
"kind": "chat",
"round": 4,
"promptTokens": 13587,
"cachedTokens": 13440,
"completionTokens": 4
},
{
"kind": "chat",
"round": 5,
"promptTokens": 13616,
"cachedTokens": 13440,
"completionTokens": 4
},
{
"kind": "chat",
"round": 6,
"promptTokens": 13645,
"cachedTokens": 13440,
"completionTokens": 4
},
{
"kind": "command",
"round": 1,
"promptTokens": 13531,
"cachedTokens": 13312,
"completionTokens": 364
},
{
"kind": "command",
"round": 1,
"promptTokens": 13942,
"cachedTokens": 13696,
"completionTokens": 20
},
{
"kind": "command",
"round": 2,
"promptTokens": 14018,
"cachedTokens": 13824,
"completionTokens": 73
},
{
"kind": "command",
"round": 2,
"promptTokens": 14138,
"cachedTokens": 13952,
"completionTokens": 20
},
{
"kind": "command",
"round": 3,
"promptTokens": 14214,
"cachedTokens": 14080,
"completionTokens": 63
},
{
"kind": "command",
"round": 3,
"promptTokens": 14324,
"cachedTokens": 14080,
"completionTokens": 20
},
{
"kind": "command",
"round": 4,
"promptTokens": 14400,
"cachedTokens": 14208,
"completionTokens": 93
},
{
"kind": "command",
"round": 4,
"promptTokens": 14540,
"cachedTokens": 14336,
"completionTokens": 20
},
{
"kind": "command",
"round": 5,
"promptTokens": 14616,
"cachedTokens": 14464,
"completionTokens": 63
},
{
"kind": "command",
"round": 5,
"promptTokens": 14726,
"cachedTokens": 14592,
"completionTokens": 20
},
{
"kind": "command",
"round": 6,
"promptTokens": 14802,
"cachedTokens": 14592,
"completionTokens": 63
},
{
"kind": "command",
"round": 6,
"promptTokens": 14912,
"cachedTokens": 14720,
"completionTokens": 20
},
{
"kind": "large-output",
"round": 1,
"promptTokens": 13558,
"cachedTokens": 13312,
"completionTokens": 243
},
{
"kind": "large-output",
"round": 1,
"promptTokens": 14394,
"cachedTokens": 13696,
"completionTokens": 97
},
{
"kind": "large-output",
"round": 1,
"promptTokens": 14618,
"cachedTokens": 14464,
"completionTokens": 4
},
{
"kind": "large-output",
"round": 2,
"promptTokens": 14705,
"cachedTokens": 14464,
"completionTokens": 63
},
{
"kind": "large-output",
"round": 2,
"promptTokens": 15360,
"cachedTokens": 14720,
"completionTokens": 96
},
{
"kind": "large-output",
"round": 2,
"promptTokens": 15582,
"cachedTokens": 15232,
"completionTokens": 4
},
{
"kind": "large-output",
"round": 3,
"promptTokens": 15669,
"cachedTokens": 15488,
"completionTokens": 63
},
{
"kind": "large-output",
"round": 3,
"promptTokens": 16326,
"cachedTokens": 15616,
"completionTokens": 98
},
{
"kind": "large-output",
"round": 3,
"promptTokens": 16552,
"cachedTokens": 16384,
"completionTokens": 4
},
{
"kind": "large-output",
"round": 4,
"promptTokens": 16639,
"cachedTokens": 16384,
"completionTokens": 63
},
{
"kind": "large-output",
"round": 4,
"promptTokens": 17294,
"cachedTokens": 16640,
"completionTokens": 96
},
{
"kind": "large-output",
"round": 4,
"promptTokens": 17516,
"cachedTokens": 17280,
"completionTokens": 4
},
{
"kind": "large-output",
"round": 5,
"promptTokens": 17603,
"cachedTokens": 17408,
"completionTokens": 63
},
{
"kind": "large-output",
"round": 5,
"promptTokens": 18261,
"cachedTokens": 17536,
"completionTokens": 99
},
{
"kind": "large-output",
"round": 5,
"promptTokens": 18489,
"cachedTokens": 18304,
"completionTokens": 4
},
{
"kind": "large-output",
"round": 6,
"promptTokens": 18576,
"cachedTokens": 18432,
"completionTokens": 63
},
{
"kind": "large-output",
"round": 6,
"promptTokens": 19236,
"cachedTokens": 18560,
"completionTokens": 101
},
{
"kind": "large-output",
"round": 6,
"promptTokens": 19468,
"cachedTokens": 19328,
"completionTokens": 4
}
],
"errors": []
},
{
"model": "deepseek-v4-flash",
"rows": [
{
"kind": "chat",
"round": 1,
"inputTokens": 13500,
"cachedTokens": 13312,
"hitRate": 98.61,
"outputTokens": 57,
"modelCalls": 1,
"toolResults": 0,
"milliseconds": 1278
},
{
"kind": "chat",
"round": 2,
"inputTokens": 13529,
"cachedTokens": 13312,
"hitRate": 98.4,
"outputTokens": 4,
"modelCalls": 1,
"toolResults": 0,
"milliseconds": 658
},
{
"kind": "chat",
"round": 3,
"inputTokens": 13558,
"cachedTokens": 13312,
"hitRate": 98.19,
"outputTokens": 4,
"modelCalls": 1,
"toolResults": 0,
"milliseconds": 809
},
{
"kind": "chat",
"round": 4,
"inputTokens": 13587,
"cachedTokens": 13440,
"hitRate": 98.92,
"outputTokens": 4,
"modelCalls": 1,
"toolResults": 0,
"milliseconds": 958
},
{
"kind": "chat",
"round": 5,
"inputTokens": 13616,
"cachedTokens": 13440,
"hitRate": 98.71,
"outputTokens": 4,
"modelCalls": 1,
"toolResults": 0,
"milliseconds": 857
},
{
"kind": "chat",
"round": 6,
"inputTokens": 13645,
"cachedTokens": 13440,
"hitRate": 98.5,
"outputTokens": 4,
"modelCalls": 1,
"toolResults": 0,
"milliseconds": 1061
},
{
"kind": "command",
"round": 1,
"inputTokens": 27300,
"cachedTokens": 26880,
"hitRate": 98.46,
"outputTokens": 195,
"modelCalls": 2,
"toolResults": 1,
"milliseconds": 4773
},
{
"kind": "command",
"round": 2,
"inputTokens": 27785,
"cachedTokens": 27264,
"hitRate": 98.12,
"outputTokens": 84,
"modelCalls": 2,
"toolResults": 2,
"milliseconds": 4529
},
{
"kind": "command",
"round": 3,
"inputTokens": 28142,
"cachedTokens": 27776,
"hitRate": 98.7,
"outputTokens": 67,
"modelCalls": 2,
"toolResults": 3,
"milliseconds": 4584
},
{
"kind": "command",
"round": 4,
"inputTokens": 28482,
"cachedTokens": 28032,
"hitRate": 98.42,
"outputTokens": 67,
"modelCalls": 2,
"toolResults": 4,
"milliseconds": 4706
},
{
"kind": "command",
"round": 5,
"inputTokens": 28822,
"cachedTokens": 28416,
"hitRate": 98.59,
"outputTokens": 67,
"modelCalls": 2,
"toolResults": 5,
"milliseconds": 4558
},
{
"kind": "command",
"round": 6,
"inputTokens": 29162,
"cachedTokens": 28800,
"hitRate": 98.76,
"outputTokens": 67,
"modelCalls": 2,
"toolResults": 6,
"milliseconds": 4496
},
{
"kind": "large-output",
"round": 1,
"inputTokens": 42428,
"cachedTokens": 41344,
"hitRate": 97.45,
"outputTokens": 270,
"modelCalls": 3,
"toolResults": 2,
"milliseconds": 5945
},
{
"kind": "large-output",
"round": 2,
"inputTokens": 45447,
"cachedTokens": 44416,
"hitRate": 97.73,
"outputTokens": 164,
"modelCalls": 3,
"toolResults": 4,
"milliseconds": 5125
},
{
"kind": "large-output",
"round": 3,
"inputTokens": 48356,
"cachedTokens": 47232,
"hitRate": 97.68,
"outputTokens": 166,
"modelCalls": 3,
"toolResults": 6,
"milliseconds": 5238
},
{
"kind": "large-output",
"round": 4,
"inputTokens": 51279,
"cachedTokens": 50176,
"hitRate": 97.85,
"outputTokens": 167,
"modelCalls": 3,
"toolResults": 8,
"milliseconds": 5535
},
{
"kind": "large-output",
"round": 5,
"inputTokens": 54195,
"cachedTokens": 53120,
"hitRate": 98.02,
"outputTokens": 164,
"modelCalls": 3,
"toolResults": 10,
"milliseconds": 5977
},
{
"kind": "large-output",
"round": 6,
"inputTokens": 57088,
"cachedTokens": 55936,
"hitRate": 97.98,
"outputTokens": 162,
"modelCalls": 3,
"toolResults": 12,
"milliseconds": 5391
}
],
"steps": [
{
"kind": "chat",
"round": 1,
"promptTokens": 13500,
"cachedTokens": 13312,
"completionTokens": 57
},
{
"kind": "chat",
"round": 2,
"promptTokens": 13529,
"cachedTokens": 13312,
"completionTokens": 4
},
{
"kind": "chat",
"round": 3,
"promptTokens": 13558,
"cachedTokens": 13312,
"completionTokens": 4
},
{
"kind": "chat",
"round": 4,
"promptTokens": 13587,
"cachedTokens": 13440,
"completionTokens": 4
},
{
"kind": "chat",
"round": 5,
"promptTokens": 13616,
"cachedTokens": 13440,
"completionTokens": 4
},
{
"kind": "chat",
"round": 6,
"promptTokens": 13645,
"cachedTokens": 13440,
"completionTokens": 4
},
{
"kind": "command",
"round": 1,
"promptTokens": 13531,
"cachedTokens": 13312,
"completionTokens": 191
},
{
"kind": "command",
"round": 1,
"promptTokens": 13769,
"cachedTokens": 13568,
"completionTokens": 4
},
{
"kind": "command",
"round": 2,
"promptTokens": 13829,
"cachedTokens": 13568,
"completionTokens": 80
},
{
"kind": "command",
"round": 2,
"promptTokens": 13956,
"cachedTokens": 13696,
"completionTokens": 4
},
{
"kind": "command",
"round": 3,
"promptTokens": 14016,
"cachedTokens": 13824,
"completionTokens": 63
},
{
"kind": "command",
"round": 3,
"promptTokens": 14126,
"cachedTokens": 13952,
"completionTokens": 4
},
{
"kind": "command",
"round": 4,
"promptTokens": 14186,
"cachedTokens": 13952,
"completionTokens": 63
},
{
"kind": "command",
"round": 4,
"promptTokens": 14296,
"cachedTokens": 14080,
"completionTokens": 4
},
{
"kind": "command",
"round": 5,
"promptTokens": 14356,
"cachedTokens": 14208,
"completionTokens": 63
},
{
"kind": "command",
"round": 5,
"promptTokens": 14466,
"cachedTokens": 14208,
"completionTokens": 4
},
{
"kind": "command",
"round": 6,
"promptTokens": 14526,
"cachedTokens": 14336,
"completionTokens": 63
},
{
"kind": "command",
"round": 6,
"promptTokens": 14636,
"cachedTokens": 14464,
"completionTokens": 4
},
{
"kind": "large-output",
"round": 1,
"promptTokens": 13558,
"cachedTokens": 13312,
"completionTokens": 166
},
{
"kind": "large-output",
"round": 1,
"promptTokens": 14320,
"cachedTokens": 13696,
"completionTokens": 100
},
{
"kind": "large-output",
"round": 1,
"promptTokens": 14550,
"cachedTokens": 14336,
"completionTokens": 4
},
{
"kind": "large-output",
"round": 2,
"promptTokens": 14637,
"cachedTokens": 14464,
"completionTokens": 63
},
{
"kind": "large-output",
"round": 2,
"promptTokens": 15293,
"cachedTokens": 14592,
"completionTokens": 97
},
{
"kind": "large-output",
"round": 2,
"promptTokens": 15517,
"cachedTokens": 15360,
"completionTokens": 4
},
{
"kind": "large-output",
"round": 3,
"promptTokens": 15604,
"cachedTokens": 15360,
"completionTokens": 63
},
{
"kind": "large-output",
"round": 3,
"promptTokens": 16262,
"cachedTokens": 15616,
"completionTokens": 99
},
{
"kind": "large-output",
"round": 3,
"promptTokens": 16490,
"cachedTokens": 16256,
"completionTokens": 4
},
{
"kind": "large-output",
"round": 4,
"promptTokens": 16577,
"cachedTokens": 16384,
"completionTokens": 63
},
{
"kind": "large-output",
"round": 4,
"promptTokens": 17236,
"cachedTokens": 16512,
"completionTokens": 100
},
{
"kind": "large-output",
"round": 4,
"promptTokens": 17466,
"cachedTokens": 17280,
"completionTokens": 4
},
{
"kind": "large-output",
"round": 5,
"promptTokens": 17553,
"cachedTokens": 17408,
"completionTokens": 63
},
{
"kind": "large-output",
"round": 5,
"promptTokens": 18209,
"cachedTokens": 17536,
"completionTokens": 97
},
{
"kind": "large-output",
"round": 5,
"promptTokens": 18433,
"cachedTokens": 18176,
"completionTokens": 4
},
{
"kind": "large-output",
"round": 6,
"promptTokens": 18520,
"cachedTokens": 18304,
"completionTokens": 63
},
{
"kind": "large-output",
"round": 6,
"promptTokens": 19174,
"cachedTokens": 18432,
"completionTokens": 95
},
{
"kind": "large-output",
"round": 6,
"promptTokens": 19394,
"cachedTokens": 19200,
"completionTokens": 4
}
],
"errors": []
}
]
}
+2
View File
@@ -22,6 +22,8 @@ export type MonitorHostState = {
| "disconnected"
| "limit";
at: number;
/** GPU publication can advance independently of the primary sample/history. */
statsAt?: number;
/** Omitted by incremental replies when the receiver already has this sample. */
stats?: MonitorStats | null;
points: MonitorPoint[];
+63
View File
@@ -0,0 +1,63 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
MONITOR_COLUMNS,
clampMonitorColumnWidth,
normalizeMonitorColumnWidths,
normalizeMonitorHiddenColumns,
monitorGridColumns,
} from "./monitorTableLayout";
test("saved column sizes cannot hide data or create unbounded table widths", () => {
const widths = normalizeMonitorColumnWidths({
host: -1,
latency: Infinity,
notes: 100000,
terminal: NaN,
unknown: 400,
});
assert.equal(widths.host, 160);
assert.equal(widths.latency, 90);
assert.equal(widths.notes, 640);
assert.equal(widths.terminal, 56);
assert.equal(Object.keys(widths).length, MONITOR_COLUMNS.length);
assert.deepEqual(
normalizeMonitorColumnWidths(null),
normalizeMonitorColumnWidths({}),
);
for (const column of MONITOR_COLUMNS) {
assert.equal(clampMonitorColumnWidth(column.key, -100), column.min);
assert.equal(
clampMonitorColumnWidth(column.key, 10000),
column.key === "terminal" ? 200 : 640,
);
}
});
test("virtual card rows adapt to narrow panes and do not allocate empty columns", () => {
assert.equal(monitorGridColumns(200, 1000), 1);
assert.equal(monitorGridColumns(575, 1000), 1);
assert.equal(monitorGridColumns(576, 1000), 2);
assert.equal(monitorGridColumns(1248, 1000), 4);
assert.equal(monitorGridColumns(1248, 2), 2);
assert.equal(monitorGridColumns(0, 1000), 1);
});
test("hidden columns always preserve host and terminal and discard unknown settings", () => {
assert.deepEqual(
normalizeMonitorHiddenColumns([
"host",
"terminal",
"cpu",
"cpu",
"unknown",
"status",
]),
["cpu", "status"],
);
assert.deepEqual(normalizeMonitorHiddenColumns(null), []);
assert.deepEqual(normalizeMonitorHiddenColumns({ cpu: true }), []);
assert.equal(
normalizeMonitorHiddenColumns(MONITOR_COLUMNS.map((c) => c.key)).length,
MONITOR_COLUMNS.length - 2,
);
});
+64
View File
@@ -0,0 +1,64 @@
export const MONITOR_COLUMNS = [
{ key: "host", width: 220, min: 160 },
{ key: "latency", width: 90, min: 80 },
{ key: "tags", width: 150, min: 90 },
{ key: "notes", width: 180, min: 110 },
{ key: "cpu", width: 120, min: 120 },
{ key: "memory", width: 120, min: 120 },
{ key: "disks", width: 240, min: 220 },
{ key: "network", width: 150, min: 140 },
{ key: "updated", width: 110, min: 95 },
{ key: "status", width: 100, min: 90 },
{ key: "terminal", width: 56, min: 56 },
] as const;
export type MonitorColumn = (typeof MONITOR_COLUMNS)[number]["key"];
export type MonitorColumnWidths = Record<MonitorColumn, number>;
export const MONITOR_CARD_HEIGHT = 368;
export function clampMonitorColumnWidth(
key: MonitorColumn,
width: number,
): number {
const column = MONITOR_COLUMNS.find((column) => column.key === key)!;
return Number.isFinite(width)
? Math.round(
Math.max(column.min, Math.min(key === "terminal" ? 200 : 640, width)),
)
: column.width;
}
export function normalizeMonitorColumnWidths(
value: unknown,
): MonitorColumnWidths {
const stored =
value && typeof value === "object"
? (value as Record<string, unknown>)
: {};
return Object.fromEntries(
MONITOR_COLUMNS.map((column) => [
column.key,
clampMonitorColumnWidth(
column.key,
typeof stored[column.key] === "number"
? (stored[column.key] as number)
: column.width,
),
]),
) as MonitorColumnWidths;
}
export function monitorGridColumns(width: number, count: number): number {
return Math.max(
1,
Math.min(count, Math.floor((Math.max(0, width) + 16) / 296)),
);
}
/** Only optional columns can be hidden, including when restoring old or corrupt settings. */
export function normalizeMonitorHiddenColumns(value: unknown): MonitorColumn[] {
return MONITOR_COLUMNS.filter(
({ key }) =>
key !== "host" &&
key !== "terminal" &&
Array.isArray(value) &&
value.includes(key),
).map(({ key }) => key);
}
+2 -1
View File
@@ -352,7 +352,8 @@ function createMonitorService({
historyId,
status: entry?.status ?? host.status ?? "limit",
at: shared?.at ?? 0,
...(append && last?.historyId === historyId && last.statsAt === (shared?.at ?? 0)
statsAt: shared?.statsAt ?? shared?.at ?? 0,
...(append && last?.historyId === historyId && last.statsAt === (shared?.statsAt ?? shared?.at ?? 0)
? {}
: { stats: shared?.result?.success ? shared.result.stats : null }),
points: append && last?.historyId === historyId
+21 -1
View File
@@ -280,7 +280,7 @@ test("full terminal readers upgrade a lightweight cached sample and keep subsequ
memTotal: 100,
memUsed: null,
topProcesses: ctx._monitorStats ? [] : [{ name: "full-process" }],
gpu: ctx._monitorStats ? null : 75,
gpu: 75,
},
};
};
@@ -752,3 +752,23 @@ test("snapshot deltas send new points only, even with 256 full retained historie
assert.ok(Object.entries(fresh.hosts).every(([id, host]) => id === "h0" || !Object.hasOwn(host, "stats")));
f.service.dispose();
});
test('a completed GPU probe reaches incremental snapshots before the next CPU sample',async()=>{
const {cacheFullGpuStats}=require('./sshBridge/monitorGpuStats.cjs');
const f=fixture();
try {
f.service.reconcile(event(1),[{...demand(),interval:60}]);
await until(()=>f.service.snapshot(event(1)).hosts.h?.status==='live');
const initial=f.service.snapshot(event(1));
const host=initial.hosts.h;
const cursor={runtimeId:initial.runtimeId,epoch:initial.epoch,after:{h:{at:host.points.at(-1)?.at??0,statsAt:host.statsAt,historyId:host.historyId}}};
cacheFullGpuStats(f.c,{gpu:75,gpuName:'NVIDIA independent',gpuMemUsed:512,gpuMemTotal:8192});
const next=f.service.snapshot(event(1),cursor).hosts.h;
assert.equal(next.stats.gpu,75,'GPU data must not be omitted by the primary sample cursor');
assert.equal(next.at,host.at,'GPU publication must not change CPU freshness');
assert.deepEqual(next.points,[],'GPU publication must not add trend samples');
assert.ok(next.statsAt>host.statsAt);
const caughtUp={...cursor,after:{h:{...cursor.after.h,statsAt:next.statsAt}}};
assert.equal(f.service.snapshot(event(1),caughtUp).hosts.h.stats,undefined,'unchanged GPU data is not repeatedly sent');
} finally {f.service.dispose();}
});
@@ -91,6 +91,54 @@ test("worker IPC installs one owner teardown across all monitor handlers", async
]);
});
test("starting monitoring publishes latency even when SSH keepalives are disabled", async () => {
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
const { selectServerStatsFixtureOutput } = require("./sshBridge/serverStatsTestHelpers.cjs");
const handlers = register();
const e = event(93);
const options = {
hostname: "latency-fixture",
username: "fixture",
password: "fixture-only",
keepaliveInterval: 0,
verifyHostKeys: false,
};
const conn = new EventEmitter();
conn._sock = { destroyed: false };
conn.config = { keepaliveInterval: 0 };
conn.end = () => {};
conn.exec = (command, callback) => {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.write = () => true;
callback(null, stream);
setImmediate(() => {
stream.emit("data", Buffer.from(selectServerStatsFixtureOutput(command,
"CPURAW:1000 900|CORES:4|PERCORERAW:|MEMINFO:8000 4000 100 900 0 0|PROCS:|DISKS:|NET:")));
stream.emit("close", 0);
});
};
const transport = pool.createTransport({ conn, endpoint: pool.buildConnectionReuseEndpoint(options) });
const holder = {};
pool.borrowTransport(transport, { kind: pool.LEASE_KINDS.shell, holder });
try {
await handlers.get("netcatty:monitor:update")(e, [{ id: "latency", hosts: [{ id: "host", options }] }]);
let snapshot;
const deadline = Date.now() + 2000;
do {
await tick();
snapshot = handlers.get("netcatty:monitor:snapshot")(e);
} while (snapshot.hosts.host?.status !== "live" && Date.now() < deadline);
assert.equal(snapshot.hosts.host?.status, "live");
assert.equal(snapshot.hosts.host.stats.memTotal, 8000);
assert.equal(snapshot.hosts.host.stats.latencyMs, null, "keepalive opt-out is preserved");
assert.equal(typeof snapshot.hosts.host.stats.responseLatencyMs, "number");
} finally {
bridge.stopAllMonitoring();
pool.returnTransport(holder);
}
});
test("worker receiving handlers accept the actual IPC sender without WebContents lifecycle methods", async () => {
const { createWorkerSender } = require("../terminalWorker/process.cjs");
@@ -0,0 +1,120 @@
"use strict";
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
const GPU_ASSIGNMENT = `gpustat=$(gpucsv=$(timeout -s KILL 2 nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,name --format=csv,noheader,nounits 2>/dev/null) && printf '%s\\n' "$gpucsv" | awk -F', *' 'NF >= 4 {g++; if($1 ~ /^[0-9]+$/) {n++; u+=$1} if($2 ~ /^[0-9]+$/) {mu+=$2; uc++} if($3 ~ /^[0-9]+$/) {mt+=$3; tc++} if(name=="") name=$4} END{if(n>0) printf "%.0f %s %s %s", u/n, (uc==g ? sprintf("%.0f",mu) : "N/A"), (tc==g ? sprintf("%.0f",mt) : "N/A"), name}' 2>/dev/null || echo "")`;
const EMPTY_GPU = {
gpu: null,
gpuName: null,
gpuMemUsed: null,
gpuMemTotal: null,
};
const states = new WeakMap();
function stateFor(conn) {
let state = states.get(conn);
if (!state) {
state = {
stats: EMPTY_GPU,
nextAt: 0,
pending: null,
controller: null,
updatedAt: 0,
disabled: false,
};
states.set(conn, state);
}
return state;
}
function updateGpuStats(state, next) {
if (Object.keys(EMPTY_GPU).some((key) => state.stats[key] !== next[key])) {
// Publication time is separate from CPU history and strictly advances even
// when a fast GPU reply shares the CPU sample's millisecond.
state.updatedAt = Math.max(Date.now() + 1, state.updatedAt + 1);
}
state.stats = next;
}
function monitorGpuUpdatedAt(conn) {
return states.get(conn)?.updatedAt ?? 0;
}
function parseGpuStats(output) {
const raw =
String(output || "")
.split("|")
.find((part) => part.startsWith("GPU:"))
?.slice(4)
.trim()
.split(/\s+/) ?? [];
const utilization = parseInt(raw[0], 10);
if (!Number.isFinite(utilization)) return EMPTY_GPU;
const number = (value) =>
Number.isFinite(parseInt(value, 10)) ? parseInt(value, 10) : null;
return {
gpu: Math.max(0, Math.min(100, utilization)),
gpuName: raw.slice(3).join(" ") || null,
gpuMemUsed: number(raw[1]),
gpuMemTotal: number(raw[2]),
};
}
function withCachedMonitorGpu(conn, result) {
const state = states.get(conn);
return state && result?.success
? { ...result, stats: { ...result.stats, ...state.stats } }
: result;
}
function cacheFullGpuStats(conn, stats) {
const state = stateFor(conn);
state.controller?.abort();
updateGpuStats(
state,
Object.fromEntries(
Object.keys(EMPTY_GPU).map((key) => [key, stats[key] ?? null]),
),
);
state.nextAt = Date.now() + 10000;
}
/** Optional GPU work never joins the CPU/memory sampler or its history clock. */
function startMonitorGpuProbe(conn, command, options = {}) {
const state = stateFor(conn);
if (state.disabled || state.pending || Date.now() < state.nextAt) return;
const controller = new AbortController();
state.controller = controller;
state.nextAt = Date.now() + 30000;
state.pending = Promise.resolve()
.then(() =>
executeBoundedSshCommand(conn, command, {
...options,
signal: controller.signal,
invalidateTransportOnAbort: false,
invalidateOnOpenTimeout: false,
openingTimeoutMs: 2500,
runTimeoutMs: 2500,
maxOutputBytes: 65536,
}),
)
.then(({ stdout }) => {
if (controller.signal.aborted) return;
updateGpuStats(state, parseGpuStats(stdout));
state.nextAt = Date.now() + (state.stats.gpu == null ? 30000 : 10000);
})
.catch((error) => {
// A timed-out channel open cannot be withdrawn from ssh2's request FIFO.
// Keep this optional probe from disconnecting SSH or piling up requests.
if (error?.code === "SSH_EXEC_OPEN_TIMEOUT") state.disabled = true;
if (!controller.signal.aborted) updateGpuStats(state, EMPTY_GPU);
})
.finally(() => {
state.pending = null;
if (state.controller === controller) state.controller = null;
});
}
function cancelMonitorGpuProbe(conn) {
states.get(conn)?.controller?.abort();
}
module.exports = {
GPU_ASSIGNMENT,
monitorGpuUpdatedAt,
parseGpuStats,
withCachedMonitorGpu,
cacheFullGpuStats,
startMonitorGpuProbe,
cancelMonitorGpuProbe,
};
@@ -0,0 +1,166 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const {
startMonitorGpuProbe,
withCachedMonitorGpu,
cacheFullGpuStats,
cancelMonitorGpuProbe,
} = require("./monitorGpuStats.cjs");
const tick = () => new Promise((resolve) => setImmediate(resolve));
function stream() {
const s = new EventEmitter();
s.stderr = new EventEmitter();
s.close = () => {};
s.destroy = () => {};
return s;
}
const sample = { success: true, stats: { cpu: 12, memTotal: 4096, gpu: null } };
test("GPU work is coalesced, cached independently, and never changes CPU readings", async (t) => {
let now = 100000,
calls = 0,
current;
t.mock.method(Date, "now", () => now);
const conn = {
exec(_command, cb) {
calls++;
current = stream();
cb(null, current);
},
};
t.after(() => cancelMonitorGpuProbe(conn));
startMonitorGpuProbe(conn, "gpu");
startMonitorGpuProbe(conn, "gpu");
await tick();
assert.equal(calls, 1);
assert.equal(withCachedMonitorGpu(conn, sample).stats.cpu, 12);
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpu, null);
current.emit("data", Buffer.from("GPU:75 1024 8192 NVIDIA T4"));
current.emit("close", 0);
await tick();
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpu, 75);
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpuName, "NVIDIA T4");
now += 9999;
startMonitorGpuProbe(conn, "gpu");
await tick();
assert.equal(calls, 1);
now += 1;
startMonitorGpuProbe(conn, "gpu");
await tick();
assert.equal(calls, 2);
current.emit("close", 0);
await tick();
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpu, null);
now += 29000;
startMonitorGpuProbe(conn, "gpu");
await tick();
assert.equal(calls, 2, "missing GPU probes back off");
});
test("cancelling optional GPU work preserves a full reader cache and ignores late results", async (t) => {
let current;
const conn = {
exec(_command, cb) {
current = stream();
cb(null, current);
},
};
startMonitorGpuProbe(conn, "gpu");
await tick();
cacheFullGpuStats(conn, {
gpu: 42,
gpuName: "NVIDIA full",
gpuMemUsed: 512,
gpuMemTotal: 8192,
});
current.emit("data", Buffer.from("GPU:99 1 2 NVIDIA late"));
current.emit("close", 0);
await tick();
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpu, 42);
assert.equal(withCachedMonitorGpu(conn, sample).stats.cpu, 12);
cancelMonitorGpuProbe(conn);
t.after(() => cancelMonitorGpuProbe(conn));
});
test("monitor snapshots include cached GPU without adding history samples or changing sample time", async () => {
const { sampleSharedStats, readSharedStats } = require("./sharedStats.cjs");
const conn = {};
await sampleSharedStats({ conn, _monitorStats: true }, async () => sample);
const before = readSharedStats(conn);
cacheFullGpuStats(conn, {
gpu: 42,
gpuName: "NVIDIA cached",
gpuMemUsed: 1,
gpuMemTotal: 2,
});
const after = readSharedStats(conn);
assert.equal(after.result.stats.gpu, 42);
assert.equal(after.result.stats.cpu, 12);
assert.equal(after.at, before.at);
assert.deepEqual(after.points, before.points);
});
test("stopping GPU work before channel-open closes the late channel without disconnecting SSH", async () => {
let callback,
closed = 0,
disconnected = 0;
const conn = {
exec(_command, cb) {
callback = cb;
},
end() {
disconnected++;
},
};
startMonitorGpuProbe(conn, "gpu");
await tick();
require("./sharedStats.cjs").cancelSharedStats(conn);
const late = stream();
late.close = () => closed++;
callback(null, late);
await tick();
assert.ok(closed > 0);
assert.equal(disconnected, 0);
});
test("optional GPU channel-open timeout neither disconnects SSH nor stacks repeated open requests", async () => {
let callback,
fireTimer,
calls = 0,
disconnected = 0,
closed = 0;
const conn = {
exec(_command, cb) {
calls++;
callback = cb;
},
end() {
disconnected++;
},
destroy() {
disconnected++;
},
};
startMonitorGpuProbe(conn, "gpu", {
setTimeoutFn: (fn) => {
fireTimer = fn;
return 1;
},
clearTimeoutFn: () => {},
});
await tick();
fireTimer();
await tick();
assert.equal(
disconnected,
0,
"optional channel timeout must leave the shared transport intact",
);
startMonitorGpuProbe(conn, "gpu");
await tick();
assert.equal(calls, 1, "one unresolved GPU open per transport at most");
const late = stream();
late.close = () => closed++;
callback(null, late);
await tick();
assert.ok(closed > 0);
});
+30 -25
View File
@@ -1,5 +1,6 @@
/* eslint-disable no-undef */
const { sampleSharedStats } = require("./sharedStats.cjs");
const { GPU_ASSIGNMENT, parseGpuStats, withCachedMonitorGpu, cacheFullGpuStats, startMonitorGpuProbe } = require("./monitorGpuStats.cjs");
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
const { listInteractiveShellPids } = require("../sshInteractiveShells.cjs");
function extraExecUnsupportedError(session) {
@@ -1066,7 +1067,18 @@ function createSessionOpsApi(ctx) {
}
async function getStatsForContext(session, event, sessionId) {
return sampleSharedStats(session, (context) => collectServerStats(context, event, sessionId));
const result = await sampleSharedStats(session, (context) => collectServerStats(context, event, sessionId));
const conn = session.conn || session.moshStatsConn || session.etStatsConn;
if (!conn || !result.success || !result.stats) return result;
if (!session._monitorStats) {
cacheFullGpuStats(conn, result.stats);
return result;
}
if (!session.singleChannelSsh) {
const command = withPosixRemoteWatchdog(`if [ "$(uname -s 2>/dev/null)" = "Linux" ]; then ${GPU_ASSIGNMENT}; echo "GPU:$gpustat"; fi`, 2500);
startMonitorGpuProbe(conn, command, {setTimeoutFn:setTimeout,clearTimeoutFn:clearTimeout});
}
return withCachedMonitorGpu(conn, result);
}
async function collectServerStats(session, event, sessionId) {
@@ -1199,7 +1211,7 @@ function createSessionOpsApi(ctx) {
`uptime=$(awk -v n="$nowsec" -v b="$bootsec" 'BEGIN{if(n>0&&b>0)printf "%.0f",n-b}')`,
`loadavg=$(sysctl -n vm.loadavg 2>/dev/null | tr -d '{}' | awk '{print $1" "$2" "$3"}')`,
`echo "CPU:$cpupct|CORES:$cores|MEMINFO:$memtotal $memfree 0 $memcached $swaptotal $swapfree|PROCS:$procs|DISKS:$disks|NET:$net|HOST:$hostname_value|OS:$osname|KERNEL:$kernel|UPTIME:$uptime|LOAD:$loadavg"`,
].filter(command => !session._monitorStats || (!command.startsWith('procs=') && !command.startsWith('gpustat='))).join('; ');
].filter(command => !session._monitorStats || !command.startsWith('procs=')).join('; ');
// Prefer df's filesystem type column so FUSE mounts can be identified
// even when their source name looks like an ordinary remote path.
@@ -1302,7 +1314,7 @@ function createSessionOpsApi(ctx) {
// Bound GPU queries independently so a stuck driver cannot stop CPU/memory
// polling. Hosts without timeout also omit this optional metric.
// macOS is skipped: powermetrics needs root, which stats must not.
`gpustat=$(gpucsv=$(timeout -s KILL 2 nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,name --format=csv,noheader,nounits 2>/dev/null) && printf '%s\\n' "$gpucsv" | awk -F', *' 'NF >= 4 {g++; if($1 ~ /^[0-9]+$/) {n++; u+=$1} if($2 ~ /^[0-9]+$/) {mu+=$2; uc++} if($3 ~ /^[0-9]+$/) {mt+=$3; tc++} if(name=="") name=$4} END{if(n>0) printf "%.0f %s %s %s", u/n, (uc==g ? sprintf("%.0f",mu) : "N/A"), (tc==g ? sprintf("%.0f",mt) : "N/A"), name}' 2>/dev/null || echo "")`,
GPU_ASSIGNMENT,
// Output all stats (using CPURAW and PERCORERAW instead of CPU and PERCORE)
`echo "CPURAW:$cpuraw|CORES:$cores|PERCORERAW:$percoreraw|MEMINFO:$meminfo|PROCS:$procs|NET:$net|GPU:$gpustat|HOST:$hostname_value|OS:$osname|KERNEL:$kernel|UPTIME:$uptime|LOAD:$loadavg"`
].filter(command => !session._monitorStats || (!command.startsWith('procs=') && !command.startsWith('gpustat='))).join('; ');
@@ -1364,6 +1376,12 @@ function createSessionOpsApi(ctx) {
const pingLatencyPromise = sshStatsConn && !pingDisabled && typeof measureSshPingLatency === 'function'
? Promise.resolve(measureSshPingLatency(sshStatsConn)).catch(() => null)
: Promise.resolve(null);
// Observe the exec acknowledgement already needed for this sample. This
// remains available when global-request keepalives are disabled, without
// sending another ping or opening an additional channel/connection.
// Keep it separate from the transport RTT: channel setup also includes
// remote processing, and shared terminal readers must retain that distinction.
let responseLatencyMs = null;
const formatStatsError = (error) => (
error?.code === "SSH_EXEC_OPEN_TIMEOUT" || error?.code === "SSH_EXEC_RUN_TIMEOUT"
? "Timeout getting server stats"
@@ -1379,7 +1397,13 @@ function createSessionOpsApi(ctx) {
};
void (async () => {
try {
const { stdout } = await executeStatsCommand(statsCommand);
const responseStartedAt = performance.now();
const { stdout } = await executeStatsCommand(statsCommand, {
...statsExecOptions,
onStream: () => {
if (sshStatsConn) responseLatencyMs = Math.max(0, Math.round(performance.now() - responseStartedAt));
},
});
if (settled) return;
let combinedStdout = String(stdout || '').trim();
const primaryOutput = combinedStdout.replace(new RegExp(`^${latencyMarker}\\|?`), '');
@@ -1555,27 +1579,7 @@ function createSessionOpsApi(ctx) {
}
}
} else if (part.startsWith('GPU:')) {
// Linux: "<avgUtil> <sumMemUsed> <sumMemTotal> <first GPU name>"
const gpuStr = part.substring(4).trim();
if (gpuStr && gpuStr !== '') {
const gpuFields = gpuStr.split(/\s+/);
const util = parseInt(gpuFields[0], 10);
if (!isNaN(util)) {
gpu = Math.min(100, Math.max(0, util));
if (gpuFields.length >= 2) {
const memUsed = parseInt(gpuFields[1], 10);
if (!isNaN(memUsed)) gpuMemUsed = memUsed;
}
if (gpuFields.length >= 3) {
const memTotal = parseInt(gpuFields[2], 10);
if (!isNaN(memTotal)) gpuMemTotal = memTotal;
}
if (gpuFields.length >= 4) {
const name = gpuFields.slice(3).join(' ').trim();
if (name) gpuName = name;
}
}
}
({gpu,gpuName,gpuMemUsed,gpuMemTotal}=parseGpuStats(part));
} else if (part.startsWith('HOST:')) {
hostname = part.substring(5).trim();
} else if (part.startsWith('OS:')) {
@@ -1744,6 +1748,7 @@ function createSessionOpsApi(ctx) {
netRxSpeed, // Total network receive speed (bytes/sec)
netTxSpeed, // Total network transmit speed (bytes/sec)
latencyMs, // SSH transport ping on the existing session (ms); never a second TCP dial (#3630)
responseLatencyMs, // Existing stats exec acknowledgement (ms), including remote channel setup
netInterfaces, // Per-interface network stats
hostname,
osName,
@@ -1,6 +1,6 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { spawnSync } = require("node:child_process");
const { spawn, spawnSync } = require("node:child_process");
const { EventEmitter } = require("node:events");
const { mkdtempSync, writeFileSync, rmSync } = require("node:fs");
const { join } = require("node:path");
@@ -682,7 +682,8 @@ for (const scenario of [
{ name: "missing timeout tool", script: "exec sleep 20", gpu: null, noTimeout: true },
{ name: "blocked GPU query", script: "trap '' TERM; exec sleep 20", gpu: null },
]) {
test(`real stats shell preserves other metrics with ${scenario.name}`, async (t) => {
for (const monitorOnly of [false, true]) {
test(`real stats shell preserves other metrics with ${scenario.name} (${monitorOnly ? "monitor-only" : "terminal"})`, async (t) => {
if (spawnSync("timeout", ["-s", "KILL", "2", "true"]).status !== 0) {
t.skip("GNU/BusyBox-compatible timeout is unavailable on this test host");
return;
@@ -690,11 +691,17 @@ for (const scenario of [
const dir = mkdtempSync(getTempFilePath("gpu-stats-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
writeFileSync(join(dir, "nvidia-smi"), `#!/bin/sh\n${scenario.script}\n`, { mode: 0o755 });
let finishGpu;
const gpuDone = new Promise(resolve => {finishGpu = resolve;});
let gpuCalls = 0;
const sessions = new Map([["sid", {
type: "ssh",
_monitorStats: monitorOnly,
conn: {
exec(command, cb) {
const execution = spawnSync("sh", ["-c", [
const gpuOnly = !command.includes("NC_LATENCY_MARK") && command.includes("nvidia-smi");
if(gpuOnly) gpuCalls++;
const child = spawn("sh", ["-c", [
"uname() { printf '%s\\n' Linux; }",
"nproc() { printf '%s\\n' 4; }",
"top() { return 1; }",
@@ -703,22 +710,39 @@ for (const scenario of [
? unwrapExecShC(command).replace("timeout -s KILL 2 nvidia-smi", "nc_missing_timeout -s KILL 2 nvidia-smi")
: unwrapExecShC(command),
].join("\n")], {
encoding: "utf8",
env: { ...process.env, PATH: `${dir}:${process.env.PATH}` },
timeout: 7000,
});
assert.equal(execution.status, 0, execution.stderr);
cb(null, fakeStream(execution.stdout));
let stdout="", stderr="";
child.stdout.on("data", data => {stdout+=data;});
child.stderr.on("data", data => {stderr+=data;});
child.on("error", cb);
child.on("close", code => {
assert.equal(code,0,stderr);
cb(null,fakeStream(stdout));
if(gpuOnly)finishGpu();
});
},
},
}]]);
const result = await makeSessionOps(sessions).getServerStats({ sender: {} }, { sessionId: "sid" });
const api=makeSessionOps(sessions);
const sampleStarted=Date.now();
let result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
if(monitorOnly){
if(scenario.name === "blocked GPU query")assert.ok(Date.now()-sampleStarted<1500,"a hung GPU must not add its two-second timeout to CPU samples");
// Main readings arrive independently, before the optional GPU query ends.
assert.equal(result.stats.gpu,null);
await gpuDone;
await new Promise(resolve=>setImmediate(()=>setImmediate(resolve)));
result=await api.getServerStats({sender:{}},{sessionId:"sid"});
assert.equal(gpuCalls,1,"cached GPU fields must not open another probe");
}
assert.equal(result.success, true);
assert.equal(result.stats.cpuCores, 4);
assert.equal(result.stats.gpu, scenario.gpu);
assert.equal(result.stats.gpuMemUsed, scenario.used ?? null);
assert.equal(result.stats.gpuMemTotal, scenario.total ?? null);
});
}
}
test("getServerStats measures latency by pinging the stats connection", async () => {
@@ -1406,6 +1430,7 @@ for (const keepaliveInterval of [0, 10000]) {
assert.equal(result.success, true);
assert.equal(pingCalls, keepaliveInterval === 0 ? 0 : 1);
assert.equal(result.stats.latencyMs, keepaliveInterval === 0 ? null : 2);
assert.equal(Number.isFinite(result.stats.responseLatencyMs), true);
});
}
@@ -1449,3 +1474,25 @@ test("real macOS stats shell parses the root disk without broken awk escaping",
assert.equal(result.stats.disks[0].capacityKey, "apfs:/dev/disk3");
assert.equal(result.stats.diskPercent, 50);
});
test("monitor CPU samples return while an optional GPU driver is still blocked", async () => {
let finishGpu, gpuCalls = 0, result;
const c = {exec(command, callback) {
if (command.includes('nvidia-smi')) {
gpuCalls++;
finishGpu = () => callback(null, fakeStream('GPU:50 1024 8192 NVIDIA test'));
} else callback(null, fakeStream(selectServerStatsFixtureOutput(command, LINUX_STATS)));
}};
const api = makeSessionOps(new Map());
const pending=api.getStatsForContext({conn:c,_monitorStats:true,_monitorIntervalMs:1}).then(value=>{result=value;});
try {
for(let i=0;i<10;i++)await new Promise(resolve=>setImmediate(resolve));
assert.ok(result?.success, 'CPU and memory must not wait for the GPU probe');
assert.equal(result.stats.memTotal,8000);
assert.equal(gpuCalls,1);
} finally {
finishGpu?.();
require("./sharedStats.cjs").cancelSharedStats(c);
await pending;
}
});
+4 -2
View File
@@ -1,5 +1,6 @@
"use strict";
// One in-memory sampler per physical authenticated transport. No persistence/logging.
const { withCachedMonitorGpu, cancelMonitorGpuProbe, monitorGpuUpdatedAt } = require("./monitorGpuStats.cjs");
const { buildEndpointKey } = require("../sshConnectionPool.cjs");
let states = new WeakMap();
const identities = new Map();
@@ -100,7 +101,7 @@ async function sampleSharedStats(session, collect, now = Date.now()) {
appendPoint(state, result, state.at, session._monitorFreshAfter);
return result;
}
// A terminal needs process/GPU fields omitted from a monitor-only query.
// A terminal needs process and GPU fields omitted from a monitor-only query.
// Finish the shared lightweight query, then upgrade once for all full readers.
await state.pending;
return sampleSharedStats(session, collect);
@@ -156,9 +157,10 @@ function readSharedStats(conn) {
const state = stateFor(conn);
if (state.points[0] && Date.now() - state.points[0].at > 30 * 60000)
state.points = state.points.filter((p) => Date.now() - p.at <= 30 * 60000);
return { result: state.latest, at: state.at, points: state.points };
return { result: state.latestFull ? state.latest : withCachedMonitorGpu(conn, state.latest), at: state.at, statsAt: Math.max(state.at, monitorGpuUpdatedAt(conn)), points: state.points };
}
function cancelSharedStats(conn) {
cancelMonitorGpuProbe(conn);
const state = states.get(conn);
if (state && !state.terminalJoined) state.controller?.abort();
}
@@ -86,6 +86,7 @@ for (const keepaliveInterval of [10000, 0]) {
assert.equal(forwardPort, 43210);
if (keepaliveInterval > 0) assert.equal(Number.isFinite(stats.stats.latencyMs), true);
else assert.equal(stats.stats.latencyMs, null);
assert.equal(Number.isFinite(stats.stats.responseLatencyMs), true);
await new Promise((resolve, reject) => conn.unforwardIn("127.0.0.1", forwardPort,
(err) => err ? reject(err) : resolve()));
}
@@ -35,11 +35,24 @@ async function capture(file, selector) {
fs.writeFileSync(file, (await owner.webContents.capturePage(bounds)).toPNG());
}
async function click(selector) {
// Electron requires native focus before sendInputEvent can deliver a click.
app.focus({ steal: true }); owner.focus(); owner.webContents.focus();
await until(() => owner.isFocused(), true, 'acceptance window has native focus for click');
const rect = await run(`(() => { const element = document.querySelector(${JSON.stringify(selector)}); element?.scrollIntoView({block:'nearest'}); const r = element?.getBoundingClientRect(); return r && {x:r.x+r.width/2,y:r.y+r.height/2}; })()`);
assert.ok(rect, `missing ${selector}`);
owner.webContents.sendInputEvent({ type: 'mouseMove', x: Math.round(rect.x), y: Math.round(rect.y) });
await run('new Promise(resolve => requestAnimationFrame(resolve))');
for (const type of ['mouseDown', 'mouseUp']) owner.webContents.sendInputEvent({ type, button: 'left', clickCount: 1, x: Math.round(rect.x), y: Math.round(rect.y) });
await run('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
}
async function pressEscape() {
app.focus({ steal: true }); owner.focus(); owner.webContents.focus();
await until(() => owner.isFocused(), true, 'acceptance window has native focus for Escape');
await run('new Promise(resolve => requestAnimationFrame(resolve))');
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
owner.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' });
await run('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
}
async function input(selector, value) {
await until(() => run(`!!document.querySelector(${JSON.stringify(selector)})`), true, `input exists: ${selector}`);
await run(`(() => { const element = document.querySelector(${JSON.stringify(selector)}); Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(element,${JSON.stringify(value)}); element.dispatchEvent(new Event('input',{bubbles:true})); })()`);
@@ -93,6 +106,65 @@ async function main() {
assert.ok(root); assert.equal(root.children.length, 1, 'only the website is a separate native view');
const page = root.children[0].webContents;
await page.executeJavaScript("document.getElementById('draft').value='Unsaved browser input'");
// A visible but stationary browser must not spend every frame reading the
// whole renderer. Settle startup/FontFaceSet work before observing idle time.
await wait(300);
const assertIdleLayout = async () => {
await run(`(() => {
const query=document.querySelector.bind(document),queryAll=document.querySelectorAll.bind(document),style=getComputedStyle;
window.layoutReads={queries:0,styles:0};
document.querySelector=(...args)=>{window.layoutReads.queries++;return query(...args)};
document.querySelectorAll=(...args)=>{window.layoutReads.queries++;return queryAll(...args)};
window.getComputedStyle=(...args)=>{window.layoutReads.styles++;return style(...args)};
window.restoreLayoutReads=()=>{document.querySelector=query;document.querySelectorAll=queryAll;window.getComputedStyle=style;};
})()`);
await wait(250);
const reads=await run('window.restoreLayoutReads();window.layoutReads');
assert.deepEqual(reads,{queries:0,styles:0},'idle browser leaves no repeated overlay/style scans');
};
await assertIdleLayout();
// Position-only CSS motion does not fire ResizeObserver on the viewport.
// Follow its real animation frames, then release the frame loop again.
await run(`(() => {const parent=document.querySelector('[data-section=host-browser-root]').parentElement;parent.id='moving-browser-fixture';parent.style.transition='transform 300ms linear';parent.style.transform='translateX(32px)';})()`);
const positions=[];
for(let index=0;index<15;index++){await wait(25);positions.push(root.getBounds().x);}
assert.ok(new Set(positions).size>=4,'the native browser follows intermediate transform positions');
const expectedX=await run('Math.round(document.querySelector("[data-section=host-browser-panel]").getBoundingClientRect().x)');
assert.equal(root.getBounds().x,expectedX,'native browser reaches the final CSS position');
await assertIdleLayout();
await run('document.getElementById("moving-browser-fixture").style.transform="none"');await wait(350);
// Exercise continuous pointer-driven size updates through the same hook and
// native view. No application session or real SSH host is involved.
await run(`(() => {
const handle=document.createElement('div');handle.id='browser-drag-fixture';handle.style.cssText='position:fixed;left:2px;top:100px;width:8px;height:200px;z-index:99999';
const parent=document.getElementById('moving-browser-fixture');
handle.onmousedown=()=>{const move=event=>{parent.style.width=Math.max(300,event.clientX-parent.getBoundingClientRect().x)+'px'};const up=()=>{window.removeEventListener('mousemove',move);window.removeEventListener('mouseup',up)};window.addEventListener('mousemove',move);window.addEventListener('mouseup',up)};
document.body.append(handle);
})()`);
owner.webContents.sendInputEvent({type:'mouseDown',button:'left',clickCount:1,x:6,y:150});
for(const x of [850,750,650,550]){owner.webContents.sendInputEvent({type:'mouseMove',x,y:150});await wait(30);}
owner.webContents.sendInputEvent({type:'mouseUp',button:'left',clickCount:1,x:550,y:150});await wait(80);
const expectedWidth=await run('Math.round(document.querySelector("[data-section=host-browser-panel]").getBoundingClientRect().width)');
assert.equal(root.getBounds().width,expectedWidth,'drag-driven viewport width reaches the native browser');
assert.ok(expectedWidth<600,'the pointer drag actually resized the fixture');
await run('document.getElementById("moving-browser-fixture").style.width="";document.getElementById("browser-drag-fixture").remove()');await wait(100);
await run('document.documentElement.style.colorScheme="light";document.documentElement.style.setProperty("--background","40 70% 90%")');
await until(()=>manager.getPanelState(event,browserId).theme,'light','theme reaches native browser without a frame poll');
await run('document.documentElement.style.colorScheme="dark";document.documentElement.style.setProperty("--background","210 25% 18%")');
await until(()=>manager.getPanelState(event,browserId).theme,'dark','theme restores');
await run(`document.body.insertAdjacentHTML('beforeend','<div id="modal-layout-fixture" role="dialog" aria-modal="true" style="position:fixed;left:0;top:0;width:1px;height:1px"></div>')`);
await until(()=>owner.contentView.children.includes(root),false,'modal beside the viewport still covers native content');
await run('document.getElementById("modal-layout-fixture").remove()');
await until(()=>owner.contentView.children.includes(root),true,'closing modal restores native content');
await run(`document.body.insertAdjacentHTML('beforeend','<div id="lock-layout-fixture" data-app-lock-overlay></div>')`);
await until(()=>owner.contentView.children.includes(root),false,'app lock hides native content');
await run('document.getElementById("lock-layout-fixture").remove()');
await until(()=>owner.contentView.children.includes(root),true,'unlock restores native content');
assert.equal(await page.executeJavaScript("document.getElementById('draft').value"),'Unsaved browser input');
for (let index = 0; index < 2; index++) {
app.focus({ steal: true });
owner.focus(); owner.webContents.focus();
@@ -110,7 +182,7 @@ async function main() {
assert.equal(owner.contentView.children.includes(root), false, 'the split menu is above the native content');
await click(`#choice-${index}-0`);
await until(() => run(`document.getElementById('choice-${index}-0').dataset.chosen`), 'true', 'split menu choices are clickable');
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
await pressEscape();
owner.webContents.sendInputEvent({ type: 'mouseMove', x: 980, y: 740 });
await until(() => owner.contentView.children.includes(root), true, 'closing menu restores live content');
assert.equal(await page.executeJavaScript("document.getElementById('draft').value"), 'Unsaved browser input');
@@ -204,7 +276,7 @@ async function main() {
await until(() => owner.contentView.children.includes(root), true, 'bookmark navigation attaches the website again');
await click('[aria-label="下载"]');
await until(() => run('document.body.textContent.includes("暂无下载")'), true, 'downloads use the app popover');
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
await pressEscape();
fs.writeFileSync(path.join(downloadsPath, 'report.txt'), 'Keep the existing file');
const activePage = root.children[0].webContents;
await activePage.executeJavaScript("document.getElementById('download').click()");
@@ -218,15 +290,15 @@ async function main() {
await until(() => fs.existsSync(savedCopy), true, 'Save as writes a separate copy');
assert.equal(fs.readFileSync(savedCopy, 'utf8'), 'Netcatty automatic download\n');
assert.equal(fs.existsSync(path.join(downloadsPath, 'report (1).txt')), true, 'Save as retains the original downloaded file');
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
await pressEscape();
await click('[title="在文件夹中显示"]');
assert.deepEqual(shellActions.at(-1), ['reveal', path.join(downloadsPath, 'report (1).txt')]);
await click('[aria-label="打开下载文件夹"]');
assert.deepEqual(shellActions.at(-1), ['open', downloadsPath]);
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
await pressEscape();
await activePage.executeJavaScript("document.getElementById('download').click()");
await until(() => run('document.body.textContent.includes("report (2).txt") && document.querySelectorAll("[data-download-id]").length === 2'), true, 'a second download also gets a new name and reopens the list');
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
await pressEscape();
activePage.downloadURL(`${url}slow.bin`);
await until(() => run('!!document.querySelector("[aria-label=取消下载]") && document.body.textContent.includes("正在下载")'), true, 'progress is visible while the server is still sending the file');
await until(() => !owner.contentView.children.includes(root), true, 'the progress popover has acquired the webpage surface');
@@ -237,7 +309,7 @@ async function main() {
await click('[aria-label="下载"]');
await click('[aria-label="取消下载"]');
await until(() => run('document.body.textContent.includes("已取消")'), true, 'download can be cancelled from the list');
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
await pressEscape();
await activePage.executeJavaScript("(() => { const a=document.createElement('a');a.href='/inline.pdf';document.body.append(a);a.click();a.remove(); })()");
await until(() => run('document.body.textContent.includes("慢速报告.pdf") && !!document.querySelector("[aria-label=取消下载]") && document.body.textContent.includes("正在下载")'), true, 'inline PDF navigation opens progress before the response finishes');
assert.equal(pendingPdf.writableEnded, false, 'the server has not sent the rest of the PDF');
@@ -246,7 +318,7 @@ async function main() {
await capture(pdfProgressScreenshot);
await click('[aria-label="取消下载"]');
await until(() => pendingPdf.destroyed, true, 'cancelling an inline PDF stops the network transfer');
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
await pressEscape();
pendingPdf = undefined;
await input('[aria-label="输入网址"]', `${url}inline.pdf`);
await run('document.querySelector("[aria-label=输入网址]").form.requestSubmit()');
@@ -258,11 +330,19 @@ async function main() {
assert.equal(await run('document.body.textContent.includes("ERR_FAILED")'), false, 'address-bar downloads do not show a false page error');
const downloadScreenshot=path.join(userData,'automatic-downloads.png');
await capture(downloadScreenshot);
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
// Close the list through its own trigger before testing independent
// navigation focus. A prior Escape may leave the baseline list open.
await click('[aria-label="下载"]');
await until(() => run('!!document.querySelector("[data-radix-popper-content-wrapper] > [role=dialog]")'), false, 'download list is closed before starting navigation');
await input('[aria-label="输入网址"]', `${url}slow-page`);
await run('document.querySelector("[aria-label=输入网址]").form.requestSubmit()');
await until(() => !!pendingNavigation, true, 'slow navigation is waiting for its response to finish');
await click('[aria-label="新标签页"]');
assert.equal(activePage.isLoading(), true, 'the response remains unfinished while switching tabs');
// Exercise the native page shortcut while its response remains unfinished.
// The new-tab button is covered above with the same real mouse input helper.
activePage.focus();
activePage.sendInputEvent({ type: 'keyDown', keyCode: 'T', modifiers: [process.platform === 'darwin' ? 'meta' : 'control'] });
activePage.sendInputEvent({ type: 'keyUp', keyCode: 'T', modifiers: [process.platform === 'darwin' ? 'meta' : 'control'] });
await until(() => run('document.activeElement.getAttribute("aria-label")'), '输入网址', 'new tab receives input focus while the previous page is loading');
await input('[aria-label="输入网址"]', 'keep my address draft');
pendingNavigation.end();
@@ -289,7 +369,7 @@ async function main() {
const screenshot = path.join(userData, 'unified-browser.png');
fs.writeFileSync(screenshot, (await owner.webContents.capturePage()).toPNG());
assert.deepEqual(errors, [], 'no renderer errors');
console.log(JSON.stringify({ ok: true, rendererChrome: true, hoverKeepsPageLive: true, splitMenusClickable: true, tabs: true, addressAndHistory: true, shortcuts: true, bookmarks: true, iconUpload: true, imageResize: true, imageReplaceAndRemove: true, editPersistence: true, downloads: true, automaticDownloads: true, duplicateNames: true, saveAs: true, downloadProgressAndCancel: true, inlinePdfProgressAndCancel: true, hiddenRestore: true, narrowLayout: true, locales: 5, screenshot, editorScreenshot, editorTextScreenshot, downloadScreenshot, pdfProgressScreenshot }));
console.log(JSON.stringify({ ok: true, rendererChrome: true, idleLayout: true, animatedLayout: true, dragLayout: true, themeLayout: true, modalAndLockLayout: true, hoverKeepsPageLive: true, splitMenusClickable: true, tabs: true, addressAndHistory: true, shortcuts: true, bookmarks: true, iconUpload: true, imageResize: true, imageReplaceAndRemove: true, editPersistence: true, downloads: true, automaticDownloads: true, duplicateNames: true, saveAs: true, downloadProgressAndCancel: true, inlinePdfProgressAndCancel: true, hiddenRestore: true, narrowLayout: true, locales: 5, screenshot, editorScreenshot, editorTextScreenshot, downloadScreenshot, pdfProgressScreenshot }));
}
app.whenReady().then(main).catch(async (error) => { console.error(error); if (owner && !owner.isDestroyed()) { const screenshot=path.join(userData,'failure.png'); fs.writeFileSync(screenshot,(await owner.webContents.capturePage()).toPNG()); console.error(screenshot); } process.exitCode = 1; }).finally(async () => {
clearTimeout(watchdog);
@@ -30,7 +30,7 @@ export interface CattyStreamChunk {
stepNumber?: number;
}
const STEP_HANDLE_NOTICE_RE = /^\[step \d+\] Tool output handles available:/;
const STEP_HANDLE_NOTICE_RE = /^(?:\[step \d+\] Tool output handles available:|\[Tool output handles available:)/;
function isStepHandleNoticeMessage(content: unknown): boolean {
return typeof content === 'string' && STEP_HANDLE_NOTICE_RE.test(content);
@@ -0,0 +1,251 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { CattyTurnDriver } from './turnDrivers/cattyTurnDriver';
import { ToolOutputStore } from './toolOutputStore';
import { ToolResultDedup } from './toolResultDedup';
import { SessionStateStore } from './sessionState';
import type { CattyTurnInput, TurnDriverContext } from './turnDrivers/types';
import type { AgentEvent } from './types';
import type { AISession, ChatMessage, ProviderConfig } from '../types';
interface WireMessage {
role: string;
content: unknown;
}
interface WireRequest {
messages: WireMessage[];
tools?: unknown[];
system?: Array<{ type: string; text: string; cache_control?: unknown }>;
}
type Reply = { tool: 'terminal_execute' | 'sftp_read_file' } | { text: string };
function fixture(t: test.TestContext, replies: Reply[], style: 'openai' | 'anthropic' = 'openai', terminalOutput = 'small output') {
const originalWindow = globalThis.window;
const originalRaf = globalThis.requestAnimationFrame;
const originalCancelRaf = globalThis.cancelAnimationFrame;
t.after(() => {
globalThis.window = originalWindow;
globalThis.requestAnimationFrame = originalRaf;
globalThis.cancelAnimationFrame = originalCancelRaf;
});
globalThis.requestAnimationFrame = callback => setTimeout(() => callback(0), 0) as unknown as number;
globalThis.cancelAnimationFrame = id => clearTimeout(id);
const requests: WireRequest[] = [];
const urls: string[] = [];
const events: AgentEvent[] = [];
const messages: ChatMessage[] = [];
const data = new Map<string, (value: string) => void>();
const end = new Map<string, () => void>();
const bridge = {
aiSetChatSessionCancelled: async () => ({ ok: true }),
aiMcpUpdateSessions: async () => undefined,
aiExec: async () => ({ ok: true, stdout: terminalOutput, stderr: '', exitCode: 0 }),
aiCapability: async (_method: string, params: Record<string, unknown>) => ({
ok: true,
path: params.path,
content: Array.from({ length: 2000 }, (_, i) => `log ${i}: synthetic ${String(params.path)}`).join('\n'),
}),
onAiStreamData: (id: string, callback: (value: string) => void) => {
data.set(id, callback);
return () => { data.delete(id); };
},
onAiStreamEnd: (id: string, callback: () => void) => {
end.set(id, callback);
return () => { end.delete(id); };
},
onAiStreamError: () => () => undefined,
aiChatCancel: async () => true,
aiChatStream: async (id: string, url: string, _headers: Record<string, string>, body: string) => {
urls.push(url);
requests.push(JSON.parse(body) as WireRequest);
const reply = replies[requests.length - 1];
assert.ok(reply, 'unexpected model request');
const send = (chunk: unknown) => data.get(id)?.(JSON.stringify(chunk));
if (style === 'anthropic') {
send({ type: 'message_start', message: {
id: `response-${requests.length}`, type: 'message', role: 'assistant', model: 'claude-sonnet-4-5',
content: [], stop_reason: null, stop_sequence: null,
usage: { input_tokens: 100, output_tokens: 0, cache_read_input_tokens: 900, cache_creation_input_tokens: 0 },
} });
send({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } });
send({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'OK' } });
send({ type: 'content_block_stop', index: 0 });
send({ type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 1 } });
send({ type: 'message_stop' });
} else {
const toolReply = 'tool' in reply;
const delta = toolReply ? {
role: 'assistant',
tool_calls: [{ index: 0, id: `call-${requests.length}`, type: 'function', function: {
name: reply.tool,
arguments: JSON.stringify(reply.tool === 'terminal_execute'
? { sessionId: 'terminal-1', command: `printf audit-${requests.length}` }
: { sessionId: 'terminal-1', path: `/fixture/log-${requests.length}` }),
} }],
} : { role: 'assistant', content: reply.text };
send({ id: 'response', object: 'chat.completion.chunk', created: 0, model: 'test-model',
choices: [{ index: 0, delta, finish_reason: null }] });
send({ id: 'response', object: 'chat.completion.chunk', created: 0, model: 'test-model',
choices: [{ index: 0, delta: {}, finish_reason: toolReply ? 'tool_calls' : 'stop' }] });
send({ id: 'response', object: 'chat.completion.chunk', created: 0, model: 'test-model', choices: [],
usage: { prompt_tokens: 1000, completion_tokens: 1, total_tokens: 1001,
prompt_cache_hit_tokens: 900, prompt_cache_miss_tokens: 100 } });
data.get(id)?.('[DONE]');
}
end.get(id)?.();
return { ok: true, statusCode: 200 };
},
};
globalThis.window = { netcatty: bridge } as unknown as Window & typeof globalThis;
const store = new ToolOutputStore();
const dedup = new ToolResultDedup();
const state = new SessionStateStore();
const provider: ProviderConfig = {
id: 'fixture', providerId: 'custom', name: 'Fixture', enabled: true, style,
defaultModel: style === 'anthropic' ? 'claude-sonnet-4-5' : 'test-model',
baseURL: style === 'anthropic' ? 'https://api.anthropic.com' : 'https://provider.example.test/v1', apiKey: 'not-a-real-key',
};
let turnNumber = 0;
return {
requests, urls, events, messages, provider,
async turn(maxIterations = 1) {
turnNumber += 1;
const history = structuredClone(messages);
const userText = `Audit round ${turnNumber}`;
const assistantId = `assistant-${turnNumber}`;
messages.push({ id: `user-${turnNumber}`, role: 'user', content: userText, timestamp: turnNumber },
{ id: assistantId, role: 'assistant', content: '', timestamp: turnNumber });
const currentSession: AISession = {
id: 'chat-1', title: 'Fixture', agentId: 'catty', scope: { type: 'terminal', targetId: 'terminal-1' },
createdAt: 0, updatedAt: turnNumber, messages: history,
};
const signal = new AbortController().signal;
const input: CattyTurnInput = {
backend: 'catty', chatSessionId: 'chat-1', sendScopeKey: 'chat-1', userText, signal,
currentSession, assistantMsgId: assistantId, maxIterations, bridge,
context: {
activeProvider: provider, activeModelId: provider.defaultModel!, scopeType: 'terminal', scopeLabel: 'Fixture',
globalPermissionMode: 'auto', commandBlocklist: [], autoTitleSession: () => undefined,
terminalSessions: [{ sessionId: 'terminal-1', hostname: 'fixture.example.test', label: 'Fixture', connected: true }],
},
ui: {
addMessageToSession: (_session, message) => { messages.push(message); },
updateMessageById: (_session, id, update) => {
const index = messages.findIndex(message => message.id === id);
if (index >= 0) messages[index] = update(messages[index]);
},
updateLastMessage: () => undefined,
reportStreamError: (_session, _signal, error) => { throw error; },
setStreamingForScope: () => undefined,
},
};
const context: TurnDriverContext = {
turnId: `turn-${turnNumber}`, chatSessionId: 'chat-1', sessionId: 'chat-1', backend: 'catty', signal,
emit: event => { events.push({
...event, sessionId: 'chat-1', chatSessionId: 'chat-1', backend: 'catty', timestamp: Date.now(),
} as AgentEvent); }, toolOutputStore: store, toolResultDedup: dedup, sessionStateStore: state,
};
dedup.beginTurn();
await new CattyTurnDriver().run(input, context);
},
};
}
function assertRequestPrefix(previous: WireRequest, next: WireRequest) {
assert.deepEqual(next.messages.slice(0, previous.messages.length), previous.messages,
'already-sent conversation must remain an exact prefix');
assert.deepEqual(next.tools, previous.tools);
}
test('Catty preserves the request prefix across six ordinary turns', async t => {
const f = fixture(t, Array.from({ length: 6 }, () => ({ text: 'OK' })));
for (let i = 0; i < 6; i += 1) await f.turn();
assert.equal(f.requests.length, 6);
for (let i = 1; i < f.requests.length; i += 1) assertRequestPrefix(f.requests[i - 1], f.requests[i]);
});
test('Catty replays completed small commands without rewriting their results', async t => {
const f = fixture(t, Array.from({ length: 6 }, () => [
{ tool: 'terminal_execute' as const }, { text: 'OK' },
]).flat());
for (let i = 0; i < 6; i += 1) await f.turn(2);
assert.equal(f.requests.length, 12);
for (let i = 1; i < f.requests.length; i += 1) assertRequestPrefix(f.requests[i - 1], f.requests[i]);
assert.equal(f.messages.at(-1)?.usage?.cachedInputTokens, 1800, 'usage includes both model steps');
assert.doesNotMatch(JSON.stringify(f.requests), /cache_control/, 'OpenAI-compatible requests use implicit caching');
});
test('Catty keeps large-output handles without replacing the conversation prefix', async t => {
const f = fixture(t, [{ tool: 'sftp_read_file' }, { tool: 'sftp_read_file' }, { text: 'OK' }]);
await f.turn(3);
assert.equal(f.requests.length, 3);
for (let i = 1; i < f.requests.length; i += 1) assertRequestPrefix(f.requests[i - 1], f.requests[i]);
assert.match(JSON.stringify(f.requests.at(-1)), /tool output handle:/);
assert.doesNotMatch(JSON.stringify(f.requests.at(-1)), /Tool output handles available/);
});
test('Catty preserves large-output history across consecutive user turns', async t => {
const f = fixture(t, Array.from({ length: 6 }, () => [
{ tool: 'sftp_read_file' as const }, { text: 'OK' },
]).flat());
for (let i = 0; i < 6; i += 1) await f.turn(2);
assert.equal(f.requests.length, 12);
for (let i = 1; i < f.requests.length; i += 1) assertRequestPrefix(f.requests[i - 1], f.requests[i]);
});
test('Catty preserves large terminal results across consecutive user turns', async t => {
const f = fixture(t, Array.from({ length: 6 }, () => [
{ tool: 'terminal_execute' as const }, { text: 'OK' },
]).flat(), 'openai', Array.from({ length: 14000 }, (_, i) => `synthetic log record ${i}`).join('\n'));
for (let i = 0; i < 6; i += 1) await f.turn(2);
assert.equal(f.requests.length, 12);
for (let i = 1; i < f.requests.length; i += 1) assertRequestPrefix(f.requests[i - 1], f.requests[i]);
});
test('Catty keeps DeepSeek cache reads in trace and persisted assistant usage', async t => {
const f = fixture(t, [{ text: 'OK' }]);
await f.turn();
const event = f.events.find(event => event.type === 'usage');
assert.ok(event?.type === 'usage');
assert.equal(event.cachedPromptTokens, 900);
assert.equal(f.messages.at(-1)?.usage?.cachedInputTokens, 900);
});
test('Catty marks a stable Claude system block and the conversation tail for caching', async t => {
const f = fixture(t, [{ text: 'OK' }], 'anthropic');
await f.turn();
const system = f.requests[0].system!;
assert.ok(system.length >= 2, 'static and dynamic system context need separate boundaries');
assert.deepEqual(system[0].cache_control, { type: 'ephemeral' });
assert.doesNotMatch(system[0].text, /## Current Scope/);
assert.match(system[1].text, /## Current Scope/);
assert.match(JSON.stringify(f.requests[0].messages.at(-1)), /cache_control/);
const event = f.events.find(event => event.type === 'usage');
assert.ok(event?.type === 'usage');
assert.equal(event.cachedPromptTokens, 900);
assert.equal(f.messages.at(-1)?.usage?.cachedInputTokens, 900);
});
test('Catty does not send vendor cache extensions to an Anthropic-compatible gateway', async t => {
const f = fixture(t, [{ text: 'OK' }], 'anthropic');
f.provider.baseURL = 'https://gateway.example.test/anthropic';
await f.turn();
assert.equal(f.requests.length, 1);
assert.doesNotMatch(JSON.stringify(f.requests), /cache_control/);
assert.equal(f.messages.at(-1)?.content, 'OK');
});
for (const providerId of ['openrouter', 'ollama'] as const) {
test(`Catty respects the ${providerId} endpoint fallback with Anthropic format`, async t => {
const f = fixture(t, [{ text: 'OK' }], 'anthropic');
f.provider.providerId = providerId;
f.provider.baseURL = '';
await f.turn();
assert.match(f.urls[0], providerId === 'openrouter' ? /^https:\/\/openrouter\.ai\// : /^http:\/\/localhost:11434\//);
assert.doesNotMatch(JSON.stringify(f.requests), /cache_control/);
assert.equal(f.messages.at(-1)?.content, 'OK');
});
}
@@ -0,0 +1,56 @@
import type { ModelMessage, SystemModelMessage } from 'ai';
import type { ProviderConfig } from '../types';
const CACHE_CONTROL = { type: 'ephemeral' } as const;
/** Native Anthropic needs explicit breakpoints; implicit-cache protocols stay untouched. */
export function createCattyPromptCache(
systemPrompt: string,
provider: string,
endpoint?: Pick<ProviderConfig, 'baseURL'>,
) {
let officialEndpoint = false;
if (endpoint) {
try {
// This must be the resolved SDK address, including vendor fallbacks.
// Only an unresolved URL after that step uses the SDK's native default.
officialEndpoint = new URL(endpoint.baseURL || 'https://api.anthropic.com').origin === 'https://api.anthropic.com';
} catch { /* An unknown endpoint must not receive vendor extensions. */ }
}
const enabled = /^anthropic(?:\.|$)/.test(provider) && officialEndpoint;
const boundary = systemPrompt.indexOf('\n\n## Current Scope');
const instructions: string | SystemModelMessage[] = enabled
? [{
role: 'system',
content: boundary < 0 ? systemPrompt : systemPrompt.slice(0, boundary),
providerOptions: { anthropic: { cacheControl: CACHE_CONTROL } },
}, ...(boundary < 0 ? [] : [{
role: 'system' as const,
content: systemPrompt.slice(boundary),
}])]
: systemPrompt;
return {
enabled,
instructions,
prepareMessages(messages: ModelMessage[]): ModelMessage[] {
if (!enabled) return messages;
// Keep at most two moving conversation breakpoints, plus the static
// system boundary. Remove our prior message-level markers as v7 carries
// prepared messages forward; never alter signed reasoning content.
const targets = new Set<number>();
for (let index = messages.length - 1; index >= 0 && targets.size < 2; index -= 1) {
if (messages[index].role === 'user' || messages[index].role === 'tool') targets.add(index);
}
return messages.map((message, index) => {
const existing = message.providerOptions?.anthropic;
if (!targets.has(index) && !existing?.cacheControl && !existing?.cache_control) return message;
const anthropic = { ...existing };
delete anthropic.cacheControl;
delete anthropic.cache_control;
if (targets.has(index)) anthropic.cacheControl = CACHE_CONTROL;
return { ...message, providerOptions: { ...message.providerOptions, anthropic } };
});
},
};
}
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ModelMessage } from 'ai';
import { createCattyPromptCache } from './cattyPromptCache';
test('leaves implicit-cache protocols unchanged', () => {
for (const provider of ['openai.chat', 'openai.responses', 'google.generative-ai']) {
const cache = createCattyPromptCache('instructions', provider);
const messages: ModelMessage[] = [{ role: 'user', content: 'hello' }];
assert.equal(cache.instructions, 'instructions');
assert.equal(cache.prepareMessages(messages), messages);
}
});
test('Claude markers stay bounded across tool steps without changing signed reasoning', () => {
const cache = createCattyPromptCache('static\n\n## Current Scope\nvolatile', 'anthropic.messages', { baseURL: 'https://api.anthropic.com' });
const reasoning = { type: 'reasoning' as const, text: 'thinking',
providerOptions: { anthropic: { signature: 'signed-value' } } };
let messages: ModelMessage[] = [{ role: 'user', content: 'hello' }];
for (let step = 0; step < 30; step += 1) {
messages = cache.prepareMessages(messages);
const marked = messages.filter(message => message.providerOptions?.anthropic?.cacheControl);
assert.ok(marked.length <= 2);
messages.push({ role: 'assistant', content: [reasoning, { type: 'text', text: 'result' }] });
messages.push({ role: 'user', content: `next-${step}` });
}
for (const message of messages) {
if (message.role === 'assistant') assert.equal(message.content[0], reasoning);
}
assert.ok(Array.isArray(cache.instructions));
assert.deepEqual(cache.instructions[0].providerOptions?.anthropic?.cacheControl, { type: 'ephemeral' });
});
test('only the official HTTPS Anthropic endpoint enables vendor cache markers', () => {
for (const baseURL of ['https://proxy.example.test/v1', 'http://api.anthropic.com', 'https://api.anthropic.com.example.test', 'invalid']) {
const cache = createCattyPromptCache('instructions', 'anthropic.messages', { baseURL });
assert.equal(cache.enabled, false);
}
assert.equal(createCattyPromptCache('instructions', 'anthropic.messages').enabled, false);
assert.equal(createCattyPromptCache('instructions', 'anthropic.messages', {}).enabled, true);
assert.equal(createCattyPromptCache('instructions', 'anthropic.messages', { baseURL: 'https://api.anthropic.com/v1' }).enabled, true);
});
@@ -78,7 +78,7 @@ test('prepareTurnContext applies typed compression before LLM summarize threshol
assert.ok(prepared.messages.length >= messages.length);
const serialized = JSON.stringify(prepared.messages);
assert.match(serialized, /Fix only the upstream timeout issue/);
assert.match(serialized, /Permission mode: confirm/);
assert.doesNotMatch(serialized, /Netcatty session context/);
assert.ok(serialized.length < JSON.stringify(messages).length);
});
@@ -163,7 +163,7 @@ test('TraceStore bounds compaction history independently from events', () => {
assert.equal(store.getCompactions('chat-bounded-compactions').length, 3);
});
test('prepareStepContext replaces prior step handle notices under v7 carry-forward semantics', async () => {
test('prepareStepContext preserves prior notices and appends only newly available handles', async () => {
const store = new ToolOutputStore();
store.store({
chatSessionId: 'chat-4',
@@ -194,9 +194,22 @@ test('prepareStepContext replaces prior step handle notices under v7 carry-forwa
&& typeof message.content === 'string'
&& message.content.includes('Tool output handles available'),
);
assert.equal(notices.length, 1);
assert.match(String(notices[0]?.content), /\[step 2\]/);
assert.doesNotMatch(String(notices[0]?.content), /tool-output-old/);
assert.equal(notices.length, 2);
assert.deepEqual(prepared.messages.slice(0, 2), [priorNotice, { role: 'user', content: 'continue' }]);
assert.equal(notices[0], priorNotice);
assert.match(String(notices[1]?.content), /^\[Tool output handles available:/);
assert.doesNotMatch(String(notices[1]?.content), /tool-output-old|\[step/);
assert.equal(prepared.messages.at(-1), notices[1]);
const next = await prepareStepContext({
messages: prepared.messages,
stepNumber: 3,
sessionId: 'chat-4',
chatSessionId: 'chat-4',
toolOutputStore: store,
runtimeContext,
});
assert.deepEqual(next.messages, prepared.messages, 'unchanged handles need no per-step rewrite');
});
test('prepareStepContext emits step compaction trace when over budget', async () => {
@@ -266,7 +279,8 @@ test('prepareStepContext retains handle notice after step budget guard', async (
&& message.content.includes('Tool output handles available'),
);
assert.equal(notices.length, 1);
assert.match(String(notices[0]?.content), /\[step 2\]/);
assert.match(String(notices[0]?.content), /^\[Tool output handles available:/);
assert.equal(prepared.messages.at(-1), notices[0]);
});
test('prepareStepContext never leaves orphan results from a parallel tool batch', async () => {
+30 -39
View File
@@ -1,6 +1,5 @@
import type { ModelMessage } from 'ai';
import type { ChatMessage, AIPermissionMode } from '../types';
import { isStepHandleNoticeMessage } from './agentEventAdapter';
import {
DEFAULT_CONTEXT_WINDOW_TOKENS,
DEFAULT_PROTECT_RECENT_MESSAGES,
@@ -265,7 +264,9 @@ export async function prepareTurnContext(
}
const reinjection = buildReinjectionMessages(input.reinjection);
if (reinjection.length > 0 && didAdjust) {
// Routine output cleanup does not discard the conversation or its goals.
// Reinserting the latest goal at the front would invalidate every turn.
if (reinjection.length > 0 && didAdjust && (underBudgetPressure || didLlmSummarize)) {
const reinjectionTokens = estimateModelMessagesTokensWithKind({
messages: reinjection,
providerId: input.providerId,
@@ -414,22 +415,6 @@ export async function prepareStepContext(
const typed = compressMessagesForRequestTooLargeRetry(working);
working = typed.messages;
const pendingHandles = input.toolOutputStore?.listPendingHandles(input.chatSessionId ?? input.sessionId) ?? [];
let didHandleNotice = false;
if (pendingHandles.length > 0 && input.stepNumber > 0) {
working = working.filter((message) => {
if (message.role !== 'user') return true;
const content = typeof message.content === 'string' ? message.content : '';
return !isStepHandleNoticeMessage(content);
});
const notice: ModelMessage = {
role: 'user',
content: `[step ${input.stepNumber}] Tool output handles available: ${pendingHandles.map(h => h.id).join(', ')}`,
};
working = [notice, ...working];
didHandleNotice = true;
}
const budgetGuard = applyStepBudgetGuard(working, {
contextWindow,
reservedTokens: input.reservedTokens ?? 0,
@@ -438,36 +423,42 @@ export async function prepareStepContext(
protectRecentMessages: protectRecent,
});
working = budgetGuard.messages;
if (didHandleNotice && pendingHandles.length > 0) {
const hasHandleNotice = working.some(
(message) => message.role === 'user' && isStepHandleNoticeMessage(message.content),
);
if (!hasHandleNotice) {
working = [{
role: 'user',
content: `[step ${input.stepNumber}] Tool output handles available: ${pendingHandles.map(h => h.id).join(', ')}`,
}, ...working];
}
}
if (didHandleNotice) {
const notices = working.filter(
message => message.role === 'user' && isStepHandleNoticeMessage(message.content),
);
const body = working.filter(
message => !(message.role === 'user' && isStepHandleNoticeMessage(message.content)),
);
const pendingHandles = input.stepNumber > 0
? input.toolOutputStore?.listPendingHandles(input.chatSessionId ?? input.sessionId) ?? []
: [];
const unannouncedHandles = () => {
// Bounded tool results already carry their read handles. Announce only
// handles whose result was pruned, so normal history needs no transient
// user message that would disappear when the next turn is rebuilt.
const visibleContent = working.map(message => typeof message.content === 'string'
? message.content : JSON.stringify(message.content));
return pendingHandles.filter(handle => !visibleContent.some(content => content.includes(handle.id)));
};
let didHandleNotice = false;
if (unannouncedHandles().length > 0) {
// Reserve the complete list: the guard may remove an older announcement.
// New notices belong at the tail; replacing a step-numbered prefix would
// invalidate the entire cached conversation on every model call.
const noticeTokens = estimateModelMessagesTokensWithKind({
messages: notices,
messages: [{ role: 'user', content: `[Tool output handles available: ${pendingHandles.map(h => h.id).join(', ')}]` }],
providerId: input.providerId,
}).tokens;
const finalGuard = applyStepBudgetGuard(body, {
const finalGuard = applyStepBudgetGuard(working, {
contextWindow,
reservedTokens: (input.reservedTokens ?? 0) + noticeTokens,
maxOutputTokens,
providerId: input.providerId,
protectRecentMessages: protectRecent,
});
working = [...notices, ...finalGuard.messages];
working = finalGuard.messages;
const handles = unannouncedHandles();
if (handles.length > 0) {
working = [...working, {
role: 'user',
content: `[Tool output handles available: ${handles.map(h => h.id).join(', ')}]`,
}];
didHandleNotice = true;
}
budgetGuard.didAdjust = budgetGuard.didAdjust || finalGuard.didAdjust;
}
const didBudgetAdjust = integrity.didAdjust || stale.didAdjust || typed.didAdjust || budgetGuard.didAdjust;
@@ -27,7 +27,7 @@ import {
type AssistantContentPart,
type CattyProviderContinuationContext,
} from '../../aiChatStreamingSupport';
import { redactSecretsInValueForModel } from '../modelSecretRedaction';
import { redactSecretsForModel, redactSecretsInValueForModel } from '../modelSecretRedaction';
import { fitLargeUserInputForModel } from '../largeUserInput';
import type { ToolOutputStore } from '../toolOutputStore';
@@ -198,7 +198,6 @@ export interface BuildCattySdkMessagesInput {
trimmed: string;
attachments?: ChatMessageAttachment[];
continuationContext: CattyProviderContinuationContext;
preserveTerminalToolResults?: ReadonlySet<ToolResult>;
chatSessionId: string;
toolOutputStore: ToolOutputStore;
fieldsByMessage: Map<ModelMessage, OpenAIChatAssistantFields | undefined>;
@@ -212,7 +211,6 @@ export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelM
trimmed,
attachments,
continuationContext,
preserveTerminalToolResults = new Set<ToolResult>(),
chatSessionId,
toolOutputStore,
fieldsByMessage,
@@ -394,9 +392,12 @@ export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelM
toolName: toolCall?.name ?? 'unknown',
output: {
type: 'text' as const,
value: buildHistoricalToolResultReplayText(tr, toolCall, {
preserveTerminalOutput: preserveTerminalToolResults.has(tr),
}),
// Live results are already bounded. Reformatting them merely
// because another user turn arrived breaks the cached prefix.
// Context-budget pruning still owns necessary reductions.
value: redactSecretsForModel(buildHistoricalToolResultReplayText(tr, toolCall, {
preserveTerminalOutput: true,
})),
},
};
}),
@@ -8,13 +8,14 @@ import {
} from '../../cattyRequestTooLargeRetry';
import { mapCattyStreamChunkToAgentEvents } from '../agentEventAdapter';
import type { AgentEvent } from '../types';
import type { ProviderAdvancedParams } from '../../types';
import { resolveProviderStyle, type ProviderAdvancedParams } from '../../types';
import type { CattyReasoningProviderOptions } from '../../cattyReasoning';
import { createModelFromConfig } from '../../sdk/providers';
import { createModelFromConfig, resolveProviderEndpoint } from '../../sdk/providers';
import type { CattyToolsBundle } from '../capabilityTools';
import { buildCattyToolApproval } from '../cattyToolApproval';
import type { CattyRuntimeContext } from '../cattyRuntimeContext';
import { buildCattyStreamTimeouts } from '../streamTimeouts';
import { createCattyPromptCache } from '../cattyPromptCache';
import {
extractProviderContinuationFromRawChunk,
mergeProviderContinuation,
@@ -39,7 +40,7 @@ import {
type ToolOutputDeniedChunk,
type ToolResultChunk,
} from '../../aiChatStreamingSupport';
import type { ChatMessage } from '../../types';
import type { ChatMessage, ProviderConfig } from '../../types';
export type CattyModel = ReturnType<typeof createModelFromConfig>;
@@ -51,6 +52,7 @@ export interface CattyStreamUiSink {
export interface ProcessCattyStreamInput {
streamSessionId: string;
model: CattyModel;
providerConfig?: ProviderConfig;
systemPrompt: string;
toolsBundle: CattyToolsBundle;
sdkMessages: ModelMessage[];
@@ -76,6 +78,7 @@ export interface ProcessCattyStreamInput {
export interface ProcessCattyStreamResult {
usage?: {
promptTokens?: number;
cachedPromptTokens?: number;
completionTokens?: number;
totalTokens?: number;
};
@@ -129,11 +132,15 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
let runtimeContext = initialRuntimeContext;
const { tools, toolsContext } = toolsBundle;
const endpoint = input.providerConfig
? resolveProviderEndpoint(input.providerConfig, resolveProviderStyle(input.providerConfig), undefined)
: undefined;
const promptCache = createCattyPromptCache(systemPrompt, model.provider, endpoint);
const result = streamText({
model,
messages: sdkMessages,
instructions: systemPrompt,
messages: promptCache.prepareMessages(sdkMessages),
instructions: promptCache.instructions,
tools,
toolsContext,
runtimeContext,
@@ -186,6 +193,7 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
modelId: step.model.modelId,
finishReason: step.finishReason,
promptTokens: usage.inputTokens ?? 0,
cachedPromptTokens: usage.inputTokenDetails?.cacheReadTokens,
completionTokens: usage.outputTokens ?? 0,
totalTokens: (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
} as AgentEvent);
@@ -201,15 +209,17 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
timestamp: Date.now(),
turnId,
promptTokens: usage.inputTokens ?? 0,
cachedPromptTokens: usage.inputTokenDetails?.cacheReadTokens,
completionTokens: usage.outputTokens ?? 0,
reasoningTokens: usage.outputTokenDetails?.reasoningTokens,
totalTokens: (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
estimated: false,
} as AgentEvent);
}
},
...(prepareStep ? {
...(prepareStep || promptCache.enabled ? {
prepareStep: async ({ stepNumber, messages, runtimeContext: stepRuntimeContext }) => {
const prepared = await prepareStep({
const prepared = await prepareStep?.({
stepNumber,
messages,
runtimeContext: stepRuntimeContext as CattyRuntimeContext,
@@ -217,7 +227,7 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
if (prepared?.runtimeContext) {
runtimeContext = prepared.runtimeContext;
}
return prepared ?? { messages };
return { ...prepared, messages: promptCache.prepareMessages(prepared?.messages ?? messages) };
},
} : {}),
...(advancedParams?.maxTokens != null && { maxOutputTokens: advancedParams.maxTokens }),
@@ -552,6 +562,21 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
const usage = await result.usage;
const finalStep = await result.finalStep;
const performance = finalStep?.performance;
// onEnd can run before this consumer flushes its final text and creates the
// post-tool assistant message. Attach turn totals only after that flush.
if (usage) {
ui.updateMessageById(streamSessionId, activeMsgId, message => ({
...message,
usage: {
inputTokens: usage.inputTokens ?? 0,
cachedInputTokens: usage.inputTokenDetails?.cacheReadTokens,
outputTokens: usage.outputTokens ?? 0,
reasoningTokens: usage.outputTokenDetails?.reasoningTokens,
totalTokens: (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
estimated: false,
},
}));
}
if (performance) {
onAgentEvent?.({
@@ -571,6 +596,7 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
return {
usage: usage ? {
promptTokens: usage.inputTokens,
cachedPromptTokens: usage.inputTokenDetails?.cacheReadTokens,
completionTokens: usage.outputTokens,
totalTokens: (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
} : undefined,
@@ -37,7 +37,6 @@ import {
buildCattySdkMessages,
collectOpenAIChatAssistantFieldsForMessages,
collectPreservedTerminalWriteFingerprints,
collectToolResultsAfterMessage,
createContinuationContext,
} from './cattyMessageBuilder';
import { hadToolProgressBeforeRequestTooLarge, processCattyStream } from './cattyStreamProcessor';
@@ -218,7 +217,6 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
const buildSdkMessages = (
allMessages: import('../../types').ChatMessage[],
includeCurrentUserMessage: boolean,
options: { preserveTerminalToolResults?: ReadonlySet<import('../../types').ToolResult> } = {},
sessionContextCompaction = currentSession?.contextCompaction,
) => buildCattySdkMessages({
allMessages,
@@ -227,7 +225,6 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
trimmed: modelUserText,
attachments: includeCurrentUserMessage ? attachments : undefined,
continuationContext,
preserveTerminalToolResults: options.preserveTerminalToolResults,
chatSessionId: sessionId,
toolOutputStore: ctx.toolOutputStore,
fieldsByMessage: openAIChatAssistantFieldsByMessage,
@@ -399,7 +396,6 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
const headSdkMessages = buildSdkMessages(
uiMessages.slice(0, compactedMessageCount),
false,
{},
undefined,
);
const compacted = await compactMessages(headSdkMessages, {
@@ -414,7 +410,7 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
};
ui.persistContextCompaction?.(sessionId, nextCompaction);
emitContextSnapshot(prepareMessagesForStream(
buildSdkMessages(uiMessages, false, {}, nextCompaction),
buildSdkMessages(uiMessages, false, nextCompaction),
));
} else {
// Non-durable outcome: keep the meter honest (full current context).
@@ -452,6 +448,7 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
await processCattyStream({
streamSessionId: sessionId,
model,
providerConfig: context.activeProvider,
systemPrompt,
toolsBundle,
sdkMessages: streamMessages,
@@ -514,12 +511,7 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
assistantMsgId,
sessionId,
);
retryBaseMessages = buildSdkMessages(latestSession.messages, false, {
preserveTerminalToolResults: collectToolResultsAfterMessage(
latestSession.messages,
assistantMsgId,
),
}, latestSession.contextCompaction);
retryBaseMessages = buildSdkMessages(latestSession.messages, false, latestSession.contextCompaction);
}
retryAssistantMsgId = generateId();
ui.addMessageToSession(sessionId, {
+1
View File
@@ -180,6 +180,7 @@ export interface StepEndEvent extends AgentEventBase {
modelId?: string;
finishReason?: string;
promptTokens: number;
cachedPromptTokens?: number;
completionTokens: number;
totalTokens: number;
}
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeOpenAIChatCacheUsage } from './openAIChatCacheUsage';
test('adapts documented DeepSeek cache usage while preserving the rest of the response', () => {
const response = { choices: [], usage: {
prompt_tokens: 1000, prompt_cache_hit_tokens: 900, prompt_cache_miss_tokens: 100,
prompt_tokens_details: { other: 1 }, completion_tokens: 5,
} };
const parsed = JSON.parse(normalizeOpenAIChatCacheUsage(JSON.stringify(response)));
assert.deepEqual(parsed, {
...response, usage: { ...response.usage, prompt_tokens_details: { other: 1, cached_tokens: 900 } },
});
});
test('preserves standard usage and rejects invalid cache counts without breaking stream data', () => {
for (const hit of [-1, 1.5, '900', 1001, null]) {
const data = JSON.stringify({ usage: { prompt_tokens: 1000, prompt_cache_hit_tokens: hit } });
assert.equal(normalizeOpenAIChatCacheUsage(data), data);
}
for (const data of ['[DONE]', 'not JSON', '{"usage":null}', '{"type":"message_start"}']) {
assert.equal(normalizeOpenAIChatCacheUsage(data), data);
}
const data = JSON.stringify({ usage: {
prompt_tokens: 1000, prompt_cache_hit_tokens: 900, prompt_tokens_details: { cached_tokens: 0 },
} });
assert.equal(normalizeOpenAIChatCacheUsage(data), data);
});
+17
View File
@@ -0,0 +1,17 @@
/** Adapt DeepSeek's documented usage fields before the OpenAI SDK validates them. */
export function normalizeOpenAIChatCacheUsage(data: string): string {
if (!data.includes('prompt_cache_hit_tokens')) return data;
try {
const body = JSON.parse(data);
const usage = body?.usage;
const hit = usage?.prompt_cache_hit_tokens;
if (!Number.isSafeInteger(hit) || hit < 0) return data;
if (Number.isFinite(usage.prompt_tokens) && hit > usage.prompt_tokens) return data;
// Preserve a provider's standard field, including an explicit zero.
if (usage.prompt_tokens_details?.cached_tokens != null) return data;
usage.prompt_tokens_details = { ...usage.prompt_tokens_details, cached_tokens: hit };
return JSON.stringify(body);
} catch {
return data;
}
}
+23 -1
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { isStepCount, streamText, tool } from 'ai';
import { generateText, isStepCount, streamText, tool } from 'ai';
import { z } from 'zod';
import { createBridgeFetchForSDK, createModelFromConfig } from './sdk/providers';
import type { OpenAIChatAssistantFields } from './providerContinuation';
@@ -1305,3 +1305,25 @@ for (const indexes of [[0], [1], [1, 4], [4, 1]]) {
indexes.map(index => `call_${index}`).sort());
});
}
test('non-streaming DeepSeek usage reaches the SDK as cached input', async t => {
const originalWindow = globalThis.window;
t.after(() => { globalThis.window = originalWindow; });
globalThis.window = { netcatty: {
aiFetch: async () => ({ ok: true, status: 200, data: JSON.stringify({
id: 'response', object: 'chat.completion', created: 0, model: 'deepseek-chat',
choices: [{ index: 0, message: { role: 'assistant', content: 'OK' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 1000, completion_tokens: 1, total_tokens: 1001,
prompt_cache_hit_tokens: 900, prompt_cache_miss_tokens: 100 },
}) }),
} } as unknown as Window & typeof globalThis;
const result = await generateText({
model: createModelFromConfig({
id: 'fixture', providerId: 'custom', name: 'Fixture', enabled: true,
baseURL: 'https://provider.example.test/v1', apiKey: 'fixture', defaultModel: 'deepseek-chat',
}),
prompt: 'hello',
});
assert.equal(result.usage.inputTokenDetails.cacheReadTokens, 900);
assert.equal(result.usage.inputTokenDetails.noCacheTokens, 100);
});
@@ -158,13 +158,19 @@ function compressContentPartForRequestRetry(part: unknown): unknown {
const output = record.output;
if (output && typeof output === "object") {
const outputRecord = output as Record<string, unknown>;
if (outputRecord.type === "text" && typeof outputRecord.value === "string") {
const value = compressAndTruncateText(outputRecord.value, RETRY_MAX_TOOL_RESULT_CHARS);
if (value === outputRecord.value) return part;
const text = outputRecord.type === "text" && typeof outputRecord.value === "string"
? outputRecord.value
: outputRecord.type === "json" ? JSON.stringify(outputRecord.value) : undefined;
if (text !== undefined) {
// Live SDK tool results are JSON, while persisted results replay as
// text. Apply the same reduction before their first model request.
const value = compressAndTruncateText(text, RETRY_MAX_TOOL_RESULT_CHARS);
if (value === text) return part;
return {
...record,
output: {
...outputRecord,
type: "text",
value,
},
};
+3 -2
View File
@@ -6,6 +6,7 @@ import { resolveOpenAIApi, resolveProviderStyle } from '../types';
import { normalizeAnthropicSdkBaseURL } from '../anthropicCompatBaseUrl';
import { buildSdkRequestHeaders } from '../providerRequestHeaders';
import { normalizeOllamaSdkBaseURL } from '../ollamaCompatBaseUrl';
import { normalizeOpenAIChatCacheUsage } from '../openAIChatCacheUsage';
export { normalizeOllamaSdkBaseURL };
import {
@@ -529,7 +530,7 @@ export function createBridgeFetchForSDK(
};
const unsubData = bridge.onAiStreamData(requestId, (data: string) => {
const normalizedData = normalizeOpenAIChatToolCalls(data);
const normalizedData = normalizeOpenAIChatCacheUsage(normalizeOpenAIChatToolCalls(data));
captureOpenAIChatFields(normalizedData);
// Re-wrap as SSE so the SDK can parse it
enqueueChunk(encoder.encode(`data: ${normalizedData}\n\n`));
@@ -627,7 +628,7 @@ export function createBridgeFetchForSDK(
// Non-streaming path
const result = await bridge.aiFetch(url, method, headers, requestBody, providerId);
return new Response(result.data, {
return new Response(normalizeOpenAIChatCacheUsage(result.data), {
status: result.status,
statusText: result.ok ? 'OK' : 'Error',
headers: { 'content-type': 'application/json' },
+3
View File
@@ -321,3 +321,6 @@ export const STORAGE_KEY_DEBUG_UPDATE_DEMO = 'debug.updateDemo';
// Portable monitor configuration. Runtime samples never use storage.
export const STORAGE_KEY_MONITOR_PANELS = 'netcatty_monitor_panels_v1';
export const STORAGE_KEY_MONITOR_TABLE_COLUMNS = 'netcatty_monitor_table_columns_v1';
export const STORAGE_KEY_MONITOR_HIDDEN_COLUMNS = "netcatty_monitor_hidden_columns_v1";
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./monitor-panels.tsx"></script>
</body>
</html>
+218
View File
@@ -0,0 +1,218 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { I18nProvider } from "../../application/i18n/I18nProvider";
import { writeMonitorPanels } from "../../application/state/monitorPanelsStorage";
import {
monitorActions,
publishMonitorSnapshot,
} from "../../application/state/monitorRuntimeStore";
import { MonitorPanels } from "../../components/vault/MonitorPanels";
import type { Host } from "../../domain/models";
import type { MonitorSnapshot } from "../../domain/monitorRuntime";
import "../../index.css";
const tokens = {
background: "210 25% 18%",
foreground: "210 20% 90%",
card: "210 23% 21%",
secondary: "210 20% 24%",
muted: "210 20% 22%",
"muted-foreground": "210 15% 65%",
border: "210 20% 30%",
primary: "170 70% 42%",
"primary-foreground": "210 20% 12%",
popover: "210 25% 20%",
"popover-foreground": "210 20% 90%",
destructive: "0 70% 60%",
input: "210 20% 30%",
ring: "170 70% 42%",
};
for (const [key, value] of Object.entries(tokens))
document.documentElement.style.setProperty(`--${key}`, value);
document.documentElement.classList.add("dark");
document.documentElement.style.setProperty("--radius", "8px");
const root = createRoot(document.getElementById("root")!);
let connections: string[] = [];
let retries: {
id: string;
credentials?: { username: string; password: string };
}[] = [];
monitorActions.retry = (id, credentials) => {
retries.push({ id, credentials });
};
let revision = 0;
let latestSnapshot: MonitorSnapshot;
function seed(
count = 2,
view: "list" | "charts" = "charts",
locale = "zh-CN",
pointCount = 12,
) {
const now = Date.now();
const hosts: Host[] = Array.from({ length: count }, (_, i) => ({
id: `host-${i}`,
label: `Production server ${String(i + 1).padStart(4, "0")}`,
hostname: `10.24.${Math.floor(i / 250)}.${(i % 250) + 1}`,
username: "fixture",
port: 22,
os: "linux",
distro: "ubuntu",
group: "Production",
tags:
i === 1
? []
: [
"production",
"database",
"critical",
"east-region",
"long-tag-value-for-overflow",
],
notes:
i === 1
? ""
: "Primary database cluster. Contact the on-call engineer before restarting.\nMaintenance window: Sunday 02:00–04:00.",
createdAt: 1,
}));
writeMonitorPanels([
{
id: "fixture",
name: "Fleet overview",
description: "",
hostIds: hosts.map((h) => h.id),
view,
interval: 5,
range: 15,
sort: "manual",
},
]);
const snapshot: MonitorSnapshot = {
historyMode: "replace",
runtimeId: "monitor-ui-fixture",
epoch: now,
takenAt: now,
running: ["fixture"],
count: 1,
hostCount: count,
activeSessions: [],
startedAt: { fixture: now - Math.max(60000, pointCount * 1000) },
hosts: Object.fromEntries(
hosts.map((host, i) => [
host.id,
{
historyId: host.id,
status: "live",
at: now,
stats: {
cpuPerCore: [],
memFree: 12288,
memBuffers: 0,
memCached: 0,
swapTotal: 0,
swapUsed: 0,
topProcesses: [],
netInterfaces: [],
cpu: 23 + (i % 60),
cpuCores: 8,
memUsed: 4096,
memTotal: 16384,
diskPercent: 35,
diskUsed: 35,
diskTotal: 100,
disks: Array.from({ length: i === 2 ? 10 : 2 }, (_, j) => ({
mountPoint: j ? `/data-${j}` : "/",
used: 35,
total: 100 + j * 50,
percent: 35,
})),
netRxSpeed: 240000,
netTxSpeed: 72000,
networkReady: true,
latencyMs:
i === 1 || i === 5
? null
: i === 2
? 180
: i === 3
? 450
: i === 4
? 0
: 12 + (i % 30),
responseLatencyMs: i === 5 ? 145 : null,
gpu: i === 0 ? 45 : i === 2 ? 0 : null,
gpuName:
i === 0
? "NVIDIA GeForce RTX 4090"
: i === 2
? "NVIDIA Tesla T4"
: null,
gpuMemUsed: i === 0 ? 8192 : i === 2 ? 0 : null,
gpuMemTotal: i === 0 ? 24576 : i === 2 ? 16384 : null,
},
points: Array.from({ length: pointCount }, (_, j) => ({
at: now - (pointCount - 1 - j) * (pointCount === 12 ? 5000 : 1000),
cpu: 20 + ((i + j) % 40),
memory: 25,
disk: 35,
rx: 200000 + j * 1000,
tx: 70000 + j * 800,
})),
},
]),
),
};
latestSnapshot = snapshot;
publishMonitorSnapshot(snapshot);
connections = [];
retries = [];
root.render(
<I18nProvider locale={locale}>
<div className="flex h-screen min-w-0 bg-background text-foreground">
<MonitorPanels
key={++revision}
hosts={hosts}
customGroups={["Production"]}
onConnect={(host) => {
connections.push(host.id);
}}
/>
</div>
</I18nProvider>,
);
}
Object.assign(window, {
monitorFixture: {
seed,
connections: () => connections,
retries: () => retries,
status: (id: string, status: string, age = 0) => {
latestSnapshot = {
...latestSnapshot,
takenAt: Date.now(),
hosts: {
...latestSnapshot.hosts,
[id]: {
...latestSnapshot.hosts[id],
at: Date.now() - age,
status: status as MonitorSnapshot["hosts"][string]["status"],
},
},
};
publishMonitorSnapshot(latestSnapshot);
},
refresh: () => {
latestSnapshot = {
...latestSnapshot,
takenAt: Date.now(),
hosts: Object.fromEntries(
Object.entries(latestSnapshot.hosts).map(([id, state]) => [
id,
{ ...state, at: Date.now(), points: [...state.points] },
]),
),
};
publishMonitorSnapshot(latestSnapshot);
},
},
});
seed(Number(new URLSearchParams(location.search).get("count")) || 2);
+840
View File
@@ -0,0 +1,840 @@
"use strict";
// Real production monitoring components, isolated storage and deterministic readings.
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { app, BrowserWindow } = require("electron");
const temp = require("../electron/bridges/tempDirBridge.cjs");
const profile = fs.mkdtempSync(temp.getTempFilePath("monitor-ui-"));
app.setPath("userData", profile);
let owner, vite;
const errors = [];
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const run = (code) => owner.webContents.executeJavaScript(code);
async function until(read, label) {
for (let i = 0; i < 150; i++) {
if (await read()) return;
await wait(30);
}
assert.ok(await read(), label);
}
async function click(selector) {
const rect = await run(
`(()=>{const e=document.querySelector(${JSON.stringify(selector)});e?.scrollIntoView({block:'nearest'});const r=e?.getBoundingClientRect();return r&&{x:r.x+r.width/2,y:r.y+r.height/2}})()`,
);
assert.ok(rect, selector);
owner.webContents.focus();
for (const type of ["mouseDown", "mouseUp"])
owner.webContents.sendInputEvent({
type,
button: "left",
clickCount: 1,
x: Math.round(rect.x),
y: Math.round(rect.y),
});
await run(
"new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))",
);
}
const watchdog = setTimeout(() => {
console.error("Monitoring UI acceptance timed out");
app.exit(1);
}, 90000);
async function main() {
const { createServer } = await import("vite");
vite = await createServer({
root: path.resolve(__dirname, ".."),
server: { host: "127.0.0.1", port: 0 },
logLevel: "error",
});
await vite.listen();
owner = new BrowserWindow({ width: 1280, height: 900, show: true });
owner.webContents.on("console-message", (event) => {
if (event.level === "error" || event.level >= 3) errors.push(event.message);
});
await owner.loadURL(
`http://127.0.0.1:${vite.httpServer.address().port}/scripts/fixtures/monitor-panels.html`,
);
await until(
() => run('document.querySelectorAll("[data-monitor-host]").length > 0'),
"monitoring renders",
);
const screenshot = async (name) => {
owner.webContents.sendInputEvent({ type: "mouseMove", x: 1, y: 1 });
await wait(200);
await run(
"document.fonts.ready.then(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))))",
);
fs.writeFileSync(
path.join(profile, name),
(await owner.webContents.capturePage()).toPNG(),
);
};
const count = () =>
run('document.querySelectorAll("[data-monitor-host]").length');
const scroll = (selector) =>
run(
`(()=>{const e=document.querySelector('[data-section="vault-monitor-list"]');e.scrollTop=${selector};})()`,
);
const hover = async (selector) => {
owner.focus();
owner.webContents.focus();
owner.webContents.sendInputEvent({ type: "mouseMove", x: 1, y: 1 });
await run(
"new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))",
);
const point = await run(
`(()=>{const e=document.querySelector(${JSON.stringify(selector)});e.scrollIntoView({block:'nearest',inline:'nearest'});const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`,
);
owner.webContents.sendInputEvent({
type: "mouseMove",
x: Math.round(point.x),
y: Math.round(point.y),
});
// Tooltip grace areas update on movement; model the following mouse event
// that a real pointer produces rather than teleporting between triggers.
await run("new Promise(r=>requestAnimationFrame(r))");
owner.webContents.sendInputEvent({
type: "mouseMove",
x: Math.round(point.x + 1),
y: Math.round(point.y),
});
};
const checkActionContrast = async (view) => {
const keys = ["accent", "accent-foreground"];
const saved = await run(
`Object.fromEntries(${JSON.stringify(["accent", "accent-foreground"])}.map(k=>[k,document.documentElement.style.getPropertyValue('--'+k)]))`,
);
await run(
`document.documentElement.style.setProperty('--accent','170 70% 42%');document.documentElement.style.setProperty('--accent-foreground','210 20% 12%')`,
);
await hover("[data-monitor-terminal=host-0]");
await wait(200);
const colors = await run(
`(()=>{const e=document.querySelector('[data-monitor-terminal=host-0]');return {background:getComputedStyle(e).backgroundColor,icon:getComputedStyle(e.querySelector('svg')).color}})()`,
);
const luminance = (color) =>
color
.match(/[\d.]+/g)
.slice(0, 3)
.map(Number)
.map((v) => v / 255)
.map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
.reduce((n, v, i) => n + v * [0.2126, 0.7152, 0.0722][i], 0);
const a = luminance(colors.background),
b = luminance(colors.icon);
assert.ok(
(Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) >= 3,
`${view} terminal icon stays visible on accent hover: ${JSON.stringify(colors)}`,
);
fs.writeFileSync(
path.join(profile, `monitor-${view}-action-hover.png`),
(await owner.webContents.capturePage()).toPNG(),
);
for (const key of keys)
await run(
`document.documentElement.style.setProperty('--'+${JSON.stringify(key)},${JSON.stringify(saved[key])})`,
);
owner.webContents.sendInputEvent({ type: "mouseMove", x: 1, y: 1 });
};
app.focus({ steal: true });
owner.focus();
owner.webContents.focus();
assert.deepEqual(
await run(
'Array.from(document.querySelectorAll("[data-monitor-host]")).map(e=>e.getBoundingClientRect().height)',
),
[368, 368],
);
assert.equal(
await run(
'document.querySelector("[data-monitor-host=host-0] [data-monitor-latency]").getAttribute("aria-label")',
),
"延迟: 12 ms",
);
assert.equal(
await run(
'document.querySelector("[data-monitor-host=host-1] [data-monitor-latency]").getAttribute("aria-label")',
),
"延迟: --",
);
assert.equal(
await run('document.body.textContent.includes("读数只保留在内存")'),
false,
);
await hover("[data-monitor-host=host-0] [data-monitor-notes]");
await until(
() =>
run(
'Array.from(document.querySelectorAll("[role=tooltip]")).some(e=>e.textContent.includes("Maintenance window"))',
),
"full notes appear on hover",
);
await screenshot("notes-tooltip.png");
owner.webContents.sendInputEvent({ type: "mouseMove", x: 1, y: 1 });
await checkActionContrast("charts");
await click("[data-monitor-terminal=host-0]");
assert.deepEqual(await run("window.monitorFixture.connections()"), [
"host-0",
]);
assert.equal(
await run(
'!!document.querySelector("[data-section=vault-monitor-detail]")',
),
false,
"terminal action does not open details",
);
await click("[data-monitor-host=host-0] > button:first-child");
await until(
() => run('!!document.querySelector("[data-monitor-gpu]")'),
"GPU details appear",
);
const gpu = await run(
'document.querySelector("[data-monitor-gpu]").textContent',
);
for (const expected of [
"NVIDIA GeForce RTX 4090",
"45.0%",
"8.0 GiB",
"24.0 GiB",
])
assert.ok(gpu.includes(expected), expected);
await screenshot("monitor-gpu.png");
await click("[data-section=vault-monitor-detail] button:has(.lucide-x)");
await click("[data-monitor-host=host-1] > button:first-child");
await until(
() =>
run('!!document.querySelector("[data-section=vault-monitor-detail]")'),
"non-GPU details open",
);
assert.equal(
await run('!!document.querySelector("[data-monitor-gpu]")'),
false,
"hosts without GPU have no empty GPU section",
);
await click("[data-section=vault-monitor-detail] button:has(.lucide-x)");
await run("window.monitorFixture.seed(6,'charts')");
await until(
() => run('document.querySelectorAll("[data-monitor-host]").length===6'),
"signal examples render",
);
assert.deepEqual(
await run(
'Array.from(document.querySelectorAll("[data-monitor-latency]")).map(e=>Number(e.dataset.monitorSignalBars))',
),
[4, 0, 2, 1, 4, 2],
);
assert.ok(
await run(
`(()=>{const row=document.querySelector('[data-monitor-host=host-0]');const a=row.querySelector('[data-monitor-latency]').getBoundingClientRect();const b=row.querySelector('[data-monitor-tags]').getBoundingClientRect();return Math.abs((a.top+a.bottom)/2-(b.top+b.bottom)/2)<1})()`,
),
"latency and tags are vertically centered",
);
await hover("[data-monitor-host=host-2] [data-monitor-latency]");
await until(
() =>
run(
'Array.from(document.querySelectorAll("[role=tooltip]")).some(e=>e.textContent.includes("180 ms"))',
),
"signal tooltip has exact latency",
);
await hover("[data-monitor-host=host-5] [data-monitor-latency]");
await until(
() =>
run(
'Array.from(document.querySelectorAll("[role=tooltip]")).some(e=>e.textContent.includes("响应延迟(含远端处理): 145 ms"))',
),
"disabled keepalive still shows response latency",
);
await screenshot("monitor-signals.png");
await click("[data-monitor-host=host-2] > button:first-child");
await until(
() => run('!!document.querySelector("[data-monitor-gpu]")'),
"idle GPU details open",
);
const idleGpu = await run(
'document.querySelector("[data-monitor-gpu]").textContent',
);
assert.ok(
idleGpu.includes("0.0%") && idleGpu.includes("0 MiB"),
"zero GPU readings remain visible",
);
await click("[data-section=vault-monitor-detail] button:has(.lucide-x)");
await run("window.monitorFixture.seed(1200,'charts')");
await until(
async () => (await count()) > 2 && (await count()) < 80,
"charts render a bounded visible subset",
);
assert.equal(
await run('document.querySelectorAll("[data-monitor-terminal]").length'),
await count(),
);
assert.ok(
await run(
'Array.from(document.querySelectorAll("[data-monitor-host]")).every(e=>e.getBoundingClientRect().height===368)',
),
);
await scroll("e.scrollHeight");
await until(
() => run('!!document.querySelector("[data-monitor-host=host-1199]")'),
"last card can be reached",
);
await click("[data-monitor-terminal=host-1199]");
assert.deepEqual(
await run("window.monitorFixture.connections()"),
["host-1199"],
"recycled card opens the correct host",
);
await click("button:has(.lucide-list)");
await until(
() => run('!!document.querySelector("table")'),
"list view opens",
);
assert.ok((await count()) < 50, "list renders only visible rows");
for (const position of [0, 450, 100000]) {
await run(
`document.querySelector('[data-section=vault-monitor-list]').scrollLeft=${position}`,
);
await run("new Promise(r=>requestAnimationFrame(r))");
assert.ok(
await run(
`(()=>{const viewport=document.querySelector('[data-section=vault-monitor-list]');const action=document.querySelector('tbody tr td:last-child');return window.innerWidth-viewport.getBoundingClientRect().right>=15 && window.innerWidth-action.getBoundingClientRect().right>=15})()`,
),
"sticky actions retain the right page gutter at every horizontal scroll position",
);
const geometry = await run(
`(()=>{const r=document.querySelector('tbody tr td:last-child').getBoundingClientRect();return {width:innerWidth,height:innerHeight,right:r.right,top:r.top,bottom:r.bottom}})()`,
);
const captured = await owner.webContents.capturePage();
const bitmap = captured.toBitmap();
const scale = Math.sqrt(
bitmap.length / (4 * geometry.width * geometry.height),
);
const pixelWidth = Math.round(geometry.width * scale);
const pixel = (x, y) => {
const start =
4 * (Math.floor(y * scale) * pixelWidth + Math.floor(x * scale));
return [...bitmap.subarray(start, start + 4)];
};
const backdrop = pixel(geometry.width - 4, geometry.top + 1);
assert.deepEqual(
pixel(geometry.right - 1, geometry.top + 1),
backdrop,
`top-right corner stays visibly rounded at scroll ${position}`,
);
assert.deepEqual(
pixel(geometry.right - 1, geometry.bottom - 2),
backdrop,
`bottom-right corner stays visibly rounded at scroll ${position}`,
);
assert.notDeepEqual(
pixel(geometry.right - 2, (geometry.top + geometry.bottom) / 2),
backdrop,
"rounded action still paints the row background",
);
if (position === 0) await screenshot("monitor-rounded-row-start.png");
}
await scroll("0");
await checkActionContrast("list");
const columnOrder = await run(
'Array.from(document.querySelectorAll("th")).map(e=>e.dataset.monitorColumn)',
);
assert.deepEqual(columnOrder.slice(-2), ["status", "terminal"]);
assert.equal(
await run(
`Array.from(document.querySelectorAll('th [role=separator]')).every(e=>getComputedStyle(e,'::after').backgroundColor==='rgba(0, 0, 0, 0)')`,
),
true,
"column dividers are hidden at rest",
);
assert.equal(
await run('document.querySelectorAll("th [role=separator]").length'),
11,
"every column can be resized",
);
await scroll("0");
await until(
() => run('!!document.querySelector("[data-monitor-host=host-2]")'),
"variable-height row is rendered",
);
await until(
() =>
run(
'document.querySelector("[data-monitor-host=host-2]").getBoundingClientRect().height>150',
),
"all partitions determine row height",
);
assert.ok(
await run(
`(()=>{const rows=[...document.querySelectorAll('tbody tr')];return rows.every((r,i)=>!i||r.getBoundingClientRect().top>=rows[i-1].getBoundingClientRect().bottom)})()`,
),
"variable-height rows never overlap",
);
await click("[data-monitor-terminal=host-0]");
assert.deepEqual(await run("window.monitorFixture.connections()"), [
"host-1199",
"host-0",
]);
assert.equal(
await run(
'!!document.querySelector("[data-section=vault-monitor-detail]")',
),
false,
);
await run(
"document.querySelector('[data-section=vault-monitor-list]').scrollLeft=0",
);
const widths = {};
for (const key of [
"host",
"status",
"latency",
"tags",
"notes",
"cpu",
"memory",
"disks",
"network",
"updated",
"terminal",
]) {
const selector = `[data-monitor-column=${key}] [role=separator]`;
const point = await run(
`(()=>{const e=document.querySelector(${JSON.stringify(selector)});e.scrollIntoView({block:'nearest',inline:'center'});const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2,width:Number(e.getAttribute('aria-valuenow'))}})()`,
);
owner.webContents.sendInputEvent({
type: "mouseDown",
button: "left",
clickCount: 1,
x: Math.round(point.x),
y: Math.round(point.y),
});
owner.webContents.sendInputEvent({
type: "mouseMove",
x: Math.round(point.x + 28),
y: Math.round(point.y),
});
owner.webContents.sendInputEvent({
type: "mouseUp",
button: "left",
clickCount: 1,
x: Math.round(point.x + 28),
y: Math.round(point.y),
});
await until(
() =>
run(
`Number(document.querySelector(${JSON.stringify(selector)}).getAttribute('aria-valuenow'))>=${point.width + 27}`,
),
`${key} width responds to dragging`,
);
widths[key] = await run(
`Number(document.querySelector(${JSON.stringify(selector)}).getAttribute('aria-valuenow'))`,
);
assert.ok(
await run(
`(()=>{const h=document.querySelector('[data-monitor-column=${key}]');const i=[...h.parentElement.children].indexOf(h);const cell=document.querySelector('tbody tr').children[i];return Math.abs(h.getBoundingClientRect().width-cell.getBoundingClientRect().width)<1})()`,
),
"header and body column widths stay aligned",
);
}
await run(
"document.querySelector('[data-monitor-column=host] [role=separator]').focus()",
);
owner.webContents.sendInputEvent({ type: "keyDown", keyCode: "Right" });
widths.host += 8;
await until(
() =>
run(
`Number(document.querySelector('[data-monitor-column=host] [role=separator]').getAttribute('aria-valuenow'))===${widths.host}`,
),
"column size can also be adjusted by keyboard",
);
await run("window.monitorFixture.seed(1200,'list')");
await until(() => run('!!document.querySelector("table")'), "list remounts");
for (const [key, width] of Object.entries(widths))
assert.equal(
await run(
`Number(document.querySelector('[data-monitor-column=${key}] [role=separator]').getAttribute('aria-valuenow'))`,
),
width,
"column widths survive remounting",
);
await scroll("e.scrollHeight");
await until(
() => run('!!document.querySelector("[data-monitor-host=host-1199]")'),
"last table row can be reached",
);
await click("[data-monitor-terminal=host-1199]");
assert.deepEqual(
await run("window.monitorFixture.connections()"),
["host-1199"],
"recycled row opens the correct host",
);
await run(
`(()=>{const e=document.querySelector('input[placeholder="搜索主机、分组、标签或备注"]');Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(e,'1199');e.dispatchEvent(new Event('input',{bubbles:true}));})()`,
);
await until(
async () => (await count()) === 1,
"filtering at the bottom does not leave a blank virtual viewport",
);
assert.ok(
await run('document.body.textContent.includes("Production server 1199")'),
);
await run("window.monitorFixture.seed(40,'list')");
await until(
() => run('document.querySelectorAll("tbody tr").length>2'),
"unfiltered list restores",
);
await screenshot("monitor-list.png");
for (const width of [460, 1000]) {
owner.setSize(width, 900);
await wait(100);
await run("window.monitorFixture.seed(40,'charts')");
await until(
() => run('!!document.querySelector("[data-monitor-host]")'),
"cards fit resized viewport",
);
assert.ok(
await run(
'Array.from(document.querySelectorAll("[data-monitor-host]")).every(e=>e.getBoundingClientRect().height===368)',
),
"card height is fixed at all widths",
);
assert.ok(
await run(
"(()=>{const e=document.querySelector('[data-section=vault-monitor-list]');return e.scrollWidth<=e.clientWidth+1})()",
),
"cards do not overflow horizontally",
);
}
await screenshot("monitor-charts.png");
for (const locale of ["en", "zh-TW", "es", "ru"]) {
await run(
`window.monitorFixture.seed(40,'list',${JSON.stringify(locale)})`,
);
await until(
() => run('!!document.querySelector("table")'),
"translated table renders",
);
assert.equal(
await run('document.body.textContent.includes("monitor.")'),
false,
"no missing translation keys",
);
}
const openColumnMenu = async () => {
const point = await run(
`(()=>{document.querySelector('[data-section=vault-monitor-list]').scrollLeft=0;const r=document.querySelector('thead').getBoundingClientRect();return {x:Math.round(r.left+40),y:Math.round(r.top+20)}})()`,
);
for (const type of ["mouseDown", "mouseUp"])
owner.webContents.sendInputEvent({
type,
button: "right",
clickCount: 1,
...point,
});
await until(
() => run('!!document.querySelector("[data-monitor-column-menu]")'),
"header right-click opens column choices",
);
};
await run("window.monitorFixture.seed(8,'list','zh-CN')");
await until(
() => run('document.querySelectorAll("th").length===11'),
"all columns start visible",
);
await openColumnMenu();
assert.equal(
await run(
`['host','terminal'].every(k=>{const e=document.querySelector('[data-monitor-toggle-column='+k+']');return e.getAttribute('aria-checked')==='true'&&e.getAttribute('aria-disabled')==='true'})`,
),
true,
"host and terminal cannot be hidden",
);
const optional = columnOrder.filter((k) => !["host", "terminal"].includes(k));
for (const key of optional)
await click(`[data-monitor-toggle-column=${key}]`);
assert.deepEqual(
await run(
'[...document.querySelectorAll("th")].map(e=>e.dataset.monitorColumn)',
),
["host", "terminal"],
);
assert.ok(
await run(
'[...document.querySelectorAll("tbody tr")].every(e=>e.children.length===2)',
),
"hidden headings and row cells stay aligned",
);
owner.webContents.sendInputEvent({ type: "keyDown", keyCode: "Escape" });
owner.webContents.sendInputEvent({ type: "keyUp", keyCode: "Escape" });
await run("window.monitorFixture.seed(8,'list','zh-CN')");
await until(
() => run('document.querySelectorAll("th").length===2'),
"column visibility survives reopening",
);
const hostWidthBefore = await run(
'document.querySelector("tbody tr td").getBoundingClientRect().width',
);
await run(
'document.querySelector("[data-monitor-column=host] [role=separator]").focus()',
);
owner.webContents.sendInputEvent({ type: "keyDown", keyCode: "Right" });
owner.webContents.sendInputEvent({ type: "keyUp", keyCode: "Right" });
await until(
() =>
run(
`document.querySelector('tbody tr td').getBoundingClientRect().width===${hostWidthBefore + 8}`,
),
"host visibly resizes after other columns are hidden",
);
await openColumnMenu();
for (const key of optional)
await click(`[data-monitor-toggle-column=${key}]`);
owner.webContents.sendInputEvent({ type: "keyDown", keyCode: "Escape" });
owner.webContents.sendInputEvent({ type: "keyUp", keyCode: "Escape" });
await until(
() => run('document.querySelectorAll("th").length===11'),
"hidden columns can be restored",
);
for (const view of ["list", "charts"]) {
await run(`window.monitorFixture.seed(3,'${view}','zh-CN')`);
await until(
() => run('!!document.querySelector("[data-monitor-host=host-0]")'),
"status fixtures render",
);
for (const status of [
"login",
"error",
"disconnected",
"unsupported",
"queued",
"connecting",
"limit",
]) {
await run(`window.monitorFixture.status('host-0','${status}')`);
await until(
() =>
run(
`document.querySelector('[data-monitor-host=host-0] button').getAttribute('aria-disabled')==='true'`,
),
"unavailable host has disabled detail action",
);
assert.equal(
await run(
"getComputedStyle(document.querySelector('[data-monitor-host=host-0]')).opacity",
),
"0.5",
"unavailable host is dimmed",
);
await click("[data-monitor-host=host-0] button");
assert.equal(
await run(
'!!document.querySelector("[data-section=vault-monitor-detail]")',
),
false,
"unavailable host does not open details",
);
}
await run("window.monitorFixture.status('host-0','live',60000)");
await until(
() =>
run(
`document.querySelector('[data-monitor-host=host-0] button').getAttribute('aria-disabled')==='true'`,
),
"stale readings cannot open details",
);
await click("[data-monitor-host=host-0] button");
assert.equal(
await run(
'!!document.querySelector("[data-section=vault-monitor-detail]")',
),
false,
"stale host stays unavailable",
);
await click("[data-monitor-terminal=host-0]");
assert.deepEqual(
await run("window.monitorFixture.connections()"),
["host-0"],
"login host can still open a terminal",
);
await run("window.monitorFixture.status('host-0','login')");
const recoveryPoint = await run(
`(()=>{const e=document.querySelector('[data-monitor-host=host-0]');e.scrollIntoView({block:'nearest',inline:'nearest'});const r=e.getBoundingClientRect();return {x:Math.round(r.left+30),y:Math.round(r.top+20)}})()`,
);
for (const type of ["mouseDown", "mouseUp"])
owner.webContents.sendInputEvent({
type,
button: "right",
clickCount: 1,
...recoveryPoint,
});
await until(
() => run('!!document.querySelector("[data-monitor-retry=host-0]")'),
"unavailable host context menu offers recovery",
);
await click("[data-monitor-retry=host-0]");
await until(
() => run('!!document.querySelector("#monitor-login-password")'),
"recovery dialog opens separately from details",
);
assert.equal(
await run(
'!!document.querySelector("[data-section=vault-monitor-detail]")',
),
false,
);
await click("#monitor-login-password");
owner.webContents.insertText("fixture-password");
await click("[role=dialog] button[type=submit]");
await until(
() => run('!document.querySelector("#monitor-login-password")'),
"recovery clears password inputs",
);
assert.deepEqual(
await run("window.monitorFixture.retries()"),
[
{
id: "host-0",
credentials: { username: "fixture", password: "fixture-password" },
},
],
"recovery forwards one-time credentials to the existing monitor retry path",
);
await run("window.monitorFixture.status('host-0','live')");
await until(
() =>
run(
`document.querySelector('[data-monitor-host=host-0] button').getAttribute('aria-disabled')==='false'`,
),
"recovered host enables details",
);
await click("[data-monitor-host=host-0] button");
await until(
() =>
run('!!document.querySelector("[data-section=vault-monitor-detail]")'),
"live host opens details",
);
await run("window.monitorFixture.status('host-0','login')");
await until(
() =>
run('!document.querySelector("[data-section=vault-monitor-detail]")'),
"unavailable details close immediately",
);
}
owner.setSize(1280, 650);
await run("window.monitorFixture.seed(3,'charts','zh-CN',1800)");
await until(
() => run('!!document.querySelector("[data-monitor-host=host-0]")'),
"long monitoring history renders",
);
await click("[data-monitor-host=host-0] button");
await until(
() => run('!!document.querySelector("[data-aside-scroll-viewport]")'),
"native monitoring detail scroll opens",
);
await until(
() =>
run(
`document.querySelector('[data-section=vault-monitor-detail]').getAnimations().every(a=>a.playState==='finished')`,
),
"sidebar opening animation finishes before wheel input",
);
const wheelPoint = await run(
`(()=>{const v=document.querySelector('[data-aside-scroll-viewport]');const r=v.getBoundingClientRect();window.monitorRefreshTimer=setInterval(()=>window.monitorFixture.refresh(),200);window.monitorWheelProbe={events:0,blocking:0};window.monitorWheelListener=e=>{window.monitorWheelProbe.events++;if(e.cancelable)window.monitorWheelProbe.blocking++;};document.addEventListener('wheel',window.monitorWheelListener,{passive:true});return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+180)}})()`,
);
for (let i = 0; i < 60; i++) {
owner.webContents.sendInputEvent({
type: "mouseWheel",
...wheelPoint,
deltaY: -12,
deltaX: 0,
canScroll: true,
});
await wait(17);
}
await until(
() =>
run(
"document.querySelector('[data-aside-scroll-viewport]').scrollTop>200",
),
"wheel reaches lower detail sections with a half-hour history",
);
const wheelResult = await run("window.monitorWheelProbe");
assert.ok(
wheelResult.events > 40,
"real wheel input reached monitoring details",
);
assert.equal(
wheelResult.blocking,
0,
"detail wheel input does not wait for a cancellable handler",
);
assert.equal(
await run(
"document.querySelector('[data-section=vault-monitor-list]').scrollTop",
),
0,
"sidebar scroll leaves host collection in place",
);
await screenshot("monitor-native-detail-scroll.png");
await run(
"clearInterval(window.monitorRefreshTimer);document.removeEventListener('wheel',window.monitorWheelListener)",
);
await click("[data-section=vault-monitor-detail] button:has(.lucide-x)");
owner.setSize(1280, 900);
await run(
`(()=>{document.documentElement.classList.remove('dark');for(const [k,v] of Object.entries({background:'0 0% 100%',foreground:'210 20% 15%',card:'210 20% 97%',secondary:'210 20% 94%',muted:'210 20% 95%','muted-foreground':'210 10% 45%',border:'210 20% 88%',popover:'0 0% 100%','popover-foreground':'210 20% 15%'}))document.documentElement.style.setProperty('--'+k,v)})()`,
);
await run("window.monitorFixture.seed(40,'charts','zh-CN')");
await until(
() => run('!!document.querySelector("[data-monitor-host]")'),
"light cards render",
);
await screenshot("monitor-light.png");
assert.deepEqual(errors, [], "no renderer errors");
console.log(
JSON.stringify({
ok: true,
virtualHosts: 1200,
fixedCardHeight: 368,
terminalActions: true,
notesTooltip: true,
latencySignals: true,
gpuDetails: true,
statusBeforeAction: true,
allColumnsResize: true,
persistedWidths: true,
visibleColumns: true,
unavailableHosts: true,
nativeDetailScroll: true,
variableRowHeights: true,
filterAfterScroll: true,
locales: 5,
profile,
}),
);
}
app
.whenReady()
.then(main)
.catch(async (e) => {
console.error(e);
process.exitCode = 1;
if (owner && !owner.isDestroyed())
fs.writeFileSync(
path.join(profile, "failure.png"),
(await owner.webContents.capturePage()).toPNG(),
);
})
.finally(async () => {
clearTimeout(watchdog);
owner?.destroy();
await vite?.close();
app.exit(process.exitCode || 0);
});
+1
View File
@@ -327,6 +327,7 @@ declare global {
netRxSpeed: number; // Total network receive speed (bytes/sec)
netTxSpeed: number; // Total network transmit speed (bytes/sec)
latencyMs: number | null; // SSH transport ping on the existing session, not a second TCP dial
responseLatencyMs?: number | null; // Existing stats exec response, including remote channel setup
netInterfaces: Array<{ // Per-interface network stats
name: string; // Interface name (e.g., eth0, ens33)
rxBytes: number; // Total received bytes