Skip to content

Bump itsjustanks/daemon to v0.13.0 - #156

Open
github-actions[bot] wants to merge 1 commit into
mainfrom
bump/itsjustanks/daemon-v0.13.0
Open

github-actions[bot] wants to merge 1 commit into
mainfrom
bump/itsjustanks/daemon-v0.13.0

Conversation

@github-actions

@github-actions github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

itsjustanks/daemon: {"kind":"git","remote":"https://github.com/itsjustanks/paseo-plugin-daemon.git","commit":"3621929c1fb04118b6d033245448eb1c7f2bfac9","tag":"v0.12.1"} -> {"kind":"git","remote":"https://github.com/itsjustanks/paseo-plugin-daemon.git","commit":"59b315d064c7d873cade37bf523c6351a4da20ca","tag":"v0.13.0"}
Git tag v0.13.0 pins commit 59b315d064c7d873cade37bf523c6351a4da20ca.
Original submitter is not recorded; refer to the source owner.

Merging approves this version. The published index keeps pointing at the previous one until then.

Artifact diff (truncated; the full diff is in the workflow artifact)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4b2d011..fb45415 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,75 @@
 # Changelog
 
+## 0.13.0 — 2026-10-07
+
+The two things that took a daemon down this week, caught early, each with a one-press fix. A plugin
+that stopped answering wedged Paseo's plugin manager three times, and a test run that grew to 36 GB
+filled memory until the daemon stopped answering. Both were fixed by hand; now Hosts sees them coming.
+
+- **Plugin health.** Hosts reads Paseo's own log (`$PASEO_HOME/daemon.log`) for "Plugin RPC timed
+  out: <plugin>.<method>" and says, per plugin, "Activity isn't answering (12 timeouts in 10 min)", or
+  that a plugin began stopping and never finished. Three timeouts in 10 minutes is the bar; a restart
+  clears the count. Shown on Overview's status card, the sidebar dot's popover and Processes. Slow
+  plugin requests are counted as context (the log doesn't say which plugin).
+- **Restart <plugin>.** Asks first, in place, saying what will and won't happen. It runs
+  `paseo plugin reload <id>` (the `paseo` that ships with the daemon, found from Hosts' own path)
+  with a 45-second timeout. If the reload hangs because the plugin manager is wedged, Hosts stops
+  only that plugin's process (SIGTERM, then SIGKILL after 10 seconds if the very same process is
+  still there), gives Paseo's queued reload 15 seconds to finish, and reloads again if it didn't. An
+  old copy stuck stopping is stopped the same way. Never the daemon, never Hosts, never anything that
+  isn't one of the daemon's plugin processes, and nothing at all when the process can't be told
+  apart. A reload that fails for another reason stops nothing. Every step is logged. A restart that
+  takes longer than Paseo lets one call wait answers "running" and the app follows it.
+- **How a plugin's process is found.** Plugin processes carry no plugin id in their argv,
+  environment or folder. The daemon logs "Loading plugin" just before forking a plugin's process and
+  "Plugin ready" just after, one plugin at a time, so a plugin process (`plugin-process.js`, a direct
+  child of this daemon, this user's) belongs to the plugin whose window holds its start time (Linux:
+  boot time plus start ticks; macOS: `lstart`), with 2 seconds of slack. A match must be unique both
+  ways; the window holding Hosts' own start time is Hosts. Identity (start time, command, parent,
+  user) is re-read immediately before each signal.
+- **Runaway memory, said plainly.** A process holding 25% or more of this computer's memory (the
+  container's limit when there is one; was 40%), or one already at 10% that added at least 10% of
+  memory (and at least 2 GB) in 5 minutes, is flagged: "A test run is using 36 GB, 61% of this
+  computer's memory. The computer will slow to a crawl soon." Urgent at 50%, or at any share while
+  memory is under pressure. Memory pressure now reads PSI "full" as well as "some" (the cgroup's
+  `memory.pressure`, else `/proc/pressure/memory`) and OOM kills from `memory.events`: "Memory is
+  nearly full: programs are stalled waiting for memory 84% of the time." `node --test`, `bun test`
+  and `*.test.*` / `*.spec.*` scripts now count as test runs.
+- **Stop and Ask an agent beside it.** Overview's attention list and the Processes banner offer Stop
+  (the same ask-first sheet: SIGTERM, SIGKILL after 10 seconds) for a runaway Hosts may stop, even
+  when its row isn't on the current page, next to Ask an agent.
+- **The check loop.** Starts when the plugin loads, not on the first app visit, and runs every 10
+  seconds (30 on macOS) whether or not Paseo is open: a few small /proc and cgroup files, one short
+  read per process, at most 1 MB of new log. Anything that goes through the daemon is given at most
+  5 seconds, so a starved daemon can't stall it.
+- **Optional memory guard, off by default.** Settings → Hosts → "Stop a runaway automatically when
+  memory is nearly full". Only after memory has been critical (PSI full ≥ 20%, some ≥ 50%, or OOM
+  kills while 90% full) for over 60 seconds, it stops the biggest process a person could stop with
+  the Stop button (started from Paseo or inside a project; never Paseo, an agent, a terminal's shell
+  or a database) that holds at least 10% of memory, through the same checked stop, then waits 90
+  seconds before considering another. Logged as an automatic stop; the sidebar dot says so for 30
+  minutes.
+- **Help.** Two plain questions: "A job is eating all the memory. What happens?" and "A plugin isn't
+  answering. What do I do?". "Recent stops" is now "Recent stops and restarts". Overview's Technical
+  details say whether plugin health can be read here and whether the memory guard is on.
+- **Safety review fixes (before release).** A plugin launch whose "Loading plugin" line is gone
+  (rotated away, outside the read budget, or a lone "Plugin ready") no longer gets a guessed window,
+  so it can never match another plugin's healthy process: Hosts says it can't tell and stops nothing.
+  A Ready line pairs with a Loading line only within 2 minutes, and no window is longer than that.
+  The memory guard now asks again immediately before every signal it sends, the SIGKILL follow-up
+  included: the switch must still be on and a reading taken right then must be critical, or nothing
+  is sent. Only one automatic stop runs at a time, and unloading Hosts disarms one in progress. A
+  failed or missing reading, or a gap of more than three check intervals, restarts the "critical for
+  over a minute" count.
+- **Known limit.** Between the last identity check and the signal, a process could exit and its PID
+  be reused within microseconds; closing that needs `pidfd_send_signal`, which Node doesn't expose.
+  This applies to every stop, as before.
+- **Settings** document version 5 (adds the guard switch); older documents keep every value.
+- **Tests.** Log parsing and the budgeted tail, launch history, plugin-process matching (start times
+  measured on a real host), the reload-then-stop escalation against a fake daemon that wedges, the
+  runaway and pressure thresholds with the incident's numbers, the auto-guard's rules, and the loop
+  surviving a hung settings read.
+
 ## 0.12.1 — 2026-10-06
 
 Fixes from an audit of the real Paseo app (0.11.0-beta.5), checked again in the real app before
diff --git a/README.md b/README.md
index 52f7285..eaa5f0f 100644
--- a/README.md
+++ b/README.md
@@ -35,6 +35,15 @@ an isolated preview: no real accounts, host addresses, project names, credential
   then stop: each is asked to exit, and anything still running after 10 seconds is stopped
   forcefully. Paseo itself, its plugins, agents, terminals and databases are never stopped, and
   every stop is logged.
