merge origin/main (779 commits) into ethie/pm-clean

Branch semantics kept where main and PM disagree: update_cmd_deps.py,
constraints-termux.txt, the Electron update-api-check module and the
post-swap hand-off test stay deleted; the pending-fleet-restart catch-up
and the local_runtime tag/download ladder stay retired (PM owns engines).

Ported from main onto the branch's shape: profile_scoped_chore for the
auto-archive and plugin-update housekeeping chores, the local-runtime
cross-process boot lock and residency cap, the checkpoint tmp_pack sweep,
the cua daemon-liveness status probe, the remote-served Desktop update
flag (posix.sh / windows.ps1), sign-in for env-pinned remote gateways
(urlDisabled on RemoteSetupFields), the uvloop extra split (uvicorn
without [standard]), and the umask-scoping spawn test.

uv.lock regenerated with pm.build_env --lock-only; new utf-8 reads from
main switched to utf-8-sig (check-windows-footguns).
This commit is contained in:
ethernet
2026-09-21 00:58:39 -04:00
1384 changed files with 51561 additions and 26427 deletions

View File

@@ -0,0 +1,115 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* One "browser tab": its own sessionStorage, its own module state (the claimed
* token) and the pages share the fake Web Locks manager, exactly like tabs on
* one origin do.
*/
type Tab = {
ptyAttachToken: (rotate?: boolean) => Promise<string>;
storage: Storage;
};
const KEY = "hermes.pty.token.chat";
function fakeStorage(seed: Record<string, string> = {}): Storage {
const store: Record<string, string> = { ...seed };
return {
clear: () => {
for (const key of Object.keys(store)) delete store[key];
},
getItem: (key: string) => store[key] ?? null,
key: (index: number) => Object.keys(store)[index] ?? null,
get length() {
return Object.keys(store).length;
},
removeItem: (key: string) => {
delete store[key];
},
setItem: (key: string, value: string) => {
store[key] = String(value);
},
} as Storage;
}
/** Web Locks, as far as attach-token claiming uses them. */
function fakeLocks(held: Set<string>, requests: string[] = []) {
return {
request: (
name: string,
_options: { ifAvailable?: boolean },
callback: (lock: { name: string } | null) => unknown,
) => {
requests.push(name);
if (held.has(name)) {
callback(null);
return Promise.resolve();
}
held.add(name);
return Promise.resolve(callback({ name }));
},
};
}
/** The lock a tab holds disappears when the document unloads (reload/close). */
function simulateUnload(held: Set<string>) {
held.clear();
}
async function openTab(
held: Set<string>,
seed: Record<string, string> = {},
requests: string[] = [],
): Promise<Tab> {
vi.resetModules(); // a new document has its own claimed-token state
const storage = fakeStorage(seed);
Object.defineProperty(window, "sessionStorage", { configurable: true, value: storage });
Object.defineProperty(window.navigator, "locks", {
configurable: true,
value: fakeLocks(held, requests),
});
const { ptyAttachToken } = await import("./pty-attach-token");
return { ptyAttachToken, storage };
}
beforeEach(() => {
vi.stubGlobal("crypto", {
getRandomValues: (values: Uint8Array) => {
values.fill(Math.floor(Math.random() * 256));
return values;
},
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
delete (window.navigator as { locks?: unknown }).locks;
});
describe("ptyAttachToken", () => {
it("mints its own token for a duplicate tab instead of sharing the live one", async () => {
const held = new Set<string>();
const first = await openTab(held);
const tokenA = await first.ptyAttachToken();
// Chrome's "Duplicate tab" clones sessionStorage into the new tab.
const duplicate = await openTab(held, { [KEY]: tokenA });
const tokenB = await duplicate.ptyAttachToken();
expect(tokenB).not.toBe(tokenA);
expect(duplicate.storage.getItem(KEY)).toBe(tokenB);
});
it("reuses the stored token after a reload, once the old document released it", async () => {
const held = new Set<string>();
const before = await openTab(held, { [KEY]: "tab-token" });
expect(await before.ptyAttachToken()).toBe("tab-token");
simulateUnload(held); // reload: the previous document's lock is gone
const reloaded = await openTab(held, { [KEY]: "tab-token" });
expect(await reloaded.ptyAttachToken()).toBe("tab-token");
});
});

View File

@@ -0,0 +1,76 @@
/**
* Identity of THIS browser tab's keep-alive PTY, sent as `?attach=`.
*
* The dashboard's PTY registry maps one attach token to exactly one PTY, so a
* token two tabs present makes the second tab take the first one's terminal
* over (the first is closed 4409 and goes dead — the reported multi-tab
* interference). localStorage is shared per ORIGIN, so every tab read the same
* value, and Chrome's "Duplicate tab" clones sessionStorage into the new tab,
* so neither storage alone isolates the tabs. The token therefore lives in
* sessionStorage *and* is claimed with a Web Lock: a tab whose token is
* already claimed by a live document mints its own instead of sharing one.
* See #115304.
*/
const PTY_ATTACH_TOKEN_KEY = "hermes.pty.token.chat";
/** The token this document claimed — a reconnect must not re-request the lock
* (Web Locks are not reentrant, so asking twice in one document deadlocks). */
let claimedToken: string | null = null;
function tabStorage(): Storage | null {
try {
return typeof window === "undefined" ? null : window.sessionStorage;
} catch {
return null; /* private mode / storage blocked */
}
}
function mint(): string {
const a = new Uint8Array(16);
crypto.getRandomValues(a);
return Array.from(a, (b) => b.toString(16).padStart(2, "0")).join("");
}
/** Never settles, so the lock stays held for this document's lifetime. */
const HOLD: Promise<void> = new Promise<void>(() => {});
/**
* Claim `token` for this document. True when we now hold it; the browser
* releases it when the document unloads, so a reload of this tab finds the
* token free again while a live second tab does not. Browsers without the Web
* Locks API fall back to sessionStorage isolation only.
*/
async function claim(token: string): Promise<boolean> {
const locks = typeof navigator === "undefined" ? undefined : navigator.locks;
if (!locks) return true;
return new Promise<boolean>((resolve) => {
void locks.request(`hermes.pty.attach.${token}`, { ifAvailable: true }, (lock) => {
resolve(lock !== null);
return lock ? HOLD : Promise.resolve();
});
});
}
/**
* This tab's `?attach=` token, minted when `rotate` starts a fresh session (the
* old keep-alive PTY must NOT be reattached) or when another live tab already
* claimed the stored one.
*/
export async function ptyAttachToken(rotate = false): Promise<string> {
const stored =
claimedToken ?? tabStorage()?.getItem(PTY_ATTACH_TOKEN_KEY) ?? "";
if (!rotate && stored && (stored === claimedToken || (await claim(stored)))) {
claimedToken = stored;
return stored;
}
const token = mint();
try {
tabStorage()?.setItem(PTY_ATTACH_TOKEN_KEY, token);
} catch {
/* ignore */
}
await claim(token);
claimedToken = token;
return token;
}

View File

@@ -133,3 +133,58 @@ describe("createPtyCompositionForwarder", () => {
expect(send).not.toHaveBeenCalled();
});
});
describe("mobile IME double-send dedup (#115505)", () => {
afterEach(() => vi.useRealTimers());
it("delivers a word exactly once across the three Android/Gboard double-send paths", () => {
vi.useFakeTimers();
const send = vi.fn();
const forwarder = createPtyCompositionForwarder(send);
// 1. compositionend fires twice with identical data.
forwarder.onCompositionEnd("hello");
vi.advanceTimersByTime(16);
forwarder.onCompositionEnd("hello");
vi.advanceTimersByTime(16);
expect(send).toHaveBeenCalledExactlyOnceWith("hello");
// 2. onData already carried the commit; a trailing compositionend must not re-arm the fallback.
vi.advanceTimersByTime(100);
forwarder.noteTerminalData("wor");
forwarder.noteTerminalData("ld");
forwarder.onCompositionEnd("world");
vi.runAllTimers();
expect(send).toHaveBeenCalledTimes(1);
// 3. onData echoes (or strictly extends) text the composition path just committed.
vi.advanceTimersByTime(100);
forwarder.onCompositionEnd("again");
vi.advanceTimersByTime(16);
expect(send).toHaveBeenLastCalledWith("again");
expect(forwarder.filterTerminalData("again")).toBe("");
expect(forwarder.filterTerminalData("again ")).toBe(" ");
});
it("keeps real repeats and unrelated input: outside the echo window and for different text", () => {
vi.useFakeTimers();
const send = vi.fn();
const forwarder = createPtyCompositionForwarder(send);
forwarder.onCompositionEnd("hello");
vi.advanceTimersByTime(16);
vi.advanceTimersByTime(100);
forwarder.onCompositionEnd("hello");
vi.advanceTimersByTime(16);
expect(send).toHaveBeenCalledTimes(2);
forwarder.noteTerminalData("abc");
forwarder.onCompositionEnd("xyz");
vi.runAllTimers();
expect(send).toHaveBeenLastCalledWith("xyz");
expect(forwarder.filterTerminalData("other")).toBe("other");
vi.advanceTimersByTime(100);
expect(forwarder.filterTerminalData("xyz")).toBe("xyz");
});
});

