@saadnvd1/agent-os 0.3.457 → 0.3.459

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 (49) hide show
  1. package/README.md +30 -5
  2. package/app/api/fresh-start/route.ts +19 -0
  3. package/app/api/workspaces/[id]/orchestrator/rules/[ruleId]/route.ts +60 -0
  4. package/app/api/workspaces/[id]/orchestrator/rules/presence.ts +29 -0
  5. package/app/api/workspaces/[id]/orchestrator/rules/route.ts +36 -0
  6. package/app/globals.css +10 -0
  7. package/components/Chat/ChatPanel.tsx +87 -4
  8. package/components/Chat/FindBar.tsx +113 -0
  9. package/components/Chat/Tools.tsx +1 -1
  10. package/components/Chat/find-highlight.test.ts +56 -0
  11. package/components/Chat/find-highlight.ts +78 -0
  12. package/components/Chat/rowState.tsx +44 -4
  13. package/components/Chat/stick-to-bottom.ts +7 -1
  14. package/components/Chat/useFind.ts +253 -0
  15. package/components/Pane/MobileTabBar.tsx +18 -0
  16. package/components/Pane/index.tsx +6 -0
  17. package/components/Workspaces/FreshStartForm.tsx +128 -0
  18. package/components/Workspaces/RulesForm.tsx +166 -0
  19. package/components/Workspaces/WorkspaceSettings.tsx +4 -0
  20. package/components/Workspaces/fresh-start.ts +19 -0
  21. package/data/chat/useChat.ts +29 -0
  22. package/data/fresh-start/index.ts +34 -0
  23. package/data/orchestrators/rules.ts +80 -0
  24. package/data/push/topics.ts +4 -0
  25. package/lib/chat/demo.ts +1 -0
  26. package/lib/chat/events.ts +7 -1
  27. package/lib/chat/find.ts +278 -0
  28. package/lib/chat/refresh-limits.ts +5 -0
  29. package/lib/chat/refresh.ts +82 -0
  30. package/lib/chat/runner.ts +11 -2
  31. package/lib/chat/search.ts +117 -0
  32. package/lib/chat/socket.ts +12 -0
  33. package/lib/chat/worker/extras.ts +11 -1
  34. package/lib/chat/worker/host.ts +70 -5
  35. package/lib/db/changes.ts +3 -0
  36. package/lib/db/migrations.ts +37 -0
  37. package/lib/db/types.ts +4 -0
  38. package/lib/orchestrator/brief.ts +14 -3
  39. package/lib/orchestrator/digest.ts +339 -0
  40. package/lib/orchestrator/presence-binding.ts +5 -0
  41. package/lib/orchestrator/rules.ts +124 -0
  42. package/lib/orchestrator/serve.ts +11 -0
  43. package/lib/orchestrator/tool-names.ts +2 -0
  44. package/lib/orchestrator/tool-schemas.ts +10 -0
  45. package/lib/orchestrator/tools.ts +4 -0
  46. package/lib/orchestrator/worker.ts +5 -0
  47. package/lib/security/presence.ts +2 -1
  48. package/package.json +1 -1
  49. package/styles/themes.css +5 -0
