@saadnvd1/agent-os 0.3.839 → 0.3.845

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 (38) hide show
  1. package/app/api/health/route.ts +4 -2
  2. package/components/SessionList/GroupShelves.tsx +8 -3
  3. package/components/SessionList/OrchestratorPinRow.tsx +2 -2
  4. package/components/SessionList/RowParts.tsx +39 -35
  5. package/components/SessionList/RowRename.test.ts +86 -17
  6. package/components/SessionList/RowShell.tsx +2 -5
  7. package/components/SessionList/SessionRow.tsx +0 -1
  8. package/components/Settings/SettingRow.tsx +82 -45
  9. package/components/Settings/settings-fields.tsx +11 -10
  10. package/components/Workspaces/HqFolderForm.tsx +21 -19
  11. package/components/Workspaces/McpServersForm.tsx +20 -28
  12. package/components/Workspaces/ProjectTiersForm.tsx +25 -12
  13. package/components/Workspaces/RuleEditor.tsx +86 -0
  14. package/components/Workspaces/RulesForm.test.ts +58 -31
  15. package/components/Workspaces/RulesForm.tsx +129 -58
  16. package/components/Workspaces/WorkspaceModeForm.tsx +3 -3
  17. package/components/Workspaces/WorkspaceModelsForm.tsx +40 -41
  18. package/components/Workspaces/WorkspacePanel.tsx +73 -0
  19. package/components/Workspaces/WorkspacePicker.tsx +44 -0
  20. package/components/Workspaces/WorkspaceSettings.test.ts +153 -0
  21. package/components/Workspaces/WorkspaceSettings.tsx +51 -77
  22. package/components/Workspaces/useSettingsWorkspace.ts +47 -0
  23. package/content/whats-new.json +24 -0
  24. package/lib/away/deploy.ts +34 -7
  25. package/lib/models/plan-fresh.ts +61 -0
  26. package/lib/models/plan-step.ts +40 -24
  27. package/lib/orchestrator/scope.ts +10 -0
  28. package/lib/updates/banner.ts +2 -0
  29. package/lib/updates/run.ts +9 -0
  30. package/lib/updates/view.ts +28 -6
  31. package/lib/worktree-deps/paths.ts +9 -4
  32. package/lib/worktrees.ts +3 -0
  33. package/package.json +1 -1
  34. package/scripts/autodeploy +11 -0
  35. package/scripts/autodeploy.test.ts +30 -0
  36. package/scripts/lib/update-verify.mjs +11 -0
  37. package/scripts/update-run.mjs +29 -0
  38. package/scripts/update-run.test.ts +62 -4
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useState } from "react";
4
4
  import { PickerSetting } from "@/components/Settings/PickerSetting";
5
- import { SettingSection } from "@/components/Settings/SettingRow";
5
+ import { SettingBlock } from "@/components/Settings/SettingRow";
6
6
  import { ModelSelect } from "@/components/Models/ModelSelect";
