@saadnvd1/agent-os 0.3.817 → 0.3.821

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 (67) hide show
  1. package/app/api/client-config/route.test.ts +3 -1
  2. package/app/api/orchestrators/[workspaceId]/intake/route.test.ts +77 -0
  3. package/app/api/owner/route.test.ts +27 -0
  4. package/app/api/owner/route.ts +17 -0
  5. package/app/api/workspaces/[id]/orchestrator/rules/[ruleId]/route.ts +2 -2
  6. package/app/api/workspaces/[id]/orchestrator/rules/presence.ts +1 -1
  7. package/app/api/workspaces/[id]/orchestrator/rules/route.ts +2 -2
  8. package/app/api/workspaces/[id]/orchestrator/tools/route.ts +6 -1
  9. package/app/api/worktree-sweep/route.ts +2 -2
  10. package/components/Chat/EventRow.tsx +5 -2
  11. package/components/Chat/Messages.tsx +9 -2
  12. package/components/SessionList/statusDot.test.ts +1 -1
  13. package/components/Settings/OwnerNameRow.tsx +55 -0
  14. package/components/Settings/SetupSection.tsx +2 -0
  15. package/content/whats-new.json +16 -0
  16. package/data/client-config/queries.ts +3 -0
  17. package/data/owner/mutations.ts +19 -0
  18. package/lib/approvals/enable.ts +2 -1
  19. package/lib/artifacts/tools.ts +2 -1
  20. package/lib/away/brief.ts +2 -1
  21. package/lib/away/run.ts +5 -4
  22. package/lib/away/sweep.ts +2 -1
  23. package/lib/chat/runner.ts +2 -1
  24. package/lib/chat/turns.ts +1 -1
  25. package/lib/config/client-config-types.ts +5 -1
  26. package/lib/config/client-config.ts +2 -0
  27. package/lib/intake/accept.ts +58 -0
  28. package/lib/intake/intake.ts +44 -17
  29. package/lib/intake/message.ts +6 -2
  30. package/lib/learning-cards/analyze.ts +2 -1
  31. package/lib/models/plan-step.ts +2 -1
  32. package/lib/orchestrator/approval-carry.ts +2 -1
  33. package/lib/orchestrator/armed.ts +9 -8
  34. package/lib/orchestrator/ask-heads.ts +2 -1
  35. package/lib/orchestrator/ask-tool.ts +6 -5
  36. package/lib/orchestrator/asks.ts +6 -4
  37. package/lib/orchestrator/brakes.ts +3 -2
  38. package/lib/orchestrator/brief.ts +61 -50
  39. package/lib/orchestrator/cross.ts +2 -1
  40. package/lib/orchestrator/describe.ts +5 -2
  41. package/lib/orchestrator/digest.ts +4 -3
  42. package/lib/orchestrator/escalate.ts +7 -6
  43. package/lib/orchestrator/external-gates.ts +5 -4
  44. package/lib/orchestrator/held-asks.ts +2 -2
  45. package/lib/orchestrator/held.ts +3 -2
  46. package/lib/orchestrator/mcp-servers.ts +1 -1
  47. package/lib/orchestrator/pause.ts +4 -3
  48. package/lib/orchestrator/questions.ts +2 -1
  49. package/lib/orchestrator/recipe-tools.ts +5 -4
  50. package/lib/orchestrator/review.ts +4 -3
  51. package/lib/orchestrator/rules.ts +2 -1
  52. package/lib/orchestrator/serve.ts +1 -1
  53. package/lib/orchestrator/shell-guard.ts +4 -4
  54. package/lib/orchestrator/signoff.test-helpers.ts +1 -1
  55. package/lib/orchestrator/signoff.ts +10 -7
  56. package/lib/orchestrator/sweep-arm.ts +2 -1
  57. package/lib/orchestrator/tool-names.ts +1 -1
  58. package/lib/orchestrator/tool-schemas.ts +13 -13
  59. package/lib/orchestrator/tools.ts +21 -21
  60. package/lib/owner-label.ts +25 -0
  61. package/lib/owner.ts +21 -0
  62. package/lib/project-add/index.ts +2 -1
  63. package/lib/schedules/index.ts +2 -1
  64. package/lib/session-logs/gather.ts +3 -2
  65. package/lib/tasks/queue.ts +3 -1
  66. package/lib/tasks/start.ts +3 -1
  67. package/package.json +1 -1
@@ -22,6 +22,7 @@ import {
22
22
  type RecipeRun,
23
23
  } from "../recipes";
24
24
  import { outputsText } from "../recipes/result";
25
+ import { owner } from "../owner";
25
26
 
26
27
  const when = (iso: string | null) =>
27
28
  iso
@@ -38,7 +39,7 @@ function runLine(r: RecipeRun, recipe: Recipe): string {
38
39
  ? "timer"
39
40
  : r.trigger === "tool"
40
41
  ? "you"
41
- : "Saad";
42
+ : owner();
42
43
  return `#${r.seq} ${r.status} (${when(r.ended_at ?? r.started_at ?? r.created_at)}, by ${by})${pr}${text}`;
43
44
  }
44
45
 
@@ -61,7 +62,7 @@ export function recipesText(w: string, name?: string): string {
61
62
  return [
62
63
  `Recipe "${r.name}" (${r.kind}, ${r.view}, project ${projectName(w, r)})${r.model ? `, model ${r.model}` : ""}${r.max_minutes ? `, time limit ${r.max_minutes} min` : ""}.`,
63
64
  timer
64
- ? `Timer: ${timer.description} ${timer.timezone}${timer.enabled ? "" : " (off)"}, set by Saad.`
65
+ ? `Timer: ${timer.description} ${timer.timezone}${timer.enabled ? "" : " (off)"}, set by ${owner()}.`
65
66
  : "No timer.",
66
67
  `Brief:\n${r.brief}`,
67
68
  runs.length
@@ -126,7 +127,7 @@ export function saveRecipe(
126
127
  },
127
128
  orchestrator?.id ?? null
128
129
  );
129
- return `Saved recipe "${recipe.name}" (${recipe.kind}, ${project.name}). Run it with run_recipe; Saad sees it in Recipes and sets any timer.`;
130
+ return `Saved recipe "${recipe.name}" (${recipe.kind}, ${project.name}). Run it with run_recipe; ${owner()} sees it in Recipes and sets any timer.`;
130
131
  }
