@saadnvd1/agent-os 0.3.468 → 0.3.470

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 (33) hide show
  1. package/README.md +28 -1
  2. package/app/api/projects/clone/route.ts +4 -2
  3. package/app/api/projects/init/route.test.ts +46 -0
  4. package/app/api/projects/init/route.ts +24 -4
  5. package/components/CommandPalette/CommandPalette.tsx +3 -1
  6. package/components/CommandPalette/useAppCommands.ts +10 -0
  7. package/components/Projects/AddProject/AddProjectDialog.tsx +25 -5
  8. package/components/Projects/AddProject/CloneForm.tsx +3 -1
  9. package/components/Projects/AddProject/FolderForm.tsx +98 -4
  10. package/components/Projects/AddProject/NameForm.tsx +3 -1
  11. package/components/Projects/AddProject/WorkspacePick.tsx +56 -0
  12. package/components/Projects/AddProject/useAddExistingProject.ts +58 -0
  13. package/components/SessionList/WorkspaceSwitcher.tsx +10 -0
  14. package/data/projects/add.ts +23 -3
  15. package/lib/chat/driver.ts +8 -0
  16. package/lib/chat/drivers/claude-approvals.ts +40 -0
  17. package/lib/chat/drivers/claude.ts +7 -10
  18. package/lib/chat/worker/extras.ts +1 -0
  19. package/lib/chat/worker/host.ts +4 -0
  20. package/lib/orchestrator/add-project.ts +35 -0
  21. package/lib/orchestrator/brief.ts +3 -2
  22. package/lib/orchestrator/diff.ts +5 -1
  23. package/lib/orchestrator/serve.ts +6 -0
  24. package/lib/orchestrator/tool-names.ts +13 -6
  25. package/lib/orchestrator/tool-schemas.ts +15 -0
  26. package/lib/orchestrator/tools.ts +2 -0
  27. package/lib/orchestrator/worker.ts +4 -1
  28. package/lib/orchestrator/write-guard.ts +295 -0
  29. package/lib/project-add/index.ts +133 -14
  30. package/lib/project-add/name.ts +1 -0
  31. package/lib/secret-paths.ts +9 -0
  32. package/package.json +1 -1
  33. package/stores/addProject.ts +12 -3
