@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
@@ -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:
@@ -161,6 +165,8 @@ const DESCRIPTIONS: Record<ToolName, string> = {
161
165
  "Bring a dropped, done or archived session back: the same session and conversation, its worktree (with its uncommitted files and plan) back from what was saved or from its branch, its status as it was (a plan-only task waits for approval again, one with a PR is idle), and a PR a drop closed reopened. Archived sessions just unarchive. Held by Pause and braked like a start. Refused when its conversation is gone, its PR was merged, its project was removed or its branch is in use. Give a reason; it goes in the decision log.",
162
166
  add_project:
163
167
  "Make a git repository on this machine a project of this workspace, by its absolute path, or move the project already at that path into this workspace when it's in no workspace. Says its default branch, detected from git. Refuses a folder that isn't a git repository or that holds secrets, and a project another workspace has.",
168
+ project_tier:
169
+ "Read or set a project's tier. production: every merge gate applies (CI, independent review, scope). fast: a task's PR merges as soon as it opens, with no CI wait or review. Without a project, lists this workspace's; with a project, reads it; with a tier too, sets it, only when Saad says to.",
164
170
  note: "Add a line to this workspace's decision log; it shows in your chat.",
165
171
  rule_add:
166
172
  'Keep a standing rule for this workspace, in your brief on every start from now on ("never merge #111", "briefs must not name third-party products"). It restricts or informs; it can never approve anything.',
@@ -183,7 +189,7 @@ const DESCRIPTIONS: Record<ToolName, string> = {
183
189
  review:
184
190
  "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
191
  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.",
192
+ "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
193
  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
194
  disarm:
189
195
  "Disarm a task armed to merge: it merges only through sign_off again.",
@@ -5,6 +5,7 @@
5
5
  * every 15s while anything in the workspace is working, every 60s when not.
6
6
  */
7
7
 
8
+ import { autoArmFast, followFastMerges } from "./fast-lane";
8
9
  import { chatState, chatStateNow, sendChat } from "../chat/runner";
9
10
  import type { ChatState } from "../chat/events";
10
11
  import { getWorkspace } from "../workspaces";
@@ -96,7 +97,11 @@ function mergePipeline(workspaceId: string, facts: SessionFacts[]): void {
96
97
  console.error(`Judging asked commits in ${workspaceId}:`, error)
97
98
  );
98
99
  await rebaseConflicting(workspaceId, facts);
100
+ await autoArmFast(workspaceId, facts);
99
101
  await mergeArmed(workspaceId, facts);
102
+ await followFastMerges(workspaceId).catch((error) =>
103
+ console.error(`Following fast merges in ${workspaceId}:`, error)
104
+ );
100
105
  })()