@@ -0,0 +1,339 @@
1
+ /**
2
+ * What an orchestrator knows when it starts: the workspace's state read
3
+ * straight from the database, no model involved. It goes in the brief on
4
+ * every start, so a fresh conversation (a new orchestrator, or one started
5
+ * over at its context threshold) loses nothing that matters. Sections run
6
+ * most important first and the whole stays under a token budget; ordering
7
+ * is by id, so the same database gives the same text.
8
+ */
9
+
10
+ import { db } from "../db";
11
+ import { openAsks } from "./asks";
12
+ import { listRules } from "./rules";
13
+ import { shortId } from "./describe";
14
+ import { untrusted } from "./untrusted";
15
+
16
+ // About four characters a token: close enough to cap a prompt.
17
+ export const CHARS_PER_TOKEN = 4;
18
+ export const DIGEST_TOKENS = 4000;
19
+ const LINE_CAP = 300;
20
+ const NOTES_SCANNED = 200;
21
+ // An external PR gated this recently is still worth a line.
22
+ const EXTERNAL_DAYS = 2;
23
+
24
+ // One line, cut to length. A cut that leaves a fence around another
25
+ // session's text open closes it, so what follows isn't read as inside it.
26
+ const clip = (text: string, cap = LINE_CAP) => {
27
+ const one = text.replace(/\s+/g, " ").trim();
28
+ if (one.length <= cap) return one;
29
+ const cut = `${one.slice(0, cap - 3)}...`;
30
+ const opens = cut.match(/<untrusted[\s>]/g)?.length ?? 0;
31
+ const closes = cut.match(/<\/untrusted>/g)?.length ?? 0;
32
+ return opens > closes ? `${cut}</untrusted>` : cut;
33
+ };
34
+
35
+ const sha7 = (sha: string | null) => (sha ? sha.slice(0, 7) : "?");
36
+
37
+ interface Section {
38
+ title: string;
39
+ lines: string[];
40
+ // Shown when the section is empty; omitted otherwise.
41
+ empty?: string;
42
+ // How to see what didn't fit.
43
+ more: string;
44
+ }
45
+
46
+ interface TaskRow {
47
+ id: string;
48
+ name: string;
49
+ project: string | null;
50
+ pr_number: number | null;
51
+ pr_status: string | null;
52
+ view: string;
53
+ armed_at: string | null;
54
+ }
55
+
56
+ interface CheckRow {
57
+ session_id: string;
58
+ sha: string;
59
+ kind: string;
60
+ status: string;
61
+ }
62
+
63
+ interface FailureRow {
64
+ session_id: string;
65
+ gate: string;
66
+ count: number;
67
+ escalated_at: string | null;
68
+ }
69
+
70
+ function checksBySession(workspaceId: string): Map<string, CheckRow[]> {
71
+ // The newest verdict of each kind per session or PR.
72
+ const rows = db
73
+ .prepare(
74
+ `SELECT c.session_id, c.sha, c.kind, c.status FROM orchestrator_checks c
75
+ WHERE c.workspace_id = ? AND c.id = (
76
+ SELECT MAX(id) FROM orchestrator_checks
77
+ WHERE workspace_id = c.workspace_id AND session_id = c.session_id
78
+ AND kind = c.kind)
79
+ ORDER BY c.session_id, c.kind`
80
+ )
81
+ .all(workspaceId) as CheckRow[];
82
+ const by = new Map<string, CheckRow[]>();
83
+ for (const r of rows)
84
+ by.set(r.session_id, [...(by.get(r.session_id) ?? []), r]);
85
+ return by;
86
+ }
87
+
88
+ function failuresBySession(workspaceId: string): Map<string, FailureRow[]> {
89
+ const rows = db
90
+ .prepare(
91
+ `SELECT session_id, gate, count, escalated_at FROM orchestrator_gate_failures
92
+ WHERE workspace_id = ? AND count > 0 ORDER BY session_id, gate`
93
+ )
94
+ .all(workspaceId) as FailureRow[];
95
+ const by = new Map<string, FailureRow[]>();
96
+ for (const r of rows)
97
+ by.set(r.session_id, [...(by.get(r.session_id) ?? []), r]);
98
+ return by;
99
+ }
100
+
101
+ function gateState(
102
+ checks: CheckRow[] = [],
103
+ failures: FailureRow[] = []
104
+ ): string {
105
+ const parts: string[] = [];
106
+ if (checks.length)
107
+ parts.push(
108
+ checks.map((c) => `${c.kind} ${c.status}@${sha7(c.sha)}`).join(", ")
109
+ );
110
+ const failed = failures.map(
111
+ (f) =>
112
+ `${f.gate} failed ${f.count}x${f.escalated_at ? " (Saad's now)" : ""}`
113
+ );
114
+ if (failed.length) parts.push(failed.join(", "));
115
+ return parts.join("; ");
116
+ }
117
+
118
+ function rulesSection(workspaceId: string): Section {
119
+ return {
120
+ title: "Standing rules",
121
+ // Saad's in plain text; its own fenced, since it writes them from what
122
+ // it read: a line planted there can't pass for his word.
123
+ lines: listRules(workspaceId).map((r) =>
124
+ r.added_by === "saad"
125
+ ? `- [rule ${r.id}, from Saad] ${clip(r.text)}`
126
+ : `- [rule ${r.id}, kept by you] ${untrusted("a rule you kept", clip(r.text))}`
127
+ ),
128
+ empty: "- None. Keep one with rule_add.",
129
+ more: "",
130
+ };
131
+ }
132
+
133
+ function asksSection(workspaceId: string): Section {
134
+ return {
135
+ title: "Open asks (waiting on Saad)",
136
+ lines: openAsks(workspaceId).map((a) => {
137
+ const on = a.subject.startsWith("task:")
138
+ ? ` | task ${shortId(a.subject.slice(5))}`
139
+ : a.subject.startsWith("pr:")
140
+ ? ` | ${a.subject.slice(3)}`
141
+ : "";
142
+ const at = a.sha ? `@${sha7(a.sha)}` : "";
143
+ return `- [ask ${a.id}] ${a.kind}: ${untrusted("ask title", clip(a.title, 160))}${on}${at}`;
144
+ }),
145
+ empty: "- None.",
146
+ more: "",
147
+ };
148
+ }
149
+
150
+ function tasksSection(
151
+ workspaceId: string,
152
+ checks: Map<string, CheckRow[]>,
153
+ failures: Map<string, FailureRow[]>
154
+ ): Section {
155
+ const tasks = db
156
+ .prepare(
157
+ `SELECT s.id, s.name, p.name AS project, s.pr_number, s.pr_status, s.view,
158
+ s.merge_armed_at AS armed_at
159
+ FROM sessions s JOIN projects p ON p.id = s.project_id
160
+ WHERE p.workspace_id = ? AND s.role IS NULL AND s.archived_at IS NULL
161
+ AND s.task_status = 'running'
162
+ ORDER BY s.created_at, s.id`
163
+ )
164
+ .all(workspaceId) as TaskRow[];
165
+ return {
166
+ title: "Running tasks",
167
+ lines: tasks.map((t) => {
168
+ const pr = t.pr_number
169
+ ? `PR #${t.pr_number} ${t.pr_status ?? "open"}`
170
+ : "no PR yet";
171
+ const gates = gateState(checks.get(t.id), failures.get(t.id));
172
+ const arm = t.armed_at ? " | armed to merge" : "";
173
+ return `- ${clip(t.name, 80)} (${t.project ?? "?"}, ${t.view}, id ${shortId(t.id)}): ${pr}${arm}${gates ? ` | ${gates}` : ""}`;
174
+ }),
175
+ empty: "- None.",
176
+ more: "the sessions tool",
177
+ };
178
+ }
179
+
180
+ function externalSection(
181
+ workspaceId: string,
182
+ checks: Map<string, CheckRow[]>,
183
+ failures: Map<string, FailureRow[]>
184
+ ): Section {
185
+ const recent = db
186
+ .prepare(
187
+ `SELECT session_id FROM orchestrator_checks
188
+ WHERE workspace_id = ? AND session_id LIKE 'pr:%'
189
+ AND created_at >= datetime('now', ?)
190
+ GROUP BY session_id ORDER BY session_id`
191
+ )
192
+ .all(workspaceId, `-${EXTERNAL_DAYS} days`) as { session_id: string }[];
193
+ return {
194
+ title: `External PRs gated in the last ${EXTERNAL_DAYS} days (may have merged since)`,
195
+ lines: recent.map(
196
+ ({ session_id }) =>
197
+ `- ${session_id.slice(3)} | ${gateState(checks.get(session_id), failures.get(session_id))}`
198
+ ),
199
+ more: "review on the PR",
200
+ };
201
+ }
202
+
203
+ function queueSection(workspaceId: string): Section {
204
+ const rows = db
205
+ .prepare(
206
+ `SELECT q.id, q.name, q.prompt, q.status, q.after_task, q.after_any,
207
+ p.name AS project, a.name AS after_name
208
+ FROM task_queue q JOIN projects p ON p.id = q.project_id
209
+ LEFT JOIN sessions a ON a.id = q.after_task
210
+ WHERE p.workspace_id = ? AND q.status IN ('queued', 'starting', 'failed')
211
+ ORDER BY q.position`
212
+ )
213
+ .all(workspaceId) as {
214
+ id: string;
215
+ name: string | null;
216
+ prompt: string;
217
+ status: string;
218
+ after_task: string | null;
219
+ after_any: string | null;
220
+ project: string;
221
+ after_name: string | null;
222
+ }[];
223
+ return {
224
+ title: "Queued tasks, in line order",
225
+ lines: rows.map((r, i) => {
226
+ const after = r.after_task
227
+ ? ` | after ${r.after_name ?? shortId(r.after_task)}`
228
+ : r.after_any
229
+ ? " | after any running task"
230
+ : "";
231
+ const status = r.status === "queued" ? "" : ` | ${r.status}`;
232
+ return `- ${i + 1}. ${clip(r.name ?? r.prompt, 80)} (${r.project}, id ${shortId(r.id)})${after}${status}`;
233
+ }),
234
+ more: "the sessions tool",
235
+ };
236
+ }
237
+
238
+ function schedulesSection(workspaceId: string): Section {
239
+ const rows = db
240
+ .prepare(
241
+ `SELECT name, cron, timezone, prompt, enabled FROM schedules
242
+ WHERE workspace_id = ? AND archived_at IS NULL ORDER BY created_at, id`
243
+ )
244
+ .all(workspaceId) as {
245
+ name: string;
246
+ cron: string;
247
+ timezone: string;
248
+ prompt: string;
249
+ enabled: number;
250
+ }[];
251
+ return {
252
+ title: "Schedules",
253
+ lines: rows.map(
254
+ (s) =>
255
+ `- "${clip(s.name, 60)}" ${s.cron} ${s.timezone}${s.enabled ? "" : " (off)"}: ${clip(s.prompt, 160)}`
256
+ ),
257
+ more: "`aos schedules`",
258
+ };
259
+ }
260
+
261
+ function notesSection(workspaceId: string): Section {
262
+ const rows = db
263
+ .prepare(
264
+ `SELECT kind, text, created_at FROM orchestrator_notes
265
+ WHERE workspace_id = ? ORDER BY id DESC LIMIT ?`
266
+ )
267
+ .all(workspaceId, NOTES_SCANNED) as {
268
+ kind: string;
269
+ text: string;
270
+ created_at: string;
271
+ }[];
272
+ return {
273
+ title: "Decision log, newest first",
274
+ lines: rows.map(
275
+ (n) =>
276
+ `- ${n.created_at.slice(0, 16)} UTC${n.kind === "note" ? "" : ` ${n.kind}`}: ${untrusted(n.kind === "note" ? "your note" : `${n.kind} log line`, clip(n.text))}`
277
+ ),
278
+ more: "",
279
+ };
280
+ }
281
+
282
+ // Sections in order until the budget runs out; a section cut short says
283
+ // how many lines it left out and where to find them.
284
+ export function renderDigest(
285
+ sections: Section[],
286
+ budgetTokens: number
287
+ ): string {
288
+ const budget = budgetTokens * CHARS_PER_TOKEN;
289
+ const out: string[] = [];
290
+ let used = 0;
291
+ const fits = (text: string) => used + text.length + 1 <= budget;
292
+ const put = (text: string) => {
293
+ out.push(text);
294
+ used += text.length + 1;
295
+ };
296
+ for (const s of sections) {
297
+ const lines = s.lines.length ? s.lines : s.empty ? [s.empty] : [];
298
+ if (!lines.length) continue;
299
+ const head = `\n### ${s.title}`;
300
+ if (!fits(`${head}\n${lines[0]}`)) break;
301
+ put(head);
302
+ let shown = 0;
303
+ for (const line of lines) {
304
+ if (!fits(line)) break;
305
+ put(line);
306
+ shown++;
307
+ }
308
+ const left = lines.length - shown;
309
+ if (left) {
310
+ const more = `- ...and ${left} more${s.more ? ` (see ${s.more})` : ""}.`;
311
+ if (fits(more)) put(more);
312
+ break;
313
+ }
314
+ }
315
+ return out.join("\n").trim();
316
+ }
317
+
318
+ export function buildDigest(
319
+ workspaceId: string,
320
+ budgetTokens = DIGEST_TOKENS
321
+ ): string {
322
+ const checks = checksBySession(workspaceId);
323
+ const failures = failuresBySession(workspaceId);
324
+ const head =
325
+ "## Where things stand (read from AgentOS's database as you started)";
326
+ const body = renderDigest(
327
+ [
328
+ rulesSection(workspaceId),
329
+ asksSection(workspaceId),
330
+ tasksSection(workspaceId, checks, failures),
331
+ externalSection(workspaceId, checks, failures),
332
+ queueSection(workspaceId),
333
+ schedulesSection(workspaceId),
334
+ notesSection(workspaceId),
335
+ ],
336
+ budgetTokens - Math.ceil((head.length + 2) / CHARS_PER_TOKEN)
337
+ );
338
+ return `${head}\n\n${body}`;
339
+ }
@@ -10,6 +10,8 @@ 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
+ // Writing a workspace's standing rules as Saad.
14
+ export const rulesPresence = (workspaceId: string) => `rules:${workspaceId}`;
13
15
 
