@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
@@ -0,0 +1,119 @@
1
+ /**
2
+ * No approvals mode: while it's on, nothing AgentOS starts or runs waits on
3
+ * a person. Asks are approved as they're raised, brakes let starts through,
4
+ * plan-only tasks go on to implement, and agents launch with their harness's
5
+ * permission prompts off. Off by default; app-wide; every point that honours
6
+ * it reads it here and records what it did with recordAutoApproval.
7
+ */
8
+
9
+ import { db } from "../db";
10
+ import type { ChatAccess } from "../chat/events";
11
+ import { addNote } from "../orchestrator/notes";
12
+ import { markAllStatusDirty } from "../status/dirty";
13
+
14
+ export const NO_APPROVALS_KEY = "approvals.none";
15
+
16
+ export type AuditSource =
17
+ | "ask"
18
+ | "brake"
19
+ | "plan"
20
+ | "chat"
21
+ | "terminal"
22
+ | "merge-approval"
23
+ | "mode";
24
+
25
+ export interface AuditRow {
26
+ id: number;
27
+ at: string;
28
+ source: AuditSource;
29
+ workspace_id: string | null;
30
+ session_id: string | null;
31
+ subject: string;
32
+ detail: string;
33
+ }
34
+
35
+ export function noApprovalsOn(): boolean {
36
+ const row = db
37
+ .prepare(`SELECT value FROM settings WHERE key = ?`)
38
+ .get(NO_APPROVALS_KEY) as { value: string } | undefined;
39
+ return row?.value === "1";
40
+ }
41
+
42
+ export function setNoApprovals(on: boolean): void {
43
+ db.prepare(
44
+ `INSERT INTO settings (key, value) VALUES (?, ?)
45
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
46
+ ).run(NO_APPROVALS_KEY, on ? "1" : "0");
47
+ markAllStatusDirty();
48
+ }
49
+
50
+ export const AUTO_LABEL = "auto-approved by No approvals mode";
51
+
52
+ const MAX_DETAIL = 500;
53
+
54
+ // Writes the audit row, and a note in the workspace's orchestrator chat when
55
+ // the approval belongs to one. Never throws: an audit failure must not turn
56
+ // into a stuck approval.
57
+ export function recordAutoApproval(a: {
58
+ source: AuditSource;
59
+ subject: string;
60
+ detail?: string;
61
+ workspaceId?: string | null;
62
+ sessionId?: string | null;
63
+ }): void {
64
+ const detail = (a.detail ?? "").slice(0, MAX_DETAIL);
65
+ try {
66
+ db.prepare(
67
+ `INSERT INTO approval_audit (source, workspace_id, session_id, subject, detail)
68
+ VALUES (?, ?, ?, ?, ?)`
69
+ ).run(
70
+ a.source,
71
+ a.workspaceId ?? null,
72
+ a.sessionId ?? null,
73
+ a.subject,
74
+ detail
75
+ );
76
+ } catch (e) {
77
+ console.error("approval audit failed:", e);
78
+ }
79
+ console.log(`[no-approvals] ${a.source}: ${a.subject}`);
80
+ if (a.workspaceId) {
81
+ try {
82
+ addNote(
83
+ a.workspaceId,
84
+ `${AUTO_LABEL[0].toUpperCase()}${AUTO_LABEL.slice(1)}: ${a.subject}${detail ? ` (${detail})` : ""}`,
85
+ "ask"
86
+ );
87
+ } catch (e) {
88
+ console.error("approval note failed:", e);
89
+ }
90
+ }
91
+ }
92
+
93
+ // For a point that's judged again and again (a PR on every poll): one row
94
+ // per source and subject.
95
+ export function recordAutoApprovalOnce(
96
+ a: Parameters<typeof recordAutoApproval>[0]
97
+ ): void {
98
+ const seen = db
99
+ .prepare(`SELECT 1 FROM approval_audit WHERE source = ? AND subject = ?`)
100
+ .get(a.source, a.subject);
101
+ if (!seen) recordAutoApproval(a);
102
+ }
103
+
104
+ export function listAutoApprovals(limit = 20): AuditRow[] {
105
+ return db
106
+ .prepare(`SELECT * FROM approval_audit ORDER BY id DESC LIMIT ?`)
107
+ .all(limit) as AuditRow[];
108
+ }
109
+
110
+ // What an agent is launched or kept with: the mode forces the most access,
111
+ // so no harness prompts. The stored choice is untouched and returns when the
112
+ // mode goes off.
113
+ export function effectiveChatAccess(stored: ChatAccess): ChatAccess {
114
+ return noApprovalsOn() ? "full" : stored;
115
+ }
116
+
117
+ export function effectiveAutoApprove(stored: boolean | number): boolean {
118
+ return !!stored || noApprovalsOn();
119
+ }
@@ -226,6 +226,8 @@ export type ChatItem =
226
226
  // Whether "always allow" is on offer.
227
227
  canAlways: boolean;
228
228
  status: "pending" | "allowed" | "denied" | "answered" | "expired";
229
+ // Allowed by No approvals mode, not by the reader.
230
+ auto?: boolean;
229
231
  })
230
232
  | (Base & {
231
233
  // Work the agent runs alongside the turn: a background shell, a
@@ -3,6 +3,7 @@
3
3
  * per-session settings that go with them: the model and the access level.
4
4
  */
5
5
 
6
+ import { effectiveChatAccess, noApprovalsOn } from "../approvals/no-approvals";
6
7
  import os from "os";
7
8
  import { db, type Session } from "../db";
8
9
  import { agentEnv } from "../agents/launch";
@@ -73,8 +74,8 @@ export function emitCapabilities(session: Session, listener?: Listener): void {
73
74
  commands: visibleCommands(caps),
74
75
  models: caps.models,
75
76
  model: chatModel(session),
76
- access: session.chat_access,
77
- plan: canPlan(session) ? !!session.chat_plan : null,
77
+ access: effectiveChatAccess(session.chat_access),
78
+ plan: canPlan(session) ? !!session.chat_plan && !noApprovalsOn() : null,
78
79
  background: chatDriverFor(session.agent_type)?.backgroundTasks === true,
79
80
  };
80
81
  if (listener) listener(m);
@@ -352,3 +353,17 @@ export async function setChatPlan(
352
353
  registry.live.get(sessionId)?.worker.command({ type: "set_plan", plan });
353
354
  emitCapabilities(getSession(sessionId));
354
355
  }
356
+
357
+ // No approvals mode was switched: every live worker takes its access and plan
358
+ // settings again, now read through the mode, and waiting tool cards are
359
+ // allowed (or, switched off, the stored choices return).
360
+ export function reapplyChatModes(): void {
361
+ for (const [id, live] of registry.live) {
362
+ const session = getSession(id);
363
+ if (!session) continue;
364
+ live.worker.command({ type: "set_access", access: session.chat_access });
365
+ if (live.canPlan)
366
+ live.worker.command({ type: "set_plan", plan: !!session.chat_plan });
367
+ emitCapabilities(session);
368
+ }
369
+ }
@@ -55,6 +55,11 @@ import { compactContext } from "../compact-context";
55
55
  import { COMPACT_BRIEF, COMPACT_INSTRUCTIONS } from "../working-state";
56
56
  import { compactionDue, staleLabel, type Staleness } from "../stale";
57
57
  import type { WorkerCommand, WorkerEvent } from "./protocol";
58
+ import {
59
+ effectiveChatAccess,
60
+ noApprovalsOn,
61
+ recordAutoApproval,
62
+ } from "../../approvals/no-approvals";
58
63
 
59
64
  // How soon after a stopped turn ends a turn the agent starts on its own
60
65
  // still counts as part of the stop: the agent starts what it queued the
@@ -131,6 +136,10 @@ export class ChatHost {
131
136
  private restTimer?: NodeJS.Timeout;
132
137
  // Background tasks seen running: one that ends is a notice the agent runs.
133
138
  private runningTasks = new Set<string>();
139
+ // Tool-permission cards waiting on the reader (questions are not: they
140
+ // need words), and the ones No approvals mode allowed.
141
+ private waitingCards = new Map<string, string>();
142
+ private autoAllowed = new Set<string>();
134
143
  // Whether its driver says when that is (lib/chat/driver atRest).
135
144
  private atRest = true;
136
145
  private restartedNote = false;
@@ -210,8 +219,9 @@ export class ChatHost {
210
219
  session.model?.trim() || resolveModelForAgent(session.agent_type, null),
211
220
  resumeId: session.claude_session_id,
212
221
  resumeAt: session.chat_resume_at,
213
- access: session.chat_access ?? "full",
214
- plan: !!session.chat_plan,
222
+ access: effectiveChatAccess(session.chat_access ?? "full"),
223
+ // A plan waits for a person to carry it out, so it isn't entered.
224
+ plan: !!session.chat_plan && !noApprovalsOn(),
215
225
  systemAppend: [
216
226
  extras.systemAppend,
217
227
  sessionRunningBrief(session.id),
@@ -273,6 +283,33 @@ export class ChatHost {
273
283
  this.done = this.pump();
274
284
  }
275
285
 
286
+ // No approvals mode: a tool-permission card is allowed as it appears, and
287
+ // marks itself so the transcript shows who allowed it.
288
+ private markAuto(item: ChatItem): ChatItem {
289
+ if (item.kind !== "approval" || item.questions) return item;
290
+ if (item.status === "pending") {
291
+ this.waitingCards.set(item.id, item.title);
292
+ return item;
293
+ }
294
+ this.waitingCards.delete(item.id);
295
+ if (!this.autoAllowed.delete(item.id)) return item;
296
+ return { ...item, auto: true };
297
+ }
298
+
299
+ private allowWaiting(): void {
300
+ if (!noApprovalsOn()) return;
301
+ for (const [id, title] of [...this.waitingCards]) {
302
+ this.waitingCards.delete(id);
303
+ this.autoAllowed.add(id);
304
+ recordAutoApproval({
305
+ source: "chat",
306
+ sessionId: this.session.id,
307
+ subject: title,
308
+ });
309
+ this.conversation.respond(id, { decision: "allow" });
310
+ }
311
+ }
312
+
276
313
  private record(item: ChatItem): void {
277
314
  // Streaming updates are written in batches; anything else right away.
278
315
  queueItem(this.session.id, item, {
@@ -325,7 +362,9 @@ export class ChatHost {
325
362
  if ("streaming" in e.item && e.item.streaming)
326
363
  this.streaming.set(e.item.id, e.item);
327
364
  else this.streaming.delete(e.item.id);
365
+ e.item = this.markAuto(e.item);
328
366
  this.record(e.item);
367
+ if (e.item.kind === "approval") this.allowWaiting();
329
368
  if (e.item.kind === "task" && !e.item.ambient) {
330
369
  if (e.item.status === "running")
331
370
  this.runningTasks.add(e.item.taskId);
@@ -705,9 +744,15 @@ export class ChatHost {
705
744
  case "set_model":
706
745
  return this.conversation.setModel(cmd.model);
707
746
  case "set_access":
708
- return (this.modeChange = this.conversation.setAccess(cmd.access));
747
+ this.modeChange = this.conversation.setAccess(
748
+ effectiveChatAccess(cmd.access)
749
+ );
750
+ this.allowWaiting();
751
+ return this.modeChange;
709
752
  case "set_plan":
710
- return (this.modeChange = this.conversation.setPlan(cmd.plan));
753
+ return (this.modeChange = this.conversation.setPlan(
754
+ cmd.plan && !noApprovalsOn()
755
+ ));
711
756
  case "respond":
712
757
  return this.conversation.respond(cmd.id, cmd);
713
758
  case "stop_task":
@@ -2010,6 +2010,24 @@ const migrations: Migration[] = [
2010
2010
  );
2011
2011
  },
2012
2012
  },
2013
+ {
2014
+ id: 84,
2015
+ name: "approval_audit",
2016
+ up: (db) => {
2017
+ // Every approval No approvals mode made in place of a person.
2018
+ db.exec(`
2019
+ CREATE TABLE IF NOT EXISTS approval_audit (
2020
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2021
+ at TEXT NOT NULL DEFAULT (datetime('now')),
2022
+ source TEXT NOT NULL,
2023
+ workspace_id TEXT,
2024
+ session_id TEXT,
2025
+ subject TEXT NOT NULL,
2026
+ detail TEXT NOT NULL DEFAULT ''
2027
+ )
2028
+ `);
2029
+ },
2030
+ },
2013
2031
  ];
2014
2032
 
2015
2033
  // `upTo`: stop after this id (tests that start from an older database).
@@ -19,6 +19,7 @@ import {
19
19
  } from "./resolve";
20
20
  import { ANTHROPIC } from "./roles";
21
21
  import { queueEvent } from "../orchestrator/events";
22
+ import { noApprovalsOn, recordAutoApproval } from "../approvals/no-approvals";
22
23
  import { addNote } from "../orchestrator/notes";
23
24
  import { amendBrief, amendmentsOf } from "../orchestrator/amendments";
24
25
  import { PIECES_PROMPT, pieceBrief } from "../plans/pieces";
@@ -341,11 +342,26 @@ export async function handoffAfterPlan(
341
342
  deleteQueued(sessionId, planRetryMessageId(sessionId));
342
343
  const retried = waiting.get(sessionId)?.retried ?? false;
343
344
  forget(sessionId);
344
- if (!s.plan_only)
345
- return (
346
- (await startImplementing(s, "planning", send, undefined, retried)) !==
347
- null
345
+ // No approvals mode doesn't stop at the plan to wait for one.
346
+ const auto = !!s.plan_only && noApprovalsOn();
347
+ if (!s.plan_only || auto) {
348
+ const ref = await startImplementing(
349
+ s,
350
+ "planning",
351
+ send,
352
+ undefined,
353
+ retried
348
354
  );
355
+ if (ref && auto)
356
+ recordAutoApproval({
357
+ source: "plan",
358
+ workspaceId: sessionWorkspace(s),
359
+ sessionId,
360
+ subject: `plan for ${s.name}`,
361
+ detail: "implementing without waiting for approve_plan",
362
+ });
363
+ return ref !== null;
364
+ }
349
365
  const awaiting = db
350
366
  .prepare(
351
367
  `UPDATE sessions SET plan_phase = 'awaiting'
@@ -401,9 +417,13 @@ export async function recheckPlanning(
401
417
 
402
418
  // approvePlan, sending through the chat runner (imported when needed: it
403
419
  // imports this file).
404
- export async function approvePlanNow(sessionId: string, message?: string) {
420
+ export async function approvePlanNow(
421
+ sessionId: string,
422
+ message?: string,
423
+ by: "saad" | "no-approvals" = "saad"
424
+ ) {
405
425
  const { sendQueuedNow } = await import("../chat/runner");
406
- return approvePlan(sessionId, message, (id, m) => sendQueuedNow(id, m));
426
+ return approvePlan(sessionId, message, (id, m) => sendQueuedNow(id, m), by);
407
427
  }
408
428
 
409
429
  // Approves a waiting plan-only task: implementation starts on the
@@ -413,7 +433,8 @@ export async function approvePlanNow(sessionId: string, message?: string) {
413
433
  export async function approvePlan(
414
434
  sessionId: string,
415
435
  message: string | undefined,
416
- send: Send
436
+ send: Send,
437
+ by: "saad" | "no-approvals" = "saad"
417
438
  ): Promise<{ ref: string; amendments: number }> {
418
439
  const s = getSession(sessionId);
419
440
  if (!s || s.plan_phase !== "awaiting" || s.task_status !== "running")
@@ -428,5 +449,13 @@ export async function approvePlan(
428
449
  }
429
450
  const ref = await startImplementing(s, "awaiting", send, text);
430
451
  if (!ref) throw new Error(`${s.name} isn't waiting for plan approval`);
452
+ if (by === "no-approvals")
453
+ recordAutoApproval({
454
+ source: "plan",
455
+ workspaceId,
456
+ sessionId,
457
+ subject: `plan for ${s.name}`,
458
+ detail: "already waiting when the mode was turned on",
459
+ });
431
460
  return { ref, amendments: amendmentsOf(s.id).length };
432
461
  }
@@ -8,6 +8,11 @@
8
8
 
9
9
  import { pushAskRaised } from "../notify/push/events";
10
10
  import { db } from "../db";
11
+ import {
12
+ AUTO_LABEL,
13
+ noApprovalsOn,
14
+ recordAutoApproval,
15
+ } from "../approvals/no-approvals";
11
16
  import { answerKey, queueEvent } from "./events";
12
17
  import { addNote } from "./notes";
13
18
  import { cleanTitle, classifyKind, titleSubject } from "./ask-text";
@@ -210,11 +215,36 @@ export function raiseAsk(input: {
210
215
  created: true,
211
216
  };
212
217
  })();
218
+ // No approvals mode answers it at once, so nothing waits and the phone
219
+ // isn't buzzed for it.
220
+ if (noApprovalsOn()) {
221
+ const ask = autoApproveAsk(input.workspaceId, result.ask);
222
+ return { ask, created: result.created };
223
+ }
213
224
  // A new ask buzzes the phone; a refresh of an open one doesn't.
214
225
  if (result.created) pushAskRaised(result.ask.id, input.workspaceId);
215
226
  return result;
216
227
  }
217
228
 
229
+ // Approves an open ask on No approvals mode's behalf, bound to the ask as it
230
+ // is now, and records it.
231
+ export function autoApproveAsk(workspaceId: string, ask: AskRow): AskRow {
232
+ const answered = answerAsk(
233
+ workspaceId,
234
+ ask.id,
235
+ { action: "approve" },
236
+ askBinding(ask),
237
+ "no-approvals"
238
+ );
239
+ recordAutoApproval({
240
+ source: "ask",
241
+ workspaceId,
242
+ subject: `${ask.kind} ask "${ask.title}"`,
243
+ detail: ask.sha ? `at ${short(ask.sha)}` : undefined,
244
+ });
245
+ return answered;
246
+ }
247
+
218
248
  export function openAsks(workspaceId: string): AskRow[] {
219
249
  return db
220
250
  .prepare(
@@ -247,10 +277,14 @@ function approvalScope(ask: AskRow): string {
247
277
  return "";
248
278
  }
249
279
 
250
- export function answerLine(ask: AskRow, answer: AskAnswer): string {
280
+ export function answerLine(
281
+ ask: AskRow,
282
+ answer: AskAnswer,
283
+ by: "saad" | "no-approvals" = "saad"
284
+ ): string {
251
285
  const head = `ask "${ask.title}"`;
252
286
  if (answer.action === "approve")
253
- return `${head}: approved${approvalScope(ask)}`;
287
+ return `${head}: approved${approvalScope(ask)}${by === "no-approvals" ? ` (${AUTO_LABEL})` : ""}`;
254
288
  if (answer.action === "decline") return `${head}: declined`;
255
289
  return `${head}: reply: ${answer.text}`;
256
290
  }
@@ -269,7 +303,8 @@ export function answerAsk(
269
303
  workspaceId: string,
270
304
  id: number,
271
305
  answer: AskAnswer,
272
- binding?: string
306
+ binding?: string,
307
+ by: "saad" | "no-approvals" = "saad"
273
308
  ): AskRow {
274
309
  const ask = getAsk(workspaceId, id);
275
310
  if (!ask) throw new Error("No such ask");
@@ -286,7 +321,8 @@ export function answerAsk(
286
321
  if (answer.action === "reply" && !text) throw new Error("The reply is empty");
287
322
  const line = answerLine(
288
323
  ask,
289
- answer.action === "reply" ? { action: "reply", text } : answer
324
+ answer.action === "reply" ? { action: "reply", text } : answer,
325
+ by
290
326
  );
291
327
  db.transaction(() => {
292
328
  db.prepare(
@@ -295,7 +331,8 @@ export function answerAsk(
295
331
  if (ask.kind === "passkey") settlePasskeyAsk(ask, answer.action);
296
332
  else queueEvent(workspaceId, answerKey(id), null, line);
297
333
  })();
298
- addNote(workspaceId, `Saad answered ${line}`, "ask");
334
+ // An auto-approval is noted once, by recordAutoApproval.
335
+ if (by === "saad") addNote(workspaceId, `Saad answered ${line}`, "ask");
299
336
  return getAsk(workspaceId, id)!;
300
337
  }
301
338
 
@@ -20,6 +20,7 @@ import { workspaceSessions } from "./facts";
20
20
  import { addNote } from "./notes";
21
21
  import { AskRefused, BRAKE_SUBJECT, raiseAsk, resolveAsks } from "./asks";
22
22
  import { spendApproval, unspentApproval, voidApprovals } from "./ask-approvals";
23
+ import { noApprovalsOn, recordAutoApproval } from "../approvals/no-approvals";
23
24
  import { isPaused } from "./pause";
24
25
  import { readUsage, windowRefusal, type UsageState } from "./usage";
25
26
 
@@ -159,6 +160,16 @@ async function refusal(
159
160
  }
160
161
  const key = on.map((b) => b.name).join("+");
161
162
  const reason = on.map((b) => b.reason).join("; ");
163
+ // No approvals mode lets the start through: no brake ask, no wait.
164
+ if (noApprovalsOn()) {
165
+ recordAutoApproval({
166
+ source: "brake",
167
+ workspaceId,
168
+ subject: `one start past the brakes (${key})`,
169
+ detail: reason,
170
+ });
171
+ return null;
172
+ }
162
173
  // An approval is for the brake it was asked about, not whichever holds now.
163
174
  const approval = unspentApproval(
164
175
  workspaceId,
@@ -5,12 +5,18 @@ import { TOOL_NAMES } from "./tool-names";
5
5
  import { UNTRUSTED_RULE } from "./untrusted";
6
6
  import { brakesEnabled } from "./brakes";
7
7
  import { mergeApprovalsOn } from "./merge-approvals";
8
+ import { noApprovalsOn } from "../approvals/no-approvals";
8
9
  import { buildDigest } from "./digest";
9
10
 
10
11
  const BRAKES_ON = `Starting work (\`start_task\`, \`start_session\`, \`stack\`) is braked: at most a set number of sessions running in this workspace (4 by default), at most a set number of starts an hour (6 by default), and nothing new once the account's usage window would run out before it resets, or can't be read at all. Each card of a stack you start counts as a start of its own; a braked card waits in the stack. A brake refuses the start with its reason and writes one note; running work carries on. Don't retry a braked start in a loop: carry on with reviews, answers and merges, and start again when a later event gives you reason to.`;
11
12
 
12
13
  const BRAKES_OFF = `Starting work has no limits right now: no cap on running sessions or starts, and no usage-window check. Saad's Pause still stops new starts; the start tools say so when it's on.`;
13
14
 
15
+ const NO_APPROVALS = `## No approvals mode is ON
16
+
17
+ Saad turned it on: nothing waits for him. Every \`ask_saad\` is approved the moment you raise it (the event says so), and so are escalated gates and brakes. That is his decision, not a reason to skip care: the hard lines still describe what is risky, so say in your chat what you are about to do before you do it, and keep to the PR's scope.
18
+
19
+ `;
14
20
  const APPROVALS_ON = `Merge approvals are on: anything touching CI config, deploy scripts or secrets handling (and build and hook scripts like package.json, agent config like .claude/, or AgentOS's own security code) goes to Saad the same way, whatever the gates say, as does a diff too big to review whole.`;
15
21
 
16
22
  const APPROVALS_OFF = `Merge approvals are off: a PR touching CI config, deploy scripts, secrets handling, build and hook scripts, agent config or AgentOS's own security code merges through the gates like any other. Don't park those PRs as asks with \`ask_saad\`; sign them off when the gates pass. A diff too big to review whole is reviewed in parts, each against the task, and the review passes only if every part does; one too big even in parts goes to Saad.`;
@@ -74,7 +80,7 @@ Tasks are woken by AgentOS itself when a check fails on their PR's head (with th
74
80
 
75
81
  "Not yet" (CI running or settling, no review of this commit yet) is not a failure: wait for the event and try again. A failure counts against the task once per commit, whichever path judges that commit and however often (a review that blocks counts when its verdict arrives); the same gate failing again on a different commit goes to Saad, with a note in your chat, and from then on only he merges or drops that task. Don't retry it; say so in your chat and move on. ${mergeApprovalsOn() ? APPROVALS_ON : APPROVALS_OFF} \`land\` merges a whole stack only if every open item passes the same gates, and judges each one again at its own head right before merging it.
76
82
 
77
- ## Brakes
83
+ ${noApprovalsOn() ? NO_APPROVALS : ""}## Brakes
78
84
 
79
85
  ${brakesEnabled() ? BRAKES_ON : BRAKES_OFF}
80
86
 
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { db } from "../db";
10
10
  import { markAllStatusDirty } from "../status/dirty";
11
+ import { noApprovalsOn } from "../approvals/no-approvals";
11
12
 
12
13
  const KEY = "orchestrator.merge_approvals";
13
14
 
@@ -15,13 +16,21 @@ const KEY = "orchestrator.merge_approvals";
15
16
  // no CI, goes to Saad either way.
16
17
  export const APPROVAL_GATES: readonly string[] = ["sensitive", "size"];
17
18
 
18
- export function mergeApprovalsOn(): boolean {
19
+ // The switch as Saad set it, whatever No approvals mode is doing.
20
+ export function mergeApprovalsSetting(): boolean {
19
21
  const row = db
20
22
  .prepare(`SELECT value FROM settings WHERE key = ?`)
21
23
  .get(KEY) as { value: string } | undefined;
22
24
  return row?.value === "1";
23
25
  }
24
26
 
27
+ // What the gates act on. No approvals mode reads it as off, so sensitive and
28
+ // large PRs go through the gates, review included, rather than waiting for
29
+ // a person (or being waved through unreviewed by an auto-approval).
30
+ export function mergeApprovalsOn(): boolean {
31
+ return mergeApprovalsSetting() && !noApprovalsOn();
32
+ }
33
+
25
34
  export function setMergeApprovals(on: boolean): void {
26
35
  db.prepare(
27
36
  `INSERT INTO settings (key, value) VALUES (?, ?)
@@ -10,6 +10,7 @@ export const askPresence = (askId: number, binding: string) =>
10
10
  export const resumePresence = (workspaceId: string) => `resume:${workspaceId}`;
11
11
  export const revokePresence = (passkeyId: string) => `revoke:${passkeyId}`;
12
12
  export const APPROVALS_OFF_PRESENCE = "approvals-off";
13
+ export const NO_APPROVALS_ON_PRESENCE = "no-approvals-on";
13
14
  // Writing a workspace's standing rules as Saad.
14
15
  export const rulesPresence = (workspaceId: string) => `rules:${workspaceId}`;
15
16
 
@@ -38,6 +39,8 @@ export function presenceBinding(b: {
38
39
  return "enroll";
39
40
  case "approvals-off":
40
41
  return APPROVALS_OFF_PRESENCE;
42
+ case "no-approvals-on":
43
+ return NO_APPROVALS_ON_PRESENCE;
41
44
  case "rules":
42
45
  if (!b.workspaceId) throw new PresenceError("Which workspace?");
43
46
  return rulesPresence(b.workspaceId);
@@ -25,7 +25,11 @@ import { diffParts } from "./diff-parts";
25
25
  import { countFailure, escalate } from "./escalate";
26
26
  import { checkGate, holdingEscalations } from "./gates";
27
27
  import { reviewWanted } from "./held-review";
28
- import { mergeApprovalsOn } from "./merge-approvals";
28
+ import { mergeApprovalsOn, mergeApprovalsSetting } from "./merge-approvals";
29
+ import {
30
+ noApprovalsOn,
31
+ recordAutoApprovalOnce,
32
+ } from "../approvals/no-approvals";
29
33
  import {
30
34
  baseReviewSkill,
31
35
  checkout,
@@ -99,6 +103,13 @@ async function reviewJob(
99
103
  const judged = target.refs.base_branch;
100
104
  const base = await fetchRefs(repo, target.refs);
101
105
  const diff = await fullDiff(repo, base, sha);
106
+ if (diff.length > DIFF_CAP && mergeApprovalsSetting() && noApprovalsOn())
107
+ recordAutoApprovalOnce({
108
+ source: "merge-approval",
109
+ workspaceId,
110
+ subject: `PR #${pr.number} at ${short(sha)} is reviewed in parts`,
111
+ detail: `its diff is ${diff.length} characters`,
112
+ });
102
113
  const parts =
103
114
  diff.length <= DIFF_CAP
104
115
  ? [diff]
@@ -36,7 +36,11 @@ import { latestApproval, refundApproval, spendApproval } from "./ask-approvals";
36
36
  import { workSubject } from "./asks";
37
37
  import { carryApproval } from "./approval-carry";
38
38
  import { heldVerdict } from "./held";
39
- import { mergeApprovalsOn } from "./merge-approvals";
39
+ import { mergeApprovalsOn, mergeApprovalsSetting } from "./merge-approvals";
40
+ import {
41
+ noApprovalsOn,
42
+ recordAutoApprovalOnce,
43
+ } from "../approvals/no-approvals";
40
44
  import { isPaused } from "./pause";
41
45
  import { commitTime, fetchRefs, repoOf } from "./repo";
42
46
  import { repoHasCI } from "./repo-ci";
@@ -214,6 +218,16 @@ export async function gateVerdict(
214
218
  const base = await fetchRefs(repo, work.refs);
215
219
  const files = await changedFiles(repo, base, sha);
216
220
  const sensitive = mergeApprovalsOn() ? sensitiveFiles(files) : [];
221
+ if (mergeApprovalsSetting() && noApprovalsOn()) {
222
+ const held = sensitiveFiles(files);
223
+ if (held.length)
224
+ recordAutoApprovalOnce({
225
+ source: "merge-approval",
226
+ workspaceId,
227
+ subject: `PR #${pr.number} at ${short(sha)} goes through the gates`,
228
+ detail: `touches ${held.map((s) => s.path).join(", ")}`,
229
+ });
230
+ }
217
231
  if (sensitive.length) {
218
232
  const what = sensitive.map((s) => `${s.path} (${s.why})`).join(", ");
219
233
  const why = `PR #${pr.number} at ${short(sha)} touches ${what}`;
@@ -2,7 +2,7 @@ import { tailscaleStatus, type TailscaleState } from "@/lib/tailscale";
2
2
  import { networkSetting, networkSettingLocked } from "./network-settings";
3
3
  import { reachableAt, type Reach } from "./reach";
4
4
  import { connectStatus, type ConnectStatus } from "@/lib/connect/serve";
5
- import { mergeApprovalsOn } from "@/lib/orchestrator/merge-approvals";
5
+ import { mergeApprovalsSetting } from "@/lib/orchestrator/merge-approvals";
6
6
 
7
7
  export interface NetworkState {
8
8
  lan: { on: boolean; locked: boolean };
@@ -24,7 +24,7 @@ export async function networkState(): Promise<NetworkState> {
24
24
  tailscale: await tailscaleStatus(),
25
25
  reach: await reachableAt(),
26
26
  connect: connectStatus(),
27
- mergeApprovals: { on: mergeApprovalsOn(), locked: false },
27
+ mergeApprovals: { on: mergeApprovalsSetting(), locked: false },
28
28
  };
29
29
  }
30
30
 
@@ -42,6 +42,7 @@ export type PresencePurpose =
42
42
  | "enroll"
43
43
  | "revoke"
44
44
  | "approvals-off"
45
+ | "no-approvals-on"
45
46
  | "rules";
46
47
 
47
48
  export class PresenceError extends Error {
@@ -6,6 +6,7 @@ export const SETTINGS_PARAM = "settings";
6
6
 
7
7
  export const SETTINGS_SECTIONS = [
8
8
  "merging",
9
+ "approvals",
9
10
  "workspaces",
10
11
  "models",
11
12
  "devices",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.551",
3
+ "version": "0.3.553",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"