@saadnvd1/agent-os 0.3.662 → 0.3.671

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 (122) 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/init/route.test.ts +2 -4
  5. package/app/api/sessions/[id]/mcp-auth/route.test.ts +86 -0
  6. package/app/api/sessions/[id]/mcp-auth/route.ts +58 -0
  7. package/app/api/sessions/[id]/route.ts +3 -3
  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 +40 -1
  11. package/app/api/worktree-sweep/route.ts +45 -6
  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 +77 -1
  17. package/components/Workspaces/WorkspaceSettings.tsx +6 -0
  18. package/data/chat/mcpAuth.ts +42 -0
  19. package/data/workspaces/index.ts +6 -0
  20. package/data/worktree-sweep/index.ts +15 -0
  21. package/lib/agentos-home.ts +11 -0
  22. package/lib/agents/launch.ts +3 -7
  23. package/lib/artifacts/store.ts +2 -5
  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/drivers/pi-approvals.ts +2 -2
  29. package/lib/chat/events.ts +12 -0
  30. package/lib/chat/mcp-auth.ts +193 -0
  31. package/lib/chat/registry.ts +2 -0
  32. package/lib/chat/runner.ts +18 -0
  33. package/lib/chat/worker/client.ts +35 -1
  34. package/lib/chat/worker/host.ts +9 -0
  35. package/lib/chat/worker/main.ts +1 -1
  36. package/lib/chat/worker/mcp-auth.ts +119 -0
  37. package/lib/chat/worker/protocol.ts +21 -2
  38. package/lib/connect/config.ts +2 -3
  39. package/lib/db/backup.ts +2 -2
  40. package/lib/db/migrations/20261018T1600_health_boot_deploys.ts +13 -0
  41. package/lib/db/migrations/20261018T1730_mcp_sign_ins.ts +22 -0
  42. package/lib/db/migrations/20261018T2100_orch_full_access.ts +16 -0
  43. package/lib/db/types.ts +3 -0
  44. package/lib/dev-instance/processInfo.ts +48 -27
  45. package/lib/dev-instance/startDevServer.ts +1 -1
  46. package/lib/dev-instance/stopDevServer.ts +20 -17
  47. package/lib/dev-servers.ts +2 -1
  48. package/lib/done/index.ts +2 -2
  49. package/lib/done/testing.ts +2 -2
  50. package/lib/env-setup.ts +8 -1
  51. package/lib/health/boots.ts +31 -0
  52. package/lib/health/ci.ts +23 -2
  53. package/lib/health/detectors.ts +2 -1
  54. package/lib/health/evidence.ts +1 -1
  55. package/lib/health/facts.ts +3 -3
  56. package/lib/health/fixtures.ts +1 -0
  57. package/lib/health/tick.ts +6 -1
  58. package/lib/health/types.ts +2 -0
  59. package/lib/orchestrator/armed.test-helpers.ts +3 -2
  60. package/lib/orchestrator/brief.ts +14 -9
  61. package/lib/orchestrator/external-gates.ts +62 -1
  62. package/lib/orchestrator/full-access.ts +21 -0
  63. package/lib/orchestrator/home.ts +2 -5
  64. package/lib/orchestrator/quick.ts +47 -4
  65. package/lib/orchestrator/serve.ts +11 -2
  66. package/lib/orchestrator/shell-guard.ts +24 -0
  67. package/lib/orchestrator/signoff.test-helpers.ts +3 -2
  68. package/lib/orchestrator/sweep-arm.ts +180 -0
  69. package/lib/orchestrator/tool-names.ts +4 -1
  70. package/lib/orchestrator/tool-schemas.ts +33 -1
  71. package/lib/orchestrator/tools.ts +6 -2
  72. package/lib/orchestrator/write-guard.ts +37 -9
  73. package/lib/plans/features.ts +2 -2
  74. package/lib/program-status/claude-hooks.ts +2 -2
  75. package/lib/program-status/tap.ts +2 -2
  76. package/lib/project-config/brief.ts +3 -1
  77. package/lib/project-config/database.ts +77 -16
  78. package/lib/project-config/index.ts +108 -21
  79. package/lib/project-config/schema.ts +10 -4
  80. package/lib/project-config/session.ts +27 -6
  81. package/lib/security/net.ts +7 -4
  82. package/lib/security/tailnet-https.ts +2 -2
  83. package/lib/session-artifacts/collect.ts +18 -1
  84. package/lib/session-artifacts/keep.ts +2 -4
  85. package/lib/session-audit/store.ts +2 -5
  86. package/lib/session-logs/folder.ts +2 -3
  87. package/lib/sessions/launch.ts +2 -2
  88. package/lib/sessions/leftovers.ts +61 -0
  89. package/lib/simulator/agent-device.ts +2 -2
  90. package/lib/tasks/finish.ts +2 -2
  91. package/lib/tasks/import.ts +3 -5
  92. package/lib/tasks/move-testing.ts +2 -2
  93. package/lib/tasks/move.ts +2 -2
  94. package/lib/tasks/moved-away.ts +2 -0
  95. package/lib/tasks/state.ts +7 -3
  96. package/lib/tasks/ui-brief.ts +2 -2
  97. package/lib/test-temp.ts +30 -0
  98. package/lib/workspaces.ts +6 -2
  99. package/lib/worktree-deps/fastcopy.ts +2 -2
  100. package/lib/worktree-deps/spare.ts +2 -2
  101. package/lib/worktree-sweep/caches.ts +129 -0
  102. package/lib/worktree-sweep/databases.ts +222 -0
  103. package/lib/worktree-sweep/du.ts +13 -0
  104. package/lib/worktree-sweep/extras.ts +196 -0
  105. package/lib/worktree-sweep/findOrphans.ts +19 -8
  106. package/lib/worktree-sweep/report.ts +86 -0
  107. package/lib/worktree-sweep/runSweep.ts +46 -13
  108. package/lib/worktree-sweep/settings.ts +24 -0
  109. package/lib/worktree-sweep/state.ts +5 -0
  110. package/lib/worktree-sweep/store.ts +11 -0
  111. package/lib/worktree-sweep/testTemp.ts +113 -0
  112. package/lib/worktree-sweep/types.ts +46 -1
  113. package/lib/worktrees.ts +3 -0
  114. package/package.json +2 -1
  115. package/scripts/autodeploy.test.ts +2 -2
  116. package/scripts/check-bundle.test.ts +2 -2
  117. package/scripts/disk-dry-run.ts +57 -0
  118. package/scripts/hooks/banned-terms.test.ts +3 -2
  119. package/scripts/hooks/staged-secrets.test.ts +2 -2
  120. package/scripts/lib/build-swap.test.ts +2 -3
  121. package/scripts/lib/wsl.test.ts +2 -3
  122. package/scripts/update-run.test.ts +2 -2
@@ -25,6 +25,9 @@ import { addNote } from "./notes";
25
25
  import { commitTime } from "./repo";
26
26
  import { review, reviewAt, type ReviewOpts } from "./review";
27
27
  import { gateVerdict, signOff, type Verdict } from "./signoff";
28
+ import { fullAccessOn } from "./full-access";
29
+ import { signOffTask } from "../tasks";
30
+ import { prFor } from "../tasks/session";
28
31
  import { ciSettleIn } from "./task-state";
29
32
  import { untrusted } from "./untrusted";
30
33
 
@@ -186,14 +189,72 @@ export async function reviewExternal(
186
189
  // sign_off: a task, or a PR no task owns in one of the workspace's repos.
187
190
  export async function signOffTarget(
188
191
  workspaceId: string,
189
- ref: string
192
+ ref: string,
193
+ opts: { direct?: boolean; reason?: string } = {}
190
194
  ): Promise<string> {
195
+ if (opts.direct) {
196
+ if (!fullAccessOn(workspaceId))
197
+ throw new Error(
198
+ "A direct merge needs Orchestrator full access (Settings → Workspaces); merge through the gates"
199
+ );
200
+ if (!opts.reason?.trim()) throw new Error("A direct merge needs a reason");
201
+ return signOffDirect(
202
+ workspaceId,
203
+ await gateTarget(workspaceId, ref),
204
+ opts.reason
205
+ );
206
+ }
191
207
  const t = await gateTarget(workspaceId, ref);
192
208
  return t.kind === "external"
193
209
  ? signOffExternal(workspaceId, t.pr)
194
210
  : signOff(workspaceId, t.task.id);
195
211
  }
196
212
 
213
+ // Orchestrator full access: merge now at the PR's current head, skipping the
214
+ // gates. The task's own non-gate checks still run (it's running, its PR is
215
+ // open and on its base, the head is the one merged); a task with Saad (the
216
+ // loop guard, no CI) is his to decide, not skipped here.
217
+ async function signOffDirect(
218
+ workspaceId: string,
219
+ t: Awaited<ReturnType<typeof gateTarget>>,
220
+ reason: string
221
+ ): Promise<string> {
222
+ if (t.kind === "external") {
223
+ const { pr, method } = {
224
+ pr: t.pr,
225
+ method: mergePolicy(t.pr.project).method,
226
+ };
227
+ if (!pr.pr.head) throw new Error(`${pr.name}'s head commit is unknown`);
228
+ await mergePR({
229
+ repo: pr.repo,
230
+ number: pr.number,
231
+ slug: pr.slug,
232
+ method,
233
+ head: pr.pr.head,
234
+ });
235
+ resolveAsks(workspaceId, pr.id, `${pr.name} merged directly`);
236
+ addNote(
237
+ workspaceId,
238
+ `Merged ${pr.name} directly at ${short(pr.pr.head)}, skipping the gates: ${reason.trim()}`
239
+ );
240
+ return `Merged ${pr.name}: ${MERGED_AS[method]} at ${short(pr.pr.head)}, without the gates.`;
241
+ }
242
+ const task = t.task;
243
+ if (holdingEscalations(task.id).length)
244
+ throw new Error(
245
+ `${task.name} is with Saad; he signs it off or drops it, a direct merge doesn't go around that`
246
+ );
247
+ const pr = await prFor(task, true);
248
+ if (!pr || pr.state !== "OPEN" || !pr.head)
249
+ throw new Error(`${task.name} has no open PR`);
250
+ const method = await signOffTask(task.id, { head: pr.head });
251
+ addNote(
252
+ workspaceId,
253
+ `Merged ${task.name} directly (PR #${pr.number}, ${short(pr.head)}), skipping the gates: ${reason.trim()}`
254
+ );
255
+ return `Merged ${task.name}: PR #${pr.number} ${method ? MERGED_AS[method] : "merged"} at ${short(pr.head)}, without the gates.`;
256
+ }
257
+
197
258
  // review: the same targets as sign_off.
198
259
  export async function reviewTarget(
199
260
  workspaceId: string,
@@ -0,0 +1,21 @@
1
+ /**
2
+ * "Orchestrator full access" (Saad, 2026-10-18): a workspace's orchestrator
3
+ * runs with no shell deny list, no write limits, no quick-change cap, and
4
+ * may merge directly. Per workspace, on by default, switched in Settings →
5
+ * Workspaces; off restores every guard unchanged. Only an orchestrator
6
+ * session is ever judged by it: task agents, ordinary sessions, devices and
7
+ * other machines' callers are never loosened.
8
+ */
9
+
10
+ import { getWorkspace } from "../workspaces";
11
+ import type { Session } from "../db";
12
+
13
+ export function fullAccessOn(workspaceId: string | null | undefined): boolean {
14
+ return !!workspaceId && !!getWorkspace(workspaceId)?.orch_full_access;
15
+ }
16
+
17
+ export function orchestratorFullAccess(
18
+ session: Pick<Session, "role" | "workspace_id">
19
+ ): boolean {
20
+ return session.role === "orchestrator" && fullAccessOn(session.workspace_id);
21
+ }
@@ -5,10 +5,9 @@
5
5
  * sessions.
6
6
  */
7
7
 
8
+ import { agentosHome } from "../agentos-home";
8
9
  import { resolveRoleOrDefault } from "../models/resolve";
9
10
  import fs from "fs";
10
- import os from "os";
11
- import path from "path";
12
11
  import { randomBytes, randomUUID, timingSafeEqual } from "crypto";
13
12
  import { db, type Session } from "../db";
14
13
  import { getWorkspace } from "../workspaces";
@@ -16,9 +15,7 @@ import { refuseRealStateUnderTest } from "../test-home-guard";
16
15
 
17
16
  // It works through tools, not in a repo: a scratch folder of its own.
18
17
  export const orchestratorDir = (workspaceId: string) =>
19
- refuseRealStateUnderTest(
20
- path.join(os.homedir(), ".agent-os", "orchestrators", workspaceId)
21
- );
18
+ refuseRealStateUnderTest(agentosHome("orchestrators", workspaceId));
22
19
 
23
20
  export function getOrchestrator(workspaceId: string): Session | null {
24
21
  return (
@@ -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);
@@ -31,6 +31,7 @@ import { addRule, removeRule } from "./rules";
31
31
  import { askSaad } from "./ask-tool";
32
32
  import { askQuestion, resolveQuestion } from "./questions";
33
33
  import { listOrchestratorPeers, messageOrchestrator } from "./cross";
34
+ import { sweepArm, sweepDryRun } from "./sweep-arm";
34
35
  import { PAUSED_REFUSAL } from "./pause";
35
36
  import { readSession } from "./read";
36
37
  import { reviewTarget, signOffTarget } from "./external-gates";
@@ -216,6 +217,12 @@ export async function runTool(
216
217
  const a = p(tool, raw);
217
218
  return moveTaskTo(w, a.task, a.machine);
218
219
  }
220
+ case "sweep_dry_run":
221
+ return sweepDryRun(p(tool, raw).machine);
222
+ case "sweep_arm": {
223
+ const a = p(tool, raw);
224
+ return sweepArm(w, a.machine, a.on, a.reason);
225
+ }
219
226
  case "stop":
220
227
  return stop(w, p(tool, raw).session);
221
228
  case "done":
@@ -265,8 +272,10 @@ export async function runTool(
265
272
  const a = p(tool, raw);
266
273
  return reviewTarget(w, a.target, { fresh: a.fresh });
267
274
  }
268
- case "sign_off":
269
- return signOffTarget(w, p(tool, raw).task);
275
+ case "sign_off": {
276
+ const a = p(tool, raw);
277
+ return signOffTarget(w, a.task, { direct: a.direct, reason: a.reason });
278
+ }
270
279
  case "arm":
271
280
  return arm(w, p(tool, raw).task);
272
281
  case "disarm":
@@ -229,6 +229,8 @@ function aosRefusal(args: string[]): string | null {
229
229
  resurrect: T.resurrect,
230
230
  stop: T.stop,
231
231
  };
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.`;
232
234
  if (sub && tool[sub])
233
235
  return `Refused: \`aos ${sub}\` isn't for you; use ${tool[sub]}, which is scoped to this workspace and held by Pause.`;
234
236
  return null;
@@ -615,3 +617,25 @@ export async function shellRefusal(
615
617
  }
616
618
  return null;
617
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);
@@ -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"),
@@ -67,7 +69,8 @@ export const TOOL_NAMES = {
67
69
  // task worktree other than its own quick changes, writing AgentOS's state,
68
70
  // stopping AgentOS or its tmux, and reading secrets. That catches honest
69
71
  // mistakes, not a determined bypass. Its file writes are checked the same
70
- // way, against a longer list (write-guard.ts).
72
+ // way, against a longer list (write-guard.ts). All of that applies only with
73
+ // the workspace's Orchestrator full access off (full-access.ts).
71
74
  export const ORCHESTRATOR_PERMISSIONS: {
72
75
  permissionMode: PermissionMode;
73
76
  allowedTools: string[];
@@ -21,6 +21,13 @@ const view = z
21
21
  "How its agent runs: chat (the default), or terminal only when the job needs a TUI (an interactive program, a full-screen tool)"
22
22
  );
23
23
 
24
+ const sweepMachine = z
25
+ .string()
26
+ .trim()
27
+ .min(1)
28
+ .max(200)
29
+ .describe('A linked machine\'s name (see Machines), or "here" for this one');
30
+
24
31
  const modelRef = z
25
32
  .string()
26
33
  .trim()
@@ -268,6 +275,17 @@ export const TOOL_SHAPES = {
268
275
  'A linked machine\'s name (see Machines), or "here" to bring the task back'
269
276
  ),
270
277
  },
278
+ sweep_dry_run: { machine: sweepMachine },
279
+ sweep_arm: {
280
+ machine: sweepMachine,
281
+ on: z.boolean().describe("true turns the full clean-up on, false off"),
282
+ reason: z
283
+ .string()
284
+ .trim()
285
+ .min(1)
286
+ .max(500)
287
+ .describe("What Saad said, and when; it goes in the decision log"),
288
+ },
271
289
  set_priority: {
272
290
  task: ref("A queued or running task: name, project/name or id"),
273
291
  priority: z.enum(PRIORITIES),
@@ -409,7 +427,21 @@ export const TOOL_SHAPES = {
409
427
  .optional()
410
428
  .describe("Run it again even if this commit has a verdict"),
411
429
  },
412
- sign_off: { task },
430
+ sign_off: {
431
+ task,
432
+ direct: z
433
+ .boolean()
434
+ .optional()
435
+ .describe(
436
+ "Merge now at the PR's head without the gates; needs Orchestrator full access and a reason"
437
+ ),
438
+ reason: z
439
+ .string()
440
+ .trim()
441
+ .max(300)
442
+ .optional()
443
+ .describe("Why the gates are skipped (required with direct)"),
444
+ },
413
445
  arm: { task },
414
446
  disarm: { task },
415
447
  ask_saad: {
@@ -132,7 +132,7 @@ const DESCRIPTIONS: Record<ToolName, string> = {
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
  quick_change:
135
- "Make a small, low-risk edit yourself (a typo, a config value, a one-file fix): cuts a worktree and branch from the project's default branch and returns its path. You may write there (Write, Edit) and run git there as `git -C <path> ...` (status, diff, add, rm, mv, restore, reset, log, show); nowhere else in a project. Anything needing real work, tests or an investigation is a task (start_task).",
135
+ "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).",
136
136
  quick_pr:
137
137
  "Commit everything in a quick change's worktree, push its branch (never the default branch) and open its PR, or push to the PR it already has. The PR goes through the same gates as a task's; with no task agent to run /do-code-review, its code-review gate is the independent review of its exact head, which starts now.",
138
138
  stack:
@@ -145,6 +145,10 @@ const DESCRIPTIONS: Record<ToolName, string> = {
145
145
  "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.",
146
146
  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.",
147
147
  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.',
148
+ sweep_dry_run:
149
+ "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.",
150
+ sweep_arm:
151
+ "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.",
148
152
  set_priority:
149
153
  "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.",
150
154
  start_now:
@@ -183,7 +187,7 @@ const DESCRIPTIONS: Record<ToolName, string> = {
183
187
  review:
184
188
  "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.",
185
189
  sign_off:
186
- "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.",
190
+ "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.",
187
191
  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.",
188
192
  disarm:
189
193
  "Disarm a task armed to merge: it merges only through sign_off again.",