+- **Runaway memory, early.** A job holding a quarter of memory, or growing fast, is flagged in plain
+  words ("A test run is using 36 GB, 61% of this computer's memory"), as is memory pressure, from a
+  check loop that runs every 10 seconds even when Paseo is closed. Stop or Ask an agent sits beside
+  it. An optional guard (off by default) stops the biggest stoppable job when memory stays nearly
+  full for over a minute.
+- **Stuck plugins, restarted in one press.** When a Paseo plugin stops answering ("Activity isn't
+  answering (12 timeouts in 10 min)"), Paseo can't add, update or reload plugins. Restart reloads just
+  that plugin; if Paseo is stuck on it, Hosts stops only that plugin's own process and reloads it. The
+  daemon is never restarted.
 - **Watched services.** Add health URLs on other machines (such as the AI Router's OmniRoute, offered
   in one press when that plugin is set up). A slow or failing answer shows on the sidebar dot and the
   Overview card in plain words: "OmniRoute is slow: 4.2 s, usually 110 ms".
@@ -247,6 +256,7 @@ Center. Settings are saved per host and shared by every client of that host.
 | Link duration | 2 hours | How long Open keeps a new browser link alive, and what each Extend adds (15 min–8 h). |
 | Close browser links on archive | On | Archiving a workspace stops browser links that point at its dev servers. |
 | Heavy jobs at once | 4 | More builds, tests and dev servers than this is flagged; nothing stops on its own. |
+| Stop a runaway automatically… | Off | Memory nearly full for a minute: stop the biggest job you could stop. |
 | Watched services | None | Health URLs elsewhere (http/https, no secrets), checked at most every 30 s. |
 
 Archive cleanup and background health checks run on the daemon, so they work even when no app is
@@ -254,7 +264,7 @@ connected. Cleanup only stops temporary browser links; the dev server itself kee
 saved settings file cannot be read, cleanup is skipped rather than guessed. Settings saved by 0.6.0
 through 0.9.0 are migrated in place: every value you chose is kept and new settings take their
 defaults. Defaults are safe on a shared team daemon: nothing is published, paired or stopped until
-someone presses a button.
+someone presses a button (or turns on the memory guard).
 
 ### Browser links: how long they live
 
@@ -352,7 +362,13 @@ is listed but view-only, with the reason on the row.
 children included, and anything that won't, with why. Confirming asks each process to exit; anything
 still running 10 seconds later is stopped forcefully. Every rule is re-checked against a fresh read at
 the moment of each signal, and every step is written to `$PASEO_HOME/daemon-link/actions.jsonl`
-(names, PIDs and outcomes only, never command lines). **Recent stops** lists them.
+(names, PIDs and outcomes only, never command lines). **Recent stops and restarts** lists them.
+
+**Stuck plugins and the memory guard (0.13.0).** A banner at the top lists any Paseo plugin that isn't
+answering, with **Restart** (see the [changelog](CHANGELOG.md) for exactly how its process is found
+and stopped). Memory runaways show **Stop** beside them even when their row isn't on this page. With
+the optional memory guard on, Hosts stops the biggest job you could stop yourself once memory has been
+nearly full for over a minute, through the same checks, and logs it as an automatic stop.
 
 ### Overview and the quick health check
 
diff --git a/client/daemon.tsx b/client/daemon.tsx
index a28d683..f7eeded 100644
--- a/client/daemon.tsx
+++ b/client/daemon.tsx
@@ -154,7 +154,7 @@ function DaemonBody(props: DaemonProps) {
         sync={<AccordionItem key={foldKey("sync")} theme={theme} compact={layout.compact} icon="FolderSync" title="Copy a project from another computer" summary="Preview its Git history before it arrives (Project Sync)" open={foldOpen("sync")}>
           <Transfers hostId={props.host.id} openPairing={() => go("servers", "private", true)} />
         </AccordionItem>} /> : null}
-      {tab === "processes" ? <ProcessesTab theme={theme} compact={layout.compact} hostId={props.host.id} say={setMessage} /> : null}
+      {tab === "processes" ? <ProcessesTab theme={theme} compact={layout.compact} hostId={props.host.id} say={setMessage} issues={verdict?.issues ?? []} onChanged={() => void health.refetch()} /> : null}
       {tab === "help" ? <HelpTab theme={theme} compact={layout.compact} go={toHelp} minutes={formatMinutes(minutes)} shortcuts={!!props.shortcuts} /> : null}
       {tab === "servers" && <View style={{ gap: t.space.xl }}>
         <View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: t.space.sm }}>
diff --git a/client/guard.tsx b/client/guard.tsx
new file mode 100644
index 0000000..289c54f
--- /dev/null
+++ b/client/guard.tsx
@@ -0,0 +1,135 @@
+import React, { useState } from "react";
+import { Text, View } from "react-native";
+import type { PluginTheme } from "@getpaseo/plugin";
+import { useRpc } from "@getpaseo/plugin/client";
+import { useMutation } from "@tanstack/react-query";
+import { pluginRestart, pluginRestartStatus, type RestartOutcome } from "../shared/guard";
+import type { HealthIssue } from "../shared/health";
+import { processPreview, processReport, processStop, type StopPlan } from "../shared/processes";
+import { Button, Disclosure, Meta, Note, Row, SPACE, TYPE, type Tone } from "./kit";
+import { StopSheet, type Say } from "./processes";
+
+type Theme = PluginTheme;
+
+/**
+ * 0.13.0's two one-press fixes, shared by Overview, Processes and the
+ * sidebar dot's popover so they look and behave the same everywhere.
+ *
+ * Restart asks first in place (no sheet, so it also works inside the
+ * popover): it says exactly what will happen and what won't, then runs.
+ * Stop opens the same ask-first sheet as the Processes tab.
+ */
+
+const toneOf = (outcome: RestartOutcome): Tone => (outcome.ok ? "success" : outcome.outcome === "refused" || outcome.outcome === "running" ? "warning" : "danger");
+const POLL_MS = 2000;
+/** A wedged restart takes about two minutes; stop asking well after that. */
+const POLL_LIMIT = 120;
+const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+/**
+ * "Restart <plugin>" for a plugin-stuck issue, with its ask-first step. `say`
+ * posts the result to the page's message bar; without it the result shows
+ * here. Plain state, no query client: the sidebar popover has none.
+ */
+export function RestartPlugin({ theme, issue, say, onDone }: { theme: Theme; issue: HealthIssue; say?: Say; onDone?: () => void }) {
+  const restart = useRpc(pluginRestart);
+  const status = useRpc(pluginRestartStatus);
+  const [asking, setAsking] = useState(false);
+  const [pending, setPending] = useState(false);
+  const [result, setResult] = useState<RestartOutcome | null>(null);
+  const name = issue.subject ?? "this plugin";
+  const run = {
+    isPending: pending,
+    mutate: () => {
+      setPending(true);
+      const pluginId = issue.plugin!;
+      // The first call answers within 20 seconds; a longer restart answers "running" and is followed here.
+      const follow = async () => {
+        let outcome = await restart({ pluginId });
+        for (let polls = 0; outcome.outcome === "running" && polls < POLL_LIMIT; polls += 1) {
+          setResult(outcome);
+          await wait(POLL_MS);
+          outcome = await status({ pluginId }).catch(() => outcome);
+        }
+        return outcome;
+      };
+      void follow()
+        .then((outcome) => { setResult(outcome); say?.({ text: outcome.message, tone: toneOf(outcome) }); onDone?.(); })
+        .catch((error: unknown) => { const text = error instanceof Error ? error.message : String(error); setResult({ ok: false, outcome: "failed", message: text, steps: [] }); say?.({ text, tone: "danger" }); })
+        .finally(() => { setPending(false); setAsking(false); });
+    },
+  };
+  if (!issue.plugin) return null;
+  if (issue.restartable === false) return <Meta theme={theme}>{issue.restartReason ?? "Restart isn't available on this host."}</Meta>;
+  return (
+    <View style={{ gap: SPACE.sm }}>
+      {asking || run.isPending ? (
+        <>
+          <Note theme={theme}>{`Hosts asks Paseo to reload ${name}. If Paseo's plugin manager is stuck on it, Hosts then stops only ${name}'s own process and reloads it. Paseo itself, your agents and the other plugins keep running. It can take up to a minute.`}</Note>
+          <Row>
+            <Button theme={theme} label="Cancel" onPress={() => setAsking(false)} disabled={run.isPending} />
+            <Button theme={theme} label={run.isPending ? "Restarting…" : `Restart ${name}`} icon="RotateCw" primary busy={run.isPending} onPress={() => run.mutate()} />
+          </Row>
+        </>
+      ) : (
+        <Row><Button theme={theme} label={`Restart ${name}…`} icon="RotateCw" accessibilityLabel={`Restart ${name}. Asks first.`} onPress={() => { setResult(null); setAsking(true); }} /></Row>
+      )}
+      {result && (!say || result.outcome === "running") ? <Note theme={theme} tone={toneOf(result)}>{result.message}</Note> : null}
+      {result?.steps.length ? (
+        <Disclosure theme={theme} label="What Hosts did" quiet>
+          {result.steps.map((step, index) => <Meta key={index} theme={theme}>{`${new Date(step.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })} · ${step.text}`}</Meta>)}
+        </Disclosure>
+      ) : null}
+    </View>
+  );
+}
+
+/** "Stop…" for one process by PID: finds its current signed handle, shows the ask-first sheet, then stops it. */
+export function StopProcess({ theme, pid, say, onDone }: { theme: Theme; pid: number; say: Say; onDone?: () => void }) {
+  const report = useRpc(processReport), preview = useRpc(processPreview), stop = useRpc(processStop);
+  const [plan, setPlan] = useState<StopPlan | null>(null);
+  const [tokens, setTokens] = useState<string[]>([]);
+  const ask = useMutation({
+    mutationFn: async () => {
+      const found = await report({ query: String(pid), filter: "stoppable", sort: "memory", limit: 10, offset: 0 });
+      const row = found.processes.find((item) => item.pid === pid && item.actionToken);
+      if (!row) throw new Error("It has already stopped, or it can no longer be stopped here.");
+      setTokens([row.actionToken!]);
+      return preview({ tokens: [row.actionToken!] });
+    },
+    onSuccess: setPlan,
+    onError: (error) => say({ text: error instanceof Error ? error.message : String(error), tone: "warning" }),
+  });
+  const confirm = useMutation({
+    mutationFn: () => stop({ tokens }),
+    onSuccess: (outcome) => {
+      setPlan(null);
+      const failed = outcome.results.filter((item) => !item.ok);
+      say({ text: outcome.results.map((item) => (item.ok ? item.message : `${item.name}: ${item.message}`)).join(" "), tone: failed.length ? "danger" : "success" });
+      onDone?.();
+    },
+    onError: (error) => { setPlan(null); say({ text: error instanceof Error ? error.message : String(error), tone: "danger" }); },
+  });
+  return (
+    <>
+      <Button theme={theme} label="Stop…" icon="OctagonX" danger busy={ask.isPending} accessibilityLabel={`Stop PID ${pid}. Asks first.`} onPress={() => ask.mutate()} />
+      <StopSheet theme={theme} plan={plan} busy={confirm.isPending} onCancel={() => setPlan(null)} onConfirm={() => confirm.mutate()} />
+    </>
+  );
+}
+
+/** Stuck plugins as a short list: what's wrong and Restart, for the Processes tab. */
+export function StuckPlugins({ theme, issues, say, onDone }: { theme: Theme; issues: readonly HealthIssue[]; say: Say; onDone?: () => void }) {
+  const stuck = issues.filter((issue) => issue.code === "plugin-stuck");
+  if (!stuck.length) return null;
+  return (
+    <View style={{ gap: SPACE.row }}>
+      {stuck.map((issue) => (
+        <View key={issue.plugin ?? issue.message} style={{ gap: SPACE.sm }}>
+          <Text style={{ ...TYPE.body, color: theme.colors.foreground }}>{issue.message}</Text>
+          <RestartPlugin theme={theme} issue={issue} say={say} onDone={onDone} />
+        </View>
+      ))}
+    </View>
+  );
+}
diff --git a/client/guide.tsx b/client/guide.tsx
index 75fc575..2edecd2 100644
--- a/client/guide.tsx
+++ b/client/guide.tsx
@@ -42,7 +42,7 @@ function WhatIs({ theme }: { theme: Theme }) {
 
 const FLOW = [
   { icon: "Eye", title: "Your daemon watches", text: "Every 30 seconds it measures memory, CPU and running jobs, and checks the services you watch." },
-  { icon: "TriangleAlert", title: "It spots trouble", text: "A job stuck at full CPU, memory near the limit, too many builds at once, or a slow service." },
+  { icon: "TriangleAlert", title: "It spots trouble", text: "A job stuck at full CPU or eating memory, memory near the limit, a plugin that stopped answering, or a slow service." },
   { icon: "Hand", title: "You decide", text: "The sidebar dot and this page say what's wrong in plain words. Nothing is stopped without asking." },
   { icon: "ExternalLink", title: "Open what's running", text: "Each dev server opens in your browser with one press, or privately on your own computer." },
 ] as const;
@@ -209,6 +209,23 @@ export function helpQuestions(minutes: string, shortcuts: boolean): Question[] {
       ],
       action: { label: "See processes", tab: "processes" },
     },
