@saadnvd1/agent-os 0.3.664 → 0.3.672

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 (95) hide show
  1. package/app/api/audits/[kind]/[id]/route.test.ts +2 -2
  2. package/app/api/mcp-auth/callback/route.test.ts +42 -0
  3. package/app/api/mcp-auth/callback/route.ts +56 -0
  4. package/app/api/projects/[id]/route.ts +26 -0
  5. package/app/api/projects/init/route.test.ts +2 -4
  6. package/app/api/sessions/[id]/mcp-auth/route.test.ts +86 -0
  7. package/app/api/sessions/[id]/mcp-auth/route.ts +58 -0
  8. package/app/api/shares/route.test.ts +3 -4
  9. package/app/api/workspaces/[id]/route.ts +9 -1
  10. package/app/api/worktree-sweep/route.test.ts +21 -0
  11. package/app/api/worktree-sweep/route.ts +24 -4
  12. package/components/Chat/ChatPanel.tsx +6 -3
  13. package/components/Chat/McpServers.tsx +61 -24
  14. package/components/Chat/McpSignIn.tsx +122 -0
  15. package/components/Chat/session.ts +8 -0
  16. package/components/Health/SweepRuns.tsx +1 -0
  17. package/components/Projects/ProjectSettingsDialog.tsx +7 -0
  18. package/components/Projects/ProjectTierField.tsx +39 -0
  19. package/components/Workspaces/ProjectTiersForm.tsx +33 -0
  20. package/components/Workspaces/WorkspaceSettings.tsx +8 -0
  21. package/data/chat/mcpAuth.ts +42 -0
  22. package/data/projects/queries.ts +4 -0
  23. package/data/workspaces/index.ts +6 -0
  24. package/lib/chat/driver.ts +24 -2
  25. package/lib/chat/drivers/claude-approvals.ts +7 -0
  26. package/lib/chat/drivers/claude-mcp-auth.ts +82 -0
  27. package/lib/chat/drivers/claude.ts +7 -0
  28. package/lib/chat/events.ts +12 -0
  29. package/lib/chat/mcp-auth.ts +193 -0
  30. package/lib/chat/registry.ts +2 -0
  31. package/lib/chat/runner.ts +18 -0
  32. package/lib/chat/worker/client.ts +35 -1
  33. package/lib/chat/worker/host.ts +9 -0
  34. package/lib/chat/worker/main.ts +1 -1
  35. package/lib/chat/worker/mcp-auth.ts +119 -0
  36. package/lib/chat/worker/protocol.ts +19 -0
  37. package/lib/db/migrations/20261018T1730_mcp_sign_ins.ts +22 -0
  38. package/lib/db/migrations/20261018T2100_orch_full_access.ts +16 -0
  39. package/lib/db/migrations/20261018T2130_project_tier.ts +38 -0
  40. package/lib/db/types.ts +7 -0
  41. package/lib/done/testing.ts +2 -2
  42. package/lib/env-setup.ts +8 -1
  43. package/lib/health/boots.ts +2 -8
  44. package/lib/orchestrator/armed.test-helpers.ts +7 -2
  45. package/lib/orchestrator/armed.ts +14 -1
  46. package/lib/orchestrator/brief.ts +23 -11
  47. package/lib/orchestrator/external-gates.ts +62 -1
  48. package/lib/orchestrator/fast-lane-state.ts +69 -0
  49. package/lib/orchestrator/fast-lane.ts +96 -0
  50. package/lib/orchestrator/full-access.ts +21 -0
  51. package/lib/orchestrator/gates.ts +8 -2
  52. package/lib/orchestrator/project-tier-tool.ts +33 -0
  53. package/lib/orchestrator/quick.ts +47 -4
  54. package/lib/orchestrator/review.ts +3 -0
  55. package/lib/orchestrator/serve.ts +17 -3
  56. package/lib/orchestrator/shell-guard.ts +23 -1
  57. package/lib/orchestrator/signoff.test-helpers.ts +6 -2
  58. package/lib/orchestrator/signoff.ts +36 -19
  59. package/lib/orchestrator/sweep-arm.ts +180 -0
  60. package/lib/orchestrator/tool-names.ts +5 -1
  61. package/lib/orchestrator/tool-schemas.ts +41 -2
  62. package/lib/orchestrator/tools.ts +8 -2
  63. package/lib/orchestrator/watcher.ts +5 -0
  64. package/lib/orchestrator/write-guard.ts +34 -7
  65. package/lib/project-config/brief.ts +3 -1
  66. package/lib/project-config/database.ts +6 -0
  67. package/lib/project-config/index.ts +108 -21
  68. package/lib/project-config/schema.ts +10 -4
  69. package/lib/project-config/session.ts +9 -6
  70. package/lib/project-tier/getProjectTier.ts +18 -0
  71. package/lib/project-tier/index.ts +3 -0
  72. package/lib/project-tier/setProjectTier.ts +14 -0
  73. package/lib/project-tier/types.ts +3 -0
  74. package/lib/security/net.ts +7 -4
  75. package/lib/start-options.ts +8 -1
  76. package/lib/tasks/finish.ts +2 -1
  77. package/lib/tasks/index.ts +10 -5
  78. package/lib/tasks/move-testing.ts +2 -2
  79. package/lib/tasks/state.ts +7 -1
  80. package/lib/test-temp.ts +30 -0
  81. package/lib/workspaces.ts +6 -2
  82. package/lib/worktree-sweep/caches.ts +6 -0
  83. package/lib/worktree-sweep/extras.ts +16 -8
  84. package/lib/worktree-sweep/report.ts +1 -0
  85. package/lib/worktree-sweep/settings.ts +2 -2
  86. package/lib/worktree-sweep/testTemp.ts +113 -0
  87. package/lib/worktree-sweep/types.ts +2 -0
  88. package/package.json +1 -1
  89. package/scripts/autodeploy.test.ts +2 -2
  90. package/scripts/check-bundle.test.ts +2 -2
  91. package/scripts/hooks/banned-terms.test.ts +3 -2
  92. package/scripts/hooks/staged-secrets.test.ts +2 -2
  93. package/scripts/lib/build-swap.test.ts +2 -3
  94. package/scripts/lib/wsl.test.ts +2 -3
  95. package/scripts/update-run.test.ts +2 -2
