@saadnvd1/agent-os 0.3.550 → 0.3.552
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.
- package/README.md +1 -0
- package/app/api/approvals/hook/route.ts +34 -0
- package/app/api/approvals/route.ts +52 -0
- package/app/api/devices/network/route.ts +2 -2
- package/app/api/sessions/[id]/summarize/route.ts +2 -1
- package/app/page.tsx +11 -2
- package/components/Approvals/NoApprovalsBadge.tsx +22 -0
- package/components/Chat/AccessPicker.tsx +13 -1
- package/components/SessionList/SidebarHeader.tsx +2 -0
- package/components/Settings/NoApprovalsSection.test.ts +115 -0
- package/components/Settings/NoApprovalsSection.tsx +103 -0
- package/components/Settings/SettingsDialog.test.ts +3 -0
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/data/approvals/index.ts +56 -0
- package/lib/approvals/enable.ts +64 -0
- package/lib/approvals/no-approvals.ts +119 -0
- package/lib/chat/events.ts +2 -0
- package/lib/chat/settings.ts +17 -2
- package/lib/chat/worker/host.ts +49 -4
- package/lib/db/migrations.ts +18 -0
- package/lib/done/index.ts +3 -0
- package/lib/models/plan-step.ts +36 -7
- package/lib/needs-you.ts +3 -1
- package/lib/orchestrator/asks.ts +42 -5
- package/lib/orchestrator/brakes.ts +11 -0
- package/lib/orchestrator/brief.ts +7 -1
- package/lib/orchestrator/merge-approvals.ts +10 -1
- package/lib/orchestrator/presence-binding.ts +3 -0
- package/lib/orchestrator/review.ts +12 -1
- package/lib/orchestrator/signoff.ts +15 -1
- package/lib/security/network-state.ts +2 -2
- package/lib/security/presence.ts +1 -0
- package/lib/settings-url.ts +1 -0
- package/lib/sidebar/shelves.ts +3 -0
- package/package.json +1 -1
- 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
|
+
}
|
package/lib/chat/events.ts
CHANGED
|
@@ -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
|
package/lib/chat/settings.ts
CHANGED
|
@@ -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
|
+
}
|
package/lib/chat/worker/host.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
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":
|
package/lib/db/migrations.ts
CHANGED
|
@@ -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).
|
package/lib/done/index.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { db, queries, type Session } from "../db";
|
|
12
12
|
import { endSessionAgent } from "../sessions/end";
|
|
13
|
+
import { clearQueue } from "../chat/queued";
|
|
13
14
|
import { getProject } from "../projects";
|
|
14
15
|
import { mergeSettled, type TaskPR } from "../tasks";
|
|
15
16
|
import { syncTaskCardInBackground } from "../lumifyhub/task-cards";
|
|
@@ -61,6 +62,8 @@ export function getDoneTarget(id: string): Session {
|
|
|
61
62
|
|
|
62
63
|
async function stopAgent(s: Session): Promise<void> {
|
|
63
64
|
await endSessionAgent(s);
|
|
65
|
+
// Nothing is left to send them to; they'd only sit unread on a stopped agent.
|
|
66
|
+
clearQueue(s.id);
|
|
64
67
|
releasePorts(s.id);
|
|
65
68
|
// Never waited on: the row is read now, and Postgres can't hold this up.
|
|
66
69
|
void dropSessionDatabase(s.id);
|
package/lib/models/plan-step.ts
CHANGED
|
@@ -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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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(
|
|
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
|
}
|
package/lib/needs-you.ts
CHANGED
|
@@ -33,11 +33,13 @@ function latestChatItem(sessionId: string): ChatItem | null {
|
|
|
33
33
|
// is routine for it.
|
|
34
34
|
export function needsYou(
|
|
35
35
|
session: Pick<Session, "id" | "view" | "updated_at" | "last_seen_at"> &
|
|
36
|
-
Partial<Pick<Session, "role" | "workspace_id">>,
|
|
36
|
+
Partial<Pick<Session, "role" | "workspace_id" | "task_status">>,
|
|
37
37
|
chatState: ChatState | null
|
|
38
38
|
): boolean {
|
|
39
39
|
const seen = sqliteMs(session.last_seen_at);
|
|
40
40
|
if (session.view === "chat") {
|
|
41
|
+
// A finished task's agent is stopped: its last turn is history, not news.
|
|
42
|
+
if (session.task_status && session.task_status !== "running") return false;
|
|
41
43
|
if (chatState === "waiting") return true;
|
|
42
44
|
if (session.role === "orchestrator")
|
|
43
45
|
return openAskCount(session.workspace_id) > 0;
|
package/lib/orchestrator/asks.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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}`;
|