@saadnvd1/agent-os 0.3.845 → 0.3.851

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +6 -0
  2. package/app/api/approvals/route.ts +5 -1
  3. package/app/api/health/config/route.ts +2 -0
  4. package/app/api/updates/run/route.ts +7 -3
  5. package/app/globals.css +6 -0
  6. package/components/Audits/AuditsPanel.tsx +42 -35
  7. package/components/Chat/quote-selection.test.ts +6 -0
  8. package/components/Chat/quote-selection.ts +13 -10
  9. package/components/Devices/ConnectCard.tsx +52 -56
  10. package/components/Devices/DeviceGroupRow.tsx +65 -0
  11. package/components/Devices/DeviceRow.tsx +2 -2
  12. package/components/Devices/DevicesPanel.tsx +63 -49
  13. package/components/Devices/NetworkSection.tsx +29 -26
  14. package/components/Devices/TailscaleCard.tsx +9 -10
  15. package/components/Devices/group.test.ts +66 -0
  16. package/components/Devices/group.ts +67 -0
  17. package/components/Health/HealthPanel.tsx +5 -81
  18. package/components/Health/MachineChecks.tsx +20 -0
  19. package/components/Health/SweepRuns.tsx +46 -51
  20. package/components/Health/Thresholds.tsx +50 -0
  21. package/components/Merge/GlobalMergeSection.tsx +5 -6
  22. package/components/Merge/MergeApprovalToggle.tsx +20 -14
  23. package/components/Merge/MergeSettingsFields.tsx +0 -2
  24. package/components/Merge/WorktreeSweepSection.tsx +15 -34
  25. package/components/Models/AgentRoleCard.tsx +7 -7
  26. package/components/Models/ModelSelect.tsx +14 -3
  27. package/components/Models/ModelsSection.tsx +42 -37
  28. package/components/Models/ProviderCard.tsx +1 -1
  29. package/components/Models/RoleModelSelect.tsx +15 -3
  30. package/components/Models/SubagentModels.tsx +60 -45
  31. package/components/PhoneNotify/PhoneNotifyForm.tsx +6 -13
  32. package/components/Recipes/RecipesPanel.tsx +20 -9
  33. package/components/Recipes/RunHistory.tsx +1 -1
  34. package/components/Schedules/SchedulesPanel.tsx +12 -10
  35. package/components/Settings/AppWideLimits.tsx +2 -2
  36. package/components/Settings/{NoApprovalsSection.test.ts → ApprovalsSection.test.ts} +61 -22
  37. package/components/Settings/ApprovalsSection.tsx +93 -0
  38. package/components/Settings/ExperimentsSection.test.ts +20 -4
  39. package/components/Settings/ExperimentsSection.tsx +54 -34
  40. package/components/Settings/GeneralSection.tsx +39 -0
  41. package/components/Settings/ListSection.test.ts +85 -0
  42. package/components/Settings/ListSection.tsx +132 -0
  43. package/components/Settings/NotificationsSection.tsx +31 -29
  44. package/components/Settings/PickerSetting.tsx +5 -1
  45. package/components/Settings/SettingRow.tsx +13 -8
  46. package/components/Settings/SettingsDialog.test.ts +31 -4
  47. package/components/Settings/SettingsDialog.tsx +22 -17
  48. package/components/Settings/ValueLabel.tsx +31 -0
  49. package/components/Settings/settings-fields.test.ts +81 -2
  50. package/components/Settings/settings-fields.tsx +27 -27
  51. package/components/Shares/SharedLinksPanel.tsx +11 -14
  52. package/components/Updates/AutoUpdates.tsx +12 -8
  53. package/components/Updates/UpdateActions.tsx +18 -3
  54. package/components/Updates/UpdateToast.test.ts +17 -0
  55. package/components/Updates/UpdateToast.tsx +15 -5
  56. package/components/Updates/UpdatesSection.tsx +27 -18
  57. package/components/Workspaces/WorkspaceSettings.test.ts +5 -5
  58. package/components/Workspaces/WorkspaceSettings.tsx +0 -2
  59. package/components/ui/switch.tsx +1 -1
  60. package/content/whats-new.json +24 -0
  61. package/data/approvals/index.ts +2 -2
  62. package/data/health/index.ts +3 -0
  63. package/hooks/useAutoSave.ts +18 -5
  64. package/hooks/useReloadWhenUpdated.ts +9 -0
  65. package/hooks/useSettingsUrl.ts +6 -3
  66. package/lib/approvals/describe.ts +64 -0
  67. package/lib/health/config.ts +70 -17
  68. package/lib/models/providers.ts +1 -1
  69. package/lib/recipes/settle.ts +22 -0
  70. package/lib/settings-url.ts +14 -2
  71. package/lib/updates/banner.ts +12 -2
  72. package/lib/updates/request.ts +29 -0
  73. package/lib/updates/run.ts +24 -5
  74. package/lib/updates/view.ts +3 -0
  75. package/package.json +1 -1
  76. package/scripts/autodeploy +25 -3
  77. package/scripts/autodeploy.test.ts +47 -0
  78. package/components/Settings/NoApprovalsSection.tsx +0 -103
  79. package/components/Settings/SettingToggle.tsx +0 -36
  80. package/components/Settings/SetupSection.tsx +0 -46