@@ -25,13 +25,16 @@ import { expandHome, forgetPR, prFor, projectPathFor } from "../tasks/session";
25
25
  import { inBackground } from "../lumifyhub/task-cards";
26
26
  import type { CheckRow } from "./checks";
27
27
  import { getOrchestrator } from "./home";
28
+ import { fullAccessOn } from "./full-access";
29
+ import { setupWorktree } from "../env-setup";
28
30
  import { addNote } from "./notes";
29
31
  import { quickWorktrees } from "./quick-store";
30
32
  import { findWorkspaceSession } from "./read";
31
33
  import { workspaceProject } from "./targets";
32
34
  import { recordPr, setTask } from "../db/session-parts";
33
35
 
34
- // Open quick changes one orchestrator may hold at once.
36
+ // Open quick changes one orchestrator may hold at once, unless its workspace
37
+ // has Orchestrator full access on (the default).
35
38
  export const MAX_OPEN_QUICK = 5;
36
39
 
37
40
  // Git with no hooks: a hook is a script in the project (or one it calls,
@@ -62,7 +65,13 @@ export async function startQuickChange(
62
65
  workspaceId: string,
63
66
  projectRef: string,
64
67
  name: string
65
- ): Promise<{ id: string; worktreePath: string; text: string }> {
68
+ ): Promise<{
69
+ id: string;
70
+ worktreePath: string;
71
+ text: string;
72
+ // Dependencies and the project's setup, running in the background.
73
+ setup: Promise<void>;
74
+ }> {
66
75
  const owner = ownerOf(workspaceId);
67
76
  const project = workspaceProject(workspaceId, projectRef);
68
77
  if (isRemoteHost(project.host_id))
@@ -70,7 +79,7 @@ export async function startQuickChange(
70
79
  `${project.name} runs on another machine; quick changes are made on this one only`
71
80
  );
72
81
  const open = quickWorktrees(owner.id);
73
- if (open.length >= MAX_OPEN_QUICK)
82
+ if (!fullAccessOn(workspaceId) && open.length >= MAX_OPEN_QUICK)
74
83
  throw new Error(
75
84
  `You have ${open.length} quick changes open (${MAX_OPEN_QUICK} at most): finish one with quick_pr, or drop it, first`
76
85
  );
@@ -122,13 +131,47 @@ export async function startQuickChange(
122
131
  workspaceId,
123
132
  `Quick change "${label}" in ${project.name} on ${wt.branchName}.`
124
133
  );
134
+ const full = fullAccessOn(workspaceId);
135
+ const setup = installDependencies(
136
+ workspaceId,
137
+ label,
138
+ wt.worktreePath,
139
+ projectPath
140
+ );
141
+ inBackground(`setup of quick change ${id}`, () => setup);
125
142
  return {
126
143
  id,
127
144
  worktreePath: wt.worktreePath,
128
- text: `Quick change "${label}" (id ${id.slice(0, 8)}) in ${project.name}: worktree ${wt.worktreePath} on ${wt.branchName}, cut from ${baseBranch}. Edit files there with Write and Edit; git there only as \`git -C ${wt.worktreePath} <status|diff|add|...>\`. Then quick_pr commits, pushes and opens its PR.`,
145
+ setup,
146
+ text: `Quick change "${label}" (id ${id.slice(0, 8)}) in ${project.name}: worktree ${wt.worktreePath} on ${wt.branchName}, cut from ${baseBranch}. Dependencies are installing in the background (a note says when they're ready). Edit files there with Write and Edit; ${full ? "run any git there" : "git there only as `git -C " + wt.worktreePath + " <status|diff|add|...>`"}. Then quick_pr commits, pushes and opens its PR.`,
129
147
  };
130
148
  }
