@saadnvd1/agent-os 0.3.468 → 0.3.471
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 +67 -37
- package/app/api/fresh-start/route.ts +9 -5
- 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/app/api/usage/route.ts +4 -2
- package/components/Chat/ChatPanel.tsx +5 -5
- package/components/Chat/StaleChip.tsx +7 -6
- 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/components/Usage/UsageDialog.tsx +31 -2
- package/components/Workspaces/FreshStartForm.tsx +13 -13
- package/data/projects/add.ts +23 -3
- package/data/usage/index.ts +2 -0
- package/lib/agents/launch.ts +2 -0
- 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/refresh.ts +31 -13
- package/lib/chat/stale.ts +25 -16
- package/lib/chat/worker/extras.ts +1 -0
- package/lib/chat/worker/host.ts +61 -22
- package/lib/db/migrations.ts +42 -0
- package/lib/orchestrator/add-project.ts +35 -0
- package/lib/orchestrator/armed.ts +1 -2
- package/lib/orchestrator/brief.ts +6 -5
- package/lib/orchestrator/claude-cli.ts +5 -0
- package/lib/orchestrator/diff.ts +6 -2
- package/lib/orchestrator/external-gates.ts +8 -43
- package/lib/orchestrator/gates.ts +10 -10
- package/lib/orchestrator/pr-wake.ts +2 -2
- package/lib/orchestrator/rebase.ts +6 -45
- package/lib/orchestrator/review.ts +18 -5
- package/lib/orchestrator/scope.ts +9 -0
- package/lib/orchestrator/serve.ts +6 -0
- package/lib/orchestrator/signoff.ts +13 -8
- 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/lib/session-audit/render.ts +1 -1
- package/lib/stacks/land.ts +23 -16
- package/lib/tasks/brief.ts +3 -14
- package/lib/tasks/code-review.ts +69 -107
- package/lib/tasks/gh.ts +3 -2
- package/lib/tasks/index.ts +1 -1
- package/lib/tasks/state.ts +4 -4
- package/lib/usage/check-runs.ts +167 -0
- package/lib/usage/report.ts +25 -0
- package/lib/usage/turns.ts +28 -0
- package/package.json +1 -1
- package/stores/addProject.ts +12 -3
- package/scripts/check-code-review.ts +0 -21
|
@@ -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
|
>
|
|
@@ -41,8 +41,8 @@ export function UsageDialog() {
|
|
|
41
41
|
<DialogHeader>
|
|
42
42
|
<DialogTitle>Usage</DialogTitle>
|
|
43
43
|
<DialogDescription>
|
|
44
|
-
The account's limits, and what chat sessions
|
|
45
|
-
cost.
|
|
44
|
+
The account's limits, and what chat sessions and gate reviews
|
|
45
|
+
on this machine cost.
|
|
46
46
|
</DialogDescription>
|
|
47
47
|
</DialogHeader>
|
|
48
48
|
|
|
@@ -124,6 +124,35 @@ export function UsageDialog() {
|
|
|
124
124
|
</>
|
|
125
125
|
)}
|
|
126
126
|
</section>
|
|
127
|
+
|
|
128
|
+
{data?.reviews && data.reviews.total.turns > 0 && (
|
|
129
|
+
<section className="space-y-3">
|
|
130
|
+
<h3 className="text-sm font-medium">Gate reviews</h3>
|
|
131
|
+
<div className="flex items-baseline gap-2">
|
|
132
|
+
<span className="text-2xl font-semibold tabular-nums">
|
|
133
|
+
{formatUsd(data.reviews.total.costUsd)}
|
|
134
|
+
</span>
|
|
135
|
+
<span className="text-muted-foreground text-xs">
|
|
136
|
+
estimate · {data.reviews.total.turns} runs ·{" "}
|
|
137
|
+
{data.reviews.total.tokens.toLocaleString()} tokens
|
|
138
|
+
</span>
|
|
139
|
+
</div>
|
|
140
|
+
<SpendList
|
|
141
|
+
title="By PR"
|
|
142
|
+
rows={data.reviews.sessions.map((s) => ({
|
|
143
|
+
key: s.id,
|
|
144
|
+
label: s.name,
|
|
145
|
+
...s,
|
|
146
|
+
}))}
|
|
147
|
+
total={data.reviews.total}
|
|
148
|
+
limit={10}
|
|
149
|
+
/>
|
|
150
|
+
<p className="text-muted-foreground text-[11px]">
|
|
151
|
+
Each run of the orchestrator's independent reviewer and scope
|
|
152
|
+
check, from its own result. Not part of chat spend.
|
|
153
|
+
</p>
|
|
154
|
+
</section>
|
|
155
|
+
)}
|
|
127
156
|
</DialogContent>
|
|
128
157
|
</Dialog>
|
|
129
158
|
);
|
|
@@ -19,7 +19,7 @@ export function FreshStartForm() {
|
|
|
19
19
|
return <p className="text-destructive text-sm">{error.message}</p>;
|
|
20
20
|
return (
|
|
21
21
|
<Form
|
|
22
|
-
key={`${data.orchestratorTokens}:${data.taskTokens}:${data.
|
|
22
|
+
key={`${data.orchestratorTokens}:${data.taskTokens}:${data.compactTokens}`}
|
|
23
23
|
saved={data}
|
|
24
24
|
/>
|
|
25
25
|
);
|
|
@@ -29,13 +29,13 @@ function Form({ saved }: { saved: RefreshSettings }) {
|
|
|
29
29
|
const update = useUpdateFreshStart();
|
|
30
30
|
const orchestrator = useThreshold(saved.orchestratorTokens, 200);
|
|
31
31
|
const task = useThreshold(saved.taskTokens, 200);
|
|
32
|
-
const
|
|
33
|
-
const error = orchestrator.error ?? task.error ??
|
|
32
|
+
const compact = useThreshold(saved.compactTokens, 150);
|
|
33
|
+
const error = orchestrator.error ?? task.error ?? compact.error;
|
|
34
34
|
const dirty =
|
|
35
35
|
!error &&
|
|
36
36
|
(orchestrator.tokens !== saved.orchestratorTokens ||
|
|
37
37
|
task.tokens !== saved.taskTokens ||
|
|
38
|
-
|
|
38
|
+
compact.tokens !== saved.compactTokens);
|
|
39
39
|
|
|
40
40
|
return (
|
|
41
41
|
<form
|
|
@@ -46,7 +46,7 @@ function Form({ saved }: { saved: RefreshSettings }) {
|
|
|
46
46
|
{
|
|
47
47
|
orchestratorTokens: orchestrator.tokens,
|
|
48
48
|
taskTokens: task.tokens,
|
|
49
|
-
|
|
49
|
+
compactTokens: compact.tokens,
|
|
50
50
|
},
|
|
51
51
|
{
|
|
52
52
|
onSuccess: () => toast.success("Context settings saved"),
|
|
@@ -65,17 +65,17 @@ function Form({ saved }: { saved: RefreshSettings }) {
|
|
|
65
65
|
</p>
|
|
66
66
|
<ThresholdRow label="Orchestrators" field={orchestrator} />
|
|
67
67
|
<ThresholdRow label="Tasks" field={task} />
|
|
68
|
-
<h3 className="pt-2 text-sm font-medium">Compact
|
|
68
|
+
<h3 className="pt-2 text-sm font-medium">Compact</h3>
|
|
69
69
|
<p className="text-muted-foreground text-sm">
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
to.
|
|
70
|
+
Chats and tasks compact when their context passes this size: Claude Code
|
|
71
|
+
does it itself, and between turns AgentOS compacts a chat over it, or
|
|
72
|
+
idle past its prompt cache, before the next message. The composer offers
|
|
73
|
+
to first. Orchestrators start fresh instead.
|
|
74
74
|
</p>
|
|
75
75
|
<ThresholdRow
|
|
76
|
-
label="
|
|
77
|
-
field={
|
|
78
|
-
switchLabel="Compact
|
|
76
|
+
label="Chats and tasks"
|
|
77
|
+
field={compact}
|
|
78
|
+
switchLabel="Compact chats and tasks at this size"
|
|
79
79
|
/>
|
|
80
80
|
{error && <p className="text-destructive text-xs">{error}</p>}
|
|
81
81
|
<div className="flex justify-end">
|
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/data/usage/index.ts
CHANGED
package/lib/agents/launch.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { runInBackground } from "../async-operations";
|
|
|
9
9
|
import { trustPromptKeys } from "../tasks/state";
|
|
10
10
|
import { BUS_BRIEF } from "./brief";
|
|
11
11
|
import { selfUrl } from "./self-env";
|
|
12
|
+
import { autoCompactEnv } from "../chat/refresh";
|
|
12
13
|
import { CLAUDE_STATUS_SETTINGS_FLAG } from "../program-status/claude-flag";
|
|
13
14
|
import {
|
|
14
15
|
sessionProjectEnv,
|
|
@@ -35,6 +36,7 @@ export function ensureBusBrief(): void {
|
|
|
35
36
|
export function agentEnv(sessionId: string): Record<string, string> {
|
|
36
37
|
return {
|
|
37
38
|
...sessionProjectEnv(sessionId),
|
|
39
|
+
...autoCompactEnv(sessionId),
|
|
38
40
|
AGENTOS_SESSION_ID: sessionId,
|
|
39
41
|
AGENTOS_URL: selfUrl(),
|
|
40
42
|
PATH: `${AOS_BIN_DIR}:${process.env.PATH ?? ""}`,
|
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 {
|
|
@@ -2,6 +2,7 @@ import fs from "fs";
|
|
|
2
2
|
import type {
|
|
3
3
|
CanUseTool,
|
|
4
4
|
HookCallback,
|
|
5
|
+
HookCallbackMatcher,
|
|
5
6
|
PermissionMode,
|
|
6
7
|
PermissionResult,
|
|
7
8
|
} from "@anthropic-ai/claude-agent-sdk";
|
|
@@ -13,6 +14,7 @@ import type {
|
|
|
13
14
|
DriverEvent,
|
|
14
15
|
} from "../events";
|
|
15
16
|
import { toolDiff, toolTitle } from "../tools";
|
|
17
|
+
import type { ToolGuard } from "../driver";
|
|
16
18
|
|
|
17
19
|
type Approval = Extract<ChatItem, { kind: "approval" }>;
|
|
18
20
|
|
|
@@ -235,3 +237,41 @@ export class Approvals {
|
|
|
235
237
|
p.resolve(result);
|
|
236
238
|
}
|
|
237
239
|
}
|
|
240
|
+
|
|
241
|
+
// A role's guard as a PreToolUse hook: a refusal denies the call before any
|
|
242
|
+
// permission rule could allow it, and a guard that fails refuses too.
|
|
243
|
+
export function guardHook(guard: ToolGuard): HookCallback {
|
|
244
|
+
return async (input) => {
|
|
245
|
+
if (input.hook_event_name !== "PreToolUse") return {};
|
|
246
|
+
const reason = await guard(
|
|
247
|
+
input.tool_name,
|
|
248
|
+
(input.tool_input ?? {}) as Record<string, unknown>
|
|
249
|
+
).catch((e: Error) => `Couldn't check this call: ${e.message}`);
|
|
250
|
+
if (reason === null) return {};
|
|
251
|
+
return {
|
|
252
|
+
hookSpecificOutput: {
|
|
253
|
+
hookEventName: "PreToolUse",
|
|
254
|
+
permissionDecision: "deny",
|
|
255
|
+
permissionDecisionReason: reason,
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// What runs before each tool call: questions and plans become cards, and a
|
|
262
|
+
// role's guard (an orchestrator's, on where it writes) sees every call.
|
|
263
|
+
export function preToolUseHooks(
|
|
264
|
+
approvals: Approvals,
|
|
265
|
+
guard?: ToolGuard
|
|
266
|
+
): HookCallbackMatcher[] {
|
|
267
|
+
return [
|
|
268
|
+
{
|
|
269
|
+
matcher: "AskUserQuestion",
|
|
270
|
+
hooks: [approvals.askQuestions],
|
|
271
|
+
// The reader may take a while to answer.
|
|
272
|
+
timeout: 24 * 60 * 60,
|
|
273
|
+
},
|
|
274
|
+
{ matcher: "ExitPlanMode", hooks: [approvals.proposePlan] },
|
|
275
|
+
...(guard ? [{ hooks: [guardHook(guard)] }] : []),
|
|
276
|
+
];
|
|
277
|
+
}
|
|
@@ -8,7 +8,12 @@ import {
|
|
|
8
8
|
import type { ChatDriver, ChatConversation } from "../driver";
|
|
9
9
|
import type { DriverEvent, FileSuggestion, McpServerView } from "../events";
|
|
10
10
|
import { InputQueue } from "../queue";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
Approvals,
|
|
13
|
+
isPlanFile,
|
|
14
|
+
preToolUseHooks,
|
|
15
|
+
sdkMode,
|
|
16
|
+
} from "./claude-approvals";
|
|
12
17
|
import { toChatContext, usageTotals } from "../context";
|
|
13
18
|
import { cacheTtlMs } from "../stale";
|
|
14
19
|
import { ClaudeMapper, toCommand, type ClaudeMessage } from "./claude-mapper";
|
|
@@ -186,15 +191,7 @@ export const claudeDriver: ChatDriver = {
|
|
|
186
191
|
allowedTools: options.allowedTools,
|
|
187
192
|
disallowedTools: options.disallowedTools,
|
|
188
193
|
hooks: {
|
|
189
|
-
PreToolUse:
|
|
190
|
-
{
|
|
191
|
-
matcher: "AskUserQuestion",
|
|
192
|
-
hooks: [approvals.askQuestions],
|
|
193
|
-
// The reader may take a while to answer.
|
|
194
|
-
timeout: 24 * 60 * 60,
|
|
195
|
-
},
|
|
196
|
-
{ matcher: "ExitPlanMode", hooks: [approvals.proposePlan] },
|
|
197
|
-
],
|
|
194
|
+
PreToolUse: preToolUseHooks(approvals, options.toolGuard),
|
|
198
195
|
},
|
|
199
196
|
settings: { attribution: NO_ATTRIBUTION },
|
|
200
197
|
enableFileCheckpointing: true,
|