101
106
  .catch((error) =>
102
107
  console.error(`Merge pipeline for ${workspaceId}:`, error)
@@ -1,5 +1,7 @@
1
1
  /**
2
- * Where an orchestrator may write. Anywhere on the machine except the
2
+ * Where an orchestrator may write (unless its workspace has Orchestrator full
3
+ * access on, the default: orchestratorToolRefusal then checks nothing but
4
+ * warns about stopping AgentOS; full-access.ts). Anywhere on the machine except the
3
5
  * workspace's project repositories and their task worktrees (code changes go
4
6
  * through tasks, so the merge gates keep meaning something), AgentOS's own
5
7
  * install and state (all of ~/.agent-os but its own folder), secret files,
@@ -18,13 +20,15 @@ import fs from "fs";
18
20
  import os from "os";
19
21
  import path from "path";
20
22
  import { db, type Session } from "../db";
23
+ import type { ToolGuardResult } from "../chat/driver";
21
24
  import { git, GitError } from "../git-exec";
22
25
  import { isOrchestratorSecretPath as isSecretPath } from "../secret-paths";
23
26
  import { WORKTREES_DIR } from "../worktrees";
24
27
  import { orchestratorDir } from "./home";
25
28
  import { LIMITS_FILE } from "./usage";
26
29
  import { quickWorktrees } from "./quick-store";
27
- import { shellRefusal } from "./shell-guard";
30
+ import { shellRefusal, shellWarning, type ShellContext } from "./shell-guard";
31
+ import { orchestratorFullAccess } from "./full-access";
28
32
  import { liveCheckout } from "../dev-instance/liveServer";
29
33
 
30
34
  // The tools that write a file, and the argument naming it.
@@ -366,24 +370,47 @@ export async function orchestratorToolRefusal(
366
370
  session: Session,
367
371
  tool: string,
368
372
  input: Record<string, unknown>
369
- ): Promise<string | null> {
370
- if (tool === "Bash") return commandRefusal(session, input.command);
373
+ ): Promise<ToolGuardResult> {
374
+ const full = orchestratorFullAccess(session);
375
+ if (tool === "Bash")
376
+ return full
377
+ ? commandWarning(session, input.command)
378
+ : commandRefusal(session, input.command);
371
379
  const arg = WRITE_TOOLS[tool];
372
380
  if (!arg) return null;
373
381
  const filePath = input[arg];
382
+ if (full) return null;
374
383
  if (typeof filePath !== "string") return "No file path to check.";
375
384
  return writeRefusal(filePath, await orchestratorWriteBounds(session));
376
385
  }
377
386
 
387
+ // With full access: only a warning for stopping AgentOS or its tmux.
388
+ async function commandWarning(
389
+ session: Session,
390
+ command: unknown
391
+ ): Promise<ToolGuardResult> {
392
+ const warning = await shellWarning(
393
+ command,
394
+ shellContext(session, await orchestratorWriteBounds(session))
395
+ );
396
+ return warning ? { warning } : null;
397
+ }
398
+
378
399
  // A shell command, against the deny list (shell-guard.ts), its writes held
379
400
  // to the shell's share of the write bounds.
380
401
  async function commandRefusal(
381
402
  session: Session,
382
403
  command: unknown
383
404
  ): Promise<string | null> {
384
- const bounds = await orchestratorWriteBounds(session);
405
+ return shellRefusal(
406
+ command,
407
+ shellContext(session, await orchestratorWriteBounds(session))
408
+ );
409
+ }
410
+
411
+ function shellContext(session: Session, bounds: WriteBounds): ShellContext {
385
412
  const dbFile = agentOsDbPath();
386
- return shellRefusal(command, {
413
+ return {
387
414
  cwd: bounds.cwd,
388
415
  writeRefusal: (p) => writeRefusal(p, bounds, { shell: true }),
389
416
  isAgentOsState: async (p) => {
@@ -402,7 +429,7 @@ async function commandRefusal(
402
429
  },
403
430
  checkoutKind: (dir) => checkoutKind(session, dir),
404
431
  protectedPids,
405
- });
432
+ };
406
433
  }
407
434
 
408
435
  // Whether a folder is a project checkout of the session's own workspace
@@ -18,9 +18,11 @@ import { simulatorBrief } from "../simulator/brief";
18
18
  export function runningBrief(
19
19
  config: ProjectConfig,
20
20
  ports: Record<string, number>,
21
- database: SessionDatabase | null = null
21
+ database: SessionDatabase | null = null,
22
+ warning: string | null = null
22
23
  ): string {
23
24
  const lines: string[] = [];
25
+ if (warning) lines.push(`- ${warning}`);
24
26
  const names = Object.entries(ports);
25
27
  if (names.length)
26
28
  lines.push(
@@ -43,6 +43,12 @@ export type PgRun = (
43
43
 
44
44
  export const pgRun: PgRun = (cmd, args, env) =>
45
45
  new Promise((resolve) => {
46
+ // A test that reached the real server could list or drop the owner's
47
+ // databases: every test injects its own run.
48
+ if (process.env.VITEST)
49
+ throw new Error(
50
+ "Refusing to run the real Postgres tools under the test runner"
51
+ );
46
52
  execFile(
47
53
  cmd,
48
54
  args,
@@ -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);
@@ -0,0 +1,18 @@
1
+ import { db } from "@/lib/db";
2
+ import type { ProjectTier } from "./types";
3
+
4
+ // An unknown project counts as production: a missing row never opens the
5
+ // fast lane.
6
+ export function projectTier(projectId: string | null): ProjectTier {
7
+ if (!projectId) return "production";
8
+ const row = db
9
+ .prepare(`SELECT tier FROM projects WHERE id = ?`)
10
+ .get(projectId) as { tier: string } | undefined;
11
+ return row?.tier === "fast" ? "fast" : "production";
12
+ }
13
+
14
+ export const taskTier = (task: { project_id: string | null }): ProjectTier =>
15
+ projectTier(task.project_id);
16
+
17
+ export const isFast = (task: { project_id: string | null }): boolean =>
18
+ taskTier(task) === "fast";
@@ -0,0 +1,3 @@
1
+ export { PROJECT_TIERS, type ProjectTier } from "./types";
2
+ export { projectTier, taskTier, isFast } from "./getProjectTier";
3
+ export { setProjectTier } from "./setProjectTier";
@@ -0,0 +1,14 @@
1
+ import { getProject } from "@/lib/projects";
2
+ import { db } from "@/lib/db";
3
+ import type { Project } from "@/lib/db/types";
4
+ import type { ProjectTier } from "./types";
5
+
6
+ export function setProjectTier(projectId: string, tier: ProjectTier): Project {
7
+ const project = getProject(projectId);
8
+ if (!project || project.is_uncategorized)
9
+ throw new Error("Project not found or cannot be modified");
10
+ db.prepare(
11
+ `UPDATE projects SET tier = ?, updated_at = datetime('now') WHERE id = ?`
12
+ ).run(tier, projectId);
13
+ return getProject(projectId)!;
14
+ }
@@ -0,0 +1,3 @@
1
+ export type { ProjectTier } from "@/lib/db/types";
2
+
3
+ export const PROJECT_TIERS = ["production", "fast"] as const;
@@ -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;
@@ -40,7 +40,14 @@ export async function startOptions(
40
40
  const opts = { workspaceId: project?.workspace_id ?? null };
41
41
  roles.plan = resolveRole("plan", opts, models).ref;
42
42
  roles.implement = resolveRole("implement", opts, models).ref;
43
- planDefault = taskRouting(opts.workspaceId, "chat", undefined).plan;
43
+ planDefault = taskRouting(
44
+ opts.workspaceId,
45
+ "chat",
46
+ undefined,
47
+ undefined,
48
+ undefined,
49
+ project?.tier
50
+ ).plan;
44
51
  } catch (error) {
45
52
  planError = error instanceof Error ? error.message : String(error);
46
53
  }
@@ -40,6 +40,7 @@ import { sessionEnded } from "../experiments/hooks";
40
40
  import { snapshotWorktree } from "../resurrect/snapshot";
41
41
  import { keepSessionFiles } from "../session-artifacts/collect";
42
42
  import { setPr, setTask } from "../db/session-parts";
43
+ import { isFast } from "../project-tier/getProjectTier";
43
44
 
44
45
  export const sessionMergePolicy = (session: Session): MergePolicy =>
45
46
  mergePolicy(session.project_id ? getProject(session.project_id) : null);
@@ -167,7 +168,7 @@ export async function signOffTask(
167
168
  throw new Error(`${opts.base} isn't a base ${session.name} may merge into`);
168
169
  const judgedBase = opts.base ?? session.base_branch;
169
170
  const pr = await prFor(session, true);
170
- const verdict = canSignOff(pr);
171
+ const verdict = canSignOff(pr, { ci: !isFast(session) });
171
172
  if (!verdict.ok) throw new Error(verdict.reason);
172
173
  if (pr!.base && pr!.base !== judgedBase)
173
174
  throw new Error(
@@ -15,7 +15,7 @@ import { effectiveModels } from "../models/config";
15
15
  import { ANTHROPIC } from "../models/roles";
16
16
  import { processTable, runsSomething } from "../process-table";
17
17
  import { randomUUID } from "crypto";
18
- import { db, queries, type Session } from "../db";
18
+ import { db, queries, type ProjectTier, type Session } from "../db";
19
19
  import { getProject } from "../projects";
20
20
  import {
21
21
  createWorktree,
@@ -267,7 +267,8 @@ async function startTask(
267
267
  view,
268
268
  opts.model,
269
269
  opts.plan,
270
- opts.planOnly
270
+ opts.planOnly,
271
+ project.tier
271
272
  ).plan;
272
273
  const session = await startRemoteTask(hostId, project, {
273
274
  id,
@@ -310,7 +311,8 @@ async function startTask(
310
311
  view,
311
312
  opts.model,
312
313
  opts.plan,
313
- opts.planOnly
314
+ opts.planOnly,
315
+ project.tier
314
316
  )
315
317
  : otherAgentRouting(opts.model);
316
318
  const projectPath = expandHome(project.working_directory);
@@ -590,7 +592,9 @@ export function taskRouting(
590
592
  view: StartView,
591
593
  model: unknown,
592
594
  plan?: boolean,
593
- planOnly?: boolean
595
+ planOnly?: boolean,
596
+ // A fast project skips the plan step unless the caller asks for it.
597
+ tier: ProjectTier = "production"
594
598
  ) {
595
599
  const override = parseOverride(model);
596
600
  const models = effectiveModels();
@@ -601,7 +605,8 @@ export function taskRouting(
601
605
  const planStep =
602
606
  view === "chat" &&
603
607
  (!!planOnly ||
604
- ((plan ?? models.planStep) && planning.ref !== implement.ref));
608
+ ((plan ?? (tier === "fast" ? false : models.planStep)) &&
609
+ planning.ref !== implement.ref));
605
610
  return {
606
611
  override,
607
612
  plan: planStep,
@@ -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"]) {
@@ -97,13 +97,19 @@ export function needsHuman(state: TaskState): boolean {
97
97
  ].includes(state);
98
98
  }
99
99
 
100
- export function canSignOff(pr: TaskPR | null): {
100
+ // `ci: false` (a fast project): checks that are failing or running don't
101
+ // refuse the merge.
102
+ export function canSignOff(
103
+ pr: TaskPR | null,
104
+ opts: { ci?: boolean } = {}
105
+ ): {
101
106
  ok: boolean;
102
107
  reason?: string;
103
108
  } {
104
109
  if (!pr) return { ok: false, reason: "No pull request yet" };
105
110
  if (pr.state !== "OPEN")
106
111
  return { ok: false, reason: `PR is ${pr.state.toLowerCase()}` };
112
+ if (opts.ci === false) return { ok: true };
107
113
  if (pr.checks === "fail")
108
114
  return { ok: false, reason: "CI checks are failing" };
109
115
  if (pr.checks === "pending")
@@ -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
+ }