131
132
 
132
133
  export function updateRecipeTool(
@@ -155,5 +156,5 @@ export function updateRecipeTool(
155
156
  },
156
157
  orchestrator?.id ?? null
157
158
  );
158
- return `Updated recipe "${a.new_name ?? recipe.name}". Earlier versions of the brief are kept; Saad sees the change.`;
159
+ return `Updated recipe "${a.new_name ?? recipe.name}". Earlier versions of the brief are kept; ${owner()} sees the change.`;
159
160
  }
@@ -56,6 +56,7 @@ import { noteQuickReview } from "./quick";
56
56
  import { workspaceTask } from "./targets";
57
57
  import { untrusted } from "./untrusted";
58
58
  import { isFast } from "../project-tier/getProjectTier";
59
+ import { owner } from "../owner";
59
60
 
60
61
  const short = (sha: string) => sha.slice(0, 7);
61
62
  // The most diff one reviewer is given, and the most parts a change too big
@@ -135,7 +136,7 @@ async function reviewJob(
135
136
  ...row,
136
137
  kind: "review",
137
138
  status: "error",
138
- detail: `${why}; it's with Saad`,
139
+ detail: `${why}; it's with ${owner()}`,
139
140
  });
140
141
  }
141
142
  const files = await changedFiles(repo, base, sha);
@@ -247,7 +248,7 @@ function countVerdicts(
247
248
  }
248
249
  }
249
250
  return escalated.length
250
- ? `; the ${escalated.join(" and ")} gate is stuck in a loop (the loop guard), so it's with Saad now`
251
+ ? `; the ${escalated.join(" and ")} gate is stuck in a loop (the loop guard), so it's with ${owner()} now`
251
252
  : "";
252
253
  }
253
254
 