7
7
  import {
8
8
  useModelsQuery,
@@ -28,45 +28,44 @@ export function WorkspaceModelsForm({ workspaceId }: { workspaceId: string }) {
28
28
  const set = Object.keys(roles).length;
29
29
 
30
30
  return (
31
- <div className="space-y-2">
32
- <button
33
- type="button"
34
- className="text-muted-foreground hover:text-foreground h-11 text-left text-xs md:h-auto"
35
- onClick={() => setOpen((o) => !o)}
36
- aria-expanded={open}
37
- >
38
- Models: {set ? `${set} set for this workspace` : "as Settings › Models"}{" "}
39
- {open ? "▴" : "▾"}
40
- </button>
41
- {open && (
42
- <SettingSection>
43
- {WORKSPACE_ROLES.map((role) => (
44
- <PickerSetting<string | null>
45
- key={role}
46
- id={`ws-${workspaceId}-${role}`}
47
- label={ROLE_LABELS[role].label}
48
- value={roles[role] ?? null}
49
- defaultValue={null}
50
- onSave={(ref) => {
51
- const next = { ...roles };
52
- if (ref) next[role] = ref;
53
- else delete next[role];
54
- return update.mutateAsync({ roles: next });
55
- }}
56
- render={({ value, commit }) => (
57
- <ModelSelect
58
- id={`ws-${workspaceId}-${role}`}
59
- value={value}
60
- effective={models.data?.effective}
61
- inheritLabel={`As Settings (${models.data?.effective.roles[role] ?? "…"})`}
62
- disabled={!models.data}
63
- onChange={commit}
64
- />
65
- )}
66
- />
67
- ))}
68
- </SettingSection>
69
- )}
70
- </div>
31
+ <>
32
+ <SettingBlock id={`models-${workspaceId}`} label="Models">
33
+ <button
34
+ type="button"
35
+ className="text-muted-foreground hover:text-foreground flex min-h-11 w-full items-center text-left text-xs md:min-h-0"
36
+ onClick={() => setOpen((o) => !o)}
37
+ aria-expanded={open}
38
+ >
39
+ {set ? `${set} set for this workspace` : "As Settings › Models"}{" "}
40
+ {open ? "▴" : "▾"}
41
+ </button>
42
+ </SettingBlock>
43
+ {open &&
44
+ WORKSPACE_ROLES.map((role) => (
45
+ <PickerSetting<string | null>
46
+ key={role}
47
+ id={`ws-${workspaceId}-${role}`}
48
+ label={ROLE_LABELS[role].label}
49
+ value={roles[role] ?? null}
50
+ defaultValue={null}
51
+ onSave={(ref) => {
52
+ const next = { ...roles };
53
+ if (ref) next[role] = ref;
54
+ else delete next[role];
55
+ return update.mutateAsync({ roles: next });
56
+ }}
57
+ render={({ value, commit }) => (
58
+ <ModelSelect
59
+ id={`ws-${workspaceId}-${role}`}
60
+ value={value}
61
+ effective={models.data?.effective}
62
+ inheritLabel={`As Settings (${models.data?.effective.roles[role] ?? "…"})`}
63
+ disabled={!models.data}
64
+ onChange={commit}
65
+ />
66
+ )}
67
+ />
68
+ ))}
69
+ </>
71
70
  );
72
71
  }
@@ -0,0 +1,73 @@
1
+ "use client";
2
+
3
+ import { useUpdateWorkspace } from "@/data/workspaces";
4
+ import type { Workspace } from "@/lib/db";
5
+ import {
6
+ NumberSetting,
7
+ SwitchSetting,
8
+ } from "@/components/Settings/settings-fields";
9
+ import { SettingSection } from "@/components/Settings/SettingRow";
10
+ import { SETTING_DEFAULTS } from "@/lib/settings/defaults";
11
+ import { HqFolderForm } from "./HqFolderForm";
12
+ import { McpServersForm } from "./McpServersForm";
13
+ import { ProjectTiersForm } from "./ProjectTiersForm";
14
+ import { RulesForm } from "./RulesForm";
15
+ import { WorkspaceModeForm } from "./WorkspaceModeForm";
16
+ import { WorkspaceModelsForm } from "./WorkspaceModelsForm";
17
+
18
+ // One workspace's settings, in titled groups.
19
+ export function WorkspacePanel({ workspace: w }: { workspace: Workspace }) {
20
+ const update = useUpdateWorkspace();
21
+ return (
22
+ <div aria-label={`${w.name} settings`} role="group">
23
+ <SettingSection
24
+ title="Limits & mode"
25
+ help="Tasks over the limit wait in Queued and start by themselves, in order, when a running one finishes."
26
+ >
27
+ <NumberSetting
28
+ id={`task-limit-${w.id}`}
29
+ label="Tasks at a time"
30
+ value={w.max_running_tasks ?? 0}
31
+ defaultValue={0}
32
+ onSave={(n) =>
33
+ update.mutateAsync({
34
+ id: w.id,
35
+ maxRunningTasks: n === 0 ? null : n,
36
+ })
37
+ }
38
+ min={1}
39
+ integer
40
+ unit="at a time"
41
+ optional={{ off: 0, on: SETTING_DEFAULTS.taskLimitOn }}
42
+ />
43
+ <WorkspaceModeForm workspaceId={w.id} />
44
+ </SettingSection>
45
+ <SettingSection
46
+ title="Orchestrator"
47
+ help="What this workspace's orchestrator may do, which models it runs on, and where its session logs go."
48
+ >
49
+ <SwitchSetting
50
+ id={`full-access-${w.id}`}
51
+ label="Full access"
52
+ help="Its shell and file writes aren't checked. Applies when its conversation next restarts."
53
+ checked={w.orch_full_access}
54
+ confirm="Let this orchestrator act without approval? It can push, commit, edit any repository or file on this machine, merge a PR directly, and edit .claude files. Off: the deny list, write limits and merge-only-through-the-gates apply."
55
+ confirmLabel="Turn on full access"
56
+ onSave={(on) => update.mutateAsync({ id: w.id, orchFullAccess: on })}
57
+ />
58
+ <SwitchSetting
59
+ id={`wake-${w.id}`}
60
+ label="Wake tasks on CI failures and reviews"
61
+ help="A task's agent is messaged once when a check fails on its PR, and when someone reviews or comments."
62
+ checked={w.wake_tasks_on_pr}
63
+ onSave={(on) => update.mutateAsync({ id: w.id, wakeTasksOnPR: on })}
64
+ />
65
+ <WorkspaceModelsForm workspaceId={w.id} />
66
+ <HqFolderForm workspaceId={w.id} />
67
+ </SettingSection>
68
+ <RulesForm workspaceId={w.id} />
69
+ <McpServersForm workspace={w} />
70
+ <ProjectTiersForm workspaceId={w.id} />
71
+ </div>
72
+ );
73
+ }
@@ -0,0 +1,44 @@
1
+ "use client";
2
+
3
+ import {
4
+ Select,
5
+ SelectContent,
6
+ SelectItem,
7
+ SelectTrigger,
8
+ SelectValue,
9
+ } from "@/components/ui/select";
10
+ import type { Workspace } from "@/lib/db";
11
+
12
+ // Which workspace's settings the page shows.
13
+ export function WorkspacePicker({
14
+ workspaces,
15
+ value,
16
+ onChange,
17
+ }: {
18
+ workspaces: Workspace[];
19
+ value: string;
20
+ onChange: (id: string) => void;
21
+ }) {
22
+ return (
23
+ <div className="mb-4 flex items-center gap-3">
24
+ <label htmlFor="settings-workspace" className="text-sm font-medium">
25
+ Workspace
26
+ </label>
27
+ <Select value={value} onValueChange={onChange}>
28
+ <SelectTrigger
29
+ id="settings-workspace"
30
+ className="h-11 min-w-0 flex-1 text-base sm:max-w-xs md:h-9 md:text-sm"
31
+ >
32
+ <SelectValue />
33
+ </SelectTrigger>
34
+ <SelectContent>
35
+ {workspaces.map((w) => (
36
+ <SelectItem key={w.id} value={w.id}>
37
+ {w.name}
38
+ </SelectItem>
39
+ ))}
40
+ </SelectContent>
41
+ </Select>
42
+ </div>
43
+ );
44
+ }
@@ -0,0 +1,153 @@
1
+ import "@/components/Chat/composer/test-dom";
2
+ import { act, createElement } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ for (const key of Object.getOwnPropertyNames(window))
7
+ if (
8
+ /^(HTML|SVG|Event|CustomEvent|FocusEvent|DocumentFragment|Node|Element|Mutation)/.test(
9
+ key
10
+ ) &&
11
+ !(key in globalThis)
12
+ )
13
+ Object.defineProperty(globalThis, key, {
14
+ value: (window as unknown as Record<string, unknown>)[key],
15
+ configurable: true,
16
+ writable: true,
17
+ });
18
+ (
19
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
20
+ ).IS_REACT_ACT_ENVIRONMENT = true;
21
+
22
+ const ws = (id: string, name: string, limit: number | null) => ({
23
+ id,
24
+ name,
25
+ max_running_tasks: limit,
26
+ orch_full_access: false,
27
+ wake_tasks_on_pr: true,
28
+ orch_mcp_servers: [],
29
+ });
30
+ const state = vi.hoisted(() => ({
31
+ workspaces: [] as unknown[],
32
+ projects: [] as unknown[],
33
+ sidebar: null as unknown,
34
+ update: vi.fn(),
35
+ }));
36
+ vi.mock("sonner", () => ({ toast: { error: vi.fn() } }));
37
+ vi.mock("@/data/workspaces", () => ({
38
+ useWorkspacesQuery: () => ({ isPending: false, data: state.workspaces }),
39
+ useUpdateWorkspace: () => ({ mutateAsync: state.update }),
40
+ }));
41
+ vi.mock("@/data/projects", () => ({
42
+ useProjectsQuery: () => ({ data: state.projects }),
43
+ }));
44
+ vi.mock("@/hooks/useSelectedWorkspace", () => ({
45
+ useSelectedWorkspace: () => ({ workspace: state.sidebar }),
46
+ }));
47
+ vi.mock("@/components/Settings/AppWideLimits", () => ({
48
+ AppWideLimits: () => null,
49
+ }));
50
+ for (const name of [
51
+ "RulesForm",
52
+ "McpServersForm",
53
+ "HqFolderForm",
54
+ "ProjectTiersForm",
55
+ "WorkspaceModeForm",
56
+ "WorkspaceModelsForm",
57
+ ])
58
+ vi.doMock(`./${name}`, () => ({
59
+ [name]: () => null,
60
+ }));
61
+
62
+ const { WorkspaceSettings } = await import("./WorkspaceSettings");
63
+ const { SettingsQueryContext } =
64
+ await import("@/components/Settings/SettingRow");
65
+
66
+ let root: Root;
67
+ let host: HTMLDivElement;
68
+ beforeEach(() => {
69
+ localStorage.clear();
70
+ state.update.mockReset().mockResolvedValue(undefined);
71
+ state.workspaces = [ws("w1", "Alpha", null), ws("w2", "Beta", 2)];
72
+ state.projects = [
73
+ { id: "p1", name: "beta-api", workspace_id: "w2", is_uncategorized: false },
74
+ ];
75
+ state.sidebar = null;
76
+ host = document.createElement("div");
77
+ document.body.appendChild(host);
78
+ root = createRoot(host);
79
+ });
80
+ afterEach(async () => {
81
+ await act(async () => root.unmount());
82
+ host.remove();
83
+ });
84
+
85
+ const render = (query = "") =>
86
+ act(async () =>
87
+ root.render(
88
+ createElement(
89
+ SettingsQueryContext.Provider,
90
+ { value: query },
91
+ createElement(WorkspaceSettings)
92
+ )
93
+ )
94
+ );
95
+ const picker = () => host.querySelector<HTMLElement>("#settings-workspace")!;
96
+ const titles = () => [...host.querySelectorAll("h3")].map((h) => h.textContent);
97
+ const limit = () =>
98
+ host.querySelector<HTMLInputElement>("#task-limit-w1, #task-limit-w2")!;
99
+
100
+ describe("WorkspaceSettings", () => {
101
+ it("shows one workspace at a time, in titled groups", async () => {
102
+ await render();
103
+ expect(host.querySelectorAll('[role="group"]')).toHaveLength(1);
104
+ expect(picker().textContent).toBe("Alpha");
105
+ expect(titles()).toEqual(
106
+ expect.arrayContaining(["Limits & mode", "Orchestrator"])
107
+ );
108
+ expect(host.textContent).not.toContain("Running task limit:");
109
+ });
110
+
111
+ it("preselects the sidebar's workspace", async () => {
112
+ state.sidebar = state.workspaces[1];
113
+ await render();
114
+ expect(picker().textContent).toBe("Beta");
115
+ });
116
+
117
+ it("remembers the choice", async () => {
118
+ localStorage.setItem("agentOS-settings-workspace", "w2");
119
+ await render();
120
+ expect(picker().textContent).toBe("Beta");
121
+ });
122
+
123
+ it("greys the number while the limit is off, keeping its value", async () => {
124
+ await render();
125
+ expect(limit().id).toBe("task-limit-w1");
126
+ expect(limit().disabled).toBe(true);
127
+ expect(limit().value).toBe("3");
128
+ expect(host.textContent).not.toContain("Default");
129
+ });
130
+
131
+ it("enables the number when a limit is set, and hints the default", async () => {
132
+ localStorage.setItem("agentOS-settings-workspace", "w2");
133
+ await render();
134
+ expect(limit().disabled).toBe(false);
135
+ expect(limit().value).toBe("2");
136
+ expect(host.textContent).toContain("Default: off");
137
+ expect(
138
+ [...host.querySelectorAll("button")].some(
139
+ (b) => b.textContent === "Reset"
140
+ )
141
+ ).toBe(true);
142
+ });
143
+
144
+ it("a search matching only another workspace's project switches to it", async () => {
145
+ await render("beta-api");
146
+ expect(picker().textContent).toBe("Beta");
147
+ });
148
+
149
+ it("a search matching rows on every page stays on the current workspace", async () => {
150
+ await render("mcp");
151
+ expect(picker().textContent).toBe("Alpha");
152
+ });
153
+ });
@@ -1,31 +1,55 @@
1
1
  "use client";
2
2
 
3
+ import { useContext, useState } from "react";
3
4
  import { settingsUiActions } from "@/stores/settingsUi";
4
- import { useUpdateWorkspace, useWorkspacesQuery } from "@/data/workspaces";
5
- import { RulesForm } from "./RulesForm";
6
- import { McpServersForm } from "./McpServersForm";
7
- import { HqFolderForm } from "./HqFolderForm";
8
- import { ProjectTiersForm } from "./ProjectTiersForm";
9
- import { WorkspaceModeForm } from "./WorkspaceModeForm";
10
- import { WorkspaceModelsForm } from "./WorkspaceModelsForm";
5
+ import { useWorkspacesQuery } from "@/data/workspaces";
6
+ import { useProjectsQuery } from "@/data/projects";
11
7
  import { AppWideLimits } from "@/components/Settings/AppWideLimits";
12
8
  import {
13
- NumberSetting,
14
- SwitchSetting,
15
- } from "@/components/Settings/settings-fields";
16
- import { SettingSection } from "@/components/Settings/SettingRow";
17
- import { SETTING_DEFAULTS } from "@/lib/settings/defaults";
9
+ matchesQuery,
10
+ SettingsQueryContext,
11
+ } from "@/components/Settings/SettingRow";
12
+ import { WorkspacePanel } from "./WorkspacePanel";
13
+ import { WorkspacePicker } from "./WorkspacePicker";
14
+ import { useSettingsWorkspace } from "./useSettingsWorkspace";
18
15
 