14
16
  export function presenceBinding(b: {
15
17
  purpose?: PresencePurpose;
@@ -36,6 +38,9 @@ export function presenceBinding(b: {
36
38
  return "enroll";
37
39
  case "approvals-off":
38
40
  return APPROVALS_OFF_PRESENCE;
41
+ case "rules":
42
+ if (!b.workspaceId) throw new PresenceError("Which workspace?");
43
+ return rulesPresence(b.workspaceId);
39
44
  case "revoke":
40
45
  if (!b.passkeyId) throw new PresenceError("Which passkey?");
41
46
  return revokePresence(b.passkeyId);
@@ -0,0 +1,124 @@
1
+ /**
2
+ * A workspace's standing rules: what its orchestrator must keep knowing
3
+ * across fresh starts ("never merge #111", "briefs must not name
4
+ * competitors"). The orchestrator writes them with rule_add and rule_remove,
5
+ * Saad in Settings → Workspaces, and every start reads them into its brief.
6
+ * They restrict and inform; none can approve anything. A removed rule stays
7
+ * in the table, marked.
8
+ */
9
+
10
+ import { db } from "../db";
11
+ import { redact } from "./untrusted";
12
+
13
+ export type RuleAuthor = "orchestrator" | "saad";
14
+
15
+ export interface RuleRow {
16
+ id: number;
17
+ workspace_id: string;
18
+ text: string;
19
+ added_by: RuleAuthor;
20
+ created_at: string;
21
+ updated_at: string;
22
+ removed_at: string | null;
23
+ removed_by: RuleAuthor | null;
24
+ }
25
+
26
+ // Kept small: every one is in every start's prompt.
27
+ export const MAX_RULES = 25;
28
+ export const MAX_RULE_CHARS = 300;
29
+
30
+ function clean(text: string): string {
31
+ const body = redact(text).replace(/\s+/g, " ").trim();
32
+ if (!body) throw new Error("The rule is empty");
33
+ if (body.length > MAX_RULE_CHARS)
34
+ throw new Error(`A rule is at most ${MAX_RULE_CHARS} characters`);
35
+ return body;
36
+ }
37
+
38
+ export function listRules(workspaceId: string): RuleRow[] {
39
+ return db
40
+ .prepare(
41
+ `SELECT * FROM orchestrator_rules
42
+ WHERE workspace_id = ? AND removed_at IS NULL ORDER BY id`
43
+ )
44
+ .all(workspaceId) as RuleRow[];
45
+ }
46
+
47
+ function getRule(workspaceId: string, id: number): RuleRow | null {
48
+ return (
49
+ (db
50
+ .prepare(
51
+ `SELECT * FROM orchestrator_rules
52
+ WHERE id = ? AND workspace_id = ? AND removed_at IS NULL`
53
+ )
54
+ .get(id, workspaceId) as RuleRow | undefined) ?? null
55
+ );
56
+ }
57
+
58
+ // The same text twice is the rule already there.
59
+ export function addRule(
60
+ workspaceId: string,
61
+ text: string,
62
+ by: RuleAuthor
63
+ ): RuleRow {
64
+ const body = clean(text);
65
+ return db.transaction(() => {
66
+ const rules = listRules(workspaceId);
67
+ const same = rules.find((r) => r.text === body);
68
+ // Saad writing one the orchestrator kept makes it his.
69
+ if (same && by === "saad" && same.added_by !== "saad") {
70
+ db.prepare(
71
+ `UPDATE orchestrator_rules SET added_by = 'saad', updated_at = datetime('now')
72
+ WHERE id = ?`
73
+ ).run(same.id);
74
+ return getRule(workspaceId, same.id)!;
75
+ }
76
+ if (same) return same;
77
+ if (rules.length >= MAX_RULES)
78
+ throw new Error(
79
+ `This workspace has ${MAX_RULES} rules already; remove one first`
80
+ );
81
+ const { lastInsertRowid } = db
82
+ .prepare(
83
+ `INSERT INTO orchestrator_rules (workspace_id, text, added_by) VALUES (?, ?, ?)`
84
+ )
85
+ .run(workspaceId, body, by);
86
+ return getRule(workspaceId, Number(lastInsertRowid))!;
87
+ })();
88
+ }
89
+
90
+ export function updateRule(
91
+ workspaceId: string,
92
+ id: number,
93
+ text: string
94
+ ): RuleRow {
95
+ const body = clean(text);
96
+ const { changes } = db
97
+ .prepare(
98
+ `UPDATE orchestrator_rules SET text = ?, updated_at = datetime('now')
99
+ WHERE id = ? AND workspace_id = ? AND removed_at IS NULL`
100
+ )
101
+ .run(body, id, workspaceId);
102
+ if (!changes) throw new Error(`No rule ${id} in this workspace`);
103
+ return getRule(workspaceId, id)!;
104
+ }
105
+
106
+ export function removeRule(
107
+ workspaceId: string,
108
+ id: number,
109
+ by: RuleAuthor
110
+ ): RuleRow {
111
+ const rule = getRule(workspaceId, id);
112
+ if (!rule) throw new Error(`No rule ${id} in this workspace`);
113
+ // Saad's restrictions hold until he lifts them: text the orchestrator
114
+ // read can't talk it into removing one.
115
+ if (by === "orchestrator" && rule.added_by === "saad")
116
+ throw new Error(
117
+ `Rule ${id} is Saad's: only he removes it, in Settings → Workspaces`
118
+ );
119
+ db.prepare(
120
+ `UPDATE orchestrator_rules SET removed_at = datetime('now'), removed_by = ?
121
+ WHERE id = ?`
122
+ ).run(by, id);
123
+ return rule;
124
+ }
@@ -15,6 +15,7 @@ import { sessionFacts } from "./facts";
15
15
  import { describeExternal, externalSessions } from "./external-sessions";
16
16
  import { resolveStaleAsks } from "./ask-settle";
17
17
  import { addNote } from "./notes";
18
+ import { addRule, removeRule } from "./rules";
18
19
  import { askSaad } from "./ask-tool";
19
20
  import { listOrchestratorPeers, messageOrchestrator } from "./cross";
20
21
  import { PAUSED_REFUSAL } from "./pause";
@@ -122,6 +123,16 @@ export async function runTool(
122
123
  case "note":
123
124
  addNote(w, p(tool, raw).text);
124
125
  return "Noted.";
126
+ case "rule_add": {
127
+ const rule = addRule(w, p(tool, raw).text, "orchestrator");
128
+ addNote(w, `Standing rule ${rule.id}: ${rule.text}`);
129
+ return `Rule ${rule.id} kept: it's in your brief on every start.`;
130
+ }
131
+ case "rule_remove": {
132
+ const rule = removeRule(w, p(tool, raw).id, "orchestrator");
133
+ addNote(w, `Removed standing rule ${rule.id}: ${rule.text}`);
134
+ return `Rule ${rule.id} removed.`;
135
+ }
125
136
  case "review": {
126
137
  const a = p(tool, raw);
127
138
  return reviewTarget(w, a.target, { fresh: a.fresh });
@@ -22,6 +22,8 @@ export const TOOL_NAMES = {
22
22
  stop: name("stop"),
23
23
  done: name("done"),
24
24
  note: name("note"),
25
+ rule_add: name("rule_add"),
26
+ rule_remove: name("rule_remove"),
25
27
  review: name("review"),
26
28
  sign_off: name("sign_off"),
27
29
  arm: name("arm"),
@@ -4,6 +4,7 @@
4
4
  import { z } from "zod";
5
5
  import { BRANCH_NAME } from "../git";
6
6
  import { RAISED_KINDS } from "./asks";
7
+ import { MAX_RULE_CHARS } from "./rules";
7
8
 
8
9
  const ref = (what: string) => z.string().trim().min(1).max(300).describe(what);
9
10
  const session = ref("Session name, project/name, or id from sessions");
@@ -100,6 +101,15 @@ export const TOOL_SHAPES = {
100
101
  stop: { session },
101
102
  done: { session },
102
103
  note: { text: z.string().trim().min(1).max(2000) },
104
+ rule_add: {
105
+ text: z
106
+ .string()
107
+ .trim()
108
+ .min(1)
109
+ .max(MAX_RULE_CHARS)
110
+ .describe("The rule, one sentence"),
111
+ },
112
+ rule_remove: { id: z.number().int().positive().describe("The rule's id") },
103
113
  review: {
104
114
  target: ref("The task, or its PR (#12 or URL)"),
105
115
  fresh: z
@@ -72,6 +72,10 @@ const DESCRIPTIONS: Record<ToolName, string> = {
72
72
  stop: "Stop a session's agent, keeping its worktree and branch.",
73
73
  done: "Finish a session whose work is complete: a task's open PR merges through the sign-off gates first (refused naming the failing gate), then its agent stops, its worktree is removed if nothing would be lost, and it's archived. Not a rejection.",
74
74
  note: "Add a line to this workspace's decision log; it shows in your chat.",
75
+ rule_add:
76
+ 'Keep a standing rule for this workspace, in your brief on every start from now on ("never merge #111", "briefs must not name competitors"). It restricts or informs; it can never approve anything.',
77
+ rule_remove:
78
+ "Remove a standing rule you kept, by its id, only when Saad says so or it no longer applies (the PR it names merged or closed). Saad's rules are his to remove.",
75
79
  review:
76
80
  "Start an independent read-only review of a PR at its exact head commit, or read the stored verdict for that commit. Takes a task, or any open PR in a workspace repository by #N, owner/repo#N or URL, even one no task owns.",
77
81
  sign_off:
@@ -9,6 +9,7 @@ import {
9
9
  orchestratorToolsDigest,
10
10
  } from "./tools";
11
11
  import type { RoleExtras } from "../chat/worker/extras";
12
+ import { refreshSettings } from "../chat/refresh";
12
13
 
13
14
  // What an orchestrator's chat worker starts with on top of any chat: its
14
15
  // brief, its tools, and permissions fixed by its role.
@@ -29,5 +30,9 @@ export async function orchestratorExtras(
29
30
  },
30
31
  ...ORCHESTRATOR_PERMISSIONS,
31
32
  toolsDigest: orchestratorToolsDigest(),
33
+ refresh: {
34
+ atTokens: refreshSettings().orchestratorTokens,
35
+ role: "orchestrator",
36
+ },
32
37
  };
33
38
  }
@@ -41,7 +41,8 @@ export type PresencePurpose =
41
41
  | "resume"
42
42
  | "enroll"
43
43
  | "revoke"
44
- | "approvals-off";
44
+ | "approvals-off"
45
+ | "rules";
45
46
 
46
47
  export class PresenceError extends Error {
47
48
  constructor(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.457",
3
+ "version": "0.3.459",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
package/styles/themes.css CHANGED
@@ -24,6 +24,10 @@
24
24
  Cool white with a violet accent
25
25
  =========================================================================== */
26
26
  :root {
27
+ /* Find in a conversation: every match, and the one it's on. */
28
+ --find-match: 48 96% 53% / 0.45;
29
+ --find-current: 25 95% 53%;
30
+ --find-current-foreground: 0 0% 0%;
27
31
  --background: 240 10% 95.5%;
28
32
  --foreground: 240 12% 14%;
29
33
  --card: 0 0% 100%;
@@ -287,6 +291,7 @@
287
291
  Blue-black with a violet accent
288
292
  =========================================================================== */
289
293
  .dark {
294
+ --find-match: 48 96% 53% / 0.35;
290
295
  --background: 240 18% 3.5%;
291
296
  --foreground: 240 10% 93%;
292
297
  --card: 240 14% 6%;