@saadnvd1/agent-os 0.3.551 → 0.3.553

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 (34) hide show
  1. package/README.md +1 -0
  2. package/app/api/approvals/hook/route.ts +34 -0
  3. package/app/api/approvals/route.ts +52 -0
  4. package/app/api/devices/network/route.ts +2 -2
  5. package/app/api/sessions/[id]/summarize/route.ts +2 -1
  6. package/app/page.tsx +11 -2
  7. package/app/pair/page.tsx +3 -2
  8. package/components/Approvals/NoApprovalsBadge.tsx +22 -0
  9. package/components/Chat/AccessPicker.tsx +13 -1
  10. package/components/SessionList/SidebarHeader.tsx +2 -0
  11. package/components/Settings/NoApprovalsSection.test.ts +115 -0
  12. package/components/Settings/NoApprovalsSection.tsx +103 -0
  13. package/components/Settings/SettingsDialog.test.ts +3 -0
  14. package/components/Settings/SettingsDialog.tsx +9 -0
  15. package/data/approvals/index.ts +56 -0
  16. package/lib/approvals/enable.ts +64 -0
  17. package/lib/approvals/no-approvals.ts +119 -0
  18. package/lib/chat/events.ts +2 -0
  19. package/lib/chat/settings.ts +17 -2
  20. package/lib/chat/worker/host.ts +49 -4
  21. package/lib/db/migrations.ts +18 -0
  22. package/lib/models/plan-step.ts +36 -7
  23. package/lib/orchestrator/asks.ts +42 -5
  24. package/lib/orchestrator/brakes.ts +11 -0
  25. package/lib/orchestrator/brief.ts +7 -1
  26. package/lib/orchestrator/merge-approvals.ts +10 -1
  27. package/lib/orchestrator/presence-binding.ts +3 -0
  28. package/lib/orchestrator/review.ts +12 -1
  29. package/lib/orchestrator/signoff.ts +15 -1
  30. package/lib/security/network-state.ts +2 -2
  31. package/lib/security/presence.ts +1 -0
  32. package/lib/settings-url.ts +1 -0
  33. package/package.json +1 -1
  34. package/scripts/claude-status-hook.sh +25 -1
package/README.md CHANGED
@@ -26,6 +26,7 @@ and check on all of it from your phone.
26
26
  - **Machine load and status.** A load gauge, each session's share of CPU and memory, and Needs you, Working, In review and Done as it changes. [More](docs/guides/sessions.md)
27
27
  - **Workspaces.** Group projects, finish work with Done, and get a log of every merged task. [More](docs/reference/workspaces.md)
28
28
  - **LumifyHub (optional).** Boards, cards and docs for a workspace, if you want them. [More](docs/lumifyhub.md)