+    {
+      icon: "MemoryStick", question: "A job is eating all the memory. What happens?",
+      answer: [
+        "Hosts checks memory every 10 seconds, even when Paseo is closed. A job using a quarter or more of this computer's memory, or growing fast, is flagged in plain words, for example \"A test run is using 36 GB, 61% of this computer's memory.\" So is memory pressure, when programs start waiting for memory.",
+        "Press Stop beside it (it asks first, then forces it after 10 seconds if it won't stop), or Ask an agent to find out why it grew.",
+        "If you'd rather Hosts acted on its own, turn on \"Stop a runaway automatically when memory is nearly full\" under Settings → Hosts. It's off by default. When memory has been nearly full for over a minute, it stops the biggest job you could stop by hand, never Paseo, an agent, a terminal's shell or a database, and the sidebar dot tells you.",
+      ],
+      action: { label: "See processes", tab: "processes" },
+    },
+    {
+      icon: "Puzzle", question: "A plugin isn't answering. What do I do?",
+      answer: [
+        "When a Paseo plugin stops answering, Paseo can't add, update or reload any plugin until it's sorted. Hosts spots this in Paseo's own log and says which plugin, for example \"Activity isn't answering (12 timeouts in 10 min)\".",
+        "Press Restart. It asks first, then reloads just that plugin. If Paseo is stuck on it, Hosts stops only that plugin's own process and reloads it again. Paseo itself, your agents and the other plugins keep running, and every step is logged.",
+      ],
+      action: { label: "See what needs attention", tab: "overview" },
+    },
     {
       icon: "Bot", question: "Can an agent help fix it?",
       answer: [
@@ -221,8 +238,8 @@ export function helpQuestions(minutes: string, shortcuts: boolean): Question[] {
     {
       icon: "ShieldCheck", question: "What can and can't be stopped?",
       answer: [
-        "Only processes started from Paseo or running inside your Paseo projects. Paseo itself, its plugins, agents, terminals and databases never can be. Nothing is ever stopped automatically.",
-        "A stop asks first, waits a few seconds, and only then forces it. Every stop is listed under Processes → Recent stops.",
+        "Only processes started from Paseo or running inside your Paseo projects. Paseo itself, its plugins, agents, terminals and databases never can be. Nothing is stopped automatically unless you turn on the memory guard under Settings → Hosts.",
+        "A stop asks first, waits a few seconds, and only then forces it. A plugin is never stopped like this; it can only be restarted, and only its own process. Every stop and restart is listed under Processes → Recent stops and restarts.",
       ],
     },
     {
diff --git a/client/home.tsx b/client/home.tsx
index f9564f2..7fb8649 100644
--- a/client/home.tsx
+++ b/client/home.tsx
@@ -2,7 +2,7 @@ import React from "react";
 import { Text, View } from "react-native";
 import type { PluginTheme } from "@getpaseo/plugin";
 import { useRpc, useSettings } from "@getpaseo/plugin/client";
-import { useMutation, useQuery } from "@tanstack/react-query";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
 import { askSubjectFor, type HealthVerdict } from "../shared/health";
 import type { ProcessReport } from "../shared/processes";
 import { hostsSettings } from "../shared/settings";
@@ -14,12 +14,14 @@ import { Accordion, AccordionItem, Button, Dot, Fact, HeroCard, HostIcon, Meta,
 import { memoryWords, shareTone, type Say } from "./processes";
 import { formatBytes } from "./ui";
 import { AskAgentButton } from "./ask";
+import { RestartPlugin, StopProcess } from "./guard";
+import { guardState } from "../shared/guard";
 
 type Theme = PluginTheme;
 type Go = (tab: TabId, fold?: Fold) => void;
 
 const WATCH_TONE: Record<WatchResult["state"], Tone> = { up: "success", slow: "warning", down: "danger", unknown: "neutral" };
-const PROCESS_CODES = new Set(["memory-pressure", "cpu-pressure", "too-many-jobs", "runaway", "pressure-driver", "process-zombie"]);
+const PROCESS_CODES = new Set(["memory-pressure", "cpu-pressure", "too-many-jobs", "runaway", "pressure-driver", "process-zombie", "auto-stopped"]);
 
 /** The hero's words: the state first, in plain English, saying each thing once. */
 export function heroState(verdict: HealthVerdict | undefined, setupDone: boolean): { tone: Tone; icon: string; title: string; lead: string | null } {
@@ -60,9 +62,11 @@ function WatchedList({ theme, watched }: { theme: Theme; watched: readonly Watch
 /**
  * Each thing that's wrong, in its own words, with "Ask an agent" where an
  * agent can help (0.12.0): a runaway or a job loading the host, a dev server
- * that stopped, a watched service that is slow or down.
+ * that stopped, a watched service that is slow or down. 0.13.0 adds the
+ * one-press fixes: Stop beside a runaway Hosts may stop, and Restart beside
+ * a plugin that isn't answering (both ask first).
  */
-function AttentionList({ theme, verdict }: { theme: Theme; verdict: HealthVerdict }) {
+function AttentionList({ theme, verdict, say, onDone }: { theme: Theme; verdict: HealthVerdict; say: Say; onDone(): void }) {
   const issues = verdict.issues.filter((issue) => issue.code !== "projects-unavailable");
   return (
     <View style={{ gap: SPACE.row }}>
@@ -74,7 +78,15 @@ function AttentionList({ theme, verdict }: { theme: Theme; verdict: HealthVerdic
               <View style={{ paddingTop: SPACE.sm }}><Dot color={toneColor(theme, issue.severity === "critical" ? "danger" : "warning")} /></View>
               <Text style={{ ...TYPE.body, color: theme.colors.foreground, flex: 1 }}>{issue.message}</Text>
             </View>
-            {subject ? <View style={{ paddingLeft: SPACE.md }}><Row><AskAgentButton theme={theme} subject={subject} /></Row></View> : null}
+            {subject || (issue.code === "runaway" && issue.stoppable && issue.pid) ? (
+              <View style={{ paddingLeft: SPACE.md }}>
+                <Row>
+                  {subject ? <AskAgentButton theme={theme} subject={subject} /> : null}
+                  {issue.code === "runaway" && issue.stoppable && issue.pid ? <StopProcess theme={theme} pid={issue.pid} say={say} onDone={onDone} /> : null}
+                </Row>
+              </View>
+            ) : null}
+            {issue.code === "plugin-stuck" ? <View style={{ paddingLeft: SPACE.md }}><RestartPlugin theme={theme} issue={issue} say={say} onDone={onDone} /></View> : null}
           </View>
         );
       })}
@@ -115,9 +127,12 @@ function watchedWords(watched: readonly WatchResult[]): { value: string; tone: T
 }
 
 /** Overview's "Technical details": the schedule, limits and where things are written. */
-function TechnicalDetails({ theme, verdict, report }: { theme: Theme; verdict: HealthVerdict | undefined; report: ProcessReport | undefined }) {
+function TechnicalDetails({ theme, hostId, verdict, report }: { theme: Theme; hostId: string; verdict: HealthVerdict | undefined; report: ProcessReport | undefined }) {
   const settings = useSettings(hostsSettings);
   const values = settings.status === "ready" ? settings.values : null;
+  const readGuard = useRpc(guardState);
+  // Older daemons have no such call; the two lines are then simply absent.
+  const guard = useQuery({ queryKey: ["daemon-link", hostId, "guard"], queryFn: () => readGuard({}), staleTime: 30_000, retry: 0 });
   return (
     <>
       {values ? <Fact theme={theme} label="Checks every" value={`${values.snapshotIntervalSeconds} seconds${values.backgroundHealthChecks ? ", even with Paseo closed" : ", while Hosts is open"}`} /> : null}
@@ -125,6 +140,8 @@ function TechnicalDetails({ theme, verdict, report }: { theme: Theme; verdict: H
       {values ? <Fact theme={theme} label="Browser links last" value={formatMinutes(values.tunnelMinutes)} /> : null}
       {report ? <Fact theme={theme} label="Memory measured" value={report.memoryBasis === "container" ? "against this container's limit" : "against the whole machine"} /> : null}
       {report ? <Fact theme={theme} label="Processes" value={`${report.total} running · Paseo uses ${formatBytes(report.paseoBytes)}`} /> : null}
+      {guard.data ? <Fact theme={theme} label="Plugin health" value={guard.data.logReadable ? "Read from Paseo's own log every 10 seconds" : "Unknown: this Paseo's log can't be read here"} /> : null}
+      {guard.data ? <Fact theme={theme} label="Memory guard" value={guard.data.autoGuard.enabled ? "On: stops the biggest job Hosts may stop after a minute of nearly full memory" : "Off (Settings → Hosts)"} /> : null}
       {verdict ? <Fact theme={theme} label="Last checked" value={new Date(verdict.checkedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })} /> : null}
       <Fact theme={theme} label="Stop log" value="$PASEO_HOME/daemon-link/actions.jsonl" />
       <Fact theme={theme} label="Summary for other plugins" value="$PASEO_HOME/daemon-link/host-summary.json" />
@@ -148,6 +165,8 @@ export function OverviewTab({ theme, compact, hostId, verdict, report, devServer
   sync?: React.ReactNode;
 }) {
   const hero = heroState(verdict, setupDone);
+  const queryClient = useQueryClient();
+  const changed = () => { void queryClient.invalidateQueries({ queryKey: ["daemon-link", hostId] }); };
   const watched = verdict?.watched ?? [];
   const processIssue = verdict?.issues.some((issue) => PROCESS_CODES.has(issue.code)) ?? false;
   const memory = report ? memoryWords(report) : null;
@@ -155,7 +174,8 @@ export function OverviewTab({ theme, compact, hostId, verdict, report, devServer
   const slow = watched.filter((service) => service.state === "slow" || service.state === "down");
   const watchedNow = watchedWords(watched);
   const pending = checks.filter((check) => check.state !== "ready" && check.state !== "optional").length;
-  const last = report?.recentActions[0];
+  // A reload isn't a stop (0.13.0): "Last stop" names the last process actually signalled.
+  const last = report?.recentActions.find((entry) => entry.action !== "plugin-reload" && entry.status === "signaled");
   // One way to refresh (0.12.1): the header's Refresh link, so the hero offers where to go, not "Check again".
   const primary: "processes" | "servers" = processIssue || !devServers ? "processes" : "servers";
   return (
@@ -180,8 +200,8 @@ export function OverviewTab({ theme, compact, hostId, verdict, report, devServer
       </HeroCard>
       <Accordion theme={theme}>
         {verdict && verdict.issues.some((issue) => issue.code !== "projects-unavailable") ? (
-          <AccordionItem key={`attention-${verdict.issues.length}`} theme={theme} compact={compact} icon="TriangleAlert" tone={hero.tone === "danger" ? "danger" : "warning"} title="What needs attention" summary="Each problem, and an agent to ask about it" open>
-            <AttentionList theme={theme} verdict={verdict} />
+          <AccordionItem key={`attention-${verdict.issues.length}`} theme={theme} compact={compact} icon="TriangleAlert" tone={hero.tone === "danger" ? "danger" : "warning"} title="What needs attention" summary="Each problem, with a fix or an agent to ask" open>
+            <AttentionList theme={theme} verdict={verdict} say={say} onDone={changed} />
           </AccordionItem>
         ) : null}
         {watched.length ? (
@@ -195,7 +215,7 @@ export function OverviewTab({ theme, compact, hostId, verdict, report, devServer
         </AccordionItem>
         {sync}
         <AccordionItem theme={theme} compact={compact} icon="SlidersHorizontal" title="Technical details" summary="How often it checks, the limits, and where it writes">
-          <TechnicalDetails theme={theme} verdict={verdict} report={report} />
+          <TechnicalDetails theme={theme} hostId={hostId} verdict={verdict} report={report} />
         </AccordionItem>
       </Accordion>
       {!watched.some((service) => /\/api\/health\/ping$/.test(service.target)) ? <WatchSuggestion theme={theme} hostId={hostId} say={say} /> : null}
diff --git a/client/processes.tsx b/client/processes.tsx
index 7bbc503..e9c58a0 100644
--- a/client/processes.tsx
+++ b/client/processes.tsx
@@ -8,6 +8,8 @@ import { processPreview, processReport, processStop, sameness, twinKeys, type Ac
 import { Accordion, AccordionItem, Banner, Button, Card, Chip, Divider, HostIcon, ItemTitle, Meta, Note, QuietLine, RADIUS, Row, SPACE, TYPE, tint, toneColor, type Tone } from "./kit";
 import { formatBytes, formatDuration, formatPercent } from "./ui";
 import { AskAgentButton } from "./ask";
+import { StopProcess, StuckPlugins } from "./guard";
+import type { HealthIssue } from "../shared/health";
 
 type Theme = PluginTheme;
 export type Say = (message: { text: string; tone: Tone } | null) => void;
@@ -89,10 +91,12 @@ function LoadCard({ theme, report }: { theme: Theme; report: ProcessReport }) {
 }
 
 /** Runaways: the one place this tab raises its voice, each with the decision it needs. */
-function RunawayBanner({ theme, report, onReview, onJobs }: { theme: Theme; report: ProcessReport; onReview(pids: number[]): void; onJobs(): void }) {
+function RunawayBanner({ theme, report, say, onDone, onJobs }: { theme: Theme; report: ProcessReport; say: Say; onDone(): void; onJobs(): void }) {
   if (report.runaways.length === 0) return null;
   const critical = report.runaways.some((runaway) => runaway.severity === "critical");
   const stoppable = new Set(report.processes.filter((row) => row.stoppable).map((row) => row.pid));
+  // 0.13.0: the report says whether each runaway can be stopped, even when its row isn't on this page.
+  const canStop = (runaway: ProcessReport["runaways"][number]) => runaway.stoppable ?? runaway.pids.some((pid) => stoppable.has(pid));
   return (
     <Banner theme={theme} tone={critical ? "danger" : "warning"} title={report.runaways.length === 1 ? "Something needs attention" : `${report.runaways.length} things need attention`}>
       {report.runaways.map((runaway, index) => (
@@ -102,7 +106,7 @@ function RunawayBanner({ theme, report, onReview, onJobs }: { theme: Theme; repo
           {(runaway.code === "cpu-runaway" || runaway.code === "memory-heavy") && runaway.pids.length ? (
             <Row>
               <AskAgentButton theme={theme} subject={{ kind: "process", pid: runaway.pids[0]! }} />
-              {runaway.pids.some((pid) => stoppable.has(pid)) ? <Button theme={theme} label="Review and stop…" icon="OctagonX" danger onPress={() => onReview(runaway.pids.filter((pid) => stoppable.has(pid)))} /> : null}
+              {canStop(runaway) ? <StopProcess theme={theme} pid={runaway.pids[0]!} say={say} onDone={onDone} /> : null}
             </Row>
           ) : null}
         </View>
@@ -195,18 +199,21 @@ function ProcessItem({ theme, row, compact, byTree, selected, onSelect, onStop,
   );
 }
 
-const ACTION_WORD: Record<ActionLogEntry["action"], string> = { stop: "Asked to stop", "force-stop": "Force stopped", "auto-force-stop": "Stopped forcefully" };
+const ACTION_WORD: Record<ActionLogEntry["action"], string> = {
+  stop: "Asked to stop", "force-stop": "Force stopped", "auto-force-stop": "Stopped forcefully",
+  "plugin-reload": "Reloaded plugin", "plugin-stop": "Stopped stuck plugin", "plugin-force-stop": "Force stopped stuck plugin", "auto-stop": "Stopped automatically (memory nearly full)",
+};
 
 /** Every stop, newest first, from this host's action log: the content of the "Recent stops" fold-out. */
 function RecentStops({ theme, entries }: { theme: Theme; entries: readonly ActionLogEntry[] }) {
-  if (entries.length === 0) return <Note theme={theme}>Nothing has been stopped from Hosts yet.</Note>;
+  if (entries.length === 0) return <Note theme={theme}>Nothing has been stopped or restarted from Hosts yet.</Note>;
   return (
     <>
       {entries.map((entry, index) => (
         <View key={`${entry.at}-${index}`} style={{ gap: SPACE.hair }}>
           {index > 0 ? <Divider theme={theme} /> : null}
           <Text style={{ ...TYPE.body, color: theme.colors.foreground }}>{`${ACTION_WORD[entry.action]}: ${entry.name}${entry.pid ? ` (PID ${entry.pid})` : ""}`}</Text>
-          <Meta theme={theme}>{[new Date(entry.at).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }), entry.owner, entry.status === "signaled" ? `${entry.signaled} process${entry.signaled === 1 ? "" : "es"} signalled` : entry.status.replace(/-/g, " ")].filter(Boolean).join(" · ")}</Meta>
+          <Meta theme={theme}>{[new Date(entry.at).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }), entry.owner, entry.status === "signaled" ? `${entry.signaled} process${entry.signaled === 1 ? "" : "es"} signalled` : entry.status === "done" ? "done" : entry.status.replace(/-/g, " ")].filter(Boolean).join(" · ")}</Meta>
         </View>
       ))}
     </>
@@ -214,7 +221,7 @@ function RecentStops({ theme, entries }: { theme: Theme; entries: readonly Actio
 }
 
 /** The ask-first sheet: exactly what will be stopped (children too), what won't and why, and what happens next. */
-function StopSheet({ theme, plan, busy, onCancel, onConfirm }: { theme: Theme; plan: StopPlan | null; busy: boolean; onCancel(): void; onConfirm(): void }) {
+export function StopSheet({ theme, plan, busy, onCancel, onConfirm }: { theme: Theme; plan: StopPlan | null; busy: boolean; onCancel(): void; onConfirm(): void }) {
   const ready = plan?.targets.filter((target) => target.ok) ?? [];
   const refused = plan?.targets.filter((target) => !target.ok) ?? [];
   const children = ready.reduce((sum, target) => sum + target.children.length, 0);
@@ -260,7 +267,7 @@ export function summarizeNames(names: readonly string[]): string {
  * The Processes tab. Status first (runaways, then the load), then the list
  * with its controls, then the log; pointers last.
  */
-export function ProcessesTab({ theme, compact, hostId, say }: { theme: Theme; compact: boolean; hostId: string; say: Say }) {
+export function ProcessesTab({ theme, compact, hostId, say, issues = [], onChanged }: { theme: Theme; compact: boolean; hostId: string; say: Say; issues?: readonly HealthIssue[]; onChanged?: () => void }) {
   const report = useRpc(processReport), preview = useRpc(processPreview), stop = useRpc(processStop);
   const [sort, setSort] = useState<ReportSort>("cpu");
   const [filter, setFilter] = useState<Filter>("all");
@@ -309,7 +316,12 @@ export function ProcessesTab({ theme, compact, hostId, say }: { theme: Theme; co
   const twins = twinKeys(rows);
   return (
     <>
-      <RunawayBanner theme={theme} report={data} onReview={(pids) => ask.mutate(pids)} onJobs={() => { setFilter("jobs"); setLimit(PAGE); }} />
+      {issues.some((issue) => issue.code === "plugin-stuck") ? (
+        <Banner theme={theme} tone={issues.some((issue) => issue.code === "plugin-stuck" && issue.severity === "critical") ? "danger" : "warning"} title="A Paseo plugin isn't answering">
+          <StuckPlugins theme={theme} issues={issues} say={say} onDone={() => { onChanged?.(); void query.refetch(); }} />
+        </Banner>
+      ) : null}
+      <RunawayBanner theme={theme} report={data} say={say} onDone={() => { onChanged?.(); void query.refetch(); }} onJobs={() => { setFilter("jobs"); setLimit(PAGE); }} />
       <LoadCard theme={theme} report={data} />
       <Card theme={theme} title={filter === "jobs" ? "Heavy jobs" : "Heaviest processes"} icon="ListOrdered" subtitle={filter === "jobs" ? "Each job with everything it started, added together" : SORTED[sort]}>
         <Pills<Filter> theme={theme} label="Show" items={FILTERS} value={filter} onChange={(next) => { setFilter(next); setLimit(PAGE); }} />
@@ -335,11 +347,11 @@ export function ProcessesTab({ theme, compact, hostId, say }: { theme: Theme; co
       </Card>
       {!data.projectsVerified ? <QuietLine theme={theme} icon="ShieldAlert">Paseo projects aren't verified on this host right now, so workspace names are missing and only processes started from Paseo can be stopped.</QuietLine> : null}
       <Accordion theme={theme}>
-        <AccordionItem theme={theme} compact={compact} icon="History" title="Recent stops" summary={data.recentActions.length ? `${data.recentActions.length} logged · last: ${data.recentActions[0]!.name}` : "None yet"}>
+        <AccordionItem theme={theme} compact={compact} icon="History" title="Recent stops and restarts" summary={data.recentActions.length ? `${data.recentActions.length} logged · last: ${data.recentActions[0]!.name}` : "None yet"}>
           <RecentStops theme={theme} entries={data.recentActions} />
         </AccordionItem>
         <AccordionItem theme={theme} compact={compact} icon="ShieldCheck" title="What can be stopped here" summary="Only your own projects' jobs, and always after asking">
-          <Note theme={theme}>Only processes started from Paseo or running inside your Paseo projects can be stopped here. Paseo itself, its plugins, agents, terminals and databases never are. Nothing is ever stopped without asking.</Note>
+          <Note theme={theme}>Only processes started from Paseo or running inside your Paseo projects can be stopped here. Paseo itself, its plugins, agents, terminals and databases never are. Nothing is stopped without asking, unless you turn on the memory guard in Settings → Hosts.</Note>
           <Meta theme={theme}>A stop asks the process to finish first and forces it only if it is still running after the grace period. Each one is logged, without command lines (where: Overview → Technical details).</Meta>
         </AccordionItem>
       </Accordion>
diff --git a/client/quick.tsx b/client/quick.tsx
index 39b16a7..7ea4fe5 100644
--- a/client/quick.tsx
+++ b/client/quick.tsx
@@ -6,6 +6,7 @@ import { hostHealth, pillText, type HealthVerdict } from "../shared/health";
 import { seconds } from "../shared/watch";
 import { Button, Dot, Meta, Note, SPACE, TYPE, toneColor, type Tone } from "./kit";
 import type { PopoverProps } from "./native";
+import { RestartPlugin } from "./guard";
 import { formatBytes } from "./ui";
 
 /**
@@ -60,7 +61,10 @@ export function makeQuickHealth(screenId: string): ComponentType<PopoverProps> {
     const { verdict, refresh, checking } = useVerdict();
     const state = quickState(verdict);
     const load = verdict?.load;
-    const issues = (verdict?.issues ?? []).filter((issue) => issue.code !== "service-slow" && issue.code !== "service-down").slice(0, 3);
+    // 0.13.0: a stuck plugin and an automatic stop come first; they are the ones that take a daemon down or explain why something vanished.
+    const rank = (code: string) => (code === "plugin-stuck" ? 0 : code === "auto-stopped" ? 1 : code === "memory-pressure" || code === "runaway" ? 2 : 3);
+    const issues = (verdict?.issues ?? []).filter((issue) => issue.code !== "service-slow" && issue.code !== "service-down" && issue.code !== "projects-unavailable")
+      .map((issue, index) => ({ issue, index })).sort((a, b) => rank(a.issue.code) - rank(b.issue.code) || a.index - b.index).map(({ issue }) => issue).slice(0, 3);
     return (
       <View style={{ padding: SPACE.md, gap: SPACE.row, minWidth: 280, maxWidth: 380 }}>
         <View style={{ flexDirection: "row", alignItems: "center", gap: SPACE.sm }}>
@@ -73,7 +77,12 @@ export function makeQuickHealth(screenId: string): ComponentType<PopoverProps> {
             <Note theme={theme}>{`Heavy jobs: ${load.heavyJobs} running, ${load.heavyJobLimit} at once is the limit${load.cpuPercent === null ? "" : ` · CPU ${Math.round(load.cpuPercent)}%`}`}</Note>
           </View>
         ) : null}
-        {issues.map((issue, index) => <Note key={`${issue.code}-${index}`} theme={theme} tone={issue.severity === "critical" ? "danger" : "warning"}>{issue.message}</Note>)}
+        {issues.map((issue, index) => (
+          <View key={`${issue.code}-${index}`} style={{ gap: SPACE.sm }}>
+            <Note theme={theme} tone={issue.severity === "critical" ? "danger" : "warning"}>{issue.message}</Note>
+            {issue.code === "plugin-stuck" ? <RestartPlugin theme={theme} issue={issue} onDone={() => void refresh()} /> : null}
+          </View>
+        ))}
         {(verdict?.watched ?? []).map((service) => (
           <View key={service.id} style={{ flexDirection: "row", alignItems: "center", gap: SPACE.sm }}>
             <Dot color={dotColor(theme, WATCH_TONE[service.state] ?? "neutral")} />
@@ -96,7 +105,7 @@ export function makeStatusTrailing(Quick: ComponentType<PopoverProps>): Componen
     const dot = <Dot color={dotColor(theme, state.tone)} />;
     if (!openPopover) return dot;
     return (
-      <Pressable accessibilityRole="button" accessibilityLabel={`Hosts: ${state.text}${state.detail ? `, ${state.detail}` : ""}. Quick health check`} hitSlop={SPACE.sm} onPress={() => openPopover(Quick)} style={{ padding: SPACE.xs }}>
+      <Pressable accessibilityRole="button" accessibilityLabel={`Hosts: ${state.text}${state.detail ? `, ${state.detail.replace(/\.+$/, "")}` : ""}. Quick health check`} hitSlop={SPACE.sm} onPress={() => openPopover(Quick)} style={{ padding: SPACE.xs }}>
         {dot}
       </Pressable>
     );
diff --git a/client/settings.tsx b/client/settings.tsx
index d0c39fd..a5915d5 100644
--- a/client/settings.tsx
+++ b/client/settings.tsx
@@ -132,7 +132,7 @@ function HostsControls({ settings }: { settings: Ready }) {
           />
         </SettingsCard>
       </SettingsSection>
-      <SettingsSection title="Heavy processes" info="Builds, tests, type checks, installs and dev servers running at once. Going over the limit is flagged in Hosts and on the sidebar dot. Nothing is ever stopped automatically.">
+      <SettingsSection title="Heavy processes" info="Builds, tests, type checks, installs and dev servers running at once. Going over the limit is flagged in Hosts and on the sidebar dot. Nothing is stopped automatically unless you turn on the memory guard below.">
         <SettingsCard>
           <SettingsSelect<string>
             label="Heavy jobs at once"
@@ -142,6 +142,13 @@ function HostsControls({ settings }: { settings: Ready }) {
             disabled={settings.saving}
             onValueChange={(value) => save({ maxHeavyJobs: Number(value) })}
           />
+          <SettingsSwitch
+            label="Stop a runaway automatically when memory is nearly full"
+            hint="Off by default. When memory has been nearly full for over a minute, Hosts stops the biggest job it would let you stop by hand: started from Paseo or inside a project, never Paseo itself, an agent, a terminal's shell or a database. It's logged, and the sidebar dot tells you."
+            value={settings.values.autoStopRunaways}
+            disabled={settings.saving}
+            onValueChange={(autoStopRunaways) => save({ autoStopRunaways })}
+          />
         </SettingsCard>
       </SettingsSection>
       <WatchedServices settings={settings} save={save} />
diff --git a/index.server.ts b/index.server.ts
index 1d341c0..25e330b 100644
--- a/index.server.ts
+++ b/index.server.ts
@@ -17,6 +17,7 @@ import { suggestions } from "./server/watch";
 import { askContext, terminalOpen } from "./shared/ask";
 import { hostsAttachmentSearch } from "./shared/attachments";
 import { createAsk } from "./server/ask";
+import { guardState, pluginRestart, pluginRestartStatus } from "./shared/guard";
 
 type SettingsHandle = { read?: () => Promise<{ status: string; values?: unknown }>; subscribe?: (listener: () => void) => () => void } | undefined;
 
@@ -41,7 +42,9 @@ export default function contribute(server: PluginServerContext) {
   const runtime = createRuntime({ readSettings });
   const removeHooks = registerHooks(server, runtime, readSettings);
   // One cached verdict per host; pills and panels read it instead of probing.
-  const health = new HealthChecker({ runtime, readSettings, onVerdict: summaryWriter() });
+  const health = new HealthChecker({ runtime, readSettings, onVerdict: summaryWriter(), guard: runtime.guard ? () => runtime.guard!.state() : undefined });
+  // 0.13.0: the check loop starts now, not on the first app visit: it matters most when the daemon is too busy to answer.
+  runtime.guard?.start();
   // 0.10+: a settings change (say, a new watched service) is checked at once, not on the next tick.
   const unsubscribe = typeof handle?.subscribe === "function" ? handle.subscribe(() => { if (health.current()) void health.check(undefined, true).catch(() => undefined); }) : () => {};
   server.handle(hostHealth, (input, context) => runtime.withContext(context, () => health.read(context, input.refresh === true)));
@@ -55,6 +58,14 @@ export default function contribute(server: PluginServerContext) {
   server.handle(askContext, ({ subject }, context) => runtime.withContext(context, async () => { await health.read(context); return ask.context(subject, context.paseo); }));
   server.handle(hostsAttachmentSearch, ({ query }, context) => runtime.withContext(context, async () => { await health.read(context); return ask.attachments(query, context.paseo); }));
   server.handle(terminalOpen, ({ pid }, context) => runtime.withContext(context, () => ask.openTerminal(pid, context.paseo)));
+  server.handle(guardState, async () => {
+    if (!runtime.guard) throw new Error("Plugin and memory checks aren't available on this host.");
+    return runtime.guard.state();
+  });
+  // The verdict should drop the plugin as soon as it answers again, not on the next interval.
+  const settled = (outcome: { ok: boolean }) => { if (outcome.ok) void runtime.guard?.tick().then(() => health.check(undefined, true)).catch(() => undefined); };
+  server.handle(pluginRestart, async ({ pluginId }) => { const outcome = await runtime.plugins.restart(pluginId); settled(outcome); return outcome; });
+  server.handle(pluginRestartStatus, async ({ pluginId }) => { const outcome = runtime.plugins.status(pluginId); settled(outcome); return outcome; });
   server.handle(watchSuggestions, async () => ({ suggestions: await suggestions((await readSettings().catch(() => HOSTS_SETTINGS_DEFAULTS)).watchedServices) }));
   server.handle(sync.syncStatus, (_input, context) => runtime!.withContext(context, async () => {
     await runtime!.scope.refresh(); return { projects: runtime!.scope.status().projects.map((p) => ({ id: p.id, name: p.name })), history: await runtime!.transfers.history(), grants: await runtime!.peers.projectGrants() };
@@ -84,5 +95,5 @@ export default function contribute(server: PluginServerContext) {
   server.handle(peer.peerServices, ({ id }, context) => runtime.withContext(context, () => runtime.peers.services(id)));
   server.handle(peer.peerForward, ({ id, port }, context) => runtime.withContext(context, () => runtime.peers.forward(id, port)));
   server.handle(peer.peerDisconnect, ({ id }, context) => runtime.withContext(context, () => runtime.peers.disconnect(id)));
-  return async () => { removeHooks(); unsubscribe(); health.close(); runtime.processes.close(); await Promise.all([runtime.links.close(), runtime.peers.close(), runtime.transfers.close()]); };
+  return async () => { removeHooks(); unsubscribe(); health.close(); runtime.guard?.close(); runtime.processes.close(); await Promise.all([runtime.links.close(), runtime.peers.close(), runtime.transfers.close()]); };
 }
diff --git a/package-lock.json b/package-lock.json
index 46f31a8..4a2cc1e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "paseo-plugin-daemon",
-  "version": "0.12.1",
+  "version": "0.13.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "paseo-plugin-daemon",
-      "version": "0.12.1",
+      "version": "0.13.0",
       "license": "MIT",
       "dependencies": {
         "@getpaseo/relay": "0.7.2",
diff --git a/package.json b/package.json
index 3355c51..f79b069 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,7 @@
   "description": "Daemon Link for Paseo: Hosts: private localhost links, reviewed Git project transfers, and project-scoped monitoring.",
   "private": true,
   "type": "module",
-  "version": "0.12.1",
+  "version": "0.13.0",
   "license": "MIT",
   "repository": {
     "type": "git",
diff --git a/server/ask.ts b/server/ask.ts
index 0b728e7..96136fd 100644
--- a/server/ask.ts
+++ b/server/ask.ts
@@ -173,7 +173,7 @@ export function createAsk(deps: AskDeps) {
     const verdict = await deps.verdict().catch(() => null);
     const flag = row.flags[0] ?? null;
     const driver = verdict?.issues.find((issue) => issue.code === "pressure-driver" && issue.pid === row.pid);
-    const code: AskFacts["code"] = flag?.code ?? (driver ? "pressure-driver" : "process");
+    const code: AskFacts["code"] = flag?.code === "memory-growing" ? "memory-heavy" : flag?.code ?? (driver ? "pressure-driver" : "process");
     const directory = absolute(row.cwd);
     const workspace = await workspaceFor(paseo, directory).catch(() => null);
     const serves = row.ports.length > 0 || row.job?.kind === "dev-server";
diff --git a/server/cgroup.ts b/server/cgroup.ts
index 8be0ab2..d34e328 100644
--- a/server/cgroup.ts
+++ b/server/cgroup.ts
@@ -23,6 +23,8 @@ export interface CgroupSample {
   /** Cumulative CPU time the cgroup has used, in microseconds; only deltas matter. */
   cpuUsageUsec: number | null;
   psiMemorySome10: number | null;
+  /** 0.13.0: "full avg10", the share of time every task waited on memory. */
+  psiMemoryFull10?: number | null;
   psiCpuSome10: number | null;
   /** How many times the kernel has OOM-killed a process in this cgroup. */
   oomKills: number | null;
@@ -78,6 +80,12 @@ export function parsePsi(text: string): number | null {
   return match ? Number(match[1]) : null;
 }
 
+/** `memory.pressure` → "full avg10" (0.13.0). */
+export function parsePsiFull(text: string): number | null {
+  const match = /^full\s+avg10=([\d.]+)/m.exec(text);
+  return match ? Number(match[1]) : null;
+}
+
 /** Working set: usage minus the inactive file cache the kernel can drop for free. */
 export function workingSet(usage: number, inactiveFile: number | undefined): number {
   return Math.max(0, usage - (inactiveFile ?? 0));
@@ -105,6 +113,7 @@ async function readV2(fs: CgroupFs, base: string, hostTotalBytes: number): Promi
     cpuLimitCores: cpuMax === null ? null : parseCpuMax(cpuMax),
     cpuUsageUsec: cpuStat === null ? null : parseKeyed(cpuStat).get("usage_usec") ?? null,
     psiMemorySome10: memPsi === null ? null : parsePsi(memPsi),
+    psiMemoryFull10: memPsi === null ? null : parsePsiFull(memPsi),
     psiCpuSome10: cpuPsi === null ? null : parsePsi(cpuPsi),
     oomKills: events === null ? null : parseKeyed(events).get("oom_kill") ?? null,
     version: 2,
diff --git a/server/collector.ts b/server/collector.ts
index 802679e..67269b8 100644
--- a/server/collector.ts
+++ b/server/collector.ts
@@ -328,7 +328,7 @@ export class Collector {
     }
     const newOomKills = raw.oomKills === null || this.oomKills === null ? null : Math.max(0, raw.oomKills - this.oomKills);
     this.oomKills = raw.oomKills;
-    const classified = classifyContainerMemory({ limitBytes: raw.memoryLimitBytes, workingSetBytes: raw.memoryWorkingSetBytes, psiSome10: raw.psiMemorySome10, newOomKills });
+    const classified = classifyContainerMemory({ limitBytes: raw.memoryLimitBytes, workingSetBytes: raw.memoryWorkingSetBytes, psiSome10: raw.psiMemorySome10, psiFull10: raw.psiMemoryFull10 ?? null, newOomKills });
     const cpuBasis = raw.cpuLimitCores ?? sample.cores;
     return {
       memoryLimitBytes: raw.memoryLimitBytes,
@@ -338,6 +338,7 @@ export class Collector {
       cpuCoresUsed: cores === null ? null : Math.round(cores * 100) / 100,
       cpuPercent: cores === null || cpuBasis <= 0 ? null : round1(Math.min(100, (cores / cpuBasis) * 100)),
       psiMemorySome10: raw.psiMemorySome10,
+      psiMemoryFull10: raw.psiMemoryFull10 ?? null,
       psiCpuSome10: raw.psiCpuSome10,
       oomKills: raw.oomKills,
       pressure: classified.pressure,
@@ -355,6 +356,7 @@ export class Collector {
       swapUsedBytes: sample.swapUsedBytes,
       swapGrowthBytes: swapGrowth.delta,
       psiSome10: sample.psiMemorySome10,
+      psiFull10: sample.psiMemoryFull10 ?? null,
       pressureSignal: sample.pressureSignal,
     });
     return {
diff --git a/server/daemon-log.ts b/server/daemon-log.ts
new file mode 100644
index 0000000..fd62e46
--- /dev/null
+++ b/server/daemon-log.ts
@@ -0,0 +1,273 @@
+import { createReadStream } from "node:fs";
+import { open, readdir, stat } from "node:fs/promises";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { createInterface } from "node:readline";
+import { PLUGIN_ID, STUCK_STOPPING_SECONDS, STUCK_TIMEOUTS, STUCK_TIMEOUTS_CRITICAL, TIMEOUT_WINDOW_MINUTES, isHostsPlugin, pluginName } from "../shared/guard";
+
+/**
+ * The daemon's own log, read for plugin health (0.13.0).
+ *
+ * Paseo writes one JSON object per line to `$PASEO_HOME/daemon.log` and
+ * rotates it to `YYYYMMDD-HHMM-NN-daemon.log` at about 10 MB. Four kinds of
+ * line matter here:
+ *  - "Plugin RPC timed out: <plugin>.<method>" (an RPC to a plugin waited 30 s);
+ *  - `ws_slow_request` for `plugin.*` requests (slow, but not attributed);
+ *  - a plugin's lifecycle lines, "[paseo] Loading plugin" / "Plugin ready" /
+ *    "Stopping plugin" / "Plugin stopped", which carry `pluginId` and the
+ *    daemon's `pid`. A plugin's process is forked between its Loading and
+ *    Ready lines, which is how its PID is found (see plugin-procs.ts).
+ * Anything else, and any line that isn't JSON, is skipped. The format is the
+ * daemon's, not a contract: when no line parses, plugin health reports
+ * "unknown" rather than "fine".
+ */
+
+export type LifecycleKind = "loading" | "ready" | "stopping" | "stopped";
+export type LogEvent =
+  | { kind: "timeout"; at: number; target: string }
+  | { kind: "slow"; at: number; requestType: string; durationMs: number }
+  | { kind: LifecycleKind; at: number; pluginId: string; daemonPid: number | null };
+
+const LIFECYCLE: Record<string, LifecycleKind> = {
+  "[paseo] Loading plugin": "loading",
+  "[paseo] Plugin ready": "ready",
+  "[paseo] Stopping plugin": "stopping",
+  "[paseo] Plugin stopped": "stopped",
+};
+const TIMEOUT = /Plugin RPC timed out: ([A-Za-z0-9][\w.-]{0,160})/;
+
+/** One line, or null when it isn't one of the four kinds. Cheap substring checks run before any JSON parse. */
+export function parseLogLine(line: string): LogEvent | null {
+  const timeout = line.includes("Plugin RPC timed out");
+  const slow = !timeout && line.includes("ws_slow_request");
+  const lifecycle = !timeout && !slow && line.includes("[paseo] ");
+  if (!timeout && !slow && !lifecycle) return null;
+  let record: Record<string, unknown>;
+  try {
+    const parsed: unknown = JSON.parse(line);
+    if (!parsed || typeof parsed !== "object") return null;
+    record = parsed as Record<string, unknown>;
+  } catch { return null; }
+  const at = typeof record.time === "number" ? record.time : typeof record.timestamp === "string" ? Date.parse(record.timestamp) : NaN;
+  if (!Number.isFinite(at)) return null;
+  if (timeout) {
+    const match = TIMEOUT.exec(line);
+    return match ? { kind: "timeout", at, target: match[1]!.replace(/\.+$/, "") } : null;
+  }
+  if (slow) {
+    if (record.msg !== "ws_slow_request" || typeof record.requestType !== "string" || !record.requestType.startsWith("plugin.")) return null;
+    return { kind: "slow", at, requestType: record.requestType, durationMs: typeof record.durationM

This version has no OVERVIEW.md. The registry requires one to update a listing; the bump cannot merge until the repository adds it.

Inline validation failed. See the Bump workflow log.

@github-actions github-actions Bot added the bump Pinned artifact update label Oct 7, 2026

@paseo-bot paseo-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs from you: nothing, changes requested

@itsjustanks, Hosts v0.13.0 can't merge yet: the repository has no OVERVIEW.md at the tagged commit, and the registry needs one for every new version.

Change needed: add OVERVIEW.md next to paseo-plugin.json, tag a new release, and the bump workflow opens a fresh pull request for it. Your file replaces the registry copy plugins/itsjustanks/daemon.md. You can start from that copy without its last "imported from paseo.cafe" line, but v0.13.0 changes what it describes, so the new file needs to cover:

  • Restart for a stuck plugin: after you confirm, it runs paseo plugin reload <id>, and if that hangs it stops that plugin's process with SIGTERM, then SIGKILL. The registry copy says the Processes tab refuses to stop Paseo components.
  • The optional automatic stop of the biggest runaway process under sustained memory pressure, off by default. The registry copy says health checks never start a stop.
  • What it reads to do this: Paseo's daemon.log under the Paseo home, checked every 10 seconds (30 on macOS) while the plugin runs, and memory pressure from the system.

The shape is in REVIEW.md: what it is and does, how it works if needed, setup, then what it reads, sends, and runs and known limits. Leave out installation steps, badges, changelog, and license sections.

The code change in v0.13.0 is fine. Nothing else needs to change.

Review data

  • Artifact: git https://github.com/itsjustanks/paseo-plugin-daemon.git, 59b315d064c7d873cade37bf523c6351a4da20ca (tag v0.13.0), from 3621929c1fb04118b6d033245448eb1c7f2bfac9 (tag v0.12.1). No pluginPath.
  • Pin: both commits exist on the remote, and both tags peel to them. paseo-plugin.json is at the root.
  • Validation: npm test passes (140/140). node scripts/validate.ts --online --changed fails with one error: itsjustanks/daemon/OVERVIEW.md is required.
  • Extracted and inspected: both commits exported and diffed locally, 41 files changed and 12 added. New server/daemon-log.ts (parses plugin timeouts and Loading/Ready lines from the daemon log), server/plugin-procs.ts (matches a plugin's process by start time), server/plugin-restart.ts (reload, then escalation), server/paseo-cli.ts (runs paseo), server/guard-loop.ts (10-second check loop and the opt-in memory guard), shared/guard.ts, client/guard.tsx. server/processes.ts adds autoStopBiggest; shared/settings.ts:40 adds autoStopRunaways, default false.
  • Install-time commands: paseo-plugin.json build is npm ci --ignore-scripts, unchanged. No manifest install. package.json has no preinstall, install, postinstall, or prepare; its scripts are dev-only and scripts/*.mjs is unchanged and not run at install.
  • Dependencies: runtime @getpaseo/relay 0.7.2 and ws 8.21.3, unchanged. package-lock.json has 443 entries, each resolved on registry.npmjs.org with an integrity, and npm ci --ignore-scripts installs it; only the root version field changes. Entries with install scripts (esbuild, fsevents) are dev only and do not run under --ignore-scripts.
  • Hosts: none added. The existing trycloudflare.com, relay.paseo.sh, and GitHub releases for cloudflared are unchanged.
  • Credentials and environment: one new read, PASEO_HOME with the Paseo home directory as fallback (server/daemon-log.ts:173), to find daemon.log. server/paseo-cli.ts:20 passes the plugin's environment plus NO_COLOR=1 to paseo. No credential files, tokens, or keychains.
  • Filesystem: reads only. daemon.log (4 MB on the first poll, then at most 1 MB of new data per poll, server/daemon-log.ts:202-212), rotated daemon logs when a restart needs them (at most 6 files and 96 MB, server/daemon-log.ts:250), and memory pressure and same-user process status from /proc and cgroups (server/guard-loop.ts:52-78, server/plugin-procs.ts:130). No new writes beyond the plugin's own action log.
  • Execution: execFile with no shell and fixed argument arrays (server/paseo-cli.ts:19-25). The binary is the paseo that ships next to the daemon, else paseo on PATH (server/paseo-cli.ts:33-41). Arguments are --version, plugin reload --help, and plugin reload <id>, where <id> must match ^[a-z0-9][a-z0-9._-]{0,63}$ (shared/guard.ts:181). Restart refuses its own id (server/plugin-restart.ts:100-101), signals only after the reload times out, and only a direct child of the daemon owned by the same user running plugin-process.js whose start time falls in exactly one Loading/Ready window (server/plugin-procs.ts:44-73,112); it re-checks the process before SIGTERM and SIGKILL (server/plugin-restart.ts:147-170). The user confirms each restart in the UI (client/guard.tsx:66-75). The memory guard is off by default and uses the existing signed stop path (server/guard-loop.ts:247-268). No eval, new Function, or dynamic import() added.
  • Runtime installs: none added; the existing cloudflared download still runs only when the user presses its button.
  • Source match: git artifact; readable TypeScript, no bundled or minified files.
  • Listing media: six PNGs pinned to 3621929, each HTTP 200 image/png.
  • Overview: missing at 59b315d. The registry copy contradicts v0.13.0 on stopping Paseo plugin processes and on health checks never stopping anything.
  • Decision: changes requested, because OVERVIEW.md is missing.
  • Reviewed commit: a362e69d0c4144419b1a1e47ae8ae0ab50290eea

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bump Pinned artifact update

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants