@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.
- package/README.md +28 -1
- package/app/api/projects/clone/route.ts +4 -2
- package/app/api/projects/init/route.test.ts +46 -0
- package/app/api/projects/init/route.ts +24 -4
- package/components/CommandPalette/CommandPalette.tsx +3 -1
- package/components/CommandPalette/useAppCommands.ts +10 -0
- package/components/Projects/AddProject/AddProjectDialog.tsx +25 -5
- package/components/Projects/AddProject/CloneForm.tsx +3 -1
- package/components/Projects/AddProject/FolderForm.tsx +98 -4
- package/components/Projects/AddProject/NameForm.tsx +3 -1
- package/components/Projects/AddProject/WorkspacePick.tsx +56 -0
- package/components/Projects/AddProject/useAddExistingProject.ts +58 -0
- package/components/SessionList/WorkspaceSwitcher.tsx +10 -0
- package/data/projects/add.ts +23 -3
- package/lib/chat/driver.ts +8 -0
- package/lib/chat/drivers/claude-approvals.ts +40 -0
- package/lib/chat/drivers/claude.ts +7 -10
- package/lib/chat/worker/extras.ts +1 -0
- package/lib/chat/worker/host.ts +4 -0
- package/lib/orchestrator/add-project.ts +35 -0
- package/lib/orchestrator/brief.ts +3 -2
- package/lib/orchestrator/diff.ts +5 -1
- package/lib/orchestrator/serve.ts +6 -0
- package/lib/orchestrator/tool-names.ts +13 -6
- package/lib/orchestrator/tool-schemas.ts +15 -0
- package/lib/orchestrator/tools.ts +2 -0
- package/lib/orchestrator/worker.ts +4 -1
- package/lib/orchestrator/write-guard.ts +295 -0
- package/lib/project-add/index.ts +133 -14
- package/lib/project-add/name.ts +1 -0
- package/lib/secret-paths.ts +9 -0
- package/package.json +1 -1
- 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`,
|
|
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
|
|
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
|
-
?
|
|
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
|
|
25
|
-
// it's made, a draft
|
|
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
|
|
55
|
+
<FolderForm
|
|
56
|
+
hostId={open.hostId}
|
|
57
|
+
workspaceId={open.workspaceId}
|
|
58
|
+
onDone={done}
|
|
59
|
+
/>
|
|
48
60
|
)}
|
|
49
61
|
{open?.kind === "clone" && (
|
|
50
|
-
<CloneForm
|
|
62
|
+
<CloneForm
|
|
63
|
+
hostId={open.hostId}
|
|
64
|
+
workspaceId={open.workspaceId}
|
|
65
|
+
onDone={done}
|
|
66
|
+
/>
|
|
51
67
|
)}
|
|
52
68
|
{open?.kind === "name" && (
|
|
53
|
-
<NameForm
|
|
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 {
|
|
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
|
>
|
package/data/projects/add.ts
CHANGED
|
@@ -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,
|
package/lib/chat/driver.ts
CHANGED
|
@@ -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 {
|