29
+ - **No approvals mode.** One switch in Settings that approves every ask, lets brakes and plan-only tasks through, and starts every agent with its permission prompts off; each approval it makes is logged. Off by default. [More](docs/reference/orchestrator.md#no-approvals-mode)
29
30
  - **Also:** a ⌘K palette, code search, a file picker, a git panel, dev servers, up to four panes, and dictation.
30
31
 
31
32
  ## Documentation
@@ -0,0 +1,34 @@
1
+ import { NextRequest } from "next/server";
2
+ import {
3
+ noApprovalsOn,
4
+ recordAutoApproval,
5
+ } from "@/lib/approvals/no-approvals";
6
+
7
+ // The Claude status hook asks here when a terminal Claude wants a tool
8
+ // permission. While No approvals mode is on the answer is Claude's own
9
+ // "allow" decision; otherwise nothing, and the prompt shows as usual. A
10
+ // question isn't a permission and is never answered. Never fails the hook.
11
+ export async function POST(request: NextRequest) {
12
+ try {
13
+ if (!noApprovalsOn()) return new Response("");
14
+ const input = (await request.json()) as { tool_name?: unknown };
15
+ const tool = typeof input?.tool_name === "string" ? input.tool_name : "";
16
+ if (!tool || tool === "AskUserQuestion") return new Response("");
17
+ recordAutoApproval({
18
+ source: "terminal",
19
+ sessionId: request.nextUrl.searchParams.get("session") || null,
20
+ subject: `${tool} in a terminal session`,
21
+ });
22
+ return new Response(
23
+ JSON.stringify({
24
+ hookSpecificOutput: {
25
+ hookEventName: "PermissionRequest",
26
+ decision: { behavior: "allow" },
27
+ },
28
+ }),
29
+ { headers: { "Content-Type": "application/json" } }
30
+ );
31
+ } catch {
32
+ return new Response("");
33
+ }
34
+ }
@@ -0,0 +1,52 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
3
+ import { switchNoApprovals } from "@/lib/approvals/enable";
4
+ import { listAutoApprovals, noApprovalsOn } from "@/lib/approvals/no-approvals";
5
+ import { NO_APPROVALS_ON_PRESENCE } from "@/lib/orchestrator/presence-binding";
6
+ import { requireApprover } from "@/lib/security/approver";
7
+ import { relyingParty, verifyPresence } from "@/lib/security/presence";
8
+ import { refusalResponse } from "@/lib/security/presence-http";
9
+ import { requireLocalTrust } from "@/lib/security/route-guard";
10
+
11
+ const view = () => ({ on: noApprovalsOn(), audit: listAutoApprovals(20) });
12
+
13
+ // GET /api/approvals - whether No approvals mode is on, and its latest audit lines
14
+ export function GET() {
15
+ return NextResponse.json(view());
16
+ }
17
+
18
+ // PUT /api/approvals {on, assertion?} - turning it on is deliberate: only
19
+ // this machine or the tailnet, and with Saad's passkey, since it switches
20
+ // off every approval. Turning it off only needs a place that may approve.
21
+ export async function PUT(request: NextRequest) {
22
+ const body = (await request.json().catch(() => ({}))) as {
23
+ on?: unknown;
24
+ assertion?: AuthenticationResponseJSON;
25
+ };
26
+ if (typeof body.on !== "boolean")
27
+ return NextResponse.json(
28
+ { error: "on must be true or false" },
29
+ { status: 400 }
30
+ );
31
+ if (body.on) {
32
+ const refused = requireLocalTrust(request);
33
+ if (refused) return refused;
34
+ if (!noApprovalsOn()) {
35
+ try {
36
+ await verifyPresence(
37
+ relyingParty(request.headers),
38
+ body.assertion,
39
+ "no-approvals-on",
40
+ NO_APPROVALS_ON_PRESENCE
41
+ );
42
+ } catch (error) {
43
+ return refusalResponse(error);
44
+ }
45
+ }
46
+ } else {
47
+ const gate = requireApprover(request);
48
+ if (!gate.ok) return gate.response;
49
+ }
50
+ await switchNoApprovals(body.on);
51
+ return NextResponse.json(view());
52
+ }
@@ -5,7 +5,7 @@ import { requireLocalTrust } from "@/lib/security/route-guard";
5
5
  import { setConnectEnabled } from "@/lib/connect/config";
6
6
  import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
7
7
  import {
8
- mergeApprovalsOn,
8
+ mergeApprovalsSetting,
9
9
  setMergeApprovals,
10
10
  } from "@/lib/orchestrator/merge-approvals";
11
11
  import { APPROVALS_OFF_PRESENCE } from "@/lib/orchestrator/presence-binding";
@@ -37,7 +37,7 @@ export async function PUT(request: NextRequest) {
37
37
  };
38
38
  // Switching Saad's merge approvals off lets sensitive PRs merge without
39
39
  // him, so it needs his passkey: anything on this machine could ask.
40
- if (body.mergeApprovals === false && mergeApprovalsOn()) {
40
+ if (body.mergeApprovals === false && mergeApprovalsSetting()) {
41
41
  try {
42
42
  await verifyPresence(
43
43
  relyingParty(request.headers),
@@ -1,3 +1,4 @@
1
+ import { effectiveAutoApprove } from "@/lib/approvals/no-approvals";
1
2
  import { routedRun } from "@/lib/models/launch";
2
3
  import { NextRequest, NextResponse } from "next/server";
3
4
  import { isRemoteHost } from "@/lib/hosts";
@@ -309,7 +310,7 @@ export async function POST(
309
310
  // Start new tmux session with Claude directly
310
311
  const isRoot = process.getuid?.() === 0;
311
312
  const envPrefix = isRoot ? "IS_SANDBOX=1 " : "";
312
- const claudeCmd = session.auto_approve
313
+ const claudeCmd = effectiveAutoApprove(session.auto_approve)
313
314
  ? `${envPrefix}claude --dangerously-skip-permissions`
314
315
  : "claude";
315
316
 
package/app/page.tsx CHANGED
@@ -59,6 +59,8 @@ import { useMoveCommands } from "@/components/Tasks/useMoveCommands";
59
59
  import dynamic from "next/dynamic";
60
60
  import { chatMetaActions } from "@/stores/chatMeta";
61
61
  import { useDemoMode } from "@/data/demo";
62
+ import { useQueryClient } from "@tanstack/react-query";
63
+ import { approvalsModeQuery } from "@/data/approvals";
62
64
 
63
65
  // The app's dialogs open from their stores, so none is needed for the first
64
66
  // paint: their code loads right after it.
@@ -159,6 +161,7 @@ function HomeContent() {
159
161
  const focusedActiveTab = getActiveTab(focusedPaneId);
160
162
  const { isMobile, isHydrated } = useViewport();
161
163
  const demo = useDemoMode();
164
+ const queryClient = useQueryClient();
162
165
 
163
166
  // Data hooks
164
167
  const {
@@ -288,10 +291,16 @@ function HomeContent() {
288
291
  clearPendingPrompt(session.id);
289
292
  }
290
293
 
294
+ // No approvals mode launches every agent with its permission prompts
295
+ // off; a failed read launches as the session was set.
296
+ const noApprovals = await queryClient
297
+ .fetchQuery(approvalsModeQuery)
298
+ .then((m) => m.on)
299
+ .catch(() => false);
291
300
  const flags = provider.buildFlags({
292
301
  sessionId: session.claude_session_id,
293
302
  parentSessionId,
294
- autoApprove: session.auto_approve,
303
+ autoApprove: session.auto_approve || noApprovals,
295
304
  model: session.model,
296
305
  initialPrompt: initialPrompt || undefined,
297
306
  });
@@ -313,7 +322,7 @@ function HomeContent() {
313
322
 
314
323
  return { sessionName, cwd, command, hostId, sessionId };
315
324
  },
316
- [sessions, getInitScriptCommand]
325
+ [sessions, getInitScriptCommand, queryClient]
317
326
  );
318
327
 
319
328
  // Attach a session to a terminal
package/app/pair/page.tsx CHANGED
@@ -85,8 +85,9 @@ export default function PairPage() {
85
85
  <Logo />
86
86
  <h1 className="text-xl font-semibold">Pair this device</h1>
87
87
  <p className="text-muted-foreground text-sm">
88
- On a device that already uses AgentOS, open the menu, choose{" "}
89
- <span className="text-foreground">Devices</span>, then{" "}
88
+ On a device that already uses AgentOS, open{" "}
89
+ <span className="text-foreground">Settings</span>, choose{" "}
90
+ <span className="text-foreground">Devices &amp; access</span>, then{" "}
90
91
  <span className="text-foreground">Add a device</span>.
91
92
  </p>
92
93
  </div>
@@ -0,0 +1,22 @@
1
+ "use client";
2
+
3
+ import { ShieldOff } from "lucide-react";
4
+ import { useApprovalsModeQuery } from "@/data/approvals";
5
+ import { settingsUiActions } from "@/stores/settingsUi";
6
+
7
+ // On screen for as long as No approvals mode is on; opens its Settings.
8
+ export function NoApprovalsBadge() {
9
+ const { data } = useApprovalsModeQuery();
10
+ if (!data?.on) return null;
11
+ return (
12
+ <button
13
+ type="button"
14
+ onClick={() => settingsUiActions.open("approvals")}
15
+ title="No approvals mode is on: nothing waits for a person. Open settings."
16
+ className="flex h-8 shrink-0 items-center gap-1 rounded-md bg-amber-500/20 px-2 text-xs font-medium text-amber-500 ring-1 ring-amber-500/50"
17
+ >
18
+ <ShieldOff className="h-3.5 w-3.5" />
19
+ No approvals
20
+ </button>
21
+ );
22
+ }
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { useApprovalsModeQuery } from "@/data/approvals";
3
4
  import {
4
5
  Check,
5
6
  ChevronDown,
@@ -44,8 +45,19 @@ export function AccessPicker({
44
45
  access: ChatAccess;
45
46
  onPick: (access: ChatAccess) => void;
46
47
  }) {
47
- const current = LEVELS[access] ?? LEVELS.full;
48
+ const noApprovals = !!useApprovalsModeQuery().data?.on;
49
+ const current = LEVELS[noApprovals ? "full" : access] ?? LEVELS.full;
48
50
  const Icon = current.icon;
51
+ if (noApprovals)
52
+ return (
53
+ <span
54
+ title="No approvals mode is on: every agent runs with full access"
55
+ className="text-muted-foreground flex h-10 shrink-0 items-center gap-1 px-2 text-xs"
56
+ >
57
+ <Icon className="h-3.5 w-3.5" />
58
+ <span className="hidden sm:inline">{current.label}</span>
59
+ </span>
60
+ );
49
61
  return (
50
62
  <DropdownMenu>
51
63
  <DropdownMenuTrigger asChild>
@@ -39,6 +39,7 @@ import { usageUiActions } from "@/stores/usageUi";
39
39
  import { WorkspaceSwitcher } from "./WorkspaceSwitcher";
40
40
  import { LoadGauge } from "./LoadGauge";
41
41
  import { AwayToggle } from "@/components/Away/AwayToggle";
42
+ import { NoApprovalsBadge } from "@/components/Approvals/NoApprovalsBadge";
42
43
  import { useSchedulesGlance } from "./useSchedulesGlance";
43
44
  import { cn } from "@/lib/utils";
44
45
 
@@ -71,6 +72,7 @@ export function SidebarHeader(props: SidebarHeaderProps) {
71
72
  <LoadGauge />
72
73
  </div>
73
74
  <div className="ml-auto flex shrink-0 items-center gap-0.5">
75
+ <NoApprovalsBadge />
74
76
  <AwayToggle />
75
77
  <ADropdownMenu
76
78
  trigger={
@@ -0,0 +1,115 @@
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
+ // Radix dispatches its own events; they must be jsdom's, not Node's.
7
+ for (const key of ["Event", "CustomEvent", "FocusEvent", "HTMLButtonElement"])
8
+ Object.defineProperty(globalThis, key, {
9
+ value: (window as unknown as Record<string, unknown>)[key],
10
+ configurable: true,
11
+ writable: true,
12
+ });
13
+ (
14
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
15
+ ).IS_REACT_ACT_ENVIRONMENT = true;
16
+
17
+ const state = vi.hoisted(() => ({
18
+ on: false,
19
+ audit: [] as object[],
20
+ mutate: vi.fn(),
21
+ }));
22
+ vi.mock("@/data/approvals", () => ({
23
+ useApprovalsModeQuery: () => ({
24
+ isPending: false,
25
+ isError: false,
26
+ data: { on: state.on, audit: state.audit },
27
+ }),
28
+ useSetApprovalsMode: () => ({
29
+ mutate: state.mutate,
30
+ isPending: false,
31
+ error: null,
32
+ }),
33
+ }));
34
+
35
+ const { NoApprovalsSection } = await import("./NoApprovalsSection");
36
+ const { NoApprovalsBadge } =
37
+ await import("@/components/Approvals/NoApprovalsBadge");
38
+
39
+ let root: Root;
40
+ let host: HTMLDivElement;
41
+ beforeEach(() => {
42
+ state.on = false;
43
+ state.audit = [];
44
+ state.mutate.mockReset();
45
+ host = document.createElement("div");
46
+ document.body.appendChild(host);
47
+ root = createRoot(host);
48
+ });
49
+ afterEach(() => {
50
+ act(() => root.unmount());
51
+ host.remove();
52
+ });
53
+
54
+ const render = (el: ReturnType<typeof createElement>) =>
55
+ act(() => root.render(el));
56
+ const click = (el: Element | null) =>
57
+ act(() => {
58
+ el?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
59
+ });
60
+ const byText = (text: string) =>
61
+ [...host.querySelectorAll("button")].find((b) =>
62
+ b.textContent?.includes(text)
63
+ ) ?? null;
64
+
65
+ describe("the No approvals toggle", () => {
66
+ it("is off, and flipping it warns first without switching anything", () => {
67
+ render(createElement(NoApprovalsSection));
68
+ const toggle = host.querySelector('[role="switch"]');
69
+ expect(toggle?.getAttribute("aria-checked")).toBe("false");
70
+ expect(host.querySelector('[role="alertdialog"]')).toBeNull();
71
+ click(toggle!);
72
+ const warning = host.querySelector('[role="alertdialog"]');
73
+ expect(warning?.textContent).toMatch(/hard lines included/);
74
+ expect(warning?.textContent).toMatch(/permission prompts off/);
75
+ expect(state.mutate).not.toHaveBeenCalled();
76
+ });
77
+
78
+ it("switches on only when the warning is confirmed, and cancel backs out", () => {
79
+ render(createElement(NoApprovalsSection));
80
+ click(host.querySelector('[role="switch"]'));
81
+ click(byText("Cancel"));
82
+ expect(host.querySelector('[role="alertdialog"]')).toBeNull();
83
+ expect(state.mutate).not.toHaveBeenCalled();
84
+ click(host.querySelector('[role="switch"]'));
85
+ click(byText("Turn on No approvals"));
86
+ expect(state.mutate).toHaveBeenCalledWith(true, expect.any(Object));
87
+ });
88
+
89
+ it("switches off at once, and lists what was approved for you", () => {
90
+ state.on = true;
91
+ state.audit = [
92
+ {
93
+ id: 1,
94
+ at: "2026-10-13 10:00:00",
95
+ source: "ask",
96
+ subject: 'money ask "Pay?"',
97
+ detail: "",
98
+ },
99
+ ];
100
+ render(createElement(NoApprovalsSection));
101
+ expect(host.textContent).toContain('money ask "Pay?"');
102
+ click(host.querySelector('[role="switch"]'));
103
+ expect(state.mutate).toHaveBeenCalledWith(false);
104
+ });
105
+ });
106
+
107
+ describe("the badge", () => {
108
+ it("shows only while the mode is on", () => {
109
+ render(createElement(NoApprovalsBadge));
110
+ expect(host.textContent).toBe("");
111
+ state.on = true;
112
+ render(createElement(NoApprovalsBadge));
113
+ expect(host.textContent).toContain("No approvals");
114
+ });
115
+ });
@@ -0,0 +1,103 @@
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 that failed its gates twice, has no CI, or is too big to review still merges.",
12
+ "Brakes stop holding new starts, and plan-only tasks go on to implement without waiting for a plan approval.",
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
+ }
@@ -28,6 +28,9 @@ const stub = (name: string) =>
28
28
  vi.mock("@/components/Merge/GlobalMergeSection", () => ({
29
29
  GlobalMergeSection: stub("merging"),
30
30
  }));
31
+ vi.mock("./NoApprovalsSection", () => ({
32
+ NoApprovalsSection: stub("approvals"),
33
+ }));
31
34
  vi.mock("@/components/Workspaces", () => ({
32
35
  WorkspaceSettings: stub("workspaces"),
33
36
  }));
@@ -13,6 +13,7 @@ import {
13
13
  GitMerge,
14
14
  LayoutGrid,
15
15
  ListChecks,
16
+ ShieldOff,
16
17
  Smartphone,
17
18
  X,
18
19
  type LucideIcon,
@@ -38,6 +39,7 @@ import { schedulesUiActions } from "@/stores/schedulesUi";
38
39
  import { RecipesPanel } from "@/components/Recipes";
39
40
  import { settingsUi, settingsUiActions } from "@/stores/settingsUi";
40
41
  import { SetupSection } from "./SetupSection";
42
+ import { NoApprovalsSection } from "./NoApprovalsSection";
41
43
  import {
42
44
  NotificationsSection,
43
45
  type BrowserNotifications,
@@ -52,6 +54,11 @@ export const SECTION_INFO: Record<
52
54
  hint: "Approval, merge method, branch clean-up",
53
55
  icon: GitMerge,
54
56
  },
57
+ approvals: {
58
+ label: "No approvals",
59
+ hint: "Switch off every approval and permission prompt",
60
+ icon: ShieldOff,
61
+ },
55
62
  workspaces: {
56
63
  label: "Workspaces",
57
64
  hint: "Running task limit",
@@ -104,6 +111,8 @@ function SectionBody({
104
111
  switch (section) {
105
112
  case "merging":
106
113
  return <GlobalMergeSection />;
114
+ case "approvals":
115
+ return <NoApprovalsSection />;
107
116
  case "workspaces":
108
117
  return <WorkspaceSettings />;
109
118
  case "models":
@@ -0,0 +1,56 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import type { AuditRow } from "@/lib/approvals/no-approvals";
3
+ import { provePresence } from "../presence";
4
+ import { usePollWhenOffline } from "../push/connection";
5
+
6
+ export interface ApprovalsMode {
7
+ on: boolean;
8
+ audit: AuditRow[];
9
+ }
10
+
11
+ export const approvalsKeys = { all: ["approvals"] as const };
12
+
13
+ async function json(res: Response): Promise<ApprovalsMode> {
14
+ const data = await res.json();
15
+ if (!res.ok) throw new Error(data.error || "Request failed");
16
+ return data;
17
+ }
18
+
19
+ // Shared with the code that builds a terminal's launch command.
20
+ export const approvalsModeQuery = {
21
+ queryKey: approvalsKeys.all,
22
+ queryFn: async () => json(await fetch("/api/approvals")),
23
+ staleTime: 10_000,
24
+ };
25
+
26
+ export function useApprovalsModeQuery() {
27
+ return useQuery({
28
+ ...approvalsModeQuery,
29
+ refetchInterval: usePollWhenOffline(60_000),
30
+ });
31
+ }
32
+
33
+ // Turning it on needs a passkey; turning it off doesn't.
34
+ export function useSetApprovalsMode() {
35
+ const queryClient = useQueryClient();
36
+ return useMutation({
37
+ mutationFn: async (on: boolean) =>
38
+ json(
39
+ await fetch("/api/approvals", {
40
+ method: "PUT",
41
+ headers: { "Content-Type": "application/json" },
42
+ body: JSON.stringify({
43
+ on,
44
+ ...(on
45
+ ? {
46
+ assertion: await provePresence({
47
+ purpose: "no-approvals-on",
48
+ }),
49
+ }
50
+ : {}),
51
+ }),
52
+ })
53
+ ),
54
+ onSuccess: (data) => queryClient.setQueryData(approvalsKeys.all, data),
55
+ });
56
+ }
@@ -0,0 +1,64 @@
1
+ // Switching No approvals mode: the one place that turns it on or off and
2
+ // brings what already exists in line with it.
3
+
4
+ import { db } from "../db";
5
+ import { reapplyChatModes } from "../chat/settings";
6
+ import { approvePlanNow } from "../models/plan-step";
7
+ import { autoApproveAsk, type AskRow } from "../orchestrator/asks";
8
+ import { addNote } from "../orchestrator/notes";
9
+ import { listOrchestrators } from "../orchestrator/home";
10
+ import {
11
+ noApprovalsOn,
12
+ recordAutoApproval,
13
+ setNoApprovals,
14
+ } from "./no-approvals";
15
+
16
+ // Everything already waiting when the mode goes on is approved: open asks,
17
+ // plan-only tasks at their plan, tool cards in live chats.
18
+ async function sweep(): Promise<void> {
19
+ const asks = db
20
+ .prepare(`SELECT * FROM orchestrator_asks WHERE status = 'open'`)
21
+ .all() as AskRow[];
22
+ for (const ask of asks) {
23
+ try {
24
+ autoApproveAsk(ask.workspace_id, ask);
25
+ } catch (e) {
26
+ console.error(`auto-approving ask ${ask.id} failed:`, e);
27
+ }
28
+ }
29
+ const plans = db
30
+ .prepare(
31
+ `SELECT id FROM sessions WHERE plan_phase = 'awaiting' AND task_status = 'running'`
32
+ )
33
+ .all() as { id: string }[];
34
+ for (const p of plans) {
35
+ try {
36
+ await approvePlanNow(p.id, undefined, "no-approvals");
37
+ } catch (e) {
38
+ console.error(`auto-approving plan of ${p.id} failed:`, e);
39
+ }
40
+ }
41
+ }
42
+
43
+ export async function switchNoApprovals(on: boolean): Promise<void> {
44
+ if (on === noApprovalsOn()) return;
45
+ setNoApprovals(on);
46
+ recordAutoApproval({
47
+ source: "mode",
48
+ subject: on
49
+ ? "No approvals mode turned on"
50
+ : "No approvals mode turned off",
51
+ });
52
+ for (const o of listOrchestrators()) {
53
+ if (!o.workspace_id) continue;
54
+ addNote(
55
+ o.workspace_id,
56
+ on
57
+ ? "No approvals mode is on: every ask is approved as it's raised, and agents run without permission prompts."
58
+ : "No approvals mode is off: asks wait for Saad again.",
59
+ "ask"
60
+ );
61
+ }
62
+ if (on) await sweep();
63
+ reapplyChatModes();
64
+ }