@saadnvd1/agent-os 0.3.664 → 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 (73) 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/shares/route.test.ts +3 -4
  8. package/app/api/workspaces/[id]/route.ts +9 -1
  9. package/app/api/worktree-sweep/route.test.ts +21 -0
  10. package/app/api/worktree-sweep/route.ts +24 -4
  11. package/components/Chat/ChatPanel.tsx +6 -3
  12. package/components/Chat/McpServers.tsx +61 -24
  13. package/components/Chat/McpSignIn.tsx +122 -0
  14. package/components/Chat/session.ts +8 -0
  15. package/components/Health/SweepRuns.tsx +1 -0
  16. package/components/Workspaces/WorkspaceSettings.tsx +6 -0
  17. package/data/chat/mcpAuth.ts +42 -0
  18. package/data/workspaces/index.ts +6 -0
  19. package/lib/chat/driver.ts +24 -2
  20. package/lib/chat/drivers/claude-approvals.ts +7 -0
  21. package/lib/chat/drivers/claude-mcp-auth.ts +82 -0
  22. package/lib/chat/drivers/claude.ts +7 -0
  23. package/lib/chat/events.ts +12 -0
  24. package/lib/chat/mcp-auth.ts +193 -0
  25. package/lib/chat/registry.ts +2 -0
  26. package/lib/chat/runner.ts +18 -0
  27. package/lib/chat/worker/client.ts +35 -1
  28. package/lib/chat/worker/host.ts +9 -0
  29. package/lib/chat/worker/main.ts +1 -1
  30. package/lib/chat/worker/mcp-auth.ts +119 -0
  31. package/lib/chat/worker/protocol.ts +19 -0
  32. package/lib/db/migrations/20261018T1730_mcp_sign_ins.ts +22 -0
  33. package/lib/db/migrations/20261018T2100_orch_full_access.ts +16 -0
  34. package/lib/db/types.ts +3 -0
  35. package/lib/done/testing.ts +2 -2
  36. package/lib/env-setup.ts +8 -1
  37. package/lib/health/boots.ts +2 -8
  38. package/lib/orchestrator/armed.test-helpers.ts +3 -2
  39. package/lib/orchestrator/brief.ts +14 -9
  40. package/lib/orchestrator/external-gates.ts +62 -1
  41. package/lib/orchestrator/full-access.ts +21 -0
  42. package/lib/orchestrator/quick.ts +47 -4
  43. package/lib/orchestrator/serve.ts +11 -2
  44. package/lib/orchestrator/shell-guard.ts +23 -1
  45. package/lib/orchestrator/signoff.test-helpers.ts +3 -2
  46. package/lib/orchestrator/sweep-arm.ts +180 -0
  47. package/lib/orchestrator/tool-names.ts +4 -1
  48. package/lib/orchestrator/tool-schemas.ts +33 -1
  49. package/lib/orchestrator/tools.ts +6 -2
  50. package/lib/orchestrator/write-guard.ts +34 -7
  51. package/lib/project-config/brief.ts +3 -1
  52. package/lib/project-config/database.ts +6 -0
  53. package/lib/project-config/index.ts +108 -21
  54. package/lib/project-config/schema.ts +10 -4
  55. package/lib/project-config/session.ts +9 -6
  56. package/lib/security/net.ts +7 -4
  57. package/lib/tasks/move-testing.ts +2 -2
  58. package/lib/test-temp.ts +30 -0
  59. package/lib/workspaces.ts +6 -2
  60. package/lib/worktree-sweep/caches.ts +6 -0
  61. package/lib/worktree-sweep/extras.ts +16 -8
  62. package/lib/worktree-sweep/report.ts +1 -0
  63. package/lib/worktree-sweep/settings.ts +2 -2
  64. package/lib/worktree-sweep/testTemp.ts +113 -0
  65. package/lib/worktree-sweep/types.ts +2 -0
  66. package/package.json +1 -1
  67. package/scripts/autodeploy.test.ts +2 -2
  68. package/scripts/check-bundle.test.ts +2 -2
  69. package/scripts/hooks/banned-terms.test.ts +3 -2
  70. package/scripts/hooks/staged-secrets.test.ts +2 -2
  71. package/scripts/lib/build-swap.test.ts +2 -3
  72. package/scripts/lib/wsl.test.ts +2 -3
  73. package/scripts/update-run.test.ts +2 -2