View File

@@ -1,14 +1,40 @@
/**
* Delays an IME/dead-key commit just long enough for xterm to emit onData.
*
* xterm is authoritative when it emits the commit. Browsers/layouts where it
* xterm is authoritative when it emits onData. Browsers/layouts where it
* does not emit onData still forward the compositionend text on the next turn.
*
* Mobile IME keyboards (Android/Gboard) turn composition events and onData
* into overlapping sources of the same user intent instead of exclusive
* ones: compositionend can fire twice with identical data, it can trail
* onData that already carried the committed text, and onData can echo a
* word the composition path just forwarded. The forwarder keeps a short
* record of both delivery channels and drops these re-sends.
*/
// How long committed text stays comparable across the two input channels.
// IME re-fires land within a few milliseconds; a human repeating the same
// commit takes far longer, so the window will not swallow real retypes.
const ECHO_WINDOW_MS = 80;
// Cap on retained plain terminal input; only recent text is compared, and
// this keeps a long typing burst from growing the record without bound.
const MAX_DELIVERED_TRACK_CHARS = 256;
interface DeliveredText {
text: string;
at: number;
}
export function createPtyCompositionForwarder(send: (data: string) => void) {
let pending: string | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
let matchedTerminalPrefix = "";
let sawUnrelatedTerminalData = false;
// Text recently delivered to the PTY through this forwarder's own send.
let lastSent: DeliveredText | null = null;
// Plain text recently delivered to the PTY through xterm's onData.
let lastTerminalData: DeliveredText | null = null;
const clearPending = () => {
pending = null;
@@ -20,21 +46,49 @@ export function createPtyCompositionForwarder(send: (data: string) => void) {
}
};
const sendCommitted = (data: string) => {
lastSent = { text: data, at: Date.now() };
send(data);
};
const withinEchoWindow = (entry: DeliveredText | null): entry is DeliveredText =>
entry !== null && Date.now() - entry.at <= ECHO_WINDOW_MS;
return {
onCompositionEnd(data: string | null) {
if (!data) return;
// A compositionend trailing terminal data that already carried the
// commit is a late duplicate, not a new commit — re-sending it
// through the fallback duplicates the word in the PTY.
if (withinEchoWindow(lastTerminalData) && lastTerminalData.text.endsWith(data)) {
return;
}
// The same IME can fire compositionend twice with identical data;
// the first copy has already been delivered.
if (withinEchoWindow(lastSent) && lastSent.text === data) {
return;
}
// Preserve rapid consecutive commits instead of discarding the first.
const previous = pending;
clearPending();
if (previous) send(previous);
if (previous) sendCommitted(previous);
pending = data;
timer = setTimeout(() => {
const committed = pending;
clearPending();
if (committed) send(committed);
if (committed) sendCommitted(committed);
}, 16);
},
noteTerminalData(data: string) {
if (!data.startsWith("\x1b")) {
const now = Date.now();
const carried = withinEchoWindow(lastTerminalData) ? lastTerminalData.text : "";
lastTerminalData = {
text: (carried + data).slice(-MAX_DELIVERED_TRACK_CHARS),
at: now,
};
}
if (!pending || data.startsWith("\x1b") || sawUnrelatedTerminalData) return;
// xterm may split committed text across callbacks, but only a clean,
@@ -49,6 +103,22 @@ export function createPtyCompositionForwarder(send: (data: string) => void) {
sawUnrelatedTerminalData = true;
}
},
dispose: clearPending,
// A mobile IME can re-emit just-committed composition text through
// xterm's onData after the composition path already forwarded it.
// Returns the portion still worth forwarding: "" for a full duplicate,
// the new suffix for a strict extension, the input unchanged otherwise.
filterTerminalData(data: string): string {
if (!withinEchoWindow(lastSent)) return data;
if (data === lastSent.text) return "";
if (data.length > lastSent.text.length && data.startsWith(lastSent.text)) {
return data.slice(lastSent.text.length);
}
return data;
},
dispose: () => {
clearPending();
lastSent = null;
lastTerminalData = null;
},
};
}