19
- // Settings > Workspaces: each workspace's own settings.
16
+ // What every workspace's page has, for the search box to find.
17
+ const SHARED_ROWS =
18
+ "tasks at a time limit operating mode orchestrator full access wake ci failures reviews models hq folder session logs rules mcp servers projects production fast merge tier";
19
+
20
+ // Settings > Workspaces: one workspace's settings at a time.
20
21
  export function WorkspaceSettings() {
21
22
  const { data: workspaces, isPending, isError, error } = useWorkspacesQuery();
22
- const update = useUpdateWorkspace();
23
+ const { data: projects } = useProjectsQuery();
24
+ const query = useContext(SettingsQueryContext);
25
+ const { current, choose } = useSettingsWorkspace(workspaces ?? []);
26
+ // The search a hand-picked workspace overrides.
27
+ const [pinnedFor, setPinnedFor] = useState<string | null>(null);
28
+
29
+ // A search that matches only another workspace (its name, a project) shows
30
+ // that workspace; the shared rows are on every page, so they never move it.
31
+ const searchHit =
32
+ query.trim() &&
33
+ workspaces &&
34
+ current &&
35
+ pinnedFor !== query &&
36
+ !matchesQuery(query, SHARED_ROWS)
37
+ ? workspaces.find((w) =>
38
+ matchesQuery(
39
+ query,
40
+ w.name,
41
+ ...(projects ?? [])
42
+ .filter((p) => p.workspace_id === w.id)
43
+ .map((p) => p.name)
44
+ )
45
+ )
46
+ : undefined;
23
47
 
24
48
  if (isPending)
25
49
  return <div className="bg-muted/40 h-32 animate-pulse rounded-lg" />;
26
50
  if (isError)
27
51
  return <p className="text-destructive text-sm">{error.message}</p>;
28
- if (!workspaces.length)
52
+ if (!workspaces.length || !current)
29
53
  return (
30
54
  <p className="text-muted-foreground text-sm">
31
55
  No workspaces yet. Make one from the workspace menu at the top of the
@@ -33,74 +57,24 @@ export function WorkspaceSettings() {
33
57
  </p>
34
58
  );
35
59
 
60
+ const shown = searchHit ?? current;
36
61
  return (
37
- <div className="space-y-4">
38
- <p className="text-muted-foreground text-sm">
39
- Running task limit: tasks over it wait in the Queued list and start by
40
- themselves, in order, when a running one finishes.
41
- </p>
42
- {workspaces.map((w) => (
43
- <section
44
- key={w.id}
45
- aria-label={w.name}
46
- className="bg-muted/40 space-y-3 rounded-lg px-3 py-3"
47
- >
48
- <h3 className="truncate text-sm font-medium">{w.name}</h3>
49
- <SettingSection>
50
- <NumberSetting
51
- id={`task-limit-${w.id}`}
52
- label="Tasks at a time"
53
- help="Cap on tasks running at once. The rest wait in Queued and start in order."
54
- value={w.max_running_tasks ?? 0}
55
- defaultValue={0}
56
- onSave={(n) =>
57
- update.mutateAsync({
58
- id: w.id,
59
- maxRunningTasks: n === 0 ? null : n,
60
- })
61
- }
62
- min={1}
63
- integer
64
- unit="at a time"
65
- optional={{ off: 0, on: SETTING_DEFAULTS.taskLimitOn }}
66
- />
67
- </SettingSection>
68
- <WorkspaceModeForm workspaceId={w.id} />
69
- <RulesForm workspaceId={w.id} />
70
- <WorkspaceModelsForm workspaceId={w.id} />
71
- <McpServersForm workspace={w} />
72
- <HqFolderForm workspaceId={w.id} />
73
- <ProjectTiersForm workspaceId={w.id} />
74
- <SettingSection>
75
- <SwitchSetting
76
- id={`wake-${w.id}`}
77
- label="Wake tasks on CI failures and reviews"
78
- help="A task's agent is messaged once when a check fails on its PR, and when someone reviews or comments."
79
- checked={w.wake_tasks_on_pr}
80
- onSave={(on) =>
81
- update.mutateAsync({ id: w.id, wakeTasksOnPR: on })
82
- }
83
- />
84
- <SwitchSetting
85
- id={`full-access-${w.id}`}
86
- label="Orchestrator full access"
87
- help="Its shell and file writes aren't checked. Applies when its conversation next restarts."
88
- checked={w.orch_full_access}
89
- confirm="Let this orchestrator act without approval? It can push, commit, edit any repository or file on this machine, merge a PR directly, and edit .claude files. Off: the deny list, write limits and merge-only-through-the-gates apply."
90
- confirmLabel="Turn on full access"
91
- onSave={(on) =>
92
- update.mutateAsync({ id: w.id, orchFullAccess: on })
93
- }
94
- />
95
- </SettingSection>
96
- </section>
97
- ))}
62
+ <div>
63
+ <WorkspacePicker
64
+ workspaces={workspaces}
65
+ value={shown.id}
66
+ onChange={(id) => {
67
+ setPinnedFor(query);
68
+ choose(id);
69
+ }}
70
+ />
71
+ <WorkspacePanel key={shown.id} workspace={shown} />
98
72
  <AppWideLimits />
99
73
  <p className="text-muted-foreground text-sm">
100
74
  Fresh starts and compaction are app-wide:{" "}
101
75
  <button
102
76
  type="button"
103
- className="text-primary underline-offset-2 hover:underline"
77
+ className="text-primary -my-3 min-h-11 underline-offset-2 hover:underline"
104
78
  onClick={() => settingsUiActions.show("context")}
105
79
  >
106
80
  open Context
@@ -0,0 +1,47 @@
1
+ "use client";
2
+
3
+ import { useCallback, useSyncExternalStore } from "react";
4
+ import type { Workspace } from "@/lib/db";
5
+ import { useSelectedWorkspace } from "@/hooks/useSelectedWorkspace";
6
+
7
+ const KEY = "agentOS-settings-workspace";
8
+
9
+ const listeners = new Set<() => void>();
10
+
11
+ function subscribe(fn: () => void) {
12
+ listeners.add(fn);
13
+ window.addEventListener("storage", fn);
14
+ return () => {
15
+ listeners.delete(fn);
16
+ window.removeEventListener("storage", fn);
17
+ };
18
+ }
19
+
20
+ function read(): string | null {
21
+ try {
22
+ return localStorage.getItem(KEY);
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ // Which workspace Settings > Workspaces shows: the one picked here last,
29
+ // else the sidebar's, else the first. A remembered one since deleted falls
30
+ // through.
31
+ export function useSettingsWorkspace(workspaces: Workspace[]) {
32
+ const { workspace: sidebar } = useSelectedWorkspace();
33
+ const picked = useSyncExternalStore(subscribe, read, () => null);
34
+ const choose = useCallback((id: string) => {
35
+ try {
36
+ localStorage.setItem(KEY, id);
37
+ } catch {
38
+ // Not remembered; the choice still holds for this visit.
39
+ }
40
+ listeners.forEach((fn) => fn());
41
+ }, []);
42
+ const current =
43
+ workspaces.find((w) => w.id === picked) ??
44
+ workspaces.find((w) => w.id === sidebar?.id) ??
45
+ workspaces[0];
46
+ return { current, choose };
47
+ }
@@ -1985,6 +1985,30 @@
1985
1985
  "area": "Settings",
1986
1986
  "text": "More settings now save as you change them, and revoking a passkey or link asks inline instead of with a browser pop-up.",
1987
1987
  "url": "https://github.com/saadnvd1/agent-os/pull/563"
1988
+ },
1989
+ {
1990
+ "id": 564,
1991
+ "date": "2026-11-02",
1992
+ "kind": "fixed",
1993
+ "area": "Updates",
1994
+ "text": "Updating AgentOS no longer rolls back a good update when the machine's automatic deploy restarts the server at the same moment.",
1995
+ "url": "https://github.com/saadnvd1/agent-os/pull/564"
1996
+ },
1997
+ {
1998
+ "id": 565,
1999
+ "date": "2026-11-02",
2000
+ "kind": "improved",
2001
+ "area": "Settings",
2002
+ "text": "The Workspaces settings page now shows one workspace at a time, in clear groups, with rules you can read in full and edit in place.",
2003
+ "url": "https://github.com/saadnvd1/agent-os/pull/565"
2004
+ },
2005
+ {
2006
+ "id": 568,
2007
+ "date": "2026-11-02",
2008
+ "kind": "fixed",
2009
+ "area": "Sidebar",
2010
+ "text": "Renaming a session for the first time no longer closes the name box before you can type.",
2011
+ "url": "https://github.com/saadnvd1/agent-os/pull/568"
1988
2012
  }
1989
2013
  ]
1990
2014
  }
@@ -33,6 +33,14 @@ export const HEALTH_GRACE_MS = 3 * 60 * 1000;
33
33
  export const GAP_MARGIN_MS = 10 * 60 * 1000;
34
34
  const DEFAULT_GAP_S = 3600;
35
35
 
36
+ type HealthAnswer = {
37
+ ok: boolean;
38
+ why?: string;
39
+ startedAt?: number;
40
+ // The commit the running server was built from, when it says.
41
+ build?: string;
42
+ };
43
+
36
44
  export interface DeployDeps {
37
45
  // When the deploy last restarted the server, in ms; null when unknown.
38
46
  restartedAt: (file: string) => number | null;
@@ -43,9 +51,7 @@ export interface DeployDeps {
43
51
  contains: (repo: string, tip: string, sha: string) => Promise<boolean>;
44
52
  // startedAt, when the server says when it started (AgentOS's
45
53
  // /api/health does), so a deploy that never restarted it is caught.
46
- health: (
47
- url: string
48
- ) => Promise<{ ok: boolean; why?: string; startedAt?: number }>;
54
+ health: (url: string) => Promise<HealthAnswer>;
49
55
  // When GitHub made the merge commit, in ms (0 when unknown): the time a
50
56
  // restart has to come after. Not when AgentOS noticed the merge, which
51
57
  // can be after a restart that already took it.
@@ -78,18 +84,25 @@ async function contains(
78
84
  return ancestor();
79
85
  }
80
86
 
81
- async function health(
82
- url: string
83
- ): Promise<{ ok: boolean; why?: string; startedAt?: number }> {
87
+ async function health(url: string): Promise<HealthAnswer> {
84
88
  try {
85
89
  const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
86
90
  if (!res.ok) return { ok: false, why: `HTTP ${res.status}` };
87
91
  const body = (await res.json().catch(() => null)) as {
88
92
  startedAt?: unknown;
93
+ build?: unknown;
89
94
  } | null;
90
95
  const startedAt =
91
96
  typeof body?.startedAt === "string" ? Date.parse(body.startedAt) : NaN;
92
- return Number.isFinite(startedAt) ? { ok: true, startedAt } : { ok: true };
97
+ const build =
98
+ typeof body?.build === "string" && SHA.test(body.build)
99
+ ? body.build
100
+ : undefined;
101
+ return {
102
+ ok: true,
103
+ ...(Number.isFinite(startedAt) ? { startedAt } : {}),
104
+ ...(build ? { build } : {}),
105
+ };
93
106
  } catch (error) {
94
107
  return {
95
108
  ok: false,
@@ -161,6 +174,20 @@ async function judge(
161
174
  if (!config.health) return { state: "ok", detail: `${short} deployed` };
162
175
  const url = healthUrl(config.health);
163
176
  const answer = await deps.health(url);
177
+ // autodeploy's marker can name a build that isn't running (a rolled-back
178
+ // update); what the server says it runs decides.
179
+ if (
180
+ answer.ok &&
181
+ answer.build &&
182
+ !(await deps.contains(input.repo, answer.build, mergeSha))
183
+ ) {
184
+ if (input.now - deployed.at < HEALTH_GRACE_MS)
185
+ return { state: "pending" };
186
+ return {
187
+ state: "failed",
188
+ detail: `not deployed: the server is running ${answer.build.slice(0, 7)}, which doesn't have ${short}`,
189
+ };
190
+ }
164
191
  const merged =
165
192
  answer.ok && answer.startedAt !== undefined
166
193
  ? await deps.committedAt(input.repo, mergeSha)