@saadnvd1/agent-os 0.3.845 → 0.3.848

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 (68) hide show
  1. package/app/api/approvals/route.ts +5 -1
  2. package/app/api/health/config/route.ts +2 -0
  3. package/app/globals.css +6 -0
  4. package/components/Audits/AuditsPanel.tsx +42 -35
  5. package/components/Chat/quote-selection.test.ts +6 -0
  6. package/components/Chat/quote-selection.ts +13 -10
  7. package/components/Devices/ConnectCard.tsx +52 -56
  8. package/components/Devices/DeviceGroupRow.tsx +65 -0
  9. package/components/Devices/DeviceRow.tsx +2 -2
  10. package/components/Devices/DevicesPanel.tsx +63 -49
  11. package/components/Devices/NetworkSection.tsx +29 -26
  12. package/components/Devices/TailscaleCard.tsx +9 -10
  13. package/components/Devices/group.test.ts +66 -0
  14. package/components/Devices/group.ts +67 -0
  15. package/components/Health/HealthPanel.tsx +5 -81
  16. package/components/Health/MachineChecks.tsx +20 -0
  17. package/components/Health/SweepRuns.tsx +46 -51
  18. package/components/Health/Thresholds.tsx +50 -0
  19. package/components/Merge/GlobalMergeSection.tsx +5 -6
  20. package/components/Merge/MergeApprovalToggle.tsx +20 -14
  21. package/components/Merge/MergeSettingsFields.tsx +0 -2
  22. package/components/Merge/WorktreeSweepSection.tsx +15 -34
  23. package/components/Models/AgentRoleCard.tsx +7 -7
  24. package/components/Models/ModelSelect.tsx +14 -3
  25. package/components/Models/ModelsSection.tsx +42 -37
  26. package/components/Models/ProviderCard.tsx +1 -1
  27. package/components/Models/RoleModelSelect.tsx +15 -3
  28. package/components/Models/SubagentModels.tsx +60 -45
  29. package/components/PhoneNotify/PhoneNotifyForm.tsx +6 -13
  30. package/components/Recipes/RecipesPanel.tsx +20 -9
  31. package/components/Recipes/RunHistory.tsx +1 -1
  32. package/components/Schedules/SchedulesPanel.tsx +12 -10
  33. package/components/Settings/AppWideLimits.tsx +2 -2
  34. package/components/Settings/{NoApprovalsSection.test.ts → ApprovalsSection.test.ts} +61 -22
  35. package/components/Settings/ApprovalsSection.tsx +93 -0
  36. package/components/Settings/ExperimentsSection.test.ts +20 -4
  37. package/components/Settings/ExperimentsSection.tsx +54 -34
  38. package/components/Settings/GeneralSection.tsx +39 -0
  39. package/components/Settings/ListSection.test.ts +85 -0
  40. package/components/Settings/ListSection.tsx +132 -0
  41. package/components/Settings/NotificationsSection.tsx +31 -29
  42. package/components/Settings/PickerSetting.tsx +5 -1
  43. package/components/Settings/SettingRow.tsx +13 -8
  44. package/components/Settings/SettingsDialog.test.ts +31 -4
  45. package/components/Settings/SettingsDialog.tsx +22 -17
  46. package/components/Settings/ValueLabel.tsx +31 -0
  47. package/components/Settings/settings-fields.test.ts +81 -2
  48. package/components/Settings/settings-fields.tsx +27 -27
  49. package/components/Shares/SharedLinksPanel.tsx +11 -14
  50. package/components/Updates/AutoUpdates.tsx +12 -8
  51. package/components/Updates/UpdatesSection.tsx +18 -17
  52. package/components/Workspaces/WorkspaceSettings.test.ts +5 -5
  53. package/components/Workspaces/WorkspaceSettings.tsx +0 -2
  54. package/components/ui/switch.tsx +1 -1
  55. package/content/whats-new.json +16 -0
  56. package/data/approvals/index.ts +2 -2
  57. package/data/health/index.ts +3 -0
  58. package/hooks/useAutoSave.ts +18 -5
  59. package/hooks/useSettingsUrl.ts +6 -3
  60. package/lib/approvals/describe.ts +64 -0
  61. package/lib/health/config.ts +70 -17
  62. package/lib/models/providers.ts +1 -1
  63. package/lib/recipes/settle.ts +22 -0
  64. package/lib/settings-url.ts +14 -2
  65. package/package.json +1 -1
  66. package/components/Settings/NoApprovalsSection.tsx +0 -103
  67. package/components/Settings/SettingToggle.tsx +0 -36
  68. package/components/Settings/SetupSection.tsx +0 -46
