feat(backends,api,ui): process trees with cpu and stderr per session, interrupt, close and kill from the system page

This commit is contained in:
hh committed 2026-10-13 01:22:41 +02:00
1 parent 9875f08016
commit 712c24de4e
12 files changed
+996 -127

No files matched your search

+324 -76
View File
@@ -1,16 +1,39 @@
<script lang="ts">
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { onMount } from "svelte";
import { base } from "$app/paths";
import type { SessionsResponse } from "$lib/api/types";
import type {
ProcessRow,
SessionRow,
SessionsResponse,
} from "$lib/api/types";
import ErrorNote from "$lib/components/error-note.svelte";
import { fmtBytes, fmtSeconds, shortId } from "$lib/format";
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { fmtBytes, fmtCpu, fmtSeconds, shortId } from "$lib/format";
import { gateway } from "$lib/gateway.svelte";
import { session } from "$lib/session.svelte";
import KindMark from "$lib/shell/kind-mark.svelte";
import ProcessTable from "$lib/system/process-table.svelte";
import SessionDetail from "$lib/system/session-detail.svelte";
import { cn } from "$lib/utils";
const POLL_MS = 10_000;
interface Order {
confirm: string;
lines: string[];
run: () => Promise<unknown>;
title: string;
}
const POLL_MS = 3000;
const SECOND_LOOK_FOR_CPU_MS = 1000;
const COLUMNS = 8;
let sessions = $state<SessionsResponse | null>(null);
let failure = $state<string | null>(null);
let unfolded = $state<Set<string>>(new Set());
let order = $state<Order | null>(null);
let orderFailure = $state<string | null>(null);
let notes = $state<Record<string, string>>({});
let busy = $state(false);
async function load() {
const { client } = session;
@@ -25,90 +48,315 @@
}
}
function toggle(key: string) {
const next = new Set(unfolded);
if (!next.delete(key)) {
next.add(key);
}
unfolded = next;
}
function ask(next: Order) {
orderFailure = null;
order = next;
}
async function carryOut(pending: Order) {
busy = true;
try {
await pending.run();
order = null;
await load();
} catch (cause) {
orderFailure = cause instanceof Error ? cause.message : String(cause);
} finally {
busy = false;
}
}
async function interrupt(row: SessionRow) {
const { client } = session;
if (!client) {
return;
}
busy = true;
try {
const { interrupted } = await client.interruptSession(row.key);
notes = {
...notes,
[row.key]: interrupted ? "" : "Nothing was running.",
};
await load();
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
notes = { ...notes, [row.key]: message };
} finally {
busy = false;
}
}
function askClose(row: SessionRow) {
const { client } = session;
if (!client) {
return;
}
const lines = [
"The claude process exits. The conversation stays and resumes from its transcript on the next message.",
];
if (row.busy) {
lines.push("A turn is running: it is cut and its reply is lost.");
}
if (row.pinned) {
lines.push("This session is pinned: the gateway never closes it itself.");
}
ask({
confirm: "Close session",
lines,
run: () => client.closeSession(row.key),
title: `Close the ${row.kind} session?`,
});
}
function askKill(process: ProcessRow) {
const { client } = session;
if (!client) {
return;
}
const owner = sessions?.sessions.find((row) => row.pid === process.pid);
const lines = [
`SIGKILL to ${process.name} and everything it started. Nothing gets to clean up.`,
];
if (owner) {
lines.push(
"It is a session's claude: the session is dropped, and the conversation resumes from its transcript on the next message."
);
}
ask({
confirm: "Kill",
lines,
run: () => client.killProcess(process.pid),
title: `Kill process ${process.pid}?`,
});
}
const titles = $derived(
new Map(gateway.conversations.map((row) => [row.id, row.title]))
);
onMount(() => {
load();
const second = setTimeout(load, SECOND_LOOK_FOR_CPU_MS);
const poll = setInterval(load, POLL_MS);
return () => clearInterval(poll);
return () => {
clearTimeout(second);
clearInterval(poll);
};
});
</script>
<section class="flex flex-col gap-3">
<div class="flex items-baseline gap-3">
<h2 class="font-medium text-sm">Live claude processes</h2>
{#if sessions}
<span class="tabular text-muted-foreground text-xs">
{sessions.sessions.length}
· {fmtBytes(sessions.rss)}
{#if sessions.rss_limit}
of {fmtBytes(sessions.rss_limit)}
{/if}
</span>
{/if}
</div>
{#snippet state(s: SessionRow)}
<span class={cn(s.busy ? "text-signal" : "text-muted-foreground")}>
{s.busy ? "busy" : "idle"}
</span>
{#if s.pinned}
<span class="ml-1 text-muted-foreground">pinned</span>
{/if}
{#if s.dirty}
<span class="ml-1 text-warn">dirty</span>
{/if}
{#if s.pending_question}
<span class="ml-1 text-attention-foreground">question</span>
{/if}
{/snippet}
<div class="flex flex-wrap items-baseline gap-3">
<h2 class="font-medium text-sm">Live claude processes</h2>
{#if sessions}
<span class="tabular text-muted-foreground text-xs">
{sessions.sessions.length}
{sessions.sessions.length === 1 ? "session" : "sessions"}
· {fmtBytes(sessions.rss)}
{#if sessions.rss_limit}
of {fmtBytes(sessions.rss_limit)}
{/if}
· cpu {fmtCpu(sessions.cpu)}
</span>
{/if}
</div>
<div class="flex flex-col gap-6">
{#if failure}
<ErrorNote message={failure} retry={load} />
{:else if !sessions}
<p class="text-muted-foreground text-sm">Loading…</p>
{:else if sessions.sessions.length === 0}
<p class="text-muted-foreground text-sm">
No claude processes alive. Sessions spawn on the first turn and are reaped
by idle time or memory pressure.
</p>
{:else}
<div class="overflow-x-auto rounded-lg border bg-strip">
<table class="w-full text-sm">
<thead class="text-muted-foreground text-xs">
<tr class="border-b text-left">
<th class="px-3 py-2 font-medium">conversation</th>
<th class="px-3 py-2 font-medium">agent</th>
<th class="px-3 py-2 font-medium">state</th>
<th class="px-3 py-2 text-right font-medium">rss</th>
<th class="px-3 py-2 text-right font-medium">idle</th>
<th class="px-3 py-2 text-right font-medium">age</th>
<th class="px-3 py-2 text-right font-medium">turns</th>
</tr>
</thead>
<tbody>
{#each sessions.sessions as s (s.key)}
<tr class="row-hover border-b last:border-b-0">
<td class="px-3 py-1.5">
<a
class="flex items-center gap-2 hover:underline"
href="{base}/conversations/{s.key}"
<section class="flex flex-col gap-2">
<h3 class="label-quiet">Sessions</h3>
{#if sessions.sessions.length === 0}
<p class="text-muted-foreground text-sm">
No claude processes alive. Sessions spawn on the first turn and are
reaped by idle time or memory pressure.
</p>
{:else}
<div class="overflow-x-auto rounded-lg border bg-strip">
<table class="w-full text-sm">
<thead class="text-muted-foreground text-xs">
<tr class="border-b text-left">
<th class="px-3 py-2 font-medium">conversation</th>
<th class="hidden px-3 py-2 font-medium sm:table-cell">
agent
</th>
<th class="hidden px-3 py-2 font-medium sm:table-cell">
state
</th>
<th class="px-3 py-2 text-right font-medium">cpu</th>
<th class="px-3 py-2 text-right font-medium">rss</th>
<th
class="hidden px-3 py-2 text-right font-medium sm:table-cell"
>
<KindMark kind={s.kind} />
<span class="tabular text-xs">{shortId(s.key)}</span>
</a>
</td>
<td class="px-3 py-1.5">{s.agent}</td>
<td class="px-3 py-1.5 text-xs">
<span
class={cn(s.busy ? "text-signal" : "text-muted-foreground")}
idle
</th>
<th
class="hidden px-3 py-2 text-right font-medium sm:table-cell"
>
{s.busy ? "busy" : "idle"}
</span>
{#if s.pinned}
<span class="ml-1 text-muted-foreground">pinned</span>
age
</th>
<th
class="hidden px-3 py-2 text-right font-medium sm:table-cell"
>
turns
</th>
</tr>
</thead>
<tbody>
{#each sessions.sessions as s (s.key)}
{@const expanded = unfolded.has(s.key)}
<tr class={cn("border-b", !expanded && "last:border-b-0")}>
<td class="max-w-0 px-1.5 py-1 sm:w-2/5">
<button
aria-expanded={expanded}
class="row-hover flex max-w-full items-center gap-2 rounded-md px-1.5 py-1 text-left max-sm:min-h-10"
onclick={() => toggle(s.key)}
type="button"
>
<ChevronRightIcon
class={cn(
"size-3.5 text-icon transition-transform duration-150",
expanded && "rotate-90"
)}
/>
<KindMark kind={s.kind} />
<span class="flex min-w-0 flex-col">
<span class="truncate font-medium">
{titles.get(s.key) ?? shortId(s.key)}
</span>
<span class="text-xs sm:hidden">
{@render state(s)}
</span>
</span>
</button>
</td>
<td class="hidden px-3 py-1.5 sm:table-cell">{s.agent}</td>
<td
class="hidden whitespace-nowrap px-3 py-1.5 text-xs sm:table-cell"
>
{@render state(s)}
</td>
<td class="tabular px-3 py-1.5 text-right">
{fmtCpu(s.cpu)}
</td>
<td class="tabular whitespace-nowrap px-3 py-1.5 text-right">
{fmtBytes(s.rss)}
</td>
<td
class="tabular hidden px-3 py-1.5 text-right sm:table-cell"
>
{fmtSeconds(s.idle_seconds)}
</td>
<td
class="tabular hidden px-3 py-1.5 text-right sm:table-cell"
>
{fmtSeconds(s.age_seconds)}
</td>
<td
class="tabular hidden px-3 py-1.5 text-right sm:table-cell"
>
{s.turns}
</td>
</tr>
{#if expanded && session.client}
<tr class="border-b last:border-b-0">
<td class="bg-background/60" colspan={COLUMNS}>
<SessionDetail
{busy}
client={session.client}
note={notes[s.key] ?? ""}
onClose={askClose}
onInterrupt={interrupt}
onKill={askKill}
row={s}
/>
</td>
</tr>
{/if}
{#if s.dirty}
<span class="ml-1 text-warn">dirty</span>
{/if}
{#if s.pending_question}
<span class="ml-1 text-attention-foreground">question</span>
{/if}
</td>
<td class="tabular px-3 py-1.5 text-right">{fmtBytes(s.rss)}</td>
<td class="tabular px-3 py-1.5 text-right">
{fmtSeconds(s.idle_seconds)}
</td>
<td class="tabular px-3 py-1.5 text-right">
{fmtSeconds(s.age_seconds)}
</td>
<td class="tabular px-3 py-1.5 text-right">{s.turns}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<section class="flex flex-col gap-2">
<h3 class="label-quiet">Outside any session</h3>
{#if sessions.others.length === 0}
<p class="text-muted-foreground text-sm">
Nothing else runs under the gateway.
</p>
{:else}
<ProcessTable onKill={askKill} rows={sessions.others} />
{/if}
</section>
{/if}
</section>
</div>
<Dialog.Root
onOpenChange={(next) => {
if (!next) {
order = null;
}
}}
open={order !== null}
>
<Dialog.Content>
{#if order}
{@const pending = order}
<Dialog.Header>
<Dialog.Title>{pending.title}</Dialog.Title>
{#each pending.lines as line (line)}
<Dialog.Description>{line}</Dialog.Description>
{/each}
{#if orderFailure}
<p class="text-destructive text-sm" role="alert">{orderFailure}</p>
{/if}
</Dialog.Header>
<Dialog.Footer>
<Button
disabled={busy}
onclick={() => {
order = null;
}}
variant="ghost"
>
Cancel
</Button>
<Button
disabled={busy}
onclick={() => carryOut(pending)}
variant="destructive"
>
{pending.confirm}
</Button>
</Dialog.Footer>
{/if}
</Dialog.Content>
</Dialog.Root>