View File

@@ -92,39 +92,16 @@ import {
ptyRejectionBanner,
type PtyBannerAction,
} from "@/lib/pty-close-copy";
import { ptyAttachToken } from "@/lib/pty-attach-token";
import { loseWebglContexts } from "@/lib/xterm-webgl-release";
import { PluginSlot } from "@/plugins";
import { useTheme } from "@/themes";
import { useProfileScope } from "@/contexts/useProfileScope";
import { errorMessage } from "@/lib/api-error";
// Stable per-browser token identifying THIS chat tab's keep-alive PTY session.
// Sent as ?attach=; lets a refresh/disconnect reattach to the same live process
// instead of spawning a fresh one. Per-localStorage, so other devices can't grab it.
// ``rotate`` mints a new token — used when the user explicitly starts a fresh
// session so the old keep-alive PTY is NOT reattached (the registry reaps it).
const PTY_ATTACH_TOKEN_KEY = "hermes.pty.token.chat";
function ptyAttachToken(rotate = false): string {
let t = "";
if (!rotate) {
try {
t = window.localStorage.getItem(PTY_ATTACH_TOKEN_KEY) ?? "";
} catch {
/* private mode / storage blocked */
}
}
if (!t) {
const a = new Uint8Array(16);
crypto.getRandomValues(a);
t = Array.from(a, (b) => b.toString(16).padStart(2, "0")).join("");
try {
window.localStorage.setItem(PTY_ATTACH_TOKEN_KEY, t);
} catch {
/* ignore */
}
}
return t;
}
// Per-tab keep-alive identity (`?attach=`): lives in pty-attach-token.ts so a
// second tab — including a Chrome "Duplicate tab" — gets its own PTY instead of
// taking over this one. See #115304.
// Channel id ties this chat tab's PTY child (publisher) to its sidebar
// (subscriber). Generated once per mount so a tab refresh starts a fresh
@@ -1254,7 +1231,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
// Keep-alive identity: reattach to this tab's living PTY across
// refresh/transient drops. A forced-fresh start rotates the token so
// the previous keep-alive PTY is not reattached (registry reaps it).
params.attach = ptyAttachToken(forceFresh);
params.attach = await ptyAttachToken(forceFresh);
// Profile-scoped chat: the PTY child gets HERMES_HOME pointed at the
// selected profile, so the conversation runs with that profile's model,
// skills, memory, and sessions (see web_server._resolve_chat_argv).
@@ -1553,7 +1530,12 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
if (!SGR_MOUSE_RE.test(data)) {
compositionForwarder.noteTerminalData(data);
}
forwardPtyData(data);
// A mobile IME can re-emit just-committed composition text through
// onData; only the part that is not an echo of that commit is real.
const unechoed = compositionForwarder.filterTerminalData(data);
if (unechoed) {
forwardPtyData(unechoed);
}
});
onResizeDisposable = term.onResize(({ cols, rows }) => {