package/README.md CHANGED
@@ -872,7 +872,11 @@ it may do on its own, and the lines that always come back to you as asks.
872
872
  - **Tools:** `sessions` (every session's status, activity, task, PR, CI and
873
873
  stack position), `read` (the end of a terminal or chat) and `cards` (the
874
874
  linked boards' cards), plus acting tools: `send`, `start_task`,
875
- `start_session`, `stack`, `stack_status`, `land`, `drop`, `stop`, `done`, `note`,
875
+ `start_session`, `stack`, `stack_status`, `land`, `drop`, `stop`, `done`,
876
+ `add_project` (a git repository on the machine becomes a project of the
877
+ workspace, or the project already at that path moves in from no workspace;
878
+ folders that hold secrets and projects of another workspace are refused),
879
+ `note`,
876
880
  `rule_add`, `rule_remove`, `review`, `sign_off`, `arm`, `disarm` and `ask_saad`. All are served in-process to its chat, refuse
877
881
  any target outside its workspace, and validate their arguments. The one
878
882
  exception is other orchestrators: `orchestrators` lists every workspace and
@@ -882,6 +886,29 @@ it may do on its own, and the lines that always come back to you as asks.
882
886
  approve, answer an ask or pass a gate. The route
883
887
  they call answers only the orchestrator's worker, by a per-orchestrator
884
888
  secret. Its shell runs only `aos` commands that read.
889
+ - **Files:** it reads anywhere and writes (Write, Edit, NotebookEdit) anywhere
890
+ except a project repository, a task worktree (`~/.agent-os/worktrees`),
891
+ AgentOS's own install and state (all of `~/.agent-os` but its own folder),
892
+ secret files (`.env`, keys, `~/.ssh`, `~/.aws`, `gh`'s token, tokenvault and
893
+ the like; the list the merge gates use for secrets handling,
894
+ `lib/secret-paths.ts`), and files that make something run: dotfiles and
895
+ dot-folders at the top of its home (shell, git, Claude and tool config, the
896
+ usage brake's `limits.json`), `~/Library`, `/Library/LaunchAgents` and
897
+ `LaunchDaemons`, git's `core.hooksPath` folders (yours and each project's),
898
+ the folders on `PATH`, and any `.git`, `.claude`, `.mcp.json`, `CLAUDE.md`
899
+ or `AGENTS.md`, since writing one would give it a shell or run code in
900
+ AgentOS's own git; and the temporary checkouts a review or rebase runs in.
901
+ Code changes to a project go through a task, so its PR still passes CI,
902
+ review and scope. It is a list of exceptions, not of allowed places: a
903
+ script a hook or scheduled job runs from an ordinary folder can still be
904
+ written, so an orchestrator misled by text it read could run code that way.
905
+ That's accepted: task agents can already write anywhere, so an allowlist
906
+ on the orchestrator alone would buy little.
907
+ Its chat worker checks every write in a PreToolUse hook before it runs, on
908
+ the path with symlinks and `..` resolved as the system will, and refuses
909
+ with the reason; the repositories are read from the database on each write,
910
+ so a project added since counts. A worker from before an update picks the
911
+ new permissions up at its handover to the new build.
885
912
  - **Brakes:** every start is refused, with the reason, while 4 sessions run in
886
913
  the workspace, after 6 starts in an hour (`orch_max_running` and
887
914
  `orch_max_starts_per_hour` on `workspaces`), or when the account's 5-hour
@@ -2,14 +2,16 @@ import { NextRequest, NextResponse } from "next/server";
2
2
  import { startClone } from "@/lib/project-add";
3
3
 
4
4
  // POST /api/projects/clone - starts cloning { url } into { parent } on a
5
- // machine; GET /api/projects/clone/:job follows it.
5
+ // machine, in { workspaceId } when given; GET /api/projects/clone/:job
6
+ // follows it.
6
7
  export async function POST(request: NextRequest) {
7
8
  try {
8
9
  const body = await request.json();
9
10
  const job = startClone(
10
11
  typeof body.hostId === "string" ? body.hostId : "local",
11
12
  String(body.parent ?? "~"),
12
- String(body.url ?? "").trim()
13
+ String(body.url ?? "").trim(),
14
+ typeof body.workspaceId === "string" ? body.workspaceId : null
13
15
  );
14
16
  return NextResponse.json({ job }, { status: 202 });
15
17
  } catch (error) {
@@ -0,0 +1,46 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { describe, expect, it } from "vitest";
5
+ import type { NextRequest } from "next/server";
6
+ import { getProject } from "@/lib/projects";
7
+ import { createWorkspace } from "@/lib/workspaces";
8
+ import { POST } from "./route";
9
+
10
+ const post = (body: object) =>
11
+ POST({ json: async () => body } as unknown as NextRequest);
12
+
13
+ describe("POST /api/projects/init", () => {
14
+ it("answers a folder that's already a project with 409 and it, and moves it on moveExisting", async () => {
15
+ const a = createWorkspace("Route A");
16
+ const b = createWorkspace("Route B");
17
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "aos-init-route-"));
18
+
19
+ const made = await post({ path: dir, workspaceId: a.id });
20
+ expect(made.status).toBe(201);
21
+ const { project } = await made.json();
22
+ expect(getProject(project.id)?.workspace_id).toBe(a.id);
23
+
24
+ const again = await post({ path: dir, workspaceId: b.id });
25
+ expect(again.status).toBe(409);
26
+ expect((await again.json()).existing).toEqual({
27
+ id: project.id,
28
+ name: project.name,
29
+ workspaceId: a.id,
30
+ });
31
+
32
+ // Only a real true moves it.
33
+ expect(
34
+ (await post({ path: dir, workspaceId: b.id, moveExisting: "true" }))
35
+ .status
36
+ ).toBe(409);
37
+ const moved = await post({
38
+ path: dir,
39
+ workspaceId: b.id,
40
+ moveExisting: true,
41
+ });
42
+ expect(moved.status).toBe(201);
43
+ expect((await moved.json()).project.id).toBe(project.id);
44
+ expect(getProject(project.id)?.workspace_id).toBe(b.id);
45
+ });
46
+ });
@@ -1,22 +1,42 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
- import { addFolder, initProject } from "@/lib/project-add";
2
+ import { addFolder, initProject, ProjectExistsError } from "@/lib/project-add";
3
3
 
4
4
  // POST /api/projects/init - a project from a folder that exists ({ path }),
5
- // or a new one from a name ({ parent, name }: folder, git init, first commit).
5
+ // or a new one from a name ({ parent, name }: folder, git init, first commit),
6
+ // in { workspaceId } when given. A folder that's already a project answers
7
+ // 409 with it, unless { moveExisting } moves it into the workspace.
6
8
  export async function POST(request: NextRequest) {
7
9
  try {
8
10
  const body = await request.json();
9
11
  const hostId = typeof body.hostId === "string" ? body.hostId : "local";
12
+ const workspaceId =
13
+ typeof body.workspaceId === "string" ? body.workspaceId : null;
10
14
  const project =
11
15
  typeof body.path === "string"
12
- ? await addFolder(hostId, body.path)
16
+ ? (
17
+ await addFolder(hostId, body.path, {
18
+ workspaceId,
19
+ moveExisting: body.moveExisting === true,
20
+ })
21
+ ).project
13
22
  : await initProject(
14
23
  hostId,
15
24
  String(body.parent ?? "~"),
16
- String(body.name ?? "")
25
+ String(body.name ?? ""),
26
+ workspaceId
17
27
  );
18
28
  return NextResponse.json({ project }, { status: 201 });
19
29
  } catch (error) {
30
+ if (error instanceof ProjectExistsError) {
31
+ const { id, name, workspace_id } = error.project;
32
+ return NextResponse.json(
33
+ {
34
+ error: error.message,
35
+ existing: { id, name, workspaceId: workspace_id },
36
+ },
37
+ { status: 409 }
38
+ );
39
+ }
20
40
  const message = error instanceof Error ? error.message : String(error);
21
41
  return NextResponse.json(
22
42
  { error: message.trim().split("\n").pop() },
@@ -51,6 +51,8 @@ function PaletteBody() {
51
51
 
52
52
  const results = useMemo(() => {
53
53
  if (query.trim()) return matchCommands(all, query);
54
+ // A picker shows every choice; it's the whole list.
55
+ if (picker) return all;
54
56
  // With no query, long groups are cut short so every group shows.
55
57
  const seen = new Map<string, number>();
56
58
  return all.filter((c) => {
@@ -58,7 +60,7 @@ function PaletteBody() {
58
60
  seen.set(c.group, n);
59
61
  return n <= GROUP_PREVIEW;
60
62
  });
61
- }, [all, query]);
63
+ }, [all, query, picker]);
62
64
  const highlighted = Math.min(active, Math.max(results.length - 1, 0));
63
65
 
64
66
  useEffect(() => {
@@ -29,6 +29,7 @@ import { sidebarUi, sidebarUiActions } from "@/stores/sidebarUi";
29
29
  import { tasksUiActions } from "@/stores/tasksUi";
30
30
  import { newDraft } from "@/stores/drafts";
31
31
  import { useAddProject } from "@/components/Projects/AddProject/useAddProject";
32
+ import { useAddExistingProject } from "@/components/Projects/AddProject/useAddExistingProject";
32
33
  import { usageUiActions } from "@/stores/usageUi";
33
34
  import { settingsCommands } from "@/components/Settings/commands";
34
35
  import { DRAFT_KEYS } from "@/lib/drafts";
@@ -66,6 +67,7 @@ export function useAppCommands({
66
67
  const current = workspaces.find((w) => w.id === workspaceId) ?? null;
67
68
  const dark = resolvedTheme?.startsWith("dark") ?? false;
68
69
  const addProject = useAddProject();
70
+ const addExisting = useAddExistingProject();
69
71
 
70
72
  const app: PaletteCommand[] = [
71
73
  {
@@ -113,6 +115,14 @@ export function useAppCommands({
113
115
  },
114
116
  ...(current
115
117
  ? [
118
+ {
119
+ id: "app.add-project-to-workspace",
120
+ title: `Add project to ${current.name}`,
121
+ group: "Actions",
122
+ keywords: ["existing", "move", "workspace", "project"],
123
+ icon: FolderPlus,
124
+ run: () => addExisting(current),
125
+ },
116
126
  {
117
127
  id: "app.orchestrator",
118
128
  title: `Open ${current.name}'s orchestrator`,
@@ -14,6 +14,7 @@ import { useHostNames } from "@/data/hosts";
14
14
  import { FolderForm } from "./FolderForm";
15
15
  import { CloneForm } from "./CloneForm";
16
16
  import { NameForm } from "./NameForm";
17
+ import { WorkspacePick } from "./WorkspacePick";
17
18
 
18
19
  const TITLES = {
19
20
  folder: "Open a folder",
@@ -21,8 +22,9 @@ const TITLES = {
21
22
  name: "New project",
22
23
  };
23
24
 
24
- // The last step of "Add project" (⌘K): a folder, a clone or a name. Once
25
- // it's made, a draft opens in it.
25
+ // The last step of "Add project" (⌘K): a folder, a clone or a name, into
26
+ // the sidebar's workspace unless changed here. Once it's made, a draft
27
+ // opens in it.
26
28
  export function AddProjectDialog() {
27
29
  const { open } = useSnapshot(addProjectUi);
28
30
  const hostNames = useHostNames();
@@ -43,14 +45,32 @@ export function AddProjectDialog() {
43
45
  : "this machine"}
44
46
  </DialogDescription>
45
47
  </DialogHeader>
48
+ {open && (
49
+ <WorkspacePick
50
+ workspaceId={open.workspaceId}
51
+ onChange={addProjectActions.setWorkspace}
52
+ />
53
+ )}
46
54
  {open?.kind === "folder" && (
47
- <FolderForm hostId={open.hostId} onDone={done} />
55
+ <FolderForm
56
+ hostId={open.hostId}
57
+ workspaceId={open.workspaceId}
58
+ onDone={done}
59
+ />
48
60
  )}
49
61
  {open?.kind === "clone" && (
50
- <CloneForm hostId={open.hostId} onDone={done} />
62
+ <CloneForm
63
+ hostId={open.hostId}
64
+ workspaceId={open.workspaceId}
65
+ onDone={done}
66
+ />
51
67
  )}
52
68
  {open?.kind === "name" && (
53
- <NameForm hostId={open.hostId} onDone={done} />
69
+ <NameForm
70
+ hostId={open.hostId}
71
+ workspaceId={open.workspaceId}
72
+ onDone={done}
73
+ />
54
74
  )}
55
75
  </DialogContent>
56
76
  </Dialog>
@@ -9,9 +9,11 @@ import { ParentPicker } from "./ParentPicker";
9
9
 
10
10
  export function CloneForm({
11
11
  hostId,
12
+ workspaceId,
12
13
  onDone,
13
14
  }: {
14
15
  hostId: string;
16
+ workspaceId: string | null;
15
17
  onDone: (projectId: string) => void;
16
18
  }) {
17
19
  const [url, setUrl] = useState("");
@@ -29,7 +31,7 @@ export function CloneForm({
29
31
  className="space-y-3"
30
32
  onSubmit={(e) => {
31
33
  e.preventDefault();
32
- start.mutate({ hostId, parent, url: url.trim() });
34
+ start.mutate({ hostId, parent, url: url.trim(), workspaceId });
33
35
  }}
34
36
  >
35
37
  <Input
@@ -2,18 +2,52 @@
2
2
 
3
3
  import { useState } from "react";
4
4
  import { Button } from "@/components/ui/button";
5
- import { useInitProject } from "@/data/projects";
5
+ import {
6
+ ProjectExistsError,
7
+ useInitProject,
8
+ type ExistingProject,
9
+ } from "@/data/projects";
10
+ import { useWorkspacesQuery } from "@/data/workspaces";
6
11
  import { FolderBrowser } from "./FolderBrowser";
7
12
 
8
13
  export function FolderForm({
9
14
  hostId,
15
+ workspaceId,
10
16
  onDone,
11
17
  }: {
12
18
  hostId: string;
19
+ workspaceId: string | null;
13
20
  onDone: (projectId: string) => void;
14
21
  }) {
15
22
  const [path, setPath] = useState("~");
16
23
  const init = useInitProject();
24
+ const [existing, setExisting] = useState<ExistingProject | null>(null);
25
+ const add = (moveExisting = false) =>
26
+ init.mutate(
27
+ { hostId, path, workspaceId, moveExisting },
28
+ {
29
+ onSuccess: (p) => onDone(p.id),
30
+ onError: (e) =>
31
+ e instanceof ProjectExistsError && setExisting(e.existing),
32
+ }
33
+ );
34
+
35
+ if (existing)
36
+ return (
37
+ <AlreadyAProject
38
+ existing={existing}
39
+ workspaceId={workspaceId}
40
+ pending={init.isPending}
41
+ error={init.error instanceof ProjectExistsError ? null : init.error}
42
+ onMove={() => add(true)}
43
+ onOpen={() => onDone(existing.id)}
44
+ onBack={() => {
45
+ setExisting(null);
46
+ init.reset();
47
+ }}
48
+ />
49
+ );
50
+
17
51
  return (
18
52
  <div className="space-y-3">
19
53
  <FolderBrowser hostId={hostId} path={path} onNavigate={setPath} />
@@ -23,12 +57,72 @@ export function FolderForm({
23
57
  <Button
24
58
  className="h-11 w-full md:h-9"
25
59
  disabled={init.isPending}
26
- onClick={() =>
27
- init.mutate({ hostId, path }, { onSuccess: (p) => onDone(p.id) })
28
- }
60
+ onClick={() => add()}
29
61
  >
30
62
  {init.isPending ? "Adding…" : "Use this folder"}
31
63
  </Button>
32
64
  </div>
33
65
  );
34
66
  }
67
+
68
+ // The folder is a project already: move it into the workspace this adds
69
+ // to, or just start in it.
70
+ function AlreadyAProject({
71
+ existing,
72
+ workspaceId,
73
+ pending,
74
+ error,
75
+ onMove,
76
+ onOpen,
77
+ onBack,
78
+ }: {
79
+ existing: ExistingProject;
80
+ workspaceId: string | null;
81
+ pending: boolean;
82
+ error: Error | null;
83
+ onMove: () => void;
84
+ onOpen: () => void;
85
+ onBack: () => void;
86
+ }) {
87
+ const { data: workspaces = [] } = useWorkspacesQuery();
88
+ const nameOf = (id: string | null) =>
89
+ workspaces.find((w) => w.id === id)?.name ?? null;
90
+ const from = nameOf(existing.workspaceId);
91
+ const to = nameOf(workspaceId);
92
+ const canMove = !!to && existing.workspaceId !== workspaceId;
93
+ return (
94
+ <div className="space-y-3">
95
+ <p className="text-sm">
96
+ <span className="font-medium">{existing.name}</span> is already a
97
+ project, {from ? `in ${from}` : "in no workspace"}.
98
+ </p>
99
+ {error && <p className="text-destructive text-sm">{error.message}</p>}
100
+ <div className="flex flex-col gap-2 sm:flex-row">
101
+ {canMove && (
102
+ <Button
103
+ className="h-11 flex-1 md:h-9"
104
+ disabled={pending}
105
+ onClick={onMove}
106
+ >
107
+ {pending ? "Moving…" : `Move it to ${to}`}
108
+ </Button>
109
+ )}
110
+ <Button
111
+ variant={canMove ? "outline" : "default"}
112
+ className="h-11 flex-1 md:h-9"
113
+ disabled={pending}
114
+ onClick={onOpen}
115
+ >
116
+ Start a session in it
117
+ </Button>
118
+ </div>
119
+ <button
120
+ type="button"
121
+ onClick={onBack}
122
+ className="text-primary min-h-11 px-1 text-sm md:min-h-8"
123
+ >
124
+ Pick another folder
125
+ </button>
126
+ </div>
127
+ );
128
+ }
@@ -10,9 +10,11 @@ import { ParentPicker } from "./ParentPicker";
10
10
  // a separate, explicit click.
11
11
  export function NameForm({
12
12
  hostId,
13
+ workspaceId,
13
14
  onDone,
14
15
  }: {
15
16
  hostId: string;
17
+ workspaceId: string | null;
16
18
  onDone: (projectId: string) => void;
17
19
  }) {
18
20
  const [name, setName] = useState("");
@@ -67,7 +69,7 @@ export function NameForm({
67
69
  className="space-y-3"
68
70
  onSubmit={(e) => {
69
71
  e.preventDefault();
70
- init.mutate({ hostId, parent, name: name.trim() });
72
+ init.mutate({ hostId, parent, name: name.trim(), workspaceId });
71
73
  }}
72
74
  >
73
75
  <Input
@@ -0,0 +1,56 @@
1
+ "use client";
2
+
3
+ import { LayoutGrid } from "lucide-react";
4
+ import {
5
+ Select,
6
+ SelectContent,
7
+ SelectItem,
8
+ SelectTrigger,
9
+ SelectValue,
10
+ } from "@/components/ui/select";
11
+ import { useWorkspacesQuery } from "@/data/workspaces";
12
+
13
+ const NONE = "none";
14
+
15
+ // "Adds to Internal Tools", changeable to another workspace or none.
16
+ export function WorkspacePick({
17
+ workspaceId,
18
+ onChange,
19
+ }: {
20
+ workspaceId: string | null;
21
+ onChange: (workspaceId: string | null) => void;
22
+ }) {
23
+ const { data: workspaces = [] } = useWorkspacesQuery();
24
+ if (workspaces.length === 0) return null;
25
+ // One deleted since the dialog opened reads as none.
26
+ const value = workspaces.some((w) => w.id === workspaceId)
27
+ ? (workspaceId as string)
28
+ : NONE;
29
+ return (
30
+ <div className="flex items-center gap-2 text-sm">
31
+ <LayoutGrid className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
32
+ <span className="text-muted-foreground shrink-0">Adds to</span>
33
+ <Select
34
+ value={value}
35
+ onValueChange={(v) => onChange(v === NONE ? null : v)}
36
+ >
37
+ <SelectTrigger
38
+ aria-label="Workspace"
39
+ className="h-11 min-w-0 flex-1 md:h-9"
40
+ >
41
+ <SelectValue />
42
+ </SelectTrigger>
43
+ <SelectContent>
44
+ <SelectItem value={NONE} className="min-h-11 md:min-h-0">
45
+ No workspace
46
+ </SelectItem>
47
+ {workspaces.map((w) => (
48
+ <SelectItem key={w.id} value={w.id} className="min-h-11 md:min-h-0">
49
+ {w.name}
50
+ </SelectItem>
51
+ ))}
52
+ </SelectContent>
53
+ </Select>
54
+ </div>
55
+ );
56
+ }
@@ -0,0 +1,58 @@
1
+ "use client";
2
+
3
+ import { FolderGit2, FolderPlus } from "lucide-react";
4
+ import { toast } from "sonner";
5
+ import type { Workspace } from "@/lib/db";
6
+ import { useProjectsQuery } from "@/data/projects";
7
+ import {
8
+ useMoveProjectToWorkspace,
9
+ useWorkspacesQuery,
10
+ } from "@/data/workspaces";
11
+ import { paletteActions } from "@/stores/palette";
12
+ import { useAddProject } from "./useAddProject";
13
+
14
+ // "Add existing project…": a searchable pick of the projects not in this
15
+ // workspace, under the workspace each is in now; picking one moves it in.
16
+ // A new one is the first choice.
17
+ export function useAddExistingProject() {
18
+ const { data: projects = [] } = useProjectsQuery();
19
+ const { data: workspaces = [] } = useWorkspacesQuery();
20
+ const move = useMoveProjectToWorkspace();
21
+ const addProject = useAddProject();
22
+ return (workspace: Workspace) => {
23
+ const nameOf = (id: string | null) =>
24
+ workspaces.find((w) => w.id === id)?.name ?? "No workspace";
25
+ const others = projects
26
+ .filter((p) => !p.is_uncategorized && p.workspace_id !== workspace.id)
27
+ .sort(
28
+ (a, b) =>
29
+ nameOf(a.workspace_id).localeCompare(nameOf(b.workspace_id)) ||
30
+ a.name.localeCompare(b.name)
31
+ );
32
+ paletteActions.pick(`Add a project to ${workspace.name}…`, [
33
+ {
34
+ id: "add-existing.new",
35
+ title: "New project (folder, clone or name)…",
36
+ group: "",
37
+ icon: FolderPlus,
38
+ run: addProject,
39
+ },
40
+ ...others.map((p) => ({
41
+ id: `add-existing.${p.id}`,
42
+ title: p.name,
43
+ group: nameOf(p.workspace_id),
44
+ keywords: [p.working_directory],
45
+ icon: FolderGit2,
46
+ run: () =>
47
+ move.mutate(
48
+ { projectId: p.id, workspaceId: workspace.id },
49
+ {
50
+ onSuccess: () =>
51
+ toast.success(`${p.name} moved to ${workspace.name}`),
52
+ onError: (e) => toast.error(e.message),
53
+ }
54
+ ),
55
+ })),
56
+ ]);
57
+ };
58
+ }
@@ -7,6 +7,7 @@ import {
7
7
  ChevronDown,
8
8
  CircleCheck,
9
9
  Clock,
10
+ FolderPlus,
10
11
  Gauge,
11
12
  LayoutGrid,
12
13
  Pencil,
@@ -29,6 +30,7 @@ import { archivedUiActions, cleanupUiActions } from "@/stores/archivedUi";
29
30
  import { orchestratorOpenActions } from "@/stores/orchestratorOpen";
30
31
  import { sidebarUiActions } from "@/stores/sidebarUi";
31
32
  import { schedulesUiActions } from "@/stores/schedulesUi";
33
+ import { useAddExistingProject } from "@/components/Projects/AddProject/useAddExistingProject";
32
34
 
33
35
  const icon = "mr-2 h-3.5 w-3.5";
34
36
 
@@ -44,6 +46,7 @@ export function WorkspaceSwitcher({
44
46
  const create = useCreateWorkspace();
45
47
  const update = useUpdateWorkspace();
46
48
  const remove = useDeleteWorkspace();
49
+ const addExisting = useAddExistingProject();
47
50
  const [dialog, setDialog] = useState<"new" | "rename" | null>(null);
48
51
  const name = current?.name ?? "All workspaces";
49
52
 
@@ -94,6 +97,13 @@ export function WorkspaceSwitcher({
94
97
  {current && (
95
98
  <>
96
99
  <DM.DropdownMenuSeparator />
100
+ <DM.DropdownMenuItem
101
+ onClick={() => addExisting(current)}
102
+ className="min-h-11 md:min-h-0"
103
+ >
104
+ <FolderPlus className={icon} />
105
+ Add existing project…
106
+ </DM.DropdownMenuItem>
97
107
  <DM.DropdownMenuItem
98
108
  onClick={() => orchestratorOpenActions.request(current.id)}
99
109
  >
@@ -2,8 +2,26 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
2
  import type { CloneJob, FolderListing } from "@/lib/project-add";
3
3
  import { projectKeys } from "./keys";
4
4
 
5
+ export interface ExistingProject {
6
+ id: string;
7
+ name: string;
8
+ workspaceId: string | null;
9
+ }
10
+
11
+ // A folder that's already a project: the add form offers to move it.
12
+ export class ProjectExistsError extends Error {
13
+ constructor(
14
+ message: string,
15
+ readonly existing: ExistingProject
16
+ ) {
17
+ super(message);
18
+ }
19
+ }
20
+
5
21
  async function json<T>(res: Response): Promise<T> {
6
22
  const data = await res.json();
23
+ if (res.status === 409 && data.existing)
24
+ throw new ProjectExistsError(data.error, data.existing);
7
25
  if (!res.ok) throw new Error(data.error || "Request failed");
8
26
  return data;
9
27
  }
@@ -28,13 +46,14 @@ export function useFolderListing(hostId: string, path: string) {
28
46
  });
29
47
  }
30
48
 
31
- // A project from a folder ({ path }) or a new one from a name.
49
+ // A project from a folder ({ path }) or a new one from a name, in a
50
+ // workspace when given. moveExisting moves the project already at { path }.
32
51
  export function useInitProject() {
33
52
  const queryClient = useQueryClient();
34
53
  return useMutation({
35
54
  mutationFn: async (
36
- input: { hostId: string } & (
37
- | { path: string }
55
+ input: { hostId: string; workspaceId: string | null } & (
56
+ | { path: string; moveExisting?: boolean }
38
57
  | { parent: string; name: string }
39
58
  )
40
59
  ) =>
@@ -54,6 +73,7 @@ export function useStartClone() {
54
73
  hostId: string;
55
74
  parent: string;
56
75
  url: string;
76
+ workspaceId: string | null;
57
77
  }) =>
58
78
  (await json<{ job: CloneJob }>(await post("/api/projects/clone", input)))
59
79
  .job,
@@ -35,8 +35,16 @@ export interface ChatStartOptions {
35
35
  // A fixed permission mode in place of the access setting, which then
36
36
  // can't be changed for this conversation.
37
37
  permissionMode?: PermissionMode;
38
+ // Checked before every tool call, whatever the permissions say: why it's
39
+ // refused, or null to let it go on to them.
40
+ toolGuard?: ToolGuard;
38
41
  }
39
42
 
43
+ export type ToolGuard = (
44
+ tool: string,
45
+ input: Record<string, unknown>
46
+ ) => Promise<string | null>;
47
+
40
48
  // One live conversation with an agent. Messages sent while a turn runs are
41
49
  // queued by the provider and folded into the conversation.
42
50
  export interface UndoResult extends UndoPreview {