@@ -1,17 +1,28 @@
1
1
  /**
2
- * The one reader of a project's config: `agentos.json`, else `.dispatch.json`,
3
- * else the legacy `.agent-os/worktrees.json` / `.agent-os.json`. The first
4
- * file that exists is the config; a broken one is an error, never a reason
5
- * to read the next. Design: docs/decisions/2026-10-10-agentos-json.md.
2
+ * The one reader of a project's config: `agentos.json` and `.dispatch.json`
3
+ * together, else the legacy `.agent-os/worktrees.json` / `.agent-os.json`
4
+ * (the first of those that exists). A broken file is an error, never a
5
+ * reason to read the next or to ignore it.
6
+ *
7
+ * When both agentos.json and .dispatch.json exist they merge by top-level
8
+ * key: agentos.json wins any key it sets, and its value replaces
9
+ * .dispatch.json's whole (no deep merge, so a partial `browse` never mixes
10
+ * with the old one); keys only .dispatch.json has still apply, after its
11
+ * normalization (`workspace` becomes `cards.workspace`, dispatch-only keys
12
+ * are dropped). `inherited` names those keys and `warning` says to move them.
13
+ * Design: docs/decisions/2026-10-10-agentos-json.md.
6
14
  */
7
15
 
8
16
  import * as fs from "fs";
9
17
  import * as path from "path";
10
18
  import type { z } from "zod";
11
19
  import {
20
+ agentosFields,
12
21
  agentosJson,
22
+ dispatchFields,
13
23
  dispatchJson,
14
24
  legacyJson,
25
+ mergedJson,
15
26
  RESERVED_ENV,
16
27
  type ProjectConfig,
17
28
  } from "./schema";
@@ -34,9 +45,20 @@ export interface LoadedConfig {
34
45
  source: string | null;
35
46
  // Why the file was refused, naming it and every bad field.
36
47
  error: string | null;
48
+ // Top-level keys that came from .dispatch.json because agentos.json lacks
49
+ // them; empty unless both files exist.
50
+ inherited: string[];
51
+ // One line for the brief when both files exist; null otherwise.
52
+ warning: string | null;
37
53
  }
38
54
 
39
- const NONE: LoadedConfig = { config: {}, source: null, error: null };
55
+ export const NONE: LoadedConfig = Object.freeze({
56
+ config: {},
57
+ source: null,
58
+ error: null,
59
+ inherited: Object.freeze([]) as unknown as string[],
60
+ warning: null,
61
+ });
40
62
 
41
63
  function describe(file: string, error: z.ZodError): string {
42
64
  const fields = error.issues.map((i) => {
@@ -61,7 +83,9 @@ function fromLegacy(raw: z.infer<typeof legacyJson>): ProjectConfig {
61
83
  return config;
62
84
  }
63
85
 
64
- function parse(file: string, text: string): LoadedConfig {
86
+ // `checked: false` leaves the port-name check to the merge, which can see
87
+ // the ports the other file declares.
88
+ function parse(file: string, text: string, checked = true): LoadedConfig {
65
89
  let raw: unknown;
66
90
  try {
67
91
  raw = JSON.parse(text);
@@ -73,31 +97,102 @@ function parse(file: string, text: string): LoadedConfig {
73
97
  };
74
98
  }
75
99
  if (file === "agentos.json") {
76
- const got = agentosJson.safeParse(raw);
100
+ const got = (checked ? agentosJson : agentosFields).safeParse(raw);
77
101
  return got.success
78
- ? { config: got.data, source: file, error: null }
102
+ ? { ...NONE, config: got.data, source: file }
79
103
  : { ...NONE, source: file, error: describe(file, got.error) };
80
104
  }
81
105
  if (file === ".dispatch.json") {
82
- const got = dispatchJson.safeParse(raw);
106
+ const got = (checked ? dispatchJson : dispatchFields).safeParse(raw);
83
107
  if (!got.success)
84
108
  return { ...NONE, source: file, error: describe(file, got.error) };
85
109
  const { workspace, ...config } = got.data;
86
110
  if (workspace && !config.cards?.workspace)
87
111
  config.cards = { ...config.cards, workspace };
88
- return { config, source: file, error: null };
112
+ return { ...NONE, config, source: file };
89
113
  }
90
114
  const got = legacyJson.safeParse(raw);
91
115
  return got.success
92
- ? { config: fromLegacy(got.data), source: file, error: null }
116
+ ? { ...NONE, config: fromLegacy(got.data), source: file }
93
117
  : { ...NONE, source: file, error: describe(file, got.error) };
94
118
  }
95
119
 
120
+ export const bothFilesWarning = (inherited: string[]): string =>
121
+ `This repo has both agentos.json and .dispatch.json; keys ${inherited.join(", ")} come from .dispatch.json. Move them into agentos.json and delete .dispatch.json.`;
122
+
123
+ function merge(agentos: ProjectConfig, dispatch: ProjectConfig): LoadedConfig {
124
+ const inherited = Object.keys(dispatch).filter((k) => !(k in agentos));
125
+ const config = {
126
+ ...Object.fromEntries(
127
+ inherited.map((k) => [k, dispatch[k as keyof ProjectConfig]])
128
+ ),
129
+ ...agentos,
130
+ } as ProjectConfig;
131
+ const got = mergedJson.safeParse(config);
132
+ if (!got.success)
133
+ return {
134
+ ...NONE,
135
+ source: "agentos.json",
136
+ error: describe("agentos.json with .dispatch.json", got.error),
137
+ };
138
+ return {
139
+ ...NONE,
140
+ config,
141
+ source: "agentos.json",
142
+ inherited,
143
+ warning: inherited.length ? bothFilesWarning(inherited) : null,
144
+ };
145
+ }
146
+
147
+ function statOf(projectPath: string, file: string): fs.Stats | null {
148
+ try {
149
+ const stat = fs.statSync(path.join(projectPath, file));
150
+ return stat.isFile() ? stat : null;
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ function readFile(projectPath: string, file: string): string | LoadedConfig {
157
+ try {
158
+ return fs.readFileSync(path.join(projectPath, file), "utf-8");
159
+ } catch (e) {
160
+ return {
161
+ ...NONE,
162
+ source: file,
163
+ error: `${file} could not be read: ${(e as Error).message}`,
164
+ };
165
+ }
166
+ }
167
+
168
+ // Both files, merged. A broken one is the error, whichever it is.
169
+ function loadBoth(projectPath: string): LoadedConfig {
170
+ const parsed: LoadedConfig[] = [];
171
+ for (const file of ["agentos.json", ".dispatch.json"]) {
172
+ const text = readFile(projectPath, file);
173
+ if (typeof text !== "string") return text;
174
+ const got = parse(file, text, false);
175
+ if (got.error) return got;
176
+ parsed.push(got);
177
+ }
178
+ return merge(parsed[0].config, parsed[1].config);
179
+ }
180
+
96
181
  // Keyed on the project; reused while the file's mtime and size hold, since
97
182
  // agentEnv reads it for every terminal and chat start.
98
183
  const cache = new Map<string, { stamp: string; loaded: LoadedConfig }>();
99
184
 
100
185
  export function loadProjectConfig(projectPath: string): LoadedConfig {
186
+ const a = statOf(projectPath, "agentos.json");
187
+ const d = statOf(projectPath, ".dispatch.json");
188
+ if (a && d) {
189
+ const stamp = `both:${a.mtimeMs}:${a.size}:${d.mtimeMs}:${d.size}`;
190
+ const hit = cache.get(projectPath);
191
+ if (hit?.stamp === stamp) return hit.loaded;
192
+ const loaded = loadBoth(projectPath);
193
+ cache.set(projectPath, { stamp, loaded });
194
+ return loaded;
195
+ }
101
196
  for (const file of CONFIG_FILES) {
102
197
  const full = path.join(projectPath, file);
103
198
  let stat: fs.Stats;
@@ -110,16 +205,8 @@ export function loadProjectConfig(projectPath: string): LoadedConfig {
110
205
  const stamp = `${file}:${stat.mtimeMs}:${stat.size}`;
111
206
  const hit = cache.get(projectPath);
112
207
  if (hit?.stamp === stamp) return hit.loaded;
113
- let text: string;
114
- try {
115
- text = fs.readFileSync(full, "utf-8");
116
- } catch (e) {
117
- return {
118
- ...NONE,
119
- source: file,
120
- error: `${file} could not be read: ${(e as Error).message}`,
121
- };
122
- }
208
+ const text = readFile(projectPath, file);
209
+ if (typeof text !== "string") return text;
123
210
  const loaded = parse(file, text);
124
211
  cache.set(projectPath, { stamp, loaded });
125
212
  return loaded;
@@ -228,13 +228,19 @@ function portRefs(
228
228
 
229
229
  // agentos.json: an unknown key is an error, because it is nearly always a
230
230
  // typo that would otherwise be silently ignored.
231
- export const agentosJson = z.strictObject(shape).superRefine(portRefs);
231
+ export const agentosFields = z.strictObject(shape);
232
+ export const agentosJson = agentosFields.superRefine(portRefs);
232
233
 
233
234
  // .dispatch.json: the same fields, but dispatch has keys of its own. Its
234
235
  // top-level `workspace` is `cards.workspace` here.
235
- export const dispatchJson = z
236
- .object({ ...shape, workspace: z.string().optional() })
237
- .superRefine(portRefs);
236
+ export const dispatchFields = z.object({
237
+ ...shape,
238
+ workspace: z.string().optional(),
239
+ });
240
+ export const dispatchJson = dispatchFields.superRefine(portRefs);
241
+
242
+ // The cross-key port check on its own, for a config merged from two files.
243
+ export const mergedJson = z.looseObject(shape).superRefine(portRefs);
238
244
 
239
245
  export const legacyJson = z.object({
240
246
  setup: z.array(z.string()).optional(),
@@ -10,7 +10,12 @@ import { sessionTmpDir } from "../sessions/leftovers";
10
10
  import { db } from "../db";
11
11
  import { getProject } from "../projects";
12
12
  import { sessionPorts } from "../ports";
13
- import { loadProjectConfig, projectEnv, type LoadedConfig } from "./index";
13
+ import {
14
+ loadProjectConfig,
15
+ NONE,
16
+ projectEnv,
17
+ type LoadedConfig,
18
+ } from "./index";
14
19
  import { runningBrief } from "./brief";
15
20
  import { databaseEnv, sessionDatabase } from "./database";
16
21
 
@@ -31,9 +36,7 @@ export function sessionConfig(
31
36
  sessionId: string
32
37
  ): LoadedConfig & { ports: Record<string, number> | null } {
33
38
  const projectPath = projectPathOf(sessionId);
34
- const loaded = projectPath
35
- ? loadProjectConfig(projectPath)
36
- : { config: {}, source: null, error: null };
39
+ const loaded = projectPath ? loadProjectConfig(projectPath) : NONE;
37
40
  return { ...loaded, ports: sessionPorts(sessionId) };
38
41
  }
39
42
 
@@ -70,9 +73,9 @@ export function sessionProjectEnv(sessionId: string): Record<string, string> {
70
73
 
71
74
  export function sessionRunningBrief(sessionId: string): string {
72
75
  try {
73
- const { config, source, ports } = sessionConfig(sessionId);
76
+ const { config, source, warning, ports } = sessionConfig(sessionId);
74
77
  return source
75
- ? runningBrief(config, ports ?? {}, sessionDatabase(sessionId))
78
+ ? runningBrief(config, ports ?? {}, sessionDatabase(sessionId), warning)
76
79
  : "";
77
80
  } catch (error) {
78
81
  console.error(`[project-config] brief for ${sessionId}:`, error);
@@ -146,10 +146,13 @@ export function originAllowed(
146
146
 
147
147
  const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
148
148
 
149
- // LumifyHub's approve page sends the browser back here, so this one URL is
150
- // reached by a cross-site top-level navigation. It acts only on a `state`
151
- // this instance issued, which a foreign page can't know.
152
- const CROSS_SITE_NAVIGATIONS = ["/api/lumifyhub/callback"];
149
+ // LumifyHub's approve page and an MCP server's sign-in send the browser back
150
+ // here, so these URLs are reached by a cross-site top-level navigation. Each
151
+ // acts only on a `state` this instance issued, which a foreign page can't know.
152
+ const CROSS_SITE_NAVIGATIONS = [
153
+ "/api/lumifyhub/callback",
154
+ "/api/mcp-auth/callback",
155
+ ];
153
156
 
154
157
  function crossSiteNavigationAllowed(req: {
155
158
  method?: string;
@@ -3,13 +3,13 @@
3
3
  // Tests mock ../worktrees (WORKTREES_DIR), ../env-setup and ../agents/launch.
4
4
 
5
5
  import fs from "fs";
6
- import os from "os";
7
6
  import path from "path";
8
7
  import { randomUUID } from "crypto";
9
8
  import { db, insertSessionRow, type Session } from "../db";
10
9
  import { createProject } from "../projects";
11
10
  import { writeTranscript } from "./transcript";
12
11
  import { gitSync } from "../git-exec";
12
+ import { tempDir } from "@/lib/test-temp";
13
13
 
14
14
  export const CLAUDE_ID = "11111111-2222-3333-4444-555555555555";
15
15
 
@@ -30,7 +30,7 @@ export interface MoveFixture {
30
30
  /** HOME, Claude's folder and git identity all point into a temp dir. */
31
31
  export function setupMoveRepo(): MoveFixture {
32
32
  const saved = { HOME: process.env.HOME };
33
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "aos-move-"));
33
+ const tmp = tempDir("aos-move-");
34
34
  process.env.HOME = tmp;
35
35
  process.env.CLAUDE_CONFIG_DIR = path.join(tmp, ".claude");
36
36
  for (const who of ["AUTHOR", "COMMITTER"]) {
@@ -0,0 +1,30 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ // Folders made by tempDir, removed when the test file ends (vitest.setup.ts
6
+ // calls removeTempDirs). Kept on globalThis: vi.resetModules() would
7
+ // otherwise lose the list.
8
+ const KEY = Symbol.for("agentos.testTempDirs");
9
+ const made = (): Set<string> => {
10
+ const g = globalThis as unknown as Record<symbol, Set<string> | undefined>;
11
+ return (g[KEY] ??= new Set<string>());
12
+ };
13
+
14
+ /** A fresh temp folder that is removed when the test file ends. */
15
+ export function tempDir(prefix: string): string {
16
+ const dir = mkdtempSync(join(tmpdir(), prefix));
17
+ made().add(dir);
18
+ return dir;
19
+ }
20
+
21
+ export function removeTempDirs(): void {
22
+ for (const dir of made()) {
23
+ try {
24
+ rmSync(dir, { recursive: true, force: true, maxRetries: 5 });
25
+ } catch {
26
+ // the run root's removal is the backstop
27
+ }
28
+ }
29
+ made().clear();
30
+ }
package/lib/workspaces.ts CHANGED
@@ -8,10 +8,11 @@ import { db, type Workspace } from "./db";
8
8
 
9
9
  type WorkspaceRow = Omit<
10
10
  Workspace,
11
- "collapsed" | "wake_tasks_on_pr" | "orch_mcp_servers"
11
+ "collapsed" | "wake_tasks_on_pr" | "orch_mcp_servers" | "orch_full_access"
12
12
  > & {
13
13
  collapsed: number;
14
14
  wake_tasks_on_pr: number;
15
+ orch_full_access: number;
15
16
  orch_mcp_servers: string;
16
17
  };
17
18
 
@@ -38,6 +39,7 @@ const toWorkspace = (row: WorkspaceRow): Workspace => ({
38
39
  ...row,
39
40
  collapsed: !!row.collapsed,
40
41
  wake_tasks_on_pr: !!row.wake_tasks_on_pr,
42
+ orch_full_access: !!row.orch_full_access,
41
43
  orch_mcp_servers: parseServers(row.orch_mcp_servers),
42
44
  });
43
45
 
@@ -86,6 +88,7 @@ export function updateWorkspace(
86
88
  sortOrder?: number;
87
89
  maxRunningTasks?: number | null;
88
90
  wakeTasksOnPR?: boolean;
91
+ orchFullAccess?: boolean;
89
92
  mcpServers?: string[];
90
93
  }
91
94
  ): Workspace | null {
@@ -93,13 +96,14 @@ export function updateWorkspace(
93
96
  if (!existing) return null;
94
97
  const name = updates.name?.trim() || existing.name;
95
98
  db.prepare(
96
- `UPDATE workspaces SET name = ?, collapsed = ?, sort_order = ?, max_running_tasks = ?, wake_tasks_on_pr = ?, orch_mcp_servers = ? WHERE id = ?`
99
+ `UPDATE workspaces SET name = ?, collapsed = ?, sort_order = ?, max_running_tasks = ?, wake_tasks_on_pr = ?, orch_full_access = ?, orch_mcp_servers = ? WHERE id = ?`
97
100
  ).run(
98
101
  name,
99
102
  (updates.collapsed ?? existing.collapsed) ? 1 : 0,
100
103
  updates.sortOrder ?? existing.sort_order,
101
104
  taskLimit(updates.maxRunningTasks, existing.max_running_tasks),
102
105
  (updates.wakeTasksOnPR ?? existing.wake_tasks_on_pr) ? 1 : 0,
106
+ (updates.orchFullAccess ?? existing.orch_full_access) ? 1 : 0,
103
107
  JSON.stringify(serverNames(updates.mcpServers, existing.orch_mcp_servers)),
104
108
  id
105
109
  );
@@ -10,6 +10,7 @@ import fs from "fs";
10
10
  import path from "path";
11
11
  import { execFile } from "child_process";
12
12
  import { SESSION_TMP_PREFIX, tmpName } from "../sessions/leftovers";
13
+ import { isTestTempName } from "./testTemp";
13
14
  import type { Candidate, SweepSkipped } from "./types";
14
15
 
15
16
  const DAY_MS = 24 * 3600_000;
@@ -92,6 +93,11 @@ export async function findCacheLeftovers(deps: CacheDeps): Promise<{
92
93
  const livePrefixes = [...deps.liveIds].map(tmpName);
93
94
  for (const dir of subdirs(deps.tmpRoot)) {
94
95
  const name = path.basename(dir);
96
+ if (isTestTempName(name)) {
97
+ if (deps.now - mtime(dir) >= DAY_MS)
98
+ await add(dir, "test-temp", "left behind by a test run");
99
+ continue;
100
+ }
95
101
  const isSession = name.startsWith(SESSION_TMP_PREFIX);
96
102
  if (!isSession && !name.startsWith("agentos-preview-")) continue;
97
103
  if (deps.now - mtime(dir) < DAY_MS) continue;
@@ -69,6 +69,18 @@ export async function cachedRootSize(
69
69
 
70
70
  export const forgetRootSizes = () => rootSizes.clear();
71
71
 
72
+ // Removes a folder directly inside one of the roots, and nothing else.
73
+ export async function dropFolderIn(
74
+ roots: string[],
75
+ folder: string
76
+ ): Promise<boolean> {
77
+ const target = path.resolve(folder);
78
+ if (!roots.some((r) => r && path.dirname(target) === path.resolve(r)))
79
+ return false;
80
+ await fs.promises.rm(target, { recursive: true, force: true });
81
+ return !fs.existsSync(target);
82
+ }
83
+
72
84
  export async function defaultExtras(
73
85
  opts: { fresh?: boolean } = {}
74
86
  ): Promise<Extras> {
@@ -108,14 +120,10 @@ export async function defaultExtras(
108
120
  count: dirCount(SPARE_ROOT),
109
121
  bytes: await cachedRootSize(SPARE_ROOT, size, !!opts.fresh),
110
122
  },
111
- drop: async (c) => {
112
- if (c.kind === "database") return dropTaskDatabase(c, froms);
113
- const target = path.resolve(c.path);
114
- if (!roots.some((r) => r && path.dirname(target) === path.resolve(r)))
115
- return false;
116
- await fs.promises.rm(target, { recursive: true, force: true });
117
- return !fs.existsSync(target);
118
- },
123
+ drop: async (c) =>
124
+ c.kind === "database"
125
+ ? dropTaskDatabase(c, froms)
126
+ : dropFolderIn(roots, c.path),
119
127
  };
120
128
  }
121
129
 
@@ -21,6 +21,7 @@ const TITLES: Record<SweepKind, string> = {
21
21
  database: "Task databases",
22
22
  spare: "Dependency spares",
23
23
  temp: "Temp folders",
24
+ "test-temp": "Test temp folders",
24
25
  "derived-data": "Xcode build folders of removed worktrees",
25
26
  };
26
27
 
@@ -83,8 +83,8 @@ export function projectSweepOverrides(): Array<{
83
83
  }
84
84
 
85
85
  // The full clean-up (test leftovers, task databases, spares, temp folders,
86
- // build caches) removes nothing until a person turns it on for this machine,
87
- // after reading what a dry run found.
86
+ // build caches) removes nothing until Saad turns it on for this machine, or
87
+ // the orchestrator does on his word after a dry run from the last hour.
88
88
  const ARMED_KEY = "worktree_sweep.reaper";
89
89
 
90
90
  export function reaperArmed(): boolean {
@@ -0,0 +1,113 @@
1
+ // Folders the test suite makes in the OS temp folder, by name. The sweep
2
+ // takes only these, never another aos-… or agent-os-… folder, so AgentOS's
3
+ // own temp folders (aos-review-, aos-rebase-…) and a person's are safe.
4
+ // lib/test-temp.test.ts fails when a test uses a prefix missing here.
5
+ export const TEST_TEMP_PREFIXES: readonly string[] = [
6
+ "agent-os-git-",
7
+ "agent-os-home-",
8
+ "agent-os-kept-",
9
+ "agent-os-limits-",
10
+ "agent-os-session-logs-",
11
+ "agent-os-test-",
12
+ "aos-acc-",
13
+ "aos-add-",
14
+ "aos-armed-repo-",
15
+ "aos-art-",
16
+ "aos-autodeploy-",
17
+ "aos-caches-",
18
+ "aos-channel-",
19
+ "aos-clone-",
20
+ "aos-config-",
21
+ "aos-crypt-",
22
+ "aos-db-",
23
+ "aos-db-drops-",
24
+ "aos-db-wt-",
25
+ "aos-decl-",
26
+ "aos-device-keys-",
27
+ "aos-done-home-",
28
+ "aos-done-repo-",
29
+ "aos-env-",
30
+ "aos-fast-",
31
+ "aos-files-",
32
+ "aos-git-",
33
+ "aos-gr-",
34
+ "aos-heavy-",
35
+ "aos-init-route-",
36
+ "aos-install-",
37
+ "aos-kind-",
38
+ "aos-launch-plain-",
39
+ "aos-launch-repo-",
40
+ "aos-limits-",
41
+ "aos-mcp-",
42
+ "aos-merge-policy-",
43
+ "aos-mig-",
44
+ "aos-move-",
45
+ "aos-no-approvals-",
46
+ "aos-nogit-",
47
+ "aos-notify-",
48
+ "aos-orch-act-",
49
+ "aos-orch-add-",
50
+ "aos-orch-approve-",
51
+ "aos-orch-armed-",
52
+ "aos-orch-asks-",
53
+ "aos-orch-brakes-",
54
+ "aos-orch-ext-repo-",
55
+ "aos-orch-external-",
56
+ "aos-orch-home-",
57
+ "aos-orch-move-",
58
+ "aos-orch-plain-",
59
+ "aos-orch-questions-",
60
+ "aos-orch-recipes-",
61
+ "aos-orch-remind-",
62
+ "aos-orch-repo-",
63
+ "aos-orch-rules-",
64
+ "aos-orch-settle-",
65
+ "aos-orch-signoff-",
66
+ "aos-orch-sweep-",
67
+ "aos-origin-",
68
+ "aos-path-",
69
+ "aos-plain-",
70
+ "aos-plan-",
71
+ "aos-ports-",
72
+ "aos-pr-",
73
+ "aos-pr-async-",
74
+ "aos-pr-create-",
75
+ "aos-pr-gen-",
76
+ "aos-pr-route-",
77
+ "aos-prompts-",
78
+ "aos-quick-",
79
+ "aos-quick-repo-",
80
+ "aos-relay-",
81
+ "aos-relay-none-",
82
+ "aos-report-",
83
+ "aos-resurrect-claude-",
84
+ "aos-resurrect-home-",
85
+ "aos-run-",
86
+ "aos-runner-",
87
+ "aos-sec-",
88
+ "aos-shot-",
89
+ "aos-sim-",
90
+ "aos-spare-",
91
+ "aos-stack-",
92
+ "aos-start-opts-",
93
+ "aos-start-plain-",
94
+ "aos-sweep-own-",
95
+ "aos-t-",
96
+ "aos-task-diff-",
97
+ "aos-temp-helper-",
98
+ "aos-tmux-",
99
+ "aos-transcripts-",
100
+ "aos-ui-",
101
+ "aos-upd-",
102
+ "aos-usage-",
103
+ "aos-wg-",
104
+ "aos-wg-path-",
105
+ "aos-wl-",
106
+ "aos-worker-env-",
107
+ "aos-worker-tmux-",
108
+ "aos-wsl-",
109
+ "aos-wt-",
110
+ ];
111
+
112
+ export const isTestTempName = (name: string) =>
113
+ TEST_TEMP_PREFIXES.some((p) => name.startsWith(p));
@@ -6,6 +6,7 @@ export type SweepKind =
6
6
  | "database"
7
7
  | "spare"
8
8
  | "temp"
9
+ | "test-temp"
9
10
  | "derived-data";
10
11
 
11
12
  // Kinds that go only once the full clean-up is turned on.
@@ -14,6 +15,7 @@ export const ARMED_KINDS: ReadonlySet<SweepKind> = new Set([
14
15
  "database",
15
16
  "spare",
16
17
  "temp",
18
+ "test-temp",
17
19
  "derived-data",
18
20
  ]);
19
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.664",
3
+ "version": "0.3.671",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -1,8 +1,8 @@
1
1
  import { execFileSync } from "child_process";
2
2
  import * as fs from "fs";
3
- import * as os from "os";
4
3
  import * as path from "path";
5
4
  import { describe, expect, it } from "vitest";
5
+ import { tempDir } from "@/lib/test-temp";
6
6
 
7
7
  // Runs the real scripts/autodeploy in a scratch clone with a stub redeploy
8
8
  // (a pull) and wait-quiet, against a scratch origin and HOME.
@@ -11,7 +11,7 @@ const sh = (cwd: string, ...args: string[]) =>
11
11
  execFileSync("git", args, { cwd, encoding: "utf-8" }).trim();
12
12
 
13
13
  function setup() {
14
- const root = fs.mkdtempSync(path.join(os.tmpdir(), "aos-autodeploy-"));
14
+ const root = tempDir("aos-autodeploy-");
15
15
  const origin = path.join(root, "origin.git");
16
16
  const work = path.join(root, "work");
17
17
  const clone = path.join(root, "live");
@@ -1,13 +1,13 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { spawnSync } from "child_process";
3
3
  import fs from "fs";
4
- import os from "os";
5
4
  import path from "path";
5
+ import { tempDir } from "@/lib/test-temp";
6
6
 
7
7
  const script = path.join(__dirname, "check-bundle.mjs");
8
8
 
9
9
  function run(stats: unknown) {
10
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-"));
10
+ const dir = tempDir("bundle-");
11
11
  const file = path.join(dir, "stats.json");
12
12
  fs.writeFileSync(file, JSON.stringify(stats));
13
13
  const r = spawnSync(process.execPath, [script, file], { encoding: "utf-8" });
@@ -4,11 +4,12 @@ import fs from "fs";
4
4
  import os from "os";
5
5
  import path from "path";
6
6
  import { GENERIC_TERMS, loadTerms, matcher, scanTree } from "./banned-terms";
7
+ import { tempDir } from "@/lib/test-temp";
7
8
 
8
9
  const generic = GENERIC_TERMS[0];
9
10
 
10
11
  function tempRepo() {
11
- const repo = fs.mkdtempSync(path.join(os.tmpdir(), "banned-terms-"));
12
+ const repo = tempDir("banned-terms-");
12
13
  const git = (...args: string[]) =>
13
14
  execFileSync("git", args, { cwd: repo, stdio: "pipe" });
14
15
  git("init", "-q");
@@ -22,7 +23,7 @@ describe("loadTerms", () => {
22
23
  });
23
24
 
24
25
  it("adds the private regexes, skipping blanks and comments", () => {
25
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "banned-names-"));
26
+ const dir = tempDir("banned-names-");
26
27
  const file = path.join(dir, "banned-names");
27
28
  fs.writeFileSync(file, "# names\nacme[ -]?widgets\n\n zorp \n");
28
29
  try {
@@ -1,9 +1,9 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { execFileSync, spawnSync } from "child_process";
3
3
  import fs from "fs";
4
- import os from "os";
5
4
  import path from "path";
6
5
  import { scanDiff } from "./staged-secrets";
6
+ import { tempDir } from "@/lib/test-temp";
7
7
 
8
8
  // Fakes are built here, never written whole: a literal would be the very
9
9
  // thing this scan (and the merge gate) refuses.
@@ -62,7 +62,7 @@ describe("scanDiff", () => {
62
62
  });
63
63
 
64
64
  it("fails the commit on a staged secret, and passes once it's gone", () => {
65
- const repo = fs.mkdtempSync(path.join(os.tmpdir(), "staged-secrets-"));
65
+ const repo = tempDir("staged-secrets-");
66
66
  const git = (...args: string[]) =>
67
67
  execFileSync("git", args, { cwd: repo, stdio: "pipe" });
68
68
  const scan = () =>