@saadnvd1/agent-os 0.3.691 → 0.3.693
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/app/api/discovered-models/route.ts +34 -0
- package/app/api/sessions/route.ts +1 -0
- package/components/Draft/DraftChips.tsx +10 -5
- package/components/Draft/DraftPanel.tsx +13 -2
- package/components/Draft/draftModels.test.ts +31 -0
- package/components/Draft/draftModels.ts +17 -0
- package/data/agents/index.ts +37 -0
- package/data/sessions/queries.ts +2 -0
- package/hooks/useDraftRequests.ts +9 -3
- package/lib/bus/read-scope.ts +2 -1
- package/lib/chat/discovered-models.ts +87 -0
- package/lib/drafts.ts +13 -3
- package/lib/model-catalog.ts +9 -0
- package/lib/projects.ts +27 -6
- package/lib/sessions/launch.ts +47 -7
- package/lib/sidebar/shelves.ts +5 -4
- package/lib/workspaces.ts +6 -0
- package/package.json +1 -1
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { discoveredModels } from "@/lib/chat/discovered-models";
|
|
4
|
+
import { getProject } from "@/lib/projects";
|
|
5
|
+
|
|
6
|
+
const query = z.object({
|
|
7
|
+
agent: z.string().min(1),
|
|
8
|
+
projectId: z.string().optional(),
|
|
9
|
+
refresh: z.string().optional(),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// GET /api/discovered-models?agent=&projectId=&refresh=1 - the models an agent
|
|
13
|
+
// reports for a project's folder, for a draft that has no session yet. Empty
|
|
14
|
+
// for an agent with a fixed list.
|
|
15
|
+
export async function GET(request: NextRequest) {
|
|
16
|
+
const parsed = query.safeParse(
|
|
17
|
+
Object.fromEntries(request.nextUrl.searchParams)
|
|
18
|
+
);
|
|
19
|
+
if (!parsed.success)
|
|
20
|
+
return NextResponse.json({ error: "agent is required" }, { status: 400 });
|
|
21
|
+
const { agent, projectId, refresh } = parsed.data;
|
|
22
|
+
const folder = projectId ? getProject(projectId)?.working_directory : null;
|
|
23
|
+
try {
|
|
24
|
+
return NextResponse.json({
|
|
25
|
+
models: await discoveredModels(agent, folder, refresh !== undefined),
|
|
26
|
+
});
|
|
27
|
+
} catch (error) {
|
|
28
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
29
|
+
return NextResponse.json(
|
|
30
|
+
{ error: `Couldn't list the models: ${message}` },
|
|
31
|
+
{ status: 502 }
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -61,6 +61,7 @@ export async function POST(request: NextRequest) {
|
|
|
61
61
|
const { session, initialPrompt, repeat } = await launchSession({
|
|
62
62
|
id: str(body.id),
|
|
63
63
|
projectId: str(body.projectId),
|
|
64
|
+
workspaceId: str(body.workspaceId),
|
|
64
65
|
project: projectRefOf(body.project),
|
|
65
66
|
workingDirectory: str(body.workingDirectory),
|
|
66
67
|
agentType:
|
|
@@ -98,11 +98,16 @@ export function DraftChips({
|
|
|
98
98
|
}, [hostGone, onChange]);
|
|
99
99
|
|
|
100
100
|
const pickProject = (id: string | null) => {
|
|
101
|
-
const next = newDraft(
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
101
|
+
const next = newDraft(
|
|
102
|
+
draft.id,
|
|
103
|
+
real.find((p) => p.id === id) ?? null,
|
|
104
|
+
{
|
|
105
|
+
agentType: draft.agentType,
|
|
106
|
+
model: draft.model,
|
|
107
|
+
access: draft.access,
|
|
108
|
+
},
|
|
109
|
+
{ workspaceId: draft.workspaceId }
|
|
110
|
+
);
|
|
106
111
|
onChange({
|
|
107
112
|
...next,
|
|
108
113
|
openPr: !!id && draft.openPr,
|
|
@@ -8,9 +8,11 @@ import { Button } from "@/components/ui/button";
|
|
|
8
8
|
import type { ChatFile, ChatImage } from "@/lib/chat/events";
|
|
9
9
|
import type { Project } from "@/lib/db";
|
|
10
10
|
import { draftComposerKey, type Draft } from "@/lib/drafts";
|
|
11
|
-
import {
|
|
11
|
+
import { discoversModels } from "@/lib/model-catalog";
|
|
12
|
+
import { useDiscoveredModels } from "@/data/agents";
|
|
12
13
|
import { useHostsQuery } from "@/data/hosts";
|
|
13
14
|
import { useGitCheck } from "@/data/git/queries";
|
|
15
|
+
import { useSelectedWorkspace } from "@/hooks/useSelectedWorkspace";
|
|
14
16
|
import { useLaunchSession } from "@/data/sessions";
|
|
15
17
|
import { useCreateTask } from "@/data/tasks";
|
|
16
18
|
import { usePanes } from "@/contexts/PaneContext";
|
|
@@ -18,6 +20,7 @@ import { setPendingPrompt } from "@/stores/initialPrompt";
|
|
|
18
20
|
import { draftsActions, draftsStore, newDraft } from "@/stores/drafts";
|
|
19
21
|
import { DraftChips } from "./DraftChips";
|
|
20
22
|
import { DraftHeading } from "./DraftHeading";
|
|
23
|
+
import { draftModels } from "./draftModels";
|
|
21
24
|
|
|
22
25
|
// A new session before its first send: choices on top, a composer below.
|
|
23
26
|
// Sending makes the session (and its worktree) and turns this tab into it.
|
|
@@ -41,7 +44,14 @@ export function DraftPanel({
|
|
|
41
44
|
const { data: git } = useGitCheck(
|
|
42
45
|
project && draft?.hostId === "local" ? project.working_directory : ""
|
|
43
46
|
);
|
|
47
|
+
const discovers = !!draft && discoversModels(draft.agentType);
|
|
48
|
+
const found = useDiscoveredModels(
|
|
49
|
+
draft?.agentType ?? "",
|
|
50
|
+
draft?.projectId ?? null,
|
|
51
|
+
discovers
|
|
52
|
+
);
|
|
44
53
|
const launch = useLaunchSession();
|
|
54
|
+
const { workspace } = useSelectedWorkspace();
|
|
45
55
|
const createTask = useCreateTask();
|
|
46
56
|
const [prefill, setPrefill] = useState<{ text: string; at: number }>();
|
|
47
57
|
const pending = launch.isPending || createTask.isPending;
|
|
@@ -85,6 +95,7 @@ export function DraftPanel({
|
|
|
85
95
|
}
|
|
86
96
|
const { session, initialPrompt, repeat } = await launch.mutateAsync({
|
|
87
97
|
projectId: project?.id ?? null,
|
|
98
|
+
workspaceId: draft.workspaceId ?? workspace?.id ?? null,
|
|
88
99
|
id: draft.id,
|
|
89
100
|
hostId:
|
|
90
101
|
!project || draft.hostId !== project.host_id
|
|
@@ -148,7 +159,7 @@ export function DraftPanel({
|
|
|
148
159
|
}
|
|
149
160
|
onSend={(text, images, files) => void send(text, images, files)}
|
|
150
161
|
onStop={() => {}}
|
|
151
|
-
models={
|
|
162
|
+
models={draftModels(draft.agentType, found)}
|
|
152
163
|
model={draft.model}
|
|
153
164
|
onSetModel={(model) => draftsActions.update(draft.id, { model })}
|
|
154
165
|
access={draft.openPr ? undefined : draft.access}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { draftModels } from "./draftModels";
|
|
3
|
+
|
|
4
|
+
const qwen = { value: "ollama/qwen3", label: "Qwen 3" };
|
|
5
|
+
|
|
6
|
+
describe("draftModels", () => {
|
|
7
|
+
it("lists the default and then what OpenCode reports", () => {
|
|
8
|
+
const list = draftModels("opencode", {
|
|
9
|
+
data: [qwen],
|
|
10
|
+
isPending: false,
|
|
11
|
+
isError: false,
|
|
12
|
+
});
|
|
13
|
+
expect(list.map((m) => m.value)).toEqual(["default", "ollama/qwen3"]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("says so beside the default while loading and on failure", () => {
|
|
17
|
+
const loading = draftModels("opencode", {
|
|
18
|
+
isPending: true,
|
|
19
|
+
isError: false,
|
|
20
|
+
});
|
|
21
|
+
const failed = draftModels("opencode", { isPending: false, isError: true });
|
|
22
|
+
expect(loading).toHaveLength(1);
|
|
23
|
+
expect(loading[0].label).toContain("loading");
|
|
24
|
+
expect(failed[0].label).toContain("couldn't");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("keeps a fixed list as it is", () => {
|
|
28
|
+
const list = draftModels("claude", { isPending: true, isError: false });
|
|
29
|
+
expect(list.map((m) => m.value)).toContain("opus");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ChatModel } from "@/lib/chat/events";
|
|
2
|
+
import type { Draft } from "@/lib/drafts";
|
|
3
|
+
import { discoversModels, getModelOptions } from "@/lib/model-catalog";
|
|
4
|
+
|
|
5
|
+
// A fixed list, or the agent's default followed by what it reports; while it
|
|
6
|
+
// loads or if it fails, the default alone says so.
|
|
7
|
+
export function draftModels(
|
|
8
|
+
agentType: Draft["agentType"],
|
|
9
|
+
found: { data?: ChatModel[]; isPending: boolean; isError: boolean }
|
|
10
|
+
): ChatModel[] {
|
|
11
|
+
const options = getModelOptions(agentType);
|
|
12
|
+
if (!discoversModels(agentType)) return options;
|
|
13
|
+
if (found.data) return [...options, ...found.data];
|
|
14
|
+
const [first, ...rest] = options;
|
|
15
|
+
const note = found.isError ? "couldn't list models" : "loading models…";
|
|
16
|
+
return [{ ...first, label: `${first.label} (${note})` }, ...rest];
|
|
17
|
+
}
|
package/data/agents/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
2
|
import type { AgentProbe } from "@/lib/agents/probe";
|
|
3
|
+
import type { ChatModel } from "@/lib/chat/events";
|
|
3
4
|
|
|
4
5
|
export const agentKeys = {
|
|
5
6
|
all: ["agents"] as const,
|
|
@@ -35,3 +36,39 @@ export function useRecheckAgents() {
|
|
|
35
36
|
onSuccess: (agents) => client.setQueryData(agentKeys.status(), agents),
|
|
36
37
|
});
|
|
37
38
|
}
|
|
39
|
+
|
|
40
|
+
export const discoveredModelKeys = {
|
|
41
|
+
list: (agent: string, projectId: string | null) =>
|
|
42
|
+
[...agentKeys.all, "models", agent, projectId] as const,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// What an agent without a fixed list reports it can run, for a draft's
|
|
46
|
+
// picker. Off for agents with a fixed list.
|
|
47
|
+
export function useDiscoveredModels(
|
|
48
|
+
agent: string,
|
|
49
|
+
projectId: string | null,
|
|
50
|
+
enabled: boolean
|
|
51
|
+
) {
|
|
52
|
+
const client = useQueryClient();
|
|
53
|
+
const key = discoveredModelKeys.list(agent, projectId);
|
|
54
|
+
const fetchModels = async (refresh: boolean) => {
|
|
55
|
+
const params = new URLSearchParams({ agent });
|
|
56
|
+
if (projectId) params.set("projectId", projectId);
|
|
57
|
+
if (refresh) params.set("refresh", "1");
|
|
58
|
+
const res = await fetch(`/api/discovered-models?${params}`);
|
|
59
|
+
const data = await res.json().catch(() => ({}));
|
|
60
|
+
if (!res.ok) throw new Error(data.error || "Couldn't list the models");
|
|
61
|
+
return data.models as ChatModel[];
|
|
62
|
+
};
|
|
63
|
+
const query = useQuery({
|
|
64
|
+
queryKey: key,
|
|
65
|
+
queryFn: () => fetchModels(false),
|
|
66
|
+
enabled,
|
|
67
|
+
staleTime: 5 * 60_000,
|
|
68
|
+
retry: false,
|
|
69
|
+
});
|
|
70
|
+
return {
|
|
71
|
+
...query,
|
|
72
|
+
refresh: async () => client.setQueryData(key, await fetchModels(true)),
|
|
73
|
+
};
|
|
74
|
+
}
|
package/data/sessions/queries.ts
CHANGED
|
@@ -182,6 +182,8 @@ export interface LaunchSessionInput {
|
|
|
182
182
|
// The draft's id: the session's, and a resend's key to the same one.
|
|
183
183
|
id: string;
|
|
184
184
|
projectId: string | null;
|
|
185
|
+
// The workspace selected: where a session in no project is filed.
|
|
186
|
+
workspaceId?: string | null;
|
|
185
187
|
// A scratch chat's machine; a project's sessions run where it lives, or
|
|
186
188
|
// on a linked machine.
|
|
187
189
|
hostId?: string;
|
|
@@ -46,9 +46,15 @@ export function useDraftRequests(options: Options) {
|
|
|
46
46
|
useEffect(() => {
|
|
47
47
|
hydrateDrafts();
|
|
48
48
|
const open = (projectId: string | null, openPr = false) => {
|
|
49
|
-
const { projects, viewing, show } = latest.current;
|
|
49
|
+
const { projects, viewing, show, workspaceId } = latest.current;
|
|
50
50
|
const drafts = Object.values(draftsStore.drafts) as Draft[];
|
|
51
|
-
const found = reusableDraft(
|
|
51
|
+
const found = reusableDraft(
|
|
52
|
+
drafts,
|
|
53
|
+
projectId,
|
|
54
|
+
draftHasText,
|
|
55
|
+
openPr,
|
|
56
|
+
workspaceId
|
|
57
|
+
);
|
|
52
58
|
if (found) return show(found.id);
|
|
53
59
|
const from = viewing.draftId ? draftsStore.drafts[viewing.draftId] : null;
|
|
54
60
|
const s = viewing.session;
|
|
@@ -58,7 +64,7 @@ export function useDraftRequests(options: Options) {
|
|
|
58
64
|
? { agentType: s.agent_type, model: s.model, access: s.chat_access }
|
|
59
65
|
: {};
|
|
60
66
|
const project = projects.find((p) => p.id === projectId) ?? null;
|
|
61
|
-
const draft = makeDraft(uuid(), project, carry, { openPr });
|
|
67
|
+
const draft = makeDraft(uuid(), project, carry, { openPr, workspaceId });
|
|
62
68
|
draftsActions.put(draft);
|
|
63
69
|
show(draft.id);
|
|
64
70
|
};
|
package/lib/bus/read-scope.ts
CHANGED
|
@@ -60,7 +60,8 @@ function inScope(s: ReadableSession, scope: ReaderScope): boolean {
|
|
|
60
60
|
if (s.role) return false;
|
|
61
61
|
if (scope.workspaceId) {
|
|
62
62
|
const p = s.project_id ? getProject(s.project_id) : null;
|
|
63
|
-
|
|
63
|
+
// A session in no project is in the workspace it was started in.
|
|
64
|
+
return (p?.workspace_id ?? s.workspace_id) === scope.workspaceId;
|
|
64
65
|
}
|
|
65
66
|
return s.project_id === scope.projectId;
|
|
66
67
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import os from "os";
|
|
2
|
+
import { discoversModels } from "../model-catalog";
|
|
3
|
+
import { chatDriverFor } from "./drivers";
|
|
4
|
+
import type { ChatModel } from "./events";
|
|
5
|
+
|
|
6
|
+
const TTL_MS = 5 * 60_000;
|
|
7
|
+
const TIMEOUT_MS = 30_000;
|
|
8
|
+
|
|
9
|
+
interface Entry {
|
|
10
|
+
models: ChatModel[];
|
|
11
|
+
at: number;
|
|
12
|
+
}
|
|
13
|
+
const cache = new Map<string, Entry>();
|
|
14
|
+
const loading = new Map<string, Promise<ChatModel[]>>();
|
|
15
|
+
|
|
16
|
+
const expand = (p: string) => p.replace(/^~(?=$|\/)/, os.homedir());
|
|
17
|
+
|
|
18
|
+
async function discover(agentType: string, cwd: string): Promise<ChatModel[]> {
|
|
19
|
+
const driver = chatDriverFor(agentType);
|
|
20
|
+
if (!driver) return [];
|
|
21
|
+
const ac = new AbortController();
|
|
22
|
+
const timer = setTimeout(
|
|
23
|
+
() => ac.abort(new Error("Discovery timed out")),
|
|
24
|
+
TIMEOUT_MS
|
|
25
|
+
);
|
|
26
|
+
timer.unref?.();
|
|
27
|
+
try {
|
|
28
|
+
const found = await Promise.race([
|
|
29
|
+
driver.discover({ cwd, env: {}, signal: ac.signal }),
|
|
30
|
+
new Promise<never>((_, reject) =>
|
|
31
|
+
ac.signal.addEventListener("abort", () => reject(ac.signal.reason), {
|
|
32
|
+
once: true,
|
|
33
|
+
})
|
|
34
|
+
),
|
|
35
|
+
]);
|
|
36
|
+
return found.models;
|
|
37
|
+
} finally {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The models an agent reports for a folder, cached for a few minutes.
|
|
43
|
+
// `refresh` skips the cache; a failure throws and is never cached.
|
|
44
|
+
export async function discoveredModels(
|
|
45
|
+
agentType: string,
|
|
46
|
+
folder: string | null | undefined,
|
|
47
|
+
refresh = false
|
|
48
|
+
): Promise<ChatModel[]> {
|
|
49
|
+
if (!discoversModels(agentType)) return [];
|
|
50
|
+
const cwd = expand(folder?.trim() || "~");
|
|
51
|
+
const key = `${agentType}:${cwd}`;
|
|
52
|
+
const hit = cache.get(key);
|
|
53
|
+
if (!refresh && hit && Date.now() - hit.at < TTL_MS) return hit.models;
|
|
54
|
+
const running = loading.get(key);
|
|
55
|
+
if (running && !refresh) return running;
|
|
56
|
+
const run = discover(agentType, cwd)
|
|
57
|
+
.then((models) => {
|
|
58
|
+
cache.set(key, { models, at: Date.now() });
|
|
59
|
+
return models;
|
|
60
|
+
})
|
|
61
|
+
.finally(() => {
|
|
62
|
+
if (loading.get(key) === run) loading.delete(key);
|
|
63
|
+
});
|
|
64
|
+
loading.set(key, run);
|
|
65
|
+
return run;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function clearDiscoveredModels(): void {
|
|
69
|
+
cache.clear();
|
|
70
|
+
loading.clear();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Whether the agent reports this model. A failed discovery says no, so a
|
|
74
|
+
// launch falls back to the agent's default rather than failing.
|
|
75
|
+
export async function isDiscoveredModel(
|
|
76
|
+
agentType: string,
|
|
77
|
+
folder: string | null | undefined,
|
|
78
|
+
model: string
|
|
79
|
+
): Promise<boolean> {
|
|
80
|
+
try {
|
|
81
|
+
return (await discoveredModels(agentType, folder)).some(
|
|
82
|
+
(m) => m.value === model
|
|
83
|
+
);
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
package/lib/drafts.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface Draft {
|
|
|
13
13
|
id: string;
|
|
14
14
|
// null: a scratch chat, in no project.
|
|
15
15
|
projectId: string | null;
|
|
16
|
+
// The workspace selected when it was made: where a scratch chat is filed.
|
|
17
|
+
workspaceId?: string | null;
|
|
16
18
|
hostId: string;
|
|
17
19
|
agentType: AgentType;
|
|
18
20
|
model: string;
|
|
@@ -55,13 +57,14 @@ export function newDraft(
|
|
|
55
57
|
id: string,
|
|
56
58
|
project: DraftProject | null,
|
|
57
59
|
carry: Carry,
|
|
58
|
-
opts: { openPr?: boolean; now?: number } = {}
|
|
60
|
+
opts: { openPr?: boolean; now?: number; workspaceId?: string | null } = {}
|
|
59
61
|
): Draft {
|
|
60
62
|
const real = project && !project.is_uncategorized ? project : null;
|
|
61
63
|
const agentType = real?.agent_type ?? carry.agentType ?? "claude";
|
|
62
64
|
return {
|
|
63
65
|
id,
|
|
64
66
|
projectId: real?.id ?? null,
|
|
67
|
+
workspaceId: real ? null : (opts.workspaceId ?? null),
|
|
65
68
|
hostId: real?.host_id || "local",
|
|
66
69
|
agentType,
|
|
67
70
|
model: resolveModelForAgent(
|
|
@@ -82,11 +85,18 @@ export function reusableDraft(
|
|
|
82
85
|
drafts: Draft[],
|
|
83
86
|
projectId: string | null,
|
|
84
87
|
hasText: (draftId: string) => boolean,
|
|
85
|
-
openPr = false
|
|
88
|
+
openPr = false,
|
|
89
|
+
workspaceId: string | null = null
|
|
86
90
|
): Draft | null {
|
|
91
|
+
// An empty scratch chat from another workspace would file this one's
|
|
92
|
+
// session there.
|
|
87
93
|
return (
|
|
88
94
|
drafts.find(
|
|
89
|
-
(d) =>
|
|
95
|
+
(d) =>
|
|
96
|
+
d.projectId === projectId &&
|
|
97
|
+
d.openPr === openPr &&
|
|
98
|
+
(projectId !== null || (d.workspaceId ?? null) === workspaceId) &&
|
|
99
|
+
!hasText(d.id)
|
|
90
100
|
) ?? null
|
|
91
101
|
);
|
|
92
102
|
}
|
package/lib/model-catalog.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AgentType } from "./providers";
|
|
2
2
|
import { AGENT_DEFAULT_MODEL } from "./providers/registry";
|
|
3
|
+
import { supportsChat } from "./chat/capabilities";
|
|
3
4
|
|
|
4
5
|
export interface ModelOption {
|
|
5
6
|
value: string;
|
|
@@ -48,6 +49,14 @@ const DEFAULT_MODEL_BY_AGENT: Partial<Record<AgentType, string>> = {
|
|
|
48
49
|
gemini: "gemini-3.8-flash",
|
|
49
50
|
};
|
|
50
51
|
|
|
52
|
+
export function hasStaticModelCatalog(agentType: string): boolean {
|
|
53
|
+
return agentType in MODEL_OPTIONS_BY_AGENT;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A chat agent with no fixed list: it reports what it can run itself.
|
|
57
|
+
export const discoversModels = (agentType: string): boolean =>
|
|
58
|
+
supportsChat(agentType) && !hasStaticModelCatalog(agentType);
|
|
59
|
+
|
|
51
60
|
export function getModelOptions(agentType: AgentType): ModelOption[] {
|
|
52
61
|
return MODEL_OPTIONS_BY_AGENT[agentType] ?? AGENT_DEFAULT_OPTIONS;
|
|
53
62
|
}
|
package/lib/projects.ts
CHANGED
|
@@ -278,11 +278,12 @@ export function deleteProject(id: string): boolean {
|
|
|
278
278
|
const project = getProject(id);
|
|
279
279
|
if (!project || project.is_uncategorized) return false;
|
|
280
280
|
|
|
281
|
-
//
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
281
|
+
// Its sessions go to Uncategorized, staying in the project's workspace.
|
|
282
|
+
db.prepare(
|
|
283
|
+
`UPDATE sessions SET project_id = 'uncategorized', updated_at = datetime('now'),
|
|
284
|
+
workspace_id = CASE WHEN role IS NULL THEN ? ELSE workspace_id END
|
|
285
|
+
WHERE project_id = ?`
|
|
286
|
+
).run(project.workspace_id ?? null, id);
|
|
286
287
|
|
|
287
288
|
// Delete dev server instances
|
|
288
289
|
queries.deleteDevServersByProject(db).run(id);
|
|
@@ -312,7 +313,27 @@ export function moveSessionToProject(
|
|
|
312
313
|
sessionId: string,
|
|
313
314
|
projectId: string
|
|
314
315
|
): void {
|
|
315
|
-
|
|
316
|
+
const to = getProject(projectId);
|
|
317
|
+
const from = db
|
|
318
|
+
.prepare(
|
|
319
|
+
`SELECT p.workspace_id AS workspace_id, s.workspace_id AS own
|
|
320
|
+
FROM sessions s LEFT JOIN projects p ON p.id = s.project_id WHERE s.id = ?`
|
|
321
|
+
)
|
|
322
|
+
.get(sessionId) as
|
|
323
|
+
{ workspace_id: string | null; own: string | null } | undefined;
|
|
324
|
+
db.transaction(() => {
|
|
325
|
+
queries.updateSessionProject(db).run(projectId, sessionId);
|
|
326
|
+
// A project's session follows the project; one left in none keeps the
|
|
327
|
+
// workspace it was in.
|
|
328
|
+
db.prepare(
|
|
329
|
+
`UPDATE sessions SET workspace_id = ? WHERE id = ? AND role IS NULL`
|
|
330
|
+
).run(
|
|
331
|
+
to && !to.is_uncategorized
|
|
332
|
+
? null
|
|
333
|
+
: (from?.workspace_id ?? from?.own ?? null),
|
|
334
|
+
sessionId
|
|
335
|
+
);
|
|
336
|
+
})();
|
|
316
337
|
}
|
|
317
338
|
|
|
318
339
|
/**
|
package/lib/sessions/launch.ts
CHANGED
|
@@ -11,10 +11,12 @@ import { randomUUID } from "crypto";
|
|
|
11
11
|
import { db, queries, type Session } from "../db";
|
|
12
12
|
import type { AgentType } from "../providers";
|
|
13
13
|
import type { ChatAccess, ChatFile, ChatImage } from "../chat/events";
|
|
14
|
-
import { resolveModelForAgent } from "../model-catalog";
|
|
14
|
+
import { discoversModels, resolveModelForAgent } from "../model-catalog";
|
|
15
|
+
import { isDiscoveredModel } from "../chat/discovered-models";
|
|
15
16
|
import { parseOverride, resolveRole, setSessionModel } from "../models/resolve";
|
|
16
17
|
import { ANTHROPIC } from "../models/roles";
|
|
17
18
|
import { getProject } from "../projects";
|
|
19
|
+
import { getWorkspace } from "../workspaces";
|
|
18
20
|
import { getHost, isRemoteHost } from "../hosts";
|
|
19
21
|
import { hostLink } from "../hosts/remote-api";
|
|
20
22
|
import { startOnPeer } from "../hosts/peer-actions";
|
|
@@ -77,6 +79,9 @@ export interface LaunchInput {
|
|
|
77
79
|
// role's model (or `model`), not the interactive one's.
|
|
78
80
|
startedBy?: string | null;
|
|
79
81
|
startedVia?: StartVia;
|
|
82
|
+
// The workspace selected when it was started. A session in a project
|
|
83
|
+
// follows the project; one in none keeps this (see inWorkspace).
|
|
84
|
+
workspaceId?: string | null;
|
|
80
85
|
groupPath?: string;
|
|
81
86
|
systemPrompt?: string | null;
|
|
82
87
|
}
|
|
@@ -177,6 +182,29 @@ export function launchSession(input: LaunchInput): Promise<Launched> {
|
|
|
177
182
|
return p;
|
|
178
183
|
}
|
|
179
184
|
|
|
185
|
+
// The workspace a project-less session is filed under: the one it was started
|
|
186
|
+
// from, else that of the orchestrator starting it. A workspace deleted since
|
|
187
|
+
// the draft was made is dropped rather than failing the send.
|
|
188
|
+
function homeWorkspace(input: LaunchInput): string | null {
|
|
189
|
+
const picked = input.workspaceId?.trim();
|
|
190
|
+
if (picked) return getWorkspace(picked) ? picked : null;
|
|
191
|
+
if (!input.startedBy) return null;
|
|
192
|
+
const by = db
|
|
193
|
+
.prepare(
|
|
194
|
+
`SELECT workspace_id FROM sessions WHERE id = ? AND role = 'orchestrator'`
|
|
195
|
+
)
|
|
196
|
+
.get(input.startedBy) as { workspace_id: string | null } | undefined;
|
|
197
|
+
return by?.workspace_id ?? null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function setHome(id: string, workspaceId: string | null): void {
|
|
201
|
+
if (workspaceId)
|
|
202
|
+
db.prepare(`UPDATE sessions SET workspace_id = ? WHERE id = ?`).run(
|
|
203
|
+
workspaceId,
|
|
204
|
+
id
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
180
208
|
async function launch(input: LaunchInput, id: string): Promise<Launched> {
|
|
181
209
|
const project = input.project
|
|
182
210
|
? await ensureProject(input.project)
|
|
@@ -184,6 +212,7 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
|
|
|
184
212
|
? getProject(input.projectId)
|
|
185
213
|
: undefined;
|
|
186
214
|
const scratch = !project || project.is_uncategorized;
|
|
215
|
+
const home = scratch ? homeWorkspace(input) : null;
|
|
187
216
|
const hostId = (scratch ? input.hostId : null) || project?.host_id || "local";
|
|
188
217
|
// A project here can start on a linked machine, which finds or clones it.
|
|
189
218
|
const carried =
|
|
@@ -209,7 +238,7 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
|
|
|
209
238
|
if (link) {
|
|
210
239
|
if (input.images?.length || input.files?.length)
|
|
211
240
|
throw new Error(`Images and files can't be sent to ${link.hostName} yet`);
|
|
212
|
-
|
|
241
|
+
const started = await startOnPeer(link, {
|
|
213
242
|
id,
|
|
214
243
|
project: scratch ? null : (project ?? null),
|
|
215
244
|
ref: carried && project ? await projectRef(project) : undefined,
|
|
@@ -221,6 +250,10 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
|
|
|
221
250
|
prompt: input.prompt?.trim(),
|
|
222
251
|
view: input.view ?? DEFAULT_START_VIEW,
|
|
223
252
|
});
|
|
253
|
+
setHome(started.session.id, home);
|
|
254
|
+
return home
|
|
255
|
+
? { ...started, session: { ...started.session, workspace_id: home } }
|
|
256
|
+
: started;
|
|
224
257
|
}
|
|
225
258
|
const projectPath = expand(folder);
|
|
226
259
|
if (scratch && projectPath === SCRATCH_DIR)
|
|
@@ -241,7 +274,7 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
|
|
|
241
274
|
const routed =
|
|
242
275
|
agentType === "claude" && (picked || !input.model?.trim()) && !remote
|
|
243
276
|
? resolveRole(role, {
|
|
244
|
-
workspaceId: project?.workspace_id,
|
|
277
|
+
workspaceId: project?.workspace_id ?? home,
|
|
245
278
|
override: picked,
|
|
246
279
|
})
|
|
247
280
|
: null;
|
|
@@ -251,12 +284,18 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
|
|
|
251
284
|
routed && (routed.provider.id === ANTHROPIC || view === "chat")
|
|
252
285
|
? routed
|
|
253
286
|
: null;
|
|
287
|
+
const wanted = input.model?.trim() || project?.default_model;
|
|
288
|
+
// An agent without a static list runs what it reports for this folder.
|
|
289
|
+
const discovered =
|
|
290
|
+
!interactive &&
|
|
291
|
+
wanted &&
|
|
292
|
+
discoversModels(agentType) &&
|
|
293
|
+
(await isDiscoveredModel(agentType, projectPath, wanted));
|
|
254
294
|
const model = interactive
|
|
255
295
|
? interactive.model
|
|
256
|
-
:
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
);
|
|
296
|
+
: discovered && wanted
|
|
297
|
+
? wanted
|
|
298
|
+
: resolveModelForAgent(agentType, wanted);
|
|
260
299
|
const text = input.prompt?.trim() ?? "";
|
|
261
300
|
const naming: Naming = text
|
|
262
301
|
? await nameFor(text, projectPath, input.name)
|
|
@@ -295,6 +334,7 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
|
|
|
295
334
|
input.startedVia ?? (input.startedBy ? "orchestrator" : null),
|
|
296
335
|
id
|
|
297
336
|
);
|
|
337
|
+
setHome(id, home);
|
|
298
338
|
if (interactive) setSessionModel(id, interactive);
|
|
299
339
|
if (input.access)
|
|
300
340
|
db.prepare(`UPDATE sessions SET chat_access = ? WHERE id = ?`).run(
|
package/lib/sidebar/shelves.ts
CHANGED
|
@@ -382,7 +382,9 @@ export function doneSummary(
|
|
|
382
382
|
}
|
|
383
383
|
|
|
384
384
|
// A workspace holds its projects' sessions and its own orchestrator; no
|
|
385
|
-
// workspace selected means every session.
|
|
385
|
+
// workspace selected means every session. A session in a project follows the
|
|
386
|
+
// project, so moving it moves them; one in no project (scratch) belongs to the
|
|
387
|
+
// workspace it was started in, kept on the session.
|
|
386
388
|
export function inWorkspace(
|
|
387
389
|
session: Pick<Session, "project_id" | "workspace_id" | "role">,
|
|
388
390
|
workspaceOf: (projectId: string) => string | null | undefined,
|
|
@@ -391,9 +393,8 @@ export function inWorkspace(
|
|
|
391
393
|
if (!workspaceId) return true;
|
|
392
394
|
if (session.role === "orchestrator")
|
|
393
395
|
return session.workspace_id === workspaceId;
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
);
|
|
396
|
+
const byProject = session.project_id ? workspaceOf(session.project_id) : null;
|
|
397
|
+
return (byProject ?? session.workspace_id) === workspaceId;
|
|
397
398
|
}
|
|
398
399
|
|
|
399
400
|
// A workspace's projects, or every project when none is selected.
|
package/lib/workspaces.ts
CHANGED
|
@@ -115,6 +115,11 @@ export function deleteWorkspace(id: string): void {
|
|
|
115
115
|
`UPDATE projects SET workspace_id = NULL WHERE workspace_id = ?`
|
|
116
116
|
).run(id);
|
|
117
117
|
db.prepare(`DELETE FROM workspaces WHERE id = ?`).run(id);
|
|
118
|
+
// Sessions filed here by being started here go back to "All workspaces".
|
|
119
|
+
db.prepare(
|
|
120
|
+
`UPDATE sessions SET workspace_id = NULL
|
|
121
|
+
WHERE role IS NULL AND workspace_id = ?`
|
|
122
|
+
).run(id);
|
|
118
123
|
// Its orchestrator goes with it; a running worker idles out on its own.
|
|
119
124
|
db.prepare(
|
|
120
125
|
`DELETE FROM sessions WHERE role = 'orchestrator' AND workspace_id = ?`
|
|
@@ -128,6 +133,7 @@ export function deleteWorkspace(id: string): void {
|
|
|
128
133
|
})();
|
|
129
134
|
}
|
|
130
135
|
|
|
136
|
+
// A project's sessions follow it, so moving one needs no change to them.
|
|
131
137
|
export function setProjectWorkspace(
|
|
132
138
|
projectId: string,
|
|
133
139
|
workspaceId: string | null
|