131
149
 
150
+ // The project's own worktree setup (dependencies, env files), so tests and
151
+ // the build run in the quick change. One note when it ends.
152
+ async function installDependencies(
153
+ workspaceId: string,
154
+ label: string,
155
+ worktreePath: string,
156
+ projectPath: string
157
+ ): Promise<void> {
158
+ try {
159
+ const r = await setupWorktree({ worktreePath, sourcePath: projectPath });
160
+ const failed = r.steps.find((s) => !s.success);
161
+ addNote(
162
+ workspaceId,
163
+ failed
164
+ ? `Quick change "${label}": setup failed at ${failed.name}: ${failed.error ?? "see its worktree"}.`
165
+ : `Quick change "${label}": dependencies installed, ready to build and test.`
166
+ );
167
+ } catch (e) {
168
+ addNote(
169
+ workspaceId,
170
+ `Quick change "${label}": setup failed: ${(e as Error).message}.`
171
+ );
172
+ }
173
+ }
174
+
132
175
  // A quick change of this workspace's orchestrator, by name, id or PR.
133
176
  export function workspaceQuick(workspaceId: string, ref: string): Session {
134
177
  const owner = ownerOf(workspaceId);
@@ -55,6 +55,7 @@ import {
55
55
  import { noteQuickReview } from "./quick";
56
56
  import { workspaceTask } from "./targets";
57
57
  import { untrusted } from "./untrusted";
58
+ import { isFast } from "../project-tier/getProjectTier";
58
59
 
59
60
  const short = (sha: string) => sha.slice(0, 7);
60
61
  // The most diff one reviewer is given, and the most parts a change too big
@@ -267,6 +268,8 @@ export async function reviewAt(
267
268
  sha: string,
268
269
  opts: ReviewOpts = {}
269
270
  ): Promise<string> {
271
+ if (target.task && isFast(target.task))
272
+ return `${target.task.name} is in a fast project: no independent review; it merges through the fast gates.`;
270
273
  const known = getCheck(target.id, sha, "review");
271
274
  // A card task's scope check runs after its review; one never stored (a
272
275
  // restart between the two) means running the job again.
@@ -26,11 +26,13 @@ import { sessionFacts } from "./facts";
26
26
  import { describeExternal, externalSessions } from "./external-sessions";
27
27
  import { resolveStaleAsks } from "./ask-settle";
28
28
  import { addNote } from "./notes";
29
+ import { projectTierTool } from "./project-tier-tool";
29
30
  import { addProject } from "./add-project";
30
31
  import { addRule, removeRule } from "./rules";
31
32
  import { askSaad } from "./ask-tool";
32
33
  import { askQuestion, resolveQuestion } from "./questions";
33
34
  import { listOrchestratorPeers, messageOrchestrator } from "./cross";
35
+ import { sweepArm, sweepDryRun } from "./sweep-arm";
34
36
  import { PAUSED_REFUSAL } from "./pause";
35
37
  import { readSession } from "./read";
36
38
  import { reviewTarget, signOffTarget } from "./external-gates";
@@ -216,6 +218,12 @@ export async function runTool(
216
218
  const a = p(tool, raw);
217
219
  return moveTaskTo(w, a.task, a.machine);
218
220
  }
221
+ case "sweep_dry_run":
222
+ return sweepDryRun(p(tool, raw).machine);
223
+ case "sweep_arm": {
224
+ const a = p(tool, raw);
225
+ return sweepArm(w, a.machine, a.on, a.reason);
226
+ }
219
227
  case "stop":
220
228
  return stop(w, p(tool, raw).session);
221
229
  case "done":
@@ -230,6 +238,10 @@ export async function runTool(
230
238
  const a = p(tool, raw);
231
239
  return addProject(w, a.path, a.name);
232
240
  }
241
+ case "project_tier": {
242
+ const a = p(tool, raw);
243
+ return projectTierTool(w, a.project, a.tier);
244
+ }
233
245
  case "note":
234
246
  addNote(w, p(tool, raw).text);
235
247
  return "Noted.";
@@ -265,10 +277,12 @@ export async function runTool(
265
277
  const a = p(tool, raw);
266
278
  return reviewTarget(w, a.target, { fresh: a.fresh });
267
279
  }
268
- case "sign_off":
269
- return signOffTarget(w, p(tool, raw).task);
280
+ case "sign_off": {
281
+ const a = p(tool, raw);
282
+ return signOffTarget(w, a.task, { direct: a.direct, reason: a.reason });
283
+ }
270
284
  case "arm":
271
- return arm(w, p(tool, raw).task);
285
+ return await arm(w, p(tool, raw).task);
272
286
  case "disarm":
273
287
  return disarm(w, p(tool, raw).task);
274
288
  case "ask_saad":
@@ -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 a person decides: Health, Clean-up, Turn on. `--dry-run` and `--disarm` are fine.";
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.`;
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;
@@ -617,3 +617,25 @@ export async function shellRefusal(
617
617
  }
618
618
  return null;
619
619
  }
620
+
621
+ // The one thing full access still flags: a command that looks like it stops
622
+ // or restarts AgentOS or its tmux, which ends every session it runs, this one
623
+ // included. A warning, never a refusal; null for anything else.
624
+ export async function shellWarning(
625
+ command: unknown,
626
+ ctx: ShellContext
627
+ ): Promise<string | null> {
628
+ if (typeof command !== "string") return null;
629
+ const parsed = parseShell(command);
630
+ if (typeof parsed === "string") return null;
631
+ for (const cmd of parsed) {
632
+ const invs = invocations(cmd);
633
+ if (typeof invs === "string") continue;
634
+ for (const inv of invs) {
635
+ const why = await stopRefusal(inv, ctx).catch(() => null);
636
+ if (why)
637
+ return `Warning: ${why.replace(/^Refused: /, "")} It will run anyway.`;
638
+ }
639
+ }
640
+ return null;
641
+ }
@@ -6,6 +6,7 @@ import { beforeAll, beforeEach, expect, vi } from "vitest";
6
6
  import type { TaskPR } from "@/lib/tasks/state";
7
7
  import type { ClaudeRun } from "./claude-cli";
8
8
  import type { SessionFacts } from "./facts";
9
+ import { tempDir } from "@/lib/test-temp";
9
10
 
10
11
  // Shared fixtures for the signoff*.test.ts files: a real repository with a
11
12
  // local bare remote, gh faked around it. Import this before anything it mocks.
@@ -162,7 +163,7 @@ export const askUp = async (
162
163
  export const POLL = { timeout: 20000, interval: 20 };
163
164
 
164
165
  beforeAll(() => {
165
- const home = fs.mkdtempSync(path.join(os.tmpdir(), "aos-orch-signoff-"));
166
+ const home = tempDir("aos-orch-signoff-");
166
167
  vi.spyOn(os, "homedir").mockReturnValue(home);
167
168
  });
168
169
  beforeEach(() => {
@@ -212,7 +213,7 @@ export function setup(
212
213
  } = {}
213
214
  ) {
214
215
  setMergeApprovals(opts.approvals ?? false);
215
- const root = fs.mkdtempSync(path.join(os.tmpdir(), "aos-orch-repo-"));
216
+ const root = tempDir("aos-orch-repo-");
216
217
  const remote = path.join(root, "remote.git");
217
218
  const repo = path.join(root, "repo");
218
219
  git(root, "init", "-q", "--bare", "-b", "main", remote);
@@ -230,6 +231,9 @@ export function setup(
230
231
  workingDirectory: repo,
231
232
  });
232
233
  setProjectWorkspace(project.id, workspace.id);
234
+ db.prepare(`UPDATE projects SET tier = 'production' WHERE id = ?`).run(
235
+ project.id
236
+ );
233
237
  ensureOrchestrator(workspace.id);
234
238
  const task = seedSession({
235
239
  projectId: project.id,
@@ -41,6 +41,8 @@ import {
41
41
  reviewSignature,
42
42
  } from "./loop-guard";
43
43
  import { addNote } from "./notes";
44
+ import { isFast } from "../project-tier/getProjectTier";
45
+ import { recordFastMerge } from "./fast-lane-state";
44
46
  import { latestApproval, refundApproval, spendApproval } from "./ask-approvals";
45
47
  import { workSubject } from "./asks";
46
48
  import { carryApproval } from "./approval-carry";
@@ -71,7 +73,7 @@ export function restackedOf(task: Session) {
71
73
 
72
74
  // `approval`: Saad approved this merge on his asks list, for this commit.
73
75
  export type Verdict =
74
- | { ok: true; sha: string; pr: number; approval?: number }
76
+ | { ok: true; sha: string; pr: number; approval?: number; fast?: true }
75
77
  | { ok: false; wait: boolean; text: string };
76
78
 
77
79
  // Whether the task may merge now, and at which commit. Landing merges a
@@ -107,13 +109,15 @@ export async function judge(
107
109
  // A PR quick_pr opened (by the row AgentOS wrote, never the body): its
108
110
  // code review is the independent review of this head, and until that
109
111
  // passes it waits, before any gate is judged or counted.
110
- const quick = isQuick(task)
111
- ? quickCodeReview(
112
- sha,
113
- getCheck(task.id, sha, "review"),
114
- restackedOf(task)?.to === sha
115
- )
116
- : null;
112
+ const fast = isFast(task);
113
+ const quick =
114
+ isQuick(task) && !fast
115
+ ? quickCodeReview(
116
+ sha,
117
+ getCheck(task.id, sha, "review"),
118
+ restackedOf(task)?.to === sha
119
+ )
120
+ : null;
117
121
  // Saad's approval of this head stands in for that review (gateVerdict).
118
122
  if (
119
123
  quick?.wait &&
@@ -157,7 +161,7 @@ export async function judge(
157
161
  return quick ? { refusal: gate.refusal, wait: null } : gate;
158
162
  })(),
159
163
  }),
160
- opts
164
+ { ...opts, fast }
161
165
  );
162
166
  }
163
167
 
@@ -178,8 +182,9 @@ export async function gateVerdict(
178
182
  workspaceId: string,
179
183
  work: GateWork,
180
184
  inputs: () => Promise<Omit<GateInput, "sha" | "ruleBreaks">>,
181
- opts: { count?: boolean } = {}
185
+ opts: { count?: boolean; fast?: boolean } = {}
182
186
  ): Promise<Verdict> {
187
+ const fast = !!opts.fast;
183
188
  const no = (text: string, wait = false): Verdict => ({
184
189
  ok: false,
185
190
  wait,
@@ -216,7 +221,9 @@ export async function gateVerdict(
216
221
  : null;
217
222
  const refusal = i.codeReview.refusal ?? stack;
218
223
  if (refusal) return no(`${approved} ${refusal}`);
219
- const ci = ciGate({ sha, ...i, repoCI: await repoCIFor(repo, work, i) });
224
+ const ci = fast
225
+ ? { state: "pass" as const }
226
+ : ciGate({ sha, ...i, repoCI: await repoCIFor(repo, work, i) });
220
227
  if (ci.state === "fail") return no(`${approved} ${ci.reason}`);
221
228
  if (ci.state === "wait") return no(`${approved} ${ci.reason}`, true);
222
229
  // "escalate" is a repository with no CI: Saad is who that goes to.
@@ -245,7 +252,7 @@ export async function gateVerdict(
245
252
  }
246
253
  // Here too, not only in the review: one reviewed in parts while approvals
247
254
  // were off goes to Saad once they're on.
248
- if (mergeApprovalsOn()) {
255
+ if (mergeApprovalsOn() && !fast) {
249
256
  const size = (await fullDiff(repo, base, sha)).length;
250
257
  if (size > DIFF_CAP) {
251
258
  const why = `PR #${pr.number}'s diff at ${short(sha)} is ${size} characters, too big to review whole`;
@@ -255,8 +262,11 @@ export async function gateVerdict(
255
262
 
256
263
  const ruled = ruleBreaks(files, await addedLines(repo, base, sha));
257
264
  const live = await inputs();
258
- const repoCI = await repoCIFor(repo, base, live);
259
- const outcomes = evaluateGates({ sha, ruleBreaks: ruled, ...live, repoCI });
265
+ const repoCI = fast ? undefined : await repoCIFor(repo, base, live);
266
+ const outcomes = evaluateGates(
267
+ { sha, ruleBreaks: ruled, ...live, repoCI },
268
+ { fast }
269
+ );
260
270
  const head = `${work.name} (PR #${pr.number} at ${short(sha)}) can't merge:`;
261
271
  const toSaad = outcomes.find((o) => o.state === "escalate");
262
272
  if (toSaad)
@@ -266,9 +276,12 @@ export async function gateVerdict(
266
276
  const failed = outcomes.filter((o) => o.state === "fail");
267
277
  const waiting = outcomes.filter((o) => o.state === "wait");
268
278
  if (!failed.length && !waiting.length)
269
- return { ok: true, sha, pr: pr.number };
279
+ return fast
280
+ ? { ok: true, sha, pr: pr.number, fast: true }
281
+ : { ok: true, sha, pr: pr.number };
270
282
  const lines: string[] = [];
271
- if (opts.count === false)
283
+ // The fast lane has no loop guard: a failure is told, never counted.
284
+ if (opts.count === false || fast)
272
285
  lines.push(...failed.map((o) => `- ${o.gate} failed: ${o.reason}`));
273
286
  else {
274
287
  for (const o of outcomes)
@@ -346,11 +359,15 @@ export async function mergeJudged(
346
359
  await refundIfRefused(task.id, verdict.approval, async () => {
347
360
  method = await signOffTask(task.id, { head: verdict.sha, wait: opts.wait });
348
361
  });
362
+ if (verdict.fast)
363
+ recordFastMerge(task.id, workspaceId, verdict.pr, verdict.sha);
349
364
  addNote(
350
365
  workspaceId,
351
- verdict.approval
352
- ? `Merged ${task.name} (PR #${verdict.pr}, ${short(verdict.sha)}) on Saad's approval of that commit.`
353
- : `Merged ${task.name} (PR #${verdict.pr}, ${short(verdict.sha)}): CI green, review passed, in scope.`
366
+ verdict.fast
367
+ ? `Merged ${task.name} (PR #${verdict.pr}, ${short(verdict.sha)}) on the fast lane: no CI wait or review.`
368
+ : 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)}): CI green, review passed, in scope.`
354
371
  );
355
372
  return `Merged ${task.name}: PR #${verdict.pr} ${method ? MERGED_AS[method] : "merged"} at ${short(verdict.sha)}.`;
356
373
  }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * The orchestrator's way to turn the full disk clean-up on or off, for this
3
+ * machine or a linked one. Turning it on is Saad's call, so the tool says it's
4
+ * for when he has said to, wants the latest dry run of that machine from the
5
+ * last hour (what it would remove is what he agreed to), and logs who and
6
+ * the dry run's totals. A linked machine is armed over ssh to its own
7
+ * loopback, where its AgentOS trusts the caller: its arm endpoint refuses a
8
+ * device token on purpose.
9
+ */
10
+
11
+ import { getHost, hostExec, hostIdNamed, isRemoteHost } from "../hosts";
12
+ import { remotePort } from "../hosts/link";
13
+ import {
14
+ cleanRemoteText,
15
+ hostApi,
16
+ requireHostLink,
17
+ type HostLink,
18
+ } from "../hosts/remote-api";
19
+ import { fromSqliteTime } from "../session-meta";
20
+ import { setReaperArmed } from "../worktree-sweep/settings";
21
+ import { sweepNow } from "../worktree-sweep/start";
22
+ import { sweepState, type SweepState } from "../worktree-sweep/state";
23
+ import type { SweepRun } from "../worktree-sweep/types";
24
+ import { addNote } from "./notes";
25
+ import { PAUSED_REFUSAL } from "./pause";
26
+ import { getWorkspace } from "../workspaces";
27
+
28
+ export const DRY_RUN_FRESH_MS = 60 * 60 * 1000;
29
+
30
+ const TOOL_DRY = "sweep_dry_run";
31
+ const GB = (b: number) => (b / 1024 ** 3).toFixed(2) + " GB";
32
+
33
+ type Verdict = { ok: true; run: SweepRun } | { ok: false; why: string };
34
+
35
+ // The newest dry run decides: an older fresh one doesn't stand in for it.
36
+ export function dryRunVerdict(
37
+ runs: SweepRun[],
38
+ where: string,
39
+ now = Date.now()
40
+ ): Verdict {
41
+ const next = `Run ${TOOL_DRY} for ${where} first (it takes a few minutes), then call sweep_arm again.`;
42
+ const run = runs.find((r) => r.dryRun);
43
+ if (!run) return { ok: false, why: `${where} has no dry run. ${next}` };
44
+ if (!run.finishedAt)
45
+ return {
46
+ ok: false,
47
+ why: `The dry run on ${where} is still going. Wait for it, then call sweep_arm again.`,
48
+ };
49
+ if (run.error)
50
+ return {
51
+ ok: false,
52
+ why: `The latest dry run on ${where} failed (${run.error}). ${next}`,
53
+ };
54
+ const age = now - fromSqliteTime(run.finishedAt).getTime();
55
+ if (age > DRY_RUN_FRESH_MS)
56
+ return {
57
+ ok: false,
58
+ why: `The latest dry run on ${where} is ${Math.round(age / 60000)} minutes old; it must be from the last hour. ${next}`,
59
+ };
60
+ return { ok: true, run };
61
+ }
62
+
63
+ export function totalsLine(run: SweepRun, now = Date.now()): string {
64
+ const d = run.details;
65
+ const mins = run.finishedAt
66
+ ? Math.max(
67
+ 0,
68
+ Math.round((now - fromSqliteTime(run.finishedAt).getTime()) / 60000)
69
+ )
70
+ : 0;
71
+ return (
72
+ `dry run #${run.id} (${mins} min ago): would remove ${run.removed} (${GB(run.freedBytes)}), ` +
73
+ `${d?.pendingTotal?.count ?? 0} (${GB(d?.pendingTotal?.bytes ?? 0)}) waiting for the full clean-up, ${run.skipped} kept` +
74
+ (d?.report ? `; list: ${cleanRemoteText(d.report)}` : "")
75
+ );
76
+ }
77
+
78
+ interface Machine {
79
+ hostId: string;
80
+ name: string;
81
+ link: HostLink | null;
82
+ }
83
+
84
+ function machineOf(ref: string): Machine {
85
+ const hostId = hostIdNamed(ref);
86
+ if (!isRemoteHost(hostId))
87
+ return { hostId, name: "this machine", link: null };
88
+ return {
89
+ hostId,
90
+ name: getHost(hostId)?.name ?? ref,
91
+ link: requireHostLink(hostId),
92
+ };
93
+ }
94
+
95
+ // Another machine's numbers, read defensively.
96
+ function clean(state: SweepState): SweepState {
97
+ const num = (n: unknown) => (Number.isFinite(Number(n)) ? Number(n) : 0);
98
+ return {
99
+ ...state,
100
+ armed: state.armed === true,
101
+ runs: (Array.isArray(state.runs) ? state.runs : []).map((r) => ({
102
+ ...r,
103
+ id: num(r.id),
104
+ dryRun: r.dryRun === true,
105
+ removed: num(r.removed),
106
+ freedBytes: num(r.freedBytes),
107
+ skipped: num(r.skipped),
108
+ error: r.error ? cleanRemoteText(r.error) : null,
109
+ })),
110
+ };
111
+ }
112
+
113
+ const readState = async (m: Machine): Promise<SweepState> =>
114
+ m.link
115
+ ? clean(await hostApi<SweepState>(m.link, "/api/worktree-sweep"))
116
+ : sweepState();
117
+
118
+ async function setArmed(m: Machine, armed: boolean): Promise<void> {
119
+ if (!m.link) return setReaperArmed(armed);
120
+ if (!armed) {
121
+ await hostApi(m.link, "/api/worktree-sweep", {
122
+ method: "PUT",
123
+ body: { armed: false },
124
+ });
125
+ return;
126
+ }
127
+ const port = remotePort(m.link.url);
128
+ await hostExec(
129
+ m.hostId,
130
+ `curl -fsS -m 10 -X PUT -H 'Content-Type: application/json' -d '{"armed":true}' http://127.0.0.1:${port}/api/worktree-sweep`,
131
+ 20000
132
+ ).catch((err) => {
133
+ throw new Error(
134
+ `Couldn't turn it on for ${m.name}: ${cleanRemoteText(err instanceof Error ? err.message : err)}`
135
+ );
136
+ });
137
+ if (!(await readState(m)).armed)
138
+ throw new Error(`${m.name} did not turn the full clean-up on`);
139
+ }
140
+
141
+ export async function sweepArm(
142
+ workspaceId: string,
143
+ machine: string,
144
+ on: boolean,
145
+ reason: string
146
+ ): Promise<string> {
147
+ const m = machineOf(machine);
148
+ const state = await readState(m);
149
+ const latest = state.runs.find((r) => r.dryRun && r.finishedAt && !r.error);
150
+ if (state.armed === on)
151
+ return `The full clean-up is already ${on ? "on" : "off"} for ${m.name}.`;
152
+ if (on) {
153
+ // Turning it off only makes things safer, so Pause doesn't hold it.
154
+ if (getWorkspace(workspaceId)?.orch_paused_at)
155
+ throw new Error(PAUSED_REFUSAL);
156
+ const verdict = dryRunVerdict(state.runs, m.name);
157
+ if (!verdict.ok) throw new Error(verdict.why);
158
+ }
159
+ await setArmed(m, on);
160
+ const totals = latest ? ` Latest ${totalsLine(latest)}.` : "";
161
+ const line = `Orchestrator turned the full disk clean-up ${on ? "on" : "off"} for ${m.name}: ${reason}.${totals}`;
162
+ addNote(workspaceId, line);
163
+ return line;
164
+ }
165
+
166
+ export async function sweepDryRun(machine: string): Promise<string> {
167
+ 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
+ if (m.link) {
170
+ await hostApi(m.link, "/api/worktree-sweep/run", {
171
+ body: { dryRun: true },
172
+ });
173
+ return started;
174
+ }
175
+ await new Promise<void>((resolve, reject) => {
176
+ sweepNow(true).then(() => resolve(), reject);
177
+ setTimeout(resolve, 250);
178
+ });
179
+ return started;
180
+ }
@@ -29,6 +29,8 @@ export const TOOL_NAMES = {
29
29
  plan_pieces: name("plan_pieces"),
30
30
  drop: name("drop"),
31
31
  move: name("move"),
32
+ sweep_dry_run: name("sweep_dry_run"),
33
+ sweep_arm: name("sweep_arm"),
32
34
  set_priority: name("set_priority"),
33
35
  start_now: name("start_now"),
34
36
  pause_task: name("pause_task"),
@@ -38,6 +40,7 @@ export const TOOL_NAMES = {
38
40
  archive: name("archive"),
39
41
  resurrect: name("resurrect"),
40
42
  add_project: name("add_project"),
43
+ project_tier: name("project_tier"),
41
44
  note: name("note"),
42
45
  question: name("question"),
43
46
  resolve_question: name("resolve_question"),
@@ -67,7 +70,8 @@ export const TOOL_NAMES = {
67
70
  // task worktree other than its own quick changes, writing AgentOS's state,
68
71
  // stopping AgentOS or its tmux, and reading secrets. That catches honest
69
72
  // mistakes, not a determined bypass. Its file writes are checked the same
70
- // way, against a longer list (write-guard.ts).
73
+ // way, against a longer list (write-guard.ts). All of that applies only with
74
+ // the workspace's Orchestrator full access off (full-access.ts).
71
75
  export const ORCHESTRATOR_PERMISSIONS: {
72
76
  permissionMode: PermissionMode;
73
77
  allowedTools: string[];
@@ -5,6 +5,7 @@ import { z } from "zod";
5
5
  import { BRANCH_NAME } from "../git";
6
6
  import { RAISED_KINDS } from "./asks";
7
7
  import { MAX_RULE_CHARS } from "./rules";
8
+ import { PROJECT_TIERS } from "../project-tier/types";
8
9
  import { PROJECT_NAME } from "../project-add/name";
9
10
  import { TASK_ROLES, type ModelRole } from "../models/roles";
10
11
  import { PRIORITIES } from "../tasks/schedule-types";
@@ -21,6 +22,13 @@ const view = z
21
22
  "How its agent runs: chat (the default), or terminal only when the job needs a TUI (an interactive program, a full-screen tool)"
22
23
  );
23
24
 
25
+ const sweepMachine = z
26
+ .string()
27
+ .trim()
28
+ .min(1)
29
+ .max(200)
30
+ .describe('A linked machine\'s name (see Machines), or "here" for this one');
31
+
24
32
  const modelRef = z
25
33
  .string()
26
34
  .trim()
@@ -153,8 +161,9 @@ export const TOOL_SHAPES = {
153
161
  model: modelOverride,
154
162
  plan: z
155
163
  .boolean()
164
+ .optional()
156
165
  .describe(
157
- "Your decision for this job: true to plan first on the planning model, then implement on the implementation model; false to skip the plan and put the steps in the prompt yourself"
166
+ "Your decision for this job: true to plan first on the planning model, then implement on the implementation model; false to skip the plan and put the steps in the prompt yourself. Default: false in a fast project, the workspace's setting in a production one"
158
167
  ),
159
168
  plan_only: z
160
169
  .boolean()
@@ -167,6 +176,7 @@ export const TOOL_SHAPES = {
167
176
  .trim()
168
177
  .min(3)
169
178
  .max(200)
179
+ .optional()
170
180
  .describe(
171
181
  'Why, in a few words Saad reads on the task card: "touches 6 files across gates and UI", "one-line default change"'
172
182
  ),
@@ -268,6 +278,17 @@ export const TOOL_SHAPES = {
268
278
  'A linked machine\'s name (see Machines), or "here" to bring the task back'
269
279
  ),
270
280
  },
281
+ sweep_dry_run: { machine: sweepMachine },
282
+ sweep_arm: {
283
+ machine: sweepMachine,
284
+ on: z.boolean().describe("true turns the full clean-up on, false off"),
285
+ reason: z
286
+ .string()
287
+ .trim()
288
+ .min(1)
289
+ .max(500)
290
+ .describe("What Saad said, and when; it goes in the decision log"),
291
+ },
271
292
  set_priority: {
272
293
  task: ref("A queued or running task: name, project/name or id"),
273
294
  priority: z.enum(PRIORITIES),
@@ -320,6 +341,10 @@ export const TOOL_SHAPES = {
320
341
  .optional()
321
342
  .describe("A new project's name (default: the folder's)"),
322
343
  },
344
+ project_tier: {
345
+ project: z.string().trim().min(1).max(200).optional(),
346
+ tier: z.enum(PROJECT_TIERS).optional(),
347
+ },
323
348
  note: { text: z.string().trim().min(1).max(2000) },
324
349
  question: {
325
350
  text: z
@@ -409,7 +434,21 @@ export const TOOL_SHAPES = {
409
434
  .optional()
410
435
  .describe("Run it again even if this commit has a verdict"),
411
436
  },
412
- sign_off: { task },
437
+ sign_off: {
438
+ task,
439
+ direct: z
440
+ .boolean()
441
+ .optional()
442
+ .describe(
443
+ "Merge now at the PR's head without the gates; needs Orchestrator full access and a reason"
444
+ ),
445
+ reason: z
446
+ .string()
447
+ .trim()
448
+ .max(300)
449
+ .optional()
450
+ .describe("Why the gates are skipped (required with direct)"),
451
+ },
413
452
  arm: { task },
414
453
  disarm: { task },
415
454
  ask_saad: {