@@ -2,7 +2,8 @@
2
2
 
3
3
  import { toast } from "sonner";
4
4
  import { CheckCircle2, CircleAlert, RotateCcw } from "lucide-react";
5
- import { SettingToggle } from "@/components/Settings/SettingToggle";
5
+ import { SwitchSetting } from "@/components/Settings/settings-fields";
6
+ import { SettingSection } from "@/components/Settings/SettingRow";
6
7
  import {
7
8
  Select,
8
9
  SelectContent,
@@ -80,13 +81,16 @@ export function AutoUpdates({ auto }: { auto: AutoUpdateView }) {
80
81
  const onError = (e: Error) => toast.error(e.message);
81
82
  return (
82
83
  <div className="space-y-3">
83
- <SettingToggle
84
- title="Install updates automatically"
85
- detail="Once everything has been idle for a while. Terminals and chats keep running through the restart, and a version that doesn't start cleanly is rolled back."
86
- checked={auto.enabled}
87
- locked={auto.locked}
88
- onChange={(on) => save.mutate({ auto: on }, { onError })}
89
- />
84
+ <SettingSection>
85
+ <SwitchSetting
86
+ id="auto-update"
87
+ label="Install updates automatically"
88
+ help="Once everything has been idle for a while. Terminals and chats keep running through the restart, and a version that doesn't start cleanly is rolled back."
89
+ checked={auto.enabled}
90
+ locked={auto.locked}
91
+ onSave={(on) => save.mutateAsync({ auto: on })}
92
+ />
93
+ </SettingSection>
90
94
  {auto.enabled && (
91
95
  <div className="flex flex-wrap items-center justify-between gap-2 px-3">
92
96
  <label htmlFor="auto-idle" className="text-sm">
@@ -3,7 +3,8 @@
3
3
  import { toast } from "sonner";
4
4
  import { Loader2 } from "lucide-react";
5
5
  import { Button } from "@/components/ui/button";
6
- import { SettingToggle } from "@/components/Settings/SettingToggle";
6
+ import { SwitchSetting } from "@/components/Settings/settings-fields";
7
+ import { SettingSection } from "@/components/Settings/SettingRow";
7
8
  import { useSetTipsOff, useTips } from "@/data/tips";
8
9
  import {
9
10
  useCheckForUpdates,
@@ -141,16 +142,17 @@ export function UpdatesSection() {
141
142
 
142
143
  {view.auto && <AutoUpdates auto={view.auto} />}
143
144
 
144
- <SettingToggle
145
- title="Show update banner"
146
- detail="Tell everyone using this AgentOS when a newer version is out, and when it updated itself."
147
- checked={view.bannerEnabled}
148
- locked={view.bannerLocked}
149
- onChange={(on) =>
150
- banner.mutate(on, { onError: (e) => toast.error(e.message) })
151
- }
152
- />
153
- <TipsToggle />
145
+ <SettingSection>
146
+ <SwitchSetting
147
+ id="update-banner"
148
+ label="Show update banner"
149
+ help="Tell everyone using this AgentOS when a newer version is out, and when it updated itself."
150
+ checked={view.bannerEnabled}
151
+ locked={view.bannerLocked}
152
+ onSave={(on) => banner.mutateAsync(on)}
153
+ />
154
+ <TipsToggle />
155
+ </SettingSection>
154
156
  </div>
155
157
  );
156
158
  }
@@ -159,13 +161,12 @@ function TipsToggle() {
159
161
  const { data } = useTips();
160
162
  const setOff = useSetTipsOff();
161
163
  return (
162
- <SettingToggle
163
- title="Show Tip of the Day"
164
- detail="A short tip about something AgentOS can do, behind the lightbulb in the sidebar. It never opens on its own."
164
+ <SwitchSetting
165
+ id="tips"
166
+ label="Show Tip of the Day"
167
+ help="A short tip about something AgentOS can do, behind the lightbulb in the sidebar. It never opens on its own."
165
168
  checked={!!data && !data.off}
166
- onChange={(on) =>
167
- setOff.mutate(!on, { onError: (e) => toast.error(e.message) })
168
- }
169
+ onSave={(on) => setOff.mutateAsync(!on)}
169
170
  />
170
171
  );
171
172
  }
@@ -124,19 +124,19 @@ describe("WorkspaceSettings", () => {
124
124
  await render();
125
125
  expect(limit().id).toBe("task-limit-w1");
126
126
  expect(limit().disabled).toBe(true);
127
- expect(limit().value).toBe("3");
128
- expect(host.textContent).not.toContain("Default");
127
+ expect(limit().value).toBe("");
128
+ expect(limit().placeholder).toBe("Off");
129
+ expect(host.textContent).not.toContain("Reset");
129
130
  });
130
131
 
131
- it("enables the number when a limit is set, and hints the default", async () => {
132
+ it("enables the number when a limit is set, and offers Reset to off", async () => {
132
133
  localStorage.setItem("agentOS-settings-workspace", "w2");
133
134
  await render();
134
135
  expect(limit().disabled).toBe(false);
135
136
  expect(limit().value).toBe("2");
136
- expect(host.textContent).toContain("Default: off");
137
137
  expect(
138
138
  [...host.querySelectorAll("button")].some(
139
- (b) => b.textContent === "Reset"
139
+ (b) => b.textContent === "Reset to off"
140
140
  )
141
141
  ).toBe(true);
142
142
  });
@@ -4,7 +4,6 @@ import { useContext, useState } from "react";
4
4
  import { settingsUiActions } from "@/stores/settingsUi";
5
5
  import { useWorkspacesQuery } from "@/data/workspaces";
6
6
  import { useProjectsQuery } from "@/data/projects";
7
- import { AppWideLimits } from "@/components/Settings/AppWideLimits";
8
7
  import {
9
8
  matchesQuery,
10
9
  SettingsQueryContext,
@@ -69,7 +68,6 @@ export function WorkspaceSettings() {
69
68
  }}
70
69
  />
71
70
  <WorkspacePanel key={shown.id} workspace={shown} />
72
- <AppWideLimits />
73
71
  <p className="text-muted-foreground text-sm">
74
72
  Fresh starts and compaction are app-wide:{" "}
75
73
  <button
@@ -13,7 +13,7 @@ function Switch({
13
13
  <SwitchPrimitive.Root
14
14
  data-slot="switch"
15
15
  className={cn(
16
- "peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
16
+ "peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted-foreground/35 data-[state=unchecked]:border-foreground/15 focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-muted-foreground/40 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
17
17
  className
18
18
  )}
19
19
  {...props}
@@ -2009,6 +2009,22 @@
2009
2009
  "area": "Sidebar",
2010
2010
  "text": "Renaming a session for the first time no longer closes the name box before you can type.",
2011
2011
  "url": "https://github.com/saadnvd1/agent-os/pull/568"
2012
+ },
2013
+ {
2014
+ "id": 569,
2015
+ "date": "2026-11-02",
2016
+ "kind": "fixed",
2017
+ "area": "Chat",
2018
+ "text": "Pressing Enter with a selection that ends on a reply's button row no longer quotes it.",
2019
+ "url": "https://github.com/saadnvd1/agent-os/pull/569"
2020
+ },
2021
+ {
2022
+ "id": 570,
2023
+ "date": "2026-11-02",
2024
+ "kind": "improved",
2025
+ "area": "Settings",
2026
+ "text": "Settings is tidier: clearer switches, shorter lists, one Approvals page, and a General page for your name and alerts.",
2027
+ "url": "https://github.com/saadnvd1/agent-os/pull/570"
2012
2028
  }
2013
2029
  ]
2014
2030
  }
@@ -1,11 +1,11 @@
1
1
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
- import type { AuditRow } from "@/lib/approvals/no-approvals";
2
+ import type { ApprovalLine } from "@/lib/approvals/describe";
3
3
  import { provePresence } from "../presence";
4
4
  import { usePollWhenOffline } from "../push/connection";
5
5
 
6
6
  export interface ApprovalsMode {
7
7
  on: boolean;
8
- audit: AuditRow[];
8
+ audit: ApprovalLine[];
9
9
  }
10
10
 
11
11
  export const approvalsKeys = { all: ["approvals"] as const };
@@ -91,10 +91,13 @@ export function useDeleteNote() {
91
91
  });
92
92
  }
93
93
 
94
+ import type { HealthMeta } from "@/lib/health/config";
95
+
94
96
  export interface ConfigResponse {
95
97
  config: Record<string, number>;
96
98
  defaults: Record<string, number>;
97
99
  labels: Record<string, string>;
100
+ meta: Record<string, HealthMeta>;
98
101
  }
99
102
 
100
103
  export function useHealthConfig() {
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useCallback, useEffect, useRef, useState } from "react";
4
4
  import { toast } from "sonner";
5
+ import { settingsUi } from "@/stores/settingsUi";
5
6
 
6
7
  export type SaveStatus = "idle" | "saving" | "saved" | "undo";
7
8
 
@@ -13,6 +14,9 @@ export interface AutoSave<T> {
13
14
  // rejected save drops the in-flight value, so this is the saved one again.
14
15
  value: T;
15
16
  status: SaveStatus;
17
+ // Why the last save was rejected; cleared by the next commit. Shown on the
18
+ // row while Settings is open (a toast would sit over the dialog).
19
+ error: string | null;
16
20
  // Commit a new value. `undoable` keeps the previous one for Undo (large or
17
21
  // destructive changes). Resolves false when the server rejected it.
18
22
  commit: (next: T, opts?: { undoable?: boolean }) => Promise<boolean>;
@@ -27,7 +31,7 @@ export function useAutoSave<T>({
27
31
  saved,
28
32
  save,
29
33
  equals = Object.is,
30
- notify = (message) => toast.error(message),
34
+ notify,
31
35
  }: {
32
36
  saved: T;
33
37
  save: (next: T) => Promise<unknown>;
@@ -36,6 +40,7 @@ export function useAutoSave<T>({
36
40
  }): AutoSave<T> {
37
41
  const [pending, setPending] = useState<{ value: T } | null>(null);
38
42
  const [status, setStatus] = useState<SaveStatus>("idle");
43
+ const [error, setError] = useState<string | null>(null);
39
44
  const previous = useRef<{ value: T } | null>(null);
40
45
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
41
46
  const seq = useRef(0);
@@ -56,6 +61,7 @@ export function useAutoSave<T>({
56
61
  const mine = ++seq.current;
57
62
  clearTimeout(timer.current);
58
63
  setPending({ value: next });
64
+ setError(null);
59
65
  setStatus("saving");
60
66
  try {
61
67
  await latest.current.save(next);
@@ -64,9 +70,10 @@ export function useAutoSave<T>({
64
70
  setPending(null);
65
71
  setStatus("idle");
66
72
  }
67
- latest.current.notify(
68
- err instanceof Error ? err.message : "Couldn't save"
69
- );
73
+ const message = err instanceof Error ? err.message : "Couldn't save";
74
+ if (latest.current.notify) latest.current.notify(message);
75
+ else if (settingsUi.open) setError(message);
76
+ else toast.error(message);
70
77
  return false;
71
78
  }
72
79
  if (mine !== seq.current) return true;
@@ -94,5 +101,11 @@ export function useAutoSave<T>({
94
101
  return run(back.value, false, latest.current.saved);
95
102
  }, [run]);
96
103
 
97
- return { value: pending ? pending.value : saved, status, commit, undo };
104
+ return {
105
+ value: pending ? pending.value : saved,
106
+ status,
107
+ error,
108
+ commit,
109
+ undo,
110
+ };
98
111
  }
@@ -19,8 +19,7 @@ function fromAddress() {
19
19
  */
20
20
  export function useSettingsUrl() {
21
21
  useEffect(() => {
22
- fromAddress();
23
- const unsubscribe = subscribe(settingsUi, () => {
22
+ const toAddress = () => {
24
23
  const href = window.location.href;
25
24
  const next = hrefWithSettings(href, {
26
25
  open: settingsUi.open,
@@ -29,7 +28,11 @@ export function useSettingsUrl() {
29
28
  const url = new URL(href);
30
29
  if (next !== url.pathname + url.search + url.hash)
31
30
  window.history.replaceState(window.history.state, "", next);
32
- });
31
+ };
32
+ fromAddress();
33
+ // An old link (?settings=setup) is rewritten to where it landed.
34
+ toAddress();
35
+ const unsubscribe = subscribe(settingsUi, toAddress);
33
36
  window.addEventListener("popstate", fromAddress);
34
37
  return () => {
35
38
  unsubscribe();
@@ -0,0 +1,64 @@
1
+ // What an auto-approval reads as to a person: a sentence, with the command
2
+ // or other raw text behind a "details" toggle. The audit table keeps the raw
3
+ // subject; this is only how it is shown.
4
+
5
+ import type { AuditRow } from "./no-approvals";
6
+
7
+ export interface ApprovalLine {
8
+ id: number;
9
+ at: string;
10
+ // The source in words ("an ask", "a chat").
11
+ where: string;
12
+ summary: string;
13
+ // Raw text (a shell command, a path) for the details toggle.
14
+ details: string | null;
15
+ }
16
+
17
+ const WHERE: Record<AuditRow["source"], string> = {
18
+ ask: "An ask",
19
+ brake: "A brake",
20
+ plan: "A plan",
21
+ chat: "A chat",
22
+ terminal: "A terminal",
23
+ "merge-approval": "A merge",
24
+ mode: "The mode",
25
+ };
26
+
27
+ const READ = new Set(["Read", "Glob", "Grep", "LS"]);
28
+ const CHANGE = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
29
+
30
+ const SHELL_START = /^(bash|run|shell|execute|command)\b[:\s]/i;
31
+ const SHELL_WORDS =
32
+ /(^|\s)(cd|ssh|scp|git|npm|npx|pnpm|rm|curl|sudo|cat|ls|sed|grep|kill|tmux|docker|kubectl|python3?|node)\s/;
33
+ const SHELL_CHARS = /&&|\|\||[|;`]|\$\(|\s\/[\w.-]+\/|~\//;
34
+
35
+ export const looksLikeCommand = (text: string) =>
36
+ SHELL_START.test(text) || SHELL_WORDS.test(text) || SHELL_CHARS.test(text);
37
+
38
+ export function describeApproval(row: AuditRow): ApprovalLine {
39
+ const base = {
40
+ id: row.id,
41
+ at: row.at,
42
+ where: WHERE[row.source] ?? row.source,
43
+ };
44
+ const subject = row.subject.trim();
45
+ if (row.source === "terminal") {
46
+ const tool = subject.replace(/ in a terminal session$/, "");
47
+ const summary =
48
+ tool === "Bash"
49
+ ? "Ran a command"
50
+ : CHANGE.has(tool)
51
+ ? "Changed a file"
52
+ : READ.has(tool)
53
+ ? "Read files"
54
+ : `Used ${tool}`;
55
+ return { ...base, summary, details: null };
56
+ }
57
+ if (row.source === "chat" && looksLikeCommand(subject))
58
+ return {
59
+ ...base,
60
+ summary: "Ran a command",
61
+ details: subject.replace(SHELL_START, "").trim() || subject,
62
+ };
63
+ return { ...base, summary: subject, details: null };
64
+ }
@@ -30,25 +30,78 @@ export const HEALTH_DEFAULTS = {
30
30
  export type HealthKey = keyof typeof HEALTH_DEFAULTS;
31
31
  export type HealthConfig = Record<HealthKey, number>;
32
32
 
33
- export const HEALTH_LABELS: Record<HealthKey, string> = {
34
- loadRedMinutes: "Machine load red for (minutes)",
35
- queueHeldMinutes: "Queued task held for (minutes)",
36
- bgQuietMinutes: "Background job quiet for (minutes)",
37
- portWaitMinutes: "Waiting on a port someone else holds for (minutes)",
38
- pieceDupOverlap: "Plan piece matches merged work (0 to 1 overlap)",
39
- restartsMax: "Server restarts allowed per window",
40
- restartsWindowMinutes: "Restart window (minutes)",
41
- ciSlowFallbackMinutes: "CI pending, when no history yet (minutes)",
42
- armedStuckMinutes: "PR armed without merging for (minutes)",
43
- serverErrorsMin: "Server errors per 10 minutes, at least",
44
- serverErrorsFactor: "Server errors, times the usual rate",
45
- envBurstSessions: "Environment failure: sessions in the window to alert",
46
- envBurstHours: "Environment failure window (hours)",
47
- envQuietDays: "Environment failure back after quiet for (days)",
48
- wakeUsageCeilingPct: "Stop waking watchers above usage (%)",
49
- watchersOn: "Watchers on (1) or off (0)",
33
+ export interface HealthMeta {
34
+ label: string;
35
+ unit?: string;
36
+ min: number;
37
+ max?: number;
38
+ integer?: boolean;
39
+ // A 1/0 value shown as a switch.
40
+ switch?: boolean;
41
+ }
42
+
43
+ // How each threshold reads in Settings: the server owns it, so a new
44
+ // threshold needs no client change.
45
+ export const HEALTH_META: Record<HealthKey, HealthMeta> = {
46
+ loadRedMinutes: { label: "Machine load red for", unit: "min", min: 1 },
47
+ queueHeldMinutes: { label: "Queued task held for", unit: "min", min: 1 },
48
+ bgQuietMinutes: { label: "Background job quiet for", unit: "min", min: 1 },
49
+ portWaitMinutes: {
50
+ label: "Waiting on a port someone else holds for",
51
+ unit: "min",
52
+ min: 1,
53
+ },
54
+ pieceDupOverlap: {
55
+ label: "Plan piece matches merged work",
56
+ min: 0,
57
+ max: 1,
58
+ integer: false,
59
+ },
60
+ restartsMax: { label: "Server restarts allowed per window", min: 1 },
61
+ restartsWindowMinutes: { label: "Restart window", unit: "min", min: 1 },
62
+ ciSlowFallbackMinutes: {
63
+ label: "CI pending, when no history yet",
64
+ unit: "min",
65
+ min: 1,
66
+ },
67
+ armedStuckMinutes: {
68
+ label: "PR armed without merging for",
69
+ unit: "min",
70
+ min: 1,
71
+ },
72
+ serverErrorsMin: {
73
+ label: "Server errors per 10 minutes, at least",
74
+ min: 1,
75
+ },
76
+ serverErrorsFactor: {
77
+ label: "Server errors, times the usual rate",
78
+ unit: "×",
79
+ min: 1,
80
+ integer: false,
81
+ },
82
+ envBurstSessions: {
83
+ label: "Environment failure: sessions in the window to alert",
84
+ min: 1,
85
+ },
86
+ envBurstHours: { label: "Environment failure window", unit: "hours", min: 1 },
87
+ envQuietDays: {
88
+ label: "Environment failure back after quiet for",
89
+ unit: "days",
90
+ min: 1,
91
+ },
92
+ wakeUsageCeilingPct: {
93
+ label: "Stop waking watchers above usage",
94
+ unit: "%",
95
+ min: 1,
96
+ max: 100,
97
+ },
98
+ watchersOn: { label: "Watchers", min: 0, max: 1, switch: true },
50
99
  };
51
100
 
101
+ export const HEALTH_LABELS = Object.fromEntries(
102
+ Object.entries(HEALTH_META).map(([k, m]) => [k, m.label])
103
+ ) as Record<HealthKey, string>;
104
+
52
105
  const settingKey = (k: HealthKey) => `health.${k}`;
53
106
 
54
107
  export function healthConfig(
@@ -190,5 +190,5 @@ export function describeSecretRef(ref: SecretRef | undefined): string {
190
190
  return `tokenvault ${ref.project}${ref.desc ? ` "${ref.desc}"` : ""}`;
191
191
  if (ref.kind === "keychain")
192
192
  return `Keychain ${ref.service}${ref.account ? ` (${ref.account})` : ""}`;
193
- return `$${ref.name}`;
193
+ return "an environment variable";
194
194
  }
@@ -24,6 +24,8 @@ import {
24
24
  import { getRecipe, toRun } from "./store";
25
25
  import type { Recipe, RecipeRun, RunStatus } from "./types";
26
26
 
27
+ // A live run with nothing running behind it for this long is given up on.
28
+ export const LOST_RUN_MS = 24 * 60 * 60_000;
27
29
  // A session run that stops without a RESULT line settles from the model's
28
30
  // reading after it has been idle this long.
29
31
  export const IDLE_SETTLE_MS = 30 * 60_000;
@@ -379,6 +381,26 @@ async function settleOne(
379
381
  session,
380
382
  now
381
383
  );
384
+ // Nothing live behind it for a day (a terminal session, an archived one, a
385
+ // worker that's gone): it isn't still running, so it must not say so.
386
+ if (
387
+ Number.isFinite(startedMs) &&
388
+ now - startedMs > LOST_RUN_MS &&
389
+ state !== "running" &&
390
+ state !== "waiting"
391
+ )
392
+ return void finish(
393
+ run,
394
+ {
395
+ status: "failed",
396
+ result: line,
397
+ source: "state",
398
+ error: "Lost track of it",
399
+ summary: "Lost track of it",
400
+ },
401
+ session,
402
+ now
403
+ );
382
404
  const idle =
383
405
  state === "idle" || session.archived_at != null || state === null;
384
406
  if (idle && session.view === "chat") {
@@ -5,6 +5,7 @@
5
5
  export const SETTINGS_PARAM = "settings";
6
6
 
7
7
  export const SETTINGS_SECTIONS = [
8
+ "general",
8
9
  "merging",
9
10
  "approvals",
10
11
  "workspaces",
@@ -18,13 +19,19 @@ export const SETTINGS_SECTIONS = [
18
19
  "schedules",
19
20
  "audits",
20
21
  "health",
21
- "setup",
22
22
  "updates",
23
23
  "experiments",
24
24
  ] as const;
25
25
 
26
26
  export type SettingsSection = (typeof SETTINGS_SECTIONS)[number];
27
27
 
28
+ // Sections that were folded into another: an old link still lands in the
29
+ // right place.
30
+ export const SECTION_REDIRECTS: Record<string, SettingsSection> = {
31
+ setup: "health",
32
+ "no-approvals": "approvals",
33
+ };
34
+
28
35
  export interface SettingsLocation {
29
36
  open: boolean;
30
37
  // null: the list on a phone, the first section on a wider screen.
@@ -40,7 +47,12 @@ export const isSettingsSection = (v: unknown): v is SettingsSection =>
40
47
  export function settingsFromHref(href: string): SettingsLocation {
41
48
  const value = new URL(href, BASE).searchParams.get(SETTINGS_PARAM);
42
49
  if (value === null) return { open: false, section: null };
43
- return { open: true, section: isSettingsSection(value) ? value : null };
50
+ const section = isSettingsSection(value)
51
+ ? value
52
+ : value !== null && Object.hasOwn(SECTION_REDIRECTS, value)
53
+ ? SECTION_REDIRECTS[value]
54
+ : null;
55
+ return { open: true, section };
44
56
  }
45
57
 
46
58
  /** The same address with Settings set (or removed), as path+query+hash. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.845",
3
+ "version": "0.3.848",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -1,103 +0,0 @@
1
- "use client";
2
-
3
- import { useState } from "react";
4
- import { Button } from "@/components/ui/button";
5
- import { SettingToggle } from "@/components/Settings/SettingToggle";
6
- import { useApprovalsModeQuery, useSetApprovalsMode } from "@/data/approvals";
7
-
8
- // What it switches off, said plainly before it can be switched on.
9
- export const NO_APPROVALS_WARNING = [
10
- "Every ask is approved the moment it is raised, hard lines included: public posts and messages, money, irreversible or destructive changes, credentials.",
11
- "A PR stuck failing its gates, with no CI, or too big to review still merges.",
12
- "Brakes stop holding new starts. Plan-only tasks still wait for approve_plan.",
13
- "Every agent AgentOS starts runs with its permission prompts off, and prompts already waiting are allowed.",
14
- ];
15
-
16
- export function NoApprovalsSection() {
17
- const { data, isPending, isError, error } = useApprovalsModeQuery();
18
- const set = useSetApprovalsMode();
19
- const [confirming, setConfirming] = useState(false);
20
-
21
- if (isPending)
22
- return <div className="bg-muted/40 h-16 animate-pulse rounded-lg" />;
23
- if (isError)
24
- return <p className="text-destructive text-sm">{error.message}</p>;
25
-
26
- return (
27
- <div className="space-y-3">
28
- <SettingToggle
29
- title="No approvals"
30
- detail="Shuts off every approval AgentOS would otherwise wait on, and every agent's permission prompts. Off by default."
31
- checked={data.on}
32
- onChange={(on) => {
33
- if (on) setConfirming(true);
34
- else set.mutate(false);
35
- }}
36
- />
37
- {confirming && !data.on && (
38
- <div
39
- role="alertdialog"
40
- aria-label="Turn on No approvals"
41
- className="rounded-lg border border-red-500/20 bg-red-500/10 p-3"
42
- >
43
- <p className="mb-2 text-sm font-medium text-red-400">
44
- Turn on No approvals?
45
- </p>
46
- <ul className="text-muted-foreground mb-3 list-disc space-y-1 pl-5 text-xs">
47
- {NO_APPROVALS_WARNING.map((line) => (
48
- <li key={line}>{line}</li>
49
- ))}
50
- <li>
51
- Terminals already running in an agent other than Claude keep
52
- asking until they are restarted. Each approval made for you is
53
- logged below.
54
- </li>
55
- </ul>
56
- <div className="flex gap-2">
57
- <Button
58
- size="sm"
59
- variant="destructive"
60
- disabled={set.isPending}
61
- onClick={() =>
62
- set.mutate(true, { onSuccess: () => setConfirming(false) })
63
- }
64
- >
65
- {set.isPending ? "Turning on..." : "Turn on No approvals"}
66
- </Button>
67
- <Button
68
- size="sm"
69
- variant="ghost"
70
- disabled={set.isPending}
71
- onClick={() => setConfirming(false)}
72
- >
73
- Cancel
74
- </Button>
75
- </div>
76
- </div>
77
- )}
78
- {set.error && (
79
- <p className="text-destructive text-xs">{set.error.message}</p>
80
- )}
81
- <div>
82
- <p className="mb-1 text-sm font-medium">Approved for you</p>
83
- {data.audit.length === 0 ? (
84
- <p className="text-muted-foreground text-xs">Nothing yet.</p>
85
- ) : (
86
- <ul className="space-y-1">
87
- {data.audit.map((row) => (
88
- <li key={row.id} className="text-xs">
89
- <span className="text-muted-foreground">
90
- {row.at} · {row.source} ·{" "}
91
- </span>
92
- {row.subject}
93
- {row.detail ? (
94
- <span className="text-muted-foreground"> ({row.detail})</span>
95
- ) : null}
96
- </li>
97
- ))}
98
- </ul>
99
- )}
100
- </div>
101
- </div>
102
- );
103
- }