Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .claude/skills/ux/references/findings.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,37 @@ checklist.md or motion.md as well.
the answers; a send no receiver took says "Not delivered to <session>: <reason>" with a Send again button, for
every kind of agent (`resend-answers`). Rule: a send reports what the receiver confirmed, never what was attempted,
and every not-delivered result offers the retry in place.
- 2026-10-24 18:09 — **The hover list closed under a scrolling pointer, and the scroll went to the window below.**
Under the Glass theme the gaps between session rows were not part of the panel: SwiftUI does not hit-test Liquid
Glass itself, so with the pointer still, each gap that scrolled under it read as "the pointer left", and 220 ms later
the preview closed. Solid themes never showed it (their fill is hit-tested). A window-server probe
(`SLSFindWindowAndOwner`) showed the window did own those points, which ruled out click-through; synthetic taps in a
scratch window then showed taps on the gap reaching nothing. Fix: the glass surfaces take `.contentShape` of their
shape (`NativeGlassShapeSurface`, `NativeGlassControl`, `WidgetGlassControl`). Rule: a glass panel is tested with a
click between its rows, not only on them.
- 2026-10-24 18:09 — **A session was "missing" from the list because it showed under its first prompt.** Claude Code
names a session itself (`ai-title` lines, the name its /resume list and the terminal tab show); the history reader
knew only `custom-title` and the old `summary`, so the row read "pls /genesis-tools:research with xhigh…" while
Martin looked for "Kalendora hotfix research". Fix: `ai-title` is the summary (a `/rename` still wins), parser
version 6. Rule: a row's title is the name the user sees for that thing elsewhere.
- 2026-10-24 18:09 — **"Loading this session's inbox…" on every switch, and the pane shrank and grew back.** The cards
came only with a snapshot naming the session: a `hub widget call` process (330 ms), the watch's 180 ms debounce, the
snapshot (65 to 105 ms). Fix: every snapshot carries the cards of up to 16 sessions that need you (`warmKeys`), the
widget caches each session it saw (`WidgetSessionCache`), the pick goes to the watch's stdin at once, the pane keeps
its height while a never-seen session loads, and nearby messages show the last read while a fresh one runs. Rule: a
switch shows what is known at once and lets the fresh data replace it.
- 2026-10-24 19:43 — **Correction to the 18:09 hover-list entry: the glass gaps were not the cause.** Inside a ScrollView the gaps
between rows are hit-tested under plain glass too (probe: row and gap reach the panel; only a blank part outside the
scroll view does not), so the contentShape fix never touched this bug, and the list kept closing (19:01:00 to
19:01:38, three times while Martin scrolled). What closes it is SwiftUI's `.onHover` reporting "left" with the
pointer still on the panel. Fix: the panel's window decides "pointer inside" by geometry (an NSTrackingArea on
`EdgePanelRootView`), and SwiftUI's hover only logs `widget.hover swiftui-left … under it: <view chain>` when it
disagrees. Rule: a window-level "pointer inside" comes from the window's geometry, never from a hit test, and a fix
is not done until the user's own repro stops.
- 2026-10-24 19:43 — **A bug that is a burst of processes was invisible to every watcher.** Genesis Markdown started one
`tools agents blame` per open note on every repository refresh: 51 to 66 at once, about 9,300 an hour, load average
380-580 and launchservicesd at 69-159%, while its log held a blame line only when one run was slow. Fix: one blame per
repository for every note that asked (`AgentBlameQueue`), and GenesisKit's `SpawnMeter` writes `spawn.storm` to the
perf log when 40 children start within 10 s or 16 run at once; `tools hub dev monitor` reports it and now also
reads Genesis Markdown's `~/.genesis/logs/perf.log`. Rule: work that starts processes per item is queued per shared
resource, and the launcher counts what it starts.
2 changes: 1 addition & 1 deletion src/hub/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export function registerDevCommands(program: Command): void {
)
.action(async (flags: MonitorFlags) => {
const sources = devMonitorSources();
const start = `[${new Date().toTimeString().slice(0, 8)}] monitor watching ${sources.perfLog}, ${sources.profileLog()}, ${sources.relayLog}, ${sources.hangs}, ${sources.crashes}`;
const start = `[${new Date().toTimeString().slice(0, 8)}] monitor watching ${sources.perfLog}, ${sources.genesisPerfLog}, ${sources.profileLog()}, ${sources.relayLog}, ${sources.hangs}, ${sources.crashes}`;
out.print(`${flags.json ? SafeJSON.stringify({ kind: "start", text: start }) : start}\n`);
await withInterrupt((signal) =>
runDevMonitor({
Expand Down
157 changes: 156 additions & 1 deletion src/hub/lib/dev-monitor.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, spyOn, test } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
Expand Down Expand Up @@ -53,6 +53,52 @@ describe("classifyPerfLine", () => {
).toBe("hang");
});

test("a burst of child processes is an event; only ongoing lines share a source-specific key", () => {
expect(
classifyPerfLine(
"[19:26:02.100] mark spawn.storm 51 child processes started in 10 s, 49 running; GenesisTools agents blame ×51"
)
).toMatchObject({ kind: "storm", time: "19:26:02.100", key: undefined });
const ongoing = "[19:26:32.100] mark spawn.storm ongoing 30 s: 400 child processes started";
expect(classifyPerfLine(ongoing, DEFAULT_CLASSIFY, "/tools/hangs")?.key).toBe("/tools/hangs:storm:ongoing");
expect(classifyPerfLine(ongoing, DEFAULT_CLASSIFY, "/genesis/hangs")?.key).toBe("/genesis/hangs:storm:ongoing");
expect(classifyPerfLine("[19:26:33.100] mark spawn.storm over after 30 s")?.key).toBeUndefined();
});

test("a Genesis Markdown line carries its tag, and its hang sample lives in its own folder", () => {
const event = classifyPerfLine(
"[17:12:43.850] [md] mark main-stall 1.0s so far — sampling to hang-2026-10-23-171243.txt recent=[x]",
DEFAULT_CLASSIFY,
"/home/fixture/.genesis/logs/hangs"
);
expect(event).toMatchObject({
kind: "hang",
file: "/home/fixture/.genesis/logs/hangs/hang-2026-10-23-171243.txt",
text: "[md] main-stall 1.0s so far — sampling to hang-2026-10-23-171243.txt",
});
expect(classifyPerfLine("[17:11:59.681] [md] mark main-stall recovered after 1806ms load=159")?.kind).toBe(
"stall"
);
});

test("tagged helper events remain distinct from otherwise identical parent events", () => {
for (const body of [
"spawn.storm ongoing 30 s: 100 child processes started",
"main-spin 5s at 99% cpu: the main thread never idles",
"main-stall recovered after 1806ms",
]) {
const parent = classifyPerfLine(`[10:00:00] mark ${body}`, DEFAULT_CLASSIFY, "/genesis/hangs");
const helper = classifyPerfLine(`[10:00:00] [md] mark ${body}`, DEFAULT_CLASSIFY, "/genesis/hangs");
expect(parent?.text).toBe(body);
expect(helper?.text).toBe(`[md] ${body}`);
expect(helper?.kind).toBe(parent?.kind);

if (parent && helper) {
expect(collapse([parent, helper])).toHaveLength(2);
}
}
});

test("a main thread spinning at full CPU is an event, and its sample names the file", () => {
expect(classifyPerfLine("[17:10:32.100] mark main-spin 5s at 99% cpu: the main thread never idles")?.kind).toBe(
"spin"
Expand Down Expand Up @@ -258,3 +304,112 @@ test("log tails read bounded chunks without losing lines or UTF-8 at chunk bound
rmSync(dir, { recursive: true, force: true });
}
});

test("both perf tails retain their storm updates and endings in a batch", async () => {
const dir = mkdtempSync(join(tmpdir(), "monitor-storm-"));
const sources = monitorFixtureSources(dir);
const batches: DevEvent[][] = [];
const controller = new AbortController();
writeFileSync(
sources.perfLog,
[
"[10:00:00] mark spawn.storm ongoing 30 s: 100 child processes started",
"[10:00:01] mark spawn.storm ongoing 60 s: 200 child processes started",
"[10:00:02] mark spawn.storm over after 60 s",
"[10:00:03] mark spawn.storm over after 1 s",
"",
].join("\n")
);
writeFileSync(
sources.genesisPerfLog,
[
"[10:00:00] mark spawn.storm ongoing 30 s: 250 child processes started",
"[10:00:00] [md] mark spawn.storm ongoing 30 s: 300 child processes started",
"[10:00:02] [md] mark spawn.storm over after 30 s",
"",
].join("\n")
);

try {
await runDevMonitor({
...DEFAULT_CLASSIFY,
sources,
fromStart: true,
intervalMs: 100,
minDelayMs: 0,
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(2000)]),
emit: (events) => {
batches.push(events);
controller.abort();
},
});
expect(batches).toHaveLength(1);
expect(batches[0].map((event) => event.text)).toEqual([
"spawn.storm ongoing 60 s: 200 child processes started (×2)",
"spawn.storm over after 60 s",
"spawn.storm over after 1 s",
"Genesis: spawn.storm ongoing 30 s: 250 child processes started",
"Genesis: [md] spawn.storm ongoing 30 s: 300 child processes started",
"Genesis: [md] spawn.storm over after 30 s",
]);
expect(batches[0][0].time).toBe("10:00:01");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test("a logged hang in one app cannot suppress the other app's same-named unannounced hang", async () => {
const dir = mkdtempSync(join(tmpdir(), "monitor-hangs-"));
const sources = monitorFixtureSources(dir);
const name = "hang-2026-01-01-100000.txt";
const batches: DevEvent[][] = [];
const controller = new AbortController();
writeFileSync(sources.perfLog, `[10:00:00] mark main-stall 1s so far sampling to ${name}\n`);

try {
await runDevMonitor({
...DEFAULT_CLASSIFY,
sources,
fromStart: true,
intervalMs: 100,
minDelayMs: 0,
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(2000)]),
emit: (events) => {
batches.push(events);
if (batches.length === 1) {
writeFileSync(join(sources.hangs, name), "logged sample");
writeFileSync(join(sources.genesisHangs, name), "unannounced sample");
} else {
controller.abort();
}
},
});
expect(batches).toHaveLength(2);
expect(batches[0]).toHaveLength(1);
expect(batches[0][0].file).toBe(join(sources.hangs, name));
expect(batches[1]).toHaveLength(1);
expect(batches[1][0]).toMatchObject({
kind: "hang",
file: join(sources.genesisHangs, name),
text: "a hang sample was written with no app-perf.log line",
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

function monitorFixtureSources(dir: string) {
const sources = {
perfLog: join(dir, "app-perf.log"),
genesisPerfLog: join(dir, "perf.log"),
genesisHangs: join(dir, "genesis-hangs"),
profileLog: () => join(dir, "profiling.log"),
relayLog: join(dir, "relay.log"),
hangs: join(dir, "hangs"),
crashes: join(dir, "crashes"),
};
for (const folder of [sources.hangs, sources.genesisHangs, sources.crashes]) {
mkdirSync(folder);
}
return sources;
}
79 changes: 59 additions & 20 deletions src/hub/lib/dev-monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ export interface DevEvent {
| "error"
| "relay"
| "crash"
| "slow";
| "slow"
| "storm";
/** The time the source line carries ("01:46:40.479"), or the file's time for a crash or a hang file. */
time: string;
text: string;
Expand Down Expand Up @@ -138,24 +139,45 @@ export function shortCommand(command: string): string {

const timePattern = /^\[(\d\d:\d\d:\d\d(?:\.\d+)?)\]\s*/;

/** An app-perf.log line as an event, or null for the routine ones (spans, marks, quick stalls). */
export function classifyPerfLine(line: string, options: ClassifyOptions = DEFAULT_CLASSIFY): DevEvent | null {
/**
* A perf log line as an event, or null for the routine ones (spans, marks, quick stalls). GenesisTools writes
* app-perf.log; Genesis Markdown writes `~/.genesis/logs/perf.log`, its lines tagged after the time (`[md] mark …`),
* and its hang samples go to `hangsDir`.
*/
export function classifyPerfLine(
line: string,
options: ClassifyOptions = DEFAULT_CLASSIFY,
hangsDir = genesisToolsDir("logs", "hangs")
): DevEvent | null {
const time = line.match(timePattern)?.[1] ?? "";
const body = line.replace(timePattern, "").replace(/^mark\s+/, "");
const short = (text: string) => text.replace(/ recent=\[.*$/, "").slice(0, 300);
const message = line.replace(timePattern, "");
const tag = message.match(/^(\[[a-z-]+\])\s+/)?.[1] ?? "";
const body = message.replace(/^\[[a-z-]+\]\s+/, "").replace(/^mark\s+/, "");
const short = (text: string) => `${tag ? `${tag} ` : ""}${text.replace(/ recent=\[.*$/, "")}`.slice(0, 300);

if (/WEDGED|main-stall ongoing/.test(body)) {
return { kind: "wedge", time, text: short(body) };
}

// A burst of child processes (GenesisKit `SpawnMeter`): one blame per open note was 9,300 launches an hour.
if (/^spawn\.storm /.test(body)) {
const source = tag ? `${hangsDir}:${tag}` : hangsDir;
return {
kind: "storm",
time,
text: short(body),
key: /^spawn\.storm ongoing\b/.test(body) ? `${source}:storm:ongoing` : undefined,
};
}

const sampled = body.match(/main-(?:stall|spin) .*sampling to (\S+)/);
if (sampled) {
return { kind: "hang", time, text: short(body), file: genesisToolsDir("logs", "hangs", sampled[1]) };
return { kind: "hang", time, text: short(body), file: join(hangsDir, sampled[1]) };
}

const stacks = body.match(/main-stall stacks .*→ (\S+)/);
if (stacks) {
return { kind: "hang", time, text: short(body), file: genesisToolsDir("logs", "hangs", stacks[1]) };
return { kind: "hang", time, text: short(body), file: join(hangsDir, stacks[1]) };
}

// A main thread that never idles (an update loop) never delays a ping, so HangWatch reports it by CPU time.
Expand Down Expand Up @@ -330,6 +352,8 @@ class FolderWatch {
}

export interface DevMonitorOptions extends ClassifyOptions {
/** Override the watched paths, for an isolated monitor or fixture. */
sources?: ReturnType<typeof devMonitorSources>;
/** Replay what the logs already hold instead of starting at their end. */
fromStart: boolean;
/** How often the files are read. */
Expand Down Expand Up @@ -372,7 +396,7 @@ export class EventBatcher {
}
}

/** Events with one key become the first of them plus "×N, max …", in the place of the first. */
/** Events with one key share a count and any measured maximum; storm updates retain their latest state. */
export function collapse(events: DevEvent[]): DevEvent[] {
const groups = new Map<string, DevEvent[]>();
const result: DevEvent[] = [];
Expand Down Expand Up @@ -402,15 +426,20 @@ export function collapse(events: DevEvent[]): DevEvent[] {
return event;
}

const max = Math.max(...group.map((item) => item.ms ?? 0));
return { ...event, text: `${event.text} (×${group.length}, max ${(max / 1000).toFixed(2)}s)` };
const latest = event.kind === "storm" ? group[group.length - 1] : event;
const durations = group.flatMap((item) => (item.ms === undefined ? [] : [item.ms]));
const maximum = durations.length > 0 ? `, max ${(Math.max(...durations) / 1000).toFixed(2)}s` : "";
return { ...latest, text: `${latest.text} (×${group.length}${maximum})` };
});
}

/** The files the monitor reads, for its start line and for tests. */
export function devMonitorSources() {
return {
perfLog: genesisToolsDir("logs", "app-perf.log"),
/** Genesis and Genesis Markdown, which share GenesisKit's hang watcher and spawn meter. */
genesisPerfLog: join(homedir(), ".genesis", "logs", "perf.log"),
genesisHangs: join(homedir(), ".genesis", "logs", "hangs"),
profileLog: () => genesisToolsDir("logs", `${formatLocalDate(new Date())}-profiling.log`),
relayLog: genesisToolsDir("app", "link-relay.log"),
hangs: genesisToolsDir("logs", "hangs"),
Expand All @@ -420,27 +449,37 @@ export function devMonitorSources() {

/** Runs until `signal` aborts. Reads by interval: app-perf.log is written by several processes at once. */
export async function runDevMonitor(options: DevMonitorOptions): Promise<void> {
const sources = devMonitorSources();
const sources = options.sources ?? devMonitorSources();
const perf = new TextTail(sources.perfLog, options.fromStart);
const genesisPerf = new TextTail(sources.genesisPerfLog, options.fromStart);
const relay = new TextTail(sources.relayLog, options.fromStart);
const profile = new TextTail(sources.profileLog, options.fromStart);
const crashes = new FolderWatch(sources.crashes, (name) => /^Genesis/.test(name) && /\.(ips|crash)$/.test(name));
// Hang files are announced by their app-perf.log line already; a file with no line means the
// process could not log any more, which is the case worth a separate event.
// `.txt` only: the daily pass stores captures older than yesterday as `hang-….txt.zst`, which is not a new hang.
const hangs = new FolderWatch(sources.hangs, (name) => name.startsWith("hang-") && name.endsWith(".txt"));
const genesisHangs = new FolderWatch(
sources.genesisHangs,
(name) => name.startsWith("hang-") && name.endsWith(".txt")
);
const announced = new Set<string>();
const batcher = new EventBatcher(options.minDelayMs, options.emit);

while (!options.signal.aborted) {
for (const line of perf.read()) {
const event = classifyPerfLine(line, options);
if (event) {
if (event.file) {
announced.add(basename(event.file));
}
for (const [tail, hangsDir, app] of [
[perf, sources.hangs, ""],
[genesisPerf, sources.genesisHangs, "Genesis: "],
] as const) {
for (const line of tail.read()) {
const event = classifyPerfLine(line, options, hangsDir);
if (event) {
if (event.file) {
announced.add(event.file);
}

batcher.add(event);
batcher.add({ ...event, text: app + event.text });
Comment thread
genesiscz marked this conversation as resolved.
}
}
}

Expand All @@ -455,8 +494,8 @@ export async function runDevMonitor(options: DevMonitorOptions): Promise<void> {
}
}

for (const path of hangs.fresh()) {
if (!announced.has(basename(path))) {
for (const path of [...hangs.fresh(), ...genesisHangs.fresh()]) {
if (!announced.has(path)) {
batcher.add({
kind: "hang",
time: "",
Expand Down
Loading
Loading