@@ -1,11 +1,13 @@
1
1
  "use client";
2
2
 
3
- import { SettingToggle as Toggle } from "@/components/Settings/SettingToggle";
3
+ import { SettingSection } from "@/components/Settings/SettingRow";
4
+ import { SwitchSetting } from "@/components/Settings/settings-fields";
4
5
  import { useNetworkQuery, useUpdateNetwork } from "@/data/devices";
5
6
  import { TailscaleCard } from "./TailscaleCard";
6
7
  import { ConnectCard } from "./ConnectCard";
7
8
  import { ReachRow } from "@/components/Reach/ReachRow";
8
9
 
10
+ // How devices reach this machine: Connect, Wi-Fi and Tailscale.
9
11
  export function NetworkSection({
10
12
  open,
11
13
  canManage,
@@ -24,31 +26,32 @@ export function NetworkSection({
24
26
  return (
25
27
  <div className="space-y-2">
26
28
  <ReachRow enabled={open} />
27
- <ConnectCard
28
- connect={data.connect}
29
- canManage={canManage}
30
- onToggle={(connect) => update.mutate({ connect })}
31
- />
32
- <TailscaleCard tailscale={data.tailscale} />
33
- <Toggle
34
- title="Allow devices on this Wi-Fi"
35
- detail="Paired devices on the same network can connect. Use it on networks you trust: Wi-Fi traffic isn't encrypted."
36
- checked={data.lan.on}
37
- locked={data.lan.locked || !canManage}
38
- onChange={(lan) => update.mutate({ lan })}
39
- />
40
- <Toggle
41
- title="Require pairing on Tailscale too"
42
- detail="By default anything on your tailnet is trusted. Turn this on if others share your tailnet."
43
- checked={data.requirePairingOnTailnet.on}
44
- locked={data.requirePairingOnTailnet.locked || !canManage}
45
- onChange={(requirePairingOnTailnet) =>
46
- update.mutate({ requirePairingOnTailnet })
47
- }
48
- />
49
- {update.error && (
50
- <p className="text-destructive text-xs">{update.error.message}</p>
51
- )}
29
+ <SettingSection>
30
+ <ConnectCard
31
+ connect={data.connect}
32
+ canManage={canManage}
33
+ onToggle={(connect) => update.mutateAsync({ connect })}
34
+ />
35
+ <TailscaleCard tailscale={data.tailscale} />
36
+ <SwitchSetting
37
+ id="network-lan"
38
+ label="Allow devices on this Wi-Fi"
39
+ help="Paired devices on the same network can connect. Use it on networks you trust: Wi-Fi traffic isn't encrypted."
40
+ checked={data.lan.on}
41
+ locked={data.lan.locked || !canManage}
42
+ onSave={(lan) => update.mutateAsync({ lan })}
43
+ />
44
+ <SwitchSetting
45
+ id="network-tailnet-pairing"
46
+ label="Require pairing on Tailscale too"
47
+ help="By default anything on your tailnet is trusted. Turn this on if others share your tailnet."
48
+ checked={data.requirePairingOnTailnet.on}
49
+ locked={data.requirePairingOnTailnet.locked || !canManage}
50
+ onSave={(requirePairingOnTailnet) =>
51
+ update.mutateAsync({ requirePairingOnTailnet })
52
+ }
53
+ />
54
+ </SettingSection>
52
55
  </div>
53
56
  );
54
57
  }
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { Globe } from "lucide-react";
3
+ import { SettingRow } from "@/components/Settings/SettingRow";
4
+ import { Address } from "./ConnectCard";
4
5
  import type { TailscaleState } from "@/lib/tailscale";
5
6
 
6
7
  const DOWNLOAD = "https://tailscale.com/download";
@@ -12,8 +13,7 @@ export function TailscaleCard({ tailscale }: { tailscale: TailscaleState }) {
12
13
  const url = `http://${tailscale.dnsName ?? tailscale.ips[0]}:${window.location.port || 3011}`;
13
14
  body = (
14
15
  <>
15
- Reachable from anywhere on your tailnet at{" "}
16
- <span className="text-foreground font-mono break-all">{url}</span>.
16
+ Reachable from anywhere on your tailnet at <Address>{url}</Address>.
17
17
  Install Tailscale on your phone and sign in to the same account to use
18
18
  it there.
19
19
  </>
@@ -52,12 +52,11 @@ export function TailscaleCard({ tailscale }: { tailscale: TailscaleState }) {
52
52
  }
53
53
 
54
54
  return (
55
- <div className="bg-muted/40 flex items-start gap-3 rounded-lg px-3 py-2.5">
56
- <Globe className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
57
- <div className="min-w-0 flex-1">
58
- <p className="text-sm font-medium">Use it away from home</p>
59
- <p className="text-muted-foreground text-xs">{body}</p>
60
- </div>
61
- </div>
55
+ <SettingRow
56
+ id="tailscale"
57
+ label="Use it away from home"
58
+ help={body}
59
+ control={null}
60
+ />
62
61
  );
63
62
  }
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { ListedDevice } from "@/lib/security/devices";
3
+ import { groupDevices } from "./group";
4
+
5
+ const NOW = Date.parse("2026-11-02T12:00:00Z");
6
+ const dev = (id: string, name: string, daysAgo: number, ua = "ua") =>
7
+ ({
8
+ id,
9
+ name,
10
+ user_agent: ua,
11
+ created_at: "2026-01-01 00:00:00",
12
+ last_seen_at: new Date(NOW - daysAgo * 86_400_000)
13
+ .toISOString()
14
+ .slice(0, 19)
15
+ .replace("T", " "),
16
+ }) as unknown as ListedDevice;
17
+
18
+ describe("groupDevices", () => {
19
+ it("folds identical devices into one group, newest first", () => {
20
+ const sims = Array.from({ length: 40 }, (_, i) =>
21
+ dev(`s${i}`, "Simulator", i / 100)
22
+ );
23
+ const { recent, older } = groupDevices(
24
+ [dev("me", "Laptop", 0, "mac"), ...sims],
25
+ "me",
26
+ NOW
27
+ );
28
+ expect(older).toHaveLength(0);
29
+ expect(recent).toHaveLength(2);
30
+ const group = recent.find((e) => e.kind === "group")!;
31
+ expect(group.kind === "group" && group.devices).toHaveLength(40);
32
+ expect(group.device.id).toBe("s0");
33
+ });
34
+
35
+ it("keeps a pair of identical devices as two rows", () => {
36
+ const { recent } = groupDevices(
37
+ [dev("a", "Phone", 1), dev("b", "Phone", 2)],
38
+ null,
39
+ NOW
40
+ );
41
+ expect(recent.map((e) => e.kind)).toEqual(["device", "device"]);
42
+ });
43
+
44
+ it("never folds or ages out the device in use", () => {
45
+ const list = [
46
+ dev("me", "Simulator", 90),
47
+ dev("a", "Simulator", 90),
48
+ dev("b", "Simulator", 90),
49
+ dev("c", "Simulator", 90),
50
+ ];
51
+ const { recent, older } = groupDevices(list, "me", NOW);
52
+ expect(recent.map((e) => e.key)).toEqual(["me"]);
53
+ expect(older).toHaveLength(1);
54
+ expect(older[0].kind).toBe("group");
55
+ });
56
+
57
+ it("sets devices unused for a month apart", () => {
58
+ const { recent, older } = groupDevices(
59
+ [dev("new", "Phone", 1), dev("old", "iPad", 45)],
60
+ null,
61
+ NOW
62
+ );
63
+ expect(recent.map((e) => e.key)).toEqual(["new"]);
64
+ expect(older.map((e) => e.key)).toEqual(["old"]);
65
+ });
66
+ });
@@ -0,0 +1,67 @@
1
+ import type { ListedDevice } from "@/lib/security/devices";
2
+
3
+ export const STALE_DAYS = 30;
4
+ // Fewer identical devices than this stay separate rows.
5
+ export const GROUP_MIN = 3;
6
+
7
+ export type DeviceEntry =
8
+ | { kind: "device"; key: string; device: ListedDevice }
9
+ // Devices with the same name and browser, none of them the one in use: a
10
+ // simulator paired over and over, say. `device` is the most recently seen.
11
+ | {
12
+ kind: "group";
13
+ key: string;
14
+ device: ListedDevice;
15
+ devices: ListedDevice[];
16
+ };
17
+
18
+ const parseUtc = (s: string) => new Date(`${s.replace(" ", "T")}Z`).getTime();
19
+
20
+ const seenAt = (d: ListedDevice) =>
21
+ d.last_seen_at ? parseUtc(d.last_seen_at) : parseUtc(d.created_at);
22
+
23
+ /**
24
+ * The list the Devices page shows: identical devices folded into one row,
25
+ * and the ones not used for a month set apart as `older`. The device in use
26
+ * is never folded or aged out.
27
+ */
28
+ export function groupDevices(
29
+ devices: ListedDevice[],
30
+ currentId: string | null | undefined,
31
+ now = Date.now()
32
+ ): { recent: DeviceEntry[]; older: DeviceEntry[] } {
33
+ const buckets = new Map<string, ListedDevice[]>();
34
+ const entries: DeviceEntry[] = [];
35
+ for (const d of devices) {
36
+ if (d.id === currentId) {
37
+ entries.push({ kind: "device", key: d.id, device: d });
38
+ continue;
39
+ }
40
+ const k = `${d.name}\u0000${d.user_agent ?? ""}`;
41
+ buckets.set(k, [...(buckets.get(k) ?? []), d]);
42
+ }
43
+ for (const [k, list] of buckets) {
44
+ const sorted = [...list].sort((a, b) => seenAt(b) - seenAt(a));
45
+ if (sorted.length >= GROUP_MIN)
46
+ entries.push({
47
+ kind: "group",
48
+ key: `group:${k}`,
49
+ device: sorted[0],
50
+ devices: sorted,
51
+ });
52
+ else
53
+ for (const d of sorted)
54
+ entries.push({ kind: "device", key: d.id, device: d });
55
+ }
56
+ entries.sort((a, b) => seenAt(b.device) - seenAt(a.device));
57
+ const cutoff = now - STALE_DAYS * 86_400_000;
58
+ const recent: DeviceEntry[] = [];
59
+ const older: DeviceEntry[] = [];
60
+ for (const e of entries) {
61
+ const keep =
62
+ (e.kind === "device" && e.device.id === currentId) ||
63
+ seenAt(e.device) >= cutoff;
64
+ (keep ? recent : older).push(e);
65
+ }
66
+ return { recent, older };
67
+ }
@@ -7,10 +7,8 @@ import {
7
7
  useAddNote,
8
8
  useDeleteNote,
9
9
  useDismissAnomaly,
10
- useHealthConfig,
11
10
  useHealthNotes,
12
11
  useHealthQuery,
13
- useSaveConfig,
14
12
  } from "@/data/health";
15
13
  import { AREAS, AREA_LABELS, type Area } from "@/lib/health/types";
16
14
  import {
@@ -20,7 +18,9 @@ import {
20
18
  } from "@/lib/health/view";
21
19
  import { cn } from "@/lib/utils";
22
20
  import { EnvFailures } from "./EnvFailures";
21
+ import { MachineChecks } from "./MachineChecks";
23
22
  import { SweepRuns } from "./SweepRuns";
23
+ import { Thresholds } from "./Thresholds";
24
24
 
25
25
  const TABS = [
26
26
  "open",
@@ -29,6 +29,7 @@ const TABS = [
29
29
  "notes",
30
30
  "thresholds",
31
31
  "cleanup",
32
+ "machine",
32
33
  ] as const;
33
34
  type Tab = (typeof TABS)[number];
34
35
  const TAB_LABEL: Record<Tab, string> = {
@@ -38,6 +39,7 @@ const TAB_LABEL: Record<Tab, string> = {
38
39
  notes: "Notes",
39
40
  thresholds: "Thresholds",
40
41
  cleanup: "Clean-up",
42
+ machine: "This machine",
41
43
  };
42
44
 
43
45
  const tokens = (n: number) =>
@@ -302,85 +304,6 @@ function Notes() {
302
304
  );
303
305
  }
304
306
 
305
- function Thresholds() {
306
- const { data } = useHealthConfig();
307
- const save = useSaveConfig();
308
- const [draft, setDraft] = useState<Record<string, string>>({});
309
- const [errors, setErrors] = useState<Record<string, string>>({});
310
- if (!data) return null;
311
- const drop = (k: string) => {
312
- setDraft(({ [k]: _, ...rest }) => rest);
313
- setErrors(({ [k]: _, ...rest }) => rest);
314
- };
315
- return (
316
- <div className="space-y-3">
317
- <p className="text-muted-foreground text-sm">
318
- When a check trips. Clear a box to go back to the default.
319
- </p>
320
- <ul className="border-border/60 overflow-hidden rounded-xl border">
321
- {Object.keys(data.defaults).map((k) => {
322
- const value = draft[k] ?? String(data.config[k]);
323
- return (
324
- <li
325
- key={k}
326
- className="border-border/60 flex items-center gap-3 border-b px-3 py-2 last:border-b-0"
327
- >
328
- <label htmlFor={`health-${k}`} className="min-w-0 flex-1 text-sm">
329
- {data.labels[k]}
330
- <span className="text-muted-foreground block text-xs">
331
- default {data.defaults[k]}
332
- </span>
333
- </label>
334
- <span className="flex flex-col items-end gap-1">
335
- <Input
336
- id={`health-${k}`}
337
- inputMode="decimal"
338
- value={value}
339
- aria-invalid={!!errors[k]}
340
- onChange={(e) => {
341
- setDraft({ ...draft, [k]: e.target.value });
342
- setErrors(({ [k]: _, ...rest }) => rest);
343
- }}
344
- onBlur={() => {
345
- if (draft[k] === undefined) return;
346
- const raw = draft[k].trim();
347
- const n = raw === "" ? null : Number(raw);
348
- if (n !== null && (!Number.isFinite(n) || n < 0))
349
- return setErrors({
350
- ...errors,
351
- [k]: "Enter a number, 0 or more.",
352
- });
353
- if (n === data.config[k]) return drop(k);
354
- save.mutate({ [k]: n }, { onSettled: () => drop(k) });
355
- }}
356
- onKeyDown={(e) => {
357
- if (e.key === "Enter") e.currentTarget.blur();
358
- if (e.key === "Escape") {
359
- drop(k);
360
- e.currentTarget.blur();
361
- }
362
- }}
363
- className="h-11 w-24 text-right tabular-nums md:h-9"
364
- />
365
- {errors[k] && (
366
- <span role="alert" className="text-destructive text-xs">
367
- {errors[k]}
368
- </span>
369
- )}
370
- </span>
371
- </li>
372
- );
373
- })}
374
- </ul>
375
- {save.error && (
376
- <p role="alert" className="text-destructive text-sm">
377
- {save.error.message}
378
- </p>
379
- )}
380
- </div>
381
- );
382
- }
383
-
384
307
  export function HealthPanel() {
385
308
  const { data, error } = useHealthQuery();
386
309
  const dismiss = useDismissAnomaly();
@@ -445,6 +368,7 @@ export function HealthPanel() {
445
368
  {tab === "notes" && <Notes />}
446
369
  {tab === "thresholds" && <Thresholds />}
447
370
  {tab === "cleanup" && <SweepRuns />}
371
+ {tab === "machine" && <MachineChecks />}
448
372
  </div>
449
373
  );
450
374
  }
@@ -0,0 +1,20 @@
1
+ "use client";
2
+
3
+ import { HealthList } from "@/components/Welcome/HealthList";
4
+ import { AgentList } from "@/components/Welcome/AgentList";
5
+
6
+ // What this machine has for running AgentOS and agents.
7
+ export function MachineChecks() {
8
+ return (
9
+ <div className="flex flex-col gap-5">
10
+ <div className="flex flex-col gap-2">
11
+ <h3 className="text-sm font-semibold">Checks</h3>
12
+ <HealthList />
13
+ </div>
14
+ <div className="flex flex-col gap-2">
15
+ <h3 className="text-sm font-semibold">Agents on this machine</h3>
16
+ <AgentList />
17
+ </div>
18
+ </div>
19
+ );
20
+ }
@@ -1,6 +1,9 @@
1
1
  "use client";
2
2
 
3
3
  import { Button } from "@/components/ui/button";
4
+ import { ListSection } from "@/components/Settings/ListSection";
5
+ import { SettingRow, SettingSection } from "@/components/Settings/SettingRow";
6
+ import { SwitchSetting } from "@/components/Settings/settings-fields";
4
7
  import {
5
8
  useRunSweep,
6
9
  useSetReaperArmed,
@@ -49,7 +52,7 @@ function Run({ run }: { run: SweepRun }) {
49
52
  const skipped = run.details?.skipped ?? [];
50
53
  const removed = run.details?.removed ?? [];
51
54
  return (
52
- <li className="border-border/60 border-b px-3 py-1 last:border-b-0">
55
+ <div className="px-3 py-1">
53
56
  <details>
54
57
  <summary className="flex min-h-11 cursor-pointer flex-wrap items-center gap-x-3 gap-y-1 text-sm">
55
58
  <span className="font-medium">
@@ -111,7 +114,7 @@ function Run({ run }: { run: SweepRun }) {
111
114
  </div>
112
115
  )}
113
116
  </details>
114
- </li>
117
+ </div>
115
118
  );
116
119
  }
117
120
 
@@ -132,45 +135,40 @@ export function SweepRuns() {
132
135
  </p>
133
136
  )}
134
137
  {data && (
135
- <div className="flex flex-wrap items-center gap-2">
136
- <p className="text-muted-foreground min-w-0 flex-1 text-sm">
137
- Full clean-up is {data.armed ? "on" : "off"}: test leftovers, stale
138
- task databases, spares, temp folders and build caches{" "}
139
- {data.armed
140
- ? "are removed with each run."
141
- : "are only listed until you turn it on. Read a dry run first."}
142
- </p>
143
- <Button
144
- variant="outline"
145
- className="min-h-11 md:min-h-8"
146
- disabled={arm.isPending}
147
- onClick={() => arm.mutate(!data.armed)}
148
- >
149
- {data.armed ? "Turn off" : "Turn on"}
150
- </Button>
151
- </div>
138
+ <SettingSection>
139
+ <SwitchSetting
140
+ id="sweep-full"
141
+ label="Full clean-up"
142
+ help="Test leftovers, stale task databases, spares, temp folders and build caches are removed with each run. Off, they are only listed: read a dry run first. Settled worktrees go after the number of days set in Merging, every 6 hours; branches and conversations stay."
143
+ checked={data.armed}
144
+ defaultValue={false}
145
+ onSave={(on) => arm.mutateAsync(on)}
146
+ />
147
+ <SettingRow
148
+ id="sweep-run"
149
+ label="Run a clean-up now"
150
+ control={
151
+ <>
152
+ <Button
153
+ variant="outline"
154
+ className="min-h-11 md:min-h-8"
155
+ disabled={run.isPending}
156
+ onClick={() => run.mutate(true)}
157
+ >
158
+ Dry run
159
+ </Button>
160
+ <Button
161
+ className="min-h-11 md:min-h-8"
162
+ disabled={run.isPending}
163
+ onClick={() => run.mutate(false)}
164
+ >
165
+ Run now
166
+ </Button>
167
+ </>
168
+ }
169
+ />
170
+ </SettingSection>
152
171
  )}
153
- <div className="flex flex-wrap items-center gap-2">
154
- <p className="text-muted-foreground min-w-0 flex-1 text-sm">
155
- Settled worktrees older than {data?.days ?? data?.defaultDays ?? 7}{" "}
156
- days are removed every 6 hours. Branches and conversations stay.
157
- </p>
158
- <Button
159
- variant="outline"
160
- className="min-h-11 md:min-h-8"
161
- disabled={run.isPending}
162
- onClick={() => run.mutate(true)}
163
- >
164
- Dry run
165
- </Button>
166
- <Button
167
- className="min-h-11 md:min-h-8"
168
- disabled={run.isPending}
169
- onClick={() => run.mutate(false)}
170
- >
171
- Run now
172
- </Button>
173
- </div>
174
172
  {run.error && (
175
173
  <p role="alert" className="text-destructive text-sm">
176
174
  {run.error.message}
@@ -184,17 +182,14 @@ export function SweepRuns() {
184
182
  {isPending && (
185
183
  <div className="bg-muted/40 h-24 animate-pulse rounded-xl" />
186
184
  )}
187
- {data && data.runs.length === 0 && (
188
- <p className="text-muted-foreground text-sm">
189
- No run yet. The first one starts 10 minutes after the server does.
190
- </p>
191
- )}
192
- {data && data.runs.length > 0 && (
193
- <ul className="border-border/60 overflow-hidden rounded-xl border">
194
- {data.runs.map((r) => (
195
- <Run key={r.id} run={r} />
196
- ))}
197
- </ul>
185
+ {data && (
186
+ <ListSection
187
+ title="Clean-up runs"
188
+ items={data.runs}
189
+ keyOf={(r) => String(r.id)}
190
+ render={(r) => <Run run={r} />}
191
+ empty="No run yet. The first one starts 10 minutes after the server does."
192
+ />
198
193
  )}
199
194
  </div>
200
195
  );
@@ -0,0 +1,50 @@
1
+ "use client";
2
+
3
+ import { SettingSection } from "@/components/Settings/SettingRow";
4
+ import {
5
+ NumberSetting,
6
+ SwitchSetting,
7
+ } from "@/components/Settings/settings-fields";
8
+ import { useHealthConfig, useSaveConfig } from "@/data/health";
9
+
10
+ // When each check trips. The server says how every one reads (label, unit,
11
+ // range, switch), so a new threshold shows up here without a client change.
12
+ export function Thresholds() {
13
+ const { data } = useHealthConfig();
14
+ const save = useSaveConfig();
15
+ if (!data) return null;
16
+ return (
17
+ <div className="space-y-3">
18
+ <SettingSection>
19
+ {Object.keys(data.defaults).map((k) => {
20
+ const meta = data.meta[k];
21
+ const put = (n: number) => save.mutateAsync({ [k]: n });
22
+ return meta.switch ? (
23
+ <SwitchSetting
24
+ key={k}
25
+ id={`health-${k}`}
26
+ label={meta.label}
27
+ help="Off, anomalies still open and reach the chat, but nothing wakes to look at them."
28
+ checked={data.config[k] !== 0}
29
+ defaultValue={data.defaults[k] !== 0}
30
+ onSave={(on) => put(on ? 1 : 0)}
31
+ />
32
+ ) : (
33
+ <NumberSetting
34
+ key={k}
35
+ id={`health-${k}`}
36
+ label={meta.label}
37
+ value={data.config[k]}
38
+ defaultValue={data.defaults[k]}
39
+ min={meta.min}
40
+ max={meta.max}
41
+ integer={meta.integer ?? true}
42
+ unit={meta.unit}
43
+ onSave={put}
44
+ />
45
+ );
46
+ })}
47
+ </SettingSection>
48
+ </div>
49
+ );
50
+ }
@@ -9,7 +9,6 @@ import {
9
9
  type MergeOverride,
10
10
  type MergeSettings,
11
11
  } from "@/lib/tasks/merge-methods";
12
- import { MergeApprovalToggle } from "./MergeApprovalToggle";
13
12
  import { MergeSettingsFields } from "./MergeSettingsFields";
14
13
  import { GlobalSweepSection } from "./WorktreeSweepSection";
15
14
 
@@ -37,7 +36,7 @@ function Overrides({ overrides }: { overrides: MergeOverride[] }) {
37
36
  <p className="truncate font-medium">{o.name}</p>
38
37
  {Object.keys(o.config).length > 0 && (
39
38
  <p className="text-muted-foreground text-xs">
40
- agentos.json: {describe(o.config)}
39
+ Repo config: {describe(o.config)}
41
40
  </p>
42
41
  )}
43
42
  {Object.keys(o.project).length > 0 && (
@@ -49,21 +48,21 @@ function Overrides({ overrides }: { overrides: MergeOverride[] }) {
49
48
  ))}
50
49
  </ul>
51
50
  <p className="text-muted-foreground text-xs">
52
- A project&rsquo;s own setting wins over its agentos.json, which wins
53
- over these. Change a project&rsquo;s in its Project Settings.
51
+ A project&rsquo;s own setting wins over its repo config, which wins over
52
+ these. Change a project&rsquo;s in its Project Settings.
54
53
  </p>
55
54
  </div>
56
55
  );
57
56
  }
58
57
 
59
- // The global merge settings, and the approval gate for sensitive PRs.
58
+ // The global merge settings and worktree clean-up. The approval gate for
59
+ // sensitive PRs is in Approvals.
60
60
  export function GlobalMergeSection() {
61
61
  const { data, isPending, isError, error } = useGlobalMergeSettings(true);
62
62
  const update = useUpdateGlobalMergeSettings();
63
63
 
64
64
  return (
65
65
  <div className="space-y-5">
66
- <MergeApprovalToggle />
67
66
  <div className="space-y-3">
68
67
  <p className="text-muted-foreground text-sm">
69
68
  How tasks&rsquo; PRs merge and what&rsquo;s cleaned up after, for