@@ -335,7 +336,7 @@ export async function review(
335
336
  const held = holdingEscalations(task.id);
336
337
  const pr = held.length ? await prFor(task, true) : null;
337
338
  if (held.length && !(pr?.head && reviewWanted(workspaceId, task.id, pr.head)))
338
- return `${task.name} is with Saad (${held.map((h) => h.gate).join(", ")}); no review needed from you.`;
339
+ return `${task.name} is with ${owner()} (${held.map((h) => h.gate).join(", ")}); no review needed from you.`;
339
340
  const there = pr ?? (await prFor(task, true));
340
341
  if (!there?.head) throw new Error(`${task.name} has no PR to review yet`);
341
342
  return reviewAt(workspaceId, taskReviewTarget(task, there), there.head, opts);
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { db } from "../db";
11
11
  import { redact } from "./untrusted";
12
+ import { owner } from "../owner";
12
13
 
13
14
  export type RuleAuthor = "orchestrator" | "saad";
14
15
 
@@ -114,7 +115,7 @@ export function removeRule(
114
115
  // read can't talk it into removing one.
115
116
  if (by === "orchestrator" && rule.added_by === "saad")
116
117
  throw new Error(
117
- `Rule ${id} is Saad's: only he removes it, in Settings → Workspaces`
118
+ `Rule ${id} is ${owner()}'s: only he removes it, in Settings → Workspaces`
118
119
  );
119
120
  db.prepare(
120
121
  `UPDATE orchestrator_rules SET removed_at = datetime('now'), removed_by = ?
@@ -320,7 +320,7 @@ export async function runTool(
320
320
  return await arm(w, p(tool, raw).task);
321
321
  case "disarm":
322
322
  return disarm(w, p(tool, raw).task);
323
- case "ask_saad":
323
+ case "ask_owner":
324
324
  return askSaad(w, p(tool, raw));
325
325
  case "question": {
326
326
  const a = p(tool, raw);
@@ -230,7 +230,7 @@ function aosRefusal(args: string[]): string | null {
230
230
  stop: T.stop,
231
231
  };
232
232
  if (sub === "sweep" && args.includes("--arm"))
233
- return `Refused: \`aos sweep --arm\` turns on removing files and databases, which is Saad's call: Health, Clean-up, Turn on. When he has said to, use ${T.sweep_arm} (after ${T.sweep_dry_run}). \`--dry-run\` and \`--disarm\` are fine.`;
233
+ return `Refused: \`aos sweep --arm\` turns on removing files and databases, which is the owner's call: Health, Clean-up, Turn on. When he has said to, use ${T.sweep_arm} (after ${T.sweep_dry_run}). \`--dry-run\` and \`--disarm\` are fine.`;
234
234
  if (sub && tool[sub])
235
235
  return `Refused: \`aos ${sub}\` isn't for you; use ${tool[sub]}, which is scoped to this workspace and held by Pause.`;
236
236
  return null;
@@ -269,7 +269,7 @@ function ghRefusal(args: string[]): string | null {
269
269
  sub === "edit" &&
270
270
  optionValues(args, ["-B"], ["--base"]).length
271
271
  )
272
- return `Refused: retargeting a PR changes what its gates judged; a stack sets its own bases (${T.stack}), and anything else is Saad's call (${T.ask_saad}).`;
272
+ return `Refused: retargeting a PR changes what its gates judged; a stack sets its own bases (${T.stack}), and anything else is the owner's call (${T.ask_owner}).`;
273
273
  if (group !== "api") return null;
274
274
  const endpoint = positionals(args, values)[1] ?? "";
275
275
  const method = optionValues(args, ["-X"], ["--method"]).pop();
@@ -494,7 +494,7 @@ async function sqliteRefusal(
494
494
  }
495
495
 
496
496
  const SERVER_PATTERN = /node|tsx|server|agent-?os|tmux|next/i;
497
- const STOP_MSG = `Refused: never stop or restart AgentOS or its tmux; that ends every session it runs, this one included. Stop a session with ${T.stop} or finish it with ${T.done}, and if AgentOS itself needs a restart, ${T.ask_saad}.`;
497
+ const STOP_MSG = `Refused: never stop or restart AgentOS or its tmux; that ends every session it runs, this one included. Stop a session with ${T.stop} or finish it with ${T.done}, and if AgentOS itself needs a restart, ${T.ask_owner}.`;
498
498
 
499
499
  async function stopRefusal(
500
500
  inv: Invocation,
@@ -538,7 +538,7 @@ async function stopRefusal(
538
538
  return null;
539
539
  }
540
540
 
541
- const SECRET_MSG = `Refused: orchestrators never read secrets (env files, keys, credential stores). If a step needs one, ${T.ask_saad}.`;
541
+ const SECRET_MSG = `Refused: orchestrators never read secrets (env files, keys, credential stores). If a step needs one, ${T.ask_owner}.`;
542
542
 
543
543
  // Orchestrators may read tokenvault's folders (and run `tv`); writing them
544
544
  // is still refused by the write guard.
@@ -118,7 +118,7 @@ export const { followAskVerdicts } = await import("./ask-verdict");
118
118
  export const { raiseHeldAsks, HOLD_MIN_MS, HOLD_MAX_MS } =
119
119
  await import("./held-asks");
120
120
 
121
- // The one ask a task's escalation leaves on Saad's list.
121
+ // The one ask a task's escalation leaves on the owner's list.
122
122
  export function oneGateAsk(w: string, task: string, sha: string) {
123
123
  const asks = openAsks(w);
124
124
  expect(asks).toHaveLength(1);
@@ -58,6 +58,7 @@ import { repoHasCI } from "./repo-ci";
58
58
  import { DIFF_CAP, review } from "./review";
59
59
  import { workspaceStack, workspaceTask } from "./targets";
60
60
  import { ciSettleIn, waitingState } from "./task-state";
61
+ import { owner, Owner } from "../owner";
61
62
 
62
63
  const short = (sha: string) => sha.slice(0, 7);
63
64
 
@@ -214,7 +215,7 @@ export async function gateVerdict(
214
215
  }
215
216
  if (approval?.sha === sha) {
216
217
  const i = await inputs();
217
- const approved = `${work.name} (PR #${pr.number} at ${short(sha)}): Saad approved this commit, but`;
218
+ const approved = `${work.name} (PR #${pr.number} at ${short(sha)}): ${owner()} approved this commit, but`;
218
219
  const stack =
219
220
  approval.raised_by === "orchestrator" || !held.length
220
221
  ? i.stackRefusal
@@ -268,10 +269,10 @@ export async function gateVerdict(
268
269
  { fast }
269
270
  );
270
271
  const head = `${work.name} (PR #${pr.number} at ${short(sha)}) can't merge:`;
271
- const toSaad = outcomes.find((o) => o.state === "escalate");
272
- if (toSaad)
272
+ const toOwner = outcomes.find((o) => o.state === "escalate");
273
+ if (toOwner)
273
274
  return no(
274
- `${head}\n- ${escalate(workspaceId, work, toSaad.gate, toSaad.reason!, pr.url, sha)}`
275
+ `${head}\n- ${escalate(workspaceId, work, toOwner.gate, toOwner.reason!, pr.url, sha)}`
275
276
  );
276
277
  const failed = outcomes.filter((o) => o.state === "fail");
277
278
  const waiting = outcomes.filter((o) => o.state === "wait");
@@ -354,7 +355,7 @@ export async function mergeJudged(
354
355
  opts: { wait?: boolean } = {}
355
356
  ): Promise<string> {
356
357
  if (verdict.approval && !spendApproval(verdict.approval))
357
- throw new Error(`Saad's approval for ${task.name} was already used`);
358
+ throw new Error(`${Owner()}'s approval for ${task.name} was already used`);
358
359
  let method: MergeMethod | null = null;
359
360
  await refundIfRefused(task.id, verdict.approval, async () => {
360
361
  method = await signOffTask(task.id, { head: verdict.sha, wait: opts.wait });
@@ -366,7 +367,7 @@ export async function mergeJudged(
366
367
  verdict.fast
367
368
  ? `Merged ${task.name} (PR #${verdict.pr}, ${short(verdict.sha)}) on the fast lane: no CI wait or review.`
368
369
  : verdict.approval
369
- ? `Merged ${task.name} (PR #${verdict.pr}, ${short(verdict.sha)}) on Saad's approval of that commit.`
370
+ ? `Merged ${task.name} (PR #${verdict.pr}, ${short(verdict.sha)}) on ${owner()}'s approval of that commit.`
370
371
  : `Merged ${task.name} (PR #${verdict.pr}, ${short(verdict.sha)}): CI green, review passed, in scope.`
371
372
  );
372
373
  return `Merged ${task.name}: PR #${verdict.pr} ${method ? MERGED_AS[method] : "merged"} at ${short(verdict.sha)}.`;
@@ -408,7 +409,9 @@ export function landGate(
408
409
  const verdict = await judge(workspaceId, task, true);
409
410
  if (verdict.ok && verdict.approval) {
410
411
  if (!spendApproval(verdict.approval))
411
- return { stop: `Saad's approval for ${task.name} was already used` };
412
+ return {
413
+ stop: `${Owner()}'s approval for ${task.name} was already used`,
414
+ };
412
415
  claimed.set(sessionId, verdict.approval);
413
416
  }
414
417
  if (verdict.ok) return { head: verdict.sha };
@@ -24,6 +24,7 @@ import type { SweepRun } from "../worktree-sweep/types";
24
24
  import { addNote } from "./notes";
25
25
  import { PAUSED_REFUSAL } from "./pause";
26
26
  import { getWorkspace } from "../workspaces";
27
+ import { owner } from "../owner";
27
28
 
28
29
  export const DRY_RUN_FRESH_MS = 60 * 60 * 1000;
29
30
 
@@ -165,7 +166,7 @@ export async function sweepArm(
165
166
 
166
167
  export async function sweepDryRun(machine: string): Promise<string> {
167
168
  const m = machineOf(machine);
168
- const started = `Dry run started on ${m.name}; it takes a few minutes and removes nothing. When Saad has said to turn the full clean-up on, call sweep_arm: it reads this run.`;
169
+ const started = `Dry run started on ${m.name}; it takes a few minutes and removes nothing. When ${owner()} has said to turn the full clean-up on, call sweep_arm: it reads this run.`;
169
170
  if (m.link) {
170
171
  await hostApi(m.link, "/api/worktree-sweep/run", {
171
172
  body: { dryRun: true },
@@ -58,7 +58,7 @@ export const TOOL_NAMES = {
58
58
  recipe_update: name("recipe_update"),
59
59
  reminders: name("reminders"),
60
60
  cancel_reminder: name("cancel_reminder"),
61
- ask_saad: name("ask_saad"),
61
+ ask_owner: name("ask_owner"),
62
62
  } as const;
63
63
 
64
64
  // What it may do, fixed by its role. It acts through its own tools, which
@@ -29,7 +29,7 @@ const group = z
29
29
  .max(60)
30
30
  .optional()
31
31
  .describe(
32
- "One of Saad's groups in this workspace, by name (see sessions): file it there when he asked, or when it continues work already in that group. It must exist; you can't create groups"
32
+ "One of the owner's groups in this workspace, by name (see sessions): file it there when he asked, or when it continues work already in that group. It must exist; you can't create groups"
33
33
  );
34
34
 
35
35
  const sweepMachine = z
@@ -96,7 +96,7 @@ export const TOOL_SHAPES = {
96
96
  .boolean()
97
97
  .optional()
98
98
  .describe(
99
- "The message changes the task's scope (Saad added, dropped or changed what it must do): it's recorded on the task's brief, and its review judges the PR against the brief as amended. Only for a change Saad made, never one the task asked for itself"
99
+ "The message changes the task's scope (the owner added, dropped or changed what it must do): it's recorded on the task's brief, and its review judges the PR against the brief as amended. Only for a change the owner made, never one the task asked for itself"
100
100
  ),
101
101
  },
102
102
  orchestrators: {},
@@ -139,7 +139,7 @@ export const TOOL_SHAPES = {
139
139
  .enum(PRIORITIES)
140
140
  .optional()
141
141
  .describe(
142
- "Where it stands in the queue: urgent (a bug Saad hit), blocking (something waits on it), normal (the default), background"
142
+ "Where it stands in the queue: urgent (a bug the owner hit), blocking (something waits on it), normal (the default), background"
143
143
  ),
144
144
  machine: z
145
145
  .enum(["mac", "any"])
@@ -159,7 +159,7 @@ export const TOOL_SHAPES = {
159
159
  .min(1)
160
160
  .max(300)
161
161
  .optional()
162
- .describe("With now: why it can't wait for a slot; logged for Saad"),
162
+ .describe("With now: why it can't wait for a slot; logged for the owner"),
163
163
  ignore_claims: z
164
164
  .boolean()
165
165
  .optional()
@@ -195,7 +195,7 @@ export const TOOL_SHAPES = {
195
195
  .max(200)
196
196
  .optional()
197
197
  .describe(
198
- 'Why, in a few words Saad reads on the task card: "touches 6 files across gates and UI", "one-line default change"'
198
+ 'Why, in a few words the owner reads on the task card: "touches 6 files across gates and UI", "one-line default change"'
199
199
  ),
200
200
  },
201
201
  set_task_model: {
@@ -238,7 +238,7 @@ export const TOOL_SHAPES = {
238
238
  .max(60)
239
239
  .nullable()
240
240
  .describe(
241
- "One of Saad's groups in this workspace by name, or null to take the session out of its group"
241
+ "One of the owner's groups in this workspace by name, or null to take the session out of its group"
242
242
  ),
243
243
  },
244
244
  quick_change: {
@@ -282,7 +282,7 @@ export const TOOL_SHAPES = {
282
282
  .max(8000)
283
283
  .optional()
284
284
  .describe(
285
- "Amends the plan: recorded on the task's brief as a scope change, so its review judges the PR against it. Only for a change Saad made"
285
+ "Amends the plan: recorded on the task's brief as a scope change, so its review judges the PR against it. Only for a change the owner made"
286
286
  ),
287
287
  },
288
288
  plan_pieces: {
@@ -317,7 +317,7 @@ export const TOOL_SHAPES = {
317
317
  .trim()
318
318
  .min(1)
319
319
  .max(500)
320
- .describe("What Saad said, and when; it goes in the decision log"),
320
+ .describe("What the owner said, and when; it goes in the decision log"),
321
321
  },
322
322
  set_priority: {
323
323
  task: ref("A queued or running task: name, project/name or id"),
@@ -330,14 +330,14 @@ export const TOOL_SHAPES = {
330
330
  .trim()
331
331
  .min(1)
332
332
  .max(300)
333
- .describe("Why it can't wait for a slot; logged for Saad"),
333
+ .describe("Why it can't wait for a slot; logged for the owner"),
334
334
  },
335
335
  pause_task: {
336
336
  task,
337
337
  for: z
338
338
  .enum(["1h", "2h", "tomorrow", "until_resumed"])
339
339
  .describe(
340
- "How long: 1h, 2h, tomorrow (9 AM, Saad's time) or until_resumed (until you or Saad resume it)"
340
+ "How long: 1h, 2h, tomorrow (9 AM, the owner's time) or until_resumed (until you or the owner resume it)"
341
341
  ),
342
342
  now: z
343
343
  .boolean()
@@ -353,7 +353,7 @@ export const TOOL_SHAPES = {
353
353
  archive: { session },
354
354
  resurrect: {
355
355
  session: ref(
356
- "A dropped, done or archived session: its name, project/name or id (from the decision log, or as Saad named it)"
356
+ "A dropped, done or archived session: its name, project/name or id (from the decision log, or as the owner named it)"
357
357
  ),
358
358
  reason: z.string().trim().min(1).max(2000),
359
359
  },
@@ -481,13 +481,13 @@ export const TOOL_SHAPES = {
481
481
  },
482
482
  arm: { task },
483
483
  disarm: { task },
484
- ask_saad: {
484
+ ask_owner: {
485
485
  title: z
486
486
  .string()
487
487
  .trim()
488
488
  .min(1)
489
489
  .max(200)
490
- .describe("What Saad decides, as a short question"),
490
+ .describe("What the owner decides, as a short question"),
491
491
  detail: z
492
492
  .string()
493
493
  .trim()
@@ -116,23 +116,23 @@ const answer = async (call: Promise<string>): Promise<CallToolResult> => {
116
116
 
117
117
  const DESCRIPTIONS: Record<ToolName, string> = {
118
118
  sessions:
119
- "Every session in this workspace: project, view, status, what it's doing, task/PR/CI state and stack position; plain idle and done sessions (on Saad's Done shelf) are listed compactly after them; then external sessions (tmux sessions AgentOS didn't start, here or on other machines, working in a workspace repository), read-only, with their branch's PR.",
119
+ "Every session in this workspace: project, view, status, what it's doing, task/PR/CI state and stack position; plain idle and done sessions (on the owner's Done shelf) are listed compactly after them; then external sessions (tmux sessions AgentOS didn't start, here or on other machines, working in a workspace repository), read-only, with their branch's PR.",
120
120
  read: "The end of one session's terminal or chat, or an external session's screen, as plain text (capped at about 4k tokens).",
121
121
  cards: "Cards on this workspace's linked LumifyHub boards, by list.",
122
122
  logs: "Session logs AgentOS wrote when tasks merged: the last N headers (task, PR, cost, duration) of the workspace or one project, or one log in full (brief, plan, timeline, usage, outcome). Read straight from the database; nothing is summarised.",
123
- send: "Message a session in this workspace over the bus. It arrives as its next prompt. When Saad changes a running task's scope, tell it with scope_change set, so its review judges the PR against the brief as amended. A task still in the queue (by id or name) takes messages too: they are held and reach it with its first prompt; scope_change on one records the change on its brief at once.",
123
+ send: "Message a session in this workspace over the bus. It arrives as its next prompt. When the owner changes a running task's scope, tell it with scope_change set, so its review judges the PR against the brief as amended. A task still in the queue (by id or name) takes messages too: they are held and reach it with its first prompt; scope_change on one records the change on its brief at once.",
124
124
  orchestrators:
125
125
  "Every workspace, and whether it has an orchestrator you can message.",
126
126
  message_orchestrator:
127
127
  "Message another workspace's orchestrator, by workspace name; never any other session there. It arrives as its next prompt, marked as from you and fenced as untrusted.",
128
128
  start_task:
129
- 'Start a task: an agent in its own worktree that ends in a PR. It runs as a chat unless view is terminal (only for a job that needs a TUI). priority (urgent, blocking, normal, background) says where it stands in the queue; machine "mac" pins work that needs this Mac. A busy machine holds a start and the result says why. With after (a task\'s id or name, or "any"), or over the workspace\'s running task limit, it\'s queued and starts by itself; the result says Queued (position N). Its models follow the role settings (planning, implementation, review, scope); give model only when Saad asks for one. Always state plan (true or false) and plan_reason (plan_only true plans, then stops and waits for approve_plan instead of implementing): plan true for a big, unclear or cross-cutting job, false for a small, clear one whose steps you write into the prompt. now true (with now_reason) starts it at once past the running-task limit instead of queueing for a slot, logged; not with after. A strong overlap with work another session has claimed (same branch, or the same files, here or on a linked machine) queues the start after it and says so; the result also lists weaker overlaps. ignore_claims true skips that. piece names the waiting piece of a plan this task builds, so it is claimed and not started a second time. Refused while a brake holds.',
129
+ 'Start a task: an agent in its own worktree that ends in a PR. It runs as a chat unless view is terminal (only for a job that needs a TUI). priority (urgent, blocking, normal, background) says where it stands in the queue; machine "mac" pins work that needs this Mac. A busy machine holds a start and the result says why. With after (a task\'s id or name, or "any"), or over the workspace\'s running task limit, it\'s queued and starts by itself; the result says Queued (position N). Its models follow the role settings (planning, implementation, review, scope); give model only when the owner asks for one. Always state plan (true or false) and plan_reason (plan_only true plans, then stops and waits for approve_plan instead of implementing): plan true for a big, unclear or cross-cutting job, false for a small, clear one whose steps you write into the prompt. now true (with now_reason) starts it at once past the running-task limit instead of queueing for a slot, logged; not with after. A strong overlap with work another session has claimed (same branch, or the same files, here or on a linked machine) queues the start after it and says so; the result also lists weaker overlaps. ignore_claims true skips that. piece names the waiting piece of a plan this task builds, so it is claimed and not started a second time. Refused while a brake holds.',
130
130
  set_task_model:
131
131
  'Resume a paused task on another model ("provider/model", or a map of role to one), keeping its conversation and worktree; it\'s told to carry on (or message). Refused mid-turn.',
132
132
  start_session:
133
133
  'Start an agent session in a project with a prompt, for work that isn\'t a PR task. It runs on the implementation model unless you give model (one "provider/model"; Opus only when the work needs deep reasoning), as a chat unless view is terminal (only for a job that needs a TUI). Refused while a brake holds.',
134
134
  set_group:
135
- "File a session of this workspace in one of Saad's groups (by name), or out of its group with null. Groups only organise his sidebar; they change nothing about the session. You can use groups that exist but can't create, rename or delete them.",
135
+ "File a session of this workspace in one of the owner's groups (by name), or out of its group with null. Groups only organise his sidebar; they change nothing about the session. You can use groups that exist but can't create, rename or delete them.",
136
136
  quick_change:
137
137
  "Make a change yourself (without full access, a small low-risk one: a typo, a config value, a one-file fix): cuts a worktree and branch from the project's default branch and returns its path. Its dependencies install in the background (a note says when). You may write there (Write, Edit) and, with Orchestrator full access, run any git there; without it, only `git -C <path> ...` status, diff, add, rm, mv, restore, reset, log, show, and nowhere else in a project. Without full access, anything needing real work, tests or an investigation is a task (start_task).",
138
138
  quick_pr:
@@ -146,34 +146,34 @@ const DESCRIPTIONS: Record<ToolName, string> = {
146
146
  plan_pieces:
147
147
  "A task whose plan split into pieces: each piece, its repo, what it waits on and its state. With merge or drop (position numbers) it edits the pieces still waiting: a merged piece's slice goes to the piece before it, a dropped one is left out and anything waiting on it is held. It can't add pieces or change reasons.",
148
148
  drop: "Reject a task: close its PR and remove its worktree. A task still waiting in the queue is removed by its id. Logged with the reason.",
149
- move: 'Carry a running task to a linked machine, or back with "here": for heavy test or build work. It keeps its branch, conversation and plan; the gates, events, read, send, stop, done and drop work the same wherever it runs, and the machine it lands on can\'t merge it. Takes a few minutes and runs in the background; an event says when it lands. Refused while paused, for a task waiting on Saad (an escalation or open ask), and for card and stacked tasks.',
149
+ move: 'Carry a running task to a linked machine, or back with "here": for heavy test or build work. It keeps its branch, conversation and plan; the gates, events, read, send, stop, done and drop work the same wherever it runs, and the machine it lands on can\'t merge it. Takes a few minutes and runs in the background; an event says when it lands. Refused while paused, for a task waiting on the owner (an escalation or open ask), and for card and stacked tasks.',
150
150
  sweep_dry_run:
151
151
  "Start a dry run of the disk clean-up on this machine or a linked one: it lists what the full clean-up would remove and removes nothing. Takes a few minutes; sweep_arm reads it.",
152
152
  sweep_arm:
153
- "Turn the full disk clean-up (test leftovers, stale task databases, spares, temp folders, build caches) on or off for this machine or a linked one. Only for when Saad has said to turn it on: put what he said in reason. Turning it on is refused unless that machine's latest dry run finished in the last hour (run sweep_dry_run first, and read its totals). Off is always allowed. Logged in the decision log with the dry run's totals.",
153
+ "Turn the full disk clean-up (test leftovers, stale task databases, spares, temp folders, build caches) on or off for this machine or a linked one. Only for when the owner has said to turn it on: put what he said in reason. Turning it on is refused unless that machine's latest dry run finished in the last hour (run sweep_dry_run first, and read its totals). Off is always allowed. Logged in the decision log with the dry run's totals.",
154
154
  set_priority:
155
155
  "Change the priority of a queued task (it re-ranks the line at once) or a running one (it only matters if it is paused and waits to resume). urgent starts first even on a busy machine; background starts last.",
156
156
  start_now:
157
- "Start a queued task now, past the workspace's running-task limit and its after, when it can't wait for a slot (Saad asked, or it unblocks others). Still refused while paused or braked, and a task that needs a Mac stays queued. Give a reason: it goes in the decision log. Says whether it started, is still held and why, or failed.",
157
+ "Start a queued task now, past the workspace's running-task limit and its after, when it can't wait for a slot (the owner asked, or it unblocks others). Still refused while paused or braked, and a task that needs a Mac stays queued. Give a reason: it goes in the decision log. Says whether it started, is still held and why, or failed.",
158
158
  pause_task:
159
- "Pause a running task for a set time: its agent ends at the end of its current step (now true interrupts the step), its worktree and conversation stay, and it frees its slot so the next queued task can start. It resumes by itself at the time, ahead of new arrivals of the same priority, with whatever was held (CI and review notes, messages) in one continue prompt. Refused for a task that is merging, moving, setting up or waiting on Saad. Give a reason; it is shown with the pause.",
159
+ "Pause a running task for a set time: its agent ends at the end of its current step (now true interrupts the step), its worktree and conversation stay, and it frees its slot so the next queued task can start. It resumes by itself at the time, ahead of new arrivals of the same priority, with whatever was held (CI and review notes, messages) in one continue prompt. Refused for a task that is merging, moving, setting up or waiting on the owner. Give a reason; it is shown with the pause.",
160
160
  resume_task:
161
- "Resume a task you paused (or the scheduler did) now: it starts at once, past the running-task limit, unless the machine load holds it (the answer says so). A pause Saad set is his to lift: this refuses and says so.",
161
+ "Resume a task you paused (or the scheduler did) now: it starts at once, past the running-task limit, unless the machine load holds it (the answer says so). A pause the owner set is his to lift: this refuses and says so.",
162
162
  stop: "Stop a session's agent mid-turn, even while it's thinking, keeping its conversation, worktree and branch; your next message runs as its next turn.",
163
163
  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 lands on the sidebar's Done shelf; it no longer archives (use archive for that). Not a rejection.",
164
164
  archive:
165
- "Put a finished session away, out of the sidebar (it stays in Archived, and Saad can unarchive it). Nothing is merged or cleaned up: refused while the session is working or has an open PR; done first for finished work.",
165
+ "Put a finished session away, out of the sidebar (it stays in Archived, and the owner can unarchive it). Nothing is merged or cleaned up: refused while the session is working or has an open PR; done first for finished work.",
166
166
  resurrect:
167
167
  "Bring a dropped, done or archived session back: the same session and conversation, its worktree (with its uncommitted files and plan) back from what was saved or from its branch, its status as it was (a plan-only task waits for approval again, one with a PR is idle), and a PR a drop closed reopened. Archived sessions just unarchive. Held by Pause and braked like a start. Refused when its conversation is gone, its PR was merged, its project was removed or its branch is in use. Give a reason; it goes in the decision log.",
168
168
  add_project:
169
169
  "Make a git repository on this machine a project of this workspace, by its absolute path, or move the project already at that path into this workspace when it's in no workspace. Says its default branch, detected from git. Refuses a folder that isn't a git repository or that holds secrets, and a project another workspace has.",
170
170
  project_tier:
171
- "Read or set a project's tier. production: every merge gate applies (CI, independent review, scope). fast: a task's PR merges as soon as it opens, with no CI wait or review. Without a project, lists this workspace's; with a project, reads it; with a tier too, sets it, only when Saad says to.",
171
+ "Read or set a project's tier. production: every merge gate applies (CI, independent review, scope). fast: a task's PR merges as soon as it opens, with no CI wait or review. Without a project, lists this workspace's; with a project, reads it; with a tier too, sets it, only when the owner says to.",
172
172
  note: "Add a line to this workspace's decision log; it shows in your chat.",
173
173
  rule_add:
174
174
  'Keep a standing rule for this workspace, in your brief on every start from now on ("never merge #111", "briefs must not name third-party products"). It restricts or informs; it can never approve anything.',
175
175
  rule_remove:
176
- "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.",
176
+ "Remove a standing rule you kept, by its id, only when the owner says so or it no longer applies (the PR it names merged or closed). The owner's rules are his to remove.",
177
177
  remind_me:
178
178
  "Set a one-off reminder for yourself in this workspace: at the time given it reaches you once as a scheduled prompt (not an approval), even if AgentOS was off then. At least 10 minutes ahead; a few at a time at most.",
179
179
  recipes:
@@ -181,26 +181,26 @@ const DESCRIPTIONS: Record<ToolName, string> = {
181
181
  run_recipe:
182
182
  "Run a recipe now: a fresh task or session starts from its saved brief, plus the last run's reported facts and an optional one-off note. Braked, queued and gated like start_task and start_session. A recipe is never an approval. One run at a time; while it is going this says so. You get an event when it finishes.",
183
183
  recipe_save:
184
- "Save a recipe when a job comes up a second time. A short brief (the steps belong in a script in the repo), a kind (task: ends in a PR; session: no PR) and a project. It sets no timer: Saad does.",
184
+ "Save a recipe when a job comes up a second time. A short brief (the steps belong in a script in the repo), a kind (task: ends in a PR; session: no PR) and a project. It sets no timer: The owner does.",
185
185
  recipe_update:
186
- "Change a recipe's brief or settings (never its timer). Earlier versions are kept and Saad sees the change.",
186
+ "Change a recipe's brief or settings (never its timer). Earlier versions are kept and the owner sees the change.",
187
187
  reminders:
188
188
  "List this workspace's upcoming reminders and the last few that ran.",
189
189
  cancel_reminder:
190
- "Cancel a reminder you set, by its id from reminders. Reminders Saad made are his to change.",
190
+ "Cancel a reminder you set, by its id from reminders. Reminders the owner made are his to change.",
191
191
  review:
192
192
  "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.",
193
193
  sign_off:
194
- "Merge a PR, with its project's merge method, through the gates: CI green and settled, a passing review of that commit, nothing blocked, in scope, stack parent merged. Takes a task, or any open PR in a workspace repository by #N, owner/repo#N or URL (an external PR). Refuses with the failing gate, or a repository outside the workspace. With direct true (and a reason) it merges now at the PR's head without the gates; only with Orchestrator full access, and not while paused or while the task waits on Saad.",
195
- arm: "Arm a task's PR to merge: AgentOS merges it, through the same gates as sign_off, the moment they all pass at its head, and tells you with one event. A gate failure comes as one event and it stays armed for the fix; one that goes to Saad disarms it. Survives restarts. Use it instead of calling sign_off until it passes.",
194
+ "Merge a PR, with its project's merge method, through the gates: CI green and settled, a passing review of that commit, nothing blocked, in scope, stack parent merged. Takes a task, or any open PR in a workspace repository by #N, owner/repo#N or URL (an external PR). Refuses with the failing gate, or a repository outside the workspace. With direct true (and a reason) it merges now at the PR's head without the gates; only with Orchestrator full access, and not while paused or while the task waits on the owner.",
195
+ arm: "Arm a task's PR to merge: AgentOS merges it, through the same gates as sign_off, the moment they all pass at its head, and tells you with one event. A gate failure comes as one event and it stays armed for the fix; one that goes to the owner disarms it. Survives restarts. Use it instead of calling sign_off until it passes.",
196
196
  disarm:
197
197
  "Disarm a task armed to merge: it merges only through sign_off again.",
198
198
  question:
199
- "Mark a question you asked Saad in chat as open: it stays pinned on his Needs-you strip until he answers or you clear it. Use it for anything you need from him that isn't an ask_saad item. The same text while open is the same question.",
199
+ "Mark a question you asked the owner in chat as open: it stays pinned on his Needs-you strip until he answers or you clear it. Use it for anything you need from him that isn't an ask_owner item. The same text while open is the same question.",
200
200
  resolve_question:
201
- "Clear an open question once Saad has answered it in chat, or it no longer matters.",
202
- ask_saad:
203
- "Park an item on Saad's asks list (a decision, or anything crossing a hard line) and carry on; it never waits. One open ask per title. His answer reaches you as an event. To ask whether to merge a PR the gates refuse, give task and sha (its head): his approval lets sign_off merge it once, at that commit, without a review (an AI-attributed body or failing CI still refuses).",
201
+ "Clear an open question once the owner has answered it in chat, or it no longer matters.",
202
+ ask_owner:
203
+ "Park an item on the owner's asks list (a decision, or anything crossing a hard line) and carry on; it never waits. One open ask per title. His answer reaches you as an event. To ask whether to merge a PR the gates refuse, give task and sha (its head): his approval lets sign_off merge it once, at that commit, without a review (an AI-attributed body or failing CI still refuses).",
204
204
  };
205
205
 
206
206
  export function orchestratorTools(
@@ -0,0 +1,25 @@
1
+ // How the installation owner is named in text. Pure, so client code can use
2
+ // it; lib/owner.ts reads the stored name.
3
+ export const OWNER_NAME_MAX = 40;
4
+
5
+ export function cleanOwnerName(raw: unknown): string | null {
6
+ if (typeof raw !== "string") return null;
7
+ const s = raw
8
+ .normalize("NFKC")
9
+ .replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}`]/gu, "")
10
+ .replace(/\s+/g, " ")
11
+ .trim();
12
+ return s ? [...s].slice(0, OWNER_NAME_MAX).join("") : null;
13
+ }
14
+
15
+ /** Agent prompts: the name, else "the owner". */
16
+ export const ownerInPrompt = (name: string | null, cap = false) =>
17
+ name ?? (cap ? "The owner" : "the owner");
18
+
19
+ /** UI text: the name, else "you". */
20
+ export const ownerInUi = (name: string | null, cap = false) =>
21
+ name ?? (cap ? "You" : "you");
22
+
23
+ /** "Alex decides" / "You decide": the verb for a name or for "you". */
24
+ export const ownerVerb = (name: string | null, named: string, you: string) =>
25
+ `${ownerInUi(name, true)} ${name ? named : you}`;
package/lib/owner.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { getSetting, putSetting } from "./settings-store";
2
+ import { cleanOwnerName, ownerInPrompt, ownerInUi } from "./owner-label";
3
+
4
+ export const OWNER_NAME_KEY = "owner.name";
5
+
6
+ /** The name the installation's owner set, or null. The one reader. */
7
+ export function ownerName(): string | null {
8
+ return cleanOwnerName(getSetting(OWNER_NAME_KEY));
9
+ }
10
+
11
+ export function setOwnerName(raw: unknown): string | null {
12
+ const name = cleanOwnerName(raw);
13
+ putSetting(OWNER_NAME_KEY, name);
14
+ return name;
15
+ }
16
+
17
+ /** For agent text: "Alex" or "the owner". */
18
+ export const owner = () => ownerInPrompt(ownerName());
19
+ export const Owner = () => ownerInPrompt(ownerName(), true);
20
+ /** For text people read: "Alex" or "you". */
21
+ export const ownerUi = (cap = false) => ownerInUi(ownerName(), cap);
@@ -20,6 +20,7 @@ import { run, shellPath, spawnOn } from "./shell";
20
20
  import { onWindowsDrive, WINDOWS_DRIVE_WARNING } from "../wsl";
21
21
 
22
22
  import { PROJECT_NAME } from "./name";
23
+ import { owner } from "../owner";
23
24
 
24
25
  export { PROJECT_NAME };
25
26
  // https://host/owner/repo(.git), ssh://…, or git@host:owner/repo(.git).
@@ -151,7 +152,7 @@ export async function addFolder(
151
152
  const from = existing.workspace_id;
152
153
  if (opts.moveOnlyUnassigned && from && from !== workspaceId)
153
154
  throw new Error(
154
- `${existing.name} belongs to the ${getWorkspace(from)?.name ?? "another"} workspace; only Saad moves a project between workspaces`
155
+ `${existing.name} belongs to the ${getWorkspace(from)?.name ?? "another"} workspace; only ${owner()} moves a project between workspaces`
155
156
  );
156
157
  const outcome =
157
158
  !workspaceId || from === workspaceId ? "unchanged" : "moved";
@@ -29,6 +29,7 @@ import {
29
29
  type ScheduleRun,
30
30
  } from "./store";
31
31
  import { isPaused } from "../orchestrator/pause";
32
+ import { ownerUi } from "../owner";
32
33
 
33
34
  export * from "./store";
34
35
  export { schedulesEnabled, startScheduler, tick } from "./scheduler";
@@ -52,7 +53,7 @@ function creatorOf(s: Schedule): ScheduleView["createdBy"] {
52
53
  const creator = s.created_by_session_id
53
54
  ? getSessionRow(s.created_by_session_id)
54
55
  : null;
55
- if (!creator) return { kind: "saad", name: "Saad" };
56
+ if (!creator) return { kind: "saad", name: ownerUi(true) };
56
57
  return creator.role === "orchestrator"
57
58
  ? { kind: "orchestrator", name: "Orchestrator" }
58
59
  : { kind: "session", name: creator.name };
@@ -20,6 +20,7 @@ import { PLAN_FILE } from "../models/plan-step";
20
20
  import { estimateCost, rowModel } from "../usage/cost";
21
21
  import { sessionPath } from "../session-url";
22
22
  import type { LogData, LogEvent, LogUsageRow } from "./build";
23
+ import { ownerUi } from "../owner";
23
24
 
24
25
  const expandHome = (p: string) => p.replace(/^~/, os.homedir());
25
26
 
@@ -146,7 +147,7 @@ function askEvents(id: string): LogEvent[] {
146
147
  return rows.flatMap((a): LogEvent[] => {
147
148
  const raised: LogEvent = {
148
149
  at: a.created_at,
149
- text: `Escalated to Saad (${a.kind})`,
150
+ text: `Escalated to ${ownerUi()} (${a.kind})`,
150
151
  sha: a.sha,
151
152
  quote: a.title,
152
153
  detail: a.detail || null,
@@ -156,7 +157,7 @@ function askEvents(id: string): LogEvent[] {
156
157
  raised,
157
158
  {
158
159
  at: a.resolved_at,
159
- text: `Saad ${a.status === "approved" ? "approved" : a.status === "declined" ? "declined" : "answered"}`,
160
+ text: `${ownerUi(true)} ${a.status === "approved" ? "approved" : a.status === "declined" ? "declined" : "answered"}`,
160
161
  sha: a.sha,
161
162
  quote: a.title,
162
163
  detail: a.answer,
@@ -68,6 +68,7 @@ export type {
68
68
  StartOutcome,
69
69
  } from "./queue-types";
70
70
  import { setTask } from "../db/session-parts";
71
+ import { owner } from "../owner";
71
72
  export { afterMet, hasFinished, resolveAfter, slotState } from "./queue-rows";
72
73
  export { findQueued, listQueue, queuedView } from "./queue-view";
73
74
  export { tickOnce, tickQueue, tickQueueSoon } from "./queue-tick";
@@ -234,7 +235,8 @@ export async function startQueuedForOrchestrator(
234
235
  outcome: "held",
235
236
  text,
236
237
  });
237
- if (isPaused(workspaceId)) return held("the orchestrator is paused by Saad");
238
+ if (isPaused(workspaceId))
239
+ return held(`the orchestrator is paused by ${owner()}`);
238
240
  if (isRemoteHost(r.host_id ?? project.host_id))
239
241
  return held(
240
242
  "it runs on another machine, and the orchestrator starts tasks on this one only"
@@ -41,6 +41,7 @@ import {
41
41
  startSetup,
42
42
  } from "../sessions/setup-progress";
43
43
  import { recordSetup, setupNote, setupOutcome, type TaskSetup } from "./setup";
44
+ import { owner } from "../owner";
44
45
 
45
46
  const execFileAsync = promisify(execFile);
46
47
 
@@ -62,7 +63,8 @@ export function launchHold(sessionId: string): string | null {
62
63
  .get(sessionId, sessionId, sessionId) as
63
64
  { workspace_id: string } | undefined;
64
65
  if (!row) return null;
65
- if (isPaused(row.workspace_id)) return "the orchestrator is paused by Saad";
66
+ if (isPaused(row.workspace_id))
67
+ return `the orchestrator is paused by ${owner()}`;
66
68
  const window = brakesEnabled() ? windowRefusal(readUsage()) : null;
67
69
  return window ? `held by the brakes: ${window}` : null;
68
70
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.817",
3
+ "version": "0.3.821",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"