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

+16
View File
@@ -313,6 +313,22 @@ export class ApiClient {
return this.get("/api/sessions");
}
sessionStderr(key: string): Promise<{ key: string; lines: string[] }> {
return this.get(`/api/sessions/${encodeURIComponent(key)}/stderr`);
}
interruptSession(key: string): Promise<{ interrupted: boolean }> {
return this.post(`/api/sessions/${encodeURIComponent(key)}/interrupt`);
}
closeSession(key: string): Promise<{ closed: boolean }> {
return this.post(`/api/sessions/${encodeURIComponent(key)}/close`);
}
killProcess(pid: number): Promise<{ killed: boolean }> {
return this.post(`/api/processes/${pid}/kill`);
}
jobs(): Promise<JobsResponse> {
return this.get("/api/jobs");
}
+18
View File
@@ -106,7 +106,9 @@ export interface ContentBlock {
export interface HistoryMessage {
content: string | ContentBlock[];
id?: string | null;
role: "user" | "assistant";
service?: string;
// When the first block of this message was written; absent for old rows.
ts?: string | null;
}
@@ -144,17 +146,31 @@ export interface AgentsResponse {
mcps: { name: string; kind: string }[];
}
export interface ProcessRow {
age_seconds: number;
command: string;
cpu: number | null;
depth: number;
name: string;
parent: number;
pid: number;
rss: number;
}
export interface SessionRow {
age_seconds: number;
agent: string;
busy: boolean;
cpu: number | null;
dirty: boolean;
idle_seconds: number;
key: string;
kind: string;
pending: number;
pending_question: boolean;
pid: number | null;
pinned: boolean;
processes: ProcessRow[];
rss: number | null;
running_turn: string | null;
session_id: string | null;
@@ -162,6 +178,8 @@ export interface SessionRow {
}
export interface SessionsResponse {
cpu: number | null;
others: ProcessRow[];
rss: number | null;
rss_limit: number | null;
sessions: SessionRow[];
+6
View File
@@ -68,6 +68,12 @@ export function fmtSeconds(seconds: number | null | undefined): string {
: fmtDuration(seconds * THOUSAND);
}
export function fmtCpu(percent: number | null | undefined): string {
return percent === null || percent === undefined
? "–"
: `${Math.round(percent)}%`;
}
export function fmtBytes(bytes: number | null | undefined): string {
if (bytes === null || bytes === undefined) {
return "–";
+68
View File
@@ -0,0 +1,68 @@
<script lang="ts">
import type { ProcessRow } from "$lib/api/types";
import { Button } from "$lib/components/ui/button";
import { fmtBytes, fmtCpu, fmtSeconds } from "$lib/format";
let {
rows,
onKill,
}: { rows: ProcessRow[]; onKill: (row: ProcessRow) => void } = $props();
const INDENT_REM = 0.875;
</script>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-muted-foreground text-xs">
<tr class="border-b text-left">
<th class="py-1.5 pr-3 font-medium">pid</th>
<th class="w-full py-1.5 pr-3 font-medium">command</th>
<th class="py-1.5 pr-3 text-right font-medium">cpu</th>
<th class="py-1.5 pr-3 text-right font-medium">rss</th>
<th class="hidden py-1.5 pr-3 text-right font-medium sm:table-cell">
age
</th>
<th class="py-1.5"><span class="sr-only">actions</span></th>
</tr>
</thead>
<tbody>
{#each rows as row (row.pid)}
<tr class="border-b last:border-b-0">
<td class="tabular py-1 pr-3 text-muted-foreground text-xs">
{row.pid}
</td>
<td class="max-w-0 py-1 pr-3">
<span
class="block truncate font-mono text-xs"
title={row.command}
style:padding-left="{row.depth * INDENT_REM}rem"
>
{row.command || row.name}
</span>
</td>
<td class="tabular py-1 pr-3 text-right text-xs">
{fmtCpu(row.cpu)}
</td>
<td class="tabular whitespace-nowrap py-1 pr-3 text-right text-xs">
{fmtBytes(row.rss)}
</td>
<td
class="tabular hidden whitespace-nowrap py-1 pr-3 text-right text-muted-foreground text-xs sm:table-cell"
>
{fmtSeconds(row.age_seconds)}
</td>
<td class="py-0.5 text-right">
<Button
class="text-muted-foreground hover:text-destructive max-sm:h-10"
onclick={() => onKill(row)}
size="xs"
variant="ghost"
>
Kill
</Button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
+150
View File
@@ -0,0 +1,150 @@
<script lang="ts">
import { onMount, untrack } from "svelte";
import { base } from "$app/paths";
import type { ApiClient } from "$lib/api/client";
import type { ProcessRow, SessionRow } from "$lib/api/types";
import { Button } from "$lib/components/ui/button";
import { shortId } from "$lib/format";
import { ConversationFeed } from "$lib/panel/conversation.svelte";
import TurnCard from "$lib/panel/turn-card.svelte";
import ProcessTable from "./process-table.svelte";
let {
client,
row,
busy,
note,
onInterrupt,
onClose,
onKill,
}: {
client: ApiClient;
row: SessionRow;
busy: boolean;
note: string;
onInterrupt: (row: SessionRow) => void;
onClose: (row: SessionRow) => void;
onKill: (process: ProcessRow) => void;
} = $props();
const TICK_MS = 1000;
const feed = new ConversationFeed(
() => client,
untrack(() => row.key)
);
let stderr = $state<string[] | null>(null);
let now = $state(Date.now());
const turn = $derived(feed.model.running);
onMount(() => {
feed.start();
const timer = setInterval(() => {
now = Date.now();
}, TICK_MS);
return () => {
feed.stop();
clearInterval(timer);
};
});
$effect(() => {
const { key, idle_seconds: polled } = row;
if (polled >= 0) {
client
.sessionStderr(key)
.then((tail) => {
stderr = tail.lines;
})
.catch(() => undefined);
}
});
</script>
<div class="flex flex-col gap-4 px-3 pt-2 pb-4">
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
<p class="tabular min-w-0 flex-1 text-muted-foreground text-xs">
session
{shortId(row.session_id)}
{#if row.pid}
· pid {row.pid}
{/if}
{#if row.pending > 0}
· {row.pending}
buffered
{/if}
</p>
<div class="flex flex-wrap items-center gap-2">
{#if note}
<span class="text-muted-foreground text-xs" role="status">{note}</span>
{/if}
{#if !feed.error}
<Button
class="max-sm:h-10"
href="{base}/conversations/{row.key}"
size="sm"
variant="ghost"
>
Open thread
</Button>
{/if}
<Button
class="max-sm:h-10"
disabled={busy || !row.busy}
onclick={() => onInterrupt(row)}
size="sm"
variant="outline"
>
Interrupt turn
</Button>
<Button
class="max-sm:h-10"
disabled={busy}
onclick={() => onClose(row)}
size="sm"
variant="destructive"
>
Close session
</Button>
</div>
</div>
<section class="flex flex-col gap-1">
<h4 class="label-quiet">Now</h4>
{#if turn}
<TurnCard {now} {turn} />
{:else}
<p class="text-muted-foreground text-sm">
{row.busy
? "A turn is running; its events have not reached this page yet."
: "Nothing in flight."}
</p>
{/if}
</section>
<section class="flex flex-col gap-1">
<h4 class="label-quiet">Process tree</h4>
{#if row.processes.length === 0}
<p class="text-muted-foreground text-sm">
The process is not visible to the gateway.
</p>
{:else}
<ProcessTable {onKill} rows={row.processes} />
{/if}
</section>
<section class="flex flex-col gap-1">
<h4 class="label-quiet">stderr</h4>
{#if stderr?.length === 0}
<p class="text-muted-foreground text-sm">
Nothing on stderr since this process started.
</p>
{:else if stderr}
<pre
class="max-h-64 overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted/40 p-2 text-xs"
>{stderr.join(
"\n"
)}</pre>
{/if}
</section>
</div>
+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>